147 lines
5.2 KiB
Python
147 lines
5.2 KiB
Python
"""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.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) -> None:
|
|
self._payload = payload
|
|
self._entailment_payload = entailment_payload or {
|
|
"entailed": True,
|
|
"unsupported": [],
|
|
}
|
|
|
|
def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002
|
|
# `_generate` also runs a post-generation entailment check; tell the
|
|
# two request shapes apart by schema so callers here only need to
|
|
# fake the main answer, not both.
|
|
payload = (
|
|
self._entailment_payload
|
|
if "entailed" in schema.get("properties", {})
|
|
else self._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
|
|
|
|
|
|
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
|