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>
559 lines
20 KiB
Python
559 lines
20 KiB
Python
"""
|
|
Study Blueprint
|
|
|
|
Data structures for the study blueprint - the validated configuration
|
|
ready for study generation.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import Dict, List, Any, Optional
|
|
import json
|
|
|
|
|
|
@dataclass
|
|
class DesignVariable:
|
|
"""Design variable specification."""
|
|
parameter: str
|
|
current_value: float
|
|
min_value: float
|
|
max_value: float
|
|
units: Optional[str] = None
|
|
is_integer: bool = False
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
def to_config_format(self) -> Dict[str, Any]:
|
|
"""Convert to optimization_config.json format."""
|
|
return {
|
|
"expression_name": self.parameter,
|
|
"bounds": [self.min_value, self.max_value],
|
|
"units": self.units or "",
|
|
"is_integer": self.is_integer,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Objective:
|
|
"""Optimization objective specification."""
|
|
name: str
|
|
goal: str # minimize, maximize, target
|
|
extractor: str # Extractor ID (e.g., "E1", "E4")
|
|
extractor_name: Optional[str] = None
|
|
extractor_params: Optional[Dict[str, Any]] = None
|
|
weight: float = 1.0
|
|
target_value: Optional[float] = None # For target objectives
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
def to_config_format(self) -> Dict[str, Any]:
|
|
"""Convert to optimization_config.json format."""
|
|
config = {
|
|
"name": self.name,
|
|
"type": self.goal,
|
|
"extractor": self.extractor,
|
|
"weight": self.weight,
|
|
}
|
|
if self.extractor_params:
|
|
config["extractor_params"] = self.extractor_params
|
|
if self.target_value is not None:
|
|
config["target"] = self.target_value
|
|
return config
|
|
|
|
|
|
@dataclass
|
|
class Constraint:
|
|
"""Optimization constraint specification."""
|
|
name: str
|
|
constraint_type: str # max, min
|
|
threshold: float
|
|
extractor: str # Extractor ID
|
|
extractor_name: Optional[str] = None
|
|
extractor_params: Optional[Dict[str, Any]] = None
|
|
is_hard: bool = True
|
|
penalty_weight: float = 1000.0 # For soft constraints
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
def to_config_format(self) -> Dict[str, Any]:
|
|
"""Convert to optimization_config.json format."""
|
|
config = {
|
|
"name": self.name,
|
|
"type": self.constraint_type,
|
|
"threshold": self.threshold,
|
|
"extractor": self.extractor,
|
|
"hard": self.is_hard,
|
|
}
|
|
if self.extractor_params:
|
|
config["extractor_params"] = self.extractor_params
|
|
if not self.is_hard:
|
|
config["penalty_weight"] = self.penalty_weight
|
|
return config
|
|
|
|
|
|
@dataclass
|
|
class StudyBlueprint:
|
|
"""
|
|
Complete study blueprint ready for generation.
|
|
|
|
This is the validated configuration that will be used to create
|
|
the study files (optimization_config.json, run_optimization.py, etc.)
|
|
"""
|
|
# Study metadata
|
|
study_name: str
|
|
study_description: str = ""
|
|
interview_session_id: str = ""
|
|
|
|
# Model paths
|
|
model_path: str = ""
|
|
sim_path: str = ""
|
|
fem_path: str = ""
|
|
|
|
# Design space
|
|
design_variables: List[DesignVariable] = field(default_factory=list)
|
|
|
|
# Optimization goals
|
|
objectives: List[Objective] = field(default_factory=list)
|
|
constraints: List[Constraint] = field(default_factory=list)
|
|
|
|
# Optimization settings
|
|
protocol: str = "protocol_10_single" # or "protocol_11_multi"
|
|
n_trials: int = 100
|
|
sampler: str = "TPE"
|
|
use_neural_acceleration: bool = False
|
|
|
|
# Solver settings
|
|
solver_config: Dict[str, Any] = field(default_factory=dict)
|
|
solve_all_solutions: bool = True
|
|
|
|
# Extractors configuration
|
|
extractors_config: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
# Validation
|
|
warnings_acknowledged: List[str] = field(default_factory=list)
|
|
baseline_validated: bool = False
|
|
baseline_results: Optional[Dict[str, Any]] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary."""
|
|
return {
|
|
"study_name": self.study_name,
|
|
"study_description": self.study_description,
|
|
"interview_session_id": self.interview_session_id,
|
|
"model_path": self.model_path,
|
|
"sim_path": self.sim_path,
|
|
"fem_path": self.fem_path,
|
|
"design_variables": [dv.to_dict() for dv in self.design_variables],
|
|
"objectives": [obj.to_dict() for obj in self.objectives],
|
|
"constraints": [con.to_dict() for con in self.constraints],
|
|
"protocol": self.protocol,
|
|
"n_trials": self.n_trials,
|
|
"sampler": self.sampler,
|
|
"use_neural_acceleration": self.use_neural_acceleration,
|
|
"solver_config": self.solver_config,
|
|
"solve_all_solutions": self.solve_all_solutions,
|
|
"extractors_config": self.extractors_config,
|
|
"warnings_acknowledged": self.warnings_acknowledged,
|
|
"baseline_validated": self.baseline_validated,
|
|
"baseline_results": self.baseline_results,
|
|
}
|
|
|
|
def to_json(self) -> str:
|
|
"""Serialize to JSON string."""
|
|
return json.dumps(self.to_dict(), indent=2)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "StudyBlueprint":
|
|
"""Create from dictionary."""
|
|
design_variables = [
|
|
DesignVariable(**dv) for dv in data.get("design_variables", [])
|
|
]
|
|
objectives = [
|
|
Objective(**obj) for obj in data.get("objectives", [])
|
|
]
|
|
constraints = [
|
|
Constraint(**con) for con in data.get("constraints", [])
|
|
]
|
|
|
|
return cls(
|
|
study_name=data.get("study_name", ""),
|
|
study_description=data.get("study_description", ""),
|
|
interview_session_id=data.get("interview_session_id", ""),
|
|
model_path=data.get("model_path", ""),
|
|
sim_path=data.get("sim_path", ""),
|
|
fem_path=data.get("fem_path", ""),
|
|
design_variables=design_variables,
|
|
objectives=objectives,
|
|
constraints=constraints,
|
|
protocol=data.get("protocol", "protocol_10_single"),
|
|
n_trials=data.get("n_trials", 100),
|
|
sampler=data.get("sampler", "TPE"),
|
|
use_neural_acceleration=data.get("use_neural_acceleration", False),
|
|
solver_config=data.get("solver_config", {}),
|
|
solve_all_solutions=data.get("solve_all_solutions", True),
|
|
extractors_config=data.get("extractors_config", {}),
|
|
warnings_acknowledged=data.get("warnings_acknowledged", []),
|
|
baseline_validated=data.get("baseline_validated", False),
|
|
baseline_results=data.get("baseline_results"),
|
|
)
|
|
|
|
def to_config_json(self) -> Dict[str, Any]:
|
|
"""
|
|
Convert to optimization_config.json format.
|
|
|
|
This is the format expected by the optimization runner.
|
|
"""
|
|
config = {
|
|
"study_name": self.study_name,
|
|
"description": self.study_description,
|
|
"version": "2.0",
|
|
|
|
"model": {
|
|
"part_file": self.model_path,
|
|
"sim_file": self.sim_path,
|
|
"fem_file": self.fem_path,
|
|
},
|
|
|
|
"design_variables": [
|
|
dv.to_config_format() for dv in self.design_variables
|
|
],
|
|
|
|
"objectives": [
|
|
obj.to_config_format() for obj in self.objectives
|
|
],
|
|
|
|
"constraints": [
|
|
con.to_config_format() for con in self.constraints
|
|
],
|
|
|
|
"optimization": {
|
|
"n_trials": self.n_trials,
|
|
"sampler": self.sampler,
|
|
"protocol": self.protocol,
|
|
"neural_acceleration": self.use_neural_acceleration,
|
|
},
|
|
|
|
"solver": {
|
|
"solve_all": self.solve_all_solutions,
|
|
**self.solver_config,
|
|
},
|
|
|
|
"extractors": self.extractors_config,
|
|
|
|
"_metadata": {
|
|
"interview_session_id": self.interview_session_id,
|
|
"warnings_acknowledged": self.warnings_acknowledged,
|
|
"baseline_validated": self.baseline_validated,
|
|
}
|
|
}
|
|
|
|
return config
|
|
|
|
def to_markdown(self) -> str:
|
|
"""Generate human-readable markdown summary."""
|
|
lines = []
|
|
|
|
lines.append(f"# Study Blueprint: {self.study_name}")
|
|
lines.append("")
|
|
|
|
if self.study_description:
|
|
lines.append(f"**Description**: {self.study_description}")
|
|
lines.append("")
|
|
|
|
# Design Variables
|
|
lines.append(f"## Design Variables ({len(self.design_variables)})")
|
|
lines.append("")
|
|
lines.append("| Parameter | Current | Min | Max | Units |")
|
|
lines.append("|-----------|---------|-----|-----|-------|")
|
|
for dv in self.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(self.objectives)})")
|
|
lines.append("")
|
|
lines.append("| Name | Goal | Extractor | Weight |")
|
|
lines.append("|------|------|-----------|--------|")
|
|
for obj in self.objectives:
|
|
lines.append(f"| {obj.name} | {obj.goal} | {obj.extractor} | {obj.weight} |")
|
|
lines.append("")
|
|
|
|
# Constraints
|
|
if self.constraints:
|
|
lines.append(f"## Constraints ({len(self.constraints)})")
|
|
lines.append("")
|
|
lines.append("| Name | Type | Threshold | Extractor | Hard? |")
|
|
lines.append("|------|------|-----------|-----------|-------|")
|
|
for con in self.constraints:
|
|
op = "<=" if con.constraint_type == "max" else ">="
|
|
lines.append(f"| {con.name} | {op} | {con.threshold} | {con.extractor} | {'Yes' if con.is_hard else 'No'} |")
|
|
lines.append("")
|
|
|
|
# Settings
|
|
lines.append("## Optimization Settings")
|
|
lines.append("")
|
|
lines.append(f"- **Protocol**: {self.protocol}")
|
|
lines.append(f"- **Trials**: {self.n_trials}")
|
|
lines.append(f"- **Sampler**: {self.sampler}")
|
|
lines.append(f"- **Neural Acceleration**: {'Enabled' if self.use_neural_acceleration else 'Disabled'}")
|
|
lines.append("")
|
|
|
|
# Validation
|
|
lines.append("## Validation")
|
|
lines.append("")
|
|
lines.append(f"- **Baseline Validated**: {'Yes' if self.baseline_validated else 'No'}")
|
|
if self.warnings_acknowledged:
|
|
lines.append(f"- **Warnings Acknowledged**: {len(self.warnings_acknowledged)}")
|
|
for w in self.warnings_acknowledged:
|
|
lines.append(f" - {w}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def validate(self) -> List[str]:
|
|
"""
|
|
Validate blueprint completeness.
|
|
|
|
Returns:
|
|
List of validation errors (empty if valid)
|
|
"""
|
|
errors = []
|
|
|
|
if not self.study_name:
|
|
errors.append("Study name is required")
|
|
|
|
if not self.design_variables:
|
|
errors.append("At least one design variable is required")
|
|
|
|
if not self.objectives:
|
|
errors.append("At least one objective is required")
|
|
|
|
for dv in self.design_variables:
|
|
if dv.min_value >= dv.max_value:
|
|
errors.append(f"Invalid bounds for {dv.parameter}: min >= max")
|
|
|
|
return errors
|
|
|
|
def is_multi_objective(self) -> bool:
|
|
"""Check if this is a multi-objective study."""
|
|
return len(self.objectives) > 1
|
|
|
|
def get_objective_count(self) -> int:
|
|
"""Get number of objectives."""
|
|
return len(self.objectives)
|
|
|
|
def get_constraint_count(self) -> int:
|
|
"""Get number of constraints."""
|
|
return len(self.constraints)
|
|
|
|
def get_design_variable_count(self) -> int:
|
|
"""Get number of design variables."""
|
|
return len(self.design_variables)
|
|
|
|
|
|
class BlueprintBuilder:
|
|
"""
|
|
Helper class for building StudyBlueprint from interview state.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize builder."""
|
|
from .interview_intelligence import InterviewIntelligence
|
|
self.intelligence = InterviewIntelligence()
|
|
|
|
def from_interview_state(
|
|
self,
|
|
state: "InterviewState",
|
|
introspection: Optional[Dict[str, Any]] = None
|
|
) -> StudyBlueprint:
|
|
"""
|
|
Build StudyBlueprint from completed interview state.
|
|
|
|
Args:
|
|
state: Completed interview state
|
|
introspection: Optional introspection results
|
|
|
|
Returns:
|
|
StudyBlueprint ready for generation
|
|
"""
|
|
answers = state.answers
|
|
intro = introspection or state.introspection
|
|
|
|
# Build design variables
|
|
design_variables = []
|
|
for dv_data in answers.get("design_variables", []):
|
|
if isinstance(dv_data, dict):
|
|
dv = DesignVariable(
|
|
parameter=dv_data.get("parameter", ""),
|
|
current_value=dv_data.get("current_value", 0),
|
|
min_value=dv_data.get("min_value", 0),
|
|
max_value=dv_data.get("max_value", 1),
|
|
units=dv_data.get("units"),
|
|
is_integer=dv_data.get("is_integer", False),
|
|
)
|
|
design_variables.append(dv)
|
|
elif isinstance(dv_data, str):
|
|
# Just a parameter name - look up in introspection
|
|
expr = self._find_expression(dv_data, intro.get("expressions", []))
|
|
if expr:
|
|
value = expr.get("value", 0)
|
|
dv = DesignVariable(
|
|
parameter=dv_data,
|
|
current_value=value,
|
|
min_value=value * 0.5 if value > 0 else value * 1.5,
|
|
max_value=value * 1.5 if value > 0 else value * 0.5,
|
|
)
|
|
design_variables.append(dv)
|
|
|
|
# Build objectives
|
|
objectives = []
|
|
primary_goal = answers.get("objectives", [{}])
|
|
if isinstance(primary_goal, list) and primary_goal:
|
|
primary = primary_goal[0] if isinstance(primary_goal[0], dict) else {"goal": primary_goal[0]}
|
|
else:
|
|
primary = {"goal": str(primary_goal)}
|
|
|
|
# Map to extractor
|
|
extractor_sel = self.intelligence.extractor_mapper.map_goal_to_extractor(
|
|
primary.get("goal", ""),
|
|
intro
|
|
)
|
|
|
|
objectives.append(Objective(
|
|
name=primary.get("name", "primary_objective"),
|
|
goal=self._normalize_goal(primary.get("goal", "")),
|
|
extractor=extractor_sel.extractor_id,
|
|
extractor_name=extractor_sel.extractor_name,
|
|
extractor_params=extractor_sel.params,
|
|
weight=primary.get("weight", 1.0),
|
|
))
|
|
|
|
# Add secondary objectives
|
|
secondary = answers.get("objectives_secondary", [])
|
|
for sec_goal in secondary:
|
|
if sec_goal == "none" or not sec_goal:
|
|
continue
|
|
|
|
sec_sel = self.intelligence.extractor_mapper.map_goal_to_extractor(
|
|
sec_goal, intro
|
|
)
|
|
|
|
objectives.append(Objective(
|
|
name=f"secondary_{sec_goal}",
|
|
goal=self._normalize_goal(sec_goal),
|
|
extractor=sec_sel.extractor_id,
|
|
extractor_name=sec_sel.extractor_name,
|
|
extractor_params=sec_sel.params,
|
|
weight=0.5, # Default lower weight for secondary
|
|
))
|
|
|
|
# Build constraints
|
|
constraints = []
|
|
constraint_answers = answers.get("constraints", {})
|
|
constraint_handling = answers.get("constraint_handling", "hard")
|
|
|
|
if "max_stress" in constraint_answers and constraint_answers["max_stress"]:
|
|
stress_sel = self.intelligence.extractor_mapper.map_constraint_to_extractor("stress", intro)
|
|
constraints.append(Constraint(
|
|
name="max_stress",
|
|
constraint_type="max",
|
|
threshold=constraint_answers["max_stress"],
|
|
extractor=stress_sel.extractor_id,
|
|
extractor_name=stress_sel.extractor_name,
|
|
extractor_params=stress_sel.params,
|
|
is_hard=constraint_handling != "soft",
|
|
))
|
|
|
|
if "max_displacement" in constraint_answers and constraint_answers["max_displacement"]:
|
|
disp_sel = self.intelligence.extractor_mapper.map_constraint_to_extractor("displacement", intro)
|
|
constraints.append(Constraint(
|
|
name="max_displacement",
|
|
constraint_type="max",
|
|
threshold=constraint_answers["max_displacement"],
|
|
extractor=disp_sel.extractor_id,
|
|
extractor_name=disp_sel.extractor_name,
|
|
extractor_params=disp_sel.params,
|
|
is_hard=constraint_handling != "soft",
|
|
))
|
|
|
|
if "min_frequency" in constraint_answers and constraint_answers["min_frequency"]:
|
|
freq_sel = self.intelligence.extractor_mapper.map_constraint_to_extractor("frequency", intro)
|
|
constraints.append(Constraint(
|
|
name="min_frequency",
|
|
constraint_type="min",
|
|
threshold=constraint_answers["min_frequency"],
|
|
extractor=freq_sel.extractor_id,
|
|
extractor_name=freq_sel.extractor_name,
|
|
extractor_params=freq_sel.params,
|
|
is_hard=constraint_handling != "soft",
|
|
))
|
|
|
|
if "max_mass" in constraint_answers and constraint_answers["max_mass"]:
|
|
mass_sel = self.intelligence.extractor_mapper.map_constraint_to_extractor("mass", intro)
|
|
constraints.append(Constraint(
|
|
name="max_mass",
|
|
constraint_type="max",
|
|
threshold=constraint_answers["max_mass"],
|
|
extractor=mass_sel.extractor_id,
|
|
extractor_name=mass_sel.extractor_name,
|
|
is_hard=constraint_handling != "soft",
|
|
))
|
|
|
|
# Determine protocol
|
|
protocol = "protocol_11_multi" if len(objectives) > 1 else "protocol_10_single"
|
|
|
|
# Get settings
|
|
n_trials = answers.get("n_trials", 100)
|
|
if n_trials == "custom":
|
|
n_trials = 100 # Default
|
|
|
|
# Build blueprint
|
|
blueprint = StudyBlueprint(
|
|
study_name=state.study_name,
|
|
study_description=answers.get("problem_description", ""),
|
|
interview_session_id=state.session_id,
|
|
model_path=intro.get("part_file", ""),
|
|
sim_path=intro.get("sim_file", ""),
|
|
fem_path=intro.get("fem_file", ""),
|
|
design_variables=design_variables,
|
|
objectives=objectives,
|
|
constraints=constraints,
|
|
protocol=protocol,
|
|
n_trials=int(n_trials) if isinstance(n_trials, (int, float)) else 100,
|
|
sampler=self.intelligence.suggest_sampler(len(objectives), len(design_variables)),
|
|
use_neural_acceleration=answers.get("use_neural_acceleration", False),
|
|
solve_all_solutions=answers.get("solve_all_solutions", True),
|
|
warnings_acknowledged=state.warnings_acknowledged,
|
|
baseline_validated=answers.get("run_baseline_validation", False),
|
|
)
|
|
|
|
return blueprint
|
|
|
|
def _find_expression(self, name: str, expressions: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
"""Find expression by name."""
|
|
for expr in expressions:
|
|
if expr.get("name") == name:
|
|
return expr
|
|
return None
|
|
|
|
def _normalize_goal(self, goal: str) -> str:
|
|
"""Normalize goal string to standard format."""
|
|
goal_lower = goal.lower()
|
|
|
|
if "minimize" in goal_lower or "reduce" in goal_lower:
|
|
return "minimize"
|
|
elif "maximize" in goal_lower or "increase" in goal_lower:
|
|
return "maximize"
|
|
elif "target" in goal_lower:
|
|
return "target"
|
|
else:
|
|
return goal
|
|
|
|
|
|
# Import for type hints
|
|
from typing import TYPE_CHECKING
|
|
if TYPE_CHECKING:
|
|
from .interview_state import InterviewState
|