181 lines
7.1 KiB
Python
181 lines
7.1 KiB
Python
"""Checks a generated answer against the evidence it was built from.
|
|
|
|
The answer layer may only rephrase retrieved text. This module is what makes
|
|
that a checkable property rather than a promise in a prompt: it recomputes,
|
|
from the evidence alone, whether every claim in a generated answer traces
|
|
back to the specific source block it cites. A generation that fails is
|
|
discarded, never shown.
|
|
|
|
Binding is per-citation, not global. The answer is split at each citation
|
|
marker group (one or more consecutive `[n]`); the text immediately before a
|
|
group is that group's claim, and only the evidence block(s) named in that
|
|
group may support it. A number that is true of evidence block 2 does not
|
|
make a claim citing `[1]` grounded — the old implementation pooled every
|
|
number from every evidence block into one set, which let a number attributed
|
|
to the wrong source pass silently. `evidence_texts` is positional: `[n]`
|
|
refers to `evidence_texts[n - 1]`.
|
|
|
|
A claim with no valid citation group is rejected outright — a citation
|
|
nobody can follow is not a citation, and an uncited clinical statement is not
|
|
verifiable, numeric or not. This catches a missing-citation defect that the
|
|
old check never looked for at all (it only ever checked numbers already
|
|
carrying a marker).
|
|
|
|
Numbers are compared **character for character**, deliberately. "7,5" and
|
|
"7.5" are not treated as equal, and no attempt is made to parse either into a
|
|
quantity. Parsing invites the one error that matters most here: `1.500` is
|
|
1500 under one reading and 1.5 under another, and a normaliser that strips
|
|
separators maps "7,5" and "75" to the same key — a tenfold dose error scored
|
|
as a match. The model is told to copy figures verbatim, so an exact match is
|
|
achievable, and every deviation from it is refused rather than interpreted.
|
|
|
|
What this module still cannot do: confirm that a citation-bearing nonnumeric
|
|
claim is actually *entailed* by the block it cites (e.g. "chữa ung thư [1]"
|
|
where evidence 1 is only about "điều trị đái tháo đường" — same drug name,
|
|
unrelated indication). Regex-level number/citation checking has no notion of
|
|
semantic content. That gap is closed separately by an LLM entailment pass
|
|
(`rag/answer.py`'s post-generation verifier call), not by this module.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
from .telemetry import traced_stage
|
|
|
|
# A digit run with internal separators kept: "500", "7,5", "1.000".
|
|
# Ranges ("4 - 6 giờ") yield two tokens, and each is checked on its own.
|
|
_NUMBER = re.compile(r"\d+(?:[.,]\d+)*")
|
|
|
|
# Citation markers are stripped before number extraction so that "[2]" is
|
|
# never mistaken for the quantity 2.
|
|
_CITATION = re.compile(r"\[(\d+)\]")
|
|
|
|
# One or more consecutive markers ("[1]", "[1][2]") count as a single group:
|
|
# the prompt allows citing more than one source for one claim, and each is
|
|
# checked against the union of just those sources, not all evidence.
|
|
_CITATION_GROUP = re.compile(r"(?:\[\d+\])+")
|
|
|
|
# Any word character — letter (Vietnamese diacritics included) or digit —
|
|
# used to tell "claim with actual content" apart from bare punctuation or
|
|
# whitespace trailing a citation, which needs no citation of its own.
|
|
_LETTER = re.compile(r"\w", re.UNICODE)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GroundingReport:
|
|
grounded: bool
|
|
unsupported_numbers: tuple[str, ...]
|
|
invalid_citations: tuple[int, ...]
|
|
uncited_claim: bool
|
|
cited_indices: tuple[int, ...]
|
|
|
|
@property
|
|
def reason(self) -> str:
|
|
if self.unsupported_numbers:
|
|
return "ungrounded_number"
|
|
if self.invalid_citations:
|
|
return "invalid_citation"
|
|
if self.uncited_claim:
|
|
return "uncited_claim"
|
|
return "grounded"
|
|
|
|
|
|
def numbers_in(text: str) -> tuple[str, ...]:
|
|
"""Numeric tokens, with citation markers removed first."""
|
|
return tuple(_NUMBER.findall(_CITATION.sub(" ", text)))
|
|
|
|
|
|
def citations_in(text: str) -> tuple[int, ...]:
|
|
return tuple(int(marker) for marker in _CITATION.findall(text))
|
|
|
|
|
|
def has_content(text: str) -> bool:
|
|
"""True once `text` carries any letter or digit — i.e. more than
|
|
punctuation or whitespace left over between/after citation markers."""
|
|
return _LETTER.search(text) is not None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Claim:
|
|
"""One citation-bounded segment of an answer: the text before a citation
|
|
group, and the (in-range) evidence indices that group names.
|
|
|
|
`indices` is empty for the trailing segment after the last citation
|
|
group, or for a claim whose only marker(s) were out of range — in both
|
|
cases there is no evidence block left to check the claim against.
|
|
"""
|
|
|
|
text: str
|
|
indices: tuple[int, ...]
|
|
|
|
|
|
def split_claims(answer: str, evidence_count: int) -> tuple[Claim, ...]:
|
|
"""The claim segmentation `verify` checks numbers against, exposed so a
|
|
semantic entailment pass can run the same per-claim binding — each claim
|
|
checked only against the evidence block(s) it actually cites, never the
|
|
whole evidence set.
|
|
"""
|
|
claims: list[Claim] = []
|
|
cursor = 0
|
|
for group in _CITATION_GROUP.finditer(answer):
|
|
text = answer[cursor:group.start()]
|
|
cursor = group.end()
|
|
indices = tuple(
|
|
i for i in citations_in(group.group(0)) if 1 <= i <= evidence_count
|
|
)
|
|
claims.append(Claim(text, indices))
|
|
claims.append(Claim(answer[cursor:], ()))
|
|
return tuple(claims)
|
|
|
|
|
|
@traced_stage("grounding")
|
|
def verify(answer: str, evidence_texts: tuple[str, ...]) -> GroundingReport:
|
|
"""Whether `answer` states only figures and sources traceable to the
|
|
specific evidence block(s) cited immediately after each claim.
|
|
|
|
See module docstring for the binding rule and its known limit (no
|
|
semantic entailment check).
|
|
"""
|
|
unsupported: list[str] = []
|
|
invalid: list[int] = []
|
|
uncited = False
|
|
cited_all: set[int] = set()
|
|
|
|
cursor = 0
|
|
for group in _CITATION_GROUP.finditer(answer):
|
|
claim = answer[cursor:group.start()]
|
|
cursor = group.end()
|
|
|
|
indices = citations_in(group.group(0))
|
|
bad = [i for i in indices if not 1 <= i <= len(evidence_texts)]
|
|
good = [i for i in indices if i not in bad]
|
|
invalid.extend(bad)
|
|
cited_all.update(good)
|
|
|
|
claim_numbers = numbers_in(claim)
|
|
if good:
|
|
source_numbers: set[str] = set()
|
|
for index in good:
|
|
source_numbers.update(numbers_in(evidence_texts[index - 1]))
|
|
unsupported.extend(n for n in claim_numbers if n not in source_numbers)
|
|
else:
|
|
# Every marker in this group was out of range: nothing to bind
|
|
# the claim to, numeric or not.
|
|
unsupported.extend(claim_numbers)
|
|
if has_content(claim):
|
|
uncited = True
|
|
|
|
tail = answer[cursor:]
|
|
unsupported.extend(numbers_in(tail))
|
|
if has_content(tail):
|
|
uncited = True
|
|
|
|
return GroundingReport(
|
|
grounded=not unsupported and not invalid and not uncited,
|
|
unsupported_numbers=tuple(unsupported),
|
|
invalid_citations=tuple(invalid),
|
|
uncited_claim=uncited,
|
|
cited_indices=tuple(sorted(cited_all)),
|
|
)
|