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:
648
optimization_engine/interview/interview_intelligence.py
Normal file
648
optimization_engine/interview/interview_intelligence.py
Normal file
@@ -0,0 +1,648 @@
|
||||
"""
|
||||
Interview Intelligence
|
||||
|
||||
Smart features for the interview process:
|
||||
- ExtractorMapper: Maps goals to appropriate extractors
|
||||
- InterviewIntelligence: Auto-detection, inference, complexity determination
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional, Literal, Tuple
|
||||
import re
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractorSelection:
|
||||
"""Result of mapping a goal to an extractor."""
|
||||
extractor_id: str
|
||||
extractor_name: str
|
||||
goal_type: str # minimize, maximize, target
|
||||
params: Dict[str, Any] = field(default_factory=dict)
|
||||
fallback: Optional[str] = None
|
||||
confidence: float = 1.0
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ExtractorMapper:
|
||||
"""
|
||||
Maps physics goals to appropriate extractors.
|
||||
|
||||
Uses the Atomizer extractor library (SYS_12) to select
|
||||
the right extractor for each objective or constraint.
|
||||
"""
|
||||
|
||||
# Goal to extractor mapping
|
||||
GOAL_MAP = {
|
||||
# Mass objectives
|
||||
"minimize_mass": ExtractorSelection(
|
||||
extractor_id="E4",
|
||||
extractor_name="BDF Mass Extraction",
|
||||
goal_type="minimize",
|
||||
fallback="E5",
|
||||
notes="Uses BDF parsing for accurate mass. Falls back to NX expression."
|
||||
),
|
||||
"minimize_weight": ExtractorSelection(
|
||||
extractor_id="E4",
|
||||
extractor_name="BDF Mass Extraction",
|
||||
goal_type="minimize",
|
||||
fallback="E5"
|
||||
),
|
||||
|
||||
# Displacement/stiffness objectives
|
||||
"minimize_displacement": ExtractorSelection(
|
||||
extractor_id="E1",
|
||||
extractor_name="Displacement Extraction",
|
||||
goal_type="minimize",
|
||||
params={"component": "magnitude", "node_id": "auto"},
|
||||
notes="Extracts displacement magnitude. Node ID auto-detected from max."
|
||||
),
|
||||
"maximize_stiffness": ExtractorSelection(
|
||||
extractor_id="E1",
|
||||
extractor_name="Displacement Extraction",
|
||||
goal_type="minimize", # Stiffness = 1/displacement
|
||||
params={"component": "magnitude", "node_id": "auto"},
|
||||
notes="Stiffness maximization = displacement minimization"
|
||||
),
|
||||
|
||||
# Frequency objectives
|
||||
"maximize_frequency": ExtractorSelection(
|
||||
extractor_id="E2",
|
||||
extractor_name="Frequency Extraction",
|
||||
goal_type="maximize",
|
||||
params={"mode_number": 1},
|
||||
notes="First natural frequency. Mode number adjustable."
|
||||
),
|
||||
"target_frequency": ExtractorSelection(
|
||||
extractor_id="E2",
|
||||
extractor_name="Frequency Extraction",
|
||||
goal_type="target",
|
||||
params={"mode_number": 1, "target": None},
|
||||
notes="Target a specific frequency value."
|
||||
),
|
||||
|
||||
# Stress objectives
|
||||
"minimize_stress": ExtractorSelection(
|
||||
extractor_id="E3",
|
||||
extractor_name="Solid Stress Extraction",
|
||||
goal_type="minimize",
|
||||
params={"element_type": "auto", "stress_type": "von_mises"},
|
||||
notes="Von Mises stress. Element type auto-detected."
|
||||
),
|
||||
|
||||
# Optical objectives
|
||||
"minimize_wavefront_error": ExtractorSelection(
|
||||
extractor_id="E8",
|
||||
extractor_name="Zernike Wavefront Fitting",
|
||||
goal_type="minimize",
|
||||
params={"n_terms": 15, "radius": "auto"},
|
||||
notes="Fits surface to Zernike polynomials. Optical applications."
|
||||
),
|
||||
|
||||
# Custom
|
||||
"custom": ExtractorSelection(
|
||||
extractor_id="custom",
|
||||
extractor_name="Custom Extractor",
|
||||
goal_type="custom",
|
||||
confidence=0.5,
|
||||
notes="User will define custom extraction logic."
|
||||
),
|
||||
}
|
||||
|
||||
# Constraint type to extractor mapping
|
||||
CONSTRAINT_MAP = {
|
||||
"stress": ExtractorSelection(
|
||||
extractor_id="E3",
|
||||
extractor_name="Solid Stress Extraction",
|
||||
goal_type="max",
|
||||
params={"stress_type": "von_mises"}
|
||||
),
|
||||
"max_stress": ExtractorSelection(
|
||||
extractor_id="E3",
|
||||
extractor_name="Solid Stress Extraction",
|
||||
goal_type="max",
|
||||
params={"stress_type": "von_mises"}
|
||||
),
|
||||
"displacement": ExtractorSelection(
|
||||
extractor_id="E1",
|
||||
extractor_name="Displacement Extraction",
|
||||
goal_type="max",
|
||||
params={"component": "magnitude"}
|
||||
),
|
||||
"max_displacement": ExtractorSelection(
|
||||
extractor_id="E1",
|
||||
extractor_name="Displacement Extraction",
|
||||
goal_type="max",
|
||||
params={"component": "magnitude"}
|
||||
),
|
||||
"frequency": ExtractorSelection(
|
||||
extractor_id="E2",
|
||||
extractor_name="Frequency Extraction",
|
||||
goal_type="min",
|
||||
params={"mode_number": 1}
|
||||
),
|
||||
"min_frequency": ExtractorSelection(
|
||||
extractor_id="E2",
|
||||
extractor_name="Frequency Extraction",
|
||||
goal_type="min",
|
||||
params={"mode_number": 1}
|
||||
),
|
||||
"mass": ExtractorSelection(
|
||||
extractor_id="E4",
|
||||
extractor_name="BDF Mass Extraction",
|
||||
goal_type="max"
|
||||
),
|
||||
"max_mass": ExtractorSelection(
|
||||
extractor_id="E4",
|
||||
extractor_name="BDF Mass Extraction",
|
||||
goal_type="max"
|
||||
),
|
||||
}
|
||||
|
||||
def map_goal_to_extractor(
|
||||
self,
|
||||
goal: str,
|
||||
introspection: Optional[Dict[str, Any]] = None
|
||||
) -> ExtractorSelection:
|
||||
"""
|
||||
Map a physics goal to the appropriate extractor.
|
||||
|
||||
Args:
|
||||
goal: Goal identifier (e.g., "minimize_mass")
|
||||
introspection: Optional introspection results for auto-detection
|
||||
|
||||
Returns:
|
||||
ExtractorSelection with extractor details
|
||||
"""
|
||||
goal_lower = goal.lower().strip()
|
||||
|
||||
# Direct match
|
||||
if goal_lower in self.GOAL_MAP:
|
||||
selection = self.GOAL_MAP[goal_lower]
|
||||
|
||||
# Auto-detect parameters if introspection available
|
||||
if introspection:
|
||||
selection = self._refine_selection(selection, introspection)
|
||||
|
||||
return selection
|
||||
|
||||
# Fuzzy matching for common variations
|
||||
for key, selection in self.GOAL_MAP.items():
|
||||
if key.replace("_", " ") in goal_lower or goal_lower in key:
|
||||
return selection
|
||||
|
||||
# Default to custom
|
||||
return self.GOAL_MAP["custom"]
|
||||
|
||||
def map_constraint_to_extractor(
|
||||
self,
|
||||
constraint_type: str,
|
||||
introspection: Optional[Dict[str, Any]] = None
|
||||
) -> ExtractorSelection:
|
||||
"""
|
||||
Map a constraint type to the appropriate extractor.
|
||||
|
||||
Args:
|
||||
constraint_type: Constraint type (e.g., "stress", "displacement")
|
||||
introspection: Optional introspection results
|
||||
|
||||
Returns:
|
||||
ExtractorSelection with extractor details
|
||||
"""
|
||||
type_lower = constraint_type.lower().strip()
|
||||
|
||||
if type_lower in self.CONSTRAINT_MAP:
|
||||
selection = self.CONSTRAINT_MAP[type_lower]
|
||||
|
||||
if introspection:
|
||||
selection = self._refine_selection(selection, introspection)
|
||||
|
||||
return selection
|
||||
|
||||
# Try to infer from name
|
||||
if "stress" in type_lower:
|
||||
return self.CONSTRAINT_MAP["stress"]
|
||||
if "disp" in type_lower or "deflect" in type_lower:
|
||||
return self.CONSTRAINT_MAP["displacement"]
|
||||
if "freq" in type_lower or "modal" in type_lower:
|
||||
return self.CONSTRAINT_MAP["frequency"]
|
||||
if "mass" in type_lower or "weight" in type_lower:
|
||||
return self.CONSTRAINT_MAP["mass"]
|
||||
|
||||
return ExtractorSelection(
|
||||
extractor_id="custom",
|
||||
extractor_name="Custom Constraint",
|
||||
goal_type="constraint",
|
||||
confidence=0.5
|
||||
)
|
||||
|
||||
def _refine_selection(
|
||||
self,
|
||||
selection: ExtractorSelection,
|
||||
introspection: Dict[str, Any]
|
||||
) -> ExtractorSelection:
|
||||
"""Refine extractor selection based on introspection."""
|
||||
import copy
|
||||
refined = copy.deepcopy(selection)
|
||||
|
||||
# Auto-detect element type for stress extraction
|
||||
if refined.extractor_id == "E3" and refined.params.get("element_type") == "auto":
|
||||
element_types = introspection.get("element_types", [])
|
||||
if "solid" in element_types or any("TET" in e or "HEX" in e for e in element_types):
|
||||
refined.params["element_type"] = "solid"
|
||||
elif "shell" in element_types or any("QUAD" in e or "TRI" in e for e in element_types):
|
||||
refined.params["element_type"] = "shell"
|
||||
refined.extractor_id = "E3_shell" # Use shell stress extractor
|
||||
|
||||
# Auto-detect node for displacement
|
||||
if refined.extractor_id == "E1" and refined.params.get("node_id") == "auto":
|
||||
# Use max displacement node from baseline if available
|
||||
if "max_disp_node" in introspection:
|
||||
refined.params["node_id"] = introspection["max_disp_node"]
|
||||
|
||||
return refined
|
||||
|
||||
def get_extractor_summary(self, selections: List[ExtractorSelection]) -> str:
|
||||
"""Generate a summary of selected extractors."""
|
||||
lines = ["**Selected Extractors:**", ""]
|
||||
|
||||
for sel in selections:
|
||||
params_str = ""
|
||||
if sel.params:
|
||||
params_str = " (" + ", ".join(f"{k}={v}" for k, v in sel.params.items()) + ")"
|
||||
|
||||
lines.append(f"- **{sel.extractor_id}**: {sel.extractor_name}{params_str}")
|
||||
if sel.notes:
|
||||
lines.append(f" > {sel.notes}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StudyTypeInference:
|
||||
"""Result of inferring study type."""
|
||||
study_type: str # single_objective, multi_objective, parametric
|
||||
protocol: str # protocol_10_single, protocol_11_multi
|
||||
confidence: float
|
||||
reasons: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class InterviewIntelligence:
|
||||
"""
|
||||
Smart features for the interview process.
|
||||
|
||||
Provides:
|
||||
- Study type inference from context
|
||||
- Auto-selection of extractors
|
||||
- History-based suggestions
|
||||
- Complexity determination
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize intelligence module."""
|
||||
self.extractor_mapper = ExtractorMapper()
|
||||
|
||||
def infer_study_type(
|
||||
self,
|
||||
study_name: str,
|
||||
user_description: str,
|
||||
introspection: Optional[Dict[str, Any]] = None
|
||||
) -> StudyTypeInference:
|
||||
"""
|
||||
Infer study type from available context.
|
||||
|
||||
Args:
|
||||
study_name: Study name (may contain hints)
|
||||
user_description: User's problem description
|
||||
introspection: Optional introspection results
|
||||
|
||||
Returns:
|
||||
StudyTypeInference with type and protocol
|
||||
"""
|
||||
reasons = []
|
||||
score_multi = 0
|
||||
score_single = 0
|
||||
|
||||
text = f"{study_name} {user_description}".lower()
|
||||
|
||||
# Check for multi-objective keywords
|
||||
if any(kw in text for kw in ["pareto", "trade-off", "tradeoff", "multi-objective", "multiobjective"]):
|
||||
score_multi += 2
|
||||
reasons.append("Multi-objective keywords detected")
|
||||
|
||||
if any(kw in text for kw in ["versus", " vs ", "and minimize", "and maximize", "balance"]):
|
||||
score_multi += 1
|
||||
reasons.append("Conflicting goals language detected")
|
||||
|
||||
# Check for single-objective keywords
|
||||
if any(kw in text for kw in ["minimize", "maximize", "reduce", "increase"]):
|
||||
# Count occurrences
|
||||
count = sum(1 for kw in ["minimize", "maximize", "reduce", "increase"] if kw in text)
|
||||
if count == 1:
|
||||
score_single += 1
|
||||
reasons.append("Single optimization goal language")
|
||||
else:
|
||||
score_multi += 1
|
||||
reasons.append("Multiple optimization verbs detected")
|
||||
|
||||
# Default to single objective if no strong signals
|
||||
if score_multi > score_single:
|
||||
return StudyTypeInference(
|
||||
study_type="multi_objective",
|
||||
protocol="protocol_11_multi",
|
||||
confidence=min(1.0, 0.5 + score_multi * 0.2),
|
||||
reasons=reasons
|
||||
)
|
||||
else:
|
||||
return StudyTypeInference(
|
||||
study_type="single_objective",
|
||||
protocol="protocol_10_single",
|
||||
confidence=min(1.0, 0.5 + score_single * 0.2),
|
||||
reasons=reasons if reasons else ["Default to single-objective"]
|
||||
)
|
||||
|
||||
def auto_select_extractors(
|
||||
self,
|
||||
objectives: List[Dict[str, Any]],
|
||||
constraints: List[Dict[str, Any]],
|
||||
introspection: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, ExtractorSelection]:
|
||||
"""
|
||||
Automatically select appropriate extractors.
|
||||
|
||||
Args:
|
||||
objectives: List of objective definitions
|
||||
constraints: List of constraint definitions
|
||||
introspection: Optional introspection results
|
||||
|
||||
Returns:
|
||||
Dict mapping objective/constraint names to ExtractorSelection
|
||||
"""
|
||||
selections = {}
|
||||
|
||||
# Map objectives
|
||||
for i, obj in enumerate(objectives):
|
||||
goal = obj.get("goal", "") if isinstance(obj, dict) else str(obj)
|
||||
name = obj.get("name", f"objective_{i}") if isinstance(obj, dict) else f"objective_{i}"
|
||||
|
||||
selection = self.extractor_mapper.map_goal_to_extractor(goal, introspection)
|
||||
selections[name] = selection
|
||||
|
||||
# Map constraints
|
||||
for i, con in enumerate(constraints):
|
||||
con_type = con.get("type", "") if isinstance(con, dict) else str(con)
|
||||
name = con.get("name", f"constraint_{i}") if isinstance(con, dict) else f"constraint_{i}"
|
||||
|
||||
selection = self.extractor_mapper.map_constraint_to_extractor(con_type, introspection)
|
||||
selections[name] = selection
|
||||
|
||||
return selections
|
||||
|
||||
def determine_complexity(
|
||||
self,
|
||||
state: "InterviewState",
|
||||
introspection: Optional[Dict[str, Any]] = None
|
||||
) -> Literal["simple", "moderate", "complex"]:
|
||||
"""
|
||||
Determine study complexity for adaptive questioning.
|
||||
|
||||
Based on:
|
||||
- Number of objectives
|
||||
- Number of design variables
|
||||
- Analysis complexity
|
||||
- Custom components
|
||||
|
||||
Args:
|
||||
state: Current interview state
|
||||
introspection: Optional introspection results
|
||||
|
||||
Returns:
|
||||
Complexity level
|
||||
"""
|
||||
score = 0
|
||||
answers = state.answers
|
||||
|
||||
# Objectives
|
||||
n_obj = len(answers.get("objectives", []))
|
||||
secondary = answers.get("objectives_secondary", [])
|
||||
if "none" not in secondary:
|
||||
n_obj += len(secondary)
|
||||
|
||||
if n_obj == 1:
|
||||
score += 0
|
||||
elif n_obj == 2:
|
||||
score += 1
|
||||
else:
|
||||
score += 2
|
||||
|
||||
# Design variables
|
||||
n_dvs = len(answers.get("design_variables", []))
|
||||
if n_dvs <= 3:
|
||||
score += 0
|
||||
elif n_dvs <= 6:
|
||||
score += 1
|
||||
else:
|
||||
score += 2
|
||||
|
||||
# Analysis types
|
||||
analysis_types = answers.get("analysis_types", [])
|
||||
if len(analysis_types) > 2:
|
||||
score += 2
|
||||
elif len(analysis_types) > 1:
|
||||
score += 1
|
||||
|
||||
if "coupled_thermal_structural" in analysis_types:
|
||||
score += 1
|
||||
if "nonlinear" in analysis_types:
|
||||
score += 1
|
||||
|
||||
# Introspection complexity
|
||||
if introspection:
|
||||
if introspection.get("multiple_solutions", False):
|
||||
score += 1
|
||||
if len(introspection.get("expressions", [])) > 20:
|
||||
score += 1
|
||||
|
||||
# Categorize
|
||||
if score <= 2:
|
||||
return "simple"
|
||||
elif score <= 5:
|
||||
return "moderate"
|
||||
else:
|
||||
return "complex"
|
||||
|
||||
def suggest_trial_count(
|
||||
self,
|
||||
n_design_variables: int,
|
||||
n_objectives: int,
|
||||
complexity: str
|
||||
) -> int:
|
||||
"""
|
||||
Suggest appropriate number of trials.
|
||||
|
||||
Args:
|
||||
n_design_variables: Number of design variables
|
||||
n_objectives: Number of objectives
|
||||
complexity: Study complexity level
|
||||
|
||||
Returns:
|
||||
Suggested trial count
|
||||
"""
|
||||
# Base: 15 trials per design variable
|
||||
base = n_design_variables * 15
|
||||
|
||||
# Multi-objective needs more
|
||||
if n_objectives > 1:
|
||||
base = int(base * 1.5)
|
||||
|
||||
# Adjust for complexity
|
||||
if complexity == "simple":
|
||||
base = max(50, base)
|
||||
elif complexity == "moderate":
|
||||
base = max(100, base)
|
||||
else:
|
||||
base = max(150, base)
|
||||
|
||||
# Round to nice numbers
|
||||
if base <= 50:
|
||||
return 50
|
||||
elif base <= 75:
|
||||
return 75
|
||||
elif base <= 100:
|
||||
return 100
|
||||
elif base <= 150:
|
||||
return 150
|
||||
elif base <= 200:
|
||||
return 200
|
||||
else:
|
||||
return int((base // 100) * 100)
|
||||
|
||||
def suggest_sampler(
|
||||
self,
|
||||
n_objectives: int,
|
||||
n_design_variables: int
|
||||
) -> str:
|
||||
"""
|
||||
Suggest appropriate sampler/optimizer.
|
||||
|
||||
Args:
|
||||
n_objectives: Number of objectives
|
||||
n_design_variables: Number of design variables
|
||||
|
||||
Returns:
|
||||
Sampler name
|
||||
"""
|
||||
if n_objectives > 1:
|
||||
return "NSGA-II" # Multi-objective
|
||||
elif n_design_variables <= 3:
|
||||
return "TPE" # Tree-structured Parzen Estimator
|
||||
elif n_design_variables <= 10:
|
||||
return "CMA-ES" # Covariance Matrix Adaptation
|
||||
else:
|
||||
return "TPE" # TPE handles high dimensions well
|
||||
|
||||
def analyze_design_variable_candidates(
|
||||
self,
|
||||
expressions: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Analyze expressions to find design variable candidates.
|
||||
|
||||
Args:
|
||||
expressions: List of expressions from introspection
|
||||
|
||||
Returns:
|
||||
Sorted list of candidates with scores
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# High confidence patterns
|
||||
high_patterns = [
|
||||
(r"thickness", "Thickness parameter"),
|
||||
(r"width", "Width parameter"),
|
||||
(r"height", "Height parameter"),
|
||||
(r"diameter", "Diameter parameter"),
|
||||
(r"radius", "Radius parameter"),
|
||||
(r"length", "Length parameter"),
|
||||
(r"depth", "Depth parameter"),
|
||||
(r"angle", "Angle parameter"),
|
||||
(r"fillet", "Fillet radius"),
|
||||
(r"chamfer", "Chamfer dimension"),
|
||||
(r"rib_", "Rib parameter"),
|
||||
(r"wall_", "Wall parameter"),
|
||||
(r"flange_", "Flange parameter"),
|
||||
]
|
||||
|
||||
# Medium confidence patterns
|
||||
medium_patterns = [
|
||||
(r"dim_", "Dimension parameter"),
|
||||
(r"size_", "Size parameter"),
|
||||
(r"param_", "Named parameter"),
|
||||
(r"^p\d+$", "Numbered parameter"),
|
||||
(r"var_", "Variable"),
|
||||
]
|
||||
|
||||
# Exclusion patterns
|
||||
exclude_patterns = [
|
||||
r"mesh_", r"count_", r"num_", r"material",
|
||||
r"derived_", r"calc_", r"_result$", r"_output$",
|
||||
r"^n\d+$", r"count$"
|
||||
]
|
||||
|
||||
for expr in expressions:
|
||||
name = expr.get("name", "")
|
||||
value = expr.get("value")
|
||||
formula = expr.get("formula", "")
|
||||
|
||||
# Skip non-numeric
|
||||
if not isinstance(value, (int, float)):
|
||||
continue
|
||||
|
||||
# Skip formulas (computed values)
|
||||
if formula and formula != str(value):
|
||||
continue
|
||||
|
||||
# Check exclusions
|
||||
if any(re.search(p, name.lower()) for p in exclude_patterns):
|
||||
continue
|
||||
|
||||
# Score
|
||||
score = 0
|
||||
reason = "Named expression"
|
||||
|
||||
for pattern, desc in high_patterns:
|
||||
if re.search(pattern, name.lower()):
|
||||
score = 3
|
||||
reason = desc
|
||||
break
|
||||
|
||||
if score == 0:
|
||||
for pattern, desc in medium_patterns:
|
||||
if re.search(pattern, name.lower()):
|
||||
score = 2
|
||||
reason = desc
|
||||
break
|
||||
|
||||
if score == 0 and len(name) > 2:
|
||||
score = 1
|
||||
|
||||
if score > 0:
|
||||
candidates.append({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"score": score,
|
||||
"reason": reason,
|
||||
"suggested_min": round(value * 0.5, 3) if value > 0 else round(value * 1.5, 3),
|
||||
"suggested_max": round(value * 1.5, 3) if value > 0 else round(value * 0.5, 3),
|
||||
})
|
||||
|
||||
# Sort by score descending
|
||||
candidates.sort(key=lambda x: (-x["score"], x["name"]))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
# Import for type hints
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .interview_state import InterviewState
|
||||
Reference in New Issue
Block a user