Fix ai-service Dockerfile: bake in drug_entities.json, override its path

This commit is contained in:
2026-08-10 10:35:13 +07:00
parent a4b8e1c4db
commit 60b4397032
51 changed files with 4302 additions and 2087 deletions
@@ -16,6 +16,7 @@ from rag.models import (
SourceRef,
SubjectScope,
)
from rag.ports import AnswerGenerationUnavailable
from rag.prompt import build_request
@@ -41,22 +42,31 @@ class _Routing:
class _Generator:
def __init__(self, payload: dict, entailment_payload: dict | None = None) -> None:
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; 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
)
# `_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)
@@ -122,6 +132,42 @@ def test_underspecified_dose_asks_instead_of_dumping():
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():
@@ -144,3 +190,52 @@ def test_bare_name_builds_an_intro_prompt():
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?"