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_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 class GroundedAnswerService: """Retrieval decides what is true; generation only decides how it reads. When a generator is configured, its output replaces the extractive text **only** if `grounding.verify` confirms every figure and citation in it traces back to the retrieved evidence. Anything else — an unsupported number, a citation to nothing, a provider outage, malformed output — falls back to quoting the source verbatim, which is always available because it was computed first. """ 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) 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) ) generated = self._generate(query, evidence_texts, intro=result.is_drug_overview) answer_text = extractive if generated is None else generated # 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, answer_text) or all_citations if generated is None: self._metrics.increment(metric_names.ANSWER_EXTRACTIVE) return GroundedAnswer(result, extractive, citations) self._metrics.increment(metric_names.GENERATION_SERVED) return GroundedAnswer(result, generated, citations, generated=True) def _generate( self, query: str, evidence_texts: tuple[str, ...], intro: bool = False ) -> str | None: """A verified generation, or None to fall back to the source text.""" if self._generator is None or not evidence_texts: return None 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 None 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 None if not isinstance(answer, str) or not isinstance(sufficient, bool): self._metrics.increment( metric_names.GENERATION_REJECTED, reason="malformed_output" ) return None 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 None report = grounding.verify(answer, evidence_texts) if not report.grounded: self._metrics.increment( metric_names.GENERATION_REJECTED, reason=report.reason ) return None return answer @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