Keep validate.py and the finding engine as-is. AGPL LICENSE stays at the repo root. Docker overlays dependency then src. Do not delete the inherited tree.
1207 lines
45 KiB
Python
1207 lines
45 KiB
Python
"""Project CRUD and file upload endpoints."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import JSONResponse, Response
|
|
from pydantic import BaseModel
|
|
|
|
MAX_UPLOAD_BYTES = 30 * 1024 * 1024 # 30 MB
|
|
|
|
from backend.config import settings
|
|
from backend.periscopex.utils import safe_mpn
|
|
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
|
from backend.services import projects as proj_svc
|
|
|
|
router = APIRouter(tags=["projects"])
|
|
|
|
|
|
def _bom_file_to_csv_bytes(path: Path) -> bytes | None:
|
|
"""Normalize a BOM found inside a KiCad zip/folder to CSV bytes."""
|
|
suffix = path.suffix.lower()
|
|
raw = path.read_bytes()
|
|
if suffix == ".csv":
|
|
return raw
|
|
if suffix == ".xlsx":
|
|
try:
|
|
import csv as csv_mod
|
|
import io
|
|
|
|
import openpyxl
|
|
|
|
wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
|
ws = wb.active
|
|
out = io.StringIO()
|
|
writer = csv_mod.writer(out)
|
|
for row in ws.iter_rows(values_only=True):
|
|
writer.writerow([("" if c is None else str(c)) for c in row])
|
|
wb.close()
|
|
return out.getvalue().encode("utf-8")
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
# --- Library check ---
|
|
|
|
|
|
class LibraryCheckRequest(BaseModel):
|
|
ic_mpns: list[str] = []
|
|
passive_mpns: list[str] = []
|
|
simple_mpns: list[str] = []
|
|
|
|
|
|
@router.post("/library/check")
|
|
async def check_library(req: LibraryCheckRequest, request: Request):
|
|
"""Check which MPNs are already resolved in the global library."""
|
|
storage = get_storage(request)
|
|
ic_resolved = [mpn for mpn in req.ic_mpns if proj_svc.library_has_extraction(storage, mpn)]
|
|
|
|
patterns = proj_svc.load_library_patterns(storage) if req.passive_mpns else []
|
|
|
|
passive_resolved: list[str] = []
|
|
if req.passive_mpns:
|
|
from backend.periscopex.resolve_passives import resolve_mpn
|
|
|
|
passive_resolved = [
|
|
mpn for mpn in req.passive_mpns
|
|
if resolve_mpn(mpn, patterns) is not None
|
|
or proj_svc.library_has_passive_model(storage, mpn) is not None
|
|
]
|
|
|
|
simple_resolved = [mpn for mpn in req.simple_mpns if proj_svc.library_has_model(storage, mpn)]
|
|
|
|
# Check which MPNs already have datasheets in the library
|
|
all_mpns = set(req.ic_mpns + req.passive_mpns + req.simple_mpns)
|
|
datasheets_available = [
|
|
mpn for mpn in all_mpns
|
|
if proj_svc.library_has_datasheet(storage, mpn, patterns=patterns)
|
|
]
|
|
|
|
return {
|
|
"ic_resolved": ic_resolved,
|
|
"passive_resolved": passive_resolved,
|
|
"simple_resolved": simple_resolved,
|
|
"datasheets_available": datasheets_available,
|
|
}
|
|
|
|
|
|
@router.get("/library")
|
|
async def get_library(request: Request):
|
|
"""List chips, passives, discrete specs, and datasheets in the shared library."""
|
|
storage = get_storage(request)
|
|
return JSONResponse(
|
|
content=proj_svc.list_library_catalog(storage),
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
@router.get("/library/datasheet/{mpn:path}")
|
|
async def get_library_datasheet(mpn: str, request: Request):
|
|
"""Stream a datasheet PDF from the shared library."""
|
|
storage = get_storage(request)
|
|
key = proj_svc.library_has_datasheet(storage, mpn)
|
|
if not key:
|
|
raise HTTPException(404, f"Datasheet not in library: {mpn}")
|
|
data = storage.read_bytes(key)
|
|
return Response(
|
|
content=data,
|
|
media_type="application/pdf",
|
|
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
|
|
)
|
|
|
|
|
|
class CreateProjectRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
# --- CRUD ---
|
|
|
|
|
|
@router.post("/projects")
|
|
async def create_project(req: CreateProjectRequest, request: Request):
|
|
"""Create a new project. No per-user project cap — credits are the rate limiter."""
|
|
storage = get_storage(request)
|
|
user_id = get_user_id(request)
|
|
meta = proj_svc.create_project(storage, user_id, req.name)
|
|
return meta.model_dump()
|
|
|
|
|
|
@router.get("/projects")
|
|
async def list_projects(request: Request):
|
|
storage = get_storage(request)
|
|
user_id = get_user_id(request)
|
|
owned = proj_svc.list_projects(storage, user_id)
|
|
shared = proj_svc.list_shared_projects(storage, user_id)
|
|
return [m.model_dump() for m in owned + shared]
|
|
|
|
|
|
@router.get("/projects/{project_id}")
|
|
async def get_project(project_id: str, request: Request):
|
|
storage = get_storage(request)
|
|
owner_user_id, meta = await resolve_or_404(request, project_id)
|
|
healed = proj_svc.heal_if_pipeline_finished(storage, owner_user_id, project_id)
|
|
if healed is not None:
|
|
meta = healed
|
|
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
|
|
if healed_pl is not None:
|
|
meta = healed_pl
|
|
healed_pcb = proj_svc.heal_if_pcb_stuck(storage, owner_user_id, project_id)
|
|
if healed_pcb is not None:
|
|
meta = healed_pcb
|
|
return meta.model_dump()
|
|
|
|
|
|
@router.delete("/projects/{project_id}")
|
|
async def delete_project(project_id: str, request: Request):
|
|
storage = get_storage(request)
|
|
user_id = get_user_id(request)
|
|
|
|
# If user is the owner, delete the project
|
|
if proj_svc.delete_project(storage, user_id, project_id):
|
|
return {"ok": True}
|
|
|
|
# If user is a collaborator, remove themselves instead of deleting
|
|
result = proj_svc.resolve_project_access(storage, user_id, project_id)
|
|
if result:
|
|
owner_user_id, _ = result
|
|
proj_svc.remove_collaborator(storage, owner_user_id, project_id, user_id)
|
|
return {"ok": True, "removed_self": True}
|
|
|
|
raise HTTPException(404, "Project not found")
|
|
|
|
|
|
# --- Reopen (cancelled / error / complete → draft, for rerun) ---
|
|
|
|
|
|
class RenameRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
@router.patch("/projects/{project_id}")
|
|
async def rename_project(project_id: str, req: RenameRequest, request: Request):
|
|
"""Update a project's display name."""
|
|
storage = get_storage(request)
|
|
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
owner_user_id, _ = result
|
|
name = req.name.strip()
|
|
if not name:
|
|
raise HTTPException(400, "Name must be non-empty")
|
|
meta = proj_svc.update_project(storage, owner_user_id, project_id, name=name)
|
|
return meta.model_dump()
|
|
|
|
|
|
@router.post("/projects/{project_id}/reopen")
|
|
async def reopen_project(project_id: str, request: Request):
|
|
"""Flip a finished-state project back to draft so the user can rerun it.
|
|
|
|
Preserves uploads, column mappings, power-source hints, extraction
|
|
cache, and historical spend. Clears pipeline artifacts (graph, report,
|
|
etc.) and the pause/review bookkeeping so the next run starts clean.
|
|
"""
|
|
storage = get_storage(request)
|
|
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
owner_user_id, meta = result
|
|
if meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED):
|
|
raise HTTPException(409, "Pipeline is running; cancel it before reopening")
|
|
meta = proj_svc.reopen_project(storage, owner_user_id, project_id)
|
|
return meta.model_dump()
|
|
|
|
|
|
# --- File downloads + datasheet inventory (for rerun prefill) ---
|
|
|
|
|
|
@router.get("/projects/{project_id}/files/bom")
|
|
async def download_bom(project_id: str, request: Request):
|
|
storage = get_storage(request)
|
|
owner_user_id, _ = await resolve_or_404(request, project_id)
|
|
key = proj_svc.get_bom_key(storage, owner_user_id, project_id)
|
|
if not key:
|
|
raise HTTPException(404, "BOM not uploaded")
|
|
return Response(
|
|
content=storage.read_bytes(key),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": 'attachment; filename="bom.csv"'},
|
|
)
|
|
|
|
|
|
@router.get("/projects/{project_id}/files/netlist")
|
|
async def download_netlist(project_id: str, request: Request):
|
|
storage = get_storage(request)
|
|
owner_user_id, _ = await resolve_or_404(request, project_id)
|
|
key = proj_svc.get_netlist_key(storage, owner_user_id, project_id)
|
|
if not key:
|
|
raise HTTPException(404, "Netlist not uploaded")
|
|
# Reflect the stored extension (.asc for PADS, .edn for EDIF) in the
|
|
# download filename so the user gets back what they uploaded.
|
|
ext = key.rsplit(".", 1)[-1] if "." in key.rsplit("/", 1)[-1] else "asc"
|
|
return Response(
|
|
content=storage.read_bytes(key),
|
|
media_type="text/plain",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="netlist.{ext}"',
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/projects/{project_id}/netlist/subdesigns")
|
|
async def get_netlist_subdesigns(project_id: str, request: Request):
|
|
"""Return the sub-design layout of an uploaded EDIF netlist.
|
|
|
|
Re-parses the stored ``.edn`` file. For PADS netlists or single-sub-design
|
|
EDIF, returns an empty list. Also returns the currently-saved
|
|
``selected`` list (None = "include everything") so the wizard can render
|
|
the picker pre-populated.
|
|
"""
|
|
from backend.periscopex.parsers_edif import list_edif_subdesigns
|
|
import tempfile, os
|
|
|
|
storage = get_storage(request)
|
|
owner_user_id, meta = await resolve_or_404(request, project_id)
|
|
if meta.netlist_format != "edif":
|
|
return {"sub_designs": [], "selected": None}
|
|
key = proj_svc.get_netlist_key(storage, owner_user_id, project_id)
|
|
if not key:
|
|
return {"sub_designs": [], "selected": None}
|
|
data = storage.read_bytes(key)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".edn")
|
|
try:
|
|
tmp.write(data)
|
|
tmp.close()
|
|
subs = list_edif_subdesigns(tmp.name)
|
|
finally:
|
|
os.unlink(tmp.name)
|
|
return {"sub_designs": subs, "selected": meta.netlist_subdesigns}
|
|
|
|
|
|
class NetlistSubdesignsUpdate(BaseModel):
|
|
selected: list[str] | None # null = include every sub-design
|
|
|
|
|
|
@router.put("/projects/{project_id}/netlist/subdesigns")
|
|
async def set_netlist_subdesigns(
|
|
project_id: str, payload: NetlistSubdesignsUpdate, request: Request,
|
|
):
|
|
"""Persist the user's sub-design selection for an EDIF netlist.
|
|
|
|
``selected = null`` means "include every sub-design" (the default and
|
|
only meaningful value for PADS / single-sub-design EDIF). Pipeline runs
|
|
pass this list to the parser to filter instances/nets.
|
|
"""
|
|
storage = get_storage(request)
|
|
user_id = get_user_id(request)
|
|
result = proj_svc.resolve_project_access(storage, user_id, project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
owner_user_id = result[0]
|
|
cleaned = [s.strip() for s in (payload.selected or []) if s and s.strip()]
|
|
meta = proj_svc.update_project(
|
|
storage, owner_user_id, project_id,
|
|
netlist_subdesigns=cleaned if payload.selected is not None else None,
|
|
)
|
|
return meta.model_dump()
|
|
|
|
|
|
@router.get("/projects/{project_id}/files/datasheets")
|
|
async def list_datasheets(project_id: str, request: Request):
|
|
"""List safe-MPN stems for datasheets uploaded to this project.
|
|
|
|
Returned stems are the filename prefix (filename without ``.pdf``).
|
|
The frontend classifies the BOM to recover MPNs and matches each
|
|
against these stems via its own safe_mpn() mirror.
|
|
"""
|
|
storage = get_storage(request)
|
|
owner_user_id, _ = await resolve_or_404(request, project_id)
|
|
stems = proj_svc.list_project_datasheets(storage, owner_user_id, project_id)
|
|
return {"stems": sorted(stems)}
|
|
|
|
|
|
# --- File uploads ---
|
|
|
|
|
|
@router.post("/projects/{project_id}/upload/bom")
|
|
async def upload_bom(
|
|
project_id: str,
|
|
file: UploadFile,
|
|
request: Request,
|
|
reference_column: str = "Reference",
|
|
mpn_column: str = "Manufacturer Part Number",
|
|
column_is_lcsc: bool | None = None,
|
|
):
|
|
storage = get_storage(request)
|
|
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
user_id = result[0] # owner_user_id for storage paths
|
|
data = await file.read()
|
|
if len(data) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
|
|
# Convert xlsx to CSV if needed
|
|
filename = file.filename or ""
|
|
if filename.lower().endswith(".xlsx"):
|
|
try:
|
|
import io, openpyxl, csv as csv_mod
|
|
|
|
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
|
ws = wb.active
|
|
out = io.StringIO()
|
|
writer = csv_mod.writer(out)
|
|
for row in ws.iter_rows(values_only=True):
|
|
writer.writerow([("" if c is None else str(c)) for c in row])
|
|
wb.close()
|
|
data = out.getvalue().encode("utf-8")
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Invalid Excel file: {e}")
|
|
|
|
# Validate by attempting to parse
|
|
import os
|
|
import tempfile
|
|
|
|
from backend.periscopex.parsers import parse_bom
|
|
|
|
try:
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
|
|
tmp.write(data)
|
|
tmp.close()
|
|
bom = parse_bom(tmp.name, reference_col=reference_column, mpn_col=mpn_column)
|
|
os.unlink(tmp.name)
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Invalid BOM file: {e}")
|
|
|
|
# If the chosen MPN column is entirely LCSC ids, resolve them all to
|
|
# real manufacturer part numbers before storing the BOM. Column-level
|
|
# only: every non-empty cell must match ^C\d+$, or the column is left
|
|
# alone. Mixed BOMs (some real MPNs, some LCSC ids) are out of scope —
|
|
# users must pick a single representation per column. The resolved
|
|
# mapping is also stashed in project metadata so the wizard UI can
|
|
# surface "C12044 → STM32F103C8T6" to the user.
|
|
lcsc_resolved = 0
|
|
lcsc_detected = False
|
|
lcsc_map: dict[str, str] = {}
|
|
lcsc_payloads: dict[str, dict] = {}
|
|
try:
|
|
from backend.services.purple_parts import (
|
|
detect_lcsc_column, resolve_lcsc_column_bytes,
|
|
)
|
|
force_lcsc = column_is_lcsc
|
|
lcsc_detected = detect_lcsc_column(data, mpn_column)
|
|
if force_lcsc or lcsc_detected:
|
|
data, lcsc_resolved, lcsc_map, lcsc_payloads = await resolve_lcsc_column_bytes(
|
|
data, mpn_col=mpn_column,
|
|
)
|
|
except Exception:
|
|
# Resolver failures must not block uploads — pipeline-stage resolver
|
|
# is still a backstop, and the user can manually upload datasheets.
|
|
import logging
|
|
logging.getLogger(__name__).warning("purple-parts BOM resolve failed", exc_info=True)
|
|
|
|
# If the LCSC rewrite ran, reparse the BOM so component classification
|
|
# below sees the resolved MPNs (and so the count we return matches what
|
|
# the pipeline will see).
|
|
if lcsc_resolved:
|
|
try:
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
|
|
tmp.write(data)
|
|
tmp.close()
|
|
bom = parse_bom(tmp.name, reference_col=reference_column, mpn_col=mpn_column)
|
|
os.unlink(tmp.name)
|
|
except Exception:
|
|
import logging
|
|
logging.getLogger(__name__).warning(
|
|
"Re-parse after LCSC rewrite failed; using pre-rewrite BOM for classification",
|
|
exc_info=True,
|
|
)
|
|
|
|
# Classify components at upload time so the wizard can render the right
|
|
# per-row resolution UI (ic → datasheet upload, passive → lcsc-resolve,
|
|
# simple → datasheet upload). Mirrors the bucket logic in
|
|
# services/pipeline.py:_stage_bom_parse so the field is correct after
|
|
# either path runs.
|
|
from backend.periscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
|
|
|
ic_mpns: list[str] = []
|
|
passive_mpns: list[str] = []
|
|
simple_mpns: list[str] = []
|
|
_seen_ic: set[str] = set()
|
|
_seen_passive: set[str] = set()
|
|
_seen_simple: set[str] = set()
|
|
for ref, info in sorted(bom.items()):
|
|
mpn = info.get("mpn")
|
|
if not mpn:
|
|
continue
|
|
typ = type_for_ref(ref)
|
|
if typ == "ic":
|
|
if mpn not in _seen_ic:
|
|
_seen_ic.add(mpn)
|
|
ic_mpns.append(mpn)
|
|
elif typ == "passive":
|
|
if mpn not in _seen_passive:
|
|
_seen_passive.add(mpn)
|
|
passive_mpns.append(mpn)
|
|
elif typ and typ in SIMPLE_TYPES:
|
|
if mpn not in _seen_simple:
|
|
_seen_simple.add(mpn)
|
|
simple_mpns.append(mpn)
|
|
|
|
# Real-MPN BOMs: enrich passives from the LCSC catalogue by reverse
|
|
# MPN lookup so the wizard can pre-resolve their specs through the exact
|
|
# same machinery as the LCSC-column path. We populate the LCSC maps keyed
|
|
# by the catalogue's LCSC id, so the wizard's mpn→lcsc map lights up the
|
|
# "Resolving Passive Specs" step and /lcsc/resolve-passive handles them.
|
|
# Genuine MPN columns only — skip when the column was LCSC ids (handled
|
|
# above) so we never feed raw LCSC ids into by-mpn.
|
|
if (
|
|
settings.use_purple_parts
|
|
and not lcsc_detected
|
|
and not column_is_lcsc
|
|
and passive_mpns
|
|
):
|
|
try:
|
|
from backend.services.purple_parts import lookup_mpn_batch
|
|
parts = await lookup_mpn_batch(passive_mpns)
|
|
for pmpn, part in parts.items():
|
|
if part and part.get("lcsc") and part.get("description"):
|
|
lcsc_map[part["lcsc"]] = pmpn
|
|
lcsc_payloads[part["lcsc"]] = dict(part)
|
|
except Exception:
|
|
import logging
|
|
logging.getLogger(__name__).warning(
|
|
"purple-parts by-mpn passive enrich failed", exc_info=True,
|
|
)
|
|
|
|
key = proj_svc.save_bom(storage, user_id, project_id, data)
|
|
# Store column mappings for the pipeline to use.
|
|
# Stash the LCSC → MPN map (if any) so the wizard UI can render it.
|
|
update_kwargs: dict = {
|
|
"bom_columns": {"reference": reference_column, "mpn": mpn_column},
|
|
"component_mpns": {
|
|
"ic": ic_mpns,
|
|
"passive": passive_mpns,
|
|
"simple": simple_mpns,
|
|
},
|
|
}
|
|
if lcsc_map:
|
|
update_kwargs["lcsc_to_mpn"] = lcsc_map
|
|
if lcsc_payloads:
|
|
update_kwargs["lcsc_payloads"] = lcsc_payloads
|
|
proj_svc.update_project(storage, user_id, project_id, **update_kwargs)
|
|
return {
|
|
"path": key,
|
|
"components": len(bom),
|
|
"lcsc_resolved": lcsc_resolved,
|
|
"lcsc_detected": lcsc_detected,
|
|
"lcsc_to_mpn": lcsc_map,
|
|
}
|
|
|
|
|
|
@router.post("/projects/{project_id}/upload/netlist")
|
|
async def upload_netlist(
|
|
project_id: str,
|
|
request: Request,
|
|
file: UploadFile | None = File(default=None),
|
|
files: list[UploadFile] | None = File(default=None),
|
|
paths: str | None = Form(default=None),
|
|
):
|
|
storage = get_storage(request)
|
|
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
user_id = result[0] # owner_user_id for storage paths
|
|
|
|
from backend.periscopex.netlist_bundle import materialize_netlist_upload
|
|
from backend.periscopex.parsers import parse_netlist_any, validate_netlist
|
|
from backend.periscopex.parsers_edif import list_edif_subdesigns
|
|
import tempfile
|
|
|
|
blobs: list[tuple[str, bytes]] = []
|
|
seen: set[tuple[str, int]] = set()
|
|
uploads = list(files or []) if files else ([file] if file is not None else [])
|
|
rels: list[str] | None = None
|
|
if paths:
|
|
try:
|
|
parsed_paths = json.loads(paths)
|
|
except json.JSONDecodeError:
|
|
parsed_paths = None
|
|
if isinstance(parsed_paths, list) and all(isinstance(x, str) for x in parsed_paths):
|
|
rels = parsed_paths
|
|
for i, uf in enumerate(uploads):
|
|
data = await uf.read()
|
|
if len(data) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(
|
|
413,
|
|
f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)",
|
|
)
|
|
name = (
|
|
rels[i]
|
|
if rels is not None and i < len(rels)
|
|
else (uf.filename or "netlist")
|
|
)
|
|
mark = (name, len(data))
|
|
if mark in seen:
|
|
continue
|
|
seen.add(mark)
|
|
blobs.append((name, data))
|
|
if not blobs:
|
|
raise HTTPException(400, "No netlist file uploaded")
|
|
|
|
sub_designs: list[dict] = []
|
|
bom_saved = False
|
|
pcb_saved = False
|
|
sheets = 1
|
|
try:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
parsed = materialize_netlist_upload(blobs, Path(tmp) / "work")
|
|
parts, nets, fmt = parse_netlist_any(parsed.root)
|
|
if fmt == "edif":
|
|
sub_designs = list_edif_subdesigns(parsed.root)
|
|
issues = validate_netlist(parts, nets)
|
|
if issues:
|
|
raise ValueError("; ".join(issues))
|
|
root_bytes = parsed.root.read_bytes()
|
|
key = proj_svc.save_netlist(storage, user_id, project_id, root_bytes, fmt=fmt)
|
|
if fmt == "kicad_sch":
|
|
proj_svc.save_companion_sheets(
|
|
storage, user_id, project_id, parsed.root, parsed.extra_sch,
|
|
)
|
|
sheets = 1 + len(parsed.extra_sch)
|
|
else:
|
|
proj_svc.clear_companion_sheets(storage, user_id, project_id)
|
|
if parsed.pcb is not None:
|
|
proj_svc.save_pcb(storage, user_id, project_id, parsed.pcb.read_bytes())
|
|
pcb_saved = True
|
|
if parsed.bom is not None:
|
|
bom_bytes = _bom_file_to_csv_bytes(parsed.bom)
|
|
if bom_bytes:
|
|
proj_svc.save_bom(storage, user_id, project_id, bom_bytes)
|
|
proj_svc.update_project(
|
|
storage, user_id, project_id,
|
|
bom_columns={
|
|
"reference": "Reference",
|
|
"mpn": "Manufacturer Part Number",
|
|
},
|
|
)
|
|
bom_saved = True
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Netlist failed sanity check: {e}") from e
|
|
|
|
designator_pins: list[dict] = []
|
|
if fmt != "pads":
|
|
designator_pins = _build_designator_pins(parts, nets)
|
|
return {
|
|
"path": key,
|
|
"parts": len(parts),
|
|
"nets": len(nets),
|
|
"format": fmt,
|
|
"sub_designs": sub_designs,
|
|
"designator_pins": designator_pins,
|
|
"pcb_saved": pcb_saved,
|
|
"bom_saved": bom_saved,
|
|
"sheets": sheets,
|
|
}
|
|
|
|
|
|
@router.post("/projects/{project_id}/upload/pcb")
|
|
async def upload_pcb(project_id: str, file: UploadFile, request: Request):
|
|
storage = get_storage(request)
|
|
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
user_id = result[0]
|
|
data = await file.read()
|
|
if len(data) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
|
|
import tempfile, os
|
|
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
|
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb")
|
|
try:
|
|
tmp.write(data)
|
|
tmp.close()
|
|
layout = parse_kicad_pcb(tmp.name)
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Invalid KiCad PCB: {e}")
|
|
finally:
|
|
os.unlink(tmp.name)
|
|
key = proj_svc.save_pcb(storage, user_id, project_id, data)
|
|
return {
|
|
"path": key,
|
|
"footprints": len(layout.footprints),
|
|
"nets": len(layout.nets),
|
|
"segments": len(layout.segments),
|
|
}
|
|
|
|
|
|
def _build_designator_pins(
|
|
parts: dict[str, str],
|
|
nets: dict[str, list[tuple[str, str]]],
|
|
) -> list[dict]:
|
|
"""Flatten parsed netlist into [{ref, pins:[{number, net_name}]}].
|
|
|
|
Inverts the net→[(ref, pin)] adjacency from ``parse_netlist_any`` into
|
|
a per-designator list. Output order matches the PADS browser preview
|
|
(natural sort on refs and on pin numbers) so the wizard's dropdowns
|
|
look identical regardless of netlist format.
|
|
"""
|
|
from backend.periscopex.utils import natural_sort_key
|
|
|
|
by_ref: dict[str, dict[str, str]] = {ref: {} for ref in parts}
|
|
for net_name, pins in nets.items():
|
|
for ref, pin in pins:
|
|
ref_pins = by_ref.setdefault(ref, {})
|
|
ref_pins.setdefault(pin, net_name)
|
|
|
|
out: list[dict] = []
|
|
for ref in sorted(by_ref, key=natural_sort_key):
|
|
pin_map = by_ref[ref]
|
|
sorted_pins = [
|
|
{"number": num, "net_name": pin_map[num]}
|
|
for num in sorted(pin_map, key=natural_sort_key)
|
|
]
|
|
out.append({"ref": ref, "pins": sorted_pins})
|
|
return out
|
|
|
|
|
|
class DatasheetUploadMeta(BaseModel):
|
|
mpn: str
|
|
|
|
|
|
@router.post("/projects/{project_id}/upload/datasheets")
|
|
async def upload_datasheets(
|
|
project_id: str, file: UploadFile, mpn: str,
|
|
request: Request, also_for: str | None = None,
|
|
):
|
|
"""Upload a datasheet PDF for a specific MPN, optionally saving for additional MPNs."""
|
|
storage = get_storage(request)
|
|
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
user_id = result[0] # owner_user_id for storage paths
|
|
if not file.filename or not file.filename.lower().endswith(".pdf"):
|
|
raise HTTPException(400, "File must be a PDF")
|
|
data = await file.read()
|
|
if len(data) > MAX_UPLOAD_BYTES:
|
|
size_mb = len(data) / 1024 / 1024
|
|
raise HTTPException(
|
|
413,
|
|
f"{file.filename or mpn} is {size_mb:.1f} MB — exceeds {MAX_UPLOAD_BYTES // 1024 // 1024} MB limit",
|
|
)
|
|
key = proj_svc.save_datasheet(storage, user_id, project_id, mpn, data)
|
|
# Save same file under additional MPN names (for shared passive datasheets)
|
|
extra_mpns: list[str] = []
|
|
if also_for:
|
|
for extra_mpn in also_for.split(","):
|
|
extra_mpn = extra_mpn.strip()
|
|
if extra_mpn:
|
|
proj_svc.save_datasheet(storage, user_id, project_id, extra_mpn, data)
|
|
extra_mpns.append(extra_mpn)
|
|
return {"path": key, "mpn": mpn, "also_for": extra_mpns}
|
|
|
|
|
|
# --- Collaborators ---
|
|
|
|
|
|
class AddCollaboratorRequest(BaseModel):
|
|
email: str
|
|
|
|
|
|
@router.get("/projects/{project_id}/collaborators")
|
|
async def list_collaborators(project_id: str, request: Request):
|
|
"""List collaborators for a project. Accessible by owner and collaborators."""
|
|
storage = get_storage(request)
|
|
user_id = get_user_id(request)
|
|
result = proj_svc.resolve_project_access(storage, user_id, project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
owner_user_id, meta = result
|
|
|
|
# Build member list: owner first, then collaborators
|
|
all_user_ids = [owner_user_id] + [c for c in meta.collaborators if c != owner_user_id]
|
|
collaborators = []
|
|
if settings.use_auth:
|
|
from backend.services.user_directory import get_user_profile
|
|
|
|
for uid in all_user_ids:
|
|
profile = await get_user_profile(uid)
|
|
collaborators.append({
|
|
"user_id": uid,
|
|
"name": profile.get("name"),
|
|
"email": profile.get("email"),
|
|
"image_url": profile.get("image_url"),
|
|
"role": "owner" if uid == owner_user_id else "collaborator",
|
|
})
|
|
else:
|
|
# Local dev — just return user_ids without enrichment
|
|
collaborators = [
|
|
{"user_id": uid, "name": None, "email": None, "image_url": None,
|
|
"role": "owner" if uid == owner_user_id else "collaborator"}
|
|
for uid in all_user_ids
|
|
]
|
|
|
|
return {"owner_user_id": owner_user_id, "collaborators": collaborators}
|
|
|
|
|
|
@router.post("/projects/{project_id}/collaborators")
|
|
async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request: Request):
|
|
"""Add a collaborator by email. Owner only."""
|
|
storage = get_storage(request)
|
|
user_id = get_user_id(request)
|
|
|
|
# Only the owner can add collaborators
|
|
meta = proj_svc.get_project(storage, user_id, project_id)
|
|
if not meta:
|
|
raise HTTPException(404, "Project not found")
|
|
|
|
if not settings.use_auth:
|
|
raise HTTPException(400, "Collaboration requires authentication to be enabled")
|
|
|
|
from backend.services.user_directory import find_user_id_by_email, get_user_profile
|
|
|
|
collab_user_id = await find_user_id_by_email(req.email)
|
|
if not collab_user_id:
|
|
raise HTTPException(404, "No user found with that email")
|
|
|
|
# Can't add yourself
|
|
if collab_user_id == user_id:
|
|
raise HTTPException(400, "Cannot add yourself as a collaborator")
|
|
|
|
# Check if already a collaborator
|
|
if collab_user_id in meta.collaborators:
|
|
raise HTTPException(409, "User is already a collaborator")
|
|
|
|
proj_svc.add_collaborator(storage, user_id, project_id, collab_user_id)
|
|
|
|
profile = await get_user_profile(collab_user_id)
|
|
return {
|
|
"user_id": collab_user_id,
|
|
"name": profile.get("name"),
|
|
"email": profile.get("email"),
|
|
"image_url": profile.get("image_url"),
|
|
}
|
|
|
|
|
|
@router.delete("/projects/{project_id}/collaborators/{collaborator_user_id}")
|
|
async def remove_collaborator(project_id: str, collaborator_user_id: str, request: Request):
|
|
"""Remove a collaborator. Owner or admin."""
|
|
from backend.routers.admin import is_admin
|
|
|
|
storage = get_storage(request)
|
|
user_id = get_user_id(request)
|
|
|
|
meta = proj_svc.get_project(storage, user_id, project_id)
|
|
owner_user_id = user_id
|
|
if not meta:
|
|
# Admin can remove a collaborator from a project they don't own.
|
|
if not await is_admin(request):
|
|
raise HTTPException(404, "Project not found")
|
|
result = proj_svc.find_project_any_user(storage, project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
owner_user_id, meta = result
|
|
|
|
if collaborator_user_id not in meta.collaborators:
|
|
raise HTTPException(404, "User is not a collaborator")
|
|
|
|
proj_svc.remove_collaborator(storage, owner_user_id, project_id, collaborator_user_id)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/projects/{project_id}/collaborators/{collaborator_user_id}/make-owner")
|
|
async def make_collaborator_owner(
|
|
project_id: str, collaborator_user_id: str, request: Request,
|
|
):
|
|
"""Promote a collaborator to owner. Admin only.
|
|
|
|
Used when Sid creates a project on behalf of another user and needs to
|
|
hand it off cleanly. The current owner is demoted to a collaborator;
|
|
Sid (or any admin) can then remove themselves via DELETE in a second
|
|
action.
|
|
"""
|
|
from backend.routers.admin import is_admin
|
|
|
|
if not await is_admin(request):
|
|
raise HTTPException(403, "Admin access required")
|
|
|
|
storage = get_storage(request)
|
|
result = proj_svc.find_project_any_user(storage, project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
current_owner_user_id, _ = result
|
|
|
|
try:
|
|
proj_svc.transfer_ownership(
|
|
storage, current_owner_user_id, project_id, collaborator_user_id,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(400, str(exc)) from exc
|
|
|
|
return {"ok": True, "owner_user_id": collaborator_user_id}
|
|
|
|
|
|
# --- Datasheet auto-fetch ---
|
|
|
|
|
|
@router.get("/digikey/datasheet")
|
|
@router.get("/datasheets/fetch")
|
|
async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = None):
|
|
"""Fetch a datasheet PDF for the given MPN.
|
|
|
|
Tries LCSC (no API key), manufacturer PDF URLs, optional Mouser, then DigiKey
|
|
if configured. ``/api/digikey/datasheet`` is kept as an alias.
|
|
"""
|
|
from backend.services.datasheet_finder import find_datasheet
|
|
|
|
result = await find_datasheet(mpn, lcsc_id=lcsc)
|
|
if not result.ok:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"detail": result.error or "Failed to fetch datasheet",
|
|
"url": result.url,
|
|
"urls": result.suggested_urls or ([result.url] if result.url else []),
|
|
"source": result.source,
|
|
},
|
|
)
|
|
headers = {"Content-Disposition": f'attachment; filename="{mpn}.pdf"'}
|
|
if result.url:
|
|
headers["X-Datasheet-Url"] = result.url
|
|
if result.source:
|
|
headers["X-Datasheet-Source"] = result.source
|
|
try:
|
|
proj_svc.remember_datasheet(
|
|
get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns,
|
|
)
|
|
except Exception:
|
|
pass
|
|
return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers)
|
|
|
|
|
|
# --- DigiKey auto-resolve ---
|
|
|
|
|
|
class AutoResolveItem(BaseModel):
|
|
mpn: str
|
|
component_type: str # "discrete", "connector", "crystal", etc.
|
|
|
|
|
|
class AutoResolveRequest(BaseModel):
|
|
items: list[AutoResolveItem]
|
|
|
|
|
|
@router.post("/digikey/auto-resolve")
|
|
async def auto_resolve(req: AutoResolveRequest, request: Request):
|
|
"""Auto-resolve simple component specs via DigiKey params + Haiku mapping.
|
|
|
|
Fetches structured parameters from DigiKey for each MPN, maps them to
|
|
taxonomy specs using a lightweight Claude model, and saves results to
|
|
the shared library. Batches up to 10 DigiKey calls in parallel.
|
|
"""
|
|
import asyncio
|
|
|
|
from backend.services.digikey import fetch_params
|
|
from backend.services.extraction import CatalogResolveMiss, auto_resolve_specs
|
|
|
|
if not settings.use_digikey:
|
|
raise HTTPException(400, "DigiKey API not configured")
|
|
|
|
storage = get_storage(request)
|
|
sem = asyncio.Semaphore(10)
|
|
|
|
async def resolve_one(item: AutoResolveItem) -> dict:
|
|
async with sem:
|
|
try:
|
|
# Skip if already in library
|
|
safe = safe_mpn(item.mpn)
|
|
if item.component_type == "passive":
|
|
lib_key = f"library/passives/{safe}.json"
|
|
# Also check legacy location for pre-migration data
|
|
if not storage.exists(lib_key):
|
|
legacy_key = f"library/models/{safe}.json"
|
|
if storage.exists(legacy_key):
|
|
return {"mpn": item.mpn, "status": "resolved"}
|
|
else:
|
|
lib_key = f"library/models/{safe}.json"
|
|
if storage.exists(lib_key):
|
|
return {"mpn": item.mpn, "status": "resolved"}
|
|
|
|
# Fetch params from DigiKey
|
|
result = await fetch_params(item.mpn)
|
|
if not result.ok or not result.params:
|
|
return {"mpn": item.mpn, "status": "failed", "error": result.error or "No parameters"}
|
|
|
|
# Map params: catalog parse first, LLM only if needed.
|
|
try:
|
|
model = await auto_resolve_specs(
|
|
mpn=item.mpn,
|
|
digikey_params=result.params.parameters,
|
|
digikey_category=result.params.category,
|
|
digikey_description=result.params.description,
|
|
component_type=item.component_type,
|
|
use_llm=settings.has_llm_credentials(),
|
|
)
|
|
except CatalogResolveMiss as e:
|
|
return {"mpn": item.mpn, "status": "failed", "error": str(e)}
|
|
|
|
# Save to library
|
|
storage.write_json(lib_key, model.model_dump())
|
|
return {"mpn": item.mpn, "status": "resolved"}
|
|
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(
|
|
"Auto-resolve failed for %s: %s", item.mpn, e, exc_info=True,
|
|
)
|
|
msg = str(e) or type(e).__name__
|
|
return {"mpn": item.mpn, "status": "failed", "error": msg}
|
|
|
|
results = await asyncio.gather(*(resolve_one(item) for item in req.items))
|
|
return {"results": results}
|
|
|
|
|
|
# --- LCSC per-row passive resolve (wizard-driven) ---
|
|
|
|
|
|
class LcscResolvePassiveRequest(BaseModel):
|
|
lcsc_id: str
|
|
|
|
|
|
@router.post("/projects/{project_id}/lcsc/resolve-passive")
|
|
async def lcsc_resolve_passive(
|
|
project_id: str, req: LcscResolvePassiveRequest, request: Request,
|
|
):
|
|
"""Resolve a single passive component to specs using its cached LCSC payload.
|
|
|
|
Called by the wizard frontend per-row. The LCSC payload (mpn, manufacturer,
|
|
package, description, category, subcategory) was cached on the project at
|
|
BOM upload time. We synthesize a DigiKey-shaped payload from it and reuse
|
|
``auto_resolve_specs`` — the same path the pipeline takes during the
|
|
passive extraction stage.
|
|
|
|
Returns ``{mpn, safe_mpn, model, cached, lcsc_id}``.
|
|
|
|
Errors:
|
|
- 404 if ``lcsc_id`` is not in the project's ``lcsc_payloads`` cache
|
|
- 402 with ``{reason, required, available}`` on insufficient credits
|
|
- 502 on extraction failure (with the underlying error)
|
|
"""
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from backend.services.api_logs import ApiLogger
|
|
from backend.services.billing_hook import InsufficientCredits, get_billing
|
|
from backend.services.extraction import auto_resolve_specs
|
|
|
|
storage = get_storage(request)
|
|
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
|
if not result:
|
|
raise HTTPException(404, "Project not found")
|
|
owner_user_id, meta = result
|
|
|
|
payloads = meta.lcsc_payloads or {}
|
|
payload = payloads.get(req.lcsc_id)
|
|
if not payload:
|
|
raise HTTPException(404, f"No cached payload for LCSC id {req.lcsc_id!r}")
|
|
|
|
mpn = (payload.get("mpn") or "").strip()
|
|
if not mpn:
|
|
raise HTTPException(404, f"Cached payload for {req.lcsc_id!r} has no MPN")
|
|
|
|
safe = safe_mpn(mpn)
|
|
project_model_key = (
|
|
f"{proj_svc.project_prefix(owner_user_id, project_id)}/models/{safe}.json"
|
|
)
|
|
|
|
# Short-circuit: if the per-project model file already exists, return it
|
|
# without re-charging. The pipeline's passive stage already short-circuits
|
|
# the same file, so re-running the pipeline after this won't double-charge.
|
|
if storage.exists(project_model_key):
|
|
model_data = storage.read_json(project_model_key)
|
|
return {
|
|
"mpn": mpn,
|
|
"safe_mpn": safe,
|
|
"model": model_data,
|
|
"cached": True,
|
|
"lcsc_id": req.lcsc_id,
|
|
}
|
|
|
|
# Library hit: copy into project storage and return without charging.
|
|
lib_key = proj_svc.library_has_passive_model(storage, mpn)
|
|
if lib_key:
|
|
storage.copy_object(lib_key, project_model_key)
|
|
model_data = storage.read_json(project_model_key)
|
|
return {
|
|
"mpn": mpn,
|
|
"safe_mpn": safe,
|
|
"model": model_data,
|
|
"cached": True,
|
|
"lcsc_id": req.lcsc_id,
|
|
}
|
|
|
|
# No cache. Synthesize a DigiKey-shaped payload and call auto_resolve_specs.
|
|
# Same shape used by the pipeline's LCSC-first branch in
|
|
# services/pipeline.py:_stage_passive_extraction.
|
|
synth_category = " / ".join(
|
|
p for p in (payload.get("category"), payload.get("subcategory")) if p
|
|
) or None
|
|
synth_params: list[dict[str, str]] = []
|
|
if payload.get("package"):
|
|
synth_params.append({"name": "Package / Case", "value": payload["package"]})
|
|
if payload.get("manufacturer"):
|
|
synth_params.append({"name": "Manufacturer", "value": payload["manufacturer"]})
|
|
description = payload.get("description") or ""
|
|
if not description:
|
|
raise HTTPException(
|
|
502,
|
|
f"Cached LCSC payload for {req.lcsc_id!r} has no description — "
|
|
"cannot auto-resolve",
|
|
)
|
|
|
|
from backend.services.passive_from_distributor import specs_from_lcsc_payload
|
|
|
|
catalog_model = specs_from_lcsc_payload(mpn, payload)
|
|
if catalog_model is not None:
|
|
storage.write_json(project_model_key, catalog_model.model_dump())
|
|
proj_svc.save_to_library(
|
|
storage, project_model_key, "passives", f"{safe}.json",
|
|
)
|
|
return {
|
|
"mpn": mpn,
|
|
"safe_mpn": safe,
|
|
"model": catalog_model.model_dump(),
|
|
"cached": False,
|
|
"lcsc_id": req.lcsc_id,
|
|
}
|
|
|
|
# Catalog miss (ferrite, odd text): LLM path, charged if the logger has tokens.
|
|
|
|
# Download taxonomy to a temp dir so auto_resolve_specs can read/write it.
|
|
# Mirrors the PipelineWorkspace pattern: periscopex operates on local paths.
|
|
api_logger = ApiLogger()
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tax_dir = Path(tmpdir) / "taxonomy"
|
|
tax_dir.mkdir()
|
|
for key in storage.list_prefix("taxonomy/"):
|
|
if key.endswith(".json"):
|
|
filename = key.rsplit("/", 1)[-1]
|
|
storage.download_to_local(key, tax_dir / filename)
|
|
# Seed from repo taxonomy if storage had no taxonomy files yet
|
|
if not any(tax_dir.glob("*.json")):
|
|
repo_tax = settings.taxonomy_dir
|
|
if repo_tax.is_dir():
|
|
import shutil
|
|
|
|
for f in repo_tax.glob("*.json"):
|
|
shutil.copy2(f, tax_dir / f.name)
|
|
|
|
try:
|
|
model = await auto_resolve_specs(
|
|
mpn=mpn,
|
|
digikey_params=synth_params,
|
|
digikey_category=synth_category or "",
|
|
digikey_description=description,
|
|
component_type="passive",
|
|
taxonomy_dir=tax_dir,
|
|
api_logger=api_logger,
|
|
)
|
|
except Exception as exc:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).warning(
|
|
"lcsc_resolve_passive: auto_resolve_specs failed for %s (lcsc=%s)",
|
|
mpn, req.lcsc_id, exc_info=True,
|
|
)
|
|
raise HTTPException(502, f"Auto-resolve failed: {exc}") from exc
|
|
|
|
# Charge the caller for the API work. The general-purpose primitive is
|
|
# billing charge — we don't have a PipelineContext here, so this
|
|
# skips the pipeline's pause/resume machinery. allow_overdraft=False
|
|
# gives the caller a clean 402 if their balance is too low. The work has
|
|
# already been done; on shortage we refuse to persist the resolved model
|
|
# so the user doesn't get the spec for free, and return 402 so the UI can
|
|
# prompt for top-up. Top-up + retry will redo the resolve (one API call),
|
|
# which is cheap.
|
|
total_credits = sum(float(e.get("credits_charged") or 0) for e in api_logger.entries)
|
|
insufficient_exc: InsufficientCredits | None = None
|
|
if total_credits > 0:
|
|
try:
|
|
get_billing().charge(
|
|
storage, owner_user_id, round(total_credits, 4),
|
|
reason="pipeline_charge",
|
|
run_id=project_id,
|
|
unit_id=f"lcsc_resolve_passive:{mpn}",
|
|
allow_overdraft=False,
|
|
)
|
|
except InsufficientCredits as exc:
|
|
insufficient_exc = exc
|
|
|
|
if insufficient_exc is not None:
|
|
# Work already done but we refuse to persist when the caller can't
|
|
# afford it — otherwise we'd give resolved specs away for free.
|
|
raise HTTPException(
|
|
402,
|
|
detail={
|
|
"reason": "insufficient_credits",
|
|
"required": insufficient_exc.required,
|
|
"available": insufficient_exc.available,
|
|
},
|
|
)
|
|
|
|
# Persist to project storage and the shared library (MPN-backed, mirrors
|
|
# the pipeline LCSC branch).
|
|
storage.write_json(project_model_key, model.model_dump())
|
|
proj_svc.save_to_library(
|
|
storage, project_model_key, "passives", f"{safe}.json",
|
|
)
|
|
|
|
# Append the api logger entries to the project's api_logs.jsonl so
|
|
# the cost shows up in admin and per-project reporting. Mirrors
|
|
# ApiLogger.flush but appends instead of overwriting.
|
|
try:
|
|
logs_key = (
|
|
f"{proj_svc.project_prefix(owner_user_id, project_id)}/api_logs.jsonl"
|
|
)
|
|
existing = storage.read_text(logs_key) if storage.exists(logs_key) else ""
|
|
appended = existing + api_logger.to_jsonl()
|
|
if appended:
|
|
storage.write_text(logs_key, appended)
|
|
except Exception:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).warning(
|
|
"lcsc_resolve_passive: failed to append api_logs.jsonl", exc_info=True,
|
|
)
|
|
|
|
# Bump the project's recorded total cost so admin/usage reflects this work.
|
|
try:
|
|
from backend.services.api_logs import total_cost as _total_cost
|
|
|
|
added_cost = _total_cost(api_logger.entries)
|
|
if added_cost > 0:
|
|
current_total = float(meta.total_cost_usd or 0)
|
|
proj_svc.update_project(
|
|
storage, owner_user_id, project_id,
|
|
total_cost_usd=round(current_total + added_cost, 6),
|
|
credits_spent=round(float(meta.credits_spent or 0) + total_credits, 4),
|
|
)
|
|
except Exception:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).warning(
|
|
"lcsc_resolve_passive: failed to update total_cost_usd", exc_info=True,
|
|
)
|
|
|
|
return {
|
|
"mpn": mpn,
|
|
"safe_mpn": safe,
|
|
"model": model.model_dump(),
|
|
"cached": False,
|
|
"lcsc_id": req.lcsc_id,
|
|
}
|