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
613 changes: 613 additions & 0 deletions PRPs/PRP-showcase-workspace-E3-workspace-tagged-plans.md

Large diffs are not rendered by default.

26 changes: 24 additions & 2 deletions app/features/demo/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@ class DemoContext:
# E1 (#390) -- workspace persistence. Set only on preservation="keep" runs
# (and only when the row insert succeeded); None on ephemeral runs.
workspace_id: str | None = None
# E3 (#392) -- workspace label for plan tagging. Set alongside
# workspace_id in run_pipeline's keep-branch; None on ephemeral runs.
workspace_name: str | None = None


# =============================================================================
Expand Down Expand Up @@ -354,6 +357,21 @@ def _format_demo_artifact_key(run_id_raw: str) -> str:
return run_id_raw.replace("-", "")[:_DEMO_ARTIFACT_KEY_LEN]


def _showcase_plan_tags(ctx: DemoContext, kind: str) -> list[str]:
"""Build the tag list for a pipeline-saved scenario plan (E3, #392).

Always: ["showcase", <kind>, "source:showcase"]. When the run records a
workspace (ctx.workspace_id set -- preservation="keep" AND the E1 insert
succeeded), append "workspace:<label>" where label is the human
workspace_name or, on unnamed runs, the 32-hex workspace_id -- the label
is never empty. No workspace row -> no workspace tag (nothing to find).
"""
tags = ["showcase", kind, "source:showcase"]
if ctx.workspace_id is not None:
tags.append(f"workspace:{ctx.workspace_name or ctx.workspace_id}")
return tags


# PRP-40 — curated 5-file user-guide corpus indexed by the knowledge phase.
# The path_prefix RAG indexing additive contract scopes discovery to this
# subset (memory anchor: [[rag-runtime-config-and-corpus-state]] — keep the
Expand Down Expand Up @@ -1297,6 +1315,7 @@ async def step_scenario_simulate_and_save(ctx: DemoContext, client: _Client) ->
"end_date": horizon_end.isoformat(),
}
}
sent_tags = _showcase_plan_tags(ctx, "price")
plan_body = await client.request(
"scenario_simulate_and_save[save]",
"POST",
Expand All @@ -1306,7 +1325,7 @@ async def step_scenario_simulate_and_save(ctx: DemoContext, client: _Client) ->
"run_id": artifact_key,
"horizon": DEMO_HORIZON,
"assumptions": assumptions,
"tags": ["showcase", "price"],
"tags": sent_tags,
},
)
scenario_id_raw = plan_body.get("scenario_id")
Expand Down Expand Up @@ -1341,6 +1360,8 @@ async def step_scenario_simulate_and_save(ctx: DemoContext, client: _Client) ->
"revenue_delta": revenue_delta,
"winner_run_id": winner_run_id,
"artifact_key": artifact_key,
# E3 (#392) -- echo the tags sent so the UI/e2e can observe them.
"tags": sent_tags,
},
)

Expand Down Expand Up @@ -1368,7 +1389,7 @@ async def step_multi_plan_compare(ctx: DemoContext, client: _Client) -> StepResu
"run_id": ctx.scenario_artifact_key,
"horizon": DEMO_HORIZON,
"assumptions": {"holiday": {"dates": [holiday_day]}},
"tags": ["showcase", "holiday"],
"tags": _showcase_plan_tags(ctx, "holiday"),
},
)
except _StepError as exc:
Expand Down Expand Up @@ -2633,6 +2654,7 @@ async def run_pipeline(app: FastAPI, req: DemoRunRequest) -> AsyncIterator[StepE
# warn-and-continue: a DB failure returns None and the run proceeds.
if req.preservation == "keep":
ctx.workspace_id = await workspace.create_workspace(req)
ctx.workspace_name = req.workspace_name # E3 (#392) -- plan-tag label
wall_start = time.monotonic()
any_fail = False
# PRP-41 — buffer for intermediate events the HITL step emits via
Expand Down
107 changes: 106 additions & 1 deletion app/features/demo/tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,41 @@ def _make_showcase_ctx(scenario: ScenarioPreset = ScenarioPreset.SHOWCASE_RICH)
return ctx


def test__showcase_plan_tags_ephemeral():
"""E3 (#392) — no workspace row -> base triple only, no workspace tag."""
ctx = pipeline.DemoContext(seed=42, skip_seed=True, reset=False)
assert pipeline._showcase_plan_tags(ctx, "price") == [
"showcase",
"price",
"source:showcase",
]


def test__showcase_plan_tags_keep_named():
"""E3 (#392) — keep run with a name -> workspace:<name> appended."""
ctx = pipeline.DemoContext(seed=42, skip_seed=True, reset=False)
ctx.workspace_id = "a" * 32
ctx.workspace_name = "bf-demo"
assert pipeline._showcase_plan_tags(ctx, "holiday") == [
"showcase",
"holiday",
"source:showcase",
"workspace:bf-demo",
]


def test__showcase_plan_tags_keep_unnamed_falls_back_to_workspace_id():
"""E3 (#392) — keep run without a name -> workspace:<workspace_id>."""
ctx = pipeline.DemoContext(seed=42, skip_seed=True, reset=False)
ctx.workspace_id = "f" * 32
assert pipeline._showcase_plan_tags(ctx, "price") == [
"showcase",
"price",
"source:showcase",
f"workspace:{'f' * 32}",
]


async def test_scenario_simulate_and_save_happy_path():
"""PRP-40 + #324 — resolves the champion via ctx.winning_run_id -> run ->
artifact_key, saves the plan. Must NOT read the demo-production alias
Expand Down Expand Up @@ -1130,11 +1165,40 @@ async def test_scenario_simulate_and_save_happy_path():
assert body["name"] == "showcase-price-cut-10pct"
assert body["run_id"] == "abc123def456"
assert body["assumptions"]["price"]["change_pct"] == -0.10
assert body["tags"] == ["showcase", "price"]
assert body["tags"] == ["showcase", "price", "source:showcase"]
# E3 (#392) — the step data echoes the tags it sent.
assert data["tags"] == ["showcase", "price", "source:showcase"]
# #324 — the safer-promote-corrupted demo-production alias must NOT be read.
assert all(path != "/registry/aliases/demo-production" for _m, path, _b in client.calls)


async def test_scenario_simulate_and_save_keep_run_carries_workspace_tag():
"""E3 (#392) — a keep run (workspace_id set) stamps workspace:<name>."""
ctx = _make_showcase_ctx()
ctx.workspace_id = "a" * 32
ctx.workspace_name = "bf-demo"
client = _RecordingClient(
None,
responses={
("GET", "/registry/runs/demo-run-abc123def456"): {
"run_id": "demo-run-abc123def456",
"artifact_uri": "demo/seasonal_naive-model_abc123def456.joblib",
},
("POST", "/scenarios"): {
"scenario_id": "scn-001",
"comparison": {"method": "heuristic"},
},
},
)
status, _detail, data = await pipeline.step_scenario_simulate_and_save(ctx, _as_client(client))
assert status == "pass"
save_call = next(c for c in client.calls if c[0] == "POST" and c[1] == "/scenarios")
body = save_call[2]
assert body is not None
assert body["tags"] == ["showcase", "price", "source:showcase", "workspace:bf-demo"]
assert data["tags"] == ["showcase", "price", "source:showcase", "workspace:bf-demo"]


async def test_scenario_simulate_and_save_missing_champion_falls_back_to_alias():
"""PRP-40 + #324 — with no champion recorded, fall back to the alias; an
alias missing run_id -> FAIL with clear detail."""
Expand Down Expand Up @@ -1307,6 +1371,47 @@ async def test_multi_plan_compare_happy_path():
assert data["ranked_by"] == "revenue_delta"
assert len(data["ranked"]) == 2
assert "winner=showcase-holiday-uplift" in detail
# E3 (#392) — the holiday-plan save carries the ephemeral tag triple.
save_call = next(c for c in client.calls if c[0] == "POST" and c[1] == "/scenarios")
body = save_call[2]
assert body is not None
assert body["tags"] == ["showcase", "holiday", "source:showcase"]


async def test_multi_plan_compare_keep_run_carries_workspace_tag():
"""E3 (#392) — the workspace tag flows to plan #2 on keep runs."""
ctx = _make_showcase_ctx()
ctx.price_cut_scenario_id = "scn-price"
ctx.scenario_artifact_key = "abc123def456"
ctx.workspace_id = "b" * 32
ctx.workspace_name = "bf-demo"
client = _RecordingClient(
None,
responses={
("POST", "/scenarios"): {
"scenario_id": "scn-holiday",
"comparison": {"method": "heuristic"},
},
("POST", "/scenarios/compare"): {
"scenarios": [
{
"scenario_id": "scn-holiday",
"name": "showcase-holiday-uplift",
"units_delta": 18.5,
"revenue_delta": 220.0,
"coverage_verdict": "ok",
"rank": 1,
},
],
},
},
)
status, _detail, _data = await pipeline.step_multi_plan_compare(ctx, _as_client(client))
assert status == "pass"
save_call = next(c for c in client.calls if c[0] == "POST" and c[1] == "/scenarios")
body = save_call[2]
assert body is not None
assert body["tags"] == ["showcase", "holiday", "source:showcase", "workspace:bf-demo"]


async def test_multi_plan_compare_second_save_failure_emits_warn():
Expand Down
60 changes: 60 additions & 0 deletions app/features/scenarios/tests/test_routes_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,66 @@ async def test_crud_round_trip(self, client: AsyncClient, trained_model: str) ->
missing = await client.get(f"/scenarios/{scenario_id}")
assert missing.status_code == 404

async def test_list_scenarios_filters_by_workspace_tag(
self, client: AsyncClient, trained_model: str
) -> None:
"""E3 (#392) — plans tagged workspace:<label> are retrievable by tag.

Proves the umbrella #389 criterion verbatim: two plans saved with a
workspace tag come back from ``GET /scenarios?tags=workspace:<label>``
— and adding a second tag narrows by JSONB containment (AND).
The tag is unique per run so a shared/stale DB can't skew the counts.
"""
workspace_tag = f"workspace:e3-it-{uuid.uuid4().hex[:8]}"
created_ids: list[str] = []
try:
for name, kind in (
("showcase-price-cut-10pct", "price"),
("showcase-holiday-uplift", "holiday"),
):
create = await client.post(
"/scenarios",
json={
"name": name,
"run_id": trained_model,
"horizon": 14,
"assumptions": _PRICE_ASSUMPTION,
"tags": ["showcase", kind, "source:showcase", workspace_tag],
},
)
assert create.status_code == 201
created_ids.append(create.json()["scenario_id"])

# Control plan WITHOUT the workspace tag — must not match the filter.
control = await client.post(
"/scenarios",
json={
"name": "ephemeral-control",
"run_id": trained_model,
"horizon": 14,
"assumptions": _PRICE_ASSUMPTION,
"tags": ["showcase", "price", "source:showcase"],
},
)
assert control.status_code == 201
created_ids.append(control.json()["scenario_id"])

listed = await client.get("/scenarios", params={"tags": [workspace_tag]})
assert listed.status_code == 200
data = listed.json()
assert data["total"] == 2
assert {item["scenario_id"] for item in data["scenarios"]} == set(created_ids[:2])

# Containment is AND — a second tag narrows to the price plan only.
narrowed = await client.get("/scenarios", params={"tags": [workspace_tag, "price"]})
assert narrowed.status_code == 200
narrowed_data = narrowed.json()
assert narrowed_data["total"] == 1
assert narrowed_data["scenarios"][0]["scenario_id"] == created_ids[0]
finally:
for scenario_id in created_ids:
await client.delete(f"/scenarios/{scenario_id}")

async def test_list_scenarios_empty_is_200(self, client: AsyncClient) -> None:
"""GET /scenarios returns 200 + an empty list, never 404."""
response = await client.get("/scenarios")
Expand Down
1 change: 1 addition & 0 deletions docs/_base/API_CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Drives the end-to-end demo pipeline for the dashboard Showcase page. Verified ag
- 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.
- PRP-38 — `scenario="showcase_rich"` extends the data phase with `phase2_enrichment` + `historical_backfill` steps and the modeling phase with `v2_train` (one V2 `prophet_like` run). Phase ids are `data` / `modeling` / `decision` / `verify` / `agent` / `cleanup` (6 phases).
- PRP-40 — `scenario="showcase_rich"` ALSO adds two phases inserted BEFORE `verify`: `planning` (2 steps — `scenario_simulate_and_save`, `multi_plan_compare`) and `knowledge` (3 steps — `embedding_provider_probe`, `rag_index_subset`, `rag_retrieve_probe`). Total step count: 19 for `showcase_rich`, 11 for `demo_minimal` and `sparse`. Phase ids on `showcase_rich` are `data` / `modeling` / `decision` / `planning` / `knowledge` / `verify` / `agent` / `cleanup` (8 phases). The knowledge steps SKIP gracefully when the embedding provider is unreachable; the pipeline still goes green.
- E3 (#392) — the planning-phase steps tag the plans they save: pipeline-saved plans now carry `source:showcase` (alongside the legacy `showcase` + `price`/`holiday` tags), and on `preservation="keep"` runs additionally `workspace:<workspace_name|workspace_id>` — retrievable via `GET /scenarios?tags=workspace:<label>` (JSONB containment, all listed tags must match). The `scenario_simulate_and_save` step's `data` additively echoes the `tags` list it sent.

## Async Events / Queues

Expand Down
25 changes: 24 additions & 1 deletion frontend/src/lib/url-params.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseEnumParam, parseIdParam, parsePageParam } from './url-params'
import { parseEnumParam, parseIdParam, parsePageParam, parseTagsParam } from './url-params'

describe('parsePageParam', () => {
it('returns the integer for a valid positive page', () => {
Expand Down Expand Up @@ -46,3 +46,26 @@ describe('parseEnumParam', () => {
expect(parseEnumParam('sideways', allowed)).toBeUndefined()
})
})

describe('parseTagsParam', () => {
it('returns an empty list for no params', () => {
expect(parseTagsParam([])).toEqual([])
})

it('passes through namespaced tags untouched', () => {
expect(parseTagsParam(['workspace:bf-demo'])).toEqual(['workspace:bf-demo'])
})

it('trims values and drops empty or whitespace-only entries', () => {
expect(parseTagsParam([' showcase ', '', ' '])).toEqual(['showcase'])
})

it('dedupes repeated tags', () => {
expect(parseTagsParam(['price', 'price', ' price '])).toEqual(['price'])
})

it('caps the list at 20 entries', () => {
const values = Array.from({ length: 50 }, (_, i) => `tag-${i}`)
expect(parseTagsParam(values)).toHaveLength(20)
})
})
17 changes: 17 additions & 0 deletions frontend/src/lib/url-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,20 @@ export function parseEnumParam<T extends string>(
? (value as T)
: undefined
}

/**
* Parse repeated `tags` query params into a clean filter list.
*
* Trims each value, drops empties, dedupes, and caps at 20 entries
* (matches the backend CreateScenarioRequest.tags item cap) so a
* hand-edited URL degrades to a sane query instead of a 50-param request.
*/
export function parseTagsParam(values: string[]): string[] {
const seen = new Set<string>()
for (const value of values) {
const tag = value.trim()
if (tag) seen.add(tag)
if (seen.size >= 20) break
}
return [...seen]
}
Loading