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

Модель пишет гладко и уверенно, и это хорошо видно на примере исследовательского агента: он может выдать связный обзор, где у статьи правильный на вид DOI, но не тот год, или где «цитата» вообще не встречается в источнике. Если отдать проверку другой модели, это не решает проблему, а просто добавляет ещё одно мнение. Нужна проверка, которая на одних и тех же входных данных всегда даёт один и тот же ответ и которую можно запустить в CI.
В статье соберём минимальный конвейер на Python, только со стандартной библиотекой. Он:
- разрешает каждую ссылку через внешний идентификатор (DOI) в публичном API Crossref и кэширует ответ;
- сравнивает название, год и первого автора с тем, что написал агент;
- ищет цитату дословно, после нормализации, в зафиксированном снимке текста источника;
- проверяет, что числа из утверждения есть в цитате;
- пишет
report.jsonи завершается с ненулевым кодом, если найдены ошибки.
Что значит «детерминированная» и где её граница
Сеть сама по себе недетерминирована: запись в реестре могут исправить, сервис может не ответить. Поэтому детерминированность здесь обеспечивается разделением этапов. Сначала один раз и явно собираются фиксированные данные: кэш метаданных и снимки текстов. Потом поверх них выполняются чистые проверки. В режиме --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())
Несколько решений, которые здесь сделаны сознательно:
- DOI нормализуется. DOI нечувствительны к регистру, а агенты пишут их и как URL, и с префиксом
doi:. Без приведения к одному виду вы получите ложные расхождения и дубли в кэше. - Кэшируется только окончательный ответ. Ответ 404 записывается как
not_found. Таймауты и ошибки 5xx пробрасываются наружу, чтобы временный сбой не превратился в постоянный вердикт «источника нет». - Цитата ищется как подстрока, без нечёткого сравнения. Fuzzy-matching с порогом даёт «почти совпало», а именно так модель и искажает цитаты: меняет одно слово или отрицание. Нормализуются только пробелы, регистр, кавычки, дефисы и Unicode-формы.
- Есть минимальная длина цитаты. Фрагмент из трёх слов найдётся почти в любом тексте, и проверка потеряет смысл.
- Отчёт без времени запуска. В нём хэш входного файла и отсортированные ключи, поэтому два запуска легко сравнить через
diff.
Шаг 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) и сверьте ошибки:
c1: пустой список ошибок. DOI в виде URL и в верхнем регистре корректно нормализован, цитата найдена, числа 18 и 7 в ней есть.c2:E_YEAR_MISMATCH,E_QUOTE_NOT_FOUND,E_NUMBER_UNSUPPORTED:3. Это классический случай: источник настоящий, а цитата и цифра придуманы.c3:E_NOT_CACHED. В офлайн-режиме неизвестный DOI не считается ни найденным, ни несуществующим.c4:E_UNCITED.
Запустите команду ещё раз и сравните: 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. Встраивание в цикл агента
- Агент пишет отчёт и
claims.json. - Оркестратор запускает аудит. Код 0 означает, что отчёт идёт дальше, на смысловое ревью.
- Код 1 означает, что агенту возвращается
report.jsonс инструкцией: исправить или удалить утверждения с ошибками, не трогая остальные. - Число итераций ограничено, например двумя. Если ошибки остались, отчёт уходит человеку вместе с аудитом.
Главное правило: у агента нет права записи в cache/ и sources/. Иначе самый короткий путь пройти проверку для него — дописать нужную цитату в снимок.
Типовые ошибки
- Кэшировать сетевые сбои. Один таймаут, и источник навсегда считается несуществующим. Кэшируйте только 200 и 404.
- Строго сравнивать названия из Crossref. В названиях встречаются HTML-теги (
<i>), подзаголовки, другой регистр. Сначала посмотрите на свои данные. Если расхождений много, понизьтеE_TITLE_MISMATCHдо предупреждения, но не убирайте. - Брать текст из PDF как есть. Переносы со знаком дефиса, лигатуры и колонтитулы рвут цитаты. NFKC справляется с лигатурами, но переносы нужно склеить при извлечении текста.
- Проверять цитату по аннотации. Если агент цитирует основной текст, а у вас в снимке только аннотация, вы получите ложный
E_QUOTE_NOT_FOUND. В снимке должен быть тот объём текста, по которому агент работал. - Считать числовую проверку смысловой.
E_NUMBER_UNSUPPORTEDсрабатывает и на год в утверждении, и на «2 из 3», переписанное как «67%». Это сигнал посмотреть внимательнее, а не приговор.
Ограничения
- Найденная цитата не означает, что утверждение подтверждено. Фрагмент мог быть вырван из контекста или относиться к другому условию эксперимента.
- Скрипт знает только Crossref. DOI других агентств регистрации (например, DataCite) и препринты без DOI потребуют отдельного резолвера с тем же интерфейсом
resolve(). - Кэш устаревает. Отзыв статьи или исправление метаданных после фиксации кэша скрипт не заметит. Перезаполняйте кэш по расписанию и смотрите
git diff. slug()теоретически может склеить два разных DOI в одно имя файла. Для большого корпуса добавьте к имени короткий хэш.- Сверяется только первый автор и год. Если агент перепутал соавторов или журнал, ошибка пройдёт.
Что дальше
Связанные материалы о проверке агентов собраны в руководствах, а термины из статьи объяснены в глоссарии. Следующий логичный шаг: добавить смысловую проверку «цитата подтверждает утверждение» отдельной стадией с отдельным полем в отчёте, чтобы её вероятностный вердикт не смешивался с детерминированными ошибками.