Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
620 changes: 620 additions & 0 deletions PRPs/PRP-showcase-workspace-E2-preset-exposure.md

Large diffs are not rendered by default.

57 changes: 48 additions & 9 deletions app/features/demo/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from typing import Any
from typing import Any, NamedTuple

import httpx
from fastapi import FastAPI
Expand Down Expand Up @@ -476,25 +476,64 @@ async def step_reset(ctx: DemoContext, client: _Client) -> StepResult:
)


_SCENARIO_SEED_PROFILE: dict[ScenarioPreset, tuple[int, int, int]] = {
ScenarioPreset.DEMO_MINIMAL: (DEMO_SEED_STORES, DEMO_SEED_PRODUCTS, DEMO_SEED_SPAN_DAYS),
class _SeedProfile(NamedTuple):
"""Demo-scaled seed profile for one scenario preset.

The /seeder/generate request overrides preset dims/window by design
(app/features/seeder/service.py:_build_config_from_params) while
preserving the preset's behavioral character (noise, promos, stockouts,
sparsity, launch ramps). ``window`` pins a fixed calendar range
(holiday_rush); when None the window runs ``span_days`` back from today.
"""

stores: int
products: int
span_days: int
window: tuple[date, date] | None = None


_SCENARIO_SEED_PROFILE: dict[ScenarioPreset, _SeedProfile] = {
ScenarioPreset.DEMO_MINIMAL: _SeedProfile(
DEMO_SEED_STORES, DEMO_SEED_PRODUCTS, DEMO_SEED_SPAN_DAYS
),
# PRP-38 — SHOWCASE_RICH profile mirrors app/shared/seeder/config.py:from_scenario.
ScenarioPreset.SHOWCASE_RICH: (5, 15, 180),
ScenarioPreset.SHOWCASE_RICH: _SeedProfile(5, 15, 180),
# PRP-38 — SPARSE picker option exercises the data-shape edge case.
ScenarioPreset.SPARSE: (DEMO_SEED_STORES, DEMO_SEED_PRODUCTS, DEMO_SEED_SPAN_DAYS),
ScenarioPreset.SPARSE: _SeedProfile(DEMO_SEED_STORES, DEMO_SEED_PRODUCTS, DEMO_SEED_SPAN_DAYS),
# E2 (#391) — demo-scaled profiles for the remaining presets; the preset's
# character comes from SeederConfig.from_scenario, dims/window from this
# request (precedence contract: app/features/seeder/service.py). All
# windows stay >= 75 days so a later showcase_rich run with
# skip_seed=true clears the historical_backfill gate.
ScenarioPreset.RETAIL_STANDARD: _SeedProfile(5, 15, 180),
ScenarioPreset.HIGH_VARIANCE: _SeedProfile(5, 15, 180),
ScenarioPreset.STOCKOUT_HEAVY: _SeedProfile(5, 15, 180),
# Extra products for launch variety (the native preset seeds 100).
ScenarioPreset.NEW_LAUNCHES: _SeedProfile(5, 25, 180),
# Calendar-pinned: the preset's HolidayConfig spikes are fixed 2024 dates
# (app/shared/seeder/config.py:from_scenario) — a today-anchored window
# would never contain them. span_days is dead data when window is set
# (the pinned range is 92 days inclusive, delta 91).
ScenarioPreset.HOLIDAY_RUSH: _SeedProfile(
5, 15, 91, window=(date(2024, 10, 1), date(2024, 12, 31))
),
}


async def step_seed(ctx: DemoContext, client: _Client) -> StepResult:
"""Seed the active scenario (skipped when ``skip_seed`` is set)."""
if ctx.skip_seed:
return ("skip", "skip_seed=true (assuming a seeded database)", {})
stores, products, span_days = _SCENARIO_SEED_PROFILE.get(
profile = _SCENARIO_SEED_PROFILE.get(
ctx.scenario,
(DEMO_SEED_STORES, DEMO_SEED_PRODUCTS, DEMO_SEED_SPAN_DAYS),
_SeedProfile(DEMO_SEED_STORES, DEMO_SEED_PRODUCTS, DEMO_SEED_SPAN_DAYS),
)
seed_end = datetime.now(UTC).date()
seed_start = seed_end - timedelta(days=span_days)
stores, products = profile.stores, profile.products
if profile.window is not None:
seed_start, seed_end = profile.window
else:
seed_end = datetime.now(UTC).date()
seed_start = seed_end - timedelta(days=profile.span_days)
body = await client.request(
"seed",
"POST",
Expand Down
102 changes: 96 additions & 6 deletions app/features/demo/tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

from __future__ import annotations

from datetime import date, timedelta
from types import SimpleNamespace
from typing import Any, cast

import pytest
from fastapi import FastAPI

from app.features.demo import pipeline
Expand Down Expand Up @@ -675,11 +677,26 @@ def test_phase_table_showcase_rich_emits_24_steps_with_agents_hitl_and_ops_snaps
]


def test_phase_table_sparse_matches_demo_minimal_shape():
"""PRP-38 — SPARSE is offered in the picker but does not extend the pipeline."""
sparse_rows = pipeline._phase_table(ScenarioPreset.SPARSE)
@pytest.mark.parametrize(
"preset",
[
ScenarioPreset.RETAIL_STANDARD,
ScenarioPreset.HOLIDAY_RUSH,
ScenarioPreset.HIGH_VARIANCE,
ScenarioPreset.STOCKOUT_HEAVY,
ScenarioPreset.NEW_LAUNCHES,
ScenarioPreset.SPARSE,
],
)
def test_phase_table_non_showcase_presets_match_demo_minimal_shape(preset: ScenarioPreset):
"""PRP-38 / E2 (#391) — only SHOWCASE_RICH extends the pipeline.

Every other preset (incl. the 5 newly exposed by E2) runs the legacy
11-step flow; the picker offers them as data-shape variations only.
"""
rows = pipeline._phase_table(preset)
minimal_rows = pipeline._phase_table(ScenarioPreset.DEMO_MINIMAL)
assert [(p, s) for p, s, _ in sparse_rows] == [(p, s) for p, s, _ in minimal_rows]
assert [(p, s) for p, s, _ in rows] == [(p, s) for p, s, _ in minimal_rows]


def test_legacy_step_table_adapter_returns_11_pairs():
Expand Down Expand Up @@ -1001,8 +1018,6 @@ def test_parse_artifact_key_v2_artifacts_models_path():

def test_parse_artifact_key_rejects_unparseable():
"""PRP-40 — a malformed artifact_uri raises ValueError (not a silent miss)."""
import pytest

with pytest.raises(ValueError, match="Cannot parse artifact-key"):
pipeline._parse_artifact_key("not-a-model-uri.bin")

Expand Down Expand Up @@ -2259,3 +2274,78 @@ async def request(
assert final.event_type == "pipeline_complete"
assert final.status == "fail"
assert final.data["workspace_id"] == "ws-e1-test"


# =============================================================================
# E2 (#391) — per-preset demo seed profiles
# =============================================================================


def test_scenario_seed_profile_covers_every_preset():
"""E2 (#391) — every ScenarioPreset member has an explicit seed profile.

The ``.get`` fallback in step_seed stays (a future 9th member must not
crash), but no CURRENT member may silently fall back to demo_minimal —
the picker cards promise per-preset seed shapes.
"""
assert set(pipeline._SCENARIO_SEED_PROFILE) == set(ScenarioPreset)


def test_scenario_seed_profile_windows_clear_backfill_gate():
"""E2 (#391) — every profile window is >= 75 days so a later
showcase_rich run with skip_seed=true clears the historical_backfill gate."""
for preset, profile in pipeline._SCENARIO_SEED_PROFILE.items():
if profile.window is not None:
span = (profile.window[1] - profile.window[0]).days
else:
span = profile.span_days
assert span >= 75, f"{preset.value} window spans only {span} days"


async def test_step_seed_holiday_rush_posts_pinned_window():
"""E2 (#391) — holiday_rush MUST seed the calendar-pinned 2024 window.

The preset's HolidayConfig spikes are fixed 2024 dates; a today-anchored
window would never contain them and the preset silently degrades.
"""
ctx = pipeline.DemoContext(
seed=42, skip_seed=False, reset=False, scenario=ScenarioPreset.HOLIDAY_RUSH
)
client = _RecordingClient(
None,
responses={("POST", "/seeder/generate"): {"records_created": {"sales": 1}}},
)
status, detail, _data = await pipeline.step_seed(ctx, _as_client(client))
assert status == "pass"
body = client.calls[0][2]
assert body is not None
assert body["scenario"] == "holiday_rush"
assert body["start_date"] == "2024-10-01"
assert body["end_date"] == "2024-12-31"
assert body["stores"] == 5
assert body["products"] == 15
assert "holiday_rush: 5 stores x 15 products" in detail


async def test_step_seed_retail_standard_posts_demo_scaled_profile():
"""E2 (#391) — retail_standard seeds 5x15 over a 180-day today-anchored window."""
ctx = pipeline.DemoContext(
seed=42, skip_seed=False, reset=False, scenario=ScenarioPreset.RETAIL_STANDARD
)
client = _RecordingClient(
None,
responses={("POST", "/seeder/generate"): {"records_created": {"sales": 1}}},
)
status, _detail, _data = await pipeline.step_seed(ctx, _as_client(client))
assert status == "pass"
body = client.calls[0][2]
assert body is not None
assert body["scenario"] == "retail_standard"
assert body["stores"] == 5
assert body["products"] == 15
start = date.fromisoformat(body["start_date"])
end = date.fromisoformat(body["end_date"])
assert end - start == timedelta(days=180)
# sparsity stays 0.0 — the seeder override fires only when > 0, which is
# what preserves the sparse preset's 50%-missing character.
assert body["sparsity"] == 0.0
4 changes: 2 additions & 2 deletions docs/_base/API_CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ All endpoints serve JSON; error responses use `application/problem+json` (RFC 78
| agents | WS | `/agents/stream` | Token-by-token streaming + tool-call events |
| seeder | (see `app/features/seeder/routes.py`) | `/seeder/*` | Trigger scenarios, status, customization |
| seeder | POST | `/seeder/phase2-enrichment` | PRP-38 — run Phase 2 generators (lifecycle, replenishment, exogenous, returns) against the existing seeded data. `422 application/problem+json` on an empty database. |
| 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. **PRP-38** — body accepts an Optional `scenario: 'demo_minimal' \| 'showcase_rich' \| 'sparse'` field; default `'demo_minimal'` (back-compat). **E1 (#390)** — body accepts additive Optional `preservation: 'ephemeral' \| 'keep'` (default `'ephemeral'`, today's no-row behavior) and `workspace_name: str \| null` (pattern `^[a-z0-9][a-z0-9\-_]*$`, ≤100 chars); `workspace_name` without `preservation='keep'` → `422 application/problem+json`. `preservation='keep'` records the run as a `showcase_workspace` row; `DemoRunResult` gains an additive Optional `workspace_id: str \| null`. |
| 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. **PRP-38** — body accepts an Optional `scenario: 'demo_minimal' \| 'showcase_rich' \| 'sparse'` field; default `'demo_minimal'` (back-compat). **E1 (#390)** — body accepts additive Optional `preservation: 'ephemeral' \| 'keep'` (default `'ephemeral'`, today's no-row behavior) and `workspace_name: str \| null` (pattern `^[a-z0-9][a-z0-9\-_]*$`, ≤100 chars); `workspace_name` without `preservation='keep'` → `422 application/problem+json`. `preservation='keep'` records the run as a `showcase_workspace` row; `DemoRunResult` gains an additive Optional `workspace_id: str \| null`. **E2 (#391)** — `scenario` accepts all 8 `ScenarioPreset` values (`retail_standard` / `holiday_rush` / `high_variance` / `stockout_heavy` / `new_launches` / `sparse` / `demo_minimal` / `showcase_rich`); only `showcase_rich` changes the step table (24 rows), every other preset runs the legacy 11-row flow. |
| demo | WS | `/demo/stream` | Stream one `StepEvent` per pipeline step for the live Showcase page |
| config | GET | `/config/ai` | Effective AI-model config (agent LLM + RAG embeddings); API keys masked, never raw |
| config | PATCH | `/config/ai` | Persist + apply AI-model changes live (no restart). `409` if an embedding-dimension change would orphan indexed RAG chunks (resend with `force=true`) |
Expand All @@ -83,7 +83,7 @@ Verified against `app/features/agents/websocket.py` and `app/features/agents/sch

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, "scenario"?: "demo_minimal" | "showcase_rich" | "sparse", "preservation"?: "ephemeral" | "keep", "workspace_name"?: str}` — all fields optional (`DemoRunRequest` supplies defaults `seed=42`, `reset=false`, `skip_seed=true`, `scenario="demo_minimal"`, `preservation="ephemeral"`, `workspace_name=null`). E1 (#390) — `workspace_name` requires `preservation="keep"` (else one `error` event from validation); unknown start-frame keys remain ignored (forward/backward compat). The pipeline runs once, then the server closes.
- **Client → server (one start frame):** `{"seed": int, "reset": bool, "skip_seed": bool, "scenario"?: "demo_minimal" | "showcase_rich" | "sparse", "preservation"?: "ephemeral" | "keep", "workspace_name"?: str}` — all fields optional (`DemoRunRequest` supplies defaults `seed=42`, `reset=false`, `skip_seed=true`, `scenario="demo_minimal"`, `preservation="ephemeral"`, `workspace_name=null`). E1 (#390) — `workspace_name` requires `preservation="keep"` (else one `error` event from validation); unknown start-frame keys remain ignored (forward/backward compat). E2 (#391) — `scenario` accepts all 8 `ScenarioPreset` values (`retail_standard` / `holiday_rush` / `high_variance` / `stockout_heavy` / `new_launches` / `sparse` / `demo_minimal` / `showcase_rich`); only `showcase_rich` changes the step table (24 rows), every other preset runs the legacy 11-row flow. 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", "phase_name"?, "phase_index"?, "phase_total"?}`. PRP-38 — the three `phase_*` fields are Optional + Nullable so legacy clients that don't render phases keep working.
- **`event_type` values (Literal in `StepEvent`):**
- `step_start` — a step began; `status` is `null`.
Expand Down
Loading