Files
duocthu/apps/ai-service/rag/understanding.py
T
2026-08-14 11:57:58 +07:00

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 '' 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"
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"/""
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