1117 lines
53 KiB
Python
1117 lines
53 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 .clinical import (
|
|
CaseContextAction,
|
|
ConditionNormalizer,
|
|
ConditionQuery,
|
|
ConditionRelation,
|
|
HepaticContext,
|
|
PatientContext,
|
|
RenalContext,
|
|
)
|
|
from .ports import AnswerGenerationUnavailable
|
|
from .sections import SectionResolver
|
|
from .text import normalize_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_SECTION_RESOLVER = SectionResolver()
|
|
|
|
# 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
|
|
"condition_to_drug", # a diagnosed disease/condition -> indicated drugs
|
|
"drug_to_condition", # what condition(s) a named drug is indicated for
|
|
"condition_relation", # reverse ADR/contraindication relation, not treatment
|
|
"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
|
|
condition: ConditionQuery | None = None
|
|
condition_relation: ConditionRelation = ConditionRelation.INDICATION
|
|
patient_context: PatientContext = field(default_factory=PatientContext)
|
|
context_action: CaseContextAction = CaseContextAction.NONE
|
|
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",
|
|
"condition": {
|
|
"original_text": "condition phrase exactly as written, or null",
|
|
"normalized_condition": (
|
|
"conservative canonical condition name; expand only an unambiguous "
|
|
"abbreviation (e.g. THA -> tăng huyết áp), or null"
|
|
),
|
|
"subtype": "explicit subtype only (e.g. B, mạn), else null",
|
|
"qualifiers": ["only qualifiers explicitly present in the turn/history"],
|
|
"ambiguous": "true when subtype materially changes the answer",
|
|
"clarify_question": "short Vietnamese clarification if ambiguous, else null",
|
|
},
|
|
"condition_relation": (
|
|
"indication | adverse_effect | contraindication | unknown. "
|
|
"DEFAULT is 'indication' — use it for the ordinary, most common case: "
|
|
"any question asking which drug to use/take/treat a condition or "
|
|
"symptom with (e.g. 'X thì dùng thuốc gì', 'bị X uống thuốc gì', "
|
|
"'thuốc trị X', 'thuốc chữa X'). Only deviate from 'indication' when "
|
|
"the question ITSELF contains an explicit reverse-direction phrase: "
|
|
"'thuốc nào gây X' -> adverse_effect; 'thuốc nào chống chỉ định ở X' -> "
|
|
"contraindication. Do not pick 'unknown' for an ordinary treatment "
|
|
"question just to hedge — 'unknown' is only for text that is not "
|
|
"readable as any of the other three at all."
|
|
),
|
|
"patient_context": {
|
|
"age_text": "age exactly as stated, else null",
|
|
"sex": "sex exactly/briefly as stated, else null",
|
|
"weight_kg": "number only when stated, else null",
|
|
"primary_condition": "the condition being treated, else null",
|
|
"comorbidities": ["diagnosed comorbidities explicitly stated"],
|
|
"allergies": ["drug/substance allergies explicitly stated"],
|
|
"previous_adverse_reactions": ["previous ADRs explicitly stated"],
|
|
"current_medications": ["current medicine names explicitly stated"],
|
|
"pregnancy_status": "pregnancy information explicitly stated, else null",
|
|
"breastfeeding": "true/false only when explicitly stated, else null",
|
|
"renal": {
|
|
"description": "renal condition wording, else null",
|
|
"ckd_stage": "e.g. G4, else null",
|
|
"egfr": "value with unit/text exactly as stated, else null",
|
|
"crcl": "value with unit/text exactly as stated, else null",
|
|
"creatinine": "value with unit/text exactly as stated, else null",
|
|
},
|
|
"hepatic": {
|
|
"description": "hepatic condition wording, else null",
|
|
"child_pugh": "class/score exactly as stated, else null",
|
|
"ast": "value exactly as stated, else null",
|
|
"alt": "value exactly as stated, else null",
|
|
"bilirubin": "value exactly as stated, else null",
|
|
},
|
|
"relevant_labs": ["other clinical labs exactly as stated"],
|
|
"treatment_history": ["treatments tried/failed exactly as stated"],
|
|
},
|
|
"context_action": (
|
|
"continue when this turn belongs to the same patient/case as recent "
|
|
"history; new when the user explicitly starts another case/patient/topic; "
|
|
"none when no patient case continuity is involved"
|
|
),
|
|
"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 = 18 # one per monograph section (see rag/sections.py SECTION_ORDER)
|
|
_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".
|
|
- MẶC ĐỊNH cho một BỆNH/TRIỆU CHỨNG đã nêu (không nhắc tên thuốc): câu hỏi
|
|
đang hỏi THUỐC NÀO DÙNG ĐỂ ĐIỀU TRỊ nó -> "condition_to_drug" (bệnh đã chẩn
|
|
đoán) hoặc "symptom_to_drug" (triệu chứng chưa phải chẩn đoán), điền
|
|
`condition`, `condition_relation="indication"`. Đây là cách đọc MẶC ĐỊNH —
|
|
mọi cách diễn đạt kiểu "bị/mắc [bệnh] thì/nên dùng/uống thuốc gì", "thuốc gì
|
|
trị/chữa [bệnh]", "[bệnh] uống thuốc gì" đều thuộc nhánh này, kể cả khi
|
|
không có từ "chỉ định". Không đánh đồng triệu chứng với bệnh đã chẩn đoán.
|
|
- Nếu hỏi một THUỐC đã nêu được chỉ định cho bệnh gì -> "drug_to_condition",
|
|
attribute="chi_dinh". Đây là chiều ngược với condition_to_drug.
|
|
- NGOẠI LỆ DUY NHẤT khỏi mặc định ở trên — turn_type="condition_relation" —
|
|
là câu hỏi tra NGƯỢC từ một bệnh/biến cố sang danh sách thuốc: "thuốc nào
|
|
GÂY tăng huyết áp", "thuốc nào CHỐNG CHỈ ĐỊNH ở bệnh nhân gout". Cụm
|
|
"chống chỉ định" tự nó KHÔNG đủ để chọn nhánh này: khi đã nêu một thuốc làm
|
|
đối tượng tra cứu, ví dụ "Chống chỉ định của Paracetamol là gì?" hoặc
|
|
"Probenecid có chống chỉ định gì?", phải là turn_type="drug_attribute",
|
|
attribute="chong_chi_dinh". Tương tự, "tác dụng không mong muốn của X" là
|
|
thuộc tính của thuốc X, không phải tra ngược. Chỉ gán condition_relation khi
|
|
chiều hỏi thực sự là bệnh/biến cố -> thuốc.
|
|
- Chuẩn hoá condition bảo thủ: "cao huyết áp"/"THA" -> "tăng huyết áp" khi
|
|
chắc chắn; giữ nguyên viết tắt mơ hồ. "Viêm gan", "ung thư", "nhiễm trùng"
|
|
không có subtype/vị trí là mơ hồ đáng kể -> ambiguous=true và hỏi làm rõ.
|
|
"Tăng huyết áp dùng thuốc gì?" không mơ hồ và không cần hỏi tuổi/xét nghiệm.
|
|
- Nếu câu hỏi có dữ liệu người bệnh, điền `patient_context` bằng ĐÚNG dữ kiện
|
|
được nêu; không suy ra field còn thiếu. Bệnh nền, thuốc đang dùng, dị ứng/ADR,
|
|
suy thận/gan, thai/cho bú và xét nghiệm là dữ liệu first-class, không bỏ vào
|
|
một chuỗi ghi chú chung.
|
|
- Chỉ đặt context_action="continue" khi lượt hiện tại thực sự tiếp tục CÙNG ca
|
|
bệnh trong lịch sử. Nếu người dùng nói ca mới/BN khác hoặc chuyển chủ đề độc
|
|
lập, đặt "new" và không mang dữ kiện bệnh nhân cũ sang.
|
|
- 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ố.
|
|
- NGOẠI LỆ về số lượng: khi CÂU HỎI đã nêu đúng MỘT thuốc cụ thể (drugs có đúng
|
|
một phần tử) nhưng KHÔNG nêu mục/thuộc tính nào (attribute=null, không hỏi
|
|
liều/chỉ định/tương tác/... cụ thể) — tức người dùng chỉ gõ tên thuốc — thì
|
|
"quick_replies" liệt kê TOÀN BỘ các mục chuyên luận của Dược thư, không giới
|
|
hạn ở 2-4: "Tên chung quốc tế", "Mã ATC", "Loại thuốc", "Dạng thuốc và hàm
|
|
lượng", "Dược lý và cơ chế tác dụng", "Chỉ định", "Chống chỉ định", "Thận
|
|
trọng", "Thời kỳ mang thai", "Thời kỳ cho con bú", "Tác dụng không mong
|
|
muốn", "Hướng dẫn xử trí ADR", "Liều lượng và cách dùng", "Tương tác thuốc",
|
|
"Độ ổn định và bảo quản", "Tương kỵ", "Quá liều và xử trí", "Thông tin quy
|
|
chế" — nguyên văn đúng 18 nhãn này, đúng thứ tự, không rút gọn, không tự
|
|
đổi chữ."""
|
|
|
|
|
|
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,
|
|
condition_normalizer: ConditionNormalizer | None = None,
|
|
) -> None:
|
|
self._llm = llm
|
|
self._catalog = catalog
|
|
self._resolver = resolver
|
|
self._condition_normalizer = condition_normalizer or ConditionNormalizer()
|
|
|
|
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:
|
|
turn_resolution = self._resolver.resolve(turn) if turn.strip() else None
|
|
resolved_turn_drug = (
|
|
turn_resolution.drug_id
|
|
if turn_resolution is not None
|
|
and turn_resolution.status == "resolved"
|
|
and turn_resolution.drug_id in self._catalog
|
|
else None
|
|
)
|
|
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",
|
|
)
|
|
frame = self._parse(raw_text, shown, turn)
|
|
frame = _apply_condition_candidate_cue(
|
|
frame, turn, self._condition_normalizer
|
|
)
|
|
frame = _apply_broad_condition_cue(
|
|
frame, turn, self._condition_normalizer
|
|
)
|
|
frame = _apply_reverse_relation_cues(frame, turn)
|
|
section_match = _SECTION_RESOLVER.resolve(turn)
|
|
frame = _apply_named_drug_cues(
|
|
frame,
|
|
turn,
|
|
resolved_turn_drug=resolved_turn_drug,
|
|
resolved_section_key=(section_match.section_key if section_match else None),
|
|
resolved_section_phrase=(section_match.phrase if section_match else None),
|
|
)
|
|
return _merge_with_prior_frame(frame, 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], original_turn: 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 ()
|
|
)
|
|
indication = _clean_str(data.get("indication"))
|
|
condition = _parse_condition(
|
|
data.get("condition"), indication, original_turn, self._condition_normalizer
|
|
)
|
|
relation = _clean_enum_value(
|
|
data.get("condition_relation"), ConditionRelation, ConditionRelation.INDICATION
|
|
)
|
|
patient_context = _parse_patient_context(
|
|
data.get("patient_context"),
|
|
fallback_age=_clean_str(data.get("age_text")),
|
|
fallback_weight=_clean_float(data.get("weight_kg")),
|
|
fallback_population=_clean_enum(data.get("population"), _ALLOWED_POPULATIONS),
|
|
)
|
|
context_action = _clean_enum_value(
|
|
data.get("context_action"), CaseContextAction, CaseContextAction.NONE
|
|
)
|
|
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=(condition.normalized_condition if condition else indication),
|
|
condition=condition,
|
|
condition_relation=relation,
|
|
patient_context=patient_context,
|
|
context_action=context_action,
|
|
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 {},
|
|
)
|
|
|
|
|
|
def _apply_reverse_relation_cues(frame: QueryFrame, turn: str) -> QueryFrame:
|
|
"""Fail closed on explicit reverse-relation wording.
|
|
|
|
The LLM remains responsible for open-ended language understanding. This
|
|
narrow post-condition only covers unambiguous surface forms where routing
|
|
to indication retrieval would reverse the requested relation. It contains
|
|
no disease or drug knowledge and never creates a candidate.
|
|
"""
|
|
text = f" {normalize_name(turn)} "
|
|
contraindication = any(
|
|
cue in text
|
|
for cue in (
|
|
" thuoc nao chong chi dinh ",
|
|
" nhung thuoc nao chong chi dinh ",
|
|
" thuoc nao can tranh o ",
|
|
" thuoc nao can tranh cho ",
|
|
)
|
|
)
|
|
adverse = any(
|
|
cue in text
|
|
for cue in (
|
|
" thuoc nao gay ",
|
|
" thuoc nao co the gay ",
|
|
" thuoc nao lam tang ",
|
|
" thuoc nao co adr ",
|
|
)
|
|
)
|
|
relation = (
|
|
ConditionRelation.CONTRAINDICATION
|
|
if contraindication
|
|
else ConditionRelation.ADVERSE_EFFECT
|
|
if adverse
|
|
else None
|
|
)
|
|
if relation is None:
|
|
return frame
|
|
return replace(
|
|
frame,
|
|
turn_type="condition_relation",
|
|
condition_relation=relation,
|
|
needs_clarify=False,
|
|
clarify_reason=None,
|
|
quick_replies=(),
|
|
)
|
|
|
|
|
|
def _apply_condition_candidate_cue(
|
|
frame: QueryFrame, turn: str, normalizer: ConditionNormalizer
|
|
) -> QueryFrame:
|
|
"""Keep current medicines subordinate in an explicit condition lookup."""
|
|
if frame.turn_type == "condition_to_drug" and frame.condition is not None:
|
|
return frame
|
|
condition = normalizer.detect_known_alias(turn)
|
|
if condition is None:
|
|
return frame
|
|
text = f" {normalize_name(turn)} "
|
|
candidate_cues = (
|
|
" dung thuoc gi ",
|
|
" dung thuoc nao ",
|
|
" thuoc nao can ",
|
|
" lua chon thuoc nao ",
|
|
" option ha ap ",
|
|
" option dieu tri ",
|
|
" ung vien nao ",
|
|
" cac ung vien nao ",
|
|
)
|
|
if not any(cue in text for cue in candidate_cues):
|
|
return frame
|
|
return replace(
|
|
frame,
|
|
turn_type="condition_to_drug",
|
|
indication=condition.normalized_condition,
|
|
condition=condition,
|
|
condition_relation=ConditionRelation.INDICATION,
|
|
needs_clarify=False,
|
|
clarify_reason=None,
|
|
quick_replies=(),
|
|
)
|
|
|
|
|
|
def _apply_broad_condition_cue(
|
|
frame: QueryFrame, turn: str, normalizer: ConditionNormalizer
|
|
) -> QueryFrame:
|
|
"""Make explicit broad disease→drug questions deterministically clarify."""
|
|
if frame.drugs:
|
|
return frame
|
|
condition = normalizer.detect_broad_question(turn)
|
|
if condition is None:
|
|
return frame
|
|
return replace(
|
|
frame,
|
|
turn_type="condition_to_drug",
|
|
indication=condition.normalized_condition,
|
|
condition=condition,
|
|
condition_relation=ConditionRelation.INDICATION,
|
|
needs_clarify=False,
|
|
clarify_reason=None,
|
|
quick_replies=(),
|
|
)
|
|
|
|
|
|
def _apply_named_drug_cues(
|
|
frame: QueryFrame,
|
|
turn: str,
|
|
resolved_turn_drug: str | None = None,
|
|
resolved_section_key: str | None = None,
|
|
resolved_section_phrase: str | None = None,
|
|
) -> QueryFrame:
|
|
"""A drug explicitly named as subject outranks reverse-condition wording."""
|
|
subject_drugs = frame.drugs or (
|
|
(resolved_turn_drug,) if resolved_turn_drug else ()
|
|
)
|
|
if not subject_drugs:
|
|
return frame
|
|
text = f" {normalize_name(turn)} "
|
|
explicit_reverse = any(
|
|
cue in text
|
|
for cue in (
|
|
" thuoc nao chong chi dinh ",
|
|
" nhung thuoc nao chong chi dinh ",
|
|
" thuoc nao can tranh o ",
|
|
" thuoc nao can tranh cho ",
|
|
" thuoc nao gay ",
|
|
" thuoc nao co the gay ",
|
|
" thuoc nao lam tang ",
|
|
" thuoc nao co adr ",
|
|
)
|
|
)
|
|
owned_section = bool(
|
|
resolved_turn_drug
|
|
and resolved_section_key
|
|
and resolved_section_phrase
|
|
and f" {normalize_name(resolved_section_phrase)} cua " in text
|
|
)
|
|
if owned_section and not explicit_reverse:
|
|
return replace(
|
|
frame,
|
|
turn_type="drug_attribute",
|
|
drugs=(resolved_turn_drug,),
|
|
attribute=resolved_section_key,
|
|
needs_clarify=False,
|
|
clarify_reason=None,
|
|
quick_replies=(),
|
|
)
|
|
purpose = (
|
|
" co tac dung gi " in text
|
|
and " tac dung khong mong muon " not in text
|
|
) or " dung de lam gi " in text
|
|
contraindication = not explicit_reverse and (
|
|
frame.attribute == "chong_chi_dinh"
|
|
or " chong chi dinh cua " in text
|
|
or " co chong chi dinh " in text
|
|
or " chong chi dinh gi " in text
|
|
or " chong chi dinh nao " in text
|
|
or " co dung duoc khong " in text
|
|
)
|
|
if purpose:
|
|
return replace(
|
|
frame,
|
|
turn_type="drug_to_condition",
|
|
drugs=subject_drugs,
|
|
attribute="chi_dinh",
|
|
condition_relation=ConditionRelation.INDICATION,
|
|
needs_clarify=False,
|
|
clarify_reason=None,
|
|
quick_replies=(),
|
|
)
|
|
if contraindication:
|
|
return replace(
|
|
frame,
|
|
turn_type="drug_attribute",
|
|
drugs=subject_drugs,
|
|
attribute="chong_chi_dinh",
|
|
needs_clarify=False,
|
|
clarify_reason=None,
|
|
quick_replies=(),
|
|
)
|
|
return frame
|
|
|
|
|
|
_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:
|
|
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}")
|
|
patient = prior_frame.patient_context
|
|
if patient.age_text:
|
|
parts.append(f"Tuổi bệnh nhân: {patient.age_text}")
|
|
if patient.sex:
|
|
parts.append(f"Giới: {patient.sex}")
|
|
if patient.comorbidities:
|
|
parts.append(f"Bệnh nền: {', '.join(patient.comorbidities)}")
|
|
if patient.allergies:
|
|
parts.append(f"Dị ứng: {', '.join(patient.allergies)}")
|
|
if patient.previous_adverse_reactions:
|
|
parts.append(f"ADR trước đây: {', '.join(patient.previous_adverse_reactions)}")
|
|
if patient.current_medications:
|
|
parts.append(f"Thuốc đang dùng: {', '.join(patient.current_medications)}")
|
|
if patient.renal.present:
|
|
parts.append(f"Thận: {patient.renal}")
|
|
if patient.hepatic.present:
|
|
parts.append(f"Gan: {patient.hepatic}")
|
|
if patient.pregnancy_status:
|
|
parts.append(f"Thai kỳ: {patient.pregnancy_status}")
|
|
if patient.breastfeeding is not None:
|
|
parts.append(f"Cho con bú: {patient.breastfeeding}")
|
|
if patient.relevant_labs:
|
|
parts.append(f"Xét nghiệm: {', '.join(patient.relevant_labs)}")
|
|
if not parts:
|
|
return ""
|
|
return (
|
|
"THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC (chỉ kế thừa nếu đây là CÙNG "
|
|
"ca bệnh và đặt context_action=continue; nếu ca mới/chủ đề mới phải đặt "
|
|
"context_action=new và bỏ qua toàn bộ khối; không hỏi lại dữ kiện đã có):\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:
|
|
return frame
|
|
continuing_case = (
|
|
frame.context_action == CaseContextAction.CONTINUE
|
|
or frame.depends_on_previous_turn
|
|
)
|
|
legacy_clarify = prior_frame.needs_clarify
|
|
if not continuing_case and not legacy_clarify:
|
|
return frame
|
|
if frame.context_action == CaseContextAction.NEW:
|
|
return frame
|
|
if frame.drugs and frame.drugs != prior_frame.drugs and not continuing_case:
|
|
return frame
|
|
patient_context = frame.patient_context
|
|
if continuing_case:
|
|
patient_context = patient_context.merged_with(prior_frame.patient_context)
|
|
condition = frame.condition
|
|
indication = frame.indication
|
|
if continuing_case and condition is None:
|
|
condition = prior_frame.condition
|
|
indication = indication or prior_frame.indication
|
|
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=indication or prior_frame.indication,
|
|
condition=condition,
|
|
patient_context=patient_context,
|
|
attribute=frame.attribute or prior_frame.attribute,
|
|
)
|
|
|
|
|
|
def _parse_condition(
|
|
value,
|
|
indication: str | None,
|
|
original_turn: str,
|
|
normalizer: ConditionNormalizer,
|
|
) -> ConditionQuery | None:
|
|
data = value if isinstance(value, dict) else {}
|
|
raw = (
|
|
_clean_str(data.get("normalized_condition"))
|
|
or _clean_str(data.get("original_text"))
|
|
or indication
|
|
)
|
|
if raw is None:
|
|
return None
|
|
return normalizer.normalize(
|
|
original_query=original_turn,
|
|
condition=raw,
|
|
subtype=_clean_str(data.get("subtype")),
|
|
qualifiers=tuple(_as_list(data.get("qualifiers"))),
|
|
ambiguous=data.get("ambiguous") is True,
|
|
clarify_question=_clean_str(data.get("clarify_question")),
|
|
)
|
|
|
|
|
|
def _parse_patient_context(
|
|
value,
|
|
*,
|
|
fallback_age: str | None,
|
|
fallback_weight: float | None,
|
|
fallback_population: str | None,
|
|
) -> PatientContext:
|
|
data = value if isinstance(value, dict) else {}
|
|
renal_data = data.get("renal") if isinstance(data.get("renal"), dict) else {}
|
|
hepatic_data = (
|
|
data.get("hepatic") if isinstance(data.get("hepatic"), dict) else {}
|
|
)
|
|
pregnancy = _clean_str(data.get("pregnancy_status"))
|
|
breastfeeding = _clean_bool(data.get("breastfeeding"))
|
|
renal_description = _clean_str(renal_data.get("description"))
|
|
hepatic_description = _clean_str(hepatic_data.get("description"))
|
|
if fallback_population == "phu_nu_co_thai" and pregnancy is None:
|
|
pregnancy = "mang thai"
|
|
if fallback_population == "phu_nu_cho_con_bu" and breastfeeding is None:
|
|
breastfeeding = True
|
|
if fallback_population == "suy_than" and renal_description is None:
|
|
renal_description = "suy thận"
|
|
if fallback_population == "suy_gan" and hepatic_description is None:
|
|
hepatic_description = "suy gan"
|
|
return PatientContext(
|
|
age_text=_clean_str(data.get("age_text")) or fallback_age,
|
|
sex=_clean_str(data.get("sex")),
|
|
weight_kg=_clean_float(data.get("weight_kg")) or fallback_weight,
|
|
primary_condition=_clean_str(data.get("primary_condition")),
|
|
comorbidities=tuple(_as_list(data.get("comorbidities"))),
|
|
allergies=tuple(_as_list(data.get("allergies"))),
|
|
previous_adverse_reactions=tuple(
|
|
_as_list(data.get("previous_adverse_reactions"))
|
|
),
|
|
current_medications=tuple(_as_list(data.get("current_medications"))),
|
|
pregnancy_status=pregnancy,
|
|
breastfeeding=breastfeeding,
|
|
renal=RenalContext(
|
|
description=renal_description,
|
|
ckd_stage=_clean_str(renal_data.get("ckd_stage")),
|
|
egfr=_clean_str(renal_data.get("egfr")),
|
|
crcl=_clean_str(renal_data.get("crcl")),
|
|
creatinine=_clean_str(renal_data.get("creatinine")),
|
|
),
|
|
hepatic=HepaticContext(
|
|
description=hepatic_description,
|
|
child_pugh=_clean_str(hepatic_data.get("child_pugh")),
|
|
ast=_clean_str(hepatic_data.get("ast")),
|
|
alt=_clean_str(hepatic_data.get("alt")),
|
|
bilirubin=_clean_str(hepatic_data.get("bilirubin")),
|
|
),
|
|
relevant_labs=tuple(_as_list(data.get("relevant_labs"))),
|
|
treatment_history=tuple(_as_list(data.get("treatment_history"))),
|
|
)
|
|
|
|
|
|
def _clean_enum_value(value, enum_type, default):
|
|
cleaned = _clean_str(value)
|
|
if cleaned is None:
|
|
return default
|
|
try:
|
|
return enum_type(cleaned)
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def _clean_bool(value) -> bool | None:
|
|
return value if isinstance(value, bool) else None
|
|
|
|
|
|
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
|