Remove corpus counts from chat chrome

This commit is contained in:
2026-08-10 17:26:58 +07:00
parent 46469468bb
commit 97cb6d16f4
31 changed files with 2192 additions and 424 deletions
+525 -66
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import json
import logging
import re
import unicodedata
from dataclasses import dataclass, replace
from . import grounding, metrics as metric_names
@@ -9,12 +11,42 @@ from .budget import RequestBudget, RequestBudgetExhausted
from .metrics import Metrics, NullMetrics
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
from .ports import AnswerGenerationUnavailable, AnswerGenerator
from .prompt import build_entailment_request, build_request, build_sufficiency_request
from .prompt import (
GenerationRequest,
build_entailment_request,
build_request,
build_sufficiency_request,
)
from .routing import QueryRoutingService
# See `_verify_entailment`'s docstring for the measured trade-off behind
# widening this from 2 to 3.
_ENTAILMENT_MAX_ATTEMPTS = 3
# Repeating the same temperature-0 prompt against the same model is a
# correlated retry, not an independent vote. One fail-closed semantic pass is
# kept after structured-claim parsing and deterministic grounding.
_ENTAILMENT_MAX_ATTEMPTS = 1
_QUICK_REPLY_MAX_ITEMS = 4
_QUICK_REPLY_MAX_CHARS = 40
logger = logging.getLogger(__name__)
def _sanitize_quick_replies(value) -> tuple[str, ...]:
"""Validate LLM-proposed chips without hard-coding their content."""
if not isinstance(value, list):
return ()
replies: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
reply = " ".join(item.split())
key = reply.casefold()
if not reply or len(reply) > _QUICK_REPLY_MAX_CHARS or key in seen:
continue
seen.add(key)
replies.append(reply)
if len(replies) >= _QUICK_REPLY_MAX_ITEMS:
break
return tuple(replies)
@dataclass(frozen=True)
@@ -33,6 +65,34 @@ class Citation:
evidence_text: str = ""
@dataclass(frozen=True)
class AnswerClaim:
"""One user-visible fact and the exact retrieved chunks supporting it."""
text: str
source_ids: tuple[str, ...] = ()
@dataclass(frozen=True)
class AnswerBlock:
"""Semantic presentation unit produced by the answer service, not the UI."""
title: str
kind: str
claims: tuple[AnswerClaim, ...]
@dataclass(frozen=True)
class AnswerPlan:
"""Compact, non-reasoning presentation plan decided before generation."""
verbosity: str
layout: str
reasoning_mode: str
show_heading: bool = False
needs_warning: bool = False
@dataclass(frozen=True)
class GroundedAnswer:
result: RetrievalResult
@@ -47,18 +107,23 @@ class GroundedAnswer:
# em") — only populated when the sufficiency check judged the question
# to have a few natural discrete answers, never invented client-side.
quick_replies: tuple[str, ...] = ()
blocks: tuple[AnswerBlock, ...] = ()
answer_mode: str = "concise"
plan: AnswerPlan | None = None
@dataclass(frozen=True)
class _GenOutcome:
answer: str | None = None
clarification: str | None = None
quick_replies: tuple[str, ...] = ()
# The specific reason a rejection happened — the exact string already
# used for the GENERATION_REJECTED metric, propagated here so
# `answer_from_result` can put it in the API response's `reason` field
# instead of a generic catch-all. `None` when `answer`/`clarification`
# is set (nothing was rejected).
reject_reason: str | None = None
claims: tuple[tuple[str, tuple[int, ...]], ...] = ()
@dataclass(frozen=True)
@@ -67,13 +132,251 @@ class _RawAttempt:
lets `_generate` retry the noisy `insufficient` case without
double-counting a rejection metric across both attempts."""
answer: str | None = None
# The model's own claim/citation boundaries (2026-08-10 structured-claims
# change) — `_verify_entailment` checks these directly instead of
# re-deriving claim boundaries from `answer` via regex. `answer` is still
# populated (assembled from these by `_assemble_answer`) because it's
# the exact string `grounding.verify` and the API response need — one
# format, not two representations that could drift apart.
claims: tuple[tuple[str, tuple[int, ...]], ...] = ()
clarification: str | None = None
quick_replies: tuple[str, ...] = ()
insufficient: bool = False
outage: bool = False
budget_exhausted: bool = False
malformed: bool = False
@dataclass(frozen=True)
class _VerificationOutcome:
supported: bool
complete: bool
missing: tuple[str, ...] = ()
def _parse_claims(raw_claims: list) -> tuple[tuple[str, tuple[int, ...]], ...] | None:
"""Validate the model's `claims` array into `(text, citation_indices)`
pairs, or `None` on any malformed entry — same fail-closed contract as
every other shape check in `_attempt_generation`."""
claims: list[tuple[str, tuple[int, ...]]] = []
for item in raw_claims:
if not isinstance(item, dict):
return None
text = item.get("text")
citations = item.get("citations")
if not isinstance(text, str) or not text.strip():
return None
if not isinstance(citations, list) or not all(
isinstance(c, int) and not isinstance(c, bool) for c in citations
):
return None
claims.append((text.strip(), tuple(citations)))
return tuple(claims)
def _prompt_evidence_texts(evidence: tuple) -> tuple[str, ...]:
"""Evidence text as shown to the generator/entailment judge — labeled with
its source drug ONLY when the evidence set spans more than one drug.
Found live 2026-08-10: a drug interaction section routinely refers to the
drug it belongs to by pharmacological class rather than by name (e.g.
warfarin's own "tương tác thuốc" section says "thuốc kháng vitamin K",
never "warfarin" — normal in a monograph, since the reader already knows
which drug's chapter they're in). Read as an isolated chunk with no
section header, that self-reference is lost, and the entailment judge
was measured flip-flopping ~50/50 on a claim naming the drug directly
against evidence that never does (10 identical calls: 5 entailed, 5 not
— see the commit this function was added in for the full trace). Labeling
each block with its own `chunk_id` drug prefix (`ingestion/.../chunker.py`
always writes `{drug_id}__{section}__{n}`, so this is not a per-drug
special case) restores that anchor without asking the judge to reason
about pharmacology — it only has to match a name already handed to it.
Single-drug evidence sets are left unlabeled: nothing there was
ambiguous, and every token here is spent on every call this product
makes, so it is not added where the measured bug does not apply.
"""
drug_ids = [item.matched_doc_id.split("__", 1)[0] for item in evidence]
if len(set(drug_ids)) < 2:
return tuple(item.text for item in evidence)
return tuple(
f"(Nguồn: chuyên luận {drug_id.replace('_', ' ').upper()}) {item.text}"
for drug_id, item in zip(drug_ids, evidence, strict=True)
)
def _assemble_answer(claims: tuple[tuple[str, tuple[int, ...]], ...]) -> str:
"""The exact display string `grounding.verify` and the API response use
— built from the model's own claim/citation structure, not written by
the model as free text. Format matches what the product already shows
(`text [n]` / `text [n][m]`) so the frontend needs no changes and
`grounding.verify`'s existing `[n]`-marker parsing applies unmodified."""
parts = []
for text, citations in claims:
marker = "".join(f"[{c}]" for c in citations)
parts.append(f"{text} {marker}".rstrip() if marker else text)
return " ".join(parts)
_SECTION_PRESENTATION = {
"adr": ("Tác dụng không mong muốn", "fact_list"),
"tac_dung_khong_mong_muon": ("Tác dụng không mong muốn", "fact_list"),
"tuong_tac_thuoc": ("Tương tác thuốc", "warning"),
"tuong_tac": ("Tương tác thuốc", "warning"),
"chong_chi_dinh": ("Chống chỉ định", "warning"),
"than_trong": ("Thận trọng", "warning"),
"canh_bao": ("Cảnh báo", "warning"),
"lieu_luong_va_cach_dung": ("Liều lượng và cách dùng", "dosage"),
"lieu_dung": ("Liều lượng và cách dùng", "dosage"),
"cach_dung": ("Cách dùng", "dosage"),
"bao_quan": ("Bảo quản", "fact_list"),
"do_on_dinh_va_bao_quan": ("Bảo quản", "fact_list"),
"chi_dinh": ("Chỉ định", "fact_list"),
"duoc_luc_hoc": ("Dược lực học", "fact_list"),
"duoc_dong_hoc": ("Dược động học", "fact_list"),
"qua_lieu_va_xu_tri": ("Quá liều và xử trí", "warning"),
}
def _section_key(chunk_id: str) -> str:
parts = chunk_id.split("__")
return parts[1].casefold() if len(parts) > 1 else ""
def _presentation_for_sources(source_ids: tuple[str, ...]) -> tuple[str, str]:
section = _section_key(source_ids[0]) if source_ids else ""
if section in _SECTION_PRESENTATION:
return _SECTION_PRESENTATION[section]
if section:
return section.replace("_", " ").strip().capitalize(), "fact_list"
return "Trả lời", "fact_list"
def _build_blocks(
claims: tuple[tuple[str, tuple[int, ...]], ...],
indexed: list[tuple[str, Citation]],
) -> tuple[AnswerBlock, ...]:
"""Map verified claims to semantic blocks and stable source ids."""
groups: list[tuple[str, str, list[AnswerClaim]]] = []
for text, citation_indices in claims:
source_ids = tuple(
indexed[index - 1][1].chunk_id
for index in citation_indices
if 1 <= index <= len(indexed)
)
claim = AnswerClaim(text=text, source_ids=tuple(dict.fromkeys(source_ids)))
title, kind = _presentation_for_sources(claim.source_ids)
if groups and groups[-1][0] == title and groups[-1][1] == kind:
groups[-1][2].append(claim)
else:
groups.append((title, kind, [claim]))
return tuple(
AnswerBlock(title=title, kind=kind, claims=tuple(items))
for title, kind, items in groups
)
def _answer_mode(claim_count: int) -> str:
if claim_count <= 2:
return "concise"
if claim_count <= 6:
return "normal"
return "detailed"
_BROAD_QUERY_CUES = (
"đầy đủ", "tất cả", "toàn bộ", "tổng hợp", "so sánh", "đối chiếu",
)
_WARNING_SECTIONS = frozenset({
"chong_chi_dinh", "than_trong", "qua_lieu_va_xu_tri",
})
_DOSAGE_SECTIONS = frozenset({"lieu_luong_va_cach_dung", "lieu_dung", "cach_dung"})
_LIST_SECTIONS = frozenset({
"chi_dinh", "chong_chi_dinh", "than_trong", "tac_dung_khong_mong_muon",
"tuong_tac_thuoc", "do_on_dinh_va_bao_quan", "qua_lieu_va_xu_tri",
})
def _plan_answer(query: str, result: RetrievalResult, list_mode: bool) -> AnswerPlan:
sections = tuple(dict.fromkeys(
_section_key(item.matched_doc_id) for item in result.evidence
if _section_key(item.matched_doc_id)
))
drugs = tuple(dict.fromkeys(
item.matched_doc_id.split("__", 1)[0] for item in result.evidence
))
multi_source = len(sections) > 1 or len(drugs) > 1 or list_mode
broad = any(cue in query.casefold() for cue in _BROAD_QUERY_CUES)
verbosity = "detailed" if broad or multi_source else (
"concise" if len(result.evidence) == 1 else "normal"
)
if any(section in _DOSAGE_SECTIONS for section in sections):
layout = "dosage"
elif multi_source or any(section in _LIST_SECTIONS for section in sections):
layout = "bullet_list"
else:
layout = "prose"
return AnswerPlan(
verbosity=verbosity,
layout=layout,
reasoning_mode="synthesis" if multi_source else "direct_lookup",
show_heading=multi_source or verbosity == "detailed",
needs_warning=any(section in _WARNING_SECTIONS for section in sections),
)
def _normalise_for_coverage(text: str) -> str:
folded = unicodedata.normalize("NFKC", text).casefold()
return " ".join(re.sub(r"[^\w]+", " ", folded, flags=re.UNICODE).split())
_MISSING_META_WORDS = {
"không", "chưa", "thiếu", "nêu", "đề", "cập", "đến", "yêu", "cầu",
"ghi", "nhận", "thông", "tin", "trong", "câu", "trả", "lời", "về",
"của", "cho", "", "hoặc", "các", "một", "những",
}
def _quote_supports_missing_description(description: str, quote: str) -> bool:
"""The cited quote must actually contain the fact described as missing."""
description_tokens = {
token
for token in _normalise_for_coverage(description).split()
if len(token) > 1 and token not in _MISSING_META_WORDS
}
quote_tokens = set(_normalise_for_coverage(quote).split())
if not description_tokens:
return False
description_numbers = {token for token in description_tokens if any(c.isdigit() for c in token)}
if not description_numbers.issubset(quote_tokens):
return False
overlap = len(description_tokens & quote_tokens) / len(description_tokens)
return overlap >= 0.5
def _missing_is_already_explicit(
missing: tuple[str, ...],
claims: tuple[tuple[str, tuple[int, ...]], ...],
) -> bool:
"""Reject a judge contradiction when every quoted 'missing' fact is present.
The semantic judge occasionally reports that an exact condition is absent
while quoting that condition verbatim from a claim that already contains it.
This narrow check only resolves that self-contradiction; unquoted or
paraphrased missing facts still fail closed and enter the repair path.
"""
answer = _normalise_for_coverage("\n".join(text for text, _ in claims))
if not missing:
return False
for item in missing:
quoted = [
value for value in re.findall(r"['\"]([^'\"]{8,})['\"]", item)
if _normalise_for_coverage(value)
]
if not quoted or not all(_normalise_for_coverage(value) in answer for value in quoted):
return False
return True
class GroundedAnswerService:
"""Retrieval decides what is true; generation only decides how it reads.
@@ -131,6 +434,7 @@ class GroundedAnswerService:
def answer_from_result(
self, query: str, result: RetrievalResult, list_mode: bool = False,
budget: RequestBudget | None = None,
prechecked: bool = False,
) -> GroundedAnswer:
"""Everything after retrieval — grounding, sufficiency, generation,
citations. Split out so the new understanding-driven orchestrator
@@ -146,9 +450,15 @@ class GroundedAnswerService:
already skips it.
`budget` (F-08): threaded through to every LLM call this method
makes (sufficiency, generate, up to 2 entailment). `None` (the
makes (sufficiency, generate, one entailment verification). `None` (the
default) means unbounded, unchanged from before F-08 — only
`RagAgent` constructs a real budget today.
`prechecked=True` means the structured agent has already enforced its
required input fields. The generation contract still has its own
`evidence_sufficient`/clarifying-question gate for ambiguity visible
only after retrieval, so the separate same-model sufficiency call is
redundant on that path. The legacy direct-answer path keeps it.
"""
if result.decision == EvidenceDecision.ABSTAIN:
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
@@ -178,15 +488,13 @@ class GroundedAnswerService:
)
evidence_texts = tuple(item.text for item in result.evidence)
extractive = "\n\n".join(
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
)
prompt_evidence_texts = _prompt_evidence_texts(result.evidence)
plan = _plan_answer(query, result, list_mode)
# Reasoning step BEFORE answering: if the turn is under-specified (a dose
# with several bands and no age/weight/condition), ask instead of dumping.
# A separate focused call is more reliable than folding it into generation.
sufficiency = (
None if list_mode
None if list_mode or prechecked
else self._check_sufficiency(
query, evidence_texts, result.is_drug_overview, budget=budget
)
@@ -198,15 +506,20 @@ class GroundedAnswerService:
)
outcome = self._generate(
query, evidence_texts, intro=result.is_drug_overview, list_mode=list_mode,
budget=budget,
query, evidence_texts, prompt_evidence_texts,
intro=result.is_drug_overview, list_mode=list_mode, budget=budget,
plan=plan,
)
if outcome.clarification is not None:
# The model judged the turn under-specified (a dose with no
# age/weight/renal-function/indication…) and asked back instead of
# listing every band. Return the question, not the whole section.
return GroundedAnswer(
result, outcome.clarification, (), clarification=outcome.clarification
result,
outcome.clarification,
(),
clarification=outcome.clarification,
quick_replies=outcome.quick_replies,
)
if outcome.answer is None:
@@ -215,8 +528,19 @@ class GroundedAnswerService:
# deliberate operating mode (e.g. ANSWER_PROVIDER=disabled),
# not a failure, so the source is quoted verbatim.
self._metrics.increment(metric_names.ANSWER_EXTRACTIVE)
citations = self._cited_only(indexed, extractive) or all_citations
return GroundedAnswer(result, extractive, citations)
claims = tuple(
(text, (index,))
for index, text in enumerate(evidence_texts, start=1)
)
blocks = _build_blocks(claims, indexed)
return GroundedAnswer(
result,
"\n".join(text for text, _ in claims),
all_citations,
blocks=blocks,
answer_mode=plan.verbosity,
plan=plan,
)
# A generator WAS configured and this specific generation did not
# clear the safety checks (provider outage, malformed output, an
# ungrounded/uncited/unsupported claim). This product is a real
@@ -248,9 +572,25 @@ class GroundedAnswerService:
# Show only the sources the answer actually cited, not every chunk that
# was retrieved — a paragraph that cites [4] must not drag 13 citation
# chips onto the screen. Falls back to all when the text cites nothing.
citations = self._cited_only(indexed, outcome.answer) or all_citations
cited_indices = tuple(dict.fromkeys(
index
for _, claim_indices in outcome.claims
for index in claim_indices
if 1 <= index <= len(indexed)
))
citations = tuple(indexed[index - 1][1] for index in cited_indices)
blocks = _build_blocks(outcome.claims, indexed)
clean_answer = "\n".join(text for text, _ in outcome.claims)
self._metrics.increment(metric_names.GENERATION_SERVED)
return GroundedAnswer(result, outcome.answer, citations, generated=True)
return GroundedAnswer(
result,
clean_answer,
citations,
generated=True,
blocks=blocks,
answer_mode=plan.verbosity,
plan=plan,
)
def _attempt_generation(
self, request: "GenerationRequest", budget: RequestBudget | None
@@ -269,7 +609,7 @@ class GroundedAnswerService:
try:
payload = json.loads(raw)
answer = payload["answer"]
raw_claims = payload["claims"]
sufficient = payload["evidence_sufficient"]
except (ValueError, TypeError, KeyError):
return _RawAttempt(malformed=True)
@@ -279,23 +619,49 @@ class GroundedAnswerService:
# claim, so it skips the number check — it states no dose.
clarify = payload.get("clarifying_question") if isinstance(payload, dict) else None
if isinstance(clarify, str) and clarify.strip():
return _RawAttempt(clarification=clarify.strip())
return _RawAttempt(
clarification=clarify.strip(),
quick_replies=_sanitize_quick_replies(payload.get("quick_replies")),
)
if not isinstance(answer, str) or not isinstance(sufficient, bool):
if not isinstance(raw_claims, list) or not isinstance(sufficient, bool):
return _RawAttempt(malformed=True)
if not sufficient:
return _RawAttempt(insufficient=True)
return _RawAttempt(answer=answer)
claims = _parse_claims(raw_claims)
if claims is None:
return _RawAttempt(malformed=True)
return _RawAttempt(answer=_assemble_answer(claims), claims=claims)
def _generate(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False,
list_mode: bool = False, budget: RequestBudget | None = None,
self,
query: str,
evidence_texts: tuple[str, ...],
prompt_evidence_texts: tuple[str, ...] | None = None,
*,
intro: bool = False,
list_mode: bool = False,
budget: RequestBudget | None = None,
plan: AnswerPlan | None = None,
) -> "_GenOutcome":
"""A verified generation, a clarifying question, or empty to fall back."""
if self._generator is None or not evidence_texts:
return _GenOutcome()
request = build_request(query, evidence_texts, intro=intro, list_mode=list_mode)
shown_evidence = prompt_evidence_texts or evidence_texts
plan = plan or AnswerPlan("normal", "prose", "direct_lookup")
request = build_request(
query,
shown_evidence,
intro=intro,
list_mode=list_mode,
answer_mode=plan.verbosity,
layout=plan.layout,
reasoning_mode=plan.reasoning_mode,
show_heading=plan.show_heading,
needs_warning=plan.needs_warning,
)
attempt = self._attempt_generation(request, budget)
if attempt.insufficient:
# Empirically noisy (found live 2026-08-07, reproduced 3/3 on a
@@ -324,7 +690,10 @@ class GroundedAnswerService:
)
return _GenOutcome(reject_reason="malformed_output")
if attempt.clarification is not None:
return _GenOutcome(clarification=attempt.clarification)
return _GenOutcome(
clarification=attempt.clarification,
quick_replies=attempt.quick_replies,
)
if attempt.insufficient:
# The model says the evidence does not answer the question, on
# both attempts. Showing the retrieved section verbatim lets the
@@ -342,17 +711,62 @@ class GroundedAnswerService:
)
return _GenOutcome(reject_reason=report.reason)
if not self._verify_entailment(answer, evidence_texts, budget=budget):
verification = self._verify_entailment(
query, attempt.claims, shown_evidence, budget=budget
)
if verification is None or not verification.supported:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
)
return _GenOutcome(reject_reason="unsupported_claim")
return _GenOutcome(answer=answer)
if not verification.complete:
missing = "; ".join(verification.missing) or "dữ kiện liên quan trong nguồn"
logger.warning(
"answer completeness repair: query=%r missing=%r claims=%r",
query,
verification.missing,
attempt.claims,
)
repair_request = GenerationRequest(
system=request.system,
user=(
f"{request.user}\n\nBẢN TRƯỚC ĐÃ BỊ LOẠI VÌ THIẾU: {missing}. "
"Hãy tạo lại từ đầu, giữ đúng phạm vi câu hỏi nhưng bổ sung các "
"nhãn, điều kiện, con số và mục liên quan bị thiếu."
),
schema=request.schema,
)
repaired = self._attempt_generation(repair_request, budget)
if repaired.answer is not None:
repaired_report = grounding.verify(repaired.answer, evidence_texts)
repaired_verification = self._verify_entailment(
query, repaired.claims, shown_evidence, budget=budget
)
logger.warning(
"answer completeness repair result: claims=%r verdict=%r",
repaired.claims,
repaired_verification,
)
if (
repaired_report.grounded
and repaired_verification is not None
and repaired_verification.supported
and repaired_verification.complete
):
return _GenOutcome(answer=repaired.answer, claims=repaired.claims)
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="incomplete_answer"
)
return _GenOutcome(reject_reason="incomplete_answer")
return _GenOutcome(answer=answer, claims=attempt.claims)
def _verify_entailment(
self, answer: str, evidence_texts: tuple[str, ...],
self,
query: str,
structured_claims: tuple[tuple[str, tuple[int, ...]], ...],
evidence_texts: tuple[str, ...],
budget: RequestBudget | None = None,
) -> bool:
) -> _VerificationOutcome | None:
"""A second, adversarial LLM pass over an answer that already passed
`grounding.verify`.
@@ -364,45 +778,57 @@ class GroundedAnswerService:
is checked against only the evidence block(s) it names, by a model
told to compare wording, not to reason about medicine.
Fails closed on an outage or malformed output — that failure mode is
reliable, not noisy, so it stops immediately rather than spending
retries on it. A single rejection is NOT reliable: live probing
(2026-08-06) found the judge call itself is noisy — the identical
claim/evidence pair, called three times, came back entailed twice
and rejected once, discarding a correct, well-cited interaction
answer. Up to `_ENTAILMENT_MAX_ATTEMPTS` same-claim calls run;
accept on the first `True`, discard only if every attempt agrees
reject. Widened from 2 to 3 attempts 2026-08-07 after a live
adversarial sample (50 real questions) measured this specific check
as roughly half of all false abstentions on genuinely answerable
questions. Trade-off, stated plainly: this raises the bar a
genuinely fabricated claim must now clear too (it survives if ANY
one of 3 noisy calls wrongly accepts it, not just 1 of 2) — accepted
because the probed noise is symmetric and the entailment prompt
itself is unchanged, not because the risk is zero.
An answer with no claim text at all (nothing between or after its
citation markers) is vacuously fine — nothing to verify, no call.
Fails closed on an outage, malformed output, budget exhaustion, or a
negative verdict. The same deterministic model and temperature are
used for every judge call, so repeating the identical prompt is not
independent evidence: it adds correlated latency and can amplify a
false acceptance. One bounded adversarial pass is the safer default;
judge quality is measured with an eval set instead of manufactured
by retrying the same request.
Reads the model's OWN claim/citation boundaries (2026-08-10
structured-claims change) instead of re-deriving them from the
assembled text via regex — `structured_claims` is exactly what the
model returned, already validated by `_parse_claims`. A claim with
no in-range citation, or no real content, has nothing to check
against and is skipped, same as before.
"""
claims = [
(claim.text, "\n".join(evidence_texts[i - 1] for i in claim.indices))
for claim in grounding.split_claims(answer, len(evidence_texts))
if claim.indices and grounding.has_content(claim.text)
(
text,
"\n".join(
evidence_texts[i - 1] for i in citations
if 1 <= i <= len(evidence_texts)
),
)
for text, citations in structured_claims
if any(1 <= i <= len(evidence_texts) for i in citations)
and grounding.has_content(text)
]
if not claims:
return True
return _VerificationOutcome(supported=True, complete=True)
request = build_entailment_request(claims)
request = build_entailment_request(query, claims, evidence_texts)
for _ in range(_ENTAILMENT_MAX_ATTEMPTS):
verdict = self._run_entailment_check(request, budget=budget)
verdict = self._run_entailment_check(
request, evidence_texts=evidence_texts, budget=budget
)
if verdict is None:
return False
if verdict:
return True
return None
if (
verdict.supported
and not verdict.complete
and _missing_is_already_explicit(verdict.missing, structured_claims)
):
return _VerificationOutcome(supported=True, complete=True)
return verdict
return False
def _run_entailment_check(
self, request, budget: RequestBudget | None = None
) -> bool | None:
self,
request,
evidence_texts: tuple[str, ...],
budget: RequestBudget | None = None,
) -> _VerificationOutcome | None:
"""One entailment call. `None` = outage/malformed/budget-exhausted
(fails closed by the caller without a retry); `True`/`False` = the
judge's verdict."""
@@ -416,11 +842,49 @@ class GroundedAnswerService:
payload = json.loads(raw)
entailed = payload["entailed"]
unsupported = payload["unsupported"]
missing_evidence = payload.get("missing_evidence", [])
except (ValueError, TypeError, KeyError):
return None
if not isinstance(entailed, bool) or not isinstance(unsupported, list):
if (
not isinstance(entailed, bool)
or not isinstance(unsupported, list)
or not isinstance(missing_evidence, list)
):
return None
return entailed and not unsupported
if not entailed or unsupported:
return _VerificationOutcome(supported=False, complete=False)
# A completeness objection is itself a factual claim about the raw
# evidence. Require the judge to point to an exact source quote and
# validate it locally. This prevents false objections such as
# "missing humidity" when the storage section never mentions humidity,
# which previously discarded a fully grounded answer after two extra
# model calls. Entailment still fails closed; only ungrounded
# *completeness objections* are ignored.
normalised_evidence = tuple(
_normalise_for_coverage(text) for text in evidence_texts
)
grounded_missing: list[str] = []
for item in missing_evidence:
if not isinstance(item, dict):
return None
description = item.get("description")
evidence_quote = item.get("evidence_quote")
if not isinstance(description, str) or not isinstance(evidence_quote, str):
return None
description = description.strip()
quote = _normalise_for_coverage(evidence_quote)
if (
description
and len(quote) >= 4
and any(quote in evidence for evidence in normalised_evidence)
and _quote_supports_missing_description(description, evidence_quote)
):
grounded_missing.append(description)
return _VerificationOutcome(
supported=True,
complete=not grounded_missing,
missing=tuple(grounded_missing),
)
def _check_sufficiency(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False,
@@ -458,12 +922,7 @@ class GroundedAnswerService:
if isinstance(payload, dict) and payload.get("sufficient") is False:
question = payload.get("clarifying_question")
if isinstance(question, str) and question.strip():
raw_replies = payload.get("quick_replies")
replies = tuple(
reply.strip()
for reply in raw_replies
if isinstance(reply, str) and reply.strip()
) if isinstance(raw_replies, list) else ()
replies = _sanitize_quick_replies(payload.get("quick_replies"))
return question.strip(), replies
return None