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:
@@ -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)
|
||||||
@@ -35,6 +35,12 @@ def extract_pdf_document_text(pdf_path: Path | str, *, max_chars: int) -> str:
|
|||||||
parts.append(f"--- page {i} ---\n{body}")
|
parts.append(f"--- page {i} ---\n{body}")
|
||||||
blob = "\n\n".join(parts)
|
blob = "\n\n".join(parts)
|
||||||
if len(blob) > max_chars:
|
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]"
|
blob = blob[:max_chars] + "\n\n[truncated: remaining pages omitted]"
|
||||||
return blob
|
return blob
|
||||||
|
|
||||||
|
|||||||
@@ -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}]"
|
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(
|
def get_pintable(
|
||||||
graph: DesignGraph,
|
graph: DesignGraph,
|
||||||
constraints_map: ConstraintsMap,
|
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 = {
|
GET_PINTABLE_SCHEMA = {
|
||||||
"name": "get_pintable",
|
"name": "get_pintable",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -727,6 +857,7 @@ GET_DATASHEET_EXCERPT_SCHEMA = {
|
|||||||
GRAPH_TOOLS = [
|
GRAPH_TOOLS = [
|
||||||
FIND_CONNECTED_COMPONENTS_SCHEMA,
|
FIND_CONNECTED_COMPONENTS_SCHEMA,
|
||||||
GET_NET_FOR_PIN_SCHEMA,
|
GET_NET_FOR_PIN_SCHEMA,
|
||||||
|
SHORTEST_PATH_SCHEMA,
|
||||||
GET_PINTABLE_SCHEMA,
|
GET_PINTABLE_SCHEMA,
|
||||||
GET_DATASHEET_EXCERPT_SCHEMA,
|
GET_DATASHEET_EXCERPT_SCHEMA,
|
||||||
]
|
]
|
||||||
@@ -770,6 +901,17 @@ def execute_tool(
|
|||||||
),
|
),
|
||||||
None,
|
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":
|
if tool_name == "get_pintable":
|
||||||
return (
|
return (
|
||||||
get_pintable(
|
get_pintable(
|
||||||
|
|||||||
@@ -104,3 +104,27 @@ class ApiLogger:
|
|||||||
|
|
||||||
key = f"{project_prefix(user_id, project_id)}/api_logs.jsonl"
|
key = f"{project_prefix(user_id, project_id)}/api_logs.jsonl"
|
||||||
storage.write_text(key, text)
|
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
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ def render_pdf_page_jpegs(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
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)
|
matrix = fitz.Matrix(zoom, zoom)
|
||||||
for i in page_indices:
|
for i in page_indices:
|
||||||
if i < 0 or i >= len(doc):
|
if i < 0 or i >= len(doc):
|
||||||
|
|||||||
@@ -839,10 +839,30 @@ async def _stage_ic_extraction(ctx: PipelineContext) -> None:
|
|||||||
api_logger=private,
|
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"
|
extracted_key = f"{ctx.ws.prefix}/extracted/{safe}.json"
|
||||||
ctx.storage.upload_from_local(json_path, extracted_key)
|
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)
|
# Upload source datasheet PDF to library (content-addressed)
|
||||||
store_datasheet(ctx.storage, pdf_path, mpn)
|
store_datasheet(ctx.storage, pdf_path, mpn)
|
||||||
|
|||||||
@@ -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ù”.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"component_count": 37,
|
||||||
|
"net_count": 36,
|
||||||
|
"graph_ok": true
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user