Pinscope open-source core

Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
This commit is contained in:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
View File
+48
View File
@@ -0,0 +1,48 @@
"""Global admin settings, persisted via StorageBackend.
Settings are stored at ``admin/settings.json`` in storage (GCS or local
``data/``). The module mirrors the pattern in ``limits.py``.
"""
from __future__ import annotations
from packaging.version import Version
from backend.services.storage import StorageBackend
_SETTINGS_KEY = "admin/settings.json"
_DEFAULTS: dict[str, str] = {
"min_model_version": "0.0.0", # no threshold by default
}
def get_admin_settings(storage: StorageBackend) -> dict:
"""Return the full admin settings dict, with defaults."""
if storage.exists(_SETTINGS_KEY):
data = storage.read_json(_SETTINGS_KEY)
return {**_DEFAULTS, **data}
return dict(_DEFAULTS)
def get_min_model_version(storage: StorageBackend) -> str:
"""Return the min_model_version threshold."""
return get_admin_settings(storage).get("min_model_version", "0.0.0")
def set_min_model_version(storage: StorageBackend, version: str) -> None:
"""Set the min_model_version threshold. Validates semver format."""
Version(version) # raises InvalidVersion if bad
data = get_admin_settings(storage)
data["min_model_version"] = version
storage.write_json(_SETTINGS_KEY, data)
def version_is_stale(component_version: str, min_version: str) -> bool:
"""Return True if *component_version* < *min_version* (semver)."""
if min_version == "0.0.0":
return False
try:
return Version(component_version) < Version(min_version)
except Exception:
return True # unparseable → treat as stale
+106
View File
@@ -0,0 +1,106 @@
"""Per-project API call logging.
Captures metadata for every LLM API call made during a pipeline run and
serialises to JSONL for storage alongside other project artefacts. Pricing
lives in ``backend.services.llm.pricing`` and is provider-aware.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from dataclasses import dataclass, field
from pydantic import BaseModel
# Re-exported for callers (pipeline.total_cost) — provider-aware now
from backend.services.llm.pricing import cost_for_entry, total_cost # noqa: F401
class ApiLogEntry(BaseModel):
timestamp: str
stage: str # pintable | rules | pattern | validation | ...
identifier: str # MPN or component designator
model: str
provider: str = "anthropic" # anthropic | gemini
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int = 0
cache_read_input_tokens: int = 0
duration_ms: int
stop_reason: str
skill_id: str | None = None
turns: int | None = None
error: str | None = None
cost_usd: float | None = None
credits_charged: float | None = None
# True when the call ran in an admin-initiated free context (e.g. regen)
# — the raw USD cost is still recorded for accounting, but no credits
# are charged to the user.
free: bool = False
@dataclass
class CallMeta:
"""Metadata returned alongside every Claude API call result."""
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int
cache_read_input_tokens: int
duration_ms: int
stop_reason: str
turns: int = 1
# ---------------------------------------------------------------------------
# Logger
# ---------------------------------------------------------------------------
@dataclass
class ApiLogger:
"""Collects API call log entries during a pipeline run.
``free=True`` marks every entry as admin-initiated and zeros the
``credits_charged`` field so downstream charging / reporting treats the
run as free to the user. The underlying USD cost is still recorded.
"""
entries: list[dict] = field(default_factory=list)
free: bool = False
def log(self, **kwargs: object) -> None:
kwargs.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
entry = ApiLogEntry(**kwargs) # type: ignore[arg-type]
d = entry.model_dump()
d["cost_usd"] = round(cost_for_entry(d), 6)
if self.free:
d["credits_charged"] = 0.0
d["free"] = True
else:
# Attribute credits to this call using the same margin used by
# the credit service. Local import to avoid a module-load cycle.
from backend.services.billing_hook import get_billing
d["credits_charged"] = get_billing().credits_for_api_cost(d["cost_usd"])
self.entries.append(d)
def to_jsonl(self) -> str:
if not self.entries:
return ""
return "\n".join(json.dumps(e) for e in self.entries) + "\n"
def flush(self, storage, user_id: str, project_id: str) -> None:
"""Write the current entries to ``api_logs.jsonl`` in storage.
Called periodically during a pipeline run so a preempted worker
doesn't lose billing data. Idempotent — safe to call repeatedly;
each flush overwrites the prior copy with the latest entries.
"""
text = self.to_jsonl()
if not text:
return
# Local import avoids a cycle with services.projects (which imports
# from services.storage which imports from here transitively).
from backend.services.projects import project_prefix
key = f"{project_prefix(user_id, project_id)}/api_logs.jsonl"
storage.write_text(key, text)
+192
View File
@@ -0,0 +1,192 @@
"""Open-core billing seam.
Everything outside the billing modules (``credits``, ``credit_grants``,
``stripe_billing``, ``stripe_customer_map``, ``auto_topup`` and the
``billing``/``credits`` routers) talks to billing exclusively through
:func:`get_billing`. With ``BILLING_ENABLED=false`` the returned
:class:`NullBilling` makes every pipeline run free — the same shape as the
existing admin ``free=True`` path — so the core can run with no credits
ledger, no Stripe, and no billing routes mounted.
This module must stay a leaf: no billing module is imported at module
level (``CreditsBilling`` lazy-imports inside each method), so the core
never touches the Stripe SDK when billing is disabled.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
from backend.config import settings
if TYPE_CHECKING:
from backend.services.storage import StorageBackend
class InsufficientCredits(RuntimeError):
"""Raised when a charge would drop the balance below zero."""
def __init__(self, required: float, available: float) -> None:
super().__init__(
f"Insufficient credits: required {required}, available {available}"
)
self.required = required
self.available = available
class BillingHook(Protocol):
"""The full billing surface the core is allowed to depend on."""
def credits_for_api_cost(self, cost_usd: float) -> float: ...
def get_balance(self, storage: "StorageBackend", user_id: str) -> float: ...
def charge(
self,
storage: "StorageBackend",
user_id: str,
amount: float,
*,
reason: str = "pipeline_charge",
run_id: str | None = None,
unit_id: str | None = None,
allow_overdraft: bool = False,
) -> None: ...
def ensure_trial_grant(self, storage: "StorageBackend", user_id: str) -> bool: ...
def list_user_ids(self, storage: "StorageBackend") -> list[str]: ...
async def maybe_auto_topup(
self, storage: "StorageBackend", user_id: str
) -> dict | None: ...
class NullBilling:
"""Billing disabled: everything is free and nothing is written.
``credits_for_api_cost`` returning 0.0 is the linchpin — every
``ApiLogger`` entry gets ``credits_charged=0``, so the pipeline's
charge path early-returns and the credit gate always allows.
"""
def credits_for_api_cost(self, cost_usd: float) -> float:
return 0.0
def get_balance(self, storage: "StorageBackend", user_id: str) -> float:
return 0.0
def charge(
self,
storage: "StorageBackend",
user_id: str,
amount: float,
*,
reason: str = "pipeline_charge",
run_id: str | None = None,
unit_id: str | None = None,
allow_overdraft: bool = False,
) -> None:
return None
def ensure_trial_grant(self, storage: "StorageBackend", user_id: str) -> bool:
return False
def list_user_ids(self, storage: "StorageBackend") -> list[str]:
return []
async def maybe_auto_topup(
self, storage: "StorageBackend", user_id: str
) -> dict | None:
return None
class CreditsBilling:
"""Production billing: delegates to the credits ledger + auto top-up."""
def credits_for_api_cost(self, cost_usd: float) -> float:
from backend.services import credits as credits_svc
return credits_svc.credits_for_api_cost(cost_usd)
def get_balance(self, storage: "StorageBackend", user_id: str) -> float:
from backend.services import credits as credits_svc
return credits_svc.get_balance(storage, user_id)
def charge(
self,
storage: "StorageBackend",
user_id: str,
amount: float,
*,
reason: str = "pipeline_charge",
run_id: str | None = None,
unit_id: str | None = None,
allow_overdraft: bool = False,
) -> None:
from backend.services import credits as credits_svc
credits_svc.charge(
storage, user_id, amount,
reason=reason,
run_id=run_id,
unit_id=unit_id,
allow_overdraft=allow_overdraft,
)
def ensure_trial_grant(self, storage: "StorageBackend", user_id: str) -> bool:
from backend.services import credits as credits_svc
return credits_svc.ensure_trial_grant(storage, user_id)
def list_user_ids(self, storage: "StorageBackend") -> list[str]:
from backend.services import credits as credits_svc
return credits_svc.list_user_ids(storage)
async def maybe_auto_topup(
self, storage: "StorageBackend", user_id: str
) -> dict | None:
"""Run an auto top-up attempt if configured.
Returns ``{"reason", "amount_usd"}`` when this call produced a NEW
failed attempt (so the caller can notify the user), else None.
"""
from backend.services.auto_topup import get_config, maybe_trigger
before = get_config(storage, user_id).last_attempt_ts
try:
await maybe_trigger(storage, user_id)
except Exception:
return None
after = get_config(storage, user_id)
if (
after.last_attempt_status == "failed"
and after.last_attempt_ts
and after.last_attempt_ts != before
):
return {
"reason": after.last_failure_reason or "unknown",
"amount_usd": after.amount_usd,
}
return None
_NULL = NullBilling()
_credits_billing: CreditsBilling | None = None
def get_billing() -> BillingHook:
"""Return the active billing implementation.
Selected per call (not at import) so the ``billing_enabled`` setting
can be monkeypatched in tests and so importing this module never pulls
in billing code.
"""
if not settings.billing_enabled:
return _NULL
global _credits_billing
if _credits_billing is None:
_credits_billing = CreditsBilling()
return _credits_billing
+398
View File
@@ -0,0 +1,398 @@
"""Pre-flight cost estimator for pipeline runs.
Walks the uploaded BOM + library cache and returns a low/high credit
range the user will see *before* they start a run. Pure read-only:
no storage writes, no API calls.
The estimator is intentionally conservative. Low/high bounds are
bracketed around a central estimate (0.7× / 1.4×) so the user always
sees a plausible range rather than a false-precision single number.
Per-call USD is computed from per-stage **token baselines**
(``STAGE_TOKEN_BASELINES``) multiplied by the runtime-resolved
provider+model rate from ``backend.services.llm.pricing.PRICING``.
This means a change to ``PROVIDER_VALIDATION`` / ``MODEL_VALIDATION``
(or any other per-stage routing env var) automatically updates the
estimate — no constant-bumping required. The baselines themselves
are hand-tuned from historical ``api_logs.jsonl`` aggregates and
should be recalibrated periodically.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel
from backend.config import settings
from backend.pinscopex.parsers import parse_bom
from backend.pinscopex.resolve_passives import resolve_mpn
from backend.pinscopex.taxonomy import SIMPLE_TYPES, type_for_ref
from backend.pinscopex.utils import safe_mpn
from backend.services import projects as proj_svc
from backend.services.billing_hook import get_billing
from backend.services.llm.pricing import CACHE_RATES, PRICING
from backend.services.storage import StorageBackend
# ---------------------------------------------------------------------------
# Per-stage token baselines (model-aware estimator)
# ---------------------------------------------------------------------------
# Average tokens per call for a single sub-unit of each stage. Values
# come from aggregating recent ``api_logs.jsonl`` runs across staging +
# prod (see ``scripts/recalibrate_estimator_baselines.py`` follow-up;
# until that lands, eyeball + paste from gcloud-mined stats).
#
# ``settings_stage`` is the key passed to ``settings.model_for_stage`` /
# ``settings.provider_for_stage``. The estimator-stage names mirror the
# ``CostItem.kind`` Literal so the breakdown stays self-consistent.
STAGE_TOKEN_BASELINES: dict[str, dict[str, int | str]] = {
"ic_extraction": {
"settings_stage": "pintable",
"input": 100, "output": 2000,
"cache_create": 80_000, "cache_read": 170_000,
},
"simple_extraction": {
"settings_stage": "specs",
"input": 100, "output": 1000,
"cache_create": 20_000, "cache_read": 20_000,
},
"passive_pattern": {
"settings_stage": "pattern",
"input": 100, "output": 7000,
"cache_create": 60_000, "cache_read": 330_000,
},
"digikey_resolve": {
"settings_stage": "auto_resolve",
"input": 2000, "output": 200,
"cache_create": 0, "cache_read": 0,
},
# Validation review per IC. The multi-turn validator pulls the
# cached system + graph + datasheet on each turn (~4-5 turns/IC),
# so cache_read dominates. Page-aware scaling was abandoned — token
# counts already encompass the PDF via cache reuse, and IC
# complexity correlates more weakly with raw page count than the
# old per-page heuristic assumed.
"review": {
"settings_stage": "validation",
"input": 13_500, "output": 2000,
"cache_create": 110_000, "cache_read": 300_000,
},
# Per-IC normalize pass — dedup + severity re-grade. Runs on the
# already-structured findings (no PDF, no graph tools), one turn,
# validation-class model (Sonnet). A few hundred input tokens for the
# rubric, a few hundred output tokens for the normalized list.
"normalize": {
"settings_stage": "normalize",
"input": 1500, "output": 600,
"cache_create": 0, "cache_read": 0,
},
# Cross-IC dedup — one call per run over all findings (no PDF/graph),
# validation-class model (Sonnet). Slightly larger input than per-IC
# normalize since it sees every IC's findings at once.
"cross_ic_dedup": {
"settings_stage": "normalize",
"input": 2500, "output": 700,
"cache_create": 0, "cache_read": 0,
},
}
LOW_MULT: float = 0.7
HIGH_MULT: float = 1.4
def estimate_stage_cost_usd(stage: str) -> float:
"""Per-call USD for one sub-unit of ``stage``, model-aware.
Resolves provider+model from ``settings`` and computes
``(input_tokens × rate) + ...`` using the same ``PRICING`` /
``CACHE_RATES`` tables that real billing in
``services.llm.pricing.cost_for_entry`` reads. Changing a
``MODEL_*`` / ``PROVIDER_*`` env var therefore updates the estimate
automatically.
Falls through to ``PRICING[provider]["default"]`` when the resolved
model is missing from the table — same fallback semantics as the
real billing code, so a missing pricing entry surfaces uniformly
everywhere instead of crashing the estimator.
Raises ``KeyError`` only when ``stage`` itself is unknown.
"""
base = STAGE_TOKEN_BASELINES[stage]
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"]
rates = table.get(model, table["default"])
cache = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
return (
int(base["input"]) * rates["input"]
+ int(base["output"]) * rates["output"]
+ int(base["cache_create"]) * rates["input"] * cache["create"]
+ int(base["cache_read"]) * rates["input"] * cache["read"]
) / 1_000_000
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
UnitKind = Literal[
"ic_extraction",
"simple_extraction",
"passive_pattern",
"digikey_resolve",
"review",
]
class CostItem(BaseModel):
identifier: str # MPN, ref, or a fixed token like a stage name
kind: UnitKind
api_cost_usd: float
source: Literal["cache_hit", "api_call", "api_call_estimated"]
note: str | None = None
class CostEstimate(BaseModel):
"""What the pipeline will likely cost this run."""
api_cost_low: float
api_cost_high: float
api_cost_mid: float
credits_low: float
credits_high: float
credits_mid: float
breakdown: list[CostItem]
ic_count: int
simple_count: int
passive_count: int
cached_ic_count: int
cached_simple_count: int
cached_passive_count: int
review_ic_count: int
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
def _load_library_patterns(storage: StorageBackend):
"""Load passive patterns from the library to test cache hits.
This mirrors the pipeline's own seeding behaviour but keeps the
estimator synchronous and side-effect free (downloads to a local
tempdir only if the backend is remote).
"""
try:
return proj_svc.load_library_patterns(storage)
except Exception:
return []
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def estimate_pipeline_cost(
storage: StorageBackend,
user_id: str,
project_id: str,
) -> CostEstimate:
"""Produce a CostEstimate for the given project without running anything.
The BOM must already be uploaded; the netlist may or may not be. If
the BOM is missing, raises FileNotFoundError.
"""
bom_key = proj_svc.get_bom_key(storage, user_id, project_id)
if not bom_key:
raise FileNotFoundError("BOM not uploaded for this project")
meta = proj_svc.get_project(storage, user_id, project_id)
col_map = (meta.bom_columns if meta else None) or {}
ref_col = col_map.get("reference", "Reference")
mpn_col = col_map.get("mpn", "Manufacturer Part Number")
# Download BOM to a local temp path so parse_bom can read it.
import tempfile
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
tmp.write(storage.read_bytes(bom_key))
bom_local_path = Path(tmp.name)
try:
bom = parse_bom(str(bom_local_path), reference_col=ref_col, mpn_col=mpn_col)
finally:
bom_local_path.unlink(missing_ok=True)
# Classify unique MPNs by type.
ic_mpns: set[str] = set()
simple_mpns: set[str] = set()
passive_mpns: set[str] = set()
for ref, info in bom.items():
mpn = info.get("mpn")
if not mpn:
continue
typ = type_for_ref(ref)
if typ == "ic":
ic_mpns.add(mpn)
elif typ == "passive":
passive_mpns.add(mpn)
elif typ in SIMPLE_TYPES:
simple_mpns.add(mpn)
# Load library patterns once so we can resolve passives against cache.
patterns = _load_library_patterns(storage)
breakdown: list[CostItem] = []
cached_ic = 0
cached_simple = 0
cached_passive = 0
# IC extraction
for mpn in sorted(ic_mpns):
if proj_svc.library_has_extraction(storage, mpn):
breakdown.append(CostItem(
identifier=mpn, kind="ic_extraction",
api_cost_usd=0.0, source="cache_hit",
note="library hit",
))
cached_ic += 1
else:
breakdown.append(CostItem(
identifier=mpn, kind="ic_extraction",
api_cost_usd=round(estimate_stage_cost_usd("ic_extraction"), 4),
source="api_call_estimated",
))
# Simple component specs
for mpn in sorted(simple_mpns):
if proj_svc.library_has_model(storage, mpn):
breakdown.append(CostItem(
identifier=mpn, kind="simple_extraction",
api_cost_usd=0.0, source="cache_hit",
))
cached_simple += 1
else:
breakdown.append(CostItem(
identifier=mpn, kind="simple_extraction",
api_cost_usd=round(estimate_stage_cost_usd("simple_extraction"), 4),
source="api_call_estimated",
))
# Passives — pattern resolution covers many MPNs with one pattern
unresolved_passives: list[str] = []
for mpn in sorted(passive_mpns):
if patterns and resolve_mpn(mpn, patterns) is not None:
breakdown.append(CostItem(
identifier=mpn, kind="passive_pattern",
api_cost_usd=0.0, source="cache_hit",
note="pattern match",
))
cached_passive += 1
continue
if proj_svc.library_has_passive_model(storage, mpn):
breakdown.append(CostItem(
identifier=mpn, kind="passive_pattern",
api_cost_usd=0.0, source="cache_hit",
note="cached passive model",
))
cached_passive += 1
continue
unresolved_passives.append(mpn)
# Each unresolved passive MPN may contribute one pattern extraction.
# Heuristic: N unique first-7-char prefixes = N new patterns.
prefixes = {m[:7] for m in unresolved_passives}
for prefix in sorted(prefixes):
sample_mpn = next(m for m in unresolved_passives if m.startswith(prefix))
breakdown.append(CostItem(
identifier=sample_mpn, kind="passive_pattern",
api_cost_usd=round(estimate_stage_cost_usd("passive_pattern"), 4),
source="api_call_estimated",
note=f"may cover {sum(1 for m in unresolved_passives if m.startswith(prefix))} MPNs",
))
# Direct datasheet review — per IC that has a datasheet available.
# Cost is the flat per-IC observed average; multi-turn cache reuse
# makes this less page-sensitive than the old per-page heuristic
# implied.
review_per_ic = estimate_stage_cost_usd("review")
if settings.normalize_findings_enabled:
review_per_ic += estimate_stage_cost_usd("normalize")
review_ic_count = 0
for mpn in sorted(ic_mpns):
pdf_path = _locate_datasheet_local(storage, user_id, project_id, mpn)
has_pdf = pdf_path is not None
has_library_pdf = (
proj_svc.library_has_datasheet(storage, mpn) is not None
if not has_pdf else False
)
if not (has_pdf or has_library_pdf):
continue # Skipped in pipeline — no datasheet, no review
breakdown.append(CostItem(
identifier=mpn, kind="review",
api_cost_usd=round(review_per_ic, 4),
source="api_call_estimated",
))
review_ic_count += 1
# One cross-IC dedup call per run, only when ≥2 ICs get reviewed (a
# single-IC run has no cross-IC pair to merge — see _maybe_dedupe_cross_ic).
if settings.cross_ic_dedup_enabled and review_ic_count > 1:
breakdown.append(CostItem(
identifier="cross-IC dedup", kind="review",
api_cost_usd=round(estimate_stage_cost_usd("cross_ic_dedup"), 4),
source="api_call_estimated",
note="collapses one interface defect reported from both ICs",
))
api_total = sum(item.api_cost_usd for item in breakdown)
api_low = round(api_total * LOW_MULT, 4)
api_high = round(api_total * HIGH_MULT, 4)
billing = get_billing()
return CostEstimate(
api_cost_low=api_low,
api_cost_high=api_high,
api_cost_mid=round(api_total, 4),
credits_low=billing.credits_for_api_cost(api_low),
credits_high=billing.credits_for_api_cost(api_high),
credits_mid=billing.credits_for_api_cost(api_total),
breakdown=breakdown,
ic_count=len(ic_mpns),
simple_count=len(simple_mpns),
passive_count=len(passive_mpns),
cached_ic_count=cached_ic,
cached_simple_count=cached_simple,
cached_passive_count=cached_passive,
review_ic_count=review_ic_count,
)
def _locate_datasheet_local(
storage: StorageBackend, user_id: str, project_id: str, mpn: str,
) -> Path | None:
"""Return a local Path to the datasheet PDF if it can be read quickly.
For LocalStorageBackend, reads directly from disk. For remote backends
we skip the page-count read (returns None) — estimator will fall back
to the mid-cap heuristic rather than downloading the PDF during pre-flight.
"""
from backend.services.storage import LocalStorageBackend
if not isinstance(storage, LocalStorageBackend):
return None
safe = safe_mpn(mpn)
key = f"users/{user_id}/projects/{project_id}/uploads/datasheets/{safe}.pdf"
if storage.exists(key):
return storage._path(key) # type: ignore[attr-defined]
legacy = f"library/datasheets/{safe}.pdf"
if storage.exists(legacy):
return storage._path(legacy) # type: ignore[attr-defined]
return None
+168
View File
@@ -0,0 +1,168 @@
"""Content-addressed datasheet storage for the shared library.
Stores PDF blobs by their MD5 hash and creates lightweight JSON ref files
that map MPN names to blob keys. This deduplicates identical PDFs that
were previously stored under different human-readable names.
Layout::
library/datasheets/
blobs/{md5hash}.pdf -- unique PDF content, stored once
refs/{safe_mpn}.json -- per-MPN pointer: {"hash": "...", "blob_key": "..."}
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from backend.pinscopex.utils import safe_mpn
from backend.services.storage import StorageBackend
BLOB_PREFIX = "library/datasheets/blobs/"
REF_PREFIX = "library/datasheets/refs/"
# ---------------------------------------------------------------------------
# Hashing helpers
# ---------------------------------------------------------------------------
def compute_md5_from_path(local_path: Path) -> str:
"""Return the hex MD5 digest of a local file (chunked read)."""
h = hashlib.md5()
with open(local_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def compute_md5_from_bytes(data: bytes) -> str:
"""Return the hex MD5 digest of in-memory bytes."""
return hashlib.md5(data).hexdigest()
# ---------------------------------------------------------------------------
# Key construction
# ---------------------------------------------------------------------------
def blob_key(md5: str) -> str:
"""Storage key for a content-addressed PDF blob."""
return f"{BLOB_PREFIX}{md5}.pdf"
def ref_key(mpn: str) -> str:
"""Storage key for an MPN → blob ref file."""
return f"{REF_PREFIX}{safe_mpn(mpn)}.json"
# ---------------------------------------------------------------------------
# Store / resolve / delete
# ---------------------------------------------------------------------------
def store_datasheet(
storage: StorageBackend,
local_path: Path,
mpn: str,
) -> str:
"""Store a datasheet PDF by content hash and create an MPN ref.
Idempotent: skips blob upload if it already exists, always writes the ref.
Returns the blob storage key.
"""
md5 = compute_md5_from_path(local_path)
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})
return bk
def store_datasheet_bytes(
storage: StorageBackend,
data: bytes,
mpn: str,
) -> str:
"""Same as :func:`store_datasheet` but from in-memory bytes."""
md5 = compute_md5_from_bytes(data)
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})
return bk
def resolve_datasheet(storage: StorageBackend, mpn: str) -> str | None:
"""Look up the blob key for an MPN via its ref file.
Returns the blob key if the ref exists *and* the blob exists, else None.
"""
rk = ref_key(mpn)
if not storage.exists(rk):
return None
ref = storage.read_json(rk)
bk = ref.get("blob_key")
if bk and storage.exists(bk):
return bk
return None
def delete_datasheet_ref(storage: StorageBackend, mpn: str) -> str | None:
"""Delete the ref for an MPN. Returns the blob key if a ref existed.
Does **not** delete the blob — other refs may point to it. Use
:func:`gc_orphan_blobs` to clean up unreferenced blobs.
"""
rk = ref_key(mpn)
if not storage.exists(rk):
return None
ref = storage.read_json(rk)
bk = ref.get("blob_key")
storage.delete_key(rk)
return bk
# ---------------------------------------------------------------------------
# Maintenance
# ---------------------------------------------------------------------------
def gc_orphan_blobs(
storage: StorageBackend, *, dry_run: bool = True,
) -> list[str]:
"""Find blobs not referenced by any ref file. Optionally delete them.
Also checks pattern ``datasheet_key`` values so blobs referenced only
by patterns (not MPN refs) are kept.
Intended for maintenance scripts, not hot paths.
"""
# Collect all hashes referenced by ref files
referenced_hashes: set[str] = set()
for rk in storage.list_recursive(REF_PREFIX):
if rk.endswith(".json"):
ref = storage.read_json(rk)
h = ref.get("hash")
if h:
referenced_hashes.add(h)
# Also collect hashes from pattern datasheet_key values
for pk in storage.list_recursive("library/patterns/"):
if pk.endswith(".json"):
pat = storage.read_json(pk)
ds_key = pat.get("datasheet_key", "")
if ds_key.startswith(BLOB_PREFIX) and ds_key.endswith(".pdf"):
h = ds_key.removeprefix(BLOB_PREFIX).removesuffix(".pdf")
referenced_hashes.add(h)
# Find orphan blobs
orphans: list[str] = []
for bk in storage.list_recursive(BLOB_PREFIX):
if not bk.endswith(".pdf"):
continue
filename = bk.rsplit("/", 1)[-1]
h = filename.removesuffix(".pdf")
if h not in referenced_hashes:
orphans.append(bk)
if not dry_run:
storage.delete_key(bk)
return orphans
+398
View File
@@ -0,0 +1,398 @@
"""Cross-IC dedup pass — collapse one physical defect reported from both ends.
Direct datasheet review runs once per IC, in isolation. An interface defect
(e.g. a 5V driver into a non-5V-tolerant input on the U2↔U3 UART) is therefore
discovered twice — once when reviewing U2, once when reviewing U3 — and the
per-IC normalize pass cannot collapse them because it only sees one IC's
findings at a time. The two copies land in the report as separate findings,
double-counting the same problem and (worse) sometimes disagreeing with each
other.
This module runs a single small LLM call over the *concatenated* findings from
all ICs (no PDF, no graph tools) to merge findings that describe the same
physical defect on the same net/interface/component. It is the cross-IC analog
of ``normalize_findings`` and follows the same fail-soft contract: on any LLM
error, schema violation, or coverage gap, the original findings are returned
unchanged. It never drops findings (that is normalize's job) and never raises a
merged finding's severity above the highest of its members.
"""
from __future__ import annotations
import json
import logging
import time
from datetime import datetime, timezone
from typing import Awaitable, Callable
from backend.pinscopex.models import Finding
from backend.services.api_logs import ApiLogger
from backend.services.llm import Message, TextBlock
from backend.services.llm.factory import call_with_fallback
from backend.services.llm.types import ToolSchema
log = logging.getLogger(__name__)
# Severity ordering — a merged group's severity is capped at the highest
# severity among its members (downgrade-only, same principle as normalize).
_INFO, _WARN, _ERR = 0, 1, 2
_SEVERITY_RANK = {"INFO": _INFO, "WARNING": _WARN, "ERROR": _ERR}
_RANK_TO_SEV = {_INFO: "INFO", _WARN: "WARNING", _ERR: "ERROR"}
def _is_unverified(why: str | None) -> bool:
return (why or "").lstrip().lower().startswith("unverified:")
SYSTEM_PROMPT = """\
You deduplicate hardware-review findings across multiple ICs.
Each finding was produced by reviewing one IC in isolation, so a defect on
the interface *between* two ICs is reported twice — once from each side. Your
job is to group findings that describe the SAME physical defect and merge each
group into one finding. You may NOT invent findings, drop findings, or change
the engineering substance.
### When two findings are the same defect (merge)
Merge when they describe the same physical problem at the same place:
- the same net or signal (e.g. both flag over-voltage on `/UART0.NCTS`),
- the same component pair / interface (e.g. "U2 RTS# drives U3 PA14" and
"U3 PA14 is driven by U2's 5V output" are one interface defect seen from
each end),
- the same shared part with the same fix.
A merged group is resolved by ONE change to the design. Name that interface or
root cause once in the merged `finding`; restate each side's consequence in
`why`.
### When findings are NOT the same defect (keep separate)
Do NOT merge findings that need different fixes, even if they touch the same
component or net:
- different pins / different signals on the same IC,
- a decoupling issue and a voltage issue on the same supply,
- two unrelated problems that happen to involve the same part.
When in doubt, keep them separate. Over-merging hides distinct problems and is
worse than a visible duplicate.
### Severity
Use the HIGHEST severity among a group's members. Never grade a merged finding
above its strongest member. If any member's `why` begins with `Unverified:`,
keep that prefix and do not grade the merged finding above WARNING.
### Output
Call `submit_deduped` exactly once. Provide a `groups` array. Every original
finding (numbered 1..N) must appear in exactly one group's `member_indices`,
and no index may appear twice.
- A group of ONE index is a passthrough — it is kept unchanged (you do not
need to restate its text).
- A group of MORE THAN ONE index is a merge — supply the merged `finding`,
`why`, `status`, `recommendation`, and a `primary_index` (one of the group's
members) whose datasheet citation/page is the strongest evidence; that
member supplies the finding's component attribution and source reference.
"""
SUBMIT_DEDUPED_SCHEMA = ToolSchema(
name="submit_deduped",
description=(
"Submit the cross-IC deduplicated findings. Every original finding "
"(1..N) must appear in exactly one group's `member_indices`."
),
input_schema={
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"type": "object",
"properties": {
"member_indices": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"description": (
"1-indexed positions in the original findings "
"list this group represents. Length 1 = "
"passthrough; length > 1 = merge."
),
},
"primary_index": {
"type": ["integer", "null"],
"description": (
"REQUIRED when member_indices has length > 1: "
"the member whose datasheet citation/source is "
"the strongest. Supplies the merged finding's "
"component attribution and source reference. "
"Must be one of member_indices."
),
},
"finding": {"type": "string"},
"why": {"type": "string"},
"status": {
"type": "string",
"enum": ["ERROR", "WARNING", "INFO"],
},
"recommendation": {"type": "string"},
"change_rationale": {
"type": "string",
"description": (
"≤1 line: 'passthrough', or 'merged N+M: "
"<shared interface/root cause>'."
),
},
},
"required": ["member_indices", "change_rationale"],
},
},
},
"required": ["groups"],
},
)
def _serialize_findings_for_prompt(findings: list[Finding]) -> str:
"""Number findings 1..N with their IC, severity, and text.
Unlike the per-IC normalize pass, the designator IS included — it is the
primary signal for spotting that two findings sit on opposite ends of one
interface.
"""
rows: list[dict] = []
for i, f in enumerate(findings, start=1):
rows.append({
"index": i,
"ic": f.designator,
"mpn": f.mpn,
"reviewer_severity": f.status,
"finding": f.finding,
"why": f.why,
"recommendation": f.recommendation,
"source_page": f.source_page,
"reference": f.reference,
})
return json.dumps(rows, indent=2)
def _build_deduped(
raw_groups: list[dict],
originals: list[Finding],
) -> list[Finding] | None:
"""Validate the tool output and reconstruct the deduped finding list.
Returns the kept/merged findings, or ``None`` if coverage/schema
validation fails (caller falls back to originals). A merge that omits a
valid ``primary_index`` is not a hard failure — that group falls back to
its per-index originals (un-merged), preserving coverage and severities.
"""
n = len(originals)
seen: set[int] = set()
result: list[Finding] = []
for group in raw_groups:
if not isinstance(group, dict):
return None
member_indices = group.get("member_indices") or []
if not isinstance(member_indices, list) or not member_indices:
return None
try:
indices = [int(x) for x in member_indices]
except (TypeError, ValueError):
return None
for idx in indices:
if idx < 1 or idx > n or idx in seen:
return None
seen.add(idx)
# Passthrough — keep the original verbatim. No laundering of text or
# severity for a finding the model chose not to merge.
if len(indices) == 1:
result.append(originals[indices[0] - 1])
continue
# Merge — needs a valid primary_index naming the canonical member.
# Missing/invalid → un-merge to per-index originals (coverage kept).
primary_raw = group.get("primary_index")
try:
primary = int(primary_raw)
except (TypeError, ValueError):
primary = None
if primary not in indices:
log.warning(
"dedupe: merge of %s has invalid primary_index %r"
"falling back to per-index originals (un-merging)",
indices, primary_raw,
)
for idx in indices:
result.append(originals[idx - 1])
continue
canon = originals[primary - 1]
members = [originals[i - 1] for i in indices]
ceiling = max(_SEVERITY_RANK.get(m.status, _ERR) for m in members)
unverified = any(_is_unverified(m.why) for m in members)
if unverified:
ceiling = min(ceiling, _WARN)
proposed = str(group.get("status") or canon.status)
final_status = _RANK_TO_SEV[
min(_SEVERITY_RANK.get(proposed, ceiling), ceiling)
]
new_why = str(group.get("why") or canon.why)
if unverified and not _is_unverified(new_why):
new_why = "Unverified: " + new_why
try:
result.append(Finding(
finding_id=canon.finding_id,
designator=canon.designator,
mpn=canon.mpn,
aspect=canon.aspect,
finding=str(group.get("finding") or canon.finding),
why=new_why,
source_page=group.get("source_page", canon.source_page),
source_quote=canon.source_quote,
source_designator=canon.source_designator,
status=final_status,
recommendation=str(
group.get("recommendation") or canon.recommendation
),
reference=str(group.get("reference") or canon.reference),
source=canon.source,
))
except Exception:
log.exception("dedupe: failed to build merged Finding")
return None
if seen != set(range(1, n + 1)):
return None
return result
async def dedupe_cross_ic_findings_async(
findings: list[Finding],
*,
api_logger: ApiLogger | None = None,
on_progress: Callable[[str, int, str, str], Awaitable[None]] | None = None,
) -> tuple[list[Finding], dict]:
"""Run the cross-IC dedup pass over findings from every IC.
Returns ``(deduped_findings, trace)``. On any failure (LLM error, schema
violation, coverage gap) returns the original findings unchanged with an
``error`` field set in the trace.
"""
trace: dict = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"input_findings": [f.model_dump(mode="json") for f in findings],
"output_findings": None,
"submission": None,
"model": None,
"provider": None,
"duration_ms": None,
"error": None,
}
# Nothing to merge across fewer than two findings.
if len(findings) < 2:
trace["output_findings"] = trace["input_findings"]
trace["error"] = "skipped: <2 findings"
return findings, trace
user_text = (
f"There are {len(findings)} findings across all reviewed ICs. "
f"Indices are 1-based. Group findings that describe the same physical "
f"defect (especially the same interface seen from both ICs) and call "
f"submit_deduped.\n\n{_serialize_findings_for_prompt(findings)}"
)
t0 = time.monotonic()
async def _run(provider, model):
trace["model"] = model
trace["provider"] = provider.name
session = await provider.create_session(
model=model,
system=SYSTEM_PROMPT,
max_tokens=4096,
temperature=0.0,
)
try:
completion = await session.complete(
messages=[Message(
role="user",
content=[TextBlock(text=user_text, cacheable=False)],
)],
tools=[SUBMIT_DEDUPED_SCHEMA],
tool_choice={"name": "submit_deduped"},
)
if api_logger:
api_logger.log(
stage="cross_ic_dedupe",
identifier="all",
model=model,
provider=provider.name,
input_tokens=completion.usage.input_tokens,
output_tokens=completion.usage.output_tokens,
cache_creation_input_tokens=completion.usage.cache_creation_tokens,
cache_read_input_tokens=completion.usage.cache_read_tokens,
duration_ms=int((time.monotonic() - t0) * 1000),
stop_reason="submit_deduped",
turns=1,
)
for tc in completion.tool_calls:
if tc.name == "submit_deduped":
return tc.input
return None
finally:
await session.close()
try:
# Reuse the "normalize" stage config (validation-class Sonnet model +
# any configured fallback); the log entry above is stamped
# "cross_ic_dedupe" so cost accounting still distinguishes it.
submission = await call_with_fallback("normalize", _run)
except Exception as exc:
log.exception("dedupe: call failed")
trace["error"] = f"{type(exc).__name__}: {exc}"
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
trace["output_findings"] = trace["input_findings"]
return findings, trace
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
trace["submission"] = submission
if not submission or not isinstance(submission, dict):
trace["error"] = "no submission"
trace["output_findings"] = trace["input_findings"]
return findings, trace
raw_groups = submission.get("groups") or []
if not isinstance(raw_groups, list):
trace["error"] = "submission.groups not a list"
trace["output_findings"] = trace["input_findings"]
return findings, trace
built = _build_deduped(raw_groups, findings)
if built is None:
trace["error"] = "invalid index coverage or schema"
trace["output_findings"] = trace["input_findings"]
log.warning(
"dedupe: invalid output (%d originals, %d groups) — "
"falling back to originals", len(findings), len(raw_groups),
)
return findings, trace
trace["output_findings"] = [f.model_dump(mode="json") for f in built]
if on_progress:
try:
await on_progress(
"cross_ic_dedupe", 0, "deduped",
f"{len(findings)}{len(built)} findings",
)
except Exception:
pass
return built, trace
+321
View File
@@ -0,0 +1,321 @@
"""DigiKey API integration — fetch datasheet PDFs and product parameters by MPN.
Uses DigiKey Product Information API v4 with OAuth2 client credentials.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
import httpx
from backend.config import settings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# OAuth2 token cache
# ---------------------------------------------------------------------------
_token_cache: dict[str, str | float] = {"access_token": "", "expires_at": 0.0}
_BASE_URLS = {
"production": "https://api.digikey.com",
"sandbox": "https://sandbox-api.digikey.com",
}
async def _get_access_token() -> str:
"""Get a DigiKey OAuth2 access token, refreshing if expired."""
now = time.time()
if _token_cache["access_token"] and float(_token_cache["expires_at"]) > now + 60:
return str(_token_cache["access_token"])
base = _BASE_URLS.get(settings.digikey_environment, _BASE_URLS["production"])
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{base}/v1/oauth2/token",
data={
"client_id": settings.digikey_client_id,
"client_secret": settings.digikey_client_secret,
"grant_type": "client_credentials",
},
)
resp.raise_for_status()
data = resp.json()
_token_cache["access_token"] = data["access_token"]
_token_cache["expires_at"] = now + data.get("expires_in", 3600)
logger.info("DigiKey OAuth token refreshed (expires in %ds)", data.get("expires_in", 3600))
return str(_token_cache["access_token"])
# ---------------------------------------------------------------------------
# Product search
# ---------------------------------------------------------------------------
def _get_mpn(product: dict) -> str:
return product.get("ManufacturerProductNumber") or product.get("ManufacturerPartNumber") or ""
def _get_ds_url(product: dict) -> str:
url = product.get("DatasheetUrl") or product.get("PrimaryDatasheet") or ""
# DigiKey sometimes returns protocol-relative URLs
if url.startswith("//"):
url = "https:" + url
return url
async def _keyword_search(mpn: str) -> list[dict]:
"""Run a DigiKey keyword search and return the raw products list."""
base = _BASE_URLS.get(settings.digikey_environment, _BASE_URLS["production"])
token = await _get_access_token()
headers = {
"Authorization": f"Bearer {token}",
"X-DIGIKEY-Client-Id": settings.digikey_client_id,
"X-DIGIKEY-Locale-Site": settings.digikey_locale_site,
"X-DIGIKEY-Locale-Language": settings.digikey_locale_language,
"X-DIGIKEY-Locale-Currency": settings.digikey_locale_currency,
"Content-Type": "application/json",
}
body = {
"Keywords": mpn,
"Limit": 5,
"Offset": 0,
"ExcludeMarketPlaceProducts": True,
}
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{base}/products/v4/search/keyword",
headers=headers,
json=body,
)
resp.raise_for_status()
data = resp.json()
return data.get("Products") or data.get("products") or []
def _find_product(mpn: str, products: list[dict]) -> dict | None:
"""Find the product whose MPN exactly matches ``mpn`` (case/space-insensitive).
Returns None when no result has a matching MPN. We intentionally do NOT
fall back to ``products[0]`` — keyword-search hits without an MPN match
are usually for a different part, and silently returning them has
polluted the library with wrong specs for non-MPN tokens like ``10uF``.
"""
if not products:
return None
mpn_upper = mpn.upper().replace(" ", "")
for product in products:
if _get_mpn(product).upper().replace(" ", "") == mpn_upper:
return product
return None
async def _search_mpn(mpn: str) -> str | None:
"""Search DigiKey for an MPN and return the primary datasheet URL, or None."""
products = await _keyword_search(mpn)
product = _find_product(mpn, products)
if not product:
return None
url = _get_ds_url(product)
return url or None
# ---------------------------------------------------------------------------
# PDF download + validation
# ---------------------------------------------------------------------------
_PDF_MAGIC = b"%PDF-"
_MIN_PDF_SIZE = 5_000 # 5 KB — anything smaller is probably an error page
async def _download_pdf(url: str) -> bytes:
"""Download a PDF from a URL and validate it.
Raises ValueError if the file isn't a valid PDF or is too small.
Raises httpx.HTTPStatusError on 4xx/5xx responses.
"""
async with httpx.AsyncClient(timeout=15, follow_redirects=True) 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 (bad magic bytes)")
if len(data) < _MIN_PDF_SIZE:
raise ValueError(f"PDF too small ({len(data)} bytes) — likely an error page")
return data
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
class DatasheetFetchResult:
"""Result of a datasheet fetch attempt."""
def __init__(
self,
mpn: str,
pdf_bytes: bytes | None = None,
error: str | None = None,
url: str | None = None,
):
self.mpn = mpn
self.pdf_bytes = pdf_bytes
self.error = error
self.url = url # DigiKey datasheet URL (present even when PDF download fails)
@property
def ok(self) -> bool:
return self.pdf_bytes is not None
async def fetch_datasheet(mpn: str) -> DatasheetFetchResult:
"""Fetch a datasheet PDF for the given MPN from DigiKey.
Returns a DatasheetFetchResult with either pdf_bytes or an error message.
The `url` field is set whenever DigiKey returns a datasheet link, even if
the PDF download itself fails.
Never raises — all errors are captured in the result.
"""
if not settings.use_digikey:
return DatasheetFetchResult(mpn, error="DigiKey API not configured")
try:
url = await _search_mpn(mpn)
except httpx.HTTPStatusError as e:
logger.warning("DigiKey search failed for %s: %s", mpn, e)
return DatasheetFetchResult(mpn, error=f"DigiKey search failed ({e.response.status_code})")
except Exception as e:
msg = str(e) or type(e).__name__
logger.warning("DigiKey search error for %s: %s", mpn, msg)
return DatasheetFetchResult(mpn, error=f"DigiKey search error: {msg}")
if not url:
return DatasheetFetchResult(mpn, error="No datasheet found on DigiKey")
try:
pdf_bytes = await _download_pdf(url)
except httpx.HTTPStatusError as e:
logger.warning("Datasheet download blocked for %s (%s): %s", mpn, url, e)
return DatasheetFetchResult(mpn, error=f"Download blocked ({e.response.status_code})", url=url)
except ValueError as e:
logger.warning("Invalid PDF for %s (%s): %s", mpn, url, e)
return DatasheetFetchResult(mpn, error=str(e), url=url)
except httpx.TimeoutException:
logger.warning("Datasheet download timed out for %s (%s)", mpn, url)
return DatasheetFetchResult(mpn, error="Download timed out", url=url)
except Exception as e:
msg = str(e) or type(e).__name__
logger.warning("Datasheet download failed for %s (%s): %s", mpn, url, msg)
return DatasheetFetchResult(mpn, error=f"Download failed: {msg}", url=url)
logger.info("Fetched datasheet for %s (%d KB)", mpn, len(pdf_bytes) // 1024)
return DatasheetFetchResult(mpn, pdf_bytes=pdf_bytes, url=url)
# ---------------------------------------------------------------------------
# Product parameters
# ---------------------------------------------------------------------------
@dataclass
class ProductParams:
"""Structured product parameters from a DigiKey search result."""
mpn: str
parameters: list[dict[str, str]] = field(default_factory=list) # [{"name": ..., "value": ...}]
category: str = ""
description: str = ""
class ParamsFetchResult:
"""Result of a product parameters fetch attempt."""
def __init__(self, mpn: str, params: ProductParams | None = None, error: str | None = None):
self.mpn = mpn
self.params = params
self.error = error
@property
def ok(self) -> bool:
return self.params is not None
def _parse_product_params(mpn: str, product: dict) -> ProductParams:
"""Extract structured parameters from a DigiKey product dict."""
raw_params = product.get("Parameters") or product.get("parameters") or []
parameters = []
for p in raw_params:
name = p.get("ParameterText") or p.get("parameterText") or ""
value = p.get("ValueText") or p.get("valueText") or ""
if name and value and value != "-":
parameters.append({"name": name, "value": value})
# Category
cat = product.get("Category") or product.get("category") or {}
category = cat.get("Name") or cat.get("name") or ""
# Description
desc_obj = product.get("Description") or product.get("description") or {}
if isinstance(desc_obj, str):
description = desc_obj
else:
description = (
desc_obj.get("ProductDescription")
or desc_obj.get("productDescription")
or desc_obj.get("DetailedDescription")
or desc_obj.get("detailedDescription")
or ""
)
return ProductParams(
mpn=mpn,
parameters=parameters,
category=category,
description=description,
)
async def fetch_params(mpn: str) -> ParamsFetchResult:
"""Fetch DigiKey product parameters for the given MPN.
Returns structured parameter data (no PDF download needed).
Never raises — all errors are captured in the result.
"""
if not settings.use_digikey:
return ParamsFetchResult(mpn, error="DigiKey API not configured")
try:
products = await _keyword_search(mpn)
except httpx.HTTPStatusError as e:
logger.warning("DigiKey search failed for %s: %s", mpn, e)
return ParamsFetchResult(mpn, error=f"DigiKey search failed ({e.response.status_code})")
except Exception as e:
msg = str(e) or type(e).__name__
logger.warning("DigiKey search error for %s: %s", mpn, msg)
return ParamsFetchResult(mpn, error=f"DigiKey search error: {msg}")
product = _find_product(mpn, products)
if not product:
return ParamsFetchResult(mpn, error="No results found on DigiKey")
params = _parse_product_params(mpn, product)
if not params.parameters:
return ParamsFetchResult(mpn, error="No parameters available on DigiKey")
logger.info("Fetched %d params for %s (category: %s)", len(params.parameters), mpn, params.category)
return ParamsFetchResult(mpn, params=params)
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
"""Cross-process event bridge for pipeline progress.
Today the FastAPI API process and the pipeline worker (Cloud Run Job
execution, or a local subprocess in dev) live in different processes, so
the in-memory ``EventBroker`` in ``services.pipeline`` can't span them.
The bridge:
* Worker writes one object per event to
``users/{user_id}/projects/{project_id}/events/{seq:010d}.json``.
The object holds ``{seq, ts, event, data}``. The worker is the only
writer for a given run, so its local monotonic ``seq`` counter
needs no coordination.
* API SSE handler tails the same prefix via ``StorageBackend.list_prefix_after``,
yielding events in order until a terminal one arrives or the caller
cancels.
This avoids appending to a single JSONL on GCS (no append API; full
rewrite or compose-per-event has worse semantics) and naturally survives
SSE reconnects (consumer just resumes from its last seen ``seq``).
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import AsyncIterator
from backend.services.projects import project_prefix
from backend.services.storage import StorageBackend
logger = logging.getLogger(__name__)
# Filename pattern: 10-digit zero-padded seq + .json. Lexicographic order
# matches numeric order so list_prefix_after pages cleanly.
_SEQ_WIDTH = 10
_FILENAME_FMT = f"{{seq:0{_SEQ_WIDTH}d}}.json"
# Terminal event names — the SSE loop stops on these.
TERMINAL_EVENTS = frozenset({
"pipeline_complete",
"pipeline_error",
"pipeline_cancelled",
"pipeline_paused",
})
def _events_prefix(user_id: str, project_id: str) -> str:
return f"{project_prefix(user_id, project_id)}/events/"
def _seq_from_key(key: str) -> int | None:
"""Extract the integer seq from an event key; ``None`` on parse failure."""
name = key.rsplit("/", 1)[-1]
if not name.endswith(".json"):
return None
stem = name[:-5]
try:
return int(stem)
except ValueError:
return None
class GCSEventBroker:
"""Drop-in for the in-memory ``EventBroker`` that persists to storage.
Same interface (``publish``, ``subscribe``, ``unsubscribe``,
``clear_history``) so the worker can swap it in for the module-level
``broker`` singleton without touching call sites. Subscription is a
no-op — the API consumes events via :func:`tail_events` instead.
"""
def __init__(self, storage: StorageBackend, user_id: str) -> None:
self.storage = storage
self.user_id = user_id
# Per-project local counter. Workers handle one project per
# execution, but the dict shape keeps parity with ``EventBroker``.
self._seq: dict[str, int] = {}
def subscribe(self, project_id: str) -> asyncio.Queue:
# Workers never subscribe — only the API tails the GCS event log.
# Returning an unfed queue is acceptable but raising is more
# honest about the contract.
raise NotImplementedError(
"GCSEventBroker is publish-only; subscribers should call "
"event_bridge.tail_events(...) instead."
)
def unsubscribe(self, project_id: str, q: asyncio.Queue) -> None:
# No-op for symmetry with the in-memory broker.
return
def clear_history(self, project_id: str) -> None:
"""Wipe all prior event objects for this project.
Called at the start of a fresh run so resumed/restarted runs
don't intermix with stale events from earlier attempts.
"""
prefix = _events_prefix(self.user_id, project_id)
try:
self.storage.delete_prefix(prefix)
except Exception:
logger.exception("failed to clear event history at %s", prefix)
self._seq[project_id] = 0
def publish(self, project_id: str, event: str, data: dict) -> None:
seq = self._seq.get(project_id, 0)
self._seq[project_id] = seq + 1
key = _events_prefix(self.user_id, project_id) + _FILENAME_FMT.format(seq=seq)
msg = {
"seq": seq,
"ts": datetime.now(timezone.utc).isoformat(),
"event": event,
"data": data,
}
try:
self.storage.write_json(key, msg)
except Exception:
# An event-write failure should never crash the pipeline.
logger.exception("failed to write event %s to %s", event, key)
async def tail_events(
storage: StorageBackend,
user_id: str,
project_id: str,
*,
poll_interval: float = 0.5,
heartbeat_interval: float = 15.0,
) -> AsyncIterator[dict]:
"""Yield events from the GCS-backed event log in order.
Stops yielding after a terminal event (``pipeline_complete``,
``pipeline_error``, ``pipeline_cancelled``). Emits a
``{"event": "heartbeat", "data": {}}`` synthetic event roughly every
``heartbeat_interval`` seconds when no real events arrive, matching
the behaviour of the in-memory broker's SSE loop.
The caller is expected to handle disconnects/cancellations and
secondary terminal-detection (``meta.status``, Cloud Run execution
state) on top of this iterator.
"""
prefix = _events_prefix(user_id, project_id)
last_seen_key: str | None = None
last_emit_ts = 0.0
while True:
try:
keys = storage.list_prefix_after(prefix, after_key=last_seen_key)
except Exception:
logger.exception("event tail: list_prefix_after failed for %s", prefix)
keys = []
emitted_any = False
for key in keys:
try:
msg = storage.read_json(key)
except Exception:
logger.exception("event tail: read_json failed for %s", key)
continue
yield msg
emitted_any = True
last_seen_key = key
last_emit_ts = asyncio.get_event_loop().time()
if msg.get("event") in TERMINAL_EVENTS:
return
now = asyncio.get_event_loop().time()
if not emitted_any and now - last_emit_ts >= heartbeat_interval:
yield {"event": "heartbeat", "data": {}}
last_emit_ts = now
await asyncio.sleep(poll_interval)
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
"""Pipeline-worker dispatcher.
In production: enqueues a Cloud Run Job execution that runs the
``backend.pipeline_worker`` entrypoint with project_id/user_id/resume/free
passed as env-var overrides.
In local dev (no ``GCS_BUCKET``): launches the worker as a child process
so the same code path runs end-to-end. Removes the in-process
``BackgroundTask`` divergence between dev and prod.
"""
from __future__ import annotations
import logging
import os
import subprocess
import sys
import threading
from typing import Literal
from backend.config import settings
logger = logging.getLogger(__name__)
ExecutionState = Literal[
"pending", "running", "succeeded", "failed", "cancelled", "unknown"
]
# ---------------------------------------------------------------------------
# Local subprocess fallback (dev mode)
# ---------------------------------------------------------------------------
# Track child processes so the API can query "is it still running?" in
# dev. In prod the Cloud Run Jobs admin API answers the same question.
_local_procs: dict[str, subprocess.Popen] = {}
_local_procs_lock = threading.Lock()
def _local_execution_name(project_id: str) -> str:
"""Stable synthetic execution name for the dev subprocess path.
Lets the rest of the codebase treat dev runs uniformly with prod
runs (we always have an ``execution_name`` to store on ProjectMeta
and pass to status / cancel calls).
"""
return f"local/projects/{project_id}"
def _spawn_local_subprocess(
project_id: str,
user_id: str,
*,
resume: bool,
free: bool,
mode: str = "run",
regen_stages: list[str] | None = None,
) -> str:
name = _local_execution_name(project_id)
env = os.environ.copy()
env["PROJECT_ID"] = project_id
env["USER_ID"] = user_id
env["RESUME"] = "1" if resume else "0"
env["FREE"] = "1" if free else "0"
env["MODE"] = mode
if regen_stages:
env["REGEN_STAGES"] = ",".join(regen_stages)
env["EXECUTION_NAME"] = name
proc = subprocess.Popen(
[sys.executable, "-m", "backend.pipeline_worker"],
env=env,
# Inherit stdout/stderr so logs appear in the dev terminal
stdin=subprocess.DEVNULL,
)
with _local_procs_lock:
# Reap any old proc for the same project before tracking the new one.
prior = _local_procs.pop(project_id, None)
if prior is not None:
try:
prior.terminate()
except Exception:
pass
_local_procs[project_id] = proc
logger.info("dev: spawned worker subprocess pid=%s for %s", proc.pid, project_id)
return name
def _local_state(project_id: str) -> ExecutionState:
with _local_procs_lock:
proc = _local_procs.get(project_id)
if proc is None:
return "unknown"
rc = proc.poll()
if rc is None:
return "running"
if rc == 0:
return "succeeded"
if rc < 0:
# Negative return = terminated by signal
return "cancelled"
return "failed"
def _local_cancel(project_id: str) -> None:
with _local_procs_lock:
proc = _local_procs.get(project_id)
if proc is None or proc.poll() is not None:
return
try:
proc.terminate()
except Exception:
logger.exception("dev: failed to terminate worker subprocess for %s", project_id)
# ---------------------------------------------------------------------------
# Cloud Run Jobs (prod path)
# ---------------------------------------------------------------------------
def _gcp_project() -> str:
"""Resolve the GCP project id for the Cloud Run Jobs admin API."""
if settings.pipeline_worker_project:
return settings.pipeline_worker_project
proj = os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("GCLOUD_PROJECT")
if proj:
return proj
# Fall back to the metadata server (works on Cloud Run).
try:
import requests # type: ignore[import-not-found]
resp = requests.get(
"http://metadata.google.internal/computeMetadata/v1/project/project-id",
headers={"Metadata-Flavor": "Google"},
timeout=2.0,
)
if resp.ok:
return resp.text.strip()
except Exception:
pass
raise RuntimeError(
"Could not resolve GCP project for Cloud Run Jobs. Set "
"PIPELINE_WORKER_PROJECT or GOOGLE_CLOUD_PROJECT."
)
def _job_resource_name() -> str:
return (
f"projects/{_gcp_project()}/locations/{settings.pipeline_worker_region}"
f"/jobs/{settings.pipeline_worker_job_name}"
)
def _jobs_client():
# Lazy import: keeps the API process startup fast in local dev where
# google-cloud-run isn't even installed (it's an optional dep there).
from google.cloud import run_v2 # type: ignore[import-not-found]
return run_v2.JobsClient()
def _executions_client():
from google.cloud import run_v2 # type: ignore[import-not-found]
return run_v2.ExecutionsClient()
def _enqueue_cloud_run_job(
project_id: str,
user_id: str,
*,
resume: bool,
free: bool,
mode: str = "run",
regen_stages: list[str] | None = None,
) -> str:
"""Issue ``RunJob`` with env-var overrides; return the execution name."""
from google.cloud import run_v2 # type: ignore[import-not-found]
env_overrides = [
run_v2.EnvVar(name="PROJECT_ID", value=project_id),
run_v2.EnvVar(name="USER_ID", value=user_id),
run_v2.EnvVar(name="RESUME", value="1" if resume else "0"),
run_v2.EnvVar(name="FREE", value="1" if free else "0"),
run_v2.EnvVar(name="MODE", value=mode),
]
if regen_stages:
env_overrides.append(
run_v2.EnvVar(name="REGEN_STAGES", value=",".join(regen_stages)),
)
overrides = run_v2.RunJobRequest.Overrides(
container_overrides=[
run_v2.RunJobRequest.Overrides.ContainerOverride(env=env_overrides),
],
)
request = run_v2.RunJobRequest(name=_job_resource_name(), overrides=overrides)
operation = _jobs_client().run_job(request=request)
# Don't wait for completion — fire and forget. The metadata is enough
# to extract the execution resource name.
metadata = operation.metadata
name = getattr(metadata, "name", None) if metadata is not None else None
if not name:
# As a fallback, peek at the operation; on Cloud Run RunJob this
# is a long-running op whose initial metadata holds the execution.
name = operation.operation.name # type: ignore[union-attr]
if not name:
raise RuntimeError("Cloud Run RunJob returned no execution name")
logger.info("enqueued Cloud Run Job execution %s for project %s", name, project_id)
return name
def _cloud_run_state(execution_name: str) -> ExecutionState:
"""Map Cloud Run Execution state to our enum."""
try:
from google.cloud import run_v2 # type: ignore[import-not-found]
client = _executions_client()
ex = client.get_execution(name=execution_name)
except Exception:
logger.exception("get_execution failed for %s", execution_name)
return "unknown"
# An Execution has reconciliation_started, completion_time, conditions.
# Map to our enum based on completion + conditions.
if ex.completion_time is None or ex.completion_time.seconds == 0:
if ex.start_time and ex.start_time.seconds:
return "running"
return "pending"
# Completed — figure out success vs failure.
failed = int(getattr(ex, "failed_count", 0) or 0)
cancelled = int(getattr(ex, "cancelled_count", 0) or 0)
succeeded = int(getattr(ex, "succeeded_count", 0) or 0)
if cancelled > 0 and succeeded == 0:
return "cancelled"
if failed > 0:
return "failed"
if succeeded > 0:
return "succeeded"
return "unknown"
def _cloud_run_cancel(execution_name: str) -> None:
try:
from google.cloud import run_v2 # type: ignore[import-not-found]
request = run_v2.CancelExecutionRequest(name=execution_name)
_executions_client().cancel_execution(request=request)
except Exception:
logger.exception("cancel_execution failed for %s", execution_name)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def use_cloud_run_jobs() -> bool:
"""True iff we should dispatch via Cloud Run Jobs.
Tied to whether GCS storage is configured — Jobs and GCS go together
in prod, and local dev uses neither.
"""
return bool(settings.gcs_bucket)
def enqueue_pipeline(
project_id: str, user_id: str, *, resume: bool = False, free: bool = False,
) -> str:
"""Dispatch a pipeline run.
In prod, returns the Cloud Run Execution resource name. In dev,
returns a synthetic ``local/projects/{id}`` name. Either way, callers
should persist the returned name on ``ProjectMeta.execution_name``.
"""
if use_cloud_run_jobs():
return _enqueue_cloud_run_job(project_id, user_id, resume=resume, free=free)
return _spawn_local_subprocess(project_id, user_id, resume=resume, free=free)
def enqueue_pipeline_regen(
project_id: str, user_id: str, *, stages: list[str],
) -> str:
"""Dispatch a regen run (graph + selected stages, free).
Same image, same worker; differs only in the env-var-driven mode.
"""
if not stages:
raise ValueError("regen requires at least one stage")
if use_cloud_run_jobs():
return _enqueue_cloud_run_job(
project_id, user_id, resume=False, free=True,
mode="regen", regen_stages=stages,
)
return _spawn_local_subprocess(
project_id, user_id, resume=False, free=True,
mode="regen", regen_stages=stages,
)
def get_execution_state(execution_name: str | None) -> ExecutionState:
"""Return current state of a previously-enqueued execution.
Used by the SSE handler's hard-crash escape hatch and by the
stale-running sweeper. ``None`` -> ``"unknown"``.
"""
if not execution_name:
return "unknown"
if execution_name.startswith("local/projects/"):
project_id = execution_name.split("/", 2)[-1]
return _local_state(project_id)
return _cloud_run_state(execution_name)
def cancel_execution(execution_name: str | None) -> None:
"""Hard-cancel an execution (Cloud Run cancel or local SIGTERM).
Best-effort. Soft cancel via ``meta.cancel_requested`` is preferred —
only fall back to this when the worker has already gone unresponsive.
"""
if not execution_name:
return
if execution_name.startswith("local/projects/"):
project_id = execution_name.split("/", 2)[-1]
_local_cancel(project_id)
return
_cloud_run_cancel(execution_name)
+36
View File
@@ -0,0 +1,36 @@
"""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
overrides via ``Settings.provider_*`` env vars route specific stages to
other providers (currently Anthropic + Gemini).
"""
from backend.services.llm.factory import call_with_fallback, get_provider
from backend.services.llm.types import (
Completion,
ContentBlock,
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolChoice,
ToolResultBlock,
ToolSchema,
Usage,
)
__all__ = [
"Completion",
"ContentBlock",
"Message",
"PdfBlock",
"TextBlock",
"ToolCall",
"ToolChoice",
"ToolResultBlock",
"ToolSchema",
"Usage",
"call_with_fallback",
"get_provider",
]
+363
View File
@@ -0,0 +1,363 @@
"""Anthropic provider — wraps AsyncAnthropic + Console Skills.
Translates the unified ``Message`` / ``Completion`` shapes into Anthropic's
native message-block format and back. Caching is per-block via
``cache_control: ephemeral``."""
from __future__ import annotations
import base64
import re
import time
from pathlib import Path
import anthropic
from backend.config import settings
from backend.services.llm.base import LLMProvider, LLMSession
from backend.services.llm.types import (
Completion,
ContentBlock,
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolChoice,
ToolResultBlock,
ToolSchema,
Usage,
)
_SKILL_MAX_TURNS = 10
# Sampling params were removed on newer Claude models (Sonnet 5, Opus 4.7+,
# Fable/Mythos 5) — sending `temperature` returns 400 "`temperature` is
# deprecated for this model". Allowlist the families that still accept it so
# unknown/future models fail safe (omit → default sampling) instead of
# 400-ing every call in the session.
_TEMPERATURE_OK = re.compile(r"^claude-(3-|opus-4-[0-6]|sonnet-4-|haiku-)")
def _model_accepts_temperature(model: str) -> bool:
return bool(_TEMPERATURE_OK.match(model))
# ---------------------------------------------------------------------------
# Translation helpers — unified types ↔ Anthropic dicts
# ---------------------------------------------------------------------------
def _encode_pdf_block(path: Path | str, *, cache: bool) -> dict:
data = base64.standard_b64encode(Path(path).read_bytes()).decode()
block: dict = {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": data},
}
if cache:
block["cache_control"] = {"type": "ephemeral"}
return block
def _to_anthropic_block(b: ContentBlock) -> dict:
if isinstance(b, TextBlock):
d: dict = {"type": "text", "text": b.text}
if b.cacheable:
d["cache_control"] = {"type": "ephemeral"}
return d
if isinstance(b, PdfBlock):
return _encode_pdf_block(b.path, cache=b.cacheable)
if isinstance(b, ToolCall):
return {"type": "tool_use", "id": b.id, "name": b.name, "input": b.input}
if isinstance(b, ToolResultBlock):
return {
"type": "tool_result",
"tool_use_id": b.tool_use_id,
"content": b.content,
}
raise TypeError(f"Unknown ContentBlock: {type(b).__name__}")
def _to_anthropic_message(m: Message) -> dict:
return {"role": m.role, "content": [_to_anthropic_block(b) for b in m.content]}
# Anthropic allows at most 4 cache_control breakpoints per request. The system
# prompt always consumes one (see AnthropicSession.complete), leaving 3 for
# message content. A multi-turn review attaches a cacheable PDF for each
# get_datasheet_excerpt fetch (validation_tools.py), so a hub IC that verifies
# two interface excerpts produced 5 breakpoints — system + initial PDF + initial
# context + 2 excerpts — and the API rejected the request with
# "A maximum of 4 blocks with cache_control may be provided. Found 5."
#
# Cap the message-block breakpoints in the translated request, keeping the most
# valuable ones: the first cacheable block (the full-datasheet anchor — a stable,
# guaranteed cache hit every turn) plus the two most recent (incremental caching
# of the growing tail). Any caller-set cache_control beyond that is dropped.
_MAX_MESSAGE_CACHE_BREAKPOINTS = 3
def _enforce_cache_breakpoint_limit(messages: list[dict]) -> None:
"""Strip excess cache_control markers from message blocks in place so that
system(1) + message breakpoints never exceed Anthropic's per-request limit."""
marked: list[dict] = []
for m in messages:
content = m.get("content")
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and "cache_control" in block:
marked.append(block)
if len(marked) <= _MAX_MESSAGE_CACHE_BREAKPOINTS:
return
keep = {id(marked[0]), id(marked[-1]), id(marked[-2])}
for block in marked:
if id(block) not in keep:
block.pop("cache_control", None)
def _to_anthropic_tool(t: ToolSchema) -> dict:
return {"name": t.name, "description": t.description, "input_schema": t.input_schema}
def _to_anthropic_tool_choice(c: ToolChoice) -> dict:
if c == "auto":
return {"type": "auto"}
if c == "none":
return {"type": "none"}
if isinstance(c, dict) and "name" in c:
return {"type": "tool", "name": c["name"]}
raise ValueError(f"Invalid tool_choice: {c!r}")
def _from_anthropic_response(resp) -> Completion:
"""Parse an Anthropic message response into a unified Completion."""
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
raw_blocks: list[ContentBlock] = []
for block in resp.content:
btype = getattr(block, "type", None)
if btype == "text":
text_parts.append(block.text)
raw_blocks.append(TextBlock(text=block.text))
elif btype == "tool_use":
tc = ToolCall(id=block.id, name=block.name, input=dict(block.input))
tool_calls.append(tc)
raw_blocks.append(tc)
# Other block types (server tool calls etc.) are pass-through ignored
usage = Usage(
input_tokens=resp.usage.input_tokens,
output_tokens=resp.usage.output_tokens,
cache_creation_tokens=getattr(resp.usage, "cache_creation_input_tokens", 0) or 0,
cache_read_tokens=getattr(resp.usage, "cache_read_input_tokens", 0) or 0,
)
return Completion(
text="".join(text_parts),
tool_calls=tool_calls,
usage=usage,
stop_reason=resp.stop_reason or "unknown",
raw_assistant_blocks=raw_blocks,
)
# ---------------------------------------------------------------------------
# Session
# ---------------------------------------------------------------------------
class AnthropicSession(LLMSession):
provider_name = "anthropic"
def __init__(
self,
*,
client: anthropic.AsyncAnthropic,
model: str,
system: str,
max_tokens: int,
temperature: float | None = None,
) -> None:
self._client = client
self.model = model
self._system = system
self._max_tokens = max_tokens
self._temperature = temperature
async def complete(
self,
*,
messages: list[Message],
tools: list[ToolSchema] | None = None,
tool_choice: ToolChoice = "auto",
) -> Completion:
kwargs: dict = {
"model": self.model,
"max_tokens": self._max_tokens,
"system": [{
"type": "text",
"text": self._system,
"cache_control": {"type": "ephemeral"},
}],
"messages": [_to_anthropic_message(m) for m in messages],
}
_enforce_cache_breakpoint_limit(kwargs["messages"])
if self._temperature is not None and _model_accepts_temperature(self.model):
kwargs["temperature"] = self._temperature
if tools:
kwargs["tools"] = [_to_anthropic_tool(t) for t in tools]
kwargs["tool_choice"] = _to_anthropic_tool_choice(tool_choice)
# Streaming, not create(): SDK 0.83+ raises ValueError pre-flight on
# `messages.create` whenever max_tokens crosses ~21k for Sonnet
# (the "may take longer than 10 minutes" guard). Review uses 32k
# max_tokens for Gemini thinking headroom; streaming bypasses that
# client-side timeout cap. get_final_message() returns the same
# shape as create(), so _from_anthropic_response is reused as-is.
async with self._client.messages.stream(**kwargs) as stream:
resp = await stream.get_final_message()
return _from_anthropic_response(resp)
async def close(self) -> None:
# Anthropic ephemeral cache cleans up on its own (5-min TTL).
pass
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
class AnthropicProvider(LLMProvider):
name = "anthropic"
def __init__(self) -> None:
self._client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
async def create_session(
self,
*,
model: str,
system: str,
max_tokens: int = 4096,
temperature: float | None = None,
) -> LLMSession:
return AnthropicSession(
client=self._client,
model=model,
system=system,
max_tokens=max_tokens,
temperature=temperature,
)
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]:
"""Anthropic Console Skills — multi-turn skill execution with the
``skills-2025-10-02`` + ``code-execution-2025-08-25`` betas.
Skill mounts in a per-call container; the model reads ``SKILL.md``,
runs ``validate.py`` server-side via code_execution, and voluntarily
calls ``output_tool`` once it has well-formed data.
"""
skill_id, version = settings.get_skill(skill_name)
# Build initial user content
user_content: list[dict] = []
if pdf_path:
user_content.append(_encode_pdf_block(pdf_path, cache=True))
user_content.append({"type": "text", "text": user_text})
messages: list[dict] = [{"role": "user", "content": user_content}]
container: dict = {
"skills": [{
"type": "custom",
"skill_id": skill_id,
"version": version,
}],
}
total_input = 0
total_output = 0
total_cache_creation = 0
total_cache_read = 0
t0 = time.monotonic()
last_resp = None
for turn in range(_SKILL_MAX_TURNS):
resp = await self._client.beta.messages.create(
model=model,
max_tokens=16384,
system=[{
"type": "text",
"text": system,
"cache_control": {"type": "ephemeral"},
}],
tools=[
{"type": "code_execution_20250825", "name": "code_execution"},
_to_anthropic_tool(output_tool),
],
container=container,
messages=messages,
betas=["skills-2025-10-02", "code-execution-2025-08-25"],
)
last_resp = resp
total_input += resp.usage.input_tokens
total_output += resp.usage.output_tokens
total_cache_creation += getattr(resp.usage, "cache_creation_input_tokens", 0) or 0
total_cache_read += getattr(resp.usage, "cache_read_input_tokens", 0) or 0
# Reuse container for subsequent turns
if hasattr(resp, "container") and resp.container:
container = {"id": resp.container.id}
for block in resp.content:
if (
getattr(block, "type", None) == "tool_use"
and block.name == output_tool.name
):
completion = Completion(
text="",
tool_calls=[ToolCall(id=block.id, name=block.name, input=dict(block.input))],
usage=Usage(
input_tokens=total_input,
output_tokens=total_output,
cache_creation_tokens=total_cache_creation,
cache_read_tokens=total_cache_read,
),
stop_reason=resp.stop_reason or "unknown",
)
# Stash turns count via attribute for callers that need it
completion.turns = turn + 1 # type: ignore[attr-defined]
completion.duration_ms = int((time.monotonic() - t0) * 1000) # type: ignore[attr-defined]
return dict(block.input), completion
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason == "pause_turn":
continue
if resp.stop_reason == "end_turn":
messages.append({
"role": "user",
"content": f"Please call {output_tool.name} with the extracted data.",
})
continue
# tool_use from code_execution — let the loop continue
continue
raise RuntimeError(
f"Skill {skill_name!r} did not produce {output_tool.name} "
f"in {_SKILL_MAX_TURNS} turns"
)
+100
View File
@@ -0,0 +1,100 @@
"""Abstract LLMProvider + LLMSession interfaces."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Protocol
from backend.services.llm.types import (
Completion,
Message,
ToolChoice,
ToolSchema,
)
class LLMSession(ABC):
"""A multi-turn conversation with provider-specific cache lifecycle.
Lifecycle::
session = await provider.create_session(model=..., system=...)
try:
messages = [Message("user", [
PdfBlock(path, cacheable=True),
TextBlock(context, cacheable=True),
])]
for turn in range(N):
completion = await session.complete(
messages=messages, tools=..., tool_choice=...,
)
# process tool_calls, append to messages, repeat
finally:
await session.close()
Caching: blocks with ``cacheable=True`` participate in provider caching.
Anthropic stamps ``cache_control: ephemeral`` on each cacheable block on
every call. Gemini collects all cacheable blocks (plus the system prompt)
on the first ``complete()`` call into a ``CachedContent`` object and
references it on subsequent calls. The system prompt is always cached.
"""
provider_name: str
"""Provider identifier ("anthropic", "gemini") — used for api_logs."""
model: str
@abstractmethod
async def complete(
self,
*,
messages: list[Message],
tools: list[ToolSchema] | None = None,
tool_choice: ToolChoice = "auto",
) -> Completion:
"""Run one inference turn."""
@abstractmethod
async def close(self) -> None:
"""Release any provider-side resources (e.g. delete a cache object).
Safe to call multiple times."""
class LLMProvider(Protocol):
"""Top-level provider interface."""
name: str
async def create_session(
self,
*,
model: str,
system: str,
max_tokens: int = 4096,
temperature: float | None = None,
) -> LLMSession:
"""Construct a session. ``system`` is always cached by the session.
``temperature`` — if not None, applied to every ``complete()`` call on
this session. ``None`` means use the provider's default. Set to 0.0
for deterministic-as-possible behavior in agentic loops where the same
inputs should produce the same outputs."""
...
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"]:
"""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."""
...
+73
View File
@@ -0,0 +1,73 @@
"""Provider factory + per-stage routing."""
from __future__ import annotations
import asyncio
import logging
from functools import lru_cache
from typing import Awaitable, Callable, TypeVar
from backend.config import settings
from backend.services.llm.base import LLMProvider
log = logging.getLogger(__name__)
T = TypeVar("T")
@lru_cache(maxsize=4)
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`."""
if name == "anthropic":
from backend.services.llm.anthropic_provider import AnthropicProvider
return AnthropicProvider()
if name == "gemini":
from backend.services.llm.gemini_provider import GeminiProvider
return GeminiProvider()
raise ValueError(f"Unknown LLM provider: {name!r}")
# Backwards-compatible alias
_get_provider_by_name = get_provider_by_name
def get_provider(stage: str) -> LLMProvider:
"""Return the provider configured for ``stage``.
Falls back to ``settings.provider_default`` if no per-stage override.
Providers are cached per-name, so repeated calls return the same
instance (and share the underlying SDK client)."""
name = settings.provider_for_stage(stage)
return get_provider_by_name(name)
async def call_with_fallback(
stage: str,
body: Callable[[LLMProvider, str], Awaitable[T]],
) -> T:
"""Run ``body(provider, model)`` for ``stage``; on any exception,
retry once with the fallback provider/model if one is configured via
``FALLBACK_PROVIDER_<STAGE>`` / ``FALLBACK_MODEL_<STAGE>``.
The fallback runs ``body`` from scratch — any tokens spent in the
primary attempt are lost (and not logged). ``asyncio.CancelledError``
is always re-raised so cancellation still works.
"""
primary_provider = get_provider(stage)
primary_model = settings.model_for_stage(stage)
try:
return await body(primary_provider, primary_model)
except asyncio.CancelledError:
raise
except Exception as exc:
fb = settings.fallback_for_stage(stage)
if fb is None:
raise
log.warning(
"[%s] primary %s/%s failed (%s) — falling back to %s/%s",
stage, primary_provider.name, primary_model,
exc, fb[0], fb[1],
)
fallback_provider = get_provider_by_name(fb[0])
return await body(fallback_provider, fb[1])
+379
View File
@@ -0,0 +1,379 @@
"""Gemini provider — wraps google-genai async client.
Translates the unified ``Message`` / ``Completion`` shapes into Gemini's
native ``Content`` / ``Part`` format. Caching uses ``CachedContent``: on the
first ``complete()`` call, cacheable blocks (system + any block flagged
``cacheable=True`` in the first user message) are uploaded as a
``CachedContent`` with TTL=30min; subsequent calls reference the cache by
name. On ``close()`` the cache is deleted. If creation fails (e.g.
sub-threshold token count), the session falls back to inline content with no
caching for the remainder of the conversation.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from google import genai
from google.genai import types as gtypes
from backend.config import settings
from backend.services.llm.base import LLMProvider, LLMSession
from backend.services.llm.types import (
Completion,
ContentBlock,
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolChoice,
ToolResultBlock,
ToolSchema,
Usage,
)
log = logging.getLogger(__name__)
_CACHE_TTL = "1800s" # 30 min — covers our longest agent loop with margin
# ---------------------------------------------------------------------------
# Translation helpers — unified types ↔ Gemini Parts/Contents
# ---------------------------------------------------------------------------
def _block_to_part(b: ContentBlock) -> gtypes.Part:
if isinstance(b, TextBlock):
return gtypes.Part(
text=b.text,
thought_signature=b.thought_signature,
)
if isinstance(b, PdfBlock):
return gtypes.Part(
inline_data=gtypes.Blob(
mime_type="application/pdf",
data=Path(b.path).read_bytes(),
),
)
if isinstance(b, ToolCall):
return gtypes.Part(
function_call=gtypes.FunctionCall(
id=b.id or None,
name=b.name,
args=b.input,
),
thought_signature=b.thought_signature,
)
if isinstance(b, ToolResultBlock):
return gtypes.Part(
function_response=gtypes.FunctionResponse(
id=b.tool_use_id or None,
name=b.name,
# FunctionResponse.response is a dict — wrap string content
response={"result": b.content},
),
)
raise TypeError(f"Unknown ContentBlock: {type(b).__name__}")
def _message_to_content(m: Message) -> gtypes.Content:
# Gemini uses "user" and "model" (not "assistant")
role = "model" if m.role == "assistant" else "user"
return gtypes.Content(
role=role,
parts=[_block_to_part(b) for b in m.content],
)
def _tool_to_function_declaration(t: ToolSchema) -> gtypes.FunctionDeclaration:
return gtypes.FunctionDeclaration(
name=t.name,
description=t.description,
parameters_json_schema=t.input_schema,
)
def _tools_to_gemini(tools: list[ToolSchema]) -> list[gtypes.Tool]:
return [
gtypes.Tool(
function_declarations=[_tool_to_function_declaration(t) for t in tools],
),
]
def _tool_choice_to_config(c: ToolChoice) -> gtypes.ToolConfig:
if c == "auto":
return gtypes.ToolConfig(
function_calling_config=gtypes.FunctionCallingConfig(mode="AUTO"),
)
if c == "none":
return gtypes.ToolConfig(
function_calling_config=gtypes.FunctionCallingConfig(mode="NONE"),
)
if isinstance(c, dict) and "name" in c:
return gtypes.ToolConfig(
function_calling_config=gtypes.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[c["name"]],
),
)
raise ValueError(f"Invalid tool_choice: {c!r}")
def _from_gemini_response(resp: Any) -> Completion:
"""Parse a Gemini GenerateContentResponse into a unified Completion."""
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
raw_blocks: list[ContentBlock] = []
stop_reason = "unknown"
candidates = getattr(resp, "candidates", None) or []
if candidates:
cand = candidates[0]
finish = getattr(cand, "finish_reason", None)
if finish:
stop_reason = str(finish).lower().split(".")[-1]
content = getattr(cand, "content", None)
if content and content.parts:
for part in content.parts:
# Preserve thought_signature (Gemini 3 thinking-mode) for
# exact replay on subsequent turns; missing signatures cause
# 400 INVALID_ARGUMENT on the next call.
sig = getattr(part, "thought_signature", None)
if getattr(part, "text", None):
text_parts.append(part.text)
raw_blocks.append(TextBlock(
text=part.text, thought_signature=sig,
))
elif getattr(part, "function_call", None):
fc = part.function_call
tc = ToolCall(
id=fc.id or f"{fc.name}_{len(tool_calls)}",
name=fc.name,
input=dict(fc.args or {}),
thought_signature=sig,
)
tool_calls.append(tc)
raw_blocks.append(tc)
usage_md = getattr(resp, "usage_metadata", None)
if usage_md is not None:
prompt_tokens = usage_md.prompt_token_count or 0
cached_tokens = usage_md.cached_content_token_count or 0
# Gemini reports prompt_token_count as the TOTAL prompt tokens —
# cached tokens are billed at the cache-read rate, the rest at the
# input rate. Subtract so they don't double-count.
non_cached = max(0, prompt_tokens - cached_tokens)
# Thinking-mode models (2.5 Pro, 3 series) report reasoning tokens
# in thoughts_token_count, billed at the output rate. Fold into
# output_tokens so cost accounting matches Gemini's actual bill.
thoughts_tokens = getattr(usage_md, "thoughts_token_count", 0) or 0
usage = Usage(
input_tokens=non_cached,
output_tokens=(usage_md.candidates_token_count or 0) + thoughts_tokens,
cache_creation_tokens=0, # Gemini doesn't expose this separately
cache_read_tokens=cached_tokens,
)
else:
usage = Usage()
return Completion(
text="".join(text_parts),
tool_calls=tool_calls,
usage=usage,
stop_reason=stop_reason,
raw_assistant_blocks=raw_blocks,
)
def _is_first_user_message_fully_cacheable(messages: list[Message]) -> bool:
"""We cache only when EVERY block in the very first user message is
flagged cacheable. This matches our actual usage (validation + power
tree both pass entirely cacheable initial messages) and avoids brittle
partial-cache scenarios."""
if not messages:
return False
first = messages[0]
if first.role != "user" or not first.content:
return False
return all(
isinstance(b, (TextBlock, PdfBlock)) and b.cacheable
for b in first.content
)
# ---------------------------------------------------------------------------
# Session
# ---------------------------------------------------------------------------
class GeminiSession(LLMSession):
provider_name = "gemini"
def __init__(
self,
*,
client: genai.Client,
model: str,
system: str,
max_tokens: int,
temperature: float | None = None,
) -> None:
self._client = client
self.model = model
self._system = system
self._max_tokens = max_tokens
self._temperature = temperature
self._cache_name: str | None = None
self._cache_attempted = False
async def _try_create_cache(self, first_msg: Message) -> str | None:
"""Attempt to create a CachedContent from system + first user message.
Returns the cache name on success, None on failure."""
try:
parts = [_block_to_part(b) for b in first_msg.content]
cache = await self._client.aio.caches.create(
model=self.model,
config=gtypes.CreateCachedContentConfig(
system_instruction=self._system,
contents=[gtypes.Content(role="user", parts=parts)],
ttl=_CACHE_TTL,
),
)
log.info(
"Gemini cache created (%s, model=%s, ttl=%s)",
cache.name, self.model, _CACHE_TTL,
)
return cache.name
except Exception as exc:
log.info(
"Gemini cache creation skipped (%s) — falling back to inline",
exc,
)
return None
async def complete(
self,
*,
messages: list[Message],
tools: list[ToolSchema] | None = None,
tool_choice: ToolChoice = "auto",
) -> Completion:
if not messages:
raise ValueError("Gemini complete() requires at least one message")
# First call: decide whether to cache
if not self._cache_attempted:
self._cache_attempted = True
if _is_first_user_message_fully_cacheable(messages):
self._cache_name = await self._try_create_cache(messages[0])
# Build per-call contents
if self._cache_name:
# Skip the cached first message — its contents are in the cache
contents = [_message_to_content(m) for m in messages[1:]]
else:
contents = [_message_to_content(m) for m in messages]
# Build config
config_kwargs: dict[str, Any] = {
"max_output_tokens": self._max_tokens,
}
if self._temperature is not None:
config_kwargs["temperature"] = self._temperature
if self._cache_name:
config_kwargs["cached_content"] = self._cache_name
else:
config_kwargs["system_instruction"] = self._system
if tools:
config_kwargs["tools"] = _tools_to_gemini(tools)
config_kwargs["tool_config"] = _tool_choice_to_config(tool_choice)
config = gtypes.GenerateContentConfig(**config_kwargs)
# When using cached_content, Gemini still requires non-empty contents.
# If the cached path leaves us with no per-call contents (only happens
# on the very first turn with a cached initial message), seed with a
# minimal continuation prompt.
if self._cache_name and not contents:
contents = [gtypes.Content(role="user", parts=[gtypes.Part(text="Continue.")])]
try:
resp = await self._client.aio.models.generate_content(
model=self.model,
contents=contents,
config=config,
)
except Exception as exc:
# Cache may have expired mid-loop — drop it and retry inline once
if self._cache_name and "cache" in str(exc).lower():
log.warning("Gemini cache failed (%s) — retrying inline", exc)
self._cache_name = None
return await self.complete(
messages=messages, tools=tools, tool_choice=tool_choice,
)
raise
return _from_gemini_response(resp)
async def close(self) -> None:
if self._cache_name:
try:
await self._client.aio.caches.delete(name=self._cache_name)
except Exception as exc:
log.warning("Gemini cache delete failed (%s): %s", self._cache_name, exc)
finally:
self._cache_name = None
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
class GeminiProvider(LLMProvider):
name = "gemini"
def __init__(self) -> None:
api_key = settings.gemini_api_key
if not api_key:
raise RuntimeError(
"GEMINI_API_KEY is not set. Either set it in .env or route "
"this stage to Anthropic via PROVIDER_<STAGE>=anthropic."
)
self._client = genai.Client(api_key=api_key)
async def create_session(
self,
*,
model: str,
system: str,
max_tokens: int = 4096,
temperature: float | None = None,
) -> LLMSession:
return GeminiSession(
client=self._client,
model=model,
system=system,
max_tokens=max_tokens,
temperature=temperature,
)
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]:
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."
)
+81
View File
@@ -0,0 +1,81 @@
"""Per-provider pricing tables and cost computation.
Replaces the flat ``PRICING`` dict that used to live in
``backend/services/api_logs.py``. Indexed by (provider, model).
"""
from __future__ import annotations
# Per-million-token USD rates. Source-of-truth links:
# Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
# Google: https://ai.google.dev/pricing
# Last updated: 2026-07-01
PRICING: dict[str, dict[str, dict[str, float]]] = {
"anthropic": {
"claude-opus-4-6": {"input": 5.00, "output": 25.00},
"claude-opus-4-5": {"input": 5.00, "output": 25.00},
"claude-opus-4-1": {"input": 15.00, "output": 75.00},
"claude-opus-4": {"input": 15.00, "output": 75.00},
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00},
# Sonnet 5 standard rate (== Sonnet 4.6). Introductory pricing of
# $2/$10 runs through 2026-08-31; intentionally NOT tracked here —
# chosen set-and-forget so no dated bump is needed on 2026-09-01.
# (New tokenizer emits ~30% more tokens, so per-run cost still rises.)
"claude-sonnet-5": {"input": 3.00, "output": 15.00},
"claude-sonnet-4-5": {"input": 3.00, "output": 15.00},
"claude-sonnet-4": {"input": 3.00, "output": 15.00},
"claude-haiku-4-5-20251001": {"input": 1.00, "output": 5.00},
"claude-haiku-4-5": {"input": 1.00, "output": 5.00},
"claude-haiku-3-5": {"input": 0.80, "output": 4.00},
"default": {"input": 3.00, "output": 15.00},
},
"gemini": {
# Gemini 3 Flash pricing (per 1M tokens). Preview alias mirrors GA.
"gemini-3-flash-preview": {"input": 0.30, "output": 2.50},
"gemini-3-flash": {"input": 0.30, "output": 2.50},
"gemini-flash-latest": {"input": 0.30, "output": 2.50},
"gemini-2.5-flash": {"input": 0.30, "output": 2.50},
"gemini-2.5-pro": {"input": 1.25, "output": 10.00},
# Gemini 3.1 Pro Preview — standard tier, prompts ≤200k tokens.
# Above 200k Google charges $4.00/$18.00; we don't yet split by
# prompt size, so we use the smaller-tier rate. Almost every
# pipeline call here is well under 200k.
"gemini-3.1-pro-preview": {"input": 2.00, "output": 12.00},
"gemini-3-pro-preview": {"input": 2.00, "output": 12.00},
"default": {"input": 0.30, "output": 2.50},
},
}
# Per-provider cache token multipliers, applied on top of the input rate.
# create: cost when a cache is *written* (Anthropic charges 1.25× input;
# Gemini charges 1.0× input — caching writes are billed as a
# normal input pass)
# read: cost when a cached prefix is *reused* (much cheaper)
CACHE_RATES: dict[str, dict[str, float]] = {
"anthropic": {"create": 1.25, "read": 0.10},
"gemini": {"create": 1.00, "read": 0.25},
}
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"]
rates = table.get(entry.get("model", ""), table["default"])
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
input_rate = rates["input"]
output_rate = rates["output"]
return (
entry.get("input_tokens", 0) * input_rate
+ entry.get("cache_creation_input_tokens", 0) * input_rate * cache_rates["create"]
+ entry.get("cache_read_input_tokens", 0) * input_rate * cache_rates["read"]
+ entry.get("output_tokens", 0) * output_rate
) / 1_000_000
def total_cost(entries: list[dict]) -> float:
"""Sum USD across entries."""
return round(sum(cost_for_entry(e) for e in entries), 6)
+116
View File
@@ -0,0 +1,116 @@
"""Provider-agnostic message and completion types.
These dataclasses are the lingua franca between calling code and providers.
Each provider implementation translates these into its native shape on the
way out and back into these on the way in.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
# ---------------------------------------------------------------------------
# Content blocks — what goes inside a Message
# ---------------------------------------------------------------------------
@dataclass
class TextBlock:
text: str
cacheable: bool = False
# Gemini 3 / thinking-mode: opaque bytes the model returns alongside text
# parts that came from internal reasoning. Must be replayed verbatim when
# this turn is fed back into the conversation, or the next call 400s.
# Anthropic: always None.
thought_signature: bytes | None = None
@dataclass
class PdfBlock:
"""Inline PDF document. Provider encodes as base64 (Anthropic) or
inline_data (Gemini) and applies caching policy if cacheable=True."""
path: Path
cacheable: bool = False
@dataclass
class ToolCall:
"""Assistant turn: model called a tool."""
id: str
name: str
input: dict[str, Any]
# 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
@dataclass
class ToolResultBlock:
"""User turn: result fed back from a tool the model invoked previously."""
tool_use_id: str
name: str
content: str
ContentBlock = TextBlock | PdfBlock | ToolCall | ToolResultBlock
@dataclass
class Message:
role: Literal["user", "assistant"]
content: list[ContentBlock]
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@dataclass
class ToolSchema:
"""JSON-schema tool definition. Both providers accept the same shape."""
name: str
description: str
input_schema: dict[str, Any]
# Tool choice: "auto" (model picks), "none" (no tools), or a forced name
ToolChoice = Literal["auto", "none"] | dict # {"name": "save_xyz"}
# ---------------------------------------------------------------------------
# Completion / usage
# ---------------------------------------------------------------------------
@dataclass
class Usage:
"""Token usage normalised across providers.
Anthropic exposes cache_creation_input_tokens (write) and
cache_read_input_tokens (hit). Gemini only exposes a cache hit count
(cached_content_token_count) — its cache writes don't bill as input.
For Gemini, ``cache_creation_tokens`` is always 0; ``cache_read_tokens``
holds the cached hit count when a cache was used.
"""
input_tokens: int = 0
output_tokens: int = 0
cache_creation_tokens: int = 0
cache_read_tokens: int = 0
@dataclass
class Completion:
"""Result of a single provider.complete() / session.complete() call."""
text: str # any text block(s) concatenated
tool_calls: list[ToolCall]
usage: Usage
stop_reason: str
raw_assistant_blocks: list[ContentBlock] = field(default_factory=list)
"""The full assistant message, in our normalised content-block form, so
callers can append it back to the conversation history when continuing
the loop."""
+565
View File
@@ -0,0 +1,565 @@
"""Per-IC normalize pass — dedup findings with a shared root cause and
re-grade severity against a fixed rubric.
Two runs of the reviewer on identical inputs can produce different *judgments*
(severity choices, finding-splitting) even when they reach the same underlying
observations. This module runs a single small LLM call against the structured
findings (no PDF, no graph tools) to:
- merge findings that describe the same defect from different angles, and
- re-grade each remaining finding's severity using an anchored rubric.
It is intentionally conservative: if the call fails, the schema is malformed,
or the index coverage is invalid, the original findings are returned
unchanged. A normalize failure must never break the per-IC review.
"""
from __future__ import annotations
import json
import logging
import time
from datetime import datetime, timezone
from typing import Awaitable, Callable
from backend.config import settings
from backend.pinscopex.models import Finding
from backend.services.api_logs import ApiLogger
from backend.services.llm import Message, TextBlock
from backend.services.llm.factory import call_with_fallback
from backend.services.llm.types import ToolSchema
log = logging.getLogger(__name__)
# Severity ordering for the downgrade-only clamp. Normalize may lower a
# finding's severity but never raise it above what the reviewer chose — the
# reviewer had the datasheet + graph; this pass sees only text.
_INFO, _WARN, _ERR = 0, 1, 2
_SEVERITY_RANK = {"INFO": _INFO, "WARNING": _WARN, "ERROR": _ERR}
_RANK_TO_SEV = {_INFO: "INFO", _WARN: "WARNING", _ERR: "ERROR"}
def _is_unverified(why: str | None) -> bool:
"""True when a finding's ``why`` is flagged ``Unverified:`` — the reviewer
could not confirm the spec from the datasheet and deliberately hedged."""
return (why or "").lstrip().lower().startswith("unverified:")
SYSTEM_PROMPT = """\
You normalize a single IC's review findings for a hardware design review tool.
Three operations:
1. **Drop** self-cancelling findings whose own analysis confirms the \
design is correct.
2. **Merge** findings that share a single-fix root cause (atomic-fix test).
3. **Re-grade severity** independently against the rubric below.
You CANNOT invent new findings or new facts. Every original finding \
(numbered 1..N) must end up in exactly one of:
- a kept/merged entry in `findings` (referenced by `merged_from`), or
- a dropped entry in `dropped` (referenced by `index`).
You ARE shown the reviewer's original severity. The reviewer graded each \
finding with the datasheet PDF and the design graph in front of it; you \
see only the finding text. You may **lower** a severity when the rubric \
clearly supports a milder grade — over-stated, conditional, or the \
`why` itself flags incomplete evidence — but you must **never raise** a \
finding above the reviewer's grade. Upgrading is where you have the \
least evidence and do the most damage: a normalize pass that promotes a \
hedged WARNING into a confident ERROR is the exact failure this rule \
exists to prevent.
### Drop rule (self-cancelling findings)
A finding is self-cancelling when its own `why` confirms the requirement \
is met or no issue actually exists. The surface reading suggested a \
problem; the analysis itself proved otherwise. Examples:
- "Output cap C1 (100 nF) is below the 1 µF minimum, but C24 (1 µF) in \
parallel satisfies the spec." → drop. Total Cout meets spec; no issue.
- "No dedicated input decoupling cap directly at VIN — but C3 (1 µF) is \
on the VIN net and satisfies the requirement." → drop. C3 IS the input \
cap, in the correct place.
- "Pin X appears unconnected, however net Y shows it is grounded." → drop.
Drop these via the `dropped` array with a short `reason`. Do NOT keep \
them as INFO — they dilute the signal of real issues. If the `why` \
contains "satisfies", "meets the requirement", "is in the correct \
place", "no issue", or equivalent language confirming the design is \
correct, the finding is almost certainly self-cancelling.
A finding that flags a real concern but acknowledges *partial* \
mitigation or *conditional* validity ("works at low load only", "meets \
spec only at room temperature") is NOT self-cancelling — keep it.
### Root-cause merge rule (atomic-fix test)
Two findings share a root cause if a SINGLE atomic change resolves both. \
The atomic-fix test: can you describe the fix in `single_fix` as ONE \
action — remove X, replace X with Y, rewire X to Z, or add X — without \
using "and", "also", or describing multiple steps?
If yes: merge. Write the combined `finding` title naming the root cause \
once. Restate downstream consequences inside `why`. Keep `source_page`, \
`source_quote`, and `reference` from the original with the strongest \
evidence.
If no: do NOT merge. Two defects involving the same component, the same \
net, or the same fix-area are still separate root causes when they \
require separate changes.
**Invalid merge example**: combining "R1 (17.8Ω) in series with VIN \
causes dropout" with "EN tied to VIN — no independent enable" into a \
single ERROR with `single_fix` = "Remove R1 AND route EN from a \
separate GPIO." That is TWO changes (remove R1; rewire EN). Keep these \
as two separate findings — the dropout finding alone may be ERROR or \
WARNING; the EN finding is INFO.
When you merge, you MUST populate `single_fix` with the one atomic \
action. If you cannot, do not merge.
### Severity rubric (grade independently)
- **ERROR**: The circuit, as wired, will not function correctly. The \
output won't reach spec, the regulator won't regulate, the signal \
won't reach the destination, abs-max is exceeded with a strict \
inequality (actual > limit), or a required pin is left undriven. A \
concrete failure mode is reachable from the design as drawn.
- **WARNING**: The circuit functions but has reduced margin, degraded \
performance, or conditional malfunction (depends on load, \
temperature, or firmware state). A recommended-but-not-required \
component is missing. The finding is "unverified" because evidence \
was incomplete.
- **INFO**: A valid topology choice that disables an optional \
feature, or a documentation/layout observation that cannot be \
verified from a netlist. Examples: EN tied to VIN to use the LDO's \
always-on mode (firmware shutdown unavailable but the chip works), \
an optional bypass cap omitted on a non-critical pin.
Grade each kept finding against this rubric, but only ever *downward* \
from the reviewer's original severity (shown to you). A merged \
finding's severity may not exceed the highest original severity among \
its members. If a finding's `why` begins with `Unverified:`, the \
reviewer could not confirm the spec from the datasheet — keep the \
`Unverified:` prefix and never grade it above WARNING.
### Output
Call the `submit_normalized` tool exactly once with:
- `findings`: kept and merged entries (each with `merged_from` indices, \
`single_fix` if merged, and a re-graded `status`).
- `dropped`: self-cancelling entries (each with `index` and `reason`).
Every original index 1..N must appear in exactly one location across \
both arrays. No index may appear twice.
"""
SUBMIT_NORMALIZED_SCHEMA = ToolSchema(
name="submit_normalized",
description=(
"Submit the normalized findings. Every original finding (1..N) "
"must appear in exactly one location across `findings.merged_from` "
"or `dropped.index`."
),
input_schema={
"type": "object",
"properties": {
"findings": {
"type": "array",
"description": (
"Kept and merged findings. Re-graded severity; merged "
"entries must include `single_fix`."
),
"items": {
"type": "object",
"properties": {
"merged_from": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"description": (
"1-indexed positions in the original "
"findings list this output entry "
"represents. Length 1 = passed through; "
"length > 1 = merged."
),
},
"finding": {"type": "string"},
"why": {"type": "string"},
"status": {
"type": "string",
"enum": ["ERROR", "WARNING", "INFO"],
},
"recommendation": {"type": "string"},
"source_page": {"type": ["integer", "null"]},
"source_quote": {"type": "string"},
"reference": {"type": "string"},
"single_fix": {
"type": "string",
"description": (
"REQUIRED when merged_from has length > 1. "
"The single atomic component or net change "
"that resolves ALL members of the merge "
"(remove X, replace X with Y, rewire X to "
"Z, or add X). If you cannot write the fix "
"in one sentence without 'and' / 'also' / "
"multiple steps, do NOT merge."
),
},
"change_rationale": {
"type": "string",
"description": (
"≤1 line: 'unchanged', or what changed "
"and why (merged X+Y, graded <sev> per "
"rubric because <reason>, ...)."
),
},
},
"required": [
"merged_from",
"finding",
"why",
"status",
"recommendation",
"change_rationale",
],
},
},
"dropped": {
"type": "array",
"description": (
"Self-cancelling findings whose own `why` confirms "
"the design is correct. These should NOT appear in "
"`findings` — they are removed entirely from the "
"report. Use this rather than demoting to INFO."
),
"items": {
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": (
"1-indexed position of the original "
"finding being dropped."
),
},
"reason": {
"type": "string",
"description": (
"Short explanation of why the finding is "
"self-cancelling (e.g., 'C1<1µF but C24 "
"in parallel meets spec', 'C3 is the "
"input cap, already in the correct "
"place')."
),
},
},
"required": ["index", "reason"],
},
},
},
"required": ["findings"],
},
)
def _serialize_findings_for_prompt(findings: list[Finding]) -> str:
"""Number the original findings 1..N and emit a compact JSON block.
The reviewer's `status` IS included: normalize re-grades only *downward*
from it (the reviewer had the datasheet + graph; this pass sees only
text). A deterministic clamp in ``_build_normalized`` enforces the
downgrade-only invariant regardless of what the model returns.
"""
rows: list[dict] = []
for i, f in enumerate(findings, start=1):
rows.append({
"index": i,
"reviewer_severity": f.status,
"finding": f.finding,
"why": f.why,
"recommendation": f.recommendation,
"source_page": f.source_page,
"source_quote": f.source_quote,
"reference": f.reference,
})
return json.dumps(rows, indent=2)
def _build_normalized(
raw_findings: list[dict],
raw_dropped: list[dict],
originals: list[Finding],
) -> tuple[list[Finding], list[dict]] | None:
"""Validate the tool output and reconstruct Finding objects.
Returns ``(kept_findings, dropped_records)`` or ``None`` if coverage /
schema validation fails (caller falls back to originals).
A merge with ``len(merged_from) > 1`` that omits ``single_fix`` is not
a hard failure — the merge is rejected and its members fall back to
their per-index originals. Self-cancelling drops require a non-empty
`reason`; missing reason = treat as ungrouped and fail coverage.
The `change_rationale` and `single_fix` fields are informational and
are not carried onto Finding objects; the full normalize trace keeps
them for forensics.
"""
n = len(originals)
seen: set[int] = set()
result: list[Finding] = []
dropped_records: list[dict] = []
# Process explicit drops first so indices are reserved before any
# accidental double-coverage from a merge.
for d in raw_dropped or []:
if not isinstance(d, dict):
return None
try:
idx = int(d.get("index"))
except (TypeError, ValueError):
return None
if idx < 1 or idx > n or idx in seen:
return None
reason = str(d.get("reason") or "").strip()
if not reason:
return None
seen.add(idx)
dropped_records.append({
"index": idx,
"reason": reason,
"original_finding": originals[idx - 1].model_dump(mode="json"),
})
for entry in raw_findings:
if not isinstance(entry, dict):
return None
merged_from = entry.get("merged_from") or []
if not isinstance(merged_from, list) or not merged_from:
return None
try:
indices = [int(x) for x in merged_from]
except (TypeError, ValueError):
return None
for idx in indices:
if idx < 1 or idx > n or idx in seen:
return None
seen.add(idx)
# Atomic-fix test: a merge (len > 1) must populate `single_fix`.
# If missing, reject the merge and fall back to the per-index
# originals — preserves coverage but un-merges. The reviewer's
# original severity is preserved on the fallback path because we
# construct each Finding directly from `originals[i-1]`.
if len(indices) > 1:
single_fix = str(entry.get("single_fix") or "").strip()
if not single_fix:
log.warning(
"normalize: merge of %s lacks single_fix — falling "
"back to per-index originals (un-merging)",
indices,
)
for idx in indices:
result.append(originals[idx - 1])
continue
# Use the first original in the group as the canonical source for
# fields the normalize layer doesn't own (designator, mpn, aspect,
# finding_id). These are identical across an IC's findings anyway
# since normalize is per-IC.
canon = originals[indices[0] - 1]
# Severity safety net — downgrade-only. Normalize may lower a
# finding's severity but never raise it above the reviewer's
# calibrated grade (the reviewer had the datasheet + graph; this
# pass sees only text). Cap at the highest original severity among
# merged members; findings the reviewer marked "Unverified:" are
# capped at WARNING and keep that prefix. This deterministic clamp
# holds even when the model ignores the prompt instruction.
members = [originals[i - 1] for i in indices]
ceiling = max(_SEVERITY_RANK.get(m.status, _ERR) for m in members)
unverified = any(_is_unverified(m.why) for m in members)
if unverified:
ceiling = min(ceiling, _WARN)
proposed = str(entry.get("status") or canon.status)
final_status = _RANK_TO_SEV[
min(_SEVERITY_RANK.get(proposed, ceiling), ceiling)
]
new_why = str(entry.get("why") or canon.why)
if unverified and not _is_unverified(new_why):
new_why = "Unverified: " + new_why
try:
result.append(Finding(
finding_id=canon.finding_id,
designator=canon.designator,
mpn=canon.mpn,
aspect=canon.aspect,
finding=str(entry.get("finding") or canon.finding),
why=new_why,
source_page=entry.get("source_page", canon.source_page),
source_quote=str(entry.get("source_quote") or canon.source_quote),
source_designator=canon.source_designator,
status=final_status,
recommendation=str(entry.get("recommendation") or canon.recommendation),
reference=str(entry.get("reference") or canon.reference),
))
except Exception:
log.exception("normalize: failed to build merged Finding")
return None
if seen != set(range(1, n + 1)):
return None
return result, dropped_records
async def normalize_findings_async(
ic_ref: str,
mpn: str,
findings: list[Finding],
*,
api_logger: ApiLogger | None = None,
on_progress: Callable[[str, int, str, str], Awaitable[None]] | None = None,
) -> tuple[list[Finding], dict]:
"""Run the per-IC normalize pass.
Returns ``(normalized_findings, trace)``. On any failure (LLM error,
schema violation, index coverage gap), returns the original findings
unchanged with an ``error`` field set in the trace.
"""
trace: dict = {
"ic_ref": ic_ref,
"mpn": mpn,
"timestamp": datetime.now(timezone.utc).isoformat(),
"input_findings": [f.model_dump(mode="json") for f in findings],
"output_findings": None,
"dropped_findings": None,
"submission": None,
"model": None,
"provider": None,
"duration_ms": None,
"error": None,
}
# Nothing to do for 0 findings. With 1 finding there is no merge to
# consider but the drop and re-grade rules still apply — let it
# through to the LLM call.
if not findings:
trace["output_findings"] = []
trace["dropped_findings"] = []
trace["error"] = "skipped: 0 findings"
return findings, trace
user_text = (
f"Original findings for IC {ic_ref} ({mpn}). "
f"There are {len(findings)} findings. "
f"Indices are 1-based.\n\n"
f"{_serialize_findings_for_prompt(findings)}\n\n"
f"Normalize them per the rubric and call submit_normalized."
)
t0 = time.monotonic()
async def _run(provider, model):
trace["model"] = model
trace["provider"] = provider.name
session = await provider.create_session(
model=model,
system=SYSTEM_PROMPT,
max_tokens=4096,
temperature=0.0,
)
try:
completion = await session.complete(
messages=[Message(
role="user",
content=[TextBlock(text=user_text, cacheable=False)],
)],
tools=[SUBMIT_NORMALIZED_SCHEMA],
tool_choice={"name": "submit_normalized"},
)
if api_logger:
api_logger.log(
stage="normalize",
identifier=ic_ref,
model=model,
provider=provider.name,
input_tokens=completion.usage.input_tokens,
output_tokens=completion.usage.output_tokens,
cache_creation_input_tokens=completion.usage.cache_creation_tokens,
cache_read_input_tokens=completion.usage.cache_read_tokens,
duration_ms=int((time.monotonic() - t0) * 1000),
stop_reason="submit_normalized",
turns=1,
)
for tc in completion.tool_calls:
if tc.name == "submit_normalized":
return tc.input
return None
finally:
await session.close()
try:
submission = await call_with_fallback("normalize", _run)
except Exception as exc:
log.exception("normalize: call failed for %s", ic_ref)
trace["error"] = f"{type(exc).__name__}: {exc}"
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
trace["output_findings"] = trace["input_findings"]
return findings, trace
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
trace["submission"] = submission
if not submission or not isinstance(submission, dict):
trace["error"] = "no submission"
trace["output_findings"] = trace["input_findings"]
return findings, trace
raw_findings = submission.get("findings") or []
if not isinstance(raw_findings, list):
trace["error"] = "submission.findings not a list"
trace["output_findings"] = trace["input_findings"]
return findings, trace
raw_dropped = submission.get("dropped") or []
if not isinstance(raw_dropped, list):
trace["error"] = "submission.dropped not a list"
trace["output_findings"] = trace["input_findings"]
return findings, trace
built = _build_normalized(raw_findings, raw_dropped, findings)
if built is None:
trace["error"] = "invalid index coverage or schema"
trace["output_findings"] = trace["input_findings"]
log.warning(
"normalize: invalid output for %s (%d originals, %d kept, "
"%d dropped) — falling back to originals",
ic_ref, len(findings), len(raw_findings), len(raw_dropped),
)
if on_progress:
try:
await on_progress(
ic_ref, 0, "normalize_skipped",
f"invalid output, kept {len(findings)} originals",
)
except Exception:
pass
return findings, trace
normalized, dropped_records = built
trace["output_findings"] = [f.model_dump(mode="json") for f in normalized]
trace["dropped_findings"] = dropped_records
if on_progress:
try:
await on_progress(
ic_ref, 0, "normalized",
f"{len(findings)}{len(normalized)} kept, "
f"{len(dropped_records)} dropped",
)
except Exception:
pass
return normalized, trace
File diff suppressed because it is too large Load Diff
+802
View File
@@ -0,0 +1,802 @@
"""Project storage via StorageBackend.
Each project lives at users/{user_id}/projects/{id}/ with:
project.json — metadata
uploads/bom.csv — uploaded BOM
uploads/netlist.asc — uploaded netlist
uploads/datasheets/*.pdf — uploaded datasheets
extracted/ — IC extraction output
patterns/ — passive patterns
models/ — cached component specs
design_graph.json — graph output
report.json — validation report
Library (global, shared across users):
library/extracted/{mpn}.json
library/patterns/{mfr}_{type}.json
library/datasheets/{mpn}.pdf
library/models/{mpn}.json — discrete/connector/crystal specs
library/passives/{mpn}.json — DigiKey-resolved passive specs
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel
from backend.pinscopex.utils import safe_mpn
from backend.services.storage import StaleGeneration, StorageBackend
class ProjectNotFound(Exception):
"""An operation targeted a project whose metadata is gone.
Raised when ``project.json`` is missing — e.g. the project was deleted
while a slow request (a large BOM upload) was still in flight. Callers /
the global handler map this to a clean 404 instead of letting the raw
storage NotFound bubble up as a 500 (which tears down the HTTP/2 stream
mid-upload and surfaces in the browser as ERR_HTTP2_PROTOCOL_ERROR).
"""
def __init__(self, project_id: str):
self.project_id = project_id
super().__init__(f"Project {project_id} not found")
# Statuses
STATUS_DRAFT = "draft"
STATUS_QUEUED = "queued"
STATUS_RUNNING = "running"
STATUS_COMPLETE = "complete"
STATUS_ERROR = "error"
STATUS_CANCELLED = "cancelled"
STATUS_PAUSED = "paused_insufficient_credits"
TERMINAL_STATUSES = frozenset({
STATUS_COMPLETE, STATUS_ERROR, STATUS_CANCELLED, STATUS_PAUSED,
})
class StatusConflict(Exception):
"""Raised when a status transition's preconditions don't hold.
Either the current status is not in ``from_status`` or another writer
won the optimistic-concurrency race.
"""
class ProjectMeta(BaseModel):
id: str
name: str
user_id: str = ""
# draft | running | complete | error | cancelled
# | paused_insufficient_credits | paused_by_user
status: str = "draft"
created: str = ""
updated: str = ""
has_bom: bool = False
has_netlist: bool = False
# "pads" | "edif" | None — None for legacy projects (pre-EDIF-support).
# Legacy reads fall back to looking for netlist.asc on disk.
netlist_format: str | None = None
# When the EDIF file contains 2+ sub-designs, this is the list of
# sub-design IDs (e.g. ["&0441"]) the user picked. None means "include
# everything found in the file" — also the value when the netlist has a
# single sub-design and no choice was offered.
netlist_subdesigns: list[str] | None = None
datasheet_count: int = 0
summary: dict[str, int] | None = None
component_mpns: dict[str, list[str]] | None = None # {ic: [...], passive: [...]}
bom_columns: dict[str, str] | None = None # {reference: "...", mpn: "..."}
# LCSC id → resolved manufacturer part number, populated by upload_bom when
# the MPN column is detected as entirely LCSC ids (^C\d+$). The wizard UI
# uses this to show "C12044 → STM32F103C8T6" alongside each row.
lcsc_to_mpn: dict[str, str] | None = None
# LCSC id → full purple-parts payload (mpn, manufacturer, package, description,
# category, subcategory). Cached at upload time so the wizard's
# /lcsc/resolve-passive endpoint can synthesize an auto-resolve call without
# a second purple-parts round trip.
lcsc_payloads: dict[str, dict] | None = None
skipped_components: list[dict[str, str]] | None = None # [{identifier, stage, error}]
pipeline_state: dict[str, Any] | None = None
total_cost_usd: float | None = None
collaborators: list[str] = [] # Clerk user_ids with access to this project
tier: str = "demo" # user tier at project creation; "demo" default for pre-existing projects
# Credit-system fields
credits_spent: float = 0.0
estimate: dict[str, Any] | None = None # CostEstimate snapshot
pause_checkpoint: dict[str, Any] | None = None # PauseCheckpoint on paused runs
pause_reason: str | None = None
completed_review_refs: list[str] = [] # IC refs already reviewed (persists across pauses)
# Pinscope app version that generated the project's report.
# Stamped on the first /start transition and preserved thereafter.
pinscope_version: str | None = None
# Worker bookkeeping (set by the API on enqueue, read by /events SSE
# and by the stale-running sweeper).
execution_name: str | None = None
queued_at: str | None = None
# User-initiated cancel signal — the worker reads this in its cancel
# gate (inside _charge_for_logs) and exits cleanly.
cancel_requested: bool = False
def _project_prefix(user_id: str, project_id: str) -> str:
return f"users/{user_id}/projects/{project_id}"
def _meta_key(user_id: str, project_id: str) -> str:
return f"{_project_prefix(user_id, project_id)}/project.json"
def _read_meta(storage: StorageBackend, user_id: str, project_id: str) -> ProjectMeta:
data = storage.read_json(_meta_key(user_id, project_id))
return ProjectMeta.model_validate(data)
def _read_meta_with_generation(
storage: StorageBackend, user_id: str, project_id: str
) -> tuple[ProjectMeta, int]:
data, gen = storage.read_json_with_generation(_meta_key(user_id, project_id))
return ProjectMeta.model_validate(data), gen
def _write_meta(storage: StorageBackend, meta: ProjectMeta) -> None:
meta.updated = datetime.now(timezone.utc).isoformat()
storage.write_json(
_meta_key(meta.user_id, meta.id),
meta.model_dump(),
)
def transition_status(
storage: StorageBackend,
user_id: str,
project_id: str,
*,
from_status: str | set[str] | frozenset[str],
to_status: str,
**fields: Any,
) -> ProjectMeta:
"""Move a project from ``from_status`` → ``to_status`` atomically.
Reads the meta with its GCS generation, refuses the write if the
current status isn't in ``from_status``, then issues a conditional
write that fails if another writer raced in. Retries up to a few
times on generation mismatch caused by unrelated field updates.
Raises :class:`StatusConflict` when the current status doesn't match.
"""
allowed: frozenset[str]
if isinstance(from_status, str):
allowed = frozenset({from_status})
else:
allowed = frozenset(from_status)
last_exc: Exception | None = None
for _ in range(5):
meta, gen = _read_meta_with_generation(storage, user_id, project_id)
if meta.status not in allowed:
raise StatusConflict(
f"project {project_id} is in status {meta.status!r}; "
f"expected one of {sorted(allowed)} for transition to {to_status!r}"
)
meta.status = to_status
for k, v in fields.items():
setattr(meta, k, v)
meta.updated = datetime.now(timezone.utc).isoformat()
try:
storage.write_json_if_match(
_meta_key(user_id, project_id), meta.model_dump(), gen,
)
return meta
except StaleGeneration as exc:
last_exc = exc
continue
raise StatusConflict(
f"project {project_id}: lost optimistic-concurrency race after retries"
) from last_exc
def request_cancel(
storage: StorageBackend, user_id: str, project_id: str
) -> ProjectMeta:
"""Set ``cancel_requested = True`` so the worker's cancel gate trips.
Does not touch ``status`` — the worker is responsible for moving the
project to ``cancelled`` when it observes the flag.
"""
return update_project(
storage, user_id, project_id, cancel_requested=True,
)
def mark_stale_running(
storage: StorageBackend, user_id: str, project_id: str, error: str,
) -> ProjectMeta | None:
"""Flip a stale ``running`` project to ``error``. No-op otherwise.
Returns the updated meta on success; ``None`` if the project's status
was already terminal or the project no longer exists.
"""
try:
return transition_status(
storage, user_id, project_id,
from_status={STATUS_RUNNING, STATUS_QUEUED},
to_status=STATUS_ERROR,
pipeline_state={"error": error},
cancel_requested=False,
)
except StatusConflict:
return None
# --- CRUD ---
def create_project(storage: StorageBackend, user_id: str, name: str) -> ProjectMeta:
project_id = uuid.uuid4().hex[:12]
meta = ProjectMeta(
id=project_id,
name=name,
user_id=user_id,
created=datetime.now(timezone.utc).isoformat(),
)
_write_meta(storage, meta)
return meta
def list_projects(storage: StorageBackend, user_id: str) -> list[ProjectMeta]:
prefix = f"users/{user_id}/projects/"
projects: list[ProjectMeta] = []
for entry in storage.list_prefix(prefix):
# entry is like users/{uid}/projects/{pid} (a directory)
# or users/{uid}/projects/{pid}/project.json (a file)
meta_key = f"{entry}/project.json" if not entry.endswith("/project.json") else entry
if storage.exists(meta_key):
data = storage.read_json(meta_key)
projects.append(ProjectMeta.model_validate(data))
return projects
def get_project(
storage: StorageBackend, user_id: str, project_id: str
) -> ProjectMeta | None:
key = _meta_key(user_id, project_id)
if not storage.exists(key):
return None
return _read_meta(storage, user_id, project_id)
def update_project(
storage: StorageBackend, user_id: str, project_id: str, **fields: Any
) -> ProjectMeta:
if not storage.exists(_meta_key(user_id, project_id)):
raise ProjectNotFound(project_id)
meta = _read_meta(storage, user_id, project_id)
for k, v in fields.items():
setattr(meta, k, v)
_write_meta(storage, meta)
return meta
def delete_project(
storage: StorageBackend, user_id: str, project_id: str
) -> bool:
key = _meta_key(user_id, project_id)
if not storage.exists(key):
return False
# Clean up shared references for all collaborators before deleting
meta = _read_meta(storage, user_id, project_id)
for collab_id in meta.collaborators:
ref_key = _shared_ref_key(collab_id, project_id)
if storage.exists(ref_key):
storage.delete_key(ref_key)
storage.delete_prefix(_project_prefix(user_id, project_id))
return True
def clear_project_extractions(
storage: StorageBackend, user_id: str, project_id: str
) -> None:
"""Delete per-project extraction JSONs and derived artifacts.
Clears extracted/, patterns/, and models/ plus derived files (graph,
power tree, BOM summary, derating, report, API logs) so the next
pipeline run starts from fresh per-project data. The global library
(library/*) is untouched — shared entries remain reusable.
Meta fields tied to the prior run (summary, skipped list, review
checkpoint, error state) are reset; historical spend fields
(total_cost_usd, credits_spent) are preserved.
"""
prefix = _project_prefix(user_id, project_id)
for subdir in ("extracted", "patterns", "models"):
storage.delete_prefix(f"{prefix}/{subdir}")
for name in (
"design_graph.json",
"bom_summary.json",
"derating.json",
"report.json",
"api_logs.jsonl",
"graph_voltage_updates.json",
):
key = f"{prefix}/{name}"
if storage.exists(key):
storage.delete_key(key)
update_project(
storage, user_id, project_id,
summary=None,
skipped_components=None,
pipeline_state=None,
pause_checkpoint=None,
pause_reason=None,
completed_review_refs=[],
)
def reopen_project(
storage: StorageBackend, user_id: str, project_id: str
) -> ProjectMeta:
"""Reset a finished/cancelled/errored project back to a draft-like state.
Clears derived artifacts (graph, report, etc.) and the pause/review
bookkeeping so the next pipeline run starts fresh, but preserves uploads,
column mappings, and the extraction cache so the rerun reuses prior work
cheaply.
"""
prefix = _project_prefix(user_id, project_id)
for name in (
"design_graph.json",
"bom_summary.json",
"derating.json",
"report.json",
"api_logs.jsonl",
"graph_voltage_updates.json",
):
key = f"{prefix}/{name}"
if storage.exists(key):
storage.delete_key(key)
return update_project(
storage, user_id, project_id,
status="draft",
summary=None,
skipped_components=None,
pipeline_state=None,
pause_checkpoint=None,
pause_reason=None,
completed_review_refs=[],
)
def list_project_datasheets(
storage: StorageBackend, user_id: str, project_id: str
) -> list[str]:
"""Return the safe-MPN stems of datasheet PDFs stored for a project."""
ds_prefix = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/"
stems: list[str] = []
for key in storage.list_prefix(ds_prefix):
if key.endswith(".pdf"):
stems.append(key.rsplit("/", 1)[-1][:-4])
return stems
# --- Collaborator access resolution ---
def _shared_ref_key(user_id: str, project_id: str) -> str:
return f"users/{user_id}/shared/{project_id}.json"
def resolve_project_access(
storage: StorageBackend, caller_user_id: str, project_id: str
) -> tuple[str, ProjectMeta] | None:
"""Resolve project access for a user — checks ownership then collaborator refs.
Returns (owner_user_id, ProjectMeta) or None if no access.
"""
# 1. Direct ownership
meta = get_project(storage, caller_user_id, project_id)
if meta is not None:
return (caller_user_id, meta)
# 2. Shared reference
ref_key = _shared_ref_key(caller_user_id, project_id)
if not storage.exists(ref_key):
return None
ref = storage.read_json(ref_key)
owner_id = ref.get("owner_user_id")
if not owner_id:
return None
meta = get_project(storage, owner_id, project_id)
if meta is None:
return None
# Verify caller is still in collaborators list
if caller_user_id not in meta.collaborators:
# Stale reference — clean up
storage.delete_key(ref_key)
return None
return (owner_id, meta)
def find_project_any_user(
storage: StorageBackend, project_id: str
) -> tuple[str, ProjectMeta] | None:
"""Scan all users to find a project by ID (for admin access).
Returns (owner_user_id, ProjectMeta) or None.
"""
seen_uids: set[str] = set()
for entry in storage.list_prefix("users/"):
parts = entry.split("/")
if len(parts) >= 2:
uid = parts[1]
if uid in seen_uids:
continue
seen_uids.add(uid)
meta = get_project(storage, uid, project_id)
if meta is not None:
return (uid, meta)
return None
def add_collaborator(
storage: StorageBackend, owner_user_id: str, project_id: str, collaborator_user_id: str
) -> ProjectMeta:
"""Add a collaborator to a project and write a shared reference."""
meta = _read_meta(storage, owner_user_id, project_id)
if collaborator_user_id not in meta.collaborators:
meta.collaborators.append(collaborator_user_id)
_write_meta(storage, meta)
# Write reverse reference for the collaborator
ref_key = _shared_ref_key(collaborator_user_id, project_id)
storage.write_json(ref_key, {"owner_user_id": owner_user_id})
return meta
def remove_collaborator(
storage: StorageBackend, owner_user_id: str, project_id: str, collaborator_user_id: str
) -> ProjectMeta:
"""Remove a collaborator from a project and delete the shared reference."""
meta = _read_meta(storage, owner_user_id, project_id)
meta.collaborators = [c for c in meta.collaborators if c != collaborator_user_id]
_write_meta(storage, meta)
# Delete reverse reference
ref_key = _shared_ref_key(collaborator_user_id, project_id)
if storage.exists(ref_key):
storage.delete_key(ref_key)
return meta
def transfer_ownership(
storage: StorageBackend,
current_owner_user_id: str,
project_id: str,
new_owner_user_id: str,
) -> ProjectMeta:
"""Make an existing collaborator the new owner of a project.
Swaps roles: ``new_owner_user_id`` becomes the owner, the previous owner
is appended to ``collaborators``. All project files are physically moved
from ``users/{old}/projects/{id}/`` to ``users/{new}/projects/{id}/`` so
that the storage layout (which keys off the owner) stays consistent.
Shared references are rewritten — the new owner's ref is deleted, the
old owner gets one, and every remaining collaborator's ref is repointed
at the new owner.
Raises ``ValueError`` if the target is already the owner or is not a
current collaborator.
"""
meta = _read_meta(storage, current_owner_user_id, project_id)
if new_owner_user_id == current_owner_user_id:
raise ValueError("target user is already the owner")
if new_owner_user_id not in meta.collaborators:
raise ValueError("target user must currently be a collaborator")
new_collaborators = [c for c in meta.collaborators if c != new_owner_user_id]
if current_owner_user_id not in new_collaborators:
new_collaborators.append(current_owner_user_id)
meta.user_id = new_owner_user_id
meta.collaborators = new_collaborators
meta.updated = datetime.now(timezone.utc).isoformat()
old_prefix = _project_prefix(current_owner_user_id, project_id)
new_prefix = _project_prefix(new_owner_user_id, project_id)
new_meta_key = _meta_key(new_owner_user_id, project_id)
# Copy every file under the old prefix to the corresponding new key.
# The old project.json is copied too — we overwrite it below with the
# refreshed meta so the new location is authoritative even if a partial
# failure leaves the old prefix in place.
for old_key in storage.list_recursive(old_prefix):
rel = old_key[len(old_prefix):].lstrip("/")
storage.copy_object(old_key, f"{new_prefix}/{rel}")
storage.write_json(new_meta_key, meta.model_dump())
storage.delete_prefix(old_prefix)
# Reverse references: new owner no longer needs one; old owner now does;
# every other collaborator's existing ref must point at the new owner.
new_owner_ref = _shared_ref_key(new_owner_user_id, project_id)
if storage.exists(new_owner_ref):
storage.delete_key(new_owner_ref)
storage.write_json(
_shared_ref_key(current_owner_user_id, project_id),
{"owner_user_id": new_owner_user_id},
)
for collab_id in new_collaborators:
if collab_id == current_owner_user_id:
continue
storage.write_json(
_shared_ref_key(collab_id, project_id),
{"owner_user_id": new_owner_user_id},
)
return meta
def list_shared_projects(storage: StorageBackend, user_id: str) -> list[ProjectMeta]:
"""List projects shared with a user (where they are a collaborator)."""
prefix = f"users/{user_id}/shared/"
shared: list[ProjectMeta] = []
for entry in storage.list_prefix(prefix):
if not entry.endswith(".json"):
continue
try:
ref = storage.read_json(entry)
owner_id = ref.get("owner_user_id")
if not owner_id:
continue
# Extract project_id from the key: users/{uid}/shared/{project_id}.json
filename = entry.rsplit("/", 1)[-1]
project_id = filename.replace(".json", "")
meta = get_project(storage, owner_id, project_id)
if meta and user_id in meta.collaborators:
shared.append(meta)
except Exception:
continue
return shared
# --- File operations ---
def save_bom(
storage: StorageBackend, user_id: str, project_id: str, data: bytes
) -> str:
key = f"{_project_prefix(user_id, project_id)}/uploads/bom.csv"
storage.write_bytes(key, data)
update_project(storage, user_id, project_id, has_bom=True)
return key
_NETLIST_EXT = {"pads": "asc", "edif": "edn"}
def _netlist_key(user_id: str, project_id: str, fmt: str) -> str:
ext = _NETLIST_EXT.get(fmt, "asc")
return f"{_project_prefix(user_id, project_id)}/uploads/netlist.{ext}"
def save_netlist(
storage: StorageBackend,
user_id: str,
project_id: str,
data: bytes,
*,
fmt: str = "pads",
) -> str:
"""Persist the uploaded netlist with the extension matching ``fmt``.
Also clears any previously-saved netlist in the *other* format so we
never have stale ``.asc`` and ``.edn`` files side-by-side (e.g. user
re-uploads with a different format).
"""
key = _netlist_key(user_id, project_id, fmt)
storage.write_bytes(key, data)
other_fmt = "edif" if fmt == "pads" else "pads"
other_key = _netlist_key(user_id, project_id, other_fmt)
if storage.exists(other_key):
storage.delete_key(other_key)
# Reset sub-design selection on every upload — the prior selection may
# reference IDs that no longer exist in the new file. Frontend resets
# the picker after upload too; this keeps backend in sync.
update_project(
storage, user_id, project_id,
has_netlist=True, netlist_format=fmt, netlist_subdesigns=None,
)
return key
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.
Library writes happen during pattern extraction (one PDF per pattern series).
"""
safe = safe_mpn(mpn)
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe}.pdf"
storage.write_bytes(key, data)
# Count datasheets
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 get_bom_key(
storage: StorageBackend, user_id: str, project_id: str
) -> str | None:
key = f"{_project_prefix(user_id, project_id)}/uploads/bom.csv"
return key if storage.exists(key) else None
def get_netlist_key(
storage: StorageBackend, user_id: str, project_id: str
) -> str | None:
"""Return the storage key of whichever netlist file exists (.asc or .edn)."""
for fmt in ("pads", "edif"):
key = _netlist_key(user_id, project_id, fmt)
if storage.exists(key):
return key
return None
def get_datasheet_key(
storage: StorageBackend, user_id: str, project_id: str, mpn: str
) -> str | None:
safe = safe_mpn(mpn)
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe}.pdf"
return key if storage.exists(key) else None
def project_prefix(user_id: str, project_id: str) -> str:
"""Return the storage prefix for a project (for use by pipeline/routers)."""
return _project_prefix(user_id, project_id)
# --- Library operations ---
def library_has_extraction(
storage: StorageBackend, mpn: str, min_version: str | None = None,
) -> str | None:
"""Check if library has a complete extraction (with pintable) for this MPN.
If *min_version* is set, also checks that the extraction's
``model_version`` meets the minimum threshold.
Returns the key if found and valid, None otherwise.
"""
safe = safe_mpn(mpn)
key = f"library/extracted/{safe}.json"
if not storage.exists(key):
return None
data = storage.read_json(key)
if not data.get("pintable"):
return None
if min_version:
from backend.services.admin_settings import version_is_stale
component_version = data.get("model_version", "0.0.0")
if version_is_stale(component_version, min_version):
return None
return key
def library_has_datasheet(
storage: StorageBackend, mpn: str, patterns: list | None = None,
) -> str | None:
"""Check if library has a datasheet PDF for this MPN.
Checks content-addressed refs first, then falls back to legacy flat
files (for pre-migration data), then pattern-based lookup.
Returns the storage key if found, None otherwise.
"""
from backend.services.datasheet_store import resolve_datasheet
# 1. Content-addressed ref lookup
resolved = resolve_datasheet(storage, mpn)
if resolved:
return resolved
# 2. Legacy flat file fallback (remove after migration confirmed)
safe = safe_mpn(mpn)
key = f"library/datasheets/{safe}.pdf"
if storage.exists(key):
return key
# 3. Pattern-based fallback for passives
if patterns:
from backend.pinscopex.resolve_passives import resolve_mpn
match = resolve_mpn(mpn, patterns)
if match is not None:
pat = match[0]
ds_key = pat.datasheet_key
if ds_key and storage.exists(ds_key):
return ds_key
return None
def library_has_model(storage: StorageBackend, mpn: str) -> str | None:
"""Check if library has a ComponentModel (specs) for this MPN.
Returns the key if found, None otherwise.
"""
safe = safe_mpn(mpn)
key = f"library/models/{safe}.json"
return key if storage.exists(key) else None
def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
"""Check if library has a DigiKey-resolved passive model for this MPN.
Checks library/passives/ first, then falls back to library/models/
for pre-migration data. Returns the key if found, None otherwise.
"""
safe = safe_mpn(mpn)
key = f"library/passives/{safe}.json"
if storage.exists(key):
return key
# Fallback: pre-migration passive specs may still be in library/models/
legacy_key = f"library/models/{safe}.json"
return legacy_key if storage.exists(legacy_key) else None
def save_to_library(
storage: StorageBackend, src_key: str, category: str, filename: str
) -> str:
"""Copy a file to the shared library."""
dst_key = f"library/{category}/{filename}"
storage.copy_object(src_key, dst_key)
return dst_key
def list_library_patterns(storage: StorageBackend) -> list[str]:
"""List all pattern keys in the library."""
prefix = "library/patterns/"
return [k for k in storage.list_prefix(prefix) if k.endswith(".json")]
def load_library_patterns(storage: StorageBackend):
"""Load and parse all passive patterns from the library.
For local backend, delegates to pinscopex. For GCS, downloads to temp first.
This function is only used by the library/check endpoint — during pipeline
execution, patterns are loaded from the workspace temp directory.
"""
from backend.pinscopex.resolve_passives import load_patterns
from backend.services.storage import LocalStorageBackend
if isinstance(storage, LocalStorageBackend):
d = storage._path("library/patterns")
if not d.is_dir():
return []
return load_patterns(str(d))
# GCS: download patterns to a temp directory
import tempfile
pattern_keys = list_library_patterns(storage)
if not pattern_keys:
return []
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) / "patterns"
tmp_path.mkdir()
for key in pattern_keys:
filename = key.rsplit("/", 1)[-1]
storage.download_to_local(key, tmp_path / filename)
return load_patterns(str(tmp_path))
# Re-export for convenience
from pathlib import Path # noqa: E402
+334
View File
@@ -0,0 +1,334 @@
"""Purple Parts API client — LCSC code → MPN resolution.
Wraps the external `purple-parts` HTTP service (a read-only API over the
jlcparts/LCSC catalogue, deployed at the URL in `settings.purple_parts_url`).
Used by the BOM-parse stage to convert LCSC codes (e.g. "C12345") into
manufacturer part numbers before the DigiKey resolver runs.
The remote service is Cloud Run with IAM auth, so calls send a Google
identity token (audience = purple_parts_url) plus an X-API-Key header. In
Cloud Run the identity token is minted automatically via ADC + the
metadata server; locally `fetch_id_token` only works if
GOOGLE_APPLICATION_CREDENTIALS points at a service-account key file. On
local dev with user creds the helper logs a debug line and the call is
skipped (returns an empty result), which the caller treats as a no-op.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from typing import Optional
import httpx
from backend.config import settings
logger = logging.getLogger(__name__)
_LCSC_RE = re.compile(r"^C\d+$", re.IGNORECASE)
# Identity tokens are valid for ~1h; refresh ~10 min early.
_TOKEN_TTL_SECONDS = 50 * 60
_token_cache: dict[str, float | str] = {"token": "", "expires_at": 0.0}
_token_lock = asyncio.Lock()
# Conservative batch size — purple-parts accepts up to 500 per request.
_BATCH_SIZE = 400
def is_lcsc_code(value: str | None) -> bool:
"""Return True if `value` looks like an LCSC part number (e.g. C12345)."""
if not value:
return False
return bool(_LCSC_RE.match(value.strip()))
async def _get_identity_token() -> str | None:
"""Mint a Google ID token for the purple-parts audience, cached.
Returns None when credentials don't support identity-token minting
(typical for local dev with `gcloud auth application-default login` user
creds). Caller should treat None as "skip the purple-parts call."
"""
now = time.time()
cached = _token_cache.get("token", "")
if cached and float(_token_cache.get("expires_at", 0.0)) > now:
return str(cached)
async with _token_lock:
cached = _token_cache.get("token", "")
if cached and float(_token_cache.get("expires_at", 0.0)) > now:
return str(cached)
try:
from google.auth.transport.requests import Request
from google.oauth2 import id_token as gid_token
except ImportError:
logger.warning("google-auth not installed; purple-parts disabled")
return None
loop = asyncio.get_running_loop()
try:
token = await loop.run_in_executor(
None,
lambda: gid_token.fetch_id_token(Request(), settings.purple_parts_url),
)
except Exception as e:
logger.debug(
"purple-parts: identity-token mint failed (%s: %s) — "
"expected for local user creds, skipping",
type(e).__name__, e,
)
return None
_token_cache["token"] = token
_token_cache["expires_at"] = now + _TOKEN_TTL_SECONDS
return token
def detect_lcsc_column(csv_bytes: bytes, mpn_col: str) -> bool:
"""Return True when every non-empty value in `mpn_col` matches `^C\\d+$`.
Used by the upload endpoint to auto-detect when the user's chosen MPN
column is actually an LCSC column (i.e. the user pasted LCSC ids into
the MPN slot, or labeled their LCSC column as "Manufacturer Part Number").
Column-level — a single non-LCSC entry disqualifies the column so that
BOMs mixing real MPNs with LCSC ids aren't silently mangled.
"""
import csv as csv_mod
import io
text = csv_bytes.decode("utf-8", errors="replace")
reader = csv_mod.DictReader(io.StringIO(text))
if not reader.fieldnames or mpn_col not in reader.fieldnames:
return False
seen_any = False
for row in reader:
val = (row.get(mpn_col) or "").strip()
if not val:
continue
if not is_lcsc_code(val):
return False
seen_any = True
return seen_any
async def resolve_lcsc_column_bytes(
csv_bytes: bytes,
*,
mpn_col: str = "Manufacturer Part Number",
) -> tuple[bytes, int, dict[str, str], dict[str, dict]]:
"""Replace every value in `mpn_col` with the manufacturer part number
resolved via purple-parts.
Returns `(new_csv_bytes, rows_updated, lcsc_to_mpn_map, lcsc_payloads_map)`.
The first map is keyed by LCSC id (e.g. "C12044") → resolved MPN string,
so the caller can surface "C12044 → STM32F103C8T6" in the UI. The second
map is keyed by the same LCSC id → the full purple-parts payload (mpn,
manufacturer, package, description, category, subcategory, ...) so the
caller can cache it on the project for the wizard's per-row resolve
endpoint. Preserves column order, headers, and untouched cells. No-op
when purple-parts isn't configured.
"""
import csv as csv_mod
import io
if not settings.use_purple_parts:
return csv_bytes, 0, {}, {}
text = csv_bytes.decode("utf-8", errors="replace")
reader = csv_mod.DictReader(io.StringIO(text))
fieldnames = reader.fieldnames or []
rows = list(reader)
if not rows or mpn_col not in fieldnames:
return csv_bytes, 0, {}, {}
todo: list[tuple[int, str]] = []
for i, row in enumerate(rows):
code = (row.get(mpn_col) or "").strip()
if is_lcsc_code(code):
todo.append((i, code))
if not todo:
return csv_bytes, 0, {}, {}
unique_codes = sorted({c for _, c in todo})
resolved = await lookup_lcsc_batch(unique_codes)
updated = 0
lcsc_to_mpn: dict[str, str] = {}
lcsc_payloads: dict[str, dict] = {}
for i, code in todo:
part = resolved.get(code)
if part and part.get("mpn"):
rows[i][mpn_col] = part["mpn"]
lcsc_to_mpn[code] = part["mpn"]
lcsc_payloads[code] = dict(part)
updated += 1
if updated == 0:
return csv_bytes, 0, {}, {}
out = io.StringIO()
writer = csv_mod.DictWriter(out, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
return out.getvalue().encode("utf-8"), updated, lcsc_to_mpn, lcsc_payloads
async def lookup_lcsc_batch(lcsc_codes: list[str]) -> dict[str, Optional[dict]]:
"""Batch LCSC → MPN lookup.
Returns `{lcsc_code: part_dict_or_None}` for every code in input. Misses,
invalid codes, and (after warning) total failures all return None values
so the caller can treat the result as a uniform per-code map. The pipeline
never aborts on a purple-parts miss; the row simply stays unresolved and
the existing DigiKey/Haiku paths handle it.
Part dict shape: {lcsc, mpn, manufacturer, package, description, stock,
basic, preferred}.
"""
if not settings.use_purple_parts:
return {c: None for c in lcsc_codes}
codes = [c for c in (raw.strip() for raw in lcsc_codes) if c]
if not codes:
return {}
token = await _get_identity_token()
if token is None:
logger.info("purple-parts: no identity token, skipping batch of %d", len(codes))
return {c: None for c in codes}
base_url = settings.purple_parts_url.rstrip("/")
headers = {
"Authorization": f"Bearer {token}",
"X-API-Key": settings.purple_parts_api_key,
"Content-Type": "application/json",
}
results: dict[str, Optional[dict]] = {c: None for c in codes}
async with httpx.AsyncClient(timeout=15) as client:
for i in range(0, len(codes), _BATCH_SIZE):
chunk = codes[i:i + _BATCH_SIZE]
try:
resp = await client.post(
f"{base_url}/v1/parts/by-lcsc/batch",
headers=headers,
json={"ids": chunk},
)
resp.raise_for_status()
body = resp.json()
except httpx.HTTPStatusError as e:
logger.warning(
"purple-parts: batch call failed %s for chunk of %d",
e.response.status_code, len(chunk),
)
continue
except Exception as e:
logger.warning("purple-parts: batch call error: %s", e)
continue
for code, part in (body.get("results") or {}).items():
results[code] = part
return results
def _norm_mpn(value: str | None) -> str:
"""Normalize an MPN for comparison: drop whitespace, uppercase."""
return "".join((value or "").split()).upper()
def _pick_exact(query: str, candidates: list[dict]) -> Optional[dict]:
"""Return the candidate whose ``mpn`` exactly matches ``query``.
Match is case- and whitespace-insensitive. purple-parts' ``by-mpn``
endpoint returns exact matches first and then prefix matches, but we
re-check rather than trust ordering — a prefix-only hit (e.g. a series
family for a more specific MPN) must be treated as a miss so it can't
pollute the shared passive library. Mirrors the exact-MPN discipline of
``services.digikey._find_product``.
"""
q = _norm_mpn(query)
for part in candidates:
if part and _norm_mpn(part.get("mpn")) == q:
return part
return None
async def lookup_mpn_batch(mpns: list[str]) -> dict[str, Optional[dict]]:
"""Reverse lookup: manufacturer part number → LCSC catalogue record.
Fans the unique MPNs out to purple-parts' batch endpoint
(``POST /v1/parts/by-mpn/batch``) in chunks of ``_BATCH_SIZE`` — one indexed
query per chunk instead of a GET per MPN, which is what stalled huge-BOM
uploads when the by-mpn query was seq-scanning. Returns
``{mpn: part_dict_or_None}`` keyed by the *input* MPN string.
The endpoint is exact-match only, and we additionally run :func:`_pick_exact`
over each MPN's candidate list (case/whitespace-insensitive) to keep the
exact-MPN discipline — a prefix / family hit can carry the wrong
voltage / dielectric / package and must never reach the shared
``library/passives``. Misses, missing creds (no identity token), and per-chunk
failures all come back as ``None`` so the caller can treat the map uniformly.
No-op (all ``None``) when purple-parts isn't configured.
Part dict shape matches :func:`lookup_lcsc_batch`: {lcsc, mpn, manufacturer,
package, description, category, subcategory, stock, basic, preferred}.
"""
if not settings.use_purple_parts:
return {m: None for m in mpns}
# Preserve input keys but query each unique, non-empty MPN once.
names = list(dict.fromkeys(m.strip() for m in mpns if m and m.strip()))
if not names:
return {}
token = await _get_identity_token()
if token is None:
logger.info("purple-parts: no identity token, skipping by-mpn batch of %d", len(names))
return {m: None for m in names}
base_url = settings.purple_parts_url.rstrip("/")
headers = {
"Authorization": f"Bearer {token}",
"X-API-Key": settings.purple_parts_api_key,
"Content-Type": "application/json",
}
results: dict[str, Optional[dict]] = {m: None for m in names}
async with httpx.AsyncClient(timeout=15) as client:
for i in range(0, len(names), _BATCH_SIZE):
chunk = names[i:i + _BATCH_SIZE]
try:
resp = await client.post(
f"{base_url}/v1/parts/by-mpn/batch",
headers=headers,
json={"mpns": chunk},
)
resp.raise_for_status()
body = resp.json()
except httpx.HTTPStatusError as e:
logger.warning(
"purple-parts: by-mpn batch call failed %s for chunk of %d",
e.response.status_code, len(chunk),
)
continue
except Exception as e:
msg = str(e) or type(e).__name__
logger.warning("purple-parts: by-mpn batch call error: %s", msg)
continue
# Each MPN maps to a candidate list; keep only the exact match.
for mpn, candidates in (body.get("results") or {}).items():
results[mpn] = _pick_exact(mpn, candidates or [])
return results
+264
View File
@@ -0,0 +1,264 @@
"""Storage abstraction layer.
Provides a StorageBackend protocol with two implementations:
- LocalStorageBackend: maps GCS-style keys to local filesystem paths (dev/test)
- GCSStorageBackend: uses Google Cloud Storage (production)
Keys use forward-slash-separated paths like GCS object names:
users/{user_id}/projects/{project_id}/project.json
library/extracted/{safe_mpn}.json
taxonomy/ic.json
"""
from __future__ import annotations
import json
import shutil
import threading
from pathlib import Path
from typing import Protocol, runtime_checkable
# Sentinel used by conditional writes to require that the object does not yet
# exist (matches GCS ``if_generation_match=0`` semantics).
GENERATION_NEW = 0
class StaleGeneration(Exception):
"""Raised when a conditional write loses an optimistic-concurrency race."""
@runtime_checkable
class StorageBackend(Protocol):
"""Abstract storage interface used by all backend services."""
def read_json(self, key: str) -> dict:
"""Read and parse a JSON object."""
...
def write_json(self, key: str, data: dict) -> None:
"""Serialize and write a JSON object."""
...
def read_bytes(self, key: str) -> bytes:
"""Read raw bytes."""
...
def write_bytes(self, key: str, data: bytes) -> None:
"""Write raw bytes."""
...
def read_text(self, key: str) -> str:
"""Read as UTF-8 text."""
...
def write_text(self, key: str, text: str) -> None:
"""Write UTF-8 text."""
...
def exists(self, key: str) -> bool:
"""Check if an object exists."""
...
def list_prefix(self, prefix: str) -> list[str]:
"""List all keys under a prefix (non-recursive by default).
Returns keys that are direct children of the prefix — i.e. one level
deep. For example, listing ``users/abc/projects/`` returns keys like
``users/abc/projects/p1/project.json`` but NOT keys nested further.
To list all keys recursively, use list_recursive().
"""
...
def list_recursive(self, prefix: str) -> list[str]:
"""List all keys under a prefix, recursively."""
...
def list_prefix_after(self, prefix: str, after_key: str | None = None) -> list[str]:
"""List keys under ``prefix`` whose name lexicographically follows
``after_key``. Used by the GCS-backed event tail (worker writes one
object per event with a zero-padded sequence number; the SSE
consumer pages through new files only).
"""
...
def read_json_with_generation(self, key: str) -> tuple[dict, int]:
"""Read JSON and return ``(data, generation)``.
``generation`` is an opaque token that callers pass back to
``write_json_if_match`` to detect lost-update races.
"""
...
def write_json_if_match(self, key: str, data: dict, generation: int) -> int:
"""Write JSON only if the current generation equals ``generation``.
Pass ``GENERATION_NEW`` (0) to require that the key does not exist.
Returns the new generation. Raises :class:`StaleGeneration` when the
precondition fails (loser of a race).
"""
...
def delete_key(self, key: str) -> None:
"""Delete a single object."""
...
def delete_prefix(self, prefix: str) -> None:
"""Delete all objects under a prefix (recursive)."""
...
def copy_object(self, src_key: str, dst_key: str) -> None:
"""Copy an object from src to dst."""
...
def download_to_local(self, key: str, local_path: Path) -> Path:
"""Download an object to a local file path. Returns the local path."""
...
def upload_from_local(self, local_path: Path, key: str) -> None:
"""Upload a local file to storage."""
...
def signed_url(self, key: str, expiration_minutes: int = 15) -> str:
"""Generate a time-limited URL for direct access to an object.
For LocalStorageBackend, returns a backend-proxied URL.
For GCS, returns a signed GCS URL.
"""
...
class LocalStorageBackend:
"""Maps GCS-style keys to local filesystem paths under a base directory.
Key ``users/abc/projects/p1/project.json`` becomes
``{base_dir}/users/abc/projects/p1/project.json``.
"""
def __init__(self, base_dir: Path) -> None:
self._base = base_dir
# In-memory generation counter for optimistic-concurrency parity with
# GCS. Single-process only; subprocess-based local workers run in a
# different process and will collide on the meta key. The local
# subprocess path is dev-only and rarely concurrent, so we accept it.
self._generations: dict[str, int] = {}
self._gen_lock = threading.Lock()
def _path(self, key: str) -> Path:
return self._base / key
def read_json(self, key: str) -> dict:
return json.loads(self._path(key).read_text())
def write_json(self, key: str, data: dict) -> None:
p = self._path(key)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(data, indent=2) + "\n")
def read_bytes(self, key: str) -> bytes:
return self._path(key).read_bytes()
def write_bytes(self, key: str, data: bytes) -> None:
p = self._path(key)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
def read_text(self, key: str) -> str:
return self._path(key).read_text()
def write_text(self, key: str, text: str) -> None:
p = self._path(key)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(text)
def exists(self, key: str) -> bool:
return self._path(key).is_file()
def list_prefix(self, prefix: str) -> list[str]:
d = self._path(prefix)
if not d.is_dir():
return []
keys: list[str] = []
for child in sorted(d.iterdir()):
rel = child.relative_to(self._base)
keys.append(str(rel))
return keys
def list_recursive(self, prefix: str) -> list[str]:
d = self._path(prefix)
if not d.is_dir():
return []
keys: list[str] = []
for child in sorted(d.rglob("*")):
if child.is_file():
rel = child.relative_to(self._base)
keys.append(str(rel))
return keys
def list_prefix_after(self, prefix: str, after_key: str | None = None) -> list[str]:
d = self._path(prefix)
if not d.is_dir():
return []
keys: list[str] = []
for child in sorted(d.iterdir()):
if not child.is_file():
continue
rel = str(child.relative_to(self._base))
if after_key is not None and rel <= after_key:
continue
keys.append(rel)
return keys
def read_json_with_generation(self, key: str) -> tuple[dict, int]:
data = json.loads(self._path(key).read_text())
with self._gen_lock:
gen = self._generations.get(key, 1)
return data, gen
def write_json_if_match(self, key: str, data: dict, generation: int) -> int:
p = self._path(key)
with self._gen_lock:
current = self._generations.get(key, 0 if not p.is_file() else 1)
if generation != current:
raise StaleGeneration(
f"generation mismatch on {key}: expected {generation}, current {current}"
)
new_gen = current + 1
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(data, indent=2) + "\n")
self._generations[key] = new_gen
return new_gen
def delete_key(self, key: str) -> None:
p = self._path(key)
if p.is_file():
p.unlink()
with self._gen_lock:
self._generations.pop(key, None)
def delete_prefix(self, prefix: str) -> None:
d = self._path(prefix)
if d.is_dir():
shutil.rmtree(d)
def copy_object(self, src_key: str, dst_key: str) -> None:
src = self._path(src_key)
dst = self._path(dst_key)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def download_to_local(self, key: str, local_path: Path) -> Path:
src = self._path(key)
local_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, local_path)
return local_path
def upload_from_local(self, local_path: Path, key: str) -> None:
dst = self._path(key)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(local_path, dst)
def signed_url(self, key: str, expiration_minutes: int = 15) -> str:
# Local dev: return a path that the backend can serve directly
return f"/api/datasheets/_local/{key}"
+126
View File
@@ -0,0 +1,126 @@
"""Google Cloud Storage backend for StorageBackend protocol."""
from __future__ import annotations
import json
from pathlib import Path
from google.api_core.exceptions import PreconditionFailed
from google.cloud import storage as gcs
from backend.services.storage import StaleGeneration
class GCSStorageBackend:
"""StorageBackend implementation using Google Cloud Storage."""
def __init__(self, bucket_name: str) -> None:
self._client = gcs.Client()
self._bucket = self._client.bucket(bucket_name)
def _blob(self, key: str) -> gcs.Blob:
return self._bucket.blob(key)
def read_json(self, key: str) -> dict:
text = self._blob(key).download_as_text()
return json.loads(text)
def write_json(self, key: str, data: dict) -> None:
text = json.dumps(data, indent=2) + "\n"
self._blob(key).upload_from_string(text, content_type="application/json")
def read_bytes(self, key: str) -> bytes:
return self._blob(key).download_as_bytes()
def write_bytes(self, key: str, data: bytes) -> None:
self._blob(key).upload_from_string(data)
def read_text(self, key: str) -> str:
return self._blob(key).download_as_text()
def write_text(self, key: str, text: str) -> None:
self._blob(key).upload_from_string(text, content_type="text/plain")
def exists(self, key: str) -> bool:
return self._blob(key).exists()
def list_prefix(self, prefix: str) -> list[str]:
# List immediate children (one level) using delimiter
blobs = self._client.list_blobs(
self._bucket, prefix=prefix, delimiter="/",
)
keys: list[str] = []
# Files directly under prefix
for blob in blobs:
keys.append(blob.name)
# "Subdirectories" — strip trailing slash for consistency
for pfx in blobs.prefixes:
keys.append(pfx.rstrip("/"))
return sorted(keys)
def list_recursive(self, prefix: str) -> list[str]:
blobs = self._client.list_blobs(self._bucket, prefix=prefix)
return sorted(blob.name for blob in blobs)
def list_prefix_after(self, prefix: str, after_key: str | None = None) -> list[str]:
# Use GCS ``start_offset`` to skip already-seen keys server-side. We
# ask for the next-after value; since after_key may be the last seen
# key, advance one byte so the listing excludes it.
kwargs: dict = {"prefix": prefix, "delimiter": "/"}
if after_key is not None:
# Request keys strictly greater than after_key. Append a NUL byte
# so GCS treats start_offset as "after" rather than "starting at".
kwargs["start_offset"] = after_key + "\x00"
blobs = self._client.list_blobs(self._bucket, **kwargs)
return sorted(blob.name for blob in blobs)
def read_json_with_generation(self, key: str) -> tuple[dict, int]:
blob = self._blob(key)
text = blob.download_as_text()
# download_as_text populates blob.generation as a side effect.
gen = int(blob.generation) if blob.generation is not None else 0
return json.loads(text), gen
def write_json_if_match(self, key: str, data: dict, generation: int) -> int:
text = json.dumps(data, indent=2) + "\n"
blob = self._blob(key)
try:
blob.upload_from_string(
text,
content_type="application/json",
if_generation_match=generation,
)
except PreconditionFailed as exc:
raise StaleGeneration(
f"generation mismatch on {key}: expected {generation}"
) from exc
# blob.generation is set by upload_from_string on success.
return int(blob.generation) if blob.generation is not None else 0
def delete_key(self, key: str) -> None:
blob = self._blob(key)
if blob.exists():
blob.delete()
def delete_prefix(self, prefix: str) -> None:
blobs = list(self._client.list_blobs(self._bucket, prefix=prefix))
if blobs:
self._bucket.delete_blobs(blobs)
def copy_object(self, src_key: str, dst_key: str) -> None:
src_blob = self._blob(src_key)
self._bucket.copy_blob(src_blob, self._bucket, dst_key)
def download_to_local(self, key: str, local_path: Path) -> Path:
local_path.parent.mkdir(parents=True, exist_ok=True)
self._blob(key).download_to_filename(str(local_path))
return local_path
def upload_from_local(self, local_path: Path, key: str) -> None:
self._blob(key).upload_from_filename(str(local_path))
def signed_url(self, key: str, expiration_minutes: int = 15) -> str:
# Not used for GCS on Cloud Run — the backend proxies PDFs directly
# via the /datasheet-proxy/ endpoint instead. Kept for interface
# compatibility.
raise NotImplementedError("Use read_bytes() and proxy instead")
+98
View File
@@ -0,0 +1,98 @@
"""Onboarding survey — appends responses to a Google Sheet and tracks completion."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from backend.config import settings
from backend.services.storage import StorageBackend
logger = logging.getLogger(__name__)
_SURVEY_PREFIX = "admin/survey/"
def _status_key(user_id: str) -> str:
return f"{_SURVEY_PREFIX}{user_id}.json"
def is_completed(storage: StorageBackend, user_id: str) -> bool:
return storage.exists(_status_key(user_id))
def _mark_completed(storage: StorageBackend, user_id: str) -> None:
payload = {"completed": True, "timestamp": datetime.now(timezone.utc).isoformat()}
storage.write_json(_status_key(user_id), payload)
def _build_sheets_service():
"""Build an authenticated Google Sheets API service.
On Cloud Run, google.auth.default() returns Compute Engine credentials
which are auto-scoped. We just need the Sheets API enabled in the GCP
project and the service account shared on the sheet.
"""
try:
import google.auth
from googleapiclient.discovery import build
except ImportError:
logger.warning("google-api-python-client not installed; survey sheet disabled")
return None
try:
credentials, project = google.auth.default()
logger.debug("Sheets: credentials type=%s project=%s", type(credentials).__name__, project)
# Compute Engine credentials don't need explicit scopes — they use
# the access scopes set on the instance (which default to cloud-platform).
# For user/SA key credentials, we need to scope them.
if hasattr(credentials, "with_scopes"):
credentials = credentials.with_scopes(
["https://www.googleapis.com/auth/spreadsheets"]
)
return build("sheets", "v4", credentials=credentials, cache_discovery=False)
except Exception:
logger.exception("Could not build Sheets service")
return None
async def append_to_sheet(
user_id: str,
email: str,
name: str,
referral_source: str,
user_profile: str,
) -> bool:
"""Append a survey row to the configured Google Sheet. Returns True on success."""
sheet_id = settings.survey_sheet_id
if not sheet_id:
logger.warning("SURVEY_SHEET_ID not set; skipping sheet append for user %s", user_id)
return False
service = _build_sheets_service()
if not service:
return False
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
row = [timestamp, user_id, email, name, referral_source, user_profile]
try:
await asyncio.to_thread(
service.spreadsheets()
.values()
.append(
spreadsheetId=sheet_id,
range="Sheet1!A:F",
valueInputOption="RAW",
insertDataOption="INSERT_ROWS",
body={"values": [row]},
)
.execute
)
logger.info("Survey response appended for user %s", user_id)
return True
except Exception:
logger.exception("Failed to append survey response to Google Sheet for user %s", user_id)
return False
+977
View File
@@ -0,0 +1,977 @@
"""Async direct datasheet review — per-IC with graph tools.
Each IC gets a review call with its datasheet PDF and circuit neighborhood.
ICs run concurrently with a semaphore. Provider-agnostic — routes through
the LLM provider abstraction so a stage env var (PROVIDER_VALIDATION) can
flip between Anthropic and Gemini without code changes.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Awaitable, Callable
log = logging.getLogger(__name__)
from backend.pinscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
NetType,
ValidationReport,
)
from backend.pinscopex.validate import (
SYSTEM_PROMPT,
_MAX_REVIEW_TURNS,
ReviewResult,
_load_datasheets,
_match_constraints,
_build_constraints_map,
assign_finding_ids,
build_component_context,
_parse_review,
)
from backend.pinscopex.utils import safe_mpn
from backend.pinscopex.pin_mux_check import check_pin_mux_feasibility
from backend.pinscopex.led_current_check import check_led_current
TRACE_VERSION = 1
def _is_deterministic(f: Finding) -> bool:
"""True for a finding produced by a deterministic check (not the LLM review)."""
return bool(getattr(f, "source", None)) and f.source != "review"
def _run_deterministic_checks(
graph: DesignGraph, constraints_map: dict
) -> list[Finding]:
"""Run the deterministic graph checks, fail-soft per check — a check bug
can never break the review or the report."""
out: list[Finding] = []
for name, fn in (
("pin_mux_check", lambda: check_pin_mux_feasibility(graph, constraints_map)),
("led_current_check", lambda: check_led_current(graph)),
):
try:
out.extend(fn())
except Exception:
log.exception("deterministic check %s failed — skipping", name)
return out
def _assistant_text(blocks) -> str:
"""Best-effort extraction of text content from a completion's raw
assistant blocks. Provider-agnostic and never raises."""
parts: list[str] = []
try:
for b in blocks or []:
txt = getattr(b, "text", None)
if txt is None and isinstance(b, dict):
txt = b.get("text") if b.get("type") == "text" else None
elif getattr(b, "type", None) not in (None, "text"):
txt = None
if isinstance(txt, str) and txt:
parts.append(txt)
except Exception:
log.exception("trace: assistant_text extraction failed")
return "\n".join(parts)
from backend.pinscopex.validation_tools import (
ALL_TOOLS,
SUBMIT_REVIEW_SCHEMA,
ConstraintsMap,
ExcerptState,
execute_tool,
)
from backend.pinscopex.utils import safe_mpn
from backend.config import settings
from backend.services.api_logs import ApiLogger
from backend.services.normalize_findings import normalize_findings_async
from backend.services.dedupe_findings import dedupe_cross_ic_findings_async
from backend.services.llm import (
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolResultBlock,
ToolSchema,
call_with_fallback,
)
# Type for progress callback: (ref, turn, tool_name_or_status, detail)
ProgressCallback = Callable[[str, int, str, str], Awaitable[None]]
# ---------------------------------------------------------------------------
# Tool schemas — defined as dicts in validation_tools.py, converted here
# ---------------------------------------------------------------------------
def _to_tool_schema(d: dict) -> ToolSchema:
return ToolSchema(
name=d["name"],
description=d["description"],
input_schema=d["input_schema"],
)
_ALL_TOOL_SCHEMAS = [_to_tool_schema(t) for t in ALL_TOOLS]
_SUBMIT_TOOL_SCHEMA = _to_tool_schema(SUBMIT_REVIEW_SCHEMA)
# ---------------------------------------------------------------------------
# Review keywords for PDF page trimming
# ---------------------------------------------------------------------------
_REVIEW_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|layout\s+(guideline|recommendation)"
r"|application\s+(circuit|schematic|information|note)"
r"|typical\s+application|reference\s+design",
re.IGNORECASE,
)
_MAX_PDF_PAGES = 90
# 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
# message and the model is steered to submit WARNING with Unverified:
# assumption rather than fetching more.
#
# The global page budget got raised from 25→60 and gained a per-neighbor
# sub-budget after the U2-001 / U3-001 false positives: a single 25-page
# global cap was exhausted by one neighbor's pin_voltage_levels excerpt
# before the abs-max table could be read, so the reviewer was forced to
# guess at the very moment it was trying to verify a damage claim. 30 pages
# 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
# 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
# fan-out on designs that use an oversized common signal (rare but possible).
_SIGNAL_NET_MAX_COMPONENTS = 8
def _signal_neighbors(graph: DesignGraph, ic_ref: str) -> set[str]:
"""Return the set of designators that share at least one *signal* net
with ``ic_ref``. Excludes power/ground rails (which connect every IC and
would otherwise fan the neighbor set out across the whole design) and
excludes the IC under review itself.
"""
comp = graph.components.get(ic_ref)
if not comp:
return set()
neighbors: set[str] = set()
for net_name in set(comp.pins.values()):
net = graph.nets.get(net_name)
if not net:
continue
if net.net_type in (NetType.POWER, NetType.GROUND):
continue
refs_on_net = {pc.component_ref for pc in net.pins}
if len(refs_on_net) > _SIGNAL_NET_MAX_COMPONENTS:
continue
for ref in refs_on_net:
if ref != ic_ref:
neighbors.add(ref)
return neighbors
def _select_review_pages(pdf_path: str) -> str:
"""Trim a datasheet PDF to pages relevant for design review.
Returns path to trimmed PDF (or original if already small enough).
Note: the reviewer cites the datasheet's *printed* page number (read from
the page content/footer), not the page's physical position in the trimmed
file — so `source_page` already matches the full original PDF the frontend
serves. No trimmed→original remap is applied (an earlier remap attempt
corrupted correct citations on large datasheets).
"""
from pypdf import PdfReader, PdfWriter
reader = PdfReader(pdf_path)
total = len(reader.pages)
if total <= _MAX_PDF_PAGES:
return pdf_path
# Always keep first 5 pages (title, TOC, overview)
keep: set[int] = set(range(min(5, total)))
# Keyword-matched pages + neighbors
for i, page in enumerate(reader.pages):
text = page.extract_text() or ""
if _REVIEW_KEYWORDS.search(text):
for neighbor in (i - 1, i, i + 1):
if 0 <= neighbor < total:
keep.add(neighbor)
# Pad from front if under budget
if len(keep) < _MAX_PDF_PAGES:
for i in range(total):
if len(keep) >= _MAX_PDF_PAGES:
break
keep.add(i)
selected = sorted(keep)[:_MAX_PDF_PAGES]
writer = PdfWriter()
for i in selected:
writer.add_page(reader.pages[i])
tmp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
writer.write(tmp)
tmp.close()
return tmp.name
# ---------------------------------------------------------------------------
# Per-IC async review
# ---------------------------------------------------------------------------
async def review_ic_async(
graph: DesignGraph,
constraints_map: ConstraintsMap,
ic_ref: str,
pdf_path: str,
on_progress: ProgressCallback | None = None,
api_logger: ApiLogger | None = None,
trace_git_commit: str = "unknown",
pdf_dir: Path | None = None,
storage=None,
excerpt_cache: dict | None = None,
) -> tuple[ReviewResult, dict]:
"""Review one IC against its datasheet. Async, multi-turn.
Returns ``(ReviewResult, trace)`` — ``trace`` is a transcript dict of the
full agentic loop (turns, tool calls + outputs, final submission) for
offline inspection. Trace assembly is best-effort and never affects the
review result.
"""
comp = graph.components[ic_ref]
mpn = comp.mpn or comp.value
# Datasheet identity for the trace — hash the original PDF, not the
# trimmed copy, so the reference is stable across trim-heuristic changes.
try:
ds_md5 = hashlib.md5(Path(pdf_path).read_bytes()).hexdigest()
except Exception:
log.exception("trace: datasheet md5 failed for %s", ic_ref)
ds_md5 = None
# Pre-compute which designators the excerpt tool will accept for this
# review (neighbors via signal nets only — power/GND fan-out filtered).
connected_designators = _signal_neighbors(graph, ic_ref)
# Designator -> MPN, so a finding citing a neighbor's datasheet excerpt
# (source_designator) is referenced against — and viewed from — that
# neighbor's datasheet rather than this IC's.
mpn_by_designator = {
ref: comp.mpn
for ref, comp in graph.components.items()
if comp.mpn
}
# Build the per-review state for the excerpt tool. ``cache`` is shared
# across ICs in the same validate_design_async run so symmetric checks
# (U2 fetches U3@abs_max, then U3 fetches U2@abs_max) don't redo pypdf
# work.
excerpt_state = ExcerptState(
current_ic=ic_ref,
connected_designators=connected_designators,
graph=graph,
pdf_dir=pdf_dir or Path(pdf_path).parent,
storage=storage,
cache=excerpt_cache if excerpt_cache is not None else {},
fetch_budget=_PER_REVIEW_FETCH_BUDGET,
page_budget=_PER_REVIEW_PAGE_BUDGET,
per_neighbor_page_budget=_PER_NEIGHBOR_PAGE_BUDGET,
)
# Trim PDF up-front — both primary and fallback attempts share it.
trimmed_pdf = _select_review_pages(pdf_path)
try:
async def _run(provider, model) -> tuple[ReviewResult, dict]:
t0 = time.monotonic()
total_input = 0
total_output = 0
total_cache_creation = 0
total_cache_read = 0
turns = 0
session = await provider.create_session(
model=model,
system=SYSTEM_PROMPT,
# Gemini 2.5/3 thinking models count thoughts against this cap.
# 4096 was too tight: U3 (largest IC) burned the entire budget
# on thinking and emitted zero visible output, dropping its
# review silently.
max_tokens=32768,
# Deterministic sampling: same inputs → same findings across
# reruns. The default temperature of 1.0 caused identical
# netlists to produce very different reports (different
# findings + severities) run-to-run.
temperature=0.0,
)
try:
context = build_component_context(graph, constraints_map, ic_ref)
initial_msg = Message(
role="user",
content=[
PdfBlock(path=Path(trimmed_pdf), cacheable=True),
TextBlock(
text=f"Review this component's usage:\n\n{context}",
cacheable=True,
),
],
)
messages: list[Message] = [initial_msg]
trace: dict = {
"trace_version": TRACE_VERSION,
"ic_ref": ic_ref,
"mpn": mpn,
"model": model,
"provider": provider.name,
"git_commit": trace_git_commit,
"datasheet": {"md5": ds_md5, "safe_mpn": safe_mpn(mpn)},
"timestamp": datetime.now(timezone.utc).isoformat(),
"max_turns": _MAX_REVIEW_TURNS,
"turns": [],
"final_submission": None,
"result": None,
"stop_reason": None,
"error": None,
"duration_ms": None,
}
# Set after a turn produces zero tool calls (model wrote
# text only). Next turn is forced to submit_review so any
# findings drafted as prose still make it to the report.
force_submit_next_turn = False
for turn in range(_MAX_REVIEW_TURNS):
is_last_turn = turn == _MAX_REVIEW_TURNS - 1
if is_last_turn or force_submit_next_turn:
tools = [_SUBMIT_TOOL_SCHEMA]
tool_choice: dict | str = {"name": "submit_review"}
else:
tools = _ALL_TOOL_SCHEMAS
tool_choice = "auto"
completion = await session.complete(
messages=messages,
tools=tools,
tool_choice=tool_choice,
)
turns += 1
total_input += completion.usage.input_tokens
total_output += completion.usage.output_tokens
total_cache_creation += completion.usage.cache_creation_tokens
total_cache_read += completion.usage.cache_read_tokens
turn_record: dict = {
"index": turn,
"assistant_text": _assistant_text(
completion.raw_assistant_blocks
),
"tool_calls": [],
"usage": {
"input_tokens": completion.usage.input_tokens,
"output_tokens": completion.usage.output_tokens,
"cache_creation_tokens": completion.usage.cache_creation_tokens,
"cache_read_tokens": completion.usage.cache_read_tokens,
},
}
try:
trace["turns"].append(turn_record)
except Exception:
log.exception("trace: turn append failed for %s", ic_ref)
# Check for submit_review
for tc in completion.tool_calls:
if tc.name == "submit_review":
result = _parse_review(
tc.input, ic_ref, mpn,
mpn_by_designator=mpn_by_designator,
connected=connected_designators,
)
turn_record["tool_calls"].append({
"name": "submit_review",
"input": tc.input,
"output": None,
"duration_ms": None,
})
trace["final_submission"] = tc.input
trace["stop_reason"] = "submit_review"
trace["result"] = {
"findings_count": len(result.findings),
"checked_areas": result.checked_areas,
}
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
if on_progress:
await on_progress(
ic_ref, turn, "submit_review",
f"{len(result.findings)} findings",
)
if api_logger:
api_logger.log(
stage="review", identifier=ic_ref,
model=model, provider=provider.name,
input_tokens=total_input, output_tokens=total_output,
cache_creation_input_tokens=total_cache_creation,
cache_read_input_tokens=total_cache_read,
duration_ms=int((time.monotonic() - t0) * 1000),
stop_reason="submit_review", turns=turns,
)
if settings.normalize_findings_enabled:
try:
normalized, norm_trace = await normalize_findings_async(
ic_ref, mpn, result.findings,
api_logger=api_logger,
on_progress=on_progress,
)
trace["normalize"] = norm_trace
result.findings = normalized
trace["result"]["findings_count"] = len(normalized)
except Exception:
log.exception(
"normalize: unexpected failure for %s "
"— keeping reviewer findings",
ic_ref,
)
return result, trace
# Process graph tool calls
tool_results: list[ToolResultBlock] = []
attached_pdfs: list[PdfBlock] = []
for tc in completion.tool_calls:
_tc_t0 = time.monotonic()
result_text, attachment = execute_tool(
graph, constraints_map, tc.name, tc.input,
state=excerpt_state,
)
turn_record["tool_calls"].append({
"name": tc.name,
"input": tc.input,
"output": result_text,
"duration_ms": int((time.monotonic() - _tc_t0) * 1000),
})
if on_progress:
await on_progress(
ic_ref, turn, tc.name, json.dumps(tc.input),
)
tool_results.append(ToolResultBlock(
tool_use_id=tc.id,
name=tc.name,
content=result_text,
))
if attachment is not None:
attached_pdfs.append(attachment)
if not tool_results:
# Model emitted text but called no tools. This is a
# known failure mode (esp. with reasoning models)
# where the model writes findings as a JSON code
# block in prose instead of calling submit_review.
# Don't drop the work — append a nudge and force
# submit_review on the next iteration.
if not is_last_turn and not force_submit_next_turn:
messages.append(Message(
role="assistant",
content=completion.raw_assistant_blocks,
))
messages.append(Message(
role="user",
content=[TextBlock(
text=(
"You produced text but did not call "
"any tool. Findings only reach the "
"report when submitted via the "
"submit_review tool — text JSON is "
"ignored. Call submit_review now with "
"the findings you identified (or an "
"empty findings array if none) and "
"your checked_areas list."
),
)],
))
force_submit_next_turn = True
continue
break
# Reset recovery flag once the model is calling tools again.
force_submit_next_turn = False
messages.append(Message(role="assistant", content=completion.raw_assistant_blocks))
# tool_result blocks first, then any PdfBlocks the tools
# attached (excerpt fetches). The Anthropic provider
# encodes each block independently — mixed-block user
# messages are supported and the cached initial PDF is
# not invalidated by appending uncached/cached content.
messages.append(Message(
role="user",
content=[*tool_results, *attached_pdfs],
))
# Fell through without submitting
trace["stop_reason"] = "no_submission"
trace["result"] = {"findings_count": 0, "checked_areas": []}
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
if api_logger:
api_logger.log(
stage="review", identifier=ic_ref,
model=model, provider=provider.name,
input_tokens=total_input, output_tokens=total_output,
cache_creation_input_tokens=total_cache_creation,
cache_read_input_tokens=total_cache_read,
duration_ms=int((time.monotonic() - t0) * 1000),
stop_reason="no_submission", turns=turns,
)
return ReviewResult([], []), trace
finally:
await session.close()
return await call_with_fallback("validation", _run)
finally:
if trimmed_pdf != pdf_path:
Path(trimmed_pdf).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# PDF resolution
# ---------------------------------------------------------------------------
def _find_pdf(
mpn: str,
pdf_dir: Path,
storage=None,
) -> Path | None:
"""Find the datasheet PDF for an MPN. Checks local dir first,
then tries to download from the library.
"""
safe = safe_mpn(mpn)
local = pdf_dir / f"{safe}.pdf"
if local.is_file():
return local
if storage:
from backend.services import projects as proj_svc
lib_key = proj_svc.library_has_datasheet(storage, mpn)
if lib_key:
storage.download_to_local(lib_key, local)
if local.is_file():
return local
return None
# ---------------------------------------------------------------------------
# Main entrypoint
# ---------------------------------------------------------------------------
BeforeIcCallback = Callable[[str], Awaitable[bool]]
"""Gate callback — called with the IC ref before review. Return False to pause."""
OnIcDoneCallback = Callable[[str, "ReviewResult", "ApiLogger | None"], Awaitable[None]]
"""Callback after each IC finishes successfully — used to charge credits.
Receives the IC's private ``ApiLogger`` (the calls made during this review)
so the charge can be attributed to exactly this IC under concurrency."""
OnIcErrorCallback = Callable[[str, BaseException], Awaitable[None]]
"""Callback after an IC review raises — used to record a SkippedItem so the
failure surfaces in the project's skipped_components list."""
OnDedupeDoneCallback = Callable[["ApiLogger | None"], Awaitable[None]]
"""Callback after the cross-IC dedup pass finishes — used to charge for that
single LLM call (it runs once at end-of-run, outside any per-IC logger)."""
async def validate_design_async(
graph_path: str,
output_path: str,
datasheets_dir: str = "datasheets/extracted",
pdf_dir: str = "uploads/datasheets",
on_progress: ProgressCallback | None = None,
api_logger: ApiLogger | None = None,
storage=None,
skip_refs: set[str] | None = None,
before_ic: BeforeIcCallback | None = None,
on_ic_done: OnIcDoneCallback | None = None,
on_ic_error: OnIcErrorCallback | None = None,
on_dedupe_done: OnDedupeDoneCallback | None = None,
project_prefix: str | None = None,
run_meta: dict | None = None,
) -> ValidationReport:
"""Review every IC against its datasheet.
By default runs concurrently via an asyncio.Semaphore. When ``before_ic``
is supplied, reviews are executed sequentially so the callback can
decide whether to pause the run between ICs. In that mode the report
is written incrementally after each IC so a pause preserves all
completed findings.
``skip_refs`` is consumed on the first pass — any IC in the set is
skipped without starting a review (used to resume a paused run).
"""
skip_refs = skip_refs or set()
raw = json.loads(Path(graph_path).read_text())
graph = DesignGraph.model_validate(raw)
datasheets = _load_datasheets(datasheets_dir)
constraints_map = _build_constraints_map(datasheets)
# Deterministic graph checks (pin-mux feasibility, LED current). Pure
# functions of the graph; fail-soft. Seeded into all_findings below.
deterministic_findings = _run_deterministic_checks(graph, constraints_map)
pdf_dir_path = Path(pdf_dir)
# Collect ICs that have a datasheet PDF available
ic_tasks: list[tuple[str, str]] = [] # (ref, pdf_path)
not_reviewed: list[dict] = [] # ICs skipped for lack of a datasheet PDF
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
mpn = comp.mpn or comp.value
pdf = _find_pdf(mpn, pdf_dir_path, storage=storage)
if pdf:
ic_tasks.append((ref, str(pdf)))
else:
not_reviewed.append({"designator": ref, "reason": "no datasheet PDF"})
if on_progress:
await on_progress(ref, 0, "skipped", "no datasheet PDF")
# Load any previously-written report so we can accumulate findings
# across a pause/resume cycle without losing prior results.
existing_path = Path(output_path)
preserved_findings: list[Finding] = []
preserved_coverage: dict[str, list[str]] = {}
preserved_comments = None
if existing_path.is_file():
try:
existing = json.loads(existing_path.read_text())
preserved_comments = existing.get("comments")
if before_ic is not None:
# Resume mode — keep findings for refs we're about to skip
for f in existing.get("findings", []):
ref = f.get("component_ref") or f.get("designator") or ""
if ref in skip_refs:
preserved_findings.append(Finding.model_validate(f))
for ref, areas in (existing.get("coverage") or {}).items():
if ref in skip_refs:
preserved_coverage[ref] = list(areas)
except (json.JSONDecodeError, OSError):
pass
# Seed deterministic findings exactly once. On resume, preserved_findings may
# already contain them (they were written to the prior report), so strip any
# deterministic findings before re-seeding to avoid double-counting.
preserved_review = [f for f in preserved_findings if not _is_deterministic(f)]
all_findings: list[Finding] = list(preserved_review) + list(deterministic_findings)
all_coverage: dict[str, list[str]] = dict(preserved_coverage)
review_errors: dict[str, str] = {}
def _sanitize_coverage(src: dict[str, list[str]]) -> dict[str, list[str]]:
"""Drop any entries that aren't a list of strings so one IC's bad
payload can't fail the whole ValidationReport validation."""
clean: dict[str, list[str]] = {}
for ref, areas in src.items():
if isinstance(areas, list) and all(isinstance(a, str) for a in areas):
clean[ref] = areas
else:
print(f"[validation] dropping coverage for {ref}: {areas!r}")
return clean
def _write_report(paused: bool = False) -> ValidationReport:
assign_finding_ids(all_findings)
summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0}
for f in all_findings:
summary[f.status] = summary.get(f.status, 0) + 1
try:
report = ValidationReport(
project=Path(graph_path).stem,
timestamp=datetime.now(timezone.utc).isoformat(),
findings=all_findings,
summary=summary,
coverage=_sanitize_coverage(all_coverage),
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
)
except Exception as exc:
print(f"[validation] report build failed, retrying without coverage: {exc}")
report = ValidationReport(
project=Path(graph_path).stem,
timestamp=datetime.now(timezone.utc).isoformat(),
findings=all_findings,
summary=summary,
coverage={},
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
)
report_dict = json.loads(report.model_dump_json(indent=2))
if preserved_comments is not None:
report_dict["comments"] = preserved_comments
if paused:
report_dict["partial"] = True
existing_path.write_text(json.dumps(report_dict, indent=2))
return report
git_commit = (run_meta or {}).get("git_commit", "unknown")
def _write_trace(trace: dict, ref: str) -> None:
"""Persist a per-IC review trace. Best-effort: a trace failure must
never break the review, the report, or the pipeline."""
if not storage or not project_prefix or not trace:
return
try:
key = f"{project_prefix}/review_traces/{safe_mpn(ref)}.json"
storage.write_json(key, trace)
except Exception:
log.exception("trace: write failed for %s", ref)
async def _maybe_dedupe_cross_ic() -> None:
"""Collapse one interface defect reported from both ICs into a single
finding. Runs once, after all per-IC reviews, when findings span ≥2
ICs. Mutates ``all_findings`` in place. Best-effort: any failure keeps
the per-IC findings (the dedup function is itself fail-soft)."""
if not settings.cross_ic_dedup_enabled:
return
# Deterministic findings never enter the LLM dedupe — it has no datasheet
# basis to judge a pin-mux/LED finding, and merging could mangle them.
review = [f for f in all_findings if not _is_deterministic(f)]
deterministic = [f for f in all_findings if _is_deterministic(f)]
if len({f.designator for f in review}) < 2:
return # nothing cross-IC to merge
# Gated path: charge via a private logger merged by on_dedupe_done.
# Legacy path (no callback): log straight to the shared logger so the
# call still shows up in api_logs even though nothing is charged.
private = (
ApiLogger(free=api_logger.free)
if (api_logger is not None and on_dedupe_done is not None)
else None
)
try:
deduped, dedupe_trace = await dedupe_cross_ic_findings_async(
review,
api_logger=private if private is not None else api_logger,
on_progress=on_progress,
)
except Exception:
log.exception("cross-IC dedupe failed — keeping per-IC findings")
return
all_findings[:] = deduped + deterministic
if storage and project_prefix and dedupe_trace:
try:
storage.write_json(
f"{project_prefix}/review_traces/_cross_ic_dedupe.json",
dedupe_trace,
)
except Exception:
log.exception("trace: cross-IC dedupe write failed")
# Charge for the single dedup call (gated path only — the private
# logger merges into the shared log and bills exactly this call).
if private is not None and on_dedupe_done is not None:
try:
await on_dedupe_done(private)
except Exception:
log.exception("on_dedupe_done callback failed")
def _stub_trace(ref: str, error: str) -> dict:
"""Minimal trace for an IC whose review raised before producing one,
so an eval harness still sees a record for every attempted IC."""
try:
comp = graph.components.get(ref)
mpn = (comp.mpn or comp.value) if comp else ref
except Exception:
mpn = ref
return {
"trace_version": TRACE_VERSION,
"ic_ref": ref,
"mpn": mpn,
"git_commit": git_commit,
"datasheet": {"md5": None, "safe_mpn": safe_mpn(mpn)},
"timestamp": datetime.now(timezone.utc).isoformat(),
"turns": [],
"final_submission": None,
"result": None,
"stop_reason": "error",
"error": error,
"duration_ms": None,
}
# Cross-IC excerpt cache — symmetric interface checks (U2 fetches U3@X,
# U3 fetches U2@X) reuse the trimmed PDF instead of redoing pypdf work.
# LLM-side ephemeral cache can't span ICs (different conversation prefix),
# so the win here is purely pypdf I/O.
excerpt_cache: dict = {}
def _cleanup_excerpt_cache() -> None:
for entry in excerpt_cache.values():
try:
if isinstance(entry, tuple) and len(entry) == 2:
Path(entry[0]).unlink(missing_ok=True)
except Exception:
pass
if before_ic is None:
# Legacy concurrent path (no credit gate)
sem = asyncio.Semaphore(settings.ic_concurrency)
async def _review_one(ref: str, pdf_path: str) -> tuple[ReviewResult, dict]:
async with sem:
return await review_ic_async(
graph, constraints_map, ref, pdf_path,
on_progress=on_progress, api_logger=api_logger,
trace_git_commit=git_commit,
pdf_dir=pdf_dir_path, storage=storage,
excerpt_cache=excerpt_cache,
)
results = await asyncio.gather(
*(_review_one(ref, pdf) for ref, pdf in ic_tasks if ref not in skip_refs),
return_exceptions=True,
)
remaining_tasks = [t for t in ic_tasks if t[0] not in skip_refs]
for i, result in enumerate(results):
ref = remaining_tasks[i][0]
if isinstance(result, BaseException):
msg = f"{type(result).__name__}: {result}"
log.exception("Review failed for %s", ref, exc_info=result)
review_errors[ref] = msg
_write_trace(_stub_trace(ref, msg), ref)
if on_progress:
await on_progress(ref, 0, "error", msg)
if on_ic_error is not None:
try:
await on_ic_error(ref, result)
except Exception:
log.exception("on_ic_error callback failed for %s", ref)
elif isinstance(result, tuple):
rr, trace = result
_write_trace(trace, ref)
all_findings.extend(rr.findings)
if rr.checked_areas:
all_coverage[ref] = rr.checked_areas
await _maybe_dedupe_cross_ic()
try:
return _write_report(paused=False)
finally:
_cleanup_excerpt_cache()
# Gated concurrent path — used by the pipeline with credit enforcement.
# Runs up to ``ic_concurrency`` reviews in parallel while keeping the
# per-IC credit gate, incremental report/trace writes, and the charging
# callback. Each IC reviews against a private ApiLogger so concurrent
# reviews don't interleave their API entries — on_ic_done charges exactly
# that IC's calls.
sem = asyncio.Semaphore(settings.ic_concurrency)
stop = False # set once a gate trips — stops *starting* new reviews
async def _gated_review_one(ref: str, pdf_path: str) -> None:
nonlocal stop
async with sem:
if stop:
return
try:
ok = await before_ic(ref)
except Exception:
ok = True
if not ok:
# Out of credits — don't start this or any further IC.
stop = True
return
private = ApiLogger(free=api_logger.free) if api_logger is not None else None
try:
result, trace = await review_ic_async(
graph, constraints_map, ref, pdf_path,
on_progress=on_progress, api_logger=private,
trace_git_commit=git_commit,
pdf_dir=pdf_dir_path, storage=storage,
excerpt_cache=excerpt_cache,
)
except Exception as exc:
msg = f"{type(exc).__name__}: {exc}"
log.exception("Review failed for %s", ref)
review_errors[ref] = msg
_write_trace(_stub_trace(ref, msg), ref)
if on_progress:
await on_progress(ref, 0, "error", msg)
if on_ic_error is not None:
try:
await on_ic_error(ref, exc)
except Exception:
log.exception("on_ic_error callback failed for %s", ref)
# Persist the error into the report so the run finishes with a
# complete picture even if every IC fails.
try:
_write_report(paused=False)
except Exception:
log.exception("incremental report write failed after error on %s", ref)
return
# Merge results — synchronous block, atomic under asyncio (no await
# until the trailing callbacks), so concurrent completions can't
# corrupt all_findings / all_coverage.
all_findings.extend(result.findings)
if result.checked_areas:
all_coverage[ref] = result.checked_areas
# Incremental write — preserves state if the process dies.
# Never let a single IC's bad payload kill the whole pipeline.
try:
_write_report(paused=False)
except Exception as exc:
print(f"[validation] incremental write failed after {ref}: {exc}")
all_coverage.pop(ref, None)
if on_progress:
await on_progress(ref, 0, "warning", f"report write failed: {exc}")
# Per-IC trace flush — written as each IC completes so a cancel/pause
# preserves every completed trace.
_write_trace(trace, ref)
if on_ic_done is not None:
try:
await on_ic_done(ref, result, private)
except Exception:
log.exception("on_ic_done callback failed for %s", ref)
results = await asyncio.gather(
*(_gated_review_one(ref, pdf) for ref, pdf in ic_tasks if ref not in skip_refs),
return_exceptions=True,
)
# Surface a hard cancellation so the pipeline's run handler cleans up.
# Per-IC review failures stay isolated (captured into review_errors above).
for r in results:
if isinstance(r, asyncio.CancelledError):
raise r
# Dedup only a *complete* run — a paused/partial run may gain more
# findings on resume, and merging now could collapse a pair before its
# counterpart exists.
if not stop:
await _maybe_dedupe_cross_ic()
try:
return _write_report(paused=bool(stop))
finally:
_cleanup_excerpt_cache()