Wire the guarded conversational RAG answer layer end-to-end
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"""Resolve an attribute question to the monograph section that answers it.
|
||||
|
||||
Measured 2026-08-04: letting vector similarity choose the section gives
|
||||
hit@1 0.544 overall and **0.05 on `chong_chi_dinh`**, because
|
||||
`duoc_ly_va_co_che_tac_dung` is the largest section and describes the drug in
|
||||
general terms, so it sits close to almost any question about that drug. A
|
||||
question that names its own attribute does not need similarity to guess.
|
||||
|
||||
Two rules make this safe:
|
||||
|
||||
**Longest phrase wins.** "chống chỉ định" and "chỉ định" differ by one prefix
|
||||
word and mean opposite things clinically. Ordering by phrase length means the
|
||||
contraindication phrase is tested first and the indication phrase can never
|
||||
capture it. The same rule keeps "quá liều" from being read as "liều" and
|
||||
"hướng dẫn xử trí ADR" from being read as "tác dụng phụ".
|
||||
|
||||
**No match is not a guess.** An unrecognised question returns `None` and the
|
||||
caller falls back to similarity search. This layer never picks a section it is
|
||||
not sure of.
|
||||
|
||||
Adding a section or a phrasing means adding an entry to `SECTION_PHRASES` —
|
||||
never editing the matching code (CLAUDE.md, open/closed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .text import normalize_name
|
||||
|
||||
# Phrases a clinician would actually type. Order within a list does not matter;
|
||||
# the resolver sorts every phrase by length across all sections.
|
||||
SECTION_PHRASES: dict[str, tuple[str, ...]] = {
|
||||
"chong_chi_dinh": (
|
||||
"chống chỉ định",
|
||||
"không được dùng cho",
|
||||
"không được dùng khi",
|
||||
"cấm dùng",
|
||||
),
|
||||
"chi_dinh": (
|
||||
"chỉ định",
|
||||
"dùng để điều trị",
|
||||
"dùng trong trường hợp nào",
|
||||
"điều trị bệnh gì",
|
||||
"dùng khi nào",
|
||||
),
|
||||
"lieu_luong_va_cach_dung": (
|
||||
"liều lượng và cách dùng",
|
||||
"liều lượng",
|
||||
"liều dùng",
|
||||
"cách dùng",
|
||||
"dùng liều",
|
||||
"uống bao nhiêu",
|
||||
"tiêm bao nhiêu",
|
||||
# Bare "liều" is safe only because longer phrases are tested first:
|
||||
# "quá liều" and "xử trí quá liều" both contain it and both win.
|
||||
# Measured need: 4 of 16 human-written golden questions say just
|
||||
# "Liều Metformin cho người lớn?".
|
||||
"liều",
|
||||
),
|
||||
"than_trong": (
|
||||
"thận trọng",
|
||||
"cần lưu ý gì",
|
||||
"lưu ý khi dùng",
|
||||
),
|
||||
"tac_dung_khong_mong_muon": (
|
||||
"tác dụng không mong muốn",
|
||||
"tác dụng phụ",
|
||||
"phản ứng có hại",
|
||||
"tác dụng ngoại ý",
|
||||
),
|
||||
"huong_dan_xu_tri_adr": (
|
||||
"hướng dẫn xử trí adr",
|
||||
"xử trí tác dụng phụ",
|
||||
"xử trí phản ứng có hại",
|
||||
"xử trí adr",
|
||||
),
|
||||
"qua_lieu_va_xu_tri": (
|
||||
"quá liều và xử trí",
|
||||
"xử trí quá liều",
|
||||
"quá liều",
|
||||
"ngộ độc",
|
||||
),
|
||||
"tuong_tac_thuoc": (
|
||||
"tương tác thuốc",
|
||||
"tương tác với",
|
||||
"tương tác",
|
||||
),
|
||||
"tuong_ky": (
|
||||
"tương kỵ",
|
||||
),
|
||||
"thoi_ky_mang_thai": (
|
||||
"thời kỳ mang thai",
|
||||
"phụ nữ có thai",
|
||||
"phụ nữ mang thai",
|
||||
"mang thai",
|
||||
"có thai",
|
||||
"thai kỳ",
|
||||
# Colloquial, and clinicians type it: one golden question asks
|
||||
# "Bà bầu dùng Ibuprofen được không?".
|
||||
"bà bầu",
|
||||
"phụ nữ mang bầu",
|
||||
),
|
||||
"thoi_ky_cho_con_bu": (
|
||||
"thời kỳ cho con bú",
|
||||
"phụ nữ cho con bú",
|
||||
"cho con bú",
|
||||
"đang cho bú",
|
||||
"thời kỳ bú mẹ",
|
||||
),
|
||||
"duoc_ly_va_co_che_tac_dung": (
|
||||
"dược lý và cơ chế tác dụng",
|
||||
"cơ chế tác dụng",
|
||||
"dược lý",
|
||||
"cơ chế",
|
||||
),
|
||||
"dang_thuoc_va_ham_luong": (
|
||||
"dạng thuốc và hàm lượng",
|
||||
"dạng bào chế",
|
||||
"dạng thuốc",
|
||||
"hàm lượng",
|
||||
),
|
||||
"do_on_dinh_va_bao_quan": (
|
||||
"độ ổn định và bảo quản",
|
||||
"độ ổn định",
|
||||
"bảo quản",
|
||||
),
|
||||
"ten_chung_quoc_te": (
|
||||
"tên chung quốc tế",
|
||||
"tên quốc tế",
|
||||
),
|
||||
"ten_thuong_mai": (
|
||||
"tên thương mại",
|
||||
"biệt dược",
|
||||
),
|
||||
"loai_thuoc": (
|
||||
"loại thuốc",
|
||||
"nhóm thuốc",
|
||||
"thuộc nhóm",
|
||||
),
|
||||
"ma_atc": (
|
||||
"mã atc",
|
||||
),
|
||||
"thong_tin_quy_che": (
|
||||
"thông tin quy chế",
|
||||
"quy chế",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Book order of monograph sections (Hướng dẫn sử dụng, printed page 39). Used to
|
||||
# present a whole-drug overview when the query names the drug but no attribute —
|
||||
# typing "PARACETAMOL" should return the monograph, never a "specify an
|
||||
# attribute" dead-end.
|
||||
SECTION_ORDER: tuple[str, ...] = (
|
||||
"ten_chung_quoc_te",
|
||||
"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",
|
||||
"do_on_dinh_va_bao_quan",
|
||||
"tuong_ky",
|
||||
"qua_lieu_va_xu_tri",
|
||||
"thong_tin_quy_che",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SectionMatch:
|
||||
section_key: str
|
||||
phrase: str
|
||||
|
||||
|
||||
def _index(phrases: dict[str, tuple[str, ...]]) -> tuple[tuple[str, str, str], ...]:
|
||||
"""(normalized_phrase, section_key, original_phrase), longest first."""
|
||||
rows = [
|
||||
(normalized, section_key, phrase)
|
||||
for section_key, section_phrases in phrases.items()
|
||||
for phrase in section_phrases
|
||||
if (normalized := normalize_name(phrase))
|
||||
]
|
||||
# Length first so a superstring is always tested before its substring;
|
||||
# the phrase text breaks ties so the order is deterministic.
|
||||
rows.sort(key=lambda row: (-len(row[0]), row[0]))
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
class SectionResolver:
|
||||
"""Maps a question to a `section_key`, or to nothing at all."""
|
||||
|
||||
def __init__(self, phrases: dict[str, tuple[str, ...]] | None = None) -> None:
|
||||
self._index = _index(phrases if phrases is not None else SECTION_PHRASES)
|
||||
|
||||
def resolve(self, query: str) -> SectionMatch | None:
|
||||
normalized_query = normalize_name(query)
|
||||
if not normalized_query:
|
||||
return None
|
||||
padded = f" {normalized_query} "
|
||||
for normalized_phrase, section_key, phrase in self._index:
|
||||
if f" {normalized_phrase} " in padded:
|
||||
return SectionMatch(section_key, phrase)
|
||||
return None
|
||||
Reference in New Issue
Block a user