dental-soap / interview_schema.py
DrZayed's picture
Adaptive ODIPARA guided interview, dental-specific question bank, age-extraction safety guard
f3def38
Raw
History Blame Contribute Delete
6.31 kB
"""Typed contract between the interview model personas and the existing intake schema.
ExtractedIntake is the narrow, validated surface the Intake-Extractor persona may
fill; extracted_to_intake() bridges it into the deterministic pipeline's
PatientProfile + StructuredIntake. Extractor strings are MODEL OUTPUT and must
clear model_text_is_safe before they may impersonate patient-reported intake.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from schema import PatientProfile, StructuredIntake, model_text_is_safe
# Mirrors app.CHECK_MAP values / StructuredIntake booleans. Kept as a module
# constant (not imported from app) to avoid an import cycle with the Gradio app;
# tests/test_interview_extract.py asserts equality with CHECK_MAP.
INTAKE_BOOL_FIELDS: tuple[str, ...] = (
"biting_pain",
"hot_cold_sensitivity",
"pain_prevents_sleep",
"swelling",
"rapidly_spreading_swelling",
"fever_or_unwell",
"breathing_or_swallowing_issue",
"limited_opening_or_locked_jaw",
"loose_crown_or_bridge",
"trauma_or_sudden_bite_change",
"numbness_or_neuro_symptoms",
"chest_pain_or_jaw_pain_with_exertion",
"jaw_pain_with_chewing_relieved_by_rest",
"vision_scalp_or_new_headache",
"gum_pimple_or_drainage",
"bruising_or_burning_after_root_canal",
)
# Extractor-authored free-text fields that flow into StructuredIntake / profile.
_GUARDED_TEXT_FIELDS = (
"name",
"chief_concern",
"tooth_or_area",
"recent_dental_work",
"symptom_duration",
"meds",
"allergies",
"goals",
)
ODIPARAAxis = Literal[
"onset",
"duration",
"intensity",
"progression",
"aggravating",
"relieving",
"associated",
]
DentalDetail = Literal[
"location",
"radiation",
"character",
"treatment_or_trauma_context",
"constant_or_episodic",
"episode_or_lingering_duration",
"day_or_night_pattern",
"current_and_worst_intensity",
"functional_impact",
"change_over_time",
"spontaneous_or_provoked",
"thermal_trigger",
"bite_or_jaw_trigger",
"relief_measures",
"local_associated_symptoms",
"regional_or_jaw_symptoms",
]
InterviewAxis = Literal[
"",
"chief_concern",
"character_radiation",
"onset",
"duration",
"intensity",
"progression",
"aggravating",
"relieving",
"associated",
"dental_history",
"medical_history",
"red_flag_infection",
"red_flag_airway",
"goals",
]
class NextQuestion(BaseModel):
"""One History-Taker turn: a single intake question, never a conclusion."""
model_config = ConfigDict(extra="ignore")
question: str = Field(default="", max_length=300)
axis: InterviewAxis = ""
covered_axes: list[ODIPARAAxis] = Field(default_factory=list)
covered_details: list[DentalDetail] = Field(default_factory=list)
class ExtractedIntake(BaseModel):
"""Everything the Intake-Extractor persona may report from the transcript."""
model_config = ConfigDict(extra="ignore")
name: str = ""
chief_concern: str = ""
tooth_or_area: str = ""
recent_dental_work: str = ""
symptom_duration: str = ""
pain_score: int = Field(default=0, ge=0, le=10)
biting_pain: bool = False
hot_cold_sensitivity: bool = False
pain_prevents_sleep: bool = False
swelling: bool = False
rapidly_spreading_swelling: bool = False
fever_or_unwell: bool = False
breathing_or_swallowing_issue: bool = False
limited_opening_or_locked_jaw: bool = False
loose_crown_or_bridge: bool = False
trauma_or_sudden_bite_change: bool = False
numbness_or_neuro_symptoms: bool = False
chest_pain_or_jaw_pain_with_exertion: bool = False
jaw_pain_with_chewing_relieved_by_rest: bool = False
vision_scalp_or_new_headache: bool = False
gum_pimple_or_drainage: bool = False
bruising_or_burning_after_root_canal: bool = False
age: int | None = Field(default=None, ge=0, le=120)
language: Literal["English", "Arabic", "Bilingual"] = "English"
meds: str = ""
allergies: str = ""
goals: str = ""
def _strict_schema(model_cls: type[BaseModel]) -> dict[str, Any]:
"""JSON schema for vLLM/xgrammar structured outputs: closed object."""
schema = model_cls.model_json_schema()
schema["additionalProperties"] = False
return schema
def intake_json_schema() -> dict[str, Any]:
return _strict_schema(ExtractedIntake)
def next_question_json_schema() -> dict[str, Any]:
return _strict_schema(NextQuestion)
def _guarded(value: str) -> str:
"""Blank extractor text that reads as diagnosis/treatment, keep the rest.
Blanking (not erroring) is fail-safe here: every guarded field has a
deterministic downstream fallback, and the raw patient transcript — which is
allowed to contain anything — still reaches the rules engine as the story.
"""
cleaned = (value or "").strip()
if not cleaned:
return ""
return cleaned if model_text_is_safe(cleaned) else ""
def extracted_to_intake(
extracted: ExtractedIntake,
) -> tuple[PatientProfile, StructuredIntake]:
"""Bridge validated extractor output into the deterministic pipeline types.
Booleans pass through unguarded: they only feed evaluate_red_flags, where a
spurious True can over-escalate (fail-safe direction) but never suppress a
flag, because story-text matching runs independently of the booleans.
"""
guarded = {field: _guarded(getattr(extracted, field)) for field in _GUARDED_TEXT_FIELDS}
profile = PatientProfile(
name=guarded["name"],
age=extracted.age,
language=extracted.language,
meds=guarded["meds"],
allergies=guarded["allergies"],
goals=guarded["goals"],
)
intake_values: dict[str, Any] = {
"chief_concern": guarded["chief_concern"],
"tooth_or_area": guarded["tooth_or_area"],
"recent_dental_work": guarded["recent_dental_work"],
"symptom_duration": guarded["symptom_duration"],
"pain_score": extracted.pain_score,
}
for field in INTAKE_BOOL_FIELDS:
intake_values[field] = getattr(extracted, field)
return profile, StructuredIntake(**intake_values)