Самое важное из мира AI — в канале MAX AgentLabОтдельные разборы, инструменты и практические схемы Читайте Agent Lab в Telegram Разборы, кейсы и новости о практических AI-агентах без лишней воды.

Детерминированная проверка ссылок в исследовательском агенте

Детерминированная проверка ссылок в исследовательском агенте
Temporary fallback cover; replace in editorial pass.

Уровень: продвинутый · Чтение: около 11 минут · Опубликовано: 19 октября 2026

Модель пишет гладко и уверенно, и это хорошо видно на примере исследовательского агента: он может выдать связный обзор, где у статьи правильный на вид DOI, но не тот год, или где «цитата» вообще не встречается в источнике. Если отдать проверку другой модели, это не решает проблему, а просто добавляет ещё одно мнение. Нужна проверка, которая на одних и тех же входных данных всегда даёт один и тот же ответ и которую можно запустить в CI.

В статье соберём минимальный конвейер на Python, только со стандартной библиотекой. Он:

Что значит «детерминированная» и где её граница

Сеть сама по себе недетерминирована: запись в реестре могут исправить, сервис может не ответить. Поэтому детерминированность здесь обеспечивается разделением этапов. Сначала один раз и явно собираются фиксированные данные: кэш метаданных и снимки текстов. Потом поверх них выполняются чистые проверки. В режиме --offline конвейер в сеть не обращается совсем. При одинаковых claims.json, кэше и снимках отчёт совпадает байт в байт.

Граница метода такая: скрипт доказывает, что источник существует, что метаданные совпадают и что процитированный фрагмент действительно есть в тексте. Он не доказывает, что фрагмент подтверждает утверждение по смыслу. Эта часть остаётся за человеком или за отдельной недетерминированной проверкой, и в отчёте их нужно разделять.

Шаг 1. Контракт вывода агента

Проверять можно только структурированные данные. Попросите агента возвращать, помимо текста, файл с утверждениями. Каждое утверждение ссылается на источник через DOI и приводит дословную цитату:

{
  "claims": [
    {
      "id": "c1",
      "text": "Утверждение в том виде, в каком оно стоит в отчёте",
      "citations": [
        {
          "doi": "10.xxxx/...",
          "title": "Название статьи",
          "year": 2024,
          "first_author": "Фамилия",
          "quote": "Дословный фрагмент из источника"
        }
      ]
    }
  ]
}

Если утверждение без ссылок, это тоже ошибка (E_UNCITED). Если агенту можно писать собственные выводы без источника, помечайте их отдельным типом и не кладите в этот файл.

Шаг 2. Подготовка каталога

mkdir citation-audit && cd citation-audit
mkdir cache sources
python3 --version   # нужен Python 3.8+

Ставить зависимости не нужно. В cache/ лежат ответы реестра, в sources/ лежат снимки текстов, которые вы добыли сами.

Шаг 3. Скрипт проверки

Сохраните как citation_audit.py:

#!/usr/bin/env python3
"""Детерминированный аудит ссылок исследовательского агента."""
import argparse, hashlib, json, pathlib, re, sys, unicodedata
import urllib.error, urllib.parse, urllib.request

DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$")
NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
MIN_QUOTE = 30


def norm(text):
    text = unicodedata.normalize("NFKC", text or "").casefold()
    text = re.sub(r"[\u2010-\u2015]", "-", text)
    text = re.sub(r"[\"'«»“”„‘’]", "", text)
    return re.sub(r"\s+", " ", text).strip()


def norm_doi(doi):
    doi = (doi or "").strip().lower()
    return re.sub(r"^(https?://(dx\.)?doi\.org/|doi:\s*)", "", doi)


def slug(doi):
    return re.sub(r"[^a-z0-9._-]", "_", doi)


def numbers(text):
    return {n.replace(",", ".") for n in NUM_RE.findall(text or "")}


def fetch_crossref(doi, mailto):
    url = "https://api.crossref.org/works/" + urllib.parse.quote(doi, safe="")
    if mailto:
        url += "?mailto=" + urllib.parse.quote(mailto)
    req = urllib.request.Request(url, headers={"User-Agent": "citation-audit/0.1"})
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            msg = json.load(resp)["message"]
    except urllib.error.HTTPError as err:
        if err.code == 404:
            return {"status": "not_found"}
        raise  # временные сбои не кэшируем
    parts = (msg.get("issued") or {}).get("date-parts") or [[None]]
    return {
        "status": "found",
        "title": (msg.get("title") or [""])[0],
        "year": parts[0][0],
        "authors": [a.get("family", "") for a in msg.get("author", [])],
    }


def resolve(doi, args):
    path = args.cache / f"{slug(doi)}.json"
    if path.exists():
        return json.loads(path.read_text("utf-8"))
    if args.offline:
        return {"status": "not_cached"}
    record = fetch_crossref(doi, args.mailto)
    args.cache.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(record, ensure_ascii=False, sort_keys=True, indent=2), "utf-8")
    return record


def check_citation(claim_text, cit, args):
    doi = norm_doi(cit.get("doi"))
    if not DOI_RE.match(doi):
        return ["E_DOI_SYNTAX"]
    rec = resolve(doi, args)
    if rec["status"] == "not_found":
        return ["E_DOI_NOT_FOUND"]
    if rec["status"] == "not_cached":
        return ["E_NOT_CACHED"]

    errors = []
    if norm(cit.get("title")) != norm(rec["title"]):
        errors.append("E_TITLE_MISMATCH")
    if str(cit.get("year")) != str(rec["year"]):
        errors.append("E_YEAR_MISMATCH")
    if norm(cit.get("first_author")) not in [norm(a) for a in rec["authors"]]:
        errors.append("E_AUTHOR_MISMATCH")

    quote = norm(cit.get("quote"))
    snapshot = args.sources / f"{slug(doi)}.txt"
    if len(quote) < MIN_QUOTE:
        errors.append("E_QUOTE_TOO_SHORT")
    elif not snapshot.exists():
        errors.append("E_NO_SNAPSHOT")
    elif quote not in norm(snapshot.read_text("utf-8")):
        errors.append("E_QUOTE_NOT_FOUND")

    missing = sorted(numbers(claim_text) - numbers(cit.get("quote")))
    if missing:
        errors.append("E_NUMBER_UNSUPPORTED:" + ",".join(missing))
    return errors


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("claims", type=pathlib.Path)
    ap.add_argument("--cache", type=pathlib.Path, default=pathlib.Path("cache"))
    ap.add_argument("--sources", type=pathlib.Path, default=pathlib.Path("sources"))
    ap.add_argument("--offline", action="store_true")
    ap.add_argument("--mailto", default="")
    ap.add_argument("--out", type=pathlib.Path, default=pathlib.Path("report.json"))
    args = ap.parse_args()

    raw = args.claims.read_bytes()
    data = json.loads(raw)
    results = []
    for claim in sorted(data["claims"], key=lambda c: c["id"]):
        cits = claim.get("citations") or []
        if not cits:
            results.append({"claim": claim["id"], "doi": None, "errors": ["E_UNCITED"]})
        for cit in cits:
            results.append({
                "claim": claim["id"],
                "doi": norm_doi(cit.get("doi")),
                "errors": check_citation(claim["text"], cit, args),
            })

    failed = [r for r in results if r["errors"]]
    report = {
        "input_sha256": hashlib.sha256(raw).hexdigest(),
        "checked": len(results),
        "failed": len(failed),
        "results": results,
    }
    args.out.write_text(
        json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", "utf-8")
    print(f"checked={len(results)} failed={len(failed)} report={args.out}")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())

Несколько решений, которые здесь сделаны сознательно:

Шаг 4. Офлайн-проверка на учебных данных

Чтобы убедиться, что всё работает, сеть не нужна. Ниже вымышленный пример: DOI 10.0000/demo.1, название, авторы и текст придуманы только для проверки скрипта и ни на какую реальную публикацию не ссылаются.

cat > cache/10.0000_demo.1.json <<'EOF'
{"status": "found", "title": "Demo Paper on Retrieval Grounding",
 "year": 2024, "authors": ["Ivanova", "Petrov"]}
EOF

cat > sources/10.0000_demo.1.txt <<'EOF'
В наших экспериментах привязка каждого утверждения к найденному фрагменту
снизила число неподтверждённых утверждений с 18 до 7 на сотню ответов.
EOF

cat > claims.json <<'EOF'
{"claims": [
  {"id": "c1",
   "text": "Привязка к фрагментам снизила число неподтверждённых утверждений с 18 до 7 на сотню ответов.",
   "citations": [{"doi": "https://doi.org/10.0000/DEMO.1",
     "title": "Demo Paper on Retrieval Grounding", "year": 2024, "first_author": "Ivanova",
     "quote": "снизила число неподтверждённых утверждений с 18 до 7 на сотню ответов"}]},
  {"id": "c2",
   "text": "Метод снижает ошибки в 3 раза.",
   "citations": [{"doi": "10.0000/demo.1",
     "title": "Demo Paper on Retrieval Grounding", "year": 2023, "first_author": "Ivanova",
     "quote": "привязка каждого утверждения устраняет все ошибки"}]},
  {"id": "c3",
   "text": "Аналогичный эффект показан в другой работе.",
   "citations": [{"doi": "10.0000/demo.2", "title": "Other", "year": 2024,
     "first_author": "Sidorov", "quote": "аналогичный эффект наблюдался и в нашей выборке"}]},
  {"id": "c4", "text": "Утверждение без источника.", "citations": []}
]}
EOF

python3 citation_audit.py claims.json --offline
echo "exit=$?"

Проверка результата

Вывод в терминале должен быть таким:

checked=4 failed=3 report=report.json
exit=1

Откройте report.json (например, python3 -m json.tool report.json) и сверьте ошибки:

Запустите команду ещё раз и сравните: cp report.json r1.json && python3 citation_audit.py claims.json --offline; diff r1.json report.json. Если diff ничего не выводит, отчёт воспроизводим.

Шаг 5. Работа с реальными DOI

Заполнение кэша выделите в отдельный, явный шаг. API Crossref просит указывать контактный адрес в параметре mailto. Подставьте свой адрес вместо примера:

python3 citation_audit.py claims.json --mailto you@example.org
git add cache/ && git commit -m "Зафиксировать метаданные источников"

Наружу уходят только DOI из вашего файла, ни текст отчёта, ни ключи туда не попадают. После фиксации кэша все последующие запуски, в том числе в CI, делайте с --offline.

Снимки текстов в sources/ готовит человек или отдельный проверяемый загрузчик, но не тот агент, чью работу вы проверяете. Скачайте статью доступным вам легальным способом, извлеките текст, сохраните его как sources/<slug DOI>.txt и закоммитьте. Тогда изменение снимка будет видно в истории.

Шаг 6. Встраивание в цикл агента

  1. Агент пишет отчёт и claims.json.
  2. Оркестратор запускает аудит. Код 0 означает, что отчёт идёт дальше, на смысловое ревью.
  3. Код 1 означает, что агенту возвращается report.json с инструкцией: исправить или удалить утверждения с ошибками, не трогая остальные.
  4. Число итераций ограничено, например двумя. Если ошибки остались, отчёт уходит человеку вместе с аудитом.

Главное правило: у агента нет права записи в cache/ и sources/. Иначе самый короткий путь пройти проверку для него — дописать нужную цитату в снимок.

Типовые ошибки

Ограничения

Что дальше

Связанные материалы о проверке агентов собраны в руководствах, а термины из статьи объяснены в глоссарии. Следующий логичный шаг: добавить смысловую проверку «цитата подтверждает утверждение» отдельной стадией с отдельным полем в отчёте, чтобы её вероятностный вердикт не смешивался с детерминированными ошибками.