| """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?" |
| ) |
|
|
| |
| |
| _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?", |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _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 "")) |
|
|
|
|
| |
| |
| |
| |
| _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?" |
| ), |
| } |
|
|
|
|
| |
| |
| _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 |
| |
| |
| |
| 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_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, |
| ) |
|
|
|
|
| |
| |
| |
| _AGE_CUE_RE = re.compile( |
| r"(\d{1,3})\s*(?:years?\s*old|years?|yrs?|y/?o|سنه|سنة)" |
| r"|(?:age|aged|i['’]?m|i\s*am|عمري|عمرى|سني)\s*[:\-]?\s*(\d{1,3})", |
| 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)) |
| |
| 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 |
| |
| |
| for match in re.finditer(r"(?<!\d)(\d{1,3})(?!\d)", normalized): |
| age = int(match.group(1)) |
| if 0 <= age <= 120: |
| return age |
| return None |
|
|
|
|
| def _hard_findings(story: str, *, age: int | None) -> 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: |
| |
| |
| |
| 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) |
|
|