diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 90bc954..541250e 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -26,7 +26,7 @@ export function safeMpn(mpn: string): string {
return mpn.replace(/\//g, "_").replace(/:/g, "_");
}
-const BASE = process.env.NEXT_PUBLIC_API_URL || "";
+const BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:18741";
// Auth token getter — set by useAuthApi hook
let _getToken: (() => Promise) | null = null;
@@ -372,6 +372,61 @@ export async function checkLibrary(
return res.json();
}
+export interface LibraryIC {
+ mpn: string;
+ type: "ic";
+ subtype: string;
+ pin_count: number;
+ has_ratings: boolean;
+ has_datasheet: boolean;
+}
+
+export interface LibraryPassive {
+ mpn: string;
+ type: "passive";
+ subtype: string;
+ description: string;
+ regex: string;
+}
+
+export interface LibrarySimple {
+ mpn: string;
+ type: "simple";
+ specs_type: string;
+ subtype: string;
+ param_count: number;
+ has_datasheet: boolean;
+}
+
+export interface LibraryDatasheet {
+ mpn: string;
+ hash: string | null;
+ has_extraction: boolean;
+ has_model: boolean;
+}
+
+export interface LibraryCatalog {
+ ics: LibraryIC[];
+ passives: LibraryPassive[];
+ simple: LibrarySimple[];
+ datasheets: LibraryDatasheet[];
+}
+
+export async function fetchLibrary(): Promise {
+ const res = await authFetch(`${BASE}/api/library`, { cache: "no-store" });
+ if (!res.ok) throw new Error("Failed to load component library");
+ return res.json();
+}
+
+export async function fetchLibraryDatasheetUrl(mpn: string): Promise {
+ const res = await authFetch(
+ `${BASE}/api/library/datasheet/${encodeURIComponent(mpn)}`,
+ );
+ if (!res.ok) return null;
+ const blob = await res.blob();
+ return URL.createObjectURL(blob);
+}
+
// --- Pipeline ---
export async function startPipeline(projectId: string) {
@@ -849,33 +904,53 @@ export async function resolveLcscPassive(
return res.json();
}
-export class DigiKeyFetchError extends Error {
+export class DatasheetFetchError extends Error {
url: string | null;
- constructor(message: string, url: string | null) {
+ source: string | null;
+ constructor(message: string, url: string | null, source: string | null = null) {
super(message);
- this.name = "DigiKeyFetchError";
+ this.name = "DatasheetFetchError";
this.url = url;
+ this.source = source;
}
}
+/** @deprecated Use DatasheetFetchError */
+export const DigiKeyFetchError = DatasheetFetchError;
+
+export async function fetchAutoDatasheet(
+ mpn: string,
+ lcscId?: string,
+): Promise<{ file: File; url: string | null; source: string | null }> {
+ const params = new URLSearchParams({ mpn });
+ if (lcscId) params.set("lcsc", lcscId);
+ const res = await authFetch(
+ `${BASE}/api/datasheets/fetch?${params.toString()}`,
+ );
+ if (!res.ok) {
+ const err = await res
+ .json()
+ .catch(() => ({ detail: "Fetch failed", url: null, source: null }));
+ throw new DatasheetFetchError(
+ err.detail || "Failed to fetch datasheet",
+ err.url ?? null,
+ err.source ?? null,
+ );
+ }
+ const url = res.headers.get("X-Datasheet-Url");
+ const source = res.headers.get("X-Datasheet-Source");
+ const blob = await res.blob();
+ return {
+ file: new File([blob], `${mpn}.pdf`, { type: "application/pdf" }),
+ url,
+ source,
+ };
+}
+
export async function fetchDigikeyDatasheet(
mpn: string,
): Promise<{ file: File; url: string | null }> {
- const res = await authFetch(
- `${BASE}/api/digikey/datasheet?mpn=${encodeURIComponent(mpn)}`,
- );
- if (!res.ok) {
- const err = await res
- .json()
- .catch(() => ({ detail: "Fetch failed", url: null }));
- throw new DigiKeyFetchError(
- err.detail || "Failed to fetch datasheet from DigiKey",
- err.url ?? null,
- );
- }
- const url = res.headers.get("X-Datasheet-Url");
- const blob = await res.blob();
- return { file: new File([blob], `${mpn}.pdf`, { type: "application/pdf" }), url };
+ return fetchAutoDatasheet(mpn);
}
// --- Datasheets ---
diff --git a/frontend/src/lib/csp-hosts.ts b/frontend/src/lib/csp-hosts.ts
index 1a880d7..5a28e83 100644
--- a/frontend/src/lib/csp-hosts.ts
+++ b/frontend/src/lib/csp-hosts.ts
@@ -5,6 +5,11 @@
export const CSP_SCRIPT_HOSTS: string[] = [];
-export const CSP_CONNECT_HOSTS: string[] = [];
+export const CSP_CONNECT_HOSTS: string[] = [
+ "http://127.0.0.1:18741",
+ "http://localhost:18741",
+ "http://127.0.0.1:8080",
+ "http://localhost:8080",
+];
export const CSP_FRAME_HOSTS: string[] = [];
diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts
index 7121f15..7e0be7a 100644
--- a/frontend/src/lib/version.ts
+++ b/frontend/src/lib/version.ts
@@ -1,4 +1,4 @@
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
-export const APP_VERSION = "2.6.0";
-export const APP_VERSION_DATE = "2026-07-12";
+export const APP_VERSION = "2.10.0";
+export const APP_VERSION_DATE = "2026-08-27";
diff --git a/scripts/update-pinscope.sh b/scripts/update-pinscope.sh
new file mode 100755
index 0000000..9ecf8ba
--- /dev/null
+++ b/scripts/update-pinscope.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# Rebuild and restart the Pinscope stack on the production host
+# (pinscope.michelebigi.it). Run from anywhere:
+#
+# ./scripts/update-pinscope.sh
+#
+# Optional:
+# SITE=https://pinscope.michelebigi.it ./scripts/update-pinscope.sh
+# ./scripts/update-pinscope.sh --no-pull
+#
+# Does not touch ./data (projects + component library).
+
+set -euo pipefail
+
+SITE="${SITE:-https://pinscope.michelebigi.it}"
+DO_PULL=1
+for arg in "$@"; do
+ case "$arg" in
+ --no-pull) DO_PULL=0 ;;
+ -h|--help)
+ sed -n '2,12p' "$0"
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $arg" >&2
+ echo "Usage: $0 [--no-pull]" >&2
+ exit 2
+ ;;
+ esac
+done
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+log() { printf '\n==> %s\n' "$*"; }
+die() { printf 'error: %s\n' "$*" >&2; exit 1; }
+
+if [[ "${ENVIRONMENT:-}" == "production" ]]; then
+ die "ENVIRONMENT=production is set. The backend will refuse to start without Clerk. Unset it for this self-hosted instance."
+fi
+
+if [[ ! -f docker-compose.yml ]]; then
+ die "docker-compose.yml not found in $ROOT — run this from the Pinscope checkout."
+fi
+
+compose() {
+ if docker compose version >/dev/null 2>&1; then
+ docker compose "$@"
+ elif command -v docker-compose >/dev/null 2>&1; then
+ docker-compose "$@"
+ else
+ die "docker compose is not installed"
+ fi
+}
+
+upsert_env() {
+ local key="$1" value="$2" file="$3"
+ python3 - "$key" "$value" "$file" <<'PY'
+import sys
+from pathlib import Path
+
+key, value, path = sys.argv[1], sys.argv[2], Path(sys.argv[3])
+text = path.read_text() if path.exists() else ""
+lines = text.splitlines()
+out = []
+found = False
+for line in lines:
+ stripped = line.strip()
+ if stripped.startswith("#"):
+ out.append(line)
+ continue
+ if stripped.split("=", 1)[0].strip() == key:
+ out.append(f"{key}={value}")
+ found = True
+ else:
+ out.append(line)
+if not found:
+ if out and out[-1] != "":
+ out.append("")
+ out.append(f"{key}={value}")
+path.write_text("\n".join(out) + ("\n" if out else ""))
+PY
+}
+
+read_env() {
+ local key="$1" file="$2"
+ python3 - "$key" "$file" <<'PY'
+import sys
+from pathlib import Path
+key, path = sys.argv[1], Path(sys.argv[2])
+if not path.exists():
+ sys.exit(0)
+for line in path.read_text().splitlines():
+ s = line.strip()
+ if not s or s.startswith("#") or "=" not in s:
+ continue
+ k, _, v = s.partition("=")
+ if k.strip() == key:
+ print(v)
+ break
+PY
+}
+
+if [[ ! -f .env ]]; then
+ if [[ -f backend/.env ]]; then
+ log "No ./.env — copying backend/.env"
+ cp backend/.env .env
+ elif [[ -f backend/.env.example ]]; then
+ log "No ./.env — copying backend/.env.example (you must set DEEPSEEK_API_KEY)"
+ cp backend/.env.example .env
+ else
+ die "No .env found. Create one with DEEPSEEK_API_KEY at $ROOT/.env"
+ fi
+fi
+
+log "Ensuring public URL in .env ($SITE)"
+upsert_env NEXT_PUBLIC_API_URL "$SITE" .env
+# JSON list — keep it a single line so docker compose / pydantic-settings parse it.
+upsert_env CORS_ORIGINS "[\"$SITE\"]" .env
+
+KEY="$(read_env DEEPSEEK_API_KEY .env || true)"
+if [[ -z "$KEY" || "$KEY" == "sk-..." ]]; then
+ die "Set a real DEEPSEEK_API_KEY in $ROOT/.env before updating."
+fi
+
+mkdir -p data
+
+if [[ "$DO_PULL" -eq 1 ]]; then
+ if [[ -d .git ]]; then
+ log "git pull"
+ branch="$(git rev-parse --abbrev-ref HEAD)"
+ git pull --ff-only origin "$branch" || git pull --ff-only
+ else
+ log "Not a git checkout — skipping pull (use --no-pull next time to silence this)"
+ fi
+else
+ log "Skipping git pull (--no-pull)"
+fi
+
+log "docker compose up -d --build (data/ is kept)"
+compose up -d --build
+
+log "Waiting for backend"
+ok=0
+for _ in $(seq 1 30); do
+ if curl -fsS "http://127.0.0.1:8080/api/library" >/dev/null 2>&1 \
+ || curl -fsS "http://127.0.0.1:8080/docs" >/dev/null 2>&1; then
+ ok=1
+ break
+ fi
+ sleep 1
+done
+if [[ "$ok" -ne 1 ]]; then
+ echo "Backend did not become ready on :8080. Last logs:" >&2
+ compose logs --tail 80 backend >&2 || true
+ exit 1
+fi
+
+log "Done. Site should be $SITE (nginx/Caddy still fronts :3000 / :8080)."
+compose ps
diff --git a/skills/extract-pintable/SKILL.md b/skills/extract-pintable/SKILL.md
index 280ace1..969bed5 100644
--- a/skills/extract-pintable/SKILL.md
+++ b/skills/extract-pintable/SKILL.md
@@ -41,7 +41,18 @@ Decode the MPN and package details into a single `PackageInfo`:
Look for an "Ordering Information" or "Device Information" table in the datasheet — most datasheets have one.
-### 4. Assign component subtype (taxonomy)
+### 4. Extract absolute maximum ratings
+
+Copy the **Absolute Maximum Ratings** table (not Recommended Operating Conditions). For each row that a reviewer would need to compare against the schematic rails:
+
+- `parameter` (str) — as printed (`VCC`, `VIN`, `I/O pin voltage`, `Storage temperature`, …)
+- `min` / `max` (number or null) — numeric limit; omit the other side if the table only lists one
+- `unit` (str) — `V`, `mA`, `°C`, …
+- `source_page` (int) — 1-based datasheet page of that row
+
+Include supply voltages, pin/input voltages, input current, and temperature. Skip ESD human-body-model rows unless they are the only voltage limit given. Do not invent numbers; if the table is a raster with no readable values, return an empty array.
+
+### 5. Assign component subtype (taxonomy)
The existing IC taxonomy subtypes are provided in the system prompt under `EXISTING IC TAXONOMY SUBTYPES`. Pick the best matching subtype based on the component's MPN, package info, and pin names.
@@ -49,7 +60,7 @@ If no existing subtype fits, propose a new one following the dot-notation conven
Set the chosen subtype on the `component_subtype` field.
-### 5. Quality checks
+### 6. Quality checks
Before producing output, verify:
- Pin count matches what the datasheet says for this package
@@ -57,7 +68,7 @@ Before producing output, verify:
- No pins are missing (compare against the datasheet's stated pin count)
- Pin names look reasonable (not garbled OCR artifacts)
-### 6. Validate and output
+### 7. Validate and output
Validate your extraction against the output schema:
diff --git a/skills/extract-pintable/schema.json b/skills/extract-pintable/schema.json
index a7c29ef..b6145ec 100644
--- a/skills/extract-pintable/schema.json
+++ b/skills/extract-pintable/schema.json
@@ -30,6 +30,20 @@
},
"required": ["number", "name"]
}
+ },
+ "absolute_maximum_ratings": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "parameter": {"type": "string"},
+ "min": {"type": ["number", "null"]},
+ "max": {"type": ["number", "null"]},
+ "unit": {"type": "string"},
+ "source_page": {"type": "integer"}
+ },
+ "required": ["parameter", "unit", "source_page"]
+ }
}
},
"required": ["component_subtype", "package_info", "pintable"]
diff --git a/skills/extract-pintable/validate.py b/skills/extract-pintable/validate.py
index cb7c770..fd24d24 100644
--- a/skills/extract-pintable/validate.py
+++ b/skills/extract-pintable/validate.py
@@ -47,6 +47,21 @@ def validate(data: dict) -> list[str]:
if dupes:
errors.append(f"Duplicate pin numbers: {dupes}")
+ if "absolute_maximum_ratings" in data:
+ ratings = data["absolute_maximum_ratings"]
+ if ratings is not None and not isinstance(ratings, list):
+ errors.append("absolute_maximum_ratings must be an array")
+ elif isinstance(ratings, list):
+ for i, row in enumerate(ratings):
+ if not isinstance(row, dict):
+ errors.append(f"absolute_maximum_ratings[{i}] must be an object")
+ continue
+ for f in ("parameter", "unit", "source_page"):
+ if f not in row:
+ errors.append(
+ f"absolute_maximum_ratings[{i}] missing required field: {f}"
+ )
+
return errors
diff --git a/tests/test_cost_estimator_model_aware.py b/tests/test_cost_estimator_model_aware.py
index 8cf50e9..a50e790 100644
--- a/tests/test_cost_estimator_model_aware.py
+++ b/tests/test_cost_estimator_model_aware.py
@@ -30,15 +30,15 @@ from backend.services.llm.pricing import PRICING
def restore_settings():
"""Snapshot every per-stage routing field; restore after the test."""
fields = [
- "anthropic_model", "gemini_model",
+ "anthropic_model", "gemini_model", "deepseek_model",
"provider_default", "provider_validation",
"provider_pintable", "provider_pattern", "provider_specs",
"provider_auto_resolve",
- "model_validation", "model_validation_gemini",
- "model_pintable", "model_pintable_gemini",
- "model_pattern", "model_pattern_gemini",
- "model_specs", "model_specs_gemini",
- "model_auto_resolve", "model_auto_resolve_gemini",
+ "model_validation", "model_validation_gemini", "model_validation_deepseek",
+ "model_pintable", "model_pintable_gemini", "model_pintable_deepseek",
+ "model_pattern", "model_pattern_gemini", "model_pattern_deepseek",
+ "model_specs", "model_specs_gemini", "model_specs_deepseek",
+ "model_auto_resolve", "model_auto_resolve_gemini", "model_auto_resolve_deepseek",
]
snapshot = {f: getattr(settings, f) for f in fields if hasattr(settings, f)}
yield
@@ -67,25 +67,21 @@ def test_review_cost_changes_with_validation_model(restore_settings):
def test_review_cost_changes_with_validation_provider(restore_settings):
- """Flipping PROVIDER_VALIDATION between anthropic and gemini must
+ """Flipping PROVIDER_VALIDATION between deepseek and anthropic must
swap the rate table the estimator pulls from."""
+ settings.provider_validation = "deepseek"
+ settings.model_validation_deepseek = "deepseek-v4-pro"
+ deepseek_cost = estimate_stage_cost_usd("review")
+
settings.provider_validation = "anthropic"
settings.model_validation = "claude-sonnet-4-6"
anthropic_cost = estimate_stage_cost_usd("review")
- settings.provider_validation = "gemini"
- settings.gemini_model = "gemini-3.1-pro-preview"
- settings.model_validation_gemini = "" # fall back to gemini_model
- gemini_cost = estimate_stage_cost_usd("review")
-
- # Both > 0 and they're different — the test doesn't lock direction
- # because the cache-read multiplier asymmetry between providers
- # could legitimately swing it either way as the rate tables evolve.
+ assert deepseek_cost > 0
assert anthropic_cost > 0
- assert gemini_cost > 0
- assert abs(anthropic_cost - gemini_cost) > 0.01, (
+ assert abs(deepseek_cost - anthropic_cost) > 0.01, (
f"expected materially different costs, got "
- f"anthropic={anthropic_cost!r} gemini={gemini_cost!r}"
+ f"deepseek={deepseek_cost!r} anthropic={anthropic_cost!r}"
)
diff --git a/tests/test_datasheet_finder.py b/tests/test_datasheet_finder.py
new file mode 100644
index 0000000..79b86fc
--- /dev/null
+++ b/tests/test_datasheet_finder.py
@@ -0,0 +1,121 @@
+"""Datasheet auto-finder: MPN matching, LCSC pick, TI slugs, routing."""
+
+from __future__ import annotations
+
+import asyncio
+
+from backend.services.datasheet_finder import (
+ DatasheetHit,
+ _pick_lcsc_product,
+ _ti_slugs,
+ find_datasheet,
+ mpn_matches,
+)
+
+
+def test_mpn_matches_exact_and_packing():
+ assert mpn_matches("CH340E", "CH340E")
+ assert mpn_matches("SPX3819M5-L-3-3", "SPX3819M5-L-3-3/TR")
+ assert mpn_matches("MSPM0G3507SPTR", "MSPM0G3507")
+ assert mpn_matches("MSPM0G3507", "MSPM0G3507SPTR")
+ # Variant letter is a different die — must not match
+ assert not mpn_matches("CH340", "CH340E")
+ assert not mpn_matches("CH340E", "CH340G")
+ assert not mpn_matches("TLV9062", "TLV9002")
+
+
+def test_pick_lcsc_prefers_exact_model():
+ products = [
+ {"productModel": "TPSPX3819M5-L-3-3", "pdfUrl": "http://a.pdf"},
+ {"productModel": "SPX3819M5-L-3-3", "pdfUrl": "http://b.pdf"},
+ {"productModel": "SPX3819M5-L-3-3/TR", "pdfUrl": "http://c.pdf"},
+ ]
+ picked = _pick_lcsc_product("SPX3819M5-L-3-3", products)
+ assert picked is not None
+ assert picked["pdfUrl"] == "http://b.pdf"
+
+
+def test_pick_lcsc_packing_fallback():
+ products = [
+ {"productModel": "CH340E/TR", "pdfUrl": "http://e.pdf"},
+ ]
+ picked = _pick_lcsc_product("CH340E", products)
+ assert picked is not None
+ assert picked["pdfUrl"] == "http://e.pdf"
+
+
+def test_pick_lcsc_rejects_unrelated():
+ products = [
+ {"productModel": "CH340G", "pdfUrl": "http://g.pdf"},
+ {"productModel": "USB3300", "pdfUrl": "http://u.pdf"},
+ ]
+ assert _pick_lcsc_product("CH340E", products) is None
+
+
+def test_ti_slugs_include_family():
+ slugs = _ti_slugs("MSPM0G3507SPTR")
+ assert slugs[0] == "mspm0g3507sptr"
+ assert "mspm0g3507" in slugs
+ # Must not clip "sptr" as if it were "...sp" + "tr".
+ assert "mspm0g3507sp" not in slugs
+ assert "mspm0g3507s" not in slugs
+
+
+def test_find_datasheet_uses_lcsc_then_skips_empty(monkeypatch):
+ async def fake_lcsc(mpn, lcsc_id=None):
+ return DatasheetHit(
+ mpn, pdf_bytes=b"%PDF-" + b"x" * 8000, url="https://datasheet.lcsc.com/x.pdf",
+ source="lcsc",
+ )
+
+ monkeypatch.setattr(
+ "backend.services.datasheet_finder._from_lcsc", fake_lcsc,
+ )
+
+ async def boom(mpn):
+ raise AssertionError("later sources should not run")
+
+ monkeypatch.setattr("backend.services.datasheet_finder._from_ti", boom)
+ monkeypatch.setattr("backend.services.datasheet_finder._from_digikey", boom)
+
+ hit = asyncio.run(find_datasheet("CH340E"))
+ assert hit.ok
+ assert hit.source == "lcsc"
+ assert hit.pdf_bytes.startswith(b"%PDF-")
+
+
+def test_find_datasheet_falls_through_to_ti(monkeypatch):
+ async def miss_lcsc(mpn, lcsc_id=None):
+ return None
+
+ async def hit_ti(mpn):
+ return DatasheetHit(
+ mpn, pdf_bytes=b"%PDF-" + b"t" * 8000,
+ url="https://www.ti.com/lit/ds/symlink/mspm0g3507.pdf",
+ source="ti",
+ )
+
+ monkeypatch.setattr("backend.services.datasheet_finder._from_lcsc", miss_lcsc)
+ monkeypatch.setattr("backend.services.datasheet_finder._from_ti", hit_ti)
+
+ async def boom(mpn):
+ raise AssertionError("digikey should not run")
+
+ monkeypatch.setattr("backend.services.datasheet_finder._from_digikey", boom)
+
+ hit = asyncio.run(find_datasheet("MSPM0G3507SPTR"))
+ assert hit.ok
+ assert hit.source == "ti"
+
+
+def test_find_datasheet_all_miss(monkeypatch):
+ async def miss(*args, **kwargs):
+ return None
+
+ monkeypatch.setattr("backend.services.datasheet_finder._from_lcsc", miss)
+ monkeypatch.setattr("backend.services.datasheet_finder._from_ti", miss)
+ monkeypatch.setattr("backend.services.datasheet_finder._from_digikey", miss)
+
+ hit = asyncio.run(find_datasheet("NOTAREALPART123"))
+ assert not hit.ok
+ assert "No datasheet found" in (hit.error or "")
diff --git a/tests/test_deepseek_provider.py b/tests/test_deepseek_provider.py
new file mode 100644
index 0000000..4acab3d
--- /dev/null
+++ b/tests/test_deepseek_provider.py
@@ -0,0 +1,184 @@
+"""DeepSeek provider: PDF ingest, OpenAI message translation, routing, pricing."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from backend.config import settings
+from backend.services.llm.pdf_ingest import extract_pdf_text, make_text_pdf
+from backend.services.llm.deepseek_provider import (
+ _is_vision_model,
+ _to_openai_tool,
+ _to_openai_tool_choice,
+ completion_from_openai,
+ messages_to_openai,
+)
+from backend.services.llm.local_skill import load_skill_markdown, load_skill_validator
+from backend.services.llm.pricing import PRICING, cost_for_entry
+from backend.services.llm.types import (
+ Message,
+ PdfBlock,
+ TextBlock,
+ ToolCall,
+ ToolResultBlock,
+ ToolSchema,
+)
+
+
+@pytest.fixture
+def sample_pdf(tmp_path: Path) -> Path:
+ pdf = tmp_path / "ds.pdf"
+ pdf.write_bytes(make_text_pdf([
+ "Pin configuration\n1 VCC Power\n2 GND Ground\n3 TXD UART transmit",
+ "Absolute maximum ratings\nVCC 6.0 V",
+ ]))
+ return pdf
+
+
+def test_extract_pdf_text_includes_page_markers(sample_pdf: Path):
+ text = extract_pdf_text(sample_pdf)
+ assert "page 1" in text.lower() or "--- page 1 ---" in text
+ assert "VCC" in text
+ assert sample_pdf.name in text
+
+
+def test_vision_model_detection():
+ assert _is_vision_model("deepseek-v4-flash-vision-exp")
+ assert not _is_vision_model("deepseek-v4-pro")
+ assert not _is_vision_model("deepseek-v4-flash")
+
+
+def test_messages_to_openai_pdf_becomes_text(sample_pdf: Path):
+ messages = [
+ Message("user", [
+ PdfBlock(path=sample_pdf, cacheable=True),
+ TextBlock("Extract the pin table."),
+ ]),
+ ]
+ out = messages_to_openai(messages, vision=False)
+ assert len(out) == 1
+ assert out[0]["role"] == "user"
+ content = out[0]["content"]
+ if isinstance(content, str):
+ blob = content
+ else:
+ blob = " ".join(p.get("text", "") for p in content if p.get("type") == "text")
+ assert not any(p.get("type") == "image_url" for p in content)
+ assert "VCC" in blob
+ assert "Extract the pin table" in blob
+
+
+def test_messages_to_openai_tool_roundtrip():
+ messages = [
+ Message("assistant", [
+ TextBlock("checking", reasoning_content="I should query the net."),
+ ToolCall(id="call_1", name="get_net_for_pin", input={"ref": "U2", "pin": "3"}),
+ ]),
+ Message("user", [
+ ToolResultBlock(tool_use_id="call_1", name="get_net_for_pin", content="UART_TX"),
+ TextBlock("continue"),
+ ]),
+ ]
+ out = messages_to_openai(messages, vision=False)
+ assert out[0]["role"] == "assistant"
+ assert out[0]["reasoning_content"] == "I should query the net."
+ assert out[0]["tool_calls"][0]["function"]["name"] == "get_net_for_pin"
+ args = json.loads(out[0]["tool_calls"][0]["function"]["arguments"])
+ assert args["ref"] == "U2"
+ assert out[1]["role"] == "tool"
+ assert out[1]["tool_call_id"] == "call_1"
+ assert out[1]["content"] == "UART_TX"
+ assert out[2]["role"] == "user"
+
+
+def test_tool_schema_and_choice():
+ schema = ToolSchema(
+ name="save_pintable",
+ description="Save pins",
+ input_schema={"type": "object", "properties": {}},
+ )
+ tool = _to_openai_tool(schema)
+ assert tool["type"] == "function"
+ assert tool["function"]["name"] == "save_pintable"
+ assert _to_openai_tool_choice("auto") == "auto"
+ forced = _to_openai_tool_choice({"name": "save_pintable"})
+ assert forced["function"]["name"] == "save_pintable"
+
+
+def test_completion_from_openai_parses_tools_and_cache():
+ fn = SimpleNamespace(name="submit_review", arguments='{"findings":[]}')
+ tc = SimpleNamespace(id="c1", function=fn)
+ msg = SimpleNamespace(
+ content="done",
+ reasoning_content="step by step",
+ tool_calls=[tc],
+ )
+ usage = SimpleNamespace(
+ prompt_tokens=1000,
+ completion_tokens=50,
+ prompt_cache_hit_tokens=400,
+ prompt_tokens_details=None,
+ )
+ resp = SimpleNamespace(
+ choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")],
+ usage=usage,
+ )
+ completion = completion_from_openai(resp)
+ assert completion.text == "done"
+ assert completion.tool_calls[0].name == "submit_review"
+ assert completion.tool_calls[0].input == {"findings": []}
+ assert completion.usage.input_tokens == 600
+ assert completion.usage.cache_read_tokens == 400
+ assert completion.raw_assistant_blocks[0].reasoning_content == "step by step"
+
+
+def test_local_skills_load():
+ md = load_skill_markdown("extract-pintable")
+ assert "pin table" in md.lower()
+ validate = load_skill_validator("extract-pintable")
+ assert validate is not None
+ errors = validate({
+ "component_subtype": "ic.mcu",
+ "component_subtype_description": "MCU",
+ "package_info": {"base_family": "MSPM0", "package": "LQFP-48", "pin_count": 2},
+ "pintable": [
+ {"number": 1, "name": "VCC"},
+ {"number": 2, "name": "GND"},
+ ],
+ })
+ assert errors == []
+
+
+def test_factory_routes_deepseek(monkeypatch):
+ monkeypatch.setattr(settings, "deepseek_api_key", "sk-test")
+ from backend.services.llm.factory import get_provider_by_name
+ get_provider_by_name.cache_clear()
+ try:
+ provider = get_provider_by_name("deepseek")
+ assert provider.name == "deepseek"
+ finally:
+ get_provider_by_name.cache_clear()
+
+
+def test_config_defaults_are_deepseek():
+ assert settings.provider_default == "deepseek"
+ assert settings.model_for_stage("validation") == settings.model_validation_deepseek
+ assert "vision" in settings.model_for_stage("pintable")
+ assert settings.provider_for_stage("pintable") == "deepseek"
+
+
+def test_deepseek_pricing_positive():
+ cost = cost_for_entry({
+ "provider": "deepseek",
+ "model": "deepseek-v4-pro",
+ "input_tokens": 1_000_000,
+ "output_tokens": 0,
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ })
+ assert cost == pytest.approx(1.32)
+ assert "default" in PRICING["deepseek"]
diff --git a/tests/test_library.py b/tests/test_library.py
new file mode 100644
index 0000000..b027076
--- /dev/null
+++ b/tests/test_library.py
@@ -0,0 +1,118 @@
+"""Shared component library: persist datasheets, catalog listing."""
+
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+
+from backend.services import projects as proj_svc
+from backend.services.datasheet_store import resolve_datasheet
+from backend.services.storage import LocalStorageBackend
+
+
+PDF = b"%PDF-1.4\n" + b"x" * 8000
+
+
+def _client(tmp_path) -> TestClient:
+ from backend.main import app
+
+ app.state.storage = LocalStorageBackend(tmp_path)
+ return TestClient(app)
+
+
+def test_save_datasheet_also_stores_in_library(storage):
+ meta = proj_svc.create_project(storage, "local", "board")
+ key = proj_svc.save_datasheet(storage, "local", meta.id, "CH340E", PDF)
+ assert key.endswith("CH340E.pdf")
+ assert storage.exists(key)
+ assert resolve_datasheet(storage, "CH340E")
+ assert proj_svc.library_has_datasheet(storage, "CH340E")
+
+
+def test_library_catalog_lists_ics_passives_and_pdfs(storage):
+ storage.write_json(
+ "library/extracted/CH340E.json",
+ {
+ "mpn": "CH340E",
+ "pintable": [{"pin": 1}, {"pin": 2}],
+ "component_subtype": "usb-uart",
+ "absolute_maximum_ratings": {"vcc": "5V"},
+ },
+ )
+ storage.write_json(
+ "library/patterns/samsung_c.json",
+ {
+ "name": "Samsung CL10",
+ "component_type": "capacitor",
+ "description": "Samsung 0603 MLCC",
+ "regex": r"^CL10",
+ },
+ )
+ storage.write_json(
+ "library/passives/CL10B474KA8NNNC.json",
+ {
+ "mpn": "CL10B474KA8NNNC",
+ "specs": {
+ "specs_type": "capacitor",
+ "component_subtype": "mlcc",
+ "values": {"capacitance": "470n"},
+ },
+ },
+ )
+ proj_svc.remember_datasheet(storage, "CH340E", PDF)
+
+ cat = proj_svc.list_library_catalog(storage)
+ assert len(cat["ics"]) == 1
+ assert cat["ics"][0]["mpn"] == "CH340E"
+ assert cat["ics"][0]["pin_count"] == 2
+ assert cat["ics"][0]["has_datasheet"] is True
+ assert cat["passives"][0]["mpn"] == "Samsung CL10"
+ assert cat["simple"][0]["mpn"] == "CL10B474KA8NNNC"
+ assert any(d["mpn"] == "CH340E" and d["has_extraction"] for d in cat["datasheets"])
+
+
+def test_library_http_catalog_and_pdf(tmp_path):
+ client = _client(tmp_path)
+ empty = client.get("/api/library")
+ assert empty.status_code == 200
+ body = empty.json()
+ assert body["ics"] == []
+ assert body["datasheets"] == []
+
+ meta = client.post("/api/projects", json={"name": "lib"}).json()
+ resp = client.post(
+ f"/api/projects/{meta['id']}/upload/datasheets",
+ params={"mpn": "MSPM0G3507"},
+ files={"file": ("msp.pdf", PDF, "application/pdf")},
+ )
+ assert resp.status_code == 200
+
+ catalog = client.get("/api/library").json()
+ assert any(d["mpn"] == "MSPM0G3507" for d in catalog["datasheets"])
+
+ pdf = client.get("/api/library/datasheet/MSPM0G3507")
+ assert pdf.status_code == 200
+ assert pdf.content.startswith(b"%PDF-")
+
+
+def test_fetch_datasheet_persists_to_library(tmp_path, monkeypatch):
+ from backend.services.datasheet_finder import DatasheetHit
+
+ async def fake_find(mpn, lcsc_id=None):
+ return DatasheetHit(
+ mpn,
+ pdf_bytes=PDF,
+ url="https://datasheet.lcsc.com/x.pdf",
+ source="lcsc",
+ )
+
+ monkeypatch.setattr(
+ "backend.services.datasheet_finder.find_datasheet", fake_find,
+ )
+ client = _client(tmp_path)
+ resp = client.get("/api/datasheets/fetch", params={"mpn": "CH340E"})
+ assert resp.status_code == 200
+ assert resp.content.startswith(b"%PDF-")
+
+ catalog = client.get("/api/library").json()
+ assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
+ assert client.get("/api/library/datasheet/CH340E").status_code == 200
diff --git a/tests/test_pdf_ingest.py b/tests/test_pdf_ingest.py
new file mode 100644
index 0000000..22a06db
--- /dev/null
+++ b/tests/test_pdf_ingest.py
@@ -0,0 +1,64 @@
+"""PDF ingest: PyMuPDF text, keyword-selected vision pages, abs-max coerce."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from backend.services.extraction import _coerce_abs_max
+from backend.services.llm.pdf_ingest import (
+ extract_pdf_text,
+ make_text_pdf,
+ relevant_page_indices,
+ render_pdf_page_jpegs,
+)
+
+
+def test_extract_pdf_text_reads_later_pages(tmp_path: Path):
+ pdf = tmp_path / "wide.pdf"
+ pdf.write_bytes(make_text_pdf([
+ "Title page",
+ "Pin configuration VCC GND TXD",
+ "Absolute maximum ratings VCC 6.0 V",
+ ]))
+ text = extract_pdf_text(pdf)
+ assert "--- page 1 ---" in text
+ assert "--- page 3 ---" in text
+ assert "6.0 V" in text
+
+
+def test_relevant_pages_prefer_abs_max_over_front_padding(tmp_path: Path):
+ pages = [f"Filler overview page {i}" for i in range(1, 8)]
+ pages.append("Absolute maximum ratings\nSupply voltage VCC 6.0 V")
+ pdf = tmp_path / "long.pdf"
+ pdf.write_bytes(make_text_pdf(pages))
+ idx = relevant_page_indices(pdf, max_pages=6)
+ assert (len(pdf.read_bytes()) > 0)
+ # 0-based: keyword is on page 8 → index 7, plus neighbor 6
+ assert 7 in idx
+ assert len(idx) <= 6
+
+
+def test_render_keyword_pages_not_only_front(tmp_path: Path):
+ pages = ["Cover"] * 5
+ pages.append("Absolute maximum ratings table VCC 6 V")
+ pdf = tmp_path / "img.pdf"
+ pdf.write_bytes(make_text_pdf(pages))
+ images = render_pdf_page_jpegs(pdf, max_pages=4)
+ page_nos = [n for n, _ in images]
+ assert 6 in page_nos
+ assert images
+ assert images[0][1][:2] == b"\xff\xd8" # JPEG
+
+
+def test_coerce_abs_max_keeps_valid_drops_junk():
+ rows = _coerce_abs_max([
+ {"parameter": "VCC", "max": "6", "unit": "V", "source_page": 12},
+ {"parameter": "bad", "unit": "V"}, # no page
+ "nope",
+ {"parameter": "Tstg", "min": -40, "max": 125, "unit": "°C", "source_page": 12},
+ ])
+ assert len(rows) == 2
+ assert rows[0]["parameter"] == "VCC"
+ assert rows[0]["max"] == 6.0
+ assert rows[0]["min"] is None
+ assert rows[1]["min"] == -40.0