Default to DeepSeek V4.1 Flash, show API cost in USD, and re-analyze after replacing BOM and netlist.

V4.1 is natively multimodal so every stage uses deepseek-flash; pricing and the UI now surface dollars instead of empty credits. KiCad netlists and neighborhood fingerprints land in the same cut so a second run can keep the project and skip unchanged ICs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 20:55:35 +02:00
co-authored by Cursor
parent d454cf75af
commit 61f85f519b
29 changed files with 1174 additions and 118 deletions
+11 -11
View File
@@ -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.
+11 -9
View File
@@ -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 = ""
+18 -1
View File
@@ -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 ------------------------------------------------
+24 -17
View File
@@ -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 ``<export`` / ``<?xml``;
KiCad s-expr netlist with ``(export``; schematic with ``(kicad_sch``.
PADS-PCB ASCII (``*PADS-PCB*``) is the default when no marker is found.
"""
if isinstance(content, bytes):
try:
text = content[:1024].decode("utf-8", errors="replace")
except Exception:
text = ""
text = content[:2048].decode("utf-8", errors="replace")
else:
text = content[:1024]
head = text.lstrip("").lstrip()
# Case-insensitive match — EDIF spec allows different capitalisations
# (KiCad emits lowercase; xDX Designer emits lowercase too).
if head[:5].lower() == "(edif":
text = content[:2048]
head = text.lstrip("\ufeff").lstrip()
low = head[:40].lower()
if low.startswith("(edif"):
return "edif"
if low.startswith("(kicad_sch"):
return "kicad_sch"
if low.startswith("(export"):
return "kicad_sexp"
if low.startswith("<?xml") or low.startswith("<export"):
return "kicad_xml"
return "pads"
@@ -196,11 +197,14 @@ def parse_netlist_any(
their nets land in the output (PADS netlists have no sub-design concept).
"""
p = Path(path)
sample = p.read_bytes()[:1024]
sample = p.read_bytes()[:2048]
fmt = detect_netlist_format(sample)
if fmt == "edif":
from backend.pinscopex.parsers_edif import parse_edif_netlist
parts, nets = parse_edif_netlist(p, include_subdesigns=include_subdesigns)
elif fmt.startswith("kicad"):
from backend.pinscopex.parsers_kicad import parse_kicad
parts, nets, _ = parse_kicad(p)
else:
parts, nets = parse_netlist(p, known_refs=known_refs)
return parts, nets, fmt
@@ -211,7 +215,10 @@ def validate_netlist(parts: dict, nets: dict) -> 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:
+484
View File
@@ -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("<?xml"):
parts, nets, fields = parse_kicad_xml_netlist(p)
else:
text = p.read_text(encoding="utf-8", errors="replace")
tree = _parse_sexp(text)
tag = _tag(tree)
if tag == "kicad_sch":
parts, nets, fields = parse_kicad_sch(tree)
elif tag == "export":
parts, nets, fields = parse_kicad_sexp_netlist(tree)
else:
raise ValueError(f"Unsupported KiCad s-expression root {tag!r}")
if not parts:
raise ValueError("No components found in KiCad file")
if not nets:
raise ValueError(
"No nets found. For a multi-sheet schematic, export a netlist "
"(File → Export → Netlist) instead of uploading .kicad_sch."
)
_fields_cache[str(p.resolve())] = fields
return parts, nets, fields
def kicad_part_fields(path: str | Path) -> dict[str, dict]:
key = str(Path(path).resolve())
if key not in _fields_cache:
parse_kicad(path)
return _fields_cache.get(key, {})
+76
View File
@@ -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
+2 -1
View File
@@ -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"
+7 -2
View File
@@ -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,
+13
View File
@@ -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)
+12 -2
View File
@@ -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:
+15 -8
View File
@@ -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},
}
+37 -3
View File
@@ -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
+18 -9
View File
@@ -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