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:
589
optimization_engine/interview/study_interview.py
Normal file
589
optimization_engine/interview/study_interview.py
Normal file
@@ -0,0 +1,589 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user