From 0a27d71675672d5d394cb56fe35c25d5b997ddfc Mon Sep 17 00:00:00 2001 From: ratchaw Date: Tue, 7 Jul 2026 12:09:54 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Phase=200:=20benchmark=20dataset=20decision?= =?UTF-8?q?=20=E2=80=94=20DocILE=20invoices=20+=20CORD-v2=20receipts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 25 docs from each, pinned by ID + checksum, fetched from canonical hosts (never vendored). SROIE and RVL-CDIP rejected: no field-level bbox ground truth, so grounding accuracy — the auditability wedge — would be unmeasurable. Full record in docs/benchmark-dataset.md. Co-Authored-By: Claude Fable 5 --- ROADMAP.md | 5 ++-- docs/benchmark-dataset.md | 61 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 docs/benchmark-dataset.md diff --git a/ROADMAP.md b/ROADMAP.md index a676375..16cf850 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,8 @@ ## Phase 0 — Pre-flight -- [ ] Benchmark dataset chosen (~50 public invoices/receipts; candidates: SROIE, CORD, RVL-CDIP subsets) +- [x] Benchmark dataset chosen: **25 DocILE invoices + 25 CORD-v2 receipts**, pinned by doc ID + checksum, fetched from canonical hosts (never vendored). Decision record: [docs/benchmark-dataset.md](./docs/benchmark-dataset.md) +- [ ] Request DocILE access token at [docile.rossum.ai](https://docile.rossum.ai/) — human action; blocks the Phase 2 data pull (CORD half is ungated) ## Phase 1 — Core library (`packages/core`) @@ -13,7 +14,7 @@ ## Phase 2 — Evals (`packages/evals`) -- [ ] Harness: accuracy per field per model, cost per 1k docs +- [ ] Harness: accuracy per field per model, grounding accuracy (predicted vs. ground-truth bbox), cost per 1k docs - [ ] Benchmark page generated from real runs (engineering target: >90% field accuracy on the invoice set) - [ ] Benchmark table in README diff --git a/docs/benchmark-dataset.md b/docs/benchmark-dataset.md new file mode 100644 index 0000000..24a70b1 --- /dev/null +++ b/docs/benchmark-dataset.md @@ -0,0 +1,61 @@ +# Benchmark dataset — Phase 0 decision record + +**Decision:** the v1 benchmark set is **~50 documents: 25 invoices from DocILE + 25 receipts from CORD-v2**, pinned by document ID + checksum and fetched from the canonical hosts at eval time. Documents are never vendored into the repo. + +Decided 2026-07-07. Feeds `packages/evals` (Phase 2). The exact pinned ID list is produced during Phase 2 curation and may shift a few documents while staying at ~50. + +## Requirements + +The benchmark exists to back the core claim — grounded, auditable extraction — with real numbers. That set four hard requirements: + +1. **Field-level ground truth** (values), so per-field accuracy is measurable. +2. **Bounding-box ground truth per field**, so provenance can be scored (predicted bbox vs. annotated bbox), not just values. Without this, the auditability wedge is unmeasurable. +3. **Domain match:** real invoices and receipts — the locked v1 scope. +4. **Workable licensing:** running and publishing benchmark results must be clearly fine, and others must be able to reproduce the runs without us redistributing documents. + +## Candidates + +| Dataset | Domain | Released docs | Field ground truth | Field bboxes | License / access | Verdict | +|---|---|---|---|---|---|---| +| **DocILE** | Real business documents (invoices, orders, etc.) from public sources | 6,680 annotated (+100k synthetic, ~1M unlabeled) | 55 field classes + line items (KILE/LIR tasks) | Yes — bbox + page per field | Research-oriented terms; free token via access-request form; tooling is MIT | **Selected — invoice half** | +| **CORD-v2** | Indonesian shop/restaurant receipts (photos) | 1,000 (800 train / 100 dev / 100 test) | 30 semantic classes in 5 superclasses, line grouping | Yes — quad boxes | CC BY 4.0, official NAVER repo on Hugging Face, ungated | **Selected — receipt half** | +| SROIE (ICDAR 2019) | Malaysian scanned receipts | ~1,000 (626 train / 347 test) | 4 fields only (company, date, address, total) | No — KIE values are not linked to boxes (word boxes exist separately in Tasks 1/2) | No explicit dataset license; portal registration; mirrors of unclear standing | Rejected — can't score grounding, schema too shallow, murky license | +| RVL-CDIP | 16-class grayscale document scans | 400k | None — one document-type label per image | No | Legacy IIT-CDIP terms | Rejected — classification dataset, no extraction ground truth | + +RVL-CDIP was listed as a roadmap candidate but is a document-*classification* dataset; it has no field annotations at all, so it cannot evaluate extraction. + +## Composition (~50 docs) + +- **25 invoices — DocILE annotated set.** Real business documents; curate for invoice-type docs with line items, mixing single- and multi-page layouts and diverse vendors. This is the hard case core targets (PDF, tables, multi-page). +- **25 receipts — CORD-v2 test split** (100 docs; we pin 25). Photographed receipts stress OCR quality: skew, crumple, low contrast. Line items present. +- The selection lives in-repo as **document IDs + content checksums** plus a fetch script: CORD-v2 from Hugging Face directly; DocILE via its downloader with a user-supplied token (e.g. `DOCILE_TOKEN`). Runners without a DocILE token can still run the receipt half. +- `packages/evals` maps each dataset's native labels onto the extractkit demo Zod schemas (invoice, receipt): vendor/merchant, date, currency, totals, tax, line items `{ description, qty, unitPrice, amount }`. The mapping code ships in the harness, so the mapping itself is auditable. + +## Metrics this choice enables (Phase 2 sketch) + +- **Per-field value accuracy** per model (normalized comparison) — already on the roadmap. +- **Grounding accuracy:** predicted bbox vs. ground-truth bbox overlap. This is the metric no value-only dataset (SROIE) could support, and the reason both selected datasets have field-level boxes. +- **Cost per 1k docs** from tracked token usage — already on the roadmap. +- Candidate, not committed: confidence calibration (reported confidence vs. observed correctness). + +## Licensing & redistribution posture + +- **CORD-v2** is CC BY 4.0 — redistribution with attribution would be allowed, but we still don't vendor images; the repo stores IDs, checksums, and mapping code only. Attribution to NAVER CLOVA goes on the benchmark page. +- **DocILE** sits behind a free access-request token under research-oriented terms. We never redistribute its documents or annotations; each benchmark runner requests their own token. What we publish is per-field accuracy numbers — standard benchmark practice, and DocILE itself operates a public leaderboard. +- If a host disappears, the pinned IDs + checksums still document exactly what every published number was computed on. + +## Caveats (state these on the benchmark page) + +- **Training contamination:** both datasets are public and widely cited; frontier models have likely seen them. The benchmark is a *comparative* measurement across models/pipelines under identical conditions, not an absolute capability claim. +- **Locale skew:** CORD receipts are Indonesian (Latin script). Fine for v1 scope; noted so the numbers aren't read as universal. +- **Manual step:** DocILE token acquisition (form at docile.rossum.ai) must happen before Phase 2 data work starts. + +## Sources + +- CORD repo + license: https://github.com/clovaai/cord +- CORD-v2 hosting/splits: https://huggingface.co/datasets/naver-clova-ix/cord-v2 +- DocILE benchmark repo: https://github.com/rossumai/docile +- DocILE dataset access + composition: https://docile.rossum.ai/ +- DocILE benchmark paper (55 classes, 6.7k annotated): https://arxiv.org/abs/2302.05658 +- SROIE challenge: https://rrc.cvc.uab.es/?ch=13 and https://arxiv.org/abs/2103.10213 +- RVL-CDIP: https://huggingface.co/datasets/aharley/rvl_cdip From b49631e90957ead68a99bc5fadef063176b617bb Mon Sep 17 00:00:00 2001 From: ratchaw Date: Tue, 7 Jul 2026 16:43:25 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Phase=201:=20core=20library=20=E2=80=94=20g?= =?UTF-8?q?rounded=20extraction=20with=20per-field=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm monorepo scaffold (TS strict, Vitest 4, tsdown, CI) plus packages/core: - extract(): Zod schema + PDF/image -> validated JSON where every leaf carries { value, confidence, page, bbox }. The user's schema is mirrored into a wire schema whose leaves are provenance wrappers; output is unwrapped, provenance-validated (page range vs real PDF page count, degenerate bboxes dropped into issues), then parsed against the original schema. - streamExtract(): field events as each leaf's wrapper completes (confidence is deliberately the last wire key), plus the same validated final result. - Provider-agnostic via AI SDK v7 (generateText + Output.object; models are passed in, no provider coupling). Transport retries delegated to the SDK; invalid output triggers repair re-prompts carrying the exact violations, with usage accumulated across attempts. - Typed failure paths: document rejection before any token is spent (signature sniffing, media-type mismatch, encrypted/invalid PDFs), DOCUMENT_UNREADABLE, MISSING_REQUIRED_FIELDS (with partial extraction), EXTRACTION_FAILED. - Transforms/defaults/catch are rejected by design: a transformed or defaulted value has no document provenance. - 53 Vitest tests against ai/test mock models, failure paths first-class. Mock-tested only; first live-provider validation lands with Phase 2 evals. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 21 + CLAUDE.md | 2 +- LICENSE | 21 + README.md | 12 +- ROADMAP.md | 10 +- package.json | 13 + packages/core/README.md | 102 +++ packages/core/package.json | 55 ++ packages/core/src/document.ts | 84 ++ packages/core/src/errors.ts | 108 +++ packages/core/src/extract.ts | 131 +++ packages/core/src/index.ts | 29 + packages/core/src/prompt.ts | 45 + packages/core/src/schema.ts | 350 ++++++++ packages/core/src/stream.ts | 167 ++++ packages/core/src/types.ts | 140 ++++ packages/core/src/usage.ts | 32 + packages/core/test/document.test.ts | 83 ++ packages/core/test/extract.test.ts | 155 ++++ packages/core/test/helpers.ts | 135 +++ packages/core/test/schema.test.ts | 238 ++++++ packages/core/test/stream.test.ts | 105 +++ packages/core/tsconfig.json | 7 + packages/core/tsdown.config.ts | 8 + packages/core/vitest.config.ts | 7 + pnpm-lock.yaml | 1173 +++++++++++++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 15 + 28 files changed, 3240 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 package.json create mode 100644 packages/core/README.md create mode 100644 packages/core/package.json create mode 100644 packages/core/src/document.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/extract.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/prompt.ts create mode 100644 packages/core/src/schema.ts create mode 100644 packages/core/src/stream.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/src/usage.ts create mode 100644 packages/core/test/document.test.ts create mode 100644 packages/core/test/extract.test.ts create mode 100644 packages/core/test/helpers.ts create mode 100644 packages/core/test/schema.test.ts create mode 100644 packages/core/test/stream.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/tsdown.config.ts create mode 100644 packages/core/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c64c018 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build diff --git a/CLAUDE.md b/CLAUDE.md index 2c13b2d..4f2cf3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ extractkit is an open-source TypeScript document-extraction engine: Zod schema + ## Current Phase -**Pre-development — spec and planning only.** This repo intentionally contains no code yet. Do not scaffold packages, configs, or app code unless explicitly asked to start building. Until then, work happens in the md files (spec refinement, roadmap, API design sketches). +**Phase 1 (core library) shipped; Phase 2 (evals) is next.** `packages/core` is implemented and tested against mock models — it has not been validated against live providers yet; that happens as Phase 2 stands up. `packages/evals` and `apps/playground` do not exist yet. Keep README/ROADMAP/docs in sync with what actually ships. Requesting the DocILE dataset token (see ROADMAP Phase 0) is a pending human action that gates the Phase 2 data pull. ## Planned Architecture diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97a5649 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 extractkit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 93a6c37..c487fd7 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ **Extraction you can audit.** Define a Zod schema, feed it a PDF or image, get back schema-validated JSON where every field carries provenance — the page and bounding box it came from — plus a confidence score. -> **Status: pre-development.** This repo currently holds the project spec and roadmap only. No code yet. +> **Status: in development.** The core library ([`packages/core`](./packages/core)) is implemented and tested against mock models; the eval benchmark and playground are next. See [ROADMAP.md](./ROADMAP.md). ## Why TypeScript has structured-output libraries (instructor-js, AI SDK `generateObject`) and document parsers (LiteParse), but nothing that does the full pipeline: **document in → grounded, validated, auditable JSON out** — with a public eval benchmark so the accuracy claims are numbers, not adjectives. -## Planned v1 +## v1 -- **Core library** — Zod schema + PDF/image → validated JSON with per-field `{ value, confidence, page, bbox }`. Provider-agnostic via the Vercel AI SDK. OCR-failure handling, retries, streaming, cost tracking built in. -- **Eval harness** — public benchmark on ~50 real documents (invoices/receipts): field accuracy per model, cost per 1k docs. -- **Playground** — drag-drop a document, watch fields extract; hover a JSON field to highlight its source region on the page. +- **Core library** (`packages/core`, shipped) — Zod schema + PDF/image → validated JSON with per-field `{ value, confidence, page, bbox }`. Provider-agnostic via the Vercel AI SDK. Document validation, typed failure handling, repair retries, streaming, and cost tracking built in. [Usage docs →](./packages/core/README.md) +- **Eval harness** (next) — public benchmark on ~50 real documents (invoices/receipts): field accuracy per model, grounding accuracy, cost per 1k docs. +- **Playground** (planned) — drag-drop a document, watch fields extract; hover a JSON field to highlight its source region on the page. See [ROADMAP.md](./ROADMAP.md) for the build plan. @@ -22,4 +22,4 @@ General-purpose business documents: invoices, receipts, contracts. ## License -MIT (to be added with first code). +[MIT](./LICENSE) diff --git a/ROADMAP.md b/ROADMAP.md index 16cf850..c3d83b5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,10 +7,12 @@ ## Phase 1 — Core library (`packages/core`) -- [ ] Zod schema → PDF/image → validated JSON with per-field `{ value, confidence, page, bbox }` -- [ ] Provider-agnostic model layer (Vercel AI SDK) -- [ ] OCR-failure handling, retries, streaming, cost tracking -- [ ] Vitest suite covering the failure paths, not just happy path +- [x] Zod schema → PDF/image → validated JSON with per-field `{ value, confidence, page, bbox }` +- [x] Provider-agnostic model layer (Vercel AI SDK v7; models passed in, no provider coupling) +- [x] OCR-failure handling, retries, streaming, cost tracking +- [x] Vitest suite covering the failure paths, not just happy path (53 tests, mock models) + +Tested against mock models only so far; first live-provider validation happens when Phase 2 eval runs stand up. ## Phase 2 — Evals (`packages/evals`) diff --git a/package.json b/package.json new file mode 100644 index 0000000..f25aab1 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "extractkit-monorepo", + "private": true, + "packageManager": "pnpm@10.17.0", + "engines": { + "node": ">=20.19" + }, + "scripts": { + "build": "pnpm -r build", + "test": "pnpm -r test", + "typecheck": "pnpm -r typecheck" + } +} diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..7a5613a --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,102 @@ +# extractkit + +**Extraction you can audit.** Define a Zod schema, feed it a PDF or image, get back schema-validated JSON where every field carries provenance — the page and bounding box it came from — plus a confidence score. + +> Pre-1.0. The API may change until the public benchmark ships. Part of the [extractkit monorepo](https://github.com/RATCHAW/extractkit). + +## Install + +```sh +npm install extractkit ai zod +``` + +`ai` (Vercel AI SDK v7) and `zod` (v4) are peer dependencies. Bring any AI SDK provider, e.g. `@ai-sdk/anthropic`. + +## Quickstart + +```ts +import { anthropic } from '@ai-sdk/anthropic'; +import { extract } from 'extractkit'; +import { readFile } from 'node:fs/promises'; +import { z } from 'zod'; + +const invoice = z.object({ + vendor: z.string().describe('Legal name of the issuing company'), + invoiceNumber: z.string(), + issueDate: z.iso.date(), + currency: z.string().describe('ISO 4217 code, e.g. USD'), + total: z.number(), + lineItems: z.array( + z.object({ + description: z.string(), + quantity: z.number(), + unitPrice: z.number(), + amount: z.number(), + }), + ), +}); + +const result = await extract({ + schema: invoice, + document: { data: await readFile('invoice.pdf') }, + model: anthropic('claude-sonnet-5'), +}); + +result.data.total; // 1250.5 — validated against the schema +result.fields.total; // { value: 1250.5, confidence: 0.97, page: 0, bbox: { x0: 0.72, y0: 0.81, x1: 0.9, y1: 0.84 } } +result.usage; // { inputTokens, outputTokens, totalTokens, modelCalls, costUSD } +result.issues; // non-fatal anomalies, e.g. dropped implausible provenance +``` + +Every leaf of your schema comes back twice: the plain value in `data` (validated, typed `z.output` of your schema) and an `ExtractedField` in `fields` with: + +- `page` — 0-based page index (always 0 for images), or `null` when the model couldn't locate the value. +- `bbox` — `{ x0, y0, x1, y1 }` normalized to page width/height (0–1, origin top-left), or `null`. Reported regions are validated: out-of-range pages and degenerate boxes are dropped and noted in `result.issues`. +- `confidence` — the model's self-reported probability (0–1) that the value is exactly right. It is **not calibrated**; treat it as a ranking signal. The eval harness in this repo measures how well it tracks reality per model. + +## Streaming + +```ts +import { streamExtract } from 'extractkit'; + +const stream = streamExtract({ schema: invoice, document, model }); + +for await (const event of stream) { + // fires as each field's provenance wrapper completes + console.log(event.path, event.field.value); +} + +const result = await stream.result; // same validated result extract() returns +``` + +Field events are emitted before final validation (the finished `result` is authoritative), and streaming does not run repair attempts — use `extract()` when robustness matters more than latency. + +## Documents + +`document.data` accepts `Uint8Array`, `ArrayBuffer`, or a base64 string. PDF, PNG, JPEG, and WebP are supported; the type is sniffed from the file signature, and a declared `mediaType` must match it. PDFs are checked before any tokens are spent: encrypted files, empty files, and unparseable files fail fast with typed errors, and the real page count is used to validate the model's page references. + +## Schemas + +Supported: `z.object`, `z.array`, `z.string` (including formats like `z.iso.date()`), `z.number`, `z.boolean`, `z.enum`, `z.literal`, plus `.optional()`, `.nullable()`, `.describe()`, and `.refine()`. Field descriptions are forwarded to the model — use them. + +Rejected by design, with an `UnsupportedSchemaError` naming the offending path: `.transform()`, `.default()`, `.catch()`, and `z.date()`. A transformed or defaulted value no longer maps to a region of the document, which breaks the provenance guarantee. Transform after extraction instead. + +## Failure handling + +| Error | `code` | Meaning | +| --- | --- | --- | +| `UnsupportedSchemaError` | `SCHEMA_UNSUPPORTED` | Schema contains an unsupported type; thrown before any model call | +| `DocumentError` | `UNSUPPORTED_MEDIA_TYPE`, `MEDIA_TYPE_MISMATCH`, `ENCRYPTED_DOCUMENT`, `INVALID_DOCUMENT` | Input rejected before any model call | +| `DocumentUnreadableError` | `DOCUMENT_UNREADABLE` | The model reported the document as blank/illegible/not a document; carries its stated `issues` | +| `MissingRequiredFieldsError` | `MISSING_REQUIRED_FIELDS` | Document was readable but required fields weren't found; carries `missingPaths` and the `partial` extraction | +| `ExtractionFailedError` | `EXTRACTION_FAILED` | Model kept returning invalid output after all repair attempts; carries `attempts`, `usage`, and the raw text | + +All extend `ExtractKitError`. Transport and provider errors from the AI SDK propagate unwrapped, with the SDK's own `maxRetries` applied. When the model returns malformed or schema-violating output, extractkit re-prompts it with the specific violations (`maxRepairAttempts`, default 1); token usage is accumulated across attempts so costs stay visible. + +## Cost tracking + +`result.usage` always carries token counts and the number of model calls. Pass `pricing: { inputPerMTokUSD, outputPerMTokUSD }` to get `costUSD`; without it, `costUSD` is `null` — extractkit ships no built-in price table, so costs are never silently wrong. + +## License + +MIT diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..670ecaa --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,55 @@ +{ + "name": "extractkit", + "version": "0.1.0", + "description": "Schema-first document extraction with per-field provenance: Zod schema + PDF/image in, validated JSON with page, bbox, and confidence out.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/RATCHAW/extractkit.git", + "directory": "packages/core" + }, + "keywords": [ + "document-extraction", + "ocr", + "zod", + "structured-output", + "pdf", + "invoice", + "receipt", + "ai-sdk" + ], + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=20.19" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "pdf-lib": "^1.17.1" + }, + "peerDependencies": { + "ai": "^7.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "ai": "^7.0.16", + "tsdown": "^0.22.3", + "typescript": "^6.0.3", + "vitest": "^4.1.10", + "zod": "^4.4.3" + } +} diff --git a/packages/core/src/document.ts b/packages/core/src/document.ts new file mode 100644 index 0000000..99b16c8 --- /dev/null +++ b/packages/core/src/document.ts @@ -0,0 +1,84 @@ +import { PDFDocument } from 'pdf-lib'; +import { DocumentError } from './errors.js'; +import type { DocumentInput, NormalizedDocument, SupportedMediaType } from './types.js'; + +function decodeBase64(data: string): Uint8Array { + let binary: string; + try { + binary = atob(data); + } catch (err) { + throw new DocumentError('INVALID_DOCUMENT', 'Document string is not valid base64.', err); + } + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function toBytes(data: DocumentInput['data']): Uint8Array { + if (typeof data === 'string') return decodeBase64(data); + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + throw new DocumentError('INVALID_DOCUMENT', 'Document data must be a Uint8Array, ArrayBuffer, or base64 string.'); +} + +function matches(bytes: Uint8Array, offset: number, signature: number[]): boolean { + return signature.every((byte, i) => bytes[offset + i] === byte); +} + +export function sniffMediaType(bytes: Uint8Array): SupportedMediaType | null { + if (matches(bytes, 0, [0x25, 0x50, 0x44, 0x46, 0x2d])) return 'application/pdf'; // %PDF- + if (matches(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png'; + if (matches(bytes, 0, [0xff, 0xd8, 0xff])) return 'image/jpeg'; + if (matches(bytes, 0, [0x52, 0x49, 0x46, 0x46]) && matches(bytes, 8, [0x57, 0x45, 0x42, 0x50])) return 'image/webp'; + return null; +} + +/** + * Validates and normalizes a document input: decodes bytes, verifies the file + * signature against the declared media type, and reads the PDF page count. + * Throws DocumentError before any tokens are spent on an unusable input. + */ +export async function normalizeDocument(input: DocumentInput): Promise { + const bytes = toBytes(input.data); + if (bytes.length === 0) { + throw new DocumentError('INVALID_DOCUMENT', 'Document is empty (0 bytes).'); + } + + const sniffed = sniffMediaType(bytes); + if (sniffed == null) { + throw new DocumentError( + 'UNSUPPORTED_MEDIA_TYPE', + 'Unrecognized file signature; supported types are PDF, PNG, JPEG, and WebP.', + ); + } + if (input.mediaType != null && input.mediaType !== sniffed) { + throw new DocumentError( + 'MEDIA_TYPE_MISMATCH', + `Declared media type is ${input.mediaType} but the file signature is ${sniffed}.`, + ); + } + + let pages = 1; + if (sniffed === 'application/pdf') { + let encrypted: boolean; + try { + // pdf-lib's error classes are ES5-compiled and fail instanceof checks, + // so encryption is detected via the isEncrypted flag rather than by + // catching EncryptedPDFError. getPageCount can also throw on structurally + // broken files that load() tolerated, so it stays inside the try. + const pdf = await PDFDocument.load(bytes, { ignoreEncryption: true }); + encrypted = pdf.isEncrypted; + pages = encrypted ? 0 : pdf.getPageCount(); + } catch (err) { + throw new DocumentError('INVALID_DOCUMENT', 'Failed to parse PDF.', err); + } + if (encrypted) { + throw new DocumentError('ENCRYPTED_DOCUMENT', 'PDF is encrypted; decrypt it before extraction.'); + } + if (pages === 0) { + throw new DocumentError('INVALID_DOCUMENT', 'PDF has no pages.'); + } + } + + return { bytes, mediaType: sniffed, pages, filename: input.filename }; +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..d54a1dc --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,108 @@ +import type { ExtractUsage } from './types.js'; + +export type ExtractErrorCode = + | 'SCHEMA_UNSUPPORTED' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'MEDIA_TYPE_MISMATCH' + | 'ENCRYPTED_DOCUMENT' + | 'INVALID_DOCUMENT' + | 'DOCUMENT_UNREADABLE' + | 'EXTRACTION_FAILED' + | 'MISSING_REQUIRED_FIELDS'; + +/** + * Base class for all extractkit errors. Provider/transport errors from the + * AI SDK (rate limits, auth, aborts) are not wrapped and propagate as-is. + */ +export class ExtractKitError extends Error { + readonly code: ExtractErrorCode; + + constructor(code: ExtractErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); + this.code = code; + this.name = new.target.name; + } +} + +/** The extraction schema uses a type that cannot carry provenance. */ +export class UnsupportedSchemaError extends ExtractKitError { + /** Path of the offending schema node, e.g. `$.lineItems[].price`. */ + readonly path: string; + + constructor(path: string, message: string) { + super('SCHEMA_UNSUPPORTED', `${message} (at ${path})`); + this.path = path; + } +} + +export type DocumentErrorCode = Extract< + ExtractErrorCode, + 'UNSUPPORTED_MEDIA_TYPE' | 'MEDIA_TYPE_MISMATCH' | 'ENCRYPTED_DOCUMENT' | 'INVALID_DOCUMENT' +>; + +/** The input document could not be accepted; thrown before any model call. */ +export class DocumentError extends ExtractKitError { + declare readonly code: DocumentErrorCode; + + constructor(code: DocumentErrorCode, message: string, cause?: unknown) { + super(code, message, { cause }); + } +} + +/** The model reported the document as unreadable (blank, illegible, not a document). */ +export class DocumentUnreadableError extends ExtractKitError { + /** The model's stated reading problems. */ + readonly issues: string[]; + readonly usage: ExtractUsage; + + constructor(issues: string[], usage: ExtractUsage) { + super( + 'DOCUMENT_UNREADABLE', + `Model reported the document as unreadable${issues.length > 0 ? `: ${issues.join('; ')}` : '.'}`, + ); + this.issues = issues; + this.usage = usage; + } +} + +/** The model kept producing invalid output after all repair attempts. */ +export class ExtractionFailedError extends ExtractKitError { + /** Model calls made, including repairs. */ + readonly attempts: number; + readonly usage: ExtractUsage; + /** Raw text of the last model response, when available. */ + readonly rawText: string | undefined; + + constructor(message: string, opts: { attempts: number; usage: ExtractUsage; rawText?: string; cause?: unknown }) { + super('EXTRACTION_FAILED', message, { cause: opts.cause }); + this.attempts = opts.attempts; + this.usage = opts.usage; + this.rawText = opts.rawText; + } +} + +/** + * The document was readable but required fields were not found. Carries the + * partial extraction so callers can decide what to do with it. + */ +export class MissingRequiredFieldsError extends ExtractKitError { + /** Paths of the missing required fields, e.g. `$.total`. */ + readonly missingPaths: string[]; + /** Unvalidated partial extraction (missing fields omitted). */ + readonly partial: { data: unknown; fields: unknown }; + readonly attempts: number; + readonly usage: ExtractUsage; + + constructor(opts: { + missingPaths: string[]; + partial: { data: unknown; fields: unknown }; + attempts: number; + usage: ExtractUsage; + }) { + super('MISSING_REQUIRED_FIELDS', `Required fields not found in document: ${opts.missingPaths.join(', ')}`); + this.missingPaths = opts.missingPaths; + this.partial = opts.partial; + this.attempts = opts.attempts; + this.usage = opts.usage; + } +} diff --git a/packages/core/src/extract.ts b/packages/core/src/extract.ts new file mode 100644 index 0000000..092e3e3 --- /dev/null +++ b/packages/core/src/extract.ts @@ -0,0 +1,131 @@ +import { generateText, NoObjectGeneratedError, Output } from 'ai'; +import type { AssistantModelMessage, ModelMessage, UserModelMessage } from 'ai'; +import type { z } from 'zod'; +import { normalizeDocument } from './document.js'; +import { DocumentUnreadableError, ExtractionFailedError, MissingRequiredFieldsError } from './errors.js'; +import { buildRepairMessage, buildSystemPrompt, buildUserMessage } from './prompt.js'; +import { + buildWireSchema, + isOnlyMissingFields, + unwrapWireOutput, + zodIssueSummaries, + type WireEnvelope, +} from './schema.js'; +import type { ExtractOptions, ExtractResult, FieldMap } from './types.js'; +import { addUsage, createUsage, finalizeUsage } from './usage.js'; + +function appendRepair(messages: ModelMessage[], previousText: string | undefined, reasons: string[]): ModelMessage[] { + const assistant: AssistantModelMessage = { + role: 'assistant', + content: previousText != null && previousText.trim() !== '' ? previousText : '[no valid output]', + }; + const user: UserModelMessage = { role: 'user', content: buildRepairMessage(reasons) }; + return [...messages, assistant, user]; +} + +function truncate(text: string, max = 500): string { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function describeInvalidOutput(err: NoObjectGeneratedError): string { + const detail = err.cause instanceof Error ? err.cause.message : err.message; + return `Response was not valid JSON for the required schema: ${truncate(detail)}`; +} + +/** + * Extracts `schema` from a PDF or image: one model call (plus repair calls + * when the model returns invalid output), then schema validation. Every leaf + * in the result carries value, confidence, page, and bbox. + */ +export async function extract(options: ExtractOptions): Promise> { + const { schema, pricing } = options; + const wire = buildWireSchema(schema); + const doc = await normalizeDocument(options.document); + const output = Output.object({ + schema: wire, + name: options.schemaName ?? 'document_extraction', + ...(options.schemaDescription != null ? { description: options.schemaDescription } : {}), + }); + const system = buildSystemPrompt(options.instructions); + const maxRepairAttempts = options.maxRepairAttempts ?? 1; + const usage = createUsage(); + let messages: ModelMessage[] = [buildUserMessage(doc)]; + + for (let attempt = 1; ; attempt++) { + let envelope: WireEnvelope; + let rawText: string; + try { + const res = await generateText({ + model: options.model, + system, + messages, + output, + ...(options.temperature != null ? { temperature: options.temperature } : {}), + ...(options.maxRetries != null ? { maxRetries: options.maxRetries } : {}), + ...(options.abortSignal != null ? { abortSignal: options.abortSignal } : {}), + }); + usage.modelCalls = attempt; + addUsage(usage, res.totalUsage); + envelope = res.output as unknown as WireEnvelope; + rawText = res.text; + } catch (err) { + if (NoObjectGeneratedError.isInstance(err)) { + usage.modelCalls = attempt; + addUsage(usage, err.usage); + if (attempt <= maxRepairAttempts) { + messages = appendRepair(messages, err.text, [describeInvalidOutput(err)]); + continue; + } + throw new ExtractionFailedError('Model output was not valid extraction JSON after all repair attempts.', { + attempts: attempt, + usage: finalizeUsage(usage, pricing), + ...(err.text != null ? { rawText: err.text } : {}), + cause: err, + }); + } + // Transport, provider, and abort errors propagate as-is; the AI SDK + // has already applied maxRetries to retryable ones. + throw err; + } + + if (!envelope.readable) { + throw new DocumentUnreadableError(envelope.issues, finalizeUsage(usage, pricing)); + } + + const unwrapped = unwrapWireOutput(schema, envelope.fields, doc.pages); + const parsed = schema.safeParse(unwrapped.data); + if (parsed.success) { + return { + data: parsed.data as z.output, + fields: unwrapped.fields as FieldMap>, + issues: [...envelope.issues, ...unwrapped.issues], + usage: finalizeUsage(usage, pricing), + pages: doc.pages, + }; + } + + if (attempt <= maxRepairAttempts) { + const reasons = [ + ...unwrapped.missingPaths.map( + (p) => `${p}: required field was null. Re-check the document; return null only if it is truly absent`, + ), + ...zodIssueSummaries(parsed.error), + ]; + messages = appendRepair(messages, rawText, reasons); + continue; + } + + if (isOnlyMissingFields(parsed.error, unwrapped.missingPaths)) { + throw new MissingRequiredFieldsError({ + missingPaths: unwrapped.missingPaths, + partial: { data: unwrapped.data, fields: unwrapped.fields }, + attempts: attempt, + usage: finalizeUsage(usage, pricing), + }); + } + throw new ExtractionFailedError( + `Extraction failed schema validation after all repair attempts: ${zodIssueSummaries(parsed.error).join('; ')}`, + { attempts: attempt, usage: finalizeUsage(usage, pricing), rawText, cause: parsed.error }, + ); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..2e714e7 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,29 @@ +export { extract } from './extract.js'; +export { streamExtract } from './stream.js'; +export { normalizeDocument, sniffMediaType } from './document.js'; +export { SYSTEM_PROMPT } from './prompt.js'; +export { + DocumentError, + DocumentUnreadableError, + ExtractionFailedError, + ExtractKitError, + MissingRequiredFieldsError, + UnsupportedSchemaError, +} from './errors.js'; +export type { DocumentErrorCode, ExtractErrorCode } from './errors.js'; +export type { + BBox, + DocumentInput, + ExtractedField, + ExtractOptions, + ExtractResult, + ExtractStream, + ExtractStreamEvent, + ExtractUsage, + FieldEvent, + FieldMap, + FieldPath, + NormalizedDocument, + Pricing, + SupportedMediaType, +} from './types.js'; diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts new file mode 100644 index 0000000..001d2f7 --- /dev/null +++ b/packages/core/src/prompt.ts @@ -0,0 +1,45 @@ +import type { UserModelMessage } from 'ai'; +import type { NormalizedDocument } from './types.js'; + +/** + * Extraction rules shared by every call. Kept exported so the eval harness + * can pin the exact prompt a benchmark run used. + */ +export const SYSTEM_PROMPT = `You are a precise document data extraction engine. + +Extract the requested fields from the attached document and return JSON that matches the response schema exactly. Every leaf field is an object { "value", "page", "bbox", "confidence" }: + +- "value": the value as it appears in the document. Never guess, compute, or infer a value that is not visibly present. Use null when the field is absent or illegible. When the schema or a field description requires a format (for example an ISO date), convert the document's text to that format. +- "page": 0-based index of the page the value was read from, or null when "value" is null. An image input is always page 0. +- "bbox": [x0, y0, x1, y1] — the tightest region containing the source text, normalized to page width and height (each coordinate between 0 and 1), origin at the top-left corner, x0 < x1 and y0 < y1. Use null when you cannot locate the value. +- "confidence": your honest probability (0 to 1) that "value" is exactly correct. Use low values for blurry, ambiguous, or partially cut-off text. Do not default to high confidence. + +Numbers must be plain JSON numbers without currency symbols or thousands separators. Strings keep the document's spelling and casing unless a field description says otherwise. + +Set "readable" to false and explain why in "issues" when the document is blank, unreadable, or not a document at all. Also use "issues" for notable problems (heavy skew, cut-off regions, watermarks) even when the document is readable.`; + +export function buildSystemPrompt(instructions: string | undefined): string { + if (instructions == null || instructions.trim() === '') return SYSTEM_PROMPT; + return `${SYSTEM_PROMPT}\n\nAdditional instructions for this extraction:\n${instructions.trim()}`; +} + +export const TASK_MESSAGE = 'Extract the fields defined by the response schema from the attached document.'; + +export function buildUserMessage(doc: NormalizedDocument): UserModelMessage { + return { + role: 'user', + content: [ + { + type: 'file', + data: doc.bytes, + mediaType: doc.mediaType, + ...(doc.filename != null ? { filename: doc.filename } : {}), + }, + { type: 'text', text: TASK_MESSAGE }, + ], + }; +} + +export function buildRepairMessage(reasons: string[]): string { + return `Your previous response was rejected:\n${reasons.map((r) => `- ${r}`).join('\n')}\n\nReturn the complete corrected extraction JSON in the same format. Keep values faithful to the document; use null for values that are not present.`; +} diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts new file mode 100644 index 0000000..1b0638a --- /dev/null +++ b/packages/core/src/schema.ts @@ -0,0 +1,350 @@ +import { z } from 'zod'; +import { UnsupportedSchemaError } from './errors.js'; +import type { BBox, ExtractedField, FieldPath } from './types.js'; + +/** + * The wire format is what the model is asked to produce: the user's schema + * with every leaf replaced by a wrapper carrying the value plus provenance. + * `confidence` is deliberately the last wrapper key, so a wrapper found in + * streamed partial JSON can be treated as final once `confidence` appears. + */ +export interface WireLeaf { + value: unknown; + page: number | null; + bbox: number[] | null; + confidence: number; +} + +export interface WireEnvelope { + readable: boolean; + issues: string[]; + fields: Record; +} + +interface Modifiers { + optional: boolean; + nullable: boolean; + description: string | undefined; +} + +function unwrapModifiers(schema: z.ZodType): { node: z.ZodType; mods: Modifiers } { + let node = schema; + const mods: Modifiers = { optional: false, nullable: false, description: schema.description }; + for (;;) { + if (node instanceof z.ZodOptional) { + mods.optional = true; + node = node.unwrap() as z.ZodType; + } else if (node instanceof z.ZodNullable) { + mods.nullable = true; + node = node.unwrap() as z.ZodType; + } else if (node instanceof z.ZodReadonly) { + node = node.unwrap() as z.ZodType; + } else { + break; + } + mods.description ??= node.description; + } + return { node, mods }; +} + +function rejectUnsupported(node: z.ZodType, path: string): void { + if (node instanceof z.ZodDate) { + throw new UnsupportedSchemaError( + path, + 'z.date() is not supported: models return text. Use z.iso.date() or z.iso.datetime() and convert after extraction', + ); + } + if (node instanceof z.ZodDefault || node instanceof z.ZodPrefault) { + throw new UnsupportedSchemaError( + path, + 'Defaults are not supported: a defaulted value has no document provenance. Make the field optional and apply defaults after extraction', + ); + } + if (node instanceof z.ZodPipe) { + throw new UnsupportedSchemaError( + path, + 'Transforms and pipes are not supported: a transformed value no longer maps to a document region. Apply transforms after extraction', + ); + } + if (node instanceof z.ZodCatch) { + throw new UnsupportedSchemaError(path, 'z.catch() is not supported: a fallback value has no document provenance'); + } +} + +function isLeaf(node: z.ZodType): boolean { + return ( + node instanceof z.ZodString || + node instanceof z.ZodNumber || + node instanceof z.ZodBoolean || + node instanceof z.ZodEnum || + node instanceof z.ZodLiteral + ); +} + +function wireLeaf(base: z.ZodType, description: string | undefined): z.ZodType { + const wrapper = z.object({ + value: base.nullable().describe('The extracted value, or null when absent or illegible.'), + page: z.number().int().nullable(), + bbox: z.array(z.number()).length(4).nullable(), + confidence: z.number(), + }); + return description == null ? wrapper : wrapper.describe(description); +} + +function withMods(wire: z.ZodType, mods: Modifiers): z.ZodType { + let out = wire; + if (mods.optional || mods.nullable) out = out.nullable(); + if (mods.description != null) out = out.describe(mods.description); + return out; +} + +function mirror(schema: z.ZodType, path: string): z.ZodType { + const { node, mods } = unwrapModifiers(schema); + rejectUnsupported(node, path); + + if (node instanceof z.ZodObject) { + const shape: Record = {}; + for (const [key, child] of Object.entries(node.shape)) { + shape[key] = mirror(child as z.ZodType, `${path}.${key}`); + } + return withMods(z.object(shape), mods); + } + if (node instanceof z.ZodArray) { + return withMods(z.array(mirror(node.element as z.ZodType, `${path}[]`)), mods); + } + if (isLeaf(node)) { + return wireLeaf(node, mods.description); + } + throw new UnsupportedSchemaError( + path, + `Unsupported schema type ${node.constructor.name}. Supported: object, array, string, number, boolean, enum, literal, plus optional/nullable/describe/refine`, + ); +} + +/** Builds the response schema sent to the model. Throws before any model call. */ +export function buildWireSchema(schema: z.ZodType): z.ZodObject { + if (!(schema instanceof z.ZodObject)) { + throw new UnsupportedSchemaError('$', 'Extraction schema root must be z.object(...)'); + } + return z.object({ + readable: z.boolean().describe('False when the document is blank, unreadable, or not a document.'), + issues: z + .array(z.string()) + .describe('Reading problems worth surfacing (blur, cut-off regions, missing pages). Empty array when none.'), + fields: mirror(schema, '$'), + }); +} + +export interface UnwrappedExtraction { + /** Data in the user's schema shape, ready for schema.parse(). */ + data: unknown; + /** ExtractedField tree parallel to `data`. */ + fields: unknown; + /** Provenance corrections made while reading the model output. */ + issues: string[]; + /** Paths of required fields the model reported as not found. */ + missingPaths: string[]; +} + +interface WalkCtx { + pages: number; + issues: string[]; + missingPaths: string[]; +} + +type NodeResult = { present: true; data: unknown; field: unknown } | { present: false }; + +const ABSENT: NodeResult = { present: false }; + +function clamp01(n: number): number { + return Math.min(1, Math.max(0, n)); +} + +function readProvenance(wrapper: WireLeaf, path: string, ctx: WalkCtx): ExtractedField { + let confidence = wrapper.confidence; + if (typeof confidence !== 'number' || Number.isNaN(confidence)) { + ctx.issues.push(`${path}: invalid confidence; set to 0`); + confidence = 0; + } else if (confidence < 0 || confidence > 1) { + ctx.issues.push(`${path}: confidence ${confidence} outside [0, 1]; clamped`); + confidence = clamp01(confidence); + } + + let page = wrapper.page; + if (page != null && (!Number.isInteger(page) || page < 0 || page >= ctx.pages)) { + ctx.issues.push(`${path}: reported page ${page} is outside the ${ctx.pages}-page document; provenance dropped`); + page = null; + } + + let bbox: BBox | null = null; + if (wrapper.bbox != null) { + if (page == null) { + ctx.issues.push(`${path}: bbox reported without a valid page; bbox dropped`); + } else { + const [rx0, ry0, rx1, ry1] = wrapper.bbox; + if (rx0 == null || ry0 == null || rx1 == null || ry1 == null) { + ctx.issues.push(`${path}: malformed bbox; dropped`); + } else { + const candidate = { x0: clamp01(rx0), y0: clamp01(ry0), x1: clamp01(rx1), y1: clamp01(ry1) }; + if (candidate.x0 < candidate.x1 && candidate.y0 < candidate.y1) { + bbox = candidate; + } else { + ctx.issues.push(`${path}: degenerate bbox [${wrapper.bbox.join(', ')}]; dropped`); + } + } + } + } + + return { value: wrapper.value, confidence, page, bbox }; +} + +function missingNode(mods: Modifiers, path: string, ctx: WalkCtx, inArray: boolean, leafConfidence?: number): NodeResult { + if (mods.nullable) { + return { present: true, data: null, field: { value: null, confidence: leafConfidence ?? 0, page: null, bbox: null } }; + } + if (mods.optional) return ABSENT; + if (inArray) { + ctx.issues.push(`${path}: array element with a missing required value was dropped`); + return ABSENT; + } + ctx.missingPaths.push(path); + return ABSENT; +} + +function missingContainer(mods: Modifiers, path: string, ctx: WalkCtx, inArray: boolean): NodeResult { + if (mods.nullable) return { present: true, data: null, field: null }; + if (mods.optional) return ABSENT; + if (inArray) { + ctx.issues.push(`${path}: array element with a missing required value was dropped`); + return ABSENT; + } + ctx.missingPaths.push(path); + return ABSENT; +} + +function unwrapNode(schema: z.ZodType, wire: unknown, path: string, ctx: WalkCtx, inArray: boolean): NodeResult { + const { node, mods } = unwrapModifiers(schema); + + if (node instanceof z.ZodObject) { + if (wire == null) return missingContainer(mods, path, ctx, inArray); + const data: Record = {}; + const field: Record = {}; + for (const [key, child] of Object.entries(node.shape)) { + const result = unwrapNode(child as z.ZodType, (wire as Record)[key], `${path}.${key}`, ctx, false); + if (result.present) { + data[key] = result.data; + field[key] = result.field; + } + } + return { present: true, data, field }; + } + + if (node instanceof z.ZodArray) { + if (wire == null) return missingContainer(mods, path, ctx, inArray); + const data: unknown[] = []; + const field: unknown[] = []; + (wire as unknown[]).forEach((item, i) => { + const result = unwrapNode(node.element as z.ZodType, item, `${path}[${i}]`, ctx, true); + if (result.present) { + data.push(result.data); + field.push(result.field); + } + }); + return { present: true, data, field }; + } + + const wrapper = wire as WireLeaf | null | undefined; + if (wrapper == null || wrapper.value == null) { + return missingNode(mods, path, ctx, inArray, wrapper?.confidence); + } + return { present: true, data: wrapper.value, field: readProvenance(wrapper, path, ctx) }; +} + +/** + * Converts validated wire output back into the user's schema shape plus a + * parallel provenance tree, repairing or dropping implausible provenance + * (out-of-range pages, degenerate bboxes) along the way. + */ +export function unwrapWireOutput(schema: z.ZodObject, wireFields: Record, pages: number): UnwrappedExtraction { + const ctx: WalkCtx = { pages, issues: [], missingPaths: [] }; + const result = unwrapNode(schema, wireFields, '$', ctx, false); + return { + data: result.present ? result.data : {}, + fields: result.present ? result.field : {}, + issues: ctx.issues, + missingPaths: ctx.missingPaths, + }; +} + +export function formatIssuePath(path: ReadonlyArray): string { + let out = '$'; + for (const segment of path) { + out += typeof segment === 'number' ? `[${segment}]` : `.${String(segment)}`; + } + return out; +} + +export function zodIssueSummaries(error: z.ZodError): string[] { + return error.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`); +} + +/** True when every validation failure is explained by a model-reported missing field. */ +export function isOnlyMissingFields(error: z.ZodError, missingPaths: readonly string[]): boolean { + if (missingPaths.length === 0) return false; + const missing = new Set(missingPaths); + return error.issues.every((issue) => missing.has(formatIssuePath(issue.path))); +} + +/** + * Walks streamed partial wire output and emits every leaf whose wrapper is + * complete (its `confidence` — the last wrapper key — has arrived) and whose + * value is present. Used by streamExtract; final validation happens on the + * complete output. + */ +export function collectCompletedLeaves( + schema: z.ZodObject, + partialFields: unknown, + pages: number, + emitted: Set, + emit: (path: FieldPath, field: ExtractedField) => void, +): void { + const ctx: WalkCtx = { pages, issues: [], missingPaths: [] }; + walkPartial(schema, partialFields, [], ctx, emitted, emit); +} + +function walkPartial( + schema: z.ZodType, + wire: unknown, + path: FieldPath, + ctx: WalkCtx, + emitted: Set, + emit: (path: FieldPath, field: ExtractedField) => void, +): void { + if (wire == null) return; + const { node } = unwrapModifiers(schema); + + if (node instanceof z.ZodObject) { + if (typeof wire !== 'object' || Array.isArray(wire)) return; + for (const [key, child] of Object.entries(node.shape)) { + walkPartial(child as z.ZodType, (wire as Record)[key], [...path, key], ctx, emitted, emit); + } + return; + } + if (node instanceof z.ZodArray) { + if (!Array.isArray(wire)) return; + wire.forEach((item, i) => { + walkPartial(node.element as z.ZodType, item, [...path, i], ctx, emitted, emit); + }); + return; + } + + const wrapper = wire as Partial; + if (wrapper.value == null) return; + if (wrapper.page === undefined || wrapper.bbox === undefined || typeof wrapper.confidence !== 'number') return; + if (wrapper.bbox !== null && (!Array.isArray(wrapper.bbox) || wrapper.bbox.length !== 4)) return; + + const key = path.map(String).join(''); + if (emitted.has(key)) return; + emitted.add(key); + emit(path, readProvenance(wrapper as WireLeaf, formatIssuePath(path), ctx)); +} diff --git a/packages/core/src/stream.ts b/packages/core/src/stream.ts new file mode 100644 index 0000000..7f5fcfb --- /dev/null +++ b/packages/core/src/stream.ts @@ -0,0 +1,167 @@ +import { NoObjectGeneratedError, Output, streamText } from 'ai'; +import type { z } from 'zod'; +import { normalizeDocument } from './document.js'; +import { DocumentUnreadableError, ExtractionFailedError, MissingRequiredFieldsError } from './errors.js'; +import { buildSystemPrompt, buildUserMessage } from './prompt.js'; +import { + buildWireSchema, + collectCompletedLeaves, + isOnlyMissingFields, + unwrapWireOutput, + zodIssueSummaries, + type WireEnvelope, +} from './schema.js'; +import type { ExtractOptions, ExtractResult, ExtractStream, ExtractStreamEvent, FieldMap } from './types.js'; +import { addUsage, createUsage, finalizeUsage } from './usage.js'; + +class AsyncQueue implements AsyncIterableIterator { + private readonly buffer: T[] = []; + private readonly takers: Array<{ + resolve: (result: IteratorResult) => void; + reject: (err: unknown) => void; + }> = []; + private closed = false; + private failure: { err: unknown } | null = null; + + push(value: T): void { + if (this.closed || this.failure != null) return; + const taker = this.takers.shift(); + if (taker != null) taker.resolve({ value, done: false }); + else this.buffer.push(value); + } + + close(): void { + if (this.closed || this.failure != null) return; + this.closed = true; + for (const taker of this.takers.splice(0)) taker.resolve({ value: undefined, done: true }); + } + + fail(err: unknown): void { + if (this.closed || this.failure != null) return; + this.failure = { err }; + for (const taker of this.takers.splice(0)) taker.reject(err); + } + + next(): Promise> { + if (this.buffer.length > 0) { + return Promise.resolve({ value: this.buffer.shift() as T, done: false }); + } + if (this.failure != null) return Promise.reject(this.failure.err); + if (this.closed) return Promise.resolve({ value: undefined, done: true }); + return new Promise((resolve, reject) => this.takers.push({ resolve, reject })); + } + + [Symbol.asyncIterator](): this { + return this; + } +} + +/** + * Streaming variant of extract(): emits a `field` event as each leaf's + * provenance wrapper completes, and resolves `result` with the same validated + * result extract() returns. Single-consumer. Streaming does not repair + * invalid output; use extract() when repair matters more than latency. + */ +export function streamExtract(options: ExtractOptions): ExtractStream { + const queue = new AsyncQueue(); + const result = run(options, queue); + // The queue surfaces the same failure to iterating consumers; without this + // a caller that only iterates would get an unhandled rejection warning. + result.catch(() => {}); + return { + [Symbol.asyncIterator]: () => queue, + result, + }; +} + +async function run( + options: ExtractOptions, + queue: AsyncQueue, +): Promise> { + try { + const { schema, pricing } = options; + const wire = buildWireSchema(schema); + const doc = await normalizeDocument(options.document); + const output = Output.object({ + schema: wire, + name: options.schemaName ?? 'document_extraction', + ...(options.schemaDescription != null ? { description: options.schemaDescription } : {}), + }); + const usage = createUsage(); + + const res = streamText({ + model: options.model, + system: buildSystemPrompt(options.instructions), + messages: [buildUserMessage(doc)], + output, + ...(options.temperature != null ? { temperature: options.temperature } : {}), + ...(options.maxRetries != null ? { maxRetries: options.maxRetries } : {}), + ...(options.abortSignal != null ? { abortSignal: options.abortSignal } : {}), + }); + + const emitted = new Set(); + for await (const partial of res.partialOutputStream) { + const fields = (partial as Partial | undefined)?.fields; + if (fields == null) continue; + collectCompletedLeaves(schema, fields, doc.pages, emitted, (path, field) => + queue.push({ type: 'field', path, field }), + ); + } + + let envelope: WireEnvelope; + try { + envelope = (await res.output) as unknown as WireEnvelope; + } catch (err) { + if (NoObjectGeneratedError.isInstance(err)) { + usage.modelCalls = 1; + addUsage(usage, err.usage); + throw new ExtractionFailedError( + 'Model output was not valid extraction JSON. Streaming does not repair; use extract() for automatic repair.', + { + attempts: 1, + usage: finalizeUsage(usage, pricing), + ...(err.text != null ? { rawText: err.text } : {}), + cause: err, + }, + ); + } + throw err; + } + usage.modelCalls = 1; + addUsage(usage, await res.totalUsage); + + if (!envelope.readable) { + throw new DocumentUnreadableError(envelope.issues, finalizeUsage(usage, pricing)); + } + + const unwrapped = unwrapWireOutput(schema, envelope.fields, doc.pages); + const parsed = schema.safeParse(unwrapped.data); + if (!parsed.success) { + if (isOnlyMissingFields(parsed.error, unwrapped.missingPaths)) { + throw new MissingRequiredFieldsError({ + missingPaths: unwrapped.missingPaths, + partial: { data: unwrapped.data, fields: unwrapped.fields }, + attempts: 1, + usage: finalizeUsage(usage, pricing), + }); + } + throw new ExtractionFailedError( + `Extraction failed schema validation: ${zodIssueSummaries(parsed.error).join('; ')}`, + { attempts: 1, usage: finalizeUsage(usage, pricing), cause: parsed.error }, + ); + } + + const result: ExtractResult = { + data: parsed.data as z.output, + fields: unwrapped.fields as FieldMap>, + issues: [...envelope.issues, ...unwrapped.issues], + usage: finalizeUsage(usage, pricing), + pages: doc.pages, + }; + queue.close(); + return result; + } catch (err) { + queue.fail(err); + throw err; + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..59b855d --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,140 @@ +import type { LanguageModel } from 'ai'; +import type { z } from 'zod'; + +/** Bounding box on a page, normalized to page width/height: 0–1, origin top-left. */ +export interface BBox { + x0: number; + y0: number; + x1: number; + y1: number; +} + +/** An extracted leaf value together with its provenance. */ +export interface ExtractedField { + value: T; + /** + * Model-reported probability (0–1) that `value` is exactly correct. + * Self-reported and uncalibrated; treat as a ranking signal, not a guarantee. + */ + confidence: number; + /** 0-based page index the value was read from; null when the model could not locate it. */ + page: number | null; + /** Source region of the value on `page`; null when not located. */ + bbox: BBox | null; +} + +/** + * Mirrors the shape of the extracted data with every leaf replaced by its + * ExtractedField. Optional fields stay optional; fields extracted as null + * (nullable leaves, nullable objects) stay null. + */ +export type FieldMap = [NonNullable] extends [ReadonlyArray] + ? FieldMap[] | Extract + : [NonNullable] extends [Record] + ? { [K in keyof NonNullable]: FieldMap[K]> } | Extract + : ExtractedField>; + +export type SupportedMediaType = 'application/pdf' | 'image/png' | 'image/jpeg' | 'image/webp'; + +export interface DocumentInput { + /** Raw document bytes, or a base64-encoded string of them. */ + data: Uint8Array | ArrayBuffer | string; + /** + * Declared media type. When provided it must match the sniffed file + * signature; when omitted the type is detected from the bytes. + */ + mediaType?: SupportedMediaType; + /** Passed through to providers that use filenames (PDF inputs). */ + filename?: string; +} + +export interface NormalizedDocument { + bytes: Uint8Array; + mediaType: SupportedMediaType; + /** Page count read from the PDF itself; always 1 for images. */ + pages: number; + filename?: string; +} + +/** Token prices used to compute `ExtractUsage.costUSD`. */ +export interface Pricing { + /** USD per 1,000,000 input tokens. */ + inputPerMTokUSD: number; + /** USD per 1,000,000 output tokens. */ + outputPerMTokUSD: number; +} + +export interface ExtractUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; + /** Model calls made, including repair attempts. */ + modelCalls: number; + /** Computed when `pricing` was provided; otherwise null. Never estimated. */ + costUSD: number | null; +} + +export interface ExtractOptions { + /** + * Extraction schema; the root must be z.object(...). Supported field types: + * object, array, string (incl. string formats), number, boolean, enum, + * literal, optional, nullable, describe, refine. Transforms, defaults, and + * catch are rejected because their output no longer maps to a document + * region. + */ + schema: S; + /** Name given to the response schema; some providers use it as guidance. */ + schemaName?: string; + schemaDescription?: string; + document: DocumentInput; + /** Any AI SDK language model, e.g. anthropic('claude-sonnet-5'). */ + model: LanguageModel; + /** Extra domain instructions appended to the extraction prompt. */ + instructions?: string; + /** Transport-level retries per model call (handled by the AI SDK). Default 2. */ + maxRetries?: number; + /** + * Re-prompts after the model returns invalid output (unparseable JSON or + * schema violations). Each repair costs one extra model call. Default 1. + */ + maxRepairAttempts?: number; + temperature?: number; + abortSignal?: AbortSignal; + /** Enables costUSD in the returned usage. */ + pricing?: Pricing; +} + +export interface ExtractResult { + /** The extraction, validated against `schema`. */ + data: z.output; + /** Provenance for every leaf in `data`. */ + fields: FieldMap>; + /** + * Non-fatal anomalies: reading problems reported by the model plus + * provenance corrections made during validation (e.g. an out-of-range page + * reference that was dropped). + */ + issues: string[]; + usage: ExtractUsage; + /** Page count of the input document (1 for images). */ + pages: number; +} + +export type FieldPath = Array; + +export interface FieldEvent { + type: 'field'; + path: FieldPath; + /** + * The field as streamed. Field events are emitted before final validation; + * the result returned by `ExtractStream.result` is authoritative. + */ + field: ExtractedField; +} + +export type ExtractStreamEvent = FieldEvent; + +export interface ExtractStream extends AsyncIterable { + /** The final validated result; rejects with the same errors extract() throws. */ + result: Promise>; +} diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts new file mode 100644 index 0000000..450a4b5 --- /dev/null +++ b/packages/core/src/usage.ts @@ -0,0 +1,32 @@ +import type { LanguageModelUsage } from 'ai'; +import type { ExtractUsage, Pricing } from './types.js'; + +export interface MutableUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; + modelCalls: number; +} + +export function createUsage(): MutableUsage { + return { inputTokens: 0, outputTokens: 0, totalTokens: 0, modelCalls: 0 }; +} + +export function addUsage(usage: MutableUsage, sdkUsage: LanguageModelUsage | undefined): void { + if (sdkUsage == null) return; + const input = sdkUsage.inputTokens ?? 0; + const output = sdkUsage.outputTokens ?? 0; + usage.inputTokens += input; + usage.outputTokens += output; + usage.totalTokens += sdkUsage.totalTokens ?? input + output; +} + +export function finalizeUsage(usage: MutableUsage, pricing: Pricing | undefined): ExtractUsage { + return { + ...usage, + costUSD: + pricing == null + ? null + : (usage.inputTokens * pricing.inputPerMTokUSD + usage.outputTokens * pricing.outputPerMTokUSD) / 1_000_000, + }; +} diff --git a/packages/core/test/document.test.ts b/packages/core/test/document.test.ts new file mode 100644 index 0000000..5194513 --- /dev/null +++ b/packages/core/test/document.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeDocument, sniffMediaType } from '../src/document.js'; +import { DocumentError } from '../src/errors.js'; +import { expectError, makeEncryptedPdf, makePdf, tinyJpeg, tinyPng, tinyWebp } from './helpers.js'; + +describe('sniffMediaType', () => { + it('detects the four supported types from magic bytes', async () => { + expect(sniffMediaType(tinyPng())).toBe('image/png'); + expect(sniffMediaType(tinyJpeg())).toBe('image/jpeg'); + expect(sniffMediaType(tinyWebp())).toBe('image/webp'); + expect(sniffMediaType(await makePdf(1))).toBe('application/pdf'); + }); + + it('returns null for unknown signatures', () => { + expect(sniffMediaType(new TextEncoder().encode('hello world'))).toBeNull(); + expect(sniffMediaType(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45]))).toBeNull(); // RIFF but WAVE + }); +}); + +describe('normalizeDocument', () => { + it('normalizes images with pages = 1', async () => { + const doc = await normalizeDocument({ data: tinyPng(), filename: 'r.png' }); + expect(doc.mediaType).toBe('image/png'); + expect(doc.pages).toBe(1); + expect(doc.filename).toBe('r.png'); + }); + + it('reads the page count from PDFs', async () => { + const doc = await normalizeDocument({ data: await makePdf(3) }); + expect(doc.mediaType).toBe('application/pdf'); + expect(doc.pages).toBe(3); + }); + + it('accepts ArrayBuffer and base64 input', async () => { + const png = tinyPng(); + const arrayBuffer = new ArrayBuffer(png.length); + new Uint8Array(arrayBuffer).set(png); + const fromArrayBuffer = await normalizeDocument({ data: arrayBuffer }); + expect(fromArrayBuffer.mediaType).toBe('image/png'); + + const base64 = btoa(String.fromCharCode(...png)); + const fromBase64 = await normalizeDocument({ data: base64 }); + expect(fromBase64.mediaType).toBe('image/png'); + expect(fromBase64.bytes).toEqual(png); + }); + + it('rejects invalid base64', async () => { + const err = await expectError(normalizeDocument({ data: '!!! not base64 !!!' }), DocumentError); + expect(err.code).toBe('INVALID_DOCUMENT'); + }); + + it('rejects empty documents', async () => { + const err = await expectError(normalizeDocument({ data: new Uint8Array() }), DocumentError); + expect(err.code).toBe('INVALID_DOCUMENT'); + }); + + it('rejects unknown file signatures', async () => { + const err = await expectError(normalizeDocument({ data: new TextEncoder().encode('plain text') }), DocumentError); + expect(err.code).toBe('UNSUPPORTED_MEDIA_TYPE'); + }); + + it('rejects a declared media type that contradicts the bytes', async () => { + const err = await expectError( + normalizeDocument({ data: tinyPng(), mediaType: 'application/pdf' }), + DocumentError, + ); + expect(err.code).toBe('MEDIA_TYPE_MISMATCH'); + expect(err.message).toContain('image/png'); + }); + + it('rejects corrupt PDFs', async () => { + const err = await expectError( + normalizeDocument({ data: new TextEncoder().encode('%PDF-1.7 this is not a pdf') }), + DocumentError, + ); + expect(err.code).toBe('INVALID_DOCUMENT'); + }); + + it('rejects encrypted PDFs with a dedicated code', async () => { + const err = await expectError(normalizeDocument({ data: await makeEncryptedPdf() }), DocumentError); + expect(err.code).toBe('ENCRYPTED_DOCUMENT'); + }); +}); diff --git a/packages/core/test/extract.test.ts b/packages/core/test/extract.test.ts new file mode 100644 index 0000000..a5bf10d --- /dev/null +++ b/packages/core/test/extract.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { extract } from '../src/extract.js'; +import { + DocumentError, + DocumentUnreadableError, + ExtractionFailedError, + MissingRequiredFieldsError, + UnsupportedSchemaError, +} from '../src/errors.js'; +import { envelopeJson, expectError, makePdf, sequenceModel, tinyPng, wireLeaf } from './helpers.js'; + +const schema = z.object({ + vendor: z.string(), + total: z.number().min(0), + notes: z.string().nullable(), + currency: z.enum(['USD', 'EUR']).optional(), +}); + +const goodFields = { + vendor: wireLeaf('ACME Corp'), + total: wireLeaf(41.5), + notes: wireLeaf(null), + currency: wireLeaf('USD'), +}; + +describe('extract', () => { + it('extracts, validates, and returns provenance and usage', async () => { + const { model, calls } = sequenceModel([envelopeJson(goodFields)]); + const result = await extract({ schema, document: { data: tinyPng() }, model }); + + expect(result.data).toEqual({ vendor: 'ACME Corp', total: 41.5, notes: null, currency: 'USD' }); + expect(result.fields.vendor).toEqual({ + value: 'ACME Corp', + confidence: 0.9, + page: 0, + bbox: { x0: 0.1, y0: 0.2, x1: 0.4, y1: 0.3 }, + }); + expect(result.fields.notes.value).toBeNull(); + expect(result.pages).toBe(1); + expect(result.issues).toEqual([]); + expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 20, totalTokens: 30, modelCalls: 1, costUSD: null }); + expect(calls()).toBe(1); + }); + + it('computes cost only when pricing is provided', async () => { + const { model } = sequenceModel([envelopeJson(goodFields)]); + const result = await extract({ + schema, + document: { data: tinyPng() }, + model, + pricing: { inputPerMTokUSD: 3, outputPerMTokUSD: 15 }, + }); + expect(result.usage.costUSD).toBeCloseTo((10 * 3 + 20 * 15) / 1_000_000, 10); + }); + + it('validates model-reported pages against the real PDF page count', async () => { + const fields = { ...goodFields, vendor: wireLeaf('ACME Corp', { page: 5 }) }; + const { model } = sequenceModel([envelopeJson(fields)]); + const result = await extract({ schema, document: { data: await makePdf(2) }, model }); + + expect(result.pages).toBe(2); + expect(result.fields.vendor.page).toBeNull(); + expect(result.fields.vendor.bbox).toBeNull(); + expect(result.issues.some((i) => i.includes('$.vendor') && i.includes('page 5'))).toBe(true); + }); + + it('repairs unparseable output and accumulates usage across attempts', async () => { + const { model, prompts, calls } = sequenceModel(['this is not json', envelopeJson(goodFields)]); + const result = await extract({ schema, document: { data: tinyPng() }, model }); + + expect(result.data.vendor).toBe('ACME Corp'); + expect(calls()).toBe(2); + expect(result.usage.modelCalls).toBe(2); + expect(result.usage.inputTokens).toBe(20); + expect(result.usage.outputTokens).toBe(40); + // The repair call carries the failed attempt and the correction request. + expect((prompts[1] as unknown[]).length).toBe((prompts[0] as unknown[]).length + 2); + }); + + it('repairs schema-validation failures with the violation details', async () => { + const belowMin = { ...goodFields, total: wireLeaf(-5) }; + const { model, calls } = sequenceModel([envelopeJson(belowMin), envelopeJson(goodFields)]); + const result = await extract({ schema, document: { data: tinyPng() }, model }); + + expect(result.data.total).toBe(41.5); + expect(calls()).toBe(2); + }); + + it('throws ExtractionFailedError when repairs are exhausted', async () => { + const { model, calls } = sequenceModel(['still not json']); + const err = await expectError( + extract({ schema, document: { data: tinyPng() }, model, maxRepairAttempts: 0 }), + ExtractionFailedError, + ); + expect(err.code).toBe('EXTRACTION_FAILED'); + expect(err.attempts).toBe(1); + expect(err.rawText).toBe('still not json'); + expect(err.usage.modelCalls).toBe(1); + expect(calls()).toBe(1); + }); + + it('throws MissingRequiredFieldsError with the partial extraction', async () => { + const missingTotal = { ...goodFields, total: wireLeaf(null) }; + const { model, calls } = sequenceModel([envelopeJson(missingTotal)]); + const err = await expectError( + extract({ schema, document: { data: tinyPng() }, model }), + MissingRequiredFieldsError, + ); + expect(err.missingPaths).toEqual(['$.total']); + expect((err.partial.data as { vendor: string }).vendor).toBe('ACME Corp'); + expect(calls()).toBe(2); // the repair attempt also came back without the field + }); + + it('throws DocumentUnreadableError without repairing', async () => { + const nulls = { + vendor: wireLeaf(null), + total: wireLeaf(null), + notes: wireLeaf(null), + currency: wireLeaf(null), + }; + const { model, calls } = sequenceModel([envelopeJson(nulls, { readable: false, issues: ['blank page'] })]); + const err = await expectError(extract({ schema, document: { data: tinyPng() }, model }), DocumentUnreadableError); + expect(err.code).toBe('DOCUMENT_UNREADABLE'); + expect(err.issues).toEqual(['blank page']); + expect(err.message).toContain('blank page'); + expect(err.usage.modelCalls).toBe(1); + expect(calls()).toBe(1); + }); + + it('merges model-reported issues with provenance issues', async () => { + const fields = { ...goodFields, vendor: wireLeaf('ACME Corp', { confidence: 2 }) }; + const { model } = sequenceModel([envelopeJson(fields, { issues: ['document is skewed'] })]); + const result = await extract({ schema, document: { data: tinyPng() }, model }); + expect(result.issues.some((i) => i === 'document is skewed')).toBe(true); + expect(result.issues.some((i) => i.includes('confidence'))).toBe(true); + }); + + it('rejects bad documents before calling the model', async () => { + const { model, calls } = sequenceModel([envelopeJson(goodFields)]); + const err = await expectError( + extract({ schema, document: { data: tinyPng(), mediaType: 'application/pdf' }, model }), + DocumentError, + ); + expect(err.code).toBe('MEDIA_TYPE_MISMATCH'); + expect(calls()).toBe(0); + }); + + it('rejects unsupported schemas before calling the model', async () => { + const { model, calls } = sequenceModel([envelopeJson(goodFields)]); + const bad = z.object({ when: z.date() }); + await expectError(extract({ schema: bad, document: { data: tinyPng() }, model }), UnsupportedSchemaError); + expect(calls()).toBe(0); + }); +}); diff --git a/packages/core/test/helpers.ts b/packages/core/test/helpers.ts new file mode 100644 index 0000000..4f47f28 --- /dev/null +++ b/packages/core/test/helpers.ts @@ -0,0 +1,135 @@ +import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; +import { PDFDocument } from 'pdf-lib'; +import { expect } from 'vitest'; + +export function tinyPng(): Uint8Array { + return new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]); +} + +export function tinyJpeg(): Uint8Array { + return new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]); +} + +export function tinyWebp(): Uint8Array { + // RIFF....WEBP + return new Uint8Array([0x52, 0x49, 0x46, 0x46, 0x1a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]); +} + +export async function makePdf(pages: number): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < pages; i++) doc.addPage([200, 200]); + return doc.save({ useObjectStreams: false }); +} + +function indexOfSeq(haystack: Uint8Array, needle: Uint8Array): number { + outer: for (let i = 0; i <= haystack.length - needle.length; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer; + } + return i; + } + return -1; +} + +/** A structurally valid PDF whose trailer references an Encrypt dictionary. */ +export async function makeEncryptedPdf(): Promise { + const bytes = await makePdf(1); + const marker = new TextEncoder().encode('trailer\n<<'); + const insert = new TextEncoder().encode('\n/Encrypt 1 0 R'); + const idx = indexOfSeq(bytes, marker); + if (idx < 0) throw new Error('fixture bug: PDF trailer not found'); + const at = idx + marker.length; + const out = new Uint8Array(bytes.length + insert.length); + out.set(bytes.subarray(0, at), 0); + out.set(insert, at); + out.set(bytes.subarray(at), at + insert.length); + return out; +} + +export interface UsageSpec { + input: number; + output: number; +} + +export function mockUsage({ input, output }: UsageSpec) { + return { + inputTokens: { total: input, noCache: input, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: output, text: output, reasoning: undefined }, + }; +} + +export function textResponse(text: string, usage: UsageSpec = { input: 10, output: 20 }) { + return { + content: [{ type: 'text' as const, text }], + finishReason: { unified: 'stop' as const, raw: undefined }, + usage: mockUsage(usage), + warnings: [], + }; +} + +/** A mock model that replies with each text in order (repeating the last). */ +export function sequenceModel(texts: string[], usage?: UsageSpec) { + const prompts: unknown[] = []; + const model = new MockLanguageModelV4({ + doGenerate: async (options: { prompt: unknown }) => { + prompts.push(options.prompt); + const i = Math.min(prompts.length - 1, texts.length - 1); + return textResponse(texts[i] as string, usage); + }, + }); + return { model, prompts, calls: () => prompts.length }; +} + +export function streamModel(deltas: string[], usage: UsageSpec = { input: 5, output: 15 }) { + return new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'text-start' as const, id: '1' }, + ...deltas.map((delta) => ({ type: 'text-delta' as const, id: '1', delta })), + { type: 'text-end' as const, id: '1' }, + { + type: 'finish' as const, + finishReason: { unified: 'stop' as const, raw: undefined }, + usage: mockUsage(usage), + }, + ], + }), + }), + }); +} + +export function splitEvery(text: string, size: number): string[] { + const parts: string[] = []; + for (let i = 0; i < text.length; i += size) parts.push(text.slice(i, i + size)); + return parts; +} + +/** Wire-format leaf wrapper with plausible provenance defaults. */ +export function wireLeaf(value: unknown, over: Record = {}) { + const missing = value == null; + return { + value, + page: missing ? null : 0, + bbox: missing ? null : [0.1, 0.2, 0.4, 0.3], + confidence: missing ? 0 : 0.9, + ...over, + }; +} + +export function envelopeJson(fields: unknown, over: Record = {}): string { + return JSON.stringify({ readable: true, issues: [], fields, ...over }); +} + +export async function expectError( + promise: Promise, + cls: abstract new (...args: never[]) => T, +): Promise { + try { + await promise; + } catch (err) { + expect(err).toBeInstanceOf(cls); + return err as T; + } + throw new Error(`Expected ${cls.name} to be thrown`); +} diff --git a/packages/core/test/schema.test.ts b/packages/core/test/schema.test.ts new file mode 100644 index 0000000..eac44ea --- /dev/null +++ b/packages/core/test/schema.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { UnsupportedSchemaError } from '../src/errors.js'; +import { + buildWireSchema, + formatIssuePath, + isOnlyMissingFields, + unwrapWireOutput, +} from '../src/schema.js'; +import { wireLeaf } from './helpers.js'; + +const receipt = z.object({ + vendor: z.string().describe('Merchant name'), + total: z.number(), + currency: z.enum(['USD', 'EUR']).optional(), + notes: z.string().nullable(), + lineItems: z.array(z.object({ description: z.string(), amount: z.number() })), +}); + +function fieldsShape(schema: z.ZodObject): z.ZodObject { + const wire = buildWireSchema(schema); + return wire.shape.fields as z.ZodObject; +} + +describe('buildWireSchema', () => { + it('rejects a non-object root', () => { + expect(() => buildWireSchema(z.string())).toThrow(UnsupportedSchemaError); + }); + + it('wraps every leaf with value, page, bbox, confidence — confidence last', () => { + const fields = fieldsShape(receipt); + const vendor = fields.shape.vendor as z.ZodObject; + expect(Object.keys(vendor.shape)).toEqual(['value', 'page', 'bbox', 'confidence']); + const lineItems = fields.shape.lineItems as z.ZodArray; + const amount = lineItems.element.shape.amount as z.ZodObject; + expect(Object.keys(amount.shape)).toEqual(['value', 'page', 'bbox', 'confidence']); + }); + + it('keeps optional and nullable leaves as plain wrappers (value carries the null)', () => { + const fields = fieldsShape(receipt); + expect(fields.shape.currency).toBeInstanceOf(z.ZodObject); + expect(fields.shape.notes).toBeInstanceOf(z.ZodObject); + }); + + it('makes optional/nullable containers nullable on the wire', () => { + const fields = fieldsShape( + z.object({ + shipping: z.object({ address: z.string() }).nullable(), + items: z.array(z.string()).optional(), + }), + ); + expect(fields.shape.shipping).toBeInstanceOf(z.ZodNullable); + expect(fields.shape.items).toBeInstanceOf(z.ZodNullable); + }); + + it('carries field descriptions onto the wire wrappers', () => { + const fields = fieldsShape(receipt); + expect((fields.shape.vendor as z.ZodType).description).toBe('Merchant name'); + }); + + it('validates a well-formed envelope', () => { + const wire = buildWireSchema(z.object({ vendor: z.string() })); + const parsed = wire.safeParse({ + readable: true, + issues: [], + fields: { vendor: wireLeaf('ACME') }, + }); + expect(parsed.success).toBe(true); + }); + + it.each([ + ['z.date()', z.object({ when: z.date() }), '$.when', 'z.iso.date'], + ['defaults', z.object({ qty: z.number().default(1) }), '$.qty', 'provenance'], + ['transforms', z.object({ id: z.string().transform((s) => s.trim()) }), '$.id', 'Transforms'], + ['catch', z.object({ qty: z.number().catch(0) }), '$.qty', 'catch'], + ['unions', z.object({ v: z.union([z.string(), z.number()]) }), '$.v', 'Unsupported schema type'], + ['records', z.object({ v: z.record(z.string(), z.string()) }), '$.v', 'Unsupported schema type'], + ['array element unions', z.object({ xs: z.array(z.union([z.string(), z.number()])) }), '$.xs[]', 'Unsupported'], + ])('rejects %s with the offending path', (_name, schema, path, messagePart) => { + try { + buildWireSchema(schema); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(UnsupportedSchemaError); + expect((err as UnsupportedSchemaError).path).toBe(path); + expect((err as UnsupportedSchemaError).message).toContain(messagePart); + } + }); + + it('supports refinements without affecting the wire shape', () => { + const schema = z.object({ total: z.number() }).refine((v) => v.total >= 0); + expect(() => buildWireSchema(schema)).not.toThrow(); + }); +}); + +describe('unwrapWireOutput', () => { + const wireFields = { + vendor: wireLeaf('ACME Corp'), + total: wireLeaf(41.5, { bbox: [0.5, 0.8, 0.9, 0.85] }), + currency: wireLeaf('USD'), + notes: wireLeaf(null), + lineItems: [ + { description: wireLeaf('Widget'), amount: wireLeaf(41.5) }, + ], + }; + + it('rebuilds data and a parallel provenance tree', () => { + const { data, fields, issues, missingPaths } = unwrapWireOutput(receipt, wireFields, 1); + expect(data).toEqual({ + vendor: 'ACME Corp', + total: 41.5, + currency: 'USD', + notes: null, + lineItems: [{ description: 'Widget', amount: 41.5 }], + }); + expect(issues).toEqual([]); + expect(missingPaths).toEqual([]); + const f = fields as Record; + expect(f.vendor).toEqual({ value: 'ACME Corp', confidence: 0.9, page: 0, bbox: { x0: 0.1, y0: 0.2, x1: 0.4, y1: 0.3 } }); + expect((f.total as { bbox: unknown }).bbox).toEqual({ x0: 0.5, y0: 0.8, x1: 0.9, y1: 0.85 }); + expect((f.lineItems as unknown[]).length).toBe(1); + }); + + it('omits missing optional fields and keeps nullable fields as null', () => { + const { data, fields, missingPaths } = unwrapWireOutput( + receipt, + { ...wireFields, currency: wireLeaf(null), notes: wireLeaf(null) }, + 1, + ); + expect(data).not.toHaveProperty('currency'); + expect((data as { notes: unknown }).notes).toBeNull(); + expect(fields).not.toHaveProperty('currency'); + expect((fields as { notes: { value: unknown } }).notes.value).toBeNull(); + expect(missingPaths).toEqual([]); + }); + + it('records required fields the model returned as null', () => { + const { data, missingPaths } = unwrapWireOutput(receipt, { ...wireFields, total: wireLeaf(null) }, 1); + expect(missingPaths).toEqual(['$.total']); + expect(data).not.toHaveProperty('total'); + }); + + it('records missing required leaves inside array elements with their indexed path', () => { + const { data, missingPaths } = unwrapWireOutput( + receipt, + { + ...wireFields, + lineItems: [ + { description: wireLeaf('Widget'), amount: wireLeaf(41.5) }, + { description: wireLeaf(null), amount: wireLeaf(1) }, + ], + }, + 1, + ); + expect((data as { lineItems: unknown[] }).lineItems).toHaveLength(2); + expect(missingPaths).toContain('$.lineItems[1].description'); + }); + + it('drops null elements of primitive arrays with an issue', () => { + const schema = z.object({ tags: z.array(z.string()) }); + const { data, issues, missingPaths } = unwrapWireOutput( + schema, + { tags: [wireLeaf('urgent'), wireLeaf(null)] }, + 1, + ); + expect((data as { tags: string[] }).tags).toEqual(['urgent']); + expect(issues.some((i) => i.includes('$.tags[1]'))).toBe(true); + expect(missingPaths).toEqual([]); + }); + + it('nulls provenance for out-of-range pages', () => { + const { fields, issues } = unwrapWireOutput(receipt, { ...wireFields, vendor: wireLeaf('ACME', { page: 3 }) }, 1); + const vendor = (fields as { vendor: { page: unknown; bbox: unknown } }).vendor; + expect(vendor.page).toBeNull(); + expect(vendor.bbox).toBeNull(); + expect(issues.some((i) => i.includes('$.vendor') && i.includes('page 3'))).toBe(true); + }); + + it('drops degenerate bboxes and keeps the page', () => { + const { fields, issues } = unwrapWireOutput( + receipt, + { ...wireFields, vendor: wireLeaf('ACME', { bbox: [0.5, 0.2, 0.1, 0.3] }) }, + 1, + ); + const vendor = (fields as { vendor: { page: unknown; bbox: unknown } }).vendor; + expect(vendor.bbox).toBeNull(); + expect(vendor.page).toBe(0); + expect(issues.some((i) => i.includes('degenerate bbox'))).toBe(true); + }); + + it('drops a bbox reported without a page', () => { + const { fields, issues } = unwrapWireOutput( + receipt, + { ...wireFields, vendor: wireLeaf('ACME', { page: null }) }, + 1, + ); + expect((fields as { vendor: { bbox: unknown } }).vendor.bbox).toBeNull(); + expect(issues.some((i) => i.includes('bbox reported without a valid page'))).toBe(true); + }); + + it('clamps out-of-range confidence and coordinates', () => { + const { fields, issues } = unwrapWireOutput( + receipt, + { ...wireFields, vendor: wireLeaf('ACME', { confidence: 1.4, bbox: [-0.1, 0.2, 1.2, 0.3] }) }, + 1, + ); + const vendor = (fields as { vendor: { confidence: number; bbox: { x0: number; x1: number } } }).vendor; + expect(vendor.confidence).toBe(1); + expect(vendor.bbox).toEqual({ x0: 0, y0: 0.2, x1: 1, y1: 0.3 }); + expect(issues.some((i) => i.includes('confidence'))).toBe(true); + }); + + it('maps nullable containers returned as null', () => { + const schema = z.object({ shipping: z.object({ address: z.string() }).nullable() }); + const { data, fields } = unwrapWireOutput(schema, { shipping: null }, 1); + expect(data).toEqual({ shipping: null }); + expect(fields).toEqual({ shipping: null }); + }); +}); + +describe('path helpers', () => { + it('formats paths like $.a[0].b', () => { + expect(formatIssuePath(['a', 0, 'b'])).toBe('$.a[0].b'); + expect(formatIssuePath([])).toBe('$'); + }); + + it('classifies pure missing-field failures', () => { + const schema = z.object({ a: z.string(), b: z.number() }); + const missingOnly = schema.safeParse({ b: 1 }); + if (missingOnly.success) throw new Error('expected failure'); + expect(isOnlyMissingFields(missingOnly.error, ['$.a'])).toBe(true); + + const mixed = schema.safeParse({ b: 'nope' }); + if (mixed.success) throw new Error('expected failure'); + expect(isOnlyMissingFields(mixed.error, ['$.a'])).toBe(false); + expect(isOnlyMissingFields(mixed.error, [])).toBe(false); + }); +}); diff --git a/packages/core/test/stream.test.ts b/packages/core/test/stream.test.ts new file mode 100644 index 0000000..41f2520 --- /dev/null +++ b/packages/core/test/stream.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { DocumentUnreadableError, ExtractionFailedError } from '../src/errors.js'; +import { streamExtract } from '../src/stream.js'; +import type { ExtractStreamEvent } from '../src/types.js'; +import { envelopeJson, expectError, splitEvery, streamModel, tinyPng, wireLeaf } from './helpers.js'; + +const schema = z.object({ + vendor: z.string(), + total: z.number(), + lineItems: z.array(z.object({ description: z.string() })), +}); + +const goodFields = { + vendor: wireLeaf('ACME Corp'), + total: wireLeaf(41.5), + lineItems: [{ description: wireLeaf('Widget') }, { description: wireLeaf('Gadget') }], +}; + +async function collect(stream: AsyncIterable): Promise { + const events: ExtractStreamEvent[] = []; + for await (const event of stream) events.push(event); + return events; +} + +describe('streamExtract', () => { + it('emits one field event per leaf, in document order, then resolves the result', async () => { + // 7-char chunks force wrapper keys and string values to split mid-token. + const model = streamModel(splitEvery(envelopeJson(goodFields), 7)); + const stream = streamExtract({ schema, document: { data: tinyPng() }, model }); + + const events = await collect(stream); + expect(events.map((e) => e.path)).toEqual([ + ['vendor'], + ['total'], + ['lineItems', 0, 'description'], + ['lineItems', 1, 'description'], + ]); + expect(events[0]?.field).toEqual({ + value: 'ACME Corp', + confidence: 0.9, + page: 0, + bbox: { x0: 0.1, y0: 0.2, x1: 0.4, y1: 0.3 }, + }); + + const result = await stream.result; + expect(result.data).toEqual({ + vendor: 'ACME Corp', + total: 41.5, + lineItems: [{ description: 'Widget' }, { description: 'Gadget' }], + }); + expect(result.usage.modelCalls).toBe(1); + expect(result.usage.inputTokens).toBe(5); + expect(result.usage.outputTokens).toBe(15); + }); + + it('does not emit events for fields the model could not find', async () => { + const withMissing = { ...goodFields, total: wireLeaf(null) }; + const model = streamModel(splitEvery(envelopeJson(withMissing), 11)); + const stream = streamExtract({ + schema: z.object({ vendor: z.string(), total: z.number().optional(), lineItems: z.array(z.object({ description: z.string() })) }), + document: { data: tinyPng() }, + model, + }); + const events = await collect(stream); + expect(events.some((e) => e.path.join('.') === 'total')).toBe(false); + const result = await stream.result; + expect(result.data).not.toHaveProperty('total'); + }); + + it('fails both the iterator and the result on invalid final output', async () => { + const model = streamModel(splitEvery('{ definitely not the schema', 5)); + const stream = streamExtract({ schema, document: { data: tinyPng() }, model }); + + await expectError(collect(stream), ExtractionFailedError); + const err = await expectError(stream.result, ExtractionFailedError); + expect(err.code).toBe('EXTRACTION_FAILED'); + expect(err.attempts).toBe(1); + }); + + it('rejects with DocumentUnreadableError when the model says so', async () => { + const nulls = { + vendor: wireLeaf(null), + total: wireLeaf(null), + lineItems: [], + }; + const model = streamModel(splitEvery(envelopeJson(nulls, { readable: false, issues: ['photo of a cat, not a document'] }), 9)); + const stream = streamExtract({ schema, document: { data: tinyPng() }, model }); + + const events = await collect(stream).catch(() => []); + expect(events).toEqual([]); + const err = await expectError(stream.result, DocumentUnreadableError); + expect(err.issues).toEqual(['photo of a cat, not a document']); + }); + + it('reports missing required fields through the result promise', async () => { + const withMissing = { ...goodFields, total: wireLeaf(null) }; + const model = streamModel(splitEvery(envelopeJson(withMissing), 8)); + const stream = streamExtract({ schema, document: { data: tinyPng() }, model }); + const err = await expectError(stream.result, Error); + expect((err as { code?: string }).code).toBe('MISSING_REQUIRED_FIELDS'); + // Iterating after failure surfaces the same error. + await expectError(collect(stream), Error); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..83f81fe --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "test", "tsdown.config.ts", "vitest.config.ts"] +} diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts new file mode 100644 index 0000000..5fec9e2 --- /dev/null +++ b/packages/core/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..43e56f4 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..639bed4 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1173 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + packages/core: + dependencies: + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 + devDependencies: + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + ai: + specifier: ^7.0.16 + version: 7.0.16(zod@4.4.3) + tsdown: + specifier: ^0.22.3 + version: 0.22.3(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)) + zod: + specifier: ^4.4.3 + version: 4.4.3 + +packages: + + '@ai-sdk/gateway@4.0.12': + resolution: {integrity: sha512-Y7Fy8xJwPz7ZC0DhSQG3HIVk+drup42hrIj6yqKlib3CxwiR0F7nYyUI8+kPrEtbZEoyKoRstvT4/o0HEyFBHA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.5': + resolution: {integrity: sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.2': + resolution: {integrity: sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==} + engines: {node: '>=22'} + + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@8.0.2': + resolution: {integrity: sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@8.0.0': + resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/types@8.0.0': + resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/node@26.1.0': + resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + + ai@7.0.16: + resolution: {integrity: sha512-OuA+uz6ZUVId+SVeB5bThi25Ofee/FmLvffAA368tlgqYCe2qAhqZUpfHL0dd9QC/iPiszdvCQYENJavSo/0gw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@3.0.0: + resolution: {integrity: sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.26.0: + resolution: {integrity: sha512-e+kEPtUiDES0htk5iqkSeF4EzAV7R+vugGB44iPDuw1Kw9E+WyL1VG7PaV0IIjGHLiacztMBcMTyrr8ON9CT1Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.22.3: + resolution: {integrity: sha512-louqbfA8Qf//B9jTTL0FPtXTNpjCWv1VPkbcmQMph2pTpzs+LnB1tbe4tDDRVpo2BjF5SgUXaTZe45SxB8pWHg==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.3 + '@tsdown/exe': 0.22.3 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + vite@8.1.3: + resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@ai-sdk/gateway@4.0.12(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.2 + '@ai-sdk/provider-utils': 5.0.5(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@5.0.5(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.2 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.0 + zod: 4.4.3 + + '@ai-sdk/provider@4.0.2': + dependencies: + json-schema: 0.4.0 + + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.0 + '@babel/types': 8.0.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-string-parser@8.0.0': {} + + '@babel/helper-validator-identifier@8.0.2': {} + + '@babel/parser@8.0.0': + dependencies: + '@babel/types': 8.0.0 + + '@babel/types@8.0.0': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.2 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.138.0': {} + + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-x64@1.1.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.4': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.4': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/jsesc@2.5.1': {} + + '@types/node@26.1.0': + dependencies: + undici-types: 8.3.0 + + '@vercel/oidc@3.2.0': {} + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.3(@types/node@26.1.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@workflow/serde@4.1.0': {} + + ai@7.0.16(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 4.0.12(zod@4.4.3) + '@ai-sdk/provider': 4.0.2 + '@ai-sdk/provider-utils': 5.0.5(zod@4.4.3) + zod: 4.4.3 + + ansis@4.3.1: {} + + assertion-error@2.0.1: {} + + ast-kit@3.0.0: + dependencies: + '@babel/parser': 8.0.0 + estree-walker: 3.0.3 + pathe: 2.0.3 + + birpc@4.0.0: {} + + cac@7.0.0: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: {} + + dts-resolver@3.0.0: {} + + empathic@2.0.1: {} + + es-module-lexer@2.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventsource-parser@3.1.0: {} + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + hookable@6.1.1: {} + + import-without-cache@0.4.0: {} + + jsesc@3.1.0: {} + + json-schema@0.4.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + 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 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.15: {} + + obug@2.1.3: {} + + pako@1.0.11: {} + + pathe@2.0.3: {} + + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + quansync@1.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.26.0(rolldown@1.1.4)(typescript@6.0.3): + dependencies: + '@babel/generator': 8.0.0 + '@babel/helper-validator-identifier': 8.0.2 + '@babel/parser': 8.0.0 + ast-kit: 3.0.0 + birpc: 4.0.0 + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.3 + rolldown: 1.1.4 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.1.4: + dependencies: + '@oxc-project/types': 0.138.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 + + semver@7.8.5: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + tree-kill@1.2.2: {} + + tsdown@0.22.3(typescript@6.0.3): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.3 + picomatch: 4.0.5 + rolldown: 1.1.4 + rolldown-plugin-dts: 0.26.0(rolldown@1.1.4)(typescript@6.0.3) + semver: 7.8.5 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - vue-tsc + + tslib@1.14.1: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@8.3.0: {} + + vite@8.1.3(@types/node@26.1.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.16 + rolldown: 1.1.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.0 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3(@types/node@26.1.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6f00273 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - packages/* + - apps/* diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..aca7981 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2023"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true + } +}