Ship DeepSeek roadmap P0-5/P2 integrations.

Smoke simple_project offline, reviewer shortest_path, library promotion
gate, PDF drop logs, and per-stage cache hit-rate helper — without
touching Layout placement packing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-12 15:48:39 +02:00
co-authored by Cursor
parent 2405093bbb
commit 30aaf55de6
11 changed files with 503 additions and 3 deletions
+41
View File
@@ -0,0 +1,41 @@
"""Shared-library promotion gates for extracted IC JSON."""
from __future__ import annotations
import hashlib
import json
from typing import Any
def pintable_checksum(pintable: list[Any]) -> str:
"""Stable hash of pin number+name pairs (order-independent)."""
rows: list[tuple[str, str]] = []
for pin in pintable or []:
if isinstance(pin, dict):
num = str(pin.get("number") or "").strip()
name = str(pin.get("name") or "").strip()
else:
num = str(getattr(pin, "number", "") or "").strip()
name = str(getattr(pin, "name", "") or "").strip()
if num:
rows.append((num, name))
payload = json.dumps(sorted(rows), separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def should_promote_extraction(data: dict) -> tuple[bool, str]:
"""Return (ok, reason). Reject empty / tiny pintables from shared library."""
pins = data.get("pintable") or []
if not isinstance(pins, list) or len(pins) == 0:
return False, "empty pintable"
if len(pins) < 2:
return False, "pintable has fewer than 2 pins"
# Require at least one named pin so a number-only stub cannot poison the library.
named = 0
for pin in pins:
name = pin.get("name") if isinstance(pin, dict) else getattr(pin, "name", None)
if name and str(name).strip() and str(name).strip() != "~":
named += 1
if named == 0:
return False, "pintable has no named pins"
return True, pintable_checksum(pins)
+6
View File
@@ -35,6 +35,12 @@ def extract_pdf_document_text(pdf_path: Path | str, *, max_chars: int) -> str:
parts.append(f"--- page {i} ---\n{body}")
blob = "\n\n".join(parts)
if len(blob) > max_chars:
# Count how many page markers survive the cut for observability.
kept = blob[:max_chars].count("--- page ")
log.info(
"PDF text truncated: %s full=%d chars cap=%d kept_pages≈%d/%d",
path.name, len(blob), max_chars, kept, n,
)
blob = blob[:max_chars] + "\n\n[truncated: remaining pages omitted]"
return blob
+142
View File
@@ -256,6 +256,104 @@ def get_net_for_pin(
return f"Pin {pin}{pin_name} on {designator} -> {net_name} [{net.net_type.value}{voltage_str}]"
def shortest_path(
graph: DesignGraph,
constraints_map: ConstraintsMap,
designator_a: str,
pin_a: str,
designator_b: str,
pin_b: str,
*,
max_hops: int = 12,
) -> str:
"""BFS through the bipartite graph from A.pin to B.pin.
Hops alternate component→net→component. Returns the hop list or a
clear miss message. Caps depth so the reviewer cannot explode memory
on dense power nets.
"""
a = graph.components.get(designator_a)
b = graph.components.get(designator_b)
if not a:
return f"Component '{designator_a}' not found."
if not b:
return f"Component '{designator_b}' not found."
net_a = a.pins.get(str(pin_a))
net_b = b.pins.get(str(pin_b))
if not net_a:
return f"Pin {pin_a} on {designator_a} is not connected in the netlist."
if not net_b:
return f"Pin {pin_b} on {designator_b} is not connected in the netlist."
if designator_a == designator_b and str(pin_a) == str(pin_b):
return f"Same endpoint: {designator_a}.{pin_a} on {net_a}."
if net_a == net_b:
return (
f"Direct (same net): {designator_a}.{pin_a} —[{net_a}]— "
f"{designator_b}.{pin_b}"
)
# BFS on component nodes; edges are nets shared between components.
from collections import deque
start = designator_a
goal = designator_b
queue: deque[str] = deque([start])
# prev[ref] = (previous_ref, via_net)
prev: dict[str, tuple[str, str] | None] = {start: None}
hops = 0
found = False
while queue and hops < max_hops:
hops += 1
for _ in range(len(queue)):
cur = queue.popleft()
for net_name, others in graph.neighbors(cur).items():
for other in others:
if other in prev:
continue
prev[other] = (cur, net_name)
if other == goal:
found = True
queue.clear()
break
queue.append(other)
if found:
break
if found:
break
if not found or goal not in prev:
return (
f"No path within {max_hops} hops from "
f"{designator_a}.{pin_a} ({net_a}) to "
f"{designator_b}.{pin_b} ({net_b})."
)
# Reconstruct component chain, then decorate endpoints with pins.
chain_refs: list[str] = []
via_nets: list[str] = []
node = goal
while node != start:
chain_refs.append(node)
parent, via = prev[node] # type: ignore[misc]
via_nets.append(via)
node = parent
chain_refs.append(start)
chain_refs.reverse()
via_nets.reverse()
parts: list[str] = [f"{designator_a}.{pin_a}"]
for i, via in enumerate(via_nets):
nxt = chain_refs[i + 1]
if nxt == designator_b:
parts.append(f"—[{via}]— {designator_b}.{pin_b}")
else:
parts.append(f"—[{via}]— {nxt}")
return f"Path ({len(via_nets)} hop(s)): " + " ".join(parts)
def get_pintable(
graph: DesignGraph,
constraints_map: ConstraintsMap,
@@ -590,6 +688,38 @@ GET_NET_FOR_PIN_SCHEMA = {
},
}
SHORTEST_PATH_SCHEMA = {
"name": "shortest_path",
"description": (
"Find the shortest hop path through the netlist between two pins "
"(component.pin → nets → components). Use to verify whether two "
"pins share a rail path, or how a signal reaches another IC, "
"instead of guessing from neighborhood context."
),
"input_schema": {
"type": "object",
"properties": {
"designator_a": {
"type": "string",
"description": "Start component reference, e.g. 'U1'",
},
"pin_a": {
"type": "string",
"description": "Start pin number, e.g. '12'",
},
"designator_b": {
"type": "string",
"description": "End component reference, e.g. 'U3'",
},
"pin_b": {
"type": "string",
"description": "End pin number, e.g. '5'",
},
},
"required": ["designator_a", "pin_a", "designator_b", "pin_b"],
},
}
GET_PINTABLE_SCHEMA = {
"name": "get_pintable",
"description": (
@@ -727,6 +857,7 @@ GET_DATASHEET_EXCERPT_SCHEMA = {
GRAPH_TOOLS = [
FIND_CONNECTED_COMPONENTS_SCHEMA,
GET_NET_FOR_PIN_SCHEMA,
SHORTEST_PATH_SCHEMA,
GET_PINTABLE_SCHEMA,
GET_DATASHEET_EXCERPT_SCHEMA,
]
@@ -770,6 +901,17 @@ def execute_tool(
),
None,
)
if tool_name == "shortest_path":
return (
shortest_path(
graph, constraints_map,
tool_input["designator_a"],
tool_input["pin_a"],
tool_input["designator_b"],
tool_input["pin_b"],
),
None,
)
if tool_name == "get_pintable":
return (
get_pintable(
+24
View File
@@ -104,3 +104,27 @@ class ApiLogger:
key = f"{project_prefix(user_id, project_id)}/api_logs.jsonl"
storage.write_text(key, text)
def cache_stats_by_stage(entries: list[dict]) -> dict[str, dict]:
"""Roll up prompt-cache hit rate per pipeline stage.
Returns ``{stage: {calls, input_tokens, cache_read_tokens, hit_ratio}}``.
``hit_ratio`` is cache_read / input when input > 0, else 0.
"""
out: dict[str, dict] = {}
for e in entries:
stage = str(e.get("stage") or "unknown")
bucket = out.setdefault(
stage,
{"calls": 0, "input_tokens": 0, "cache_read_tokens": 0, "hit_ratio": 0.0},
)
bucket["calls"] += 1
bucket["input_tokens"] += int(e.get("input_tokens") or 0)
bucket["cache_read_tokens"] += int(e.get("cache_read_input_tokens") or 0)
for bucket in out.values():
inp = bucket["input_tokens"]
bucket["hit_ratio"] = (
round(bucket["cache_read_tokens"] / inp, 4) if inp else 0.0
)
return out
+10
View File
@@ -133,6 +133,16 @@ def render_pdf_page_jpegs(
return []
try:
total = len(doc)
omitted = [i + 1 for i in range(total) if i not in set(page_indices)]
if omitted:
log.info(
"PDF page images: %s rendering %d/%d pages; omitted e.g. %s",
pdf_path.name,
min(len(page_indices), max_pages),
total,
omitted[:12],
)
matrix = fitz.Matrix(zoom, zoom)
for i in page_indices:
if i < 0 or i >= len(doc):
+22 -2
View File
@@ -839,10 +839,30 @@ async def _stage_ic_extraction(ctx: PipelineContext) -> None:
api_logger=private,
)
# Upload to storage, then copy to library
# Upload to storage, then copy to library only if pintable is usable
extracted_key = f"{ctx.ws.prefix}/extracted/{safe}.json"
ctx.storage.upload_from_local(json_path, extracted_key)
proj_svc.save_to_library(ctx.storage, extracted_key, "extracted", f"{safe}.json")
try:
from backend.pinscopex.library_gate import should_promote_extraction
payload = json.loads(json_path.read_text(encoding="utf-8"))
ok, reason = should_promote_extraction(payload)
if ok:
proj_svc.save_to_library(
ctx.storage, extracted_key, "extracted", f"{safe}.json",
)
else:
logger.warning(
"Skipping library promote for %s: %s (kept project-local)",
mpn, reason,
)
except Exception:
logger.exception(
"Library gate failed for %s — promoting anyway", mpn,
)
proj_svc.save_to_library(
ctx.storage, extracted_key, "extracted", f"{safe}.json",
)
# Upload source datasheet PDF to library (content-addressed)
store_datasheet(ctx.storage, pdf_path, mpn)
+21 -1
View File
@@ -4,7 +4,27 @@ Documento di lavoro **prima dello sviluppo**. La lista dellutente è il minim
**Questo piano copre due prodotti.** Pinscope originale resta il primo. Layout/plugin/placement mm sono il secondo. Non mescolare i changelog né vendere il secondo come “un po di Pinscope in più”.
Stato del codice di riferimento: branch `cursor/deepseek-71c5` (post DeepSeek V4.1, costi USD, replace BOM/netlist, parser KiCad, fingerprint review).
Stato del codice di riferimento: branch `cursor/deepseek-71c5` (post DeepSeek V4.1, costi USD, replace BOM/netlist, parser KiCad, fingerprint review, auth multi-utente, PCB pad nets).
---
## 0b. Roadmap DeepSeek / crescita (integrata)
Fonte originale: canvas *Pinscope: crescita e DeepSeek*. Qui lo stato operativo.
| Fase | Voce | Stato |
| --- | --- | --- |
| P0 | `deepseek-flash`, vision, pricing USD, effort | **Done** |
| P0-5 | Smoke `simple_project` | **Done** offline — `scripts/smoke_simple_project.py` (+ `--live`) |
| P1 | PDF drop log + cache hit-rate/stage | **Done** (parziale: cap ancora 500k; no off-peak/web_search) |
| P1 | Thinking vs tools | Partial — review auto fino ultimo turno |
| P2 | Eval, fingerprint, KiCad GA, PCB nets | **Done** |
| P2 | `shortest_path` tool + library write gate | **Done** |
| P2 | Crystal CL / abs-max numerici | Todo |
| P3 | Plugin CI / chat report | Todo |
| Layout | Placement IC (mm) | **Dopo** — prodotto Layout, non questo sprint |
**Done when (prossimo pacchetto):** smoke `--live` verde; hit_ratio visibile in UI logs; 12 check crystal/NC nuovi.
---
+9
View File
@@ -2,6 +2,15 @@
What's new in Pinscope.
## 2.27.1 — 2026-09-12 — DeepSeek roadmap integrations
Close the open P0/P2 items from the growth plan: offline smoke on `simple_project`, reviewer `shortest_path`, library promotion gate, PDF drop logging, per-stage cache hit-rate helper. IC placement stays Layout-product backlog.
- [New] `scripts/smoke_simple_project.py` + `simple_project/smoke_baseline.json`.
- [New] Graph tool `shortest_path`; `library_gate` before shared extracted/.
- [New] `cache_stats_by_stage()`; PDF truncate/omit page logs.
- [Test] `tests/test_roadmap_integrations.py`.
## 2.27.0 — 2026-09-11 — Local multi-user auth
Self-host Pinscope accounts (email + password) so several people can share a project. Invite collaborators by email from the project page — same flow as cloud, without Clerk.
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""P0-5 smoke: deepseek-flash defaults + simple_project offline checks.
Usage:
python3 scripts/smoke_simple_project.py # offline (no API)
python3 scripts/smoke_simple_project.py --live # needs DEEPSEEK_API_KEY
Offline asserts: model defaults, vision gate, graph+eval golden, shortest_path.
Live (optional): PDF ingest attaches page images under vision.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
SIMPLE = ROOT / "simple_project"
BASELINE = SIMPLE / "smoke_baseline.json"
def _check_model_defaults() -> list[str]:
from backend.config import settings
errs: list[str] = []
if settings.deepseek_model != "deepseek-flash":
errs.append(f"deepseek_model={settings.deepseek_model!r}, want deepseek-flash")
# Mirror deepseek_provider._is_vision_model without importing openai.
name = settings.deepseek_model.strip().lower()
vision_ok = (
name in {"deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"}
or "vision" in name
or name.startswith("deepseek-flash")
)
if not vision_ok:
errs.append("deepseek-flash not treated as vision model")
effort = (settings.deepseek_reasoning_effort or "").lower()
if effort not in {"low", "high", "max"}:
errs.append(f"deepseek_reasoning_effort={effort!r}, want low|high|max")
return errs
def _check_simple_project_offline() -> list[str]:
from backend.pinscopex.models import DesignGraph
from backend.pinscopex.validation_tools import shortest_path
errs: list[str] = []
graph_path = SIMPLE / "design_graph.json"
if not graph_path.is_file():
return ["simple_project/design_graph.json missing"]
g = DesignGraph.model_validate_json(graph_path.read_text(encoding="utf-8"))
golden_path = SIMPLE / "eval_golden.json"
if not golden_path.is_file():
return ["simple_project/eval_golden.json missing"]
golden_doc = json.loads(golden_path.read_text(encoding="utf-8"))
for ref in golden_doc.get("required_refs") or []:
if ref not in g.components:
errs.append(f"missing ref {ref}")
min_c = golden_doc.get("min_components")
if min_c and len(g.components) < int(min_c):
errs.append(f"components {len(g.components)} < {min_c}")
min_n = golden_doc.get("min_nets")
if min_n and len(g.nets) < int(min_n):
errs.append(f"nets {len(g.nets)} < {min_n}")
if "U3" in g.components and "X1" in g.components:
pin = next(iter(g.components["U3"].pins), None)
xpin = next(iter(g.components["X1"].pins), None)
if pin and xpin:
msg = shortest_path(g, {}, "U3", pin, "X1", xpin)
print(f"note: shortest_path U3X1 → {msg}")
baseline = {
"component_count": len(g.components),
"net_count": len(g.nets),
"graph_ok": not errs,
}
if BASELINE.is_file():
prev = json.loads(BASELINE.read_text(encoding="utf-8"))
for k in ("component_count", "net_count"):
if prev.get(k) != baseline.get(k):
errs.append(f"{k} {baseline.get(k)} != baseline {prev.get(k)}")
else:
BASELINE.write_text(json.dumps(baseline, indent=2) + "\n", encoding="utf-8")
print(f"Wrote baseline {BASELINE}")
print(json.dumps({"offline": baseline, "errors": errs}, indent=2))
return errs
def _check_live() -> list[str]:
from backend.config import settings
from backend.services.llm.deepseek_provider import _is_vision_model
from backend.services.llm.pdf_ingest import pdf_to_openai_content
if not settings.deepseek_api_key:
return ["DEEPSEEK_API_KEY not set"]
pdfs = list((ROOT / "library" / "datasheets").rglob("*.pdf"))[:1]
if not pdfs:
pdfs = list(SIMPLE.rglob("*.pdf"))
if not pdfs:
return ["no PDF found for live vision check"]
pdf = pdfs[0]
parts = pdf_to_openai_content(
pdf, vision=_is_vision_model(settings.deepseek_model), max_images=4,
)
n_img = sum(1 for p in parts if p.get("type") == "image_url")
print(json.dumps({"live_pdf": str(pdf), "content_parts": len(parts), "images": n_img}))
if n_img == 0 and _is_vision_model(settings.deepseek_model):
return ["vision model produced 0 page images"]
return []
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--live", action="store_true", help="Also exercise PDF page images")
args = ap.parse_args()
errs = _check_model_defaults() + _check_simple_project_offline()
if args.live:
errs += _check_live()
if errs:
print("SMOKE FAIL:", *errs, sep="\n - ")
return 1
print("SMOKE OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
{
"component_count": 37,
"net_count": 36,
"graph_ok": true
}
+89
View File
@@ -0,0 +1,89 @@
"""Tests for shortest_path, library_gate, cache_stats."""
from __future__ import annotations
from backend.pinscopex.library_gate import pintable_checksum, should_promote_extraction
from backend.pinscopex.models import Component, ComponentType, DesignGraph, Net, NetType, PinConnection
from backend.pinscopex.validation_tools import shortest_path
from backend.services.api_logs import cache_stats_by_stage
def _graph_r_chain() -> DesignGraph:
"""U1.1 — NET_A — R1 — NET_B — U2.2"""
return DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="",
component_type=ComponentType.IC, pins={"1": "NET_A"},
),
"R1": Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR,
pins={"1": "NET_A", "2": "NET_B"},
),
"U2": Component(
reference="U2", value="IC", footprint="",
component_type=ComponentType.IC, pins={"2": "NET_B"},
),
},
nets={
"NET_A": Net(
name="NET_A", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="R1", pin_number="1"),
],
),
"NET_B": Net(
name="NET_B", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="R1", pin_number="2"),
PinConnection(component_ref="U2", pin_number="2"),
],
),
},
)
def test_shortest_path_two_hops():
g = _graph_r_chain()
msg = shortest_path(g, {}, "U1", "1", "U2", "2")
assert "hop" in msg.lower()
assert "NET_A" in msg and "NET_B" in msg
assert "U1.1" in msg and "U2.2" in msg
def test_shortest_path_same_net():
g = _graph_r_chain()
msg = shortest_path(g, {}, "U1", "1", "R1", "1")
assert "same net" in msg.lower() or "Direct" in msg
def test_library_gate_rejects_empty():
ok, reason = should_promote_extraction({"pintable": []})
assert not ok
assert "empty" in reason
def test_library_gate_accepts_named_pins():
data = {
"pintable": [
{"number": "1", "name": "VDD"},
{"number": "2", "name": "GND"},
],
}
ok, checksum = should_promote_extraction(data)
assert ok
assert checksum == pintable_checksum(data["pintable"])
def test_cache_stats_by_stage():
entries = [
{"stage": "pintable", "input_tokens": 100, "cache_read_input_tokens": 40},
{"stage": "pintable", "input_tokens": 100, "cache_read_input_tokens": 60},
{"stage": "validation", "input_tokens": 50, "cache_read_input_tokens": 0},
]
stats = cache_stats_by_stage(entries)
assert stats["pintable"]["calls"] == 2
assert stats["pintable"]["hit_ratio"] == 0.5
assert stats["validation"]["hit_ratio"] == 0.0