Remove corpus counts from chat chrome
This commit is contained in:
+525
-66
@@ -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", "và", "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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user