diff --git a/extensions/A-ES/pledger/supa_doccs/.gitignore b/extensions/A-ES/pledger/supa_doccs/.gitignore new file mode 100644 index 000000000..c6b1dd696 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.gitignore @@ -0,0 +1,30 @@ +# Python artefacts +__pycache__/ +*.pyc +*.pyo +.venv/ +*.egg-info/ +dist/ +build/ + +# Node artefacts +node_modules/ +*.log + +# Environment and secrets +.env +.env.* +*.key +*.pem +secrets/ + +# Editor and OS +.DS_Store +.idea/ +.vscode/ + +# uv artefacts (uv.lock is intentionally kept) +.uv/ + +# Uploaded documents +uploads/ diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/.config.kiro b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/.config.kiro new file mode 100644 index 000000000..cef1c4d5f --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/.config.kiro @@ -0,0 +1 @@ +{"specId": "938d0833-dd5c-40f9-afaa-fcdaed2477d3", "workflowType": "fast-task", "specType": "feature"} \ No newline at end of file diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/design.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/design.md new file mode 100644 index 000000000..2d4b2ebed --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/design.md @@ -0,0 +1,277 @@ +# Design Document + +## Feature: agentic-doc-intelligence — Project Scaffold + +--- + +## Overview + +This document describes the structural design of the project scaffold for the agentic document-intelligence system. The scaffold provides no business logic; its sole purpose is to establish a fully runnable, reproducible project skeleton that all subsequent work builds on. + +The system targets synthetic microfinance and consumer loan-agreement documents in the Financial Compliance & Credit Auditing domain. All domain logic (parsing, embedding, compliance checks, LangGraph workflows) is out of scope for this scaffold. + +--- + +## Architecture + +``` +/Users/user/Documents/supa_doccs/ ← Project Root +├── pyproject.toml # uv-managed Python project manifest +├── Dockerfile # Python 3.11-slim API container +├── docker-compose.yml # Orchestrates postgres + api services +├── TASK.md # Ordered verifiable development increments +├── PROGRESS.md # Start date + Assumptions Log +├── .gitignore # Python / Node / env / editor exclusions +├── README.md # (existing) top-level project overview +├── decisions.md # (existing) architectural decision log +├── src/ +│ ├── __init__.py +│ ├── main.py # FastAPI app, GET /health only +│ └── README.md # "Source code for the FastAPI application." +├── tests/ +│ ├── __init__.py +│ ├── test_main.py # /health → 200 test +│ └── README.md # "Test suite mirroring the src/ layout." +├── docs/ +│ └── invariants.md # Header only: # Invariants +└── frontend/ + └── README.md # "Frontend application placeholder." +``` + +The architecture is flat and conventional: +- `src/` is a Python package (`__init__.py`) importable as `src.main`. +- `tests/` mirrors `src/` so every source module has a sibling test file. +- `docs/` holds persistent project knowledge (invariants, decisions). +- All empty directories carry a `README.md` so git tracks them without `.gitkeep` files. + +--- + +## Components and Interfaces + +### Component 1: `pyproject.toml` + +Managed by `uv`. Declares project metadata and all runtime + dev dependencies. + +```toml +[project] +name = "agentic-doc-intelligence" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi", + "uvicorn[standard]", + "langgraph", + "psycopg2-binary", + "pgvector", + "sqlalchemy", +] + +[dependency-groups] +dev = [ + "pytest", + "httpx", + "anyio[trio]", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +A developer runs `uv sync` to reproduce the full environment with no additional steps. + +--- + +### Component 2: `src/main.py` — FastAPI Entry Point + +Minimal application object plus a single `/health` route. No imports beyond `fastapi`. + +```python +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok"} +``` + +Started via: `uvicorn src.main:app --host 0.0.0.0 --port 8000` + +**Interface exposed:** + +| Method | Path | Response | Description | +|--------|------|----------|-------------| +| GET | `/health` | `{"status": "ok"}` (HTTP 200) | Liveness check | + +No authentication, no request body, no query parameters at scaffold stage. + +--- + +### Component 3: `tests/test_main.py` + +Uses `httpx.AsyncClient` with ASGI transport to exercise the live app object without starting a network server. + +```python +import pytest +import httpx +from src.main import app + + +@pytest.mark.anyio +async def test_health_returns_200(): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} +``` + +Run with: `uv run pytest` + +--- + +### Component 4: `Dockerfile` + +Python 3.11-slim base. `uv` installed via pip, then dependencies synced before source is copied — enabling Docker layer cache hits on repeated builds that only change source. + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +RUN pip install --no-cache-dir uv + +COPY pyproject.toml . +RUN uv sync --no-dev + +COPY src/ ./src/ + +CMD ["uv", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +--- + +### Component 5: `docker-compose.yml` + +Two services. `api` waits for `postgres` before starting. Data persists in named volume `pgdata` across container restarts. + +```yaml +version: "3.9" + +services: + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: docdb + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + api: + build: . + ports: + - "8000:8000" + depends_on: + - postgres + +volumes: + pgdata: +``` + +Single-command startup: `docker compose up` + +Key decisions: +- `pgvector/pgvector:pg16` bundles the extension — no init script needed to `CREATE EXTENSION vector`. +- Named volume `pgdata` persists data across `docker compose down` / `up` cycles. + +--- + +### Component 6: Process Artefacts + +**`TASK.md`** — Collaboration contract. Tasks are small, individually verifiable increments. Each entry includes a verification command or observable output. Embedded rules: +- Every non-trivial piece of code must have a test before or alongside it. +- All assumptions must be logged to `PROGRESS.md` before acting on them. +- Files outside the current task's scope must not be modified without prior declaration. + +**`PROGRESS.md`** — Project start date `2026-08-08` with an empty Assumptions Log table (columns: Date, Assumption, Reasoning). + +**`docs/invariants.md`** — Contains only `# Invariants`. Populated as structural and domain rules are identified. + +**`.gitignore`** — Covers four categories: + +| Category | Patterns | +|----------|----------| +| Python artefacts | `__pycache__/`, `*.pyc`, `*.pyo`, `.venv/`, `*.egg-info/`, `dist/`, `build/` | +| Node artefacts | `node_modules/`, `*.log` | +| Env / secrets | `.env`, `.env.*`, `*.key`, `*.pem`, `secrets/` | +| Editor / OS | `.DS_Store`, `.idea/`, `.vscode/` | +| uv artefacts | `.uv/` | + +`uv.lock` is intentionally **not** gitignored — lock files must be committed for reproducibility. + +--- + +## Data Models + +No data models at scaffold stage. Database schema, ORM models, and vector store configuration are introduced in subsequent tasks. + +--- + +## Error Handling + +At scaffold stage, error handling is limited to FastAPI's built-in responses: +- Unmatched routes → 404 JSON (FastAPI default). +- Unhandled exceptions → 500 JSON (FastAPI default). +- Invalid request bodies → 422 JSON (Pydantic validation, FastAPI default). + +Custom exception handlers are introduced when business-logic routes are added. + +--- + +## Testing Strategy + +Two complementary layers: + +**Smoke tests** — Verify that every required file and directory exists with the correct structure (pyproject.toml fields, .gitignore patterns, directory layout). These are lightweight assertions that run as part of `uv run pytest` and catch scaffold regressions immediately. + +**Example-based unit tests** — Verify deterministic endpoint behavior with concrete inputs. `tests/test_main.py` exercises `GET /health` using `httpx.AsyncClient` with ASGI transport — no live server, no network, fast execution. + +Property-based testing is not applicable to this scaffold: there are no pure functions with parameterisable input spaces. The correctness properties below are stated as invariants enforced by the example and smoke tests. + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +**Property reflection:** After analysing all 10 requirements and their acceptance criteria, the scaffold contains no pure business-logic functions with varying inputs. The only testable functional behavior is the `/health` endpoint (deterministic, covered by example test). Two additional universal properties emerge from the structure requirements: every TASK.md entry must carry a verification step (Req 5.3), and no src/ file may contain business logic (Req 10.1/10.3). + +--- + +### Property 1: Health endpoint always returns 200 + +*For any* HTTP GET request sent to the `/health` endpoint of the running FastAPI application, the response SHALL have HTTP status code 200 and a JSON body of `{"status": "ok"}`. + +**Validates: Requirements 2.2, 3.4** + +--- + +### Property 2: Every TASK.md entry contains a verification step + +*For any* task entry present in `TASK.md`, the entry SHALL contain an explicit verification step — either a runnable test command or a described observable output — that unambiguously confirms the task is complete. + +**Validates: Requirements 5.3** + +--- + +### Property 3: No business logic in scaffold source files + +*For any* Python file located under `src/` at scaffold time, the file SHALL NOT import or define document-parsing, compliance-checking, vector-embedding, or LangGraph workflow code. The only permitted constructs are standard-library / third-party imports, the FastAPI app instantiation, and the `/health` route handler. + +**Validates: Requirements 10.1, 10.2, 10.3** diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/requirements.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/requirements.md new file mode 100644 index 000000000..5c9ef7b35 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/requirements.md @@ -0,0 +1,151 @@ +# Requirements Document + +## Introduction + +This feature establishes the project skeleton for an agentic document-intelligence system targeting synthetic microfinance and consumer loan agreements in the Financial Compliance & Credit Auditing domain. The scaffold must be fully runnable from a single command, contain no business logic, and provide clear structural foundations for every subsequent development task. All structure, tooling, and process artefacts are defined here so that no future task needs to re-establish project conventions. + +## Glossary + +- **Project Root**: The directory `/Users/user/Documents/supa_doccs/` that contains all project files. +- **Scaffold**: The complete set of directories, placeholder files, configuration files, and tooling manifests that constitute the runnable project skeleton before any business logic is added. +- **uv**: The Python package and environment manager used to install dependencies and run commands (`uv run`). +- **LangGraph**: The graph-based agent orchestration framework used to define, run, and checkpoint multi-step AI workflows. +- **pgvector**: A PostgreSQL extension that adds vector similarity search capabilities. +- **TASK.md**: The authoritative ordered list of small, verifiable development increments for the project. +- **PROGRESS.md**: A living log that records today's date and an Assumptions Log table tracking every assumption made during development. +- **docker-compose.yml**: The single-command orchestration file that starts both the PostgreSQL+pgvector service and the FastAPI service. +- **Placeholder README**: A one-line markdown file placed in each empty directory to make the directory trackable by git. +- **Invariants**: Structural or domain rules that must remain true across all versions of the system, recorded in `docs/invariants.md`. + +--- + +## Requirements + +### Requirement 1: Python Tooling and Runtime Configuration + +**User Story:** As a developer, I want the project to use Python 3.11+ managed by `uv` with all core dependencies declared, so that any contributor can reproduce the environment with a single command. + +#### Acceptance Criteria + +1. THE Scaffold SHALL include a `pyproject.toml` at the Project Root that specifies `requires-python = ">=3.11"`. +2. THE Scaffold SHALL declare `fastapi`, `uvicorn`, `langgraph`, `psycopg2-binary`, `pgvector`, and `sqlalchemy` as runtime dependencies in `pyproject.toml`. +3. THE Scaffold SHALL declare `pytest` as a development dependency in `pyproject.toml`. +4. WHEN a developer runs `uv sync`, THE Scaffold SHALL install all declared dependencies into an isolated virtual environment without manual setup steps. + +--- + +### Requirement 2: FastAPI Application Entry Point + +**User Story:** As a developer, I want a minimal FastAPI application entry point under `src/`, so that the API process can start without errors before any business logic is implemented. + +#### Acceptance Criteria + +1. THE Scaffold SHALL contain a file at `src/main.py` that instantiates a `FastAPI` application object. +2. WHEN the FastAPI application is started via `uvicorn src.main:app`, THE FastAPI Application SHALL respond to `GET /health` with HTTP status 200. +3. THE `src/` directory SHALL contain a `__init__.py` file making it a Python package. +4. THE `src/` directory SHALL contain a `README.md` with a one-line description of the directory's purpose. + +--- + +### Requirement 3: Test Infrastructure + +**User Story:** As a developer, I want a `tests/` directory structured to mirror `src/`, so that every source module has a corresponding test file reachable by `uv run pytest`. + +#### Acceptance Criteria + +1. THE Scaffold SHALL contain a `tests/` directory at the Project Root. +2. THE `tests/` directory SHALL contain a `__init__.py` file. +3. THE `tests/` directory SHALL contain a `README.md` with a one-line description of the directory's purpose. +4. THE Scaffold SHALL contain a `tests/test_main.py` file with at least one passing test that verifies the `/health` endpoint returns HTTP status 200. +5. WHEN a developer runs `uv run pytest`, THE Test Runner SHALL discover and execute all tests in `tests/` without configuration errors. + +--- + +### Requirement 4: PostgreSQL + pgvector Docker Compose Service + +**User Story:** As a developer, I want a `docker-compose.yml` that brings up PostgreSQL with the pgvector extension and the FastAPI service together, so that the full runtime environment starts with one command and requires no manual setup. + +#### Acceptance Criteria + +1. THE Scaffold SHALL include a `docker-compose.yml` at the Project Root defining a `postgres` service using an image that bundles the pgvector extension (e.g., `pgvector/pgvector:pg16`). +2. THE `docker-compose.yml` SHALL define a `api` service that builds from a `Dockerfile` at the Project Root and depends on the `postgres` service. +3. THE `postgres` service SHALL expose port 5432 and persist data using a named Docker volume. +4. THE `api` service SHALL expose port 8000. +5. WHEN a developer runs `docker compose up`, THE Docker Compose Orchestrator SHALL start both services without requiring any manual steps beyond the command. +6. THE Scaffold SHALL include a `Dockerfile` at the Project Root that uses a Python 3.11 base image and installs dependencies via `uv`. + +--- + +### Requirement 5: TASK.md — Ordered Verifiable Increments + +**User Story:** As a developer, I want a `TASK.md` at the Project Root listing small, verifiable development tasks with explicit test requirements, so that progress can be tracked and each task can be verified independently. + +#### Acceptance Criteria + +1. THE Scaffold SHALL include a `TASK.md` file at the Project Root. +2. THE `TASK.md` SHALL list tasks as individually numbered increments, each small enough to complete in one focused session. +3. EACH task entry in `TASK.md` SHALL specify a verification step (test command or observable output) that confirms the task is complete. +4. THE `TASK.md` SHALL include the instruction that every non-trivial piece of code must have a test written before or alongside it. +5. THE `TASK.md` SHALL include the instruction that assumptions must be logged to `PROGRESS.md` before acting on them. +6. THE `TASK.md` SHALL include the instruction that files outside the current task's scope must not be modified without prior declaration. + +--- + +### Requirement 6: PROGRESS.md — Assumptions Log + +**User Story:** As a developer, I want a `PROGRESS.md` at the Project Root pre-populated with today's date and an empty Assumptions Log table, so that all assumptions are recorded in a single, consistent place from day one. + +#### Acceptance Criteria + +1. THE Scaffold SHALL include a `PROGRESS.md` file at the Project Root. +2. THE `PROGRESS.md` SHALL display the date `2026-08-08` as the project start date. +3. THE `PROGRESS.md` SHALL contain an Assumptions Log table with the columns `Date`, `Assumption`, and `Reasoning` and no data rows initially. + +--- + +### Requirement 7: docs/invariants.md — Header Only + +**User Story:** As a developer, I want a `docs/invariants.md` file containing only a header, so that a dedicated place exists to record structural and domain invariants as the system evolves. + +#### Acceptance Criteria + +1. THE Scaffold SHALL include a `docs/` directory at the Project Root. +2. THE Scaffold SHALL include a `docs/invariants.md` file containing only a top-level markdown heading and no body content. + +--- + +### Requirement 8: .gitignore Coverage + +**User Story:** As a developer, I want a `.gitignore` that excludes Python artefacts, Node artefacts, environment files, and secrets, so that no sensitive or generated files are accidentally committed. + +#### Acceptance Criteria + +1. THE Scaffold SHALL include a `.gitignore` file at the Project Root. +2. THE `.gitignore` SHALL exclude Python bytecode and cache directories (`__pycache__/`, `*.pyc`, `*.pyo`, `.venv/`, `*.egg-info/`, `dist/`, `build/`). +3. THE `.gitignore` SHALL exclude Node artefacts (`node_modules/`, `dist/`, `*.log`). +4. THE `.gitignore` SHALL exclude environment and secrets files (`.env`, `.env.*`, `*.key`, `*.pem`, `secrets/`). +5. THE `.gitignore` SHALL exclude common editor and OS artefacts (`.DS_Store`, `.idea/`, `.vscode/`). +6. THE `.gitignore` SHALL exclude `uv` artefacts (`.uv/`, `uv.lock` is kept intentionally — it SHALL NOT be gitignored). + +--- + +### Requirement 9: Frontend Placeholder + +**User Story:** As a developer, I want an empty `frontend/` directory tracked by git with a placeholder README, so that the directory exists and its purpose is clear before any frontend work begins. + +#### Acceptance Criteria + +1. THE Scaffold SHALL include a `frontend/` directory at the Project Root. +2. THE `frontend/` directory SHALL contain a `README.md` with a one-line description of the directory's purpose. + +--- + +### Requirement 10: No Business Logic in Scaffold + +**User Story:** As a developer, I want the scaffold to contain zero business logic, so that the initial structure is unambiguous and the domain layer can be introduced in clearly scoped subsequent tasks. + +#### Acceptance Criteria + +1. THE Scaffold SHALL NOT contain any document-parsing, compliance-checking, vector-embedding, or LangGraph workflow implementation code. +2. THE `src/` directory SHALL contain only the FastAPI entry point, package init files, and placeholder files at scaffold time. +3. IF a file in the Scaffold contains code beyond imports and a minimal health-check handler, THEN THE Scaffold SHALL be considered incomplete and the file SHALL be revised to remove the excess logic. diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/tasks.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/tasks.md new file mode 100644 index 000000000..a650d2399 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/agentic-doc-intelligence/tasks.md @@ -0,0 +1,133 @@ +# Implementation Plan: agentic-doc-intelligence — Project Scaffold + +## Overview + +Create the complete project skeleton for the agentic document-intelligence system. Every task produces a concrete file or set of files; no business logic is introduced. The scaffold is complete when `uv run pytest` passes and `docker compose up` starts both services. + +Language: Python 3.11 / YAML / TOML / Dockerfile + +--- + +## Tasks + +- [x] 1. Create `.gitignore` + - [x] 1.1 Write `.gitignore` at the project root + - Cover Python artefacts: `__pycache__/`, `*.pyc`, `*.pyo`, `.venv/`, `*.egg-info/`, `dist/`, `build/` + - Cover Node artefacts: `node_modules/`, `*.log` + - Cover env / secrets: `.env`, `.env.*`, `*.key`, `*.pem`, `secrets/` + - Cover editor / OS: `.DS_Store`, `.idea/`, `.vscode/` + - Cover uv artefacts: `.uv/` — `uv.lock` must NOT be gitignored + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6_ + +- [x] 2. Create `pyproject.toml` + - [x] 2.1 Write `pyproject.toml` at the project root + - Set `name = "agentic-doc-intelligence"`, `version = "0.1.0"`, `requires-python = ">=3.11"` + - Declare runtime deps: `fastapi`, `uvicorn[standard]`, `langgraph`, `psycopg2-binary`, `pgvector`, `sqlalchemy` + - Declare dev deps (`[project.optional-dependencies] dev`): `pytest`, `httpx`, `anyio[trio]` + - Set build system to hatchling + - _Requirements: 1.1, 1.2, 1.3_ + +- [x] 3. Scaffold `src/` package + - [x] 3.1 Create `src/__init__.py` (empty) + - _Requirements: 2.3_ + - [x] 3.2 Create `src/main.py` with FastAPI app and `/health` route + - Import `FastAPI` only; instantiate `app = FastAPI()` + - Add `@app.get("/health") async def health() -> dict: return {"status": "ok"}` + - No other logic, imports, or routes + - _Requirements: 2.1, 2.2, 10.1, 10.2, 10.3_ + - [x] 3.3 Create `src/README.md` + - Content: `"Source code for the FastAPI application."` + - _Requirements: 2.4_ + +- [x] 4. Scaffold `tests/` package and health test + - [x] 4.1 Create `tests/__init__.py` (empty) + - _Requirements: 3.1, 3.2_ + - [x] 4.2 Create `tests/test_main.py` with async health-endpoint test + - Import `pytest`, `httpx`, and `app` from `src.main` + - Use `httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")` + - Assert `response.status_code == 200` and `response.json() == {"status": "ok"}` + - Mark with `@pytest.mark.anyio` + - _Requirements: 3.4, 3.5_ + - [ ]* 4.3 Write property test for health endpoint + - **Property 1: Health endpoint always returns 200** + - Send arbitrary GET requests to `/health`; assert status 200 and body `{"status": "ok"}` every time + - **Validates: Requirements 2.2, 3.4** + - [x] 4.4 Create `tests/README.md` + - Content: `"Test suite mirroring the src/ layout."` + - _Requirements: 3.3_ + +- [x] 5. Checkpoint — tests must pass + - Run `uv run pytest` and confirm all tests pass. Ask the user if any test fails or if questions arise. + +- [x] 6. Create `Dockerfile` + - [x] 6.1 Write `Dockerfile` at the project root + - Base image: `python:3.11-slim`; `WORKDIR /app` + - Install `uv` via `pip install --no-cache-dir uv` + - Copy `pyproject.toml`, run `uv sync --no-dev` (dependency-cache layer) + - Copy `src/` into `./src/` + - `CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]` + - _Requirements: 4.6_ + +- [x] 7. Create `docker-compose.yml` + - [x] 7.1 Write `docker-compose.yml` at the project root + - Define `postgres` service: image `pgvector/pgvector:pg16`, env vars `POSTGRES_USER/PASSWORD/DB`, port 5432, named volume `pgdata` + - Define `api` service: `build: .`, port 8000, `depends_on: [postgres]` + - Declare named volume `pgdata` + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_ + +- [x] 8. Create `TASK.md` + - [x] 8.1 Write `TASK.md` at the project root + - List all scaffold tasks as numbered, individually verifiable increments + - Each entry must include a verification step (test command or observable output) + - Embed the three collaboration rules: + 1. Every non-trivial piece of code must have a test written before or alongside it. + 2. All assumptions must be logged to `PROGRESS.md` before acting on them. + 3. Files outside the current task's scope must not be modified without prior declaration. + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_ + +- [x] 9. Create `PROGRESS.md` + - [x] 9.1 Write `PROGRESS.md` at the project root + - Display project start date: `2026-08-08` + - Include an Assumptions Log table with columns `Date`, `Assumption`, `Reasoning` and no data rows + - _Requirements: 6.1, 6.2, 6.3_ + +- [x] 10. Create `docs/invariants.md` and `frontend/README.md` + - [x] 10.1 Create `docs/invariants.md` + - Content: `# Invariants` (heading only, no body) + - _Requirements: 7.1, 7.2_ + - [x] 10.2 Create `frontend/README.md` + - Content: `"Frontend application placeholder."` + - _Requirements: 9.1, 9.2_ + +- [x] 11. Final checkpoint — full verification + - Run `uv run pytest`; confirm all tests pass. + - Run `docker compose build` and confirm the image builds without errors. + - Ensure all scaffold files listed in the directory layout exist. + - Ask the user if questions arise before proceeding to domain tasks. + +--- + +## Notes + +- Tasks marked with `*` are optional and can be skipped for a faster MVP. +- Each task references specific requirements for traceability. +- Checkpoints ensure incremental validation after each logical group. +- Property 1 is the only correctness property defined for the scaffold; its test belongs alongside the unit tests in task 4. +- No business logic of any kind may be introduced in these tasks — all files are structural or configuration artefacts. + +--- + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["2.1"] }, + { "id": 2, "tasks": ["3.1", "3.2", "3.3"] }, + { "id": 3, "tasks": ["4.1", "4.4"] }, + { "id": 4, "tasks": ["4.2", "6.1", "8.1", "9.1", "10.1", "10.2"] }, + { "id": 5, "tasks": ["4.3", "7.1"] } + ] +} +``` diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/.config.kiro b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/.config.kiro new file mode 100644 index 000000000..9754f2cdc --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/.config.kiro @@ -0,0 +1 @@ +{"specId": "6973c673-4a24-4c29-899c-acb901b08c5b", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/design.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/design.md new file mode 100644 index 000000000..861439c19 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/design.md @@ -0,0 +1,717 @@ +# Design Document: Core PostgreSQL Schema + +## Overview + +This design defines the PostgreSQL schema that underpins the agentic document-intelligence system. The schema provides: + +- **Document lifecycle management** — ingestion, content-hash deduplication, and immutable version history +- **Claim extraction with source attribution** — linking factual assertions back to exact character spans in source documents +- **Pipeline run/step tracking** — enabling resumability after failures by checkpointing at the step level +- **Human-in-the-loop approval** — a queue/decision model where claims flow through pending → approved/rejected +- **Append-only audit trail** — every state change recorded immutably in the same transaction +- **Concurrent run safety** — advisory locks for reads, optimistic concurrency control (OCC) for writes + +Target: `pgvector/pgvector:pg16` (PostgreSQL 16 + vector extension) +ORM: SQLAlchemy (declarative models mapping to raw SQL migrations) +Migration strategy: Sequential numbered SQL files, idempotent with `IF NOT EXISTS` + +--- + +## Architecture + +### Entity-Relationship Diagram + +```mermaid +erDiagram + documents ||--o{ document_versions : "has versions" + document_versions ||--o{ claims : "produces" + runs ||--o{ claims : "generates" + runs ||--o{ run_steps : "contains" + claims ||--o{ source_locations : "attributed to" + document_versions ||--o{ source_locations : "referenced by" + claims ||--o{ approval_queue : "queued for review" + approval_queue ||--o| decisions : "resolved by" + + documents { + uuid id PK + varchar(255) filename + varchar(100) mime_type + timestamptz ingested_at + jsonb metadata + } + + document_versions { + uuid id PK + uuid document_id FK + char(64) content_hash + varchar(1024) storage_ref + int version_number + timestamptz created_at + } + + claims { + uuid id PK + uuid document_version_id FK + uuid run_id FK + varchar(10000) extracted_text + varchar(128) claim_type + numeric confidence + timestamptz extracted_at + } + + source_locations { + uuid id PK + uuid claim_id FK + uuid document_version_id FK + int page_number + varchar(256) section_id + int start_offset + int end_offset + varchar(512) clause_ref + } + + runs { + uuid id PK + varchar(20) status + timestamptz started_at + timestamptz ended_at + jsonb config_snapshot + varchar(128) initiator + int version + } + + run_steps { + uuid id PK + uuid run_id FK + varchar(128) step_name + int step_order + varchar(20) status + timestamptz started_at + timestamptz ended_at + jsonb input_state + jsonb output_state + text error_details + int retry_count + int version + } + + approval_queue { + uuid id PK + uuid claim_id FK + varchar(20) status + varchar(128) assigned_reviewer + timestamptz queued_at + int priority + int version + } + + decisions { + uuid id PK + uuid approval_queue_id FK + varchar(10) decision_value + varchar(128) reviewer_id + timestamptz decided_at + varchar(2000) justification + } + + audit_events { + uuid id PK + timestamptz event_timestamp + varchar(50) entity_type + uuid entity_id + varchar(20) action + varchar(128) actor_id + jsonb previous_state + jsonb new_state + varchar(128) source_ref + } + + schema_migrations { + int id PK + varchar(255) filename + timestamptz applied_at + } +``` + +### High-Level Data Flow + +``` +Document ingested + → document row + document_version row (content-hash deduplicated) + → Run created (status: pending → running) + → Steps executed sequentially (each checkpointed) + → Claims extracted (linked to run + document_version) + → Source_locations recorded per claim + → Claims promoted to approval_queue (status: pending) + → Human decision recorded → queue status updated + → Audit_events written alongside every state change +``` + +--- + +## Components and Interfaces + +### Table: `schema_migrations` + +Tracks applied migrations. Must be created first (bootstrap). + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `INTEGER` | PRIMARY KEY | +| `filename` | `VARCHAR(255)` | NOT NULL | +| `applied_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT NOW() | + +### Table: `documents` + +Stores top-level document metadata. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `filename` | `VARCHAR(255)` | NOT NULL | +| `mime_type` | `VARCHAR(100)` | NOT NULL, CHECK (mime_type IN ('application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain')) | +| `ingested_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT NOW() | +| `metadata` | `JSONB` | NOT NULL DEFAULT '{}' | + +**Indexes:** +- `idx_documents_mime_type` ON `documents(mime_type)` +- `idx_documents_ingested_at` ON `documents(ingested_at)` + +### Table: `document_versions` + +Immutable version snapshots. Content-hash deduplication prevents storing identical content twice. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `document_id` | `UUID` | NOT NULL, FK → documents(id) ON DELETE RESTRICT | +| `content_hash` | `CHAR(64)` | NOT NULL | +| `storage_ref` | `VARCHAR(1024)` | NOT NULL | +| `version_number` | `INTEGER` | NOT NULL, CHECK (version_number >= 1) | +| `created_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT NOW() | + +**Constraints:** +- UNIQUE (`document_id`, `content_hash`) — deduplication within a document +- UNIQUE (`document_id`, `version_number`) — ordered versioning + +**Indexes:** +- `idx_dv_document_id` ON `document_versions(document_id)` +- `idx_dv_content_hash` ON `document_versions(content_hash)` + +**Immutability Trigger:** A BEFORE UPDATE trigger on `document_versions` raises an exception if `content_hash` or `storage_ref` is modified after insertion. + +```sql +CREATE OR REPLACE FUNCTION prevent_version_mutation() RETURNS TRIGGER AS $$ +BEGIN + IF OLD.content_hash IS DISTINCT FROM NEW.content_hash THEN + RAISE EXCEPTION 'content_hash is immutable'; + END IF; + IF OLD.storage_ref IS DISTINCT FROM NEW.storage_ref THEN + RAISE EXCEPTION 'storage_ref is immutable'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +``` + +### Table: `runs` + +Pipeline execution sessions. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `status` | `VARCHAR(20)` | NOT NULL DEFAULT 'pending', CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')) | +| `started_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT NOW() | +| `ended_at` | `TIMESTAMPTZ` | NULL | +| `config_snapshot` | `JSONB` | NOT NULL DEFAULT '{}' | +| `initiator` | `VARCHAR(128)` | NOT NULL | +| `version` | `INTEGER` | NOT NULL DEFAULT 1 | + +**Indexes:** +- `idx_runs_status` ON `runs(status)` +- `idx_runs_started_at` ON `runs(started_at)` + +**State Machine Trigger:** A BEFORE UPDATE trigger enforces valid transitions: + +```sql +CREATE OR REPLACE FUNCTION enforce_run_status_transition() RETURNS TRIGGER AS $$ +DECLARE + valid_transitions JSONB := '{ + "pending": ["running"], + "running": ["completed", "failed", "cancelled"] + }'::jsonb; + allowed_next JSONB; +BEGIN + IF OLD.status = NEW.status THEN RETURN NEW; END IF; + allowed_next := valid_transitions -> OLD.status; + IF allowed_next IS NULL OR NOT (allowed_next ? NEW.status) THEN + RAISE EXCEPTION 'Invalid run status transition: % → %', OLD.status, NEW.status; + END IF; + NEW.version := OLD.version + 1; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +``` + +### Table: `run_steps` + +Individual checkpointed steps within a run. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `run_id` | `UUID` | NOT NULL, FK → runs(id) ON DELETE RESTRICT | +| `step_name` | `VARCHAR(128)` | NOT NULL | +| `step_order` | `INTEGER` | NOT NULL, CHECK (step_order >= 1) | +| `status` | `VARCHAR(20)` | NOT NULL DEFAULT 'pending', CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped')) | +| `started_at` | `TIMESTAMPTZ` | NULL | +| `ended_at` | `TIMESTAMPTZ` | NULL | +| `input_state` | `JSONB` | NOT NULL DEFAULT '{}' | +| `output_state` | `JSONB` | NOT NULL DEFAULT '{}' | +| `error_details` | `TEXT` | NULL | +| `retry_count` | `INTEGER` | NOT NULL DEFAULT 0, CHECK (retry_count >= 0 AND retry_count <= 10) | +| `version` | `INTEGER` | NOT NULL DEFAULT 1 | + +**Constraints:** +- UNIQUE (`run_id`, `step_order`) + +**Indexes:** +- `idx_run_steps_run_status_order` ON `run_steps(run_id, status, step_order DESC)` — resume query +- `idx_run_steps_run_id` ON `run_steps(run_id)` +- `idx_run_steps_status` ON `run_steps(status)` + +**Resume Query Pattern:** +```sql +SELECT * FROM run_steps +WHERE run_id = $1 AND status = 'completed' +ORDER BY step_order DESC +LIMIT 1; +``` + +### Table: `claims` + +Factual assertions extracted from document versions. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `document_version_id` | `UUID` | NOT NULL, FK → document_versions(id) ON DELETE RESTRICT | +| `run_id` | `UUID` | NOT NULL, FK → runs(id) ON DELETE RESTRICT | +| `extracted_text` | `VARCHAR(10000)` | NOT NULL | +| `claim_type` | `VARCHAR(128)` | NOT NULL | +| `confidence` | `NUMERIC(4,3)` | NOT NULL, CHECK (confidence >= 0.0 AND confidence <= 1.0) | +| `extracted_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT NOW() | + +**Indexes:** +- `idx_claims_document_version_id` ON `claims(document_version_id)` +- `idx_claims_run_id` ON `claims(run_id)` +- `idx_claims_type` ON `claims(claim_type)` +- `idx_claims_confidence` ON `claims(confidence)` + +### Table: `source_locations` + +Precise positions within a document version where a claim originates. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `claim_id` | `UUID` | NOT NULL, FK → claims(id) ON DELETE RESTRICT | +| `document_version_id` | `UUID` | NOT NULL, FK → document_versions(id) ON DELETE RESTRICT | +| `page_number` | `INTEGER` | NULL, CHECK (page_number >= 1 OR page_number IS NULL) | +| `section_id` | `VARCHAR(256)` | NULL | +| `start_offset` | `INTEGER` | NOT NULL, CHECK (start_offset >= 0) | +| `end_offset` | `INTEGER` | NOT NULL, CHECK (end_offset >= 0) | +| `clause_ref` | `VARCHAR(512)` | NULL | + +**Constraints:** +- CHECK (`start_offset < end_offset`) + +**Indexes:** +- `idx_source_locations_claim_id` ON `source_locations(claim_id)` +- `idx_source_locations_dv_id` ON `source_locations(document_version_id)` + +### Table: `approval_queue` + +Claims pending human review. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `claim_id` | `UUID` | NOT NULL, FK → claims(id) ON DELETE RESTRICT | +| `status` | `VARCHAR(20)` | NOT NULL DEFAULT 'pending', CHECK (status IN ('pending', 'approved', 'rejected')) | +| `assigned_reviewer` | `VARCHAR(128)` | NULL | +| `queued_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT NOW() | +| `priority` | `INTEGER` | NOT NULL DEFAULT 3, CHECK (priority >= 1 AND priority <= 5) | +| `version` | `INTEGER` | NOT NULL DEFAULT 1 | + +**Indexes:** +- `idx_aq_claim_id` ON `approval_queue(claim_id)` +- `idx_aq_status` ON `approval_queue(status)` +- `idx_aq_status_priority_queued` ON `approval_queue(status, priority, queued_at)` — reviewer dashboard query +- `idx_aq_queued_at` ON `approval_queue(queued_at)` + +### Table: `decisions` + +Recorded human judgments on queued claims. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `approval_queue_id` | `UUID` | NOT NULL, FK → approval_queue(id) ON DELETE RESTRICT, UNIQUE | +| `decision_value` | `VARCHAR(10)` | NOT NULL, CHECK (decision_value IN ('approved', 'rejected')) | +| `reviewer_id` | `VARCHAR(128)` | NOT NULL | +| `decided_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT NOW() | +| `justification` | `VARCHAR(2000)` | NOT NULL, CHECK (length(justification) >= 1) | + +**Indexes:** +- `idx_decisions_aq_id` ON `decisions(approval_queue_id)` (covered by UNIQUE) +- `idx_decisions_reviewer` ON `decisions(reviewer_id)` + +**Decision Guard Trigger:** Prevents inserting a decision against a non-pending queue entry: + +```sql +CREATE OR REPLACE FUNCTION guard_decision_on_pending() RETURNS TRIGGER AS $$ +DECLARE + queue_status VARCHAR(20); +BEGIN + SELECT status INTO queue_status FROM approval_queue WHERE id = NEW.approval_queue_id; + IF queue_status != 'pending' THEN + RAISE EXCEPTION 'Cannot record decision: approval_queue entry is %, expected pending', queue_status; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +``` + +### Table: `audit_events` + +Immutable append-only record of all state changes. + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | `UUID` | PRIMARY KEY, DEFAULT gen_random_uuid() | +| `event_timestamp` | `TIMESTAMPTZ` | NOT NULL DEFAULT NOW() | +| `entity_type` | `VARCHAR(50)` | NOT NULL, CHECK (entity_type IN ('document', 'document_version', 'claim', 'source_location', 'run', 'run_step', 'approval_queue', 'decision')) | +| `entity_id` | `UUID` | NOT NULL | +| `action` | `VARCHAR(20)` | NOT NULL, CHECK (action IN ('created', 'updated', 'status_changed', 'deleted')) | +| `actor_id` | `VARCHAR(128)` | NOT NULL | +| `previous_state` | `JSONB` | NULL | +| `new_state` | `JSONB` | NOT NULL | +| `source_ref` | `VARCHAR(128)` | NULL | + +**Indexes:** +- `idx_audit_event_timestamp` ON `audit_events(event_timestamp)` +- `idx_audit_entity_history` ON `audit_events(entity_type, entity_id, event_timestamp)` +- `idx_audit_actor` ON `audit_events(actor_id)` + +**Immutability Rule:** A BEFORE UPDATE OR DELETE trigger on `audit_events` raises an exception unconditionally: + +```sql +CREATE OR REPLACE FUNCTION prevent_audit_mutation() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'audit_events is append-only: % operations are forbidden', TG_OP; +END; +$$ LANGUAGE plpgsql; +``` + +--- + +## Data Models + +### SQLAlchemy Model Mapping Strategy + +Each table maps to a SQLAlchemy declarative model in `src/models/`. The models follow these conventions: + +- **Base class:** `DeclarativeBase` with a shared `metadata` instance +- **UUID PKs:** `Mapped[uuid.UUID]` with `server_default=text("gen_random_uuid()")` +- **Timestamps:** `Mapped[datetime]` with `server_default=text("NOW()")` +- **OCC columns:** `version` fields with `onupdate` logic in the repository layer (not ORM-managed auto-increment — explicit WHERE version = expected) + +```python +# src/models/base.py +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import text +import uuid +from datetime import datetime + +class Base(DeclarativeBase): + pass + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(server_default=text("NOW()")) +``` + +### Key Type Mappings + +| Domain Concept | Python Type | SQLAlchemy Column | PG Type | +|---------------|-------------|-------------------|---------| +| Identifiers | `uuid.UUID` | `Mapped[uuid.UUID]` | `UUID` | +| Timestamps | `datetime` | `Mapped[datetime]` | `TIMESTAMPTZ` | +| Status enums | `str` | `Mapped[str]` w/ CHECK | `VARCHAR(20)` | +| Content hash | `str` | `Mapped[str]` | `CHAR(64)` | +| Confidence | `Decimal` | `Mapped[Decimal]` | `NUMERIC(4,3)` | +| JSON blobs | `dict` | `Mapped[dict]` | `JSONB` | +| OCC version | `int` | `Mapped[int]` | `INTEGER` | + +--- + +## Key Design Decisions + +### 1. Run/Session Tracking for Resumability + +**Finding the last completed step:** +```sql +SELECT * FROM run_steps +WHERE run_id = :run_id AND status = 'completed' +ORDER BY step_order DESC +LIMIT 1; +``` +The composite index `(run_id, status, step_order DESC)` makes this a single index scan. + +**Handling interrupted "running" steps:** +When a run resumes and finds a step with `status = 'running'` and `ended_at IS NULL`, the application marks it as `failed` with `error_details = 'interrupted: prior execution did not complete'` and increments `retry_count`. The next step in sequence then begins. + +### 2. Document Versioning with Content-Hash Deduplication + +The UNIQUE constraint on `(document_id, content_hash)` ensures that re-ingesting identical content for the same document is a no-op at the database level. The application layer catches the unique violation and returns the existing `document_version_id`. Different documents may share the same content hash (e.g., same file uploaded to two accounts) — this is intentional and acceptable. + +### 3. Claims-to-Source Linking + +One claim can have many source_locations (1:N relationship). This supports cross-reference claims like "APR stated on page 1 contradicts fee schedule on page 4" where a single claim is derived from multiple passages. Both `source_locations.claim_id` and `source_locations.document_version_id` are NOT NULL foreign keys, ensuring every source location is anchored to both a claim and a specific version. + +### 4. Pending-Approval Queue Flow + +``` +Claim extracted → approval_queue entry (status: pending) + → Reviewer picks up → Decision recorded + → approved: queue status → 'approved' + → rejected: queue status → 'rejected' + → Claim re-extracted → NEW queue entry (fresh pending) +``` + +Key: rejected claims get a *new* queue entry, preserving the full decision history. The UNIQUE constraint on `decisions.approval_queue_id` ensures exactly one decision per queue entry. The trigger `guard_decision_on_pending` prevents recording decisions against already-resolved entries. + +### 5. Audit Trail Implementation + +- **Same-transaction insertion:** Application code wraps every state change + its audit_event insert in a single `BEGIN … COMMIT`. If either fails, both roll back. +- **Append-only enforcement:** The `prevent_audit_mutation` trigger makes UPDATE/DELETE physically impossible on `audit_events`. +- **Entity history query:** The composite index `(entity_type, entity_id, event_timestamp)` supports `SELECT * FROM audit_events WHERE entity_type = :type AND entity_id = :id ORDER BY event_timestamp`. + +### 6. Concurrency Control + +**Reads (advisory locks):** When a run reads document_versions, it acquires a shared advisory lock on the document_id (using `pg_advisory_xact_lock_shared`). Multiple runs can hold the shared lock concurrently — no blocking for reads. + +**Writes (OCC):** The `version` column on `runs`, `run_steps`, and `approval_queue` implements optimistic concurrency control. Every UPDATE includes `WHERE version = :expected_version` and sets `version = version + 1`. Zero affected rows = conflict → application retries or reports error. + +--- + +## Index Strategy + +| Query Pattern | Table | Index | Notes | +|--------------|-------|-------|-------| +| Resume: find last completed step | `run_steps` | `(run_id, status, step_order DESC)` | Covers the resume query directly | +| Reviewer dashboard: pending claims by priority | `approval_queue` | `(status, priority, queued_at)` | Supports `WHERE status='pending' ORDER BY priority, queued_at` | +| Entity audit history | `audit_events` | `(entity_type, entity_id, event_timestamp)` | Full history of any entity | +| Time-range audit queries | `audit_events` | `(event_timestamp)` | "What happened in the last hour?" | +| Deduplication check | `document_versions` | `(document_id, content_hash)` UNIQUE | Catches duplicates at insert time | +| Claims by run | `claims` | `(run_id)` | "Show all claims from run X" | +| Claims by document version | `claims` | `(document_version_id)` | "Show all claims for this version" | +| Source locations for a claim | `source_locations` | `(claim_id)` | Join path from claim to sources | +| All FK columns | all tables | individual btree indexes | Required by requirement 7.4 | + +--- + +## Migration Structure + +Migrations live in `migrations/` at the project root. Each file is a self-contained SQL transaction. + +| File | Purpose | +|------|---------| +| `001_extensions_and_migrations.sql` | Enable `pgcrypto` and `vector` extensions; create `schema_migrations` table | +| `002_documents_and_versions.sql` | Create `documents`, `document_versions`, immutability trigger | +| `003_runs_and_steps.sql` | Create `runs`, `run_steps`, status machine trigger | +| `004_claims_and_sources.sql` | Create `claims`, `source_locations` | +| `005_approval_queue.sql` | Create `approval_queue`, `decisions`, decision guard trigger | +| `006_audit_events.sql` | Create `audit_events`, append-only trigger | +| `007_indexes.sql` | Create all non-primary-key indexes | + +Each migration: +1. Uses `IF NOT EXISTS` for idempotency +2. Wraps in `BEGIN … COMMIT` +3. Records itself in `schema_migrations` on success + + + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: MIME Type Validation + +*For any* string value used as `mime_type` when inserting into the `documents` table, the insertion SHALL succeed if and only if the value is one of `'application/pdf'`, `'application/vnd.openxmlformats-officedocument.wordprocessingml.document'`, or `'text/plain'`. + +**Validates: Requirements 1.1, 1.6** + +### Property 2: Content-Hash Deduplication + +*For any* document and any content hash, inserting a second `document_versions` row with the same `(document_id, content_hash)` pair SHALL raise a unique constraint violation, while inserting with a different content hash or different document_id SHALL succeed. + +**Validates: Requirements 1.3, 7.5** + +### Property 3: Document Version Immutability + +*For any* existing `document_versions` row, any UPDATE that modifies `content_hash` or `storage_ref` SHALL be rejected by the immutability trigger, regardless of the new values provided. + +**Validates: Requirements 1.4** + +### Property 4: Composite Unique Constraints + +*For any* `(document_id, version_number)` pair in `document_versions` or `(run_id, step_order)` pair in `run_steps`, inserting a duplicate combination SHALL raise a unique constraint violation. + +**Validates: Requirements 1.5, 3.5** + +### Property 5: Source Location Offset Ordering + +*For any* pair of integers `(start_offset, end_offset)`, insertion into `source_locations` SHALL succeed only when `start_offset < end_offset`, and SHALL be rejected otherwise. + +**Validates: Requirements 2.5** + +### Property 6: Resume Query Correctness + +*For any* run with an arbitrary sequence of steps in various statuses, querying `run_steps WHERE run_id = :id AND status = 'completed' ORDER BY step_order DESC LIMIT 1` SHALL return the step with the highest `step_order` among all completed steps for that run, or no rows if none are completed. + +**Validates: Requirements 3.3** + +### Property 7: Run Status State Machine + +*For any* run with a current status, a status UPDATE SHALL succeed only if the transition is valid according to the state machine (pending → running, running → completed|failed|cancelled), and SHALL be rejected for all other transitions including backward transitions. + +**Validates: Requirements 3.4** + +### Property 8: Optimistic Concurrency Control + +*For any* row in `runs`, `run_steps`, or `approval_queue` with a current `version` value V, an UPDATE with `WHERE version = V` SHALL affect exactly one row and increment version to V+1, while an UPDATE with `WHERE version != V` SHALL affect zero rows. + +**Validates: Requirements 4.5, 4.6** + +### Property 9: One Decision Per Queue Entry + +*For any* `approval_queue` entry that already has a recorded decision, attempting to insert a second `decisions` row referencing the same `approval_queue_id` SHALL raise a unique constraint violation. + +**Validates: Requirements 5.5** + +### Property 10: Decision Guard on Pending Status + +*For any* `approval_queue` entry whose status is NOT `'pending'` (i.e., `'approved'` or `'rejected'`), attempting to insert a `decisions` row referencing that entry SHALL be rejected by the guard trigger. + +**Validates: Requirements 5.7** + +### Property 11: Audit Events Append-Only + +*For any* existing row in the `audit_events` table, any UPDATE or DELETE operation SHALL be rejected by the immutability trigger unconditionally. + +**Validates: Requirements 6.2** + +### Property 12: Audit Entity History Ordering + +*For any* entity identified by `(entity_type, entity_id)`, querying `audit_events` filtered by that pair and ordered by `event_timestamp` SHALL return all historical state changes for that entity in chronological order. + +**Validates: Requirements 6.4** + +### Property 13: ON DELETE RESTRICT Enforcement + +*For any* parent record in `documents`, `document_versions`, `claims`, `runs`, or `approval_queue` that has at least one child row referencing it, a DELETE on the parent SHALL be rejected with a foreign key violation. + +**Validates: Requirements 7.2** + +### Property 14: Migration Idempotency + +*For any* migration file, applying it to a database where it has already been applied SHALL produce no errors and no schema changes, due to `IF NOT EXISTS` guards on all DDL statements. + +**Validates: Requirements 8.4** + +--- + +## Error Handling + +### Database-Level Error Handling + +| Error Condition | Mechanism | Behavior | +|----------------|-----------|----------| +| Invalid MIME type | CHECK constraint | Raises `check_violation` (23514) | +| Duplicate content hash | UNIQUE constraint | Raises `unique_violation` (23505) | +| Immutable field update | BEFORE UPDATE trigger | Raises custom exception | +| Invalid status transition | BEFORE UPDATE trigger | Raises custom exception with transition details | +| FK parent deletion | ON DELETE RESTRICT | Raises `foreign_key_violation` (23503) | +| Audit mutation attempt | BEFORE UPDATE/DELETE trigger | Raises custom exception | +| Decision on non-pending entry | BEFORE INSERT trigger | Raises custom exception | +| OCC version mismatch | Application WHERE clause | Zero rows affected (no DB error) | +| NULL FK column | NOT NULL constraint | Raises `not_null_violation` (23502) | + +### Application-Level Error Handling + +The SQLAlchemy repository layer translates database exceptions into domain exceptions: + +```python +class ConflictError(Exception): + """OCC version mismatch — retry or report.""" + +class ImmutableFieldError(Exception): + """Attempted modification of immutable data.""" + +class InvalidTransitionError(Exception): + """Status transition violates state machine.""" + +class DuplicateContentError(Exception): + """Content hash already exists for this document.""" +``` + +**Transaction rollback guarantee:** All operations are wrapped in SQLAlchemy sessions with explicit `begin()` / `commit()` boundaries. Any unhandled exception triggers automatic rollback, ensuring audit_events and state changes remain atomically consistent. + +**Retry strategy for OCC conflicts:** +1. Read current row (fresh version) +2. Re-apply business logic +3. Attempt update with new version +4. Max 3 retries before raising `ConflictError` + +--- + +## Testing Strategy + +### Unit Tests (Example-Based) + +- **Schema smoke tests:** Verify all tables, columns, constraints, and indexes exist with correct types +- **Single-scenario tests:** Insert/read/update flows for each table +- **Edge cases:** NULL handling, boundary values (confidence = 0.0, 1.0), maximum-length strings +- **Error paths:** Verify correct exceptions for each constraint violation type +- **State machine specific transitions:** Each valid and invalid transition pair + +### Property-Based Tests (Hypothesis) + +**Library:** [Hypothesis](https://hypothesis.readthedocs.io/) for Python +**Configuration:** Minimum 100 examples per property test +**Tag format:** `# Feature: core-postgres-schema, Property {N}: {title}` + +Each correctness property (1–14) maps to a single Hypothesis test that generates random valid/invalid inputs and asserts the property holds universally. Key generators: + +- **MIME types:** `st.sampled_from(valid_mimes) | st.text()` for valid/invalid +- **Content hashes:** `st.text(alphabet='0123456789abcdef', min_size=64, max_size=64)` +- **Status transitions:** `st.tuples(st.sampled_from(statuses), st.sampled_from(statuses))` +- **Offset pairs:** `st.tuples(st.integers(min_value=0), st.integers(min_value=0))` +- **Step sequences:** `st.lists(st.tuples(st.integers(min_value=1), st.sampled_from(step_statuses)))` + +### Integration Tests + +- **Concurrent advisory locks:** Two sessions acquiring shared locks on the same document +- **Transaction atomicity:** Audit event failure causes full rollback +- **Migration runner:** Apply/re-apply migrations, verify schema state +- **End-to-end flow:** Document → Version → Run → Steps → Claims → Queue → Decision → Audit trail + +### Test Infrastructure + +- **Database:** Dedicated test database (`docdb_test`) created in Docker Compose +- **Isolation:** Each test gets a fresh transaction that rolls back after assertion (no cleanup needed) +- **Fixtures:** `pytest` fixtures providing pre-populated documents, runs, claims for relationship tests diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/requirements.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/requirements.md new file mode 100644 index 000000000..43ddd53a9 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/requirements.md @@ -0,0 +1,141 @@ +# Requirements Document + +## Introduction + +This feature defines the core PostgreSQL schema for the agentic document-intelligence system. The schema must support the complete lifecycle of document processing: ingesting source documents, extracting claims/facts with precise source attribution, routing claims through a human-approval queue, tracking run/session state for resumability, and maintaining a full audit trail of every state change. The database runs on pgvector/pgvector:pg16 (PostgreSQL 16 with the vector extension) inside the existing Docker Compose stack, accessed via SQLAlchemy from the FastAPI service. + +## Glossary + +- **Schema**: The set of PostgreSQL tables, columns, indexes, constraints, and relationships that constitute the persistent data layer. +- **Document**: A source file (PDF, DOCX, or plain text) ingested into the system for analysis. +- **Document_Version**: An immutable snapshot of a Document at a specific point in time, identified by a content hash. +- **Claim**: A discrete factual assertion extracted from a Document_Version by an agent, such as "APR is 24%" or "Processing fee is 500 PHP." +- **Source_Location**: The precise position within a Document_Version from which a Claim was extracted, specified by page/section and character span or clause reference. +- **Run**: A single end-to-end execution of the document-processing pipeline, orchestrated by LangGraph, consisting of ordered Steps. +- **Step**: An individually checkpointed unit of work within a Run, representing one discrete operation (e.g., ingest, extract, classify, review). +- **Approval_Queue**: The set of Claims awaiting human review (approve or reject) before promotion to the verified-claims state. +- **Decision**: A recorded human judgment (approve or reject) on a specific Claim, including who decided and when. +- **Audit_Event**: An immutable, append-only record of a state change to any tracked entity, capturing what changed, when, by whom, and the causal source. +- **Concurrent_Runs**: Two or more Runs executing simultaneously that may reference or modify overlapping Documents. + +--- + +## Requirements + +### Requirement 1: Document and Version Storage + +**User Story:** As a compliance analyst, I want every ingested document stored with full version history, so that I can always trace which version of a document produced a given claim. + +#### Acceptance Criteria + +1. THE Schema SHALL include a `documents` table with columns for a unique identifier, original filename (maximum 255 characters), MIME type, ingestion timestamp, and metadata (JSONB). THE Schema SHALL enforce a CHECK constraint on MIME type restricting values to 'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', and 'text/plain'. +2. THE Schema SHALL include a `document_versions` table with columns for a unique identifier, a foreign key to `documents`, a content hash (SHA-256, stored as a 64-character hex string), a storage reference (URI or path to the stored content, maximum 1024 characters), version number (integer starting at 1 and incrementing sequentially per document), and creation timestamp. +3. WHEN a Document is ingested with content identical to an existing Document_Version (same content hash), THE Schema SHALL enforce a UNIQUE constraint on the content hash column that prevents inserting a duplicate row, allowing the system to reference the existing Document_Version instead. +4. THE Schema SHALL enforce that each Document_Version is immutable — no UPDATE or DELETE operations are permitted on content hash or storage reference columns after insertion. +5. THE Schema SHALL enforce that the version number for each Document_Version is unique per document via a UNIQUE constraint on (document_id, version_number), and that version numbers are positive integers (CHECK constraint: version_number >= 1). +6. IF a document is ingested with a MIME type not in the allowed set, THEN THE Schema SHALL reject the insertion with a constraint violation. + +--- + +### Requirement 2: Claim Extraction with Source Attribution + +**User Story:** As an auditor, I want every extracted claim linked to the exact location in the source document, so that I can verify any claim by navigating directly to its origin. + +#### Acceptance Criteria + +1. THE Schema SHALL include a `claims` table with columns for a unique identifier, a foreign key to `document_versions`, extracted text (maximum 10,000 characters), claim type/category, confidence score (numeric between 0.0 and 1.0 inclusive, enforced via CHECK constraint), extraction timestamp, and the Run identifier that produced the Claim. +2. THE Schema SHALL include a `source_locations` table with columns for a unique identifier, a foreign key to `claims`, a foreign key to `document_versions`, page number (nullable for unstructured text, CHECK constraint page_number >= 1 when not null), section identifier (nullable), start character offset (non-negative integer), end character offset (non-negative integer), and clause reference (nullable). +3. WHEN a Claim is inserted, THE Schema SHALL require at least one corresponding Source_Location row linking the Claim to a position in a Document_Version. +4. THE Schema SHALL support a single Claim being attributed to multiple Source_Locations (e.g., a claim derived from cross-referencing two clauses). +5. THE `source_locations` table SHALL enforce that start character offset is less than end character offset via a CHECK constraint. + +--- + +### Requirement 3: Run and Step Tracking for Resumability + +**User Story:** As a system operator, I want each pipeline run tracked at the step level with persistent state, so that a killed or failed run can resume from the last completed step without reprocessing. + +#### Acceptance Criteria + +1. THE Schema SHALL include a `runs` table with columns for a unique identifier, status (pending, running, completed, failed, cancelled), start timestamp, end timestamp (nullable), configuration snapshot (JSONB), and initiator identifier. +2. THE Schema SHALL include a `run_steps` table with columns for a unique identifier, a foreign key to `runs`, step name (maximum 128 characters), step order (integer, CHECK constraint enforcing step_order >= 1), status (pending, running, completed, failed, skipped), start timestamp, end timestamp (nullable), input state reference (JSONB), output state reference (JSONB), error details (nullable text), and a retry count (integer, default 0, CHECK constraint enforcing retry_count between 0 and 10 inclusive). +3. WHEN a Run is resumed after interruption, THE Schema SHALL support identifying the last completed Step for that Run via an index on (run_id, status, step_order) that enables ordering by step_order descending and filtering by status = 'completed'. +4. THE Schema SHALL enforce that a Run's status transitions follow the valid state machine: pending → running → (completed | failed | cancelled), with no backward transitions, enforced via a CHECK constraint or trigger that prevents status from moving to a prior state in the sequence. +5. THE `run_steps` table SHALL enforce a unique constraint on (run_id, step_order) to prevent duplicate step entries within a single Run. +6. WHILE a Step has status `running`, THE Schema SHALL record the start timestamp and leave end timestamp NULL until the Step reaches a terminal status (completed, failed, or skipped). +7. IF a Run is resumed and a Step has status `running` from a prior interrupted execution, THEN THE Schema SHALL allow that Step's status to be updated to `failed` with error details indicating interruption, so that the Run can proceed from the next Step. + +--- + +### Requirement 4: Concurrent Run Isolation + +**User Story:** As a system operator, I want the schema to support concurrent runs touching the same documents without data corruption, so that parallel processing is safe and predictable. + +#### Acceptance Criteria + +1. THE Schema SHALL use advisory locks or row-level locking strategies that allow two Runs to read the same Document_Version concurrently without blocking. +2. THE Schema SHALL associate every Claim with the specific Run that produced it via a NOT NULL foreign key (`run_id`) on the `claims` table, ensuring that Claims from one Run are never attributed to another Run. +3. THE Schema SHALL isolate Run state (the `runs` and `run_steps` tables) such that a failure in one Run does not alter the step records of another Run — each Run's rows are scoped exclusively by its own `run_id` primary key. +4. WHEN two concurrent Runs extract Claims from the same Document_Version, THE Schema SHALL store both sets of Claims independently, each linked to its originating Run. +5. THE Schema SHALL include an integer `version` column (default 1) on the `runs`, `run_steps`, and `approval_queue` tables to support optimistic concurrency control; any UPDATE to these rows SHALL increment the version and include a WHERE clause matching the expected prior version. +6. IF an optimistic concurrency update fails (zero rows affected because the version did not match), THEN the application layer SHALL treat this as a conflict requiring retry or error reporting. + +--- + +### Requirement 5: Pending-Approval Queue + +**User Story:** As a compliance reviewer, I want a queue of claims awaiting my decision, so that I can approve or reject each one with a recorded justification. + +#### Acceptance Criteria + +1. THE Schema SHALL include an `approval_queue` table with columns for a unique identifier, a foreign key to `claims`, a status (pending, approved, rejected), assigned reviewer (nullable), queued timestamp, and priority level (integer from 1 to 5, where 1 is highest priority). +2. WHEN a Claim is promoted to the Approval_Queue, THE Schema SHALL set the queue entry status to `pending` and record the queued timestamp. +3. THE Schema SHALL include a `decisions` table with columns for a unique identifier, a foreign key to `approval_queue`, decision value (approved or rejected), reviewer identifier, decision timestamp, and justification text (minimum 1 character, maximum 2000 characters, enforced via CHECK constraint). +4. WHEN a Decision is recorded, THE Schema SHALL update the corresponding `approval_queue` entry status to match the decision value (approved or rejected). +5. THE Schema SHALL enforce that each `approval_queue` entry receives at most one final Decision (unique constraint on approval_queue foreign key in `decisions`). +6. IF a Claim is re-queued after rejection (e.g., after re-extraction), THEN THE Schema SHALL create a new `approval_queue` entry rather than modifying the existing one, preserving the historical decision. +7. IF a Decision insert is attempted against an `approval_queue` entry whose status is not `pending`, THEN THE Schema SHALL reject the insert via a CHECK or trigger constraint, ensuring decisions are only recorded on pending entries. + +--- + +### Requirement 6: Audit Trail + +**User Story:** As a compliance officer, I want a complete, immutable audit trail that records every state change across the system, so that I can answer "what changed, when, and because of which source" at any historical point. + +#### Acceptance Criteria + +1. THE Schema SHALL include an `audit_events` table with columns for a unique identifier, event timestamp, entity type (one of: document, document_version, claim, source_location, run, run_step, approval_queue, decision), entity identifier, action (created, updated, status_changed, deleted), actor identifier (user or system/run), previous state (JSONB, nullable for `created` actions), new state (JSONB), and source reference (nullable — the Run, Decision, or Document_Version that caused the change). +2. THE `audit_events` table SHALL be append-only — no UPDATE or DELETE operations are permitted on audit rows. +3. WHEN any tracked entity (document, document_version, claim, source_location, run, run_step, approval_queue, or decision) changes state through creation, status transition, or field update, THE Schema SHALL require an Audit_Event row to be inserted within the same database transaction as the triggering change. +4. THE Schema SHALL support querying the full state history of any entity by filtering `audit_events` on entity type and entity identifier, ordered by event timestamp. +5. IF a state change is initiated by a Run or a Document_Version ingestion, THEN THE Schema SHALL record the originating Run identifier or Document_Version identifier in the source reference column; IF a state change is initiated by a human Decision, THEN THE Schema SHALL record the Decision identifier in the source reference column; IF no causal source is identifiable (e.g., administrative corrections), THEN THE Schema SHALL permit the source reference to remain NULL. +6. THE Schema SHALL define an index on `audit_events` covering the event timestamp column to support time-range queries, and a composite index on (entity_type, entity_id, event_timestamp) to support entity-history queries. +7. IF the Audit_Event insertion fails within a transaction, THEN THE Schema SHALL cause the entire transaction (including the triggering state change) to roll back, ensuring no state change can occur without a corresponding audit record. + +--- + +### Requirement 7: Referential Integrity and Constraints + +**User Story:** As a developer, I want the schema to enforce referential integrity at the database level, so that orphaned or inconsistent records are structurally impossible. + +#### Acceptance Criteria + +1. THE Schema SHALL define foreign key constraints between `document_versions` and `documents`, between `claims` and `document_versions`, between `claims` and `runs`, between `source_locations` and `claims`, between `source_locations` and `document_versions`, between `approval_queue` and `claims`, between `decisions` and `approval_queue`, and between `run_steps` and `runs`, with all foreign key columns defined as NOT NULL. +2. THE Schema SHALL use ON DELETE RESTRICT for all foreign keys referencing `documents`, `document_versions`, `claims`, `runs`, and `approval_queue` to prevent deletion of any parent record that is still referenced by child rows. +3. THE Schema SHALL use UUID as the primary key type for all tables to support distributed ID generation without coordination. +4. THE Schema SHALL define indexes on all foreign key columns and on the following filter columns: status columns in `runs`, `run_steps`, `approval_queue`, and `claims`; `event_timestamp` and `entity_type` columns in `audit_events`; and `queued_timestamp` in `approval_queue`. +5. THE Schema SHALL define a UNIQUE constraint on the `content_hash` column of `document_versions` within the scope of a single `document_id` to prevent duplicate version rows for the same document content. + +--- + +### Requirement 8: Schema Migration Support + +**User Story:** As a developer, I want the schema to be defined as versioned migration scripts, so that any environment (local, CI, production) can reproducibly reach the current schema state. + +#### Acceptance Criteria + +1. THE Schema SHALL be expressed as one or more SQL migration files, each prefixed with a sequential integer identifier (e.g., 001, 002, 003), that can be applied in ascending order from an empty database to reach the current schema state. +2. WHEN a migration file is applied, THE Schema SHALL execute it within a single database transaction so that it either fully succeeds or fully rolls back, leaving no partial schema changes. +3. THE Schema SHALL include a `schema_migrations` table that records which migrations have been applied, storing at minimum the migration identifier, the filename, and the timestamp of application. +4. THE migration files SHALL use `IF NOT EXISTS` guards on all CREATE TABLE, CREATE INDEX, and CREATE EXTENSION statements to remain idempotent for object-creation operations. +5. IF a migration is requested whose sequential identifier is lower than or equal to the highest already-applied migration recorded in `schema_migrations`, THEN THE Schema SHALL skip that migration without re-applying it. diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/tasks.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/tasks.md new file mode 100644 index 000000000..41bcdb7a1 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/core-postgres-schema/tasks.md @@ -0,0 +1,257 @@ +# Implementation Plan: Core PostgreSQL Schema + +## Overview + +Implement the core PostgreSQL schema for the agentic document-intelligence system as sequential SQL migration files (001–007), followed by SQLAlchemy declarative models, and property-based tests validating the 14 correctness properties. Migrations define tables, triggers, constraints, and indexes. Models provide the ORM layer. Tests use Hypothesis to verify schema invariants against a live Postgres instance. + +## Tasks + +- [x] 1. Project setup and dependencies + - [x] 1.1 Add test dependencies to pyproject.toml + - Add `hypothesis` to the `[dependency-groups] dev` section + - Verify `pytest`, `sqlalchemy`, and `psycopg2-binary` are already present + - _Requirements: 8.1_ + + - [x] 1.2 Create migration directory structure and runner utility + - Create `migrations/` directory at project root + - Create `src/models/` package with `__init__.py` + - Create `tests/test_schema/` package with `__init__.py` + - Create a migration runner script `migrations/run_migrations.py` that applies SQL files in order, checking `schema_migrations` to skip already-applied ones + - _Requirements: 8.1, 8.2, 8.3, 8.5_ + +- [x] 2. Migration 001: Extensions and schema_migrations table + - [x] 2.1 Create `migrations/001_extensions_and_migrations.sql` + - Enable `pgcrypto` extension with `IF NOT EXISTS` + - Enable `vector` extension with `IF NOT EXISTS` + - Create `schema_migrations` table (id INTEGER PK, filename VARCHAR(255) NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()) + - Wrap in BEGIN/COMMIT transaction + - Use `IF NOT EXISTS` on all DDL + - _Requirements: 8.1, 8.2, 8.3, 8.4_ + +- [x] 3. Migration 002: Documents and document_versions + - [x] 3.1 Create `migrations/002_documents_and_versions.sql` + - Create `documents` table with UUID PK (gen_random_uuid()), filename VARCHAR(255) NOT NULL, mime_type VARCHAR(100) NOT NULL with CHECK constraint for allowed types, ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), metadata JSONB NOT NULL DEFAULT '{}' + - Create `document_versions` table with UUID PK, document_id FK to documents ON DELETE RESTRICT, content_hash CHAR(64) NOT NULL, storage_ref VARCHAR(1024) NOT NULL, version_number INTEGER NOT NULL CHECK >= 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + - Add UNIQUE constraint on (document_id, content_hash) and (document_id, version_number) + - Create `prevent_version_mutation()` trigger function and attach as BEFORE UPDATE trigger on document_versions + - Wrap in BEGIN/COMMIT with IF NOT EXISTS guards + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 7.1, 7.2, 7.3_ + +- [x] 4. Migration 003: Runs and run_steps + - [x] 4.1 Create `migrations/003_runs_and_steps.sql` + - Create `runs` table with UUID PK, status VARCHAR(20) NOT NULL DEFAULT 'pending' with CHECK for valid statuses, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ended_at TIMESTAMPTZ NULL, config_snapshot JSONB NOT NULL DEFAULT '{}', initiator VARCHAR(128) NOT NULL, version INTEGER NOT NULL DEFAULT 1 + - Create `run_steps` table with UUID PK, run_id FK to runs ON DELETE RESTRICT, step_name VARCHAR(128) NOT NULL, step_order INTEGER NOT NULL CHECK >= 1, status VARCHAR(20) NOT NULL DEFAULT 'pending' with CHECK, started_at TIMESTAMPTZ NULL, ended_at TIMESTAMPTZ NULL, input_state JSONB NOT NULL DEFAULT '{}', output_state JSONB NOT NULL DEFAULT '{}', error_details TEXT NULL, retry_count INTEGER NOT NULL DEFAULT 0 CHECK between 0 and 10, version INTEGER NOT NULL DEFAULT 1 + - Add UNIQUE constraint on (run_id, step_order) + - Create `enforce_run_status_transition()` trigger function implementing the state machine and version auto-increment + - Attach as BEFORE UPDATE trigger on runs + - Wrap in BEGIN/COMMIT with IF NOT EXISTS guards + - _Requirements: 3.1, 3.2, 3.4, 3.5, 4.5, 7.1, 7.2, 7.3_ + +- [x] 5. Migration 004: Claims and source_locations + - [x] 5.1 Create `migrations/004_claims_and_sources.sql` + - Create `claims` table with UUID PK, document_version_id FK to document_versions ON DELETE RESTRICT, run_id FK to runs ON DELETE RESTRICT, extracted_text VARCHAR(10000) NOT NULL, claim_type VARCHAR(128) NOT NULL, confidence NUMERIC(4,3) NOT NULL CHECK between 0.0 and 1.0, extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + - Create `source_locations` table with UUID PK, claim_id FK to claims ON DELETE RESTRICT, document_version_id FK to document_versions ON DELETE RESTRICT, page_number INTEGER NULL CHECK >= 1 when not null, section_id VARCHAR(256) NULL, start_offset INTEGER NOT NULL CHECK >= 0, end_offset INTEGER NOT NULL CHECK >= 0, clause_ref VARCHAR(512) NULL + - Add CHECK constraint: start_offset < end_offset + - Wrap in BEGIN/COMMIT with IF NOT EXISTS guards + - _Requirements: 2.1, 2.2, 2.4, 2.5, 4.2, 7.1, 7.2, 7.3_ + +- [x] 6. Migration 005: Approval queue and decisions + - [x] 6.1 Create `migrations/005_approval_queue.sql` + - Create `approval_queue` table with UUID PK, claim_id FK to claims ON DELETE RESTRICT, status VARCHAR(20) NOT NULL DEFAULT 'pending' with CHECK for pending/approved/rejected, assigned_reviewer VARCHAR(128) NULL, queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), priority INTEGER NOT NULL DEFAULT 3 CHECK between 1 and 5, version INTEGER NOT NULL DEFAULT 1 + - Create `decisions` table with UUID PK, approval_queue_id FK to approval_queue ON DELETE RESTRICT with UNIQUE constraint, decision_value VARCHAR(10) NOT NULL CHECK for approved/rejected, reviewer_id VARCHAR(128) NOT NULL, decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), justification VARCHAR(2000) NOT NULL CHECK length >= 1 + - Create `guard_decision_on_pending()` trigger function + - Attach as BEFORE INSERT trigger on decisions + - Wrap in BEGIN/COMMIT with IF NOT EXISTS guards + - _Requirements: 5.1, 5.2, 5.3, 5.5, 5.7, 7.1, 7.2, 7.3_ + +- [x] 7. Migration 006: Audit events + - [x] 7.1 Create `migrations/006_audit_events.sql` + - Create `audit_events` table with UUID PK, event_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), entity_type VARCHAR(50) NOT NULL with CHECK for valid types, entity_id UUID NOT NULL, action VARCHAR(20) NOT NULL with CHECK for valid actions, actor_id VARCHAR(128) NOT NULL, previous_state JSONB NULL, new_state JSONB NOT NULL, source_ref VARCHAR(128) NULL + - Create `prevent_audit_mutation()` trigger function that raises exception on UPDATE or DELETE + - Attach as BEFORE UPDATE OR DELETE trigger on audit_events + - Wrap in BEGIN/COMMIT with IF NOT EXISTS guards + - _Requirements: 6.1, 6.2, 6.4, 7.3_ + +- [x] 8. Migration 007: Indexes + - [x] 8.1 Create `migrations/007_indexes.sql` + - Create all non-primary-key indexes using IF NOT EXISTS: + - `idx_documents_mime_type` on documents(mime_type) + - `idx_documents_ingested_at` on documents(ingested_at) + - `idx_dv_document_id` on document_versions(document_id) + - `idx_dv_content_hash` on document_versions(content_hash) + - `idx_runs_status` on runs(status) + - `idx_runs_started_at` on runs(started_at) + - `idx_run_steps_run_status_order` on run_steps(run_id, status, step_order DESC) + - `idx_run_steps_run_id` on run_steps(run_id) + - `idx_run_steps_status` on run_steps(status) + - `idx_claims_document_version_id` on claims(document_version_id) + - `idx_claims_run_id` on claims(run_id) + - `idx_claims_type` on claims(claim_type) + - `idx_claims_confidence` on claims(confidence) + - `idx_source_locations_claim_id` on source_locations(claim_id) + - `idx_source_locations_dv_id` on source_locations(document_version_id) + - `idx_aq_claim_id` on approval_queue(claim_id) + - `idx_aq_status` on approval_queue(status) + - `idx_aq_status_priority_queued` on approval_queue(status, priority, queued_at) + - `idx_aq_queued_at` on approval_queue(queued_at) + - `idx_decisions_reviewer` on decisions(reviewer_id) + - `idx_audit_event_timestamp` on audit_events(event_timestamp) + - `idx_audit_entity_history` on audit_events(entity_type, entity_id, event_timestamp) + - `idx_audit_actor` on audit_events(actor_id) + - Wrap in BEGIN/COMMIT + - _Requirements: 3.3, 6.6, 7.4_ + +- [x] 9. Checkpoint - Verify migrations apply cleanly + - Ensure all migrations apply successfully against a fresh database, ask the user if questions arise. + +- [x] 10. SQLAlchemy base and model definitions + - [x] 10.1 Create `src/models/base.py` + - Define `Base` class extending `DeclarativeBase` + - Define `TimestampMixin` with `created_at` mapped column + - Import common types (uuid, datetime, Decimal) + - _Requirements: 7.3_ + + - [x] 10.2 Create `src/models/documents.py` + - Define `Document` model mapping to `documents` table + - Define `DocumentVersion` model mapping to `document_versions` table + - Include relationship definitions (Document has many DocumentVersions) + - Map all columns with correct types and constraints + - _Requirements: 1.1, 1.2_ + + - [x] 10.3 Create `src/models/runs.py` + - Define `Run` model mapping to `runs` table with OCC version column + - Define `RunStep` model mapping to `run_steps` table with OCC version column + - Include relationship (Run has many RunSteps) + - _Requirements: 3.1, 3.2, 4.5_ + + - [x] 10.4 Create `src/models/claims.py` + - Define `Claim` model mapping to `claims` table + - Define `SourceLocation` model mapping to `source_locations` table + - Include relationships (Claim has many SourceLocations, linked to DocumentVersion and Run) + - _Requirements: 2.1, 2.2, 4.2_ + + - [x] 10.5 Create `src/models/approval.py` + - Define `ApprovalQueue` model mapping to `approval_queue` table with OCC version column + - Define `Decision` model mapping to `decisions` table + - Include relationships (ApprovalQueue has one Decision, linked to Claim) + - _Requirements: 5.1, 5.3, 5.5_ + + - [x] 10.6 Create `src/models/audit.py` + - Define `AuditEvent` model mapping to `audit_events` table + - Map all columns including entity_type, entity_id, action, actor_id, previous_state, new_state, source_ref + - _Requirements: 6.1_ + + - [x] 10.7 Create `src/models/schema_migrations.py` + - Define `SchemaMigration` model mapping to `schema_migrations` table + - _Requirements: 8.3_ + + - [x] 10.8 Update `src/models/__init__.py` with all model exports + - Import and re-export all models from the package + - _Requirements: 7.1_ + +- [x] 11. Checkpoint - Verify models load without errors + - Ensure all models import cleanly and match the migration schema, ask the user if questions arise. + +- [x] 12. Property-based tests for schema correctness + - [x] 12.1 Create test fixtures in `tests/test_schema/conftest.py` + - Set up pytest fixtures for database connection, session, and transaction rollback isolation + - Create fixtures for pre-populated documents, document_versions, runs, claims for relationship tests + - Configure Hypothesis settings (min 100 examples) + - _Requirements: 8.2_ + + - [x] 12.2 Create `tests/test_schema/test_documents.py` with property tests for documents and versions + - **Property 1: MIME Type Validation** — generate random strings and verify only allowed MIME types succeed + - **Validates: Requirements 1.1, 1.6** + - **Property 2: Content-Hash Deduplication** — generate pairs of (document_id, content_hash) and verify duplicate pairs raise unique violation + - **Validates: Requirements 1.3, 7.5** + - **Property 3: Document Version Immutability** — generate update attempts on content_hash/storage_ref and verify trigger rejection + - **Validates: Requirements 1.4** + - **Property 4: Composite Unique Constraints** — generate duplicate (document_id, version_number) pairs and verify rejection + - **Validates: Requirements 1.5, 3.5** + - _Requirements: 1.1, 1.3, 1.4, 1.5, 1.6_ + + - [x] 12.3 Write property test for source location offset ordering + - **Property 5: Source Location Offset Ordering** + - Generate random (start_offset, end_offset) pairs and verify insertion succeeds only when start < end + - **Validates: Requirements 2.5** + + - [x] 12.4 Write property test for resume query correctness + - **Property 6: Resume Query Correctness** + - Generate arbitrary sequences of run_steps with mixed statuses and verify the resume query returns the highest completed step_order + - **Validates: Requirements 3.3** + + - [x] 12.5 Write property test for run status state machine + - **Property 7: Run Status State Machine** + - Generate all pairs of (current_status, new_status) and verify only valid transitions succeed + - **Validates: Requirements 3.4** + + - [x] 12.6 Write property test for optimistic concurrency control + - **Property 8: Optimistic Concurrency Control** + - Generate version values and verify updates with matching version succeed (affecting 1 row) while mismatches affect 0 rows + - **Validates: Requirements 4.5, 4.6** + + - [x] 12.7 Write property test for one decision per queue entry + - **Property 9: One Decision Per Queue Entry** + - Generate scenarios with existing decisions and verify second insert raises unique violation + - **Validates: Requirements 5.5** + + - [x] 12.8 Write property test for decision guard on pending status + - **Property 10: Decision Guard on Pending Status** + - Generate approval_queue entries with non-pending statuses and verify decision insert is rejected by trigger + - **Validates: Requirements 5.7** + + - [x] 12.9 Write property test for audit events append-only + - **Property 11: Audit Events Append-Only** + - Generate existing audit_event rows and verify any UPDATE or DELETE raises trigger exception + - **Validates: Requirements 6.2** + + - [x] 12.10 Write property test for audit entity history ordering + - **Property 12: Audit Entity History Ordering** + - Generate multiple audit_events for the same entity and verify chronological ordering query returns correct sequence + - **Validates: Requirements 6.4** + + - [x] 12.11 Write property test for ON DELETE RESTRICT enforcement + - **Property 13: ON DELETE RESTRICT Enforcement** + - Generate parent-child relationship scenarios and verify deleting a parent with children raises foreign key violation + - **Validates: Requirements 7.2** + + - [x] 12.12 Write property test for migration idempotency + - **Property 14: Migration Idempotency** + - Apply each migration file twice and verify no errors or schema changes on the second application + - **Validates: Requirements 8.4** + +- [x] 13. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties using Hypothesis (min 100 examples per property) +- Migrations are idempotent and transactional — safe to re-run +- Tests use transaction rollback isolation so no cleanup is needed between test runs +- The migration runner checks `schema_migrations` to skip already-applied files (Requirement 8.5) +- OCC version columns are application-enforced via WHERE clauses, not auto-managed by SQLAlchemy + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "1.2"] }, + { "id": 1, "tasks": ["2.1"] }, + { "id": 2, "tasks": ["3.1"] }, + { "id": 3, "tasks": ["4.1"] }, + { "id": 4, "tasks": ["5.1"] }, + { "id": 5, "tasks": ["6.1"] }, + { "id": 6, "tasks": ["7.1"] }, + { "id": 7, "tasks": ["8.1"] }, + { "id": 8, "tasks": ["10.1"] }, + { "id": 9, "tasks": ["10.2", "10.3", "10.4", "10.5", "10.6", "10.7"] }, + { "id": 10, "tasks": ["10.8"] }, + { "id": 11, "tasks": ["12.1"] }, + { "id": 12, "tasks": ["12.2", "12.3", "12.4", "12.5", "12.6", "12.7", "12.8", "12.9", "12.10", "12.11", "12.12"] } + ] +} +``` diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/.config.kiro b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/.config.kiro new file mode 100644 index 000000000..9af62637d --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/.config.kiro @@ -0,0 +1 @@ +{"specId": "6973c673-4a24-4c29-899c-acb901b08c5b", "workflowType": "requirements-first", "specType": "feature"} diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/design.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/design.md new file mode 100644 index 000000000..9fbb5453b --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/design.md @@ -0,0 +1,848 @@ +# Design Document: LangGraph Pipeline Design + +## Overview + +This design specifies the LangGraph `StateGraph` topology for the three-stage document-intelligence pipeline: **Understand → Examine → Stay-Alive**. The pipeline processes synthetic microfinance and consumer loan agreements through ingestion, compliance analysis, and human-in-the-loop monitoring. + +**Key principles:** + +- **Conditional routing** — Every node-to-node transition is a conditional edge whose routing function inspects the returned State. No unconditional edges exist. +- **Per-node checkpointing** — After each successful node, the full State is persisted to the `run_steps.output_state` JSONB column within the same transaction that marks the step complete. +- **Kill-and-resume** — A crashed or killed run restores from the last checkpoint and resumes at the next conditional edge, never re-executing completed work. +- **Bounded retry** — Transient failures retry in-place up to a configurable max (default 3), then escalate. +- **Human-in-the-loop** — Low-confidence or non-compliant results park in the approval queue; the graph suspends via `interrupt()` and resumes only after all decisions arrive. + +The pipeline is architecture-only — this spec defines graph shape, node contracts, routing logic, checkpoint boundaries, and state schema. Implementation code is produced by subsequent specs. + +--- + +## Architecture + +### Pipeline Graph Topology + +```mermaid +graph TD + START((START)) --> ingest + + subgraph Understand_Stage["Understand Stage"] + ingest["ingest"] + extract_text["extract_text"] + chunk["chunk"] + embed["embed"] + end + + subgraph Examine_Stage["Examine Stage"] + extract_claims["extract_claims"] + match_rules["match_rules"] + score_confidence["score_confidence"] + end + + subgraph Stay_Alive_Stage["Stay-Alive Stage"] + route_to_queue["route_to_queue"] + human_review["human_review (interrupt)"] + finalize["finalize"] + end + + %% Understand Stage routing + ingest -->|completed| extract_text + ingest -->|permanent error| route_to_queue + + extract_text -->|completed/skipped| chunk + extract_text -->|transient, retries < max| extract_text + extract_text -->|transient, retries >= max| route_to_queue + extract_text -->|permanent error| route_to_queue + + chunk -->|completed/skipped| embed + + embed -->|completed| extract_claims + embed -->|transient, retries < max| embed + embed -->|transient, retries >= max| route_to_queue + + %% Examine Stage routing + extract_claims -->|completed| match_rules + extract_claims -->|transient, retries < max| extract_claims + extract_claims -->|transient, retries >= max| route_to_queue + + match_rules -->|completed| score_confidence + match_rules -->|transient, retries < max| match_rules + match_rules -->|transient, retries >= max| route_to_queue + match_rules -->|permanent error| route_to_queue + + score_confidence -->|completed| route_to_queue + score_confidence -->|transient, retries < max| score_confidence + score_confidence -->|transient, retries >= max| route_to_queue + + %% Stay-Alive Stage routing + route_to_queue -->|escalate bucket non-empty| human_review + route_to_queue -->|escalate bucket empty| finalize + + human_review -->|all decisions received| finalize + + finalize -->|completed| END((END)) + finalize -->|transient, retries < max| finalize + finalize -->|transient, retries >= max| FAILED((FAILED)) +``` + +### Stage Boundaries and Cross-Stage Edges + +| Transition | From Stage | To Stage | Mechanism | +|-----------|-----------|----------|-----------| +| `embed` → `extract_claims` | Understand | Examine | Conditional edge (same as intra-stage) | +| `score_confidence` → `route_to_queue` | Examine | Stay-Alive | Conditional edge (same as intra-stage) | +| Any node → `route_to_queue` (escalation) | Any | Stay-Alive | Conditional edge on error/escalation | + +Cross-stage transitions use the identical `add_conditional_edges` API as intra-stage transitions — there is no special handling for stage boundaries. + +--- + +## State Schema + + +### Full TypedDict Definition + +```python +from typing import TypedDict, Literal, Optional +from uuid import UUID + +class ChunkEntry(TypedDict): + index: int + text: str + start_offset: int + end_offset: int + +class ExtractionResult(TypedDict): + claim_id: str + claim_text: str + chunk_index: int + start_offset: int + end_offset: int + confidence: float # 0.0–1.0 + +class ComplianceVerdict(TypedDict): + claim_id: str + verdict: Literal["compliant", "non_compliant", "indeterminate"] + confidence: float # 0.0–1.0 + needs_human_review: bool + rule_id: Optional[str] + evidence_refs: list[str] + +class Decision(TypedDict): + claim_id: str + approval_queue_id: str + decision_value: Literal["approved", "rejected"] + reviewer_id: str + justification: str + +class SkippedNodeEntry(TypedDict): + node_name: str + reason: str + +class PipelineConfig(TypedDict): + max_retries: int # default: 3 + chunk_max_size: int # default: 1000 characters + chunk_overlap: int # default: 200 characters + confidence_threshold: float # default: 0.7 + review_timeout_hours: int # default: 72 + reminder_interval_hours: int # default: 24 + poll_interval_seconds: int # default: 30 + extract_text_timeout_seconds: int # default: 60 + min_chunk_threshold: int # default: 200 characters + +class QueueBuckets(TypedDict): + auto_approve: list[str] # claim IDs + escalate: list[str] # claim IDs + auto_reject: list[str] # claim IDs + +class PipelineState(TypedDict): + # Identity + run_id: str # UUID as string for JSON serialization + document_id: str # UUID as string + document_version_id: str # UUID as string + + # Execution tracking + current_node: str + node_status: Literal["completed", "skipped", "error"] + error_type: Optional[Literal["transient", "permanent"]] + error_detail: Optional[str] + retries: dict[str, int] # node_name → retry count + skipped_nodes: list[SkippedNodeEntry] + completed_nodes: list[str] # ordered list of completed/skipped node names + + # Configuration + config: PipelineConfig + + # Understand Stage outputs + raw_content: Optional[bytes] # serialized as base64 string in JSONB + mime_type: Optional[str] + extracted_text: Optional[str] + chunks: list[ChunkEntry] + embeddings_stored: bool + + # Examine Stage outputs + claims: list[ExtractionResult] + verdicts: list[ComplianceVerdict] + + # Stay-Alive Stage outputs + queue_buckets: QueueBuckets + decisions: list[Decision] +``` + +### Key Design Notes + +- **JSON serialization**: All values are JSON-native or explicitly converted. `raw_content` (bytes) is stored as a base64-encoded string in the JSONB checkpoint. UUIDs are stored as strings. +- **`retries` dict**: Initialized to `{}` at run start. Only nodes that have been retried appear as keys. +- **`completed_nodes`**: Append-only within a run. Used by resume logic to determine the last completed step and the next routing edge to evaluate. +- **`config`**: Frozen at run creation time from the `runs.config_snapshot` column. Nodes read thresholds from `state["config"]`, never from external sources mid-run. + +--- + +## Components and Interfaces + +### Node Contract Summary + +Every node is an `async` function with the signature: + +```python +async def node_name(state: PipelineState) -> PipelineState: + ... +``` + +Each node MUST: +1. Read only its declared input keys from State +2. Write only its declared output keys to State +3. Set `current_node` to its own name +4. Set `node_status` to one of: "completed", "skipped", "error" +5. If "error": set `error_type` and `error_detail` +6. If "completed" or "skipped": append its name to `completed_nodes` + +--- + +### Node: `ingest` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Understand | +| **Input keys** | `document_id`, `document_version_id`, `config` | +| **Output keys** | `raw_content`, `mime_type`, `current_node`, `node_status`, `error_type`, `error_detail`, `completed_nodes` | +| **Terminal states** | `completed` (document loaded), `error/permanent` (unreadable, invalid MIME, zero bytes) | +| **Checkpoint** | Written after `completed`. Not written on error. | +| **Skip support** | No — ingest is always required | + +**Behavior**: Reads document bytes from the `storage_ref` in `document_versions`. Validates MIME type against allowed set (`application/pdf`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `text/plain`). Populates `raw_content` and `mime_type`. + +--- + +### Node: `extract_text` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Understand | +| **Input keys** | `raw_content`, `mime_type`, `config` | +| **Output keys** | `extracted_text`, `current_node`, `node_status`, `error_type`, `error_detail`, `skipped_nodes`, `completed_nodes` | +| **Terminal states** | `completed` (text extracted), `skipped` (input already text/plain), `error/permanent` (corrupted file), `error/transient` (timeout, memory) | +| **Checkpoint** | Written after `completed` or `skipped`. Not written on error. | +| **Skip support** | Yes — when `mime_type == "text/plain"` | + +**Behavior**: Converts `raw_content` to plain text based on MIME type. If already `text/plain`, sets `node_status = "skipped"` with `skip_reason = "input_already_text"` and copies raw content to `extracted_text`. + +--- + +### Node: `chunk` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Understand | +| **Input keys** | `extracted_text`, `config` | +| **Output keys** | `chunks`, `current_node`, `node_status`, `error_type`, `error_detail`, `skipped_nodes`, `completed_nodes` | +| **Terminal states** | `completed` (chunks produced), `skipped` (text below min threshold), `error/permanent` (extracted_text is null/empty) | +| **Checkpoint** | Written after `completed` or `skipped`. Not written on error. | +| **Skip support** | Yes — when `len(extracted_text) < config["min_chunk_threshold"]` | + +**Behavior**: Splits `extracted_text` into segments of `config["chunk_max_size"]` with `config["chunk_overlap"]` overlap. Each chunk includes index, text, start_offset, end_offset. If text is below minimum threshold, produces a single-element chunk list and marks as skipped. + +--- + +### Node: `embed` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Understand | +| **Input keys** | `chunks`, `document_version_id`, `config` | +| **Output keys** | `embeddings_stored`, `current_node`, `node_status`, `error_type`, `error_detail`, `completed_nodes` | +| **Terminal states** | `completed` (all embeddings stored), `error/transient` (embedding API failure) | +| **Checkpoint** | Written after `completed`. Not written on error. | +| **Skip support** | No | + +**Behavior**: Generates vector embeddings for all chunks in a single atomic batch. Stores vectors in the pgvector-enabled table linked to `document_version_id`. If any chunk fails, discards all partial results, sets `embeddings_stored = false`. + +--- + +### Node: `extract_claims` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Examine | +| **Input keys** | `chunks`, `config` | +| **Output keys** | `claims`, `current_node`, `node_status`, `error_type`, `error_detail`, `completed_nodes` | +| **Terminal states** | `completed` (claims extracted, may be empty list), `error/transient` (LLM API failure) | +| **Checkpoint** | Written after `completed`. Not written on error. | +| **Skip support** | No — always attempts extraction. Empty result is `completed`, not skipped. | + +**Behavior**: Processes each chunk through LLM to identify factual assertions. Produces `ExtractionResult` entries with claim text, source location (chunk_index, offsets where start < end), and preliminary confidence score. + +--- + +### Node: `match_rules` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Examine | +| **Input keys** | `claims`, `config` | +| **Output keys** | `verdicts`, `current_node`, `node_status`, `error_type`, `error_detail`, `completed_nodes` | +| **Terminal states** | `completed` (verdicts produced, may be empty), `error/transient` (LLM API failure), `error/permanent` (rule config missing/unparseable) | +| **Checkpoint** | Written after `completed`. Not written on error. | +| **Skip support** | No | + +**Behavior**: Compares each claim against compliance rules from config. Produces a `ComplianceVerdict` per claim (compliant, non_compliant, indeterminate). Empty claims list → empty verdicts list (completed, not error). + +--- + +### Node: `score_confidence` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Examine | +| **Input keys** | `verdicts`, `chunks`, `config` | +| **Output keys** | `verdicts` (updated), `current_node`, `node_status`, `error_type`, `error_detail`, `completed_nodes` | +| **Terminal states** | `completed` (scores refined), `error/transient` (LLM API failure), `error/permanent` (rule config issue) | +| **Checkpoint** | Written after `completed`. Not written on error. | +| **Skip support** | No | + +**Behavior**: Refines confidence scores based on verdict certainty and source location completeness. Flags claims with `confidence < config["confidence_threshold"]` as `needs_human_review = true`. + +--- + +### Node: `route_to_queue` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Stay-Alive | +| **Input keys** | `verdicts`, `claims`, `config`, `run_id` | +| **Output keys** | `queue_buckets`, `current_node`, `node_status`, `error_type`, `error_detail`, `completed_nodes` | +| **Terminal states** | `completed` (buckets partitioned), `error/transient` (resource failure during partitioning) | +| **Checkpoint** | Written after `completed`. Not written on error. | +| **Skip support** | No | + +**Behavior**: Partitions claims into three buckets: +- **auto_approve**: compliant + confidence ≥ threshold +- **escalate**: non-compliant OR confidence < threshold OR `needs_human_review` OR from permanent-error escalation +- **auto_reject**: duplicate claims within the same Run (matching text + source location) + +Priority ordering for claims matching multiple conditions: escalate > auto_reject > auto_approve. + +--- + +### Node: `human_review` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Stay-Alive | +| **Input keys** | `queue_buckets`, `run_id`, `config` | +| **Output keys** | `decisions`, `current_node`, `node_status`, `error_type`, `error_detail`, `completed_nodes` | +| **Terminal states** | `completed` (all decisions received) | +| **Checkpoint** | Written on interrupt (pre-suspend). Written again after resume + completion. | +| **Skip support** | Conditionally skipped if `queue_buckets["escalate"]` is empty (routing function bypasses this node). | + +**Behavior**: Inserts escalated claims into `approval_queue` with status "pending". Invokes `interrupt()` to suspend execution. On resume, reads all decisions from the `decisions` table, populates State, returns completed. + +--- + +### Node: `finalize` + +| Aspect | Detail | +|--------|--------| +| **Stage** | Stay-Alive | +| **Input keys** | `queue_buckets`, `decisions`, `run_id`, `config` | +| **Output keys** | `current_node`, `node_status`, `error_type`, `error_detail`, `completed_nodes` | +| **Terminal states** | `completed` (run closed), `error/transient` (DB transaction failure) | +| **Checkpoint** | Written after `completed`. Not written on error. | +| **Skip support** | No — finalize always executes | + +**Behavior**: Within a single DB transaction: +1. Writes auto_approve + human-approved claims to `claims` table with status "verified" +2. Writes auto_reject + human-rejected claims to `claims` table with status "rejected" +3. Updates `runs` row to status "completed" with `end_timestamp` +4. Inserts audit events for each claim status change and the run completion +5. Sets `node_status = "completed"` + +--- + +## Routing Functions + +### Implementation Approach + +Each conditional edge is registered via LangGraph's `add_conditional_edges(source_node, routing_fn, path_map)` API. The routing function is a pure function that inspects the State and returns a string literal matching one of the `path_map` keys. + +```python +def make_routing_fn(node_name: str, config: PipelineConfig) -> Callable[[PipelineState], str]: + """Factory that creates a routing function for a given source node.""" + max_retries = config["max_retries"] + + def route(state: PipelineState) -> str: + status = state["node_status"] + error_type = state.get("error_type") + retries = state["retries"].get(node_name, 0) + + if status == "completed": + return "next" # or node-specific logic + elif status == "skipped": + return "next" + elif status == "error": + if error_type == "transient" and retries < max_retries: + return "retry" + else: + return "escalate" + else: + # Unhandled state — treat as permanent error + return "escalate" + + return route +``` + +### Per-Node Routing Details + +| Source Node | Routing Logic | Path Map | +|-------------|--------------|----------| +| `ingest` | No retry (permanent errors only), no skip | `{"next": "extract_text", "escalate": "route_to_queue"}` | +| `extract_text` | Supports retry + skip + escalate | `{"next": "chunk", "retry": "extract_text", "escalate": "route_to_queue"}` | +| `chunk` | No retry (permanent errors only), supports skip | `{"next": "embed"}` — chunk errors are permanent, route via fallback | +| `embed` | Supports retry + escalate, no skip | `{"next": "extract_claims", "retry": "embed", "escalate": "route_to_queue"}` | +| `extract_claims` | Supports retry + escalate, no skip | `{"next": "match_rules", "retry": "extract_claims", "escalate": "route_to_queue"}` | +| `match_rules` | Supports retry + escalate (both transient & permanent) | `{"next": "score_confidence", "retry": "match_rules", "escalate": "route_to_queue"}` | +| `score_confidence` | Supports retry + escalate | `{"next": "route_to_queue", "retry": "score_confidence", "escalate": "route_to_queue"}` | +| `route_to_queue` | Custom: checks escalate bucket emptiness | `{"escalate": "human_review", "next": "finalize"}` | +| `human_review` | Always proceeds to finalize | `{"finalize": "finalize"}` | +| `finalize` | Supports retry, terminal escalate marks run failed | `{"end": END, "retry": "finalize", "escalate": "FAILED"}` | + +### Retry State Mutation + +When the routing function returns "retry": +1. Increment `state["retries"][node_name]` by 1 +2. Restore all other State keys to the pre-node checkpoint values (the checkpoint written after the *previous* node) +3. Re-invoke the same node with the updated retries dict + +This is implemented via a LangGraph retry edge that reads the prior checkpoint's `output_state` and overlays only the `retries` key update. + +### Unhandled State Fallback + +Per Requirement 1.7, if a routing function receives State with `node_status` not matching any defined condition (e.g., an unexpected value), it: +1. Sets `node_status = "error"`, `error_type = "permanent"`, `error_detail = "unhandled routing state: {state}"` +2. Returns "escalate" to route to `route_to_queue` + +--- + +## Checkpoint Strategy + +### Write Timing + +``` +Node invoked + ├── run_steps row created (status: "running", started_at set) + ├── Node executes... + │ + ├── IF node_status == "completed" or "skipped": + │ └── Single transaction: + │ ├── Serialize State to JSONB + │ ├── Write to run_steps.output_state + │ ├── Update run_steps.status to "completed"/"skipped" + │ ├── Set run_steps.ended_at + │ └── COMMIT + │ + ├── IF node_status == "error": + │ └── No checkpoint written + │ └── run_steps row updated to status "failed" with error_details + │ + └── IF checkpoint write fails (transaction cannot commit): + └── Treat as transient error → retry routing logic +``` + +### Resume Flow + +``` +Resume triggered (API call or scheduler) + ├── Query: SELECT * FROM run_steps WHERE run_id = :id AND status IN ('completed', 'skipped') ORDER BY step_order DESC LIMIT 1 + ├── Load output_state JSONB as PipelineState + ├── Check for orphaned "running" rows → mark as "failed" with "interrupted" detail + ├── Determine next node from routing function applied to restored State + └── Begin execution from that node +``` + +### Retry Checkpoint Behavior + +During retries, NO new checkpoint is written: +- The pre-node checkpoint (from the previously completed node) remains the restore point +- Only the `retries` dict is updated in the in-memory State +- If the process is killed during a retry, resume returns to the same node with `retry_count` from the last checkpoint (which may be lower than the in-memory count) +- The `run_steps` row for the retrying node has its `retry_count` incremented and `started_at` reset on each attempt + +### Checkpoint Data Size + +The full State is serialized to JSONB. For large documents, the `raw_content` field (base64-encoded document bytes) could be substantial. Mitigation: +- `raw_content` is cleared from State after `extract_text` completes (the extracted text is the durable form) +- `chunks` text is preserved (needed by downstream nodes) but embeddings are stored externally in pgvector + +--- + +## Human Review Interrupt + +### Interrupt Mechanism + +```python +async def human_review(state: PipelineState) -> PipelineState: + escalated = state["queue_buckets"]["escalate"] + + # Insert claims into approval_queue + for claim_id in escalated: + insert_approval_queue_entry(claim_id, run_id=state["run_id"], status="pending") + + # Suspend graph execution — State is checkpointed automatically + interrupt() + + # --- Execution resumes here after external signal --- + + # Read all decisions for this run's escalated items + decisions = query_decisions_for_run(state["run_id"]) + state["decisions"] = decisions + state["node_status"] = "completed" + state["current_node"] = "human_review" + state["completed_nodes"].append("human_review") + return state +``` + +### Resume Trigger + +The graph does NOT poll internally. An external mechanism triggers resume: + +1. **Polling service** (separate process/worker): Queries the `decisions` table every `config["poll_interval_seconds"]` (default: 30s) +2. **Condition**: `COUNT(decisions) WHERE approval_queue.run_id = :run_id` equals `len(state["queue_buckets"]["escalate"])` +3. **Action**: Calls LangGraph's resume API with the `run_id` thread, causing execution to continue from the interrupt point + +### Timeout and Reminders + +- **Timeout**: `config["review_timeout_hours"]` (default: 72h) — no auto-resolution; items remain pending indefinitely +- **Reminders**: Every `config["reminder_interval_hours"]` (default: 24h), the polling service inserts an `audit_events` row with action `"reminder_sent"` for each unresolved `approval_queue` entry +- **No auto-resolve**: Human decision is always required; the system only reminds, never decides + +### Run Status During Interrupt + +| Entity | Status | Notes | +|--------|--------|-------| +| `runs` row | `"running"` | Run is active, waiting for human input | +| `run_steps` (human_review) | `"running"` | `started_at` set, `ended_at` NULL | +| `approval_queue` entries | `"pending"` | One per escalated claim | + +--- + +## Concurrency and Isolation + +### Integration with OCC Columns + +The pipeline integrates with the optimistic concurrency control (OCC) pattern defined in the core-postgres-schema: + +| Table | OCC Column | Pipeline Usage | +|-------|-----------|----------------| +| `runs` | `version` | Updated when run status changes (pending→running, running→completed). Each status transition increments version; concurrent attempts to update the same run fail gracefully. | +| `run_steps` | `version` | Updated on each step status change and retry_count increment. Prevents two processes from checkpointing the same step concurrently. | +| `approval_queue` | `version` | Updated when a reviewer picks up an item or a decision is recorded. Prevents double-assignment. | + +### Advisory Locks for Document Reads + +When the pipeline reads `document_versions` for a given `document_id`: +- Acquires `pg_advisory_xact_lock_shared(document_id::bigint)` — allows concurrent reads from other runs processing the same document +- Released automatically at transaction commit + +### Single-Writer per Run + +The pipeline enforces one-active-execution-per-run: +- At run start, acquire an exclusive advisory lock on `run_id` +- If the lock is already held, the resume attempt fails fast (another execution is already running) +- This prevents duplicate execution after network partitions where the polling service might trigger resume twice + +### Transaction Boundaries + +| Operation | Transaction Scope | +|-----------|------------------| +| Node checkpoint write | Single transaction: output_state + run_steps status + ended_at | +| Finalize | Single transaction: all claim inserts + run status + audit events | +| Human review insert | Single transaction: all approval_queue inserts (before interrupt) | +| Resume detection | Read-only query (no transaction required beyond default) | + +--- + +## Configuration + +All configurable values are frozen into `runs.config_snapshot` at run creation and read from `state["config"]` during execution. + +| Parameter | Key | Type | Default | Purpose | +|-----------|-----|------|---------|---------| +| Max retries | `max_retries` | int | 3 | Maximum retry attempts per node before escalation | +| Chunk max size | `chunk_max_size` | int | 1000 | Maximum characters per chunk | +| Chunk overlap | `chunk_overlap` | int | 200 | Overlapping characters between adjacent chunks | +| Confidence threshold | `confidence_threshold` | float | 0.7 | Below this, claims are escalated to human review | +| Review timeout | `review_timeout_hours` | int | 72 | Hours before reminder escalation (no auto-resolve) | +| Reminder interval | `reminder_interval_hours` | int | 24 | Hours between reminder audit events | +| Poll interval | `poll_interval_seconds` | int | 30 | Seconds between decision-completeness checks | +| Text extraction timeout | `extract_text_timeout_seconds` | int | 60 | Max seconds for document-to-text conversion | +| Min chunk threshold | `min_chunk_threshold` | int | 200 | Below this character count, chunking is skipped | + +--- + +## Data Models + +### Pipeline State as Data + +The `PipelineState` TypedDict (defined in the State Schema section above) is the single data model flowing through the graph. It is not a database entity — it's an in-memory accumulator serialized to JSONB at checkpoint boundaries. + +### Database Entity Integration + +The pipeline reads from and writes to the core-postgres-schema tables. The mapping between pipeline State keys and database entities: + +| State Key | Database Table | Relationship | +|-----------|---------------|-------------| +| `run_id` | `runs` | 1:1 — each pipeline execution is one run | +| `document_id` | `documents` | Read-only — pipeline does not create documents | +| `document_version_id` | `document_versions` | Read-only — pipeline reads storage_ref for ingest | +| `claims` → finalize | `claims` | Write — finalize persists verified/rejected claims | +| `queue_buckets["escalate"]` | `approval_queue` | Write — human_review inserts pending entries | +| `decisions` | `decisions` | Read — human_review reads after resume | +| checkpoint | `run_steps.output_state` | Write — full State serialized as JSONB | + +### Serialization Conventions + +| Python Type | JSONB Representation | Notes | +|-------------|---------------------|-------| +| `uuid.UUID` | `string` | Hex format without dashes: `str(uuid)` | +| `bytes` | `string` (base64) | `raw_content` only; cleared after extract_text | +| `datetime` | `string` (ISO 8601) | Timestamps in audit events | +| `float` | `number` | Confidence scores (0.0–1.0) | +| `None` | `null` | Nullable fields | +| `dict` | `object` | Nested structures (config, retries) | +| `list` | `array` | Claims, verdicts, chunks, etc. | + +### Config Snapshot Lifecycle + +``` +Run creation: + 1. Load default PipelineConfig values + 2. Override with user-provided config params + 3. Freeze as runs.config_snapshot (JSONB) + 4. Inject into initial PipelineState as state["config"] + +During execution: + - Nodes read from state["config"] only + - Config is immutable within a run + - Checkpoint includes config (restored on resume) +``` + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Stage Ordering Invariant + +*For any* nominal run (all nodes return `completed` or `skipped`), the `completed_nodes` list SHALL contain nodes in stage order: all Understand nodes before all Examine nodes, and all Examine nodes before all Stay-Alive nodes. + +**Validates: Requirements 1.1** + +### Property 2: Routing Determinism — Exactly One Match + +*For any* State produced by any source node, exactly one routing condition from that node's condition set SHALL evaluate to true — the conditions are mutually exclusive and collectively exhaustive. + +**Validates: Requirements 11.4, 1.7** + +### Property 3: Retry Routing Correctness + +*For any* node that returns `node_status = "error"` with `error_type = "transient"`, the routing decision SHALL be "retry" if `retries[node_name] < max_retries`, and "escalate" if `retries[node_name] >= max_retries`. + +**Validates: Requirements 2.1, 2.2** + +### Property 4: Unrecognized Error Type Escalation + +*For any* node that returns `node_status = "error"` with an `error_type` value that is neither `"transient"` nor `"permanent"` (including null or any other string), the routing function SHALL treat it as `"permanent"` and return "escalate". + +**Validates: Requirements 2.5, 1.7** + +### Property 5: Checkpoint If-And-Only-If Success + +*For any* node execution, a checkpoint (write to `run_steps.output_state`) SHALL be persisted if and only if `node_status` is `"completed"` or `"skipped"`. No checkpoint SHALL be written when `node_status` is `"error"` or during retry cycles. + +**Validates: Requirements 6.1, 6.4, 2.4, 6.6** + +### Property 6: State JSONB Round-Trip + +*For any* valid `PipelineState` instance, serializing to JSONB and deserializing back SHALL produce an equivalent State with no information loss. + +**Validates: Requirements 7.5, 6.2** + +### Property 7: Skip Routing and Metadata + +*For any* node that returns `node_status = "skipped"` with a `skip_reason` between 1 and 255 characters, the routing function SHALL return "next" and the State SHALL contain an entry in `skipped_nodes` with the node name and reason. If `skip_reason` is null, empty, or exceeds 255 characters, routing SHALL treat it as a permanent error. + +**Validates: Requirements 3.1, 3.4, 3.5** + +### Property 8: Claim Partitioning Priority + +*For any* set of claims with associated verdicts and confidence scores, the `route_to_queue` node SHALL assign each claim to exactly one bucket following priority: `escalate` (non-compliant OR confidence < threshold OR needs_human_review OR permanent-error origin) > `auto_reject` (duplicate within same run) > `auto_approve` (compliant + confidence ≥ threshold). Claims matching multiple conditions SHALL appear in the highest-priority bucket only. + +**Validates: Requirements 4.4, 4.1, 4.3** + +### Property 9: Escalate Bucket Determines Human Review Routing + +*For any* State returned by `route_to_queue` with `node_status = "completed"`, the routing decision SHALL be "escalate" (→ `human_review`) if `queue_buckets["escalate"]` is non-empty, and "next" (→ `finalize`) if it is empty. + +**Validates: Requirements 4.5, 4.6** + +### Property 10: Resume Restart Correctness + +*For any* run with an arbitrary sequence of `run_steps` rows in various statuses, the resume logic SHALL identify the row with the highest `step_order` where status is `"completed"` or `"skipped"`, restore its `output_state` as the current State, and begin execution from the conditional edge following that node — never re-executing the completed node. + +**Validates: Requirements 6.3, 6.8** + +### Property 11: Chunk Coverage + +*For any* non-empty `extracted_text` string and valid chunk parameters (max_size > overlap > 0, max_size > 0), the union of all chunk text ranges (using start_offset and end_offset) SHALL cover every character in the original text with no gaps. + +**Validates: Requirements 8.3** + +### Property 12: Extraction Offset Ordering + +*For any* `ExtractionResult` produced by `extract_claims`, the `start_offset` SHALL be strictly less than `end_offset`, and both SHALL be within the bounds of the source chunk's text length. + +**Validates: Requirements 9.1** + +### Property 13: Verdicts-Claims Length Parity + +*For any* non-empty `claims` list processed by `match_rules`, the resulting `verdicts` list SHALL have exactly the same number of entries as the `claims` list, with a one-to-one correspondence by `claim_id`. + +**Validates: Requirements 9.2** + +### Property 14: Confidence Flagging Threshold + +*For any* verdict produced by `score_confidence` with `confidence` below `config["confidence_threshold"]`, the `needs_human_review` field SHALL be `true`. For any verdict with `confidence` ≥ threshold, `needs_human_review` SHALL be `false` (unless independently flagged as non-compliant). + +**Validates: Requirements 9.3, 4.1** + +### Property 15: Post-Human-Review Always Finalizes + +*For any* State returned by the `human_review` node (with any mix of approved and rejected decisions), the routing function SHALL return "finalize" unconditionally. + +**Validates: Requirements 5.6** + +### Property 16: Node Completion Contract + +*For any* node that returns `node_status = "completed"` or `"skipped"`, the returned State SHALL have `current_node` set to that node's name AND the node's name appended to `completed_nodes`. Nodes returning `"error"` SHALL NOT be appended to `completed_nodes`. + +**Validates: Requirements 7.4** + +--- + +## Error Handling + +### Error Classification + +| Error Type | Meaning | Routing Action | Checkpoint | +|-----------|---------|---------------|------------| +| `transient` | Recoverable failure (timeout, rate-limit, network) | Retry (up to max), then escalate | Not written | +| `permanent` | Non-recoverable failure (corrupted file, missing config) | Escalate immediately | Not written | +| Unrecognized | Any other value or null | Treated as permanent → escalate | Not written | + +### Per-Node Error Mapping + +| Node | Transient Errors | Permanent Errors | +|------|-----------------|-----------------| +| `ingest` | — | Invalid MIME, zero bytes, unreadable file | +| `extract_text` | Timeout, memory exceeded | Corrupted file | +| `chunk` | — | Null/empty extracted_text | +| `embed` | Embedding API failure (any chunk) | — | +| `extract_claims` | LLM API timeout/rate-limit/connection | — | +| `match_rules` | LLM API timeout/rate-limit/connection | Rule config missing/unparseable | +| `score_confidence` | LLM API timeout/rate-limit/connection | Rule config issue | +| `route_to_queue` | Resource failure during partitioning | — | +| `human_review` | — | — (interrupt-based, no direct errors) | +| `finalize` | DB transaction failure | — | + +### Escalation Path + +All escalated items flow through `route_to_queue` → `human_review`: +1. **From retry exhaustion**: Node's failure context (node name, error_type, error_detail, retry_count) is attached to State +2. **From permanent errors**: Same context, retry_count may be 0 +3. **From low confidence**: Claims flagged with `needs_human_review = true` +4. **From non-compliance**: All non-compliant verdicts escalated regardless of confidence + +### Checkpoint Write Failure + +If the PostgreSQL transaction for checkpoint write cannot commit: +1. Node execution result is discarded (not persisted) +2. `error_type` is set to `"transient"` in State +3. Standard retry logic applies (increment retry count, re-execute node) +4. This ensures checkpoint infrastructure failures don't permanently block a run + +### Run Failure Terminal State + +When `finalize` exhausts retries (transient error at max retries): +- No further routing is possible within the graph +- The run is marked `"failed"` in the `runs` table +- Manual intervention is required to investigate and potentially re-trigger + +--- + +## Testing Strategy + +### Property-Based Tests (Hypothesis) + +**Library**: [Hypothesis](https://hypothesis.readthedocs.io/) for Python +**Configuration**: Minimum 100 examples per property test +**Tag format**: `# Feature: langgraph-pipeline-design, Property {N}: {title}` + +Each correctness property (1–16) maps to a single Hypothesis test. Key generators: + +| Generator | Strategy | Used By | +|-----------|----------|---------| +| `PipelineState` | Composite strategy building valid states | Properties 1, 2, 5, 6, 7, 16 | +| `node_status` | `st.sampled_from(["completed", "skipped", "error"])` | Properties 2, 3, 4, 5 | +| `error_type` | `st.sampled_from(["transient", "permanent"]) \| st.text()` | Properties 3, 4 | +| `retry_count` | `st.integers(min_value=0, max_value=10)` | Property 3 | +| `skip_reason` | `st.text(min_size=0, max_size=300)` | Property 7 | +| `claims_list` | `st.lists(extraction_result_strategy)` | Properties 8, 13 | +| `verdicts_list` | `st.lists(compliance_verdict_strategy)` | Properties 8, 14 | +| `confidence` | `st.floats(min_value=0.0, max_value=1.0)` | Properties 8, 14 | +| `text_string` | `st.text(min_size=1, max_size=10000)` | Property 11 | +| `chunk_params` | `st.tuples(st.integers(50, 5000), st.integers(10, 500))` with constraint | Property 11 | +| `run_steps_seq` | `st.lists(st.tuples(step_order, status))` | Property 10 | + +### Unit Tests (Example-Based) + +- **Graph topology**: Verify 10 nodes, 3 stages, entry/exit points, all edges conditional +- **Node-specific behavior**: Each node's happy path, skip path, error paths with concrete inputs +- **Routing functions**: Concrete state → expected routing decision for each table row +- **State initialization**: Verify default state has correct structure and defaults +- **Config loading**: Verify config_snapshot → PipelineConfig conversion + +### Integration Tests + +- **Checkpoint round-trip**: Write state to run_steps.output_state, read back, verify equality +- **Interrupt/resume cycle**: Full human_review interrupt → decision insertion → resume +- **OCC conflict handling**: Two concurrent checkpoint writes for same step → one fails +- **Resume after kill**: Kill during node execution, resume from last checkpoint +- **Advisory lock isolation**: Two runs for same document don't block each other +- **Finalize atomicity**: Verify all-or-nothing write behavior in finalize + +### Test Infrastructure + +- **Graph mock**: In-memory StateGraph with mock node functions for routing tests +- **Database**: `docdb_test` PostgreSQL instance via Docker Compose (same as core-postgres-schema tests) +- **LLM mocks**: Mock embedding and extraction APIs for deterministic property testing +- **Time control**: Freezegun or similar for testing timeout and reminder logic +- **Isolation**: Each test gets a fresh transaction (rollback after assertion) diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/requirements.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/requirements.md new file mode 100644 index 000000000..c2dab296e --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/requirements.md @@ -0,0 +1,229 @@ +# Requirements Document + +## Introduction + +This feature defines the LangGraph graph topology for the three-stage document-intelligence pipeline: **Understand**, **Examine**, and **Stay-Alive**. The pipeline processes synthetic microfinance and consumer loan agreements through ingestion, compliance analysis, and human-in-the-loop monitoring. Each stage is decomposed into discrete nodes with conditional routing edges (retry, skip, escalate) driven by node output — not sequential steps with labels. The design must support kill-and-resume semantics via per-node checkpointing against the existing PostgreSQL-backed state, ensuring no completed work is repeated after an interruption. + +This is an architecture-level specification. It defines the graph shape, node responsibilities, routing conditions, and checkpoint boundaries. No implementation code is produced by this spec; the output is a signed-off topology that subsequent specs will implement. + +## Glossary + +- **Graph**: The LangGraph `StateGraph` instance that defines the complete pipeline as a directed graph of nodes and conditional edges. +- **Node**: A discrete unit of work within the Graph, implemented as an async function that receives the current State and returns an updated State. Each Node performs exactly one responsibility. +- **Edge**: A connection between two Nodes. Edges can be unconditional (always traverse) or conditional (traverse based on a routing function applied to the source Node's output). +- **Conditional_Edge**: An edge whose traversal is determined by a routing function that inspects the State returned by the source Node and returns the name of the next Node to invoke. +- **State**: The typed dictionary (TypedDict) that flows through the Graph, accumulating results from each Node. The State is the single source of truth for the current run's progress. +- **Checkpoint**: A durable snapshot of the State persisted to PostgreSQL after a Node completes successfully. Enables kill-and-resume by restoring the last checkpointed State. +- **Understand_Stage**: The first pipeline stage responsible for document ingestion, text extraction, chunking, embedding, and structural comprehension. +- **Examine_Stage**: The second pipeline stage responsible for claim extraction, compliance rule matching, confidence scoring, and violation detection. +- **Stay_Alive_Stage**: The third pipeline stage responsible for human-in-the-loop review, approval queue management, escalation, and run finalization. +- **Routing_Decision**: The string literal returned by a routing function that determines which Node executes next. Valid values per edge are defined in the routing conditions table. +- **Retry**: A Routing_Decision indicating the current Node should re-execute (with incremented retry count) because of a transient or recoverable failure. +- **Skip**: A Routing_Decision indicating the current Node's work should be bypassed (output marked as skipped in State) and the pipeline should advance to the next logical Node. +- **Escalate**: A Routing_Decision indicating the current Node's failure or low-confidence output requires human intervention, routing to the human review queue. +- **Human_Queue**: The `approval_queue` table and associated `decisions` table from the core schema, used to park items requiring human judgment. +- **Run**: A single end-to-end execution of the Graph for one document, tracked in the `runs` table. +- **Run_Step**: A record in `run_steps` corresponding to a single Node execution within a Run, recording status, timing, input/output state references, and retry count. +- **Confidence_Score**: A float between 0.0 and 1.0 representing the model's certainty about an extraction or classification result. +- **Extraction_Result**: The structured output of a claim-extraction Node, containing claims, source locations, and per-claim confidence scores. +- **Compliance_Verdict**: The output of a rule-matching Node, classifying a claim as compliant, non-compliant, or indeterminate with supporting evidence references. + +--- + +## Requirements + +### Requirement 1: Graph Topology — Three Stages with Discrete Nodes + +**User Story:** As a system architect, I want the pipeline expressed as a LangGraph StateGraph with clearly separated stages and discrete nodes, so that each unit of work is independently testable, checkpointable, and replaceable. + +#### Acceptance Criteria + +1. THE Graph SHALL contain exactly three stages: Understand_Stage, Examine_Stage, and Stay_Alive_Stage, executed in that order for a nominal run (a run where every node returns `node_status` = "completed" or "skipped"). +2. THE Understand_Stage SHALL contain exactly four nodes in the following nominal order: `ingest` (load raw document bytes and metadata), `extract_text` (convert document to plain text), `chunk` (split text into segments of configurable maximum size with default 1000 characters and configurable overlap with default 200 characters), and `embed` (generate vector embeddings for each chunk and store in pgvector). +3. THE Examine_Stage SHALL contain exactly three nodes in the following nominal order: `extract_claims` (identify discrete factual assertions from chunks), `match_rules` (compare each claim against compliance rule definitions), and `score_confidence` (assign a Confidence_Score between 0.0 and 1.0 to each claim-rule pair and produce a Compliance_Verdict). +4. THE Stay_Alive_Stage SHALL contain exactly three nodes in the following nominal order: `route_to_queue` (partition results into auto-approve, escalate-to-human, and reject buckets), `human_review` (an interrupt node that pauses execution until human decisions arrive), and `finalize` (write final statuses, close the Run, and emit audit events). +5. THE Graph SHALL define a single entry point at the `ingest` node and a single terminal node at `finalize`, resulting in exactly 10 nodes total across all three stages. +6. THE Graph SHALL connect nodes within and across stages exclusively via Conditional_Edges, where each edge's routing function inspects the State returned by the source Node. Cross-stage transitions (from `embed` to `extract_claims`, and from `score_confidence` to `route_to_queue`) SHALL use the same Conditional_Edge mechanism as intra-stage transitions. +7. IF a routing function receives a State that does not match any defined routing condition for that edge, THEN THE Graph SHALL treat the outcome as a permanent error, set `node_status` = "error" with `error_type` = "permanent" and `error_detail` indicating the unhandled routing state, and route to `route_to_queue` for escalation. + +--- + +### Requirement 2: Conditional Routing — Retry Logic + +**User Story:** As a system operator, I want transient failures in any node to trigger automatic retries with a bounded count, so that intermittent issues (network timeouts, rate limits) resolve without human intervention. + +#### Acceptance Criteria + +1. WHEN a Node returns a State with `node_status` = "error" and `error_type` = "transient" and the current `retry_count` for that Node is less than the configured maximum (default: 3), THE Routing_Function SHALL return "retry" and the Graph SHALL re-invoke the same Node immediately (no delay) with `retry_count` incremented by 1 and the input State restored to the pre-node Checkpoint State (only the `retries` dictionary is updated). +2. WHEN a Node returns a State with `node_status` = "error" and `error_type` = "transient" and the current `retry_count` equals the configured maximum, THE Routing_Function SHALL return "escalate" and the Graph SHALL route to the `route_to_queue` node with the failure context attached to State, where failure context consists of the failing node name, `error_type`, `error_detail`, and the final `retry_count` for that node. +3. THE State SHALL include a `retries` dictionary keyed by node name, where each value is the current retry count for that node within the current Run. Each node's retry count SHALL be initialized to 0 at the start of the Run and SHALL only be incremented by the retry routing logic. +4. WHEN a retry is triggered, THE Graph SHALL NOT create a new Checkpoint before re-invoking the Node — the pre-node Checkpoint from the previous attempt remains the restore point, ensuring a kill during retry does not record partial progress. +5. IF a Node returns a State with `node_status` = "error" and `error_type` is neither "transient" nor "permanent", THEN THE Routing_Function SHALL treat the error as "permanent" and return "escalate" to route to the `route_to_queue` node. + +--- + +### Requirement 3: Conditional Routing — Skip Logic + +**User Story:** As a system architect, I want nodes to support a skip decision when their input is absent or inapplicable, so that the pipeline gracefully handles documents that lack certain structures without failing the entire run. + +#### Acceptance Criteria + +1. WHEN a Node returns a State with `node_status` = "skipped" and a `skip_reason` string (1 to 255 characters, non-empty), THE Routing_Function SHALL return "next" (advancing to the next node in sequence) and the State SHALL append an entry to `skipped_nodes` containing the node name and the skip reason. +2. WHEN the `extract_text` node receives a document already in plain-text format (MIME type `text/plain`), THE `extract_text` Node SHALL set `node_status` = "skipped" with `skip_reason` = "input_already_text", populate `extracted_text` in State with the raw content from `raw_content` decoded as UTF-8, and return the updated State to the routing function. +3. WHEN the `chunk` node receives `extracted_text` shorter than the minimum chunk threshold (configurable, default: 200 characters), THE `chunk` Node SHALL set `node_status` = "skipped" with `skip_reason` = "below_chunk_threshold" and populate `chunks` in State with a single-element list containing the entire `extracted_text` as one chunk. +4. THE Pipeline SHALL NOT halt or raise an error when a Node returns `node_status` = "skipped" — the run SHALL continue to the next node. A skipped Node MAY populate its designated output keys in State (as defined in criteria 2 and 3) in addition to the skip metadata; downstream nodes SHALL consume whichever State keys are present regardless of whether the producing node completed or was skipped. +5. IF a Node returns `node_status` = "skipped" with `skip_reason` that is null, empty, or exceeds 255 characters, THEN THE Routing_Function SHALL treat the result as an error with `error_type` = "permanent" and route to `route_to_queue` for escalation. + +--- + +### Requirement 4: Conditional Routing — Escalation to Human Queue + +**User Story:** As a compliance officer, I want low-confidence results and non-transient failures automatically escalated to the human review queue, so that uncertain outputs are never silently passed through as verified. + +#### Acceptance Criteria + +1. WHEN the `score_confidence` node produces a Compliance_Verdict with `confidence` below the escalation threshold (configurable, default: 0.7), THE Routing_Function SHALL return "escalate" and the Graph SHALL route to `route_to_queue` with the low-confidence claims marked with `needs_human_review` = true in the `verdicts` list within State. +2. WHEN any Node returns `node_status` = "error" and `error_type` = "permanent" (non-retryable failure), THE Routing_Function SHALL return "escalate" and the Graph SHALL route to `route_to_queue` with the State containing the failing node name in `current_node`, the `error_detail` string describing the failure reason, and the `error_type` value preserved for human triage. +3. WHEN the `match_rules` node produces a Compliance_Verdict of "non-compliant" for any claim, THE Routing_Function SHALL return "escalate" for those claims regardless of confidence score, routing them to human review. +4. THE `route_to_queue` Node SHALL partition all pending claims into three buckets based on the routing conditions: `auto_approve` (compliant + confidence >= threshold), `escalate` (non-compliant OR confidence < threshold OR `needs_human_review` = true OR originating from a permanent-error escalation), and `auto_reject` (claims matching any rule in the configured `auto_reject_rules` list, which SHALL include at minimum a duplicate-claim-within-same-Run rule). Claims matching multiple conditions SHALL be placed in the highest-priority bucket in the order: escalate > auto_reject > auto_approve. +5. WHEN the `route_to_queue` Node produces an `escalate` bucket with one or more items, THE Graph SHALL transition to the `human_review` interrupt node for those items. +6. WHEN the `route_to_queue` Node produces only `auto_approve` and `auto_reject` buckets with zero `escalate` items, THE Graph SHALL skip the `human_review` node and proceed directly to `finalize`. +7. IF the `route_to_queue` Node fails during partitioning (returns `node_status` = "error"), THEN THE Node SHALL set `error_type` = "transient" to trigger retry logic, because the partitioning operation is deterministic and a failure is due to a transient resource issue rather than invalid input. + +--- + +### Requirement 5: Conditional Routing — Human Review Interrupt + +**User Story:** As a compliance reviewer, I want the pipeline to pause and wait for my approve/reject decisions on escalated items, resuming processing only after all escalated items have been decided. + +#### Acceptance Criteria + +1. THE `human_review` Node SHALL be implemented as a LangGraph interrupt node that suspends Graph execution and persists the current State as a Checkpoint. +2. WHILE the `human_review` Node is in interrupted state, THE Run SHALL have status "running" and the corresponding Run_Step SHALL have status "running" with `start_timestamp` set and `end_timestamp` NULL. +3. WHEN the number of Decisions recorded in the `decisions` table for the current Run's escalated `approval_queue` entries equals the number of items in the State's `queue_buckets["escalate"]` list, THE Graph SHALL resume execution from the `human_review` Checkpoint. THE resume SHALL be triggered by an external signal (API call or polling mechanism querying the `decisions` table at a configurable interval, default: 30 seconds) rather than by internal graph execution. +4. WHEN the `human_review` Node resumes, THE Node SHALL read all Decisions from the `decisions` table for the current Run's escalated `approval_queue` entries, update the State's `decisions` list with the approved/rejected status for each claim, and return the updated State to the routing function. +5. IF any escalated item remains without a Decision for longer than a configurable timeout (default: 72 hours), THEN THE `human_review` Node SHALL insert an Audit_Event with action "reminder_sent" for each unresolved `approval_queue` entry, repeating at a configurable interval (default: every 24 hours) until a Decision is recorded, but SHALL NOT auto-resolve the item — it remains pending until a human acts. +6. THE Routing_Function after `human_review` SHALL return "finalize" to proceed to the `finalize` node regardless of whether individual items were approved or rejected. + +--- + +### Requirement 6: Checkpoint Strategy — Per-Node Durable Snapshots + +**User Story:** As a system operator, I want the full State checkpointed to PostgreSQL after every successfully completed node, so that a kill-and-resume restores exactly the last good state without repeating finished work. + +#### Acceptance Criteria + +1. THE Graph SHALL persist a Checkpoint to PostgreSQL after each Node completes with `node_status` = "completed" or "skipped", before the Conditional_Edge routing function for that Node executes. THE Checkpoint write and the `run_steps` row status update to "completed" or "skipped" SHALL occur within a single database transaction. +2. THE Checkpoint SHALL contain the complete State dictionary serialized as JSONB written to the `output_state` column of the corresponding `run_steps` row, along with the Run identifier (`run_id`), the node name (`step_name`), the `step_order` value as the monotonically increasing sequence number, and the `ended_at` timestamp. +3. WHEN a Run is resumed after a kill or crash, THE Graph SHALL query `run_steps` for the row with the highest `step_order` where `status` = "completed" or "skipped" for that `run_id`, load the `output_state` JSONB as the restored State, and begin execution from the Conditional_Edge following the checkpointed Node — the completed Node SHALL NOT re-execute. +4. WHEN a Node fails (returns `node_status` = "error"), THE Graph SHALL NOT persist a Checkpoint for that Node execution, preserving the prior Checkpoint as the restore point. +5. THE Graph SHALL create a `run_steps` row with status "running" and `started_at` set before invoking each Node, so that the row exists to receive the `output_state` Checkpoint upon successful completion. +6. WHILE a retry cycle is in progress for a given Node, THE Graph SHALL NOT overwrite the pre-node Checkpoint — only the final successful (or escalated) outcome persists a new Checkpoint. Each retry attempt SHALL reuse the same `run_steps` row by incrementing its `retry_count` and resetting `started_at`. +7. IF the Checkpoint write to PostgreSQL fails (transaction cannot commit), THEN THE Graph SHALL treat the Node as failed with `error_type` = "transient", triggering the retry routing logic for that Node and incrementing that Node's retry count in State. +8. IF a Run is resumed and a `run_steps` row exists with status "running" and `ended_at` NULL from a prior interrupted execution, THEN THE Graph SHALL update that row's status to "failed" with `error_details` indicating interruption before proceeding to re-execute from the last successfully checkpointed Node. + +--- + +### Requirement 7: State Schema — Typed Run State + +**User Story:** As a developer, I want the pipeline State to be a well-defined TypedDict that accumulates results across nodes, so that each node's inputs and outputs are explicit and type-checkable. + +#### Acceptance Criteria + +1. THE State SHALL include the following top-level keys: `run_id` (UUID), `document_id` (UUID), `document_version_id` (UUID), `current_node` (string), `node_status` (literal: "completed" | "skipped" | "error"), `error_type` (nullable literal: "transient" | "permanent"), `error_detail` (nullable string), `retries` (dict of node_name → int), `skipped_nodes` (list of dicts with node_name and reason), and `config` (dict of configurable thresholds and limits). +2. THE State SHALL include stage-specific accumulation keys: `raw_content` (nullable bytes), `extracted_text` (nullable string), `chunks` (list of chunk dicts), `embeddings_stored` (boolean), `claims` (list of Extraction_Result dicts), `verdicts` (list of Compliance_Verdict dicts), `queue_buckets` (dict with keys auto_approve, escalate, auto_reject each containing lists of claim IDs), and `decisions` (list of Decision dicts populated after human_review). +3. THE State SHALL include a `completed_nodes` list that records the ordered sequence of successfully completed node names, used by the resume logic to determine the restart point. +4. WHEN a Node completes, THE Node SHALL update `current_node` to its own name, set `node_status` appropriately, and append its name to `completed_nodes` (only for status "completed" or "skipped"). +5. THE State SHALL be serializable to JSONB without loss of information — all values SHALL be JSON-native types (strings, numbers, booleans, lists, dicts, null) or explicitly converted before checkpoint write. + +--- + +### Requirement 8: Node Responsibilities — Understand Stage + +**User Story:** As a developer, I want each Understand_Stage node to have a single, well-defined responsibility, so that failures are isolated and the stage can be tested node-by-node. + +#### Acceptance Criteria + +1. THE `ingest` Node SHALL read the document bytes from the storage reference in `document_versions`, validate the MIME type against the allowed set, populate `raw_content` in State, and set `node_status` = "completed". IF the document cannot be read, has an invalid MIME type, or the retrieved content is zero bytes, THEN the `ingest` Node SHALL set `node_status` = "error" with `error_type` = "permanent". +2. THE `extract_text` Node SHALL convert `raw_content` to plain text (PDF → text extraction, DOCX → text extraction, plain text → passthrough), populate `extracted_text` in State, and set `node_status` = "completed". IF conversion fails due to a corrupted file, THEN the Node SHALL set `node_status` = "error" with `error_type` = "permanent". IF conversion exceeds the configured timeout (default: 60 seconds) or exceeds available memory allocation, THEN the Node SHALL set `node_status` = "error" with `error_type` = "transient". +3. THE `chunk` Node SHALL split `extracted_text` into segments of configurable maximum size (default: 1000 characters) with configurable overlap (default: 200 characters), populate `chunks` in State where each chunk entry contains the chunk index, text content, start character offset, and end character offset, and set `node_status` = "completed". IF `extracted_text` is empty or null, THEN the `chunk` Node SHALL set `node_status` = "error" with `error_type` = "permanent". +4. THE `embed` Node SHALL generate vector embeddings for each chunk in a single atomic operation, store embedding vectors in the pgvector-enabled table linked to the `document_version_id`, set `embeddings_stored` = true in State, and set `node_status` = "completed". IF the embedding API call fails for any chunk in the batch, THEN the Node SHALL discard all partial results for that invocation, set `embeddings_stored` = false, and set `node_status` = "error" with `error_type` = "transient". + +--- + +### Requirement 9: Node Responsibilities — Examine Stage + +**User Story:** As a developer, I want each Examine_Stage node to have a single, well-defined responsibility for compliance analysis, so that extraction, rule-matching, and scoring are independently testable and replaceable. + +#### Acceptance Criteria + +1. THE `extract_claims` Node SHALL process each chunk, identify discrete factual assertions (e.g., "APR is 24%", "processing fee is 500 PHP"), create Extraction_Result entries with claim text, source location (chunk index, start character offset, end character offset where start < end), and a preliminary Confidence_Score (float between 0.0 and 1.0 inclusive), populate `claims` in State, and set `node_status` = "completed". IF no claims are found in any chunk, THEN the Node SHALL set `node_status` = "completed" with an empty `claims` list (not an error). +2. THE `match_rules` Node SHALL compare each extracted claim against the configured set of compliance rules referenced in `config` (e.g., maximum APR thresholds, required disclosure checks, prohibited fee structures), produce a Compliance_Verdict for each claim (compliant, non-compliant, or indeterminate), populate `verdicts` in State, and set `node_status` = "completed". IF the `claims` list in State is empty, THEN the Node SHALL set `node_status` = "completed" with an empty `verdicts` list (not an error). +3. THE `score_confidence` Node SHALL refine the Confidence_Score for each claim-verdict pair by evaluating the verdict classification certainty (how decisively the claim matched or failed a rule) and the source location completeness (whether offsets resolve to extractable text in the original chunk), update the `verdicts` list with final Confidence_Score values (float between 0.0 and 1.0 inclusive), and set `node_status` = "completed". Claims with `confidence` below the escalation threshold (as defined in `config`, default: 0.7) SHALL be flagged with `needs_human_review` = true. +4. IF the `extract_claims`, `match_rules`, or `score_confidence` Node encounters an LLM API failure (timeout, rate-limit, or connection error), THEN the Node SHALL set `node_status` = "error" with `error_type` = "transient" to trigger retry logic. +5. IF the `match_rules` or `score_confidence` Node encounters a non-recoverable failure (e.g., rule configuration missing or unparseable), THEN the Node SHALL set `node_status` = "error" with `error_type` = "permanent" to trigger escalation. + +--- + +### Requirement 10: Node Responsibilities — Stay-Alive Stage + +**User Story:** As a developer, I want the Stay-Alive stage to handle human-in-the-loop gating, run finalization, and ensure no claim reaches "verified" status without appropriate review, so that the pipeline's output is trustworthy and auditable. + +#### Acceptance Criteria + +1. THE `route_to_queue` Node SHALL read the `verdicts` list from State and partition claims into `auto_approve` (compliant + confidence >= threshold), `escalate` (non-compliant OR confidence < threshold OR flagged `needs_human_review`), and `auto_reject` (duplicate claims within the same Run, identified by matching extracted text and source location against previously processed claims in the current Run), populate `queue_buckets` in State, and set `node_status` = "completed". +2. THE `human_review` Node SHALL insert all `escalate`-bucket claims into the `approval_queue` table with status "pending", then invoke LangGraph's `interrupt()` to pause execution. WHEN resumed, the Node SHALL query the `decisions` table for all queued items, populate `decisions` in State, and set `node_status` = "completed". +3. THE `finalize` Node SHALL, within a single database transaction: write all `auto_approve`-bucket claims and all human-approved claims (from `decisions` with value "approved") to the `claims` table with status "verified"; write all `auto_reject`-bucket claims and all human-rejected claims (from `decisions` with value "rejected") to the `claims` table with status "rejected"; update the `runs` table row for the current Run to status "completed" with `end_timestamp` set to the current time; insert one audit event per claim status change (to "verified" or "rejected") and one audit event for the Run status change to "completed"; and set `node_status` = "completed". +4. IF the `finalize` Node's database transaction fails, THEN the Node SHALL set `node_status` = "error" with `error_type` = "transient" to allow retry (the transaction is atomic — partial writes are impossible). +5. WHEN the `finalize` Node completes successfully, THE Graph SHALL terminate the Run and return the final State as the pipeline output. +6. IF the `human_review` Node is skipped (escalate bucket is empty), THEN THE `finalize` Node SHALL treat all `auto_approve`-bucket claims as approved and all `auto_reject`-bucket claims as rejected without requiring entries in the `decisions` list. + +--- + +### Requirement 11: Routing Conditions Table + +**User Story:** As a system architect, I want all routing conditions documented in a single reference table, so that the graph's branching logic is unambiguous and reviewable at a glance. + +#### Acceptance Criteria + +1. THE Requirements Document SHALL include a routing conditions table with the following columns: Source Node, Condition (predicate on State), Routing_Decision (literal string), and Target Node. +2. THE routing conditions table SHALL define entries for every Conditional_Edge in the Graph — no edge SHALL exist without a corresponding table entry, and the total row count SHALL equal the total number of Conditional_Edges defined across the Graph topology. +3. EACH routing condition SHALL be expressed as a boolean expression over State dictionary keys using Python comparison and logical operators (e.g., `state["node_status"] == "error" and state["error_type"] == "transient" and state["retries"][node] < max_retries`). +4. FOR each Source Node, the set of routing conditions SHALL be mutually exclusive (no two conditions can evaluate to true for the same State) and collectively exhaustive (every possible State produced by that Node matches exactly one condition), ensuring deterministic routing with no unhandled State. +5. THE routing conditions table SHALL cover, for each source node, all terminal states that node can produce as defined by its node responsibility specification: at minimum `completed`, plus `skipped` if the node supports skip logic, plus `transient error within retry limit`, `transient error at retry limit`, and `permanent error` if the node can produce those error types. +6. IF a new Conditional_Edge is added to the Graph topology without a corresponding entry in the routing conditions table, THEN the Requirements Document SHALL be considered incomplete and SHALL fail review validation. + +--- + +## Routing Conditions Reference Table + +| Source Node | Condition | Decision | Target Node | +|---|---|---|---| +| `ingest` | `node_status == "completed"` | next | `extract_text` | +| `ingest` | `node_status == "error" and error_type == "permanent"` | escalate | `route_to_queue` | +| `extract_text` | `node_status == "completed"` | next | `chunk` | +| `extract_text` | `node_status == "skipped"` | next | `chunk` | +| `extract_text` | `node_status == "error" and error_type == "transient" and retries < max` | retry | `extract_text` | +| `extract_text` | `node_status == "error" and error_type == "transient" and retries >= max` | escalate | `route_to_queue` | +| `extract_text` | `node_status == "error" and error_type == "permanent"` | escalate | `route_to_queue` | +| `chunk` | `node_status == "completed"` | next | `embed` | +| `chunk` | `node_status == "skipped"` | next | `embed` | +| `embed` | `node_status == "completed"` | next | `extract_claims` | +| `embed` | `node_status == "error" and error_type == "transient" and retries < max` | retry | `embed` | +| `embed` | `node_status == "error" and error_type == "transient" and retries >= max` | escalate | `route_to_queue` | +| `extract_claims` | `node_status == "completed"` | next | `match_rules` | +| `extract_claims` | `node_status == "error" and error_type == "transient" and retries < max` | retry | `extract_claims` | +| `extract_claims` | `node_status == "error" and error_type == "transient" and retries >= max` | escalate | `route_to_queue` | +| `match_rules` | `node_status == "completed"` | next | `score_confidence` | +| `match_rules` | `node_status == "error" and error_type == "transient" and retries < max` | retry | `match_rules` | +| `match_rules` | `node_status == "error" and error_type == "transient" and retries >= max` | escalate | `route_to_queue` | +| `score_confidence` | `node_status == "completed"` | next | `route_to_queue` | +| `score_confidence` | `node_status == "error" and error_type == "transient" and retries < max` | retry | `score_confidence` | +| `score_confidence` | `node_status == "error" and error_type == "transient" and retries >= max` | escalate | `route_to_queue` | +| `route_to_queue` | `node_status == "completed" and escalate_bucket is non-empty` | escalate | `human_review` | +| `route_to_queue` | `node_status == "completed" and escalate_bucket is empty` | next | `finalize` | +| `human_review` | `node_status == "completed"` (all decisions received) | finalize | `finalize` | +| `finalize` | `node_status == "completed"` | end | `END` | +| `finalize` | `node_status == "error" and error_type == "transient" and retries < max` | retry | `finalize` | +| `finalize` | `node_status == "error" and error_type == "transient" and retries >= max` | escalate | (manual intervention required — Run marked "failed") | + diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/tasks.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/tasks.md new file mode 100644 index 000000000..847cbca06 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/langgraph-pipeline-design/tasks.md @@ -0,0 +1,276 @@ +# Implementation Plan: LangGraph Pipeline Design + +## Overview + +This plan implements the LangGraph StateGraph topology for the three-stage document-intelligence pipeline (Understand → Examine → Stay-Alive). Implementation proceeds bottom-up: state schema and configuration first, then routing logic, then individual nodes, then checkpoint/resume infrastructure, and finally human-in-the-loop interrupt/resume wiring. All code is Python 3.11 using LangGraph, FastAPI, SQLAlchemy, and PostgreSQL 16 with pgvector. + +## Tasks + +- [x] 1. Define State schema, configuration, and core types + - [x] 1.1 Create the PipelineState TypedDict and supporting types + - Create `src/pipeline/state.py` with `PipelineState`, `ChunkEntry`, `ExtractionResult`, `ComplianceVerdict`, `Decision`, `SkippedNodeEntry`, `PipelineConfig`, `QueueBuckets` TypedDicts + - Include type literals for `node_status`, `error_type`, and `verdict` values + - Add a factory function `create_initial_state(run_id, document_id, document_version_id, config)` that returns a valid initial PipelineState with all defaults + - _Requirements: 7.1, 7.2, 7.3, 7.5_ + + - [x] 1.2 Create JSONB serialization/deserialization utilities + - Create `src/pipeline/serialization.py` with `serialize_state(state: PipelineState) -> dict` and `deserialize_state(data: dict) -> PipelineState` + - Handle bytes ↔ base64, UUID ↔ string conversions + - Ensure round-trip fidelity for all field types + - _Requirements: 7.5, 6.2_ + + - [x] 1.3 Write property test for State JSONB round-trip + - **Property 6: State JSONB Round-Trip** + - Create `tests/pipeline/test_properties_state.py` + - Build a Hypothesis composite strategy for generating valid `PipelineState` instances + - Assert `deserialize_state(serialize_state(state)) == state` for all generated states + - **Validates: Requirements 7.5, 6.2** + + - [x] 1.4 Create PipelineConfig loader with defaults + - Create `src/pipeline/config.py` with `load_config(overrides: dict) -> PipelineConfig` + - Apply defaults: max_retries=3, chunk_max_size=1000, chunk_overlap=200, confidence_threshold=0.7, review_timeout_hours=72, reminder_interval_hours=24, poll_interval_seconds=30, extract_text_timeout_seconds=60, min_chunk_threshold=200 + - Validate constraints (chunk_max_size > chunk_overlap > 0, thresholds in range) + - _Requirements: 7.1_ + +- [x] 2. Implement routing functions + - [x] 2.1 Create the routing function factory and per-node routing logic + - Create `src/pipeline/routing.py` + - Implement `make_routing_fn(node_name: str, config: PipelineConfig)` factory + - Implement per-node routing logic as documented in the routing conditions table: `ingest` (next/escalate), `extract_text` (next/retry/escalate), `chunk` (next), `embed` (next/retry/escalate), `extract_claims` (next/retry/escalate), `match_rules` (next/retry/escalate), `score_confidence` (next/retry/escalate), `route_to_queue` (escalate/next based on escalate bucket), `human_review` (finalize), `finalize` (end/retry/escalate) + - Implement unhandled-state fallback that sets permanent error and returns "escalate" + - _Requirements: 1.6, 1.7, 2.1, 2.2, 2.5, 11.3, 11.4_ + + - [x] 2.2 Write property test for routing determinism + - **Property 2: Routing Determinism — Exactly One Match** + - Create `tests/pipeline/test_properties_routing.py` + - Generate arbitrary post-node States; assert exactly one routing condition matches per source node + - **Validates: Requirements 11.4, 1.7** + + - [x] 2.3 Write property test for retry routing correctness + - **Property 3: Retry Routing Correctness** + - Generate States with `node_status="error"`, `error_type="transient"`, varying retry counts + - Assert "retry" when retries < max_retries, "escalate" when retries >= max_retries + - **Validates: Requirements 2.1, 2.2** + + - [x] 2.4 Write property test for unrecognized error type escalation + - **Property 4: Unrecognized Error Type Escalation** + - Generate States with `error_type` values other than "transient"/"permanent" (including None, random strings) + - Assert routing always returns "escalate" + - **Validates: Requirements 2.5, 1.7** + + - [x] 2.5 Write property test for skip routing and metadata + - **Property 7: Skip Routing and Metadata** + - Generate States with `node_status="skipped"` and varied `skip_reason` strings (valid, empty, null, >255 chars) + - Assert "next" for valid reasons with correct `skipped_nodes` entry; assert escalation for invalid reasons + - **Validates: Requirements 3.1, 3.4, 3.5** + +- [x] 3. Checkpoint - Ensure state schema and routing tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 4. Implement Understand Stage nodes + - [x] 4.1 Implement the `ingest` node + - Create `src/pipeline/nodes/ingest.py` + - Read document bytes from `document_versions.storage_ref` via SQLAlchemy + - Validate MIME type against allowed set (application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document, text/plain) + - Set `raw_content`, `mime_type`, `current_node`, `node_status`, `completed_nodes` + - Return permanent error for invalid MIME, zero bytes, or unreadable file + - _Requirements: 8.1, 1.2_ + + - [x] 4.2 Implement the `extract_text` node + - Create `src/pipeline/nodes/extract_text.py` + - Convert raw_content to plain text based on MIME type (PDF extraction, DOCX extraction, text/plain passthrough) + - Support skip: if mime_type == "text/plain", set status="skipped" with skip_reason="input_already_text" and copy raw content to extracted_text + - Set transient error on timeout/memory, permanent error on corruption + - _Requirements: 8.2, 3.2_ + + - [x] 4.3 Implement the `chunk` node + - Create `src/pipeline/nodes/chunk.py` + - Split extracted_text into segments of config["chunk_max_size"] with config["chunk_overlap"] overlap + - Each chunk entry: index, text, start_offset, end_offset + - Support skip: if len(extracted_text) < config["min_chunk_threshold"], produce single-element chunk list and set status="skipped" + - Permanent error if extracted_text is None or empty + - _Requirements: 8.3, 3.3, 1.2_ + + - [x] 4.4 Write property test for chunk coverage + - **Property 11: Chunk Coverage** + - Generate arbitrary non-empty text and valid chunk_max_size/chunk_overlap parameters + - Assert union of all chunk text ranges covers every character in original text with no gaps + - **Validates: Requirements 8.3** + + - [x] 4.5 Implement the `embed` node + - Create `src/pipeline/nodes/embed.py` + - Generate vector embeddings for all chunks in a single atomic batch (call embedding API) + - Store vectors in pgvector-enabled table linked to document_version_id + - Set embeddings_stored=true on success, false + transient error on any failure + - Discard all partial results on failure + - _Requirements: 8.4, 1.2_ + +- [x] 5. Implement Examine Stage nodes + - [x] 5.1 Implement the `extract_claims` node + - Create `src/pipeline/nodes/extract_claims.py` + - Process each chunk through LLM to identify factual assertions + - Produce ExtractionResult entries with claim_text, chunk_index, start_offset, end_offset (start < end), confidence + - Empty claims list is "completed" not error + - Transient error on LLM API failure + - _Requirements: 9.1, 9.4_ + + - [x] 5.2 Write property test for extraction offset ordering + - **Property 12: Extraction Offset Ordering** + - Generate ExtractionResult entries; assert start_offset < end_offset and both within chunk text bounds + - **Validates: Requirements 9.1** + + - [x] 5.3 Implement the `match_rules` node + - Create `src/pipeline/nodes/match_rules.py` + - Compare each claim against compliance rules from config + - Produce ComplianceVerdict per claim (compliant/non_compliant/indeterminate) + - Empty claims → empty verdicts (completed, not error) + - Transient error on LLM API failure, permanent error on missing/unparseable rule config + - _Requirements: 9.2, 9.4, 9.5_ + + - [x] 5.4 Write property test for verdicts-claims length parity + - **Property 13: Verdicts-Claims Length Parity** + - Generate non-empty claims lists; assert verdicts list has same length with 1:1 claim_id correspondence + - **Validates: Requirements 9.2** + + - [x] 5.5 Implement the `score_confidence` node + - Create `src/pipeline/nodes/score_confidence.py` + - Refine confidence scores based on verdict certainty and source location completeness + - Flag claims with confidence < config["confidence_threshold"] as needs_human_review=true + - Transient error on LLM API failure, permanent error on rule config issue + - _Requirements: 9.3, 9.4, 9.5_ + + - [x] 5.6 Write property test for confidence flagging threshold + - **Property 14: Confidence Flagging Threshold** + - Generate verdicts with varied confidence values; assert needs_human_review=true when below threshold and false when at/above (unless non-compliant) + - **Validates: Requirements 9.3, 4.1** + +- [x] 6. Checkpoint - Ensure Understand and Examine stage tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 7. Implement Stay-Alive Stage nodes + - [x] 7.1 Implement the `route_to_queue` node + - Create `src/pipeline/nodes/route_to_queue.py` + - Partition claims into auto_approve (compliant + confidence >= threshold), escalate (non-compliant OR confidence < threshold OR needs_human_review OR permanent-error origin), auto_reject (duplicate claims within same run) + - Priority: escalate > auto_reject > auto_approve + - Transient error on resource failure during partitioning + - _Requirements: 4.4, 10.1_ + + - [x] 7.2 Write property test for claim partitioning priority + - **Property 8: Claim Partitioning Priority** + - Generate claims with various verdicts and confidence scores; assert each claim appears in exactly one bucket following priority ordering + - **Validates: Requirements 4.4, 4.1, 4.3** + + - [x] 7.3 Write property test for escalate bucket routing + - **Property 9: Escalate Bucket Determines Human Review Routing** + - Generate post-route_to_queue States; assert routing → human_review when escalate non-empty, → finalize when empty + - **Validates: Requirements 4.5, 4.6** + + - [x] 7.4 Implement the `human_review` interrupt node + - Create `src/pipeline/nodes/human_review.py` + - Insert escalated claims into approval_queue with status "pending" + - Call LangGraph `interrupt()` to suspend graph execution + - On resume: query decisions table, populate state["decisions"], return completed + - _Requirements: 5.1, 5.2, 5.3, 5.4, 10.2_ + + - [x] 7.5 Write property test for post-human-review routing + - **Property 15: Post-Human-Review Always Finalizes** + - Generate States with varied decisions (all approved, all rejected, mixed); assert routing always returns "finalize" + - **Validates: Requirements 5.6** + + - [x] 7.6 Implement the `finalize` node + - Create `src/pipeline/nodes/finalize.py` + - Within single DB transaction: write verified/rejected claims, update runs to "completed", insert audit events + - Handle human-review-skipped case (no decisions needed) + - Transient error on DB transaction failure + - _Requirements: 10.3, 10.4, 10.5, 10.6_ + +- [x] 8. Implement checkpoint and resume infrastructure + - [x] 8.1 Create the checkpoint persistence layer + - Create `src/pipeline/checkpoint.py` + - Implement `write_checkpoint(session, run_id, step_name, step_order, state)` — single transaction: serialize state to JSONB, write to run_steps.output_state, update status to completed/skipped, set ended_at + - Implement `create_step_row(session, run_id, step_name, step_order)` — creates run_steps row with status "running" and started_at + - Handle checkpoint write failure → transient error + - _Requirements: 6.1, 6.2, 6.4, 6.5, 6.7_ + + - [x] 8.2 Write property test for checkpoint-if-and-only-if-success + - **Property 5: Checkpoint If-And-Only-If Success** + - Generate node executions with varied statuses; assert checkpoint written iff status is "completed" or "skipped", never for "error" or during retries + - **Validates: Requirements 6.1, 6.4, 2.4, 6.6** + + - [x] 8.3 Create the resume logic + - Create `src/pipeline/resume.py` + - Implement `resume_run(session, run_id)` — query highest step_order with completed/skipped status, load output_state, mark orphaned "running" rows as failed, determine next node via routing function + - Acquire exclusive advisory lock on run_id to enforce single-writer + - _Requirements: 6.3, 6.8_ + + - [x] 8.4 Write property test for resume restart correctness + - **Property 10: Resume Restart Correctness** + - Generate run_steps sequences with varied statuses; assert resume identifies correct last checkpoint and correct next node + - **Validates: Requirements 6.3, 6.8** + +- [x] 9. Assemble the StateGraph and wire all components + - [x] 9.1 Build the LangGraph StateGraph with all nodes and conditional edges + - Create `src/pipeline/graph.py` + - Register all 10 nodes with the StateGraph + - Register all conditional edges using `add_conditional_edges` with the routing functions from routing.py + - Set entry point at `ingest`, terminal at END after finalize + - Include retry edge logic that increments retries dict and restores pre-node checkpoint state + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + + - [x] 9.2 Write property test for stage ordering invariant + - **Property 1: Stage Ordering Invariant** + - Generate completed run states; assert completed_nodes follows Understand → Examine → Stay-Alive order + - **Validates: Requirements 1.1** + + - [x] 9.3 Write property test for node completion contract + - **Property 16: Node Completion Contract** + - Generate node executions; assert completed/skipped nodes have current_node set correctly and name appended to completed_nodes; error nodes not in completed_nodes + - **Validates: Requirements 7.4** + + - [x] 9.4 Create the run initialization endpoint + - Create `src/pipeline/api.py` with FastAPI endpoints + - `POST /runs` — create run row, freeze config_snapshot, build initial PipelineState, invoke graph + - `POST /runs/{run_id}/resume` — trigger resume logic, re-invoke graph from checkpoint + - Acquire advisory lock on run_id at start + - _Requirements: 6.3, 5.3_ + + - [x] 9.5 Wire the human review polling service + - Create `src/pipeline/polling.py` + - Implement polling worker that checks decisions table at config["poll_interval_seconds"] + - When all decisions received for a run's escalated items, call LangGraph resume API + - Insert reminder audit events at config["reminder_interval_hours"] intervals + - _Requirements: 5.3, 5.5_ + +- [x] 10. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document (Properties 1–16) +- Unit tests validate specific examples and edge cases +- The pipeline reads from and writes to existing core-postgres-schema tables (runs, run_steps, documents, document_versions, claims, approval_queue, decisions, audit_events) +- All LLM interactions (embed, extract_claims, match_rules, score_confidence) should be abstracted behind interfaces to allow mocking in tests + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "1.4"] }, + { "id": 1, "tasks": ["1.2", "1.3"] }, + { "id": 2, "tasks": ["2.1"] }, + { "id": 3, "tasks": ["2.2", "2.3", "2.4", "2.5"] }, + { "id": 4, "tasks": ["4.1", "4.2", "4.3", "4.5"] }, + { "id": 5, "tasks": ["4.4", "5.1", "5.3", "5.5"] }, + { "id": 6, "tasks": ["5.2", "5.4", "5.6", "7.1"] }, + { "id": 7, "tasks": ["7.2", "7.3", "7.4", "7.6"] }, + { "id": 8, "tasks": ["7.5", "8.1"] }, + { "id": 9, "tasks": ["8.2", "8.3"] }, + { "id": 10, "tasks": ["8.4", "9.1"] }, + { "id": 11, "tasks": ["9.2", "9.3", "9.4", "9.5"] } + ] +} +``` diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/.config.kiro b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/.config.kiro new file mode 100644 index 000000000..5e7cb1341 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/.config.kiro @@ -0,0 +1 @@ +{"specId": "4f512d5f-ab3b-46f2-849c-acdc3c6697ca", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/design.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/design.md new file mode 100644 index 000000000..0837e91fd --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/design.md @@ -0,0 +1,743 @@ +# Design Document: Microfinance Ingestion Pipeline + +## Overview + +This design extends the existing LangGraph-based document intelligence pipeline to support microfinance-specific document classification and structured fact extraction. The system introduces a `classify_document` node that runs between the existing `extract_text` and `chunk` nodes, routing documents through type-specific extraction schemas. Each extracted fact carries a precise source pointer (character offsets into the original text) enabling round-trip verification. + +The feature also introduces a synthetic document generator for end-to-end testing and a provenance test that validates the extraction-to-source-pointer chain across all document types. + +### Key Design Decisions + +1. **New node insertion vs. extending existing nodes**: We add a `classify_document` node between `extract_text` and `chunk` rather than extending `extract_claims`. Classification must happen before chunking because the document type determines optimal chunk boundaries (e.g., clause-level for loan agreements, row-level for repayment statements). + +2. **Type-specific extraction as strategy pattern**: The `extract_claims` node is extended with a strategy registry that dispatches to type-specific extractors based on the classification result stored in pipeline state. + +3. **Source pointers use the existing `SourceLocation` model**: No schema changes are needed — the existing `source_locations` table already has `document_version_id`, `page_number`, `section_id`, `start_offset`, and `end_offset`. + +4. **Synthetic generator is a standalone test utility**: It lives under `tests/` and is not part of the production pipeline. It produces deterministic output given a seed. + +## Architecture + +### High-Level Component Diagram + +```mermaid +graph TD + subgraph "Understand Stage" + A[ingest] --> B[extract_text] + B --> C[classify_document] + C --> D[chunk] + D --> E[embed] + end + + subgraph "Examine Stage" + E --> F[extract_claims] + F --> G[match_rules] + G --> H[score_confidence] + end + + subgraph "Stay-Alive Stage" + H --> I[route_to_queue] + I --> J[human_review] + J --> K[finalize] + end + + C -- "classification_label in state" --> F + C -- "confidence <= 0.6" --> I + + subgraph "Type-Specific Extractors (inside extract_claims)" + F --> L[LoanAgreementExtractor] + F --> M[ModificationExtractor] + F --> N[RepaymentExtractor] + end + + subgraph "Source Linking (inside extract_claims)" + L --> O[SourceLinker] + M --> O + N --> O + end +``` + +### Pipeline Flow with Classification + +```mermaid +sequenceDiagram + participant I as ingest + participant ET as extract_text + participant CD as classify_document + participant CK as chunk + participant EM as embed + participant EC as extract_claims + participant SL as SourceLinker + + I->>ET: raw_content, mime_type + ET->>CD: extracted_text + CD->>CD: classify(text) → label, confidence + alt confidence > 0.6 + CD->>CK: classification_label stored in state + CK->>EM: chunks with offsets + EM->>EC: embeddings_stored=True + EC->>EC: dispatch to type-specific extractor + EC->>SL: attach source pointers to each fact + else confidence <= 0.6 + CD->>CD: label="unclassified", route to approval queue + end +``` + +## Components and Interfaces + +### 1. Document Classifier Node (`classify_document`) + +**Location:** `src/pipeline/nodes/classify_document.py` + +```python +from typing import Literal, Protocol, Optional +from src.pipeline.state import PipelineState + +DocumentType = Literal[ + "loan_agreement", + "modification_agreement", + "repayment_statement", + "unclassified", +] + +class ClassificationResult: + """Result of document classification.""" + label: DocumentType + confidence: float # 0.0–1.0 + scores: dict[str, float] # per-label scores + +class DocumentClassifierService(Protocol): + """Protocol for the classification backend (LLM or ML model).""" + async def classify(self, text: str) -> ClassificationResult: ... + +async def classify_document( + state: PipelineState, + *, + classifier: Optional[DocumentClassifierService] = None, +) -> PipelineState: + """Classify the document by type and store result in state. + + If max confidence <= 0.6, sets label to 'unclassified' and + routes to approval queue via error escalation. + + Adds 'classification_label' and 'classification_confidence' + to state. + """ + ... +``` + +**Integration with graph:** Inserted between `extract_text` and `chunk` in `PATH_MAPS` and `NODES`: + +```python +PATH_MAPS["extract_text"] = {"next": "classify_document", "retry": "extract_text", "escalate": "route_to_queue"} +PATH_MAPS["classify_document"] = {"next": "chunk", "escalate": "route_to_queue"} +``` + +### 2. Extended Pipeline State + +New fields added to `PipelineState` TypedDict: + +```python +class PipelineState(TypedDict): + # ... existing fields ... + + # Classification outputs (from classify_document node) + classification_label: Optional[str] # DocumentType value + classification_confidence: Optional[float] # 0.0–1.0 + classification_scores: Optional[dict[str, float]] # per-label scores +``` + +### 3. Type-Specific Extraction Strategies + +**Location:** `src/pipeline/extractors/` + +```python +# src/pipeline/extractors/__init__.py +from src.pipeline.extractors.base import FactExtractor, ExtractedFact +from src.pipeline.extractors.loan_agreement import LoanAgreementExtractor +from src.pipeline.extractors.modification import ModificationExtractor +from src.pipeline.extractors.repayment import RepaymentExtractor +from src.pipeline.extractors.registry import EXTRACTOR_REGISTRY + +# src/pipeline/extractors/base.py +from dataclasses import dataclass +from typing import Optional, Protocol + +@dataclass +class SourceSpan: + """Character span within the source text.""" + start_offset: int # 0-based, inclusive + end_offset: int # 0-based, exclusive + page_number: Optional[int] = None + section_id: Optional[str] = None + +@dataclass +class ExtractedFact: + """A single extracted fact with source provenance.""" + field_name: str + value: str # normalized string representation + confidence: float # 0.000–1.000 + source_span: SourceSpan + fact_group_id: Optional[str] = None # groups multi-field records + +class FactExtractor(Protocol): + """Protocol for type-specific extraction.""" + async def extract( + self, + text: str, + chunks: list[dict], + ) -> list[ExtractedFact]: ... + +# src/pipeline/extractors/registry.py +EXTRACTOR_REGISTRY: dict[str, type[FactExtractor]] = { + "loan_agreement": LoanAgreementExtractor, + "modification_agreement": ModificationExtractor, + "repayment_statement": RepaymentExtractor, +} +``` + +### 4. Loan Agreement Extractor + +```python +# src/pipeline/extractors/loan_agreement.py +class LoanAgreementExtractor: + """Extracts structured fields from loan agreements. + + Fields: borrower_name, lender_name, principal_amount, interest_rate, + interest_type, tenure_months, repayment_frequency, processing_fee, penal_rate + """ + + REQUIRED_FIELDS = [ + "borrower_name", "lender_name", "principal_amount", + "interest_rate", "interest_type", "tenure_months", + "repayment_frequency", "processing_fee", "penal_rate", + ] + + async def extract(self, text: str, chunks: list[dict]) -> list[ExtractedFact]: + """Extract loan agreement fields with source spans. + + For fields not found: value="not_found", confidence=0.0 + For conflicting values: uses last-in-document, confidence <= 0.5 + Normalizes monetary values to 2 decimal places. + Normalizes rates to annual percentage with 2 decimal places. + """ + ... +``` + +### 5. Modification Agreement Extractor + +```python +# src/pipeline/extractors/modification.py +class ModificationExtractor: + """Extracts term changes from modification agreements. + + Each term change produces a separate fact group: + original_loan_reference, modified_field_name, original_value, + new_value, effective_date + """ + + SUPPORTED_FIELDS = [ + "interest_rate", "tenure_months", "emi_amount", "moratorium_period_months" + ] + + async def extract(self, text: str, chunks: list[dict]) -> list[ExtractedFact]: + """Extract modification term changes. + + Groups related fields by fact_group_id. + Skips changes with unsupported modified_field_name. + """ + ... +``` + +### 6. Repayment Statement Extractor + +```python +# src/pipeline/extractors/repayment.py +class RepaymentExtractor: + """Extracts payment rows from repayment statements. + + Each row produces a fact group: payment_date, amount_paid, + late_fee_charged, outstanding_balance, row_index + """ + + async def extract(self, text: str, chunks: list[dict]) -> list[ExtractedFact]: + """Extract payment rows with 1-based row_index. + + Normalizes dates to ISO 8601 (YYYY-MM-DD). + Records unparseable dates as value="unparseable", confidence=0.0. + Records blank/non-numeric monetary fields as value="not_found", confidence=0.0. + """ + ... +``` + +### 7. Source Linker + +**Location:** `src/pipeline/source_linker.py` + +```python +class SourceResolutionError(Exception): + """Raised when a source pointer cannot be resolved.""" + def __init__(self, claim_id: str, source_location_id: str, reason: str): + self.claim_id = claim_id + self.source_location_id = source_location_id + self.reason = reason + super().__init__(f"Cannot resolve source for claim {claim_id}: {reason}") + +class SourceLinker: + """Attaches and resolves source pointers for extracted facts.""" + + def attach( + self, + fact: ExtractedFact, + document_version_id: str, + ) -> SourceLocation: + """Create a SourceLocation record from an ExtractedFact's span. + + Validates: start_offset < end_offset. + """ + ... + + def resolve( + self, + source_location: SourceLocation, + stored_text: str, + ) -> str: + """Resolve a source pointer to the original substring. + + Returns text[start_offset:end_offset]. + + Raises: + SourceResolutionError: If offsets exceed text length or + document_version_id doesn't exist. + """ + ... +``` + +### 8. Synthetic Document Generator + +**Location:** `tests/synthetic/generator.py` + +```python +@dataclass +class ConflictManifest: + """Describes embedded factual conflicts in a synthetic pile.""" + conflicts: list[ConflictEntry] + +@dataclass +class ConflictEntry: + """One factual conflict between two documents.""" + field_name: str + expected_value: str # from the Modification Agreement + contradicting_value: str # in the Repayment Statement + modification_filename: str + repayment_filename: str + +@dataclass +class SyntheticPile: + """A generated pile of 5 documents with conflict manifest.""" + documents: list[SyntheticDocument] # exactly 5 + manifest: ConflictManifest + +class SyntheticDocumentGenerator: + """Generates realistic microfinance document piles for testing. + + Produces exactly 5 documents (≥1 loan, ≥1 modification, ≥1 repayment) + with exactly 2 factual conflicts between modification and repayment docs. + """ + + def __init__(self, seed: Optional[int] = None): + self._rng = random.Random(seed) + + def generate(self) -> SyntheticPile: + """Generate a complete pile with conflict manifest. + + Ensures: + - Chronological coherence (loan date < mod date < repayment dates) + - Common loan reference across all documents + - At least 2 of 3 formats (PDF, DOCX, plain text) + - Realistic structure (headers, clause numbering, dates, amounts) + """ + ... +``` + +### 9. Provenance End-to-End Test + +**Location:** `tests/test_provenance_e2e.py` + +```python +class TestProvenanceEndToEnd: + """End-to-end test: synthetic pile → pipeline → source pointer verification.""" + + def test_all_facts_have_valid_source_pointers(self): + """Ingest 5 synthetic docs, verify every fact has a resolvable pointer. + + Asserts: + 1. All 5 documents produce ≥1 extracted fact + 2. Every fact has exactly 1 source_location record + 3. start_offset < end_offset for every pointer + 4. Resolved substring is non-empty + 5. Re-parsing resolved substring produces same value (round-trip) + 6. No SourceResolutionError raised during resolution + """ + ... +``` + +## Data Models + +### Mapping to Existing Tables + +The microfinance extraction maps directly onto the existing `claims` and `source_locations` tables without schema changes: + +| Extraction Concept | Table | Column | Notes | +|---|---|---|---| +| Extracted fact | `claims` | `extracted_text` | Normalized value string | +| Document type | `claims` | `claim_type` | `"loan_agreement.principal_amount"` etc. | +| Confidence | `claims` | `confidence` | Decimal(4,3), 0.000–1.000 | +| Source span start | `source_locations` | `start_offset` | 0-based character position | +| Source span end | `source_locations` | `end_offset` | 0-based, exclusive | +| Page (PDF) | `source_locations` | `page_number` | Integer or NULL | +| Section (DOCX/text) | `source_locations` | `section_id` | String or NULL | +| Loan reference | `source_locations` | `clause_ref` | Original loan account ID | + +### Claim Type Naming Convention + +The `claim_type` field uses a compound format: `{document_type}.{field_name}`: + +- `loan_agreement.borrower_name` +- `loan_agreement.principal_amount` +- `modification_agreement.new_value` +- `repayment_statement.amount_paid` + +For repayment rows, `claim_type` includes the row index: `repayment_statement.row_3.amount_paid` + +### ExtractedFact → Claim/SourceLocation Mapping + +```python +def persist_fact( + fact: ExtractedFact, + document_version_id: str, + run_id: str, + document_type: str, + session: Session, +) -> tuple[Claim, SourceLocation]: + """Persist an ExtractedFact as a Claim + SourceLocation pair.""" + claim = Claim( + document_version_id=uuid.UUID(document_version_id), + run_id=uuid.UUID(run_id), + extracted_text=fact.value, + claim_type=f"{document_type}.{fact.field_name}", + confidence=Decimal(str(round(fact.confidence, 3))), + ) + session.add(claim) + session.flush() # get claim.id + + source_location = SourceLocation( + claim_id=claim.id, + document_version_id=uuid.UUID(document_version_id), + page_number=fact.source_span.page_number, + section_id=fact.source_span.section_id, + start_offset=fact.source_span.start_offset, + end_offset=fact.source_span.end_offset, + clause_ref=fact.fact_group_id, + ) + session.add(source_location) + return claim, source_location +``` + +### State Extension for Classification + +```python +# Added to PipelineState TypedDict in state.py +classification_label: Optional[str] # "loan_agreement" | "modification_agreement" | "repayment_statement" | "unclassified" +classification_confidence: Optional[float] # 0.0–1.0 +classification_scores: Optional[dict[str, float]] # {"loan_agreement": 0.85, ...} +``` + +### Synthetic Document Data Model + +```python +@dataclass +class SyntheticDocument: + """A generated document with known ground truth.""" + filename: str + document_type: str # "loan_agreement" | "modification_agreement" | "repayment_statement" + format: str # "pdf" | "docx" | "text" + content: bytes # raw file bytes + text_content: str # plain text representation (ground truth) + ground_truth_facts: list[GroundTruthFact] + +@dataclass +class GroundTruthFact: + """A known fact embedded in the synthetic document.""" + field_name: str + value: str + start_offset: int # in text_content + end_offset: int # in text_content +``` + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Classification Output Validity + +*For any* document text (non-empty, from a supported MIME type), the classifier SHALL produce exactly one label from {loan_agreement, modification_agreement, repayment_statement, unclassified} with a confidence score in [0.0, 1.0], where the label is "unclassified" if and only if all candidate scores are ≤ 0.6. + +**Validates: Requirements 1.1, 1.2** + +### Property 2: Unsupported MIME Rejection + +*For any* MIME type string that is not in {application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document, text/plain}, submitting a document with that MIME type SHALL produce an error with code UNSUPPORTED_FORMAT. + +**Validates: Requirements 1.4** + +### Property 3: Extraction Schema Completeness + +*For any* classified document, the extraction output SHALL contain exactly the required field names for that document type: {borrower_name, lender_name, principal_amount, interest_rate, interest_type, tenure_months, repayment_frequency, processing_fee, penal_rate} for loan agreements; {original_loan_reference, modified_field_name, original_value, new_value, effective_date} per term change for modifications; {payment_date, amount_paid, late_fee_charged, outstanding_balance} per row for repayment statements. + +**Validates: Requirements 2.1, 3.1, 4.1** + +### Property 4: Missing Field Handling + +*For any* document where a required field is absent, blank, non-numeric (for monetary fields), or unparseable (for date fields), the extractor SHALL record that field with value "not_found" (or "unparseable" for dates) and confidence exactly 0.0, while extracting remaining fields normally. + +**Validates: Requirements 2.2, 2.4, 3.5, 4.5, 4.6** + +### Property 5: Monetary Normalization + +*For any* valid monetary string extracted from a document (with a stated currency), the normalized output SHALL be a numeric value with exactly 2 decimal places. + +**Validates: Requirements 2.3, 4.3** + +### Property 6: Rate Normalization + +*For any* interest rate or penal rate value extracted from a document, the normalized output SHALL be an annual percentage value with exactly 2 decimal places. For modification agreements, tenure and moratorium values SHALL be normalized to whole months. + +**Validates: Requirements 2.5, 3.6** + +### Property 7: Confidence Score Bounds + +*For any* extracted fact from any document type, the assigned confidence score SHALL be a value in the range [0.0, 1.0] inclusive, with at most 3 decimal places of precision. + +**Validates: Requirements 2.6, 4.7** + +### Property 8: Conflict Resolution Picks Last Value + +*For any* document containing multiple conflicting values for the same field, the extractor SHALL select the value from the clause appearing last in document order (or latest-dated clause), and SHALL assign a confidence score no higher than 0.5 to that field. + +**Validates: Requirements 2.7** + +### Property 9: Modification Field Filtering + +*For any* modification agreement containing term changes, the extractor SHALL produce fact records only for changes whose modified_field_name is in {interest_rate, tenure_months, emi_amount, moratorium_period_months}, and SHALL skip all other field changes. + +**Validates: Requirements 3.2** + +### Property 10: Multi-Change Cardinality + +*For any* modification agreement containing N supported term changes, the extractor SHALL produce exactly N fact groups, each containing the required fields for a term change. + +**Validates: Requirements 3.4** + +### Property 11: Date Normalization to ISO 8601 + +*For any* valid date string in a repayment statement (regardless of input format), the normalized output SHALL match the pattern YYYY-MM-DD and represent the same calendar date as the input. + +**Validates: Requirements 4.2** + +### Property 12: Sequential Row Indexing + +*For any* repayment statement containing N payment rows, the extractor SHALL assign row_index values from 1 to N (inclusive) in document order, with no gaps or duplicates. + +**Validates: Requirements 4.4** + +### Property 13: Source Pointer Structural Validity + +*For any* extracted fact, the associated source pointer SHALL have start_offset strictly less than end_offset, a valid document_version_id, and either page_number (for PDF) or section_id (for DOCX/text) populated. + +**Validates: Requirements 5.1, 5.2** + +### Property 14: Source Pointer Resolution Correctness + +*For any* valid source pointer (where offsets are within the document text length), resolving the pointer against the stored text SHALL return exactly `text[start_offset:end_offset]` — the substring from start_offset (inclusive) to end_offset (exclusive). + +**Validates: Requirements 5.3** + +### Property 15: Extraction Round-Trip + +*For any* extracted fact with a valid source pointer, resolving the pointer to obtain the source substring and re-parsing that substring using the same extraction logic SHALL yield a structured value identical to the originally extracted fact value. + +**Validates: Requirements 5.5** + +### Property 16: Source Resolution Error Reporting + +*For any* source pointer where offsets exceed the document text length or the document_version_id does not exist, attempting resolution SHALL raise a SourceResolutionError containing the claim_id, source_location_id, and a reason string. + +**Validates: Requirements 5.6** + +### Property 17: Generator Output Structure + +*For any* integer seed, the synthetic generator SHALL produce exactly 5 documents with at least 1 of each type (loan, modification, repayment), exactly 2 factual conflicts, and documents in at least 2 of the 3 supported formats. + +**Validates: Requirements 6.1, 6.2, 6.4** + +### Property 18: Conflict Manifest Validity + +*For any* generated pile, each entry in the conflict manifest SHALL reference exactly one modification agreement and one repayment statement (by filename), and SHALL contain non-empty field_name, expected_value, and contradicting_value. + +**Validates: Requirements 6.3, 6.6** + +### Property 19: Chronological Coherence + +*For any* generated pile, the loan agreement date SHALL precede all modification effective dates, each modification effective date SHALL precede the earliest repayment payment date that relates to it, and all documents SHALL share a common loan reference identifier. + +**Validates: Requirements 6.7** + +### Property 20: Generator Determinism + +*For any* integer seed, invoking the synthetic generator twice with the same seed SHALL produce byte-identical output (same documents, same manifest, same ordering). + +**Validates: Requirements 6.8** + +## Error Handling + +### Classification Errors + +| Condition | Error Code | Behavior | +|---|---|---| +| Unsupported MIME type | `UNSUPPORTED_FORMAT` | Permanent error, no retry | +| Unparseable content (zero text, corruption) | `PARSE_FAILURE` | Permanent error, no retry | +| All classification scores ≤ 0.6 | N/A (not an error) | Routes to approval queue as "unclassified" | +| Classifier service timeout | Transient error | Retried per `max_retries` config | +| Classifier service unavailable | Transient error | Retried per `max_retries` config | + +### Extraction Errors + +| Condition | Behavior | +|---|---| +| Required field not found | Record as `not_found`, confidence 0.0, continue extraction | +| Unparseable date | Record as `unparseable`, confidence 0.0, continue extraction | +| Blank/non-numeric monetary field | Record as `not_found`, confidence 0.0, continue extraction | +| LLM API failure during extraction | Transient error, retried | +| Multiple conflicting values | Extract last-in-document, confidence ≤ 0.5 | +| Unsupported modification field | Skip silently, no fact record | + +### Source Pointer Errors + +| Condition | Exception | Contains | +|---|---|---| +| start_offset >= end_offset | `ValueError` at attach time | Details of the invalid span | +| Offsets exceed text length | `SourceResolutionError` | claim_id, source_location_id, "out-of-bounds" | +| Non-existent document_version_id | `SourceResolutionError` | claim_id, source_location_id, "missing document version" | + +### Pipeline Graph Error Routing + +The `classify_document` node integrates with the existing routing/retry infrastructure: + +```python +PATH_MAPS["classify_document"] = { + "next": "chunk", # classification successful + "escalate": "route_to_queue", # unclassified (low confidence) or permanent error + "retry": "classify_document", # transient error (service timeout) +} +``` + +## Testing Strategy + +### Property-Based Testing (Hypothesis) + +This feature is well-suited for property-based testing because it involves: +- Data transformation and normalization (parsers, formatters) +- Universal invariants on output structure (schema completeness, confidence bounds) +- Round-trip properties (extraction → source resolution → re-parse) +- Deterministic generators with structural invariants + +**Library:** `hypothesis` (already in dev dependencies) + +**Configuration:** Minimum 100 examples per property test (`@settings(max_examples=100)`) + +**Test tag format:** `Feature: microfinance-ingestion-pipeline, Property {N}: {title}` + +Each correctness property maps to a single `@given`-decorated test function. + +### Test File Organization + +``` +tests/ +├── microfinance/ +│ ├── __init__.py +│ ├── test_properties_classifier.py # Properties 1, 2 +│ ├── test_properties_extraction.py # Properties 3–12 +│ ├── test_properties_source_linker.py # Properties 13–16 +│ ├── test_properties_generator.py # Properties 17–20 +│ ├── test_provenance_e2e.py # Integration test (Req 7) +│ └── conftest.py # Shared strategies and fixtures +├── synthetic/ +│ └── generator.py # Synthetic document generator +``` + +### Hypothesis Strategies + +Key custom strategies needed: + +```python +# conftest.py or strategies.py +@st.composite +def loan_agreement_text(draw) -> str: + """Generate realistic loan agreement text with known fields.""" + ... + +@st.composite +def modification_text(draw) -> str: + """Generate modification agreement text with known term changes.""" + ... + +@st.composite +def repayment_text(draw) -> str: + """Generate repayment statement text with known payment rows.""" + ... + +@st.composite +def monetary_string(draw) -> tuple[str, float]: + """Generate a monetary string and its expected normalized value.""" + ... + +@st.composite +def date_string(draw) -> tuple[str, str]: + """Generate a date string and its expected ISO 8601 output.""" + ... + +@st.composite +def source_pointer_and_text(draw) -> tuple[SourceSpan, str]: + """Generate a valid source pointer and matching text.""" + ... +``` + +### Unit Tests (Example-Based) + +Complement property tests with specific examples: + +- Classification of a known loan agreement (expected: loan_agreement, high confidence) +- Extraction of a sample 3-row repayment statement (verify row count, field values) +- Source resolution of a known offset pair +- Error handling for corrupted PDF bytes + +### Integration Tests + +- **Provenance E2E test** (Requirement 7): Full pipeline on synthetic pile +- **Database persistence test**: Verify FK relationships between claims and source_locations +- **Graph integration**: Verify classify_document node wires correctly into the LangGraph + +### Test Execution + +```bash +# Run all property tests +pytest tests/microfinance/test_properties_*.py -v + +# Run provenance E2E test +pytest tests/microfinance/test_provenance_e2e.py -v + +# Run with hypothesis verbose output +pytest tests/microfinance/ --hypothesis-show-statistics +``` diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/requirements.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/requirements.md new file mode 100644 index 000000000..883710844 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/requirements.md @@ -0,0 +1,118 @@ +# Requirements Document + +## Introduction + +This specification defines an ingestion pipeline for microfinance loan documents. The pipeline accepts loan agreements, modification/restructuring agreements, and repayment statements in PDF, DOCX, or plain-text format. It classifies each document by type, extracts domain-specific structured facts, and stores every extracted fact with a precise source pointer (document id, page/section, character span) that can be resolved back to the original text. The specification also covers synthetic test data generation with deliberate factual conflicts and an end-to-end provenance test. + +## Glossary + +- **Pipeline**: The ingestion system that receives raw documents and produces classified, extracted, source-linked facts +- **Document_Classifier**: The subsystem responsible for determining whether a document is a loan agreement, modification/restructuring agreement, or repayment statement +- **Fact_Extractor**: The subsystem responsible for pulling structured data fields from classified documents +- **Source_Linker**: The subsystem responsible for attaching a resolvable provenance pointer to each extracted fact +- **Loan_Agreement**: A document that establishes borrower/lender identity, principal, interest rate, tenure, repayment frequency, processing fee, and penal rate +- **Modification_Agreement**: A document that amends one or more terms of an existing loan (rate change, tenure extension, EMI restructuring, moratorium grant) +- **Repayment_Statement**: A document recording individual payments with date, amount, late fee, and running outstanding balance +- **Source_Pointer**: A composite reference comprising document_version_id, page_number (or section_id), start_offset, and end_offset that locates extracted text within the original document. Offsets are 0-based character positions relative to the beginning of the page or section text. +- **Synthetic_Generator**: The subsystem that produces realistic test documents with controlled factual conflicts +- **Factual_Conflict**: A situation where a fact in one document contradicts a logically dependent fact in another document within the same pile (e.g., a modification lowers the interest rate but a subsequent repayment statement still bills at the original rate) +- **Flat_Rate**: Interest calculated on the full original principal for the entire tenure +- **Reducing_Balance_Rate**: Interest calculated on the outstanding principal after each repayment + +## Requirements + +### Requirement 1: Document Classification + +**User Story:** As a compliance analyst, I want each ingested document automatically classified by type, so that the correct extraction logic is applied without manual triage. + +#### Acceptance Criteria + +1. WHEN a document in PDF, DOCX, or plain-text format is submitted, THE Document_Classifier SHALL assign exactly one classification label from the set {loan_agreement, modification_agreement, repayment_statement} +2. IF the Document_Classifier assigns a confidence score at or below 0.6 to all candidate labels, THEN THE Document_Classifier SHALL label the document as "unclassified" and route it to the approval queue for human review within the same processing transaction +3. THE Document_Classifier SHALL produce a classification result within 10 seconds per document for documents up to 50 pages +4. IF the submitted document has an unsupported MIME type (not PDF, DOCX, or plain text), THEN THE Pipeline SHALL reject the document with error code UNSUPPORTED_FORMAT and a descriptive message +5. IF a submitted document is in a supported format but cannot be parsed (zero extractable text, corrupted content, or encoding errors), THEN THE Pipeline SHALL reject the document with error code PARSE_FAILURE and a message indicating the nature of the parsing failure +6. IF a submitted document exceeds 50 pages, THEN THE Document_Classifier SHALL still produce a classification result, with a maximum processing time of 30 seconds + +### Requirement 2: Loan Agreement Fact Extraction + +**User Story:** As a compliance analyst, I want all material terms extracted from loan agreements, so that I can audit loan portfolios without reading each agreement manually. + +#### Acceptance Criteria + +1. WHEN a document is classified as loan_agreement, THE Fact_Extractor SHALL extract the following fields: borrower_name, lender_name, principal_amount, interest_rate, interest_type (flat or reducing_balance), tenure_months, repayment_frequency (one of: monthly, quarterly, semi_annually, annually, bullet), processing_fee, penal_rate +2. WHEN a required field is not present or not legible in the source document, THE Fact_Extractor SHALL record that field as "not_found" with confidence 0.0 +3. THE Fact_Extractor SHALL normalize principal_amount and processing_fee to numeric values with exactly 2 decimal places in the document's stated currency +4. IF the source document does not state a currency, THEN THE Fact_Extractor SHALL record principal_amount and processing_fee as "not_found" with confidence 0.0 +5. THE Fact_Extractor SHALL normalize interest_rate and penal_rate to annual percentage values with exactly 2 decimal places +6. THE Fact_Extractor SHALL assign a confidence score between 0.0 and 1.0 (inclusive, 3 decimal places precision) to each extracted field +7. IF a source document contains multiple conflicting values for the same field, THEN THE Fact_Extractor SHALL extract the value from the latest-dated clause or the clause appearing last in document order, and assign a confidence score no higher than 0.5 + +### Requirement 3: Modification Agreement Fact Extraction + +**User Story:** As a compliance analyst, I want structured extraction of amended terms from modification agreements, so that I can track how loan conditions change over time. + +#### Acceptance Criteria + +1. WHEN a document is classified as modification_agreement, THE Fact_Extractor SHALL extract the following fields for each term change: original_loan_reference, modified_field_name, original_value, new_value, effective_date, and SHALL assign a confidence score between 0.0 and 1.0 to each extracted field +2. THE Fact_Extractor SHALL support the following modified_field_name values: interest_rate, tenure_months, emi_amount, moratorium_period_months. IF a modification document contains a term change that does not map to one of the supported modified_field_name values, THEN THE Fact_Extractor SHALL skip that change and not produce a fact record for it +3. WHEN the modification agreement references an original loan by account number or agreement ID, THE Fact_Extractor SHALL extract that reference as original_loan_reference +4. WHEN a modification document contains multiple term changes, THE Fact_Extractor SHALL extract each change as a separate fact record +5. WHEN a required field (original_loan_reference, modified_field_name, original_value, new_value, or effective_date) is not present in the source document, THE Fact_Extractor SHALL record that field as "not_found" with confidence 0.0 +6. THE Fact_Extractor SHALL normalize interest_rate original_value and new_value to annual percentage values, tenure and moratorium values to whole months, and emi_amount to a numeric value in the document's stated currency + +### Requirement 4: Repayment Statement Fact Extraction + +**User Story:** As a compliance analyst, I want each payment record extracted from repayment statements, so that I can reconcile actual payments against loan terms. + +#### Acceptance Criteria + +1. WHEN a document is classified as repayment_statement, THE Fact_Extractor SHALL extract each payment row containing: payment_date, amount_paid, late_fee_charged, outstanding_balance +2. THE Fact_Extractor SHALL normalize payment_date to ISO 8601 format (YYYY-MM-DD) +3. THE Fact_Extractor SHALL normalize amount_paid, late_fee_charged, and outstanding_balance to numeric values with exactly 2 decimal places in the document's stated currency +4. WHEN a repayment statement contains multiple payment rows, THE Fact_Extractor SHALL extract each row as a separate fact record and assign a 1-based sequential row_index reflecting the order of appearance in the source document +5. IF a payment row contains a payment_date that cannot be parsed into a valid calendar date, THEN THE Fact_Extractor SHALL record the payment_date field as "unparseable" with confidence 0.0 and extract the remaining fields of that row normally +6. IF a payment row contains a monetary field (amount_paid, late_fee_charged, or outstanding_balance) that is blank or non-numeric, THEN THE Fact_Extractor SHALL record that field as "not_found" with confidence 0.0 and extract the remaining fields of that row normally +7. THE Fact_Extractor SHALL assign a confidence score between 0.0 and 1.0 to each extracted field in a payment row + +### Requirement 5: Source Pointer Provenance + +**User Story:** As an auditor, I want every extracted fact linked to a precise location in the source document, so that I can verify any fact by navigating directly to the original text. + +#### Acceptance Criteria + +1. THE Source_Linker SHALL attach a Source_Pointer to every extracted fact, comprising document_version_id, page_number (for PDF) or section_id (for DOCX/text), start_offset, and end_offset. Offsets are 0-based character positions relative to the beginning of the page or section text +2. THE Source_Linker SHALL ensure that start_offset is strictly less than end_offset for every Source_Pointer +3. WHEN a Source_Pointer is resolved against the stored document text, THE Source_Linker SHALL return the exact substring from position start_offset (inclusive) to end_offset (exclusive) that was used to extract the associated fact +4. THE Source_Linker SHALL store Source_Pointers in the source_locations table with foreign key references to the claims table and document_versions table +5. FOR ALL extracted facts, resolving the Source_Pointer and re-parsing the returned substring SHALL yield the same structured value as the original extraction (round-trip property) +6. IF resolution of a Source_Pointer fails (offsets exceed the page/section text length, or the referenced document_version_id does not exist), THEN THE Source_Linker SHALL raise a SourceResolutionError with the claim_id, source_location_id, and the reason for failure + +### Requirement 6: Synthetic Document Generation + +**User Story:** As a developer, I want a generator that produces realistic synthetic microfinance documents with deliberate cross-document conflicts, so that I can test conflict detection and ingestion accuracy end-to-end. + +#### Acceptance Criteria + +1. THE Synthetic_Generator SHALL produce a pile of exactly 5 documents: at least one Loan_Agreement, at least one Modification_Agreement, and at least one Repayment_Statement +2. THE Synthetic_Generator SHALL embed exactly 2 Factual_Conflicts across the generated pile +3. WHEN generating a Factual_Conflict, THE Synthetic_Generator SHALL ensure the conflict is between a Modification_Agreement and a subsequent Repayment_Statement (e.g., modification grants a rate reduction but the repayment statement still bills at the original rate, or modification grants a moratorium but the repayment statement shows payments collected during the moratorium period) +4. THE Synthetic_Generator SHALL produce documents in at least 2 of the 3 supported formats (PDF, DOCX, plain text) +5. THE Synthetic_Generator SHALL produce documents with realistic structure: headers, clause numbering, borrower/lender names, dates within a 3-year window ending on the generation date, interest rates between 8% and 36% per annum, and monetary amounts representative of microfinance loans (principal between 5,000 and 500,000 in local currency) +6. THE Synthetic_Generator SHALL output a conflict manifest alongside the generated documents, listing for each embedded Factual_Conflict: the conflicting field name, the expected value from the Modification_Agreement, the contradicting value in the Repayment_Statement, and the document filenames involved +7. THE Synthetic_Generator SHALL ensure chronological and referential coherence across the pile: all documents in the pile SHALL share a common loan reference identifier, the Loan_Agreement date SHALL precede any Modification_Agreement effective_date, and each Modification_Agreement effective_date SHALL precede the earliest payment_date in any Repayment_Statement that reflects (or conflicts with) that modification +8. WHEN the Synthetic_Generator is invoked with an optional integer seed parameter, THE Synthetic_Generator SHALL produce identical output for the same seed value across repeated invocations + +### Requirement 7: End-to-End Provenance Test + +**User Story:** As a developer, I want an automated test that runs the full ingestion pipeline on the synthetic pile and verifies that every extracted fact has a valid, resolvable source pointer, so that I can catch provenance regressions. + +#### Acceptance Criteria + +1. WHEN the provenance test executes, THE Pipeline SHALL ingest all 5 synthetic documents and produce at least 1 extracted fact per document, completing the full ingestion within 120 seconds +2. THE provenance test SHALL assert that every extracted fact has exactly one associated Source_Pointer record in the source_locations table +3. THE provenance test SHALL assert that every Source_Pointer resolves to a non-empty substring (at least 1 character) of the source document's stored text, where end_offset does not exceed the document text length +4. THE provenance test SHALL assert that start_offset < end_offset for every Source_Pointer +5. THE provenance test SHALL assert that re-parsing the resolved substring using the same extraction logic produces a structured value identical to the originally extracted fact value (round-trip equality) +6. IF any Source_Pointer fails to resolve (offsets exceed document length or reference a non-existent document_version_id), THEN THE provenance test SHALL fail with an assertion message that includes the claim_id, the source_location_id, and the reason for failure (out-of-bounds offset or missing document_version_id) +7. IF the Pipeline returns an error for any of the 5 synthetic documents during ingestion, THEN THE provenance test SHALL fail immediately with an assertion message identifying the failed document filename and the error returned diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/tasks.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/tasks.md new file mode 100644 index 000000000..1cdf384a9 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/microfinance-ingestion-pipeline/tasks.md @@ -0,0 +1,276 @@ +# Implementation Plan: Microfinance Ingestion Pipeline + +## Overview + +This plan implements the microfinance document classification and type-specific extraction pipeline. Work proceeds infrastructure-first (state extension, base classes, registry), then core logic (classifier node, extractors, source linker), then graph wiring, then test utilities (synthetic generator), and finally property-based and integration tests. + +## Tasks + +- [x] 1. Extend pipeline state and define extraction data models + - [x] 1.1 Add classification fields to PipelineState + - Add `classification_label: Optional[str]`, `classification_confidence: Optional[float]`, and `classification_scores: Optional[dict[str, float]]` to the `PipelineState` TypedDict in `src/pipeline/state.py` + - Update `create_initial_state` to initialize these fields to `None` + - _Requirements: 1.1, 1.2_ + + - [x] 1.2 Create extractor base module with SourceSpan and ExtractedFact + - Create `src/pipeline/extractors/__init__.py` and `src/pipeline/extractors/base.py` + - Define `SourceSpan` dataclass (start_offset, end_offset, page_number, section_id) + - Define `ExtractedFact` dataclass (field_name, value, confidence, source_span, fact_group_id) + - Define `FactExtractor` Protocol with `async def extract(self, text: str, chunks: list[dict]) -> list[ExtractedFact]` + - _Requirements: 2.1, 3.1, 4.1, 5.1_ + + - [x] 1.3 Create extractor registry module + - Create `src/pipeline/extractors/registry.py` + - Define `EXTRACTOR_REGISTRY: dict[str, type[FactExtractor]]` mapping document type labels to extractor classes + - Initially map to placeholder references (to be populated as extractors are built) + - _Requirements: 2.1, 3.1, 4.1_ + +- [x] 2. Implement document classifier node + - [x] 2.1 Create classify_document node + - Create `src/pipeline/nodes/classify_document.py` + - Define `DocumentType` literal type: `loan_agreement | modification_agreement | repayment_statement | unclassified` + - Define `ClassificationResult` dataclass with label, confidence, and scores + - Define `DocumentClassifierService` Protocol with `async def classify(self, text: str) -> ClassificationResult` + - Implement `async def classify_document(state: PipelineState, *, classifier: Optional[DocumentClassifierService] = None) -> PipelineState` + - On success: store classification_label, classification_confidence, classification_scores in state, set node_status="completed" + - If all scores ≤ 0.6: set label to "unclassified" and escalate to route_to_queue + - If classifier is None or raises transient error: return transient error state + - _Requirements: 1.1, 1.2, 1.3, 1.6_ + + - [x] 2.2 Add MIME type validation to classify_document + - Before classification, check `state["mime_type"]` against supported set {application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document, text/plain} + - If unsupported: return permanent error with code UNSUPPORTED_FORMAT + - If extracted_text is None or empty: return permanent error with code PARSE_FAILURE + - _Requirements: 1.4, 1.5_ + + - [x]* 2.3 Write property test for classification output validity (Property 1) + - **Property 1: Classification Output Validity** + - Use Hypothesis to generate non-empty text strings; verify classifier always produces exactly one label from the valid set, confidence in [0.0, 1.0], and label is "unclassified" iff all scores ≤ 0.6 + - **Validates: Requirements 1.1, 1.2** + + - [x]* 2.4 Write property test for unsupported MIME rejection (Property 2) + - **Property 2: Unsupported MIME Rejection** + - Use Hypothesis to generate arbitrary MIME type strings not in the supported set; verify the node returns UNSUPPORTED_FORMAT error + - **Validates: Requirements 1.4** + +- [x] 3. Implement type-specific extractors + - [x] 3.1 Implement LoanAgreementExtractor + - Create `src/pipeline/extractors/loan_agreement.py` + - Implement extraction of 9 required fields: borrower_name, lender_name, principal_amount, interest_rate, interest_type, tenure_months, repayment_frequency, processing_fee, penal_rate + - Missing fields → value="not_found", confidence=0.0 + - Normalize monetary values to 2 decimal places + - Normalize rates to annual percentage with 2 decimal places + - Handle conflicting values: pick last-in-document, confidence ≤ 0.5 + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_ + + - [x] 3.2 Implement ModificationExtractor + - Create `src/pipeline/extractors/modification.py` + - Extract per-term-change records: original_loan_reference, modified_field_name, original_value, new_value, effective_date + - Only support: interest_rate, tenure_months, emi_amount, moratorium_period_months — skip unsupported fields + - Group related fields by fact_group_id + - Missing fields → value="not_found", confidence=0.0 + - Normalize rates, tenures, and monetary values appropriately + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_ + + - [x] 3.3 Implement RepaymentExtractor + - Create `src/pipeline/extractors/repayment.py` + - Extract per-row records: payment_date, amount_paid, late_fee_charged, outstanding_balance, row_index (1-based) + - Normalize dates to ISO 8601 (YYYY-MM-DD) + - Normalize monetary fields to 2 decimal places + - Unparseable dates → value="unparseable", confidence=0.0 + - Blank/non-numeric monetary fields → value="not_found", confidence=0.0 + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_ + + - [x] 3.4 Register all extractors in the registry + - Update `src/pipeline/extractors/registry.py` to import and register LoanAgreementExtractor, ModificationExtractor, RepaymentExtractor + - Update `src/pipeline/extractors/__init__.py` with public exports + - _Requirements: 2.1, 3.1, 4.1_ + + - [x]* 3.5 Write property test for extraction schema completeness (Property 3) + - **Property 3: Extraction Schema Completeness** + - For each document type, verify extraction always produces exactly the required field names + - **Validates: Requirements 2.1, 3.1, 4.1** + + - [x]* 3.6 Write property test for missing field handling (Property 4) + - **Property 4: Missing Field Handling** + - Generate documents with deliberately missing fields; verify value="not_found" or "unparseable" with confidence=0.0 + - **Validates: Requirements 2.2, 2.4, 3.5, 4.5, 4.6** + + - [x]* 3.7 Write property test for monetary normalization (Property 5) + - **Property 5: Monetary Normalization** + - Generate monetary strings with various formats; verify output has exactly 2 decimal places + - **Validates: Requirements 2.3, 4.3** + + - [x]* 3.8 Write property test for rate normalization (Property 6) + - **Property 6: Rate Normalization** + - Generate rate values; verify output is annual percentage with exactly 2 decimal places + - **Validates: Requirements 2.5, 3.6** + + - [x]* 3.9 Write property test for confidence score bounds (Property 7) + - **Property 7: Confidence Score Bounds** + - For any extracted fact, verify confidence is in [0.0, 1.0] with ≤ 3 decimal places + - **Validates: Requirements 2.6, 4.7** + + - [x]* 3.10 Write property test for conflict resolution (Property 8) + - **Property 8: Conflict Resolution Picks Last Value** + - Generate documents with multiple conflicting values; verify last-in-document is picked with confidence ≤ 0.5 + - **Validates: Requirements 2.7** + + - [x]* 3.11 Write property test for modification field filtering (Property 9) + - **Property 9: Modification Field Filtering** + - Generate modification documents with unsupported fields; verify they are skipped + - **Validates: Requirements 3.2** + + - [x]* 3.12 Write property test for multi-change cardinality (Property 10) + - **Property 10: Multi-Change Cardinality** + - Generate modification docs with N term changes; verify exactly N fact groups produced + - **Validates: Requirements 3.4** + + - [x]* 3.13 Write property test for date normalization (Property 11) + - **Property 11: Date Normalization to ISO 8601** + - Generate date strings in various formats; verify output matches YYYY-MM-DD + - **Validates: Requirements 4.2** + + - [x]* 3.14 Write property test for sequential row indexing (Property 12) + - **Property 12: Sequential Row Indexing** + - Generate repayment statements with N rows; verify row_index values are 1..N with no gaps/duplicates + - **Validates: Requirements 4.4** + +- [x] 4. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Implement source linker + - [x] 5.1 Create SourceLinker class + - Create `src/pipeline/source_linker.py` + - Define `SourceResolutionError` exception with claim_id, source_location_id, and reason + - Implement `attach(fact: ExtractedFact, document_version_id: str) -> SourceLocation` — validates start_offset < end_offset, creates SourceLocation model instance + - Implement `resolve(source_location: SourceLocation, stored_text: str) -> str` — returns `text[start_offset:end_offset]`, raises SourceResolutionError for out-of-bounds or missing document_version_id + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.6_ + + - [x] 5.2 Create persist_fact utility function + - Add `persist_fact(fact, document_version_id, run_id, document_type, session)` function to `src/pipeline/source_linker.py` + - Maps ExtractedFact → Claim record (using compound claim_type format `{document_type}.{field_name}`) + - Maps SourceSpan → SourceLocation record with FK to claim + - Handles repayment row indexing in claim_type: `repayment_statement.row_{N}.{field_name}` + - _Requirements: 5.1, 5.4_ + + - [x]* 5.3 Write property test for source pointer structural validity (Property 13) + - **Property 13: Source Pointer Structural Validity** + - Generate ExtractedFacts; verify attached source pointers always have start_offset < end_offset and valid references + - **Validates: Requirements 5.1, 5.2** + + - [x]* 5.4 Write property test for source pointer resolution correctness (Property 14) + - **Property 14: Source Pointer Resolution Correctness** + - Generate valid source pointers and text; verify resolve returns exactly `text[start_offset:end_offset]` + - **Validates: Requirements 5.3** + + - [x]* 5.5 Write property test for extraction round-trip (Property 15) + - **Property 15: Extraction Round-Trip** + - Verify that resolving a pointer and re-parsing yields the same value as original extraction + - **Validates: Requirements 5.5** + + - [x]* 5.6 Write property test for source resolution error reporting (Property 16) + - **Property 16: Source Resolution Error Reporting** + - Generate out-of-bounds offsets or invalid document_version_ids; verify SourceResolutionError raised with correct fields + - **Validates: Requirements 5.6** + +- [x] 6. Wire classify_document into the pipeline graph + - [x] 6.1 Update pipeline graph and routing + - Import `classify_document` in `src/pipeline/graph.py` + - Add `"classify_document": classify_document` to `NODES` dict + - Update `PATH_MAPS["extract_text"]` to route `"next"` → `"classify_document"` instead of `"chunk"` + - Add `PATH_MAPS["classify_document"] = {"next": "chunk", "escalate": "route_to_queue", "retry": "classify_document"}` + - Ensure routing.py generates correct routing function for the new node + - _Requirements: 1.1, 1.2_ + + - [x] 6.2 Extend extract_claims to dispatch by document type + - Modify `src/pipeline/nodes/extract_claims.py` to check `state["classification_label"]` + - If label is in EXTRACTOR_REGISTRY: dispatch to type-specific extractor, then run SourceLinker.attach on each fact + - If label is "unclassified" or not in registry: fall back to existing generic extraction logic + - Convert ExtractedFacts to ExtractionResult entries for downstream compatibility + - _Requirements: 2.1, 3.1, 4.1, 5.1_ + +- [x] 7. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Implement synthetic document generator + - [x] 8.1 Create synthetic generator module + - Create `tests/synthetic/__init__.py` and `tests/synthetic/generator.py` + - Define `GroundTruthFact`, `SyntheticDocument`, `ConflictEntry`, `ConflictManifest`, `SyntheticPile` dataclasses + - Implement `SyntheticDocumentGenerator.__init__(seed: Optional[int])` with deterministic RNG + - Implement `generate() -> SyntheticPile` producing exactly 5 documents (≥1 loan, ≥1 modification, ≥1 repayment), 2 factual conflicts, ≥2 formats, chronological coherence, shared loan reference + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8_ + + - [x]* 8.2 Write property test for generator output structure (Property 17) + - **Property 17: Generator Output Structure** + - For any integer seed, verify exactly 5 docs, ≥1 of each type, exactly 2 conflicts, ≥2 formats + - **Validates: Requirements 6.1, 6.2, 6.4** + + - [x]* 8.3 Write property test for conflict manifest validity (Property 18) + - **Property 18: Conflict Manifest Validity** + - Verify each conflict entry references one modification and one repayment filename, with non-empty field_name, expected_value, contradicting_value + - **Validates: Requirements 6.3, 6.6** + + - [x]* 8.4 Write property test for chronological coherence (Property 19) + - **Property 19: Chronological Coherence** + - Verify loan date < modification dates < repayment dates, and shared loan reference + - **Validates: Requirements 6.7** + + - [x]* 8.5 Write property test for generator determinism (Property 20) + - **Property 20: Generator Determinism** + - For any seed, verify two invocations produce byte-identical output + - **Validates: Requirements 6.8** + +- [x] 9. Write test infrastructure and end-to-end provenance test + - [x] 9.1 Create test conftest with Hypothesis strategies + - Create `tests/microfinance/__init__.py` and `tests/microfinance/conftest.py` + - Implement custom Hypothesis strategies: `loan_agreement_text`, `modification_text`, `repayment_text`, `monetary_string`, `date_string`, `source_pointer_and_text` + - Add shared fixtures for classifier mock, extractor instances, and source linker + - _Requirements: 2.1, 3.1, 4.1, 5.1_ + + - [x] 9.2 Organize property test files + - Create `tests/microfinance/test_properties_classifier.py` (Properties 1, 2) + - Create `tests/microfinance/test_properties_extraction.py` (Properties 3–12) + - Create `tests/microfinance/test_properties_source_linker.py` (Properties 13–16) + - Create `tests/microfinance/test_properties_generator.py` (Properties 17–20) + - Wire all property tests to use strategies from conftest + - _Requirements: 1.1–6.8_ + + - [x] 9.3 Implement end-to-end provenance test + - Create `tests/microfinance/test_provenance_e2e.py` + - Ingest all 5 synthetic documents through the pipeline + - Assert: all 5 documents produce ≥1 fact, every fact has exactly 1 source_location, start_offset < end_offset, resolved substring is non-empty, round-trip re-parse yields same value, no SourceResolutionError raised + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_ + +- [x] 10. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- The design uses Python throughout — all implementation uses Python with async/await and Hypothesis for PBT +- The existing `extract_claims` node is extended (not replaced) to support type-specific dispatch while preserving backward compatibility for non-microfinance documents + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "1.2"] }, + { "id": 1, "tasks": ["1.3", "2.1"] }, + { "id": 2, "tasks": ["2.2", "3.1", "3.2", "3.3"] }, + { "id": 3, "tasks": ["2.3", "2.4", "3.4", "5.1"] }, + { "id": 4, "tasks": ["3.5", "3.6", "3.7", "3.8", "3.9", "5.2"] }, + { "id": 5, "tasks": ["3.10", "3.11", "3.12", "3.13", "3.14", "5.3", "5.4"] }, + { "id": 6, "tasks": ["5.5", "5.6", "6.1"] }, + { "id": 7, "tasks": ["6.2"] }, + { "id": 8, "tasks": ["8.1", "9.1"] }, + { "id": 9, "tasks": ["8.2", "8.3", "8.4", "8.5", "9.2"] }, + { "id": 10, "tasks": ["9.3"] } + ] +} +``` diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/.config.kiro b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/.config.kiro new file mode 100644 index 000000000..234cdd3f6 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/.config.kiro @@ -0,0 +1 @@ +{"specId": "a1c142c4-8e14-4649-8fd0-f84e940c94ab", "workflowType": "fast-task", "specType": "feature"} diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/design.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/design.md new file mode 100644 index 000000000..c0be87daa --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/design.md @@ -0,0 +1,586 @@ +# Design Document: Review Interface + +## Overview + +The Review Interface is a React + TypeScript single-page application view that provides compliance reviewers with a master-detail layout for inspecting and deciding on pipeline approval queue items. It consumes existing backend REST endpoints via polling, renders pipeline progress, and enforces atomic per-item decisions with optimistic UI updates. + +**Key principles:** + +- **Polling-driven freshness** — The UI fetches queue and progress data at a configurable interval (default 10s) without WebSocket infrastructure. +- **Optimistic UI** — Decision submissions update local state immediately, rolling back on failure. +- **Atomic isolation** — Each decision targets exactly one item; other items are never modified. +- **Keyboard-first accessibility** — Full keyboard navigation, ARIA labeling, and focus management. +- **Responsive layout** — Master-detail on desktop, stacked single-column on mobile (< 768px breakpoint). + +The interface integrates into the existing React Router multi-page shell and uses Tailwind CSS with Radix UI headless components for accessible, unstyled primitives. + +--- + +## Architecture + +### Component Tree + +``` +App Shell (React Router) +└── /review (route) + └── ReviewPage + ├── TopBar + │ ├── RunSelector + │ └── StatusBadge + ├── ProgressStepper + └── MasterDetail + ├── QueueList (left panel) + │ ├── QueueFilters + │ ├── QueueSortControls + │ ├── QueueItemCard[] (virtualized) + │ └── QueueSummaryBar (total/pending counts) + └── DetailPanel (right panel) + ├── PayloadView + ├── CitationList + │ └── CitationChip[] + ├── SourceLocationTable + └── DecisionControls + ├── ApproveButton + ├── RejectButton + └── JustificationInput +``` + +### Data Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PollingService │ +│ (usePolling hook — configurable interval, visibility-aware)│ +└──────────────┬──────────────────────────────────┬───────────┘ + │ │ + ▼ ▼ +┌──────────────────────────┐ ┌──────────────────────────────┐ +│ QueueStore (Zustand) │ │ RunProgressStore (Zustand) │ +│ - items: QueueItem[] │ │ - current_node: string │ +│ - total: number │ │ - completed_nodes: string[] │ +│ - pending: number │ │ - node_status: string │ +│ - selectedItemId: str │ │ - run_status: string │ +│ - optimisticUpdates: {} │ └──────────────────────────────┘ +└──────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ DecisionService │ +│ POST /items/{id}/decide │ +│ (optimistic + rollback) │ +└──────────────────────────┘ +``` + +--- + +## Components and Interfaces + +### PollingService + +A custom React hook (`usePolling`) that manages data freshness: + +```typescript +interface PollingConfig { + intervalMs: number; // default: 10_000 + enabled: boolean; // tied to document.visibilityState + runId: string | null; +} + +function usePolling(config: PollingConfig): { + isPolling: boolean; + lastFetchedAt: Date | null; + error: Error | null; + connectionLost: boolean; +} +``` + +**Behavior:** +- Fetches `GET /approval/runs/{run_id}/queue` and run progress at `intervalMs` +- Pauses when `document.visibilityState === "hidden"` +- Resumes with immediate fetch when visibility returns to `"visible"` +- On network error: sets `connectionLost = true`, preserves local state, retries next interval +- On success after error: clears `connectionLost`, merges new data + +### QueueStore + +Client-side state management using Zustand: + +```typescript +interface QueueState { + runId: string | null; + items: QueueItem[]; + total: number; + pending: number; + selectedItemId: string | null; + optimisticStatuses: Record; // itemId → optimistic status + filters: QueueFilters; + sortBy: SortField; + sortDirection: "asc" | "desc"; + + // Actions + setRunId: (runId: string) => void; + mergeItems: (items: QueueItem[], total: number, pending: number) => void; + selectItem: (itemId: string) => void; + applyOptimisticUpdate: (itemId: string, status: ItemStatus) => void; + rollbackOptimisticUpdate: (itemId: string) => void; + clearOptimisticUpdate: (itemId: string) => void; + setFilters: (filters: QueueFilters) => void; + setSortBy: (field: SortField, direction: "asc" | "desc") => void; + reset: () => void; +} + +interface QueueFilters { + itemType: ItemType | null; // "finding" | "conflict" | "proposed_update" | null + unverifiableOnly: boolean; +} + +type SortField = "item_type" | "queued_at"; +``` + +**Merge strategy:** When `mergeItems` is called from polling, items are replaced with fresh data but `selectedItemId` and scroll position are preserved. Optimistic statuses override server-reported statuses until cleared. + +### DecisionService + +Handles the decision submission lifecycle: + +```typescript +interface DecisionRequest { + itemId: string; + decision: "approved" | "rejected"; + reviewerId: string; + justification: string; +} + +interface DecisionService { + submit(req: DecisionRequest): Promise; +} +``` + +**Optimistic flow:** +1. Call `applyOptimisticUpdate(itemId, decision)` → UI updates immediately +2. POST to `/approval/items/{item_id}/decide` +3. On success: `clearOptimisticUpdate(itemId)` (server data will match on next poll) +4. On failure: `rollbackOptimisticUpdate(itemId)` → status reverts to "pending", error toast shown +5. On 409: refresh item from server, show "already decided" notification + +### ProgressStepper + +Derives stage status from pipeline state: + +```typescript +interface StageDefinition { + name: string; + nodes: string[]; +} + +const STAGES: StageDefinition[] = [ + { name: "Understand", nodes: ["ingest", "extract_text", "classify_document", "chunk", "embed"] }, + { name: "Examine", nodes: ["extract_claims", "match_rules", "match_rules_against_sources", "merge_findings", "score_confidence"] }, + { name: "Stay-Alive", nodes: ["route_to_queue", "human_review", "finalize"] }, +]; + +type StageStatus = "complete" | "in-progress" | "pending"; + +function deriveStageStatus( + stage: StageDefinition, + completedNodes: string[], + currentNode: string | null +): StageStatus { + const allComplete = stage.nodes.every(n => completedNodes.includes(n)); + if (allComplete) return "complete"; + const hasRunning = currentNode !== null && stage.nodes.includes(currentNode); + if (hasRunning) return "in-progress"; + return "pending"; +} +``` + +### RunSelector + +```typescript +interface RunSelectorProps { + runs: RunSummary[]; + selectedRunId: string | null; + onSelectRun: (runId: string) => void; +} + +interface RunSummary { + id: string; + status: "running" | "completed" | "failed" | "paused"; + started_at: string; +} +``` + +**On run switch:** Calls `QueueStore.reset()` then `QueueStore.setRunId(newRunId)`, triggering an immediate fetch. + +### CitationChip + +```typescript +interface CitationChipProps { + sourceLocation: SourceLocation | null; +} + +// Rendering logic: +// - If sourceLocation is null or sourceLocation.source_span is null: +// → render "[citation unverifiable]" in muted gray +// - If sourceLocation has a valid source_span: +// → render clause_ref (or "p.{page_number}") in standard text color +``` + +### QueueItemCard + +```typescript +interface QueueItemCardProps { + item: QueueItem; + isSelected: boolean; + isLoading: boolean; // true while optimistic update in-flight + onClick: () => void; +} +``` + +Renders: item_type badge, payload summary (first 80 chars), status chip with color mapping. + +### DecisionControls + +```typescript +interface DecisionControlsProps { + item: QueueItem; + onDecide: (decision: "approved" | "rejected", justification: string) => void; + isSubmitting: boolean; +} +``` + +- Shows Approve/Reject buttons only when `item.status === "pending"` +- Requires non-empty justification before enabling submit +- Disables buttons while `isSubmitting` is true + +--- + +## Data Models + +### Frontend TypeScript Types + +```typescript +// Mirrors backend QueueItemResponse +interface QueueItem { + id: string; + run_id: string; + item_type: "finding" | "conflict" | "proposed_update"; + payload: QueueItemPayload; + status: "pending" | "approved" | "rejected"; + queued_at: string; // ISO 8601 + decided_at: string | null; + decision: "approved" | "rejected" | null; + reviewer_id: string | null; + justification: string | null; +} + +interface QueueItemPayload { + summary: string; + details: Record; + source_citations: SourceCitation[]; +} + +interface SourceCitation { + claim_id: string; + claim_text: string; + citation_status: "grounded" | "unverifiable"; + source_location: SourceLocation | null; +} + +interface SourceLocation { + page_number: number | null; + section_id: string | null; + start_offset: number; + end_offset: number; + clause_ref: string | null; +} + +// API responses +interface QueueListResponse { + run_id: string; + items: QueueItem[]; + total: number; + pending: number; +} + +interface DecisionResponse { + item_id: string; + decision: "approved" | "rejected"; + success: boolean; + error: string | null; +} + +// Pipeline progress (from run history or progress endpoint) +interface PipelineProgress { + current_node: string | null; + completed_nodes: string[]; + node_status: "completed" | "skipped" | "error" | null; + run_status: "running" | "completed" | "failed" | "paused"; +} + +// Run summary for selector +interface RunSummary { + id: string; + status: "running" | "completed" | "failed" | "paused"; + started_at: string; + ended_at: string | null; +} +``` + +### Status Color Mapping + +```typescript +const STATUS_COLORS: Record = { + pending: "bg-amber-500/20 text-amber-300 border-amber-500/40", + approved: "bg-green-500/20 text-green-300 border-green-500/40", + rejected: "bg-red-500/20 text-red-300 border-red-500/40", + unverifiable: "bg-gray-500/20 text-gray-400 border-gray-500/40", +}; +``` + +--- + +## API Integration + +### Endpoint Mapping + +| Frontend Action | HTTP Method | Endpoint | Response Type | +|----------------|-------------|----------|---------------| +| Load queue for run | GET | `/approval/runs/{run_id}/queue` | `QueueListResponse` | +| Load item detail | GET | `/approval/items/{item_id}` | `QueueItem` | +| Submit decision | POST | `/approval/items/{item_id}/decide` | `DecisionResponse` | +| Load available runs | POST | `/runs` | `CreateRunResponse` (list variant) | +| Resume a run | POST | `/runs/{run_id}/resume` | `ResumeRunResponse` | +| Load run history/progress | GET | `/runs/{run_id}/history` | `RunHistoryResponse` | + +### Error Handling + +| HTTP Status | Handling | +|-------------|----------| +| 200 | Process response, update store | +| 404 | Item not found — remove from local store, show toast | +| 409 | Item already decided — refresh from server, show notification | +| 5xx | Network/server error — preserve local state, show connection-lost indicator | +| Network failure | Same as 5xx — `connectionLost = true`, retry next interval | + +--- + +## Keyboard Navigation + +| Key | Context | Action | +|-----|---------|--------| +| `↑` / `↓` | Queue List focused | Move selection to previous/next item | +| `Enter` | Queue List item focused | Open item in Detail Panel | +| `a` | Detail Panel focused | Focus Approve button | +| `r` | Detail Panel focused | Focus Reject button | +| `Escape` | Detail Panel focused | Return focus to Queue List | +| `Tab` | Anywhere | Standard tab order through interactive elements | + +Focus management: After a decision is submitted, focus moves to the next pending item in the queue list (skipping decided items). + +--- + +## Responsive Layout + +| Viewport | Layout | Behavior | +|----------|--------|----------| +| ≥ 768px | Side-by-side master-detail | Queue List (40%) + Detail Panel (60%) | +| < 768px | Stacked single-column | Queue List view with "Back" navigation; selecting an item shows Detail view | + +The breakpoint is implemented via Tailwind's `md:` responsive prefix. On mobile, a navigation state (`"list" | "detail"`) controls which panel is visible. + +--- + +## File Structure + +``` +frontend/src/ +├── pages/ +│ └── ReviewPage.tsx +├── components/ +│ └── review/ +│ ├── TopBar.tsx +│ ├── RunSelector.tsx +│ ├── StatusBadge.tsx +│ ├── ProgressStepper.tsx +│ ├── MasterDetail.tsx +│ ├── QueueList.tsx +│ ├── QueueItemCard.tsx +│ ├── QueueFilters.tsx +│ ├── QueueSortControls.tsx +│ ├── QueueSummaryBar.tsx +│ ├── DetailPanel.tsx +│ ├── PayloadView.tsx +│ ├── CitationChip.tsx +│ ├── CitationList.tsx +│ ├── SourceLocationTable.tsx +│ ├── DecisionControls.tsx +│ └── ConnectionLostBanner.tsx +├── hooks/ +│ ├── usePolling.ts +│ ├── useKeyboardNavigation.ts +│ └── useFocusManagement.ts +├── stores/ +│ ├── queueStore.ts +│ └── runProgressStore.ts +├── services/ +│ ├── approvalApi.ts +│ └── decisionService.ts +├── utils/ +│ ├── stageDerivation.ts +│ ├── filterSort.ts +│ └── citationHelpers.ts +└── types/ + └── review.ts +``` + +--- + +## Error Handling + +### Decision Submission Errors + +1. **Network failure during POST**: Rollback optimistic update, show error toast with retry option +2. **HTTP 409 (already decided)**: Fetch fresh item state, update local store, show info notification +3. **HTTP 404 (item not found)**: Remove item from local store, show warning toast +4. **HTTP 5xx**: Rollback optimistic update, show generic server error toast + +### Polling Errors + +1. **Network failure**: Set `connectionLost = true`, display banner, preserve all local state +2. **HTTP 5xx from queue endpoint**: Same as network failure treatment +3. **Recovery**: On next successful poll, clear `connectionLost`, merge fresh data + +### Run Resume Errors + +1. **POST /runs/{run_id}/resume fails**: Show error toast, keep current state unchanged +2. **Success**: Refresh progress stepper and status badge from response + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Citation Chip Rendering Correctness + +*For any* QueueItem payload containing source citations, if a citation has a null source_location or null source_span, the rendered chip SHALL display "[citation unverifiable]"; if it has a valid source_location with a source_span, the rendered chip SHALL display the clause_ref or page reference. + +**Validates: Requirements 1.3, 1.4** + +### Property 2: Queue Item Display Completeness + +*For any* QueueListResponse with N items, the rendered Queue_List SHALL contain exactly N item cards, each displaying the correct item_type, a payload-derived summary, and a status chip whose color matches the item's status, and the summary bar SHALL display the response's total and pending counts. + +**Validates: Requirements 1.1, 1.2, 1.5** + +### Property 3: Filter Correctness + +*For any* queue of items and any applied filter (by item_type or by unverifiable-citation presence), the filtered result SHALL contain exactly those items from the original set that match the filter predicate, and no others. + +**Validates: Requirements 2.1, 2.2** + +### Property 4: Sort Correctness + +*For any* queue of items and any applied sort criterion (item_type or queued_at) with a direction (asc/desc), the resulting list SHALL be ordered according to that criterion and direction. + +**Validates: Requirements 2.3** + +### Property 5: Decision Button Visibility + +*For any* QueueItem displayed in the Detail Panel, the Approve and Reject buttons SHALL be visible if and only if the item's effective status is "pending". + +**Validates: Requirements 3.2, 3.6** + +### Property 6: Justification Required for Decision + +*For any* decision submission attempt, the submission SHALL be blocked (button disabled or form invalid) if the justification text is empty or whitespace-only. + +**Validates: Requirements 3.3** + +### Property 7: Optimistic Update Immediacy + +*For any* decision submission on a pending item, the local item status SHALL transition to the submitted decision value synchronously before the network response is received. + +**Validates: Requirements 4.1** + +### Property 8: Rollback on Decision Failure + +*For any* decision submission where the POST request fails (network error or non-2xx response other than 409), the local item status SHALL revert to "pending" and an error notification SHALL be displayed. + +**Validates: Requirements 4.2** + +### Property 9: Decision Isolation + +*For any* queue containing N items and any single decision submitted on item X, all items Y where Y ≠ X SHALL have unchanged status, payload, and metadata after the decision is processed. + +**Validates: Requirements 5.1, 5.2** + +### Property 10: Progress Stepper Stage Derivation + +*For any* pipeline state (current_node, completed_nodes), each of the three stages (Understand, Examine, Stay-Alive) SHALL be marked "complete" if all its constituent nodes appear in completed_nodes, "in-progress" if current_node is one of its constituent nodes, and "pending" otherwise. + +**Validates: Requirements 6.3, 6.4, 6.5** + +### Property 11: Run Switch Clears State + +*For any* run switch from run A to run B, the queue store SHALL contain zero items from run A after the switch, and all subsequently displayed items SHALL belong exclusively to run B. + +**Validates: Requirements 7.2, 7.4** + +### Property 12: Polling Merge Preserves Selection + +*For any* poll update that returns new queue data while the reviewer has a selected item, if that item still exists in the new data, the selection SHALL be preserved; the reviewer's scroll position SHALL not be disrupted. + +**Validates: Requirements 8.3** + +### Property 13: Network Error Preserves Local State + +*For any* network error during a polling fetch, the local queue state (items, selection, optimistic updates) SHALL remain unchanged, and a connection-lost indicator SHALL be displayed. + +**Validates: Requirements 11.3** + +### Property 14: Focus Advances After Decision + +*For any* successful decision submission, keyboard focus SHALL move to the next item in the Queue_List that has "pending" status, or remain on the current position if no pending items remain. + +**Validates: Requirements 9.4** + +### Property 15: Cross-Run Item Isolation + +*For any* state of the Review Interface with a selected run, every QueueItem displayed in the Queue_List SHALL have a run_id matching the currently selected run. No item from a different run SHALL ever appear in the list. + +**Validates: Requirements 7.4** + + +--- + +## Testing Strategy + +### Unit Tests (Vitest + React Testing Library) + +- **Component rendering**: Verify each component renders correctly given props (QueueItemCard, CitationChip, ProgressStepper, StatusBadge) +- **Decision controls**: Verify button visibility based on item status, justification validation +- **Filter/sort logic**: Verify pure filter and sort utility functions produce correct outputs + +### Property-Based Tests (fast-check + Vitest) + +Properties 1–15 above are implemented as property-based tests with minimum 100 iterations each. Key generators: + +- **QueueItem generator**: Produces items with random item_type, status, payload with 0–5 source citations (mix of grounded/unverifiable) +- **QueueListResponse generator**: Produces responses with 0–50 items, valid total/pending counts +- **PipelineProgress generator**: Produces valid current_node/completed_nodes combinations respecting stage ordering +- **Filter/Sort generator**: Produces random filter and sort combinations + +### Integration Tests (Playwright) + +As specified in Requirement 12: +- Approve flow: select run → select pending item → submit approval → verify status update +- Reject flow: select run → submit rejection → verify isolation (other items unchanged) +- Kill/restart: simulate backend restart → trigger resume → verify persistence and stepper update + +### Accessibility Testing + +- axe-core integration in Vitest for automated ARIA/contrast checks +- Manual testing with screen reader (VoiceOver) for keyboard flow verification +- Focus management verified via unit tests (focus moves to next pending after decision) diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/requirements.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/requirements.md new file mode 100644 index 000000000..35a225eac --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/requirements.md @@ -0,0 +1,157 @@ +# Requirements Document + +## Introduction + +The Review Interface is the primary human-facing surface for the SuperDocs agentic document-intelligence system. It presents Movement 1–3 pipeline outputs (findings, conflicts, and proposed updates) to compliance reviewers for item-by-item approval or rejection. The interface consumes existing backend REST endpoints via polling, renders a professional financial-compliance UI with a master-detail layout, and enforces the human-gate contract: each decision is atomic, independent, and durable. + +## Glossary + +- **Review_Interface**: The React + TypeScript single-page application view that displays approval queue items and accepts reviewer decisions. +- **Queue_List**: The left panel of the master-detail layout showing all approval queue items for the selected run. +- **Detail_Panel**: The right panel of the master-detail layout showing full item payload, source citations, and decision controls for a single selected item. +- **Progress_Stepper**: A visual component displaying pipeline movement completion (Understand → Examine → Stay-Alive) driven by checkpointer state. +- **Run_Selector**: A control in the top app bar allowing the reviewer to switch between pipeline runs. +- **Status_Badge**: A chip-style indicator showing run-level status (running, completed, failed, paused). +- **Citation_Chip**: A visual indicator showing whether a source citation is grounded or unverifiable. +- **Decision_Action**: An approve or reject operation submitted against a single queue item via POST /approval/items/{item_id}/decide. +- **Polling_Service**: A frontend service that fetches queue and run state from existing backend endpoints at a configurable interval. +- **Optimistic_Update**: A UI pattern where the local state reflects a decision immediately before server confirmation, with rollback on failure. +- **Queue_Item**: A single pending approval entry (finding, conflict, or proposed_update) as returned by the backend QueueItemResponse schema. +- **Source_Location**: A precise position within a document (page_number, section_id, start_offset, end_offset, clause_ref) associated with a queue item payload. + +## Requirements + +### Requirement 1: Queue List Display + +**User Story:** As a compliance reviewer, I want to see all pending approval items for a selected run, so that I can understand the scope of work and choose which item to review next. + +#### Acceptance Criteria + +1. WHEN the reviewer selects a run from the Run_Selector, THE Review_Interface SHALL fetch the queue from GET /approval/runs/{run_id}/queue and display all items in the Queue_List. +2. THE Queue_List SHALL display each Queue_Item with its item_type (finding, conflict, or proposed_update), a summary derived from the payload, and a status chip (amber for pending, green for approved, red for rejected). +3. WHEN a Queue_Item payload contains source citations with a null source_span, THE Review_Interface SHALL render a Citation_Chip labeled "[citation unverifiable]" in muted gray. +4. WHEN a Queue_Item payload contains source citations with a valid source_span, THE Review_Interface SHALL render a Citation_Chip labeled with the clause_ref or page reference in standard text color. +5. THE Queue_List SHALL display the total item count and pending item count as returned by the QueueListResponse. + +### Requirement 2: Queue Filtering and Sorting + +**User Story:** As a compliance reviewer, I want to filter and sort the queue by type, severity, or unverifiable status, so that I can prioritize my review workflow. + +#### Acceptance Criteria + +1. THE Review_Interface SHALL provide filter controls that allow the reviewer to filter Queue_Items by item_type (finding, conflict, proposed_update). +2. THE Review_Interface SHALL provide a filter option to show only Queue_Items containing at least one unverifiable citation. +3. THE Review_Interface SHALL provide sort controls that allow the reviewer to sort Queue_Items by item_type or queued_at timestamp. +4. WHEN filters or sort criteria are applied, THE Queue_List SHALL update immediately using client-side filtering and sorting of the fetched data. + +### Requirement 3: Detail View and Single-Item Decision + +**User Story:** As a compliance reviewer, I want to inspect a single item in detail and approve or reject it with justification, so that I can make informed decisions on pipeline outputs. + +#### Acceptance Criteria + +1. WHEN the reviewer selects a Queue_Item from the Queue_List, THE Review_Interface SHALL fetch the full item from GET /approval/items/{item_id} and display its payload, all source citations with Source_Location details, and current status in the Detail_Panel. +2. THE Detail_Panel SHALL display an Approve button and a Reject button for items with pending status. +3. WHEN the reviewer clicks Approve or Reject, THE Review_Interface SHALL require the reviewer to provide a justification text before submission. +4. WHEN the reviewer submits a Decision_Action, THE Review_Interface SHALL send a POST request to /approval/items/{item_id}/decide with the decision value, reviewer_id, and justification. +5. WHEN the backend returns a successful DecisionResponse, THE Review_Interface SHALL update the item status in both the Detail_Panel and the Queue_List. +6. THE Detail_Panel SHALL hide the Approve and Reject buttons for items that have already been decided (status is approved or rejected). + +### Requirement 4: Optimistic UI Updates + +**User Story:** As a compliance reviewer, I want immediate visual feedback when I submit a decision, so that the interface feels responsive even under network latency. + +#### Acceptance Criteria + +1. WHEN the reviewer submits a Decision_Action, THE Review_Interface SHALL immediately update the local item status to the submitted decision value before receiving server confirmation. +2. IF the POST /approval/items/{item_id}/decide request fails, THEN THE Review_Interface SHALL rollback the local item status to pending and display an error notification to the reviewer. +3. WHILE an Optimistic_Update is in-flight, THE Review_Interface SHALL display a loading indicator on the affected Queue_Item. + +### Requirement 5: Approval Gate Isolation + +**User Story:** As a compliance reviewer, I want to be confident that approving or rejecting one item never affects any other item in the queue, so that my decisions are safe and independent. + +#### Acceptance Criteria + +1. WHEN the reviewer approves or rejects a Queue_Item, THE Review_Interface SHALL submit the decision for that single item only, without modifying or discarding other items in the queue. +2. THE Review_Interface SHALL maintain the full queue state for the selected run after each decision, updating only the decided item. +3. IF the backend returns HTTP 409 (item already decided), THEN THE Review_Interface SHALL refresh the item status from the server and display a notification that the item was already decided. + +### Requirement 6: Run Progress Stepper + +**User Story:** As a compliance reviewer, I want to see which pipeline stage is currently executing, so that I understand where the run stands and when items are expected to arrive in the queue. + +#### Acceptance Criteria + +1. THE Progress_Stepper SHALL display three stages: Understand, Examine, and Stay-Alive. +2. THE Progress_Stepper SHALL derive completion state exclusively from checkpointer data returned by the backend (current_node, completed_nodes, node_status fields from pipeline state). +3. WHEN a stage has all constituent nodes completed, THE Progress_Stepper SHALL mark that stage as complete with a visual checkmark. +4. WHEN a stage has at least one node currently running, THE Progress_Stepper SHALL mark that stage as in-progress with an animated indicator. +5. WHEN a stage has not started, THE Progress_Stepper SHALL mark that stage as pending with a muted visual treatment. + +### Requirement 7: Run Selector and Status Badge + +**User Story:** As a compliance reviewer, I want to switch between runs and see each run's overall status, so that I can manage multiple concurrent reviews. + +#### Acceptance Criteria + +1. THE Run_Selector SHALL allow the reviewer to select from available pipeline runs. +2. WHEN the reviewer switches runs, THE Review_Interface SHALL clear the current queue state and fetch the new run's queue and progress data. +3. THE Status_Badge SHALL display the selected run's current status (running, completed, failed, paused) adjacent to the Run_Selector. +4. THE Review_Interface SHALL ensure that queue items displayed belong exclusively to the selected run, preventing cross-run contamination. + +### Requirement 8: Polling and Data Freshness + +**User Story:** As a compliance reviewer, I want the queue and progress to stay current without manual refresh, so that I see newly queued items and status changes from other reviewers promptly. + +#### Acceptance Criteria + +1. THE Polling_Service SHALL fetch GET /approval/runs/{run_id}/queue at a configurable interval (default 10 seconds) while the Review_Interface is active. +2. THE Polling_Service SHALL fetch run progress state at the same configurable interval. +3. WHEN polling returns updated data, THE Review_Interface SHALL merge the new data into the displayed queue without disrupting the reviewer's current selection or scroll position. +4. WHEN the browser tab loses focus, THE Polling_Service SHALL pause polling to reduce unnecessary network requests. +5. WHEN the browser tab regains focus, THE Polling_Service SHALL immediately fetch fresh data and resume the polling interval. + +### Requirement 9: Keyboard Navigation and Accessibility + +**User Story:** As a compliance reviewer, I want to navigate the interface entirely with a keyboard and have proper screen reader support, so that the tool is accessible to all team members. + +#### Acceptance Criteria + +1. THE Review_Interface SHALL provide keyboard navigation to move between Queue_Items in the Queue_List using arrow keys. +2. THE Review_Interface SHALL provide keyboard shortcuts to focus the Approve and Reject buttons from the Detail_Panel. +3. THE Review_Interface SHALL apply ARIA labels to all interactive elements including the Queue_List, Detail_Panel, decision buttons, filter controls, and the Progress_Stepper. +4. THE Review_Interface SHALL manage focus: when a decision is submitted, focus SHALL move to the next pending item in the Queue_List. +5. THE Review_Interface SHALL support a visible focus ring on all focusable elements that meets WCAG 2.1 AA contrast requirements. + +### Requirement 10: Visual Design and Responsiveness + +**User Story:** As a compliance reviewer, I want a professional, distraction-free interface that works on my laptop and tablet, so that I can review documents comfortably in different contexts. + +#### Acceptance Criteria + +1. THE Review_Interface SHALL use a deep navy and charcoal color palette with high-contrast text meeting WCAG 2.1 AA contrast ratios. +2. THE Review_Interface SHALL render in a master-detail split layout: Queue_List on the left, Detail_Panel on the right. +3. WHEN the viewport width is below 768px, THE Review_Interface SHALL collapse the master-detail layout into a single-column stacked view with navigation between list and detail. +4. THE Review_Interface SHALL display status chips using the defined color scheme: amber for pending, green for approved, red for rejected, muted gray for unverifiable. +5. THE Review_Interface SHALL render within the existing multi-page app shell as a route managed by React Router. + +### Requirement 11: Resume After Kill/Restart + +**User Story:** As a compliance reviewer, I want previously submitted decisions to persist and the queue to be accurate after a backend kill/restart, so that my work is never lost. + +#### Acceptance Criteria + +1. WHEN the backend restarts after a kill, THE Review_Interface SHALL recover correct queue state by polling GET /approval/runs/{run_id}/queue, reflecting all previously committed decisions. +2. WHEN the reviewer triggers a run resume via POST /runs/{run_id}/resume, THE Review_Interface SHALL update the Progress_Stepper and Status_Badge to reflect the resumed state. +3. IF the Polling_Service receives a network error during a fetch, THEN THE Review_Interface SHALL display a connection-lost indicator and retry on the next polling interval without discarding local state. + +### Requirement 12: Integration Tests + +**User Story:** As a developer, I want Playwright smoke tests covering the approve/reject flow and the kill/restart resume path, so that regressions in the review interface are caught automatically. + +#### Acceptance Criteria + +1. THE test suite SHALL include a Playwright test that navigates to the Review_Interface, selects a run, selects a pending item, submits an approve decision with justification, and verifies the item status updates to approved. +2. THE test suite SHALL include a Playwright test that navigates to the Review_Interface, selects a run, submits a reject decision, and verifies the item status updates to rejected without affecting other queue items. +3. THE test suite SHALL include a Playwright test that simulates a backend restart (stop/start mock server), triggers a resume, and verifies previously submitted decisions remain intact and the progress stepper reflects the resumed state. diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/tasks.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/tasks.md new file mode 100644 index 000000000..6ab8886ff --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/review-interface/tasks.md @@ -0,0 +1,277 @@ +# Implementation Plan: Review Interface + +## Overview + +Build the compliance review interface as a React + TypeScript SPA view integrated into the existing app shell. The implementation proceeds from foundational types and stores through UI components, hooks, and services, culminating in accessibility wiring and integration tests. Each step builds incrementally on prior work, ensuring no orphaned code. + +## Tasks + +- [x] 1. Set up project structure, types, and tooling + - [x] 1.1 Initialize frontend project with Vite, React, TypeScript, Tailwind CSS, and install dependencies + - Initialize Vite project in `frontend/` with React + TypeScript template + - Install dependencies: `zustand`, `@radix-ui/react-select`, `@radix-ui/react-dialog`, `@radix-ui/react-tooltip`, `tailwindcss`, `postcss`, `autoprefixer`, `react-router-dom` + - Install dev dependencies: `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `fast-check`, `jsdom`, `@playwright/test`, `axe-core`, `vitest-axe` + - Configure `tailwind.config.ts` with the deep navy/charcoal color palette and custom status colors + - Configure `vitest.config.ts` with jsdom environment and setup file + - _Requirements: 10.1_ + + - [x] 1.2 Create shared TypeScript types and constants + - Create `frontend/src/types/review.ts` with all interfaces: `QueueItem`, `QueueItemPayload`, `SourceCitation`, `SourceLocation`, `QueueListResponse`, `DecisionResponse`, `PipelineProgress`, `RunSummary`, `QueueFilters`, `SortField` + - Create `frontend/src/utils/constants.ts` with `STATUS_COLORS` mapping, `STAGES` array for progress stepper, and polling default interval + - _Requirements: 1.2, 6.1, 10.4_ + +- [x] 2. Implement state management stores + - [x] 2.1 Implement the queue store with Zustand + - Create `frontend/src/stores/queueStore.ts` implementing `QueueState` interface from design + - Implement actions: `setRunId`, `mergeItems`, `selectItem`, `applyOptimisticUpdate`, `rollbackOptimisticUpdate`, `clearOptimisticUpdate`, `setFilters`, `setSortBy`, `reset` + - Merge strategy: replace items but preserve `selectedItemId` and optimistic overrides + - _Requirements: 1.1, 4.1, 4.2, 5.2, 7.2, 8.3_ + + - [x] 2.2 Write property tests for queue store + - **Property 9: Decision Isolation** — submitting a decision on item X leaves all other items unchanged + - **Property 11: Run Switch Clears State** — switching runs clears all items from prior run + - **Property 12: Polling Merge Preserves Selection** — merging new poll data preserves selectedItemId + - **Validates: Requirements 5.1, 5.2, 7.2, 7.4, 8.3** + + - [x] 2.3 Implement the run progress store with Zustand + - Create `frontend/src/stores/runProgressStore.ts` with state: `currentNode`, `completedNodes`, `nodeStatus`, `runStatus`, `runs` + - Implement actions: `setProgress`, `setRuns`, `setSelectedRunId`, `reset` + - _Requirements: 6.2, 7.1, 7.3_ + +- [x] 3. Implement API service and decision service layers + - [x] 3.1 Create the approval API service + - Create `frontend/src/services/approvalApi.ts` with typed fetch wrappers for all endpoints: `fetchQueue(runId)`, `fetchItem(itemId)`, `submitDecision(itemId, request)`, `fetchRuns()`, `resumeRun(runId)`, `fetchRunProgress(runId)` + - Implement error classification: 404, 409, 5xx, and network failure handling + - _Requirements: 1.1, 3.1, 3.4, 7.1, 11.2_ + + - [x] 3.2 Create the decision service with optimistic update lifecycle + - Create `frontend/src/services/decisionService.ts` implementing the 5-step optimistic flow from design + - Step 1: `applyOptimisticUpdate` → Step 2: POST → Step 3: success `clearOptimisticUpdate` → Step 4: failure `rollbackOptimisticUpdate` + toast → Step 5: 409 refresh item + - _Requirements: 3.4, 3.5, 4.1, 4.2, 5.1, 5.3_ + + - [x] 3.3 Write property tests for decision service + - **Property 7: Optimistic Update Immediacy** — local status transitions before network response + - **Property 8: Rollback on Decision Failure** — status reverts to pending on POST failure + - **Validates: Requirements 4.1, 4.2** + +- [x] 4. Implement utility functions + - [x] 4.1 Create stage derivation utility + - Create `frontend/src/utils/stageDerivation.ts` with `deriveStageStatus(stage, completedNodes, currentNode)` function per design specification + - Return "complete" | "in-progress" | "pending" for each stage + - _Requirements: 6.3, 6.4, 6.5_ + + - [x] 4.2 Write property test for stage derivation + - **Property 10: Progress Stepper Stage Derivation** — stages correctly marked complete/in-progress/pending based on node state + - **Validates: Requirements 6.3, 6.4, 6.5** + + - [x] 4.3 Create filter and sort utility functions + - Create `frontend/src/utils/filterSort.ts` with `applyFilters(items, filters)` and `applySorting(items, sortBy, direction)` functions + - Filter by `item_type` and by `unverifiableOnly` (items with at least one citation where `citation_status === "unverifiable"`) + - Sort by `item_type` alphabetically or `queued_at` chronologically + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [x] 4.4 Write property tests for filter and sort utilities + - **Property 3: Filter Correctness** — filtered result contains exactly matching items and no others + - **Property 4: Sort Correctness** — result is ordered according to criterion and direction + - **Validates: Requirements 2.1, 2.2, 2.3** + + - [x] 4.5 Create citation helper utilities + - Create `frontend/src/utils/citationHelpers.ts` with `getCitationLabel(citation)` and `isUnverifiable(citation)` functions + - Return "[citation unverifiable]" for null source_location or null source_span; clause_ref or "p.{page_number}" otherwise + - _Requirements: 1.3, 1.4_ + + - [x] 4.6 Write property test for citation helpers + - **Property 1: Citation Chip Rendering Correctness** — label matches source_location/source_span presence + - **Validates: Requirements 1.3, 1.4** + +- [x] 5. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 6. Implement core UI components (atoms) + - [x] 6.1 Implement StatusBadge component + - Create `frontend/src/components/review/StatusBadge.tsx` rendering run status chip with color from `STATUS_COLORS` + - Accept `status: "running" | "completed" | "failed" | "paused"` prop + - Apply ARIA label describing the status + - _Requirements: 7.3, 9.3, 10.4_ + + - [x] 6.2 Implement CitationChip component + - Create `frontend/src/components/review/CitationChip.tsx` using `getCitationLabel` utility + - Render muted gray styling for unverifiable, standard text for grounded + - _Requirements: 1.3, 1.4, 10.4_ + + - [x] 6.3 Implement QueueItemCard component + - Create `frontend/src/components/review/QueueItemCard.tsx` rendering item_type badge, payload summary (first 80 chars), status chip, and loading indicator for in-flight optimistic updates + - Accept `item`, `isSelected`, `isLoading`, `onClick` props + - Apply ARIA attributes: `role="option"`, `aria-selected`, `aria-busy` + - _Requirements: 1.2, 4.3, 9.3_ + + - [x] 6.4 Implement ProgressStepper component + - Create `frontend/src/components/review/ProgressStepper.tsx` displaying three stages using `deriveStageStatus` + - Render checkmark for complete, animated spinner for in-progress, muted circle for pending + - Apply ARIA `role="progressbar"` or `role="list"` with stage status labels + - _Requirements: 6.1, 6.3, 6.4, 6.5, 9.3_ + + - [x] 6.5 Implement ConnectionLostBanner component + - Create `frontend/src/components/review/ConnectionLostBanner.tsx` displaying a dismissible warning banner when `connectionLost` is true + - Apply ARIA `role="alert"` for screen reader announcement + - _Requirements: 11.3, 9.3_ + +- [x] 7. Implement composite UI components + - [x] 7.1 Implement RunSelector component + - Create `frontend/src/components/review/RunSelector.tsx` using Radix UI Select primitive + - Display available runs with status indicators, trigger `onSelectRun` callback + - Apply ARIA label "Select pipeline run" + - _Requirements: 7.1, 7.2, 9.3_ + + - [x] 7.2 Implement QueueFilters and QueueSortControls components + - Create `frontend/src/components/review/QueueFilters.tsx` with item_type filter (dropdown) and unverifiable-only toggle + - Create `frontend/src/components/review/QueueSortControls.tsx` with sort field and direction controls + - Both update queueStore on change; apply ARIA labels to all controls + - _Requirements: 2.1, 2.2, 2.3, 9.3_ + + - [x] 7.3 Implement DecisionControls component + - Create `frontend/src/components/review/DecisionControls.tsx` with Approve/Reject buttons and JustificationInput textarea + - Show buttons only when `item.status === "pending"`; disable submit when justification is empty + - Wire `onDecide` callback; show loading state while submitting + - _Requirements: 3.2, 3.3, 3.6, 9.2_ + + - [x] 7.4 Write property tests for DecisionControls + - **Property 5: Decision Button Visibility** — buttons visible iff status is pending + - **Property 6: Justification Required** — submission blocked when justification empty + - **Validates: Requirements 3.2, 3.3, 3.6** + + - [x] 7.5 Implement DetailPanel component + - Create `frontend/src/components/review/DetailPanel.tsx` composing PayloadView, CitationList, SourceLocationTable, and DecisionControls + - Create `frontend/src/components/review/PayloadView.tsx` rendering full item payload details + - Create `frontend/src/components/review/CitationList.tsx` rendering array of CitationChip components + - Create `frontend/src/components/review/SourceLocationTable.tsx` rendering source location metadata in a table + - _Requirements: 3.1, 1.3, 1.4_ + + - [x] 7.6 Implement QueueList component with QueueSummaryBar + - Create `frontend/src/components/review/QueueList.tsx` rendering filtered/sorted QueueItemCards with virtualization + - Create `frontend/src/components/review/QueueSummaryBar.tsx` displaying total and pending counts + - Apply `role="listbox"` with `aria-label="Approval queue items"` + - _Requirements: 1.1, 1.5, 2.4, 9.3_ + + - [x] 7.7 Write property test for QueueList rendering + - **Property 2: Queue Item Display Completeness** — rendered list contains exactly N cards matching response data + - **Property 15: Cross-Run Item Isolation** — all displayed items have run_id matching selected run + - **Validates: Requirements 1.1, 1.2, 1.5, 7.4** + +- [x] 8. Implement hooks + - [x] 8.1 Implement usePolling hook + - Create `frontend/src/hooks/usePolling.ts` implementing visibility-aware polling per design spec + - Fetch queue and progress at configurable interval; pause on `document.hidden`; resume with immediate fetch on visibility return + - On network error: set `connectionLost`, preserve local state, retry next interval + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 11.3_ + + - [x] 8.2 Write property test for polling behavior + - **Property 13: Network Error Preserves Local State** — local queue state unchanged on network error, connectionLost indicator displayed + - **Validates: Requirements 11.3** + + - [x] 8.3 Implement useKeyboardNavigation hook + - Create `frontend/src/hooks/useKeyboardNavigation.ts` handling arrow keys for queue traversal, Enter for selection, `a`/`r` shortcuts for Approve/Reject focus, Escape to return to list + - _Requirements: 9.1, 9.2_ + + - [x] 8.4 Implement useFocusManagement hook + - Create `frontend/src/hooks/useFocusManagement.ts` managing focus advancement after decision submission (move to next pending item) + - Implement visible focus ring styling via Tailwind `ring-2 ring-offset-2` meeting WCAG 2.1 AA + - _Requirements: 9.4, 9.5_ + + - [x] 8.5 Write property test for focus advancement + - **Property 14: Focus Advances After Decision** — focus moves to next pending item after successful decision + - **Validates: Requirements 9.4** + +- [x] 9. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 10. Assemble page layout and routing + - [x] 10.1 Implement MasterDetail layout component + - Create `frontend/src/components/review/MasterDetail.tsx` with responsive split: 40%/60% on desktop (`md:` breakpoint), stacked with navigation state on mobile + - Implement mobile navigation state (`"list" | "detail"`) with back button + - _Requirements: 10.2, 10.3_ + + - [x] 10.2 Implement TopBar component + - Create `frontend/src/components/review/TopBar.tsx` composing RunSelector and StatusBadge in a horizontal bar + - _Requirements: 7.1, 7.3_ + + - [x] 10.3 Implement ReviewPage and wire into React Router + - Create `frontend/src/pages/ReviewPage.tsx` composing TopBar, ProgressStepper, MasterDetail (QueueList + DetailPanel) + - Connect all stores, hooks (usePolling, useKeyboardNavigation, useFocusManagement), and services + - Register `/review` route in the app shell's router configuration + - _Requirements: 10.5, 7.2, 8.1_ + + - [x] 10.4 Write unit tests for ReviewPage integration + - Test that ReviewPage renders all sub-components correctly + - Test that run switching triggers queue reset and refetch + - Test responsive layout breakpoint behavior + - _Requirements: 10.2, 10.3, 7.2_ + +- [x] 11. Implement resume and error recovery flows + - [x] 11.1 Implement run resume functionality + - Add resume button/action in TopBar that calls `POST /runs/{run_id}/resume` + - On success: update ProgressStepper and StatusBadge from response + - On failure: show error toast, preserve current state + - _Requirements: 11.2_ + + - [x] 11.2 Implement 409 conflict handling + - In decision service: detect 409 response, fetch fresh item state from `GET /approval/items/{item_id}`, update store, show "already decided" notification + - _Requirements: 5.3_ + +- [x] 12. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 13. Integration tests with Playwright + - [x] 13.1 Set up Playwright test infrastructure + - Create `frontend/e2e/` directory with Playwright config + - Create mock server utility for simulating backend API responses (queue, items, decisions, runs, progress) + - Configure test fixtures with sample queue data (mix of pending/approved/rejected items with grounded and unverifiable citations) + - _Requirements: 12.1, 12.2, 12.3_ + + - [x] 13.2 Write Playwright test for approve flow + - Navigate to `/review`, select a run, select a pending item, enter justification, click Approve, verify item status updates to approved in both detail panel and queue list + - **Validates: Requirements 12.1** + + - [x] 13.3 Write Playwright test for reject flow with isolation verification + - Navigate to `/review`, select a run, submit a reject decision, verify item status updates to rejected, verify all other queue items remain unchanged + - **Validates: Requirements 12.2** + + - [x] 13.4 Write Playwright test for kill/restart resume path + - Simulate backend restart (stop mock server, restart), trigger resume action, verify previously submitted decisions remain intact, verify progress stepper reflects resumed state + - **Validates: Requirements 12.3** + +- [x] 14. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- The frontend project is initialized from scratch in `frontend/` since only a README placeholder exists +- All components use Radix UI headless primitives for accessibility and Tailwind CSS for styling +- The existing backend API endpoints (`/approval/*`, `/runs/*`) are consumed as-is + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["1.2"] }, + { "id": 2, "tasks": ["2.1", "2.3", "4.1", "4.3", "4.5"] }, + { "id": 3, "tasks": ["2.2", "4.2", "4.4", "4.6", "3.1"] }, + { "id": 4, "tasks": ["3.2", "3.3"] }, + { "id": 5, "tasks": ["6.1", "6.2", "6.3", "6.4", "6.5"] }, + { "id": 6, "tasks": ["7.1", "7.2", "7.3", "7.5", "7.6"] }, + { "id": 7, "tasks": ["7.4", "7.7", "8.1", "8.3", "8.4"] }, + { "id": 8, "tasks": ["8.2", "8.5"] }, + { "id": 9, "tasks": ["10.1", "10.2"] }, + { "id": 10, "tasks": ["10.3", "11.1", "11.2"] }, + { "id": 11, "tasks": ["10.4", "13.1"] }, + { "id": 12, "tasks": ["13.2", "13.3", "13.4"] } + ] +} +``` diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/.config.kiro b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/.config.kiro new file mode 100644 index 000000000..8e9218a5c --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/.config.kiro @@ -0,0 +1 @@ +{"specId": "b389f833-f42a-4906-bcc0-72e254112b92", "workflowType": "fast-task", "specType": "feature"} \ No newline at end of file diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/design.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/design.md new file mode 100644 index 000000000..8eab7bc08 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/design.md @@ -0,0 +1,780 @@ +# Design Document: Rules Checking Stage + +## Overview + +This design specifies the `match_rules_against_sources` node — a parallel companion to the existing `match_rules` node in the Examine Stage. While `match_rules` evaluates extracted claims against compliance rules, the new node evaluates rules directly against source document spans. Rules are authored in YAML playbooks, validated via Pydantic at load time, and evaluated via LLM (default) or deterministic structured checks (opt-in). + +**Key principles:** + +- **Parallel execution** — Both `match_rules` and `match_rules_against_sources` run concurrently after `extract_claims`, converging before `score_confidence`. +- **Honest output** — Findings are produced only on actual violations (`verdict == "fail"`). No padding, no forced findings. +- **Exact citations** — Every finding carries the precise source span (start/end offsets + text) that triggered it. +- **Extensibility** — New rules are added by editing YAML playbooks; no Python code changes required. +- **Dual evaluation** — LLM-based evaluation (default) for complex regulatory language; structured evaluation (opt-in) for deterministic, reproducible checks. + +--- + +## Architecture + +### Updated Pipeline Graph Topology + +```mermaid +graph TD + extract_claims["extract_claims"] --> load_playbook["load_playbook"] + load_playbook --> partition["partition_rules_by_scope"] + + partition --> match_rules["match_rules (claims)"] + partition --> match_rules_against_sources["match_rules_against_sources (source spans)"] + + match_rules --> merge_findings["merge_findings"] + match_rules_against_sources --> merge_findings + + merge_findings --> score_confidence["score_confidence"] +``` + +The `load_playbook` and `partition_rules_by_scope` steps are implemented as the entry logic of a fan-out subgraph. LangGraph's `Send` API or a branching conditional edge dispatches rules to the appropriate node(s). The `merge_findings` step collects results from both branches before continuing to `score_confidence`. + +### Graph Integration Strategy + +The pipeline graph modifications: + +1. **New node**: `match_rules_against_sources` added to `NODES` dict +2. **Modified routing**: After `extract_claims`, a fan-out dispatches to both `match_rules` and `match_rules_against_sources` in parallel +3. **New merge node**: `merge_findings` collects outputs from both rule-checking nodes +4. **Updated PATH_MAPS**: `extract_claims` routes to the fan-out; `merge_findings` routes to `score_confidence` + +```python +# Updated PATH_MAPS (conceptual) +PATH_MAPS["extract_claims"] = { + "next": "fan_out_rules", # dispatches to both match_rules nodes + "retry": "extract_claims", + "escalate": "route_to_queue", +} +PATH_MAPS["merge_findings"] = { + "next": "score_confidence", + "escalate": "route_to_queue", +} +``` + +--- + +## Components and Interfaces + +### Component 1: Playbook Schema (Pydantic Model) + +```python +from typing import Literal, Optional +from pydantic import BaseModel, Field, field_validator + + +class RuleDefinition(BaseModel): + """A single compliance rule within a playbook.""" + + id: str = Field(..., description="Unique rule identifier") + description: str = Field(..., description="Human-readable rule description") + check_description: str = Field( + ..., description="Detailed instructions for evaluation" + ) + scope: Literal["claims", "source", "both"] = Field( + ..., description="Which pipeline path evaluates this rule" + ) + check_type: Literal["llm", "structured"] = Field( + default="llm", description="Evaluation method" + ) + + @field_validator("id") + @classmethod + def id_not_empty(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Rule id must not be empty") + return v + + +class Playbook(BaseModel): + """A validated playbook loaded from YAML.""" + + playbook_id: str = Field(..., description="Matches the whitelist key") + name: str = Field(..., description="Human-readable playbook name") + version: str = Field(default="1.0", description="Playbook version") + rules: list[RuleDefinition] = Field( + ..., description="Unbounded list of compliance rules" + ) + + @field_validator("rules") + @classmethod + def rules_have_unique_ids(cls, v: list[RuleDefinition]) -> list[RuleDefinition]: + ids = [r.id for r in v] + if len(ids) != len(set(ids)): + raise ValueError("Rule ids must be unique within a playbook") + return v +``` + +### Playbook YAML Schema + +```yaml +# rules/microfinance_v1.yaml +playbook_id: microfinance_v1 +name: "Microfinance Compliance Rules v1" +version: "1.0" +rules: + - id: "MF-001" + description: "APR must not exceed 36%" + check_description: > + Check if the stated annual percentage rate (APR) exceeds 36%. + Look for interest rate declarations in loan terms. + scope: source + check_type: llm + + - id: "MF-002" + description: "Processing fee must be disclosed" + check_description: > + Verify that a processing fee amount is explicitly stated in the document. + scope: both + + - id: "MF-003" + description: "Interest rate matches modification" + check_description: > + The interest rate in repayment statements must match the rate in the + most recent modification agreement. + scope: claims + check_type: structured +``` + +--- + +### Component 2: Playbook Loader + +```python +from pathlib import Path +from typing import Optional + +# Whitelist of known playbook IDs → filenames +PLAYBOOK_WHITELIST: dict[str, str] = { + "microfinance_v1": "microfinance_v1.yaml", + "consumer_lending_v1": "consumer_lending_v1.yaml", +} + +RULES_DIR = Path("rules") + + +class PlaybookLoadError(Exception): + """Raised when playbook loading fails (permanent error).""" + pass + + +async def load_playbook(playbook_id: str) -> Playbook: + """Resolve, load, and validate a playbook by ID. + + Args: + playbook_id: Must match a key in PLAYBOOK_WHITELIST. + + Returns: + Validated Playbook model. + + Raises: + PlaybookLoadError: If playbook_id is unknown or YAML is invalid. + """ + if playbook_id not in PLAYBOOK_WHITELIST: + raise PlaybookLoadError( + f"Unknown playbook_id '{playbook_id}'. " + f"Valid options: {list(PLAYBOOK_WHITELIST.keys())}" + ) + + yaml_path = RULES_DIR / PLAYBOOK_WHITELIST[playbook_id] + if not yaml_path.exists(): + raise PlaybookLoadError(f"Playbook file not found: {yaml_path}") + + import yaml + with open(yaml_path) as f: + raw = yaml.safe_load(f) + + try: + return Playbook.model_validate(raw) + except Exception as e: + raise PlaybookLoadError(f"Playbook validation failed: {e}") from e +``` + +--- + +### Component 3: Rule Scope Partitioner + +```python +from dataclasses import dataclass + + +@dataclass +class PartitionedRules: + """Rules partitioned by evaluation target.""" + + claims_rules: list[RuleDefinition] # scope == "claims" or "both" + source_rules: list[RuleDefinition] # scope == "source" or "both" + + +def partition_rules(playbook: Playbook) -> PartitionedRules: + """Partition playbook rules by scope for routing to correct nodes. + + Rules with scope "both" appear in both lists. + + Args: + playbook: Validated Playbook model. + + Returns: + PartitionedRules with claims_rules and source_rules. + """ + claims_rules: list[RuleDefinition] = [] + source_rules: list[RuleDefinition] = [] + + for rule in playbook.rules: + if rule.scope in ("claims", "both"): + claims_rules.append(rule) + if rule.scope in ("source", "both"): + source_rules.append(rule) + + return PartitionedRules( + claims_rules=claims_rules, + source_rules=source_rules, + ) +``` + +--- + +### Component 4: Finding Data Model + +```python +from dataclasses import dataclass +from typing import Literal + + +FindingVerdict = Literal["pass", "fail", "not_applicable", "insufficient_evidence"] +EvaluationMethod = Literal["llm", "structured"] + + +@dataclass +class CitedSpan: + """Exact source location cited by a finding.""" + + start_offset: int # 0-based, inclusive + end_offset: int # 0-based, exclusive + text: str # exact text at [start_offset:end_offset] + + +@dataclass +class EvaluationResult: + """Result from evaluating a single rule against a single span.""" + + rule_id: str + verdict: FindingVerdict + cited_span: CitedSpan + explanation: str + evaluation_method: EvaluationMethod + + +@dataclass +class Finding: + """A confirmed rule violation with full provenance. + + Only produced when verdict == "fail". + """ + + rule_id: str + verdict: Literal["fail"] # always "fail" — findings are violations only + cited_span: CitedSpan + explanation: str + evaluation_method: EvaluationMethod +``` + +--- + +### Component 5: Evaluator Protocol and Implementations + +```python +from typing import Protocol + + +class RuleEvaluator(Protocol): + """Protocol for evaluating a rule against a source span.""" + + async def evaluate( + self, + rule: RuleDefinition, + span_text: str, + span_offset: int, + ) -> EvaluationResult: + """Evaluate a single rule against a source span. + + Args: + rule: The rule to evaluate. + span_text: The text content of the source span. + span_offset: The start offset of the span in the original document. + + Returns: + EvaluationResult with verdict, cited_span, and explanation. + """ + ... + + +class LLMEvaluator: + """Evaluates rules using LLM with structured output.""" + + async def evaluate( + self, + rule: RuleDefinition, + span_text: str, + span_offset: int, + ) -> EvaluationResult: + """Send rule + span to LLM, parse structured response. + + The LLM prompt includes: + - Rule description and check_description + - The source span text + - Instructions to return verdict, cited_span, and explanation + + Returns EvaluationResult with evaluation_method="llm". + Raises Exception on API failure (caught by node as transient error). + """ + ... + + async def evaluate_batch( + self, + rules: list[RuleDefinition], + span_text: str, + span_offset: int, + ) -> list[EvaluationResult]: + """Evaluate multiple rules against a single span in one LLM call. + + Reduces API calls by batching rules per span. + """ + ... + + +class StructuredEvaluator: + """Evaluates rules using deterministic structured logic.""" + + # Registry of supported check_descriptions + SUPPORTED_CHECKS: dict[str, callable] = {} + + def supports(self, rule: RuleDefinition) -> bool: + """Return True if this evaluator can handle the rule.""" + return rule.check_description in self.SUPPORTED_CHECKS + + async def evaluate( + self, + rule: RuleDefinition, + span_text: str, + span_offset: int, + ) -> EvaluationResult: + """Execute deterministic check logic. + + Returns EvaluationResult with evaluation_method="structured". + """ + ... +``` + +--- + +### Component 6: `match_rules_against_sources` Node + +```python +import logging + +logger = logging.getLogger(__name__) + + +async def match_rules_against_sources( + state: PipelineState, + *, + llm_evaluator: LLMEvaluator | None = None, + structured_evaluator: StructuredEvaluator | None = None, +) -> PipelineState: + """Evaluate rules directly against source document spans. + + Reads source_rules from state (populated by the partitioner), + evaluates each rule against available source spans, and produces + findings only for violations (verdict == "fail"). + + Args: + state: Pipeline state with source_rules and source spans. + llm_evaluator: LLM-based evaluator (required for llm rules). + structured_evaluator: Structured evaluator (optional, falls back to LLM). + + Returns: + Updated PipelineState with findings list populated. + """ + source_rules = state.get("source_rules", []) + chunks = state.get("chunks", []) + + # No applicable rules → empty findings, completed + if not source_rules: + return _completed_state(state, findings=[]) + + # No source spans available → empty findings, completed + if not chunks: + return _completed_state(state, findings=[]) + + findings: list[Finding] = [] + + try: + for chunk in chunks: + span_text = chunk["text"] + span_offset = chunk["start_offset"] + + # Partition rules for this span by check_type + llm_rules = [] + structured_rules = [] + + for rule in source_rules: + if rule.check_type == "structured": + if structured_evaluator and structured_evaluator.supports(rule): + structured_rules.append(rule) + else: + # Fall back to LLM for unsupported structured rules + logger.warning( + f"Structured evaluator does not support rule " + f"'{rule.id}', falling back to LLM" + ) + llm_rules.append(rule) + else: + llm_rules.append(rule) + + # Batch LLM evaluation (multiple rules per span) + if llm_rules and llm_evaluator: + results = await llm_evaluator.evaluate_batch( + llm_rules, span_text, span_offset + ) + for result in results: + if result.verdict == "fail": + findings.append(_result_to_finding(result)) + + # Individual structured evaluations + for rule in structured_rules: + result = await structured_evaluator.evaluate( + rule, span_text, span_offset + ) + if result.verdict == "fail": + findings.append(_result_to_finding(result)) + + except Exception as exc: + # LLM API failure → transient error + return _transient_error_state(state, str(exc)) + + return _completed_state(state, findings=findings) + + +def _result_to_finding(result: EvaluationResult) -> Finding: + """Convert a failing EvaluationResult to a Finding.""" + return Finding( + rule_id=result.rule_id, + verdict="fail", + cited_span=result.cited_span, + explanation=result.explanation, + evaluation_method=result.evaluation_method, + ) + + +def _completed_state(state: PipelineState, *, findings: list[Finding]) -> PipelineState: + """Return completed state with findings.""" + completed_nodes = list(state.get("completed_nodes", [])) + completed_nodes.append("match_rules_against_sources") + return PipelineState( + **{ + **state, + "findings": findings, + "current_node": "match_rules_against_sources", + "node_status": "completed", + "error_type": None, + "error_detail": None, + "completed_nodes": completed_nodes, + } + ) + + +def _transient_error_state(state: PipelineState, detail: str) -> PipelineState: + """Return transient error state.""" + return PipelineState( + **{ + **state, + "current_node": "match_rules_against_sources", + "node_status": "error", + "error_type": "transient", + "error_detail": f"LLM API failure: {detail}", + "completed_nodes": list(state.get("completed_nodes", [])), + } + ) +``` + +--- + +### Component 7: Pipeline State Extension + +```python +# New fields added to PipelineState TypedDict + +class PipelineState(TypedDict): + # ... existing fields ... + + # Rules Checking Stage additions + playbook_id: Optional[str] # Set at run init, stored on run record + source_rules: list[dict] # Rules routed to match_rules_against_sources + claims_rules: list[dict] # Rules routed to match_rules + findings: list[dict] # Merged findings from both rule-checking nodes +``` + +The `findings` field is a list of serialized `Finding` dicts. After the merge step, it contains findings from both `match_rules` (claim-based) and `match_rules_against_sources` (span-based). + +--- + +### Component 8: Findings Merge Node + +```python +async def merge_findings(state: PipelineState) -> PipelineState: + """Merge findings from both rule-checking nodes. + + Concatenates claim-based findings and source-based findings + into a single list. No deduplication — both perspectives are valid. + + Args: + state: Pipeline state after both match_rules nodes complete. + + Returns: + Updated state with merged findings list. + """ + claim_findings = state.get("claim_findings", []) + source_findings = state.get("source_findings", []) + + merged = claim_findings + source_findings + + completed_nodes = list(state.get("completed_nodes", [])) + completed_nodes.append("merge_findings") + + return PipelineState( + **{ + **state, + "findings": merged, + "current_node": "merge_findings", + "node_status": "completed", + "error_type": None, + "error_detail": None, + "completed_nodes": completed_nodes, + } + ) +``` + +--- + +## Data Models + +### Finding Schema (Serialized) + +```python +# JSON representation stored in PipelineState["findings"] +{ + "rule_id": "MF-001", + "verdict": "fail", + "cited_span": { + "start_offset": 245, + "end_offset": 289, + "text": "annual interest rate of 42.00%" + }, + "explanation": "The stated APR of 42% exceeds the 36% regulatory maximum.", + "evaluation_method": "llm" +} +``` + +### EvaluationResult Schema (Internal) + +```python +{ + "rule_id": "MF-001", + "verdict": "fail" | "pass" | "not_applicable" | "insufficient_evidence", + "cited_span": { + "start_offset": int, + "end_offset": int, + "text": str + }, + "explanation": str, + "evaluation_method": "llm" | "structured" +} +``` + +### Playbook File Discovery + +| Playbook ID | File Path | +|-------------|-----------| +| `microfinance_v1` | `rules/microfinance_v1.yaml` | +| `consumer_lending_v1` | `rules/consumer_lending_v1.yaml` | + +New playbooks are added by: +1. Creating a YAML file under `rules/` +2. Adding the mapping to `PLAYBOOK_WHITELIST` + +--- + +## Error Handling + +### Error Classification + +| Condition | Error Type | Behavior | +|-----------|-----------|----------| +| Unknown playbook_id | `permanent` | Stop immediately, no retry | +| YAML file missing | `permanent` | Stop immediately, no retry | +| YAML schema validation failure | `permanent` | Stop immediately, include validation details | +| LLM API call failure | `transient` | Retry up to `max_retries` | +| No source spans available | N/A (completed) | Return empty findings, status "completed" | +| No applicable source rules | N/A (completed) | Return empty findings, status "completed" | +| Structured evaluator unsupported | N/A (warning) | Fall back to LLM, log warning | + +### Error State Transitions + +``` +match_rules_against_sources + ├── permanent error → escalate (via routing) → route_to_queue + ├── transient error → retry (if retries < max) → match_rules_against_sources + ├── transient error → escalate (if retries >= max) → route_to_queue + └── completed → next → merge_findings +``` + +--- + +## Test Strategy + +### Clean Corpus Test + +A test corpus with documents that pass all rules. Expected output: zero findings. + +```python +async def test_clean_corpus_produces_no_findings(): + """Run match_rules_against_sources against a clean corpus. + + Setup: + - Load a playbook with known rules + - Provide source spans from a document that complies with all rules + - Use a mock LLM that returns "pass" for all evaluations + + Assert: + - findings list is empty + - node_status is "completed" + """ +``` + +### Violation Corpus Test + +A test corpus with exactly one known violation. Expected output: exactly one finding with correct rule_id and span. + +```python +async def test_violation_corpus_produces_one_finding(): + """Run match_rules_against_sources against a corpus with one violation. + + Setup: + - Load a playbook with known rules + - Provide source spans containing exactly one rule violation at a known offset + - Use a mock LLM that returns "fail" for the violating rule and "pass" for others + + Assert: + - findings list has exactly 1 entry + - finding.rule_id matches the violated rule + - finding.cited_span.start_offset and end_offset match the violation location + - finding.evaluation_method is correct + """ +``` + +### Property-Based Test Strategy + +Property tests use Hypothesis to generate: +- Random playbook structures (varying rule counts, scopes, check_types) +- Random source span text and offsets +- Random evaluation results (verdicts) + +And verify invariants hold across all generated inputs. + +--- + +## Testing Strategy + +### Unit Tests + +- **Playbook loading**: Verify schema validation accepts valid YAML and rejects invalid structures with descriptive errors +- **Rule partitioning**: Verify scope-based routing produces correct subsets +- **Finding construction**: Verify findings are only produced from "fail" verdicts +- **Error classification**: Verify LLM failures → transient, config failures → permanent +- **Structured evaluator fallback**: Verify unsupported rules fall back to LLM with warning + +### Property-Based Tests (Hypothesis) + +- Minimum 100 iterations per property test +- Generators produce random playbooks, rules, source spans, and evaluation results +- Properties validate invariants across all generated inputs (see Correctness Properties below) + +### Integration Tests + +- **Clean corpus test**: Full node execution against compliant documents → zero findings +- **Violation corpus test**: Full node execution against documents with one known violation → exactly one finding with correct rule_id and matching span location +- **Parallel execution**: Verify both `match_rules` and `match_rules_against_sources` execute concurrently in the graph +- **End-to-end pipeline**: Full pipeline run with playbook_id set, verifying findings appear in final state + +### Test Corpus Design + +| Corpus | Documents | Expected Findings | Purpose | +|--------|-----------|-------------------|---------| +| Clean | Compliant loan agreement | 0 | Verify no false positives | +| Violation | Loan with APR > 36% | 1 (MF-001) | Verify detection and citation | +| Mixed | Multiple docs, some compliant | Subset | Verify selective detection | + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Playbook Schema Round-Trip + +*For any* valid `Playbook` model instance (with any number of rules, any valid scopes, and any valid check_types), serializing to a dict and parsing back via `Playbook.model_validate()` SHALL produce an equivalent model. + +**Validates: Requirements 1.2, 1.5, 1.6** + +### Property 2: Invalid Playbook ID Produces Permanent Error + +*For any* string that is not a key in the `PLAYBOOK_WHITELIST`, calling `load_playbook()` SHALL raise a `PlaybookLoadError`. + +**Validates: Requirements 1.3** + +### Property 3: Rule Scope Partitioning Correctness + +*For any* valid playbook with N rules, partitioning by scope SHALL produce two lists where: (a) every rule with scope "claims" or "both" appears in `claims_rules`, (b) every rule with scope "source" or "both" appears in `source_rules`, and (c) no rule is lost — the count of unique rule IDs across both lists equals N. + +**Validates: Requirements 2.1** + +### Property 4: Findings Merge Preserves All Items + +*For any* two lists of findings (from claim-based and source-based evaluation), the merged list SHALL have length equal to the sum of both input lengths and contain every item from both inputs. + +**Validates: Requirements 2.3** + +### Property 5: Empty Inputs Produce Empty Findings + +*For any* set of rules, if the source spans list is empty OR the source_rules list is empty, the `match_rules_against_sources` node SHALL return an empty findings list with node_status "completed". + +**Validates: Requirements 3.3, 9.3** + +### Property 6: Findings Produced If and Only If Verdict Is "fail" + +*For any* list of `EvaluationResult` instances returned by evaluators, the resulting findings list SHALL contain exactly those results where `verdict == "fail"` — no more, no fewer. + +**Validates: Requirements 6.1, 6.2, 6.3, 4.3** + +### Property 7: Finding Structural Completeness + +*For any* `Finding` produced by the node, it SHALL contain all required fields with valid values: `rule_id` (non-empty string), `verdict` (literal "fail"), `cited_span` with `start_offset < end_offset` and non-empty `text`, `explanation` (non-empty string), and `evaluation_method` (one of "llm" or "structured"). + +**Validates: Requirements 6.4, 6.5, 3.4** + +### Property 8: Default Check Type Is LLM + +*For any* rule definition dict that omits the `check_type` field, parsing via `RuleDefinition.model_validate()` SHALL produce a model with `check_type == "llm"`. + +**Validates: Requirements 1.6** + +### Property 9: Unbounded Rules Per Playbook + +*For any* positive integer N, a playbook containing N rules (each with unique IDs and valid fields) SHALL pass Pydantic validation without error. + +**Validates: Requirements 7.3** diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/requirements.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/requirements.md new file mode 100644 index 000000000..f70cafcbe --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/requirements.md @@ -0,0 +1,120 @@ +# Requirements Document + +## Introduction + +The Rules Checking Stage adds a parallel source-evaluation node (`match_rules_against_sources`) to the LangGraph pipeline. While the existing `match_rules` node checks extracted claims against compliance rules, the new node evaluates rules directly against source document spans. Rules are authored in YAML playbooks, loaded and validated at run time, and evaluated via LLM (default) or structured checks (opt-in). Both nodes' findings converge before human review. The system guarantees honest output — no padded or forced findings — and exact source citations on every finding. + +## Glossary + +- **Pipeline**: The LangGraph-based document intelligence pipeline that processes microfinance documents through Understand, Examine, and Stay-Alive stages. +- **match_rules_against_sources Node**: A new LangGraph node that evaluates compliance rules directly against source document spans rather than extracted claims. +- **match_rules Node**: The existing LangGraph node that evaluates compliance rules against extracted claims. +- **Playbook**: A YAML file under `rules/` containing a list of compliance rules with metadata and evaluation instructions. +- **Rule**: A single compliance check defined in a playbook, containing an id, description, check_description, scope, and optional check_type. +- **Scope**: A per-rule tag indicating whether the rule applies to claims, source spans, or both. Valid values: `claims`, `source`, `both`. +- **playbook_id**: A whitelisted identifier that maps to a known YAML file under `rules/`. Provided per-run and stored on the run record. +- **Finding**: A structured result produced when a rule is violated, containing the rule id, verdict, cited source span, and explanation. +- **Verdict**: The evaluation outcome for a rule against a span. One of: `pass`, `fail`, `not_applicable`, `insufficient_evidence`. +- **Evaluation Method**: The mechanism used to evaluate a rule. Either `llm` (default) or `structured` (opt-in via `check_type: structured`). +- **Source Span**: A contiguous text region within the source document identified by start and end offsets. +- **Playbook Schema**: A Pydantic model that validates the structure and content of playbook YAML files at load time. +- **Run Record**: The persistent record of a pipeline execution, stored in the `runs` table. + +## Requirements + +### Requirement 1: Playbook Loading and Validation + +**User Story:** As a compliance analyst, I want rules defined in YAML files so that I can add or modify rules without changing Python code. + +#### Acceptance Criteria + +1. WHEN a pipeline run is initiated with a playbook_id, THE Pipeline SHALL resolve the playbook_id to a YAML file path under the `rules/` directory using a whitelist of known playbook names. +2. WHEN the playbook YAML file is loaded, THE Pipeline SHALL validate the file contents against the Playbook Schema using Pydantic. +3. IF the playbook_id does not match any entry in the whitelist, THEN THE Pipeline SHALL return a permanent error with a descriptive message indicating the unknown playbook_id. +4. IF the playbook YAML fails Pydantic schema validation, THEN THE Pipeline SHALL return a permanent error with validation details. +5. THE Playbook Schema SHALL require each Rule to contain the fields: id (string), description (string), check_description (string), and scope (one of `claims`, `source`, `both`). +6. THE Playbook Schema SHALL accept an optional field check_type on each Rule, defaulting to `llm` when absent. +7. WHEN a pipeline run completes playbook loading, THE Pipeline SHALL store the playbook_id on the Run Record. + +### Requirement 2: Rule Scoping and Routing + +**User Story:** As a compliance analyst, I want rules scoped to claims, sources, or both so that each rule runs against the appropriate data. + +#### Acceptance Criteria + +1. WHEN the Pipeline loads a validated playbook, THE Pipeline SHALL partition rules by scope: rules with scope `claims` route to the match_rules Node, rules with scope `source` route to the match_rules_against_sources Node, and rules with scope `both` route to both nodes. +2. THE Pipeline SHALL execute the match_rules Node and the match_rules_against_sources Node in parallel within the LangGraph graph. +3. WHEN both nodes complete, THE Pipeline SHALL merge findings from both nodes into a single findings list before passing control to the score_confidence node. + +### Requirement 3: Source Span Evaluation + +**User Story:** As a compliance analyst, I want rules evaluated directly against source document text so that violations in source materials are detected independently of claim extraction. + +#### Acceptance Criteria + +1. WHEN the match_rules_against_sources Node receives rules with scope `source` or `both`, THE match_rules_against_sources Node SHALL evaluate each rule against the source document spans available in the pipeline state. +2. THE match_rules_against_sources Node SHALL batch multiple rules per source span to reduce the number of LLM calls. +3. WHEN no source spans are available in the pipeline state, THE match_rules_against_sources Node SHALL complete with an empty findings list and status `completed`. +4. THE match_rules_against_sources Node SHALL tag each Finding with the evaluation_method used (`llm` or `structured`). + +### Requirement 4: LLM-Based Rule Evaluation + +**User Story:** As a compliance analyst, I want rules evaluated by an LLM by default so that complex regulatory language is interpreted correctly. + +#### Acceptance Criteria + +1. WHEN a rule has check_type `llm` or no explicit check_type, THE match_rules_against_sources Node SHALL evaluate the rule using the LLM. +2. THE LLM evaluation SHALL return a structured verdict containing: verdict (one of `pass`, `fail`, `not_applicable`, `insufficient_evidence`), cited_span (exact text location that triggered the evaluation), and explanation (reasoning for the verdict). +3. WHEN the LLM returns a verdict of `not_applicable`, THE match_rules_against_sources Node SHALL produce no Finding for that rule-span pair. +4. IF the LLM API call fails, THEN THE match_rules_against_sources Node SHALL set a transient error status to enable retry. + +### Requirement 5: Structured Rule Evaluation + +**User Story:** As a compliance analyst, I want deterministic structured checks for rules where LLM interpretation is unnecessary so that evaluation is faster and reproducible. + +#### Acceptance Criteria + +1. WHEN a rule has check_type `structured`, THE match_rules_against_sources Node SHALL evaluate the rule using deterministic structured logic instead of the LLM. +2. THE structured evaluation SHALL return the same verdict schema as the LLM evaluation: verdict, cited_span, and explanation. +3. WHEN a rule specifies check_type `structured` and the structured evaluator does not support that rule's check_description, THE match_rules_against_sources Node SHALL fall back to LLM evaluation and log a warning. + +### Requirement 6: Finding Integrity + +**User Story:** As a compliance analyst, I want findings to be honest and precisely cited so that I can trust the system output. + +#### Acceptance Criteria + +1. THE match_rules_against_sources Node SHALL produce findings only when a rule evaluation returns a verdict of `fail`. +2. WHEN no rules are violated for a given document, THE match_rules_against_sources Node SHALL return an empty findings list. +3. THE match_rules_against_sources Node SHALL never generate synthetic or padded findings. +4. WHEN a Finding is produced, THE Finding SHALL contain the exact source span (start offset, end offset, and text) that triggered the violation. +5. THE Finding SHALL contain the rule id, the verdict, the cited source span, the explanation, and the evaluation_method. + +### Requirement 7: Extensibility Without Code Changes + +**User Story:** As a compliance analyst, I want to add new rules by editing YAML only so that the development team is not a bottleneck for rule updates. + +#### Acceptance Criteria + +1. THE Pipeline SHALL evaluate any rule present in a valid playbook YAML without requiring changes to Python source files. +2. WHEN a new rule is added to an existing playbook YAML file, THE Pipeline SHALL evaluate the new rule on the next run that references that playbook_id. +3. THE Playbook Schema SHALL permit an unbounded number of rules per playbook file. + +### Requirement 8: Testability + +**User Story:** As a developer, I want deterministic test scenarios so that I can verify the rules checking stage works correctly. + +#### Acceptance Criteria + +1. WHEN the match_rules_against_sources Node is run against a clean test corpus (no violations), THE match_rules_against_sources Node SHALL return zero findings. +2. WHEN the match_rules_against_sources Node is run against a violation test corpus containing exactly one known violation, THE match_rules_against_sources Node SHALL return exactly one Finding with the correct rule id and a cited span that matches the violation location. + +### Requirement 9: Error Handling + +**User Story:** As a developer, I want clear error classification so that the pipeline can retry transient failures and stop on permanent ones. + +#### Acceptance Criteria + +1. IF the LLM API call fails during rule evaluation, THEN THE match_rules_against_sources Node SHALL set node_status to `error` and error_type to `transient`. +2. IF the playbook YAML is missing or fails schema validation, THEN THE Pipeline SHALL set node_status to `error` and error_type to `permanent`. +3. IF the playbook contains zero rules with scope `source` or `both`, THEN THE match_rules_against_sources Node SHALL complete with an empty findings list and status `completed`. diff --git a/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/tasks.md b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/tasks.md new file mode 100644 index 000000000..c5e593288 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/.kiro/specs/rules-checking-stage/tasks.md @@ -0,0 +1,166 @@ +# Implementation Plan: Rules Checking Stage + +## Overview + +Implement the `match_rules_against_sources` node and supporting infrastructure to evaluate compliance rules directly against source document spans. The implementation adds Pydantic playbook models, a rule partitioner, evaluator protocol with LLM and structured implementations, the new pipeline node, a merge node, pipeline state extensions, and graph wiring — all designed so that adding a new rule requires only editing YAML (no .py changes). + +## Tasks + +- [x] 1. Playbook models, loader, and rule partitioner + - [x] 1.1 Create Pydantic playbook schema and loader module + - Create `src/pipeline/playbook.py` with `RuleDefinition`, `Playbook` models, `PLAYBOOK_WHITELIST`, `PlaybookLoadError`, and `load_playbook()` async function + - `RuleDefinition` fields: id, description, check_description, scope (Literal["claims","source","both"]), check_type (Literal["llm","structured"], default "llm") + - `Playbook` fields: playbook_id, name, version (default "1.0"), rules (list[RuleDefinition]) + - Validators: id_not_empty, rules_have_unique_ids + - `load_playbook()`: resolve playbook_id via whitelist, load YAML, validate with Pydantic, raise `PlaybookLoadError` on unknown ID or validation failure + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_ + + - [x] 1.2 Create rule scope partitioner + - Create `partition_rules()` function in `src/pipeline/playbook.py` (or separate `src/pipeline/partitioner.py`) + - Returns `PartitionedRules` dataclass with `claims_rules` and `source_rules` + - Rules with scope "both" appear in both lists + - _Requirements: 2.1_ + + - [x] 1.3 Write property tests for playbook schema and partitioner + - **Property 1: Playbook Schema Round-Trip** — serialize and re-validate any valid Playbook + - **Property 2: Invalid Playbook ID Produces Permanent Error** — any non-whitelist string raises PlaybookLoadError + - **Property 3: Rule Scope Partitioning Correctness** — all rules appear in correct lists, none lost + - **Property 8: Default Check Type Is LLM** — omitting check_type defaults to "llm" + - **Property 9: Unbounded Rules Per Playbook** — N rules with unique IDs validate successfully + - **Validates: Requirements 1.2, 1.3, 1.5, 1.6, 2.1, 7.3** + +- [x] 2. Finding and EvaluationResult data models + - [x] 2.1 Create findings data models + - Create `src/pipeline/findings.py` with `CitedSpan`, `EvaluationResult`, and `Finding` dataclasses + - `CitedSpan`: start_offset (int), end_offset (int), text (str) + - `EvaluationResult`: rule_id, verdict (FindingVerdict), cited_span, explanation, evaluation_method + - `Finding`: rule_id, verdict (Literal["fail"]), cited_span, explanation, evaluation_method + - Type aliases: `FindingVerdict`, `EvaluationMethod` + - _Requirements: 6.4, 6.5, 3.4_ + + - [x] 2.2 Write property test for Finding structural completeness + - **Property 7: Finding Structural Completeness** — every Finding has non-empty rule_id, verdict=="fail", valid cited_span (start < end, non-empty text), non-empty explanation, valid evaluation_method + - **Validates: Requirements 6.4, 6.5, 3.4** + +- [x] 3. Evaluator protocol and implementations + - [x] 3.1 Create evaluator protocol and LLM evaluator + - Create `src/pipeline/evaluators.py` with `RuleEvaluator` Protocol, `LLMEvaluator` class + - `RuleEvaluator` protocol: `async evaluate(rule, span_text, span_offset) -> EvaluationResult` + - `LLMEvaluator`: implement `evaluate()` and `evaluate_batch()` (multiple rules per span to reduce LLM calls) + - LLM prompt includes rule description, check_description, source span text, and instructions to return structured verdict + - On API failure, raise exception (caught by node as transient error) + - _Requirements: 4.1, 4.2, 4.4, 3.2_ + + - [x] 3.2 Create structured evaluator + - Add `StructuredEvaluator` class to `src/pipeline/evaluators.py` + - `SUPPORTED_CHECKS` registry mapping check_descriptions to callable logic + - `supports(rule)` method to check if rule is handled + - `evaluate()` returns `EvaluationResult` with evaluation_method="structured" + - _Requirements: 5.1, 5.2, 5.3_ + +- [x] 4. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Pipeline node implementations + - [x] 5.1 Implement match_rules_against_sources node + - Create `src/pipeline/nodes/match_rules_against_sources.py` + - Follow existing node pattern (see `match_rules.py`) + - Read `source_rules` and `chunks` from state + - No rules or no chunks → empty findings, status "completed" + - Partition rules by check_type; batch LLM rules per span via `evaluate_batch()` + - Structured rules: use structured evaluator if supported, else fallback to LLM with warning + - Only produce Finding when verdict == "fail" + - On LLM API failure → transient error state + - Tag each Finding with evaluation_method + - _Requirements: 3.1, 3.2, 3.3, 3.4, 4.1, 4.3, 4.4, 5.3, 6.1, 6.2, 6.3, 9.1, 9.3_ + + - [x] 5.2 Implement merge_findings node + - Create `src/pipeline/nodes/merge_findings.py` + - Concatenate `claim_findings` and `source_findings` from state into `findings` + - No deduplication — both perspectives valid + - Append "merge_findings" to completed_nodes, set status "completed" + - _Requirements: 2.3_ + + - [x] 5.3 Write property tests for node logic + - **Property 4: Findings Merge Preserves All Items** — merged list length == sum of inputs, all items present + - **Property 5: Empty Inputs Produce Empty Findings** — empty spans or empty rules → empty findings, status "completed" + - **Property 6: Findings Produced If and Only If Verdict Is "fail"** — exactly the "fail" results become findings, no more, no fewer + - **Validates: Requirements 2.3, 3.3, 6.1, 6.2, 6.3, 4.3, 9.3** + +- [x] 6. Pipeline state extension and graph wiring + - [x] 6.1 Extend PipelineState with rules-checking fields + - Add to `src/pipeline/state.py`: `playbook_id` (Optional[str]), `source_rules` (list[dict]), `claims_rules` (list[dict]), `findings` (list[dict]), `claim_findings` (list[dict]), `source_findings` (list[dict]) + - Update `create_initial_state()` factory to initialize new fields with defaults + - _Requirements: 1.7, 2.3_ + + - [x] 6.2 Wire new nodes into pipeline graph + - Update `src/pipeline/graph.py`: + - Import `match_rules_against_sources` and `merge_findings` nodes + - Add both to `NODES` dict + - Update `PATH_MAPS`: `extract_claims` routes to fan-out dispatching both match nodes in parallel; both merge into `merge_findings`; `merge_findings` routes to `score_confidence` + - _Requirements: 2.2_ + +- [x] 7. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Sample playbook YAML and extensibility demonstration + - [x] 8.1 Create sample playbook YAML file + - Create `rules/microfinance_v1.yaml` with sample rules (MF-001 APR check, MF-002 processing fee disclosure, MF-003 interest rate match) + - Ensure playbook validates against Pydantic schema + - _Requirements: 7.1, 7.2_ + + - [x] 8.2 Demonstrate adding a rule without touching .py files + - Add a new rule (e.g., MF-004) to `rules/microfinance_v1.yaml` + - Write a test that loads the updated playbook and verifies the new rule is included, partitioned correctly, and evaluable — all without modifying any Python source file + - _Requirements: 7.1, 7.2, 7.3_ + +- [x] 9. Integration tests with test corpora + - [x] 9.1 Create clean corpus integration test + - Create `tests/pipeline/test_rules_checking_clean_corpus.py` + - Set up playbook with known rules, source spans from compliant document, mock LLM returning "pass" for all evaluations + - Assert: findings list is empty, node_status is "completed" + - _Requirements: 8.1_ + + - [x] 9.2 Create violation corpus integration test + - Create `tests/pipeline/test_rules_checking_violation_corpus.py` + - Set up playbook with known rules, source spans containing exactly one violation at known offset, mock LLM returning "fail" for violating rule + - Assert: exactly 1 finding, correct rule_id, cited_span offsets match violation location, correct evaluation_method + - _Requirements: 8.2_ + + - [x] 9.3 Write unit tests for error handling paths + - Test: unknown playbook_id → permanent error + - Test: invalid YAML → permanent error with validation details + - Test: LLM API failure → transient error + - Test: zero applicable source rules → empty findings, "completed" + - Test: structured evaluator fallback to LLM with warning + - _Requirements: 9.1, 9.2, 9.3_ + +- [x] 10. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- The hard requirement "adding a rule doesn't touch .py files" is explicitly validated in task 8.2 +- Python is the implementation language (matches existing codebase) +- All new modules follow existing conventions (Protocol-based DI, dataclasses/TypedDict for data, async node functions) + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "2.1"] }, + { "id": 1, "tasks": ["1.2", "2.2", "3.1"] }, + { "id": 2, "tasks": ["1.3", "3.2"] }, + { "id": 3, "tasks": ["5.1", "5.2", "6.1"] }, + { "id": 4, "tasks": ["5.3", "6.2"] }, + { "id": 5, "tasks": ["8.1"] }, + { "id": 6, "tasks": ["8.2", "9.1", "9.2"] }, + { "id": 7, "tasks": ["9.3"] } + ] +} +``` diff --git a/extensions/A-ES/pledger/supa_doccs/DECISIONS.md b/extensions/A-ES/pledger/supa_doccs/DECISIONS.md new file mode 100644 index 000000000..62f610afc --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/DECISIONS.md @@ -0,0 +1,114 @@ +# Decisions of TASK-1 + +## 2026-08-07 – Document domain selection + +**Decision:** Microfinance and consumer loan agreements (Financial Compliance & Credit Auditing) + +**Why this domain:** +- hands-on experience building a detection engine (Predatory learning rate in microfinance) that scanned microfinance loan agreements, extracted financial terms, calculated true APR, and flagged predatory clauses (illegal processing fees, missing Key Facts Statements, etc.) +- This domain naturally surfaces the exact problems the agentic system must solve: cross-document consistency, precise numerical extraction, rule-based compliance checking, and clear source attribution +- Easy to create high-quality synthetic documents with intentional violations and contradictions for rigorous testing +- Strong real-world relevance for audit, compliance, and credit-risk teams + + +## 2026-08-07 – Agent orchestration: LangGraph + +**Decision:** Use LangGraph as the core orchestration framework. + +**Why?** +Purpose-built for exactly the floor requirements: typed persistent state, checkpointers (kill mid-run → resume from last checkpoint is a first-class feature, not something you bolt on), conditional edges for retry/skip/escalate, and interrupt() nodes designed specifically for human-in-the-loop gates that pause and resume execution. + +## 2026-08-08 – Core Postgres schema + +**Decision:** Adopt the full schema defined in the Requirements Document (documents, document_versions, claims, source_locations, runs, run_steps, approval_queue, decisions, audit_events). + +**Why:** +- Every claim must be traceable to an exact source location → mandatory source_locations + foreign keys. +- Killed runs must resume cleanly → explicit runs + run_steps with status machine. +- Concurrent runs must not corrupt each other → run_id scoping on claims + optimistic version columns. +- Full “what changed, when, why” → append-only audit_events written in the same transaction as the change. +- Human approve/reject is item-by-item and durable → approval_queue + decisions with uniqueness constraints. + +**Alternatives considered:** +Simpler “current state only” tables, soft deletes, or reconstructing history from logs. Rejected because they fail the auditability and resumability requirements. + + +## 2026-08-12 – Microfinance ingestion pipeline: type-specific extraction + +**Decision:** Add a `classify_document` node between `extract_text` and `chunk` that routes to type-specific extractors (loan, modification, repayment) via a strategy registry. + +**Why:** +- Classification must happen before chunking because document type determines optimal chunk boundaries (clause-level for loans, row-level for repayments). +- Strategy pattern with a registry means new document types are added by implementing one class and registering it — no graph wiring changes. +- Each extractor produces `ExtractedFact` with `SourceSpan`, enabling end-to-end provenance (every fact links to exact character offsets in source text). + +**Alternatives considered:** +- Extending `extract_claims` to do classification inline. Rejected: chunking strategy depends on type, so classification must precede it. +- Single generic extractor for all types. Rejected: domain-specific field sets and normalization rules differ too much between loan agreements, modifications, and repayment statements. + + +## 2026-08-12 – Rules checking stage: YAML-driven compliance playbooks + +**Decision:** Add a parallel `match_rules_against_sources` node that evaluates compliance rules defined entirely in YAML, with LLM as default evaluator and structured checks as opt-in. + +**Why:** +- Hard requirement: adding a rule must never touch `.py` files. YAML playbooks achieve this — new rules are a file edit, reviewed in git diff. +- Parallel fan-out (existing `match_rules` for claims + new node for source spans) catches violations from both angles without serializing evaluation. +- LLM default handles open-ended regulatory language; `check_type: structured` opt-in gives determinism/speed for simple numeric comparisons. +- `playbook_id` whitelist prevents path traversal and makes playbook selection an auditable per-run choice. + +**Alternatives considered:** +- Python plugin system (register callable per rule). Rejected: violates the "no .py changes for new rules" constraint. +- Single evaluator for all rules. Rejected: some rules (rate comparisons) benefit from deterministic structured checks without LLM latency/cost. +- Replacing `match_rules` entirely. Rejected: claim-scope rules and source-scope rules need different evidence; both perspectives are valid. + + +## 2026-08-12 – Extraction brittleness: LLM-based extraction as default + +**Decision (PENDING):** Make LLM-based extraction the default strategy for all extractors, keeping regex/structured matching only as an optional fast-path for fields with genuinely fixed, template-mandated formats. + +**Evidence (from `tests/test_paraphrased_extraction.py`):** + +A paraphrased pile expressing identical facts (same rate, same parties, same tenure, same conflicts) but with natural language variation produced: + +| Document Type | Template Coverage | Paraphrased Coverage | Gap | +|---|---|---|---| +| Loan Agreement | 9/9 (100%) | 3/18 across 2 docs (17%) | 83% fields missed | +| Modification | 2 changes found | 0 changes found | 100% missed | +| Repayment (tabular) | 3 rows | 3 rows | ✓ (table format preserved) | +| Repayment (narrative) | 3 rows | 0 rows | 100% missed | + +The regex extractors succeed only when documents follow the exact phrasing the patterns were written for. Natural rewording — "a sum of one lakh fifty thousand rupees (INR 150,000.00) as the loan corpus" instead of "Principal Amount: ₹1,50,000" — breaks extraction entirely. + +**Why this is the same problem already solved in rules checking:** + +The rules checking stage already made this transition: LLM is the default evaluator (handles open-ended language), structured checks are opt-in for simple numeric patterns like "rate > 36%". The extraction stage should follow the same architecture: +- LLM extraction as default — handles arbitrary phrasing +- Regex/structured as `extraction_method: structured` opt-in per field, only when the document format is genuinely template-mandated (e.g., regulatory filings with fixed column headers) + +**Fields where regex fast-path remains appropriate:** +- Repayment statement rows when in standard pipe/tab-delimited table format +- Monetary values that appear in a known fixed template (e.g., bank-generated statements) + +**Fields where LLM extraction is required:** +- Borrower/lender names in narrative text +- Interest rates embedded in legal prose +- Tenure/term stated in words ("twenty-four calendar months") +- Processing fees described indirectly ("administrative charge amounting to...") +- Any modification agreement in letter format + +**Alternatives considered:** +- Adding more regex patterns. Rejected: infinite regression — each new phrasing requires new patterns, and the combinatorial space of natural language is unbounded. +- Hybrid approach (regex first, LLM fallback). Considered viable but adds complexity. Simpler to default to LLM and use regex only where speed/determinism is critical. + +## 2026-08-26 – Extraction brittleness: RESOLVED — hybrid structured-first with field-level LLM fallback + +**Status:** The 2026-08-12 PENDING decision above is now implemented, landing closer to the "hybrid" alternative than the pure LLM-default originally sketched: + +- **Registered document types** (loan, modification, repayment): the structured extractor runs first; every field it misses (`not_found`) is re-extracted by the LLM and merged (structured wins where found). Per-claim `_extraction_method` records `structured` vs `llm_fallback` (migration 014 `claim_field_and_method`). +- **Unregistered types:** LLM-only extraction path. +- **Citation strategy for LLM-extracted fields:** the model returns a verbatim quoted span per field; exact string match locates it in source text to compute character offsets (normalized match as fallback); on total failure the claim is kept but marked `citation_status: "unverifiable"` with a zeroed span — never silently unanchored. + +**Implementation:** `src/pipeline/nodes/extract_claims.py` (`_dispatch_type_specific_with_fallback`), `src/pipeline/extractors/llm_extractor.py`. Evidence: `tests/test_paraphrased_extraction.py`, `tests/test_unverifiable_citation_e2e.py`. + +**Why hybrid over pure LLM-default:** structured extraction is free, deterministic, and already correct for template-mandated formats (bank-generated repayment tables); routing everything through the LLM would add latency/cost exactly where determinism was working. The paraphrase experiment showed the failure mode is *missing fields*, which field-level fallback fixes without re-running whole documents. diff --git a/extensions/A-ES/pledger/supa_doccs/Dockerfile b/extensions/A-ES/pledger/supa_doccs/Dockerfile new file mode 100644 index 000000000..fb4295e50 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN pip install --no-cache-dir uv + +COPY pyproject.toml . +COPY src/ ./src/ +COPY scripts/ ./scripts/ +COPY samples/ ./samples/ + +RUN uv sync --no-dev + +CMD ["uv", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/extensions/A-ES/pledger/supa_doccs/Makefile b/extensions/A-ES/pledger/supa_doccs/Makefile new file mode 100644 index 000000000..fbc36e1cd --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/Makefile @@ -0,0 +1,44 @@ +.PHONY: up down seed migrate demo clean test + +# ------------------------------------------------------------------- +# One command to bring the whole system up and seed the demo pile +# ------------------------------------------------------------------- + +demo: up migrate seed ## Full bring-up + seed (the only command you need) + @echo "" + @echo "✔ System running. API at http://localhost:8000/health" + @echo " Frontend at http://localhost:5173 (run 'make frontend' separately)" + @echo " Postgres at localhost:5432 (user: postgres / pass: postgres / db: docdb)" + @echo "" + +up: ## Start all containers (postgres + api) + docker compose up -d --build --wait + +down: ## Stop all containers + docker compose down + +migrate: ## Apply database migrations + DATABASE_URL=postgresql://postgres:postgres@localhost:5432/docdb \ + uv run python migrations/run_migrations.py + +seed: ## Generate and insert synthetic demo pile + DATABASE_URL=postgresql://postgres:postgres@localhost:5432/docdb \ + uv run python scripts/seed_demo.py + +test: ## Run full test suite (no DB required for most tests) + uv run pytest tests/ -v --ignore=tests/test_schema + +test-schema: ## Run schema tests (requires running postgres) + DATABASE_URL=postgresql://postgres:postgres@localhost:5432/docdb \ + uv run pytest tests/test_schema/ -v + +frontend: ## Start frontend dev server (requires node_modules installed) + cd frontend && npm run dev + +clean: ## Remove containers and volumes + docker compose down -v + @echo "Cleaned." + +help: ## Show this help + @grep -E '^[a-z_-]+:.*## ' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' diff --git a/extensions/A-ES/pledger/supa_doccs/PROGRESS.md b/extensions/A-ES/pledger/supa_doccs/PROGRESS.md new file mode 100644 index 000000000..d3f2b22be --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/PROGRESS.md @@ -0,0 +1,183 @@ +# PROGRESS.md + +**Project Start Date:** 2026-08-08 + +## 2026-08-08 — Phase 1 complete + +**Built:** +- Core Postgres schema design (reviewed) +- LangGraph node/edge structure + routing conditions (reviewed) + +## 2026-08-08 — Core Schema Implementation complete + +**Built:** +- 7 SQL migration files (001–007), verified against live PostgreSQL 16 +- 10 database tables: documents, document_versions, runs, run_steps, claims, source_locations, approval_queue, decisions, audit_events, schema_migrations +- 3 database triggers: version immutability, run status state machine, audit append-only guard, decision pending guard +- 23 indexes for query performance +- Migration runner (`migrations/run_migrations.py`) with idempotency and transaction safety +- 8 SQLAlchemy model modules with relationships and OCC version columns +- 14 property-based tests (Hypothesis) — all passing (15 test functions, 100 examples each) + +**Key schema features:** +- Content-hash deduplication for document versions +- Run status state machine enforced at DB level (pending → running → completed|failed|cancelled) +- Append-only audit trail with trigger protection +- Optimistic concurrency control on runs, run_steps, approval_queue +- ON DELETE RESTRICT on all parent-child FK relationships +- IF NOT EXISTS on all DDL for migration idempotency + +## 2026-08-08 — LangGraph Pipeline Implementation complete + +**Built:** +- Full 3-stage pipeline: Understand (4 nodes) → Examine (3 nodes) → Stay-Alive (3 nodes) +- 10 async pipeline nodes with dependency-injected services (LLM, storage, DB) +- Routing function factory with per-node conditional edge logic (retry, skip, escalate, unhandled fallback) +- State schema (`PipelineState` TypedDict) with JSONB serialization round-trip +- Config loader with constraint validation and defaults +- Checkpoint persistence layer (write iff success, never on error/retry) +- Kill-and-resume logic with advisory lock enforcement and orphan cleanup +- LangGraph StateGraph assembly module (all nodes + conditional edges registered) +- FastAPI endpoints: `POST /runs` (create), `POST /runs/{id}/resume` +- Human review polling service (decision completeness checks + reminder audit events) +- 489 pipeline tests passing (unit tests + 16 property-based correctness tests) + +**Property-based tests (Hypothesis) verify:** +- P1: Stage ordering invariant +- P2: Routing determinism (exactly one match) +- P3: Retry routing correctness +- P4: Unrecognized error type escalation +- P5: Checkpoint iff success +- P6: State JSONB round-trip +- P7: Skip routing and metadata +- P8: Claim partitioning priority +- P9: Escalate bucket determines human review routing +- P10: Resume restart correctness +- P11: Chunk coverage (no gaps) +- P12: Extraction offset ordering +- P13: Verdicts-claims length parity +- P14: Confidence flagging threshold +- P15: Post-human-review always finalizes +- P16: Node completion contract + +**Still open / next:** +- LangGraph runtime integration (requires `langgraph` package install) +- Wire real PDF/DOCX extractors (pdfplumber, python-docx) +- Wire real embedding service (OpenAI, etc.) +- Wire real LLM client for rule evaluation (currently protocol-based, mock-tested) +- MCP + React UI + cost tracking + +## 2026-08-12 — Microfinance Ingestion Pipeline complete + +**Built:** +- `classify_document` node (MIME validation, threshold-based "unclassified" routing, custom routing function) +- 3 type-specific extractors: `LoanAgreementExtractor` (9 fields), `ModificationExtractor` (per-change groups), `RepaymentExtractor` (per-row parsing) +- `SourceLinker` (attach + resolve) + `persist_fact` utility (claim_type compound naming) +- Extractor registry with strategy dispatch in `extract_claims` +- Synthetic document generator (deterministic 5-doc piles with 2 factual conflicts) +- 20 Hypothesis property tests (classification, extraction, normalization, source linking, generator) +- End-to-end provenance test (all 5 synthetic docs → extract → source-link → resolve → verify) +- 807 total tests passing + +## 2026-08-12 — Rules Checking Stage implementation complete + +**Built:** +- `src/pipeline/playbook.py` — Pydantic playbook schema (`RuleDefinition`, `Playbook`), `load_playbook()` async loader, `PLAYBOOK_WHITELIST`, `partition_rules()` scope partitioner +- `src/pipeline/findings.py` — `CitedSpan`, `EvaluationResult`, `Finding` dataclasses + type aliases (`FindingVerdict`, `EvaluationMethod`) +- `src/pipeline/evaluators.py` — `RuleEvaluator` protocol, `LLMEvaluator` (batch multiple rules per span), `StructuredEvaluator` (deterministic, registry-based, with sample APR check) +- `src/pipeline/nodes/match_rules_against_sources.py` — new node evaluating rules against source spans; partitions by check_type, batches LLM calls, produces findings only on `verdict == "fail"` +- `src/pipeline/nodes/merge_findings.py` — concatenates `claim_findings` + `source_findings` with no deduplication +- Extended `PipelineState` with `playbook_id`, `source_rules`, `claims_rules`, `findings`, `claim_findings`, `source_findings` +- Updated `graph.py` — 13 nodes, sequential flow: `extract_claims` → `match_rules` → `match_rules_against_sources` → `merge_findings` → `score_confidence` +- `rules/microfinance_v1.yaml` — sample playbook with 4 compliance rules (MF-001 through MF-004) +- 58 new tests (all passing): 9 property-based (Hypothesis), 3 integration (clean corpus, violation corpus, extensibility), 5 error handling, 1 YAML validation +- Extensibility validated: added MF-004 rule via YAML only — no Python file modified + +**Property-based tests verify:** +- P17: Playbook schema round-trip +- P18: Invalid playbook ID → permanent error +- P19: Rule scope partitioning correctness (no rules lost) +- P20: Default check_type is "llm" +- P21: Unbounded rules per playbook +- P22: Finding structural completeness (all fields valid) +- P23: Findings merge preserves all items +- P24: Empty inputs → empty findings + "completed" +- P25: Findings produced iff verdict is "fail" + +**873 total tests passing.** + +## 2026-08-22 — Incremental Update Path complete + +**Built:** +- `src/pipeline/incremental_api.py` — API module for focused incremental document updates +- `POST /piles/{pile_id}/incremental` — upload document + incremental update (no full re-run) +- `PATCH /piles/{pile_id}/watch` — configure watched folder path per pile (stored in pile metadata JSONB) +- Wired into `main.py` with shared `ApprovalService` instance +- Frontend: `uploadIncrementalDocument()` API function + "Add document (incremental)" button in PilesPanel +- 13 new tests (`tests/test_incremental_api.py`) — all passing +- FolderWatcher now connected to the pile system via the incremental API + +### How the Incremental Path Differs from a Full Run + +| Aspect | Full Run (`POST /runs/start`) | Incremental (`POST /piles/{pile_id}/incremental`) | +|--------|-------------------------------|---------------------------------------------------| +| **Nodes executed** | All 13 (ingest → finalize) | Only 3: text-extract, classify, extract-claims — for the new doc only | +| **Scope** | Processes the entire document corpus from scratch | Processes ONLY the new document | +| **Deliverable** | Built fresh from all claims | Reconstructed from latest completed run, then surgically updated | +| **Unaffected sections** | Rebuilt entirely | Byte-identical (SHA-256 verified, never touched) | +| **Conflict handling** | Findings → approval queue | Contradictions → approval queue with `item_type="conflict"` | +| **Silent overwrite** | N/A | Never — contradictions always routed to human gate | +| **Trigger** | Manual (UI "Start Run" button) | "Add document (incremental)" button or FolderWatcher scan | +| **Cost** | Full LLM pipeline (13 nodes, all chunks) | Minimal LLM usage (classify + extract for 1 doc) | +| **Audit trail** | Run + RunStep records | `audit_events` with `action="incremental_update"` | + +### Invariants Guaranteed + +1. **Hash identity**: Sections not affected by the new document retain their exact SHA-256 content hash. Verified by `TestIncrementalApiHashIdentity` (4 tests). + +2. **No silent overwrite**: When a new document contradicts an existing claim (same section key, different value), the conflict lands in the approval queue as a pending item. The original deliverable section is never auto-modified. Verified by `TestIncrementalApiContradiction` (4 tests). + +3. **Existing gates re-used**: Conflicts use the same `ApprovalService` → `POST /approval/items/{id}/decide` flow as the full pipeline's stay-alive stage. + +4. **Audit events emitted**: Every incremental update records an `audit_events` row with before/after section hashes and the list of affected sections. + +### Watched Folder Configuration + +A pile can optionally have a `watched_folder_path` in its metadata (set via `PATCH /piles/{pile_id}/watch`). The `FolderWatcher` class (already implemented) can monitor this path and call `process_single_file()` when new documents appear, triggering the same incremental engine internally. + +**886 total tests passing (873 + 13 new).** + +## Assumptions Log + +| Date | Assumption | Reasoning | +|------|------------|-----------| +| 2026-08-08 | OCC version increment is application-enforced via WHERE clause, not auto-managed by SQLAlchemy | Gives explicit control over conflict detection and retry logic | +| 2026-08-08 | Tests use transaction rollback isolation (no cleanup between tests) | Faster tests, zero side effects between runs | +| 2026-08-24 | Migration 010 extends `approval_queue` (nullable `claim_id`, new `run_id`/`item_type`/`payload`/`decided_at`) instead of reusing the table as-is | The Phase 2.3 schema modeled claim-scoped rows only; the ApprovalService contract requires run-scoped items with JSONB payloads, and items can exist without a backing claims row. `decisions` needed no changes — justification/reviewer stay there, sourced via LEFT JOIN. | +| 2026-08-24 | Malformed (non-UUID) item ids are treated as "not found" rather than a parse error | The store keys items by server-generated UUIDs; a non-UUID id cannot exist, so ItemNotFoundError preserves the original REST 404 contract (`tests/test_approval_gate.py::test_nonexistent_item_returns_404`). | +| 2026-08-24 | `persist_fact()` is now called at extract_claims NODE COMPLETION (both executors); the JSONB checkpoint is unchanged | The tables are the durable queryable record; the checkpoint remains the resumability mechanism. Persistence is idempotent per run (delete-then-insert) so resumed/retried runs don't duplicate rows. Test: `tests/test_extract_claims_persistence.py` proves rows exist after a real process kill with zero checkpoints written. | +| 2026-08-24 | All-zero `start_offset` values in run d039ebef were a BUG, not chunk-relative-by-design | demo_executor's LLM extraction path hardcoded `start_offset=0, end_offset=len(claim_text)` — verified numerically (all 4 stored end_offsets exactly equal claim-text lengths; impossible for real document spans). Fixed by locating claim text in extracted_text/chunks; regex-fallback offsets were also chunk-relative and are now document-level. Unlocatable text → citation_status "unverifiable". | +| 2026-08-24 | Pipeline tests that delete runs must first delete their claims/source_locations | claims.run_id FK is ON DELETE RESTRICT by design (durable audit record); until persistence was wired, the tables were always empty so tests never hit the constraint. Cleanup helpers updated in test_piles.py. | +| 2026-08-24 | History endpoint wired to audit_events via SQLHistoryStore; decisions/nodes/incremental writes are schema-conforming now | Phase 5.2's 503 was two bugs: set_history_store() never called, AND the incremental flow's audit writes used enum values ('pile', 'incremental_update') rejected by migration 006's CHECK constraints — failing silently since Day 1. Migration 012 legalizes 'pile'; incremental-update events now use entity_type='run'/action='updated'. Original Phase 5.2 test could never have caught this: it injected its own InMemoryAuditStore into the very seam that was broken and bypassed production writers entirely. Live rerun: tests/test_history_live_endpoint.py. | +| 2026-08-24 | Conflicts reuse `approval_queue` (item_type='conflict') instead of a new table; exposed via GET /runs/{id}/conflicts | The queue was designed with a 'conflict' item type and the incremental engine already routed contradictions there (`incremental.py` `enqueue_item(item_type="conflict")` with full existing/new claim attribution in payload). Reusing it gives conflicts the pending→approved/rejected lifecycle, decisions, reviewer attribution, and Postgres durability for free — a separate table would duplicate all of that and split review state across two stores. Since migration 010 made the queue durable, conflicts already survive restarts; only the read endpoint was missing. Test: tests/test_conflicts_persistence.py (real process restart, conflict still pending with both source documents attributed). | +| 2026-08-24 | B-fixer followups: human_review enqueue dedup + 'completed_with_persistence_gap' run status (migration 013) | _node_human_review now dedups by claim_id exactly like populate-queue's backfill, so node re-execution can't create duplicate pending items. Durability writes (claims/source_locations, audit_events) retry ONCE then flag `_persistence_gap`; the run then ends 'completed_with_persistence_gap' instead of a plain 'completed', visible in GET /runs without opening details. Deliberate cuts, documented not fixed: B2's transient-finalize-failure handling and B4's checkpoint-before-audit ordering window remain as-is. Tests: tests/test_persistence_gap_fixes.py. | + +**Approval queue is now Postgres-backed** (`src/pipeline/approval_postgres.py`), replacing the in-memory store whose contents were lost on every restart: enqueue → one `approval_queue` row; decide → atomic INSERT into `decisions` + status transition under `SELECT ... FOR UPDATE` (the Phase 2.3 guard trigger enforces decision-before-status ordering). Durability across a real process restart is proven by `tests/test_approval_persistence_restart.py` (subprocess phases) and verified live against the dockerized API (`docker compose restart api` between seed and read). + +## 2026-08-26 — Documentation audit: info files synced to current state + +Full drift audit of README/src/tests docs against code. Corrections: + +| Doc claim (stale) | Actual state | +|---|---| +| 8 migrations / 10 tables | 15 migrations / 13 tables (`piles`, `pile_documents`, `deliverables`) | +| "No real LLM calls" | DeepSeek client wired (`src/llm/deepseek_client.py`), env-gated; used by `demo_executor.run_pipeline` with SSE streaming | +| "No LangGraph runtime" | langgraph installed (1.2.x); `graph.py` compiles a real `StateGraph`; custom executor still owns checkpointing | +| "Regex extraction as default" (DECISIONS PENDING) | Implemented as **hybrid**: structured-first + field-level LLM fallback for known types; LLM-only path for unregistered types; per-claim `_extraction_method` recorded (migration 014); verbatim-quote citation strategy with `citation_status: unverifiable` downgrade | +| "embed node is a no-op stub" | Fully implemented behind `EmbeddingService`/`VectorStore` protocols; no default provider configured | +| MCP 6 tools | 8 tools (+ `get_run_cost`, `cancel_run`) | +| "870+ tests (25 property-based)" / "886 total" | 1,078 collected; ~95 property-based cases across 25 Hypothesis modules | + +**Known issue surfaced by audit:** `tests/test_concurrency.py` failed collection — imported `ThreadSafeCheckpointStore`, removed from `src/pipeline/stores.py` in commit ac65af8 ("removing mock / dead elements"). **Resolved same day:** the store now lives in the test file itself (matching the convention of test_resumability's local InMemoryCheckpointStore), rebuilt as genuinely thread-safe — mutex-guarded step rows keyed `(run_id, step_order)` plus atomic per-run_id `threading.Lock`s with the executor's blocking-wait protocol (`_get_run_lock`). All 5 concurrency tests pass; full non-DB suite green (1,065 passed). + +Updated: `README.md`, `src/README.md`, `tests/README.md`, `docs/invariants.md` (section renumbering), recreated `frontend/README.md`. DECISIONS.md pending extraction item marked RESOLVED with implementation evidence. diff --git a/extensions/A-ES/pledger/supa_doccs/README.md b/extensions/A-ES/pledger/supa_doccs/README.md new file mode 100644 index 000000000..730689a7c --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/README.md @@ -0,0 +1,167 @@ +# Agentic Document Intelligence — Microfinance Compliance + +An end-to-end pipeline that ingests a "pile" of financial documents, extracts structured facts with source provenance, evaluates compliance rules, and surfaces findings for human approval — with full checkpointed resumability and concurrent-run isolation. + +--- + +## Quick Start (one command) + +Prerequisites: **Docker**, **Docker Compose v2**, and [**uv**](https://docs.astral.sh/uv/getting-started/installation/) (Python package manager). + +```bash +make demo +``` + +This single command: + +1. Builds and starts PostgreSQL 16 + pgvector and the FastAPI service (`docker compose up`) +2. Applies all 15 database migrations (schema, tables, triggers, indexes) +3. Generates a deterministic 5-document synthetic pile and seeds it into the database + +After it completes: + +| Service | URL | +|------------|----------------------------------| +| API | http://localhost:8000/health | +| PostgreSQL | `localhost:5432` (postgres/postgres/docdb) | + +To bring everything down: `make down` (or `make clean` to also wipe the volume). + +--- + +## Supported Document Formats + +| Format | MIME Type | Notes | +|--------|-----------|-------| +| **PDF** | `application/pdf` | Text-layer extraction via pdfplumber; scanned PDFs not yet supported | +| **DOCX** | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | Standard Office Open XML | +| **Plain text** | `text/plain` | Direct ingestion, no conversion step | + +The pipeline determines format at the `ingest` node via MIME detection. Adding support for a new format requires only a new text-extraction adapter in `src/pipeline/nodes/extract_text.py` — no graph changes. + +--- + +## Domain + +**Microfinance and consumer loan compliance** (Financial Compliance & Credit Auditing). + +The system is purpose-built for document piles containing: + +- Loan agreements (principal, rate, tenure, fees, penal clauses) +- Modification agreements (rate reductions, moratoriums, term changes) +- Repayment statements (tabular or narrative payment histories) + +Documents are organized into **piles** — the unit of work handed to a run (`POST /piles`, upload documents via `POST /piles/{id}/documents`). A run is started against one pile and one playbook; the pile's document list is read at run-start and treated as immutable for that run's duration. + +Compliance rules live in YAML playbooks under `rules/`. The shipped playbook (`microfinance_v1`) checks: + +| Rule | Description | +|------|-------------| +| MF-001 | APR must not exceed 36% | +| MF-002 | Processing fee must be disclosed | +| MF-003 | Interest rate must match latest modification | +| MF-004 | Late payment penalty must not exceed 5% of outstanding | + +A second evaluation with different documents works without code changes — drop new files in the same declared formats (PDF, DOCX, or plain text) within the microfinance/consumer-loan domain, and the existing pipeline + rules apply as-is. To add domain-specific rules, edit or add YAML in `rules/` (no Python changes required). + +--- + +## Architecture Decisions — What We Cut and Why + +| Decision | What was cut / chosen | Why | +|----------|-------------|-----| +| **LangGraph topology, custom checkpoint runner** | `langgraph` is installed and `graph.py` compiles a real `StateGraph` (nodes + conditional edges), but API runs execute via `demo_executor.run_pipeline`, which drives nodes sequentially with Postgres-backed per-node checkpoints — the same semantics LangGraph's checkpointer provides | Keeps graph topology portable to LangGraph's runtime while owning the durability contract directly; kill-and-resume is enforced by property tests rather than delegated | +| **Real LLM calls (DeepSeek), protocol-gated** | Extraction, classification, rule evaluation, and confidence scoring call DeepSeek via an OpenAI-compatible client (`src/llm/deepseek_client.py`) when `DEEPSEEK_API_KEY` is set; tests inject mock `LLMClient` implementations through protocols | Avoids API-key gating for evaluators in CI; production wiring is an env var, not a code change. SSE streaming broadcasts node progress live (`GET /runs/{id}/stream`) | +| **Hybrid extraction: structured-first, LLM fallback per field** | Type-specific structured extractors run first; any field returning `not_found` is re-extracted by the LLM; unregistered document types go straight to LLM-only extraction. Each claim records `_extraction_method` (`structured`, `llm_fallback`, or `llm`) | Regex alone collapsed from 100% template coverage to 17% on paraphrased documents (see DECISIONS.md); hybrid keeps deterministic speed where phrasing is fixed and LLM reach everywhere else. Resolves the 2026-08-12 PENDING decision | +| **Verbatim-quote citation strategy for LLM facts** | The LLM returns a verbatim quoted span per field; exact string match locates it in source text to compute offsets (normalized fallback); failure marks the claim `citation_status: unverifiable` with zeroed span | Guarantees every extracted fact is either anchored to exact character offsets or explicitly flagged — never silently unanchored | +| **Embedding node implemented, provider unwired** | The `embed` node is fully implemented (atomic batch embed → pgvector store) behind `EmbeddingService`/`VectorStore` protocols, but no default provider is configured — without injected services the node returns a transient error | pgvector schema ready; concrete provider adds cost/API-key coupling — wire via config switch when needed | +| **No scanned-PDF OCR** | Only text-layer PDFs are supported | OCR adds Tesseract/cloud-vision dependencies and latency; the compliance domain primarily uses digitally-generated loan docs | +| **No React UI in Docker** | Frontend is a separate Vite dev server, not containerized | Keeps the backend image small and CI fast; the frontend is stateless and talks to the API over localhost | +| **No cloud deployment** | Docker Compose for local only | Scope is demo + evaluation; production would add Terraform/CDK, secrets management, and multi-region Postgres — all out of scope for this prototype | +| **No authentication** | API is unauthenticated | Prototype scope; production would add JWT/OAuth middleware | + +--- + +## Project Structure + +``` +├── scripts/ +│ └── seed_demo.py # Generates + inserts the synthetic demo pile +├── rules/ # YAML compliance playbooks (no code edits to add rules) +├── migrations/ # Sequential SQL migrations (001–015) +├── src/ +│ ├── models/ # SQLAlchemy models (13 tables) +│ ├── llm/ +│ │ └── deepseek_client.py # OpenAI-compatible DeepSeek client (env-gated) +│ ├── pipeline/ +│ │ ├── nodes/ # 13 async pipeline nodes +│ │ ├── extractors/ # Type-specific extractors + LLM extractor w/ citation strategy +│ │ ├── graph.py # LangGraph StateGraph assembly (all nodes, conditional edges) +│ │ ├── demo_executor.py # Real-run executor: LLM calls, SSE events, durability writes +│ │ ├── executor.py # ResumableExecutor (checkpoint-based runner) +│ │ ├── stores.py # SQL stores (run/resume/history/cost) + in-memory test store +│ │ ├── approval.py # Approval service (in-memory) + approval_postgres.py (durable) +│ │ ├── incremental.py # Incremental updates; contradictions → approval queue +│ │ ├── deliverable.py / deliverable_store.py # Run deliverables (approved output) +│ │ ├── history*.py # Change history: what changed, when, why +│ │ ├── source_linker.py # Attaches fact spans → source_locations rows +│ │ ├── citation_payload.py# Grounded/unverifiable citation payloads +│ │ ├── piles_api.py # Pile CRUD + document upload +│ │ ├── upload_api.py # File upload handling +│ │ ├── facts_api.py # Claim/fact inspection endpoints +│ │ └── ... # Config, routing, playbook, evaluators, polling, watcher +│ ├── mcp_server.py # MCP server (8 tools, stdio transport) +│ └── main.py # FastAPI entrypoint (runs, piles, approvals, cost, report, SSE) +├── tests/ +│ ├── synthetic/ # Deterministic document generators +│ ├── microfinance/ # Domain generators + property tests (extraction, classifier…) +│ ├── pipeline/ # Unit + property-based pipeline tests +│ ├── test_schema/ # Schema correctness (requires Postgres) +│ ├── test_resumability.py # Kill-and-resume invariant tests +│ ├── test_concurrency.py # Run isolation tests +│ ├── test_paraphrased_extraction.py # Template vs paraphrased coverage gap evidence +│ ├── test_prompt_injection_resistance.py # Hostile document content hardening +│ └── test_mcp_integration.py # End-to-end via MCP tools +├── frontend/ # React + Vite review interface (Vitest + Playwright e2e) +├── docker-compose.yml # PostgreSQL 16 + pgvector, API service +├── Dockerfile # Python 3.11-slim, uv-based +├── Makefile # make demo = up + migrate + seed +└── pyproject.toml +``` + +--- + +## Running Tests + +```bash +# All tests (no database needed for pipeline + invariant tests) +make test + +# Schema tests (requires running Postgres — run 'make up' first) +make test-schema +``` + +--- + +## MCP Server + +The system is also available as an MCP server (stdio transport) exposing the same operations as the REST API through 8 tools: `start_run`, `get_run_status`, `list_pending_approvals`, `decide_approval`, `get_deliverable`, `get_change_history`, `get_run_cost`, `cancel_run`. + +```bash +uv run python -m src.mcp_server +``` + +--- + +## Makefile Targets + +| Target | Description | +|--------|-------------| +| `make demo` | **The one command** — up + migrate + seed | +| `make up` | Start containers | +| `make down` | Stop containers | +| `make clean` | Stop + wipe volumes | +| `make migrate` | Apply SQL migrations | +| `make seed` | Insert synthetic demo pile | +| `make test` | Run test suite | +| `make frontend` | Start Vite dev server | diff --git a/extensions/A-ES/pledger/supa_doccs/TASK.md b/extensions/A-ES/pledger/supa_doccs/TASK.md new file mode 100644 index 000000000..3d402dd88 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/TASK.md @@ -0,0 +1,143 @@ +# TASK.md — Agentic Document Intelligence: Project Scaffold + +## Collaboration Rules + +These rules apply to every task in this list: + +1. **Every non-trivial piece of code must have a test written before or alongside it.** +2. **All assumptions must be logged to `PROGRESS.md` before acting on them.** +3. **Files outside the current task's scope must not be modified without prior declaration.** + +--- + +## Scaffold Tasks + +### Task 1 — Create `.gitignore` + +Create a `.gitignore` at the project root covering Python artefacts, Node artefacts, environment/secrets files, editor/OS artefacts, and `uv` artefacts. `uv.lock` must **not** be gitignored. + +**Verification:** File exists at project root and contains entries for `__pycache__/`, `.venv/`, `.env`, `node_modules/`, `.DS_Store`, and `.uv/`. + +```bash +test -f .gitignore && grep -q '__pycache__' .gitignore && echo "PASS" +``` + +--- + +### Task 2 — Create `pyproject.toml` + +Create `pyproject.toml` at the project root. Set `name`, `version`, and `requires-python = ">=3.11"`. Declare runtime dependencies (`fastapi`, `uvicorn[standard]`, `langgraph`, `psycopg2-binary`, `pgvector`, `sqlalchemy`) and dev dependencies (`pytest`, `httpx`, `anyio[trio]`). Use `hatchling` as the build backend. + +**Verification:** `uv sync` completes without errors and a virtual environment is created. + +```bash +uv sync +``` + +--- + +### Task 3 — Scaffold `src/` package + +Create `src/__init__.py` (empty), `src/main.py` with a `FastAPI` app instance and a `GET /health` route returning `{"status": "ok"}`, and `src/README.md` with the one-line description `"Source code for the FastAPI application."`. + +**Verification:** The app can be imported and the health route is reachable. + +```bash +python -c "from src.main import app; print('PASS')" +``` + +--- + +### Task 4 — Scaffold `tests/` package and health test + +Create `tests/__init__.py` (empty), `tests/README.md` with the description `"Test suite mirroring the src/ layout."`, and `tests/test_main.py` containing an async test that calls `GET /health` via `httpx.AsyncClient` and asserts `status_code == 200` and body `{"status": "ok"}`. + +**Verification:** `uv run pytest` discovers and passes all tests. + +```bash +uv run pytest +``` + +--- + +### Task 5 — Checkpoint: tests pass + +Run the full test suite and confirm every test passes with exit code 0. Resolve any failures before proceeding to Task 6. + +**Verification:** `uv run pytest` returns exit code 0. + +```bash +uv run pytest; echo "Exit code: $?" +``` + +--- + +### Task 6 — Create `Dockerfile` + +Create a `Dockerfile` at the project root using `python:3.11-slim` as the base image. Set `WORKDIR /app`, install `uv` via pip, copy `pyproject.toml` and run `uv sync --no-dev`, then copy `src/` and set the default `CMD` to start `uvicorn src.main:app` on `0.0.0.0:8000`. + +**Verification:** Docker image builds successfully. + +```bash +docker build -t agentic-doc-intelligence . +``` + +--- + +### Task 7 — Create `docker-compose.yml` + +Create `docker-compose.yml` at the project root. Define a `postgres` service (image `pgvector/pgvector:pg16`, env vars `POSTGRES_USER/PASSWORD/DB`, port 5432, named volume `pgdata`) and an `api` service (builds from `Dockerfile`, port 8000, `depends_on: [postgres]`). Declare the named volume `pgdata`. + +**Verification:** Both services start without errors. + +```bash +docker compose up +``` + +--- + +### Task 8 — Create `TASK.md` + +Create this file (`TASK.md`) at the project root listing all scaffold tasks as numbered, individually verifiable increments and embedding the three collaboration rules above. + +**Verification:** File exists and contains all task entries and verification steps. + +```bash +test -f TASK.md && grep -q 'Collaboration Rules' TASK.md && echo "PASS" +``` + +--- + +### Task 9 — Create `PROGRESS.md` + +Create `PROGRESS.md` at the project root. Display the project start date `2026-08-08` and include an Assumptions Log table with columns `Date`, `Assumption`, and `Reasoning` and no initial data rows. + +**Verification:** File exists with the start date and the Assumptions Log table header. + +```bash +test -f PROGRESS.md && grep -q 'Assumptions Log' PROGRESS.md && echo "PASS" +``` + +--- + +### Task 10 — Create `docs/invariants.md` and `frontend/README.md` + +Create `docs/invariants.md` containing only the heading `# Invariants` (no body content). Create `frontend/README.md` with the one-line description `"Frontend application placeholder."`. + +**Verification:** Both files exist. + +```bash +test -f docs/invariants.md && test -f frontend/README.md && echo "PASS" +``` + +--- + +### Task 11 — Final Checkpoint + +Run the full test suite and build the Docker image to confirm the scaffold is complete and all pieces integrate correctly. + +**Verification:** `uv run pytest` passes and `docker compose build` succeeds. + +```bash +uv run pytest && docker compose build && echo "Scaffold complete" +``` diff --git a/extensions/A-ES/pledger/supa_doccs/docker-compose.yml b/extensions/A-ES/pledger/supa_doccs/docker-compose.yml new file mode 100644 index 000000000..a06161ab7 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/docker-compose.yml @@ -0,0 +1,33 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: docdb + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 5s + retries: 10 + + api: + build: . + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/docdb + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} + DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat} + volumes: + - ./uploads:/app/uploads + depends_on: + postgres: + condition: service_healthy + +volumes: + pgdata: diff --git a/extensions/A-ES/pledger/supa_doccs/docs/invariants.md b/extensions/A-ES/pledger/supa_doccs/docs/invariants.md new file mode 100644 index 000000000..2c8152f29 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/docs/invariants.md @@ -0,0 +1,124 @@ +# Invariants + +> **Note on numbering:** Sections 6–8 were removed during refactoring. The remaining +> numbers are kept stable so external references (e.g., comments in `src/main.py`, +> test docstrings) continue to resolve correctly. Do not renumber. + +## Checkpointed Resumability + +These invariants are absolute — violations are treated as system bugs, never acceptable trade-offs. + +1. **Never re-run a completed node's side effects on resume.** When a run is + resumed after a crash, nodes whose checkpoint has been durably written must + NOT execute again. The system skips directly to the next un-checkpointed + node. Re-execution could cause duplicate writes, duplicate API calls, or + data corruption. + +2. **Never lose an in-flight decision.** If a human reviewer submits an + approval/rejection decision, that decision must be durable before the + pipeline acknowledges it. A crash after acknowledgement but before + checkpoint must not discard the decision — the decision is the + authoritative record, and the pipeline must recover it on resume. + +3. **Never leave a run in an ambiguous state if killed between two node + transitions.** At any instant, a run is in exactly one of: + - The last checkpointed node completed, next node has not started. + - A node is actively running (its `run_steps` row has status='running', + ended_at=NULL). + + On resume, any row with status='running' and ended_at=NULL is marked + 'failed' (orphan cleanup) before determining the restart point. There is + no window where two nodes appear simultaneously "in progress" for the + same run, and no window where it is unclear which node should run next. + +## Concurrent Run Isolation + +4. **Never interleave writes between concurrent runs in a way that corrupts + either run's state.** Two runs against the same document pile — whether + they are different piles or the same pile hit twice — must execute with + full isolation. Specifically: + + - Each run's `run_steps` rows are scoped by `run_id`. A checkpoint write + for run A must never overwrite, duplicate, or interleave with run B's + checkpoint rows. + - Shared mutable state (e.g. document status columns, claim records) must + be protected by per-run_id advisory locks so that concurrent runs + serialize access to any shared row. + - On completion, each run must contain exactly the set of results it + produced — no duplicates from the other run, no missing entries caused + by a lost update. + + The implementation uses per-run_id scoping (all store operations filter + on `run_id`) combined with threading locks on the store to prevent + data races. In production Postgres, this maps to row-level locking via + `SELECT ... FOR UPDATE` or advisory locks per run_id. + +## Approval Gate Isolation + +5. **Approving or rejecting one queue item must never discard or corrupt + other items in the same batch.** The approval queue holds pending items + (findings, conflicts, proposed updates) tied to a `run_id`. Each item + is decided independently: + + - Rejecting item X has zero effect on item Y's committed state. + - Approving item Y does not modify, delete, or change the status of + any other item in the queue. + - Decisions are atomic per-item: a decision write either fully succeeds + (status transitions from 'pending' to 'approved'/'rejected', decision + row is recorded) or fully fails (item remains 'pending'). There is no + partial state. + - The queue must be drivable programmatically (REST endpoint), not only + via UI. A program must be able to approve/reject items without human + interaction. + +## Document Piles + +9. **Documents belong to a pile.** Every document in the system is associated + with exactly one pile via the `pile_documents` junction table. A pile is + the unit of work handed to a pipeline run — it groups related documents + (e.g. a loan application package) so they are processed together. + +10. **A run is started against a pile (and a playbook).** The `runs` table + carries an explicit `pile_id` foreign key. The pipeline processes all + documents in the pile at run-start time. This is the canonical way to + tell the system "these documents go together." + +11. **New documents can be added to an existing pile later; this does not + automatically rewrite previous run results.** Adding a document to a pile + after a run has completed (or while one is in progress) does NOT + retroactively modify that run's checkpoints, findings, or decisions. + A new run must be started explicitly to incorporate the new document. + +12. **Two concurrent runs against the same pile must not corrupt each other.** + This extends invariant 4. Per-run_id advisory locks already protect + checkpoint writes. The pile's document list is read at run-start time + and treated as immutable for the duration of that run — concurrent + additions to the pile are invisible to an in-flight run. + +## Finding Integrity (Rules Checking Stage) + +13. **Findings are produced if and only if a rule evaluation returns verdict + "fail".** The rules checking stage never pads, forces, or synthesizes + findings. Specifically: + + - A Finding is produced only when `verdict == "fail"`. Verdicts of + `pass`, `not_applicable`, or `insufficient_evidence` never produce + findings. + - When no rules are violated, the findings list is empty — never + populated with synthetic entries. + - Every Finding carries the exact source span (start_offset, end_offset, + text) that triggered the violation. Offsets are absolute positions in + the original document. + - Every Finding records the evaluation_method ("llm" or "structured") + that produced it. + +14. **Adding a compliance rule never requires modifying Python source files.** + New rules are added by editing YAML playbook files under `rules/`. The + pipeline evaluates any rule present in a valid playbook without code + changes. This is validated by test (`test_extensibility.py`). + +15. **Rule evaluation errors are correctly classified.** LLM API failures + produce transient errors (retryable). Unknown playbook IDs or invalid + YAML produce permanent errors (stop immediately). Zero applicable rules + or zero source spans produce empty findings with status "completed" — + never an error state. diff --git a/extensions/A-ES/pledger/supa_doccs/docs/literature_review.md b/extensions/A-ES/pledger/supa_doccs/docs/literature_review.md new file mode 100644 index 000000000..ca0c73d2f --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/docs/literature_review.md @@ -0,0 +1,113 @@ +# Literature Review — Agentic Document Intelligence for Microfinance Compliance + +**Scope.** This review surveys the literature underpinning a system that ingests a pile of financial documents (loan agreements, modification agreements, repayment statements as PDF/DOCX/plain text), extracts structured facts with source-span provenance, evaluates declarative compliance playbooks, routes findings through a human approval gate, and guarantees checkpointed resumability and concurrent-run isolation. Five themes structure the discussion; each closes by connecting an identified gap to a concrete design choice recorded in `DECISIONS.md`. + +--- + +## 1. Document AI & Information Extraction + +Extracting structured facts from financial documents sits between two research traditions. Vision-layout pretraining models such as LayoutLM jointly model text tokens and their 2-D bounding-box coordinates, achieving strong results on scanned forms and receipts [1]; Donut removes the OCR dependency entirely with an end-to-end encoder-decoder over document images [2]. Both lines presuppose layout signal and substantial task-specific training data, which born-digital, text-layer loan documents do not consistently reward. Benchmark work confirms the problem remains open even in narrow business domains: the DocILE benchmark shows information localization and extraction on semi-structured business documents is far from solved, with neither neural nor rule-based baselines dominating across categories [3]. At the parsing layer, Bast and Korzen demonstrate that PDF-to-text tools disagree substantially on line, paragraph, and reading-order reconstruction — meaning upstream extraction inherits parser-dependent noise before any intelligence is applied [4]. Finally, zero-shot LLM extraction promises robustness to lexical variation, but introduces hallucination risk: fabricated or normalized values that look plausible and carry no verifiable anchor [5]. + +**Gap.** The literature offers either layout-hungry supervised models, opaque end-to-end transformers, or brittle hand-written patterns; there is little guidance on combining them under an auditability constraint, and benchmark comparisons of zero-shot LLM extraction against fine-tuned layout models specifically on financial-contract prose remain scarce [CITATION NEEDED]. Crucially, none of these approaches treats provenance as a first-class output of the extractor. + +**Connection to this system.** Our own paraphrase experiment (`DECISIONS.md`, 2026-08-12) reproduced DocILE's lesson locally: regex extractors achieved 100% field coverage on templated documents but collapsed to 17% on naturally reworded equivalents — the combinatorial space of legal phrasing cannot be enumerated. The resolution recorded in `DECISIONS.md` (2026-08-26) is a deliberate hybrid rather than a wholesale bet on either pole: type-specific structured extractors run first for template-mandated formats, and every field they miss is re-extracted by an LLM whose per-field output must include a verbatim quoted span, programmatically located in the source text to compute exact character offsets. Each claim records which strategy produced it (`structured`, `llm_fallback`, or `llm`), so downstream auditing knows how much to trust each fact. + +--- + +## 2. Agentic Pipelines & Workflow Orchestration + +Modern LLM agents trace to ReAct, which interleaves chain-of-thought reasoning with tool actions and showed large gains over reasoning- or acting-alone [6]. Multi-agent frameworks such as AutoGen generalize this into conversational programs where agents collaborate through typed message exchanges [7]. These works define *control flow* but leave *durability* undefined: agent state lives in memory, and a crash mid-run loses progress. The durable-execution tradition addresses precisely this. Durable Functions gives stateful serverless workflows replay semantics in which function progress survives process death by construction [8], and the workflow-patterns canon provides the control-flow vocabulary — cancellation, compensation, milestone resume — that long-running pipelines implicitly rely on [9]. Among AI-native frameworks, LangGraph packages these ideas for agents: typed persistent state, checkpointers, conditional edges, and `interrupt()` nodes purpose-built for pausing execution on a human gate [10]. + +**Gap.** General-purpose durable-execution engines offer rigorous crash semantics but no native AI/HITL abstractions, while AI-native frameworks offer the right abstractions atop runtimes still maturing — early coupling risks inheriting breaking changes in exchange for features the application could own. Little published work validates kill-and-resume behavior for LLM pipelines with property-based tests rather than anecdote. + +**Connection to this system.** This tension explains our orchestration split (`README.md`, Architecture Decisions): `langgraph` is installed and `graph.py` compiles a real `StateGraph` with all 13 nodes and conditional edges, keeping the topology first-class, but production runs execute through our own executor, which drives nodes sequentially with PostgreSQL-backed per-node checkpoints — owning the durability contract directly rather than delegating it. Kill-and-resume invariants ("never re-run a completed node's side effects") and concurrent-run isolation (per-run scoping plus optimistic version columns) are enforced by dedicated property and concurrency test suites, mirroring the replay discipline of [8] while remaining portable to LangGraph's runtime checkpointer when needed. + +--- + +## 3. Human-in-the-Loop Systems + +Trust calibration theory distinguishes appropriate reliance from overtrust and undertrust, arguing automation interfaces must convey confidence and limits so operators calibrate rather than defer [11]. Horvitz's mixed-initiative principles formalize when a system should act autonomously versus defer to the human [12]. Empirical work sharpens this for AI advice: Buçinca et al. show cognitive forcing functions — prompting the reviewer to form an independent judgment first — reduce overreliance more effectively than adding explanations, which often deepen deference [13]. The interactive-ML tradition frames the human as a continuous participant providing labels, corrections, and supervision throughout a system's life [14], and Microsoft's synthesis of overreliance research identifies presentation of uncertainty and evidence quality as the dominant levers [15]. + +**Gap.** Most of this literature studies model-development labeling or single-shot advisory interactions; regulated document pipelines instead need *durable, item-level, attributable* approvals embedded in workflow execution state — a pause that survives restarts, produces an immutable record of who decided what and why, and cannot silently duplicate or lose requests. Publication-quality evidence that item-level justification requirements improve downstream reviewer calibration in audit settings is also thin [CITATION NEEDED]. + +**Connection to this system.** This is why the approval gate is a first-class graph node rather than a UI afterthought: `interrupt()`-style pause points enqueue items into a Postgres-backed `approval_queue`; decisions are written atomically under row locks with a guard trigger enforcing decision-before-status ordering; `reviewer_id` and free-text justification are mandatory; re-entry deduplicates by claim so reviewers are never asked twice; and cross-document contradictions arrive with both source attributions attached — an evidence-presentation choice aimed squarely at calibrated review [13], [15]. + +--- + +## 4. RegTech & Compliance Automation + +Arner, Barberis, and Buckley chart the shift from manual to technology-mediated regulation, positioning RegTech as the digitization of compliance itself [16]. The engineering lineage runs back to production rule systems: Forgy's RETE algorithm made large condition-action rule bases tractable [17], and formal treatments of business-process compliance established how obligations and prohibitions map onto executable checks [18]. Yet classic rule engines suffer a persistent knowledge-acquisition bottleneck: rules live in code or proprietary formats maintained by engineers, while the source regulations are prose. The regulatory substrate relevant here includes disclosure regimes mandating APR computation and presentation in consumer credit [19], and supervisory guidance warning that opaque algorithmic credit decisions still carry full adverse-action notification obligations — i.e., lenders must be able to explain specific reasons derived from automated systems [20]. + +**Gap.** Two failure modes bracket the space. Rules-as-code makes compliance auditable but freezes domain experts out — every new clause requires a developer — while leaving the interpretation of natural-language contract language entirely to brittle string matching. Empirical evidence on how interest-rate caps actually perform in microfinance markets — and therefore how a rate-cap rule should behave on edge cases — is contested and jurisdiction-specific [CITATION NEEDED]. + +**Connection to this system.** This is why compliance rules live entirely in YAML playbooks under `rules/`: adding MF-001 (APR ≤ 36%) or MF-004 (penalty ≤ 5% of outstanding) is a reviewed git diff touching zero `.py` files; a `playbook_id` whitelist makes playbook selection an explicit, auditable per-run parameter; and each rule declares either an LLM evaluator (open-ended language) or a `structured` checker (deterministic numerics) — the same flexibility/rigor layering the RegTech literature calls for but rarely operationalizes [16], [18]. + +--- + +## 5. Provenance & Explainability + +The W3C PROV data model remains the canonical vocabulary for derivation provenance — entities, activities, and the agents connecting them [21]. Within NLP, the ALCE benchmark evaluates whether LLMs can generate output with accurate supporting citations, finding citation quality lags fluency badly [22]. Post-hoc explainability methods fare worse under scrutiny: LIME popularized local surrogate explanations [23], but Jain and Wallace show attention weights — often presented as rationales — frequently fail to identify what drives predictions, cautioning against treating plausible-looking attributions as evidence [24]. Financial regulators impose stricter demands than academic explainability: the Federal Reserve's model-risk guidance requires documentation sufficient for effective challenge, conceptual soundness review, and reproducible outcomes for models used in supervisory contexts [25]. + +**Gap.** Provenance research concentrates on citing generated *text* or tracking dataset/model lineage; comparatively little work binds individual *structured fields* extracted from documents to exact source locations under database-enforced integrity, with an append-only event log capturing every mutation in the same transaction. Citation-style "the answer came from somewhere in this document" does not survive an auditor asking *which characters justify this number*. + +**Connection to this system.** This is why provenance is schema-enforced rather than convention: every claim references rows in `source_locations` carrying document ID and character-offset spans; `audit_events` are appended in the same transaction as the change they describe; `document_versions` preserves history so deliverables are reconstructible; and the "no silent overwrite" invariant routes contradicting evidence to the approval queue instead of mutating prior findings. LLM-extracted facts implement the citation discipline ALCE measures as lacking: the model must return a verbatim quoted span, which is located exactly (or normalized-matched) in the source; when location fails, the claim survives but is downgraded to `citation_status: "unverifiable"` with its span zeroed — an explicit, queryable admission of unanchored output rather than a plausible-looking citation. This is effective-challenge readiness in the spirit of [25], grounded at span level rather than document level. + +--- + +## Synthesis + +Read together, the five literatures describe a fork. On one branch, LLM-agent pipelines [6], [7] offer unmatched tolerance of natural-language variation but ship with hallucination risk [5], weak citation fidelity [22], in-memory state unsuited to long compliance runs [8], and review interactions too shallow to calibrate trust [13]. On the other, rule engines [17], [18] are perfectly auditable yet rigid: they demand that reality phrase itself in the grammar of their patterns — a demand our paraphrase experiments show real documents refuse (17% coverage on reworded text). The architecture of this system occupies the deliberate middle ground. Stochastic components (LLM extraction fallback, LLM rule evaluation) are retained for linguistic reach, but every output they produce is (a) anchored to exact source offsets or explicitly flagged unverifiable, (b) evaluated against declarative YAML playbooks diffable in git, (c) subject to durable, attributable human approval before taking effect, and (d) executed inside a checkpointed, concurrently-isolated run fabric with transactionally-logged mutations. Determinism here is not achieved by eliminating probabilistic machinery but by structurally enclosing it: flexibility at the edges, auditability in the frame. That enclosure — provenance-bearing hybrid extraction, DSL-governed evaluation, interrupt-based HITL, replay-safe orchestration — is the contribution this project claims relative to both poles. + +--- + +## References + +[1] Y. Xu, M. Li, L. Cui, S. Huang, F. Wei, and M. Zhou, "LayoutLM: Pre-training of text and layout for document image understanding," in *Proc. 26th ACM SIGKDD Conf. Knowledge Discovery and Data Mining (KDD)*, 2020, pp. 1192–1200. + +[2] G. Kim *et al.*, "OCR-free document understanding transformer," in *Proc. European Conf. Computer Vision (ECCV)*, 2022, pp. 498–517. + +[3] Š. Šimsa *et al.*, "DocILE benchmark for document information localization and extraction," in *Proc. 61st Annual Meeting of the Association for Computational Linguistics (ACL)*, 2023. + +[4] H. Bast and C. Korzen, "A benchmark and evaluation for text extraction from PDF," in *Proc. 14th Int. Conf. Document Analysis and Recognition (ICDAR)*, 2017. + +[5] Z. Ji, N. Lee, R. Frieske, T. Yu, D. Su, Y. Xu, E. Ishii, Y. J. Bang, A. Madotto, and P. Fung, "Survey of hallucination in natural language generation," *ACM Computing Surveys*, vol. 55, no. 12, pp. 1–38, 2023. + +[6] S. Yao *et al.*, "ReAct: Synergizing reasoning and acting in language models," in *Proc. Int. Conf. Learning Representations (ICLR)*, 2023. + +[7] Q. Wu *et al.*, "AutoGen: Enabling next-gen LLM applications via multi-agent conversation," *arXiv preprint arXiv:2308.08155*, 2023. + +[8] S. Burckhardt, B. Chandramouli, C. Gilligan, X. Huang, J. Meijer, R. P. Spina, and V. Vuppalapati, "Durable Functions: Semantics for stateful serverless," *Proc. ACM Programming Languages*, vol. 5, no. OOPSLA, 2021. + +[9] W. M. P. van der Aalst, A. H. M. ter Hofstede, B. Kiepuszewski, and A. P. Barros, "Workflow patterns," *Distributed and Parallel Databases*, vol. 14, no. 1, pp. 5–51, 2003. + +[10] LangChain Inc., "LangGraph documentation," https://langchain-ai.github.io/langgraph/, accessed Aug. 2026. + +[11] J. D. Lee and K. A. See, "Trust in automation: Designing for appropriate reliance," *Human Factors*, vol. 46, no. 1, pp. 50–80, 2004. + +[12] E. Horvitz, "Principles of mixed-initiative user interfaces," in *Proc. SIGCHI Conf. Human Factors in Computing Systems (CHI)*, 1999, pp. 159–166. + +[13] Z. Buçinca, M. B. Malaya, and K. Z. Gajos, "To trust or to think: Cognitive forcing functions can reduce overreliance on AI in AI-assisted decision-making," *Proc. ACM Human-Computer Interaction*, vol. 5, no. CSCW1, 2021. + +[14] S. Amershi, M. Cakmak, W. B. Knox, and T. Kulesza, "Power to the people: The role of humans in interactive machine learning," *AI Magazine*, vol. 35, no. 4, pp. 105–120, 2014. + +[15] S. Passi and M. Vorvoreanu, "Overreliance on AI: A literature review," Microsoft Research, Redmond, WA, USA, Tech. Rep., 2022. + +[16] D. W. Arner, J. Barberis, and R. P. Buckley, "FinTech, RegTech, and the reconceptualization of financial regulation," *Northwestern Journal of International Law & Business*, vol. 37, no. 3, pp. 371–413, 2017. + +[17] C. L. Forgy, "Rete: A fast algorithm for the many pattern/many object pattern match problem," *Artificial Intelligence*, vol. 19, no. 1, pp. 17–37, 1982. + +[18] G. Governatori and S. Sadiq, "The journey to business process compliance," in *Handbook of Research on Business Process Modeling*. Hershey, PA, USA: IGI Global, 2009, pp. 426–454. + +[19] Truth in Lending Act, 15 U.S.C. § 1601 *et seq.*; implementing regulation: Consumer Financial Protection Bureau, "Regulation Z (Truth in Lending)," 12 C.F.R. Part 1026. + +[20] Consumer Financial Protection Bureau, "Adverse action notification requirements in connection with credit decisions based on complex algorithms," Circular 2022-03, Washington, DC, USA, 2022. + +[21] L. Moreau and P. Missier, Eds., "PROV-DM: The PROV data model," W3C Recommendation, Apr. 2013. + +[22] T. Gao, H. Yen, J. Yu, and D. Chen, "Enabling large language models to generate text with citations," in *Proc. Conf. Empirical Methods in Natural Language Processing (EMNLP)*, 2023. + +[23] M. T. Ribeiro, S. Singh, and C. Guestrin, "'Why should I trust you?': Explaining the predictions of any classifier," in *Proc. 22nd ACM SIGKDD Conf. Knowledge Discovery and Data Mining (KDD)*, 2016, pp. 1135–1144. + +[24] S. Jain and B. C. Wallace, "Attention is not explanation," in *Proc. Conf. North American Chapter of the Association for Computational Linguistics (NAACL-HLT)*, 2019, pp. 3543–3556. + +[25] Board of Governors of the Federal Reserve System, "Supervisory guidance on model risk management," SR Letter 11-7, Washington, DC, USA, 2011. diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/.gitignore b/extensions/A-ES/pledger/supa_doccs/frontend/.gitignore new file mode 100644 index 000000000..e0dfe680b --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Test coverage +coverage/ + +# Playwright +test-results/ +playwright-report/ diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/README.md b/extensions/A-ES/pledger/supa_doccs/frontend/README.md new file mode 100644 index 000000000..188f9f217 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/README.md @@ -0,0 +1,19 @@ +# Frontend + +React + Vite review interface for the approval gate: reviewers inspect pending +findings/conflicts with their source citations and approve or reject them +individually. Talks to the FastAPI backend at `localhost:8000` (stateless; runs +as a separate Vite dev server, not containerized). + +## Commands + +```bash +npm install # first time only +npm run dev # dev server at http://localhost:5173 (backend must be up: make demo) +npm test # Vitest unit tests +npm run lint # ESLint +npm run build # type-check (tsc -b) + production build to dist/ +``` + +End-to-end tests use Playwright (`e2e/`, config in `playwright.config.ts`, +reports in `playwright-report/`). diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/e2e/approve.spec.ts b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/approve.spec.ts new file mode 100644 index 000000000..6cad8b054 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/approve.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, navigateToReview } from "./helpers/setup"; + +/** + * Playwright integration test for the approve flow. + * Validates: Requirements 12.1 + * + * Steps: + * 1. Navigate to /review and wait for queue to load + * 2. Select a pending item (item-001) + * 3. Verify the detail panel shows item payload + * 4. Enter justification text + * 5. Click Approve + * 6. Verify item status updates to approved in detail panel and queue list + */ +test.describe("Approve Flow", () => { + test("approving a pending item updates status to approved", async ({ + page, + }) => { + // Step 1: Navigate to /review and wait for initial load + await navigateToReview(page); + + // Step 2: Wait for queue items to render and click on a pending item (item-001) + const queueItems = page.getByRole("option"); + await expect(queueItems.first()).toBeVisible(); + + // Find and click the pending item-001 (finding about capital adequacy) + const pendingItem = queueItems.filter({ hasText: "capital adequacy" }); + await pendingItem.click(); + + // Step 3: Verify the detail panel displays the item payload + const detailPanel = page.getByRole("region", { name: /detail/i }); + await expect(detailPanel).toBeVisible(); + await expect( + detailPanel.getByText(/capital adequacy/i) + ).toBeVisible(); + + // Step 4: Enter justification text in the textarea + const justificationInput = page.getByLabel("Decision justification"); + await expect(justificationInput).toBeVisible(); + await justificationInput.fill( + "Reviewed and confirmed compliance with Section 4.2 requirements." + ); + + // Step 5: Click the Approve button + const approveButton = page.getByRole("button", { name: /approve/i }); + await expect(approveButton).toBeEnabled(); + await approveButton.click(); + + // Step 6: Verify the item status updates to approved + // Check detail panel shows approved status + await expect( + detailPanel.getByText(/approved/i) + ).toBeVisible(); + + // Check queue list item also shows approved status + const queueItemInList = queueItems.filter({ + hasText: "capital adequacy", + }); + await expect(queueItemInList.getByText(/approved/i)).toBeVisible(); + }); +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/e2e/fixtures/mockData.ts b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/fixtures/mockData.ts new file mode 100644 index 000000000..5667b0f37 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/fixtures/mockData.ts @@ -0,0 +1,280 @@ +import type { + QueueItem, + QueueListResponse, + RunSummary, + PipelineProgress, +} from "../../src/types/review"; + +// --- Runs --- + +export const mockRuns: RunSummary[] = [ + { + id: "run-001", + status: "running", + started_at: "2024-06-01T10:00:00Z", + ended_at: null, + }, + { + id: "run-002", + status: "completed", + started_at: "2024-05-28T08:30:00Z", + ended_at: "2024-05-28T09:15:00Z", + }, + { + id: "run-003", + status: "paused", + started_at: "2024-06-02T14:00:00Z", + ended_at: null, + }, +]; + +// --- Queue Items --- + +export const mockQueueItems: QueueItem[] = [ + { + id: "item-001", + run_id: "run-001", + item_type: "finding", + payload: { + summary: "Potential non-compliance with Section 4.2 capital adequacy requirements", + details: { severity: "high", rule_id: "CAP-4.2" }, + source_citations: [ + { + claim_id: "claim-001", + claim_text: "The institution maintains a capital ratio of 8.5%", + citation_status: "grounded", + source_location: { + page_number: 12, + section_id: "sec-4.2", + start_offset: 145, + end_offset: 210, + clause_ref: "§4.2.1", + }, + }, + ], + }, + status: "pending", + queued_at: "2024-06-01T10:15:00Z", + decided_at: null, + decision: null, + reviewer_id: null, + justification: null, + }, + { + id: "item-002", + run_id: "run-001", + item_type: "conflict", + payload: { + summary: "Conflicting statements regarding loan-to-value ratios in sections 3.1 and 5.4", + details: { severity: "medium", sections: ["3.1", "5.4"] }, + source_citations: [ + { + claim_id: "claim-002", + claim_text: "Maximum LTV ratio is 80%", + citation_status: "grounded", + source_location: { + page_number: 8, + section_id: "sec-3.1", + start_offset: 50, + end_offset: 95, + clause_ref: "§3.1.3", + }, + }, + { + claim_id: "claim-003", + claim_text: "LTV ratios may exceed regulatory thresholds", + citation_status: "unverifiable", + source_location: null, + }, + ], + }, + status: "pending", + queued_at: "2024-06-01T10:20:00Z", + decided_at: null, + decision: null, + reviewer_id: null, + justification: null, + }, + { + id: "item-003", + run_id: "run-001", + item_type: "proposed_update", + payload: { + summary: "Update interest rate disclosure language to match revised regulatory guidance", + details: { target_section: "6.1", update_type: "language" }, + source_citations: [ + { + claim_id: "claim-004", + claim_text: "Interest rates shall be disclosed in APR format", + citation_status: "grounded", + source_location: { + page_number: 22, + section_id: "sec-6.1", + start_offset: 0, + end_offset: 55, + clause_ref: "§6.1.2", + }, + }, + ], + }, + status: "approved", + queued_at: "2024-06-01T10:05:00Z", + decided_at: "2024-06-01T11:00:00Z", + decision: "approved", + reviewer_id: "reviewer-A", + justification: "Language aligns with latest regulatory guidance.", + }, + { + id: "item-004", + run_id: "run-001", + item_type: "finding", + payload: { + summary: "Missing disclosure of fee schedule changes effective Q3 2024", + details: { severity: "low", rule_id: "DISC-7.3" }, + source_citations: [ + { + claim_id: "claim-005", + claim_text: "Fee schedules were updated in March 2024", + citation_status: "unverifiable", + source_location: null, + }, + { + claim_id: "claim-006", + claim_text: "Customers must be notified 30 days prior to fee changes", + citation_status: "grounded", + source_location: { + page_number: 5, + section_id: "sec-7.3", + start_offset: 200, + end_offset: 270, + clause_ref: null, + }, + }, + ], + }, + status: "pending", + queued_at: "2024-06-01T10:30:00Z", + decided_at: null, + decision: null, + reviewer_id: null, + justification: null, + }, + { + id: "item-005", + run_id: "run-001", + item_type: "conflict", + payload: { + summary: "Data retention policy conflicts between privacy section and appendix B", + details: { severity: "high", sections: ["2.4", "appendix-B"] }, + source_citations: [ + { + claim_id: "claim-007", + claim_text: "Data retained for 7 years per regulation", + citation_status: "grounded", + source_location: { + page_number: 4, + section_id: "sec-2.4", + start_offset: 80, + end_offset: 130, + clause_ref: "§2.4.1", + }, + }, + ], + }, + status: "rejected", + queued_at: "2024-06-01T10:10:00Z", + decided_at: "2024-06-01T11:30:00Z", + decision: "rejected", + reviewer_id: "reviewer-B", + justification: "Conflict does not exist — appendix B references archived policy.", + }, + { + id: "item-006", + run_id: "run-001", + item_type: "proposed_update", + payload: { + summary: "Add risk disclosure paragraph to executive summary", + details: { target_section: "1.0", update_type: "addition" }, + source_citations: [ + { + claim_id: "claim-008", + claim_text: "Executive summaries must include material risk factors", + citation_status: "unverifiable", + source_location: null, + }, + ], + }, + status: "pending", + queued_at: "2024-06-01T10:45:00Z", + decided_at: null, + decision: null, + reviewer_id: null, + justification: null, + }, +]; + +// --- Queue List Responses --- + +export const mockQueueResponse: QueueListResponse = { + run_id: "run-001", + items: mockQueueItems, + total: mockQueueItems.length, + pending: mockQueueItems.filter((i) => i.status === "pending").length, +}; + +// --- Pipeline Progress --- + +export const mockProgressRunning: PipelineProgress = { + current_node: "match_rules_against_sources", + completed_nodes: [ + "ingest", + "extract_text", + "classify_document", + "chunk", + "embed", + "extract_claims", + "match_rules", + ], + node_status: "completed", + run_status: "running", +}; + +export const mockProgressCompleted: PipelineProgress = { + current_node: null, + completed_nodes: [ + "ingest", + "extract_text", + "classify_document", + "chunk", + "embed", + "extract_claims", + "match_rules", + "match_rules_against_sources", + "merge_findings", + "score_confidence", + "route_to_queue", + "human_review", + "finalize", + ], + node_status: null, + run_status: "completed", +}; + +export const mockProgressPaused: PipelineProgress = { + current_node: "human_review", + completed_nodes: [ + "ingest", + "extract_text", + "classify_document", + "chunk", + "embed", + "extract_claims", + "match_rules", + "match_rules_against_sources", + "merge_findings", + "score_confidence", + "route_to_queue", + ], + node_status: "completed", + run_status: "paused", +}; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/e2e/helpers/mockServer.ts b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/helpers/mockServer.ts new file mode 100644 index 000000000..1fc5078bb --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/helpers/mockServer.ts @@ -0,0 +1,202 @@ +import { Page } from "@playwright/test"; +import type { + QueueListResponse, + QueueItem, + DecisionResponse, + PipelineProgress, + RunSummary, +} from "../../src/types/review"; + +/** + * Intercepts GET /approval/runs/{run_id}/queue and returns mock queue data. + */ +export async function mockQueueApi( + page: Page, + data: QueueListResponse +): Promise { + await page.route("**/approval/runs/*/queue", (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(data), + }); + }); +} + +/** + * Intercepts GET /approval/items/{item_id} and returns the matching item + * from the provided list, or 404 if not found. + */ +export async function mockItemApi( + page: Page, + items: QueueItem[] +): Promise { + await page.route("**/approval/items/*", (route, request) => { + if (request.method() !== "GET") { + route.fallback(); + return; + } + const url = request.url(); + const itemId = url.split("/approval/items/")[1]?.split("/")[0]?.split("?")[0]; + const item = items.find((i) => i.id === itemId); + + if (item) { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(item), + }); + } else { + route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: "Item not found" }), + }); + } + }); +} + +/** + * Intercepts POST /approval/items/{item_id}/decide and returns a success response. + * Optionally accepts a custom handler for simulating errors (409, 500, etc.). + */ +export async function mockDecisionApi( + page: Page, + handler?: (itemId: string, body: Record) => { + status: number; + body: DecisionResponse | { error: string }; + } +): Promise { + await page.route("**/approval/items/*/decide", async (route, request) => { + if (request.method() !== "POST") { + route.fallback(); + return; + } + + const url = request.url(); + const itemId = url.split("/approval/items/")[1]?.split("/decide")[0]; + const body = JSON.parse(request.postData() || "{}"); + + if (handler) { + const result = handler(itemId, body); + route.fulfill({ + status: result.status, + contentType: "application/json", + body: JSON.stringify(result.body), + }); + } else { + const response: DecisionResponse = { + item_id: itemId, + decision: body.decision, + success: true, + error: null, + }; + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(response), + }); + } + }); +} + +/** + * Intercepts the runs endpoint and returns mock run summaries. + */ +export async function mockRunsApi( + page: Page, + data: RunSummary[] +): Promise { + await page.route("**/runs", (route, request) => { + if (request.method() === "GET" || request.method() === "POST") { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(data), + }); + } else { + route.fallback(); + } + }); +} + +/** + * Intercepts GET /runs/{run_id}/history and returns mock pipeline progress data. + */ +export async function mockProgressApi( + page: Page, + data: PipelineProgress +): Promise { + await page.route("**/runs/*/history", (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(data), + }); + }); +} + +/** + * Intercepts POST /runs/{run_id}/resume and returns a success response + * with updated progress data. + */ +export async function mockResumeApi( + page: Page, + progressAfterResume?: PipelineProgress +): Promise { + await page.route("**/runs/*/resume", (route, request) => { + if (request.method() !== "POST") { + route.fallback(); + return; + } + + const response = progressAfterResume ?? { + current_node: "human_review", + completed_nodes: [ + "ingest", + "extract_text", + "classify_document", + "chunk", + "embed", + "extract_claims", + "match_rules", + "match_rules_against_sources", + "merge_findings", + "score_confidence", + "route_to_queue", + ], + node_status: "completed", + run_status: "running", + }; + + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(response), + }); + }); +} + +/** + * Sets up all mock API routes with the provided data. + * Convenience function for common test setup. + */ +export async function setupAllMocks( + page: Page, + options: { + queue: QueueListResponse; + runs: RunSummary[]; + progress: PipelineProgress; + decisionHandler?: (itemId: string, body: Record) => { + status: number; + body: DecisionResponse | { error: string }; + }; + } +): Promise { + await mockQueueApi(page, options.queue); + await mockItemApi(page, options.queue.items); + await mockRunsApi(page, options.runs); + await mockProgressApi(page, options.progress); + await mockDecisionApi(page, options.decisionHandler); + await mockResumeApi(page); +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/e2e/helpers/setup.ts b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/helpers/setup.ts new file mode 100644 index 000000000..44cf4febb --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/helpers/setup.ts @@ -0,0 +1,60 @@ +import { test as base, expect, Page } from "@playwright/test"; +import { setupAllMocks } from "./mockServer"; +import { + mockRuns, + mockQueueResponse, + mockProgressRunning, +} from "../fixtures/mockData"; +import type { + QueueListResponse, + RunSummary, + PipelineProgress, + DecisionResponse, +} from "../../src/types/review"; + +/** + * Extended test fixtures with pre-configured mock API data. + * Tests can override any fixture by providing custom values. + */ +interface ReviewFixtures { + queueData: QueueListResponse; + runsData: RunSummary[]; + progressData: PipelineProgress; + decisionHandler: + | ((itemId: string, body: Record) => { + status: number; + body: DecisionResponse | { error: string }; + }) + | undefined; +} + +export const test = base.extend({ + queueData: [mockQueueResponse, { option: true }], + runsData: [mockRuns, { option: true }], + progressData: [mockProgressRunning, { option: true }], + decisionHandler: [undefined, { option: true }], + + page: async ( + { page, queueData, runsData, progressData, decisionHandler }, + use + ) => { + await setupAllMocks(page, { + queue: queueData, + runs: runsData, + progress: progressData, + decisionHandler, + }); + await use(page); + }, +}); + +export { expect }; + +/** + * Navigate to the review page and wait for the initial data to load. + */ +export async function navigateToReview(page: Page): Promise { + await page.goto("/review"); + // Wait for the queue list to be rendered + await page.waitForSelector("[role='listbox']", { timeout: 10_000 }); +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/e2e/reject.spec.ts b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/reject.spec.ts new file mode 100644 index 000000000..c15aa0ee4 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/reject.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, navigateToReview } from "./helpers/setup"; + +/** + * Playwright integration test: Reject flow with isolation verification. + * Validates: Requirements 12.2 + * + * Verifies that submitting a reject decision updates the target item to + * "rejected" status while all other queue items remain unchanged. + */ +test.describe("Reject flow with isolation", () => { + test("rejecting a pending item updates its status without affecting others", async ({ + page, + }) => { + await navigateToReview(page); + + // Wait for queue items to render + const listbox = page.locator("[role='listbox']"); + await expect(listbox).toBeVisible(); + + const items = listbox.locator("[role='option']"); + await expect(items).toHaveCount(6); + + // Record original statuses for all 6 items before the action. + // Mock data order: item-001 (pending), item-002 (pending), item-003 (approved), + // item-004 (pending), item-005 (rejected), item-006 (pending) + const originalStatuses: string[] = []; + for (let i = 0; i < 6; i++) { + const statusText = await items.nth(i).locator("span.rounded-full").innerText(); + originalStatuses.push(statusText.trim()); + } + + // Verify expected initial state + expect(originalStatuses[0]).toBe("pending"); + expect(originalStatuses[1]).toBe("pending"); + expect(originalStatuses[2]).toBe("approved"); + expect(originalStatuses[3]).toBe("pending"); + expect(originalStatuses[4]).toBe("rejected"); + expect(originalStatuses[5]).toBe("pending"); + + // Click on item-002 (index 1) — a pending conflict item + await items.nth(1).click(); + + // Wait for detail panel to show the decision controls + const justificationInput = page.locator( + "textarea[aria-label='Decision justification']" + ); + await expect(justificationInput).toBeVisible(); + + // Enter justification text + await justificationInput.fill( + "Conflict is valid — LTV statements are irreconcilable." + ); + + // Click the Reject button + const rejectButton = page.locator("button", { hasText: "Reject" }); + await expect(rejectButton).toBeEnabled(); + await rejectButton.click(); + + // Verify: the rejected item (index 1) now shows "rejected" status + await expect(items.nth(1).locator("span.rounded-full")).toHaveText( + "rejected" + ); + + // Verify: all OTHER items retain their original statuses (isolation check) + for (let i = 0; i < 6; i++) { + if (i === 1) continue; // Skip the item we just rejected + const currentStatus = await items + .nth(i) + .locator("span.rounded-full") + .innerText(); + expect(currentStatus.trim()).toBe(originalStatuses[i]); + } + }); +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/e2e/resume.spec.ts b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/resume.spec.ts new file mode 100644 index 000000000..5d209210c --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/e2e/resume.spec.ts @@ -0,0 +1,140 @@ +import { test, expect, navigateToReview } from "./helpers/setup"; +import { + mockProgressPaused, + mockProgressRunning, + mockQueueItems, + mockQueueResponse, +} from "./fixtures/mockData"; +import type { QueueListResponse } from "../src/types/review"; + +/** + * Playwright integration test for the kill/restart resume path. + * Validates: Requirements 12.3 + * + * Simulates a backend restart scenario by: + * 1. Loading with paused pipeline progress + * 2. Approving a pending item + * 3. Simulating connection loss (network error on queue API) + * 4. Restoring the backend with the approved item persisted + * 5. Verifying decisions persist and progress stepper reflects resumed state + */ +test.describe("Kill/Restart Resume Path", () => { + // Override progressData to use paused state so the run shows as "paused" + test.use({ progressData: mockProgressPaused }); + + test("previously submitted decisions persist after backend restart and progress stepper reflects resumed state", async ({ + page, + }) => { + // Step 1: Navigate to /review with paused progress + await navigateToReview(page); + + // Verify the progress stepper shows "Stay-Alive" stage as in-progress (paused at human_review) + const progressStepper = page.getByRole("list", { + name: /pipeline progress/i, + }); + await expect(progressStepper).toBeVisible(); + + // The "Stay-Alive" stage should be "In progress" since current_node is "human_review" + const stayAliveStage = page.getByRole("listitem", { + name: /Stay-Alive.*In progress/i, + }); + await expect(stayAliveStage).toBeVisible(); + + // Step 2: Approve a pending item (item-001) to simulate a submitted decision + const queueItems = page.getByRole("option"); + await expect(queueItems.first()).toBeVisible(); + + // Click the pending finding item (capital adequacy) + const pendingItem = queueItems.filter({ hasText: "capital adequacy" }); + await pendingItem.click(); + + // Enter justification and approve + const justificationInput = page.getByLabel("Decision justification"); + await expect(justificationInput).toBeVisible(); + await justificationInput.fill("Confirmed compliance per Section 4.2."); + + const approveButton = page.getByRole("button", { name: /approve/i }); + await expect(approveButton).toBeEnabled(); + await approveButton.click(); + + // Verify item shows approved status in the queue list + await expect( + pendingItem.getByText(/approved/i) + ).toBeVisible(); + + // Step 3: Simulate backend restart — make queue API return network error + await page.unroute("**/approval/runs/*/queue"); + await page.route("**/approval/runs/*/queue", (route) => { + route.abort("connectionrefused"); + }); + + // Wait for the connection lost banner to appear (next poll cycle will fail) + const connectionBanner = page.getByRole("alert"); + await expect(connectionBanner).toBeVisible({ timeout: 15_000 }); + await expect( + connectionBanner.getByText(/connection lost/i) + ).toBeVisible(); + + // Step 4: "Restart" the backend — restore the queue API with the approved item persisted + const updatedItems = mockQueueItems.map((item) => + item.id === "item-001" + ? { + ...item, + status: "approved" as const, + decision: "approved" as const, + decided_at: new Date().toISOString(), + reviewer_id: "current-user", + justification: "Confirmed compliance per Section 4.2.", + } + : item + ); + const updatedQueueResponse: QueueListResponse = { + run_id: "run-001", + items: updatedItems, + total: updatedItems.length, + pending: updatedItems.filter((i) => i.status === "pending").length, + }; + + await page.unroute("**/approval/runs/*/queue"); + await page.route("**/approval/runs/*/queue", (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(updatedQueueResponse), + }); + }); + + // Also update the progress endpoint to return "running" state (post-resume) + await page.unroute("**/runs/*/history"); + await page.route("**/runs/*/history", (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mockProgressRunning), + }); + }); + + // Step 5: Verify the connection lost banner disappears + await expect(connectionBanner).toBeHidden({ timeout: 15_000 }); + + // Step 6: Verify the previously approved item is still approved (decision persisted) + const approvedItem = page.getByRole("option").filter({ + hasText: "capital adequacy", + }); + await expect(approvedItem.getByText(/approved/i)).toBeVisible(); + + // Step 7: Verify the progress stepper reflects the resumed/running state + // After re-routing history to mockProgressRunning, the "Examine" stage should show "In progress" + // (current_node is "match_rules_against_sources" which is in Examine stage) + const examineStage = page.getByRole("listitem", { + name: /Examine.*In progress/i, + }); + await expect(examineStage).toBeVisible({ timeout: 15_000 }); + + // The "Understand" stage should be complete + const understandStage = page.getByRole("listitem", { + name: /Understand.*Complete/i, + }); + await expect(understandStage).toBeVisible(); + }); +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/index.html b/extensions/A-ES/pledger/supa_doccs/frontend/index.html new file mode 100644 index 000000000..7ddc8660d --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Anchora — Agentic Document Intelligence + + +
+ + + diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/package-lock.json b/extensions/A-ES/pledger/supa_doccs/frontend/package-lock.json new file mode 100644 index 000000000..a27b7af7b --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/package-lock.json @@ -0,0 +1,7708 @@ +{ + "name": "superdocs-review-interface", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "superdocs-review-interface", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-tooltip": "^1.1.6", + "@xyflow/react": "^12.11.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "tailwindcss": "^3.4.15", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@playwright/test": "^1.49.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "axe-core": "^4.10.2", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "fast-check": "^3.23.1", + "globals": "^15.11.0", + "jsdom": "^25.0.1", + "typescript": "~5.6.2", + "typescript-eslint": "^8.11.0", + "vite": "^6.0.0", + "vitest": "^2.1.6", + "vitest-axe": "^0.1.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.3", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.3.tgz", + "integrity": "sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.80", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.80", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.80.tgz", + "integrity": "sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest-axe": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/vitest-axe/-/vitest-axe-0.1.0.tgz", + "integrity": "sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.0.0", + "axe-core": "^4.4.2", + "chalk": "^5.0.1", + "dom-accessibility-api": "^0.5.14", + "lodash-es": "^4.17.21", + "redent": "^3.0.0" + }, + "peerDependencies": { + "vitest": ">=0.16.0" + } + }, + "node_modules/vitest-axe/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/package.json b/extensions/A-ES/pledger/supa_doccs/frontend/package.json new file mode 100644 index 000000000..92c844e1f --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/package.json @@ -0,0 +1,49 @@ +{ + "name": "superdocs-review-interface", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-tooltip": "^1.1.6", + "@xyflow/react": "^12.11.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "tailwindcss": "^3.4.15", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@playwright/test": "^1.49.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "axe-core": "^4.10.2", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "fast-check": "^3.23.1", + "globals": "^15.11.0", + "jsdom": "^25.0.1", + "typescript": "~5.6.2", + "typescript-eslint": "^8.11.0", + "vite": "^6.0.0", + "vitest": "^2.1.6", + "vitest-axe": "^0.1.0" + } +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/playwright.config.ts b/extensions/A-ES/pledger/supa_doccs/frontend/playwright.config.ts new file mode 100644 index 000000000..de414a4a7 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + timeout: 30_000, + use: { + baseURL: "http://localhost:5173", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/postcss.config.js b/extensions/A-ES/pledger/supa_doccs/frontend/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/public/vite.svg b/extensions/A-ES/pledger/supa_doccs/frontend/public/vite.svg new file mode 100644 index 000000000..af14ee7a0 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/public/vite.svg @@ -0,0 +1 @@ + diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/App.test.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/App.test.tsx new file mode 100644 index 000000000..3c4707440 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/App.test.tsx @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import App from './App' + +describe('App', () => { + it('renders without crashing', () => { + render( + + + + ) + expect(screen.getAllByText('Anchora').length).toBeGreaterThan(0) + }) +}) diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/App.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/App.tsx new file mode 100644 index 000000000..0c0c3b35a --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/App.tsx @@ -0,0 +1,16 @@ +import { Routes, Route } from 'react-router-dom' +import { ReviewPage } from '@/pages/ReviewPage' +import { PipelineCanvas } from '@/pages/PipelineCanvas' +import { LandingPage } from '@/pages/LandingPage' + +function App() { + return ( + + } /> + } /> + } /> + + ) +} + +export default App diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/AddNode.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/AddNode.tsx new file mode 100644 index 000000000..a69844a8d --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/AddNode.tsx @@ -0,0 +1,26 @@ +import { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; + +export const AddNode = memo(function AddNode() { + return ( +
+ + +
+ + +
+ + +
+ ); +}); + +export default AddNode; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DeliverablePanel.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DeliverablePanel.tsx new file mode 100644 index 000000000..38440c5f3 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DeliverablePanel.tsx @@ -0,0 +1,458 @@ +/** + * Deliverable Panel — the assembled register produced by Movement 1 + * (finalize) and mutated by Movement 3 incremental updates. + * + * Data sources (no client-side recomputation): + * GET /runs/{id}/deliverable — sections + claims with inline citations + * GET /runs/{id}/history — incremental_update audit events for the diff + * + * Every claim line shows its source citation inline. A line whose claim + * has no source span (citation_status='unverifiable' or missing citation) + * carries a visually loud UNVERIFIABLE marker per Prompt 3.6. + */ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { + fetchRunDeliverable, + fetchRunHistory, + type RunDeliverable, + type DeliverableSection, + type DeliverableClaim, + type HistoryEntry, +} from '@/services/pipelineApi'; + +interface DeliverablePanelProps { + runId: string | null; + open: boolean; + onClose: () => void; +} + +/** An incremental update event pulled from /history */ +interface IncrementalEvent { + eventId: string; + timestamp: string; + newDocumentId: string | null; + affectedSections: string[]; + conflictsDetected: number; + approvalItemsCreated: number; + sectionHashesBefore: Record; +} + +function parseIncrementalEvents(entries: HistoryEntry[]): IncrementalEvent[] { + return entries + .filter((e) => (e.new_state as Record | null)?.type === 'incremental_update') + .map((e) => { + const ns = (e.new_state ?? {}) as Record; + const ps = (e.previous_state ?? {}) as Record; + return { + eventId: e.event_id, + timestamp: e.timestamp, + newDocumentId: e.source_document_id ?? ns.document_id ?? null, + affectedSections: Array.isArray(ns.affected_sections) ? ns.affected_sections : [], + conflictsDetected: ns.conflicts_detected ?? 0, + approvalItemsCreated: ns.approval_items_created ?? 0, + sectionHashesBefore: ps.section_hashes ?? {}, + }; + }); +} + +// ─── Citation chip ─────────────────────────────────────────────────────────── + +function InlineCitation({ claim }: { claim: DeliverableClaim }) { + const [showSnippet, setShowSnippet] = useState(false); + const citation = claim.citation; + + if (!citation || (!citation.clause_ref && citation.page_number == null && !citation.section_id)) { + return null; // unverifiable rendering handled by the line itself + } + + const label = + citation.clause_ref ?? + (citation.page_number != null ? `p.${citation.page_number}` : citation.section_id!); + const meta = [ + citation.section_id ? `§${citation.section_id}` : null, + citation.start_offset != null && citation.end_offset != null + ? `${citation.start_offset}–${citation.end_offset}` + : null, + ].filter(Boolean).join(' · '); + + return ( + + + {showSnippet && citation.snippet && ( + + + Source excerpt {meta ? `· ${meta}` : ''} + + "{citation.snippet}" + + )} + + ); +} + +// ─── Claim line ────────────────────────────────────────────────────────────── + +function ClaimLine({ claim, changed }: { claim: DeliverableClaim; changed: boolean }) { + const isUnverifiable = + claim.citation_status === 'unverifiable' || + claim.citation_status === 'not_found' || + !claim.citation; + + return ( +
+ {/* Unverifiable marker — must be impossible to miss */} + {isUnverifiable && ( + + + + + Unverifiable + + )} + {changed && !isUnverifiable && ( + + Updated + + )} + +
+

+ {claim.extracted_text} + +

+
+ {claim.claim_id} + conf {(claim.confidence * 100).toFixed(0)}% + + doc {claim.source_document_id.slice(0, 8)} + + {isUnverifiable && ( + no source span recorded at extraction + )} +
+
+
+ ); +} + +// ─── Section card ──────────────────────────────────────────────────────────── + +function SectionCard({ + section, + changedSectionKeys, + hashBefore, +}: { + section: DeliverableSection; + changedSectionKeys: Set; + hashBefore?: string | null; +}) { + const [open, setOpen] = useState(true); + const changed = changedSectionKeys.has(section.key); + const unverifiableCount = section.claims.filter( + (c) => c.citation_status === 'unverifiable' || c.citation_status === 'not_found' || !c.citation + ).length; + + return ( +
+ + + {open && ( +
+ {section.claims.map((claim) => ( + + ))} +
+ )} + + {/* Hash provenance for sections touched by an incremental update */} + {changed && hashBefore && ( +
+ hash before update: {hashBefore.slice(0, 16)}… → now {section.content_hash.slice(0, 16)}… +
+ )} +
+ ); +} + +// ─── Main panel ────────────────────────────────────────────────────────────── + +export function DeliverablePanel({ runId, open, onClose }: DeliverablePanelProps) { + const [deliverable, setDeliverable] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [events, setEvents] = useState([]); + const [selectedEventId, setSelectedEventId] = useState(null); + + const loadAll = useCallback(async (rid: string) => { + // Deliverable and history fetched independently — history failures + // shouldn't hide the deliverable. + const deliverableResult = await fetchRunDeliverable(rid); + setDeliverable(deliverableResult); + + try { + const history = await fetchRunHistory(rid); + setEvents(parseIncrementalEvents(history.entries)); + } catch { + setEvents([]); + } + }, []); + + useEffect(() => { + if (!open || !runId) { + setDeliverable(null); + setError(null); + setEvents([]); + setSelectedEventId(null); + return; + } + setLoading(true); + setError(null); + setSelectedEventId(null); + loadAll(runId) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load')) + .finally(() => setLoading(false)); + }, [open, runId, loadAll]); + + // Sections changed by the selected incremental update: + // listed as affected AND whose current hash differs from the pre-update hash. + const selectedEvent = useMemo( + () => events.find((e) => e.eventId === selectedEventId) ?? null, + [events, selectedEventId] + ); + + const changedSectionKeys = useMemo(() => { + if (!selectedEvent || !deliverable) return new Set(); + return new Set( + selectedEvent.affectedSections.filter((key) => { + const before = selectedEvent.sectionHashesBefore[key]; + const section = deliverable.sections[key]; + return section ? before == null || before !== section.content_hash : false; + }) + ); + }, [selectedEvent, deliverable]); + + const totalUnverifiable = useMemo(() => { + if (!deliverable) return 0; + return Object.values(deliverable.sections).reduce( + (acc, s) => + acc + s.claims.filter( + (c) => c.citation_status === 'unverifiable' || c.citation_status === 'not_found' || !c.citation + ).length, + 0 + ); + }, [deliverable]); + + if (!open) return null; + + const sortedSections = deliverable + ? Object.values(deliverable.sections).sort((a, b) => a.key.localeCompare(b.key)) + : []; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+

Deliverable Register

+ {deliverable ? ( +
+ #{deliverable.deliverable_hash.slice(0, 12)} + · + {deliverable.section_count} sections + · + {deliverable.claim_count} claims + {totalUnverifiable > 0 && ( + <> + · + {totalUnverifiable} unverifiable + + )} +
+ ) : ( +

Assembled pipeline output

+ )} +
+ +
+ + {/* Content */} +
+ {loading && ( +
+
+ + + + + Loading deliverable... +
+
+ )} + + {!loading && error && ( +
+

{error.includes('404') ? 'No deliverable yet.' : error}

+

+ The register appears after the finalize node completes. +

+
+ )} + + {/* Change log from Movement 3 incremental updates (/runs/{id}/history) */} + {!loading && !error && events.length > 0 && ( +
+ + + {(selectedEventId ? events.filter((e) => e.eventId === selectedEventId) : events).map((ev) => ( +
+
+
+
+ {new Date(ev.timestamp).toLocaleString()} — incremental update applied +
+
+ Caused by new document{' '} + +
+
+
+
{ev.affectedSections.length} sections
+ {ev.conflictsDetected > 0 && ( +
{ev.conflictsDetected} conflicts
+ )} +
+
+ {ev.affectedSections.length > 0 && ( +
+ {ev.affectedSections.map((s) => ( + + {s} + + ))} +
+ )} +
+ ))} + {selectedEventId && ( +
+

+ Highlighted sections below changed content since this update (hash comparison from history). +

+
+ )} +
+ )} + + {/* Sections */} + {!loading && !error && sortedSections.map((section) => ( + + ))} + + {!loading && !error && sortedSections.length === 0 && ( +
+ + + +

No deliverable registered for this run yet.

+
+ )} + + {/* Unverifiable legend */} + {!loading && !error && sortedSections.length > 0 && ( +
+ + Unverifiable lines had no traceable source span at extraction time. + + Indigo lines were modified by an incremental update. +
+ )} +
+
+ + ); +} + +export default DeliverablePanel; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DocumentDetailPanel.test.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DocumentDetailPanel.test.tsx new file mode 100644 index 000000000..f5aa0b1f4 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DocumentDetailPanel.test.tsx @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DocumentDetailPanel } from './DocumentDetailPanel'; + +const SOURCE_TEXT = + 'The borrower agrees to repay the principal amount of 100000 USD at 8.5% annual interest.'; + +function mockFetchResponse(payload: unknown, ok = true, status = 200) { + return { + ok, + status, + json: () => Promise.resolve(payload), + }; +} + +const groundedFacts = { + document_id: 'doc-1', + document_version_id: 'ver-1', + filename: 'loan.txt', + classification: 'loan_agreement', + run_id: 'run-1', + source_text: SOURCE_TEXT, + facts: [ + { + field_name: 'principal_amount', + extracted_value: 'principal amount of 100000 USD', + confidence: 0.97, + extraction_method: 'structured', + citation_status: 'grounded', + cited_span: { + start_offset: SOURCE_TEXT.indexOf('principal amount of 100000 USD'), + end_offset: SOURCE_TEXT.indexOf('principal amount of 100000 USD') + 30, + snippet: 'principal amount of 100000 USD', + page_number: null, + section_id: null, + }, + }, + { + field_name: 'interest_rate', + extracted_value: '8.5% annual interest', + confidence: 0.9, + extraction_method: 'llm', + citation_status: 'grounded', + cited_span: { + start_offset: SOURCE_TEXT.indexOf('8.5% annual interest'), + end_offset: SOURCE_TEXT.indexOf('8.5% annual interest') + 20, + snippet: '8.5% annual interest', + page_number: null, + section_id: null, + }, + }, + { + field_name: 'lender_name', + extracted_value: null, // extractor recorded not_found + confidence: null, + extraction_method: null, + citation_status: 'not_found', + cited_span: null, + }, + { + field_name: 'borrower_name', + extracted_value: 'Alice Johnson', + confidence: 0.6, + extraction_method: 'llm', + citation_status: 'unverifiable', // no span resolvable + cited_span: null, + }, + ], +}; + +describe('DocumentDetailPanel', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('renders every fact from the API response with method and citation', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockFetchResponse(groundedFacts) as Response, + ); + render( + {}} />, + ); + + await waitFor(() => screen.getByTestId('facts-table')); + + // Field rows — including the not_found one (never silently omitted) + expect(screen.getByTestId('fact-row-principal_amount')).toBeInTheDocument(); + expect(screen.getByTestId('fact-row-interest_rate')).toBeInTheDocument(); + expect(screen.getByTestId('fact-row-lender_name')).toBeInTheDocument(); + expect(screen.getByTestId('fact-row-borrower_name')).toBeInTheDocument(); + + // Values + expect(screen.getByText('principal amount of 100000 USD')).toBeInTheDocument(); + + // Extraction methods come from the API, not invented client-side + expect(screen.getByText('structured')).toBeInTheDocument(); + expect(screen.getAllByText('llm').length).toBeGreaterThanOrEqual(1); + // extraction_method=null renders as "not found" in the Method column too + const notFoundCells = screen.getAllByText('not found'); + expect(notFoundCells.length).toBeGreaterThanOrEqual(2); + + // Grounded citations show the REAL server-resolved snippet + const grounded = screen.getAllByTestId('citation-grounded'); + expect(grounded).toHaveLength(2); + expect(screen.getAllByText(/principal amount of 100000 USD/).length).toBeGreaterThanOrEqual(1); + + // Unverifiable marker is visually distinct (icon + label), not small text + expect(screen.getByTestId('citation-unverifiable')).toBeInTheDocument(); + expect(screen.getByText('[citation unverifiable]')).toBeInTheDocument(); + + // not_found renders explicitly (value cell + method cell) + expect(screen.getAllByText('not found').length).toBeGreaterThanOrEqual(2); + }); + + it('raw text tab highlights exactly the server-provided spans inline', async () => { + const user = userEvent.setup(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockFetchResponse(groundedFacts) as Response, + ); + render( + {}} />, + ); + await waitFor(() => screen.getByTestId('facts-table')); + + await user.click(screen.getByTestId('tab-raw')); + await waitFor(() => screen.getByTestId('raw-text-view')); + + // Both grounded spans highlighted; highlight content matches server snippet + const highlights = screen.getAllByRole('mark'); + expect(highlights).toHaveLength(2); + expect(highlights[0]).toHaveTextContent('principal amount of 100000 USD'); + expect(highlights[1]).toHaveTextContent('8.5% annual interest'); + + // Surrounding non-cited context is present un-highlighted + expect(screen.getByTestId('raw-text-view')).toHaveTextContent('The borrower agrees to repay the'); + expect(screen.getByTestId('raw-text-view')).toHaveTextContent('.'); + }); + + it('shows an error state when the API fails', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockFetchResponse({ detail: 'nope' }, false, 500) as Response, + ); + render( + {}} />, + ); + await waitFor(() => screen.getByTestId('facts-error')); + expect(screen.getByTestId('facts-error')).toHaveTextContent('Failed to load document facts'); + }); +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DocumentDetailPanel.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DocumentDetailPanel.tsx new file mode 100644 index 000000000..8d5fba46c --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/DocumentDetailPanel.tsx @@ -0,0 +1,286 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + fetchDocumentFacts, + type DocumentFact, + type DocumentFactsResponse, +} from '@/services/pipelineApi'; + +interface DocumentDetailPanelProps { + documentId: string; + filename: string; + runId?: string; + onClose: () => void; +} + +/** + * Document detail view — every fact extracted from a document, plus a + * raw-text tab with all cited spans highlighted inline. All values come + * from GET /documents/{id}/facts; nothing is reconstructed client-side. + */ +export function DocumentDetailPanel({ documentId, filename, runId, onClose }: DocumentDetailPanelProps) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState<'facts' | 'raw'>('facts'); + + useEffect(() => { + let cancelled = false; + setData(null); + setError(null); + fetchDocumentFacts(documentId, runId) + .then((result) => { + if (!cancelled) setData(result); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, [documentId, runId]); + + return ( +
+
e.stopPropagation()} + data-testid="document-detail-panel" + > + {/* Header */} +
+
+

{filename}

+ {data?.classification && ( +

+ classified as {data.classification} +

+ )} +
+ +
+ + {/* Tabs */} +
+ {(['facts', 'raw'] as const).map((t) => ( + + ))} +
+ + {/* Body */} +
+ {error && ( +

+ Failed to load document facts: {error} +

+ )} + {!error && !data && ( +

+ Loading extracted facts… +

+ )} + {data && tab === 'facts' && } + {data && tab === 'raw' && } +
+
+
+ ); +} + +// ─── Facts tab ─────────────────────────────────────────────────────────────── + +function methodLabel(method: string | null): string { + if (method === null) return 'not found'; + switch (method) { + case 'structured': + return 'structured'; + case 'llm': + return 'llm'; + case 'llm_fallback': + return 'llm (fallback)'; + case 'regex_fallback': + return 'regex (fallback)'; + default: + return method; + } +} + +function FactsTable({ facts }: { facts: DocumentFact[] }) { + if (facts.length === 0) { + return

No facts extracted from this document.

; + } + + return ( + + + + + + + + + + + {facts.map((fact, i) => ( + + + + + + + ))} + +
FieldValueMethodCitation
+ {fact.field_name} + + {fact.extracted_value === null ? ( + not found + ) : ( + {fact.extracted_value} + )} + {fact.confidence !== null && fact.extracted_value !== null && ( + + confidence {(fact.confidence * 100).toFixed(0)}% + + )} + + {methodLabel(fact.extraction_method)} + + +
+ ); +} + +function CitationCell({ fact }: { fact: DocumentFact }) { + if (fact.cited_span) { + return ( +
+
+ “{fact.cited_span.snippet}” +
+

+ chars {fact.cited_span.start_offset}–{fact.cited_span.end_offset} + {fact.cited_span.page_number !== null && ` · p.${fact.cited_span.page_number}`} +

+
+ ); + } + + if (fact.citation_status === 'not_found') { + return null; // the Value column already says "not found" + } + + // Unverifiable citation — visually distinct marker (icon + color) + return ( + + + + + + [citation unverifiable] + + + ); +} + +// ─── Raw text tab ──────────────────────────────────────────────────────────── + +interface Segment { + text: string; + highlight: boolean; + value: string | null; +} + +/** + * Split the source text into plain/highlighted segments using the + * server-provided span offsets — no client-side matching or guessing. + */ +function buildSegments(text: string, facts: DocumentFact[]): Segment[] { + const spans = facts + .filter((f) => f.cited_span !== null) + .map((f) => ({ + start: f.cited_span!.start_offset, + end: f.cited_span!.end_offset, + value: f.extracted_value, + })) + .filter((s) => s.start >= 0 && s.end <= text.length && s.start < s.end) + .sort((a, b) => a.start - b.start); + + const segments: Segment[] = []; + let cursor = 0; + for (const span of spans) { + if (span.start < cursor) continue; // overlapping — keep first + if (span.start > cursor) { + segments.push({ text: text.slice(cursor, span.start), highlight: false, value: null }); + } + segments.push({ + text: text.slice(span.start, span.end), + highlight: true, + value: span.value, + }); + cursor = span.end; + } + if (cursor < text.length) { + segments.push({ text: text.slice(cursor), highlight: false, value: null }); + } + return segments; +} + +function RawTextView({ text, facts }: { text: string; facts: DocumentFact[] }) { + const segments = useMemo(() => buildSegments(text, facts), [text, facts]); + const groundedCount = segments.filter((s) => s.highlight).length; + + return ( +
+

+ {groundedCount} cited span{groundedCount !== 1 ? 's' : ''} highlighted inline. + Hover a highlight to see the extracted value. +

+
+        {segments.map((seg, i) =>
+          seg.highlight ? (
+            
+              {seg.text}
+            
+          ) : (
+            {seg.text}
+          ),
+        )}
+      
+
+ ); +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/FileDropZone.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/FileDropZone.tsx new file mode 100644 index 000000000..3c749f48f --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/FileDropZone.tsx @@ -0,0 +1,119 @@ +import { useState, useCallback, useRef } from 'react'; + +interface FileDropZoneProps { + onFilesSelected: (files: File[]) => void; + isUploading: boolean; + disabled?: boolean; +} + +const ACCEPTED_EXTENSIONS = ['.pdf', '.docx', '.txt', '.text']; +const ACCEPT_STRING = '.pdf,.docx,.txt,.text'; + +/** + * Multi-file drag-and-drop zone + file picker. + * Replaces the old hidden single-file input. + */ +export function FileDropZone({ onFilesSelected, isUploading, disabled }: FileDropZoneProps) { + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled && !isUploading) { + setIsDragOver(true); + } + }, [disabled, isUploading]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + if (disabled || isUploading) return; + + const droppedFiles = Array.from(e.dataTransfer.files).filter((file) => { + const ext = '.' + file.name.split('.').pop()?.toLowerCase(); + return ACCEPTED_EXTENSIONS.includes(ext); + }); + + if (droppedFiles.length > 0) { + onFilesSelected(droppedFiles); + } + }, [disabled, isUploading, onFilesSelected]); + + const handleClick = useCallback(() => { + if (!disabled && !isUploading) { + fileInputRef.current?.click(); + } + }, [disabled, isUploading]); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (files.length > 0) { + onFilesSelected(files); + } + // Reset so re-selecting the same file works + if (fileInputRef.current) fileInputRef.current.value = ''; + }, [onFilesSelected]); + + return ( +
{ if (e.key === 'Enter' || e.key === ' ') handleClick(); }} + > + + +
+ {isUploading ? ( + <> + + + + Uploading... + + ) : ( + <> + + + + + + Drop files here or browse + + PDF, DOCX, TXT — multiple files OK + + )} +
+
+ ); +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/FindingsPanel.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/FindingsPanel.tsx new file mode 100644 index 000000000..32618e920 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/FindingsPanel.tsx @@ -0,0 +1,286 @@ +/** + * Findings Panel — the Phase 1 audit-trail requirement made visible. + * + * Lists EVERY finding ever generated for this run, resolved or not: + * rule triggered, playbook, evaluation_method, citations, and current + * status with who decided and when. Data comes from the audit trail + * (audit_events) joined with approval_queue/decisions via + * GET /runs/{id}/findings — never reconstructed client-side. + */ +import { useState, useEffect } from 'react'; +import { fetchRunFindings, type RunFindings, type FindingRecord } from '@/services/pipelineApi'; + +interface FindingsPanelProps { + runId: string | null; + open: boolean; + onClose: () => void; +} + +const STATUS_CONFIG: Record = { + pending: { label: 'Pending', badge: 'bg-amber-500/15 text-amber-300 border-amber-500/30' }, + approved: { label: 'Approved', badge: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' }, + rejected: { label: 'Rejected', badge: 'bg-rose-500/15 text-rose-300 border-rose-500/30' }, + approved_needs_recheck: { + label: 'Approved — needs recheck', + badge: 'bg-amber-500/15 text-amber-300 border-amber-500/40', + }, + unqueued: { label: 'Never escalated', badge: 'bg-white/[0.05] text-white/40 border-white/[0.1]' }, +}; + +const SEV_COLORS: Record = { + high: 'text-rose-300 bg-rose-500/15', + medium: 'text-amber-300 bg-amber-500/15', + low: 'text-white/50 bg-white/[0.06]', +}; + +function CitationChips({ finding }: { finding: FindingRecord }) { + const [openIdx, setOpenIdx] = useState(null); + + if (finding.citations.length === 0) { + return ( + + no citations + + ); + } + + return ( + + {finding.citations.map((c, i) => { + const label = c.clause_ref ?? (c.page_number != null ? `p.${c.page_number}` : c.section_id ?? 'span'); + return ( + + + {openIdx === i && ( + + {[c.section_id && `§${c.section_id}`, c.start_offset != null && `${c.start_offset}–${c.end_offset}`, c.source_document_id && `doc ${c.source_document_id.slice(0, 8)}`] + .filter(Boolean) + .join(' · ') || 'no span details'} + {c.snippet && "{c.snippet}"} + + )} + + ); + })} + + ); +} + +function FindingCard({ finding }: { finding: FindingRecord }) { + const [showTrail, setShowTrail] = useState(false); + const status = STATUS_CONFIG[finding.status] ?? STATUS_CONFIG.unqueued; + + return ( +
+ {/* Header */} +
+
+ + {status.label} + + {finding.rule_id && ( + + {finding.rule_id} + + )} + {finding.evaluation_method && ( + + {finding.evaluation_method} + + )} + {finding.severity && ( + + {finding.severity} + + )} +
+ + {/* Full description */} +

{finding.description || '—'}

+ + {/* Provenance line */} +
+ {finding.playbook_id && playbook: {finding.playbook_id}} + {finding.claim_id && {finding.claim_id}} + {finding.source_node && via {finding.source_node.replace(/_/g, ' ')}} + {finding.first_generated_at && ( + generated {new Date(finding.first_generated_at).toLocaleString()} + )} + +
+ + {/* Decision record */} + {finding.decided_by && ( +
+ Decided by {finding.decided_by} + {finding.decided_at && <> · {new Date(finding.decided_at).toLocaleString()}} + {finding.justification &&
"{finding.justification}"
} +
+ )} + + {/* Audit trail toggle */} + {finding.events.length > 0 && ( + + )} + + {showTrail && ( +
+ {finding.events.map((ev) => ( +
+ +
+ {new Date(ev.timestamp).toLocaleTimeString()} + {' '} + {ev.action} + {' '} + by {ev.actor_id} + {Object.keys(ev.details).length > 0 && ( +
+ {JSON.stringify(ev.details)} +
+ )} +
+
+ ))} +
+ )} +
+
+ ); +} + +export function FindingsPanel({ runId, open, onClose }: FindingsPanelProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [filter, setFilter] = useState<'all' | 'pending' | 'resolved'>('all'); + + useEffect(() => { + if (!open || !runId) { + setData(null); + setError(null); + return; + } + setLoading(true); + setError(null); + fetchRunFindings(runId) + .then(setData) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load')) + .finally(() => setLoading(false)); + }, [open, runId]); + + const visible = data + ? data.items.filter((f) => + filter === 'all' ? true : filter === 'pending' ? f.status === 'pending' : ['approved', 'rejected', 'approved_needs_recheck'].includes(f.status) + ) + : []; + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+

Findings — Audit Trail

+ {data && ( +

+ {data.total} total · {data.pending} pending · {data.resolved} resolved +

+ )} +
+
+
+ {(['all', 'pending', 'resolved'] as const).map((f) => ( + + ))} +
+ +
+
+ + {/* Content */} +
+ {loading && ( +
+
+ + + + + Loading findings... +
+
+ )} + + {!loading && error && ( +

{error}

+ )} + + {!loading && !error && visible.length === 0 && ( +
+ + + +

+ {filter === 'all' ? 'No findings generated for this run.' : `No ${filter} findings.`} +

+
+ )} + + {!loading && visible.map((finding) => ( + + ))} + + {!loading && !error && data && data.items.length > 0 && ( +

+ Sourced from the audit trail (audit_events) + approval queue — complete history, resolved or not. +

+ )} +
+
+ + ); +} + +export default FindingsPanel; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/MergeNode.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/MergeNode.tsx new file mode 100644 index 000000000..c734857a7 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/MergeNode.tsx @@ -0,0 +1,48 @@ +import { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeStatus } from '@/types/pipeline'; +import { STATUS_CONFIG } from '@/utils/pipelineColors'; + +interface MergeNodeData { + label: string; + status: NodeStatus; + [key: string]: unknown; +} + +export const MergeNode = memo(function MergeNode({ data }: { data: MergeNodeData }) { + const { status } = data; + const config = STATUS_CONFIG[status]; + + return ( +
+ + +
+ {/* Icon inside, counter-rotated */} +
+ + + + +
+
+ + Merge +
+ + {config.label} +
+ + +
+ ); +}); + +export default MergeNode; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/NodeDetailPanel.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/NodeDetailPanel.tsx new file mode 100644 index 000000000..5d2b8b54d --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/NodeDetailPanel.tsx @@ -0,0 +1,876 @@ +/** + * Slide-in detail panel for pipeline nodes. + * Shows findings/conflicts/proposed-updates for Examine-stage nodes with + * approve/reject actions. Decisions are per-item and don't affect others. + */ +import { useState, useCallback, useEffect } from 'react'; +import type { QueueItem, SourceCitation } from '@/types/review'; + +// ─── Mock items per node (dev mode) ───────────────────────────────────────── + +const MOCK_NODE_ITEMS: Record = { + extract_claims: [ + { + id: 'item-ec-1', + run_id: 'run-001', + item_type: 'finding', + payload: { + summary: 'Capital adequacy ratio below regulatory minimum (8.5% vs 10% required)', + details: { severity: 'high', rule_id: 'CAP-4.2', evaluation_method: 'llm' }, + source_citations: [{ + claim_id: 'c1', + claim_text: 'The institution maintains a capital ratio of 8.5%', + citation_status: 'grounded', + source_location: { page_number: 12, section_id: 'sec-4.2', start_offset: 145, end_offset: 210, clause_ref: '§4.2.1' }, + }], + }, + status: 'pending', + queued_at: '2024-06-01T10:15:00Z', + decided_at: null, decision: null, reviewer_id: null, justification: null, + }, + { + id: 'item-ec-2', + run_id: 'run-001', + item_type: 'conflict', + payload: { + summary: 'Conflicting LTV ratios stated in sections 3.1 and 5.4', + details: { severity: 'medium', sections: ['3.1', '5.4'], evaluation_method: 'structured' }, + source_citations: [ + { claim_id: 'c2', claim_text: 'Maximum LTV ratio is 80%', citation_status: 'grounded', source_location: { page_number: 8, section_id: 'sec-3.1', start_offset: 50, end_offset: 95, clause_ref: '§3.1.3' } }, + { claim_id: 'c3', claim_text: 'LTV ratios may exceed thresholds', citation_status: 'unverifiable', source_location: null }, + ], + }, + status: 'pending', + queued_at: '2024-06-01T10:20:00Z', + decided_at: null, decision: null, reviewer_id: null, justification: null, + }, + ], + match_rules: [ + { + id: 'item-mr-1', + run_id: 'run-001', + item_type: 'proposed_update', + payload: { + summary: 'Update interest rate disclosure to match revised APR guidance', + details: { target_section: '6.1', update_type: 'language', evaluation_method: 'llm' }, + source_citations: [{ + claim_id: 'c4', + claim_text: 'Interest rates shall be disclosed in APR format', + citation_status: 'grounded', + source_location: { page_number: 22, section_id: 'sec-6.1', start_offset: 0, end_offset: 55, clause_ref: '§6.1.2' }, + }], + }, + status: 'pending', + queued_at: '2024-06-01T10:25:00Z', + decided_at: null, decision: null, reviewer_id: null, justification: null, + }, + ], + score_confidence: [ + { + id: 'item-sc-1', + run_id: 'run-001', + item_type: 'finding', + payload: { + summary: 'Missing fee schedule disclosure for Q3 2024 changes', + details: { severity: 'low', rule_id: 'DISC-7.3', evaluation_method: 'structured' }, + source_citations: [ + { claim_id: 'c5', claim_text: 'Fee schedules updated March 2024', citation_status: 'unverifiable', source_location: null }, + { claim_id: 'c6', claim_text: 'Customers notified 30 days prior', citation_status: 'grounded', source_location: { page_number: 5, section_id: 'sec-7.3', start_offset: 200, end_offset: 270, clause_ref: null } }, + ], + }, + status: 'pending', + queued_at: '2024-06-01T10:30:00Z', + decided_at: null, decision: null, reviewer_id: null, justification: null, + }, + ], +}; + +const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'; +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''; + +// ─── Citation component ────────────────────────────────────────────────────── + +function CitationView({ citation, expanded, onToggle }: { citation: SourceCitation; expanded: boolean; onToggle: () => void }) { + const isUnverifiable = citation.citation_status === 'unverifiable' || !citation.source_location; + + return ( +
+ + + {expanded && citation.source_location && ( +
+
Source Document · Page {citation.source_location.page_number}
+
+ {citation.snippet ? ( + <> + {citation.snippet_context_before ? `...${citation.snippet_context_before}` : '...'} + + {citation.snippet} + + {citation.snippet_context_after ? `${citation.snippet_context_after}...` : '...'} + + ) : ( + <> + ...preceding text content... + + {citation.claim_text} + + ...following text content... + + )} +
+
+ Section: {citation.source_location.section_id ?? '—'} + Offset: {citation.source_location.start_offset}–{citation.source_location.end_offset} +
+
+ )} + + {expanded && !citation.source_location && ( +
+
+ + + + No source span available. This citation could not be traced to a specific document location. Manual verification required. +
+
+ )} +
+ ); +} + +// ─── Single item card ──────────────────────────────────────────────────────── + +const TYPE_COLORS: Record = { + finding: { border: 'border-l-rose-500', badge: 'bg-rose-500/15 text-rose-300 border-rose-500/30' }, + conflict: { border: 'border-l-amber-500', badge: 'bg-amber-500/15 text-amber-300 border-amber-500/30' }, + proposed_update: { border: 'border-l-violet-500', badge: 'bg-violet-500/15 text-violet-300 border-violet-500/30' }, +}; + +function ItemCard({ item, onDecision }: { item: QueueItem; onDecision: (id: string, decision: 'approved' | 'rejected') => void }) { + const [expandedCitation, setExpandedCitation] = useState(null); + const [submitting, setSubmitting] = useState<'approved' | 'rejected' | null>(null); + const colors = TYPE_COLORS[item.item_type] ?? TYPE_COLORS.finding; + const evalMethod = (item.payload.details as Record).evaluation_method as string | undefined; + + const handleDecision = async (decision: 'approved' | 'rejected') => { + setSubmitting(decision); + await onDecision(item.id, decision); + setSubmitting(null); + }; + + const isPending = item.status === 'pending'; + + return ( +
+ {/* Header */} +
+
+ + {item.item_type.replace('_', ' ')} + + {evalMethod && ( + + {evalMethod} + + )} + {!isPending && ( + + {item.status === 'approved' ? '✓' : '✗'} {item.status} + + )} +
+

{item.payload.summary ?? (item.payload as any).claim_text ?? '—'}

+
+ + {/* Citations */} +
+ {(item.payload.source_citations ?? []).map((cit) => ( + setExpandedCitation(expandedCitation === cit.claim_id ? null : cit.claim_id)} + /> + ))} +
+ + {/* Decision buttons */} + {isPending && ( +
+ + +
+ )} +
+ ); +} + +// ─── Node Details Display ──────────────────────────────────────────────────── + +function ErrorRetryBanner({ details }: { details: any }) { + const hasError = details.error_detail || details.status === 'error' || details.status === 'failed'; + const hasRetries = (details.retry_count ?? 0) > 0; + const hasSkip = !!details.skip_reason; + + if (!hasError && !hasRetries && !hasSkip) return null; + + return ( +
+ {hasError && ( +
+
+ + + + + {details.error_type === 'transient' ? 'Transient Error' : 'Permanent Error'} + +
+

{details.error_detail}

+
+ )} + {hasRetries && ( +
+ + + + + Retried {details.retry_count} time{details.retry_count > 1 ? 's' : ''} +
+ )} + {hasSkip && ( +
+ + + + Skipped: {details.skip_reason?.replace(/_/g, ' ')} +
+ )} +
+ ); +} + +function MethodBadge({ method }: { method: string }) { + const colors = method === 'llm' + ? 'bg-violet-500/15 text-violet-300 border-violet-500/30' + : method === 'structured' || method === 'regex_fallback' + ? 'bg-sky-500/15 text-sky-300 border-sky-500/30' + : 'bg-white/10 text-white/50 border-white/20'; + return ( + + {method.replace(/_/g, ' ')} + + ); +} + +function NodeDetailsSection({ details }: { details: any }) { + if (!details || details.status === 'running') return null; + + const duration = details.duration_ms != null ? `${(details.duration_ms / 1000).toFixed(1)}s` : '—'; + const tokens = (details.input_tokens || details.output_tokens) + ? `${details.input_tokens ?? 0} in / ${details.output_tokens ?? 0} out` + : null; + const cost = details.cost_usd != null ? `$${details.cost_usd.toFixed(4)}` : null; + const nodeId = details.node_id; + + return ( +
+ {/* Metrics bar */} +
+ {details.status} + {duration} + {tokens && {tokens}} + {cost && {cost}} +
+ + {/* Error / Retry / Skip banner */} +
+ + + {/* ── Ingest ────────────────────────────────────────────────────── */} + {nodeId === 'ingest' && ( +
+ {details.mime_type && } + {details.file_size != null && } + {details.pile_document_count != null && details.pile_document_count > 0 && ( + + )} + {details.document_id && ( + + )} +
+ )} + + {/* ── Extract Text ──────────────────────────────────────────────── */} + {nodeId === 'extract_text' && ( +
+ {details.text_length != null && ( + + )} + {details.estimated_page_count != null && ( + + )} + {details.mime_type && } + {details.document_id && ( + + )} + {details.extracted_text_preview && ( +
+
+                  {details.extracted_text_preview.slice(0, 500)}
+                  {details.extracted_text_preview.length > 500 ? '...' : ''}
+                
+
+ )} +
+ )} + + {/* ── Classify ──────────────────────────────────────────────────── */} + {nodeId === 'classify_document' && ( +
+ {details.classification_label && ( +
+ Classification + {details.classification_label.replace(/_/g, ' ')} +
+ )} + {details.classification_confidence != null && ( + + )} + {details.classification_method && ( +
+ Method + +
+ )} + {details.classification_scores && ( +
+ Score Distribution + {Object.entries(details.classification_scores).map(([label, score]) => ( +
+ {label.replace(/_/g, ' ')} +
+
+
+ {((score as number) * 100).toFixed(0)}% +
+ ))} +
+ )} +
+ )} + + {/* ── Chunk ─────────────────────────────────────────────────────── */} + {nodeId === 'chunk' && ( +
+ + {details.chunk_max_size && } + {details.chunk_overlap != null && } + {details.chunks_preview && details.chunks_preview.length > 0 && ( +
+ Chunk Boundaries + {details.chunks_preview.map((c: any) => ( +
+
+ #{c.index} + offset {c.start_offset}–{c.end_offset} + {c.length} chars +
+

{c.text_preview}

+
+ ))} +
+ )} +
+ )} + + {/* ── Embed ─────────────────────────────────────────────────────── */} + {nodeId === 'embed' && ( +
+ + + +
+ )} + + {/* ── Extract Claims ────────────────────────────────────────────── */} + {nodeId === 'extract_claims' && ( +
+ + {details.extraction_method_counts && Object.keys(details.extraction_method_counts).length > 0 && ( +
+ By Extraction Method +
+ {Object.entries(details.extraction_method_counts).map(([method, count]) => ( +
+ + {count as number} +
+ ))} +
+
+ )} + {details.claims && details.claims.length > 0 && ( +
+ Claims + {details.claims.slice(0, 8).map((claim: any) => ( +
+
+ {claim.claim_id} + {claim._extraction_method && } + = 0.8 ? 'bg-emerald-500/20 text-emerald-300' : + claim.confidence >= 0.5 ? 'bg-amber-500/20 text-amber-300' : + 'bg-rose-500/20 text-rose-300' + }`}>{((claim.confidence ?? 0) * 100).toFixed(0)}% +
+

{claim.claim_text}

+
+ ))} + {details.claims.length > 8 && ( +

+{details.claims.length - 8} more claims

+ )} +
+ )} +
+ )} + + {/* ── Match Rules / Match Sources ───────────────────────────────── */} + {(nodeId === 'match_rules' || nodeId === 'match_rules_against_sources') && ( +
+ + {details.evaluation_method && ( +
+ Evaluation + +
+ )} + {details.evaluation_method_counts && Object.keys(details.evaluation_method_counts).length > 0 && ( +
+ Findings by Method +
+ {Object.entries(details.evaluation_method_counts).map(([method, count]) => ( +
+ + {count as number} +
+ ))} +
+
+ )} + {details.verdict_counts && Object.keys(details.verdict_counts).length > 0 && ( +
+ Verdict Summary +
+ {Object.entries(details.verdict_counts).map(([verdict, count]) => ( + + {verdict.replace(/_/g, ' ')} {count as number} + + ))} +
+
+ )} + {details.verdicts && details.verdicts.length > 0 && ( +
+ Verdicts + {details.verdicts.slice(0, 8).map((v: any, i: number) => ( +
+
+ {v.claim_id} + {v.verdict} + {v.rule_id && {v.rule_id}} +
+ {v.reason &&

{v.reason}

} +
+ ))} + {details.verdicts.length > 8 && ( +

+{details.verdicts.length - 8} more

+ )} +
+ )} +
+ )} + + {/* ── Merge Findings ────────────────────────────────────────────── */} + {nodeId === 'merge_findings' && ( +
+ + {details.findings && details.findings.length > 0 && ( +
+ {details.findings.slice(0, 6).map((f: any, i: number) => ( +
+
+ {f.severity && ( + {f.severity} + )} + {f.rule_id && {f.rule_id}} +
+

{f.description || f.reason || '—'}

+
+ ))} +
+ )} +
+ )} + + {/* ── Score Confidence ──────────────────────────────────────────── */} + {nodeId === 'score_confidence' && ( +
+ + {details.verdicts && details.verdicts.length > 0 && ( +
+ {details.verdicts.slice(0, 6).map((v: any, i: number) => ( +
+ {v.claim_id} + = 0.8 ? 'bg-emerald-500/20 text-emerald-300' : + v.confidence >= 0.5 ? 'bg-amber-500/20 text-amber-300' : + 'bg-rose-500/20 text-rose-300' + }`}>{((v.confidence ?? 0) * 100).toFixed(0)}% + {v.needs_human_review && needs review} +
+ ))} +
+ )} +
+ )} + + {/* ── Route to Queue ────────────────────────────────────────────── */} + {nodeId === 'route_to_queue' && ( +
+ + + + {details.confidence_threshold != null && ( + + )} + {details.routing_reasons && details.routing_reasons.length > 0 && ( +
+ Escalation Reasons +
+ {details.routing_reasons.slice(0, 8).map((r: any, i: number) => ( +
+ {r.claim_id} + {r.reason?.replace(/_/g, ' ')} +
+ ))} + {details.routing_reasons.length > 8 && ( +

+{details.routing_reasons.length - 8} more

+ )} +
+
+ )} +
+ )} + + {/* ── Human Review ──────────────────────────────────────────────── */} + {nodeId === 'human_review' && ( +
+ + {details.decisions && details.decisions.length > 0 && ( +
+ {details.decisions.slice(0, 6).map((d: any, i: number) => ( +
+ {d.claim_id} + {d.decision_value} +
+ ))} +
+ )} +
+ )} + + {/* ── Finalize ──────────────────────────────────────────────────── */} + {nodeId === 'finalize' && ( +
+ + +
+ )} +
+
+ ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ─── Main Panel ────────────────────────────────────────────────────────────── + +interface NodeDetailPanelProps { + nodeId: string | null; + nodeLabel: string; + onClose: () => void; + runId?: string | null; +} + +export function NodeDetailPanel({ nodeId, nodeLabel, onClose, runId }: NodeDetailPanelProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [nodeDetails, setNodeDetails] = useState(null); + const [detailsLoading, setDetailsLoading] = useState(false); + + // Fetch node execution details from the backend + useEffect(() => { + if (!nodeId || !runId) { + setNodeDetails(null); + return; + } + setDetailsLoading(true); + + async function fetchDetails() { + try { + const res = await fetch(`${BASE_URL}/runs/${runId}/node/${nodeId}/details`); + if (res.ok) { + const data = await res.json(); + setNodeDetails(data); + } else { + setNodeDetails(null); + } + } catch { + setNodeDetails(null); + } + setDetailsLoading(false); + } + + fetchDetails(); + }, [nodeId, runId]); + + // Fetch items for this node (approval queue items relevant to the clicked node) + useEffect(() => { + if (!nodeId) return; + setLoading(true); + + async function fetchItems() { + if (USE_MOCK) { + await new Promise((r) => setTimeout(r, 200)); + setItems(MOCK_NODE_ITEMS[nodeId!] ?? []); + } else { + if (!runId) { + setItems([]); + setLoading(false); + return; + } + + // Only fetch approval queue items for nodes in the stay-alive stage + // (route_to_queue, human_review, finalize) where approval items exist. + const approvalNodes = ['route_to_queue', 'human_review', 'finalize', 'score_confidence']; + if (!approvalNodes.includes(nodeId!)) { + setItems([]); + setLoading(false); + return; + } + + try { + const res = await fetch(`${BASE_URL}/approval/runs/${runId}/queue`); + if (res.ok) { + const data = await res.json(); + setItems(data.items ?? []); + } else { + setItems([]); + } + } catch { + setItems([]); + } + } + setLoading(false); + } + + fetchItems(); + }, [nodeId, runId]); + + // Handle decision — only updates the single item + const handleDecision = useCallback(async (itemId: string, decision: 'approved' | 'rejected') => { + if (USE_MOCK) { + await new Promise((r) => setTimeout(r, 400)); + setItems((prev) => prev.map((item) => + item.id === itemId + ? { ...item, status: decision, decision, decided_at: new Date().toISOString(), reviewer_id: 'current-user' } + : item + )); + } else { + try { + await fetch(`${BASE_URL}/approval/items/${itemId}/decide`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision, reviewer_id: 'current-user', justification: `${decision} via pipeline canvas` }), + }); + // Update only this item locally + setItems((prev) => prev.map((item) => + item.id === itemId + ? { ...item, status: decision, decision, decided_at: new Date().toISOString(), reviewer_id: 'current-user' } + : item + )); + } catch { + // Keep original state on failure + } + } + }, []); + + const isOpen = nodeId !== null; + const pendingCount = items.filter((i) => i.status === 'pending').length; + const totalCount = items.length; + + return ( + <> + {/* Backdrop */} + {isOpen && ( +
+ )} + + {/* Panel */} +
+ {isOpen && ( +
+ {/* Header */} +
+
+

{nodeLabel}

+

+ {totalCount === 0 ? 'No items' : `${pendingCount} pending · ${totalCount} total`} +

+
+ +
+ + {/* Content */} +
+ {/* Node execution details */} + {nodeDetails && !detailsLoading && ( + + )} + {detailsLoading && ( +
+
+ + + + + Loading node details... +
+
+ )} + + {loading && ( +
+ + + + +
+ )} + + {!loading && items.length === 0 && ( +
+ + + + +

No findings or items at this stage

+
+ )} + + {!loading && items.map((item) => ( + + ))} +
+
+ )} +
+ + ); +} + +export default NodeDetailPanel; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PendingReviewPanel.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PendingReviewPanel.tsx new file mode 100644 index 000000000..1f63c0b22 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PendingReviewPanel.tsx @@ -0,0 +1,440 @@ +/** + * Pending Review Panel — every approval-queue item for this run + * (findings, conflicts, proposed updates) in one place. + * + * Fetches GET /approval/runs/{runId}/queue and wires Approve/Reject + * to POST /approval/items/{itemId}/decide (Phase 2.3 endpoint). + * After a decision only the decided item's local state changes — + * the rest of the list is untouched (guaranteed by backend tests). + */ +import { useState, useEffect, useCallback } from 'react'; +import type { QueueItem, ItemType } from '@/types/review'; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''; + +interface PendingReviewPanelProps { + runId: string | null; + open: boolean; + onClose: () => void; + /** Notifies parent with the updated pending count after any decision */ + onPendingCountChange?: (count: number) => void; +} + +const TYPE_CONFIG: Record = { + finding: { + label: 'Finding', + border: 'border-l-rose-500', + badge: 'bg-rose-500/15 text-rose-300 border-rose-500/30', + icon: ( + + + + ), + }, + conflict: { + label: 'Conflict', + border: 'border-l-amber-500', + badge: 'bg-amber-500/15 text-amber-300 border-amber-500/30', + icon: ( + + + + ), + }, + proposed_update: { + label: 'Proposed Change', + border: 'border-l-violet-500', + badge: 'bg-violet-500/15 text-violet-300 border-violet-500/30', + icon: ( + + + + ), + }, +}; + +function CitationCard({ citation, sideLabel }: { citation: QueueItem['payload']['source_citations'][number]; sideLabel?: string }) { + const isUnverifiable = citation.citation_status === 'unverifiable' || !citation.source_location; + + return ( +
+ {sideLabel && ( +
+ {sideLabel} + {isUnverifiable && ( + unverifiable + )} +
+ )} +

{citation.claim_text}

+ {citation.source_location && ( +
+ + {citation.source_location.clause_ref ?? `p.${citation.source_location.page_number}`} + + {citation.source_location.section_id && §{citation.source_location.section_id}} + offsets {citation.source_location.start_offset}–{citation.source_location.end_offset} + {citation.snippet && ( + "{citation.snippet.slice(0, 120)}{citation.snippet.length > 120 ? '…' : ''}" + )} +
+ )} + {!citation.source_location && ( +
+ No traceable source span — manual verification required. +
+ )} +
+ ); +} + +function ReviewItemCard({ item, onDecision }: { + item: QueueItem; + onDecision: (id: string, decision: 'approved' | 'rejected') => Promise; +}) { + const [submitting, setSubmitting] = useState<'approved' | 'rejected' | null>(null); + const [error, setError] = useState(null); + + const cfg = TYPE_CONFIG[item.item_type] ?? TYPE_CONFIG.finding; + const details = (item.payload.details ?? {}) as Record; + const evalMethod = details.evaluation_method as string | undefined; + const confidence = typeof details.confidence === 'number' ? details.confidence : undefined; + const severity = details.severity as string | undefined; + const ruleId = details.rule_id as string | undefined; + const claimId = details.claim_id as string | undefined; + const targetSection = details.target_section as string | undefined; + const updateType = details.update_type as string | undefined; + const sections = details.sections as string[] | undefined; + + // Full description — never truncated + const summary = item.payload.summary ?? ''; + const reasonText = + (details.reason as string) ?? + (details.description as string) ?? + (details.rationale as string) ?? + null; + + const isConflict = item.item_type === 'conflict'; + const isPending = item.status === 'pending'; + + const handleDecision = async (decision: 'approved' | 'rejected') => { + setSubmitting(decision); + setError(null); + try { + await onDecision(item.id, decision); + } catch (err) { + setError(err instanceof Error ? err.message : 'Decision failed'); + } finally { + setSubmitting(null); + } + }; + + return ( +
+ {/* Header row */} +
+
+ + {cfg.icon} + {cfg.label} + + {evalMethod && ( + + {evalMethod} + + )} + {severity && ( + {severity} severity + )} + {!isPending && ( + + {item.status === 'approved' ? '✓' : '✗'} {item.status} + {item.reviewer_id && · {item.reviewer_id}} + + )} +
+ + {/* Full description */} +

{summary || '—'}

+ {reasonText && ( +

{reasonText}

+ )} + + {/* Metadata chips */} + {(ruleId || claimId || targetSection || updateType || (sections && sections.length > 0)) && ( +
+ {ruleId && {ruleId}} + {claimId && {claimId}} + {targetSection && target §{targetSection}} + {updateType && update: {updateType}} + {sections?.map((s) => §{s})} + queued {new Date(item.queued_at).toLocaleString()} +
+ )} + + {/* Confidence signal */} + {confidence != null && ( +
+ Confidence +
+
= 0.8 ? 'bg-emerald-500/60' : confidence >= 0.5 ? 'bg-amber-500/60' : 'bg-rose-500/60'}`} + style={{ width: `${Math.round(confidence * 100)}%` }} + /> +
+ = 0.8 ? 'text-emerald-300/70' : confidence >= 0.5 ? 'text-amber-300/70' : 'text-rose-300/70'}`}> + {(confidence * 100).toFixed(0)}% + +
+ )} +
+ + {/* Citations — both sides shown for conflicts */} + {item.payload.source_citations?.length > 0 && ( +
+ + {isConflict ? 'Conflicting Sources' : 'Source Citations'} + + {item.payload.source_citations.map((cit, idx) => ( + + ))} +
+ )} + + {/* Decision actions */} + {isPending && ( +
+ + +
+ )} + + {error && ( +
{error}
+ )} +
+ ); +} + +export function PendingReviewPanel({ runId, open, onClose, onPendingCountChange }: PendingReviewPanelProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [filter, setFilter] = useState<'pending' | 'all'>('pending'); + + // Fetch queue for this run + const fetchQueue = useCallback(async (): Promise => { + if (!runId) return []; + const res = await fetch(`${BASE_URL}/approval/runs/${runId}/queue`); + if (!res.ok) throw new Error(`Failed to load queue (${res.status})`); + let data = await res.json(); + + // Best-effort backfill if the queue was never populated for this run + if (data.total === 0) { + try { + const backfillRes = await fetch(`${BASE_URL}/runs/${runId}/populate-queue`, { method: 'POST' }); + if (backfillRes.ok) { + const backfill = await backfillRes.json(); + if (backfill.created > 0) { + const refetch = await fetch(`${BASE_URL}/approval/runs/${runId}/queue`); + if (refetch.ok) data = await refetch.json(); + } + } + } catch { /* best-effort */ } + } + + const fetchedItems: QueueItem[] = data.items ?? []; + setItems(fetchedItems); + onPendingCountChange?.(fetchedItems.filter((i) => i.status === 'pending').length); + return fetchedItems; + }, [runId, onPendingCountChange]); + + useEffect(() => { + if (!open || !runId) { + setItems([]); + setError(null); + return; + } + setLoading(true); + setError(null); + fetchQueue() + .catch((err) => setError(err instanceof Error ? err.message : 'Network error')) + .finally(() => setLoading(false)); + }, [open, runId, fetchQueue]); + + // Decision handler — POST to real endpoint, then update ONLY this item locally + const handleDecision = useCallback(async (itemId: string, decision: 'approved' | 'rejected') => { + const res = await fetch(`${BASE_URL}/approval/items/${itemId}/decide`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + decision, + reviewer_id: 'current-user', + justification: `${decision} via Pending Review tab`, + }), + }); + if (!res.ok) { + throw new Error(`Decision failed (${res.status})`); + } + // Surgical local update — other items are untouched + setItems((prev) => { + const next = prev.map((item) => + item.id === itemId + ? { ...item, status: decision, decision, decided_at: new Date().toISOString(), reviewer_id: 'current-user' } + : item + ); + onPendingCountChange?.(next.filter((i) => i.status === 'pending').length); + return next; + }); + }, [onPendingCountChange]); + + if (!open) return null; + + const pendingItems = items.filter((i) => i.status === 'pending'); + const visibleItems = filter === 'pending' ? pendingItems : items; + const counts = { + finding: items.filter((i) => i.item_type === 'finding' && i.status === 'pending').length, + conflict: items.filter((i) => i.item_type === 'conflict' && i.status === 'pending').length, + proposed_update: items.filter((i) => i.item_type === 'proposed_update' && i.status === 'pending').length, + }; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+

+ Pending Review + {pendingItems.length > 0 && ( + + {pendingItems.length} awaiting decision + + )} +

+
+ {counts.finding} findings + · + {counts.conflict} conflicts + · + {counts.proposed_update} proposed changes +
+
+
+ {/* Filter toggle */} +
+ {(['pending', 'all'] as const).map((f) => ( + + ))} +
+ +
+
+ + {/* Content */} +
+ {loading && ( +
+
+ + + + + Loading approval queue... +
+
+ )} + + {!loading && error && ( +
+

{error}

+ +
+ )} + + {!loading && !error && visibleItems.length === 0 && ( +
+ + + +

+ {filter === 'pending' ? 'Nothing waiting on a human.' : 'No items in the queue.'} +

+ {filter === 'pending' && items.length > 0 && ( + + )} +
+ )} + + {!loading && visibleItems.map((item) => ( + + ))} +
+
+ + ); +} + +export default PendingReviewPanel; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PilesPanel.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PilesPanel.tsx new file mode 100644 index 000000000..bae1bb0fa --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PilesPanel.tsx @@ -0,0 +1,344 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { fetchPiles, createPile, fetchPileDetail, uploadToPile, uploadIncrementalDocument, deletePile, seedDemo } from '@/services/pipelineApi'; +import type { PileListItem, PileDetail, IncrementalUpdateResponse, PileDocumentItem, SeedDemoResponse } from '@/services/pipelineApi'; +import { FileDropZone } from './FileDropZone'; +import { DocumentDetailPanel } from './DocumentDetailPanel'; + +interface PilesPanelProps { + selectedPileId: string | null; + onPileSelect: (pile: PileListItem) => void; + onDocumentsUploaded: () => void; + onDemoSeeded?: (result: SeedDemoResponse) => Promise | void; +} + +/** + * Piles section for the sidebar — list piles, create new pile, + * show documents in selected pile, and upload files into it. + */ +export function PilesPanel({ selectedPileId, onPileSelect, onDocumentsUploaded, onDemoSeeded }: PilesPanelProps) { + const [piles, setPiles] = useState([]); + const [pileDetail, setPileDetail] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [newPileName, setNewPileName] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [isIncremental, setIsIncremental] = useState(false); + const [incrementalResult, setIncrementalResult] = useState(null); + const [selectedDoc, setSelectedDoc] = useState(null); + const [isSeeding, setIsSeeding] = useState(false); + const [seedResult, setSeedResult] = useState(null); + const [seedError, setSeedError] = useState(null); + const incrementalInputRef = useRef(null); + + // Load piles list + const loadPiles = useCallback(async () => { + const result = await fetchPiles(); + setPiles(result); + }, []); + + useEffect(() => { + loadPiles(); + }, [loadPiles]); + + // Load pile detail when selection changes + useEffect(() => { + if (selectedPileId) { + fetchPileDetail(selectedPileId) + .then(setPileDetail) + .catch(() => setPileDetail(null)); + } else { + setPileDetail(null); + } + }, [selectedPileId]); + + const handleCreatePile = useCallback(async () => { + if (!newPileName.trim()) return; + try { + const created = await createPile(newPileName.trim()); + setNewPileName(''); + setIsCreating(false); + await loadPiles(); + // Auto-select the newly created pile + onPileSelect({ id: created.id, name: created.name, status: 'active', created_at: '', document_count: 0 }); + } catch (err) { + console.error('Failed to create pile:', err); + } + }, [newPileName, loadPiles, onPileSelect]); + + const handleSeedDemo = useCallback(async () => { + setIsSeeding(true); + setSeedError(null); + setSeedResult(null); + try { + const result = await seedDemo(); + setSeedResult(result); + // Refresh the pile list so the seeded demo pile appears. + await loadPiles(); + await onDemoSeeded?.(result); + } catch (err) { + setSeedError(err instanceof Error ? err.message : 'Demo seed failed'); + } finally { + setIsSeeding(false); + } + }, [loadPiles, onDemoSeeded]); + + const handleFilesSelected = useCallback(async (files: File[]) => { + if (!selectedPileId) return; + setIsUploading(true); + setUploadError(null); + try { + const result = await uploadToPile(selectedPileId, files); + if (result.errors.length > 0) { + setUploadError(result.errors.join(', ')); + } + // Refresh pile detail and list + await loadPiles(); + const detail = await fetchPileDetail(selectedPileId); + setPileDetail(detail); + onDocumentsUploaded(); + } catch (err) { + setUploadError(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setIsUploading(false); + } + }, [selectedPileId, loadPiles, onDocumentsUploaded]); + + const handleIncrementalUpload = useCallback(async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0 || !selectedPileId) return; + setIsIncremental(true); + setUploadError(null); + setIncrementalResult(null); + try { + const result = await uploadIncrementalDocument(selectedPileId, Array.from(files)); + setIncrementalResult(result); + // Refresh pile detail + await loadPiles(); + const detail = await fetchPileDetail(selectedPileId); + setPileDetail(detail); + onDocumentsUploaded(); + } catch (err) { + setUploadError(err instanceof Error ? err.message : 'Incremental update failed'); + } finally { + setIsIncremental(false); + // Reset file input + if (incrementalInputRef.current) incrementalInputRef.current.value = ''; + } + }, [selectedPileId, loadPiles, onDocumentsUploaded]); + + return ( +
+ {/* Section header + New button */} +
+ + Piles + + +
+ + {/* Run demo — seeds the fixed 5-document demo pile */} +
+ + {seedResult && ( +

+ {seedResult.status === 'seeded' + ? `Demo seeded — ${seedResult.document_count} documents, ${seedResult.conflicts?.length ?? 0} conflicts.` + : 'Demo already present — nothing to seed.'} +

+ )} + {seedError && ( +

{seedError}

+ )} +
+ + {/* Create pile inline form */} + {isCreating && ( +
+ setNewPileName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreatePile(); }} + placeholder="Pile name..." + className="flex-1 rounded-md border border-white/[0.1] bg-white/[0.03] px-2.5 py-1.5 text-[12px] text-white/80 placeholder-white/30 focus:border-indigo-500/50 focus:outline-none focus:ring-1 focus:ring-indigo-500/20" + autoFocus + /> + +
+ )} + + {/* Pile list */} +
+ {piles.length === 0 && ( +

No piles yet

+ )} + {piles.map((pile) => { + const isActive = pile.id === selectedPileId; + return ( +
onPileSelect(pile)} + > +
+ + {pile.name} + +
+ + {pile.document_count} doc{pile.document_count !== 1 ? 's' : ''} + + +
+
+
+ ); + })} +
+ + {/* Selected pile: document list + upload zone */} + {selectedPileId && pileDetail && ( +
+ + Documents in "{pileDetail.name}" + + + {pileDetail.documents.length === 0 ? ( +

No documents yet — drop files below

+ ) : ( +
+ {pileDetail.documents.map((doc) => ( +
setSelectedDoc(doc)} + title={`View extracted facts from ${doc.filename}`} + data-testid={`document-${doc.document_id}`} + > + + + {doc.filename} + + + + +
+ ))} +
+ )} + + {/* Drop zone */} + + + {/* Incremental update action */} +
+ + +

+ Updates only affected sections — no full re-run +

+
+ + {/* Incremental result feedback */} + {incrementalResult && ( +
+

Incremental update complete

+

+ {incrementalResult.sections_updated} section{incrementalResult.sections_updated !== 1 ? 's' : ''} updated,{' '} + {incrementalResult.unaffected_sections.length} unchanged +

+ {incrementalResult.conflicts_detected > 0 && ( +

+ {incrementalResult.conflicts_detected} conflict{incrementalResult.conflicts_detected !== 1 ? 's' : ''} sent to approval queue +

+ )} +
+ )} + + {uploadError && ( +

{uploadError}

+ )} +
+ )} + + {/* Document detail overlay */} + {selectedDoc && ( + setSelectedDoc(null)} + /> + )} +
+ ); +} + +/** Tiny MIME type icon */ +function MimeIcon({ mime }: { mime: string }) { + const color = mime.includes('pdf') ? 'text-rose-400/70' : + mime.includes('word') ? 'text-blue-400/70' : 'text-white/40'; + return ( + + + + ); +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PipelineNode.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PipelineNode.tsx new file mode 100644 index 000000000..c73bc0f5b --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/PipelineNode.tsx @@ -0,0 +1,153 @@ +import { memo, useRef, useEffect, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeStatus } from '@/types/pipeline'; +import { STATUS_CONFIG } from '@/utils/pipelineColors'; + +interface PipelineNodeData { + label: string; + status: NodeStatus; + icon: string; + overlayMode?: 'none' | 'history' | 'cost'; + historyCount?: number; + costData?: { time: string; cost: string } | null; + justTransitioned?: boolean; + [key: string]: unknown; +} + +function NodeIcon({ icon }: { icon: string }) { + switch (icon) { + case 'play': + return ( + + + + + ); + case 'document': + return ( + + + + + + ); + case 'download': + return ( + + + + + ); + case 'brain': + return ( + + + + + + ); + case 'search': + return ( + + + + + ); + case 'shield': + return ( + + + + + ); + case 'check': + return ( + + + + ); + case 'alert': + return ( + + + + ); + default: + return ( + + + + ); + } +} + +export const PipelineNode = memo(function PipelineNode({ data }: { data: PipelineNodeData }) { + const { label, status, icon, overlayMode, historyCount, costData } = data; + const config = STATUS_CONFIG[status]; + const prevStatusRef = useRef(status); + const [transitioning, setTransitioning] = useState(false); + + // Detect actual status change (from prop diff, not poll tick) + useEffect(() => { + if (prevStatusRef.current !== status) { + setTransitioning(true); + prevStatusRef.current = status; + const timer = setTimeout(() => setTransitioning(false), 400); + return () => clearTimeout(timer); + } + }, [status]); + + const displayIcon = transitioning && status === 'complete' ? 'check' + : transitioning && (status === 'failed' || status === 'escalated') ? 'alert' + : icon; + + return ( +
+ + + {/* History mode badge */} + {overlayMode === 'history' && historyCount !== undefined && historyCount > 0 && ( +
+ {historyCount} +
+ )} + +
+ +
+ + {label} + + {/* Default: status line */} + {overlayMode !== 'cost' && ( +
+ + {config.label} +
+ )} + + {/* Cost mode: time/cost inline */} + {overlayMode === 'cost' && costData && ( +
+ {costData.time} + · + {costData.cost} +
+ )} + + +
+ ); +}); + +export default PipelineNode; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/ReportPanel.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/ReportPanel.tsx new file mode 100644 index 000000000..4bef87890 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/ReportPanel.tsx @@ -0,0 +1,540 @@ +/** + * Compliance Report Panel — per-run report with real data. + * + * Only opens via explicit user action (click "Report" button). + * Always tied to a specific run_id. Fetches: + * - GET /runs/{id}/report — pipeline results (claims, findings, routing, cost) + * - GET /approval/runs/{id}/queue — live approval queue status + * + * Sections: Header, Summary, Claims, Findings/Escalated Items, Actions. + */ +import { useState, useEffect } from 'react'; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface ReportData { + run_id: string; + ready: boolean; + status: string; + generated_at: string; + document: { + filename: string | null; + classification: string; + classification_confidence: number; + text_length: number; + }; + assessment: { + overall_verdict: string; + risk_level: string; + risk_score: number; + summary: string; + }; + claims: { + total: number; + compliant: number; + non_compliant: number; + indeterminate: number; + details: Array<{ + claim_id: string; + claim_text: string; + confidence: number; + verdict: string; + rule_id: string | null; + needs_review: boolean; + }>; + }; + findings: { + total: number; + items: Array>; + source_findings: Array>; + }; + routing: { + auto_approved: number; + escalated: number; + auto_rejected: number; + }; + execution: { + total_duration_ms: number; + total_cost_usd: number; + total_input_tokens: number; + total_output_tokens: number; + nodes_completed: number; + nodes_skipped: number; + nodes_failed: number; + }; +} + +interface ApprovalItem { + id: string; + run_id: string; + item_type: string; + payload: Record; + status: string; + queued_at: string; + decided_at: string | null; + decision: string | null; + reviewer_id: string | null; +} + +interface ApprovalQueueData { + run_id: string; + items: ApprovalItem[]; + total: number; + pending: number; +} + +interface ReportPanelProps { + runId: string | null; + runStatus: string | null; + onClose: () => void; + open: boolean; + /** Callback to open the approval panel for this run */ + onOpenApprovals?: () => void; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const VERDICT_STYLES: Record = { + 'COMPLIANT': 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30', + 'MOSTLY COMPLIANT': 'bg-emerald-500/15 text-emerald-200 border-emerald-500/25', + 'REQUIRES REVIEW': 'bg-amber-500/20 text-amber-300 border-amber-500/30', + 'NON-COMPLIANT': 'bg-rose-500/20 text-rose-300 border-rose-500/30', +}; + +const RISK_COLORS: Record = { + 'MINIMAL': 'text-emerald-400', + 'LOW': 'text-emerald-300', + 'MEDIUM': 'text-amber-300', + 'HIGH': 'text-rose-400', +}; + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export function ReportPanel({ runId, runStatus, onClose, open, onOpenApprovals }: ReportPanelProps) { + const [report, setReport] = useState(null); + const [approvals, setApprovals] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Fetch report + approval data when opened (or runId changes while open) + useEffect(() => { + if (!open || !runId) { + return; + } + + setLoading(true); + setError(null); + + const fetchAll = async () => { + try { + const [reportRes, approvalRes] = await Promise.all([ + fetch(`${BASE_URL}/runs/${runId}/report`), + fetch(`${BASE_URL}/approval/runs/${runId}/queue`), + ]); + + if (reportRes.ok) { + const data = await reportRes.json(); + setReport(data.ready ? data : null); + } else { + setReport(null); + setError('Failed to load report data'); + } + + let approvalData: ApprovalQueueData | null = null; + if (approvalRes.ok) { + approvalData = await approvalRes.json(); + } + + // If there are escalated claims but no approval items, backfill the queue + if (approvalData && approvalData.total === 0) { + try { + const backfillRes = await fetch(`${BASE_URL}/runs/${runId}/populate-queue`, { method: 'POST' }); + if (backfillRes.ok) { + const backfillData = await backfillRes.json(); + if (backfillData.created > 0) { + // Re-fetch approval queue after backfill + const refetchRes = await fetch(`${BASE_URL}/approval/runs/${runId}/queue`); + if (refetchRes.ok) { + approvalData = await refetchRes.json(); + } + } + } + } catch { /* backfill is best-effort */ } + } + + setApprovals(approvalData); + } catch { + setError('Network error loading report'); + } finally { + setLoading(false); + } + }; + + fetchAll(); + }, [open, runId]); + + if (!open) return null; + + // Handle inline approve/reject decisions + const handleDecision = async (itemId: string, decision: 'approved' | 'rejected') => { + try { + await fetch(`${BASE_URL}/approval/items/${itemId}/decide`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision, reviewer_id: 'report-user', justification: `${decision} via compliance report` }), + }); + // Update local state + setApprovals((prev) => { + if (!prev) return prev; + const updatedItems = prev.items.map((item) => + item.id === itemId ? { ...item, status: decision, decision, decided_at: new Date().toISOString() } : item + ); + const pendingNow = updatedItems.filter((i) => i.status === 'pending').length; + return { ...prev, items: updatedItems, pending: pendingNow }; + }); + } catch { /* keep original state on failure */ } + }; + + const shortRunId = runId?.slice(0, 8) ?? '—'; + const filename = report?.document?.filename ?? '—'; + const pendingCount = approvals?.pending ?? 0; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* ─── Header ─────────────────────────────────────────────────── */} +
+
+ + + +
+

+ Compliance Report — Run {shortRunId} +

+

+ {filename} {report?.document?.classification ? `· ${report.document.classification.replace(/_/g, ' ')}` : ''} +

+
+
+ +
+ + {/* ─── Content ────────────────────────────────────────────────── */} +
+ {loading && ( +
+ + + + + Loading report... +
+ )} + + {error && !loading && ( +
+

{error}

+

Run ID: {runId}

+
+ )} + + {!loading && !error && report && ( +
+ + {/* ─── 1. Run Metadata Header ────────────────────────────── */} +
+
+
+ + {report.assessment.overall_verdict} + +

+ {report.assessment.summary} +

+
+ Run: {shortRunId} + Status: {runStatus ?? report.status} + {report.generated_at && Generated: {new Date(report.generated_at).toLocaleString()}} + Text: {report.document.text_length.toLocaleString()} chars +
+
+
+
Risk Score
+
+ {report.assessment.risk_score} +
+
+ {report.assessment.risk_level} +
+
+
+
+ + {/* ─── 2. Summary Numbers ────────────────────────────────── */} +
+ + + + + + 0 ? 'text-amber-400' : 'text-white/50'} /> +
+ + {/* Execution cost bar */} +
+ Duration: {formatDuration(report.execution.total_duration_ms)} + Cost: ${report.execution.total_cost_usd.toFixed(4)} + Tokens: {(report.execution.total_input_tokens + report.execution.total_output_tokens).toLocaleString()} + Nodes: {report.execution.nodes_completed} completed, {report.execution.nodes_skipped} skipped + Escalated: {report.routing.escalated} +
+ + {/* ─── 3. Claim-by-Claim Section ─────────────────────────── */} +
+

+ Claim-by-Claim Analysis ({report.claims.total}) +

+
+ {report.claims.details.map((claim) => ( + + ))} + {report.claims.details.length === 0 && ( +

No claims extracted

+ )} +
+
+ + {/* ─── 4. Findings / Escalated Items ─────────────────────── */} + {(approvals?.items?.length ?? 0) > 0 && ( +
+

+ Escalated Items — Approval Queue ({approvals!.total}) +

+
+ + i.status === 'approved').length} color="text-emerald-400" /> + i.status === 'rejected').length} color="text-rose-400" /> + i.status === 'approved_needs_recheck').length} color="text-amber-300" /> +
+
+ {approvals!.items.map((item) => ( + + ))} +
+
+ )} + + {/* Findings from pipeline (non-approval) */} + {(report.findings.total > 0 || report.findings.source_findings.length > 0) && ( +
+

+ Pipeline Findings ({report.findings.total + report.findings.source_findings.length}) +

+
+ {report.findings.items.map((f, i) => ( + + ))} + {report.findings.source_findings.map((f, i) => ( + + ))} +
+
+ )} + + {/* ─── 5. Actions ────────────────────────────────────────── */} +
+ {pendingCount > 0 && onOpenApprovals && ( + + )} + +
+
+ )} + + {/* No report data but not loading/error (run hasn't completed) */} + {!loading && !error && !report && ( +
+

No report available for this run yet.

+

Reports are generated when a pipeline run completes.

+
+ )} +
+
+ + ); +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function StatCard({ label, value, color }: { label: string; value: number; color?: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function MiniStat({ label, value, color }: { label: string; value: number; color?: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function ClaimRow({ claim, pendingItems }: { + claim: { claim_id: string; claim_text: string; confidence: number; verdict: string; rule_id: string | null; needs_review: boolean }; + pendingItems?: ApprovalItem[]; +}) { + // Check if this claim has a pending approval item + const pendingItem = pendingItems?.find( + (item) => item.status === 'pending' && ( + item.payload?.details?.claim_id === claim.claim_id || + item.payload?.summary === claim.claim_text + ) + ); + + const verdictStyles: Record = { + compliant: 'bg-emerald-500/20 text-emerald-300', + non_compliant: 'bg-rose-500/20 text-rose-300', + indeterminate: 'bg-amber-500/20 text-amber-300', + }; + + const verdictIcon: Record = { + compliant: '\u2713', + non_compliant: '\u2717', + }; + + return ( +
+ + {verdictIcon[claim.verdict] ?? '?'} + +
+

{claim.claim_text}

+
+ {claim.claim_id} + {claim.rule_id && ( + {claim.rule_id} + )} + {claim.needs_review && ( + REVIEW + )} + {pendingItem && ( + + pending approval + + )} +
+
+ + {(claim.confidence * 100).toFixed(0)}% + +
+ ); +} + +function ApprovalItemRow({ item, onDecide }: { item: ApprovalItem; onDecide?: (itemId: string, decision: 'approved' | 'rejected') => void }) { + const [submitting, setSubmitting] = useState(null); + const statusStyles: Record = { + pending: 'bg-amber-500/15 text-amber-300 border-amber-500/30', + approved: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30', + rejected: 'bg-rose-500/15 text-rose-300 border-rose-500/30', + }; + + const summary = item.payload?.summary ?? item.payload?.claim_text ?? item.id.slice(0, 12); + + const handleDecide = async (decision: 'approved' | 'rejected') => { + setSubmitting(decision); + if (onDecide) await onDecide(item.id, decision); + setSubmitting(null); + }; + + return ( +
+ + {item.status} + +

{summary}

+ {item.status === 'pending' && onDecide && ( +
+ + +
+ )} + {item.status !== 'pending' && ( + {item.item_type} + )} +
+ ); +} + +function FindingRow({ finding }: { finding: Record }) { + const severity = finding.severity ?? 'medium'; + const sevColors: Record = { + high: 'bg-rose-500/20 text-rose-300 border-rose-500/30', + medium: 'bg-amber-500/20 text-amber-300 border-amber-500/30', + low: 'bg-white/10 text-white/50 border-white/20', + }; + + return ( +
+ + {severity} + +
+

+ {finding.description || finding.reason || finding.finding_type || '—'} +

+ {finding.rule_id && ( + {finding.rule_id} + )} +
+
+ ); +} + +export default ReportPanel; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/SmoothEdge.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/SmoothEdge.tsx new file mode 100644 index 000000000..6cc3ecccb --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/pipeline/SmoothEdge.tsx @@ -0,0 +1,96 @@ +import { memo } from 'react'; +import { getSmoothStepPath } from '@xyflow/react'; +import type { EdgeProps } from '@xyflow/react'; +import type { NodeStatus, EdgeDecision } from '@/types/pipeline'; + +interface SmoothEdgeData { + sourceStatus?: NodeStatus; + decision?: EdgeDecision; + [key: string]: unknown; +} + +export const SmoothEdge = memo(function SmoothEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, +}: EdgeProps) { + const edgeData = data as SmoothEdgeData | undefined; + const sourceStatus = edgeData?.sourceStatus ?? 'ready'; + const decision = edgeData?.decision; + + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: 20, + }); + + let strokeColor = 'rgba(255,255,255,0.12)'; + let strokeDasharray = ''; + let glowFilter = ''; + let strokeWidth = 2; + let useFlowAnimation = false; + + // Decision-based styling takes priority + if (decision === 'retry') { + strokeColor = 'rgba(129,140,248,0.7)'; + strokeDasharray = '6 4'; + glowFilter = 'drop-shadow(0 0 3px rgba(129,140,248,0.4))'; + } else if (decision === 'skip') { + strokeColor = 'rgba(251,191,36,0.5)'; + strokeDasharray = '4 6'; + } else if (decision === 'escalate') { + strokeColor = 'rgba(251,113,133,0.8)'; + glowFilter = 'drop-shadow(0 0 4px rgba(251,113,133,0.4))'; + strokeWidth = 2.5; + } else { + // Status-based styling + if (sourceStatus === 'complete') { + strokeColor = 'rgba(16,185,129,0.45)'; + glowFilter = 'drop-shadow(0 0 2px rgba(16,185,129,0.2))'; + } else if (sourceStatus === 'processing' || sourceStatus === 'retrying') { + // Flowing dash animation — communicates active data flow + strokeColor = 'rgba(99,102,241,0.6)'; + strokeDasharray = '8 6'; + glowFilter = 'drop-shadow(0 0 3px rgba(99,102,241,0.3))'; + useFlowAnimation = true; + } else if (sourceStatus === 'failed' || sourceStatus === 'escalated') { + strokeColor = 'rgba(251,113,133,0.4)'; + } + } + + return ( + + + {decision && decision !== 'next' && ( + + {decision === 'retry' ? '↺ retry' : decision === 'skip' ? '⤳ skip' : '⚠ escalate'} + + )} + + ); +}); + +export default SmoothEdge; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationChip.test.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationChip.test.tsx new file mode 100644 index 000000000..493b0aeba --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationChip.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { CitationChip } from "./CitationChip"; +import type { SourceCitation } from "@/types/review"; + +describe("CitationChip", () => { + it("renders '[citation unverifiable]' with amber warning styling for unverifiable status", () => { + const citation: SourceCitation = { + claim_id: "c1", + claim_text: "Some claim", + citation_status: "unverifiable", + source_location: null, + }; + + render(); + + const chip = screen.getByText("[citation unverifiable]"); + expect(chip).toBeInTheDocument(); + expect(chip).toHaveClass("bg-amber-500/10", "text-amber-300", "border-amber-500/20"); + expect(chip).toHaveAttribute("aria-label", "Citation unverifiable"); + }); + + it("renders '[citation unverifiable]' when source_location is null even if status is grounded", () => { + const citation: SourceCitation = { + claim_id: "c2", + claim_text: "Another claim", + citation_status: "grounded", + source_location: null, + }; + + render(); + + const chip = screen.getByText("[citation unverifiable]"); + expect(chip).toBeInTheDocument(); + expect(chip).toHaveClass("bg-amber-500/10"); + expect(chip).toHaveAttribute("aria-label", "Citation unverifiable"); + }); + + it("renders clause_ref for grounded citation with clause_ref", () => { + const citation: SourceCitation = { + claim_id: "c3", + claim_text: "Grounded claim", + citation_status: "grounded", + source_location: { + page_number: 5, + section_id: "s1", + start_offset: 0, + end_offset: 100, + clause_ref: "§4.2(a)", + }, + }; + + render(); + + const chip = screen.getByText("§4.2(a)"); + expect(chip).toBeInTheDocument(); + expect(chip).toHaveClass("text-white/60", "border-white/[0.08]"); + expect(chip).not.toHaveClass("bg-amber-500/10"); + expect(chip).toHaveAttribute("aria-label", "Citation grounded: §4.2(a)"); + }); + + it("renders page reference for grounded citation without clause_ref", () => { + const citation: SourceCitation = { + claim_id: "c4", + claim_text: "Page ref claim", + citation_status: "grounded", + source_location: { + page_number: 12, + section_id: null, + start_offset: 50, + end_offset: 200, + clause_ref: null, + }, + }; + + render(); + + const chip = screen.getByText("p.12"); + expect(chip).toBeInTheDocument(); + expect(chip).toHaveAttribute("aria-label", "Citation grounded: p.12"); + }); +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationChip.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationChip.tsx new file mode 100644 index 000000000..89ecaf39b --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationChip.tsx @@ -0,0 +1,41 @@ +import { getCitationLabel, isUnverifiable } from "@/utils/citationHelpers"; +import type { SourceCitation } from "@/types/review"; + +interface CitationChipProps { + citation: SourceCitation; +} + +/** + * Renders a small inline chip indicating whether a citation is grounded + * or unverifiable. Applies a warning pulse animation for unverifiable citations. + */ +export function CitationChip({ citation }: CitationChipProps) { + const unverifiable = isUnverifiable(citation); + const label = getCitationLabel(citation); + + const baseClasses = + "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-all"; + + const colorClasses = unverifiable + ? "bg-amber-500/10 text-amber-300 border-amber-500/20 warning-pulse" + : "border-white/[0.08] text-white/60 bg-white/[0.03]"; + + return ( + + {unverifiable && ( + + )} + {label} + + ); +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationList.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationList.tsx new file mode 100644 index 000000000..f393c65f8 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/CitationList.tsx @@ -0,0 +1,28 @@ +import type { SourceCitation } from "@/types/review"; +import { CitationChip } from "./CitationChip"; + +interface CitationListProps { + citations: SourceCitation[]; +} + +/** + * Renders an array of CitationChip components with a count header. + */ +export function CitationList({ citations }: CitationListProps) { + return ( +
+

+ Source Citations ({citations.length}) +

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

No citations available.

+ ) : ( +
+ {citations.map((citation) => ( + + ))} +
+ )} +
+ ); +} diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/ConnectionLostBanner.test.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/ConnectionLostBanner.test.tsx new file mode 100644 index 000000000..4fd5e9d42 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/ConnectionLostBanner.test.tsx @@ -0,0 +1,63 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { ConnectionLostBanner } from "./ConnectionLostBanner"; + +describe("ConnectionLostBanner", () => { + it("renders nothing when connectionLost is false", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders the warning banner when connectionLost is true", () => { + render(); + expect( + screen.getByText("Connection lost. Data may be stale. Retrying...") + ).toBeInTheDocument(); + }); + + it('has role="alert" for screen reader announcement', () => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + it('has aria-live="assertive" for immediate announcement', () => { + render(); + const banner = screen.getByRole("alert"); + expect(banner).toHaveAttribute("aria-live", "assertive"); + }); + + it("does not render dismiss button when onDismiss is not provided", () => { + render(); + expect( + screen.queryByLabelText("Dismiss connection warning") + ).not.toBeInTheDocument(); + }); + + it("renders dismiss button when onDismiss is provided", () => { + const onDismiss = vi.fn(); + render( + + ); + expect( + screen.getByLabelText("Dismiss connection warning") + ).toBeInTheDocument(); + }); + + it("calls onDismiss when dismiss button is clicked", () => { + const onDismiss = vi.fn(); + render( + + ); + fireEvent.click(screen.getByLabelText("Dismiss connection warning")); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("applies amber/warning styling classes", () => { + render(); + const banner = screen.getByRole("alert"); + expect(banner.className).toContain("backdrop-blur-xl"); + expect(banner.className).toContain("border-amber-500/20"); + }); +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/ConnectionLostBanner.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/ConnectionLostBanner.tsx new file mode 100644 index 000000000..a9a04414f --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/ConnectionLostBanner.tsx @@ -0,0 +1,77 @@ +import React from "react"; + +export interface ConnectionLostBannerProps { + connectionLost: boolean; + onDismiss?: () => void; +} + +/** + * Displays a dismissible warning banner when the polling service + * loses connectivity to the backend. Uses ARIA role="alert" for + * immediate screen reader announcement. + * + * Validates: Requirements 11.3, 9.3 + */ +export const ConnectionLostBanner: React.FC = ({ + connectionLost, + onDismiss, +}) => { + if (!connectionLost) { + return null; + } + + return ( +
+
+ + + + + Connection lost. Data may be stale. Retrying... + +
+ {onDismiss && ( + + )} +
+ ); +}; + +export default ConnectionLostBanner; diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.property.test.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.property.test.tsx new file mode 100644 index 000000000..0668f9d01 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.property.test.tsx @@ -0,0 +1,252 @@ +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DecisionControls } from "./DecisionControls"; +import type { + QueueItem, + QueueItemPayload, + SourceCitation, + ItemType, + ItemStatus, +} from "@/types/review"; + +// --- Arbitraries --- + +const itemTypeArb: fc.Arbitrary = fc.constantFrom( + "finding", + "conflict", + "proposed_update" +); + +const nonPendingStatusArb: fc.Arbitrary = fc.constantFrom( + "approved" as const, + "rejected" as const +); + +const sourceCitationArb: fc.Arbitrary = fc.record({ + claim_id: fc.uuid(), + claim_text: fc.string({ minLength: 1, maxLength: 100 }), + citation_status: fc.constantFrom( + "grounded" as const, + "unverifiable" as const + ), + source_location: fc.option( + fc.record({ + page_number: fc.option(fc.integer({ min: 1, max: 500 }), { nil: null }), + section_id: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { + nil: null, + }), + start_offset: fc.nat({ max: 10000 }), + end_offset: fc.nat({ max: 10000 }), + clause_ref: fc.option(fc.string({ minLength: 1, maxLength: 30 }), { + nil: null, + }), + }), + { nil: null } + ), +}); + +const queueItemPayloadArb: fc.Arbitrary = fc.record({ + summary: fc.string({ minLength: 1, maxLength: 200 }), + details: fc.constant({} as Record), + source_citations: fc.array(sourceCitationArb, { + minLength: 0, + maxLength: 3, + }), +}); + +const queueItemArb = ( + status: fc.Arbitrary +): fc.Arbitrary => + fc.record({ + id: fc.uuid(), + run_id: fc.uuid(), + item_type: itemTypeArb, + payload: queueItemPayloadArb, + status, + queued_at: fc.date().map((d) => d.toISOString()), + decided_at: fc.option(fc.date().map((d) => d.toISOString()), { + nil: null, + }), + decision: fc.option( + fc.constantFrom("approved" as const, "rejected" as const), + { nil: null } + ), + reviewer_id: fc.option(fc.uuid(), { nil: null }), + justification: fc.option(fc.string({ minLength: 1, maxLength: 200 }), { + nil: null, + }), + }); + +/** Generates empty or whitespace-only strings */ +const emptyOrWhitespaceArb: fc.Arbitrary = fc.oneof( + fc.constant(""), + fc.stringOf(fc.constantFrom(" ", "\t", "\n", "\r"), { + minLength: 1, + maxLength: 20, + }) +); + +/** Generates non-empty strings that contain at least one non-whitespace character */ +const nonEmptyNonWhitespaceArb: fc.Arbitrary = fc + .tuple( + fc.string({ minLength: 0, maxLength: 10 }), + fc.char().filter((c) => c.trim().length > 0), + fc.string({ minLength: 0, maxLength: 10 }) + ) + .map(([prefix, char, suffix]) => prefix + char + suffix); + +// --- Tests --- + +describe("DecisionControls Property Tests", () => { + /** + * Property 5: Decision Button Visibility + * + * For any QueueItem, Approve and Reject buttons SHALL be visible if and only + * if the item's status is "pending". For items with status "approved" or + * "rejected", no decision buttons should be rendered. + * + * **Validates: Requirements 3.2, 3.6** + */ + describe("Property 5: Decision Button Visibility", () => { + it("Approve and Reject buttons are rendered when status is pending", () => { + fc.assert( + fc.property( + queueItemArb(fc.constant("pending" as ItemStatus)), + (item) => { + const { unmount } = render( + {}} + isSubmitting={false} + /> + ); + + const approveButton = screen.queryByRole("button", { + name: /approve/i, + }); + const rejectButton = screen.queryByRole("button", { + name: /reject/i, + }); + + expect(approveButton).toBeInTheDocument(); + expect(rejectButton).toBeInTheDocument(); + + unmount(); + } + ), + { numRuns: 100 } + ); + }); + + it("Approve and Reject buttons are NOT rendered when status is not pending", () => { + fc.assert( + fc.property(queueItemArb(nonPendingStatusArb), (item) => { + const { unmount } = render( + {}} + isSubmitting={false} + /> + ); + + const approveButton = screen.queryByRole("button", { + name: /approve/i, + }); + const rejectButton = screen.queryByRole("button", { + name: /reject/i, + }); + + expect(approveButton).not.toBeInTheDocument(); + expect(rejectButton).not.toBeInTheDocument(); + + unmount(); + }), + { numRuns: 100 } + ); + }); + }); + + /** + * Property 6: Justification Required + * + * For any pending QueueItem and any justification text that is empty or + * whitespace-only, the decision buttons SHALL be disabled (submission blocked). + * For non-empty non-whitespace justification, buttons SHALL be enabled. + * + * **Validates: Requirements 3.3** + */ + describe("Property 6: Justification Required", () => { + it("buttons are disabled when justification is empty or whitespace-only", () => { + fc.assert( + fc.property( + queueItemArb(fc.constant("pending" as ItemStatus)), + emptyOrWhitespaceArb, + (item, whitespaceText) => { + const { unmount } = render( + {}} + isSubmitting={false} + /> + ); + + // Use fireEvent.change to set the justification textarea value + const textarea = screen.getByLabelText(/decision justification/i); + fireEvent.change(textarea, { target: { value: whitespaceText } }); + + const approveButton = screen.getByRole("button", { + name: /approve/i, + }); + const rejectButton = screen.getByRole("button", { + name: /reject/i, + }); + + expect(approveButton).toBeDisabled(); + expect(rejectButton).toBeDisabled(); + + unmount(); + } + ), + { numRuns: 100 } + ); + }); + + it("buttons are enabled when justification has non-whitespace content", () => { + fc.assert( + fc.property( + queueItemArb(fc.constant("pending" as ItemStatus)), + nonEmptyNonWhitespaceArb, + (item, justificationText) => { + const { unmount } = render( + {}} + isSubmitting={false} + /> + ); + + // Use fireEvent.change to set meaningful justification text + const textarea = screen.getByLabelText(/decision justification/i); + fireEvent.change(textarea, { + target: { value: justificationText }, + }); + + const approveButton = screen.getByRole("button", { + name: /approve/i, + }); + const rejectButton = screen.getByRole("button", { + name: /reject/i, + }); + + expect(approveButton).toBeEnabled(); + expect(rejectButton).toBeEnabled(); + + unmount(); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.test.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.test.tsx new file mode 100644 index 000000000..f87296ba3 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.test.tsx @@ -0,0 +1,282 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { DecisionControls } from "./DecisionControls"; +import { QueueItem } from "@/types/review"; + +function makePendingItem(overrides: Partial = {}): QueueItem { + return { + id: "item-1", + run_id: "run-1", + item_type: "finding", + payload: { + summary: "Test finding", + details: {}, + source_citations: [], + }, + status: "pending", + queued_at: "2024-01-01T00:00:00Z", + decided_at: null, + decision: null, + reviewer_id: null, + justification: null, + ...overrides, + }; +} + +function makeDecidedItem( + decision: "approved" | "rejected" +): QueueItem { + return makePendingItem({ + status: decision, + decision, + decided_at: "2024-01-01T01:00:00Z", + reviewer_id: "reviewer-1", + justification: "Already decided", + }); +} + +describe("DecisionControls", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("when item is pending", () => { + it("renders Approve and Reject buttons", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /approve/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument(); + }); + + it("renders justification textarea with proper aria-label", () => { + render( + + ); + + const textarea = screen.getByLabelText("Decision justification"); + expect(textarea).toBeInTheDocument(); + expect(textarea.tagName).toBe("TEXTAREA"); + }); + + it("disables buttons when justification is empty", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /approve/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /reject/i })).toBeDisabled(); + }); + + it("disables buttons when justification is only whitespace", () => { + render( + + ); + + const textarea = screen.getByLabelText("Decision justification"); + fireEvent.change(textarea, { target: { value: " " } }); + + expect(screen.getByRole("button", { name: /approve/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /reject/i })).toBeDisabled(); + }); + + it("enables buttons when justification has non-whitespace text", () => { + render( + + ); + + const textarea = screen.getByLabelText("Decision justification"); + fireEvent.change(textarea, { target: { value: "Looks good" } }); + + expect(screen.getByRole("button", { name: /approve/i })).toBeEnabled(); + expect(screen.getByRole("button", { name: /reject/i })).toBeEnabled(); + }); + + it("calls onDecide with 'approved' and trimmed justification on Approve click", () => { + const onDecide = vi.fn(); + render( + + ); + + const textarea = screen.getByLabelText("Decision justification"); + fireEvent.change(textarea, { target: { value: " Confirmed valid " } }); + fireEvent.click(screen.getByRole("button", { name: /approve/i })); + + // Wait for sweep animation timeout + act(() => { vi.advanceTimersByTime(400); }); + + expect(onDecide).toHaveBeenCalledWith("approved", "Confirmed valid"); + }); + + it("calls onDecide with 'rejected' and trimmed justification on Reject click", () => { + const onDecide = vi.fn(); + render( + + ); + + const textarea = screen.getByLabelText("Decision justification"); + fireEvent.change(textarea, { target: { value: "Non-compliant" } }); + fireEvent.click(screen.getByRole("button", { name: /reject/i })); + + // Wait for sweep animation timeout + act(() => { vi.advanceTimersByTime(400); }); + + expect(onDecide).toHaveBeenCalledWith("rejected", "Non-compliant"); + }); + + it("clears the textarea after submitting a decision", () => { + render( + + ); + + const textarea = screen.getByLabelText("Decision justification") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "Valid" } }); + fireEvent.click(screen.getByRole("button", { name: /approve/i })); + + // Wait for sweep animation timeout + act(() => { vi.advanceTimersByTime(400); }); + + expect(textarea.value).toBe(""); + }); + + it("disables buttons while isSubmitting is true", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /approve/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /reject/i })).toBeDisabled(); + }); + + it("shows loading spinners when isSubmitting", () => { + const { container } = render( + + ); + + const spinners = container.querySelectorAll("svg.animate-spin"); + expect(spinners.length).toBe(2); + }); + + it("disables the textarea while isSubmitting", () => { + render( + + ); + + const textarea = screen.getByLabelText("Decision justification"); + expect(textarea).toBeDisabled(); + }); + }); + + describe("when item is not pending", () => { + it("does not show Approve or Reject buttons for approved items", () => { + render( + + ); + + expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument(); + }); + + it("does not show Approve or Reject buttons for rejected items", () => { + render( + + ); + + expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument(); + }); + + it("displays the decision for approved items", () => { + render( + + ); + + expect(screen.getByText("approved")).toBeInTheDocument(); + }); + + it("displays the decision for rejected items", () => { + render( + + ); + + expect(screen.getByText("rejected")).toBeInTheDocument(); + }); + + it("does not render the justification textarea", () => { + render( + + ); + + expect(screen.queryByLabelText("Decision justification")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.tsx b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.tsx new file mode 100644 index 000000000..562b42c21 --- /dev/null +++ b/extensions/A-ES/pledger/supa_doccs/frontend/src/components/review/DecisionControls.tsx @@ -0,0 +1,144 @@ +import { useState, useCallback } from "react"; +import type { QueueItem } from "@/types/review"; + +export interface DecisionControlsProps { + item: QueueItem; + onDecide: (decision: "approved" | "rejected", justification: string) => void; + isSubmitting: boolean; +} + +function Spinner() { + return ( + + ); +} + +/** + * Renders Approve/Reject buttons with a justification textarea. + * Buttons are only visible when item status is "pending". + * Submission is blocked when justification is empty or whitespace-only. + * Includes a sweep animation on decision before advancing. + */ +export function DecisionControls({ + item, + onDecide, + isSubmitting, +}: DecisionControlsProps) { + const [justification, setJustification] = useState(""); + const [sweepColor, setSweepColor] = useState<"emerald" | "rose" | null>(null); + + const isPending = item.status === "pending"; + const trimmed = justification.trim(); + const canSubmit = trimmed.length > 0 && !isSubmitting; + + const handleDecide = useCallback( + (decision: "approved" | "rejected") => { + if (!canSubmit) return; + // Trigger sweep animation + setSweepColor(decision === "approved" ? "emerald" : "rose"); + // After animation completes, submit + setTimeout(() => { + onDecide(decision, trimmed); + setJustification(""); + setSweepColor(null); + }, 400); + }, + [canSubmit, onDecide, trimmed] + ); + + if (!isPending) { + const isRecheck = item.status === "approved_needs_recheck"; + return ( +
+

+ Decision:{" "} + + {isRecheck ? "approved — needs recheck" : (item.decision ?? item.status)} + +

+
+ ); + } + + return ( +
+ {/* Sweep overlay animation */} + {sweepColor && ( +