Verificação dupla de citações
Apanha citações erradas ou inventadas ao verificá-las contra o documento de origem. Uma pergunta Choice decide se o contexto da citação sustenta a afirmação.
Um LLM responde a uma pergunta e anexa citações: por cada afirmação, uma secção de um documento de origem e a citação textual em que se apoia. Algumas dessas citações estão erradas ou são inventadas: a citação textual pode faltar por completo do documento, ou estar nele palavra por palavra enquanto o seu contexto diz o contrário da afirmação.
Verificar uma à mão é lento: encontrar o documento, encontrar a citação textual dentro dele e depois ler contexto suficiente para dizer se sustenta a afirmação.
Para automatizar essa verificação, procuramos primeiro as citações em falta com uma simples
comparação de cadeias de texto e depois usamos uma pergunta Choice para ler o contexto de
cada citação textual que resta e decidir se sustenta a afirmação.
%%{init: {"flowchart": {"wrappingWidth": 330}}}%%
flowchart LR
cite["source document + citation"]
match{"is the quote<br/>in the source?"}
fab["mark <b>fabricated</b>"]
subgraph request[" "]
q["Choice — how does the<br/>section relate to the claim?<br/>supports → mark <b>verified</b><br/>contradicts → mark <b>contradicted</b><br/>says nothing → mark <b>unsupported</b>"]
end
gate{"confidence<br/>≥ 0.8?"}
stand["let the verdict stand"]
review["a human confirms it"]
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 -- "found" --> request
match -- "no quote" --> request
match -- "not found" --> fab
request --> gate
gate --> stand
gate --> review
Abaixo, oito citações da resposta de um LLM sobre o RFC 7519 (JSON Web Token) passam pela
verificação. As quatro corretas voltaram como verified, com uma confiança de 0.93 ou
mais. As quatro falhas plantadas foram todas detetadas: uma citação textual fabricada, uma
afirmação contradita e duas citações não sustentadas enviadas a uma pessoa.
check_citation(), a função que construis aqui, recebe um documento de origem e uma
citação e devolve um de quatro veredictos: verified, unsupported, contradicted ou
fabricated. Devolve também uma confiança que assinala as que uma pessoa deveria ver.
Configuração
pip install ipython 'cooksafe>=0.2.0,<0.3.0'
depois define TYPESAFE_API_KEY. Cada chamada à API fica em cache no json_cache.json,
que é distribuído com o cookbook, por isso voltar a executar reproduz os números publicados
em vez de chamar a API. Elimina esse ficheiro para executar tudo em direto.
Os números abaixo vêm do jev-1.12, de 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"))
Carrega a fonte e as citações
A fonte é o RFC 7519 (JSON Web Token),
obtido de rfc-editor.org e incluído junto a este cookbook como rfc7519.txt. O código
abaixo remove os cabeçalhos e os rodapés de página e depois divide o texto em secções
numeradas.
As oito citações em citations.json foram escritas por um LLM contra o RFC. Quatro estão
corretas; editámos as outras quatro para falharem a verificação.
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"
}
Encontra cada citação textual na fonte
Uma citação textual que não está na fonte está fabricada, e não é preciso nenhum modelo para o descobrir. Normaliza os espaços e as aspas curvas para que uma citação textual continue a coincidir através das quebras de linha do RFC e depois procura-a como subcadeia. Uma correspondência também diz de que secção veio a citação textual, e essa secção é o texto que o modelo lê no passo seguinte.
Uma citação pode nomear uma secção sem citar nada dela. Nesse caso não há nada que comparar, por isso toma a secção que a citação nomeia e vai diretamente ao modelo.
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
Verifica se a fonte sustenta a afirmação
Uma citação que ainda tem uma citação textual neste ponto coincide com a fonte palavra por palavra. Isso não basta: a citação textual pode ser exata e a afirmação construída sobre ela continuar a ser falsa. Decidir isso exige o contexto da citação textual, a secção que o passo 1 encontrou.
Uma pergunta Choice por cada citação que resta cobre as três formas como uma secção se
pode relacionar com uma afirmação.
A opção com a maior probabilidade é o veredicto, e AUTO_ACCEPT (0.8 no código acima)
decide o que lhe acontece:
- confiança de 0.8 ou mais: o veredicto sustenta-se por si só;
- abaixo de 0.8: uma pessoa confirma o veredicto antes de algo agir sobre ele.
Começa alto e baixa o limiar à medida que vês como o modelo se comporta com os teus próprios documentos.
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)}
Verifica cada citação
As oito citações pela mesma verificação:
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
Quatro citações voltaram como verified, uma como fabricated, uma como contradicted e
duas como unsupported.
epoch_seconds,aud_reject,clock_skeweduplicate_namessão as quatro corretas. Todas voltaram comoverified, com uma confiança de 0.93 ou mais, bem acima deAUTO_ACCEPT.sig_reportingnunca chegou ao modelo. A sua citação textual não está no RFC, por isso a comparação de cadeias, só por si, marca-a comofabricated.exp_requiredcita a secção 4.1.4 palavra por palavra, e essa mesma secção diz «Use of this claim is OPTIONAL», por isso écontradicted, com uma confiança de 0.99.pii_encryptioneiat_futurevoltaram comounsupported, com 0.27 e 0.56, ambas abaixo do limiar, por isso as duas foram para uma pessoa.pii_encryptionmostra porque é que a comparação de cadeias não basta por si só: a sua citação textual está na fonte palavra por palavra, e a secção de onde veio não diz nada sobre a afirmação.
Para apontar isto aos teus próprios dados, substitui rfc7519.txt e citations.json.
load_source() e split_sections() estão escritas para a disposição de um RFC, por isso um
documento com outra forma precisa da sua própria análise.
A comparação de cadeias é exata após a normalização: uma citação textual truncada ou
ligeiramente reformulada volta como fabricated. Um sistema em produção que tolere citações
descuidadas precisaria, em vez disso, de uma correspondência difusa.
Abre-o no playground
O link contém a afirmação e a secção de uma citação, mais a pergunta. Abre-o para executar a mesma chamada em direto no navegador.
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})"))
Abre a afirmação + a secção de uma citação no playground da TypeSafe →