"""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, verified 2026-08-14 against the actual # PDF (physical page 38 = printed page 39, "HƯỚNG DẪN SỬ DỤNG DƯỢC THƯ QUỐC # GIA VIỆT NAM"), not assumed from an earlier reading of this constant. The # guide numbers 19 items; item 1, "Tên chuyên luận thuốc", is the monograph's # own title/heading, not a content section with a `section_key` — items 2-19 # are exactly these 18 keys, in exactly this order. Confirms this tuple was # already complete and correctly ordered for the book's own stated template. # # `ten_thuong_mai` (trade name) is real, present in the corpus (492/684 # drugs) but is NOT one of the guide's 19 numbered items — the book's own # template never promises it, so there is no book-verified position to place # it at. Inserted right after `ten_chung_quoc_te` (generic/INN name) as the # most natural adjacency (same convention `understanding.py`'s `SECTION_KEYS` # already uses) — a judgment call, not a sourced fact, unlike the 18 above. # # 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", "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", "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 def resolve_all(self, query: str) -> tuple[SectionMatch, ...]: """Every distinct section a question genuinely names, not just the first. Same longest-first order as `resolve()`, and the same span-claiming rule: once a phrase's occurrence is accepted, any shorter phrase whose only occurrence falls inside that already- claimed span is a substring of it, not a second section — e.g. "chỉ định" inside "chống chỉ định của X" must NOT count as a second, separate mention of `chi_dinh`. A phrase counts only when it has an occurrence that does not overlap any span already claimed by a longer, earlier-accepted phrase. Returns `()` for no match and exactly one item when the question names only one section — this is a superset of `resolve()`, not a replacement for it. """ normalized_query = normalize_name(query) if not normalized_query: return () padded = f" {normalized_query} " claimed: list[tuple[int, int]] = [] seen_sections: set[str] = set() matches: list[SectionMatch] = [] for normalized_phrase, section_key, phrase in self._index: needle = f" {normalized_phrase} " idx = padded.find(needle) if idx == -1: continue span = (idx, idx + len(needle)) if any(span[0] < c_end and c_start < span[1] for c_start, c_end in claimed): continue claimed.append(span) if section_key not in seen_sections: seen_sections.add(section_key) matches.append(SectionMatch(section_key, phrase)) return tuple(matches)