Add production condition retrieval smoke test

This commit is contained in:
2026-08-11 14:58:28 +07:00
parent 59e6ad2d0d
commit 7ebbe1f309
38 changed files with 3752 additions and 121 deletions
+409 -13
View File
@@ -40,7 +40,17 @@ from dataclasses import dataclass, field, replace
from typing import Protocol, Sequence
from .budget import RequestBudget
from .clinical import (
CaseContextAction,
ConditionNormalizer,
ConditionQuery,
ConditionRelation,
HepaticContext,
PatientContext,
RenalContext,
)
from .ports import AnswerGenerationUnavailable
from .text import normalize_name
logger = logging.getLogger(__name__)
@@ -114,6 +124,9 @@ TURN_TYPES = (
"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
"condition_to_drug", # a diagnosed disease/condition -> indicated drugs
"drug_to_condition", # what condition(s) a named drug is indicated for
"condition_relation", # reverse ADR/contraindication relation, not treatment
"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
@@ -132,6 +145,10 @@ class QueryFrame:
weight_kg: float | None = None
age_text: str | None = None
indication: str | None = None # symptom/disease, for symptom_to_drug
condition: ConditionQuery | None = None
condition_relation: ConditionRelation = ConditionRelation.INDICATION
patient_context: PatientContext = field(default_factory=PatientContext)
context_action: CaseContextAction = CaseContextAction.NONE
route: str | None = None # e.g. "uong", "tiem_tinh_mach", "dat_truc_trang"
# True when the user is asking to survey/summarise a whole named section
# (for example all ADRs, precautions, or dosage regimens), rather than
@@ -178,6 +195,55 @@ FRAME_SCHEMA = {
),
"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",
"condition": {
"original_text": "condition phrase exactly as written, or null",
"normalized_condition": (
"conservative canonical condition name; expand only an unambiguous "
"abbreviation (e.g. THA -> tăng huyết áp), or null"
),
"subtype": "explicit subtype only (e.g. B, mạn), else null",
"qualifiers": ["only qualifiers explicitly present in the turn/history"],
"ambiguous": "true when subtype materially changes the answer",
"clarify_question": "short Vietnamese clarification if ambiguous, else null",
},
"condition_relation": (
"indication | adverse_effect | contraindication | unknown. "
"'thuốc nào gây X' is adverse_effect; 'thuốc nào chống chỉ định ở X' "
"is contraindication, never indication"
),
"patient_context": {
"age_text": "age exactly as stated, else null",
"sex": "sex exactly/briefly as stated, else null",
"weight_kg": "number only when stated, else null",
"primary_condition": "the condition being treated, else null",
"comorbidities": ["diagnosed comorbidities explicitly stated"],
"allergies": ["drug/substance allergies explicitly stated"],
"previous_adverse_reactions": ["previous ADRs explicitly stated"],
"current_medications": ["current medicine names explicitly stated"],
"pregnancy_status": "pregnancy information explicitly stated, else null",
"breastfeeding": "true/false only when explicitly stated, else null",
"renal": {
"description": "renal condition wording, else null",
"ckd_stage": "e.g. G4, else null",
"egfr": "value with unit/text exactly as stated, else null",
"crcl": "value with unit/text exactly as stated, else null",
"creatinine": "value with unit/text exactly as stated, else null",
},
"hepatic": {
"description": "hepatic condition wording, else null",
"child_pugh": "class/score exactly as stated, else null",
"ast": "value exactly as stated, else null",
"alt": "value exactly as stated, else null",
"bilirubin": "value exactly as stated, else null",
},
"relevant_labs": ["other clinical labs exactly as stated"],
"treatment_history": ["treatments tried/failed exactly as stated"],
},
"context_action": (
"continue when this turn belongs to the same patient/case as recent "
"history; new when the user explicitly starts another case/patient/topic; "
"none when no patient case continuity is involved"
),
"route": (
"route of administration if stated or implied, normalized to one of: "
"uong | tiem_tinh_mach | tiem_bap | tiem_duoi_da | dat_truc_trang | "
@@ -233,8 +299,28 @@ Quy tắc bắt buộc:
- 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 là BỆNH/CONDITION đã nêu và hỏi thuốc nào có chỉ định điều trị ->
"condition_to_drug", điền `condition`, `condition_relation="indication"`.
Có thể dùng "symptom_to_drug" cho triệu chứng chưa phải chẩn đoán; không đánh
đồng triệu chứng với bệnh đã chẩn đoán.
- Nếu hỏi một THUỐC đã nêu được chỉ định cho bệnh gì -> "drug_to_condition",
attribute="chi_dinh". Đây là chiều ngược với condition_to_drug.
- Phân biệt QUAN HỆ: "thuốc nào GÂY tăng huyết áp" ->
turn_type="condition_relation", condition_relation="adverse_effect"; "thuốc
nào CHỐNG CHỈ ĐỊNH ở bệnh nhân gout" -> "condition_relation",
condition_relation="contraindication". TUYỆT ĐỐI không gán hai câu này thành
condition_to_drug/indication.
- Chuẩn hoá condition bảo thủ: "cao huyết áp"/"THA" -> "tăng huyết áp" khi
chắc chắn; giữ nguyên viết tắt mơ hồ. "Viêm gan", "ung thư", "nhiễm trùng"
không có subtype/vị trí là mơ hồ đáng kể -> ambiguous=true và hỏi làm rõ.
"Tăng huyết áp dùng thuốc gì?" không mơ hồ và không cần hỏi tuổi/xét nghiệm.
- Nếu câu hỏi có dữ liệu người bệnh, điền `patient_context` bằng ĐÚNG dữ kiện
được nêu; không suy ra field còn thiếu. Bệnh nền, thuốc đang dùng, dị ứng/ADR,
suy thận/gan, thai/cho bú và xét nghiệm là dữ liệu first-class, không bỏ vào
một chuỗi ghi chú chung.
- Chỉ đặt context_action="continue" khi lượt hiện tại thực sự tiếp tục CÙNG ca
bệnh trong lịch sử. Nếu người dùng nói ca mới/BN khác hoặc chuyển chủ đề độc
lập, đặt "new" và không mang dữ kiện bệnh nhân cũ sang.
- 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"/""
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
@@ -337,10 +423,17 @@ class LlmQueryUnderstander:
safer and cheaper.
"""
def __init__(self, llm: JsonLlm, catalog: dict[str, str], resolver: CandidateSource) -> None:
def __init__(
self,
llm: JsonLlm,
catalog: dict[str, str],
resolver: CandidateSource,
condition_normalizer: ConditionNormalizer | None = None,
) -> None:
self._llm = llm
self._catalog = catalog
self._resolver = resolver
self._condition_normalizer = condition_normalizer or ConditionNormalizer()
def _candidate_ids(self, turn: str, history: Sequence[str]) -> set[str]:
"""Every drug_id a deterministic pass finds plausible in the turn or
@@ -433,7 +526,16 @@ class LlmQueryUnderstander:
"sau ít phút.",
system_error="understanding_provider_unavailable",
)
return _merge_with_prior_frame(self._parse(raw_text, shown), prior_frame)
frame = self._parse(raw_text, shown, turn)
frame = _apply_condition_candidate_cue(
frame, turn, self._condition_normalizer
)
frame = _apply_broad_condition_cue(
frame, turn, self._condition_normalizer
)
frame = _apply_reverse_relation_cues(frame, turn)
frame = _apply_named_drug_cues(frame, turn)
return _merge_with_prior_frame(frame, prior_frame)
@staticmethod
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
@@ -453,7 +555,9 @@ class LlmQueryUnderstander:
return drug_id
return None
def _parse(self, raw_text: str, shown: dict[str, str]) -> QueryFrame:
def _parse(
self, raw_text: str, shown: dict[str, str], original_turn: str = ""
) -> QueryFrame:
try:
data = json.loads(raw_text)
except (json.JSONDecodeError, TypeError):
@@ -490,6 +594,22 @@ class LlmQueryUnderstander:
if needs_clarify and clarify_reason
else ()
)
indication = _clean_str(data.get("indication"))
condition = _parse_condition(
data.get("condition"), indication, original_turn, self._condition_normalizer
)
relation = _clean_enum_value(
data.get("condition_relation"), ConditionRelation, ConditionRelation.INDICATION
)
patient_context = _parse_patient_context(
data.get("patient_context"),
fallback_age=_clean_str(data.get("age_text")),
fallback_weight=_clean_float(data.get("weight_kg")),
fallback_population=_clean_enum(data.get("population"), _ALLOWED_POPULATIONS),
)
context_action = _clean_enum_value(
data.get("context_action"), CaseContextAction, CaseContextAction.NONE
)
return QueryFrame(
turn_type=turn_type,
drugs=drugs,
@@ -498,7 +618,11 @@ class LlmQueryUnderstander:
population=_clean_enum(data.get("population"), _ALLOWED_POPULATIONS),
weight_kg=_clean_float(data.get("weight_kg")),
age_text=_clean_str(data.get("age_text")),
indication=_clean_str(data.get("indication")),
indication=(condition.normalized_condition if condition else indication),
condition=condition,
condition_relation=relation,
patient_context=patient_context,
context_action=context_action,
route=_clean_enum(data.get("route"), _ALLOWED_ROUTES),
section_overview=data.get("section_overview") is True,
standalone_query=_clean_str(data.get("standalone_query")),
@@ -510,6 +634,142 @@ class LlmQueryUnderstander:
)
def _apply_reverse_relation_cues(frame: QueryFrame, turn: str) -> QueryFrame:
"""Fail closed on explicit reverse-relation wording.
The LLM remains responsible for open-ended language understanding. This
narrow post-condition only covers unambiguous surface forms where routing
to indication retrieval would reverse the requested relation. It contains
no disease or drug knowledge and never creates a candidate.
"""
text = f" {normalize_name(turn)} "
contraindication = any(
cue in text
for cue in (
" thuoc nao chong chi dinh ",
" nhung thuoc nao chong chi dinh ",
" thuoc nao can tranh o ",
" thuoc nao can tranh cho ",
)
)
adverse = any(
cue in text
for cue in (
" thuoc nao gay ",
" thuoc nao co the gay ",
" thuoc nao lam tang ",
" thuoc nao co adr ",
)
)
relation = (
ConditionRelation.CONTRAINDICATION
if contraindication
else ConditionRelation.ADVERSE_EFFECT
if adverse
else None
)
if relation is None:
return frame
return replace(
frame,
turn_type="condition_relation",
condition_relation=relation,
needs_clarify=False,
clarify_reason=None,
quick_replies=(),
)
def _apply_condition_candidate_cue(
frame: QueryFrame, turn: str, normalizer: ConditionNormalizer
) -> QueryFrame:
"""Keep current medicines subordinate in an explicit condition lookup."""
if frame.turn_type == "condition_to_drug" and frame.condition is not None:
return frame
condition = normalizer.detect_known_alias(turn)
if condition is None:
return frame
text = f" {normalize_name(turn)} "
candidate_cues = (
" dung thuoc gi ",
" dung thuoc nao ",
" thuoc nao can ",
" lua chon thuoc nao ",
" option ha ap ",
" option dieu tri ",
" ung vien nao ",
" cac ung vien nao ",
)
if not any(cue in text for cue in candidate_cues):
return frame
return replace(
frame,
turn_type="condition_to_drug",
indication=condition.normalized_condition,
condition=condition,
condition_relation=ConditionRelation.INDICATION,
needs_clarify=False,
clarify_reason=None,
quick_replies=(),
)
def _apply_broad_condition_cue(
frame: QueryFrame, turn: str, normalizer: ConditionNormalizer
) -> QueryFrame:
"""Make explicit broad disease→drug questions deterministically clarify."""
if frame.drugs:
return frame
condition = normalizer.detect_broad_question(turn)
if condition is None:
return frame
return replace(
frame,
turn_type="condition_to_drug",
indication=condition.normalized_condition,
condition=condition,
condition_relation=ConditionRelation.INDICATION,
needs_clarify=False,
clarify_reason=None,
quick_replies=(),
)
def _apply_named_drug_cues(frame: QueryFrame, turn: str) -> QueryFrame:
"""A drug explicitly named as subject outranks reverse-condition wording."""
if not frame.drugs:
return frame
text = f" {normalize_name(turn)} "
purpose = (
" co tac dung gi " in text
and " tac dung khong mong muon " not in text
) or " dung de lam gi " in text
contraindication = (
" co chong chi dinh " in text
or " co dung duoc khong " in text
)
if purpose:
return replace(
frame,
turn_type="drug_to_condition",
attribute="chi_dinh",
condition_relation=ConditionRelation.INDICATION,
needs_clarify=False,
clarify_reason=None,
quick_replies=(),
)
if contraindication:
return replace(
frame,
turn_type="drug_attribute",
attribute="chong_chi_dinh",
needs_clarify=False,
clarify_reason=None,
quick_replies=(),
)
return frame
_KNOWN_FACT_LABELS: tuple[tuple[str, str], ...] = (
("population", "Đối tượng"),
("age_text", "Tuổi"),
@@ -534,7 +794,7 @@ def _known_facts_block(prior_frame: QueryFrame | None) -> str:
with_prior_frame` below is the code-level backstop for whatever the
model still drops.
"""
if prior_frame is None or not prior_frame.needs_clarify:
if prior_frame is None:
return ""
parts = []
if prior_frame.drugs:
@@ -545,12 +805,35 @@ def _known_facts_block(prior_frame: QueryFrame | None) -> str:
value = getattr(prior_frame, field_name)
if value:
parts.append(f"{label}: {value}")
patient = prior_frame.patient_context
if patient.age_text:
parts.append(f"Tuổi bệnh nhân: {patient.age_text}")
if patient.sex:
parts.append(f"Giới: {patient.sex}")
if patient.comorbidities:
parts.append(f"Bệnh nền: {', '.join(patient.comorbidities)}")
if patient.allergies:
parts.append(f"Dị ứng: {', '.join(patient.allergies)}")
if patient.previous_adverse_reactions:
parts.append(f"ADR trước đây: {', '.join(patient.previous_adverse_reactions)}")
if patient.current_medications:
parts.append(f"Thuốc đang dùng: {', '.join(patient.current_medications)}")
if patient.renal.present:
parts.append(f"Thận: {patient.renal}")
if patient.hepatic.present:
parts.append(f"Gan: {patient.hepatic}")
if patient.pregnancy_status:
parts.append(f"Thai kỳ: {patient.pregnancy_status}")
if patient.breastfeeding is not None:
parts.append(f"Cho con bú: {patient.breastfeeding}")
if patient.relevant_labs:
parts.append(f"Xét nghiệm: {', '.join(patient.relevant_labs)}")
if not parts:
return ""
return (
"THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC (dữ liệu CÓ THẬT, đã xác nhận "
"— KHÔNG hỏi lại các mục này; nếu câu hỏi hiện tại là một chủ đề mới "
"không liên quan, hãy bỏ qua khối này thay vì gán nhầm vào lượt mới):\n"
"THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC (chỉ kế thừa nếu đây là CÙNG "
"ca bệnh và đặt context_action=continue; nếu ca mới/chủ đề mới phải đặt "
"context_action=new và bỏ qua toàn bộ khối; không hỏi lại dữ kiện đã có):\n"
+ "\n".join(parts) + "\n\n"
)
@@ -566,10 +849,27 @@ def _merge_with_prior_frame(frame: QueryFrame, prior_frame: QueryFrame | None) -
the old one (the headache/OMEPRAZOL bleed this guards against runs the
other way: don't let old fields survive into an unrelated new drug either).
"""
if prior_frame is None or not prior_frame.needs_clarify:
if prior_frame is None:
return frame
if frame.drugs and frame.drugs != prior_frame.drugs:
continuing_case = (
frame.context_action == CaseContextAction.CONTINUE
or frame.depends_on_previous_turn
)
legacy_clarify = prior_frame.needs_clarify
if not continuing_case and not legacy_clarify:
return frame
if frame.context_action == CaseContextAction.NEW:
return frame
if frame.drugs and frame.drugs != prior_frame.drugs and not continuing_case:
return frame
patient_context = frame.patient_context
if continuing_case:
patient_context = patient_context.merged_with(prior_frame.patient_context)
condition = frame.condition
indication = frame.indication
if continuing_case and condition is None:
condition = prior_frame.condition
indication = indication or prior_frame.indication
return replace(
frame,
drugs=frame.drugs or prior_frame.drugs,
@@ -577,11 +877,107 @@ def _merge_with_prior_frame(frame: QueryFrame, prior_frame: QueryFrame | None) -
age_text=frame.age_text or prior_frame.age_text,
weight_kg=frame.weight_kg if frame.weight_kg is not None else prior_frame.weight_kg,
route=frame.route or prior_frame.route,
indication=frame.indication or prior_frame.indication,
indication=indication or prior_frame.indication,
condition=condition,
patient_context=patient_context,
attribute=frame.attribute or prior_frame.attribute,
)
def _parse_condition(
value,
indication: str | None,
original_turn: str,
normalizer: ConditionNormalizer,
) -> ConditionQuery | None:
data = value if isinstance(value, dict) else {}
raw = (
_clean_str(data.get("normalized_condition"))
or _clean_str(data.get("original_text"))
or indication
)
if raw is None:
return None
return normalizer.normalize(
original_query=original_turn,
condition=raw,
subtype=_clean_str(data.get("subtype")),
qualifiers=tuple(_as_list(data.get("qualifiers"))),
ambiguous=data.get("ambiguous") is True,
clarify_question=_clean_str(data.get("clarify_question")),
)
def _parse_patient_context(
value,
*,
fallback_age: str | None,
fallback_weight: float | None,
fallback_population: str | None,
) -> PatientContext:
data = value if isinstance(value, dict) else {}
renal_data = data.get("renal") if isinstance(data.get("renal"), dict) else {}
hepatic_data = (
data.get("hepatic") if isinstance(data.get("hepatic"), dict) else {}
)
pregnancy = _clean_str(data.get("pregnancy_status"))
breastfeeding = _clean_bool(data.get("breastfeeding"))
renal_description = _clean_str(renal_data.get("description"))
hepatic_description = _clean_str(hepatic_data.get("description"))
if fallback_population == "phu_nu_co_thai" and pregnancy is None:
pregnancy = "mang thai"
if fallback_population == "phu_nu_cho_con_bu" and breastfeeding is None:
breastfeeding = True
if fallback_population == "suy_than" and renal_description is None:
renal_description = "suy thận"
if fallback_population == "suy_gan" and hepatic_description is None:
hepatic_description = "suy gan"
return PatientContext(
age_text=_clean_str(data.get("age_text")) or fallback_age,
sex=_clean_str(data.get("sex")),
weight_kg=_clean_float(data.get("weight_kg")) or fallback_weight,
primary_condition=_clean_str(data.get("primary_condition")),
comorbidities=tuple(_as_list(data.get("comorbidities"))),
allergies=tuple(_as_list(data.get("allergies"))),
previous_adverse_reactions=tuple(
_as_list(data.get("previous_adverse_reactions"))
),
current_medications=tuple(_as_list(data.get("current_medications"))),
pregnancy_status=pregnancy,
breastfeeding=breastfeeding,
renal=RenalContext(
description=renal_description,
ckd_stage=_clean_str(renal_data.get("ckd_stage")),
egfr=_clean_str(renal_data.get("egfr")),
crcl=_clean_str(renal_data.get("crcl")),
creatinine=_clean_str(renal_data.get("creatinine")),
),
hepatic=HepaticContext(
description=hepatic_description,
child_pugh=_clean_str(hepatic_data.get("child_pugh")),
ast=_clean_str(hepatic_data.get("ast")),
alt=_clean_str(hepatic_data.get("alt")),
bilirubin=_clean_str(hepatic_data.get("bilirubin")),
),
relevant_labs=tuple(_as_list(data.get("relevant_labs"))),
treatment_history=tuple(_as_list(data.get("treatment_history"))),
)
def _clean_enum_value(value, enum_type, default):
cleaned = _clean_str(value)
if cleaned is None:
return default
try:
return enum_type(cleaned)
except ValueError:
return default
def _clean_bool(value) -> bool | None:
return value if isinstance(value, bool) else None
def _as_list(value) -> list[str]:
if isinstance(value, str):
return [value] if value.strip() else []