feat: Add Studio UI, intake system, and extractor improvements
Dashboard: - Add Studio page with drag-drop model upload and Claude chat - Add intake system for study creation workflow - Improve session manager and context builder - Add intake API routes and frontend components Optimization Engine: - Add CLI module for command-line operations - Add intake module for study preprocessing - Add validation module with gate checks - Improve Zernike extractor documentation - Update spec models with better validation - Enhance solve_simulation robustness Documentation: - Add ATOMIZER_STUDIO.md planning doc - Add ATOMIZER_UX_SYSTEM.md for UX patterns - Update extractor library docs - Add study-readme-generator skill Tools: - Add test scripts for extraction validation - Add Zernike recentering test Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,11 +47,13 @@ from optimization_engine.config.spec_validator import (
|
||||
|
||||
class SpecManagerError(Exception):
|
||||
"""Base error for SpecManager operations."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SpecNotFoundError(SpecManagerError):
|
||||
"""Raised when spec file doesn't exist."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -118,7 +120,7 @@ class SpecManager:
|
||||
if not self.spec_path.exists():
|
||||
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
|
||||
|
||||
with open(self.spec_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.spec_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate:
|
||||
@@ -141,14 +143,15 @@ class SpecManager:
|
||||
if not self.spec_path.exists():
|
||||
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
|
||||
|
||||
with open(self.spec_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.spec_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save(
|
||||
self,
|
||||
spec: Union[AtomizerSpec, Dict[str, Any]],
|
||||
modified_by: str = "api",
|
||||
expected_hash: Optional[str] = None
|
||||
expected_hash: Optional[str] = None,
|
||||
skip_validation: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Save spec with validation and broadcast.
|
||||
@@ -157,6 +160,7 @@ class SpecManager:
|
||||
spec: Spec to save (AtomizerSpec or dict)
|
||||
modified_by: Who/what is making the change
|
||||
expected_hash: If provided, verify current file hash matches
|
||||
skip_validation: If True, skip strict validation (for draft specs)
|
||||
|
||||
Returns:
|
||||
New spec hash
|
||||
@@ -167,7 +171,7 @@ class SpecManager:
|
||||
"""
|
||||
# Convert to dict if needed
|
||||
if isinstance(spec, AtomizerSpec):
|
||||
data = spec.model_dump(mode='json')
|
||||
data = spec.model_dump(mode="json")
|
||||
else:
|
||||
data = spec
|
||||
|
||||
@@ -176,24 +180,30 @@ class SpecManager:
|
||||
current_hash = self.get_hash()
|
||||
if current_hash != expected_hash:
|
||||
raise SpecConflictError(
|
||||
"Spec was modified by another client",
|
||||
current_hash=current_hash
|
||||
"Spec was modified by another client", current_hash=current_hash
|
||||
)
|
||||
|
||||
# Update metadata
|
||||
now = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
data["meta"]["modified"] = now
|
||||
data["meta"]["modified_by"] = modified_by
|
||||
|
||||
# Validate
|
||||
self.validator.validate(data, strict=True)
|
||||
# Validate (skip for draft specs or when explicitly requested)
|
||||
status = data.get("meta", {}).get("status", "draft")
|
||||
is_draft = status in ("draft", "introspected", "configured")
|
||||
|
||||
if not skip_validation and not is_draft:
|
||||
self.validator.validate(data, strict=True)
|
||||
elif not skip_validation:
|
||||
# For draft specs, just validate non-strictly (collect warnings only)
|
||||
self.validator.validate(data, strict=False)
|
||||
|
||||
# Compute new hash
|
||||
new_hash = self._compute_hash(data)
|
||||
|
||||
# Atomic write (write to temp, then rename)
|
||||
temp_path = self.spec_path.with_suffix('.tmp')
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
temp_path = self.spec_path.with_suffix(".tmp")
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
temp_path.replace(self.spec_path)
|
||||
@@ -202,12 +212,9 @@ class SpecManager:
|
||||
self._last_hash = new_hash
|
||||
|
||||
# Broadcast to subscribers
|
||||
self._broadcast({
|
||||
"type": "spec_updated",
|
||||
"hash": new_hash,
|
||||
"modified_by": modified_by,
|
||||
"timestamp": now
|
||||
})
|
||||
self._broadcast(
|
||||
{"type": "spec_updated", "hash": new_hash, "modified_by": modified_by, "timestamp": now}
|
||||
)
|
||||
|
||||
return new_hash
|
||||
|
||||
@@ -219,7 +226,7 @@ class SpecManager:
|
||||
"""Get current spec hash."""
|
||||
if not self.spec_path.exists():
|
||||
return ""
|
||||
with open(self.spec_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.spec_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return self._compute_hash(data)
|
||||
|
||||
@@ -240,12 +247,7 @@ class SpecManager:
|
||||
# Patch Operations
|
||||
# =========================================================================
|
||||
|
||||
def patch(
|
||||
self,
|
||||
path: str,
|
||||
value: Any,
|
||||
modified_by: str = "api"
|
||||
) -> AtomizerSpec:
|
||||
def patch(self, path: str, value: Any, modified_by: str = "api") -> AtomizerSpec:
|
||||
"""
|
||||
Apply a JSONPath-style modification.
|
||||
|
||||
@@ -306,7 +308,7 @@ class SpecManager:
|
||||
"""Parse JSONPath into parts."""
|
||||
# Handle both dot notation and bracket notation
|
||||
parts = []
|
||||
for part in re.split(r'\.|\[|\]', path):
|
||||
for part in re.split(r"\.|\[|\]", path):
|
||||
if part:
|
||||
parts.append(part)
|
||||
return parts
|
||||
@@ -316,10 +318,7 @@ class SpecManager:
|
||||
# =========================================================================
|
||||
|
||||
def add_node(
|
||||
self,
|
||||
node_type: str,
|
||||
node_data: Dict[str, Any],
|
||||
modified_by: str = "canvas"
|
||||
self, node_type: str, node_data: Dict[str, Any], modified_by: str = "canvas"
|
||||
) -> str:
|
||||
"""
|
||||
Add a new node (design var, extractor, objective, constraint).
|
||||
@@ -353,20 +352,19 @@ class SpecManager:
|
||||
self.save(data, modified_by)
|
||||
|
||||
# Broadcast node addition
|
||||
self._broadcast({
|
||||
"type": "node_added",
|
||||
"node_type": node_type,
|
||||
"node_id": node_id,
|
||||
"modified_by": modified_by
|
||||
})
|
||||
self._broadcast(
|
||||
{
|
||||
"type": "node_added",
|
||||
"node_type": node_type,
|
||||
"node_id": node_id,
|
||||
"modified_by": modified_by,
|
||||
}
|
||||
)
|
||||
|
||||
return node_id
|
||||
|
||||
def update_node(
|
||||
self,
|
||||
node_id: str,
|
||||
updates: Dict[str, Any],
|
||||
modified_by: str = "canvas"
|
||||
self, node_id: str, updates: Dict[str, Any], modified_by: str = "canvas"
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing node.
|
||||
@@ -396,11 +394,7 @@ class SpecManager:
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def remove_node(
|
||||
self,
|
||||
node_id: str,
|
||||
modified_by: str = "canvas"
|
||||
) -> None:
|
||||
def remove_node(self, node_id: str, modified_by: str = "canvas") -> None:
|
||||
"""
|
||||
Remove a node and all edges referencing it.
|
||||
|
||||
@@ -427,24 +421,18 @@ class SpecManager:
|
||||
# Remove edges referencing this node
|
||||
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
|
||||
data["canvas"]["edges"] = [
|
||||
e for e in data["canvas"]["edges"]
|
||||
e
|
||||
for e in data["canvas"]["edges"]
|
||||
if e.get("source") != node_id and e.get("target") != node_id
|
||||
]
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
# Broadcast node removal
|
||||
self._broadcast({
|
||||
"type": "node_removed",
|
||||
"node_id": node_id,
|
||||
"modified_by": modified_by
|
||||
})
|
||||
self._broadcast({"type": "node_removed", "node_id": node_id, "modified_by": modified_by})
|
||||
|
||||
def update_node_position(
|
||||
self,
|
||||
node_id: str,
|
||||
position: Dict[str, float],
|
||||
modified_by: str = "canvas"
|
||||
self, node_id: str, position: Dict[str, float], modified_by: str = "canvas"
|
||||
) -> None:
|
||||
"""
|
||||
Update a node's canvas position.
|
||||
@@ -456,12 +444,7 @@ class SpecManager:
|
||||
"""
|
||||
self.update_node(node_id, {"canvas_position": position}, modified_by)
|
||||
|
||||
def add_edge(
|
||||
self,
|
||||
source: str,
|
||||
target: str,
|
||||
modified_by: str = "canvas"
|
||||
) -> None:
|
||||
def add_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
|
||||
"""
|
||||
Add a canvas edge between nodes.
|
||||
|
||||
@@ -483,19 +466,11 @@ class SpecManager:
|
||||
if edge.get("source") == source and edge.get("target") == target:
|
||||
return # Already exists
|
||||
|
||||
data["canvas"]["edges"].append({
|
||||
"source": source,
|
||||
"target": target
|
||||
})
|
||||
data["canvas"]["edges"].append({"source": source, "target": target})
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def remove_edge(
|
||||
self,
|
||||
source: str,
|
||||
target: str,
|
||||
modified_by: str = "canvas"
|
||||
) -> None:
|
||||
def remove_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
|
||||
"""
|
||||
Remove a canvas edge.
|
||||
|
||||
@@ -508,7 +483,8 @@ class SpecManager:
|
||||
|
||||
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
|
||||
data["canvas"]["edges"] = [
|
||||
e for e in data["canvas"]["edges"]
|
||||
e
|
||||
for e in data["canvas"]["edges"]
|
||||
if not (e.get("source") == source and e.get("target") == target)
|
||||
]
|
||||
|
||||
@@ -524,7 +500,7 @@ class SpecManager:
|
||||
code: str,
|
||||
outputs: List[str],
|
||||
description: Optional[str] = None,
|
||||
modified_by: str = "claude"
|
||||
modified_by: str = "claude",
|
||||
) -> str:
|
||||
"""
|
||||
Add a custom extractor function.
|
||||
@@ -546,9 +522,7 @@ class SpecManager:
|
||||
try:
|
||||
compile(code, f"<custom:{name}>", "exec")
|
||||
except SyntaxError as e:
|
||||
raise SpecValidationError(
|
||||
f"Invalid Python syntax: {e.msg} at line {e.lineno}"
|
||||
)
|
||||
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
|
||||
|
||||
data = self.load_raw()
|
||||
|
||||
@@ -561,13 +535,9 @@ class SpecManager:
|
||||
"name": description or f"Custom: {name}",
|
||||
"type": "custom_function",
|
||||
"builtin": False,
|
||||
"function": {
|
||||
"name": name,
|
||||
"module": "custom_extractors.dynamic",
|
||||
"source_code": code
|
||||
},
|
||||
"function": {"name": name, "module": "custom_extractors.dynamic", "source_code": code},
|
||||
"outputs": [{"name": o, "metric": "custom"} for o in outputs],
|
||||
"canvas_position": self._auto_position("extractor", data)
|
||||
"canvas_position": self._auto_position("extractor", data),
|
||||
}
|
||||
|
||||
data["extractors"].append(extractor)
|
||||
@@ -580,7 +550,7 @@ class SpecManager:
|
||||
extractor_id: str,
|
||||
code: Optional[str] = None,
|
||||
outputs: Optional[List[str]] = None,
|
||||
modified_by: str = "claude"
|
||||
modified_by: str = "claude",
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing custom function.
|
||||
@@ -611,9 +581,7 @@ class SpecManager:
|
||||
try:
|
||||
compile(code, f"<custom:{extractor_id}>", "exec")
|
||||
except SyntaxError as e:
|
||||
raise SpecValidationError(
|
||||
f"Invalid Python syntax: {e.msg} at line {e.lineno}"
|
||||
)
|
||||
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
|
||||
if "function" not in extractor:
|
||||
extractor["function"] = {}
|
||||
extractor["function"]["source_code"] = code
|
||||
@@ -672,7 +640,7 @@ class SpecManager:
|
||||
"design_variable": "dv",
|
||||
"extractor": "ext",
|
||||
"objective": "obj",
|
||||
"constraint": "con"
|
||||
"constraint": "con",
|
||||
}
|
||||
prefix = prefix_map.get(node_type, node_type[:3])
|
||||
|
||||
@@ -697,7 +665,7 @@ class SpecManager:
|
||||
"design_variable": "design_variables",
|
||||
"extractor": "extractors",
|
||||
"objective": "objectives",
|
||||
"constraint": "constraints"
|
||||
"constraint": "constraints",
|
||||
}
|
||||
return section_map.get(node_type, node_type + "s")
|
||||
|
||||
@@ -709,7 +677,7 @@ class SpecManager:
|
||||
"design_variable": 50,
|
||||
"extractor": 740,
|
||||
"objective": 1020,
|
||||
"constraint": 1020
|
||||
"constraint": 1020,
|
||||
}
|
||||
|
||||
x = x_positions.get(node_type, 400)
|
||||
@@ -729,11 +697,123 @@ class SpecManager:
|
||||
|
||||
return {"x": x, "y": y}
|
||||
|
||||
# =========================================================================
|
||||
# Intake Workflow Methods
|
||||
# =========================================================================
|
||||
|
||||
def update_status(self, status: str, modified_by: str = "api") -> None:
|
||||
"""
|
||||
Update the spec status field.
|
||||
|
||||
Args:
|
||||
status: New status (draft, introspected, configured, validated, ready, running, completed, failed)
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
data["meta"]["status"] = status
|
||||
self.save(data, modified_by)
|
||||
|
||||
def get_status(self) -> str:
|
||||
"""
|
||||
Get the current spec status.
|
||||
|
||||
Returns:
|
||||
Current status string
|
||||
"""
|
||||
if not self.exists():
|
||||
return "unknown"
|
||||
data = self.load_raw()
|
||||
return data.get("meta", {}).get("status", "draft")
|
||||
|
||||
def add_introspection(
|
||||
self, introspection_data: Dict[str, Any], modified_by: str = "introspection"
|
||||
) -> None:
|
||||
"""
|
||||
Add introspection data to the spec's model section.
|
||||
|
||||
Args:
|
||||
introspection_data: Dict with timestamp, expressions, mass_kg, etc.
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
if "model" not in data:
|
||||
data["model"] = {}
|
||||
|
||||
data["model"]["introspection"] = introspection_data
|
||||
data["meta"]["status"] = "introspected"
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def add_baseline(
|
||||
self, baseline_data: Dict[str, Any], modified_by: str = "baseline_solve"
|
||||
) -> None:
|
||||
"""
|
||||
Add baseline solve results to introspection data.
|
||||
|
||||
Args:
|
||||
baseline_data: Dict with timestamp, solve_time_seconds, mass_kg, etc.
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
if "model" not in data:
|
||||
data["model"] = {}
|
||||
if "introspection" not in data["model"] or data["model"]["introspection"] is None:
|
||||
data["model"]["introspection"] = {}
|
||||
|
||||
data["model"]["introspection"]["baseline"] = baseline_data
|
||||
|
||||
# Update status based on baseline success
|
||||
if baseline_data.get("success", False):
|
||||
data["meta"]["status"] = "validated"
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def set_topic(self, topic: str, modified_by: str = "api") -> None:
|
||||
"""
|
||||
Set the spec's topic field.
|
||||
|
||||
Args:
|
||||
topic: Topic folder name
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
data["meta"]["topic"] = topic
|
||||
self.save(data, modified_by)
|
||||
|
||||
def get_introspection(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get introspection data from spec.
|
||||
|
||||
Returns:
|
||||
Introspection dict or None if not present
|
||||
"""
|
||||
if not self.exists():
|
||||
return None
|
||||
data = self.load_raw()
|
||||
return data.get("model", {}).get("introspection")
|
||||
|
||||
def get_design_candidates(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get expressions marked as design variable candidates.
|
||||
|
||||
Returns:
|
||||
List of expression dicts where is_candidate=True
|
||||
"""
|
||||
introspection = self.get_introspection()
|
||||
if not introspection:
|
||||
return []
|
||||
|
||||
expressions = introspection.get("expressions", [])
|
||||
return [e for e in expressions if e.get("is_candidate", False)]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Factory Function
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def get_spec_manager(study_path: Union[str, Path]) -> SpecManager:
|
||||
"""
|
||||
Get a SpecManager instance for a study.
|
||||
|
||||
Reference in New Issue
Block a user