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
+192 -7
View File
@@ -35,9 +35,15 @@ stub runs the whole path offline in tests.
from __future__ import annotations
import json
from dataclasses import dataclass, field
import logging
from dataclasses import dataclass, field, replace
from typing import Protocol, Sequence
from .budget import RequestBudget
from .ports import AnswerGenerationUnavailable
logger = logging.getLogger(__name__)
# The 19 monograph section keys, kept here as the closed vocabulary the model may
# use for `attribute`. Adding a new section is one entry, not a code change.
SECTION_KEYS = (
@@ -126,8 +132,22 @@ class QueryFrame:
weight_kg: float | None = None
age_text: str | None = None
indication: str | None = None # symptom/disease, for symptom_to_drug
route: str | None = None # e.g. "uong", "tiem_tinh_mach", "dat_truc_trang"
needs_clarify: bool = False
clarify_reason: str | None = None
# Short suggested replies for `clarify_reason` (e.g. ("Người lớn", "Trẻ
# em")) — only when the model judged the missing detail has a few
# natural discrete answers; often empty (e.g. a question needing a
# specific weight has no clean short options).
quick_replies: tuple[str, ...] = ()
# Set only when `needs_clarify` fired because of a real technical
# failure (provider outage, malformed model output) rather than the
# model genuinely judging the turn under-specified. Found live
# 2026-08-07: both cases produced the exact same `reason="needs_more_info"`
# downstream, making a real, diagnosable outage indistinguishable from an
# ordinary clarifying question in the API response and trace — this lets
# `RagAgent._route` surface the real cause instead.
system_error: str | None = None
raw: dict = field(default_factory=dict, compare=False)
@@ -147,8 +167,24 @@ FRAME_SCHEMA = {
),
"age_text": "the age exactly as stated (e.g. '3 tuổi', '5 tháng'), else null",
"indication": "the symptom or disease if turn_type is symptom_to_drug, else null",
"route": (
"route of administration if stated or implied, normalized to one of: "
"uong | tiem_tinh_mach | tiem_bap | tiem_duoi_da | dat_truc_trang | "
"boi_ngoai_da | nho_mat | nho_mui | khac, else null. A bare reply like "
"'uống' or 'tiêm' to your own prior clarify question about route IS "
"this field — read it here, do not leave it null and re-ask."
),
"needs_clarify": "true only if the turn cannot be acted on without more info",
"clarify_reason": "short Vietnamese question to ask, or null",
"quick_replies": (
"2-4 short suggested replies (each under ~20 chars) to your OWN "
"clarify_reason, for the user to tap instead of typing — ONLY when "
"clarify_reason genuinely has a few natural discrete answers (e.g. "
"['Người lớn', 'Trẻ em'] or ['Uống', 'Tiêm']). Empty list [] if the "
"missing detail needs a specific free-form value (e.g. an exact "
"weight) with no clean short options — never invent numeric-ish "
"options."
),
}
_SYSTEM = """Bạn là bộ HIỂU CÂU HỎI cho một chatbot tra cứu Dược thư Quốc gia Việt Nam.
@@ -169,7 +205,39 @@ Quy tắc bắt buộc:
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
- Lượt nối tiếp ("còn liều thì sao", "nó dùng cho trẻ em?") -> dùng LỊCH SỬ để biết
thuốc đang nói tới và điền vào "drugs".
- Chào hỏi/vu vơ -> "smalltalk". Ngoài phạm vi chuyên luận thuốc -> "out_of_scope"."""
- QUAN TRỌNG — lượt hiện tại trả lời câu hỏi bạn VỪA hỏi: nếu dòng "Trợ lý:" cuối
cùng trong LỊCH SỬ là một câu hỏi làm rõ (vd "Người lớn hay trẻ em?", "Uống hay
tiêm?", "Cân nặng bao nhiêu kg?"), và CÂU HỎI HIỆN TẠI là một câu trả lời ngắn
hợp lý cho đúng câu đó (vd "Uống", "Người lớn", "30kg") — hãy đọc nó là câu trả
lời, điền vào field tương ứng (route/population/weight_kg/age_text), giữ lại các
field đã biết từ các lượt trước đó trong LỊCH SỬ (đừng bỏ trống lại), và CHỈ đặt
needs_clarify=true với PHẦN THÔNG TIN CÒN THIẾU KHÁC (nếu có) — TUYỆT ĐỐI KHÔNG
lặp lại nguyên văn clarify_reason đã được trả lời. Nếu sau khi điền, đã đủ dữ
kiện (đối tượng + đường dùng, và tuổi/cân nặng nếu là trẻ em) thì needs_clarify=false.
Nếu có khối "THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC" bên dưới, các mục đó ĐÃ
ĐƯỢC XÁC NHẬN — không hỏi lại, không đặt needs_clarify=true vì thiếu đúng mục
đã liệt kê ở đó.
- NGƯỢC LẠI — nếu CÂU HỎI HIỆN TẠI là một câu hỏi y khoa MỚI, KHÔNG phải một câu
trả lời ngắn cho câu hỏi làm rõ gần nhất (không khớp loại thông tin vừa hỏi) và
KHÔNG nhắc lại thuốc/triệu chứng nào đã có trong LỊCH SỬ hay khối "THÔNG TIN ĐÃ
XÁC ĐỊNH": đây là LƯỢT MỚI HOÀN TOÀN — TUYỆT ĐỐI KHÔNG mang "drugs"/
"population"/"weight_kg"/"age_text"/"route"/"indication" của lượt trước sang lượt
này, chỉ điền những gì thực sự có trong CÂU HỎI HIỆN TẠI. Ví dụ: lượt trước đang
hỏi về Omeprazol nhưng câu hiện tại là "tôi bị đau đầu nên uống thuốc gì" (không
nhắc Omeprazol) -> chủ đề mới, "drugs" phải để trống trừ khi có thuốc thực sự
được nhắc trong câu này.
- Nếu CÂU HỎI HIỆN TẠI là một lời PHỦ ĐỊNH/SỬA LẠI câu trả lời vừa rồi (vd "tôi
có hỏi X đâu", "tôi không hỏi vậy", "đâu phải thế", "ý tôi không phải vậy",
"sai rồi") — đây là dấu hiệu bạn vừa hiểu SAI ý người dùng ở lượt trước.
TUYỆT ĐỐI KHÔNG lặp lại đúng route/population/thuộc tính vừa trả lời (đã bị
từ chối): đặt needs_clarify=true và hỏi lại thật ngắn gọn, cụ thể người dùng
thực sự muốn hỏi điều gì (vd "Anh/chị muốn hỏi đường dùng nào ạ?"), không tự
suy đoán lại giá trị cũ.
- Chào hỏi/vu vơ -> "smalltalk". Ngoài phạm vi chuyên luận thuốc -> "out_of_scope".
- Khi needs_clarify=true, kèm "quick_replies": 2-4 phương án NGẮN cho câu hỏi lại
đó, CHỈ khi nó thực sự có vài lựa chọn rời rạc tự nhiên (vd đối tượng: "Người
lớn"/"Trẻ em"). Để mảng rỗng nếu cần một giá trị cụ thể không có lựa chọn ngắn
(vd hỏi cân nặng chính xác) — không bịa phương án dạng số."""
class JsonLlm(Protocol):
@@ -181,7 +249,11 @@ class JsonLlm(Protocol):
class QueryUnderstander(Protocol):
def understand(
self, turn: str, history: Sequence[str] = ()
self,
turn: str,
history: Sequence[str] = (),
budget: RequestBudget | None = None,
prior_frame: QueryFrame | None = None,
) -> QueryFrame: ...
@@ -240,7 +312,13 @@ class LlmQueryUnderstander:
ids.add(drug_id)
return ids
def understand(self, turn: str, history: Sequence[str] = ()) -> QueryFrame:
def understand(
self,
turn: str,
history: Sequence[str] = (),
budget: RequestBudget | None = None,
prior_frame: QueryFrame | None = None,
) -> QueryFrame:
shown = {
drug_id: self._catalog[drug_id]
for drug_id in self._candidate_ids(turn, history)
@@ -255,6 +333,7 @@ class LlmQueryUnderstander:
"LỊCH SỬ HỘI THOẠI (cũ -> mới):\n" + "\n".join(history)
if history else "LỊCH SỬ HỘI THOẠI: (chưa có)"
)
known_block = _known_facts_block(prior_frame)
user = (
f"DANH SÁCH THUỐC ỨNG VIÊN cho lượt này (drug_id\\ttên) — CHỈ được chọn "
f"drug_id từ đây, đây KHÔNG phải toàn bộ Dược thư, chỉ là các thuốc khớp "
@@ -263,11 +342,39 @@ class LlmQueryUnderstander:
"CÁC SECTION KEY hợp lệ cho 'attribute' (key: ý nghĩa):\n"
+ "\n".join(f"{key}: {SECTION_KEY_HINTS[key]}" for key in SECTION_KEYS)
+ "\n\n"
f"{known_block}"
f"{history_block}\n\n"
f"CÂU HỎI HIỆN TẠI: {turn}"
)
raw_text = self._llm.generate(_SYSTEM, user, FRAME_SCHEMA)
return self._parse(raw_text, shown)
# Found live 2026-08-07 (F-10 adversarial pass): unlike every other
# LLM call site in this product (`answer.py`'s sufficiency/generate/
# entailment all catch this), this one call had no error handling at
# all — a provider outage here propagated straight through
# `RagAgent.handle()` and `routers/rag.py` (which only wraps the
# trace-save call, not `agent.handle()`) into an unhandled 500,
# rather than the graceful abstain every other failure mode gets.
try:
if budget is not None:
budget.require()
raw_text = self._llm.generate(_SYSTEM, user, FRAME_SCHEMA)
except AnswerGenerationUnavailable as exc:
# Found live 2026-08-07: this except block silently swallowed
# the real exception entirely — no log line anywhere — so a
# genuine provider outage (throttling, timeout, IAM, whatever)
# left zero trace to diagnose from. Now logged with the actual
# exception, and tagged with a `system_error` code distinct from
# an ordinary clarify (see `QueryFrame.system_error`).
logger.warning(
"understanding call failed (%s): %s", type(exc).__name__, exc
)
return QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Dịch vụ đang gặp sự cố tạm thời, vui lòng thử lại "
"sau ít phút.",
system_error="understanding_provider_unavailable",
)
return _merge_with_prior_frame(self._parse(raw_text, shown), prior_frame)
@staticmethod
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
@@ -291,11 +398,15 @@ class LlmQueryUnderstander:
try:
data = json.loads(raw_text)
except (json.JSONDecodeError, TypeError):
# Fail closed to a clarify rather than to a wrong reading.
# Fail closed to a clarify rather than to a wrong reading. Not a
# provider outage (the call succeeded) — the model's own output
# didn't parse, a distinct, separately diagnosable cause.
logger.warning("understanding call returned unparseable JSON: %r", raw_text)
return QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Xin lỗi, tôi chưa hiểu rõ câu hỏi. Anh/chị hỏi lại giúp nhé?",
system_error="understanding_malformed_output",
)
resolved = [
(d, self._resolve_id(d, shown)) for d in _as_list(data.get("drugs"))
@@ -322,12 +433,86 @@ class LlmQueryUnderstander:
weight_kg=_clean_float(data.get("weight_kg")),
age_text=_clean_str(data.get("age_text")),
indication=_clean_str(data.get("indication")),
route=_clean_str(data.get("route")),
needs_clarify=bool(data.get("needs_clarify")),
clarify_reason=_clean_str(data.get("clarify_reason")),
quick_replies=tuple(_as_list(data.get("quick_replies"))),
raw=data if isinstance(data, dict) else {},
)
_KNOWN_FACT_LABELS: tuple[tuple[str, str], ...] = (
("population", "Đối tượng"),
("age_text", "Tuổi"),
("route", "Đường dùng"),
("indication", "Chỉ định/triệu chứng"),
("attribute", "Thuộc tính đang tra"),
)
def _known_facts_block(prior_frame: QueryFrame | None) -> str:
"""The structured "already established" summary shown to the model on a
clarify-continuation turn.
Found live 2026-08-07 (50-question hand-typed browser audit): relying on
the model to re-derive the WHOLE frame from raw text history every turn
is fragile — reproduced 3 times independently (Insulin storage, weight-
based Azithromycin dosing, a headache question mislabeled OMEPRAZOL) as
either a non-terminating re-ask of an already-answered clarify question,
or a stale drug bleeding into an unrelated new topic. Stating the known
fields explicitly, as data rather than asking the model to infer them
from a growing text transcript, removes most of the guesswork; `_merge_
with_prior_frame` below is the code-level backstop for whatever the
model still drops.
"""
if prior_frame is None or not prior_frame.needs_clarify:
return ""
parts = []
if prior_frame.drugs:
parts.append(f"Thuốc: {', '.join(prior_frame.drugs)}")
if prior_frame.weight_kg is not None:
parts.append(f"Cân nặng: {prior_frame.weight_kg:g} kg")
for field_name, label in _KNOWN_FACT_LABELS:
value = getattr(prior_frame, field_name)
if value:
parts.append(f"{label}: {value}")
if not parts:
return ""
return (
"THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC (dữ liệu CÓ THẬT, đã xác nhận "
"— KHÔNG hỏi lại các mục này; nếu câu hỏi hiện tại là một chủ đề mới "
"không liên quan, hãy bỏ qua khối này thay vì gán nhầm vào lượt mới):\n"
+ "\n".join(parts) + "\n\n"
)
def _merge_with_prior_frame(frame: QueryFrame, prior_frame: QueryFrame | None) -> QueryFrame:
"""Code-level backstop for the model dropping an already-known field.
Only fires when the prior turn was itself a clarify (there is something
to continue) and this turn's own `drugs` extraction agrees with it (empty,
meaning a short reply like "20kg"/"Uống" that names no drug of its own, or
an exact match) — a turn that resolves a DIFFERENT drug is a genuine topic
change and must not inherit stale population/weight/route/indication from
the old one (the headache/OMEPRAZOL bleed this guards against runs the
other way: don't let old fields survive into an unrelated new drug either).
"""
if prior_frame is None or not prior_frame.needs_clarify:
return frame
if frame.drugs and frame.drugs != prior_frame.drugs:
return frame
return replace(
frame,
drugs=frame.drugs or prior_frame.drugs,
population=frame.population or prior_frame.population,
age_text=frame.age_text or prior_frame.age_text,
weight_kg=frame.weight_kg if frame.weight_kg is not None else prior_frame.weight_kg,
route=frame.route or prior_frame.route,
indication=frame.indication or prior_frame.indication,
attribute=frame.attribute or prior_frame.attribute,
)
def _as_list(value) -> list[str]:
if isinstance(value, str):
return [value] if value.strip() else []