Add production condition retrieval smoke test
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
"""Small clinical query contracts for condition-centric formulary retrieval.
|
||||
|
||||
This module contains no treatment knowledge and no disease-to-drug map. It
|
||||
only preserves facts the clinician supplied, normalises a deliberately small
|
||||
set of unambiguous Vietnamese aliases/abbreviations, and groups retrieved
|
||||
evidence by safety facet. Medication candidates always originate in the
|
||||
corpus's ``chi_dinh`` sections.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
from .models import Evidence
|
||||
from .text import normalize_name
|
||||
|
||||
|
||||
class ConditionRelation(StrEnum):
|
||||
INDICATION = "indication"
|
||||
ADVERSE_EFFECT = "adverse_effect"
|
||||
CONTRAINDICATION = "contraindication"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class CaseContextAction(StrEnum):
|
||||
NONE = "none"
|
||||
CONTINUE = "continue"
|
||||
NEW = "new"
|
||||
|
||||
|
||||
class CandidateStatus(StrEnum):
|
||||
SUPPORTED = "supported"
|
||||
SUPPORTED_WITH_CAUTION = "supported_with_caution"
|
||||
REQUIRES_ADDITIONAL_INFORMATION = "requires_additional_information"
|
||||
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConditionQuery:
|
||||
original_query: str
|
||||
normalized_condition: str
|
||||
subtype: str | None = None
|
||||
qualifiers: tuple[str, ...] = ()
|
||||
ambiguous: bool = False
|
||||
clarify_question: str | None = None
|
||||
|
||||
@property
|
||||
def retrieval_text(self) -> str:
|
||||
"""Most specific safe text to search, without inventing a subtype."""
|
||||
if self.subtype and normalize_name(self.subtype) not in normalize_name(
|
||||
self.normalized_condition
|
||||
):
|
||||
return f"{self.normalized_condition} {self.subtype}".strip()
|
||||
return self.normalized_condition
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenalContext:
|
||||
description: str | None = None
|
||||
ckd_stage: str | None = None
|
||||
egfr: str | None = None
|
||||
crcl: str | None = None
|
||||
creatinine: str | None = None
|
||||
|
||||
@property
|
||||
def present(self) -> bool:
|
||||
return any((self.description, self.ckd_stage, self.egfr, self.crcl, self.creatinine))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HepaticContext:
|
||||
description: str | None = None
|
||||
child_pugh: str | None = None
|
||||
ast: str | None = None
|
||||
alt: str | None = None
|
||||
bilirubin: str | None = None
|
||||
|
||||
@property
|
||||
def present(self) -> bool:
|
||||
return any((self.description, self.child_pugh, self.ast, self.alt, self.bilirubin))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatientContext:
|
||||
age_text: str | None = None
|
||||
sex: str | None = None
|
||||
weight_kg: float | None = None
|
||||
primary_condition: str | None = None
|
||||
comorbidities: tuple[str, ...] = ()
|
||||
allergies: tuple[str, ...] = ()
|
||||
previous_adverse_reactions: tuple[str, ...] = ()
|
||||
current_medications: tuple[str, ...] = ()
|
||||
pregnancy_status: str | None = None
|
||||
breastfeeding: bool | None = None
|
||||
renal: RenalContext = field(default_factory=RenalContext)
|
||||
hepatic: HepaticContext = field(default_factory=HepaticContext)
|
||||
relevant_labs: tuple[str, ...] = ()
|
||||
treatment_history: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def present(self) -> bool:
|
||||
return any((
|
||||
self.age_text,
|
||||
self.sex,
|
||||
self.weight_kg is not None,
|
||||
self.primary_condition,
|
||||
self.comorbidities,
|
||||
self.allergies,
|
||||
self.previous_adverse_reactions,
|
||||
self.current_medications,
|
||||
self.pregnancy_status,
|
||||
self.breastfeeding is not None,
|
||||
self.renal.present,
|
||||
self.hepatic.present,
|
||||
self.relevant_labs,
|
||||
self.treatment_history,
|
||||
))
|
||||
|
||||
@property
|
||||
def requires_safety_review(self) -> bool:
|
||||
"""Whether this condition lookup needs the bounded stage-2 path."""
|
||||
return any((
|
||||
self.age_text,
|
||||
self.weight_kg is not None,
|
||||
self.comorbidities,
|
||||
self.allergies,
|
||||
self.previous_adverse_reactions,
|
||||
self.current_medications,
|
||||
self.pregnancy_status,
|
||||
self.breastfeeding is not None,
|
||||
self.renal.present,
|
||||
self.hepatic.present,
|
||||
self.relevant_labs,
|
||||
self.treatment_history,
|
||||
))
|
||||
|
||||
def merged_with(self, earlier: "PatientContext") -> "PatientContext":
|
||||
"""Prefer facts in the current turn and retain earlier case facts.
|
||||
|
||||
Tuple fields are unioned in conversation order. This is called only
|
||||
after query understanding explicitly marks the turn as the same case;
|
||||
it must never decide case continuity itself.
|
||||
"""
|
||||
return PatientContext(
|
||||
age_text=self.age_text or earlier.age_text,
|
||||
sex=self.sex or earlier.sex,
|
||||
weight_kg=self.weight_kg if self.weight_kg is not None else earlier.weight_kg,
|
||||
primary_condition=self.primary_condition or earlier.primary_condition,
|
||||
comorbidities=_merge_tuple(earlier.comorbidities, self.comorbidities),
|
||||
allergies=_merge_tuple(earlier.allergies, self.allergies),
|
||||
previous_adverse_reactions=_merge_tuple(
|
||||
earlier.previous_adverse_reactions, self.previous_adverse_reactions
|
||||
),
|
||||
current_medications=_merge_tuple(
|
||||
earlier.current_medications, self.current_medications
|
||||
),
|
||||
pregnancy_status=self.pregnancy_status or earlier.pregnancy_status,
|
||||
breastfeeding=(
|
||||
self.breastfeeding
|
||||
if self.breastfeeding is not None
|
||||
else earlier.breastfeeding
|
||||
),
|
||||
renal=_merge_renal(self.renal, earlier.renal),
|
||||
hepatic=_merge_hepatic(self.hepatic, earlier.hepatic),
|
||||
relevant_labs=_merge_tuple(earlier.relevant_labs, self.relevant_labs),
|
||||
treatment_history=_merge_tuple(
|
||||
earlier.treatment_history, self.treatment_history
|
||||
),
|
||||
)
|
||||
|
||||
def safety_query(self) -> str:
|
||||
"""Search text made only from supplied/normalised case facts."""
|
||||
parts = [
|
||||
*self.comorbidities,
|
||||
*self.allergies,
|
||||
*self.previous_adverse_reactions,
|
||||
*self.current_medications,
|
||||
*self.relevant_labs,
|
||||
self.renal.description,
|
||||
self.renal.ckd_stage,
|
||||
self.renal.egfr,
|
||||
self.renal.crcl,
|
||||
self.renal.creatinine,
|
||||
self.hepatic.description,
|
||||
self.hepatic.child_pugh,
|
||||
self.hepatic.ast,
|
||||
self.hepatic.alt,
|
||||
self.hepatic.bilirubin,
|
||||
]
|
||||
if self.pregnancy_status:
|
||||
parts.extend(("mang thai", self.pregnancy_status))
|
||||
if self.breastfeeding is True:
|
||||
parts.append("cho con bú")
|
||||
if self.allergies or self.previous_adverse_reactions:
|
||||
parts.extend(("dị ứng", "quá mẫn"))
|
||||
if self.renal.present:
|
||||
parts.extend(("suy thận", "chức năng thận", "độ thanh thải creatinin"))
|
||||
if self.hepatic.present:
|
||||
parts.extend(("suy gan", "chức năng gan"))
|
||||
if self.age_text:
|
||||
parts.append(self.age_text)
|
||||
digits = "".join(char if char.isdigit() else " " for char in self.age_text)
|
||||
values = [int(item) for item in digits.split() if item.isdigit()]
|
||||
if values and values[0] >= 65:
|
||||
parts.append("người cao tuổi")
|
||||
return ". ".join(str(item).strip() for item in parts if str(item or "").strip())
|
||||
|
||||
def interaction_query(self) -> str:
|
||||
return ". ".join(self.current_medications)
|
||||
|
||||
def warning_query(self) -> str:
|
||||
parts = [
|
||||
*self.comorbidities,
|
||||
*self.allergies,
|
||||
*self.previous_adverse_reactions,
|
||||
*self.relevant_labs,
|
||||
self.renal.description,
|
||||
self.renal.ckd_stage,
|
||||
self.renal.egfr,
|
||||
self.renal.crcl,
|
||||
self.renal.creatinine,
|
||||
self.hepatic.description,
|
||||
self.hepatic.child_pugh,
|
||||
self.hepatic.ast,
|
||||
self.hepatic.alt,
|
||||
self.hepatic.bilirubin,
|
||||
self.age_text,
|
||||
]
|
||||
if self.allergies or self.previous_adverse_reactions:
|
||||
parts.extend(("dị ứng", "quá mẫn"))
|
||||
if self.renal.present:
|
||||
parts.extend(("suy thận", "chức năng thận", "độ thanh thải creatinin"))
|
||||
if self.hepatic.present:
|
||||
parts.extend(("suy gan", "chức năng gan"))
|
||||
if self.age_text:
|
||||
parts.append("người cao tuổi")
|
||||
return ". ".join(str(item).strip() for item in parts if str(item or "").strip())
|
||||
|
||||
def dosage_context_query(self) -> str:
|
||||
parts = [
|
||||
self.renal.description,
|
||||
self.renal.ckd_stage,
|
||||
self.renal.egfr,
|
||||
self.renal.crcl,
|
||||
self.renal.creatinine,
|
||||
self.hepatic.description,
|
||||
self.hepatic.child_pugh,
|
||||
self.age_text,
|
||||
]
|
||||
if self.renal.present:
|
||||
parts.extend(("suy thận", "độ thanh thải creatinin"))
|
||||
if self.hepatic.present:
|
||||
parts.append("suy gan")
|
||||
if self.age_text:
|
||||
parts.append("người cao tuổi")
|
||||
return ". ".join(str(item).strip() for item in parts if str(item or "").strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MedicationCandidateAssessment:
|
||||
drug_id: str
|
||||
drug_name: str
|
||||
indication_supported: bool
|
||||
indication_evidence: tuple[Evidence, ...] = ()
|
||||
contraindication_evidence: tuple[Evidence, ...] = ()
|
||||
precaution_evidence: tuple[Evidence, ...] = ()
|
||||
interaction_evidence: tuple[Evidence, ...] = ()
|
||||
renal_evidence: tuple[Evidence, ...] = ()
|
||||
hepatic_evidence: tuple[Evidence, ...] = ()
|
||||
pregnancy_evidence: tuple[Evidence, ...] = ()
|
||||
breastfeeding_evidence: tuple[Evidence, ...] = ()
|
||||
age_evidence: tuple[Evidence, ...] = ()
|
||||
dose_evidence: tuple[Evidence, ...] = ()
|
||||
status: CandidateStatus = CandidateStatus.SUPPORTED
|
||||
|
||||
@property
|
||||
def evidence(self) -> tuple[Evidence, ...]:
|
||||
ordered = (
|
||||
self.indication_evidence
|
||||
+ self.contraindication_evidence
|
||||
+ self.precaution_evidence
|
||||
+ self.interaction_evidence
|
||||
+ self.renal_evidence
|
||||
+ self.hepatic_evidence
|
||||
+ self.pregnancy_evidence
|
||||
+ self.breastfeeding_evidence
|
||||
+ self.age_evidence
|
||||
+ self.dose_evidence
|
||||
)
|
||||
output: list[Evidence] = []
|
||||
seen: set[str] = set()
|
||||
for item in ordered:
|
||||
if item.evidence_id in seen:
|
||||
continue
|
||||
seen.add(item.evidence_id)
|
||||
output.append(item)
|
||||
return tuple(output)
|
||||
|
||||
|
||||
class ConditionNormalizer:
|
||||
"""Conservative terminology normalisation, never treatment mapping.
|
||||
|
||||
Only unambiguous aliases required by the professional UX are canonicalised.
|
||||
Every unknown phrase is preserved, so an ambiguous abbreviation is never
|
||||
silently expanded by this class.
|
||||
"""
|
||||
|
||||
_ALIASES = {
|
||||
"tha": "tăng huyết áp",
|
||||
"cao huyet ap": "tăng huyết áp",
|
||||
"tang huyet ap": "tăng huyết áp",
|
||||
"gout": "gút",
|
||||
"benh gout": "gút",
|
||||
"benh gut": "gút",
|
||||
"gut": "gút",
|
||||
}
|
||||
_BROAD = frozenset({"viem gan", "ung thu", "nhiem trung", "nhiem khuan"})
|
||||
_BROAD_QUESTIONS = {
|
||||
"viem gan": "Bạn đang hỏi viêm gan A, B, C hay loại viêm gan nào?",
|
||||
"ung thu": "Bạn đang hỏi loại ung thư cụ thể nào?",
|
||||
"nhiem trung": "Bạn đang hỏi nhiễm trùng ở vị trí nào và do tác nhân nào đã xác định?",
|
||||
"nhiem khuan": "Bạn đang hỏi nhiễm khuẩn ở vị trí nào và do tác nhân nào đã xác định?",
|
||||
}
|
||||
|
||||
def detect_known_alias(self, original_query: str) -> ConditionQuery | None:
|
||||
"""Find a conservative condition alias anywhere in a clinician turn."""
|
||||
text = normalize_name(original_query)
|
||||
matches: list[tuple[int, int, str]] = []
|
||||
for alias in self._ALIASES:
|
||||
match = re.search(rf"(?:^| ){re.escape(alias)}(?:$| )", text)
|
||||
if match:
|
||||
matches.append((match.start(), -len(alias), alias))
|
||||
if not matches:
|
||||
return None
|
||||
_, _, alias = min(matches)
|
||||
return self.normalize(original_query, alias)
|
||||
|
||||
def detect_broad_question(self, original_query: str) -> ConditionQuery | None:
|
||||
"""Recognise only a bare broad condition followed by a drug request.
|
||||
|
||||
A subtype/site between the broad noun and the request deliberately
|
||||
prevents a match (for example ``nhiễm trùng đường tiết niệu``), so the
|
||||
guard does not over-clarify a condition the clinician already narrowed.
|
||||
"""
|
||||
text = normalize_name(original_query)
|
||||
request = (
|
||||
r"(?:dung thuoc gi|nen dung thuoc nao|dieu tri (?:bang )?thuoc nao|"
|
||||
r"co thuoc nao(?: dieu tri)?|thuoc nao dieu tri)"
|
||||
)
|
||||
for broad in sorted(self._BROAD, key=len, reverse=True):
|
||||
patterns = (
|
||||
rf"(?:benh )?{re.escape(broad)} {request}",
|
||||
rf"thuoc nao (?:dieu tri )?(?:benh )?{re.escape(broad)}",
|
||||
)
|
||||
if any(re.fullmatch(pattern, text) for pattern in patterns):
|
||||
return ConditionQuery(
|
||||
original_query=original_query,
|
||||
normalized_condition=broad,
|
||||
ambiguous=True,
|
||||
clarify_question=self._BROAD_QUESTIONS[broad],
|
||||
)
|
||||
return None
|
||||
|
||||
def normalize(
|
||||
self,
|
||||
original_query: str,
|
||||
condition: str,
|
||||
*,
|
||||
subtype: str | None = None,
|
||||
qualifiers: tuple[str, ...] = (),
|
||||
ambiguous: bool = False,
|
||||
clarify_question: str | None = None,
|
||||
) -> ConditionQuery:
|
||||
cleaned = " ".join(condition.split())
|
||||
key = normalize_name(cleaned)
|
||||
canonical = self._ALIASES.get(key, cleaned)
|
||||
broad = normalize_name(canonical) in self._BROAD and not subtype
|
||||
return ConditionQuery(
|
||||
original_query=original_query,
|
||||
normalized_condition=canonical,
|
||||
subtype=subtype,
|
||||
qualifiers=qualifiers,
|
||||
ambiguous=ambiguous or broad,
|
||||
clarify_question=(
|
||||
clarify_question
|
||||
or self._BROAD_QUESTIONS.get(normalize_name(canonical))
|
||||
if ambiguous or broad
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _merge_tuple(left: tuple[str, ...], right: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys((*left, *right)))
|
||||
|
||||
|
||||
def _merge_renal(current: RenalContext, earlier: RenalContext) -> RenalContext:
|
||||
return RenalContext(
|
||||
description=current.description or earlier.description,
|
||||
ckd_stage=current.ckd_stage or earlier.ckd_stage,
|
||||
egfr=current.egfr or earlier.egfr,
|
||||
crcl=current.crcl or earlier.crcl,
|
||||
creatinine=current.creatinine or earlier.creatinine,
|
||||
)
|
||||
|
||||
|
||||
def _merge_hepatic(current: HepaticContext, earlier: HepaticContext) -> HepaticContext:
|
||||
return HepaticContext(
|
||||
description=current.description or earlier.description,
|
||||
child_pugh=current.child_pugh or earlier.child_pugh,
|
||||
ast=current.ast or earlier.ast,
|
||||
alt=current.alt or earlier.alt,
|
||||
bilirubin=current.bilirubin or earlier.bilirubin,
|
||||
)
|
||||
Reference in New Issue
Block a user