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:
@@ -49,8 +49,9 @@ def test_extract_pdf_text_includes_page_markers(sample_pdf: Path):
|
||||
|
||||
def test_vision_model_detection():
|
||||
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-flash")
|
||||
|
||||
|
||||
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():
|
||||
assert settings.provider_default == "deepseek"
|
||||
assert settings.model_for_stage("validation") == settings.model_validation_deepseek
|
||||
assert "vision" in settings.model_for_stage("pintable")
|
||||
from backend.config import Settings
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_deepseek_pricing_positive():
|
||||
cost = cost_for_entry({
|
||||
pro = cost_for_entry({
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-v4-pro",
|
||||
"input_tokens": 1_000_000,
|
||||
@@ -344,5 +348,24 @@ def test_deepseek_pricing_positive():
|
||||
"cache_creation_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 "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