"""Two answer-UX fixes, pinned: - citations shown = only the sources the answer cited, not every retrieved chunk; - a bare drug name is introduced, not restated section-by-section. """ from __future__ import annotations import json from rag.answer import GroundedAnswerService from rag.models import ( Evidence, EvidenceDecision, QueryIntent, RetrievalResult, SourceRef, SubjectScope, ) from rag.ports import AnswerGenerationUnavailable from rag.prompt import build_request def _evidence(i: int, page: int) -> Evidence: return Evidence( evidence_id=f"drug::sec::{i}", matched_doc_id=f"drug::sec::{i}", kind="prose", text=f"đoạn bằng chứng {i}", score=1.0, source_refs=(SourceRef(physical_page=page, precision="exact", printed_page=page),), hydrated_from_parent=False, requires_visual_check=False, ) class _Routing: def __init__(self, result: RetrievalResult) -> None: self._result = result def retrieve(self, query, subject_scope, intent): # noqa: ARG002 return self._result class _Generator: def __init__( self, payload: dict, entailment_payload: dict | None = None, sufficiency_payload: dict | None = None, ) -> None: self._payload = payload self._entailment_payload = entailment_payload or { "entailed": True, "unsupported": [], } self._sufficiency_payload = sufficiency_payload def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002 # `_generate` also runs a post-generation entailment check, and # `_check_sufficiency` runs its own separate call before that — tell # the three request shapes apart by schema so a test asserting on one # doesn't have to also shape a payload for the others. props = schema.get("properties", {}) if "entailed" in props: payload = self._entailment_payload elif "sufficient" in props and self._sufficiency_payload is not None: payload = self._sufficiency_payload else: payload = self._payload if isinstance(payload, BaseException): raise payload return json.dumps(payload, ensure_ascii=False) def _answerable(*evidence: Evidence, is_overview: bool = False) -> RetrievalResult: return RetrievalResult( EvidenceDecision.ANSWERABLE, "grounded_evidence_available", tuple(evidence), resolved_drug_id="drug", is_drug_overview=is_overview, ) def test_only_cited_sources_are_returned(): result = _answerable(_evidence(0, 100), _evidence(1, 200), _evidence(2, 300)) service = GroundedAnswerService( _Routing(result), _Generator({"answer": "Chỉ dùng đoạn hai [2].", "evidence_sufficient": True}), ) grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP) assert grounded.generated is True assert len(grounded.citations) == 1 assert grounded.citations[0].printed_page_start == 200 def test_answer_citing_nothing_is_rejected_not_dressed_up_with_borrowed_citations(): result = _answerable(_evidence(0, 100), _evidence(1, 200)) service = GroundedAnswerService( _Routing(result), # no [n] marker at all: grounding.verify rejects this outright (an # uncited claim, per F-01). A generator is configured, so the # rejection abstains — it must not attach every retrieved citation # to dress an uncited generation up as sourced (the old behavior), # and it must not silently degrade to a raw extractive quote either # (owner correction, 2026-08-06: no fallback to the retired # offline-extractive shape when a real generator is configured). _Generator({"answer": "Không có trích dẫn.", "evidence_sufficient": True}), ) grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP) assert grounded.generated is False assert grounded.answer is None assert grounded.citations == () assert grounded.result.decision == EvidenceDecision.ABSTAIN def test_underspecified_dose_asks_instead_of_dumping(): """The reasoning step: a dose question spanning bands with no age/weight is turned into a clarification, not the whole section.""" result = _answerable(_evidence(0, 100), _evidence(1, 200)) gen = _Generator( {"sufficient": False, "clarifying_question": "Bé mấy tuổi, cân nặng bao nhiêu kg?"} ) service = GroundedAnswerService(_Routing(result), gen) g = service.answer("paracetamol cho trẻ em", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP) assert g.clarification is not None assert "tuổi" in g.clarification assert g.answer == g.clarification assert g.generated is False assert g.quick_replies == () def test_underspecified_dose_carries_quick_replies_when_the_model_offers_them(): result = _answerable(_evidence(0, 100), _evidence(1, 200)) gen = _Generator( {"sufficient": False, "clarifying_question": "Người lớn hay trẻ em?", "quick_replies": ["Người lớn", "Trẻ em"]} ) service = GroundedAnswerService(_Routing(result), gen) g = service.answer("paracetamol", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP) assert g.quick_replies == ("Người lớn", "Trẻ em") def test_sufficiency_check_outage_fails_open_to_generation_not_abstain(): """F-10: `_check_sufficiency` documents (and this pins) a deliberate fail-OPEN on provider outage — unlike every other failure mode in this service, a sufficiency-check outage does not abstain, it just skips the clarify heuristic and lets grounding/entailment (tested elsewhere) be the real safety net on whatever gets generated next.""" result = _answerable(_evidence(0, 100), _evidence(1, 200)) gen = _Generator( {"answer": "Đoạn bằng chứng 0 [1].", "evidence_sufficient": True, "clarifying_question": None}, sufficiency_payload=AnswerGenerationUnavailable("Bedrock unreachable"), ) service = GroundedAnswerService(_Routing(result), gen) g = service.answer("liều người lớn", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP) assert g.clarification is None assert g.result.decision == EvidenceDecision.ANSWERABLE assert g.generated is True def test_sufficient_query_is_not_turned_into_a_clarification(): result = _answerable(_evidence(0, 100), _evidence(1, 200)) gen = _Generator({"sufficient": True, "clarifying_question": None}) service = GroundedAnswerService(_Routing(result), gen) g = service.answer("liều người lớn", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP) # sufficiency passes; generation then runs (its payload lacks answer keys, so # it falls back to the source text) — the point is no clarification fired. assert g.clarification is None def test_bare_name_builds_an_intro_prompt(): intro = build_request("PARACETAMOL", ("đoạn A", "đoạn B"), intro=True) assert "GIỚI THIỆU" in intro.user assert "CÂU HỎI:" not in intro.user normal = build_request("Liều?", ("đoạn A",), intro=False) assert "CÂU HỎI:" in normal.user assert "GIỚI THIỆU" not in normal.user def test_list_mode_prompt_instructs_enumerating_every_drug_not_ranking(): """2026-08-07 finding: without an explicit instruction, the model picked one drug out of 8 real symptom_to_drug matches and silently dropped the rest — verified live. `list_mode` closes that.""" request = build_request("thuốc gì trị sốt", ("chỉ định A", "chỉ định B"), list_mode=True) assert "LIỆT KÊ TẤT CẢ" in request.user assert "không xếp hạng" in request.user or "KHÔNG" in request.user assert "CÂU HỎI:" in request.user def test_list_mode_skips_the_sufficiency_clarify(): """If the sufficiency call were NOT actually skipped, it would read `sufficiency_payload` (`sufficient=False`) and clarify. `list_mode=True` must never call it at all, so only the real answer payload is ever read.""" result = _answerable(_evidence(0, 100), _evidence(1, 200)) gen = _Generator( {"answer": "Đoạn bằng chứng 0 [1]. Đoạn bằng chứng 1 [2].", "evidence_sufficient": True, "clarifying_question": None}, sufficiency_payload={ "sufficient": False, "clarifying_question": "Loại nào?", "quick_replies": [], }, ) service = GroundedAnswerService(_Routing(result), gen) g = service.answer_from_result("thuốc gì trị sốt", result, list_mode=True) assert g.clarification is None assert g.answer is not None assert g.generated is True def test_without_list_mode_the_same_evidence_does_ask_for_clarification(): """Control for the test above: the same sufficiency payload, without `list_mode`, must actually clarify — proving the previous test's "not skipped" branch is reachable and would have failed loudly.""" result = _answerable(_evidence(0, 100), _evidence(1, 200)) gen = _Generator( {"answer": "unused", "evidence_sufficient": True, "clarifying_question": None}, sufficiency_payload={ "sufficient": False, "clarifying_question": "Loại nào?", "quick_replies": [], }, ) service = GroundedAnswerService(_Routing(result), gen) g = service.answer_from_result("thuốc gì trị sốt", result, list_mode=False) assert g.clarification == "Loại nào?"