"""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