commit 6672d2be5744239bd6edfee8abc2ac43d3705f93 Author: Siddharth Kothari Date: Thu Jul 16 21:29:45 2026 -0700 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). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6329830 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +**/.env +**/.env.local +.pytest_cache +.mypy_cache +data/ +frontend/ +.git/ +.claude/settings.* +.claude/plans/ +.claude/memory/ +simple_project/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b6857c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r backend/requirements.txt pytest pytest-asyncio + - run: pytest tests/ -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa6c3dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Staged at build time by cloudbuild from frontend/content/changelog.md +# so the Dockerfile can COPY it past .dockerignore's frontend/ exclude. +backend/_changelog.md + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Testing / coverage +.pytest_cache/ +.coverage +htmlcov/ + +# Type checkers +.mypy_cache/ +.pyre/ +.pytype/ + +# Editors / OS +.vscode/ +.idea/ +.DS_Store + +# Secrets +.env +.env.local +**/.env +**/.env.local + +# Claude Code +.claude/ +.agents/ +skills-lock.json + +# Runtime data (projects, library) +data/ +backend/data/ + +# Frontend +frontend/node_modules/ +frontend/.next/ +frontend/out/ +.next/ + +# retrospective +*.retrospective +.vercel + +# Local-only sample inputs (client schematics, test files) +edif-files/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..666f33f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# Pinscope — Agentic Schematic Validation + +Pinscope validates hardware schematics against component datasheets. It extracts constraints from PDFs, parses netlists and BOMs into a queryable graph, and runs an agentic validation loop to flag design violations. + +> **Open-core note.** This is the open-source core. A small set of files are +> "gateway-owned seams" — pass-through stubs here (`frontend/src/proxy.ts`, +> `use-optional-auth.ts`, `clerk-theme-provider.tsx`, +> `components/billing/*`, `sidebar-auth.tsx`, `pricing-section.tsx`, +> `analytics/*`, `lib/csp-hosts.ts`) that the hosted-cloud repo replaces +> with auth/billing implementations. Keep their export signatures stable, +> and never import auth/billing SDKs anywhere else in the frontend. On the +> backend, everything reaches billing only through +> `backend/services/billing_hook.py:get_billing()` (a no-op here). + +## System Overview + +Three layers: + +| Layer | Location | Purpose | +|-------|----------|---------| +| **Core library** | `backend/pinscopex/` | Models, parsers, graph builder, agentic validator, passive resolver, taxonomy, BOM summary, derating | +| **Backend** | `backend/` | FastAPI app — async pipeline orchestration, SSE progress, project/file storage | +| **Frontend** | `frontend/` | Next.js 16 app — project dashboard, pipeline progress, report viewer, derating, admin dashboard | + +Plus `skills/` — Claude Console Skills for datasheet extraction (pintable, patterns, specs). + +The pipeline stages: Parse BOM → Extract IC Pintables → Extract Simple Components → Extract Passives → DigiKey Auto-Resolve + Value Fallback → Build Graph → Direct Datasheet Review. Pipeline runs can be cancelled mid-execution via `POST /api/pipeline/{id}/cancel`. + +## Example Project + +`simple_project/` is the reference design for development and testing: + +- **MCU**: TI MSPM0G3507SPTR (U3) — 48-pin LQFP +- **USB-UART Bridge**: CH340E (U2) +- **LDO Regulator**: SPX3819M5-L-3-3 (U1) — 5V to 3.3V +- **ESD Protection**: USBLC6-2SC6 (D1) +- **Crystal**: 8 MHz (X1) with 18pF load caps (C9, C10) + +Files: `.asc` (PADS-PCB netlist; `.edn` EDIF 2.0.0 also accepted), `.csv`/`.xlsx` (BOM), `design_graph.json` (committed reference fixture used by tests). + +## Architecture Principles + +- **Modular extractors** — Domain-specific extraction per component type, unified constraint schema +- **Netlist as graph** — Queryable bipartite graph (components + nets) with traversal helpers +- **Claude API for PDF extraction** — Forced tool calls for structured output (pintable, passive patterns, specs) +- **Prompt caching** — Extraction and review API calls use `cache_control={"type": "ephemeral"}` on system prompts and input context to reduce cost on repeated calls +- **Claude Console Skills** — Extraction prompts deployed as managed skills; skill_ids and versions loaded from `backend/skills_manifest.json` (upload your own via `scripts/upload_skills.py`) +- **Direct datasheet review** — Claude reads the IC datasheet PDF and circuit neighborhood together, compares to reference application circuit, and flags issues via graph query tools (`find_connected_components`, `get_net_for_pin`, `get_pintable`) +- **Datasheet page trimming** — Large PDFs are keyword-trimmed to relevant pages before sending to Claude, reducing token cost (`pypdf`) +- **DigiKey fallback (exact MPN only)** — When pattern-based and direct extraction fail, DigiKey API fetches product parameters for auto-resolve. DigiKey matches only on exact MPN; fuzzy hits are rejected to avoid polluting the shared library with wrong-dielectric / wrong-voltage parts. +- **Value-string fallback** — When DigiKey misses an R/C/L/FB passive, a value-string resolver maps the BOM `Value` string to typed passive specs. Value-derived specs are persisted per-project only — never to the shared library. +- **Per-IC review error isolation** — Direct datasheet review runs each IC independently; one malformed payload or bad response cannot kill the whole run. Failed ICs surface as skipped components with the error. +- **Cross-IC excerpt budget (per-neighbor)** — To verify an interface finding the reviewer can pull a *connected* IC's datasheet pages (`get_datasheet_excerpt`). The budget is a global per-review page ceiling **plus a per-neighbor sub-budget**, so verifying one interface is never starved by pages already spent on other neighbors. +- **Finding normalization is downgrade-only** — A post-review per-IC normalize pass (`services/normalize_findings.py`) drops self-cancelling findings, merges same-root-cause findings, and re-grades severity — but only ever *downward*. A deterministic clamp caps each finding at the reviewer's calibrated severity (and any `Unverified:` finding at WARNING, preserving the prefix). +- **Cross-IC finding dedup** — After all per-IC reviews complete, a single pass (`services/dedupe_findings.py`) collapses one physical interface defect reported from both endpoints into a single finding. Gated by `cross_ic_dedup_enabled`; fail-soft. +- **Capacitor voltage derating** — Deterministic derating table computed from graph (ceramic/tantalum/electrolytic percentages, pass/fail per capacitor) +- **Deterministic checks over heuristics** — Exact checks where possible +- **Zero coupling between layers** — Backend calls pinscopex functions with paths; frontend talks to backend via REST + SSE +- **Library deduplication** — Shared library (`library/extracted/`, `library/patterns/`, `library/models/`, `library/passives/`, `library/datasheets/`) caches extractions across projects +- **Content-addressed datasheets** — `library/datasheets/blobs/{md5}.pdf` stores unique PDFs once; `library/datasheets/refs/{safe_mpn}.json` maps MPNs to blobs (dedupe + multi-MPN sharing) +- **Taxonomy-driven extraction** — Living component taxonomy (`taxonomy/`) with per-subtype classification and specs schemas +- **Per-stage model config** — Each pipeline stage can use a different Claude model (e.g., Sonnet for review, Haiku for auto-resolve) +- **API call logging** — Every Claude API call is logged with token counts, cost, and timing per pipeline run +- **Report versioning** — Each project run is stamped with the current app version on the first `/start` transition (`ProjectMeta.pinscope_version`). The version comes from `frontend/content/changelog.md`'s latest `##` heading — single source of truth — read at backend startup via `backend/_version.py`. + +## Datasheet Extraction + +Extracted data lives in `library/extracted/` (shared) or per-project under the storage backend. One JSON per MPN, schema in `backend/pinscopex/models.py`. + +Per-MPN IC extraction captures: +1. **Pintable** — Pin number + name (required), description + alt functions (optional) +2. **Package info** — Base family, package, pin count, description +3. **Component subtype** — Dotted taxonomy path (e.g., `ic.mcu`, `ic.power.ldo`) + +For discrete/simple components: +4. **Specs** — Component specs (value, tolerance, package, voltage rating, etc.); parameters are filtered against taxonomy specs schemas + +Extraction uses **Claude Console Skills** (required, via `skill_id` in `backend/skills_manifest.json`). No inline fallback — raises error if skill not configured. Skills are defined in `skills/` and uploaded via `scripts/upload_skills.py` — run it once against your own Anthropic Console account to populate the manifest with your skill IDs. + +## Claude Console Skills + +``` +skills/ +├── extract-pintable/ # Pin table + package info + taxonomy +│ ├── SKILL.md # System prompt (YAML frontmatter + markdown) +│ ├── schema.json # Tool output schema +│ └── validate.py # Validation script +├── extract-pattern/ # Passive MPN pattern +└── extract-specs/ # Component specs (discrete, connectors, crystals, etc.) +``` + +## Taxonomy + +Living component taxonomy in `taxonomy/` — one JSON file per top-level type (ic, passive, connector, crystal, discrete, fuse, switch, test_point, transformer). Each subtype entry includes `description` and `example_mpn`. + +Key taxonomy features: +- **Ref prefix mapping** — `U→ic`, `R/C/L→passive`, `D/Q→discrete`, `X→crystal`, etc. +- **Dotted subtype paths** — e.g., `ic.mcu`, `passive.capacitor.ceramic`, `ic.protection.esd` +- **Dynamic growth** — `add_subtype()` adds new entries; concurrent-safe JSON writes +- **Specs schema auto-generation** — Type-level and subtype-level parameter specs schemas are auto-generated via Claude when a taxonomy entry has none; extraction discards parameters not in the schema (`extra_specs` field) + +## Scripts + +- `scripts/upload_skills.py` — Create, update, or list Claude Console Skills. Reads/writes skill IDs to `backend/skills_manifest.json` +- `scripts/migrate_datasheets_to_library.py` — One-time migration: copy per-project datasheets to `library/datasheets/` (dry-run by default, `--apply` to execute) +- `scripts/migrate_datasheets_to_blobs.py` — Migrate named-PDF datasheets into the content-addressed blobs/refs layout (dry-run by default, `--apply` to execute) +- `scripts/dedup_library_datasheets.py` — Remove redundant per-MPN datasheet PDFs when a passive pattern already has a `datasheet_key` (dry-run by default, `--apply` to execute) +- `scripts/gc_orphan_blobs.py` — Garbage-collect `library/datasheets/blobs/*.pdf` not referenced by any ref file +- `scripts/clear_rules_from_extractions.py` — Strip deprecated `rules`/`absolute_maximum_ratings` from existing library extractions + +## Tech Stack + +- **Core**: Python 3.12+, Pydantic 2.x, Anthropic SDK (async + sync), openpyxl (XLSX BOM support), pypdf (datasheet page trimming) +- **Backend**: FastAPI, uvicorn, sse-starlette, pydantic-settings +- **Frontend**: Next.js 16 (App Router, Turbopack), React 19, Tailwind CSS v4, shadcn/ui (Base UI), react-pdf +- **AI**: Claude API with forced tool calls for extraction, direct datasheet review for validation +- **Model**: `claude-sonnet-4-6` default for extraction and review, `claude-haiku-4-5` for DigiKey auto-resolve and passive value fallback (per-stage overrides via `.env`) +- **Skills**: Claude Console Skills API for managed extraction prompts (3 active skills: pintable, pattern, specs) +- **External APIs**: DigiKey API v4 (OAuth2) — optional datasheet auto-fetch and parameter-based auto-resolve (`DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`) + +## Extracted Model Versioning + +All `ComponentConstraints` extracted JSON files carry a `model_version` semver field: + +- **Initial value** — set from `default_model_version` in `backend/skills_manifest.json` (starts at `1.0.0`) +- **Minor bump** — `default_model_version` in `skills_manifest.json` is incremented by `scripts/upload_skills.py --update`, so all new extractions after a skill update start at the new minor (e.g. `1.0.0` → `1.1.0`) + +**Rule**: When committing or pushing changes under `skills/`, run `python3 scripts/upload_skills.py --update` before the commit/push to sync skill versions and bump `default_model_version`. + +## Development Guidelines + +- Write tests against `simple_project/` — it's the ground truth +- Netlist parser and BOM parser are pure functions with no side effects +- All data structures use Pydantic models in `backend/pinscopex/models.py` +- Frontend types in `frontend/src/lib/types.ts` must stay in sync with `backend/pinscopex/models.py` +- Extraction prompts live in `skills/` as Claude Console Skills (SKILL.md + schema.json + validate.py) +- **Never swallow exceptions silently** — prefer logging or re-raising over bare `except: continue`. Silent failures hide real bugs. + +## Running + +```bash +# Backend (copy backend/.env.example to .env at repo root first) +python3 -m uvicorn backend.main:app --reload # localhost:8000 + +# Frontend +cd frontend && npm run dev # localhost:3000 +``` + +Local mode needs no cloud services and no auth — projects are stored in `data/` and you are `user_id="local"` with admin access. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d39d076 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Pinscope + +**Agentic schematic validation — catch hardware design errors before you fab.** + +Pinscope reviews your schematic against every component datasheet. It parses your netlist and BOM into a queryable design graph, extracts pin tables and specs from datasheet PDFs with Claude, and runs an agentic per-IC review that compares your circuit neighborhood to the datasheet's reference application — flagging wrong pull-ups, missing decoupling, voltage-domain violations, swapped signals, and derating failures, each finding cited back to the datasheet page that backs it up. + +> Pinscope is the open-source core of [pinscope.ai](https://pinscope.ai) (same code, hosted, with team accounts). Self-hosting it like this is fully supported: everything runs locally against your own Anthropic API key, no account or cloud services required. + +## How it works + +``` +Upload BOM + netlist + datasheets + │ + ▼ +Parse BOM ─ Extract IC pintables ─ Extract passives ─ DigiKey/value fallback + │ + ▼ +Build design graph (bipartite: components ⇄ nets, queryable) + │ + ▼ +Per-IC direct datasheet review (Claude reads the PDF + circuit neighborhood, + │ queries the graph, cites pages for findings) + ▼ +Report + BOM summary + capacitor derating table +``` + +- **Netlists**: PADS-PCB ASCII (`.asc`) and EDIF 2.0.0 (`.edn`) — exportable from KiCad, Altium, OrCAD, Cadence, Xpedition, EasyEDA, EAGLE +- **BOM**: CSV or XLSX +- **Datasheets**: PDF per MPN (DigiKey auto-fetch supported with API keys) +- **Deterministic where possible**: graph build, BOM collation, and capacitor voltage derating are exact computations, no AI +- **Extraction is cached**: every extracted pintable/spec lands in a shared `library/` so an MPN is only ever paid for once + +## Quickstart + +Prereqs: Python 3.12+, Node 20+, an [Anthropic API key](https://console.anthropic.com/). + +```bash +git clone https://github.com/Faradworks/Pinscope.git +cd Pinscope + +# 1. Backend +python3 -m venv .venv && source .venv/bin/activate +pip install -r backend/requirements.txt +cp backend/.env.example .env # then set ANTHROPIC_API_KEY + +# 2. Extraction skills (one-time): uploads the three extraction prompts in +# skills/ to YOUR Anthropic Console account and writes their IDs into +# backend/skills_manifest.json +python3 scripts/upload_skills.py --update + +# 3. Run +python3 -m uvicorn backend.main:app --reload # http://localhost:8000 +cd frontend && npm install && npm run dev # http://localhost:3000 +``` + +Open http://localhost:3000, create a project, and upload the files from `simple_project/` (an MSPM0G3507 + CH340E reference design) to see a full run end-to-end. Projects and the extraction library are stored in `data/`; you run as a local admin user — no login. + +## What's in the box + +| Layer | Where | What | +|---|---|---| +| Core library | `backend/pinscopex/` | Pydantic models, netlist/BOM parsers, graph builder, agentic validator, passive resolvers, taxonomy, derating | +| Backend | `backend/` | FastAPI — async pipeline with SSE progress, project + library storage, per-call API cost logging | +| Frontend | `frontend/` | Next.js 16 — dashboard, pipeline progress, report viewer with datasheet citations, derating table, admin console | +| Extraction skills | `skills/` | Claude Console Skills for pintable / passive-pattern / specs extraction | +| Taxonomy | `taxonomy/` | Living component taxonomy with per-subtype specs schemas | + +See [CLAUDE.md](CLAUDE.md) for architecture details and development guidelines. + +## Configuration + +Everything is env-driven (see `backend/.env.example`): + +- `ANTHROPIC_API_KEY` — required; extraction and review models are configurable per stage +- `DIGIKEY_CLIENT_ID` / `DIGIKEY_CLIENT_SECRET` — optional; enables datasheet auto-fetch and parameter-based passive auto-resolve +- `GCS_BUCKET` — optional; swaps local `data/` storage for Google Cloud Storage +- `GEMINI_API_KEY` + `PROVIDER_*` — optional; route individual stages to Gemini + +## Tests + +```bash +pip install pytest pytest-asyncio +pytest tests/ -q +``` + +Tests run against `simple_project/` — it's the ground-truth reference design. + +## License + +[AGPL-3.0](LICENSE). Commercial licensing is available — contact dev@faradworks.com. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c927a44 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,66 @@ +# Pinscope Backend — Environment Variables +# Copy to .env and fill in values. Only ANTHROPIC_API_KEY is required. + +# -- AI ---------------------------------------------------------------------- +ANTHROPIC_API_KEY=sk-ant-... +ANTHROPIC_MODEL=claude-sonnet-4-6 +# Per-stage Anthropic model overrides (leave empty to use ANTHROPIC_MODEL) +MODEL_PINTABLE= +MODEL_PATTERN= +MODEL_VALIDATION= + +# -- AI provider routing ----------------------------------------------------- +# Default provider for every stage; per-stage env vars override. +# Valid values: anthropic | gemini +PROVIDER_DEFAULT=anthropic +# Set a specific stage to "gemini" to route just that stage to Gemini +# (leaves the rest on Anthropic). Skills-based extraction stages +# (pintable / pattern / specs) require Anthropic — Gemini has no +# equivalent of Anthropic Console Skills. +# PROVIDER_VALIDATION=gemini +# PROVIDER_POWER_TREE=gemini +# PROVIDER_AUTO_RESOLVE= + +# -- Gemini (required when any PROVIDER_* is set to "gemini") ---------------- +GEMINI_API_KEY= +GEMINI_MODEL=gemini-3-flash-preview +# Per-stage Gemini model overrides (leave empty to use GEMINI_MODEL) +# MODEL_VALIDATION_GEMINI= +# MODEL_POWER_TREE_GEMINI= + +# -- Per-stage fallback ------------------------------------------------------ +# If set, the stage retries once with FALLBACK_PROVIDER_ / +# FALLBACK_MODEL_ when the primary provider raises (e.g. Gemini 503 +# UNAVAILABLE). FALLBACK_MODEL_ may be empty — defaults to that +# provider's default model (ANTHROPIC_MODEL or GEMINI_MODEL). Leave +# FALLBACK_PROVIDER_ empty to disable fallback for that stage. +# FALLBACK_PROVIDER_VALIDATION=anthropic +# FALLBACK_MODEL_VALIDATION=claude-sonnet-4-6 + +# -- Storage ----------------------------------------------------------------- +# Set GCS_BUCKET to store projects/library in Google Cloud Storage. +# Leave empty for local mode (uses the data/ directory). +GCS_BUCKET= + +# -- CORS -------------------------------------------------------------------- +# Frontend URL(s), JSON list +CORS_ORIGINS=["http://localhost:3000"] + +# -- DigiKey (optional) ------------------------------------------------------ +# Enables datasheet auto-fetch and parameter-based passive auto-resolve. +# DIGIKEY_CLIENT_ID= +# DIGIKEY_CLIENT_SECRET= +# DIGIKEY_ENVIRONMENT=production +# DIGIKEY_LOCALE_SITE=US +# DIGIKEY_LOCALE_LANGUAGE=en +# DIGIKEY_LOCALE_CURRENCY=USD + +# -- Email notifications (optional) ------------------------------------------ +# Gmail API via domain-wide delegation. Leave EMAIL_SENDER empty to disable. +# Service account credentials come from GOOGLE_APPLICATION_CREDENTIALS. +EMAIL_SENDER= +EMAIL_FRONTEND_URL= +# Fixed admin email for pipeline-started notifications (leave empty to disable) +EMAIL_ADMIN_NOTIFY= +# Recipient for /api/contact form submissions (leave empty to disable) +CONTACT_RECIPIENT= diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000..d8a27b8 --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1,148 @@ +# Pinscope Backend + +FastAPI application providing async pipeline orchestration, project storage, and SSE progress streaming. Wraps the `pinscopex/` core library — calls existing functions with local paths, adds no domain logic of its own. + +## Running + +```bash +# From project root +python3 -m uvicorn backend.main:app --reload # localhost:8000 +``` + +Config reads from `.env` at project root (see `config.py`). Key settings: `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` (default `claude-sonnet-4-6`), per-stage model overrides (`model_pintable`, `model_pattern`, `model_specs`, `model_validation`, `model_auto_resolve`), `CORS_ORIGINS`, `DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`, `DIGIKEY_ENVIRONMENT`. + +For local mode, leave `GCS_BUCKET` empty — uses `LocalStorageBackend` (`data/` directory) and no auth (user_id defaults to `"local"`, admin access granted). + +## Architecture + +``` +backend/ +├── main.py # App entry, lifespan hook, CORS, auth middleware, router includes +├── config.py # Pydantic Settings from .env +├── _version.py # Reads app version from frontend/content/changelog.md (single source of truth) +├── Dockerfile # Python 3.12-slim, copies taxonomy/ + changelog.md for runtime +├── skills_manifest.json # Claude Console Skill IDs (extract-pintable, extract-pattern, extract-specs) +├── pinscopex/ # Core library (models, parsers, graph, validator, taxonomy, derating) +│ ├── utils.py # Shared utilities: safe_mpn(), natural_sort_key() +│ └── resolve_passives.py # Passive MPN pattern matching + value decoders (R/C/L) +├── middleware/ +│ └── auth.py # JWT verification via JWKS (enabled when CLERK_JWKS_URL is set; off in OSS mode) +├── routers/ +│ ├── deps.py # Shared router dependencies: get_storage(), get_user_id(), resolve_or_404() +│ ├── projects.py # CRUD + file upload + library check + collaborators + DigiKey endpoints +│ ├── pipeline.py # Start, cancel, estimate, resume, restart, regen, SSE, status +│ ├── reports.py # Report, comments, graph, datasheet, API logs, BOM, derating +│ ├── admin.py # Admin-only: components, users, usage, projects, runs, settings +│ ├── feedback.py # User feedback tickets +│ └── contact.py # Contact form (email relay; inert unless email is configured) +└── services/ + ├── storage.py # StorageBackend protocol + LocalStorageBackend + ├── storage_gcs.py # GCSStorageBackend (optional, Google Cloud Storage) + ├── projects.py # Project CRUD + library ops via StorageBackend + ├── pipeline.py # Multi-stage orchestrator + EventBroker + PipelineWorkspace + ├── extraction.py # Async Claude API calls (pintable, patterns, specs, auto-resolve, value fallback) + skills + page trimming + ├── validation.py # Async agentic validation wrapper with per-IC error isolation + ├── normalize_findings.py # Post-review per-IC normalize pass (downgrade-only) + ├── dedupe_findings.py # Cross-IC finding dedup + ├── billing_hook.py # Open-core billing seam (NullBilling here — pipelines run free) + ├── digikey.py # DigiKey API v4 — OAuth2, datasheet fetch, parameter fetch (exact MPN only) + ├── purple_parts.py # Optional external LCSC→MPN resolver (env-gated; fails soft when unset) + ├── api_logs.py # API call logging, cost calculation per pipeline run + ├── cost_estimator.py # Pre-flight pipeline estimate (read-only) + ├── datasheet_store.py # Content-addressed PDF storage: blobs/{md5}.pdf + refs/{safe_mpn}.json + ├── admin_settings.py # Admin-only settings (e.g., min_model_version threshold) + ├── job_runner.py # Optional Cloud Run job trigger (in-process asyncio locally) + └── email.py # Email notifications (optional, env-gated) +``` + +## Storage Abstraction + +All file I/O goes through `StorageBackend` (protocol in `services/storage.py`): +- **LocalStorageBackend**: Maps keys to `data/` directory. Default. +- **GCSStorageBackend**: Uses `google-cloud-storage` SDK. Used when `GCS_BUCKET` is set. + +Storage keys follow GCS-style paths: `users/{user_id}/projects/{id}/uploads/bom.csv` + +The `pinscopex/` core library is **unaware of storage** — it operates on local paths. During pipeline execution, `PipelineWorkspace` downloads files to a temp dir, runs `pinscopex/` functions locally, then uploads results back. + +## Project Storage + +``` +# data/ directory (or GCS bucket) +users/{user_id}/projects/{id}/ +├── project.json # ProjectMeta (name, status, timestamps, user_id, total_cost_usd) +├── uploads/ +│ ├── bom.csv +│ ├── netlist.asc # OR netlist.edn for EDIF uploads +│ └── datasheets/*.pdf +├── extracted/ # Per-project IC extractions +├── patterns/ # Per-project passive patterns +├── models/ # Per-project resolved specs +├── design_graph.json +├── bom_summary.json # BOM summary table (collated from design graph) +├── derating.json # Capacitor voltage derating table +├── report.json # Findings + comments +└── api_logs.jsonl # Claude API call log (token counts, cost, timing) + +library/ # Shared across projects +├── extracted/ # Shared IC extractions +├── patterns/ # Shared passive patterns +├── models/ # Shared component specs (discrete, connectors, etc.) +├── passives/ # DigiKey-resolved passive specs (exact-MPN only) +└── datasheets/ + ├── blobs/{md5}.pdf # Content-addressed PDF blobs (deduped) + └── refs/{safe_mpn}.json # MPN → blob pointer ({hash, blob_key}) + +taxonomy/ # Component taxonomy (repo taxonomy/ dir in local mode) +``` + +Library lookups happen first — if an MPN was already extracted, it's reused without re-calling the API. + +## Pipeline Stages + +The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE events via `EventBroker` (async queue per subscriber). `PipelineWorkspace` handles download/upload. Pipelines can be cancelled mid-run via `POST /api/pipeline/{id}/cancel`. + +1. **Parse BOM** — Read uploaded CSV/XLSX (uses stored column mappings from upload; XLSX converted to CSV via openpyxl) +2. **Extract IC Pintables** — Async Claude API calls for pintable per IC MPN (datasheets keyword-trimmed via `pypdf`). Cache-miss MPNs are extracted **concurrently**, up to `IC_CONCURRENCY` (default 6) in flight at once. +2.5. **Extract Simple Components** — Specs extraction for discrete/simple components with datasheets +3. **Extract Passives** — Pattern-based extraction per MPN group, then a specs fallback per MPN. +3.5. **DigiKey Auto-Resolve (exact MPN)** — Fallback for unresolved passives; parameters mapped to taxonomy specs via Haiku. Requires exact MPN match so the shared `library/passives/` stays clean. +3.6. **Value Fallback (R/C/L/FB only)** — When DigiKey misses, parse the BOM `Value` string via Haiku into typed passive specs. Per-project only; never written to the shared library. +4. **Build Graph** — Call `pinscopex.graph.build_graph()` with local temp paths +5. **BOM Summary** — Collate components from design graph (no AI) +6. **Derating Table** — Capacitor voltage derating computation (no AI) +7. **Direct Datasheet Review** — Per-IC (isolated): Claude reads the datasheet PDF + circuit neighborhood from the graph, compares to reference application circuit, and submits findings via graph query tools. ICs are reviewed **concurrently**, up to `IC_CONCURRENCY` in flight at once. + +**Concurrency knob** — `IC_CONCURRENCY` (`config.py: ic_concurrency`, default 6) governs parallelism for stage 2 (IC extraction), stage 3.5 (passive specs fallback), and stage 7 (review). Set `IC_CONCURRENCY=1` for fully sequential behavior. + +## Key Patterns + +- **StorageBackend protocol** — all file I/O is abstracted; swap local/GCS via `GCS_BUCKET` env var +- **PipelineWorkspace** — downloads to temp dir, runs pinscopex locally, uploads results +- **BillingHook seam (open-core)** — core code reaches billing exclusively through `services/billing_hook.py:get_billing()`. In this repo that's `NullBilling`: every pipeline runs free and no billing routes are mounted. Never import billing modules directly from core code — go through the hook. +- **Auth middleware** — JWT verification via a JWKS endpoint; disabled when `CLERK_JWKS_URL` is empty (local mode: `user_id="local"`, `is_admin()` returns True) +- **AsyncAnthropic** for all Claude API calls — extraction and validation +- **Claude Console Skills** — extraction uses managed skills (skill_id + version from `skills_manifest.json`); no fallback, raises error if skill not configured +- **Prompt caching** — extraction and validation calls use `cache_control={"type": "ephemeral"}` on system prompts and input context +- **Forced tool calls** for extraction — structured output via `tool_choice` +- **SSE via sse-starlette** — `EventBroker` manages per-project async queues with history replay +- **API call logging** — `ApiLogger` in `services/api_logs.py` collects per-call metadata; `CallMeta` returned from extraction functions +- **Traceability IDs** — findings get IDs (format: `{designator}-{001}`) for audit trails +- **Taxonomy specs schemas** — auto-generated via Claude per type/subtype; extraction discards parameters not in schema (`extra_specs`) +- **Shared router deps** — `routers/deps.py` centralizes `get_storage()`, `get_user_id()`, `resolve_or_404()` across all routers +- **DigiKey OAuth2** — Token caching in `services/digikey.py`; `_find_product` requires exact MPN (no silent first-match fallback) +- **Version stamping** — `backend/_version.py` reads the latest `##` heading from `frontend/content/changelog.md` and exports `PINSCOPE_VERSION`; stamped onto `ProjectMeta.pinscope_version` at `/start` +- **Datasheet page trimming** — `_select_pages()` in `extraction.py` keyword-trims large PDFs to reduce token costs +- **Content-addressed datasheets** — `datasheet_store.py` writes PDFs to `library/datasheets/blobs/{md5}.pdf` and maps MPNs via refs +- **Passive value decoders** — `pinscopex/resolve_passives.py` decodes EIA-198, R-notation, letter-decimal, EIA3/EIA4 for R/C/L values +- **Collaborator access** — `resolve_or_404()` grants access to both owner and collaborators +- **Per-IC review isolation** — In `services/validation.py`, each IC review is wrapped so a single bad payload is captured as a skipped component rather than aborting the run + +## Guidelines + +- Keep all Claude API interaction in `services/extraction.py` and `services/validation.py`; logging in `services/api_logs.py` +- Keep all storage operations in `services/projects.py` (uses `StorageBackend`) +- Routers are thin — validate input, call service, return response +- Thread `user_id` from `request.state` through to all service calls +- Don't import from `backend/` in `pinscopex/` — dependency flows one way +- CORS is configured for `localhost:3000` by default; override with `CORS_ORIGINS` env var diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a45266b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies first (layer caching). +# Open-core: the private gateway repo adds backend/requirements-gateway.txt +# (Stripe, etc.); a plain core checkout has no such file and skips that step. +COPY backend/requirements*.txt /app/backend/ +RUN pip install --no-cache-dir -r /app/backend/requirements.txt \ + && if [ -f /app/backend/requirements-gateway.txt ]; then \ + pip install --no-cache-dir -r /app/backend/requirements-gateway.txt; \ + fi + +# Copy application code +COPY backend/ /app/backend/ + +# Copy runtime assets needed by the backend +# Taxonomy: fallback for local mode; GCS mode downloads from bucket +COPY taxonomy/ /app/taxonomy/ + +# Changelog: single source of truth for the user-facing Pinscope version. +# Read by backend/_version.py at startup and stamped onto each new pipeline run. +# Staged into backend/ by cloudbuild before this step runs so the broad +# `frontend/` exclude in .dockerignore doesn't block the COPY. +COPY backend/_changelog.md /app/changelog.md + +EXPOSE 8080 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/_version.py b/backend/_version.py new file mode 100644 index 0000000..f59b08e --- /dev/null +++ b/backend/_version.py @@ -0,0 +1,38 @@ +"""Pinscope app version, sourced from frontend/content/changelog.md. + +The changelog is the single source of truth for the user-facing version. +The Dockerfile copies it into the image at /app/changelog.md; locally we +fall back to the in-repo path. +""" +from __future__ import annotations + +import re +from functools import lru_cache +from pathlib import Path + + +def _candidate_paths() -> list[Path]: + here = Path(__file__).resolve() + return [ + Path("/app/changelog.md"), + here.parent.parent / "frontend" / "content" / "changelog.md", + ] + + +_VERSION_RE = re.compile(r"^##\s+(\d+\.\d+\.\d+)\b", re.MULTILINE) + + +@lru_cache(maxsize=1) +def get_pinscope_version() -> str: + for path in _candidate_paths(): + try: + text = path.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + continue + m = _VERSION_RE.search(text) + if m: + return m.group(1) + return "unknown" + + +PINSCOPE_VERSION = get_pinscope_version() diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..0a00ba5 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,231 @@ +"""Backend configuration via environment variables.""" + +import importlib.util +import json +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings + +# Resolve paths relative to the project root (one level up from backend/) +_BACKEND_DIR = Path(__file__).resolve().parent +_PROJECT_ROOT = _BACKEND_DIR.parent + +# Load skills manifest once at import time +_MANIFEST_PATH = _BACKEND_DIR / "skills_manifest.json" +_SKILLS_MANIFEST: dict = ( + json.loads(_MANIFEST_PATH.read_text()) if _MANIFEST_PATH.exists() else {} +) + + +class Settings(BaseSettings): + # Anthropic + anthropic_api_key: str = "" + anthropic_model: str = "claude-sonnet-4-6" + + # Per-stage model overrides (fall back to anthropic_model if empty) + model_pintable: str = "" + model_pattern: str = "" + model_specs: str = "" + model_validation: str = "claude-sonnet-4-6" + model_auto_resolve: str = "claude-haiku-4-5-20251001" + model_normalize: str = "claude-sonnet-4-6" + + # Gemini (set GEMINI_API_KEY to enable) + gemini_api_key: str = "" + gemini_model: str = "gemini-3.1-pro-preview" + + # Per-stage Gemini model overrides (fall back to gemini_model if empty) + model_validation_gemini: str = "" + model_pintable_gemini: str = "" + model_pattern_gemini: str = "" + model_specs_gemini: str = "" + model_auto_resolve_gemini: str = "" + model_normalize_gemini: str = "" + + # Provider routing — provider_default is the global default; per-stage + # overrides win when non-empty. Set provider_validation=gemini to route + # the validation stage to Gemini while leaving extraction on Anthropic. + provider_default: str = "anthropic" + provider_pintable: str = "" + provider_pattern: str = "" + provider_specs: str = "" + provider_validation: str = "" + provider_auto_resolve: str = "" + provider_normalize: str = "" + + # Per-stage fallback provider/model — used if the primary stage call + # raises (e.g. Gemini 503 UNAVAILABLE). Leave empty to disable fallback + # for that stage. If fallback_provider_ is set but + # fallback_model_ is empty, the fallback uses that provider's + # default model (anthropic_model or gemini_model). + fallback_provider_pintable: str = "" + fallback_provider_pattern: str = "" + fallback_provider_specs: str = "" + fallback_provider_validation: str = "" + fallback_provider_auto_resolve: str = "" + fallback_provider_normalize: str = "" + fallback_model_pintable: str = "" + fallback_model_pattern: str = "" + fallback_model_specs: str = "" + fallback_model_validation: str = "" + fallback_model_auto_resolve: str = "" + fallback_model_normalize: str = "" + + # Max parallel IC agents — the single knob controlling concurrency for + # BOTH the IC pintable extraction stage and the direct datasheet review + # stage. Change this one number (or the IC_CONCURRENCY env var) to scale + # how many ICs are processed in parallel. + ic_concurrency: int = 6 + + # Per-IC normalize pass — dedup findings sharing a root cause and + # re-grade severity against a fixed rubric. Runs after submit_review. + normalize_findings_enabled: bool = True + + # Cross-IC dedup pass — collapse one physical interface defect reported + # from both ICs (e.g. a 5V-into-3V3 net flagged once per endpoint) into a + # single finding. Runs once after all per-IC reviews complete. + cross_ic_dedup_enabled: bool = True + + # Paths (relative to project root, used by LocalStorageBackend) + data_dir: Path = _PROJECT_ROOT / "data" + taxonomy_dir: Path = _PROJECT_ROOT / "taxonomy" + + # GCS (if set, use GCSStorageBackend; otherwise LocalStorageBackend) + gcs_bucket: str = "" + + # Clerk authentication + clerk_secret_key: str = "" + clerk_publishable_key: str = "" + clerk_jwks_url: str = "" + + # DigiKey API (optional — enables auto-fetch datasheets) + digikey_client_id: str = "" + digikey_client_secret: str = "" + digikey_environment: str = "production" + digikey_locale_site: str = "US" + digikey_locale_language: str = "en" + digikey_locale_currency: str = "USD" + + # Purple Parts API (optional — converts LCSC codes to MPNs before DigiKey) + purple_parts_url: str = "" + purple_parts_api_key: str = "" + + # Email notifications (Gmail API via service account) + email_sender: str = "" + email_frontend_url: str = "" + email_admin_notify: str = "" # fixed recipient for pipeline-started alerts + contact_recipient: str = "" # where /api/contact submissions are delivered + + # Stripe billing (pay-as-you-go only — no subscription prices needed) + stripe_secret_key: str = "" + stripe_webhook_secret: str = "" + + # Open-core: master switch for the credits/Stripe billing system. + # True = credit gating + charges + billing/credits routers. + # False = OSS/self-host mode: pipelines run free, billing routes unmounted. + # Defaults to whether the private billing modules exist in this checkout + # (present in the cloud/gateway repo, absent in the open-source core), so + # a bare core checkout runs free with no configuration. An explicit + # BILLING_ENABLED env var always wins. + billing_enabled: bool = Field( + default_factory=lambda: importlib.util.find_spec( + "backend.services.stripe_billing" + ) + is not None + ) + + # Onboarding survey (Google Sheet) + survey_sheet_id: str = "" + + # CORS + cors_origins: list[str] = ["http://localhost:3000"] + + # Cloud Run Job worker (pipeline runner) + pipeline_worker_job_name: str = "pinscopex-pipeline-worker" + pipeline_worker_region: str = "us-central1" + pipeline_worker_project: str = "" # GCP project id; defaults to GOOGLE_CLOUD_PROJECT or metadata + pipeline_worker_timeout_seconds: int = 3600 + + # Sweeper: a "running" project is considered stale if its last update + # timestamp is older than this and the worker execution is in a + # terminal Cloud Run state (or the executor isn't reachable). + pipeline_sweeper_stale_seconds: int = 60 + + model_config = { + "env_file": str(_BACKEND_DIR / ".env"), + "env_file_encoding": "utf-8", + "extra": "ignore", + } + + @property + def use_stripe(self) -> bool: + return bool(self.stripe_secret_key) + + @property + def use_digikey(self) -> bool: + return bool(self.digikey_client_id and self.digikey_client_secret) + + @property + def use_purple_parts(self) -> bool: + return bool(self.purple_parts_url and self.purple_parts_api_key) + + @property + def use_gcs(self) -> bool: + return bool(self.gcs_bucket) + + @property + def use_auth(self) -> bool: + return bool(self.clerk_secret_key and self.clerk_jwks_url) + + @property + def use_email(self) -> bool: + return bool(self.email_sender and self.email_frontend_url) + + def provider_for_stage(self, stage: str) -> str: + """Return the LLM provider name for a pipeline stage.""" + override = getattr(self, f"provider_{stage}", "") + return override or self.provider_default + + def model_for_stage(self, stage: str) -> str: + """Return the model for a pipeline stage, provider-aware. + + For Anthropic: falls back to model_, then anthropic_model. + For Gemini: falls back to model__gemini, then gemini_model. + """ + provider = self.provider_for_stage(stage) + if provider == "gemini": + override = getattr(self, f"model_{stage}_gemini", "") + return override or self.gemini_model + override = getattr(self, f"model_{stage}", "") + return override or self.anthropic_model + + def fallback_for_stage(self, stage: str) -> tuple[str, str] | None: + """Return (provider, model) for the stage's fallback, or None if no + fallback is configured. Used by call_with_fallback() to retry once + when the primary provider raises. + """ + fb_provider = getattr(self, f"fallback_provider_{stage}", "") + if not fb_provider: + return None + fb_model = getattr(self, f"fallback_model_{stage}", "") + if not fb_model: + fb_model = self.gemini_model if fb_provider == "gemini" else self.anthropic_model + return (fb_provider, fb_model) + + def get_default_model_version(self) -> str: + """Return the default model_version for new extractions from skills_manifest.json.""" + return _SKILLS_MANIFEST.get("default_model_version", "1.0.0") + + def get_skill(self, name: str) -> tuple[str, str]: + """Return (skill_id, version) from skills_manifest.json or raise.""" + entry = _SKILLS_MANIFEST.get(name) + if not entry: + raise RuntimeError( + f"Skill '{name}' not found in {_MANIFEST_PATH}. " + f"Run scripts/upload_skills.py to create skills." + ) + return entry["skill_id"], entry["latest_version"] + + +settings = Settings() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..7ab7cf3 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,157 @@ +"""PinscopeX backend — FastAPI application.""" + +import logging +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from backend.config import settings +from backend.routers import admin, contact, feedback, pipeline, projects, reports, survey +from backend.services.projects import ProjectNotFound +from backend.services.storage import LocalStorageBackend + +logger = logging.getLogger(__name__) + +# Default user ID for unauthenticated local dev +LOCAL_DEV_USER = "local" + + +def _create_storage(): + """Create the appropriate storage backend based on config.""" + if settings.use_gcs: + from backend.services.storage_gcs import GCSStorageBackend + + return GCSStorageBackend(settings.gcs_bucket) + return LocalStorageBackend(settings.data_dir) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Guard: refuse to start in production without authentication + env = os.getenv("ENVIRONMENT", "").lower() + if env == "production" and not settings.use_auth: + raise RuntimeError( + "CLERK_JWKS_URL and CLERK_SECRET_KEY must be set in production. " + "Authentication cannot be disabled in production." + ) + if not settings.use_auth: + logger.warning( + "Authentication is DISABLED — all users have full access. " + "This is only safe for local development." + ) + if not settings.billing_enabled: + logger.warning( + "Billing is DISABLED — pipelines run free and the billing/credits " + "routes are not mounted." + ) + + app.state.storage = _create_storage() + + # For local backend, ensure base directories exist + if isinstance(app.state.storage, LocalStorageBackend): + base = settings.data_dir + (base / "users").mkdir(parents=True, exist_ok=True) + (base / "library" / "extracted").mkdir(parents=True, exist_ok=True) + (base / "library" / "patterns").mkdir(parents=True, exist_ok=True) + (base / "library" / "models").mkdir(parents=True, exist_ok=True) + yield + # Pipelines run in a separate Cloud Run Job worker (or local + # subprocess in dev), so the API process has nothing to clean up + # on shutdown. + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Add standard security headers to all responses.""" + + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if settings.use_auth: + # Only set HSTS when running behind TLS in production + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + return response + + +class AuthMiddleware(BaseHTTPMiddleware): + """Extract user_id from Clerk JWT or default to local dev user.""" + + async def dispatch(self, request: Request, call_next): + # Let CORS preflight through — browsers send OPTIONS without credentials + if request.method == "OPTIONS": + return await call_next(request) + # Public endpoints that don't require authentication + if request.url.path == "/api/contact": + request.state.user_id = LOCAL_DEV_USER + return await call_next(request) + if settings.use_auth: + from backend.middleware.auth import verify_clerk_token + + user_id = await verify_clerk_token(request) + if user_id is None: + is_production = os.getenv("ENVIRONMENT", "").lower() == "production" + if is_production: + from fastapi.responses import JSONResponse + + return JSONResponse( + status_code=401, + content={"detail": "Authentication required"}, + ) + # Non-production: fall back to local dev user so Clerk config + # doesn't block local development when no token is present. + user_id = LOCAL_DEV_USER + request.state.user_id = user_id + else: + request.state.user_id = LOCAL_DEV_USER + response = await call_next(request) + return response + + +app = FastAPI( + title="PinscopeX", + description="Agentic schematic validation API", + lifespan=lifespan, +) + +# Middleware order matters: Starlette applies in LIFO order (last added = +# outermost). CORSMiddleware MUST be outermost so that CORS headers are +# present on every response — including 401s from AuthMiddleware. +app.add_middleware(AuthMiddleware) +app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["content-type", "authorization"], + expose_headers=["X-Datasheet-Url"], +) + +@app.exception_handler(ProjectNotFound) +async def _project_not_found_handler(request: Request, exc: ProjectNotFound): + # A mutation raced a project deletion (or hit never-fully-created metadata). + # Return a clean 404 — CORSMiddleware is outermost, so headers still land. + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + +app.include_router(projects.router, prefix="/api") +app.include_router(pipeline.router, prefix="/api") +app.include_router(reports.router, prefix="/api") +app.include_router(admin.router, prefix="/api") +if settings.billing_enabled: + # Import guarded too: with billing disabled the core never loads the + # billing/credits routers (or, transitively, the Stripe SDK). + from backend.routers import billing, credits + + app.include_router(billing.router, prefix="/api") + app.include_router(credits.router, prefix="/api") +app.include_router(contact.router, prefix="/api") +app.include_router(feedback.router, prefix="/api") +app.include_router(survey.router, prefix="/api") diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/middleware/auth.py b/backend/middleware/auth.py new file mode 100644 index 0000000..0a15b8a --- /dev/null +++ b/backend/middleware/auth.py @@ -0,0 +1,90 @@ +"""Clerk JWT verification for FastAPI. + +Validates JWT tokens from the Authorization header against Clerk's JWKS endpoint. +Extracts user_id (sub claim) for per-user storage scoping. +""" + +from __future__ import annotations + +import time +from typing import Any + +import jwt +from fastapi import Request + +from backend.config import settings + +# JWKS cache +_jwks_client: jwt.PyJWKClient | None = None +_SKIP_PATHS = {"/docs", "/openapi.json", "/redoc", "/health", "/api/billing/webhook"} + + +def _get_jwks_client() -> jwt.PyJWKClient: + global _jwks_client + if _jwks_client is None: + jwks_url = settings.clerk_jwks_url + if not jwks_url: + # Default Clerk JWKS URL derived from publishable key + # Clerk publishable keys start with pk_test_ or pk_live_ + # JWKS is at https://{clerk-frontend-api}/.well-known/jwks.json + # The user must set CLERK_JWKS_URL explicitly + raise RuntimeError( + "CLERK_JWKS_URL must be set for authentication. " + "Find it in your Clerk dashboard under API Keys." + ) + _jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True) + return _jwks_client + + +async def verify_clerk_token(request: Request) -> str | None: + """Verify Clerk JWT and return user_id, or None if invalid. + + Returns None for: + - Missing Authorization header + - Invalid/expired token + - Skip paths (docs, health) + """ + # Skip auth for docs/health endpoints + if request.url.path in _SKIP_PATHS: + return "anonymous" + + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + # Fallback: check query param (EventSource/SSE can't send headers) + token = request.query_params.get("token") + if not token: + return None + else: + token = auth_header[7:] + + try: + client = _get_jwks_client() + signing_key = client.get_signing_key_from_jwt(token) + + payload: dict[str, Any] = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + options={ + "verify_exp": True, + "verify_aud": False, # Clerk doesn't always set aud + "verify_iss": True, + }, + # Clerk tokens use the Clerk instance URL as issuer + # e.g. https://abc123.clerk.accounts.dev from https://abc123.clerk.accounts.dev/.well-known/jwks.json + issuer=settings.clerk_jwks_url.replace("/.well-known/jwks.json", "") if settings.clerk_jwks_url else None, + leeway=10, # 10 second clock skew tolerance + ) + + user_id = payload.get("sub") + if not user_id: + return None + + return user_id + + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + except Exception: + return None diff --git a/backend/pinscopex/__init__.py b/backend/pinscopex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/pinscopex/bom_summary.py b/backend/pinscopex/bom_summary.py new file mode 100644 index 0000000..f8e1be9 --- /dev/null +++ b/backend/pinscopex/bom_summary.py @@ -0,0 +1,88 @@ +"""Build a BOM summary table from the design graph. No AI — pure collation.""" + +from __future__ import annotations + +from backend.pinscopex.models import ComponentType, DesignGraph +from backend.pinscopex.utils import natural_sort_key + + +def build_bom_summary( + graph: DesignGraph, + datasheet_mpns: set[str] | None = None, + descriptions: dict[str, str] | None = None, +) -> list[dict]: + """Group components by MPN and collate BOM summary rows. + + ``descriptions`` is an optional ``{mpn: description}`` map (e.g. from + extracted ``package_info.description``). When supplied, IC rows get a + ``description`` field — used by the frontend to show what the chip does + in place of the empty Specs cell. + + Returns a list of dicts, each with: + mpn, designators, value, category, specs, description + """ + # Group components by MPN (or by value+type if no MPN) + by_key: dict[str, list] = {} + for comp in graph.components.values(): + key = comp.mpn if comp.mpn else f"__no_mpn__{comp.value}__{comp.component_type}" + by_key.setdefault(key, []).append(comp) + + rows = [] + for comps in by_key.values(): + first = comps[0] + designators = sorted( + [c.reference for c in comps], key=natural_sort_key + ) + + # Extract display-friendly specs + specs_dict = None + if first.specs: + if hasattr(first.specs, "values"): + # SimpleComponentSpecs — flatten the values dict + raw = {k: v for k, v in first.specs.values.items() if v is not None} + else: + raw = first.specs.model_dump(exclude={"specs_type"}) + # Drop None values and internal numeric fields + raw = { + k: v for k, v in raw.items() + if v is not None and k not in ("value_ohms", "value_farads", "value_henries") + } + specs_dict = raw if raw else None + + has_ds = bool( + first.mpn + and datasheet_mpns is not None + and first.mpn in datasheet_mpns + ) + + description = None + if ( + descriptions is not None + and first.mpn + and first.component_type == ComponentType.IC + ): + description = descriptions.get(first.mpn) + + rows.append({ + "mpn": first.mpn, + "designators": designators, + "value": first.value, + "category": first.component_subtype, + "specs": specs_dict, + "description": description, + "has_datasheet": has_ds, + }) + + # Sort: ICs first, then passives, then others; within each by category then MPN + def sort_key(row: dict) -> tuple: + cat = row["category"] or "" + if cat.startswith("ic"): + group = 0 + elif cat.startswith("passive"): + group = 1 + else: + group = 2 + return (group, cat, row["mpn"] or "") + + rows.sort(key=sort_key) + return rows diff --git a/backend/pinscopex/derating.py b/backend/pinscopex/derating.py new file mode 100644 index 0000000..e54d3f9 --- /dev/null +++ b/backend/pinscopex/derating.py @@ -0,0 +1,123 @@ +"""Build a capacitor voltage derating table from the design graph. No AI — pure computation.""" + +from __future__ import annotations + +import re + +from backend.pinscopex.models import ComponentType, DesignGraph, NetType +from backend.pinscopex.utils import natural_sort_key + +# Dielectric strings that indicate ceramic capacitors +_CERAMIC_DIELECTRICS = {"X7R", "X5R", "C0G", "NP0", "Y5V", "X7S", "X6S", "X8R", "C0G (NP0)"} + + +def _parse_voltage_rating(s: str | None) -> float | None: + """Extract numeric voltage from a rating string like '16V', '25V', '2.5V'.""" + if not s: + return None + m = re.match(r"([\d.]+)", s) + return float(m.group(1)) if m else None + + +def _dielectric_category(component_subtype: str | None, dielectric: str | None) -> str | None: + """Map component subtype / dielectric to a derating category.""" + if component_subtype: + low = component_subtype.lower() + if "tantalum" in low: + return "tantalum" + if "electrolytic" in low: + return "electrolytic" + if "ceramic" in low: + return "ceramic" + + if dielectric: + upper = dielectric.upper().strip() + if upper in _CERAMIC_DIELECTRICS or any(d in upper for d in _CERAMIC_DIELECTRICS): + return "ceramic" + low = dielectric.lower() + if "tantalum" in low or low == "ta": + return "tantalum" + if "electrolytic" in low or low == "al": + return "electrolytic" + + # Default to ceramic (most common) + return "ceramic" + + +def build_derating_table(graph: DesignGraph) -> list[dict]: + """Build a capacitor voltage derating table from the design graph. + + For each capacitor, determines: + - Rated voltage (from specs) + - Operating voltage (from connected net voltages) + - Dielectric category (ceramic / tantalum / electrolytic) + + Returns a sorted list of dicts, one per capacitor designator. + """ + rows: list[dict] = [] + + for comp in graph.components.values(): + if comp.component_type != ComponentType.CAPACITOR: + continue + + # Rated voltage from specs + rated_v: float | None = None + value_fmt: str | None = None + dielectric: str | None = None + if comp.specs and hasattr(comp.specs, "voltage_rating_v"): + rated_v = _parse_voltage_rating(comp.specs.voltage_rating_v) + value_fmt = getattr(comp.specs, "value_formatted", None) + dielectric = getattr(comp.specs, "dielectric", None) + + # Operating voltage: max non-zero voltage among connected nets + op_voltage: float | None = None + op_source: str | None = None + for net_name in comp.pins.values(): + net = graph.nets.get(net_name) + if net and net.voltage is not None and net.voltage > 0: + if op_voltage is None or net.voltage > op_voltage: + op_voltage = net.voltage + op_source = net_name + + # Determine net+ (highest voltage) and net- (ground / lowest voltage). + # Deduplicate net names (multi-pin caps may connect twice to same net). + seen: set[str] = set() + connected: list[tuple[str, float | None, NetType | None]] = [] + for net_name in comp.pins.values(): + if net_name in seen: + continue + seen.add(net_name) + net = graph.nets.get(net_name) + v = net.voltage if net else None + nt = net.net_type if net else None + connected.append((net_name, v, nt)) + + net_plus: str | None = None + net_minus: str | None = None + if len(connected) == 1: + # Single-net cap (both pins on same net) — show as net+ + net_plus = connected[0][0] + elif len(connected) >= 2: + # Sort: ground first, then ascending by voltage (None < any number) + by_v = sorted(connected, key=lambda c: ( + c[2] != NetType.GROUND, # ground nets first + c[1] is not None, # None before numbers + c[1] or 0, # ascending voltage + )) + net_minus = by_v[0][0] + net_plus = by_v[-1][0] + + rows.append({ + "designator": comp.reference, + "mpn": comp.mpn, + "value_formatted": value_fmt, + "rated_voltage_v": rated_v, + "operating_voltage_v": op_voltage, + "operating_voltage_source": op_source, + "net_plus": net_plus, + "net_minus": net_minus, + "dielectric_category": _dielectric_category(comp.component_subtype, dielectric), + }) + + rows.sort(key=lambda r: natural_sort_key(r["designator"])) + return rows diff --git a/backend/pinscopex/graph.py b/backend/pinscopex/graph.py new file mode 100644 index 0000000..9ad823f --- /dev/null +++ b/backend/pinscopex/graph.py @@ -0,0 +1,374 @@ +"""Build a DesignGraph deterministically from netlist + BOM + extracted datasheets.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from backend.pinscopex.utils import safe_mpn +from backend.pinscopex.models import ( + Component, + ComponentConstraints, + ComponentModel, + ComponentSpecs, + ComponentType, + DesignGraph, + Net, + NetType, + PinConnection, + SimpleComponentSpecs, +) + +# Datasheets are loaded here for pin-name enrichment during graph build, +# but NOT embedded into the graph. The validator loads them separately. +from backend.pinscopex.parsers import parse_bom, parse_netlist_any +from backend.pinscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs + +# --------------------------------------------------------------------------- +# Component type classification +# --------------------------------------------------------------------------- + +_PREFIX_TYPE: dict[str, ComponentType] = { + "R": ComponentType.RESISTOR, + "C": ComponentType.CAPACITOR, + "L": ComponentType.INDUCTOR, + "U": ComponentType.IC, + "IC": ComponentType.IC, + "J": ComponentType.CONNECTOR, + "X": ComponentType.CRYSTAL, + "Y": ComponentType.CRYSTAL, + "D": ComponentType.DISCRETE, + "LED": ComponentType.DISCRETE, + "Q": ComponentType.DISCRETE, + "T": ComponentType.TRANSFORMER, + "F": ComponentType.FUSE, + "SW": ComponentType.SWITCH, + "TP": ComponentType.TEST_POINT, + "FM": ComponentType.FIDUCIAL, + "MH": ComponentType.MECHANICAL, +} + +# Fallback footprint patterns for designators whose prefix isn't a known +# EE convention (e.g. pure-numeric refs like "4", descriptive refs like +# "CV GND", "CAN BUS IN", "12V ACTIVE"). Order matters — first match wins. +_FOOTPRINT_TYPE_PATTERNS: list[tuple[re.Pattern, ComponentType]] = [ + (re.compile( + r"(?i)(?:^|[\s_])(" + r"CONN(?:_|\b)|TERM(?:\b|_BLK)|HEADER|SOCKET|JACK|RECEPTACLE|PLUG|" + r"SCREW\s*TERM|PINHEADER|BARREL|BANANA|XT30|XT60|XT90|USB|" + r"WURTH\s*746\d|TE\s*282834|TE\s*2828\d|MOLEX|JST" + r")" + ), ComponentType.CONNECTOR), + (re.compile(r"(?i)TestPoint|TEST[_\s]POINT|\bTP_"), ComponentType.TEST_POINT), + (re.compile(r"(?i)^LED[\s_]|\bLED\s+\d{3,4}"), ComponentType.DISCRETE), + (re.compile(r"(?i)^CAP[\s_]|\bCAP_|CAPACITOR"), ComponentType.CAPACITOR), + (re.compile(r"(?i)^RES[\s_]|\bRES_|RESISTOR"), ComponentType.RESISTOR), + (re.compile(r"(?i)^IND[\s_]|\bIND_|INDUCTOR"), ComponentType.INDUCTOR), + (re.compile(r"(?i)DO214|DO220|SOD\d|SMD?J5|SMB_|SOT-?23"), ComponentType.DISCRETE), +] + + +def _classify_component(ref: str, footprint: str) -> ComponentType: + """Classify a component by its reference prefix, with footprint fallback.""" + prefix = re.match(r"^[A-Za-z]+", ref) + if prefix: + t = _PREFIX_TYPE.get(prefix.group()) + if t is not None: + return t + # Fallback: use footprint hints when the ref prefix isn't recognised + # (e.g. pure-numeric refs, or descriptive refs like "CV GND", "12V ACTIVE") + fp = footprint or "" + for pattern, ctype in _FOOTPRINT_TYPE_PATTERNS: + if pattern.search(fp): + return ctype + return ComponentType.UNKNOWN + + +# --------------------------------------------------------------------------- +# Net type / voltage inference +# --------------------------------------------------------------------------- + +# Patterns for common power rail names -> nominal voltage +_VOLTAGE_RE: list[tuple[re.Pattern, float]] = [ + (re.compile(r"^\+(\d+)V(\d+)$"), 0), # +3V3 -> 3.3, +1V35 -> 1.35 + (re.compile(r"^\+(\d+(?:\.\d+)?)V$"), 0), # +5V -> 5.0, +12V -> 12.0 +] + + +def _parse_rail_voltage(name: str) -> float | None: + """Try to extract a numeric voltage from a power-rail net name. + + Handles patterns like: +3V3, +5V, VDD_1V8, DVDD3V3, VBUS_5V0, etc. + """ + # +3V3 style: digits + V + digits -> "3.3" + m = re.match(r"^\+(\d+)V(\d+)$", name) + if m: + return float(f"{m.group(1)}.{m.group(2)}") + + # +5V style + m = re.match(r"^\+(\d+(?:\.\d+)?)V$", name) + if m: + return float(m.group(1)) + + # Embedded voltage: *_1V8, *_3V3, *1V35, *3V3, etc. + m = re.search(r"(\d+)V(\d+)", name) + if m: + return float(f"{m.group(1)}.{m.group(2)}") + + # Embedded voltage: *_5V0, *_12V, *5V, etc. + m = re.search(r"(\d+(?:\.\d+)?)V(?:\d|$|_)", name) + if m: + return float(m.group(1)) + + return None + + +# Net name prefixes that indicate power rails (case-insensitive) +_POWER_PREFIXES = ( + "VCC", "VDD", "VBUS", "VBAT", "VSYS", "VSUP", "VPWR", + "AVDD", "DVDD", "AVCC", "DVCC", "PVDD", "PVCC", + "V_", +) + +# Net name suffixes that indicate ground (case-insensitive) +_GROUND_SUFFIXES = ("_GND", "GND") +_GROUND_NAMES = {"GND", "AGND", "DGND", "PGND", "VSS", "AVSS", "DVSS", "PVSS"} + + +def _infer_net_properties(name: str) -> tuple[NetType, float | None]: + """Deterministically classify a net by its name.""" + upper = name.upper() + + # Ground nets — exact names and suffixes + if upper in _GROUND_NAMES or any(upper.endswith(s) for s in _GROUND_SUFFIXES): + return NetType.GROUND, 0.0 + + # Power rails: names starting with "+" + if name.startswith("+"): + voltage = _parse_rail_voltage(name) + return NetType.POWER, voltage + + # Power rails: common prefixes (VDD, VCC, VBUS, etc.) + if any(upper.startswith(p) for p in _POWER_PREFIXES): + voltage = _parse_rail_voltage(name) + return NetType.POWER, voltage + + # Everything else is a signal + return NetType.SIGNAL, None + + +# --------------------------------------------------------------------------- +# Datasheet loading +# --------------------------------------------------------------------------- + + +def _load_datasheets(directory: str | Path) -> dict[str, tuple[Path, ComponentConstraints]]: + """Load all extracted datasheet JSONs, keyed by MPN.""" + result: dict[str, tuple[Path, ComponentConstraints]] = {} + dirpath = Path(directory) + if not dirpath.is_dir(): + return result + + for json_file in dirpath.glob("*.json"): + raw = json.loads(json_file.read_text()) + constraints = ComponentConstraints.model_validate(raw) + result[constraints.mpn] = (json_file, constraints) + + return result + + +def _match_datasheet( + mpn: str | None, + datasheets: dict[str, tuple[Path, ComponentConstraints]], +) -> tuple[Path | None, ComponentConstraints | None]: + """Match a BOM MPN to an extracted datasheet. Tries exact then normalized.""" + if not mpn: + return None, None + + # Exact match + if mpn in datasheets: + return datasheets[mpn] + + # Normalize: strip common suffixes, lowercase compare + def _norm(s: str) -> str: + return re.sub(r"[/_\-\s]", "", s).upper() + + mpn_norm = _norm(mpn) + for ds_mpn, (path, constraints) in datasheets.items(): + if _norm(ds_mpn) == mpn_norm: + return path, constraints + + return None, None + + +# --------------------------------------------------------------------------- +# Component model loading / saving (passive specs cache) +# --------------------------------------------------------------------------- + + +def _load_component_models(directory: str | Path) -> dict[str, ComponentSpecs]: + """Load all component model JSONs, keyed by MPN.""" + result: dict[str, ComponentSpecs] = {} + dirpath = Path(directory) + if not dirpath.is_dir(): + return result + for json_file in dirpath.glob("*.json"): + raw = json.loads(json_file.read_text()) + model = ComponentModel.model_validate(raw) + result[model.mpn] = model.specs + return result + + +def _save_component_model(mpn: str, specs: ComponentSpecs, directory: Path) -> None: + """Save a ComponentModel to the component-models directory.""" + directory.mkdir(parents=True, exist_ok=True) + safe_name = safe_mpn(mpn) + model = ComponentModel(mpn=mpn, specs=specs) + (directory / f"{safe_name}.json").write_text( + model.model_dump_json(indent=2) + "\n" + ) + + +# --------------------------------------------------------------------------- +# Graph builder +# --------------------------------------------------------------------------- + + +def build_graph( + netlist_path: str | Path, + bom_path: str | Path, + datasheets_dir: str | Path = "datasheets/extracted", + patterns_dir: str | Path = "component-patterns", + component_models_dir: str | Path = "component-models", + *, + reference_col: str = "Reference", + mpn_col: str = "Manufacturer Part Number", + skipped: list[SkippedItem] | None = None, + include_subdesigns: set[str] | None = None, +) -> DesignGraph: + """Build a DesignGraph deterministically from project files. + + Steps: + 1. Parse netlist -> parts (ref, footprint) and nets (name, pin connections) + 2. Parse BOM -> values, MPNs, LCSC codes per reference + 3. Load extracted datasheets and match by MPN + 4. Resolve passive specs from patterns + cached component models + 5. Assemble components with classified type, linked constraints, and specs + 6. Assemble nets with inferred type/voltage and enriched pin names + """ + # Parse BOM first so we can feed known refs into the netlist parser — + # PADS-PCB netlists allow multi-word designators (e.g. "CV GND"), which + # only tokenise correctly with the BOM's ref list as a lookup. EDIF + # netlists ignore known_refs (designators are unambiguous tokens). + bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col) + parts, raw_nets, _ = parse_netlist_any( + netlist_path, + known_refs=set(bom.keys()), + include_subdesigns=include_subdesigns, + ) + datasheets = _load_datasheets(datasheets_dir) + + # --- Resolve passive specs ------------------------------------------------ + models_dir = Path(component_models_dir) + mpn_specs: dict[str, ComponentSpecs] = _load_component_models(models_dir) + mpn_subtype: dict[str, str] = {} # MPN -> component_subtype from patterns + + for rp in resolve_bom(bom_path, patterns_dir, reference_col=reference_col, mpn_col=mpn_col, skipped=skipped): + if rp.component_subtype: + mpn_subtype[rp.mpn] = rp.component_subtype + if rp.mpn not in mpn_specs: + try: + specs = resolved_to_specs(rp) + mpn_specs[rp.mpn] = specs + _save_component_model(rp.mpn, specs, models_dir) + except Exception as e: + if skipped is not None: + skipped.append(SkippedItem(rp.mpn, "passive_specs", str(e))) + + components: dict[str, Component] = {} + nets: dict[str, Net] = {} + + # --- Build components --------------------------------------------------- + # Some PADS-PCB netlist exports omit the *PART* section. When that happens + # derive the component list from BOM entries + refs found in nets so the + # graph is still fully populated. + if not parts: + net_refs = {ref for pins in raw_nets.values() for ref, _ in pins} + all_refs = set(bom.keys()) | net_refs + parts = {ref: bom.get(ref, {}).get("footprint", "") for ref in all_refs} + + for ref, footprint in parts.items(): + bom_entry = bom.get(ref, {}) + value = bom_entry.get("value", "") + mpn = bom_entry.get("mpn") + + components[ref] = Component( + reference=ref, + value=value, + footprint=footprint, + component_type=_classify_component(ref, footprint), + mpn=mpn, + pins={}, + ) + + # Build MPN -> constraints lookup for pin-name enrichment and subtype + _constraints_by_ref: dict[str, ComponentConstraints] = {} + for ref, comp in components.items(): + if comp.mpn: + _, constraints = _match_datasheet(comp.mpn, datasheets) + if constraints: + _constraints_by_ref[ref] = constraints + if constraints.component_subtype: + comp.component_subtype = constraints.component_subtype + # Attach specs (passive or simple component) and subtype + if comp.mpn in mpn_specs: + comp.specs = mpn_specs[comp.mpn] + # SimpleComponentSpecs carries its own subtype + if not comp.component_subtype: + s = mpn_specs[comp.mpn] + if hasattr(s, "component_subtype") and s.component_subtype: + comp.component_subtype = s.component_subtype + if not comp.component_subtype and comp.mpn in mpn_subtype: + comp.component_subtype = mpn_subtype[comp.mpn] + + # --- Build nets and wire up pins ---------------------------------------- + + for net_name, pin_list in raw_nets.items(): + net_type, voltage = _infer_net_properties(net_name) + + pin_connections: list[PinConnection] = [] + for ref, pin_num in pin_list: + # Record on the component side: pin -> net + if ref in components: + components[ref].pins[pin_num] = net_name + + # Enrich pin name from datasheet (IC constraints or simple specs) + pin_name = None + constraints = _constraints_by_ref.get(ref) + if constraints: + pin_obj = constraints.pin_by_number(pin_num) + if pin_obj: + pin_name = pin_obj.name + elif ref in components and components[ref].mpn: + # Check SimpleComponentSpecs pintable + s = mpn_specs.get(components[ref].mpn) + if isinstance(s, SimpleComponentSpecs) and s.pintable: + pin_obj = s.pin_by_number(pin_num) + if pin_obj: + pin_name = pin_obj.name + + pin_connections.append(PinConnection( + component_ref=ref, + pin_number=pin_num, + pin_name=pin_name, + )) + + nets[net_name] = Net( + name=net_name, + net_type=net_type, + voltage=voltage, + pins=pin_connections, + ) + + return DesignGraph(components=components, nets=nets) diff --git a/backend/pinscopex/led_current_check.py b/backend/pinscopex/led_current_check.py new file mode 100644 index 0000000..ab0091d --- /dev/null +++ b/backend/pinscopex/led_current_check.py @@ -0,0 +1,320 @@ +"""Deterministic LED forward-current check. + +For each LED, compute the worst-case forward current per channel +``I = (V_rail - Vf) / R`` (0 V driver drop) and compare against the LED's +datasheet forward-current rating. Over-current is a hard ERROR; ambiguous cases +(unknown rail, no rating, no resistor found, possible constant-current driver) +are left alone or flagged WARNING rather than guessed. One finding per LED — +the worst offending channel. + +All inputs come straight off the design graph — the LED's extracted specs +(``Component.specs.values``: per-colour ``forward_voltage_*_v``, +``forward_current_per_channel_a`` / ``forward_current_a``) and the series +resistor's ``value_ohms`` (or parsed ``value`` string). Nothing is re-fetched. +""" + +from __future__ import annotations + +import re + +from backend.pinscopex.models import ComponentType, DesignGraph, Finding, NetType +from backend.pinscopex.resolve_passives import _parse_spice_value + +_COLOR_TOKENS = { + "R": "red", "RED": "red", + "G": "green", "GRN": "green", "GREEN": "green", + "B": "blue", "BLU": "blue", "BLUE": "blue", +} + + +# --------------------------------------------------------------------------- +# Value parsing +# --------------------------------------------------------------------------- + +def _num(v: object) -> float | None: + """Parse a free-form spec value ("13mA", "2.8V", "3.3V typ, 4V max", or a + bare float) to a float in base units, or None.""" + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + s = str(v).strip() + for cand in (s, *re.findall(r"[-+]?\d*\.?\d+\s*[a-zA-Zµ]*", s)): + cand = cand.strip() + if not cand: + continue + try: + return _parse_spice_value(cand) + except ValueError: + pass + m = re.match(r"^[-+]?\d*\.?\d+", cand) + if m: + try: + return float(m.group(0)) + except ValueError: + pass + return None + + +def _parse_resistance(v: object) -> float | None: + """Parse a resistance string to ohms: "5.6K"->5600, "5K6"->5600, + "150R"->150, "4R7"->4.7, "1M"->1e6, "0"->0.""" + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + t = str(v).strip().upper().replace("OHMS", "").replace("OHM", "").replace("Ω", "").replace(" ", "") + if not t: + return None + mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9} + m = re.match(r"^(\d+)([RKMG])(\d+)$", t) # 5K6, 4R7, 1M5 + if m: + return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult[m.group(2)] + m = re.match(r"^(\d*\.?\d+)([RKMG])$", t) # 5.6K, 150R, 1M + if m: + return float(m.group(1)) * mult[m.group(2)] + try: + return float(t) + except ValueError: + return None + + +def _spec(values: dict, *keys: str) -> float | None: + for k in keys: + if k in values: + n = _num(values[k]) + if n is not None: + return n + return None + + +def _imax(values: dict) -> float | None: + """LED forward-current rating in amps.""" + i = _spec(values, "forward_current_per_channel_a", "forward_current_a", + "max_forward_current_a", "if_max_a") + if i is None: + return None + # A per-channel LED current >= 1 A is almost certainly mA written without a + # unit (e.g. "13" meaning 13 mA) — scale down. + if i >= 1.0: + i = i / 1000.0 + return i + + +def _vf(values: dict, color: str | None) -> float | None: + vf = None + if color: + vf = _spec(values, f"forward_voltage_{color}_v") + if vf is None: + vf = _spec(values, "forward_voltage_v", "vf_v") + if vf is None: + cands = [_spec(values, f"forward_voltage_{c}_v") for c in ("red", "green", "blue")] + cands = [c for c in cands if c is not None] + vf = min(cands) if cands else None # lowest Vf = most conservative (highest I) + if vf is not None and vf > 20: # mV given without scaling + vf = vf / 1000.0 + return vf + + +# --------------------------------------------------------------------------- +# Graph helpers +# --------------------------------------------------------------------------- + +def _net_voltage(graph: DesignGraph, net_name: str | None) -> float | None: + if not net_name: + return None + net = graph.nets.get(net_name) + return net.voltage if net else None + + +def _is_rail_net(graph: DesignGraph, net_name: str) -> bool: + net = graph.nets.get(net_name) + if not net: + return False + return net.net_type in (NetType.POWER, NetType.GROUND) or net.voltage is not None + + +def _series_resistor(graph: DesignGraph, net_name: str, exclude_ref: str): + """Return (resistor_ref, ohms, far_net) for a 2-terminal series resistor on a + private (degree-2) net, or None. Requiring degree 2 ensures the resistor is + truly in series with the LED leg, not merely sharing a bus/rail net.""" + net = graph.nets.get(net_name) + if not net or len(net.pins) != 2: + return None + for pc in net.pins: + if pc.component_ref == exclude_ref: + continue + c = graph.components.get(pc.component_ref) + if not c or c.component_type != ComponentType.RESISTOR: + continue + rval = getattr(c.specs, "value_ohms", None) if c.specs else None + if rval is None: + rval = _parse_resistance(c.value) + if rval is None or rval <= 0: + continue + far = next((n for n in c.pins.values() if n != net_name), None) + return (pc.component_ref, float(rval), far) + return None + + +def _leg_to_ic(graph: DesignGraph, net_name: str, exclude_ref: str) -> bool: + """True if an IC sits on this leg net (possible constant-current driver).""" + for r in graph.components_on_net(net_name): + if r == exclude_ref: + continue + c = graph.components.get(r) + if c and c.component_type == ComponentType.IC: + return True + return False + + +def _leg_color(pid: str, comp) -> str | None: + if pid.upper() in _COLOR_TOKENS: + return _COLOR_TOKENS[pid.upper()] + specs = comp.specs + pin = specs.pin_by_number(pid) if specs and hasattr(specs, "pin_by_number") else None + if pin: + for tok in re.split(r"[\s_/-]+", pin.name.upper()): + if tok in _COLOR_TOKENS: + return _COLOR_TOKENS[tok] + return None + + +# --------------------------------------------------------------------------- +# Per-LED check +# --------------------------------------------------------------------------- + +def check_led_current(graph: DesignGraph) -> list[Finding]: + findings: list[Finding] = [] + for ref in sorted(graph.components_by_subtype("discrete.led")): + comp = graph.components.get(ref) + if not comp or not comp.specs: + continue + values = getattr(comp.specs, "values", None) + if not values: + continue + imax = _imax(values) + if imax is None: + continue # no forward-current rating -> nothing to check against + finding = _check_led(graph, ref, comp, values, imax) + if finding is not None: + findings.append(finding) + return findings + + +def _check_led(graph, ref, comp, values, imax) -> Finding | None: + pins = comp.pins # pid -> net + pin_volts = [v for v in (_net_voltage(graph, n) for n in pins.values()) if v is not None] + + # Channels carrying current sit on private (signal) nets; for a 2-pin LED the + # single channel is whichever pin actually has a series resistor. + if len(pins) <= 2: + leg = next( + ((pid, net, _series_resistor(graph, net, ref)) + for pid, net in pins.items() + if _series_resistor(graph, net, ref)), + None, + ) + if leg is None: + cand = next(((pid, net) for pid, net in pins.items() + if not _is_rail_net(graph, net)), None) + legs_iter = [(cand[0], cand[1], None)] if cand else [] + else: + legs_iter = [leg] + else: + legs_iter = [ + (pid, net, _series_resistor(graph, net, ref)) + for pid, net in pins.items() + if not _is_rail_net(graph, net) + ] + + worst = None # (i, color, net, vrail, vf, rval, rref) + no_res = None # (color, net, vrail, vf) + for pid, net, res in legs_iter: + color = _leg_color(pid, comp) + vf = _vf(values, color) + cand = list(pin_volts) + if res and res[2]: + fv = _net_voltage(graph, res[2]) + if fv is not None: + cand.append(fv) + vrail = max(cand) if cand else None + + if res is None: + if no_res is None and vrail is not None and vrail > 0 and not _leg_to_ic(graph, net, ref): + no_res = (color, net, vrail, vf) + continue + rref, rval, _far = res + if vrail is None or vf is None or vrail <= vf or rval <= 0: + continue + i = (vrail - vf) / rval + if i > imax and (worst is None or i > worst[0]): + worst = (i, color, net, vrail, vf, rval, rref) + + if worst is not None: + i, color, net, vrail, vf, rval, rref = worst + return _over_current_finding(ref, comp, net, color, vrail, vf, rval, rref, imax, i) + if no_res is not None: + color, net, vrail, vf = no_res + return _no_resistor_finding(ref, comp, net, color, vrail, vf, imax) + return None + + +def _chan(color: str | None) -> str: + return f"{color} channel" if color else "LED" + + +def _over_current_finding(ref, comp, net, color, vrail, vf, rval, rref, imax, i) -> Finding: + rmin = (vrail - vf) / imax + return Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="led_current", + source="led_current_check", + source_page=None, + status="ERROR", + finding=( + f"{ref} {_chan(color)} forward current is ~{i * 1000:.0f} mA, " + f"exceeding its {imax * 1000:.0f} mA forward-current rating." + ), + why=( + f"With the supply at {vrail:.1f} V and Vf≈{vf:.1f} V, series resistor " + f"{rref} ({rval:.0f} Ω) on net '{net}' passes " + f"~({vrail:.1f}−{vf:.1f})/{rval:.0f} = {i * 1000:.0f} mA (worst case, " + f"0 V driver drop) — above the {imax * 1000:.0f} mA rating." + ), + recommendation=( + f"Increase the series resistor to at least {rmin:.0f} Ω to keep the " + f"{_chan(color)} at or below {imax * 1000:.0f} mA." + ), + reference=f"{comp.mpn or ref} LED specs", + ) + + +def _no_resistor_finding(ref, comp, net, color, vrail, vf, imax) -> Finding: + rec = "Add a series current-limiting resistor, or confirm a constant-current driver." + if vf is not None and vrail > vf: + rec = ( + f"Add a series resistor of at least {((vrail - vf) / imax):.0f} Ω " + f"(or confirm a constant-current driver)." + ) + return Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="led_current", + source="led_current_check", + source_page=None, + status="WARNING", + finding=( + f"Unverified: {ref} {_chan(color)} has no series current-limiting " + f"resistor on net '{net}'." + ), + why=( + f"The {_chan(color)} on net '{net}' has no series resistor between the " + f"LED and the {vrail:.1f} V supply. If it is not driven by a " + f"constant-current source, forward current can exceed the " + f"{imax * 1000:.0f} mA rating." + ), + recommendation=rec, + reference=f"{comp.mpn or ref} LED specs", + ) diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py new file mode 100644 index 0000000..f5b596c --- /dev/null +++ b/backend/pinscopex/models.py @@ -0,0 +1,411 @@ +"""Pydantic models for PinscopeX: datasheet constraints and design graph.""" + +from __future__ import annotations + +from enum import Enum +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Discriminator, Field, Tag, field_validator + + +class Pin(BaseModel): + number: int | str + name: str + description: str | None = None + functions: list[str] | None = None + + +class PackageInfo(BaseModel): + base_family: str + package: str + pin_count: int + description: str | None = None + + +class AbsMaxRating(BaseModel): + parameter: str + min: float | None = None + max: float | None = None + unit: str + source_page: int + + +class Rule(BaseModel): + rule_id: str | None = None # {MPN}-{001} + description: str + source_page: int + + +def _check_subtype(v: object) -> str | None: + """Shared pre-validator for component_subtype fields.""" + if v is None or v == "": + return None + from backend.pinscopex.taxonomy import validate_subtype + return validate_subtype(str(v)) + + +class ComponentConstraints(BaseModel): + mpn: str + model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor) + component_subtype: str | None = None # dotted taxonomy path, e.g. "ic.ldo", "ic.mcu" + package_info: PackageInfo | None = None + pintable: list[Pin] + absolute_maximum_ratings: list[AbsMaxRating] + rules: list[Rule] + + _validate_subtype = field_validator("component_subtype", mode="before")( + staticmethod(_check_subtype) + ) + + def pin_by_number(self, number: int | str) -> Pin | None: + """Look up a pin by its number.""" + for p in self.pintable: + if str(p.number) == str(number): + return p + return None + + +# --------------------------------------------------------------------------- +# Design graph models +# --------------------------------------------------------------------------- + + +class NetType(str, Enum): + POWER = "power" + GROUND = "ground" + SIGNAL = "signal" + UNKNOWN = "unknown" + + +class ComponentType(str, Enum): + RESISTOR = "resistor" + CAPACITOR = "capacitor" + INDUCTOR = "inductor" + IC = "ic" + CONNECTOR = "connector" + CRYSTAL = "crystal" + DISCRETE = "discrete" + TRANSFORMER = "transformer" + FUSE = "fuse" + SWITCH = "switch" + TEST_POINT = "test_point" + FIDUCIAL = "fiducial" + MECHANICAL = "mechanical" + UNKNOWN = "unknown" + + +# --------------------------------------------------------------------------- +# Component specs taxonomy — type-specific, standardised-unit models +# --------------------------------------------------------------------------- + + +class ResistorSpecs(BaseModel): + """Standardised resistor parameters. Value always in ohms.""" + specs_type: Literal["resistor"] = "resistor" + component_subtype: str | None = None # e.g. "passive.resistor" + value_ohms: float + value_formatted: str + tolerance: str | None = None # "±1%" or "±0.5ohm" + package: str | None = None + power_rating_w: str | None = None + + _validate_subtype = field_validator("component_subtype", mode="before")( + staticmethod(_check_subtype) + ) + + +class CapacitorSpecs(BaseModel): + """Standardised capacitor parameters. Value always in farads.""" + specs_type: Literal["capacitor"] = "capacitor" + component_subtype: str | None = None # e.g. "passive.capacitor.ceramic" + value_farads: float + value_formatted: str + tolerance: str | None = None # "±10%" or "±0.25pF" + package: str | None = None + voltage_rating_v: str | None = None + dielectric: str | None = None + + _validate_subtype = field_validator("component_subtype", mode="before")( + staticmethod(_check_subtype) + ) + + +class InductorSpecs(BaseModel): + """Standardised inductor parameters. Value always in henries.""" + specs_type: Literal["inductor"] = "inductor" + component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead" + value_henries: float + value_formatted: str + tolerance: str | None = None # "±5%" or "±0.1uH" + package: str | None = None + current_rating_a: str | None = None + dcr_ohms: float | None = None + + _validate_subtype = field_validator("component_subtype", mode="before")( + staticmethod(_check_subtype) + ) + + +class SimpleComponentSpecs(BaseModel): + """Specs for discrete/simple components. Schema defined in taxonomy JSON.""" + specs_type: str # taxonomy type: "discrete", "connector", "crystal", etc. + component_subtype: str | None = None + values: dict[str, float | str | None] = {} + pintable: list[Pin] = [] + package_info: PackageInfo | None = None + + _validate_subtype = field_validator("component_subtype", mode="before")( + staticmethod(_check_subtype) + ) + + def pin_by_number(self, number: int | str) -> Pin | None: + """Look up a pin by its number.""" + for p in self.pintable: + if str(p.number) == str(number): + return p + return None + + +def _specs_tag(v: Any) -> str: + """Route to the correct specs model based on specs_type.""" + st = v.get("specs_type") if isinstance(v, dict) else v.specs_type + return st if st in ("resistor", "capacitor", "inductor") else "simple" + + +ComponentSpecs = Annotated[ + Annotated[ResistorSpecs, Tag("resistor")] + | Annotated[CapacitorSpecs, Tag("capacitor")] + | Annotated[InductorSpecs, Tag("inductor")] + | Annotated[SimpleComponentSpecs, Tag("simple")], + Discriminator(_specs_tag), +] + + +class ComponentModel(BaseModel): + """Persisted specs file — one per MPN in component-models/.""" + mpn: str + specs: ComponentSpecs + + +# --------------------------------------------------------------------------- +# Design graph models +# --------------------------------------------------------------------------- + + +class PinConnection(BaseModel): + """A pin on a component that participates in a net.""" + component_ref: str + pin_number: str + pin_name: str | None = None # enriched from datasheet pintable + + +class Net(BaseModel): + """An electrical net with mutable type/voltage for agent refinement.""" + name: str + net_type: NetType = NetType.UNKNOWN + voltage: float | None = None + pins: list[PinConnection] = [] + + +class Component(BaseModel): + """A placed component in the design graph (topology only).""" + reference: str + value: str + footprint: str + component_type: ComponentType = ComponentType.UNKNOWN + component_subtype: str | None = None # dotted taxonomy path, e.g. "ic.ldo", "ic.mcu" + mpn: str | None = None + pins: dict[str, str] = {} # pin_number -> net_name + specs: ComponentSpecs | None = None + + _validate_subtype = field_validator("component_subtype", mode="before")( + staticmethod(_check_subtype) + ) + + +class DesignGraph(BaseModel): + """ + Bipartite design graph: Components <-> Nets. + + Traversal paths: + component.pins[pin_num] -> net_name -> graph.nets[net_name].pins -> other components + net.pins[i].component_ref -> graph.components[ref] -> its other pins/nets + """ + components: dict[str, Component] = {} + nets: dict[str, Net] = {} + + # -- Traversal helpers -------------------------------------------------- + + def components_on_net(self, net_name: str) -> list[str]: + """All component refs connected to a net.""" + net = self.nets.get(net_name) + if not net: + return [] + return list({pc.component_ref for pc in net.pins}) + + def nets_of_component(self, ref: str) -> list[str]: + """All net names a component touches.""" + comp = self.components.get(ref) + if not comp: + return [] + return list(set(comp.pins.values())) + + def neighbors(self, ref: str) -> dict[str, list[str]]: + """Components sharing a net with *ref*, grouped by net name.""" + result: dict[str, list[str]] = {} + for net_name in self.nets_of_component(ref): + others = [r for r in self.components_on_net(net_name) if r != ref] + if others: + result[net_name] = others + return result + + def components_by_type(self, comp_type: ComponentType) -> list[str]: + """All refs matching a component type.""" + return [r for r, c in self.components.items() if c.component_type == comp_type] + + def power_nets(self) -> list[Net]: + """All power and ground nets.""" + return [n for n in self.nets.values() if n.net_type in (NetType.POWER, NetType.GROUND)] + + def capacitors_on_net(self, net_name: str) -> list[str]: + """Capacitor refs connected to a net (useful for decoupling checks).""" + return [ + r for r in self.components_on_net(net_name) + if self.components[r].component_type == ComponentType.CAPACITOR + ] + + def components_by_subtype(self, prefix: str) -> list[str]: + """All refs whose component_subtype starts with *prefix*. + + Examples: + components_by_subtype("ic.power") -> all power ICs + components_by_subtype("passive.capacitor") -> all capacitors + components_by_subtype("passive") -> all passives + """ + prefix_dot = prefix if prefix.endswith(".") else prefix + "." + return [ + r for r, c in self.components.items() + if c.component_subtype and ( + c.component_subtype == prefix + or c.component_subtype.startswith(prefix_dot) + ) + ] + + def pin_net(self, ref: str, pin_number: str) -> str | None: + """Net name for a specific pin on a component.""" + comp = self.components.get(ref) + if not comp: + return None + return comp.pins.get(pin_number) + + +# --------------------------------------------------------------------------- +# Validation report models +# --------------------------------------------------------------------------- + + +class Finding(BaseModel): + """A single review finding — an issue found during direct datasheet review.""" + finding_id: str | None = None + designator: str + mpn: str = "" + aspect: str | None = None # "power_supply", "clock", etc. (for complex ICs) + finding: str # What was observed in the actual circuit + why: str = "" # Why it matters — from the datasheet + source_page: int | None = None # Datasheet page (null for deterministic checks) + source_quote: str = "" # Verbatim datasheet text supporting the finding (for PDF highlight) + source_designator: str | None = None # Designator whose datasheet source_page/source_quote refer to; None = this finding's own `designator`. Set when the evidence came from a connected component's datasheet excerpt (get_datasheet_excerpt), so the viewer opens the right PDF at the right page. + status: Literal["ERROR", "WARNING", "INFO"] + recommendation: str = "" + reference: str = "" + source: str | None = None # None/"review" = LLM datasheet review; "pin_mux_check"/"led_current_check" = deterministic + + +class ValidationReport(BaseModel): + """Full validation output.""" + project: str + timestamp: str + findings: list[Finding] + summary: dict[str, int] + coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK + review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised + not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF) + + +class FindingComment(BaseModel): + """A comment on a finding, stored outside the ValidationReport model.""" + comment_id: str + finding_id: str + user_id: str + user_name: str + text: str + mentions: list[str] = [] + created_at: str + + +# --------------------------------------------------------------------------- +# Passive component pattern models +# --------------------------------------------------------------------------- + + +class PassiveFieldDef(BaseModel): + """One named field in a passive component part number.""" + name: str + position: int + length: int + description: str + lookup: dict[str, str] = {} + + +class ValueDecoder(BaseModel): + """How to decode the value field (resistance/capacitance) into a number. + + letter_multipliers maps characters to power-of-10 exponents (int) or the + special string ``"decimal_point"`` for R-notation (e.g. 4R7 = 4.7 ohms). + """ + type: str # "eia3_pf" | "eia4_ohm_conditional" + base_unit: str # "pF" | "ohm" + output_unit: str # "F" | "ohm" + letter_multipliers: dict[str, int | str] = {} + zero_code: str | None = None + conditional_on: dict | None = None + + +class PassivePattern(BaseModel): + """Regex pattern + field decoders for a passive component family.""" + manufacturer: str + series: str + component_type: ComponentType + component_subtype: str | None = None # dotted taxonomy path, e.g. "passive.capacitor.ceramic" + description: str + regex: str + fields: list[PassiveFieldDef] + value_decoder: ValueDecoder + example_mpns: list[str] = [] + datasheet_key: str | None = None # library storage key for shared datasheet PDF + + _validate_subtype = field_validator("component_subtype", mode="before")( + staticmethod(_check_subtype) + ) + + +class ResolvedPassive(BaseModel): + """Result of resolving a BOM MPN against a stored pattern.""" + mpn: str + references: list[str] + component_type: ComponentType + component_subtype: str | None = None # dotted taxonomy path, e.g. "passive.resistor" + + _validate_subtype = field_validator("component_subtype", mode="before")( + staticmethod(_check_subtype) + ) + manufacturer: str + series: str + value: float + value_formatted: str + tolerance: str | None = None + package: str | None = None + voltage_rating: str | None = None + power_rating: str | None = None + dielectric: str | None = None + raw_fields: dict[str, str] = {} diff --git a/backend/pinscopex/parsers.py b/backend/pinscopex/parsers.py new file mode 100644 index 0000000..e38ffd9 --- /dev/null +++ b/backend/pinscopex/parsers.py @@ -0,0 +1,280 @@ +"""Pure parsers for PADS-PCB netlists and KiCad BOM CSV files.""" + +from __future__ import annotations + +import csv +from pathlib import Path +from typing import Literal + +NetlistFormat = Literal["pads", "edif"] + + +def parse_netlist( + path: str | Path, + known_refs: set[str] | None = None, +) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]]]: + """Parse a PADS-PCB ASCII netlist (.asc). + + PADS-PCB allows reference designators containing spaces (e.g. ``CV GND``, + ``CAN BUS IN``, ``3.3V ACTIVE``). When ``known_refs`` is supplied (typically + from the BOM), tokens are greedily matched to the longest known designator + so multi-word refs parse correctly. Without ``known_refs`` the parser falls + back to single-word tokenisation. + + Returns: + parts: {reference: footprint} + nets: {net_name: [(component_ref, pin_number), ...]} + """ + text = Path(path).read_text() + lines = text.splitlines() + + parts: dict[str, str] = {} + nets: dict[str, list[tuple[str, str]]] = {} + + section = None + current_net: str | None = None + + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + + # Section markers. PADS-PCB headers may carry trailing labels + # (e.g. "*PART* ITEMS" or "*MISC* MISCELLANEOUS PARAMETERS" + # from EasyEDA Pro), so match the marker prefix rather than the whole + # line. Unknown markers (anything starred that we don't recognise) are + # treated as section terminators — without this, EasyEDA Pro's *MISC* + # ATTRIBUTE VALUES block leaks into the net section and "Datasheet" + # URLs / footprint strings get misparsed as pin connections. + if line.startswith("*"): + if line.startswith("*SIGNAL*"): + pass # sub-marker within *NET*; handled in the net branch + elif line.startswith("*PART*"): + section = "part" + current_net = None + continue + elif line.startswith("*NET*"): + section = "net" + current_net = None + continue + elif line.startswith("*END*"): + break + else: + # *PADS-PCB*, *REMARK*, *MISC*, or any unrecognised marker + section = None + current_net = None + continue + + if section == "part": + tokens = line.split() + ref, footprint = _parse_part_tokens(tokens, known_refs) + if ref: + parts[ref] = footprint + + elif section == "net": + if line.startswith("*SIGNAL*"): + current_net = line.split("*SIGNAL*", 1)[1].strip() + if current_net not in nets: + nets[current_net] = [] + elif current_net is not None: + # Pin entries: "REF.PIN REF.PIN ..." (REF may contain spaces) + nets[current_net].extend(_parse_pin_tokens(line.split(), known_refs)) + + # Some PADS-PCB exports omit the *PART* section entirely and ship only + # connectivity. Synthesize parts from refs seen in *SIGNAL* blocks so + # downstream validation and graph-building still work; footprints stay + # empty (the BOM is the source of truth for footprints anyway). + if not parts and nets: + for pins in nets.values(): + for ref, _pin in pins: + parts.setdefault(ref, "") + + return parts, nets + + +def _parse_part_tokens( + tokens: list[str], + known_refs: set[str] | None, +) -> tuple[str | None, str]: + """Split a *PART* line into (ref, footprint), respecting multi-word refs.""" + if not tokens: + return None, "" + + if known_refs: + # Greedy longest-prefix match against known refs + for n in range(min(len(tokens), 8), 0, -1): + candidate = " ".join(tokens[:n]) + if candidate in known_refs: + return candidate, " ".join(tokens[n:]) + + # Fallback: single-word ref, rest is footprint + if len(tokens) >= 2: + return tokens[0], " ".join(tokens[1:]) + return tokens[0], "" + + +def _parse_pin_tokens( + tokens: list[str], + known_refs: set[str] | None, +) -> list[tuple[str, str]]: + """Parse a *SIGNAL* pin line into (ref, pin) pairs. + + Tokens terminate on a ``.`` — everything before (back to the previous + consumed position) is the ref, possibly with internal spaces. + """ + pins: list[tuple[str, str]] = [] + consumed = -1 + + for j, token in enumerate(tokens): + if j <= consumed or "." not in token: + continue + + last_word, pin = token.rsplit(".", 1) + + # Greedy longest match when known_refs is available + if known_refs: + matched_start: int | None = None + for start in range(consumed + 1, j + 1): + parts = tokens[start:j] + ([last_word] if last_word else []) + candidate = " ".join(parts) + if candidate and candidate in known_refs: + matched_start = start + break + if matched_start is not None: + ref = " ".join( + tokens[matched_start:j] + ([last_word] if last_word else []) + ) + pins.append((ref, pin)) + consumed = j + continue + + # Fallback: single-word ref (original behaviour) + ref = last_word + pins.append((ref, pin)) + consumed = j + + return pins + + +def detect_netlist_format(content: bytes | str) -> NetlistFormat: + """Sniff the first chunk of a netlist to decide whether it's PADS or EDIF. + + EDIF s-expressions start with ``(edif …`` (with possible leading whitespace + or BOM); PADS-PCB ASCII files start with ``*PADS-PCB*``. The "pads" branch + is the default when no clear marker is found — preserves the old behavior + where the parser raises a friendly error on unrecognised input. + """ + if isinstance(content, bytes): + try: + text = content[:1024].decode("utf-8", errors="replace") + except Exception: + text = "" + else: + text = content[:1024] + head = text.lstrip("").lstrip() + # Case-insensitive match — EDIF spec allows different capitalisations + # (KiCad emits lowercase; xDX Designer emits lowercase too). + if head[:5].lower() == "(edif": + return "edif" + return "pads" + + +def parse_netlist_any( + path: str | Path, + known_refs: set[str] | None = None, + *, + include_subdesigns: set[str] | None = None, +) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], NetlistFormat]: + """Auto-detect the netlist format and parse. + + Returns ``(parts, nets, format)``. The ``parts`` and ``nets`` shapes match + :func:`parse_netlist`; downstream code (graph build, validation) doesn't + need to know which parser ran. ``known_refs`` is only relevant for PADS — + EDIF designators are unambiguous tokens. ``include_subdesigns`` is only + relevant for EDIF — it filters which ``&NNNN``-prefixed instances and + their nets land in the output (PADS netlists have no sub-design concept). + """ + p = Path(path) + sample = p.read_bytes()[:1024] + fmt = detect_netlist_format(sample) + if fmt == "edif": + from backend.pinscopex.parsers_edif import parse_edif_netlist + parts, nets = parse_edif_netlist(p, include_subdesigns=include_subdesigns) + else: + parts, nets = parse_netlist(p, known_refs=known_refs) + return parts, nets, fmt + + +def validate_netlist(parts: dict, nets: dict) -> list[str]: + """Sanity-check parsed netlist data. Returns a list of error strings (empty = valid).""" + errors: list[str] = [] + + if not parts: + errors.append("No components found — is this a PADS-PCB (.asc) or EDIF (.edn) netlist?") + return errors # further checks are meaningless without parts + + if not nets: + errors.append("No nets found — the connectivity section (*NET*) is missing or empty") + return errors + + # At least some parts must appear in the net connections + refs_in_nets = {ref for pins in nets.values() for ref, _ in pins} + if not (set(parts) & refs_in_nets): + errors.append( + "No components are wired to any net — the connectivity section may be missing or malformed" + ) + + # Every real schematic has a ground net + gnd_names = {"GND", "AGND", "DGND", "PGND", "VSS", "0V"} + has_gnd = any( + n.upper() in gnd_names or n.upper().endswith("GND") or n.upper().startswith("GND") + for n in nets + ) + if not has_gnd: + errors.append( + "No ground net found (expected GND, AGND, DGND, VSS, etc.) — " + "this may not be a complete schematic netlist" + ) + + return errors + + +def parse_bom( + path: str | Path, + *, + reference_col: str = "Reference", + mpn_col: str = "Manufacturer Part Number", +) -> dict[str, dict]: + """Parse a KiCad BOM CSV with grouped references. + + Args: + path: Path to the BOM CSV file. + reference_col: Column name for reference designators. + mpn_col: Column name for manufacturer part numbers. + + Returns: + {reference: {"value": str, "footprint": str, "mpn": str|None, "lcsc": str|None}} + One entry per individual reference (groups are expanded). + """ + result: dict[str, dict] = {} + text = Path(path).read_text() + reader = csv.DictReader(text.splitlines()) + + for row in reader: + refs_raw = row.get(reference_col, "") + value = row.get("Value", "") or row.get("Comment", "") + footprint = row.get("Footprint", "") + mpn = row.get(mpn_col, "") or None + lcsc = row.get("LCSC", "") or None + + # Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"] + for ref in (r.strip() for r in refs_raw.split(",")): + if ref: + result[ref] = { + "value": value, + "footprint": footprint, + "mpn": mpn, + "lcsc": lcsc, + } + + return result diff --git a/backend/pinscopex/parsers_edif.py b/backend/pinscopex/parsers_edif.py new file mode 100644 index 0000000..43b675b --- /dev/null +++ b/backend/pinscopex/parsers_edif.py @@ -0,0 +1,470 @@ +"""Parser for EDIF 2.0.0 netlists (Siemens xDX Designer flavor). + +Yields the same ``(parts, nets)`` shape as :func:`parsers.parse_netlist` so +downstream graph building doesn't care which netlist format the user uploaded. + +Tested against xDX Designer's exporter. Other EDIF 2.0.0 exporters (OrCAD, +Altium, KiCad, Eagle) will *probably* parse — the s-expression handling is +generic and the EDIF instance/cell/net structure is standardised — but they +have not been verified against real files. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Iterator + + +# --------------------------------------------------------------------------- +# Tokenizer + s-expression parser +# --------------------------------------------------------------------------- + + +class _Str(str): + """Marker subclass so quoted-string tokens are distinguishable from atoms. + + Both atoms (e.g. ``viewRef``, ``&0441I3151``) and string values + (e.g. ``"U3"``, ``"GROUND"``) end up as Python ``str`` in the parsed + tree. EDIF rarely needs that distinction — string equality compares the + same way — but the marker is here in case future logic does. + """ + + +def _tokenize(text: str) -> Iterator[object]: + """Yield tokens: ``'('``, ``')'``, atom :class:`str`, or quoted :class:`_Str`.""" + i, n = 0, len(text) + while i < n: + c = text[i] + if c.isspace(): + i += 1 + continue + if c == ";": + # EDIF doesn't really use comments, but tolerate them just in case + while i < n and text[i] != "\n": + i += 1 + continue + if c in "()": + yield c + i += 1 + continue + if c == '"': + j = i + 1 + buf: list[str] = [] + while j < n and text[j] != '"': + if text[j] == "\\" and j + 1 < n: + buf.append(text[j + 1]) + j += 2 + else: + buf.append(text[j]) + j += 1 + yield _Str("".join(buf)) + i = j + 1 + continue + j = i + while j < n and not text[j].isspace() and text[j] not in '()"': + j += 1 + yield text[i:j] + i = j + + +def _parse_sexp(tokens: list[object]) -> list: + """Build a nested list tree. Atoms / strings remain as ``str`` / ``_Str``.""" + it = iter(tokens) + + def parse_form() -> list: + result: list = [] + for tok in it: + if tok == "(": + result.append(parse_form()) + elif tok == ")": + return result + else: + result.append(tok) + return result # unterminated at EOF — return what we have + + top: list = [] + for tok in it: + if tok == "(": + top.append(parse_form()) + elif tok == ")": + raise ValueError("EDIF: unexpected ')' at top level") + else: + top.append(tok) + return top + + +# --------------------------------------------------------------------------- +# Tree walkers +# --------------------------------------------------------------------------- + + +def _walk(node: object, head: str) -> Iterator[list]: + """Yield every nested list whose first element equals ``head``.""" + if not isinstance(node, list): + return + if node and isinstance(node[0], str) and node[0] == head: + yield node + for child in node: + if isinstance(child, list): + yield from _walk(child, head) + + +def _node_id(node: list) -> str | None: + """Return the identifying atom of ``( ...)``. + + Handles ``( (rename &INTERNAL "display") ...)`` by returning + ``&INTERNAL`` — the form used elsewhere by ``cellRef`` / ``instanceRef``. + """ + if len(node) < 2: + return None + second = node[1] + if isinstance(second, list) and len(second) >= 2 and second[0] == "rename": + return str(second[1]) + if isinstance(second, str): + return str(second) + return None + + +def _direct_property(node: list, prop_name: str) -> str | None: + """Return the string value of a ``(property NAME (string "X") ...)`` child. + + Only looks at direct children of ``node`` — does not recurse into nested + forms — so it can be called on an ``instance`` without picking up + properties tucked inside ``portInstance`` blocks. + """ + for child in node: + if not (isinstance(child, list) and len(child) >= 2 and child[0] == "property"): + continue + name_node = child[1] + if isinstance(name_node, list) and name_node and name_node[0] == "rename": + actual = str(name_node[1]) if len(name_node) >= 2 else "" + elif isinstance(name_node, str): + actual = str(name_node) + else: + continue + if actual != prop_name: + continue + for elem in child[2:]: + if isinstance(elem, list) and len(elem) >= 2 and elem[0] == "string": + return str(elem[1]) + return None + + +# --------------------------------------------------------------------------- +# Stage extractors +# --------------------------------------------------------------------------- + + +def _build_cell_library(tree: list) -> dict[tuple[str, str], dict[str, str | None]]: + """Build ``(library_name, cell_id) -> {port_name: pin_type}``. + + ``pin_type`` is ``"GROUND"`` (or any other ``Pin_Type`` property value) when + the cell tagged the port; ``None`` when no Pin_Type property is present. + Used to detect which nets are ground. + """ + cells: dict[tuple[str, str], dict[str, str | None]] = {} + for lib in _walk(tree, "library"): + if len(lib) < 2: + continue + lib_name = str(lib[1]) + for cell in _walk(lib, "cell"): + cell_id = _node_id(cell) + if not cell_id: + continue + port_map: dict[str, str | None] = {} + for port in _walk(cell, "port"): + if len(port) < 2: + continue + port_name = str(port[1]) + port_map[port_name] = _direct_property(port, "Pin_Type") + cells[(lib_name, cell_id)] = port_map + return cells + + +def _find_cell_ref(node: list) -> tuple[str, str] | None: + """From an ``(instance ...)`` form, return ``(library_name, cell_id)`` from + its ``(viewRef VIEW (cellRef CELL (libraryRef LIB)))`` triple.""" + for child in node: + if not (isinstance(child, list) and child and child[0] == "viewRef"): + continue + for sub in child[1:]: + if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "cellRef": + cell_id = str(sub[1]) + lib_name = "" + for sub2 in sub[2:]: + if isinstance(sub2, list) and len(sub2) >= 2 and sub2[0] == "libraryRef": + lib_name = str(sub2[1]) + break + return (lib_name, cell_id) + return None + + +_SUBDESIGN_PREFIX = re.compile(r"^(&\d+)[IN]\d+") + + +def _subdesign_id(internal_id: str | None) -> str | None: + """Extract the sub-design prefix from an EDIF instance or net ID. + + Siemens xDX Designer emits internal IDs like ``&0441I2234`` (instance) or + ``&0441N2250`` (net), where ``&0441`` identifies the sub-design / + schematic view the symbol belongs to. Different sub-designs in one file + get different numeric prefixes; back-annotation, contents, and viewMap + all reuse the same prefix per design. + + Returns ``None`` when the ID doesn't match the prefix scheme (bare-named + cells, named nets like ``+5V``, or exports from non-xDX tools). The + parser treats ``None`` as "shared / no sub-design" and includes those + forms in every selection. + """ + if not internal_id: + return None + m = _SUBDESIGN_PREFIX.match(internal_id) + return m.group(1) if m else None + + +def _build_instance_map(tree: list) -> dict[str, dict]: + """Walk every ``(instance ...)`` form. Skip back-annotation refs in viewMap. + + Each entry: ``{cell_ref, port_pins, inline_designator, footprint, subdesign_id}``. + """ + instances: dict[str, dict] = {} + for inst in _walk(tree, "instance"): + inst_id = _node_id(inst) + if not inst_id: + continue + + cell_ref = _find_cell_ref(inst) + + port_pins: dict[str, str] = {} + inline_des: str | None = None + for child in inst: + if not isinstance(child, list) or not child: + continue + if child[0] == "portInstance" and len(child) >= 2: + port_name = str(child[1]) + for sub in child[2:]: + if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "designator": + port_pins[port_name] = str(sub[1]) + break + elif child[0] == "designator" and len(child) >= 2 and inline_des is None: + inline_des = str(child[1]) + + instances[inst_id] = { + "cell_ref": cell_ref, + "port_pins": port_pins, + "inline_designator": inline_des, + "footprint": _direct_property(inst, "Cell_Name") or "", + "subdesign_id": _subdesign_id(inst_id), + } + return instances + + +def _build_back_annotation(tree: list) -> dict[str, str]: + """``instance_id -> real_designator`` from ``viewMap.instanceBackAnnotate``.""" + annotations: dict[str, str] = {} + for ann in _walk(tree, "instanceBackAnnotate"): + inst_id: str | None = None + des: str | None = None + for child in ann[1:]: + if not isinstance(child, list) or len(child) < 2: + continue + if child[0] == "instanceRef": + inst_id = str(child[1]) + elif child[0] == "designator": + des = str(child[1]) + if inst_id and des: + annotations[inst_id] = des + return annotations + + +def _is_template_designator(des: str) -> bool: + """xDX exports unconfigured instances with templates like ``R?`` / ``U?``.""" + return des.endswith("?") + + +def _resolve_designators( + instances: dict[str, dict], back_anno: dict[str, str] +) -> dict[str, str]: + """For each instance, pick the real designator. Drop template-only ones.""" + resolved: dict[str, str] = {} + for inst_id, inst in instances.items(): + inline = inst["inline_designator"] + annotated = back_anno.get(inst_id) + if inline and not _is_template_designator(inline): + resolved[inst_id] = inline + elif annotated and not _is_template_designator(annotated): + resolved[inst_id] = annotated + # else: unconfigured library symbol — skip + return resolved + + +def _extract_nets( + tree: list, + instances: dict[str, dict], + designators: dict[str, str], + cell_lib: dict[tuple[str, str], dict[str, str | None]], + include_subdesigns: set[str] | None = None, +) -> dict[str, list[tuple[str, str]]]: + """Walk every ``(net ...)`` form. Rename ground-touching nets to ``GND``. + + When ``include_subdesigns`` is supplied, endpoints belonging to + excluded sub-designs are dropped. A net is kept iff it has at least one + surviving endpoint — bare-named nets (no sub-design prefix) survive as + long as any of their referenced instances does. + """ + nets: dict[str, list[tuple[str, str]]] = {} + for net in _walk(tree, "net"): + if len(net) < 2: + continue + name_node = net[1] + if isinstance(name_node, list) and len(name_node) >= 3 and name_node[0] == "rename": + net_name = str(name_node[2]) + elif isinstance(name_node, str): + net_name = str(name_node) + else: + continue + + connections: list[tuple[str, str]] = [] + touches_ground = False + for child in net[1:]: + if not (isinstance(child, list) and child and child[0] == "joined"): + continue + for ref in child[1:]: + if not (isinstance(ref, list) and len(ref) >= 2 and ref[0] == "portRef"): + continue + port_name = str(ref[1]) + inst_id: str | None = None + for sub in ref[2:]: + if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "instanceRef": + inst_id = str(sub[1]) + break + if not inst_id or inst_id not in instances: + continue + inst = instances[inst_id] + if include_subdesigns is not None: + if inst["subdesign_id"] not in include_subdesigns: + continue + pin = inst["port_pins"].get(port_name) + des = designators.get(inst_id) + if not pin or not des: + continue + if inst["cell_ref"]: + port_map = cell_lib.get(inst["cell_ref"], {}) + if port_map.get(port_name) == "GROUND": + touches_ground = True + connections.append((des, pin)) + + if not connections: + continue + final_name = "GND" if touches_ground else net_name + nets.setdefault(final_name, []).extend(connections) + return nets + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + + +def _parse_tree(path: str | Path) -> list: + text = Path(path).read_text(encoding="utf-8", errors="replace") + return _parse_sexp(list(_tokenize(text))) + + +def parse_edif_netlist( + path: str | Path, + *, + include_subdesigns: set[str] | None = None, +) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]]]: + """Parse a Siemens xDX Designer EDIF 2.0.0 netlist (``.edn``). + + Args: + path: file to parse. + include_subdesigns: when supplied, restrict the output to instances + whose ``&NNNN`` sub-design prefix is in this set. Instances with + no prefix (bare-named cells) are always kept. ``None`` (default) + includes every sub-design — same behavior as before this flag + existed. + + Returns: + parts: ``{reference: footprint}`` (footprint from the instance's + ``Cell_Name`` property — typically a package size like ``"0402"``) + nets: ``{net_name: [(component_ref, pin_number), ...]}`` + + Ground nets are renamed to ``"GND"`` based on ``Pin_Type=GROUND`` port + tags in the cell library; if no port tags ground (rare), net names stay + as the EDIF-generated ``$NN…`` strings and downstream validation will + surface the missing ground. + """ + tree = _parse_tree(path) + + cell_lib = _build_cell_library(tree) + instances = _build_instance_map(tree) + back_anno = _build_back_annotation(tree) + designators = _resolve_designators(instances, back_anno) + + if include_subdesigns is not None: + # Drop excluded instances before nets are walked. Instances with + # subdesign_id=None (bare-named, no prefix) are always kept — they're + # shared between sub-designs in the xDX export and dropping them + # would orphan otherwise-included nets. + designators = { + iid: des + for iid, des in designators.items() + if instances[iid]["subdesign_id"] is None + or instances[iid]["subdesign_id"] in include_subdesigns + } + + nets = _extract_nets( + tree, instances, designators, cell_lib, + include_subdesigns=include_subdesigns, + ) + + parts: dict[str, str] = {} + for inst_id, des in designators.items(): + parts[des] = instances[inst_id]["footprint"] + + return parts, nets + + +def list_edif_subdesigns(path: str | Path) -> list[dict]: + """Return one entry per sub-design found in the file. + + Each entry: ``{"id": "&0441", "instance_count": 21, + "designators": ["C1", "C2", ...]}``. Sub-designs are identified by the + ``&NNNN`` prefix on EDIF instance IDs; instances with no prefix (bare + cells, rare in xDX exports) are bundled under ``"id": None`` and are + always included regardless of the user's selection. + + Designators are sorted naturally (R1 before R10) within each sub-design; + sub-designs themselves are sorted by their first BOM-style designator so + output is deterministic across runs. + """ + tree = _parse_tree(path) + instances = _build_instance_map(tree) + back_anno = _build_back_annotation(tree) + designators = _resolve_designators(instances, back_anno) + + by_sub: dict[str | None, list[str]] = {} + for iid, des in designators.items(): + sub = instances[iid]["subdesign_id"] + by_sub.setdefault(sub, []).append(des) + + def _key(des: str) -> tuple: + # Sort R1 before R10 — split on the first digit run. + head = des.rstrip("0123456789") + tail = des[len(head):] + return (head, int(tail) if tail.isdigit() else 0) + + out: list[dict] = [] + for sub, dlist in by_sub.items(): + dlist.sort(key=_key) + out.append({ + "id": sub, + "instance_count": len(dlist), + "designators": dlist, + }) + + out.sort(key=lambda e: (e["designators"][0] if e["designators"] else "", e["id"] or "")) + return out diff --git a/backend/pinscopex/pin_function_tokens.py b/backend/pinscopex/pin_function_tokens.py new file mode 100644 index 0000000..b530b86 --- /dev/null +++ b/backend/pinscopex/pin_function_tokens.py @@ -0,0 +1,142 @@ +"""Peripheral-function tokens parsed from net names and pin alternate-function +strings. + +A *token* is a ``(peripheral, signal)`` pair, e.g. ``("UART5", "TX")`` or +``("I2C1", "SDA")``. Both the schematic net name (user-authored, e.g. +``"MCU-UART5-TX"``) and the datasheet-extracted pin functions (e.g. +``"UART5_RX"``, ``"SPI3_MOSI/I2S3_SDO"``) are reduced to the same canonical +token space so they can be compared. + +Used by: + * ``pin_mux_check`` — the deterministic pin-mux feasibility check + * ``validate.build_component_context`` — to render alt-functions only on + peripheral-named-net pins (token-conscious context rendering) + +Design goal is *high precision, low recall*: only emit a token when both the +bus family and the signal are unambiguous, so the feasibility check never +false-positives on opaque nets or vocabulary mismatches (CS vs NSS, TXD vs TX). +""" + +from __future__ import annotations + +import re + +# Bus families whose pin assignment is muxed and whose naming is stable enough +# to validate. Longer families that contain a shorter one as a substring +# (FDCAN/CAN, OCTOSPI/QSPI, USART/UART) are listed first; the patterns are +# anchored, so a token like "OCTOSPI1" never matches the bare "SPI" family. +_FAMILIES = ( + "LPUART", "USART", "UART", "I2C", "OCTOSPI", "QSPI", "SPI", + "FDCAN", "CAN", "SDMMC", "SDIO", "I2S", "SAI", "USB", +) +_FAMILY_ALT = "|".join(_FAMILIES) + +# A single net-name token that is exactly a bus family + optional instance number. +_PERIPHERAL_RE = re.compile(rf"^({_FAMILY_ALT})(\d*)$") +# A pin alternate-function string: _. +_FUNCTION_RE = re.compile(rf"^({_FAMILY_ALT})(\d*)_(.+)$") + +# Canonical signal names we compare on — restricted to signals with stable +# naming across user net labels and datasheet function strings. SPI's +# controller/peripheral names (PICO/POCI/COPI/CIPO) are NOT canonical — they are +# synonyms of MOSI/MISO (same physical line, renamed) and collapse below. +_SIGNALS = { + "TX", "RX", "SDA", "SCL", "MOSI", "MISO", + "SCK", "NSS", "DP", "DM", +} + +# Synonyms collapsed to a canonical signal before comparison. +_SIGNAL_SYNONYMS = { + "TXD": "TX", "RXD": "RX", + "SCLK": "SCK", "CLK": "SCK", + "SS": "NSS", "CS": "NSS", "NCS": "NSS", "STE": "NSS", + "DPLUS": "DP", "DMINUS": "DM", + # SPI controller/peripheral nomenclature — the same physical lines as + # master/slave MOSI/MISO, just renamed (TI/NXP/ST modern parts). A net + # labelled SPI0_MOSI landing on a pin whose datasheet function is SPI0_PICO + # is feasible, not a defect. (SDO/SDI deliberately omitted — their meaning + # flips with controller-vs-peripheral perspective, so they aren't safe to + # equate here.) + "PICO": "MOSI", "COPI": "MOSI", + "POCI": "MISO", "CIPO": "MISO", +} + +# Directional complements — the signal that *should* be present if the asserted +# one isn't. Used to phrase a feasibility finding as a likely swap. Keyed on +# canonical signals only (PICO/POCI collapse to MOSI/MISO before this is read). +_COMPLEMENT = { + "TX": "RX", "RX": "TX", + "SDA": "SCL", "SCL": "SDA", + "MOSI": "MISO", "MISO": "MOSI", + "DP": "DM", "DM": "DP", +} + +# Chip-select alternates often carry an instance suffix (SPI0_CS0..CS3, STE0..); +# strip the trailing index so every variant canonicalises to the bare CS token. +_CHIP_SELECT_INDEXED_RE = re.compile(r"^(N?CS|SS|STE)\d+$") + + +def _canon_signal(tok: str) -> str | None: + """Canonicalise a raw signal token, or return None if it isn't a known signal.""" + t = tok.upper() + m = _CHIP_SELECT_INDEXED_RE.match(t) + if m: + t = m.group(1) + t = _SIGNAL_SYNONYMS.get(t, t) + return t if t in _SIGNALS else None + + +def _tokens(name: str) -> list[str]: + """Split a net name into delimiter-separated tokens (uppercased).""" + s = name.upper().lstrip("/") + # Map the only signals that embed a delimiter char before splitting. + s = s.replace("D+", "DP").replace("D-", "DM") + s = re.sub(r"[._/]", "-", s) + return [p for p in s.split("-") if p] + + +def parse_net_token(net_name: str) -> tuple[str, str] | None: + """Extract a ``(peripheral, canonical_signal)`` token from a net name, or None. + + Emits only when a bus-family token is immediately followed by a known + signal, e.g. ``"MCU-UART5-TX" -> ("UART5", "TX")``, + ``"I2C1-SDA-3V3" -> ("I2C1", "SDA")``. Opaque nets (``"NetC7_1"``, + ``"MCU-RESET"``) return None. + """ + parts = _tokens(net_name) + for i in range(len(parts) - 1): + m = _PERIPHERAL_RE.match(parts[i]) + if not m: + continue + sig = _canon_signal(parts[i + 1]) + if sig is None: + continue + return (m.group(1) + m.group(2), sig) + return None + + +def normalize_functions(functions: list[str] | None) -> set[tuple[str, str]]: + """Reduce a pin's alternate-function strings to canonical + ``(peripheral, signal)`` tokens. Splits slash-joined alternates + (``"SPI3_MOSI/I2S3_SDO"`` -> two tokens).""" + out: set[tuple[str, str]] = set() + for f in functions or []: + for alt in f.upper().replace("D+", "DP").replace("D-", "DM").split("/"): + m = _FUNCTION_RE.match(alt.strip()) + if not m: + continue + sig = _canon_signal(m.group(3)) + if sig is None: + continue + out.add((m.group(1) + m.group(2), sig)) + return out + + +def signals_for_peripheral(funcs: set[tuple[str, str]], peripheral: str) -> set[str]: + """All canonical signals a function set exposes for one peripheral instance.""" + return {s for (p, s) in funcs if p == peripheral} + + +def complement(signal: str) -> str | None: + """The directional complement of a signal (TX<->RX, SDA<->SCL, ...), or None.""" + return _COMPLEMENT.get(signal) diff --git a/backend/pinscopex/pin_mux_check.py b/backend/pinscopex/pin_mux_check.py new file mode 100644 index 0000000..f090db0 --- /dev/null +++ b/backend/pinscopex/pin_mux_check.py @@ -0,0 +1,172 @@ +"""Deterministic pin-mux feasibility check. + +For each IC pin whose net name asserts a peripheral function (e.g. a net named +``MCU-UART5-TX`` asserts ``UART5_TX``), verify that the pin can actually be +configured for that function per the datasheet alternate-function table. A pin +that exposes peripheral P but *not* the asserted signal S (e.g. PD2 exposes +UART5 only as ``UART5_RX``) cannot be muxed to S — a hard, context-free defect. + +This is a FEASIBILITY check, never a DIRECTION check. It makes no claim about +whether a TX should connect to a peer's RX (direct-UART crossover) or TX +(transceiver/isolator straight-through) — that is context-dependent and left to +the agentic reviewer. To stay sound it SKIPS any net that also lands on another +IC exposing the same peripheral (an inter-device link, where the net name's +perspective is ambiguous). +""" + +from __future__ import annotations + +from backend.pinscopex.models import ( + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, +) +from backend.pinscopex.pin_function_tokens import ( + complement, + normalize_functions, + parse_net_token, + signals_for_peripheral, +) +from backend.pinscopex.validate import _match_constraints + + +def check_pin_mux_feasibility( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints], +) -> list[Finding]: + """Flag IC pins assigned a peripheral function their silicon can't route.""" + findings: list[Finding] = [] + + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, constraints_map) + if not cons: + continue + + for pin_num, net_name in comp.pins.items(): + token = parse_net_token(net_name) + if token is None: + continue + peripheral, signal = token + + pin = cons.pin_by_number(pin_num) + if pin is None or not pin.functions: + continue + exposed = signals_for_peripheral( + normalize_functions(pin.functions), peripheral + ) + if not exposed: + continue # pin doesn't expose this peripheral at all — not our case + if signal in exposed: + continue # feasible; any direction question is the reviewer's call + + # Pin exposes the peripheral but NOT the asserted signal -> infeasible. + # Gate: skip if another IC pin on this net also exposes the peripheral + # (inter-device same-peripheral link — could be a legitimate crossover + # or transceiver straight-through; leave it to the agentic reviewer). + if _peer_exposes_peripheral( + graph, constraints_map, net_name, ref, peripheral + ): + continue + + findings.append( + _feasibility_finding( + ref, comp.mpn or "", pin_num, pin.name, + net_name, peripheral, signal, exposed, pin.functions, + ) + ) + + return findings + + +def _peer_exposes_peripheral( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints], + net_name: str, + self_ref: str, + peripheral: str, +) -> bool: + """True if any *other* IC pin on this net exposes the given peripheral.""" + net = graph.nets.get(net_name) + if not net: + return False + for pc in net.pins: + if pc.component_ref == self_ref: + continue + other = graph.components.get(pc.component_ref) + if not other or other.component_type != ComponentType.IC: + continue + ocons = _match_constraints(other.mpn or other.value, constraints_map) + if not ocons: + continue + opin = ocons.pin_by_number(pc.pin_number) + if opin is None or not opin.functions: + continue + if signals_for_peripheral(normalize_functions(opin.functions), peripheral): + return True + return False + + +def _feasibility_finding( + ref: str, + mpn: str, + pin_num: str, + pin_name: str, + net_name: str, + peripheral: str, + signal: str, + exposed: set[str], + functions: list[str], +) -> Finding: + # Full alternate-function list, verbatim from the datasheet and in datasheet + # order — NOT our canonicalized tokens. Printing the raw strings keeps the + # finding self-auditing: a reader (or a future us) can spot a naming synonym + # we haven't taught the tokenizer yet (this is how the SPI PICO/POCI==MOSI/MISO + # false positive slipped through — the finding only showed the derived subset). + functions_str = ", ".join(functions) if functions else "(none listed)" + comp_sig = complement(signal) + is_swap = bool(comp_sig and comp_sig in exposed) + + swap_hint = "" + rec = ( + f"Move '{net_name}' to a pin whose alternate functions include " + f"{peripheral}_{signal}." + ) + if is_swap: + swap_hint = ( + f" This pin's {peripheral} role is {peripheral}_{comp_sig} — the " + f"complement of {peripheral}_{signal} — so the {signal}/{comp_sig} " + f"nets are most likely swapped." + ) + rec = ( + f"Move '{net_name}' to a {peripheral}_{signal}-capable pin, or swap " + f"it with the paired {peripheral}_{comp_sig} net if that resolves both." + ) + + return Finding( + designator=ref, + mpn=mpn, + aspect="pin_mux", + source="pin_mux_check", + source_page=None, + status="ERROR", + finding=( + f"Net '{net_name}' assigns {ref} pin {pin_num} ({pin_name}) the " + f"{peripheral}_{signal} function, but this pin cannot be muxed as " + f"{peripheral}_{signal}." + ), + why=( + f"The intended function {peripheral}_{signal} was inferred from the " + f"net name '{net_name}'. Per the datasheet alternate-function table, " + f"pin {pin_num} ({pin_name}) can be muxed as: {functions_str}. " + f"{peripheral}_{signal} is not in that list, so the silicon cannot " + f"route it here regardless of downstream wiring." + swap_hint + + f" If '{net_name}' is not actually configured for {peripheral} in " + f"firmware (e.g. bit-banged GPIO, or a label carried over from the " + f"connected part), disregard this finding." + ), + recommendation=rec, + reference=f"{mpn or ref} alternate-function table", + ) diff --git a/backend/pinscopex/resolve_passives.py b/backend/pinscopex/resolve_passives.py new file mode 100644 index 0000000..a51a507 --- /dev/null +++ b/backend/pinscopex/resolve_passives.py @@ -0,0 +1,554 @@ +"""Resolve passive component MPNs against stored manufacturer patterns.""" + +from __future__ import annotations + +import argparse +import json +import re +from collections import defaultdict +from pathlib import Path + +from backend.pinscopex.models import ( + CapacitorSpecs, + ComponentSpecs, + ComponentType, + InductorSpecs, + PassivePattern, + ResistorSpecs, + ResolvedPassive, + SimpleComponentSpecs, + ValueDecoder, +) +from backend.pinscopex.parsers import parse_bom + + +# --------------------------------------------------------------------------- +# Value decoders +# --------------------------------------------------------------------------- + + +def _multiplier(digit: str, letter_multipliers: dict[str, int | str]) -> float: + """Convert a multiplier character to its power-of-10 value. + + Raises ValueError for ``"decimal_point"`` entries — callers must handle + R-notation before reaching here. + """ + if digit in letter_multipliers: + val = letter_multipliers[digit] + if val == "decimal_point": + raise ValueError(f"Letter '{digit}' is a decimal-point marker, not a multiplier") + return 10.0 ** int(val) + return 10.0 ** int(digit) + + +def _decode_eia3_pf(digits: str) -> float: + """3-digit EIA code → picofarads. e.g. '106' → 10×10^6 = 10_000_000 pF.""" + sig = int(digits[:2]) + mult = int(digits[2]) + return float(sig) * (10.0 ** mult) + + +def _decode_r_notation(digits: str, decimal_letters: set[str]) -> float | None: + """Try to decode R-notation (e.g. '4R70' → 4.70, '47R0' → 47.0). + + Returns None if no decimal-point letter is found in *digits*. + """ + for letter in decimal_letters: + if letter in digits: + return float(digits.replace(letter, ".")) + return None + + +def _decode_eia4_ohm( + digits: str, + tolerance_code: str, + decoder: ValueDecoder, +) -> float: + """4-digit resistance code → ohms, with tolerance-conditional layout.""" + if decoder.zero_code and digits == decoder.zero_code: + return 0.0 + + # Handle R-notation: letters marked as "decimal_point" in letter_multipliers + decimal_letters = { + k for k, v in decoder.letter_multipliers.items() if v == "decimal_point" + } + if decimal_letters: + r_val = _decode_r_notation(digits, decimal_letters) + if r_val is not None: + return r_val + + cond = decoder.conditional_on or {} + high_tol = cond.get("high_tolerance", []) + + if tolerance_code in high_tol: + layout = cond.get("high_tolerance_layout", {}) + else: + layout = cond.get("low_tolerance_layout", {}) + + sig_start = layout.get("significant_start", 0) + sig_count = layout.get("significant_count", 3) + mult_idx = layout.get("multiplier_index", 3) + + sig = int(digits[sig_start : sig_start + sig_count]) + mult_char = digits[mult_idx] + return float(sig) * _multiplier(mult_char, decoder.letter_multipliers) + + +def _decode_letter_decimal(digits: str, decoder: ValueDecoder) -> float: + """Letter-decimal notation: letter serves as decimal point AND multiplier. + + Examples (resistor): 2K2→2200Ω, 97R6→97.6Ω, 10K→10000Ω, 1M→1MΩ + """ + for letter, mult in decoder.letter_multipliers.items(): + if letter in digits: + before, after = digits.split(letter, 1) + if after: + value = float(f"{before}.{after}") + else: + value = float(before) + return value * float(mult) + # No letter found — pure numeric + return float(digits) + + +def decode_value( + digits: str, + decoder: ValueDecoder, + tolerance_code: str | None = None, +) -> float: + """Dispatch to the correct decoder and convert to output_unit.""" + if decoder.type == "eia3_pf": + pf = _decode_eia3_pf(digits) + if decoder.output_unit == "F": + return pf * 1e-12 + return pf + + if decoder.type == "eia4_ohm_conditional": + return _decode_eia4_ohm(digits, tolerance_code or "", decoder) + + if decoder.type == "letter_decimal_ohm": + return _decode_letter_decimal(digits, decoder) + + raise ValueError(f"Unknown decoder type: {decoder.type}") + + +# --------------------------------------------------------------------------- +# Value formatting +# --------------------------------------------------------------------------- + +_SI_PREFIXES_OHM = [ + (1e6, "Mohm"), + (1e3, "kohm"), + (1.0, "ohm"), + (1e-3, "mohm"), +] + +_SI_PREFIXES_F = [ + (1e-3, "mF"), + (1e-6, "uF"), + (1e-9, "nF"), + (1e-12, "pF"), + (1e-15, "fF"), +] + + +def _format_value(value: float, unit: str) -> str: + """Format a value with appropriate SI prefix.""" + if value == 0.0: + return f"0 {unit}" + + prefixes = _SI_PREFIXES_OHM if unit == "ohm" else _SI_PREFIXES_F + + for threshold, label in prefixes: + if abs(value) >= threshold * 0.999: + scaled = value / threshold + # Prefer integer display when possible + if scaled == int(scaled): + return f"{int(scaled)} {label}" + # Up to 2 decimal places, strip trailing zeros + return f"{scaled:.2f}".rstrip("0").rstrip(".") + f" {label}" + + # Fallback + return f"{value} {unit}" + + +def _parse_wattage(s: str) -> str: + """Pass through wattage string as-is (e.g. '1/10W').""" + return s + + +# --------------------------------------------------------------------------- +# ResolvedPassive → ComponentSpecs converter +# --------------------------------------------------------------------------- + + +def resolved_to_specs(resolved: ResolvedPassive) -> ComponentSpecs: + """Convert a ResolvedPassive to its type-specific specs model.""" + if resolved.component_type == ComponentType.RESISTOR: + return ResistorSpecs( + value_ohms=resolved.value, + value_formatted=resolved.value_formatted, + tolerance=resolved.tolerance, + package=resolved.package, + power_rating_w=resolved.power_rating, + ) + if resolved.component_type == ComponentType.CAPACITOR: + return CapacitorSpecs( + value_farads=resolved.value, + value_formatted=resolved.value_formatted, + tolerance=resolved.tolerance, + package=resolved.package, + voltage_rating_v=resolved.voltage_rating, + dielectric=resolved.dielectric, + ) + if resolved.component_type == ComponentType.INDUCTOR: + return InductorSpecs( + value_henries=resolved.value, + value_formatted=resolved.value_formatted, + tolerance=resolved.tolerance, + package=resolved.package, + ) + raise ValueError(f"Unsupported component type: {resolved.component_type}") + + +# --------------------------------------------------------------------------- +# SimpleComponentSpecs → typed passive specs (for DigiKey auto-resolve) +# --------------------------------------------------------------------------- + +_SPICE_MULTIPLIERS: dict[str, float] = { + "T": 1e12, "G": 1e9, "M": 1e6, "k": 1e3, + "m": 1e-3, "u": 1e-6, "n": 1e-9, "p": 1e-12, +} + +_UNIT_SUFFIXES = ("ohm", "F", "H", "V", "W", "A", "Hz") + + +def _parse_spice_value(s: str) -> float: + """Parse a SPICE-prefixed value string to a float. + + Examples: "5.1kohm" → 5100.0, "470nF" → 4.7e-7, "30V" → 30.0, + "120 at 100MHz" → 120.0 + """ + s = s.strip() + + # Strip conditional clauses like "at 100MHz" or "@ 100MHz" + for sep in (" at ", " @ ", "@"): + idx = s.find(sep) + if idx > 0: + s = s[:idx].strip() + break + + # Strip unit suffix + for suffix in _UNIT_SUFFIXES: + if s.endswith(suffix): + s = s[: -len(suffix)] + break + + # Try direct float (no multiplier) + try: + return float(s) + except ValueError: + pass + + # Find multiplier character (last non-digit, non-dot char) + for i in range(len(s) - 1, -1, -1): + ch = s[i] + if ch in _SPICE_MULTIPLIERS: + numeric = s[:i] + s[i + 1 :] + return float(numeric) * _SPICE_MULTIPLIERS[ch] + + raise ValueError(f"Cannot parse SPICE value: {s!r}") + + +def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpecs: + """Convert auto-resolved SimpleComponentSpecs to a typed passive model.""" + subtype = simple.component_subtype or "" + vals = simple.values + + # Common optional fields + value_formatted = str(vals.get("value_formatted") or "") + tolerance = str(vals.get("tolerance")) if vals.get("tolerance") else None + package = str(vals.get("package")) if vals.get("package") else None + + subtype_for_specs = subtype or None + + if subtype.startswith("passive.resistor") or subtype == "passive.resistor": + raw = vals.get("value_ohms") + if raw is None: + raise ValueError(f"Missing value_ohms in auto-resolved resistor specs") + value_ohms = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw) + power_rating_w = str(vals.get("power_rating_w")) if vals.get("power_rating_w") else None + return ResistorSpecs( + component_subtype=subtype_for_specs, + value_ohms=value_ohms, + value_formatted=value_formatted or _format_value(value_ohms, "ohm"), + tolerance=tolerance, + package=package, + power_rating_w=power_rating_w, + ) + + if subtype.startswith("passive.capacitor"): + raw = vals.get("value_farads") + if raw is None: + raise ValueError(f"Missing value_farads in auto-resolved capacitor specs") + value_farads = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw) + voltage_rating_v = str(vals.get("voltage_rating_v")) if vals.get("voltage_rating_v") else None + dielectric = str(vals.get("dielectric")) if vals.get("dielectric") else None + return CapacitorSpecs( + component_subtype=subtype_for_specs, + value_farads=value_farads, + value_formatted=value_formatted or _format_value(value_farads, "F"), + tolerance=tolerance, + package=package, + voltage_rating_v=voltage_rating_v, + dielectric=dielectric, + ) + + if subtype.startswith("passive.inductor") or subtype == "passive.ferrite_bead": + raw = vals.get("value_henries") + if raw is None: + raise ValueError(f"Missing value_henries in auto-resolved inductor specs") + value_henries = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw) + current_rating_a = str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None + dcr_raw = vals.get("dcr_ohms") + dcr_ohms: float | None = None + if dcr_raw is not None: + dcr_ohms = _parse_spice_value(str(dcr_raw)) if isinstance(dcr_raw, str) else float(dcr_raw) + return InductorSpecs( + component_subtype=subtype_for_specs, + value_henries=value_henries, + value_formatted=value_formatted, + tolerance=tolerance, + package=package, + current_rating_a=current_rating_a, + dcr_ohms=dcr_ohms, + ) + + raise ValueError(f"Unsupported passive subtype for conversion: {subtype!r}") + + +# --------------------------------------------------------------------------- +# Pattern loading and matching +# --------------------------------------------------------------------------- + + +class SkippedItem: + """A component or pattern that was skipped due to an error.""" + __slots__ = ("identifier", "stage", "error") + + def __init__(self, identifier: str, stage: str, error: str) -> None: + self.identifier = identifier + self.stage = stage + self.error = error + + def to_dict(self) -> dict[str, str]: + return {"identifier": self.identifier, "stage": self.stage, "error": self.error} + + +def load_patterns( + patterns_dir: str | Path, + skipped: list[SkippedItem] | None = None, +) -> list[PassivePattern]: + """Load all pattern JSON files from a directory. + + Invalid pattern files are silently skipped (appended to *skipped* if provided). + """ + patterns_dir = Path(patterns_dir) + patterns: list[PassivePattern] = [] + for f in sorted(patterns_dir.glob("*.json")): + try: + data = json.loads(f.read_text()) + patterns.append(PassivePattern(**data)) + except Exception as e: + if skipped is not None: + skipped.append(SkippedItem(f.stem, "passive_pattern_load", str(e))) + return patterns + + +def resolve_mpn( + mpn: str, + patterns: list[PassivePattern], +) -> tuple[PassivePattern, dict[str, str]] | None: + """Match an MPN against loaded patterns. Returns (pattern, captured_groups) or None.""" + for pat in patterns: + m = re.match(pat.regex, mpn) + if m: + return pat, m.groupdict() + return None + + +# --------------------------------------------------------------------------- +# BOM resolution +# --------------------------------------------------------------------------- + + +def resolve_bom( + bom_path: str | Path, + patterns_dir: str | Path = "component-patterns", + *, + reference_col: str = "Reference", + mpn_col: str = "Manufacturer Part Number", + skipped: list[SkippedItem] | None = None, +) -> list[ResolvedPassive]: + """Resolve all passive MPNs in a BOM against stored patterns. + + Individual MPNs that fail to decode are silently skipped (appended to + *skipped* if provided). + """ + patterns = load_patterns(patterns_dir, skipped=skipped) + bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col) + + # Group references by MPN + mpn_refs: dict[str, list[str]] = defaultdict(list) + mpn_value: dict[str, str] = {} + for ref, info in bom.items(): + mpn = info.get("mpn") + if mpn: + mpn_refs[mpn].append(ref) + mpn_value[mpn] = info.get("value", "") + + resolved: list[ResolvedPassive] = [] + for mpn, refs in sorted(mpn_refs.items()): + match = resolve_mpn(mpn, patterns) + if match is None: + continue + + try: + pat, groups = match + fields_by_name = {f.name: f for f in pat.fields} + + # Decode the primary value — find the value field by name + value_digits = groups.get("resistance") or groups.get("capacitance") or "" + tolerance_code = groups.get("tolerance", "") + + value = decode_value(value_digits, pat.value_decoder, tolerance_code) + value_formatted = _format_value(value, pat.value_decoder.output_unit) + + # Decode tolerance + tolerance_field = fields_by_name.get("tolerance") + tolerance = ( + tolerance_field.lookup.get(tolerance_code) if tolerance_field else None + ) + + # Decode package size + size_field = fields_by_name.get("size") + size_code = groups.get("size", "") + package = size_field.lookup.get(size_code, size_code) if size_field else None + + # Decode voltage rating (capacitors) + voltage_field = fields_by_name.get("voltage") + voltage_code = groups.get("voltage", "") + voltage_rating = ( + voltage_field.lookup.get(voltage_code) if voltage_field else None + ) + + # Decode power rating (resistors) + wattage_field = fields_by_name.get("wattage") + wattage_code = groups.get("wattage", "") + power_rating = ( + wattage_field.lookup.get(wattage_code) if wattage_field else None + ) + + # Decode dielectric (capacitors) + dielectric_field = fields_by_name.get("dielectric") + dielectric_code = groups.get("dielectric", "") + dielectric = ( + dielectric_field.lookup.get(dielectric_code) + if dielectric_field + else None + ) + + # Build raw_fields: code → decoded value for all fields + raw_fields: dict[str, str] = {} + for fname, fval in groups.items(): + fd = fields_by_name.get(fname) + if fd and fd.lookup: + raw_fields[fname] = fd.lookup.get(fval, fval) + else: + raw_fields[fname] = fval + + resolved.append( + ResolvedPassive( + mpn=mpn, + references=sorted(refs), + component_type=pat.component_type, + component_subtype=pat.component_subtype, + manufacturer=pat.manufacturer, + series=pat.series, + value=value, + value_formatted=value_formatted, + tolerance=tolerance, + package=package, + voltage_rating=voltage_rating, + power_rating=power_rating, + dielectric=dielectric, + raw_fields=raw_fields, + ) + ) + except Exception as e: + if skipped is not None: + skipped.append(SkippedItem(mpn, "passive_resolve", str(e))) + + return resolved + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve passive component MPNs from a BOM against stored patterns", + ) + parser.add_argument( + "bom", + nargs="?", + default="simple_project/TI-MSP-KICAD9-TUTORIAL.csv", + help="Path to BOM CSV file", + ) + parser.add_argument( + "--patterns", + default="component-patterns", + help="Directory containing pattern JSON files", + ) + parser.add_argument( + "--output", + default=None, + help="Write resolved JSON to this path", + ) + args = parser.parse_args() + + resolved = resolve_bom(args.bom, args.patterns) + + if not resolved: + print("No passive components resolved.") + return + + for r in resolved: + extras = [] + if r.tolerance: + extras.append(r.tolerance) + if r.package: + extras.append(r.package) + if r.dielectric: + extras.append(r.dielectric) + if r.voltage_rating: + extras.append(r.voltage_rating) + if r.power_rating: + extras.append(r.power_rating) + extra_str = ", ".join(extras) + print(f" {r.mpn} → {r.value_formatted} ({extra_str})") + print(f" refs: {', '.join(r.references)}") + + print(f"\nResolved {len(resolved)} passive component(s).") + + if args.output: + Path(args.output).write_text( + json.dumps([r.model_dump() for r in resolved], indent=2) + "\n" + ) + print(f"Written to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/backend/pinscopex/taxonomy.py b/backend/pinscopex/taxonomy.py new file mode 100644 index 0000000..b5e023b --- /dev/null +++ b/backend/pinscopex/taxonomy.py @@ -0,0 +1,313 @@ +"""Living component taxonomy: load, query, and grow the subtype tree. + +Storage: one JSON file per top-level type in ``taxonomy/``. +Each file is a self-contained document that maps 1:1 to a Firestore +document, so only the relevant branch needs to be fetched/injected +into extraction prompts. + +:: + + taxonomy/ + ├── ic.json # all IC subtypes + ├── passive.json # all passive subtypes + ├── discrete.json # diodes, transistors, LEDs + ├── connector.json + ├── crystal.json + └── ... +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +TAXONOMY_DIR = Path(__file__).resolve().parent.parent.parent / "taxonomy" + +# Reference-designator prefix -> taxonomy top-level type. +# Used by extraction skills: "I see 'U' so I only need the ic branch." +REF_PREFIX_TO_TYPE: dict[str, str] = { + "U": "ic", + "IC": "ic", + "R": "passive", + "C": "passive", + "L": "passive", + "FB": "passive", + "J": "connector", + "X": "crystal", + "Y": "crystal", + "D": "discrete", + "LED": "discrete", + "Q": "discrete", + "T": "transformer", + "F": "fuse", + "SW": "switch", + "TP": "test_point", + "FM": "fiducial", + "MH": "mechanical", +} + +# Canonical format for dotted subtype keys. +SUBTYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$") + +# All valid top-level taxonomy types (derived from ref-prefix mapping). +KNOWN_TYPES: frozenset[str] = frozenset(REF_PREFIX_TO_TYPE.values()) + + +def validate_subtype(value: str) -> str: + """Validate and normalize a component_subtype string. + + Lowercases, replaces hyphens/spaces with underscores, then checks + the dotted format and that the top-level segment is a known type. + + Returns the normalized value. Raises ``ValueError`` if invalid. + """ + v = value.strip().lower().replace("-", "_").replace(" ", "_") + if not SUBTYPE_PATTERN.match(v): + raise ValueError( + f"Invalid component_subtype format: {value!r}. " + f"Expected dotted lowercase path like 'ic.mcu' or 'passive.resistor'" + ) + top = v.split(".")[0] + if top not in KNOWN_TYPES: + raise ValueError( + f"Unknown top-level taxonomy type: {top!r} (from {value!r}). " + f"Known types: {sorted(KNOWN_TYPES)}" + ) + return v + + +def type_for_ref(ref: str) -> str | None: + """Map a reference designator (e.g. 'U3', 'C12') to a taxonomy type.""" + prefix = re.match(r"^[A-Za-z]+", ref) + if not prefix: + return None + return REF_PREFIX_TO_TYPE.get(prefix.group().upper()) + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + + +def _load_type_file(top_type: str, directory: Path = TAXONOMY_DIR) -> dict: + """Load a single type file, returning its raw JSON.""" + path = directory / f"{top_type}.json" + if not path.exists(): + return {"type": top_type, "subtypes": {}} + return json.loads(path.read_text()) + + +def _save_type_file(top_type: str, data: dict, directory: Path = TAXONOMY_DIR) -> None: + """Write a type file back to disk.""" + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{top_type}.json" + path.write_text(json.dumps(data, indent=2) + "\n") + + +def load_subtypes( + top_type: str | None = None, + directory: Path = TAXONOMY_DIR, +) -> dict[str, dict]: + """Return subtypes as ``{dotted_key: {description, example_mpn?}}``. + + If *top_type* is given (e.g. ``"ic"``), only that file is loaded — + keeping prompt injection small. If ``None``, all files are merged. + """ + if top_type is not None: + return dict(_load_type_file(top_type, directory).get("subtypes", {})) + + merged: dict[str, dict] = {} + for f in sorted(directory.glob("*.json")): + data = json.loads(f.read_text()) + merged.update(data.get("subtypes", {})) + return merged + + +def list_subtypes( + prefix: str | None = None, + directory: Path = TAXONOMY_DIR, +) -> list[str]: + """List subtype keys, optionally filtered by dotted prefix. + + Efficient: if *prefix* starts with a known top-level type, only that + single file is loaded. + + Examples:: + + list_subtypes() # all subtypes (loads every file) + list_subtypes("ic") # only ic.json loaded + list_subtypes("ic.power") # only ic.json loaded, filtered + list_subtypes("passive") # only passive.json loaded + """ + # Determine which top-level type file to load + top_type: str | None = None + if prefix is not None: + top_type = prefix.split(".")[0] + + subtypes = load_subtypes(top_type, directory) + + if prefix is None: + return sorted(subtypes.keys()) + + prefix_dot = prefix if prefix.endswith(".") else prefix + "." + return sorted(k for k in subtypes if k == prefix or k.startswith(prefix_dot)) + + +def get_subtype(key: str, directory: Path = TAXONOMY_DIR) -> dict | None: + """Get a single subtype entry by its dotted key, or None.""" + top_type = key.split(".")[0] + subtypes = load_subtypes(top_type, directory) + return subtypes.get(key) + + +def set_type_specs( + top_type: str, + specs: list[dict], + directory: Path = TAXONOMY_DIR, +) -> None: + """Set type-level specs on a taxonomy file.""" + data = _load_type_file(top_type, directory) + data["specs"] = specs + _save_type_file(top_type, data, directory) + + +def set_extra_specs( + subtype_key: str, + extra_specs: list[dict], + directory: Path = TAXONOMY_DIR, +) -> None: + """Set extra_specs on an existing subtype entry.""" + top_type = subtype_key.split(".")[0] + data = _load_type_file(top_type, directory) + subtypes = data.get("subtypes", {}) + if subtype_key not in subtypes: + return + subtypes[subtype_key]["extra_specs"] = extra_specs + _save_type_file(top_type, data, directory) + + +def has_specs(top_type: str, directory: Path = TAXONOMY_DIR) -> bool: + """Check if a taxonomy type has any specs defined (type-level or extra).""" + data = _load_type_file(top_type, directory) + if data.get("specs"): + return True + for entry in data.get("subtypes", {}).values(): + if entry.get("extra_specs"): + return True + return False + + +def add_subtype( + key: str, + description: str, + example_mpn: str | None = None, + directory: Path = TAXONOMY_DIR, +) -> None: + """Add a new subtype. Creates the type file if needed. No-op if exists.""" + key = validate_subtype(key) + top_type = key.split(".")[0] + data = _load_type_file(top_type, directory) + subtypes = data.setdefault("subtypes", {}) + + if key in subtypes: + return + + entry: dict[str, str] = {"description": description} + if example_mpn: + entry["example_mpn"] = example_mpn + subtypes[key] = entry + + data["type"] = top_type + _save_type_file(top_type, data, directory) + + +def get_specs_schema( + top_type: str, + subtype_key: str | None = None, + directory: Path = TAXONOMY_DIR, +) -> list[dict]: + """Return merged specs list: type-level ``specs`` + subtype ``extra_specs``.""" + data = _load_type_file(top_type, directory) + specs = list(data.get("specs", [])) + if subtype_key: + entry = data.get("subtypes", {}).get(subtype_key, {}) + specs.extend(entry.get("extra_specs", [])) + return specs + + +def format_specs_for_prompt(top_type: str, directory: Path = TAXONOMY_DIR) -> str: + """Format type-level + all subtype extra_specs as prompt text. + + Includes all possible parameters across subtypes so the extraction + skill knows the full set of fields it might encounter. + """ + data = _load_type_file(top_type, directory) + base_specs = data.get("specs", []) + # Collect all extra_specs across subtypes (deduplicate by name) + all_extra: dict[str, dict] = {} + for entry in data.get("subtypes", {}).values(): + for s in entry.get("extra_specs", []): + all_extra[s["name"]] = s + all_specs = list(base_specs) + list(all_extra.values()) + if not all_specs: + return "" + lines = [ + "PARAMETERS TO EXTRACT (include all that are relevant to this component):", + "", + "Use SPICE multiplier prefixes for values: " + "T=1e12, G=1e9, M=1e6, k=1e3, m=1e-3, u=1e-6, n=1e-9, p=1e-12.", + "Examples: 30V, 240mV, 500mA, 47mohm, 18pF, 8MHz, 10nC.", + "Always include the unit with the multiplier in the value string.", + "", + ] + for s in all_specs: + req = " (REQUIRED)" if s.get("required") else "" + unit = f" [{s['unit']}]" if s.get("unit") else "" + lines.append(f"- {s['name']}{unit}: {s['description']}{req}") + return "\n".join(lines) + + +def format_for_prompt(top_type: str, directory: Path = TAXONOMY_DIR) -> str: + """Format a type's subtypes as a compact string for LLM prompt injection. + + Returns something like:: + + ic.mcu — Microcontroller (e.g. MSPM0G3507SPTR) + ic.power.ldo — Low-dropout voltage regulator (e.g. SPX3819M5-L-3-3) + ic.power.switching_regulator — Switching voltage regulator (buck, boost, buck-boost) + ... + """ + subtypes = load_subtypes(top_type, directory) + lines: list[str] = [] + for key in sorted(subtypes): + entry = subtypes[key] + line = f"{key} — {entry['description']}" + if "example_mpn" in entry: + line += f" (e.g. {entry['example_mpn']})" + lines.append(line) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Simple types (taxonomy-driven specs extraction via PDF) +# --------------------------------------------------------------------------- + + +def _compute_simple_types(directory: Path = TAXONOMY_DIR) -> frozenset[str]: + """Types that have a ``specs`` schema and use PDF-based extraction. + + Excludes ``ic`` (pintable + rules) and ``passive`` (pattern-based). + """ + result: set[str] = set() + if not directory.is_dir(): + return frozenset(result) + for f in directory.glob("*.json"): + data = json.loads(f.read_text()) + t = data.get("type", "") + if t not in ("ic", "passive") and data.get("specs"): + result.add(t) + return frozenset(result) + + +SIMPLE_TYPES: frozenset[str] = _compute_simple_types() diff --git a/backend/pinscopex/utils.py b/backend/pinscopex/utils.py new file mode 100644 index 0000000..df43431 --- /dev/null +++ b/backend/pinscopex/utils.py @@ -0,0 +1,21 @@ +"""Shared utility functions for the pinscopex core library.""" + +from __future__ import annotations + +import re + + +def safe_mpn(mpn: str) -> str: + """Sanitize an MPN string for use in filenames and storage keys.""" + return mpn.replace("/", "_").replace(":", "_") + + +def natural_sort_key(s: str) -> tuple: + """Sort key for natural ordering: R1, R2, R10 (not R1, R10, R2).""" + parts: list[int | str] = [] + for chunk in re.split(r"(\d+)", s): + if chunk.isdigit(): + parts.append(int(chunk)) + else: + parts.append(chunk.lower()) + return tuple(parts) diff --git a/backend/pinscopex/validate.py b/backend/pinscopex/validate.py new file mode 100644 index 0000000..a19f8af --- /dev/null +++ b/backend/pinscopex/validate.py @@ -0,0 +1,1001 @@ +"""Direct datasheet review — validates IC usage by comparing the actual +circuit to the component's datasheet. + +No intermediate rule extraction. Claude reads the datasheet PDF and the +component's circuit neighborhood together and flags issues directly. +""" + +from __future__ import annotations + +import base64 +import json +import re +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path + +import anthropic +from dotenv import load_dotenv + +load_dotenv() + +from backend.pinscopex.models import ( + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + NetType, + ValidationReport, +) +from backend.pinscopex.pin_function_tokens import parse_net_token +from backend.pinscopex.validation_tools import ( + ALL_TOOLS, + SUBMIT_REVIEW_SCHEMA, + ConstraintsMap, + execute_tool, + _format_specs, + _is_thermal_pad_pin, + _pin_sort_key, + _reviewer_voltage_str, +) + + +# --------------------------------------------------------------------------- +# System prompt +# --------------------------------------------------------------------------- + +SYSTEM_PROMPT = """\ +You are an electrical engineer reviewing how a component is used in a \ +hardware design. You have the component's datasheet and a description of \ +how it's wired in the actual circuit. + +### Review approach +Treat this IC as a COVERAGE CHECKLIST, not a single investigation. Before \ +hunting for problems, enumerate every focus area this IC has — derive them \ +from its pins, nets, neighbors, and subtype. A typical checklist: +- Power & decoupling on each supply pin. +- Each signal interface to each connected component — voltage \ +compatibility, direction, and correct cross-connection (e.g. TX↔RX). +- Absolute-maximum ratings on each pin vs. the actual rail driving it. +- Reset / enable / boot / mode-strap / configuration pins. +- Clock or crystal circuit, if present. +- Required external components named by the datasheet. +- Unused / no-connect pins. + +Then work the areas one at a time. For EACH area, don't just confirm a \ +part is present — ask what specific failure mode would make it wrong \ +(missing part, wrong value, over-voltage, swapped pair, wrong topology) \ +and check the datasheet and the actual netlist topology against that \ +failure mode. + +Every area must end up accounted for: either as a finding, or listed in \ +`checked_areas` as reviewed-and-correct. After you resolve one area, move \ +on to the NEXT area — do NOT stop and submit just because you found or \ +cleared the first issue. You have a generous turn budget; the goal is to \ +cover the whole IC, not to finish fast. + +### Reference designators — datasheet vs. schematic +The datasheet's reference/application circuit uses its OWN example \ +designators (e.g. "R2", "C1", "L1"). These are NOT the designators in \ +this project's schematic. The project's real designators are the ones \ +shown in the component context (e.g. "U1", "R5", "C12"). + +Before citing any passive or discrete in a finding, resolve its role to \ +the actual schematic designator: +1. Identify the component's *role* from the datasheet (e.g. "the resistor \ +between the VIN pin and the SW pin", "the feedback divider top resistor", \ +"the bootstrap capacitor between SW and BOOT"). +2. Use the component context — or `find_connected_components` / \ +`get_net_for_pin` — to find which schematic designator plays that role \ +in this design. +3. Cite ONLY the schematic designator (and its value/MPN) in your \ +finding. Never cite the datasheet's example designator. + +If no schematic component plays that role, say so explicitly ("no \ +component is connected between pin 3 (VIN) and pin 5 (SW)") rather than \ +naming a datasheet-example part. If you cannot resolve the role to a \ +schematic designator with confidence, demote the finding to WARNING or \ +INFO and describe the role instead of naming a part. + +### What to report +Only report issues. Do not report things that are correct. + +If your investigation concludes the design is correct — even when the \ +surface reading suggested otherwise (e.g., "C1 (100 nF) is below the \ +1 µF minimum, but C24 (1 µF) in parallel satisfies the spec", or "no \ +dedicated input cap is shown, but C3 is on the VIN net and satisfies \ +the requirement") — do NOT submit it as a finding. Add the topic to \ +`checked_areas` instead. A finding whose own `why` field confirms the \ +requirement is met dilutes the signal of real issues. If you write \ +"satisfies", "meets the requirement", "is in the correct place", or \ +"no issue" in your reasoning, the result belongs in `checked_areas`, \ +not `findings`. + +For each issue: +- **finding**: A concise one-line title of the issue (the rule title). \ +Keep it to a single line — cite the key component refs, values, net names, \ +or pin numbers, but do not elaborate. No multi-sentence descriptions here. +- **why**: The explanation — what the datasheet says and what could go \ +wrong. Keep this to **2 lines at most** (roughly 2 short sentences). This \ +is the most important field — explain the engineering consequence, not \ +just the rule, but stay terse. +- **status**: ERROR (will cause malfunction or violate abs max), \ +WARNING (may degrade reliability or is conditionally wrong), \ +INFO (worth noting but unlikely to cause problems). +- **source_page**: The datasheet page where the requirement is stated. +- **source_quote**: The exact verbatim sentence or clause from the datasheet \ +that states the requirement. Copy it precisely, character-for-character (a \ +short span, ~200 chars max) so it can be located and highlighted in the PDF. \ +Omit this field when the requirement is shown only in a figure or a \ +rasterized table with no selectable text — do not paraphrase or invent a quote. +- **source_designator**: Leave unset when `source_page`/`source_quote` come \ +from THIS component's datasheet (the default). Set it to a connected \ +component's designator (e.g. `U3`) only when the page/quote come from that \ +neighbor's datasheet that you fetched via `get_datasheet_excerpt` — this \ +links the page number to the right datasheet. +- **recommendation**: What to change (for ERROR/WARNING only). + +### Calibration +ERROR only for clear violations: required pin floating, voltage exceeding \ +absolute max, required external component completely missing, wrong \ +connection topology. + +WARNING when: component value differs from recommended but might be \ +adequate, rule is conditional on firmware/mode, concern is real but not \ +certain to cause failure. + +INFO when: design uses a valid but non-standard approach, optional feature \ +is unused, or a layout-level concern exists that cannot be verified from \ +the netlist. + +### ERROR requires a concrete harm pathway +Every ERROR that alleges damage, abs-max violation, or out-of-spec \ +stress must state the harm pathway with concrete numbers, not \ +speculation. Before submitting an ERROR, the `why` field must answer: +1. **Which pin or component takes the stress** (this IC's pin, an \ +internal node named by the datasheet, or an external part). +2. **What the actual voltage / current / temperature on it is**, derived \ +from the topology (the rail it ties to, the divider ratio, the regulator \ +output, the bias current). Numbers, not net labels. +3. **What the datasheet's limit is**, quoted from an abs-max table, \ +recommended-operating range, or pin description. +4. **Why (1) exceeds (3)** — the inequality, in numbers. + +If you cannot produce all four, downgrade to WARNING and write the \ +`why` as `Unverified: `. \ +Hedged language alone — "may damage", "could degrade", "might cause" — \ +is not enough for ERROR; replace it with the inequality or demote the \ +finding. This applies especially when the alleged damage is to an \ +*internal* component (internal DC-block cap, ESD diode, on-die clamp): \ +those are designed against the same package abs-max ratings as the \ +external pin, so an external stress within the pin's abs-max does not \ +damage the part inside. + +Two additional constraints on the inequality: +- **Pin-matched limit.** The abs-max number in (3) must be from the \ +abs-max row for *the same pin or signal* that takes the stress in \ +(1). Vdd's abs-max does not apply to an RF, signal, or I/O pin — \ +those pins have their own abs-max rows (commonly `V_RFIN`, `V_pin`, \ +`V_in` ranges, or are governed by the recommended-operating range). \ +If the datasheet does not list an abs-max for the specific pin under \ +stress, write `Unverified: no abs-max listed for pin ` and demote \ +to WARNING — do not borrow a different pin's number. +- **Strict inequality.** Abs-max is the don't-exceed line. The \ +inequality in (4) must be strict (`actual > limit`). "Equal to \ +abs-max" is not a violation — it may stress lifetime but does not \ +qualify as damage. If the math comes out to `=` rather than `>`, the \ +finding is at most a WARNING. + +### Decoupling capacitors +Larger caps satisfy smaller specs: 470nF satisfies "0.1uF", 10uF satisfies \ +"1uF minimum". Only flag if actual value is below the minimum specified. + +### Netlist limitations +You are reviewing a NETLIST, not PCB layout. You cannot verify component \ +proximity, trace routing, or thermal management. If a functional \ +requirement is met at the netlist level, do not flag it as an issue. + +### Identify the role of each external part before judging it +For each external part on this IC's pins (R, C, L, FB, diodes, \ +transistors), derive its role in the design from first principles \ +before concluding whether the connection is correct. The role is the \ +answer to "what does this part do in this circuit?" — not the answer \ +to "does this pattern have a name I recognize?". Reason from: +1. **What the pin does** (from the datasheet pin description in your \ +context — e.g. "DC blocked", "AC coupled", "internally biased", \ +"open-drain", "high impedance", "reference output"). +2. **What the part is** (its value class and approximate value — an \ +inductor at RF frequencies is a choke; a small cap to GND is shunt \ +decoupling; a series cap is AC coupling or DC blocking; a divider \ +sets a sense ratio). +3. **Where the other end of the part goes** — trace it with \ +`find_connected_components` / `get_net_for_pin` / the bridges list. \ +A part terminated on a power rail does something different from one \ +terminated at a connector or another IC pin. +4. **What (1) + (2) + (3) imply about the part's purpose.** + +When a documented characteristic of the pin would *prevent* the \ +surface-reading interaction (e.g. a DC rail tied through an inductor \ +to a pin that is documented as DC-blocked), the part is almost always \ +serving the rest of the circuit, not the chip — its role is found by \ +asking what the remaining circuit needs the part for, including \ +loads reached through a connector or coax further down the net. Do \ +not raise a finding against the chip for a part that does not stress \ +the chip. + +If you cannot articulate the role after a brief look, submit the \ +concern as `status="WARNING"` with `why` starting `Unverified: role \ +of on pin not determined` — never ERROR on a component \ +whose purpose you have not identified. + +### Budget per concern: cap ONE concern, not the whole review +A single concern (one potential finding under investigation) gets at \ +most two follow-up tool calls beyond what was already in your initial \ +context. If the concern is not resolved within that budget, submit it \ +as WARNING with `why` starting `Unverified: ` and move on to the next area. This per-concern \ +cap exists so one concern cannot swallow the whole review — NOT so you \ +finish early. Your total budget across all concerns is generous: spend it \ +on breadth. The failure mode to avoid is leaving focus areas of this IC \ +uninvestigated, not spending too many turns. Do not call submit_review \ +while any enumerated focus area is still uninvestigated. + +### Net names are not voltage labels +Net names are user-chosen labels — they describe a signal's *role*, not its \ +actual voltage. A net named `VBAT_SENSE`, `8S_LiPo`, or `HV_FB` may carry \ +only a low-voltage MCU control line, a divided-down sense voltage, or be \ +misnamed entirely. + +Before flagging any absolute-max violation, supply-mismatch, or "pin driven \ +beyond rated input" issue, use `find_connected_components` to identify the \ +*actual* driver of the net (power rail, regulator output, MCU pin, voltage \ +divider, connector, etc.). Only flag when the topology confirms the \ +voltage. If the driver is ambiguous, demote to WARNING and describe what \ +would need to be verified. + +### Voltage tags in tool output: trusted but sparse +The `(power, X.X V)` annotation in net-info lines and `[power, X.X V]` tag \ +on pin lines only appear when the voltage is sourced from the netlist \ +itself — either the net name encodes it (`+5V`, `+3V3`, `1V5`) or the \ +user declared it via a power-source hint. Power-tree-derived voltages \ +(deterministic propagation through passthroughs, regulator-output back- \ +annotation, model inferences) are deliberately suppressed from your tool \ +output — they are too lossy to trust at review time, and trusting them \ +has produced false-positive findings in the past. + +When a pin's net has no voltage tag, the netlist does not establish what \ +voltage flows there. Trace topology (find_connected_components, walking \ +back through passthroughs and regulators) to discover the source, or \ +treat the rail as unknown. + +### Rail voltages and VREF: do not guess +When you cannot establish an IC's supply or signal voltage from any of: + +- the net name (e.g., `+5V`, `+3V3`, `GND`), +- a `(power, X.X V)` tag in tool output, +- a connected source / regulator output whose voltage IS visible by the \ +rules above (reached by walking topology through find_connected_components), + +you must NOT reconstruct it by assuming a VREF on an upstream regulator's \ +feedback divider. VREF varies by part (1.20V LDO, 1.25V LDO, 0.6V buck, \ +0.8V buck, 0.925V buck-boost, 1.205V LDO, ...). A guessed VREF cascades \ +into a wrong rail voltage and false-positive out-of-spec findings — this \ +has happened (assumed VREF=0.5V → Vdd=1.48V → bogus 'below operating \ +range' WARNING). + +If a finding hinges on knowing the rail voltage and you cannot establish \ +it from the rules above, downgrade to WARNING with `why` starting \ +`Unverified: rail voltage at could not be established without \ +guessing a regulator's VREF`. Do not raise ERROR on guessed rails. + +### Cross-IC interface checks and uncertainty +When a finding hinges on a *connected* IC's spec (5V-tolerance, abs-max, \ +VIH/VIL, drive strength), that spec lives in the neighbor's datasheet, \ +not yours. Before raising ERROR on such a finding, call \ +`get_datasheet_excerpt(designator, topic)` on the neighbor (e.g. \ +`topic="pin_voltage_levels"` for 5V-tolerance, `"absolute_max"` for \ +stress ratings) and read the returned pages. When a finding then cites a \ +page or quote you read from that neighbor's excerpt, set the finding's \ +`source_designator` to the neighbor's designator and put the neighbor's \ +page number in `source_page` — the citation must point at the datasheet the \ +evidence actually lives in, not yours. + +If the excerpt does not resolve the spec, call `submit_review` with \ +`status="WARNING"` (not ERROR) for that finding, and start its `why` \ +field with `Unverified: `. Reserve ERROR \ +for cases where the violation is established from both sides of the \ +interface — a false ERROR is the single biggest trust-killer for this \ +review. + +### Alternate-function feasibility vs. direction +Pins on peripheral-named nets show their datasheet alternate-function list \ +inline as `[alt: ...]` (and `get_pintable` shows it for any pin on demand). \ +That list is datasheet-extracted ground truth for what the pin can be muxed \ +to. Use it for a FEASIBILITY check — never a direction check: + +- FEASIBILITY (hard ERROR): if a net name asserts a peripheral function — \ +e.g. a net `...UART5-TX...` on a pin whose `[alt: ...]` exposes UART5 only \ +as `UART5_RX` — the silicon cannot route that function to that pin. It is \ +physically unrealizable regardless of anything downstream. Raise ERROR and \ +name the functions the pin actually exposes for that peripheral. +- DIRECTION (context-dependent — do NOT auto-flag): a TX wired to the other \ +device's RX is normal. A direct UART link crosses TX→RX; a transceiver, \ +isolator, or level-shifter is often straight-through (MCU TX → transceiver \ +TXD/DI). Whether a TX/RX (or SDA/SCL) connection is correct depends on the \ +role of the part on the other end, which you must reason about from the \ +circuit — never flag a TX-on-an-RX-named-net (or vice versa) on naming \ +alone. Only raise a direction ERROR when topology forces it (e.g. two \ +push-pull outputs on one net). Otherwise WARNING/INFO, stating the \ +downstream role you'd need to confirm. + +### Pin labels in your context can be wrong +The `Pin N (NAME)` labels in the component context come from a separate \ +datasheet-extraction pass. For image-only PDFs, small or dense pin \ +tables, and non-standard parts, that pass can mis-label individual pins \ +(D+/D− swaps, TX/RX, CC1/CC2, IN+/IN−, anode/cathode, A/K, +/−). Before \ +raising an ERROR whose logic turns on the polarity or identity of a \ +specific pin pair on THIS IC (differential-pair swap, supply polarity, \ +input/output orientation), re-read the pin-mapping page of the datasheet \ +PDF already in your initial context and verify each pin label against \ +it. If the datasheet contradicts the in-context label, trust the \ +datasheet — demote the finding to WARNING and state explicitly which \ +pin label in the context appears mis-extracted (e.g. \ +`Pin A6 labeled "D−" in context, datasheet shows "D+"`). The `[alt: ...]` \ +alternate-function list shown for peripheral-named-net pins is taken \ +verbatim from the datasheet pin table and is reliable even when the short \ +`(NAME)` label is not — prefer it when judging what a pin can be muxed to. + +### Direction-control and transceiver function tables +Bidirectional transceivers, level shifters, mux/demux, bus switches, and \ +analog switches (74xx245, 74xx125, 74xx157, TS3A-family, etc.) often \ +print their function/truth table in a column-segmented layout where two \ +adjacent cells read as a single English phrase ("input B = A", \ +"high-Z input"). Scanning left-to-right inverts the meaning and \ +invalidates every downstream finding. Before raising any ERROR \ +involving bus contention, "two outputs on one net", or direction-control \ +polarity, re-read the function table from THIS IC's datasheet PDF and \ +quote each cell of the relevant row separately. State the direction \ +explicitly ("DIR=H → A is input, B is output, A→B") before claiming \ +output contention. + +### One root cause = one finding +If two ERRORs you're about to submit collapse to the same underlying \ +mistake — e.g. a single mis-configured DIR pin produces both "bus \ +contention on TXD" AND "device is unidirectional only" — submit ONE \ +combined finding that names the root cause. Restate the downstream \ +consequences inside the `why` field instead of as separate findings. \ +Two ERRORs that share a premise read as independent problems, double \ +the review's apparent severity, and dilute trust if the shared premise \ +turns out to be wrong. + +### Bridges between IC pins +The component context includes a `Bridges between 's pins:` section \ +listing 2-or-more-terminal components that connect two of this IC's nets \ +(decoupling caps, feedback dividers, sense resistors, snubbers, etc.). This \ +is the most direct view of external passives associated with the IC. \ +Before claiming a required external part is missing, scan this section — \ +the part may be there under a different role label. Required passives \ +must always be cited from the bridges list (or via a graph tool query) — \ +never inferred from a single-pin listing alone. + +### Exposed pad / thermal pad (EP, DAP, ePAD) +The datasheet pintable's EP/DAP pin number often does not match the number \ +the schematic symbol uses. Schematic symbols commonly assign the exposed \ +pad a custom number (frequently pin_count+1, or a unique name). \ +Unmatched schematic pins that aren't in the datasheet pintable are listed \ +as "Additional schematic pins (not in datasheet pintable)" at the end of \ +the component context — these are almost always the EP/thermal pad. \ +Before flagging an EP-unconnected error, check that no additional \ +schematic pin is tied to GND. If any additional pin is on a GND net, \ +treat the EP requirement as satisfied and do not flag it. + +### Output — submit_review is the ONLY way findings reach the report +You MUST call the `submit_review` tool to record findings. Writing \ +findings as a JSON block in your text response does NOT save them — they \ +will be dropped. When you are ready to record findings (even just one), \ +call `submit_review` with the findings array and checked_areas list. If \ +the circuit matches the datasheet with no issues, still call \ +`submit_review` with an empty findings array. + +In **checked_areas**, list what you reviewed and confirmed correct — \ +short labels like "input decoupling", "output capacitor", "enable logic", \ +"voltage margins". This tells the engineer what was verified, not just \ +what failed. + +Before calling submit_review, confirm every focus area you enumerated at \ +the start is accounted for — present in either `findings` or \ +`checked_areas`. If any enumerated area is still uninvestigated, \ +investigate it before submitting. + +Use the graph query tools to investigate connections beyond the provided \ +context if needed. +""" + + +# Maximum turns for the review agentic loop +_MAX_REVIEW_TURNS = 10 + + +# --------------------------------------------------------------------------- +# Component context builder +# --------------------------------------------------------------------------- + +_GROUND_NET_MAX_COMPONENTS = 5 # Summarize ground nets with more than this + + +def build_component_context( + graph: DesignGraph, + constraints_map: ConstraintsMap, + ref: str, +) -> str: + """Build a text summary of an IC's full circuit neighborhood. + + Shows every pin, its net, and every component connected to that net + (with values and specs). Ground/power nets with many connections are + summarized to avoid noise. + """ + comp = graph.components.get(ref) + if not comp: + return f"Component '{ref}' not found in design graph." + + constraints = constraints_map.get(comp.mpn or "") + lines: list[str] = [] + + # Header + lines.append(f"Component: {ref} ({comp.mpn or comp.value})") + if comp.component_subtype: + lines.append(f"Type: {comp.component_subtype}") + if constraints and constraints.package_info: + pi = constraints.package_info + lines.append(f"Package: {pi.package}, {pi.pin_count} pins") + lines.append("") + + # Build pin list — prefer extracted pintable order, fall back to netlist. + # (pin_num, pin_name, net_name, note, functions) + pin_entries: list[ + tuple[str, str | None, str | None, str | None, list[str] | None] + ] = [] + matched_schematic_pins: set[str] = set() + unmatched_ep_entries: list = [] # pintable EP rows whose number isn't in schematic + + if constraints and constraints.pintable: + for p in sorted(constraints.pintable, key=lambda x: _pin_sort_key(str(x.number))): + net_name = comp.pins.get(str(p.number)) + if net_name is not None: + matched_schematic_pins.add(str(p.number)) + pin_entries.append((str(p.number), p.name, net_name, None, p.functions)) + elif _is_thermal_pad_pin(p): + unmatched_ep_entries.append(p) + else: + pin_entries.append((str(p.number), p.name, None, None, p.functions)) + else: + for pn in sorted(comp.pins.keys(), key=_pin_sort_key): + matched_schematic_pins.add(pn) + pin_entries.append((pn, None, comp.pins[pn], None, None)) + + # Orphan schematic pins: present in netlist but not matched to any + # pintable entry. Commonly this is the EP/thermal pad under a user- + # chosen pin number (e.g. pin_count+1). + orphan_pins = [pn for pn in comp.pins if pn not in matched_schematic_pins] + orphan_pins.sort(key=_pin_sort_key) + + # If there's exactly one unmatched EP pintable row and one orphan + # schematic pin, map them together in the main list rather than + # listing both separately. + fused_ep_note = ( + "exposed pad / thermal pad — datasheet pintable lists this " + "without a usable pin number; matched to orphan schematic pin" + ) + if len(unmatched_ep_entries) == 1 and len(orphan_pins) == 1: + ep_row = unmatched_ep_entries[0] + orphan_pin = orphan_pins[0] + pin_entries.append(( + orphan_pin, + ep_row.name, + comp.pins[orphan_pin], + fused_ep_note, + ep_row.functions, + )) + unmatched_ep_entries = [] + orphan_pins = [] + + # Track nets already shown to avoid repetition + seen_nets: set[str] = set() + + for pin_num, pin_name, net_name, note, functions in pin_entries: + name_str = f" ({pin_name})" if pin_name else "" + note_str = f" [{note}]" if note else "" + # Render the datasheet alternate-function list inline only for pins whose + # net name asserts a peripheral role (UART5_TX, I2C1_SDA, ...), so the + # reviewer can check the asserted function against what the pin actually + # supports — without bloating the context for every GPIO. + alt_str = "" + if functions and net_name and parse_net_token(net_name): + alt_str = f" [alt: {', '.join(functions)}]" + + if not net_name: + lines.append(f"Pin {pin_num}{name_str} → [unconnected]{note_str}") + lines.append("") + continue + + net = graph.nets.get(net_name) + if not net: + lines.append(f"Pin {pin_num}{name_str} → {net_name}{alt_str}") + lines.append("") + continue + + voltage_str = _reviewer_voltage_str(net) + lines.append( + f"Pin {pin_num}{name_str} → {net_name} " + f"[{net.net_type.value}{voltage_str}]{alt_str}{note_str}" + ) + + # If we already showed this net's components, just note it + if net_name in seen_nets: + lines.append(f" (same net as above)") + lines.append("") + continue + seen_nets.add(net_name) + + # Collect neighbors on this net (excluding self) + neighbors = [ + pc for pc in net.pins + if pc.component_ref != ref and pc.component_ref in graph.components + ] + + # For large ground/power nets, summarize + if len(neighbors) > _GROUND_NET_MAX_COMPONENTS and net.net_type in (NetType.GROUND, NetType.POWER): + # Group by type + by_type: dict[str, list[str]] = {} + for pc in neighbors: + nb = graph.components[pc.component_ref] + ctype = nb.component_type.value + by_type.setdefault(ctype, []).append(pc.component_ref) + parts = [f"{len(refs)} {ctype}{'s' if len(refs) > 1 else ''}" for ctype, refs in sorted(by_type.items())] + lines.append(f" {len(neighbors)} components on this net: {', '.join(parts)}") + # Still list ICs specifically since they're important + for pc in neighbors: + nb = graph.components[pc.component_ref] + if nb.component_type == ComponentType.IC: + pin_name_str = "" + nb_constraints = constraints_map.get(nb.mpn or "") + if nb_constraints: + p = nb_constraints.pin_by_number(pc.pin_number) + if p: + pin_name_str = f" ({p.name})" + lines.append(f" {nb.reference}: {nb.mpn or nb.value} [pin {pc.pin_number}{pin_name_str}]") + else: + for pc in neighbors: + nb = graph.components[pc.component_ref] + mpn_str = f", {nb.mpn}" if nb.mpn else "" + specs_str = _format_specs(nb.specs) + if specs_str: + specs_str = f" ({specs_str})" + + # Pin name on the neighbor + pin_name_str = "" + nb_constraints = constraints_map.get(nb.mpn or "") + if nb_constraints: + p = nb_constraints.pin_by_number(pc.pin_number) + if p: + pin_name_str = f" ({p.name})" + + lines.append( + f" {nb.reference}: {nb.value}{mpn_str}{specs_str}" + f" [pin {pc.pin_number}{pin_name_str}]" + ) + + lines.append("") + + # Bridges: components whose pins land on two or more of this IC's nets. + # Captures Rsense / feedback dividers / decoupling caps / snubbers / + # protection resistors that span two IC pins — easy to miss when each + # endpoint is on a different net section, especially when one endpoint + # is on a power/ground net that gets summarized. + pin_name_by_num = {pn: pname for pn, pname, _, _, _ in pin_entries if pname} + ic_net_to_pins: dict[str, list[str]] = {} + for ic_pin, ic_net in comp.pins.items(): + if ic_net: + ic_net_to_pins.setdefault(ic_net, []).append(ic_pin) + ic_nets_set = set(ic_net_to_pins.keys()) + + def _label_endpoint(net: str) -> str: + pins = sorted(ic_net_to_pins.get(net, []), key=_pin_sort_key) + prefix = "pins" if len(pins) > 1 else "pin" + names = [pin_name_by_num.get(p) for p in pins] + named = [n for n in names if n] + if named: + unique = list(dict.fromkeys(named)) # preserve order, dedupe + name_str = "/".join(unique) + return f"{prefix} {'/'.join(pins)} ({name_str}, {net})" + return f"{prefix} {'/'.join(pins)} ({net})" + + # A bridge is "interesting" only if at least one endpoint is a signal + # net. Pure VCC↔GND bridges (bypass caps, every IC sharing the rail) + # would otherwise drown out the signal-bearing topology like Rsense or + # MCU pulldowns. Decoupling-cap counts are already visible in the + # per-pin listing's "N capacitors on this net" summary. + def _is_signal_net(name: str) -> bool: + n = graph.nets.get(name) + return bool(n and n.net_type == NetType.SIGNAL) + + bridge_lines: list[str] = [] + skipped_power_only = 0 + for nb_ref, nb in graph.components.items(): + if nb_ref == ref: + continue + nets_touched = {n for n in nb.pins.values() if n in ic_nets_set} + if len(nets_touched) < 2: + continue + if not any(_is_signal_net(n) for n in nets_touched): + skipped_power_only += 1 + continue + nets_sorted = sorted(nets_touched) + endpoints = " ↔ ".join(_label_endpoint(n) for n in nets_sorted) + mpn_str = f", {nb.mpn}" if nb.mpn else "" + specs_str = _format_specs(nb.specs) + if specs_str: + specs_str = f" ({specs_str})" + value_str = nb.value if nb.value else nb.component_type.value + bridge_lines.append( + f" {nb.reference}: {value_str}{mpn_str}{specs_str} — bridges {endpoints}" + ) + + if bridge_lines or skipped_power_only: + lines.append(f"Bridges between {ref}'s pins (signal-bearing only):") + if bridge_lines: + bridge_lines.sort() + lines.extend(bridge_lines) + if skipped_power_only: + lines.append( + f" ({skipped_power_only} additional bypass/rail-sharing " + f"bridges between power & ground nets, omitted — see per-pin listing for counts)" + ) + lines.append("") + + # Orphan schematic pins — not matched to any pintable entry. These are + # frequently the EP/thermal pad (schematic symbols commonly assign a + # custom pin number to the exposed pad). + if orphan_pins or unmatched_ep_entries: + lines.append("Additional schematic pins (not in datasheet pintable):") + if unmatched_ep_entries: + ep_names = ", ".join( + f"{p.name} (pintable #{p.number})" for p in unmatched_ep_entries + ) + lines.append( + f" (datasheet pintable lists these without a schematic-matched " + f"pin number — likely the exposed pad: {ep_names})" + ) + for pn in orphan_pins: + net_name = comp.pins.get(pn) + if not net_name: + continue + net = graph.nets.get(net_name) + if net is None: + lines.append(f" Pin {pn} → {net_name}") + continue + voltage_str = _reviewer_voltage_str(net) + lines.append( + f" Pin {pn} → {net_name} [{net.net_type.value}{voltage_str}]" + ) + if not orphan_pins and unmatched_ep_entries: + lines.append( + " (no matching orphan schematic pin found — the EP may be " + "genuinely unconnected in the schematic)" + ) + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# PDF helper +# --------------------------------------------------------------------------- + + +def _pdf_content_block(pdf_path: str) -> dict: + """Build a Claude API document block from a PDF file.""" + data = base64.standard_b64encode(Path(pdf_path).read_bytes()).decode() + return { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": data}, + "cache_control": {"type": "ephemeral"}, + } + + +# --------------------------------------------------------------------------- +# Per-IC review +# --------------------------------------------------------------------------- + + +class ReviewResult: + """Findings + coverage from a single IC review.""" + __slots__ = ("findings", "checked_areas") + + def __init__(self, findings: list[Finding], checked_areas: list[str]): + self.findings = findings + self.checked_areas = checked_areas + + +def review_component( + client: anthropic.Anthropic, + graph: DesignGraph, + constraints_map: ConstraintsMap, + ic_ref: str, + pdf_path: str, + model: str = "claude-sonnet-4-6", +) -> ReviewResult: + """Review an IC's usage against its datasheet. Returns findings + coverage.""" + comp = graph.components[ic_ref] + mpn = comp.mpn or comp.value + context = build_component_context(graph, constraints_map, ic_ref) + + user_content: list[dict] = [ + _pdf_content_block(pdf_path), + { + "type": "text", + "text": f"Review this component's usage:\n\n{context}", + "cache_control": {"type": "ephemeral"}, + }, + ] + + messages: list[dict] = [{"role": "user", "content": user_content}] + + for turn in range(_MAX_REVIEW_TURNS): + is_last_turn = turn == _MAX_REVIEW_TURNS - 1 + + # On the last turn, force submit_review + if is_last_turn: + tools = [SUBMIT_REVIEW_SCHEMA] + tool_choice = {"type": "tool", "name": "submit_review"} + else: + tools = ALL_TOOLS + tool_choice = {"type": "auto"} + + response = client.messages.create( + model=model, + max_tokens=4096, + system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}], + tools=tools, + tool_choice=tool_choice, + messages=messages, + ) + + # Check for submit_review + for block in response.content: + if block.type == "tool_use" and block.name == "submit_review": + return _parse_review(block.input, ic_ref, mpn) + + # Process graph tool calls + tool_results = [] + for block in response.content: + if block.type == "tool_use": + result_text = execute_tool(graph, constraints_map, block.name, block.input) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": result_text, + }) + + if not tool_results: + # Model responded with text only — no tools called, no submission + break + + messages.append({"role": "assistant", "content": response.content}) + messages.append({"role": "user", "content": tool_results}) + + return ReviewResult([], []) # No findings submitted + + +def _coerce_str_list(value) -> list[str]: + """Coerce a tool-input value into a list of non-empty strings. + + Claude occasionally violates the tool schema (e.g. returns a stringified + list instead of a real array). Sanitize here so downstream Pydantic + validation of ValidationReport cannot fail on a single IC's output. + """ + if value is None: + return [] + if isinstance(value, list): + return [str(x).strip() for x in value if x is not None and str(x).strip()] + if isinstance(value, str): + s = value.strip() + if not s: + return [] + try: + parsed = json.loads(s) + if isinstance(parsed, list): + return [str(x).strip() for x in parsed if x is not None and str(x).strip()] + except (json.JSONDecodeError, ValueError): + pass + return [s] + return [str(value).strip()] + + +def _parse_review( + tool_input: dict, + ic_ref: str, + mpn: str, + *, + mpn_by_designator: dict[str, str] | None = None, + connected: set[str] | None = None, +) -> ReviewResult: + """Parse submit_review tool output into findings + coverage. + + A finding whose evidence came from a *connected* neighbor's datasheet + excerpt carries that neighbor's designator in ``source_designator`` — its + ``source_page`` is a page in the neighbor's PDF, not the IC under review. + ``mpn_by_designator`` resolves that designator to the MPN so ``reference`` + (and the frontend viewer) point at the correct datasheet; ``connected`` + restricts which neighbor designators are honored. When the map/neighbor is + absent or unresolvable, the citation falls back to this IC's own datasheet + so the cited page and the datasheet the viewer opens never disagree. + """ + mpn_by_designator = mpn_by_designator or {} + findings: list[Finding] = [] + raw_findings = tool_input.get("findings") or [] + if not isinstance(raw_findings, list): + raw_findings = [] + for item in raw_findings: + if not isinstance(item, dict): + continue + try: + page = item.get("source_page") + raw_src = str(item.get("source_designator") or "").strip() + if ( + raw_src + and raw_src != ic_ref + and raw_src in mpn_by_designator + and (connected is None or raw_src in connected) + ): + src_designator: str | None = raw_src + src_mpn = mpn_by_designator[raw_src] + else: + src_designator = None + src_mpn = mpn + findings.append(Finding( + designator=ic_ref, + mpn=mpn, + source_designator=src_designator, + finding=item["finding"], + why=item.get("why", ""), + status=item["status"], + source_page=page, + source_quote=item.get("source_quote", ""), + recommendation=item.get("recommendation", ""), + reference=f"{src_mpn} datasheet p.{page if page is not None else '?'}", + )) + except (KeyError, TypeError, ValueError) as exc: + print(f"Skipping malformed finding for {ic_ref}: {exc}", file=sys.stderr) + continue + checked_areas = _coerce_str_list(tool_input.get("checked_areas")) + return ReviewResult(findings, checked_areas) + + +def assign_finding_ids(findings: list[Finding]) -> None: + """Assign finding_id: {designator}-{001}, {002}, ...""" + counter: Counter[str] = Counter() + for f in findings: + counter[f.designator] += 1 + f.finding_id = f"{f.designator}-{counter[f.designator]:03d}" + + +# --------------------------------------------------------------------------- +# Datasheet loading (for pintable/constraints lookup) +# --------------------------------------------------------------------------- + + +def _load_datasheets(directory: str | Path) -> dict[str, ComponentConstraints]: + """Load all extracted datasheet JSONs, keyed by MPN.""" + result: dict[str, ComponentConstraints] = {} + dirpath = Path(directory) + if not dirpath.is_dir(): + return result + for f in dirpath.glob("*.json"): + raw = json.loads(f.read_text()) + c = ComponentConstraints.model_validate(raw) + result[c.mpn] = c + return result + + +def _match_constraints( + mpn: str | None, + datasheets: dict[str, ComponentConstraints], +) -> ComponentConstraints | None: + """Match a component MPN to extracted constraints (exact then normalized).""" + if not mpn: + return None + if mpn in datasheets: + return datasheets[mpn] + norm = re.sub(r"[/_\-\s]", "", mpn).upper() + for ds_mpn, constraints in datasheets.items(): + if re.sub(r"[/_\-\s]", "", ds_mpn).upper() == norm: + return constraints + return None + + +def _build_constraints_map(datasheets: dict[str, ComponentConstraints]) -> ConstraintsMap: + """Build MPN -> constraints map for tool lookups.""" + return dict(datasheets) + + +# --------------------------------------------------------------------------- +# Main (CLI entry point) +# --------------------------------------------------------------------------- + + +def validate_design( + graph_path: str, + pdf_dir: str, + output_path: str = "report.json", + datasheets_dir: str = "datasheets/extracted", + model: str = "claude-sonnet-4-6", +) -> ValidationReport: + """Load graph, review every IC against its datasheet, write report.""" + from backend.pinscopex.utils import safe_mpn + + raw = json.loads(Path(graph_path).read_text()) + graph = DesignGraph.model_validate(raw) + datasheets = _load_datasheets(datasheets_dir) + constraints_map = _build_constraints_map(datasheets) + + client = anthropic.Anthropic() + all_findings: list[Finding] = [] + all_coverage: dict[str, list[str]] = {} + + pdf_dir_path = Path(pdf_dir) + + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + + # Find the datasheet PDF + mpn = comp.mpn or comp.value + pdf_path = pdf_dir_path / f"{safe_mpn(mpn)}.pdf" + if not pdf_path.is_file(): + print(f"Skipping {ref} ({mpn}) — no datasheet PDF at {pdf_path}") + continue + + print(f"Reviewing {ref} ({mpn}) ...", flush=True) + result = review_component( + client, graph, constraints_map, ref, str(pdf_path), model=model, + ) + all_findings.extend(result.findings) + if result.checked_areas: + all_coverage[ref] = result.checked_areas + print(f" {len(result.findings)} findings: " + f"{sum(1 for f in result.findings if f.status == 'ERROR')} ERROR, " + f"{sum(1 for f in result.findings if f.status == 'WARNING')} WARNING, " + f"{sum(1 for f in result.findings if f.status == 'INFO')} INFO") + if result.checked_areas: + print(f" Checked OK: {', '.join(result.checked_areas)}") + + 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 + + report = ValidationReport( + project=Path(graph_path).stem, + timestamp=datetime.now(timezone.utc).isoformat(), + findings=all_findings, + summary=summary, + coverage=all_coverage, + ) + + Path(output_path).write_text(report.model_dump_json(indent=2)) + print(f"\nReport: {output_path}") + print( + f"Total: {summary['total']} — " + f"{summary['ERROR']} ERROR, {summary['WARNING']} WARNING, {summary['INFO']} INFO" + ) + return report + + +if __name__ == "__main__": + gpath = sys.argv[1] if len(sys.argv) > 1 else "simple_project/design_graph.json" + pdir = sys.argv[2] if len(sys.argv) > 2 else "simple_project/datasheets" + opath = sys.argv[3] if len(sys.argv) > 3 else "simple_project/report.json" + validate_design(gpath, pdir, opath) diff --git a/backend/pinscopex/validation_tools.py b/backend/pinscopex/validation_tools.py new file mode 100644 index 0000000..96c203a --- /dev/null +++ b/backend/pinscopex/validation_tools.py @@ -0,0 +1,783 @@ +"""Graph-query tools for direct datasheet review. + +Tools let the reviewer trace connections beyond the pre-built +component context. The submit_review tool collects all findings. +""" + +from __future__ import annotations + +import logging +import re +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from backend.pinscopex.models import ( + ComponentConstraints, + DesignGraph, +) +from backend.pinscopex.utils import safe_mpn + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _pin_sort_key(pin: str) -> tuple: + m = re.match(r"^(\d+)", pin) + if m: + return (0, int(m.group(1)), pin) + return (1, 0, pin) + + +_THERMAL_PAD_NAME_RE = re.compile( + r"\b(e[\s\-]?pad|epad|ep|dap|thermal\s*pad|exposed\s*(?:pad|paddle)|die[\s\-]?(?:attach\s*)?pad)\b", + re.IGNORECASE, +) + + +def _reviewer_voltage_str(net) -> str: + """Format a net's voltage for reviewer tool output.""" + if net is None or net.voltage is None: + return "" + return f", {net.voltage}V" + + +def _is_thermal_pad_pin(pin) -> bool: + """Heuristic: does a pintable entry describe the exposed/thermal pad? + + Users commonly assign the EP a custom pin number in their schematic + symbol (often pin_count+1) that doesn't match the datasheet pintable's + number for the same pad. Detecting EP pintable entries lets the + reviewer match them to orphan schematic pins instead of reporting them + as unconnected. + """ + for field in (getattr(pin, "name", None), getattr(pin, "description", None)): + if field and _THERMAL_PAD_NAME_RE.search(str(field)): + return True + number = str(getattr(pin, "number", "")).strip() + if number and not number.isdigit() and _THERMAL_PAD_NAME_RE.search(number): + return True + return False + + +def _format_specs(specs) -> str: + """Format component specs as a compact string.""" + if not specs: + return "" + d = specs.model_dump(exclude_none=True, exclude={"specs_type"}) + if not d: + return "" + parts = [] + for k, v in d.items(): + parts.append(f"{k}={v}") + return ", ".join(parts) + + +# Type alias for constraints lookup +ConstraintsMap = dict[str, ComponentConstraints] # MPN -> constraints + + +# --------------------------------------------------------------------------- +# Excerpt tool — per-review state, topic regexes, page selection +# --------------------------------------------------------------------------- + +# Each topic maps to a narrow keyword regex used to pick relevant pages from +# a neighbor IC's datasheet. Narrower than _REVIEW_KEYWORDS so an excerpt +# fetch returns a focused slice (~5-10 pages) rather than 30+. +EXCERPT_TOPICS: dict[str, re.Pattern] = { + "absolute_max": re.compile( + r"absolute\s+maximum|maximum\s+ratings?|stress\s+rating", + re.IGNORECASE, + ), + "recommended_operating": re.compile( + r"recommended\s+operating|operating\s+conditions?|operating\s+range", + re.IGNORECASE, + ), + "electrical_characteristics": re.compile( + r"electrical\s+characteristics?|DC\s+characteristics?|AC\s+characteristics?" + r"|V[IO][HL]\s*\(|input\s+(high|low)\s+voltage|output\s+(high|low)\s+voltage", + re.IGNORECASE, + ), + "pin_voltage_levels": re.compile( + r"5[\s\-]?V[\s\-]?tolerant|5V[\s\-]?tolerance|voltage\s+tolerance" + r"|input\s+voltage\s+range|pin\s+voltage|I/O\s+voltage" + r"|V[IO][HL]\b|VIO\b|VDDIO\b|tolerant\s+input", + re.IGNORECASE, + ), + "power_supply": re.compile( + r"power\s+supply|supply\s+voltage|VDD|VCC|VBAT|supply\s+current" + r"|quiescent\s+current", + re.IGNORECASE, + ), + "thermal": re.compile( + r"thermal\s+(resistance|shutdown|pad|characteristics)|junction\s+temperature" + r"|theta[\s\-]?J[AC]|θJ[AC]", + re.IGNORECASE, + ), + "application_circuit": re.compile( + r"application\s+(circuit|schematic|information|note)" + r"|typical\s+application|reference\s+design|recommended\s+circuit", + re.IGNORECASE, + ), +} + +_EXCERPT_MAX_PAGES_PER_FETCH = 10 # cap per single excerpt call + + +@dataclass +class ExcerptState: + """Per-review state threaded through ``execute_tool`` so the excerpt tool + can enforce neighbor-only access, run a fetch/page budget, and reuse + pypdf trim work across ICs in the same validation run. + + Created in ``review_ic_async``; carries the cross-IC ``cache`` from the + caller (``validate_design_async``). + """ + + current_ic: str + connected_designators: set[str] + graph: DesignGraph + pdf_dir: Path + storage: Any | None = None + # Cross-IC trimmed-PDF cache keyed by (designator, topic, ds_md5) + # -> (trimmed_pdf_path, [original_page_numbers]). Lives for the duration + # of one validate_design_async. + cache: dict[tuple[str, str, str], tuple[str, list[int]]] = field( + default_factory=dict + ) + # Per-review budget counters. ``page_budget`` is the global ceiling that + # bounds total fan-out on a hub IC; ``per_neighbor_page_budget`` is a + # sub-budget so that verifying ONE interface (which needs ~2-3 topic + # fetches from a single neighbor — e.g. pin_voltage_levels + absolute_max) + # is never blocked by pages already spent on a *different* neighbor. This + # is the fix for the U2-001 / U3-001 false positives, where a single + # 25-page global budget got exhausted before the abs-max table could be + # read, forcing the reviewer to guess. + fetch_count: int = 0 + page_count: int = 0 + fetch_budget: int = 8 + page_budget: int = 60 + per_neighbor_page_budget: int = 30 + pages_per_neighbor: dict[str, int] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Tool implementations +# --------------------------------------------------------------------------- + + +def find_connected_components( + graph: DesignGraph, + constraints_map: ConstraintsMap, + designator: str, + pin: str, + designator_filter: str | None = None, +) -> str: + """Find all components on the net at designator.pin, with full specs.""" + comp = graph.components.get(designator) + if not comp: + return f"Component '{designator}' not found." + + net_name = comp.pins.get(str(pin)) + if not net_name: + return f"Pin {pin} on {designator} is not connected in the netlist." + + net = graph.nets[net_name] + voltage_str = _reviewer_voltage_str(net) + lines = [f"Net: {net_name} ({net.net_type.value}{voltage_str})"] + + count = 0 + for pc in net.pins: + if pc.component_ref == designator: + continue + if designator_filter and not pc.component_ref.upper().startswith(designator_filter.upper()): + continue + + neighbor = graph.components.get(pc.component_ref) + if not neighbor: + continue + count += 1 + + # Component header + mpn_str = f", MPN={neighbor.mpn}" if neighbor.mpn else "" + sub_str = f", {neighbor.component_subtype}" if neighbor.component_subtype else "" + specs_str = _format_specs(neighbor.specs) + if specs_str: + specs_str = f" ({specs_str})" + + lines.append( + f" {neighbor.reference}: {neighbor.value}{mpn_str}, " + f"{neighbor.component_type.value}{sub_str}{specs_str}" + ) + + # Pin map + pin_strs = [] + for pn, pnet in sorted(neighbor.pins.items(), key=lambda x: _pin_sort_key(x[0])): + pin_strs.append(f"{pn}->{pnet}") + lines.append(f" pins: {', '.join(pin_strs)}") + + if count == 0: + filter_note = f" matching '{designator_filter}*'" if designator_filter else "" + lines.append(f" (no components{filter_note} on this net)") + + return "\n".join(lines) + + +def get_net_for_pin( + graph: DesignGraph, + constraints_map: ConstraintsMap, + designator: str, + pin: str, +) -> str: + """Get net info for a specific pin — lightweight, no component listing.""" + comp = graph.components.get(designator) + if not comp: + return f"Component '{designator}' not found." + + net_name = comp.pins.get(str(pin)) + if not net_name: + return f"Pin {pin} on {designator} is not connected in the netlist." + + net = graph.nets[net_name] + voltage_str = _reviewer_voltage_str(net) + + # Get pin name from constraints + pin_name = "" + constraints = constraints_map.get(comp.mpn or "") + if constraints: + p = constraints.pin_by_number(pin) + if p: + pin_name = f" ({p.name})" + + return f"Pin {pin}{pin_name} on {designator} -> {net_name} [{net.net_type.value}{voltage_str}]" + + +def get_pintable( + graph: DesignGraph, + constraints_map: ConstraintsMap, + designator: str, +) -> str: + """Get full pintable with connection status.""" + comp = graph.components.get(designator) + if not comp: + return f"Component '{designator}' not found." + + constraints = constraints_map.get(comp.mpn or "") + if not constraints: + # Fall back to just showing netlist pins + lines = [f"Pintable for {designator} ({comp.mpn or comp.value}) — no extracted pintable:"] + for pn, pnet in sorted(comp.pins.items(), key=lambda x: _pin_sort_key(x[0])): + net = graph.nets.get(pnet) + ntype = f" [{net.net_type.value}]" if net else "" + lines.append(f" Pin {pn}: -> {pnet}{ntype} [connected]") + return "\n".join(lines) + + lines = [f"Pintable for {designator} ({comp.mpn}):"] + matched: set[str] = set() + for p in sorted(constraints.pintable, key=lambda x: _pin_sort_key(str(x.number))): + net_name = comp.pins.get(str(p.number)) + func_str = f" [alt: {', '.join(p.functions)}]" if p.functions else "" + if net_name: + matched.add(str(p.number)) + net = graph.nets.get(net_name) + voltage_str = _reviewer_voltage_str(net) + ntype = net.net_type.value if net else "?" + lines.append(f" Pin {p.number} ({p.name}): -> {net_name} [{ntype}{voltage_str}]{func_str} [connected]") + else: + tp_note = " [likely exposed pad — check orphan schematic pins below]" if _is_thermal_pad_pin(p) else "" + lines.append(f" Pin {p.number} ({p.name}){func_str}: [unconnected]{tp_note}") + + orphans = [pn for pn in comp.pins if pn not in matched] + if orphans: + lines.append("") + lines.append( + "Additional schematic pins (not in datasheet pintable — " + "commonly the EP/thermal pad under a user-chosen pin number):" + ) + for pn in sorted(orphans, key=_pin_sort_key): + net_name = comp.pins.get(pn) or "" + net = graph.nets.get(net_name) + voltage_str = _reviewer_voltage_str(net) + ntype = net.net_type.value if net else "?" + lines.append(f" Pin {pn}: -> {net_name} [{ntype}{voltage_str}]") + + return "\n".join(lines) + + +def _resolve_neighbor_pdf( + state: ExcerptState, + mpn: str, +) -> Path | None: + """Resolve a neighbor IC's MPN to a local PDF path. + + Mirrors validation._find_pdf's local-then-library lookup so neighbor + datasheets follow the same resolution rules as the IC under review. + """ + safe = safe_mpn(mpn) + local = state.pdf_dir / f"{safe}.pdf" + if local.is_file(): + return local + if state.storage is not None: + try: + from backend.services import projects as proj_svc + lib_key = proj_svc.library_has_datasheet(state.storage, mpn) + if lib_key: + state.storage.download_to_local(lib_key, local) + if local.is_file(): + return local + except Exception: + log.exception("excerpt: library lookup failed for %s", mpn) + return None + + +def _trim_pdf_by_keywords( + pdf_path: Path, + keyword_re: re.Pattern, + max_pages: int, +) -> tuple[str, list[int]]: + """Pypdf-trim a PDF to pages matching a keyword regex (+/-1 neighbors). + + Returns ``(trimmed_pdf_path, kept_page_numbers_1indexed)``. The trimmed + path is a temp file the caller is responsible for cleaning up *eventually* + — in practice we keep these for the lifetime of the validation run so the + same excerpt can be reused across ICs. + + Page numbers in the return list are 1-indexed and refer to the *original* + PDF, so the model can cite them as ``source_page`` consistent with the + no-remap convention used everywhere else in the reviewer. + """ + from pypdf import PdfReader, PdfWriter + + reader = PdfReader(str(pdf_path)) + total = len(reader.pages) + if total == 0: + return str(pdf_path), [] + + keep: set[int] = set() + for i, page in enumerate(reader.pages): + try: + text = page.extract_text() or "" + except Exception: + text = "" + if keyword_re.search(text): + for n in (i - 1, i, i + 1): + if 0 <= n < total: + keep.add(n) + if len(keep) >= max_pages: + break + + if not keep: + # Fall back: first few pages so the model gets *something* it can + # decline to use, rather than an empty excerpt. + keep = set(range(min(3, total))) + + selected = sorted(keep)[:max_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, [i + 1 for i in selected] + + +def get_datasheet_excerpt( + graph: DesignGraph, + constraints_map: ConstraintsMap, + designator: str, + topic: str, + state: ExcerptState | None, +): + """Return pages from a *connected* neighbor IC's datasheet for a topic. + + Returns ``(text_summary, pdf_block_or_none)`` — the caller treats the text + as the tool's ``content`` and attaches the PdfBlock (if present) to the + same user message so the model can read the pages on the next turn. + + Restricted to neighbors of the IC under review (state.connected_designators). + Subject to per-review fetch/page budget caps. + """ + if state is None: + return ("get_datasheet_excerpt called without per-review state — " + "this is a bug, no excerpt returned.", None) + + # Lazy import to avoid backend↔pinscopex circular dependency at module load. + from backend.services.llm import PdfBlock + + designator = (designator or "").strip() + topic = (topic or "").strip().lower() + + if topic not in EXCERPT_TOPICS: + valid = ", ".join(sorted(EXCERPT_TOPICS.keys())) + return (f"Unknown topic '{topic}'. Valid topics: {valid}.", None) + + if designator == state.current_ic: + return ( + f"You are already reviewing {designator}'s datasheet — its pages " + f"are in your initial context. Use the existing PDF, no excerpt " + f"fetch needed.", + None, + ) + + if designator not in state.connected_designators: + return ( + f"{designator} is not a signal neighbor of {state.current_ic} " + f"in this design. The excerpt tool is restricted to ICs that " + f"share a signal net with the IC under review. If you suspect " + f"the issue still applies, submit WARNING with an explicit " + f"Unverified: assumption.", + None, + ) + + comp = graph.components.get(designator) + if comp is None: + return (f"Component '{designator}' not found in design graph.", None) + + mpn = comp.mpn or comp.value + if not mpn: + return (f"{designator} has no MPN — cannot resolve a datasheet.", None) + + # Budget checks before doing pypdf work. Three caps, in order: + # - fetch_count: total excerpt calls this review (bounds turn cost). + # - per_neighbor_page_budget: pages already pulled from THIS neighbor — + # once a neighbor is fully examined, more pages won't help. + # - page_budget: global ceiling across all neighbors (hub-IC fan-out). + # The per-neighbor cap is checked before the global one so that pulling + # the 2-3 topics needed to verify a single interface is never starved by + # pages spent on other neighbors. + neighbor_pages = state.pages_per_neighbor.get(designator, 0) + if state.fetch_count >= state.fetch_budget: + return ( + f"Excerpt budget exhausted ({state.fetch_count}/" + f"{state.fetch_budget} fetches used). Submit WARNING with an " + f"explicit Unverified: assumption rather than fetching more.", + None, + ) + if neighbor_pages >= state.per_neighbor_page_budget: + return ( + f"Per-neighbor excerpt budget for {designator} exhausted " + f"({neighbor_pages}/{state.per_neighbor_page_budget} pages). " + f"You have read enough of {designator}'s datasheet; submit " + f"WARNING with an explicit Unverified: assumption if the spec " + f"still isn't resolved.", + None, + ) + if state.page_count >= state.page_budget: + return ( + f"Excerpt page budget exhausted ({state.page_count}/" + f"{state.page_budget} pages used). Submit WARNING with an " + f"explicit Unverified: assumption rather than fetching more.", + None, + ) + + pdf_path = _resolve_neighbor_pdf(state, mpn) + if pdf_path is None: + return ( + f"No datasheet PDF available for {designator} ({mpn}). Submit " + f"WARNING with an explicit Unverified: assumption stating what " + f"you needed to verify.", + None, + ) + + # Stable cache key — md5 the source PDF once, reuse across ICs. + import hashlib + try: + ds_md5 = hashlib.md5(pdf_path.read_bytes()).hexdigest() + except Exception: + log.exception("excerpt: md5 failed for %s", pdf_path) + ds_md5 = pdf_path.name + + cache_key = (designator, topic, ds_md5) + cache_val = state.cache.get(cache_key) + pages: list[int] + trimmed_path: str + if ( + isinstance(cache_val, tuple) + and len(cache_val) == 2 + and Path(cache_val[0]).is_file() + ): + trimmed_path, pages = cache_val # type: ignore[assignment] + else: + keyword_re = EXCERPT_TOPICS[topic] + remaining_budget = min( + _EXCERPT_MAX_PAGES_PER_FETCH, + max(1, state.page_budget - state.page_count), + max(1, state.per_neighbor_page_budget - neighbor_pages), + ) + trimmed_path, pages = _trim_pdf_by_keywords( + pdf_path, keyword_re, remaining_budget, + ) + state.cache[cache_key] = (trimmed_path, pages) + + # Update per-review budget counters + state.fetch_count += 1 + state.page_count += len(pages) + state.pages_per_neighbor[designator] = neighbor_pages + len(pages) + + block = PdfBlock(path=Path(trimmed_path), cacheable=True) + summary = ( + f"Returned {len(pages)} pages from {designator} ({mpn}) matching " + f"topic '{topic}': pages {pages}. The PDF excerpt is attached to " + f"this message — read it and cite the printed page number from the " + f"original datasheet in any resulting finding. These pages are from " + f"{designator}'s datasheet (not the component under review), so set " + f"that finding's source_designator to \"{designator}\" — otherwise the " + f"page number would resolve against the wrong datasheet." + ) + return summary, block + + +# --------------------------------------------------------------------------- +# Tool schemas (for Claude API) +# --------------------------------------------------------------------------- + +FIND_CONNECTED_COMPONENTS_SCHEMA = { + "name": "find_connected_components", + "description": ( + "Find all components connected to the same net as a specific pin. " + "Returns net info and each component with full specs and pin map. " + "Use designator_filter to narrow results (e.g. 'C' for capacitors, 'R' for resistors)." + ), + "input_schema": { + "type": "object", + "properties": { + "designator": { + "type": "string", + "description": "Component reference, e.g. 'U1', 'U2'", + }, + "pin": { + "type": "string", + "description": "Pin number, e.g. '1', '7'", + }, + "designator_filter": { + "type": "string", + "description": "Optional prefix filter: 'C' for caps, 'R' for resistors, 'U' for ICs, etc.", + }, + }, + "required": ["designator", "pin"], + }, +} + +GET_NET_FOR_PIN_SCHEMA = { + "name": "get_net_for_pin", + "description": ( + "Get the net name, type, and voltage for a specific pin. " + "Lightweight — no component listing. Use for quick voltage checks." + ), + "input_schema": { + "type": "object", + "properties": { + "designator": { + "type": "string", + "description": "Component reference, e.g. 'U1'", + }, + "pin": { + "type": "string", + "description": "Pin number, e.g. '1'", + }, + }, + "required": ["designator", "pin"], + }, +} + +GET_PINTABLE_SCHEMA = { + "name": "get_pintable", + "description": ( + "Get the full pin mapping for a component: pin numbers, names, " + "net connections, and whether each pin is connected or unconnected. " + "Use when pin naming is ambiguous or to check for floating pins." + ), + "input_schema": { + "type": "object", + "properties": { + "designator": { + "type": "string", + "description": "Component reference, e.g. 'U1'", + }, + }, + "required": ["designator"], + }, +} + +SUBMIT_REVIEW_SCHEMA = { + "name": "submit_review", + "description": ( + "Submit all findings from your review. Only include issues in findings — " + "do not submit findings for things that are correct. " + "List what you checked and found OK in checked_areas." + ), + "input_schema": { + "type": "object", + "properties": { + "findings": { + "type": "array", + "description": "List of issues found. Empty array if no issues.", + "items": { + "type": "object", + "properties": { + "finding": { + "type": "string", + "description": "What you observed in the actual circuit. 1-3 sentences.", + }, + "why": { + "type": "string", + "description": "Why this matters — what the datasheet says and what could go wrong. 1-3 sentences.", + }, + "status": { + "type": "string", + "enum": ["ERROR", "WARNING", "INFO"], + "description": "ERROR: will cause malfunction. WARNING: may degrade reliability. INFO: worth noting.", + }, + "source_page": { + "type": "integer", + "description": "Datasheet page number where the requirement is stated.", + }, + "source_quote": { + "type": "string", + "description": ( + "The exact verbatim text from the datasheet that " + "states this requirement — copy it " + "character-for-character (max ~200 chars). Omit " + "if the evidence is only in a figure or a " + "rasterized table with no selectable text." + ), + }, + "source_designator": { + "type": "string", + "description": ( + "Designator of the component whose datasheet " + "source_page and source_quote refer to. OMIT " + "this when the page/quote is from the component " + "you are reviewing (its own datasheet — the " + "common case). Set it ONLY when the evidence " + "came from a connected component's datasheet " + "that you fetched with get_datasheet_excerpt " + "(e.g. \"U3\"), so source_page resolves to the " + "correct datasheet." + ), + }, + "recommendation": { + "type": "string", + "description": "What to change to fix the issue. Only for ERROR/WARNING.", + }, + }, + "required": ["finding", "why", "status", "source_page"], + }, + }, + "checked_areas": { + "type": "array", + "description": ( + "Areas you reviewed and found correct. Short labels, e.g. " + "'input decoupling', 'output capacitor', 'enable logic', " + "'crystal circuit', 'voltage margins', 'reset circuit'." + ), + "items": {"type": "string"}, + }, + }, + "required": ["findings", "checked_areas"], + }, +} + +GET_DATASHEET_EXCERPT_SCHEMA = { + "name": "get_datasheet_excerpt", + "description": ( + "Fetch a focused excerpt of a *connected* IC's datasheet — the pages " + "covering one topic (abs-max, electrical characteristics, 5V-tolerance, " + "etc.). Use this BEFORE flagging any cross-IC interface issue that " + "depends on the counterpart's spec. Restricted to ICs that share a " + "signal net with the IC under review. Subject to a per-review fetch " + "budget; if exhausted, submit WARNING with an explicit Unverified: " + "assumption rather than guessing." + ), + "input_schema": { + "type": "object", + "properties": { + "designator": { + "type": "string", + "description": ( + "Reference of a connected IC (e.g. 'U3'). Must be a " + "signal neighbor of the IC under review." + ), + }, + "topic": { + "type": "string", + "enum": sorted(EXCERPT_TOPICS.keys()), + "description": ( + "Which datasheet section to pull. Pick the narrowest " + "topic that covers the spec you need — pin_voltage_levels " + "for 5V-tolerance / VIH / VIL, absolute_max for stress " + "ratings, electrical_characteristics for drive " + "strengths, application_circuit for reference designs." + ), + }, + }, + "required": ["designator", "topic"], + }, +} + +GRAPH_TOOLS = [ + FIND_CONNECTED_COMPONENTS_SCHEMA, + GET_NET_FOR_PIN_SCHEMA, + GET_PINTABLE_SCHEMA, + GET_DATASHEET_EXCERPT_SCHEMA, +] +ALL_TOOLS = GRAPH_TOOLS + [SUBMIT_REVIEW_SCHEMA] + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + +def execute_tool( + graph: DesignGraph, + constraints_map: ConstraintsMap, + tool_name: str, + tool_input: dict, + state: ExcerptState | None = None, +): + """Execute a graph-query tool call. + + Returns ``(text, attachment)`` where ``attachment`` is an optional + PdfBlock the caller should append to the next user message alongside the + tool_result. All tools except ``get_datasheet_excerpt`` return + ``(text, None)``. + """ + if tool_name == "find_connected_components": + return ( + find_connected_components( + graph, constraints_map, + tool_input["designator"], + tool_input["pin"], + tool_input.get("designator_filter"), + ), + None, + ) + if tool_name == "get_net_for_pin": + return ( + get_net_for_pin( + graph, constraints_map, + tool_input["designator"], + tool_input["pin"], + ), + None, + ) + if tool_name == "get_pintable": + return ( + get_pintable( + graph, constraints_map, + tool_input["designator"], + ), + None, + ) + if tool_name == "get_datasheet_excerpt": + return get_datasheet_excerpt( + graph, constraints_map, + tool_input.get("designator", ""), + tool_input.get("topic", ""), + state, + ) + return (f"Unknown tool: {tool_name}", None) diff --git a/backend/pipeline_worker.py b/backend/pipeline_worker.py new file mode 100644 index 0000000..1ff5d1d --- /dev/null +++ b/backend/pipeline_worker.py @@ -0,0 +1,126 @@ +"""Pipeline worker entrypoint — runs as a Cloud Run Job execution. + +Invoked by Cloud Run Jobs (prod) or as a child subprocess (local dev). +Reads execution parameters from environment variables, swaps the +in-memory event broker for the GCS-backed one, and dispatches to either +``run_pipeline`` (full run) or ``run_regen_pipeline`` (admin regen). + +Required env vars: + PROJECT_ID — project to run + USER_ID — owner user id (Clerk sub or "local") + +Optional env vars: + RESUME "1"/"0" — resume a paused run from its checkpoint + FREE "1"/"0" — admin-initiated free run (no charge) + MODE "run" (default) | "regen" + REGEN_STAGES comma-separated, e.g. "derating" (regen mode only) + EXECUTION_NAME Cloud Run execution resource name (purely for log + correlation — the API already wrote it onto + ``ProjectMeta.execution_name`` at enqueue time) + +This module **must not** import :mod:`backend.main` — the FastAPI +lifespan would attempt to wire up shutdown handlers we don't want here. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys + +from backend.config import settings +from backend.services import event_bridge as event_bridge +from backend.services import pipeline as pipeline_svc +from backend.services.storage import LocalStorageBackend, StorageBackend + + +def _build_storage() -> StorageBackend: + if settings.use_gcs: + from backend.services.storage_gcs import GCSStorageBackend + + return GCSStorageBackend(settings.gcs_bucket) + return LocalStorageBackend(settings.data_dir) + + +def _required_env(name: str) -> str: + val = os.environ.get(name, "").strip() + if not val: + raise SystemExit(f"missing required env var: {name}") + return val + + +def _bool_env(name: str, default: bool = False) -> bool: + raw = os.environ.get(name, "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +async def _run() -> None: + project_id = _required_env("PROJECT_ID") + user_id = _required_env("USER_ID") + resume = _bool_env("RESUME") + free = _bool_env("FREE") + mode = os.environ.get("MODE", "run").strip().lower() or "run" + execution_name = os.environ.get("EXECUTION_NAME", "").strip() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [worker %(name)s] %(message)s", + ) + log = logging.getLogger("backend.pipeline_worker") + log.info( + "starting worker mode=%s project=%s user=%s resume=%s free=%s execution=%s", + mode, project_id, user_id, resume, free, execution_name or "(none)", + ) + + storage = _build_storage() + + # Swap in the GCS-backed broker so events written from this process + # are visible to any API instance tailing the event log. + pipeline_svc.set_broker(event_bridge.GCSEventBroker(storage, user_id)) + + # Fresh runs wipe the prior event log so the SSE consumer doesn't + # mix old events into the new run. Resume keeps the prior log so + # users see the full history. + if not resume: + pipeline_svc.broker.clear_history(project_id) + + if mode == "run": + await pipeline_svc.run_pipeline( + storage, user_id, project_id, resume=resume, free=free, + ) + elif mode == "regen": + stages_raw = os.environ.get("REGEN_STAGES", "").strip() + stages = [s for s in (s.strip() for s in stages_raw.split(",")) if s] + if not stages: + raise SystemExit("REGEN_STAGES must list at least one stage in regen mode") + await pipeline_svc.run_regen_pipeline( + storage, user_id, project_id, stages, + ) + else: + raise SystemExit(f"unknown MODE={mode!r}; expected 'run' or 'regen'") + + +def main() -> None: + try: + asyncio.run(_run()) + except SystemExit: + raise + except KeyboardInterrupt: + # Local dev convenience — the run_pipeline cancel handler will + # have already transitioned the project on SIGTERM. + sys.exit(130) + except BaseException as exc: # pragma: no cover — last-mile safety + # The pipeline's own ``except Exception`` already logs and writes + # ``status=error`` for the project. This catch only exists so a + # truly unhandled BaseException (e.g. SystemExit during boot + # before run_pipeline starts) still surfaces as a non-zero exit + # code, which Cloud Run records as "Failed" on the execution. + logging.exception("worker crashed before run_pipeline cleanup: %s", exc) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..29de97a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,17 @@ +fastapi>=0.115 +uvicorn[standard] +anthropic>=0.83 +google-genai>=1.59 +pydantic[email]>=2.0 +pydantic-settings +python-multipart +sse-starlette +python-dotenv +openpyxl>=3.1 +google-cloud-storage>=2.14 +google-cloud-run>=0.10 +google-api-python-client>=2.100 +pypdf>=4.0 +PyJWT[crypto]>=2.8 +cryptography>=42.0 +packaging>=23.0 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..49c4bf8 --- /dev/null +++ b/backend/routers/admin.py @@ -0,0 +1,736 @@ +"""Admin endpoints — library components, user management, and limits. + +All endpoints require the requesting user to have role: "admin" in their +Clerk public metadata. In local dev (no auth), all requests are treated as admin. +""" + +from __future__ import annotations + +import asyncio +import re + +import httpx +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from backend.config import settings +from backend.pinscopex.utils import safe_mpn +from backend.routers.deps import get_storage +from backend.services import admin_settings as settings_svc +from backend.services.billing_hook import get_billing +from backend.services import projects as proj_svc + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +# --------------------------------------------------------------------------- +# Admin verification +# --------------------------------------------------------------------------- + +async def is_admin(request: Request) -> bool: + """Check if the caller is an admin. Result is cached on request.state.""" + cached = getattr(request.state, "_is_admin", None) + if cached is not None: + return cached + + user_id: str = request.state.user_id + + # Local dev — no auth, treat as admin + if not settings.use_auth: + request.state._is_admin = True + return True + + # Fetch user from Clerk Backend API and check public_metadata.role + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"https://api.clerk.com/v1/users/{user_id}", + headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, + ) + if resp.status_code == 200: + data = resp.json() + role = data.get("public_metadata", {}).get("role") + result = role == "admin" + else: + result = False + except Exception: + result = False + + request.state._is_admin = result + return result + + +async def _require_admin(request: Request) -> str: + """Return user_id if the caller is an admin, else raise 403.""" + if not await is_admin(request): + raise HTTPException(403, "Admin access required") + return request.state.user_id + + +# --------------------------------------------------------------------------- +# Library components +# --------------------------------------------------------------------------- + +@router.get("/components") +async def list_components(request: Request): + """List all extracted IC components and passive patterns in the library.""" + await _require_admin(request) + storage = get_storage(request) + + # IC extractions (deduplicate by MPN) + ic_keys = [ + k for k in storage.list_prefix("library/extracted/") + if k.endswith(".json") + ] + ics = [] + seen_ic_mpns: set[str] = set() + for key in ic_keys: + try: + data = storage.read_json(key) + mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "") + if mpn in seen_ic_mpns: + continue + seen_ic_mpns.add(mpn) + ics.append({ + "mpn": mpn, + "type": "ic", + "subtype": data.get("component_subtype", ""), + "pin_count": len(data.get("pintable", [])), + "has_ratings": bool(data.get("absolute_maximum_ratings")), + }) + except Exception: + continue + + # Passive patterns + pattern_keys = [ + k for k in storage.list_prefix("library/patterns/") + if k.endswith(".json") + ] + passives = [] + seen_passive_names: set[str] = set() + for key in pattern_keys: + try: + data = storage.read_json(key) + name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "") + if name in seen_passive_names: + continue + seen_passive_names.add(name) + passives.append({ + "mpn": name, + "type": "passive", + "subtype": data.get("component_type", ""), + "description": data.get("description", ""), + "regex": data.get("regex", ""), + }) + except Exception: + continue + + # Simple component models (library/models/) + passive models (library/passives/) + model_keys = [ + k for k in storage.list_prefix("library/models/") + if k.endswith(".json") + ] + passive_model_keys = [ + k for k in storage.list_prefix("library/passives/") + if k.endswith(".json") + ] + simple_models = [] + seen_model_mpns: set[str] = set() + for key in model_keys + passive_model_keys: + try: + data = storage.read_json(key) + mpn = data.get("mpn", "") + if mpn in seen_model_mpns: + continue + seen_model_mpns.add(mpn) + specs = data.get("specs", {}) + simple_models.append({ + "mpn": mpn, + "type": "simple", + "specs_type": specs.get("specs_type", ""), + "subtype": specs.get("component_subtype", ""), + "param_count": len(specs.get("values", {})), + }) + except Exception: + continue + + return JSONResponse( + content={"ics": ics, "passives": passives, "simple": simple_models}, + headers={"Cache-Control": "no-store"}, + ) + + +def _safe_name(name: str) -> str: + """Sanitize MPN to safe filename (same logic as pipeline).""" + safe = safe_mpn(name) + if ".." in safe or not re.match(r"^[A-Za-z0-9]", safe): + raise HTTPException(400, "Invalid component name") + return safe + + +@router.get("/components/{component_type}/{name:path}") +async def get_component(component_type: str, name: str, request: Request): + """Return the raw JSON for an IC extraction or passive pattern.""" + await _require_admin(request) + safe = _safe_name(name) + storage = get_storage(request) + + if component_type == "ic": + key = f"library/extracted/{safe}.json" + elif component_type == "passive": + key = f"library/patterns/{safe}.json" + elif component_type == "simple": + # Check library/passives/ first, then library/models/ + key = f"library/passives/{safe}.json" + if not storage.exists(key): + key = f"library/models/{safe}.json" + else: + raise HTTPException(400, f"Unknown component type: {component_type}") + + if not storage.exists(key): + raise HTTPException(404, f"Component not found: {name}") + + return JSONResponse(content=storage.read_json(key)) + + +@router.delete("/components/{component_type}/{name:path}") +async def delete_component(component_type: str, name: str, request: Request): + """Delete an IC extraction or passive pattern from the shared library.""" + await _require_admin(request) + safe = _safe_name(name) + storage = get_storage(request) + + if component_type == "ic": + key = f"library/extracted/{safe}.json" + elif component_type == "passive": + key = f"library/patterns/{safe}.json" + elif component_type == "simple": + # Check library/passives/ first, then library/models/ + key = f"library/passives/{safe}.json" + if not storage.exists(key): + key = f"library/models/{safe}.json" + else: + raise HTTPException(400, f"Unknown component type: {component_type}") + + if not storage.exists(key): + raise HTTPException(404, f"Component not found: {name}") + + storage.delete_key(key) + + # Delete datasheet ref (blob preserved for other refs; GC cleans orphans) + from backend.services.datasheet_store import delete_datasheet_ref + + deleted_datasheets = 0 + old_blob = delete_datasheet_ref(storage, name) + if old_blob: + deleted_datasheets += 1 + # Legacy flat file cleanup (remove after migration confirmed) + ds_key = f"library/datasheets/{safe}.pdf" + if storage.exists(ds_key): + storage.delete_key(ds_key) + deleted_datasheets += 1 + + return {"deleted": key, "deleted_datasheets": deleted_datasheets} + + +def _clerk_profile_fields(clerk: dict) -> dict: + """Pull display name / email / avatar out of a Clerk user object.""" + first = clerk.get("first_name") or "" + last = clerk.get("last_name") or "" + emails = clerk.get("email_addresses", []) + return { + "name": f"{first} {last}".strip() or None, + "email": emails[0].get("email_address") if emails else None, + "image_url": clerk.get("image_url"), + } + + +def _base_admin_user(storage, uid: str) -> dict: + """Build the project-count + balance record for a single user_id.""" + try: + project_count = len(proj_svc.list_projects(storage, uid)) + except Exception: + project_count = 0 + try: + balance = get_billing().get_balance(storage, uid) + except Exception: + balance = 0.0 + return { + "user_id": uid, + "project_count": project_count, + "balance": round(balance, 4), + "name": None, + "email": None, + "image_url": None, + } + + +async def _enrich_clerk_profiles(users: dict[str, dict]) -> None: + """Fill name/email/avatar for each user via the Clerk Backend API. + + Fetches in parallel (bounded) so the list stays fast even with many + users. Failures per-user are swallowed — the row still renders with + the user_id as a fallback label. + """ + sem = asyncio.Semaphore(10) + + async with httpx.AsyncClient(timeout=10.0) as client: + async def _one(uid: str) -> None: + async with sem: + try: + resp = await client.get( + f"https://api.clerk.com/v1/users/{uid}", + headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, + ) + if resp.status_code == 200: + users[uid].update(_clerk_profile_fields(resp.json())) + except Exception: + pass + + await asyncio.gather(*(_one(uid) for uid in users)) + + +@router.get("/users") +async def list_users(request: Request): + """List every user with a project or any credit activity. + + The balance file is written on a user's first ``GET /api/credits`` + (trial grant), so this includes everyone who has ever opened the + authenticated app — not only project creators. To find a user who has + never opened the app, use ``GET /api/admin/users/search?email=``. + """ + await _require_admin(request) + storage = get_storage(request) + + user_ids: set[str] = set() + + # Project creators (users/{user_id}/...) + for entry in storage.list_prefix("users/"): + parts = entry.split("/") + if len(parts) >= 2 and parts[1]: + user_ids.add(parts[1]) + + # Anyone with credit activity (covers the trial grant on first app open) + user_ids.update(get_billing().list_user_ids(storage)) + + users: dict[str, dict] = {uid: _base_admin_user(storage, uid) for uid in user_ids} + + # Enrich with Clerk user info when auth is enabled + if settings.use_auth and users: + await _enrich_clerk_profiles(users) + + return list(users.values()) + + +@router.get("/users/search") +async def search_users(request: Request, email: str): + """Find users by email via Clerk so any account can be topped up (admin). + + Resolves even users with no project and no credit activity yet — useful + for granting credits to someone who has just signed up. Requires auth + to be enabled (no Clerk directory exists in local dev). + """ + await _require_admin(request) + storage = get_storage(request) + + email = email.strip() + if not email: + return [] + if not settings.use_auth: + raise HTTPException(400, "User search requires authentication to be enabled") + + async with httpx.AsyncClient(timeout=10.0) as client: + try: + resp = await client.get( + "https://api.clerk.com/v1/users", + params={"email_address": [email]}, + headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, + ) + except Exception as exc: + raise HTTPException(502, "Failed to look up user") from exc + + if resp.status_code != 200: + raise HTTPException(502, "Failed to look up user") + + results: list[dict] = [] + for clerk in resp.json(): + uid = clerk.get("id") + if not uid: + continue + entry = _base_admin_user(storage, uid) + entry.update(_clerk_profile_fields(clerk)) + results.append(entry) + + return results + + +# --------------------------------------------------------------------------- +# Usage / cost tracking +# --------------------------------------------------------------------------- + +@router.get("/usage") +async def get_usage(request: Request): + """Aggregate API token usage and cost across all users and projects.""" + await _require_admin(request) + storage = get_storage(request) + + user_entries = storage.list_prefix("users/") + seen_uids: set[str] = set() + user_rows: list[dict] = [] + grand_total = 0.0 + + for entry in user_entries: + parts = entry.split("/") + if len(parts) >= 2: + uid = parts[1] + if uid in seen_uids: + continue + seen_uids.add(uid) + + projects = proj_svc.list_projects(storage, uid) + user_cost = 0.0 + project_details = [] + for p in projects: + cost = p.total_cost_usd or 0.0 + user_cost += cost + project_details.append({ + "id": p.id, + "name": p.name, + "status": p.status, + "cost_usd": cost, + "created": p.created, + }) + + user_rows.append({ + "user_id": uid, + "project_count": len(projects), + "total_cost_usd": round(user_cost, 4), + "projects": project_details, + "name": None, + "email": None, + }) + grand_total += user_cost + + # Enrich with Clerk user info + if settings.use_auth and user_rows: + async with httpx.AsyncClient() as client: + for row in user_rows: + try: + resp = await client.get( + f"https://api.clerk.com/v1/users/{row['user_id']}", + headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, + ) + if resp.status_code == 200: + clerk = resp.json() + first = clerk.get("first_name") or "" + last = clerk.get("last_name") or "" + row["name"] = f"{first} {last}".strip() or None + emails = clerk.get("email_addresses", []) + row["email"] = emails[0].get("email_address") if emails else None + except Exception: + pass + + return { + "grand_total_usd": round(grand_total, 4), + "users": user_rows, + } + + +# --------------------------------------------------------------------------- +# All projects (cross-user) +# --------------------------------------------------------------------------- + +async def _enrich_with_clerk_info( + items: list[dict], uid_key: str = "user_id", + name_key: str = "owner_name", email_key: str = "owner_email", +) -> None: + """Enrich a list of dicts with Clerk user info, deduplicating API calls.""" + if not settings.use_auth or not items: + return + cache: dict[str, dict] = {} + async with httpx.AsyncClient() as client: + for item in items: + uid = item[uid_key] + if uid not in cache: + try: + resp = await client.get( + f"https://api.clerk.com/v1/users/{uid}", + headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, + ) + if resp.status_code == 200: + clerk = resp.json() + first = clerk.get("first_name") or "" + last = clerk.get("last_name") or "" + emails = clerk.get("email_addresses", []) + cache[uid] = { + name_key: f"{first} {last}".strip() or None, + email_key: emails[0].get("email_address") if emails else None, + } + else: + cache[uid] = {name_key: None, email_key: None} + except Exception: + cache[uid] = {name_key: None, email_key: None} + item.update(cache[uid]) + + +@router.get("/projects") +async def list_all_projects(request: Request): + """List all projects across all users with metadata.""" + await _require_admin(request) + storage = get_storage(request) + + user_entries = storage.list_prefix("users/") + seen_uids: set[str] = set() + all_projects: list[dict] = [] + + for entry in user_entries: + parts = entry.split("/") + if len(parts) >= 2: + uid = parts[1] + if uid in seen_uids: + continue + seen_uids.add(uid) + projects = proj_svc.list_projects(storage, uid) + for p in projects: + all_projects.append({ + "id": p.id, + "name": p.name, + "user_id": p.user_id, + "status": p.status, + "created": p.created, + "updated": p.updated, + "has_bom": p.has_bom, + "has_netlist": p.has_netlist, + "datasheet_count": p.datasheet_count, + "total_cost_usd": p.total_cost_usd, + "pipeline_state": p.pipeline_state, + "summary": p.summary, + "owner_name": None, + "owner_email": None, + }) + + await _enrich_with_clerk_info(all_projects) + return all_projects + + +# --------------------------------------------------------------------------- +# Running pipelines +# --------------------------------------------------------------------------- + +@router.get("/runs") +async def list_running_pipelines(request: Request): + """List queued and running pipelines, plus drive the stale-running sweeper. + + Source of truth is ``project.json`` (``status`` ∈ {queued, running}); we + cross-check with the Cloud Run Job execution. Any project whose + execution is in a terminal Cloud Run state but whose status is still + queued/running is flipped to ``error`` here — this is the sweeper that + keeps zombie projects from showing "running" forever in the UI. + """ + from datetime import datetime, timezone + + from backend.services import job_runner + + await _require_admin(request) + storage = get_storage(request) + + now = datetime.now(timezone.utc) + runs: list[dict] = [] + seen_uids: set[str] = set() + + for entry in storage.list_prefix("users/"): + parts = entry.split("/") + if len(parts) < 2: + continue + uid = parts[1] + if uid in seen_uids: + continue + seen_uids.add(uid) + prefix = f"users/{uid}/projects/" + for proj_entry in storage.list_prefix(prefix): + meta_key = ( + proj_entry if proj_entry.endswith("/project.json") + else f"{proj_entry}/project.json" + ) + if not storage.exists(meta_key): + continue + try: + meta = proj_svc.ProjectMeta.model_validate(storage.read_json(meta_key)) + except Exception: + continue + if meta.status not in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING): + continue + + # Sweeper: if the execution is in a terminal Cloud Run state, + # the worker is already gone. Flip status → error so the UI + # stops lying. Skip the sweep when execution_name is missing + # (worker may still be enqueueing). + exec_state = "unknown" + if meta.execution_name: + exec_state = job_runner.get_execution_state(meta.execution_name) + if exec_state in ("succeeded", "failed", "cancelled"): + # Allow a short grace period so we don't race the worker + # writing its own terminal status. updated may be stale + # if the worker died before any status write. + try: + last_update = datetime.fromisoformat(meta.updated) + age = (now - last_update).total_seconds() + except Exception: + age = settings.pipeline_sweeper_stale_seconds + 1 + if age >= settings.pipeline_sweeper_stale_seconds: + proj_svc.mark_stale_running( + storage, uid, meta.id, + f"Worker terminated (execution state={exec_state}); please restart.", + ) + continue + + try: + started_at = datetime.fromisoformat(meta.updated) + except Exception: + started_at = now + runs.append({ + "project_id": meta.id, + "project_name": meta.name, + "user_id": uid, + "status": meta.status, + "execution_name": meta.execution_name, + "execution_state": exec_state, + "started_at": started_at.isoformat(), + "duration_seconds": int((now - started_at).total_seconds()), + "owner_name": None, + "owner_email": None, + }) + + await _enrich_with_clerk_info(runs) + return runs + + +# --------------------------------------------------------------------------- +# Global settings +# --------------------------------------------------------------------------- + +class UpdateMinVersionRequest(BaseModel): + min_model_version: str + + +@router.get("/settings") +async def get_settings(request: Request): + """Get global admin settings (model version threshold, etc.).""" + await _require_admin(request) + storage = get_storage(request) + data = settings_svc.get_admin_settings(storage) + data["default_model_version"] = settings.get_default_model_version() + return data + + +@router.put("/settings/min-model-version") +async def set_min_model_version(req: UpdateMinVersionRequest, request: Request): + """Set the minimum model version for library reuse.""" + await _require_admin(request) + storage = get_storage(request) + try: + settings_svc.set_min_model_version(storage, req.min_model_version) + except Exception as e: + raise HTTPException(400, f"Invalid version: {e}") + return {"min_model_version": req.min_model_version} + + +# --------------------------------------------------------------------------- +# Email test +# --------------------------------------------------------------------------- + +class TestEmailRequest(BaseModel): + to_email: str + + +@router.post("/test-email") +async def test_email(req: TestEmailRequest, request: Request): + """Send a test email to verify Gmail API setup. Admin only.""" + await _require_admin(request) + from backend.services.email import send_test_email + result = await send_test_email(req.to_email) + return result + + +# --------------------------------------------------------------------------- +# Project state overrides +# --------------------------------------------------------------------------- + +@router.post("/projects/{project_id}/mark-complete") +async def mark_project_complete(project_id: str, request: Request): + """Admin-only: force a paused project to ``complete`` status. + + Intended for projects stuck at ``paused_insufficient_credits`` that the + admin has decided to finalize rather than resume. Clears the pause + checkpoint/reason; does not touch credits, cost totals, or artifacts. + """ + from backend.routers.deps import resolve_or_404 + from backend.services import projects as proj_svc + + await _require_admin(request) + storage = get_storage(request) + owner_user_id, meta = await resolve_or_404(request, project_id) + + if meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED): + raise HTTPException(409, "Cannot mark a running pipeline complete; cancel it first") + if meta.status == "complete": + return {"status": "complete", "project_id": project_id} + + proj_svc.update_project( + storage, + owner_user_id, + project_id, + status="complete", + pause_checkpoint=None, + pause_reason=None, + ) + return {"status": "complete", "project_id": project_id} + + +# --------------------------------------------------------------------------- +# Report overrides +# --------------------------------------------------------------------------- + +@router.delete("/projects/{project_id}/findings/{finding_id}") +async def delete_finding(project_id: str, finding_id: str, request: Request): + """Admin-only: delete a single finding (rule violation) from a report. + + Rewrites ``report.json`` without the matching finding, recomputes summary + counts, and mirrors the summary onto ``ProjectMeta`` so dashboard totals + stay consistent. Returns 404 if the project, report, or finding is missing. + """ + from backend.routers.deps import resolve_or_404 + + await _require_admin(request) + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + + key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/report.json" + if not storage.exists(key): + raise HTTPException(404, "Report not found") + + report = storage.read_json(key) + findings = report.get("findings", []) or [] + remaining = [f for f in findings if f.get("finding_id") != finding_id] + if len(remaining) == len(findings): + raise HTTPException(404, f"Finding not found: {finding_id}") + + summary = {"total": len(remaining), "ERROR": 0, "WARNING": 0, "INFO": 0} + for f in remaining: + status = f.get("status") + if status in summary: + summary[status] += 1 + + report["findings"] = remaining + report["summary"] = summary + storage.write_json(key, report) + + proj_svc.update_project(storage, owner_user_id, project_id, summary=summary) + + return { + "deleted": finding_id, + "project_id": project_id, + "remaining": len(remaining), + "summary": summary, + } diff --git a/backend/routers/contact.py b/backend/routers/contact.py new file mode 100644 index 0000000..76b8407 --- /dev/null +++ b/backend/routers/contact.py @@ -0,0 +1,135 @@ +"""Public contact form endpoint — no authentication required.""" + +from __future__ import annotations + +import html +import logging +import time + +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from fastapi import APIRouter, Request +from pydantic import BaseModel, EmailStr, Field + +from backend.config import settings +from backend.services.email import _send_raw + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Simple in-memory rate limiting (per-instance, resets on deploy) +_recent: dict[str, float] = {} +_RATE_LIMIT_SECONDS = 60 + + +class ContactRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + email: EmailStr = Field(..., max_length=254) + message: str = Field(..., min_length=1, max_length=5000) + company: str = Field("", max_length=200) + subject: str = Field("", max_length=200) + honeypot: str = Field("", alias="_honey") + + +class ContactResponse(BaseModel): + success: bool + message: str + + +def _build_contact_message(data: ContactRequest) -> MIMEMultipart: + """Build the contact form email.""" + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = settings.contact_recipient + msg["Reply-To"] = data.email + msg["Subject"] = f"[Pinscope Contact] {data.subject or 'New message'} from {data.name}" + + # Plain text + lines = [ + f"Name: {data.name}", + f"Email: {data.email}", + ] + if data.company: + lines.append(f"Company: {data.company}") + if data.subject: + lines.append(f"Subject: {data.subject}") + lines += ["", data.message, "", "— Sent from the Pinscope contact form"] + msg.attach(MIMEText("\n".join(lines), "plain")) + + # HTML + name = html.escape(data.name) + email = html.escape(data.email) + company = html.escape(data.company) + subject = html.escape(data.subject) + message = html.escape(data.message) + + rows = f"""\ + + Name + {name} + + + Email + {email} + """ + if data.company: + rows += f"""\ + + Company + {company} + """ + if data.subject: + rows += f"""\ + + Subject + {subject} + """ + + html_body = f"""\ +
+

New contact form submission

+ + {rows} +
+
{message}
+

Sent from the Pinscope contact form

+
""" + msg.attach(MIMEText(html_body, "html")) + + return msg + + +@router.post("/contact", response_model=ContactResponse) +async def submit_contact(data: ContactRequest, request: Request): + # Honeypot check — bots fill hidden fields + if data.honeypot: + return ContactResponse(success=True, message="Message sent! We'll get back to you soon.") + + # Rate limiting by IP + ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host + now = time.time() + last = _recent.get(ip) + if last and now - last < _RATE_LIMIT_SECONDS: + return ContactResponse(success=False, message="Please wait a minute before submitting again.") + _recent[ip] = now + + # Clean up old entries + if len(_recent) > 1000: + cutoff = now - _RATE_LIMIT_SECONDS + for key in [k for k, v in _recent.items() if v < cutoff]: + del _recent[key] + + # Check email is configured + if not settings.use_email or not settings.contact_recipient: + logger.warning("Contact form submitted but email is not configured") + return ContactResponse( + success=False, + message="Email is not configured on this server.", + ) + + msg = _build_contact_message(data) + await _send_raw(settings.contact_recipient, msg, "Contact form") + + return ContactResponse(success=True, message="Message sent! We'll get back to you soon.") diff --git a/backend/routers/deps.py b/backend/routers/deps.py new file mode 100644 index 0000000..ddceef0 --- /dev/null +++ b/backend/routers/deps.py @@ -0,0 +1,37 @@ +"""Shared dependencies for FastAPI routers.""" + +from __future__ import annotations + +from fastapi import HTTPException, Request + +from backend.services import projects as proj_svc +from backend.services.storage import StorageBackend + + +def get_storage(request: Request) -> StorageBackend: + return request.app.state.storage + + +def get_user_id(request: Request) -> str: + return request.state.user_id + + +async def resolve_or_404(request: Request, project_id: str) -> tuple[str, proj_svc.ProjectMeta]: + """Resolve project access (owner, collaborator, or admin) or raise 404.""" + storage = get_storage(request) + user_id = get_user_id(request) + + # 1. Try normal access — cheap, no external API call + result = proj_svc.resolve_project_access(storage, user_id, project_id) + if result: + return result + + # 2. Admin fallback — Clerk API call only when normal access fails + from backend.routers.admin import is_admin + + if await is_admin(request): + result = proj_svc.find_project_any_user(storage, project_id) + if result: + return result + + raise HTTPException(404, "Project not found") diff --git a/backend/routers/feedback.py b/backend/routers/feedback.py new file mode 100644 index 0000000..28a3f03 --- /dev/null +++ b/backend/routers/feedback.py @@ -0,0 +1,303 @@ +"""User feedback / ticket system. + +Users can submit feedback tickets (bugs, rule reports, feature requests). +Tickets are stored as individual JSON files with JSONL indexes for fast listing. + +Storage layout: + admin/feedback/tickets/{ticket_id}.json + admin/feedback/index/by_user/{user_id}.jsonl + admin/feedback/index/by_project/{project_id}.jsonl + admin/feedback/index/all.jsonl +""" + +from __future__ import annotations + +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Literal + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from backend.routers.deps import get_storage, get_user_id +from backend.services.storage import StorageBackend + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# --------------------------------------------------------------------------- +# Storage key helpers +# --------------------------------------------------------------------------- + +_TICKETS_PREFIX = "admin/feedback/tickets/" +_INDEX_BY_USER = "admin/feedback/index/by_user/" +_INDEX_BY_PROJECT = "admin/feedback/index/by_project/" +_INDEX_ALL = "admin/feedback/index/all.jsonl" + + +def _ticket_key(ticket_id: str) -> str: + return f"{_TICKETS_PREFIX}{ticket_id}.json" + + +def _user_index_key(user_id: str) -> str: + return f"{_INDEX_BY_USER}{user_id}.jsonl" + + +def _project_index_key(project_id: str) -> str: + return f"{_INDEX_BY_PROJECT}{project_id}.jsonl" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +FeedbackType = Literal["bug", "rule_feedback", "feature_request"] +FeedbackStatus = Literal["open", "acknowledged", "resolved"] + + +class FeedbackTicket(BaseModel): + ticket_id: str + user_id: str + user_name: str | None = None + user_email: str | None = None + project_id: str | None = None + project_name: str | None = None + type: FeedbackType + status: FeedbackStatus = "open" + finding_id: str | None = None + finding_text: str | None = None + finding_designator: str | None = None + finding_mpn: str | None = None + finding_status: str | None = None + message: str + admin_notes: str | None = None + created_at: str + updated_at: str + + +class CreateFeedbackRequest(BaseModel): + type: FeedbackType + message: str = Field(..., min_length=1, max_length=5000) + project_id: str | None = None + project_name: str | None = None + user_name: str | None = None + user_email: str | None = None + finding_id: str | None = None + finding_text: str | None = None + finding_designator: str | None = None + finding_mpn: str | None = None + finding_status: str | None = None + + +class UpdateFeedbackRequest(BaseModel): + status: FeedbackStatus | None = None + admin_notes: str | None = None + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _append_index(storage: StorageBackend, key: str, entry: dict) -> None: + existing = "" + if storage.exists(key): + existing = storage.read_text(key) + line = json.dumps(entry) + "\n" + storage.write_text(key, existing + line) + + +def _read_index(storage: StorageBackend, key: str) -> list[dict]: + if not storage.exists(key): + return [] + text = storage.read_text(key) + entries: list[dict] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries + + +def _read_ticket(storage: StorageBackend, ticket_id: str) -> FeedbackTicket | None: + key = _ticket_key(ticket_id) + if not storage.exists(key): + return None + try: + data = storage.read_json(key) + return FeedbackTicket(**data) + except Exception: + logger.warning("Failed to read ticket %s", ticket_id) + return None + + +def _read_tickets_from_index( + storage: StorageBackend, + index_key: str, + *, + status: str | None = None, + ticket_type: str | None = None, + project_id: str | None = None, +) -> list[FeedbackTicket]: + index_entries = _read_index(storage, index_key) + tickets: list[FeedbackTicket] = [] + for entry in reversed(index_entries): + tid = entry.get("ticket_id") + if not tid: + continue + ticket = _read_ticket(storage, tid) + if not ticket: + continue + if status and ticket.status != status: + continue + if ticket_type and ticket.type != ticket_type: + continue + if project_id and ticket.project_id != project_id: + continue + tickets.append(ticket) + return tickets + + +# --------------------------------------------------------------------------- +# User endpoints +# --------------------------------------------------------------------------- + + +@router.post("/feedback", response_model=FeedbackTicket) +async def create_feedback(body: CreateFeedbackRequest, request: Request): + storage = get_storage(request) + user_id = get_user_id(request) + now = datetime.now(timezone.utc).isoformat() + ticket_id = uuid.uuid4().hex[:12] + + ticket = FeedbackTicket( + ticket_id=ticket_id, + user_id=user_id, + user_name=body.user_name, + user_email=body.user_email, + project_id=body.project_id, + project_name=body.project_name, + type=body.type, + status="open", + finding_id=body.finding_id, + finding_text=body.finding_text, + finding_designator=body.finding_designator, + finding_mpn=body.finding_mpn, + finding_status=body.finding_status, + message=body.message, + admin_notes=None, + created_at=now, + updated_at=now, + ) + + storage.write_json(_ticket_key(ticket_id), ticket.model_dump()) + + index_entry = {"ticket_id": ticket_id, "created_at": now} + _append_index(storage, _user_index_key(user_id), index_entry) + _append_index(storage, _INDEX_ALL, index_entry) + if body.project_id: + _append_index(storage, _project_index_key(body.project_id), index_entry) + + # Notify the admin inbox (fire-and-forget, identical pattern to + # pipeline-started). Errors are swallowed inside the email service. + try: + from backend.services.email import send_feedback_received_email + await send_feedback_received_email( + ticket_id=ticket_id, + user_id=user_id, + feedback_type=body.type, + message=body.message, + submitter_name=body.user_name, + submitter_email=body.user_email, + project_name=body.project_name, + project_id=body.project_id, + finding_designator=body.finding_designator, + finding_mpn=body.finding_mpn, + finding_status=body.finding_status, + finding_text=body.finding_text, + ) + except Exception: + logger.exception("Failed to enqueue feedback-received email for %s", ticket_id) + + return ticket + + +@router.get("/feedback", response_model=list[FeedbackTicket]) +async def list_my_feedback(request: Request, status: str | None = None): + storage = get_storage(request) + user_id = get_user_id(request) + return _read_tickets_from_index( + storage, _user_index_key(user_id), status=status, + ) + + +# --------------------------------------------------------------------------- +# Admin endpoints +# --------------------------------------------------------------------------- + + +@router.get("/admin/feedback", response_model=list[FeedbackTicket]) +async def list_all_feedback( + request: Request, + status: str | None = None, + type: str | None = None, + project_id: str | None = None, +): + from backend.routers.admin import _require_admin + + await _require_admin(request) + storage = get_storage(request) + return _read_tickets_from_index( + storage, _INDEX_ALL, status=status, ticket_type=type, project_id=project_id, + ) + + +@router.put("/admin/feedback/{ticket_id}", response_model=FeedbackTicket) +async def update_feedback(ticket_id: str, body: UpdateFeedbackRequest, request: Request): + from backend.routers.admin import _require_admin + + await _require_admin(request) + storage = get_storage(request) + + ticket = _read_ticket(storage, ticket_id) + if not ticket: + raise HTTPException(404, "Ticket not found") + + prev_admin_notes = (ticket.admin_notes or "").strip() + + if body.status is not None: + ticket.status = body.status + if body.admin_notes is not None: + ticket.admin_notes = body.admin_notes + ticket.updated_at = datetime.now(timezone.utc).isoformat() + + storage.write_json(_ticket_key(ticket_id), ticket.model_dump()) + + # If admin_notes changed to a new, non-empty value, notify the submitter. + new_admin_notes = (ticket.admin_notes or "").strip() + if new_admin_notes and new_admin_notes != prev_admin_notes: + try: + from backend.services.email import send_feedback_reply_email + await send_feedback_reply_email( + user_id=ticket.user_id, + reply_text=new_admin_notes, + original_message=ticket.message, + recipient_name=ticket.user_name, + recipient_email=ticket.user_email, + project_name=ticket.project_name, + finding_designator=ticket.finding_designator, + finding_mpn=ticket.finding_mpn, + ) + except Exception: + logger.exception( + "Failed to enqueue feedback-reply email for ticket %s", ticket_id + ) + + return ticket diff --git a/backend/routers/pipeline.py b/backend/routers/pipeline.py new file mode 100644 index 0000000..f7bffb1 --- /dev/null +++ b/backend/routers/pipeline.py @@ -0,0 +1,421 @@ +"""Pipeline start, SSE events, and status endpoints. + +Pipelines run in a Cloud Run Job worker (or, in local dev, a child +subprocess). The API only enqueues, transitions status with +``if-generation-match`` for idempotency, and tails the GCS-backed event +log for SSE. +""" + +from __future__ import annotations + +import asyncio +import json +import logging + +from fastapi import APIRouter, HTTPException, Request +from sse_starlette.sse import EventSourceResponse + +from pydantic import BaseModel + +from backend.routers.deps import get_storage, resolve_or_404 +from backend.services import event_bridge, job_runner +from backend.services import projects as proj_svc + +logger = logging.getLogger(__name__) + +VALID_REGEN_STAGES = {"derating"} + + +class RegenRequest(BaseModel): + stages: list[str] + + +router = APIRouter(tags=["pipeline"]) + + +# Statuses from which a fresh ``/start`` is allowed to transition into queued. +_START_OK_FROM = frozenset({ + proj_svc.STATUS_DRAFT, + proj_svc.STATUS_COMPLETE, + proj_svc.STATUS_ERROR, + proj_svc.STATUS_CANCELLED, +}) + + +def _project_active(meta: proj_svc.ProjectMeta) -> bool: + """A project is "active" if a worker is or could be running for it. + + Used as the running-guard. We trust the meta status as the primary + signal, and only fall back to the Cloud Run execution state when the + status is one we expect a worker to be touching. This deliberately + does NOT call get_execution_state on every request — it's an admin + API call. The stale-running sweeper is responsible for clearing + zombie ``running`` projects. + """ + return meta.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING) + + +@router.post("/pipeline/{project_id}/start", status_code=202) +async def start(project_id: str, request: Request): + from backend.routers.deps import get_user_id + from backend.services.billing_hook import get_billing + + storage = get_storage(request) + owner_user_id, meta = await resolve_or_404(request, project_id) + if not meta.has_bom or not meta.has_netlist: + raise HTTPException(400, "Upload BOM and netlist before starting pipeline") + + # Ensure the caller has at least their trial credits allocated. The + # pipeline itself enforces pause-on-empty — this just makes sure a + # brand-new user isn't blocked before their grant is issued. + get_billing().ensure_trial_grant(storage, get_user_id(request)) + + # Idempotent enqueue: only one ``draft|complete|error|cancelled`` -> + # ``queued`` transition can win. Concurrent /start clicks => 409. + from backend._version import PINSCOPE_VERSION + try: + proj_svc.transition_status( + storage, owner_user_id, project_id, + from_status=_START_OK_FROM, + to_status=proj_svc.STATUS_QUEUED, + cancel_requested=False, + execution_name=None, + pinscope_version=PINSCOPE_VERSION, + ) + except proj_svc.StatusConflict: + raise HTTPException(409, "Pipeline already running or queued") + + try: + execution_name = job_runner.enqueue_pipeline( + project_id, owner_user_id, resume=False, free=False, + ) + except Exception: + logger.exception("enqueue_pipeline failed for %s", project_id) + # Roll the meta back so the user can retry. + proj_svc.update_project( + storage, owner_user_id, project_id, + status=proj_svc.STATUS_ERROR, + pipeline_state={"error": "Failed to enqueue worker"}, + ) + raise HTTPException(503, "Failed to enqueue pipeline worker; please retry") + + proj_svc.update_project( + storage, owner_user_id, project_id, execution_name=execution_name, + ) + return {"status": "started", "project_id": project_id} + + +@router.post("/pipeline/{project_id}/cancel") +async def cancel(project_id: str, request: Request): + """Soft-cancel: set ``cancel_requested`` so the worker exits cleanly. + + The worker re-reads this flag inside ``_charge_for_logs`` after every + Claude API call (throttled). Cancellation latency is bounded by the + in-flight call's duration, typ 1–60s. + """ + storage = get_storage(request) + owner_user_id, meta = await resolve_or_404(request, project_id) + if not _project_active(meta): + raise HTTPException(409, f"Pipeline is not running (status={meta.status})") + proj_svc.request_cancel(storage, owner_user_id, project_id) + return {"status": "cancel_requested", "project_id": project_id} + + +@router.post("/pipeline/{project_id}/estimate") +async def estimate(project_id: str, request: Request): + """Pre-flight cost estimate — read-only, no side effects.""" + from backend.services.cost_estimator import estimate_pipeline_cost + + storage = get_storage(request) + owner_user_id, meta = await resolve_or_404(request, project_id) + if not meta.has_bom: + raise HTTPException(400, "Upload a BOM before requesting an estimate") + try: + est = estimate_pipeline_cost(storage, owner_user_id, project_id) + except FileNotFoundError as exc: + raise HTTPException(400, str(exc)) from exc + return est.model_dump() + + +@router.post("/pipeline/{project_id}/resume", status_code=202) +async def resume(project_id: str, request: Request): + """Resume a pipeline that was paused for insufficient credits.""" + storage = get_storage(request) + owner_user_id, meta = await resolve_or_404(request, project_id) + if meta.status != proj_svc.STATUS_PAUSED: + raise HTTPException( + 400, + f"Project is not paused (status={meta.status}); nothing to resume.", + ) + if not meta.has_bom or not meta.has_netlist: + raise HTTPException(400, "Project is missing BOM or netlist") + + try: + proj_svc.transition_status( + storage, owner_user_id, project_id, + from_status=proj_svc.STATUS_PAUSED, + to_status=proj_svc.STATUS_QUEUED, + cancel_requested=False, + ) + except proj_svc.StatusConflict: + raise HTTPException(409, "Project state changed; refresh and retry") + + try: + execution_name = job_runner.enqueue_pipeline( + project_id, owner_user_id, resume=True, free=False, + ) + except Exception: + logger.exception("enqueue_pipeline (resume) failed for %s", project_id) + proj_svc.update_project( + storage, owner_user_id, project_id, + status=proj_svc.STATUS_ERROR, + pipeline_state={"error": "Failed to enqueue worker"}, + ) + raise HTTPException(503, "Failed to enqueue pipeline worker; please retry") + + proj_svc.update_project( + storage, owner_user_id, project_id, execution_name=execution_name, + ) + return {"status": "resumed", "project_id": project_id} + + +@router.post("/pipeline/{project_id}/restart", status_code=202) +async def restart(project_id: str, request: Request): + """Admin-only: wipe per-project extractions and run the pipeline free.""" + from backend.routers.admin import _require_admin + + await _require_admin(request) + storage = get_storage(request) + owner_user_id, meta = await resolve_or_404(request, project_id) + if not meta.has_bom or not meta.has_netlist: + raise HTTPException(400, "Upload BOM and netlist before starting pipeline") + + # If something is currently running/queued, request cancel and wait + # briefly for the worker to honour it (or exit on its own). Hard-kill + # the execution as a last resort. + if _project_active(meta): + proj_svc.request_cancel(storage, owner_user_id, project_id) + await _await_terminal(storage, owner_user_id, project_id, timeout_s=10.0) + # If still active, hard-kill via Cloud Run cancel. + meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta + if _project_active(meta) and meta.execution_name: + job_runner.cancel_execution(meta.execution_name) + await _await_terminal(storage, owner_user_id, project_id, timeout_s=5.0) + + proj_svc.clear_project_extractions(storage, owner_user_id, project_id) + + # After clear_project_extractions the project is left in whatever + # status it was; the transition below enforces queued. + try: + proj_svc.transition_status( + storage, owner_user_id, project_id, + from_status=_START_OK_FROM | {proj_svc.STATUS_PAUSED}, + to_status=proj_svc.STATUS_QUEUED, + cancel_requested=False, + execution_name=None, + ) + except proj_svc.StatusConflict: + raise HTTPException(409, "Pipeline is busy; cancel first then retry") + + try: + execution_name = job_runner.enqueue_pipeline( + project_id, owner_user_id, resume=False, free=True, + ) + except Exception: + logger.exception("enqueue_pipeline (restart) failed for %s", project_id) + proj_svc.update_project( + storage, owner_user_id, project_id, + status=proj_svc.STATUS_ERROR, + pipeline_state={"error": "Failed to enqueue worker"}, + ) + raise HTTPException(503, "Failed to enqueue pipeline worker; please retry") + + proj_svc.update_project( + storage, owner_user_id, project_id, execution_name=execution_name, + ) + return {"status": "restarted", "project_id": project_id} + + +@router.post("/pipeline/{project_id}/regen", status_code=202) +async def regen(project_id: str, req: RegenRequest, request: Request): + """Rebuild graph and regenerate only the requested stages.""" + storage = get_storage(request) + owner_user_id, meta = await resolve_or_404(request, project_id) + if not meta.has_bom or not meta.has_netlist: + raise HTTPException(400, "Upload BOM and netlist before running regen") + invalid = set(req.stages) - VALID_REGEN_STAGES + if invalid: + raise HTTPException(400, f"Invalid regen stages: {sorted(invalid)}. Valid: {sorted(VALID_REGEN_STAGES)}") + if not req.stages: + raise HTTPException(400, "At least one stage is required") + + if _project_active(meta): + proj_svc.request_cancel(storage, owner_user_id, project_id) + await _await_terminal(storage, owner_user_id, project_id, timeout_s=10.0) + meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta + if _project_active(meta) and meta.execution_name: + job_runner.cancel_execution(meta.execution_name) + await _await_terminal(storage, owner_user_id, project_id, timeout_s=5.0) + + try: + proj_svc.transition_status( + storage, owner_user_id, project_id, + from_status=_START_OK_FROM | {proj_svc.STATUS_PAUSED}, + to_status=proj_svc.STATUS_QUEUED, + cancel_requested=False, + execution_name=None, + ) + except proj_svc.StatusConflict: + raise HTTPException(409, "Pipeline is busy; cancel first then retry") + + try: + execution_name = job_runner.enqueue_pipeline_regen( + project_id, owner_user_id, stages=req.stages, + ) + except Exception: + logger.exception("enqueue_pipeline_regen failed for %s", project_id) + proj_svc.update_project( + storage, owner_user_id, project_id, + status=proj_svc.STATUS_ERROR, + pipeline_state={"error": "Failed to enqueue worker"}, + ) + raise HTTPException(503, "Failed to enqueue pipeline worker; please retry") + + proj_svc.update_project( + storage, owner_user_id, project_id, execution_name=execution_name, + ) + return {"status": "regen_started", "project_id": project_id, "stages": req.stages} + + +# --------------------------------------------------------------------------- +# SSE events +# --------------------------------------------------------------------------- + + +_EXEC_TERMINAL = frozenset({"succeeded", "failed", "cancelled"}) + + +@router.get("/pipeline/{project_id}/events") +async def events(project_id: str, request: Request): + """SSE stream of pipeline progress events. + + Tails the GCS-backed event log written by the worker. Stops on + terminal events as today, but also has two hard-crash escape + hatches: the project's status reaching a terminal value, and the + Cloud Run execution reaching a terminal state. Either of those + triggers a synthetic ``pipeline_error`` so the SSE doesn't hang + forever when the worker dies without writing its terminal event. + """ + owner_user_id, meta = await resolve_or_404(request, project_id) + storage = get_storage(request) + + async def event_generator(): + execution_name = meta.execution_name + # Drive the GCS tail and the escape-hatch poll concurrently. The + # tail yields events; the escape hatch flips a flag. + crash_detected: dict[str, str | None] = {"reason": None} + + async def watch_status() -> None: + poll_interval = 2.0 + while True: + await asyncio.sleep(poll_interval) + try: + cur = proj_svc.get_project(storage, owner_user_id, project_id) + except Exception: + continue + if cur is None: + continue + if cur.status in proj_svc.TERMINAL_STATUSES: + crash_detected["reason"] = ( + f"project status={cur.status} (terminal)" + ) + return + # Cloud Run hard-crash detection + if execution_name: + try: + state = job_runner.get_execution_state(execution_name) + except Exception: + state = "unknown" + if state in _EXEC_TERMINAL: + crash_detected["reason"] = ( + f"execution state={state}" + ) + return + + watcher = asyncio.create_task(watch_status()) + try: + async for msg in event_bridge.tail_events( + storage, owner_user_id, project_id, + ): + if crash_detected["reason"] is not None: + break + yield { + "event": msg["event"], + "data": json.dumps(msg.get("data", {})), + } + if msg["event"] in event_bridge.TERMINAL_EVENTS: + return + + # tail_events exited without a terminal event — escape hatch + if crash_detected["reason"] is not None: + # Re-read the current meta so the synthetic event has + # the most up-to-date error information. + cur = proj_svc.get_project(storage, owner_user_id, project_id) + err = ( + (cur.pipeline_state or {}).get("error") + if cur and cur.pipeline_state + else crash_detected["reason"] + ) + yield { + "event": "pipeline_error", + "data": json.dumps({ + "error": err or "worker terminated without writing a terminal event", + "synthetic": True, + }), + } + finally: + watcher.cancel() + try: + await watcher + except (asyncio.CancelledError, Exception): + pass + + return EventSourceResponse(event_generator()) + + +@router.get("/pipeline/{project_id}/status") +async def status(project_id: str, request: Request): + """Polling fallback — returns current project state.""" + _, meta = await resolve_or_404(request, project_id) + return { + "status": meta.status, + "summary": meta.summary, + "pipeline_state": meta.pipeline_state, + "running": meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED), + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _await_terminal( + storage, user_id: str, project_id: str, *, timeout_s: float, +) -> None: + """Poll project status until it reaches a terminal state or the timeout + elapses. Used by /restart and /regen between cancel and re-enqueue. + """ + poll = 0.5 + elapsed = 0.0 + while elapsed < timeout_s: + try: + meta = proj_svc.get_project(storage, user_id, project_id) + except Exception: + meta = None + if meta is None: + return + if meta.status in proj_svc.TERMINAL_STATUSES: + return + await asyncio.sleep(poll) + elapsed += poll diff --git a/backend/routers/projects.py b/backend/routers/projects.py new file mode 100644 index 0000000..5d6ff56 --- /dev/null +++ b/backend/routers/projects.py @@ -0,0 +1,1053 @@ +"""Project CRUD and file upload endpoints.""" + +import httpx +from fastapi import APIRouter, 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.pinscopex.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"]) + + +# --- 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.pinscopex.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, + } + + +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): + _, meta = await resolve_or_404(request, project_id) + 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.pinscopex.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.pinscopex.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.pinscopex.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, 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] # 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)") + + # Auto-detect PADS vs EDIF from the file's first bytes — users don't pick + # a format, the wizard accepts either. + from backend.pinscopex.parsers import ( + detect_netlist_format, parse_netlist_any, validate_netlist, + ) + from backend.pinscopex.parsers_edif import list_edif_subdesigns + import tempfile, os + + fmt = detect_netlist_format(data) + suffix = ".edn" if fmt == "edif" else ".asc" + sub_designs: list[dict] = [] + try: + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + tmp.write(data) + tmp.close() + parts, nets, _ = parse_netlist_any(tmp.name) + # For EDIF, also surface the sub-design layout so the wizard can + # decide whether to prompt the user. Cheap second parse — same file. + if fmt == "edif": + sub_designs = list_edif_subdesigns(tmp.name) + os.unlink(tmp.name) + except Exception as e: + raise HTTPException(400, f"Invalid netlist: {e}") + issues = validate_netlist(parts, nets) + if issues: + raise HTTPException(400, f"Netlist failed sanity check: {'; '.join(issues)}") + key = proj_svc.save_netlist(storage, user_id, project_id, data, fmt=fmt) + # EDIF: emit a designator→pins preview matching the PADS browser-side + # shape, so the wizard's power-sources step can render its dropdowns + # without re-parsing the (s-expression-heavy) file in the browser. + designator_pins: list[dict] = [] + if fmt == "edif": + 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, + } + + +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.pinscopex.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: + async with httpx.AsyncClient() as client: + for uid in all_user_ids: + entry: dict = {"user_id": uid, "name": None, "email": None, "image_url": None, + "role": "owner" if uid == owner_user_id else "collaborator"} + try: + resp = await client.get( + f"https://api.clerk.com/v1/users/{uid}", + headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, + ) + if resp.status_code == 200: + clerk = resp.json() + first = clerk.get("first_name") or "" + last = clerk.get("last_name") or "" + entry["name"] = f"{first} {last}".strip() or None + emails = clerk.get("email_addresses", []) + if emails: + entry["email"] = emails[0].get("email_address") + entry["image_url"] = clerk.get("image_url") + except Exception: + pass + collaborators.append(entry) + 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") + + # Look up user by email via Clerk Backend API + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://api.clerk.com/v1/users", + params={"email_address": [req.email]}, + headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, + ) + if resp.status_code != 200: + raise HTTPException(502, "Failed to look up user") + + users = resp.json() + if not users: + raise HTTPException(404, "No user found with that email") + + clerk_user = users[0] + collab_user_id = clerk_user.get("id") + 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) + + # Return the collaborator info + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + emails = clerk_user.get("email_addresses", []) + return { + "user_id": collab_user_id, + "name": f"{first} {last}".strip() or None, + "email": emails[0].get("email_address") if emails else None, + "image_url": clerk_user.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} + + +# --- DigiKey auto-fetch --- + + +@router.get("/digikey/datasheet") +async def fetch_digikey_datasheet(mpn: str, request: Request): + """Fetch a datasheet PDF from DigiKey for the given MPN. + + Returns the PDF bytes on success, or a JSON error on failure. + """ + from backend.services.digikey import fetch_datasheet + + result = await fetch_datasheet(mpn) + if not result.ok: + # 404, not 502: "DigiKey has no exact match" / "the manufacturer CDN + # blocked the download" is an expected per-MPN miss the wizard handles + # (it shows a "fetch failed — upload manually" row), not a broken + # gateway. 502 made a board full of exotic parts read as a server + # meltdown in the browser console. + return JSONResponse( + status_code=404, + content={"detail": result.error or "Failed to fetch datasheet", "url": result.url}, + ) + headers = {"Content-Disposition": f'attachment; filename="{mpn}.pdf"'} + if result.url: + headers["X-Datasheet-Url"] = result.url + 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 auto_resolve_specs + + if not settings.use_digikey: + raise HTTPException(400, "DigiKey API not configured") + if not settings.anthropic_api_key: + raise HTTPException(400, "Anthropic API key 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 to taxonomy via Haiku + 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, + ) + + # 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", + ) + + # Download taxonomy to a temp dir so auto_resolve_specs can read/write it. + # Mirrors the PipelineWorkspace pattern: pinscopex 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, + } diff --git a/backend/routers/reports.py b/backend/routers/reports.py new file mode 100644 index 0000000..7d4efdb --- /dev/null +++ b/backend/routers/reports.py @@ -0,0 +1,245 @@ +"""Report, graph, datasheet, and API log serving endpoints.""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from backend.pinscopex.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=["reports"]) + +# Allow alphanumeric, dash, underscore, dot, colon, forward-slash, plus, hash, space +_SAFE_MPN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.:/ +#,()]*$") + + +def _validate_mpn(mpn: str) -> None: + """Reject MPN values that could cause path traversal.""" + if not _SAFE_MPN.match(mpn) or ".." in mpn: + raise HTTPException(400, "Invalid MPN format") + + +@router.get("/report/{project_id}") +async def get_report(project_id: str, request: Request): + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + prefix = proj_svc.project_prefix(owner_user_id, project_id) + key = f"{prefix}/report.json" + if not storage.exists(key): + raise HTTPException(404, "Report not found — run the pipeline first") + return JSONResponse(storage.read_json(key)) + + +class AddCommentBody(BaseModel): + finding_id: str + text: str + user_name: str + mentions: list[str] = [] + + +@router.post("/report/{project_id}/comments") +async def add_comment(project_id: str, body: AddCommentBody, request: Request): + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + user_id = get_user_id(request) + prefix = proj_svc.project_prefix(owner_user_id, project_id) + key = f"{prefix}/report.json" + if not storage.exists(key): + raise HTTPException(404, "Report not found") + report_data = storage.read_json(key) + comment = { + "comment_id": str(uuid.uuid4()), + "finding_id": body.finding_id, + "user_id": user_id, + "user_name": body.user_name, + "text": body.text, + "mentions": body.mentions, + "created_at": datetime.now(timezone.utc).isoformat(), + } + comments = report_data.setdefault("comments", {}) + comments.setdefault(body.finding_id, []).append(comment) + storage.write_json(key, report_data) + return JSONResponse(comment, status_code=201) + + +@router.delete("/report/{project_id}/comments/{comment_id}") +async def delete_comment(project_id: str, comment_id: str, request: Request): + storage = get_storage(request) + owner_user_id, meta = await resolve_or_404(request, project_id) + user_id = get_user_id(request) + prefix = proj_svc.project_prefix(owner_user_id, project_id) + key = f"{prefix}/report.json" + if not storage.exists(key): + raise HTTPException(404, "Report not found") + report_data = storage.read_json(key) + comments = report_data.get("comments", {}) + for finding_id, comment_list in comments.items(): + for i, c in enumerate(comment_list): + if c["comment_id"] == comment_id: + if c["user_id"] != user_id and user_id != owner_user_id: + raise HTTPException(403, "Cannot delete another user's comment") + comment_list.pop(i) + if not comment_list: + del comments[finding_id] + storage.write_json(key, report_data) + return JSONResponse({"ok": True}) + raise HTTPException(404, "Comment not found") + + +@router.get("/bom/{project_id}") +async def get_bom_summary(project_id: str, request: Request): + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + prefix = proj_svc.project_prefix(owner_user_id, project_id) + key = f"{prefix}/bom_summary.json" + if not storage.exists(key): + raise HTTPException(404, "BOM summary not found — run the pipeline first") + return JSONResponse(storage.read_json(key)) + + +@router.get("/derating/{project_id}") +async def get_derating(project_id: str, request: Request): + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + prefix = proj_svc.project_prefix(owner_user_id, project_id) + key = f"{prefix}/derating.json" + if not storage.exists(key): + raise HTTPException(404, "Derating data not found — run the pipeline first") + return JSONResponse(storage.read_json(key)) + + +@router.get("/graph/{project_id}") +async def get_graph(project_id: str, request: Request): + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + prefix = proj_svc.project_prefix(owner_user_id, project_id) + key = f"{prefix}/design_graph.json" + if not storage.exists(key): + raise HTTPException(404, "Design graph not found — run the pipeline first") + return JSONResponse(storage.read_json(key)) + + +@router.get("/projects/{project_id}/logs") +async def get_project_logs(project_id: str, request: Request): + """Return API call logs for a project pipeline run.""" + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + prefix = proj_svc.project_prefix(owner_user_id, project_id) + key = f"{prefix}/api_logs.jsonl" + if not storage.exists(key): + return JSONResponse([]) + text = storage.read_text(key) + entries = [json.loads(line) for line in text.strip().split("\n") if line.strip()] + return JSONResponse(entries) + + +def _find_datasheet_key( + storage, owner_user_id: str, project_id: str, safe: str, + mpn: str | None = None, +) -> str | None: + """Return the storage key for a datasheet PDF, or None.""" + from backend.services.datasheet_store import resolve_datasheet + + # 1. Project uploads + key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/uploads/datasheets/{safe}.pdf" + if storage.exists(key): + return key + # 2. Content-addressed ref lookup + resolved = resolve_datasheet(storage, safe) + if resolved: + return resolved + # 3. Legacy flat file fallback (remove after migration confirmed) + key = f"library/datasheets/{safe}.pdf" + if storage.exists(key): + return key + # 4. Pattern-based fallback (passives with shared datasheets) + if mpn: + return proj_svc.library_has_datasheet(storage, mpn) + return None + + +@router.get("/projects/{project_id}/datasheet-url/{mpn:path}") +async def get_datasheet_url(project_id: str, mpn: str, request: Request): + """Return a URL for accessing a datasheet PDF. + + Returns a backend proxy URL that streams the PDF through Cloud Run. + This avoids GCS signed-URL issues (IAM signBlob scope problems) and + works identically for local and cloud storage. + """ + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + _validate_mpn(mpn) + safe = safe_mpn(mpn) + + key = _find_datasheet_key(storage, owner_user_id, project_id, safe, mpn=mpn) + if key is None: + raise HTTPException(404, f"Datasheet not found for MPN: {mpn}") + + # Return a proxy URL that points back to this backend + proxy_path = f"/api/projects/{project_id}/datasheet/{mpn}" + base = str(request.base_url).rstrip("/") + return {"url": f"{base}{proxy_path}"} + + +@router.get("/projects/{project_id}/datasheet/{mpn:path}") +async def get_datasheet_proxy(project_id: str, mpn: str, request: Request): + """Stream a datasheet PDF from storage (GCS or local). + + This is the proxy endpoint returned by get_datasheet_url. + """ + from fastapi.responses import Response + + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + _validate_mpn(mpn) + safe = safe_mpn(mpn) + + key = _find_datasheet_key(storage, owner_user_id, project_id, safe, mpn=mpn) + if key is None: + raise HTTPException(404, f"Datasheet not found for MPN: {mpn}") + + data = storage.read_bytes(key) + return Response( + content=data, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{safe}.pdf"'}, + ) + + +@router.get("/datasheets/{mpn}") +async def get_datasheet(mpn: str, request: Request): + """Serve a datasheet PDF (legacy local-dev endpoint).""" + from fastapi.responses import FileResponse + + from backend.services.storage import LocalStorageBackend + + storage = get_storage(request) + user_id = get_user_id(request) + _validate_mpn(mpn) + safe = safe_mpn(mpn) + + if not isinstance(storage, LocalStorageBackend): + raise HTTPException( + 400, + "Use GET /projects/{project_id}/datasheet-url/{mpn} for cloud storage", + ) + + user_prefix = f"users/{user_id}/projects/" + for entry in storage.list_prefix(user_prefix): + pdf_key = f"{entry}/uploads/datasheets/{safe}.pdf" + if storage.exists(pdf_key): + return FileResponse( + storage._path(pdf_key), + media_type="application/pdf", + filename=f"{safe}.pdf", + ) + + raise HTTPException(404, f"Datasheet not found for MPN: {mpn}") diff --git a/backend/routers/survey.py b/backend/routers/survey.py new file mode 100644 index 0000000..d5d7136 --- /dev/null +++ b/backend/routers/survey.py @@ -0,0 +1,69 @@ +"""Onboarding survey endpoints.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request +from pydantic import BaseModel + +from backend.config import settings +from backend.routers.deps import get_storage, get_user_id +from backend.services import survey as survey_svc + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/survey", tags=["survey"]) + + +class SurveySubmission(BaseModel): + referral_source: str + user_profile: str + + +@router.get("/status") +async def survey_status(request: Request): + storage = get_storage(request) + user_id = get_user_id(request) + return {"completed": survey_svc.is_completed(storage, user_id)} + + +@router.post("") +async def submit_survey(request: Request, body: SurveySubmission): + storage = get_storage(request) + user_id = get_user_id(request) + + if survey_svc.is_completed(storage, user_id): + return {"ok": True, "detail": "already_submitted"} + + # Resolve user email/name from Clerk if available + email = "unknown" + name = "unknown" + if settings.use_auth: + try: + from backend.services.email import _resolve_clerk_user + + clerk_user = await _resolve_clerk_user(user_id) + if clerk_user: + emails = clerk_user.get("email_addresses", []) + email = emails[0].get("email_address", "unknown") if emails else "unknown" + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + name = f"{first} {last}".strip() or "unknown" + except Exception: + logger.warning("Failed to resolve Clerk user %s for survey", user_id) + + sheet_ok = await survey_svc.append_to_sheet( + user_id=user_id, + email=email, + name=name, + referral_source=body.referral_source, + user_profile=body.user_profile, + ) + + if sheet_ok or not settings.survey_sheet_id: + survey_svc._mark_completed(storage, user_id) + return {"ok": True} + + # Sheet write failed — don't mark completed so the user can retry + return {"ok": False, "detail": "sheet_write_failed"} diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/admin_settings.py b/backend/services/admin_settings.py new file mode 100644 index 0000000..fbd4d27 --- /dev/null +++ b/backend/services/admin_settings.py @@ -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 diff --git a/backend/services/api_logs.py b/backend/services/api_logs.py new file mode 100644 index 0000000..22b3649 --- /dev/null +++ b/backend/services/api_logs.py @@ -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) diff --git a/backend/services/billing_hook.py b/backend/services/billing_hook.py new file mode 100644 index 0000000..b626079 --- /dev/null +++ b/backend/services/billing_hook.py @@ -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 diff --git a/backend/services/cost_estimator.py b/backend/services/cost_estimator.py new file mode 100644 index 0000000..6a99f24 --- /dev/null +++ b/backend/services/cost_estimator.py @@ -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 diff --git a/backend/services/datasheet_store.py b/backend/services/datasheet_store.py new file mode 100644 index 0000000..0f9355d --- /dev/null +++ b/backend/services/datasheet_store.py @@ -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 diff --git a/backend/services/dedupe_findings.py b/backend/services/dedupe_findings.py new file mode 100644 index 0000000..da197f5 --- /dev/null +++ b/backend/services/dedupe_findings.py @@ -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: " + "'." + ), + }, + }, + "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 diff --git a/backend/services/digikey.py b/backend/services/digikey.py new file mode 100644 index 0000000..fca52ae --- /dev/null +++ b/backend/services/digikey.py @@ -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) diff --git a/backend/services/email.py b/backend/services/email.py new file mode 100644 index 0000000..859cc1c --- /dev/null +++ b/backend/services/email.py @@ -0,0 +1,1265 @@ +"""Email notification service using Gmail API with domain-wide delegation.""" + +from __future__ import annotations + +import asyncio +import base64 +import logging +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import httpx + +from backend.config import settings + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Clerk user resolution +# --------------------------------------------------------------------------- + + +async def _resolve_clerk_user(user_id: str) -> dict | None: + """Fetch user profile from Clerk Backend API. Returns None on failure.""" + if not settings.use_auth: + return None + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + f"https://api.clerk.com/v1/users/{user_id}", + headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, + ) + if resp.status_code == 200: + return resp.json() + except Exception: + logger.warning("Failed to fetch Clerk user %s for email notification", user_id) + return None + + +# --------------------------------------------------------------------------- +# Gmail API +# --------------------------------------------------------------------------- + + +def _build_gmail_service(): + """Build an authenticated Gmail API service using domain-wide delegation. + + On Cloud Run, google.auth.default() returns compute engine credentials + which don't support .with_subject() for domain-wide delegation. We use + the IAM signBlob API to create proper service account credentials that + can impersonate the sender via domain-wide delegation. + + Returns None if credentials cannot be built. + """ + try: + import google.auth + import google.auth.transport.requests + from google.auth import iam + from google.oauth2 import service_account + from googleapiclient.discovery import build + except ImportError: + logger.warning("google-api-python-client not installed; email disabled") + return None + + scopes = ["https://www.googleapis.com/auth/gmail.send"] + + try: + source_credentials, _ = google.auth.default() + logger.debug("Gmail: got default credentials type=%s", type(source_credentials).__name__) + + # Check if these credentials already support with_subject (e.g. key-file) + if hasattr(source_credentials, "_signer"): + logger.debug("Gmail: using service account key-file path (with_subject)") + delegated = source_credentials.with_subject(settings.email_sender) + return build("gmail", "v1", credentials=delegated, cache_discovery=False) + + # Cloud Run path: use IAM signBlob to create credentials that support + # the `subject` claim needed for domain-wide delegation. + logger.debug("Gmail: using IAM signBlob path (Cloud Run / Compute Engine)") + request = google.auth.transport.requests.Request() + source_credentials.refresh(request) + sa_email = source_credentials.service_account_email + logger.debug("Gmail: resolved service account email=%s", sa_email) + + signer = iam.Signer( + request=request, + credentials=source_credentials, + service_account_email=sa_email, + ) + + credentials = service_account.Credentials( + signer=signer, + service_account_email=sa_email, + token_uri="https://oauth2.googleapis.com/token", + scopes=scopes, + subject=settings.email_sender, + ) + + svc = build("gmail", "v1", credentials=credentials, cache_discovery=False) + logger.debug("Gmail: service built successfully, sender=%s", settings.email_sender) + return svc + + except Exception: + logger.warning("Could not obtain credentials for Gmail API", exc_info=True) + return None + + +# --------------------------------------------------------------------------- +# HTML email template +# --------------------------------------------------------------------------- + +_STATUS_COLORS = { + "ERROR": "#ef4444", + "WARNING": "#f59e0b", + "INFO": "#3b82f6", +} + + +def _render_report_email( + recipient_name: str, + project_name: str, + project_id: str, + summary: dict[str, int], + total_cost_usd: float | None, +) -> str: + """Render the HTML email body with inline CSS.""" + report_url = f"{settings.email_frontend_url}/project/{project_id}/report" + + total = summary.get("total", 0) + errors = summary.get("ERROR", 0) + warnings = summary.get("WARNING", 0) + infos = summary.get("INFO", 0) + + # Summary rows + summary_rows = "" + for label, count, color in [ + ("Errors", errors, _STATUS_COLORS["ERROR"]), + ("Warnings", warnings, _STATUS_COLORS["WARNING"]), + ("Info", infos, _STATUS_COLORS["INFO"]), + ]: + if count > 0: + summary_rows += f""" + + + + {count} {label} + + """ + + # Headline color based on worst finding + if errors > 0: + headline_color = _STATUS_COLORS["ERROR"] + headline_text = f"{errors} error{'s' if errors != 1 else ''} found" + elif warnings > 0: + headline_color = _STATUS_COLORS["WARNING"] + headline_text = f"{warnings} warning{'s' if warnings != 1 else ''} found" + elif total == 0: + headline_color = "#10b981" + headline_text = "No issues found" + else: + headline_color = _STATUS_COLORS["INFO"] + headline_text = f"{infos} note{'s' if infos != 1 else ''}" + + return f"""\ + + + + + + +
+ + + + + + + + + + + +
+ + + + + +
+ Pinscope + + Report Ready +
+
+ + + + + + + + + + + + + + + +
+ Hi {recipient_name}, +
+ Your validation report for {project_name} is ready. +
+ + +
+ {headline_text} +
+
+ + +
+ + + + +
+ Validation Summary +
+ {total} findings +
+ + {summary_rows} +
+
+
+
+ + + + View Report → + + +
+
+ + +
+ Pinscope · Agentic schematic validation +
+
+
+ +""" + + +# --------------------------------------------------------------------------- +# Pipeline-started email template (admin notification) +# --------------------------------------------------------------------------- + + +def _render_pipeline_started_email( + creator_name: str, + creator_email: str, + project_name: str, + project_id: str, + num_components: int, + num_nets: int, + num_ics: int, + num_passives: int, + num_simple: int, +) -> str: + """Render the pipeline-started HTML email for admin notification.""" + project_url = f"{settings.email_frontend_url}/project/{project_id}" + + return f"""\ + + + + + + +
+ + + + + + + + + + + +
+ + + + + +
+ Pinscope + + Pipeline Started +
+
+ + + + + + + + + + + + + + +
+ A new pipeline has been triggered for {project_name}. +
+ + +
+ + + + +
+ Created by +
+ {creator_name} +
+ {creator_email} +
+
+
+ + +
+ + + + + +
+ Design Overview +
+ + + + + + +
+ {num_components} + Components + + {num_nets} + Nets +
+
+ + + + + + +
+ + {num_ics} IC{"s" if num_ics != 1 else ""} + + + {num_passives} Passive{"s" if num_passives != 1 else ""} + + + {num_simple} Discrete +
+
+
+
+ + + + View Project → + + +
+
+ + +
+ Pinscope · Agentic schematic validation +
+
+
+ +""" + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def _encode_message(msg: MIMEMultipart) -> dict: + """Encode a MIME message as a Gmail API payload.""" + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii") + return {"raw": raw} + + +async def _send_raw(to_email: str, msg: MIMEMultipart, label: str) -> None: + """Send a MIME message via Gmail API. Logs but never raises.""" + try: + service = _build_gmail_service() + if not service: + logger.warning("Gmail service unavailable; skipping %s", label) + return + await asyncio.to_thread( + service.users().messages().send( + userId="me", body=_encode_message(msg) + ).execute + ) + logger.info("%s sent to %s", label, to_email) + except Exception: + logger.exception("Failed to send %s to %s", label, to_email) + + +def _build_report_message( + to_email: str, + recipient_name: str, + project_name: str, + project_id: str, + summary: dict[str, int], + total_cost_usd: float | None, +) -> MIMEMultipart: + """Build the report-ready email message.""" + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = to_email + msg["Subject"] = f"Report ready: {project_name}" + + # Plain text fallback + report_url = f"{settings.email_frontend_url}/project/{project_id}/report" + total = summary.get("total", 0) + errors = summary.get("ERROR", 0) + warnings = summary.get("WARNING", 0) + infos = summary.get("INFO", 0) + text_body = ( + f"Hi {recipient_name},\n\n" + f"Your Pinscope validation report for \"{project_name}\" is ready.\n\n" + f"Summary: {total} findings — {errors} errors, {warnings} warnings, {infos} info\n\n" + f"View the report: {report_url}\n" + ) + msg.attach(MIMEText(text_body, "plain")) + + html_body = _render_report_email( + recipient_name, project_name, project_id, + summary, total_cost_usd, + ) + msg.attach(MIMEText(html_body, "html")) + return msg + + +def _build_paused_message( + to_email: str, + recipient_name: str, + project_name: str, + project_id: str, + last_completed: str | None, + stage: str | None, + balance: float, + credits_needed_low: float, +) -> MIMEMultipart: + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = to_email + msg["Subject"] = f"Paused: {project_name} is waiting for credits" + + project_url = f"{settings.email_frontend_url}/project/{project_id}" + last_line = f"Last completed: {last_completed}." if last_completed else "" + stage_line = f"Paused during: {stage}." if stage else "" + + text_body = ( + f"Hi {recipient_name},\n\n" + f"Your Pinscope run for \"{project_name}\" paused because you're low on credits.\n\n" + f"{last_line}\n{stage_line}\n\n" + f"Current balance: {balance:.2f} credits\n" + f"Credits needed to finish (est): {credits_needed_low:.2f}+\n\n" + f"Top up and resume here: {project_url}\n" + ) + msg.attach(MIMEText(text_body, "plain")) + return msg + + +def _build_topup_failed_message( + to_email: str, recipient_name: str, + amount_usd: float, reason: str, +) -> MIMEMultipart: + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = to_email + msg["Subject"] = "Pinscope: auto top-up failed" + manage_url = f"{settings.email_frontend_url}/credits" + text_body = ( + f"Hi {recipient_name},\n\n" + f"We tried to auto top-up your Pinscope balance with " + f"${amount_usd:.2f} but the charge failed.\n\n" + f"Reason: {reason}\n\n" + f"Auto top-up has been disabled until you update your payment method. " + f"Update your card here: {manage_url}\n" + ) + msg.attach(MIMEText(text_body, "plain")) + return msg + + +async def send_topup_failed_email( + user_id: str, *, amount_usd: float, reason: str, +) -> None: + if not settings.use_email: + return + clerk_user = await _resolve_clerk_user(user_id) + if not clerk_user: + return + emails = clerk_user.get("email_addresses", []) + to_email = emails[0].get("email_address") if emails else None + if not to_email: + return + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + name = f"{first} {last}".strip() or "there" + msg = _build_topup_failed_message(to_email, name, amount_usd, reason) + await _send_raw(to_email, msg, "Top-up-failed email") + + +def _build_low_balance_message( + to_email: str, recipient_name: str, balance: float, threshold: float, +) -> MIMEMultipart: + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = to_email + msg["Subject"] = "Pinscope: low credit balance" + credits_url = f"{settings.email_frontend_url}/credits" + text_body = ( + f"Hi {recipient_name},\n\n" + f"Your Pinscope credit balance has dropped to " + f"{balance:.2f} credits (below your threshold of {threshold:.2f}).\n\n" + f"Top up here so your pipelines don't pause mid-run: {credits_url}\n" + ) + msg.attach(MIMEText(text_body, "plain")) + return msg + + +async def send_low_balance_email( + user_id: str, *, balance: float, threshold: float, +) -> None: + if not settings.use_email: + return + clerk_user = await _resolve_clerk_user(user_id) + if not clerk_user: + return + emails = clerk_user.get("email_addresses", []) + to_email = emails[0].get("email_address") if emails else None + if not to_email: + return + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + name = f"{first} {last}".strip() or "there" + msg = _build_low_balance_message(to_email, name, balance, threshold) + await _send_raw(to_email, msg, "Low-balance email") + + +async def send_pipeline_paused_email( + user_id: str, + project_name: str, + project_id: str, + *, + last_completed: str | None, + stage: str | None, + balance: float, + credits_needed_low: float, +) -> None: + """Send a 'pipeline paused, awaiting credits' email. Fire-and-forget.""" + if not settings.use_email: + return + clerk_user = await _resolve_clerk_user(user_id) + if not clerk_user: + return + emails = clerk_user.get("email_addresses", []) + to_email = emails[0].get("email_address") if emails else None + if not to_email: + return + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + recipient_name = f"{first} {last}".strip() or "there" + + msg = _build_paused_message( + to_email, recipient_name, project_name, project_id, + last_completed, stage, balance, credits_needed_low, + ) + await _send_raw(to_email, msg, "Pipeline-paused email") + + +async def send_report_ready_email( + user_id: str, + project_name: str, + project_id: str, + summary: dict[str, int], + total_cost_usd: float | None = None, +) -> None: + """Send a 'report ready' email to the project creator. Fire-and-forget.""" + if not settings.use_email: + return + + clerk_user = await _resolve_clerk_user(user_id) + if not clerk_user: + logger.warning("Cannot send report email: Clerk user %s not found", user_id) + return + + emails = clerk_user.get("email_addresses", []) + to_email = emails[0].get("email_address") if emails else None + if not to_email: + logger.warning("Cannot send report email: no email for Clerk user %s", user_id) + return + + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + recipient_name = f"{first} {last}".strip() or "there" + + msg = _build_report_message( + to_email, recipient_name, project_name, project_id, + summary, total_cost_usd, + ) + await _send_raw(to_email, msg, "Report-ready email") + + +async def send_test_email(to_email: str) -> dict: + """Send a test email directly to the given address. Returns a status dict.""" + result: dict = {"ok": False, "step": "", "error": ""} + + if not settings.use_email: + result["step"] = "config" + result["error"] = f"use_email=False (email_sender={settings.email_sender!r}, email_frontend_url={settings.email_frontend_url!r})" + return result + + result["step"] = "build_service" + try: + import google.auth + import google.auth.transport.requests + from google.auth import iam + from google.oauth2 import service_account + from googleapiclient.discovery import build + except ImportError as e: + result["error"] = f"Import failed: {e}" + return result + + scopes = ["https://www.googleapis.com/auth/gmail.send"] + try: + source_credentials, _ = google.auth.default() + cred_type = type(source_credentials).__name__ + + if hasattr(source_credentials, "_signer"): + delegated = source_credentials.with_subject(settings.email_sender) + service = build("gmail", "v1", credentials=delegated, cache_discovery=False) + else: + req = google.auth.transport.requests.Request() + source_credentials.refresh(req) + sa_email = source_credentials.service_account_email + signer = iam.Signer(request=req, credentials=source_credentials, service_account_email=sa_email) + credentials = service_account.Credentials( + signer=signer, + service_account_email=sa_email, + token_uri="https://oauth2.googleapis.com/token", + scopes=scopes, + subject=settings.email_sender, + ) + service = build("gmail", "v1", credentials=credentials, cache_discovery=False) + cred_type = f"{cred_type} → IAM signer sa={sa_email}" + + result["step"] = "send" + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = to_email + msg["Subject"] = "Pinscope email test" + msg.attach(MIMEText(f"Test email from Pinscope. Sender: {settings.email_sender}. Creds: {cred_type}", "plain")) + + import asyncio as _asyncio + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii") + await _asyncio.to_thread( + service.users().messages().send(userId="me", body={"raw": raw}).execute + ) + result["ok"] = True + result["step"] = "sent" + result["error"] = "" + logger.info("Test email sent to %s via %s", to_email, cred_type) + except Exception as exc: + result["error"] = str(exc) + logger.exception("Test email failed at step=%s", result["step"]) + + return result + + +async def send_pipeline_started_email( + user_id: str, + project_name: str, + project_id: str, + num_components: int, + num_nets: int, + num_ics: int, + num_passives: int, + num_simple: int, +) -> None: + """Send a 'pipeline started' email to the admin. Fire-and-forget.""" + if not settings.use_email or not settings.email_admin_notify: + return + + # Resolve creator info from Clerk + creator_name = "Unknown" + creator_email = "unknown" + clerk_user = await _resolve_clerk_user(user_id) + if clerk_user: + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + creator_name = f"{first} {last}".strip() or "Unknown" + emails = clerk_user.get("email_addresses", []) + creator_email = emails[0].get("email_address", "unknown") if emails else "unknown" + + to_email = settings.email_admin_notify + + # Build message + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = to_email + msg["Subject"] = f"Pipeline started: {project_name} ({num_components} components)" + + text_body = ( + f"Pipeline started for \"{project_name}\"\n\n" + f"Created by: {creator_name} ({creator_email})\n" + f"Components: {num_components} ({num_ics} ICs, {num_passives} passives, {num_simple} discrete)\n" + f"Nets: {num_nets}\n\n" + f"View project: {settings.email_frontend_url}/project/{project_id}\n" + ) + msg.attach(MIMEText(text_body, "plain")) + + html_body = _render_pipeline_started_email( + creator_name, creator_email, project_name, project_id, + num_components, num_nets, num_ics, num_passives, num_simple, + ) + msg.attach(MIMEText(html_body, "html")) + + await _send_raw(to_email, msg, "Pipeline-started email") + + +# --------------------------------------------------------------------------- +# Feedback received (admin notification) +# --------------------------------------------------------------------------- + + +_FEEDBACK_TYPE_LABELS = { + "bug": "Bug report", + "rule_feedback": "Finding feedback", + "feature_request": "Feature request", +} + +_FEEDBACK_TYPE_COLORS = { + "bug": "#ef4444", + "rule_feedback": "#f59e0b", + "feature_request": "#3b82f6", +} + + +def _esc(s: str | None) -> str: + """Minimal HTML escape so user text can't break the template.""" + if s is None: + return "" + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +def _render_feedback_email( + ticket_id: str, + feedback_type: str, + type_label: str, + type_color: str, + submitter_name: str, + submitter_email: str, + project_name: str | None, + project_id: str | None, + finding_designator: str | None, + finding_mpn: str | None, + finding_status: str | None, + finding_text: str | None, + message: str, +) -> str: + admin_url = f"{settings.email_frontend_url}/admin?tab=feedback" + + project_row = "" + if project_name: + project_link = ( + f"{settings.email_frontend_url}/project/{project_id}" + if project_id else "" + ) + project_value = ( + f'{_esc(project_name)}' + if project_link else _esc(project_name) + ) + project_row = f""" + + Project + {project_value} + """ + + finding_rows = "" + if finding_designator or finding_mpn or finding_status: + bits = [] + if finding_designator: + bits.append(f'{_esc(finding_designator)}') + if finding_mpn: + bits.append(f'{_esc(finding_mpn)}') + if finding_status: + bits.append(f'{_esc(finding_status)}') + finding_rows = f""" + + Finding + {' · '.join(bits)} + """ + + finding_text_block = "" + if finding_text: + finding_text_block = f""" + + + +
+ Finding text + {_esc(finding_text)} +
+ """ + + return f"""\ + + + + + + +
+ + + + + + + + + + + +
+ + + + + +
+ Pinscope + + Feedback Received +
+
+ + + + + + + + + + + + {finding_text_block} + + + + + + + + + + +
+ + +
+ {type_label} +
+
+ + +
+ + + + +
+ Submitted by +
+ {_esc(submitter_name)} +
+ {_esc(submitter_email)} +
+
+
+ + {project_row} + {finding_rows} +
+
+ + +
+ {_esc(message)} +
+
+ + + + Open in admin → + + +
+ Ticket {_esc(ticket_id)} +
+
+ + +
+ Pinscope · Agentic schematic validation +
+
+
+ +""" + + +async def send_feedback_received_email( + ticket_id: str, + user_id: str, + feedback_type: str, + message: str, + *, + submitter_name: str | None = None, + submitter_email: str | None = None, + project_name: str | None = None, + project_id: str | None = None, + finding_designator: str | None = None, + finding_mpn: str | None = None, + finding_status: str | None = None, + finding_text: str | None = None, +) -> None: + """Notify the admin inbox that a new feedback ticket landed. Fire-and-forget.""" + if not settings.use_email or not settings.email_admin_notify: + return + + # Fill in submitter info from Clerk when the client didn't pass it. + name = (submitter_name or "").strip() + email = (submitter_email or "").strip() + if not name or not email: + clerk_user = await _resolve_clerk_user(user_id) + if clerk_user: + if not name: + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + name = f"{first} {last}".strip() + if not email: + emails = clerk_user.get("email_addresses", []) + email = emails[0].get("email_address", "") if emails else "" + name = name or "Unknown user" + email = email or user_id + + type_label = _FEEDBACK_TYPE_LABELS.get(feedback_type, feedback_type) + type_color = _FEEDBACK_TYPE_COLORS.get(feedback_type, "#6b7280") + + to_email = settings.email_admin_notify + subject_ctx = project_name or "general" + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = to_email + msg["Subject"] = f"Feedback ({type_label}): {subject_ctx}" + + # Plain text fallback + lines = [ + f"{type_label} from {name} <{email}>", + ] + if project_name: + lines.append(f"Project: {project_name}") + if finding_designator or finding_mpn or finding_status: + finding_bits = " · ".join( + x for x in (finding_designator, finding_mpn, finding_status) if x + ) + lines.append(f"Finding: {finding_bits}") + if finding_text: + lines.append(f"Finding text: {finding_text}") + lines.append("") + lines.append(message) + lines.append("") + lines.append(f"Open in admin: {settings.email_frontend_url}/admin?tab=feedback") + lines.append(f"Ticket: {ticket_id}") + msg.attach(MIMEText("\n".join(lines), "plain")) + + html_body = _render_feedback_email( + ticket_id=ticket_id, + feedback_type=feedback_type, + type_label=type_label, + type_color=type_color, + submitter_name=name, + submitter_email=email, + project_name=project_name, + project_id=project_id, + finding_designator=finding_designator, + finding_mpn=finding_mpn, + finding_status=finding_status, + finding_text=finding_text, + message=message, + ) + msg.attach(MIMEText(html_body, "html")) + + await _send_raw(to_email, msg, "Feedback-received email") + + +# --------------------------------------------------------------------------- +# Feedback reply (notify the original submitter) +# --------------------------------------------------------------------------- + + +def _render_feedback_reply_email( + recipient_first_name: str, + project_name: str | None, + finding_designator: str | None, + finding_mpn: str | None, + original_message: str, + reply_text: str, +) -> str: + feedback_url = f"{settings.email_frontend_url}/feedback" + + context_line = "" + if project_name: + finding_bits = " · ".join( + x for x in (finding_designator, finding_mpn) if x + ) + context_suffix = f" on {_esc(finding_bits)}" if finding_bits else "" + context_line = f""" + + In response to your feedback on {_esc(project_name)}{context_suffix}. + """ + else: + context_line = """ + + In response to the feedback you shared. + """ + + return f"""\ + + + + + + +
+ + + + + + + + + + + +
+ + + + + +
+ Pinscope + + New Reply +
+
+ + + + + + + {context_line} + + + + + + + + + + + + + + +
+ Hi {_esc(recipient_first_name)}, +
+ The Pinscope team just replied to your feedback. +
+ + +
+ + + +
+ Pinscope team +
+ {_esc(reply_text)} +
+
+
+ + +
+ + + +
+ Your original message +
+ {_esc(original_message)} +
+
+
+ + + + View in Pinscope → + + +
+ Thank you so much for taking the time to share your feedback — we truly value it. +
+ — The Pinscope team +
+
+ + +
+ Pinscope · Agentic schematic validation +
+
+
+ +""" + + +async def send_feedback_reply_email( + user_id: str, + reply_text: str, + original_message: str, + *, + recipient_name: str | None = None, + recipient_email: str | None = None, + project_name: str | None = None, + finding_designator: str | None = None, + finding_mpn: str | None = None, +) -> None: + """Notify the original submitter that the Pinscope team replied. Fire-and-forget.""" + if not settings.use_email: + return + + full_name = (recipient_name or "").strip() + to_email = (recipient_email or "").strip() + if not full_name or not to_email: + clerk_user = await _resolve_clerk_user(user_id) + if clerk_user: + if not full_name: + first = clerk_user.get("first_name") or "" + last = clerk_user.get("last_name") or "" + full_name = f"{first} {last}".strip() + if not to_email: + emails = clerk_user.get("email_addresses", []) + to_email = emails[0].get("email_address", "") if emails else "" + + if not to_email: + logger.warning( + "Cannot send feedback-reply email: no email for user %s", user_id + ) + return + + first_name = full_name.split()[0] if full_name else "there" + + msg = MIMEMultipart("alternative") + msg["From"] = f"Pinscope <{settings.email_sender}>" + msg["To"] = to_email + msg["Subject"] = "The Pinscope team replied to your feedback" + + # Plain text fallback + text_lines = [ + f"Hi {first_name},", + "", + "The Pinscope team just replied to your feedback.", + "", + "— Reply —", + reply_text, + "", + "— Your original message —", + original_message, + "", + f"View in Pinscope: {settings.email_frontend_url}/feedback", + "", + "Thank you so much for taking the time to share your feedback — we truly value it.", + "— The Pinscope team", + ] + msg.attach(MIMEText("\n".join(text_lines), "plain")) + + html_body = _render_feedback_reply_email( + recipient_first_name=first_name, + project_name=project_name, + finding_designator=finding_designator, + finding_mpn=finding_mpn, + original_message=original_message, + reply_text=reply_text, + ) + msg.attach(MIMEText(html_body, "html")) + + await _send_raw(to_email, msg, "Feedback-reply email") diff --git a/backend/services/event_bridge.py b/backend/services/event_bridge.py new file mode 100644 index 0000000..a51cb3b --- /dev/null +++ b/backend/services/event_bridge.py @@ -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) diff --git a/backend/services/extraction.py b/backend/services/extraction.py new file mode 100644 index 0000000..e088af8 --- /dev/null +++ b/backend/services/extraction.py @@ -0,0 +1,1081 @@ +"""Async datasheet extraction using Claude API. + +Ports the extraction steps from run_pipeline.py to async: + - extract_pintable: Pin table + package info + taxonomy assignment + - extract_pattern: Passive MPN pattern + - extract_specs: Component specs (discrete, connectors, crystals, etc.) +""" + +from __future__ import annotations + +import json +import logging +import re +import tempfile +import time +from pathlib import Path + +from backend.pinscopex.utils import safe_mpn +from backend.pinscopex.models import ( + CapacitorSpecs, + ComponentConstraints, + ComponentModel, + ComponentType, + DesignGraph, + NetType, + SimpleComponentSpecs, +) +from backend.pinscopex.taxonomy import ( + TAXONOMY_DIR, + add_subtype, + format_for_prompt, + format_specs_for_prompt, + get_specs_schema, + get_subtype, + has_specs, + set_extra_specs, + set_type_specs, +) + +from backend.config import settings +from backend.services.api_logs import ApiLogger, CallMeta +from backend.services.llm import ( + Message, + PdfBlock, + TextBlock, + ToolResultBlock, + ToolSchema, + call_with_fallback, + get_provider, +) + +# --------------------------------------------------------------------------- +# Tool schemas (from run_pipeline.py) +# --------------------------------------------------------------------------- + +PINTABLE_TOOL = { + "name": "save_pintable", + "description": "Save the extracted pin table, package info, and component subtype.", + "input_schema": { + "type": "object", + "properties": { + "component_subtype": { + "type": "string", + "description": "Dotted taxonomy path using lowercase segments joined by periods. Must start with 'ic.'. Examples: ic.mcu, ic.power.ldo, ic.interface.usb_uart_bridge", + "pattern": "^[a-z][a-z0-9_]+(\\.[a-z][a-z0-9_]+)*$", + }, + "component_subtype_description": { + "type": "string", + "description": "Brief human-readable description of the component subtype, e.g. 'Low-dropout voltage regulator', 'USB to UART bridge IC'. Used when this is a new taxonomy entry.", + }, + "package_info": { + "type": "object", + "properties": { + "base_family": {"type": "string"}, + "package": {"type": "string"}, + "pin_count": {"type": "integer"}, + "description": {"type": "string"}, + }, + "required": ["base_family", "package", "pin_count"], + }, + "pintable": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": {}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "functions": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["number", "name"], + }, + }, + }, + "required": ["component_subtype", "component_subtype_description", "package_info", "pintable"], + }, +} + +PATTERN_TOOL = { + "name": "save_pattern", + "description": "Save the extracted passive component MPN pattern.", + "input_schema": { + "type": "object", + "properties": { + "manufacturer": {"type": "string"}, + "series": {"type": "string"}, + "component_type": { + "type": "string", + "enum": ["resistor", "capacitor", "inductor"], + }, + "component_subtype": { + "type": "string", + "description": "Dotted taxonomy path using lowercase segments joined by periods. Must start with 'passive.'. Examples: passive.resistor, passive.capacitor.ceramic, passive.inductor", + "pattern": "^[a-z][a-z0-9_]+(\\.[a-z][a-z0-9_]+)*$", + }, + "component_subtype_description": { + "type": "string", + "description": "Brief human-readable description of the component subtype, e.g. 'Multi-layer ceramic capacitor (MLCC)', 'Chip resistor'. Used when this is a new taxonomy entry.", + }, + "description": {"type": "string"}, + "regex": {"type": "string"}, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "position": {"type": "integer"}, + "length": {"type": "integer"}, + "description": {"type": "string"}, + "lookup": {"type": "object"}, + }, + "required": ["name", "position", "length", "description"], + }, + }, + "value_decoder": {"type": "object"}, + "example_mpns": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": [ + "manufacturer", "series", "component_type", "component_subtype", + "component_subtype_description", "description", "regex", "fields", + "value_decoder", "example_mpns", + ], + }, +} + +SPECS_TOOL = { + "name": "save_specs", + "description": "Save extracted component specifications and pin table.", + "input_schema": { + "type": "object", + "properties": { + "component_subtype": { + "type": "string", + "description": "Dotted taxonomy path, e.g. discrete.diode.schottky, connector.usb", + "pattern": "^[a-z][a-z0-9_]+(\\.[a-z][a-z0-9_]+)*$", + }, + "component_subtype_description": { + "type": "string", + "description": "Brief description of the component subtype. Used when this is a new taxonomy entry.", + }, + "package_info": { + "type": "object", + "properties": { + "base_family": {"type": "string"}, + "package": {"type": "string"}, + "pin_count": {"type": "integer"}, + "description": {"type": "string"}, + }, + "required": ["base_family", "package", "pin_count"], + }, + "pintable": { + "type": "array", + "description": "Pin table for the component. Include ALL pins.", + "items": { + "type": "object", + "properties": { + "number": {}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "functions": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["number", "name"], + }, + }, + "values": { + "type": "object", + "description": "Extracted parameter values keyed ONLY by parameter names from the PARAMETERS TO EXTRACT list. Use SPICE multiplier prefixes (k, M, m, u, n, p) with units. Use null for missing/inapplicable parameters. Do NOT add parameters not in the list.", + "additionalProperties": {"type": ["string", "number", "null"]}, + }, + }, + "required": ["component_subtype", "component_subtype_description", "package_info", "pintable", "values"], + }, +} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +_MAX_PDF_PAGES = 90 + +log = logging.getLogger(__name__) + +# Keywords used to find relevant pages for each extraction stage. +_PINTABLE_KEYWORDS = re.compile( + r"pin\s*(out|diagram|configuration|description|assignment|function|name|table|map)" + r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description", + re.IGNORECASE, +) + + +def _select_pages( + pdf_path: str, keywords: re.Pattern, max_pages: int = _MAX_PDF_PAGES, +) -> str: + """Return path to a trimmed PDF containing only relevant pages. + + Strategy: + 1. Always include pages 0-4 (title/TOC/overview). + 2. Scan all pages for keyword matches and include those + neighbors. + 3. If still under budget, pad with remaining pages from the front. + Returns the original path if the PDF is already within limits. + """ + from pypdf import PdfReader, PdfWriter + + reader = PdfReader(pdf_path) + total = len(reader.pages) + if total <= max_pages: + return pdf_path + + log.info("PDF %s has %d pages (limit %d) — selecting relevant pages", pdf_path, total, max_pages) + + # Always keep the first 5 pages (title, TOC, overview) + keep: set[int] = set(range(min(5, total))) + + # Scan pages for keyword hits and include neighbors (±1) + for i, page in enumerate(reader.pages): + text = page.extract_text() or "" + if keywords.search(text): + for neighbor in (i - 1, i, i + 1): + if 0 <= neighbor < total: + keep.add(neighbor) + + # If still under budget, pad from the front + if len(keep) < max_pages: + for i in range(total): + if len(keep) >= max_pages: + break + keep.add(i) + + selected = sorted(keep)[:max_pages] + log.info("Selected %d/%d pages for %s", len(selected), total, pdf_path) + + 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 + + +def _to_tool(d: dict) -> ToolSchema: + """Convert a tool-definition dict to our unified ToolSchema.""" + return ToolSchema( + name=d["name"], + description=d["description"], + input_schema=d["input_schema"], + ) + + +_GENERATE_SPECS_TOOL = { + "name": "save_specs_schema", + "description": "Save the standardized parameter schema for a component type.", + "input_schema": { + "type": "object", + "properties": { + "specs": { + "type": "array", + "description": "Electrical parameters useful for schematic/design validation.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": ( + "snake_case name with unit suffix: e.g. voltage_rating_v, " + "current_rating_a, resistance_ohm, frequency_hz, capacitance_f, " + "power_w, inductance_h. Use _mm for length." + ), + }, + "description": { + "type": "string", + "description": "Brief description of the parameter and its common datasheet symbol.", + }, + "unit": { + "type": "string", + "description": "SI unit: V, A, ohm, F, Hz, W, s, H, dB, mm, ppm. Omit for dimensionless.", + }, + "required": { + "type": "boolean", + "description": "True if this parameter is essential for validation.", + }, + }, + "required": ["name", "description"], + }, + }, + }, + "required": ["specs"], + }, +} + + +async def _generate_type_specs( + component_type: str, + taxonomy_dir: Path, + api_logger: ApiLogger | None = None, +) -> list[dict]: + """Generate type-level specs schema for a component type with no specs defined.""" + system = ( + "You are a hardware design expert defining standardized extraction parameters " + "for electronic components. Given a component type, define 3-6 electrical " + "parameters that are:\n" + "1. Common across ALL subtypes of this component\n" + "2. Useful for schematic/PCB design VALIDATION (checking connections, ratings, compatibility)\n" + "3. Extractable from a typical datasheet\n\n" + "Do NOT include mechanical, material, or cosmetic parameters.\n" + "Do NOT include parameters only relevant to specific subtypes.\n\n" + "Use snake_case names with unit suffix matching SI units:\n" + "- Voltage: _v (unit: V)\n" + "- Current: _a (unit: A)\n" + "- Resistance: _ohm (unit: ohm)\n" + "- Capacitance: _f (unit: F)\n" + "- Frequency: _hz (unit: Hz)\n" + "- Power: _w (unit: W)\n" + "- Inductance: _h (unit: H)\n" + "- Time: _s (unit: s)\n" + "- Length: _mm (unit: mm)\n\n" + "Values will use SPICE multiplier prefixes: k=1e3, M=1e6, m=1e-3, u=1e-6, n=1e-9, p=1e-12.\n\n" + "Mark the single most important parameter as required.\n" + "Call save_specs_schema with the parameter list." + ) + + async def _call(provider, model): + session = await provider.create_session(model=model, system=system, max_tokens=1024) + t0 = time.monotonic() + try: + completion = await session.complete( + messages=[Message("user", [TextBlock( + f"Define standardized extraction parameters for component type: {component_type}", + )])], + tools=[_to_tool(_GENERATE_SPECS_TOOL)], + tool_choice={"name": "save_specs_schema"}, + ) + finally: + await session.close() + return completion, time.monotonic() - t0, provider.name, model + + completion, elapsed, provider_name, model = await call_with_fallback("specs", _call) + + if api_logger: + api_logger.log( + stage="generate_type_specs", identifier=component_type, + 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(elapsed * 1000), + stop_reason=completion.stop_reason, + turns=1, + ) + + for tc in completion.tool_calls: + if tc.name == "save_specs_schema": + specs = tc.input["specs"] + set_type_specs(component_type, specs, taxonomy_dir) + return specs + return [] + + +async def _generate_extra_specs( + subtype_key: str, + subtype_description: str, + component_type: str, + taxonomy_dir: Path, + api_logger: ApiLogger | None = None, +) -> list[dict]: + """Generate extra_specs for a new subtype.""" + type_specs = get_specs_schema(component_type, directory=taxonomy_dir) + existing_names = [s["name"] for s in type_specs] + + system = ( + "You are a hardware design expert defining subtype-specific extraction parameters " + "for electronic components. Given a component subtype, define 2-5 additional " + "electrical parameters that are:\n" + "1. SPECIFIC to this subtype (not common across all subtypes of the parent type)\n" + "2. Useful for schematic/PCB design VALIDATION (checking connections, ratings, compatibility)\n" + "3. Extractable from a typical datasheet\n\n" + "Do NOT include mechanical, material, or cosmetic parameters.\n" + "Do NOT duplicate these existing type-level parameters: " + f"{', '.join(existing_names)}\n\n" + "Use snake_case names with unit suffix matching SI units:\n" + "- Voltage: _v (V), Current: _a (A), Resistance: _ohm (ohm)\n" + "- Capacitance: _f (F), Frequency: _hz (Hz), Power: _w (W)\n" + "- Inductance: _h (H), Time: _s (s), Length: _mm (mm)\n\n" + "Values will use SPICE multiplier prefixes: k=1e3, M=1e6, m=1e-3, u=1e-6, n=1e-9, p=1e-12.\n\n" + "If this subtype needs NO additional parameters beyond the type-level ones, " + "return an empty specs array.\n" + "Call save_specs_schema." + ) + + async def _call(provider, model): + session = await provider.create_session(model=model, system=system, max_tokens=1024) + t0 = time.monotonic() + try: + completion = await session.complete( + messages=[Message("user", [TextBlock( + f"Component subtype: {subtype_key} — {subtype_description}\n" + f"Parent type: {component_type}\n" + f"Existing type-level parameters: {', '.join(existing_names)}", + )])], + tools=[_to_tool(_GENERATE_SPECS_TOOL)], + tool_choice={"name": "save_specs_schema"}, + ) + finally: + await session.close() + return completion, time.monotonic() - t0, provider.name, model + + completion, elapsed, provider_name, model = await call_with_fallback("specs", _call) + + if api_logger: + api_logger.log( + stage="generate_extra_specs", identifier=subtype_key, + 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(elapsed * 1000), + stop_reason=completion.stop_reason, + turns=1, + ) + + for tc in completion.tool_calls: + if tc.name == "save_specs_schema": + extra = tc.input["specs"] + if extra: + set_extra_specs(subtype_key, extra, taxonomy_dir) + return extra + return [] + + +# --------------------------------------------------------------------------- +# Extraction steps +# --------------------------------------------------------------------------- + + +async def extract_pintable( + mpn: str, + pdf_path: str, + output_dir: Path, + taxonomy_dir: Path | None = None, + api_logger: ApiLogger | None = None, +) -> Path: + """Extract pin table from datasheet PDF. Returns path to constraints JSON.""" + tax_dir = taxonomy_dir or settings.taxonomy_dir + taxonomy = format_for_prompt("ic", tax_dir) + + trimmed = _select_pages(pdf_path, _PINTABLE_KEYWORDS) + skill_id, version = settings.get_skill("extract-pintable") + system = ( + f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n" + f"MPN: {mpn}\n\n" + f"EXISTING IC TAXONOMY SUBTYPES:\n{taxonomy}\n\n" + f"After reading the skill and extracting data, call save_pintable." + ) + provider = get_provider("pintable") + model = settings.model_for_stage("pintable") + try: + result, completion = await provider.run_skill( + skill_name="extract-pintable", + model=model, + system=system, + user_text=f"Extract pin table and package info for MPN: {mpn}", + pdf_path=trimmed, + output_tool=_to_tool(PINTABLE_TOOL), + ) + finally: + if trimmed != pdf_path: + Path(trimmed).unlink(missing_ok=True) + + if api_logger: + api_logger.log( + stage="pintable", identifier=mpn, model=model, + provider=provider.name, skill_id=skill_id, + 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=getattr(completion, "duration_ms", 0), + stop_reason=completion.stop_reason, + turns=getattr(completion, "turns", 1), + ) + + # Check that the datasheet actually matches the requested MPN + base_family = result.get("package_info", {}).get("base_family", "") + if base_family: + mpn_norm = mpn.upper().replace("-", "").replace("_", "") + bf_norm = base_family.upper().replace("-", "").replace("_", "") + if bf_norm not in mpn_norm and mpn_norm not in bf_norm: + raise ValueError( + f"Datasheet mismatch for {mpn}: extracted base_family " + f"'{base_family}' does not match the requested MPN. " + f"The uploaded PDF may be the wrong datasheet." + ) + + # Empty pintable means extraction effectively failed — refuse to + # persist it anywhere (including the shared library). Raising here + # lets the pipeline's per-IC error handler mark this MPN as skipped. + if not result.get("pintable"): + raise ValueError( + f"Empty pintable extracted for {mpn} — the uploaded PDF may " + f"not be a valid datasheet for this component." + ) + + # Ensure taxonomy entry exists + subtype = result["component_subtype"] + subtype_desc = result.get("component_subtype_description", "") + if not get_subtype(subtype, tax_dir): + add_subtype(subtype, subtype_desc or f"(auto-added for {mpn})", + example_mpn=mpn, directory=tax_dir) + + constraints = ComponentConstraints( + mpn=mpn, + model_version=settings.get_default_model_version(), + component_subtype=subtype, + package_info=result["package_info"], + pintable=result["pintable"], + absolute_maximum_ratings=[], + rules=[], + ) + + output_dir.mkdir(parents=True, exist_ok=True) + safe = safe_mpn(mpn) + out_path = output_dir / f"{safe}.json" + out_path.write_text(constraints.model_dump_json(indent=2) + "\n") + return out_path + + +async def extract_pattern( + pdf_path: str, + mpns: list[str], + output_dir: Path, + trigger_mpn: str | None = None, + taxonomy_dir: Path | None = None, + api_logger: ApiLogger | None = None, +) -> Path | None: + """Extract passive MPN pattern from datasheet. Returns path to pattern JSON. + + If *trigger_mpn* is provided and the extracted regex does not match it, + returns ``None`` so the MPN falls through to specs extraction instead of + being silently missed. + """ + tax_dir = taxonomy_dir or settings.taxonomy_dir + taxonomy = format_for_prompt("passive", tax_dir) + + skill_id, version = settings.get_skill("extract-pattern") + system = ( + f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n\n" + f"EXISTING PASSIVE TAXONOMY SUBTYPES:\n{taxonomy}\n\n" + f"BOM MPNs that should match this pattern: {mpns}\n\n" + f"After reading the skill and extracting data, call save_pattern." + ) + provider = get_provider("pattern") + model = settings.model_for_stage("pattern") + result, completion = await provider.run_skill( + skill_name="extract-pattern", + model=model, + system=system, + user_text="Extract the part numbering pattern from this datasheet.", + pdf_path=pdf_path, + output_tool=_to_tool(PATTERN_TOOL), + ) + + if api_logger: + api_logger.log( + stage="pattern", identifier=Path(pdf_path).stem, + model=model, provider=provider.name, skill_id=skill_id, + 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=getattr(completion, "duration_ms", 0), + stop_reason=completion.stop_reason, + turns=getattr(completion, "turns", 1), + ) + + # Validate: the pattern must at least match the MPN whose datasheet + # was used, otherwise the extraction is useless for that MPN. + regex = result.get("regex", "") + if trigger_mpn and regex: + try: + if not re.match(regex, trigger_mpn): + log.warning( + "Pattern regex from %s does not match trigger MPN %s — discarding", + Path(pdf_path).name, trigger_mpn, + ) + return None + except re.error: + log.warning("Invalid regex from %s: %s", Path(pdf_path).name, regex) + return None + + # Ensure taxonomy entry + subtype = result.get("component_subtype", "") + subtype_desc = result.get("component_subtype_description", "") + if subtype and not get_subtype(subtype, tax_dir): + add_subtype(subtype, subtype_desc or f"(auto-added for {result['manufacturer']} {result['series']})", + directory=tax_dir) + + output_dir.mkdir(parents=True, exist_ok=True) + filename = f"{safe_mpn(result['manufacturer'])}_{safe_mpn(result['series'])}_{result['component_type']}.json" + out_path = output_dir / filename + + out_path.write_text(json.dumps(result, indent=2) + "\n") + return out_path + + +async def extract_specs( + mpn: str, + pdf_path: str, + component_type: str, + output_dir: Path, + taxonomy_dir: Path | None = None, + api_logger: ApiLogger | None = None, +) -> Path: + """Extract specs from datasheet for a simple/discrete component. + + Returns path to the ComponentModel JSON in *output_dir*. + """ + tax_dir = taxonomy_dir or settings.taxonomy_dir + + # Auto-generate type-level specs if none exist for this component type + if not has_specs(component_type, tax_dir): + try: + await _generate_type_specs(component_type, tax_dir, api_logger) + except Exception: + import logging + logging.getLogger(__name__).warning( + "Failed to auto-generate specs schema for %s", component_type, + exc_info=True, + ) + + subtypes_text = format_for_prompt(component_type, tax_dir) + specs_text = format_specs_for_prompt(component_type, tax_dir) + + skill_id, version = settings.get_skill("extract-specs") + system = ( + f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n" + f"MPN: {mpn}\n" + f"Component type: {component_type}\n\n" + f"EXISTING {component_type.upper()} TAXONOMY SUBTYPES:\n{subtypes_text}\n\n" + f"{specs_text}\n\n" + f"After reading the skill and extracting data, call save_specs." + ) + provider = get_provider("specs") + model = settings.model_for_stage("specs") + result, completion = await provider.run_skill( + skill_name="extract-specs", + model=model, + system=system, + user_text=f"Extract specifications for MPN: {mpn}", + pdf_path=pdf_path, + output_tool=_to_tool(SPECS_TOOL), + ) + + if api_logger: + api_logger.log( + stage="specs", identifier=mpn, model=model, + provider=provider.name, skill_id=skill_id, + 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=getattr(completion, "duration_ms", 0), + stop_reason=completion.stop_reason, + turns=getattr(completion, "turns", 1), + ) + + # Ensure taxonomy entry; auto-generate extra_specs for new subtypes + subtype = result["component_subtype"] + subtype_desc = result.get("component_subtype_description", "") + if not get_subtype(subtype, tax_dir): + add_subtype( + subtype, subtype_desc or f"(auto-added for {mpn})", + example_mpn=mpn, directory=tax_dir, + ) + try: + await _generate_extra_specs( + subtype, subtype_desc or subtype, + component_type, tax_dir, api_logger, + ) + except Exception: + import logging + logging.getLogger(__name__).warning( + "Failed to auto-generate extra_specs for %s", subtype, + exc_info=True, + ) + + # Filter values to taxonomy-defined parameter names only + allowed_keys = {s["name"] for s in get_specs_schema(component_type, subtype, tax_dir)} + filtered_values = {k: v for k, v in result["values"].items() if k in allowed_keys} + + # Build and persist ComponentModel + specs = SimpleComponentSpecs( + specs_type=component_type, + component_subtype=subtype, + values=filtered_values, + pintable=result.get("pintable", []), + package_info=result.get("package_info"), + ) + model_obj = ComponentModel(mpn=mpn, specs=specs) + + output_dir.mkdir(parents=True, exist_ok=True) + safe = safe_mpn(mpn) + out_path = output_dir / f"{safe}.json" + out_path.write_text(model_obj.model_dump_json(indent=2) + "\n") + return out_path + + +# --------------------------------------------------------------------------- +# Auto-resolve specs from DigiKey parameters (no PDF needed) +# --------------------------------------------------------------------------- + +AUTO_RESOLVE_TOOL = { + "name": "save_resolved_specs", + "description": "Save the resolved component specifications mapped from distributor parameters.", + "input_schema": { + "type": "object", + "properties": { + "component_subtype": { + "type": "string", + "description": "Dotted taxonomy path, e.g. discrete.diode.schottky, connector.usb", + }, + "component_subtype_description": { + "type": "string", + "description": "Brief description of the component subtype.", + }, + "values": { + "type": "object", + "description": "Parameter values keyed by taxonomy spec names. Use SPICE multiplier prefixes with units.", + "additionalProperties": {"type": ["string", "number", "null"]}, + }, + "package": { + "type": ["string", "null"], + "description": "Package type, e.g. SOD-123, SOT-23, TO-220", + }, + }, + "required": ["component_subtype", "values"], + }, +} + +_AUTO_RESOLVE_SYSTEM = """\ +You are a hardware component classifier and parameter mapper. + +Given distributor product parameters for an electronic component, you must: +1. Classify the component into the correct taxonomy subtype +2. Map the parameter values to the standardized taxonomy parameters + +COMPONENT TYPE: {component_type} + +EXISTING SUBTYPES: +{subtypes_text} + +{specs_text} + +RULES: +- Map distributor parameter values to the taxonomy parameter names listed above. +- Use SPICE multiplier prefixes (T=1e12, G=1e9, M=1e6, k=1e3, m=1e-3, u=1e-6, n=1e-9, p=1e-12) with units. + Examples: 30V, 500mA, 47mohm, 18pF, 8MHz, 10nC, 250mW. +- Always include the unit with the multiplier in the value string. +- If a distributor parameter doesn't map to any taxonomy parameter, skip it. +- If a taxonomy parameter isn't available from the distributor data, use null. +- Pick the most specific matching subtype from the list above. + +Call save_resolved_specs with the mapped values.\ +""" + + +async def auto_resolve_specs( + mpn: str, + digikey_params: list[dict[str, str]], + digikey_category: str, + digikey_description: str, + component_type: str, + taxonomy_dir: Path | None = None, + api_logger: ApiLogger | None = None, +) -> ComponentModel: + """Map DigiKey product parameters to taxonomy specs using a lightweight model. + + Returns a ComponentModel ready to persist. Raises on failure. + """ + tax_dir = taxonomy_dir or settings.taxonomy_dir + + # Auto-generate type-level specs if none exist + if not has_specs(component_type, tax_dir): + try: + await _generate_type_specs(component_type, tax_dir, api_logger=api_logger) + except Exception: + import logging as _logging + _logging.getLogger(__name__).warning( + "Failed to auto-generate specs schema for %s", component_type, + exc_info=True, + ) + + subtypes_text = format_for_prompt(component_type, tax_dir) + specs_text = format_specs_for_prompt(component_type, tax_dir) + + system = _AUTO_RESOLVE_SYSTEM.format( + component_type=component_type, + subtypes_text=subtypes_text, + specs_text=specs_text, + ) + + # Format DigiKey params as readable text + params_lines = [f"- {p['name']}: {p['value']}" for p in digikey_params] + user_text = ( + f"MPN: {mpn}\n" + f"Category: {digikey_category}\n" + f"Description: {digikey_description}\n\n" + f"DISTRIBUTOR PARAMETERS:\n" + "\n".join(params_lines) + ) + + async def _call(provider, model_name): + session = await provider.create_session( + model=model_name, system=system, max_tokens=1024, + ) + t0 = time.monotonic() + try: + completion = await session.complete( + messages=[Message("user", [TextBlock(user_text)])], + tools=[_to_tool(AUTO_RESOLVE_TOOL)], + tool_choice={"name": "save_resolved_specs"}, + ) + finally: + await session.close() + return completion, time.monotonic() - t0, provider.name, model_name + + completion, elapsed, provider_name, model_name = await call_with_fallback( + "auto_resolve", _call, + ) + + # Parse forced tool response + result: dict | None = None + for tc in completion.tool_calls: + if tc.name == "save_resolved_specs": + result = tc.input + break + if not result: + raise RuntimeError(f"Auto-resolve failed for {mpn}: no tool response") + + import logging as _logging + _logging.getLogger(__name__).info( + "Auto-resolved %s → %s in %.1fs (model=%s, in=%d, out=%d)", + mpn, result.get("component_subtype", "?"), elapsed, model_name, + completion.usage.input_tokens, completion.usage.output_tokens, + ) + if api_logger: + api_logger.log( + stage="auto_resolve", identifier=mpn, + model=model_name, 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(elapsed * 1000), + stop_reason=completion.stop_reason, + turns=1, + ) + + # Ensure taxonomy entry for new subtypes + subtype = result.get("component_subtype", "") + subtype_desc = result.get("component_subtype_description", "") + if subtype and not get_subtype(subtype, tax_dir): + add_subtype( + subtype, subtype_desc or f"(auto-added for {mpn})", + example_mpn=mpn, directory=tax_dir, + ) + + # Filter values to taxonomy-defined parameter names only + allowed_keys = {s["name"] for s in get_specs_schema(component_type, subtype, tax_dir)} + raw_values = result.get("values", {}) + filtered_values = {k: v for k, v in raw_values.items() if k in allowed_keys} + + # Include package in values if taxonomy defines it + pkg = result.get("package") + if pkg and "package" in allowed_keys: + filtered_values.setdefault("package", pkg) + + specs = SimpleComponentSpecs( + specs_type=component_type, + component_subtype=subtype, + values=filtered_values, + ) + + # Convert passive SimpleComponentSpecs to typed models + if component_type == "passive": + from backend.pinscopex.resolve_passives import simple_to_typed_passive_specs + typed = simple_to_typed_passive_specs(specs) + return ComponentModel(mpn=mpn, specs=typed) + + return ComponentModel(mpn=mpn, specs=specs) + + +# --------------------------------------------------------------------------- +# Value-based fallback (last resort when no MPN, no datasheet, no DigiKey hit) +# --------------------------------------------------------------------------- + +_PASSIVE_PREFIX_HINT: dict[str, str] = { + "C": "capacitor — populate value_farads", + "R": "resistor — populate value_ohms", + "L": "inductor — populate value_henries", + "FB": "ferrite bead — populate value_ohms (impedance)", +} + +_VALUE_RESOLVE_SYSTEM = """\ +You are parsing a passive component value string from a schematic BOM when no +manufacturer part number and no datasheet are available. The only signal you +have is a value string (e.g. "10uF", "4.7k", "100nH") and the reference-designator +prefix telling you whether it is R/C/L. + +COMPONENT TYPE: {component_type} + +EXISTING SUBTYPES: +{subtypes_text} + +{specs_text} + +CRITICAL RULES: +- You ONLY have a value string. You do NOT know the tolerance, voltage rating, + dielectric, package, or power rating. Never invent these. +- Populate EXACTLY TWO fields: ``value_formatted`` (a normalized human-readable + string) and the matching primary numeric field + (``value_farads`` / ``value_ohms`` / ``value_henries``). Leave every other + parameter out (do not include a null entry — omit the key entirely). +- Express numeric values with SPICE multiplier prefixes and units + (u=1e-6, n=1e-9, p=1e-12, k=1e3, M=1e6). Examples: ``10uF``, ``4.7kohm``, ``100nH``. +- Pick the GENERIC parent subtype — e.g. ``passive.capacitor``, ``passive.resistor``, + ``passive.inductor``. Do NOT guess a more specific subtype (ceramic, tantalum, + film, etc.) from a value alone. Only use subtypes that already exist in the + EXISTING SUBTYPES list. +- If the value string is ambiguous or clearly not a passive component value + (e.g. an IC part number, a net name), still produce your best guess but keep + it to the parent subtype. + +Call save_resolved_specs with the mapped values.\ +""" + + +async def resolve_from_value( + *, + mpn: str, + value: str, + ref_prefix: str, + component_type: str = "passive", + taxonomy_dir: Path | None = None, + api_logger: ApiLogger | None = None, +) -> ComponentModel: + """Map a bare BOM value string (e.g. ``10uF``) to typed passive specs. + + Last-resort fallback used when the BOM's MPN column contains a value rather + than a real part number and DigiKey has no matching hit. Only sets the + primary value — never fabricates tolerance, voltage, dielectric, or package. + Never auto-adds new taxonomy subtypes; callers should NOT persist the result + to the shared library because the ``mpn`` is not a real part number. + """ + tax_dir = taxonomy_dir or settings.taxonomy_dir + + if not has_specs(component_type, tax_dir): + try: + await _generate_type_specs(component_type, tax_dir, api_logger=api_logger) + except Exception: + logging.getLogger(__name__).warning( + "Failed to auto-generate specs schema for %s", component_type, + exc_info=True, + ) + + subtypes_text = format_for_prompt(component_type, tax_dir) + specs_text = format_specs_for_prompt(component_type, tax_dir) + + system = _VALUE_RESOLVE_SYSTEM.format( + component_type=component_type, + subtypes_text=subtypes_text, + specs_text=specs_text, + ) + + hint = _PASSIVE_PREFIX_HINT.get(ref_prefix.upper(), "") + user_text = ( + f"BOM token (used as MPN): {mpn}\n" + f"BOM value: {value}\n" + f"Reference prefix: {ref_prefix}" + + (f" ({hint})" if hint else "") + ) + + async def _call(provider, model_name): + session = await provider.create_session( + model=model_name, system=system, max_tokens=512, + ) + t0 = time.monotonic() + try: + completion = await session.complete( + messages=[Message("user", [TextBlock(user_text)])], + tools=[_to_tool(AUTO_RESOLVE_TOOL)], + tool_choice={"name": "save_resolved_specs"}, + ) + finally: + await session.close() + return completion, time.monotonic() - t0, provider.name, model_name + + completion, elapsed, provider_name, model_name = await call_with_fallback( + "auto_resolve", _call, + ) + + result: dict | None = None + for tc in completion.tool_calls: + if tc.name == "save_resolved_specs": + result = tc.input + break + if not result: + raise RuntimeError(f"Value fallback failed for {mpn}: no tool response") + + logging.getLogger(__name__).info( + "Resolved from value %s=%r → %s in %.1fs (model=%s)", + mpn, value, result.get("component_subtype", "?"), elapsed, model_name, + ) + if api_logger: + api_logger.log( + stage="value_resolve", identifier=mpn, + model=model_name, 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(elapsed * 1000), + stop_reason=completion.stop_reason, + turns=1, + ) + + subtype = result.get("component_subtype", "") or "passive" + # Do NOT auto-add subtypes here — we only have a value, not a real part. + if not get_subtype(subtype, tax_dir): + subtype = component_type # fall back to top-level type + + allowed_keys = {s["name"] for s in get_specs_schema(component_type, subtype, tax_dir)} + raw_values = result.get("values", {}) + filtered_values = {k: v for k, v in raw_values.items() if k in allowed_keys and v is not None} + + specs = SimpleComponentSpecs( + specs_type=component_type, + component_subtype=subtype, + values=filtered_values, + ) + + if component_type == "passive": + from backend.pinscopex.resolve_passives import simple_to_typed_passive_specs + typed = simple_to_typed_passive_specs(specs) + return ComponentModel(mpn=mpn, specs=typed) + + return ComponentModel(mpn=mpn, specs=specs) + diff --git a/backend/services/job_runner.py b/backend/services/job_runner.py new file mode 100644 index 0000000..9545915 --- /dev/null +++ b/backend/services/job_runner.py @@ -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) diff --git a/backend/services/llm/__init__.py b/backend/services/llm/__init__.py new file mode 100644 index 0000000..ae0b347 --- /dev/null +++ b/backend/services/llm/__init__.py @@ -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", +] diff --git a/backend/services/llm/anthropic_provider.py b/backend/services/llm/anthropic_provider.py new file mode 100644 index 0000000..677f362 --- /dev/null +++ b/backend/services/llm/anthropic_provider.py @@ -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" + ) diff --git a/backend/services/llm/base.py b/backend/services/llm/base.py new file mode 100644 index 0000000..a4d4fa0 --- /dev/null +++ b/backend/services/llm/base.py @@ -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.""" + ... diff --git a/backend/services/llm/factory.py b/backend/services/llm/factory.py new file mode 100644 index 0000000..feecb0d --- /dev/null +++ b/backend/services/llm/factory.py @@ -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_`` / ``FALLBACK_MODEL_``. + + 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]) diff --git a/backend/services/llm/gemini_provider.py b/backend/services/llm/gemini_provider.py new file mode 100644 index 0000000..7245329 --- /dev/null +++ b/backend/services/llm/gemini_provider.py @@ -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_=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." + ) diff --git a/backend/services/llm/pricing.py b/backend/services/llm/pricing.py new file mode 100644 index 0000000..02641c7 --- /dev/null +++ b/backend/services/llm/pricing.py @@ -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) diff --git a/backend/services/llm/types.py b/backend/services/llm/types.py new file mode 100644 index 0000000..0c862d1 --- /dev/null +++ b/backend/services/llm/types.py @@ -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.""" diff --git a/backend/services/normalize_findings.py b/backend/services/normalize_findings.py new file mode 100644 index 0000000..8e5ef12 --- /dev/null +++ b/backend/services/normalize_findings.py @@ -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 per " + "rubric because , ...)." + ), + }, + }, + "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 diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py new file mode 100644 index 0000000..694764d --- /dev/null +++ b/backend/services/pipeline.py @@ -0,0 +1,1934 @@ +"""Pipeline orchestrator — runs all stages and emits SSE events. + +Stages: + 1. Parse BOM → classify IC, discrete/simple, and passive MPNs + 2. IC Pintable Extraction (per MPN) — pin names for graph enrichment + 2.5. Simple Component Specs Extraction (per MPN with datasheet) + 3. Passive Extraction: pattern-based (per MPN group), then specs fallback (per MPN) + 4. Build Design Graph + 5. Direct Datasheet Review — per-IC review with PDF + circuit neighborhood + +This module no longer spawns the pipeline as a FastAPI ``BackgroundTask``. +The API enqueues a Cloud Run Job execution (or, in dev, a subprocess) via +:mod:`backend.services.job_runner`; the actual ``run_pipeline`` and +``run_regen_pipeline`` coroutines are imported and invoked by +:mod:`backend.pipeline_worker`. Progress events flow through the +module-level ``broker`` — in the API it stays an in-memory +:class:`EventBroker` (used only by tests / local importers); in the +worker it is replaced with a :class:`GCSEventBroker` via :func:`set_broker` +before any pipeline code runs. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import shutil +import tempfile +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Awaitable, Callable + + +from backend.pinscopex.models import ComponentType +from backend.pinscopex.utils import natural_sort_key, safe_mpn +from backend.pinscopex.bom_summary import build_bom_summary +from backend.pinscopex.derating import build_derating_table +from backend.pinscopex.validate import _load_datasheets +from backend.pinscopex.graph import build_graph +from backend.pinscopex.parsers import parse_bom, parse_netlist_any +from backend.pinscopex.resolve_passives import SkippedItem, load_patterns, resolve_mpn +from backend.pinscopex.taxonomy import SIMPLE_TYPES, type_for_ref + +from backend.config import settings +from backend.services import admin_settings as settings_svc +from backend.services.billing_hook import InsufficientCredits, get_billing +from backend.services.datasheet_store import compute_md5_from_path, store_datasheet +from backend.services import extraction, projects as proj_svc +from backend.services.api_logs import ApiLogger, total_cost +from backend.services.cost_estimator import estimate_stage_cost_usd +from backend.services.storage import StorageBackend +from backend.services.validation import validate_design_async + +logger = logging.getLogger(__name__) + + +_GIT_COMMIT: str | None = None + + +def _ic_descriptions(extracted_dir: Path) -> dict[str, str]: + """Read ``package_info.description`` from extracted IC constraints, keyed + by MPN. Used to populate the BOM Specs column for ICs with a one-line + "what this chip does" summary. Best-effort — missing or unreadable files + are skipped silently.""" + out: dict[str, str] = {} + try: + for mpn, c in _load_datasheets(extracted_dir).items(): + desc = c.package_info.description if c.package_info else None + if desc: + out[mpn] = desc + except Exception: + logger.exception("ic_descriptions: load failed for %s", extracted_dir) + return out + + +def _git_commit() -> str: + """Short git SHA of the running code, resolved once and cached. + Stamped into per-IC review traces. Never raises.""" + global _GIT_COMMIT + if _GIT_COMMIT is None: + try: + import subprocess + + _GIT_COMMIT = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, timeout=5, + cwd=Path(__file__).resolve().parent, + ).stdout.strip() or "unknown" + except Exception: + logger.exception("could not resolve git commit for review traces") + _GIT_COMMIT = "unknown" + return _GIT_COMMIT + + +# --------------------------------------------------------------------------- +# SSE Event Broker +# --------------------------------------------------------------------------- + +class EventBroker: + """In-memory pub/sub for SSE events, keyed by project_id. + + Buffers all events per project so late subscribers (e.g. after a page + navigation) receive the full history before seeing live events. + """ + + def __init__(self): + self._queues: dict[str, list[asyncio.Queue]] = {} + self._history: dict[str, list[dict]] = {} + + def subscribe(self, project_id: str) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue() + # Replay buffered events so the subscriber catches up + for msg in self._history.get(project_id, []): + q.put_nowait(msg) + self._queues.setdefault(project_id, []).append(q) + return q + + def unsubscribe(self, project_id: str, q: asyncio.Queue) -> None: + qs = self._queues.get(project_id, []) + if q in qs: + qs.remove(q) + if not qs: + self._queues.pop(project_id, None) + + def clear_history(self, project_id: str) -> None: + self._history.pop(project_id, None) + + def publish(self, project_id: str, event: str, data: dict) -> None: + msg = {"event": event, "data": data} + self._history.setdefault(project_id, []).append(msg) + for q in self._queues.get(project_id, []): + q.put_nowait(msg) + + +broker: EventBroker = EventBroker() + + +def set_broker(b: EventBroker) -> None: + """Replace the module-level broker. + + Called by :mod:`backend.pipeline_worker` at startup to swap in the + GCS-backed broker so events written from the worker are visible to + the API's SSE handler. Must be called *before* :func:`run_pipeline` + or :func:`run_regen_pipeline`. + """ + global broker + broker = b + + +# Per-process cancel-flag cache: re-reading the project meta from GCS on +# every Claude API call would dominate latency. The worker's cancel gate +# (inside ``_charge_for_logs``) refreshes at most every +# ``_CANCEL_POLL_INTERVAL_S`` seconds. +_CANCEL_POLL_INTERVAL_S = 3.0 + + +class CancelRequested(Exception): + """Raised by the cancel gate when ``meta.cancel_requested == True``. + + Bubbles up through the stage loop; the top-level run handler catches + it, emits ``pipeline_cancelled``, transitions the project status to + ``cancelled``, and exits. + """ + + +def _cancel_gate_check(ctx: PipelineContext) -> None: + """Check the cancel flag on disk; raise if set. + + Caches the last poll time on the context so we don't hammer GCS. + Uses ``time.monotonic`` rather than the asyncio event loop's clock + so callers can invoke this from sync test code without first + spinning up an event loop. + """ + import time as _time + + last = getattr(ctx, "_last_cancel_poll", 0.0) + now = _time.monotonic() + if now - last < _CANCEL_POLL_INTERVAL_S: + return + ctx._last_cancel_poll = now # type: ignore[attr-defined] + try: + meta = proj_svc.get_project(ctx.storage, ctx.user_id, ctx.project_id) + except Exception: + # Storage hiccups must not abort the pipeline. + return + if meta is not None and meta.cancel_requested: + raise CancelRequested(f"cancel requested for {ctx.project_id}") + + +# --------------------------------------------------------------------------- +# Pipeline Workspace +# --------------------------------------------------------------------------- + +class PipelineWorkspace: + """Downloads project files from storage to a temp dir for pipeline execution. + + The pinscopex core library operates on local paths. This context manager + downloads inputs at enter, provides local paths, and uploads results at exit. + """ + + def __init__( + self, + storage: StorageBackend, + user_id: str, + project_id: str, + ) -> None: + self.storage = storage + self.user_id = user_id + self.project_id = project_id + self.prefix = proj_svc.project_prefix(user_id, project_id) + self._tmpdir: tempfile.TemporaryDirectory | None = None + self.local_dir: Path = Path() + + async def __aenter__(self) -> PipelineWorkspace: + self._tmpdir = tempfile.TemporaryDirectory() + self.local_dir = Path(self._tmpdir.name) + + # Create subdirectories + (self.local_dir / "uploads" / "datasheets").mkdir(parents=True) + (self.local_dir / "extracted").mkdir(parents=True) + (self.local_dir / "patterns").mkdir(parents=True) + (self.local_dir / "models").mkdir(parents=True) + (self.local_dir / "taxonomy").mkdir(parents=True) + + # Download project files from storage + all_keys = self.storage.list_recursive(self.prefix) + for key in all_keys: + # key is like users/{uid}/projects/{pid}/uploads/bom.csv + # We want the relative part after the project prefix + rel = key[len(self.prefix) + 1:] # strip prefix + trailing / + local_path = self.local_dir / rel + self.storage.download_to_local(key, local_path) + + # Download taxonomy files from storage + taxonomy_keys = self.storage.list_prefix("taxonomy/") + for key in taxonomy_keys: + if key.endswith(".json"): + filename = key.rsplit("/", 1)[-1] + self.storage.download_to_local(key, self.local_dir / "taxonomy" / filename) + + # Seed from repo taxonomy if storage had no taxonomy files yet + local_tax = self.local_dir / "taxonomy" + if not any(local_tax.glob("*.json")): + repo_tax = settings.taxonomy_dir + if repo_tax.is_dir(): + for f in repo_tax.glob("*.json"): + shutil.copy2(f, local_tax / f.name) + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if exc_type is None: + # Upload outputs back to storage + self._upload_dir("extracted") + self._upload_dir("patterns") + self._upload_dir("models") + self._upload_file("design_graph.json") + self._upload_file("bom_summary.json") + self._upload_file("derating.json") + self._upload_file("report.json") + self._upload_file("api_logs.jsonl") + + # Merge taxonomy: read current from storage, add any new entries + # from this run, write back. This avoids clobbering subtypes + # that a concurrent pipeline added while we were running. + tax_dir = self.local_dir / "taxonomy" + if tax_dir.is_dir(): + for f in tax_dir.iterdir(): + if f.is_file() and f.suffix == ".json": + local_data = json.loads(f.read_text()) + local_subtypes = local_data.get("subtypes", {}) + storage_key = f"taxonomy/{f.name}" + + if self.storage.exists(storage_key): + current = self.storage.read_json(storage_key) + merged = current.get("subtypes", {}) + for key, entry in local_subtypes.items(): + if key not in merged: + merged[key] = entry + else: + # Backfill fields the local run generated + # (e.g. specs_schema) that the + # storage copy is missing. + for field, value in entry.items(): + if field not in merged[key]: + merged[key][field] = value + current["subtypes"] = merged + self.storage.write_json(storage_key, current) + else: + self.storage.write_json(storage_key, local_data) + + if self._tmpdir: + self._tmpdir.cleanup() + + def _upload_dir(self, subdir: str) -> None: + """Upload all files in a subdirectory back to storage.""" + local = self.local_dir / subdir + if not local.is_dir(): + return + for f in local.rglob("*"): + if f.is_file(): + rel = f.relative_to(self.local_dir) + key = f"{self.prefix}/{rel}" + self.storage.upload_from_local(f, key) + + def _upload_file(self, name: str) -> None: + """Upload a single file back to storage if it exists.""" + local = self.local_dir / name + if local.is_file(): + self.storage.upload_from_local(local, f"{self.prefix}/{name}") + + def local_path(self, rel: str) -> Path: + """Get a local path within the workspace.""" + return self.local_dir / rel + + def netlist_local_path(self) -> Path: + """Local path of whichever netlist file was synced (``.asc`` or ``.edn``). + + Pipeline workspace mirrors the entire project prefix, so whichever + format the user uploaded lands locally with its original extension. + Falls back to ``uploads/netlist.asc`` if neither exists — downstream + code will raise a clearer error when it tries to read the missing + file than a ``None`` return would. + """ + for ext in ("asc", "edn"): + p = self.local_dir / "uploads" / f"netlist.{ext}" + if p.exists(): + return p + return self.local_dir / "uploads" / "netlist.asc" + + @property + def taxonomy_dir(self) -> Path: + return self.local_dir / "taxonomy" + + +# --------------------------------------------------------------------------- +# Pipeline context — shared state threaded through all stage functions +# --------------------------------------------------------------------------- + + +@dataclass +class PipelineContext: + """All shared state for a single pipeline run. + + Infrastructure fields are set up once in ``run_pipeline`` before the stage + loop starts. Stage-output fields are written by each stage and read by + later ones. To reorder stages, change ``PIPELINE_STAGES`` below. + """ + + # Infrastructure (set up once before the stage loop) + storage: StorageBackend + user_id: str + project_id: str + ws: PipelineWorkspace + api_logger: ApiLogger + meta: Any # ProjectMeta + min_ver: str # minimum extraction model version for cache freshness + + # Accumulated across all stages + skipped: list[SkippedItem] = field(default_factory=list) + + # Stage outputs — each stage writes here; later stages read + ic_mpns: dict[str, list[str]] = field(default_factory=dict) + passive_mpns: dict[str, list[str]] = field(default_factory=dict) + # Captured BOM Value per passive MPN. Used as a last-resort fallback when + # the MPN column actually contains a value token (e.g. "10uF") — we resolve + # the primary numeric value from here without saving to the shared library. + passive_values: dict[str, str] = field(default_factory=dict) + simple_mpns: dict[str, list[str]] = field(default_factory=dict) + simple_mpn_types: dict[str, str] = field(default_factory=dict) + # Cached purple-parts payload (description, category, subcategory, manufacturer, + # package, ...) keyed by *resolved* MPN. Populated by _resolve_lcsc_codes during + # BOM parse; consumed by passive extraction as a first-pass auto-resolve source + # before falling through to DigiKey. + lcsc_data: dict[str, dict] = field(default_factory=dict) + ref_col: str = "Reference" + mpn_col: str = "Manufacturer Part Number" + patterns: list = field(default_factory=list) # loaded + mutated by passive_extraction + graph: Any | None = None # DesignGraph + report: Any | None = None # ValidationReport + + # Credit-gate state + paused: bool = False + pause_stage: str | None = None + pause_unit_id: str | None = None + pause_last_completed: str | None = None + credits_spent: float = 0.0 + completed_review_refs: set[str] = field(default_factory=set) + # Every IC ref the validation stage plans to review (has datasheet PDF). + # Populated at validation stage start so a pause checkpoint can expose + # what's left. Empty for runs that pause before validation. + all_review_refs: list[str] = field(default_factory=list) + + # Admin-initiated free run: skip credit gate and cost accrual. Every + # API call is still made (and the USD cost is still recorded in logs), + # but nothing is charged to the user's balance. + free: bool = False + + +# --------------------------------------------------------------------------- +# Credit gate — checked before each expensive sub-unit +# --------------------------------------------------------------------------- + + +def _check_credit_gate( + ctx: PipelineContext, stage: str, unit_id: str, estimated_cost_usd: float, +) -> bool: + """Return True if the run can spend ``estimated_cost_usd`` on this unit. + + On insufficient balance, sets ``ctx.paused`` and records where we stopped. + The caller should break out of its loop when this returns False. + """ + if ctx.paused: + return False + # Admin-initiated free runs never hit the balance gate. + if ctx.free: + return True + billing = get_billing() + required_credits = billing.credits_for_api_cost(estimated_cost_usd) + if required_credits <= 0: + return True # Cached / free work — no balance check needed + balance = billing.get_balance(ctx.storage, ctx.user_id) + if balance < required_credits: + ctx.paused = True + ctx.pause_stage = stage + ctx.pause_unit_id = unit_id + return False + return True + + +def _charge_for_logs(ctx: PipelineContext, before_count: int) -> None: + """Charge the user for all API log entries added since ``before_count``. + + Reads the logger's entry list directly — each entry already has + ``cost_usd`` and ``credits_charged`` populated by ``ApiLogger.log``. + + If the user has auto top-up enabled and the charge dropped their + balance below the threshold, fires an off-session top-up attempt. + + Also acts as the worker's cancel gate: after every Claude API call + we re-read the project meta and bail with :class:`CancelRequested` + when the user has requested cancellation. Polling is throttled in + :func:`_cancel_gate_check`, so this is cheap. + """ + # Cancel-gate check first — if the user pressed Cancel, don't spend + # any more on this run. Cheap: throttled to one GCS read per few + # seconds. May raise; the top-level run handler catches and cleans up. + _cancel_gate_check(ctx) + + new_entries = ctx.api_logger.entries[before_count:] + total_credits = sum(float(e.get("credits_charged") or 0) for e in new_entries) + if total_credits <= 0: + return + # Work for this unit is already done — charge the full amount even if + # it exceeds the current balance. The credit gate in + # ``_check_credit_gate`` prevents us from *starting* a new unit once + # the balance is insufficient, so only the unit currently in flight + # (e.g. an IC review) can push the ledger negative. + amount = round(total_credits, 4) + unit_id = new_entries[-1].get("identifier") if new_entries else None + stage = new_entries[-1].get("stage") if new_entries else None + billing = get_billing() + try: + billing.charge( + ctx.storage, ctx.user_id, amount, + reason="pipeline_charge", + run_id=ctx.project_id, + unit_id=f"{stage}:{unit_id}" if stage else None, + allow_overdraft=True, + ) + ctx.credits_spent += amount + broker.publish( + ctx.project_id, "credits_update", + { + "credits_spent": round(ctx.credits_spent, 4), + "balance_after": round(billing.get_balance(ctx.storage, ctx.user_id), 4), + "delta": round(amount, 4), + "stage": stage, + "unit_id": unit_id, + }, + ) + except InsufficientCredits: + # Shouldn't happen because we took min(amount, balance); log and move on. + pass + + # Fire auto top-up if configured. It runs as a background task so the + # pipeline isn't blocked by Stripe round-trips. On failure we publish + # an SSE event so the progress page can show an in-app toast without + # waiting on email delivery. + try: + async def _run_and_notify() -> None: + failure = await billing.maybe_auto_topup(ctx.storage, ctx.user_id) + if failure: + broker.publish(ctx.project_id, "auto_topup_failed", failure) + + asyncio.create_task(_run_and_notify()) + except Exception: + pass + + +def _charge_private_logger(ctx: PipelineContext, private: ApiLogger) -> None: + """Merge a concurrent unit's private ``ApiLogger`` into the shared log and + charge for exactly its entries. + + Concurrent stages (IC extraction, review) give each in-flight unit its own + ``ApiLogger`` so that ``_charge_for_logs``' index slice can't mix one unit's + API calls with another's. This runs synchronously — there is no ``await`` + between capturing ``before`` and the charge — so under asyncio it is atomic: + no other coroutine can append to ``ctx.api_logger.entries`` in that window, + and the slice is exactly this unit's entries. + """ + before = len(ctx.api_logger.entries) + ctx.api_logger.entries.extend(private.entries) + _charge_for_logs(ctx, before) + + +async def _paused_stage_publish(ctx: PipelineContext, stage: str, reason: str) -> None: + broker.publish(ctx.project_id, "step_update", + {"stage": stage, "status": "paused", + "detail": reason}) + + +# --------------------------------------------------------------------------- +# Stage functions — one per UI step +# --------------------------------------------------------------------------- + + +async def _resolve_lcsc_codes(ctx: PipelineContext, bom: dict[str, dict]) -> None: + """Convert LCSC part numbers in `bom` to MPNs via the purple-parts API. + + Mutates `bom` in place: any row whose `mpn` field is empty (but `lcsc` + is set) or whose `mpn` itself looks like an LCSC code gets its `mpn` + field populated from the lookup. Rows that don't resolve are left + untouched — the existing DigiKey/Haiku paths still handle them. + + No-op when purple-parts is not configured (`settings.use_purple_parts`). + """ + if not settings.use_purple_parts: + return + + from backend.services.purple_parts import is_lcsc_code, lookup_lcsc_batch + + # Backstop only: cover the unambiguous case where the dedicated `LCSC` + # column is populated and the MPN slot is empty. The primary path is + # upload-time column-level resolution in routers/projects.py:upload_bom, + # which rewrites the stored BOM before any pipeline run. Mixed BOMs are + # explicitly out of scope — users must pick one representation per + # column, so per-row MPN-shape detection at this point would be noise. + todo: list[tuple[str, str]] = [] + for ref, info in bom.items(): + mpn = (info.get("mpn") or "").strip() + lcsc = (info.get("lcsc") or "").strip() + if not mpn and is_lcsc_code(lcsc): + todo.append((ref, lcsc)) + + if not todo: + return + + unique_codes = sorted({code for _, code in todo}) + broker.publish( + ctx.project_id, "step_update", + {"stage": "bom_parse", "status": "running", + "detail": f"Resolving {len(unique_codes)} LCSC code(s) via purple-parts"}, + ) + + resolved = await lookup_lcsc_batch(unique_codes) + + hits = 0 + for ref, code in todo: + part = resolved.get(code) + if part and part.get("mpn"): + mpn = part["mpn"] + bom[ref]["mpn"] = mpn + # Cache the rich payload keyed by the resolved MPN so downstream + # passive extraction can skip DigiKey when LCSC already has the + # description + category Haiku needs. + ctx.lcsc_data.setdefault(mpn, part) + hits += 1 + + logger.info( + "purple-parts: resolved %d/%d LCSC codes (covered %d BOM refs)", + hits, len(unique_codes), len(todo), + ) + + +async def _stage_bom_parse(ctx: PipelineContext) -> None: + """Stage 1 — Parse BOM and classify components by type.""" + broker.publish(ctx.project_id, "step_update", + {"stage": "bom_parse", "status": "running"}) + + col_map = ctx.meta.bom_columns or {} + ctx.ref_col = col_map.get("reference", "Reference") + ctx.mpn_col = col_map.get("mpn", "Manufacturer Part Number") + + bom_path = ctx.ws.local_path("uploads/bom.csv") + bom = parse_bom(str(bom_path), reference_col=ctx.ref_col, mpn_col=ctx.mpn_col) + + await _resolve_lcsc_codes(ctx, bom) + + for ref, info in sorted(bom.items()): + mpn = info.get("mpn") + if not mpn: + continue + typ = type_for_ref(ref) + if typ == "ic": + ctx.ic_mpns.setdefault(mpn, []).append(ref) + elif typ == "passive": + ctx.passive_mpns.setdefault(mpn, []).append(ref) + val = (info.get("value") or "").strip() + if val and not ctx.passive_values.get(mpn): + ctx.passive_values[mpn] = val + elif typ and typ in SIMPLE_TYPES: + ctx.simple_mpns.setdefault(mpn, []).append(ref) + ctx.simple_mpn_types[mpn] = typ + + proj_svc.update_project( + ctx.storage, ctx.user_id, ctx.project_id, + component_mpns={ + "ic": list(ctx.ic_mpns.keys()), + "passive": list(ctx.passive_mpns.keys()), + "simple": list(ctx.simple_mpns.keys()), + }, + ) + + # Quick netlist parse for net count (used in admin email). Auto-detect + # PADS vs EDIF and honor any sub-design filter the user picked, so the + # email reports the count for the slice the pipeline will actually review. + netlist_path = ctx.ws.netlist_local_path() + _, nets, _ = parse_netlist_any( + str(netlist_path), + known_refs=set(bom.keys()), + include_subdesigns=( + set(ctx.meta.netlist_subdesigns) + if ctx.meta.netlist_subdesigns is not None + else None + ), + ) + + broker.publish(ctx.project_id, "step_update", + {"stage": "bom_parse", "status": "complete", + "detail": f"{len(bom)} refs, {len(ctx.ic_mpns)} ICs, " + f"{len(ctx.simple_mpns)} discrete/simple, {len(ctx.passive_mpns)} passives"}) + + # Notify admin that a pipeline started (fire-and-forget) + from backend.services.email import send_pipeline_started_email + try: + await send_pipeline_started_email( + user_id=ctx.user_id, + project_name=ctx.meta.name, + project_id=ctx.project_id, + num_components=len(bom), + num_nets=len(nets), + num_ics=len(ctx.ic_mpns), + num_passives=len(ctx.passive_mpns), + num_simple=len(ctx.simple_mpns), + ) + except Exception: + pass # send_pipeline_started_email handles errors internally + + +async def _stage_ic_extraction(ctx: PipelineContext) -> None: + """Stage 2 — Extract IC pin tables from datasheets.""" + extracted_dir = ctx.ws.local_path("extracted") + + # Pre-categorize: workspace cache, library cache, or needs extraction + _ic_cache: dict[str, tuple] = {} + _ic_new_count = 0 + for mpn in ctx.ic_mpns: + safe = safe_mpn(mpn) + json_path = extracted_dir / f"{safe}.json" + if json_path.is_file(): + existing = json.loads(json_path.read_text()) + if existing.get("pintable"): + ws_ver = existing.get("model_version", "0.0.0") + if not settings_svc.version_is_stale(ws_ver, ctx.min_ver): + _ic_cache[mpn] = ("workspace",) + continue + lib_key = proj_svc.library_has_extraction(ctx.storage, mpn, min_version=ctx.min_ver) + if lib_key: + _ic_cache[mpn] = ("library", lib_key) + continue + _ic_new_count += 1 + + broker.publish(ctx.project_id, "step_update", + {"stage": "ic_extraction", "status": "running", + "total_new": _ic_new_count}) + + # Phase 1 (sequential, read-only, no API cost): resolve cached MPNs and + # locate datasheet PDFs. Cache-miss MPNs are collected for concurrent + # extraction in Phase 2. + pending: list[tuple[str, str, Path, Path]] = [] # (mpn, safe, json_path, pdf_path) + for mpn, refs in ctx.ic_mpns.items(): + safe = safe_mpn(mpn) + json_path = extracted_dir / f"{safe}.json" + + _cached = _ic_cache.get(mpn) + if _cached: + if _cached[0] == "library": + ctx.storage.download_to_local(_cached[1], json_path) + detail = "already extracted" if _cached[0] == "workspace" else "from library" + broker.publish(ctx.project_id, "step_update", + {"stage": "ic_extraction", "substep": mpn, + "status": "complete", "detail": detail}) + continue + + # Need PDF — check project uploads first, then library + pdf_path = ctx.ws.local_path(f"uploads/datasheets/{safe}.pdf") + if not pdf_path.is_file(): + lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, mpn) + if lib_ds_key: + ctx.storage.download_to_local(lib_ds_key, pdf_path) + else: + ctx.skipped.append(SkippedItem(mpn, "ic_extraction", "No datasheet uploaded")) + broker.publish(ctx.project_id, "step_update", + {"stage": "ic_extraction", "substep": mpn, + "status": "failed", "error": "No datasheet uploaded"}) + continue + pending.append((mpn, safe, json_path, pdf_path)) + + # Phase 2 (concurrent, up to ic_concurrency): extract cache-miss MPNs. + sem = asyncio.Semaphore(settings.ic_concurrency) + + async def _extract_one(mpn: str, safe: str, json_path: Path, pdf_path: Path) -> None: + async with sem: + # Soft gate: once the balance is exhausted, don't *start* new ICs. + # The first unit to trip sets ctx.paused; later units that acquire + # the semaphore bail here, while in-flight units finish + charge. + if ctx.paused: + return + if not _check_credit_gate(ctx, "ic_extraction", mpn, + estimate_stage_cost_usd("ic_extraction")): + await _paused_stage_publish(ctx, "ic_extraction", "out of credits") + return + + # Private logger so concurrent extractions don't interleave their + # API entries — charging slices exactly this IC's calls. + private = ApiLogger(free=ctx.api_logger.free) + try: + broker.publish(ctx.project_id, "step_update", + {"stage": "ic_extraction", "substep": mpn, + "status": "running", "detail": "extracting pintable"}) + + await extraction.extract_pintable( + mpn, str(pdf_path), extracted_dir, + taxonomy_dir=ctx.ws.taxonomy_dir, + api_logger=private, + ) + + # Upload to storage, then copy to library + extracted_key = f"{ctx.ws.prefix}/extracted/{safe}.json" + ctx.storage.upload_from_local(json_path, extracted_key) + proj_svc.save_to_library(ctx.storage, extracted_key, "extracted", f"{safe}.json") + + # Upload source datasheet PDF to library (content-addressed) + store_datasheet(ctx.storage, pdf_path, mpn) + + # Merge this IC's API entries into the shared log and charge — + # post-execution so a crash before the save above would not + # have charged the user. + _charge_private_logger(ctx, private) + ctx.pause_last_completed = f"Extracted {mpn}" + + broker.publish(ctx.project_id, "step_update", + {"stage": "ic_extraction", "substep": mpn, + "status": "complete"}) + + except CancelRequested: + # Cancel aborts the whole run. Preserve billing data for any + # completed calls, then propagate so gather surfaces it. + if private.entries: + ctx.api_logger.entries.extend(private.entries) + raise + except Exception as e: + # Per-IC isolation. Preserve billing data for any calls that + # did complete (logged but, as before, not charged on failure). + if private.entries: + ctx.api_logger.entries.extend(private.entries) + ctx.skipped.append(SkippedItem(mpn, "ic_extraction", str(e))) + broker.publish(ctx.project_id, "step_update", + {"stage": "ic_extraction", "substep": mpn, + "status": "failed", "error": str(e)}) + + results = await asyncio.gather( + *(_extract_one(mpn, safe, json_path, pdf_path) + for mpn, safe, json_path, pdf_path in pending), + return_exceptions=True, + ) + # Surface cancellation so the top-level run handler cleans up. Per-IC + # failures stay isolated (already captured as skipped components above). + for r in results: + if isinstance(r, (asyncio.CancelledError, CancelRequested)): + raise r + + broker.publish(ctx.project_id, "step_update", + {"stage": "ic_extraction", "status": "complete"}) + + +async def _stage_simple_extraction(ctx: PipelineContext) -> None: + """Stage 2.5 — Extract specs for discrete/simple components.""" + if not ctx.simple_mpns: + return + + models_dir = ctx.ws.local_path("models") + + _simple_cache: dict[str, tuple] = {} + _simple_new_count = 0 + for mpn in ctx.simple_mpns: + safe = safe_mpn(mpn) + model_path = models_dir / f"{safe}.json" + if model_path.is_file(): + _simple_cache[mpn] = ("workspace",) + continue + lib_key = proj_svc.library_has_model(ctx.storage, mpn) + if lib_key: + _simple_cache[mpn] = ("library", lib_key) + continue + _simple_new_count += 1 + + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "status": "running", + "total_new": _simple_new_count}) + + for mpn, refs in ctx.simple_mpns.items(): + safe = safe_mpn(mpn) + model_path = models_dir / f"{safe}.json" + + _cached = _simple_cache.get(mpn) + if _cached: + if _cached[0] == "library": + ctx.storage.download_to_local(_cached[1], model_path) + detail = "already extracted" if _cached[0] == "workspace" else "from library" + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "complete", "detail": detail}) + continue + + # Check for uploaded PDF — check project uploads first, then library + pdf_path = ctx.ws.local_path(f"uploads/datasheets/{safe}.pdf") + if not pdf_path.is_file(): + lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, mpn) + if lib_ds_key: + ctx.storage.download_to_local(lib_ds_key, pdf_path) + else: + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "complete", "detail": "no datasheet (optional)"}) + continue + + if not _check_credit_gate(ctx, "simple_extraction", mpn, estimate_stage_cost_usd("simple_extraction")): + await _paused_stage_publish(ctx, "simple_extraction", "out of credits") + return + + try: + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "running", "detail": "extracting specs"}) + + before_count = len(ctx.api_logger.entries) + comp_type = ctx.simple_mpn_types[mpn] + await extraction.extract_specs( + mpn, str(pdf_path), comp_type, models_dir, + taxonomy_dir=ctx.ws.taxonomy_dir, + api_logger=ctx.api_logger, + ) + + # Upload to storage, then copy to library + model_key = f"{ctx.ws.prefix}/models/{safe}.json" + ctx.storage.upload_from_local(model_path, model_key) + proj_svc.save_to_library(ctx.storage, model_key, "models", f"{safe}.json") + + # Upload source datasheet PDF to library (content-addressed) + store_datasheet(ctx.storage, pdf_path, mpn) + + _charge_for_logs(ctx, before_count) + ctx.pause_last_completed = f"Extracted {mpn}" + + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "complete"}) + + except Exception as e: + ctx.skipped.append(SkippedItem(mpn, "simple_extraction", str(e))) + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "failed", "error": str(e)}) + + # DigiKey fallback for simple components without datasheets + if settings.use_digikey: + no_datasheet_mpns = [ + mpn for mpn in ctx.simple_mpns + if mpn not in _simple_cache + and not (models_dir / f"{safe_mpn(mpn)}.json").is_file() + ] + if no_datasheet_mpns: + from backend.services.digikey import fetch_params + + for mpn in no_datasheet_mpns: + safe = safe_mpn(mpn) + model_path = models_dir / f"{safe}.json" + + try: + # Check library first (may have been added during this run) + lib_key = proj_svc.library_has_model(ctx.storage, mpn) + if lib_key: + ctx.storage.download_to_local(lib_key, model_path) + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "complete", "detail": "specs from library"}) + continue + + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "running", "detail": "auto-resolving via DigiKey"}) + + result = await fetch_params(mpn) + if not result.ok or not result.params: + raise RuntimeError(result.error or "No DigiKey parameters") + + comp_type = ctx.simple_mpn_types[mpn] + model = await extraction.auto_resolve_specs( + mpn=mpn, + digikey_params=result.params.parameters, + digikey_category=result.params.category, + digikey_description=result.params.description, + component_type=comp_type, + taxonomy_dir=ctx.ws.taxonomy_dir, + api_logger=ctx.api_logger, + ) + + model_path.write_text(model.model_dump_json(indent=2) + "\n") + + # Upload to storage + library + model_key = f"{ctx.ws.prefix}/models/{safe}.json" + ctx.storage.upload_from_local(model_path, model_key) + proj_svc.save_to_library( + ctx.storage, model_key, "models", f"{safe}.json", + ) + + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "complete", "detail": "auto-resolved via DigiKey"}) + + except Exception as e: + ctx.skipped.append(SkippedItem( + mpn, "simple_digikey_resolve", str(e), + )) + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "substep": mpn, + "status": "failed", "error": str(e)}) + + broker.publish(ctx.project_id, "step_update", + {"stage": "simple_extraction", "status": "complete"}) + + +async def _stage_passive_extraction(ctx: PipelineContext) -> None: + """Stage 3 — Extract passive patterns; DigiKey fallback for unresolved.""" + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", "status": "running"}) + + patterns_dir = ctx.ws.local_path("patterns") + models_dir = ctx.ws.local_path("models") + + # Seed project patterns from library + lib_pattern_keys = proj_svc.list_library_patterns(ctx.storage) + for lib_key in lib_pattern_keys: + filename = lib_key.rsplit("/", 1)[-1] + dest = patterns_dir / filename + if not dest.exists(): + ctx.storage.download_to_local(lib_key, dest) + + ctx.patterns = load_patterns(str(patterns_dir)) if patterns_dir.is_dir() else [] + + unresolved: dict[str, list[str]] = {} + for mpn, refs in ctx.passive_mpns.items(): + if resolve_mpn(mpn, ctx.patterns) is not None: + continue + # Check if specs already extracted in a previous run + safe = safe_mpn(mpn) + # Per-project model already on disk (e.g. the wizard's + # /lcsc/resolve-passive endpoint resolved it before the pipeline + # ran). Trust it — no re-charge, no re-extraction. + if (models_dir / f"{safe}.json").is_file(): + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "complete", + "detail": "specs already resolved"}) + continue + lib_model_key = proj_svc.library_has_passive_model(ctx.storage, mpn) + if lib_model_key: + dest = models_dir / f"{safe}.json" + if not dest.is_file(): + ctx.storage.download_to_local(lib_model_key, dest) + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "complete", + "detail": "specs from library"}) + continue + unresolved[mpn] = refs + + if not unresolved: + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", "status": "complete", + "detail": "all passives already resolved"}) + return + + ds_dir = ctx.ws.local_path("uploads/datasheets") + + # Collect unique datasheets: deduplicate by library source key + # AND by content hash so the same PDF isn't extracted multiple + # times for cousin MPNs stored under different keys. + _seen_lib_keys: set[str] = set() + _seen_hashes: set[str] = set() + passive_pdfs = [] + for mpn in unresolved: + safe = safe_mpn(mpn) + pdf = ds_dir / f"{safe}.pdf" + if not pdf.is_file(): + # Check library for datasheet + lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, mpn, patterns=ctx.patterns) + if lib_ds_key: + if lib_ds_key in _seen_lib_keys: + continue # Same datasheet already queued for another MPN + _seen_lib_keys.add(lib_ds_key) + ctx.storage.download_to_local(lib_ds_key, pdf) + if pdf.is_file(): + h = compute_md5_from_path(pdf) + if h in _seen_hashes: + continue # Duplicate content already queued + _seen_hashes.add(h) + passive_pdfs.append(pdf) + + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", "status": "running", + "total_new": len(passive_pdfs)}) + + # Build set of datasheet blobs that already have a pattern + # so we don't re-extract from a PDF that was already processed. + _extracted_ds_keys: set[str] = set() + for pat in ctx.patterns: + dk = getattr(pat, "datasheet_key", None) or "" + if dk: + _extracted_ds_keys.add(dk) + + for pdf_path in passive_pdfs: + # Skip if all unresolved MPNs are now covered + if not unresolved: + break + + # Skip if this MPN was already resolved by a previously extracted pattern + _safe_unresolved = {safe_mpn(m) for m in unresolved} + if pdf_path.stem not in _safe_unresolved: + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": pdf_path.stem, + "status": "complete", "detail": "resolved by pattern"}) + continue + + # Skip if a pattern was already extracted from this exact + # PDF content (in this run or a previous one) — re-extracting + # would produce the same regex that already failed to match. + _pdf_hash = compute_md5_from_path(pdf_path) + _pdf_blob_key = f"library/datasheets/blobs/{_pdf_hash}.pdf" + if _pdf_blob_key in _extracted_ds_keys: + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": pdf_path.stem, + "status": "complete", + "detail": "pattern already extracted from this PDF"}) + continue + + # Resolve the safe filename back to the original MPN + _trigger_mpn = next( + (m for m in unresolved if safe_mpn(m) == pdf_path.stem), + None, + ) + + if not _check_credit_gate(ctx, "passive_extraction", pdf_path.stem, + estimate_stage_cost_usd("passive_pattern")): + await _paused_stage_publish(ctx, "passive_extraction", "out of credits") + return + + try: + mpn_list = list(unresolved.keys()) + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": pdf_path.stem, + "status": "running", + "detail": "extracting pattern"}) + + before_count = len(ctx.api_logger.entries) + out = await extraction.extract_pattern( + str(pdf_path), mpn_list, patterns_dir, + trigger_mpn=_trigger_mpn, + taxonomy_dir=ctx.ws.taxonomy_dir, + api_logger=ctx.api_logger, + ) + + if out: + # Upload source datasheet PDF to library (content-addressed) + blob_k = store_datasheet(ctx.storage, pdf_path, out.stem) + _extracted_ds_keys.add(blob_k) + + # Write datasheet_key into pattern JSON + pattern_data = json.loads(out.read_text()) + pattern_data["datasheet_key"] = blob_k + out.write_text(json.dumps(pattern_data, indent=2) + "\n") + + # Upload pattern to storage, then copy to library + rel = out.relative_to(ctx.ws.local_dir) + pattern_key = f"{ctx.ws.prefix}/{rel}" + ctx.storage.upload_from_local(out, pattern_key) + proj_svc.save_to_library(ctx.storage, pattern_key, "patterns", out.name) + + # Reload and recheck + ctx.patterns = load_patterns(str(patterns_dir)) + _prev_count = len(unresolved) + still = {m: r for m, r in unresolved.items() + if resolve_mpn(m, ctx.patterns) is None} + _newly_resolved = _prev_count - len(still) + unresolved = still + + _charge_for_logs(ctx, before_count) + ctx.pause_last_completed = f"Pattern from {pdf_path.stem}" + + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": pdf_path.stem, + "status": "complete", + "detail": f"pattern extracted, resolved {_newly_resolved} MPNs" if out else "pattern failed, MPN falls to DigiKey"}) + + except Exception as e: + ctx.skipped.append(SkippedItem(pdf_path.stem, "passive_extraction", str(e))) + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": pdf_path.stem, + "status": "failed", "error": str(e)}) + + # Fallback for still-unresolved passives: DigiKey exact-MPN lookup first, + # then a Haiku-powered value resolver for BOMs where the MPN column holds a + # value token (e.g. "10uF"). Value-based results are per-project only and + # are never written to the shared library. + if unresolved: + still_unresolved = [ + mpn for mpn in unresolved + if not (models_dir / f"{safe_mpn(mpn)}.json").is_file() + ] + if still_unresolved: + from backend.services.digikey import fetch_params + + # By-MPN backstop: passives with no cached LCSC payload (real-MPN + # BOMs not pre-resolved at upload/wizard time, or wizard gaps like + # skipped / errored / out-of-credits) get a reverse catalogue lookup + # so the LCSC branch below can fire for them too. Fail-soft — misses + # simply fall through to the DigiKey path. + _need_lcsc = [m for m in still_unresolved if m not in ctx.lcsc_data] + if _need_lcsc and settings.use_purple_parts: + try: + from backend.services.purple_parts import lookup_mpn_batch + _parts = await lookup_mpn_batch(_need_lcsc) + for _m, _part in _parts.items(): + if _part and _part.get("description"): + ctx.lcsc_data.setdefault(_m, _part) + except Exception: + logger.warning("purple-parts by-mpn backstop failed", exc_info=True) + + # Resolve each passive concurrently (bounded by ic_concurrency), + # mirroring _stage_ic_extraction: each in-flight unit uses a private + # ApiLogger so concurrent API entries don't interleave, and + # _charge_private_logger bills exactly that unit's calls atomically. + sem = asyncio.Semaphore(settings.ic_concurrency) + + async def _resolve_one(mpn: str) -> None: + async with sem: + # Soft gate: once the balance is exhausted, don't *start* new + # units; in-flight ones finish + charge (bounded overdraft). + if ctx.paused: + return + safe = safe_mpn(mpn) + model_path = models_dir / f"{safe}.json" + + # Check library first — free, no charge, no gate. + lib_key = proj_svc.library_has_passive_model(ctx.storage, mpn) + if lib_key: + ctx.storage.download_to_local(lib_key, model_path) + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "complete", + "detail": "specs from library"}) + return + + if not _check_credit_gate(ctx, "passive_extraction", mpn, + estimate_stage_cost_usd("digikey_resolve")): + await _paused_stage_publish(ctx, "passive_extraction", "out of credits") + return + + # Private logger so concurrent resolves don't interleave their + # API entries — charging slices exactly this passive's calls. + private = ApiLogger(free=ctx.api_logger.free) + model = None + resolved_via: str | None = None + first_error: str | None = None + try: + # --- LCSC (purple-parts) first: the description carries + # value/voltage/dielectric/tolerance/package for typical + # passives. Synthesize a DigiKey-shaped payload and reuse + # auto_resolve_specs. + lcsc = ctx.lcsc_data.get(mpn) + if lcsc and lcsc.get("description"): + try: + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "running", + "detail": "auto-resolving via LCSC"}) + synth_category = " / ".join( + p for p in (lcsc.get("category"), lcsc.get("subcategory")) if p + ) + synth_params: dict[str, str] = {} + if lcsc.get("package"): + synth_params["Package / Case"] = lcsc["package"] + if lcsc.get("manufacturer"): + synth_params["Manufacturer"] = lcsc["manufacturer"] + model = await extraction.auto_resolve_specs( + mpn=mpn, + digikey_params=synth_params, + digikey_category=synth_category or None, + digikey_description=lcsc["description"], + component_type="passive", + taxonomy_dir=ctx.ws.taxonomy_dir, + api_logger=private, + ) + if model is not None: + resolved_via = "lcsc" + except Exception as e: + first_error = str(e) + + # --- DigiKey: only trusted on an exact MPN hit ---------- + if model is None and settings.use_digikey: + try: + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "running", + "detail": "auto-resolving via DigiKey"}) + result = await fetch_params(mpn) + if result.ok and result.params: + model = await extraction.auto_resolve_specs( + mpn=mpn, + digikey_params=result.params.parameters, + digikey_category=result.params.category, + digikey_description=result.params.description, + component_type="passive", + taxonomy_dir=ctx.ws.taxonomy_dir, + api_logger=private, + ) + resolved_via = "digikey" + else: + first_error = result.error or "no DigiKey parameters" + except Exception as e: + first_error = str(e) + + # --- Value fallback: parse the BOM value via Haiku ------ + if model is None: + bom_value = ctx.passive_values.get(mpn, "").strip() + refs = ctx.passive_mpns.get(mpn, []) + pref_match = re.match(r"^[A-Za-z]+", refs[0]) if refs else None + ref_prefix = pref_match.group(0).upper() if pref_match else "" + if bom_value and ref_prefix in {"C", "R", "L", "FB"}: + try: + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "running", + "detail": f"resolving from BOM value {bom_value!r}"}) + model = await extraction.resolve_from_value( + mpn=mpn, value=bom_value, + ref_prefix=ref_prefix, + component_type="passive", + taxonomy_dir=ctx.ws.taxonomy_dir, + api_logger=private, + ) + resolved_via = "value" + except Exception as e: + first_error = first_error or str(e) + + if model is None: + err = first_error or "no LCSC/DigiKey hit and no usable BOM value" + # Preserve any logged-but-failed calls (not charged). + if private.entries: + ctx.api_logger.entries.extend(private.entries) + ctx.skipped.append(SkippedItem(mpn, "passive_resolve", err)) + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "failed", + "error": err}) + return + + model_path.write_text(model.model_dump_json(indent=2) + "\n") + + # Always upload to the project's own storage so graph build picks it up + model_key = f"{ctx.ws.prefix}/models/{safe}.json" + ctx.storage.upload_from_local(model_path, model_key) + + # Share to the global library when the resolution is backed + # by a real MPN (DigiKey or LCSC). Value-based tokens like + # "10uF" are not real MPNs and would poison lookups for + # every future project. + if resolved_via in ("digikey", "lcsc"): + proj_svc.save_to_library( + ctx.storage, model_key, "passives", f"{safe}.json", + ) + + # Merge this passive's API entries into the shared log and + # charge — post-save so a crash before the writes above + # would not have charged the user. + _charge_private_logger(ctx, private) + ctx.pause_last_completed = f"Resolved {mpn}" + + detail = { + "lcsc": "auto-resolved via LCSC", + "digikey": "auto-resolved via DigiKey", + "value": "resolved from BOM value (not saved to library)", + }.get(resolved_via, "resolved") + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "complete", + "detail": detail}) + + except CancelRequested: + # Cancel aborts the whole run. Preserve billing data for + # any completed calls, then propagate so gather surfaces it. + if private.entries: + ctx.api_logger.entries.extend(private.entries) + raise + except Exception as e: + # Per-passive isolation. Preserve billing data for any + # calls that did complete (logged but not charged on failure). + if private.entries: + ctx.api_logger.entries.extend(private.entries) + ctx.skipped.append(SkippedItem(mpn, "passive_resolve", str(e))) + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "failed", + "error": str(e)}) + + results = await asyncio.gather( + *(_resolve_one(m) for m in still_unresolved), + return_exceptions=True, + ) + # Surface cancellation so the top-level run handler cleans up. Per- + # passive failures stay isolated (captured as skipped above). + for r in results: + if isinstance(r, (asyncio.CancelledError, CancelRequested)): + raise r + + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", "status": "complete"}) + + +async def _stage_graph_build(ctx: PipelineContext) -> None: + """Stage 4 — Build the design graph from netlist, BOM, and extracted data.""" + broker.publish(ctx.project_id, "step_update", + {"stage": "graph_build", "status": "running"}) + + bom_path = ctx.ws.local_path("uploads/bom.csv") + netlist_path = ctx.ws.netlist_local_path() + extracted_dir = ctx.ws.local_path("extracted") + patterns_dir = ctx.ws.local_path("patterns") + models_dir = ctx.ws.local_path("models") + + ctx.graph = build_graph( + str(netlist_path), + str(bom_path), + str(extracted_dir), + str(patterns_dir), + str(models_dir), + reference_col=ctx.ref_col, + mpn_col=ctx.mpn_col, + skipped=ctx.skipped, + include_subdesigns=( + set(ctx.meta.netlist_subdesigns) + if ctx.meta.netlist_subdesigns is not None + else None + ), + ) + + graph_path = ctx.ws.local_path("design_graph.json") + graph_path.write_text(ctx.graph.model_dump_json(indent=2) + "\n") + + broker.publish(ctx.project_id, "step_update", + {"stage": "graph_build", "status": "complete", + "detail": f"{len(ctx.graph.components)} components, {len(ctx.graph.nets)} nets"}) + + +async def _stage_validation(ctx: PipelineContext) -> None: + """Stage 6 — BOM summary, derating, then per-IC direct datasheet review. + + BOM summary and derating are quick deterministic steps that run first; + they're not separate UI steps but they depend on the graph being ready. + """ + ds_dir = ctx.ws.local_path("uploads/datasheets") + extracted_dir = ctx.ws.local_path("extracted") + graph_path = ctx.ws.local_path("design_graph.json") + + # BOM summary (collate — no AI, no SSE event) + ds_mpns: set[str] = set() + if ds_dir.is_dir(): + for pdf in ds_dir.glob("*.pdf"): + ds_mpns.add(pdf.stem) + # Also include datasheets available in the global library + # (covers resolved MPNs whose PDFs weren't downloaded to workspace) + for comp in ctx.graph.components.values(): + if comp.mpn and comp.mpn not in ds_mpns: + if proj_svc.library_has_datasheet(ctx.storage, comp.mpn, patterns=ctx.patterns): + ds_mpns.add(comp.mpn) + descriptions = _ic_descriptions(extracted_dir) + bom_rows = build_bom_summary( + ctx.graph, datasheet_mpns=ds_mpns, descriptions=descriptions, + ) + bom_summary_path = ctx.ws.local_path("bom_summary.json") + bom_summary_path.write_text(json.dumps(bom_rows, indent=2) + "\n") + + # Capacitor voltage derating (no AI, no SSE event) + derating_rows = build_derating_table(ctx.graph) + derating_path = ctx.ws.local_path("derating.json") + derating_path.write_text(json.dumps(derating_rows, indent=2) + "\n") + + # Ensure all IC datasheet PDFs are available locally for review. + # Cached ICs skipped pintable extraction, so their PDFs may not + # have been downloaded yet. + for mpn in ctx.ic_mpns: + safe = safe_mpn(mpn) + pdf_path = ds_dir / f"{safe}.pdf" + if not pdf_path.is_file(): + lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, mpn) + if lib_ds_key: + ctx.storage.download_to_local(lib_ds_key, pdf_path) + + # Snapshot the full review queue so pause checkpoints can show what's left. + # Mirrors the filter in validate_design_async: ICs with a PDF available. + planned_refs: list[str] = [] + for ref, comp in ctx.graph.components.items(): + if comp.component_type != ComponentType.IC: + continue + mpn = comp.mpn or comp.value + if not mpn: + continue + if (ds_dir / f"{safe_mpn(mpn)}.pdf").is_file(): + planned_refs.append(ref) + ctx.all_review_refs = sorted(planned_refs, key=natural_sort_key) + + broker.publish(ctx.project_id, "step_update", + {"stage": "validation", "status": "running"}) + + report_path = ctx.ws.local_path("report.json") + + async def on_validation_progress(ref: str, turn: int, tool: str, detail: str): + if tool == "error": + broker.publish(ctx.project_id, "step_update", + {"stage": "validation", "substep": ref, + "status": "failed", "detail": detail}) + return + is_done = tool in ("submit_review", "skipped") + broker.publish(ctx.project_id, "step_update", + {"stage": "validation", "substep": ref, + "status": "complete" if is_done else "running", + "detail": detail if is_done else tool}) + + async def on_ic_error(ref: str, exc: BaseException) -> None: + ctx.skipped.append(SkippedItem( + ref, "validation", f"{type(exc).__name__}: {exc}", + )) + + async def before_ic(ref: str) -> bool: + # Per-IC credit gate — review cost is the biggest single unit. + # Include the post-review normalize pass so we don't run out of + # margin between the two halves of a single IC's work. + ic_cost = estimate_stage_cost_usd("review") + if settings.normalize_findings_enabled: + ic_cost += estimate_stage_cost_usd("normalize") + if not _check_credit_gate(ctx, "validation", ref, ic_cost): + await _paused_stage_publish(ctx, "validation", "out of credits") + return False + return True + + async def on_ic_done(ref: str, result: Any, private: ApiLogger | None = None) -> None: + # Charge for exactly this IC's API calls (its private logger), merging + # them into the shared log. Concurrency-safe: the charge slice can't + # pick up another in-flight IC's entries. + if private is not None: + _charge_private_logger(ctx, private) + ctx.completed_review_refs.add(ref) + ctx.pause_last_completed = f"Reviewed {ref}" + + async def on_dedupe_done(private: ApiLogger | None = None) -> None: + # The cross-IC dedup is a single end-of-run LLM call; charge it like a + # per-IC unit. Post-charge (no pre-gate): by the time all ICs are + # reviewed the run isn't paused, and one Haiku-class call is within the + # bounded-overdraft tolerance already used for in-flight units. + if private is not None: + _charge_private_logger(ctx, private) + + # Resume-aware: skip ICs that were already reviewed in a previous pass + ctx.report = await validate_design_async( + str(graph_path), + str(report_path), + str(extracted_dir), + pdf_dir=str(ds_dir), + on_progress=on_validation_progress, + api_logger=ctx.api_logger, + storage=ctx.storage, + skip_refs=set(ctx.completed_review_refs), + before_ic=before_ic, + on_ic_done=on_ic_done, + on_ic_error=on_ic_error, + on_dedupe_done=on_dedupe_done, + project_prefix=proj_svc.project_prefix(ctx.user_id, ctx.project_id), + run_meta={"git_commit": _git_commit()}, + ) + + if ctx.paused: + return + + broker.publish(ctx.project_id, "step_update", + {"stage": "validation", "status": "complete"}) + + +# --------------------------------------------------------------------------- +# Stage registry — reorder entries here to change pipeline execution order +# --------------------------------------------------------------------------- + + +@dataclass +class StageSpec: + """Metadata + function reference for a single pipeline stage.""" + stage_id: str + title: str + fn: Callable[[PipelineContext], Awaitable[None]] + + +PIPELINE_STAGES: list[StageSpec] = [ + StageSpec("bom_parse", "Parse BOM", _stage_bom_parse), + StageSpec("ic_extraction", "IC Datasheet Extraction", _stage_ic_extraction), + StageSpec("simple_extraction", "Component Specs Extraction", _stage_simple_extraction), + StageSpec("passive_extraction", "Passive Pattern Extraction", _stage_passive_extraction), + StageSpec("graph_build", "Build Design Graph", _stage_graph_build), + StageSpec("validation", "Review Design", _stage_validation), +] + + +# --------------------------------------------------------------------------- +# Pipeline +# --------------------------------------------------------------------------- + + +async def run_pipeline( + storage: StorageBackend, user_id: str, project_id: str, + *, + resume: bool = False, + free: bool = False, +) -> None: + """Run the full pipeline for a project. + + Iterates through ``PIPELINE_STAGES`` in order. If a stage sets + ``ctx.paused = True`` (credit gate tripped), the loop exits early and + the project is left in ``paused_insufficient_credits`` with a + checkpoint so it can be resumed later. + + When ``resume=True``, prior completed review refs and spent credits are + restored from the project's ``pause_checkpoint`` so completed work is + skipped on the next pass. + + When ``free=True`` (admin-initiated rerun), every call runs through + ``ApiLogger(free=True)`` so ``credits_charged`` is zeroed, the credit + gate is bypassed, and ``meta.total_cost_usd`` is preserved rather than + incremented. The raw Anthropic cost is still captured in log entries. + """ + try: + meta = proj_svc.get_project(storage, user_id, project_id) + if not meta: + raise ValueError(f"Project {project_id} not found") + + bom_key = proj_svc.get_bom_key(storage, user_id, project_id) + netlist_key = proj_svc.get_netlist_key(storage, user_id, project_id) + + if not bom_key or not netlist_key: + proj_svc.update_project(storage, user_id, project_id, status="error", + pipeline_state={"error": "Missing BOM or netlist"}) + broker.publish(project_id, "pipeline_error", + {"error": "Missing BOM or netlist"}) + return + + api_logger = ApiLogger(free=free) + + # Worker boot transition: queued → running, gen-match enforced so + # two concurrent worker boots can't both progress past this line. + # Tolerate already-running for resume from a previously-killed + # worker (rare, but safe). + try: + proj_svc.transition_status( + storage, user_id, project_id, + from_status={proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING}, + to_status=proj_svc.STATUS_RUNNING, + pause_checkpoint=None, pause_reason=None, + cancel_requested=False, + ) + except proj_svc.StatusConflict: + # Project moved to a terminal state (cancelled/error/complete) + # before this worker booted — nothing more to do. + logger.warning("worker booted into non-queued project %s; exiting", project_id) + return + + async with PipelineWorkspace(storage, user_id, project_id) as ws: + min_ver = settings_svc.get_min_model_version(storage) + ctx = PipelineContext( + storage=storage, + user_id=user_id, + project_id=project_id, + ws=ws, + api_logger=api_logger, + meta=meta, + min_ver=min_ver, + free=free, + ) + + # On resume: carry over prior per-IC review completion so + # validate_design_async skips ICs we've already paid for. + if resume and meta.completed_review_refs: + ctx.completed_review_refs = set(meta.completed_review_refs) + ctx.credits_spent = float(meta.credits_spent or 0) + + for spec in PIPELINE_STAGES: + await spec.fn(ctx) + # Flush api logs at every stage boundary so a preempted + # worker (Cloud Run scale-in, OOM, manual cancel between + # stages) doesn't lose billing data. + try: + api_logger.flush(storage, user_id, project_id) + except Exception: + logger.exception("api_logs flush failed at stage boundary") + if ctx.paused: + break + + # Write API call logs to project storage regardless of state + log_jsonl = api_logger.to_jsonl() + if log_jsonl: + log_path = ws.local_path("api_logs.jsonl") + log_path.write_text(log_jsonl) + + # --- PipelineWorkspace exit uploads results --- + + skipped_dicts = [s.to_dict() for s in ctx.skipped] if ctx.skipped else None + # Free admin reruns preserve prior spend: the Anthropic cost is + # still real, but it shouldn't surface as user-borne cost. + if ctx.free: + project_cost = float(meta.total_cost_usd or 0) + else: + project_cost = total_cost(api_logger.entries) + float(meta.total_cost_usd or 0) + + if ctx.paused: + pending_refs = [ + r for r in ctx.all_review_refs + if r not in ctx.completed_review_refs + ] + checkpoint = { + "paused_at": ctx.pause_unit_id, + "paused_stage": ctx.pause_stage, + "last_completed_label": ctx.pause_last_completed, + "completed_review_refs": sorted(ctx.completed_review_refs, key=natural_sort_key), + "pending_review_refs": pending_refs, + } + proj_svc.update_project( + storage, user_id, project_id, + status="paused_insufficient_credits", + skipped_components=skipped_dicts or None, + total_cost_usd=project_cost, + credits_spent=ctx.credits_spent, + pause_checkpoint=checkpoint, + pause_reason="insufficient_credits", + completed_review_refs=sorted(ctx.completed_review_refs, key=natural_sort_key), + ) + broker.publish(project_id, "pipeline_paused", + {"reason": "insufficient_credits", + "last_completed": ctx.pause_last_completed, + "stage": ctx.pause_stage, + "unit_id": ctx.pause_unit_id, + "completed_review_refs": sorted(ctx.completed_review_refs, key=natural_sort_key), + "pending_review_refs": pending_refs}) + + # Fire-and-forget paused email + from backend.services.email import send_pipeline_paused_email + from backend.services.cost_estimator import estimate_pipeline_cost + try: + balance = get_billing().get_balance(storage, user_id) + # Re-estimate against current library state so the email + # shows remaining work, not the original pre-run total. + needed_low = 0.0 + try: + remaining = estimate_pipeline_cost(storage, user_id, project_id) + needed_low = max(0.0, remaining.credits_low - max(0.0, balance)) + except Exception: + pass + await send_pipeline_paused_email( + user_id=user_id, + project_name=meta.name, + project_id=project_id, + last_completed=ctx.pause_last_completed, + stage=ctx.pause_stage, + balance=balance, + credits_needed_low=needed_low, + ) + except Exception: + pass + return + + report_summary = ctx.report.summary if ctx.report else {} + proj_svc.update_project( + storage, user_id, project_id, + status="complete", + summary=report_summary, + skipped_components=skipped_dicts or None, + total_cost_usd=project_cost, + credits_spent=ctx.credits_spent, + pause_checkpoint=None, pause_reason=None, + completed_review_refs=sorted(ctx.completed_review_refs), + ) + + broker.publish(project_id, "pipeline_complete", + {"summary": report_summary, + "skipped": skipped_dicts or []}) + + # Send email notification (fire-and-forget) + from backend.services.email import send_report_ready_email + try: + await send_report_ready_email( + user_id=user_id, + project_name=meta.name, + project_id=project_id, + summary=report_summary, + total_cost_usd=project_cost, + ) + except Exception: + pass # send_report_ready_email handles errors internally + + except (asyncio.CancelledError, CancelRequested): + # CancelRequested fires from the cancel gate inside + # _charge_for_logs after the user clicks Cancel. + # asyncio.CancelledError can also arrive during local-dev + # subprocess shutdown (SIGTERM). Both are handled the same way. + try: + proj_svc.transition_status( + storage, user_id, project_id, + from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED}, + to_status=proj_svc.STATUS_CANCELLED, + pipeline_state={"error": "Pipeline cancelled by user"}, + cancel_requested=False, + ) + except proj_svc.StatusConflict: + pass + broker.publish(project_id, "pipeline_cancelled", {"error": "Pipeline cancelled by user"}) + # Last-mile flush so partial billing is captured. + try: + api_logger.flush(storage, user_id, project_id) # type: ignore[has-type] + except Exception: + pass + + except Exception as e: + logger.exception("Pipeline run crashed for project %s", project_id) + try: + proj_svc.transition_status( + storage, user_id, project_id, + from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED}, + to_status=proj_svc.STATUS_ERROR, + pipeline_state={"error": str(e)}, + cancel_requested=False, + ) + except proj_svc.StatusConflict: + pass + broker.publish(project_id, "pipeline_error", {"error": str(e)}) + try: + api_logger.flush(storage, user_id, project_id) # type: ignore[has-type] + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Regen Pipeline (graph + selected stages only) +# --------------------------------------------------------------------------- + + +async def run_regen_pipeline( + storage: StorageBackend, user_id: str, project_id: str, stages: list[str] +) -> None: + """Rebuild the design graph and regenerate only the requested stages. + + Valid stages: "derating". Graph build always runs first. + BOM summary is always regenerated since it depends on the graph and is cheap. + """ + try: + meta = proj_svc.get_project(storage, user_id, project_id) + if not meta: + raise ValueError(f"Project {project_id} not found") + + # Regen is admin-initiated — run in free mode so log entries record + # `credits_charged: 0` and don't surface as user-borne cost. + api_logger = ApiLogger(free=True) + + try: + proj_svc.transition_status( + storage, user_id, project_id, + from_status={proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING}, + to_status=proj_svc.STATUS_RUNNING, + cancel_requested=False, + ) + except proj_svc.StatusConflict: + logger.warning("regen worker booted into non-queued project %s; exiting", project_id) + return + + async with PipelineWorkspace(storage, user_id, project_id) as ws: + bom_path = ws.local_path("uploads/bom.csv") + netlist_path = ws.netlist_local_path() + extracted_dir = ws.local_path("extracted") + patterns_dir = ws.local_path("patterns") + models_dir = ws.local_path("models") + + col_map = meta.bom_columns or {} + ref_col = col_map.get("reference", "Reference") + mpn_col = col_map.get("mpn", "Manufacturer Part Number") + + # ------------------------------------------------------------------ + # Rebuild Graph (always) + # ------------------------------------------------------------------ + broker.publish(project_id, "step_update", + {"stage": "graph_build", "status": "running"}) + + graph = build_graph( + str(netlist_path), + str(bom_path), + str(extracted_dir), + str(patterns_dir), + str(models_dir), + reference_col=ref_col, + mpn_col=mpn_col, + include_subdesigns=( + set(meta.netlist_subdesigns) + if meta.netlist_subdesigns is not None + else None + ), + ) + + graph_path = ws.local_path("design_graph.json") + graph_path.write_text(graph.model_dump_json(indent=2) + "\n") + + broker.publish(project_id, "step_update", + {"stage": "graph_build", "status": "complete", + "detail": f"{len(graph.components)} components, {len(graph.nets)} nets"}) + + # ------------------------------------------------------------------ + # BOM Summary (always — cheap, depends on graph) + # ------------------------------------------------------------------ + patterns = load_patterns(str(patterns_dir)) if patterns_dir.is_dir() else [] + ds_dir = ws.local_path("uploads/datasheets") + ds_mpns: set[str] = set() + if ds_dir.is_dir(): + for pdf in ds_dir.glob("*.pdf"): + ds_mpns.add(pdf.stem) + for comp in graph.components.values(): + if comp.mpn and comp.mpn not in ds_mpns: + if proj_svc.library_has_datasheet(storage, comp.mpn, patterns=patterns): + ds_mpns.add(comp.mpn) + descriptions = _ic_descriptions(ws.local_path("extracted")) + bom_rows = build_bom_summary( + graph, datasheet_mpns=ds_mpns, descriptions=descriptions, + ) + bom_summary_path = ws.local_path("bom_summary.json") + bom_summary_path.write_text(json.dumps(bom_rows, indent=2) + "\n") + + # ------------------------------------------------------------------ + # Derating (if requested) + # ------------------------------------------------------------------ + if "derating" in stages: + derating_rows = build_derating_table(graph) + derating_path = ws.local_path("derating.json") + derating_path.write_text(json.dumps(derating_rows, indent=2) + "\n") + + # Write API call logs + log_jsonl = api_logger.to_jsonl() + if log_jsonl: + log_path = ws.local_path("api_logs.jsonl") + log_path.write_text(log_jsonl) + + # --- PipelineWorkspace exit uploads results --- + + # Regen is admin-initiated and runs free to the user: preserve the + # existing total_cost_usd (the API cost was still incurred by + # Anthropic, but it shouldn't appear as user spend). + proj_svc.update_project( + storage, user_id, project_id, + status="complete", + ) + + broker.publish(project_id, "pipeline_complete", + {"summary": meta.summary or {}, + "regen_stages": stages}) + + except (asyncio.CancelledError, CancelRequested): + try: + proj_svc.transition_status( + storage, user_id, project_id, + from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED}, + to_status=proj_svc.STATUS_CANCELLED, + pipeline_state={"error": "Regen cancelled"}, + cancel_requested=False, + ) + except proj_svc.StatusConflict: + pass + broker.publish(project_id, "pipeline_cancelled", {"error": "Regen cancelled"}) + + except Exception as e: + logger.exception("Regen pipeline crashed for project %s", project_id) + try: + proj_svc.transition_status( + storage, user_id, project_id, + from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED}, + to_status=proj_svc.STATUS_ERROR, + pipeline_state={"error": str(e)}, + cancel_requested=False, + ) + except proj_svc.StatusConflict: + pass + broker.publish(project_id, "pipeline_error", {"error": str(e)}) + + +# Regen runs through the same Cloud Run Job worker as a full pipeline +# run; the API enqueues it via :mod:`backend.services.job_runner`. diff --git a/backend/services/projects.py b/backend/services/projects.py new file mode 100644 index 0000000..9a229c7 --- /dev/null +++ b/backend/services/projects.py @@ -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 diff --git a/backend/services/purple_parts.py b/backend/services/purple_parts.py new file mode 100644 index 0000000..0ef24f3 --- /dev/null +++ b/backend/services/purple_parts.py @@ -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 diff --git a/backend/services/storage.py b/backend/services/storage.py new file mode 100644 index 0000000..5c40bcf --- /dev/null +++ b/backend/services/storage.py @@ -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}" diff --git a/backend/services/storage_gcs.py b/backend/services/storage_gcs.py new file mode 100644 index 0000000..85d3be5 --- /dev/null +++ b/backend/services/storage_gcs.py @@ -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") diff --git a/backend/services/survey.py b/backend/services/survey.py new file mode 100644 index 0000000..4eb5d1c --- /dev/null +++ b/backend/services/survey.py @@ -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 diff --git a/backend/services/validation.py b/backend/services/validation.py new file mode 100644 index 0000000..119f524 --- /dev/null +++ b/backend/services/validation.py @@ -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() diff --git a/backend/skills_manifest.json b/backend/skills_manifest.json new file mode 100644 index 0000000..6de5dbd --- /dev/null +++ b/backend/skills_manifest.json @@ -0,0 +1,18 @@ +{ + "default_model_version": "1.4.0", + "extract-pintable": { + "skill_id": "skill_013cTQFk8bqwJVemreNihQRW", + "latest_version": "1777167199421424", + "display_title": "Extract Pin Table" + }, + "extract-pattern": { + "skill_id": "skill_0195iVb55HeQgKHFkePC56hP", + "latest_version": "1777167200857394", + "display_title": "Extract Passive Pattern" + }, + "extract-specs": { + "skill_id": "skill_016sqcgvuVea95Nb4uJYBj7h", + "latest_version": "1777167202182784", + "display_title": "Extract Component Specs" + } +} diff --git a/docs/review-false-positives.md b/docs/review-false-positives.md new file mode 100644 index 0000000..990ca80 --- /dev/null +++ b/docs/review-false-positives.md @@ -0,0 +1,168 @@ +# Review false positives — case log + +Real false positives caught in the wild, with the root cause and the +reviewer-prompt change (or open work) that addresses them. Use this as +the seed corpus when evaluating future prompt edits — a change is only +worth landing if it would have prevented or downgraded one of these +without regressing the true findings. + +## U1-002 — "5V DC on RF out via L5 damages internal DC block" + +**Project:** `434e247dfa98` (prod, 2026-05-26), version 2.3.1. +**IC:** U1, CMD263P3 (5–11 GHz LNA). +**Topology:** L5 (RF choke) connects `+5V` to U1 pin 11 (RF out). C6 +(broadband 0.1 µF) shunts the same node to GND. J2 (RF output coax) +sits on the same node. Pin 11 is documented as "DC blocked and 50 Ω +matched" internally. + +**What the reviewer flagged (ERROR):** "DC bias being applied to an +internally DC-blocked RF output … may damage it or degrade RF +performance." + +**Why it's wrong:** this is a textbook **bias-T**. The L+C network +exists to inject DC onto the coax to power a downstream active device +(active antenna / external LNA / mixer) through the same cable that +carries the RF signal back. The chip's *internal* DC block is exactly +the feature that makes bias-T safe: it isolates the LNA's RF stage +from the externally applied DC. The internal block cap is rated +against the chip's package abs-max (≥ Vdd abs max = 5 V here), so 5 V +across it is a non-event. + +**Root cause:** the reviewer correctly identified an unusual topology +(DC rail on an RF pin) and correctly read the datasheet ("DC blocked +internally"), but never connected the two. Two reasoning gaps: +1. **No harm-pathway numbers.** The `why` was pure speculation — + "*may* damage", "*may* degrade" — with no abs-max quoted, no + voltage stress computed. If the reviewer had been forced to write + the inequality (e.g. "internal DC-block cap rated for X V, sees Y V + → Y > X"), it would have either produced that proof or dropped the + claim. +2. **No "what is this part for" step.** The reviewer asked "what does + L5 do?" and answered "it puts 5 V on the RF pin" — true, but + incomplete. The next inference ("the DC doesn't reach the chip + because of the internal block, so it must be powering something + downstream of J2") never happened. + +**Prompt change landed:** new "ERROR requires a concrete harm pathway" +section in `backend/pinscopex/validate.py:SYSTEM_PROMPT`. Forces every +ERROR that alleges damage / abs-max violation / stress to name the +stressed component, the actual voltage/current on it, the datasheet +limit, and the inequality between the two. Hedged language without +numbers is no longer enough for ERROR — it must demote to WARNING. +Also adds an explicit note that *internal* components share the chip's +package abs-max, so external stress within the pin abs-max cannot +damage them by definition. + +**Expected effect on U1-002:** demotes to WARNING at worst (the +reviewer can no longer write "may damage" without quantifying the +stress on the internal cap), or drops entirely once the reviewer +recognizes the 5 V is below pin abs-max. + +## U1-001 — same bias-T, same IC, new project (landed: role-of-part step) + +**Project:** `13730c6991e3` (staging, 2026-05-26), version 2.3.1. +**IC/topology:** identical to U1-002 above — CMD263P3, L5 bias-T on +pin 11 (RF out), C6 shunt, J2 coax. Different project, same false +positive class. + +**Why harm-pathway alone wasn't enough:** the reviewer now cites +numbers (the gate's letter is satisfied), but the inequality is +bogus on two counts: +1. Wrong pin's abs-max — "places the RF output node at 5.0V — equal + to the absolute maximum **Vdd** rating (5.0V)" cites Vdd's limit + (pin 14) against pin 11 (RF out). +2. `=` is not `>` — abs-max is the don't-exceed line; *at* abs-max is + not damage. + +**Trace evidence:** the reviewer reached the right premise on its +own at turn 2 — *"L5 is an RF choke connecting RF_out to +5V. This +is a DC bias injection topology — but the datasheet says pin 11 is +'DC blocked and 50 ohm matched' internally"* — then dropped the +inference. The next step ("if the DC doesn't reach the chip, what +*is* it powering?") never happened. Both halves of the proof were +named in the same sentence; only the conclusion was missing. + +**Prompt changes landed:** +1. New "Identify the role of each external part before judging it" + section in `SYSTEM_PROMPT`, positioned before the "Net names" / + "Cross-IC interface" sections. Forces a four-step derivation + (pin behavior → part class → where the other end goes → role) + per external component, from first principles. Explicitly: when + a documented pin characteristic *prevents* the surface-reading + interaction (DC-blocked pin, AC-coupled pin, …), the part is + serving the rest of the circuit, not the chip. +2. New "Budget per concern: at most two follow-up tool calls" + section — caps deep-dive on one concern to two queries, demotes + to WARNING with `Unverified:` rather than burning turns. Stops + the death-spiral pattern where one suspect finding starves the + rest of the IC review. +3. Harm-pathway gate tightened: (a) abs-max number must come from + the *same pin's* abs-max row, not a different pin's; (b) strict + inequality (`>`, not `≥`) — equal-to-abs-max is at most WARNING. +4. `_MAX_REVIEW_TURNS` raised from 8 to 10 to absorb the role step + without truncating the rest of the review. + +**Expected effect on U1-001:** drops entirely once the reviewer +states "L5 + DC-blocked pin 11 + downstream J2 → bias-T powering a +downstream load". If the role step is skipped for any reason, the +tightened gate still demotes to WARNING (Vdd abs-max is no longer +valid as the limit for pin 11; `=` is no longer enough). + +**Open follow-ups:** +- Re-run this project (`/restart` admin path) to confirm U1-001 + drops or demotes. Add the resulting trace turn count as a sanity + check that 10 turns is enough. +- Watch the next 2–3 production runs for *regressions* — the role + step adds one reasoning pass per external part and could in + theory cause the reviewer to over-explain valid concerns as + WARNING. If any true ERROR demotes incorrectly, log it here. + +## Historical: "what is this part for?" reasoning step (now landed above) + +The harm-pathway requirement catches U1-002 by raising the bar on +ERROR. The deeper fix — and the one that catches the whole *family* of +unusual-but-correct RF topologies (bias-T, AC coupling, matching +networks, baluns, π/T attenuators) — is a forced reasoning step: +**for every external part on a chip pin, state what role it plays in +the design *before* judging whether it's correct.** + +The chain that kills U1-002 directly: + +> L5 connects +5 V to pin 11. Pin 11 is internally DC-blocked. +> Therefore the DC does not reach the chip. Therefore L5 must be +> powering something downstream of J2. → bias-T topology, expected. + +The chain that kills the U3-001/U1-001 contradiction (a separate but +related issue from the same project): + +> Net `$1N2250` is labeled 1.48 V but the ADJ divider math says 3.70 V. +> Which is the cause and which is the consequence? The divider is +> physical (resistor values), the label is an annotation. Trust the +> physics. → U1-001 ("Vdd below 2 V min") is the false consequence of +> trusting the label. + +**Status: landed** — see U1-001 case above. The role-of-part step +went in alongside the harm-pathway gate tightenings and a per-concern +turn budget, after the U1-001 run (same false-positive class, fresh +project) confirmed the harm-pathway change alone was not enough. + +**Original deferral rationale (retained for context):** the +harm-pathway fix was one prompt section and shipped first. The "what +is this part for" step is structurally larger — it changes the +reviewer's loop (one extra reasoning pass per external component) +and is more likely to regress true findings if done sloppily. Worth +doing after we've seen the harm-pathway change in production for a +few runs — which is exactly what U1-001 provided. + +**Pointer for whoever maintains this section:** +- The reasoning step lives *before* the existing "Net names are not + voltage labels" / "Cross-IC interface checks" sections in + `SYSTEM_PROMPT` — it's a precondition to those. +- Canonical RF patterns (bias-T, AC coupling, …) are deliberately + NOT listed by name. The win of agentic review over a pattern + library is that the reviewer figures out the role from first + principles. Don't add patterns to the prompt — add reasoning + scaffolds instead. +- Cross-check against this file: any prompt change that no longer + prevents U1-002 / U1-001 (and the future cases logged below them) + is a regression. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..e2764f9 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# clerk configuration (can include secrets) +/.clerk/ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..4f765be --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,102 @@ +@AGENTS.md + +# Pinscope Frontend + +Next.js 16 app (App Router, Turbopack) providing a web UI for Pinscope schematic validation. Talks to the FastAPI backend at `localhost:8000`. + +## Architecture + +- **Next.js 16** with App Router, Tailwind CSS v4, shadcn/ui (Base UI primitives, not Radix) +- **Route groups**: `(app)` for app routes (dashboard, projects, admin), `(marketing)` for public pages (landing, contact, privacy, terms) +- **Backend integration**: `src/lib/api.ts` fetches from `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:8000`) +- **SSE for pipeline progress**: Streams events from `GET /api/pipeline/{id}/events` +- **Sidebar navigation**: Project pages use sidebar nav with tabs via URL query params (`?tab=bom|derating|power|logs|settings`) +- **Power tree visualization**: Interactive graph via React Flow (`@xyflow/react`) + dagre layout +- **Project collaborators**: Email-based invites, shared access badges, owner/member roles +- **Comments on findings**: Reviewers can leave threaded comments with `@mention` support on any finding card + +## Open-core seams + +A handful of files are gateway-owned stubs the hosted-cloud repo replaces +with Clerk/billing implementations. Keep their export signatures stable and +never import auth/billing SDKs elsewhere: + +- `src/proxy.ts` — pass-through middleware here +- `src/hooks/use-optional-auth.ts` — always the local admin user here +- `src/components/theme/clerk-theme-provider.tsx` — pass-through here +- `src/components/billing/credits-context.tsx` — `useCredits()` always null here +- `src/components/billing/paused-run-banner.tsx` — renders null here +- `src/components/layout/sidebar-auth.tsx` — renders null here +- `src/components/marketing/pricing-section.tsx` — renders null here +- `src/components/analytics/*` — render null here +- `src/lib/csp-hosts.ts` — empty allow-lists here + +Read auth state only through `useOptionalAuth()`/`useOptionalUser()`, and +credit state only through `useCredits()` — both are inert in this repo. + +## Key Paths + +| Path | Purpose | +|---|---| +| `src/lib/types.ts` | TS types mirroring `pinscopex/models.py` | +| `src/lib/api.ts` | All data fetching — single integration point with backend | +| `src/lib/mock-data.ts` | Pipeline step definitions for progress UI | +| `src/components/report/` | Report viewer components + power tree React Flow graph + derating table + finding comments | +| `src/components/progress/` | Pipeline progress stepper | +| `src/components/dashboard/` | Project card + create-project dialog | +| `src/components/upload/` | File upload components | +| `src/components/layout/` | Sidebar and layout shells | +| `src/components/pdf/` | PDF viewer (uses `react-pdf` for in-browser datasheet viewing) | +| `src/components/legal/` | Shared footer / legal-page scaffolding | +| `src/components/ui/` | shadcn primitives — Base UI, not Radix | +| `src/hooks/` | `use-auth-api`, `use-pipeline-progress`, `use-report`, `use-reviewed-count`, `use-reviewed-findings` | + +## Routes + +| Route | Page | +|---|---| +| `/` | Marketing landing page | +| `/contact`, `/privacy`, `/terms` | Marketing static pages | +| `/dashboard` | Project grid | +| `/project/[id]` | Project detail — tabbed: Project (uploads), BOM, Derating, Power, Logs, Settings | +| `/project/[id]/report` | Report viewer — findings grouped by component, filters in URL params, threaded comments | +| `/project/[id]/progress` | Pipeline progress — SSE-driven stepper | +| `/admin` | Admin dashboard — tabbed: Components, Users, Usage, Projects, Runs, Settings | + +## shadcn/ui: Base UI, Not Radix + +This project uses **Base UI** primitives (`@base-ui/react`), not Radix. Key differences: +- No `asChild` prop — use `render={}` instead for composition +- `Select.onValueChange` signature is `(value: string | null, details) => void` +- `CollapsibleTrigger` renders its children directly, no slot forwarding needed + +Always check `src/components/ui/*.tsx` for the actual component API before using a shadcn component. + +## Data Flow + +1. Client components call functions from `src/lib/api.ts` +2. `api.ts` calls the FastAPI backend (`/api/projects`, `/api/report/{id}`, `/api/graph/{id}`, etc.) +3. File uploads (BOM, netlist, datasheets) use multipart form-data with MPN query param for datasheets; datasheet uploads support `also_for` for multi-MPN sharing +4. Pipeline progress streams via SSE; polling fallback at `/api/pipeline/{id}/status`; cancel via `cancelPipeline()` +5. BOM summary: `fetchBomSummary()`; derating: `fetchDerating()`; power tree: `fetchPowerTree()`; logs: `fetchProjectLogs()` +6. Comments: `addComment()`, `deleteComment()` on finding cards +7. Collaborators: `fetchCollaborators()`, `addCollaborator()`, `removeCollaborator()` +8. DigiKey: `autoResolveSimple()`, `fetchDigikeyDatasheet()` + +## Development + +```bash +cd frontend +npm run dev # starts on localhost:3000 +npm run build # production build (verifies types) +``` + +Requires the backend running at `localhost:8000` (or set `NEXT_PUBLIC_API_URL`). + +## Guidelines + +- Keep all data fetching in `src/lib/api.ts` — don't scatter fetch calls across components +- Report filters persist in URL search params (`?status=ERROR&component=U3&q=decoupling`) +- When modifying types, keep `src/lib/types.ts` in sync with `backend/pinscopex/models.py` +- Use `font-mono` for technical values: designators (U1), MPNs, pin names, component values +- Status colors: emerald = PASS, amber = WARNING, rose = ERROR, blue = accent/active diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..8d886db --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md new file mode 100644 index 0000000..bf8d76d --- /dev/null +++ b/frontend/content/changelog.md @@ -0,0 +1,104 @@ +# Changelog + +What's new in Pinscope. + +## 2.6.0 — 2026-07-12 — Export Report to Excel + +Download a project's findings as an Excel spreadsheet straight from the report — one click, ready to share, filter, or archive outside Pinscope. + +- [New] "Export Excel" button on the validation report. Every finding becomes a spreadsheet row — designator, part number, ID, severity, title, description, recommendation, and its datasheet source (page included) — sorted most-severe first. + +## 2.5.1 — 2026-07-04 — More Thorough Reviews + +Schematic review now works through every functional area of a component before finishing, so a part with several independent issues has all of them surfaced in one pass instead of just the first. + +- [Improved] For each IC, the review covers power and decoupling, every signal interface, absolute-maximum ratings, and reset/boot/configuration and unused pins before reporting — catching multiple issues on the same component that could previously be missed. + +## 2.5.0 — 2026-07-02 — Light Mode + +Pinscope now has a light theme. Toggle between light and dark with the sun/moon button — in the sidebar next to your account menu, or in the header on the website. + +- [New] Theme toggle. Switch between light and dark mode anywhere in the app; your choice is remembered on this device. Everything defaults to dark, exactly as before, until you flip it. +- [Improved] Every status color — error, warning, and pass badges, finding cards, the progress view, billing — is tuned for both themes, so reports stay legible either way. +- [Improved] The sign-in page and account menu now follow the app theme instead of always rendering light. + +## 2.4.0 — 2026-07-01 — Automatic Pin & LED Current Checks + +Two datasheet-grounded checks now run on every project, independent of the schematic review — catching a swapped-peripheral pin or an over-driven LED — plus a clear list of any components that had no datasheet to review against. + +- [New] Pin-function feasibility check. Pinscope now flags when a net assigns an IC pin a peripheral function its silicon can't route — for example a `UART5_TX` net on a pin whose alternate-function table only offers `UART5_RX`. It's reported as an error straight from the datasheet's pin table and names the likely swap (TX↔RX, SDA↔SCL). It deliberately does not judge signal *direction* across an interface — a direct UART crosses TX↔RX while a transceiver runs straight through — so it only fires on physically impossible pin assignments, never on wiring style. +- [New] LED forward-current check. For each LED, Pinscope computes the forward current from the supply rail, the series resistor, and the LED's rated forward voltage, and flags any channel whose current exceeds the LED's rated maximum. Each color of an RGB LED is checked separately, and a leg with no current-limiting resistor at all is called out as a caution. +- [New] "Not reviewed" list on the report. Components with no datasheet on file — for instance a do-not-populate footprint that isn't in the BOM — are now called out explicitly, so a mis-wired pin on an unreviewed part shows up as a known gap instead of being silently absent. +- [New] Findings from these automatic checks carry an "Automated check" badge, so they're easy to tell apart from datasheet-review findings. + +## 2.3.3 — 2026-06-08 — Faster Reviews + +Multi-chip designs now review several times faster — Pinscope works through ICs in parallel instead of one at a time. + +- [Improved] Datasheet extraction and schematic review now process multiple ICs at once, so reports on multi-IC projects come back substantially faster. The findings are unchanged — only the wait is shorter. + +## 2.3.2 — 2026-05-26 — Smarter RF Topology Review + +Schematic review now reasons about *what each external part is for* before flagging it — catching valid bias, coupling, and matching circuits that previously looked like errors. + +- [Improved] The reviewer states the role of every external part on an IC pin (choke, blocking cap, divider, decoupling, matching) before judging the connection. Common RF topologies like bias-T (DC injected onto a coax through a choke, with the chip protected by an internal DC block and a downstream load doing the actual draw) are no longer flagged as errors against the chip. +- [Improved] Stricter absolute-maximum-rating checks: the cited limit must come from the same pin under stress (a Vdd abs-max no longer counts against an RF or signal pin), and the inequality must be a strict exceed — equal-to-abs-max is at most a Warning. +- [Improved] Single-concern deep dives are capped at two follow-up queries; concerns that can't be resolved in that budget are reported as Warnings with the unresolved question stated, so one suspect finding can't starve the rest of the IC review. +- [Improved] Inferred rail voltages from the power-tree pass are no longer treated as ground truth by the schematic reviewer. Voltages set by net name (`+5V`, `+3V3`) or by user power-source hints are trusted as before; voltages the power-tree LLM guessed for an adjustable regulator output or propagated through inference are kept on the power-tree view for reference but excluded from review reasoning, so a single misread rail can't anchor a false-positive Error. +- [Improved] After each IC's review, a second pass normalizes findings against a fixed Error/Warning/Info rubric and merges any two findings that share a single root cause (e.g. "series resistor drops VIN" and "VOUT setpoint exceeds available VIN" are one defect, not two). Cuts run-to-run severity drift and avoids inflating the error count when one defect can be described from multiple angles. + +## 2.3.1 — 2026-05-25 — EDIF Netlist Support + +EDIF 2.0.0 netlists upload alongside PADS-PCB, with a sub-design picker for files that contain more than one design. Schematic review is also more cautious about polarity / direction-control findings. + +- [New] Upload EDIF 2.0.0 (`.edn`) netlists directly. Format is auto-detected from the file contents — no need to convert to PADS-PCB first. Verified against Siemens xDX Designer exports. +- [New] When an EDIF file contains multiple sub-designs, project setup shows a picker so you can choose which one to review. The picker auto-confirms when there's a single clean match against your BOM and only asks when it's ambiguous; unselected sub-designs are filtered out of the design graph. +- [Improved] Stronger verification of differential and polarity pin assignments (USB D+/D−, TX/RX, IN+/IN−, anode/cathode) directly against the datasheet. +- [Improved] Improved support for bidirectional buffers and level translators (74xx245 and friends) — direction-control truth tables are factored into bus-contention analysis. +- [Improved] Findings that share a single root cause on the same chip are grouped into one combined finding. + +## 2.3.0 — 2026-05-24 — LCSC Part Number Support + +JLCPCB-style BOMs with LCSC part numbers (e.g. `C12044`) now work out of the box — Pinscope auto-detects the column, resolves each id to the real manufacturer part number, and shows you what it resolved to before the pipeline runs. + +- [New] LCSC part numbers in the manufacturer part number column are auto-detected at BOM upload and converted to real MPNs. Works with JLCPCB / EasyEDA exports without any column renaming. +- [New] Project setup now shows the LCSC → MPN mapping on each IC row in the datasheet step (e.g. `C12044 → TP4057-42-SOT26-R`), so you can see what each LCSC id became before the pipeline starts. +- [New] Passive specs (value, voltage, tolerance, dielectric, package) are resolved from the LCSC catalog during project setup, with per-row progress and status — you see what's resolved before spending credits on the full pipeline. +- [Improved] Datasheet auto-fetch hit rate is dramatically higher on LCSC BOMs, because DigiKey now sees real MPNs instead of `C…` ids. + +## 2.2.1 — 2026-05-22 — Easier Netlist Uploads & Xpedition Support + +Tabbed file upload guide with per-tool instructions, Xpedition coverage, and direct `.net` / `.txt` uploads. + +- [New] Documentation for exporting a PADS-PCB netlist from Siemens Xpedition Designer / DxDesigner (VX.2.x, including VX.2.14). +- [Improved] File upload guide reorganized into tabs — KiCad, Altium, OrCAD/Allegro, Xpedition, EasyEDA, and Eagle each get their own panel. +- [Improved] Netlist uploads now accept `.asc`, `.net`, `.NET`, and `.txt` directly — no more renaming required before upload. +- [Improved] File guide now calls out the difference between the PADS-PCB schematic netlist Pinscope needs and the `!PADS-POWERPCB` PCB-layout dump that some EDA tools also save as `.asc`. + +## 2.2.0 — 2026-05-20 — Cross-chip Datasheet Review + +The reviewer now reads neighbor-chip datasheets to verify cross-chip constraints, with fewer false errors when a spec can't be confirmed. + +- [Improved] Schematic review now cross-references connected chips: when an issue depends on a neighbor's spec (5V tolerance, absolute-max, drive strength), the reviewer pulls the relevant pages from that chip's datasheet before flagging it. +- [Improved] Fewer false errors on cross-chip findings: if a counterpart spec can't be confirmed from the datasheet, the issue is reported as a Warning with the unverified assumption stated — instead of being overstated as an Error. +- [Fixed] Some review findings could occasionally fail to appear in the report. + +## 2.1.0 — 2026-05-19 — Datasheet Reference Highlighting + +Datasheet citations now highlight the exact supporting sentence on the PDF page, with more reliable page numbers on large datasheets. + +- [Improved] Datasheet references now highlight the exact supporting sentence on the PDF page, not just the page number. +- [Fixed] Datasheet citations landing on the wrong page for large (multi-hundred-page) datasheets. +- [Fixed] Reviewed findings losing their checked state on page refresh. + +## 2.0.1 — 2026-05-01 — Flagging & Onboarding + +One-click flags on finding cards, an onboarding survey for new users, and small UI polish. + +- [New] Report findings with one click via the flag button on any finding card. +- [New] Onboarding survey for new users to help us improve the product. +- [Improved] Comment input box now fills available width. + +## 2.0.0 — 2026-04-29 — Public Changelog + +- [New] Initial public changelog. diff --git a/frontend/content/file-guide.md b/frontend/content/file-guide.md new file mode 100644 index 0000000..f21b9cd --- /dev/null +++ b/frontend/content/file-guide.md @@ -0,0 +1,182 @@ +# File Upload Guide + +Pinscope needs two files from your EDA tool to review a design: + +- A **PADS-PCB ASCII netlist** or an **EDIF 2.0.0 netlist** — the circuit's connectivity. Pinscope accepts `.asc`, `.net`, `.NET`, `.txt` (PADS-PCB) and `.edn`, `.edif`, `.edf` (EDIF); the format is auto-detected from the file's first bytes. +- A **Bill of Materials** (CSV or XLSX) — mapping each reference designator to a manufacturer part number. + +## Example files + +New to Pinscope? Here's a complete set of files from [Phil's Lab](https://www.youtube.com/@PhilsLab)' KiCad 9 TI MSPM0 tutorial you can download and upload as a starter project: + +- [TI-MSP-KICAD9-TUTORIAL.asc](/examples/TI-MSP-KICAD9-TUTORIAL.asc) — netlist +- [TI-MSP-KICAD9-TUTORIAL.csv](/examples/TI-MSP-KICAD9-TUTORIAL.csv) — BOM +- [TI-MSP-KICAD9-TUTORIAL-SCHEMATIC.pdf](/examples/TI-MSP-KICAD9-TUTORIAL-SCHEMATIC.pdf) — schematic (for your reference; Pinscope doesn't need this) + +The BOM below shows the shape Pinscope is looking for. + +## The BOM + +Pinscope auto-detects BOM columns by header name. After upload you'll confirm which column holds designators and which holds part numbers, so the exact header names don't matter — only that these columns exist. + +**Required** + +- **Designator / Reference** — one row per part, or grouped references like `C1,C2,C5` in a single row (Pinscope expands these automatically). +- **Manufacturer Part Number (MPN)** — the full orderable part number. Pinscope uses this to look up datasheets, so `10uF 0805` on its own is **not** enough — it needs e.g. `GRM21BR61C106KE15L`. + +**Recommended** + +- **Value** or **Comment** — passive values (`10uF`, `4.7k`, `8MHz`). Used for passive value resolution and mismatch detection when the MPN can't be matched. +- **Footprint** — used to enrich the design graph. + +CSV and XLSX both work. For XLSX, the first worksheet is used. + +## The Netlist + +Pinscope accepts either a **PADS-PCB ASCII netlist** or an **EDIF 2.0.0 netlist** — the upload form auto-detects which one you sent based on the file's first bytes, so you don't have to pick a format. + +### PADS-PCB ASCII + +This is the default format most EDA tools can export. It looks like this: + +```text +*PADS-PCB* +*PART* +U1 LQFP48 +C1 0402 +... +*NET* +*SIGNAL* GND +U1.1 U1.48 C1.2 +*SIGNAL* VCC +U1.24 C1.1 +... +*END* +``` + +If your file starts with `*PADS-PCB*` and ends with `*END*`, you're good. Reference designators may contain spaces (e.g. `CV GND`) — Pinscope resolves them against your BOM. + +**Heads up — two different `.asc` files exist.** PADS (and tools that interop with PADS, like Xpedition) use the `.asc` extension for two unrelated things: + +- The **schematic-exported netlist** starts with `*PADS-PCB*` and lists `*PART*` / `*NET*` sections. **This is what Pinscope wants.** +- The **full PCB layout dump** starts with `!PADS-POWERPCB-V…` and contains routing/footprint geometry. Pinscope cannot parse this. + +If your upload errors with "No components found", check the first line of the file. + +### EDIF 2.0.0 + +EDIF is a vendor-neutral s-expression format. The first form is `(edif …`, with an `(edifVersion 2 0 0)` declaration near the top, libraries that define each cell's pin list, and a design library that lists `(instance …)` and `(net …)` forms. Pinscope has been verified against **Siemens xDX Designer / DxDesigner** exports; other EDIF 2.0.0 exporters (OrCAD, Altium, KiCad, Eagle) follow the same grammar and should work, but haven't been broadly tested. If your EDIF file doesn't parse, [contact us](/contact) and send a snippet — most fixes are small. + +If your tool exports both PADS-PCB and EDIF, PADS-PCB is the path more users have validated; use EDIF when it's the only option. + +## Exporting from your EDA tool + +### KiCad + +Works with KiCad 7.x, 8.x, and 9.x. + +**Netlist** + +1. Open the schematic in eeschema. +2. **File → Export → Netlist…** +3. Select the **PADS** tab → **Export Netlist**. +4. Save the resulting `.net` file and upload it directly. + +**BOM** + +1. In eeschema: **Tools → Generate BOM…** +2. Use the built-in `bom_csv_grouped_by_value_with_fp` plugin (or similar) — it produces a CSV with Reference, Value, Footprint, and any custom MPN field on your symbols. +3. If your symbols don't have an MPN field yet, add one via **Edit → Edit Symbol Fields** before generating the BOM. + +### Altium Designer + +**Netlist** + +1. In the Schematic Editor: **Design → Netlist for Project → PADS**. +2. The `.NET` file lands in `Project Outputs for …`. Upload it directly. + +**BOM** + +1. **Reports → Bill of Materials**. +2. In the template, include `Designator`, `Manufacturer Part Number` (or your equivalent parameter), `Comment` (= value), and `Footprint`. +3. **Export** → CSV or Excel. + +### OrCAD / Allegro + +For OrCAD Capture and Allegro Design Entry. + +**Netlist** + +1. **Tools → Create Netlist** → format **PADS-PCB (.asc)**. +2. Save to the project output directory. + +**BOM** + +1. **Tools → Bill of Materials** → configure columns to include `Reference`, `Manufacturer Part Number`, and `Value`. +2. Export CSV. + +### Xpedition + +Works in **Xpedition Designer / DxDesigner** (VX.2.x, including VX.2.14). Xpedition and PADS are both Siemens EDA tools and share a netlist exchange format — but the PADS netlist export is only available in projects created with the **Netlist** project type. Projects on the integrated Xpedition flow (which forward directly into Xpedition Layout) don't expose PADS as a layout target. + +**Netlist** + +1. Open the schematic in Xpedition Designer / DxDesigner. +2. **Setup → Settings → Layout Tool** → confirm **PADS** (or PADS Professional) is selected. If this option isn't available, the project was created as an Integrated/Expedition project — create a new Netlist-type project and import the schematics into it. +3. **Tools → PCB Interface** → choose the **PADS** template (`pads2007.cfg` or equivalent). +4. Run the export. The output's first line should be `*PADS-PCB*` with `*PART*` and `*NET*` sections below. The file extension (`.txt`, `.net`, or `.asc`) doesn't matter — upload it as-is. + +If your project is locked to the integrated Xpedition flow, **export EDIF instead** — DxDesigner's EDIF Exporter runs against any project type and Pinscope accepts the resulting `.edn` as an equivalent input. See **Netlist (EDIF alternative)** below. + +**Netlist (EDIF alternative)** + +1. Open the schematic in Xpedition Designer / DxDesigner. +2. **File → Export → EDIF…** (older builds: **Tools → Run Tool → Edif Exporter**). +3. In the export dialog: + - **EDIF version**: **2.0.0** (Pinscope only supports 2.0.0) + - **EDIF level**: **0** (the default) + - **Output format**: **Netlist view** — make sure cells, instances, nets, and the `viewMap` back-annotation block are all included + - **Designator source**: include back-annotated designators (otherwise instances export as templates like `U?` / `R?` and Pinscope drops them) +4. Save as `.edn` and upload it as the netlist. The file should start with `(edif …)` and contain `(edifVersion 2 0 0)` near the top. + +If neither PADS nor EDIF works for your project setup, send us the BOM and schematic PDF via [Contact](/contact) — we can usually help unblock the export. + +**BOM** + +1. **Reports → Bill of Materials** in Xpedition Designer, or run the Variant Manager BOM export. +2. Include `Reference Designator`, `Part Number` (manufacturer part), `Value`, and `Footprint` columns. +3. Export CSV or XLSX. + +### EasyEDA + +For both EasyEDA Standard and EasyEDA Pro. + +**Netlist** + +1. EasyEDA Std: **File → Export → PADS-PCB Netlist (.asc)**. +2. EasyEDA Pro: **Design → Output → Netlist → PADS-PCB**. + +**BOM** + +1. **Fabrication → BOM** → export CSV. +2. Confirm the Manufacturer Part Number column is populated — it comes from the LCSC supplier data or from a custom MPN attribute you've set on each part. + +### Autodesk Eagle + +**Netlist** + +1. In the schematic editor: **File → Run ULP → `pads-pcb.ulp`** (or **File → Export → Netlist → PADS** in newer builds). +2. Save and upload as-is. + +**BOM** + +1. **File → Export → Bill of Materials** → CSV. +2. Ensure your parts carry an `MPN` or `MFR_PART` attribute so the column ends up in the export. + +## Troubleshooting + +- **"No components found"** — your netlist is missing the `*PART*` section. Re-export specifically in PADS-PCB format (not Spice, Protel, or a generic text netlist). If the first line is `!PADS-POWERPCB-V…`, you uploaded the PCB layout dump instead of the schematic netlist — re-export from the schematic side. +- **"No ground net found"** — your netlist has no net named `GND`, `VSS`, `AGND`, `DGND`, or similar. If you exported a sub-sheet, re-export the top sheet instead. +- **Unresolved parts after the pipeline runs** — a BOM row has no MPN, or the MPN wasn't found on DigiKey. Add the MPN, or rely on Pinscope's value fallback (fills in from the `Value` / `Comment` column). + +Still stuck? [Contact us](/contact) with your netlist and BOM attached and we'll take a look. diff --git a/frontend/content/privacy.md b/frontend/content/privacy.md new file mode 100644 index 0000000..1b60645 --- /dev/null +++ b/frontend/content/privacy.md @@ -0,0 +1,185 @@ +# Pinscope Privacy Policy + +**Last updated: April 5, 2026** + +This Privacy Policy explains how Faradworks, Inc. ("Faradworks," "we," "us," and "our") collects, uses, and discloses information in connection with the Pinscope website (pinscope.ai), platform, and related services (the "Service"). + +This Privacy Policy is intended for free users and self-serve paid users. Enterprise customers typically use the Service under a separate agreement and (if applicable) a data processing agreement ("DPA"), which may include additional privacy and security terms. + +## 1. Key definitions + +**"Customer Content"** means any files, data, text, images, netlists, schematics, datasheets, design files, chat inputs, or other materials uploaded or submitted by you. + +**"Outputs"** means analyses, checks, summaries, recommendations, or responses generated by the Service based on Customer Content. + +**"Derived Data"** means technical artifacts generated solely to operate the Service, such as parsed text, indexes, embeddings, summaries, or extracted metadata. + +**"Content"** refers collectively to Customer Content, Outputs, and Derived Data. + +**"Personal Data"** (or "personal information") means information that identifies, relates to, describes, is reasonably capable of being associated with, or could reasonably be linked, directly or indirectly, with an individual, as defined under applicable law. + +## 2. Roles: controller vs. processor (business customers) + +Faradworks is the controller for Personal Data associated with operating the Service for users and prospective customers (for example account data, billing metadata, and website analytics). + +If you upload Personal Data within Customer Content on behalf of a business, you may be the controller of that Personal Data and Faradworks may process it as a service provider/processor. Where required, we will make a DPA available upon request. + +## 3. Information we collect + +We collect only the information reasonably necessary to operate the Service. + +### 3.1 Account information + +- Name, email address, and organization (optional) +- Authentication identifiers (for example password hashes, session tokens, and account IDs) +- Subscription and plan metadata + +### 3.2 Billing information + +- Payments are processed by Stripe, a PCI-compliant third-party processor. +- Faradworks receives billing status and subscription metadata (for example plan name, renewal date, and payment status). +- We do not store payment card details. + +### 3.3 Customer Content + +- Engineering files (for example netlists, schematics, datasheets, and images) +- Chat inputs and related context +- Design rules and configuration preferences + +### 3.4 Usage and operational data + +Usage and performance telemetry (for example request timestamps, feature usage, response times, error rates, and billing/usage measurements). This data is generally operational in nature and is designed not to include the substance of your Customer Content, except as described in Section 6.3 (Support diagnostics). + +### 3.5 Cookies and analytics preferences + +- We use cookies and similar technologies needed for core site operations and security. +- We store an analytics preference cookie that records whether analytics tracking is granted or denied. +- When analytics is granted, we may collect site and product usage and performance telemetry through providers such as Vercel Analytics and Speed Insights. + +### 3.6 Contact and enterprise inquiry data + +- Contact and profile details submitted through contact or enterprise inquiry forms (for example name, email, business type, and capacity needs) +- Interest signals (for example request for a demo, free review evaluation, or higher-limit self-serve access) +- Inquiry notes and follow-up communications related to your request + +## 4. How we use information + +We use Personal Data and Content to: + +- Provide, operate, and maintain the Service +- Perform AI-assisted engineering analysis +- Enforce usage limits and prevent abuse +- Diagnose errors and improve reliability +- Respond to support requests +- Investigate support requests and Issue Reports and, where you explicitly opt in, use those submissions and the related context to evaluate, test, debug, monitor, develop, and improve the Service +- Send service-related communications, including onboarding, feedback requests, product updates, and security notices +- Review and respond to contact or enterprise inquiries, including demo requests and plan-fit conversations +- Comply with legal obligations and enforce our Terms + +## 5. AI model training and service improvement + +### 5.1 No training on Customer Content for foundation models + +Faradworks treats all Customer Content as confidential and does not use it to train, fine-tune, or improve any general-purpose or foundation AI models. Your content remains isolated to your account and is not shared across customers or included in public datasets. + +### 5.2 Aggregated and de-identified service improvement + +To improve and operate the Service, we rely primarily on aggregated or otherwise de-identified usage metrics (for example error rates and feature usage) that are not reasonably capable of being traced back to you. + +## 6. How we share information + +We do not sell Personal Data. + +We may disclose information in the following circumstances: + +### 6.1 Subprocessors and service providers + +We use subprocessors and service providers to host and operate the Service (for example hosting, storage, authentication, billing, observability, and database providers). We may share Personal Data and Content with these providers only as necessary to provide the Service. + +### 6.2 AI model providers + +To generate Outputs, relevant portions of Customer Content are transmitted to AI model providers acting as subprocessors. Providers may include services such as OpenAI, Anthropic, Google, or similar AI platforms. + +These providers process Content to generate responses. Their data handling, retention, and caching practices are governed by their respective terms and our configuration. + +### 6.3 Support and troubleshooting diagnostics + +When needed for reliability, security, abuse prevention, or support troubleshooting, we may process account-linked technical diagnostics (for example request IDs, timestamps, stack traces, provider error payloads, and limited excerpts of inputs or outputs associated with a failing request). + +If you submit a support request, submit an Issue Report, or otherwise ask us to investigate an issue with the Service, we may access and review the Customer Content, Outputs, and related Derived Data reasonably necessary to investigate, reproduce, resolve, remediate, and prevent recurrence of that issue. This may include the affected project, review results, uploaded files or excerpts, citations, configuration context, and associated diagnostics. + +If, through the applicable in-product support or feedback flow, you explicitly opt in to broader improvement use, we may also use the submitted materials and the reasonably necessary related project/review context to evaluate, test, debug, monitor, support, secure, operate, develop, and improve the Service and related features, systems, and workflows, including for the benefit of other users. This may include quality evaluation, failure analysis, prompt and retrieval improvements, ranking or classification improvements, validation logic, reliability engineering, abuse prevention, and other product-quality, safety, and operational improvements. + +We limit this access and use to authorized personnel and permitted service-related purposes. We do not use this content to train, fine-tune, or improve any general-purpose or foundation AI model unless you separately and expressly opt in to that use. + +Outside of those circumstances, we do not access or review the substance of your Customer Content except: (a) at your request, (b) to investigate security issues or incidents, (c) if legally compelled, or (d) with your explicit consent. + +### 6.4 Legal and safety + +We may disclose information to comply with applicable law, lawful requests, and legal process; to protect the rights, property, and safety of Faradworks, our users, and others; and to enforce our agreements and policies. + +### 6.5 Business transfers + +If Faradworks is involved in a merger, acquisition, financing, reorganization, bankruptcy, or sale of assets, information may be transferred as part of that transaction, subject to standard confidentiality protections. + +## 7. Cookies and analytics controls + +You can control analytics cookies through the in-product or site controls (where available) and through your browser settings. If you deny analytics cookies, we will not run optional analytics tracking, but essential cookies may still be required for core functionality and security. + +## 8. Data retention and deletion + +### 8.1 User-controlled deletion + +You may delete uploaded files, chats, and review history from within the Service (where available) and you may request closure of your account. + +### 8.2 Retention periods + +**Active accounts.** We retain Customer Content and Outputs for as long as your account remains active, unless you delete them earlier. + +**Account closure.** When you close your account (or we close it at your request), we will delete Customer Content and Outputs associated with your account within 30 days, except where retention is required by law or for legitimate business purposes described below. + +**Backups.** Residual metadata or encrypted backups may persist for a limited period (generally up to 90 days) for security, integrity, and disaster-recovery purposes. + +**Operational artifacts.** We may retain limited technical artifacts (for example hashed identifiers, aggregated statistics, and anonymized error metadata) for security, abuse prevention, system integrity, and service improvement. These artifacts are not intended to and are not reasonably capable of reconstructing your original designs. + +**Support and improvement records.** If you submit a support request or an Issue Report, we may retain the support record, related investigation notes, and the limited associated project/review context needed to document, resolve, and prevent recurrence of the issue. If you explicitly opt in to broader improvement use, we may also retain the submitted materials and related analyses for the service-improvement purposes described in this Policy, subject to the same confidentiality and deletion framework described in this Policy. + +**Billing and accounting.** We retain billing records and aggregated usage statistics as required by law and for legitimate business purposes (for example taxes, accounting, and audit). + +## 9. Security + +We use commercially reasonable administrative, technical, and organizational safeguards designed to protect information, including encryption in transit (TLS) and at rest, per-user access isolation, secure credential management, and monitoring. No system is perfectly secure. + +## 10. International data transfers + +By default, the Service is hosted in the United States. If you access the Service from outside the United States, information may be transferred to, stored in, and processed in the United States and other countries where we or our subprocessors operate. Where required, we will use appropriate safeguards for international transfers (for example contractual protections in a DPA). + +## 11. Your rights and choices + +### 11.1 GDPR/UK GDPR + +Depending on your jurisdiction, you may have rights to access, correct, delete, restrict, object to processing, and port your Personal Data. You may also have the right to withdraw consent where processing is based on consent. + +### 11.2 California (CCPA/CPRA) + +If you are a California resident, you may have rights to know, access, delete, correct, and opt out of the "sale" or "sharing" of Personal Data, and to limit the use of "sensitive personal information," as those terms are defined under California law. Faradworks does not sell Personal Data. We do not share Personal Data for cross-context behavioral advertising. + +### 11.3 How to exercise rights + +Privacy requests (data access, deletion, correction): dev@faradworks.com + +For faster handling, use subject line: "Privacy Request (Access/Deletion/Correction)". + +We will respond to verified privacy requests within applicable legal timelines (typically within 30 days for GDPR requests). + +## 12. Children + +The Service is not directed to children, and we do not knowingly collect Personal Data from children under 13 (or under the age threshold applicable in your jurisdiction). + +## 13. Changes to this Privacy Policy + +We may update this Privacy Policy from time to time. If we make material changes, we will provide reasonable notice by email or by posting a notice on the Service before the changes take effect. The updated policy will be effective as of the "Last updated" date unless otherwise stated. + +## 14. Contact + +Questions about this Privacy Policy: [dev@faradworks.com](mailto:dev@faradworks.com) diff --git a/frontend/content/terms.md b/frontend/content/terms.md new file mode 100644 index 0000000..bb742c3 --- /dev/null +++ b/frontend/content/terms.md @@ -0,0 +1,355 @@ +# Pinscope Terms of Service + +**Last updated: April 5, 2026** + +These Terms of Service ("Terms") govern access to and use of the Pinscope platform (pinscope.ai) and related services (the "Service"). These Terms apply to free users and self-serve paid users. If you have a separate written agreement signed by Faradworks, Inc. (for example, an enterprise agreement), that agreement governs your use of the Service to the extent it conflicts with these Terms. + +These Terms incorporate Faradworks' [Privacy Policy](/privacy) and any policies referenced in the Service. + +By creating an account, clicking to accept these Terms (for example, by clicking an "I agree" button in our signup flow), or otherwise accessing or using the Service, you agree to be bound by these Terms. If you do not agree, do not use the Service. + +## 1. Definitions + +**"Customer Content"** means any files, data, text, images, netlists, schematics, datasheets, design files, chat inputs, or other materials uploaded or submitted by you. + +**"Outputs"** means analyses, checks, summaries, recommendations, or responses generated by the Service based on Customer Content. + +**"Derived Data"** means technical artifacts generated solely to operate the Service, such as parsed text, indexes, embeddings, summaries, or extracted metadata. + +**"Service"** means the Pinscope platform (pinscope.ai), including all features, APIs, and related services. + +**"Content"** refers collectively to Customer Content, Outputs, and Derived Data. + +**"Faradworks," "we," and "us"** means Faradworks, Inc., a Delaware corporation. + +## 2. Acceptance; eligibility; authority; electronic communications + +### 2.1 Authority. + +If you use the Service on behalf of a company or other entity, you represent and warrant that you have authority to bind that entity. In that case, "you" and "your" refer to that entity. + +### 2.2 Eligibility. + +You must be at least 18 years old (or the age of legal majority where you live) to use the Service. + +### 2.3 Electronic delivery and notices. + +You consent to receive communications from Faradworks electronically (for example, by email and in-product notices). You agree that all agreements, notices, disclosures, and other communications we provide electronically satisfy any legal requirement that such communications be in writing. + +## 3. Service description; informational use; no professional advice + +### 3.1 Informational service. + +The Service analyzes user-submitted electrical design files and datasheets using automated systems, including machine learning models, to generate informational Outputs. Outputs are provided on an "AS IS" and "AS AVAILABLE" basis. + +### 3.2 No professional engineering advice. + +The Service does not provide professional engineering, safety, regulatory, certification, legal, or compliance advice. You are solely responsible for independently reviewing and validating any Outputs before use in real-world designs, fabrication, procurement, manufacturing, or deployment. Outputs may be incomplete, inaccurate, or unsuitable for your specific application. + +### 3.3 AI limitations. + +You acknowledge that AI-generated Outputs are probabilistic and may be inaccurate, incomplete, or misleading; that Output quality depends on input quality; and that models and Outputs may change over time. + +## 4. Accounts; security; administrators + +### 4.1 Account security. + +You are responsible for maintaining the confidentiality of your credentials and for all activity occurring under your account. You must promptly notify Faradworks if you suspect unauthorized access. + +### 4.2 Administrators and workspace users. + +If your account supports multiple users, administrators may be able to manage users, permissions, billing, and settings within your workspace. You are responsible for actions taken by anyone you allow to access the Service through your account. + +### 4.3 Responsibility for systems and backups. + +You are responsible for your own systems, networks, and devices used to access the Service, and for maintaining appropriate backups of your Customer Content. + +## 5. Ownership; licenses; feedback + +### 5.1 Your ownership. + +You retain all right, title, and interest in and to your Customer Content. + +### 5.2 Outputs. + +To the extent permitted by law, you own the Outputs generated from your Customer Content. Ownership of Outputs does not confer ownership of the Service, software, models, or analytical methods used to generate them. + +### 5.3 Faradworks ownership. + +Faradworks retains all right, title, and interest in and to the Service (including its software, models, workflows, analytical methods, user interfaces, and underlying infrastructure), including all improvements and derivatives. + +### 5.4 License to operate the Service. + +You grant Faradworks a limited, non-exclusive, worldwide, royalty-free license to host, store, process, transmit, reproduce (as necessary for processing), and otherwise use your Customer Content and Derived Data solely to provide, maintain, secure, and support the Service. Any broader use of Customer Content, Outputs, support requests, Issue Reports, or related project/review context for service-improvement purposes is permitted only to the extent expressly described in these Terms, the Privacy Policy, and any opt-in choices you make in the Service. + +### 5.5 No model training on Customer Content. + +Faradworks will not use Customer Content to train, fine-tune, or improve any general-purpose or foundation AI model, and will not permit third parties to do so, unless you separately and explicitly agree to that use. + +### 5.6 Support reports and optional improvement consent. + +By default, Faradworks will not use Customer Content, Outputs, support requests, Issue Reports, or related project/review context to improve the Service beyond investigating, resolving, and preventing recurrence of the specific incident for which such materials were submitted. + +If you explicitly opt in through the applicable in-product support or feedback flow, you grant Faradworks a limited, revocable, non-exclusive, worldwide, royalty-free license to access, review, retain, and use the submitted materials and the reasonably necessary related Customer Content, Outputs, Derived Data, and project/review context to evaluate, test, debug, monitor, support, secure, operate, develop, and improve the Service and related features, systems, and workflows. This may include quality evaluation, failure analysis, prompt and retrieval improvements, ranking or classification improvements, validation logic, reliability engineering, abuse prevention, and other product-quality, safety, and operational improvements for the benefit of current and future users. + +This consent does not authorize Faradworks or any third party to train, fine-tune, or improve any general-purpose or foundation AI model unless you separately and expressly agree to that use. + +You may withdraw this optional improvement consent at any time through the Service settings or by contacting Faradworks. Withdrawal will apply prospectively and will not require Faradworks to unwind or delete improvements, evaluations, or analyses already created or completed before withdrawal, subject to the Privacy Policy's retention and deletion terms. + +### 5.7 Feedback. + +If you provide suggestions or feedback, you grant Faradworks a perpetual, irrevocable, worldwide, royalty-free license to use and incorporate it without restriction or obligation. + +## 6. Customer responsibilities; prohibited data; export and restricted data + +### 6.1 Rights in content you upload. + +You represent and warrant that you have all rights necessary to upload and use Customer Content with the Service and to grant the rights in these Terms. + +### 6.2 Prohibited Data. + +Unless Faradworks expressly agrees in writing, you will not submit or upload any of the following ("Prohibited Data"): (a) patient, medical, or other protected health information regulated by HIPAA or similar laws; (b) payment card data subject to PCI DSS; (c) bank account numbers, passwords, or authentication secrets intended to access financial accounts; (d) social security numbers, driver's license numbers, or other unique government ID numbers; (e) "special categories" of personal data under the GDPR (or similar sensitive personal data classifications); or (f) any other similarly sensitive personal information that would impose heightened legal or regulatory obligations on Faradworks. + +### 6.3 GDPR / DPA. + +If you are subject to GDPR/UK GDPR and need a data processing agreement ("DPA"), contact us at [dev@faradworks.com](mailto:dev@faradworks.com). If we provide a DPA for your use case, you must execute it before submitting personal data governed by GDPR/UK GDPR as Customer Content. If a DPA applies, it will govern the parties' rights and obligations with respect to such personal data and will control in the event of conflict with these Terms. + +### 6.4 Export-controlled and restricted technical data. + +You are responsible for compliance with all applicable export control laws and regulations, including U.S. EAR and ITAR. You agree not to upload export-controlled technical data, classified information, or government-restricted information without proper authorization. By default, the Service is hosted in the United States. By using the Service, you acknowledge that Content may be processed and stored in the U.S. or other regions where we or our subprocessors operate. + +## 7. Acceptable use; restrictions + +You agree not to: + +- (a) upload content you do not have rights to use; +- (b) upload malware, malicious code, or content intended to disrupt or compromise the Service; +- (c) circumvent usage limits or security controls; +- (d) interfere with service integrity or access others' data; +- (e) systematically extract, scrape, benchmark, or reverse engineer the Service, Outputs, or underlying models for the purpose of building a competing product; +- (f) resell, rent, or redistribute the Service without Faradworks' written consent; or +- (g) use the Service for illegal purposes or in violation of applicable laws. + +Faradworks may suspend or terminate access for violations of this section. + +## 8. High-risk applications + +The Service is not designed or intended for use in life-critical or safety-critical systems where failure could result in death, bodily injury, or significant property or environmental damage, including medical devices, automotive safety systems, aerospace systems, nuclear facilities, or weapons systems. + +If you choose to use the Service in connection with such applications, you do so at your own risk. You are solely responsible for independently validating all Outputs and for ensuring that any resulting designs, products, or systems meet all applicable safety, regulatory, and certification requirements. + +## 9. Free trials; beta features; no reliance + +### 9.1 Free and trial access. + +Faradworks may offer free plans, free trials, promotional credits, or other no-fee access ("Free Access"). Faradworks may modify, suspend, or end Free Access at any time. + +### 9.2 Beta features. + +Faradworks may make available features labeled alpha, beta, preview, or similar ("Beta Features"). Beta Features are experimental and may be changed or discontinued at any time. + +### 9.3 No SLA; no reliance. + +Free Access and Beta Features are provided "AS IS" and "AS AVAILABLE," without any service level commitment and without any obligation to provide support. You should not rely on Free Access or Beta Features for production use. + +## 10. Third-party providers; subprocessors; third-party services + +### 10.1 Subprocessors. + +The Service relies on third-party providers (including AI model providers, hosting, storage, authentication, billing, and observability providers) to help deliver functionality. Faradworks' data handling practices are described in the Privacy Policy. + +### 10.2 Third-party services/links. + +The Service may integrate with or link to third-party services. Faradworks is not responsible for third-party services, and your use of them is governed by the third party's terms and policies. + +## 11. Usage limits; billing; usage allocations; taxes; refunds + +### 11.1 Usage limits. + +Plans may include limits on reviews, tokens, files, API spend, usage allocations, or other usage metrics. Faradworks may throttle, restrict, or suspend usage to enforce limits or protect system stability. + +### 11.2 Prepaid service credits. + +Certain Services, plans, or features may allow or require you to prepay for future eligible Pinscope review services for professional, business, or organizational use by purchasing prepaid service credits ("Usage Credits"). Usage Credits represent a prepaid, limited, revocable, non-transferable license to access eligible Pinscope review services up to the applicable credited amount and may be used only for eligible Pinscope review charges as described in the Service. Faradworks may also, in its sole discretion, provide free or promotional credits ("Promotional Credits"), which may be subject to additional restrictions or expiration dates stated when issued. + +### 11.3 Credit characteristics and workspace scope. + +Credits may be used only for eligible Pinscope review charges and may not be used for any other product or service unless Faradworks expressly states otherwise in the Service. Credits are not legal tender, are not currency, are not redeemable for cash, are not refundable except as required by law or expressly stated by Faradworks, do not constitute or confer any personal property right, and do not constitute a bank account, deposit account, stored-value account, digital wallet, payment instrument, or other monetary account. Credits are an internal service accounting mechanism that measures the amount of eligible Pinscope review services you have prepaid and are licensed to use. Any credit balance or similar amount displayed in the Service reflects only our record of remaining prepaid eligibility for future eligible review charges and does not represent money held on your behalf. Credits are non-transferable, may not be sold, assigned, gifted, or sublicensed, and may be used only by the workspace or account to which they are issued. If credits are issued to an organization or workspace, they belong to that workspace and may be consumed by authorized users acting within that workspace. + +### 11.4 Credit purchases and application to charges. + +Your order for Usage Credits constitutes an offer to purchase those Usage Credits. Faradworks may accept or reject any purchase request in its discretion. Credits are issued when Faradworks confirms the purchase or otherwise makes the credits available in your account or workspace. Credits are applied to eligible Pinscope review charges in the manner described in the Service. Faradworks may reserve, deduct, reverse, release, or adjust credits to reflect quoted charges, completed usage, failed runs, duplicate requests, fraud checks, refunds, chargebacks, or billing corrections. Credit pricing, minimum purchase amounts, maximum purchase amounts, and applicable taxes will be shown in the Service or at checkout. Fees are exclusive of taxes unless stated otherwise. + +### 11.5 Credit expiration, forfeiture, and promotional credits. + +Unless otherwise stated in the Service or required by law, purchased Usage Credits expire 12 months after the date they are issued to your account or workspace. Expiration does not restart because credits are partially used, because you change plans, or because your workspace or account settings change, unless Faradworks expressly states otherwise. Promotional Credits expire on the date stated when they are issued, or if no date is stated, 12 months after issuance unless required otherwise by law. Credits may be forfeited if the applicable account or workspace is closed, terminated, or suspended, subject to applicable law. Faradworks may also withhold, void, or reverse credits associated with chargebacks, payment reversals, fraud, abuse, or violations of these Terms. + +### 11.6 Per-review usage impact. + +Paid plans may include monthly usage limits, included usage budgets, or similar usage allocations. These allocations reset each billing period unless otherwise stated. Each review has a usage impact based on scope, selected model, and your applicable plan or pricing structure. Before you run a review, Faradworks will show the usage impact of that review, which may be displayed as a dollar amount, credit amount, percentage of plan limit, or similar usage metric. That usage impact may change over time, including for the same or a similar review configuration, and prepaid credits do not lock in a future review price unless Faradworks expressly states otherwise in the Service. Unless Faradworks expressly states otherwise, the applicable review charge is the quoted amount or usage impact shown in the Service at the time you submit or start the review, and any quote may expire or require refresh before use. + +### 11.7 Plan changes. + +If you change plans mid-cycle, Faradworks may apply prorated or other adjustments to your available usage limits, credits, budget, or similar usage allocation for that active billing period. + +### 11.8 Auto-renewal; cancellation. + +Paid subscriptions renew automatically unless canceled. You may cancel at any time through your account settings. Cancellation stops future renewals; it does not retroactively refund fees already paid except where required by law. + +### 11.9 Grandfathered plans. + +Grandfathered plans may remain available only while the subscription stays active and in good standing. If a grandfathered plan is canceled, lapses, or is changed, reactivation may require enrolling in a then-current plan. Faradworks may retire grandfathered plans with reasonable notice where permitted by law. + +### 11.10 Taxes. + +Fees are exclusive of taxes unless stated otherwise. You are responsible for applicable taxes, except taxes based on Faradworks' net income. + +### 11.11 Refunds and billing corrections. + +You may request a refund for completely unused Usage Credits within 24 hours after purchase by contacting [dev@faradworks.com](mailto:dev@faradworks.com). If no refund request is received within 24 hours after purchase, unused Usage Credits become non-refundable except where required by law. Once any portion of purchased Usage Credits has been used, that purchase becomes non-refundable except where required by law or expressly authorized by Faradworks. Otherwise, refunds are provided at Faradworks' discretion or as required by law. Faradworks may correct pricing errors, mistaken issuances, duplicate grants, or accounting mistakes, including by adjusting, removing, or restoring credits where appropriate. + +## 12. Confidentiality; security; support access + +### 12.1 Confidential Customer Content. + +Faradworks treats Customer Content as confidential and does not disclose it to third parties except as necessary to provide the Service (including to subprocessors), as required by law, or with your consent, as further described in the Privacy Policy. + +### 12.2 Security. + +Faradworks uses commercially reasonable administrative, technical, and organizational safeguards designed to protect Content. However, no system is perfectly secure and Faradworks does not guarantee that unauthorized access, hacking, data loss, or other security incidents will never occur. + +### 12.3 Security incident communications. + +Where required by applicable law, Faradworks will provide notice of a confirmed unauthorized access to personal data under our control. + +### 12.4 Your responsibility for sensitive material. + +You are responsible for evaluating whether the Service meets your confidentiality requirements before uploading sensitive or proprietary designs. + +### 12.5 Support troubleshooting access. + +If you submit a support request or an Issue Report, Faradworks may access account-linked diagnostic records and the Customer Content, Outputs, and related project/review context reasonably necessary to investigate, reproduce, resolve, remediate, and prevent recurrence of the reported issue. This may include the affected project, review results, uploaded files or excerpts, citations, and associated diagnostics. + +If you explicitly opt in through the applicable support or feedback flow, Faradworks may also use those submitted materials and related context for the broader service-improvement purposes described in Section 5.6. Faradworks will limit such access and use to authorized personnel and to the permitted purposes described in these Terms and the Privacy Policy. + +## 13. Suspension; termination; effect of termination + +### 13.1 Suspension/termination by Faradworks. + +Faradworks may suspend or terminate access for: (a) violations of these Terms; (b) excessive, abusive, or fraudulent usage; (c) non-payment; (d) legal, regulatory, or infrastructure constraints; or (e) security, abuse-prevention, or risk-management reasons. + +### 13.2 Termination by you. + +You may stop using the Service at any time and may request account closure. + +### 13.3 Effect. + +Upon termination, your right to access the Service ends immediately. Faradworks will delete your Content in accordance with the Privacy Policy's data retention and deletion terms, subject to legal retention requirements and limited residual technical artifacts described in the Privacy Policy. + +### 13.4 Survival. + +Sections that by their nature should survive will survive termination, including ownership, confidentiality, disclaimers, limitation of liability, indemnification, disputes, and general terms. + +## 14. DISCLAIMERS + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE SERVICE AND OUTPUTS ARE PROVIDED "AS IS" AND "AS AVAILABLE," WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ACCURACY, AND QUIET ENJOYMENT. + +FARADWORKS DOES NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, SECURE, OR FREE OF HARMFUL COMPONENTS, OR THAT OUTPUTS WILL MEET YOUR REQUIREMENTS OR BE CORRECT FOR ANY PARTICULAR USE. + +## 15. LIMITATION OF LIABILITY + +### 15.1 EXCLUSION OF CERTAIN DAMAGES. + +TO THE FULLEST EXTENT PERMITTED BY LAW, IN NO EVENT WILL FARADWORKS (OR ITS AFFILIATES, OFFICERS, DIRECTORS, EMPLOYEES, CONTRACTORS, AGENTS, LICENSORS, SUPPLIERS, OR SUBPROCESSORS) BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE, BUSINESS OPPORTUNITIES, GOODWILL, OR DATA, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +### 15.2 SPECIFIC EXCLUSIONS. + +TO THE FULLEST EXTENT PERMITTED BY LAW, FARADWORKS IS NOT LIABLE FOR: (a) ERROR OR INTERRUPTION OF USE; (b) LOSS, INACCURACY, CORRUPTION, OR UNAUTHORIZED DISCLOSURE OF DATA OR CONTENT; (c) COST OF PROCUREMENT OF SUBSTITUTE GOODS, SERVICES, OR TECHNOLOGY; (d) HARDWARE FAILURES, DESIGN DEFECTS, MANUFACTURING ISSUES, OR SAFETY INCIDENTS RELATED TO DESIGNS REVIEWED USING THE SERVICE; OR (e) ANY MATTER BEYOND FARADWORKS' REASONABLE CONTROL (INCLUDING THIRD-PARTY PROVIDER FAILURES). + +### 15.3 AGGREGATE CAP. + +TO THE FULLEST EXTENT PERMITTED BY LAW, FARADWORKS' TOTAL AGGREGATE AND CUMULATIVE LIABILITY FOR ALL CLAIMS ARISING OUT OF OR RELATING TO THESE TERMS OR THE SERVICE, UNDER ANY THEORY OF LIABILITY (CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, STATUTE, OR OTHERWISE), WILL NOT EXCEED THE FEES PAID BY YOU TO FARADWORKS FOR THE SERVICE IN THE TWELVE (12) MONTHS PRECEDING THE FIRST EVENT GIVING RISE TO THE CLAIM. IF YOU HAVE NOT PAID ANY FEES TO FARADWORKS IN THAT PERIOD (FOR EXAMPLE, DURING FREE ACCESS OR A FREE TRIAL), FARADWORKS' TOTAL LIABILITY WILL NOT EXCEED ONE HUNDRED U.S. DOLLARS (US$100). + +### 15.4 BASIS OF THE BARGAIN; FAILURE OF ESSENTIAL PURPOSE. + +YOU ACKNOWLEDGE THAT THE FEES (IF ANY) REFLECT THE ALLOCATION OF RISK AND THAT FARADWORKS WOULD NOT PROVIDE THE SERVICE WITHOUT THESE LIMITATIONS. THE LIMITATIONS IN THIS SECTION APPLY EVEN IF ANY LIMITED REMEDY FAILS OF ITS ESSENTIAL PURPOSE. + +### 15.5 LIMITATIONS REQUIRED BY LAW. + +NOTHING IN THESE TERMS LIMITS OR EXCLUDES LIABILITY TO THE EXTENT SUCH LIMITATION OR EXCLUSION IS PROHIBITED BY APPLICABLE LAW. + +## 16. Indemnification + +### 16.1 Your indemnity. + +You agree to indemnify, defend, and hold harmless Faradworks and its affiliates, officers, directors, employees, contractors, and agents from and against any claims, damages, losses, liabilities, and expenses (including reasonable attorneys' fees) arising out of or relating to: (a) your use of the Service or Outputs; (b) your Customer Content; (c) your violation of these Terms; (d) your violation of any third-party rights (including IP rights); or (e) designs, products, or systems you create using or informed by Outputs. + +### 16.2 Indemnification procedure. + +Your obligations under this Section 16 are conditioned on Faradworks: (a) providing you prompt notice of the claim (provided that failure to provide prompt notice will relieve you only to the extent materially prejudiced); (b) providing reasonable assistance at your expense; and (c) allowing you sole control of the defense and settlement of the claim, except that you may not settle any claim in a manner that admits fault by Faradworks or imposes obligations on Faradworks without Faradworks' prior written consent. Faradworks may participate in the defense with counsel of its choosing at its own expense. + +## 17. Force majeure + +Faradworks will not be liable for any delay or failure to perform due to events beyond its reasonable control, including acts of God, natural disasters, war, terrorism, riots, labor disputes, pandemics, public utility failures, internet or cloud provider failures, or governmental actions ("Force Majeure Events"). A Force Majeure Event does not excuse your payment obligations for fees accrued prior to the Force Majeure Event. + +## 18. Disputes; governing law; venue; time limit to bring claims + +### 18.1 Informal resolution. + +Before filing a claim (other than for injunctive relief), you agree to first contact Faradworks at [dev@faradworks.com](mailto:dev@faradworks.com) with a brief description of the dispute and your contact information. The parties will attempt in good faith to resolve the dispute for at least 30 days. + +### 18.2 Governing law. + +These Terms are governed by the laws of the State of California, without regard to conflict of law principles. + +### 18.3 Venue. + +Any disputes arising out of or relating to these Terms or the Service must be brought in the state or federal courts located in California, and each party consents to personal jurisdiction and venue there. + +### 18.4 Injunctive relief. + +Nothing in these Terms prevents either party from seeking injunctive or other equitable relief to protect its intellectual property or confidential information. + +### 18.5 Time limit to bring claims. + +To the fullest extent permitted by law, any claim arising out of or relating to these Terms or the Service must be filed within one (1) year after the cause of action accrues; otherwise, it is permanently barred. + +## 19. Changes to Terms + +We may update these Terms from time to time. If we make material changes, we will provide reasonable notice by email or by posting a notice on the Service before the changes take effect. The updated Terms will be effective as of the "Last updated" date unless otherwise stated. Your continued use of the Service after the effective date, including accepting the updated Terms through the Service, constitutes acceptance of the updated Terms. If you do not agree to the changes, you must stop using the Service and close your account. + +## 20. General terms + +### 20.1 Entire agreement; order of precedence. + +These Terms (including the [Privacy Policy](/privacy) and any policies referenced in the Service) are the entire agreement between you and Faradworks regarding the Service and supersede prior or contemporaneous agreements or understandings. If you have a separate written agreement signed by Faradworks that expressly governs the Service, that agreement will control to the extent of conflict. + +### 20.2 Severability. + +If any provision of these Terms is held invalid or unenforceable, the remaining provisions will remain in full force and effect. + +### 20.3 Waiver. + +Failure to enforce any provision is not a waiver of future enforcement. + +### 20.4 Assignment. + +You may not assign these Terms without Faradworks' prior written consent. Faradworks may assign these Terms in connection with a merger, acquisition, corporate reorganization, or sale of all or substantially all of its assets, or otherwise upon notice. + +### 20.5 No third-party beneficiaries. + +Except for Faradworks' affiliates, licensors, suppliers, and subprocessors as intended third-party beneficiaries of Sections 14 (Disclaimers) and 15 (Limitation of Liability), there are no third-party beneficiaries to these Terms. + +### 20.6 Independent contractors. + +The parties are independent contractors. Nothing in these Terms creates any agency, partnership, joint venture, or employment relationship. + +### 20.7 Headings. + +Headings are for convenience only and do not affect interpretation. + +## 21. Contact + +Faradworks, Inc. + +Questions about these Terms or the Privacy Policy: [dev@faradworks.com](mailto:dev@faradworks.com) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..b9c0143 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,52 @@ +import type { NextConfig } from "next"; +import { + CSP_CONNECT_HOSTS, + CSP_FRAME_HOSTS, + CSP_SCRIPT_HOSTS, +} from "./src/lib/csp-hosts"; + +const extra = (hosts: string[]) => (hosts.length ? ` ${hosts.join(" ")}` : ""); + +const nextConfig: NextConfig = { + serverExternalPackages: ["pdfjs-dist"], + turbopack: { + resolveAlias: { + canvas: { browser: "" }, + }, + }, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Strict-Transport-Security", + value: "max-age=31536000; includeSubDomains", + }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://vercel.live" + + extra(CSP_SCRIPT_HOSTS), + "style-src 'self' 'unsafe-inline' https://vercel.live https://fonts.googleapis.com", + "img-src 'self' data: https: blob:", + "font-src 'self' data: https://vercel.live https://assets.vercel.com https://fonts.gstatic.com", + "connect-src 'self' blob: https://storage.googleapis.com https://vercel.live wss://ws-us3.pusher.com" + + extra(CSP_CONNECT_HOSTS) + + " " + + (process.env.NEXT_PUBLIC_API_URL?.trim() || "http://localhost:8000"), + "frame-src 'self' https://vercel.live" + extra(CSP_FRAME_HOSTS), + "worker-src 'self' blob:", + ].join("; "), + }, + ], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..12536dd --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,11719 @@ +{ + "name": "frontend", + "version": "2.6.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "2.6.0", + "dependencies": { + "@base-ui/react": "^1.3.0", + "@types/dagre": "^0.7.54", + "@xyflow/react": "^12.10.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dagre": "^0.8.5", + "lucide-react": "^1.8.0", + "next": "16.2.3", + "next-themes": "^0.4.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-markdown": "^10.1.0", + "react-pdf": "^10.4.1", + "shadcn": "^4.2.0", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.3", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.3.0.tgz", + "integrity": "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@base-ui/utils": "0.2.6", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.4.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.6.tgz", + "integrity": "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz", + "integrity": "sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^17.2.1", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.2", + "which": "^4.0.0", + "yocto-spinner": "^1.1.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", + "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz", + "integrity": "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.3.tgz", + "integrity": "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", + "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", + "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", + "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", + "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", + "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", + "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", + "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz", + "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@ts-morph/common/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eciesjs": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", + "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.5", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.3.tgz", + "integrity": "sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.3", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.2.tgz", + "integrity": "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", + "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.3", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.3", + "@next/swc-darwin-x64": "16.2.3", + "@next/swc-linux-arm64-gnu": "16.2.3", + "@next/swc-linux-arm64-musl": "16.2.3", + "@next/swc-linux-x64-gnu": "16.2.3", + "@next/swc-linux-x64-musl": "16.2.3", + "@next/swc-win32-arm64-msvc": "16.2.3", + "@next/swc-win32-x64-msvc": "16.2.3", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "license": "MIT" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-pdf": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz", + "integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.2.0.tgz", + "integrity": "sha512-ZDuV340itidaUd4Gi1BxQX+Y7Ush6BHp6URZBM2RyxUUBZ6yFtOWIr4nVY+Ro+YRSpo82v7JrsmtcU5xoBCMJQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.26.0", + "@types/validate-npm-package-name": "^4.0.2", + "browserslist": "^4.26.2", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "https-proxy-agent": "^7.0.6", + "kleur": "^4.1.5", + "msw": "^2.10.4", + "node-fetch": "^3.3.2", + "open": "^11.0.0", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "postcss-selector-parser": "^7.1.0", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "stringify-object": "^5.0.0", + "tailwind-merge": "^3.0.1", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "validate-npm-package-name": "^7.0.1", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + } + }, + "node_modules/shadcn/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/shadcn/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/shadcn/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/shadcn/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.1.0.tgz", + "integrity": "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..422ebfa --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "frontend", + "version": "2.6.0", + "private": true, + "scripts": { + "sync-version": "node scripts/sync-version.mjs", + "predev": "node scripts/sync-version.mjs", + "prebuild": "node scripts/sync-version.mjs", + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@base-ui/react": "^1.3.0", + "@types/dagre": "^0.7.54", + "@xyflow/react": "^12.10.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dagre": "^0.8.5", + "lucide-react": "^1.8.0", + "next": "16.2.3", + "next-themes": "^0.4.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-markdown": "^10.1.0", + "react-pdf": "^10.4.1", + "shadcn": "^4.2.0", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.3", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/datasheet.gif b/frontend/public/datasheet.gif new file mode 100644 index 0000000..0ac848a Binary files /dev/null and b/frontend/public/datasheet.gif differ diff --git a/frontend/public/derating.png b/frontend/public/derating.png new file mode 100644 index 0000000..6d01c1a Binary files /dev/null and b/frontend/public/derating.png differ diff --git a/frontend/public/eda-logos/altium.svg b/frontend/public/eda-logos/altium.svg new file mode 100644 index 0000000..9e118a4 --- /dev/null +++ b/frontend/public/eda-logos/altium.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/frontend/public/eda-logos/autodesk.svg b/frontend/public/eda-logos/autodesk.svg new file mode 100644 index 0000000..2698bf1 --- /dev/null +++ b/frontend/public/eda-logos/autodesk.svg @@ -0,0 +1,79 @@ + + + Autodesk logo + + + + + + + Autodesk logo + 21 sep 2021 + + + + + + + + + + + + + + + + diff --git a/frontend/public/eda-logos/cadence.svg b/frontend/public/eda-logos/cadence.svg new file mode 100644 index 0000000..faf5461 --- /dev/null +++ b/frontend/public/eda-logos/cadence.svg @@ -0,0 +1,39 @@ + + +Cadence Design Systems logo +A software company based in San Jose, California, United States that specialised in EDA + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/eda-logos/easyeda.svg b/frontend/public/eda-logos/easyeda.svg new file mode 100644 index 0000000..51c2e92 --- /dev/null +++ b/frontend/public/eda-logos/easyeda.svg @@ -0,0 +1,12 @@ + + + + Group 33 + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/frontend/public/eda-logos/kicad.svg b/frontend/public/eda-logos/kicad.svg new file mode 100644 index 0000000..a153366 --- /dev/null +++ b/frontend/public/eda-logos/kicad.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/frontend/public/eda-logos/orcad.svg b/frontend/public/eda-logos/orcad.svg new file mode 100644 index 0000000..bea1aff --- /dev/null +++ b/frontend/public/eda-logos/orcad.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/eda-logos/siemens.svg b/frontend/public/eda-logos/siemens.svg new file mode 100644 index 0000000..6b02d60 --- /dev/null +++ b/frontend/public/eda-logos/siemens.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL-SCHEMATIC.pdf b/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL-SCHEMATIC.pdf new file mode 100644 index 0000000..5b69588 Binary files /dev/null and b/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL-SCHEMATIC.pdf differ diff --git a/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL.asc b/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL.asc new file mode 100644 index 0000000..2a18ce0 --- /dev/null +++ b/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL.asc @@ -0,0 +1,122 @@ +*PADS-PCB* +*PART* +MH2 MountingHole:MountingHole_2.2mm_M2_Pad_Via +FM2 Fiducial:Fiducial_1mm_Mask2mm +C12 Capacitor_SMD:C_0603_1608Metric +C9 Capacitor_SMD:C_0603_1608Metric +FM3 Fiducial:Fiducial_1mm_Mask2mm +C6 Capacitor_SMD:C_0603_1608Metric +R2 Resistor_SMD:R_0603_1608Metric +C11 Capacitor_SMD:C_0603_1608Metric +J1 Connector_USB:USB_C_Receptacle_GCT_USB4085 +J3 Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Horizontal +MH4 MountingHole:MountingHole_2.2mm_M2_Pad_Via +U3 Package_QFP:LQFP-48_7x7mm_P0.5mm +R8 Resistor_SMD:R_0603_1608Metric +J2 Connector:Tag-Connect_TC2030-IDC-FP_2x03_P1.27mm_Vertical +R6 Resistor_SMD:R_0603_1608Metric +R4 Resistor_SMD:R_0603_1608Metric +R7 Resistor_SMD:R_0603_1608Metric +C4 Capacitor_SMD:C_0805_2012Metric +C10 Capacitor_SMD:C_0603_1608Metric +C3 Capacitor_SMD:C_0805_2012Metric +FM1 Fiducial:Fiducial_1mm_Mask2mm +X1 Crystal:Crystal_SMD_3225-4Pin_3.2x2.5mm +R3 Resistor_SMD:R_0603_1608Metric +MH1 MountingHole:MountingHole_2.2mm_M2_Pad_Via +D1 Package_TO_SOT_SMD:SOT-23-6 +C1 Capacitor_SMD:C_0603_1608Metric +U2 Package_SO:MSOP-10_3x3mm_P0.5mm +C2 Capacitor_SMD:C_0603_1608Metric +C5 Capacitor_SMD:C_0603_1608Metric +C8 Capacitor_SMD:C_0603_1608Metric +R1 Resistor_SMD:R_0603_1608Metric +D3 LED_SMD:LED_0603_1608Metric +U1 Package_TO_SOT_SMD:SOT-23-5 +C7 Capacitor_SMD:C_0805_2012Metric +R5 Resistor_SMD:R_0603_1608Metric +D2 LED_SMD:LED_0603_1608Metric +MH3 MountingHole:MountingHole_2.2mm_M2_Pad_Via + +*NET* +*SIGNAL* +1V35 +C8.1 U3.48 +*SIGNAL* +3V3 +C4.1 C7.1 J2.1 J3.6 R8.1 U1.5 U3.6 + +*SIGNAL* +5V +C2.1 C3.1 C5.1 R3.2 U1.1 U1.3 U2.7 + +*SIGNAL* +VUSB +C1.1 D1.5 J1.A4 J1.A9 J1.B4 J1.B9 R3.1 + +*SIGNAL* /HFXIN +C9.1 U3.11 X1.1 +*SIGNAL* /HFXOUT +C10.1 U3.12 X1.3 +*SIGNAL* /I2C0.SCL +J3.10 U3.19 +*SIGNAL* /I2C0.SDA +J3.8 U3.18 +*SIGNAL* /LEDGK +D3.1 R7.1 +*SIGNAL* /LEDRK +D2.1 R6.1 +*SIGNAL* /NRST +C12.1 J2.3 R8.2 U3.4 +*SIGNAL* /ROSC +R4.1 U3.8 +*SIGNAL* /SPI0.CLK +J3.1 U3.37 +*SIGNAL* /SPI0.CS0 +J3.4 U3.16 +*SIGNAL* /SPI0.MISO +J3.2 U3.38 +*SIGNAL* /SPI0.MOSI +J3.3 U3.36 +*SIGNAL* /SWCLK +J2.4 U3.35 +*SIGNAL* /SWDIO +J2.2 U3.34 +*SIGNAL* /TIMA0.CCP0 +J3.7 U3.24 +*SIGNAL* /TIMA0.CCP1 +J3.9 U3.23 +*SIGNAL* /TIMA1.CCP0 +D2.2 U3.14 +*SIGNAL* /TIMA1.CCP1 +D3.2 U3.15 +*SIGNAL* /UART0.NCTS +U2.4 U3.29 +*SIGNAL* /UART0.NRTS +U2.5 U3.30 +*SIGNAL* /UART0.RX +U2.8 U3.2 +*SIGNAL* /UART0.TX +U2.9 U3.1 +*SIGNAL* /UARTV3 +C6.1 U2.10 +*SIGNAL* /USB.D+ +D1.3 U2.1 +*SIGNAL* /USB.D- +D1.1 U2.2 +*SIGNAL* /USBC.CC1 +J1.A5 R2.1 +*SIGNAL* /USBC.CC2 +J1.B5 R1.1 +*SIGNAL* /USBC.D+ +D1.4 J1.A6 J1.B6 +*SIGNAL* /USBC.D- +D1.6 J1.A7 J1.B7 +*SIGNAL* /VREF+ +C11.2 U3.43 +*SIGNAL* /VREF- +C11.1 R5.1 U3.39 +*SIGNAL* GND +C1.2 C10.2 C12.2 C2.2 C3.2 C4.2 C5.2 +C6.2 C7.2 C8.2 C9.2 D1.2 J1.A1 +J1.A12 J1.B1 J1.B12 J1.S1 J2.5 J3.5 +MH1.1 MH2.1 MH3.1 MH4.1 R1.2 R2.2 +R4.2 R5.2 R6.2 R7.2 U1.2 U2.3 +U3.7 X1.2 X1.4 +*END* diff --git a/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL.csv b/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL.csv new file mode 100644 index 0000000..1dcb79c --- /dev/null +++ b/frontend/public/examples/TI-MSP-KICAD9-TUTORIAL.csv @@ -0,0 +1,17 @@ +"Reference","Qty","Value","Footprint","Datasheet","LCSC","Manufacturer Part Number" +"C1,C2,C5,C6,C8,C11,C12","7","470n","Capacitor_SMD:C_0603_1608Metric","","C1623","CL10B474KA8NNNC" +"C3,C4,C7","3","10u","Capacitor_SMD:C_0805_2012Metric","","C15850","CL21A106KAYNNNE" +"C9,C10","2","18p","Capacitor_SMD:C_0603_1608Metric","","C1647","CL10C180JB8NNNC" +"D1","1","USBLC6-2SC6","Package_TO_SOT_SMD:SOT-23-6","https://www.st.com/resource/en/datasheet/usblc6-2.pdf","C7519","USBLC6-2SC6" +"D2","1","RED","LED_SMD:LED_0603_1608Metric","","C2286","KT-0603R" +"D3","1","WHITE","LED_SMD:LED_0603_1608Metric","","C2290","KT-0603W" +"J1","1","USB4085-GF-A","Connector_USB:USB_C_Receptacle_GCT_USB4085","https://www.usb.org/sites/default/files/documents/usb_type-c.zip","C7095263","USB4085-GF-A" +"J3","1","PH-00834","Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Horizontal","","C2685185","PH-00834" +"R1,R2,R8","3","5k1","Resistor_SMD:R_0603_1608Metric","","C23186","0603WAF5101T5E" +"R3,R5","2","0R","Resistor_SMD:R_0603_1608Metric","","C21189","0603WAF0000T5E" +"R4","1","100k","Resistor_SMD:R_0603_1608Metric","","C25803","0603WAF1003T5E" +"R6,R7","2","1k","Resistor_SMD:R_0603_1608Metric","","C21190","0603WAF1001T5E" +"U1","1","SPX3819M5-L-3-3","Package_TO_SOT_SMD:SOT-23-5","https://www.exar.com/content/document.ashx?id=22106&languageid=1033&type=Datasheet&partnumber=SPX3819&filename=SPX3819.pdf&part=SPX3819","C9055","SPX3819M5-L-3-3/TR" +"U2","1","CH340E","Package_SO:MSOP-10_3x3mm_P0.5mm","https://www.mpja.com/download/35227cpdata.pdf","C99652","CH340E" +"U3","1","MSPM0G3507SPTR","Package_QFP:LQFP-48_7x7mm_P0.5mm","https://www.ti.com/lit/ds/symlink/mspm0g3507.pdf?ts=1747484513194&ref_url=https%253A%252F%252Fwww.ti.com%252Fproduct%252FMSPM0G3507","C22362630","MSPM0G3507SPTR" +"X1","1","8MHz","Crystal:Crystal_SMD_3225-4Pin_3.2x2.5mm","","C189764","AV08000301" diff --git a/frontend/public/faradworks-logo-white.png b/frontend/public/faradworks-logo-white.png new file mode 100644 index 0000000..583c1dc Binary files /dev/null and b/frontend/public/faradworks-logo-white.png differ diff --git a/frontend/public/favicon_io/android-chrome-192x192.png b/frontend/public/favicon_io/android-chrome-192x192.png new file mode 100644 index 0000000..ff61d81 Binary files /dev/null and b/frontend/public/favicon_io/android-chrome-192x192.png differ diff --git a/frontend/public/favicon_io/android-chrome-512x512.png b/frontend/public/favicon_io/android-chrome-512x512.png new file mode 100644 index 0000000..f0505d1 Binary files /dev/null and b/frontend/public/favicon_io/android-chrome-512x512.png differ diff --git a/frontend/public/favicon_io/apple-touch-icon.png b/frontend/public/favicon_io/apple-touch-icon.png new file mode 100644 index 0000000..ff6527b Binary files /dev/null and b/frontend/public/favicon_io/apple-touch-icon.png differ diff --git a/frontend/public/favicon_io/favicon-16x16.png b/frontend/public/favicon_io/favicon-16x16.png new file mode 100644 index 0000000..5e701e6 Binary files /dev/null and b/frontend/public/favicon_io/favicon-16x16.png differ diff --git a/frontend/public/favicon_io/favicon-32x32.png b/frontend/public/favicon_io/favicon-32x32.png new file mode 100644 index 0000000..a4b8f2b Binary files /dev/null and b/frontend/public/favicon_io/favicon-32x32.png differ diff --git a/frontend/public/favicon_io/favicon.ico b/frontend/public/favicon_io/favicon.ico new file mode 100644 index 0000000..ba96565 Binary files /dev/null and b/frontend/public/favicon_io/favicon.ico differ diff --git a/frontend/public/favicon_io/site.webmanifest b/frontend/public/favicon_io/site.webmanifest new file mode 100644 index 0000000..57ec2d5 --- /dev/null +++ b/frontend/public/favicon_io/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Pinscope", + "short_name": "Pinscope", + "icons": [ + { + "src": "/favicon_io/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon_io/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#3B82F6", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/power-tree.gif b/frontend/public/power-tree.gif new file mode 100644 index 0000000..8929186 Binary files /dev/null and b/frontend/public/power-tree.gif differ diff --git a/frontend/public/report.png b/frontend/public/report.png new file mode 100644 index 0000000..5644f0e Binary files /dev/null and b/frontend/public/report.png differ diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/scripts/sync-version.mjs b/frontend/scripts/sync-version.mjs new file mode 100644 index 0000000..f28f95c --- /dev/null +++ b/frontend/scripts/sync-version.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * Single source of truth for the app version: content/changelog.md. + * + * Parses the topmost `## X.Y.Z — ` heading and propagates it to: + * - src/lib/version.ts (APP_VERSION, shown in the UI sidebar) + * - package.json ("version" field) + * + * Runs automatically via the `predev` / `prebuild` npm hooks, so the + * sidebar version, package.json, and the changelog can never drift. + * To cut a release, just add a new `## X.Y.Z — ` section at the + * top of content/changelog.md. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const changelogPath = join(root, "content", "changelog.md"); +const versionTsPath = join(root, "src", "lib", "version.ts"); +const pkgPath = join(root, "package.json"); + +const changelog = readFileSync(changelogPath, "utf-8"); +const match = changelog.match( + /^##\s+(\d+\.\d+\.\d+)(?:\s+[—-]\s+(\d{4}-\d{2}-\d{2}))?/m, +); +if (!match) { + console.error( + `[sync-version] No "## X.Y.Z" heading found in ${changelogPath}`, + ); + process.exit(1); +} +const version = match[1]; +const versionDate = match[2] ?? ""; + +// --- src/lib/version.ts (generated; do not edit by hand) --------------- +const versionTs = `// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md. +// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog. +export const APP_VERSION = "${version}"; +export const APP_VERSION_DATE = "${versionDate}"; +`; +let tsChanged = false; +try { + tsChanged = readFileSync(versionTsPath, "utf-8") !== versionTs; +} catch { + tsChanged = true; +} +if (tsChanged) writeFileSync(versionTsPath, versionTs); + +// --- package.json "version" ------------------------------------------- +const pkgRaw = readFileSync(pkgPath, "utf-8"); +const pkg = JSON.parse(pkgRaw); +const pkgChanged = pkg.version !== version; +if (pkgChanged) { + pkg.version = version; + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); +} + +if (tsChanged || pkgChanged) { + console.log( + `[sync-version] Synced to ${version} (` + + `${tsChanged ? "version.ts" : ""}` + + `${tsChanged && pkgChanged ? " + " : ""}` + + `${pkgChanged ? "package.json" : ""}).`, + ); +} else { + console.log(`[sync-version] Already at ${version}.`); +} diff --git a/frontend/src/app/(app)/admin/page.tsx b/frontend/src/app/(app)/admin/page.tsx new file mode 100644 index 0000000..2cf5a6f --- /dev/null +++ b/frontend/src/app/(app)/admin/page.tsx @@ -0,0 +1,1899 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useOptionalUser } from "@/hooks/use-optional-auth"; +import { useRouter } from "next/navigation"; +import { + Database, + Users, + Shield, + Loader2, + Check, + Package, + Cpu, + Zap, + Trash2, + DollarSign, + ChevronDown, + ChevronRight, + FolderOpen, + Activity, + ExternalLink, + Clock, + RotateCw, + MoreVertical, + Gauge, + Copy, + Settings, + SlidersHorizontal, + MessageSquareWarning, + Search, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + adminAdjustCredits, + fetchAdminComponents, + fetchAdminComponentJson, + fetchAdminUsers, + searchAdminUsers, + fetchAdminUsage, + fetchAdminProjects, + fetchAdminRuns, + fetchAdminSettings, + setMinModelVersion, + restartPipeline, + regenPipeline, + adminMarkProjectComplete, + deleteAdminComponent, + deleteAdminFinding, + fetchAdminFeedback, + updateAdminFeedback, + type AdminComponents, + type AdminUser, + type AdminUsage, + type AdminUsageUser, + type AdminProject, + type AdminPipelineRun, + type AdminSettings, + type FeedbackTicket, +} from "@/lib/api"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +type Tab = "projects" | "runs" | "components" | "users" | "usage" | "overrides" | "feedback" | "settings"; + +const TABS: { id: Tab; label: string; icon: typeof Database }[] = [ + { id: "projects", label: "Projects", icon: FolderOpen }, + { id: "runs", label: "Pipeline Runs", icon: Activity }, + { id: "components", label: "Components", icon: Database }, + { id: "users", label: "Users & Credits", icon: Users }, + { id: "usage", label: "API Usage", icon: DollarSign }, + { id: "overrides", label: "Overrides", icon: SlidersHorizontal }, + { id: "feedback", label: "Feedback", icon: MessageSquareWarning }, + { id: "settings", label: "Settings", icon: Settings }, +]; + +export default function AdminPage() { + const { user, isLoaded } = useOptionalUser(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState("projects"); + + const isAdmin = user?.isAdmin ?? false; + + // Redirect non-admins once Clerk has loaded + useEffect(() => { + if (isLoaded && !isAdmin) { + router.replace("/dashboard"); + } + }, [isLoaded, isAdmin, router]); + + if (!isLoaded) { + return ( +
+ +
+ ); + } + + if (!isAdmin) return null; + + return ( +
+ {/* Header */} +
+
+ +

Admin

+
+

+ Manage library components and user settings +

+
+ + {/* Body: sidebar tabs + content */} +
+ {/* Left tab navigation */} + + + {/* Content panel */} +
+ {activeTab === "projects" && } + {activeTab === "runs" && } + {activeTab === "components" && } + {activeTab === "users" && } + {activeTab === "usage" && } + {activeTab === "overrides" && } + {activeTab === "feedback" && } + {activeTab === "settings" && } +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Projects Panel +// --------------------------------------------------------------------------- + +const STATUS_COLORS: Record = { + draft: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400", + running: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + complete: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", + error: "bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300", + cancelled: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", + paused_insufficient_credits: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", + paused_by_user: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", +}; + +function formatCostShort(usd: number | null): string { + if (!usd || usd === 0) return "-"; + if (usd < 0.01) return "<$0.01"; + return "$" + usd.toFixed(2); +} + +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function ProjectsPanel() { + const router = useRouter(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + const [restarting, setRestarting] = useState(null); + + async function handleRestart(projectId: string) { + setRestarting(projectId); + try { + await restartPipeline(projectId); + setProjects((prev) => + prev.map((p) => (p.id === projectId ? { ...p, status: "running", pipeline_state: null } : p)), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to restart pipeline"); + } finally { + setRestarting(null); + } + } + + async function handleRegen(projectId: string, stages: string[]) { + setRestarting(projectId); + try { + await regenPipeline(projectId, stages); + setProjects((prev) => + prev.map((p) => (p.id === projectId ? { ...p, status: "running", pipeline_state: null } : p)), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start regen"); + } finally { + setRestarting(null); + } + } + + function handleCloneAsNew(projectId: string) { + // Stash the source project id and hand off to the dashboard, which + // mounts the create-project dialog. The dialog fetches the full + // Project on its end so we don't have to pass the entire object here. + window.sessionStorage.setItem("pinscopex:cloneAsNewProjectId", projectId); + router.push("/dashboard"); + } + + async function handleMarkComplete(projectId: string) { + setRestarting(projectId); + try { + await adminMarkProjectComplete(projectId); + setProjects((prev) => + prev.map((p) => (p.id === projectId ? { ...p, status: "complete" } : p)), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to mark complete"); + } finally { + setRestarting(null); + } + } + + useEffect(() => { + fetchAdminProjects() + .then(setProjects) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + if (loading) + return ( +
+ Loading projects... +
+ ); + if (error) + return

Error: {error}

; + + const lf = filter.toLowerCase(); + const filtered = projects + .filter( + (p) => + p.name.toLowerCase().includes(lf) || + (p.owner_name ?? "").toLowerCase().includes(lf) || + (p.owner_email ?? "").toLowerCase().includes(lf) || + p.status.toLowerCase().includes(lf), + ) + .sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); + + const statusCounts = projects.reduce>((acc, p) => { + acc[p.status] = (acc[p.status] || 0) + 1; + return acc; + }, {}); + + return ( +
+ {/* Stats bar */} +
+
+ + {projects.length} + total +
+ {Object.entries(statusCounts).map(([status, count]) => ( +
+ + {count} + {status} +
+ ))} +
+ setFilter(e.target.value)} + className="w-72" + /> +
+ + {filtered.length === 0 ? ( +
+ +

+ {filter ? "No projects match your filter" : "No projects yet"} +

+
+ ) : ( +
+ + + + + + + + + + + + + + {filtered.map((p) => { + const errorMsg = p.status === "error" && p.pipeline_state + ? (p.pipeline_state as Record).error + : null; + return ( + router.push(`/project/${p.id}`)} + > + + + + + + + + + ); + })} + +
ProjectOwnerStatusCostCreatedErrorActions
+
{p.name}
+
{p.id.slice(0, 8)}
+
+
+ {p.owner_name ? ( +
{p.owner_name}
+ ) : null} +
+ {p.owner_email || p.user_id.slice(0, 12)} +
+
+
+ + {p.status} + + + {formatCostShort(p.total_cost_usd)} + + {formatRelativeTime(p.created)} + + {errorMsg ? ( + {errorMsg} + ) : ( + - + )} + e.stopPropagation()}> + {p.has_bom && p.has_netlist && ( + restarting === p.id ? ( + + ) : ( + + + + + + {(p.status === "paused_insufficient_credits" || + p.status === "paused_by_user") && ( + <> +
+ Paused run +
+ handleMarkComplete(p.id)}> + + Mark as complete + + + + )} +
+ Free — not charged to user +
+ handleRestart(p.id)}> + + Rerun full pipeline + + +
+ Regen only +
+ handleRegen(p.id, ["derating"])}> + + Regen derating + + +
+ Clone +
+ handleCloneAsNew(p.id)}> + + Rerun as new project + +
+
+ ) + )} +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Pipeline Runs Panel +// --------------------------------------------------------------------------- + +const STAGE_LABELS: Record = { + bom_parse: "Parsing BOM", + ic_extraction: "IC Extraction", + simple_extraction: "Specs Extraction", + passive_extraction: "Passive Extraction", + digikey_resolve: "DigiKey Resolve", + graph_build: "Building Graph", + bom_summary: "BOM Summary", + derating: "Derating", + validation: "Validation", +}; + +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (m === 0) return `${s}s`; + return `${m}m ${s}s`; +} + +function RunsPanel() { + const router = useRouter(); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchRuns = useCallback(() => { + fetchAdminRuns() + .then(setRuns) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + fetchRuns(); + const interval = setInterval(fetchRuns, 5000); + return () => clearInterval(interval); + }, [fetchRuns]); + + if (loading) + return ( +
+ Loading pipeline runs... +
+ ); + if (error) + return

Error: {error}

; + + if (runs.length === 0) { + return ( +
+
+ + Auto-refreshes every 5 seconds +
+
+ +

No pipelines currently running

+
+
+ ); + } + + return ( +
+
+
+ + {runs.length} + running +
+
+ + Auto-refreshes every 5s +
+
+ +
+ + + + + + + + + + + + {runs.map((run) => ( + router.push(`/project/${run.project_id}/progress`)} + > + + + + + + + + ))} + +
ProjectOwnerStageSubstepDuration +
{run.project_name} +
+ {run.owner_name ? ( +
{run.owner_name}
+ ) : null} +
+ {run.owner_email || run.user_id.slice(0, 12)} +
+
+
+ {run.current_stage ? ( + + {STAGE_LABELS[run.current_stage] || run.current_stage} + + ) : ( + Starting... + )} + + {run.current_substep || "-"} + + {formatDuration(run.duration_seconds)} + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Components Panel +// --------------------------------------------------------------------------- + +function ComponentsPanel() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + const [deleting, setDeleting] = useState(null); + const [selectedComponent, setSelectedComponent] = useState<{ + type: "ic" | "passive" | "simple"; + name: string; + } | null>(null); + const [componentJson, setComponentJson] = useState | null>(null); + const [jsonLoading, setJsonLoading] = useState(false); + + function reload() { + setError(null); + fetchAdminComponents() + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + } + + useEffect(() => { + reload(); + }, []); + + async function handleDelete(type: "ic" | "passive" | "simple", name: string) { + const label = type === "ic" ? "IC extraction" : type === "passive" ? "passive pattern" : "component specs"; + if (!confirm(`Delete ${label} "${name}"? This cannot be undone.`)) return; + setDeleting(name); + setError(null); + try { + await deleteAdminComponent(type, name); + // Remove from local state immediately, then refresh from server + setData((prev) => { + if (!prev) return prev; + if (type === "ic") return { ...prev, ics: prev.ics.filter((c) => c.mpn !== name) }; + if (type === "passive") return { ...prev, passives: prev.passives.filter((c) => c.mpn !== name) }; + return { ...prev, simple: prev.simple.filter((c) => c.mpn !== name) }; + }); + reload(); + } catch (e) { + setError(e instanceof Error ? e.message : "Delete failed"); + } finally { + setDeleting(null); + } + } + + async function handleRowClick(type: "ic" | "passive" | "simple", name: string) { + setSelectedComponent({ type, name }); + setComponentJson(null); + setJsonLoading(true); + try { + const json = await fetchAdminComponentJson(type, name); + setComponentJson(json); + } catch { + setComponentJson({ error: "Failed to load component JSON" }); + } finally { + setJsonLoading(false); + } + } + + if (loading) + return ( +
+ Loading components... +
+ ); + if (error) + return

Error: {error}

; + if (!data) return null; + + const lf = filter.toLowerCase(); + const filteredICs = data.ics.filter( + (c) => + c.mpn.toLowerCase().includes(lf) || + c.subtype.toLowerCase().includes(lf), + ); + const filteredPassives = data.passives.filter( + (c) => + c.mpn.toLowerCase().includes(lf) || + c.subtype.toLowerCase().includes(lf) || + c.description.toLowerCase().includes(lf), + ); + const filteredSimple = (data.simple ?? []).filter( + (c) => + c.mpn.toLowerCase().includes(lf) || + c.subtype.toLowerCase().includes(lf) || + c.specs_type.toLowerCase().includes(lf), + ); + + return ( +
+ {/* Stats bar */} +
+
+ + {data.ics.length} + ICs +
+
+ + {data.passives.length} + Passive Patterns +
+ {(data.simple?.length ?? 0) > 0 && ( +
+ + {data.simple.length} + Component Specs +
+ )} +
+ setFilter(e.target.value)} + className="w-64" + /> +
+ + {/* IC table */} + {filteredICs.length > 0 && ( +
+

IC Extractions

+
+ + + + + + + + + + + {filteredICs.map((ic) => ( + handleRowClick("ic", ic.mpn)}> + + + + + + + ))} + +
MPNSubtypePins + Abs Max + +
{ic.mpn} + {ic.subtype ? ( + {ic.subtype} + ) : ( + - + )} + {ic.pin_count} + {ic.has_ratings ? ( + + ) : ( + - + )} + + +
+
+
+ )} + + {/* Passive patterns table */} + {filteredPassives.length > 0 && ( +
+

Passive Patterns

+
+ + + + + + + + + + + {filteredPassives.map((p) => ( + handleRowClick("passive", p.mpn)}> + + + + + + + ))} + +
NameType + Description + Regex +
{p.mpn} + {p.subtype ? ( + {p.subtype} + ) : ( + - + )} + + {p.description || "-"} + + {p.regex || "-"} + + +
+
+
+ )} + + {/* Simple component specs table */} + {filteredSimple.length > 0 && ( +
+

Component Specs

+
+ + + + + + + + + + + {filteredSimple.map((s) => ( + handleRowClick("simple", s.mpn)}> + + + + + + + ))} + +
MPNTypeSubtypeParams +
{s.mpn} + {s.specs_type} + + {s.subtype ? ( + {s.subtype} + ) : ( + - + )} + {s.param_count} + +
+
+
+ )} + + {filteredICs.length === 0 && filteredPassives.length === 0 && filteredSimple.length === 0 && ( +
+ +

+ {filter ? "No components match your filter" : "No components in the library yet"} +

+
+ )} + + {/* Component JSON viewer dialog */} + { if (!open) setSelectedComponent(null); }} + > + + + + {selectedComponent?.name} + + +
+ {jsonLoading ? ( +
+ Loading... +
+ ) : componentJson ? ( +
+                {JSON.stringify(componentJson, null, 2)}
+              
+ ) : null} +
+ +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Users Panel +// --------------------------------------------------------------------------- + +function UsersTable({ + users, + onAdjust, +}: { + users: AdminUser[]; + onAdjust: (u: AdminUser) => void; +}) { + return ( +
+ + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
UserProjectsBalance +
+
+ {u.image_url ? ( + + ) : ( +
+ +
+ )} +
+ {u.name ? ( + <> +
{u.name}
+ {u.email && ( +
{u.email}
+ )} + + ) : ( +
+ {u.email || u.user_id} +
+ )} +
+
+
{u.project_count} + {u.balance.toFixed(2)} + + +
+
+ ); +} + +function UsersPanel() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [adjusting, setAdjusting] = useState(null); + + // Email search — finds any registered user via Clerk, including those + // with no project and no credit activity yet. + const [query, setQuery] = useState(""); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [results, setResults] = useState(null); + + const reload = useCallback(() => { + setLoading(true); + fetchAdminUsers() + .then(setUsers) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + reload(); + }, [reload]); + + const runSearch = useCallback(async () => { + const email = query.trim(); + if (!email) { + setResults(null); + setSearchError(null); + return; + } + setSearching(true); + setSearchError(null); + try { + setResults(await searchAdminUsers(email)); + } catch (e) { + setSearchError(e instanceof Error ? e.message : "Search failed"); + setResults(null); + } finally { + setSearching(false); + } + }, [query]); + + const clearSearch = useCallback(() => { + setQuery(""); + setResults(null); + setSearchError(null); + }, []); + + const afterAdjust = useCallback(() => { + setAdjusting(null); + reload(); + if (results !== null) runSearch(); + }, [reload, results, runSearch]); + + if (loading) + return ( +
+ Loading users... +
+ ); + if (error) + return

Error: {error}

; + + const showingSearch = results !== null; + const list = showingSearch ? results : users; + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") runSearch(); + }} + placeholder="Find any user by email…" + className="max-w-xs" + /> + + {showingSearch && ( + + )} +
+ {searchError &&

{searchError}

} + +

+ {showingSearch ? `Search results (${list.length})` : `Users (${list.length})`} +

+ + {list.length === 0 ? ( +
+ +

+ {showingSearch ? "No user found with that email" : "No users yet"} +

+
+ ) : ( + + )} + + {adjusting && ( + setAdjusting(null)} + onAdjusted={afterAdjust} + /> + )} +
+ ); +} + +function AdjustCreditsDialog({ + user, + onClose, + onAdjusted, +}: { + user: AdminUser; + onClose: () => void; + onAdjusted: () => void; +}) { + const [delta, setDelta] = useState("0"); + const [note, setNote] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const parsed = parseFloat(delta); + const valid = !Number.isNaN(parsed) && parsed !== 0 && note.trim().length > 0; + + async function handleSave() { + if (!valid) return; + setSaving(true); + setError(null); + try { + await adminAdjustCredits(user.user_id, parsed, note.trim()); + onAdjusted(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed"); + setSaving(false); + } + } + + return ( + !o && onClose()}> + + + + Adjust credits — {user.name || user.email || user.user_id} + + +
+
+
Current balance
+
{user.balance.toFixed(2)}
+
+
+ + setDelta(e.target.value)} + className="font-mono mt-1" + /> +
+
+ + setNote(e.target.value)} + placeholder="e.g. goodwill / correction" + className="mt-1" + /> +
+ {error &&

{error}

} +
+ + + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Usage Panel +// --------------------------------------------------------------------------- + +function formatCost(usd: number): string { + if (usd === 0) return "$0.00"; + if (usd < 0.01) return "<$0.01"; + return "$" + usd.toFixed(2); +} + +function UsagePanel() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedUser, setExpandedUser] = useState(null); + + useEffect(() => { + fetchAdminUsage() + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + if (loading) + return ( +
+ Loading usage data... +
+ ); + if (error) + return

Error: {error}

; + if (!data) return null; + + const sortedUsers = [...data.users].sort( + (a, b) => b.total_cost_usd - a.total_cost_usd, + ); + + return ( +
+ {/* Grand total */} +
+
+ + {formatCost(data.grand_total_usd)} + total spend +
+
+ + {data.users.length} + users +
+
+ + {sortedUsers.length === 0 ? ( +
+ +

No usage data yet

+
+ ) : ( +
+

Usage by User

+
+ + + + + + + + + + {sortedUsers.map((u) => ( + + setExpandedUser( + expandedUser === u.user_id ? null : u.user_id, + ) + } + /> + ))} + +
+ UserProjectsCost
+
+
+ )} +
+ ); +} + +function UserUsageRow({ + user, + expanded, + onToggle, +}: { + user: AdminUsageUser; + expanded: boolean; + onToggle: () => void; +}) { + const sortedProjects = [...user.projects].sort( + (a, b) => b.cost_usd - a.cost_usd, + ); + + return ( + <> + + + {expanded ? ( + + ) : ( + + )} + + +
+ {user.name ? ( + <> +
{user.name}
+ {user.email && ( +
+ {user.email} +
+ )} + + ) : ( +
+ {user.email || user.user_id} +
+ )} +
+ + {user.project_count} + + {formatCost(user.total_cost_usd)} + + + {expanded && + sortedProjects.map((p) => ( + + + + {p.name} + + + + {p.status} + + + + {formatCost(p.cost_usd)} + + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Settings Panel +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Overrides Panel +// --------------------------------------------------------------------------- + +function OverridesPanel() { + const [projectId, setProjectId] = useState(""); + const [ruleId, setRuleId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState<{ + deleted: string; + project_id: string; + remaining: number; + } | null>(null); + + async function handleDelete() { + if (!projectId.trim() || !ruleId.trim()) return; + setSubmitting(true); + setError(null); + setResult(null); + try { + const res = await deleteAdminFinding(projectId.trim(), ruleId.trim()); + setResult({ + deleted: res.deleted, + project_id: res.project_id, + remaining: res.remaining, + }); + setRuleId(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to delete rule"); + } finally { + setSubmitting(false); + } + } + + const canSubmit = projectId.trim() && ruleId.trim() && !submitting; + + return ( +
+
+

Overrides

+

+ Manual corrections to pipeline outputs. More override tools will land + here over time. +

+
+ +
+
+
Delete rule from report
+
+ Removes a single finding from the project's report.json and + refreshes the summary counts. No-op if the rule is not in the + report. +
+
+ +
+
+ + ) => + setProjectId(e.target.value) + } + placeholder="e.g. 7f3a2c1e-..." + className="font-mono" + /> +
+ +
+ + ) => + setRuleId(e.target.value) + } + placeholder="e.g. U3-001" + className="font-mono" + /> +
+ +
+ +
+
+ + {error &&

{error}

} + + {result && ( +
+
+ Deleted rule{" "} + {result.deleted} from project{" "} + {result.project_id}. +
+
+ {result.remaining} finding{result.remaining === 1 ? "" : "s"}{" "} + remaining in report. +
+
+ )} +
+
+ ); +} + +function SettingsPanel() { + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editVersion, setEditVersion] = useState(""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + fetchAdminSettings() + .then((data) => { + setSettings(data); + setEditVersion(data.min_model_version); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + async function handleSave() { + setSaving(true); + setError(null); + setSaved(false); + try { + await setMinModelVersion(editVersion); + setSettings((prev) => + prev ? { ...prev, min_model_version: editVersion } : prev, + ); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSaving(false); + } + } + + const hasChanged = settings && editVersion !== settings.min_model_version; + const isValidSemver = /^\d+\.\d+\.\d+$/.test(editVersion); + + if (loading) + return ( +
+ Loading settings... +
+ ); + + if (!settings) return null; + + return ( +
+
+

Model Version

+

+ Control when cached library extractions are re-extracted with newer + skills. +

+
+ + {error &&

{error}

} + +
+ {/* Read-only: current default */} +
+
+
+ Current extraction version +
+
+ From skills_manifest.json (read-only) +
+
+ + {settings.default_model_version} + +
+ + {/* Editable: min version threshold */} +
+
+
+ Force refresh if older than +
+
+ Library components with a model_version below this will be + re-extracted on next pipeline run. To refresh all existing + extractions, set this to the current extraction version above. + Set to 0.0.0 to disable. +
+
+
+ ) => + setEditVersion(e.target.value) + } + placeholder="e.g. 1.0.1" + className="w-32 font-mono" + /> + +
+ {editVersion && !isValidSemver && ( +

+ Must be a valid semver (e.g. 1.0.1) +

+ )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Feedback Panel +// --------------------------------------------------------------------------- + +const FEEDBACK_STATUS_STYLES: Record = { + open: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30", + acknowledged: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30", + resolved: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30", +}; + + +function FeedbackPanel() { + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [selected, setSelected] = useState(null); + const [editStatus, setEditStatus] = useState(""); + const [editNotes, setEditNotes] = useState(""); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + const data = await fetchAdminFeedback(); + setTickets(data); + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const filtered = tickets.filter((t) => { + if (statusFilter !== "all" && t.status !== statusFilter) return false; + if (filter) { + const q = filter.toLowerCase(); + const searchable = [t.user_name, t.user_email, t.project_name, t.finding_id, t.message] + .filter(Boolean) + .join(" ") + .toLowerCase(); + if (!searchable.includes(q)) return false; + } + return true; + }); + + const counts = { + total: tickets.length, + open: tickets.filter((t) => t.status === "open").length, + acknowledged: tickets.filter((t) => t.status === "acknowledged").length, + resolved: tickets.filter((t) => t.status === "resolved").length, + }; + + function openDetail(t: FeedbackTicket) { + setSelected(t); + setEditStatus(t.status); + setEditNotes(t.admin_notes ?? ""); + } + + async function handleSave() { + if (!selected) return; + setSaving(true); + try { + const updated = await updateAdminFeedback(selected.ticket_id, { + status: editStatus, + admin_notes: editNotes || undefined, + }); + setTickets((prev) => prev.map((t) => (t.ticket_id === updated.ticket_id ? updated : t))); + setSelected(null); + } catch { + // ignore + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Stats */} +
+ + Total: {counts.total} + + + Open: {counts.open} + + + Acknowledged: {counts.acknowledged} + + + Resolved: {counts.resolved} + +
+ + {/* Filters */} +
+ setFilter(e.target.value)} + className="max-w-xs" + /> +
+ {["all", "open", "acknowledged", "resolved"].map((s) => ( + + ))} +
+
+ + {/* Table */} + {filtered.length === 0 ? ( +

+ {tickets.length === 0 ? "No feedback tickets yet." : "No tickets match your filters."} +

+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((t) => ( + openDetail(t)} + className="border-b border-border last:border-0 hover:bg-accent/30 cursor-pointer transition-colors" + > + + + + + + + + ))} + +
UserFindingProjectMessageStatusCreated
+
{t.user_name ?? "—"}
+
{t.user_email ?? ""}
+
+ {t.finding_id ?? "—"} + + {t.project_name ?? "—"} + + {t.message} + + + {t.status} + + + {formatRelativeTime(t.created_at)} +
+
+ )} + + {/* Detail dialog */} + { if (!open) setSelected(null); }}> + + + Feedback Detail + + {selected && ( +
+ {/* Meta */} +
+ + {selected.status} + + + by {selected.user_name ?? selected.user_email ?? selected.user_id} + + + {formatRelativeTime(selected.created_at)} + +
+ + {/* Finding context */} + {selected.finding_id && ( +
+
+ {selected.finding_id} + {selected.finding_designator && ( + {selected.finding_designator} + )} + {selected.finding_mpn && ( + {selected.finding_mpn} + )} + {selected.finding_status && ( + + {selected.finding_status} + + )} +
+ {selected.finding_text && ( +

{selected.finding_text}

+ )} +
+ )} + + {/* Project */} + {selected.project_name && ( +

+ Project: {selected.project_name} +

+ )} + + {/* Message */} +
+

{selected.message}

+
+ + {/* Status update */} +
+ +
+ {(["open", "acknowledged", "resolved"] as const).map((s) => ( + + ))} +
+
+ + {/* Admin notes */} +
+ +