diff --git a/CLAUDE.md b/CLAUDE.md index 8708e1d..84f71cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ Key taxonomy features: - **Backend**: FastAPI, uvicorn, sse-starlette, pydantic-settings - **Frontend**: Next.js 16 (App Router, Turbopack), React 19, Tailwind CSS v4, shadcn/ui (Base UI), react-pdf - **AI**: DeepSeek Chat Completions (OpenAI-compatible) with forced tool calls for extraction and agentic review. Optional Anthropic / Gemini fallbacks. -- **Model**: `deepseek-v4-flash-vision-exp` for extraction, `deepseek-v4-pro` for review, `deepseek-v4-flash` for auto-resolve (per-stage overrides via `.env`) +- **Model**: `deepseek-flash` for extraction, review, auto-resolve, and normalize (per-stage overrides via `.env`) - **Skills**: Local SKILL.md + validate.py (DeepSeek/Gemini); optional Anthropic Console Skills - **External APIs**: DigiKey API v4 (OAuth2) — optional datasheet auto-fetch and parameter-based auto-resolve (`DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`) diff --git a/README.md b/README.md index e352b54..a046a12 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Pinscope reviews schematics the way a good senior engineer does: with the datasheets open. -This tree is adapted from [manvalan/pinscope](https://github.com/manvalan/pinscope) so the pipeline talks to the **DeepSeek API** (`deepseek-v4-flash`, `deepseek-v4-pro`, and `deepseek-v4-flash-vision-exp`) instead of requiring an Anthropic Console skill upload. Anthropic and Gemini remain optional fallbacks. +This tree is adapted from [manvalan/pinscope](https://github.com/manvalan/pinscope) so the pipeline talks to the **DeepSeek API** (`deepseek-flash`, with legacy aliases still accepted) instead of requiring an Anthropic Console skill upload. Anthropic and Gemini remain optional fallbacks. Give it a netlist, a BOM, and your datasheet PDFs. It builds a graph of your design, reads each IC's datasheet, and checks the circuit around every part against what the manufacturer actually specifies — reference application, pin functions, absolute maximums, recommended operating conditions. Every finding points at the datasheet page that backs it up. @@ -19,9 +19,9 @@ Default routing: | Stage | Model | | --- | --- | -| Pintable / pattern / specs extraction | `deepseek-v4-flash-vision-exp` | -| Per-IC datasheet review | `deepseek-v4-pro` | -| Auto-resolve / normalize | `deepseek-v4-flash` | +| Pintable / pattern / specs extraction | `deepseek-flash` (native vision) | +| Per-IC datasheet review | `deepseek-flash` | +| Auto-resolve / normalize | `deepseek-flash` | Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `backend/.env`. See `backend/.env.example`. diff --git a/backend/.env.example b/backend/.env.example index 0322c9a..31cc0a4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,24 +5,24 @@ # Key from https://platform.deepseek.com/ DEEPSEEK_API_KEY=sk-... DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_MODEL=deepseek-v4-flash -DEEPSEEK_VISION_MODEL=deepseek-v4-flash-vision-exp +DEEPSEEK_MODEL=deepseek-flash +DEEPSEEK_VISION_MODEL=deepseek-flash # enabled (default) | disabled — thinking mode on DeepSeek V4 DEEPSEEK_THINKING=enabled -DEEPSEEK_REASONING_EFFORT=medium +# Official values: low | high | max +DEEPSEEK_REASONING_EFFORT=high # PDF ingest (DeepSeek cannot take native PDFs) # DEEPSEEK_PDF_MAX_CHARS=500000 # DEEPSEEK_PDF_IMAGE_PAGES=32 # Per-stage DeepSeek model overrides (leave empty to use the defaults below) -# Extraction stages default to the vision model so pin diagrams are readable. -# Review defaults to deepseek-v4-pro. -# MODEL_PINTABLE_DEEPSEEK=deepseek-v4-flash-vision-exp -# MODEL_PATTERN_DEEPSEEK=deepseek-v4-flash -# MODEL_SPECS_DEEPSEEK=deepseek-v4-flash-vision-exp -# MODEL_VALIDATION_DEEPSEEK=deepseek-v4-pro -# MODEL_AUTO_RESOLVE_DEEPSEEK=deepseek-v4-flash -# MODEL_NORMALIZE_DEEPSEEK=deepseek-v4-flash +# V4.1 Flash is natively multimodal — extraction and review share deepseek-flash. +# MODEL_PINTABLE_DEEPSEEK=deepseek-flash +# MODEL_PATTERN_DEEPSEEK=deepseek-flash +# MODEL_SPECS_DEEPSEEK=deepseek-flash +# MODEL_VALIDATION_DEEPSEEK=deepseek-flash +# MODEL_AUTO_RESOLVE_DEEPSEEK=deepseek-flash +# MODEL_NORMALIZE_DEEPSEEK=deepseek-flash # -- AI provider routing ----------------------------------------------------- # Default provider for every stage; per-stage env vars override. diff --git a/backend/config.py b/backend/config.py index 4c58884..1149e1b 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,12 +22,14 @@ class Settings(BaseSettings): # DeepSeek (default provider — OpenAI-compatible Chat Completions) deepseek_api_key: str = "" deepseek_base_url: str = "https://api.deepseek.com" - deepseek_model: str = "deepseek-v4-flash" - deepseek_vision_model: str = "deepseek-v4-flash-vision-exp" + deepseek_model: str = "deepseek-flash" + deepseek_vision_model: str = "deepseek-flash" # "enabled" (default) or "disabled". DeepSeek V4 thinks by default; # disable to cut cost on simple mapping calls. deepseek_thinking: str = "enabled" - deepseek_reasoning_effort: str = "medium" + # Official values: low | high | max. Review sessions with + # max_tokens >= 16000 still bump to "high" in the provider. + deepseek_reasoning_effort: str = "high" # PDF ingest: DeepSeek does not accept native PDFs. Text is always # extracted; page images are attached only when the stage model is a # vision model (see model_*_deepseek defaults below). @@ -35,12 +37,12 @@ class Settings(BaseSettings): deepseek_pdf_image_pages: int = 32 # Per-stage DeepSeek model overrides (fall back to deepseek_model) - model_pintable_deepseek: str = "deepseek-v4-flash-vision-exp" - model_pattern_deepseek: str = "deepseek-v4-flash" - model_specs_deepseek: str = "deepseek-v4-flash-vision-exp" - model_validation_deepseek: str = "deepseek-v4-pro" - model_auto_resolve_deepseek: str = "deepseek-v4-flash" - model_normalize_deepseek: str = "deepseek-v4-flash" + model_pintable_deepseek: str = "deepseek-flash" + model_pattern_deepseek: str = "deepseek-flash" + model_specs_deepseek: str = "deepseek-flash" + model_validation_deepseek: str = "deepseek-flash" + model_auto_resolve_deepseek: str = "deepseek-flash" + model_normalize_deepseek: str = "deepseek-flash" # Anthropic (optional fallback) anthropic_api_key: str = "" diff --git a/backend/pinscopex/graph.py b/backend/pinscopex/graph.py index 2579e32..1b91ac6 100644 --- a/backend/pinscopex/graph.py +++ b/backend/pinscopex/graph.py @@ -269,11 +269,28 @@ def build_graph( # only tokenise correctly with the BOM's ref list as a lookup. EDIF # netlists ignore known_refs (designators are unambiguous tokens). bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col) - parts, raw_nets, _ = parse_netlist_any( + parts, raw_nets, fmt = parse_netlist_any( netlist_path, known_refs=set(bom.keys()), include_subdesigns=include_subdesigns, ) + if fmt.startswith("kicad"): + from backend.pinscopex.parsers_kicad import kicad_part_fields + for ref, extra in kicad_part_fields(netlist_path).items(): + entry = bom.setdefault( + ref, + {"value": "", "footprint": "", "mpn": None, "lcsc": None, "datasheet_url": None}, + ) + if extra.get("mpn") and ( + not entry.get("mpn") or entry.get("mpn") == entry.get("value") + ): + entry["mpn"] = extra["mpn"] + if extra.get("lcsc") and not entry.get("lcsc"): + entry["lcsc"] = extra["lcsc"] + if extra.get("value") and not entry.get("value"): + entry["value"] = extra["value"] + if extra.get("footprint") and not entry.get("footprint"): + entry["footprint"] = extra["footprint"] datasheets = _load_datasheets(datasheets_dir) # --- Resolve passive specs ------------------------------------------------ diff --git a/backend/pinscopex/parsers.py b/backend/pinscopex/parsers.py index 4b1aa79..eb8c586 100644 --- a/backend/pinscopex/parsers.py +++ b/backend/pinscopex/parsers.py @@ -7,7 +7,7 @@ import re from pathlib import Path from typing import Literal -NetlistFormat = Literal["pads", "edif"] +NetlistFormat = Literal["pads", "edif", "kicad_xml", "kicad_sexp", "kicad_sch"] def parse_netlist( @@ -158,25 +158,26 @@ def _parse_pin_tokens( def detect_netlist_format(content: bytes | str) -> NetlistFormat: - """Sniff the first chunk of a netlist to decide whether it's PADS or EDIF. + """Sniff the first chunk of a netlist to decide the format. - EDIF s-expressions start with ``(edif …`` (with possible leading whitespace - or BOM); PADS-PCB ASCII files start with ``*PADS-PCB*``. The "pads" branch - is the default when no clear marker is found — preserves the old behavior - where the parser raises a friendly error on unrecognised input. + EDIF starts with ``(edif``; KiCad XML with `` list[str]: errors: list[str] = [] if not parts: - errors.append("No components found — is this a PADS-PCB (.asc) or EDIF (.edn) netlist?") + errors.append( + "No components found — is this a PADS-PCB (.asc), EDIF (.edn), " + "or KiCad netlist / .kicad_sch?" + ) return errors # further checks are meaningless without parts if not nets: diff --git a/backend/pinscopex/parsers_kicad.py b/backend/pinscopex/parsers_kicad.py new file mode 100644 index 0000000..c3d8809 --- /dev/null +++ b/backend/pinscopex/parsers_kicad.py @@ -0,0 +1,484 @@ +"""KiCad netlist (XML / s-expression) and single-sheet ``.kicad_sch`` parser. + +Yields the same ``(parts, nets)`` shape as PADS/EDIF so graph build is format-agnostic. +``.kicad_sch`` uses embedded ``lib_symbols`` plus wires/labels; hierarchical +sheets in other files are not followed (export a netlist for those). +""" + +from __future__ import annotations + +import math +import re +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Iterator + +_MPN_FIELD_NAMES = { + "mpn", "manufacturer part number", "manufacturer_part_number", + "manf#", "part number", "partnumber", "p/n", +} + +# --------------------------------------------------------------------------- +# S-expression +# --------------------------------------------------------------------------- + + +def _tokenize(text: str) -> Iterator[str]: + i, n = 0, len(text) + while i < n: + c = text[i] + if c.isspace(): + i += 1 + continue + if c == "(" or c == ")": + yield c + i += 1 + continue + if c == '"': + j = i + 1 + buf: list[str] = [] + while j < n and text[j] != '"': + if text[j] == "\\" and j + 1 < n: + buf.append(text[j + 1]) + j += 2 + else: + buf.append(text[j]) + j += 1 + yield '"' + "".join(buf) + i = j + 1 + continue + j = i + while j < n and not text[j].isspace() and text[j] not in "()": + j += 1 + yield text[i:j] + i = j + + +def _parse_sexp(text: str) -> Any: + tokens = list(_tokenize(text)) + it = iter(tokens) + + def form() -> Any: + out: list[Any] = [] + for tok in it: + if tok == "(": + out.append(form()) + elif tok == ")": + return out + elif tok.startswith('"'): + out.append(tok[1:]) + else: + out.append(tok) + return out + + first = next(it, None) + if first != "(": + raise ValueError("KiCad file is not an s-expression") + return form() + + +def _tag(node: Any) -> str: + if isinstance(node, list) and node: + return str(node[0]) + return "" + + +def _kids(node: Any, name: str) -> list[list]: + if not isinstance(node, list): + return [] + return [x for x in node[1:] if isinstance(x, list) and x and x[0] == name] + + +def _kid(node: Any, name: str) -> list | None: + found = _kids(node, name) + return found[0] if found else None + + +def _val(node: Any, name: str) -> str: + k = _kid(node, name) + if not k or len(k) < 2: + return "" + return str(k[1]) + + +def _unquote_attr(node: ET.Element, key: str) -> str: + return (node.get(key) or "").strip() + + +def _local(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +# --------------------------------------------------------------------------- +# XML netlist (File → Export → Netlist) +# --------------------------------------------------------------------------- + + +def _iter_xml(root: ET.Element, name: str) -> Iterator[ET.Element]: + for el in root.iter(): + if _local(el.tag) == name: + yield el + + +def parse_kicad_xml_netlist(path: str | Path) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]: + tree = ET.parse(path) + root = tree.getroot() + parts: dict[str, str] = {} + fields: dict[str, dict] = {} + for comp in _iter_xml(root, "comp"): + ref = _unquote_attr(comp, "ref") + if not ref: + continue + value = "" + footprint = "" + mpn = None + lcsc = None + for child in list(comp): + loc = _local(child.tag) + if loc == "value": + value = (child.text or "").strip() + elif loc == "footprint": + footprint = (child.text or "").strip() + elif loc == "fields": + for field in child: + if _local(field.tag) != "field": + continue + fname = (field.get("name") or "").strip().lower() + fval = (field.text or "").strip() + if fname in _MPN_FIELD_NAMES and fval: + mpn = fval + elif fname == "lcsc" and fval: + lcsc = fval + elif loc == "property": + pname = (child.get("name") or "").strip().lower() + pval = (child.get("value") or child.text or "").strip() + if pname in _MPN_FIELD_NAMES and pval: + mpn = pval + elif pname == "lcsc" and pval: + lcsc = pval + parts[ref] = footprint + fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc} + nets: dict[str, list[tuple[str, str]]] = {} + for net in _iter_xml(root, "net"): + name = _unquote_attr(net, "name") or f"Net-{_unquote_attr(net, 'code')}" + pins: list[tuple[str, str]] = [] + for node in net: + if _local(node.tag) != "node": + continue + ref = _unquote_attr(node, "ref") + pin = _unquote_attr(node, "pin") + if ref and pin: + pins.append((ref, pin)) + if name: + nets[name] = pins + return parts, nets, fields + + +# --------------------------------------------------------------------------- +# S-expression netlist (kicad-cli sch export netlist) +# --------------------------------------------------------------------------- + + +def parse_kicad_sexp_netlist(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]: + parts: dict[str, str] = {} + fields: dict[str, dict] = {} + comps = _kid(tree, "components") or [] + for comp in comps[1:]: + if _tag(comp) != "comp": + continue + ref = _val(comp, "ref") + if not ref: + continue + value = _val(comp, "value") + footprint = _val(comp, "footprint") + mpn = None + lcsc = None + for field in _kids(_kid(comp, "fields") or [], "field"): + fname = "" + fval = "" + name_el = _kid(field, "name") + if name_el and len(name_el) >= 2: + fname = str(name_el[1]).lower() + strs = [str(x) for x in field[1:] if not isinstance(x, list)] + if strs: + fval = strs[-1] + if fname in _MPN_FIELD_NAMES and fval: + mpn = fval + elif fname == "lcsc" and fval: + lcsc = fval + parts[ref] = footprint + fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc} + + nets: dict[str, list[tuple[str, str]]] = {} + nets_el = _kid(tree, "nets") or [] + for net in nets_el[1:]: + if _tag(net) != "net": + continue + name = _val(net, "name") or f"Net-{_val(net, 'code')}" + pins: list[tuple[str, str]] = [] + for node in _kids(net, "node"): + ref = _val(node, "ref") + pin = _val(node, "pin") + if ref and pin: + pins.append((ref, pin)) + if name: + nets[name] = pins + return parts, nets, fields + + +# --------------------------------------------------------------------------- +# Single-sheet .kicad_sch (embedded lib_symbols + wires) +# --------------------------------------------------------------------------- + + +def _fnum(v: Any) -> float: + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + +def _at(node: Any) -> tuple[float, float, float]: + k = _kid(node, "at") + if not k or len(k) < 3: + return 0.0, 0.0, 0.0 + rot = _fnum(k[3]) if len(k) > 3 else 0.0 + return _fnum(k[1]), _fnum(k[2]), rot + + +def _snap(x: float, y: float) -> tuple[int, int]: + return round(x * 1000), round(y * 1000) + + +def _rotate(px: float, py: float, deg: float) -> tuple[float, float]: + r = deg % 360.0 + rad = math.radians(r) + c, s = math.cos(rad), math.sin(rad) + return px * c + py * s, -px * s + py * c + + +def _lib_pins(sym: Any) -> dict[tuple[int, str], tuple[float, float]]: + """(unit, pin_number) -> (x, y) in symbol space. unit 0 = common.""" + out: dict[tuple[int, str], tuple[float, float]] = {} + + def walk(node: Any, unit: int) -> None: + if not isinstance(node, list) or not node: + return + if node[0] == "symbol" and len(node) > 1 and isinstance(node[1], str): + # nested unit symbol Device:R_1_1 → unit 1 + m = re.search(r"_(\d+)_(\d+)$", str(node[1])) + u = int(m.group(1)) if m else unit + for ch in node[1:]: + walk(ch, u) + return + if node[0] == "pin": + ax, ay, _ = _at(node) + num = _val(node, "number") or "" + if not num and len(node) > 1: + num = str(node[1]) + if num: + out[(unit, num)] = (ax, ay) + out[(0, num)] = (ax, ay) + return + for ch in node[1:]: + if isinstance(ch, list): + walk(ch, unit) + + walk(sym, 0) + return out + + +class _DSU: + def __init__(self) -> None: + self.p: dict[tuple[int, int], tuple[int, int]] = {} + + def add(self, pt: tuple[int, int]) -> None: + self.p.setdefault(pt, pt) + + def find(self, a: tuple[int, int]) -> tuple[int, int]: + self.add(a) + if self.p[a] != a: + self.p[a] = self.find(self.p[a]) + return self.p[a] + + def union(self, a: tuple[int, int], b: tuple[int, int]) -> None: + ra, rb = self.find(a), self.find(b) + if ra != rb: + self.p[rb] = ra + + +def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]: + lib_pins: dict[str, dict[tuple[int, str], tuple[float, float]]] = {} + for sym in _kids(_kid(tree, "lib_symbols") or [], "symbol"): + lid = str(sym[1]) if len(sym) > 1 else "" + if lid: + lib_pins[lid] = _lib_pins(sym) + + parts: dict[str, str] = {} + fields: dict[str, dict] = {} + pin_at: dict[tuple[str, str], tuple[int, int]] = {} + dsu = _DSU() + labels: dict[tuple[int, int], str] = {} + power_pts: list[tuple[tuple[int, int], str]] = [] + + def prop(sym: Any, key: str) -> str: + for p in _kids(sym, "property"): + if len(p) >= 3 and str(p[1]) == key: + return str(p[2]) + return "" + + for sym in _kids(tree, "symbol"): + lib_id = _val(sym, "lib_id") + ix, iy, rot = _at(sym) + unit = int(_fnum(_val(sym, "unit") or "1") or 1) + mirror = bool(_kid(sym, "mirror")) + ref = prop(sym, "Reference") + if ref.startswith("#"): + # power flag / graphic + val = prop(sym, "Value") or lib_id.rsplit(":", 1)[-1] + lp = lib_pins.get(lib_id, {}) + xy = lp.get((unit, "1")) or lp.get((0, "1")) or (0.0, 0.0) + px, py = _rotate(xy[0], xy[1], rot) + if mirror: + px = -px + pt = _snap(ix + px, iy + py) + dsu.add(pt) + if val: + power_pts.append((pt, val)) + continue + if not ref: + continue + value = prop(sym, "Value") + footprint = prop(sym, "Footprint") + mpn = None + lcsc = None + for p in _kids(sym, "property"): + if len(p) < 3: + continue + n = str(p[1]).strip().lower() + v = str(p[2]).strip() + if n in _MPN_FIELD_NAMES and v: + mpn = v + elif n == "lcsc" and v: + lcsc = v + parts[ref] = footprint + fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc} + lp = lib_pins.get(lib_id, {}) + for pin_el in _kids(sym, "pin"): + num = str(pin_el[1]) if len(pin_el) > 1 else "" + if not num: + continue + xy = lp.get((unit, num)) or lp.get((0, num)) or (0.0, 0.0) + px, py = _rotate(xy[0], xy[1], rot) + if mirror: + px = -px + pt = _snap(ix + px, iy + py) + pin_at[(ref, num)] = pt + dsu.add(pt) + + def collect_pts(node: Any) -> None: + if not isinstance(node, list) or not node: + return + tag = node[0] + if tag == "wire": + pts = _kid(node, "pts") + coords: list[tuple[int, int]] = [] + if pts: + for xy in _kids(pts, "xy"): + if len(xy) >= 3: + pt = _snap(_fnum(xy[1]), _fnum(xy[2])) + dsu.add(pt) + coords.append(pt) + for a, b in zip(coords, coords[1:]): + dsu.union(a, b) + return + if tag in {"label", "global_label", "hierarchical_label"}: + name = str(node[1]) if len(node) > 1 else "" + x, y, _ = _at(node) + pt = _snap(x, y) + dsu.add(pt) + if name: + labels[pt] = name + return + if tag == "junction": + x, y, _ = _at(node) + dsu.add(_snap(x, y)) + return + for ch in node[1:]: + if isinstance(ch, list): + collect_pts(ch) + + collect_pts(tree) + + for pt, name in labels.items(): + dsu.add(pt) + for pt, _name in power_pts: + dsu.add(pt) + + # Merge labels/power onto coinciding pin/wire points (already same snap keys). + nets: dict[str, list[tuple[str, str]]] = {} + root_name: dict[tuple[int, int], str] = {} + for pt, name in labels.items(): + root_name[dsu.find(pt)] = name + for pt, name in power_pts: + root_name.setdefault(dsu.find(pt), name) + + grouped: dict[tuple[int, int], list[tuple[str, str]]] = {} + for (ref, pin), pt in pin_at.items(): + grouped.setdefault(dsu.find(pt), []).append((ref, pin)) + + used_names: set[str] = set() + for root, pins in grouped.items(): + name = root_name.get(root) + if not name: + ref0, pin0 = pins[0] + name = f"Net-({ref0}-Pad{pin0})" + while name in used_names: + name = name + "_" + used_names.add(name) + nets[name] = pins + + return parts, nets, fields + + +# --------------------------------------------------------------------------- +# Public +# --------------------------------------------------------------------------- + +_fields_cache: dict[str, dict[str, dict]] = {} + + +def parse_kicad( + path: str | Path, +) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]: + p = Path(path) + raw = p.read_bytes() + head = raw[:256].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip() + if head.startswith("<") or head.startswith(" dict[str, dict]: + key = str(Path(path).resolve()) + if key not in _fields_cache: + parse_kicad(path) + return _fields_cache.get(key, {}) diff --git a/backend/pinscopex/review_fingerprint.py b/backend/pinscopex/review_fingerprint.py new file mode 100644 index 0000000..dcc0abf --- /dev/null +++ b/backend/pinscopex/review_fingerprint.py @@ -0,0 +1,76 @@ +"""Stable per-IC neighborhood hash so a second review can skip unchanged chips.""" + +from __future__ import annotations + +import hashlib +import json + +from backend.pinscopex.models import ComponentType, DesignGraph +from backend.pinscopex.validate import _match_constraints + + +def ic_neighborhood_fingerprint( + graph: DesignGraph, + ref: str, + constraints_map: dict | None = None, +) -> str | None: + """Hash MPN, pin→net, 1-hop neighbors, and extraction model_version. + + Returns None if *ref* is not an IC. Neighbor changes (pull-up added on + SDA, etc.) invalidate every IC on that net. + """ + comp = graph.components.get(ref) + if not comp or comp.component_type != ComponentType.IC: + return None + pins = tuple(sorted((str(p), n) for p, n in comp.pins.items())) + neighbors: list[tuple[str, str, str, str]] = [] + for _pin, net_name in pins: + for other in graph.components_on_net(net_name): + if other == ref: + continue + o = graph.components[other] + o_pins_on_net = tuple( + sorted(str(p) for p, n in o.pins.items() if n == net_name) + ) + neighbors.append( + (other, o.mpn or "", o.component_type.value, ",".join(o_pins_on_net)) + ) + model_version = "" + cons = _match_constraints(comp.mpn or comp.value, constraints_map or {}) + if cons is not None: + model_version = getattr(cons, "model_version", "") or "" + payload = { + "mpn": comp.mpn or "", + "pins": pins, + "neighbors": tuple(sorted(neighbors)), + "model_version": model_version, + } + blob = json.dumps(payload, sort_keys=True, default=str).encode() + return hashlib.sha256(blob).hexdigest() + + +def graph_ic_fingerprints( + graph: DesignGraph, + constraints_map: dict | None = None, +) -> dict[str, str]: + out: dict[str, str] = {} + for ref, comp in graph.components.items(): + if comp.component_type != ComponentType.IC: + continue + fp = ic_neighborhood_fingerprint(graph, ref, constraints_map) + if fp: + out[ref] = fp + return out + + +def skip_unchanged_ics( + completed_refs: set[str], + previous: dict[str, str], + current: dict[str, str], +) -> set[str]: + """Keep skip only for completed ICs whose neighborhood hash is unchanged.""" + skip: set[str] = set() + for ref in completed_refs: + if ref in current and previous.get(ref) == current[ref]: + skip.add(ref) + return skip diff --git a/backend/routers/pipeline.py b/backend/routers/pipeline.py index fc718b2..0377ac7 100644 --- a/backend/routers/pipeline.py +++ b/backend/routers/pipeline.py @@ -37,7 +37,8 @@ class RegenRequest(BaseModel): class ReprocessRequest(BaseModel): - """``failed`` retries skipped / errored IC reviews; ``all`` re-reviews every IC.""" + """``failed`` retries skipped/errored reviews and ICs whose circuit + neighborhood changed; ``all`` re-reviews every IC.""" mode: Literal["failed", "all"] = "failed" diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 0bbdd19..edb3941 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -481,7 +481,12 @@ async def upload_netlist(project_id: str, file: UploadFile, request: Request): import tempfile, os fmt = detect_netlist_format(data) - suffix = ".edn" if fmt == "edif" else ".asc" + suffix = { + "edif": ".edn", + "kicad_xml": ".xml", + "kicad_sexp": ".kicad_net", + "kicad_sch": ".kicad_sch", + }.get(fmt, ".asc") sub_designs: list[dict] = [] try: tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) @@ -503,7 +508,7 @@ async def upload_netlist(project_id: str, file: UploadFile, request: Request): # shape, so the wizard's power-sources step can render its dropdowns # without re-parsing the (s-expression-heavy) file in the browser. designator_pins: list[dict] = [] - if fmt == "edif": + if fmt != "pads": designator_pins = _build_designator_pins(parts, nets) return { "path": key, diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 7d4efdb..6bf09a2 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -138,6 +138,19 @@ async def get_project_logs(project_id: str, request: Request): return JSONResponse([]) text = storage.read_text(key) entries = [json.loads(line) for line in text.strip().split("\n") if line.strip()] + from backend.services.llm.pricing import cost_for_entry + + for entry in entries: + if any( + entry.get(k) + for k in ( + "input_tokens", + "output_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", + ) + ): + entry["cost_usd"] = round(cost_for_entry(entry), 6) return JSONResponse(entries) diff --git a/backend/services/llm/deepseek_provider.py b/backend/services/llm/deepseek_provider.py index 927a1e9..7d7d022 100644 --- a/backend/services/llm/deepseek_provider.py +++ b/backend/services/llm/deepseek_provider.py @@ -38,11 +38,21 @@ from backend.services.llm.types import ( log = logging.getLogger(__name__) -_VISION_HINT = "vision" +# V4.1 Flash is natively multimodal. Legacy flash / vision-exp names +# still route there. v4-pro does not accept images until it is retired +# onto Flash (2026-09-14). +_VISION_MODELS = { + "deepseek-flash", + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", +} def _is_vision_model(model: str) -> bool: - return _VISION_HINT in model.lower() + name = (model or "").strip().lower() + if name in _VISION_MODELS or "vision" in name: + return True + return name.startswith("deepseek-flash") def _to_openai_tool(t: ToolSchema) -> dict: diff --git a/backend/services/llm/pricing.py b/backend/services/llm/pricing.py index a19a39f..dff2bb1 100644 --- a/backend/services/llm/pricing.py +++ b/backend/services/llm/pricing.py @@ -11,15 +11,22 @@ from __future__ import annotations # DeepSeek: https://api-docs.deepseek.com/quick_start/pricing # Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing # Google: https://ai.google.dev/pricing -# Last updated: 2026-08-27 +# Last updated: 2026-09-10 +# +# DeepSeek: peak weekday rates (conservative). Off-peak is 50% of these. +# Cache-hit input is billed via CACHE_RATES["deepseek"]["read"] as a +# multiplier on the miss input rate (0.006 / 0.30 = 0.02). +_DEEPSEEK_FLASH = {"input": 0.30, "output": 1.20} +_DEEPSEEK_PRO = {"input": 1.32, "output": 3.96} + PRICING: dict[str, dict[str, dict[str, float]]] = { "deepseek": { - # Peak-hour rates (conservative). Off-peak is 50% of these. - # Cache-hit input is billed via CACHE_RATES["deepseek"]["read"]. - "deepseek-v4-flash": {"input": 0.44, "output": 1.32}, - "deepseek-v4-flash-vision-exp": {"input": 0.44, "output": 1.32}, - "deepseek-v4-pro": {"input": 1.32, "output": 3.96}, - "default": {"input": 0.44, "output": 1.32}, + "deepseek-flash": _DEEPSEEK_FLASH, + "deepseek-v4-flash": _DEEPSEEK_FLASH, + "deepseek-v4-flash-vision-exp": _DEEPSEEK_FLASH, + # Billed at Pro until 2026-09-14 04:00 UTC, then routed to Flash. + "deepseek-v4-pro": _DEEPSEEK_PRO, + "default": _DEEPSEEK_FLASH, }, "anthropic": { "claude-opus-4-6": {"input": 5.00, "output": 25.00}, @@ -63,7 +70,7 @@ PRICING: dict[str, dict[str, dict[str, float]]] = { # normal input pass) # read: cost when a cached prefix is *reused* (much cheaper) CACHE_RATES: dict[str, dict[str, float]] = { - "deepseek": {"create": 1.00, "read": 0.032}, + "deepseek": {"create": 1.00, "read": 0.02}, "anthropic": {"create": 1.25, "read": 0.10}, "gemini": {"create": 1.00, "read": 0.25}, } diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index b0e4214..4bf56e6 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -260,6 +260,7 @@ class PipelineWorkspace: self._upload_file("bom_summary.json") self._upload_file("derating.json") self._upload_file("report.json") + self._upload_file("review_fingerprints.json") self._upload_file("api_logs.jsonl") # Merge taxonomy: read current from storage, add any new entries @@ -324,7 +325,7 @@ class PipelineWorkspace: code will raise a clearer error when it tries to read the missing file than a ``None`` return would. """ - for ext in ("asc", "edn"): + for ext in ("asc", "edn", "xml", "kicad_net", "kicad_sch"): p = self.local_dir / "uploads" / f"netlist.{ext}" if p.exists(): return p @@ -1679,7 +1680,37 @@ async def _stage_validation(ctx: PipelineContext) -> None: if private is not None: _charge_private_logger(ctx, private) - # Resume-aware: skip ICs that were already reviewed in a previous pass + skip_refs = set(ctx.completed_review_refs) + fp_path = ctx.ws.local_path("review_fingerprints.json") + current_fp: dict[str, str] = {} + try: + from backend.pinscopex.review_fingerprint import ( + graph_ic_fingerprints, + skip_unchanged_ics, + ) + from backend.pinscopex.validate import _build_constraints_map, _load_datasheets + + cmap = _build_constraints_map(_load_datasheets(extracted_dir)) + current_fp = graph_ic_fingerprints(ctx.graph, cmap) + previous_fp: dict[str, str] = {} + if fp_path.is_file(): + try: + previous_fp = json.loads(fp_path.read_text()) + except json.JSONDecodeError: + previous_fp = {} + if previous_fp: + skip_refs = skip_unchanged_ics(skip_refs, previous_fp, current_fp) + for ref in sorted(skip_refs): + broker.publish( + ctx.project_id, "step_update", + {"stage": "validation", "substep": ref, "status": "complete", + "detail": "unchanged since last review"}, + ) + except Exception: + logger.exception("review fingerprints failed — reviewing all kept refs") + + # Resume-aware: skip ICs that were already reviewed and whose + # neighborhood fingerprint is unchanged. ctx.report = await validate_design_async( str(graph_path), str(report_path), @@ -1688,7 +1719,7 @@ async def _stage_validation(ctx: PipelineContext) -> None: on_progress=on_validation_progress, api_logger=ctx.api_logger, storage=ctx.storage, - skip_refs=set(ctx.completed_review_refs), + skip_refs=skip_refs, before_ic=before_ic, on_ic_done=on_ic_done, on_ic_error=on_ic_error, @@ -1697,6 +1728,9 @@ async def _stage_validation(ctx: PipelineContext) -> None: run_meta={"git_commit": _git_commit()}, ) + if current_fp: + fp_path.write_text(json.dumps(current_fp, indent=2) + "\n") + if ctx.paused: return diff --git a/backend/services/projects.py b/backend/services/projects.py index f6bf76a..68369b8 100644 --- a/backend/services/projects.py +++ b/backend/services/projects.py @@ -357,6 +357,7 @@ def clear_project_extractions( "bom_summary.json", "derating.json", "report.json", + "review_fingerprints.json", "api_logs.jsonl", "graph_voltage_updates.json", ): @@ -390,6 +391,7 @@ def reopen_project( "bom_summary.json", "derating.json", "report.json", + "review_fingerprints.json", "api_logs.jsonl", "graph_voltage_updates.json", ): @@ -611,7 +613,13 @@ def save_bom( return key -_NETLIST_EXT = {"pads": "asc", "edif": "edn"} +_NETLIST_EXT = { + "pads": "asc", + "edif": "edn", + "kicad_xml": "xml", + "kicad_sexp": "kicad_net", + "kicad_sch": "kicad_sch", +} def _netlist_key(user_id: str, project_id: str, fmt: str) -> str: @@ -629,16 +637,17 @@ def save_netlist( ) -> str: """Persist the uploaded netlist with the extension matching ``fmt``. - Also clears any previously-saved netlist in the *other* format so we - never have stale ``.asc`` and ``.edn`` files side-by-side (e.g. user - re-uploads with a different format). + Also clears any previously-saved netlist in another format so we + never have stale files side-by-side (e.g. user re-uploads KiCad after PADS). """ key = _netlist_key(user_id, project_id, fmt) storage.write_bytes(key, data) - other_fmt = "edif" if fmt == "pads" else "pads" - other_key = _netlist_key(user_id, project_id, other_fmt) - if storage.exists(other_key): - storage.delete_key(other_key) + for other in _NETLIST_EXT: + if other == fmt: + continue + other_key = _netlist_key(user_id, project_id, other) + if storage.exists(other_key): + storage.delete_key(other_key) # Reset sub-design selection on every upload — the prior selection may # reference IDs that no longer exist in the new file. Frontend resets # the picker after upload too; this keeps backend in sync. @@ -690,7 +699,7 @@ def get_netlist_key( storage: StorageBackend, user_id: str, project_id: str ) -> str | None: """Return the storage key of whichever netlist file exists (.asc or .edn).""" - for fmt in ("pads", "edif"): + for fmt in _NETLIST_EXT: key = _netlist_key(user_id, project_id, fmt) if storage.exists(key): return key diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index bd7dea1..791cfe3 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.11.0 — 2026-09-10 — DeepSeek V4.1 and re-analyze + +Pinscope now defaults to DeepSeek-V4.1-Flash (`deepseek-flash`) for every LLM stage, shows API cost in dollars, and lets you replace the BOM and netlist on an existing project without deleting it. + +- [New] Default model is `deepseek-flash` (native vision). Legacy `deepseek-v4-flash` / `deepseek-v4-flash-vision-exp` names still work; they route to V4.1. +- [New] Replace BOM & netlist on a finished project and re-run the analysis. History, library cache, and prior spend stay on the same project. +- [Improved] API cost uses V4.1 Flash peak rates and is shown on the report, logs tab, and run estimate (not only credits). + ## 2.10.0 — 2026-08-27 — Deeper datasheet review Each IC review now sees more of the datasheet and starts from a structured abs-max table, so voltage, decoupling, and interface checks are less likely to stop at "Unverified". diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 12536dd..e3454e4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "2.6.0", + "version": "2.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "2.6.0", + "version": "2.10.0", "dependencies": { "@base-ui/react": "^1.3.0", "@types/dagre": "^0.7.54", diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index 6cb92f1..07029a8 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -20,6 +20,7 @@ import { resumePipeline, fetchPipelineEstimate, } from "@/lib/api"; +import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog"; import { PausedRunBanner } from "@/components/billing/paused-run-banner"; import type { Project, SkippedComponent, ApiLogEntry, BomSummaryRow, DeratingRow, DeratingSettings, Collaborator, CostEstimate } from "@/lib/types"; import { @@ -36,6 +37,7 @@ import { OctagonX, Copy, Check, + Upload, } from "lucide-react"; import { useOptionalUser } from "@/hooks/use-optional-auth"; import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet"; @@ -106,6 +108,7 @@ export default function ProjectDetailPage({ const tab = searchParams.get("tab") ?? "bom"; const [starting, setStarting] = useState(false); const [estimate, setEstimate] = useState(null); + const [rerunProject, setRerunProject] = useState(null); const canRun = Boolean(project?.hasBom && project?.hasNetlist); const canReprocess = @@ -187,11 +190,28 @@ export default function ProjectDetailPage({

{project.name}

{new Date(project.created).toLocaleDateString()} + {typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && ( + + ${project.totalCostUsd.toFixed(4)} + + )}

- - {project.status} - +
+ {project.status !== "running" && project.status !== "queued" && ( + + )} + + {project.status} + +
{isPaused && ( @@ -285,13 +305,12 @@ export default function ProjectDetailPage({ )} {canRun && estimate && estimate.review_ic_count > 0 && ( -
+
- ≈ {(estimate.review_ic_count * 2).toFixed(0)} - –{(estimate.review_ic_count * 2.5).toFixed(0)} credits + ≈ ${estimate.api_cost_low.toFixed(2)}–${estimate.api_cost_high.toFixed(2)} - + · {estimate.review_ic_count} IC {estimate.review_ic_count === 1 ? "" : "s"} to review @@ -346,6 +365,18 @@ export default function ProjectDetailPage({ mpn={pdfState.mpn} initialPage={1} /> + { + setRerunProject(null); + reload(); + }} + onCreateProject={(p) => { + setRerunProject(null); + router.push(`/project/${p.id}/progress`); + }} + />
); } @@ -414,7 +445,9 @@ function ApiLogsSection({ logs }: { logs: ApiLogEntry[] }) { {formatTokens(totalInput)} input tokens {formatTokens(totalOutput)} output tokens {formatDuration(totalDuration)} total - {totalCost > 0 && ${totalCost.toFixed(4)}} + {totalCost > 0 && ( + ${totalCost.toFixed(4)} + )}
@@ -448,8 +481,10 @@ function ApiLogsSection({ logs }: { logs: ApiLogEntry[] }) { {formatTokens(log.output_tokens)} out {formatDuration(log.duration_ms)} {log.model} - {log.cost_usd != null && log.cost_usd > 0 && ( - ${log.cost_usd.toFixed(4)} + {log.cost_usd != null && ( + + ${log.cost_usd.toFixed(4)} + )} diff --git a/frontend/src/app/(app)/project/[id]/report/page.tsx b/frontend/src/app/(app)/project/[id]/report/page.tsx index e292fc2..1fd6bbc 100644 --- a/frontend/src/app/(app)/project/[id]/report/page.tsx +++ b/frontend/src/app/(app)/project/[id]/report/page.tsx @@ -37,6 +37,7 @@ function ReportContent({ projectId }: { projectId: string }) { const [collaborators, setCollaborators] = useState([]); const [comments, setComments] = useState>({}); const [creditsSpent, setCreditsSpent] = useState(); + const [totalCostUsd, setTotalCostUsd] = useState(null); const [projectName, setProjectName] = useState(""); const [feedbackFinding, setFeedbackFinding] = useState(null); const [feedbackOpen, setFeedbackOpen] = useState(false); @@ -70,6 +71,7 @@ function ReportContent({ projectId }: { projectId: string }) { fetchProject(projectId) .then((p) => { setCreditsSpent(p.creditsSpent); + setTotalCostUsd(p.totalCostUsd ?? null); setProjectName(p.name); }) .catch(() => {}); @@ -249,6 +251,7 @@ function ReportContent({ projectId }: { projectId: string }) { summary={report.summary} reviewedCount={reviewedCount} creditsSpent={creditsSpent} + totalCostUsd={totalCostUsd} /> {report.review_errors && Object.keys(report.review_errors).length > 0 && (
diff --git a/frontend/src/components/dashboard/create-project-dialog.tsx b/frontend/src/components/dashboard/create-project-dialog.tsx index 6782b1d..eb918ce 100644 --- a/frontend/src/components/dashboard/create-project-dialog.tsx +++ b/frontend/src/components/dashboard/create-project-dialog.tsx @@ -463,6 +463,8 @@ interface CreateProjectDialogProps { onRerunDone?: () => void; cloneAsNewProject?: Project | null; onCloneAsNewDone?: () => void; + /** Hide the "New Project" trigger — used when the parent opens rerun mode. */ + hideTrigger?: boolean; } function countNetsInNetlist(text: string): number { @@ -475,6 +477,16 @@ function isEdifNetlist(text: string): boolean { return text.slice(0, 1024).trimStart().slice(0, 5).toLowerCase() === "(edif"; } +function isKicadNetlist(text: string): boolean { + const head = text.slice(0, 2048).trimStart().slice(0, 40).toLowerCase(); + return ( + head.startsWith("(kicad_sch") || + head.startsWith("(export") || + head.startsWith("("details"); @@ -889,6 +902,12 @@ export function CreateProjectDialog({ setNetlistIsEdif(true); return; } + if (isKicadNetlist(text)) { + setNetlistNetCount(null); + setNetlistPreview([]); + setNetlistIsEdif(false); + return; + } setNetlistIsEdif(false); setNetlistNetCount(countNetsInNetlist(text)); try { @@ -1152,20 +1171,20 @@ export function CreateProjectDialog({ // the user just uploaded a new BOM via the modal haven't been pushed // to the backend yet, so skip until there's something to estimate. useEffect(() => { - if (!authEnabled) return; // OSS mode: no credits — no estimate UI if (!existingProjectId) return; if (step !== activeSteps[activeSteps.length - 1]?.key) return; if (!initialBomFile) return; let cancelled = false; setEstimateLoading(true); - Promise.all([ - fetchPipelineEstimate(existingProjectId), - fetchCredits(), - ]) - .then(([est, cr]) => { + const jobs: Promise[] = [fetchPipelineEstimate(existingProjectId)]; + if (authEnabled) jobs.push(fetchCredits()); + Promise.all(jobs) + .then((results) => { if (cancelled) return; - setEstimate(est); - setBalance(cr.balance); + setEstimate(results[0] as CostEstimate); + if (authEnabled && results[1]) { + setBalance((results[1] as { balance: number }).balance); + } }) .catch(() => { /* Estimate is best-effort; swallow so the Run button still works. */ @@ -1940,7 +1959,7 @@ export function CreateProjectDialog({ else resetAndClose(); }} > - {disabled ? ( + {hideTrigger ? null : disabled ? (
@@ -2076,11 +2095,11 @@ export function CreateProjectDialog({

) : netlistFile ? (

- EDIF detected — net count will appear after upload. + Net count will appear after upload.

) : (

- PADS-PCB ASCII or EDIF 2.0.0. .asc / .net / .txt / .edn all work.{" "} + PADS-PCB, EDIF, or KiCad netlist / .kicad_sch.{" "} Estimating… - ) : estimate && balance !== null ? ( + ) : estimate ? ( <> - - Balance:{" "} - - {balance.toFixed(2)} + {balance !== null && ( + + Balance:{" "} + + {balance.toFixed(2)} + - + )} Est:{" "} - {estimate.credits_low.toFixed(2)}–{estimate.credits_high.toFixed(2)} - {" "} - credits + ${estimate.api_cost_low.toFixed(2)}–${estimate.api_cost_high.toFixed(2)} + + {authEnabled && ( + <> + {" "} + ({estimate.credits_low.toFixed(2)}–{estimate.credits_high.toFixed(2)} credits) + + )} {!canRunRerun && ( diff --git a/frontend/src/components/dashboard/project-card.tsx b/frontend/src/components/dashboard/project-card.tsx index 0577c45..879e637 100644 --- a/frontend/src/components/dashboard/project-card.tsx +++ b/frontend/src/components/dashboard/project-card.tsx @@ -36,6 +36,13 @@ export function ProjectCard({ const checkedCount = useReviewedCount(project.id); const isCancelled = project.status === "cancelled"; const isDraft = project.status === "draft"; + const canReplaceFiles = + !isShared && + onRerun != null && + (project.status === "complete" || + project.status === "error" || + project.status === "cancelled" || + project.status === "draft"); const opensModalOnClick = isDraft && !isShared && onRerun != null; async function handleDelete(e: React.MouseEvent) { @@ -79,10 +86,10 @@ export function ProjectCard({ {project.status} - {isCancelled && !isShared && onRerun && ( + {canReplaceFiles && (