Fix live multi-turn: pass the resolved drug, stop did-you-mean garbage
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""The Converse adapter must translate provider failure into the domain error,
|
||||
isolate the JSON envelope the API cannot enforce, and never leak an SDK type.
|
||||
|
||||
No network: a stub client stands in for `bedrock-runtime`. The grounding check
|
||||
that actually guards correctness lives in `rag.grounding` and is tested there;
|
||||
here we prove only the adapter's envelope and failure contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from adapters.bedrock_converse import (
|
||||
BedrockConverseAnswerGenerator,
|
||||
BedrockCohereReranker,
|
||||
RerankUnavailable,
|
||||
_extract_json,
|
||||
)
|
||||
from rag.ports import AnswerGenerationUnavailable
|
||||
|
||||
|
||||
def _converse_reply(text: str, stop_reason: str = "end_turn") -> dict:
|
||||
return {
|
||||
"stopReason": stop_reason,
|
||||
"output": {"message": {"content": [{"text": text}]}},
|
||||
"usage": {"inputTokens": 10, "outputTokens": 5},
|
||||
}
|
||||
|
||||
|
||||
class _Client:
|
||||
def __init__(self, reply: dict) -> None:
|
||||
self._reply = reply
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def converse(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return self._reply
|
||||
|
||||
|
||||
def _payload(answer: str = "Liều 500 mg [1]", sufficient: bool = True) -> str:
|
||||
return json.dumps({"answer": answer, "evidence_sufficient": sufficient}, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_returns_the_models_json_on_success():
|
||||
client = _Client(_converse_reply(_payload()))
|
||||
gen = BedrockConverseAnswerGenerator(client=client, model_id="deepseek.v3.2")
|
||||
|
||||
raw = gen.generate("system", "user", {"type": "object"})
|
||||
|
||||
parsed = json.loads(raw)
|
||||
assert parsed["answer"] == "Liều 500 mg [1]"
|
||||
assert parsed["evidence_sufficient"] is True
|
||||
# The prompt carries the schema and a JSON-only directive to the model.
|
||||
sent = client.calls[0]["messages"][0]["content"][0]["text"]
|
||||
assert "JSON" in sent
|
||||
|
||||
|
||||
def test_strips_a_markdown_fence_the_api_cannot_forbid():
|
||||
fenced = f"```json\n{_payload()}\n```"
|
||||
gen = BedrockConverseAnswerGenerator(client=_Client(_converse_reply(fenced)))
|
||||
|
||||
parsed = json.loads(gen.generate("s", "u", {}))
|
||||
|
||||
assert parsed["answer"] == "Liều 500 mg [1]"
|
||||
|
||||
|
||||
def test_extracts_json_when_the_model_adds_prose_around_it():
|
||||
noisy = f"Đây là câu trả lời: {_payload()} Hết."
|
||||
assert json.loads(_extract_json(noisy))["evidence_sufficient"] is True
|
||||
|
||||
|
||||
def test_a_filtered_stop_reason_is_an_outage_not_an_answer():
|
||||
client = _Client(_converse_reply("", stop_reason="content_filtered"))
|
||||
gen = BedrockConverseAnswerGenerator(client=client)
|
||||
|
||||
with pytest.raises(AnswerGenerationUnavailable):
|
||||
gen.generate("s", "u", {})
|
||||
|
||||
|
||||
def test_empty_text_raises_rather_than_returning_blank():
|
||||
gen = BedrockConverseAnswerGenerator(client=_Client(_converse_reply(" ")))
|
||||
|
||||
with pytest.raises(AnswerGenerationUnavailable):
|
||||
gen.generate("s", "u", {})
|
||||
|
||||
|
||||
def test_real_botocore_client_error_becomes_the_domain_error():
|
||||
botocore_exceptions = pytest.importorskip("botocore.exceptions")
|
||||
|
||||
class _Refusing:
|
||||
def converse(self, **kwargs):
|
||||
raise botocore_exceptions.ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
"Converse",
|
||||
)
|
||||
|
||||
gen = BedrockConverseAnswerGenerator(client=_Refusing())
|
||||
|
||||
with pytest.raises(AnswerGenerationUnavailable):
|
||||
gen.generate("s", "u", {})
|
||||
|
||||
|
||||
# --- reranker -----------------------------------------------------------------
|
||||
|
||||
|
||||
class _RerankBody:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self._data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
|
||||
class _RerankClient:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def invoke_model(self, **kwargs):
|
||||
return {"body": _RerankBody(self._payload)}
|
||||
|
||||
|
||||
def test_rerank_returns_indices_most_relevant_first():
|
||||
client = _RerankClient({"results": [{"index": 2}, {"index": 0}, {"index": 1}]})
|
||||
reranker = BedrockCohereReranker(client=client)
|
||||
|
||||
order = reranker.rerank("chống chỉ định", ["a", "b", "c"])
|
||||
|
||||
assert order == [2, 0, 1]
|
||||
|
||||
|
||||
def test_rerank_raises_so_the_caller_keeps_original_order():
|
||||
botocore_exceptions = pytest.importorskip("botocore.exceptions")
|
||||
|
||||
class _Refusing:
|
||||
def invoke_model(self, **kwargs):
|
||||
raise botocore_exceptions.ClientError(
|
||||
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
|
||||
"InvokeModel",
|
||||
)
|
||||
|
||||
with pytest.raises(RerankUnavailable):
|
||||
BedrockCohereReranker(client=_Refusing()).rerank("q", ["a", "b"])
|
||||
|
||||
|
||||
def test_rerank_of_nothing_is_empty():
|
||||
assert BedrockCohereReranker(client=_RerankClient({})).rerank("q", []) == []
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002
|
||||
return json.dumps(self._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_falls_back_to_all_citations():
|
||||
result = _answerable(_evidence(0, 100), _evidence(1, 200))
|
||||
service = GroundedAnswerService(
|
||||
_Routing(result),
|
||||
# no [n] marker at all: rather than show zero provenance, show all.
|
||||
_Generator({"answer": "Không có trích dẫn.", "evidence_sufficient": True}),
|
||||
)
|
||||
|
||||
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
|
||||
assert len(grounded.citations) == 2
|
||||
|
||||
|
||||
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
|
||||
@@ -31,8 +31,8 @@ class FakeAnswers:
|
||||
self._e = evidence_text
|
||||
self.calls = []
|
||||
|
||||
def answer(self, query, subject_scope, intent):
|
||||
self.calls.append(query)
|
||||
def answer(self, query, subject_scope, intent, drug_id=None):
|
||||
self.calls.append((query, drug_id))
|
||||
return _grounded(self._a, self._e)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def test_medical_turn_returns_grounded_answer():
|
||||
assert out.grounded is not None
|
||||
|
||||
|
||||
def test_followup_inherits_drug_and_names_it_and_rewrites_query():
|
||||
def test_followup_inherits_drug_and_passes_it_resolved():
|
||||
answers = FakeAnswers(
|
||||
"Ở trẻ em điều chỉnh theo cân nặng.",
|
||||
"Ở trẻ em, liều metformin điều chỉnh theo cân nặng.",
|
||||
@@ -76,12 +76,40 @@ def test_followup_inherits_drug_and_names_it_and_rewrites_query():
|
||||
out = svc.answer("c3", "còn trẻ em thì sao?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
assert out.inherited_drug == "metformin"
|
||||
assert out.answer.startswith("Về metformin:")
|
||||
# The follow-up was rewritten self-contained before hitting the engine.
|
||||
assert "metformin" in answers.calls[-1]
|
||||
# The inherited drug is passed already-resolved (not re-resolved from the
|
||||
# rewritten turn text), and the raw follow-up drives section routing.
|
||||
last_query, last_drug_id = answers.calls[-1]
|
||||
assert last_drug_id == "metformin"
|
||||
assert last_query == "còn trẻ em thì sao?"
|
||||
# State carried the drug forward.
|
||||
assert svc._store.load("c3").focus.drug_id == "metformin"
|
||||
|
||||
|
||||
def test_confirmation_is_not_fuzzy_matched_to_a_drug():
|
||||
"""'đúng' must not be fuzzy-matched to terbinafin/tretinoin (the did-you-mean
|
||||
loop the reviewer hit); it asks which drug instead."""
|
||||
answers = FakeAnswers("x", "x")
|
||||
svc = _service(answers)
|
||||
out = svc.answer("cc", "đúng", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
assert out.clarification is not None
|
||||
assert out.clarification.reason == "confirm_without_context"
|
||||
assert answers.calls == []
|
||||
|
||||
|
||||
def test_a_long_sentence_that_names_no_drug_is_not_offered_did_you_mean():
|
||||
"""A full question ('EPO điều trị thiếu máu...') that resolves no drug is
|
||||
answered honestly, not with garbage suggestions from fuzzing the sentence."""
|
||||
answers = FakeAnswers("x", "x")
|
||||
svc = _service(answers) # catalog holds only metformin
|
||||
out = svc.answer(
|
||||
"cl", "EPO điều trị thiếu máu do hóa trị ung thư liều khởi đầu bao nhiêu",
|
||||
SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP,
|
||||
)
|
||||
assert out.clarification is not None
|
||||
assert out.clarification.reason == "drug_not_supported"
|
||||
assert answers.calls == []
|
||||
|
||||
|
||||
def test_no_close_drug_reports_not_supported():
|
||||
answers = FakeAnswers("x", "x")
|
||||
svc = _service(answers) # catalog holds only metformin
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""A free-form question about a resolved drug must not dump the whole monograph
|
||||
at the model. When a reranker is configured, the overview is reordered by
|
||||
relevance and trimmed to top-k; a bare drug name still returns everything.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from rag.models import EvidenceDecision, RetrievalDocument, SearchHit, SourceRef
|
||||
from rag.ports import RerankUnavailable
|
||||
from rag.service import EvidencePolicy, RetrievalService
|
||||
|
||||
|
||||
def _hit(i: int) -> SearchHit:
|
||||
doc = RetrievalDocument(
|
||||
doc_id=f"d{i}",
|
||||
drug_id="paracetamol",
|
||||
kind="prose",
|
||||
text=f"section {i} text",
|
||||
section_key=f"sec_{i}",
|
||||
source_refs=(
|
||||
SourceRef(physical_page=100 + i, precision="exact", printed_page=i),
|
||||
),
|
||||
)
|
||||
return SearchHit(document=doc, score=1.0)
|
||||
|
||||
|
||||
class _Retriever:
|
||||
def __init__(self, n: int) -> None:
|
||||
self._hits = [_hit(i) for i in range(n)]
|
||||
|
||||
def find_by_drug(self, drug_id: str) -> list[SearchHit]:
|
||||
return list(self._hits)
|
||||
|
||||
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]: # noqa: ARG002
|
||||
return []
|
||||
|
||||
|
||||
class _ParentStore:
|
||||
def get(self, parent_id: str): # noqa: ARG002
|
||||
return None
|
||||
|
||||
|
||||
class _Reranker:
|
||||
def __init__(self, order: list[int], fail: bool = False) -> None:
|
||||
self._order = order
|
||||
self._fail = fail
|
||||
self.calls: list[str] = []
|
||||
|
||||
def rerank(self, query: str, documents: list[str], top_n=None): # noqa: ARG002
|
||||
self.calls.append(query)
|
||||
if self._fail:
|
||||
raise RerankUnavailable("provider down")
|
||||
return self._order
|
||||
|
||||
|
||||
def _service(reranker=None, top_k=3) -> RetrievalService:
|
||||
return RetrievalService(
|
||||
_Retriever(8),
|
||||
_ParentStore(),
|
||||
EvidencePolicy(rerank_top_k=top_k),
|
||||
section_resolver=None,
|
||||
reranker=reranker,
|
||||
)
|
||||
|
||||
|
||||
def test_a_question_reranks_the_overview_and_keeps_top_k():
|
||||
reranker = _Reranker(order=[7, 6, 5, 4, 3, 2, 1, 0])
|
||||
result = _service(reranker, top_k=3).retrieve(
|
||||
"sốt cao uống được không", "paracetamol"
|
||||
)
|
||||
|
||||
assert result.decision == EvidenceDecision.ANSWERABLE
|
||||
assert reranker.calls, "reranker should run on a free-form question"
|
||||
assert len(result.evidence) == 3
|
||||
assert [e.matched_doc_id for e in result.evidence] == ["d7", "d6", "d5"]
|
||||
|
||||
|
||||
def test_a_bare_drug_name_returns_the_whole_monograph_unreranked():
|
||||
reranker = _Reranker(order=[0])
|
||||
result = _service(reranker).retrieve("PARACETAMOL", "paracetamol")
|
||||
|
||||
assert reranker.calls == [], "a bare name must not be reranked/trimmed"
|
||||
assert len(result.evidence) == 8
|
||||
|
||||
|
||||
def test_rerank_outage_keeps_the_original_order():
|
||||
reranker = _Reranker(order=[], fail=True)
|
||||
result = _service(reranker).retrieve("sốt cao uống được không", "paracetamol")
|
||||
|
||||
# Fail-open: the answer survives, in book order, when rerank is unreachable.
|
||||
assert result.decision == EvidenceDecision.ANSWERABLE
|
||||
assert len(result.evidence) == 8
|
||||
assert [e.matched_doc_id for e in result.evidence][:2] == ["d0", "d1"]
|
||||
Reference in New Issue
Block a user