"""Parsing helpers for model responses."""
from __future__ import annotations
import json
import re
from typing import Any
from snap2sim.schema import validate_analysis
_FENCE_RE = re.compile(r"^```(?:json|html)?\s*|\s*```$", re.IGNORECASE)
_THINK_RE = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL)
def parse_analysis_response(text: str) -> dict[str, Any]:
"""Extract and validate a JSON object from a model response."""
raw = _strip_fences(text)
errors: list[str] = []
for start, json_text in reversed(_json_object_candidates(raw)):
try:
payload = json.loads(json_text)
return validate_analysis(payload)
except (json.JSONDecodeError, ValueError) as exc:
errors.append(f"object at {start}: {exc}")
if errors:
raise ValueError("Model response did not contain a valid analysis JSON object. " + errors[-1])
raise ValueError("Model response did not contain a complete JSON object.")
def coerce_analysis_response(text: str) -> dict[str, Any]:
"""Best-effort conversion of partial model output into a valid analysis."""
raw = _strip_fences(text)
fallback_component = _infer_component(raw)
for _, json_text in reversed(_json_object_candidates(raw)):
try:
payload = json.loads(json_text)
except json.JSONDecodeError:
continue
if not isinstance(payload, dict):
continue
if not _looks_like_analysis_payload(payload):
continue
try:
return validate_analysis(_coerce_analysis_payload(payload, fallback_component))
except ValueError:
continue
return validate_analysis(_generic_analysis(fallback_component))
def _strip_fences(text: str) -> str:
raw = _FENCE_RE.sub("", text.strip()).strip()
return _strip_reasoning(raw)
def _strip_reasoning(text: str) -> str:
cleaned = _THINK_RE.sub("", text)
unclosed = re.search(r"]*>", cleaned, re.IGNORECASE)
if unclosed:
after = cleaned[unclosed.end() :]
json_start = after.find("{")
if json_start >= 0:
cleaned = cleaned[: unclosed.start()] + after[json_start:]
else:
cleaned = cleaned[: unclosed.start()]
return cleaned.strip()
def _json_object_candidates(text: str) -> list[tuple[int, str]]:
candidates = []
for index, char in enumerate(text):
if char != "{":
continue
try:
candidates.append((index, _balanced_json_object(text, index)))
except ValueError:
continue
return candidates
def _balanced_json_object(text: str, start: int) -> str:
depth = 0
in_string = False
escaped = False
for index in range(start, len(text)):
char = text[index]
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return text[start : index + 1]
raise ValueError("Model response contained an unterminated JSON object.")
def _looks_like_analysis_payload(payload: dict[str, Any]) -> bool:
return any(key in payload for key in ["component", "summary", "trigger", "motion_sequence", "parts"])
def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -> dict[str, Any]:
parts = payload.get("parts")
if not isinstance(parts, list):
parts = []
coerced_parts = [_coerce_part(part, index) for index, part in enumerate(parts[:6]) if isinstance(part, dict)]
coerced_parts = [part for part in coerced_parts if part is not None]
if not coerced_parts:
coerced_parts = _generic_analysis(fallback_component)["parts"]
confidence = payload.get("confidence", 0.55)
if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
confidence = 0.55
result = {
"component": _non_empty_string(payload.get("component"), fallback_component),
"confidence": max(0.0, min(1.0, float(confidence))),
"summary": _non_empty_string(
payload.get("summary"),
f"{fallback_component.title()} approximated as primitive cutaway parts.",
),
"trigger": _non_empty_string(payload.get("trigger"), "manual input"),
"motion_sequence": _string_list(
payload.get("motion_sequence"),
["input is applied", "internal parts move through the inferred mechanism"],
),
"parts": coerced_parts,
}
if payload.get("render_mode") in {"three", "annotate", "unavailable"}:
result["render_mode"] = payload["render_mode"]
return result
def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
annotation = _coerce_annotation(part.get("annotation"))
geometry = part.get("geometry")
has_geometry_input = isinstance(geometry, dict) and bool(geometry)
if not has_geometry_input:
geometry = {}
motion = part.get("motion")
if not isinstance(motion, dict):
motion = {}
base_part: dict[str, Any] = {
"id": _identifier(part.get("id"), f"part_{index + 1}"),
"name": _non_empty_string(part.get("name"), f"part {index + 1}"),
"role": _non_empty_string(part.get("role"), "inferred mechanical element"),
}
if annotation:
base_part["annotation"] = annotation
if annotation and not has_geometry_input:
return base_part
shape = geometry.get("shape")
if shape not in {"box", "cylinder", "sphere", "gear", "rod", "cone", "capsule", "torus", "spring"}:
shape = "box"
motion_type = motion.get("type")
if motion_type not in {"rotate", "translate", "oscillate", "static", "screw", "orbit", "pulse"}:
motion_type = "static"
coerced_motion: dict[str, Any] = {"type": motion_type}
axis = _axis_vector(motion.get("axis"))
if axis:
coerced_motion["axis"] = axis
for key in ["speed", "amplitude", "phase", "pitch"]:
if isinstance(motion.get(key), (int, float)) and not isinstance(motion.get(key), bool):
coerced_motion[key] = float(motion[key])
values = _number_list(motion.get("range"), 2)
if values:
coerced_motion["range"] = values
values = _number_list(motion.get("pivot"), 3)
if values:
coerced_motion["pivot"] = values
coerced_geometry: dict[str, Any] = {
"shape": shape,
"size": _geometry_size(geometry, shape),
"position": _number_list(geometry.get("position"), 3) or [float(index) - 1.0, 0.0, 0.0],
}
values = _number_list(geometry.get("rotation"), 3)
if values:
coerced_geometry["rotation"] = values
if isinstance(geometry.get("teeth"), int):
coerced_geometry["teeth"] = geometry["teeth"]
if isinstance(geometry.get("coils"), int):
coerced_geometry["coils"] = geometry["coils"]
if isinstance(geometry.get("wire"), (int, float)) and not isinstance(geometry.get("wire"), bool):
coerced_geometry["wire"] = float(geometry["wire"])
if isinstance(geometry.get("color"), str) and geometry["color"].strip():
coerced_geometry["color"] = geometry["color"].strip()
base_part["geometry"] = coerced_geometry
base_part["motion"] = coerced_motion
return base_part
def _generic_analysis(component: str) -> dict[str, Any]:
return {
"component": component,
"confidence": 0.45,
"summary": f"{component.title()} rendered as a conservative generic cutaway.",
"trigger": "manual input",
"motion_sequence": [
"input is applied to the housing",
"central rotor transfers motion",
"guide elements hold alignment",
],
"parts": [
{
"id": "housing",
"name": "outer housing",
"role": "supports the internal mechanism",
"geometry": {"shape": "box", "size": [2.4, 0.45, 1.4], "position": [0, 0, 0]},
"motion": {"type": "static"},
},
{
"id": "rotor",
"name": "central rotor",
"role": "transfers motion through the assembly",
"geometry": {"shape": "cylinder", "size": [0.9, 0.35, 0.9], "position": [0, 0.18, 0]},
"motion": {"type": "rotate", "speed": 0.55},
},
{
"id": "guide",
"name": "guide rail",
"role": "keeps the moving part aligned",
"geometry": {"shape": "rod", "size": [1.6, 0.12, 0.12], "position": [0, 0.42, 0.48]},
"motion": {"type": "static"},
},
],
}
def _infer_component(text: str) -> str:
match = re.search(r'"component"\s*:\s*"([^"]+)"', text)
if match and match.group(1).strip():
return match.group(1).strip()
lowered = text.lower()
for name in ["ratchet", "gear", "hinge", "motor", "lens", "target", "switch", "bearing"]:
if name in lowered:
return name
return "observed component"
def _non_empty_string(value: Any, fallback: str) -> str:
return value.strip() if isinstance(value, str) and value.strip() else fallback
def _string_list(value: Any, fallback: list[str]) -> list[str]:
if isinstance(value, list):
values = [item.strip() for item in value if isinstance(item, str) and item.strip()]
if values:
return values
return fallback
def _number_list(value: Any, length: int) -> list[float] | None:
if not isinstance(value, list) or len(value) != length:
return None
if not all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in value):
return None
return [float(item) for item in value]
def _geometry_size(geometry: dict[str, Any], shape: str) -> list[float]:
size = _number_list(geometry.get("size"), 3)
if size:
return size
radius = _number_value(geometry.get("radius"))
height = _number_value(geometry.get("height"))
length = _number_value(geometry.get("length"))
width = _number_value(geometry.get("width"))
depth = _number_value(geometry.get("depth"))
if shape in {"cylinder", "gear", "cone", "capsule", "torus", "spring"} and radius and height:
diameter = radius * 2
return [diameter, height, diameter]
if shape == "rod" and (length or height) and radius:
diameter = radius * 2
return [diameter, diameter, length or height or 1.0]
if width and height and depth:
return [width, height, depth]
return [1.0, 0.4, 1.0]
def _axis_vector(value: Any) -> list[float] | None:
vector = _number_list(value, 3)
if vector:
return vector
if isinstance(value, str):
key = value.strip().lower()
if key == "x":
return [1.0, 0.0, 0.0]
if key == "y":
return [0.0, 1.0, 0.0]
if key == "z":
return [0.0, 0.0, 1.0]
return None
def _number_value(value: Any) -> float | None:
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
return None
def _unit_number_list(value: Any, length: int) -> list[float] | None:
values = _number_list(value, length)
if not values:
return None
return [max(0.0, min(1.0, item)) for item in values]
def _coerce_annotation(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
point = _unit_number_list(value.get("point"), 2)
if not point:
return None
annotation: dict[str, Any] = {"point": point}
box = _unit_number_list(value.get("box"), 4)
if box:
annotation["box"] = box
for key in ["label", "note"]:
if isinstance(value.get(key), str) and value[key].strip():
annotation[key] = value[key].strip()
return annotation
def _identifier(value: Any, fallback: str) -> str:
text = _non_empty_string(value, fallback).lower()
text = re.sub(r"[^a-z0-9_]+", "_", text).strip("_")
return text or fallback