smemon819/circuit-copilot
0
1import os, io, json, base64, re, datetime2from typing import List, Dict3import asyncio4 5import schemdraw6import schemdraw.elements as elm7import matplotlib8matplotlib.use("Agg")9import matplotlib.pyplot as plt10 11from reportlab.lib.pagesizes import A412from reportlab.lib import colors13from reportlab.lib.units import mm14from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer,15 Image as RLImage, Table, TableStyle, HRFlowable)16from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle17from reportlab.lib.enums import TA_CENTER18 19from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect20from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse21from fastapi.staticfiles import StaticFiles22from groq import AsyncGroq23from slowapi import Limiter, _rate_limit_exceeded_handler24from slowapi.util import get_remote_address25from slowapi.errors import RateLimitExceeded26 27async def _custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):28 retry_after = int(exc.retry_after) if hasattr(exc, "retry_after") else 6029 return JSONResponse(30 {"error": "Rate limit exceeded", "retry_after": retry_after,31 "message": f"Too many requests. Please wait {retry_after} seconds."},32 status_code=429, headers={"Retry-After": str(retry_after)})33 34app = FastAPI(title="Circuit Copilot v4")35limiter = Limiter(key_func=get_remote_address)36app.state.limiter = limiter37app.add_exception_handler(RateLimitExceeded, _custom_rate_limit_handler)38import random39 40_groq_keys = [41 os.environ.get("GROQ_API_KEY", ""),42 os.environ.get("GROQ_API_KEY_2", ""),43 os.environ.get("GROQ_API_KEY_3", ""),44 os.environ.get("GROQ_API_KEY_4", ""),45 os.environ.get("GROQ_API_KEY_5", ""),46]47_valid_keys = [k for k in _groq_keys if k.strip()]48 49groq_clients = [50 AsyncGroq(api_key=k, max_retries=0, timeout=30.0) 51 for k in _valid_keys52]53 54def get_groq_client():55 if not groq_clients:56 # Fallback to empty client so error handling catches it57 return AsyncGroq(api_key="empty", max_retries=0, timeout=30.0)58 return random.choice(groq_clients)59 60@app.get("/api/status")61async def get_system_status():62 return JSONResponse({63 "status": "online",64 "keys_in_pool": len(groq_clients)65 })66GROQ_MODEL = "llama-3.3-70b-versatile"67GROQ_VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"68GROQ_AGENT_MODEL = "compound-beta" # Agentic model with built-in web search69 70# ── Database ───────────────────────────────────────────────────────────────────71from supabase import create_client, Client72 73SUPABASE_URL = os.environ.get("SUPABASE_URL", "")74SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")75 76# Initialize Supabase client if credentials exist, otherwise None77if SUPABASE_URL and SUPABASE_KEY:78 supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)79else:80 supabase = None81 print("WARNING: Supabase credentials not found. Database features will be unavailable.")82 83# ── WebSocket Collaboration ────────────────────────────────────────────────────84class ConnectionManager:85 def __init__(self):86 self.active_connections: Dict[str, List[WebSocket]] = {}87 async def connect(self, ws: WebSocket, cid: str):88 await ws.accept()89 self.active_connections.setdefault(cid, []).append(ws)90 def disconnect(self, ws: WebSocket, cid: str):91 if cid in self.active_connections:92 self.active_connections[cid].remove(ws)93 async def broadcast(self, msg: str, cid: str, sender: WebSocket):94 for c in self.active_connections.get(cid, []):95 if c != sender: await c.send_text(msg)96manager = ConnectionManager()97 98# ── System Prompts ─────────────────────────────────────────────────────────────99SCHEMATIC_PROMPT = """You are an expert electronics engineer. When the user describes a circuit,100respond with ONLY a JSON object — no markdown, no explanation.101 102The JSON MUST include x/y grid coordinates so the frontend can draw a proper schematic:103- Battery/power → left side, orientation "up"104- Series components → arranged left-to-right horizontally105- Ground → bottom106- Parallel branches → stacked vertically (different y values)107- x/y are grid units (integers 0-8); keep layout compact and sensible108 109Example for "LED with 220Ω resistor, 9V battery":110{111 "components": [112 {"id":"V1","type":"battery","value":"9V","label":"V1","x":0,"y":1,"orientation":"up"},113 {"id":"R1","type":"resistor","value":"220Ω","label":"R1","x":1,"y":0,"orientation":"right"},114 {"id":"LED1","type":"led","value":"red","label":"LED1","x":2,"y":0,"orientation":"right"},115 {"id":"GND1","type":"ground","value":"","label":"GND","x":2,"y":2,"orientation":"down"}116 ],117 "connections":[118 {"from":"V1.pos","to":"R1.start"},119 {"from":"R1.end","to":"LED1.anode"},120 {"from":"LED1.cathode","to":"GND1.top"},121 {"from":"V1.neg","to":"GND1.top"}122 ],123 "nets":[124 {"name":"VCC","nodes":["V1.pos","R1.start"]},125 {"name":"MID","nodes":["R1.end","LED1.anode"]},126 {"name":"GND","nodes":["LED1.cathode","V1.neg","GND1.top"]}127 ],128 "title":"LED Circuit",129 "description":"Series LED circuit with current-limiting resistor. I = (9-2)/220 ≈ 31.8mA.",130 "difficulty":"Beginner",131 "use_case":"Indicators, learning circuits"132}133 134Supported types: resistor, capacitor, led, battery, switch, ground, diode, transistor,135inductor, ic, potentiometer, mosfet, op_amp, voltage_reg, buzzer, motor.136orientation values: "right","left","up","down"137Output valid JSON only."""138 139COMPONENT_PROMPT = """You are an expert electronics component advisor (multi-turn).140For each component include: exact part number, key specs, why it fits, cost USD, supplier.141Use markdown tables. Be beginner-friendly but technically precise."""142 143DEBUG_PROMPT = """You are an expert circuit debugger (multi-turn).144For each issue: name it, explain why (physics), give exact fix with values,145rate 🔴Critical/🟡Warning/🟢Info. Use markdown."""146 147ARDUINO_PROMPT = """You are an expert Arduino programmer (multi-turn).148Generate complete upload-ready .ino code: #define pin constants, comments on every block,149full setup()+loop(), Serial.begin(9600), required libraries noted.150After code, explain in 3-5 sentences."""151 152SIMULATION_PROMPT = """You are a circuit simulation expert. Perform DC operating point analysis.153Respond ONLY with valid JSON — no markdown, no explanation:154{155 "circuit_title":"name","supply_voltage":9,"supply_unit":"V",156 "nodes":[{"id":"N1","name":"V_supply","voltage":9.0,"unit":"V","description":"..."}],157 "branches":[{"id":"B1","name":"Loop","current":31.8,"unit":"mA","through":"R1,LED1","description":"..."}],158 "power":[{"component":"R1","power":223,"unit":"mW","status":"OK"}],159 "summary":"Plain English summary with key values and safety notes.",160 "warnings":[]161}"""162 163BOM_PROMPT = """You are an electronics procurement expert. Respond ONLY with valid JSON:164{165 "project_name":"name","total_cost_usd":4.75,166 "items":[{167 "ref":"R1","description":"Carbon Film Resistor","value":"220Ω",168 "part_number":"CF14JT220R","quantity":1,"unit_cost":0.10,"total_cost":0.10,169 "supplier":"DigiKey","supplier_url":"https://www.digikey.com",170 "package":"Through-hole axial","notes":"1/4W 5%"171 }],172 "tools_needed":["Breadboard","Multimeter"],173 "estimated_build_time":"30 minutes","difficulty":"Beginner"174}"""175 176LEARN_PROMPT = """You are a friendly electronics teacher for beginners (multi-turn).177Format: one-sentence summary, then markdown sections:178## How it works ## The math ## Real-world analogy179## Common mistakes ## Try it yourself180Encourage, use analogies, avoid jargon. Audience: 16-year-olds."""181 182BREADBOARD_PROMPT = """You are an expert electronics router.183You will receive a JSON circuit schema. Your job is to map these components onto a standard half-size breadboard (30 columns).184The breadboard has:185- Top power rails: 'top_+' and 'top_-'186- Bottom power rails: 'bottom_+' and 'bottom_-'187- Main terminal strips: rows 'A'-'C' (top half) and 'D'-'F' (bottom half), columns 1 to 30.188Output ONLY valid JSON matching this schema:189{190 "routing": [191 {"id": "R1", "type": "resistor", "start": "B2", "end": "B6", "color": "#0090ff", "value": "1k\u03a9"},192 {"id": "V1", "type": "battery", "start": "top_+", "end": "top_-", "color": "#ff3333", "value": "9V"},193 {"id": "LED1", "type": "led", "start": "C6", "end": "top_-", "color": "#00f090", "value": "Red"},194 {"id": "Wire1", "type": "wire", "start": "top_+", "end": "A2", "color": "#ff3333", "value": "Jumper"}195 ],196 "steps": [197 "1. Connect Battery V1 positive to top + rail and negative to top - rail.",198 "2. Place Resistor R1 from B2 to B6.",199 "3. Insert LED1 anode at C6 and cathode to top - rail."200 ]201}202Ensure connections physically make sense and match the schematic. Components must share columns to connect. Example: if R1 ends at column 6, LED1 must start at column 6 to be in series.203Output valid JSON only. No markdown formatting."""204 205IMAGE_CIRCUIT_PROMPT = """You are an expert electronics engineer with vision.206Analyze this image (hand-drawn circuit, breadboard photo, or PCB).207Identify all components and connections. Respond ONLY with valid JSON matching this schema:208{209 "components":[{"id":"V1","type":"battery","value":"9V","label":"V1","x":0,"y":1,"orientation":"up"}],210 "connections":[{"from":"V1.pos","to":"R1.start"}],211 "nets":[{"name":"VCC","nodes":["V1.pos","R1.start"]}],212 "title":"Identified Circuit",213 "description":"What this circuit does.",214 "difficulty":"Beginner","use_case":"...",215 "confidence":"high",216 "notes":"Any caveats about image quality or identification uncertainty."217}218Supported types: resistor,capacitor,led,battery,switch,ground,diode,transistor,inductor,ic,potentiometer,mosfet,op_amp.219Output valid JSON only."""220 221# ── Schematic PNG Renderer (used for PDF export & fallback) ───────────────────222def render_schematic(schema: dict) -> str:223 try:224 components = schema.get("components", [])225 title = schema.get("title", "Circuit")226 difficulty = schema.get("difficulty", "")227 from matplotlib.figure import Figure228 from matplotlib.backends.backend_agg import FigureCanvasAgg229 fig = Figure(figsize=(11, 6.5), facecolor="#07090f")230 canvas = FigureCanvasAgg(fig)231 ax = fig.add_subplot(111)232 ax.set_facecolor("#07090f")233 with schemdraw.Drawing(canvas=ax) as d:234 d.config(fontsize=11, color="#c8d8e8", lw=2.0)235 batts = [c for c in components if c.get("type")=="battery"]236 series = [c for c in components if c.get("type") not in ("battery","ground")]237 grnds = [c for c in components if c.get("type")=="ground"]238 def _elem(c):239 t = c.get("type",""); lbl = c.get("label",""); val = c.get("value","")240 dl = f"{lbl}\n{val}" if val else lbl241 mapping = {242 "resistor": elm.Resistor().right().label(dl, loc="top"),243 "capacitor": elm.Capacitor().right().label(dl, loc="top"),244 "led": elm.LED().right().label(dl, loc="top").color("#00ff88"),245 "battery": elm.Battery().up().label(dl, loc="left"),246 "switch": elm.Switch().right().label(dl, loc="top"),247 "diode": elm.Diode().right().label(dl, loc="top"),248 "ground": elm.Ground(),249 "transistor": elm.BjtNpn(circle=True).anchor("base").label(dl),250 "inductor": elm.Inductor().right().label(dl, loc="top"),251 "potentiometer": elm.Potentiometer().right().label(dl, loc="top"),252 }253 return mapping.get(t, elm.Resistor().right().label(dl, loc="top"))254 for c in batts: d.add(_elem(c))255 for c in series: d.add(_elem(c))256 if series and (batts or grnds):257 d.add(elm.Line().down())258 d.add(elm.Line().left())259 if grnds: d.add(elm.Ground())260 else: d.add(elm.Line().up())261 title_txt = f"{title} [{difficulty}]" if difficulty else title262 ax.set_title(title_txt, color="#00c8f0", fontsize=13,263 fontweight="bold", fontfamily="monospace", pad=10)264 buf = io.BytesIO()265 fig.tight_layout()266 fig.savefig(buf, format="png", dpi=180, bbox_inches="tight",267 facecolor="#07090f", edgecolor="none")268 buf.seek(0)269 return base64.b64encode(buf.read()).decode()270 except Exception as e:271 return _fallback_schematic(schema, str(e))272 273def _fallback_schematic(schema: dict, error: str) -> str:274 components = schema.get("components", [])275 title = schema.get("title", "Circuit")276 lines = [f" {title}", "─"*44, schema.get("description",""), "", "Components:"]277 for c in components:278 lines.append(f" [{c.get('type','?').upper():12s}] {c.get('label','')} {c.get('value','')}")279 lines += ["", "Connections:"]280 for cn in schema.get("connections", []):281 lines.append(f" {cn.get('from','')} → {cn.get('to','')}")282 from matplotlib.figure import Figure283 from matplotlib.backends.backend_agg import FigureCanvasAgg284 fig = Figure(figsize=(9, max(4, len(lines)*0.32)), facecolor="#07090f")285 canvas = FigureCanvasAgg(fig)286 ax = fig.add_subplot(111)287 ax.set_facecolor("#07090f"); ax.axis("off")288 ax.text(0.04, 0.97, "\n".join(lines), transform=ax.transAxes,289 fontsize=10, color="#39d353", va="top", fontfamily="monospace", linespacing=1.5)290 buf = io.BytesIO()291 fig.savefig(buf, format="png", dpi=150, bbox_inches="tight", facecolor="#07090f")292 buf.seek(0)293 return base64.b64encode(buf.read()).decode()294 295# ── Falstad URL ────────────────────────────────────────────────────────────────296def build_falstad_url(schema: dict) -> str:297 try:298 comps = schema.get("components", [])299 lines = ["$ 1 0.000005 10.235265340896002 50 5 43 5e-11"]300 step = 112; cx, cy = 160, 160301 batts = [c for c in comps if c.get("type")=="battery"]302 series = [c for c in comps if c.get("type") not in ("battery","ground","wire")]303 def _val(comp):304 vs = re.sub(r"[^\d.km]","", comp.get("value","1000").lower()) or "1000"305 try:306 v = float(re.sub(r"[^\d.]","",vs) or "1000")307 if "k" in vs: v *= 1000308 if "m" in vs and comp.get("type") not in ("battery",): v /= 1000309 except: v = 1000310 return v311 for b in batts:312 v = _val(b)313 lines.append(f"v {cx-step//2} {cy+step//2} {cx-step//2} {cy-step//2} 0 0 40 {v} 0 0 0.5")314 for i, c in enumerate(series):315 t = c.get("type","resistor"); v = _val(c)316 x1 = cx + i*step; y1 = cy - step//2; x2 = x1 + step; y2 = y1317 if t == "resistor": lines.append(f"r {x1} {y1} {x2} {y2} 0 {v}")318 elif t == "capacitor":lines.append(f"c {x1} {y1} {x2} {y2} 0 {v*1e-6} 0")319 elif t == "led": lines.append(f"d {x1} {y1} {x2} {y2} 2 default-led")320 elif t == "diode": lines.append(f"d {x1} {y1} {x2} {y2} 2 default")321 elif t == "switch": lines.append(f"s {x1} {y1} {x2} {y2} 0 1 false")322 elif t == "inductor": lines.append(f"l {x1} {y1} {x2} {y2} 0 {v*1e-3}")323 else: lines.append(f"r {x1} {y1} {x2} {y2} 0 1000")324 encoded = base64.b64encode("\n".join(lines).encode()).decode()325 return f"https://falstad.com/circuit/circuitjs.html?ctz={encoded}"326 except:327 return "https://falstad.com/circuit/circuitjs.html"328 329# ── PDF Export ─────────────────────────────────────────────────────────────────330def generate_pdf(data: dict) -> bytes:331 buf = io.BytesIO()332 doc = SimpleDocTemplate(buf, pagesize=A4,333 leftMargin=20*mm, rightMargin=20*mm,334 topMargin=20*mm, bottomMargin=20*mm)335 styles = getSampleStyleSheet()336 def PS(n, **kw): return ParagraphStyle(n, parent=styles["Normal"], **kw)337 T = PS("T", fontSize=22, textColor=colors.HexColor("#003366"), spaceAfter=4, alignment=TA_CENTER, fontName="Helvetica-Bold")338 S = PS("S", fontSize=10, textColor=colors.HexColor("#555"), spaceAfter=12, alignment=TA_CENTER)339 H2 = PS("H2", fontSize=13, textColor=colors.HexColor("#003366"), spaceBefore=14,spaceAfter=6, fontName="Helvetica-Bold")340 B = PS("B", fontSize=10, leading=15)341 W = PS("W", fontSize=10, leading=14, textColor=colors.HexColor("#cc6600"), leftIndent=10)342 C = PS("C", fontSize=8, leading=11, fontName="Courier")343 F = PS("F", fontSize=8, textColor=colors.HexColor("#999"), alignment=TA_CENTER)344 ERR= PS("ERR",fontSize=8, textColor=colors.red, leftIndent=10)345 346 def sanitize(txt):347 if not isinstance(txt, str): txt = str(txt)348 return txt.replace("Ω","Ohm").replace("μ","u").replace("©","(c)").replace("™","(tm)")349 350 story = []351 now = datetime.datetime.now().strftime("%B %d, %Y %H:%M")352 story += [Paragraph("Circuit Copilot", T),353 Paragraph(f"AI-Powered Circuit Design Report · {now}", S),354 HRFlowable(width="100%", thickness=2, color=colors.HexColor("#003366")),355 Spacer(1, 8*mm)]356 if data.get("project_name"):357 story += [Paragraph(f"Project: {data['project_name']}", PS("pn",fontSize=14,textColor=colors.HexColor("#003366"),fontName="Helvetica-Bold")),358 Spacer(1, 4*mm)]359 360 # Schematic Section361 if data.get("schematic_image"):362 try:363 story.append(Paragraph("Circuit Schematic", H2))364 story.append(RLImage(io.BytesIO(base64.b64decode(data["schematic_image"])), width=160*mm, height=90*mm))365 story.append(Spacer(1, 4*mm))366 except Exception as e:367 story.append(Paragraph(f"[Technical Error Rendering Schematic]", ERR))368 369 if data.get("schematic_description"):370 story += [Paragraph(sanitize(data["schematic_description"]), B), Spacer(1, 6*mm)]371 372 # BOM Section373 if data.get("bom"):374 try:375 bom = data["bom"]376 story.append(Paragraph("Bill of Materials", H2))377 rows = [["Ref","Description","Value","Qty","Unit $","Total","Supplier"]]378 for it in bom.get("items",[]):379 try: uc = float(it.get('unit_cost',0))380 except: uc = 0.0381 try: tc = float(it.get('total_cost',0))382 except: tc = 0.0383 rows.append([sanitize(it.get("ref","")), sanitize(it.get("description","")), sanitize(it.get("value","")),384 str(it.get("quantity",1)),f"${uc:.2f}",385 f"${tc:.2f}",sanitize(it.get("supplier",""))])386 try: total_val = float(bom.get('total_cost_usd', 0))387 except: total_val = 0.0388 rows.append(["","","","","TOTAL",f"${total_val:.2f}",""])389 t = Table(rows, colWidths=[15*mm,40*mm,22*mm,10*mm,18*mm,18*mm,32*mm]) 390 t.setStyle(TableStyle([391 ("BACKGROUND",(0,0),(-1,0),colors.HexColor("#003366")),("TEXTCOLOR",(0,0),(-1,0),colors.white),392 ("FONTNAME",(0,0),(-1,0),"Helvetica-Bold"),("FONTSIZE",(0,0),(-1,-1),8),393 ("BACKGROUND",(0,-1),(-1,-1),colors.HexColor("#e8f0fe")),("FONTNAME",(0,-1),(-1,-1),"Helvetica-Bold"),394 ("ROWBACKGROUNDS",(0,1),(-1,-2),[colors.HexColor("#f0f4ff"),colors.white]),395 ("GRID",(0,0),(-1,-1),0.4,colors.HexColor("#ccc")),("PADDING",(0,0),(-1,-1),5),396 ]))397 story += [t, Spacer(1, 6*mm)]398 except Exception as e:399 story.append(Paragraph(f"[Technical Error Rendering BOM Table]", ERR))400 401 # Simulation Section402 if data.get("simulation"):403 try:404 sim = data["simulation"]405 story.append(Paragraph("Simulation Results", H2))406 story += [Paragraph(sanitize(sim.get("summary","")), B), Spacer(1,4*mm)]407 if sim.get("nodes"):408 nr = [["Node","Voltage","Description"]]409 for n in sim["nodes"]: nr.append([sanitize(n.get("name","")),f"{n.get('voltage',0)} {sanitize(n.get('unit','V'))}",sanitize(n.get("description",""))])410 nt = Table(nr, colWidths=[35*mm,30*mm,95*mm]) 411 nt.setStyle(TableStyle([412 ("BACKGROUND",(0,0),(-1,0),colors.HexColor("#005588")),("TEXTCOLOR",(0,0),(-1,0),colors.white),413 ("FONTNAME",(0,0),(-1,0),"Helvetica-Bold"),("FONTSIZE",(0,0),(-1,-1),9),414 ("ROWBACKGROUNDS",(0,1),(-1,-1),[colors.HexColor("#eef6ff"),colors.white]),415 ("GRID",(0,0),(-1,-1),0.5,colors.HexColor("#ccc")),("PADDING",(0,0),(-1,-1),5),416 ]))417 story += [nt, Spacer(1,4*mm)]418 for w in sim.get("warnings",[]): story.append(Paragraph(f"Warning: {sanitize(w)}", W))419 except Exception as e:420 story.append(Paragraph(f"[Technical Error Rendering Simulation]", ERR))421 422 # Arduino Section423 if data.get("arduino_code"):424 try:425 story.append(Paragraph("Arduino Code", H2))426 code = data["arduino_code"]427 m = re.search(r"```(?:cpp|arduino|ino)?\n([\s\S]*?)```", code)428 clean_code = (m.group(1) if m else code)429 for line in clean_code[:3000].split("\n"):430 if not line.strip(): continue431 story.append(Paragraph(sanitize(line).replace(" "," ").replace("<","<").replace(">",">") or " ", C))432 except Exception as e:433 story.append(Paragraph(f"[Technical Error Rendering Arduino Code]", ERR))434 435 story += [HRFlowable(width="100%",thickness=1,color=colors.HexColor("#ccc")),Spacer(1,3*mm),436 Paragraph("Generated by Circuit Copilot v4 · Powered by Groq LLaMA 3.3 70B · github.com/smemon819/circuit-copilot", F)]437 doc.build(story)438 buf.seek(0)439 return buf.read()440 441# ── LLM Helpers ────────────────────────────────────────────────────────────────442async def llm(system: str, messages: list, max_tokens: int = 1024) -> str:443 r = await get_groq_client().chat.completions.create(444 model=GROQ_MODEL, max_tokens=max_tokens,445 messages=[{"role":"system","content":system}] + messages)446 return r.choices[0].message.content447 448async def llm_stream(system: str, messages: list, max_tokens: int = 1024):449 """Async generator yielding SSE chunks."""450 stream = await get_groq_client().chat.completions.create(451 model=GROQ_MODEL, max_tokens=max_tokens, stream=True,452 messages=[{"role":"system","content":system}] + messages)453 async for chunk in stream:454 delta = chunk.choices[0].delta.content455 if delta:456 yield f"data: {json.dumps({'content': delta})}\n\n"457 yield "data: [DONE]\n\n"458 459async def llm_compound(system: str, messages: list, max_tokens: int = 1500) -> str:460 """Call compound-beta with web_search tool for live data (prices, stock)."""461 try:462 r = await get_groq_client().chat.completions.create(463 model=GROQ_AGENT_MODEL, max_tokens=max_tokens,464 messages=[{"role":"system","content":system}] + messages)465 return r.choices[0].message.content466 except Exception:467 # Fallback to standard model if compound-beta unavailable468 return await llm(system, messages, max_tokens)469 470async def llm_compound_stream(system: str, messages: list, max_tokens: int = 1500):471 """Streaming variant of compound-beta with graceful fallback."""472 try:473 stream = await get_groq_client().chat.completions.create(474 model=GROQ_AGENT_MODEL, max_tokens=max_tokens, stream=True,475 messages=[{"role":"system","content":system}] + messages)476 async for chunk in stream:477 delta = chunk.choices[0].delta.content478 if delta:479 yield f"data: {json.dumps({'content': delta})}\n\n"480 except Exception:481 async for chunk in llm_stream(system, messages, max_tokens):482 yield chunk483 yield "data: [DONE]\n\n"484 485 486# ── API Routes ─────────────────────────────────────────────────────────────────487 488@app.post("/api/schematic")489@limiter.limit("5/minute")490async def generate_schematic(request: Request):491 body = await request.json()492 prompt = body.get("prompt",""); history = body.get("history",[])493 if not prompt: return JSONResponse({"error":"No prompt"}, status_code=400)494 495 try:496 raw = await llm(SCHEMATIC_PROMPT, history+[{"role":"user","content":prompt}], 1600)497 except Exception as e:498 return JSONResponse({"error": f"Groq API Error: {str(e)}"}, status_code=500)499 500 m = re.search(r"\{.*\}", raw, re.DOTALL)501 if not m: return JSONResponse({"error":"Could not parse schematic JSON","raw":raw}, status_code=500)502 schema = json.loads(m.group())503 return JSONResponse({504 "schema": schema,505 "image": render_schematic(schema),506 "description": schema.get("description",""),507 "title": schema.get("title",""),508 "difficulty": schema.get("difficulty",""),509 "use_case": schema.get("use_case",""),510 "falstad_url": build_falstad_url(schema),511 "assistant_message": f"{schema.get('title','Circuit')}: {schema.get('description','')}"512 })513 514@app.post("/api/image-to-circuit")515@limiter.limit("5/minute")516async def image_to_circuit(request: Request):517 """Vision endpoint: base64 image → identified circuit schema."""518 body = await request.json()519 image_b64 = body.get("image","")520 image_type = body.get("type","image/jpeg")521 if not image_b64:522 return JSONResponse({"error":"No image provided"}, status_code=400)523 try:524 resp = await get_groq_client().chat.completions.create(525 model=GROQ_VISION_MODEL, max_tokens=1600,526 messages=[{"role":"user","content":[527 {"type":"image_url","image_url":{"url":f"data:{image_type};base64,{image_b64}"}},528 {"type":"text","text":IMAGE_CIRCUIT_PROMPT}529 ]}])530 raw = resp.choices[0].message.content531 m = re.search(r"\{.*\}", raw, re.DOTALL)532 if not m: return JSONResponse({"error":"Could not parse vision response","raw":raw}, status_code=500)533 schema = json.loads(m.group())534 return JSONResponse({535 "schema": schema, "image": render_schematic(schema),536 "description": schema.get("description",""),537 "title": schema.get("title","Identified Circuit"),538 "difficulty": schema.get("difficulty",""),539 "confidence": schema.get("confidence","medium"),540 "notes": schema.get("notes",""),541 "falstad_url": build_falstad_url(schema),542 })543 except Exception as e:544 return JSONResponse({"error": f"Vision error: {str(e)}"}, status_code=500)545 546@app.post("/api/components")547@limiter.limit("10/minute")548async def recommend_components(request: Request):549 body = await request.json()550 result = await llm_compound(COMPONENT_PROMPT, body.get("history",[])+[{"role":"user","content":body.get("prompt","")}], 1500)551 return JSONResponse({"result": result, "model": "compound-beta"})552 553@app.post("/api/components/stream")554@limiter.limit("10/minute")555async def components_stream(request: Request):556 body = await request.json()557 return StreamingResponse(558 llm_compound_stream(COMPONENT_PROMPT, body.get("history",[])+[{"role":"user","content":body.get("prompt","")}], 1500),559 media_type="text/event-stream", headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})560 561@app.post("/api/debug")562@limiter.limit("10/minute")563async def debug_circuit(request: Request):564 body = await request.json()565 prompt = body.get("prompt","")566 schema = body.get("schema")567 full_prompt = (f"Circuit Schema: {json.dumps(schema)}\n\n" if schema else "") + prompt568 result = await llm(DEBUG_PROMPT, body.get("history",[])+[{"role":"user","content":full_prompt}], 1200)569 return JSONResponse({"result": result})570 571@app.post("/api/debug/stream")572@limiter.limit("10/minute")573async def debug_stream(request: Request):574 body = await request.json()575 prompt = body.get("prompt","")576 schema = body.get("schema")577 full_prompt = (f"Circuit Schema: {json.dumps(schema)}\n\n" if schema else "") + prompt578 return StreamingResponse(579 llm_stream(DEBUG_PROMPT, body.get("history",[])+[{"role":"user","content":full_prompt}], 1200),580 media_type="text/event-stream", headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})581 582@app.post("/api/arduino")583@limiter.limit("10/minute")584async def generate_arduino(request: Request):585 body = await request.json()586 prompt = body.get("prompt","")587 schema = body.get("schema")588 full_prompt = (f"Circuit Schema: {json.dumps(schema)}\n\n" if schema else "") + prompt589 result = await llm(ARDUINO_PROMPT, body.get("history",[])+[{"role":"user","content":full_prompt}], 2500)590 return JSONResponse({"result": result})591 592@app.post("/api/arduino/stream")593@limiter.limit("10/minute")594async def arduino_stream(request: Request):595 body = await request.json()596 prompt = body.get("prompt","")597 schema = body.get("schema")598 full_prompt = (f"Circuit Schema: {json.dumps(schema)}\n\n" if schema else "") + prompt599 return StreamingResponse(600 llm_stream(ARDUINO_PROMPT, body.get("history",[])+[{"role":"user","content":full_prompt}], 2500),601 media_type="text/event-stream", headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})602 603@app.post("/api/learn")604@limiter.limit("10/minute")605async def learn(request: Request):606 body = await request.json()607 prompt = body.get("prompt","")608 schema = body.get("schema")609 full_prompt = (f"Circuit Schema: {json.dumps(schema)}\n\n" if schema else "") + prompt610 result = await llm(LEARN_PROMPT, body.get("history",[])+[{"role":"user","content":full_prompt}], 1500)611 return JSONResponse({"result": result})612 613@app.post("/api/learn/stream")614@limiter.limit("10/minute")615async def learn_stream(request: Request):616 body = await request.json()617 prompt = body.get("prompt","")618 schema = body.get("schema")619 full_prompt = (f"Circuit Schema: {json.dumps(schema)}\n\n" if schema else "") + prompt620 return StreamingResponse(621 llm_stream(LEARN_PROMPT, body.get("history", []) + [{"role": "user", "content": full_prompt}], 1500),622 media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})623 624@app.post("/api/breadboard")625@limiter.limit("5/minute")626async def generate_breadboard(request: Request):627 body = await request.json()628 raw = await llm(BREADBOARD_PROMPT, body.get("history", []) + [{"role": "user", "content": "Schema: " + json.dumps(body.get("schema",{}))}], 1500)629 m = re.search(r"\{.*\}", raw, re.DOTALL)630 if not m: return JSONResponse({"error":"Could not parse breadboard JSON","raw":raw}, status_code=500)631 return JSONResponse({"breadboard": json.loads(m.group())})632 633@app.post("/api/simulate")634async def simulate_circuit(request: Request):635 body = await request.json()636 prompt = body.get("prompt","")637 schema = body.get("schema")638 full_prompt = (f"Circuit Schema: {json.dumps(schema)}\n\n" if schema else "") + prompt639 raw = await llm(SIMULATION_PROMPT, body.get("history",[])+[{"role":"user","content":full_prompt}], 1500)640 m = re.search(r"\{.*\}", raw, re.DOTALL)641 if not m: return JSONResponse({"error":"Could not parse simulation JSON"}, status_code=500)642 return JSONResponse({"simulation": json.loads(m.group())})643 644@app.post("/api/bom")645@limiter.limit("10/minute")646async def generate_bom(request: Request):647 body = await request.json()648 prompt = body.get("prompt","")649 schema = body.get("schema")650 full_prompt = (f"Circuit Schema: {json.dumps(schema)}\n\n" if schema else "") + prompt651 raw = await llm(BOM_PROMPT, body.get("history",[])+[{"role":"user","content":full_prompt}], 1500)652 m = re.search(r"\{.*\}", raw, re.DOTALL)653 if not m: return JSONResponse({"error":"Could not parse BOM JSON"}, status_code=500)654 return JSONResponse({"bom": json.loads(m.group())})655 656@app.post("/api/save-circuit")657async def save_circuit(request: Request):658 if not supabase: return JSONResponse({"error":"Database not configured"}, status_code=500)659 body = await request.json()660 name = body.get("name","Untitled Circuit").strip() or "Untitled Circuit"661 device_id = body.get("device_id", "unknown")662 saved_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")663 tech = {k: body.get(k) for k in ("schematic_image","schematic_description","components","simulation","arduino_code","bom")}664 tech["device_id"] = device_id665 666 try:667 data = {668 "name": name,669 "saved_at": saved_at,670 "data": tech,671 "is_public": 1 if body.get("is_public") else 0,672 "upvotes": 0673 }674 res = supabase.table("circuits").insert(data).execute()675 return JSONResponse({"id": str(res.data[0]["id"]), "name": name})676 except Exception as e:677 return JSONResponse({"error": str(e)}, status_code=500)678 679@app.get("/api/list-circuits")680async def list_circuits(device_id: str = None):681 if not supabase: return JSONResponse({"circuits": []})682 try:683 query = supabase.table("circuits").select("id,name,saved_at")684 if device_id:685 query = query.eq("data->>device_id", device_id)686 res = query.order("id", desc=True).limit(50).execute()687 return JSONResponse({"circuits": [{"id": str(r["id"]), "name": r["name"], "saved_at": r["saved_at"]} for r in res.data]})688 except Exception as e:689 return JSONResponse({"error": str(e)}, status_code=500)690 691@app.get("/api/load-circuit/{cid}")692async def load_circuit(cid: str):693 if not supabase: return JSONResponse({"error":"Database not configured"}, status_code=404)694 try:695 res = supabase.table("circuits").select("name,saved_at,data").eq("id", cid).execute()696 if not res.data: return JSONResponse({"error":"Not found"},status_code=404)697 row = res.data[0]698 data = row["data"]699 data["name"] = row["name"]700 data["saved_at"] = row["saved_at"]701 return JSONResponse(data)702 except Exception as e:703 return JSONResponse({"error": str(e)}, status_code=500)704 705# gallery endpoint moved below with upvote support706 707@app.post("/api/export-kicad")708async def export_kicad(request: Request):709 body = await request.json()710 name = body.get("name","Circuit"); components = body.get("components",[])711 type_map = {"resistor":"R","capacitor":"C","led":"LED","battery":"Battery",712 "diode":"D","transistor":"Q","inductor":"L","switch":"SW","mosfet":"Q","op_amp":"U"}713 kicad = f'(kicad_sch (version 20230121) (generator "circuit_copilot")\n (paper "A4")\n'714 for i, c in enumerate(components):715 ref = c.get("label",f"U{i+1}"); val = c.get("value","?")716 sym = type_map.get(c.get("type","resistor"),"R")717 x, y = 50 + (i % 6)*50, 50 + (i//6)*50718 kicad += f' (symbol (lib_id "Device:{sym}") (at {x} {y} 0)\n'719 kicad += f' (property "Reference" "{ref}" (id 0) (at {x} {y-7} 0))\n'720 kicad += f' (property "Value" "{val}" (id 1) (at {x} {y+7} 0)) )\n'721 kicad += ')'722 return JSONResponse({"kicad_sch": kicad})723 724@app.post("/api/export-pdf")725async def export_pdf(request: Request):726 data = await request.json()727 pdf_bytes = generate_pdf(data)728 fname = data.get("project_name","circuit_report").replace(" ","_")+".pdf"729 return StreamingResponse(io.BytesIO(pdf_bytes), media_type="application/pdf",730 headers={"Content-Disposition":f"attachment; filename={fname}"})731 732@app.websocket("/ws/{cid}")733async def websocket_endpoint(websocket: WebSocket, cid: str):734 await manager.connect(websocket, cid)735 try:736 while True:737 await manager.broadcast(await websocket.receive_text(), cid, websocket)738 except WebSocketDisconnect:739 manager.disconnect(websocket, cid)740 741@app.get("/", response_class=HTMLResponse)742async def landing():743 with open("static/landing.html", encoding="utf-8") as f: return f.read()744 745@app.get("/app", response_class=HTMLResponse)746async def main_app():747 with open("static/index.html", encoding="utf-8") as f: return f.read()748 749@app.get("/sw.js")750async def serve_sw():751 return FileResponse("static/sw.js", media_type="application/javascript")752 753app.mount("/static", StaticFiles(directory="static"), name="static")754 755 756@app.post("/api/upvote/{cid}")757async def upvote_circuit(cid: str):758 """Increment upvote count for a circuit."""759 if not supabase: return JSONResponse({"error":"Database not configured"}, status_code=500)760 try:761 res = supabase.table("circuits").select("upvotes").eq("id", cid).execute()762 if not res.data: return JSONResponse({"error": "Not found"}, status_code=404)763 764 current_upvotes = res.data[0].get("upvotes", 0) or 0765 new_upvotes = current_upvotes + 1766 767 upd = supabase.table("circuits").update({"upvotes": new_upvotes}).eq("id", cid).execute()768 return JSONResponse({"id": cid, "upvotes": new_upvotes})769 except Exception as e:770 return JSONResponse({"error": str(e)}, status_code=500)771 772 773@app.get("/api/gallery")774async def get_gallery():775 if not supabase: return JSONResponse({"gallery": []})776 try:777 res = supabase.table("circuits").select("id,name,saved_at,data,upvotes").eq("is_public", 1).order("upvotes", desc=True).order("id", desc=True).limit(30).execute()778 return JSONResponse({"gallery": [{779 "id": str(r["id"]),780 "name": r["name"],781 "saved_at": r["saved_at"],782 "image": r["data"].get("schematic_image"),783 "description": r["data"].get("schematic_description", ""),784 "upvotes": r["upvotes"] or 0785 } for r in res.data]})786 except Exception as e:787 return JSONResponse({"error": str(e)}, status_code=500)