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:
588
optimization_engine/interview/interview_presenter.py
Normal file
588
optimization_engine/interview/interview_presenter.py
Normal file
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Interview Presenter
|
||||
|
||||
Abstract presentation layer for different UI modes.
|
||||
Handles:
|
||||
- Formatting questions for display
|
||||
- Parsing user responses
|
||||
- Showing summaries and warnings
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, List, Dict
|
||||
import re
|
||||
|
||||
from .question_engine import Question, QuestionOption
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresentedQuestion:
|
||||
"""A question formatted for presentation."""
|
||||
question_id: str
|
||||
formatted_text: str
|
||||
question_number: int
|
||||
total_questions: int
|
||||
category_name: str
|
||||
|
||||
|
||||
class InterviewPresenter(ABC):
|
||||
"""
|
||||
Abstract base for interview presentation.
|
||||
|
||||
Different presenters handle UI-specific rendering:
|
||||
- ClaudePresenter: Markdown for Claude conversation
|
||||
- DashboardPresenter: WebSocket events for React UI (future)
|
||||
- CLIPresenter: Interactive terminal prompts (future)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def present_question(
|
||||
self,
|
||||
question: Question,
|
||||
question_number: int,
|
||||
total_questions: int,
|
||||
category_name: str,
|
||||
dynamic_content: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Format a question for display.
|
||||
|
||||
Args:
|
||||
question: Question to present
|
||||
question_number: Current question number
|
||||
total_questions: Estimated total questions
|
||||
category_name: Name of the question category
|
||||
dynamic_content: Dynamic content to inject (e.g., extractor summary)
|
||||
|
||||
Returns:
|
||||
Formatted question string
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_response(self, response: str, question: Question) -> Any:
|
||||
"""
|
||||
Parse user's response into structured value.
|
||||
|
||||
Args:
|
||||
response: Raw user response
|
||||
question: Question being answered
|
||||
|
||||
Returns:
|
||||
Parsed answer value
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def show_summary(self, blueprint: "StudyBlueprint") -> str:
|
||||
"""
|
||||
Format interview summary/blueprint for display.
|
||||
|
||||
Args:
|
||||
blueprint: Generated study blueprint
|
||||
|
||||
Returns:
|
||||
Formatted summary string
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def show_warning(self, warning: str, severity: str = "warning") -> str:
|
||||
"""
|
||||
Format a warning message for display.
|
||||
|
||||
Args:
|
||||
warning: Warning message
|
||||
severity: "error", "warning", or "info"
|
||||
|
||||
Returns:
|
||||
Formatted warning string
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def show_progress(self, current: int, total: int, phase: str) -> str:
|
||||
"""
|
||||
Format progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current question number
|
||||
total: Estimated total questions
|
||||
phase: Current phase name
|
||||
|
||||
Returns:
|
||||
Formatted progress string
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ClaudePresenter(InterviewPresenter):
|
||||
"""
|
||||
Presenter for Claude conversation mode (VS Code, Web).
|
||||
|
||||
Formats questions and responses as markdown for natural
|
||||
conversation flow with Claude.
|
||||
"""
|
||||
|
||||
def present_question(
|
||||
self,
|
||||
question: Question,
|
||||
question_number: int,
|
||||
total_questions: int,
|
||||
category_name: str,
|
||||
dynamic_content: Optional[str] = None
|
||||
) -> str:
|
||||
"""Format question as markdown for Claude to present."""
|
||||
lines = []
|
||||
|
||||
# Header with progress
|
||||
lines.append(f"### Question {question_number} of ~{total_questions}: {category_name}")
|
||||
lines.append("")
|
||||
|
||||
# Main question text
|
||||
lines.append(question.text)
|
||||
lines.append("")
|
||||
|
||||
# Dynamic content if provided
|
||||
if dynamic_content:
|
||||
lines.append(dynamic_content)
|
||||
lines.append("")
|
||||
|
||||
# Options for choice questions
|
||||
if question.options and question.question_type in ["choice", "multi_choice"]:
|
||||
for i, opt in enumerate(question.options, 1):
|
||||
desc = f" - {opt.description}" if opt.description else ""
|
||||
lines.append(f"{i}. **{opt.label}**{desc}")
|
||||
lines.append("")
|
||||
|
||||
# Help text
|
||||
if question.help_text:
|
||||
lines.append(f"> {question.help_text}")
|
||||
lines.append("")
|
||||
|
||||
# Engineering guidance
|
||||
if question.engineering_guidance:
|
||||
lines.append(f"> **Tip**: {question.engineering_guidance}")
|
||||
lines.append("")
|
||||
|
||||
# Default value hint
|
||||
if question.default is not None and question.default != []:
|
||||
if isinstance(question.default, list):
|
||||
default_str = ", ".join(str(d) for d in question.default)
|
||||
else:
|
||||
default_str = str(question.default)
|
||||
lines.append(f"*Default: {default_str}*")
|
||||
lines.append("")
|
||||
|
||||
# Input prompt based on type
|
||||
if question.question_type == "text":
|
||||
lines.append("Please describe:")
|
||||
elif question.question_type == "numeric":
|
||||
units = question.validation.units if question.validation else ""
|
||||
lines.append(f"Enter value{f' ({units})' if units else ''}:")
|
||||
elif question.question_type == "choice":
|
||||
lines.append("Type your choice (number or description):")
|
||||
elif question.question_type == "multi_choice":
|
||||
lines.append("Type your choices (numbers or descriptions, comma-separated):")
|
||||
elif question.question_type == "confirm":
|
||||
lines.append("Type **yes** or **no**:")
|
||||
elif question.question_type == "parameter_select":
|
||||
lines.append("Type parameter names (comma-separated) or select by number:")
|
||||
elif question.question_type == "bounds":
|
||||
lines.append("Enter bounds (e.g., '2 to 10' or 'min 2, max 10'):")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def parse_response(self, response: str, question: Question) -> Any:
|
||||
"""Parse natural language response into structured answer."""
|
||||
response = response.strip()
|
||||
|
||||
if question.question_type == "text":
|
||||
return response
|
||||
|
||||
elif question.question_type == "numeric":
|
||||
return self._parse_numeric(response, question)
|
||||
|
||||
elif question.question_type == "confirm":
|
||||
return self._parse_confirm(response)
|
||||
|
||||
elif question.question_type == "choice":
|
||||
return self._parse_choice(response, question)
|
||||
|
||||
elif question.question_type == "multi_choice":
|
||||
return self._parse_multi_choice(response, question)
|
||||
|
||||
elif question.question_type == "parameter_select":
|
||||
return self._parse_parameter_select(response, question)
|
||||
|
||||
elif question.question_type == "bounds":
|
||||
return self._parse_bounds(response)
|
||||
|
||||
return response
|
||||
|
||||
def _parse_numeric(self, response: str, question: Question) -> Optional[float]:
|
||||
"""Parse numeric response with unit handling."""
|
||||
# Remove common unit suffixes
|
||||
cleaned = re.sub(r'\s*(mm|cm|m|kg|g|MPa|Pa|GPa|Hz|kHz|MHz|°|deg)s?\s*$', '', response, flags=re.I)
|
||||
|
||||
# Extract number
|
||||
match = re.search(r'[-+]?\d*\.?\d+', cleaned)
|
||||
if match:
|
||||
return float(match.group())
|
||||
return None
|
||||
|
||||
def _parse_confirm(self, response: str) -> Optional[bool]:
|
||||
"""Parse yes/no confirmation."""
|
||||
lower = response.lower().strip()
|
||||
|
||||
# Positive responses
|
||||
if lower in ["yes", "y", "true", "1", "ok", "sure", "yep", "yeah", "correct", "confirmed", "confirm", "affirmative"]:
|
||||
return True
|
||||
|
||||
# Negative responses
|
||||
if lower in ["no", "n", "false", "0", "nope", "nah", "cancel", "incorrect", "negative"]:
|
||||
return False
|
||||
|
||||
# Try to detect intent from natural language
|
||||
if "yes" in lower or "ok" in lower or "correct" in lower:
|
||||
return True
|
||||
if "no" in lower or "don't" in lower or "not" in lower:
|
||||
return False
|
||||
|
||||
return None
|
||||
|
||||
def _parse_choice(self, response: str, question: Question) -> Any:
|
||||
"""Parse single choice response."""
|
||||
if not question.options:
|
||||
return response
|
||||
|
||||
# Try by number
|
||||
if response.isdigit():
|
||||
idx = int(response) - 1
|
||||
if 0 <= idx < len(question.options):
|
||||
return question.options[idx].value
|
||||
|
||||
# Try by value (exact match)
|
||||
for opt in question.options:
|
||||
if response.lower() == str(opt.value).lower():
|
||||
return opt.value
|
||||
|
||||
# Try by label (exact match)
|
||||
for opt in question.options:
|
||||
if response.lower() == opt.label.lower():
|
||||
return opt.value
|
||||
|
||||
# Try fuzzy match on label
|
||||
for opt in question.options:
|
||||
if response.lower() in opt.label.lower():
|
||||
return opt.value
|
||||
|
||||
# Return as-is for custom values
|
||||
return response
|
||||
|
||||
def _parse_multi_choice(self, response: str, question: Question) -> List[Any]:
|
||||
"""Parse multiple choice response."""
|
||||
# Split by comma, 'and', or numbers
|
||||
parts = re.split(r'[,&]|\band\b|\s+', response)
|
||||
parts = [p.strip() for p in parts if p.strip()]
|
||||
|
||||
values = []
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# Try by number
|
||||
if part.isdigit() and question.options:
|
||||
idx = int(part) - 1
|
||||
if 0 <= idx < len(question.options):
|
||||
value = question.options[idx].value
|
||||
if value not in values:
|
||||
values.append(value)
|
||||
continue
|
||||
|
||||
# Try by value/label
|
||||
if question.options:
|
||||
found = False
|
||||
for opt in question.options:
|
||||
if part.lower() == str(opt.value).lower() or part.lower() == opt.label.lower():
|
||||
if opt.value not in values:
|
||||
values.append(opt.value)
|
||||
found = True
|
||||
break
|
||||
if part.lower() in opt.label.lower():
|
||||
if opt.value not in values:
|
||||
values.append(opt.value)
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
continue
|
||||
|
||||
# Add as custom value
|
||||
if part not in values:
|
||||
values.append(part)
|
||||
|
||||
return values
|
||||
|
||||
def _parse_parameter_select(self, response: str, question: Question) -> List[str]:
|
||||
"""Parse parameter selection response."""
|
||||
# Split by comma, 'and', or numbers
|
||||
parts = re.split(r'[,&]|\band\b', response)
|
||||
parameters = []
|
||||
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# Try by number if we have options
|
||||
if part.isdigit() and question.options:
|
||||
idx = int(part) - 1
|
||||
if 0 <= idx < len(question.options):
|
||||
parameters.append(question.options[idx].value)
|
||||
continue
|
||||
|
||||
# Add as parameter name
|
||||
parameters.append(part)
|
||||
|
||||
return parameters
|
||||
|
||||
def _parse_bounds(self, response: str) -> Optional[Dict[str, float]]:
|
||||
"""Parse bounds specification."""
|
||||
bounds = {}
|
||||
|
||||
# Try "min to max" format
|
||||
match = re.search(r'(\d+\.?\d*)\s*(?:to|-)\s*(\d+\.?\d*)', response)
|
||||
if match:
|
||||
bounds["min"] = float(match.group(1))
|
||||
bounds["max"] = float(match.group(2))
|
||||
return bounds
|
||||
|
||||
# Try "min: X, max: Y" format
|
||||
min_match = re.search(r'min[:\s]+(\d+\.?\d*)', response, re.I)
|
||||
max_match = re.search(r'max[:\s]+(\d+\.?\d*)', response, re.I)
|
||||
|
||||
if min_match:
|
||||
bounds["min"] = float(min_match.group(1))
|
||||
if max_match:
|
||||
bounds["max"] = float(max_match.group(1))
|
||||
|
||||
return bounds if bounds else None
|
||||
|
||||
def show_summary(self, blueprint: "StudyBlueprint") -> str:
|
||||
"""Format interview summary/blueprint for display."""
|
||||
lines = []
|
||||
|
||||
lines.append(f"## Study Blueprint: {blueprint.study_name}")
|
||||
lines.append("")
|
||||
|
||||
# Description
|
||||
if blueprint.study_description:
|
||||
lines.append(f"**Description**: {blueprint.study_description}")
|
||||
lines.append("")
|
||||
|
||||
# Design Variables
|
||||
lines.append(f"### Design Variables ({len(blueprint.design_variables)})")
|
||||
lines.append("")
|
||||
lines.append("| Parameter | Current | Min | Max | Units |")
|
||||
lines.append("|-----------|---------|-----|-----|-------|")
|
||||
for dv in blueprint.design_variables:
|
||||
lines.append(f"| {dv.parameter} | {dv.current_value} | {dv.min_value} | {dv.max_value} | {dv.units or '-'} |")
|
||||
lines.append("")
|
||||
|
||||
# Objectives
|
||||
lines.append(f"### Objectives ({len(blueprint.objectives)})")
|
||||
lines.append("")
|
||||
lines.append("| Goal | Extractor | Parameters |")
|
||||
lines.append("|------|-----------|------------|")
|
||||
for obj in blueprint.objectives:
|
||||
params = ", ".join(f"{k}={v}" for k, v in (obj.extractor_params or {}).items()) or "-"
|
||||
lines.append(f"| {obj.goal} | {obj.extractor} | {params} |")
|
||||
lines.append("")
|
||||
|
||||
# Constraints
|
||||
if blueprint.constraints:
|
||||
lines.append(f"### Constraints ({len(blueprint.constraints)})")
|
||||
lines.append("")
|
||||
lines.append("| Type | Threshold | Extractor |")
|
||||
lines.append("|------|-----------|-----------|")
|
||||
for con in blueprint.constraints:
|
||||
op = "<=" if con.constraint_type == "max" else ">="
|
||||
lines.append(f"| {con.name} | {op} {con.threshold} | {con.extractor} |")
|
||||
lines.append("")
|
||||
|
||||
# Settings
|
||||
lines.append("### Settings")
|
||||
lines.append("")
|
||||
lines.append(f"- **Protocol**: {blueprint.protocol}")
|
||||
lines.append(f"- **Trials**: {blueprint.n_trials}")
|
||||
lines.append(f"- **Sampler**: {blueprint.sampler}")
|
||||
lines.append("")
|
||||
|
||||
# Warnings
|
||||
if blueprint.warnings_acknowledged:
|
||||
lines.append("### Acknowledged Warnings")
|
||||
lines.append("")
|
||||
for warning in blueprint.warnings_acknowledged:
|
||||
lines.append(f"- {warning}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("Does this look correct? Reply **yes** to generate the study, or describe what to change.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def show_warning(self, warning: str, severity: str = "warning") -> str:
|
||||
"""Format a warning message for display."""
|
||||
icons = {
|
||||
"error": "X",
|
||||
"warning": "!",
|
||||
"info": "i"
|
||||
}
|
||||
icon = icons.get(severity, "!")
|
||||
|
||||
if severity == "error":
|
||||
return f"\n**[{icon}] ERROR**: {warning}\n"
|
||||
elif severity == "warning":
|
||||
return f"\n**[{icon}] Warning**: {warning}\n"
|
||||
else:
|
||||
return f"\n*[{icon}] Note*: {warning}\n"
|
||||
|
||||
def show_progress(self, current: int, total: int, phase: str) -> str:
|
||||
"""Format progress indicator."""
|
||||
percentage = int((current / total) * 100) if total > 0 else 0
|
||||
bar_length = 20
|
||||
filled = int(bar_length * current / total) if total > 0 else 0
|
||||
bar = "=" * filled + "-" * (bar_length - filled)
|
||||
|
||||
return f"**Progress**: [{bar}] {percentage}% - {phase}"
|
||||
|
||||
|
||||
class DashboardPresenter(InterviewPresenter):
|
||||
"""
|
||||
Presenter for dashboard UI mode (future).
|
||||
|
||||
Emits WebSocket events for React UI to render.
|
||||
"""
|
||||
|
||||
def present_question(
|
||||
self,
|
||||
question: Question,
|
||||
question_number: int,
|
||||
total_questions: int,
|
||||
category_name: str,
|
||||
dynamic_content: Optional[str] = None
|
||||
) -> str:
|
||||
"""Emit WebSocket event for dashboard to render."""
|
||||
# This would emit an event to the dashboard
|
||||
# For now, return JSON representation
|
||||
import json
|
||||
return json.dumps({
|
||||
"type": "question",
|
||||
"data": {
|
||||
"question_id": question.id,
|
||||
"question_number": question_number,
|
||||
"total_questions": total_questions,
|
||||
"category": category_name,
|
||||
"text": question.text,
|
||||
"question_type": question.question_type,
|
||||
"options": [{"value": o.value, "label": o.label} for o in (question.options or [])],
|
||||
"help_text": question.help_text,
|
||||
"default": question.default,
|
||||
"dynamic_content": dynamic_content,
|
||||
}
|
||||
})
|
||||
|
||||
def parse_response(self, response: str, question: Question) -> Any:
|
||||
"""Parse JSON response from dashboard."""
|
||||
import json
|
||||
try:
|
||||
data = json.loads(response)
|
||||
return data.get("value", response)
|
||||
except json.JSONDecodeError:
|
||||
# Fall back to Claude parser
|
||||
claude = ClaudePresenter()
|
||||
return claude.parse_response(response, question)
|
||||
|
||||
def show_summary(self, blueprint: "StudyBlueprint") -> str:
|
||||
"""Emit summary event for dashboard."""
|
||||
import json
|
||||
return json.dumps({
|
||||
"type": "summary",
|
||||
"data": blueprint.to_dict() if hasattr(blueprint, 'to_dict') else str(blueprint)
|
||||
})
|
||||
|
||||
def show_warning(self, warning: str, severity: str = "warning") -> str:
|
||||
"""Emit warning event for dashboard."""
|
||||
import json
|
||||
return json.dumps({
|
||||
"type": "warning",
|
||||
"data": {"message": warning, "severity": severity}
|
||||
})
|
||||
|
||||
def show_progress(self, current: int, total: int, phase: str) -> str:
|
||||
"""Emit progress event for dashboard."""
|
||||
import json
|
||||
return json.dumps({
|
||||
"type": "progress",
|
||||
"data": {"current": current, "total": total, "phase": phase}
|
||||
})
|
||||
|
||||
|
||||
class CLIPresenter(InterviewPresenter):
|
||||
"""
|
||||
Presenter for CLI wizard mode (future).
|
||||
|
||||
Interactive terminal prompts using Rich/Questionary.
|
||||
"""
|
||||
|
||||
def present_question(
|
||||
self,
|
||||
question: Question,
|
||||
question_number: int,
|
||||
total_questions: int,
|
||||
category_name: str,
|
||||
dynamic_content: Optional[str] = None
|
||||
) -> str:
|
||||
"""Format for CLI display."""
|
||||
# Simple text format for CLI
|
||||
lines = []
|
||||
lines.append(f"\n[{question_number}/{total_questions}] {category_name}")
|
||||
lines.append("-" * 50)
|
||||
lines.append(question.text)
|
||||
|
||||
if question.options:
|
||||
for i, opt in enumerate(question.options, 1):
|
||||
lines.append(f" {i}. {opt.label}")
|
||||
|
||||
if question.help_text:
|
||||
lines.append(f"\nHint: {question.help_text}")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
def parse_response(self, response: str, question: Question) -> Any:
|
||||
"""Parse CLI response (delegate to Claude parser)."""
|
||||
claude = ClaudePresenter()
|
||||
return claude.parse_response(response, question)
|
||||
|
||||
def show_summary(self, blueprint: "StudyBlueprint") -> str:
|
||||
"""Format summary for CLI."""
|
||||
claude = ClaudePresenter()
|
||||
return claude.show_summary(blueprint)
|
||||
|
||||
def show_warning(self, warning: str, severity: str = "warning") -> str:
|
||||
"""Format warning for CLI."""
|
||||
icons = {"error": "[ERROR]", "warning": "[WARN]", "info": "[INFO]"}
|
||||
return f"\n{icons.get(severity, '[WARN]')} {warning}\n"
|
||||
|
||||
def show_progress(self, current: int, total: int, phase: str) -> str:
|
||||
"""Format progress for CLI."""
|
||||
return f"Progress: {current}/{total} ({phase})"
|
||||
|
||||
|
||||
# Import for type hints
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .study_blueprint import StudyBlueprint
|
||||
Reference in New Issue
Block a user