from __future__ import annotations import json import re from dataclasses import dataclass, replace from . import grounding, metrics as metric_names 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 .routing import QueryRoutingService @dataclass(frozen=True) class Citation: chunk_id: str printed_page_start: int printed_page_end: int physical_page: int block_id: str | None = None bbox: tuple[float, float, float, float] | None = None source_crop: str | None = None attachment: str | None = None @dataclass(frozen=True) class GroundedAnswer: result: RetrievalResult answer: str | None citations: tuple[Citation, ...] = () generated: bool = False # Set when the model decided the turn is under-specified and asked back # (e.g. a dose question with no age/weight). The answer field carries the # question; the caller renders it as a clarification, not a final answer. clarification: str | None = None @dataclass(frozen=True) class _GenOutcome: answer: str | None = None clarification: str | None = None class GroundedAnswerService: """Retrieval decides what is true; generation only decides how it reads. Two operating modes, not to be confused with each other: - **No generator configured** (`generator=None`, e.g. `ANSWER_PROVIDER= disabled`) is retrieval-only mode, a deliberate and fully supported way to run this service. It quotes the retrieved source verbatim. - **A generator IS configured.** Its output replaces the extractive text only if it clears two independent checks: `grounding.verify` (every figure and citation traces to the specific evidence block it cites, and every claim carries one) and `_verify_entailment` (a second LLM pass confirming each cited claim's *content* — not just its numbers — is actually stated by that block). If a configured generation fails any check, or the provider itself is unreachable, or its output is malformed, the turn **abstains** (`reason="generation_unavailable"` or the specific `grounding.verify` reason) rather than silently degrading to a raw source dump — this product is a real LLM chatbot, and a citation-stapled paragraph of book text is not an acceptable stand-in for an answer the model was supposed to produce. """ def __init__( self, routing: QueryRoutingService, generator: AnswerGenerator | None = None, metrics: Metrics | None = None, ) -> None: self._routing = routing self._generator = generator self._metrics = metrics or NullMetrics() def answer( self, query: str, subject_scope: SubjectScope, intent: QueryIntent, drug_id: str | None = None, ) -> GroundedAnswer: # When the caller already resolved the drug (e.g. the conversational # layer, incl. an inherited follow-up), retrieve for it directly instead # of re-resolving from the turn text — re-resolution from a rewritten # turn is what abstained good follow-ups as "ambiguous". if drug_id is not None: result = self._routing.retrieve_for_drug( query, drug_id, subject_scope, intent ) else: result = self._routing.retrieve(query, subject_scope, intent) return self.answer_from_result(query, result) def answer_from_result( self, query: str, result: RetrievalResult ) -> GroundedAnswer: """Everything after retrieval — grounding, sufficiency, generation, citations. Split out so the new understanding-driven orchestrator (`rag/agent.py`) reuses the safe answer path without going through the old `QueryRoutingService` text resolution.""" if result.decision == EvidenceDecision.ABSTAIN: self._metrics.increment(metric_names.ABSTENTION, reason=result.reason) return GroundedAnswer(result, None) indexed = self._indexed_citations(result) if indexed is None: return GroundedAnswer( replace( result, decision=EvidenceDecision.ABSTAIN, reason="missing_printed_page_provenance", evidence=(), ), None, ) all_citations = tuple(citation for _, citation in indexed) if result.decision == EvidenceDecision.VERIFY_PDF: # Never generated over. A quarantined table or formula is exactly # the evidence whose numbers were not reliably reconstructed, so # rephrasing it is the one case where fluency could invent a dose. return GroundedAnswer( result, "Nguồn có bảng hoặc công thức cần đối chiếu trực tiếp với ảnh PDF; " "không tự động trích số liệu.", all_citations, ) 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) ) # 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. clarify_q = self._check_sufficiency(query, evidence_texts, result.is_drug_overview) if clarify_q is not None: return GroundedAnswer(result, clarify_q, (), clarification=clarify_q) outcome = self._generate(query, evidence_texts, intro=result.is_drug_overview) 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 ) if outcome.answer is None: if self._generator is None: # No generator configured at all — retrieval-only mode. A # 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) # 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 # LLM chatbot, not the retired offline-extractive build — a raw # source dump is not an acceptable stand-in for a failed # generation, so this abstains instead of silently degrading to # one. self._metrics.increment( metric_names.ABSTENTION, reason="generation_unavailable" ) return GroundedAnswer( replace( result, decision=EvidenceDecision.ABSTAIN, reason="generation_unavailable", ), None, ) # 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 self._metrics.increment(metric_names.GENERATION_SERVED) return GroundedAnswer(result, outcome.answer, citations, generated=True) def _generate( self, query: str, evidence_texts: tuple[str, ...], intro: bool = False ) -> "_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) try: raw = self._generator.generate(request.system, request.user, request.schema) except AnswerGenerationUnavailable: self._metrics.increment( metric_names.GENERATION_REJECTED, reason="provider_unavailable" ) return _GenOutcome() try: payload = json.loads(raw) answer = payload["answer"] sufficient = payload["evidence_sufficient"] except (ValueError, TypeError, KeyError): self._metrics.increment( metric_names.GENERATION_REJECTED, reason="malformed_output" ) return _GenOutcome() # The model asked for a missing detail (age/weight/renal function/ # indication…) instead of listing every band. A clarify is not a grounded # 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 _GenOutcome(clarification=clarify.strip()) if not isinstance(answer, str) or not isinstance(sufficient, bool): self._metrics.increment( metric_names.GENERATION_REJECTED, reason="malformed_output" ) return _GenOutcome() if not sufficient: # The model says the evidence does not answer the question. Showing # the retrieved section verbatim lets the clinician judge that. self._metrics.increment( metric_names.GENERATION_REJECTED, reason="evidence_insufficient" ) return _GenOutcome() report = grounding.verify(answer, evidence_texts) if not report.grounded: self._metrics.increment( metric_names.GENERATION_REJECTED, reason=report.reason ) return _GenOutcome() if not self._verify_entailment(answer, evidence_texts): self._metrics.increment( metric_names.GENERATION_REJECTED, reason="unsupported_claim" ) return _GenOutcome() return _GenOutcome(answer=answer) def _verify_entailment(self, answer: str, evidence_texts: tuple[str, ...]) -> bool: """A second, adversarial LLM pass over an answer that already passed `grounding.verify`. The regex check above only binds numbers and citation indices — it has no notion of meaning, so "Metformin chữa ung thư [1]" citing an evidence block about "điều trị đái tháo đường" sails through it untouched: right drug, right citation shape, fabricated indication. This call is what catches that: each substantive, validly-cited claim 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. A single rejection is NOT: 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. So a reject triggers one same-claim retry, and only a second, agreeing reject discards the generation; a single provider outage/malformed response still fails closed immediately (that failure mode is reliable, not noisy — no retry needed there). An answer with no claim text at all (nothing between or after its citation markers) is vacuously fine — nothing to verify, no call. """ 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) ] if not claims: return True request = build_entailment_request(claims) first = self._run_entailment_check(request) if first is None: return False if first: return True second = self._run_entailment_check(request) return bool(second) def _run_entailment_check(self, request) -> bool | None: """One entailment call. `None` = outage/malformed (fails closed by the caller without a retry); `True`/`False` = the judge's verdict.""" try: raw = self._generator.generate(request.system, request.user, request.schema) except AnswerGenerationUnavailable: return None try: payload = json.loads(raw) entailed = payload["entailed"] unsupported = payload["unsupported"] except (ValueError, TypeError, KeyError): return None if not isinstance(entailed, bool) or not isinstance(unsupported, list): return None return entailed and not unsupported def _check_sufficiency( self, query: str, evidence_texts: tuple[str, ...], intro: bool = False ) -> str | None: """A focused reasoning call: is the turn specific enough to answer, or must we ask? Returns a clarifying question, or None to proceed. Skipped without a model, for a bare-name intro (not a dose), or for a single evidence block (nothing to disambiguate).""" if self._generator is None or intro or len(evidence_texts) < 2: return None request = build_sufficiency_request(query, evidence_texts) try: raw = self._generator.generate(request.system, request.user, request.schema) except AnswerGenerationUnavailable: return None try: payload = json.loads(raw) except (ValueError, TypeError): return None if isinstance(payload, dict) and payload.get("sufficient") is False: question = payload.get("clarifying_question") if isinstance(question, str) and question.strip(): return question.strip() return None @staticmethod def _cited_only( indexed: list[tuple[int, Citation]], answer_text: str ) -> tuple[Citation, ...]: """Keep citations whose 1-based evidence marker [n] appears in the text.""" used = {int(m) for m in re.findall(r"\[(\d+)\]", answer_text)} return tuple(citation for index, citation in indexed if index in used) @staticmethod def _indexed_citations( result: RetrievalResult, ) -> list[tuple[int, Citation]] | None: """Citations tagged with the 1-based evidence index the prompt gives them, so the response can show only the ones the answer cited.""" citations: list[tuple[int, Citation]] = [] for index, evidence in enumerate(result.evidence, start=1): if not evidence.source_refs: return None for source in evidence.source_refs: printed_range = source.printed_page_range if printed_range is not None: start, end = printed_range elif source.printed_page is not None: start = end = source.printed_page else: return None citations.append((index, Citation( chunk_id=evidence.matched_doc_id, printed_page_start=int(start), printed_page_end=int(end), physical_page=source.physical_page, block_id=source.block_id, bbox=source.bbox, source_crop=source.source_crop, # Backward-compatible compact attachment identifier. A # real crop path wins; otherwise the block id plus the # structured page/bbox fields is enough to render later. attachment=source.source_crop or source.block_id, ))) return citations