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