"""Hygienist-style guided interview: a pure, bounded state machine. Three agents, one model, and the safety agent is not an LLM at all: - History-Taker (model persona): asks ONE intake question per turn. - Intake-Extractor (model persona): converts the finished transcript to ExtractedIntake for the existing deterministic pipeline. - Safety Sentinel (deterministic): safety_rules.evaluate_red_flags runs over the accumulated patient story after EVERY answer and is the only authority that can interrupt the interview. The model can neither author nor suppress escalation. The machine is pure: step() takes state + answer + an injected call_model and returns a new frozen state. No network, no Gradio, no globals — fully testable. """ from __future__ import annotations import dataclasses import json import re from dataclasses import dataclass from typing import Any, Callable, Protocol from interview_schema import ( DentalDetail, ExtractedIntake, NextQuestion, intake_json_schema, next_question_json_schema, ) from render import HARD_RULE_IDS from safety_rules import evaluate_red_flags from schema import BLOCKED_QUESTION_TERMS, RedFlagFinding, StructuredIntake, model_text_is_safe class CallModel(Protocol): def __call__(self, messages: list[dict[str, Any]], schema: dict[str, Any]) -> dict[str, Any]: ... MAX_TOTAL_TURNS = 15 ODIPARA_AXES = ( "onset", "duration", "intensity", "progression", "aggravating", "relieving", "associated", ) ODIPARA_LABELS = { "onset": "Onset", "duration": "Duration", "intensity": "Intensity", "progression": "Progression", "aggravating": "Aggravating", "relieving": "Relieving", "associated": "Associated", } _QUALIFIER_DETAILS: tuple[DentalDetail, ...] = ( "location", "radiation", "character", ) _ODIPARA_DETAIL_REQUIREMENTS: dict[str, tuple[DentalDetail, ...]] = { "onset": ("treatment_or_trauma_context",), "duration": ( "constant_or_episodic", "episode_or_lingering_duration", "day_or_night_pattern", ), "intensity": ("current_and_worst_intensity", "functional_impact"), "progression": ("change_over_time",), "aggravating": ( "spontaneous_or_provoked", "thermal_trigger", "bite_or_jaw_trigger", ), "relieving": ("relief_measures",), "associated": ("local_associated_symptoms", "regional_or_jaw_symptoms"), } _PHASE_ORDER = ( "demographics", "chief_concern", "dental_qualifiers", "odipara", "background", "red_flag_screen", "goals", ) _PHASE_BUDGET = { "demographics": 1, "chief_concern": 1, "dental_qualifiers": 1, "odipara": len(ODIPARA_AXES), "background": 2, "red_flag_screen": 2, "goals": 1, } _OPENING_QUESTION = ( "Hi, I'm your Dental SOAP interview guide. What name should I use for you, and how old are you?" ) # Deterministic fallback questions — used whenever the model is unavailable or a # model-authored question fails the safety guard. Same register as app._base_questions. _QUESTION_BANK: dict[str, str] = { "chief_concern": ( "What brought you in today, and which exact tooth, side, or jaw area is involved?" ), "character_radiation": ( "How would you describe the feeling, such as sharp, dull, throbbing, burning, " "electric, pressure, or bruised, and does it stay there or spread anywhere?" ), "onset": ( "When and how did this begin, and was it near dental work, an injury, or a change " "in your bite?" ), "duration": ( "Is it constant or in episodes, how long does it or a hot/cold response linger, " "and is there a day or night pattern?" ), "intensity": ( "What is it now and at its worst from 0 to 10, and does it limit eating, speaking, " "opening, or sleep?" ), "progression": ( "Since it began, is it improving, worsening, becoming more frequent, or spreading?" ), "aggravating": ( "Does it start on its own or with hot, cold, sweets, biting down or release, " "chewing, or opening your mouth?" ), "relieving": ( "What have you tried, such as avoiding that side, heat, cold, or medicine, " "and how much did it help?" ), "associated": ( "What else occurs with it, such as swelling, a gum pimple or bad taste, a loose " "or darker tooth, numbness, jaw clicking or locking, headache, or ear symptoms?" ), "dental_history": ( "Have you had recent dental work, an injury, or a similar problem before in this area?" ), "medical_history": ( "What medicines, allergies, or conditions should your dentist know about, including " "blood thinners, steroids, or bone-strengthening medicines?" ), "red_flag_infection": ( "Have you had facial or neck swelling, fever, drainage or a bad taste, " "or felt generally unwell?" ), "red_flag_airway": ( "Have you had trouble breathing or swallowing, new numbness, " "or difficulty opening your mouth?" ), "goals": "What do you most want from your dental visit?", } # --- Deterministic volunteered-detail reader ------------------------------- # The History-Taker model is asked to report covered_details, but a 4B model # does not reliably notice a qualifier the patient volunteered in an earlier # turn (e.g. "sharp pain" given with the chief concern). This reader is the # backstop so the qualifier phase never re-asks what the patient already said, # independent of whether the model cooperates. # # Bias is deliberately conservative. A missed signal only means the question is # still asked (no harm). A false positive would skip a needed question, so # location and radiation require specific locator/spread wording — never a bare # "tooth" — and character relies on distinctive descriptor words. _CHARACTER_TERMS: tuple[str, ...] = ( "sharp", "dull", "throb", "burning", "stabbing", "shooting", "electric", "pressure", "tender", "gnawing", "pulsating", "pounding", "stinging", "aching", "achy", "sore", "bruised", ) _CHARACTER_TERMS_AR: tuple[str, ...] = ("حاد", "نابض", "حارق", "حرقان", "كليل", "ضاغط", "موجع", "وخز") _RADIATION_TERMS: tuple[str, ...] = ( "spread", "radiat", "shoots to", "shoots up", "travels to", "moves to", "up to my", "down to my", "into my ear", "into my jaw", "one spot", "stays in one", "doesn't spread", "does not spread", "doesn't move", "localized", "localised", ) _RADIATION_TERMS_AR: tuple[str, ...] = ("ينتشر", "يمتد", "يوصل", "بيوصل", "بينتشر") _LOCATION_TERMS: tuple[str, ...] = ( "molar", "premolar", "incisor", "canine", "wisdom", "gum", "upper", "lower", "top tooth", "bottom tooth", "back tooth", "front tooth", ) _LOCATION_TERMS_AR: tuple[str, ...] = ("ضرس", "فوق", "تحت", "يمين", "شمال", "يسار") def detect_qualifier_details(story: str) -> frozenset[DentalDetail]: """Read clearly-volunteered pain qualifiers from the accumulated story.""" lowered = story.lower() found: set[DentalDetail] = set() if any(t in lowered for t in _CHARACTER_TERMS) or any(t in story for t in _CHARACTER_TERMS_AR): found.add("character") if any(t in lowered for t in _RADIATION_TERMS) or any(t in story for t in _RADIATION_TERMS_AR): found.add("radiation") if any(t in lowered for t in _LOCATION_TERMS) or any(t in story for t in _LOCATION_TERMS_AR): found.add("location") return frozenset(found) _ARABIC_CHAR_RE = re.compile(r"[؀-ۿ]") def _text_is_arabic(text: str) -> bool: """True when the patient is writing (partly) Arabic — pick Arabic fallbacks.""" return bool(_ARABIC_CHAR_RE.search(text or "")) # Granular qualifier questions keyed by which details are still MISSING. Asking # only the missing pieces is what stops the interview re-asking "sharp or dull?" # after the patient already said "sharp". The (radiation, character) entry reuses # the bundled bank question so existing behavior/tests are unchanged. _QUALIFIER_QUESTIONS: dict[tuple[DentalDetail, ...], str] = { ("location",): "Which exact tooth, side, or area of your mouth or jaw is involved?", ("radiation",): ( "Does the feeling stay in that one spot, or does it spread anywhere, " "such as your jaw, ear, head, or another tooth?" ), ("character",): ( "How would you describe the feeling, such as sharp, dull, throbbing, " "burning, electric, pressure, or bruised?" ), ("location", "radiation"): ( "Which exact tooth or area is involved, and does the feeling stay there " "or spread anywhere, such as your jaw, ear, or head?" ), ("location", "character"): ( "Which exact tooth or area is involved, and how would you describe the " "feeling, such as sharp, dull, throbbing, burning, electric, or pressure?" ), ("location", "radiation", "character"): ( "Which exact tooth or area is involved, how would you describe the feeling, " "such as sharp, dull, throbbing, burning, electric, or pressure, and does it " "stay there or spread anywhere?" ), } # Egyptian-Arabic counterparts — used when the patient writes in Arabic so the # deterministic override never replies in English mid-interview. _QUALIFIER_QUESTIONS_AR: dict[tuple[DentalDetail, ...], str] = { ("location",): "أنهي سن أو ضرس أو ناحية بالظبط في بقك أو فكك؟", ("radiation",): ( "الإحساس بيفضل في مكانه ولا بينتشر لأي حتة تانية، زي الفك أو الودن " "أو الراس أو سنة تانية؟" ), ("character",): ( "إزاي ممكن توصف الإحساس؟ مثلاً حاد، خفيف، نابض، حارق، كهربائي، ضغط، " "ولا زي الكدمة؟" ), ("location", "radiation"): ( "أنهي سن أو ناحية بالظبط، والإحساس بيفضل في مكانه ولا بينتشر زي للفك " "أو الودن أو الراس؟" ), ("location", "character"): ( "أنهي سن أو ناحية بالظبط، وإزاي توصف الإحساس؟ حاد، خفيف، نابض، حارق، " "كهربائي، ولا ضغط؟" ), ("radiation", "character"): ( "إزاي توصف الإحساس — حاد، خفيف، نابض، حارق، كهربائي، ولا ضغط — والإحساس " "بيفضل في مكانه ولا بينتشر؟" ), ("location", "radiation", "character"): ( "أنهي سن أو ناحية بالظبط، إزاي توصف الإحساس (حاد، خفيف، نابض، حارق، " "كهربائي، ضغط)، والإحساس بيفضل في مكانه ولا بينتشر؟" ), } def _qualifier_question(missing: tuple[DentalDetail, ...], *, arabic: bool = False) -> str: """Question covering only the missing qualifier details, in canonical order. Picks the Arabic phrasing when the patient is writing Arabic, so the deterministic override (used when the model is down or re-asks a covered detail) never flips the interview into English mid-conversation. """ key = tuple(detail for detail in _QUALIFIER_DETAILS if detail in set(missing)) if arabic: return _QUALIFIER_QUESTIONS_AR.get( key, _QUALIFIER_QUESTIONS_AR[("location", "radiation", "character")] ) return _QUALIFIER_QUESTIONS.get(key, _QUESTION_BANK["character_radiation"]) _WRAP_UP_MESSAGE = ( "Thank you — the interview is complete. You can now build your dentist handoff, " "review it, print it, or download the PDF." ) HISTORY_TAKER_PROMPT = """You are the history-taking assistant of Dental SOAP, working like a dental hygienist preparing a patient for a dentist visit. Your goal is to gather a complete and organized dental history from the patient. Be conversational, empathetic, and clinical but warm. Avoid sounding robotic or like a cold form. Use ODIPARA to characterize the current problem: - onset: when/how it began and whether dental work, trauma, or a bite change preceded it - duration: constant versus episodic, episode/thermal-lingering length, and day/night pattern - intensity: current and worst 0-10 severity plus impact on eating, speaking, opening, or sleep - progression: better, worse, more frequent, spreading, unchanged, or otherwise changing - aggravating: spontaneous versus provoked; thermal/sweets; biting down/release, chewing, or opening - relieving: measures tried and their effect - associated: local dental/gum signs plus numbness, jaw/TMJ, headache, or ear symptoms Before ODIPARA, make sure the exact location, the symptom character, and whether it stays local or radiates are known. Never ask again about any detail the patient has already given — ask only about what is still missing. Use covered_details to report only dental details explicitly answered anywhere in the transcript. Only mark an ODIPARA axis in covered_axes when every required detail for that axis is present. Hard rules: - Never diagnose, never name a suspected condition, never recommend treatment. - Never interpret X-rays or images, never ask the patient to upload images. - Never state urgency or tell the patient to go anywhere; a separate safety system owns that. - Ask exactly ONE short, plain-language question based on the conversation so far. - The question must end with a question mark and be easily answerable by a layperson. - Keep the question focused on exactly one target axis. Do not bundle unrelated history items. - Mark every fully answered ODIPARA axis in covered_axes. - If the suggested target was already answered, choose another allowed unanswered axis. - The axis must be one of the allowed target axes supplied by the application. - Adapt to the patient's language. If the patient responds in Arabic, write the next question in Arabic. If they respond in English, write it in English. - If every ODIPARA axis is covered, return an empty question and empty axis. - Return JSON: {"question": "...", "axis": "...", "covered_axes": ["..."], "covered_details": ["..."]}.""" EXTRACTOR_PROMPT = """You are the intake-extraction assistant of Dental SOAP. Convert the finished patient interview transcript into the JSON schema supplied. Hard rules: - Use only facts the patient stated; never infer a diagnosis or treatment need. - Copy the patient's own wording for the chief concern, neutrally summarized. - Set a symptom boolean to true only when the patient clearly reported it. - Patients may use shorthand: "endo" or "RCT" means root canal treatment; a "cap" means a crown; "pulled" means extraction. Record shorthand as recent dental work. - Leave any field you are unsure about at its default. - Return only the JSON object.""" @dataclass(frozen=True) class InterviewState: phase: str = "demographics" phase_turns: int = 0 user_turns: tuple[str, ...] = () questions_asked: tuple[str, ...] = () axes_asked: tuple[str, ...] = () axes_covered: tuple[str, ...] = () details_covered: tuple[str, ...] = () patient_age: int | None = None early_exit: bool = False done: bool = False # Honest-dashboard telemetry: how many asked questions actually came from # the model (vs the deterministic question bank). The UI must never claim # the History agent "completed" a model run that never happened. model_question_count: int = 0 @dataclass(frozen=True) class StepResult: state: InterviewState assistant_message: str hard_findings: tuple[RedFlagFinding, ...] = () def opening_question() -> str: return _OPENING_QUESTION def story_so_far(state: InterviewState) -> str: return " ".join(turn.strip() for turn in state.user_turns if turn.strip()) _DIGIT_TRANSLATION = str.maketrans("٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹", "01234567890123456789") # Tooth-numbered mentions ("tooth 14", "molar #3", "الضرس رقم 26") must never be # read as an age: a false young age silently disables age-gated red-flag rules # (GCA fires at >=50), which is strictly worse than the fail-safe None. _TOOTH_NUMBER = re.compile( r"(?:\btooth\b|\bteeth\b|\bmolar\b|\bpremolar\b|\bincisor\b|#" r"|(?:ال)?ضرس\w*|\bسن\b|\bالسن\b)" r"\s*(?:number|no\.?|رقم)?\s*[:#]?\s*\d{1,3}", re.IGNORECASE, ) # A number tied to an explicit age cue beats a bare number, so "I have 2 missing # teeth and I am 58" reads 58 — not 2. A wrong young age silently disables the # age-gated GCA rule (fires at >=50), so this guards a real safety bypass. _AGE_CUE_RE = re.compile( r"(\d{1,3})\s*(?:years?\s*old|years?|yrs?|y/?o|سنه|سنة)" # "58 years old", "58 سنة" r"|(?:age|aged|i['’]?m|i\s*am|عمري|عمرى|سني)\s*[:\-]?\s*(\d{1,3})", # "I'm 58", "عمري 58" re.IGNORECASE, ) def _extract_age(answer: str) -> int | None: """Read a stated age from the dedicated demographics answer.""" normalized = _TOOTH_NUMBER.sub(" ", answer.translate(_DIGIT_TRANSLATION)) # Prefer a number bound to an explicit age cue over a bare leading number. for match in _AGE_CUE_RE.finditer(normalized): token = match.group(1) or match.group(2) if token is not None: age = int(token) if 0 <= age <= 120: return age # Fallback: the first standalone number (demographics answers are usually # "Name, age", where the only number IS the age). for match in re.finditer(r"(? tuple[RedFlagFinding, ...]: """Deterministic sentinel over the raw accumulated story. A blank StructuredIntake is intentional: at interview time nothing structured exists yet, and the rules engine's story-text matchers (hardened by the 1,040-story audit) are the authority. The age stated in the dedicated demographics answer is passed through for age-gated safety rules. """ findings = evaluate_red_flags(story, StructuredIntake(), age=age) return tuple(f for f in findings if f.rule_id in HARD_RULE_IDS) def _interrupt_message(findings: tuple[RedFlagFinding, ...]) -> str: lead = findings[0] return ( f"{lead.patient_message}\n\n" "I've stopped the interview so this isn't delayed. Your handoff below " "includes what you told me and the urgent-care guidance — please act on " "it first." ) def _next_phase(phase: str) -> str | None: index = _PHASE_ORDER.index(phase) return _PHASE_ORDER[index + 1] if index + 1 < len(_PHASE_ORDER) else None def _advance(state: InterviewState) -> InterviewState: """Move through every phase whose coverage requirement is already satisfied. Coverage-gated phases (qualifiers, ODIPARA) may be skipped outright when the patient has already volunteered everything they cover; turn-gated phases (including both red-flag screens) always need at least one turn on entry, so they are never auto-skipped — the safety questions are always asked. """ while True: if state.phase == "dental_qualifiers": complete = all(detail in state.details_covered for detail in _QUALIFIER_DETAILS) complete = complete or state.phase_turns >= _PHASE_BUDGET[state.phase] elif state.phase == "odipara": complete = all(axis in state.axes_covered for axis in ODIPARA_AXES) complete = complete or state.phase_turns >= _PHASE_BUDGET[state.phase] else: complete = state.phase_turns >= _PHASE_BUDGET[state.phase] if not complete: return state nxt = _next_phase(state.phase) if nxt is None: return dataclasses.replace(state, done=True) state = dataclasses.replace(state, phase=nxt, phase_turns=0) def _bank_question(state: InterviewState) -> tuple[str, str]: """Deterministic (question, axis) for the current phase.""" if state.phase == "demographics": return _OPENING_QUESTION, "demographics" if state.phase == "chief_concern": return _QUESTION_BANK["chief_concern"], "chief_concern" if state.phase == "dental_qualifiers": missing = tuple(d for d in _QUALIFIER_DETAILS if d not in state.details_covered) if not missing: return "", "" arabic = _text_is_arabic(story_so_far(state)) return _qualifier_question(missing, arabic=arabic), "character_radiation" if state.phase == "odipara": for axis in ODIPARA_AXES: if axis not in state.axes_covered: return _QUESTION_BANK[axis], axis return "", "" if state.phase == "background": for axis in ("dental_history", "medical_history"): if axis not in state.axes_asked: return _QUESTION_BANK[axis], axis return _QUESTION_BANK["medical_history"], "medical_history" if state.phase == "red_flag_screen": for axis in ("red_flag_infection", "red_flag_airway"): if axis not in state.axes_asked: return _QUESTION_BANK[axis], axis return _QUESTION_BANK["red_flag_airway"], "red_flag_airway" return _QUESTION_BANK["goals"], "goals" def _question_is_safe(question: str) -> bool: if not question.endswith("?"): return False lowered = question.lower() if any(term in lowered for term in BLOCKED_QUESTION_TERMS): return False return model_text_is_safe(question) def _transcript_messages(state: InterviewState) -> list[dict[str, Any]]: """Interleave asked questions and answers for the model's context.""" messages: list[dict[str, Any]] = [] questions = (_OPENING_QUESTION,) + state.questions_asked for index, answer in enumerate(state.user_turns): if index < len(questions): messages.append({"role": "assistant", "content": questions[index]}) messages.append({"role": "user", "content": answer}) return messages def _allowed_axes(state: InterviewState, bank_axis: str) -> list[str]: if state.phase == "odipara": return [axis for axis in ODIPARA_AXES if axis not in state.axes_covered] return [bank_axis] def _required_details(axis: str) -> tuple[DentalDetail, ...]: if axis == "character_radiation": return _QUALIFIER_DETAILS return _ODIPARA_DETAIL_REQUIREMENTS.get(axis, ()) def _model_question( state: InterviewState, call_model: CallModel, ) -> tuple[str, str, tuple[str, ...], tuple[str, ...], bool]: """History-Taker persona with deterministic fallback. Never raises. Returns (question, axis, covered_axes, covered_details, from_model). from_model is False whenever the deterministic bank supplied the question. """ bank_question, bank_axis = _bank_question(state) allowed_axes = _allowed_axes(state, bank_axis) try: raw = call_model( [ {"role": "system", "content": HISTORY_TAKER_PROMPT}, *_transcript_messages(state), { "role": "user", "content": ( f"Interview phase: {state.phase}.\n" f"Suggested target axis: {bank_axis}.\n" f"Allowed target axes: {', '.join(allowed_axes) or 'none'}.\n" f"Axes already asked: {', '.join(state.axes_asked) or 'none'}.\n" f"ODIPARA axes already covered: {', '.join(state.axes_covered) or 'none'}.\n" f"Dental details already covered: " f"{', '.join(state.details_covered) or 'none'}.\n" "Review the whole transcript, report all dental details and fully answered " "ODIPARA axes, then ask one focused question for an allowed target. " "If all ODIPARA axes are covered, return an empty question and axis." ), }, ], next_question_json_schema(), ) candidate = NextQuestion.model_validate(raw) except Exception: return bank_question, bank_axis, (), (), False covered_details = tuple(dict.fromkeys(candidate.covered_details)) combined_details = set(state.details_covered) | set(covered_details) covered = () if state.phase == "odipara": covered = tuple( axis for axis in dict.fromkeys(candidate.covered_axes) if set(_required_details(axis)).issubset(combined_details) ) covered_set = set(state.axes_covered) | set(covered) remaining = [axis for axis in ODIPARA_AXES if axis not in covered_set] if not remaining: return "", "", covered, covered_details, True allowed_axes = remaining bank_axis = remaining[0] bank_question = _QUESTION_BANK[bank_axis] elif state.phase == "dental_qualifiers": missing = tuple(d for d in _QUALIFIER_DETAILS if d not in combined_details) if not missing: return "", "", (), covered_details, True arabic = _text_is_arabic(story_so_far(state)) if missing != _QUALIFIER_DETAILS: # The patient already volunteered at least one qualifier. Ask only what # is still missing — a model that ignores covered_details cannot re-ask # the part the patient already answered. return ( _qualifier_question(missing, arabic=arabic), "character_radiation", (), covered_details, False, ) bank_question = _qualifier_question(missing, arabic=arabic) question = candidate.question.strip() if not _question_is_safe(question): return bank_question, bank_axis, covered, covered_details, False axis = candidate.axis.strip() if axis not in allowed_axes: return bank_question, bank_axis, covered, covered_details, False if axis in state.axes_asked or question.casefold() in { asked.casefold() for asked in state.questions_asked }: return bank_question, bank_axis, covered, covered_details, False return question, axis, covered, covered_details, True def _record_answered_axis(state: InterviewState) -> InterviewState: """Treat the dental details targeted by the last focused question as addressed.""" if not state.axes_asked: return state answered_axis = state.axes_asked[-1] required = _required_details(answered_axis) if not required: return state details = tuple(dict.fromkeys((*state.details_covered, *required))) axes = state.axes_covered if answered_axis in ODIPARA_AXES and answered_axis not in axes: axes = axes + (answered_axis,) return dataclasses.replace(state, details_covered=details, axes_covered=axes) def _ask_next(state: InterviewState, call_model: CallModel) -> tuple[InterviewState, str]: """Choose the next question, skipping ODIPARA axes already volunteered.""" while not state.done: question, axis, covered, details, from_model = _model_question(state, call_model) if covered or details: merged_axes = tuple(dict.fromkeys((*state.axes_covered, *covered))) merged_details = tuple(dict.fromkeys((*state.details_covered, *details))) state = dataclasses.replace( state, axes_covered=merged_axes, details_covered=merged_details, ) advanced = _advance(state) if advanced.phase != state.phase or advanced.done: state = advanced continue if not question or not axis: bank_question, bank_axis = _bank_question(state) question, axis, from_model = bank_question, bank_axis, False asked = dataclasses.replace( state, questions_asked=state.questions_asked + (question,), axes_asked=state.axes_asked + (axis,), model_question_count=state.model_question_count + (1 if from_model else 0), ) return asked, question return state, _WRAP_UP_MESSAGE def step(state: InterviewState, user_message: str, *, call_model: CallModel) -> StepResult: """One interview turn: record the answer, run the sentinel, ask or finish.""" if state.done: return StepResult(state=state, assistant_message="") patient_age = state.patient_age if state.phase == "demographics" and patient_age is None: patient_age = _extract_age(user_message) state = dataclasses.replace( state, user_turns=state.user_turns + (user_message,), phase_turns=state.phase_turns + 1, patient_age=patient_age, ) state = _record_answered_axis(state) volunteered = detect_qualifier_details(story_so_far(state)) if volunteered: state = dataclasses.replace( state, details_covered=tuple(dict.fromkeys((*state.details_covered, *volunteered))), ) findings = _hard_findings(story_so_far(state), age=state.patient_age) if findings: interrupted = dataclasses.replace(state, early_exit=True, done=True) return StepResult( state=interrupted, assistant_message=_interrupt_message(findings), hard_findings=findings, ) state = _advance(state) if state.done or len(state.user_turns) >= MAX_TOTAL_TURNS: finished = dataclasses.replace(state, done=True) return StepResult(state=finished, assistant_message=_WRAP_UP_MESSAGE) asked, question = _ask_next(state, call_model) return StepResult(state=asked, assistant_message=question) def progress_summary(state: InterviewState) -> tuple[int, int, str]: """Return completed milestones, total milestones, and a patient-facing stage.""" weights = dict(_PHASE_BUDGET) labels = { "demographics": "Getting acquainted", "chief_concern": "Your main concern", "dental_qualifiers": "Location and pain character", "odipara": "Understanding the symptom", "background": "Dental and medical background", "red_flag_screen": "Safety check", "goals": "Your visit goal", } total = sum(weights.values()) if state.done: label = "Safety stop" if state.early_exit else "Ready to build" return total, total, label phase_index = _PHASE_ORDER.index(state.phase) completed = sum(weights[phase] for phase in _PHASE_ORDER[:phase_index]) if state.phase == "odipara": completed += len(set(state.axes_covered) & set(ODIPARA_AXES)) else: completed += min(state.phase_turns, weights[state.phase]) return completed, total, labels[state.phase] def finalize(state: InterviewState, *, call_model: CallModel) -> ExtractedIntake: """Intake-Extractor persona over the finished transcript. Raises on any failure — the caller falls back to a blank ExtractedIntake; the raw transcript still reaches the rules engine as the story either way. """ transcript = _transcript_messages(state) raw = call_model( [ {"role": "system", "content": EXTRACTOR_PROMPT}, { "role": "user", "content": ( "Interview transcript (assistant questions and patient answers):\n" + json.dumps(transcript, ensure_ascii=False) ), }, ], intake_json_schema(), ) return ExtractedIntake.model_validate(raw)