Files
Atomizer/optimization_engine/interview/study_interview.py
Anto01 32caa5d05c 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>
2026-01-03 11:06:07 -05:00

590 lines
19 KiB
Python

"""
Study Interview Engine
Main orchestrator for the interview process.
Coordinates question flow, state management, validation, and blueprint generation.
"""
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any, Optional, Literal
import uuid
from .interview_state import (
InterviewState,
InterviewPhase,
InterviewStateManager,
AnsweredQuestion,
LogEntry,
)
from .question_engine import QuestionEngine, Question
from .interview_presenter import InterviewPresenter, ClaudePresenter
from .engineering_validator import EngineeringValidator, ValidationResult, AntiPattern
from .interview_intelligence import InterviewIntelligence
from .study_blueprint import StudyBlueprint, BlueprintBuilder
@dataclass
class InterviewSession:
"""Represents an active interview session."""
session_id: str
study_name: str
study_path: Path
started_at: datetime
current_phase: InterviewPhase
introspection: Dict[str, Any]
is_complete: bool = False
is_resumed: bool = False
@dataclass
class NextAction:
"""What should happen after processing an answer."""
action_type: Literal["ask_question", "show_summary", "validate", "generate", "error", "confirm_warning"]
question: Optional[Question] = None
message: Optional[str] = None
warnings: List[str] = field(default_factory=list)
blueprint: Optional[StudyBlueprint] = None
anti_patterns: List[AntiPattern] = field(default_factory=list)
class StudyInterviewEngine:
"""
Main orchestrator for study interviews.
Manages the complete interview lifecycle:
1. Start or resume interview
2. Present questions via presenter
3. Process answers with validation
4. Generate blueprint for review
5. Handle modifications
6. Coordinate study generation
"""
def __init__(
self,
study_path: Path,
presenter: Optional[InterviewPresenter] = None
):
"""
Initialize interview engine.
Args:
study_path: Path to the study directory
presenter: Presentation layer (defaults to ClaudePresenter)
"""
self.study_path = Path(study_path)
self.presenter = presenter or ClaudePresenter()
self.state_manager = InterviewStateManager(self.study_path)
self.question_engine = QuestionEngine()
self.validator = EngineeringValidator()
self.intelligence = InterviewIntelligence()
self.blueprint_builder = BlueprintBuilder()
# Current state
self.state: Optional[InterviewState] = None
self.introspection: Dict[str, Any] = {}
self.current_question: Optional[Question] = None
self.session: Optional[InterviewSession] = None
# Estimated questions (for progress)
self.estimated_total_questions = 12 # Will be updated based on complexity
def start_interview(
self,
study_name: str,
model_path: Optional[Path] = None,
introspection: Optional[Dict[str, Any]] = None
) -> InterviewSession:
"""
Start a new interview or resume existing one.
Args:
study_name: Name for the study
model_path: Path to the NX model (optional)
introspection: Pre-computed introspection results (optional)
Returns:
InterviewSession representing the active interview
"""
# Check for existing state
existing_state = self.state_manager.load_state()
if existing_state and not existing_state.is_complete():
# Resume existing interview
self.state = existing_state
self.introspection = existing_state.introspection
self.session = InterviewSession(
session_id=existing_state.session_id,
study_name=existing_state.study_name,
study_path=self.study_path,
started_at=datetime.fromisoformat(existing_state.started_at),
current_phase=existing_state.get_phase(),
introspection=self.introspection,
is_resumed=True,
)
return self.session
# Start new interview
self.state = InterviewState(
session_id=str(uuid.uuid4()),
study_name=study_name,
study_path=str(self.study_path),
current_phase=InterviewPhase.INTROSPECTION.value,
)
# Store introspection if provided
if introspection:
self.introspection = introspection
self.state.introspection = introspection
# Move to problem definition if introspection already done
self.state.set_phase(InterviewPhase.PROBLEM_DEFINITION)
# Save initial state
self.state_manager.save_state(self.state)
self.session = InterviewSession(
session_id=self.state.session_id,
study_name=study_name,
study_path=self.study_path,
started_at=datetime.now(),
current_phase=self.state.get_phase(),
introspection=self.introspection,
)
return self.session
def get_first_question(self) -> NextAction:
"""
Get the first question to ask.
Returns:
NextAction with the first question
"""
if self.state is None:
return NextAction(
action_type="error",
message="Interview not started. Call start_interview() first."
)
# Get next question
next_q = self.question_engine.get_next_question(self.state, self.introspection)
if next_q is None:
# No questions - should not happen at start
return NextAction(
action_type="error",
message="No questions available."
)
self.current_question = next_q
return NextAction(
action_type="ask_question",
question=next_q,
message=self.presenter.present_question(
next_q,
question_number=self.state.current_question_count() + 1,
total_questions=self.estimated_total_questions,
category_name=self._get_category_name(next_q.category),
)
)
def process_answer(self, answer: str) -> NextAction:
"""
Process user answer and determine next action.
Args:
answer: User's answer (natural language)
Returns:
NextAction indicating what to do next
"""
if self.state is None or self.current_question is None:
return NextAction(
action_type="error",
message="No active question. Call get_first_question() or get_next_question()."
)
question = self.current_question
# 1. Parse answer based on question type
parsed = self.presenter.parse_response(answer, question)
# 2. Validate answer
is_valid, error_msg = self.question_engine.validate_answer(parsed, question)
if not is_valid:
return NextAction(
action_type="error",
message=f"Invalid answer: {error_msg}",
question=question, # Re-ask same question
)
# 3. Store answer
self._store_answer(question, answer, parsed)
# 4. Update phase if needed
self._update_phase(question)
# 5. Update complexity after initial questions
if question.category == "problem_definition":
self._update_complexity()
# 6. Check for warnings/anti-patterns
anti_patterns = self.validator.detect_anti_patterns(self.state, self.introspection)
new_warnings = [ap.description for ap in anti_patterns if ap.severity in ["error", "warning"]]
# Filter to only new warnings
existing_warnings = set(self.state.warnings)
for w in new_warnings:
if w not in existing_warnings:
self.state.add_warning(w)
# 7. Check if we should show anti-pattern warnings
blocking_patterns = [ap for ap in anti_patterns if ap.severity == "error" and not ap.acknowledged]
if blocking_patterns:
return NextAction(
action_type="confirm_warning",
message=self._format_anti_pattern_warnings(blocking_patterns),
anti_patterns=blocking_patterns,
)
# 8. Get next question
next_q = self.question_engine.get_next_question(self.state, self.introspection)
if next_q is None:
# Interview complete - generate blueprint
return self._finalize_interview()
self.current_question = next_q
return NextAction(
action_type="ask_question",
question=next_q,
message=self.presenter.present_question(
next_q,
question_number=self.state.current_question_count() + 1,
total_questions=self.estimated_total_questions,
category_name=self._get_category_name(next_q.category),
),
warnings=[w for w in self.state.warnings if w not in self.state.warnings_acknowledged],
)
def acknowledge_warnings(self, acknowledged: bool = True) -> NextAction:
"""
Acknowledge current warnings and continue.
Args:
acknowledged: Whether user acknowledged warnings
Returns:
NextAction (continue or abort)
"""
if not acknowledged:
return NextAction(
action_type="error",
message="Interview paused. Please fix the issues and restart, or acknowledge warnings to proceed."
)
# Mark all current warnings as acknowledged
for w in self.state.warnings:
self.state.acknowledge_warning(w)
# Continue to next question
next_q = self.question_engine.get_next_question(self.state, self.introspection)
if next_q is None:
return self._finalize_interview()
self.current_question = next_q
return NextAction(
action_type="ask_question",
question=next_q,
message=self.presenter.present_question(
next_q,
question_number=self.state.current_question_count() + 1,
total_questions=self.estimated_total_questions,
category_name=self._get_category_name(next_q.category),
)
)
def generate_blueprint(self) -> StudyBlueprint:
"""
Generate study blueprint from interview state.
Returns:
StudyBlueprint ready for generation
"""
if self.state is None:
raise ValueError("No interview state available")
blueprint = self.blueprint_builder.from_interview_state(
self.state,
self.introspection
)
# Store in state
self.state.blueprint = blueprint.to_dict()
self.state_manager.save_state(self.state)
return blueprint
def modify_blueprint(self, changes: Dict[str, Any]) -> StudyBlueprint:
"""
Apply what-if modifications to the blueprint.
Args:
changes: Dictionary of changes to apply
Returns:
Modified StudyBlueprint
"""
if self.state is None or self.state.blueprint is None:
raise ValueError("No blueprint available to modify")
blueprint = StudyBlueprint.from_dict(self.state.blueprint)
# Apply changes
for key, value in changes.items():
if key == "n_trials":
blueprint.n_trials = int(value)
elif key == "sampler":
blueprint.sampler = value
elif key == "add_constraint":
# Handle adding constraints
pass
elif key == "remove_constraint":
# Handle removing constraints
pass
# Add more modification types as needed
# Re-validate
validation_errors = blueprint.validate()
if validation_errors:
raise ValueError(f"Invalid modifications: {validation_errors}")
# Update state
self.state.blueprint = blueprint.to_dict()
self.state_manager.save_state(self.state)
return blueprint
def confirm_blueprint(self) -> bool:
"""
Confirm blueprint and mark interview as complete.
Returns:
True if successful
"""
if self.state is None:
return False
self.state.set_phase(InterviewPhase.COMPLETE)
self.state_manager.save_state(self.state)
# Finalize log
self.state_manager.finalize_log(self.state)
return True
def get_current_state(self) -> Optional[InterviewState]:
"""Get current interview state."""
return self.state
def get_progress(self) -> str:
"""Get formatted progress string."""
if self.state is None:
return "No active interview"
return self.presenter.show_progress(
self.state.current_question_count(),
self.estimated_total_questions,
self._get_phase_name(self.state.current_phase)
)
def reset_interview(self) -> None:
"""Reset interview and start fresh."""
self.state_manager.delete_state()
self.state = None
self.current_question = None
self.session = None
# Private methods
def _store_answer(self, question: Question, raw: str, parsed: Any) -> None:
"""Store answer in state."""
# Create answered question record
answered = AnsweredQuestion(
question_id=question.id,
answered_at=datetime.now().isoformat(),
raw_response=raw,
parsed_value=parsed,
)
self.state.add_answered_question(answered)
# Map to answer field
self._map_answer_to_field(question.maps_to, parsed)
# Create log entry
log_entry = LogEntry(
timestamp=datetime.now(),
question_id=question.id,
question_text=question.text,
answer_raw=raw,
answer_parsed=parsed,
)
self.state_manager.append_log(log_entry)
self.state_manager.save_state(self.state)
def _map_answer_to_field(self, maps_to: str, value: Any) -> None:
"""Map parsed value to the appropriate answer field."""
if not maps_to:
return
# Handle array indexing: "objectives[0].goal"
if "[" in maps_to:
import re
match = re.match(r"(\w+)\[(\d+)\]\.(\w+)", maps_to)
if match:
array_name, idx, field = match.groups()
idx = int(idx)
# Ensure array exists
if array_name not in self.state.answers:
self.state.answers[array_name] = []
# Ensure element exists
while len(self.state.answers[array_name]) <= idx:
self.state.answers[array_name].append({})
self.state.answers[array_name][idx][field] = value
return
# Handle nested fields: "constraints.max_stress"
if "." in maps_to:
parts = maps_to.split(".")
current = self.state.answers
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return
# Simple field
self.state.set_answer(maps_to, value)
def _update_phase(self, question: Question) -> None:
"""Update interview phase based on question category."""
category_to_phase = {
"problem_definition": InterviewPhase.PROBLEM_DEFINITION,
"objectives": InterviewPhase.OBJECTIVES,
"constraints": InterviewPhase.CONSTRAINTS,
"design_variables": InterviewPhase.DESIGN_VARIABLES,
"physics_config": InterviewPhase.DESIGN_VARIABLES,
"optimization_settings": InterviewPhase.VALIDATION,
"validation": InterviewPhase.VALIDATION,
}
new_phase = category_to_phase.get(question.category)
if new_phase and new_phase != self.state.get_phase():
self.state.set_phase(new_phase)
def _update_complexity(self) -> None:
"""Update complexity estimate after initial questions."""
complexity = self.intelligence.determine_complexity(self.state, self.introspection)
self.state.complexity = complexity
# Adjust estimated questions
if complexity == "simple":
self.estimated_total_questions = 8
elif complexity == "moderate":
self.estimated_total_questions = 12
else:
self.estimated_total_questions = 16
def _finalize_interview(self) -> NextAction:
"""Finalize interview and show summary."""
self.state.set_phase(InterviewPhase.REVIEW)
blueprint = self.generate_blueprint()
return NextAction(
action_type="show_summary",
message=self.presenter.show_summary(blueprint),
blueprint=blueprint,
)
def _format_anti_pattern_warnings(self, patterns: List[AntiPattern]) -> str:
"""Format anti-pattern warnings for display."""
lines = ["**Issues Detected:**", ""]
for ap in patterns:
severity_icon = "X" if ap.severity == "error" else "!"
lines.append(f"[{severity_icon}] **{ap.name}**")
lines.append(f" {ap.description}")
if ap.fix_suggestion:
lines.append(f" *Suggestion*: {ap.fix_suggestion}")
lines.append("")
lines.append("Would you like to proceed anyway? Type **yes** to continue or **no** to go back and fix.")
return "\n".join(lines)
def _get_category_name(self, category: str) -> str:
"""Get human-readable category name."""
names = {
"problem_definition": "Problem Definition",
"objectives": "Optimization Goals",
"constraints": "Constraints",
"design_variables": "Design Variables",
"physics_config": "Physics Configuration",
"optimization_settings": "Optimization Settings",
"validation": "Validation",
}
return names.get(category, category.replace("_", " ").title())
def _get_phase_name(self, phase: str) -> str:
"""Get human-readable phase name."""
names = {
"introspection": "Model Analysis",
"problem_definition": "Problem Definition",
"objectives": "Setting Objectives",
"constraints": "Defining Constraints",
"design_variables": "Selecting Variables",
"validation": "Validation",
"review": "Review & Confirm",
"complete": "Complete",
}
return names.get(phase, phase.replace("_", " ").title())
# Convenience function for quick interview
def run_interview(
study_path: Path,
study_name: str,
introspection: Optional[Dict[str, Any]] = None
) -> StudyInterviewEngine:
"""
Create and start an interview engine.
Args:
study_path: Path to study directory
study_name: Study name
introspection: Optional introspection results
Returns:
Configured StudyInterviewEngine ready for use
"""
engine = StudyInterviewEngine(study_path)
engine.start_interview(study_name, introspection=introspection)
return engine