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

211 lines
6.5 KiB
Python

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