Verificación de citas
Detecta citas erróneas o alucinadas verificándolas contra el documento original. Una pregunta de opción única de TypeSafe determina si el contexto de la cita respalda la afirmación, y su nivel de confianza puede marcar la cita para revisión humana.
Traducido automáticamente del en, sin revisión. Solo como referencia rápida.

Un modelo de lenguaje grande responde a una pregunta y adjunta citas: para cada afirmación, una sección de un documento fuente y la cita en la que se basa. Algunas de esas citas son incorrectas o alucinadas: la cita puede faltar por completo en el documento, o estar presente palabra por palabra mientras su contexto dice lo contrario de la afirmación.
Revisar uno a uno manualmente es lento: buscar el documento, encontrar la cita dentro de él y luego leer suficiente contexto para determinar si respalda la afirmación.
Para automatizar esa comprobación, primero buscamos comillas faltantes mediante una coincidencia de cadenas ordinaria,
y luego usamos una pregunta Choice para leer el contexto de cada comilla restante y decidir
si respalda la afirmación.
Dirección del flujo: LR
| Nodo | Descripción | Grupo |
|---|---|---|
cite |
documento fuente + cita | — |
match |
¿es la cita / en el fuente? | — |
fab |
marcar como fabricada | — |
request |
solicitud | solicitud |
q |
Choice — ¿cómo se relaciona la sección / con la afirmación? / apoya → marcar verificada / contradice → marcar contradicha / no dice nada → marcar no soportada | solicitud |
gate |
confianza / ≥ 0.8? | — |
stand |
dejar que el veredicto se mantenga | — |
review |
un humano lo confirma | — |
| De | Condición | A |
|---|---|---|
cite |
— | match |
match |
encontrado | request |
match |
sin cita | request |
match |
no encontrado | fab |
request |
— | gate |
gate |
— | stand |
gate |
— | review |
A continuación, ocho citas de la respuesta de un LLM sobre RFC 7519 (JSON Web Token) pasan por el
control. Las cuatro precisas volvieron verified con una confianza de 0,93 o superior. Se detectaron todos los cuatro
fallos plantados: una cita fabricada, una afirmación contradicha y dos citas sin respaldo
enviadas a un humano.
check_citation(), la función que construyes aquí, toma un documento de origen y una cita
y devuelve uno de cuatro veredictos: verified, unsupported, contradicted, o
fabricated. También devuelve una confianza que marca aquellos que un humano debería revisar.
Configuración
pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
entonces establece TYPESAFE_API_KEY. Cada llamada a la API se almacena en caché en json_cache.json, que se incluye con el libro de recetas, por lo que volver a ejecutar reproduce los números publicados en lugar de llamar a la API. Elimina ese archivo para ejecutar todo en tiempo real.
Los números de abajo provienen de jev-1.12 el 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"))
Cargar la fuente y las citas
La fuente es RFC 7519 (JSON Web Token),
obtenida de rfc-editor.org y comprometida junto a este cookbook como rfc7519.txt. El código
de abajo elimina los encabezados y pies de página, luego divide el texto en secciones numeradas.
Los ocho citados en citations.json fueron escritos por un modelo de lenguaje frente al RFC. Cuatro son
precisos; editamos los otros cuatro para que fallaran la verificación.
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"
}
Encuentra cada cita en la fuente
Una cita que no está en la fuente es fabricada, y no se necesita ningún modelo para descubrirlo. Normaliza los espacios en blanco y las comillas curvas para que una cita siga coincidiendo a través de los saltos de línea del RFC, luego búscala como una subcadena. Una coincidencia también indica de qué sección proviene la cita, y esa sección es el texto que el modelo lee en el siguiente paso.
Una cita puede nombrar una sección sin citar nada de ella. No hay nada que coincidir en ese caso, así que toma la sección que la cita nombra y ve directamente al 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
Verificar si la fuente respalda la afirmación
Una cita que aún conserva una comilla en este punto coincide palabra por palabra con la fuente. Eso no es suficiente: la cita puede ser precisa y la afirmación construida sobre ella aún así ser incorrecta. Decidirlo requiere el contexto de la cita, el paso de la sección 1 encontrado.
Una Choice pregunta por cada cita sobreviviente cubre las tres formas en que una sección puede relacionarse con una afirmación.
La opción con la mayor probabilidad es el veredicto, y AUTO_ACCEPT (0.8 en el código anterior) decide qué sucede con ella:
- confianza igual o superior a 0.8: el veredicto se mantiene por sí mismo;
- inferior a 0.8: un humano confirma el veredicto antes de que se actúe sobre él.
Empieza alto y baja el umbral a medida que veas cómo se comporta el modelo con tus propios 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
Los ocho citaciones pasan la misma verificación:
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
Volvieron cuatro citas verified, una fabricated, una contradicted y dos unsupported.
epoch_seconds,aud_reject,clock_skewyduplicate_namesson los cuatro correctos. Todos devolvieronverifiedcon una confianza de 0,93 o superior, muy por encima deAUTO_ACCEPT.sig_reportingnunca llegó al modelo. Su cita no está en la RFC, por lo que la coincidencia de cadena por sí sola lo marca comofabricated.exp_requiredcita la sección 4.1.4 palabra por palabra, y la misma sección dice “El uso de esta afirmación es OPCIONAL”, por lo que escontradicted, con una confianza de 0,99.pii_encryptionyiat_futuredevolvieronunsupportedcon valores de 0,27 y 0,56, ambos por debajo del umbral, por lo que ambos fueron asignados a un humano.pii_encryptionmuestra por qué la coincidencia de cadena no es suficiente por sí sola: su cita está en la fuente palabra por palabra, y la sección de la que proviene no dice nada sobre la afirmación.
Para apuntar esto a tus propios datos, reemplaza rfc7519.txt y citations.json.
load_source() y split_sections() están escritos para la disposición de un RFC, por lo que un documento
de otra forma necesita su propio análisis.
La coincidencia de cadenas es exacta tras la normalización: una cita que está truncada o ligeramente reescrita vuelve como fabricated. Un sistema de producción que tolere citas descuidadas necesitaría coincidencia difusa en su lugar.
Ábrelo en el playground
El enlace contiene la afirmación y la sección de una cita, además de la pregunta. Ábrelo para ejecutar la misma llamada en vivo en el 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 la reclamación y la sección de una cita en el playground de TypeSafe →