Double vérification des citations
Repère les citations erronées ou inventées en les vérifiant contre le document source. Une question Choice décide si le contexte de la citation étaye l’affirmation.
Un LLM répond à une question et y joint des citations pour chaque affirmation, une section d’un document source et l’extrait sur lequel elle repose. Certaines de ces citations sont fausses ou inventées l’extrait peut manquer complètement au document, ou s’y trouver mot pour mot alors que son contexte dit le contraire de l’affirmation.
Vérifier une citation à la main est lent trouver le document, trouver l’extrait dedans, puis lire assez de contexte pour dire s’il étaye l’affirmation.
Pour automatiser cette vérification, on cherche d’abord les extraits manquants avec une simple comparaison de chaînes, puis on utilise une question Choice pour lire le contexte de chaque extrait restant et décider s’il étaye l’affirmation.
%%{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
Ci-dessous, huit citations tirées de la réponse d’un LLM sur la RFC 7519 (JSON Web Token) passent par la vérification. Les quatre exactes sont revenues verified avec une confiance de 0.93 ou plus. Les quatre échecs plantés ont tous été détectés un extrait fabriqué, une affirmation contredite et deux citations non étayées envoyées à un humain.
check_citation(), la fonction que tu construis ici, prend un document source et une citation et renvoie l’un de quatre verdicts verified, unsupported, contradicted ou fabricated. Elle renvoie aussi une confiance qui signale celles qu’un humain devrait examiner.
Configuration
pip install ipython 'cooksafe>=0.2.0,<0.3.0'
puis définis TYPESAFE_API_KEY. Chaque appel API est mis en cache dans json_cache.json, livré avec le cookbook, donc une nouvelle exécution rejoue les chiffres publiés au lieu d’appeler l’API. Supprime ce fichier pour tout exécuter en direct.
Les chiffres ci-dessous proviennent de jev-1.12 le 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"))
Charge la source et les citations
La source est la RFC 7519 (JSON Web Token), récupérée depuis rfc-editor.org et versionnée à côté de ce cookbook sous le nom rfc7519.txt. Le code ci-dessous retire les en-têtes et les pieds de page, puis découpe le texte en sections numérotées.
Les huit citations de citations.json ont été écrites par un LLM à partir de la RFC. Quatre sont exactes nous avons modifié les quatre autres pour qu’elles échouent à la vérification.
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"
}
Trouve chaque extrait dans la source
Un extrait qui n’est pas dans la source est fabriqué, et aucun modèle n’est nécessaire pour le découvrir. Normalise les espaces et les guillemets typographiques pour qu’un extrait corresponde encore à travers les retours à la ligne de la RFC, puis cherche-le comme sous-chaîne. Une correspondance dit aussi de quelle section vient l’extrait, et cette section est le texte que le modèle lira à l’étape suivante.
Une citation peut nommer une section sans rien en citer. Dans ce cas il n’y a rien à faire correspondre prends la section que nomme la citation et va directement au modèle.
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
Vérifie si la source étaye l’affirmation
Une citation qui a encore un extrait à ce stade correspond à la source mot pour mot. Cela ne suffit pas l’extrait peut être exact et l’affirmation construite dessus rester fausse. En décider demande le contexte de l’extrait, la section qu’a trouvée l’étape 1.
Une question Choice par citation restante couvre les trois façons dont une section peut se rapporter à une affirmation.
L’option de plus haute probabilité est le verdict, et AUTO_ACCEPT (0.8 dans le code ci-dessus) décide de ce qui lui arrive
- une confiance égale ou supérieure à 0.8 le verdict tient tout seul
- en dessous de 0.8 un humain confirme le verdict avant que quoi que ce soit agisse dessus.
Commence haut, et baisse le seuil à mesure que tu vois comment le modèle se comporte sur tes propres documents.
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)}
Vérifie chaque citation
Les huit citations passent par la même vérification
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
Quatre citations sont revenues verified, une fabricated, une contradicted et deux unsupported.
epoch_seconds,aud_reject,clock_skewetduplicate_namessont les quatre exactes. Toutes sont revenuesverifiedavec une confiance de 0.93 ou plus, bien au-dessus deAUTO_ACCEPT.sig_reportingn’a jamais atteint le modèle. Son extrait n’est pas dans la RFC, donc la comparaison de chaînes seule la marquefabricated.exp_requiredcite la section 4.1.4 mot pour mot, et cette même section dit « Use of this claim is OPTIONAL », donc elle estcontradicted, avec une confiance de 0.99.pii_encryptionetiat_futuresont revenuesunsupportedà 0.27 et 0.56, toutes deux sous le seuil, donc les deux sont allées à un humain.pii_encryptionmontre pourquoi la comparaison de chaînes ne suffit pas à elle seule son extrait est dans la source mot pour mot, et la section dont il vient ne dit rien sur l’affirmation.
Pour pointer ceci sur tes propres données, remplace rfc7519.txt et citations.json. load_source() et split_sections() sont écrites pour la mise en page d’une RFC, donc un document d’une autre forme a besoin de son propre découpage.
La comparaison de chaînes est exacte après normalisation un extrait tronqué ou légèrement reformulé revient comme fabricated. Un système en production qui tolère des citations approximatives aurait besoin d’une correspondance floue à la place.
Ouvre-le dans le playground
Le lien contient l’affirmation et la section d’une citation, plus la question. Ouvre-le pour exécuter le même appel en direct dans le navigateur.
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})"))
Ouvre l’affirmation + la section d’une citation dans le playground TypeSafe →