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:
@@ -114,7 +114,7 @@ Key taxonomy features:
|
|||||||
- **Backend**: FastAPI, uvicorn, sse-starlette, pydantic-settings
|
- **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
|
- **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.
|
- **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
|
- **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`)
|
- **External APIs**: DigiKey API v4 (OAuth2) — optional datasheet auto-fetch and parameter-based auto-resolve (`DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Pinscope reviews schematics the way a good senior engineer does: with the datasheets open.
|
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.
|
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 |
|
| Stage | Model |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Pintable / pattern / specs extraction | `deepseek-v4-flash-vision-exp` |
|
| Pintable / pattern / specs extraction | `deepseek-flash` (native vision) |
|
||||||
| Per-IC datasheet review | `deepseek-v4-pro` |
|
| Per-IC datasheet review | `deepseek-flash` |
|
||||||
| Auto-resolve / normalize | `deepseek-v4-flash` |
|
| Auto-resolve / normalize | `deepseek-flash` |
|
||||||
|
|
||||||
Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `backend/.env`. See `backend/.env.example`.
|
Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `backend/.env`. See `backend/.env.example`.
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -5,24 +5,24 @@
|
|||||||
# Key from https://platform.deepseek.com/
|
# Key from https://platform.deepseek.com/
|
||||||
DEEPSEEK_API_KEY=sk-...
|
DEEPSEEK_API_KEY=sk-...
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
DEEPSEEK_MODEL=deepseek-flash
|
||||||
DEEPSEEK_VISION_MODEL=deepseek-v4-flash-vision-exp
|
DEEPSEEK_VISION_MODEL=deepseek-flash
|
||||||
# enabled (default) | disabled — thinking mode on DeepSeek V4
|
# enabled (default) | disabled — thinking mode on DeepSeek V4
|
||||||
DEEPSEEK_THINKING=enabled
|
DEEPSEEK_THINKING=enabled
|
||||||
DEEPSEEK_REASONING_EFFORT=medium
|
# Official values: low | high | max
|
||||||
|
DEEPSEEK_REASONING_EFFORT=high
|
||||||
# PDF ingest (DeepSeek cannot take native PDFs)
|
# PDF ingest (DeepSeek cannot take native PDFs)
|
||||||
# DEEPSEEK_PDF_MAX_CHARS=500000
|
# DEEPSEEK_PDF_MAX_CHARS=500000
|
||||||
# DEEPSEEK_PDF_IMAGE_PAGES=32
|
# DEEPSEEK_PDF_IMAGE_PAGES=32
|
||||||
|
|
||||||
# Per-stage DeepSeek model overrides (leave empty to use the defaults below)
|
# Per-stage DeepSeek model overrides (leave empty to use the defaults below)
|
||||||
# Extraction stages default to the vision model so pin diagrams are readable.
|
# V4.1 Flash is natively multimodal — extraction and review share deepseek-flash.
|
||||||
# Review defaults to deepseek-v4-pro.
|
# MODEL_PINTABLE_DEEPSEEK=deepseek-flash
|
||||||
# MODEL_PINTABLE_DEEPSEEK=deepseek-v4-flash-vision-exp
|
# MODEL_PATTERN_DEEPSEEK=deepseek-flash
|
||||||
# MODEL_PATTERN_DEEPSEEK=deepseek-v4-flash
|
# MODEL_SPECS_DEEPSEEK=deepseek-flash
|
||||||
# MODEL_SPECS_DEEPSEEK=deepseek-v4-flash-vision-exp
|
# MODEL_VALIDATION_DEEPSEEK=deepseek-flash
|
||||||
# MODEL_VALIDATION_DEEPSEEK=deepseek-v4-pro
|
# MODEL_AUTO_RESOLVE_DEEPSEEK=deepseek-flash
|
||||||
# MODEL_AUTO_RESOLVE_DEEPSEEK=deepseek-v4-flash
|
# MODEL_NORMALIZE_DEEPSEEK=deepseek-flash
|
||||||
# MODEL_NORMALIZE_DEEPSEEK=deepseek-v4-flash
|
|
||||||
|
|
||||||
# -- AI provider routing -----------------------------------------------------
|
# -- AI provider routing -----------------------------------------------------
|
||||||
# Default provider for every stage; per-stage env vars override.
|
# Default provider for every stage; per-stage env vars override.
|
||||||
|
|||||||
+11
-9
@@ -22,12 +22,14 @@ class Settings(BaseSettings):
|
|||||||
# DeepSeek (default provider — OpenAI-compatible Chat Completions)
|
# DeepSeek (default provider — OpenAI-compatible Chat Completions)
|
||||||
deepseek_api_key: str = ""
|
deepseek_api_key: str = ""
|
||||||
deepseek_base_url: str = "https://api.deepseek.com"
|
deepseek_base_url: str = "https://api.deepseek.com"
|
||||||
deepseek_model: str = "deepseek-v4-flash"
|
deepseek_model: str = "deepseek-flash"
|
||||||
deepseek_vision_model: str = "deepseek-v4-flash-vision-exp"
|
deepseek_vision_model: str = "deepseek-flash"
|
||||||
# "enabled" (default) or "disabled". DeepSeek V4 thinks by default;
|
# "enabled" (default) or "disabled". DeepSeek V4 thinks by default;
|
||||||
# disable to cut cost on simple mapping calls.
|
# disable to cut cost on simple mapping calls.
|
||||||
deepseek_thinking: str = "enabled"
|
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
|
# PDF ingest: DeepSeek does not accept native PDFs. Text is always
|
||||||
# extracted; page images are attached only when the stage model is a
|
# extracted; page images are attached only when the stage model is a
|
||||||
# vision model (see model_*_deepseek defaults below).
|
# vision model (see model_*_deepseek defaults below).
|
||||||
@@ -35,12 +37,12 @@ class Settings(BaseSettings):
|
|||||||
deepseek_pdf_image_pages: int = 32
|
deepseek_pdf_image_pages: int = 32
|
||||||
|
|
||||||
# Per-stage DeepSeek model overrides (fall back to deepseek_model)
|
# Per-stage DeepSeek model overrides (fall back to deepseek_model)
|
||||||
model_pintable_deepseek: str = "deepseek-v4-flash-vision-exp"
|
model_pintable_deepseek: str = "deepseek-flash"
|
||||||
model_pattern_deepseek: str = "deepseek-v4-flash"
|
model_pattern_deepseek: str = "deepseek-flash"
|
||||||
model_specs_deepseek: str = "deepseek-v4-flash-vision-exp"
|
model_specs_deepseek: str = "deepseek-flash"
|
||||||
model_validation_deepseek: str = "deepseek-v4-pro"
|
model_validation_deepseek: str = "deepseek-flash"
|
||||||
model_auto_resolve_deepseek: str = "deepseek-v4-flash"
|
model_auto_resolve_deepseek: str = "deepseek-flash"
|
||||||
model_normalize_deepseek: str = "deepseek-v4-flash"
|
model_normalize_deepseek: str = "deepseek-flash"
|
||||||
|
|
||||||
# Anthropic (optional fallback)
|
# Anthropic (optional fallback)
|
||||||
anthropic_api_key: str = ""
|
anthropic_api_key: str = ""
|
||||||
|
|||||||
@@ -269,11 +269,28 @@ def build_graph(
|
|||||||
# only tokenise correctly with the BOM's ref list as a lookup. EDIF
|
# only tokenise correctly with the BOM's ref list as a lookup. EDIF
|
||||||
# netlists ignore known_refs (designators are unambiguous tokens).
|
# netlists ignore known_refs (designators are unambiguous tokens).
|
||||||
bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col)
|
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,
|
netlist_path,
|
||||||
known_refs=set(bom.keys()),
|
known_refs=set(bom.keys()),
|
||||||
include_subdesigns=include_subdesigns,
|
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)
|
datasheets = _load_datasheets(datasheets_dir)
|
||||||
|
|
||||||
# --- Resolve passive specs ------------------------------------------------
|
# --- Resolve passive specs ------------------------------------------------
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
NetlistFormat = Literal["pads", "edif"]
|
NetlistFormat = Literal["pads", "edif", "kicad_xml", "kicad_sexp", "kicad_sch"]
|
||||||
|
|
||||||
|
|
||||||
def parse_netlist(
|
def parse_netlist(
|
||||||
@@ -158,25 +158,26 @@ def _parse_pin_tokens(
|
|||||||
|
|
||||||
|
|
||||||
def detect_netlist_format(content: bytes | str) -> NetlistFormat:
|
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
|
EDIF starts with ``(edif``; KiCad XML with ``<export`` / ``<?xml``;
|
||||||
or BOM); PADS-PCB ASCII files start with ``*PADS-PCB*``. The "pads" branch
|
KiCad s-expr netlist with ``(export``; schematic with ``(kicad_sch``.
|
||||||
is the default when no clear marker is found — preserves the old behavior
|
PADS-PCB ASCII (``*PADS-PCB*``) is the default when no marker is found.
|
||||||
where the parser raises a friendly error on unrecognised input.
|
|
||||||
"""
|
"""
|
||||||
if isinstance(content, bytes):
|
if isinstance(content, bytes):
|
||||||
try:
|
text = content[:2048].decode("utf-8", errors="replace")
|
||||||
text = content[:1024].decode("utf-8", errors="replace")
|
|
||||||
except Exception:
|
|
||||||
text = ""
|
|
||||||
else:
|
else:
|
||||||
text = content[:1024]
|
text = content[:2048]
|
||||||
head = text.lstrip("").lstrip()
|
head = text.lstrip("\ufeff").lstrip()
|
||||||
# Case-insensitive match — EDIF spec allows different capitalisations
|
low = head[:40].lower()
|
||||||
# (KiCad emits lowercase; xDX Designer emits lowercase too).
|
if low.startswith("(edif"):
|
||||||
if head[:5].lower() == "(edif":
|
|
||||||
return "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"
|
return "pads"
|
||||||
|
|
||||||
|
|
||||||
@@ -196,11 +197,14 @@ def parse_netlist_any(
|
|||||||
their nets land in the output (PADS netlists have no sub-design concept).
|
their nets land in the output (PADS netlists have no sub-design concept).
|
||||||
"""
|
"""
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
sample = p.read_bytes()[:1024]
|
sample = p.read_bytes()[:2048]
|
||||||
fmt = detect_netlist_format(sample)
|
fmt = detect_netlist_format(sample)
|
||||||
if fmt == "edif":
|
if fmt == "edif":
|
||||||
from backend.pinscopex.parsers_edif import parse_edif_netlist
|
from backend.pinscopex.parsers_edif import parse_edif_netlist
|
||||||
parts, nets = parse_edif_netlist(p, include_subdesigns=include_subdesigns)
|
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:
|
else:
|
||||||
parts, nets = parse_netlist(p, known_refs=known_refs)
|
parts, nets = parse_netlist(p, known_refs=known_refs)
|
||||||
return parts, nets, fmt
|
return parts, nets, fmt
|
||||||
@@ -211,7 +215,10 @@ def validate_netlist(parts: dict, nets: dict) -> list[str]:
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
if not parts:
|
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
|
return errors # further checks are meaningless without parts
|
||||||
|
|
||||||
if not nets:
|
if not nets:
|
||||||
|
|||||||
@@ -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, {})
|
||||||
@@ -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
|
||||||
@@ -37,7 +37,8 @@ class RegenRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ReprocessRequest(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"
|
mode: Literal["failed", "all"] = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -481,7 +481,12 @@ async def upload_netlist(project_id: str, file: UploadFile, request: Request):
|
|||||||
import tempfile, os
|
import tempfile, os
|
||||||
|
|
||||||
fmt = detect_netlist_format(data)
|
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] = []
|
sub_designs: list[dict] = []
|
||||||
try:
|
try:
|
||||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
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
|
# shape, so the wizard's power-sources step can render its dropdowns
|
||||||
# without re-parsing the (s-expression-heavy) file in the browser.
|
# without re-parsing the (s-expression-heavy) file in the browser.
|
||||||
designator_pins: list[dict] = []
|
designator_pins: list[dict] = []
|
||||||
if fmt == "edif":
|
if fmt != "pads":
|
||||||
designator_pins = _build_designator_pins(parts, nets)
|
designator_pins = _build_designator_pins(parts, nets)
|
||||||
return {
|
return {
|
||||||
"path": key,
|
"path": key,
|
||||||
|
|||||||
@@ -138,6 +138,19 @@ async def get_project_logs(project_id: str, request: Request):
|
|||||||
return JSONResponse([])
|
return JSONResponse([])
|
||||||
text = storage.read_text(key)
|
text = storage.read_text(key)
|
||||||
entries = [json.loads(line) for line in text.strip().split("\n") if line.strip()]
|
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)
|
return JSONResponse(entries)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,21 @@ from backend.services.llm.types import (
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
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:
|
def _to_openai_tool(t: ToolSchema) -> dict:
|
||||||
|
|||||||
@@ -11,15 +11,22 @@ from __future__ import annotations
|
|||||||
# DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
|
# DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
|
||||||
# Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
|
# Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
|
||||||
# Google: https://ai.google.dev/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]]] = {
|
PRICING: dict[str, dict[str, dict[str, float]]] = {
|
||||||
"deepseek": {
|
"deepseek": {
|
||||||
# Peak-hour rates (conservative). Off-peak is 50% of these.
|
"deepseek-flash": _DEEPSEEK_FLASH,
|
||||||
# Cache-hit input is billed via CACHE_RATES["deepseek"]["read"].
|
"deepseek-v4-flash": _DEEPSEEK_FLASH,
|
||||||
"deepseek-v4-flash": {"input": 0.44, "output": 1.32},
|
"deepseek-v4-flash-vision-exp": _DEEPSEEK_FLASH,
|
||||||
"deepseek-v4-flash-vision-exp": {"input": 0.44, "output": 1.32},
|
# Billed at Pro until 2026-09-14 04:00 UTC, then routed to Flash.
|
||||||
"deepseek-v4-pro": {"input": 1.32, "output": 3.96},
|
"deepseek-v4-pro": _DEEPSEEK_PRO,
|
||||||
"default": {"input": 0.44, "output": 1.32},
|
"default": _DEEPSEEK_FLASH,
|
||||||
},
|
},
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"claude-opus-4-6": {"input": 5.00, "output": 25.00},
|
"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)
|
# normal input pass)
|
||||||
# read: cost when a cached prefix is *reused* (much cheaper)
|
# read: cost when a cached prefix is *reused* (much cheaper)
|
||||||
CACHE_RATES: dict[str, dict[str, float]] = {
|
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},
|
"anthropic": {"create": 1.25, "read": 0.10},
|
||||||
"gemini": {"create": 1.00, "read": 0.25},
|
"gemini": {"create": 1.00, "read": 0.25},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ class PipelineWorkspace:
|
|||||||
self._upload_file("bom_summary.json")
|
self._upload_file("bom_summary.json")
|
||||||
self._upload_file("derating.json")
|
self._upload_file("derating.json")
|
||||||
self._upload_file("report.json")
|
self._upload_file("report.json")
|
||||||
|
self._upload_file("review_fingerprints.json")
|
||||||
self._upload_file("api_logs.jsonl")
|
self._upload_file("api_logs.jsonl")
|
||||||
|
|
||||||
# Merge taxonomy: read current from storage, add any new entries
|
# 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
|
code will raise a clearer error when it tries to read the missing
|
||||||
file than a ``None`` return would.
|
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}"
|
p = self.local_dir / "uploads" / f"netlist.{ext}"
|
||||||
if p.exists():
|
if p.exists():
|
||||||
return p
|
return p
|
||||||
@@ -1679,7 +1680,37 @@ async def _stage_validation(ctx: PipelineContext) -> None:
|
|||||||
if private is not None:
|
if private is not None:
|
||||||
_charge_private_logger(ctx, private)
|
_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(
|
ctx.report = await validate_design_async(
|
||||||
str(graph_path),
|
str(graph_path),
|
||||||
str(report_path),
|
str(report_path),
|
||||||
@@ -1688,7 +1719,7 @@ async def _stage_validation(ctx: PipelineContext) -> None:
|
|||||||
on_progress=on_validation_progress,
|
on_progress=on_validation_progress,
|
||||||
api_logger=ctx.api_logger,
|
api_logger=ctx.api_logger,
|
||||||
storage=ctx.storage,
|
storage=ctx.storage,
|
||||||
skip_refs=set(ctx.completed_review_refs),
|
skip_refs=skip_refs,
|
||||||
before_ic=before_ic,
|
before_ic=before_ic,
|
||||||
on_ic_done=on_ic_done,
|
on_ic_done=on_ic_done,
|
||||||
on_ic_error=on_ic_error,
|
on_ic_error=on_ic_error,
|
||||||
@@ -1697,6 +1728,9 @@ async def _stage_validation(ctx: PipelineContext) -> None:
|
|||||||
run_meta={"git_commit": _git_commit()},
|
run_meta={"git_commit": _git_commit()},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if current_fp:
|
||||||
|
fp_path.write_text(json.dumps(current_fp, indent=2) + "\n")
|
||||||
|
|
||||||
if ctx.paused:
|
if ctx.paused:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ def clear_project_extractions(
|
|||||||
"bom_summary.json",
|
"bom_summary.json",
|
||||||
"derating.json",
|
"derating.json",
|
||||||
"report.json",
|
"report.json",
|
||||||
|
"review_fingerprints.json",
|
||||||
"api_logs.jsonl",
|
"api_logs.jsonl",
|
||||||
"graph_voltage_updates.json",
|
"graph_voltage_updates.json",
|
||||||
):
|
):
|
||||||
@@ -390,6 +391,7 @@ def reopen_project(
|
|||||||
"bom_summary.json",
|
"bom_summary.json",
|
||||||
"derating.json",
|
"derating.json",
|
||||||
"report.json",
|
"report.json",
|
||||||
|
"review_fingerprints.json",
|
||||||
"api_logs.jsonl",
|
"api_logs.jsonl",
|
||||||
"graph_voltage_updates.json",
|
"graph_voltage_updates.json",
|
||||||
):
|
):
|
||||||
@@ -611,7 +613,13 @@ def save_bom(
|
|||||||
return key
|
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:
|
def _netlist_key(user_id: str, project_id: str, fmt: str) -> str:
|
||||||
@@ -629,16 +637,17 @@ def save_netlist(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Persist the uploaded netlist with the extension matching ``fmt``.
|
"""Persist the uploaded netlist with the extension matching ``fmt``.
|
||||||
|
|
||||||
Also clears any previously-saved netlist in the *other* format so we
|
Also clears any previously-saved netlist in another format so we
|
||||||
never have stale ``.asc`` and ``.edn`` files side-by-side (e.g. user
|
never have stale files side-by-side (e.g. user re-uploads KiCad after PADS).
|
||||||
re-uploads with a different format).
|
|
||||||
"""
|
"""
|
||||||
key = _netlist_key(user_id, project_id, fmt)
|
key = _netlist_key(user_id, project_id, fmt)
|
||||||
storage.write_bytes(key, data)
|
storage.write_bytes(key, data)
|
||||||
other_fmt = "edif" if fmt == "pads" else "pads"
|
for other in _NETLIST_EXT:
|
||||||
other_key = _netlist_key(user_id, project_id, other_fmt)
|
if other == fmt:
|
||||||
if storage.exists(other_key):
|
continue
|
||||||
storage.delete_key(other_key)
|
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
|
# Reset sub-design selection on every upload — the prior selection may
|
||||||
# reference IDs that no longer exist in the new file. Frontend resets
|
# reference IDs that no longer exist in the new file. Frontend resets
|
||||||
# the picker after upload too; this keeps backend in sync.
|
# 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
|
storage: StorageBackend, user_id: str, project_id: str
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Return the storage key of whichever netlist file exists (.asc or .edn)."""
|
"""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)
|
key = _netlist_key(user_id, project_id, fmt)
|
||||||
if storage.exists(key):
|
if storage.exists(key):
|
||||||
return key
|
return key
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
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
|
## 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".
|
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".
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.6.0",
|
"version": "2.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.6.0",
|
"version": "2.10.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@types/dagre": "^0.7.54",
|
"@types/dagre": "^0.7.54",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
resumePipeline,
|
resumePipeline,
|
||||||
fetchPipelineEstimate,
|
fetchPipelineEstimate,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
|
||||||
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
|
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
|
||||||
import type { Project, SkippedComponent, ApiLogEntry, BomSummaryRow, DeratingRow, DeratingSettings, Collaborator, CostEstimate } from "@/lib/types";
|
import type { Project, SkippedComponent, ApiLogEntry, BomSummaryRow, DeratingRow, DeratingSettings, Collaborator, CostEstimate } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
OctagonX,
|
OctagonX,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||||
import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet";
|
import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet";
|
||||||
@@ -106,6 +108,7 @@ export default function ProjectDetailPage({
|
|||||||
const tab = searchParams.get("tab") ?? "bom";
|
const tab = searchParams.get("tab") ?? "bom";
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
|
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
|
||||||
|
const [rerunProject, setRerunProject] = useState<Project | null>(null);
|
||||||
|
|
||||||
const canRun = Boolean(project?.hasBom && project?.hasNetlist);
|
const canRun = Boolean(project?.hasBom && project?.hasNetlist);
|
||||||
const canReprocess =
|
const canReprocess =
|
||||||
@@ -187,11 +190,28 @@ export default function ProjectDetailPage({
|
|||||||
<h1 className="text-lg font-semibold">{project.name}</h1>
|
<h1 className="text-lg font-semibold">{project.name}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{new Date(project.created).toLocaleDateString()}
|
{new Date(project.created).toLocaleDateString()}
|
||||||
|
{typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && (
|
||||||
|
<span className="ml-2 font-mono tabular-nums text-foreground">
|
||||||
|
${project.totalCostUsd.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="capitalize text-xs">
|
<div className="flex items-center gap-2">
|
||||||
{project.status}
|
{project.status !== "running" && project.status !== "queued" && (
|
||||||
</Badge>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRerunProject(project)}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-1" />
|
||||||
|
Replace BOM & netlist
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="capitalize text-xs">
|
||||||
|
{project.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
@@ -285,13 +305,12 @@ export default function ProjectDetailPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canRun && estimate && estimate.review_ic_count > 0 && (
|
{canRun && estimate && estimate.review_ic_count > 0 && (
|
||||||
<div className="inline-flex items-center gap-1.5 text-[11px] text-amber-700/90 dark:text-amber-300/90 animate-pulse drop-shadow-[0_0_6px_rgba(251,191,36,0.55)]">
|
<div className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
<Coins className="h-3 w-3" />
|
<Coins className="h-3 w-3" />
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
≈ {(estimate.review_ic_count * 2).toFixed(0)}
|
≈ ${estimate.api_cost_low.toFixed(2)}–${estimate.api_cost_high.toFixed(2)}
|
||||||
–{(estimate.review_ic_count * 2.5).toFixed(0)} credits
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-amber-700/60 dark:text-amber-300/60">
|
<span>
|
||||||
· {estimate.review_ic_count} IC
|
· {estimate.review_ic_count} IC
|
||||||
{estimate.review_ic_count === 1 ? "" : "s"} to review
|
{estimate.review_ic_count === 1 ? "" : "s"} to review
|
||||||
</span>
|
</span>
|
||||||
@@ -346,6 +365,18 @@ export default function ProjectDetailPage({
|
|||||||
mpn={pdfState.mpn}
|
mpn={pdfState.mpn}
|
||||||
initialPage={1}
|
initialPage={1}
|
||||||
/>
|
/>
|
||||||
|
<CreateProjectDialog
|
||||||
|
hideTrigger
|
||||||
|
rerunProject={rerunProject}
|
||||||
|
onRerunDone={() => {
|
||||||
|
setRerunProject(null);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
onCreateProject={(p) => {
|
||||||
|
setRerunProject(null);
|
||||||
|
router.push(`/project/${p.id}/progress`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -414,7 +445,9 @@ function ApiLogsSection({ logs }: { logs: ApiLogEntry[] }) {
|
|||||||
<span>{formatTokens(totalInput)} input tokens</span>
|
<span>{formatTokens(totalInput)} input tokens</span>
|
||||||
<span>{formatTokens(totalOutput)} output tokens</span>
|
<span>{formatTokens(totalOutput)} output tokens</span>
|
||||||
<span>{formatDuration(totalDuration)} total</span>
|
<span>{formatDuration(totalDuration)} total</span>
|
||||||
{totalCost > 0 && <span className="font-medium text-foreground">${totalCost.toFixed(4)}</span>}
|
{totalCost > 0 && (
|
||||||
|
<span className="font-medium text-foreground">${totalCost.toFixed(4)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -448,8 +481,10 @@ function ApiLogsSection({ logs }: { logs: ApiLogEntry[] }) {
|
|||||||
<span>{formatTokens(log.output_tokens)} out</span>
|
<span>{formatTokens(log.output_tokens)} out</span>
|
||||||
<span>{formatDuration(log.duration_ms)}</span>
|
<span>{formatDuration(log.duration_ms)}</span>
|
||||||
<span className="font-mono">{log.model}</span>
|
<span className="font-mono">{log.model}</span>
|
||||||
{log.cost_usd != null && log.cost_usd > 0 && (
|
{log.cost_usd != null && (
|
||||||
<span className="font-medium text-foreground">${log.cost_usd.toFixed(4)}</span>
|
<span className="font-medium text-foreground">
|
||||||
|
${log.cost_usd.toFixed(4)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
|
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
|
||||||
const [comments, setComments] = useState<Record<string, FindingComment[]>>({});
|
const [comments, setComments] = useState<Record<string, FindingComment[]>>({});
|
||||||
const [creditsSpent, setCreditsSpent] = useState<number | undefined>();
|
const [creditsSpent, setCreditsSpent] = useState<number | undefined>();
|
||||||
|
const [totalCostUsd, setTotalCostUsd] = useState<number | null>(null);
|
||||||
const [projectName, setProjectName] = useState<string>("");
|
const [projectName, setProjectName] = useState<string>("");
|
||||||
const [feedbackFinding, setFeedbackFinding] = useState<Finding | null>(null);
|
const [feedbackFinding, setFeedbackFinding] = useState<Finding | null>(null);
|
||||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||||
@@ -70,6 +71,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
fetchProject(projectId)
|
fetchProject(projectId)
|
||||||
.then((p) => {
|
.then((p) => {
|
||||||
setCreditsSpent(p.creditsSpent);
|
setCreditsSpent(p.creditsSpent);
|
||||||
|
setTotalCostUsd(p.totalCostUsd ?? null);
|
||||||
setProjectName(p.name);
|
setProjectName(p.name);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -249,6 +251,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
summary={report.summary}
|
summary={report.summary}
|
||||||
reviewedCount={reviewedCount}
|
reviewedCount={reviewedCount}
|
||||||
creditsSpent={creditsSpent}
|
creditsSpent={creditsSpent}
|
||||||
|
totalCostUsd={totalCostUsd}
|
||||||
/>
|
/>
|
||||||
{report.review_errors && Object.keys(report.review_errors).length > 0 && (
|
{report.review_errors && Object.keys(report.review_errors).length > 0 && (
|
||||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
|
||||||
|
|||||||
@@ -463,6 +463,8 @@ interface CreateProjectDialogProps {
|
|||||||
onRerunDone?: () => void;
|
onRerunDone?: () => void;
|
||||||
cloneAsNewProject?: Project | null;
|
cloneAsNewProject?: Project | null;
|
||||||
onCloneAsNewDone?: () => void;
|
onCloneAsNewDone?: () => void;
|
||||||
|
/** Hide the "New Project" trigger — used when the parent opens rerun mode. */
|
||||||
|
hideTrigger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countNetsInNetlist(text: string): number {
|
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";
|
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("<?xml") ||
|
||||||
|
head.startsWith("<export")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SuggestedDatasheetLinks({ urls }: { urls: string[] }) {
|
function SuggestedDatasheetLinks({ urls }: { urls: string[] }) {
|
||||||
if (!urls.length) return null;
|
if (!urls.length) return null;
|
||||||
return (
|
return (
|
||||||
@@ -512,6 +524,7 @@ export function CreateProjectDialog({
|
|||||||
onRerunDone,
|
onRerunDone,
|
||||||
cloneAsNewProject,
|
cloneAsNewProject,
|
||||||
onCloneAsNewDone,
|
onCloneAsNewDone,
|
||||||
|
hideTrigger = false,
|
||||||
}: CreateProjectDialogProps) {
|
}: CreateProjectDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [step, setStep] = useState<WizardStep>("details");
|
const [step, setStep] = useState<WizardStep>("details");
|
||||||
@@ -889,6 +902,12 @@ export function CreateProjectDialog({
|
|||||||
setNetlistIsEdif(true);
|
setNetlistIsEdif(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isKicadNetlist(text)) {
|
||||||
|
setNetlistNetCount(null);
|
||||||
|
setNetlistPreview([]);
|
||||||
|
setNetlistIsEdif(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setNetlistIsEdif(false);
|
setNetlistIsEdif(false);
|
||||||
setNetlistNetCount(countNetsInNetlist(text));
|
setNetlistNetCount(countNetsInNetlist(text));
|
||||||
try {
|
try {
|
||||||
@@ -1152,20 +1171,20 @@ export function CreateProjectDialog({
|
|||||||
// the user just uploaded a new BOM via the modal haven't been pushed
|
// 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.
|
// to the backend yet, so skip until there's something to estimate.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authEnabled) return; // OSS mode: no credits — no estimate UI
|
|
||||||
if (!existingProjectId) return;
|
if (!existingProjectId) return;
|
||||||
if (step !== activeSteps[activeSteps.length - 1]?.key) return;
|
if (step !== activeSteps[activeSteps.length - 1]?.key) return;
|
||||||
if (!initialBomFile) return;
|
if (!initialBomFile) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setEstimateLoading(true);
|
setEstimateLoading(true);
|
||||||
Promise.all([
|
const jobs: Promise<unknown>[] = [fetchPipelineEstimate(existingProjectId)];
|
||||||
fetchPipelineEstimate(existingProjectId),
|
if (authEnabled) jobs.push(fetchCredits());
|
||||||
fetchCredits(),
|
Promise.all(jobs)
|
||||||
])
|
.then((results) => {
|
||||||
.then(([est, cr]) => {
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setEstimate(est);
|
setEstimate(results[0] as CostEstimate);
|
||||||
setBalance(cr.balance);
|
if (authEnabled && results[1]) {
|
||||||
|
setBalance((results[1] as { balance: number }).balance);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* Estimate is best-effort; swallow so the Run button still works. */
|
/* Estimate is best-effort; swallow so the Run button still works. */
|
||||||
@@ -1940,7 +1959,7 @@ export function CreateProjectDialog({
|
|||||||
else resetAndClose();
|
else resetAndClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{disabled ? (
|
{hideTrigger ? null : disabled ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
@@ -2060,8 +2079,8 @@ export function CreateProjectDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<FileUploadZone
|
<FileUploadZone
|
||||||
label="Netlist (.asc / .net / .txt / .edn)"
|
label="Netlist (.asc / .net / .edn / .kicad_sch)"
|
||||||
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf"
|
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net"
|
||||||
files={netlistFile ? [netlistFile] : []}
|
files={netlistFile ? [netlistFile] : []}
|
||||||
onFilesChange={handleNetlistChange}
|
onFilesChange={handleNetlistChange}
|
||||||
/>
|
/>
|
||||||
@@ -2076,11 +2095,11 @@ export function CreateProjectDialog({
|
|||||||
</p>
|
</p>
|
||||||
) : netlistFile ? (
|
) : netlistFile ? (
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||||
EDIF detected — net count will appear after upload.
|
Net count will appear after upload.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||||
PADS-PCB ASCII or EDIF 2.0.0. .asc / .net / .txt / .edn all work.{" "}
|
PADS-PCB, EDIF, or KiCad netlist / .kicad_sch.{" "}
|
||||||
<a
|
<a
|
||||||
href="/file-guide#the-netlist"
|
href="/file-guide#the-netlist"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -3056,20 +3075,27 @@ export function CreateProjectDialog({
|
|||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
Estimating…
|
Estimating…
|
||||||
</span>
|
</span>
|
||||||
) : estimate && balance !== null ? (
|
) : estimate ? (
|
||||||
<>
|
<>
|
||||||
<span>
|
{balance !== null && (
|
||||||
Balance:{" "}
|
<span>
|
||||||
<span className="font-mono text-foreground">
|
Balance:{" "}
|
||||||
{balance.toFixed(2)}
|
<span className="font-mono text-foreground">
|
||||||
|
{balance.toFixed(2)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
)}
|
||||||
<span>
|
<span>
|
||||||
Est:{" "}
|
Est:{" "}
|
||||||
<span className="font-mono text-foreground">
|
<span className="font-mono text-foreground">
|
||||||
{estimate.credits_low.toFixed(2)}–{estimate.credits_high.toFixed(2)}
|
${estimate.api_cost_low.toFixed(2)}–${estimate.api_cost_high.toFixed(2)}
|
||||||
</span>{" "}
|
</span>
|
||||||
credits
|
{authEnabled && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
({estimate.credits_low.toFixed(2)}–{estimate.credits_high.toFixed(2)} credits)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!canRunRerun && (
|
{!canRunRerun && (
|
||||||
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export function ProjectCard({
|
|||||||
const checkedCount = useReviewedCount(project.id);
|
const checkedCount = useReviewedCount(project.id);
|
||||||
const isCancelled = project.status === "cancelled";
|
const isCancelled = project.status === "cancelled";
|
||||||
const isDraft = project.status === "draft";
|
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;
|
const opensModalOnClick = isDraft && !isShared && onRerun != null;
|
||||||
|
|
||||||
async function handleDelete(e: React.MouseEvent) {
|
async function handleDelete(e: React.MouseEvent) {
|
||||||
@@ -79,10 +86,10 @@ export function ProjectCard({
|
|||||||
<Badge variant="outline" className={cn("text-xs capitalize", STATUS_STYLES[project.status])}>
|
<Badge variant="outline" className={cn("text-xs capitalize", STATUS_STYLES[project.status])}>
|
||||||
{project.status}
|
{project.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isCancelled && !isShared && onRerun && (
|
{canReplaceFiles && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRerun}
|
onClick={handleRerun}
|
||||||
title="Rerun project"
|
title="Replace files and re-analyze"
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
className="p-1 rounded-md text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ function ProjectRow({
|
|||||||
const checkedCount = useReviewedCount(project.id);
|
const checkedCount = useReviewedCount(project.id);
|
||||||
const isCancelled = project.status === "cancelled";
|
const isCancelled = project.status === "cancelled";
|
||||||
const isDraft = project.status === "draft";
|
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;
|
const opensModalOnClick = isDraft && !isShared && onRerun != null;
|
||||||
|
|
||||||
async function handleDelete(e: React.MouseEvent) {
|
async function handleDelete(e: React.MouseEvent) {
|
||||||
@@ -156,10 +163,10 @@ function ProjectRow({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
{isCancelled && !isShared && onRerun && (
|
{canReplaceFiles && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRerun}
|
onClick={handleRerun}
|
||||||
title="Rerun project"
|
title="Replace files and re-analyze"
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
className="p-1 rounded-md text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface ReportSummaryProps {
|
|||||||
summary: Record<string, number>;
|
summary: Record<string, number>;
|
||||||
reviewedCount: number;
|
reviewedCount: number;
|
||||||
creditsSpent?: number;
|
creditsSpent?: number;
|
||||||
|
totalCostUsd?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAT_CONFIG = [
|
const STAT_CONFIG = [
|
||||||
@@ -20,16 +21,19 @@ export function ReportSummary({
|
|||||||
summary,
|
summary,
|
||||||
reviewedCount,
|
reviewedCount,
|
||||||
creditsSpent,
|
creditsSpent,
|
||||||
|
totalCostUsd,
|
||||||
}: ReportSummaryProps) {
|
}: ReportSummaryProps) {
|
||||||
const total = summary.total || 0;
|
const total = summary.total || 0;
|
||||||
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
|
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
|
||||||
|
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
|
||||||
|
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-4",
|
"grid gap-4",
|
||||||
showCredits ? "grid-cols-6" : "grid-cols-5",
|
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{STAT_CONFIG.map(({ key, label, color }) => (
|
{STAT_CONFIG.map(({ key, label, color }) => (
|
||||||
@@ -50,6 +54,16 @@ export function ReportSummary({
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{showCost && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-4">
|
||||||
|
<p className="text-sm text-muted-foreground">API cost</p>
|
||||||
|
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
|
||||||
|
${totalCostUsd!.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{showCredits && (
|
{showCredits && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4 pb-4">
|
<CardContent className="pt-4 pb-4">
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function mapProject(p: Record<string, unknown>): Project {
|
|||||||
userId: p.user_id as string | undefined,
|
userId: p.user_id as string | undefined,
|
||||||
collaborators: (p.collaborators as string[] | null) ?? undefined,
|
collaborators: (p.collaborators as string[] | null) ?? undefined,
|
||||||
creditsSpent: (p.credits_spent as number | undefined) ?? undefined,
|
creditsSpent: (p.credits_spent as number | undefined) ?? undefined,
|
||||||
|
totalCostUsd: (p.total_cost_usd as number | null | undefined) ?? null,
|
||||||
pauseCheckpoint: (p.pause_checkpoint as PauseCheckpoint | null) ?? null,
|
pauseCheckpoint: (p.pause_checkpoint as PauseCheckpoint | null) ?? null,
|
||||||
pauseReason: (p.pause_reason as string | null | undefined) ?? null,
|
pauseReason: (p.pause_reason as string | null | undefined) ?? null,
|
||||||
bomColumns: (p.bom_columns as { reference: string; mpn: string } | null) ?? null,
|
bomColumns: (p.bom_columns as { reference: string; mpn: string } | null) ?? null,
|
||||||
@@ -79,7 +80,7 @@ function mapProject(p: Record<string, unknown>): Project {
|
|||||||
lcscPayloads: (p.lcsc_payloads as Record<string, LcscPayload> | null) ?? null,
|
lcscPayloads: (p.lcsc_payloads as Record<string, LcscPayload> | null) ?? null,
|
||||||
componentMpns: (p.component_mpns as ComponentMpnBuckets | null) ?? null,
|
componentMpns: (p.component_mpns as ComponentMpnBuckets | null) ?? null,
|
||||||
pinscopeVersion: (p.pinscope_version as string | null | undefined) ?? null,
|
pinscopeVersion: (p.pinscope_version as string | null | undefined) ?? null,
|
||||||
netlistFormat: (p.netlist_format as "pads" | "edif" | null | undefined) ?? null,
|
netlistFormat: (p.netlist_format as Project["netlistFormat"]) ?? null,
|
||||||
netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null,
|
netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -268,7 +269,7 @@ export interface UploadNetlistResult {
|
|||||||
path: string;
|
path: string;
|
||||||
parts: number;
|
parts: number;
|
||||||
nets: number;
|
nets: number;
|
||||||
format: "pads" | "edif";
|
format: "pads" | "edif" | "kicad_xml" | "kicad_sexp" | "kicad_sch";
|
||||||
sub_designs: EdifSubDesign[]; // empty for PADS / single-sub-design EDIF
|
sub_designs: EdifSubDesign[]; // empty for PADS / single-sub-design EDIF
|
||||||
// EDIF only: server-built designator→pins preview, same shape as the
|
// EDIF only: server-built designator→pins preview, same shape as the
|
||||||
// browser-side PADS parser produces. Empty list for PADS uploads (the
|
// browser-side PADS parser produces. Empty list for PADS uploads (the
|
||||||
@@ -294,7 +295,7 @@ export async function uploadNetlist(
|
|||||||
path: data.path as string,
|
path: data.path as string,
|
||||||
parts: data.parts as number,
|
parts: data.parts as number,
|
||||||
nets: data.nets as number,
|
nets: data.nets as number,
|
||||||
format: data.format as "pads" | "edif",
|
format: data.format as UploadNetlistResult["format"],
|
||||||
sub_designs: (data.sub_designs as EdifSubDesign[] | undefined) ?? [],
|
sub_designs: (data.sub_designs as EdifSubDesign[] | undefined) ?? [],
|
||||||
designator_pins:
|
designator_pins:
|
||||||
(data.designator_pins as NetlistPreviewDesignator[] | undefined) ?? [],
|
(data.designator_pins as NetlistPreviewDesignator[] | undefined) ?? [],
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export interface Project {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
collaborators?: string[];
|
collaborators?: string[];
|
||||||
creditsSpent?: number;
|
creditsSpent?: number;
|
||||||
|
totalCostUsd?: number | null;
|
||||||
pauseCheckpoint?: PauseCheckpoint | null;
|
pauseCheckpoint?: PauseCheckpoint | null;
|
||||||
pauseReason?: string | null;
|
pauseReason?: string | null;
|
||||||
bomColumns?: { reference: string; mpn: string } | null;
|
bomColumns?: { reference: string; mpn: string } | null;
|
||||||
@@ -243,9 +244,9 @@ export interface Project {
|
|||||||
lcscPayloads?: Record<string, LcscPayload> | null;
|
lcscPayloads?: Record<string, LcscPayload> | null;
|
||||||
componentMpns?: ComponentMpnBuckets | null;
|
componentMpns?: ComponentMpnBuckets | null;
|
||||||
pinscopeVersion?: string | null;
|
pinscopeVersion?: string | null;
|
||||||
// "pads" | "edif" — what kind of netlist file the user uploaded. null on
|
// "pads" | "edif" | "kicad_*" — netlist the user uploaded. null on legacy projects.
|
||||||
// projects predating EDIF support; treat null as PADS for rendering.
|
// projects predating EDIF support; treat null as PADS for rendering.
|
||||||
netlistFormat?: "pads" | "edif" | null;
|
netlistFormat?: "pads" | "edif" | "kicad_xml" | "kicad_sexp" | "kicad_sch" | null;
|
||||||
// Sub-design IDs (e.g. ["&0441"]) the user chose to include in the review.
|
// Sub-design IDs (e.g. ["&0441"]) the user chose to include in the review.
|
||||||
// null means "include every sub-design found in the file" — the default
|
// null means "include every sub-design found in the file" — the default
|
||||||
// for single-sub-design EDIFs and all PADS netlists.
|
// for single-sub-design EDIFs and all PADS netlists.
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ def test_extract_pdf_text_includes_page_markers(sample_pdf: Path):
|
|||||||
|
|
||||||
def test_vision_model_detection():
|
def test_vision_model_detection():
|
||||||
assert _is_vision_model("deepseek-v4-flash-vision-exp")
|
assert _is_vision_model("deepseek-v4-flash-vision-exp")
|
||||||
|
assert _is_vision_model("deepseek-flash")
|
||||||
|
assert _is_vision_model("deepseek-v4-flash")
|
||||||
assert not _is_vision_model("deepseek-v4-pro")
|
assert not _is_vision_model("deepseek-v4-pro")
|
||||||
assert not _is_vision_model("deepseek-v4-flash")
|
|
||||||
|
|
||||||
|
|
||||||
def test_messages_to_openai_pdf_becomes_text(sample_pdf: Path):
|
def test_messages_to_openai_pdf_becomes_text(sample_pdf: Path):
|
||||||
@@ -329,14 +330,17 @@ def test_factory_routes_deepseek(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_config_defaults_are_deepseek():
|
def test_config_defaults_are_deepseek():
|
||||||
assert settings.provider_default == "deepseek"
|
from backend.config import Settings
|
||||||
assert settings.model_for_stage("validation") == settings.model_validation_deepseek
|
|
||||||
assert "vision" in settings.model_for_stage("pintable")
|
assert Settings.model_fields["provider_default"].default == "deepseek"
|
||||||
|
assert Settings.model_fields["deepseek_model"].default == "deepseek-flash"
|
||||||
|
assert Settings.model_fields["model_pintable_deepseek"].default == "deepseek-flash"
|
||||||
|
assert Settings.model_fields["model_validation_deepseek"].default == "deepseek-flash"
|
||||||
assert settings.provider_for_stage("pintable") == "deepseek"
|
assert settings.provider_for_stage("pintable") == "deepseek"
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_pricing_positive():
|
def test_deepseek_pricing_positive():
|
||||||
cost = cost_for_entry({
|
pro = cost_for_entry({
|
||||||
"provider": "deepseek",
|
"provider": "deepseek",
|
||||||
"model": "deepseek-v4-pro",
|
"model": "deepseek-v4-pro",
|
||||||
"input_tokens": 1_000_000,
|
"input_tokens": 1_000_000,
|
||||||
@@ -344,5 +348,24 @@ def test_deepseek_pricing_positive():
|
|||||||
"cache_creation_input_tokens": 0,
|
"cache_creation_input_tokens": 0,
|
||||||
"cache_read_input_tokens": 0,
|
"cache_read_input_tokens": 0,
|
||||||
})
|
})
|
||||||
assert cost == pytest.approx(1.32)
|
assert pro == pytest.approx(1.32)
|
||||||
|
flash = cost_for_entry({
|
||||||
|
"provider": "deepseek",
|
||||||
|
"model": "deepseek-flash",
|
||||||
|
"input_tokens": 1_000_000,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
})
|
||||||
|
assert flash == pytest.approx(0.30)
|
||||||
|
cached = cost_for_entry({
|
||||||
|
"provider": "deepseek",
|
||||||
|
"model": "deepseek-flash",
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 1_000_000,
|
||||||
|
})
|
||||||
|
assert cached == pytest.approx(0.006)
|
||||||
assert "default" in PRICING["deepseek"]
|
assert "default" in PRICING["deepseek"]
|
||||||
|
assert "deepseek-flash" in PRICING["deepseek"]
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.pinscopex.parsers import detect_netlist_format, parse_netlist_any, validate_netlist
|
||||||
|
|
||||||
|
|
||||||
|
XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<export version="E">
|
||||||
|
<components>
|
||||||
|
<comp ref="U1">
|
||||||
|
<value>MSPM0G3507</value>
|
||||||
|
<footprint>Package_QFP:LQFP-48_7x7mm_P0.5mm</footprint>
|
||||||
|
<fields>
|
||||||
|
<field name="MPN">MSPM0G3507SPTR</field>
|
||||||
|
</fields>
|
||||||
|
</comp>
|
||||||
|
<comp ref="C1">
|
||||||
|
<value>100n</value>
|
||||||
|
<footprint>Capacitor_SMD:C_0603</footprint>
|
||||||
|
</comp>
|
||||||
|
<comp ref="R1">
|
||||||
|
<value>10k</value>
|
||||||
|
<footprint>Resistor_SMD:R_0603</footprint>
|
||||||
|
</comp>
|
||||||
|
</components>
|
||||||
|
<nets>
|
||||||
|
<net code="1" name="GND">
|
||||||
|
<node ref="U1" pin="8"/>
|
||||||
|
<node ref="C1" pin="2"/>
|
||||||
|
</net>
|
||||||
|
<net code="2" name="3V3">
|
||||||
|
<node ref="U1" pin="1"/>
|
||||||
|
<node ref="C1" pin="1"/>
|
||||||
|
<node ref="R1" pin="2"/>
|
||||||
|
</net>
|
||||||
|
<net code="3" name="I2C_SDA">
|
||||||
|
<node ref="U1" pin="12"/>
|
||||||
|
<node ref="R1" pin="1"/>
|
||||||
|
</net>
|
||||||
|
</nets>
|
||||||
|
</export>
|
||||||
|
"""
|
||||||
|
|
||||||
|
SEXP = """(export (version "E")
|
||||||
|
(components
|
||||||
|
(comp (ref "U1")
|
||||||
|
(value "MSPM0G3507")
|
||||||
|
(footprint "Package_QFP:LQFP-48")
|
||||||
|
(fields
|
||||||
|
(field (name "MPN") "MSPM0G3507SPTR")
|
||||||
|
(field (name "LCSC") "C12345")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(comp (ref "C1")
|
||||||
|
(value "100n")
|
||||||
|
(footprint "C_0603")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(nets
|
||||||
|
(net (code "1") (name "GND")
|
||||||
|
(node (ref "U1") (pin "8"))
|
||||||
|
(node (ref "C1") (pin "2"))
|
||||||
|
)
|
||||||
|
(net (code "2") (name "3V3")
|
||||||
|
(node (ref "U1") (pin "1"))
|
||||||
|
(node (ref "C1") (pin "1"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_kicad_xml():
|
||||||
|
assert detect_netlist_format(XML) == "kicad_xml"
|
||||||
|
assert detect_netlist_format(SEXP) == "kicad_sexp"
|
||||||
|
assert detect_netlist_format("(kicad_sch (version 20231120)") == "kicad_sch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_kicad_xml(tmp_path: Path):
|
||||||
|
p = tmp_path / "net.xml"
|
||||||
|
p.write_text(XML)
|
||||||
|
parts, nets, fmt = parse_netlist_any(p)
|
||||||
|
assert fmt == "kicad_xml"
|
||||||
|
assert parts["U1"].startswith("Package_QFP")
|
||||||
|
assert ("U1", "8") in nets["GND"]
|
||||||
|
assert ("C1", "1") in nets["3V3"]
|
||||||
|
assert ("R1", "1") in nets["I2C_SDA"]
|
||||||
|
assert validate_netlist(parts, nets) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_kicad_sexp_and_mpn_fields(tmp_path: Path):
|
||||||
|
p = tmp_path / "net.kicad_net"
|
||||||
|
p.write_text(SEXP)
|
||||||
|
parts, nets, fmt = parse_netlist_any(p)
|
||||||
|
assert fmt == "kicad_sexp"
|
||||||
|
assert ("U1", "1") in nets["3V3"]
|
||||||
|
from backend.pinscopex.parsers_kicad import kicad_part_fields
|
||||||
|
fields = kicad_part_fields(p)
|
||||||
|
assert fields["U1"]["mpn"] == "MSPM0G3507SPTR"
|
||||||
|
assert fields["U1"]["lcsc"] == "C12345"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kicad_mpn_fills_empty_bom(tmp_path: Path):
|
||||||
|
from backend.pinscopex.graph import build_graph
|
||||||
|
|
||||||
|
net = tmp_path / "net.xml"
|
||||||
|
net.write_text(XML)
|
||||||
|
bom = tmp_path / "bom.csv"
|
||||||
|
bom.write_text(
|
||||||
|
"Reference,Value,Footprint,Manufacturer Part Number\n"
|
||||||
|
"U1,MSPM0,,\nC1,100n,C_0603,\nR1,10k,R_0603,\n"
|
||||||
|
)
|
||||||
|
g = build_graph(
|
||||||
|
net, bom, tmp_path / "empty_ex", tmp_path / "empty_pat", tmp_path / "empty_mod",
|
||||||
|
)
|
||||||
|
assert g.components["U1"].mpn == "MSPM0G3507SPTR"
|
||||||
|
assert "GND" in g.nets
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Reopen a finished project and replace BOM/netlist without deleting it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.services import projects as proj_svc
|
||||||
|
from backend.services.storage import LocalStorageBackend
|
||||||
|
|
||||||
|
_BOM_V1 = (
|
||||||
|
b"Reference,Value,Manufacturer Part Number\n"
|
||||||
|
b"U1,MCU,STM32F103C8T6\n"
|
||||||
|
)
|
||||||
|
_BOM_V2 = (
|
||||||
|
b"Reference,Value,Manufacturer Part Number\n"
|
||||||
|
b"U1,MCU,STM32F103C8T6\n"
|
||||||
|
b"R1,10k,RC0603FR-0710KL\n"
|
||||||
|
)
|
||||||
|
_NETLIST = b"""*PADS-PCB*
|
||||||
|
*PART*
|
||||||
|
U1 LQFP48
|
||||||
|
*NET*
|
||||||
|
*SIGNAL* GND
|
||||||
|
U1.1
|
||||||
|
*END*
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _client(tmp_path) -> TestClient:
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
app.state.storage = LocalStorageBackend(tmp_path)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_then_replace_bom_and_netlist(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
meta = client.post("/api/projects", json={"name": "board"}).json()
|
||||||
|
pid = meta["id"]
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/projects/{pid}/upload/bom",
|
||||||
|
files={"file": ("bom.csv", _BOM_V1, "text/csv")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/projects/{pid}/upload/netlist",
|
||||||
|
files={"file": ("netlist.asc", _NETLIST, "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
storage = client.app.state.storage
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, "local", pid,
|
||||||
|
status="complete",
|
||||||
|
total_cost_usd=1.23,
|
||||||
|
summary={"ERROR": 1, "WARNING": 0, "INFO": 0, "total": 1},
|
||||||
|
)
|
||||||
|
prefix = f"users/local/projects/{pid}"
|
||||||
|
storage.write_json(f"{prefix}/report.json", {"findings": []})
|
||||||
|
storage.write_text(f"{prefix}/api_logs.jsonl", '{"cost_usd": 1.23}\n')
|
||||||
|
|
||||||
|
reopen = client.post(f"/api/projects/{pid}/reopen")
|
||||||
|
assert reopen.status_code == 200, reopen.text
|
||||||
|
body = reopen.json()
|
||||||
|
assert body["status"] == "draft"
|
||||||
|
assert body["id"] == pid
|
||||||
|
assert body["has_bom"] is True
|
||||||
|
assert body["has_netlist"] is True
|
||||||
|
assert body["total_cost_usd"] == 1.23
|
||||||
|
assert not storage.exists(f"{prefix}/report.json")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/projects/{pid}/upload/bom",
|
||||||
|
files={"file": ("bom.csv", _BOM_V2, "text/csv")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["components"] == 2
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/projects/{pid}/upload/netlist",
|
||||||
|
files={"file": ("netlist.asc", _NETLIST, "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
fresh = client.get(f"/api/projects/{pid}").json()
|
||||||
|
assert fresh["id"] == pid
|
||||||
|
assert fresh["status"] == "draft"
|
||||||
|
assert fresh["has_bom"] is True
|
||||||
|
assert fresh["has_netlist"] is True
|
||||||
|
assert "RC0603FR-0710KL" in (fresh.get("component_mpns") or {}).get("passive", [])
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from backend.pinscopex.models import (
|
||||||
|
Component,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
Net,
|
||||||
|
NetType,
|
||||||
|
PinConnection,
|
||||||
|
)
|
||||||
|
from backend.pinscopex.review_fingerprint import (
|
||||||
|
graph_ic_fingerprints,
|
||||||
|
skip_unchanged_ics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _g():
|
||||||
|
u1 = Component(
|
||||||
|
reference="U1", value="", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="IC1",
|
||||||
|
pins={"1": "3V3", "2": "SDA"},
|
||||||
|
)
|
||||||
|
r1 = Component(
|
||||||
|
reference="R1", value="4k7", footprint="",
|
||||||
|
component_type=ComponentType.RESISTOR, mpn="R",
|
||||||
|
pins={"1": "SDA", "2": "3V3"},
|
||||||
|
)
|
||||||
|
return DesignGraph(
|
||||||
|
components={"U1": u1, "R1": r1},
|
||||||
|
nets={
|
||||||
|
"3V3": Net(name="3V3", net_type=NetType.POWER, pins=[
|
||||||
|
PinConnection(component_ref="U1", pin_number="1"),
|
||||||
|
PinConnection(component_ref="R1", pin_number="2"),
|
||||||
|
]),
|
||||||
|
"SDA": Net(name="SDA", net_type=NetType.SIGNAL, pins=[
|
||||||
|
PinConnection(component_ref="U1", pin_number="2"),
|
||||||
|
PinConnection(component_ref="R1", pin_number="1"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fingerprint_changes_when_neighbor_added():
|
||||||
|
g = _g()
|
||||||
|
fp1 = graph_ic_fingerprints(g)["U1"]
|
||||||
|
c2 = Component(
|
||||||
|
reference="C1", value="100n", footprint="",
|
||||||
|
component_type=ComponentType.CAPACITOR, mpn="C",
|
||||||
|
pins={"1": "3V3", "2": "GND"},
|
||||||
|
)
|
||||||
|
g.components["C1"] = c2
|
||||||
|
g.nets["3V3"].pins.append(PinConnection(component_ref="C1", pin_number="1"))
|
||||||
|
fp2 = graph_ic_fingerprints(g)["U1"]
|
||||||
|
assert fp1 != fp2
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_unchanged_drops_changed_refs():
|
||||||
|
prev = {"U1": "aaa", "U2": "bbb"}
|
||||||
|
cur = {"U1": "aaa", "U2": "ccc"}
|
||||||
|
skip = skip_unchanged_ics({"U1", "U2"}, prev, cur)
|
||||||
|
assert skip == {"U1"}
|
||||||
Reference in New Issue
Block a user