CoolFace
Apppublic

smemon819/circuit-copilot

sourceHugging Facemitupdated 7mo agoView on Hugging Face
0likes
app.py787 linesDownload Raw Back to root
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(" ","&nbsp;").replace("<","&lt;").replace(">","&gt;") or "&nbsp;", 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)