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
+242 -1
View File
@@ -10,7 +10,13 @@ from __future__ import annotations
import json
from rag.understanding import SECTION_KEY_HINTS, SECTION_KEYS, LlmQueryUnderstander
from rag.ports import AnswerGenerationUnavailable
from rag.understanding import (
SECTION_KEY_HINTS,
SECTION_KEYS,
LlmQueryUnderstander,
QueryFrame,
)
CATALOG = {
"paracetamol_acetaminophen": "paracetamol acetaminophen, PARACETAMOL",
@@ -23,6 +29,8 @@ class _FixedLlm:
self._payload = payload
def generate(self, system: str, user: str, schema: dict) -> str:
if isinstance(self._payload, BaseException):
raise self._payload
if isinstance(self._payload, str):
return self._payload
return json.dumps(self._payload, ensure_ascii=False)
@@ -139,11 +147,108 @@ def test_fuzzy_suggestion_bounds_a_typo_into_the_candidate_set():
assert frame.drugs == ("metformin",)
# --- F-10: a small battery of invented near-alias shapes, beyond the single
# "aspirinol" case above — each simulates a different way a name could be
# crafted to *look* like it should fuzzy-match a real drug ---------------
def test_a_real_drug_name_with_a_brand_like_suffix_is_not_substituted():
resolver = _FakeResolver({}) # nothing in this turn resolves or suggests
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": [],
"unknown_drugs": ["metforminex"], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, resolver)
frame = understander.understand("liều metforminex")
assert frame.drugs == ()
assert frame.unknown_drugs == ("metforminex",)
def test_a_name_blending_two_real_drugs_is_not_substituted_for_either():
resolver = _FakeResolver({})
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["metformin", "paracetamol_acetaminophen"],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, resolver)
frame = understander.understand("liều metformacetamol")
# Neither real id has deterministic candidate support for this turn —
# F-04's bound must reject both, not accept the ones that happen to be
# real catalog members.
assert frame.drugs == ()
assert "metformin" in frame.unknown_drugs
assert "paracetamol_acetaminophen" in frame.unknown_drugs
def test_malformed_json_fails_closed_to_a_clarify():
understander = LlmQueryUnderstander(_FixedLlm("not json"), CATALOG, RESOLVER)
frame = understander.understand("gì đó")
assert frame.turn_type == "out_of_scope"
assert frame.needs_clarify is True
assert frame.quick_replies == ()
def test_quick_replies_are_parsed_when_the_model_offers_them():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": True, "clarify_reason": "Người lớn hay trẻ em?",
"quick_replies": ["Người lớn", "Trẻ em"],
}), CATALOG, RESOLVER)
frame = understander.understand("liều paracetamol")
assert frame.quick_replies == ("Người lớn", "Trẻ em")
def test_missing_quick_replies_key_defaults_to_empty_not_a_crash():
"""The model is asked for `quick_replies` but structured-output providers
aren't guaranteed to include every optional key — a clarify without it
must still parse, just with no chips."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": [],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": True, "clarify_reason": "Cân nặng bao nhiêu kg?",
}), CATALOG, RESOLVER)
frame = understander.understand("liều cho bé")
assert frame.quick_replies == ()
def test_route_is_parsed_when_the_model_resolves_it():
"""The 2026-08-07 bug: a bare reply like 'Uống' answering the model's own
prior route question had nowhere to be recorded (QueryFrame had no route
field), so the model could only repeat its clarify_reason verbatim."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [], "attribute": "lieu_luong_va_cach_dung",
"population": "nguoi_lon", "weight_kg": None, "age_text": None,
"indication": None, "route": "uong",
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand(
"Uống",
history=(
"Người dùng: Liều paracetamol hạ sốt là bao nhiêu?",
"Trợ lý: Người lớn hay trẻ em? Uống hay đặt trực tràng?",
"Người dùng: Người lớn",
"Trợ lý: Uống hay đặt trực tràng?",
),
)
assert frame.route == "uong"
assert frame.needs_clarify is False
def test_missing_route_key_defaults_to_none_not_a_crash():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": [],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand("liều metformin")
assert frame.route is None
def test_unrecognised_turn_type_falls_back_based_on_whether_a_drug_resolved():
@@ -200,3 +305,139 @@ def test_invalid_attribute_is_dropped_not_passed_through():
}), CATALOG, RESOLVER)
frame = understander.understand("metformin")
assert frame.attribute is None
# --- F-10: provider outage during the ONE call site that had no error
# handling at all ------------------------------------------------------------
# --- F-11: prior-frame merge — the code-level backstop for the model
# dropping an already-established slot mid clarify-chain. Found live
# 2026-08-07 (50-question hand-typed browser audit): reproduced 3 times
# independently as either a non-terminating re-ask of the same clarify
# question, or a stale drug bleeding into an unrelated new topic. --------
def test_prior_frame_known_fields_survive_a_short_reply_the_model_drops():
"""The Insulin/Azithromycin shape: the new call's own JSON comes back
with the just-answered field null (a real, observed failure — the model
is asked to restate it and sometimes doesn't), but since this turn named
no drug of its own (a short reply like "20kg" never does), the previously
established fields must survive via the merge, not be silently lost."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": [],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": 20, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
prior = QueryFrame(
turn_type="dosing_calc", drugs=("paracetamol_acetaminophen",),
attribute="lieu_luong_va_cach_dung", needs_clarify=True,
clarify_reason="Bé nặng bao nhiêu kg?",
)
frame = understander.understand("bé nặng 20 cân", prior_frame=prior)
assert frame.drugs == ("paracetamol_acetaminophen",)
assert frame.attribute == "lieu_luong_va_cach_dung"
assert frame.weight_kg == 20
def test_prior_frame_is_not_merged_when_the_turn_resolves_a_different_drug():
"""The headache/OMEPRAZOL bleed this guards against: a turn that itself
names a real, different drug is a genuine topic change and must not
inherit the old drug's population/weight/route — merging here would
reproduce the exact bug being fixed."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": "chi_dinh", "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
prior = QueryFrame(
turn_type="drug_attribute", drugs=("paracetamol_acetaminophen",),
population="tre_em", weight_kg=20, needs_clarify=True,
clarify_reason="Bé nặng bao nhiêu kg?",
)
frame = understander.understand("chỉ định của metformin là gì", prior_frame=prior)
assert frame.drugs == ("metformin",)
assert frame.population is None
assert frame.weight_kg is None
def test_prior_frame_that_was_already_resolved_is_not_merged():
"""A prior turn that already answered (needs_clarify=False) has nothing
to continue — merging it into a brand-new turn would leak stale state
into an unrelated question that happens to follow it."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "smalltalk", "drugs": [],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
prior = QueryFrame(
turn_type="drug_attribute", drugs=("metformin",),
population="nguoi_lon", needs_clarify=False,
)
frame = understander.understand("cảm ơn bạn", prior_frame=prior)
assert frame.drugs == ()
assert frame.population is None
def test_known_facts_block_is_sent_to_the_model_on_a_clarify_continuation():
captured = {}
class _CapturingLlm:
def generate(self, system, user, schema):
captured["user"] = user
return json.dumps({
"turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": 20, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
})
understander = LlmQueryUnderstander(_CapturingLlm(), CATALOG, RESOLVER)
prior = QueryFrame(
turn_type="dosing_calc", drugs=("paracetamol_acetaminophen",),
population="tre_em", needs_clarify=True,
clarify_reason="Bé nặng bao nhiêu kg?",
)
understander.understand("20 cân", prior_frame=prior)
assert "THÔNG TIN ĐÃ XÁC ĐỊNH" in captured["user"]
assert "paracetamol_acetaminophen" in captured["user"]
def test_no_known_facts_block_when_there_is_no_prior_clarify():
captured = {}
class _CapturingLlm:
def generate(self, system, user, schema):
captured["user"] = user
return json.dumps({
"turn_type": "smalltalk", "drugs": [],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
})
understander = LlmQueryUnderstander(_CapturingLlm(), CATALOG, RESOLVER)
understander.understand("chào bạn")
assert "THÔNG TIN ĐÃ XÁC ĐỊNH" not in captured["user"]
def test_provider_outage_fails_closed_to_a_clarify_not_an_unhandled_crash():
"""Found live 2026-08-07: unlike every other LLM call site in this
product, `understand()` had no try/except around its call at all — a
Bedrock outage here propagated straight through `RagAgent.handle()`
into an unhandled 500 (`routers/rag.py` only wraps the trace-save call,
not `agent.handle()`), instead of the graceful abstain every other
failure mode already gets."""
understander = LlmQueryUnderstander(
_FixedLlm(AnswerGenerationUnavailable("Bedrock unreachable")),
CATALOG, RESOLVER,
)
frame = understander.understand("liều paracetamol cho người lớn")
assert frame.needs_clarify is True
assert frame.clarify_reason is not None
assert frame.drugs == ()
assert frame.quick_replies == ()