Проверка цитат и фактчекинг
Catch wrong or hallucinated citations by checking against the source document. One TypeSafe Choice question decides whether the quote's context supports the claim, and its confidence can flag the citation for human review.
Выявляйте неверные или галлюцинированные цитаты путем сверки с исходным документом. Один вопрос TypeSafe Choice определяет, подтверждает ли контекст цитаты утверждение, а его показатель уверенности (confidence) позволяет направить сомнительную цитату на проверку человеку.
LLM отвечает на вопрос и прикрепляет цитаты: для каждого утверждения указывается раздел исходного документа и цитата, на которую оно опирается. Некоторые из этих цитат оказываются ошибочными или вымышленными: цитата может вообще отсутствовать в документе либо присутствовать дословно, но в контексте, утверждающем прямо противоположное.
Проверять каждую цитату вручную слишком долго: нужно найти документ, отыскать в нем цитату, а затем прочитать достаточный фрагмент контекста, чтобы понять, действительно ли он подтверждает сказанное.
Чтобы автоматизировать эту проверку, мы сначала ищем отсутствующие цитаты с помощью обычного строкового сопоставления, а затем используем вопрос Choice, чтобы проанализировать контекст каждой найденной цитаты и определить, подтверждает ли он утверждение.
%%{init: {"flowchart": {"wrappingWidth": 330}}}%%
flowchart LR
cite["исходный документ + цитата"]
match{"присутствует ли цитата<br/>в источнике?"}
fab["пометить <b>fabricated</b>"]
subgraph request[" "]
q["Choice — как раздел<br/>соотносится с утверждением?<br/>supports → пометить <b>verified</b><br/>contradicts → пометить <b>contradicted</b><br/>says nothing → пометить <b>unsupported</b>"]
end
gate{"уверенность<br/>≥ 0.8?"}
stand["оставить вердикт"]
review["проверка человеком"]
cite --> match
%% the two edges that reach the call come first, so they stay adjacent; the
%% string match's own verdict is declared last and lands below them
match -- "найдена" --> request
match -- "нет цитаты" --> request
match -- "не найдена" --> fab
request --> gate
gate --> stand
gate --> review
Ниже восемь цитат из ответа LLM о спецификации RFC 7519 (JSON Web Token) проходят через эту проверку. Четыре корректные цитаты получили вердикт verified с уверенностью 0.93 или выше. Все четыре специально внедренные ошибки были успешно выявлены: сфабрикованная цитата, противоречащее утверждение и две неподтвержденные цитаты, отправленные человеку на проверку.
Функция check_citation(), которую вы создадите в этом руководстве, принимает исходный документ и одну цитату и возвращает один из четырех вердиктов: verified, unsupported, contradicted или fabricated. Она также возвращает показатель уверенности, позволяющий отметить случаи, требующие внимания человека.
Настройка
pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
Затем установите переменную TYPESAFE_API_KEY. Каждый вызов API кэшируется в файле json_cache.json, который поставляется вместе с руководством, поэтому при повторном запуске воспроизводятся сохраненные показатели без фактических запросов к API. Удалите этот файл, чтобы выполнить все вызовы вживую.
Приведенные ниже значения получены на модели jev-1.12 от 2026-08-16.
import json
import os
import re
from pathlib import Path
from time import perf_counter
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12"
AUTO_ACCEPT = 0.8 # start high for more human review as you build trust in the model
client = TypeSafeClient(
api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"),
base_url=os.environ.get("TYPESAFE_ENDPOINT"),
timeout=120.0,
)
json_cache = JsonCache(Path("json_cache.json"))
Загрузка источника и цитат
Источником служит спецификация RFC 7519 (JSON Web Token), загруженная с rfc-editor.org и сохраненная рядом с этим руководством в файле rfc7519.txt. Код ниже удаляет колонтитулы страниц и разбивает текст на пронумерованные разделы.
Восемь цитат в файле citations.json были сгенерированы LLM по тексту RFC. Четыре из них точны; остальные четыре мы отредактировали так, чтобы они не прошли проверку.
def load_source() -> str:
"""RFC 7519 verbatim, minus the page headers and footers that interrupt its paragraphs."""
lines = []
for line in Path("rfc7519.txt").read_text().splitlines():
bare = line.lstrip("\f")
if re.match(r"Jones, et al\.\s.*\[Page \d+\]$", bare):
continue
if re.match(r"RFC 7519\s+JSON Web Token \(JWT\)\s+May 2015$", bare):
continue
lines.append(bare)
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines))
def split_sections(source: str) -> dict[str, str]:
"""Map each numbered section ("4.1.3") to its text, split on the RFC's header lines."""
boundary = re.compile(r"(?m)^(?:(\d+(?:\.\d+)*)\. .+|Appendix [A-Z]\..*)$")
marks = list(boundary.finditer(source))
sections = {}
for mark, nxt in zip(marks, marks[1:] + [None]):
if mark.group(1) is None: # an appendix header only terminates the section before it
continue
sections[mark.group(1)] = source[mark.start() : nxt.start() if nxt else len(source)].strip()
return sections
SOURCE = load_source()
SECTIONS = split_sections(SOURCE)
CITATIONS = json.loads(Path("citations.json").read_text())
print(f"{len(SOURCE):,} characters, {len(SECTIONS)} numbered sections, {len(CITATIONS)} citations")
print("\nA citation with a quote:")
print(json.dumps(CITATIONS[1], indent=2))
print("\nA claim-only citation:")
print(json.dumps(next(c for c in CITATIONS if c["quote"] is None), indent=2))
58,365 characters, 45 numbered sections, 8 citations
A citation with a quote:
{
"id": "aud_reject",
"claim": "If a validator does not find itself in a token's audience list, it has to reject the token.",
"quote": "If the principal processing the claim does not identify itself with a value in the \"aud\" claim when this claim is present, then the JWT MUST be rejected.",
"section": "4.1.3"
}
A claim-only citation:
{
"id": "iat_future",
"claim": "The \"iat\" claim requires validators to reject tokens whose issue time is in the future.",
"quote": null,
"section": "4.1.6"
}
Поиск каждой цитаты в источнике
Цитата, которой нет в источнике, является сфабрикованной, и чтобы это понять, никакая нейросеть не требуется. Нормализуйте пробелы и фигурные кавычки, чтобы цитата совпадала даже при переносах строк в RFC, а затем выполните поиск подстроки. Совпадение также указывает, из какого раздела взята цитата, и этот раздел передается модели для чтения на следующем шаге.
Цитата может указывать раздел, не приводя прямых выдержек из него. В таком случае сопоставлять нечего, поэтому мы берем указанный раздел и сразу переходим к модели.
def normalize(text: str) -> str:
"""Collapse whitespace and fold curly quotes, so a quote matches across line wraps."""
table = str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'"})
return re.sub(r"\s+", " ", text.translate(table)).strip()
def find_quote(sections: dict[str, str], quote: str) -> str | None:
"""The number of the section that contains the quote verbatim, or None."""
needle = normalize(quote)
for number in sorted(sections, key=lambda n: [int(p) for p in n.split(".")]):
if needle in normalize(sections[number]):
return number
return None
def locate(sections: dict[str, str], citation: dict) -> tuple[str, str | None]:
"""Step 1 for one citation: a status, plus the section step 2 will read."""
if citation["quote"] is None:
return "section-only", sections[citation["section"]]
number = find_quote(sections, citation["quote"])
if number is None:
return "missing", None
return "found", sections[number]
for citation in CITATIONS:
status, section = locate(SECTIONS, citation)
where = f"section of {len(section):,} chars" if section else "not in the source"
print(f"{citation['id']:<18}{status:<14}{where}")
epoch_seconds found section of 3,122 chars
aud_reject found section of 761 chars
sig_reporting missing not in the source
clock_skew found section of 529 chars
exp_required found section of 529 chars
pii_encryption found section of 1,653 chars
iat_future section-only section of 270 chars
duplicate_names found section of 918 chars
Проверка того, подтверждает ли источник утверждение
Цитата, оставшаяся на данном этапе, дословно присутствует в источнике. Однако этого недостаточно: цитата может быть точной, но построенное на ней утверждение — ложным. Чтобы проверить это, необходим контекст цитаты — раздел, найденный на шаге 1.
Один вопрос Choice на каждую найденную цитату покрывает три варианта соотношения раздела и утверждения. Вариант с наибольшей вероятностью становится вердиктом, а параметр AUTO_ACCEPT (0.8 в коде выше) определяет дальнейшие действия:
- уверенность равна 0.8 или выше: вердикт принимается автоматически;
- ниже 0.8: человек должен подтвердить вердикт перед выполнением каких-либо действий.
Начните с высокого значения порога и снижайте его по мере того, как вы убедитесь в надежности работы модели на ваших документах.
QUESTIONS = {
"relation": Choice(
instructions="How does the section relate to the claim?",
criteria={
"supports": "The section states the claim or directly implies that it is true",
"contradicts": "The section states the opposite of the claim or implies it is false",
"says_nothing": "The section does not address what the claim asserts, either way",
},
),
}
RELATION_TO_VERDICT = {
"supports": "verified",
"contradicts": "contradicted",
"says_nothing": "unsupported",
}
@json_cache
def ask(claim: str, section: str) -> dict:
started = perf_counter()
response = client.system_one(
state={"claim": claim, "section": section},
questions=QUESTIONS,
model=TYPESAFE_MODEL,
)
answer = response.answers["relation"]
return {
"choice": answer.choice,
"probabilities": answer.probabilities,
"confidence": answer.confidence,
"seconds": round(perf_counter() - started, 2),
"input_tokens": response.usage.input_tokens or 0,
"output_tokens": response.usage.output_tokens or 0,
}
def verdict(status: str, answer: dict | None) -> dict:
"""Fold step 1 and step 2 into one of the four labels, plus an auto-or-review flag."""
if status == "missing":
# confidence None: no model was called, so there is no model confidence to report
return {"verdict": "fabricated", "confidence": None, "auto": True}
return {
"verdict": RELATION_TO_VERDICT[answer["choice"]],
"confidence": answer["confidence"],
"auto": answer["confidence"] >= AUTO_ACCEPT,
}
def check_citation(sections: dict[str, str], citation: dict) -> dict:
status, section = locate(sections, citation)
answer = ask(citation["claim"], section) if section is not None else None
return {"id": citation["id"], "status": status, "answer": answer, **verdict(status, answer)}
Проверка каждой цитаты
Все восемь цитат проходят одинаковую проверку:
print(f"{'citation':<18}{'quote':<14}{'relation':<14}{'conf':>6} {'verdict':<13}{'action':>7}")
for citation in CITATIONS:
result = check_citation(SECTIONS, citation)
answer = result["answer"]
relation = answer["choice"] if answer else "-"
conf = f"{answer['confidence']:.2f}" if answer else "-"
action = "auto" if result["auto"] else "review"
print(
f"{result['id']:<18}{result['status']:<14}{relation:<14}{conf:>6}"
f" {result['verdict']:<13}{action:>7}"
)
citation quote relation conf verdict action
epoch_seconds found supports 0.93 verified auto
aud_reject found supports 0.95 verified auto
sig_reporting missing - - fabricated auto
clock_skew found supports 0.99 verified auto
exp_required found contradicts 0.99 contradicted auto
pii_encryption found says_nothing 0.27 unsupported review
iat_future section-only says_nothing 0.56 unsupported review
duplicate_names found supports 0.99 verified auto
Четыре цитаты получили вердикт verified, одна — fabricated, одна — contradicted, и две — unsupported.
epoch_seconds,aud_reject,clock_skewиduplicate_names— корректные цитаты. Все они получили оценкуverifiedс показателем уверенности 0.93 или выше, что заметно выше порогаAUTO_ACCEPT.sig_reportingдаже не передавалась в модель. Ее цитата отсутствует в RFC, поэтому строковое сопоставление сразу пометило ее какfabricated.exp_requiredцитирует раздел 4.1.4 слово в слово, однако этот же раздел говорит: "Use of this claim is OPTIONAL" (использование этого клейма необязательно). Поэтому утверждение признано противоречащим (contradicted) с уверенностью 0.99.pii_encryptionиiat_futureвернули результатunsupportedс оценками 0.27 и 0.56 соответственно — оба ниже порога, поэтому оба отправлены человеку. Примерpii_encryptionпоказывает, почему одного строкового сопоставления недостаточно: цитата присутствует в источнике дословно, но исходный раздел вообще ничего не говорит о выдвинутом утверждении.
Чтобы применить этот подход к собственным данным, замените rfc7519.txt и citations.json. Функции load_source() и split_sections() написаны с учетом разметки RFC, поэтому для документов другой структуры потребуется собственный парсинг.
Строковое сопоставление является точным после нормализации: усеченная или слегка перефразированная цитата будет помечена как fabricated. Для рабочей системы, которая должна допускать нестрогие цитаты, потребуется нечеткий поиск (fuzzy matching).
Открыть в Playground
По ссылке доступны утверждение и раздел для одной из цитат, а также сам вопрос. Откройте ее, чтобы запустить такой же запрос в браузере.
example = next(c for c in CITATIONS if c["id"] == "exp_required")
_, example_section = locate(SECTIONS, example)
playground_link = make_playground_link(
{"claim": example["claim"], "section": example_section}, QUESTIONS, models=[TYPESAFE_MODEL]
)
display(Markdown(f"🔗 [Open one citation's claim + section in the TypeSafe playground]({playground_link})"))