Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
"""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
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
# 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
|
||||
needs_clarify: bool = False
|
||||
clarify_reason: 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",
|
||||
"needs_clarify": "true only if the turn cannot be acted on without more info",
|
||||
"clarify_reason": "short Vietnamese question to ask, or null",
|
||||
}
|
||||
|
||||
_SYSTEM = """Bạn là bộ HIỂU CÂU HỎI cho một chatbot tra cứu Dược thư Quốc gia Việt Nam.
|
||||
Nhiệm vụ: đọc câu hỏi (tiếng Việt, có thể sai chính tả, viết tắt, nhiều lượt) và
|
||||
XUẤT RA một JSON mô tả ý định. TUYỆT ĐỐI KHÔNG trả lời câu hỏi y khoa, không nêu liều.
|
||||
|
||||
Quy tắc bắt buộc:
|
||||
- Trường "drugs" CHỈ được chứa các drug_id có trong DANH SÁCH THUỐC được cung cấp.
|
||||
Nếu người dùng nhắc một thuốc KHÔNG có trong danh sách (kể cả tên bịa như
|
||||
"aspirinol"), đưa tên đó vào "unknown_drugs", KHÔNG được gán sang thuốc gần giống.
|
||||
- Sai chính tả một thuốc CÓ trong danh sách thì sửa về đúng drug_id của nó
|
||||
(ví dụ "amoxicillin" -> "amoxicilin", "metfomin" -> "metformin").
|
||||
- Nếu câu nhắc 2 thuốc trở lên và hỏi về dùng chung/tương tác -> turn_type="interaction".
|
||||
- Nếu là triệu chứng/bệnh cần gợi ý thuốc (không nêu tên thuốc) -> "symptom_to_drug",
|
||||
điền "indication".
|
||||
- Nếu hỏi liều cần cân nặng/tuổi -> "dosing_calc", điền weight_kg/age_text nếu có.
|
||||
Nói cân nặng kiểu thường ngày ("bé 30 cân", "nặng 30 ký", chỉ 1 số + "cân"/"ký"
|
||||
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
|
||||
- Lượt nối tiếp ("còn liều thì sao", "nó dùng cho trẻ em?") -> dùng LỊCH SỬ để biết
|
||||
thuốc đang nói tới và điền vào "drugs".
|
||||
- Chào hỏi/vu vơ -> "smalltalk". Ngoài phạm vi chuyên luận thuốc -> "out_of_scope"."""
|
||||
|
||||
|
||||
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] = ()
|
||||
) -> 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] = ()) -> 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ó)"
|
||||
)
|
||||
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"{history_block}\n\n"
|
||||
f"CÂU HỎI HIỆN TẠI: {turn}"
|
||||
)
|
||||
raw_text = self._llm.generate(_SYSTEM, user, FRAME_SCHEMA)
|
||||
return self._parse(raw_text, shown)
|
||||
|
||||
@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.
|
||||
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é?",
|
||||
)
|
||||
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")),
|
||||
needs_clarify=bool(data.get("needs_clarify")),
|
||||
clarify_reason=_clean_str(data.get("clarify_reason")),
|
||||
raw=data if isinstance(data, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user