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

Based on manvalan/pinscope main. Default LLM is DeepSeek with local
skills and PDF ingest. Datasheets are fetched from LCSC/TI, stored in
the component library, and review extracts abs-max with a deeper
checklist. Adds scripts/update-pinscope.sh for the production host.
This commit is contained in:
Cursor Agent
2026-08-27 23:06:23 +00:00
parent ab1c5b081c
commit 48246f31bd
53 changed files with 3005 additions and 314 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ class ApiLogEntry(BaseModel):
stage: str # pintable | rules | pattern | validation | ...
identifier: str # MPN or component designator
model: str
provider: str = "anthropic" # anthropic | gemini
provider: str = "deepseek" # deepseek | anthropic | gemini
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int = 0
@@ -42,7 +42,7 @@ class ApiLogEntry(BaseModel):
@dataclass
class CallMeta:
"""Metadata returned alongside every Claude API call result."""
"""Metadata returned alongside every LLM API call result."""
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int
+2 -2
View File
@@ -125,9 +125,9 @@ def estimate_stage_cost_usd(stage: str) -> float:
settings_stage = str(base["settings_stage"])
provider = settings.provider_for_stage(settings_stage)
model = settings.model_for_stage(settings_stage)
table = PRICING.get(provider) or PRICING["anthropic"]
table = PRICING.get(provider) or PRICING["deepseek"]
rates = table.get(model, table["default"])
cache = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
cache = CACHE_RATES.get(provider, CACHE_RATES["deepseek"])
return (
int(base["input"]) * rates["input"]
+ int(base["output"]) * rates["output"]
+287
View File
@@ -0,0 +1,287 @@
"""Automatic datasheet lookup — LCSC, manufacturer URLs, optional DigiKey.
DeepSeek-adapted Pinscope still needs the actual PDF. The original wizard
only auto-fetched via DigiKey, which requires paid API keys and often
fails when the manufacturer CDN blocks the download.
This module tries, in order:
1. LCSC product search (no API key) — exact MPN match, then packing-suffix
variants (``/TR``, ``SPTR``, …).
2. Direct manufacturer URLs for vendors with stable datasheet paths (TI).
3. DigiKey, if ``DIGIKEY_CLIENT_ID`` / ``SECRET`` are configured.
Never raises: every failure is captured on :class:`DatasheetHit`.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
import httpx
from backend.config import settings
log = logging.getLogger(__name__)
_PDF_MAGIC = b"%PDF-"
_MIN_PDF_SIZE = 5_000
_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Pinscope/2.8"
)
_LCSC_BASE = "https://wmsc.lcsc.com/ftps/wm"
# Remainder after a common prefix that we treat as packing / orderable-code,
# not a different die (CH340 vs CH340E is a different part — rejected).
_PACKING_REMAINDER = re.compile(
r"^(S?P?TR|TR|T|R|MTR|PBF|CT|AT|XT|G4|EVM|ND)$",
re.IGNORECASE,
)
_TI_PREFIXES = (
"mspm", "msp430", "tms", "tlv", "tps", "sn74", "sn54", "iso", "tmp1",
"tmp2", "tmp3", "ina", "ads1", "ads8", "ads9", "tcan", "tmux", "opa",
"ths", "ref3", "ref5", "ref6", "ucc", "bq2", "bq3", "csd", "drv",
"tpd", "txb", "txs", "am26", "lm3", "lm2", "lm7", "lmx", "sitara",
)
@dataclass
class DatasheetHit:
mpn: str
pdf_bytes: bytes | None = None
error: str | None = None
url: str | None = None
source: str | None = None # "lcsc" | "ti" | "digikey" | ...
@property
def ok(self) -> bool:
return self.pdf_bytes is not None
def _alnum(mpn: str) -> str:
return re.sub(r"[^A-Z0-9]", "", mpn.upper())
def mpn_matches(query: str, candidate: str) -> bool:
"""True when ``candidate`` is the same part as ``query``, allowing
packing / tape-reel suffixes but not variant letters (CH340 vs CH340E)."""
q = _alnum(query)
c = _alnum(candidate)
if not q or not c:
return False
if q == c:
return True
longer, shorter = (q, c) if len(q) >= len(c) else (c, q)
if not longer.startswith(shorter):
return False
return bool(_PACKING_REMAINDER.match(longer[len(shorter):]))
def _pick_lcsc_product(mpn: str, products: list[dict]) -> dict | None:
exact: dict | None = None
loose: dict | None = None
want = _alnum(mpn)
for p in products:
model = p.get("productModel") or ""
if not model:
continue
if _alnum(model) == want:
exact = p
break
if loose is None and mpn_matches(mpn, model):
loose = p
return exact or loose
async def _download_pdf(url: str) -> bytes:
async with httpx.AsyncClient(
timeout=25, follow_redirects=True, headers={"User-Agent": _UA, "Accept": "*/*"},
) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.content
if not data.startswith(_PDF_MAGIC):
raise ValueError("Downloaded file is not a valid PDF")
if len(data) < _MIN_PDF_SIZE:
raise ValueError(f"PDF too small ({len(data)} bytes)")
return data
async def _lcsc_search(keyword: str) -> list[dict]:
async with httpx.AsyncClient(
timeout=20,
headers={"User-Agent": _UA, "Content-Type": "application/json", "Accept": "application/json"},
) as client:
resp = await client.post(
f"{_LCSC_BASE}/product/query/list",
json={"keyword": keyword, "currentPage": 1, "pageSize": 15},
)
resp.raise_for_status()
data = resp.json()
result = data.get("result") or {}
return result.get("dataList") or []
async def _lcsc_detail(product_code: str) -> dict | None:
async with httpx.AsyncClient(
timeout=20,
headers={"User-Agent": _UA, "Accept": "application/json"},
) as client:
resp = await client.get(
f"{_LCSC_BASE}/product/detail",
params={"productCode": product_code},
)
resp.raise_for_status()
data = resp.json()
result = data.get("result")
return result if isinstance(result, dict) else None
def _pdf_url_from_product(product: dict) -> str | None:
url = product.get("pdfUrl") or product.get("pdfURL") or product.get("pdfLinkUrl")
if url and isinstance(url, str) and url.startswith("http"):
return url
return None
async def _from_lcsc(mpn: str, lcsc_id: str | None) -> DatasheetHit | None:
product: dict | None = None
if lcsc_id:
code = lcsc_id.strip().upper()
if not code.startswith("C"):
code = "C" + code
try:
product = await _lcsc_detail(code)
except Exception as exc:
log.info("LCSC detail %s failed: %s", code, exc)
if product is not None and not _pdf_url_from_product(product):
product = None
if product is None:
keywords = [mpn]
stripped = _strip_packing_alnum(mpn)
if stripped and stripped.upper() != _alnum(mpn):
keywords.append(stripped)
for keyword in keywords:
try:
products = await _lcsc_search(keyword)
except Exception as exc:
log.info("LCSC search %s failed: %s", keyword, exc)
continue
product = _pick_lcsc_product(mpn, products)
if product:
break
if not product:
return None
url = _pdf_url_from_product(product)
if not url:
return None
try:
pdf = await _download_pdf(url)
except Exception as exc:
log.info("LCSC PDF download failed for %s (%s): %s", mpn, url, exc)
return DatasheetHit(mpn, error=f"LCSC download failed: {exc}", url=url, source="lcsc")
log.info("Fetched datasheet for %s via LCSC (%d KB)", mpn, len(pdf) // 1024)
return DatasheetHit(mpn, pdf_bytes=pdf, url=url, source="lcsc")
def _strip_packing_alnum(mpn: str) -> str | None:
"""Return the alnum MPN with a trailing packing code removed, if any."""
compact = _alnum(mpn)
for suf in ("SPTR", "PTR", "MTR", "TR"):
if compact.endswith(suf) and len(compact) > len(suf) + 3:
return compact[: -len(suf)]
return None
def _ti_slugs(mpn: str) -> list[str]:
"""Candidate TI datasheet slugs, most specific first."""
raw = mpn.lower().replace("/", "-").strip("-")
slugs = [raw]
# Longest packing / orderable suffixes first so "sptr" is not clipped to "sp".
for suffix in ("-t/r", "/tr", "-tr", "-reel", "sptr", "ptr", "mtr", "tr"):
if raw.endswith(suffix) and len(raw) > len(suffix) + 3:
base = raw[: -len(suffix)].rstrip("-")
if base and base not in slugs:
slugs.append(base)
break
return slugs
def _looks_like_ti(mpn: str) -> bool:
s = mpn.lower()
return any(s.startswith(p) for p in _TI_PREFIXES)
async def _from_ti(mpn: str) -> DatasheetHit | None:
if not _looks_like_ti(mpn):
return None
last_err = None
last_url = None
for slug in _ti_slugs(mpn):
url = f"https://www.ti.com/lit/ds/symlink/{slug}.pdf"
last_url = url
try:
pdf = await _download_pdf(url)
except Exception as exc:
last_err = exc
continue
log.info("Fetched datasheet for %s via TI (%s, %d KB)", mpn, slug, len(pdf) // 1024)
return DatasheetHit(mpn, pdf_bytes=pdf, url=url, source="ti")
if last_err:
log.info("TI lookup missed %s: %s", mpn, last_err)
return DatasheetHit(mpn, error=f"TI download failed: {last_err}", url=last_url, source="ti")
return None
async def _from_digikey(mpn: str) -> DatasheetHit | None:
if not settings.use_digikey:
return None
from backend.services.digikey import fetch_datasheet
result = await fetch_datasheet(mpn)
if result.ok:
return DatasheetHit(
mpn, pdf_bytes=result.pdf_bytes, url=result.url, source="digikey",
)
return DatasheetHit(mpn, error=result.error, url=result.url, source="digikey")
async def find_datasheet(mpn: str, lcsc_id: str | None = None) -> DatasheetHit:
"""Find and download a datasheet PDF for ``mpn``.
Tries LCSC, then TI (when the MPN looks like a TI part), then DigiKey.
"""
mpn = (mpn or "").strip()
if not mpn:
return DatasheetHit(mpn, error="Empty MPN")
errors: list[str] = []
last_url: str | None = None
for source_fn in (_from_lcsc, _from_ti, _from_digikey):
try:
if source_fn is _from_lcsc:
hit = await _from_lcsc(mpn, lcsc_id)
else:
hit = await source_fn(mpn) # type: ignore[misc]
except Exception as exc:
log.info("Datasheet source %s raised for %s: %s", source_fn.__name__, mpn, exc)
errors.append(f"{source_fn.__name__}: {exc}")
continue
if hit is None:
continue
if hit.ok:
return hit
if hit.error:
errors.append(f"{hit.source or source_fn.__name__}: {hit.error}")
if hit.url:
last_url = hit.url
detail = "; ".join(errors) if errors else "No datasheet found"
return DatasheetHit(mpn, error=detail, url=last_url)
+2 -2
View File
@@ -73,7 +73,7 @@ def store_datasheet(
bk = blob_key(md5)
if not storage.exists(bk):
storage.upload_from_local(local_path, bk)
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk})
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk, "mpn": mpn})
return bk
@@ -87,7 +87,7 @@ def store_datasheet_bytes(
bk = blob_key(md5)
if not storage.exists(bk):
storage.write_bytes(bk, data)
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk})
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk, "mpn": mpn})
return bk
+78 -8
View File
@@ -1,9 +1,12 @@
"""Async datasheet extraction using Claude API.
"""Async datasheet extraction using the configured LLM provider.
Ports the extraction steps from run_pipeline.py to async:
- extract_pintable: Pin table + package info + taxonomy assignment
- extract_pattern: Passive MPN pattern
- extract_specs: Component specs (discrete, connectors, crystals, etc.)
Skills (SKILL.md + validate.py) run locally for DeepSeek/Gemini. Anthropic
can still use Console Skills when a skill_id is in skills_manifest.json.
"""
from __future__ import annotations
@@ -55,7 +58,7 @@ from backend.services.llm import (
PINTABLE_TOOL = {
"name": "save_pintable",
"description": "Save the extracted pin table, package info, and component subtype.",
"description": "Save the extracted pin table, package info, absolute-maximum ratings, and component subtype.",
"input_schema": {
"type": "object",
"properties": {
@@ -94,6 +97,31 @@ PINTABLE_TOOL = {
"required": ["number", "name"],
},
},
"absolute_maximum_ratings": {
"type": "array",
"description": (
"Rows from the Absolute Maximum Ratings table: supplies, "
"pin voltages, current, temperature. Omit recommended-"
"operating values. Empty array if the table is unreadable."
),
"items": {
"type": "object",
"properties": {
"parameter": {
"type": "string",
"description": "As printed, e.g. 'VCC', 'VIN', 'Storage temperature'",
},
"min": {"type": ["number", "null"]},
"max": {"type": ["number", "null"]},
"unit": {"type": "string", "description": "V, mA, °C, …"},
"source_page": {
"type": "integer",
"description": "1-based datasheet page of this row",
},
},
"required": ["parameter", "unit", "source_page"],
},
},
},
"required": ["component_subtype", "component_subtype_description", "package_info", "pintable"],
},
@@ -208,14 +236,16 @@ SPECS_TOOL = {
# ---------------------------------------------------------------------------
_MAX_PDF_PAGES = 90
_MAX_PDF_PAGES = 120
log = logging.getLogger(__name__)
# Keywords used to find relevant pages for each extraction stage.
_PINTABLE_KEYWORDS = re.compile(
r"pin\s*(out|diagram|configuration|description|assignment|function|name|table|map)"
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description",
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description"
r"|absolute\s+maximum|recommended\s+operating|electrical\s+characteristics"
r"|ordering\s+information|device\s+information",
re.IGNORECASE,
)
@@ -280,6 +310,44 @@ def _to_tool(d: dict) -> ToolSchema:
)
def _coerce_abs_max(raw: object) -> list[dict]:
"""Keep well-formed abs-max rows; drop garbage rather than failing extraction."""
if not isinstance(raw, list):
return []
out: list[dict] = []
for row in raw:
if not isinstance(row, dict):
continue
parameter = str(row.get("parameter") or "").strip()
unit = str(row.get("unit") or "").strip()
page = row.get("source_page")
if not parameter or not unit:
continue
try:
source_page = int(page)
except (TypeError, ValueError):
continue
if source_page < 1:
continue
def _num(v: object) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
out.append({
"parameter": parameter,
"min": _num(row.get("min")),
"max": _num(row.get("max")),
"unit": unit,
"source_page": source_page,
})
return out
_GENERATE_SPECS_TOOL = {
"name": "save_specs_schema",
"description": "Save the standardized parameter schema for a component type.",
@@ -479,7 +547,7 @@ async def extract_pintable(
taxonomy = format_for_prompt("ic", tax_dir)
trimmed = _select_pages(pdf_path, _PINTABLE_KEYWORDS)
skill_id, version = settings.get_skill("extract-pintable")
skill_id, version = settings.get_skill_or_none("extract-pintable")
system = (
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n"
f"MPN: {mpn}\n\n"
@@ -548,7 +616,9 @@ async def extract_pintable(
component_subtype=subtype,
package_info=result["package_info"],
pintable=result["pintable"],
absolute_maximum_ratings=[],
absolute_maximum_ratings=_coerce_abs_max(
result.get("absolute_maximum_ratings") or [],
),
rules=[],
)
@@ -576,7 +646,7 @@ async def extract_pattern(
tax_dir = taxonomy_dir or settings.taxonomy_dir
taxonomy = format_for_prompt("passive", tax_dir)
skill_id, version = settings.get_skill("extract-pattern")
skill_id, version = settings.get_skill_or_none("extract-pattern")
system = (
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n\n"
f"EXISTING PASSIVE TAXONOMY SUBTYPES:\n{taxonomy}\n\n"
@@ -665,7 +735,7 @@ async def extract_specs(
subtypes_text = format_for_prompt(component_type, tax_dir)
specs_text = format_specs_for_prompt(component_type, tax_dir)
skill_id, version = settings.get_skill("extract-specs")
skill_id, version = settings.get_skill_or_none("extract-specs")
system = (
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n"
f"MPN: {mpn}\n"
+3 -3
View File
@@ -1,9 +1,9 @@
"""Provider-agnostic LLM client layer.
All Claude API calls in the backend route through this package via the
``LLMProvider`` interface. The default provider is Anthropic; per-stage
All model calls in the backend route through this package via the
``LLMProvider`` interface. The default provider is DeepSeek; per-stage
overrides via ``Settings.provider_*`` env vars route specific stages to
other providers (currently Anthropic + Gemini).
Anthropic or Gemini if those keys are configured.
"""
from backend.services.llm.factory import call_with_fallback, get_provider
@@ -274,6 +274,18 @@ class AnthropicProvider(LLMProvider):
except Exception:
skill_id, version = None, None
if not skill_id:
from backend.services.llm.local_skill import run_skill_locally
return await run_skill_locally(
self,
skill_name=skill_name,
model=model,
system=system,
user_text=user_text,
pdf_path=pdf_path,
output_tool=output_tool,
)
# Build initial user content
user_content: list[dict] = []
if pdf_path:
+3 -5
View File
@@ -92,9 +92,7 @@ class LLMProvider(Protocol):
) -> tuple[dict, "Completion"]:
"""Execute a managed Skill and return (forced-tool input, Completion).
Anthropic uses Console Skills (skill_id + container + code_execution
beta). Gemini raises ``NotImplementedError`` — there is no
Gemini-managed-Skill equivalent today; if you want a Gemini path for
skill-style extraction, inline the SKILL.md content as ``system`` and
run validation locally."""
DeepSeek and Gemini inline ``skills/<name>/SKILL.md`` and run
``validate.py`` locally. Anthropic uses Console Skills when a
skill_id is configured, otherwise the same local path."""
...
+335
View File
@@ -0,0 +1,335 @@
"""DeepSeek provider — OpenAI-compatible Chat Completions.
Translates the unified ``Message`` / ``Completion`` shapes into DeepSeek's
OpenAI-style chat format. DeepSeek does not accept native PDF documents, so
``PdfBlock`` is converted to extracted text (and page images when the
session model is a vision model). Thinking-mode ``reasoning_content`` is
round-tripped on subsequent turns.
Extraction skills run locally via :mod:`backend.services.llm.local_skill`
(DeepSeek has no Anthropic Console Skills equivalent).
"""
from __future__ import annotations
import json
import logging
from typing import Any
from openai import AsyncOpenAI
from backend.config import settings
from backend.services.llm.base import LLMProvider, LLMSession
from backend.services.llm.local_skill import run_skill_locally
from backend.services.llm.pdf_ingest import pdf_to_openai_content
from backend.services.llm.types import (
Completion,
ContentBlock,
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolChoice,
ToolResultBlock,
ToolSchema,
Usage,
)
log = logging.getLogger(__name__)
_VISION_HINT = "vision"
def _is_vision_model(model: str) -> bool:
return _VISION_HINT in model.lower()
def _to_openai_tool(t: ToolSchema) -> dict:
return {
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.input_schema,
},
}
def _to_openai_tool_choice(c: ToolChoice) -> dict | str:
if c == "auto":
return "auto"
if c == "none":
return "none"
if isinstance(c, dict) and "name" in c:
return {"type": "function", "function": {"name": c["name"]}}
raise ValueError(f"Invalid tool_choice: {c!r}")
def _reasoning_from_blocks(blocks: list[ContentBlock]) -> str | None:
for b in blocks:
rc = getattr(b, "reasoning_content", None)
if rc:
return rc
return None
def _pdf_parts(path, *, vision: bool) -> list[dict]:
return pdf_to_openai_content(
path,
vision=vision,
max_chars=settings.deepseek_pdf_max_chars,
max_images=settings.deepseek_pdf_image_pages,
)
def _user_content_parts(blocks: list[ContentBlock], *, vision: bool) -> list[dict]:
"""Flatten user-side blocks (text / pdf) into OpenAI content parts."""
parts: list[dict] = []
for b in blocks:
if isinstance(b, TextBlock):
parts.append({"type": "text", "text": b.text})
elif isinstance(b, PdfBlock):
parts.extend(_pdf_parts(b.path, vision=vision))
else:
raise TypeError(
f"Unexpected block in user content: {type(b).__name__}"
)
return parts
def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]:
"""Convert unified messages into DeepSeek/OpenAI chat messages.
Tool results become ``role=tool`` messages (OpenAI does not mix
``tool_result`` with documents in one user turn). Any PdfBlocks that
accompanied tool results are emitted as a following user message.
"""
out: list[dict] = []
for m in messages:
if m.role == "assistant":
text_parts = [b.text for b in m.content if isinstance(b, TextBlock)]
tool_calls = [b for b in m.content if isinstance(b, ToolCall)]
msg: dict[str, Any] = {"role": "assistant"}
text = "".join(text_parts)
msg["content"] = text if text else None
if tool_calls:
msg["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.input),
},
}
for tc in tool_calls
]
reasoning = _reasoning_from_blocks(m.content)
if reasoning:
msg["reasoning_content"] = reasoning
out.append(msg)
continue
# user
tool_results = [b for b in m.content if isinstance(b, ToolResultBlock)]
other = [b for b in m.content if not isinstance(b, ToolResultBlock)]
for tr in tool_results:
out.append({
"role": "tool",
"tool_call_id": tr.tool_use_id,
"content": tr.content,
})
if other:
parts = _user_content_parts(other, vision=vision)
if len(parts) == 1 and parts[0].get("type") == "text":
out.append({"role": "user", "content": parts[0]["text"]})
else:
out.append({"role": "user", "content": parts})
elif not tool_results:
out.append({"role": "user", "content": ""})
return out
def _parse_tool_arguments(raw: str | None) -> dict:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
log.warning("DeepSeek tool arguments were not valid JSON: %s", raw[:200])
return {}
return data if isinstance(data, dict) else {}
def _cache_hit_tokens(usage: Any) -> int:
hit = getattr(usage, "prompt_cache_hit_tokens", None)
if hit:
return int(hit)
details = getattr(usage, "prompt_tokens_details", None)
if details is not None:
cached = getattr(details, "cached_tokens", None)
if cached:
return int(cached)
return 0
def completion_from_openai(resp: Any) -> Completion:
choice = resp.choices[0]
msg = choice.message
text = msg.content or ""
reasoning = getattr(msg, "reasoning_content", None) or None
tool_calls: list[ToolCall] = []
raw_blocks: list[ContentBlock] = []
if text or reasoning:
raw_blocks.append(TextBlock(text=text or "", reasoning_content=reasoning))
for i, tc in enumerate(msg.tool_calls or []):
fn = tc.function
parsed = _parse_tool_arguments(getattr(fn, "arguments", None))
call = ToolCall(
id=tc.id or f"{fn.name}_{i}",
name=fn.name,
input=parsed,
reasoning_content=reasoning if i == 0 and not text else None,
)
tool_calls.append(call)
raw_blocks.append(call)
usage_md = getattr(resp, "usage", None)
if usage_md is not None:
prompt = int(usage_md.prompt_tokens or 0)
cached = _cache_hit_tokens(usage_md)
usage = Usage(
input_tokens=max(0, prompt - cached),
output_tokens=int(usage_md.completion_tokens or 0),
cache_creation_tokens=0,
cache_read_tokens=cached,
)
else:
usage = Usage()
stop = choice.finish_reason or "unknown"
return Completion(
text=text,
tool_calls=tool_calls,
usage=usage,
stop_reason=str(stop),
raw_assistant_blocks=raw_blocks,
)
class DeepSeekSession(LLMSession):
provider_name = "deepseek"
def __init__(
self,
*,
client: AsyncOpenAI,
model: str,
system: str,
max_tokens: int,
temperature: float | None = None,
thinking: bool = True,
reasoning_effort: str = "medium",
) -> None:
self._client = client
self.model = model
self._system = system
self._max_tokens = max_tokens
self._temperature = temperature
self._thinking = thinking
self._reasoning_effort = reasoning_effort
self._vision = _is_vision_model(model)
async def complete(
self,
*,
messages: list[Message],
tools: list[ToolSchema] | None = None,
tool_choice: ToolChoice = "auto",
) -> Completion:
oai_messages: list[dict] = [
{"role": "system", "content": self._system},
]
oai_messages.extend(messages_to_openai(messages, vision=self._vision))
extra_body: dict[str, Any] = {
"thinking": {"type": "enabled" if self._thinking else "disabled"},
}
if self._thinking:
extra_body["reasoning_effort"] = self._reasoning_effort
kwargs: dict[str, Any] = {
"model": self.model,
"messages": oai_messages,
"max_tokens": self._max_tokens,
"extra_body": extra_body,
}
if self._temperature is not None:
kwargs["temperature"] = self._temperature
if tools:
kwargs["tools"] = [_to_openai_tool(t) for t in tools]
kwargs["tool_choice"] = _to_openai_tool_choice(tool_choice)
resp = await self._client.chat.completions.create(**kwargs)
return completion_from_openai(resp)
async def close(self) -> None:
return None
class DeepSeekProvider(LLMProvider):
name = "deepseek"
def __init__(self) -> None:
api_key = settings.deepseek_api_key
if not api_key:
raise RuntimeError(
"DEEPSEEK_API_KEY is not set. Copy backend/.env.example to "
"backend/.env and add a key from https://platform.deepseek.com/"
)
self._client = AsyncOpenAI(
api_key=api_key,
base_url=settings.deepseek_base_url,
)
async def create_session(
self,
*,
model: str,
system: str,
max_tokens: int = 4096,
temperature: float | None = None,
) -> LLMSession:
thinking = settings.deepseek_thinking.strip().lower() != "disabled"
effort = settings.deepseek_reasoning_effort
if max_tokens >= 16000:
effort = "high"
return DeepSeekSession(
client=self._client,
model=model,
system=system,
max_tokens=max_tokens,
temperature=temperature,
thinking=thinking,
reasoning_effort=effort,
)
async def run_skill(
self,
*,
skill_name: str,
model: str,
system: str,
user_text: str,
pdf_path: str | None,
output_tool: ToolSchema,
) -> tuple[dict, Completion]:
return await run_skill_locally(
self,
skill_name=skill_name,
model=model,
system=system,
user_text=user_text,
pdf_path=pdf_path,
output_tool=output_tool,
)
+7 -3
View File
@@ -15,10 +15,14 @@ log = logging.getLogger(__name__)
T = TypeVar("T")
@lru_cache(maxsize=4)
@lru_cache(maxsize=8)
def get_provider_by_name(name: str) -> LLMProvider:
"""Return a singleton provider instance for ``name`` ("anthropic" |
"gemini"). Used by :func:`get_provider` and :func:`call_with_fallback`."""
"""Return a singleton provider instance for ``name`` ("deepseek" |
"anthropic" | "gemini"). Used by :func:`get_provider` and
:func:`call_with_fallback`."""
if name == "deepseek":
from backend.services.llm.deepseek_provider import DeepSeekProvider
return DeepSeekProvider()
if name == "anthropic":
from backend.services.llm.anthropic_provider import AnthropicProvider
return AnthropicProvider()
+9 -5
View File
@@ -371,9 +371,13 @@ class GeminiProvider(LLMProvider):
pdf_path: str | None,
output_tool: ToolSchema,
) -> tuple[dict, Completion]:
raise NotImplementedError(
f"GeminiProvider.run_skill() not implemented (skill={skill_name!r}). "
f"Anthropic Console Skills have no Gemini equivalent. To migrate "
f"this skill to Gemini, inline its SKILL.md as the system prompt "
f"and run validate.py locally."
from backend.services.llm.local_skill import run_skill_locally
return await run_skill_locally(
self,
skill_name=skill_name,
model=model,
system=system,
user_text=user_text,
pdf_path=pdf_path,
output_tool=output_tool,
)
+191
View File
@@ -0,0 +1,191 @@
"""Provider-agnostic local skill runner.
Anthropic Console Skills have no equivalent on DeepSeek (or Gemini). This
module inlines ``skills/<name>/SKILL.md`` as the system prompt, drives a
normal tool-calling session, and runs ``validate.py`` locally after each
``output_tool`` call. Used by DeepSeek and Gemini; Anthropic falls back
here when no Console skill id is configured.
"""
from __future__ import annotations
import importlib.util
import logging
import re
import time
from pathlib import Path
from backend.config import settings
from backend.services.llm.base import LLMProvider
from backend.services.llm.types import (
Completion,
Message,
PdfBlock,
TextBlock,
ToolResultBlock,
ToolSchema,
Usage,
)
log = logging.getLogger(__name__)
_SKILL_MAX_TURNS = 10
_FRONTMATTER = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
_LOCAL_SKILL_TAIL = """
You cannot run shell commands or Python. Do not try to execute validate.py.
After extracting the data, call the `{tool}` tool with the structured result.
The server validates the payload. If validation fails you will receive the
errors and must call `{tool}` again with a corrected payload.
Do NOT write files to disk.
"""
def skills_dir() -> Path:
return Path(settings.skills_dir)
def load_skill_markdown(skill_name: str) -> str:
path = skills_dir() / skill_name / "SKILL.md"
if not path.is_file():
raise FileNotFoundError(
f"Skill {skill_name!r} not found at {path}. "
f"Expected skills/{skill_name}/SKILL.md in the repo."
)
raw = path.read_text(encoding="utf-8")
return _FRONTMATTER.sub("", raw).strip()
def load_skill_validator(skill_name: str):
"""Import ``skills/<name>/validate.py`` and return its ``validate`` fn."""
path = skills_dir() / skill_name / "validate.py"
if not path.is_file():
return None
spec = importlib.util.spec_from_file_location(
f"pinscope_skill_{skill_name.replace('-', '_')}_validate", path,
)
if spec is None or spec.loader is None:
return None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
fn = getattr(mod, "validate", None)
return fn if callable(fn) else None
def _sum_usage(total: Usage, piece: Usage) -> Usage:
return Usage(
input_tokens=total.input_tokens + piece.input_tokens,
output_tokens=total.output_tokens + piece.output_tokens,
cache_creation_tokens=total.cache_creation_tokens + piece.cache_creation_tokens,
cache_read_tokens=total.cache_read_tokens + piece.cache_read_tokens,
)
async def run_skill_locally(
provider: LLMProvider,
*,
skill_name: str,
model: str,
system: str,
user_text: str,
pdf_path: str | None,
output_tool: ToolSchema,
max_turns: int = _SKILL_MAX_TURNS,
) -> tuple[dict, Completion]:
"""Run ``skill_name`` as an in-process tool loop on ``provider``."""
skill_md = load_skill_markdown(skill_name)
validator = load_skill_validator(skill_name)
full_system = (
skill_md
+ "\n\n"
+ system.strip()
+ "\n"
+ _LOCAL_SKILL_TAIL.format(tool=output_tool.name)
)
user_blocks: list = []
if pdf_path:
user_blocks.append(PdfBlock(path=Path(pdf_path), cacheable=True))
user_blocks.append(TextBlock(text=user_text, cacheable=True))
messages: list[Message] = [Message(role="user", content=user_blocks)]
total = Usage()
t0 = time.monotonic()
last_completion: Completion | None = None
session = await provider.create_session(
model=model, system=full_system, max_tokens=16384, temperature=0.0,
)
try:
for turn in range(max_turns):
force = turn >= max_turns - 2
completion = await session.complete(
messages=messages,
tools=[output_tool],
tool_choice={"name": output_tool.name} if force else "auto",
)
last_completion = completion
total = _sum_usage(total, completion.usage)
payload: dict | None = None
for tc in completion.tool_calls:
if tc.name == output_tool.name:
payload = dict(tc.input)
break
messages.append(Message(
role="assistant", content=completion.raw_assistant_blocks,
))
if payload is None:
messages.append(Message(role="user", content=[TextBlock(
text=(
f"You did not call {output_tool.name}. "
f"Call it now with the extracted data."
),
)]))
continue
errors: list[str] = []
if validator is not None:
try:
errors = list(validator(payload) or [])
except Exception as exc:
log.warning(
"Skill %s validate.py raised: %s", skill_name, exc,
)
errors = [f"validator crashed: {exc}"]
if not errors:
completion.usage = total
completion.turns = turn + 1 # type: ignore[attr-defined]
completion.duration_ms = int((time.monotonic() - t0) * 1000) # type: ignore[attr-defined]
return payload, completion
messages.append(Message(
role="user",
content=[
ToolResultBlock(
tool_use_id=completion.tool_calls[0].id,
name=output_tool.name,
content="VALIDATION FAILED:\n" + "\n".join(
f"- {e}" for e in errors
),
),
TextBlock(
text=(
"Fix the payload and call "
f"{output_tool.name} again."
),
),
],
))
finally:
await session.close()
raise RuntimeError(
f"Skill {skill_name!r} did not produce a valid {output_tool.name} "
f"in {max_turns} turns"
+ (f" (last stop_reason={last_completion.stop_reason})"
if last_completion else "")
)
+258
View File
@@ -0,0 +1,258 @@
"""Convert datasheet PDFs into text (and optional page images).
DeepSeek's Chat Completions API does not accept native PDF documents.
Anthropic/Gemini providers send the file bytes; DeepSeek instead extracts
text with PyMuPDF (pypdf fallback) and, on a vision model, renders the
pages that actually matter (pin tables, abs-max, electrical, application)
rather than always the first N pages.
"""
from __future__ import annotations
import base64
import io
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
_DEFAULT_MAX_CHARS = 500_000
_DEFAULT_MAX_IMAGES = 32
_RENDER_ZOOM = 1.55
# Pages whose diagrams/tables the model must actually see.
_PAGE_KEYWORDS = re.compile(
r"pin\s*(out|diagram|configuration|description|assignment|function|name|table|map)"
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description"
r"|absolute\s+maximum|recommended\s+operating|electrical\s+characteristics"
r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)"
r"|decoupling|bypass\s+capacitor|typical\s+application"
r"|application\s+(circuit|schematic|information|note)|reference\s+design"
r"|ordering\s+information|device\s+information",
re.IGNORECASE,
)
def extract_pdf_text(path: Path | str, *, max_chars: int = _DEFAULT_MAX_CHARS) -> str:
"""Return datasheet text with page markers, truncated to ``max_chars``.
Prefers PyMuPDF (better on datasheet tables) and falls back to pypdf.
"""
pdf_path = Path(path)
blob = _extract_text_pymupdf(pdf_path)
if blob is None:
blob = _extract_text_pypdf(pdf_path)
if len(blob) > max_chars:
blob = blob[:max_chars] + "\n\n[truncated: remaining pages omitted]"
return blob
def _extract_text_pymupdf(pdf_path: Path) -> str | None:
try:
import fitz
except ImportError:
return None
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
return None
try:
parts: list[str] = [f"[PDF: {pdf_path.name}, {len(doc)} pages]"]
for i, page in enumerate(doc, start=1):
try:
text = page.get_text("text") or ""
except Exception:
text = ""
parts.append(f"--- page {i} ---\n{text.strip()}")
return "\n\n".join(parts)
finally:
doc.close()
def _extract_text_pypdf(pdf_path: Path) -> str:
from pypdf import PdfReader
try:
reader = PdfReader(str(pdf_path))
except Exception as exc:
log.warning("Failed to open PDF %s: %s", pdf_path, exc)
return f"[PDF {pdf_path.name}: unreadable ({exc})]"
parts: list[str] = [f"[PDF: {pdf_path.name}, {len(reader.pages)} pages]"]
for i, page in enumerate(reader.pages, start=1):
try:
text = page.extract_text() or ""
except Exception:
text = ""
parts.append(f"--- page {i} ---\n{text.strip()}")
return "\n\n".join(parts)
def relevant_page_indices(
path: Path | str,
*,
max_pages: int,
keywords: re.Pattern[str] = _PAGE_KEYWORDS,
) -> list[int]:
"""0-based page indices to send as images: front matter + keyword hits."""
pdf_path = Path(path)
try:
import fitz
doc = fitz.open(str(pdf_path))
except Exception:
return list(range(max_pages))
try:
total = len(doc)
if total <= max_pages:
return list(range(total))
hits: set[int] = set()
for i in range(total):
try:
text = doc[i].get_text("text") or ""
except Exception:
text = ""
if keywords.search(text):
for neighbor in (i - 1, i, i + 1):
if 0 <= neighbor < total:
hits.add(neighbor)
front = set(range(min(5, total)))
ranked_hits = sorted(hits)
if len(ranked_hits) >= max_pages:
keep_front = [i for i in ranked_hits if i < 5][:2]
rest = [i for i in ranked_hits if i not in keep_front]
need = max_pages - len(keep_front)
return sorted(keep_front + rest[-need:])
chosen = set(hits)
for i in sorted(front) + list(range(total)):
if len(chosen) >= max_pages:
break
chosen.add(i)
return sorted(chosen)
finally:
doc.close()
def render_pdf_page_jpegs(
path: Path | str,
*,
max_pages: int = _DEFAULT_MAX_IMAGES,
zoom: float = _RENDER_ZOOM,
page_indices: list[int] | None = None,
) -> list[tuple[int, bytes]]:
"""Render selected pages as JPEG bytes.
``page_indices`` is 0-based. When omitted, keyword-relevant pages are
chosen instead of always rendering the front of the PDF.
Returns a list of (1-based page number, jpeg bytes). Empty if PyMuPDF
is not installed or rendering fails — callers should still send text.
"""
try:
import fitz # PyMuPDF
except ImportError:
log.info("PyMuPDF not installed — DeepSeek vision page images skipped")
return []
pdf_path = Path(path)
if page_indices is None:
page_indices = relevant_page_indices(pdf_path, max_pages=max_pages)
out: list[tuple[int, bytes]] = []
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
return []
try:
matrix = fitz.Matrix(zoom, zoom)
for i in page_indices:
if i < 0 or i >= len(doc):
continue
page = doc[i]
pix = page.get_pixmap(matrix=matrix, alpha=False)
jpeg = pix.tobytes("jpeg")
out.append((i + 1, jpeg))
if len(out) >= max_pages:
break
except Exception as exc:
log.warning("PyMuPDF render failed for %s: %s", pdf_path, exc)
return out
finally:
doc.close()
return out
def jpeg_data_url(jpeg: bytes) -> str:
b64 = base64.standard_b64encode(jpeg).decode("ascii")
return f"data:image/jpeg;base64,{b64}"
def pdf_to_openai_content(
path: Path | str,
*,
vision: bool,
max_chars: int = _DEFAULT_MAX_CHARS,
max_images: int = _DEFAULT_MAX_IMAGES,
) -> list[dict]:
"""OpenAI-style content parts for one PDF: text, plus images if vision."""
text = extract_pdf_text(path, max_chars=max_chars)
parts: list[dict] = [{"type": "text", "text": text}]
if not vision:
return parts
images = render_pdf_page_jpegs(path, max_pages=max_images)
if not images:
return parts
parts.append({
"type": "text",
"text": (
f"The following {len(images)} image(s) are rendered pages of "
f"{Path(path).name} (pin tables, abs-max, electrical, and "
f"application sections preferred over the front matter). "
f"Use them for diagrams and tables that text extraction may have missed."
),
})
for page_no, jpeg in images:
parts.append({
"type": "text",
"text": f"[page {page_no} image]",
})
parts.append({
"type": "image_url",
"image_url": {"url": jpeg_data_url(jpeg), "detail": "high"},
})
return parts
def make_text_pdf(pages: list[str]) -> bytes:
"""Build a tiny text-only PDF for tests. Uses PyMuPDF when available,
otherwise a hand-rolled one-page PDF."""
try:
import fitz
doc = fitz.open()
for body in pages:
page = doc.new_page()
page.insert_text((72, 72), body, fontsize=11)
buf = io.BytesIO()
doc.save(buf)
doc.close()
return buf.getvalue()
except ImportError:
pass
# Minimal one-page PDF with the first page's text.
payload = (pages[0] if pages else "test").encode("latin-1", "replace")
stream = b"BT /F1 12 Tf 72 720 Td (" + payload.replace(b"(", b"[").replace(b")", b"]") + b") Tj ET"
return (
b"%PDF-1.1\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>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 " + str(len(stream)).encode() + b">>stream\n"
+ stream + b"\nendstream\nendobj\n"
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
b"xref\n0 6\n0000000000 65535 f \n"
b"trailer<</Size 6/Root 1 0 R>>\nstartxref\n0\n%%EOF\n"
)
+14 -4
View File
@@ -8,10 +8,19 @@ from __future__ import annotations
# Per-million-token USD rates. Source-of-truth links:
# DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
# Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
# Google: https://ai.google.dev/pricing
# Last updated: 2026-07-01
# Last updated: 2026-08-27
PRICING: dict[str, dict[str, dict[str, float]]] = {
"deepseek": {
# Peak-hour rates (conservative). Off-peak is 50% of these.
# Cache-hit input is billed via CACHE_RATES["deepseek"]["read"].
"deepseek-v4-flash": {"input": 0.44, "output": 1.32},
"deepseek-v4-flash-vision-exp": {"input": 0.44, "output": 1.32},
"deepseek-v4-pro": {"input": 1.32, "output": 3.96},
"default": {"input": 0.44, "output": 1.32},
},
"anthropic": {
"claude-opus-4-6": {"input": 5.00, "output": 25.00},
"claude-opus-4-5": {"input": 5.00, "output": 25.00},
@@ -54,6 +63,7 @@ PRICING: dict[str, dict[str, dict[str, float]]] = {
# normal input pass)
# read: cost when a cached prefix is *reused* (much cheaper)
CACHE_RATES: dict[str, dict[str, float]] = {
"deepseek": {"create": 1.00, "read": 0.032},
"anthropic": {"create": 1.25, "read": 0.10},
"gemini": {"create": 1.00, "read": 0.25},
}
@@ -62,10 +72,10 @@ CACHE_RATES: dict[str, dict[str, float]] = {
def cost_for_entry(entry: dict) -> float:
"""USD cost for an api_logs entry. Reads ``provider`` (default
``anthropic`` for legacy entries) and ``model`` to pick rates."""
provider = entry.get("provider") or "anthropic"
table = PRICING.get(provider) or PRICING["anthropic"]
provider = entry.get("provider") or "deepseek"
table = PRICING.get(provider) or PRICING["deepseek"]
rates = table.get(entry.get("model", ""), table["default"])
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["deepseek"])
input_rate = rates["input"]
output_rate = rates["output"]
return (
+8 -2
View File
@@ -26,12 +26,17 @@ class TextBlock:
# this turn is fed back into the conversation, or the next call 400s.
# Anthropic: always None.
thought_signature: bytes | None = None
# DeepSeek thinking-mode: assistant ``reasoning_content`` that must be
# replayed on the next turn or the API returns 400.
reasoning_content: str | None = None
@dataclass
class PdfBlock:
"""Inline PDF document. Provider encodes as base64 (Anthropic) or
inline_data (Gemini) and applies caching policy if cacheable=True."""
"""Inline PDF document. Anthropic encodes as base64, Gemini as
inline_data. DeepSeek does not accept PDFs natively — the provider
converts the file to extracted text (and page images on a vision
model) before sending."""
path: Path
cacheable: bool = False
@@ -45,6 +50,7 @@ class ToolCall:
# Same purpose as TextBlock.thought_signature — Gemini 3 attaches one
# to every function_call part when thinking is on. Round-trip required.
thought_signature: bytes | None = None
reasoning_content: str | None = None
@dataclass
+72 -26
View File
@@ -46,7 +46,7 @@ from backend.pinscopex.taxonomy import SIMPLE_TYPES, type_for_ref
from backend.config import settings
from backend.services import admin_settings as settings_svc
from backend.services.billing_hook import InsufficientCredits, get_billing
from backend.services.datasheet_store import compute_md5_from_path, store_datasheet
from backend.services.datasheet_store import compute_md5_from_path, store_datasheet, store_datasheet_bytes
from backend.services import extraction, projects as proj_svc
from backend.services.api_logs import ApiLogger, total_cost
from backend.services.cost_estimator import estimate_stage_cost_usd
@@ -660,6 +660,60 @@ async def _stage_bom_parse(ctx: PipelineContext) -> None:
pass # send_pipeline_started_email handles errors internally
def _lcsc_id_for_mpn(ctx: PipelineContext, mpn: str) -> str | None:
payload = ctx.lcsc_data.get(mpn) or {}
code = payload.get("lcsc") or payload.get("lcsc_id")
if isinstance(code, str) and code.strip():
return code.strip()
mapping = getattr(ctx.meta, "lcsc_to_mpn", None) or {}
for lcsc, resolved in mapping.items():
if resolved == mpn:
return lcsc
return None
async def _ensure_local_datasheet(
ctx: PipelineContext, mpn: str, pdf_path: Path, *, stage: str = "ic_extraction",
) -> bool:
"""Make ``pdf_path`` exist: project upload, library, or auto-fetch.
Returns True if the PDF is on disk afterwards.
"""
if pdf_path.is_file():
return True
lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, mpn)
if lib_ds_key:
ctx.storage.download_to_local(lib_ds_key, pdf_path)
return True
from backend.services.datasheet_finder import find_datasheet
broker.publish(
ctx.project_id, "step_update",
{"stage": stage, "substep": mpn,
"status": "running", "detail": "finding datasheet"},
)
hit = await find_datasheet(mpn, lcsc_id=_lcsc_id_for_mpn(ctx, mpn))
if not hit.ok or not hit.pdf_bytes:
return False
pdf_path.parent.mkdir(parents=True, exist_ok=True)
pdf_path.write_bytes(hit.pdf_bytes)
try:
proj_svc.save_datasheet(
ctx.storage, ctx.user_id, ctx.project_id, mpn, hit.pdf_bytes,
)
except Exception:
logger.exception("Failed to persist auto-fetched datasheet for %s", mpn)
try:
store_datasheet_bytes(ctx.storage, hit.pdf_bytes, mpn)
except Exception:
logger.exception("Failed to library-store auto-fetched datasheet for %s", mpn)
logger.info(
"Auto-fetched datasheet for %s via %s (%d KB)",
mpn, hit.source or "unknown", len(hit.pdf_bytes) // 1024,
)
return True
async def _stage_ic_extraction(ctx: PipelineContext) -> None:
"""Stage 2 — Extract IC pin tables from datasheets."""
extracted_dir = ctx.ws.local_path("extracted")
@@ -705,18 +759,14 @@ async def _stage_ic_extraction(ctx: PipelineContext) -> None:
"status": "complete", "detail": detail})
continue
# Need PDF — check project uploads first, then library
# Need PDF — check project uploads first, then library, then auto-fetch
pdf_path = ctx.ws.local_path(f"uploads/datasheets/{safe}.pdf")
if not pdf_path.is_file():
lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, mpn)
if lib_ds_key:
ctx.storage.download_to_local(lib_ds_key, pdf_path)
else:
ctx.skipped.append(SkippedItem(mpn, "ic_extraction", "No datasheet uploaded"))
broker.publish(ctx.project_id, "step_update",
{"stage": "ic_extraction", "substep": mpn,
"status": "failed", "error": "No datasheet uploaded"})
continue
if not await _ensure_local_datasheet(ctx, mpn, pdf_path):
ctx.skipped.append(SkippedItem(mpn, "ic_extraction", "No datasheet found"))
broker.publish(ctx.project_id, "step_update",
{"stage": "ic_extraction", "substep": mpn,
"status": "failed", "error": "No datasheet found"})
continue
pending.append((mpn, safe, json_path, pdf_path))
# Phase 2 (concurrent, up to ic_concurrency): extract cache-miss MPNs.
@@ -836,17 +886,15 @@ async def _stage_simple_extraction(ctx: PipelineContext) -> None:
"status": "complete", "detail": detail})
continue
# Check for uploaded PDF — check project uploads first, then library
# Check for uploaded PDF — project, library, then auto-fetch
pdf_path = ctx.ws.local_path(f"uploads/datasheets/{safe}.pdf")
if not pdf_path.is_file():
lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, mpn)
if lib_ds_key:
ctx.storage.download_to_local(lib_ds_key, pdf_path)
else:
broker.publish(ctx.project_id, "step_update",
{"stage": "simple_extraction", "substep": mpn,
"status": "complete", "detail": "no datasheet (optional)"})
continue
if not await _ensure_local_datasheet(
ctx, mpn, pdf_path, stage="simple_extraction",
):
broker.publish(ctx.project_id, "step_update",
{"stage": "simple_extraction", "substep": mpn,
"status": "complete", "detail": "no datasheet (optional)"})
continue
if not _check_credit_gate(ctx, "simple_extraction", mpn, estimate_stage_cost_usd("simple_extraction")):
await _paused_stage_publish(ctx, "simple_extraction", "out of credits")
@@ -1426,14 +1474,12 @@ async def _stage_validation(ctx: PipelineContext) -> None:
# Ensure all IC datasheet PDFs are available locally for review.
# Cached ICs skipped pintable extraction, so their PDFs may not
# have been downloaded yet.
# have been downloaded yet. Auto-fetch fills remaining gaps.
for mpn in ctx.ic_mpns:
safe = safe_mpn(mpn)
pdf_path = ds_dir / f"{safe}.pdf"
if not pdf_path.is_file():
lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, mpn)
if lib_ds_key:
ctx.storage.download_to_local(lib_ds_key, pdf_path)
await _ensure_local_datasheet(ctx, mpn, pdf_path, stage="review")
# Snapshot the full review queue so pause checkpoints can show what's left.
# Mirrors the filter in validate_design_async: ICs with a PDF available.
+123 -3
View File
@@ -21,10 +21,13 @@ Library (global, shared across users):
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
log = logging.getLogger(__name__)
from pydantic import BaseModel
from backend.pinscopex.utils import safe_mpn
@@ -618,20 +621,31 @@ def save_netlist(
def save_datasheet(
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes
) -> str:
"""Save a datasheet PDF to the project uploads directory.
"""Save a datasheet PDF to the project and to the shared library.
Library writes happen during pattern extraction (one PDF per pattern series).
The project copy is what the pipeline reads for this run. The library
copy means a later project with the same MPN can skip the download.
"""
safe = safe_mpn(mpn)
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe}.pdf"
storage.write_bytes(key, data)
# Count datasheets
remember_datasheet(storage, mpn, data)
ds_prefix = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/"
count = sum(1 for k in storage.list_prefix(ds_prefix) if k.endswith(".pdf"))
update_project(storage, user_id, project_id, datasheet_count=count)
return key
def remember_datasheet(storage: StorageBackend, mpn: str, data: bytes) -> None:
"""Write a datasheet into the shared library without failing the caller."""
try:
from backend.services.datasheet_store import store_datasheet_bytes
store_datasheet_bytes(storage, data, mpn)
except Exception:
log.exception("Failed to store datasheet for %s in the shared library", mpn)
def get_bom_key(
storage: StorageBackend, user_id: str, project_id: str
) -> str | None:
@@ -759,6 +773,112 @@ def save_to_library(
return dst_key
def list_library_catalog(storage: StorageBackend) -> dict:
"""List ICs, passive patterns, discrete specs, and datasheet refs.
Used by the user-facing library page and the admin components panel.
"""
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet
ics: list[dict] = []
seen_ic_mpns: set[str] = set()
for key in storage.list_prefix("library/extracted/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_ic_mpns:
continue
seen_ic_mpns.add(mpn)
ics.append({
"mpn": mpn,
"type": "ic",
"subtype": data.get("component_subtype", ""),
"pin_count": len(data.get("pintable", [])),
"has_ratings": bool(data.get("absolute_maximum_ratings")),
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
})
except Exception:
continue
passives: list[dict] = []
seen_passive_names: set[str] = set()
for key in storage.list_prefix("library/patterns/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
if name in seen_passive_names:
continue
seen_passive_names.add(name)
passives.append({
"mpn": name,
"type": "passive",
"subtype": data.get("component_type", ""),
"description": data.get("description", ""),
"regex": data.get("regex", ""),
})
except Exception:
continue
simple_models: list[dict] = []
seen_model_mpns: set[str] = set()
for prefix in ("library/models/", "library/passives/"):
for key in storage.list_prefix(prefix):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_model_mpns:
continue
seen_model_mpns.add(mpn)
specs = data.get("specs", {}) or {}
simple_models.append({
"mpn": mpn,
"type": "simple",
"specs_type": specs.get("specs_type", ""),
"subtype": specs.get("component_subtype", ""),
"param_count": len(specs.get("values", {}) or {}),
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
})
except Exception:
continue
datasheets: list[dict] = []
seen_ds: set[str] = set()
for key in storage.list_prefix(REF_PREFIX):
if not key.endswith(".json"):
continue
try:
ref = storage.read_json(key)
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_ds:
continue
seen_ds.add(mpn)
datasheets.append({
"mpn": mpn,
"hash": ref.get("hash"),
"has_extraction": mpn in seen_ic_mpns,
"has_model": mpn in seen_model_mpns,
})
except Exception:
continue
ics.sort(key=lambda r: r["mpn"].lower())
passives.sort(key=lambda r: r["mpn"].lower())
simple_models.sort(key=lambda r: r["mpn"].lower())
datasheets.sort(key=lambda r: r["mpn"].lower())
return {
"ics": ics,
"passives": passives,
"simple": simple_models,
"datasheets": datasheets,
}
def list_library_patterns(storage: StorageBackend) -> list[str]:
"""List all pattern keys in the library."""
prefix = "library/patterns/"
+4 -4
View File
@@ -144,7 +144,7 @@ _REVIEW_KEYWORDS = re.compile(
re.IGNORECASE,
)
_MAX_PDF_PAGES = 90
_MAX_PDF_PAGES = 120
# Per-review excerpt budget — keeps fan-out cost bounded on hub ICs (e.g. an
# MCU connected to many neighbors). On exhaustion, the tool returns a budget
@@ -159,9 +159,9 @@ _MAX_PDF_PAGES = 90
# per neighbor fits the ~3 topic fetches (pin levels + abs-max + electrical)
# one interface check needs; 60 global allows ~2 such neighbors before the
# fan-out ceiling kicks in.
_PER_REVIEW_FETCH_BUDGET = 8
_PER_REVIEW_PAGE_BUDGET = 60
_PER_NEIGHBOR_PAGE_BUDGET = 30
_PER_REVIEW_FETCH_BUDGET = 12
_PER_REVIEW_PAGE_BUDGET = 90
_PER_NEIGHBOR_PAGE_BUDGET = 45
# A signal net with more components than this is treated as a hub/bus and
# excluded from the neighbor set even if classified as "signal". Bounds