635 lines
33 KiB
Python
635 lines
33 KiB
Python
"""LLM-driven query understanding — the front-end of the RAG chatbot.
|
|
|
|
The old drug-first path resolved a drug with a fuzzy `SequenceMatcher` and routed
|
|
sections with a Vietnamese phrase table. Both are brittle string heuristics: they
|
|
false-matched a made-up name (``aspirinol`` -> aspirin), tied on a correctly
|
|
spelled English INN (``amoxicillin``), and mistook a common word (``uống``) for a
|
|
drug. This module replaces that with the model the system already has.
|
|
|
|
Division of labour, and why it is safe for a formulary:
|
|
|
|
- The **LLM** does the messy human-language part — which token is a drug, is this
|
|
an interaction / a symptom lookup / a weight-based dose, what section is asked,
|
|
what population/weight. It is good at exactly the fuzziness the heuristics were
|
|
bad at.
|
|
- The **catalog** stays the authority on drug *identity*. The model may only pick
|
|
``drug_id`` values from a list *bounded before the model ever runs* — a
|
|
deterministic alias/fuzzy pass over the turn and history (`CandidateSource`)
|
|
decides which real drugs are even plausible candidates, and only those are
|
|
shown. This closes a gap the catalog-whitelist alone did not (F-04, Codex
|
|
2026-08-06 review): validating that an output id is *some* real drug_id does
|
|
not prove it is the *one the user's text actually named* — an LLM could
|
|
satisfy that whitelist while mapping an unrelated or invented name to any of
|
|
the other 683 real drugs. Bounding candidates first removes that degree of
|
|
freedom: the model can still read ``amoxicillin`` as ``amoxicilin`` (a fuzzy
|
|
match puts it in the candidate set) but cannot map ``aspirinol`` to aspirin,
|
|
because nothing about ``aspirinol`` fuzzy-matches anything and the candidate
|
|
set the model is shown is empty or contains unrelated drugs, not aspirin.
|
|
- Nothing here answers the medical question. It only produces a structured frame;
|
|
retrieval + ``grounding.verify`` remain the load-bearing safety layer downstream.
|
|
|
|
`rag/` imports no SDK: the LLM is injected as a ``JsonLlm`` protocol (satisfied by
|
|
`adapters.bedrock_converse.BedrockConverseAnswerGenerator`), and a deterministic
|
|
stub runs the whole path offline in tests.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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 = (
|
|
"ten_chung_quoc_te",
|
|
"ten_thuong_mai",
|
|
"ma_atc",
|
|
"loai_thuoc",
|
|
"dang_thuoc_va_ham_luong",
|
|
"duoc_ly_va_co_che_tac_dung",
|
|
"chi_dinh",
|
|
"chong_chi_dinh",
|
|
"than_trong",
|
|
"thoi_ky_mang_thai",
|
|
"thoi_ky_cho_con_bu",
|
|
"tac_dung_khong_mong_muon",
|
|
"huong_dan_xu_tri_adr",
|
|
"lieu_luong_va_cach_dung",
|
|
"tuong_tac_thuoc",
|
|
"qua_lieu_va_xu_tri",
|
|
"do_on_dinh_va_bao_quan",
|
|
"tuong_ky",
|
|
"thong_tin_quy_che",
|
|
)
|
|
|
|
# Short glosses shown to the model alongside each key. Found live 2026-08-06
|
|
# (golden e2e set): a bare key list gives the model nothing to disambiguate
|
|
# "thận trọng" from "chống chỉ định" — 9/9 live calls for "X cần thận trọng
|
|
# gì?" picked chong_chi_dinh, silently answering from the wrong section
|
|
# (and, downstream, dropping the specific safety content the precautions
|
|
# section actually has, e.g. metformin's lactic acidosis warning). The two
|
|
# are genuinely adjacent concepts in Vietnamese medical text; a bare slug
|
|
# name is not enough to tell a model which one a question means.
|
|
SECTION_KEY_HINTS: dict[str, str] = {
|
|
"ten_chung_quoc_te": "tên chung quốc tế/INN",
|
|
"ten_thuong_mai": "tên thương mại/biệt dược",
|
|
"ma_atc": "mã ATC",
|
|
"loai_thuoc": "phân loại thuốc",
|
|
"dang_thuoc_va_ham_luong": "dạng bào chế và hàm lượng",
|
|
"duoc_ly_va_co_che_tac_dung": "dược lý, cơ chế tác dụng",
|
|
"chi_dinh": "chỉ định — bệnh/triệu chứng thuốc dùng để điều trị",
|
|
"chong_chi_dinh": (
|
|
"CHỐNG CHỈ ĐỊNH — trường hợp TUYỆT ĐỐI KHÔNG được dùng thuốc này"
|
|
),
|
|
"than_trong": (
|
|
"THẬN TRỌNG — KHÁC chống chỉ định: vẫn dùng được nhưng cần cảnh "
|
|
"giác/theo dõi/chỉnh liều (ví dụ nguy cơ nhiễm toan lactic của "
|
|
"metformin, độc tính thận/tai của gentamicin). Câu hỏi có chữ "
|
|
"\"thận trọng\", \"cẩn thận\", \"lưu ý gì\", \"cần chú ý\" → key này, "
|
|
"KHÔNG PHẢI chong_chi_dinh."
|
|
),
|
|
"thoi_ky_mang_thai": "dùng khi mang thai",
|
|
"thoi_ky_cho_con_bu": "dùng khi cho con bú",
|
|
"tac_dung_khong_mong_muon": "tác dụng phụ/ADR",
|
|
"huong_dan_xu_tri_adr": "cách xử trí khi gặp ADR",
|
|
"lieu_luong_va_cach_dung": "liều dùng và cách dùng",
|
|
"tuong_tac_thuoc": "tương tác với thuốc khác",
|
|
"qua_lieu_va_xu_tri": "quá liều và cách xử trí",
|
|
"do_on_dinh_va_bao_quan": "độ ổn định, bảo quản",
|
|
"tuong_ky": "tương kỵ (không pha/trộn được với gì)",
|
|
"thong_tin_quy_che": "thông tin quy chế/pháp lý",
|
|
}
|
|
|
|
# What kind of turn this is — the router branches on it. Deliberately explicit so a
|
|
# symptom lookup is never silently treated as a failed drug lookup, and a two-drug
|
|
# interaction never collapses to an "ambiguous drug" abstain.
|
|
TURN_TYPES = (
|
|
"drug_attribute", # one drug, one/more sections ("liều paracetamol")
|
|
"drug_overview", # a bare drug name, wants the monograph
|
|
"interaction", # 2+ drugs, asks about combining them
|
|
"symptom_to_drug", # a symptom/indication, wants candidate drugs
|
|
"dosing_calc", # a dose that needs weight/age arithmetic
|
|
"smalltalk", # greeting / meta, not a medical query
|
|
"out_of_scope", # not answerable from the Part-2 monographs
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QueryFrame:
|
|
"""The structured reading of one user turn. No medical content, only intent."""
|
|
|
|
turn_type: str
|
|
drugs: tuple[str, ...] = () # canonical drug_ids, from the catalog only
|
|
unknown_drugs: tuple[str, ...] = () # mentioned, not in the catalog
|
|
attribute: str | None = None # a SECTION_KEYS value, or None
|
|
population: str | None = None # e.g. "tre_em", "nguoi_lon", "suy_than"
|
|
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"
|
|
# True when the user is asking to survey/summarise a whole named section
|
|
# (for example all ADRs, precautions, or dosage regimens), rather than
|
|
# asking for one regimen to apply to a particular patient. This is a
|
|
# materially different sufficiency contract: a section survey must label
|
|
# every branch, while a patient-specific dose may need clarification.
|
|
section_overview: bool = False
|
|
# Self-contained meaning used downstream. It rewrites references/omitted
|
|
# subjects only; retrieval still trusts the separately validated drug ids,
|
|
# facet and constraints below rather than parsing authority back from text.
|
|
standalone_query: str | None = None
|
|
depends_on_previous_turn: bool = False
|
|
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)
|
|
|
|
|
|
# The JSON contract the model must fill. Stated in the prompt (Converse has no
|
|
# server-side schema) and validated on the way back.
|
|
FRAME_SCHEMA = {
|
|
"turn_type": "one of: " + " | ".join(TURN_TYPES),
|
|
"drugs": ["drug_id exactly as it appears in the provided catalog list"],
|
|
"unknown_drugs": ["a drug name the user mentioned that is NOT in the catalog"],
|
|
"attribute": "one of the section keys provided, or null",
|
|
"population": "tre_em | tre_so_sinh | nguoi_lon | nguoi_cao_tuoi | phu_nu_co_thai | phu_nu_cho_con_bu | suy_than | suy_gan | null",
|
|
"weight_kg": (
|
|
"number if a body weight is given, else null. Vietnamese casual speech "
|
|
"states weight as a bare number of 'cân' or 'ký' with no unit word "
|
|
"('bé 30 cân', 'nặng 30 ký') — both mean kilograms; read the number as "
|
|
"weight_kg the same as if 'kg' had been written."
|
|
),
|
|
"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."
|
|
),
|
|
"section_overview": (
|
|
"true when the user asks for a general survey of the requested section "
|
|
"(all ADRs/interactions/precautions/dose regimens, 'theo Dược thư', "
|
|
"'nếu có'), not one dose/decision for a specific patient"
|
|
),
|
|
"standalone_query": (
|
|
"self-contained Vietnamese question after resolving pronouns/omitted subject "
|
|
"from history; do not answer it. For a self-contained current turn, copy its "
|
|
"meaning without adding facts"
|
|
),
|
|
"depends_on_previous_turn": (
|
|
"true only when standalone_query needed entity/facet/constraints from history"
|
|
),
|
|
"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."
|
|
),
|
|
}
|
|
|
|
_ALLOWED_POPULATIONS = {
|
|
"tre_em", "tre_so_sinh", "nguoi_lon", "nguoi_cao_tuoi",
|
|
"phu_nu_co_thai", "phu_nu_cho_con_bu", "suy_than", "suy_gan",
|
|
}
|
|
_ALLOWED_ROUTES = {
|
|
"uong", "tiem_tinh_mach", "tiem_bap", "tiem_duoi_da",
|
|
"dat_truc_trang", "boi_ngoai_da", "nho_mat", "nho_mui", "khac",
|
|
}
|
|
_QUICK_REPLY_MAX_ITEMS = 4
|
|
_QUICK_REPLY_MAX_CHARS = 40
|
|
|
|
_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.
|
|
Nhiệm vụ: đọc câu hỏi (tiếng Việt, có thể sai chính tả, viết tắt, nhiều lượt) và
|
|
XUẤT RA một JSON mô tả ý định. TUYỆT ĐỐI KHÔNG trả lời câu hỏi y khoa, không nêu liều.
|
|
|
|
Quy tắc bắt buộc:
|
|
- Trường "drugs" CHỈ được chứa các drug_id có trong DANH SÁCH THUỐC được cung cấp.
|
|
Nếu người dùng nhắc một thuốc KHÔNG có trong danh sách (kể cả tên bịa như
|
|
"aspirinol"), đưa tên đó vào "unknown_drugs", KHÔNG được gán sang thuốc gần giống.
|
|
- Sai chính tả một thuốc CÓ trong danh sách thì sửa về đúng drug_id của nó
|
|
(ví dụ "amoxicillin" -> "amoxicilin", "metfomin" -> "metformin").
|
|
- Nếu câu nhắc 2 thuốc trở lên và hỏi về dùng chung/tương tác -> turn_type="interaction".
|
|
- Nếu là triệu chứng/bệnh cần gợi ý thuốc (không nêu tên thuốc) -> "symptom_to_drug",
|
|
điền "indication".
|
|
- Nếu hỏi liều cần cân nặng/tuổi -> "dosing_calc", điền weight_kg/age_text nếu có.
|
|
Nói cân nặng kiểu thường ngày ("bé 30 cân", "nặng 30 ký", chỉ 1 số + "cân"/"ký"
|
|
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
|
|
- Với "dosing_calc", bộ hiểu câu hỏi chỉ bắt buộc làm rõ population và tuổi/cân
|
|
nặng nếu là trẻ em/trẻ sơ sinh. KHÔNG tự kết luận rằng route luôn bắt buộc: nếu
|
|
người dùng chưa nêu đường dùng thì giữ route=null; tầng trả lời sẽ nhìn chính
|
|
BẰNG CHỨNG truy xuất được để quyết định câu hỏi đã đủ rõ hay chưa. Nhờ vậy nếu
|
|
nguồn chỉ có một đường dùng phù hợp thì trả lời thẳng, còn nếu có nhiều nhánh
|
|
khác nhau mới hỏi lại và sinh quick_replies.
|
|
- Phân biệt tra cứu TOÀN MỤC với áp dụng cho MỘT NGƯỜI BỆNH. Nếu người dùng hỏi
|
|
tổng quan kiểu "Dược thư hướng dẫn dùng thế nào", "các ADR/tương tác/thận trọng
|
|
nào", "theo nhóm/tần suất", "nếu có" thì đặt section_overview=true và KHÔNG
|
|
hỏi người lớn/trẻ em/đường dùng chỉ để thu hẹp. Hãy trả về các nhánh trong sách
|
|
với nhãn rõ ràng. Chỉ đặt section_overview=false và hỏi thêm khi người dùng muốn
|
|
chọn một liều/quyết định áp dụng cho ca bệnh cụ thể.
|
|
- 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". Đồng thời viết `standalone_query` thành
|
|
câu có nghĩa độc lập (ví dụ "Còn chống chỉ định của levetiracetam?" ->
|
|
"Chống chỉ định của levetiracetam") và đặt `depends_on_previous_turn=true`.
|
|
- Nếu lượt hiện tại đã tự đủ nghĩa, `standalone_query` chỉ chuẩn hóa chính câu đó,
|
|
không chèn thuốc/đối tượng cũ và đặt `depends_on_previous_turn=false`.
|
|
- 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):
|
|
"""A model that returns a single JSON object as text. Satisfied by the
|
|
existing Bedrock Converse generator, so this adds no SDK to `rag/`."""
|
|
|
|
def generate(self, system: str, user: str, schema: dict) -> str: ...
|
|
|
|
|
|
class QueryUnderstander(Protocol):
|
|
def understand(
|
|
self,
|
|
turn: str,
|
|
history: Sequence[str] = (),
|
|
budget: RequestBudget | None = None,
|
|
prior_frame: QueryFrame | None = None,
|
|
) -> QueryFrame: ...
|
|
|
|
|
|
class CandidateSource(Protocol):
|
|
"""Deterministic, no-LLM drug-name matching — what bounds the model's
|
|
choice before it ever runs (F-04). Satisfied by `routing.CatalogDrugResolver`;
|
|
kept as a protocol (not an import of it) so this module stays decoupled
|
|
from the fuzzy-matching implementation, only its shape.
|
|
"""
|
|
|
|
def resolve(self, query: str): ...
|
|
def suggest(
|
|
self, query: str, k: int = 3, min_score: float = 0.5
|
|
) -> list[tuple[str, float]]: ...
|
|
|
|
|
|
class LlmQueryUnderstander:
|
|
"""Turns a raw user turn into a `QueryFrame` with one LLM call.
|
|
|
|
`catalog` maps drug_id -> a human name (used only to label whichever
|
|
candidates get shown). `resolver` is what actually decides which real
|
|
drugs are plausible for this turn, deterministically, before the model
|
|
runs at all: every drug_id an exact-alias or fuzzy match finds anywhere
|
|
in the turn or the raw history text. The model then picks only among
|
|
those — never the full ~684-drug catalog — so it structurally cannot
|
|
map an invented or unrelated name to some other real drug_id it merely
|
|
happens to also list correctly (F-04). This also directly answers a
|
|
separate 2026-08-06 review finding: sending the full catalog on every
|
|
turn is unbounded token cost; a per-turn candidate shortlist is both
|
|
safer and cheaper.
|
|
"""
|
|
|
|
def __init__(self, llm: JsonLlm, catalog: dict[str, str], resolver: CandidateSource) -> None:
|
|
self._llm = llm
|
|
self._catalog = catalog
|
|
self._resolver = resolver
|
|
|
|
def _candidate_ids(self, turn: str, history: Sequence[str]) -> set[str]:
|
|
"""Every drug_id a deterministic pass finds plausible in the turn or
|
|
the raw history text. Deliberately generous — an exact alias match
|
|
plus a fuzzy `suggest` well below the resolver's own auto-answer
|
|
threshold — because the job here is only to rule out drugs nothing
|
|
in the conversation plausibly refers to, not to pick the right one;
|
|
that disambiguation is still the model's job, within this bound.
|
|
"""
|
|
ids: set[str] = set()
|
|
for line in (turn, *history):
|
|
if not line.strip():
|
|
continue
|
|
resolution = self._resolver.resolve(line)
|
|
if resolution.status == "resolved" and resolution.drug_id:
|
|
ids.add(resolution.drug_id)
|
|
elif resolution.status == "ambiguous":
|
|
ids.update(resolution.candidate_drug_ids)
|
|
else:
|
|
# Exact/ambiguous resolution has already exhaustively scanned
|
|
# the aliases present in this line. Running the O(catalog)
|
|
# fuzzy pass as well only adds unrelated candidates and cost
|
|
# (~1s on the real catalog); fuzzy suggestions are useful only
|
|
# when no exact candidate was found (the typo path).
|
|
for drug_id, _score in self._resolver.suggest(
|
|
line, k=5, min_score=0.55
|
|
):
|
|
ids.add(drug_id)
|
|
return ids
|
|
|
|
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)
|
|
if drug_id in self._catalog
|
|
}
|
|
catalog_block = (
|
|
"\n".join(f"{drug_id}\t{name}" for drug_id, name in sorted(shown.items()))
|
|
if shown
|
|
else "(không có thuốc nào trong Dược thư khớp với lượt này hoặc lịch sử gần đây)"
|
|
)
|
|
history_block = (
|
|
"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 "
|
|
f"với chữ trong lượt/lịch sử:\n"
|
|
f"{catalog_block}\n\n"
|
|
"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}"
|
|
)
|
|
# 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:
|
|
if value in shown:
|
|
return value
|
|
# `drug_id` is shown with underscores ("paracetamol_acetaminophen")
|
|
# but its own canonical display name (bootstrap's `_catalog_names`)
|
|
# is the same string with spaces — found live 2026-08-06: the two
|
|
# look near-identical in the "drug_id\tname" table, and the model
|
|
# sometimes echoes the spaced display form instead of the id. This
|
|
# is a deterministic, lossless formatting difference (not a fuzzy
|
|
# match — one specific known substitution), so it's tolerated here
|
|
# rather than dropping a correctly-identified drug to unknown.
|
|
spaced = value.strip().casefold()
|
|
for drug_id in shown:
|
|
if drug_id.replace("_", " ").casefold() == spaced:
|
|
return drug_id
|
|
return None
|
|
|
|
def _parse(self, raw_text: str, shown: dict[str, str]) -> QueryFrame:
|
|
try:
|
|
data = json.loads(raw_text)
|
|
except (json.JSONDecodeError, TypeError):
|
|
# 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"))
|
|
]
|
|
drugs = tuple(dict.fromkeys(rid for _, rid in resolved if rid is not None))
|
|
# A drug the model named but that resolves to no id among the shown
|
|
# candidates (exact or underscore/space form) is unknown, not a
|
|
# silent drop and not a fuzzy substitution to an unrelated drug.
|
|
unknown = tuple(
|
|
d for d, rid in resolved if rid is None
|
|
) + tuple(_as_list(data.get("unknown_drugs")))
|
|
attribute = data.get("attribute")
|
|
if attribute not in SECTION_KEYS:
|
|
attribute = None
|
|
turn_type = data.get("turn_type")
|
|
if turn_type not in TURN_TYPES:
|
|
turn_type = "drug_attribute" if drugs else "out_of_scope"
|
|
needs_clarify = data.get("needs_clarify") is True
|
|
clarify_reason = _clean_str(data.get("clarify_reason"))
|
|
quick_replies = (
|
|
_clean_quick_replies(data.get("quick_replies"))
|
|
if needs_clarify and clarify_reason
|
|
else ()
|
|
)
|
|
return QueryFrame(
|
|
turn_type=turn_type,
|
|
drugs=drugs,
|
|
unknown_drugs=tuple(dict.fromkeys(unknown)),
|
|
attribute=attribute,
|
|
population=_clean_enum(data.get("population"), _ALLOWED_POPULATIONS),
|
|
weight_kg=_clean_float(data.get("weight_kg")),
|
|
age_text=_clean_str(data.get("age_text")),
|
|
indication=_clean_str(data.get("indication")),
|
|
route=_clean_enum(data.get("route"), _ALLOWED_ROUTES),
|
|
section_overview=data.get("section_overview") is True,
|
|
standalone_query=_clean_str(data.get("standalone_query")),
|
|
depends_on_previous_turn=data.get("depends_on_previous_turn") is True,
|
|
needs_clarify=needs_clarify,
|
|
clarify_reason=clarify_reason,
|
|
quick_replies=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 []
|
|
if isinstance(value, list):
|
|
return [str(v).strip() for v in value if str(v).strip()]
|
|
return []
|
|
|
|
|
|
def _clean_str(value) -> str | None:
|
|
if isinstance(value, str) and value.strip() and value.strip().lower() != "null":
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
def _clean_enum(value, allowed: set[str]) -> str | None:
|
|
cleaned = _clean_str(value)
|
|
return cleaned if cleaned in allowed else None
|
|
|
|
|
|
def _clean_quick_replies(value) -> tuple[str, ...]:
|
|
"""Keep the LLM's dynamic suggestions, but enforce the UI contract."""
|
|
if not isinstance(value, list):
|
|
return ()
|
|
replies: list[str] = []
|
|
seen: set[str] = set()
|
|
for item in value:
|
|
if not isinstance(item, str):
|
|
continue
|
|
reply = " ".join(item.split())
|
|
key = reply.casefold()
|
|
if not reply or len(reply) > _QUICK_REPLY_MAX_CHARS or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
replies.append(reply)
|
|
if len(replies) >= _QUICK_REPLY_MAX_ITEMS:
|
|
break
|
|
return tuple(replies)
|
|
|
|
|
|
def _clean_float(value) -> float | None:
|
|
if isinstance(value, (int, float)):
|
|
parsed = float(value)
|
|
return parsed if 0 < parsed <= 500 else None
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = float(value.replace(",", ".").split()[0])
|
|
return parsed if 0 < parsed <= 500 else None
|
|
except (ValueError, IndexError):
|
|
return None
|
|
return None
|