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:
747
optimization_engine/interview/question_engine.py
Normal file
747
optimization_engine/interview/question_engine.py
Normal file
@@ -0,0 +1,747 @@
|
||||
"""
|
||||
Question Engine
|
||||
|
||||
This module manages question definitions, conditions, and dynamic options.
|
||||
It handles:
|
||||
- Loading question schemas from JSON
|
||||
- Evaluating conditional logic
|
||||
- Populating dynamic options from introspection
|
||||
- Question ordering and flow control
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional, Literal, Union
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationRule:
|
||||
"""Validation rule for a question answer."""
|
||||
required: bool = False
|
||||
min_length: Optional[int] = None
|
||||
max_length: Optional[int] = None
|
||||
min: Optional[float] = None
|
||||
max: Optional[float] = None
|
||||
min_selections: Optional[int] = None
|
||||
max_selections: Optional[int] = None
|
||||
pattern: Optional[str] = None
|
||||
units: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> Optional["ValidationRule"]:
|
||||
"""Create from dictionary."""
|
||||
if data is None:
|
||||
return None
|
||||
return cls(
|
||||
required=data.get("required", False),
|
||||
min_length=data.get("min_length"),
|
||||
max_length=data.get("max_length"),
|
||||
min=data.get("min"),
|
||||
max=data.get("max"),
|
||||
min_selections=data.get("min_selections"),
|
||||
max_selections=data.get("max_selections"),
|
||||
pattern=data.get("pattern"),
|
||||
units=data.get("units"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestionOption:
|
||||
"""Option for choice/multi_choice questions."""
|
||||
value: Any
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "QuestionOption":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
value=data["value"],
|
||||
label=data["label"],
|
||||
description=data.get("description"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestionCondition:
|
||||
"""
|
||||
Conditional logic for when to ask a question.
|
||||
|
||||
Supports:
|
||||
- answered: field has been answered
|
||||
- equals: field equals value
|
||||
- contains: array field contains value
|
||||
- greater_than: numeric comparison
|
||||
- less_than: numeric comparison
|
||||
- exists: field exists and is not None
|
||||
- introspection_has: introspection data has field
|
||||
- complexity_is: complexity level matches
|
||||
- and/or/not: logical operators
|
||||
"""
|
||||
type: str
|
||||
field: Optional[str] = None
|
||||
value: Optional[Any] = None
|
||||
condition: Optional["QuestionCondition"] = None # For 'not'
|
||||
conditions: Optional[List["QuestionCondition"]] = None # For 'and'/'or'
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> Optional["QuestionCondition"]:
|
||||
"""Create from dictionary."""
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
condition = cls(
|
||||
type=data["type"],
|
||||
field=data.get("field"),
|
||||
value=data.get("value"),
|
||||
)
|
||||
|
||||
# Handle nested 'not' condition
|
||||
if "condition" in data:
|
||||
condition.condition = cls.from_dict(data["condition"])
|
||||
|
||||
# Handle nested 'and'/'or' conditions
|
||||
if "conditions" in data:
|
||||
condition.conditions = [
|
||||
cls.from_dict(c) for c in data["conditions"]
|
||||
]
|
||||
|
||||
return condition
|
||||
|
||||
|
||||
@dataclass
|
||||
class DynamicOptions:
|
||||
"""Configuration for dynamic option population."""
|
||||
type: str
|
||||
source: str
|
||||
filter: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> Optional["DynamicOptions"]:
|
||||
"""Create from dictionary."""
|
||||
if data is None:
|
||||
return None
|
||||
return cls(
|
||||
type=data["type"],
|
||||
source=data["source"],
|
||||
filter=data.get("filter"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DynamicContent:
|
||||
"""Configuration for dynamic content in question text."""
|
||||
type: str
|
||||
source: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> Optional["DynamicContent"]:
|
||||
"""Create from dictionary."""
|
||||
if data is None:
|
||||
return None
|
||||
return cls(
|
||||
type=data["type"],
|
||||
source=data["source"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Question:
|
||||
"""Represents a single interview question."""
|
||||
id: str
|
||||
category: str
|
||||
text: str
|
||||
question_type: Literal["text", "choice", "multi_choice", "numeric", "confirm", "parameter_select", "bounds"]
|
||||
maps_to: str
|
||||
help_text: Optional[str] = None
|
||||
options: Optional[List[QuestionOption]] = None
|
||||
default: Optional[Any] = None
|
||||
validation: Optional[ValidationRule] = None
|
||||
condition: Optional[QuestionCondition] = None
|
||||
engineering_guidance: Optional[str] = None
|
||||
dynamic_options: Optional[DynamicOptions] = None
|
||||
dynamic_content: Optional[DynamicContent] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Question":
|
||||
"""Create from dictionary."""
|
||||
options = None
|
||||
if data.get("options"):
|
||||
options = [QuestionOption.from_dict(o) for o in data["options"]]
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
category=data["category"],
|
||||
text=data["text"],
|
||||
question_type=data["question_type"],
|
||||
maps_to=data["maps_to"],
|
||||
help_text=data.get("help_text"),
|
||||
options=options,
|
||||
default=data.get("default"),
|
||||
validation=ValidationRule.from_dict(data.get("validation")),
|
||||
condition=QuestionCondition.from_dict(data.get("condition")),
|
||||
engineering_guidance=data.get("engineering_guidance"),
|
||||
dynamic_options=DynamicOptions.from_dict(data.get("dynamic_options")),
|
||||
dynamic_content=DynamicContent.from_dict(data.get("dynamic_content")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestionCategory:
|
||||
"""Category of related questions."""
|
||||
id: str
|
||||
name: str
|
||||
phase: str
|
||||
order: int
|
||||
always_ask: bool = True
|
||||
condition: Optional[QuestionCondition] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "QuestionCategory":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
phase=data["phase"],
|
||||
order=data["order"],
|
||||
always_ask=data.get("always_ask", True),
|
||||
condition=QuestionCondition.from_dict(data.get("condition")),
|
||||
)
|
||||
|
||||
|
||||
class QuestionEngine:
|
||||
"""
|
||||
Manages question definitions and flow logic.
|
||||
|
||||
Handles:
|
||||
- Loading questions from JSON schema
|
||||
- Evaluating conditions to determine next question
|
||||
- Populating dynamic options from introspection
|
||||
- Answer parsing and validation
|
||||
"""
|
||||
|
||||
def __init__(self, schema_path: Optional[Path] = None):
|
||||
"""
|
||||
Initialize question engine.
|
||||
|
||||
Args:
|
||||
schema_path: Path to question schema JSON. If None, uses default.
|
||||
"""
|
||||
if schema_path is None:
|
||||
schema_path = Path(__file__).parent / "schemas" / "interview_questions.json"
|
||||
|
||||
self.schema_path = schema_path
|
||||
self.schema: Dict[str, Any] = {}
|
||||
self.categories: List[QuestionCategory] = []
|
||||
self.questions: Dict[str, Question] = {}
|
||||
self.questions_by_category: Dict[str, List[Question]] = {}
|
||||
|
||||
self._load_schema()
|
||||
|
||||
def _load_schema(self) -> None:
|
||||
"""Load question schema from JSON file."""
|
||||
if not self.schema_path.exists():
|
||||
raise FileNotFoundError(f"Question schema not found: {self.schema_path}")
|
||||
|
||||
with open(self.schema_path, "r", encoding="utf-8") as f:
|
||||
self.schema = json.load(f)
|
||||
|
||||
# Parse categories
|
||||
self.categories = [
|
||||
QuestionCategory.from_dict(c) for c in self.schema.get("categories", [])
|
||||
]
|
||||
self.categories.sort(key=lambda c: c.order)
|
||||
|
||||
# Parse questions
|
||||
for q_data in self.schema.get("questions", []):
|
||||
question = Question.from_dict(q_data)
|
||||
self.questions[question.id] = question
|
||||
|
||||
# Organize by category
|
||||
if question.category not in self.questions_by_category:
|
||||
self.questions_by_category[question.category] = []
|
||||
self.questions_by_category[question.category].append(question)
|
||||
|
||||
def get_all_questions(self) -> List[Question]:
|
||||
"""Get all questions in order."""
|
||||
result = []
|
||||
for category in self.categories:
|
||||
if category.id in self.questions_by_category:
|
||||
result.extend(self.questions_by_category[category.id])
|
||||
return result
|
||||
|
||||
def get_question(self, question_id: str) -> Optional[Question]:
|
||||
"""Get a specific question by ID."""
|
||||
return self.questions.get(question_id)
|
||||
|
||||
def get_next_question(
|
||||
self,
|
||||
state: "InterviewState",
|
||||
introspection: Dict[str, Any]
|
||||
) -> Optional[Question]:
|
||||
"""
|
||||
Determine the next question based on state and conditions.
|
||||
|
||||
Args:
|
||||
state: Current interview state
|
||||
introspection: Introspection results from model
|
||||
|
||||
Returns:
|
||||
Next question to ask, or None if interview is complete
|
||||
"""
|
||||
answered_ids = {q["question_id"] for q in state.questions_answered}
|
||||
|
||||
# Go through categories in order
|
||||
for category in self.categories:
|
||||
# Check if category should be asked
|
||||
if not self._should_ask_category(category, state, introspection):
|
||||
continue
|
||||
|
||||
# Get questions in this category
|
||||
category_questions = self.questions_by_category.get(category.id, [])
|
||||
|
||||
for question in category_questions:
|
||||
# Skip if already answered
|
||||
if question.id in answered_ids:
|
||||
continue
|
||||
|
||||
# Check if question condition is met
|
||||
if self._should_ask_question(question, state, introspection):
|
||||
# Populate dynamic options if needed
|
||||
return self._prepare_question(question, state, introspection)
|
||||
|
||||
# No more questions
|
||||
return None
|
||||
|
||||
def _should_ask_category(
|
||||
self,
|
||||
category: QuestionCategory,
|
||||
state: "InterviewState",
|
||||
introspection: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""Check if a category should be asked."""
|
||||
if category.always_ask:
|
||||
return True
|
||||
|
||||
if category.condition:
|
||||
return self.evaluate_condition(category.condition, state, introspection)
|
||||
|
||||
return True
|
||||
|
||||
def _should_ask_question(
|
||||
self,
|
||||
question: Question,
|
||||
state: "InterviewState",
|
||||
introspection: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""Check if a question should be asked."""
|
||||
if question.condition is None:
|
||||
return True
|
||||
|
||||
return self.evaluate_condition(question.condition, state, introspection)
|
||||
|
||||
def evaluate_condition(
|
||||
self,
|
||||
condition: QuestionCondition,
|
||||
state: "InterviewState",
|
||||
introspection: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Evaluate if a condition is met.
|
||||
|
||||
Args:
|
||||
condition: Condition to evaluate
|
||||
state: Current interview state
|
||||
introspection: Introspection results
|
||||
|
||||
Returns:
|
||||
True if condition is met
|
||||
"""
|
||||
cond_type = condition.type
|
||||
|
||||
if cond_type == "answered":
|
||||
return self._get_nested_value(state.answers, condition.field) is not None
|
||||
|
||||
elif cond_type == "equals":
|
||||
actual = self._get_nested_value(state.answers, condition.field)
|
||||
return actual == condition.value
|
||||
|
||||
elif cond_type == "contains":
|
||||
actual = self._get_nested_value(state.answers, condition.field)
|
||||
if isinstance(actual, list):
|
||||
return condition.value in actual
|
||||
return False
|
||||
|
||||
elif cond_type == "greater_than":
|
||||
actual = self._get_nested_value(state.answers, condition.field)
|
||||
if actual is not None and isinstance(actual, (int, float)):
|
||||
return actual > condition.value
|
||||
return False
|
||||
|
||||
elif cond_type == "less_than":
|
||||
actual = self._get_nested_value(state.answers, condition.field)
|
||||
if actual is not None and isinstance(actual, (int, float)):
|
||||
return actual < condition.value
|
||||
return False
|
||||
|
||||
elif cond_type == "exists":
|
||||
actual = self._get_nested_value(state.answers, condition.field)
|
||||
return actual is not None
|
||||
|
||||
elif cond_type == "introspection_has":
|
||||
return condition.field in introspection
|
||||
|
||||
elif cond_type == "complexity_is":
|
||||
expected = condition.value
|
||||
if isinstance(expected, list):
|
||||
return state.complexity in expected
|
||||
return state.complexity == expected
|
||||
|
||||
elif cond_type == "and":
|
||||
if condition.conditions:
|
||||
return all(
|
||||
self.evaluate_condition(c, state, introspection)
|
||||
for c in condition.conditions
|
||||
)
|
||||
return True
|
||||
|
||||
elif cond_type == "or":
|
||||
if condition.conditions:
|
||||
return any(
|
||||
self.evaluate_condition(c, state, introspection)
|
||||
for c in condition.conditions
|
||||
)
|
||||
return False
|
||||
|
||||
elif cond_type == "not":
|
||||
if condition.condition:
|
||||
return not self.evaluate_condition(condition.condition, state, introspection)
|
||||
return True
|
||||
|
||||
else:
|
||||
# Unknown condition type
|
||||
return True
|
||||
|
||||
def _get_nested_value(self, data: Dict[str, Any], path: str) -> Any:
|
||||
"""
|
||||
Get a value from nested dict using dot notation.
|
||||
|
||||
Supports array indexing: "objectives[0].goal"
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
|
||||
parts = re.split(r'\.|\[|\]', path)
|
||||
parts = [p for p in parts if p] # Remove empty strings
|
||||
|
||||
current = data
|
||||
for part in parts:
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
if isinstance(current, dict):
|
||||
current = current.get(part)
|
||||
elif isinstance(current, list):
|
||||
try:
|
||||
idx = int(part)
|
||||
if 0 <= idx < len(current):
|
||||
current = current[idx]
|
||||
else:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
return current
|
||||
|
||||
def _prepare_question(
|
||||
self,
|
||||
question: Question,
|
||||
state: "InterviewState",
|
||||
introspection: Dict[str, Any]
|
||||
) -> Question:
|
||||
"""
|
||||
Prepare a question for presentation.
|
||||
|
||||
Populates dynamic options and content.
|
||||
"""
|
||||
# Create a copy to avoid mutating the original
|
||||
import copy
|
||||
prepared = copy.deepcopy(question)
|
||||
|
||||
# Populate dynamic options
|
||||
if prepared.dynamic_options:
|
||||
prepared.options = self._populate_dynamic_options(
|
||||
prepared.dynamic_options, state, introspection
|
||||
)
|
||||
|
||||
return prepared
|
||||
|
||||
def _populate_dynamic_options(
|
||||
self,
|
||||
dynamic: DynamicOptions,
|
||||
state: "InterviewState",
|
||||
introspection: Dict[str, Any]
|
||||
) -> List[QuestionOption]:
|
||||
"""Populate dynamic options from introspection data."""
|
||||
options = []
|
||||
|
||||
if dynamic.type == "expressions":
|
||||
# Get expressions from introspection
|
||||
expressions = introspection.get("expressions", [])
|
||||
|
||||
# Apply filter if specified
|
||||
if dynamic.filter == "design_variable_heuristics":
|
||||
expressions = self._filter_design_variables(expressions)
|
||||
elif dynamic.filter == "exclude_selected_dvs":
|
||||
selected = [dv.get("parameter") for dv in state.answers.get("design_variables", [])]
|
||||
expressions = [e for e in expressions if e.get("name") not in selected]
|
||||
|
||||
# Convert to options
|
||||
for expr in expressions:
|
||||
name = expr.get("name", "")
|
||||
value = expr.get("value", 0)
|
||||
options.append(QuestionOption(
|
||||
value=name,
|
||||
label=f"{name} (current: {value})",
|
||||
description=expr.get("formula") if expr.get("formula") != str(value) else None,
|
||||
))
|
||||
|
||||
return options
|
||||
|
||||
def _filter_design_variables(self, expressions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Filter expressions to likely design variables using heuristics."""
|
||||
# High confidence patterns
|
||||
high_patterns = [
|
||||
r"thickness", r"width", r"height", r"diameter", r"radius",
|
||||
r"length", r"depth", r"angle", r"fillet", r"chamfer",
|
||||
r"rib_\w+", r"wall_\w+", r"flange_\w+"
|
||||
]
|
||||
|
||||
# Medium confidence patterns
|
||||
medium_patterns = [
|
||||
r"dim_\w+", r"size_\w+", r"param_\w+", r"p\d+", r"var_\w+"
|
||||
]
|
||||
|
||||
# Exclusion patterns
|
||||
exclude_patterns = [
|
||||
r"mesh_\w+", r"count_\w+", r"num_\w+", r"material\w*",
|
||||
r"derived_\w+", r"calc_\w+", r"_result$", r"_output$"
|
||||
]
|
||||
|
||||
def matches_any(name: str, patterns: List[str]) -> bool:
|
||||
return any(re.search(p, name.lower()) for p in patterns)
|
||||
|
||||
# Score and filter
|
||||
scored = []
|
||||
for expr in expressions:
|
||||
name = expr.get("name", "")
|
||||
|
||||
# Skip exclusions
|
||||
if matches_any(name, exclude_patterns):
|
||||
continue
|
||||
|
||||
# Skip if not a simple numeric value
|
||||
value = expr.get("value")
|
||||
if not isinstance(value, (int, float)):
|
||||
continue
|
||||
|
||||
# Skip if it's a formula (computed value)
|
||||
formula = expr.get("formula", "")
|
||||
if formula and formula != str(value):
|
||||
continue
|
||||
|
||||
# Score
|
||||
score = 0
|
||||
if matches_any(name, high_patterns):
|
||||
score = 2
|
||||
elif matches_any(name, medium_patterns):
|
||||
score = 1
|
||||
|
||||
if score > 0 or len(name) > 2: # Include if named or matches pattern
|
||||
scored.append((score, expr))
|
||||
|
||||
# Sort by score descending
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
|
||||
return [expr for _, expr in scored]
|
||||
|
||||
def validate_answer(
|
||||
self,
|
||||
answer: Any,
|
||||
question: Question
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate an answer against question rules.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if question.validation is None:
|
||||
return True, None
|
||||
|
||||
validation = question.validation
|
||||
|
||||
# Required check
|
||||
if validation.required:
|
||||
if answer is None or answer == "" or answer == []:
|
||||
return False, "This field is required"
|
||||
|
||||
# Skip further validation if empty and not required
|
||||
if answer is None or answer == "":
|
||||
return True, None
|
||||
|
||||
# Text length validation
|
||||
if question.question_type == "text":
|
||||
if validation.min_length and len(str(answer)) < validation.min_length:
|
||||
return False, f"Answer must be at least {validation.min_length} characters"
|
||||
if validation.max_length and len(str(answer)) > validation.max_length:
|
||||
return False, f"Answer must be at most {validation.max_length} characters"
|
||||
|
||||
# Numeric validation
|
||||
if question.question_type == "numeric":
|
||||
try:
|
||||
num = float(answer)
|
||||
if validation.min is not None and num < validation.min:
|
||||
return False, f"Value must be at least {validation.min}"
|
||||
if validation.max is not None and num > validation.max:
|
||||
return False, f"Value must be at most {validation.max}"
|
||||
except (ValueError, TypeError):
|
||||
return False, "Please enter a valid number"
|
||||
|
||||
# Multi-choice validation
|
||||
if question.question_type in ["multi_choice", "parameter_select"]:
|
||||
if isinstance(answer, list):
|
||||
if validation.min_selections and len(answer) < validation.min_selections:
|
||||
return False, f"Please select at least {validation.min_selections} option(s)"
|
||||
if validation.max_selections and len(answer) > validation.max_selections:
|
||||
return False, f"Please select at most {validation.max_selections} option(s)"
|
||||
|
||||
# Pattern validation
|
||||
if validation.pattern:
|
||||
if not re.match(validation.pattern, str(answer)):
|
||||
return False, "Answer does not match required format"
|
||||
|
||||
return True, None
|
||||
|
||||
def parse_answer(
|
||||
self,
|
||||
raw_answer: str,
|
||||
question: Question
|
||||
) -> Any:
|
||||
"""
|
||||
Parse a raw answer string into the appropriate type.
|
||||
|
||||
Args:
|
||||
raw_answer: Raw string answer from user
|
||||
question: Question being answered
|
||||
|
||||
Returns:
|
||||
Parsed answer value
|
||||
"""
|
||||
answer = raw_answer.strip()
|
||||
|
||||
if question.question_type == "text":
|
||||
return answer
|
||||
|
||||
elif question.question_type == "numeric":
|
||||
# Extract number, handling units
|
||||
match = re.search(r"[-+]?\d*\.?\d+", answer)
|
||||
if match:
|
||||
return float(match.group())
|
||||
return None
|
||||
|
||||
elif question.question_type == "confirm":
|
||||
lower = answer.lower()
|
||||
if lower in ["yes", "y", "true", "1", "ok", "sure", "confirm", "correct"]:
|
||||
return True
|
||||
elif lower in ["no", "n", "false", "0", "cancel", "incorrect"]:
|
||||
return False
|
||||
return None
|
||||
|
||||
elif question.question_type == "choice":
|
||||
# Try matching by number
|
||||
if answer.isdigit():
|
||||
idx = int(answer) - 1
|
||||
if question.options and 0 <= idx < len(question.options):
|
||||
return question.options[idx].value
|
||||
|
||||
# Try matching by value or label
|
||||
if question.options:
|
||||
for opt in question.options:
|
||||
if answer.lower() == str(opt.value).lower():
|
||||
return opt.value
|
||||
if answer.lower() == opt.label.lower():
|
||||
return opt.value
|
||||
# Fuzzy match
|
||||
if answer.lower() in opt.label.lower():
|
||||
return opt.value
|
||||
|
||||
return answer
|
||||
|
||||
elif question.question_type == "multi_choice":
|
||||
# Parse comma/and separated values
|
||||
parts = re.split(r"[,&]|\band\b", answer)
|
||||
values = []
|
||||
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# Try matching by number
|
||||
if part.isdigit():
|
||||
idx = int(part) - 1
|
||||
if question.options and 0 <= idx < len(question.options):
|
||||
values.append(question.options[idx].value)
|
||||
continue
|
||||
|
||||
# Try matching by value or label
|
||||
if question.options:
|
||||
for opt in question.options:
|
||||
if part.lower() == str(opt.value).lower():
|
||||
values.append(opt.value)
|
||||
break
|
||||
if part.lower() == opt.label.lower():
|
||||
values.append(opt.value)
|
||||
break
|
||||
if part.lower() in opt.label.lower():
|
||||
values.append(opt.value)
|
||||
break
|
||||
|
||||
return values if values else [answer]
|
||||
|
||||
elif question.question_type == "parameter_select":
|
||||
# Similar to multi_choice but for parameters
|
||||
parts = re.split(r"[,&]|\band\b", answer)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
elif question.question_type == "bounds":
|
||||
# Parse bounds like "2-10" or "2 to 10" or "min 2, max 10"
|
||||
bounds = {}
|
||||
|
||||
# Try "min to max" format
|
||||
match = re.search(r"(\d+\.?\d*)\s*(?:to|-)\s*(\d+\.?\d*)", answer)
|
||||
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*)", answer.lower())
|
||||
max_match = re.search(r"max[:\s]+(\d+\.?\d*)", answer.lower())
|
||||
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
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
# Import InterviewState here to avoid circular imports
|
||||
from .interview_state import InterviewState
|
||||
Reference in New Issue
Block a user