Make the component library a standalone product door (2.63.0).

Add /api/library datasheet import and component GET/PUT with no exam.
Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI.
Document Rust criteria (none chosen; no rustup) and coding conformity.
This commit is contained in:
2026-09-21 22:12:00 +02:00
parent 20733f0ec6
commit 4df04df5d4
101 changed files with 2060 additions and 302 deletions
View File
@@ -0,0 +1,81 @@
"""Tests for the Anthropic cache_control breakpoint cap.
Anthropic rejects requests with >4 cache_control blocks. The review path adds
one per cacheable PDF excerpt, so a hub IC fetching two interface excerpts hit
5 (system + initial PDF + initial context + 2 excerpts) and the API returned
"A maximum of 4 blocks with cache_control may be provided. Found 5." The
provider caps message-block breakpoints so system(1) + messages never exceed 4.
"""
from __future__ import annotations
from backend.services.llm.anthropic_provider import _enforce_cache_breakpoint_limit
def _cc(block: dict) -> bool:
return "cache_control" in block
def _count(messages: list[dict]) -> int:
return sum(_cc(b) for m in messages for b in m["content"])
def _ephemeral() -> dict:
return {"cache_control": {"type": "ephemeral"}}
def test_u2_two_excerpts_capped_to_three():
"""The exact failure: initial PDF + context + 2 excerpts = 4 message
breakpoints (5 with system). Cap to 3 so the total lands at 4."""
initial_pdf = {"type": "document", "source": {}, **_ephemeral()}
initial_text = {"type": "text", "text": "ctx", **_ephemeral()}
excerpt1 = {"type": "document", "source": {}, **_ephemeral()}
excerpt2 = {"type": "document", "source": {}, **_ephemeral()}
messages = [
{"role": "user", "content": [initial_pdf, initial_text]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "a", "name": "x", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "."}, excerpt1]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "b", "name": "x", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "b", "content": "."}, excerpt2]},
]
_enforce_cache_breakpoint_limit(messages)
assert _count(messages) == 3 # +1 system = 4 total, within the limit
# The stable full-datasheet anchor and the two most recent excerpts survive;
# the initial context block (cheap to reprocess) loses its breakpoint.
assert _cc(initial_pdf)
assert not _cc(initial_text)
assert _cc(excerpt1)
assert _cc(excerpt2)
def test_heavy_fanout_keeps_anchor_and_tail():
"""A heavy-fanout review (many excerpts) still caps to 3 and always keeps
the first cacheable block (the full-datasheet anchor)."""
anchor = {"type": "document", "source": {}, **_ephemeral()}
messages = [{"role": "user", "content": [anchor, {"type": "text", "text": "x", **_ephemeral()}]}]
excerpts = []
for _ in range(6):
ex = {"type": "document", "source": {}, **_ephemeral()}
excerpts.append(ex)
messages.append({"role": "user", "content": [ex]})
_enforce_cache_breakpoint_limit(messages)
assert _count(messages) == 3
assert _cc(anchor)
# Two most-recent excerpts retained for incremental tail caching.
assert _cc(excerpts[-1]) and _cc(excerpts[-2])
def test_under_limit_untouched():
"""Three or fewer message breakpoints are left exactly as-is."""
pdf = {"type": "document", "source": {}, **_ephemeral()}
text = {"type": "text", "text": "x", **_ephemeral()}
messages = [{"role": "user", "content": [pdf, text]}]
_enforce_cache_breakpoint_limit(messages)
assert _cc(pdf) and _cc(text)
assert _count(messages) == 2
@@ -0,0 +1,224 @@
"""Tests for get_datasheet_excerpt: safety guards, budget cap, cache."""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
import json
from pathlib import Path
import pytest
from pypdf import PdfWriter
from backend.periscopex.models import DesignGraph
from backend.periscopex.utils import safe_mpn
from backend.periscopex.review_tools import (
EXCERPT_TOPICS,
ExcerptState,
execute_tool,
get_datasheet_excerpt,
)
from backend.services.llm.types import PdfBlock
from backend.services.review_session import _signal_neighbors
GRAPH = SIMPLE_PROJECT / "design_graph.json"
IC_MPNS = {
"U1": "SPX3819M5-L-3-3/TR",
"U2": "CH340E",
"U3": "MSPM0G3507SPTR",
}
def _blank_pdf(path: Path, pages: int = 3) -> None:
w = PdfWriter()
for _ in range(pages):
w.add_blank_page(width=200, height=200)
with path.open("wb") as fh:
w.write(fh)
@pytest.fixture
def graph_and_pdfs(tmp_path):
graph = DesignGraph.model_validate(json.loads(GRAPH.read_text()))
pdf_dir = tmp_path / "datasheets"
pdf_dir.mkdir()
for mpn in IC_MPNS.values():
_blank_pdf(pdf_dir / f"{safe_mpn(mpn)}.pdf")
return graph, pdf_dir
def _state(graph, current_ic, pdf_dir, **kwargs):
return ExcerptState(
current_ic=current_ic,
connected_designators=_signal_neighbors(graph, current_ic),
graph=graph,
pdf_dir=pdf_dir,
storage=None,
**kwargs,
)
def test_rejects_non_neighbor(graph_and_pdfs):
"""U1's review cannot fetch U2/U3 — they're not signal neighbors."""
graph, pdf_dir = graph_and_pdfs
state = _state(graph, "U1", pdf_dir)
text, attachment = get_datasheet_excerpt(
graph, {}, "U2", "absolute_max", state,
)
assert attachment is None
assert "not a signal neighbor" in text
assert state.fetch_count == 0
def test_rejects_self(graph_and_pdfs):
"""The IC under review can't excerpt its own datasheet — it's already in
context. Steer the model to use the existing PDF."""
graph, pdf_dir = graph_and_pdfs
state = _state(graph, "U2", pdf_dir)
text, attachment = get_datasheet_excerpt(
graph, {}, "U2", "absolute_max", state,
)
assert attachment is None
assert "already reviewing" in text
def test_rejects_unknown_topic(graph_and_pdfs):
"""Bad topic enum returns a clean error listing valid topics."""
graph, pdf_dir = graph_and_pdfs
state = _state(graph, "U2", pdf_dir)
text, attachment = get_datasheet_excerpt(
graph, {}, "U3", "bogus_topic", state,
)
assert attachment is None
assert "Unknown topic" in text
assert "absolute_max" in text
def test_returns_pdfblock_for_valid_neighbor(graph_and_pdfs):
"""U2's review can fetch U3 (a signal neighbor) — returns PdfBlock."""
graph, pdf_dir = graph_and_pdfs
state = _state(graph, "U2", pdf_dir)
text, attachment = get_datasheet_excerpt(
graph, {}, "U3", "pin_voltage_levels", state,
)
assert isinstance(attachment, PdfBlock)
assert attachment.cacheable is True
assert "U3" in text
assert "pin_voltage_levels" in text
assert state.fetch_count == 1
assert state.page_count > 0
def test_budget_cap_fetch_count(graph_and_pdfs):
"""After fetch_budget hits, further fetches are rejected with budget message."""
graph, pdf_dir = graph_and_pdfs
# Tight budget so the test is fast
state = _state(graph, "U3", pdf_dir, fetch_budget=2, page_budget=100)
# Two valid fetches succeed
for topic in ("absolute_max", "pin_voltage_levels"):
_, att = get_datasheet_excerpt(graph, {}, "U2", topic, state)
assert att is not None, f"topic {topic} should have succeeded"
# Third fetch should be budget-blocked
text, attachment = get_datasheet_excerpt(
graph, {}, "U2", "electrical_characteristics", state,
)
assert attachment is None
assert "budget exhausted" in text
def test_per_neighbor_page_budget_blocks_third_topic_on_same_neighbor(graph_and_pdfs):
"""A single neighbor can be fetched up to its per-neighbor page budget,
then further topics on it are blocked — bounds one interface's cost
without touching the global budget."""
graph, pdf_dir = graph_and_pdfs
# Blank PDFs fall back to 3 pages/fetch; budget of 4 admits the first two
# fetches (0→3, 3→6) and blocks the third once the neighbor is over.
state = _state(
graph, "U3", pdf_dir,
fetch_budget=10, page_budget=1000, per_neighbor_page_budget=4,
)
_, a1 = get_datasheet_excerpt(graph, {}, "U2", "absolute_max", state)
assert a1 is not None
_, a2 = get_datasheet_excerpt(graph, {}, "U2", "pin_voltage_levels", state)
assert a2 is not None
text, a3 = get_datasheet_excerpt(graph, {}, "U2", "electrical_characteristics", state)
assert a3 is None
assert "Per-neighbor" in text and "U2" in text
def test_global_page_budget_still_bounds_total_fanout(graph_and_pdfs):
"""The global page budget is a hard ceiling even when a neighbor is under
its per-neighbor sub-budget — bounds hub-IC fan-out."""
graph, pdf_dir = graph_and_pdfs
state = _state(
graph, "U3", pdf_dir,
fetch_budget=10, page_budget=2, per_neighbor_page_budget=100,
)
_, a1 = get_datasheet_excerpt(graph, {}, "U2", "absolute_max", state)
assert a1 is not None # capped to 2 pages → page_count hits global budget
text, a2 = get_datasheet_excerpt(graph, {}, "U2", "pin_voltage_levels", state)
assert a2 is None
assert "page budget exhausted" in text
def test_cache_shared_across_states(graph_and_pdfs):
"""Same (designator, topic, ds_md5) → second fetch reuses trimmed PDF.
Mirrors the cross-IC case: U2's review fetches U3@abs_max, then U3's
review fetches U3@abs_max from a separate ExcerptState sharing the same
cache dict.
"""
graph, pdf_dir = graph_and_pdfs
shared_cache: dict = {}
s1 = _state(graph, "U2", pdf_dir, cache=shared_cache)
s2 = _state(graph, "U3", pdf_dir, cache=shared_cache)
_, att1 = get_datasheet_excerpt(graph, {}, "U3", "absolute_max", s1)
assert att1 is not None
path1 = att1.path
# U3's review reusing the same neighbor (loopback: U3 fetching itself is
# blocked, so use a different IC). We assert path identity for the U2
# case from s1's perspective is preserved across a fresh state with the
# same cache by re-fetching from s1's neighbor U3 with state s_other.
s_other = _state(graph, "U2", pdf_dir, cache=shared_cache)
_, att2 = get_datasheet_excerpt(graph, {}, "U3", "absolute_max", s_other)
assert att2 is not None
assert att2.path == path1 # cache hit — same trimmed file
def test_no_state_returns_clean_error(graph_and_pdfs):
"""Calling without state (regression guard) returns an explicit message."""
graph, _ = graph_and_pdfs
text, attachment = get_datasheet_excerpt(graph, {}, "U3", "absolute_max", None)
assert attachment is None
assert "without per-review state" in text
def test_execute_tool_dispatcher_routes_correctly(graph_and_pdfs):
"""The dispatcher recognises the new tool name and forwards state."""
graph, pdf_dir = graph_and_pdfs
state = _state(graph, "U2", pdf_dir)
text, attachment = execute_tool(
graph, {}, "get_datasheet_excerpt",
{"designator": "U3", "topic": "absolute_max"},
state=state,
)
assert isinstance(attachment, PdfBlock)
assert state.fetch_count == 1
def test_existing_tools_return_tuple_with_none_attachment(graph_and_pdfs):
"""Backward compat: existing tools now return (text, None)."""
graph, _ = graph_and_pdfs
text, att = execute_tool(graph, {}, "get_pintable", {"designator": "U2"})
assert isinstance(text, str) and text
assert att is None
def test_topics_cover_u2_001_use_case():
"""The U2-001 false positive needs to verify MSPM0G3507 PA1's 5V-tolerance
— assert that the pin_voltage_levels topic regex matches typical
datasheet phrasings for this."""
pat = EXCERPT_TOPICS["pin_voltage_levels"]
assert pat.search("This pin is 5V-tolerant under normal operating conditions.")
assert pat.search("VIH = 0.7 × VDD")
assert pat.search("Input voltage range: -0.3V to VDD+0.3V")
+236
View File
@@ -0,0 +1,236 @@
"""Datasheet auto-finder: MPN matching, LCSC pick, TI slugs, routing."""
from __future__ import annotations
import asyncio
from backend.services.datasheet_finder import (
DatasheetHit,
_pdf_links_in_html,
_pick_lcsc_product,
_ti_slugs,
find_datasheet,
manufacturer_pdf_candidates,
mpn_catalog_match,
mpn_matches,
mpn_query_variants,
_download_pdf,
)
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")
assert mpn_matches("25AA1024-I_SM", "25AA1024-I/SM")
# 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_mpn_catalog_match_orderable_suffix():
assert mpn_catalog_match("ESP32-S31-WROOM-3", "ESP32-S31-WROOM-3-N16R16V")
assert mpn_catalog_match("24AA025E64", "24AA025E64-I/SN")
assert mpn_catalog_match("LAN8720A", "LAN8720A-CP-TR")
assert mpn_catalog_match("W25Q128JVS", "W25Q128JVSIQ")
assert not mpn_catalog_match("CH340", "CH340E")
assert not mpn_catalog_match("10uF", "GRM21BR61A106KE19L")
def test_mpn_query_variants_underscore_and_reel():
variants = mpn_query_variants("25AA1024-I_SM")
assert "25AA1024-I/SM" in variants
variants = mpn_query_variants("RC0805FR-0733RL — 33 Ω — 1% — 0805")
assert "RC0805FR-0733RL" in variants
def test_pick_lcsc_family_orderable():
products = [
{
"productModel": "ESP32-S31-WROOM-3-N16R16V",
"pdfUrl": "http://esp.pdf",
},
]
picked = _pick_lcsc_product("ESP32-S31-WROOM-3", products)
assert picked is not None
assert picked["pdfUrl"] == "http://esp.pdf"
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_ti_slugs_from_orderable_code():
slugs = _ti_slugs("INA228AQDGSRQ1")
assert "ina228-q1" in slugs
assert "ina228" in slugs
slugs = _ti_slugs("SN74AXC1T45DBVR")
assert "sn74axc1t45" 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 "")
def test_manufacturer_candidates_ti_gpn_and_espressif():
ti = manufacturer_pdf_candidates("INA228AQDGSRQ1")
urls = [u for _, u in ti]
assert any("/lit/ds/symlink/ina228-q1.pdf" in u for u in urls)
assert any("/lit/gpn/ina228" in u for u in urls)
esp = manufacturer_pdf_candidates("ESP32-S31-WROOM-3-N16R16V")
urls = [u for _, u in esp]
assert any("esp32-s31-wroom-3_datasheet_en.pdf" in u for u in urls)
assert any("esp32-s31_datasheet_en.pdf" in u for u in urls)
adi = manufacturer_pdf_candidates("ADAU1467WBCPZ300R")
assert any(u.endswith("/ADAU1467.pdf") for _, u in adi)
mcp = manufacturer_pdf_candidates("MCP23S17-E/ML")
urls = [u for _, u in mcp]
assert any("microchip.com/en-us/product/mcp23s17" in u for u in urls)
murata = manufacturer_pdf_candidates("LQW18AN18NJ00D")
urls = [u for _, u in murata]
assert any("LQW18AN18NJ00-01.pdf" in u for u in urls)
silabs = manufacturer_pdf_candidates("Si4684-A10-GM")
urls = [u for _, u in silabs]
assert any("Si4684-A10.pdf" in u for u in urls)
def test_suggested_urls_on_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_mouser", miss)
monkeypatch.setattr("backend.services.datasheet_finder._from_digikey", miss)
hit = asyncio.run(find_datasheet("MCP23S17-E/ML"))
assert not hit.ok
assert hit.suggested_urls
assert any("microchip.com" in u for u in hit.suggested_urls)
def test_pick_mouser_family_orderable():
from backend.services.mouser import pick_mouser_product
products = [
{"ManufacturerPartNumber": "LAN8720A-CP-TR", "DataSheetUrl": "http://lan.pdf"},
{"ManufacturerPartNumber": "OTHER", "DataSheetUrl": "http://no.pdf"},
]
picked = pick_mouser_product("LAN8720A", products)
assert picked is not None
assert picked["DataSheetUrl"] == "http://lan.pdf"
def test_pdf_links_in_html_require_family_match():
html = '''<a href="/lit/ds/symlink/ina228-q1.pdf">ds</a>
<a href="https://evil.example/unrelated.pdf">no</a>'''
links = _pdf_links_in_html(html, "https://www.ti.com/product/INA228", "INA228AQDGSRQ1")
assert links == ["https://www.ti.com/lit/ds/symlink/ina228-q1.pdf"]
def test_download_pdf_follows_html_interstitial(monkeypatch):
pdf = b"%PDF-" + b"x" * 8000
async def fake_get(url: str) -> bytes:
if url.endswith(".pdf"):
return pdf
return (
b'<html><a href="https://www.ti.com/lit/ds/symlink/tpd2e007.pdf">'
b"datasheet</a></html>"
)
monkeypatch.setattr("backend.services.datasheet_finder._http_get", fake_get)
data = asyncio.run(_download_pdf("https://www.ti.com/product/TPD2E007", mpn="TPD2E007DCKR"))
assert data.startswith(b"%PDF-")
+54
View File
@@ -0,0 +1,54 @@
"""Periscope LLM routing is DeepSeek only.
Favor: every pipeline stage uses DeepSeek even if PROVIDER_* is set to
anthropic; model_for_stage stays on deepseek-flash.
Against: anthropic fallback is ignored; get_provider_by_name('anthropic')
does not construct the Anthropic SDK client.
"""
from __future__ import annotations
import pytest
from backend.config import settings
from backend.services.llm.factory import get_provider, get_provider_by_name
@pytest.fixture
def restore_routing():
snap = {
"provider_default": settings.provider_default,
"provider_validation": settings.provider_validation,
"fallback_provider_validation": settings.fallback_provider_validation,
"fallback_model_validation": settings.fallback_model_validation,
"deepseek_api_key": settings.deepseek_api_key,
}
if not settings.deepseek_api_key:
settings.deepseek_api_key = "sk-test-not-called"
yield
for k, v in snap.items():
setattr(settings, k, v)
get_provider_by_name.cache_clear()
def test_stage_stays_deepseek_when_env_says_anthropic(restore_routing):
settings.provider_validation = "anthropic"
assert settings.provider_for_stage("validation") == "deepseek"
get_provider_by_name.cache_clear()
assert get_provider("validation").name == "deepseek"
assert "deepseek" in settings.model_for_stage("validation")
def test_anthropic_fallback_is_not_used(restore_routing):
settings.fallback_provider_validation = "anthropic"
settings.fallback_model_validation = "claude-sonnet-4-6"
assert settings.fallback_for_stage("validation") is None
def test_get_provider_by_name_does_not_load_anthropic(restore_routing):
get_provider_by_name.cache_clear()
try:
p = get_provider_by_name("anthropic")
assert p.name == "deepseek"
finally:
get_provider_by_name.cache_clear()
+406
View File
@@ -0,0 +1,406 @@
"""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,
_repair_assistant_messages,
_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 _is_vision_model("deepseek-flash")
assert _is_vision_model("deepseek-v4-flash")
assert not _is_vision_model("deepseek-v4-pro")
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_thinking_only_assistant_sends_empty_string_content():
"""DeepSeek accepts content=\"\" and rejects content=null / omitted."""
out = messages_to_openai(
[Message("assistant", [
TextBlock("", reasoning_content="Need to inspect pin 3 first."),
])],
vision=False,
)
assert out[0]["role"] == "assistant"
assert out[0]["content"] == ""
assert "content" in out[0]
assert out[0]["reasoning_content"] == "Need to inspect pin 3 first."
assert "tool_calls" not in out[0]
def test_empty_assistant_blocks_still_set_content():
out = messages_to_openai([Message("assistant", [])], vision=False)
assert out[0]["content"] == ""
def test_tool_call_assistant_sends_empty_string_content():
out = messages_to_openai(
[Message("assistant", [
ToolCall(id="c1", name="get_pintable", input={"ref": "U2"},
reasoning_content="look up pins"),
])],
vision=False,
)
assert out[0]["content"] == ""
assert out[0]["tool_calls"][0]["function"]["name"] == "get_pintable"
assert out[0]["reasoning_content"] == "look up pins"
def test_repair_null_content_even_when_tool_calls_present():
repaired = _repair_assistant_messages([
{"role": "assistant", "content": None, "tool_calls": [{"id": "c1"}]},
{"role": "user", "content": "ok"},
])
assert repaired[0]["content"] == ""
assert repaired[0]["tool_calls"][0]["id"] == "c1"
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_forced_tool_choice_disables_thinking():
captured: dict = {}
class FakeCompletions:
async def create(self, **kwargs):
captured.update(kwargs)
fn = SimpleNamespace(name="save_resolved_specs", arguments="{}")
tc = SimpleNamespace(id="c1", function=fn)
msg = SimpleNamespace(content=None, reasoning_content=None, tool_calls=[tc])
usage = SimpleNamespace(
prompt_tokens=10, completion_tokens=5,
prompt_cache_hit_tokens=0, prompt_tokens_details=None,
)
return SimpleNamespace(
choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")],
usage=usage,
)
class FakeClient:
def __init__(self):
self.chat = SimpleNamespace(completions=FakeCompletions())
from backend.services.llm.deepseek_provider import DeepSeekSession
session = DeepSeekSession(
client=FakeClient(),
model="deepseek-v4-flash",
system="sys",
max_tokens=256,
thinking=True,
reasoning_effort="medium",
)
import asyncio
from backend.services.llm.types import Message, TextBlock, ToolSchema
asyncio.run(session.complete(
messages=[Message("user", [TextBlock("resolve")])],
tools=[ToolSchema(name="save_resolved_specs", description="x", input_schema={"type": "object"})],
tool_choice={"name": "save_resolved_specs"},
))
assert captured["extra_body"]["thinking"]["type"] == "disabled"
assert "reasoning_effort" not in captured["extra_body"]
assert captured["tool_choice"]["function"]["name"] == "save_resolved_specs"
def test_thinking_only_echoes_reasoning_content_on_follow_up():
"""Live U11/U12/U38: thinking-only then retry without reasoning_content → 400."""
calls: list[dict] = []
class FakeCompletions:
async def create(self, **kwargs):
calls.append({
"thinking": kwargs["extra_body"]["thinking"]["type"],
"messages": kwargs["messages"],
})
usage = SimpleNamespace(
prompt_tokens=10, completion_tokens=5,
prompt_cache_hit_tokens=0, prompt_tokens_details=None,
)
if kwargs["extra_body"]["thinking"]["type"] == "enabled" and len(calls) == 1:
msg = SimpleNamespace(
content=None,
reasoning_content="pondering the schematic",
tool_calls=None,
model_extra=None,
)
return SimpleNamespace(
choices=[SimpleNamespace(message=msg, finish_reason="stop")],
usage=usage,
)
fn = SimpleNamespace(name="get_pintable", arguments='{"ref":"U11"}')
tc = SimpleNamespace(id="c1", function=fn)
msg = SimpleNamespace(
content=None, reasoning_content=None, tool_calls=[tc], model_extra=None,
)
return SimpleNamespace(
choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")],
usage=usage,
)
class FakeClient:
def __init__(self):
self.chat = SimpleNamespace(completions=FakeCompletions())
from backend.services.llm.deepseek_provider import DeepSeekSession
from backend.services.llm.types import Message, TextBlock, ToolSchema
import asyncio
session = DeepSeekSession(
client=FakeClient(),
model="deepseek-flash",
system="sys",
max_tokens=256,
thinking=True,
reasoning_effort="medium",
)
completion = asyncio.run(session.complete(
messages=[Message("user", [TextBlock("review U11")])],
tools=[ToolSchema(
name="get_pintable", description="x",
input_schema={"type": "object"},
)],
tool_choice="auto",
))
assert len(calls) == 2
assert calls[0]["thinking"] == "enabled"
assert calls[1]["thinking"] == "enabled"
asst = [m for m in calls[1]["messages"] if m.get("role") == "assistant"]
assert asst
assert asst[0]["reasoning_content"] == "pondering the schematic"
assert asst[0]["content"] == ""
assert completion.tool_calls[0].name == "get_pintable"
def test_completion_from_openai_reads_model_extra_reasoning():
fn = SimpleNamespace(name="get_pintable", arguments='{"ref":"U11"}')
tc = SimpleNamespace(id="c1", function=fn)
msg = SimpleNamespace(
content=None,
reasoning_content=None,
tool_calls=[tc],
model_extra={"reasoning_content": "need pin table"},
)
usage = SimpleNamespace(
prompt_tokens=10, completion_tokens=5,
prompt_cache_hit_tokens=0, prompt_tokens_details=None,
)
resp = SimpleNamespace(
choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")],
usage=usage,
)
completion = completion_from_openai(resp)
assert completion.raw_assistant_blocks[0].reasoning_content == "need pin table"
out = messages_to_openai(
[Message("assistant", list(completion.raw_assistant_blocks))],
vision=False,
)
assert out[0]["reasoning_content"] == "need pin table"
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({
"mpn": "MSPM0G3507SPTR",
"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_wroom_rejects_bare_soc_pin1_ant():
from backend.services.llm.local_skill import load_skill_validator
validate = load_skill_validator("extract-pintable")
errors = validate({
"mpn": "ESP32-S31-WROOM-3",
"component_subtype": "ic.mcu",
"package_info": {"base_family": "ESP32-S31", "package": "module", "pin_count": 2},
"pintable": [
{"number": 1, "name": "ANT"},
{"number": 2, "name": "CHIP_PU"},
{"number": 78, "name": "XTAL_N"},
{"number": 79, "name": "XTAL_P"},
],
})
assert any("module" in e.lower() or "pad" in e.lower() or "SoC" in e or "WROOM" in e for e in 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():
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():
pro = 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 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"]
+23
View File
@@ -0,0 +1,23 @@
from backend.services.digikey import _find_product
def test_find_product_accepts_underscore_vs_slash():
products = [{"ManufacturerProductNumber": "25AA1024-I/SM", "DatasheetUrl": "http://a.pdf"}]
picked = _find_product("25AA1024-I_SM", products)
assert picked is not None
assert picked["DatasheetUrl"] == "http://a.pdf"
def test_find_product_accepts_family_orderable():
products = [
{"ManufacturerProductNumber": "24AA025E64-I/SN", "DatasheetUrl": "http://b.pdf"},
]
picked = _find_product("24AA025E64", products)
assert picked is not None
assert picked["DatasheetUrl"] == "http://b.pdf"
def test_find_product_rejects_unrelated():
products = [{"ManufacturerProductNumber": "GRM21BR61A106KE19L", "DatasheetUrl": "http://c.pdf"}]
assert _find_product("10uF", products) is None
assert _find_product("CH340", [{"ManufacturerProductNumber": "CH340E"}]) is None
+279
View File
@@ -0,0 +1,279 @@
"""Ferrite beads: recover Z from evidence; never invent; never abort ingest."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from pydantic import ValidationError
from backend.periscopex.ferrite_z import recover_bead_specs, z_from_datasheet_text
from backend.periscopex.graph import _load_component_models
from backend.periscopex.models import ComponentModel, InductorSpecs, SimpleComponentSpecs
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
def test_ferrite_bead_quoted_ohm_fills_z_not_henries():
specs = InductorSpecs(
component_subtype="passive.ferrite_bead",
value_formatted="120 ohm @ 100 MHz",
current_rating_a="3A",
dcr_ohms=0.03,
)
assert specs.impedance_ohm == 120.0
assert specs.value_henries is None
model = ComponentModel(mpn="BLM21PG121SN1D", specs=specs)
assert model.specs.impedance_ohm == 120.0
def test_ferrite_bead_live_payload_fills_z_from_formatted():
model = ComponentModel.model_validate({
"mpn": "BLM21PG121SN1D",
"specs": {
"specs_type": "inductor",
"component_subtype": "passive.ferrite_bead",
"value_henries": 120.0,
"value_formatted": "120 ohm @ 100 MHz",
"tolerance": None,
"package": "0805",
"current_rating_a": "3A",
"dcr_ohms": 0.03,
},
})
assert model.specs.impedance_ohm == 120.0
assert model.specs.value_henries is None
assert model.specs.current_rating_a == "3A"
assert model.specs.dcr_ohms == 0.03
def test_ferrite_bead_with_impedance_ohm_still_ok():
specs = InductorSpecs(
component_subtype="passive.ferrite_bead",
value_formatted="120 ohm @ 100 MHz",
impedance_ohm=120.0,
current_rating_a="3A",
dcr_ohms=0.03,
)
assert specs.impedance_ohm == 120.0
assert specs.value_henries is None
def test_inductor_still_requires_value_henries():
with pytest.raises(ValidationError, match="inductor requires value_henries"):
InductorSpecs(
component_subtype="passive.inductor",
value_formatted="15nH",
)
def test_load_models_bead_without_z_does_not_raise(tmp_path: Path):
payload = {
"mpn": "BLM21PG121SN1D",
"specs": {
"specs_type": "inductor",
"component_subtype": "passive.ferrite_bead",
"value_formatted": "FB",
"current_rating_a": "3A",
"dcr_ohms": 0.03,
},
}
(tmp_path / "BLM21PG121SN1D.json").write_text(json.dumps(payload) + "\n")
loaded = _load_component_models(tmp_path)
assert "BLM21PG121SN1D" in loaded
assert loaded["BLM21PG121SN1D"].impedance_ohm is None
def test_load_models_skips_inductor_missing_henries(tmp_path: Path):
bad = {
"mpn": "LQW-BAD",
"specs": {
"specs_type": "inductor",
"component_subtype": "passive.inductor",
"value_formatted": "x",
},
}
(tmp_path / "LQW-BAD.json").write_text(json.dumps(bad) + "\n")
loaded = _load_component_models(tmp_path)
assert loaded == {}
def test_simple_to_typed_bead_without_z():
simple = SimpleComponentSpecs(
specs_type="passive",
component_subtype="passive.ferrite_bead",
values={"current_rating_a": "3A", "dcr_ohms": 0.03},
)
specs = simple_to_typed_passive_specs(simple)
assert specs.specs_type == "inductor"
assert specs.impedance_ohm is None
assert specs.dcr_ohms == 0.03
def test_bead_mpn_datasheet_text_fills_z():
text = (
"BLM21PG121SN1D chip ferrite bead\n"
"Impedance (at 100MHz / 20°C) 120 ohm\n"
"Rated current 3A\n"
"DC resistance 0.03 ohm\n"
)
hit = z_from_datasheet_text(text)
assert hit is not None
z, formatted = hit
assert z == 120.0
assert "100" in formatted
assert "datasheet" in formatted
specs = InductorSpecs(
component_subtype="passive.ferrite_bead",
value_formatted="FB",
current_rating_a="3A",
dcr_ohms=0.03,
)
filled = recover_bead_specs(specs, extra_text=text)
assert filled.impedance_ohm == 120.0
assert filled.value_henries is None
def test_bead_mpn_datasheet_without_z_does_not_invent():
text = "BLM21PG121SN1D\nRated current 3 A\nDCR 0.03 ohm max\nPackage 0805"
assert z_from_datasheet_text(text) is None
specs = InductorSpecs(
component_subtype="passive.ferrite_bead",
value_formatted="FB",
current_rating_a="3A",
dcr_ohms=0.03,
)
filled = recover_bead_specs(specs, extra_text=text)
assert filled.impedance_ohm is None
model = ComponentModel(mpn="BLM21PG121SN1D", specs=filled)
assert model.specs.impedance_ohm is None
def _write_text_pdf(path: Path, text: str) -> None:
"""Write a one-page PDF pypdf can extract without PyMuPDF."""
escaped = (
text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
)
lines = escaped.split("\n")
ops = ["BT /F1 12 Tf 72 720 Td"]
for i, line in enumerate(lines):
if i:
ops.append("0 -16 Td")
ops.append(f"({line}) Tj")
ops.append("ET")
stream = "\n".join(ops).encode("latin-1", "replace")
objects = [
b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
(
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n"
),
b"4 0 obj << /Length %d >> stream\n" % len(stream) + stream + b"\nendstream endobj\n",
b"5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n",
]
header = b"%PDF-1.4\n"
body = header
offsets = [0]
for obj in objects:
offsets.append(len(body))
body += obj
xref_pos = len(body)
xref = [b"xref\n0 6\n0000000000 65535 f \n"]
for off in offsets[1:]:
xref.append(f"{off:010d} 00000 n \n".encode())
trailer = (
b"trailer << /Root 1 0 R /Size 6 >>\nstartxref\n"
+ str(xref_pos).encode()
+ b"\n%%EOF\n"
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(body + b"".join(xref) + trailer)
_BEAD_MODEL = {
"mpn": "BLM21PG121SN1D",
"specs": {
"specs_type": "inductor",
"component_subtype": "passive.ferrite_bead",
"value_formatted": "FB",
"current_rating_a": "3A",
"dcr_ohms": 0.03,
},
}
def test_bead_mpn_datasheet_pdf_fills_z(tmp_path: Path):
models = tmp_path / "models"
models.mkdir()
(models / "BLM21PG121SN1D.json").write_text(json.dumps(_BEAD_MODEL) + "\n")
_write_text_pdf(
tmp_path / "uploads" / "datasheets" / "BLM21PG121SN1D.pdf",
"BLM21PG121SN1D\nImpedance (at 100MHz / 20C) 120 ohm\nRated current 3A",
)
loaded = _load_component_models(models)
assert loaded["BLM21PG121SN1D"].impedance_ohm == 120.0
assert loaded["BLM21PG121SN1D"].value_henries is None
persisted = json.loads((models / "BLM21PG121SN1D.json").read_text())
assert persisted["specs"]["impedance_ohm"] == 120.0
assert persisted["specs"]["value_henries"] is None
assert "100" in (persisted["specs"].get("value_formatted") or "")
def test_bead_live_json_persist_unstuffs_henries(tmp_path: Path):
models = tmp_path / "models"
models.mkdir()
payload = {
"mpn": "BLM21PG121SN1D",
"specs": {
"specs_type": "inductor",
"component_subtype": "passive.ferrite_bead",
"value_henries": 120.0,
"value_formatted": "120 ohm @ 100 MHz",
"package": "0805",
"current_rating_a": "3A",
"dcr_ohms": 0.03,
},
}
(models / "BLM21PG121SN1D.json").write_text(json.dumps(payload) + "\n")
loaded = _load_component_models(models)
assert loaded["BLM21PG121SN1D"].impedance_ohm == 120.0
assert loaded["BLM21PG121SN1D"].value_henries is None
persisted = json.loads((models / "BLM21PG121SN1D.json").read_text())
assert persisted["specs"]["impedance_ohm"] == 120.0
assert persisted["specs"]["value_henries"] is None
def test_bead_mpn_datasheet_pdf_without_z_does_not_invent(tmp_path: Path):
models = tmp_path / "models"
models.mkdir()
(models / "BLM21PG121SN1D.json").write_text(json.dumps(_BEAD_MODEL) + "\n")
_write_text_pdf(
tmp_path / "uploads" / "datasheets" / "BLM21PG121SN1D.pdf",
"BLM21PG121SN1D\nRated current 3 A\nDCR 0.03 ohm max\nPackage 0805",
)
loaded = _load_component_models(models)
assert "BLM21PG121SN1D" in loaded
assert loaded["BLM21PG121SN1D"].impedance_ohm is None
assert loaded["BLM21PG121SN1D"].value_henries is None
def test_bead_mpn_library_blob_fills_z(tmp_path: Path):
models = tmp_path / "proj" / "models"
models.mkdir(parents=True)
(models / "BLM21PG121SN1D.json").write_text(json.dumps(_BEAD_MODEL) + "\n")
blob = tmp_path / "library" / "datasheets" / "blobs" / "deadbeef.pdf"
_write_text_pdf(
blob,
"Impedance (at 100MHz / 20C) 120 ohm\nRated current 3A",
)
ref = tmp_path / "library" / "datasheets" / "refs" / "BLM21PG121SN1D.json"
ref.parent.mkdir(parents=True, exist_ok=True)
ref.write_text(json.dumps({
"hash": "deadbeef",
"blob_key": "library/datasheets/blobs/deadbeef.pdf",
"mpn": "BLM21PG121SN1D",
}) + "\n")
loaded = _load_component_models(models, extra_pdf_dirs=[tmp_path])
assert loaded["BLM21PG121SN1D"].impedance_ohm == 120.0
assert loaded["BLM21PG121SN1D"].value_henries is None
+77
View File
@@ -0,0 +1,77 @@
"""layout_rules validator — numbers are parameters, never guessed defaults.
Favor: a numeric max_distance_mm / min_via_count is kept as given.
Against: unknown kind rejected; non-numeric distance becomes null.
"""
from __future__ import annotations
from backend.periscopex.layout_rules import validate_layout_rules
def test_numeric_max_distance_mm_is_kept():
given = 2.0
ok, errors = validate_layout_rules([
{"kind": "decoupling_proximity", "max_distance_mm": given, "source_page": 1},
])
assert errors == []
assert ok[0]["max_distance_mm"] == given
def test_impedance_kind_keeps_zdiff_window():
ok, errors = validate_layout_rules([{
"kind": "impedance",
"zdiff_ohm": 90,
"tolerance_pct": 10,
"net_class": "usb",
"source_page": 12,
}])
assert errors == []
assert ok[0]["zdiff_ohm"] == 90
assert ok[0]["tolerance_pct"] == 10
assert ok[0]["net_class"] == "usb"
given = 2.0
ok, errors = validate_layout_rules([
{"kind": "length_match", "max_distance_mm": given},
])
assert errors == []
assert ok[0]["max_distance_mm"] == given
def test_empty_list_is_explicit_skip():
ok, errors = validate_layout_rules([])
assert ok == []
assert errors == []
def test_needs_refresh_when_empty_and_old_version():
from backend.periscopex.layout_rules import needs_layout_rules_refresh
assert needs_layout_rules_refresh(
{"model_version": "1.9.0", "layout_rules": []},
min_scan_version="1.10.0",
)
assert not needs_layout_rules_refresh(
{"model_version": "1.10.0", "layout_rules": []},
min_scan_version="1.10.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.9.0",
"layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": None}],
},
min_scan_version="1.10.0",
)
def test_unknown_kind_rejected_and_non_numeric_distance_is_null():
ok, errors = validate_layout_rules([
{"kind": "not_a_kind", "max_distance_mm": 1.0},
{"kind": "decoupling_proximity", "max_distance_mm": "close"},
{"kind": "thermal_via", "min_via_count": 4},
])
assert errors
dist_rows = [r for r in ok if r["kind"] == "decoupling_proximity"]
assert dist_rows[0]["max_distance_mm"] is None
via = [r for r in ok if r["kind"] == "thermal_via"]
assert via[0]["min_via_count"] == 4
@@ -0,0 +1,31 @@
"""Native datasheet_extract vs inherited extraction — prove before switching pipeline."""
from __future__ import annotations
import ast
from pathlib import Path
from backend.services.datasheet_extract import _coerce_abs_max
from backend.services.extraction import _coerce_abs_max as inherited_coerce
def test_coerce_abs_max_matches_inherited():
raw = [
{"parameter": "Vin", "min": 0, "max": 6, "unit": "V", "source_page": 4},
"not-a-dict",
]
assert _coerce_abs_max(raw) == inherited_coerce(raw)
assert _coerce_abs_max(None) == inherited_coerce(None)
def test_native_extract_has_no_anthropic_import():
path = Path(__import__("backend.services.datasheet_extract", fromlist=["x"]).__file__)
tree = ast.parse(path.read_text())
imported = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
imported.append(node.module)
elif isinstance(node, ast.Import):
imported.extend(a.name for a in node.names)
assert not any("anthropic" in (m or "") for m in imported)
assert "backend.services.llm.factory" in imported
@@ -0,0 +1,134 @@
from tests.paths import SIMPLE_PROJECT, TAXONOMY
from backend.services.passive_from_distributor import specs_from_distributor
def test_lcsc_description_capacitor():
model = specs_from_distributor(
mpn="CL21B225KPFNNNE",
params=[{"name": "Package / Case", "value": "0805"}],
category="Capacitors / Ceramic Capacitors",
description="2.2uF ±10% 10V X7R 0805",
)
assert model is not None
specs = model.specs
assert specs.component_subtype == "passive.capacitor.ceramic"
assert abs(specs.value_farads - 2.2e-6) < 1e-12
assert specs.dielectric == "X7R"
assert specs.package == "0805"
def test_digikey_params_resistor():
model = specs_from_distributor(
mpn="RC0805FR-0710KL",
params=[
{"name": "Resistance", "value": "10 kOhms"},
{"name": "Tolerance", "value": "±1%"},
{"name": "Power (Watts)", "value": "0.125W"},
{"name": "Package / Case", "value": "0805 (2012 Metric)"},
],
category="Resistors / Chip Resistor - Surface Mount",
description="RES SMD 10K OHM 1% 1/8W 0805",
)
assert model is not None
assert model.specs.value_ohms == 10000
assert model.specs.package == "0805"
def test_skips_when_no_value():
assert specs_from_distributor(
mpn="MYSTERY",
params=[{"name": "Manufacturer", "value": "Murata"}],
category="",
description="some module",
) is None
def test_ferrite_bead_from_impedance():
model = specs_from_distributor(
mpn="BLM18PG121SN1D",
params=[
{"name": "Impedance @ Frequency", "value": "120 Ohms @ 100 MHz"},
{"name": "Package / Case", "value": "0603"},
{"name": "Current Rating", "value": "2 A"},
],
category="Filters / Ferrite Beads",
description="FERRITE BEAD 120 OHM 0603 1LN",
)
assert model is not None
specs = model.specs
assert specs.component_subtype == "passive.ferrite_bead"
assert specs.impedance_ohm == 120
assert specs.value_henries is None
assert "120ohm" in specs.value_formatted
assert "100" in specs.value_formatted
assert specs.package == "0603"
assert specs.current_rating_a == "2 A"
def test_specs_from_lcsc_payload():
from backend.services.passive_from_distributor import specs_from_lcsc_payload
model = specs_from_lcsc_payload(
"CL21B225KPFNNNE",
{
"package": "0805",
"manufacturer": "Samsung",
"category": "Capacitors",
"subcategory": "MLCC",
"description": "2.2uF ±10% 10V X7R 0805",
},
)
assert model is not None
assert abs(model.specs.value_farads - 2.2e-6) < 1e-12
def test_auto_resolve_skips_llm_when_catalog_parses(monkeypatch):
import asyncio
from pathlib import Path
from backend.services.datasheet_extract import auto_resolve_specs
async def boom(*_a, **_k):
raise AssertionError("LLM must not run")
monkeypatch.setattr("backend.services.datasheet_extract.call_with_fallback", boom)
tax = TAXONOMY
model = asyncio.run(
auto_resolve_specs(
mpn="CL21B225KPFNNNE",
digikey_params=[{"name": "Package / Case", "value": "0805"}],
digikey_category="Capacitors / Ceramic Capacitors",
digikey_description="2.2uF ±10% 10V X7R 0805",
component_type="passive",
taxonomy_dir=tax,
)
)
assert abs(model.specs.value_farads - 2.2e-6) < 1e-12
def test_auto_resolve_use_llm_false_on_ferrite(monkeypatch):
import asyncio
from pathlib import Path
from backend.services.datasheet_extract import auto_resolve_specs
async def boom(*_a, **_k):
raise AssertionError("LLM must not run")
monkeypatch.setattr("backend.services.datasheet_extract.call_with_fallback", boom)
tax = TAXONOMY
model = asyncio.run(
auto_resolve_specs(
mpn="BLM18PG121SN1D",
digikey_params=[
{"name": "Impedance @ Frequency", "value": "120 Ohms @ 100 MHz"},
],
digikey_category="Filters / Ferrite Beads",
digikey_description="FERRITE BEAD 120 OHM 0603 1LN",
component_type="passive",
taxonomy_dir=tax,
use_llm=False,
)
)
assert model.specs.impedance_ohm == 120
assert model.specs.component_subtype == "passive.ferrite_bead"
+85
View File
@@ -0,0 +1,85 @@
from pathlib import Path
from backend.services.datasheet_finder import find_local_pdf
from backend.services.passive_from_mpn import specs_from_mpn
def test_walsin_c0g_18pf():
model = specs_from_mpn("0805CG180J500NT")
assert model is not None
specs = model.specs
assert specs.specs_type == "capacitor"
assert abs(specs.value_farads - 18e-12) < 1e-15
assert specs.package == "0805"
assert specs.dielectric == "C0G"
assert specs.tolerance == "±5%"
assert specs.voltage_rating_v == "50V"
def test_avx_c0g_150pf():
model = specs_from_mpn("08055A151FAT2A")
assert model is not None
specs = model.specs
assert abs(specs.value_farads - 150e-12) < 1e-15
assert specs.package == "0805"
assert specs.dielectric == "C0G"
assert specs.voltage_rating_v == "50V"
def test_chip_resistor_from_mpn():
model = specs_from_mpn("FRC0805F1212TS")
assert model is not None
assert abs(model.specs.value_ohms - 12100) < 0.1
assert model.specs.package == "0805"
assert model.specs.tolerance == "±1%"
model = specs_from_mpn("0805W8F2201T5E")
assert model is not None
assert abs(model.specs.value_ohms - 2200) < 0.1
def test_murata_lqw18an():
model = specs_from_mpn("LQW18AN12NG00D")
assert model is not None
assert model.specs.specs_type == "inductor"
assert abs(model.specs.value_henries - 12e-9) < 1e-15
assert model.specs.package == "0603"
assert model.specs.tolerance == "±2%"
model = specs_from_mpn("LQW18AN18NJ00D")
assert abs(model.specs.value_henries - 18e-9) < 1e-15
assert model.specs.tolerance == "±5%"
model = specs_from_mpn("LQW18AN2N2D00D")
assert abs(model.specs.value_henries - 2.2e-9) < 1e-15
assert model.specs.tolerance == "±0.5nH"
model = specs_from_mpn("LQW18ANR10G00D")
assert abs(model.specs.value_henries - 100e-9) < 1e-15
def test_skips_opaque_murata_and_bare_value():
assert specs_from_mpn("GRM21A5C2J200JA01") is None
assert specs_from_mpn("18pF") is None
assert specs_from_mpn("CH340E") is None
def test_find_local_pdf_family_vs_orderable(tmp_path: Path):
pdf = tmp_path / "ESP32-S31-WROOM-3-N16R16V.pdf"
pdf.write_bytes(b"%PDF-1.4\n" + b"x" * 100)
hit = find_local_pdf(tmp_path, "ESP32-S31-WROOM-3")
assert hit is not None
assert hit.name == pdf.name
other = tmp_path / "only"
other.mkdir()
family = other / "ESP32-S31-WROOM-3.pdf"
family.write_bytes(b"%PDF-1.4\n" + b"y" * 100)
hit = find_local_pdf(other, "ESP32-S31-WROOM-3-N16R16V")
assert hit is not None
assert hit.name == family.name
def test_find_local_pdf_does_not_steal_sibling_die(tmp_path: Path):
(tmp_path / "CH340E.pdf").write_bytes(b"%PDF-1.4\n" + b"x" * 100)
assert find_local_pdf(tmp_path, "CH340") is None
@@ -0,0 +1,89 @@
from tests.paths import SIMPLE_PROJECT, TAXONOMY
import asyncio
from pathlib import Path
import pytest
from backend.services.passive_from_value import (
is_placeholder_value,
specs_from_bom_value,
)
def test_capacitor_picofarads():
model = specs_from_bom_value("18pF", "18pF", "C")
assert model is not None
assert model.specs.specs_type == "capacitor"
assert abs(model.specs.value_farads - 18e-12) < 1e-18
def test_resistor_kilo_and_euro():
k = specs_from_bom_value("4.7k", "4.7k", "R")
assert k is not None
assert abs(k.specs.value_ohms - 4700) < 1e-6
euro = specs_from_bom_value("4k7", "4k7", "R")
assert euro is not None
assert abs(euro.specs.value_ohms - 4700) < 1e-6
def test_inductor_uh():
model = specs_from_bom_value("10uH", "10uH", "L")
assert model is not None
assert abs(model.specs.value_henries - 10e-6) < 1e-12
def test_ferrite_impedance_at_freq():
model = specs_from_bom_value("600R@100MHz", "600R@100MHz", "FB")
assert model is not None
assert model.specs.component_subtype == "passive.ferrite_bead"
assert model.specs.impedance_ohm == 600
assert model.specs.value_henries is None
assert "100MHz" in model.specs.value_formatted
def test_placeholders_skip():
for raw in ("DNP", "NC", "JUMPER", "TBD", "-"):
assert is_placeholder_value(raw)
assert specs_from_bom_value(raw, raw, "R") is None
def test_ambiguous_string_returns_none():
assert specs_from_bom_value("mystery", "do not stuff", "R") is None
def test_resolve_from_value_skips_llm_when_parseable(monkeypatch):
from backend.services.datasheet_extract import resolve_from_value
async def boom(*_a, **_k):
raise AssertionError("LLM must not run")
monkeypatch.setattr("backend.services.datasheet_extract.call_with_fallback", boom)
tax = TAXONOMY
model = asyncio.run(
resolve_from_value(
mpn="18pF",
value="18pF",
ref_prefix="C",
taxonomy_dir=tax,
)
)
assert abs(model.specs.value_farads - 18e-12) < 1e-18
def test_resolve_from_value_placeholder_no_llm(monkeypatch):
from backend.services.datasheet_extract import resolve_from_value
async def boom(*_a, **_k):
raise AssertionError("LLM must not run")
monkeypatch.setattr("backend.services.datasheet_extract.call_with_fallback", boom)
tax = TAXONOMY
with pytest.raises(ValueError, match="Placeholder"):
asyncio.run(
resolve_from_value(
mpn="DNP",
value="DNP",
ref_prefix="R",
taxonomy_dir=tax,
)
)
+86
View File
@@ -0,0 +1,86 @@
"""PDF ingest: PyMuPDF text, keyword-selected vision pages, abs-max coerce."""
from __future__ import annotations
from pathlib import Path
from backend.services.datasheet_extract 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_sparse_page_is_flagged(tmp_path: Path):
pdf = tmp_path / "scan.pdf"
pdf.write_bytes(make_text_pdf([" "]))
text = extract_pdf_text(pdf)
assert "low-text page" in text
def test_one_table_markdown_from_extract_rows():
from backend.periscopex.pdf_text import _one_table_markdown
class _Table:
def to_markdown(self):
raise RuntimeError("no markdown")
def extract(self):
return [["Pin", "Name"], ["1", "VCC"]]
md = _one_table_markdown(_Table())
assert "VCC" in md
assert "Pin" in md
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
@@ -0,0 +1,765 @@
"""Tests for the LCSC → MPN resolver wired into the BOM-parse stage."""
from __future__ import annotations
import pytest
def test_is_lcsc_code():
from backend.services.purple_parts import is_lcsc_code
assert is_lcsc_code("C12345") is True
assert is_lcsc_code("c123") is True
assert is_lcsc_code("C0") is True
assert is_lcsc_code("TPS62840DLCR") is False
assert is_lcsc_code("C") is False
assert is_lcsc_code("C123abc") is False
assert is_lcsc_code("") is False
assert is_lcsc_code(None) is False
assert is_lcsc_code(" C12345 ") is True
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_no_op_when_disabled(monkeypatch):
"""When purple-parts isn't configured, the helper must not touch the BOM."""
from backend.config import settings
from backend.services.pipeline import _resolve_lcsc_codes
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
bom = {
"U1": {"value": "", "footprint": "", "mpn": None, "lcsc": "C12345"},
"C1": {"value": "10uF", "footprint": "0603", "mpn": "C25804", "lcsc": None},
}
snapshot = {k: dict(v) for k, v in bom.items()}
await _resolve_lcsc_codes(ctx=None, bom=bom)
assert bom == snapshot
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_fills_mpn_from_lcsc_column(monkeypatch):
"""LCSC code in the dedicated `lcsc` column populates the empty `mpn`."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_lookup(codes):
assert sorted(codes) == ["C12345"]
return {"C12345": {"lcsc": "C12345", "mpn": "TPS62840DLCR", "manufacturer": "TI"}}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
bom = {
"U1": {"value": "", "footprint": "LQFP-48", "mpn": None, "lcsc": "C12345"},
}
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
assert bom["U1"]["mpn"] == "TPS62840DLCR"
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_pipeline_backstop_is_lcsc_column_only(monkeypatch):
"""Pipeline-stage resolver is a backstop for the unambiguous case only:
LCSC column populated AND MPN slot empty. An LCSC-shaped value sitting
in the MPN slot is NOT resolved here — column-level upload-time
resolution is the primary path."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
called = False
async def fake_lookup(codes):
nonlocal called
called = True
return {}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
bom = {
# MPN field has an LCSC-shaped value — pipeline backstop must NOT touch this.
"U2": {"value": "", "footprint": "", "mpn": "C2040", "lcsc": None},
# A real MPN starting with C must NOT be touched.
"C1": {"value": "10uF", "footprint": "0402", "mpn": "CL05B104KO5NNNC", "lcsc": None},
}
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
assert called is False
assert bom["U2"]["mpn"] == "C2040"
assert bom["C1"]["mpn"] == "CL05B104KO5NNNC"
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_leaves_unresolved_alone(monkeypatch):
"""A miss in purple-parts must not blank out the existing MPN/LCSC."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_lookup(codes):
return {"C99999999": None}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
bom = {
"U3": {"value": "", "footprint": "", "mpn": None, "lcsc": "C99999999"},
}
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
assert bom["U3"]["mpn"] is None # unchanged
assert bom["U3"]["lcsc"] == "C99999999"
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_caches_payload_for_passive_stage(monkeypatch):
"""The resolver must stash the full LCSC payload on ctx.lcsc_data so the
downstream passive extraction can use the description/category without a
second purple-parts call."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
payload = {
"lcsc": "C15850",
"mpn": "CL21A106KAYNNNE",
"manufacturer": "Samsung",
"package": "0805",
"description": "10uF 25V X5R ±10% 0805 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
}
async def fake_lookup(codes):
return {"C15850": payload}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
ctx = _Ctx()
bom = {
"C1": {"value": "10uF", "footprint": "0805", "mpn": None, "lcsc": "C15850"},
"C2": {"value": "10uF", "footprint": "0805", "mpn": None, "lcsc": "C15850"},
}
await pipeline._resolve_lcsc_codes(ctx, bom)
assert ctx.lcsc_data == {"CL21A106KAYNNNE": payload}
assert bom["C1"]["mpn"] == "CL21A106KAYNNNE"
assert bom["C2"]["mpn"] == "CL21A106KAYNNNE"
def test_detect_lcsc_column_all_c_codes():
from backend.services.purple_parts import detect_lcsc_column
csv = b"Reference,Manufacturer Part Number,Value\nU1,C12044,\nU2,C2040,\nU3,C25804,\n"
assert detect_lcsc_column(csv, "Manufacturer Part Number") is True
def test_detect_lcsc_column_mixed_returns_false():
from backend.services.purple_parts import detect_lcsc_column
csv = (b"Reference,Manufacturer Part Number\n"
b"U1,C12044\n"
b"U2,TPS62840DLCR\n")
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
def test_detect_lcsc_column_empty_returns_false():
from backend.services.purple_parts import detect_lcsc_column
csv = b"Reference,Manufacturer Part Number\nU1,\nU2,\n"
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
def test_detect_lcsc_column_missing_column():
from backend.services.purple_parts import detect_lcsc_column
csv = b"Reference,Value\nU1,10uF\n"
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
@pytest.mark.asyncio
async def test_resolve_lcsc_column_bytes_rewrites_csv(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_lookup(codes):
return {
"C12044": {"lcsc": "C12044", "mpn": "STM32F103C8T6"},
"C2040": {"lcsc": "C2040", "mpn": "RP2040"},
"C99999999": None, # unresolved
}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
csv_in = (
b"Reference,Value,Footprint,Manufacturer Part Number\n"
b"U1,STM32,LQFP-48,C12044\n"
b"U2,RP2040,QFN-56,C2040\n"
b"U3,Mystery,SOT-23,C99999999\n"
)
out_bytes, updated, lcsc_map, lcsc_payloads = await purple_parts.resolve_lcsc_column_bytes(
csv_in, mpn_col="Manufacturer Part Number",
)
assert updated == 2
assert lcsc_map == {"C12044": "STM32F103C8T6", "C2040": "RP2040"}
# Full payloads keyed by LCSC id (verbatim from purple-parts) so the wizard
# can short-cut DigiKey when synthesising auto_resolve_specs input.
assert set(lcsc_payloads.keys()) == {"C12044", "C2040"}
assert lcsc_payloads["C12044"]["mpn"] == "STM32F103C8T6"
out_text = out_bytes.decode()
# Resolved rows updated
assert "STM32F103C8T6" in out_text
assert "RP2040" in out_text
# Unresolved row preserved as-is
assert "C99999999" in out_text
# Column order and other columns preserved
assert out_text.splitlines()[0] == "Reference,Value,Footprint,Manufacturer Part Number"
assert "LQFP-48" in out_text
@pytest.mark.asyncio
async def test_resolve_lcsc_column_bytes_no_op_when_disabled(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
csv_in = b"Reference,Manufacturer Part Number\nU1,C12044\n"
out_bytes, updated, lcsc_map, lcsc_payloads = await purple_parts.resolve_lcsc_column_bytes(
csv_in, mpn_col="Manufacturer Part Number",
)
assert updated == 0
assert lcsc_map == {}
assert lcsc_payloads == {}
assert out_bytes == csv_in
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_skips_rows_with_real_mpn(monkeypatch):
"""Rows that already have a real MPN should not be re-resolved."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
call_count = 0
async def fake_lookup(codes):
nonlocal call_count
call_count += 1
return {c: None for c in codes}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
bom = {
"C1": {"value": "10uF", "footprint": "0603", "mpn": "GRM188R71C104KA01D", "lcsc": "C14663"},
}
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
assert call_count == 0 # nothing to resolve
assert bom["C1"]["mpn"] == "GRM188R71C104KA01D"
# ---------------------------------------------------------------------------
# Upload-time classification + lcsc_payloads caching + /lcsc/resolve-passive
# ---------------------------------------------------------------------------
def _make_test_client(tmp_path):
"""Build a FastAPI TestClient backed by a fresh LocalStorageBackend.
Mirrors the production app's middleware stack but defaults the user_id
to ``"local"`` (auth disabled) so we can hit endpoints without Clerk.
"""
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
return TestClient(app)
def test_upload_bom_classifies_components(tmp_path, monkeypatch):
"""Upload-time component classification populates ``component_mpns``
for a mixed-type BOM, even without LCSC resolution running."""
from backend.config import settings
# Disable purple-parts so we exercise the no-LCSC path.
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
client = _make_test_client(tmp_path)
# Create project and upload a mixed BOM.
resp = client.post("/api/projects", json={"name": "mixed"})
assert resp.status_code == 200
project_id = resp.json()["id"]
csv = (
b"Reference,Value,Footprint,Manufacturer Part Number\n"
b"U1,STM32,LQFP-48,STM32F103C8T6\n"
b"C1,10uF,0603,CL10A106KQ8NNNC\n"
b"R1,10k,0603,RC0603FR-0710KL\n"
b"D1,Diode,SOD-123,1N4148WS\n"
)
files = {"file": ("bom.csv", csv, "text/csv")}
resp = client.post(
f"/api/projects/{project_id}/upload/bom",
files=files,
)
assert resp.status_code == 200, resp.text
# The classification should land on ProjectMeta.component_mpns.
resp = client.get(f"/api/projects/{project_id}")
meta = resp.json()
assert meta["component_mpns"] is not None
assert meta["component_mpns"]["ic"] == ["STM32F103C8T6"]
assert sorted(meta["component_mpns"]["passive"]) == sorted(
["CL10A106KQ8NNNC", "RC0603FR-0710KL"]
)
assert meta["component_mpns"]["simple"] == ["1N4148WS"]
def test_upload_bom_populates_lcsc_payloads(tmp_path, monkeypatch):
"""When the MPN column is detected as all-LCSC ids, the full purple-parts
payloads must be cached on ProjectMeta.lcsc_payloads."""
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_lookup(codes):
return {
"C15850": {
"lcsc": "C15850",
"mpn": "CL21A106KAYNNNE",
"manufacturer": "Samsung",
"package": "0805",
"description": "10uF 25V X5R 0805 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
},
"C14663": {
"lcsc": "C14663",
"mpn": "GRM188R71C104KA01D",
"manufacturer": "Murata",
"package": "0603",
"description": "0.1uF 16V X7R 0603 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
},
}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
client = _make_test_client(tmp_path)
resp = client.post("/api/projects", json={"name": "lcsc"})
project_id = resp.json()["id"]
csv = (
b"Reference,Value,Footprint,Manufacturer Part Number\n"
b"C1,10uF,0805,C15850\n"
b"C2,0.1uF,0603,C14663\n"
)
resp = client.post(
f"/api/projects/{project_id}/upload/bom",
files={"file": ("bom.csv", csv, "text/csv")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["lcsc_resolved"] == 2
assert body["lcsc_to_mpn"] == {
"C15850": "CL21A106KAYNNNE", "C14663": "GRM188R71C104KA01D",
}
meta = client.get(f"/api/projects/{project_id}").json()
assert meta["lcsc_payloads"] is not None
assert set(meta["lcsc_payloads"].keys()) == {"C15850", "C14663"}
p = meta["lcsc_payloads"]["C15850"]
assert p["mpn"] == "CL21A106KAYNNNE"
assert p["description"].startswith("10uF")
# Component classification ran on the resolved BOM, so the MPNs are the
# real manufacturer parts (not the LCSC codes).
assert sorted(meta["component_mpns"]["passive"]) == sorted(
["CL21A106KAYNNNE", "GRM188R71C104KA01D"]
)
def test_lcsc_resolve_passive_404_when_lcsc_id_missing(tmp_path, monkeypatch):
"""404 when the requested lcsc_id isn't in the project's cached payloads."""
from backend.config import settings
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
client = _make_test_client(tmp_path)
resp = client.post("/api/projects", json={"name": "p"})
project_id = resp.json()["id"]
resp = client.post(
f"/api/projects/{project_id}/lcsc/resolve-passive",
json={"lcsc_id": "C99999999"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch):
"""First call parses the LCSC description without the LLM. Second call
short-circuits with cached=True and does not invoke auto_resolve_specs."""
from backend.config import settings
from backend.periscopex.models import CapacitorSpecs, ComponentModel
from backend.services import purple_parts
from backend.services import datasheet_extract as extraction_svc
# Billing modules are absent in the open-source repo; the endpoint under
# test only charges when billing is enabled, so skip there.
credits_svc = pytest.importorskip("backend.services.credits")
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
payload = {
"lcsc": "C15850",
"mpn": "CL21A106KAYNNNE",
"manufacturer": "Samsung",
"package": "0805",
"description": "10uF 25V X5R 0805 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
}
async def fake_lookup(codes):
return {"C15850": payload}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
call_count = {"n": 0}
async def fake_auto_resolve_specs(**kwargs):
call_count["n"] += 1
# Drop a synthetic api log entry so the credit-charge path runs.
api_logger = kwargs.get("api_logger")
if api_logger is not None:
api_logger.log(
stage="auto_resolve", identifier=kwargs["mpn"],
model="claude-haiku-4-5-20251001",
input_tokens=500, output_tokens=200,
cache_creation_input_tokens=0, cache_read_input_tokens=0,
duration_ms=100, stop_reason="end_turn", turns=1,
)
return ComponentModel(
mpn=kwargs["mpn"],
specs=CapacitorSpecs(
value_farads=10e-6,
value_formatted="10uF",
voltage_rating_v="25V",
tolerance="±10%",
dielectric="X5R",
package="0805",
),
)
monkeypatch.setattr(extraction_svc, "auto_resolve_specs", fake_auto_resolve_specs)
# Need credits to charge against — grant enough.
from backend.services.storage import LocalStorageBackend
storage = LocalStorageBackend(tmp_path)
credits_svc.grant(storage, "local", 100.0, "trial_grant")
from backend.main import app
from fastapi.testclient import TestClient
app.state.storage = storage
client = TestClient(app)
resp = client.post("/api/projects", json={"name": "lcsc"})
project_id = resp.json()["id"]
csv = b"Reference,Value,Footprint,Manufacturer Part Number\nC1,10uF,0805,C15850\n"
resp = client.post(
f"/api/projects/{project_id}/upload/bom",
files={"file": ("bom.csv", csv, "text/csv")},
)
assert resp.status_code == 200, resp.text
# First call: catalog parse, no LLM.
resp = client.post(
f"/api/projects/{project_id}/lcsc/resolve-passive",
json={"lcsc_id": "C15850"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["mpn"] == "CL21A106KAYNNNE"
assert body["lcsc_id"] == "C15850"
assert body["cached"] is False
assert body["model"]["mpn"] == "CL21A106KAYNNNE"
assert call_count["n"] == 0
# Library copy should exist for cross-project reuse.
from backend.periscopex.utils import safe_mpn
safe = safe_mpn("CL21A106KAYNNNE")
assert storage.exists(f"library/passives/{safe}.json")
# Second call: short-circuits via the project model file.
resp = client.post(
f"/api/projects/{project_id}/lcsc/resolve-passive",
json={"lcsc_id": "C15850"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["cached"] is True
assert body["model"]["mpn"] == "CL21A106KAYNNNE"
assert call_count["n"] == 0 # still no LLM
# ---------------------------------------------------------------------------
# Reverse MPN → LCSC lookup (by-mpn) + upload-time real-MPN passive enrich
# ---------------------------------------------------------------------------
def test_pick_exact_matches_case_and_space_insensitive():
from backend.services.purple_parts import _pick_exact
cands = [
{"mpn": "OTHER-PART", "lcsc": "C1"},
{"mpn": "tps62840 dlcr", "lcsc": "C2040"},
]
hit = _pick_exact("TPS62840DLCR", cands)
assert hit is not None
assert hit["lcsc"] == "C2040"
def test_pick_exact_rejects_prefix_only():
from backend.services.purple_parts import _pick_exact
# Only a longer family part is returned — not an exact match → reject so it
# can't pollute the shared library.
cands = [{"mpn": "RC0603FR-0710KLZZ", "lcsc": "C999"}]
assert _pick_exact("RC0603FR-0710KL", cands) is None
assert _pick_exact("X", []) is None
class _FakeResp:
def __init__(self, data):
self._data = data
def raise_for_status(self):
return None
def json(self):
return self._data
class _FakeAsyncClient:
"""Stand-in for httpx.AsyncClient used by lookup_mpn_batch — serves the
POST /v1/parts/by-mpn/batch endpoint from a {mpn: [candidates]} routes map."""
def __init__(self, routes):
self.routes = routes
self.posts: list[dict] = [] # captured request bodies
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def post(self, url, headers=None, json=None):
body = json or {}
self.posts.append(body)
mpns = body.get("mpns", [])
return _FakeResp({"results": {m: self.routes.get(m, []) for m in mpns}})
@pytest.mark.asyncio
async def test_lookup_mpn_batch_disabled_is_noop(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
out = await purple_parts.lookup_mpn_batch(["TPS62840DLCR"])
assert out == {"TPS62840DLCR": None}
@pytest.mark.asyncio
async def test_lookup_mpn_batch_exact_dedup_and_miss(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_token():
return "tok"
monkeypatch.setattr(purple_parts, "_get_identity_token", fake_token)
routes = {
"TPS62840DLCR": [{"lcsc": "C2040", "mpn": "TPS62840DLCR", "description": "buck"}],
"RC0603FR-0710KL": [
{"lcsc": "C999", "mpn": "RC0603FR-0710KLZZ", "description": "prefix only"}
],
"MISSING1": [],
}
fake = _FakeAsyncClient(routes)
monkeypatch.setattr(purple_parts.httpx, "AsyncClient", lambda *a, **kw: fake)
out = await purple_parts.lookup_mpn_batch(
["TPS62840DLCR", "TPS62840DLCR", "RC0603FR-0710KL", "MISSING1"]
)
assert out["TPS62840DLCR"]["lcsc"] == "C2040" # exact hit kept
assert out["RC0603FR-0710KL"] is None # prefix-only rejected
assert out["MISSING1"] is None # real miss
# Deduped into a single batch POST; TPS appears once in the request body.
assert len(fake.posts) == 1
assert fake.posts[0]["mpns"].count("TPS62840DLCR") == 1
@pytest.mark.asyncio
async def test_lookup_mpn_batch_no_token_returns_all_none(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def no_token():
return None
monkeypatch.setattr(purple_parts, "_get_identity_token", no_token)
out = await purple_parts.lookup_mpn_batch(["TPS62840DLCR", "RP2040"])
assert out == {"TPS62840DLCR": None, "RP2040": None}
@pytest.mark.asyncio
async def test_lookup_mpn_batch_chunks_large_input(monkeypatch):
"""More than _BATCH_SIZE unique MPNs are split across multiple POSTs."""
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
monkeypatch.setattr(purple_parts, "_BATCH_SIZE", 2)
async def fake_token():
return "tok"
monkeypatch.setattr(purple_parts, "_get_identity_token", fake_token)
routes = {m: [{"lcsc": f"C{i}", "mpn": m}] for i, m in enumerate(["A", "B", "C"])}
fake = _FakeAsyncClient(routes)
monkeypatch.setattr(purple_parts.httpx, "AsyncClient", lambda *a, **kw: fake)
out = await purple_parts.lookup_mpn_batch(["A", "B", "C"])
assert {k: v["lcsc"] for k, v in out.items()} == {"A": "C0", "B": "C1", "C": "C2"}
assert len(fake.posts) == 2 # 3 MPNs / chunk size 2
assert [len(p["mpns"]) for p in fake.posts] == [2, 1]
def test_upload_bom_enriches_real_mpn_passives_via_by_mpn(tmp_path, monkeypatch):
"""For a genuine MPN column, upload reverse-looks-up passives in the LCSC
catalogue and caches lcsc_to_mpn + lcsc_payloads so the wizard resolves them
through the same machinery as the LCSC-column flow. ICs/simple are excluded,
catalogue misses fall through to the pipeline, and the CSV is not rewritten."""
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
captured = {}
async def fake_by_mpn(mpns, **kw):
captured["mpns"] = list(mpns)
return {
"CL10A106KQ8NNNC": {
"lcsc": "C1525",
"mpn": "CL10A106KQ8NNNC",
"manufacturer": "Samsung",
"package": "0603",
"description": "10uF 6.3V X5R 0603 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
},
"RC0603FR-0710KL": None, # catalogue miss
}
monkeypatch.setattr(purple_parts, "lookup_mpn_batch", fake_by_mpn)
client = _make_test_client(tmp_path)
resp = client.post("/api/projects", json={"name": "realmpn"})
project_id = resp.json()["id"]
csv = (
b"Reference,Value,Footprint,Manufacturer Part Number\n"
b"U1,STM32,LQFP-48,STM32F103C8T6\n"
b"C1,10uF,0603,CL10A106KQ8NNNC\n"
b"R1,10k,0603,RC0603FR-0710KL\n"
b"D1,Diode,SOD-123,1N4148WS\n"
)
resp = client.post(
f"/api/projects/{project_id}/upload/bom",
files={"file": ("bom.csv", csv, "text/csv")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["lcsc_resolved"] == 0 # no CSV rewrite
assert body["lcsc_to_mpn"] == {"C1525": "CL10A106KQ8NNNC"}
# Only passives are looked up (IC + simple excluded).
assert sorted(captured["mpns"]) == sorted(
["CL10A106KQ8NNNC", "RC0603FR-0710KL"]
)
meta = client.get(f"/api/projects/{project_id}").json()
assert set(meta["lcsc_payloads"].keys()) == {"C1525"}
assert meta["lcsc_payloads"]["C1525"]["mpn"] == "CL10A106KQ8NNNC"
assert meta["lcsc_to_mpn"] == {"C1525": "CL10A106KQ8NNNC"}
+111
View File
@@ -0,0 +1,111 @@
"""Datasheet quote must appear in the PDF text, not just in the model output."""
from pathlib import Path
from backend.periscopex.models import Finding
from backend.periscopex.quote_verify import (
locate_quote,
quote_in_text,
verify_finding_citations,
)
from backend.periscopex.review_parse import parse_submit_review as _parse_review
from backend.services.llm.pdf_ingest import make_text_pdf
def test_quote_in_text_folds_whitespace_and_mu():
assert quote_in_text(
"CIO = 12 pF typical",
"CIO = 12\npF typical",
)
assert quote_in_text("I/O capacitance 12 µF", "I/O capacitance 12 μF")
assert not quote_in_text(
"TPD2E007 IO-to-GND diode forward-conducts near -0.8 V",
"The TPD2E007 is a bidirectional ESD protection device.",
)
def test_locate_quote_page_window(tmp_path: Path):
pdf = tmp_path / "part.pdf"
pdf.write_bytes(make_text_pdf([
"cover",
"Working voltage Vrwm is ±13 V bidirectional back-to-back diodes.",
"package drawing",
]))
reason, page = locate_quote(
pdf, 1,
"Working voltage Vrwm is ±13 V bidirectional back-to-back diodes.",
)
assert reason == "ok"
assert page == 2 # cited p.1, found on p.2 within ±1
def test_fake_quote_demotes_error(tmp_path: Path):
pdf = tmp_path / "TPD2E007.pdf"
pdf.write_bytes(make_text_pdf([
"TPD2E007 2-channel ESD protection",
"Bidirectional working voltage ±13 V. Suitable for audio interfaces.",
]))
findings = [
Finding(
designator="U14",
mpn="TPD2E007",
finding="unidirectional clamp clips audio",
why="IO-to-GND diode conducts at -0.8 V.",
status="ERROR",
source_page=2,
source_quote="IO-to-GND diode forward-conducts near -0.8 V clipping audio",
reference="TPD2E007 datasheet p.2",
)
]
verify_finding_citations(
findings,
default_pdf=pdf,
default_mpn="TPD2E007",
)
assert findings[0].status == "WARNING"
assert findings[0].why.startswith("Unverified: cited text not found")
def test_real_quote_keeps_error(tmp_path: Path):
pdf = tmp_path / "AXP.pdf"
pdf.write_bytes(make_text_pdf([
"Connect FB5 to the output sense node of DCDC5.",
]))
findings = [
Finding(
designator="U16",
mpn="AXP2101",
finding="FB5 floating",
why="FB5 is NC.",
status="ERROR",
source_page=1,
source_quote="Connect FB5 to the output sense node of DCDC5.",
reference="AXP2101 datasheet p.1",
)
]
verify_finding_citations(
findings, default_pdf=pdf, default_mpn="AXP2101",
)
assert findings[0].status == "ERROR"
assert not findings[0].why.startswith("Unverified:")
def test_parse_warning_without_quote():
result = _parse_review(
{
"findings": [
{
"finding": "maybe clip",
"why": "typical unidirectional array",
"status": "WARNING",
"source_page": 3,
"source_quote": "",
}
],
"checked_areas": [],
},
"U14",
"TPD2E007",
)
assert result.findings[0].status == "WARNING"
assert result.findings[0].why.startswith("Unverified: no verbatim datasheet quote.")