From a864ae3af1404b639c4c8f5999ba8491cd768582 Mon Sep 17 00:00:00 2001 From: Gabor Szabo <168316277+w7-mgfcode@users.noreply.github.com> Date: Thu, 14 May 2026 06:11:42 +0200 Subject: [PATCH 1/4] feat(data): implement MarkdownGenerator age_days trigger via heuristic (#94) (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #94 via the heuristic path documented in the issue. No schema column, no Alembic migration, no FIFO cohort tracking — the trigger self-reads the existing per-(store, product) on_hand_qty series and fires when inventory has been "unrefreshed" past `cfg.age_days_threshold`. Decision rationale (schema column vs. heuristic): - The schema column path would add `oldest_unit_age_days` to `inventory_snapshot_daily`, plus an Alembic migration, plus FIFO cohort tracking in `InventorySnapshotGenerator`. No downstream consumer reads this column today — adding it for one generator trigger violates the "don't design for hypothetical future requirements" rule in CLAUDE.md. - The heuristic path is self-contained in MarkdownGenerator, deterministic (preserves the zero-rng-draw regression invariant), and additive (no migration, no model change). 354 LOC net, all inside one slice. Heuristic spec: - A "refresh" is a day where `on_hand_qty` rose by >= `_AGE_DAYS_SPIKE_THRESHOLD` (0.3 = 30% jump) vs the prior day. - Age at day t = days since most recent refresh (or `dates[0]` if no refresh has been observed). - Firing requires age >= `age_days_threshold` AND on_hand >= `markdown_min_units_remaining` — never markdown an empty shelf. - After firing, refresh anchor resets to the day AFTER the markdown window ends, so back-to-back fires can't happen and the next age clock starts from a "clear shelf" baseline. Wiring: `MarkdownGenerator.generate()` gains an optional kwarg `inventory_records: list[dict[str, Any]] | None = None` which `core.py` passes through from `InventorySnapshotGenerator`. Disabled-path and non-age_days-path behavior is byte-identical (kwarg ignored). Tests: +7 new in `TestAgeDaysTrigger`, -1 obsolete `NotImplementedError` test. Coverage: no-records defensive, threshold not-met, threshold met, spike resets age, post-fire reset avoids back-to-back, low-inventory skip, unknown-product skip, rng non-consumption. Validation (local): - ruff check + format: clean - mypy --strict: 0 issues, 192 files - pyright --strict: 0 errors - pytest -m "not integration": 969 passed (+7 vs pre-PR) Closes #94. --- app/shared/seeder/core.py | 1 + app/shared/seeder/generators/markdowns.py | 148 ++++++++++- .../seeder/tests/test_phase2_markdowns.py | 229 +++++++++++++++++- 3 files changed, 354 insertions(+), 24 deletions(-) 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_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 From 1b4447da3756468b261121edefe851c41370a618 Mon Sep 17 00:00:00 2001 From: Gabor Szabo <168316277+w7-mgfcode@users.noreply.github.com> Date: Thu, 14 May 2026 16:49:22 +0200 Subject: [PATCH 2/4] feat(api,docs): e2e demo pipeline + showcase script (#128) (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(docs): add INITIAL-14 + PRP-15 e2e demo pipeline plan (#128) Adds the planning documents for the end-to-end demo pipeline work tracked in #128. Implementation commits follow on this branch. - INITIAL-14.md: PRD for `make demo` (problem, solution, success metrics, open questions resolved in the PRP). - PRPs/PRP-15-e2e-demo-pipeline.md: full execution plan (16 tasks → 6 commits, additive only — no schema changes, no API edits). * feat(data): add demo_minimal scenario preset (#128) Tiny preset that powers the upcoming `make demo` target. Three stores × ten products × 92 days (2024-10-01..2024-12-31) — wide enough to satisfy an expanding backtest with n_splits=3, horizon=14, min_train_size=30 (needs >= 72 days, 92 leaves margin), small enough to keep the demo loop comfortable on a laptop. Mirrors the retail_standard tuning (mild linear trend, noise_sigma=0.10, modest promotion + stockout probabilities) so backtest WAPE stays non-NaN across all three baseline models. - app/shared/seeder/config.py: add DEMO_MINIMAL enum + from_scenario branch - app/features/seeder/service.py: add ScenarioInfo entry in list_scenarios - tests cover the new preset and the updated scenario count * feat(api,docs): scripts/run_demo.py end-to-end pipeline driver (#128) Single-file async driver that walks the published HTTP surface (precheck -> reset? -> seed -> status -> features -> train x3 -> backtest x3 -> register -> verify -> agent -> cleanup). Mirrors the shape of scripts/seed_random.py and scripts/check_db.py. - HttpClient: thin httpx.AsyncClient wrapper with explicit 60 s timeout (default 5 s is too short for /seeder/generate); surfaces RFC 7807 problem+json bodies as a typed StepError that echoes title / detail / request_id (never the raw body — secrets-safe). - DemoContext + StepOutcome dataclasses thread cross-step references. - Reporter renders the output-formatting.md glyphs (verbose by default, --quiet collapses to one line per step). - Per-step error handling converts httpx + StepError into fail outcomes; precheck failure exits 2, any other failure exits 1, green exits 0. - Agent step (10/11) skips with ⏭️ when neither OPENAI_API_KEY nor ANTHROPIC_API_KEY is set; reads via app.core.config.get_settings() to honor the no-os.environ-in-feature-code rule. - Registry handshake uses the mandatory pending -> running -> success transition and the wire alias "model_config" (not "model_config_data"); artifact_hash is computed client-side via sha256 since we share the FS with the API on this single-host system. - Winner selection: lowest aggregated WAPE, skipping NaN folds. Also adds scripts/__init__.py so tests can `import scripts.run_demo` without invoking the file as a script. * feat(repo): top-level Makefile with demo / demo-quick / demo-clean (#128) Wraps scripts/run_demo.py so reviewers can run the full end-to-end demo with one command. Recipes mirror the three modes the script supports: full run, skip-seed iteration, destructive reset. Make targets: - demo — docker compose up -d + alembic + run_demo - demo-quick — run_demo --skip-seed (no compose/migration touch) - demo-clean — full reset (--reset) before seeding - help — default goal; lists targets + preconditions Tab-indented recipes and .PHONY declarations per make conventions. Preconditions (Postgres on :5433, uvicorn on :8123) documented in the help block; the script itself enforces them via the precheck step and exits 2 on failure. * test(api): unit + integration coverage for run_demo (#128) Unit (`tests/test_run_demo_unit.py`, 32 cases): - argparse defaults + all-flags variants - DemoContext defaults (no leaking state across runs) - _select_winner: lowest-WAPE, NaN-skip, all-NaN -> None, empty -> None - _model_config_payload: discriminated-union shape per baseline; rejects unsupported model_type (defends the "no lightgbm in PRP-15" boundary) - Reporter: glyph mapping; verbose + quiet output; summary green / failure / over-budget soft-warn branches - StepError formats RFC 7807 (title/detail/request_id) without leaking the raw response body - HttpClient (mocked httpx.AsyncClient): 2xx, 204, non-2xx -> StepError - Step payload sanity: seed sends demo_minimal+correct dims+ISO dates; features sends cutoff_date as ISO; train fires three model_types in parallel; agent step skips with ⏭️ when no LLM key Integration (`tests/test_e2e_demo.py`, @pytest.mark.integration): - Skips if Postgres on :5433 isn't reachable - Boots uvicorn on :8124 as a subprocess (avoids the dev :8123 default) - Runs scripts/run_demo.py --reset against it; asserts exit 0 + canonical "runs=3 winner=... alias=demo-production" summary - Second case asserts a bogus URL exits 2 (no silent success) - Cleans up uvicorn on teardown with terminate/kill fallback - Resolves `uv` via shutil.which to keep ruff S607 happy and avoid PATH-dependent exec at test time * ci(repo): nightly e2e demo workflow (#128) Adds .github/workflows/e2e-nightly.yml — runs scripts/run_demo.py against a fresh Postgres+pgvector service every night at 07:00 UTC (plus on-demand via workflow_dispatch). Catches regressions in the documented end-to-end pipeline before they bleed into the per-PR gate. Per PRP-15 + INITIAL-14 risk note: this workflow is intentionally NOT a required status check on dev or main. Flake-budget lives in the nightly slot, not in ci.yml. - pgvector/pgvector:pg16 service container (same as ci.yml `test` job) - uvicorn started in background; /health polled with a 30 s deadline - run_demo.py called with --seed 42 (deterministic) - LLM-key env vars intentionally absent — agent step auto-skips via ⏭️, keeping the workflow self-contained - uvicorn logs uploaded as artifact on failure (7-day retention) so postmortems can read what the API was doing when the script broke - astral-sh/setup-uv pinned by 40-char SHA per security-patterns.md - permissions: contents: read (least-privilege) * docs(docs): cross-link make demo from README + RUNBOOKS + REPO_MAP_INDEX (#128) Discoverability layer for PRP-15. - README.md: new 'Try it: end-to-end demo' step right after the curl /health verification; shows the canonical final-line summary so reviewers know what green looks like. - docs/DAILY-FLOW.md: new 'First-Run Smoke (Demo Pipeline)' section documenting all three Make targets. - docs/_base/RUNBOOKS.md: new 'make demo fails at step X' Common Incidents entry with a 7-point diagnosis flow keyed to the script's step names + a postmortem-capture recipe. - docs/_base/REPO_MAP_INDEX.md: Makefile and scripts/run_demo.py rows added to the Document Index table. Pure additive; no existing content removed or renamed. * fix(data): update /seeder/scenarios route test for demo_minimal preset (#128) Companion to feat(data): add demo_minimal scenario preset — the route-level assertion in TestListScenarios.test_returns_scenarios still expected 6 scenarios; bumping to 7 and adding the demo_minimal name membership check to match the service-layer + config-layer tests already updated in 005c189. * fix(api): harden run_demo for integration test + real DB (#128) Three real failures surfaced when first running the integration test against docker-compose Postgres + a freshly booted uvicorn; all three are now closed: scripts/run_demo.py: 1. step_status: discover the real (store_id, product_id) from /dimensions/stores + /dimensions/products instead of hardcoding 1. Postgres auto-increment does NOT reset after delete, so the freshly seeded IDs are NOT 1 (they were ~150-260 on this branch after a few delete/seed cycles). 2. step_register: copy the trained-model artifact into the registry's own root (settings.registry_artifact_root) and record a registry- relative URI. The registry verify endpoint resolves artifact_uri against its own root, which is separate from where /forecasting/train writes (settings.forecast_model_artifacts_dir). Pre-fix, verify returned 404 even though the artifact existed on disk. 3. step_agent: skip with the soft-skip glyph on any LLM provider failure (invalid key, model unavailable, 5xx), and make _llm_key_present provider-aware so it matches the right env var to the configured agent_default_model. Pre-fix, an .env with anthropic/openai keys but a Gemini default model failed hard at chat-time. 4. Bumped DEFAULT_TIMEOUT_S from 60 to 120 because /seeder/generate for demo_minimal can spend 60-90 s on slower laptops once you include inventory + prices + promotions inserts. 5. step_seed detail string: GenerateResult.records_created uses 'sales' (singular), not 'sales_daily'; cosmetic fix. tests/test_e2e_demo.py: - Redirect uvicorn stdout to a temp file rather than subprocess.PIPE. The seeder + structlog produce enough INFO log volume to fill a 64-KB pipe buffer; once full, uvicorn blocks on write and seeder requests hang for the full --timeout. Verified locally: integration suite now passes in ~6.5 s instead of timing out at 120 s. - Cleanup leaves the log file on disk only when the test failed (postmortem-friendly). tests/test_run_demo_unit.py: - Bump test_defaults timeout expectation to match the new 120 s default. End-to-end manual run on this machine: 11 steps, wall_clock=2 s, exit 0. Integration test: 2 passed in 6.48 s. --- .github/workflows/e2e-nightly.yml | 131 +++ INITIAL-14.md | 245 +++++ Makefile | 45 + PRPs/PRP-15-e2e-demo-pipeline.md | 845 ++++++++++++++++ README.md | 18 + app/features/seeder/service.py | 8 + app/features/seeder/tests/test_routes.py | 3 +- app/features/seeder/tests/test_service.py | 14 +- app/shared/seeder/config.py | 23 + app/shared/seeder/tests/test_config.py | 23 + docs/DAILY-FLOW.md | 19 + docs/_base/REPO_MAP_INDEX.md | 2 + docs/_base/RUNBOOKS.md | 26 + scripts/__init__.py | 7 + scripts/run_demo.py | 1084 +++++++++++++++++++++ tests/test_e2e_demo.py | 228 +++++ tests/test_run_demo_unit.py | 550 +++++++++++ 17 files changed, 3269 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/e2e-nightly.yml create mode 100644 INITIAL-14.md create mode 100644 Makefile create mode 100644 PRPs/PRP-15-e2e-demo-pipeline.md create mode 100644 scripts/__init__.py create mode 100644 scripts/run_demo.py create mode 100644 tests/test_e2e_demo.py create mode 100644 tests/test_run_demo_unit.py 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/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/README.md b/README.md index 1dbe35ed..7c1213c9 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,24 @@ 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). + ### Frontend Setup 8. **Install frontend dependencies** 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/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/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/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/_base/REPO_MAP_INDEX.md b/docs/_base/REPO_MAP_INDEX.md index e34bace0..3bc99483 100644 --- a/docs/_base/REPO_MAP_INDEX.md +++ b/docs/_base/REPO_MAP_INDEX.md @@ -19,6 +19,8 @@ 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 | | [`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..e8a2f92f 100644 --- a/docs/_base/RUNBOOKS.md +++ b/docs/_base/RUNBOOKS.md @@ -60,6 +60,32 @@ 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 +``` + ### 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/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_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) From 7034e48c3abab885813610f0aa429f4399a506d8 Mon Sep 17 00:00:00 2001 From: Gabor Szabo <168316277+w7-mgfcode@users.noreply.github.com> Date: Thu, 14 May 2026 17:00:26 +0200 Subject: [PATCH 3/4] chore(repo): bump authlib + fastmcp to clear Socket-flagged CVEs (#130) (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headline: - authlib 1.6.6 -> 1.7.2 (clears GHSA-wvwj-cvrp-7pv5 — JWS signature verification bypass; patched at >= 1.6.9) - fastmcp 2.14.4 -> 3.2.4 (clears GHSA-vv7q-7jx5-f767 — OpenAPI Provider SSRF + path traversal; patched at >= 3.2.0) Both CVEs were flagged on PR #129 by Socket Security and are pre-existing on dev (not introduced by #128). Wider scope — read before merging: `uv lock --upgrade-package authlib --upgrade-package fastmcp` triggers a full re-resolve of the dependency graph. Because dev's uv.lock had drifted from pyproject.toml (the project's constraint envelope had loosened over time), this single command also brings the lockfile in sync with current pyproject.toml. Net diff: 243 insertions / 369 deletions on uv.lock; no other files touched. Transitive cascades worth flagging: - anthropic 0.77.0 -> 0.102.0 (pydantic-ai-slim extra) - pydantic-graph 1.51.0 -> 1.96.0 - temporalio 1.20.0 -> 1.27.2 - alembic 1.18.1 -> 1.18.4 - aws-* and cohere transitives bumped along - griffe 1.15.0 dropped in favor of griffelib 2.0.2 (fastmcp 3.x switched) - Removed: cloudpickle, diskcache, fakeredis, invoke, lupa, prometheus exporter, pydocket, redis, rsa, sortedcontainers — these were transitives of fastmcp 2.x that fastmcp 3.x no longer pulls in. Verification on this host: - uv sync --extra dev -> green - ruff check . -> clean - mypy --strict app/ -> 192 files clean - pyright app/ -> 0 errors (50 warnings, pre-existing) - pytest -m 'not integration' -> 969 passed Known install quirk: griffelib 2.0.2 ships a top-level `griffe/` package whose RECORD files don't always materialize on first install when uv replaces an older `griffe` dist in the same sync. A clean venv install (which CI does via `uv sync --frozen`) is unaffected; local devs who upgrade in place may need a one-shot `uv pip install --force-reinstall griffelib` if `import griffe` fails. --- uv.lock | 612 ++++++++++++++++++++++---------------------------------- 1 file changed, 243 insertions(+), 369 deletions(-) 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" From 51d9149ece20b94f6abe598e4ac29e7388d081d8 Mon Sep 17 00:00:00 2001 From: Gabor Szabo <168316277+w7-mgfcode@users.noreply.github.com> Date: Sun, 17 May 2026 19:55:18 +0200 Subject: [PATCH 4/4] feat(api,ui): in-product demo showcase page (#132) (#133) * feat(api): add demo slice driving the e2e pipeline via /demo endpoints (#132) New app/features/demo slice exposing POST /demo/run and WS /demo/stream. It drives the published API surface in-process via httpx.ASGITransport (no cross-slice imports, satisfying the vertical-slice rule) and streams one StepEvent per pipeline step: precheck -> reset -> seed -> status -> features -> train x3 -> backtest x3 -> register -> verify -> agent -> cleanup. A module-level asyncio.Lock enforces single-flight; concurrent runs get an RFC 7807 409. The orchestration is a faithful in-process port of scripts/run_demo.py (PR #129). Implements PRP-17. * test(api): cover the demo slice pipeline, routes, and e2e integration (#132) Unit tests mock the in-process HTTP client to exercise step sequencing, winner selection, and fail-fast; route tests cover POST /demo/run (200 + 409) and the WS /demo/stream handler. The integration test seeds demo_minimal and asserts an end-to-end green run against real Postgres. Implements PRP-17. * feat(ui): add showcase page streaming the live demo pipeline (#132) New /showcase route and nav entry. The page opens a one-shot WebSocket to /demo/stream via a use-demo-pipeline hook (wrapping useWebSocket) and renders the 11 pipeline steps as live status cards: glyph, detail, duration, the backtest per-model WAPE breakdown with the winner highlighted, and a pass/fail summary banner. Also block-scopes a pre-existing no-case-declarations lint error in chat.tsx so pnpm lint is green for this PR. Implements PRP-17. * test(ui): add vitest setup and use-demo-pipeline hook coverage (#132) Adds the frontend test stack (vitest + jsdom + @testing-library/react), a test script, and vitest.config.ts. use-demo-pipeline.test.ts covers the pure event reducer (idle -> running -> pass transitions, summary assembly, error phase) and a renderHook smoke test. The package.json pnpm.onlyBuiltDependencies entry is the RUNBOOKS-documented fix for pnpm 11's esbuild build-script gate. Implements PRP-17. * docs(docs): document the demo slice and showcase page (#132) Adds the PRP-17 spec; a 'Try it in the browser' pointer in README; the /demo/run + /demo/stream rows and a WebSocket Events section in API_CONTRACTS; a 'Showcase pipeline fails' runbook incident; and REPO_MAP_INDEX rows for the demo slice and showcase page. Implements PRP-17. --- PRPs/PRP-17-demo-showcase-page.md | 875 ++++++++++++++++++ README.md | 4 + app/features/demo/__init__.py | 19 + app/features/demo/pipeline.py | 766 +++++++++++++++ app/features/demo/routes.py | 97 ++ app/features/demo/schemas.py | 105 +++ app/features/demo/service.py | 80 ++ app/features/demo/tests/__init__.py | 0 app/features/demo/tests/conftest.py | 22 + app/features/demo/tests/test_pipeline.py | 300 ++++++ app/features/demo/tests/test_routes.py | 114 +++ app/features/demo/tests/test_schemas.py | 74 ++ app/main.py | 2 + docs/_base/API_CONTRACTS.md | 15 + docs/_base/REPO_MAP_INDEX.md | 2 + docs/_base/RUNBOOKS.md | 9 + frontend/package.json | 12 +- frontend/pnpm-lock.yaml | 685 ++++++++++++++ frontend/src/App.tsx | 9 + .../src/components/demo/demo-step-card.tsx | 131 +++ frontend/src/components/demo/index.ts | 1 + frontend/src/hooks/index.ts | 1 + frontend/src/hooks/use-demo-pipeline.test.ts | 173 ++++ frontend/src/hooks/use-demo-pipeline.ts | 190 ++++ frontend/src/lib/constants.ts | 110 ++- frontend/src/pages/chat.tsx | 155 ++-- frontend/src/pages/showcase.tsx | 164 ++++ frontend/src/types/api.ts | 36 + frontend/tsconfig.node.json | 2 +- frontend/vitest.config.ts | 16 + tests/test_demo_showcase_integration.py | 58 ++ 31 files changed, 4096 insertions(+), 131 deletions(-) create mode 100644 PRPs/PRP-17-demo-showcase-page.md create mode 100644 app/features/demo/__init__.py create mode 100644 app/features/demo/pipeline.py create mode 100644 app/features/demo/routes.py create mode 100644 app/features/demo/schemas.py create mode 100644 app/features/demo/service.py create mode 100644 app/features/demo/tests/__init__.py create mode 100644 app/features/demo/tests/conftest.py create mode 100644 app/features/demo/tests/test_pipeline.py create mode 100644 app/features/demo/tests/test_routes.py create mode 100644 app/features/demo/tests/test_schemas.py create mode 100644 frontend/src/components/demo/demo-step-card.tsx create mode 100644 frontend/src/components/demo/index.ts create mode 100644 frontend/src/hooks/use-demo-pipeline.test.ts create mode 100644 frontend/src/hooks/use-demo-pipeline.ts create mode 100644 frontend/src/pages/showcase.tsx create mode 100644 frontend/vitest.config.ts create mode 100644 tests/test_demo_showcase_integration.py 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 7c1213c9..c30a970d 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ 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** 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/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/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/REPO_MAP_INDEX.md b/docs/_base/REPO_MAP_INDEX.md index 3bc99483..bceb6157 100644 --- a/docs/_base/REPO_MAP_INDEX.md +++ b/docs/_base/REPO_MAP_INDEX.md @@ -21,6 +21,8 @@ ForecastLabAI is a portfolio-grade, single-host retail-demand-forecasting system | [`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 e8a2f92f..1ad13d29 100644 --- a/docs/_base/RUNBOOKS.md +++ b/docs/_base/RUNBOOKS.md @@ -86,6 +86,15 @@ rm -rf frontend/node_modules && corepack enable pnpm && cd frontend && pnpm inst 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() { } /> + }> + + + } + /> = { + 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/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/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")