Files
duocthu/apps/ai-service/rag/understanding.py
T

539 lines
28 KiB
Python

"""LLM-driven query understanding — the front-end of the RAG chatbot.
The old drug-first path resolved a drug with a fuzzy `SequenceMatcher` and routed
sections with a Vietnamese phrase table. Both are brittle string heuristics: they
false-matched a made-up name (``aspirinol`` -> aspirin), tied on a correctly
spelled English INN (``amoxicillin``), and mistook a common word (``uống``) for a
drug. This module replaces that with the model the system already has.
Division of labour, and why it is safe for a formulary:
- The **LLM** does the messy human-language part — which token is a drug, is this
an interaction / a symptom lookup / a weight-based dose, what section is asked,
what population/weight. It is good at exactly the fuzziness the heuristics were
bad at.
- The **catalog** stays the authority on drug *identity*. The model may only pick
``drug_id`` values from a list *bounded before the model ever runs* — a
deterministic alias/fuzzy pass over the turn and history (`CandidateSource`)
decides which real drugs are even plausible candidates, and only those are
shown. This closes a gap the catalog-whitelist alone did not (F-04, Codex
2026-08-06 review): validating that an output id is *some* real drug_id does
not prove it is the *one the user's text actually named* — an LLM could
satisfy that whitelist while mapping an unrelated or invented name to any of
the other 683 real drugs. Bounding candidates first removes that degree of
freedom: the model can still read ``amoxicillin`` as ``amoxicilin`` (a fuzzy
match puts it in the candidate set) but cannot map ``aspirinol`` to aspirin,
because nothing about ``aspirinol`` fuzzy-matches anything and the candidate
set the model is shown is empty or contains unrelated drugs, not aspirin.
- Nothing here answers the medical question. It only produces a structured frame;
retrieval + ``grounding.verify`` remain the load-bearing safety layer downstream.
`rag/` imports no SDK: the LLM is injected as a ``JsonLlm`` protocol (satisfied by
`adapters.bedrock_converse.BedrockConverseAnswerGenerator`), and a deterministic
stub runs the whole path offline in tests.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field, replace
from typing import Protocol, Sequence
from .budget import RequestBudget
from .ports import AnswerGenerationUnavailable
logger = logging.getLogger(__name__)
# The 19 monograph section keys, kept here as the closed vocabulary the model may
# use for `attribute`. Adding a new section is one entry, not a code change.
SECTION_KEYS = (
"ten_chung_quoc_te",
"ten_thuong_mai",
"ma_atc",
"loai_thuoc",
"dang_thuoc_va_ham_luong",
"duoc_ly_va_co_che_tac_dung",
"chi_dinh",
"chong_chi_dinh",
"than_trong",
"thoi_ky_mang_thai",
"thoi_ky_cho_con_bu",
"tac_dung_khong_mong_muon",
"huong_dan_xu_tri_adr",
"lieu_luong_va_cach_dung",
"tuong_tac_thuoc",
"qua_lieu_va_xu_tri",
"do_on_dinh_va_bao_quan",
"tuong_ky",
"thong_tin_quy_che",
)
# Short glosses shown to the model alongside each key. Found live 2026-08-06
# (golden e2e set): a bare key list gives the model nothing to disambiguate
# "thận trọng" from "chống chỉ định" — 9/9 live calls for "X cần thận trọng
# gì?" picked chong_chi_dinh, silently answering from the wrong section
# (and, downstream, dropping the specific safety content the precautions
# section actually has, e.g. metformin's lactic acidosis warning). The two
# are genuinely adjacent concepts in Vietnamese medical text; a bare slug
# name is not enough to tell a model which one a question means.
SECTION_KEY_HINTS: dict[str, str] = {
"ten_chung_quoc_te": "tên chung quốc tế/INN",
"ten_thuong_mai": "tên thương mại/biệt dược",
"ma_atc": "mã ATC",
"loai_thuoc": "phân loại thuốc",
"dang_thuoc_va_ham_luong": "dạng bào chế và hàm lượng",
"duoc_ly_va_co_che_tac_dung": "dược lý, cơ chế tác dụng",
"chi_dinh": "chỉ định — bệnh/triệu chứng thuốc dùng để điều trị",
"chong_chi_dinh": (
"CHỐNG CHỈ ĐỊNH — trường hợp TUYỆT ĐỐI KHÔNG được dùng thuốc này"
),
"than_trong": (
"THẬN TRỌNG — KHÁC chống chỉ định: vẫn dùng được nhưng cần cảnh "
"giác/theo dõi/chỉnh liều (ví dụ nguy cơ nhiễm toan lactic của "
"metformin, độc tính thận/tai của gentamicin). Câu hỏi có chữ "
"\"thận trọng\", \"cẩn thận\", \"lưu ý gì\", \"cần chú ý\" → key này, "
"KHÔNG PHẢI chong_chi_dinh."
),
"thoi_ky_mang_thai": "dùng khi mang thai",
"thoi_ky_cho_con_bu": "dùng khi cho con bú",
"tac_dung_khong_mong_muon": "tác dụng phụ/ADR",
"huong_dan_xu_tri_adr": "cách xử trí khi gặp ADR",
"lieu_luong_va_cach_dung": "liều dùng và cách dùng",
"tuong_tac_thuoc": "tương tác với thuốc khác",
"qua_lieu_va_xu_tri": "quá liều và cách xử trí",
"do_on_dinh_va_bao_quan": "độ ổn định, bảo quản",
"tuong_ky": "tương kỵ (không pha/trộn được với gì)",
"thong_tin_quy_che": "thông tin quy chế/pháp lý",
}
# What kind of turn this is — the router branches on it. Deliberately explicit so a
# symptom lookup is never silently treated as a failed drug lookup, and a two-drug
# interaction never collapses to an "ambiguous drug" abstain.
TURN_TYPES = (
"drug_attribute", # one drug, one/more sections ("liều paracetamol")
"drug_overview", # a bare drug name, wants the monograph
"interaction", # 2+ drugs, asks about combining them
"symptom_to_drug", # a symptom/indication, wants candidate drugs
"dosing_calc", # a dose that needs weight/age arithmetic
"smalltalk", # greeting / meta, not a medical query
"out_of_scope", # not answerable from the Part-2 monographs
)
@dataclass(frozen=True)
class QueryFrame:
"""The structured reading of one user turn. No medical content, only intent."""
turn_type: str
drugs: tuple[str, ...] = () # canonical drug_ids, from the catalog only
unknown_drugs: tuple[str, ...] = () # mentioned, not in the catalog
attribute: str | None = None # a SECTION_KEYS value, or None
population: str | None = None # e.g. "tre_em", "nguoi_lon", "suy_than"
weight_kg: float | None = None
age_text: str | None = None
indication: str | None = None # symptom/disease, for symptom_to_drug
route: str | None = None # e.g. "uong", "tiem_tinh_mach", "dat_truc_trang"
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",
"route": (
"route of administration if stated or implied, normalized to one of: "
"uong | tiem_tinh_mach | tiem_bap | tiem_duoi_da | dat_truc_trang | "
"boi_ngoai_da | nho_mat | nho_mui | khac, else null. A bare reply like "
"'uống' or 'tiêm' to your own prior clarify question about route IS "
"this field — read it here, do not leave it null and re-ask."
),
"needs_clarify": "true only if the turn cannot be acted on without more info",
"clarify_reason": "short Vietnamese question to ask, or null",
"quick_replies": (
"2-4 short suggested replies (each under ~20 chars) to your OWN "
"clarify_reason, for the user to tap instead of typing — ONLY when "
"clarify_reason genuinely has a few natural discrete answers (e.g. "
"['Người lớn', 'Trẻ em'] or ['Uống', 'Tiêm']). Empty list [] if the "
"missing detail needs a specific free-form value (e.g. an exact "
"weight) with no clean short options — never invent numeric-ish "
"options."
),
}
_SYSTEM = """Bạn là bộ HIỂU CÂU HỎI cho một chatbot tra cứu Dược thư Quốc gia Việt Nam.
Nhiệm vụ: đọc câu hỏi (tiếng Việt, có thể sai chính tả, viết tắt, nhiều lượt) và
XUẤT RA một JSON mô tả ý định. TUYỆT ĐỐI KHÔNG trả lời câu hỏi y khoa, không nêu liều.
Quy tắc bắt buộc:
- Trường "drugs" CHỈ được chứa các drug_id có trong DANH SÁCH THUỐC được cung cấp.
Nếu người dùng nhắc một thuốc KHÔNG có trong danh sách (kể cả tên bịa như
"aspirinol"), đưa tên đó vào "unknown_drugs", KHÔNG được gán sang thuốc gần giống.
- Sai chính tả một thuốc CÓ trong danh sách thì sửa về đúng drug_id của nó
(ví dụ "amoxicillin" -> "amoxicilin", "metfomin" -> "metformin").
- Nếu câu nhắc 2 thuốc trở lên và hỏi về dùng chung/tương tác -> turn_type="interaction".
- Nếu là triệu chứng/bệnh cần gợi ý thuốc (không nêu tên thuốc) -> "symptom_to_drug",
điền "indication".
- Nếu hỏi liều cần cân nặng/tuổi -> "dosing_calc", điền weight_kg/age_text nếu có.
Nói cân nặng kiểu thường ngày ("bé 30 cân", "nặng 30 ký", chỉ 1 số + "cân"/""
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
- Lượt nối tiếp ("còn liều thì sao", "nó dùng cho trẻ em?") -> dùng LỊCH SỬ để biết
thuốc đang nói tới và điền vào "drugs".
- QUAN TRỌNG — lượt hiện tại trả lời câu hỏi bạn VỪA hỏi: nếu dòng "Trợ lý:" cuối
cùng trong LỊCH SỬ là một câu hỏi làm rõ (vd "Người lớn hay trẻ em?", "Uống hay
tiêm?", "Cân nặng bao nhiêu kg?"), và CÂU HỎI HIỆN TẠI là một câu trả lời ngắn
hợp lý cho đúng câu đó (vd "Uống", "Người lớn", "30kg") — hãy đọc nó là câu trả
lời, điền vào field tương ứng (route/population/weight_kg/age_text), giữ lại các
field đã biết từ các lượt trước đó trong LỊCH SỬ (đừng bỏ trống lại), và CHỈ đặt
needs_clarify=true với PHẦN THÔNG TIN CÒN THIẾU KHÁC (nếu có) — TUYỆT ĐỐI KHÔNG
lặp lại nguyên văn clarify_reason đã được trả lời. Nếu sau khi điền, đã đủ dữ
kiện (đối tượng + đường dùng, và tuổi/cân nặng nếu là trẻ em) thì needs_clarify=false.
Nếu có khối "THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC" bên dưới, các mục đó ĐÃ
ĐƯỢC XÁC NHẬN — không hỏi lại, không đặt needs_clarify=true vì thiếu đúng mục
đã liệt kê ở đó.
- NGƯỢC LẠI — nếu CÂU HỎI HIỆN TẠI là một câu hỏi y khoa MỚI, KHÔNG phải một câu
trả lời ngắn cho câu hỏi làm rõ gần nhất (không khớp loại thông tin vừa hỏi) và
KHÔNG nhắc lại thuốc/triệu chứng nào đã có trong LỊCH SỬ hay khối "THÔNG TIN ĐÃ
XÁC ĐỊNH": đây là LƯỢT MỚI HOÀN TOÀN — TUYỆT ĐỐI KHÔNG mang "drugs"/
"population"/"weight_kg"/"age_text"/"route"/"indication" của lượt trước sang lượt
này, chỉ điền những gì thực sự có trong CÂU HỎI HIỆN TẠI. Ví dụ: lượt trước đang
hỏi về Omeprazol nhưng câu hiện tại là "tôi bị đau đầu nên uống thuốc gì" (không
nhắc Omeprazol) -> chủ đề mới, "drugs" phải để trống trừ khi có thuốc thực sự
được nhắc trong câu này.
- Nếu CÂU HỎI HIỆN TẠI là một lời PHỦ ĐỊNH/SỬA LẠI câu trả lời vừa rồi (vd "tôi
có hỏi X đâu", "tôi không hỏi vậy", "đâu phải thế", "ý tôi không phải vậy",
"sai rồi") — đây là dấu hiệu bạn vừa hiểu SAI ý người dùng ở lượt trước.
TUYỆT ĐỐI KHÔNG lặp lại đúng route/population/thuộc tính vừa trả lời (đã bị
từ chối): đặt needs_clarify=true và hỏi lại thật ngắn gọn, cụ thể người dùng
thực sự muốn hỏi điều gì (vd "Anh/chị muốn hỏi đường dùng nào ạ?"), không tự
suy đoán lại giá trị cũ.
- Chào hỏi/vu vơ -> "smalltalk". Ngoài phạm vi chuyên luận thuốc -> "out_of_scope".
- Khi needs_clarify=true, kèm "quick_replies": 2-4 phương án NGẮN cho câu hỏi lại
đó, CHỈ khi nó thực sự có vài lựa chọn rời rạc tự nhiên (vd đối tượng: "Người
lớn"/"Trẻ em"). Để mảng rỗng nếu cần một giá trị cụ thể không có lựa chọn ngắn
(vd hỏi cân nặng chính xác) — không bịa phương án dạng số."""
class JsonLlm(Protocol):
"""A model that returns a single JSON object as text. Satisfied by the
existing Bedrock Converse generator, so this adds no SDK to `rag/`."""
def generate(self, system: str, user: str, schema: dict) -> str: ...
class QueryUnderstander(Protocol):
def understand(
self,
turn: str,
history: Sequence[str] = (),
budget: RequestBudget | None = None,
prior_frame: QueryFrame | None = None,
) -> QueryFrame: ...
class CandidateSource(Protocol):
"""Deterministic, no-LLM drug-name matching — what bounds the model's
choice before it ever runs (F-04). Satisfied by `routing.CatalogDrugResolver`;
kept as a protocol (not an import of it) so this module stays decoupled
from the fuzzy-matching implementation, only its shape.
"""
def resolve(self, query: str): ...
def suggest(
self, query: str, k: int = 3, min_score: float = 0.5
) -> list[tuple[str, float]]: ...
class LlmQueryUnderstander:
"""Turns a raw user turn into a `QueryFrame` with one LLM call.
`catalog` maps drug_id -> a human name (used only to label whichever
candidates get shown). `resolver` is what actually decides which real
drugs are plausible for this turn, deterministically, before the model
runs at all: every drug_id an exact-alias or fuzzy match finds anywhere
in the turn or the raw history text. The model then picks only among
those — never the full ~684-drug catalog — so it structurally cannot
map an invented or unrelated name to some other real drug_id it merely
happens to also list correctly (F-04). This also directly answers a
separate 2026-08-06 review finding: sending the full catalog on every
turn is unbounded token cost; a per-turn candidate shortlist is both
safer and cheaper.
"""
def __init__(self, llm: JsonLlm, catalog: dict[str, str], resolver: CandidateSource) -> None:
self._llm = llm
self._catalog = catalog
self._resolver = resolver
def _candidate_ids(self, turn: str, history: Sequence[str]) -> set[str]:
"""Every drug_id a deterministic pass finds plausible in the turn or
the raw history text. Deliberately generous — an exact alias match
plus a fuzzy `suggest` well below the resolver's own auto-answer
threshold — because the job here is only to rule out drugs nothing
in the conversation plausibly refers to, not to pick the right one;
that disambiguation is still the model's job, within this bound.
"""
ids: set[str] = set()
for line in (turn, *history):
if not line.strip():
continue
resolution = self._resolver.resolve(line)
if resolution.status == "resolved" and resolution.drug_id:
ids.add(resolution.drug_id)
elif resolution.status == "ambiguous":
ids.update(resolution.candidate_drug_ids)
for drug_id, _score in self._resolver.suggest(line, k=5, min_score=0.55):
ids.add(drug_id)
return ids
def understand(
self,
turn: str,
history: Sequence[str] = (),
budget: RequestBudget | None = None,
prior_frame: QueryFrame | None = None,
) -> QueryFrame:
shown = {
drug_id: self._catalog[drug_id]
for drug_id in self._candidate_ids(turn, history)
if drug_id in self._catalog
}
catalog_block = (
"\n".join(f"{drug_id}\t{name}" for drug_id, name in sorted(shown.items()))
if shown
else "(không có thuốc nào trong Dược thư khớp với lượt này hoặc lịch sử gần đây)"
)
history_block = (
"LỊCH SỬ HỘI THOẠI (cũ -> mới):\n" + "\n".join(history)
if history else "LỊCH SỬ HỘI THOẠI: (chưa có)"
)
known_block = _known_facts_block(prior_frame)
user = (
f"DANH SÁCH THUỐC ỨNG VIÊN cho lượt này (drug_id\\ttên) — CHỈ được chọn "
f"drug_id từ đây, đây KHÔNG phải toàn bộ Dược thư, chỉ là các thuốc khớp "
f"với chữ trong lượt/lịch sử:\n"
f"{catalog_block}\n\n"
"CÁC SECTION KEY hợp lệ cho 'attribute' (key: ý nghĩa):\n"
+ "\n".join(f"{key}: {SECTION_KEY_HINTS[key]}" for key in SECTION_KEYS)
+ "\n\n"
f"{known_block}"
f"{history_block}\n\n"
f"CÂU HỎI HIỆN TẠI: {turn}"
)
# Found live 2026-08-07 (F-10 adversarial pass): unlike every other
# LLM call site in this product (`answer.py`'s sufficiency/generate/
# entailment all catch this), this one call had no error handling at
# all — a provider outage here propagated straight through
# `RagAgent.handle()` and `routers/rag.py` (which only wraps the
# trace-save call, not `agent.handle()`) into an unhandled 500,
# rather than the graceful abstain every other failure mode gets.
try:
if budget is not None:
budget.require()
raw_text = self._llm.generate(_SYSTEM, user, FRAME_SCHEMA)
except AnswerGenerationUnavailable as exc:
# Found live 2026-08-07: this except block silently swallowed
# the real exception entirely — no log line anywhere — so a
# genuine provider outage (throttling, timeout, IAM, whatever)
# left zero trace to diagnose from. Now logged with the actual
# exception, and tagged with a `system_error` code distinct from
# an ordinary clarify (see `QueryFrame.system_error`).
logger.warning(
"understanding call failed (%s): %s", type(exc).__name__, exc
)
return QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Dịch vụ đang gặp sự cố tạm thời, vui lòng thử lại "
"sau ít phút.",
system_error="understanding_provider_unavailable",
)
return _merge_with_prior_frame(self._parse(raw_text, shown), prior_frame)
@staticmethod
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
if value in shown:
return value
# `drug_id` is shown with underscores ("paracetamol_acetaminophen")
# but its own canonical display name (bootstrap's `_catalog_names`)
# is the same string with spaces — found live 2026-08-06: the two
# look near-identical in the "drug_id\tname" table, and the model
# sometimes echoes the spaced display form instead of the id. This
# is a deterministic, lossless formatting difference (not a fuzzy
# match — one specific known substitution), so it's tolerated here
# rather than dropping a correctly-identified drug to unknown.
spaced = value.strip().casefold()
for drug_id in shown:
if drug_id.replace("_", " ").casefold() == spaced:
return drug_id
return None
def _parse(self, raw_text: str, shown: dict[str, str]) -> QueryFrame:
try:
data = json.loads(raw_text)
except (json.JSONDecodeError, TypeError):
# Fail closed to a clarify rather than to a wrong reading. Not a
# provider outage (the call succeeded) — the model's own output
# didn't parse, a distinct, separately diagnosable cause.
logger.warning("understanding call returned unparseable JSON: %r", raw_text)
return QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Xin lỗi, tôi chưa hiểu rõ câu hỏi. Anh/chị hỏi lại giúp nhé?",
system_error="understanding_malformed_output",
)
resolved = [
(d, self._resolve_id(d, shown)) for d in _as_list(data.get("drugs"))
]
drugs = tuple(dict.fromkeys(rid for _, rid in resolved if rid is not None))
# A drug the model named but that resolves to no id among the shown
# candidates (exact or underscore/space form) is unknown, not a
# silent drop and not a fuzzy substitution to an unrelated drug.
unknown = tuple(
d for d, rid in resolved if rid is None
) + tuple(_as_list(data.get("unknown_drugs")))
attribute = data.get("attribute")
if attribute not in SECTION_KEYS:
attribute = None
turn_type = data.get("turn_type")
if turn_type not in TURN_TYPES:
turn_type = "drug_attribute" if drugs else "out_of_scope"
return QueryFrame(
turn_type=turn_type,
drugs=drugs,
unknown_drugs=tuple(dict.fromkeys(unknown)),
attribute=attribute,
population=_clean_str(data.get("population")),
weight_kg=_clean_float(data.get("weight_kg")),
age_text=_clean_str(data.get("age_text")),
indication=_clean_str(data.get("indication")),
route=_clean_str(data.get("route")),
needs_clarify=bool(data.get("needs_clarify")),
clarify_reason=_clean_str(data.get("clarify_reason")),
quick_replies=tuple(_as_list(data.get("quick_replies"))),
raw=data if isinstance(data, dict) else {},
)
_KNOWN_FACT_LABELS: tuple[tuple[str, str], ...] = (
("population", "Đối tượng"),
("age_text", "Tuổi"),
("route", "Đường dùng"),
("indication", "Chỉ định/triệu chứng"),
("attribute", "Thuộc tính đang tra"),
)
def _known_facts_block(prior_frame: QueryFrame | None) -> str:
"""The structured "already established" summary shown to the model on a
clarify-continuation turn.
Found live 2026-08-07 (50-question hand-typed browser audit): relying on
the model to re-derive the WHOLE frame from raw text history every turn
is fragile — reproduced 3 times independently (Insulin storage, weight-
based Azithromycin dosing, a headache question mislabeled OMEPRAZOL) as
either a non-terminating re-ask of an already-answered clarify question,
or a stale drug bleeding into an unrelated new topic. Stating the known
fields explicitly, as data rather than asking the model to infer them
from a growing text transcript, removes most of the guesswork; `_merge_
with_prior_frame` below is the code-level backstop for whatever the
model still drops.
"""
if prior_frame is None or not prior_frame.needs_clarify:
return ""
parts = []
if prior_frame.drugs:
parts.append(f"Thuốc: {', '.join(prior_frame.drugs)}")
if prior_frame.weight_kg is not None:
parts.append(f"Cân nặng: {prior_frame.weight_kg:g} kg")
for field_name, label in _KNOWN_FACT_LABELS:
value = getattr(prior_frame, field_name)
if value:
parts.append(f"{label}: {value}")
if not parts:
return ""
return (
"THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC (dữ liệu CÓ THẬT, đã xác nhận "
"— KHÔNG hỏi lại các mục này; nếu câu hỏi hiện tại là một chủ đề mới "
"không liên quan, hãy bỏ qua khối này thay vì gán nhầm vào lượt mới):\n"
+ "\n".join(parts) + "\n\n"
)
def _merge_with_prior_frame(frame: QueryFrame, prior_frame: QueryFrame | None) -> QueryFrame:
"""Code-level backstop for the model dropping an already-known field.
Only fires when the prior turn was itself a clarify (there is something
to continue) and this turn's own `drugs` extraction agrees with it (empty,
meaning a short reply like "20kg"/"Uống" that names no drug of its own, or
an exact match) — a turn that resolves a DIFFERENT drug is a genuine topic
change and must not inherit stale population/weight/route/indication from
the old one (the headache/OMEPRAZOL bleed this guards against runs the
other way: don't let old fields survive into an unrelated new drug either).
"""
if prior_frame is None or not prior_frame.needs_clarify:
return frame
if frame.drugs and frame.drugs != prior_frame.drugs:
return frame
return replace(
frame,
drugs=frame.drugs or prior_frame.drugs,
population=frame.population or prior_frame.population,
age_text=frame.age_text or prior_frame.age_text,
weight_kg=frame.weight_kg if frame.weight_kg is not None else prior_frame.weight_kg,
route=frame.route or prior_frame.route,
indication=frame.indication or prior_frame.indication,
attribute=frame.attribute or prior_frame.attribute,
)
def _as_list(value) -> list[str]:
if isinstance(value, str):
return [value] if value.strip() else []
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_float(value) -> float | None:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.replace(",", ".").split()[0])
except (ValueError, IndexError):
return None
return None