From 30aaf55de6a3af81ca9d70a4c44e98ee04eec635 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sat, 12 Sep 2026 15:48:39 +0200 Subject: [PATCH] Ship DeepSeek roadmap P0-5/P2 integrations. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/pinscopex/library_gate.py | 41 ++++++++ backend/pinscopex/pdf_text.py | 6 ++ backend/pinscopex/validation_tools.py | 142 ++++++++++++++++++++++++++ backend/services/api_logs.py | 24 +++++ backend/services/llm/pdf_ingest.py | 10 ++ backend/services/pipeline.py | 24 ++++- docs/piano-implementazione.md | 22 +++- frontend/content/changelog.md | 9 ++ scripts/smoke_simple_project.py | 134 ++++++++++++++++++++++++ simple_project/smoke_baseline.json | 5 + tests/test_roadmap_integrations.py | 89 ++++++++++++++++ 11 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 backend/pinscopex/library_gate.py create mode 100644 scripts/smoke_simple_project.py create mode 100644 simple_project/smoke_baseline.json create mode 100644 tests/test_roadmap_integrations.py diff --git a/backend/pinscopex/library_gate.py b/backend/pinscopex/library_gate.py new file mode 100644 index 0000000..f5bb51c --- /dev/null +++ b/backend/pinscopex/library_gate.py @@ -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) diff --git a/backend/pinscopex/pdf_text.py b/backend/pinscopex/pdf_text.py index e91cad3..863d3bb 100644 --- a/backend/pinscopex/pdf_text.py +++ b/backend/pinscopex/pdf_text.py @@ -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 diff --git a/backend/pinscopex/validation_tools.py b/backend/pinscopex/validation_tools.py index a1a7455..2182f38 100644 --- a/backend/pinscopex/validation_tools.py +++ b/backend/pinscopex/validation_tools.py @@ -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( diff --git a/backend/services/api_logs.py b/backend/services/api_logs.py index d5b5223..8a7a986 100644 --- a/backend/services/api_logs.py +++ b/backend/services/api_logs.py @@ -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 diff --git a/backend/services/llm/pdf_ingest.py b/backend/services/llm/pdf_ingest.py index 52766ab..60571c6 100644 --- a/backend/services/llm/pdf_ingest.py +++ b/backend/services/llm/pdf_ingest.py @@ -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): diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index 007c596..c865fc4 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -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) diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md index 208c148..2b8f77c 100644 --- a/docs/piano-implementazione.md +++ b/docs/piano-implementazione.md @@ -4,7 +4,27 @@ Documento di lavoro **prima dello sviluppo**. La lista dell’utente è 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; 1–2 check crystal/NC nuovi. --- diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 06fa377..74745a8 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -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. diff --git a/scripts/smoke_simple_project.py b/scripts/smoke_simple_project.py new file mode 100644 index 0000000..12e986f --- /dev/null +++ b/scripts/smoke_simple_project.py @@ -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 U3–X1 → {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()) diff --git a/simple_project/smoke_baseline.json b/simple_project/smoke_baseline.json new file mode 100644 index 0000000..bb3e7c7 --- /dev/null +++ b/simple_project/smoke_baseline.json @@ -0,0 +1,5 @@ +{ + "component_count": 37, + "net_count": 36, + "graph_ok": true +} diff --git a/tests/test_roadmap_integrations.py b/tests/test_roadmap_integrations.py new file mode 100644 index 0000000..2640a8e --- /dev/null +++ b/tests/test_roadmap_integrations.py @@ -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