| """Interview state machine: phase flow, turn caps, question guarding, immutability.""" |
| from __future__ import annotations |
|
|
| import dataclasses |
|
|
| import pytest |
|
|
| import interview |
| from interview import ( |
| MAX_TOTAL_TURNS, |
| InterviewState, |
| finalize, |
| opening_question, |
| step, |
| ) |
|
|
|
|
| def _fake_question_model(question: str = "When did this start?", axis: str = "onset"): |
| """call_model stub returning a fixed History-Taker question.""" |
|
|
| def call_model(messages, schema): |
| return { |
| "question": question, |
| "axis": axis, |
| "covered_axes": [], |
| "covered_details": [], |
| } |
|
|
| return call_model |
|
|
|
|
| BENIGN_ANSWER = "My crown feels a bit high when I bite on that side." |
|
|
|
|
| def test_opening_question_is_deterministic_and_safe(): |
| q = opening_question() |
| assert q.endswith("?") |
| assert "diagnos" not in q.lower() |
|
|
|
|
| def test_first_step_advances_from_demographics(): |
| state = InterviewState() |
| result = step(state, BENIGN_ANSWER, call_model=_fake_question_model()) |
| assert result.state.phase != "demographics" |
| assert result.state.user_turns == (BENIGN_ANSWER,) |
| assert result.assistant_message.endswith("?") |
| assert not result.state.done |
|
|
|
|
| def test_state_is_immutable(): |
| state = InterviewState() |
| with pytest.raises(dataclasses.FrozenInstanceError): |
| state.phase = "goals" |
|
|
|
|
| def test_step_returns_new_state_object(): |
| state = InterviewState() |
| result = step(state, BENIGN_ANSWER, call_model=_fake_question_model()) |
| assert result.state is not state |
| assert state.user_turns == () |
|
|
|
|
| def test_total_turn_cap_always_terminates(): |
| state = InterviewState() |
| answers = 0 |
| for _ in range(MAX_TOTAL_TURNS + 3): |
| result = step(state, BENIGN_ANSWER, call_model=_fake_question_model()) |
| state = result.state |
| answers += 1 |
| if state.done: |
| break |
| assert state.done |
| assert answers <= MAX_TOTAL_TURNS + 1 |
|
|
|
|
| def test_interview_completes_through_all_phases(): |
| """A benign interview must reach done with phases traversed in order.""" |
| state = InterviewState() |
| seen_phases = [state.phase] |
| for _ in range(MAX_TOTAL_TURNS + 3): |
| result = step(state, BENIGN_ANSWER, call_model=_fake_question_model()) |
| state = result.state |
| if state.phase not in seen_phases: |
| seen_phases.append(state.phase) |
| if state.done: |
| break |
| assert state.done |
| assert not state.early_exit |
| assert seen_phases.index("demographics") < seen_phases.index("chief_concern") |
| assert seen_phases.index("chief_concern") < seen_phases.index("dental_qualifiers") |
| assert seen_phases.index("dental_qualifiers") < seen_phases.index("odipara") |
| assert seen_phases.index("odipara") < seen_phases.index("background") |
| assert seen_phases.index("background") < seen_phases.index("red_flag_screen") |
| assert seen_phases.index("red_flag_screen") < seen_phases.index("goals") |
|
|
|
|
| def test_unsafe_model_question_falls_back_to_bank(): |
| """A diagnosis-flavored question from the model must never reach the patient.""" |
| bad = _fake_question_model( |
| question="Sounds like an abscess — is it swollen?", axis="provocation" |
| ) |
| result = step(InterviewState(), BENIGN_ANSWER, call_model=bad) |
| msg = result.assistant_message.lower() |
| assert "abscess" not in msg |
| assert result.assistant_message.endswith("?") |
|
|
|
|
| def test_model_failure_falls_back_to_bank(): |
| def broken(messages, schema): |
| raise RuntimeError("modal cold") |
|
|
| result = step(InterviewState(), BENIGN_ANSWER, call_model=broken) |
| assert result.assistant_message.endswith("?") |
| assert not result.state.done |
|
|
|
|
| def test_non_question_model_output_falls_back(): |
| flat = _fake_question_model(question="Tell me more about the pain.", axis="quality") |
| result = step(InterviewState(), BENIGN_ANSWER, call_model=flat) |
| assert result.assistant_message.endswith("?") |
|
|
|
|
| def test_odipara_axes_are_not_repeated(): |
| """The same ODIPARA axis must not be asked twice in one interview.""" |
| state = InterviewState() |
| for _ in range(MAX_TOTAL_TURNS + 3): |
| result = step(state, BENIGN_ANSWER, call_model=_fake_question_model()) |
| state = result.state |
| if state.done: |
| break |
| duplicated = [a for a in state.axes_asked if state.axes_asked.count(a) > 1] |
| assert duplicated == [], f"axes repeated: {duplicated}" |
| assert set(interview.ODIPARA_AXES).issubset(state.axes_covered) |
|
|
|
|
| def test_model_marks_volunteered_odipara_axes_as_covered(): |
| state = InterviewState( |
| phase="odipara", |
| user_turns=("Sam, 35.", "Upper molar pain."), |
| questions_asked=("What brought you in today?",), |
| axes_asked=("chief_concern",), |
| ) |
|
|
| def coverage_model(messages, schema): |
| return { |
| "question": "Since it began, is it getting better, worse, or staying the same?", |
| "axis": "progression", |
| "covered_axes": ["onset", "duration", "intensity"], |
| "covered_details": [ |
| "treatment_or_trauma_context", |
| "constant_or_episodic", |
| "episode_or_lingering_duration", |
| "day_or_night_pattern", |
| "current_and_worst_intensity", |
| "functional_impact", |
| ], |
| } |
|
|
| result = step( |
| state, |
| "It began yesterday, lasts ten minutes, and reaches 7 out of 10.", |
| call_model=coverage_model, |
| ) |
| assert {"onset", "duration", "intensity"}.issubset(result.state.axes_covered) |
| assert result.state.axes_asked[-1] == "progression" |
|
|
|
|
| def test_model_cannot_skip_an_axis_without_its_dental_details(): |
| state = InterviewState( |
| phase="odipara", |
| user_turns=("Sam, 35.", "Upper molar pain."), |
| questions_asked=("What brought you in today?",), |
| axes_asked=("chief_concern",), |
| ) |
|
|
| def shallow_coverage_model(messages, schema): |
| return { |
| "question": "Since it began, is it getting better or worse?", |
| "axis": "progression", |
| "covered_axes": ["onset", "duration", "intensity"], |
| "covered_details": [], |
| } |
|
|
| result = step(state, "It hurts.", call_model=shallow_coverage_model) |
| assert not {"onset", "duration", "intensity"} & set(result.state.axes_covered) |
| assert result.state.axes_asked[-1] == "progression" |
|
|
| follow_up = step(result.state, "It is getting worse.", call_model=shallow_coverage_model) |
| assert follow_up.state.axes_asked[-1] == "onset" |
| assert follow_up.assistant_message == interview._QUESTION_BANK["onset"] |
|
|
|
|
| def test_model_cannot_leave_the_odipara_axis_contract(): |
| state = InterviewState( |
| phase="odipara", |
| user_turns=("Sam, 35.", "Upper molar pain."), |
| questions_asked=("What brought you in today?",), |
| axes_asked=("chief_concern",), |
| ) |
| invalid = _fake_question_model( |
| question="How would you describe the quality of the pain?", |
| axis="quality", |
| ) |
| result = step(state, "It hurts.", call_model=invalid) |
| assert result.state.axes_asked[-1] == "onset" |
| assert result.assistant_message == interview._QUESTION_BANK["onset"] |
|
|
|
|
| def test_fallback_reaches_both_red_flag_screens(): |
| def broken(messages, schema): |
| raise RuntimeError("model unavailable") |
|
|
| state = InterviewState() |
| for _ in range(MAX_TOTAL_TURNS + 1): |
| result = step(state, BENIGN_ANSWER, call_model=broken) |
| state = result.state |
| if state.done: |
| break |
| assert state.done |
| assert "red_flag_infection" in state.axes_asked |
| assert "red_flag_airway" in state.axes_asked |
| assert set(interview.ODIPARA_AXES).issubset(state.axes_covered) |
| assert "character_radiation" in state.axes_asked |
| assert set(interview._QUALIFIER_DETAILS).issubset(state.details_covered) |
|
|
|
|
| def test_fallback_questions_cover_dental_specific_odipara_details(): |
| bank = interview._QUESTION_BANK |
| assert "spread" in bank["character_radiation"] |
| assert "dental work" in bank["onset"] |
| assert "linger" in bank["duration"] |
| assert "eating" in bank["intensity"] |
| assert "biting down or release" in bank["aggravating"] |
| assert "gum pimple" in bank["associated"] |
|
|
|
|
| def test_progress_summary_is_complete_only_when_interview_is_done(): |
| completed, total, stage = interview.progress_summary(InterviewState()) |
| assert completed == 0 |
| |
| assert total == sum(interview._PHASE_BUDGET.values()) |
| assert stage == "Getting acquainted" |
|
|
| done = dataclasses.replace(InterviewState(), done=True) |
| completed, total, stage = interview.progress_summary(done) |
| assert completed == total |
| assert stage == "Ready to build" |
|
|
|
|
| def test_step_after_done_is_a_noop(): |
| done_state = dataclasses.replace(InterviewState(), done=True) |
| result = step(done_state, "anything", call_model=_fake_question_model()) |
| assert result.state.done |
| assert result.state.user_turns == () |
|
|
|
|
| def test_finalize_returns_validated_extracted_intake(): |
| def extractor(messages, schema): |
| return { |
| "chief_concern": "Crown feels high", |
| "tooth_or_area": "Upper left molar", |
| "recent_dental_work": "Crown placement", |
| "symptom_duration": "Three weeks", |
| "pain_score": 6, |
| "biting_pain": True, |
| "goals": "Understand whether the crown fit explains the pain", |
| } |
|
|
| state = InterviewState(user_turns=(BENIGN_ANSWER,)) |
| extracted = finalize(state, call_model=extractor) |
| assert extracted.chief_concern == "Crown feels high" |
| assert extracted.biting_pain is True |
| assert extracted.pain_score == 6 |
|
|
|
|
| def test_finalize_raises_on_invalid_payload(): |
| def extractor(messages, schema): |
| return {"pain_score": 55} |
|
|
| with pytest.raises(Exception): |
| finalize(InterviewState(user_turns=(BENIGN_ANSWER,)), call_model=extractor) |
|
|
|
|
| def test_story_so_far_joins_only_user_turns(): |
| state = InterviewState(user_turns=("First answer.", "Second answer.")) |
| story = interview.story_so_far(state) |
| assert "First answer." in story |
| assert "Second answer." in story |
|
|