diff --git a/.env.example b/.env.example index 7203756..bb2d926 100644 --- a/.env.example +++ b/.env.example @@ -8,13 +8,19 @@ POSTGRES_DB=mobilityops POSTGRES_USER=mobilityops POSTGRES_PASSWORD=mobilityops APP_SECRET=replace-in-production -DEMO_TODAY=2026-08-01 TZ=Europe/Brussels # Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the # current Unraid review environment); set true only once MobilityOps is served over HTTPS, # otherwise browsers will silently drop the cookie and no one can log in. SESSION_COOKIE_SECURE=false +# Demo presentation (fictional org identity, badge/manifest, reset safety valve). +# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent +# of role -- a safety valve for any environment where the dataset must not be rebuildable. +DEMO_ORGANIZATION_NAME=Northstar Mobility +DEMO_TIMEZONE=Europe/Brussels +DEMO_ALLOW_RESET=true + # n8n N8N_BASE_URL=http://n8n:5678 N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 341b408..13f46c8 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -507,3 +507,314 @@ any change at `docs/functional-completion/server-baseline.md`. re-verified against the live server after every batch. See `artifacts/functional-completion/final-summary.md` for the definitive acceptance evidence. + +## Demo productization (in progress, same branch `feat/mobilityops-functional-completion`) + +Follows the functional-completion work above; turns the now feature-complete PoC into a +guided, honestly-labelled demo (fictional org "Northstar Mobility", guided tour, 5 named +scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-demo-gap-audit.md`. + +### Batch 1 — seed date anchoring (complete) + +- **Real bug fixed**: `seed/bookings.csv` etc. store absolute ISO timestamps authored + around a fixed anchor (`2026-08-01`). Nothing previously re-anchored them at seed/reset + time, so scenario bookings (e.g. `BK-DEMO-RETURN`) silently drifted into the past every + day the environment wasn't reset. `dashboard.py::_today()` compounded this by filtering + "today's movements" against the same frozen `demo_today` setting instead of real time. +- Fix: `seed_loader.py` now computes `shift = today - SEED_AUTHORED_ANCHOR` once per + `load_seed()` call and applies it to every seeded booking/inspection/maintenance/outbox + datetime column, so scenarios stay "today"/"near-future" relative to the actual reset + moment. `SeedResult` now also carries `anchor_date`/`seeded_at`; `POST /api/v1/demo/reset` + returns them; a `demo_data_seeded` audit event records the anchor for traceability. + `dashboard.py::_today()` switched from the frozen `demo_today` setting to real wall-clock + UTC date. The now-dead `demo_today` setting/env var was removed from `config.py`, + `compose.yaml`, `.env`, `.env.example` (nothing else referenced it). +- Added seed-validation tests (`backend/tests/test_seed.py`) proving S1 (`BK-DEMO-RETURN`/ + `MO-024`), S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`), S4 (`MO-016`/ + `BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) and S5 (seeded failed outbox event + `00000000-0000-4000-8000-000000000020`, confirmed genuinely `failed` immediately after a + fresh reset, not silently auto-healed by the background dispatcher since it only claims + `pending` rows) are fully present after every reset, plus a dedicated anchoring test + asserting the shift and the audit marker. +- Live-verified locally: reseeded and confirmed via `psql` that `BK-DEMO-RETURN` now ends + today and `BK-DEMO-NEXT`/overlap bookings sit in the near future (today = 2026-08-03). +- Evidence: `pytest` **122 passed** (117 + 5 new/expanded seed tests), `ruff check .` + clean, `mypy app` clean (46 files, canonical `make` scope). +- Deployed to Unraid (commit `8989ffb`): pushed to Gitea, `git archive` tarball extracted + over `/mnt/user/appdata/mobilityops` preserving `.env`/volumes, `api` rebuilt (`db`/`web` + untouched — no frontend changes this batch), migrations confirmed at `e7b08389f47f + (head)`, reseeded, live-verified via `psql` that `BK-DEMO-RETURN`/`BK-DEMO-NEXT`/overlap + bookings sit at the same real-time-relative positions as local. `curl` to + `http://192.168.10.150:1236/` returns 200. +- Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent + demo badge (task #30), then the Demo Guide + scenario overview (task #31). + +### Batch 2 — demo manifest, Dutch demo entry, permanent demo badge, About page (complete) + +- `GET /api/v1/demo/manifest` (unauthenticated): single source of truth for demo org + identity, synthetic-data flag, reset allowance/timestamp/anchor date, guide + availability, and the 5 named scenarios with **live** readiness (queries the actual + `BK-DEMO-RETURN`/`DQ-DEMO-DUPLICATE`/`DQ-DEMO-OVERLAP`/seeded-failed-event/knowledge- + provider records — not hardcoded), plus plain-language integration summaries. Backed by + new `backend/app/services/demo_manifest.py`. Refactored the n8n status derivation out of + `integration_status.py` into a shared `services/integration_status.py` so the manifest + and the existing authenticated `/integrations/status` endpoint reuse one implementation. +- New settings (`backend/app/core/config.py`, wired through `compose.yaml`/`.env.example`): + `DEMO_ORGANIZATION_NAME` (default "Northstar Mobility" — surfaces the project's already- + locked fictitious tenant, previously only used internally as the `ragcore_tenant` slug), + `DEMO_TIMEZONE`, `DEMO_ALLOW_RESET` (a safety valve — `false` makes `POST + /api/v1/demo/reset` return 403 regardless of role; the now-dead `demo_today` setting + removed in Batch 1 stays removed). +- Rewrote `Login.tsx` in Dutch: names the fictional org, one-sentence explanation sourced + from the manifest, no password shown/copyable anywhere, "Start begeleide demo" primary + CTA (logs in as Operations Manager, navigates to `/dashboard?guide=start` for task #31 to + consume) plus "Verken als Operations Manager"/"Verken als Rental Employee" secondary + actions. Added a permanent demo badge (topbar pill + popover: synthetic notice, + "workflows are real" reassurance, last-reset timestamp, link to `/about`) replacing the + old full-width static `.demo-banner` bar — subtle by design per the brief, not a warning + bar. New `/about` + page (`AboutDemo.tsx`) covering the fictional problem, what's really implemented, what's + synthetic, honest per-integration labels (via the manifest), and a reset pointer — + reachable from the badge popover, not added to primary nav (preserves the existing Control + Rail nav per the "not a redesign" constraint). Frontend nav/design otherwise untouched. +- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files); + frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **41 passed** + (37 existing + 4 new `demo-entry.spec.ts` covering entry copy/no-password, guided-demo + login redirect, badge popover content + About link, and Escape/outside-click close). + Updated stale English login-button aria-labels and login-copy assertions across the + existing specs to match the new Dutch copy. +- Deployed to Unraid (commit `ac427f4`): pushed to Gitea, `git archive` tarball extracted + preserving `.env`/volumes, both `api` and `web` rebuilt (frontend changed this batch), + both healthy, migrations unchanged, reseeded. Live-verified: `GET + /api/v1/demo/manifest` returns `organization_name: "Northstar Mobility"`, + `allow_reset: true`, and all 5 scenarios `ready: true` right after reset. Ran + `demo-entry.spec.ts` (4 tests) and the five-minute demo script directly against + `http://192.168.10.150:1236` — **5/5 passed**. Reseeded again afterward to leave the + server demo-ready. +- Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards + on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31. + +### Batch 3 — Demo Guide + scenario overview (complete) + +- New `/scenarios` page (`Scenarios.tsx`): all 5 named scenarios as cards (title, + operational problem, duration, required role(s), "toont aan", ready/blocked status + from the manifest, "Start scenario" linking to the live `start_path`). Dashboard gets + one compact "Probeer een demonstratiescenario" panel (not 5 more cards — keeps the + existing dashboard uncluttered per the brief) showing readiness count and, for + Operations Managers, a guide resume/start control. +- New Demo Guide: `DemoGuideContext` (sessionStorage-persisted `currentIndex`/`completed` + set — browser-only, never touches auth or business logic), 8 static steps + (`data/demoGuideSteps.ts`) each with what-you'll-see/why/start-action/expected-outcome, + resolving live routes from the manifest for the two scenario-backed steps (return, + duplicate-merge) so they can't drift from actual records. `DemoGuide.tsx` renders a + fixed side panel (desktop) that becomes a bottom sheet at ≤700px via CSS only (no + layout duplication); `DemoGuideTrigger` (topbar, Operations-Manager-only — the 8 steps + require OM throughout) shows a live `completed/8` pill. "Demo opnieuw voorbereiden" + calls the real reset endpoint, resets guide progress, and returns to `/login` (mirrors + the existing sidebar reset flow). Login's "Start begeleide demo" logs in as OM and + passes a one-shot `?guide=start` marker the dashboard consumes once then strips. +- Fixed a real regression caught by the responsive-overflow tests: the new topbar guide + trigger pushed `.topbar-meta` past the viewport at ≤420px; fixed by hiding the guide + trigger (icon+pill) at that breakpoint — the Dashboard's own "Start demo-gids" control + remains reachable there. Also fixed a genuine mobile overflow in the new + `.demo-start-panel` (flex items without `min-width:0`/wrap on narrow screens). +- Fixed one fragile new test (asserted on the transient `?guide=start` URL param, which + the app intentionally strips immediately — changed to assert the guide's actual open + state instead) and two Playwright strict-mode ambiguous-match errors; confirmed the + full 41+6=47-test suite passes twice in a row after these fixes (ruling out flakiness). +- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite + **47 passed** (41 existing + 6 new `demo-guide.spec.ts`: scenario overview shows 5 + ready cards after reset, starting a scenario navigates to its fixed record, guide + step navigation/jump/close, progress persists across page navigation, guide hidden + from Rental Employee, restart-from-guide resets data and returns to login). Backend + untouched this batch (no re-run needed; last backend gate was 127 passed/ruff/mypy + clean in Batch 2). +- Deployed to Unraid (commits `9fff84d`, then `14c2ad3` for a test-only fix): pushed to + Gitea, tarball extracted, `web` rebuilt (frontend-only batch), healthy. Live-verified: + ran `demo-guide.spec.ts` (6), `demo-entry.spec.ts` (4) and the five-minute demo script + directly against `http://192.168.10.150:1236` — **11/11 passed**. Caught and fixed one + real environment-sensitive test bug in the process: two tests navigated straight to + `/scenarios` right after a login click without waiting for the `/dashboard` redirect, + which raced harmlessly on localhost but flaked against Unraid's higher latency — fixed + by asserting the redirect first, no app-code change needed. Reseeded afterward to leave + the server demo-ready. +- Exact next action: layer plain-language Dutch explanation onto the return flow, the 5 + data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33. + +### Batch 4 — return/data-quality/knowledge demo legibility (complete) + +- **Fixed a real honesty bug**: `Knowledge.tsx` named "RAGcore" in the body copy and the + retrieval-flow diagram even though the active provider is the demo TF-IDF one (the + small badge below was already honest, contradicting the prose one line above). Now + derives a `providerLabel` ("Demo knowledge base" vs "RAGcore") from the real health + check and uses it everywhere; added an explicit disclosure note when not RAGcore. + Added 4 suggested-question chips. **Discovered and fixed a second real bug in the + process**: the brief's suggested Dutch questions (and my own Demo Guide step 6 wording) + would have returned "insufficient evidence" against the demo provider, because the + indexed procedures are English-only — verified empirically (Dutch question → + `insufficient`, its English equivalent → `grounded`). Fixed by keeping suggested + questions in English (matching the indexed content) and rewording the Guide step to + explain the knowledge base is English, rather than mistranslating the demo's + centerpiece feature into silently returning wrong answers. +- Return flow: `BookingDetail.tsx` now detects the one named return-anomaly scenario + booking (via the manifest, not a hardcoded ref) and fetches that vehicle's real + canonical odometer to pre-fill `ReturnForm`'s "End odometer" field with a suspicious + value below it, plus a callout explaining why — the brief explicitly requires the demo + not ask a visitor to invent a suspicious number themselves. Scoped narrowly to that one + scenario booking; ordinary returns are unaffected. `ReturnResultPanel` now links to + Automation and Audit trail (previously only the vehicle), and shows a "Ga verder met de + demo" button when the Demo Guide is open (advances the guide and navigates to the next + step). **Fixed a real regression caught by the existing return-review e2e test**: the + async pre-fill could silently overwrite odometer text a visitor had already started + typing, if the vehicle-detail fetch resolved after they began typing — fixed with an + `odometerEditedByUser` ref guard. +- Data quality: added a shared `RuleExplainer` (what's wrong / why it matters, in plain + language) for all 5 rule types on `DataQualityIssueDetail.tsx`; added a generic + post-resolution confirmation (audit-trail link, vehicle link, "Ga verder met de demo") + for the 4 rule types that previously just silently flipped their status badge with no + explicit confirmation, and extended `VehicleStatusConflictPanel`'s existing confirmation + with the same links rather than duplicating it. Added a "Demo scenario's only" checkbox + filter on `DataQuality.tsx` (client-side `public_ref.startsWith("DQ-DEMO-")`, no new + business logic) so the curated issues are easy to find among the full queue. +- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite + **51 passed** (47 existing + 4 new `demo-legibility.spec.ts`: return pre-fill + why- + suspicious explanation + result links, rule explainer visible, demo-scenario filter + narrows correctly, knowledge suggested question returns grounded evidence with the + correct provider label). Backend untouched this batch. +- Deployed to Unraid (commit `ddc3a98`): pushed to Gitea, tarball extracted, `web` + rebuilt (frontend-only), healthy. Live-verified: ran `demo-legibility.spec.ts` (4) and + the five-minute demo script directly against `http://192.168.10.150:1236` — + **5/5 passed**. Reseeded afterward to leave the server demo-ready. +- Exact next action: plain-language integration-status labels, richer audit narration, + the full "Over deze demo" page content (currently a first pass from Batch 2), and + wiring reset into the guide/About/OM menu narrative — task #34. + +### Batch 5 — integration-status UX, audit UX, About page, reset integrity (complete) + +- Plain-language integration status: extracted `frontend/src/data/integrationLabels.ts` + (`N8N_STATE_META`/`MCP_STATE_META`) mapping raw backend states to honest labels + ("Operational"/"Not connected"/"Prepared"/"Delivery failed"/"Retry available") while + keeping each mapped onto an existing `.status-*` CSS colour class (a few raw values like + `degraded`/`disabled`/`configured` had no matching CSS rule at all before this — a real, + pre-existing colour-coding gap). `StatusBadge` gained an optional `label` override prop + (backward compatible) so the badge's colour class and its displayed text can differ. + Wired into both `Automation.tsx` and `Dashboard.tsx`'s integration cards; also renamed + the "RAGcore" card heading to "Knowledge assistant" and made its text honestly name the + actual active provider (same bug class fixed in Knowledge.tsx in Batch 4). +- Audit trail: added a "Follow-up" column with a "View related events" action per row that + filters the same list by `correlation_id` (reuses the backend's existing, already-tested + `correlation_id` query param — no new business logic), with a "Clear this filter" + affordance. This is how a visitor sees "what else happened as a result of this action" + (e.g. a return's linked vehicle-status-changed / workflow-queued events) without a + bigger grouped-timeline rebuild. +- About page: added target-audience/scope, a short architecture summary, security + principles, and a testing-approach section (previously only covered the fictional + problem/real/synthetic/integrations/reset); added a "Start begeleide demo" CTA for + Operations Managers that opens the Demo Guide directly from this page. +- Reset integrity: added `scenario_integrity_report()` (`backend/app/services/ + demo_manifest.py`), reusing the exact same scenario-readiness derivation the manifest + and scenario overview already use (so it can't drift), and wired it into `POST + /api/v1/demo/reset` — both the response body and the `demo_reset` audit event's + metadata now carry `scenario_integrity: {all_ready, not_ready}`. This is the + server-side post-reset integrity check the brief asks for; visible today via the audit + event's raw-detail view, satisfying the requirement without adding a UI banner to a + flow that immediately logs the user out and redirects to `/login`. +- **Fixed a second real regression this batch, caught by the existing return-review + e2e test**: restructured the odometer pre-fill so `BookingDetail.tsx` withholds + rendering `ReturnForm` until the scenario's canonical odometer has resolved (with a + brief "Scenario voorbereiden…" loading state), instead of mounting the form immediately + and patching its value in asynchronously. The previous approach raced visibly with + Playwright's `fill()` (and would have raced with a real visitor typing quickly), + producing a corrupted concatenated value in one observed failure. This also let the + now-unnecessary `odometerEditedByUser` ref guard be removed — simpler and more robust + than the effect-based patch it replaced. +- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files); + frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **51 passed**, + confirmed stable across three consecutive full runs (given how many timing races this + batch and the previous one surfaced, stability was verified deliberately rather than + assumed from a single green run). +- Deployed to Unraid (commit `5fa4fe0`): pushed to Gitea, tarball extracted, both `api` + and `web` rebuilt, healthy, migrations unchanged at `e7b08389f47f (head)`, reseeded. + Live-verified: ran `demo-legibility.spec.ts` (4), the five-minute demo script, and the + full `interactive-elements.spec.ts` suite (26) directly against + `http://192.168.10.150:1236` — **31/31 passed**. Reseeded afterward to leave the + server demo-ready. +- Exact next action: full guided-demo Playwright test + remaining targeted demo tests per + section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/ + reduced-motion/console/network checks) — task #35. + +### Batch 6 — full guided-demo test + targeted demo tests (complete) + +- **Found and fixed a real, fairly serious desktop layout bug** while writing the full + guided-demo test: the Demo Guide's fixed right-side panel (400px wide) overlapped the + main content area at normal desktop widths with no reflow, so its own step-list buttons + intercepted pointer events meant for the page underneath (concretely: the return form's + "Review return" button was unclickable while the guide was open, at exactly the + viewport size Playwright's default test browser uses — this would have hit real + visitors on ordinary laptop screens too). Fixed by adding a `guide-open` class to + `.app-workspace` that reserves `padding-right: min(400px, 92vw)` while the guide is open + (≥701px only; the ≤700px bottom-sheet layout is unaffected), so content reflows aside + instead of sitting underneath the panel. +- Added `frontend/e2e/guided-demo-full.spec.ts`: one comprehensive test walking a fresh + Operations Manager session through all 8 Demo Guide steps in order, performing the + **real** action at each step (not just verifying copy) — processes the actual + odometer-anomaly return, resolves the resulting data-quality issue, merges the + duplicate customer, asks a suggested knowledge question, checks automation + audit, + reviews the About page — using the guide's own progression controls + ("Volgende"/"Ga naar deze stap"/"Ga verder met de demo") throughout, then resets the + demo data again at the end to restore the environment per the brief's requirement. +- Added `frontend/e2e/demo-accessibility.spec.ts` (4 tests): the guide renders as a + correctly-anchored bottom sheet on a 390px mobile viewport with no horizontal overflow; + the guide never covers the return form's action buttons on desktop (regression test for + the bug above); the demo badge and guide trigger are keyboard-focusable and operable + (Enter to open, explicit close controls); key demo pages (dashboard, scenarios, about, + guide open) load with no unexpected console errors (the one expected benign 401 from + the app's own session-probe on first load is explicitly allow-listed, not silenced + blindly). +- Evidence: full Playwright suite **56 passed** (51 existing + 1 guided-demo-full + 4 + demo-accessibility), confirmed stable across two consecutive full runs. Backend + untouched this batch (last gate: 127 passed/ruff/mypy clean, Batch 5). +- Deployed to Unraid (commit `07d5605`): pushed to Gitea, tarball extracted, `web` + rebuilt (frontend-only), healthy, reseeded. Live-verified: ran + `guided-demo-full.spec.ts` and `demo-accessibility.spec.ts` directly against + `http://192.168.10.150:1236` — **5/5 passed**, confirming the desktop-overlay layout + fix holds on the real deployment too. Reseeded afterward to leave the server + demo-ready. +- Exact next action: clean-checkout demo drill, final documentation set (demo-concept/ + demo-scenarios/demo-data/demo-guide/demo-runbook, README, .env.example), final Unraid + deploy + live evidence with screenshots, `artifacts/demo-release/final-summary.md` — + task #36 (final). + +### Batch 7 (final) — clean-checkout drill, docs, final Unraid evidence (complete) + +- **Clean-checkout drill**: fresh `git clone` into an isolated scratch directory, + isolated Compose project (`mobilityops-cleandrill`) + remapped ports via + `compose.override.yaml`, `up --build -d` from empty volumes. Migrations ran + automatically to `e7b08389f47f (head)`; seeded; full backend gate **127 passed**, + ruff/mypy clean; `npm ci` clean (same pre-existing advisories as before, unchanged); + `tsc -b`/`vite build` clean; full Playwright suite **56 passed** against the isolated + stack; reseeded and confirmed all 5 scenarios `ready: true` via the manifest; torn down + (`down -v` on the isolated project only — the working dev stack was untouched + throughout). +- Added the full demo-release documentation set: `docs/demo-release/demo-concept.md`, + `demo-scenarios.md`, `demo-data.md`, `demo-guide.md`, `demo-runbook.md`; updated + `README.md` (current test counts, links to the new docs, a "Demo" section) and + `docs/17-runbook.md` (cross-reference to the demo-specific runbook). +- Added `frontend/e2e/_capture-demo-screenshots.spec.ts` (tooling, excluded from the + regular suite) and captured 17 evidence screenshots live against + `http://192.168.10.150:1236` into `artifacts/demo-release/screenshots/`. +- Final live acceptance: full Playwright suite re-run against the live server — + **56 passed**; `docker compose ps` on the server shows `api`/`db`/`web` all healthy; + `docker logs` for `api`/`web` show no errors; reseeded to leave the server + demo-ready after evidence capture. +- Wrote `artifacts/demo-release/final-summary.md` with the full required evidence + (branches/commits, org/roles/guide/scenarios, seed/date-anchor/reset strategy, real vs. + synthetic vs. not-connected, all test results, clean-checkout result, deployment/ + health/console/log results, responsive/accessibility results including the two real + layout bugs found and fixed this work (mobile topbar overflow in Batch 3, desktop + guide-panel overlap in Batch 6), known limitations, 5-/10-minute demo flows, redeploy/ + rollback commands, and the screenshot list). +- Demo-productization work on this branch is complete. Every task (#29–#36) is done; + every batch was tested locally, deployed to Unraid, and re-verified live before moving + to the next. See `artifacts/demo-release/final-summary.md` for the definitive + acceptance evidence. diff --git a/README.md b/README.md index c03e484..0a1b3cc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,17 @@ design decision and visual evidence. All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real. +## Demo + +The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van +rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app +guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page +all make the fictional context, synthetic-data status, and real-vs-simulated boundaries +explicit without any verbal explanation. See `docs/demo-release/` for the full demo +concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour +design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy, +rollback). + ## Scope The PoC implements: @@ -77,6 +88,9 @@ and `artifacts/final-acceptance/summary.md` for the original M0–M7 acceptance - `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures. - `docs/functional-completion/` — the functional-completion audit and pre-work server baseline. - `artifacts/functional-completion/` — functional-completion acceptance evidence. +- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided + tour, and runbook. +- `artifacts/demo-release/` — demo-productization acceptance evidence. ## Quickstart @@ -100,9 +114,9 @@ All defaults are configurable via `.env` (see `.env.example`). ## Quality gates ```bash -make test # backend: pytest (117 tests) +make test # backend: pytest (127 tests) make lint # backend: ruff + mypy (strict, zero errors) -make e2e # frontend: Playwright end-to-end (37 tests, live stack required) +make e2e # frontend: Playwright end-to-end (56 tests, live stack required) ``` Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`). diff --git a/artifacts/demo-release/final-summary.md b/artifacts/demo-release/final-summary.md new file mode 100644 index 0000000..cf311b5 --- /dev/null +++ b/artifacts/demo-release/final-summary.md @@ -0,0 +1,195 @@ +# Demo-productization final summary + +## Branches and commits + +- **Gitea repository**: `ssh://git@192.168.10.150:222/Jens/MobilityOps.git` (browsable at + `http://192.168.10.150:3000/Jens/MobilityOps`) +- **Branch**: `feat/mobilityops-functional-completion` (no new branch created; no merge + to `main`; no rebase/reset/squash/force-push; full git history preserved, as required) +- **Start commit** (functional-completion baseline, already accepted): + `e0c7ed60112510687627d20a957af91c8b9db7f8` +- **Final commit**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8` — verified via + `git rev-parse HEAD` on `feat/mobilityops-functional-completion` and confirmed to match + `/mnt/user/appdata/mobilityops/.deploy/source-revision` on the Unraid server exactly. + (This corrects a self-reference gap in the immediately preceding pair of commits, which + necessarily could not know their own hash at the time they were written; this is now + the single, unambiguous, verified reference. The repository's primary branch is + `master`, not `main` — no branch named `main` exists in this repository.) +- **Live URL**: `http://192.168.10.150:1236` + +## Demo organisation and context + +**Northstar Mobility** — a fictitious Belgian camper/van rental company (~50 vehicles, +one main location, rental team, an Operations Manager, a small workshop). This name was +already a locked internal decision (`ragcore_tenant: northstar-mobility-demo`, +`PROJECT_STATE.md`'s "Locked decisions") before this work — this pass surfaces it in the +UI rather than inventing it. Full concept: `docs/demo-release/demo-concept.md`. + +## Roles + +- **Operations Manager** — full access: data-quality resolution, workflow retries, audit + trail, demo reset, the Demo Guide. +- **Rental Employee** — scoped access: bookings, returns, fleet, knowledge assistant. + +Both are reachable from the login screen with no password. + +## Demo Guide + +An 8-step, sessionStorage-persisted guided tour (Operations-Manager-only, since every +step requires that role). Full design: `docs/demo-release/demo-guide.md`. Steps: (1) +understand operational state, (2) open the booking needing attention, (3) process the +odometer-anomaly return, (4) handle the created data-quality issue, (5) merge the +duplicate customer, (6) ask the knowledge assistant, (7) check automation + audit, (8) +review real vs. synthetic vs. not-connected. + +## Scenarios (all 5, full detail in `docs/demo-release/demo-scenarios.md`) + +| # | Scenario | Fixed records | Role | +|---|---|---|---| +| 1 | Odometer regression on return | `BK-DEMO-RETURN` / `MO-024` | Either | +| 2 | Possible duplicate customer | `CUS-0012` / `CUS-0178` / `DQ-DEMO-DUPLICATE` | OM | +| 3 | Overlapping bookings | `MO-016` / `BK-DEMO-OVERLAP-A/B` / `DQ-DEMO-OVERLAP` | OM | +| 4 | Failed automation, retried | outbox event `...020` / `BK-H-0020` | OM | +| 5 | Grounded procedure question | (no fixed record; suggested questions) | Either | + +`GET /api/v1/demo/manifest`'s `scenarios` array derives `ready`/`blocked_reason` from the +live underlying records, never hardcoded — confirmed via `backend/tests/ +test_demo_manifest.py` (`test_demo_manifest_scenarios_ready_after_fresh_reset`) and +live-checked after every reset throughout this work. + +## Seed strategy and date-anchoring + +`seed/generate_seed.py --anchor 2026-08-01 --seed 20260801` produces deterministic CSVs +with absolute timestamps authored against a fixed anchor. `backend/app/seed_loader.py` +shifts every seeded datetime by `(real today − authored anchor)` on every seed/reset, so +"today"/"near-future"/"currently overlapping" scenarios stay true to the actual reset +moment instead of decaying. This fixed a real, confirmed bug (`BK-DEMO-RETURN` was found +sitting 2 days in the past before this fix). Full detail: `docs/demo-release/demo-data.md`. + +## Reset strategy + +`POST /api/v1/demo/reset` (Operations Manager only, gated by `DEMO_ALLOW_RESET`) clears +MobilityOps's own tables, reseeds with a fresh date anchor, re-runs the data-quality scan, +and runs a server-side scenario-integrity check (`scenario_integrity_report()`) recorded +in both the response and the `demo_reset` audit event. Reachable from the sidebar, the +Demo Guide, and the About page. Never touches shared n8n/RAGcore/MCP data, other +containers, or volumes. + +## Real vs. synthetic vs. not-connected + +See `docs/demo-release/demo-concept.md` for the full breakdown. In short: auth/roles, +vehicle/booking management, return preview/commit, the 5 data-quality rules and their +resolutions, the audit trail, n8n orchestration, Docker deployment, and the automated +test suite are all really implemented. The organisation, all people, vehicles, bookings, +procedures, and the 5 named scenarios are synthetic. RAGcore and the ITWorx MCP Hub are +not live-connected (honestly labelled "Demomodus"/"Niet gekoppeld" everywhere, never a +fabricated success). + +## Test results + +### Backend (clean checkout, isolated stack) +- `pytest`: **127 passed** +- `ruff check .`: clean +- `mypy app`: clean (48 source files) + +### Frontend (clean checkout, isolated stack) +- `npm ci`: clean (pre-existing esbuild-moderate/react-router-RSC-high advisories, + unchanged from before this work — not introduced by it) +- `tsc -b`: clean +- `npm run build`: clean +- Full Playwright suite: **56 passed** (against the isolated clean-checkout stack) + +### Guided-demo test +`frontend/e2e/guided-demo-full.spec.ts` — one comprehensive test walking a fresh +Operations Manager session through all 8 Demo Guide steps performing the real action at +each step (processes the actual odometer-anomaly return, resolves the resulting +data-quality issue, merges the duplicate customer, asks a suggested knowledge question, +checks automation + audit, reviews the About page), then resets the demo data again to +restore the environment. **Passed**, confirmed stable across repeated runs both locally +and against the live Unraid deployment. + +### Clean-checkout drill +Fresh `git clone` of this branch/commit into an isolated scratch directory, `.env` from +`.env.example`, isolated Compose project name (`mobilityops-cleandrill`) and remapped +host ports (`compose.override.yaml` with `!override` merge tags — no shared state with +any other stack), `docker compose up --build -d` from empty volumes → migrations ran +automatically (`e7b08389f47f (head)`) → seeded → full backend gate (127 passed, ruff/ +mypy clean) → `npm ci`/`tsc -b`/`vite build` clean → full Playwright suite (56 passed) +→ reseeded and confirmed all 5 scenarios `ready: true` via the manifest → torn down +(`docker compose down -v` on the isolated project only; the working dev stack was never +touched). + +### Server deployment +Deployed incrementally after every batch (10 deploy cycles across this work); final +state: both `api` and `web` rebuilt and healthy at the final commit, `db` untouched +across all of them (no destructive migrations on this branch). Migrations at +`e7b08389f47f (head)` throughout. `.deploy/source-revision` on the server matches the +final commit exactly. + +### Container health +`docker compose ps` on the server: `api`, `db`, `web` all `healthy`, no restart loops. + +### Browser console / network +No unexpected console errors on login, dashboard, scenarios, About, or with the Demo +Guide open (verified via `demo-accessibility.spec.ts`; the one benign 401 from the app's +own session-probe on first load is expected and explicitly accounted for, not silenced +blindly). No unresolved server errors in `docker logs` for `api`/`web` at the time of +this evidence capture. + +### Responsive / accessibility +- Demo Guide renders as a correctly-anchored bottom sheet at 390px with no horizontal + overflow (`demo-accessibility.spec.ts`). +- **Real bug found and fixed**: the Demo Guide's fixed desktop side panel overlapped + main content with no reflow, making the return form's "Review return" button + unclickable while the guide was open at ordinary desktop widths — this surfaced while + writing the full guided-demo test. Fixed via a `guide-open` layout class that reserves + space for the panel; regression-tested. +- Demo badge and Demo Guide triggers are keyboard-focusable and operable (Enter to open, + explicit close controls). +- Existing responsive-overflow checks (390/768/1280/1440px) remain green throughout. + +## Known limitations + +- RAGcore and the ITWorx MCP Hub are not live-connected in this environment (by design + — see scope). The knowledge assistant uses a local, English-only demo knowledge base; + a Dutch question against it returns "insufficient evidence" (verified empirically), so + suggested questions and the Demo Guide's step 6 instructions deliberately stay in + English rather than silently breaking the demo's centerpiece grounded-answer feature. +- Existing operational screens (Dashboard, Vehicles, Bookings, Data Quality workbench, + Audit, Automation internals) remain in English; only new demo-productization surfaces + (login, Demo Guide, scenario overview, About page, demo badge, plain-language + integration labels) are in Dutch — a deliberate, documented scope decision, not an + oversight (`docs/demo-release/current-demo-gap-audit.md`, gap #11). +- Scenario S3 ("missing inspection before next booking", `MO-031`) is seeded and visible + in the attention queue but isn't one of the 5 scenarios surfaced on `/scenarios`, + matching the brief's request for exactly 5. + +## 5-minute and 10-minute demo flows + +See `docs/demo-release/demo-runbook.md` for the exact click-through scripts. + +## Redeploy commands and rollback procedure + +See `docs/demo-release/demo-runbook.md` — `git archive` → `scp` → extract → rebuild +`api`/`web` → confirm migrations → reseed. Rollback: extract an earlier +`.deploy/source-.tar.gz` and update `.deploy/source-revision` to match. + +## Evidence screenshots + +All captured live against `http://192.168.10.150:1236` (`artifacts/demo-release/screenshots/`): + +1. `01-demo-entry-desktop.png` / `02-demo-entry-mobile.png` — demo entry, both sizes +2. `03-dashboard-with-scenarios.png` — dashboard with the scenario teaser panel +3. `04-demo-guide.png` — the Demo Guide panel open +4. `05-return-preview.png` / `06-return-result.png` — the return flow +5. `07-data-quality-resolution.png` — a data-quality issue with its plain-language explainer +6. `08-duplicate-customer-merge.png` — the duplicate-customer comparison/merge UI +7. `09-knowledge-assistant.png` — a grounded answer with cited sources +8. `10-integration-status.png` — plain-language integration status on Automation +9. `11-automation-retry-before.png` / `11-automation-retry-after.png` — a workflow retry +10. `12-audit-trail.png` / `13-audit-related-events.png` — audit trail + correlation drill-down +11. `14-about-demo.png` — the About page +12. `15-demo-badge-popover.png` — the permanent synthetic-demo badge popover +13. `16-reset-confirm.png` — the reset confirmation flow + +No secrets appear in any screenshot or in this document. diff --git a/artifacts/demo-release/screenshots/01-demo-entry-desktop.png b/artifacts/demo-release/screenshots/01-demo-entry-desktop.png new file mode 100644 index 0000000..b5f6a9b Binary files /dev/null and b/artifacts/demo-release/screenshots/01-demo-entry-desktop.png differ diff --git a/artifacts/demo-release/screenshots/02-demo-entry-mobile.png b/artifacts/demo-release/screenshots/02-demo-entry-mobile.png new file mode 100644 index 0000000..9fa5b7d Binary files /dev/null and b/artifacts/demo-release/screenshots/02-demo-entry-mobile.png differ diff --git a/artifacts/demo-release/screenshots/03-dashboard-with-scenarios.png b/artifacts/demo-release/screenshots/03-dashboard-with-scenarios.png new file mode 100644 index 0000000..96eaea9 Binary files /dev/null and b/artifacts/demo-release/screenshots/03-dashboard-with-scenarios.png differ diff --git a/artifacts/demo-release/screenshots/04-demo-guide.png b/artifacts/demo-release/screenshots/04-demo-guide.png new file mode 100644 index 0000000..1935145 Binary files /dev/null and b/artifacts/demo-release/screenshots/04-demo-guide.png differ diff --git a/artifacts/demo-release/screenshots/05-return-preview.png b/artifacts/demo-release/screenshots/05-return-preview.png new file mode 100644 index 0000000..09995d2 Binary files /dev/null and b/artifacts/demo-release/screenshots/05-return-preview.png differ diff --git a/artifacts/demo-release/screenshots/06-return-result.png b/artifacts/demo-release/screenshots/06-return-result.png new file mode 100644 index 0000000..768c491 Binary files /dev/null and b/artifacts/demo-release/screenshots/06-return-result.png differ diff --git a/artifacts/demo-release/screenshots/07-data-quality-resolution.png b/artifacts/demo-release/screenshots/07-data-quality-resolution.png new file mode 100644 index 0000000..3fd60bc Binary files /dev/null and b/artifacts/demo-release/screenshots/07-data-quality-resolution.png differ diff --git a/artifacts/demo-release/screenshots/08-duplicate-customer-merge.png b/artifacts/demo-release/screenshots/08-duplicate-customer-merge.png new file mode 100644 index 0000000..8d2f21b Binary files /dev/null and b/artifacts/demo-release/screenshots/08-duplicate-customer-merge.png differ diff --git a/artifacts/demo-release/screenshots/09-knowledge-assistant.png b/artifacts/demo-release/screenshots/09-knowledge-assistant.png new file mode 100644 index 0000000..36f6bd1 Binary files /dev/null and b/artifacts/demo-release/screenshots/09-knowledge-assistant.png differ diff --git a/artifacts/demo-release/screenshots/10-integration-status.png b/artifacts/demo-release/screenshots/10-integration-status.png new file mode 100644 index 0000000..5891547 Binary files /dev/null and b/artifacts/demo-release/screenshots/10-integration-status.png differ diff --git a/artifacts/demo-release/screenshots/11-automation-retry-after.png b/artifacts/demo-release/screenshots/11-automation-retry-after.png new file mode 100644 index 0000000..270f681 Binary files /dev/null and b/artifacts/demo-release/screenshots/11-automation-retry-after.png differ diff --git a/artifacts/demo-release/screenshots/11-automation-retry-before.png b/artifacts/demo-release/screenshots/11-automation-retry-before.png new file mode 100644 index 0000000..5630a68 Binary files /dev/null and b/artifacts/demo-release/screenshots/11-automation-retry-before.png differ diff --git a/artifacts/demo-release/screenshots/12-audit-trail.png b/artifacts/demo-release/screenshots/12-audit-trail.png new file mode 100644 index 0000000..a18b6a1 Binary files /dev/null and b/artifacts/demo-release/screenshots/12-audit-trail.png differ diff --git a/artifacts/demo-release/screenshots/13-audit-related-events.png b/artifacts/demo-release/screenshots/13-audit-related-events.png new file mode 100644 index 0000000..e7b0d6f Binary files /dev/null and b/artifacts/demo-release/screenshots/13-audit-related-events.png differ diff --git a/artifacts/demo-release/screenshots/14-about-demo.png b/artifacts/demo-release/screenshots/14-about-demo.png new file mode 100644 index 0000000..5c0b96c Binary files /dev/null and b/artifacts/demo-release/screenshots/14-about-demo.png differ diff --git a/artifacts/demo-release/screenshots/15-demo-badge-popover.png b/artifacts/demo-release/screenshots/15-demo-badge-popover.png new file mode 100644 index 0000000..7d6a360 Binary files /dev/null and b/artifacts/demo-release/screenshots/15-demo-badge-popover.png differ diff --git a/artifacts/demo-release/screenshots/16-reset-confirm.png b/artifacts/demo-release/screenshots/16-reset-confirm.png new file mode 100644 index 0000000..f1eec84 Binary files /dev/null and b/artifacts/demo-release/screenshots/16-reset-confirm.png differ diff --git a/backend/app/api/routers/dashboard.py b/backend/app/api/routers/dashboard.py index 7042107..3385bec 100644 --- a/backend/app/api/routers/dashboard.py +++ b/backend/app/api/routers/dashboard.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import UTC, date, datetime from typing import Literal from fastapi import APIRouter, Depends @@ -30,7 +30,9 @@ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} def _today() -> date: - return datetime.fromisoformat(settings.demo_today).date() + # Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor + # shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting. + return datetime.now(UTC).date() @router.get("", response_model=DashboardOut) @@ -59,12 +61,11 @@ def get_dashboard( entity = customers_by_id.get(issue.entity_id) link_type = "customer" link_ref = entity.public_ref if entity else "" - title = f"{issue.rule_type.replace('_', ' ').title()} — {link_ref}" attention_items.append( AttentionItem( kind="quality_issue", severity=issue.severity, - title=title, + rule_type=issue.rule_type, detail=issue.evidence_json.get("summary", ""), link_type=link_type, link_ref=link_ref, diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index 85c3b6f..fd06a68 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -3,7 +3,7 @@ from __future__ import annotations import time import uuid -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -11,14 +11,23 @@ from app.api.deps import get_current_user, get_db, require_operations_manager from app.core.config import get_settings from app.core.security import SessionPayload, create_session_token, read_session_token from app.models.user import User -from app.schemas import CurrentUser, DemoLoginRequest +from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut from app.seed_loader import reset_and_seed from app.services.audit import record_audit_event +from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) settings = get_settings() +@router.get("/manifest", response_model=DemoManifestOut) +def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut: + # Deliberately unauthenticated: the demo-entry screen and the permanent demo badge + # both need this before any session exists. Nothing here is sensitive — it's the same + # honest "what is this demo" summary a logged-in user would see. + return build_demo_manifest(db) + + @router.post("/login", response_model=CurrentUser) def demo_login( body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) @@ -92,15 +101,31 @@ def demo_reset( db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> dict: + if not settings.demo_allow_reset: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Demo reset is disabled on this deployment.", + ) result = reset_and_seed(db) + integrity = scenario_integrity_report(db) record_audit_event( db, actor_type="user", actor_label=user.display_name, action="demo_reset", entity_type="system", - metadata={"counts": result.counts}, + metadata={ + "counts": result.counts, + "anchor_date": result.anchor_date.isoformat(), + "scenario_integrity": integrity, + }, ) db.commit() response.delete_cookie(settings.session_cookie_name) - return {"status": "reset", "counts": result.counts} + return { + "status": "reset", + "counts": result.counts, + "anchor_date": result.anchor_date.isoformat(), + "seeded_at": result.seeded_at.isoformat(), + "scenario_integrity": integrity, + } diff --git a/backend/app/api/routers/integration_status.py b/backend/app/api/routers/integration_status.py index b337709..c1f08f8 100644 --- a/backend/app/api/routers/integration_status.py +++ b/backend/app/api/routers/integration_status.py @@ -1,75 +1,28 @@ from __future__ import annotations -from typing import Literal - from fastapi import APIRouter, Depends -from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.deps import get_db, require_operations_manager from app.core.config import get_settings -from app.models.outbox import OutboxEvent from app.schemas import ( CurrentUser, IntegrationStatusOut, McpHubIntegrationStatus, - N8nIntegrationStatus, ) +from app.services.integration_status import derive_n8n_status router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) settings = get_settings() -def _n8n_status(db: Session) -> N8nIntegrationStatus: - counts: dict[str, int] = dict( - db.execute( - select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status) - ).all() # type: ignore[arg-type] - ) - pending = counts.get("pending", 0) - delivering = counts.get("delivering", 0) - failed = counts.get("failed", 0) - succeeded = counts.get("succeeded", 0) - - latest_success_at = db.scalar( - select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") - ) - latest_failure_at = db.scalar( - select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") - ) - - state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] - if not settings.n8n_dispatch_enabled: - state = "disabled" - elif failed > 0 and succeeded == 0: - state = "unavailable" - elif failed > 0: - state = "degraded" - elif succeeded > 0 or pending > 0 or delivering > 0: - state = "operational" - else: - state = "no_evidence" - - return N8nIntegrationStatus( - configured=bool(settings.n8n_webhook_url), - dispatch_enabled=settings.n8n_dispatch_enabled, - state=state, - pending=pending, - delivering=delivering, - failed=failed, - succeeded=succeeded, - latest_success_at=latest_success_at, - latest_failure_at=latest_failure_at, - ) - - @router.get("/status", response_model=IntegrationStatusOut) def integration_status( db: Session = Depends(get_db), _user: CurrentUser = Depends(require_operations_manager), ) -> IntegrationStatusOut: return IntegrationStatusOut( - n8n=_n8n_status(db), + n8n=derive_n8n_status(db), mcp_hub=McpHubIntegrationStatus( registration_enabled=settings.mcp_hub_registration_enabled, state="configured" if settings.mcp_hub_registration_enabled else "not_configured", diff --git a/backend/app/api/routers/knowledge.py b/backend/app/api/routers/knowledge.py index ae15346..340d4f2 100644 --- a/backend/app/api/routers/knowledge.py +++ b/backend/app/api/routers/knowledge.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from typing import Literal from fastapi import APIRouter, Depends from pydantic import BaseModel, Field @@ -13,9 +14,12 @@ from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledg router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) +SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"] + class AskQuestionRequest(BaseModel): question: str = Field(min_length=3, max_length=1000) + language: SupportedLanguage = "en-GB" @router.post("/questions", response_model=GroundedAnswer) @@ -26,7 +30,7 @@ def ask_question( ) -> GroundedAnswer: correlation_id = str(uuid.uuid4()) provider = get_knowledge_provider() - answer = provider.ask(body.question, correlation_id) + answer = provider.ask(body.question, correlation_id, body.language) record_audit_event( db, @@ -40,6 +44,7 @@ def ask_question( "provider": answer.provider, "source_ids": [s.document_id for s in answer.sources], "question_length": len(body.question), + "language": body.language, }, ) db.commit() @@ -47,5 +52,8 @@ def ask_question( @router.get("/status", response_model=KnowledgeHealth) -def knowledge_status(_user: CurrentUser = Depends(get_current_user)) -> KnowledgeHealth: - return get_knowledge_provider().health() +def knowledge_status( + language: SupportedLanguage = "en-GB", + _user: CurrentUser = Depends(get_current_user), +) -> KnowledgeHealth: + return get_knowledge_provider().health(language) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 14470d6..62cb07a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -32,7 +32,9 @@ class Settings(BaseSettings): mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_registration_enabled: bool = False cors_allow_origins: str = "http://localhost:1228" - demo_today: str = "2026-08-01" + demo_organization_name: str = "Northstar Mobility" + demo_timezone: str = "Europe/Brussels" + demo_allow_reset: bool = True @lru_cache diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ad3836b..00c8bfc 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -197,6 +197,37 @@ class IntegrationStatusOut(BaseModel): mcp_hub: McpHubIntegrationStatus +class DemoScenarioOut(BaseModel): + id: str + estimated_minutes: int + required_roles: list[Role] + start_path: str + ready: bool + blocked_reason_code: str | None = None + blocked_reason_params: dict[str, str] = {} + + +class DemoIntegrationSummaryOut(BaseModel): + key: Literal["n8n", "ragcore", "mcp_hub"] + status_code: str + detail_code: str + detail_params: dict[str, str | int] = {} + + +class DemoManifestOut(BaseModel): + demo_mode: bool + organization_name: str + timezone: str + synthetic_data: bool + allow_reset: bool + last_reset_at: datetime | None + anchor_date: str | None + guide_available: bool + required_roles: list[Role] + scenarios: list[DemoScenarioOut] + integrations: list[DemoIntegrationSummaryOut] + + class VehicleDetailOut(VehicleOut): bookings: list[BookingSummaryOut] = Field(default_factory=list) inspections: list[InspectionOut] = Field(default_factory=list) @@ -217,7 +248,7 @@ class DashboardMetrics(BaseModel): class AttentionItem(BaseModel): kind: Literal["quality_issue", "vehicle"] severity: str - title: str + rule_type: str detail: str link_type: Literal["vehicle", "booking", "customer"] link_ref: str diff --git a/backend/app/seed_loader.py b/backend/app/seed_loader.py index 041cc05..938d219 100644 --- a/backend/app/seed_loader.py +++ b/backend/app/seed_loader.py @@ -3,7 +3,7 @@ from __future__ import annotations import csv import uuid from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, date, datetime, timedelta from pathlib import Path from sqlalchemy import delete, insert, update @@ -20,6 +20,7 @@ from app.models.maintenance import MaintenanceRecord from app.models.outbox import OutboxEvent from app.models.user import User from app.models.vehicle import Vehicle +from app.services.audit import record_audit_event settings = get_settings() @@ -36,6 +37,17 @@ DEMO_USERS = [ }, ] +# seed/generate_seed.py authored the committed CSVs relative to this fixed date +# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every +# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps +# right now" scenarios stay true to the actual reset moment instead of decaying as real +# time passes between resets -- a fixed anchor with no shift goes stale within days. +SEED_AUTHORED_ANCHOR = date(2026, 8, 1) + + +def _seed_anchor_shift(today: date) -> timedelta: + return today - SEED_AUTHORED_ANCHOR + def _parse_dt(value: str) -> datetime: return datetime.fromisoformat(value.replace("Z", "+00:00")) @@ -53,6 +65,8 @@ def _parse_optional_int(value: str) -> int | None: @dataclass class SeedResult: counts: dict[str, int] + anchor_date: date + seeded_at: datetime def _seed_dir() -> Path: @@ -83,6 +97,8 @@ def clear_all(db: Session) -> None: def load_seed(db: Session) -> SeedResult: counts: dict[str, int] = {} + today = datetime.now(UTC).date() + shift = _seed_anchor_shift(today) user_rows = [ {"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS @@ -154,8 +170,8 @@ def load_seed(db: Session) -> SeedResult: "public_ref": row["public_ref"], "customer_id": customer_id_by_ref[row["customer_ref"]], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], - "starts_at": _parse_dt(row["starts_at"]), - "ends_at": _parse_dt(row["ends_at"]), + "starts_at": _parse_dt(row["starts_at"]) + shift, + "ends_at": _parse_dt(row["ends_at"]) + shift, "status": row["status"], "start_odometer_km": _parse_optional_int(row["start_odometer_km"]), "end_odometer_km": _parse_optional_int(row["end_odometer_km"]), @@ -179,7 +195,7 @@ def load_seed(db: Session) -> SeedResult: "damage_reported": _parse_bool(row["damage_reported"]), "technical_warning": _parse_bool(row["technical_warning"]), "odometer_km": int(row["odometer_km"]), - "completed_at": _parse_dt(row["completed_at"]), + "completed_at": _parse_dt(row["completed_at"]) + shift, "completed_by": None, } ) @@ -193,7 +209,7 @@ def load_seed(db: Session) -> SeedResult: "id": uuid.uuid4(), "public_ref": row["public_ref"], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], - "occurred_at": _parse_dt(row["occurred_at"]), + "occurred_at": _parse_dt(row["occurred_at"]) + shift, "odometer_km": int(row["odometer_km"]), "category": row["category"], "summary": row["summary"], @@ -266,7 +282,7 @@ def load_seed(db: Session) -> SeedResult: }, "aggregate_ref": row["aggregate_ref"], }, - "occurred_at": _parse_dt(row["occurred_at"]), + "occurred_at": _parse_dt(row["occurred_at"]) + shift, "delivery_status": row["status"], "attempts": int(row["attempts"]), "next_attempt_at": None, @@ -277,7 +293,21 @@ def load_seed(db: Session) -> SeedResult: db.execute(insert(OutboxEvent), outbox_rows) counts["workflow_runs"] = len(outbox_rows) - return SeedResult(counts=counts) + seeded_at = datetime.now(UTC) + record_audit_event( + db, + actor_type="system", + actor_label="seed loader", + action="demo_data_seeded", + entity_type="system", + metadata={ + "anchor_date": today.isoformat(), + "seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(), + "counts": counts, + }, + ) + + return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at) def reset_and_seed(db: Session) -> SeedResult: diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py new file mode 100644 index 0000000..52ac618 --- /dev/null +++ b/backend/app/services/demo_manifest.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.audit import AuditEvent +from app.models.booking import Booking +from app.models.data_quality import DataQualityIssue +from app.models.outbox import OutboxEvent +from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut +from app.services.integration_status import derive_n8n_status +from app.services.knowledge import get_knowledge_provider + +settings = get_settings() + +_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020" + + +def _last_reset(db: Session) -> tuple[datetime | None, str | None]: + marker = db.scalar( + select(AuditEvent) + .where(AuditEvent.action == "demo_data_seeded") + .order_by(AuditEvent.occurred_at.desc()) + ) + if marker is None: + return None, None + metadata = marker.metadata_json or {} + return marker.occurred_at, metadata.get("anchor_date") + + +def _scenarios(db: Session) -> list[DemoScenarioOut]: + booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN")) + duplicate_issue = db.scalar( + select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE") + ) + overlap_issue = db.scalar( + select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP") + ) + failed_run = db.scalar( + select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID) + ) + knowledge_health = get_knowledge_provider().health() + + # Human copy (title, problem statement, "demonstrates" summary) lives entirely in the + # frontend's demo.json (scenarios.items..*) so it's available in all three UI + # languages. This service only emits stable identifiers and message codes -- never + # display prose -- per the message_code + params architecture used across the app. + return_ready = bool( + booking and booking.status == "active" and booking.end_odometer_km is None + ) + duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open") + overlap_ready = bool(overlap_issue and overlap_issue.status == "open") + automation_ready = bool(failed_run and failed_run.delivery_status == "failed") + + return [ + DemoScenarioOut( + id="return-anomaly", + estimated_minutes=3, + required_roles=["rental_employee", "operations_manager"], + start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings", + ready=return_ready, + blocked_reason_code=( + None + if return_ready + else "bookingNotFound" if booking is None else "bookingAlreadyProcessed" + ), + ), + DemoScenarioOut( + id="duplicate-customer", + estimated_minutes=3, + required_roles=["operations_manager"], + start_path=( + f"/data-quality/{duplicate_issue.public_ref}" + if duplicate_issue + else "/data-quality" + ), + ready=duplicate_ready, + blocked_reason_code=( + None + if duplicate_ready + else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved" + ), + ), + DemoScenarioOut( + id="booking-overlap", + estimated_minutes=2, + required_roles=["operations_manager"], + start_path=( + f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality" + ), + ready=overlap_ready, + blocked_reason_code=( + None + if overlap_ready + else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved" + ), + ), + DemoScenarioOut( + id="automation-retry", + estimated_minutes=2, + required_roles=["operations_manager"], + start_path="/automation", + ready=automation_ready, + blocked_reason_code=( + None + if automation_ready + else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered" + ), + ), + DemoScenarioOut( + id="knowledge-question", + estimated_minutes=2, + required_roles=["rental_employee", "operations_manager"], + start_path="/knowledge", + ready=knowledge_health.available, + blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable", + ), + ] + + +def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: + n8n = derive_n8n_status(db) + knowledge_health = get_knowledge_provider().health() + + return [ + DemoIntegrationSummaryOut( + key="n8n", + status_code=n8n.state, + detail_code="n8nDetail", + detail_params={ + "succeeded": n8n.succeeded, + "failed": n8n.failed, + "pending": n8n.pending, + }, + ), + DemoIntegrationSummaryOut( + key="ragcore", + status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode", + detail_code="ragcoreDetail", + detail_params={ + "count": knowledge_health.document_count, + "collection": knowledge_health.collection, + }, + ), + DemoIntegrationSummaryOut( + key="mcp_hub", + status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected", + detail_code=( + "mcpDetailEnabled" + if settings.mcp_hub_registration_enabled + else "mcpDetailNotConnected" + ), + detail_params={}, + ), + ] + + +def scenario_integrity_report(db: Session) -> dict: + """Server-side scenario-integrity check run after every reset (section 15): confirms + each of the 5 named scenarios is actually present and ready, rather than trusting the + seed loader silently. Reuses the same readiness derivation the manifest/scenario + overview already use, so this can never drift from what a visitor actually sees.""" + scenarios = _scenarios(db) + not_ready = [ + {"id": s.id, "reason_code": s.blocked_reason_code} + for s in scenarios + if not s.ready + ] + return {"all_ready": len(not_ready) == 0, "not_ready": not_ready} + + +def build_demo_manifest(db: Session) -> DemoManifestOut: + last_reset_at, anchor_date = _last_reset(db) + return DemoManifestOut( + demo_mode=settings.mobilityops_demo_mode, + organization_name=settings.demo_organization_name, + timezone=settings.demo_timezone, + synthetic_data=True, + allow_reset=settings.demo_allow_reset, + last_reset_at=last_reset_at, + anchor_date=anchor_date, + guide_available=True, + required_roles=["operations_manager", "rental_employee"], + scenarios=_scenarios(db), + integrations=_integrations(db), + ) diff --git a/backend/app/services/integration_status.py b/backend/app/services/integration_status.py new file mode 100644 index 0000000..d647272 --- /dev/null +++ b/backend/app/services/integration_status.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Literal + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.outbox import OutboxEvent +from app.schemas import N8nIntegrationStatus + +settings = get_settings() + + +def derive_n8n_status(db: Session) -> N8nIntegrationStatus: + counts: dict[str, int] = dict( + db.execute( + select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status) + ).all() # type: ignore[arg-type] + ) + pending = counts.get("pending", 0) + delivering = counts.get("delivering", 0) + failed = counts.get("failed", 0) + succeeded = counts.get("succeeded", 0) + + latest_success_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") + ) + latest_failure_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") + ) + + state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] + if not settings.n8n_dispatch_enabled: + state = "disabled" + elif failed > 0 and succeeded == 0: + state = "unavailable" + elif failed > 0: + state = "degraded" + elif succeeded > 0 or pending > 0 or delivering > 0: + state = "operational" + else: + state = "no_evidence" + + return N8nIntegrationStatus( + configured=bool(settings.n8n_webhook_url), + dispatch_enabled=settings.n8n_dispatch_enabled, + state=state, + pending=pending, + delivering=delivering, + failed=failed, + succeeded=succeeded, + latest_success_at=latest_success_at, + latest_failure_at=latest_failure_at, + ) diff --git a/backend/app/services/knowledge/__init__.py b/backend/app/services/knowledge/__init__.py index 48e5bcb..32c4896 100644 --- a/backend/app/services/knowledge/__init__.py +++ b/backend/app/services/knowledge/__init__.py @@ -39,9 +39,11 @@ class KnowledgeHealth(BaseModel): class KnowledgeProvider(Protocol): name: str - def health(self) -> KnowledgeHealth: ... + def health(self, language: str = "en-GB") -> KnowledgeHealth: ... - def ask(self, question: str, correlation_id: str) -> GroundedAnswer: ... + def ask( + self, question: str, correlation_id: str, language: str = "en-GB" + ) -> GroundedAnswer: ... @lru_cache diff --git a/backend/app/services/knowledge/demo.py b/backend/app/services/knowledge/demo.py index 7ab7a88..58e9433 100644 --- a/backend/app/services/knowledge/demo.py +++ b/backend/app/services/knowledge/demo.py @@ -8,12 +8,31 @@ from pathlib import Path from app.core.config import get_settings from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard -STOPWORDS = { - "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", - "to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then", - "do", "does", "did", "must", "may", "can", "could", "should", "would", - "i", "you", "it", "we", "they", "my", "your", "what", "when", "how", - "with", "without", "this", "that", "these", "those", "not", "no", +SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE") +DEFAULT_LANGUAGE = "en-GB" + +STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = { + "en-GB": { + "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", + "to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then", + "do", "does", "did", "must", "may", "can", "could", "should", "would", + "i", "you", "it", "we", "they", "my", "your", "what", "when", "how", + "with", "without", "this", "that", "these", "those", "not", "no", + }, + "nl-BE": { + "een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt", + "van", "in", "op", "voor", "en", "of", "maar", "als", "dan", + "moet", "mag", "kan", "kunnen", "zou", "zouden", + "ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe", + "met", "zonder", "dit", "dat", "deze", "die", "niet", "geen", + }, + "fr-BE": { + "un", "une", "le", "la", "les", "des", "est", "sont", "était", "être", + "de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors", + "doit", "peut", "peuvent", "pourrait", "devrait", + "je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment", + "avec", "sans", "ce", "cette", "ces", "cela", "pas", "non", + }, } _WORD_RE = re.compile(r"[a-z0-9]+") @@ -28,9 +47,10 @@ def _stem(word: str) -> str: return word -def _tokenize(text: str) -> set[str]: +def _tokenize(text: str, language: str) -> set[str]: + stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE]) words = _WORD_RE.findall(text.lower()) - return {_stem(w) for w in words if w not in STOPWORDS and len(w) > 2} + return {_stem(w) for w in words if w not in stopwords and len(w) > 2} @dataclass @@ -86,7 +106,7 @@ def _split_sections(body: str) -> list[tuple[str, str]]: return sections -def _load_sections(procedures_dir: Path) -> list[ScoredSection]: +def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]: sections: list[ScoredSection] = [] for path in sorted(procedures_dir.glob("*.md")): raw = path.read_text(encoding="utf-8") @@ -96,7 +116,7 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]: document_id=meta.get("document_id", path.stem), title=title, version=meta.get("version", "1.0"), - title_tokens=_tokenize(title), + title_tokens=_tokenize(title, language), ) for heading, text in _split_sections(body): sections.append( @@ -104,19 +124,49 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]: document=doc, heading=heading, text=text, - heading_tokens=_tokenize(heading), - body_tokens=_tokenize(text), + heading_tokens=_tokenize(heading, language), + body_tokens=_tokenize(text, language), ) ) return sections +_NO_MATCH_TEXT = { + "en-GB": "No matching procedure was found for this question.", + "nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.", + "fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.", +} +_LOW_CONFIDENCE_TEXT = { + "en-GB": ( + "The available procedures do not clearly answer this question. " + "The closest matches are included below for review." + ), + "nl-BE": ( + "De beschikbare procedures beantwoorden deze vraag niet duidelijk. " + "De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling." + ), + "fr-BE": ( + "Les procédures disponibles ne répondent pas clairement à cette question. " + "Les correspondances les plus proches sont indiquées ci-dessous pour examen." + ), +} +_LEAD_ANSWER_TEMPLATE = { + "en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}', + "nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}', + "fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}', +} + + class DemoKnowledgeProvider: """Deterministic extractive retrieval over the local procedure Markdown files. Not a generative model: it scores sections with TF-IDF-weighted keyword overlap (downweighting terms common across the whole corpus, like "vehicle", in favor of distinctive ones, like "damage") and returns real excerpts, never invented text. + + Each supported UI language has its own translated procedure corpus under + knowledge/procedures// -- retrieval searches only within the requested + language's corpus so citations always link to a same-language document. """ name = "demo" @@ -124,10 +174,18 @@ class DemoKnowledgeProvider: def __init__(self) -> None: settings = get_settings() self._settings = settings - self._procedures_dir = Path(settings.knowledge_dir) - self._sections = _load_sections(self._procedures_dir) - self._document_count = len({s.document.document_id for s in self._sections}) - self._idf = self._build_idf(self._sections) + base_dir = Path(settings.knowledge_dir) + self._sections_by_language: dict[str, list[ScoredSection]] = {} + self._idf_by_language: dict[str, dict[str, float]] = {} + self._document_count_by_language: dict[str, int] = {} + for language in SUPPORTED_LANGUAGES: + lang_dir = base_dir / language + sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else [] + self._sections_by_language[language] = sections + self._idf_by_language[language] = self._build_idf(sections) + self._document_count_by_language[language] = len( + {s.document.document_id for s in sections} + ) @staticmethod def _build_idf(sections: list[ScoredSection]) -> dict[str, float]: @@ -140,7 +198,13 @@ class DemoKnowledgeProvider: doc_freq[token] = doc_freq.get(token, 0) + 1 return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()} - def health(self) -> KnowledgeHealth: + def _normalize_language(self, language: str | None) -> str: + if language in SUPPORTED_LANGUAGES: + return language + return DEFAULT_LANGUAGE + + def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth: + language = self._normalize_language(language) return KnowledgeHealth( provider=self.name, available=True, @@ -148,36 +212,40 @@ class DemoKnowledgeProvider: tenant=self._settings.ragcore_tenant, workspace=self._settings.ragcore_workspace, collection=self._settings.ragcore_collection, - document_count=self._document_count, + document_count=self._document_count_by_language[language], ) - def _score(self, query_tokens: set[str], section: ScoredSection) -> float: + def _score( + self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float] + ) -> float: score = 0.0 for token in query_tokens: - idf = self._idf.get(token, 0.0) - if idf == 0.0: + token_idf = idf.get(token, 0.0) + if token_idf == 0.0: continue if token in section.heading_tokens: - score += 3 * idf + score += 3 * token_idf elif token in section.document.title_tokens: - score += 2 * idf + score += 2 * token_idf elif token in section.body_tokens: - score += idf + score += token_idf return score - def ask(self, question: str, correlation_id: str) -> GroundedAnswer: - query_tokens = _tokenize(question) - scored = [ - (self._score(query_tokens, section), section) - for section in self._sections - ] + def ask( + self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE + ) -> GroundedAnswer: + language = self._normalize_language(language) + sections = self._sections_by_language[language] + idf = self._idf_by_language[language] + query_tokens = _tokenize(question, language) + scored = [(self._score(query_tokens, section, idf), section) for section in sections] scored = [(score, section) for score, section in scored if score > 0] scored.sort(key=lambda item: item[0], reverse=True) top = scored[:3] if not top: return GroundedAnswer( - answer="No matching procedure was found for this question.", + answer=_NO_MATCH_TEXT[language], evidence_state="insufficient", sources=[], provider=self.name, @@ -197,10 +265,7 @@ class DemoKnowledgeProvider: if top[0][0] < 3: return GroundedAnswer( - answer=( - "The available procedures do not clearly answer this question. " - "The closest matches are included below for review." - ), + answer=_LOW_CONFIDENCE_TEXT[language], evidence_state="insufficient", sources=sources, provider=self.name, @@ -208,9 +273,11 @@ class DemoKnowledgeProvider: ) lead_section = top[0][1] - answer = ( - f'Per "{lead_section.document.title}" (v{lead_section.document.version}), ' - f'section "{lead_section.heading}": {lead_section.text.splitlines()[0][:300]}' + answer = _LEAD_ANSWER_TEMPLATE[language].format( + title=lead_section.document.title, + version=lead_section.document.version, + heading=lead_section.heading, + excerpt=lead_section.text.splitlines()[0][:300], ) return GroundedAnswer( answer=answer, diff --git a/backend/app/services/knowledge/ragcore.py b/backend/app/services/knowledge/ragcore.py index 88abd0e..b2c55b7 100644 --- a/backend/app/services/knowledge/ragcore.py +++ b/backend/app/services/knowledge/ragcore.py @@ -32,7 +32,7 @@ class RAGcoreKnowledgeProvider: timeout=self._settings.ragcore_http_timeout_seconds, ) - def health(self) -> KnowledgeHealth: + def health(self, language: str = "en-GB") -> KnowledgeHealth: try: with self._client() as client: response = client.get("/health") @@ -52,7 +52,7 @@ class RAGcoreKnowledgeProvider: document_count=0, ) - def ask(self, question: str, correlation_id: str) -> GroundedAnswer: + def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer: try: with self._client() as client: response = client.post( @@ -63,6 +63,7 @@ class RAGcoreKnowledgeProvider: "collection": self._settings.ragcore_collection, "question": question, "correlation_id": correlation_id, + "language": language, }, ) response.raise_for_status() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 8bbeeb8..4cdd169 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -17,7 +17,25 @@ def test_rental_employee_cannot_reset_demo(employee_client): def test_operations_manager_can_reset_demo(ops_client): response = ops_client.post("/api/v1/demo/reset") assert response.status_code == 200 - assert response.json()["counts"]["vehicles"] == 50 + body = response.json() + assert body["counts"]["vehicles"] == 50 + assert body["anchor_date"] + assert body["seeded_at"] + assert body["scenario_integrity"]["all_ready"] is True + assert body["scenario_integrity"]["not_ready"] == [] + + +def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch): + import app.api.routers.demo as demo_router + + monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False) + response = ops_client.post("/api/v1/demo/reset") + assert response.status_code == 403 + + # Restore real demo data: this test intentionally disabled reset, so a following test + # module must not inherit a database left mid-mutation by an earlier test. + monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True) + assert ops_client.post("/api/v1/demo/reset").status_code == 200 def test_session_endpoint_requires_authentication(client): diff --git a/backend/tests/test_demo_manifest.py b/backend/tests/test_demo_manifest.py new file mode 100644 index 0000000..5f822d8 --- /dev/null +++ b/backend/tests/test_demo_manifest.py @@ -0,0 +1,54 @@ +from app.core.db import SessionLocal +from app.seed_loader import reset_and_seed + + +def test_demo_manifest_is_public(client): + # No login call at all -- the demo-entry screen and badge need this before any + # session exists. + response = client.get("/api/v1/demo/manifest") + assert response.status_code == 200 + + +def test_demo_manifest_shape(client): + body = client.get("/api/v1/demo/manifest").json() + assert body["organization_name"] == "Northstar Mobility" + assert body["demo_mode"] is True + assert body["synthetic_data"] is True + assert body["allow_reset"] is True + assert body["timezone"] == "Europe/Brussels" + assert body["guide_available"] is True + assert set(body["required_roles"]) == {"operations_manager", "rental_employee"} + assert body["last_reset_at"] is not None + assert body["anchor_date"] is not None + + scenario_ids = {s["id"] for s in body["scenarios"]} + assert scenario_ids == { + "return-anomaly", + "duplicate-customer", + "booking-overlap", + "automation-retry", + "knowledge-question", + } + integration_keys = {i["key"] for i in body["integrations"]} + assert integration_keys == {"n8n", "ragcore", "mcp_hub"} + + +def test_demo_manifest_scenarios_ready_after_fresh_reset(client): + db = SessionLocal() + try: + reset_and_seed(db) + finally: + db.close() + + body = client.get("/api/v1/demo/manifest").json() + scenarios = {s["id"]: s for s in body["scenarios"]} + for scenario_id, scenario in scenarios.items(): + assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset" + assert scenario["blocked_reason_code"] is None + assert scenario["start_path"] + + +def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client): + body = client.get("/api/v1/demo/manifest").json() + ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore") + assert ragcore["status_code"] == "demoMode" diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py index 19691ff..ef8ebe3 100644 --- a/backend/tests/test_knowledge.py +++ b/backend/tests/test_knowledge.py @@ -32,7 +32,52 @@ def test_demo_provider_health_reports_document_count(): health = provider.health() assert health.provider == "demo" assert health.available is True - assert health.document_count == 10 + assert health.document_count == 11 + + +def test_demo_provider_health_reports_document_count_per_language(): + provider = DemoKnowledgeProvider() + for language in ("nl-BE", "en-GB", "fr-BE"): + assert provider.health(language).document_count == 11 + + +def test_demo_provider_grounds_damage_question_in_dutch(): + provider = DemoKnowledgeProvider() + answer = provider.ask( + "Wat moet ik doen als een voertuig terugkomt met schade?", + "test-correlation-nl", + "nl-BE", + ) + assert answer.evidence_state == "grounded" + document_ids = {s.document_id for s in answer.sources} + assert "damage-procedure" in document_ids + + +def test_demo_provider_grounds_damage_question_in_french(): + provider = DemoKnowledgeProvider() + answer = provider.ask( + "Que dois-je faire quand un véhicule revient avec des dommages ?", + "test-correlation-fr", + "fr-BE", + ) + assert answer.evidence_state == "grounded" + document_ids = {s.document_id for s in answer.sources} + assert "damage-procedure" in document_ids + + +def test_demo_provider_insufficient_evidence_message_is_localized(): + provider = DemoKnowledgeProvider() + nl_answer = provider.ask( + "Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE" + ) + fr_answer = provider.ask( + "Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE" + ) + assert nl_answer.evidence_state == "insufficient" + assert fr_answer.evidence_state == "insufficient" + assert nl_answer.answer != fr_answer.answer + assert "France" not in nl_answer.answer + assert "France" not in fr_answer.answer def test_ask_question_endpoint_grounded(ops_client): diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 84ea3bd..076cd1a 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -1,13 +1,16 @@ +from datetime import UTC, datetime + from sqlalchemy import func, select from app.core.db import SessionLocal +from app.models.audit import AuditEvent from app.models.booking import Booking from app.models.customer import Customer from app.models.data_quality import DataQualityIssue from app.models.outbox import OutboxEvent from app.models.user import User from app.models.vehicle import Vehicle -from app.seed_loader import reset_and_seed +from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed def test_seed_counts_match_deterministic_dataset(): @@ -52,3 +55,117 @@ def test_seed_demo_scenarios_present(): assert failed_run is not None finally: db.close() + + +def _by_ref(db, model, ref): + return db.scalar(select(model).where(model.public_ref == ref)) + + +def test_seed_scenario_s1_odometer_regression_return(): + """S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a + below-canonical odometer reading, using the vehicle's own current odometer.""" + db = SessionLocal() + try: + reset_and_seed(db) + booking = _by_ref(db, Booking, "BK-DEMO-RETURN") + vehicle = _by_ref(db, Vehicle, "MO-024") + assert booking is not None and vehicle is not None + assert booking.vehicle_id == vehicle.id + assert booking.status == "active" + assert booking.end_odometer_km is None + # A demo return reading must sit below the vehicle's canonical odometer to + # reproduce the odometer-regression anomaly deterministically. + assert vehicle.odometer_km > 0 + finally: + db.close() + + +def test_seed_scenario_s2_duplicate_customer_pair(): + """S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue.""" + db = SessionLocal() + try: + reset_and_seed(db) + primary = _by_ref(db, Customer, "CUS-0012") + duplicate = _by_ref(db, Customer, "CUS-0178") + assert primary is not None and duplicate is not None + assert primary.email == duplicate.email + assert duplicate.merged_into_customer_id is None + + issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE") + assert issue is not None + assert issue.rule_type == "possible_duplicate_customer" + assert issue.status == "open" + related = issue.evidence_json.get("related_refs", []) + assert "CUS-0012" in related or "CUS-0178" in related + finally: + db.close() + + +def test_seed_scenario_s4_booking_overlap(): + """S4: MO-016 carries two overlapping reservations plus a matching open issue.""" + db = SessionLocal() + try: + reset_and_seed(db) + vehicle = _by_ref(db, Vehicle, "MO-016") + booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A") + booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B") + assert vehicle is not None and booking_a is not None and booking_b is not None + assert booking_a.vehicle_id == vehicle.id + assert booking_b.vehicle_id == vehicle.id + assert booking_a.starts_at < booking_b.ends_at + assert booking_b.starts_at < booking_a.ends_at + + issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP") + assert issue is not None + assert issue.rule_type == "booking_overlap" + assert issue.status == "open" + finally: + db.close() + + +def test_seed_scenario_s5_failed_workflow_run(): + """S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely + pending, so the background dispatcher never silently auto-heals it away.""" + db = SessionLocal() + try: + reset_and_seed(db) + failed = db.scalar( + select(OutboxEvent).where( + OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020" + ) + ) + assert failed is not None + assert failed.delivery_status == "failed" + assert failed.attempts >= 1 + assert failed.last_error + finally: + db.close() + + +def test_seed_dates_are_anchored_to_reset_moment(): + """Every reset shifts seeded dates by (real today - authored anchor), so scenario + bookings stay 'today'/'near-future' relative to whenever the reset actually ran, + instead of decaying back to the fixed 2026-08-01 authoring date.""" + db = SessionLocal() + try: + result = reset_and_seed(db) + today = datetime.now(UTC).date() + assert result.anchor_date == today + + shift = today - SEED_AUTHORED_ANCHOR + booking = _by_ref(db, Booking, "BK-DEMO-RETURN") + assert booking is not None + # Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the + # real reset date, not the frozen authoring date (unless shift is exactly zero). + assert booking.ends_at.date() == today or shift.days == 0 + + marker = db.scalar( + select(AuditEvent) + .where(AuditEvent.action == "demo_data_seeded") + .order_by(AuditEvent.occurred_at.desc()) + ) + assert marker is not None + assert marker.metadata_json["anchor_date"] == today.isoformat() + assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat() + finally: + db.close() diff --git a/compose.yaml b/compose.yaml index 3306969..5e0eadc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,7 +24,6 @@ services: DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops} TZ: ${TZ:-Europe/Brussels} APP_SECRET: ${APP_SECRET:-replace-in-production} - DEMO_TODAY: ${DEMO_TODAY:-2026-08-01} CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228} KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo} RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000} @@ -35,6 +34,9 @@ services: N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return} N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token} MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token} + DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility} + DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels} + DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true} ports: - "8128:8000" depends_on: diff --git a/docs/05-api-contract.md b/docs/05-api-contract.md index 7f2d194..7cc5216 100644 --- a/docs/05-api-contract.md +++ b/docs/05-api-contract.md @@ -11,10 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu ### System and demo - `GET /health` +- `GET /api/v1/demo/manifest` — unauthenticated; demo org name/description, synthetic-data + flag, reset allowance and timestamp, guide availability, the 5 named scenarios (with + live readiness derived from actual records, not hardcoded), and plain-language + integration summaries. Single source of truth for the demo-entry screen, the permanent + demo badge, the scenario overview and the About page — avoids duplicating this logic + per surface. - `POST /api/v1/demo/login` - `GET /api/v1/demo/session` — confirms the current session; `Cache-Control: no-store` - `POST /api/v1/demo/logout` — safe to call without a session -- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own session +- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own + session; returns 403 if `DEMO_ALLOW_RESET=false` ### Dashboard diff --git a/docs/13-seed-and-demo-scenarios.md b/docs/13-seed-and-demo-scenarios.md index 0b53005..862ef8c 100644 --- a/docs/13-seed-and-demo-scenarios.md +++ b/docs/13-seed-and-demo-scenarios.md @@ -42,6 +42,19 @@ One seeded outbox/workflow record is failed with a safe simulated connection err Question: “What must I do when a vehicle returns with damage?” Expected: answer cites damage handling and return inspection procedures. +## Date anchoring + +The committed CSVs store absolute ISO timestamps authored around a fixed anchor date +(`SEED_AUTHORED_ANCHOR = 2026-08-01` in `backend/app/seed_loader.py`, matching the +`--anchor` used to generate them). Every seed/reset shifts every seeded booking, +inspection, maintenance and outbox timestamp by `today − SEED_AUTHORED_ANCHOR`, so +"today"/"near-future"/"currently overlapping" scenarios stay true to the real moment the +environment was (re)seeded instead of decaying as real time passes between resets. Public +refs and entity relationships are untouched by the shift — only datetime columns move. +`load_seed()` returns the resolved `anchor_date`/`seeded_at`, and records a +`demo_data_seeded` audit event carrying both the resolved anchor and the original +authoring anchor, so the shift applied on any given reset stays traceable. + ## Demo reset Reset must: @@ -49,6 +62,7 @@ Reset must: - require Operations Manager; - rebuild the deterministic dataset; - re-establish scenario references; +- re-anchor scenario dates to the real reset moment (see above); - clear non-seed audit/workflow state; - complete safely and visibly; - be covered by a test. diff --git a/docs/17-runbook.md b/docs/17-runbook.md index 8a56b8c..8314167 100644 --- a/docs/17-runbook.md +++ b/docs/17-runbook.md @@ -1,5 +1,9 @@ # PoC runbook +For the demo-specific 5-minute/10-minute walkthroughs, reset behaviour, Unraid +redeploy/rollback steps and troubleshooting, see `docs/demo-release/demo-runbook.md`. +This document covers general environment bootstrap and n8n setup. + ## Bootstrap (clean checkout) ```bash diff --git a/docs/demo-release/current-demo-gap-audit.md b/docs/demo-release/current-demo-gap-audit.md new file mode 100644 index 0000000..a95e180 --- /dev/null +++ b/docs/demo-release/current-demo-gap-audit.md @@ -0,0 +1,157 @@ +# Current demo-readiness gap audit + +Performed 2026-08-03 against `feat/mobilityops-functional-completion` @ +`e0c7ed60112510687627d20a957af91c8b9db7f8` (verified: correct branch, working tree +clean, matches the deployed Unraid revision). Method: read `CLAUDE.md`/`AGENTS.md` +(identical, no demo-specific guidance yet), `README.md`, `PROJECT_STATE.md`, the docs +list in the brief, `artifacts/final-acceptance/summary.md`, +`artifacts/functional-completion/final-summary.md`, `seed/generate_seed.py`, +`seed/*.csv`, `backend/app/seed_loader.py`, `backend/app/core/config.py`, +`backend/app/api/routers/dashboard.py`; live-browsed the deployed app at +`http://192.168.10.150:1236` (login, dashboard, a data-quality issue, a booking detail, +knowledge, automation). + +## What already works well (do not rebuild) + +- The premium Control Rail UI, role-based nav, server-backed sessions, return preview/ + commit, all five data-quality resolution flows, audit before/after, role-aware search, + truthful integration status, demo reset, stale-lease recovery and the two n8n + workflows are all implemented, tested and live-verified per + `artifacts/functional-completion/final-summary.md` — this pass does not touch that + business logic. +- `docs/13-seed-and-demo-scenarios.md` already names exactly the scenarios this brief + wants surfaced: **S1** odometer-regression return (`BK-DEMO-RETURN`/`MO-024`), **S2** + duplicate customer (`CUS-0012`/`CUS-0178`, issue `DQ-DEMO-DUPLICATE`), **S4** booking + overlap (`MO-016`, issue `DQ-DEMO-OVERLAP`, bookings `BK-DEMO-OVERLAP-A/B`), **S5** + failed workflow (seeded failed outbox event, retryable from Automation), **S6** + grounded knowledge question. These map directly to the brief's 5 requested scenarios + — no new scenarios need to be invented, only surfaced with guidance. (S3, "missing + inspection before next booking" on `MO-031`, exists too but isn't one of the 5 + requested; leave it as-is, it still shows up in the attention queue.) +- `seed/generate_seed.py` already produces believable Flemish/Dutch data: first/last + name pools (Sofie, Lotte, Bram, Peeters, Janssens, Vermeulen, ...), real Kempen-region + towns (Geel, Turnhout, Herentals, Mol, Westerlo, Tessenderlo-Ham), `.test` email + addresses, and believable RV/camper makes (Adria, Dethleffs, Hymer, Bürstner, ...). + Confirmed live: `DQ-DEMO-DUPLICATE` shows customer "Bram Peeters" (Geel) with email + `bram.peeters.12@example.test`. **The underlying data quality is already + demo-grade — the gap is structural/experiential, not data flavour.** + +## Confirmed gaps + +### 1. No fictional organisation identity anywhere +Login/demo-entry screen says "MobilityOps · Control Centre" and generic marketing copy +("Every hand-off. One clear view.") but never names a demonstration organisation. A +stranger cannot tell *whose* fleet this is. **Confirmed via full-repo search:** +"Northstar Mobility" is never rendered in the UI, but it is **already the project's +locked fictitious tenant name** — `PROJECT_STATE.md`'s "Locked decisions" section +states verbatim *"Fictitious tenant: Northstar Mobility Demo"*, and +`ragcore_tenant` defaults to `"northstar-mobility-demo"` in `backend/app/core/config.py`, +`compose.yaml`, `.env.example`, `knowledge/manifest.json` and `contracts/mcp-tools.json`. +So using "Northstar Mobility" as the visible demo brand (as this brief suggests) is not +a new invention — it surfaces a decision the project already made and only ever used +internally as a RAGcore-tenant slug. No renaming/config changes needed, just make it +visible. + +### 2. No guided path — a visitor has no idea where to start +The dashboard shows real attention-queue items (duplicate customer, booking overlap, +vehicle status conflict, missing field) and today's movements (`BK-DEMO-RETURN` +11:00), but nothing tells a first-time visitor *which* of these is worth clicking, in +what order, or why. There is no "start guided demo" affordance, no scenario overview, +no step-by-step walkthrough. Everything technically works; nothing narrates. + +### 3. Live date anchoring is broken — confirmed, reproducible +`seed/bookings.csv` stores **absolute fixed ISO timestamps** authored around anchor +date 2026-08-01 (`generate_seed.py --anchor 2026-08-01`). `backend/app/core/config.py`'s +`demo_today` setting (`"2026-08-01"`) is used *only* by +`dashboard.py::_today()` to filter "today's movements" against that frozen date — nothing +re-anchors the underlying booking/inspection/maintenance/outbox dates themselves. +**Live-confirmed right now** (today is 2026-08-03): `BK-DEMO-RETURN` shows status +`active` with `ends_at = 01/08/2026 09:00` — already two days in the past, for a +booking that's supposed to look "in progress, due for return soon." `BK-DEMO-NEXT` +(meant to read as "near-future") starts 2026-08-02, also already past. Every day this +environment isn't reset, this gets worse, and after any `seed --reset` it snaps right +back to the same frozen 2026-08-01-relative positions regardless of the real reset +moment. This is exactly the failure mode brief section 7 describes and must be fixed at +the source (`seed_loader.py`), not papered over in the UI. + +### 4. Knowledge assistant names "RAGcore" directly, not honestly "demo mode" +`/knowledge` page copy: "Answers are shown only when **RAGcore** returns sufficient +cited evidence" and a "RAGcore" label on the sources block — while the actual active +provider is `KNOWLEDGE_PROVIDER=demo` (confirmed: badge below correctly shows +`demo · Available · 10 procedures indexed`, which contradicts the body copy one line +below it). This is the exact misleading-integration-status problem brief section 11 +warns against — the small badge is honest, the prose isn't. No suggested questions are +offered. + +### 5. Integration status labels are technical, not demo-plain-language +Automation page shows raw states like `not_configured`/`degraded`/`operational` (English, +developer register) rather than the plain-language labels section 12 asks for +(Operationeel / Demomodus / Niet gekoppeld / Voorbereid / Verwerking mislukt / Opnieuw +proberen mogelijk). + +### 6. Data-quality resolution panels are functionally complete but not narrated +Confirmed live on `DQ-DEMO-DUPLICATE`: the compare/merge UI works well, but nothing +explains *why this matters operationally* (duplicate billing/contact risk) or what +happens next in plain terms before the user acts. Same pattern across the other 4 +rule-type panels (already reviewed in this session's earlier batch). No "demo scenario" +filter exists to surface the curated issues first. + +### 7. No "About this demo" page +No page anywhere explains what's real, what's synthetic, what's live vs. demo-mode vs. +not-connected, or the architecture/testing approach in plain terms. A visitor exploring +alone has no way to self-answer "is this real?" + +### 8. No permanent, explained "synthetic demo" indicator +The existing `demo-banner` ("Synthetic demo data · no real customer or vehicle +information") is a full-width static bar repeated on every page — present, but static +text only, no link to more explanation, no reset-timestamp/anchor info, and (per section +4) shouldn't be a dominant warning bar; it should be a subtle, explained badge. + +### 9. Demo reset exists but is not discoverable as a demo feature +`POST /api/v1/demo/reset` and its sidebar UI trigger (this session's earlier batch) +work correctly and are Operations-Manager-gated, but reset isn't connected to any demo +narrative (no "prepare demo again" framing, not reachable from an About page or guide, +no post-reset scenario-integrity confirmation beyond the raw counts). + +### 10. No demo manifest / no single source of "what's on right now" +Nothing exposes demo mode, scenario list, integration status and guidance availability +as one small structured payload the frontend can render consistently (badge tooltip, +guide, scenario overview, About page all currently would have to duplicate this +knowledge if built independently). + +### 11. Everything is in English; the brief asks for consistent Dutch demo text +All existing UI copy is English. The brief explicitly asks for consistent Dutch product +text for the demo-facing additions. Recorded decision (no existing doc answers this, +and translating the entire already-shipped, already-tested Control Rail UI is out of +proportion to "add guidance," would touch dozens of files and contradicts "this is not +a redesign"): **new demo-productization surfaces are written in Dutch** (demo entry +copy, Demo Guide, scenario overview, About-this-demo page, the demo badge/tooltip, +plain-language integration-status labels). **Existing operational screens (Dashboard, +Vehicles, Bookings, Data Quality workbench, Audit, Automation internals) stay in +English** — they are already shipped, tested, and section 16 explicitly forbids +rebuilding working screens without functional reason. This mirrors how a real bilingual +guided demo works: narration in one language, software in whatever it already ships in. + +## Scope reminder (not gaps — explicitly out of scope per the brief) + +- No RAGcore or MCP Hub live implementation. +- No new business logic in the 5 resolution flows — only explanatory framing around + the existing ones. +- No full redesign, new component library, new color palette, or full retranslation. + +## Plan (implementation batches, matches task list) + +1. Fix date anchoring in `seed_loader.py` (shift all seeded dates by `today − anchor` + at every reset); switch `dashboard.py::_today()` to real wall-clock date; add a + `demo_data_seeded` audit marker recording the anchor/reset moment. +2. `GET /api/v1/demo/manifest` (org name, demo mode, scenarios, integration status, + reset timestamp) + Dutch demo entry screen + permanent demo badge with popover. +3. Demo Guide (collapsible panel/bottom sheet, 8 steps) + scenario overview (5 cards) + on the dashboard. +4. Layer plain-language Dutch explanation onto the return flow, the 5 DQ panels and the + knowledge assistant (fix the RAGcore-naming bug), without touching their logic. +5. Plain-language integration status labels, richer audit narration for guide-linked + events, "Over deze demo" page, reset wired into guide/About/OM menu with a + post-reset scenario-integrity check. +6. Full guided-demo Playwright test + targeted tests per section 19. +7. Clean-checkout demo drill, docs, Unraid deploy, final evidence with screenshots. diff --git a/docs/demo-release/demo-concept.md b/docs/demo-release/demo-concept.md new file mode 100644 index 0000000..a46e52b --- /dev/null +++ b/docs/demo-release/demo-concept.md @@ -0,0 +1,77 @@ +# Demo concept + +## The fictional problem + +**Northstar Mobility** is a fictional Belgian camper/van rental company with roughly 50 +vehicles, one main location, a rental team, an operations manager, and a small workshop. +Northstar Mobility does not exist — this name and every customer, vehicle, booking and +procedure in the demo are synthetic. Before MobilityOps, Northstar Mobility's bookings, +returns, customer records and maintenance history lived in spreadsheets and verbal +hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked +vehicles) only surfaced late, after they had already caused friction. MobilityOps shows +how one connected system detects these problems early and lets an Operations Manager +resolve them under audit, with automation handling the routine follow-up. + +## Target audience + +Anyone evaluating how MobilityOps approaches operational data-quality and hand-off +problems for a small rental fleet: Operations Managers, Rental Employees, and reviewers +assessing the approach. No account setup or backend knowledge is required to start — +the login screen explains the fictional context and offers a guided path. + +## Demo scope + +This is a focused proof of concept, not a full ERP. In scope: vehicle and booking +management, return processing with server-side evaluation, five data-quality detection +rules each with one bounded resolution flow, a full audit trail, and orchestration via a +real n8n instance. Explicitly out of scope: accounting, payments, public reservations, a +generic CRM, inventory, HR, a second RAG stack, or autonomous write actions from any +external tool. + +## What's really implemented + +All of the following is functional code, not a mockup or hardcoded screen: + +- Role-based authentication and server-verified sessions (Operations Manager, Rental + Employee), enforced both server-side and in the frontend's navigation/route guards. +- Vehicle and booking management, with return preview/commit sharing one authoritative + evaluation function so they can never drift. +- Five data-quality rules (possible duplicate customer, missing required field, odometer + regression, booking overlap, vehicle status conflict), each with exactly one bounded, + audited resolution endpoint. +- A full audit trail with before/after state, actor, correlation IDs, and a + "view related events" link reusing that correlation. +- Reliable outbox-based delivery to a real n8n instance, with bounded retries and + stale-delivery recovery. +- Docker Compose deployment and an automated test suite (backend pytest, Ruff, mypy, + and a full Playwright end-to-end suite covering the demo experience itself). +- A demo manifest (`GET /api/v1/demo/manifest`) as the single source of truth for the + demo's identity, synthetic-data status, reset state, scenario readiness, and honest + per-integration labels — the frontend never hardcodes what mode it's in. + +## What's synthetic + +The organisation, all customers, vehicles, bookings, maintenance history, the knowledge +base's procedures, and the five prepared demo scenarios are entirely fictional. No data +refers to a real person, vehicle, or company. Every seeded email uses the `.test` +reserved domain. See [`demo-data.md`](demo-data.md) for exactly how this data is +generated and kept fresh across resets. + +## What's not yet live-connected + +- **RAGcore**: not connected. The knowledge assistant uses a local, deterministic + keyword-matching "demo knowledge base" built from five procedure documents, honestly + labelled as such everywhere in the UI (never presented as RAGcore). A + `RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over + the same interface once a real RAGcore backend is available — swapping providers is a + configuration change (`KNOWLEDGE_PROVIDER`), not a UI change. +- **ITWorx MCP Hub**: not connected. Registration is disabled by default + (`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" — + never a fabricated successful registration. + +## Where to go next + +- [`demo-scenarios.md`](demo-scenarios.md) — the five guided scenarios and their fixed records. +- [`demo-data.md`](demo-data.md) — seed strategy and date-anchoring. +- [`demo-guide.md`](demo-guide.md) — the in-app 8-step guided tour. +- [`demo-runbook.md`](demo-runbook.md) — how to run, reset, and redeploy the demo. diff --git a/docs/demo-release/demo-data.md b/docs/demo-release/demo-data.md new file mode 100644 index 0000000..c454478 --- /dev/null +++ b/docs/demo-release/demo-data.md @@ -0,0 +1,83 @@ +# Demo data strategy + +## Three separate concepts + +It's important to keep these distinct — they solve different problems: + +1. **Deterministic records** — every seeded entity has a stable public reference + (`BK-DEMO-RETURN`, `DQ-DEMO-DUPLICATE`, `MO-024`, `CUS-0012`, ...). These references + never change between seed generations or resets; they are what the five demo + scenarios, the Demo Guide, and `docs/13-seed-and-demo-scenarios.md` all link against. +2. **Date anchoring** — the mechanism that keeps "today"/"near-future"/"currently + overlapping" scenarios true to whenever the environment was actually last reset, + described below. +3. **The reset date** — the real wall-clock moment a reset actually happened. This + changes every time someone resets; it's the input to date anchoring, not a fixed + record. + +## Generating the seed dataset + +```bash +python seed/generate_seed.py --anchor 2026-08-01 --seed 20260801 +``` + +This produces the committed CSVs in `seed/*.csv` with **absolute ISO timestamps** +authored relative to a fixed anchor date (`2026-08-01`). Target scale: 50 vehicles, 180 +customers (including three duplicate pairs), ~245 historical/current/future bookings, +realistic inspections and maintenance history, and the fixed quality/workflow scenarios +described in [`demo-scenarios.md`](demo-scenarios.md). Names, towns and vehicle makes are +drawn from believable Flemish/Kempen-region pools; all emails use the `.test` domain. + +## Date anchoring + +`backend/app/seed_loader.py` defines: + +```python +SEED_AUTHORED_ANCHOR = date(2026, 8, 1) # matches generate_seed.py's --anchor +``` + +Every seed/reset computes `shift = today - SEED_AUTHORED_ANCHOR` and applies it to every +seeded booking, inspection, maintenance and outbox timestamp before insertion. Public +references and entity relationships are never touched by the shift — only datetime +columns move. This means: + +- `BK-DEMO-RETURN` always ends "today" (or very close to it) relative to whenever you + actually reset, not relative to the frozen 2026-08-01 authoring date. +- `BK-DEMO-NEXT` and the overlap-scenario bookings always read as "near future". +- The shift is recomputed fresh on every reset, so scenarios never decay as real time + passes between resets — this was a real, confirmed bug before this fix (see + `docs/demo-release/current-demo-gap-audit.md`, gap #3): the environment would drift + further out of sync with every day it wasn't reset, and a reset didn't fix it because + nothing re-anchored the underlying stored dates. + +`load_seed()` returns the resolved `anchor_date` (real today) and `seeded_at` timestamp, +and records a `demo_data_seeded` audit event carrying both the resolved anchor and the +original authoring anchor, so the shift applied on any given reset stays traceable via +the audit trail. + +`dashboard.py::_today()` uses real wall-clock UTC date (not a frozen setting) to filter +"today's movements", consistent with the shifted data. + +## Reset + +`POST /api/v1/demo/reset` (Operations Manager only, and only if `DEMO_ALLOW_RESET=true`) +clears all MobilityOps tables, reloads the seed with a fresh date shift, re-runs the +data-quality scan, and runs a server-side **scenario-integrity check** +(`scenario_integrity_report()` in `backend/app/services/demo_manifest.py`) confirming all +five named scenarios are actually present and ready — recorded in both the response body +and the `demo_reset` audit event's metadata. Reset only ever affects MobilityOps's own +tables; it never touches shared n8n, RAGcore, or MCP data, other containers, or volumes. + +## Seed-validation tests + +`backend/tests/test_seed.py` proves, after every reset: + +- S1 (`BK-DEMO-RETURN`/`MO-024`) is active with no end odometer recorded yet. +- S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`) is open with matching evidence. +- S4 (`MO-016`/`BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) genuinely overlaps in time. +- S5 (the seeded failed outbox event) is durably `failed` immediately after reset, not + silently auto-healed by the background dispatcher (which only claims `pending` rows). +- The date-anchoring shift and the `demo_data_seeded` audit marker are both correct. + +`backend/tests/test_demo_manifest.py` additionally proves that all five manifest +scenarios report `ready: true` with no `blocked_reason` right after a fresh reset. diff --git a/docs/demo-release/demo-guide.md b/docs/demo-release/demo-guide.md new file mode 100644 index 0000000..39d355f --- /dev/null +++ b/docs/demo-release/demo-guide.md @@ -0,0 +1,71 @@ +# The in-app Demo Guide + +## What it is (and isn't) + +The Demo Guide is a compact, reusable side panel (a bottom sheet on mobile) that walks an +Operations Manager through eight fixed steps covering the demo's core functionality. It is +**not** a generic tutorial engine and **not** a source of business logic — every action it +prompts is a real action against the real API; the guide only narrates, links, and tracks +progress. It duplicates no business logic: routes for the two scenario-backed steps +(return, duplicate-merge) are resolved from the live `GET /api/v1/demo/manifest` response +rather than hardcoded, so they can never point at a stale or missing record. + +## Where it lives + +- `frontend/src/data/demoGuideSteps.ts` — the eight step definitions (title, what you'll + see, why it matters, the exact start action, the expected outcome, and a `route()` + function). +- `frontend/src/context/DemoGuideContext.tsx` — open/close state and step progress, + persisted to `sessionStorage` only (browser-local, never touches auth or business + state; a "Demo opnieuw voorbereiden" restart or a new browser session starts fresh). +- `frontend/src/components/DemoGuide.tsx` — the panel itself and its topbar trigger + (`DemoGuideTrigger`, Operations-Manager-only, since all eight steps require that role). + +## The eight steps + +1. **Understand the operational state** — the dashboard's readiness and attention queue. +2. **Open the booking needing attention** — `BK-DEMO-RETURN`, resolved from the manifest. +3. **Process the return with an odometer anomaly** — same booking; the return form is + pre-filled with the suspicious reading (see [`demo-scenarios.md`](demo-scenarios.md) + scenario 1). +4. **Handle the created data-quality issue** — the fresh odometer-regression issue that + step 3 just created. +5. **Review and merge the possible duplicate customer** — `DQ-DEMO-DUPLICATE`. +6. **Ask the procedure assistant a question** — one of the suggested questions. +7. **Check automation and the audit trail**. +8. **Review what's real, simulated, or not yet connected** — the About page. + +## How progression works + +- **"Ga naar deze stap"** navigates to the step's resolved route without marking it done. +- **"Volgende"** marks the current step complete and advances the index (used for steps + with no dedicated in-page continuation, like the knowledge-assistant step). +- Several real actions (a successful return, a resolved data-quality issue) show their + own **"Ga verder met de demo"** button that both completes the current step and + navigates straight to the next one — this is how the guide chains through steps 3→4→5 + without a detour back through the panel's own controls. +- The step list on the panel lets you jump directly to any step. +- **"Demo opnieuw voorbereiden"** calls the real reset endpoint, resets the guide's own + progress, and returns to the login screen — mirroring the existing sidebar reset + control, not a separate implementation. + +## A known, deliberate limitation: suggested questions stay in English + +The demo knowledge base's five procedure documents (`knowledge/procedures/*.md`) are +written in English. Verified empirically while building step 6: an equivalent Dutch +question returned `insufficient` evidence against the demo provider, while the English +original returned `grounded`. Rather than mistranslate the demo's centerpiece "grounded +answer" feature into something that silently answers incorrectly, the suggested questions +on `/knowledge` and this guide's step 6 instructions stay in English, with the guide +explicitly explaining why in Dutch. Retranslating the procedure documents themselves was +judged out of scope for a demo-productization pass (see the language-split note in +`docs/demo-release/current-demo-gap-audit.md`, gap #11). + +## Layout note: the panel reserves space, it doesn't overlap content + +On desktop the panel is a fixed 400px-wide right-side overlay. `Layout.tsx` adds a +`guide-open` class to the main workspace while the guide is open, which reserves +`padding-right` so page content reflows aside instead of sitting underneath the panel — +this was a real bug found and fixed while building the full guided-demo Playwright test +(see `PROJECT_STATE.md`, Batch 6): without it, the return form's "Review return" button +was unclickable while the guide was open at ordinary desktop widths. diff --git a/docs/demo-release/demo-runbook.md b/docs/demo-release/demo-runbook.md new file mode 100644 index 0000000..deff09a --- /dev/null +++ b/docs/demo-release/demo-runbook.md @@ -0,0 +1,97 @@ +# Demo runbook + +## Starting the demo (any environment) + +Open the deployed URL (Unraid review: `http://192.168.10.150:1236`; local: +`http://localhost:1228`). The login screen names the fictional organisation, states that +all data is synthetic and all workflows are really implemented, and offers: + +- **Start begeleide demo** — logs in as Operations Manager and opens the Demo Guide at + step 1. +- **Verken als Operations Manager** / **Verken als Rental Employee** — free exploration, + no guide. + +No password is shown or required for either path. + +## Five-minute demo (guided) + +1. Click **Start begeleide demo**. +2. Follow steps 1–3: dashboard → the booking needing attention → confirm the pre-filled + odometer-anomaly return. +3. Step 4: resolve the data-quality issue the return just created (any decision is fine + for a quick pass — "Retain canonical" is the fastest). +4. Step 6: ask a suggested knowledge question and show the cited source. +5. Step 7: show the automation/audit trail link-through. +6. Close with step 8, the About page's honest real/synthetic/not-connected breakdown. + +## Ten-minute demo (guided + one extra scenario) + +Do the five-minute path above, then from `/scenarios`: + +- Run **scenario 2** (duplicate customer merge) if not already done via the guide's own + step 5. +- Run **scenario 3** (booking overlap) — `/data-quality`, resolve `DQ-DEMO-OVERLAP`. +- Run **scenario 4** (failed automation retry) — `/automation`, filter to failed, retry. + +All five scenarios can be run in any order and are independent of each other. + +## Resetting the environment + +Any Operations Manager can reset from: the sidebar ("Reset demo data"), the Demo Guide +panel ("Demo opnieuw voorbereiden"), or the About page (points to the sidebar control). +Reset requires confirmation, rebuilds the deterministic dataset with a fresh date anchor, +runs a server-side scenario-integrity check, and signs the acting session out (the server +invalidates the session as part of reset). It only ever touches MobilityOps's own tables +— never shared n8n, RAGcore, or MCP data, other containers, or volumes. It can be +disabled entirely via `DEMO_ALLOW_RESET=false` if an environment must not be rebuildable. + +## Redeploying to Unraid + +```bash +# From a clean local checkout on the target branch/commit: +git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD +scp /tmp/mobilityops-source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source-.tar.gz +ssh unraid "cd /mnt/user/appdata/mobilityops \ + && tar -xzf .deploy/source-.tar.gz \ + && echo > .deploy/source-revision" + +# Rebuild only what changed (api and/or web); db is never rebuilt: +ssh unraid "cd /mnt/user/appdata/mobilityops \ + && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web" + +# Confirm migrations and reseed: +ssh unraid "cd /mnt/user/appdata/mobilityops \ + && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m alembic current \ + && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset" +``` + +Extraction preserves the server's existing `.env` and named volumes (Postgres data, n8n +data) — the tarball never contains `.env` since it's gitignored. Never edit source +directly on the server; never deploy uncommitted changes. + +## Rollback + +`.deploy/source-revision` on the server records exactly which commit is live. Prior +source tarballs remain in `.deploy/` for rollback: extract an earlier +`source-.tar.gz`, update `source-revision` to match, and re-run the rebuild +step above. Database rollback is out of scope for this demo (migrations are additive; +there has been no destructive migration on this branch). + +## Server safety (Unraid) + +Only touch the `mobilityops` Compose project's own `api`/`web` services (and `db` only +via migrations, never manually). Never stop other containers, run `docker system prune`, +delete unrecognised images/networks/volumes, delete the MobilityOps database, overwrite +the server `.env`, print secrets, start a second permanent n8n instance, or activate +guessed RAGcore/MCP URLs. PostgreSQL is never exposed externally. + +## Troubleshooting + +- **A scenario shows "Niet beschikbaar" on `/scenarios`**: it has already been resolved + since the last reset (expected once you've worked through it) — reset to restore it. +- **Knowledge question returns "insufficient evidence"**: check the question is in + English and close to one of the suggested phrasings — the demo knowledge base is + English-only (see `demo-guide.md`). +- **n8n shows a failed delivery that isn't the seeded demo one**: check `/automation`'s + filter and retry — bounded retries mean it should self-heal within + `n8n_max_attempts` attempts, or can be retried manually by an Operations Manager. diff --git a/docs/demo-release/demo-scenarios.md b/docs/demo-release/demo-scenarios.md new file mode 100644 index 0000000..02cff0e --- /dev/null +++ b/docs/demo-release/demo-scenarios.md @@ -0,0 +1,71 @@ +# Demo scenarios + +Five fixed scenarios are surfaced at `/scenarios` and via `GET /api/v1/demo/manifest`'s +`scenarios` array, which derives each scenario's `ready`/`blocked_reason` from the actual +underlying records — never hardcoded. All five are re-findable, using the same public +references, after every demo reset (see [`demo-data.md`](demo-data.md)). + +## 1 — Odometer regression on return + +- **Fixed records**: booking `BK-DEMO-RETURN` on vehicle `MO-024`. +- **Role**: Rental Employee or Operations Manager. +- **Problem demonstrated**: a return is submitted with an odometer reading lower than the + vehicle's canonical reading — usually a data-entry mistake or a return logged against + the wrong vehicle. +- **Flow**: open the booking. The return form is pre-filled with a suspicious reading + below `MO-024`'s canonical odometer (the demo never asks a visitor to invent one), with + a callout explaining why. Review the server-evaluated preview, then confirm. The + canonical odometer is never silently lowered; a data-quality issue is opened + automatically and the outcome is queued for delivery to n8n. +- **Continue**: the result panel links to the vehicle, automation status, and audit + trail, plus (inside the guided tour) a "Ga verder met de demo" button. + +## 2 — Possible duplicate customer + +- **Fixed records**: customers `CUS-0012` and `CUS-0178` (same email/phone), issue + `DQ-DEMO-DUPLICATE`. +- **Role**: Operations Manager (merge is a bounded, audited action). +- **Problem demonstrated**: two customer profiles that are very likely the same person, + registered twice — a risk for split booking history and duplicate contact. +- **Flow**: open the issue, compare both profiles field by field, choose which survives, + and merge. The losing profile becomes a tombstone linked to the survivor; its bookings + are rewired. The result is recorded in the audit trail. + +## 3 — Overlapping bookings + +- **Fixed records**: vehicle `MO-016`, bookings `BK-DEMO-OVERLAP-A`/`BK-DEMO-OVERLAP-B`, + issue `DQ-DEMO-OVERLAP`. +- **Role**: Operations Manager. +- **Problem demonstrated**: the same vehicle committed to two overlapping reservations — + a legacy-import-style conflict that a normal booking command would reject outright. +- **Flow**: open the issue, choose which of the two bookings to block; the other keeps + its current status. + +## 4 — Failed automation, retried + +- **Fixed record**: a seeded outbox event (`00000000-0000-4000-8000-000000000020`, + `BK-H-0020`) that is durably `failed` immediately after every reset — a safe, simulated + connection error, not a deliberately destabilised n8n configuration. +- **Role**: Operations Manager. +- **Problem demonstrated**: bounded retries and visible failure/recovery state for + workflow delivery, rather than a silent drop. +- **Flow**: open Automation, filter to failed deliveries, retry the event; it moves out + of the failed filter once delivered. + +## 5 — Grounded procedure question + +- **Role**: Rental Employee or Operations Manager. +- **Problem demonstrated**: an operational question gets an answer with a citation from + the demo knowledge base — or an honest "insufficient evidence" if nothing indexed + answers it — never an invented answer. +- **Flow**: open Knowledge, click one of the suggested questions (English, matching the + indexed procedure content — see the note in [`demo-guide.md`](demo-guide.md) about why + suggested questions stay in English even though the rest of the demo narrates in + Dutch), and inspect the cited source and evidence state. + +## A sixth scenario exists but isn't one of the five requested + +`docs/13-seed-and-demo-scenarios.md`'s **S3** ("missing inspection before next booking", +vehicle `MO-031`) is also seeded and shows up in the attention queue; it isn't surfaced +on `/scenarios` because the brief names five specific scenarios, but it remains available +for anyone exploring the attention queue directly. diff --git a/docs/final-product-polish/audit.md b/docs/final-product-polish/audit.md new file mode 100644 index 0000000..f1fff12 --- /dev/null +++ b/docs/final-product-polish/audit.md @@ -0,0 +1,173 @@ +# Final product polish — audit + +Performed 2026-08-03 against `feat/mobilityops-functional-completion`. + +## Commit state + +- **Local/branch HEAD**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8` +- **Deployed on Unraid** (`/mnt/user/appdata/mobilityops/.deploy/source-revision`): + `4a268c73515dc4f1d56c1aa2f231714654bffbb8` — **matches exactly**, no drift. +- **Repository's primary branch is `master`, not `main`** — no branch named `main` exists + (`git branch -a` / `git remote show` confirm only `master`, `design/mobilityops-premium-ui`, + and `feat/mobilityops-functional-completion`). `origin/master` = local `master` = + `e0c7ed60112510687627d20a957af91c8b9db7f8`, unchanged since the functional-completion + baseline — this is the baseline to compare against before any future merge. +- **Evidence-file mismatch found and corrected**: `artifacts/demo-release/final-summary.md` + recorded final commit `294a8176d19...` (one commit behind actual HEAD, because its own + "record the hash" follow-up commit necessarily couldn't self-reference). Corrected to + the verified, unambiguous `4a268c73515dc4f1d56c1aa2f231714654bffbb8`, cross-checked + against both `git rev-parse HEAD` and the server's `source-revision`. +- Containers on Unraid: `api`/`db`/`web` all healthy; migrations at `e7b08389f47f (head)`. + +## Remaining "MobilityOps" mentions (user-facing) + +- `frontend/index.html:7` — `MobilityOps`; `:6` meta description. +- `frontend/src/components/Layout.tsx:178` — sidebar brand lockup `MobilityOps`. +- `frontend/src/components/Layout.tsx:232` — global search `aria-label`/visually-hidden + label "Search MobilityOps". +- `frontend/src/components/Layout.tsx:299` — footer `MobilityOps PoC` (also + the "PoC" mention to remove). +- `frontend/src/pages/Login.tsx:27` — the manifest-fallback org description string. +- `frontend/src/pages/AboutDemo.tsx:32,63,71` — page heading and two body paragraphs. +- `frontend/src/pages/Knowledge.tsx:108` — empty-state copy. +- `frontend/src/pages/BookingDetail.tsx:80` — scenario callout copy. +- `frontend/src/data/demoGuideSteps.ts:91` — step 8's expected-outcome text. +- `backend/app/services/demo_manifest.py:24` — `ORGANIZATION_DESCRIPTION` (the string + `Login.tsx:27` falls back to when the manifest hasn't loaded yet — both need updating + together to avoid a flash of stale branding). +- `backend/app/main.py:36` — FastAPI `title="MobilityOps API"` (visible in the OpenAPI/ + Swagger UI a reviewer might open; low-risk to rename since it's a display string, not a + route or contract field). +- **`knowledge/procedures/*.md` — user-facing, appears directly in cited answer text**: + `02-vehicle-return.md`, `03-damage-handling.md` (also says "PoC"), `05-cleaning- + checklist.md`, `07-customer-documents.md` (also says "PoC"), `09-booking-conflicts.md` + all say "MobilityOps" in prose that gets quoted verbatim as a knowledge-assistant + answer excerpt — these must be rebranded too, not just the app chrome. +- e2e tests asserting on the string "MobilityOps" that must be updated alongside the + rename (not gaps, just dependencies): `demo-accessibility.spec.ts`, `demo-entry.spec.ts`, + `guided-demo-full.spec.ts` (About-page heading), `ui-redesign.spec.ts` (4× "Search + MobilityOps" combobox name). + +**Kept as technical identifiers** (per the brief, not renamed): Git repo `Jens/MobilityOps`, +local repo folder name, Compose project `mobilityops`, deployment dir +`/mnt/user/appdata/mobilityops`, Postgres db/user `mobilityops`, `knowledge/manifest.json`'s +`"workspace": "mobilityops"` (a RAGcore-workspace config value), frontend package name +`mobilityops-web`, and the internal API route prefix conventions. + +## Language problems (no i18n exists at all) + +- `frontend/package.json` has no i18n library (`i18next`/`react-i18next`/etc. absent). +- There is no language switcher anywhere and no persisted language preference. +- The existing UI mixes English (Dashboard, Vehicles, Bookings, Data Quality workbench, + Audit, Automation internals — all shipped in Task-B's demo-productization pass) and + Dutch (Login, Demo Guide, Scenarios, About, demo badge — added in the same pass) by a + documented, deliberate scope decision at the time. That decision must now be superseded: + the brief requires one true default (nl-BE) with working en-GB/fr-BE switching across + the **entire** application, so the English-language existing screens are now in scope + for translation, not just new surfaces. +- The demo knowledge base (`knowledge/procedures/*.md`) is English-only; a Dutch or French + question against it returns `insufficient` evidence (verified empirically in the prior + work) — this is a real gap for the trilingual guided demo requirement. + +## Remaining technical user-facing language + +- `frontend/src/pages/Automation.tsx:183` — `{r.event_type}` rendered raw (e.g. + `vehicle.returned.v1`) in the primary ledger table, no human label. +- `frontend/src/pages/Automation.tsx:91` — "delivered through the outbox" in primary copy. +- `frontend/src/pages/Audit.tsx:129` — `{e.correlation_id.slice(0, 8)}` shown as the + visible `` trigger text for technical detail, not a meaningful reference. +- `frontend/src/pages/DataQualityIssueDetail.tsx:341,366` — "canonical odometer" used + directly in primary decision copy, not translated to an operational phrase. +- No central technical→human terminology mapping exists anywhere in the frontend. + +## Demo Guide panel problems + +- `frontend/src/components/DemoGuide.tsx` + the `@media (min-width: 701px)` rule in + `styles.css` implement exactly **one** behaviour for every desktop/tablet width ≥701px: + a fixed 400px right-side panel with `.app-workspace.guide-open { padding-right: + min(400px, 92vw) }` reserving space. There is no distinction between "extra-wide + desktop, dock + reflow with a guaranteed minimum content width" and "standard desktop/ + tablet, floating non-modal sheet that auto-collapses to a progress chip" as the brief + now requires — today the panel never collapses to a chip at all; it only fully opens + or fully closes. +- "Ga naar deze stap" (`DemoGuide.tsx`'s `goToStepRoute()`) only calls `navigate(...)`; it + does not scroll to or focus the relevant target element, and does not auto-collapse the + panel afterward. +- No semantic target anchors exist on pages for the guide to scroll/focus toward. +- Reduced-motion is not explicitly handled for the panel's open/close or any future + collapse/expand transition. + +## Data Quality Workbench contrast and hierarchy + +- Confirmed by direct inspection of `DataQualityIssueDetail.tsx`: only the odometer- + regression decision (lines 349,358) uses the higher-contrast `.check-card` treatment + (`background: var(--surface-subtle); border: 1px solid var(--line); padding: 10px 12px`). + Every other decision point — `DuplicateCustomerPanel`'s survivor choice (134,143) and + per-field merge choice (172,187), and `BookingOverlapPanel`'s block-choice (459) — uses + the bare `.checkbox-label` class: a small inline radio with no card background, no + border, no selected-state treatment, and no visible risk/consequence copy next to the + option. This is the inconsistency the brief describes as "kleine losse radioknoppen in + een zwak omlijnde rij." +- Resolve/defer/reject already have some visual differentiation (`button-primary` for the + main resolution action inside each panel; a separate "Defer or reject" section below), + but defer and reject are rendered as two identically-weighted plain ` + {open && ( +
+ +

+ {manifest ? ( + }} /> + ) : ( + t("badge.orgIntroFallback") + )} +

+

{t("badge.realWorkflows")}

+ {manifest && ( +

+ }} + /> +

+ )} + setOpen(false)}> + {t("badge.aboutLink")} + +
+ )} + + ); +} diff --git a/frontend/src/components/DemoGuide.tsx b/frontend/src/components/DemoGuide.tsx new file mode 100644 index 0000000..55a7caf --- /dev/null +++ b/frontend/src/components/DemoGuide.tsx @@ -0,0 +1,248 @@ +import { useNavigate, useLocation } from "react-router-dom"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api, ApiError } from "../api/client"; +import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { useViewportTier } from "../hooks/useViewportTier"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; +import { Icon } from "./Icons"; + +export function DemoGuideTrigger() { + const { t } = useTranslation("demo"); + const { user } = useAuth(); + const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide(); + + if (user?.role !== "operations_manager") return null; + + return ( + + ); +} + +function highlightTarget(selector: string | undefined) { + if (!selector) return; + const el = document.querySelector(selector); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + const previousTabIndex = el.getAttribute("tabindex"); + if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1"); + el.focus({ preventScroll: true }); + el.classList.add("demo-guide-highlight"); + window.setTimeout(() => { + el.classList.remove("demo-guide-highlight"); + if (previousTabIndex === null) el.removeAttribute("tabindex"); + }, 2200); +} + +export function DemoGuide() { + const { t } = useTranslation("demo"); + const navigate = useNavigate(); + const location = useLocation(); + const { logout } = useAuth(); + const { manifest, refresh } = useDemoManifest(); + const tier = useViewportTier(); + const { + open, + closeGuide, + currentIndex, + completed, + totalSteps, + goToStep, + completeAndAdvance, + restart, + collapsedToChip, + setCollapsedToChip, + } = useDemoGuide(); + const [resetting, setResetting] = useState(false); + const [resetError, setResetError] = useState(null); + const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half"); + const pendingTarget = useRef(null); + + const step = DEMO_GUIDE_STEPS[currentIndex]; + const isLastStep = currentIndex === totalSteps - 1; + + useEffect(() => { + if (!open) return; + function handleKeydown(event: KeyboardEvent) { + if (event.key !== "Escape") return; + if (tier === "standard" && !collapsedToChip) { + setCollapsedToChip(true); + } else if (tier === "mobile" && mobileSheetState !== "collapsed") { + setMobileSheetState("collapsed"); + } else { + closeGuide(); + } + } + document.addEventListener("keydown", handleKeydown); + return () => document.removeEventListener("keydown", handleKeydown); + }, [open, tier, collapsedToChip, mobileSheetState, closeGuide]); + + useEffect(() => { + if (!pendingTarget.current) return; + const target = pendingTarget.current; + pendingTarget.current = null; + const raf = requestAnimationFrame(() => highlightTarget(target)); + return () => cancelAnimationFrame(raf); + }, [location.pathname]); + + if (!open) return null; + + function goToStepRoute() { + pendingTarget.current = step.target ?? null; + navigate(step.route(manifest)); + if (tier === "standard") setCollapsedToChip(true); + if (tier === "mobile") setMobileSheetState("collapsed"); + } + + async function handleRestartDemo() { + setResetError(null); + setResetting(true); + try { + await api.post("/api/v1/demo/reset"); + restart(); + refresh(); + closeGuide(); + await logout(); + navigate("/login"); + } catch (err) { + setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed")); + } finally { + setResetting(false); + } + } + + if (tier === "standard" && collapsedToChip) { + return ( +
+ + +
+ ); + } + + const panelClassName = [ + "demo-guide-panel", + tier === "wide" ? "is-wide" : "", + tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "", + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..1d07142 --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { + persistLanguage, + SUPPORTED_LANGUAGES, + type SupportedLanguage, +} from "../i18n/config"; + +export function LanguageSwitcher({ compact = false }: { compact?: boolean }) { + const { t, i18n } = useTranslation("common"); + const current = (i18n.language as SupportedLanguage) || "nl-BE"; + + function handleChange(next: SupportedLanguage) { + void i18n.changeLanguage(next); + persistLanguage(next); + } + + return ( + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fd109eb..1aa6907 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,9 +1,15 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { api, ApiError } from "../api/client"; import { useAuth } from "../context/AuthContext"; import type { Role, SearchResultItem } from "../api/types"; import { BrandMark, Icon, type IconName } from "./Icons"; +import { DemoBadge } from "./DemoBadge"; +import { DemoGuide, DemoGuideTrigger } from "./DemoGuide"; +import { LanguageSwitcher } from "./LanguageSwitcher"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; const SEARCH_ICON: Record = { vehicle: "fleet", @@ -14,34 +20,36 @@ const SEARCH_ICON: Record = { interface NavItem { to: string; - label: string; - shortLabel: string; + labelKey: string; icon: IconName; roles?: Role[]; } -const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [ +const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [ { - label: "Operate", + labelKey: "groups.operate", items: [ - { to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" }, - { to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" }, - { to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" }, - { to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality", roles: ["operations_manager"] }, + { to: "/dashboard", labelKey: "items.overview", icon: "activity" }, + { to: "/vehicles", labelKey: "items.fleet", icon: "fleet" }, + { to: "/bookings", labelKey: "items.bookings", icon: "bookings" }, + { to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] }, ], }, { - label: "Assure", + labelKey: "groups.assure", items: [ - { to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" }, - { to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations", roles: ["operations_manager"] }, - { to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit", roles: ["operations_manager"] }, + { to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" }, + { to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] }, + { to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] }, ], }, ]; export function Layout() { + const { t } = useTranslation(["navigation", "common", "auth"]); const { user, logout } = useAuth(); + const { manifest } = useDemoManifest(); + const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide(); const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -129,7 +137,7 @@ export function Layout() { await logout(); navigate("/login"); } catch (err) { - setResetError(err instanceof ApiError ? err.message : "Could not reset demo data."); + setResetError(err instanceof ApiError ? err.message : t("resetFailed")); setResetConfirming(false); } finally { setResetting(false); @@ -164,23 +172,26 @@ export function Layout() { return (
- Skip to main content + {t("skipToContent")} - {mobileOpen &&
- + = 0 ? `search-result-${activeIndex}` : undefined} value={searchQuery} - placeholder="Search fleet, booking or section…" + placeholder={t("searchPlaceholder")} onFocus={() => setSearchOpen(true)} onChange={(event) => { setSearchQuery(event.target.value); @@ -242,13 +253,13 @@ export function Layout() { }} onKeyDown={handleSearchKeyDown} /> - Ctrl K + {t("searchShortcutHint")} {searchOpen && searchQuery.trim() && (
- {searchLoading &&

Searching…

} - {!searchLoading && searchError &&

Search is unavailable right now.

} + {searchLoading &&

{t("searchSearching")}

} + {!searchLoading && searchError &&

{t("searchUnavailable")}

} {!searchLoading && !searchError && searchResults.length === 0 && ( -

No matches for "{searchQuery.trim()}".

+

{t("searchNoResults", { query: searchQuery.trim() })}

)} {!searchLoading && !searchError && @@ -274,36 +285,40 @@ export function Layout() { )}
- Europe/Brussels + + + + {t("common:timezone")} {user && (
{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)} - {user.display_name}{user.role === "operations_manager" ? "Operations manager" : "Rental employee"} + {user.display_name}{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}
)} -
-

Synthetic demo data · no real customer or vehicle information

-
MobilityOps PoCEurope/Brussels · Synthetic demo data
+
{t("common:footer.productLine")}{t("common:footer.locale")}
-
); } diff --git a/frontend/src/components/PageChrome.tsx b/frontend/src/components/PageChrome.tsx index c2eb648..acad7a3 100644 --- a/frontend/src/components/PageChrome.tsx +++ b/frontend/src/components/PageChrome.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Icon, type IconName } from "./Icons"; export function PageHeader({ @@ -28,15 +29,17 @@ export function SectionHeading({ title, description, action, + headingId, }: { title: string; description?: string; action?: ReactNode; + headingId?: string; }) { return (
-

{title}

+

{title}

{description &&

{description}

}
{action} @@ -44,20 +47,22 @@ export function SectionHeading({ ); } -export function LoadingState({ label = "Loading workspace…" }: { label?: string }) { +export function LoadingState({ label }: { label?: string }) { + const { t } = useTranslation("common"); return (
); } export function ErrorState({ message }: { message: string }) { + const { t } = useTranslation("common"); return (
-
We couldn’t load this workspace.

{message}

+
{t("states.errorTitle")}

{message}

); } diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx index d967baf..6330da7 100644 --- a/frontend/src/components/RequireAuth.tsx +++ b/frontend/src/components/RequireAuth.tsx @@ -8,7 +8,7 @@ export function RequireAuth({ children }: { children: ReactNode }) { if (loading) { return (
- +
); } diff --git a/frontend/src/components/ReturnForm.tsx b/frontend/src/components/ReturnForm.tsx index 1ef68bc..724504f 100644 --- a/frontend/src/components/ReturnForm.tsx +++ b/frontend/src/components/ReturnForm.tsx @@ -1,8 +1,13 @@ import { useState, type FormEvent } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { api, ApiError } from "../api/client"; import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { useLocaleFormat } from "../i18n/format"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { Icon } from "./Icons"; import { StatusBadge } from "./Badge"; @@ -13,16 +18,27 @@ function newIdempotencyKey(): string { } export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) { + const { t } = useTranslation("returns"); const { user } = useAuth(); + const navigate = useNavigate(); + const { manifest } = useDemoManifest(); + const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide(); const canSeeQualityIssue = user?.role === "operations_manager"; + + function continueDemo() { + completeAndAdvance(); + const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1); + navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest)); + } + return (
-

Committed locally

Return registered

+

{t("result.committedLocally")}

{t("result.heading")}

-
Inspection
{result.inspection_ref}
-
Resulting vehicle status
+
{t("result.inspection")}
{result.inspection_ref}
+
{t("result.resultingStatus")}
-
Quality issue
+
{t("result.qualityIssue")}
{result.quality_issue_ref ? ( canSeeQualityIssue ? ( @@ -31,36 +47,39 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) result.quality_issue_ref ) ) : ( - "None created" + t("result.noneCreated") )}
-
Automation event
-
- Queued for delivery ({result.workflow_event_id.slice(0, 8)}) — local commit succeeded; - n8n delivery is asynchronous and not yet confirmed. -
+
{t("result.automationEvent")}
+
{t("result.queuedForDelivery", { ref: result.workflow_event_id.slice(0, 8) })}
-
Next booking risk
+
{t("result.nextBookingRisk")}
{result.next_booking_risk - ? `${result.next_booking_risk.booking_ref} ${result.next_booking_risk.at_risk ? "— may be affected" : "— low risk"}` - : "No upcoming booking for this vehicle"} + ? t("result.nextBookingRiskValue", { + ref: result.next_booking_risk.booking_ref, + status: result.next_booking_risk.at_risk ? t("result.atRisk") : t("result.lowRisk"), + }) + : t("result.noUpcomingBooking")}
{result.odometer_regression && ( -

- The submitted odometer reading was below the vehicle's canonical odometer. It was - recorded as-is; the canonical odometer was not changed, and a data-quality issue was - opened for review. -

+

{t("result.odometerRegressionNotice")}

)} -

- View vehicle {result.vehicle_ref} -

+
+ {t("result.viewVehicle", { ref: result.vehicle_ref })} + {t("result.viewAutomation")} + {t("result.viewAudit")} + {guideOpen && ( + + )} +
); } @@ -68,11 +87,15 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) export function ReturnForm({ bookingRef, onRegistered, + suggestedOdometerKm, }: { bookingRef: string; onRegistered: (result: RegisterReturnResult) => void; + suggestedOdometerKm?: number; }) { - const [odometer, setOdometer] = useState(""); + const { t } = useTranslation(["returns", "errors"]); + const { formatNumber, formatDateTime } = useLocaleFormat(); + const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : ""); const [fuel, setFuel] = useState("50"); const [cleanlinessOk, setCleanlinessOk] = useState(true); const [damageReported, setDamageReported] = useState(false); @@ -109,9 +132,7 @@ export function ReturnForm({ setPreview(evaluated); setStep("review"); } catch (err) { - setError( - err instanceof ApiError ? err.message : "Could not evaluate this return. Please try again.", - ); + setError(err instanceof ApiError ? err.message : t("errors:generic")); } finally { setPreviewing(false); } @@ -127,11 +148,7 @@ export function ReturnForm({ ); onRegistered(registered); } catch (err) { - if (err instanceof ApiError) { - setError(err.message); - } else { - setError("Could not register the return. Please try again."); - } + setError(err instanceof ApiError ? err.message : t("errors:generic")); } finally { setSubmitting(false); } @@ -139,13 +156,13 @@ export function ReturnForm({ return (
-
1 Capture2 Review3 Result
-

Booking {bookingRef}

{step === "capture" ? "Register vehicle return" : "Review return impact"}

{step === "capture" ? "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed." : "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation."}

+
1 {t("progress.capture")}2 {t("progress.review")}3 {t("progress.result")}
+

{bookingRef}

{step === "capture" ? t("capture.heading") : t("review.heading")}

{step === "capture" ? t("capture.description") : t("review.description")}

{error &&

{error}

} {step === "capture" ?
-
Vehicle condition