Files
duocthu/apps/ai-service/tests/test_citation_and_intro.py
T

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