diff --git a/PRPs/PRP-19-knowledge-and-agent-guide-pages.md b/PRPs/PRP-19-knowledge-and-agent-guide-pages.md new file mode 100644 index 00000000..2991a0df --- /dev/null +++ b/PRPs/PRP-19-knowledge-and-agent-guide-pages.md @@ -0,0 +1,952 @@ +name: "PRP-19 — Knowledge page + Agent Guide page (in-product self-documentation)" +description: | + Add two new React pages to the ForecastLabAI dashboard, frontend-led and + fully additive: + + 1. **Knowledge** (`/knowledge`) — presents, in detail, *what ForecastLabAI + currently knows*: the RAG knowledge base (indexed sources + a live semantic + search box) plus a summary of the live system state the agents can query + (seeded data, registered model runs, deployment aliases). + 2. **Agent Guide** (`/guide`) — explains, in detail, *how to use the Chat + agents*: the two agent types, their tools, the human-in-the-loop approval + flow, session limits, the streaming protocol, and copy-paste example prompts. + + Frontend-led, with one small additive backend change: the existing + `GET /config/ai` response gains read-only agent-limit fields so the Guide + shows session limits live. No new backend slice, no migration, no new env var. + Every other endpoint these pages consume already exists with a frontend hook. + +## Purpose +Close the in-product self-documentation gap. Today a dashboard visitor can open +`/chat` and talk to an agent, but nothing in the UI tells them (a) what the +RAG assistant actually has indexed to answer from, or (b) how the agents work, +what they can do, or how the approval gate behaves. The two pages turn implicit +system knowledge into a visible, browsable surface — a natural onboarding pair: +**Knowledge** = "what it knows" → **Agent Guide** = "how to ask it". + +> **PRP numbering:** `PRP-16` is reserved (Phase-2 LightGBM, per PRP-15). +> `PRP-17` (Showcase) and `PRP-18` (AI Model console) are used. This is `PRP-19`. + +## Core Principles +1. **Context is King** — every endpoint shape, hook name, schema field, and + pattern referenced below is linked to a real source file + line. +2. **Reuse existing patterns** — both pages are lazy routes registered exactly + like `Showcase` (PRP-17); data comes through existing TanStack Query hooks + (`useRagSources`, `useSeederStatus`, `useAIConfig`, …); UI uses existing + shadcn primitives (`Card`, `Badge`, `Input`, `Tabs`, `Button`). No new + streaming primitive, no new fetch wrapper. +3. **Additive only** — no new backend slice, no Alembic migration, no new + `.env` var. The one backend change is additive: read-only agent-limit fields + appended to the existing `AIModelConfig` (`GET /config/ai`) response. Plus + one new hook (`useRetrieve`), three new TS interfaces, two new pages, one + pure-helper module. +4. **Read-only, no duplication** — the Knowledge page is *presentational*. It + does NOT duplicate Admin's RAG management (index / delete) — those stay in + `frontend/src/pages/admin.tsx`. It adds the semantic-search exploration that + Admin lacks. +5. **Strict gates honored** — `pnpm tsc --noEmit` + `pnpm lint` + `pnpm test` + green; AND because the `config` slice `.py` files change, the repo-wide + `ruff`/`mypy`/`pyright`/`pytest` CI jobs must be run and stay green — the + `/config/ai` change ships with `config` slice tests. +6. **UI through skills** — pages built via `frontend-design` + `shadcn-ui` and + dogfooded via `webapp-testing` / `agent-browser` per `.claude/rules/ui-design.md`. + A green type-check is NOT proof the UI works. + +--- + +## Goal +Two new nav items route to two new pages. + +**`/knowledge` — Knowledge** +- A **Knowledge Base** section: `total_sources` / `total_chunks` summary, a + read-only list of every indexed RAG source (path, type badge, chunk count, + indexed date), and a **semantic search box** that POSTs to `/rag/retrieve` + and renders the matching chunks with relevance scores + source citations. +- A **Live System State** section: the seeded-data summary (stores / products / + sales / date range), the count of registered model runs, and the deployment + aliases — i.e. what the *experiment* agent can query through its tools. +- A short explainer tying it together: "the RAG assistant answers from the + Knowledge Base; the experiment agent acts on the Live System State." + +**`/guide` — Agent Guide** +- Describes the **two agents** (`rag_assistant`, `experiment`), each with its + purpose, its exact tool names, and what it returns. +- Walks through **how a chat session works**: pick agent → Start Session → + send a message → streamed text + tool-call chips → approval prompts → + New Session. +- Explains the **human-in-the-loop approval gate** (`create_alias`, + `archive_run`). +- Lists **session limits** (token budget, tool-call cap, timeout, TTL, retries) + — rendered **live** from `/config/ai`, which is extended to return them. +- Gives **copy-paste example prompts** per agent. +- Surfaces the **currently configured agent model** (live, from `/config/ai`) + and links to Chat and Admin → AI Models. +- Reachable both from a flat top-level nav item AND from a help link on the + Chat page. + +## Why +- **Portfolio identity.** `.claude/rules/product-vision.md` principle 1 — + "portfolio-grade, end-to-end … every phase ships working code". The agentic + layer (PRP-10) and RAG layer (PRP-9) are fully built but invisible as + *capabilities* — a reviewer has to read code to learn what the agents do. +- **Onboarding.** A first-time user opening `/chat` has no idea what to ask the + RAG assistant (it can only answer from indexed docs) or that the experiment + agent can run real backtests. These two pages remove that guesswork. +- **Low-cost surface.** Almost everything needed already exists server-side; + the only backend work is a small additive `/config/ai` extension. This is + high-value-per-line work: mostly composition of shipped endpoints into two + polished pages. + +## What +Frontend-led. Two lazy-loaded pages mirroring the `Showcase` registration +(PRP-17), two new `ROUTES` entries, two `NAV_ITEMS` entries, a help link to +`/guide` on the Chat page, one new mutation hook (`useRetrieve` for +`POST /rag/retrieve`), three new TS interfaces, and one pure-helper module with +a vitest. Plus one additive backend change: the `config` slice's `AIModelConfig` +schema + `get_effective_config` service gain read-only agent-limit fields +(`agent_max_tool_calls`, `agent_timeout_seconds`, `agent_retry_attempts`, +`agent_session_ttl_minutes`, `agent_require_approval`) so the Guide's limits are +live; shipped with `config` slice tests. No migration, no new env var. + +### Success Criteria +- [ ] `GET /knowledge` in the running SPA renders the Knowledge Base section + (source list + summary) and the Live System State section. +- [ ] The semantic search box on `/knowledge` POSTs `/rag/retrieve` and renders + `ChunkResult`s with a relevance score; an empty query is rejected client-side; + a `502` (no embedding provider) shows a graceful "search unavailable" state + while the source list still renders. +- [ ] An empty knowledge base shows a friendly empty state pointing at + Admin → RAG Sources (not a crash, not a blank card). +- [ ] `GET /guide` renders both agent cards with the **exact** tool names from + the agent definitions, the approval-gate explainer, the example prompts, + and the session limits + agent model rendered **live** from `/config/ai`. +- [ ] Both pages appear in the top nav (desktop + mobile sheet) and in `App.tsx` + as lazy ``s wrapped in ``; the Chat page links to `/guide`. +- [ ] `cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run` all clean. +- [ ] `frontend/src/lib/knowledge-utils.test.ts` passes (pure-helper coverage). +- [ ] `GET /config/ai` returns the five additive agent-limit fields; the `config` + slice tests (`test_schemas.py`/`test_service.py`/`test_routes.py`) cover + them and `ruff`/`mypy`/`pyright`/`pytest` stay green. +- [ ] Only the `config` slice changes server-side; no Alembic migration; no + `.env`/`.env.example` var. +- [ ] Admin's RAG index/delete management is untouched and NOT duplicated. +- [ ] Both pages dogfooded in a real browser (screenshot captured). + +--- + +## All Needed Context + +### Documentation & References +```yaml +- url: https://tanstack.com/query/latest/docs/framework/react/guides/queries + why: useQuery (GET) vs useMutation (POST) — the Knowledge search is a mutation + critical: | + GET data → useQuery({ queryKey, queryFn }). POST actions → useMutation({ + mutationFn }). The repo's hooks follow this exactly (see use-rag-sources.ts). + Semantic search is a POST → a useMutation, NOT a useQuery. + +- url: https://reactrouter.com/en/main/route/lazy + why: react-router v6 route registration; the repo lazy-loads every page + critical: Mirror App.tsx — `lazy(() => import('@/pages/x'))` + ``. + +- file: PRPs/PRP-17-demo-showcase-page.md + why: The most recent "add a new page" PRP. Its frontend tasks (constants + + App.tsx + lazy route + nav entry) are the exact pattern to copy. + critical: This PRP follows PRP-17's frontend half precisely; the only deltas + are "two pages instead of one" and "no backend slice". + +- file: frontend/src/App.tsx + why: Lazy-route registration. Add `KnowledgePage` and `GuidePage` lazily and a + `` / `` exactly + like the existing `ShowcasePage` block (lines 12, 42-49). + critical: Pages are `lazy(() => import(...))`; each route element is wrapped in + `}>`. + +- file: frontend/src/lib/constants.ts + why: ROUTES + NAV_ITEMS. Add `KNOWLEDGE: '/knowledge'` and `GUIDE: '/guide'` + to ROUTES, and two NAV_ITEMS entries. + critical: | + NAV_ITEMS is `as const`. Knowledge and Agent Guide are flat top-level items + (not grouped). Place `Knowledge` after `Visualize` and `Agent Guide` after + `Chat` so the nav reads: Dashboard · Showcase · Explorer · Visualize · + Knowledge · Chat · Agent Guide · Admin (a "know it → chat → how to chat" + cluster). No new WS URL needed. + +- file: frontend/src/pages/admin.tsx + why: THE reference page. `RagSourcesPanel` (lines 116-253) already lists + `/rag/sources` data — copy its source-row markup. `SeederPanel`'s `StatCard` + (lines 769-785) is the data-summary tile to reuse on the Knowledge page. + critical: | + - admin.tsx keeps all sub-components in ONE file (RagSourcesPanel, + AliasesPanel, SeederPanel, StatCard helpers). Mirror that: knowledge.tsx + and guide.tsx each hold their own internal function components — do NOT + create a components/knowledge/ directory. + - The Knowledge page is READ-ONLY. Copy the source LIST markup but DROP the + "Index Document" dialog and the per-row delete AlertDialog — those are + management actions that stay in Admin. + - Reuse loading/error states: `` and + ``. + +- file: frontend/src/hooks/use-rag-sources.ts + why: Existing RAG hooks. `useRagSources()` (GET /rag/sources) is reused as-is. + ADD a new `useRetrieve()` mutation hook here for POST /rag/retrieve. + critical: | + useRagSources already returns SourceListResponse. The new useRetrieve wraps + `api('/rag/retrieve', { method: 'POST', body })`. It is a + useMutation (no cache invalidation needed — search is ephemeral). + +- file: frontend/src/lib/api.ts + why: The `api()` fetch wrapper + `ApiError` (carries the RFC 7807 + ProblemDetail) + `getErrorMessage()`. + critical: | + `api('/rag/retrieve', { method: 'POST', body: {...} })` JSON-encodes `body`. + On non-2xx it throws `ApiError` with `.status` and `.detail`. The Knowledge + search must catch this: `502` → "search unavailable, configure an embedding + provider"; other → `getErrorMessage(err)`. + +- file: app/features/rag/routes.py + why: The RAG endpoints the Knowledge page consumes. + critical: | + - GET /rag/sources → SourceListResponse (no embeddings needed — always works) + - POST /rag/retrieve → RetrieveResponse (needs an embedding provider; + returns 502 application/problem+json if embedding generation fails — see + routes.py:214-224). The page must degrade gracefully on 502. + +- file: app/features/rag/schemas.py + why: AUTHORITATIVE wire shapes. Mirror these field-for-field into types/api.ts. + critical: | + RetrieveRequest (model_config = ConfigDict(extra="forbid") — send NOTHING + extra): query:str(1..2000), top_k:int(1..50, default 5), + similarity_threshold:float|null(0..1, default from settings — OMIT to use + the server default), filters:dict|null. + ChunkResult: chunk_id, source_id, source_path, source_type, content, + relevance_score:float(0..1), metadata:dict|null. + RetrieveResponse: results:ChunkResult[], query_embedding_time_ms:float, + search_time_ms:float, total_chunks_searched:int. + SourceResponse (already typed as `RagSource` in types/api.ts:157): source_id, + source_type, source_path, chunk_count, content_hash, indexed_at, metadata. + +- file: frontend/src/types/api.ts + why: TS type surface. `RagSource` + `SourceListResponse` (lines 157-171), + `AgentType` (line 199), `AIModelConfig`/`ProviderHealth` (lines 360-415) + already exist. ADD `RetrieveRequest`, `ChunkResult`, `RetrieveResponse` + near the `// === RAG ===` block (line 156). + critical: snake_case field names on the wire — match the Pydantic models exactly. + +- file: app/features/agents/agents/experiment.py + why: The experiment agent's EXACT tool names + behavior for the Guide page. + critical: | + Tools (use these EXACT names on the Guide page): tool_list_runs, + tool_get_run, tool_run_backtest, tool_compare_backtest_results, + tool_compare_runs, tool_create_alias (REQUIRES APPROVAL), + tool_archive_run (REQUIRES APPROVAL). The system prompt (lines 45-72) + describes the workflow — paraphrase it, do not invent capabilities. + +- file: app/features/agents/agents/rag_assistant.py + why: The RAG assistant's EXACT tool names + behavior for the Guide page. + critical: | + Tools: tool_retrieve_context, tool_format_citations, tool_check_evidence, + tool_list_sources. It answers ONLY from retrieved evidence, cites + source_path:chunk_id, and says "I don't have enough information" when the + knowledge base lacks coverage (system prompt lines 38-67). + +- file: app/features/agents/agents/base.py + why: Shared agent behavior + the approval helper for the Guide page. + critical: | + `requires_approval(name)` checks `settings.agent_require_approval`. + SYSTEM_PROMPT_HEADER / SAFETY_INSTRUCTIONS (lines 269-294) state the safety + contract — the Guide's "approval" section paraphrases SAFETY_INSTRUCTIONS. + +- file: app/core/config.py + why: The agent session limits to state on the Guide page (lines 147-172). + critical: | + Defaults to quote on the Guide (label them "default"): agent_max_tokens=4096, + agent_max_tool_calls=10, agent_timeout_seconds=120, agent_retry_attempts=3, + agent_require_approval=["create_alias","archive_run"], + agent_session_ttl_minutes=120, agent_default_model="anthropic:claude-sonnet-4-5". + The LIVE model is shown via /config/ai (useAIConfig) — the static numbers + above are config defaults; phrase them as "default" since an operator can + change them in Admin → AI Models. + +- file: frontend/src/hooks/use-config.ts + why: `useAIConfig()` (GET /config/ai) — the Guide page uses it to show the + currently-configured agent model AND the (now live) session limits. + critical: Reuse the hook as-is; do NOT add a config hook. The hook's response + type `AIModelConfig` in types/api.ts gains the five new agent-limit fields. + +- file: app/features/config/schemas.py + why: `AIModelConfig` (GET /config/ai response, lines 65-83). Extend it with + read-only agent-limit fields so the Guide renders limits live. + critical: | + ADD to AIModelConfig (NOT to AIModelConfigUpdate — these stay read-only, + not operator-settable here): agent_max_tool_calls:int, + agent_timeout_seconds:int, agent_retry_attempts:int, + agent_session_ttl_minutes:int, agent_require_approval:list[str]. + agent_max_tokens is ALREADY present — do not re-add it. + +- file: app/features/config/service.py + why: `get_effective_config` (line 129) builds AIModelConfig from the Settings + singleton. Populate the five new fields from `settings.*`. + critical: The new fields are sourced from Settings exactly like the existing + agent_* fields (app/core/config.py lines 147-172) — pure read, no DB, no + migration. Mirror the existing `agent_max_tokens=settings.agent_max_tokens` + line. + +- file: app/features/config/tests/ + why: test_schemas.py / test_service.py / test_routes.py — extend each so the + five new fields are covered (construction, service mapping from Settings, + and the GET /config/ai route response). Required by test-requirements.md. + +- file: frontend/src/pages/chat.tsx + why: The actual chat flow the Guide page describes — keep the Guide accurate + to it: pick agent in a Select → "Start Session" → type → stream → approval + prompt → "New Session". + critical: | + Client → server WS frame is `{ session_id, message }`. Server → client + events: text_delta, tool_call_start, tool_call_end, approval_required, + complete, error (see types/api.ts:185-197 AgentEventType). Describe these + accurately; do not invent event names. + +- file: docs/_base/API_CONTRACTS.md + why: Cross-check the /rag and /agents endpoint contracts + WS event list. + critical: The "WebSocket Events (/agents/stream)" section is the source of + truth for the Guide's streaming description. + +- file: frontend/src/hooks/use-demo-pipeline.test.ts + why: The vitest pattern — test PURE exported helpers (applyEvent, + createInitialSteps), not the React component. `knowledge-utils.test.ts` + mirrors this. + +- file: frontend/src/lib/date-utils.ts & frontend/src/lib/status-utils.ts + why: Precedent for a `lib/*.ts` pure-helper module. `knowledge-utils.ts` joins + them — pure functions, no React, easy to unit-test. + +- file: frontend/src/hooks/use-runs.ts & frontend/src/hooks/use-seeder.ts + why: The Live System State section reuses these. use-seeder.ts exports + `useSeederStatus()` (GET /seeder/status → SeederStatus). use-runs.ts exports + the runs + aliases hooks used by admin.tsx (`useAliases`) and + explorer/runs.tsx. + critical: VERIFY the exact export names in use-runs.ts before wiring — reuse + whatever it exports for runs (paginated) + aliases; do not add new hooks. + +- file: .claude/rules/ui-design.md + why: UI built/dogfooded via frontend-design + shadcn-ui + webapp-testing. +- file: .claude/rules/output-formatting.md + why: If the Guide uses status glyphs, reuse the ✅/⚠️/⏭️ vocabulary. +- file: .claude/rules/test-requirements.md + why: New TS component owning non-trivial state SHOULD have a vitest — satisfied + by extracting pure helpers into knowledge-utils.ts and testing them. +- file: .claude/rules/commit-format.md + why: `type(scope): description (#issue)`; scope `ui` for frontend/**, `docs` + for README/docs. Open the tracking issue FIRST. +- file: .claude/rules/branch-naming.md + why: `/` off dev → `feat/knowledge-and-guide-pages`. +``` + +### Current Codebase tree (relevant) +```bash +frontend/src/ +├── App.tsx # MOD — add /knowledge + /guide lazy routes +├── lib/ +│ ├── api.ts # reuse api() + ApiError + getErrorMessage +│ ├── constants.ts # MOD — ROUTES + NAV_ITEMS +│ ├── date-utils.ts # precedent: pure lib helper module +│ ├── status-utils.ts # precedent: pure lib helper module +│ └── knowledge-utils.ts # NEW — pure helpers for the Knowledge page +├── types/api.ts # MOD — +RetrieveRequest, ChunkResult, RetrieveResponse +├── hooks/ +│ ├── use-rag-sources.ts # MOD — +useRetrieve mutation +│ ├── use-seeder.ts # reuse useSeederStatus +│ ├── use-runs.ts # reuse runs + aliases hooks +│ └── use-config.ts # reuse useAIConfig +├── pages/ +│ ├── admin.tsx # reference (RagSourcesPanel, StatCard) — UNCHANGED +│ ├── chat.tsx # reference for the Guide's accuracy — UNCHANGED +│ ├── showcase.tsx # reference page registration (PRP-17) +│ ├── knowledge.tsx # NEW — the Knowledge page +│ └── guide.tsx # NEW — the Agent Guide page +└── components/ + ├── ui/ # reuse Card, Badge, Input, Button, Tabs, Separator + └── common/ # reuse LoadingState, ErrorDisplay +``` + +### Desired Codebase tree (files added / changed) +```bash +NEW frontend/src/pages/knowledge.tsx # Knowledge page (KB + live state) +NEW frontend/src/pages/guide.tsx # Agent Guide page +NEW frontend/src/lib/knowledge-utils.ts # pure helpers (testable, no React) +NEW frontend/src/lib/knowledge-utils.test.ts # vitest — pure-helper coverage +MOD frontend/src/types/api.ts # +RetrieveRequest/ChunkResult/RetrieveResponse; +5 AIModelConfig fields +MOD frontend/src/hooks/use-rag-sources.ts # +useRetrieve mutation hook +MOD frontend/src/lib/constants.ts # +KNOWLEDGE/GUIDE routes, +2 NAV_ITEMS +MOD frontend/src/App.tsx # +2 lazy imports, +2 s +MOD frontend/src/pages/chat.tsx # + help link to /guide +MOD app/features/config/schemas.py # +5 read-only agent-limit fields on AIModelConfig +MOD app/features/config/service.py # populate the 5 fields in get_effective_config +MOD app/features/config/tests/test_schemas.py # cover the new fields +MOD app/features/config/tests/test_service.py # cover get_effective_config mapping +MOD app/features/config/tests/test_routes.py # cover GET /config/ai response +MOD README.md # mention the two new pages in the feature list +MOD docs/_base/REPO_MAP_INDEX.md # +rows for knowledge.tsx + guide.tsx +KEEP frontend/src/pages/admin.tsx # UNCHANGED — management stays here +KEEP all other app/** (backend) # UNCHANGED — only the config slice changes +``` + +### Known Gotchas & Library Quirks +```typescript +// CRITICAL: FRONTEND-LED PRP with ONE additive backend change — the config +// slice only (schemas.py + service.py + tests). No new slice, no Alembic +// migration, no .env var. Because .py files DO change, the repo-wide +// ruff/mypy/pyright/pytest gates genuinely apply — run them (see Validation +// Level 4), do not assume they pass trivially. The three pnpm gates still +// gate the frontend half. + +// CRITICAL: /rag/retrieve needs an embedding provider (OpenAI key or Ollama). +// With none configured it returns 502 application/problem+json. The Knowledge +// page MUST degrade gracefully: the source LIST (GET /rag/sources) needs NO +// embeddings and always works; only the SEARCH box can 502 — catch ApiError, +// show "Semantic search unavailable — configure an embedding provider in +// Admin → AI Models", keep the rest of the page functional. + +// CRITICAL: RetrieveRequest is ConfigDict(extra="forbid"). Send ONLY +// { query, top_k } (+ optional similarity_threshold/filters). Any stray field +// → 422. OMIT similarity_threshold entirely to use the server-side default. + +// CRITICAL: search is a useMutation, NOT a useQuery. The query string is +// user-typed and submitted on click/Enter — it is an imperative action with +// ephemeral results, exactly the useMutation shape. (useQuery would re-fire +// on every keystroke / refetch.) + +// CRITICAL: the Knowledge page is READ-ONLY. Do NOT add index/delete actions — +// they already live in Admin → RAG Sources (admin.tsx RagSourcesPanel). The +// Knowledge page COPIES the source-row display markup but DROPS the dialog +// and the delete AlertDialog. Duplicating management UI is the anti-pattern. + +// CRITICAL: the Guide page must use the EXACT agent tool names from the agent +// definitions (experiment.py / rag_assistant.py). Do not paraphrase tool +// names. A user copying "tool_run_backtest" into chat must match reality. + +// GOTCHA: agent limit numbers (4096 tokens, 10 tool calls, 120s, TTL 120 min) +// are config DEFAULTS — an operator can change them. Label them "default" on +// the Guide. The LIVE agent model comes from /config/ai (useAIConfig); render +// that dynamically, not a hardcoded model string. + +// GOTCHA: empty knowledge base — a fresh DB has zero RAG sources. The Knowledge +// Base section must show a friendly empty state ("No documents indexed yet — +// add some in Admin → RAG Sources, or run the RAG seeder scenario"), not a +// blank card and not a crash. + +// GOTCHA: NAV_ITEMS is declared `as const`. Adding two flat entries is fine; +// keep the object shape `{ label, href }` identical to the existing flat +// items (Dashboard/Showcase/Chat/Admin) so top-nav.tsx's `'items' in item` +// discriminator still works. + +// GOTCHA: react-router lazy route — the page file MUST `export default` the +// component (App.tsx does `lazy(() => import('@/pages/knowledge'))`). Named +// helper exports from the SAME file are allowed, but the Knowledge page's +// pure helpers live in lib/knowledge-utils.ts so they are import-cheap to +// unit-test (mirrors use-demo-pipeline.ts exporting applyEvent et al.). + +// GOTCHA: new frontend files use LF line endings (the repo's CRLF note in +// memory applies to .py files only). Match the surrounding .tsx files — they +// are LF. eslint.config.js + tsc are the enforcers. + +// 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 +interpretation: + decision: "ForecastLab's current knowledge" = the RAG knowledge base (what the + rag_assistant answers from) PLUS the live system state (what the experiment + agent acts on: seeded data, runs, aliases). The Knowledge page shows both. + why: The agentic layer has two agents with two distinct knowledge surfaces. + Showing only the RAG corpus would under-represent "what the system knows" + and would also thinly duplicate Admin's RAG tab. Showing both makes the page + a genuine "knowledge dashboard" and a true counterpart to the Agent Guide. + status: confirmed — Resolved Decision 1 keeps both the RAG corpus and the + Live System State section; not scoped down to RAG-only. +minimal-backend: + decision: no NEW backend slice and no /knowledge or /guide API. The only + server-side change is additive: read-only agent-limit fields on the existing + AIModelConfig (GET /config/ai) response. + why: Every page datum except the live session limits is already served + (/rag/sources, /rag/retrieve, /seeder/status, /registry/runs, + /registry/aliases, /config/ai). The maintainer chose live limits over static + text (Resolved Decision 3), and /config/ai is the natural, already-existing + home for them — extending it beats a new endpoint. +guide-content-plus-live-config: + decision: the Guide page is hand-authored content + live /config/ai data (the + configured model AND the session limits). + why: It is documentation; the prose (agents, tools, approval flow, example + prompts) is stable. The two things that legitimately drift — the model and + the limits — are both fetched live from /config/ai. +search-is-mutation: + decision: semantic search uses useMutation, not useQuery. + why: it is a user-initiated imperative action with throwaway results. +``` + +--- + +## Implementation Blueprint + +### Data models / types (`frontend/src/types/api.ts`, add near line 156 `// === RAG ===`) +```typescript +// Append to the existing RAG block — mirror app/features/rag/schemas.py exactly. + +export interface RetrieveRequest { + query: string + top_k?: number // 1..50, server default 5 + similarity_threshold?: number // 0..1 — OMIT to use the server default + filters?: Record | null +} + +export interface ChunkResult { + chunk_id: string + source_id: string + source_path: string + source_type: string + content: string + relevance_score: number // 0..1 + metadata: Record | null +} + +export interface RetrieveResponse { + results: ChunkResult[] + query_embedding_time_ms: number + search_time_ms: number + total_chunks_searched: number +} +``` + +### Backend change (`app/features/config/schemas.py` + `service.py`) +```python +# schemas.py — append to AIModelConfig (the GET /config/ai response model), +# NOT to AIModelConfigUpdate (these are read-only, not operator-settable here): +agent_max_tool_calls: int = Field(description="Per-session tool-call cap") +agent_timeout_seconds: int = Field(description="Per-run agent timeout (seconds)") +agent_retry_attempts: int = Field(description="Agent retry attempts on failure") +agent_session_ttl_minutes: int = Field(description="Session time-to-live (minutes)") +agent_require_approval: list[str] = Field( + description="Tool names gated by human-in-the-loop approval" +) +# agent_max_tokens is ALREADY on AIModelConfig — do not re-add it. + +# service.py — get_effective_config(): populate each from the Settings singleton, +# mirroring the existing `agent_max_tokens=settings.agent_max_tokens` line. +``` + +### Frontend type extension (`frontend/src/types/api.ts`, existing `AIModelConfig`) +```typescript +// EXTEND the existing AIModelConfig interface (~line 360) with the five fields +// the backend now returns — snake_case, matching the Pydantic model: +// agent_max_tool_calls: number +// agent_timeout_seconds: number +// agent_retry_attempts: number +// agent_session_ttl_minutes: number +// agent_require_approval: string[] +// agent_max_tokens already exists on AIModelConfig — do not duplicate it. +``` + +### Hook (`frontend/src/hooks/use-rag-sources.ts`, append) +```typescript +// Pseudocode — mirror the existing useIndexDocument mutation shape. +import type { RetrieveRequest, RetrieveResponse } from '@/types/api' + +export function useRetrieve() { + return useMutation({ + mutationFn: (body: RetrieveRequest) => + api('/rag/retrieve', { method: 'POST', body }), + // no onSuccess cache invalidation — search results are ephemeral + }) +} +``` + +### Pure helpers (`frontend/src/lib/knowledge-utils.ts`) +```typescript +// Pure, React-free, unit-testable. Exact helper set is implementer's choice; +// at minimum provide these two so knowledge-utils.test.ts has real coverage: + +import type { RagSource, ChunkResult } from '@/types/api' + +/** Relevance score (0..1) → a display percentage string, e.g. 0.873 -> "87%". */ +export function formatRelevance(score: number): string { /* clamp 0..1, round */ } + +/** Group indexed sources by source_type for the "by type" summary. */ +export function groupSourcesByType(sources: RagSource[]): Record { /* ... */ } + +/** Optional: short, single-line excerpt of a chunk for the result card. */ +export function chunkExcerpt(chunk: ChunkResult, maxChars?: number): string { /* ... */ } +``` + +### Knowledge page (`frontend/src/pages/knowledge.tsx`) +```text +export default function KnowledgePage() +Layout (build with frontend-design + shadcn-ui; mirror admin.tsx structure): + +- Header:

Knowledge

+ one sentence: "Everything ForecastLabAI can + currently draw on — the RAG knowledge base its assistant answers from, and the + live data its experiment agent acts on." + +- SECTION 1 — Knowledge Base (Card): + * useRagSources() → SourceListResponse. + * CardDescription: "{total_sources} sources • {total_chunks} chunks". + * Source list: read-only rows (path, {source_type}, + "{chunk_count} chunks", "Indexed {date}"). COPY the row markup from + admin.tsx RagSourcesPanel lines 209-243 MINUS the delete AlertDialog. + * Empty state when sources.length === 0 → friendly message + link to + ROUTES.ADMIN ("Index documents in Admin → RAG Sources"). + * isLoading → ; error → . + +- SECTION 2 — Semantic Search (Card, inside or below Section 1): + * Controlled for the query + a "Search" +

Compare runs

+

+ Pick two model runs to compare their configuration and metrics side by side. +

+ + + + + Select runs + + The comparison is deep-linkable — the URL carries the two run ids. + + + + selectRun('a', id)} /> + selectRun('b', id)} /> + + + + {(!a || !b) && ( + + + Select two runs above to see the comparison. + + + )} + + {a && b && compareQuery.error && ( + void compareQuery.refetch()} /> + )} + + {a && b && compareQuery.isLoading && } + + {a && b && comparison && ( + <> + + + Profile + Side-by-side registry records. + + + + + + Field + Run A + Run B + + + + + Run ID + + {comparison.run_a.run_id} + + + {comparison.run_b.run_id} + + + + Model type + {comparison.run_a.model_type} + {comparison.run_b.model_type} + + + Status + + + {comparison.run_a.status} + + + + + {comparison.run_b.status} + + + + + Data window + + {comparison.run_a.data_window_start} → {comparison.run_a.data_window_end} + + + {comparison.run_b.data_window_start} → {comparison.run_b.data_window_end} + + + + Config hash + + {comparison.run_a.config_hash} + + + {comparison.run_b.config_hash} + + + + Created + {fmtDate(comparison.run_a.created_at)} + {fmtDate(comparison.run_b.created_at)} + + +
+
+
+ + + + Config diff + Keys whose values differ between the two runs. + + + {Object.keys(comparison.config_diff).length === 0 ? ( +

+ The two runs share an identical configuration. +

+ ) : ( + + )} +
+
+ + + + Metrics diff + + Δ is Run B minus Run A — sign only, not a quality judgement. + + + + {Object.keys(comparison.metrics_diff).length === 0 ? ( +

No metrics to compare.

+ ) : ( + + + + Metric + Run A + Run B + Δ + + + + {Object.entries(comparison.metrics_diff).map(([metric, m]) => ( + + {metric} + {m.a != null ? formatNumber(m.a, 4) : '—'} + {m.b != null ? formatNumber(m.b, 4) : '—'} + + + + + ))} + +
+ )} +
+
+ + )} + + ) +} diff --git a/frontend/src/pages/explorer/run-detail.tsx b/frontend/src/pages/explorer/run-detail.tsx new file mode 100644 index 00000000..1a41ca5e --- /dev/null +++ b/frontend/src/pages/explorer/run-detail.tsx @@ -0,0 +1,272 @@ +import { useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { format } from 'date-fns' +import { + AlertTriangle, + ArrowLeft, + CheckCircle2, + GitCompare, + Loader2, + ShieldCheck, +} from 'lucide-react' +import { useRun, useVerifyArtifact } from '@/hooks/use-runs' +import { JsonBlock } from '@/components/common/json-block' +import { ErrorDisplay } from '@/components/common/error-display' +import { LoadingState } from '@/components/common/loading-state' +import { StatusBadge } from '@/components/common/status-badge' +import { getStatusVariant } from '@/lib/status-utils' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { formatNumber, getErrorMessage } from '@/lib/api' +import { ROUTES } from '@/lib/constants' + +function fmtDate(value: string | null | undefined): string { + return value ? format(new Date(value), 'MMM d, yyyy HH:mm') : '—' +} + +function Field({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +export default function RunDetailPage() { + const { runId } = useParams() + const runQuery = useRun(runId ?? '', !!runId) + + // The verify GET is button-gated: disabled until the first click, then refetch. + const [verifyOn, setVerifyOn] = useState(false) + const verifyQuery = useVerifyArtifact(runId ?? '', verifyOn) + + if (!runId) { + return ( +
+

Run Detail

+ +
+ ) + } + + if (runQuery.error) { + return ( +
+

Run Detail

+ void runQuery.refetch()} /> +
+ ) + } + + if (runQuery.isLoading || !runQuery.data) { + return + } + + const run = runQuery.data + + function handleVerify() { + if (!verifyOn) setVerifyOn(true) + else void verifyQuery.refetch() + } + + return ( +
+
+
+ +
+

{run.run_id}

+ {run.status} +
+

{run.model_type}

+
+ +
+ + + + Run profile + Registry record for this model run. + + +
+ +
+
Store
+
+ + #{run.store_id} + +
+
+
+
Product
+
+ + #{run.product_id} + +
+
+ + + + + + +
+
+
+ + {run.status === 'failed' && run.error_message && ( + + + Error + + +

{run.error_message}

+
+
+ )} + + + + Metrics + Evaluation metrics recorded for this run. + + + + + + +
+ + + Model config + + + + + + + + Feature config + + + + + +
+ + + + Runtime info + Environment captured at training time. + + + + + + + {run.agent_context && ( + + + Agent context + The agent session that created this run. + + + + + + )} + + + + Artifact + Stored model artifact and SHA-256 integrity check. + + +
+ + + +
+ +
+ + {!run.artifact_uri && ( + This run has no artifact. + )} +
+ + {verifyOn && !verifyQuery.isFetching && verifyQuery.error && ( +
+ + {getErrorMessage(verifyQuery.error)} +
+ )} + + {verifyOn && + !verifyQuery.isFetching && + verifyQuery.data && + (verifyQuery.data.verified ? ( +
+ + + Artifact verified — the stored checksum matches. + {verifyQuery.data.computed_hash && ( + + {verifyQuery.data.computed_hash} + + )} + +
+ ) : ( +
+ + + Integrity check failed — the artifact does not match its stored hash. + {verifyQuery.data.error && ( + {verifyQuery.data.error} + )} + +
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/explorer/runs.tsx b/frontend/src/pages/explorer/runs.tsx index 31b1c436..c6fb43fc 100644 --- a/frontend/src/pages/explorer/runs.tsx +++ b/frontend/src/pages/explorer/runs.tsx @@ -1,26 +1,32 @@ -import { useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { format } from 'date-fns' -import { ColumnDef, PaginationState } from '@tanstack/react-table' +import { ColumnDef, OnChangeFn, PaginationState, SortingState } from '@tanstack/react-table' +import { Download, GitCompare } from 'lucide-react' import { useRuns } from '@/hooks/use-runs' import { DataTable } from '@/components/data-table/data-table' import { DataTableToolbar } from '@/components/data-table/data-table-toolbar' +import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header' import { StatusBadge } from '@/components/common/status-badge' import { getStatusVariant } from '@/lib/status-utils' import { ErrorDisplay } from '@/components/common/error-display' +import { Button } from '@/components/ui/button' +import { toCsv, downloadCsv, type CsvColumn } from '@/lib/csv-export' import type { ModelRun, RunStatus } from '@/types/api' -import { DEFAULT_PAGE_SIZE } from '@/lib/constants' +import { DEFAULT_PAGE_SIZE, ROUTES } from '@/lib/constants' const columns: ColumnDef[] = [ { accessorKey: 'run_id', header: 'Run ID', + enableSorting: false, + enableHiding: false, cell: ({ row }) => ( {row.original.run_id.substring(0, 8)}... ), }, { accessorKey: 'status', - header: 'Status', + header: ({ column }) => , cell: ({ row }) => ( {row.original.status} @@ -29,20 +35,21 @@ const columns: ColumnDef[] = [ }, { accessorKey: 'model_type', - header: 'Model Type', + header: ({ column }) => , cell: ({ row }) => {row.original.model_type}, }, { accessorKey: 'store_id', - header: 'Store', + header: ({ column }) => , }, { accessorKey: 'product_id', - header: 'Product', + header: ({ column }) => , }, { accessorKey: 'data_window_start', header: 'Data Window', + enableSorting: false, cell: ({ row }) => ( {format(new Date(row.original.data_window_start), 'MMM d')} -{' '} @@ -53,6 +60,7 @@ const columns: ColumnDef[] = [ { accessorKey: 'metrics', header: 'MAE', + enableSorting: false, cell: ({ row }) => { const mae = row.original.metrics?.mae return mae !== undefined ? mae.toFixed(2) : '-' @@ -60,42 +68,95 @@ const columns: ColumnDef[] = [ }, { accessorKey: 'created_at', - header: 'Created', + header: ({ column }) => , cell: ({ row }) => format(new Date(row.original.created_at), 'MMM d, HH:mm'), }, ] +const csvColumns: CsvColumn[] = [ + { key: 'run_id', header: 'Run ID' }, + { key: 'status', header: 'Status' }, + { key: 'model_type', header: 'Model Type' }, + { key: 'store_id', header: 'Store' }, + { key: 'product_id', header: 'Product' }, + { key: 'data_window_start', header: 'Data Window Start' }, + { key: 'data_window_end', header: 'Data Window End' }, + { key: 'created_at', header: 'Created' }, +] + export default function RunsExplorerPage() { - const [pagination, setPagination] = useState({ - pageIndex: 0, + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + + // URL query string is the single source of truth for filter/sort/page state, + // so a pasted URL reproduces the exact view. + const modelType = searchParams.get('model_type') ?? undefined + const status = searchParams.get('status') ?? undefined + const page = Number(searchParams.get('page')) || 1 + const sortBy = searchParams.get('sort_by') ?? undefined + const sortOrder: 'asc' | 'desc' = searchParams.get('sort_order') === 'desc' ? 'desc' : 'asc' + + const pagination: PaginationState = { + pageIndex: page - 1, pageSize: DEFAULT_PAGE_SIZE, - }) - const [filters, setFilters] = useState>({}) + } + const sorting: SortingState = sortBy ? [{ id: sortBy, desc: sortOrder === 'desc' }] : [] const { data, isLoading, error, refetch } = useRuns({ - page: pagination.pageIndex + 1, + page, pageSize: pagination.pageSize, - modelType: filters.modelType, - status: filters.status as RunStatus | undefined, + modelType, + status: status as RunStatus | undefined, + sortBy, + sortOrder: sortBy ? sortOrder : undefined, }) + function updateParams(updates: Record) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + for (const [key, value] of Object.entries(updates)) { + if (value === undefined || value === '') next.delete(key) + else next.set(key, value) + } + return next + }) + } + + const handlePaginationChange: OnChangeFn = (updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater + updateParams({ page: String(next.pageIndex + 1) }) + } + + const handleSortingChange: OnChangeFn = (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater + const first = next[0] + updateParams({ + sort_by: first?.id, + sort_order: first ? (first.desc ? 'desc' : 'asc') : undefined, + page: '1', + }) + } + const handleFilterChange = (key: string, value: string | undefined) => { - setFilters((prev) => ({ ...prev, [key]: value })) - setPagination((prev) => ({ ...prev, pageIndex: 0 })) + const paramKey = key === 'modelType' ? 'model_type' : key + updateParams({ [paramKey]: value, page: '1' }) } const handleReset = () => { - setFilters({}) - setPagination({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }) + setSearchParams(new URLSearchParams()) } - const hasActiveFilters = Object.values(filters).some(Boolean) + const handleExport = () => { + downloadCsv('model-runs.csv', toCsv(data?.runs ?? [], csvColumns)) + } + + const hasActiveFilters = !!modelType || !!status || !!sortBy if (error) { return (

Model Runs

- + void refetch()} />
) } @@ -104,44 +165,62 @@ export default function RunsExplorerPage() { return (
-

Model Runs

- - +
+

Model Runs

+ +
+ +
+ + +
navigate(`/explorer/runs/${run.run_id}`)} + enableColumnVisibility isLoading={isLoading} emptyMessage="No model runs found." /> diff --git a/frontend/src/pages/explorer/sales.tsx b/frontend/src/pages/explorer/sales.tsx index b2a7b9b9..ba32036b 100644 --- a/frontend/src/pages/explorer/sales.tsx +++ b/frontend/src/pages/explorer/sales.tsx @@ -1,54 +1,169 @@ import { useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { format, subDays } from 'date-fns' +import { X } from 'lucide-react' import { DateRange } from 'react-day-picker' import { useDrilldowns } from '@/hooks/use-drilldowns' +import { useTimeseries } from '@/hooks/use-timeseries' import { DateRangePicker } from '@/components/common/date-range-picker' -import { dateRangeToStrings } from '@/lib/date-utils' import { ErrorDisplay } from '@/components/common/error-display' import { LoadingState } from '@/components/common/loading-state' +import { RevenueBarChart } from '@/components/charts/revenue-bar-chart' +import { TimeSeriesChart } from '@/components/charts/time-series-chart' +import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { dateRangeToStrings, stringsToDateRange } from '@/lib/date-utils' import { formatCurrency, formatNumber } from '@/lib/api' import type { DrilldownDimension } from '@/types/api' export default function SalesExplorerPage() { - const [dateRange, setDateRange] = useState({ - from: subDays(new Date(), 30), - to: new Date(), - }) - const [dimension, setDimension] = useState('store') + const [searchParams, setSearchParams] = useSearchParams() + + // dimension + cross-filter state live in the URL so the view is shareable. + const dimension = (searchParams.get('dimension') as DrilldownDimension | null) ?? 'store' + const storeIdParam = searchParams.get('store_id') + const productIdParam = searchParams.get('product_id') + const storeId = storeIdParam ? Number(storeIdParam) : undefined + const productId = productIdParam ? Number(productIdParam) : undefined + + const startParam = searchParams.get('start_date') + const endParam = searchParams.get('end_date') + const [dateRange, setDateRange] = useState(() => + startParam + ? stringsToDateRange(startParam, endParam ?? undefined) + : { from: subDays(new Date(), 30), to: new Date() } + ) const { startDate, endDate } = dateRangeToStrings(dateRange) + const rangeReady = !!startDate && !!endDate - const { data, isLoading, error, refetch } = useDrilldowns({ + function updateParams(updates: Record) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + for (const [key, value] of Object.entries(updates)) { + if (value === undefined || value === '') next.delete(key) + else next.set(key, value) + } + return next + }) + } + + const handleDateChange = (range: DateRange | undefined) => { + setDateRange(range) + const { startDate: nextStart, endDate: nextEnd } = dateRangeToStrings(range) + updateParams({ start_date: nextStart, end_date: nextEnd }) + } + + const drilldown = useDrilldowns({ dimension, startDate: startDate ?? '', endDate: endDate ?? '', + storeId, + productId, maxItems: 20, - enabled: !!startDate && !!endDate, + enabled: rangeReady, + }) + + const timeseries = useTimeseries({ + startDate: startDate ?? '', + endDate: endDate ?? '', + granularity: 'day', + storeId, + productId, + enabled: rangeReady, }) - if (error) { + if (drilldown.error) { return (

Sales Explorer

- + void drilldown.refetch()} />
) } + const items = drilldown.data?.items ?? [] + const points = timeseries.data?.points ?? [] + const dimensionLabel = dimension.charAt(0).toUpperCase() + dimension.slice(1) + return (

Sales Explorer

- + +
+ + {(storeId !== undefined || productId !== undefined) && ( +
+ {storeId !== undefined && ( + + Filtered to store #{storeId} + + + )} + {productId !== undefined && ( + + Filtered to product #{productId} + + + )} +
+ )} + +
+ {items.length > 0 ? ( + ({ + label: item.dimension_value, + revenue: Number(item.metrics.total_revenue), + }))} + /> + ) : ( + + + Revenue by {dimensionLabel} + No sales data for the selected period. + + + )} + {points.length > 0 ? ( + ({ + date: p.period, + actual: Number(p.metrics.total_revenue), + }))} + showPredicted={false} + /> + ) : ( + + + Revenue over time + No sales data for the selected period. + + + )}
- setDimension(v as DrilldownDimension)}> + updateParams({ dimension: v })}> By Store By Product @@ -58,22 +173,22 @@ export default function SalesExplorerPage() { - {isLoading ? ( + {drilldown.isLoading ? ( ) : ( - Sales by {dimension.charAt(0).toUpperCase() + dimension.slice(1)} + Sales by {dimensionLabel} - {data?.total_items ?? 0} items found for{' '} + {drilldown.data?.total_items ?? 0} items found for{' '} {startDate && format(new Date(startDate), 'MMM d, yyyy')} -{' '} {endDate && format(new Date(endDate), 'MMM d, yyyy')} - {data?.items.length ? ( + {items.length ? (
- {data.items.map((item, idx) => ( + {items.map((item, idx) => (
0 + + const [dateRange, setDateRange] = useState({ + from: subDays(new Date(), 30), + to: new Date(), + }) + const { startDate, endDate } = dateRangeToStrings(dateRange) + const rangeReady = !!startDate && !!endDate + + const storeQuery = useStore(id, validId) + const kpiQuery = useKPIs({ + startDate: startDate ?? '', + endDate: endDate ?? '', + storeId: id, + enabled: validId && rangeReady, + }) + const timeseriesQuery = useTimeseries({ + startDate: startDate ?? '', + endDate: endDate ?? '', + granularity: 'day', + storeId: id, + enabled: validId && rangeReady, + }) + const topProductsQuery = useDrilldowns({ + dimension: 'product', + startDate: startDate ?? '', + endDate: endDate ?? '', + storeId: id, + maxItems: 10, + enabled: validId && rangeReady, + }) + + if (!validId) { + return ( +
+

Store Detail

+ +
+ ) + } + + if (storeQuery.error) { + return ( +
+

Store Detail

+ void storeQuery.refetch()} /> +
+ ) + } + + const store = storeQuery.data + const metrics = kpiQuery.data?.metrics + const points = timeseriesQuery.data?.points ?? [] + const topProducts = topProductsQuery.data?.items ?? [] + + return ( +
+
+
+ +

{store?.name ?? 'Store'}

+ {store && ( +

+ {store.code} + {store.region ? ` · ${store.region}` : ''} +

+ )} +
+
+ + +
+
+ + + + Store profile + Dimension record for this store. + + +
+
+
Code
+
{store?.code ?? '-'}
+
+
+
Region
+
{store?.region ?? '-'}
+
+
+
City
+
{store?.city ?? '-'}
+
+
+
Type
+
{store?.store_type ?? '-'}
+
+
+
+
+ +
+ + + + +
+ + {points.length > 0 ? ( + ({ + date: p.period, + actual: Number(p.metrics.total_revenue), + }))} + showPredicted={false} + /> + ) : ( + + + Revenue over time + No sales in the selected period. + + + )} + + {topProducts.length > 0 ? ( + ({ + label: item.dimension_value, + revenue: Number(item.metrics.total_revenue), + }))} + /> + ) : ( + + + Top products + No product sales in the selected period. + + + )} +
+ ) +} diff --git a/frontend/src/pages/explorer/stores.tsx b/frontend/src/pages/explorer/stores.tsx index 36f19d35..99914107 100644 --- a/frontend/src/pages/explorer/stores.tsx +++ b/frontend/src/pages/explorer/stores.tsx @@ -1,9 +1,13 @@ -import { useState } from 'react' -import { ColumnDef, PaginationState } from '@tanstack/react-table' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { ColumnDef, OnChangeFn, PaginationState, SortingState } from '@tanstack/react-table' +import { Download } from 'lucide-react' import { useStores } from '@/hooks/use-stores' import { DataTable } from '@/components/data-table/data-table' import { DataTableToolbar } from '@/components/data-table/data-table-toolbar' +import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header' import { ErrorDisplay } from '@/components/common/error-display' +import { Button } from '@/components/ui/button' +import { toCsv, downloadCsv, type CsvColumn } from '@/lib/csv-export' import type { Store } from '@/types/api' import { DEFAULT_PAGE_SIZE } from '@/lib/constants' @@ -11,74 +15,124 @@ const columns: ColumnDef[] = [ { accessorKey: 'id', header: 'ID', + enableSorting: false, + enableHiding: false, cell: ({ row }) => {row.original.id}, }, { accessorKey: 'code', - header: 'Code', + header: ({ column }) => , cell: ({ row }) => {row.original.code}, }, { accessorKey: 'name', - header: 'Name', + header: ({ column }) => , }, { accessorKey: 'region', - header: 'Region', + header: ({ column }) => , cell: ({ row }) => row.original.region ?? '-', }, { accessorKey: 'city', - header: 'City', + header: ({ column }) => , cell: ({ row }) => row.original.city ?? '-', }, { accessorKey: 'store_type', - header: 'Type', + header: ({ column }) => , cell: ({ row }) => row.original.store_type ?? '-', }, ] +const csvColumns: CsvColumn[] = [ + { key: 'id', header: 'ID' }, + { key: 'code', header: 'Code' }, + { key: 'name', header: 'Name' }, + { key: 'region', header: 'Region' }, + { key: 'city', header: 'City' }, + { key: 'store_type', header: 'Type' }, +] + export default function StoresExplorerPage() { - const [pagination, setPagination] = useState({ - pageIndex: 0, + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + + // URL query string is the single source of truth for filter/sort/page state, + // so a pasted URL reproduces the exact view. + const search = searchParams.get('search') ?? '' + const region = searchParams.get('region') ?? undefined + const storeType = searchParams.get('store_type') ?? undefined + const page = Number(searchParams.get('page')) || 1 + const sortBy = searchParams.get('sort_by') ?? undefined + const sortOrder: 'asc' | 'desc' = searchParams.get('sort_order') === 'desc' ? 'desc' : 'asc' + + const pagination: PaginationState = { + pageIndex: page - 1, pageSize: DEFAULT_PAGE_SIZE, - }) - const [search, setSearch] = useState('') - const [filters, setFilters] = useState>({}) + } + const sorting: SortingState = sortBy ? [{ id: sortBy, desc: sortOrder === 'desc' }] : [] - // Convert 0-indexed pageIndex to 1-indexed page for API const { data, isLoading, error, refetch } = useStores({ - page: pagination.pageIndex + 1, + page, pageSize: pagination.pageSize, search: search.length >= 2 ? search : undefined, - region: filters.region, - storeType: filters.storeType, + region, + storeType, + sortBy, + sortOrder: sortBy ? sortOrder : undefined, }) - const handleFilterChange = (key: string, value: string | undefined) => { - setFilters((prev) => ({ ...prev, [key]: value })) - setPagination((prev) => ({ ...prev, pageIndex: 0 })) + function updateParams(updates: Record) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + for (const [key, value] of Object.entries(updates)) { + if (value === undefined || value === '') next.delete(key) + else next.set(key, value) + } + return next + }) + } + + const handlePaginationChange: OnChangeFn = (updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater + updateParams({ page: String(next.pageIndex + 1) }) + } + + const handleSortingChange: OnChangeFn = (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater + const first = next[0] + updateParams({ + sort_by: first?.id, + sort_order: first ? (first.desc ? 'desc' : 'asc') : undefined, + page: '1', + }) } const handleSearchChange = (value: string) => { - setSearch(value) - setPagination((prev) => ({ ...prev, pageIndex: 0 })) + updateParams({ search: value || undefined, page: '1' }) + } + + const handleFilterChange = (key: string, value: string | undefined) => { + const paramKey = key === 'storeType' ? 'store_type' : key + updateParams({ [paramKey]: value, page: '1' }) } const handleReset = () => { - setSearch('') - setFilters({}) - setPagination({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }) + setSearchParams(new URLSearchParams()) } - const hasActiveFilters = !!search || Object.values(filters).some(Boolean) + const handleExport = () => { + downloadCsv('stores.csv', toCsv(data?.stores ?? [], csvColumns)) + } + + const hasActiveFilters = !!search || !!region || !!storeType || !!sortBy if (error) { return (

Stores

- + void refetch()} />
) } @@ -89,43 +143,53 @@ export default function StoresExplorerPage() {

Stores

- +
+ + +
navigate(`/explorer/stores/${store.id}`)} + enableColumnVisibility isLoading={isLoading} emptyMessage="No stores found." /> diff --git a/frontend/src/pages/guide.tsx b/frontend/src/pages/guide.tsx new file mode 100644 index 00000000..787a24e8 --- /dev/null +++ b/frontend/src/pages/guide.tsx @@ -0,0 +1,362 @@ +import { Link } from 'react-router-dom' +import { + Bot, + Search, + FlaskConical, + ShieldCheck, + Workflow, + Gauge, + MessageSquare, + ArrowRight, + Settings, + AlertTriangle, +} from 'lucide-react' +import { useAIConfig } from '@/hooks/use-config' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { ROUTES } from '@/lib/constants' + +// Tool inventories — kept verbatim in sync with the agent definitions +// (app/features/agents/agents/experiment.py + rag_assistant.py). The +// `approval` flag mirrors agent_require_approval (create_alias / archive_run). +interface ToolInfo { + name: string + desc: string + approval?: boolean +} + +const RAG_TOOLS: ToolInfo[] = [ + { name: 'tool_retrieve_context', desc: 'Semantic search over the indexed knowledge base.' }, + { name: 'tool_list_sources', desc: 'List indexed sources and chunk counts.' }, + { name: 'tool_format_citations', desc: 'Turn retrieval results into stable citations.' }, + { name: 'tool_check_evidence', desc: 'Decide whether the evidence is sufficient to answer.' }, +] + +const EXPERIMENT_TOOLS: ToolInfo[] = [ + { name: 'tool_list_runs', desc: 'Browse existing model runs in the registry.' }, + { name: 'tool_get_run', desc: 'Fetch the full detail of one model run.' }, + { name: 'tool_run_backtest', desc: 'Run a time-series backtest for a store / product.' }, + { + name: 'tool_compare_backtest_results', + desc: 'Compare two backtest results and recommend a winner.', + }, + { name: 'tool_compare_runs', desc: 'Diff two registered runs (config + metrics).' }, + { name: 'tool_create_alias', desc: 'Promote a successful run to a deployment alias.', approval: true }, + { name: 'tool_archive_run', desc: 'Archive a model run.', approval: true }, +] + +const SESSION_STEPS = [ + 'Open Chat, pick an agent type, and click "Start Session".', + 'Type a message and send it.', + 'Watch the reply stream token-by-token; tool calls appear as chips (start → end).', + 'If the agent proposes a guarded action, an approval prompt appears — approve or reject it.', + '"New Session" starts a fresh conversation with a clean history.', +] + +const RAG_PROMPTS = [ + 'What forecasting models does ForecastLabAI support?', + 'How does backtesting prevent data leakage?', + 'What is in your knowledge base?', +] + +const EXPERIMENT_PROMPTS = [ + 'Backtest a seasonal_naive model for store 1 product 1 over the last 90 days and compare it to the naive baseline.', + 'List the most recent model runs and tell me which has the lowest WAPE.', +] + +export default function GuidePage() { + const { data: config, isLoading: configLoading } = useAIConfig() + + return ( +
+ {/* Header */} +
+

Agent Guide

+

How to use the Chat agents.

+
+ + {/* Live model callout */} + {config && ( +
+ + + Agents currently run on{' '} + {config.agent_default_model}. + + + + Manage in Admin → AI Models + +
+ )} + + {/* The two agents */} +
+ + See what it can answer from → Knowledge + + + } + /> + + See the runs it acts on → Model Runs + + + } + /> +
+ + {/* How a chat session works */} + + +
+ + How a chat session works +
+ + Each session is one conversation. Replies stream over a WebSocket — text arrives as{' '} + text_delta events and tool + calls as tool_call_start /{' '} + tool_call_end events. + +
+ +
    + {SESSION_STEPS.map((step, i) => ( +
  1. + + {i + 1} + + {step} +
  2. + ))} +
+
+
+ + {/* Human-in-the-loop approval */} + + +
+ + Human-in-the-loop approval +
+ + Tools that change registry state never run unattended. + +
+ +

+ When an agent calls a guarded tool, the run pauses and the Chat page shows an approval + prompt. The action only proceeds once you approve it; rejecting it returns control to + the agent. This keeps every mutation of the model registry under human control. +

+
+ Approval-gated tools: + {config ? ( + config.agent_require_approval.map((tool) => ( + + + {tool} + + )) + ) : ( + + )} +
+
+
+ + {/* Session limits */} + + +
+ + Session limits +
+ + Live from GET /config/ai. + These are the configured defaults — an operator can change them in Admin → AI Models. + +
+ + {configLoading && } + {config && ( + + + + Limit + Value + + + + + Token budget per session + {config.agent_max_tokens.toLocaleString()} tokens + + + Tool calls per session + {config.agent_max_tool_calls} + + + Per-run timeout + {config.agent_timeout_seconds} seconds + + + Retry attempts + {config.agent_retry_attempts} + + + Session time-to-live + {config.agent_session_ttl_minutes} minutes + + + Approval-gated tools + + {config.agent_require_approval.join(', ') || 'none'} + + + +
+ )} + {!configLoading && !config && ( +

+ Session limits are unavailable right now — the configuration endpoint could not be + reached. +

+ )} +
+
+ + {/* Example prompts */} + + +
+ + Example prompts +
+ Copy one of these into Chat to get started. +
+ + + + +
+ + {/* CTA */} +
+ +
+
+ ) +} + +function AgentCard({ + icon: Icon, + title, + agentId, + purpose, + tools, + footer, +}: { + icon: React.ComponentType<{ className?: string }> + title: string + agentId: string + purpose: string + tools: ToolInfo[] + footer: React.ReactNode +}) { + return ( + + +
+ + {title} + + {agentId} + +
+ {purpose} +
+ +
+

Tools

+
    + {tools.map((tool) => ( +
  • +
    + {tool.name} + {tool.approval && ( + + + requires approval + + )} +
    +

    {tool.desc}

    +
  • + ))} +
+
+
{footer}
+
+
+ ) +} + +function PromptList({ title, prompts }: { title: string; prompts: string[] }) { + return ( +
+

{title}

+ {prompts.map((prompt) => ( + + {prompt} + + ))} +
+ ) +} diff --git a/frontend/src/pages/knowledge.tsx b/frontend/src/pages/knowledge.tsx new file mode 100644 index 00000000..1335ae88 --- /dev/null +++ b/frontend/src/pages/knowledge.tsx @@ -0,0 +1,344 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { format } from 'date-fns' +import { + Library, + Search, + FileText, + Loader2, + Store, + Package, + TrendingUp, + CalendarRange, + Database, + Tag, + ArrowRight, + FolderOpen, +} from 'lucide-react' +import { useRagSources, useRetrieve } from '@/hooks/use-rag-sources' +import { useSeederStatus } from '@/hooks/use-seeder' +import { useRuns, useAliases } from '@/hooks/use-runs' +import { LoadingState } from '@/components/common/loading-state' +import { ErrorDisplay } from '@/components/common/error-display' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { ApiError, getErrorMessage } from '@/lib/api' +import { ROUTES } from '@/lib/constants' +import { formatRelevance, chunkExcerpt, groupSourcesByType } from '@/lib/knowledge-utils' + +export default function KnowledgePage() { + return ( +
+ {/* Header */} +
+

Knowledge

+

+ Everything ForecastLabAI can currently draw on — the RAG knowledge base its assistant + answers from, and the live data its experiment agent acts on. +

+
+ + + + +
+ ) +} + +// === Section 1 — Knowledge Base (indexed RAG sources, read-only) === + +function KnowledgeBaseSection() { + const { data, isLoading, error, refetch } = useRagSources() + + if (error) { + return + } + if (isLoading) { + return + } + + const sources = data?.sources ?? [] + const byType = groupSourcesByType(sources) + + return ( + + +
+ + Knowledge Base +
+ + {data?.total_sources ?? 0} sources • {data?.total_chunks ?? 0} chunks + {sources.length > 0 && ( + <> + {' • '} + {Object.entries(byType) + .map(([type, items]) => `${items.length} ${type}`) + .join(', ')} + + )} + +
+ + {sources.length > 0 ? ( +
+ {sources.map((source) => ( +
+
+ +
+

{source.source_path}

+

+ {source.chunk_count} chunks • Indexed{' '} + {format(new Date(source.indexed_at), 'MMM d, yyyy')} +

+
+
+ + {source.source_type} + +
+ ))} +
+ ) : ( +
+ +
+

No documents indexed yet

+

+ The RAG assistant has nothing to answer from. Index documents in Admin → RAG + Sources, or run the RAG seeder scenario. +

+
+ +
+ )} +
+
+ ) +} + +// === Section 2 — Semantic Search (POST /rag/retrieve) === + +function SemanticSearchSection() { + const [query, setQuery] = useState('') + const retrieve = useRetrieve() + + const trimmed = query.trim() + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!trimmed || retrieve.isPending) return + retrieve.mutate({ query: trimmed, top_k: 5 }) + } + + const searchUnavailable = retrieve.error instanceof ApiError && retrieve.error.status === 502 + const results = retrieve.data?.results ?? [] + + return ( + + +
+ + Semantic Search +
+ + Search the indexed knowledge base the way the RAG assistant does — by meaning, not + keywords. + +
+ +
+ setQuery(e.target.value)} + placeholder="e.g. How does backtesting prevent data leakage?" + aria-label="Semantic search query" + /> + +
+ + {searchUnavailable && ( +

+ Semantic search is unavailable — configure an embedding provider in{' '} + + Admin → AI Models + + . The source list above does not need embeddings and still works. +

+ )} + + {retrieve.isError && !searchUnavailable && ( +

+ {getErrorMessage(retrieve.error)} +

+ )} + + {retrieve.isSuccess && results.length === 0 && ( +

+ No matching content found. Try rephrasing the query. +

+ )} + + {results.length > 0 && ( +
+

+ {results.length} match{results.length === 1 ? '' : 'es'} •{' '} + {retrieve.data?.total_chunks_searched ?? 0} chunks searched in{' '} + {Math.round(retrieve.data?.search_time_ms ?? 0)} ms +

+ {results.map((chunk) => ( +
+
+

+ {chunk.source_path} + ({chunk.source_type}) +

+ + {formatRelevance(chunk.relevance_score)} match + +
+

{chunkExcerpt(chunk)}

+
+ ))} +
+ )} +
+
+ ) +} + +// === Section 3 — Live System State (what the experiment agent acts on) === + +function StatCard({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ className?: string }> + label: string + value: string | number +}) { + return ( +
+ +

{typeof value === 'number' ? value.toLocaleString() : value}

+

{label}

+
+ ) +} + +function LiveSystemStateSection() { + const { data: status, isLoading: statusLoading } = useSeederStatus() + const { data: runs, isLoading: runsLoading } = useRuns({ page: 1, pageSize: 1 }) + const { data: aliases, isLoading: aliasesLoading } = useAliases() + + const dateRange = + status?.date_range_start && status?.date_range_end + ? `${status.date_range_start} → ${status.date_range_end}` + : 'No data' + + return ( + + +
+ + Live System State +
+ + The seeded data and registered models the experiment agent can query through its tools. + +
+ + {/* Seeded data tiles */} + {statusLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ + + + +
+ )} + + {/* Registry summary */} +
+
+
+ +

Registered model runs

+
+

+ {runsLoading ? '—' : (runs?.total ?? 0).toLocaleString()} +

+ + Browse all runs + + +
+ +
+
+ +

Deployment aliases

+
+ {aliasesLoading ? ( +

Loading…

+ ) : aliases && aliases.length > 0 ? ( +
    + {aliases.map((alias) => ( +
  • + {alias.alias_name} + {alias.model_type} +
  • + ))} +
+ ) : ( +

No aliases yet.

+ )} +
+
+ + {/* Explainer */} +

+ The RAG assistant answers from the Knowledge Base above; the experiment agent acts on this + Live System State. Learn how to use them in the{' '} + + Agent Guide + + , or start a conversation in{' '} + + Chat + + . +

+
+
+ ) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index c356c6ab..ecd51d02 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -76,6 +76,46 @@ export interface DrilldownResponse { product_id: number | null } +// Bucket size for GET /analytics/timeseries. +export type TimeGranularity = 'day' | 'week' | 'month' | 'quarter' + +// One aggregated period of the sales time series. +export interface TimeSeriesPoint { + period: string // ISO date (bucket start) + metrics: KPIMetrics +} + +// Response from GET /analytics/timeseries (points ascending by period). +export interface TimeSeriesResponse { + granularity: TimeGranularity + points: TimeSeriesPoint[] + total_points: number + start_date: string + end_date: string + store_id: number | null + product_id: number | null + category: string | null +} + +// One day of a product's lifecycle demand curve. +export interface LifecyclePoint { + date: string // ISO date + stage: string + multiplier: number +} + +// Response from GET /dimensions/products/{id}/lifecycle-curve. +export interface LifecycleCurveResponse { + product_id: number + sku: string + launch_date: string | null + discontinue_date: string | null + start_date: string + end_date: string + points: LifecyclePoint[] + total: number +} + // === Registry === export type RunStatus = 'pending' | 'running' | 'success' | 'failed' | 'archived' @@ -125,6 +165,17 @@ export interface RunCompareResponse { metrics_diff: Record } +// Response from GET /registry/runs/{run_id}/verify (SHA-256 integrity check). +// On a checksum mismatch the endpoint returns HTTP 200 with verified:false + error. +export interface ArtifactVerifyResponse { + verified: boolean + run_id: string + artifact_uri: string + stored_hash?: string + computed_hash?: string + error?: string +} + // === Jobs === export type JobType = 'train' | 'predict' | 'backtest' export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' @@ -181,6 +232,35 @@ export interface IndexDocumentResponse { chunks_created: number } +// Semantic-search request for POST /rag/retrieve. +// Mirrors app/features/rag/schemas.py RetrieveRequest (extra="forbid" — send +// nothing beyond these fields). Omit similarity_threshold to use the server default. +export interface RetrieveRequest { + query: string + top_k?: number // 1..50, server default 5 + similarity_threshold?: number // 0..1 + filters?: Record | null +} + +// One matching chunk from a semantic search. +export interface ChunkResult { + chunk_id: string + source_id: string + source_path: string + source_type: string + content: string + relevance_score: number // 0..1 + metadata: Record | null +} + +// Response from POST /rag/retrieve. +export interface RetrieveResponse { + results: ChunkResult[] + query_embedding_time_ms: number + search_time_ms: number + total_chunks_searched: number +} + // === Agents WebSocket === export type AgentEventType = | 'text_delta' @@ -373,6 +453,11 @@ export interface AIModelConfig { agent_temperature: number agent_max_tokens: number agent_thinking_budget: number | null + agent_max_tool_calls: number + agent_timeout_seconds: number + agent_retry_attempts: number + agent_session_ttl_minutes: number + agent_require_approval: string[] rag_embedding_provider: string rag_embedding_model: string rag_embedding_dimension: number diff --git a/scripts/run_demo.py b/scripts/run_demo.py index 03d26913..8acfc255 100644 --- a/scripts/run_demo.py +++ b/scripts/run_demo.py @@ -43,7 +43,7 @@ import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from datetime import date, timedelta +from datetime import UTC, date, datetime, timedelta from pathlib import Path from typing import Any, Final @@ -75,8 +75,10 @@ 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) +# Seed window is anchored to *today* so the demo always runs on +# current-looking data; it spans DEMO_SEED_SPAN_DAYS back from today (92 days +# inclusive). Must stay >= 72 for a non-NaN backtest WAPE. +DEMO_SEED_SPAN_DAYS: Final[int] = 91 DEMO_MODEL_TYPES: Final[tuple[str, ...]] = ("naive", "seasonal_naive", "moving_average") @@ -411,6 +413,8 @@ async def step_seed(ctx: DemoContext, client: HttpClient) -> StepOutcome: detail="--skip-seed set", duration_ms=(time.monotonic() - start) * 1000, ) + seed_end = datetime.now(UTC).date() + seed_start = seed_end - timedelta(days=DEMO_SEED_SPAN_DAYS) body = await client.request( "seed", "POST", @@ -420,8 +424,8 @@ async def step_seed(ctx: DemoContext, client: HttpClient) -> StepOutcome: "seed": ctx.seed, "stores": DEMO_SEED_STORES, "products": DEMO_SEED_PRODUCTS, - "start_date": DEMO_SEED_START.isoformat(), - "end_date": DEMO_SEED_END.isoformat(), + "start_date": seed_start.isoformat(), + "end_date": seed_end.isoformat(), "sparsity": 0.0, "dry_run": False, }, diff --git a/scripts/seed_random.py b/scripts/seed_random.py index 27263e96..c544ad43 100644 --- a/scripts/seed_random.py +++ b/scripts/seed_random.py @@ -40,6 +40,8 @@ RetailPatternConfig, SparsityConfig, TimeSeriesConfig, + default_seed_end_date, + default_seed_start_date, ) from app.shared.seeder.rag_scenario import run_rag_scenario @@ -112,8 +114,10 @@ def load_config_from_yaml(path: Path) -> SeederConfig: # Parse date range date_range = data.get("date_range", {}) - start_date = parse_date(date_range["start"]) if "start" in date_range else date(2024, 1, 1) - end_date = parse_date(date_range["end"]) if "end" in date_range else date(2024, 12, 31) + start_date = ( + parse_date(date_range["start"]) if "start" in date_range else default_seed_start_date() + ) + end_date = parse_date(date_range["end"]) if "end" in date_range else default_seed_end_date() # Parse time series config ts_data = data.get("time_series", {}) @@ -243,14 +247,14 @@ def create_parser() -> argparse.ArgumentParser: parser.add_argument( "--start-date", type=parse_date, - default=date(2024, 1, 1), - help="Start of date range (default: 2024-01-01)", + default=default_seed_start_date(), + help="Start of date range (default: one year before today)", ) parser.add_argument( "--end-date", type=parse_date, - default=date(2024, 12, 31), - help="End of date range (default: 2024-12-31)", + default=default_seed_end_date(), + help="End of date range (default: today)", ) parser.add_argument( "--sparsity", diff --git a/tests/test_demo_showcase_integration.py b/tests/test_demo_showcase_integration.py index a9538551..5c0c5b38 100644 --- a/tests/test_demo_showcase_integration.py +++ b/tests/test_demo_showcase_integration.py @@ -7,14 +7,21 @@ ``integration`` so it is excluded from the fast unit run. """ +from datetime import timedelta + import pytest +from app.shared.seeder.config import DEMO_MINIMAL_SPAN_DAYS, default_seed_end_date + 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. + # The window is anchored to today, mirroring the demo pipeline's own seed step. + seed_end = default_seed_end_date() + seed_start = seed_end - timedelta(days=DEMO_MINIMAL_SPAN_DAYS) seed_resp = await client.post( "/seeder/generate", json={ @@ -22,8 +29,8 @@ async def test_demo_run_pipeline_end_to_end(client): "seed": 42, "stores": 3, "products": 10, - "start_date": "2024-10-01", - "end_date": "2024-12-31", + "start_date": seed_start.isoformat(), + "end_date": seed_end.isoformat(), "sparsity": 0.0, "dry_run": False, }, diff --git a/tests/test_run_demo_unit.py b/tests/test_run_demo_unit.py index 4140d95a..0630b1d1 100644 --- a/tests/test_run_demo_unit.py +++ b/tests/test_run_demo_unit.py @@ -8,16 +8,19 @@ from __future__ import annotations import math +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock import pytest +from app.shared.seeder.config import default_seed_end_date from scripts import run_demo from scripts.run_demo import ( DEMO_ALIAS, DEMO_HORIZON, DEMO_MODEL_TYPES, + DEMO_SEED_SPAN_DAYS, GLYPHS, DemoArgs, DemoContext, @@ -341,7 +344,11 @@ class TestStepPayloads: async def test_step_seed_sends_demo_minimal( self, ) -> None: - """Seed step posts demo_minimal scenario with correct dims + dates.""" + """Seed step posts demo_minimal scenario with correct dims + dates. + + The seed window is anchored to *today* and runs DEMO_SEED_SPAN_DAYS + backwards, so the demo always seeds current-looking data. + """ calls: list[dict[str, Any]] = [] class _RecordingClient: @@ -374,8 +381,9 @@ async def request( 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" + today = default_seed_end_date() + assert body["end_date"] == today.isoformat() + assert body["start_date"] == (today - timedelta(days=DEMO_SEED_SPAN_DAYS)).isoformat() @pytest.mark.asyncio async def test_step_seed_skipped(self) -> None: