feat: Implement Study Interview Mode as default study creation method
Study Interview Mode is now the DEFAULT for all study creation requests. This intelligent Q&A system guides users through optimization setup with: - 7-phase interview flow: introspection → objectives → constraints → design_variables → validation → review → complete - Material-aware validation with 12 materials and fuzzy name matching - Anti-pattern detection for 12 common mistakes (mass-no-constraint, stress-over-yield, etc.) - Auto extractor mapping E1-E24 based on goal keywords - State persistence with JSON serialization and backup rotation - StudyBlueprint generation with full validation Triggers: "create a study", "new study", "optimize this", any study creation intent Skip with: "skip interview", "quick setup", "manual config" Components: - StudyInterviewEngine: Main orchestrator - QuestionEngine: Conditional logic evaluation - EngineeringValidator: MaterialsDatabase + AntiPatternDetector - InterviewPresenter: Markdown formatting for Claude - StudyBlueprint: Validated configuration output - InterviewState: Persistent state management All 129 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
431
tests/interview/test_study_interview.py
Normal file
431
tests/interview/test_study_interview.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""Integration tests for StudyInterviewEngine."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from optimization_engine.interview.study_interview import (
|
||||
StudyInterviewEngine,
|
||||
InterviewSession,
|
||||
NextAction,
|
||||
run_interview,
|
||||
)
|
||||
from optimization_engine.interview.interview_state import InterviewState, InterviewPhase
|
||||
|
||||
|
||||
class TestInterviewSession:
|
||||
"""Tests for InterviewSession dataclass."""
|
||||
|
||||
def test_creation(self):
|
||||
"""Test creating interview session."""
|
||||
from datetime import datetime
|
||||
|
||||
session = InterviewSession(
|
||||
session_id="abc123",
|
||||
study_name="test_study",
|
||||
study_path=Path("/tmp/test"),
|
||||
started_at=datetime.now(),
|
||||
current_phase=InterviewPhase.INTROSPECTION,
|
||||
introspection={}
|
||||
)
|
||||
|
||||
assert session.session_id == "abc123"
|
||||
assert session.study_name == "test_study"
|
||||
assert not session.is_complete
|
||||
|
||||
|
||||
class TestNextAction:
|
||||
"""Tests for NextAction dataclass."""
|
||||
|
||||
def test_ask_question_action(self):
|
||||
"""Test ask_question action type."""
|
||||
from optimization_engine.interview.question_engine import Question
|
||||
|
||||
question = Question(
|
||||
id="test",
|
||||
category="test",
|
||||
text="Test?",
|
||||
question_type="text",
|
||||
maps_to="test_field"
|
||||
)
|
||||
|
||||
action = NextAction(
|
||||
action_type="ask_question",
|
||||
question=question,
|
||||
message="Test question"
|
||||
)
|
||||
|
||||
assert action.action_type == "ask_question"
|
||||
assert action.question is not None
|
||||
|
||||
def test_show_summary_action(self):
|
||||
"""Test show_summary action type."""
|
||||
action = NextAction(
|
||||
action_type="show_summary",
|
||||
message="Summary here"
|
||||
)
|
||||
|
||||
assert action.action_type == "show_summary"
|
||||
|
||||
def test_error_action(self):
|
||||
"""Test error action type."""
|
||||
action = NextAction(
|
||||
action_type="error",
|
||||
message="Something went wrong"
|
||||
)
|
||||
|
||||
assert action.action_type == "error"
|
||||
assert "wrong" in action.message
|
||||
|
||||
|
||||
class TestStudyInterviewEngine:
|
||||
"""Tests for StudyInterviewEngine."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test engine initialization."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
|
||||
assert engine.study_path == study_path
|
||||
assert engine.state is None
|
||||
assert engine.presenter is not None
|
||||
|
||||
def test_start_interview_new(self):
|
||||
"""Test starting a new interview."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
session = engine.start_interview("test_study")
|
||||
|
||||
assert session is not None
|
||||
assert session.study_name == "test_study"
|
||||
assert not session.is_resumed
|
||||
assert engine.state is not None
|
||||
|
||||
def test_start_interview_with_introspection(self):
|
||||
"""Test starting interview with introspection data."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
|
||||
introspection = {
|
||||
"expressions": ["thickness", "width"],
|
||||
"model_path": "/path/model.prt",
|
||||
"sim_path": "/path/sim.sim"
|
||||
}
|
||||
|
||||
session = engine.start_interview(
|
||||
"test_study",
|
||||
introspection=introspection
|
||||
)
|
||||
|
||||
assert session.introspection == introspection
|
||||
# Should skip introspection phase
|
||||
assert engine.state.get_phase() == InterviewPhase.PROBLEM_DEFINITION
|
||||
|
||||
def test_start_interview_resume(self):
|
||||
"""Test resuming an existing interview."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
# Start first interview
|
||||
engine1 = StudyInterviewEngine(study_path)
|
||||
session1 = engine1.start_interview("test_study")
|
||||
|
||||
# Make some progress
|
||||
engine1.state.answers["study_description"] = "Test"
|
||||
engine1.state.set_phase(InterviewPhase.OBJECTIVES)
|
||||
engine1.state_manager.save_state(engine1.state)
|
||||
|
||||
# Create new engine and resume
|
||||
engine2 = StudyInterviewEngine(study_path)
|
||||
session2 = engine2.start_interview("test_study")
|
||||
|
||||
assert session2.is_resumed
|
||||
assert engine2.state.get_phase() == InterviewPhase.OBJECTIVES
|
||||
|
||||
def test_get_first_question(self):
|
||||
"""Test getting first question."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={"expressions": []})
|
||||
|
||||
action = engine.get_first_question()
|
||||
|
||||
assert action.action_type == "ask_question"
|
||||
assert action.question is not None
|
||||
assert action.message is not None
|
||||
|
||||
def test_get_first_question_without_start(self):
|
||||
"""Test error when getting question without starting."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
|
||||
action = engine.get_first_question()
|
||||
|
||||
assert action.action_type == "error"
|
||||
|
||||
def test_process_answer(self):
|
||||
"""Test processing an answer."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={})
|
||||
|
||||
# Get first question
|
||||
action = engine.get_first_question()
|
||||
assert action.question is not None
|
||||
|
||||
# Answer it
|
||||
next_action = engine.process_answer("This is my test study description")
|
||||
|
||||
# May get next question, show summary, error, or confirm_warning
|
||||
assert next_action.action_type in ["ask_question", "show_summary", "error", "confirm_warning"]
|
||||
|
||||
def test_process_answer_invalid(self):
|
||||
"""Test processing an invalid answer."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={})
|
||||
engine.get_first_question()
|
||||
|
||||
# For a required question, empty answer should fail
|
||||
# This depends on question validation rules
|
||||
# Just verify we don't crash
|
||||
action = engine.process_answer("")
|
||||
assert action.action_type in ["error", "ask_question"]
|
||||
|
||||
def test_full_simple_interview_flow(self):
|
||||
"""Test complete simple interview flow."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={
|
||||
"expressions": [
|
||||
{"name": "thickness", "value": 5.0},
|
||||
{"name": "width", "value": 10.0}
|
||||
],
|
||||
"model_path": "/model.prt",
|
||||
"sim_path": "/sim.sim"
|
||||
})
|
||||
|
||||
# Simulate answering questions
|
||||
answers = [
|
||||
"Bracket mass optimization", # study description
|
||||
"minimize mass", # objective
|
||||
"1", # single objective confirm
|
||||
"stress, 200 MPa", # constraint
|
||||
"thickness, width", # design variables
|
||||
"yes", # confirm settings
|
||||
]
|
||||
|
||||
action = engine.get_first_question()
|
||||
max_iterations = 20
|
||||
|
||||
for i, answer in enumerate(answers):
|
||||
if action.action_type == "show_summary":
|
||||
break
|
||||
if action.action_type == "error":
|
||||
# Try to recover
|
||||
continue
|
||||
|
||||
action = engine.process_answer(answer)
|
||||
|
||||
if i > max_iterations:
|
||||
break
|
||||
|
||||
# Should eventually complete or show summary
|
||||
# (exact behavior depends on question flow)
|
||||
|
||||
def test_acknowledge_warnings(self):
|
||||
"""Test acknowledging warnings."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={})
|
||||
|
||||
# Add some warnings
|
||||
engine.state.add_warning("Test warning 1")
|
||||
engine.state.add_warning("Test warning 2")
|
||||
|
||||
action = engine.acknowledge_warnings(acknowledged=True)
|
||||
|
||||
# Warnings should be acknowledged
|
||||
assert "Test warning 1" in engine.state.warnings_acknowledged
|
||||
assert "Test warning 2" in engine.state.warnings_acknowledged
|
||||
|
||||
def test_acknowledge_warnings_rejected(self):
|
||||
"""Test rejecting warnings pauses interview."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={})
|
||||
engine.state.add_warning("Test warning")
|
||||
|
||||
action = engine.acknowledge_warnings(acknowledged=False)
|
||||
|
||||
assert action.action_type == "error"
|
||||
|
||||
def test_generate_blueprint(self):
|
||||
"""Test blueprint generation."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={
|
||||
"model_path": "/model.prt",
|
||||
"sim_path": "/sim.sim"
|
||||
})
|
||||
|
||||
# Set up minimal answers for blueprint
|
||||
engine.state.answers = {
|
||||
"study_description": "Test",
|
||||
"objectives": [{"goal": "minimize_mass"}],
|
||||
"constraints": [{"type": "stress", "threshold": 200}],
|
||||
"design_variables": [{"name": "t", "min": 1, "max": 10}],
|
||||
}
|
||||
|
||||
blueprint = engine.generate_blueprint()
|
||||
|
||||
assert blueprint is not None
|
||||
assert blueprint.study_name == "test_study"
|
||||
assert len(blueprint.objectives) == 1
|
||||
|
||||
def test_modify_blueprint(self):
|
||||
"""Test blueprint modification."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={})
|
||||
|
||||
# Set up and generate blueprint
|
||||
engine.state.answers = {
|
||||
"study_description": "Test",
|
||||
"objectives": [{"goal": "minimize_mass"}],
|
||||
"constraints": [],
|
||||
"design_variables": [{"name": "t", "min": 1, "max": 10}],
|
||||
}
|
||||
engine.generate_blueprint()
|
||||
|
||||
# Modify n_trials
|
||||
modified = engine.modify_blueprint({"n_trials": 200})
|
||||
|
||||
assert modified.n_trials == 200
|
||||
|
||||
def test_confirm_blueprint(self):
|
||||
"""Test confirming blueprint."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={})
|
||||
|
||||
engine.state.answers = {
|
||||
"study_description": "Test",
|
||||
"objectives": [{"goal": "minimize_mass"}],
|
||||
"constraints": [],
|
||||
"design_variables": [{"name": "t", "min": 1, "max": 10}],
|
||||
}
|
||||
engine.generate_blueprint()
|
||||
|
||||
result = engine.confirm_blueprint()
|
||||
|
||||
assert result is True
|
||||
assert engine.state.get_phase() == InterviewPhase.COMPLETE
|
||||
|
||||
def test_get_progress(self):
|
||||
"""Test getting progress string."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={})
|
||||
|
||||
progress = engine.get_progress()
|
||||
|
||||
assert isinstance(progress, str)
|
||||
assert len(progress) > 0
|
||||
|
||||
def test_reset_interview(self):
|
||||
"""Test resetting interview."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
engine.start_interview("test_study", introspection={})
|
||||
|
||||
# Make some progress
|
||||
engine.state.answers["test"] = "value"
|
||||
|
||||
engine.reset_interview()
|
||||
|
||||
assert engine.state is None
|
||||
assert engine.session is None
|
||||
|
||||
def test_get_current_state(self):
|
||||
"""Test getting current state."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = StudyInterviewEngine(study_path)
|
||||
|
||||
assert engine.get_current_state() is None
|
||||
|
||||
engine.start_interview("test_study", introspection={})
|
||||
|
||||
state = engine.get_current_state()
|
||||
assert state is not None
|
||||
assert state.study_name == "test_study"
|
||||
|
||||
|
||||
class TestRunInterview:
|
||||
"""Tests for run_interview convenience function."""
|
||||
|
||||
def test_run_interview(self):
|
||||
"""Test run_interview function."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
engine = run_interview(
|
||||
study_path,
|
||||
"test_study",
|
||||
introspection={"expressions": []}
|
||||
)
|
||||
|
||||
assert engine is not None
|
||||
assert engine.state is not None
|
||||
assert engine.session is not None
|
||||
|
||||
Reference in New Issue
Block a user