""" Claude client for the vibration MVP. Provides: - generate_fixture_spec(): returns a validated FixtureSpec (mock fallback) - generate_prose(): generic prose call for recommendations & report Without ANTHROPIC_API_KEY everything still runs using a deterministic fallback fixture spec and clearly-labeled mock prose, so the CAD + profile demo works end-to-end offline. """ import json import os from typing import Optional from cad_engine import FixtureSpec, BoltPattern DEFAULT_MODEL = "claude-sonnet-4-6" # --------------------------------------------------------------------------- # def _fallback_spec(inputs: dict) -> FixtureSpec: """Deterministic, reasonable fixture when no LLM is available.""" # crude sizing from mass if provided spec = FixtureSpec() spec.rationale = ( "[MOCK fixture - no API key] Default 200x200x15 mm 6061-T6 base with a " "100x100x25 mm raised boss, corner table bolts and a centered article " "bolt pattern, ribs added for stiffness. Set ANTHROPIC_API_KEY for an " "AI-tailored design." ) return spec def _coerce_spec(d: dict) -> FixtureSpec: """Validate/parse an LLM JSON dict into a FixtureSpec, with safe defaults.""" def num(x, default): try: return float(x) except (TypeError, ValueError): return default tbp = d.get("table_bolt_pattern", {}) or {} abp = d.get("article_bolt_pattern", {}) or {} spec = FixtureSpec( base_length_mm=num(d.get("base_length_mm"), 200), base_width_mm=num(d.get("base_width_mm"), 200), base_thickness_mm=num(d.get("base_thickness_mm"), 15), table_bolt_pattern=BoltPattern( num(tbp.get("spacing_x_mm"), 160), num(tbp.get("spacing_y_mm"), 160), num(tbp.get("hole_dia_mm"), 9), ), boss_length_mm=num(d.get("boss_length_mm"), 100), boss_width_mm=num(d.get("boss_width_mm"), 100), boss_height_mm=num(d.get("boss_height_mm"), 25), article_bolt_pattern=BoltPattern( num(abp.get("spacing_x_mm"), 80), num(abp.get("spacing_y_mm"), 80), num(abp.get("hole_dia_mm"), 5.5), ), edge_chamfer_mm=num(d.get("edge_chamfer_mm"), 3), add_ribs=bool(d.get("add_ribs", True)), rib_thickness_mm=num(d.get("rib_thickness_mm"), 8), material=str(d.get("material", "6061-T6 Aluminum")), rationale=str(d.get("rationale", "")), ) return spec def generate_fixture_spec(system_prompt: str, user_prompt: str, inputs: dict, model: str = DEFAULT_MODEL) -> dict: """ Returns {"spec": FixtureSpec, "mode": "live"|"mock"|"error", "raw": str}. """ api_key = os.environ.get("ANTHROPIC_API_KEY") if not api_key: return {"spec": _fallback_spec(inputs), "mode": "mock", "raw": ""} try: import anthropic client = anthropic.Anthropic(api_key=api_key) msg = client.messages.create( model=model, max_tokens=1200, system=system_prompt, messages=[{"role": "user", "content": user_prompt}], ) raw = "".join(b.text for b in msg.content if b.type == "text").strip() cleaned = raw.replace("```json", "").replace("```", "").strip() spec = _coerce_spec(json.loads(cleaned)) return {"spec": spec, "mode": "live", "raw": raw} except Exception as e: # noqa: BLE001 fb = _fallback_spec(inputs) fb.rationale = f"[Spec generation error: {e}] " + fb.rationale return {"spec": fb, "mode": "error", "raw": str(e)} def generate_prose(system_prompt: str, user_prompt: str, label: str = "content", model: str = DEFAULT_MODEL, max_tokens: int = 2500) -> dict: """Generic prose generation with mock fallback.""" api_key = os.environ.get("ANTHROPIC_API_KEY") if not api_key: return {"text": f"> **MOCK {label}** - set ANTHROPIC_API_KEY for real " f"AI-generated {label}.", "mode": "mock"} try: import anthropic client = anthropic.Anthropic(api_key=api_key) msg = client.messages.create( model=model, max_tokens=max_tokens, system=system_prompt, messages=[{"role": "user", "content": user_prompt}], ) text = "".join(b.text for b in msg.content if b.type == "text") return {"text": text, "mode": "live"} except Exception as e: # noqa: BLE001 return {"text": f"**Error generating {label}:** {e}", "mode": "error"}