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:
@@ -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")
|
||||
@@ -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-")
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user