Fix ai-service Dockerfile: bake in drug_entities.json, override its path
This commit is contained in:
@@ -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 == ()
|
||||
|
||||
Reference in New Issue
Block a user