Adapt Pinscope to DeepSeek, auto datasheets, and a shared library.

Based on manvalan/pinscope main. Default LLM is DeepSeek with local
skills and PDF ingest. Datasheets are fetched from LCSC/TI, stored in
the component library, and review extracts abs-max with a deeper
checklist. Adds scripts/update-pinscope.sh for the production host.
This commit is contained in:
Cursor Agent
2026-08-27 23:06:23 +00:00
parent ab1c5b081c
commit 48246f31bd
53 changed files with 3005 additions and 314 deletions
+14 -18
View File
@@ -30,15 +30,15 @@ from backend.services.llm.pricing import PRICING
def restore_settings():
"""Snapshot every per-stage routing field; restore after the test."""
fields = [
"anthropic_model", "gemini_model",
"anthropic_model", "gemini_model", "deepseek_model",
"provider_default", "provider_validation",
"provider_pintable", "provider_pattern", "provider_specs",
"provider_auto_resolve",
"model_validation", "model_validation_gemini",
"model_pintable", "model_pintable_gemini",
"model_pattern", "model_pattern_gemini",
"model_specs", "model_specs_gemini",
"model_auto_resolve", "model_auto_resolve_gemini",
"model_validation", "model_validation_gemini", "model_validation_deepseek",
"model_pintable", "model_pintable_gemini", "model_pintable_deepseek",
"model_pattern", "model_pattern_gemini", "model_pattern_deepseek",
"model_specs", "model_specs_gemini", "model_specs_deepseek",
"model_auto_resolve", "model_auto_resolve_gemini", "model_auto_resolve_deepseek",
]
snapshot = {f: getattr(settings, f) for f in fields if hasattr(settings, f)}
yield
@@ -67,25 +67,21 @@ def test_review_cost_changes_with_validation_model(restore_settings):
def test_review_cost_changes_with_validation_provider(restore_settings):
"""Flipping PROVIDER_VALIDATION between anthropic and gemini must
"""Flipping PROVIDER_VALIDATION between deepseek and anthropic must
swap the rate table the estimator pulls from."""
settings.provider_validation = "deepseek"
settings.model_validation_deepseek = "deepseek-v4-pro"
deepseek_cost = estimate_stage_cost_usd("review")
settings.provider_validation = "anthropic"
settings.model_validation = "claude-sonnet-4-6"
anthropic_cost = estimate_stage_cost_usd("review")
settings.provider_validation = "gemini"
settings.gemini_model = "gemini-3.1-pro-preview"
settings.model_validation_gemini = "" # fall back to gemini_model
gemini_cost = estimate_stage_cost_usd("review")
# Both > 0 and they're different — the test doesn't lock direction
# because the cache-read multiplier asymmetry between providers
# could legitimately swing it either way as the rate tables evolve.
assert deepseek_cost > 0
assert anthropic_cost > 0
assert gemini_cost > 0
assert abs(anthropic_cost - gemini_cost) > 0.01, (
assert abs(deepseek_cost - anthropic_cost) > 0.01, (
f"expected materially different costs, got "
f"anthropic={anthropic_cost!r} gemini={gemini_cost!r}"
f"deepseek={deepseek_cost!r} anthropic={anthropic_cost!r}"
)
+121
View File
@@ -0,0 +1,121 @@
"""Datasheet auto-finder: MPN matching, LCSC pick, TI slugs, routing."""
from __future__ import annotations
import asyncio
from backend.services.datasheet_finder import (
DatasheetHit,
_pick_lcsc_product,
_ti_slugs,
find_datasheet,
mpn_matches,
)
def test_mpn_matches_exact_and_packing():
assert mpn_matches("CH340E", "CH340E")
assert mpn_matches("SPX3819M5-L-3-3", "SPX3819M5-L-3-3/TR")
assert mpn_matches("MSPM0G3507SPTR", "MSPM0G3507")
assert mpn_matches("MSPM0G3507", "MSPM0G3507SPTR")
# Variant letter is a different die — must not match
assert not mpn_matches("CH340", "CH340E")
assert not mpn_matches("CH340E", "CH340G")
assert not mpn_matches("TLV9062", "TLV9002")
def test_pick_lcsc_prefers_exact_model():
products = [
{"productModel": "TPSPX3819M5-L-3-3", "pdfUrl": "http://a.pdf"},
{"productModel": "SPX3819M5-L-3-3", "pdfUrl": "http://b.pdf"},
{"productModel": "SPX3819M5-L-3-3/TR", "pdfUrl": "http://c.pdf"},
]
picked = _pick_lcsc_product("SPX3819M5-L-3-3", products)
assert picked is not None
assert picked["pdfUrl"] == "http://b.pdf"
def test_pick_lcsc_packing_fallback():
products = [
{"productModel": "CH340E/TR", "pdfUrl": "http://e.pdf"},
]
picked = _pick_lcsc_product("CH340E", products)
assert picked is not None
assert picked["pdfUrl"] == "http://e.pdf"
def test_pick_lcsc_rejects_unrelated():
products = [
{"productModel": "CH340G", "pdfUrl": "http://g.pdf"},
{"productModel": "USB3300", "pdfUrl": "http://u.pdf"},
]
assert _pick_lcsc_product("CH340E", products) is None
def test_ti_slugs_include_family():
slugs = _ti_slugs("MSPM0G3507SPTR")
assert slugs[0] == "mspm0g3507sptr"
assert "mspm0g3507" in slugs
# Must not clip "sptr" as if it were "...sp" + "tr".
assert "mspm0g3507sp" not in slugs
assert "mspm0g3507s" not in slugs
def test_find_datasheet_uses_lcsc_then_skips_empty(monkeypatch):
async def fake_lcsc(mpn, lcsc_id=None):
return DatasheetHit(
mpn, pdf_bytes=b"%PDF-" + b"x" * 8000, url="https://datasheet.lcsc.com/x.pdf",
source="lcsc",
)
monkeypatch.setattr(
"backend.services.datasheet_finder._from_lcsc", fake_lcsc,
)
async def boom(mpn):
raise AssertionError("later sources should not run")
monkeypatch.setattr("backend.services.datasheet_finder._from_ti", boom)
monkeypatch.setattr("backend.services.datasheet_finder._from_digikey", boom)
hit = asyncio.run(find_datasheet("CH340E"))
assert hit.ok
assert hit.source == "lcsc"
assert hit.pdf_bytes.startswith(b"%PDF-")
def test_find_datasheet_falls_through_to_ti(monkeypatch):
async def miss_lcsc(mpn, lcsc_id=None):
return None
async def hit_ti(mpn):
return DatasheetHit(
mpn, pdf_bytes=b"%PDF-" + b"t" * 8000,
url="https://www.ti.com/lit/ds/symlink/mspm0g3507.pdf",
source="ti",
)
monkeypatch.setattr("backend.services.datasheet_finder._from_lcsc", miss_lcsc)
monkeypatch.setattr("backend.services.datasheet_finder._from_ti", hit_ti)
async def boom(mpn):
raise AssertionError("digikey should not run")
monkeypatch.setattr("backend.services.datasheet_finder._from_digikey", boom)
hit = asyncio.run(find_datasheet("MSPM0G3507SPTR"))
assert hit.ok
assert hit.source == "ti"
def test_find_datasheet_all_miss(monkeypatch):
async def miss(*args, **kwargs):
return None
monkeypatch.setattr("backend.services.datasheet_finder._from_lcsc", miss)
monkeypatch.setattr("backend.services.datasheet_finder._from_ti", miss)
monkeypatch.setattr("backend.services.datasheet_finder._from_digikey", miss)
hit = asyncio.run(find_datasheet("NOTAREALPART123"))
assert not hit.ok
assert "No datasheet found" in (hit.error or "")
+184
View File
@@ -0,0 +1,184 @@
"""DeepSeek provider: PDF ingest, OpenAI message translation, routing, pricing."""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from backend.config import settings
from backend.services.llm.pdf_ingest import extract_pdf_text, make_text_pdf
from backend.services.llm.deepseek_provider import (
_is_vision_model,
_to_openai_tool,
_to_openai_tool_choice,
completion_from_openai,
messages_to_openai,
)
from backend.services.llm.local_skill import load_skill_markdown, load_skill_validator
from backend.services.llm.pricing import PRICING, cost_for_entry
from backend.services.llm.types import (
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolResultBlock,
ToolSchema,
)
@pytest.fixture
def sample_pdf(tmp_path: Path) -> Path:
pdf = tmp_path / "ds.pdf"
pdf.write_bytes(make_text_pdf([
"Pin configuration\n1 VCC Power\n2 GND Ground\n3 TXD UART transmit",
"Absolute maximum ratings\nVCC 6.0 V",
]))
return pdf
def test_extract_pdf_text_includes_page_markers(sample_pdf: Path):
text = extract_pdf_text(sample_pdf)
assert "page 1" in text.lower() or "--- page 1 ---" in text
assert "VCC" in text
assert sample_pdf.name in text
def test_vision_model_detection():
assert _is_vision_model("deepseek-v4-flash-vision-exp")
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):
messages = [
Message("user", [
PdfBlock(path=sample_pdf, cacheable=True),
TextBlock("Extract the pin table."),
]),
]
out = messages_to_openai(messages, vision=False)
assert len(out) == 1
assert out[0]["role"] == "user"
content = out[0]["content"]
if isinstance(content, str):
blob = content
else:
blob = " ".join(p.get("text", "") for p in content if p.get("type") == "text")
assert not any(p.get("type") == "image_url" for p in content)
assert "VCC" in blob
assert "Extract the pin table" in blob
def test_messages_to_openai_tool_roundtrip():
messages = [
Message("assistant", [
TextBlock("checking", reasoning_content="I should query the net."),
ToolCall(id="call_1", name="get_net_for_pin", input={"ref": "U2", "pin": "3"}),
]),
Message("user", [
ToolResultBlock(tool_use_id="call_1", name="get_net_for_pin", content="UART_TX"),
TextBlock("continue"),
]),
]
out = messages_to_openai(messages, vision=False)
assert out[0]["role"] == "assistant"
assert out[0]["reasoning_content"] == "I should query the net."
assert out[0]["tool_calls"][0]["function"]["name"] == "get_net_for_pin"
args = json.loads(out[0]["tool_calls"][0]["function"]["arguments"])
assert args["ref"] == "U2"
assert out[1]["role"] == "tool"
assert out[1]["tool_call_id"] == "call_1"
assert out[1]["content"] == "UART_TX"
assert out[2]["role"] == "user"
def test_tool_schema_and_choice():
schema = ToolSchema(
name="save_pintable",
description="Save pins",
input_schema={"type": "object", "properties": {}},
)
tool = _to_openai_tool(schema)
assert tool["type"] == "function"
assert tool["function"]["name"] == "save_pintable"
assert _to_openai_tool_choice("auto") == "auto"
forced = _to_openai_tool_choice({"name": "save_pintable"})
assert forced["function"]["name"] == "save_pintable"
def test_completion_from_openai_parses_tools_and_cache():
fn = SimpleNamespace(name="submit_review", arguments='{"findings":[]}')
tc = SimpleNamespace(id="c1", function=fn)
msg = SimpleNamespace(
content="done",
reasoning_content="step by step",
tool_calls=[tc],
)
usage = SimpleNamespace(
prompt_tokens=1000,
completion_tokens=50,
prompt_cache_hit_tokens=400,
prompt_tokens_details=None,
)
resp = SimpleNamespace(
choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")],
usage=usage,
)
completion = completion_from_openai(resp)
assert completion.text == "done"
assert completion.tool_calls[0].name == "submit_review"
assert completion.tool_calls[0].input == {"findings": []}
assert completion.usage.input_tokens == 600
assert completion.usage.cache_read_tokens == 400
assert completion.raw_assistant_blocks[0].reasoning_content == "step by step"
def test_local_skills_load():
md = load_skill_markdown("extract-pintable")
assert "pin table" in md.lower()
validate = load_skill_validator("extract-pintable")
assert validate is not None
errors = validate({
"component_subtype": "ic.mcu",
"component_subtype_description": "MCU",
"package_info": {"base_family": "MSPM0", "package": "LQFP-48", "pin_count": 2},
"pintable": [
{"number": 1, "name": "VCC"},
{"number": 2, "name": "GND"},
],
})
assert errors == []
def test_factory_routes_deepseek(monkeypatch):
monkeypatch.setattr(settings, "deepseek_api_key", "sk-test")
from backend.services.llm.factory import get_provider_by_name
get_provider_by_name.cache_clear()
try:
provider = get_provider_by_name("deepseek")
assert provider.name == "deepseek"
finally:
get_provider_by_name.cache_clear()
def test_config_defaults_are_deepseek():
assert settings.provider_default == "deepseek"
assert settings.model_for_stage("validation") == settings.model_validation_deepseek
assert "vision" in settings.model_for_stage("pintable")
assert settings.provider_for_stage("pintable") == "deepseek"
def test_deepseek_pricing_positive():
cost = cost_for_entry({
"provider": "deepseek",
"model": "deepseek-v4-pro",
"input_tokens": 1_000_000,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
})
assert cost == pytest.approx(1.32)
assert "default" in PRICING["deepseek"]
+118
View File
@@ -0,0 +1,118 @@
"""Shared component library: persist datasheets, catalog listing."""
from __future__ import annotations
from fastapi.testclient import TestClient
from backend.services import projects as proj_svc
from backend.services.datasheet_store import resolve_datasheet
from backend.services.storage import LocalStorageBackend
PDF = b"%PDF-1.4\n" + b"x" * 8000
def _client(tmp_path) -> TestClient:
from backend.main import app
app.state.storage = LocalStorageBackend(tmp_path)
return TestClient(app)
def test_save_datasheet_also_stores_in_library(storage):
meta = proj_svc.create_project(storage, "local", "board")
key = proj_svc.save_datasheet(storage, "local", meta.id, "CH340E", PDF)
assert key.endswith("CH340E.pdf")
assert storage.exists(key)
assert resolve_datasheet(storage, "CH340E")
assert proj_svc.library_has_datasheet(storage, "CH340E")
def test_library_catalog_lists_ics_passives_and_pdfs(storage):
storage.write_json(
"library/extracted/CH340E.json",
{
"mpn": "CH340E",
"pintable": [{"pin": 1}, {"pin": 2}],
"component_subtype": "usb-uart",
"absolute_maximum_ratings": {"vcc": "5V"},
},
)
storage.write_json(
"library/patterns/samsung_c.json",
{
"name": "Samsung CL10",
"component_type": "capacitor",
"description": "Samsung 0603 MLCC",
"regex": r"^CL10",
},
)
storage.write_json(
"library/passives/CL10B474KA8NNNC.json",
{
"mpn": "CL10B474KA8NNNC",
"specs": {
"specs_type": "capacitor",
"component_subtype": "mlcc",
"values": {"capacitance": "470n"},
},
},
)
proj_svc.remember_datasheet(storage, "CH340E", PDF)
cat = proj_svc.list_library_catalog(storage)
assert len(cat["ics"]) == 1
assert cat["ics"][0]["mpn"] == "CH340E"
assert cat["ics"][0]["pin_count"] == 2
assert cat["ics"][0]["has_datasheet"] is True
assert cat["passives"][0]["mpn"] == "Samsung CL10"
assert cat["simple"][0]["mpn"] == "CL10B474KA8NNNC"
assert any(d["mpn"] == "CH340E" and d["has_extraction"] for d in cat["datasheets"])
def test_library_http_catalog_and_pdf(tmp_path):
client = _client(tmp_path)
empty = client.get("/api/library")
assert empty.status_code == 200
body = empty.json()
assert body["ics"] == []
assert body["datasheets"] == []
meta = client.post("/api/projects", json={"name": "lib"}).json()
resp = client.post(
f"/api/projects/{meta['id']}/upload/datasheets",
params={"mpn": "MSPM0G3507"},
files={"file": ("msp.pdf", PDF, "application/pdf")},
)
assert resp.status_code == 200
catalog = client.get("/api/library").json()
assert any(d["mpn"] == "MSPM0G3507" for d in catalog["datasheets"])
pdf = client.get("/api/library/datasheet/MSPM0G3507")
assert pdf.status_code == 200
assert pdf.content.startswith(b"%PDF-")
def test_fetch_datasheet_persists_to_library(tmp_path, monkeypatch):
from backend.services.datasheet_finder import DatasheetHit
async def fake_find(mpn, lcsc_id=None):
return DatasheetHit(
mpn,
pdf_bytes=PDF,
url="https://datasheet.lcsc.com/x.pdf",
source="lcsc",
)
monkeypatch.setattr(
"backend.services.datasheet_finder.find_datasheet", fake_find,
)
client = _client(tmp_path)
resp = client.get("/api/datasheets/fetch", params={"mpn": "CH340E"})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF-")
catalog = client.get("/api/library").json()
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
assert client.get("/api/library/datasheet/CH340E").status_code == 200
+64
View File
@@ -0,0 +1,64 @@
"""PDF ingest: PyMuPDF text, keyword-selected vision pages, abs-max coerce."""
from __future__ import annotations
from pathlib import Path
from backend.services.extraction import _coerce_abs_max
from backend.services.llm.pdf_ingest import (
extract_pdf_text,
make_text_pdf,
relevant_page_indices,
render_pdf_page_jpegs,
)
def test_extract_pdf_text_reads_later_pages(tmp_path: Path):
pdf = tmp_path / "wide.pdf"
pdf.write_bytes(make_text_pdf([
"Title page",
"Pin configuration VCC GND TXD",
"Absolute maximum ratings VCC 6.0 V",
]))
text = extract_pdf_text(pdf)
assert "--- page 1 ---" in text
assert "--- page 3 ---" in text
assert "6.0 V" in text
def test_relevant_pages_prefer_abs_max_over_front_padding(tmp_path: Path):
pages = [f"Filler overview page {i}" for i in range(1, 8)]
pages.append("Absolute maximum ratings\nSupply voltage VCC 6.0 V")
pdf = tmp_path / "long.pdf"
pdf.write_bytes(make_text_pdf(pages))
idx = relevant_page_indices(pdf, max_pages=6)
assert (len(pdf.read_bytes()) > 0)
# 0-based: keyword is on page 8 → index 7, plus neighbor 6
assert 7 in idx
assert len(idx) <= 6
def test_render_keyword_pages_not_only_front(tmp_path: Path):
pages = ["Cover"] * 5
pages.append("Absolute maximum ratings table VCC 6 V")
pdf = tmp_path / "img.pdf"
pdf.write_bytes(make_text_pdf(pages))
images = render_pdf_page_jpegs(pdf, max_pages=4)
page_nos = [n for n, _ in images]
assert 6 in page_nos
assert images
assert images[0][1][:2] == b"\xff\xd8" # JPEG
def test_coerce_abs_max_keeps_valid_drops_junk():
rows = _coerce_abs_max([
{"parameter": "VCC", "max": "6", "unit": "V", "source_page": 12},
{"parameter": "bad", "unit": "V"}, # no page
"nope",
{"parameter": "Tstg", "min": -40, "max": 125, "unit": "°C", "source_page": 12},
])
assert len(rows) == 2
assert rows[0]["parameter"] == "VCC"
assert rows[0]["max"] == 6.0
assert rows[0]["min"] is None
assert rows[1]["min"] == -40.0