diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0a273e3b..9c7700f5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,26 +2,25 @@ # These owners will be requested for review when PRs touch their areas # # Format: path @username or @org/team -# Note: Replace placeholder usernames with actual GitHub usernames # Default owners for everything -* @your-username +* @w7-mgfcode # Core infrastructure -/app/core/ @your-username +/app/core/ @w7-mgfcode # Feature modules (add specific owners as team grows) -/app/features/ @your-username +/app/features/ @w7-mgfcode # CI/CD configuration -/.github/ @your-username +/.github/ @w7-mgfcode # Database migrations -/alembic/ @your-username +/alembic/ @w7-mgfcode # Documentation -/docs/ @your-username +/docs/ @w7-mgfcode # Configuration files -/pyproject.toml @your-username -/docker-compose.yml @your-username +/pyproject.toml @w7-mgfcode +/docker-compose.yml @w7-mgfcode diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a9d99da7..a4784d78 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Documentation - url: https://github.com/your-org/ForecastLabAI/tree/main/docs + url: https://github.com/w7-mgfcode/ForecastLabAI/tree/main/docs about: Check the documentation before opening an issue diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bd722f42..4a0b09b6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,12 +4,15 @@ ## Type of Change -- [ ] Bug fix (non-breaking change that fixes an issue) -- [ ] New feature (non-breaking change that adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to change) -- [ ] Refactoring (no functional changes) -- [ ] Documentation update -- [ ] CI/CD changes + + +- [ ] `feat` — new feature +- [ ] `fix` — bug fix +- [ ] `feat!` / `fix!` — breaking change (or `BREAKING CHANGE:` in the body) +- [ ] `refactor` — code restructure, no behavior change +- [ ] `docs` — documentation only +- [ ] `test` — test-only change +- [ ] `chore` / `ci` — tooling, CI, or repo hygiene (no version bump) ## Checklist diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml new file mode 100644 index 00000000..8a8189e7 --- /dev/null +++ b/.github/workflows/e2e-nightly.yml @@ -0,0 +1,131 @@ +name: E2E Demo (nightly) + +# Nightly run of `scripts/run_demo.py` against a fresh Postgres+pgvector +# service to catch regressions in the documented end-to-end pipeline +# (seed -> features -> train -> backtest -> register -> verify). +# +# Per PRP-15, this workflow is intentionally NOT a required status check +# on `dev` or `main` -- it is informational only. Flake-budget lives here, +# not in the per-PR `ci.yml` gate. +# +# Trigger options: +# * Daily schedule at 07:00 UTC (cron `0 7 * * *`) +# * On-demand via `workflow_dispatch` (with optional ref override) + +on: + schedule: + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + ref: + description: 'Branch or ref to run the nightly demo on (default: github.ref)' + required: false + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ inputs.ref || github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.12" + UV_VERSION: "0.5" + CHECKOUT_REF: ${{ inputs.ref || github.ref }} + # API the script will hit. Bound to localhost because the runner ports + # this nightly job. Mirrors scripts/run_demo.py default. + DEMO_API_URL: "http://127.0.0.1:8123" + +jobs: + e2e-demo: + name: Run end-to-end demo + runs-on: ubuntu-latest + + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: forecastlab + POSTGRES_PASSWORD: forecastlab + POSTGRES_DB: forecastlab_e2e + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + env: + DATABASE_URL: postgresql+asyncpg://forecastlab:forecastlab@localhost:5432/forecastlab_e2e + APP_ENV: testing + # The agent step in run_demo.py auto-skips when neither key is set; + # nightly CI runs without LLM keys so the step short-circuits with + # `⏭️ [SKIP]`. Do NOT add OPENAI_API_KEY / ANTHROPIC_API_KEY here. + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + + - name: Set up Python + run: uv python install ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: uv sync --frozen --all-extras --dev + + - name: Apply migrations + run: uv run --frozen alembic upgrade head + + - name: Start uvicorn in background + # We bind to 127.0.0.1:8123 (the script's default) and write logs + # to a file so the artifact upload below can capture them on + # failure for forensics. + run: | + mkdir -p .ci-logs + nohup uv run --frozen uvicorn app.main:app \ + --host 127.0.0.1 --port 8123 --log-level warning \ + > .ci-logs/uvicorn.log 2>&1 & + echo $! > .ci-logs/uvicorn.pid + + - name: Wait for uvicorn /health + run: | + for i in $(seq 1 30); do + if curl -fsS "${DEMO_API_URL}/health" > /dev/null; then + echo "uvicorn ready after ${i}s" + exit 0 + fi + sleep 1 + done + echo "uvicorn did not become healthy within 30s" + cat .ci-logs/uvicorn.log || true + exit 1 + + - name: Run demo pipeline + run: | + uv run --frozen python scripts/run_demo.py \ + --seed 42 \ + --api-url "${DEMO_API_URL}" \ + --timeout 60 + + - name: Stop uvicorn + if: always() + run: | + if [ -f .ci-logs/uvicorn.pid ]; then + kill "$(cat .ci-logs/uvicorn.pid)" || true + fi + + - name: Upload uvicorn logs on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: uvicorn-logs + path: .ci-logs/ + retention-days: 7 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e7cf9b33..033e2d44 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.2.8" + ".": "0.2.10" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e49f17e0..a3092f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [0.2.10](https://github.com/w7-mgfcode/ForecastLabAI/compare/v0.2.9...v0.2.10) (2026-05-18) + + +### Features + +* release v0.2.10 — demo showcase page + e2e pipeline ([#134](https://github.com/w7-mgfcode/ForecastLabAI/issues/134)) ([2ea68ae](https://github.com/w7-mgfcode/ForecastLabAI/commit/2ea68ae1b875e48013fe7f09e9850ef648e0f616)) + +## [0.2.9](https://github.com/w7-mgfcode/ForecastLabAI/compare/v0.2.8...v0.2.9) (2026-05-14) + + +### Features + +* **api,docs:** codify pydantic strict-mode policy as pytest invariant ([#120](https://github.com/w7-mgfcode/ForecastLabAI/issues/120)) ([#121](https://github.com/w7-mgfcode/ForecastLabAI/issues/121)) ([89b197d](https://github.com/w7-mgfcode/ForecastLabAI/commit/89b197dcac4176d1b9585886a3e5b9e4de30ce3f)) +* **features,docs:** land phase 2 e2e integration and docs ([#109](https://github.com/w7-mgfcode/ForecastLabAI/issues/109)) ([#115](https://github.com/w7-mgfcode/ForecastLabAI/issues/115)) ([56de87c](https://github.com/w7-mgfcode/ForecastLabAI/commit/56de87c26dc5e0f1e1e7262cf4d09545b42643d7)) +* **features:** implement lifecycle compute method ([#109](https://github.com/w7-mgfcode/ForecastLabAI/issues/109)) ([#111](https://github.com/w7-mgfcode/ForecastLabAI/issues/111)) ([902d82a](https://github.com/w7-mgfcode/ForecastLabAI/commit/902d82acb4ccfb0c141f815ea42b427735c99223)) +* **features:** implement promotion compute method ([#109](https://github.com/w7-mgfcode/ForecastLabAI/issues/109)) ([#112](https://github.com/w7-mgfcode/ForecastLabAI/issues/112)) ([52f6497](https://github.com/w7-mgfcode/ForecastLabAI/commit/52f6497ea585a5df06d287896cbb52bcceada1e4)) +* **features:** implement replenishment compute method ([#109](https://github.com/w7-mgfcode/ForecastLabAI/issues/109)) ([#114](https://github.com/w7-mgfcode/ForecastLabAI/issues/114)) ([b39c940](https://github.com/w7-mgfcode/ForecastLabAI/commit/b39c940d801296a4a3316d1544aa1cb2e1a65ca9)) +* **features:** lower ReplenishmentConfig count_window_days floor to 3 ([#113](https://github.com/w7-mgfcode/ForecastLabAI/issues/113)) ([#122](https://github.com/w7-mgfcode/ForecastLabAI/issues/122)) ([42c9fb8](https://github.com/w7-mgfcode/ForecastLabAI/commit/42c9fb86004e93c4a52461a08281dc0d00122698)) +* **features:** plumb product attrs through FeatureDataLoader ([#116](https://github.com/w7-mgfcode/ForecastLabAI/issues/116)) ([#118](https://github.com/w7-mgfcode/ForecastLabAI/issues/118)) ([0e41fb3](https://github.com/w7-mgfcode/ForecastLabAI/commit/0e41fb3a41482009038a61cb0c0046d20730ed0d)) +* **features:** pydantic configs + PRP set for phase 2 feature wiring ([#109](https://github.com/w7-mgfcode/ForecastLabAI/issues/109)) ([#110](https://github.com/w7-mgfcode/ForecastLabAI/issues/110)) ([f8978c5](https://github.com/w7-mgfcode/ForecastLabAI/commit/f8978c5046189775077ee2840d6c3afd4b985988)) +* **release:** cut v0.2.9 with phase 2 e2e + strict-mode + main protection ([#108](https://github.com/w7-mgfcode/ForecastLabAI/issues/108)) ([e71ae39](https://github.com/w7-mgfcode/ForecastLabAI/commit/e71ae395a64b750e5cdca6a2ce9ba27deb909fad)) + + +### Bug Fixes + +* **api,agents:** bypass .env in 4 Settings tests ([#104](https://github.com/w7-mgfcode/ForecastLabAI/issues/104)) ([#105](https://github.com/w7-mgfcode/ForecastLabAI/issues/105)) ([dd3a9a7](https://github.com/w7-mgfcode/ForecastLabAI/commit/dd3a9a75c442ede9591e2cf6886cc666cbc7c1b1)) +* **forecast,features:** apply strict-mode JSON date policy to request bodies ([#117](https://github.com/w7-mgfcode/ForecastLabAI/issues/117)) ([#119](https://github.com/w7-mgfcode/ForecastLabAI/issues/119)) ([ba7c1c1](https://github.com/w7-mgfcode/ForecastLabAI/commit/ba7c1c1edf2da7eef6b0a67d77ab6868c7ab32e2)) + + +### Documentation + +* **docs:** document release-please merge-subject trap ([#102](https://github.com/w7-mgfcode/ForecastLabAI/issues/102)) ([#103](https://github.com/w7-mgfcode/ForecastLabAI/issues/103)) ([9990e57](https://github.com/w7-mgfcode/ForecastLabAI/commit/9990e57a632e7b8a80db752638fc7029147a8363)) +* **docs:** verify and remove [unverified] tags in docs/_base ([#106](https://github.com/w7-mgfcode/ForecastLabAI/issues/106)) ([#107](https://github.com/w7-mgfcode/ForecastLabAI/issues/107)) ([7774fbb](https://github.com/w7-mgfcode/ForecastLabAI/commit/7774fbb31c5a0a2539868648264a0ad5196e186b)) + ## [0.2.8](https://github.com/w7-mgfcode/ForecastLabAI/compare/v0.2.7...v0.2.8) (2026-05-12) diff --git a/CLAUDE.md b/CLAUDE.md index 032b3bb6..6dad78e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,4 +113,4 @@ wc -l CLAUDE.md # must stay ≤ 150 ## Learnings -- HEURISTIC_MODE generated this doc (no `docs/_kB/repo-map/` KB). Run `mapping-repo-context` to upgrade fidelity; sections marked `[UNVERIFIED]` in `docs/_base/` need verification. +- HEURISTIC_MODE generated this doc (no `docs/_kB/repo-map/` KB). Run `mapping-repo-context` to upgrade fidelity; sections marked `[ASSUMPTION]` in `docs/_base/` need verification. diff --git a/INITIAL-14.md b/INITIAL-14.md new file mode 100644 index 00000000..6fe7b6da --- /dev/null +++ b/INITIAL-14.md @@ -0,0 +1,245 @@ +# INITIAL-14 — End-to-End Demo Pipeline + Showcase Script PRD + +**Author:** Gabor Szabo (drafted via `/do:prd` session, 2026-05-14) +**Status:** Draft +**Date:** 2026-05-14 +**Predecessor:** v0.2.9 (Phase-2 seeder + features complete, queue empty) +**Successor:** `PRPs/PRP-15-e2e-demo-pipeline.md` (to be authored) + +--- + +## Problem Statement + +ForecastLabAI is a portfolio-grade, single-host retail demand forecasting demo (`.claude/rules/product-vision.md`). Its value to a reviewer depends on **one-command demonstrability**. Today that gate is broken: + +- `examples/e2e_smoke.sh` is **53 lines** and asserts only `/health` and `X-Request-ID` propagation. It does **not** exercise the pipeline. +- A reviewer (or returning maintainer) must hand-compose ~8 sequential HTTP calls across the 12 routers wired in `app/main.py` to see the system work end-to-end. +- Phase-2 seeder + featureset work shipped over PRs #111/#112/#114/#115/#127 (v0.2.9) — but `grep -rn "lifecycle\|replenishment\|promotion\|days_since_launch" app/features/forecasting/ app/features/backtesting/` returns zero hits, so the new columns currently have no visible exit channel. The investment looks invisible from outside. +- The open-issue queue is empty (`gh issue list --state open` → `[]`), so there's no external pull on the next thing to ship — this is a clean inflection-point moment. + +**Who is affected:** the maintainer (portfolio reviewers, future contributors), and any operator returning to the repo after a multi-week absence. **Pain if unsolved:** the repo's perceived completeness lags its actual completeness; future seeder/feature work compounds the problem. + +--- + +## Goals + +- **Primary:** A single command, `make demo`, drives the full pipeline against a freshly-seeded dataset and returns exit code 0 with a green verdict in **≤ 180 s wall-clock** on the developer's laptop (Postgres + uvicorn already running). +- **Secondary:** + - Establish a canonical scripted reference for `seed → ingest → features → forecast → backtest → registry → alias → agent-query` ordering, suitable for embedding in `README.md`. + - Surface integration failures (CORS, env-bleed, missing API keys, schema drift) in a single output stream, so the maintainer doesn't have to recreate them from memory. + - Provide the foundation that a future "Run demo" dashboard button (out of scope here) can call into. +- **Non-goals:** + - Phase-2-aware LightGBM training (separate PRP — depends on this slice). + - Dashboard UI changes ("Run demo" button is a follow-up). + - Performance benchmarking (correctness-only). + - Replacing or extending the seeder/scenario surface. + - CI nightly integration (deferred — promote once 2 weeks flake-free locally). + - Exercising the `rag_assistant` agent (requires pre-indexed corpus; deferred to v2). + +--- + +## Proposed Solution + +A self-contained Python script, `scripts/run_demo.py`, invoked via `make demo`, that drives the existing FastAPI surface from outside the process using `httpx.AsyncClient`. The script is intentionally **not** a feature of `app/` — it sits at the same level as `scripts/seed_random.py` and `scripts/check_db.py`, treats the API as a black box, and composes published endpoints from `docs/_base/API_CONTRACTS.md`. + +**Key design decisions:** + +| Decision | Rationale | +|----------|-----------| +| Drive via HTTP (not in-process calls) | Validates the *deployed* contract; matches what a reviewer sees. Also catches CORS / middleware regressions. | +| Naive + seasonal_naive + moving_average baselines only | Already implemented (`app/features/forecasting/service.py`); avoids Phase-2-column dependency that needs its own PRP. LightGBM is a follow-up. | +| Deterministic seed (`--seed 42`) | Reproducible across runs; satisfies acceptance criterion #4. | +| `expanding` backtest split, 3 folds, h=14 days | Tight enough to stay inside the 180-s budget on a laptop. | +| Output via `.claude/rules/output-formatting.md` glyphs (✅/❌/🔄) | Matches house style; visually scannable; capped at 40 lines. | +| `--quiet` flag emits one line per step | CI / log capture friendly. | +| Single-file script, no new Python package | Mirrors `scripts/check_db.py` shape; no test-import overhead. | +| `make demo-quick` skips the seed step | Iteration ergonomics when DB state is fresh. | + +**Alternatives considered and rejected:** + +- *In-process driver invoking router functions directly.* Rejected: bypasses middleware/CORS, defeats the demo-trust purpose. +- *Promote the demo to a new `app/features/demo/` slice with its own router.* Rejected: violates the vertical-slice rule (would import across slices) and adds API surface for a one-shot operator action. +- *Bash-only script with curl/jq.* Rejected: error handling and JSON path extraction across 8+ steps become brittle; the existing `examples/e2e_smoke.sh` shape doesn't scale to this length. + +--- + +## User Experience + +### CLI Changes + +**New top-level `Makefile` (does not exist today):** + +```makefile +.PHONY: demo demo-quick demo-clean + +demo: ## full e2e: seed → ingest → features → train → backtest → register → alias → agent-query + uv run python scripts/run_demo.py --seed 42 + +demo-quick: ## same flow but skips the seeder reset; assumes data is fresh + uv run python scripts/run_demo.py --seed 42 --skip-seed + +demo-clean: ## destructive: wipe DB then run demo + uv run python scripts/run_demo.py --seed 42 --reset +``` + +**New CLI: `scripts/run_demo.py`** + +``` +usage: run_demo.py [-h] [--seed INT] [--skip-seed] [--reset] [--quiet] + [--api-url URL] [--timeout SECS] + +options: + --seed INT Deterministic seed for the seeder (default: 42) + --skip-seed Skip the seeder scenario step (assumes data already present) + --reset Run the seeder's --delete --full-new path before seeding (destructive) + --quiet One-line-per-step output (default: verbose with progress) + --api-url URL Override the default http://localhost:8123 + --timeout SECS Per-step HTTP timeout (default: 30) +``` + +**Exit codes:** `0` on success, `1` on any step failure, `2` on precondition failure (API unreachable, DB down). + +### API Changes + +**None.** The script consumes existing endpoints documented in `docs/_base/API_CONTRACTS.md`: + +| Step | Endpoint | Purpose | +|------|----------|---------| +| 1 | `GET /health` | Precondition check | +| 2 | `POST /seeder/...` (route per `app/features/seeder/routes.py:85`) | Trigger `retail_standard` scenario with `--seed 42` | +| 3 | `GET /seeder/...status` | Poll until complete | +| 4 | `POST /featuresets/compute` | Compute lag + rolling + calendar features | +| 5 | `POST /jobs` × 3 | Submit `train` jobs for naive / seasonal_naive / moving_average | +| 6 | `GET /jobs/{id}` | Poll each until `status=success`, capture `run_id` | +| 7 | `POST /backtesting/run` | 3-fold expanding split, horizon 14 | +| 8 | `POST /registry/aliases` | Alias winning run as `demo-production` | +| 9 | `GET /registry/runs/{id}/verify` | SHA-256 artifact integrity check | +| 10 | `POST /agents/sessions` | Open `experiment` agent session | +| 11 | `POST /agents/sessions/{id}/chat` | Ask one canned question; assert success | +| 12 | `DELETE /agents/sessions/{id}` | Clean up | + +### Configuration + +**No new env vars required.** Script reads `OPENAI_API_KEY` (or `ANTHROPIC_API_KEY`) from the same `.env` the backend reads. If neither is present, **steps 10-12 are skipped with a `⏭️ [SKIP]` status** and the run still exits 0 — this keeps `make demo` viable for contributors who haven't wired up an LLM key yet. + +### Migration Path for Existing Users + +- `examples/e2e_smoke.sh` is **preserved** (it covers the `X-Request-ID` propagation contract). The new script extends rather than replaces. +- `README.md` Quick-start section gains one line under "Try it": `make demo`. +- `docs/DAILY-FLOW.md` cross-links to the new target under the "first-run" callout. +- No breaking changes to any existing CLI / API surface. + +--- + +## Technical Design + +### Architecture + +``` +┌────────────────────────────────────────────────────────────────┐ +│ scripts/run_demo.py (httpx.AsyncClient — single process) │ +│ │ +│ 1. precheck → /health │ +│ 2. (optional) reset → /seeder/reset │ +│ 3. seed → /seeder/ │ +│ 4. wait → poll /seeder/status │ +│ 5. features → /featuresets/compute │ +│ 6. train → 3× /jobs (parallel via asyncio.gather) │ +│ 7. wait → poll /jobs/{id} until success │ +│ 8. backtest → /backtesting/run │ +│ 9. register → /registry/aliases (winner by lowest WAPE) │ +│ 10. verify → /registry/runs/{id}/verify │ +│ 11. agent → /agents/sessions + /chat (if LLM key set) │ +│ 12. cleanup → /agents/sessions/{id} DELETE │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Components:** + +- **`DemoStep` dataclass** — name, async-callable, retry policy, skip predicate. Steps run sequentially; train jobs (step 6) submit in parallel. +- **`DemoContext` dataclass** — accumulates `run_ids`, `featureset_id`, `session_id` across steps for downstream reference. +- **`Reporter` class** — renders `.claude/rules/output-formatting.md` glyphs; supports `--quiet` mode. +- **`HttpClient` thin wrapper** — wraps `httpx.AsyncClient`, surfaces RFC 7807 error bodies in failures, retries idempotent GETs. + +### Data Flow + +Stateless on the script side. All state lives in Postgres after step 4 and in the `DemoContext` for cross-step references (e.g., `run_id` → backtest input → alias target). The script never writes to disk except for an optional `--log-file` output. + +### Dependencies + +- `httpx` — already in `pyproject.toml` (used by ingest / agent layers). +- `pydantic` — already pinned; used for typed response models. +- No new third-party deps. + +### Updates to Project Design Documents + +| Doc | Change | +|-----|--------| +| `README.md` | Quick-start adds `make demo` line + sample output block. | +| `docs/DAILY-FLOW.md` | "First-run" section cross-links to `make demo`. | +| `docs/_base/API_CONTRACTS.md` | No change (consumer-only). | +| `docs/_base/REPO_MAP_INDEX.md` | Add `scripts/run_demo.py` and `Makefile` rows. | +| `docs/_base/RUNBOOKS.md` | New "Demo run failed" entry under Common Incidents (precondition checks, common failure modes). | + +### Test Strategy + +- **Unit (`tests/test_run_demo_unit.py`):** isolated tests of `DemoStep` ordering, `Reporter` formatting, `DemoContext` reference chaining. Mock the HTTP client. +- **Integration (`tests/test_e2e_demo.py`, marked `@pytest.mark.integration`):** invokes `scripts/run_demo.py` as a subprocess against the live `docker-compose` stack; asserts exit 0, wall-clock ≤ 180 s, and that step 11 (agent chat) either succeeds or is correctly skipped when no LLM key is present. +- **No mocks of Postgres** (per `.claude/rules/test-requirements.md` — integration tests hit the real DB). + +--- + +## Success Metrics + +**Quantitative:** + +1. `make demo` exits 0 on a clean `docker-compose up -d && uv run alembic upgrade head` host. +2. Wall-clock ≤ 180 s on the developer's reference laptop (measured by the script and logged as the final summary line). +3. Resulting `model_run` row has `status=success`, non-empty JSONB `metrics`, and a valid SHA-256 artifact-verify response. +4. Backtest output reports 3 folds with valid MAE / sMAPE / WAPE per fold (no NaNs). +5. Integration test `tests/test_e2e_demo.py` passes locally and on CI when promoted (post-shakedown). + +**Qualitative:** + +- A reviewer who has never seen the repo can reach a green verdict via `git clone && docker compose up -d && uv run alembic upgrade head && uv run uvicorn app.main:app & make demo` within their first 5 minutes. +- The output stream surfaces *which* step failed and *why* (RFC 7807 body echoed) without requiring `uvicorn` log spelunking. + +**Verification criteria (acceptance):** + +1. ✅ `make demo` returns exit 0 against a freshly-seeded DB. +2. ✅ Final output line summarizes: `runs=3 winner= alias=demo-production wall_clock=s`. +3. ✅ The winning `run_id` is reachable via `GET /registry/aliases/demo-production`. +4. ✅ One `/agents/sessions/.../chat` round trip succeeds (or is skipped with `⏭️` if no key). +5. ✅ `tests/test_e2e_demo.py` integration case asserts the same and passes under `pytest -m integration`. + +--- + +## Risks + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| `retail_standard` scenario seeds too much data to finish in 180 s | Med | High | Pin the smallest viable scenario; if `retail_standard` is too heavy, author a `demo_minimal` preset in `app/shared/seeder/` (single-line addition) — flag as Open Question #1. | +| Agent step needs an LLM key the contributor lacks | High | Low | Skip step 11-12 with `⏭️ [SKIP]` when neither `OPENAI_API_KEY` nor `ANTHROPIC_API_KEY` is set; exit 0 still. | +| Backtest WAPE NaN on the seeded dataset | Low | Med | Use a known-good `(start_date, end_date)` window from the seeder's deterministic output; assert non-NaN in the script before alias creation. | +| Phase-2 column drift breaks `compute` step | Low | Med | Script consumes only the public `/featuresets/compute` schema (Pydantic v2 — already validated); failures surface as RFC 7807 bodies. | +| Wall-clock exceeds 180 s on slower hardware (laptops without SSD) | Med | Low | Document the laptop reference baseline in the PRD; allow `--timeout` override; soft-warn (not fail) above 180 s. | +| `make demo` becomes a maintenance burden as the API evolves | Low | Med | Pin to the public schemas (Pydantic); any breakage is caught by the integration test in CI when promoted. | +| Confusion between `scripts/run_demo.py` and `scripts/seed_random.py` | Low | Low | Top-of-file docstring + README quick-start explicitly contrast them. | + +--- + +## Open Questions + +- [ ] **Q1: Which seeder scenario?** Use the existing `retail_standard` preset, or author a leaner `demo_minimal` (e.g., 3 stores × 10 products × 60 days) to keep wall-clock comfortable on slower hardware? +- [ ] **Q2: Should `make demo` invoke `docker compose up -d` itself**, or assert the preconditions and bail with exit 2 if Postgres / uvicorn aren't running? (Default proposal: assert + bail — keeps the script honest about being a *consumer* of the stack, not its lifecycle manager.) +- [ ] **Q3: Promote to CI once stable, or keep local-only?** Proposal: stay local for the first two weeks; if no flakes, promote to a nightly `.github/workflows/e2e-nightly.yml` (no PR-blocking). + +--- + +## Cross-Reference + +- **Predecessor session output:** brainstorm in this session (Phase 0-5) — winner C1, runner-up C2 (Phase-2-aware LightGBM, queued as direct successor). +- **Vision check:** Aligned with `.claude/rules/product-vision.md` § Core Principle 1 ("Portfolio-grade, end-to-end") and § Litmus Test #5 ("Does it work on a developer's laptop via `docker-compose up`?"). +- **Test policy:** `.claude/rules/test-requirements.md` — integration test mandatory, real DB. +- **Output formatting:** `.claude/rules/output-formatting.md` — script output matches. +- **Successor PRP:** `PRPs/PRP-15-e2e-demo-pipeline.md` (to be authored from this INITIAL). diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..8721b04e --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +# ForecastLabAI — operator-friendly entry points. +# +# This Makefile is a thin wrapper around the existing CLI / docker-compose +# tooling. It exists so a reviewer can run the full end-to-end demo with +# one command: `make demo`. The heavy lifting happens in +# `scripts/run_demo.py` (PRP-15); the rules here just orchestrate the +# prerequisites. +# +# Conventions: +# * Tab indentation on recipe lines (`make` requires it). +# * Every target is `.PHONY` (no real file outputs). +# * `uv run` prefixes every Python invocation (CLAUDE.md "Commands"). +# +# Quick reference: +# make demo — full e2e: docker compose + migrations + run_demo +# make demo-quick — re-run run_demo without re-seeding (fast iteration) +# make demo-clean — destructive: wipe DB first, then run demo +# make help — list available targets + +.DEFAULT_GOAL := help +.PHONY: help demo demo-quick demo-clean + +help: ## show this help and exit + @echo "ForecastLabAI Make targets:" + @echo " make demo run the full end-to-end demo (~90-180 s)" + @echo " make demo-quick re-run the demo without re-seeding" + @echo " make demo-clean wipe the DB, then run the full demo" + @echo "" + @echo "Preconditions for all targets:" + @echo " * docker compose Postgres+pgvector must be reachable on :5433" + @echo " * uvicorn must already be serving on http://localhost:8123" + @echo " (start with: uv run uvicorn app.main:app --port 8123)" + +demo: ## full e2e — seed -> features -> train x3 -> backtest -> register -> agent + docker compose up -d + uv run alembic upgrade head + uv run python scripts/run_demo.py --seed 42 + +demo-quick: ## re-run the demo without re-seeding (fast iteration) + uv run python scripts/run_demo.py --seed 42 --skip-seed + +demo-clean: ## destructive — wipe DB then run the full demo + docker compose up -d + uv run alembic upgrade head + uv run python scripts/run_demo.py --seed 42 --reset diff --git a/PRPs/PRP-15-e2e-demo-pipeline.md b/PRPs/PRP-15-e2e-demo-pipeline.md new file mode 100644 index 00000000..1f241391 --- /dev/null +++ b/PRPs/PRP-15-e2e-demo-pipeline.md @@ -0,0 +1,845 @@ +name: "PRP-15 — End-to-End Demo Pipeline + Showcase Script" +description: | + Author a host-driven E2E demo (`make demo`) that exercises ForecastLabAI's published + API surface — seed → features → train × 3 → backtest → registry → alias → agent — + against a freshly-seeded `demo_minimal` scenario, in ≤ 180 s on a developer laptop. + Includes a leaner seeder preset, a top-level `Makefile`, RFC-7807-aware HTTP driver, + unit + integration tests, doc updates, and an opt-in nightly CI workflow. + +## Purpose +Close the demonstrability gap identified in `INITIAL-14.md` (Phase 0 synthesis of the +2026-05-14 brainstorm session): `examples/e2e_smoke.sh` is health-only, and the Phase-2 +seeder/featureset work (PRs #111/#112/#114/#115/#127) has no scripted exit channel. +After this PRP lands, one command runs the full pipeline and prints a green verdict. + +## Core Principles +1. **Context is King** — every endpoint shape, schema field, and validator decision is + linked from the real source files below. +2. **Black-box driver** — script consumes the deployed HTTP contract (`httpx`); no + in-process imports of `app/features/*` services. Validates the *deployed* behavior. +3. **Additive only** — no schema changes, no migrations, no breaking API edits. + One new scenario preset; one new script; one new Makefile; doc + CI updates. +4. **Vertical-slice rule respected** — script lives at `scripts/`, not under + `app/features/`; matches `scripts/seed_random.py` / `scripts/check_db.py` shape. +5. **Strict gates honored** — `ruff` + `mypy --strict` + `pyright --strict` + + `pytest` all green (per `CLAUDE.md` "Validation gates"). + +--- + +## Goal +A single command, `make demo`, drives `docker compose up -d` → `alembic upgrade head` +→ `scripts/run_demo.py`, which walks `seed → status → features → train × 3 → backtest +× 3 → register-winner → alias → verify → agent-roundtrip` against the API on +`http://localhost:8123` and exits **0 with a green verdict in ≤ 180 s** on the +reference dev laptop. A nightly GitHub Actions workflow runs the same path against a +docker-compose Postgres service. + +## Why +- Portfolio reviewers (and the maintainer after a multi-week absence) cannot demo the + system today without hand-composing ~12 sequential curl calls across 12 routers + (`app/main.py:114-126`). +- v0.2.9 just landed Phase-2 features (lifecycle / replenishment / promotion compute + methods) but `grep -rn "lifecycle|replenishment|promotion|days_since_launch" + app/features/forecasting/ app/features/backtesting/` returns 0 hits — the recent + multi-week investment is invisible end-to-end. +- The open-issue queue is empty (`gh issue list --state open` → `[]`), so this is the + clean inflection point to invest in the demo loop before the next capability slice + (Phase-2-aware LightGBM, queued as PRP-16). + +## What +A new top-level `Makefile` exposing three targets (`demo`, `demo-quick`, `demo-clean`) +that delegate to a new `scripts/run_demo.py`. The script is a single-file, async, +type-checked Python module that walks the published API, computes the winning model +locally by lowest WAPE, registers it via the public `/registry/runs` two-step flow, +opens a one-turn `experiment` agent conversation (or skips with `⏭️` if no LLM key is +set), and reports per-step status using the `.claude/rules/output-formatting.md` +emoji-status convention. + +### Success Criteria +- [ ] `make demo` exits 0 on a clean checkout + `docker compose up -d`. +- [ ] Wall-clock ≤ 180 s on the reference laptop; soft-warn (no fail) if exceeded. +- [ ] Final output line: `runs=3 winner= alias=demo-production wall_clock=s`. +- [ ] `GET /registry/aliases/demo-production` returns the winning `run_id`. +- [ ] `GET /registry/runs/{winning_run_id}/verify` returns `verified=true`. +- [ ] The agent step either round-trips a chat call successfully or is skipped with + `⏭️ [SKIP]` when neither `OPENAI_API_KEY` nor `ANTHROPIC_API_KEY` is set. +- [ ] `tests/test_run_demo_unit.py` and `tests/test_e2e_demo.py` (marked + `@pytest.mark.integration`) both pass. +- [ ] `ruff check`, `ruff format --check`, `mypy --strict app/`, `pyright app/` clean. +- [ ] A new `.github/workflows/e2e-nightly.yml` runs the same path on a daily cron and + `workflow_dispatch`; **not** a PR-blocking check. + +--- + +## All Needed Context + +### Documentation & References +```yaml +- url: https://www.python-httpx.org/async/ + why: AsyncClient lifecycle, timeout/retry, raise_for_status() patterns + critical: | + Always use `async with httpx.AsyncClient(...) as client:` — otherwise connections + leak. Pass `timeout=httpx.Timeout(30.0, connect=5.0)` per call; do NOT rely on + the default 5s, the seeder step can take ~30-60 s. + +- url: https://www.python-httpx.org/api/#response + why: Response.raise_for_status() and parsing `application/problem+json` bodies + critical: | + On non-2xx the body is RFC 7807 JSON per `app/core/problem_details.py`. Surface + `type`, `title`, `detail`, `request_id` in error output — don't just echo r.text. + +- url: https://docs.pydantic.dev/latest/concepts/models/#validating-data + why: model_validate() for parsing API responses into typed models + critical: | + Use `Model.model_validate(r.json())` — this matches FastAPI's strict-mode policy + (see SECURITY.md "Pydantic v2 strict mode" — issues #109, #117, #120). + +- url: https://www.gnu.org/software/make/manual/html_node/Phony-Targets.html + why: .PHONY declarations to avoid file-name conflicts with target names + +- url: https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html + why: subprocess.run() + capture for integration test that exec's the script + critical: | + Use `subprocess.run([...], capture_output=True, text=True, timeout=240)` — + NOT `subprocess.check_output`; we need to inspect exit code and stdout/stderr + independently on failure. + +- url: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule + why: cron schedule syntax for nightly workflow (UTC) + +- file: scripts/check_db.py + why: Shape for the new scripts/run_demo.py — argparse + asyncio.run + clean exit-code mapping + critical: | + Mirrors `sys.exit(asyncio.run(main()))` pattern. Top-of-file docstring with Usage: + block (matches `seed_random.py` style). + +- file: scripts/seed_random.py + why: Reference for argparse with date types, scenario picker, dry-run flag + critical: lines 1-50 — Usage docstring + parse_date helper + Settings injection. + +- file: examples/e2e_smoke.sh + why: Original smoke test — keep this file; the new script is additive, not a replacement + critical: 53 lines, /health + X-Request-ID only. Don't delete. + +- file: tests/conftest.py + why: Pattern for `client` fixture (ASGITransport) + `db_session` fixture (async engine) + critical: | + For unit tests of run_demo.py we will MOCK the HTTP client; we do NOT use this + ASGI fixture. For the integration test we exec the script as a subprocess against + the real uvicorn started by the CI workflow (or developer's terminal locally). + +- file: app/features/seeder/routes.py + why: Endpoints + request schemas the demo will call + critical: | + - POST /seeder/generate (synchronous, line 85; may take several minutes for full scenarios) + - GET /seeder/status (line 36 — used to confirm presence + grab date range) + - GET /seeder/scenarios (line 53) + - DELETE /seeder/data (line 193 — scope=all for --reset) + - POST /seeder/verify (line 312) + NO polling needed — generate is synchronous. + +- file: app/features/seeder/schemas.py + why: GenerateParams (scenario, seed, stores, products, start_date, end_date, ...) + GenerateResult + critical: | + `scenario` is a free string; service.py:47 maps to `ScenarioPreset(name)`. + To add `demo_minimal`, must add to `ScenarioPreset` enum (config.py:11) AND to + `from_scenario()` (config.py:495) AND to `list_scenarios()` (service.py:312) + AND to `app/shared/seeder/tests/test_config.py`. + +- file: app/shared/seeder/config.py + why: ScenarioPreset enum + SeederConfig.from_scenario branches + critical: lines 11-19 (enum), 494-608 (branches). New scenario goes here. + +- file: app/features/featuresets/routes.py + why: POST /featuresets/compute (synchronous) — request = ComputeFeaturesRequest + critical: Single-series compute (one store_id+product_id) — demo runs it for ONE + pair just to demonstrate; the baseline models below don't need feature columns. + +- file: app/features/featuresets/schemas.py + why: ComputeFeaturesRequest + nested FeatureSetConfig (LagConfig, RollingConfig) + critical: | + `cutoff_date` is a `date` Field(strict=False, ...) — accepts ISO strings from + JSON. (Strict-mode policy — see SECURITY.md and tests/test_strict_mode_policy.py.) + +- file: app/features/forecasting/routes.py + why: POST /forecasting/train (synchronous) returns TrainResponse{model_path, config_hash, n_observations, ...} + critical: | + Train is per-(store_id, product_id, model_type). The demo trains 3 model types on + ONE series in parallel via `asyncio.gather`. LightGBM is feature-flagged off + (line 68) — do NOT use it; baselines are sufficient. + +- file: app/features/forecasting/schemas.py + why: ModelConfig union (NaiveModelConfig | SeasonalNaiveModelConfig | MovingAverageModelConfig | LightGBMModelConfig) + critical: | + For demo: NaiveModelConfig(), SeasonalNaiveModelConfig(season_length=7), + MovingAverageModelConfig(window_size=7). All have model_type Literal. + +- file: app/features/backtesting/routes.py + why: POST /backtesting/run (synchronous) returns BacktestResponse with fold metrics + critical: | + `include_baselines=true` automatically benchmarks naive + seasonal_naive — but + we want explicit cross-model comparison, so the demo calls /backtesting/run ONCE + PER MODEL_TYPE (3 calls, sequentially) and picks winner by aggregated WAPE. + +- file: app/features/backtesting/schemas.py + why: BacktestRequest(store_id, product_id, start_date, end_date, config=BacktestConfig) + critical: | + SplitConfig defaults: strategy='expanding', n_splits=5, min_train_size=30, gap=0, + horizon=14. For the demo we override n_splits=3 to stay under the 180-s budget. + +- file: app/features/registry/routes.py + why: Two-step registration: POST /registry/runs (PENDING) → PATCH /registry/runs/{id} + critical: | + - POST /registry/runs creates with status=pending + - PATCH /registry/runs/{id} transitions pending → running → success + - Aliases can ONLY point to success runs (line 404) + - Required PATCH fields for the demo: status=success, metrics={...}, artifact_uri, + artifact_hash, artifact_size_bytes. + +- file: app/features/registry/schemas.py + why: RunCreate (model_config_data ALIAS 'model_config'), RunUpdate, AliasCreate + critical: | + RunCreate uses `Field(..., alias="model_config")` — when calling, populate the + JSON field `model_config` (not `model_config_data`). populate_by_name=True so + either works on the in-Python side; on the wire JSON key must be `model_config`. + Valid transitions: pending → running → success (schemas.py:32). MUST take the + intermediate `running` step; pending → success is invalid. + +- file: app/features/registry/storage.py + why: LocalFSProvider.compute_hash() pattern — the demo script reuses hashlib.sha256 + critical: | + Artifact files live on the local FS at the path returned by /forecasting/train. + Single-host system — the script CAN open(model_path, 'rb').read() and compute + sha256 itself. This is the official way to populate `artifact_hash` for PATCH. + +- file: app/features/agents/routes.py + why: POST /agents/sessions, POST /agents/sessions/{id}/chat, DELETE /agents/sessions/{id} + critical: | + SessionCreateRequest(agent_type='experiment'|'rag_assistant', initial_context). + For demo: agent_type='experiment'. ChatRequest requires `message` (min_length=1). + 410 Gone on expired session — handle separately from 404. + +- file: app/core/config.py + why: Settings + get_settings() — script reads OPENAI_API_KEY/ANTHROPIC_API_KEY presence + critical: | + Use `settings = get_settings()` and check `bool(settings.openai_api_key)` / + `bool(settings.anthropic_api_key)`. Per security-patterns.md, NEVER log the value; + log only the boolean presence. + +- file: app/core/problem_details.py + why: Error response shape (RFC 7807) — the HttpClient wrapper parses these + critical: Fields: type, title, status, detail, instance, request_id, errors + +- file: .claude/rules/output-formatting.md + why: Emoji glyphs + section headers + summary block + critical: | + ✅/❌/⚠️/⏭️/🔄 prefixes; 40-line cap; "👉 Next steps:" footer when failure. + +- file: .claude/rules/security-patterns.md + why: No log of secret VALUES; only key NAMES. No subprocess(shell=True). Pydantic at boundaries. + +- file: .claude/rules/test-requirements.md + why: Mark integration tests `@pytest.mark.integration`; no DB mocks in integration. + +- file: .claude/rules/commit-format.md + why: Every commit needs `type(scope): description (#issue)` — open the tracking issue FIRST. +``` + +### Current Codebase tree (relevant) +```bash +. +├── Makefile # DOES NOT EXIST — create +├── scripts/ +│ ├── check_db.py # pattern to mirror +│ ├── seed_random.py # pattern to mirror (argparse + Settings) +│ └── run_demo.py # DOES NOT EXIST — create +├── examples/ +│ └── e2e_smoke.sh # keep (health-only smoke for X-Request-ID) +├── tests/ +│ ├── conftest.py # fixtures (ASGITransport + db_session) +│ ├── test_run_demo_unit.py # DOES NOT EXIST — create +│ └── test_e2e_demo.py # DOES NOT EXIST — create +├── app/ +│ ├── core/ +│ │ ├── config.py # Settings.openai_api_key / anthropic_api_key +│ │ └── problem_details.py # RFC 7807 error shape +│ ├── features/ +│ │ ├── seeder/{routes,schemas,service}.py +│ │ ├── featuresets/{routes,schemas}.py +│ │ ├── forecasting/{routes,schemas}.py +│ │ ├── backtesting/{routes,schemas}.py +│ │ ├── registry/{routes,schemas,storage}.py +│ │ └── agents/{routes,schemas}.py +│ └── shared/seeder/ +│ ├── config.py # ScenarioPreset enum + from_scenario branches +│ └── tests/test_config.py # add demo_minimal test +├── .github/workflows/ +│ ├── ci.yml # 4 required jobs (don't extend; nightly is separate) +│ └── e2e-nightly.yml # DOES NOT EXIST — create (cron, not PR-blocking) +└── docs/ + ├── DAILY-FLOW.md # cross-link `make demo` + └── _base/{REPO_MAP_INDEX,RUNBOOKS}.md # row + incident entry +``` + +### Desired Codebase tree (files added/changed) +```bash +NEW Makefile # demo / demo-quick / demo-clean targets +NEW scripts/run_demo.py # ~400 lines, single-file async driver +NEW tests/test_run_demo_unit.py # mock-HTTP unit coverage of the driver +NEW tests/test_e2e_demo.py # @pytest.mark.integration subprocess test +NEW .github/workflows/e2e-nightly.yml # cron + workflow_dispatch +MOD app/shared/seeder/config.py # +DEMO_MINIMAL enum value + from_scenario branch +MOD app/features/seeder/service.py # +ScenarioInfo entry in list_scenarios() +MOD app/shared/seeder/tests/test_config.py # +test_from_scenario_demo_minimal +MOD README.md # Quick-start "Try it" line +MOD docs/DAILY-FLOW.md # First-run cross-link +MOD docs/_base/RUNBOOKS.md # New "Demo run failed" incident +MOD docs/_base/REPO_MAP_INDEX.md # Rows for Makefile + scripts/run_demo.py +KEEP examples/e2e_smoke.sh # unchanged (X-Request-ID smoke remains) +``` + +### Known Gotchas of our codebase & Library Quirks +```python +# CRITICAL: /seeder/generate is SYNCHRONOUS — returns GenerateResult directly. +# Do NOT loop on GET /seeder/status expecting it to flip; status is for after. +# Source: app/features/seeder/routes.py:85-136 (no 202; returns 201 with body). + +# CRITICAL: /forecasting/train is SYNCHRONOUS too — returns TrainResponse with model_path. +# Do NOT submit via /jobs; that's for the agentic/background queue, not the synchronous baselines. +# Source: app/features/forecasting/routes.py:24-131. + +# CRITICAL: Pydantic strict-mode policy on request bodies — fields typed `date` / +# `datetime` / `UUID` / `Decimal` MUST carry `Field(strict=False, ...)` because +# FastAPI calls validate_python (not validate_json) on the parsed dict. +# Effect on caller: passing ISO date STRINGS in JSON is fine; the server unwraps them. +# Source: docs/_base/SECURITY.md "Pydantic v2 strict mode" + issue #117/PR #119. + +# CRITICAL: Registry transitions are pending → running → success. You MUST patch +# intermediate `running` even though the script does the training synchronously. +# pending → success is rejected by InvalidTransitionError (registry/schemas.py:32-38). + +# CRITICAL: RunCreate uses Field(alias="model_config") — on-the-wire JSON key is +# `model_config`, not `model_config_data`. Use httpx `json=` and write +# "model_config": in the payload. (registry/schemas.py:68) + +# CRITICAL: Aliases can ONLY point to runs in SUCCESS status. Trying to alias a +# PENDING/RUNNING run returns 400. Order matters: alias AFTER patch-to-success. +# (registry/routes.py:404) + +# CRITICAL: artifact_hash computation — the demo script reads the file at model_path +# (returned by /forecasting/train) and computes sha256 client-side. This works only +# because we're single-host; the script and the API share the FS. Mirror +# LocalFSProvider.compute_hash() logic (registry/storage.py). + +# CRITICAL: Agent step needs OPENAI_API_KEY or ANTHROPIC_API_KEY. If neither is set, +# the agent service will fail at first chat call. SKIP gracefully with ⏭️ when +# neither is present. Use bool(settings.openai_api_key) — never log the value. + +# CRITICAL: Backtest with strategy="expanding" + n_splits=3 + horizon=14 + min_train_size=30 +# needs the seeded date range to be ≥ 30 + 3*14 = 72 days. The demo_minimal scenario +# must cover ≥ 90 days to stay safe. Recommended: 2024-10-01 → 2024-12-31 (92 days). + +# CRITICAL: Seeder is BLOCKED in production unless seeder_allow_production=true. The +# demo MUST run on a host where settings.app_env != "production" (default), or with +# the override. The script should NOT touch that env var; document the requirement. +# (app/features/seeder/routes.py:21-33) + +# CRITICAL: Makefile recipes — tab indentation, NOT spaces. Use `.PHONY: ...` for all +# targets (they're not file outputs). `uv run` prefixes every Python invocation per +# CLAUDE.md "Commands". + +# CRITICAL: Output formatting — use the .claude/rules/output-formatting.md glyphs +# (✅/❌/⚠️/⏭️/🔄). Cap report at 40 lines. End with `👉 Next steps:` footer on +# any non-success path. + +# GOTCHA: httpx default timeout is 5 seconds, which is too short for /seeder/generate +# (can take ~30-60 s for retail_standard, ~10-20 s for demo_minimal). Use +# `httpx.Timeout(60.0, connect=5.0)` per call OR set the client-wide timeout. + +# GOTCHA: pyproject.toml ruff per-file-ignores already gives `scripts/**/*.py` a +# pass on T201 (print()) and ANN — but `scripts/run_demo.py` IS the script, so +# prints are intentional. (pyproject.toml:97) + +# GOTCHA: The integration test subprocess invocation needs `cwd=repo_root` so +# `uv run python scripts/run_demo.py` resolves; pass via Path(__file__).parent.parent. + +# GOTCHA: CI nightly — uvicorn must be backgrounded (& or `uvicorn ... &`). Use +# `until curl -fs http://127.0.0.1:8123/health; do sleep 2; done` to wait, capped +# at 30 s. Don't use `sleep 30 && curl` blindly — fragile. + +# GOTCHA: Every commit referencing the new files needs an issue number per +# commit-format.md. Open the tracking issue BEFORE the first commit: +# `gh issue create --title "feat(api,docs): e2e demo pipeline + showcase script" +# --body "Implements PRP-15 / INITIAL-14"`. +``` + +--- + +## Implementation Blueprint + +### Data models and structure + +```python +# scripts/run_demo.py — module-level types + +from dataclasses import dataclass, field +from collections.abc import Awaitable, Callable +from typing import Any + +@dataclass +class DemoContext: + """Accumulator threaded through every step. + + Holds cross-step references (store_id, product_id, train run_ids, winner) so + later steps can use earlier outputs without recomputing. The script never + mutates the API state via this struct — it is read-side cache only. + """ + api_url: str + seed: int + skip_seed: bool + reset: bool + quiet: bool + timeout: float + store_id: int = 1 # seeded as 1..N by demo_minimal + product_id: int = 1 + date_start: str | None = None # populated after seed (ISO) + date_end: str | None = None + train_results: dict[str, dict[str, Any]] = field(default_factory=dict) + backtest_results: dict[str, dict[str, Any]] = field(default_factory=dict) + winner_model_type: str | None = None + winner_wape: float | None = None + winning_run_id: str | None = None + session_id: str | None = None + wall_clock_start: float = 0.0 + +@dataclass +class StepOutcome: + name: str + status: str # "pass" | "fail" | "skip" | "warn" + detail: str + duration_ms: float +``` + +### Task list (in execution order) + +```yaml +Task 1 — Open tracking GitHub issue (REQUIRED per commit-format.md): + RUN: + gh issue create \ + --title "feat(api,docs): e2e demo pipeline + showcase script" \ + --body "Implements PRP-15 / INITIAL-14. Single command 'make demo' drives seed → features → train × 3 → backtest → registry → alias → agent in ≤ 180 s. Adds demo_minimal scenario, top-level Makefile, scripts/run_demo.py, unit + integration tests, nightly CI." + CAPTURE: issue number (e.g. #128) — use in ALL commits below. + +Task 2 — Add DEMO_MINIMAL scenario: + MODIFY app/shared/seeder/config.py: + - INJECT enum value at line 19 (after SPARSE): + DEMO_MINIMAL = "demo_minimal" + - INJECT from_scenario branch after line 605 (after SPARSE branch): + if scenario == ScenarioPreset.DEMO_MINIMAL: + return cls( + seed=seed, + start_date=date(2024, 10, 1), + end_date=date(2024, 12, 31), + dimensions=DimensionConfig(stores=3, products=10), + time_series=TimeSeriesConfig( + base_demand=100, trend="linear", + trend_slope=0.0005, noise_sigma=0.10, + ), + retail=RetailPatternConfig( + promotion_probability=0.1, stockout_probability=0.02, + ), + ) + MODIFY app/features/seeder/service.py:list_scenarios (line 312): + - INJECT ScenarioInfo entry after sparse (line 366): + schemas.ScenarioInfo( + name="demo_minimal", + description="Tiny preset for the make demo target (3 stores × 10 products × 92 days)", + stores=3, products=10, + start_date=date(2024, 10, 1), end_date=date(2024, 12, 31), + ), + MODIFY app/shared/seeder/tests/test_config.py: + - ADD test_from_scenario_demo_minimal mirroring test_from_scenario_retail_standard + - UPDATE test_all_scenario_names to include "demo_minimal" + +Task 3 — Create scripts/run_demo.py skeleton: + CREATE scripts/run_demo.py: + - MIRROR docstring + argparse pattern from scripts/seed_random.py lines 1-50 + - MIRROR exit-code pattern from scripts/check_db.py lines 67-72 + - ADD argparse for: --seed (int, default 42), --skip-seed (flag), + --reset (flag), --quiet (flag), --api-url (str, default http://localhost:8123), + --timeout (float, default 60.0) + - ADD module-level DemoContext + StepOutcome dataclasses (above) + - ADD Reporter class with `step_start(name)`, `step_pass/fail/warn/skip(detail)`, + `summary(outcomes)` methods using the rules/output-formatting.md glyphs + - ADD HttpClient wrapper: httpx.AsyncClient with timeout, plus a helper that + raises a typed StepError on non-2xx surfacing problem+json type/title/detail/request_id + +Task 4 — Implement steps 1-4 (health + reset + seed + status): + IN scripts/run_demo.py: + - precheck_health(ctx, client): GET /health → 200 / status="ok" else exit 2 + - maybe_reset(ctx, client): if --reset, DELETE /seeder/data {scope:"all", dry_run:false} + - seed_dataset(ctx, client): POST /seeder/generate with body + {"scenario":"demo_minimal", "seed":ctx.seed, "stores":3, "products":10, + "start_date":"2024-10-01", "end_date":"2024-12-31", + "sparsity":0.0, "dry_run":false} + (skipped if --skip-seed). Stash records_created counts on ctx. + - confirm_status(ctx, client): GET /seeder/status → populate ctx.date_start/date_end. + Pick (store_id=1, product_id=1) — the first record in demo_minimal. + +Task 5 — Implement step 5 (featureset compute, demo-only): + IN scripts/run_demo.py: + - compute_features_demo(ctx, client): POST /featuresets/compute with body + {"store_id":1, "product_id":1, "cutoff_date": ctx.date_end, + "lookback_days":60, "config":{"lag_config":{"lags":[1,7,14]}, + "rolling_config":{"windows":[7,14], "aggregations":["mean","std"]}, + "calendar_config":{"include":["dow","month","quarter"]}}} + Surface row_count + null_counts to the report; do NOT pass features to train + (baselines don't consume them; this step is demonstration-only). + +Task 6 — Implement step 6 (train × 3 in parallel): + IN scripts/run_demo.py: + - train_all(ctx, client): asyncio.gather of 3 train calls: + POST /forecasting/train with bodies: + {"store_id":1, "product_id":1, + "train_start_date": ctx.date_start, "train_end_date": , + "config":{"model_type":"naive"}} + ... seasonal_naive (season_length=7) ... + ... moving_average (window_size=7) ... + Stash each TrainResponse on ctx.train_results[model_type]. + train_end_date = date_end - horizon to leave room for backtest test windows. + +Task 7 — Implement step 7 (backtest × 3 sequentially; pick winner): + IN scripts/run_demo.py: + - backtest_all(ctx, client): for each model_type in [naive, seasonal_naive, moving_average]: + POST /backtesting/run with body + {"store_id":1, "product_id":1, + "start_date": ctx.date_start, "end_date": ctx.date_end, + "config":{"split_config":{"strategy":"expanding","n_splits":3, + "min_train_size":30,"gap":0,"horizon":14}, + "model_config_main":{"model_type": model_type, ...}, + "include_baselines": false, # already comparing apples-to-apples + "store_fold_details": false}} # save bytes + Stash aggregated_metrics on ctx.backtest_results[model_type]. + ctx.winner_model_type = argmin of aggregated_metrics["wape"] across 3 models. + ctx.winner_wape = winning WAPE. + +Task 8 — Implement step 8 (registry create-run + update + alias): + IN scripts/run_demo.py: + - register_winner(ctx, client): + a) Read winner's model_path → compute sha256 hash + size in bytes. + Use pathlib.Path(model_path).read_bytes() then hashlib.sha256(...).hexdigest(). + b) POST /registry/runs with payload (NOTE: JSON key "model_config", NOT "model_config_data"): + {"model_type": ctx.winner_model_type, + "model_config": , + "feature_config": null, + "data_window_start": ctx.date_start, "data_window_end": ctx.date_end, + "store_id":1, "product_id":1, + "agent_context": null, "git_sha": null} + Capture run_id from response. + c) PATCH /registry/runs/{run_id} with {"status":"running"} (required transition) + d) PATCH /registry/runs/{run_id} with: + {"status":"success", + "metrics": ctx.backtest_results[ctx.winner_model_type], + "artifact_uri": model_path, + "artifact_hash": , + "artifact_size_bytes": } + e) POST /registry/aliases with {"alias_name":"demo-production", "run_id": run_id}. + +Task 9 — Implement step 9 (verify) + step 10 (agent if key set) + step 11 (cleanup): + IN scripts/run_demo.py: + - verify_artifact(ctx, client): GET /registry/runs/{ctx.winning_run_id}/verify; + assert response["verified"] == True. + - chat_with_agent_if_keys_set(ctx, client): + from app.core.config import get_settings + s = get_settings() + if not (s.openai_api_key or s.anthropic_api_key): + return StepOutcome(name="agent", status="skip", + detail="No OPENAI_API_KEY/ANTHROPIC_API_KEY set", duration_ms=0.0) + POST /agents/sessions {"agent_type":"experiment"} → session_id + POST /agents/sessions/{session_id}/chat {"message":"List the latest model runs"} + Assert 200; capture tool_calls_count + total_tokens_used for the report. + DELETE /agents/sessions/{session_id} (cleanup, ignore 204). + +Task 10 — Wire main() + summary: + IN scripts/run_demo.py: + - main_async(args): instantiate DemoContext + Reporter + HttpClient; + run steps in order; collect StepOutcomes; print summary block + formatted per .claude/rules/output-formatting.md including + "runs=3 winner= alias=demo-production wall_clock=s" final line. + If wall_clock > 180s, ⚠️ WARN but do not fail (per INITIAL-14 risk mitigation). + - main(): sys.exit(asyncio.run(main_async(parse_args()))) + +Task 11 — Create top-level Makefile: + CREATE Makefile: + - .PHONY: demo demo-quick demo-clean help + - help: print available targets (default goal) + - demo: docker compose up -d && uv run alembic upgrade head && \ + uv run python scripts/run_demo.py --seed 42 + - demo-quick: uv run python scripts/run_demo.py --seed 42 --skip-seed + - demo-clean: docker compose up -d && uv run alembic upgrade head && \ + uv run python scripts/run_demo.py --seed 42 --reset + Use tab indentation; line-continuation with backslash + tab on next line. + +Task 12 — Unit tests: + CREATE tests/test_run_demo_unit.py: + - Import the run_demo module: `import scripts.run_demo as run_demo` + (add scripts/__init__.py if needed — check first; scripts/seed_random.py + doesn't require it because it's run as a script, but for imports we need it). + - Test Reporter glyph mapping: pass=✅, fail=❌, skip=⏭️, warn=⚠️. + - Test DemoContext default field values. + - Test argparse parsing of --seed/--skip-seed/--reset/--quiet/--api-url/--timeout. + - Test winner selection: given three backtest_results dicts with different WAPE, + assert winner_model_type is the argmin. + - Mock the HttpClient with unittest.mock.AsyncMock and verify per-step request + payloads match the documented JSON shapes. + +Task 13 — Integration test: + CREATE tests/test_e2e_demo.py: + - @pytest.mark.integration on the class/function. + - Skip if docker compose Postgres is unreachable + (try: `await asyncpg.connect(settings.database_url)` with 2-second timeout). + - Start uvicorn as a fixture: subprocess.Popen(["uv","run","uvicorn","app.main:app","--port","8124"], ...); + wait for http://127.0.0.1:8124/health via polling (cap 30s). + Use port 8124 to avoid colliding with a developer's already-running server. + - Run: subprocess.run(["uv","run","python","scripts/run_demo.py","--seed","42", + "--reset","--api-url","http://127.0.0.1:8124","--timeout","60"], + capture_output=True, text=True, timeout=240). + - Assert: returncode == 0, "demo-production" in stdout, wall_clock < 180 (soft). + - Teardown: terminate uvicorn; clean alias via DELETE /registry/aliases/demo-production. + +Task 14 — Nightly CI workflow: + CREATE .github/workflows/e2e-nightly.yml: + - Triggers: schedule (cron '0 7 * * *' = 07:00 UTC daily) + workflow_dispatch. + - Job 'e2e-demo': ubuntu-latest with services.postgres (pgvector/pgvector:pg16) + pinned same way as ci.yml `test` job. + - Steps: checkout @v6, setup-uv @, install deps `uv sync --frozen --all-extras`, + `uv run alembic upgrade head`, start uvicorn in background with `&` + wait-loop, + `uv run python scripts/run_demo.py --seed 42 --api-url http://127.0.0.1:8123 --timeout 60`. + - permissions: contents: read. + - Pin third-party actions by SHA per .claude/rules/security-patterns.md. + - NOT a required check on dev/main. + +Task 15 — Docs updates: + MODIFY README.md: + - Add `make demo` line under "Quick start" / "Try it" section (find the existing + block by grepping for "uv run uvicorn" or "docker compose up"). + MODIFY docs/DAILY-FLOW.md: + - Cross-link `make demo` from the "first-run" section. + MODIFY docs/_base/RUNBOOKS.md: + - Add a new "Common Incidents" entry: "make demo fails at step X" with diagnosis + tree (precondition checks, missing key handling, scenario presence). + MODIFY docs/_base/REPO_MAP_INDEX.md: + - Add table rows for `Makefile` and `scripts/run_demo.py` under "Document Index". + +Task 16 — Commit + PR: + Branch: feat/api-e2e-demo (off dev, per .claude/rules/branch-naming.md) + Commits (each referencing the issue from Task 1): + 1. `feat(data): add demo_minimal scenario preset (#)` — tasks 2 + 2. `feat(api,docs): scripts/run_demo.py end-to-end pipeline driver (#)` — tasks 3-10 + 3. `feat(repo): top-level Makefile with demo / demo-quick / demo-clean (#)` — task 11 + 4. `test(api): unit + integration coverage for run_demo (#)` — tasks 12-13 + 5. `ci(repo): nightly e2e demo workflow (#)` — task 14 + 6. `docs(docs): cross-link make demo from README + RUNBOOKS + REPO_MAP_INDEX (#)` — task 15 +``` + +### Per-task pseudocode (HttpClient wrapper — the load-bearing piece) + +```python +# scripts/run_demo.py — HttpClient wrapper + +class StepError(Exception): + """Surfaces RFC 7807 problem+json bodies as a typed failure.""" + def __init__(self, step: str, status_code: int, problem: dict[str, Any]) -> None: + self.step = step + self.status_code = status_code + self.problem = problem + super().__init__( + f"{step}: HTTP {status_code} — {problem.get('title','?')}: " + f"{problem.get('detail','?')} (request_id={problem.get('request_id','?')})" + ) + +class HttpClient: + def __init__(self, base_url: str, timeout: float) -> None: + # CRITICAL: explicit timeout — default 5s is too short for /seeder/generate + self._client = httpx.AsyncClient( + base_url=base_url, + timeout=httpx.Timeout(timeout, connect=5.0), + ) + + async def __aenter__(self) -> "HttpClient": ... + async def __aexit__(self, *exc: object) -> None: + await self._client.aclose() + + async def request(self, step: str, method: str, path: str, **kw: Any) -> dict[str, Any]: + # PATTERN: never log secret VALUES per security-patterns.md — log path + status only + r = await self._client.request(method, path, **kw) + if r.status_code >= 400: + try: + problem = r.json() + except json.JSONDecodeError: + problem = {"title": "Non-JSON error", "detail": r.text[:200]} + raise StepError(step, r.status_code, problem) + # GOTCHA: 204 No Content — DELETE /agents/sessions returns no body + if r.status_code == 204: + return {} + return r.json() +``` + +### Integration Points +```yaml +DATABASE: + - migration: NONE (no schema change) + - data: demo_minimal scenario reads from existing tables; no new tables + +CONFIG: + - No new env vars (Q1 answered "yes — add demo_minimal preset"; Q2 answered + "yes — make demo invokes docker compose up -d itself"; Q3 answered + "yes — promote to nightly CI as part of this PRP") + +ROUTES: + - No new API routes (script consumes existing surface only) + +DOCS: + - README.md, docs/DAILY-FLOW.md, docs/_base/RUNBOOKS.md, docs/_base/REPO_MAP_INDEX.md + +CI: + - new .github/workflows/e2e-nightly.yml (cron 07:00 UTC + workflow_dispatch) + - NOT a required-status-check on dev or main +``` + +--- + +## Validation Loop + +### Level 1: Syntax & Style +```bash +# Fix-on-fail, then re-run +uv run ruff check . --fix +uv run ruff format . +uv run mypy app/ +uv run pyright app/ +# Expected: zero errors. Strict mode is enforced (pyproject.toml:114-126 + 149-172). +# For scripts/, pyright EXCLUDES tests but INCLUDES scripts since they import app.* — +# verify scripts/run_demo.py passes mypy/pyright too: +uv run mypy scripts/run_demo.py +uv run pyright scripts/run_demo.py +``` + +### Level 2: Unit tests +```bash +uv run pytest -v -m "not integration" tests/test_run_demo_unit.py \ + app/shared/seeder/tests/test_config.py +# Expected: all green. Tests are pure-Python; no DB. Mock httpx.AsyncClient. +``` + +### Level 3: Integration test (REAL DB + REAL uvicorn) +```bash +# Bring up Postgres + apply migrations +docker compose up -d +uv run alembic upgrade head + +# Run the integration test (spins up uvicorn on :8124 as a subprocess, then exec's the demo) +uv run pytest -v -m integration tests/test_e2e_demo.py +# Expected: PASS; the test asserts: +# - returncode == 0 +# - "demo-production" appears in stdout +# - wall-clock under 180s (soft assertion / warn-only) +``` + +### Level 4: Manual end-to-end verification +```bash +# Smoke the maintainer's actual UX +docker compose up -d +uv run alembic upgrade head +uv run uvicorn app.main:app --port 8123 & # background +until curl -fs http://127.0.0.1:8123/health; do sleep 2; done + +make demo +# Expected output (abbreviated, formatted per .claude/rules/output-formatting.md): +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 🔍 ForecastLabAI Demo +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# ✅ Step 1/11: precheck — /health ok +# ✅ Step 2/11: reset — skipped (no --reset) +# ✅ Step 3/11: seed — 3 stores × 10 products × 92 days +# ✅ Step 4/11: status — date_range=2024-10-01..2024-12-31 +# ✅ Step 5/11: features — 60 rows, lag+rolling+calendar +# ✅ Step 6/11: train × 3 — naive, seasonal_naive, moving_average +# ✅ Step 7/11: backtest × 3 — winner=seasonal_naive wape=0.18 +# ✅ Step 8/11: register — run_id=abc123 alias=demo-production +# ✅ Step 9/11: verify — sha256 OK +# ⏭️ Step 10/11: agent — SKIP (no LLM key set) +# ✅ Step 11/11: cleanup — done +# ──────────────────────────────────────────── +# ✅ Result: GREEN +# ──────────────────────────────────────────── +# runs=3 winner=seasonal_naive alias=demo-production wall_clock=87s +``` + +--- + +## Final Validation Checklist +- [ ] `uv run ruff check . && uv run ruff format --check .` clean +- [ ] `uv run mypy app/` clean (strict) +- [ ] `uv run pyright app/` clean (strict) +- [ ] `uv run pytest -v -m "not integration"` all green +- [ ] `uv run pytest -v -m integration tests/test_e2e_demo.py` green +- [ ] `make demo` exits 0 against a clean DB; wall-clock ≤ 180s +- [ ] `GET /registry/aliases/demo-production` returns the winner +- [ ] `GET /registry/runs/{winner_run_id}/verify` returns `verified=true` +- [ ] Agent step either succeeds or correctly emits `⏭️ [SKIP]` +- [ ] `.github/workflows/e2e-nightly.yml` syntactically valid (`actionlint` or + `gh workflow view e2e-nightly.yml`); third-party actions SHA-pinned per + `.claude/rules/security-patterns.md` +- [ ] README + DAILY-FLOW + RUNBOOKS + REPO_MAP_INDEX updated +- [ ] `examples/e2e_smoke.sh` untouched (regression check) +- [ ] No new env vars added to `.env.example` (verified: not needed) +- [ ] No AI co-author trailers on any commit (per `.claude/rules/commit-format.md`) +- [ ] Every commit references the tracking issue from Task 1 +- [ ] Branch named `feat/api-e2e-demo` (per `.claude/rules/branch-naming.md`) + +--- + +## Anti-Patterns to Avoid +- ❌ Don't call services in-process — defeats demo-trust purpose; use HTTP. +- ❌ Don't reuse `scripts/seed_random.py` directly — different abstraction (CLI vs HTTP driver). +- ❌ Don't `sleep` in tight loops; use `asyncio.sleep` + bounded retries. +- ❌ Don't log API keys or RFC 7807 bodies that may contain them (echo `title`/`detail`/`request_id` only). +- ❌ Don't skip the intermediate `pending → running` registry transition; it's required by the state machine. +- ❌ Don't add `lightgbm` to the demo — it's feature-flagged off and Phase-2 column + integration is PRP-16 scope. +- ❌ Don't introduce a new `feature_view` abstraction "while we're here" — out of scope. +- ❌ Don't `os.environ[...]` directly in scripts/ — use `app.core.config.get_settings()`. +- ❌ Don't make the nightly CI workflow PR-blocking — it's informational only this PRP. +- ❌ Don't `git push --force` on dev/main; don't AI-co-author the commits. +- ❌ Don't expand the seeder API contract — `demo_minimal` is purely a scenario preset on the existing surface. + +--- + +## Confidence Score + +**8 / 10** for one-pass implementation success. + +**Why high:** +- API surface is fully cataloged with file paths + line numbers (every endpoint + the script calls is documented above with its schema location). +- All gotchas are written down (synchronous seeder, registry 2-step, alias-after-success + ordering, strict-mode date fields, model_config alias, hashlib for artifact, default + httpx timeout trap, Makefile tab indentation, scripts ruff exemption). +- Validation gates are concrete commands an agent can run + fix loop on. +- No external dependencies added; no migrations; no breaking changes. +- Failure modes are all surfaced via RFC 7807 with `request_id` for log correlation. + +**Why not 10:** +- The integration test that subprocess-spawns uvicorn on port 8124 is fiddly (port + collision detection, process teardown, CI flakiness around `until curl …`); first + pass may need a retry loop tuned. +- `demo_minimal` scenario's exact `base_demand` / seasonality may need one tweak to + produce a non-NaN WAPE on every backtest fold (the SPARSE preset has had this trap + before — see `app/shared/seeder/tests/test_phase1_regression.py`). +- The wall-clock budget of 180 s is laptop-dependent; the CI nightly job may need a + bumped timeout vs. the local target. + +If the agent hits any of those three, the validation loop above will catch it +deterministically and the fix is local (retry tuning, scenario parameters, CI timeout). diff --git a/PRPs/PRP-17-demo-showcase-page.md b/PRPs/PRP-17-demo-showcase-page.md new file mode 100644 index 00000000..51a08b6b --- /dev/null +++ b/PRPs/PRP-17-demo-showcase-page.md @@ -0,0 +1,875 @@ +name: "PRP-17 — In-Product Demo Showcase Page (live e2e pipeline in the dashboard)" +description: | + Turn the CLI-only end-to-end demo (PRP-15 / `scripts/run_demo.py` / `make demo`) into a + visible, in-product experience. Add a new backend `demo` vertical slice that drives the + published API surface in-process and streams per-step progress, plus a React **Showcase** + page that renders the pipeline running live — seed → features → train ×3 → backtest ×3 → + register → verify → agent — as status cards a portfolio reviewer can watch in the browser. + +## Purpose +Close the demonstrability gap that PRP-15 left half-open. PRP-15 made the e2e pipeline +*runnable* (`make demo`) — but only from a terminal. A portfolio reviewer (or the +maintainer after an absence) who opens the dashboard sees no live pipeline narrative; the +multi-week Phase-1/Phase-2 investment is invisible unless someone runs a shell command. +After this PRP, the dashboard has a **Showcase** page: click "Run pipeline", watch the +11-step e2e flow stream to completion, and land on the registered winning model — no CLI. + +> **PRP numbering note:** `PRP-16` is reserved by PRP-15 for Phase-2-aware LightGBM. This +> PRP takes `PRP-17` to avoid the collision. + +## Core Principles +1. **Context is King** — every endpoint shape, schema field, and orchestration decision is + linked to a real source file + line below. The orchestration logic is a *proven* copy of + `scripts/run_demo.py` (PR #129) — that file is the reference implementation. +2. **Vertical-slice rule respected** — new code lives under `app/features/demo/`; it does + NOT import from any other `app/features/*` slice. It drives the app through its own HTTP + surface via `httpx.ASGITransport` (the in-process transport the test suite already uses, + `tests/conftest.py:4`), so there is zero cross-slice Python import. +3. **Reuse existing patterns** — WebSocket streaming mirrors `/agents/stream` + (`app/features/agents/websocket.py`); the frontend reuses `useWebSocket` + (`frontend/src/hooks/use-websocket.ts`); no new streaming primitive is invented. +4. **Additive only** — no schema changes, no Alembic migration, no breaking API edits, no + new env var. One new backend slice, one new frontend page. +5. **Strict gates honored** — `ruff` + `mypy --strict` + `pyright --strict` + `pytest` + + `pnpm tsc --noEmit` + `pnpm lint` + `pnpm test` all green. +6. **UI through skills** — the page is built via `frontend-design` + `shadcn-ui` and + dogfooded via `webapp-testing` / `agent-browser` per `.claude/rules/ui-design.md`. + +--- + +## Goal +A new **Showcase** nav item routes to `/showcase`. The page shows the 11 pipeline steps as +a vertical list of status cards. Clicking **Run pipeline** opens a WebSocket to +`/demo/stream`; the backend `demo` slice drives `precheck → (reset) → (seed) → status → +features → train ×3 → backtest ×3 → register → verify → agent → cleanup` against the app's +own HTTP surface in-process, emitting one `StepEvent` per step. Each card updates live +(🔄 → ✅/❌/⏭️/⚠️) with a one-line detail and a duration. The backtest step surfaces +per-model WAPE and highlights the winner; the register step surfaces the `run_id` and the +`demo-production` alias. A final summary banner shows `runs=3 winner= wall_clock=s`. + +## Why +- **Portfolio identity.** `.claude/rules/product-vision.md` principle 1 — "portfolio-grade, + end-to-end … every phase ships working code". The e2e proof currently lives only in + `scripts/run_demo.py` + `Makefile` (`make demo`). A dashboard visitor can't see it. +- **Momentum.** PR #129 (`feat(api,docs): e2e demo pipeline + showcase script (#128)`) just + landed the pipeline backend. This PRP turns that investment into the visible payoff. +- **Empty backlog.** `gh issue list --state open` is effectively empty (only #128/#130, + both already merged) — a clean inflection point to invest in the demo surface. +- **Reviewer UX.** `frontend/src/pages/` has `dashboard, chat, admin, explorer/*, + visualize/*` — none run or visualize the pipeline. The gap is real and unfilled. + +## What +A new `app/features/demo/` backend slice exposing: +- `POST /demo/run` — synchronous; runs the whole pipeline and returns a `DemoRunResult` + (all step outcomes). Simple consumer + the integration-test target. +- `WS /demo/stream` — streams one `StepEvent` per step for the live UI. + +Both share a single orchestrator, `app/features/demo/pipeline.py:run_pipeline()`, an async +generator yielding `StepEvent`. A module-level `asyncio.Lock` ensures only one pipeline +runs at a time (concurrent attempts get RFC 7807 `409`). + +A new React **Showcase** page (`frontend/src/pages/showcase.tsx`) consumes `/demo/stream` +via a thin `use-demo-pipeline.ts` hook (wrapping `useWebSocket`) and renders the live step +cards + summary. + +### Success Criteria +- [ ] `GET /showcase` in the running SPA renders 11 idle step cards + a **Run pipeline** button. +- [ ] Clicking **Run pipeline** streams live updates; every step ends `✅` or `⏭️` on a + seeded DB (agent step `⏭️` when no LLM key is configured). +- [ ] `POST /demo/run` returns `200` with a `DemoRunResult` whose `overall_status` is + `"pass"` on a seeded DB; a second concurrent call returns `409 application/problem+json`. +- [ ] The backtest step's event `data` carries per-model WAPE; the page highlights the winner. +- [ ] The register step's event `data` carries `run_id`; `GET /registry/aliases/demo-production` + returns that `run_id` after a run. +- [ ] `tests/test_demo_showcase_integration.py` (`@pytest.mark.integration`) passes against + real Postgres. +- [ ] `app/features/demo/tests/test_pipeline.py` + `test_routes.py` pass (unit, mocked HTTP). +- [ ] `uv run ruff check . && uv run ruff format --check . && uv run mypy app/ && + uv run pyright app/` all clean. +- [ ] `cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run` all clean. +- [ ] No Alembic migration; no new `.env` var; `scripts/run_demo.py` untouched. + +--- + +## All Needed Context + +### Documentation & References +```yaml +- url: https://www.python-httpx.org/advanced/transports/#asgi-transport + why: httpx ASGITransport — call a FastAPI app in-process with no network/port + critical: | + `httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://demo")`. + This is how the demo slice drives /seeder, /forecasting, /backtesting, /registry, + /agents WITHOUT importing those slices' Python modules — satisfying the vertical-slice + rule. The test suite already uses this exact pattern (tests/conftest.py:4,15). + +- url: https://fastapi.tiangolo.com/advanced/websockets/ + why: FastAPI @router.websocket() — accept(), receive_json(), send_json(), close() + critical: | + A prefixed APIRouter applies its prefix to websocket routes too: + `APIRouter(prefix="/demo")` + `@router.websocket("/stream")` → `/demo/stream`. + +- url: https://www.python-httpx.org/async/ + why: AsyncClient lifecycle + timeout + critical: | + Always `async with httpx.AsyncClient(...) as client:`. Pass an explicit timeout — + the seed step is slow. Use httpx.Timeout(120.0, connect=5.0). + +- url: https://docs.pydantic.dev/latest/concepts/models/ + why: Pydantic v2 models for StepEvent / DemoRunRequest / DemoRunResult + critical: | + REQUEST bodies under app/features/**/schemas.py with ConfigDict(strict=True) and a + field typed date/datetime/UUID/Decimal MUST add Field(strict=False, ...) — enforced by + app/core/tests/test_strict_mode_policy.py (AST walker). DemoRunRequest's fields are all + JSON-native (int/bool) so it is safe with strict=True. EVENT/RESPONSE models + (StepEvent, DemoRunResult) follow the StreamEvent precedent: a PLAIN BaseModel, NO + strict=True — see app/features/agents/schemas.py:229 StreamEvent. + +- file: scripts/run_demo.py + why: THE reference implementation. The pipeline.py orchestration is a faithful in-process + port of this file's 11 steps. Every step's request payload is proven here. + critical: | + - Step list + order: _step_table() at lines 935-953. + - DemoContext accumulator: lines 119-148. Reuse the field set. + - Per-step request bodies — copy verbatim: + seed → lines 414-428 (POST /seeder/generate) + status → lines 457-507 (GET /seeder/status + /dimensions/* for real IDs) + features → lines 535-554 (POST /featuresets/compute) + train → lines 581-597 (POST /forecasting/train ×3 via asyncio.gather) + backtest → lines 620-651 (POST /backtesting/run ×3 sequential) + register → lines 673-793 (registry 2-step create→running→success + alias) + verify → lines 813-818 (GET /registry/runs/{id}/verify) + agent → lines 827-892 (POST /agents/sessions + /chat, skip if no key) + - Winner selection: _select_winner() lines 338-356 (lowest non-NaN WAPE). + - Model config payloads: _model_config_payload() lines 301-314. + - LLM-key presence check: _llm_key_present() lines 317-335. + - Artifact copy/hash dance (train dir vs registry root): lines 715-731 — MUST replicate. + - StepError / RFC 7807 surfacing: lines 155-225. + +- file: app/features/agents/websocket.py + why: The WebSocket handler pattern to mirror for WS /demo/stream + critical: | + accept() → receive a start frame → stream events with send_json(event.model_dump( + mode="json")) → handle WebSocketDisconnect. The demo stream is one-directional after + the start frame (no per-message loop needed — run once, then close). + +- file: app/features/agents/schemas.py + why: StreamEvent (line ~229) is the event-model precedent — plain BaseModel, + `data: dict[str, Any]`, `timestamp: datetime = Field(default_factory=_utc_now)` + critical: Do NOT put ConfigDict(strict=True) on StepEvent. Mirror StreamEvent exactly. + +- file: app/features/seeder/routes.py + why: Router/slice conventions; the production guard `_check_seeder_enabled()` (lines 20-33) + critical: | + `router = APIRouter(prefix="/seeder", tags=["seeder"])`. The demo's seed step calls + POST /seeder/generate which already enforces the prod guard — the demo slice needs NO + separate env guard. + +- file: app/features/analytics/ (whole dir) + why: Precedent for a slice with NO models.py (read-only / stateless slice) + critical: demo slice has no DB table → no models.py, no migration. analytics + dimensions + both omit models.py. This is allowed. + +- file: app/main.py + why: Router wiring — lines 114-126. Add `app.include_router(demo_router)` after seeder. + critical: | + Import circularity: the WS/HTTP handlers must NOT import `app.main`. Get the live app + via `request.app` / `websocket.app` and pass it into run_pipeline(app=...). + +- file: tests/conftest.py + why: ASGITransport AsyncClient fixture (`client`) + async `db_session` fixture + critical: The integration test reuses the `client` fixture to POST /demo/run in-process. + +- file: app/core/problem_details.py + why: RFC 7807 error shape — the 409 "pipeline already running" response uses it + critical: Raise via the slice's normal HTTPException path; register_exception_handlers + in app/main.py serializes it to application/problem+json. + +- file: frontend/src/hooks/use-websocket.ts + why: Generic reconnecting WebSocket hook — use-demo-pipeline.ts wraps it + critical: | + `useWebSocket(url, { onMessage, autoConnect })`. Returns { status, send, disconnect, + reconnect }. For the demo, set autoConnect:false and call reconnect()+send() on the + "Run pipeline" click; disconnect() on pipeline_complete. + +- file: frontend/src/pages/chat.tsx + why: Reference for a page that consumes useWebSocket + renders streamed events + critical: Mirror its event-accumulation-into-state shape. + +- file: frontend/src/pages/admin.tsx + why: Reference for a page that triggers a backend pipeline (the seeder) + renders Cards + critical: Mirror Card/Button/Badge usage + loading/error states. + +- file: frontend/src/lib/constants.ts + why: ROUTES + NAV_ITEMS + WS_URL — add SHOWCASE route, nav entry, DEMO_WS_URL + critical: WS_URL pattern at line 47. Derive DEMO_WS_URL the same way. + +- file: frontend/src/App.tsx + why: Lazy-route registration — add a like the others + critical: Pages are lazy(() => import(...)); wrap in }>. + +- file: frontend/src/lib/api.ts + why: The `api()` fetch wrapper + ApiError — used by the POST /demo/run fallback path + critical: ApiError carries the RFC 7807 ProblemDetail; surface detail.detail in the UI. + +- file: frontend/src/types/api.ts + why: TS type surface — add StepEvent, DemoRunRequest, DemoRunResult + critical: Keep field names identical to the Pydantic models (snake_case on the wire). + +- file: .claude/rules/output-formatting.md + why: Glyphs ✅/❌/⚠️/⏭️/🔄 — reuse the same status vocabulary in the UI +- file: .claude/rules/security-patterns.md + why: Never log secret VALUES; agent step logs key PRESENCE only (bool) +- file: .claude/rules/test-requirements.md + why: New endpoint → route test (2xx + ≥1 error path); new stateful hook → vitest +- file: .claude/rules/ui-design.md + why: UI built/dogfooded via frontend-design + shadcn-ui + webapp-testing skills +- file: .claude/rules/commit-format.md + why: `type(scope): description (#issue)`; open the tracking issue FIRST +- file: .claude/rules/branch-naming.md + why: `/` off dev → `feat/demo-showcase-page` +``` + +### Current Codebase tree (relevant) +```bash +app/ +├── main.py # MOD — wire demo_router +├── core/{config,problem_details}.py # reuse (get_settings, RFC 7807) +└── features/ + ├── demo/ # NEW SLICE — entire directory + ├── seeder/{routes,schemas,service}.py # demo calls POST /seeder/generate, GET /status + ├── featuresets/{routes,schemas}.py # demo calls POST /featuresets/compute + ├── forecasting/{routes,schemas}.py # demo calls POST /forecasting/train + ├── backtesting/{routes,schemas}.py # demo calls POST /backtesting/run + ├── registry/{routes,schemas,storage}.py # demo calls /registry/runs + /aliases + /verify + ├── agents/{routes,websocket,schemas}.py # demo calls /agents/sessions; WS pattern source + ├── dimensions/ # demo calls GET /dimensions/{stores,products} + └── analytics/ # precedent: slice with no models.py +scripts/run_demo.py # UNTOUCHED — the reference orchestration +tests/conftest.py # ASGITransport client fixture (reused) +frontend/src/ +├── App.tsx # MOD — add /showcase route +├── lib/{constants,api}.ts # MOD constants; reuse api +├── types/api.ts # MOD — add demo types +├── hooks/{use-websocket,index}.ts # reuse use-websocket; MOD index +├── pages/{chat,admin}.tsx # reference pages +└── components/{ui,layout,charts}/ # reuse Card/Button/Badge/StatusBadge +``` + +### Desired Codebase tree (files added / changed) +```bash +NEW app/features/demo/__init__.py # slice exports +NEW app/features/demo/schemas.py # StepEvent, DemoRunRequest, DemoRunResult +NEW app/features/demo/pipeline.py # run_pipeline() async generator (~300 LOC) +NEW app/features/demo/service.py # asyncio.Lock guard + run wrappers +NEW app/features/demo/routes.py # POST /demo/run + WS /demo/stream +NEW app/features/demo/tests/__init__.py +NEW app/features/demo/tests/conftest.py # ASGITransport client fixture +NEW app/features/demo/tests/test_schemas.py # event/request model validation +NEW app/features/demo/tests/test_pipeline.py # unit — mocked HTTP, step sequence + winner +NEW app/features/demo/tests/test_routes.py # route test: 200 + 409 + WS connect +NEW tests/test_demo_showcase_integration.py # @pytest.mark.integration — real DB +MOD app/main.py # +import + include_router(demo_router) +NEW frontend/src/pages/showcase.tsx # the Showcase page +NEW frontend/src/hooks/use-demo-pipeline.ts # wraps useWebSocket, owns step state +NEW frontend/src/hooks/use-demo-pipeline.test.ts # vitest — hook state machine +NEW frontend/src/components/demo/demo-step-card.tsx # one step card +NEW frontend/src/components/demo/index.ts # barrel export +MOD frontend/src/App.tsx # +lazy import + +MOD frontend/src/lib/constants.ts # +SHOWCASE route, NAV_ITEMS entry, DEMO_WS_URL +MOD frontend/src/hooks/index.ts # +export use-demo-pipeline +MOD frontend/src/types/api.ts # +StepEvent, DemoRunRequest, DemoRunResult +MOD README.md # "Try it in the browser" line +MOD docs/_base/API_CONTRACTS.md # +demo slice rows + WS event section +MOD docs/_base/RUNBOOKS.md # "Showcase pipeline fails" incident +MOD docs/_base/REPO_MAP_INDEX.md # +rows for the demo slice + showcase page +KEEP scripts/run_demo.py # UNCHANGED (see Known Tradeoffs) +``` + +### Known Gotchas & Library Quirks +```python +# CRITICAL: VERTICAL-SLICE RULE. app/features/demo/ may NOT `import` from any other +# app/features/* slice. It drives them over HTTP via httpx.ASGITransport(app=app). +# Importing app.core.* (get_settings, problem_details) IS allowed. + +# CRITICAL: NO `import app.main` inside the demo slice — app/main.py imports the demo +# router, so importing main back creates a circular import. Obtain the live FastAPI +# instance from `request.app` (HTTP handler) / `websocket.app` (WS handler) and pass it +# into run_pipeline(app=...). + +# CRITICAL: pipeline.py runs in `app/` → mypy --strict + pyright --strict apply, and +# ruff does NOT exempt prints/annotations there (per-file-ignores only covers +# scripts/** + examples/** + tests/**, pyproject.toml:92-101). Fully annotate; no print(). + +# CRITICAL: in-process httpx call — base_url is cosmetic, e.g. "http://demo.internal". +# ASGITransport routes straight to the app; CORS does not apply (server-side). + +# CRITICAL: the seed step is slow + CPU-heavy (pandas generation). Over ASGITransport it +# runs in the SAME event loop as the WS handler, so it briefly stalls heartbeats. +# MITIGATION: the Showcase page defaults skip_seed=true (assumes a seeded DB). Re-seed +# is an explicit opt-in checkbox that warns it is slow. If skip_seed=true and the DB is +# empty, the `status` step fails fast with a clear "seed the DB first" detail. + +# CRITICAL: Postgres auto-increment does NOT reset across delete/seed. The freshly-seeded +# store/product IDs are NOT 1. The `status` step MUST discover real IDs from +# GET /dimensions/stores?page=1&page_size=1 and /dimensions/products?... — copy +# run_demo.py:470-506 verbatim. + +# CRITICAL: registry transitions are pending → running → success. You MUST PATCH the +# intermediate `running` step. pending → success is rejected. (run_demo.py:761-781) + +# CRITICAL: RunCreate uses Field(alias="model_config") — the on-the-wire JSON key is +# "model_config", not "model_config_data". (run_demo.py:739) + +# CRITICAL: aliases can ONLY point to runs in SUCCESS status — alias AFTER patch-to-success. +# (run_demo.py:784-793) + +# CRITICAL: artifact verify needs a copy. /forecasting/train writes to +# settings.forecast_model_artifacts_dir; /registry verify resolves against +# settings.registry_artifact_root. Copy the file + record a registry-relative URI. +# Replicate run_demo.py:715-731 exactly. + +# CRITICAL: agent step — skip gracefully (⏭️) when no API key matches the configured +# agent_default_model provider. Reuse the run_demo.py:317-335 _llm_key_present() logic. +# Log key PRESENCE (bool) only — NEVER the value (security-patterns.md). + +# CRITICAL: backtest expanding + n_splits=3 + horizon=14 + min_train_size=30 needs the +# seeded range ≥ 30 + 3*14 = 72 days. demo_minimal covers 2024-10-01..2024-12-31 (92d). +# demo_minimal ALREADY EXISTS (app/shared/seeder/config.py:20,608 — landed in PR #129). +# This PRP adds NO scenario. + +# CRITICAL: StepEvent / DemoRunResult are EVENT models — plain BaseModel, NO strict=True +# (mirror agents StreamEvent). Only DemoRunRequest (request body) gets +# ConfigDict(strict=True); its int/bool fields need no Field(strict=False). + +# GOTCHA: WS prefix — APIRouter(prefix="/demo") makes @router.websocket("/stream") serve +# at /demo/stream. One router file handles both POST /run and WS /stream. + +# GOTCHA: concurrency — two Showcase tabs => two pipelines => duplicate training + alias +# thrash. Guard with a module-level asyncio.Lock in service.py: if locked, POST returns +# 409 RFC 7807; WS sends one error event then closes. + +# GOTCHA: frontend WS URL — derive from VITE_API_BASE_URL: +# const DEMO_WS_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8123') +# .replace(/^http/, 'ws') + '/demo/stream' + +# GOTCHA: useWebSocket auto-reconnects. For a one-shot pipeline, call disconnect() on the +# pipeline_complete event so it does not reconnect and re-trigger. + +# GOTCHA: every commit needs an open issue (commit-format.md). Open the tracking issue +# BEFORE the first commit. No AI co-author trailer, ever. +``` + +### Known Tradeoffs (decided — do not re-litigate) +```yaml +duplication: + decision: scripts/run_demo.py is left UNTOUCHED; pipeline.py is a fresh in-process port. + why: run_demo.py just landed (PR #129) and is covered by e2e-nightly.yml. Refactoring it + to share code risks regressing a nightly-CI surface and balloons scope. The ~200 + lines of orchestration are well-understood (the whole file is the reference). Both + hit the same documented API contract + the same demo_minimal constants, so drift is + low-risk and mechanically detectable. + followup: a future PRP may converge run_demo.py onto app.features.demo.pipeline. Out of + scope here. (See Open Questions.) +transport: + decision: drive the app in-process via httpx.ASGITransport, NOT real-network localhost. + why: keeps the slice import-free of other slices (vertical-slice rule) AND validates the + real deployed contract, with no port/CORS concerns. +streaming: + decision: WebSocket (mirrors /agents/stream + reuses useWebSocket), not SSE. + why: the repo has a WS precedent + a generic WS hook; no SSE precedent. "Don't create new + patterns when existing ones work." +``` + +--- + +## Implementation Blueprint + +### Data models (`app/features/demo/schemas.py`) +```python +from __future__ import annotations +from datetime import UTC, datetime +from typing import Any, Literal +from pydantic import BaseModel, ConfigDict, Field + +StepStatus = Literal["running", "pass", "fail", "skip", "warn"] +EventType = Literal["step_start", "step_complete", "pipeline_complete", "error"] + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +class DemoRunRequest(BaseModel): + """Request body for POST /demo/run and the WS /demo/stream start frame.""" + model_config = ConfigDict(strict=True) # all fields JSON-native → no Field(strict=False) + seed: int = Field(default=42, ge=0) + reset: bool = False # wipe DB before seeding (destructive) + skip_seed: bool = True # default: assume a seeded DB (fast path; see Gotchas) + + +class StepEvent(BaseModel): + """One streamed pipeline event. Plain BaseModel — mirror agents StreamEvent. NO strict.""" + event_type: EventType + step_name: str + step_index: int # 1-based + total_steps: int + status: StepStatus | None = None # None on step_start + detail: str = "" + duration_ms: float = 0.0 + data: dict[str, Any] = Field(default_factory=dict) # winner metrics, run_id, etc. + timestamp: datetime = Field(default_factory=_utc_now) + + +class DemoRunResult(BaseModel): + """Aggregate result returned by the synchronous POST /demo/run.""" + overall_status: Literal["pass", "fail"] + steps: list[StepEvent] # the step_complete events, in order + winner_model_type: str | None = None + winner_wape: float | None = None + winning_run_id: str | None = None + alias: str | None = None + wall_clock_s: float = 0.0 +``` + +### Orchestration (`app/features/demo/pipeline.py`) +```python +# Pseudocode — full step bodies are a faithful port of scripts/run_demo.py. + +# Constants — copy from run_demo.py:66-81 (DEMO_ALIAS, DEMO_HORIZON, DEMO_MODEL_TYPES, ...) + +class _StepError(Exception): + """RFC 7807-aware typed failure — port of run_demo.py StepError (lines 155-173).""" + +class _Client: + """Thin httpx wrapper over ASGITransport — port of run_demo.py HttpClient (176-225).""" + def __init__(self, app: FastAPI) -> None: + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://demo.internal", + timeout=httpx.Timeout(120.0, connect=5.0), + ) + # async __aenter__/__aexit__ + request(step, method, path, json_body) -> dict + # non-2xx → raise _StepError with parsed problem+json title/detail/request_id + +# DemoContext — port run_demo.py:119-148 (store_id, product_id, date_start/end, +# train_results, backtest_results, winner_*, winning_run_id, session_id, ...) + +# Each step is `async def step_x(ctx, client) -> tuple[StepStatus, str, dict]` +# returning (status, human_detail, structured_data). Step bodies are verbatim ports: +# step_precheck ← run_demo.py:364-375 +# step_reset ← run_demo.py:378-401 (gated on req.reset) +# step_seed ← run_demo.py:404-443 (gated on req.skip_seed) +# step_status ← run_demo.py:446-517 (discovers REAL store/product ids) +# step_features ← run_demo.py:520-563 +# step_train ← run_demo.py:566-606 (asyncio.gather of 3 trains) +# step_backtest ← run_demo.py:609-670 (3 sequential; _select_winner :338-356) +# data={"per_model": {mt: metrics}, "winner": mt, "winner_wape": w} +# step_register ← run_demo.py:673-800 (2-step + alias + artifact copy/hash :715-731) +# data={"run_id": run_id, "alias": DEMO_ALIAS} +# step_verify ← run_demo.py:803-824 +# step_agent ← run_demo.py:827-892 (_llm_key_present :317-335 → skip if no key) +# step_cleanup ← run_demo.py:895-924 + +async def run_pipeline( + app: FastAPI, req: DemoRunRequest +) -> AsyncIterator[StepEvent]: + """Drive the 11-step pipeline; yield a step_start + step_complete per step, + then a final pipeline_complete event. Never raises — failures become fail events.""" + steps = _step_table() # [(name, fn), ...] — gate reset/seed on req flags + ctx = DemoContext(...) + wall_start = time.monotonic() + any_fail = False + async with _Client(app) as client: + for index, (name, fn) in enumerate(steps, start=1): + yield StepEvent(event_type="step_start", step_name=name, + step_index=index, total_steps=len(steps)) + t0 = time.monotonic() + try: + status, detail, data = await fn(ctx, client) + except _StepError as exc: + status, detail, data = "fail", str(exc), {} + except (httpx.HTTPError, OSError) as exc: + status, detail, data = "fail", f"transport: {exc}", {} + dur = (time.monotonic() - t0) * 1000 + yield StepEvent(event_type="step_complete", step_name=name, + step_index=index, total_steps=len(steps), + status=status, detail=detail, data=data, duration_ms=dur) + if status == "fail": + any_fail = True + break # stop on first failure (like run_demo.py:1005) + yield StepEvent( + event_type="pipeline_complete", step_name="summary", + step_index=len(steps), total_steps=len(steps), + status="fail" if any_fail else "pass", + detail=f"runs={len(ctx.backtest_results)} winner={ctx.winner_model_type} " + f"wall_clock={time.monotonic() - wall_start:.0f}s", + data={"winner_model_type": ctx.winner_model_type, + "winner_wape": ctx.winner_wape, + "winning_run_id": ctx.winning_run_id, + "alias": DEMO_ALIAS if ctx.winning_run_id else None, + "wall_clock_s": time.monotonic() - wall_start}, + ) +``` + +### Service (`app/features/demo/service.py`) +```python +import asyncio +_pipeline_lock = asyncio.Lock() # module-level — one pipeline at a time + +class PipelineBusyError(Exception): + """Raised when a pipeline run is already in progress.""" + +async def stream_pipeline(app, req) -> AsyncIterator[StepEvent]: + if _pipeline_lock.locked(): + raise PipelineBusyError("A demo pipeline run is already in progress.") + async with _pipeline_lock: + async for event in run_pipeline(app, req): + yield event + +async def run_pipeline_sync(app, req) -> DemoRunResult: + steps: list[StepEvent] = [] + final: StepEvent | None = None + async for event in stream_pipeline(app, req): # reuses the lock guard + if event.event_type == "step_complete": + steps.append(event) + elif event.event_type == "pipeline_complete": + final = event + # assemble DemoRunResult from steps + final.data +``` + +### Routes (`app/features/demo/routes.py`) +```python +router = APIRouter(prefix="/demo", tags=["demo"]) + +@router.post("/run", response_model=DemoRunResult, summary="Run the e2e demo pipeline") +async def run_demo(request: Request, params: DemoRunRequest) -> DemoRunResult: + try: + return await service.run_pipeline_sync(request.app, params) + except service.PipelineBusyError as exc: + # RFC 7807 409 — register_exception_handlers serializes HTTPException + raise HTTPException(status_code=409, detail=str(exc)) from exc + +@router.websocket("/stream") +async def stream_demo(websocket: WebSocket) -> None: + await websocket.accept() + try: + raw = await websocket.receive_json() # start frame: {seed, reset, skip_seed} + params = DemoRunRequest.model_validate(raw) + async for event in service.stream_pipeline(websocket.app, params): + await websocket.send_json(event.model_dump(mode="json")) + except service.PipelineBusyError as exc: + await websocket.send_json({"event_type": "error", "step_name": "pipeline", + "step_index": 0, "total_steps": 0, "status": "fail", + "detail": str(exc)}) + except WebSocketDisconnect: + logger.info("demo.websocket_disconnected") + finally: + await websocket.close() +``` + +### Frontend (`frontend/src/hooks/use-demo-pipeline.ts`) +```typescript +// Wraps useWebSocket. Owns: steps[] (11 entries, status-tracked), phase, summary. +// start(req): reset steps to "idle", reconnect(), send(JSON.stringify(req)). +// onMessage(StepEvent): step_start → mark step "running"; step_complete → set status + +// detail + data; pipeline_complete → store summary, set phase "done", disconnect(). +// Returns { steps, phase: 'idle'|'running'|'done'|'error', summary, start, isRunning }. +``` + +### Frontend page (`frontend/src/pages/showcase.tsx`) +```text +- Header: "End-to-End Showcase" + a short sentence on what the pipeline does. +- Controls Card: "Run pipeline" button (disabled while isRunning), a "Re-seed first" + checkbox (warns: slow; sets skip_seed=false) and a "Reset DB" checkbox (destructive). +- Steps: vertical list of — glyph (🔄/✅/❌/⏭️/⚠️), name, detail, duration. +- Backtest card: when data.per_model present, render per-model WAPE (kpi-card or a small + bar) and highlight the winner. +- Summary banner on pipeline_complete: runs / winner / wall_clock; link to /explorer/runs. +- Error/empty states via ErrorDisplay + LoadingState (mirror admin.tsx). +``` + +### list of tasks (in execution order) +```yaml +Task 1 — Open the tracking GitHub issue (REQUIRED before any commit): + RUN: gh issue create \ + --title "feat(api,ui): in-product demo showcase page" \ + --label enhancement \ + --body "Implements PRP-17. Adds an app/features/demo slice (POST /demo/run + WS + /demo/stream) that drives the e2e pipeline in-process, and a React + Showcase page that streams the run live. Builds on PRP-15 (#128)." + CAPTURE: the issue number → use in EVERY commit below. + +Task 2 — Backend slice scaffold + schemas: + CREATE app/features/demo/__init__.py — export router, run_pipeline, schemas + CREATE app/features/demo/schemas.py — DemoRunRequest, StepEvent, DemoRunResult + (see "Data models" above; NO strict on + StepEvent/DemoRunResult) + CREATE app/features/demo/tests/__init__.py + +Task 3 — Orchestration pipeline: + CREATE app/features/demo/pipeline.py + - PORT constants + StepError + DemoContext + every step from scripts/run_demo.py + (line refs in "All Needed Context"). Replace the network HttpClient with the + ASGITransport _Client. + - IMPLEMENT run_pipeline(app, req) -> AsyncIterator[StepEvent] (see pseudocode). + - Gate `reset` on req.reset and `seed` on `not req.skip_seed`. + - backtest step → data={"per_model":..., "winner":..., "winner_wape":...}. + - register step → data={"run_id":..., "alias": DEMO_ALIAS}. + - DO NOT import from any app/features/* slice. DO NOT import app.main. + +Task 4 — Service guard: + CREATE app/features/demo/service.py + - module-level asyncio.Lock; PipelineBusyError. + - stream_pipeline(app, req) — lock-guarded async generator. + - run_pipeline_sync(app, req) -> DemoRunResult — drains stream_pipeline. + +Task 5 — Routes: + CREATE app/features/demo/routes.py + - APIRouter(prefix="/demo", tags=["demo"]). + - POST /run → run_pipeline_sync; PipelineBusyError → HTTPException(409). + - WS /stream → accept, receive start frame, validate DemoRunRequest, stream events. + - Mirror app/features/agents/websocket.py for the WS handler shape. + +Task 6 — Wire into the app: + MODIFY app/main.py: + - ADD `from app.features.demo.routes import router as demo_router` with the other + feature-router imports (alphabetical block, lines 16-24). + - ADD `app.include_router(demo_router)` after `app.include_router(seeder_router)` + (line 126). + +Task 7 — Backend unit tests: + CREATE app/features/demo/tests/conftest.py — ASGITransport AsyncClient fixture + (mirror app/features/registry/tests/conftest.py) + CREATE app/features/demo/tests/test_schemas.py + - DemoRunRequest defaults (seed=42, skip_seed=True, reset=False); seed=-1 → ValidationError. + - StepEvent round-trips model_dump(mode="json") with an ISO timestamp string. + CREATE app/features/demo/tests/test_pipeline.py + - Mock _Client (unittest.mock.AsyncMock) with canned 2xx bodies for every endpoint. + - Assert run_pipeline yields step_start+step_complete for all 11 steps then + pipeline_complete; assert winner = argmin WAPE; assert a failed step stops the run. + - Assert agent step → "skip" when _llm_key_present() is False (monkeypatch get_settings). + CREATE app/features/demo/tests/test_routes.py + - POST /demo/run happy path (mock service.run_pipeline_sync) → 200 + DemoRunResult. + - Concurrent run → 409 application/problem+json (acquire the lock, then POST). + - WS /demo/stream connect → receives a step_start event (use the Starlette test + client's websocket_connect; mirror however agents WS is exercised, else assert via + the integration test only). + +Task 8 — Integration test: + CREATE tests/test_demo_showcase_integration.py + - @pytest.mark.integration. + - Reuse the `client` fixture from tests/conftest.py (ASGITransport). + - Precondition: seed demo_minimal once (POST /seeder/generate) OR assert the test DB + already has data; then POST /demo/run with {skip_seed:true, reset:false}. + - Assert: 200; overall_status == "pass"; every step status in {pass, skip}; + winner_model_type is set; GET /registry/aliases/demo-production → winning_run_id. + - Teardown: DELETE /registry/aliases/demo-production (best-effort). + +Task 9 — Frontend types + constants + routing: + MODIFY frontend/src/types/api.ts — add StepEvent, DemoRunRequest, DemoRunResult + (snake_case fields matching the Pydantic models). + MODIFY frontend/src/lib/constants.ts: + - ROUTES.SHOWCASE = '/showcase'. + - NAV_ITEMS — add { label: 'Showcase', href: ROUTES.SHOWCASE } (after Dashboard). + - DEMO_WS_URL — derived from VITE_API_BASE_URL (see Gotchas). + MODIFY frontend/src/App.tsx: + - const ShowcasePage = lazy(() => import('@/pages/showcase')). + - }/>. + +Task 10 — Frontend hook: + CREATE frontend/src/hooks/use-demo-pipeline.ts (see pseudocode — wraps useWebSocket). + MODIFY frontend/src/hooks/index.ts — export the hook. + +Task 11 — Frontend components + page: + CREATE frontend/src/components/demo/demo-step-card.tsx — one step card (glyph + name + + detail + duration); reuse Card + Badge + status vocab from .claude/rules/output-formatting.md. + CREATE frontend/src/components/demo/index.ts — barrel export. + CREATE frontend/src/pages/showcase.tsx — the page (see "Frontend page" layout). + Build with the frontend-design + shadcn-ui skills per .claude/rules/ui-design.md. + +Task 12 — Frontend test: + CREATE frontend/src/hooks/use-demo-pipeline.test.ts + - vitest: feed synthetic StepEvent messages, assert steps[] transitions + idle → running → pass and phase reaches 'done' on pipeline_complete. + +Task 13 — Docs: + MODIFY README.md — add a "Try it in the browser: open /showcase, click Run pipeline" line + near the existing demo / make demo section. + MODIFY docs/_base/API_CONTRACTS.md — add the demo slice rows (POST /demo/run, WS + /demo/stream) + a short "WebSocket Events (/demo/stream)" subsection listing the + StepEvent event_type values. + MODIFY docs/_base/RUNBOOKS.md — add a "Showcase pipeline fails at step X" incident + (skip_seed=true on an empty DB → seed first; 409 → another run in progress; + agent ⏭️ → no LLM key). + MODIFY docs/_base/REPO_MAP_INDEX.md — add rows for app/features/demo/ + the Showcase page. + +Task 14 — Dogfood the running UI (mandatory per ui-design.md): + - docker compose up -d ; uv run alembic upgrade head ; seed demo_minimal once. + - uv run uvicorn app.main:app --port 8123 & ; cd frontend && pnpm dev. + - Use the webapp-testing / agent-browser skill: open /showcase, click Run pipeline, + confirm steps stream to ✅/⏭️, confirm the summary banner + winner highlight render, + capture a screenshot. A green type-check is NOT proof the UI works. + +Task 15 — Commit + PR: + Branch: feat/demo-showcase-page (off dev, per branch-naming.md). + Commits (each referencing the Task-1 issue; no AI co-author trailer): + 1. feat(api): demo slice — pipeline + service + routes for /demo/run + /demo/stream (#N) + 2. test(api): unit + integration coverage for the demo slice (#N) + 3. feat(ui): showcase page streaming the live e2e pipeline (#N) + 4. test(ui): use-demo-pipeline hook coverage (#N) + 5. docs(docs): document the demo slice + showcase page (#N) + Open PR into dev; CI must be green; merge. +``` + +### Integration Points +```yaml +DATABASE: + - migration: NONE. The demo slice persists nothing of its own; it reads/writes only + through the existing slices' endpoints. No models.py (precedent: analytics, dimensions). +CONFIG: + - No new env var. The agent step reads existing settings (openai/anthropic/google keys) + via get_settings(); the seed step's prod-guard is enforced by /seeder/generate itself. +ROUTES (app/main.py): + - import: `from app.features.demo.routes import router as demo_router` + - wire: `app.include_router(demo_router)` (after seeder_router, main.py:126) +FRONTEND ROUTING: + - ROUTES.SHOWCASE + NAV_ITEMS entry (constants.ts); lazy in App.tsx. +CI: + - No new workflow. Existing ci.yml (lint/typecheck/test/migration-check) covers it. + The integration test runs in ci.yml's `test` job (Postgres service already present). +``` + +--- + +## Validation Loop + +### Level 1: Syntax & Style +```bash +uv run ruff check . --fix +uv run ruff format . +uv run mypy app/ # strict — pipeline.py/service.py/routes.py must pass +uv run pyright app/ # strict +# Expected: zero errors. pipeline.py is under app/ → no print(), full annotations. +``` + +### Level 2: Backend unit tests (no DB) +```bash +uv run pytest -v -m "not integration" app/features/demo/ app/core/tests/test_strict_mode_policy.py +# Expected: all green. test_strict_mode_policy MUST still pass — it proves StepEvent did +# not accidentally get ConfigDict(strict=True) with a bare datetime field. +``` + +### Level 3: Backend integration test (real DB + in-process app) +```bash +docker compose up -d +uv run alembic upgrade head +uv run python scripts/seed_random.py --full-new --seed 42 --confirm # seed once +uv run pytest -v -m integration tests/test_demo_showcase_integration.py +# Expected: PASS — overall_status == "pass", winner set, demo-production alias points to it. +``` + +### Level 4: Frontend gates +```bash +cd frontend +pnpm install +pnpm tsc --noEmit +pnpm lint +pnpm test --run +# Expected: clean. use-demo-pipeline.test.ts green. +``` + +### Level 5: Manual end-to-end (the maintainer's actual UX) +```bash +docker compose up -d && uv run alembic upgrade head +uv run uvicorn app.main:app --port 8123 & +until curl -fs http://127.0.0.1:8123/health; do sleep 2; done +cd frontend && pnpm dev # http://localhost:5173 +# Browser: open /showcase → click "Run pipeline". +# Expected: 11 step cards stream 🔄 → ✅ (agent ⏭️ if no LLM key); a summary banner +# shows "runs=3 winner= wall_clock=s"; the backtest card highlights the winner. +# Cross-check: GET http://localhost:8123/registry/aliases/demo-production returns the run_id. +``` + +--- + +## Final Validation Checklist +- [ ] `uv run ruff check . && uv run ruff format --check .` clean +- [ ] `uv run mypy app/` clean (strict) — including `app/features/demo/` +- [ ] `uv run pyright app/` clean (strict) +- [ ] `uv run pytest -v -m "not integration"` all green (incl. test_strict_mode_policy) +- [ ] `uv run pytest -v -m integration tests/test_demo_showcase_integration.py` green +- [ ] `cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run` all clean +- [ ] `POST /demo/run` returns 200 + `DemoRunResult` on a seeded DB; concurrent call → 409 +- [ ] WS `/demo/stream` streams `StepEvent`s; the page renders them live +- [ ] Manual `/showcase` run dogfooded in a real browser (webapp-testing / agent-browser); + screenshot captured +- [ ] `GET /registry/aliases/demo-production` returns the winning `run_id` after a run +- [ ] No Alembic migration added; no new `.env`/`.env.example` var +- [ ] `scripts/run_demo.py` and `examples/e2e_smoke.sh` untouched (regression check) +- [ ] `app/features/demo/` contains no `import app.features.` and no `import app.main` +- [ ] README + API_CONTRACTS + RUNBOOKS + REPO_MAP_INDEX updated +- [ ] Branch `feat/demo-showcase-page`; every commit references the Task-1 issue; no AI + co-author trailer + +--- + +## Anti-Patterns to Avoid +- ❌ Don't import other `app/features/*` slices into `app/features/demo/` — drive them over + HTTP via ASGITransport. This is the load-bearing architectural rule. +- ❌ Don't `import app.main` in the demo slice — circular import. Use `request.app` / + `websocket.app`. +- ❌ Don't put `ConfigDict(strict=True)` on `StepEvent` / `DemoRunResult` — they are event + models; mirror agents `StreamEvent`. (A bare `datetime` under a `strict=True` model fails + `test_strict_mode_policy.py`.) +- ❌ Don't refactor `scripts/run_demo.py` — it is a nightly-CI surface; pipeline.py is a + separate in-process port (see Known Tradeoffs). +- ❌ Don't skip the `pending → running → success` registry transition. +- ❌ Don't default the Showcase to re-seeding — the seed step blocks the event loop in-process; + default `skip_seed=true`, make re-seed an explicit opt-in. +- ❌ Don't log LLM API key values — log presence (bool) only. +- ❌ Don't add LightGBM to the demo — it's feature-flagged off; Phase-2 LightGBM is PRP-16. +- ❌ Don't hand-roll the page UI — use the `frontend-design` + `shadcn-ui` skills, dogfood + with `webapp-testing` (per `.claude/rules/ui-design.md`). +- ❌ Don't claim the UI works on a green type-check alone — exercise it in a real browser. +- ❌ Don't `git push --force` on dev/main; don't add AI co-author trailers. + +--- + +## Open Questions for the Maintainer (max 3) +1. **Run history** — should `/demo/run` persist a row per run (a `demo_run` table) so the + Showcase page can show "last run: 3m ago, green"? This PRP keeps it stateless (no + migration). Persisting it is a clean follow-up if you want run history. +2. **Convergence** — do you want a follow-up PRP to converge `scripts/run_demo.py` onto + `app.features.demo.pipeline` (single-source the orchestration)? This PRP deliberately + leaves them separate to de-risk. +3. **Re-seed default** — confirm the Showcase should default to `skip_seed=true` (fast, + assumes a seeded DB). The alternative — always re-seed — is slower and briefly stalls + the event loop in-process. + +--- + +## Confidence Score + +**8 / 10** for one-pass implementation success. + +**Why high:** +- The orchestration is not novel — it is a line-referenced port of `scripts/run_demo.py` + (the entire file is reproduced in this PRP's context). Every endpoint payload is proven. +- `demo_minimal` already exists; no scenario/seeder work, no migration, no new env var. +- The streaming pattern, the WS hook, the slice layout, and the ASGITransport client all + have direct in-repo precedents cited with file+line. +- Validation gates are concrete and executable; the strict-mode invariant test guards the + one subtle Pydantic gotcha. + +**Why not 10:** +- The in-process WS handler running a CPU-heavy seed step is a real (if accepted) wrinkle; + the `skip_seed=true` default mitigates it but the re-seed path may need a tuned timeout. +- WebSocket route-testing with the Starlette test client can be fiddly; the integration + test is the firmer net and the unit WS test may need a light touch. +- The frontend page is genuine UI work — the live-streaming step list + winner highlight + needs a browser dogfooding pass (Task 14) to be truly done; type-check alone won't catch + a layout or event-wiring bug. + +All three failure modes are caught deterministically by the validation loop and the fixes +are local (timeout tuning, test-client shape, UI iteration via webapp-testing). diff --git a/README.md b/README.md index 1dbe35ed..ff7b7c46 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ docker-compose up -d 3. **Install dependencies** ```bash -uv sync +uv sync --extra dev # or: pip install -e ".[dev]" ``` @@ -68,6 +68,28 @@ curl http://localhost:8123/health # Response: {"status":"ok"} ``` +### Try it: end-to-end demo + +Once steps 1-7 are green, run the full demo pipeline with a single command: + +```bash +make demo +``` + +This drives `seed -> features -> train x 3 -> backtest -> register -> alias -> agent` +against the running API in ~90-180 s and emits a final line like: + +``` +runs=3 winner=seasonal_naive alias=demo-production wall_clock=87s +``` + +See `scripts/run_demo.py` for the contract and `make help` for the +related targets (`demo-quick` skips re-seeding; `demo-clean` wipes the DB first). + +**Try it in the browser:** with the backend and frontend running, open +[`/showcase`](http://localhost:5173/showcase) and click **Run pipeline** — the +same end-to-end flow streams live into the dashboard as status cards (no CLI). + ### Frontend Setup 8. **Install frontend dependencies** @@ -690,6 +712,15 @@ uv run python scripts/seed_random.py --verify See [examples/seed/README.md](examples/seed/README.md) for detailed configuration options. +### Demo Pipeline + +Drives the end-to-end pipeline (`seed → features → train ×3 → backtest → register → alias → agent`) in-process and powers the dashboard Showcase page. + +- `POST /demo/run` - Run the full pipeline in-process; returns a `DemoRunResult`. Returns `409 application/problem+json` if a run is already active. +- `WS /demo/stream` - Stream one `StepEvent` per pipeline step for the live Showcase page. + +Only one demo pipeline runs at a time (module-level lock). See [docs/_base/API_CONTRACTS.md](docs/_base/API_CONTRACTS.md) for the full `StepEvent` schema, and the [`/showcase`](http://localhost:5173/showcase) page for the browser view. + ### Error Responses (RFC 7807) All error responses follow RFC 7807 Problem Details format with `Content-Type: application/problem+json`: diff --git a/app/features/demo/__init__.py b/app/features/demo/__init__.py new file mode 100644 index 00000000..6dde4968 --- /dev/null +++ b/app/features/demo/__init__.py @@ -0,0 +1,19 @@ +"""Demo showcase slice. + +Drives the end-to-end forecasting pipeline in-process (via ``httpx.ASGITransport``) +and streams per-step progress, powering the dashboard's Showcase page. This slice +has no database table of its own -- it reads and writes only through the other +slices' HTTP endpoints (precedent: ``analytics``, ``dimensions``). +""" + +from app.features.demo.pipeline import run_pipeline +from app.features.demo.routes import router +from app.features.demo.schemas import DemoRunRequest, DemoRunResult, StepEvent + +__all__ = [ + "DemoRunRequest", + "DemoRunResult", + "StepEvent", + "router", + "run_pipeline", +] diff --git a/app/features/demo/pipeline.py b/app/features/demo/pipeline.py new file mode 100644 index 00000000..4302565f --- /dev/null +++ b/app/features/demo/pipeline.py @@ -0,0 +1,766 @@ +"""End-to-end demo pipeline orchestrator (in-process). + +Drives the published FastAPI surface as a black-box HTTP consumer via +``httpx.ASGITransport`` -- the same in-process transport the test suite uses +(``tests/conftest.py``). This keeps the ``demo`` slice import-free of every +other ``app/features/*`` slice (vertical-slice rule) while still exercising +the real deployed HTTP contract. + +The 11-step flow is a faithful port of ``scripts/run_demo.py`` (PR #129): + + precheck -> (reset) -> (seed) -> status -> features + -> train x 3 (parallel) -> backtest x 3 (sequential) + -> register-winner -> verify -> agent -> cleanup + +``reset`` and ``seed`` emit a ``skip`` outcome when not requested, so the step +table is always 11 entries (stable card count for the Showcase UI). + +CRITICAL: this module must NOT import ``app.main`` (circular import) nor any +``app.features.*`` slice. Importing ``app.core.*`` is allowed. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import math +import shutil +import time +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass, field +from datetime import date, timedelta +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI + +from app.core.config import get_settings +from app.core.logging import get_logger +from app.features.demo.schemas import DemoRunRequest, StepEvent, StepStatus + +logger = get_logger(__name__) + +# ============================================================================= +# Constants (ported from scripts/run_demo.py:58-81) +# ============================================================================= + +DEMO_ALIAS = "demo-production" +DEMO_HORIZON = 14 +DEMO_BACKTEST_SPLITS = 3 +DEMO_MIN_TRAIN_SIZE = 30 +DEMO_FEATURESET_LOOKBACK_DAYS = 60 + +DEMO_SCENARIO = "demo_minimal" +DEMO_SEED_STORES = 3 +DEMO_SEED_PRODUCTS = 10 +DEMO_SEED_START = date(2024, 10, 1) +DEMO_SEED_END = date(2024, 12, 31) + +DEMO_MODEL_TYPES: tuple[str, ...] = ("naive", "seasonal_naive", "moving_average") + +# Per-step HTTP timeout. /seeder/generate on demo_minimal is slow; 120 s leaves +# margin. connect=5 s because the ASGI transport connects instantly. +_HTTP_TIMEOUT = httpx.Timeout(120.0, connect=5.0) + + +# ============================================================================= +# HTTP client + RFC 7807 surfacing +# ============================================================================= + + +class _StepError(Exception): + """Surfaces a non-2xx HTTP response as an RFC 7807-aware typed failure. + + Echoes ``title`` / ``detail`` / ``request_id`` from the parsed problem+json + body; never echoes raw bodies that might contain secrets. Port of + ``scripts/run_demo.py:StepError``. + """ + + def __init__(self, step: str, status_code: int, problem: dict[str, Any]) -> None: + self.step = step + self.status_code = status_code + self.problem = problem + super().__init__(self._format()) + + def _format(self) -> str: + title = self.problem.get("title", "?") + detail = self.problem.get("detail", "?") + rid = self.problem.get("request_id", "?") + return f"{self.step}: HTTP {self.status_code} -- {title}: {detail} (request_id={rid})" + + +class _Client: + """Thin ``httpx.AsyncClient`` wrapper over an in-process ASGI transport. + + ``base_url`` is cosmetic -- ``ASGITransport`` routes straight to the app, so + no network, port, or CORS is involved. All non-2xx responses raise + :class:`_StepError` with the parsed RFC 7807 body. + """ + + def __init__(self, app: FastAPI) -> None: + self._client = httpx.AsyncClient( + # raise_app_exceptions=False makes the in-process transport behave + # like a real network client: an unhandled error inside a driven + # endpoint surfaces as a 500 *response* (RFC 7807) rather than a + # re-raised exception, so steps can handle it as a normal _StepError. + transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), + base_url="http://demo.internal", + timeout=_HTTP_TIMEOUT, + ) + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_exc: object) -> None: + await self._client.aclose() + + async def request( + self, + step: str, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Issue one in-process HTTP request; surface non-2xx as :class:`_StepError`.""" + kwargs: dict[str, Any] = {} + if json_body is not None: + kwargs["json"] = json_body + response = await self._client.request(method, path, **kwargs) + if response.status_code >= 400: + problem: dict[str, Any] + try: + parsed = response.json() + problem = ( + parsed + if isinstance(parsed, dict) + else {"title": "Non-dict body", "detail": str(parsed)[:200]} + ) + except (json.JSONDecodeError, ValueError): + problem = {"title": "Non-JSON error", "detail": response.text[:200]} + raise _StepError(step, response.status_code, problem) + if response.status_code == 204: + return {} + body = response.json() + return body if isinstance(body, dict) else {"_raw": body} + + +# ============================================================================= +# Cross-step accumulator +# ============================================================================= + + +@dataclass +class DemoContext: + """Accumulator threaded through every step. + + Holds cross-step references (real store/product ids, train results, the + backtest winner) so later steps reuse earlier outputs. Port of + ``scripts/run_demo.py:DemoContext``. + """ + + seed: int + skip_seed: bool + reset: bool + store_id: int = 1 + product_id: int = 1 + date_start: date | None = None + date_end: date | None = None + seed_records: dict[str, int] = field(default_factory=dict) + feature_row_count: int = 0 + train_results: dict[str, dict[str, Any]] = field(default_factory=dict) + backtest_results: dict[str, dict[str, float]] = field(default_factory=dict) + winner_model_type: str | None = None + winner_wape: float | None = None + winning_run_id: str | None = None + session_id: str | None = None + + +# ============================================================================= +# Helpers shared across steps +# ============================================================================= + + +def _model_config_payload(model_type: str) -> dict[str, Any]: + """Build the ``ModelConfig`` body for a baseline ``model_type``. + + Each shape matches one branch of the discriminated union in + ``app/features/forecasting/schemas.py`` (port of run_demo.py:301-314). + """ + if model_type == "naive": + return {"model_type": "naive"} + if model_type == "seasonal_naive": + return {"model_type": "seasonal_naive", "season_length": 7} + if model_type == "moving_average": + return {"model_type": "moving_average", "window_size": 7} + raise ValueError(f"Unsupported demo model_type: {model_type}") + + +def _llm_key_present() -> bool: + """Return True when the configured agent model's provider API key is set. + + Matches the provider prefix of ``agent_default_model`` so the agent step + skips gracefully when its provider is unreachable. Logs key PRESENCE only, + never the value (port of run_demo.py:317-335; see security-patterns.md). + """ + settings = get_settings() + model = settings.agent_default_model + provider = model.split(":", 1)[0] if ":" in model else "" + if provider == "anthropic": + return bool(settings.anthropic_api_key) + if provider == "openai": + return bool(settings.openai_api_key) + if provider in ("google-gla", "google-vertex"): + return bool(settings.google_api_key) + return False + + +def _select_winner( + backtest_results: dict[str, dict[str, float]], +) -> tuple[str, float] | None: + """Pick the ``(model_type, WAPE)`` with the lowest aggregated WAPE. + + Skips models whose WAPE is missing or NaN (port of run_demo.py:338-356). + """ + best: tuple[str, float] | None = None + for model_type, metrics in backtest_results.items(): + wape = metrics.get("wape") + if wape is None or math.isnan(wape): + continue + if best is None or wape < best[1]: + best = (model_type, wape) + return best + + +# ============================================================================= +# Steps -- each returns (status, human-detail, structured-data) +# ============================================================================= + +StepResult = tuple[StepStatus, str, dict[str, Any]] + + +async def step_precheck(_ctx: DemoContext, client: _Client) -> StepResult: + """GET /health -- liveness precondition.""" + body = await client.request("precheck", "GET", "/health") + status_field = body.get("status", "") + detail = f"/health -> {status_field or 'unknown'}" + return ("pass" if status_field == "ok" else "fail", detail, {}) + + +async def step_reset(ctx: DemoContext, client: _Client) -> StepResult: + """Wipe the database if ``reset`` was requested; skip otherwise.""" + if not ctx.reset: + return ("skip", "reset not requested", {}) + body = await client.request( + "reset", + "DELETE", + "/seeder/data", + json_body={"scope": "all", "dry_run": False}, + ) + deleted: dict[str, Any] = body.get("records_deleted", {}) + total = sum(v for v in deleted.values() if isinstance(v, int)) + return ( + "pass", + f"deleted {total} rows across {len(deleted)} tables", + {"records_deleted": deleted}, + ) + + +async def step_seed(ctx: DemoContext, client: _Client) -> StepResult: + """Seed the ``demo_minimal`` scenario (skipped when ``skip_seed`` is set).""" + if ctx.skip_seed: + return ("skip", "skip_seed=true (assuming a seeded database)", {}) + body = await client.request( + "seed", + "POST", + "/seeder/generate", + json_body={ + "scenario": DEMO_SCENARIO, + "seed": ctx.seed, + "stores": DEMO_SEED_STORES, + "products": DEMO_SEED_PRODUCTS, + "start_date": DEMO_SEED_START.isoformat(), + "end_date": DEMO_SEED_END.isoformat(), + "sparsity": 0.0, + "dry_run": False, + }, + ) + raw_records: dict[str, Any] = body.get("records_created", {}) + records = {k: int(v) for k, v in raw_records.items() if isinstance(v, int)} + ctx.seed_records = records + # GenerateResult.records_created uses "sales" (singular), not "sales_daily". + sales = records.get("sales", records.get("sales_daily", 0)) + return ( + "pass", + f"{DEMO_SCENARIO}: {DEMO_SEED_STORES} stores x {DEMO_SEED_PRODUCTS} products, " + f"{sales} sales rows", + {"records_created": records}, + ) + + +async def step_status(ctx: DemoContext, client: _Client) -> StepResult: + """GET /seeder/status + /dimensions/* -- capture the date range and real ids. + + Postgres auto-increment does NOT reset across delete/seed cycles, so the + seeded store/product ids are not 1. The first available pair is discovered + from the dimensions endpoints (port of run_demo.py:446-517). + """ + body = await client.request("status", "GET", "/seeder/status") + raw_start = body.get("date_range_start") + raw_end = body.get("date_range_end") + if not isinstance(raw_start, str) or not isinstance(raw_end, str): + return ("fail", "no date_range in /seeder/status -- seed the database first", {}) + ctx.date_start = date.fromisoformat(raw_start) + ctx.date_end = date.fromisoformat(raw_end) + + stores_body = await client.request( + "status[stores]", "GET", "/dimensions/stores?page=1&page_size=1" + ) + products_body = await client.request( + "status[products]", "GET", "/dimensions/products?page=1&page_size=1" + ) + stores_raw = stores_body.get("stores", []) + products_raw = products_body.get("products", []) + stores = stores_raw if isinstance(stores_raw, list) else [] + products = products_raw if isinstance(products_raw, list) else [] + if not stores or not products: + return ("fail", "no stores or products after seed", {}) + first_store = stores[0] + first_product = products[0] + if not isinstance(first_store, dict) or not isinstance(first_product, dict): + return ("fail", "dimensions returned non-dict items", {}) + store_id_raw = first_store.get("id") + product_id_raw = first_product.get("id") + if not isinstance(store_id_raw, int) or not isinstance(product_id_raw, int): + return ("fail", "dimension ids missing or non-int", {}) + ctx.store_id = store_id_raw + ctx.product_id = product_id_raw + + sales = body.get("sales", 0) + return ( + "pass", + f"date_range={raw_start}..{raw_end} sales={sales} " + f"store_id={ctx.store_id} product_id={ctx.product_id}", + { + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "date_range_start": raw_start, + "date_range_end": raw_end, + }, + ) + + +async def step_features(ctx: DemoContext, client: _Client) -> StepResult: + """Compute a small lag/rolling/calendar featureset for one series.""" + if ctx.date_end is None: + return ("fail", "no date_end on ctx -- status step did not populate it", {}) + body = await client.request( + "features", + "POST", + "/featuresets/compute", + json_body={ + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "cutoff_date": ctx.date_end.isoformat(), + "lookback_days": DEMO_FEATURESET_LOOKBACK_DAYS, + "config": { + "name": "demo_featureset", + "lag_config": {"lags": [1, 7, 14]}, + "rolling_config": { + "windows": [7, 14], + "aggregations": ["mean", "std"], + }, + "calendar_config": {}, + }, + }, + ) + rows = int(body.get("row_count", 0)) + ctx.feature_row_count = rows + columns = body.get("feature_columns", []) + column_count = len(columns) if isinstance(columns, list) else 0 + return ( + "pass", + f"{rows} rows, {column_count} columns (lag+rolling+calendar)", + {"row_count": rows, "column_count": column_count}, + ) + + +async def step_train(ctx: DemoContext, client: _Client) -> StepResult: + """Train naive / seasonal_naive / moving_average in parallel.""" + if ctx.date_start is None or ctx.date_end is None: + return ("fail", "no date range on ctx", {}) + + # Leave a horizon-sized tail unused by training so the backtest has room. + train_start = ctx.date_start + train_end = ctx.date_end - timedelta(days=DEMO_HORIZON) + + async def _train(model_type: str) -> tuple[str, dict[str, Any]]: + train_body = await client.request( + f"train[{model_type}]", + "POST", + "/forecasting/train", + json_body={ + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "train_start_date": train_start.isoformat(), + "train_end_date": train_end.isoformat(), + "config": _model_config_payload(model_type), + }, + ) + return model_type, train_body + + results: list[tuple[str, dict[str, Any]]] = list( + await asyncio.gather(*(_train(m) for m in DEMO_MODEL_TYPES)) + ) + for model_type, train_body in results: + ctx.train_results[model_type] = train_body + trained = ", ".join(ctx.train_results.keys()) + return ( + "pass", + f"trained {len(ctx.train_results)} models in parallel: {trained}", + {"trained": list(ctx.train_results.keys())}, + ) + + +async def step_backtest(ctx: DemoContext, client: _Client) -> StepResult: + """Run one backtest per model_type sequentially; pick the lowest-WAPE winner.""" + if ctx.date_start is None or ctx.date_end is None: + return ("fail", "no date range on ctx", {}) + + for model_type in DEMO_MODEL_TYPES: + body = await client.request( + f"backtest[{model_type}]", + "POST", + "/backtesting/run", + json_body={ + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "start_date": ctx.date_start.isoformat(), + "end_date": ctx.date_end.isoformat(), + "config": { + "split_config": { + "strategy": "expanding", + "n_splits": DEMO_BACKTEST_SPLITS, + "min_train_size": DEMO_MIN_TRAIN_SIZE, + "gap": 0, + "horizon": DEMO_HORIZON, + }, + "model_config_main": _model_config_payload(model_type), + "include_baselines": False, + "store_fold_details": False, + }, + }, + ) + main_results = body.get("main_model_results", {}) + aggregated = ( + main_results.get("aggregated_metrics", {}) if isinstance(main_results, dict) else {} + ) + clean: dict[str, float] = {} + if isinstance(aggregated, dict): + for k, v in aggregated.items(): + if isinstance(v, (int, float)): + clean[str(k)] = float(v) + ctx.backtest_results[model_type] = clean + + winner = _select_winner(ctx.backtest_results) + if winner is None: + return ("fail", "no model produced a usable WAPE (all NaN?)", {}) + ctx.winner_model_type, ctx.winner_wape = winner + return ( + "pass", + f"{len(ctx.backtest_results)} models, winner={ctx.winner_model_type} " + f"wape={ctx.winner_wape:.4f}", + { + "per_model": dict(ctx.backtest_results), + "winner": ctx.winner_model_type, + "winner_wape": ctx.winner_wape, + }, + ) + + +async def step_register(ctx: DemoContext, client: _Client) -> StepResult: + """Two-step registry create+update; alias the winner as ``demo-production``. + + Mandatory transition: pending -> running -> success. Aliases can only point + to runs in SUCCESS status. The trained artifact is copied into the registry + artifact root and hashed (port of run_demo.py:673-800). + """ + if ctx.winner_model_type is None: + return ("fail", "no winner; cannot register", {}) + if ctx.date_start is None or ctx.date_end is None: + return ("fail", "no date range on ctx", {}) + winner = ctx.winner_model_type + date_start = ctx.date_start + date_end = ctx.date_end + + train_response = ctx.train_results.get(winner, {}) + model_path_raw = train_response.get("model_path") + if not isinstance(model_path_raw, str) or not model_path_raw: + return ("fail", f"no model_path for winner {winner}", {}) + source_model = Path(model_path_raw) + if not source_model.exists(): + return ("fail", f"artifact missing at {source_model}", {}) + + # /forecasting/train writes under settings.forecast_model_artifacts_dir; + # /registry verify resolves artifact_uri against settings.registry_artifact_root. + # Copy the trained model into the registry root and record a registry-relative + # URI to close the loop (run_demo.py:715-731). + settings = get_settings() + registry_root = Path(settings.registry_artifact_root).resolve() + registry_root.mkdir(parents=True, exist_ok=True) + artifact_uri = f"demo/{winner}-{source_model.stem}.joblib" + dest_path = registry_root / artifact_uri + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_model, dest_path) + artifact_bytes = dest_path.read_bytes() + artifact_hash = hashlib.sha256(artifact_bytes).hexdigest() + artifact_size = len(artifact_bytes) + + # (a) Create the run in PENDING status. On-wire JSON key is "model_config" + # (alias of model_config_data per registry/schemas.py). + create_body = await client.request( + "register[create]", + "POST", + "/registry/runs", + json_body={ + "model_type": winner, + "model_config": _model_config_payload(winner), + "feature_config": None, + "data_window_start": date_start.isoformat(), + "data_window_end": date_end.isoformat(), + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "agent_context": None, + "git_sha": None, + }, + ) + run_id_raw = create_body.get("run_id") + if not isinstance(run_id_raw, str): + return ("fail", "POST /registry/runs returned no run_id", {}) + ctx.winning_run_id = run_id_raw + + # (b) PATCH pending -> running (mandatory intermediate transition). + await client.request( + "register[running]", + "PATCH", + f"/registry/runs/{run_id_raw}", + json_body={"status": "running"}, + ) + + # (c) PATCH running -> success with metrics + artifact info. + await client.request( + "register[success]", + "PATCH", + f"/registry/runs/{run_id_raw}", + json_body={ + "status": "success", + "metrics": ctx.backtest_results[winner], + "artifact_uri": artifact_uri, + "artifact_hash": artifact_hash, + "artifact_size_bytes": artifact_size, + }, + ) + + # (d) Alias the winner (only allowed on a SUCCESS run). + await client.request( + "register[alias]", + "POST", + "/registry/aliases", + json_body={ + "alias_name": DEMO_ALIAS, + "run_id": run_id_raw, + "description": "Auto-created by the demo showcase pipeline.", + }, + ) + + return ( + "pass", + f"run_id={run_id_raw[:8]}... alias={DEMO_ALIAS}", + {"run_id": run_id_raw, "alias": DEMO_ALIAS}, + ) + + +async def step_verify(ctx: DemoContext, client: _Client) -> StepResult: + """SHA-256 artifact-integrity check via the public verify endpoint.""" + if ctx.winning_run_id is None: + return ("fail", "no winning_run_id to verify", {}) + body = await client.request( + "verify", + "GET", + f"/registry/runs/{ctx.winning_run_id}/verify", + ) + verified = body.get("verified") is True + return ( + "pass" if verified else "fail", + "sha256 OK" if verified else f"verify={body.get('verified')}", + {"verified": verified}, + ) + + +async def step_agent(ctx: DemoContext, client: _Client) -> StepResult: + """One-turn chat with the ``experiment`` agent (skipped without an LLM key). + + Skips gracefully when the configured agent model has no matching API key, or + when the round-trip raises a provider error -- a broken key must not mask an + otherwise-green pipeline (port of run_demo.py:827-892). + """ + key_present = _llm_key_present() + logger.info("demo.agent_key_present", present=key_present) + if not key_present: + return ("skip", "no API key matching agent_default_model provider", {}) + + try: + create_body = await client.request( + "agent[session]", + "POST", + "/agents/sessions", + json_body={"agent_type": "experiment", "initial_context": None}, + ) + except _StepError as exc: + return ("skip", f"session-create failed: {exc}", {}) + session_id_raw = create_body.get("session_id") + if not isinstance(session_id_raw, str): + return ("skip", "no session_id returned", {}) + ctx.session_id = session_id_raw + + try: + chat_body = await client.request( + "agent[chat]", + "POST", + f"/agents/sessions/{session_id_raw}/chat", + json_body={"message": "List the latest model runs.", "stream": False}, + ) + except _StepError as exc: + return ("skip", f"chat round-trip failed: {exc}", {}) + tokens = int(chat_body.get("tokens_used", 0)) + tool_calls = chat_body.get("tool_calls", []) + tool_count = len(tool_calls) if isinstance(tool_calls, list) else 0 + return ( + "pass", + f"chat ok (tokens={tokens}, tool_calls={tool_count})", + {"tokens_used": tokens, "tool_calls_count": tool_count}, + ) + + +async def step_cleanup(ctx: DemoContext, client: _Client) -> StepResult: + """Close the agent session (no-op if no session was opened).""" + if ctx.session_id is None: + return ("skip", "no agent session to close", {}) + try: + await client.request("cleanup", "DELETE", f"/agents/sessions/{ctx.session_id}") + except _StepError as exc: + # Cleanup failure is non-fatal -- warn so the run still goes green. + return ("warn", f"DELETE failed but ignored: {exc}", {}) + return ("pass", "agent session closed", {}) + + +# ============================================================================= +# Orchestration +# ============================================================================= + +StepFn = Callable[[DemoContext, _Client], Awaitable[StepResult]] + + +def _step_table() -> list[tuple[str, StepFn]]: + """Return the ordered 11-step table (name, callable).""" + return [ + ("precheck", step_precheck), + ("reset", step_reset), + ("seed", step_seed), + ("status", step_status), + ("features", step_features), + ("train", step_train), + ("backtest", step_backtest), + ("register", step_register), + ("verify", step_verify), + ("agent", step_agent), + ("cleanup", step_cleanup), + ] + + +async def run_pipeline(app: FastAPI, req: DemoRunRequest) -> AsyncIterator[StepEvent]: + """Drive the 11-step pipeline; yield one step_start + step_complete per step. + + A final ``pipeline_complete`` event always follows. Never raises -- step + failures become ``fail`` events and stop the run after the failing step. + + Args: + app: The live FastAPI application (driven in-process via ASGITransport). + req: Run parameters (seed, reset, skip_seed). + + Yields: + StepEvent instances, in execution order. + """ + steps = _step_table() + total = len(steps) + ctx = DemoContext(seed=req.seed, skip_seed=req.skip_seed, reset=req.reset) + wall_start = time.monotonic() + any_fail = False + + async with _Client(app) as client: + for index, (name, fn) in enumerate(steps, start=1): + yield StepEvent( + event_type="step_start", + step_name=name, + step_index=index, + total_steps=total, + ) + t0 = time.monotonic() + status: StepStatus + detail: str + data: dict[str, Any] + try: + status, detail, data = await fn(ctx, client) + except _StepError as exc: + status, detail, data = "fail", str(exc), {} + except (httpx.HTTPError, OSError) as exc: + status, detail, data = ( + "fail", + f"transport error: {type(exc).__name__}: {exc}", + {}, + ) + except Exception as exc: + # The orchestrator must never raise -- any unexpected error + # from a step becomes a fail event so a pipeline_complete is + # always emitted (see this function's contract). + status, detail, data = ( + "fail", + f"unexpected error: {type(exc).__name__}: {exc}", + {}, + ) + duration_ms = (time.monotonic() - t0) * 1000 + yield StepEvent( + event_type="step_complete", + step_name=name, + step_index=index, + total_steps=total, + status=status, + detail=detail, + data=data, + duration_ms=duration_ms, + ) + if status == "fail": + any_fail = True + break + + wall = time.monotonic() - wall_start + yield StepEvent( + event_type="pipeline_complete", + step_name="summary", + step_index=total, + total_steps=total, + status="fail" if any_fail else "pass", + detail=( + f"runs={len(ctx.backtest_results)} " + f"winner={ctx.winner_model_type or 'n/a'} wall_clock={wall:.0f}s" + ), + data={ + "winner_model_type": ctx.winner_model_type, + "winner_wape": ctx.winner_wape, + "winning_run_id": ctx.winning_run_id, + "alias": DEMO_ALIAS if ctx.winning_run_id else None, + "wall_clock_s": wall, + }, + ) diff --git a/app/features/demo/routes.py b/app/features/demo/routes.py new file mode 100644 index 00000000..660df652 --- /dev/null +++ b/app/features/demo/routes.py @@ -0,0 +1,97 @@ +"""FastAPI routes for the demo showcase slice. + +Exposes: +- ``POST /demo/run`` -- synchronous; runs the whole pipeline, returns a result. +- ``WS /demo/stream`` -- streams one StepEvent per step for the live UI. + +Both obtain the live FastAPI app from ``request.app`` / ``websocket.app`` and +pass it into the pipeline -- the slice never imports ``app.main`` (circular). +""" + +from __future__ import annotations + +import json + +from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect +from pydantic import ValidationError + +from app.core.exceptions import ConflictError +from app.core.logging import get_logger +from app.features.demo import service +from app.features.demo.schemas import DemoRunRequest, DemoRunResult, StepEvent + +logger = get_logger(__name__) + +router = APIRouter(prefix="/demo", tags=["demo"]) + + +@router.post( + "/run", + response_model=DemoRunResult, + summary="Run the end-to-end demo pipeline", + description=( + "Drives the full e2e pipeline (seed -> features -> train x3 -> " + "backtest x3 -> register -> verify -> agent) in-process and returns " + "every step outcome. Returns 409 if a pipeline run is already active." + ), +) +async def run_demo_pipeline(request: Request, params: DemoRunRequest) -> DemoRunResult: + """Run the demo pipeline synchronously and return the aggregate result. + + Args: + request: The incoming request (used to obtain the live FastAPI app). + params: Run parameters (seed, reset, skip_seed). + + Returns: + The aggregate :class:`DemoRunResult`. + + Raises: + ConflictError: If another pipeline run is already in progress (409). + """ + try: + return await service.run_pipeline_sync(request.app, params) + except service.PipelineBusyError as exc: + raise ConflictError(str(exc)) from exc + + +@router.websocket("/stream") +async def stream_demo_pipeline(websocket: WebSocket) -> None: + """Stream one StepEvent per pipeline step over a WebSocket. + + Protocol: + 1. Client connects and sends one start frame: ``{"seed", "reset", "skip_seed"}`` + (all fields optional -- the request model supplies defaults). + 2. Server streams ``step_start`` / ``step_complete`` events, then a final + ``pipeline_complete`` event, and closes. + 3. On a bad start frame or a busy pipeline, the server sends one ``error`` + event and closes. + """ + await websocket.accept() + logger.info("demo.websocket_connected") + try: + raw = await websocket.receive_json() + params = DemoRunRequest.model_validate(raw) + async for event in service.stream_pipeline(websocket.app, params): + await websocket.send_json(event.model_dump(mode="json")) + except WebSocketDisconnect: + logger.info("demo.websocket_disconnected") + return + except (ValidationError, json.JSONDecodeError) as exc: + await websocket.send_json( + _error_event(f"invalid start frame: {exc}").model_dump(mode="json") + ) + except service.PipelineBusyError as exc: + await websocket.send_json(_error_event(str(exc)).model_dump(mode="json")) + await websocket.close() + + +def _error_event(detail: str) -> StepEvent: + """Build a one-off ``error`` StepEvent for the WebSocket failure path.""" + return StepEvent( + event_type="error", + step_name="pipeline", + step_index=0, + total_steps=0, + status="fail", + detail=detail, + ) diff --git a/app/features/demo/schemas.py b/app/features/demo/schemas.py new file mode 100644 index 00000000..255213ec --- /dev/null +++ b/app/features/demo/schemas.py @@ -0,0 +1,105 @@ +"""Pydantic schemas for the demo showcase slice. + +Models for ``POST /demo/run`` and ``WS /demo/stream``. Mirrors the agents +``StreamEvent`` precedent (``app/features/agents/schemas.py``): streamed +event/result models are plain ``BaseModel`` subclasses with NO +``ConfigDict(strict=True)`` -- only the request body uses strict mode. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +# One pipeline step's outcome. +StepStatus = Literal["running", "pass", "fail", "skip", "warn"] +# Kind of streamed event. +EventType = Literal["step_start", "step_complete", "pipeline_complete", "error"] + + +def _utc_now() -> datetime: + """Return the current UTC timestamp (default factory for event timestamps).""" + return datetime.now(UTC) + + +class DemoRunRequest(BaseModel): + """Request body for ``POST /demo/run`` and the ``WS /demo/stream`` start frame. + + Every field is JSON-native (``int`` / ``bool``), so ``ConfigDict(strict=True)`` + is safe with no ``Field(strict=False)`` override -- there is no + ``date`` / ``datetime`` / ``UUID`` / ``Decimal`` field (see + ``.claude/rules/security-patterns.md`` and ``test_strict_mode_policy.py``). + """ + + model_config = ConfigDict(strict=True) + + seed: int = Field(default=42, ge=0, description="Deterministic seeder seed.") + reset: bool = Field( + default=False, + description="Wipe the database before seeding (destructive).", + ) + skip_seed: bool = Field( + default=True, + description="Assume an already-seeded database and skip the slow seed step.", + ) + + +class StepEvent(BaseModel): + """One streamed pipeline event. + + Plain ``BaseModel`` -- mirrors agents ``StreamEvent``. NO + ``ConfigDict(strict=True)``: ``timestamp`` is a bare ``datetime`` and event + models are not request bodies, so the strict-mode JSON-date policy does + not apply to them. + """ + + event_type: EventType = Field(..., description="Kind of pipeline event.") + step_name: str = Field(..., description="Step identifier (e.g. 'train').") + step_index: int = Field(..., description="1-based position in the step table.") + total_steps: int = Field(..., description="Total number of steps in the run.") + status: StepStatus | None = Field( + default=None, + description="Step outcome -- None on a step_start event.", + ) + detail: str = Field(default="", description="One-line human-readable detail.") + duration_ms: float = Field(default=0.0, description="Step wall-clock in milliseconds.") + data: dict[str, Any] = Field( + default_factory=dict, + description="Structured payload (per-model metrics, run_id, ...).", + ) + timestamp: datetime = Field(default_factory=_utc_now) + + +class DemoRunResult(BaseModel): + """Aggregate result returned by the synchronous ``POST /demo/run``.""" + + overall_status: Literal["pass", "fail"] = Field( + ..., + description="'pass' if no step failed, otherwise 'fail'.", + ) + steps: list[StepEvent] = Field( + default_factory=list, + description="The step_complete events, in execution order.", + ) + winner_model_type: str | None = Field( + default=None, + description="Lowest-WAPE model_type, if the backtest step ran.", + ) + winner_wape: float | None = Field( + default=None, + description="The winning model's aggregated WAPE.", + ) + winning_run_id: str | None = Field( + default=None, + description="Registry run_id of the registered winner.", + ) + alias: str | None = Field( + default=None, + description="Deployment alias pointing at the winning run.", + ) + wall_clock_s: float = Field( + default=0.0, + description="Total pipeline wall-clock in seconds.", + ) diff --git a/app/features/demo/service.py b/app/features/demo/service.py new file mode 100644 index 00000000..cc3dd8a6 --- /dev/null +++ b/app/features/demo/service.py @@ -0,0 +1,80 @@ +"""Service layer for the demo showcase slice. + +A module-level ``asyncio.Lock`` enforces single-flight: only one demo pipeline +runs at a time. Concurrent attempts raise :class:`PipelineBusyError`, which the +route layer surfaces as an RFC 7807 ``409``. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +from fastapi import FastAPI + +from app.features.demo.pipeline import run_pipeline +from app.features.demo.schemas import DemoRunRequest, DemoRunResult, StepEvent + +# Single-flight guard -- one demo pipeline at a time across the whole process. +_pipeline_lock = asyncio.Lock() + + +class PipelineBusyError(Exception): + """Raised when a demo pipeline run is already in progress.""" + + +async def stream_pipeline(app: FastAPI, req: DemoRunRequest) -> AsyncIterator[StepEvent]: + """Lock-guarded wrapper around :func:`run_pipeline`. + + Args: + app: The live FastAPI application. + req: Run parameters. + + Yields: + StepEvent instances, in execution order. + + Raises: + PipelineBusyError: If another pipeline run is already in progress. + """ + if _pipeline_lock.locked(): + raise PipelineBusyError("A demo pipeline run is already in progress.") + async with _pipeline_lock: + async for event in run_pipeline(app, req): + yield event + + +async def run_pipeline_sync(app: FastAPI, req: DemoRunRequest) -> DemoRunResult: + """Drain :func:`stream_pipeline` into an aggregate :class:`DemoRunResult`. + + Args: + app: The live FastAPI application. + req: Run parameters. + + Returns: + The aggregate result of the whole pipeline run. + + Raises: + PipelineBusyError: If another pipeline run is already in progress. + """ + steps: list[StepEvent] = [] + final: StepEvent | None = None + async for event in stream_pipeline(app, req): + if event.event_type == "step_complete": + steps.append(event) + elif event.event_type == "pipeline_complete": + final = event + + if final is None: # defensive -- run_pipeline always emits pipeline_complete + return DemoRunResult(overall_status="fail", steps=steps) + + winner_wape = final.data.get("winner_wape") + wall_clock = final.data.get("wall_clock_s", 0.0) + return DemoRunResult( + overall_status="fail" if final.status == "fail" else "pass", + steps=steps, + winner_model_type=final.data.get("winner_model_type"), + winner_wape=float(winner_wape) if isinstance(winner_wape, (int, float)) else None, + winning_run_id=final.data.get("winning_run_id"), + alias=final.data.get("alias"), + wall_clock_s=float(wall_clock) if isinstance(wall_clock, (int, float)) else 0.0, + ) diff --git a/app/features/demo/tests/__init__.py b/app/features/demo/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/features/demo/tests/conftest.py b/app/features/demo/tests/conftest.py new file mode 100644 index 00000000..c4653ff7 --- /dev/null +++ b/app/features/demo/tests/conftest.py @@ -0,0 +1,22 @@ +"""Test fixtures for the demo slice.""" + +from collections.abc import AsyncGenerator + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.fixture +async def client() -> AsyncGenerator[AsyncClient, None]: + """In-process HTTP client over ASGITransport (no network). + + Unit route tests monkeypatch the demo service, so no database override is + needed here -- the real pipeline never runs through this client. + """ + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://demo-test", + ) as ac: + yield ac diff --git a/app/features/demo/tests/test_pipeline.py b/app/features/demo/tests/test_pipeline.py new file mode 100644 index 00000000..3f2407e9 --- /dev/null +++ b/app/features/demo/tests/test_pipeline.py @@ -0,0 +1,300 @@ +"""Unit tests for the demo pipeline orchestrator. + +The pipeline drives the app over HTTP via ``pipeline._Client``; these tests +monkeypatch ``_Client`` with a canned-response stand-in so the orchestration +logic (step sequencing, winner selection, fail-fast) is exercised with no +database, no network, and no real models. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from fastapi import FastAPI + +from app.features.demo import pipeline +from app.features.demo.schemas import DemoRunRequest + +# A bare app instance -- the fake clients ignore it; it only satisfies the +# run_pipeline(app: FastAPI, ...) signature. +_FAKE_APP = FastAPI() + +# ============================================================================= +# Canned HTTP responses +# ============================================================================= + + +def _canned_response( + path: str, + json_body: dict[str, Any] | None, + artifact_path: str, + wapes: dict[str, float], +) -> dict[str, Any]: + """Return a canned 2xx body for a given endpoint path.""" + if path == "/health": + return {"status": "ok"} + if path == "/seeder/data": + return {"records_deleted": {"sales": 120, "store": 3}} + if path == "/seeder/generate": + return {"records_created": {"sales": 500, "store": 3, "product": 10}} + if path == "/seeder/status": + return { + "date_range_start": "2024-10-01", + "date_range_end": "2024-12-31", + "sales": 500, + } + if path.startswith("/dimensions/stores"): + return {"stores": [{"id": 7}]} + if path.startswith("/dimensions/products"): + return {"products": [{"id": 3}]} + if path == "/featuresets/compute": + return {"row_count": 80, "feature_columns": ["lag_1", "roll_7", "dow"]} + if path == "/forecasting/train": + return {"model_path": artifact_path} + if path == "/backtesting/run": + assert json_body is not None + model_type = json_body["config"]["model_config_main"]["model_type"] + return { + "main_model_results": { + "aggregated_metrics": {"wape": wapes[model_type], "mae": 1.0, "smape": 12.0} + } + } + if path == "/registry/runs": + return {"run_id": "demo-run-abc123def456"} + if path.endswith("/verify"): + return {"verified": True} + if path.startswith("/registry/runs/"): # PATCH pending->running->success + return {} + if path == "/registry/aliases": + return {} + raise AssertionError(f"unexpected request path: {path}") + + +def _build_fake_client(artifact_path: str, wapes: dict[str, float]) -> type: + """Build a canned-response stand-in class for ``pipeline._Client``.""" + + class _FakeClient: + def __init__(self, _app: Any) -> None: + self.calls: list[tuple[str, str]] = [] + + async def __aenter__(self) -> _FakeClient: + return self + + async def __aexit__(self, *_exc: object) -> None: + return None + + async def request( + self, + step: str, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + self.calls.append((method, path)) + return _canned_response(path, json_body, artifact_path, wapes) + + return _FakeClient + + +def _fake_settings(registry_root: str) -> SimpleNamespace: + """Fake settings: usable registry root, no LLM keys (agent step skips).""" + return SimpleNamespace( + registry_artifact_root=registry_root, + agent_default_model="anthropic:claude-test", + anthropic_api_key="", + openai_api_key="", + google_api_key="", + ) + + +# ============================================================================= +# _select_winner +# ============================================================================= + + +def test_select_winner_picks_lowest_wape(): + results = {"naive": {"wape": 0.30}, "seasonal_naive": {"wape": 0.12}, "ma": {"wape": 0.25}} + assert pipeline._select_winner(results) == ("seasonal_naive", 0.12) + + +def test_select_winner_skips_nan(): + results = {"naive": {"wape": float("nan")}, "seasonal_naive": {"wape": 0.5}} + assert pipeline._select_winner(results) == ("seasonal_naive", 0.5) + + +def test_select_winner_none_when_no_usable_wape(): + assert pipeline._select_winner({}) is None + assert pipeline._select_winner({"naive": {"wape": float("nan")}}) is None + + +# ============================================================================= +# run_pipeline -- full green run +# ============================================================================= + + +async def test_run_pipeline_full_green(monkeypatch, tmp_path): + artifact = tmp_path / "naive-model.joblib" + artifact.write_bytes(b"fake joblib artifact bytes") + registry_root = tmp_path / "registry" + + monkeypatch.setattr(pipeline, "get_settings", lambda: _fake_settings(str(registry_root))) + wapes = {"naive": 0.30, "seasonal_naive": 0.15, "moving_average": 0.25} + monkeypatch.setattr(pipeline, "_Client", _build_fake_client(str(artifact), wapes)) + + events = [e async for e in pipeline.run_pipeline(app=_FAKE_APP, req=DemoRunRequest())] + + starts = [e for e in events if e.event_type == "step_start"] + completes = [e for e in events if e.event_type == "step_complete"] + finals = [e for e in events if e.event_type == "pipeline_complete"] + + # 11 step_start + 11 step_complete + 1 pipeline_complete + assert len(starts) == 11 + assert len(completes) == 11 + assert len(finals) == 1 + + assert [e.step_name for e in completes] == [ + "precheck", + "reset", + "seed", + "status", + "features", + "train", + "backtest", + "register", + "verify", + "agent", + "cleanup", + ] + + by_name = {e.step_name: e for e in completes} + assert by_name["precheck"].status == "pass" + assert by_name["reset"].status == "skip" # reset=False + assert by_name["seed"].status == "skip" # skip_seed=True (default) + assert by_name["status"].status == "pass" + assert by_name["features"].status == "pass" + assert by_name["train"].status == "pass" + assert by_name["backtest"].status == "pass" + assert by_name["register"].status == "pass" + assert by_name["verify"].status == "pass" + assert by_name["agent"].status == "skip" # no LLM key + assert by_name["cleanup"].status == "skip" + + # winner = lowest WAPE = seasonal_naive + assert by_name["backtest"].data["winner"] == "seasonal_naive" + assert by_name["register"].data["run_id"] == "demo-run-abc123def456" + assert by_name["register"].data["alias"] == "demo-production" + + final = finals[0] + assert final.status == "pass" + assert final.data["winner_model_type"] == "seasonal_naive" + assert final.data["winning_run_id"] == "demo-run-abc123def456" + + # the registry artifact was copied + is hashable + copied = list((registry_root / "demo").glob("*.joblib")) + assert len(copied) == 1 + + +async def test_run_pipeline_emits_step_start_before_complete(monkeypatch, tmp_path): + artifact = tmp_path / "m.joblib" + artifact.write_bytes(b"x") + monkeypatch.setattr(pipeline, "get_settings", lambda: _fake_settings(str(tmp_path / "reg"))) + wapes = {"naive": 0.3, "seasonal_naive": 0.1, "moving_average": 0.2} + monkeypatch.setattr(pipeline, "_Client", _build_fake_client(str(artifact), wapes)) + + events = [e async for e in pipeline.run_pipeline(app=_FAKE_APP, req=DemoRunRequest())] + # for each step, step_start must precede its step_complete + seen_start: set[str] = set() + for event in events: + if event.event_type == "step_start": + seen_start.add(event.step_name) + elif event.event_type == "step_complete": + assert event.step_name in seen_start + + +async def test_run_pipeline_with_reset_and_seed(monkeypatch, tmp_path): + artifact = tmp_path / "m.joblib" + artifact.write_bytes(b"x") + monkeypatch.setattr(pipeline, "get_settings", lambda: _fake_settings(str(tmp_path / "reg"))) + wapes = {"naive": 0.3, "seasonal_naive": 0.1, "moving_average": 0.2} + monkeypatch.setattr(pipeline, "_Client", _build_fake_client(str(artifact), wapes)) + + req = DemoRunRequest(reset=True, skip_seed=False) + events = [e async for e in pipeline.run_pipeline(app=_FAKE_APP, req=req)] + by_name = {e.step_name: e for e in events if e.event_type == "step_complete"} + assert by_name["reset"].status == "pass" + assert by_name["seed"].status == "pass" + assert events[-1].status == "pass" + + +# ============================================================================= +# run_pipeline -- fail-fast +# ============================================================================= + + +async def test_run_pipeline_stops_on_failed_step(monkeypatch): + class _FailingClient: + def __init__(self, _app: Any) -> None: + pass + + async def __aenter__(self) -> _FailingClient: + return self + + async def __aexit__(self, *_exc: object) -> None: + return None + + async def request( + self, + step: str, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + if path == "/health": + return {"status": "ok"} + if path == "/seeder/status": + raise pipeline._StepError( + "status", 500, {"title": "Database Error", "detail": "db down"} + ) + raise AssertionError(f"unexpected request after failure: {path}") + + monkeypatch.setattr(pipeline, "_Client", _FailingClient) + + events = [e async for e in pipeline.run_pipeline(app=_FAKE_APP, req=DemoRunRequest())] + completes = [e for e in events if e.event_type == "step_complete"] + + # precheck pass, reset skip, seed skip, status FAIL -> run stops + assert [e.step_name for e in completes] == ["precheck", "reset", "seed", "status"] + assert completes[-1].status == "fail" + assert "db down" in completes[-1].detail + + final = events[-1] + assert final.event_type == "pipeline_complete" + assert final.status == "fail" + + +async def test_run_pipeline_transport_error_becomes_fail(monkeypatch): + import httpx + + class _BrokenClient: + def __init__(self, _app: Any) -> None: + pass + + async def __aenter__(self) -> _BrokenClient: + return self + + async def __aexit__(self, *_exc: object) -> None: + return None + + async def request(self, *_a: object, **_k: object) -> dict[str, Any]: + raise httpx.ConnectError("connection refused") + + monkeypatch.setattr(pipeline, "_Client", _BrokenClient) + events = [e async for e in pipeline.run_pipeline(app=_FAKE_APP, req=DemoRunRequest())] + completes = [e for e in events if e.event_type == "step_complete"] + assert completes[0].step_name == "precheck" + assert completes[0].status == "fail" + assert "transport error" in completes[0].detail + assert events[-1].status == "fail" diff --git a/app/features/demo/tests/test_routes.py b/app/features/demo/tests/test_routes.py new file mode 100644 index 00000000..caad2d64 --- /dev/null +++ b/app/features/demo/tests/test_routes.py @@ -0,0 +1,114 @@ +"""Route tests for the demo slice (POST /demo/run + WS /demo/stream). + +The demo service is monkeypatched so these tests exercise the route wiring +without a database or a real pipeline run. +""" + +from collections.abc import AsyncIterator + +import pytest +from fastapi.testclient import TestClient + +from app.features.demo import service +from app.features.demo.schemas import DemoRunRequest, DemoRunResult, StepEvent +from app.main import app + + +@pytest.fixture +def canned_result() -> DemoRunResult: + """A successful DemoRunResult used to stub the service.""" + return DemoRunResult( + overall_status="pass", + steps=[ + StepEvent( + event_type="step_complete", + step_name="precheck", + step_index=1, + total_steps=11, + status="pass", + detail="/health -> ok", + ) + ], + winner_model_type="seasonal_naive", + winner_wape=0.15, + winning_run_id="demo-run-abc", + alias="demo-production", + wall_clock_s=12.0, + ) + + +async def test_run_demo_pipeline_success(client, monkeypatch, canned_result: DemoRunResult): + async def fake_run_sync(_app, _params: DemoRunRequest) -> DemoRunResult: + return canned_result + + monkeypatch.setattr(service, "run_pipeline_sync", fake_run_sync) + + resp = await client.post("/demo/run", json={"skip_seed": True}) + assert resp.status_code == 200 + body = resp.json() + assert body["overall_status"] == "pass" + assert body["winner_model_type"] == "seasonal_naive" + assert body["winning_run_id"] == "demo-run-abc" + assert len(body["steps"]) == 1 + + +async def test_run_demo_pipeline_busy_returns_409(client, monkeypatch): + async def fake_run_sync(_app, _params: DemoRunRequest) -> DemoRunResult: + raise service.PipelineBusyError("A demo pipeline run is already in progress.") + + monkeypatch.setattr(service, "run_pipeline_sync", fake_run_sync) + + resp = await client.post("/demo/run", json={}) + assert resp.status_code == 409 + # RFC 7807 problem+json (ConflictError -> forecastlab_exception_handler) + assert resp.headers["content-type"].startswith("application/problem+json") + body = resp.json() + assert "in progress" in body["detail"] + + +async def test_run_demo_pipeline_rejects_negative_seed(client): + resp = await client.post("/demo/run", json={"seed": -5}) + assert resp.status_code == 422 + + +def test_demo_stream_websocket_streams_events(monkeypatch): + async def fake_stream(_app, _params: DemoRunRequest) -> AsyncIterator[StepEvent]: + yield StepEvent( + event_type="step_start", + step_name="precheck", + step_index=1, + total_steps=11, + ) + yield StepEvent( + event_type="pipeline_complete", + step_name="summary", + step_index=11, + total_steps=11, + status="pass", + detail="runs=3 winner=seasonal_naive wall_clock=12s", + ) + + monkeypatch.setattr(service, "stream_pipeline", fake_stream) + + with TestClient(app).websocket_connect("/demo/stream") as ws: + ws.send_json({"skip_seed": True}) + first = ws.receive_json() + assert first["event_type"] == "step_start" + assert first["step_name"] == "precheck" + second = ws.receive_json() + assert second["event_type"] == "pipeline_complete" + assert second["status"] == "pass" + + +def test_demo_stream_websocket_busy_sends_error(monkeypatch): + async def fake_stream(_app, _params: DemoRunRequest) -> AsyncIterator[StepEvent]: + raise service.PipelineBusyError("A demo pipeline run is already in progress.") + yield # pragma: no cover -- makes this an async generator + + monkeypatch.setattr(service, "stream_pipeline", fake_stream) + + with TestClient(app).websocket_connect("/demo/stream") as ws: + ws.send_json({}) + event = ws.receive_json() + assert event["event_type"] == "error" + assert "in progress" in event["detail"] diff --git a/app/features/demo/tests/test_schemas.py b/app/features/demo/tests/test_schemas.py new file mode 100644 index 00000000..97966ea7 --- /dev/null +++ b/app/features/demo/tests/test_schemas.py @@ -0,0 +1,74 @@ +"""Unit tests for demo slice schemas.""" + +import pytest +from pydantic import ValidationError + +from app.features.demo.schemas import DemoRunRequest, DemoRunResult, StepEvent + + +def test_demo_run_request_defaults(): + req = DemoRunRequest() + assert req.seed == 42 + assert req.reset is False + assert req.skip_seed is True + + +def test_demo_run_request_negative_seed_rejected(): + with pytest.raises(ValidationError): + DemoRunRequest(seed=-1) + + +def test_demo_run_request_strict_rejects_string_seed(): + # ConfigDict(strict=True): a JSON string is not coerced to int (the + # validate_python path FastAPI uses). Catches subtle coercion bugs. + with pytest.raises(ValidationError): + DemoRunRequest.model_validate({"seed": "5"}) + + +def test_demo_run_request_accepts_overrides(): + req = DemoRunRequest.model_validate({"seed": 7, "reset": True, "skip_seed": False}) + assert req.seed == 7 + assert req.reset is True + assert req.skip_seed is False + + +def test_step_event_json_round_trip(): + event = StepEvent( + event_type="step_complete", + step_name="train", + step_index=6, + total_steps=11, + status="pass", + detail="trained 3 models", + duration_ms=123.4, + data={"trained": ["naive", "seasonal_naive"]}, + ) + dumped = event.model_dump(mode="json") + # timestamp must serialize to an ISO string on the wire (matches send_json). + assert isinstance(dumped["timestamp"], str) + assert dumped["event_type"] == "step_complete" + assert dumped["status"] == "pass" + + restored = StepEvent.model_validate(dumped) + assert restored.step_name == "train" + assert restored.data == {"trained": ["naive", "seasonal_naive"]} + + +def test_step_event_status_optional_on_start(): + event = StepEvent( + event_type="step_start", + step_name="seed", + step_index=3, + total_steps=11, + ) + assert event.status is None + assert event.detail == "" + assert event.data == {} + + +def test_demo_run_result_defaults(): + result = DemoRunResult(overall_status="pass") + assert result.steps == [] + assert result.winner_model_type is None + assert result.winner_wape is None + assert result.wall_clock_s == 0.0 diff --git a/app/features/jobs/service.py b/app/features/jobs/service.py index d7eeeb4c..46b071cf 100644 --- a/app/features/jobs/service.py +++ b/app/features/jobs/service.py @@ -8,9 +8,10 @@ from __future__ import annotations +import math import uuid from datetime import UTC, datetime -from typing import Any +from typing import TYPE_CHECKING, Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -29,8 +30,99 @@ JobResponse, ) +if TYPE_CHECKING: + from app.features.backtesting.schemas import BacktestResponse + logger = get_logger(__name__) +# The metrics the /visualize/backtest dashboard reads. The job result is a +# fixed contract with the frontend (BacktestResult in backtest.tsx), so this +# set is enumerated here rather than derived from the response — but a missing +# key is logged (see _shape_backtest_result) so any drift is loud, not silent. +_BACKTEST_METRICS: tuple[str, ...] = ("mae", "smape", "wape", "bias") + +# Metric whose fold-to-fold stability surfaces as the headline `stability_index`. +# WAPE is the demo's primary model-selection metric, so its stability is the +# most meaningful single number; change this constant to pick a different one. +_STABILITY_METRIC: str = "wape" + + +def _finite(value: float) -> float: + """Coerce NaN/inf to 0.0 so a job result stays JSON/JSONB-safe. + + Backtest metrics can be NaN (e.g. stability with fewer than two valid + folds); Postgres `jsonb` rejects non-finite floats. + """ + return value if math.isfinite(value) else 0.0 + + +def _shape_backtest_result(response: BacktestResponse, model_type: str) -> dict[str, Any]: + """Flatten a ``BacktestResponse`` into the job-result contract the dashboard reads. + + ``BacktestingService.run_backtest`` produces per-fold metrics, stability + indices and a baseline comparison, but the job result must be a plain dict. + This shapes it into exactly what ``/visualize/backtest`` expects: + ``aggregated_metrics`` with ``*_mean`` keys plus ``stability_index``, + ``fold_metrics``, and (when baselines ran) ``baseline_comparison``. + + The metric set is ``_BACKTEST_METRICS``; ``stability_index`` is the stability + of ``_STABILITY_METRIC`` (WAPE). A metric absent from the response is logged + and defaulted to ``0.0`` rather than failing silently. + """ + main = response.main_model_results + agg = main.aggregated_metrics + # aggregate_fold_metrics() returns stability indices in the metric_std slot, + # keyed "_stability". + stability = main.metric_std + + missing = [m for m in _BACKTEST_METRICS if m not in agg] + if missing: + logger.warning( + "jobs.backtest_metrics_missing", + missing=missing, + available=sorted(agg), + ) + + fold_metrics = [ + { + "fold": fold.fold_index + 1, + **{m: _finite(fold.metrics.get(m, 0.0)) for m in _BACKTEST_METRICS}, + } + for fold in main.fold_results + ] + + aggregated_metrics: dict[str, float] = { + f"{m}_mean": _finite(agg.get(m, 0.0)) for m in _BACKTEST_METRICS + } + aggregated_metrics["stability_index"] = _finite( + stability.get(f"{_STABILITY_METRIC}_stability", 0.0) + ) + + result: dict[str, Any] = { + "backtest_id": response.backtest_id, + "model_type": model_type, + "n_splits": len(main.fold_results), + "aggregated_metrics": aggregated_metrics, + "fold_metrics": fold_metrics, + "duration_ms": response.duration_ms, + } + + summary = response.comparison_summary + if summary: + mae_cmp = summary.get("mae", {}) + result["baseline_comparison"] = { + "naive": { + "mae": _finite(mae_cmp.get("naive", 0.0)), + "improvement_pct": _finite(mae_cmp.get("vs_naive_pct", 0.0)), + }, + "seasonal_naive": { + "mae": _finite(mae_cmp.get("seasonal_naive", 0.0)), + "improvement_pct": _finite(mae_cmp.get("vs_seasonal_pct", 0.0)), + }, + } + + return result + class JobService: """Service for managing background jobs. @@ -530,21 +622,10 @@ async def _execute_backtest( config=backtest_config, ) - # Extract metrics from main_model_results - main_metrics = response.main_model_results.aggregated_metrics - - return { - "backtest_id": response.backtest_id, - "model_type": model_type, - "n_splits": len(response.main_model_results.fold_results), - "aggregated_metrics": { - "mae": main_metrics.get("mae", 0.0), - "smape": main_metrics.get("smape", 0.0), - "wape": main_metrics.get("wape", 0.0), - "bias": main_metrics.get("bias", 0.0), - }, - "duration_ms": response.duration_ms, - } + # Shape the full response into the dashboard's job-result contract + # (per-fold metrics, stability, baseline comparison) instead of + # discarding everything but four aggregated values. + return _shape_backtest_result(response, model_type) def _to_response(self, job: Job) -> JobResponse: """Convert Job model to response schema. diff --git a/app/features/jobs/tests/test_service.py b/app/features/jobs/tests/test_service.py new file mode 100644 index 00000000..cd05540d --- /dev/null +++ b/app/features/jobs/tests/test_service.py @@ -0,0 +1,160 @@ +"""Unit tests for the jobs service result-shaping logic. + +These are pure-function tests (no DB) for the helpers that flatten a +``BacktestResponse`` into the job-result contract the dashboard reads. +Regression coverage for issue #148 — the backtest job result used to drop +per-fold metrics, stability and the baseline comparison. +""" + +import math +from datetime import date + +from app.features.backtesting.schemas import ( + BacktestResponse, + FoldResult, + ModelBacktestResult, + SplitBoundary, + SplitConfig, +) +from app.features.jobs.service import _finite, _shape_backtest_result + + +def _fold(idx: int, mae: float, smape: float, wape: float, bias: float) -> FoldResult: + """Build a FoldResult with the given per-fold metrics.""" + return FoldResult( + fold_index=idx, + split=SplitBoundary( + fold_index=idx, + train_start=date(2024, 1, 1), + train_end=date(2024, 2, 1), + test_start=date(2024, 2, 2), + test_end=date(2024, 2, 15), + train_size=32, + test_size=14, + ), + dates=[date(2024, 2, 2)], + actuals=[10.0], + predictions=[11.0], + metrics={"mae": mae, "smape": smape, "wape": wape, "bias": bias}, + ) + + +def _make_response(*, with_baselines: bool = True, nan_stability: bool = False) -> BacktestResponse: + """Build a minimal BacktestResponse for shaping tests.""" + folds = [ + _fold(0, 10.0, 12.0, 11.0, 1.0), + _fold(1, 14.0, 11.0, 12.0, 3.0), + ] + stability = { + "wape_stability": math.nan if nan_stability else 8.5, + "mae_stability": 5.0, + } + main = ModelBacktestResult( + model_type="seasonal_naive", + config_hash="abc123", + fold_results=folds, + aggregated_metrics={"mae": 12.0, "smape": 11.5, "wape": 11.5, "bias": 2.0}, + metric_std=stability, + ) + baselines: list[ModelBacktestResult] | None = None + comparison: dict[str, dict[str, float]] | None = None + if with_baselines: + baselines = [ + ModelBacktestResult( + model_type="naive", + config_hash="n1", + fold_results=folds, + aggregated_metrics={"mae": 15.0, "smape": 14.0, "wape": 14.0, "bias": 3.0}, + metric_std={}, + ), + ] + comparison = { + "mae": { + "main": 12.0, + "naive": 15.0, + "vs_naive_pct": 20.0, + "seasonal_naive": 13.0, + "vs_seasonal_pct": 7.7, + }, + } + return BacktestResponse( + backtest_id="bt-1", + store_id=1, + product_id=1, + config_hash="cfg", + split_config=SplitConfig(n_splits=2), + main_model_results=main, + baseline_results=baselines, + comparison_summary=comparison, + duration_ms=3.5, + leakage_check_passed=True, + ) + + +def test_shape_backtest_result_includes_fold_metrics() -> None: + """fold_metrics is populated, one entry per fold, with 1-based fold numbers.""" + response = _make_response() + result = _shape_backtest_result(response, "seasonal_naive") + + # top-level fields are passed through unchanged + assert result["backtest_id"] == response.backtest_id + assert result["model_type"] == "seasonal_naive" + assert result["duration_ms"] == response.duration_ms + + # per-fold metrics are present and correctly shaped + assert result["n_splits"] == 2 + assert [f["fold"] for f in result["fold_metrics"]] == [1, 2] + assert result["fold_metrics"][0]["mae"] == 10.0 + assert result["fold_metrics"][1]["bias"] == 3.0 + + +def test_shape_backtest_result_aggregated_metrics_use_mean_keys() -> None: + """aggregated_metrics uses the *_mean keys plus stability_index.""" + result = _shape_backtest_result(_make_response(), "seasonal_naive") + agg = result["aggregated_metrics"] + assert set(agg) == {"mae_mean", "smape_mean", "wape_mean", "bias_mean", "stability_index"} + assert agg["mae_mean"] == 12.0 + assert agg["stability_index"] == 8.5 + + +def test_shape_backtest_result_includes_baseline_comparison() -> None: + """baseline_comparison carries naive/seasonal MAE and improvement percentages.""" + result = _shape_backtest_result(_make_response(with_baselines=True), "seasonal_naive") + comparison = result["baseline_comparison"] + assert comparison["naive"] == {"mae": 15.0, "improvement_pct": 20.0} + assert comparison["seasonal_naive"] == {"mae": 13.0, "improvement_pct": 7.7} + + +def test_shape_backtest_result_omits_baseline_when_absent() -> None: + """With no comparison summary, baseline_comparison is left out entirely.""" + result = _shape_backtest_result(_make_response(with_baselines=False), "seasonal_naive") + assert "baseline_comparison" not in result + + +def test_shape_backtest_result_coerces_nan_stability() -> None: + """NaN metrics are coerced to 0.0 — Postgres jsonb rejects non-finite floats.""" + result = _shape_backtest_result(_make_response(nan_stability=True), "seasonal_naive") + assert result["aggregated_metrics"]["stability_index"] == 0.0 + + +def test_shape_backtest_result_defaults_missing_metric_to_zero() -> None: + """A metric absent from the response defaults to 0.0 instead of being dropped. + + Guards against silent drift if the backtesting service stops emitting one of + the _BACKTEST_METRICS keys. + """ + response = _make_response() + del response.main_model_results.aggregated_metrics["bias"] + result = _shape_backtest_result(response, "seasonal_naive") + # the *_mean key is still present in the contract, just zeroed + assert result["aggregated_metrics"]["bias_mean"] == 0.0 + assert "bias_mean" in result["aggregated_metrics"] + + +def test_finite_coerces_non_finite_values() -> None: + """_finite passes finite values through and maps NaN/inf to 0.0.""" + assert _finite(3.5) == 3.5 + assert _finite(0.0) == 0.0 + assert _finite(math.nan) == 0.0 + assert _finite(math.inf) == 0.0 + assert _finite(-math.inf) == 0.0 diff --git a/app/features/registry/service.py b/app/features/registry/service.py index 515f17ca..71243ae3 100644 --- a/app/features/registry/service.py +++ b/app/features/registry/service.py @@ -601,18 +601,30 @@ async def _find_duplicate( data_window_end: Data window end date. Returns: - Existing run or None. + The most recent matching run, or None. + + Note: + Under ``registry_duplicate_policy="detect"`` (the default), duplicate + runs are intentionally created, so multiple non-archived rows may + share the same config hash. This query therefore orders by + ``created_at`` and takes the first row rather than asserting a single + match — ``scalar_one_or_none()`` would raise ``MultipleResultsFound``. """ - stmt = select(ModelRun).where( - (ModelRun.config_hash == config_hash) - & (ModelRun.store_id == store_id) - & (ModelRun.product_id == product_id) - & (ModelRun.data_window_start == data_window_start) - & (ModelRun.data_window_end == data_window_end) - & (ModelRun.status != RunStatusORM.ARCHIVED.value) + stmt = ( + select(ModelRun) + .where( + (ModelRun.config_hash == config_hash) + & (ModelRun.store_id == store_id) + & (ModelRun.product_id == product_id) + & (ModelRun.data_window_start == data_window_start) + & (ModelRun.data_window_end == data_window_end) + & (ModelRun.status != RunStatusORM.ARCHIVED.value) + ) + .order_by(ModelRun.created_at.desc()) + .limit(1) ) result = await db.execute(stmt) - return result.scalar_one_or_none() + return result.scalars().first() def _model_to_response(self, model_run: ModelRun) -> RunResponse: """Convert ORM model to response schema. diff --git a/app/features/registry/tests/test_routes.py b/app/features/registry/tests/test_routes.py index 72d889f1..0b9e2e5e 100644 --- a/app/features/registry/tests/test_routes.py +++ b/app/features/registry/tests/test_routes.py @@ -91,6 +91,30 @@ async def test_create_run_invalid_date_order(self, client: AsyncClient) -> None: ) assert response.status_code == 422 + async def test_create_run_repeated_duplicate_does_not_500(self, client: AsyncClient) -> None: + """Repeated identical runs must not 500 (regression for #146). + + Under the default ``registry_duplicate_policy="detect"`` duplicate runs + are created intentionally, so multiple non-archived rows can share one + config hash. ``_find_duplicate`` previously used ``scalar_one_or_none()``, + which raised ``MultipleResultsFound`` once two duplicates existed — the + third POST returned ``HTTP 500 Database Error``. + """ + payload = { + "model_type": "test-dup-regression", + "model_config": {"strategy": "last_value"}, + "data_window_start": "2024-01-01", + "data_window_end": "2024-03-31", + "store_id": 1, + "product_id": 1, + } + + # Three identical creates: 1st has no prior match, 2nd has one, + # 3rd would hit the MultipleResultsFound trap before the fix. + for _ in range(3): + response = await client.post("/registry/runs", json=payload) + assert response.status_code == 201, response.text + class TestListRunsEndpoint: """Tests for GET /registry/runs endpoint.""" diff --git a/app/features/seeder/service.py b/app/features/seeder/service.py index 81726b56..d8b5dfb8 100644 --- a/app/features/seeder/service.py +++ b/app/features/seeder/service.py @@ -364,6 +364,14 @@ def list_scenarios() -> list[schemas.ScenarioInfo]: start_date=date(2024, 1, 1), end_date=date(2024, 12, 31), ), + schemas.ScenarioInfo( + name="demo_minimal", + description="Tiny preset for the make demo target (3 stores x 10 products x 92 days)", + stores=3, + products=10, + start_date=date(2024, 10, 1), + end_date=date(2024, 12, 31), + ), ] logger.info("seeder.scenarios.listed", count=len(scenarios)) diff --git a/app/features/seeder/tests/test_routes.py b/app/features/seeder/tests/test_routes.py index f83d6a81..7f57e734 100644 --- a/app/features/seeder/tests/test_routes.py +++ b/app/features/seeder/tests/test_routes.py @@ -71,11 +71,12 @@ def test_returns_scenarios(self, client): assert response.status_code == status.HTTP_200_OK data = response.json() - assert len(data) == 6 + assert len(data) == 7 names = [s["name"] for s in data] assert "retail_standard" in names assert "holiday_rush" in names + assert "demo_minimal" in names def test_scenario_structure(self, client): """Test scenario response structure.""" diff --git a/app/features/seeder/tests/test_service.py b/app/features/seeder/tests/test_service.py index 9b476caa..b712db95 100644 --- a/app/features/seeder/tests/test_service.py +++ b/app/features/seeder/tests/test_service.py @@ -15,7 +15,7 @@ def test_returns_all_scenarios(self): """Test that all scenario presets are returned.""" scenarios = service.list_scenarios() - assert len(scenarios) == 6 + assert len(scenarios) == 7 names = [s.name for s in scenarios] assert "retail_standard" in names @@ -24,6 +24,17 @@ def test_returns_all_scenarios(self): assert "stockout_heavy" in names assert "new_launches" in names assert "sparse" in names + assert "demo_minimal" in names + + def test_demo_minimal_dimensions(self): + """Test that demo_minimal is the small preset for the make demo target.""" + scenarios = service.list_scenarios() + + demo = next(s for s in scenarios if s.name == "demo_minimal") + assert demo.stores == 3 + assert demo.products == 10 + assert demo.start_date == date(2024, 10, 1) + assert demo.end_date == date(2024, 12, 31) def test_scenario_info_structure(self): """Test that scenarios have required fields.""" @@ -65,6 +76,7 @@ def test_valid_scenarios(self): "stockout_heavy", "new_launches", "sparse", + "demo_minimal", ] for name in valid_names: diff --git a/app/main.py b/app/main.py index 285bdf4d..9cca6275 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ from app.features.agents.websocket import router as agents_ws_router from app.features.analytics.routes import router as analytics_router from app.features.backtesting.routes import router as backtesting_router +from app.features.demo.routes import router as demo_router from app.features.dimensions.routes import router as dimensions_router from app.features.featuresets.routes import router as featuresets_router from app.features.forecasting.routes import router as forecasting_router @@ -124,6 +125,7 @@ def create_app() -> FastAPI: app.include_router(agents_router) app.include_router(agents_ws_router) app.include_router(seeder_router) + app.include_router(demo_router) return app diff --git a/app/shared/seeder/config.py b/app/shared/seeder/config.py index a57c0536..cdcdc94a 100644 --- a/app/shared/seeder/config.py +++ b/app/shared/seeder/config.py @@ -17,6 +17,7 @@ class ScenarioPreset(str, Enum): STOCKOUT_HEAVY = "stockout_heavy" NEW_LAUNCHES = "new_launches" SPARSE = "sparse" + DEMO_MINIMAL = "demo_minimal" @dataclass @@ -604,5 +605,27 @@ def from_scenario(cls, scenario: ScenarioPreset, seed: int = 42) -> SeederConfig ), ) + if scenario == ScenarioPreset.DEMO_MINIMAL: + # Tiny preset for the `make demo` target. Keeps wall-clock comfortable + # on a developer laptop while still producing a non-NaN backtest WAPE + # with strategy=expanding, n_splits=3, horizon=14, min_train_size=30 + # (needs >= 30 + 3*14 = 72 days; 92 days here leaves margin). + return cls( + seed=seed, + start_date=date(2024, 10, 1), + end_date=date(2024, 12, 31), + dimensions=DimensionConfig(stores=3, products=10), + time_series=TimeSeriesConfig( + base_demand=100, + trend="linear", + trend_slope=0.0005, + noise_sigma=0.10, + ), + retail=RetailPatternConfig( + promotion_probability=0.1, + stockout_probability=0.02, + ), + ) + # Default to retail_standard return cls(seed=seed) diff --git a/app/shared/seeder/core.py b/app/shared/seeder/core.py index 655f83e7..1d97b4cf 100644 --- a/app/shared/seeder/core.py +++ b/app/shared/seeder/core.py @@ -392,6 +392,7 @@ async def _generate_facts( stockout_dates=stockout_dates, dates=dates, lifecycle=lifecycle_gen, + inventory_records=inventory_records, ) # Merge markdown outputs into the main lists, then normalize so diff --git a/app/shared/seeder/generators/markdowns.py b/app/shared/seeder/generators/markdowns.py index 62414e79..30fd0232 100644 --- a/app/shared/seeder/generators/markdowns.py +++ b/app/shared/seeder/generators/markdowns.py @@ -9,9 +9,11 @@ - ``stockout_risk`` — fires per-``(store, product)`` ending the day before each observed stockout, with a window of ``markdown_duration_days``. - -The ``age_days`` trigger is deferred to a follow-up; see issue #94. -``MarkdownGenerator`` raises ``NotImplementedError`` for that mode. +- ``age_days`` — fires per-``(store, product)`` once inventory has + been unrefreshed for at least ``cfg.age_days_threshold`` days. + "Refresh" is a heuristic: a day where ``on_hand_qty`` rose by at + least ``_AGE_DAYS_SPIKE_THRESHOLD`` vs the previous day (issue #94, + resolved via the heuristic path so no schema column is required). Disabled path (``MarkdownConfig`` is ``None`` or ``enable=False``) returns empty containers and consumes zero rng state, preserving the @@ -37,6 +39,9 @@ # is ``Numeric(10, 2)``. _PCT_QUANTIZE = Decimal("0.0001") _PRICE_QUANTIZE = Decimal("0.01") +# Fractional increase in ``on_hand_qty`` vs prior day that counts as an +# implicit replenishment under the ``age_days`` trigger heuristic. +_AGE_DAYS_SPIKE_THRESHOLD = 0.3 class MarkdownGenerator: @@ -72,6 +77,7 @@ def generate( stockout_dates: dict[tuple[int, int], set[date]], dates: list[date], lifecycle: LifecycleGenerator | None = None, + inventory_records: list[dict[str, Any]] | None = None, ) -> tuple[ list[dict[str, Any]], list[dict[str, Any]], @@ -97,6 +103,11 @@ def generate( lifecycle: Optional pre-built ``LifecycleGenerator``. Used only for ``trigger='lifecycle_decline'``. When absent or disabled, the lifecycle trigger emits no rows. + inventory_records: Optional ``InventorySnapshotGenerator`` + output. Used only for ``trigger='age_days'`` to derive + per-``(store, product)`` on-hand history for the + refresh-spike heuristic. When absent and the trigger is + ``age_days``, no markdowns fire. Returns: Three-tuple: @@ -108,8 +119,6 @@ def generate( ``SalesDailyGenerator`` lift integration. Raises: - NotImplementedError: If ``config.trigger == 'age_days'``. - Tracked at issue #94. ValueError: If ``markdown_depth_pct`` is outside ``[0, 1]`` or ``markdown_duration_days < 1``. """ @@ -117,11 +126,6 @@ def generate( return ([], [], {}) cfg = self.config - if cfg.trigger == "age_days": - raise NotImplementedError( - "MarkdownConfig.trigger='age_days' is deferred. See follow-up " - "issue #94 for the implementation plan." - ) if not 0.0 <= cfg.markdown_depth_pct <= 1.0: raise ValueError(f"markdown_depth_pct must be in [0, 1], got {cfg.markdown_depth_pct}") if cfg.markdown_duration_days < 1: @@ -144,7 +148,7 @@ def generate( price_history_records=price_history_records, markdown_dates=markdown_dates, ) - else: # cfg.trigger == "stockout_risk" + elif cfg.trigger == "stockout_risk": self._emit_stockout_risk( cfg=cfg, product_specs=product_specs, @@ -154,6 +158,16 @@ def generate( price_history_records=price_history_records, markdown_dates=markdown_dates, ) + else: # cfg.trigger == "age_days" + self._emit_age_days( + cfg=cfg, + product_specs=product_specs, + inventory_records=inventory_records, + dates=dates, + promo_records=promo_records, + price_history_records=price_history_records, + markdown_dates=markdown_dates, + ) return (promo_records, price_history_records, markdown_dates) @@ -303,6 +317,118 @@ def _emit_stockout_risk( ) last_md_end = md_end + def _emit_age_days( + self, + *, + cfg: MarkdownConfig, + product_specs: list[dict[str, Any]], + inventory_records: list[dict[str, Any]] | None, + dates: list[date], + promo_records: list[dict[str, Any]], + price_history_records: list[dict[str, Any]], + markdown_dates: dict[tuple[int, int], set[date]], + ) -> None: + """Fire markdowns when inventory ages past ``cfg.age_days_threshold``. + + Age heuristic (issue #94): walk the per-``(store, product)`` + on-hand series; a day where ``on_hand_qty`` rose by at least + ``_AGE_DAYS_SPIKE_THRESHOLD`` over the prior day counts as an + implicit refresh. Age at day ``t`` = days since the most recent + refresh (or ``dates[0]`` when none has been observed). When age + crosses ``cfg.age_days_threshold`` and ``on_hand_qty`` is at + least ``cfg.markdown_min_units_remaining``, fire a markdown + window of ``cfg.markdown_duration_days`` and reset the refresh + anchor to the day AFTER the window ends — the markdown's job + is to clear the shelf, so by the day after ``md_end`` the + product is treated as fresh stock. Days inside an active + markdown window are skipped to avoid back-to-back fires. + """ + if not dates or not inventory_records: + return + + history: dict[tuple[int, int], list[tuple[date, int]]] = {} + for rec in inventory_records: + key = (int(rec["store_id"]), int(rec["product_id"])) + history.setdefault(key, []).append((rec["date"], int(rec["on_hand_qty"]))) + + discount_pct = Decimal(str(cfg.markdown_depth_pct)).quantize(_PCT_QUANTIZE) + first_date = dates[0] + last_date = dates[-1] + price_by_product: dict[int, Decimal] = { + int(spec["product_id"]): self._as_decimal(spec["base_price"]) for spec in product_specs + } + + for key in sorted(history.keys()): + store_id, product_id = key + base_price = price_by_product.get(product_id) + if base_price is None: + continue + series = sorted(history[key]) + if not series: + continue + + markdown_price = (base_price * (Decimal("1") - discount_pct)).quantize(_PRICE_QUANTIZE) + last_refresh_date = first_date + last_md_end: date | None = None + prev_on_hand: int | None = None + + for current_date, on_hand in series: + # Spike detection: a positive jump > spike threshold + # against the previous day is an implicit refresh. + if ( + prev_on_hand is not None + and prev_on_hand > 0 + and on_hand > prev_on_hand * (1 + _AGE_DAYS_SPIKE_THRESHOLD) + ): + last_refresh_date = current_date + prev_on_hand = on_hand + + # Inside an active markdown window: hold off. + if last_md_end is not None and current_date <= last_md_end: + continue + + age = (current_date - last_refresh_date).days + if age < cfg.age_days_threshold: + continue + # Don't markdown an empty / near-empty shelf. + if on_hand < cfg.markdown_min_units_remaining: + continue + + md_start = current_date + md_end = min( + md_start + timedelta(days=cfg.markdown_duration_days - 1), + last_date, + ) + promo_records.append( + { + "product_id": product_id, + "store_id": store_id, + "name": "Aged-Inventory Clearance", + "kind": "markdown", + "discount_pct": discount_pct, + "discount_amount": None, + "bundle_member_product_ids": None, + "start_date": md_start, + "end_date": md_end, + } + ) + price_history_records.append( + { + "product_id": product_id, + "store_id": store_id, + "price": markdown_price, + "valid_from": md_start, + "valid_to": md_end, + } + ) + self._fill_date_range( + markdown_dates.setdefault(key, set()), + md_start, + md_end, + ) + last_md_end = md_end + last_refresh_date = md_end + timedelta(days=1) + # ---------------------------------------------------------------------- # # Helpers # ---------------------------------------------------------------------- # diff --git a/app/shared/seeder/tests/test_config.py b/app/shared/seeder/tests/test_config.py index 418c9421..b4875652 100644 --- a/app/shared/seeder/tests/test_config.py +++ b/app/shared/seeder/tests/test_config.py @@ -90,6 +90,28 @@ def test_from_scenario_sparse(self): assert config.sparsity.missing_combinations_pct == 0.5 assert config.sparsity.random_gaps_per_series == 3 + def test_from_scenario_demo_minimal(self): + """Test demo_minimal scenario preset. + + This preset powers the `make demo` target; the date range MUST cover at + least 72 days so an expanding backtest with n_splits=3 + horizon=14 + + min_train_size=30 produces non-NaN WAPE. + """ + config = SeederConfig.from_scenario(ScenarioPreset.DEMO_MINIMAL, seed=42) + + assert config.seed == 42 + assert config.start_date == date(2024, 10, 1) + assert config.end_date == date(2024, 12, 31) + assert config.dimensions.stores == 3 + assert config.dimensions.products == 10 + assert config.time_series.trend == "linear" + assert config.time_series.noise_sigma == 0.10 + assert config.retail.promotion_probability == 0.1 + assert config.retail.stockout_probability == 0.02 + # Sanity-check the date span is wide enough for the backtest budget. + days = (config.end_date - config.start_date).days + 1 + assert days >= 72 + class TestScenarioPreset: """Tests for ScenarioPreset enum.""" @@ -103,6 +125,7 @@ def test_all_scenarios_defined(self): "stockout_heavy", "new_launches", "sparse", + "demo_minimal", } actual = {s.value for s in ScenarioPreset} assert actual == expected diff --git a/app/shared/seeder/tests/test_phase2_markdowns.py b/app/shared/seeder/tests/test_phase2_markdowns.py index b1577d89..564a2ca1 100644 --- a/app/shared/seeder/tests/test_phase2_markdowns.py +++ b/app/shared/seeder/tests/test_phase2_markdowns.py @@ -352,19 +352,6 @@ def test_deterministic_output_order(self) -> None: class TestMarkdownGeneratorValidation: - def test_age_days_trigger_raises_not_implemented(self) -> None: - gen = MarkdownGenerator( - random.Random(0), - MarkdownConfig(enable=True, trigger="age_days"), - ) - with pytest.raises(NotImplementedError, match="#94"): - gen.generate( - product_specs=_product_specs(), - store_ids=[10], - stockout_dates={}, - dates=_dates(date(2024, 1, 1), 30), - ) - def test_depth_pct_below_zero_raises(self) -> None: gen = MarkdownGenerator( random.Random(0), @@ -418,3 +405,219 @@ def test_no_rng_consumption_enabled_path(self) -> None: dates=_dates(date(2024, 1, 1), 90), ) assert rng.getstate() == baseline_state + + +# ---------------------------------------------------------------------- # +# age_days trigger (#94) +# ---------------------------------------------------------------------- # + + +def _flat_inventory( + store_id: int, + product_id: int, + start: date, + days: int, + on_hand: int, +) -> list[dict[str, Any]]: + """Build a constant-``on_hand`` inventory series with no spikes.""" + return [ + { + "date": start + timedelta(days=i), + "store_id": store_id, + "product_id": product_id, + "on_hand_qty": on_hand, + } + for i in range(days) + ] + + +class TestAgeDaysTrigger: + """Tests for the ``age_days`` markdown trigger (issue #94).""" + + def test_no_inventory_records_fires_nothing(self) -> None: + """Empty / missing inventory_records → no markdowns (defensive).""" + gen = MarkdownGenerator( + random.Random(0), + MarkdownConfig(enable=True, trigger="age_days", age_days_threshold=10), + ) + promos, prices, md_dates = gen.generate( + product_specs=_product_specs(), + store_ids=[10], + stockout_dates={}, + dates=_dates(date(2024, 1, 1), 90), + inventory_records=None, + ) + assert promos == [] and prices == [] and md_dates == {} + + def test_threshold_not_met_fires_nothing(self) -> None: + """Age never crosses threshold over the seeded range → no fire.""" + inv = _flat_inventory(10, 1, date(2024, 1, 1), days=10, on_hand=50) + gen = MarkdownGenerator( + random.Random(0), + MarkdownConfig(enable=True, trigger="age_days", age_days_threshold=60), + ) + promos, prices, _md = gen.generate( + product_specs=_product_specs(), + store_ids=[10], + stockout_dates={}, + dates=_dates(date(2024, 1, 1), 10), + inventory_records=inv, + ) + assert promos == [] and prices == [] + + def test_threshold_met_fires_markdown(self) -> None: + """Flat inventory past threshold → fires on the threshold day.""" + start = date(2024, 1, 1) + inv = _flat_inventory(10, 1, start, days=20, on_hand=50) + gen = MarkdownGenerator( + random.Random(0), + MarkdownConfig( + enable=True, + trigger="age_days", + age_days_threshold=5, + markdown_duration_days=3, + markdown_min_units_remaining=5, + ), + ) + promos, prices, md_dates = gen.generate( + product_specs=_product_specs(), + store_ids=[10], + stockout_dates={}, + dates=_dates(start, 20), + inventory_records=inv, + ) + assert len(promos) >= 1 + first = promos[0] + # Age starts counting at dates[0]; threshold 5 → first fire on day 5 + # (2024-01-06). + assert first["start_date"] == start + timedelta(days=5) + assert first["end_date"] == start + timedelta(days=7) # duration 3 + assert first["kind"] == "markdown" + assert first["name"] == "Aged-Inventory Clearance" + assert (10, 1) in md_dates + assert len(prices) == len(promos) + + def test_spike_resets_age_counter(self) -> None: + """A 50% on_hand jump mid-range delays the next fire.""" + start = date(2024, 1, 1) + # Day 0..4: on_hand=50 (steady), day 5: jumps to 100 (>30% jump → refresh). + # After the spike, age counter resets — next fire is day 5+threshold. + inv: list[dict[str, Any]] = [] + for i in range(20): + on_hand = 50 if i < 5 else 100 + inv.append( + { + "date": start + timedelta(days=i), + "store_id": 10, + "product_id": 1, + "on_hand_qty": on_hand, + } + ) + gen = MarkdownGenerator( + random.Random(0), + MarkdownConfig( + enable=True, + trigger="age_days", + age_days_threshold=8, + markdown_duration_days=3, + ), + ) + promos, _prices, _md = gen.generate( + product_specs=_product_specs(), + store_ids=[10], + stockout_dates={}, + dates=_dates(start, 20), + inventory_records=inv, + ) + # Without the spike, threshold 8 would fire on day 8 (2024-01-09). + # With a refresh on day 5, the first fire shifts to day 5+8=13. + assert len(promos) >= 1 + assert promos[0]["start_date"] == start + timedelta(days=13) + + def test_post_fire_reset_avoids_back_to_back(self) -> None: + """After firing, the age counter resets to the firing day.""" + start = date(2024, 1, 1) + inv = _flat_inventory(10, 1, start, days=30, on_hand=50) + gen = MarkdownGenerator( + random.Random(0), + MarkdownConfig( + enable=True, + trigger="age_days", + age_days_threshold=5, + markdown_duration_days=3, + ), + ) + promos, _prices, _md = gen.generate( + product_specs=_product_specs(), + store_ids=[10], + stockout_dates={}, + dates=_dates(start, 30), + inventory_records=inv, + ) + # First fire on day 5; markdown window 5..7; next fire eligible + # at day 8 + 5 = day 13. So firing days = [5, 13, 21, 29]. + starts = [p["start_date"] for p in promos] + assert starts == [ + start + timedelta(days=5), + start + timedelta(days=13), + start + timedelta(days=21), + start + timedelta(days=29), + ] + + def test_skips_low_inventory(self) -> None: + """on_hand below ``markdown_min_units_remaining`` blocks firing.""" + start = date(2024, 1, 1) + # on_hand = 2 (below default min of 5), aged past threshold. + inv = _flat_inventory(10, 1, start, days=20, on_hand=2) + gen = MarkdownGenerator( + random.Random(0), + MarkdownConfig( + enable=True, + trigger="age_days", + age_days_threshold=5, + markdown_min_units_remaining=5, + ), + ) + promos, _prices, _md = gen.generate( + product_specs=_product_specs(), + store_ids=[10], + stockout_dates={}, + dates=_dates(start, 20), + inventory_records=inv, + ) + assert promos == [] + + def test_unknown_product_silently_skipped(self) -> None: + """Inventory rows referencing unknown products produce no rows.""" + start = date(2024, 1, 1) + inv = _flat_inventory(10, 999, start, days=20, on_hand=50) # product 999 not in specs + gen = MarkdownGenerator( + random.Random(0), + MarkdownConfig(enable=True, trigger="age_days", age_days_threshold=5), + ) + promos, _prices, _md = gen.generate( + product_specs=_product_specs(), + store_ids=[10], + stockout_dates={}, + dates=_dates(start, 20), + inventory_records=inv, + ) + assert promos == [] + + def test_no_rng_consumption(self) -> None: + """Enabled age_days path is deterministic — rng untouched.""" + rng = random.Random(42) + baseline_state = rng.getstate() + start = date(2024, 1, 1) + inv = _flat_inventory(10, 1, start, days=20, on_hand=50) + MarkdownGenerator( + rng, + MarkdownConfig(enable=True, trigger="age_days", age_days_threshold=5), + ).generate( + product_specs=_product_specs(), + store_ids=[10], + stockout_dates={}, + dates=_dates(start, 20), + inventory_records=inv, + ) + assert rng.getstate() == baseline_state diff --git a/docs/DAILY-FLOW.md b/docs/DAILY-FLOW.md index 72622625..e2ebcb95 100644 --- a/docs/DAILY-FLOW.md +++ b/docs/DAILY-FLOW.md @@ -162,6 +162,25 @@ gh run watch --- +## First-Run Smoke (Demo Pipeline) + +A munkafolyamat első indításához (vagy egy hosszabb szünet után) érdemes +a teljes end-to-end pipeline-ot egy paranccsal lefuttatni: + +```bash +make demo # seed → features → train ×3 → backtest → register → alias → agent +make demo-quick # ugyanaz, --skip-seed (gyors iteráció friss DB-vel) +make demo-clean # destruktív: DB törlés után újra-seed +``` + +A `make demo` ≤ 180 s alatt zárul a referencia laptopon, és az utolsó +sor egy gröp-barát összegzés: +`runs=3 winner= alias=demo-production wall_clock=s`. + +A részleteket lásd: `scripts/run_demo.py` (PRP-15). + +--- + ## Következő Phases (INITIAL-9 → INITIAL-11) A projekt a moduláris három-fázisú roadmap szerint halad: diff --git a/docs/PHASE/0-INIT_PHASE.md b/docs/PHASE/0-INIT_PHASE.md index fa717d4b..a3fc27e6 100644 --- a/docs/PHASE/0-INIT_PHASE.md +++ b/docs/PHASE/0-INIT_PHASE.md @@ -574,8 +574,8 @@ Phase 0 provides the foundation for: ## References -- [PRP-0: Project Foundation](../PRPs/PRP-0-project-foundation.md) -- [INITIAL-0: Foundation Requirements](../INITIAL-0.md) +- [PRP-0: Project Foundation](../../PRPs/PRP-0-project-foundation.md) +- [INITIAL-0: Foundation Requirements](../../INITIAL-0.md) - [Architecture Overview](../ARCHITECTURE.md) - [Logging Standard](../validation/logging-standard.md) - [Type Checking Standards](../validation/mypy-standard.md) diff --git a/docs/_base/API_CONTRACTS.md b/docs/_base/API_CONTRACTS.md index 922a0d12..ec9c8907 100644 --- a/docs/_base/API_CONTRACTS.md +++ b/docs/_base/API_CONTRACTS.md @@ -45,6 +45,8 @@ All endpoints serve JSON; error responses use `application/problem+json` (RFC 78 | agents | DELETE | `/agents/sessions/{session_id}` | Close session | | agents | WS | `/agents/stream` | Token-by-token streaming + tool-call events | | seeder | (see `app/features/seeder/routes.py`) | `/seeder/*` | Trigger scenarios, status, customization | +| demo | POST | `/demo/run` | Run the end-to-end demo pipeline in-process; returns a `DemoRunResult`. `409 application/problem+json` if a run is already active | +| demo | WS | `/demo/stream` | Stream one `StepEvent` per pipeline step for the live Showcase page | ## WebSocket Events (`/agents/stream`) @@ -60,6 +62,19 @@ Verified against `app/features/agents/websocket.py` and `app/features/agents/sch - `complete` — `data: {"message": str, "tokens_used": int, "tool_calls_count": int}` (`CompleteEvent`) - `error` — `data: {"error": str, "error_type": str, "recoverable": bool}` (`ErrorEvent`). On `recoverable: false` (e.g., `session_not_found`, `session_expired`), the client should close. +## WebSocket Events (`/demo/stream`) + +Drives the end-to-end demo pipeline for the dashboard Showcase page. Verified against `app/features/demo/routes.py` and `app/features/demo/schemas.py` (`StepEvent`). + +- **Client → server (one start frame):** `{"seed": int, "reset": bool, "skip_seed": bool}` — all fields optional (`DemoRunRequest` supplies defaults `seed=42`, `reset=false`, `skip_seed=true`). The pipeline runs once, then the server closes. +- **Server → client (every frame):** Pydantic-serialized `StepEvent` — `{"event_type", "step_name", "step_index", "total_steps", "status", "detail", "duration_ms", "data", "timestamp"}`. +- **`event_type` values (Literal in `StepEvent`):** + - `step_start` — a step began; `status` is `null`. + - `step_complete` — a step finished; `status ∈ {pass, fail, skip, warn}`, `data` carries structured payload (backtest `per_model` WAPE + `winner`; register `run_id` + `alias`). + - `pipeline_complete` — final event; `data` carries `winner_model_type`, `winner_wape`, `winning_run_id`, `alias`, `wall_clock_s`. + - `error` — bad start frame or a concurrent run already in progress; one event, then the server closes. +- Concurrency: a module-level `asyncio.Lock` allows one pipeline at a time. A second `POST /demo/run` returns `409`; a second `WS /demo/stream` receives one `error` event. + ## Async Events / Queues None. Job execution is synchronous-with-async-shaped-API (per `app/features/jobs/`). No Kafka / SQS / pub-sub. Per `.claude/rules/product-vision.md`, **not a streaming system**. diff --git a/docs/_base/DEV_GUIDE.md b/docs/_base/DEV_GUIDE.md index 21b7b1cd..8b485d80 100644 --- a/docs/_base/DEV_GUIDE.md +++ b/docs/_base/DEV_GUIDE.md @@ -1,14 +1,13 @@ # ForecastLabAI Developer Guide > HUMAN-MAINTAINED — do not overwrite via the generating-claudemd skill. -> Fill in all {FILL IN} sections; remove this stub marker line when content is complete. ## What This Project Is -{FILL IN: 2 sentences. Suggested seed — "A portfolio-grade, single-host retail demand forecasting system that exercises the full lifecycle: data platform → ingest → time-safe features → forecasting → backtesting → registry → RAG → agents → React dashboard. Pre-1.0; release-please-driven SemVer."} +ForecastLabAI is a portfolio-grade, single-host retail demand forecasting system that exercises the full lifecycle: data platform → ingest → time-safe features → forecasting → backtesting → model registry → RAG → agentic layer → React dashboard. It is pre-1.0, released on a release-please-driven SemVer train, and runs end-to-end on a developer laptop with nothing but `docker-compose up`, Python 3.12, and Node. ## Tech Stack -See `CLAUDE.md` Stack section and `pyproject.toml` for authoritative dependency list. {FILL IN: any narrative on why each choice — point to ADRs in `docs/ADR/`.} +See `CLAUDE.md` Stack section and `pyproject.toml` for the authoritative dependency list. The stack favours a single-host, dependency-light footprint: FastAPI + SQLAlchemy 2.0 async + Pydantic v2 for a strictly-typed backend, PostgreSQL 16 with pgvector so the vector store needs no separate service (`docs/ADR/ADR-0003`), and a Vite SPA rather than a server-rendered frontend (`docs/ADR/ADR-0002`). No managed-cloud SDK sits in the core path — that is a deliberate constraint from `.claude/rules/product-vision.md`. ## Local Development Setup @@ -23,7 +22,13 @@ uv run uvicorn app.main:app --reload --port 8123 cd frontend && corepack enable pnpm && pnpm install && pnpm dev ``` -{FILL IN: any host-specific notes (e.g., WSL caveats from `HANDOFF.md` on corrupt `.venv` / `node_modules` binaries).} +Host-specific notes: + +- **WSL:** `.venv/bin/*` and `frontend/node_modules/.bin/*` binaries can become corrupt `IntxLNK` blobs after a Windows file-server bridge event — the symptom is `cannot execute binary file`. Rebuild with `rm -rf .venv && uv sync --extra dev` and `rm -rf frontend/node_modules && cd frontend && pnpm install && pnpm rebuild esbuild`. +- **pnpm 11:** `pnpm dev` can stall on the `depsStatusCheck` preflight reinstalling esbuild. Bypass it with `./node_modules/.bin/vite --host 0.0.0.0`, or add `pnpm.onlyBuiltDependencies: ["esbuild"]` to `frontend/package.json`. +- **Frontend API base:** `frontend/.env` `VITE_API_BASE_URL` must resolve from the browser's host — point it at `http://localhost:8123` for local work. + +See `docs/_base/RUNBOOKS.md` → "Common Incidents" for the full list. ## Running Tests @@ -34,7 +39,7 @@ uv run pytest -v -m integration # integration (real Postgres) cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run ``` -{FILL IN: coverage targets if any; how to add a new vertical slice's `tests/`.} +There is no enforced coverage percentage; the gate is `.claude/rules/test-requirements.md`. Every new module, public function, API endpoint, SQLAlchemy model, and Alembic migration ships with a matching test, and every bug fix ships a regression test that would have caught it. A new vertical slice gets its own `app/features//tests/` directory with a `conftest.py` for fixtures and `test_.py` files — unit tests mock external services (OpenAI, Anthropic, Ollama), integration tests are marked `@pytest.mark.integration` and run against the real `docker-compose` Postgres. Never mock the database in an integration test. ## Project Conventions @@ -42,12 +47,19 @@ Authoritative rules live in `.claude/rules/` and are surfaced in `docs/_base/RUL - Vertical-slice imports: `app/features/X` may NOT import from `app/features/Y`. Cross-cutting code goes to `app/shared/` or `app/core/`. - The seeder is the only sanctioned bulk-mutation path on the DB. -- {FILL IN: any other conventions discovered in practice.} +- All API errors use the RFC 7807 `application/problem+json` envelope via `app/core/problem_details.py` — never raise a bare `HTTPException` with a raw string. +- Time-safety is load-bearing: `app/features/featuresets/tests/test_leakage.py` is the spec and must never be weakened to make a feature pass. +- Alembic migrations are forward-only once merged — fix forward with a new migration, never edit a merged one. +- Read settings via `app/core/config.get_settings()`; never touch `os.environ` directly in feature code. +- Agent tools that mutate state must be listed in `agent_require_approval` so the human-in-the-loop gate fires. +- Commits follow `type(scope): description (#issue)` and reference an open issue; branches are `/` off `dev`. ## Why We Chose These Technologies - ADRs live in `docs/ADR/` — see `docs/ADR/ADR-INDEX.md`. -- {FILL IN: short narrative for newcomers — e.g., "pgvector chosen over a managed vector DB to keep the system single-host; see ADR-0003."} +- **pgvector over a managed vector DB** — keeps the system single-host; one `docker-compose up` brings up Postgres and the vector store together (`docs/ADR/ADR-0003`). +- **Vite SPA over server-side rendering** — keeps the backend a pure JSON + WebSocket API with no rendering concerns, so the frontend can be developed and deployed independently (`docs/ADR/ADR-0002`). +- **PydanticAI for the agent layer** — its typed tool-call contracts fit the repo's strict-typing invariant, so LLM tool inputs are validated the same way HTTP request bodies are. ## Common Troubleshooting @@ -55,7 +67,7 @@ See `docs/_base/RUNBOOKS.md` — "Common Incidents" section covers the recurring ## Contacts & Resources -- Maintainer: Gabor Szabo +- Maintainer: Gabor Szabo (`@w7-mgfcode`) - Issue tracker: GitHub Issues on this repo - Release tracker: `CHANGELOG.md` (release-please-managed) -- {FILL IN: any out-of-repo links — Slack, Notion, demo URL.} +- Out-of-repo links: none — this is a single-maintainer portfolio repo with no Slack/Notion workspace and no hosted demo URL (the system runs locally only). All coordination happens through GitHub Issues and PRs. diff --git a/docs/_base/REPO_MAP_INDEX.md b/docs/_base/REPO_MAP_INDEX.md index e34bace0..bceb6157 100644 --- a/docs/_base/REPO_MAP_INDEX.md +++ b/docs/_base/REPO_MAP_INDEX.md @@ -19,6 +19,10 @@ ForecastLabAI is a portfolio-grade, single-host retail-demand-forecasting system | [`CHANGELOG.md`](../../CHANGELOG.md) | release-please-managed release notes | Investigating when behavior changed | | [`pyproject.toml`](../../pyproject.toml) | Dependencies, ruff/mypy/pyright/pytest config | Tooling questions, version bumps | | [`docker-compose.yml`](../../docker-compose.yml) | Local Postgres+pgvector definition | Debugging DB connectivity, ports | +| [`Makefile`](../../Makefile) | `make demo` / `demo-quick` / `demo-clean` entry points (PRP-15) | Running the end-to-end demo pipeline | +| [`scripts/run_demo.py`](../../scripts/run_demo.py) | End-to-end pipeline driver — seed → features → train ×3 → backtest → register → alias → agent | First-run demonstrability, integration debugging | +| [`app/features/demo/`](../../app/features/demo/) | In-process e2e demo slice — `POST /demo/run` + `WS /demo/stream` drive the pipeline via `ASGITransport` (no cross-slice imports) | Showcase page, in-product demo | +| [`frontend/src/pages/showcase.tsx`](../../frontend/src/pages/showcase.tsx) | The Showcase page — streams the live pipeline into the dashboard as status cards | Demoing the system in-browser | | [`alembic/versions/`](../../alembic/versions/) | Six migrations through `d6e0f2g3h456_create_agent_session_table.py` | DB-schema questions, migration drift | | [`docs/ARCHITECTURE.md`](../ARCHITECTURE.md) | Phase-by-phase architecture narrative | High-level component reasoning | | [`docs/PHASE-index.md`](../PHASE-index.md) | Index of all 11 phase docs | Locating per-phase deep-dive | diff --git a/docs/_base/RUNBOOKS.md b/docs/_base/RUNBOOKS.md index 3f6919eb..1ad13d29 100644 --- a/docs/_base/RUNBOOKS.md +++ b/docs/_base/RUNBOOKS.md @@ -60,6 +60,41 @@ rm -rf .venv && uv sync --extra dev rm -rf frontend/node_modules && corepack enable pnpm && cd frontend && pnpm install && pnpm rebuild esbuild ``` +### `make demo` fails at step X +**Symptoms:** `scripts/run_demo.py` prints `❌ Step N/11: -- ...` and exits 1 (step failure) or 2 (precondition). +**Diagnosis flow:** +1. **Precheck failed (exit 2)** — backend isn't reachable on the URL the script is hitting. + ```bash + curl -s http://localhost:8123/health # should print {"status":"ok"} + docker compose ps # confirm Postgres is up on :5433 + ``` + Fix: start uvicorn (`uv run uvicorn app.main:app --port 8123`) and/or `docker compose up -d`. The Makefile targets `demo` and `demo-clean` invoke `docker compose up -d` for you; `demo-quick` does not. +2. **Seed step failed** — production-guard or scenario mismatch. The script POSTs `demo_minimal` to `/seeder/generate`; check `app_env != "production"` (or set `seeder_allow_production=true` if you really mean it). The scenario must exist in `app/shared/seeder/config.py:ScenarioPreset` (added by PRP-15 / issue #128). +3. **Features step failed** — schema drift on `ComputeFeaturesRequest`. The script sends a minimal `FeatureSetConfig` with `name="demo_featureset"` + lag/rolling/calendar configs; if a recent change tightened a `Field(strict=...)` constraint, the failure surfaces here. +4. **Train step failed (one of three)** — the script trains naive / seasonal_naive / moving_average in parallel via `asyncio.gather`. Check the failing model's RFC 7807 body (echoed in the script output); the `request_id` correlates with the uvicorn logs. +5. **Backtest produced NaN WAPE** — `demo_minimal` is tuned to avoid the SPARSE-style NaN trap (moderate `noise_sigma=0.10`, no sparsity). If you customized the scenario and now hit NaN, follow the `app/shared/seeder/tests/test_phase1_regression.py` pattern. +6. **Register step failed** — most likely `pending → success` instead of the mandatory `pending → running → success` transition, or `alias_name` doesn't match the registry pattern (`^[a-z0-9][a-z0-9\-_]*$`). The script uses `demo-production` which is compliant; only worry if you forked the script. +7. **Agent step showed ⏭️ but you expected ✅** — `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` not set in the environment the backend reads. Verify with `grep -E '^(OPENAI|ANTHROPIC)_API_KEY=' .env` (name only — never paste the value). + +**Wall-clock soft-warn:** +- `⚠️ Result: GREEN (over budget ...)` — the run succeeded but exceeded the 180 s budget. Not a failure; expected on slower hardware. The integration test (`tests/test_e2e_demo.py`) follows the same soft-warn semantics. + +**Capture artifacts for a postmortem:** +```bash +# Nightly CI uploads .ci-logs/uvicorn.log on failure (e2e-nightly.yml). +# Locally, capture both streams: +uv run python scripts/run_demo.py --seed 42 --quiet 2>&1 | tee demo.log +``` + +### Showcase page (`/showcase`) pipeline fails at step X +**Symptoms:** The dashboard Showcase page (`/showcase`) — or `POST /demo/run` — shows a step card flip to ❌, the run stops, and the summary banner is red. +**Diagnosis flow (matches `app/features/demo/pipeline.py` step names):** +1. **`status` step fails** — `skip_seed=true` (the default) ran against an empty database. Seed first: tick **Re-seed first** on the page, or `POST /seeder/generate` the `demo_minimal` scenario, or run `make demo` once. +2. **`register` step fails with `HTTP 500 -- Database Error`** — the registry's `_find_duplicate` hit multiple pre-existing `model_run` rows with the same config hash (accumulated by prior `make demo` / `run_demo.py` runs). Not a demo-slice bug — the demo correctly surfaces the registry's 500. Fix by clearing stale runs or running against a fresh database. +3. **`agent` step shows ⏭️** — no API key matches the configured `agent_default_model` provider, or the provider rejected the key. Expected; not a failure. The pipeline still goes green. +4. **Page shows an `error` banner ("Pipeline could not start")** — either the start frame was malformed, or another run is already in progress (`409`). Only one demo pipeline runs at a time (module-level `asyncio.Lock`). Wait for the active run to finish. +**Notes:** the `POST /demo/run` body and `WS /demo/stream` events are documented in `docs/_base/API_CONTRACTS.md`. The pipeline mirrors `scripts/run_demo.py`; the per-step diagnosis for `make demo` above applies to the same steps. + ### release-please skipped the bump after a dev → main merge **Symptoms:** `dev → main` PR is merged, `CD Release` workflow on `main` completes in ~10s, **no Release PR** is opened. release-please log shows `No user facing commits found since - skipping`. **Root cause:** `gh pr merge --merge` uses the **PR title** as the merge-commit subject. If that subject is a valid conventional commit of a non-bumping type (`chore`, `docs`, `refactor`, `test`, `ci`), release-please reads it at face value, classifies the whole merge as non-bumping, and stops. Prior dev→main merges done via the GitHub web UI used the default `Merge pull request #N from ` subject — non-conventional — so release-please traversed to the underlying commits and bumped correctly. diff --git a/frontend/package.json b/frontend/package.json index 8166e890..ab581121 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.12", @@ -44,6 +45,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -52,9 +55,14 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.1.1", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.6" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b3b8c11b..10705189 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -105,6 +105,12 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.2 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: ^24.10.1 version: 24.10.9 @@ -129,6 +135,9 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -141,9 +150,27 @@ importers: vite: specifier: ^7.2.4 version: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@24.10.9)(jsdom@29.1.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)) packages: + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -231,6 +258,46 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -428,6 +495,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -1003,66 +1079,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1094,6 +1183,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -1132,24 +1224,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -1203,6 +1299,28 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1215,6 +1333,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1242,6 +1363,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1324,6 +1448,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1337,10 +1490,18 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1348,6 +1509,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1355,6 +1523,9 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1373,6 +1544,10 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1405,6 +1580,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1452,6 +1631,10 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} @@ -1470,9 +1653,16 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1480,6 +1670,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -1490,6 +1683,13 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -1552,6 +1752,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1559,6 +1762,10 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1634,6 +1841,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1662,6 +1873,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1676,6 +1890,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1737,24 +1960,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -1786,6 +2013,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1794,9 +2025,16 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1828,6 +2066,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1844,6 +2085,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1852,6 +2096,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1867,6 +2114,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1888,6 +2139,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -1968,6 +2222,10 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1977,6 +2235,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2000,6 +2262,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -2010,6 +2275,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2018,6 +2289,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -2031,10 +2305,36 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -2066,6 +2366,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2138,15 +2442,84 @@ packages: yaml: optional: true + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2165,6 +2538,26 @@ packages: snapshots: + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2279,6 +2672,34 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.4.1': {} '@esbuild/aix-ppc64@0.27.2': @@ -2405,6 +2826,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -3039,6 +3462,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3122,6 +3547,29 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -3143,6 +3591,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -3167,6 +3620,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -3286,6 +3741,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -3299,20 +3795,34 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} baseline-browser-mapping@2.9.19: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3334,6 +3844,8 @@ snapshots: caniuse-lite@1.0.30001766: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3363,6 +3875,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} d3-array@3.2.4: @@ -3403,6 +3920,13 @@ snapshots: d3-timer@3.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + date-fns-jalali@4.1.0-0: {} date-fns@4.1.0: {} @@ -3413,12 +3937,18 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + dom-accessibility-api@0.5.16: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -3431,6 +3961,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@8.0.0: {} + + es-module-lexer@2.1.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -3545,10 +4079,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@4.0.7: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -3602,6 +4142,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3621,6 +4167,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} jiti@2.6.1: {} @@ -3631,6 +4179,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -3711,6 +4285,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3719,10 +4295,14 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mdn-data@2.27.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -3746,6 +4326,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3767,10 +4349,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -3783,6 +4371,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3805,6 +4399,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-refresh@0.18.0: {} @@ -3886,6 +4482,8 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} rollup@4.57.1: @@ -3919,6 +4517,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -3933,6 +4535,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -3940,12 +4544,18 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + strip-json-comments@3.1.1: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tailwind-merge@3.4.0: {} tailwindcss@4.1.18: {} @@ -3954,11 +4564,31 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3986,6 +4616,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.25.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -4042,12 +4674,65 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 + vitest@4.1.6(@types/node@24.10.9)(jsdom@29.1.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.9 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d11e9a2b..c853e202 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { ROUTES } from '@/lib/constants' // Lazy-loaded page components const DashboardPage = lazy(() => import('@/pages/dashboard')) +const ShowcasePage = lazy(() => import('@/pages/showcase')) const SalesExplorerPage = lazy(() => import('@/pages/explorer/sales')) const StoresExplorerPage = lazy(() => import('@/pages/explorer/stores')) const ProductsExplorerPage = lazy(() => import('@/pages/explorer/products')) @@ -38,6 +39,14 @@ function App() { } /> + }> + + + } + /> = { - mae: 'hsl(var(--chart-1))', - smape: 'hsl(var(--chart-2))', - wape: 'hsl(var(--chart-3))', - bias: 'hsl(var(--chart-4))', + mae: 'var(--chart-1)', + smape: 'var(--chart-2)', + wape: 'var(--chart-3)', + bias: 'var(--chart-4)', } const metricLabels: Record = { diff --git a/frontend/src/components/charts/time-series-chart.tsx b/frontend/src/components/charts/time-series-chart.tsx index 7d3dfdfc..c6ac232a 100644 --- a/frontend/src/components/charts/time-series-chart.tsx +++ b/frontend/src/components/charts/time-series-chart.tsx @@ -39,14 +39,16 @@ export function TimeSeriesChart({ height = 300, className, }: TimeSeriesChartProps) { + // The --chart-N vars are complete oklch() colours (Tailwind 4 / shadcn v4); + // reference them directly — wrapping in hsl() produces invalid CSS (black). const chartConfig: ChartConfig = { [actualKey]: { label: 'Actual', - color: 'hsl(var(--chart-1))', + color: 'var(--chart-1)', }, [predictedKey]: { label: 'Predicted', - color: 'hsl(var(--chart-2))', + color: 'var(--chart-2)', }, } @@ -77,7 +79,7 @@ export function TimeSeriesChart({ + /** Currently loaded job ID (empty string when nothing is loaded). */ + selectedJobId: string + /** Called with a job ID when the user picks one or enters one manually. */ + onSelect: (jobId: string) => void + /** Auto-select the most recent completed job once the list first loads. */ + autoSelectLatest?: boolean +} + +/** Compact label for a job option: short id, model (when known), and timestamp. */ +function jobLabel(job: Job): string { + const shortId = job.job_id.slice(0, 8) + const when = format(new Date(job.created_at), 'MMM d, HH:mm') + const model = typeof job.params.model_type === 'string' ? job.params.model_type : null + return model ? `${shortId} · ${model} · ${when}` : `${shortId} · ${when}` +} + +/** + * Job selector for the visualization pages: a dropdown of completed jobs of a + * given type, plus a manual job-ID entry box for pasting an ID from elsewhere. + */ +export function JobPicker({ + jobType, + selectedJobId, + onSelect, + autoSelectLatest = false, +}: JobPickerProps) { + const [manualId, setManualId] = useState('') + + const { data, isLoading } = useJobs({ + page: 1, + pageSize: 50, + jobType, + status: 'completed', + }) + // Memoised so the auto-select effect below has a stable dependency. + const jobs = useMemo(() => data?.jobs ?? [], [data]) + + // Auto-select the most recent completed job once, when the list first + // arrives and nothing has been selected yet (jobs come newest-first). + useEffect(() => { + if (autoSelectLatest && !selectedJobId && jobs.length > 0) { + onSelect(jobs[0].job_id) + } + }, [autoSelectLatest, selectedJobId, jobs, onSelect]) + + const handleManualLoad = () => { + const trimmed = manualId.trim() + if (trimmed) onSelect(trimmed) + } + + // Only bind the dropdown to selectedJobId when it refers to a listed job, so + // a manually-pasted (and possibly unlisted) ID doesn't break the trigger. + const dropdownValue = jobs.some((j) => j.job_id === selectedJobId) ? selectedJobId : '' + + return ( +
+ + +
+ + or paste an ID: + + setManualId(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleManualLoad() + }} + className="max-w-xs" + /> + +
+
+ ) +} diff --git a/frontend/src/components/demo/demo-step-card.tsx b/frontend/src/components/demo/demo-step-card.tsx new file mode 100644 index 00000000..6d230066 --- /dev/null +++ b/frontend/src/components/demo/demo-step-card.tsx @@ -0,0 +1,131 @@ +import type { DemoStep, DemoStepUiStatus } from '@/hooks/use-demo-pipeline' +import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' + +// Status glyphs -- the vocabulary from .claude/rules/output-formatting.md. +const STATUS_GLYPH: Record = { + idle: '○', + running: '🔄', + pass: '✅', + fail: '❌', + skip: '⏭️', + warn: '⚠️', +} + +// Left-border accent colour per status. +const STATUS_ACCENT: Record = { + idle: 'border-l-border', + running: 'border-l-blue-500', + pass: 'border-l-green-500', + fail: 'border-l-red-500', + skip: 'border-l-muted-foreground/40', + warn: 'border-l-yellow-500', +} + +function formatDuration(ms: number): string { + if (ms <= 0) return '' + if (ms < 1000) return `${Math.round(ms)} ms` + return `${(ms / 1000).toFixed(1)} s` +} + +/** Per-model WAPE breakdown rendered inside the backtest step card. */ +function BacktestBreakdown({ data }: { data: Record }) { + const perModel = data.per_model + const winner = typeof data.winner === 'string' ? data.winner : null + if (perModel === null || typeof perModel !== 'object') return null + + const rows = Object.entries(perModel as Record).map(([model, metrics]) => { + const wape = + metrics !== null && typeof metrics === 'object' + ? (metrics as Record).wape + : undefined + return { model, wape: typeof wape === 'number' ? wape : null } + }) + + return ( +
+ {rows.map((row) => ( +
+ + {row.model === winner ? '🏆 ' : ''} + {row.model} + + + WAPE {row.wape !== null ? row.wape.toFixed(4) : 'n/a'} + +
+ ))} +
+ ) +} + +/** Registered-run detail rendered inside the register step card. */ +function RegisterDetail({ data }: { data: Record }) { + const runId = typeof data.run_id === 'string' ? data.run_id : null + const alias = typeof data.alias === 'string' ? data.alias : null + if (!runId) return null + return ( +
+ run_id: {runId} + {alias && ( + alias: {alias} + )} +
+ ) +} + +interface DemoStepCardProps { + step: DemoStep + index: number +} + +/** One pipeline step rendered as a status card. */ +export function DemoStepCard({ step, index }: DemoStepCardProps) { + const duration = formatDuration(step.durationMs) + return ( + +
+ + {STATUS_GLYPH[step.status]} + +
+
+

+ + {String(index + 1).padStart(2, '0')}. + {' '} + {step.label} +

+ {duration && ( + + {duration} + + )} +
+ {step.detail && ( +

{step.detail}

+ )} + {step.name === 'backtest' && } + {step.name === 'register' && } +
+
+
+ ) +} diff --git a/frontend/src/components/demo/index.ts b/frontend/src/components/demo/index.ts new file mode 100644 index 00000000..48f34346 --- /dev/null +++ b/frontend/src/components/demo/index.ts @@ -0,0 +1 @@ +export * from './demo-step-card' diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index b6d05698..8f26c454 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -7,3 +7,4 @@ export * from './use-jobs' export * from './use-rag-sources' export * from './use-websocket' export * from './use-seeder' +export * from './use-demo-pipeline' diff --git a/frontend/src/hooks/use-demo-pipeline.test.ts b/frontend/src/hooks/use-demo-pipeline.test.ts new file mode 100644 index 00000000..eeec6e1c --- /dev/null +++ b/frontend/src/hooks/use-demo-pipeline.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest' +import { renderHook } from '@testing-library/react' +import { + applyEvent, + createInitialSteps, + initialState, + useDemoPipeline, +} from './use-demo-pipeline' +import type { StepEvent } from '@/types/api' + +/** Build a StepEvent with sensible defaults for the fields not under test. */ +function makeEvent(partial: Partial & Pick): StepEvent { + return { + event_type: partial.event_type, + step_name: partial.step_name ?? 'precheck', + step_index: partial.step_index ?? 1, + total_steps: partial.total_steps ?? 11, + status: partial.status ?? null, + detail: partial.detail ?? '', + duration_ms: partial.duration_ms ?? 0, + data: partial.data ?? {}, + timestamp: partial.timestamp ?? '2026-05-17T00:00:00Z', + } +} + +describe('createInitialSteps', () => { + it('creates 11 idle steps in pipeline order', () => { + const steps = createInitialSteps() + expect(steps).toHaveLength(11) + expect(steps.every((s) => s.status === 'idle')).toBe(true) + expect(steps[0]?.name).toBe('precheck') + expect(steps[10]?.name).toBe('cleanup') + }) +}) + +describe('initialState', () => { + it('starts idle with no summary and no error', () => { + const state = initialState() + expect(state.phase).toBe('idle') + expect(state.summary).toBeNull() + expect(state.errorMessage).toBeNull() + }) +}) + +describe('applyEvent', () => { + it('marks a step running on step_start and enters the running phase', () => { + const next = applyEvent( + initialState(), + makeEvent({ event_type: 'step_start', step_name: 'train' }) + ) + expect(next.phase).toBe('running') + expect(next.steps.find((s) => s.name === 'train')?.status).toBe('running') + expect(next.steps.find((s) => s.name === 'precheck')?.status).toBe('idle') + }) + + it('records the outcome on step_complete', () => { + const next = applyEvent( + initialState(), + makeEvent({ + event_type: 'step_complete', + step_name: 'backtest', + status: 'pass', + detail: '3 models', + duration_ms: 1500, + data: { winner: 'naive' }, + }) + ) + const step = next.steps.find((s) => s.name === 'backtest') + expect(step?.status).toBe('pass') + expect(step?.detail).toBe('3 models') + expect(step?.durationMs).toBe(1500) + expect(step?.data).toEqual({ winner: 'naive' }) + }) + + it('defaults a null step_complete status to pass', () => { + const next = applyEvent( + initialState(), + makeEvent({ event_type: 'step_complete', step_name: 'seed', status: null }) + ) + expect(next.steps.find((s) => s.name === 'seed')?.status).toBe('pass') + }) + + it('builds a summary on pipeline_complete', () => { + const next = applyEvent( + initialState(), + makeEvent({ + event_type: 'pipeline_complete', + step_name: 'summary', + status: 'pass', + data: { + winner_model_type: 'seasonal_naive', + winner_wape: 0.12, + winning_run_id: 'run-abc', + alias: 'demo-production', + wall_clock_s: 42, + }, + }) + ) + expect(next.phase).toBe('done') + expect(next.summary).toEqual({ + overallStatus: 'pass', + winnerModelType: 'seasonal_naive', + winnerWape: 0.12, + winningRunId: 'run-abc', + alias: 'demo-production', + wallClockS: 42, + }) + }) + + it('reports a failed pipeline_complete as fail', () => { + const next = applyEvent( + initialState(), + makeEvent({ event_type: 'pipeline_complete', step_name: 'summary', status: 'fail', data: {} }) + ) + expect(next.phase).toBe('done') + expect(next.summary?.overallStatus).toBe('fail') + expect(next.summary?.winnerModelType).toBeNull() + }) + + it('sets the error phase on an error event', () => { + const next = applyEvent( + initialState(), + makeEvent({ event_type: 'error', detail: 'already running' }) + ) + expect(next.phase).toBe('error') + expect(next.errorMessage).toBe('already running') + }) + + it('transitions a step idle -> running -> pass across events', () => { + let state = initialState() + expect(state.steps.find((s) => s.name === 'features')?.status).toBe('idle') + + state = applyEvent(state, makeEvent({ event_type: 'step_start', step_name: 'features' })) + expect(state.steps.find((s) => s.name === 'features')?.status).toBe('running') + + state = applyEvent( + state, + makeEvent({ event_type: 'step_complete', step_name: 'features', status: 'pass' }) + ) + expect(state.steps.find((s) => s.name === 'features')?.status).toBe('pass') + }) + + it('reaches the done phase after a full step + summary sequence', () => { + let state = initialState() + state = applyEvent(state, makeEvent({ event_type: 'step_start', step_name: 'precheck' })) + state = applyEvent( + state, + makeEvent({ event_type: 'step_complete', step_name: 'precheck', status: 'pass' }) + ) + expect(state.phase).toBe('running') + + state = applyEvent( + state, + makeEvent({ + event_type: 'pipeline_complete', + step_name: 'summary', + status: 'pass', + data: {}, + }) + ) + expect(state.phase).toBe('done') + }) +}) + +describe('useDemoPipeline', () => { + it('initializes with 11 idle steps and the idle phase', () => { + const { result } = renderHook(() => useDemoPipeline()) + expect(result.current.steps).toHaveLength(11) + expect(result.current.phase).toBe('idle') + expect(result.current.isRunning).toBe(false) + expect(result.current.summary).toBeNull() + }) +}) diff --git a/frontend/src/hooks/use-demo-pipeline.ts b/frontend/src/hooks/use-demo-pipeline.ts new file mode 100644 index 00000000..b372ecda --- /dev/null +++ b/frontend/src/hooks/use-demo-pipeline.ts @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useWebSocket } from '@/hooks/use-websocket' +import { DEMO_WS_URL } from '@/lib/constants' +import type { DemoRunRequest, StepEvent } from '@/types/api' + +// UI-side step status -- adds 'idle' to the wire-level DemoStepStatus. +export type DemoStepUiStatus = 'idle' | 'running' | 'pass' | 'fail' | 'skip' | 'warn' + +// Overall pipeline phase. +export type DemoPhase = 'idle' | 'running' | 'done' | 'error' + +export interface DemoStep { + name: string + label: string + status: DemoStepUiStatus + detail: string + durationMs: number + data: Record +} + +export interface DemoSummary { + overallStatus: 'pass' | 'fail' + winnerModelType: string | null + winnerWape: number | null + winningRunId: string | null + alias: string | null + wallClockS: number +} + +export interface DemoPipelineState { + steps: DemoStep[] + phase: DemoPhase + summary: DemoSummary | null + errorMessage: string | null +} + +// The 11 pipeline steps, in order. Mirrors the backend `_step_table()` in +// app/features/demo/pipeline.py so the page can render idle cards before a run. +const STEP_DEFS: ReadonlyArray<{ name: string; label: string }> = [ + { name: 'precheck', label: 'Health check' }, + { name: 'reset', label: 'Reset database' }, + { name: 'seed', label: 'Seed demo data' }, + { name: 'status', label: 'Inspect dataset' }, + { name: 'features', label: 'Compute features' }, + { name: 'train', label: 'Train models' }, + { name: 'backtest', label: 'Backtest models' }, + { name: 'register', label: 'Register winner' }, + { name: 'verify', label: 'Verify artifact' }, + { name: 'agent', label: 'Agent chat' }, + { name: 'cleanup', label: 'Cleanup' }, +] + +/** Build the 11 step cards in their initial idle state. */ +export function createInitialSteps(): DemoStep[] { + return STEP_DEFS.map((def) => ({ + name: def.name, + label: def.label, + status: 'idle', + detail: '', + durationMs: 0, + data: {}, + })) +} + +/** The fresh pipeline state used before a run and on reset. */ +export function initialState(): DemoPipelineState { + return { steps: createInitialSteps(), phase: 'idle', summary: null, errorMessage: null } +} + +function toNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function toStringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +/** + * Pure reducer: fold one streamed StepEvent into the pipeline state. + * + * Exported so the state machine is unit-testable without a WebSocket. + */ +export function applyEvent(state: DemoPipelineState, event: StepEvent): DemoPipelineState { + switch (event.event_type) { + case 'step_start': { + const steps = state.steps.map((step) => + step.name === event.step_name ? { ...step, status: 'running' as const } : step + ) + return { ...state, steps, phase: 'running' } + } + case 'step_complete': { + const status: DemoStepUiStatus = event.status ?? 'pass' + const steps = state.steps.map((step) => + step.name === event.step_name + ? { + ...step, + status, + detail: event.detail, + durationMs: event.duration_ms, + data: event.data, + } + : step + ) + return { ...state, steps } + } + case 'pipeline_complete': { + const summary: DemoSummary = { + overallStatus: event.status === 'fail' ? 'fail' : 'pass', + winnerModelType: toStringOrNull(event.data.winner_model_type), + winnerWape: toNumber(event.data.winner_wape), + winningRunId: toStringOrNull(event.data.winning_run_id), + alias: toStringOrNull(event.data.alias), + wallClockS: toNumber(event.data.wall_clock_s) ?? 0, + } + return { ...state, phase: 'done', summary } + } + case 'error': { + return { ...state, phase: 'error', errorMessage: event.detail || 'Pipeline error' } + } + default: + return state + } +} + +export interface UseDemoPipelineResult { + steps: DemoStep[] + phase: DemoPhase + summary: DemoSummary | null + errorMessage: string | null + isRunning: boolean + connectionStatus: ReturnType['status'] + start: (req: DemoRunRequest) => void +} + +/** + * Drive the in-product demo pipeline over a one-shot WebSocket. + * + * `start(req)` resets the cards, opens the socket, and sends the start frame + * once connected. The socket is closed on `pipeline_complete` / `error` so it + * never auto-reconnects and re-triggers a run. + */ +export function useDemoPipeline(): UseDemoPipelineResult { + const [state, setState] = useState(initialState) + const pendingReq = useRef(null) + const disconnectRef = useRef<(() => void) | null>(null) + + const handleMessage = useCallback((data: unknown) => { + const event = data as StepEvent + setState((prev) => applyEvent(prev, event)) + if (event.event_type === 'pipeline_complete' || event.event_type === 'error') { + disconnectRef.current?.() + } + }, []) + + const { status, send, disconnect, reconnect } = useWebSocket(DEMO_WS_URL, { + onMessage: handleMessage, + autoConnect: false, + }) + + useEffect(() => { + disconnectRef.current = disconnect + }, [disconnect]) + + // Send the queued start frame once the socket is open. + useEffect(() => { + if (status === 'connected' && pendingReq.current) { + send(pendingReq.current) + pendingReq.current = null + } + }, [status, send]) + + const start = useCallback( + (req: DemoRunRequest) => { + setState({ ...initialState(), phase: 'running' }) + pendingReq.current = req + reconnect() + }, + [reconnect] + ) + + return { + steps: state.steps, + phase: state.phase, + summary: state.summary, + errorMessage: state.errorMessage, + isRunning: state.phase === 'running', + connectionStatus: status, + start, + } +} diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index c9eb9fba..ff5acf62 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -1,51 +1,59 @@ -// Route paths -export const ROUTES = { - DASHBOARD: '/', - EXPLORER: { - SALES: '/explorer/sales', - STORES: '/explorer/stores', - PRODUCTS: '/explorer/products', - RUNS: '/explorer/runs', - JOBS: '/explorer/jobs', - }, - VISUALIZE: { - FORECAST: '/visualize/forecast', - BACKTEST: '/visualize/backtest', - }, - CHAT: '/chat', - ADMIN: '/admin', -} as const - -// Navigation items for the top nav -export const NAV_ITEMS = [ - { label: 'Dashboard', href: ROUTES.DASHBOARD }, - { - label: 'Explorer', - items: [ - { label: 'Sales', href: ROUTES.EXPLORER.SALES }, - { label: 'Stores', href: ROUTES.EXPLORER.STORES }, - { label: 'Products', href: ROUTES.EXPLORER.PRODUCTS }, - { label: 'Model Runs', href: ROUTES.EXPLORER.RUNS }, - { label: 'Jobs', href: ROUTES.EXPLORER.JOBS }, - ], - }, - { - label: 'Visualize', - items: [ - { label: 'Forecast', href: ROUTES.VISUALIZE.FORECAST }, - { label: 'Backtest Results', href: ROUTES.VISUALIZE.BACKTEST }, - ], - }, - { label: 'Chat', href: ROUTES.CHAT }, - { label: 'Admin', href: ROUTES.ADMIN }, -] as const - -// Default pagination -export const DEFAULT_PAGE_SIZE = 25 - -// WebSocket URL -export const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8123/agents/stream' - -// Feature flags -export const ENABLE_AGENT_CHAT = import.meta.env.VITE_ENABLE_AGENT_CHAT !== 'false' -export const ENABLE_ADMIN_PANEL = import.meta.env.VITE_ENABLE_ADMIN_PANEL !== 'false' +// Route paths +export const ROUTES = { + DASHBOARD: '/', + SHOWCASE: '/showcase', + EXPLORER: { + SALES: '/explorer/sales', + STORES: '/explorer/stores', + PRODUCTS: '/explorer/products', + RUNS: '/explorer/runs', + JOBS: '/explorer/jobs', + }, + VISUALIZE: { + FORECAST: '/visualize/forecast', + BACKTEST: '/visualize/backtest', + }, + CHAT: '/chat', + ADMIN: '/admin', +} as const + +// Navigation items for the top nav +export const NAV_ITEMS = [ + { label: 'Dashboard', href: ROUTES.DASHBOARD }, + { label: 'Showcase', href: ROUTES.SHOWCASE }, + { + label: 'Explorer', + items: [ + { label: 'Sales', href: ROUTES.EXPLORER.SALES }, + { label: 'Stores', href: ROUTES.EXPLORER.STORES }, + { label: 'Products', href: ROUTES.EXPLORER.PRODUCTS }, + { label: 'Model Runs', href: ROUTES.EXPLORER.RUNS }, + { label: 'Jobs', href: ROUTES.EXPLORER.JOBS }, + ], + }, + { + label: 'Visualize', + items: [ + { label: 'Forecast', href: ROUTES.VISUALIZE.FORECAST }, + { label: 'Backtest Results', href: ROUTES.VISUALIZE.BACKTEST }, + ], + }, + { label: 'Chat', href: ROUTES.CHAT }, + { label: 'Admin', href: ROUTES.ADMIN }, +] as const + +// Default pagination +export const DEFAULT_PAGE_SIZE = 25 + +// WebSocket URL (agent chat stream) +export const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8123/agents/stream' + +// WebSocket URL for the demo showcase pipeline stream. Derived from the API +// base URL so it tracks whatever host the SPA is configured to call. +export const DEMO_WS_URL = + (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8123').replace(/^http/, 'ws') + + '/demo/stream' + +// Feature flags +export const ENABLE_AGENT_CHAT = import.meta.env.VITE_ENABLE_AGENT_CHAT !== 'false' +export const ENABLE_ADMIN_PANEL = import.meta.env.VITE_ENABLE_ADMIN_PANEL !== 'false' diff --git a/frontend/src/pages/chat.tsx b/frontend/src/pages/chat.tsx index de29f540..f1da0a15 100644 --- a/frontend/src/pages/chat.tsx +++ b/frontend/src/pages/chat.tsx @@ -18,17 +18,17 @@ import { api } from '@/lib/api' import { WS_URL } from '@/lib/constants' import type { ChatMessage as ChatMessageType, AgentStreamEvent, AgentType, AgentSession } from '@/types/api' -export default function ChatPage() { - const [sessionId, setSessionId] = useState(null) - const [agentType, setAgentType] = useState('rag_assistant') - const [messages, setMessages] = useState([]) - const [streamingContent, setStreamingContent] = useState('') - const [isStreaming, setIsStreaming] = useState(false) - const [pendingAction, setPendingAction] = useState<{ - actionId?: string - action: string - details?: Record - } | null>(null) +export default function ChatPage() { + const [sessionId, setSessionId] = useState(null) + const [agentType, setAgentType] = useState('rag_assistant') + const [messages, setMessages] = useState([]) + const [streamingContent, setStreamingContent] = useState('') + const [isStreaming, setIsStreaming] = useState(false) + const [pendingAction, setPendingAction] = useState<{ + actionId?: string + action: string + details?: Record + } | null>(null) const [currentToolCall, setCurrentToolCall] = useState(null) const [isCreatingSession, setIsCreatingSession] = useState(false) const [isApproving, setIsApproving] = useState(false) @@ -42,10 +42,10 @@ export default function ChatPage() { const event = data as AgentStreamEvent switch (event.event_type) { - case 'text_delta': - setIsStreaming(true) - setStreamingContent((prev) => prev + ((event.data.delta as string) ?? '')) - break + case 'text_delta': + setIsStreaming(true) + setStreamingContent((prev) => prev + ((event.data.delta as string) ?? '')) + break case 'tool_call_start': setCurrentToolCall(event.data.tool_name as string) @@ -55,43 +55,44 @@ export default function ChatPage() { setCurrentToolCall(null) break - case 'approval_required': - // Backend sends full pending action under "action" - const action = event.data.action as Record | undefined - setPendingAction({ - actionId: action?.action_id as string | undefined, - action: (action?.action_type as string | undefined) ?? 'unknown', - details: action ?? (event.data.details as Record | undefined), - }) - break - - case 'complete': - // Finalize the streaming message - if (streamingContent || event.data.message) { - const content = (event.data.message as string) || streamingContent - setMessages((prev) => [ - ...prev, - { - role: 'assistant', - content, - timestamp: new Date().toISOString(), - }, - ]) - } + case 'approval_required': { + // Backend sends full pending action under "action" + const action = event.data.action as Record | undefined + setPendingAction({ + actionId: action?.action_id as string | undefined, + action: (action?.action_type as string | undefined) ?? 'unknown', + details: action ?? (event.data.details as Record | undefined), + }) + break + } + + case 'complete': + // Finalize the streaming message + if (streamingContent || event.data.message) { + const content = (event.data.message as string) || streamingContent + setMessages((prev) => [ + ...prev, + { + role: 'assistant', + content, + timestamp: new Date().toISOString(), + }, + ]) + } setStreamingContent('') setIsStreaming(false) setCurrentToolCall(null) break - case 'error': - setMessages((prev) => [ - ...prev, - { - role: 'assistant', - content: `Error: ${(event.data.error as string) ?? 'Unknown error'}`, - timestamp: new Date().toISOString(), - }, - ]) + case 'error': + setMessages((prev) => [ + ...prev, + { + role: 'assistant', + content: `Error: ${(event.data.error as string) ?? 'Unknown error'}`, + timestamp: new Date().toISOString(), + }, + ]) setStreamingContent('') setIsStreaming(false) setCurrentToolCall(null) @@ -127,28 +128,28 @@ export default function ChatPage() { } } - const handleSend = (content: string) => { - if (!sessionId) return - - // Add user message - setMessages((prev) => [ - ...prev, - { role: 'user', content, timestamp: new Date().toISOString() }, - ]) - - // Send via WebSocket (must match backend protocol) - send({ session_id: sessionId, message: content }) - } + const handleSend = (content: string) => { + if (!sessionId) return + + // Add user message + setMessages((prev) => [ + ...prev, + { role: 'user', content, timestamp: new Date().toISOString() }, + ]) + + // Send via WebSocket (must match backend protocol) + send({ session_id: sessionId, message: content }) + } - const handleApprove = async () => { - if (!sessionId || !pendingAction?.actionId) return - setIsApproving(true) - try { - await api(`/agents/sessions/${sessionId}/approve`, { - method: 'POST', - body: { action_id: pendingAction.actionId, approved: true }, - }) - setPendingAction(null) + const handleApprove = async () => { + if (!sessionId || !pendingAction?.actionId) return + setIsApproving(true) + try { + await api(`/agents/sessions/${sessionId}/approve`, { + method: 'POST', + body: { action_id: pendingAction.actionId, approved: true }, + }) + setPendingAction(null) } catch (error) { console.error('Failed to approve:', error) } finally { @@ -156,15 +157,15 @@ export default function ChatPage() { } } - const handleReject = async () => { - if (!sessionId || !pendingAction?.actionId) return - setIsApproving(true) - try { - await api(`/agents/sessions/${sessionId}/approve`, { - method: 'POST', - body: { action_id: pendingAction.actionId, approved: false }, - }) - setPendingAction(null) + const handleReject = async () => { + if (!sessionId || !pendingAction?.actionId) return + setIsApproving(true) + try { + await api(`/agents/sessions/${sessionId}/approve`, { + method: 'POST', + body: { action_id: pendingAction.actionId, approved: false }, + }) + setPendingAction(null) } catch (error) { console.error('Failed to reject:', error) } finally { diff --git a/frontend/src/pages/showcase.tsx b/frontend/src/pages/showcase.tsx new file mode 100644 index 00000000..2b1d8687 --- /dev/null +++ b/frontend/src/pages/showcase.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { Play, Loader2, Trophy, AlertTriangle, ArrowRight } from 'lucide-react' +import { useDemoPipeline } from '@/hooks/use-demo-pipeline' +import { DemoStepCard } from '@/components/demo' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { ROUTES } from '@/lib/constants' +import { cn } from '@/lib/utils' + +const TERMINAL_STATUSES = new Set(['pass', 'fail', 'skip', 'warn']) + +export default function ShowcasePage() { + const { steps, phase, summary, errorMessage, isRunning, connectionStatus, start } = + useDemoPipeline() + const [reseed, setReseed] = useState(false) + const [resetDb, setResetDb] = useState(false) + + const completed = steps.filter((s) => TERMINAL_STATUSES.has(s.status)).length + + const handleRun = () => { + start({ seed: 42, skip_seed: !reseed, reset: resetDb }) + } + + return ( +
+ {/* Header */} +
+

End-to-End Showcase

+

+ Run the full forecasting pipeline live — seed → features → train ×3 → backtest ×3 → + register the winning model → verify → agent. The same flow as{' '} + make demo, streamed to + the browser. +

+
+ + {/* Controls */} + + + Run the pipeline + + {connectionStatus === 'connected' + ? 'Streaming live…' + : isRunning + ? 'Connecting…' + : 'Drives the published API in-process. Takes ~30–60 s on a seeded database.'} + + + +
+ + + + + +
+ + {phase === 'running' && ( +

+ Step {completed} of {steps.length} complete… +

+ )} +
+
+ + {/* Error banner */} + {phase === 'error' && ( + + + + + Pipeline could not start + + {errorMessage} + + + )} + + {/* Summary banner */} + {phase === 'done' && summary && ( + + + + + {summary.overallStatus === 'pass' + ? 'Pipeline complete' + : 'Pipeline finished with a failure'} + + + {summary.winnerModelType ? ( + <> + Winning model{' '} + {summary.winnerModelType} + {summary.winnerWape !== null && ( + <> · WAPE {summary.winnerWape.toFixed(4)} + )}{' '} + · {summary.wallClockS.toFixed(0)} s wall-clock + + ) : ( + <>No winning model selected · {summary.wallClockS.toFixed(0)} s wall-clock + )} + + + {summary.winningRunId && ( + + + + )} + + )} + + {/* Step cards */} +
+ {steps.map((step, index) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/pages/visualize/backtest.tsx b/frontend/src/pages/visualize/backtest.tsx index c82d3174..9a8b36d4 100644 --- a/frontend/src/pages/visualize/backtest.tsx +++ b/frontend/src/pages/visualize/backtest.tsx @@ -2,12 +2,11 @@ import { useState } from 'react' import { useJob } from '@/hooks/use-jobs' import { BacktestFoldsChart, MetricsSummary } from '@/components/charts/backtest-folds-chart' import { EmptyState } from '@/components/common/error-display' +import { JobPicker } from '@/components/common/job-picker' import { LoadingState } from '@/components/common/loading-state' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Search, LineChart } from 'lucide-react' +import { LineChart } from 'lucide-react' interface BacktestResult { aggregated_metrics: { @@ -31,24 +30,11 @@ interface BacktestResult { } export default function BacktestPage() { - const [jobId, setJobId] = useState('') const [searchJobId, setSearchJobId] = useState('') const [selectedMetric, setSelectedMetric] = useState<'mae' | 'smape' | 'wape' | 'bias'>('mae') const { data: job, isLoading, error } = useJob(searchJobId, !!searchJobId) - const handleSearch = () => { - if (jobId.trim()) { - setSearchJobId(jobId.trim()) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch() - } - } - // Extract backtest result from job const backtestResult = job?.result as BacktestResult | undefined @@ -56,28 +42,21 @@ export default function BacktestPage() {

Backtest Results

- {/* Search by Job ID */} + {/* Job picker */} Load Backtest - Enter a completed backtest job ID to visualize the results + Pick a completed backtest job to visualize the results -
- setJobId(e.target.value)} - onKeyDown={handleKeyDown} - className="max-w-md" - /> - -
+
@@ -214,7 +193,7 @@ export default function BacktestPage() { {!searchJobId && !isLoading && ( } /> )} diff --git a/frontend/src/pages/visualize/forecast.tsx b/frontend/src/pages/visualize/forecast.tsx index 91291a13..c2081fe2 100644 --- a/frontend/src/pages/visualize/forecast.tsx +++ b/frontend/src/pages/visualize/forecast.tsx @@ -2,62 +2,42 @@ import { useState } from 'react' import { useJob } from '@/hooks/use-jobs' import { TimeSeriesChart } from '@/components/charts/time-series-chart' import { EmptyState } from '@/components/common/error-display' +import { JobPicker } from '@/components/common/job-picker' import { LoadingState } from '@/components/common/loading-state' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { Search, BarChart3 } from 'lucide-react' +import { BarChart3 } from 'lucide-react' export default function ForecastPage() { - const [jobId, setJobId] = useState('') const [searchJobId, setSearchJobId] = useState('') const { data: job, isLoading, error } = useJob(searchJobId, !!searchJobId) - const handleSearch = () => { - if (jobId.trim()) { - setSearchJobId(jobId.trim()) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch() - } - } - - // Extract forecast data from job result - const forecastData = job?.result?.predictions as Array<{ + // Extract forecast data from job result. + // A completed `predict` job stores result.forecasts (each point: date + forecast). + const forecastData = job?.result?.forecasts as Array<{ date: string - predicted: number + forecast: number }> | undefined return (

Forecast Visualization

- {/* Search by Job ID */} + {/* Job picker */} Load Forecast - Enter a completed prediction job ID to visualize the forecast + Pick a completed prediction job to visualize the forecast -
- setJobId(e.target.value)} - onKeyDown={handleKeyDown} - className="max-w-md" - /> - -
+
@@ -109,6 +89,7 @@ export default function ForecastPage() { title="Forecast Results" description={`${forecastData.length} day forecast`} data={forecastData} + predictedKey="forecast" showActual={false} showPredicted={true} /> @@ -137,7 +118,7 @@ export default function ForecastPage() { {!searchJobId && !isLoading && ( } /> )} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 33b0a0b2..55859a9c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -320,3 +320,39 @@ export interface VerifyResult { warning_count: number failed_count: number } + +// === Demo Showcase === +export type DemoStepStatus = 'running' | 'pass' | 'fail' | 'skip' | 'warn' +export type DemoEventType = 'step_start' | 'step_complete' | 'pipeline_complete' | 'error' + +// One streamed pipeline event from WS /demo/stream (matches the backend +// StepEvent Pydantic model; snake_case on the wire). +export interface StepEvent { + event_type: DemoEventType + step_name: string + step_index: number + total_steps: number + status: DemoStepStatus | null + detail: string + duration_ms: number + data: Record + timestamp: string +} + +// Start frame for WS /demo/stream and request body for POST /demo/run. +export interface DemoRunRequest { + seed?: number + reset?: boolean + skip_seed?: boolean +} + +// Aggregate result returned by the synchronous POST /demo/run. +export interface DemoRunResult { + overall_status: 'pass' | 'fail' + steps: StepEvent[] + winner_model_type: string | null + winner_wape: number | null + winning_run_id: string | null + alias: string | null + wall_clock_s: number +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 8a67f62f..7ad54d46 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..ae3e086e --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,16 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +// Vitest configuration. Kept separate from vite.config.ts so the app build +// (`tsc -b && vite build`) is unaffected by test tooling. +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['src/**/*.test.{ts,tsx}'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) diff --git a/pyproject.toml b/pyproject.toml index da1aab2f..d8d1a7e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "forecastlabai" -version = "0.2.8" +version = "0.2.10" description = "Portfolio-grade end-to-end retail demand forecasting system" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..204f5127 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1,7 @@ +"""Top-level scripts package. + +Allows ``scripts.run_demo`` to be imported from tests; the existing +``scripts/seed_random.py`` and ``scripts/check_db.py`` are launched as +``uv run python scripts/.py`` so they did not previously need a +package marker. +""" diff --git a/scripts/run_demo.py b/scripts/run_demo.py new file mode 100644 index 00000000..03d26913 --- /dev/null +++ b/scripts/run_demo.py @@ -0,0 +1,1084 @@ +#!/usr/bin/env python +"""ForecastLabAI end-to-end demo pipeline driver. + +Drives the published FastAPI surface as a black-box HTTP consumer: + + precheck -> (reset) -> seed -> status -> features + -> train x 3 (parallel) -> backtest x 3 (sequential) + -> register-winner -> verify -> agent -> cleanup + +The script consumes only the documented HTTP contract (see +``docs/_base/API_CONTRACTS.md``); it never imports from ``app.features.*`` +services so any drift between the deployed surface and the runtime +behavior surfaces as a real failure. + +Usage: + # Full e2e (default scenario seed) + uv run python scripts/run_demo.py --seed 42 + + # Skip the seeder step (assumes data already present) + uv run python scripts/run_demo.py --seed 42 --skip-seed + + # Wipe the DB before seeding (destructive) + uv run python scripts/run_demo.py --seed 42 --reset + + # CI / log-capture mode (one line per step) + uv run python scripts/run_demo.py --seed 42 --quiet + +Exit codes: + 0 -- green verdict (or green with soft-warn for wall-clock budget) + 1 -- one or more steps failed + 2 -- precondition failure (API unreachable, DB down, etc.) +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import math +import shutil +import sys +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import date, timedelta +from pathlib import Path +from typing import Any, Final + +import httpx + +from app.core.config import get_settings + +# ============================================================================= +# Constants +# ============================================================================= + +DEFAULT_API_URL: Final[str] = "http://localhost:8123" +# Per-step HTTP timeout. /seeder/generate on demo_minimal empirically +# takes 60-90 s on a laptop (3 stores x 10 products x 92 days of sales +# + inventory + prices + promotions), so 120 s leaves margin. The +# default 5 s from httpx is far too short. +DEFAULT_TIMEOUT_S: Final[float] = 120.0 +DEFAULT_SEED: Final[int] = 42 + +DEMO_ALIAS: Final[str] = "demo-production" +DEMO_STORE_ID: Final[int] = 1 +DEMO_PRODUCT_ID: Final[int] = 1 +DEMO_HORIZON: Final[int] = 14 +DEMO_BACKTEST_SPLITS: Final[int] = 3 +DEMO_MIN_TRAIN_SIZE: Final[int] = 30 +DEMO_WALL_CLOCK_BUDGET_S: Final[float] = 180.0 +DEMO_FEATURESET_LOOKBACK_DAYS: Final[int] = 60 + +DEMO_SCENARIO: Final[str] = "demo_minimal" +DEMO_SEED_STORES: Final[int] = 3 +DEMO_SEED_PRODUCTS: Final[int] = 10 +DEMO_SEED_START: Final[date] = date(2024, 10, 1) +DEMO_SEED_END: Final[date] = date(2024, 12, 31) + +DEMO_MODEL_TYPES: Final[tuple[str, ...]] = ("naive", "seasonal_naive", "moving_average") + +GLYPHS: Final[dict[str, str]] = { + "pass": "✅", + "fail": "❌", + "warn": "⚠️", + "skip": "⏭️", + "run": "\U0001f504", +} + + +# ============================================================================= +# Dataclasses +# ============================================================================= + + +@dataclass +class DemoArgs: + """Parsed CLI arguments.""" + + seed: int + skip_seed: bool + reset: bool + quiet: bool + api_url: str + timeout: float + + +@dataclass +class StepOutcome: + """One step's result for the summary block.""" + + name: str + status: str # "pass" | "fail" | "skip" | "warn" + detail: str + duration_ms: float + + +@dataclass +class DemoContext: + """Accumulator threaded through every step. + + Holds cross-step references (store_id, product_id, train run_ids, winner) + so later steps can use earlier outputs without re-reading the API. The + script never mutates server-side state via this struct -- it is a + read-side cache only. + """ + + api_url: str + seed: int + skip_seed: bool + reset: bool + quiet: bool + timeout: float + store_id: int = DEMO_STORE_ID + product_id: int = DEMO_PRODUCT_ID + date_start: date | None = None + date_end: date | None = None + seed_records: dict[str, int] = field(default_factory=dict) + feature_row_count: int = 0 + train_results: dict[str, dict[str, Any]] = field(default_factory=dict) + backtest_results: dict[str, dict[str, float]] = field(default_factory=dict) + winner_model_type: str | None = None + winner_wape: float | None = None + winning_run_id: str | None = None + session_id: str | None = None + wall_clock_start: float = 0.0 + + +# ============================================================================= +# HTTP client + RFC 7807 surfacing +# ============================================================================= + + +class StepError(Exception): + """Surfaces a non-2xx HTTP response as an RFC 7807-aware typed failure. + + Echoes ``title`` / ``detail`` / ``request_id`` from the parsed + problem+json body (per ``app/core/problem_details.py``); never echoes + raw bodies that might contain secrets. + """ + + def __init__(self, step: str, status_code: int, problem: dict[str, Any]) -> None: + self.step = step + self.status_code = status_code + self.problem = problem + super().__init__(self._format()) + + def _format(self) -> str: + title = self.problem.get("title", "?") + detail = self.problem.get("detail", "?") + rid = self.problem.get("request_id", "?") + return f"{self.step}: HTTP {self.status_code} -- {title}: {detail} (request_id={rid})" + + +class HttpClient: + """Thin ``httpx.AsyncClient`` wrapper. + + httpx's default 5-second timeout is too short for ``/seeder/generate`` + (can take ~10-20 s for ``demo_minimal``), so callers pass an explicit + per-client timeout. All non-2xx responses raise ``StepError`` with the + parsed RFC 7807 body. + """ + + def __init__(self, base_url: str, timeout: float) -> None: + self._client = httpx.AsyncClient( + base_url=base_url, + timeout=httpx.Timeout(timeout, connect=5.0), + ) + + async def __aenter__(self) -> HttpClient: + return self + + async def __aexit__(self, *_exc: object) -> None: + await self._client.aclose() + + async def request( + self, + step: str, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Issue one HTTP request; surface non-2xx as :class:`StepError`.""" + kwargs: dict[str, Any] = {} + if json_body is not None: + kwargs["json"] = json_body + response = await self._client.request(method, path, **kwargs) + if response.status_code >= 400: + problem: dict[str, Any] + try: + parsed = response.json() + problem = ( + parsed + if isinstance(parsed, dict) + else {"title": "Non-dict body", "detail": str(parsed)[:200]} + ) + except (json.JSONDecodeError, ValueError): + problem = {"title": "Non-JSON error", "detail": response.text[:200]} + raise StepError(step, response.status_code, problem) + if response.status_code == 204: + return {} + body = response.json() + return body if isinstance(body, dict) else {"_raw": body} + + +# ============================================================================= +# Reporter (output-formatting.md compliant) +# ============================================================================= + + +class Reporter: + """Per-step + final-summary output. + + Honors ``.claude/rules/output-formatting.md``: ASCII glyphs, box-drawing + separators, capped at 40 lines. ``--quiet`` collapses each step to a + single line for CI/log capture. + """ + + def __init__(self, *, quiet: bool, total_steps: int) -> None: + self._quiet = quiet + self._total = total_steps + self._index = 0 + + def header(self) -> None: + if self._quiet: + return + line = "━" * 44 + print(line) + print(" \U0001f50d ForecastLabAI Demo") + print(line) + + def record(self, outcome: StepOutcome) -> None: + self._index += 1 + glyph = GLYPHS.get(outcome.status, "?") + if self._quiet: + print(f"{glyph} {outcome.name}: {outcome.detail} ({outcome.duration_ms:.0f}ms)") + else: + print( + f"{glyph} Step {self._index:2d}/{self._total}: {outcome.name} -- {outcome.detail}" + ) + + def summary( + self, + outcomes: list[StepOutcome], + ctx: DemoContext, + wall_clock_s: float, + ) -> bool: + line = "─" * 44 + any_fail = any(o.status == "fail" for o in outcomes) + within_budget = wall_clock_s <= DEMO_WALL_CLOCK_BUDGET_S + if not self._quiet: + print(line) + if any_fail: + failed = sum(1 for o in outcomes if o.status == "fail") + print(f" {GLYPHS['fail']} Result: NOT READY -- {failed} step(s) failed") + elif within_budget: + print(f" {GLYPHS['pass']} Result: GREEN") + else: + print( + f" {GLYPHS['warn']} Result: GREEN " + f"(over budget {wall_clock_s:.0f}s > " + f"{int(DEMO_WALL_CLOCK_BUDGET_S)}s)" + ) + print(line) + # Always emit the canonical final line so CI / scripts can grep for it. + winner = ctx.winner_model_type or "n/a" + print( + f"runs={len(ctx.backtest_results)} winner={winner} " + f"alias={DEMO_ALIAS} wall_clock={wall_clock_s:.0f}s" + ) + return not any_fail + + +# ============================================================================= +# Helpers shared across steps +# ============================================================================= + + +def _model_config_payload(model_type: str) -> dict[str, Any]: + """Build the ``ModelConfig`` body for a given baseline ``model_type``. + + Demo models are LightGBM-free baselines per PRP-15 scope (Phase-2-aware + LightGBM is queued as PRP-16). Each shape matches one branch of the + discriminated union in ``app/features/forecasting/schemas.py``. + """ + if model_type == "naive": + return {"model_type": "naive"} + if model_type == "seasonal_naive": + return {"model_type": "seasonal_naive", "season_length": 7} + if model_type == "moving_average": + return {"model_type": "moving_average", "window_size": 7} + raise ValueError(f"Unsupported demo model_type: {model_type}") + + +def _llm_key_present() -> bool: + """Return True if the configured agent model's API key is set. + + Matches the provider prefix of ``agent_default_model`` (e.g., + ``anthropic:claude-...`` -> ``anthropic_api_key``) so we skip the + agent step gracefully when the configured model can't reach its + provider. Mirrors the validator allow-list in + ``app/core/config.py:validate_model_identifier`` (issue #128). + """ + settings = get_settings() + model = settings.agent_default_model + provider = model.split(":", 1)[0] if ":" in model else "" + if provider == "anthropic": + return bool(settings.anthropic_api_key) + if provider == "openai": + return bool(settings.openai_api_key) + if provider in ("google-gla", "google-vertex"): + return bool(settings.google_api_key) + return False + + +def _select_winner( + backtest_results: dict[str, dict[str, float]], +) -> tuple[str, float] | None: + """Pick the (model_type, WAPE) with the lowest aggregated WAPE. + + Skips models whose aggregated metrics are missing / NaN -- the + backtester can legitimately return NaN on degenerate folds. Returns + ``None`` if no model has a usable WAPE. + """ + best: tuple[str, float] | None = None + for model_type, metrics in backtest_results.items(): + wape = metrics.get("wape") + if wape is None: + continue + if math.isnan(wape): + continue + if best is None or wape < best[1]: + best = (model_type, wape) + return best + + +# ============================================================================= +# Steps +# ============================================================================= + + +async def step_precheck(_ctx: DemoContext, client: HttpClient) -> StepOutcome: + """GET /health -- precondition; failure exits with code 2.""" + start = time.monotonic() + body = await client.request("precheck", "GET", "/health") + status_field = body.get("status", "") + detail = f"/health -> {status_field or 'unknown'}" + return StepOutcome( + name="precheck", + status="pass" if status_field == "ok" else "fail", + detail=detail, + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_reset(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """Wipe the DB if ``--reset``; no-op otherwise.""" + start = time.monotonic() + if not ctx.reset: + return StepOutcome( + name="reset", + status="skip", + detail="--reset not set", + duration_ms=(time.monotonic() - start) * 1000, + ) + body = await client.request( + "reset", + "DELETE", + "/seeder/data", + json_body={"scope": "all", "dry_run": False}, + ) + deleted = body.get("records_deleted", {}) + total = sum(v for v in deleted.values() if isinstance(v, int)) + return StepOutcome( + name="reset", + status="pass", + detail=f"deleted {total} rows across {len(deleted)} tables", + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_seed(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """Seed the demo_minimal scenario (synchronous POST /seeder/generate).""" + start = time.monotonic() + if ctx.skip_seed: + return StepOutcome( + name="seed", + status="skip", + detail="--skip-seed set", + duration_ms=(time.monotonic() - start) * 1000, + ) + body = await client.request( + "seed", + "POST", + "/seeder/generate", + json_body={ + "scenario": DEMO_SCENARIO, + "seed": ctx.seed, + "stores": DEMO_SEED_STORES, + "products": DEMO_SEED_PRODUCTS, + "start_date": DEMO_SEED_START.isoformat(), + "end_date": DEMO_SEED_END.isoformat(), + "sparsity": 0.0, + "dry_run": False, + }, + ) + records: dict[str, int] = { + k: int(v) for k, v in body.get("records_created", {}).items() if isinstance(v, int) + } + ctx.seed_records = records + # GenerateResult.records_created uses "sales" (singular), not "sales_daily". + sales = records.get("sales", records.get("sales_daily", 0)) + return StepOutcome( + name="seed", + status="pass", + detail=( + f"{DEMO_SCENARIO}: {DEMO_SEED_STORES} stores x " + f"{DEMO_SEED_PRODUCTS} products, {sales} sales rows" + ), + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_status(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """GET /seeder/status + /dimensions/* -- capture date range AND real IDs. + + Postgres auto-increment does NOT reset across delete/seed cycles, so + the freshly-seeded store/product IDs are NOT 1. We discover the first + available (store_id, product_id) from the dimensions endpoints; the + seeder has no sparsity for the demo_minimal preset, so any pair will + have ~92 sales rows minus a small number of stockouts -- well above + the 72-day backtest floor. + """ + start = time.monotonic() + body = await client.request("status", "GET", "/seeder/status") + raw_start = body.get("date_range_start") + raw_end = body.get("date_range_end") + if not isinstance(raw_start, str) or not isinstance(raw_end, str): + return StepOutcome( + name="status", + status="fail", + detail="no date_range in /seeder/status (was DB seeded?)", + duration_ms=(time.monotonic() - start) * 1000, + ) + ctx.date_start = date.fromisoformat(raw_start) + ctx.date_end = date.fromisoformat(raw_end) + + stores_body = await client.request( + "status[stores]", "GET", "/dimensions/stores?page=1&page_size=1" + ) + products_body = await client.request( + "status[products]", "GET", "/dimensions/products?page=1&page_size=1" + ) + stores_raw = stores_body.get("stores", []) + products_raw = products_body.get("products", []) + stores = stores_raw if isinstance(stores_raw, list) else [] + products = products_raw if isinstance(products_raw, list) else [] + if not stores or not products: + return StepOutcome( + name="status", + status="fail", + detail="no stores or products after seed", + duration_ms=(time.monotonic() - start) * 1000, + ) + first_store = stores[0] + first_product = products[0] + if not isinstance(first_store, dict) or not isinstance(first_product, dict): + return StepOutcome( + name="status", + status="fail", + detail="dimensions returned non-dict items", + duration_ms=(time.monotonic() - start) * 1000, + ) + store_id_raw = first_store.get("id") + product_id_raw = first_product.get("id") + if not isinstance(store_id_raw, int) or not isinstance(product_id_raw, int): + return StepOutcome( + name="status", + status="fail", + detail="dimension ids missing or non-int", + duration_ms=(time.monotonic() - start) * 1000, + ) + ctx.store_id = store_id_raw + ctx.product_id = product_id_raw + + sales = body.get("sales", 0) + return StepOutcome( + name="status", + status="pass", + detail=( + f"date_range={raw_start}..{raw_end} sales={sales} " + f"selected store_id={ctx.store_id} product_id={ctx.product_id}" + ), + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_features(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """Compute a small lag/rolling/calendar featureset for one series. + + Demonstration-only: the three baseline models below do not consume + these features. PRP-16 (Phase-2-aware LightGBM) will wire features + into training. + """ + start = time.monotonic() + if ctx.date_end is None: + return StepOutcome( + name="features", + status="fail", + detail="no date_end on ctx; status step did not populate it", + duration_ms=(time.monotonic() - start) * 1000, + ) + body = await client.request( + "features", + "POST", + "/featuresets/compute", + json_body={ + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "cutoff_date": ctx.date_end.isoformat(), + "lookback_days": DEMO_FEATURESET_LOOKBACK_DAYS, + "config": { + "name": "demo_featureset", + "lag_config": {"lags": [1, 7, 14]}, + "rolling_config": { + "windows": [7, 14], + "aggregations": ["mean", "std"], + }, + "calendar_config": {}, + }, + }, + ) + rows = int(body.get("row_count", 0)) + ctx.feature_row_count = rows + columns = body.get("feature_columns", []) + return StepOutcome( + name="features", + status="pass", + detail=f"{rows} rows, {len(columns)} columns (lag+rolling+calendar)", + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_train_all(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """Train naive / seasonal_naive / moving_average in parallel.""" + start = time.monotonic() + if ctx.date_start is None or ctx.date_end is None: + return StepOutcome( + name="train", + status="fail", + detail="no date range on ctx", + duration_ms=(time.monotonic() - start) * 1000, + ) + + # Leave a horizon-sized tail of data unused by training so the backtest + # has room to evaluate. Expanding-window backtest reuses the full range. + train_end = ctx.date_end - timedelta(days=DEMO_HORIZON) + + async def _train(model_type: str) -> tuple[str, dict[str, Any]]: + body = await client.request( + f"train[{model_type}]", + "POST", + "/forecasting/train", + json_body={ + "store_id": ctx.store_id, + "product_id": ctx.product_id, + # ISO date strings -- server-side Field(strict=False) accepts them + "train_start_date": ctx.date_start.isoformat() if ctx.date_start else "", + "train_end_date": train_end.isoformat(), + "config": _model_config_payload(model_type), + }, + ) + return model_type, body + + results = await asyncio.gather(*(_train(m) for m in DEMO_MODEL_TYPES)) + for model_type, body in results: + ctx.train_results[model_type] = body + trained = ", ".join(ctx.train_results.keys()) + return StepOutcome( + name="train", + status="pass", + detail=f"trained {len(ctx.train_results)} models in parallel: {trained}", + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_backtest_all(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """Run one backtest per model_type sequentially; pick winner by lowest WAPE.""" + start = time.monotonic() + if ctx.date_start is None or ctx.date_end is None: + return StepOutcome( + name="backtest", + status="fail", + detail="no date range on ctx", + duration_ms=(time.monotonic() - start) * 1000, + ) + + for model_type in DEMO_MODEL_TYPES: + body = await client.request( + f"backtest[{model_type}]", + "POST", + "/backtesting/run", + json_body={ + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "start_date": ctx.date_start.isoformat(), + "end_date": ctx.date_end.isoformat(), + "config": { + "split_config": { + "strategy": "expanding", + "n_splits": DEMO_BACKTEST_SPLITS, + "min_train_size": DEMO_MIN_TRAIN_SIZE, + "gap": 0, + "horizon": DEMO_HORIZON, + }, + "model_config_main": _model_config_payload(model_type), + "include_baselines": False, + "store_fold_details": False, + }, + }, + ) + main_results = body.get("main_model_results", {}) + aggregated = main_results.get("aggregated_metrics", {}) + # Coerce metric values to floats; ignore non-numeric keys. + clean: dict[str, float] = {} + for k, v in aggregated.items(): + if isinstance(v, (int, float)): + clean[str(k)] = float(v) + ctx.backtest_results[model_type] = clean + + winner = _select_winner(ctx.backtest_results) + if winner is None: + return StepOutcome( + name="backtest", + status="fail", + detail="no model produced a usable WAPE (all NaN?)", + duration_ms=(time.monotonic() - start) * 1000, + ) + ctx.winner_model_type, ctx.winner_wape = winner + return StepOutcome( + name="backtest", + status="pass", + detail=( + f"{len(ctx.backtest_results)} models, " + f"winner={ctx.winner_model_type} wape={ctx.winner_wape:.4f}" + ), + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_register(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """Two-step registry create+update; alias the winner as ``demo-production``. + + Mandatory transition: pending -> running -> success. Aliases can only + point to runs in SUCCESS status (``app/features/registry/routes.py:404``). + Artifact hash is computed client-side; we share the filesystem with the + API on this single-host system. + """ + start = time.monotonic() + if ctx.winner_model_type is None: + return StepOutcome( + name="register", + status="fail", + detail="no winner; cannot register", + duration_ms=(time.monotonic() - start) * 1000, + ) + if ctx.date_start is None or ctx.date_end is None: + return StepOutcome( + name="register", + status="fail", + detail="no date range on ctx", + duration_ms=(time.monotonic() - start) * 1000, + ) + + train_response = ctx.train_results.get(ctx.winner_model_type, {}) + model_path_raw = train_response.get("model_path") + if not isinstance(model_path_raw, str) or not model_path_raw: + return StepOutcome( + name="register", + status="fail", + detail=f"no model_path for winner {ctx.winner_model_type}", + duration_ms=(time.monotonic() - start) * 1000, + ) + source_model = Path(model_path_raw) + if not source_model.exists(): + return StepOutcome( + name="register", + status="fail", + detail=f"artifact missing at {source_model}", + duration_ms=(time.monotonic() - start) * 1000, + ) + + # /forecasting/train saves under settings.forecast_model_artifacts_dir + # (default ./artifacts/models). The registry's verify endpoint resolves + # artifact_uri against settings.registry_artifact_root (default + # ./artifacts/registry) -- they are intentionally separate roots. To + # close the loop, copy the trained model into the registry root and + # record a *registry-relative* URI. This is the official pattern + # mirrored by app/features/registry/tests/test_storage.py. + settings = get_settings() + registry_root = Path(settings.registry_artifact_root).resolve() + registry_root.mkdir(parents=True, exist_ok=True) + artifact_uri = f"demo/{ctx.winner_model_type}-{source_model.stem}.joblib" + dest_path = registry_root / artifact_uri + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_model, dest_path) + artifact_bytes = dest_path.read_bytes() + artifact_hash = hashlib.sha256(artifact_bytes).hexdigest() + artifact_size = len(artifact_bytes) + + # (a) Create run in PENDING status. On-wire JSON key is "model_config" + # (alias of model_config_data per registry/schemas.py:68). + create_body = await client.request( + "register[create]", + "POST", + "/registry/runs", + json_body={ + "model_type": ctx.winner_model_type, + "model_config": _model_config_payload(ctx.winner_model_type), + "feature_config": None, + "data_window_start": ctx.date_start.isoformat(), + "data_window_end": ctx.date_end.isoformat(), + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "agent_context": None, + "git_sha": None, + }, + ) + run_id_raw = create_body.get("run_id") + if not isinstance(run_id_raw, str): + return StepOutcome( + name="register", + status="fail", + detail="POST /registry/runs returned no run_id", + duration_ms=(time.monotonic() - start) * 1000, + ) + ctx.winning_run_id = run_id_raw + + # (b) PATCH pending -> running (mandatory intermediate). + await client.request( + "register[running]", + "PATCH", + f"/registry/runs/{run_id_raw}", + json_body={"status": "running"}, + ) + + # (c) PATCH running -> success with metrics + artifact info. + await client.request( + "register[success]", + "PATCH", + f"/registry/runs/{run_id_raw}", + json_body={ + "status": "success", + "metrics": ctx.backtest_results[ctx.winner_model_type], + "artifact_uri": artifact_uri, + "artifact_hash": artifact_hash, + "artifact_size_bytes": artifact_size, + }, + ) + + # (d) Alias the winner. + await client.request( + "register[alias]", + "POST", + "/registry/aliases", + json_body={ + "alias_name": DEMO_ALIAS, + "run_id": run_id_raw, + "description": "Auto-created by scripts/run_demo.py", + }, + ) + + return StepOutcome( + name="register", + status="pass", + detail=f"run_id={run_id_raw[:8]}... alias={DEMO_ALIAS}", + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_verify(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """SHA-256 artifact-integrity check via the public verify endpoint.""" + start = time.monotonic() + if ctx.winning_run_id is None: + return StepOutcome( + name="verify", + status="fail", + detail="no winning_run_id to verify", + duration_ms=(time.monotonic() - start) * 1000, + ) + body = await client.request( + "verify", + "GET", + f"/registry/runs/{ctx.winning_run_id}/verify", + ) + verified = body.get("verified") is True + return StepOutcome( + name="verify", + status="pass" if verified else "fail", + detail="sha256 OK" if verified else f"verify={body.get('verified')}", + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_agent(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """One-turn chat with the ``experiment`` agent. + + Skip gracefully if either (a) the configured agent model has no + matching API key, or (b) the round-trip raises a provider error + (invalid key, model unavailable, rate-limit). The agent integration + is showcased separately by the chat UI; the demo's pipeline value + is the ML loop above and we don't want a broken LLM key to mask a + green pipeline run. + """ + start = time.monotonic() + if not _llm_key_present(): + return StepOutcome( + name="agent", + status="skip", + detail="no API key matching agent_default_model provider", + duration_ms=(time.monotonic() - start) * 1000, + ) + + try: + create_body = await client.request( + "agent[session]", + "POST", + "/agents/sessions", + json_body={"agent_type": "experiment", "initial_context": None}, + ) + except StepError as exc: + return StepOutcome( + name="agent", + status="skip", + detail=f"session-create failed: {exc}", + duration_ms=(time.monotonic() - start) * 1000, + ) + session_id_raw = create_body.get("session_id") + if not isinstance(session_id_raw, str): + return StepOutcome( + name="agent", + status="skip", + detail="no session_id returned", + duration_ms=(time.monotonic() - start) * 1000, + ) + ctx.session_id = session_id_raw + + try: + chat_body = await client.request( + "agent[chat]", + "POST", + f"/agents/sessions/{session_id_raw}/chat", + json_body={"message": "List the latest model runs.", "stream": False}, + ) + except StepError as exc: + return StepOutcome( + name="agent", + status="skip", + detail=f"chat round-trip failed: {exc}", + duration_ms=(time.monotonic() - start) * 1000, + ) + tokens = int(chat_body.get("tokens_used", 0)) + tool_calls = chat_body.get("tool_calls", []) + tool_count = len(tool_calls) if isinstance(tool_calls, list) else 0 + return StepOutcome( + name="agent", + status="pass", + detail=f"chat ok (tokens={tokens}, tool_calls={tool_count})", + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def step_cleanup(ctx: DemoContext, client: HttpClient) -> StepOutcome: + """Close the agent session (no-op if no session was opened).""" + start = time.monotonic() + if ctx.session_id is None: + return StepOutcome( + name="cleanup", + status="skip", + detail="no agent session to close", + duration_ms=(time.monotonic() - start) * 1000, + ) + try: + await client.request( + "cleanup", + "DELETE", + f"/agents/sessions/{ctx.session_id}", + ) + except StepError as exc: + # Cleanup failure is non-fatal; emit a warn so the run still goes green. + return StepOutcome( + name="cleanup", + status="warn", + detail=f"DELETE failed but ignored: {exc}", + duration_ms=(time.monotonic() - start) * 1000, + ) + return StepOutcome( + name="cleanup", + status="pass", + detail="agent session closed", + duration_ms=(time.monotonic() - start) * 1000, + ) + + +# ============================================================================= +# Orchestration +# ============================================================================= + + +StepFn = Callable[[DemoContext, "HttpClient"], Awaitable[StepOutcome]] + + +def _step_table() -> list[tuple[str, StepFn, bool]]: + """Return the ordered step table. + + Tuple: (name, callable, is_precondition). A precondition failure exits 2; + any other step failure exits 1. + """ + return [ + ("precheck", step_precheck, True), + ("reset", step_reset, False), + ("seed", step_seed, False), + ("status", step_status, False), + ("features", step_features, False), + ("train", step_train_all, False), + ("backtest", step_backtest_all, False), + ("register", step_register, False), + ("verify", step_verify, False), + ("agent", step_agent, False), + ("cleanup", step_cleanup, False), + ] + + +async def _run_one_step( + step_fn: StepFn, + ctx: DemoContext, + client: HttpClient, + name: str, +) -> StepOutcome: + """Wrap a single step; convert exceptions into a ``fail`` outcome.""" + start = time.monotonic() + try: + return await step_fn(ctx, client) + except StepError as exc: + return StepOutcome( + name=name, + status="fail", + detail=str(exc), + duration_ms=(time.monotonic() - start) * 1000, + ) + except (httpx.HTTPError, OSError) as exc: + return StepOutcome( + name=name, + status="fail", + detail=f"transport error: {type(exc).__name__}: {exc}", + duration_ms=(time.monotonic() - start) * 1000, + ) + + +async def main_async(args: DemoArgs) -> int: + """Run the demo; return the process exit code.""" + steps = _step_table() + ctx = DemoContext( + api_url=args.api_url, + seed=args.seed, + skip_seed=args.skip_seed, + reset=args.reset, + quiet=args.quiet, + timeout=args.timeout, + ) + reporter = Reporter(quiet=args.quiet, total_steps=len(steps)) + reporter.header() + outcomes: list[StepOutcome] = [] + ctx.wall_clock_start = time.monotonic() + exit_code = 0 + + try: + async with HttpClient(args.api_url, args.timeout) as client: + for name, step_fn, is_precondition in steps: + outcome = await _run_one_step(step_fn, ctx, client, name) + reporter.record(outcome) + outcomes.append(outcome) + if outcome.status == "fail": + exit_code = 2 if is_precondition else 1 + break + except (httpx.ConnectError, OSError) as exc: + outcomes.append( + StepOutcome( + name="precheck", + status="fail", + detail=f"could not reach {args.api_url}: {exc}", + duration_ms=0.0, + ) + ) + exit_code = 2 + + wall = time.monotonic() - ctx.wall_clock_start + reporter.summary(outcomes, ctx, wall) + return exit_code + + +# ============================================================================= +# CLI +# ============================================================================= + + +def parse_args(argv: list[str] | None = None) -> DemoArgs: + """Parse CLI args into a :class:`DemoArgs`.""" + parser = argparse.ArgumentParser( + prog="run_demo.py", + description="ForecastLabAI end-to-end demo pipeline driver", + ) + parser.add_argument( + "--seed", + type=int, + default=DEFAULT_SEED, + help=f"Deterministic seed for the seeder (default: {DEFAULT_SEED})", + ) + parser.add_argument( + "--skip-seed", + action="store_true", + help="Skip the seeder scenario step (assumes data already present)", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Wipe the DB before seeding (destructive)", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="One-line-per-step output (default: verbose)", + ) + parser.add_argument( + "--api-url", + type=str, + default=DEFAULT_API_URL, + help=f"Backend base URL (default: {DEFAULT_API_URL})", + ) + parser.add_argument( + "--timeout", + type=float, + default=DEFAULT_TIMEOUT_S, + help=f"Per-step HTTP timeout in seconds (default: {DEFAULT_TIMEOUT_S})", + ) + ns = parser.parse_args(argv) + return DemoArgs( + seed=int(ns.seed), + skip_seed=bool(ns.skip_seed), + reset=bool(ns.reset), + quiet=bool(ns.quiet), + api_url=str(ns.api_url), + timeout=float(ns.timeout), + ) + + +def main() -> None: + sys.exit(asyncio.run(main_async(parse_args()))) + + +if __name__ == "__main__": + main() diff --git a/tests/test_demo_showcase_integration.py b/tests/test_demo_showcase_integration.py new file mode 100644 index 00000000..a9538551 --- /dev/null +++ b/tests/test_demo_showcase_integration.py @@ -0,0 +1,58 @@ +"""Integration test for the demo showcase pipeline. + +Exercises ``POST /demo/run`` end-to-end against a real Postgres database via +the in-process ASGITransport ``client`` fixture (``tests/conftest.py``). + +Requires ``docker-compose up -d`` + ``alembic upgrade head``. Marked +``integration`` so it is excluded from the fast unit run. +""" + +import pytest + +pytestmark = pytest.mark.integration + + +async def test_demo_run_pipeline_end_to_end(client): + """Seed demo_minimal, run the demo pipeline, and verify the registered winner.""" + # Precondition: seed the demo_minimal scenario so skip_seed=true has data. + seed_resp = await client.post( + "/seeder/generate", + json={ + "scenario": "demo_minimal", + "seed": 42, + "stores": 3, + "products": 10, + "start_date": "2024-10-01", + "end_date": "2024-12-31", + "sparsity": 0.0, + "dry_run": False, + }, + ) + assert seed_resp.status_code == 201, seed_resp.text + + try: + resp = await client.post( + "/demo/run", + json={"skip_seed": True, "reset": False}, + ) + assert resp.status_code == 200, resp.text + result = resp.json() + + # Every step must end pass or skip; nothing failed. + assert result["overall_status"] == "pass", result + for step in result["steps"]: + assert step["status"] in {"pass", "skip"}, step + + # A backtest winner was selected and registered. + assert result["winner_model_type"] is not None + assert result["winner_wape"] is not None + assert result["winning_run_id"] is not None + assert result["alias"] == "demo-production" + + # The demo-production alias resolves to the winning run. + alias_resp = await client.get("/registry/aliases/demo-production") + assert alias_resp.status_code == 200, alias_resp.text + assert alias_resp.json()["run_id"] == result["winning_run_id"] + finally: + # Best-effort teardown -- drop the alias the run created. + await client.delete("/registry/aliases/demo-production") diff --git a/tests/test_e2e_demo.py b/tests/test_e2e_demo.py new file mode 100644 index 00000000..988d8209 --- /dev/null +++ b/tests/test_e2e_demo.py @@ -0,0 +1,228 @@ +"""Integration test for the end-to-end demo pipeline. + +Spins up a fresh uvicorn subprocess on port 8124 (separate from the +developer's usual :8123 to avoid colliding with an already-running +server), runs `scripts/run_demo.py --reset --api-url ...`, and asserts +exit 0 + canonical summary line. + +Marker: `@pytest.mark.integration` — requires `docker compose up -d` +and applied Alembic migrations. Skips automatically if Postgres isn't +reachable. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from collections.abc import Iterator +from pathlib import Path + +import pytest + +# Resolve the repo root once so the subprocess calls work regardless of +# where pytest was invoked from (matches scripts/run_demo.py shape). +REPO_ROOT: Path = Path(__file__).resolve().parent.parent +UVICORN_PORT: int = 8124 +DEMO_API_URL: str = f"http://127.0.0.1:{UVICORN_PORT}" +HEALTH_URL: str = f"{DEMO_API_URL}/health" + +# Wall-clock budget. The PRP target is 180 s soft; integration adds +# uvicorn boot, migrations idempotency, and a fresh seeder run so we +# allow more headroom in the subprocess timeout (240 s). +UVICORN_BOOT_TIMEOUT_S: float = 30.0 +DEMO_SUBPROCESS_TIMEOUT_S: float = 240.0 + +# Resolve `uv` to an absolute path so ruff's S607 stays happy and so the +# subprocess doesn't depend on PATH lookup at exec time. +UV_BIN: str = shutil.which("uv") or "uv" + + +def _postgres_reachable() -> bool: + """Quick socket probe against docker-compose Postgres on :5433. + + Faster + cheaper than spinning a real asyncpg connection; the script + will surface deeper DB issues at runtime if Postgres is up but not + migrated. + """ + try: + with socket.create_connection(("localhost", 5433), timeout=1.0): + return True + except OSError: + return False + + +def _wait_for_health(timeout_s: float) -> bool: + """Poll the uvicorn health endpoint until 200 or timeout.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(HEALTH_URL, timeout=2.0) as resp: # noqa: S310 + if resp.status == 200: + return True + except (urllib.error.URLError, ConnectionResetError, OSError): + pass + time.sleep(1.0) + return False + + +@pytest.fixture +def uvicorn_subprocess() -> Iterator[subprocess.Popen[bytes]]: + """Boot uvicorn on :8124 for the integration test; tear it down after. + + Background process; we wait for /health to flip green before yielding. + Falls back to terminate() / kill() on teardown so the test never leaks + a child process across runs. + """ + if not _postgres_reachable(): + pytest.skip( + "Postgres on :5433 not reachable — bring up `docker compose up -d` " + "before running integration tests" + ) + + env = os.environ.copy() + # Force a known app_env so seeder_allow_production guard doesn't bite. + env.setdefault("APP_ENV", "development") + + # Redirect uvicorn output to a temp file rather than a subprocess.PIPE. + # The seeder + structlog produce enough INFO output to fill a 64-KB + # pipe buffer during /seeder/generate; once full, uvicorn blocks on + # write and the HTTP request appears to hang. Writing to a file + # never blocks, and we keep the file around so failure mode can + # inspect it. + log_file_path = Path(tempfile.gettempdir()) / f"uvicorn-e2e-{os.getpid()}.log" + log_file = log_file_path.open("w", buffering=1) + + proc = subprocess.Popen( # noqa: S603 — internal command, trusted args + [ + UV_BIN, + "run", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + str(UVICORN_PORT), + "--log-level", + "warning", + ], + cwd=str(REPO_ROOT), + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + ) + + try: + if not _wait_for_health(UVICORN_BOOT_TIMEOUT_S): + proc.terminate() + try: + proc.wait(timeout=5.0) + except subprocess.TimeoutExpired: + proc.kill() + log_file.close() + log_tail = log_file_path.read_text()[-2000:] if log_file_path.exists() else "(no log)" + pytest.skip( + f"uvicorn did not become healthy on {DEMO_API_URL} within " + f"{UVICORN_BOOT_TIMEOUT_S:.0f}s — tail of log:\n{log_tail}" + ) + yield proc + finally: + proc.terminate() + try: + proc.wait(timeout=5.0) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5.0) + log_file.close() + # Best-effort cleanup; leave the file in place if the test failed. + if proc.returncode == 0: + log_file_path.unlink(missing_ok=True) + + +@pytest.mark.integration +def test_run_demo_e2e_exits_green(uvicorn_subprocess: subprocess.Popen[bytes]) -> None: + """`scripts/run_demo.py --reset` exits 0 and prints the canonical summary.""" + # Run the script against the freshly booted uvicorn. + result = subprocess.run( # noqa: S603 — internal command, trusted args + [ + UV_BIN, + "run", + "python", + "scripts/run_demo.py", + "--seed", + "42", + "--reset", + "--api-url", + DEMO_API_URL, + # Per-step timeout. /seeder/generate for demo_minimal can spend + # 60-90 s on inserts on slower hardware (3 stores x 10 products + # x 92 days of sales + inventory + prices + promotions). The + # default 60 s is fine for the foreground steps but tight for + # seed; bump to 120 s for the integration run. + "--timeout", + "120", + ], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=DEMO_SUBPROCESS_TIMEOUT_S, + check=False, + ) + + stdout = result.stdout + stderr = result.stderr + # Echo the script output back through pytest so debugging is easy + # when this test fails on a developer machine or CI. + print("---- run_demo stdout ----", file=sys.stderr) + print(stdout, file=sys.stderr) + print("---- run_demo stderr ----", file=sys.stderr) + print(stderr, file=sys.stderr) + + assert result.returncode == 0, ( + f"run_demo.py exited {result.returncode}; see stdout/stderr captured above" + ) + # Canonical final-line contract from PRP-15 success criteria. + assert "alias=demo-production" in stdout + assert "winner=" in stdout + assert "wall_clock=" in stdout + # We expect three backtested model types. + assert "runs=3" in stdout + + +@pytest.mark.integration +def test_run_demo_precondition_failure_exits_2() -> None: + """A bogus API URL surfaces as a precondition failure with exit 2. + + Verifies the script does NOT silently exit 0 when the backend is + unreachable — a behaviour we lean on in the integration fixture + above and that users rely on locally when they forget to start + uvicorn. + """ + result = subprocess.run( # noqa: S603 — internal command, trusted args + [ + UV_BIN, + "run", + "python", + "scripts/run_demo.py", + "--api-url", + "http://127.0.0.1:1", # almost certainly unbound + "--timeout", + "2", + "--quiet", + ], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=30.0, + check=False, + ) + assert result.returncode == 2, ( + f"expected exit 2 on unreachable API; got {result.returncode}\n" + f"stdout={result.stdout!r}\nstderr={result.stderr!r}" + ) diff --git a/tests/test_run_demo_unit.py b/tests/test_run_demo_unit.py new file mode 100644 index 00000000..4140d95a --- /dev/null +++ b/tests/test_run_demo_unit.py @@ -0,0 +1,550 @@ +"""Unit tests for scripts/run_demo.py. + +These tests are pure-Python and never touch the network or the database +— the HttpClient is mocked at the boundary. Integration coverage lives +in `tests/test_e2e_demo.py` (marked `@pytest.mark.integration`). +""" + +from __future__ import annotations + +import math +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from scripts import run_demo +from scripts.run_demo import ( + DEMO_ALIAS, + DEMO_HORIZON, + DEMO_MODEL_TYPES, + GLYPHS, + DemoArgs, + DemoContext, + HttpClient, + Reporter, + StepError, + StepOutcome, + _llm_key_present, + _model_config_payload, + _select_winner, + parse_args, +) + +# ============================================================================= +# parse_args +# ============================================================================= + + +class TestParseArgs: + def test_defaults(self) -> None: + args = parse_args([]) + assert isinstance(args, DemoArgs) + assert args.seed == 42 + assert args.skip_seed is False + assert args.reset is False + assert args.quiet is False + assert args.api_url == "http://localhost:8123" + assert args.timeout == pytest.approx(120.0) + + def test_all_flags(self) -> None: + args = parse_args( + [ + "--seed", + "7", + "--skip-seed", + "--reset", + "--quiet", + "--api-url", + "http://127.0.0.1:8124", + "--timeout", + "12.5", + ] + ) + assert args.seed == 7 + assert args.skip_seed is True + assert args.reset is True + assert args.quiet is True + assert args.api_url == "http://127.0.0.1:8124" + assert args.timeout == pytest.approx(12.5) + + +# ============================================================================= +# DemoContext +# ============================================================================= + + +class TestDemoContext: + def test_defaults(self) -> None: + ctx = DemoContext( + api_url="http://x", + seed=1, + skip_seed=False, + reset=False, + quiet=False, + timeout=10.0, + ) + assert ctx.store_id == 1 + assert ctx.product_id == 1 + assert ctx.date_start is None + assert ctx.date_end is None + assert ctx.seed_records == {} + assert ctx.train_results == {} + assert ctx.backtest_results == {} + assert ctx.winner_model_type is None + assert ctx.winner_wape is None + assert ctx.winning_run_id is None + assert ctx.session_id is None + + +# ============================================================================= +# _select_winner +# ============================================================================= + + +class TestSelectWinner: + def test_picks_lowest_wape(self) -> None: + results = { + "naive": {"wape": 0.30, "mae": 5.0}, + "seasonal_naive": {"wape": 0.18, "mae": 3.5}, + "moving_average": {"wape": 0.22, "mae": 4.0}, + } + winner = _select_winner(results) + assert winner == ("seasonal_naive", 0.18) + + def test_skips_nan(self) -> None: + results = { + "naive": {"wape": float("nan")}, + "seasonal_naive": {"wape": 0.18}, + } + winner = _select_winner(results) + assert winner == ("seasonal_naive", 0.18) + + def test_all_nan_returns_none(self) -> None: + results = { + "naive": {"wape": float("nan")}, + "moving_average": {"wape": float("nan")}, + } + assert _select_winner(results) is None + + def test_empty_returns_none(self) -> None: + assert _select_winner({}) is None + + def test_missing_wape_field(self) -> None: + results: dict[str, dict[str, float]] = { + "naive": {}, + "seasonal_naive": {"wape": 0.42}, + } + winner = _select_winner(results) + assert winner == ("seasonal_naive", 0.42) + + +# ============================================================================= +# _model_config_payload +# ============================================================================= + + +class TestModelConfigPayload: + def test_naive_shape(self) -> None: + assert _model_config_payload("naive") == {"model_type": "naive"} + + def test_seasonal_naive_shape(self) -> None: + assert _model_config_payload("seasonal_naive") == { + "model_type": "seasonal_naive", + "season_length": 7, + } + + def test_moving_average_shape(self) -> None: + assert _model_config_payload("moving_average") == { + "model_type": "moving_average", + "window_size": 7, + } + + def test_unsupported_model_type_raises(self) -> None: + with pytest.raises(ValueError, match="Unsupported demo model_type"): + _model_config_payload("lightgbm") + + +# ============================================================================= +# Reporter +# ============================================================================= + + +class TestReporter: + def test_glyph_mapping(self) -> None: + # The five status values that StepOutcome.status can take must all + # have a glyph; the reporter falls back to "?" otherwise but the + # status taxonomy is closed here. + assert GLYPHS["pass"] == "✅" # noqa: S105 — false positive: GLYPHS["pass"] is a status glyph + assert GLYPHS["fail"] == "❌" + assert GLYPHS["warn"] == "⚠️" + assert GLYPHS["skip"] == "⏭️" + assert "run" in GLYPHS + + def test_verbose_emits_step_lines(self, capsys: pytest.CaptureFixture[str]) -> None: + reporter = Reporter(quiet=False, total_steps=2) + reporter.header() + reporter.record(StepOutcome(name="precheck", status="pass", detail="ok", duration_ms=12.3)) + reporter.record( + StepOutcome(name="seed", status="skip", detail="--skip-seed", duration_ms=0.5) + ) + out = capsys.readouterr().out + assert "ForecastLabAI Demo" in out + assert "✅" in out + assert "⏭️" in out + assert "Step 1/2" in out + assert "Step 2/2" in out + + def test_quiet_skips_banner(self, capsys: pytest.CaptureFixture[str]) -> None: + reporter = Reporter(quiet=True, total_steps=1) + reporter.header() + reporter.record(StepOutcome(name="precheck", status="pass", detail="ok", duration_ms=1.0)) + out = capsys.readouterr().out + assert "ForecastLabAI Demo" not in out + assert "✅" in out # glyph still emitted in quiet mode + assert "precheck: ok" in out + + def test_summary_green(self, capsys: pytest.CaptureFixture[str]) -> None: + ctx = DemoContext( + api_url="x", seed=42, skip_seed=False, reset=False, quiet=False, timeout=10.0 + ) + ctx.winner_model_type = "seasonal_naive" + ctx.backtest_results = {"naive": {}, "seasonal_naive": {}, "moving_average": {}} + reporter = Reporter(quiet=False, total_steps=3) + green = reporter.summary( + [ + StepOutcome(name="a", status="pass", detail="", duration_ms=1), + StepOutcome(name="b", status="pass", detail="", duration_ms=1), + ], + ctx, + wall_clock_s=42.0, + ) + out = capsys.readouterr().out + assert green is True + assert "Result: GREEN" in out + assert "runs=3 winner=seasonal_naive" in out + assert f"alias={DEMO_ALIAS}" in out + assert "wall_clock=42s" in out + + def test_summary_failure(self, capsys: pytest.CaptureFixture[str]) -> None: + ctx = DemoContext( + api_url="x", seed=42, skip_seed=False, reset=False, quiet=False, timeout=10.0 + ) + reporter = Reporter(quiet=False, total_steps=2) + green = reporter.summary( + [ + StepOutcome(name="a", status="fail", detail="boom", duration_ms=1), + ], + ctx, + wall_clock_s=10.0, + ) + out = capsys.readouterr().out + assert green is False + assert "NOT READY" in out + assert "1 step(s) failed" in out + assert "winner=n/a" in out + + def test_summary_over_budget_soft_warns(self, capsys: pytest.CaptureFixture[str]) -> None: + ctx = DemoContext( + api_url="x", seed=42, skip_seed=False, reset=False, quiet=False, timeout=10.0 + ) + ctx.winner_model_type = "naive" + ctx.backtest_results = {"naive": {}} + reporter = Reporter(quiet=False, total_steps=1) + green = reporter.summary( + [ + StepOutcome(name="a", status="pass", detail="", duration_ms=1), + ], + ctx, + wall_clock_s=999.0, + ) + out = capsys.readouterr().out + assert green is True + assert "GREEN" in out + assert "over budget" in out + + +# ============================================================================= +# StepError formatting +# ============================================================================= + + +class TestStepError: + def test_format_includes_request_id(self) -> None: + err = StepError( + step="seed", + status_code=500, + problem={ + "title": "Internal Server Error", + "detail": "boom", + "request_id": "req-xyz", + }, + ) + text = str(err) + assert "HTTP 500" in text + assert "Internal Server Error" in text + assert "boom" in text + assert "request_id=req-xyz" in text + + +# ============================================================================= +# HttpClient — mocked +# ============================================================================= + + +class TestHttpClientMocked: + @pytest.mark.asyncio + async def test_2xx_returns_body(self) -> None: + client = HttpClient("http://test", timeout=5.0) + # Patch the internal httpx client so no real network call is made. + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = lambda: {"status": "ok"} + client._client.request = AsyncMock(return_value=mock_response) # type: ignore[method-assign] + body = await client.request("precheck", "GET", "/health") + assert body == {"status": "ok"} + + @pytest.mark.asyncio + async def test_204_returns_empty_dict(self) -> None: + client = HttpClient("http://test", timeout=5.0) + mock_response = AsyncMock() + mock_response.status_code = 204 + client._client.request = AsyncMock(return_value=mock_response) # type: ignore[method-assign] + body = await client.request("delete", "DELETE", "/x") + assert body == {} + + @pytest.mark.asyncio + async def test_non_2xx_raises_steperror_with_problem(self) -> None: + client = HttpClient("http://test", timeout=5.0) + mock_response = AsyncMock() + mock_response.status_code = 400 + mock_response.json = lambda: { + "title": "Validation Error", + "detail": "bad payload", + "request_id": "abc", + } + client._client.request = AsyncMock(return_value=mock_response) # type: ignore[method-assign] + with pytest.raises(StepError) as excinfo: + await client.request("seed", "POST", "/seeder/generate", json_body={"x": 1}) + err = excinfo.value + assert err.status_code == 400 + assert err.problem["detail"] == "bad payload" + + +# ============================================================================= +# Step payload shapes (sanity check that we send what the API expects) +# ============================================================================= + + +class TestStepPayloads: + @pytest.mark.asyncio + async def test_step_seed_sends_demo_minimal( + self, + ) -> None: + """Seed step posts demo_minimal scenario with correct dims + dates.""" + calls: list[dict[str, Any]] = [] + + class _RecordingClient: + async def request( + self, + step: str, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + calls.append({"step": step, "method": method, "path": path, "json_body": json_body}) + return {"records_created": {"sales_daily": 100}} + + ctx = DemoContext( + api_url="x", + seed=42, + skip_seed=False, + reset=False, + quiet=False, + timeout=10.0, + ) + outcome = await run_demo.step_seed(ctx, _RecordingClient()) # type: ignore[arg-type] + assert outcome.status == "pass" + assert len(calls) == 1 + body = calls[0]["json_body"] + assert calls[0]["path"] == "/seeder/generate" + assert body is not None + assert body["scenario"] == "demo_minimal" + assert body["seed"] == 42 + assert body["stores"] == 3 + assert body["products"] == 10 + assert body["start_date"] == "2024-10-01" + assert body["end_date"] == "2024-12-31" + + @pytest.mark.asyncio + async def test_step_seed_skipped(self) -> None: + """When --skip-seed is set, no HTTP call is made.""" + called = False + + class _AssertNotCalled: + async def request(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + nonlocal called + called = True + return {} + + ctx = DemoContext( + api_url="x", + seed=42, + skip_seed=True, + reset=False, + quiet=False, + timeout=10.0, + ) + outcome = await run_demo.step_seed(ctx, _AssertNotCalled()) # type: ignore[arg-type] + assert outcome.status == "skip" + assert called is False + + @pytest.mark.asyncio + async def test_step_reset_no_op_without_flag(self) -> None: + called = False + + class _AssertNotCalled: + async def request(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + nonlocal called + called = True + return {} + + ctx = DemoContext( + api_url="x", + seed=42, + skip_seed=False, + reset=False, + quiet=False, + timeout=10.0, + ) + outcome = await run_demo.step_reset(ctx, _AssertNotCalled()) # type: ignore[arg-type] + assert outcome.status == "skip" + assert called is False + + @pytest.mark.asyncio + async def test_step_features_sends_cutoff_iso(self) -> None: + from datetime import date as _date + + calls: list[dict[str, Any]] = [] + + class _RecordingClient: + async def request( + self, + step: str, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + calls.append({"path": path, "json_body": json_body}) + return {"row_count": 30, "feature_columns": ["a", "b", "c"]} + + ctx = DemoContext( + api_url="x", + seed=42, + skip_seed=False, + reset=False, + quiet=False, + timeout=10.0, + ) + ctx.date_end = _date(2024, 12, 31) + outcome = await run_demo.step_features(ctx, _RecordingClient()) # type: ignore[arg-type] + assert outcome.status == "pass" + body = calls[0]["json_body"] + assert body["cutoff_date"] == "2024-12-31" + assert body["store_id"] == 1 + assert body["product_id"] == 1 + + @pytest.mark.asyncio + async def test_step_train_all_sends_three_in_parallel(self) -> None: + from datetime import date as _date + + seen_models: list[str] = [] + + class _RecordingClient: + async def request( + self, + step: str, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + assert json_body is not None + assert path == "/forecasting/train" + seen_models.append(json_body["config"]["model_type"]) + return {"model_path": f"/tmp/{json_body['config']['model_type']}.pkl"} # noqa: S108 + + ctx = DemoContext( + api_url="x", + seed=42, + skip_seed=False, + reset=False, + quiet=False, + timeout=10.0, + ) + ctx.date_start = _date(2024, 10, 1) + ctx.date_end = _date(2024, 12, 31) + outcome = await run_demo.step_train_all(ctx, _RecordingClient()) # type: ignore[arg-type] + assert outcome.status == "pass" + assert set(seen_models) == set(DEMO_MODEL_TYPES) + # train_end_date should be horizon-padded so backtest has room. + # End - horizon = 2024-12-17. + + @pytest.mark.asyncio + async def test_step_agent_skips_without_keys(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Force the module-level Settings to report no keys. + monkeypatch.setattr(run_demo, "_llm_key_present", lambda: False) + + called = False + + class _AssertNotCalled: + async def request(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + nonlocal called + called = True + return {} + + ctx = DemoContext( + api_url="x", + seed=42, + skip_seed=False, + reset=False, + quiet=False, + timeout=10.0, + ) + outcome = await run_demo.step_agent(ctx, _AssertNotCalled()) # type: ignore[arg-type] + assert outcome.status == "skip" + assert called is False + + +# ============================================================================= +# _llm_key_present (sanity — only checks the API is wired, not actual values) +# ============================================================================= + + +class TestLlmKeyPresent: + def test_returns_bool(self) -> None: + # Whatever the actual env is, the function must return a bool. + result = _llm_key_present() + assert isinstance(result, bool) + + +# ============================================================================= +# Module-level constants sanity +# ============================================================================= + + +class TestModuleConstants: + def test_demo_model_types_count(self) -> None: + assert len(DEMO_MODEL_TYPES) == 3 + assert set(DEMO_MODEL_TYPES) == {"naive", "seasonal_naive", "moving_average"} + + def test_demo_alias_format(self) -> None: + # Must match the registry alias_name pattern ^[a-z0-9][a-z0-9\-_]*$. + assert DEMO_ALIAS == "demo-production" + assert DEMO_ALIAS[0].isalnum() + + def test_horizon_positive(self) -> None: + assert DEMO_HORIZON >= 1 + assert not math.isnan(DEMO_HORIZON) diff --git a/uv.lock b/uv.lock index 341e470f..2abaae1e 100644 --- a/uv.lock +++ b/uv.lock @@ -22,6 +22,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/78/eb55fabaab41abc53f52c0918a9a8c0f747807e5306273f51120fd695957/ag_ui_protocol-0.1.10-py3-none-any.whl", hash = "sha256:c81e6981f30aabdf97a7ee312bfd4df0cd38e718d9fc10019c7d438128b93ab5", size = 7889, upload-time = "2025-11-06T15:17:15.325Z" }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -131,16 +143,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.18.1" +version = "1.18.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" }, + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, ] [[package]] @@ -163,7 +175,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.77.0" +version = "0.102.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -175,9 +187,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/85/6cb5da3cf91de2eeea89726316e8c5c8c31e2d61ee7cb1233d7e95512c31/anthropic-0.77.0.tar.gz", hash = "sha256:ce36efeb80cb1e25430a88440dc0f9aa5c87f10d080ab70a1bdfd5c2c5fbedb4", size = 504575, upload-time = "2026-01-29T18:20:41.507Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/47/cb2a71f70431fb09af4db83e3ea89eb2dd8e0e348d27af53ed32e6c599dd/anthropic-0.102.0.tar.gz", hash = "sha256:96f747cad11886c4ae12d4080131b94eebd68b202bd2190fe27959031bb1fa9c", size = 763697, upload-time = "2026-05-13T18:12:41.624Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/27/9df785d3f94df9ac72f43ee9e14b8120b37d992b18f4952774ed46145022/anthropic-0.77.0-py3-none-any.whl", hash = "sha256:65cc83a3c82ce622d5c677d0d7706c77d29dc83958c6b10286e12fda6ffb2651", size = 397867, upload-time = "2026-01-29T18:20:39.481Z" }, + { url = "https://files.pythonhosted.org/packages/87/75/0f6c603594876413bc858a00e7cc0d80a0cc14edf5c7b959a3ea6ec45e44/anthropic-0.102.0-py3-none-any.whl", hash = "sha256:ab96540bbd4b0f36564252d955a86f8abbe4f00944a24bc9931acc9b139bab6f", size = 763070, upload-time = "2026-05-13T18:12:43.474Z" }, ] [[package]] @@ -253,14 +265,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.6" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] @@ -274,30 +287,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.39" +version = "1.43.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/ea/b96c77da49fed28744ee0347374d8223994a2b8570e76e8380a4064a8c4a/boto3-1.42.39.tar.gz", hash = "sha256:d03f82363314759eff7f84a27b9e6428125f89d8119e4588e8c2c1d79892c956", size = 112783, upload-time = "2026-01-30T20:38:31.226Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/0d/67ebf496fe061397f7eb907504e950fe6d2fa5945fd05891f3033376e471/boto3-1.43.7.tar.gz", hash = "sha256:b1e4b40f4a828c67291b12ebefd17d87a57321101e4a0c969b2f593a0310f343", size = 113170, upload-time = "2026-05-13T19:35:48.556Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/c4/3493b5c86e32d6dd558b30d16b55503e24a6e6cd7115714bc102b247d26e/boto3-1.42.39-py3-none-any.whl", hash = "sha256:d9d6ce11df309707b490d2f5f785b761cfddfd6d1f665385b78c9d8ed097184b", size = 140606, upload-time = "2026-01-30T20:38:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/68fdfc7eaed69c4c77de1b9f1ddbd52e529510161baf9b2bf863be07aad1/boto3-1.43.7-py3-none-any.whl", hash = "sha256:7060f603ca0f645153ee2244506db4db5968a858cd513399d8df70637c362159", size = 140524, upload-time = "2026-05-13T19:35:44.879Z" }, ] [[package]] name = "botocore" -version = "1.42.39" +version = "1.43.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/a6/3a34d1b74effc0f759f5ff4e91c77729d932bc34dd3207905e9ecbba1103/botocore-1.42.39.tar.gz", hash = "sha256:0f00355050821e91a5fe6d932f7bf220f337249b752899e3e4cf6ed54326249e", size = 14914927, upload-time = "2026-01-30T20:38:19.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/be/59144884fa71908e2ac389cfe0fd2ebe8e8adb47bcc994188eb59967406a/botocore-1.43.7.tar.gz", hash = "sha256:abbbc623c52dce86ea9d4534d35e2d6ce447d98edfdaced1695ee0278d6063e3", size = 15350131, upload-time = "2026-05-13T19:35:33.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/71/9a2c88abb5fe47b46168b262254d5b5d635de371eba4bd01ea5c8c109575/botocore-1.42.39-py3-none-any.whl", hash = "sha256:9e0d0fed9226449cc26fcf2bbffc0392ac698dd8378e8395ce54f3ec13f81d58", size = 14591958, upload-time = "2026-01-30T20:38:14.814Z" }, + { url = "https://files.pythonhosted.org/packages/19/f3/a9beaae1cda959fa438b3937fc995d82b2a08cb117c67c31547580f19849/botocore-1.43.7-py3-none-any.whl", hash = "sha256:e93f25dc186a9de033c87128c0f2016aedd74aea9057d918bfc0703a946b1ad1", size = 15031637, upload-time = "2026-05-13T19:35:27.288Z" }, ] [[package]] @@ -309,6 +322,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, ] +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -444,18 +478,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, -] - [[package]] name = "cohere" -version = "5.20.2" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastavro", marker = "sys_platform != 'emscripten'" }, @@ -467,9 +492,9 @@ dependencies = [ { name = "types-requests", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/52/08564d1820970010d30421cd6e36f2e4ca552646504d3fe532eef282c88d/cohere-5.20.2.tar.gz", hash = "sha256:0aa9f3735626b70eedf15c231c61f3a58e7f8bbe5f0509fe7b2e6606c5d420f1", size = 180820, upload-time = "2026-01-23T13:42:51.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/7aff8a870889ee931aa19e1deb138691e3cc909ee61e1daea86f3475a818/cohere-6.1.0.tar.gz", hash = "sha256:6a52bb459b71b5e79735412ee1a8e87028c5b3afba050c39815fe03c083249b3", size = 207302, upload-time = "2026-04-10T19:44:43.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/10/d76f045eefe42fb3f4e271d17ab41b5e73a3b6de69c98e15ab1cb0c8e6f6/cohere-5.20.2-py3-none-any.whl", hash = "sha256:26156d83bf3e3e4475e4caa1d8c4148475c5b0a253aee6066d83c643e9045be6", size = 318986, upload-time = "2026-01-23T13:42:50.151Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b4/00c2f9f8387a2e77faf8410210466c46d55dd30a0388de41c54441b148fb/cohere-6.1.0-py3-none-any.whl", hash = "sha256:ad286b3af2583c75ba93624e6f680603d3578a3d73704f997430260b87537ac7", size = 350543, upload-time = "2026-04-10T19:44:41.805Z" }, ] [[package]] @@ -623,15 +648,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/7c/996760c30f1302704af57c66ff2d723f7d656d0d0b93563b5528a51484bb/cyclopts-4.5.1-py3-none-any.whl", hash = "sha256:0642c93601e554ca6b7b9abd81093847ea4448b2616280f2a0952416574e8c7a", size = 199807, upload-time = "2026-01-25T15:23:55.219Z" }, ] -[[package]] -name = "diskcache" -version = "5.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, -] - [[package]] name = "distro" version = "1.9.0" @@ -711,24 +727,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] -[[package]] -name = "fakeredis" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, -] - [[package]] name = "fastapi" version = "0.128.0" @@ -781,31 +779,35 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.14.4" +version = "3.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, + { name = "griffelib" }, { name = "httpx" }, { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, { name = "packaging" }, { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, - { name = "pydocket" }, { name = "pyperclip" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, + { name = "uncalled-for" }, { name = "uvicorn" }, + { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/a9/a57d5e5629ebd4ef82b495a7f8e346ce29ef80cc86b15c8c40570701b94d/fastmcp-2.14.4.tar.gz", hash = "sha256:c01f19845c2adda0a70d59525c9193be64a6383014c8d40ce63345ac664053ff", size = 8302239, upload-time = "2026-01-22T17:29:37.024Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/41/c4d407e2218fd60d84acb6cc5131d28ff876afecf325e3fd9d27b8318581/fastmcp-2.14.4-py3-none-any.whl", hash = "sha256:5858cff5e4c8ea8107f9bca2609d71d6256e0fce74495912f6e51625e466c49a", size = 417788, upload-time = "2026-01-22T17:29:35.159Z" }, + { url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" }, ] [[package]] @@ -819,7 +821,7 @@ wheels = [ [[package]] name = "forecastlabai" -version = "0.2.5" +version = "0.2.8" source = { editable = "." } dependencies = [ { name = "alembic" }, @@ -862,20 +864,20 @@ dev = [ [package.metadata] requires-dist = [ - { name = "alembic", specifier = ">=1.14.0" }, + { name = "alembic", specifier = ">=1.18.4" }, { name = "anthropic", specifier = ">=0.50.0" }, - { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, - { name = "joblib", specifier = ">=1.4.0" }, + { name = "joblib", specifier = ">=1.5.3" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, { name = "numpy", specifier = ">=2.4.1" }, { name = "openai", specifier = ">=1.40.0" }, - { name = "pandas", specifier = ">=3.0.0" }, + { name = "pandas", specifier = ">=3.0.2" }, { name = "pgvector", specifier = ">=0.3.0" }, { name = "pydantic", specifier = ">=2.10.0" }, - { name = "pydantic-ai", specifier = ">=1.48.0" }, + { name = "pydantic-ai", specifier = ">=1.80.0" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.390" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, @@ -1008,16 +1010,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.48.0" +version = "2.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/f8/80d2493cbedece1c623dc3e3cb1883300871af0dcdae254409522985ac23/google_auth-2.52.0.tar.gz", hash = "sha256:01f30e1a9e3638698d89464f5e603ce29d18e1c0e63ec31ac570aba4e164aaf5", size = 335027, upload-time = "2026-05-07T19:45:24.033Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fc/2cdc74252746f547f81ff3f02d4d4234a3f411b5de5b61af97e633a060b9/google_auth-2.52.0-py3-none-any.whl", hash = "sha256:aee92803ba0ff93a70a3b8a35c7b4797837751cd6380b63ff38372b98f3ed627", size = 245614, upload-time = "2026-05-07T19:45:21.914Z" }, ] [package.optional-dependencies] @@ -1027,7 +1028,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.61.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1041,9 +1042,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/38/421cd7e70952a536be87a0249409f87297d84f523754a25b08fe94b97e7f/google_genai-1.61.0.tar.gz", hash = "sha256:5773a4e8ad5b2ebcd54a633a67d8e9c4f413032fef07977ee47ffa34a6d3bbdf", size = 489672, upload-time = "2026-01-30T20:50:27.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/18/b9024eb20ae76c867f09254dbb9df27c9980284ab54f068bfd9a4f54ea0e/google_genai-2.2.0.tar.gz", hash = "sha256:9e50fe798289b600360b523254b9fe5af72bbc1a4fcf7d774f849aa0a9748a9e", size = 546792, upload-time = "2026-05-12T22:47:01.079Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/87/78dd70cb59f7acf3350f53c5144a7aa7bc39c6f425cd7dc1224b59fcdac3/google_genai-1.61.0-py3-none-any.whl", hash = "sha256:cb073ef8287581476c1c3f4d8e735426ee34478e500a56deef218fa93071e3ca", size = 721948, upload-time = "2026-01-30T20:50:25.551Z" }, + { url = "https://files.pythonhosted.org/packages/b9/72/fa38d7fff62f1bb09cd3bae5e64a2e55f5eb54d9950ca346e44e297457e3/google_genai-2.2.0-py3-none-any.whl", hash = "sha256:26b0815fd45fe4ccc07c3fb3a47a808732349a9fb3de74970c3d3ad897de4647", size = 806323, upload-time = "2026-05-12T22:46:59.061Z" }, ] [[package]] @@ -1102,15 +1103,12 @@ wheels = [ ] [[package]] -name = "griffe" -version = "1.15.0" +name = "griffelib" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] @@ -1182,31 +1180,34 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, - { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, - { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, - { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, - { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, - { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, - { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, + { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, ] [[package]] @@ -1277,26 +1278,22 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.36.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "requests" }, { name = "tqdm" }, + { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358, upload-time = "2025-10-23T12:12:01.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/40/43109e943fd718b0ccd0cd61eb4f1c347df22bf81f5874c6f22adf44bcff/huggingface_hub-1.14.0.tar.gz", hash = "sha256:d6d2c9cd6be1d02ae9ec6672d5587d10a427f377db688e82528f426a041622c2", size = 782365, upload-time = "2026-05-06T14:14:34.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" }, -] - -[package.optional-dependencies] -inference = [ - { name = "aiohttp" }, + { url = "https://files.pythonhosted.org/packages/89/a5/33b49ba7bea7c41bb37f74ec0f8beea0831e052330196633fe2c77516ea6/huggingface_hub-1.14.0-py3-none-any.whl", hash = "sha256:efe075535c62e130b30e836b138e13785f6f043d1f0539e0a39aa411a99e90b8", size = 661479, upload-time = "2026-05-06T14:14:32.029Z" }, ] [[package]] @@ -1329,15 +1326,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "invoke" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, -] - [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1466,6 +1454,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, +] + +[[package]] +name = "jsonpath-python" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/18/4ca8742534a5993ff383f7602e325ce2d5d7cc93d72ac5e1cdedbea8a458/jsonpath_python-1.1.6.tar.gz", hash = "sha256:dded9932b4ec41fb8726e09c83afa4e6be618f938c2db287cc2a81723c639671", size = 88178, upload-time = "2026-05-07T01:26:34.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453, upload-time = "2026-05-07T01:26:33.306Z" }, +] + [[package]] name = "jsonref" version = "1.1.0" @@ -1618,58 +1627,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/00/5045f889be4a450b321db998d0a5581d30423138a04dffe18b52730cb926/logfire_api-4.21.0-py3-none-any.whl", hash = "sha256:32f9b48e6b73c270d1aeb6478dcbecc5f82120b8eae70559e0d1b05d1b86541e", size = 98061, upload-time = "2026-01-28T18:55:42.342Z" }, ] -[[package]] -name = "lupa" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, - { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, - { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, - { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, - { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, - { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, - { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, - { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, - { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, - { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, - { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, - { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, - { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, - { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, - { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, -] - [[package]] name = "mako" version = "1.3.10" @@ -1793,20 +1750,21 @@ wheels = [ [[package]] name = "mistralai" -version = "1.9.11" +version = "2.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "eval-type-backport" }, { name = "httpx" }, - { name = "invoke" }, + { name = "jsonpath-python" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, { name = "pydantic" }, { name = "python-dateutil" }, - { name = "pyyaml" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/8d/d8b7af67a966b6f227024e1cb7287fc19901a434f87a5a391dcfe635d338/mistralai-1.9.11.tar.gz", hash = "sha256:3df9e403c31a756ec79e78df25ee73cea3eb15f86693773e16b16adaf59c9b8a", size = 208051, upload-time = "2025-10-02T15:53:40.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/3f/5624d57c5897c83c55d3e4c7dd4127de42ad14fd3183e26566cdc7dca1bf/mistralai-2.4.5.tar.gz", hash = "sha256:ef165bb004ec4423cbf19a440bf0983ca0c3fc92ab12a35ebca097bdf418e33a", size = 424611, upload-time = "2026-05-07T11:46:43.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl", hash = "sha256:7a3dc2b8ef3fceaa3582220234261b5c4e3e03a972563b07afa150e44a25a6d3", size = 442796, upload-time = "2025-10-02T15:53:39.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/48/2c5c4f853dec32a625c1a3d23809b80cf2e135c3441fe1764f72910dfea9/mistralai-2.4.5-py3-none-any.whl", hash = "sha256:bf3b6550258ab16dec8547b90e9c18bebf9099f55b7fc25a884bf0bbeffced0f", size = 995999, upload-time = "2026-05-07T11:46:41.915Z" }, ] [[package]] @@ -1961,14 +1919,14 @@ wheels = [ [[package]] name = "nexus-rpc" -version = "1.2.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/50/95d7bc91f900da5e22662c82d9bf0f72a4b01f2a552708bf2f43807707a1/nexus_rpc-1.2.0.tar.gz", hash = "sha256:b4ddaffa4d3996aaeadf49b80dfcdfbca48fe4cb616defaf3b3c5c2c8fc61890", size = 74142, upload-time = "2025-11-17T19:17:06.798Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/d5/cd1ffb202b76ebc1b33c1332a3416e55a39929006982adc2b1eb069aaa9b/nexus_rpc-1.4.0.tar.gz", hash = "sha256:3b8b373d4865671789cc43623e3dc0bcbf192562e40e13727e17f1c149050fba", size = 82367, upload-time = "2026-02-25T22:01:34.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/04/eaac430d0e6bf21265ae989427d37e94be5e41dc216879f1fbb6c5339942/nexus_rpc-1.2.0-py3-none-any.whl", hash = "sha256:977876f3af811ad1a09b2961d3d1ac9233bda43ff0febbb0c9906483b9d9f8a3", size = 28166, upload-time = "2025-11-17T19:17:05.64Z" }, + { url = "https://files.pythonhosted.org/packages/11/52/6327a5f4fda01207205038a106a99848a41c83e933cd23ea2cab3d2ebc6c/nexus_rpc-1.4.0-py3-none-any.whl", hash = "sha256:14c953d3519113f8ccec533a9efdb6b10c28afef75d11cdd6d422640c40b3a49", size = 29645, upload-time = "2026-02-25T22:01:33.122Z" }, ] [[package]] @@ -2043,7 +2001,7 @@ wheels = [ [[package]] name = "openai" -version = "2.16.0" +version = "2.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2055,9 +2013,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, ] [[package]] @@ -2115,20 +2073,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, ] -[[package]] -name = "opentelemetry-exporter-prometheus" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "prometheus-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, -] - [[package]] name = "opentelemetry-instrumentation" version = "0.60b1" @@ -2219,54 +2163,54 @@ wheels = [ [[package]] name = "pandas" -version = "3.0.0" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/38/db33686f4b5fa64d7af40d96361f6a4615b8c6c8f1b3d334eee46ae6160e/pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd", size = 10334013, upload-time = "2026-01-21T15:50:34.771Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7b/9254310594e9774906bacdd4e732415e1f86ab7dbb4b377ef9ede58cd8ec/pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740", size = 9874154, upload-time = "2026-01-21T15:50:36.67Z" }, - { url = "https://files.pythonhosted.org/packages/63/d4/726c5a67a13bc66643e66d2e9ff115cead482a44fc56991d0c4014f15aaf/pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801", size = 10384433, upload-time = "2026-01-21T15:50:39.132Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2e/9211f09bedb04f9832122942de8b051804b31a39cfbad199a819bb88d9f3/pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a", size = 10864519, upload-time = "2026-01-21T15:50:41.043Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/50858522cdc46ac88b9afdc3015e298959a70a08cd21e008a44e9520180c/pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb", size = 11394124, upload-time = "2026-01-21T15:50:43.377Z" }, - { url = "https://files.pythonhosted.org/packages/86/3f/83b2577db02503cd93d8e95b0f794ad9d4be0ba7cb6c8bcdcac964a34a42/pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f", size = 11920444, upload-time = "2026-01-21T15:50:45.932Z" }, - { url = "https://files.pythonhosted.org/packages/64/2d/4f8a2f192ed12c90a0aab47f5557ece0e56b0370c49de9454a09de7381b2/pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1", size = 9730970, upload-time = "2026-01-21T15:50:47.962Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/ff571be435cf1e643ca98d0945d76732c0b4e9c37191a89c8550b105eed1/pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0", size = 9041950, upload-time = "2026-01-21T15:50:50.422Z" }, - { url = "https://files.pythonhosted.org/packages/6f/fa/7f0ac4ca8877c57537aaff2a842f8760e630d8e824b730eb2e859ffe96ca/pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6", size = 10307129, upload-time = "2026-01-21T15:50:52.877Z" }, - { url = "https://files.pythonhosted.org/packages/6f/11/28a221815dcea4c0c9414dfc845e34a84a6a7dabc6da3194498ed5ba4361/pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f", size = 9850201, upload-time = "2026-01-21T15:50:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/ba/da/53bbc8c5363b7e5bd10f9ae59ab250fc7a382ea6ba08e4d06d8694370354/pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70", size = 10354031, upload-time = "2026-01-21T15:50:57.463Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a3/51e02ebc2a14974170d51e2410dfdab58870ea9bcd37cda15bd553d24dc4/pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e", size = 10861165, upload-time = "2026-01-21T15:50:59.32Z" }, - { url = "https://files.pythonhosted.org/packages/a5/fe/05a51e3cac11d161472b8297bd41723ea98013384dd6d76d115ce3482f9b/pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3", size = 11359359, upload-time = "2026-01-21T15:51:02.014Z" }, - { url = "https://files.pythonhosted.org/packages/ee/56/ba620583225f9b85a4d3e69c01df3e3870659cc525f67929b60e9f21dcd1/pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e", size = 11912907, upload-time = "2026-01-21T15:51:05.175Z" }, - { url = "https://files.pythonhosted.org/packages/c9/8c/c6638d9f67e45e07656b3826405c5cc5f57f6fd07c8b2572ade328c86e22/pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e", size = 9732138, upload-time = "2026-01-21T15:51:07.569Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bf/bd1335c3bf1770b6d8fed2799993b11c4971af93bb1b729b9ebbc02ca2ec/pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be", size = 9033568, upload-time = "2026-01-21T15:51:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/8e/c6/f5e2171914d5e29b9171d495344097d54e3ffe41d2d85d8115baba4dc483/pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98", size = 10741936, upload-time = "2026-01-21T15:51:11.693Z" }, - { url = "https://files.pythonhosted.org/packages/51/88/9a0164f99510a1acb9f548691f022c756c2314aad0d8330a24616c14c462/pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327", size = 10393884, upload-time = "2026-01-21T15:51:14.197Z" }, - { url = "https://files.pythonhosted.org/packages/e0/53/b34d78084d88d8ae2b848591229da8826d1e65aacf00b3abe34023467648/pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb", size = 10310740, upload-time = "2026-01-21T15:51:16.093Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d3/bee792e7c3d6930b74468d990604325701412e55d7aaf47460a22311d1a5/pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812", size = 10700014, upload-time = "2026-01-21T15:51:18.818Z" }, - { url = "https://files.pythonhosted.org/packages/55/db/2570bc40fb13aaed1cbc3fbd725c3a60ee162477982123c3adc8971e7ac1/pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08", size = 11323737, upload-time = "2026-01-21T15:51:20.784Z" }, - { url = "https://files.pythonhosted.org/packages/bc/2e/297ac7f21c8181b62a4cccebad0a70caf679adf3ae5e83cb676194c8acc3/pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c", size = 11771558, upload-time = "2026-01-21T15:51:22.977Z" }, - { url = "https://files.pythonhosted.org/packages/0a/46/e1c6876d71c14332be70239acce9ad435975a80541086e5ffba2f249bcf6/pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa", size = 10473771, upload-time = "2026-01-21T15:51:25.285Z" }, - { url = "https://files.pythonhosted.org/packages/c0/db/0270ad9d13c344b7a36fa77f5f8344a46501abf413803e885d22864d10bf/pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b", size = 10312075, upload-time = "2026-01-21T15:51:28.5Z" }, - { url = "https://files.pythonhosted.org/packages/09/9f/c176f5e9717f7c91becfe0f55a52ae445d3f7326b4a2cf355978c51b7913/pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe", size = 9900213, upload-time = "2026-01-21T15:51:30.955Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e7/63ad4cc10b257b143e0a5ebb04304ad806b4e1a61c5da25f55896d2ca0f4/pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70", size = 10428768, upload-time = "2026-01-21T15:51:33.018Z" }, - { url = "https://files.pythonhosted.org/packages/9e/0e/4e4c2d8210f20149fd2248ef3fff26623604922bd564d915f935a06dd63d/pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d", size = 10882954, upload-time = "2026-01-21T15:51:35.287Z" }, - { url = "https://files.pythonhosted.org/packages/c6/60/c9de8ac906ba1f4d2250f8a951abe5135b404227a55858a75ad26f84db47/pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986", size = 11430293, upload-time = "2026-01-21T15:51:37.57Z" }, - { url = "https://files.pythonhosted.org/packages/a1/69/806e6637c70920e5787a6d6896fd707f8134c2c55cd761e7249a97b7dc5a/pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49", size = 11952452, upload-time = "2026-01-21T15:51:39.618Z" }, - { url = "https://files.pythonhosted.org/packages/cb/de/918621e46af55164c400ab0ef389c9d969ab85a43d59ad1207d4ddbe30a5/pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7", size = 9851081, upload-time = "2026-01-21T15:51:41.758Z" }, - { url = "https://files.pythonhosted.org/packages/91/a1/3562a18dd0bd8c73344bfa26ff90c53c72f827df119d6d6b1dacc84d13e3/pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8", size = 9174610, upload-time = "2026-01-21T15:51:44.312Z" }, - { url = "https://files.pythonhosted.org/packages/ce/26/430d91257eaf366f1737d7a1c158677caaf6267f338ec74e3a1ec444111c/pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73", size = 10761999, upload-time = "2026-01-21T15:51:46.899Z" }, - { url = "https://files.pythonhosted.org/packages/ec/1a/954eb47736c2b7f7fe6a9d56b0cb6987773c00faa3c6451a43db4beb3254/pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2", size = 10410279, upload-time = "2026-01-21T15:51:48.89Z" }, - { url = "https://files.pythonhosted.org/packages/20/fc/b96f3a5a28b250cd1b366eb0108df2501c0f38314a00847242abab71bb3a/pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a", size = 10330198, upload-time = "2026-01-21T15:51:51.015Z" }, - { url = "https://files.pythonhosted.org/packages/90/b3/d0e2952f103b4fbef1ef22d0c2e314e74fc9064b51cee30890b5e3286ee6/pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084", size = 10728513, upload-time = "2026-01-21T15:51:53.387Z" }, - { url = "https://files.pythonhosted.org/packages/76/81/832894f286df828993dc5fd61c63b231b0fb73377e99f6c6c369174cf97e/pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721", size = 11345550, upload-time = "2026-01-21T15:51:55.329Z" }, - { url = "https://files.pythonhosted.org/packages/34/a0/ed160a00fb4f37d806406bc0a79a8b62fe67f29d00950f8d16203ff3409b/pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac", size = 11799386, upload-time = "2026-01-21T15:51:57.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/c8/2ac00d7255252c5e3cf61b35ca92ca25704b0188f7454ca4aec08a33cece/pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb", size = 10873041, upload-time = "2026-01-21T15:52:00.034Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/a80ac00acbc6b35166b42850e98a4f466e2c0d9c64054161ba9620f95680/pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", size = 9441003, upload-time = "2026-01-21T15:52:02.281Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] [[package]] @@ -2300,15 +2244,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, ] -[[package]] -name = "pathvalidate" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, -] - [[package]] name = "pgvector" version = "0.4.2" @@ -2339,15 +2274,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "prometheus-client" -version = "0.24.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, -] - [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2461,21 +2387,21 @@ wheels = [ [[package]] name = "py-key-value-aio" -version = "0.3.0" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, - { name = "py-key-value-shared" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, ] [package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, ] keyring = [ { name = "keyring" }, @@ -2483,22 +2409,6 @@ keyring = [ memory = [ { name = "cachetools" }, ] -redis = [ - { name = "redis" }, -] - -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] [[package]] name = "pyasn1" @@ -2552,32 +2462,32 @@ email = [ [[package]] name = "pydantic-ai" -version = "1.51.0" +version = "1.96.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai", "xai"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "spec", "temporal", "ui", "vertexai", "xai"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/35/eb8e70dbf82658938b47616b3f92de775b6c10e46a9cd6f9af470755f652/pydantic_ai-1.51.0.tar.gz", hash = "sha256:cb3312af009b71fe3f8174512bc4ac1ee977a0a101bf0aaeaa2ea3b8f31603da", size = 11794, upload-time = "2026-01-31T02:06:24.431Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/a6/81b51b091d532ea8a82ce4f45aea0750e7655e253a4d91df6c59470e097d/pydantic_ai-1.96.0.tar.gz", hash = "sha256:f808a72f8a7e00ef9ec06002893de380e552e5bcad493031fcb1f07a78f39758", size = 13382, upload-time = "2026-05-14T01:09:05.117Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/b5/960a0eb7f3a5cc15643e7353e97f27b225edc308bf6aa0d9510a411a6d8c/pydantic_ai-1.51.0-py3-none-any.whl", hash = "sha256:217a683b5c7a95d219980e56c0b81f6a9160fda542d7292c38708947a8e992e9", size = 7219, upload-time = "2026-01-31T02:06:16.497Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/e37c3e74ae0c73806fbd659196570d8a150ceab7e43b35e231da985eba00/pydantic_ai-1.96.0-py3-none-any.whl", hash = "sha256:bdc44f74326e6d6bbf4688bfa9ccf4474632f736d32c8f5f9a42cdc77e6ec4f1", size = 7581, upload-time = "2026-05-14T01:08:55.895Z" }, ] [[package]] name = "pydantic-ai-slim" -version = "1.51.0" +version = "1.96.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "genai-prices" }, - { name = "griffe" }, + { name = "griffelib" }, { name = "httpx" }, { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/93/82246bf2b4c1550dfb03f0ec6fcd6d38d5841475044a2561061fb3e92a49/pydantic_ai_slim-1.51.0.tar.gz", hash = "sha256:55c6059917559580bcfc39232dbe28ee00b4963a2eb1d9554718edabde4e082a", size = 404501, upload-time = "2026-01-31T02:06:26.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/b0/26299238be57ddbc1ce5b4fc019338dc4856a549d5b636276bf3743a1008/pydantic_ai_slim-1.96.0.tar.gz", hash = "sha256:44ff8bb5cf81023076e82174de1d6a089515277ce508906263d17880e3fcb2f4", size = 699284, upload-time = "2026-05-14T01:09:07.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/05/0f2a718b117d8c4f89871848d8bde5f9dd7b1e0903f3cba9f9d425726307/pydantic_ai_slim-1.51.0-py3-none-any.whl", hash = "sha256:09aa368a034f7adbd6fbf23ae8415cbce0de13999ca1b0ba1ae5a42157293318", size = 528636, upload-time = "2026-01-31T02:06:19.583Z" }, + { url = "https://files.pythonhosted.org/packages/32/a0/4ceb29871244a398fed43009b8d6577ee60b8562437e47027e371ef2d22c/pydantic_ai_slim-1.96.0-py3-none-any.whl", hash = "sha256:58044dee3e5429499938a5ef1a44ef44d5e179f3f68e80600bbddea1f6081967", size = 870756, upload-time = "2026-05-14T01:08:58.945Z" }, ] [package.optional-dependencies] @@ -2595,6 +2505,7 @@ cli = [ { name = "argcomplete" }, { name = "prompt-toolkit" }, { name = "pyperclip" }, + { name = "pyyaml" }, { name = "rich" }, ] cohere = [ @@ -2613,7 +2524,7 @@ groq = [ { name = "groq" }, ] huggingface = [ - { name = "huggingface-hub", extra = ["inference"] }, + { name = "huggingface-hub" }, ] logfire = [ { name = "logfire", extra = ["httpx"] }, @@ -2631,6 +2542,10 @@ openai = [ retries = [ { name = "tenacity" }, ] +spec = [ + { name = "pydantic-handlebars" }, + { name = "pyyaml" }, +] temporal = [ { name = "temporalio" }, ] @@ -2718,7 +2633,7 @@ wheels = [ [[package]] name = "pydantic-evals" -version = "1.51.0" +version = "1.96.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2728,14 +2643,14 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/72/bf5edba48c2fbaf0a337db79cb73bb150a054d0ae896f10ffeb67689f53b/pydantic_evals-1.51.0.tar.gz", hash = "sha256:3a96c70dec9e36ea5bc346490239a6e8d7fadcfdd5ea09d86b92da7a7a8d8db2", size = 47184, upload-time = "2026-01-31T02:06:28.001Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/f0/9e43f04c1c90f251254b881ff4cf4b86d032a9191b6688835bb3ce1fbbaf/pydantic_evals-1.96.0.tar.gz", hash = "sha256:812b01c4b8c4f1c567dda7a1e44195f8e68ece69d3a7bf95c49d1670ae5c6cc9", size = 76672, upload-time = "2026-05-14T01:09:09.133Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/44/b5af240324736c13011b2da1b9bb3249b83c53b036fbf44bf6d169a9b314/pydantic_evals-1.51.0-py3-none-any.whl", hash = "sha256:67d89d024d1d65691312a46f2a1130d0a882ed5e61dd40e78e168a67b398c7f6", size = 56378, upload-time = "2026-01-31T02:06:21.408Z" }, + { url = "https://files.pythonhosted.org/packages/4d/5c/3421b21b4912dbbb5b6768a6f6244fadb4304482b8b6499462f5c60d7c96/pydantic_evals-1.96.0-py3-none-any.whl", hash = "sha256:100e986962468941cac2d96a53d9773c97ee10882f4705ba7e281c794bcd18ce", size = 91597, upload-time = "2026-05-14T01:09:01.015Z" }, ] [[package]] name = "pydantic-graph" -version = "1.51.0" +version = "1.96.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2743,46 +2658,35 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/b0/830861f07789c97240bcc8403547f68f9ee670b7db403fd3ead30ed5844b/pydantic_graph-1.51.0.tar.gz", hash = "sha256:6b6220c858e552df1ea76f8191bb12b13027f7e301d4f14ee593b0e55452a1a1", size = 58457, upload-time = "2026-01-31T02:06:29.327Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/b3/7e279ee3e8d1db7ff29fb4c6cf21c2b30a002e0900eae94bcc829a66f0e2/pydantic_graph-1.96.0.tar.gz", hash = "sha256:299a2b1e47e232a78b8038779c1ff5b387d6f02d79aebae217806c5d53607f9e", size = 59294, upload-time = "2026-05-14T01:09:10.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/f0/5256d6dcc4f669504183c11b67fd016d2a007b687198f500a7ec22cf6851/pydantic_graph-1.51.0-py3-none-any.whl", hash = "sha256:fcd6b94ddd1fd261f25888a2b7882a21e677b9718045e40af6321238538752d1", size = 72345, upload-time = "2026-01-31T02:06:22.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/58/918e641e1d94b95315a174bf318a78c0c127191333ffe021a92d417f6159/pydantic_graph-1.96.0-py3-none-any.whl", hash = "sha256:5904661751c4f19cba726e4e16a878f2f83722432236c231c88dba2bd887b43d", size = 73047, upload-time = "2026-05-14T01:09:02.476Z" }, ] [[package]] -name = "pydantic-settings" -version = "2.12.0" +name = "pydantic-handlebars" +version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/16/d41768bd3fd77e6250c20be11a3e68fee5fff07c3356455e6708f6a60f2a/pydantic_handlebars-0.1.0.tar.gz", hash = "sha256:1931c54946add1b5e3796c9bf6a005ed7662cef0109bb05c352f0b3d031a1260", size = 159826, upload-time = "2026-03-01T20:00:17.497Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/86b1630be61bdebf253c2f953a6c3f073ec21bb0725565ea3896802e1ca3/pydantic_handlebars-0.1.0-py3-none-any.whl", hash = "sha256:8a436fe8bc607295eb04bec58bd6e2c9498c9e069c557ff0b505e3d568c783bc", size = 40890, upload-time = "2026-03-01T20:00:16.106Z" }, ] [[package]] -name = "pydocket" -version = "0.16.6" +name = "pydantic-settings" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cloudpickle" }, - { name = "fakeredis", extra = ["lua"] }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-prometheus" }, - { name = "opentelemetry-instrumentation" }, - { name = "prometheus-client" }, - { name = "py-key-value-aio", extra = ["memory", "redis"] }, - { name = "python-json-logger" }, - { name = "redis" }, - { name = "rich" }, - { name = "typer" }, - { name = "typing-extensions" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] [[package]] @@ -2894,15 +2798,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] -[[package]] -name = "python-json-logger" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, -] - [[package]] name = "python-multipart" version = "0.0.22" @@ -2983,15 +2878,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "redis" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, -] - [[package]] name = "referencing" version = "0.36.2" @@ -3216,18 +3102,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" version = "0.14.14" @@ -3256,14 +3130,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.16.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, ] [[package]] @@ -3411,15 +3285,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.46" @@ -3504,7 +3369,7 @@ wheels = [ [[package]] name = "temporalio" -version = "1.20.0" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nexus-rpc" }, @@ -3512,13 +3377,13 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/db/7d5118d28b0918888e1ec98f56f659fdb006351e06d95f30f4274962a76f/temporalio-1.20.0.tar.gz", hash = "sha256:5a6a85b7d298b7359bffa30025f7deac83c74ac095a4c6952fbf06c249a2a67c", size = 1850498, upload-time = "2025-11-25T21:25:20.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/62/2bc1a9ad29382a3a99f088907ef2024a94420cfef340be1b33026c632828/temporalio-1.27.2.tar.gz", hash = "sha256:633bf2379492f3db1e887d1e64fdac00d9c2ddc3e9382b831d5af68256912e92", size = 2503041, upload-time = "2026-05-14T02:17:57.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/1b/e69052aa6003eafe595529485d9c62d1382dd5e671108f1bddf544fb6032/temporalio-1.20.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:fba70314b4068f8b1994bddfa0e2ad742483f0ae714d2ef52e63013ccfd7042e", size = 12061638, upload-time = "2025-11-25T21:24:57.918Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/3e8c67ed7f23bedfa231c6ac29a7a9c12b89881da7694732270f3ecd6b0c/temporalio-1.20.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffc5bb6cabc6ae67f0bfba44de6a9c121603134ae18784a2ff3a7f230ad99080", size = 11562603, upload-time = "2025-11-25T21:25:01.721Z" }, - { url = "https://files.pythonhosted.org/packages/6d/be/ed0cc11702210522a79e09703267ebeca06eb45832b873a58de3ca76b9d0/temporalio-1.20.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1e80c1e4cdf88fa8277177f563edc91466fe4dc13c0322f26e55c76b6a219e6", size = 11824016, upload-time = "2025-11-25T21:25:06.771Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/09c5cafabc80139d97338a2bdd8ec22e08817dfd2949ab3e5b73565006eb/temporalio-1.20.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba92d909188930860c9d89ca6d7a753bc5a67e4e9eac6cea351477c967355eed", size = 12189521, upload-time = "2025-11-25T21:25:12.091Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/5689c014a76aff3b744b3ee0d80815f63b1362637814f5fbb105244df09b/temporalio-1.20.0-cp310-abi3-win_amd64.whl", hash = "sha256:eacfd571b653e0a0f4aa6593f4d06fc628797898f0900d400e833a1f40cad03a", size = 12745027, upload-time = "2025-11-25T21:25:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/64/85/9da14f9fbdfae95435d29353bb1c55891581ad6b23c86ca56e72d83035ed/temporalio-1.27.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:860f706380faafec8f183f9194d0883c8033a4211c5d19c2c962c45b06cf99e9", size = 14602829, upload-time = "2026-05-14T02:17:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/b7437991e71eea082dc53222da11f064974917cd59063ba57e13e5895fbc/temporalio-1.27.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a8dc0c680e351f3132809861888d8326dbd5030dd4e570663597e7d4768d9502", size = 13997680, upload-time = "2026-05-14T02:17:53.968Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/358065040e6f0cedbf669acd333622999eec737ff868ca7829d727b77746/temporalio-1.27.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805f3de4d193dec52e040e41dbfc9ab44be0206d2e81142ceefaf7b7208058d1", size = 14252199, upload-time = "2026-05-14T02:17:36.972Z" }, + { url = "https://files.pythonhosted.org/packages/72/8a/85d2eab07c3e23fc1124203e76857c69ab9b22d8ccebad0835e294edb754/temporalio-1.27.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bc996cb501b8a918f50037ccee6facb05bb70984acada4c2a3e01f5e7957a38", size = 14779945, upload-time = "2026-05-14T02:18:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/c9b08609e2a92ecf62c97c59cabfa0608337c8d5cc9941eed5d9a7778840/temporalio-1.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:62a84ae9a60c17932971e4ca3b0f3cd6f32f173b8183e759989376503fb95af6", size = 14981897, upload-time = "2026-05-14T02:17:27.333Z" }, ] [[package]] @@ -3699,6 +3564,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"