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