Author SHA1 Message Date
NuklearRabbit ad1182582d docs: record visual roadmap evidence 2026-08-10 01:05:30 +02:00
NuklearRabbit f2cdad194c UX: implement visual product roadmap 2026-08-10 01:05:07 +02:00
NuklearRabbitandClaude Sonnet 5 13ad2ba6a3 docs: record the MCP Hub URL fix and RAGcore Procedure Sync go-live
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 22:07:41 +02:00
NuklearRabbitandClaude Sonnet 5 086dfed992 fix: publish RAGcore Procedure Sync and derive its evidence for real
MCP_HUB_BASE_URL had the same wrong-hostname bug as RAGCORE_BASE_URL earlier
this session (itworx-mcp-hub:8000 doesn't resolve; the real container is
reachable at the host's own 192.168.10.150:1100) -- fixed live, resolving the
Automation page showing "Operationeel" and "Hub Onbereikbaar" simultaneously.

Went on to actually publish the "Fleet Ops -- RAGcore Procedure Sync" n8n
workflow now that RAGcore is reachable: its own RAGcore Sync Token credential
had gone stale from the same rotation as the earlier one, so minted a fresh,
dedicated, minimally-scoped (sources:sync only) credential, verified a real
manual run (33 synced, 0 failed, result registered) before publishing.

That exposed a real, now-stale bug: derive_n8n_status() hardcoded this
workflow's evidence to None with a comment explaining it was unpublished --
true when written, false now. The workflow's own result-report callback
already writes a real n8n_procedures_synced audit event; wired that in as its
evidence source, the same pattern the scheduled scan and error handler already
use, instead of a value that could never update itself once the workflow went
live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 22:05:13 +02:00
NuklearRabbitandClaude Sonnet 5 64cc96fa4b docs: record the RAGcore go-live fix and evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:22:57 +02:00
NuklearRabbitandClaude Sonnet 5 319f43312e fix: drop RAGcore's opaque version UUID from the fallback answer sentence
document_version_id is an internal UUID, not a human-meaningful version like
the demo corpus's markdown frontmatter -- confirmed live it made the fallback
answer read as "Per \"vehicle-checkout-procedure.md\" (v2e139422-b10b-...)".
Still shown on the source card itself, just not in the composed sentence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:18:58 +02:00
NuklearRabbitandClaude Sonnet 5 a2433d7fa3 fix: fall back to real RAGcore search when /v1/answers is unavailable
RAGcore's /v1/answers (generation + citation validation) is currently returning
a consistent 503 VALIDATION_RETRIES_EXHAUSTED live -- a RAGcore-side bug in its
own generation/validation step, out of scope to fix here (CLAUDE.md forbids
modifying the RAGcore repo). Its retrieval pipeline (/v1/search) is a materially
different, simpler stage with no generation step, and returns real, correctly
cited results right now.

RAGcoreKnowledgeProvider.ask() tries /v1/answers first (unchanged behavior once
RAGcore's generation is fixed), and only when that endpoint itself is
unavailable -- non-2xx or unreachable, never a real 200 classifying the
question as insufficiently answerable -- falls back to /v1/search and builds
the shown "answer" as an extractive citation-wrapped excerpt, mirroring
DemoKnowledgeProvider's own existing template exactly. Never invents an answer
to the question; only ever shows a real, cited excerpt RAGcore's own search
actually found.

Also fixed two real config bugs found while wiring this up live: RAGCORE_BASE_URL
pointed at a non-existent internal hostname (ragcore-api:8000 -- the real
container is reachable at the host's own address on port 1237), and the
previous test credential had been invalidated with nothing to replace it. Minted
a fresh, correctly-scoped service-account credential via RAGcore's own admin
control plane (the documented, legitimate way to obtain one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:11:28 +02:00
NuklearRabbitandClaude Sonnet 5 453c7241fe docs: record the MCP Hub go-live fix and evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:19:07 +02:00
NuklearRabbitandClaude Sonnet 5 529e7364a9 fix: pass MCP_HUB_REGISTRATION_ENABLED/MCP_HUB_BASE_URL through to the api container
compose.yaml's api service environment block forwarded MCP_HUB_SERVICE_TOKEN but
never these two -- so .env's value was silently ignored and Settings always fell
back to its Python default (false / empty), no matter what .env said. Found while
flipping the flag live: the container's actual reported registration_enabled
stayed false after a full recreate, even though .env had been updated and two
real mcp_tool_request audit events already existed (itworx-mcp-hub:readiness ->
fleet_ops_get_operations_summary), proving the Hub's connector already reaches
Fleet Ops successfully independent of this flag -- only the status display was
gated, and silently stuck off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:15:17 +02:00
NuklearRabbitandClaude Sonnet 5 7d686ae2aa docs: record polish fixes, merge, and second deploy evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 19:04:42 +02:00
NuklearRabbitandClaude Sonnet 5 c2b8268927 fix: form field alignment, raw maintenance text, and static movements list
- .form-grid labels (missing-field form, odometer-regression correction fields)
  and the odometer/overlap note textareas had no stacked label-above-input
  styling at all -- the shared rule only covered .filters/.return-form, so these
  fell back to default inline browser layout with mismatched input widths.
  Extended the existing rule to cover .form-grid and label:has(> textarea).

- Vehicle maintenance list showed the raw, untranslated seed text
  ("Synthetic scheduled service record") regardless of locale -- purely
  decorative and 1:1 redundant with the (already-translated) category. Replaced
  it with the record's real odometer reading, mirroring the sibling
  Inspections tab's pattern.

- "Today's movements" was always the same fixed 4 bookings (2 returns, 2
  departures) on every reset, reading as a static mockup rather than live
  fleet activity. Added 8 more bookings anchored to land on "today" across 8
  additional vehicles, spread through the day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:29:56 +02:00
NuklearRabbitandClaude Sonnet 5 9e9dd8e0e3 docs: record the three-defect fix, gates, and live verification evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:52:13 +02:00
NuklearRabbitandClaude Sonnet 5 4faac24b5a fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers
Three content defects found by a live reviewer:

- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
  11 of 15 seeded issues that text was literally "Synthetic deterministic seed
  issue". AttentionItem now exposes evidence_signals (stable code + params, same
  shape as the issue detail page) instead of a detail string; the frontend renders
  them through a shared describeEvidenceSignal() used by both the dashboard and the
  issue detail page. Every previously-placeholder seed row now cites a real,
  per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
  field, or a real pair of booking odometer readings) instead of invented prose.

- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
  one, so "needs attention" led nowhere. Each now has a real open
  missing_required_field issue backed by a genuinely blank field (no schema change,
  no migration -- reuses the existing data-quality pipeline).

- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
  show a localized explanation ("trip hasn't started yet" / "not yet closed").
  MO-024's rented-but-service-overdue contradiction was already caught by the
  vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
  regression test rather than new logic.

Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:44:32 +02:00
NuklearRabbitandClaude Sonnet 5 3808bbe132 docs: record push, Unraid deploy, and live verification evidence
Closes out the demo-scenario fix: pushed the two pending commits, deployed
6f77a30 to Unraid, and verified all four live checks (integration status,
failed-workflow listing, retry via API and UI, audit trail, Automation page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:39:41 +02:00
NuklearRabbit 6f77a30dce fix: stop the prepared demo failure from degrading n8n integration health
The demo seed plants exactly one failed delivery (BK-H-0020) to demonstrate
retry and audit. Because derive_n8n_status() counted any failure, every fresh
reset pinned the n8n integration to "degraded" -- the demo showed a warning
about a prop, which tells a viewer something untrue about the automation.

The seeded failure now carries its own error code, demoScenarioTimeout, rather
than the generic connectionError a real timeout produces. No column and no
migration: last_error_code already existed, is already surfaced to the UI and is
already localizable.

- integration status splits failed into unexpected_failed and
  demo_scenario_failed; only unexpected failures may move the state. A staged
  failure alone leaves n8n operational.
- latest_failure_at is a health signal and now ignores the staged failure;
  latest_demo_scenario_at reports it separately.
- /api/v1/workflows exposes is_demo_scenario. The Automation page labels the run
  as a prepared demo scenario, explains that it is a simulated temporary failure
  that does not affect automation health, and offers a distinct "retry demo
  scenario" action. Translated in nl-BE, en-GB and fr-BE.
- the carve-out stays narrow: a real failure still degrades n8n, and a genuine
  later failure of the same event overwrites the demo code with the real one.
- the retry itself is unchanged and real: the event goes back on the outbox and
  the dispatcher delivers it to n8n like any other, so 19+1 becomes 20+0 only on
  an actual round trip. The audit records which kind of failure was retried.

Tests that assert on the seeded scenario now reseed first, since earlier test
files legitimately mutate the outbox and the suite shares one database.

Verified locally against a real PostgreSQL 16: 181 passed, ruff clean, mypy
clean (50 files), tsc clean, frontend build clean. Not deployed and not
browser-verified.
2026-08-05 14:07:05 +00:00
NuklearRabbit e5307a7c0f fix: derive demo-manifest MCP Hub status from real tool-call evidence
The demo manifest still reported the MCP Hub integration as operational purely
because MCP_HUB_REGISTRATION_ENABLED was set, while the integration status page
had already moved to evidence-based status in Batch 4. Registration is
catalog-driven on the Hub's side, so the flag alone proves nothing; reuse
derive_mcp_hub_status() so "operational" requires real recorded mcp_tool_request
calls.

No change to the MCP integration contract: the four read-only routes, service
token and client id handling, inbound X-Correlation-Id preservation, the locale
field on search-knowledge and the provider/correlation_id response fields were
verified as already correct at deployed revision 727c19a and left untouched.
2026-08-05 13:28:43 +00:00
NuklearRabbitandClaude Sonnet 5 dee8f2f7e9 docs: record Batch 5 evidence and final session state
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:51:11 +02:00
NuklearRabbitandClaude Sonnet 5 57992bf153 M10: AI Operations Brief runbook, full live e2e regression, final evidence
Ran a real AI Operations Brief via the live ITWorx MCP Hub connector's own
MobilityOpsClient against production Fleet Ops: real operations summary, real
most-pressing vehicle, real grounded knowledge answer with citations, real
correlation IDs verified end-to-end in Fleet Ops's own audit log. No write
actions performed. Runbook and full output in
docs/final-integrations/ai-operations-brief-runbook.md.

Ran the full Playwright e2e suite against the live deployed instance and fixed
two pre-existing fragile locators unrelated to this session's feature work
(both broke because Automation now legitimately has two tables sharing the
same generic selectors, exposed by running the full suite rather than
individual files) plus one pre-existing untranslated-loanword false positive.
All specs pass.

artifacts/final-integrations/final-summary.md has the complete evidence
write-up: repository/deployment state, what was fixed vs. handed off, test
results, and known limitations stated plainly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:49:23 +02:00
NuklearRabbitandClaude Sonnet 5 727c19a779 M9: MCP Hub locale/correlation propagation, real Hub health check, fix stale test image
Fixed two concrete gaps in the MCP knowledge-search endpoint: no locale field
existed at all (now nl-BE/en-GB/fr-BE, wired to the knowledge provider's
existing language param), and the correlation ID was always freshly minted,
ignoring any inbound X-Correlation-Id header. Added a shared dependency and
applied it to all four MCP endpoints so Fleet Ops's own audit log preserves
the Hub's real correlation ID end to end.

MCP_HUB_BASE_URL/MCP_PROVIDER_ID were declared in .env.example but never read
anywhere. Since the Hub's own registration is catalog-driven (it never needs
Fleet Ops to push a registration call), wired mcp_hub_base_url for a real Hub
reachability health check instead of an unneeded self-registration call.

Renamed Fleet Ops's own internal audit tool labels mobilityops_* -> fleet_ops_*
(mirrored in contracts/mcp-tools.json with mobilityops_* kept as deprecated
aliases); documented that the live Hub connector's own dotted tool namespace
is a separate, Hub-owned naming layer, deliberately not touched.

Automation page's MCP card now shows real evidence (last tool/client/count/
timestamp, honest no-evidence state) instead of just the registration flag.

Also fixed a real methodology gap found mid-session: compose.yaml's api
service has no bind mount, so `docker compose run --rm api` silently tests a
stale image until rebuilt. Re-ran every local gate after rebuilding; fixed one
genuinely stale test assertion and two lint line-length errors surfaced by
that rebuild. 176 tests passing, ruff clean, mypy clean (50 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:30:24 +02:00
NuklearRabbitandClaude Sonnet 5 2ae2044e3a docs: record Batches 1-3 evidence and exact next action
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:05:53 +02:00
NuklearRabbitandClaude Sonnet 5 34df66d28c M8: GUI polish, n8n workflow-3 fixes, RAGcore retrieval root-cause and fix
GUI: dashboard Attention Queue presents a curated severity mix instead of pure
severity-sort (grouped Now/Today/Later headers); Today's Movements seed data
curated so a fresh reset shows a credible day (2+ departures, 2+ returns), with
a new seed-integrity test; About Demo restructured into a compact grid with
progressive disclosure for technical sections; Duplicate Merge shows match/conflict
counts, hides matching fields by default, and previews the final merged record
before confirmation.

Repo hygiene: removed a stray empty `backend;C` directory and an untracked 31MB
zip export; `.gitignore` now excludes future archive exports.

n8n: fixed invalid JSON (a missing `},` between two node objects) in the committed
`fleet-ops-vehicle-return.json` -- the file could not be parsed. Live-validated
workflow 3 (RAGcore Procedure Sync): found and fixed a real defect (three body
parameters had a stray trailing `}}`) and a missing Error Workflow wiring, both
via the safe `n8n import:workflow` CLI path; exported the corrected, still-
inactive workflow as the new source of truth and updated MANIFEST.md/check_drift.py.
Publishing it (starts real daily unattended runs) remains a separate decision.

RAGcore: root-caused and fixed (live, approved) the "zero retrieval candidates"
bug -- a filesystem permission bug (`embedding_profiles.json` unreadable by the
app's own runtime user) that broke every retrieval call before it reached Qdrant.
Every other suspect (grants, scope resolution, Qdrant filters, embeddings) was
verified healthy first. Found a second, deeper gap: the reranker adapter calls
an Ollama HTTP route that does not exist on the deployed Ollama version, so
`/v1/answers` still returns `not_answerable`. `KNOWLEDGE_PROVIDER` stays `demo`
until that is resolved on the RAGcore side. Evidence-based MCP Hub integration
status (real tool-call audit history, not just a boolean flag) replaces the old
`configured`/`not_configured` guess. Full findings in
`docs/final-integrations/current-state-audit.md`.

Backend: 172 tests passing, ruff clean, mypy clean (50 files). Frontend: tsc
clean, production build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:05:02 +02:00
NuklearRabbitandClaude Sonnet 5 3ebca9e9b7 feat: replace brand mark with waypoint (pin-on-route) logo
Swaps the peaks-over-a-road glyph for a location pin on a route line
in both the favicon and the BrandMark component, so the mark stays
legible at 16px favicon size and reads more literally as fleet/route
tracking. Palette unchanged (navy #0f172a, teal #2dd4bf).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:47:11 +02:00
NuklearRabbitandClaude Sonnet 5 b66521da82 docs: record branch push and Unraid redeploy to 0571a40
Pushed feat/live-n8n-ragcore-integration to origin, then redeployed
the live Fleet Ops instance from 0da5251 to 0571a40 following the
deployment directory's own established source-archive convention.
Verified live: /health OK, the new n8n procedures endpoint (added
this branch) is reachable and correctly auth-gated, KNOWLEDGE_PROVIDER
still demo as intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:25:30 +02:00
NuklearRabbitandClaude Sonnet 5 0571a40649 docs: record n8n proxy-hops fix and completed workflow-3 build
Root cause found via the live n8n container's own logs: N8N_PROXY_HOPS=0
in the Unraid template didn't match the real reverse-proxy in front of
it, breaking the browserId/CSRF check on every workflow save while
leaving the UI looking fully signed in -- the user's pushback that it
"shows logged in" was correct and prompted digging into server logs
instead of continuing to guess client-side.

Fixed by editing the Unraid template (N8N_PROXY_HOPS 0->1, backed up
first) and recreating the container with every other setting preserved
exactly. Verified by reproducing the exact save action that used to
fail; it now works, and node persistence survives a full reload.

Built and saved both remaining workflow-3 nodes (Summarize sync
result, Report sync result to Fleet Ops) after recovering from an
errant Ctrl+A that deleted a node mid-verification (caught via node
count, restored via n8n's own Version History, redone carefully). Not
published -- that starts real daily production runs and is left for a
separate decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 07:12:57 +02:00
NuklearRabbitandClaude Sonnet 5 4227fe4f58 docs: record live /v1/answers verification and a retrieval finding
Issued a fresh scoped credential via the RAGcore admin UI (separate,
working OIDC session, unaffected by the n8n auth problem) and made a
real authenticated /v1/answers call. Got a clean 200 with a real
answer_id/retrieval_run_id, not degraded -- but not_answerable, 0
citations.

Confirmed this isn't a regression: the same query through RAGcore's
own pre-existing Query Lab tool (untouched this session) returns the
identical result down to zero dense/sparse candidates at the raw
retrieval stage. Ruled out the obvious causes via direct Qdrant/
Postgres checks -- workspace_id, space_id, status, and embedding
digest all correctly match real indexed content. Root cause not yet
found; flagged as a follow-up rather than pursued further to avoid
scope creep on what was a deployment-verification task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 04:01:16 +02:00
NuklearRabbitandClaude Sonnet 5 c790ec99cb docs: record RAGcore search/answer wiring deployment to production
Deployed a2905cc to the live RAGcore instance (approved). Verified
the fix directly: POST /v1/search now returns 401
AUTHENTICATION_REQUIRED instead of the old permanent 503
SEARCH_UNAVAILABLE, proving the endpoint reaches real request
handling. A full authenticated /v1/answers call with a real grounded
answer is still outstanding -- the previously-issued production
credential's raw token was never persisted anywhere retrievable, and
issuing a fresh one needs the RAGcore admin UI, not attempted this
round.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:44:39 +02:00
NuklearRabbitandClaude Sonnet 5 fd390df423 docs: record n8n workflow-3 wipe/recovery and the live auth blocker
The live "Fleet Ops -- RAGcore Procedure Sync" workflow's canvas was
found at zero nodes -- the earlier session's abandoned direct n8n
REST API attempt had gone far enough to wipe it before hitting its
401. Recovered via n8n's own Version History "Restore version"
action back to the last good 4-node save; verified via DOM node
count before and after.

Adding the two remaining nodes then hit the same failure mode: n8n's
own first-party autosave reported "Unauthorized" moments after a
fresh, successful interactive sign-in. Stopped deliberately rather
than retrying against a live instance that already caused one data
loss incident this session -- this looks like an n8n-side session/auth
problem, not something fixable from browser automation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:38:16 +02:00
NuklearRabbitandClaude Sonnet 5 e5d8466266 knowledge: rewrite RAGcoreKnowledgeProvider to the real search/answers contract
The previous adapter targeted an endpoint shape RAGcore never actually
exposed. health() now checks /health/ready and ask() posts to the real
POST /v1/answers with Bearer auth and requested_space_ids, matching
RAGcore's actual contract after this session's Bearer-auth and
search/answer wiring work.

Adds RAGCORE_SPACE_ID config/env plumbing (a question is meaningless
without a knowledge space to scope it to) and 12 new adapter tests
covering degradation paths: missing space id, connection errors,
non-200 responses, malformed responses, not-answerable, and
answerable-without-citations all fail closed to "insufficient
evidence" rather than fabricating an answer.

KNOWLEDGE_PROVIDER stays "demo" in production for now -- switching
requires RAGcore's own search/answer application to actually be
deployed and live-verified, tracked separately in PROJECT_STATE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:20:55 +02:00
NuklearRabbit 0da5251524 n8n: add backend endpoints for the RAGcore Procedure Sync workflow
GET /api/v1/integrations/n8n/procedures lists every procedure Markdown
file Fleet Ops ships (all languages) with a stable per-document id and
content hash, ready for workflow 3 to push into RAGcore. POST
.../procedures-sync-result records the sync outcome as an idempotent
audit event, matching the existing return-callback/workflow-error
pattern. Extracted frontmatter parsing out of the demo knowledge
provider into a shared module so both read the same source of truth.
2026-08-04 19:47:39 +02:00
NuklearRabbit 2afceea5e4 docs: record root cause and fix for the RAGcore credential-issuance bug
With explicit owner approval, traced the persistent credential-issuance
rejection to a cross-transaction race in RAGcore's own dependency
injection (two independent DB transactions per request instead of one
shared transaction), fixed and deployed it in RAGcore, and verified a
working "RAGcore Sync Token" n8n credential now exists. Unblocks
workflow 3 and the RAGcoreKnowledgeProvider adapter rewrite.
2026-08-04 18:11:34 +02:00
NuklearRabbit cf4d8e3649 docs: write final n8n + RAGcore integration evidence summary
Consolidates this effort's outcome across all four canonical workflows:
WF1/WF2 hardened and live, WF3 blocked on a RAGcore-side credential
rejection (with trace IDs for the operator to investigate), WF4 built
and live-validated with one open non-blocking follow-up. No credential
values or secrets included.
2026-08-04 17:07:54 +02:00
NuklearRabbit aaa1630535 docs: record WF2 retry fix and WF4's n8n-session-expiry blocker 2026-08-04 17:04:33 +02:00
NuklearRabbit 167bf49b6e n8n: add bounded retries to WF2's quality-scan callback
Found during this round's full acceptance pass: WF2 had the same
timeouts/bounded-retries gap as WF1 (timeout was already set, but Retry
On Fail was disabled). Fixed live (3 tries, 1000ms wait), published, and
synced the repo definition + manifest checksum.
2026-08-04 17:03:52 +02:00
NuklearRabbit fd0c55b13b docs: record RAGcore credential re-attempt and its concrete failure evidence
User explicitly authorized issuing the RAGcore credential directly this
round. Retried via the admin UI (Platform Admin role) after the earlier
raw-API attempt; both fail with an opaque server-side rejection carrying
a trace ID. Documents this as a RAGcore-side blocker, not a Fleet Ops gap.
2026-08-04 16:54:02 +02:00
NuklearRabbit 05628936ca n8n: add bounded retries and timeout to WF1's Fleet Ops callback
Vehicle Return Orchestration had no explicit timeout and Retry On Fail
disabled on its outbound HTTP call, a gap against the acceptance
checklist's timeouts/bounded-retries requirement. Fixed live (3 tries,
1000ms wait, 15s timeout, matching WF2's existing convention) and
synced the repo definition + manifest checksum.
2026-08-04 16:46:11 +02:00
NuklearRabbitandClaude Sonnet 5 b341436e77 docs: record integration status page verification and deploy evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 16:32:43 +02:00
NuklearRabbitandClaude Sonnet 5 4049c0c6b1 n8n: surface real per-workflow evidence on the integration status page
Fleet Ops integration status no longer depends only on a config
boolean or the most recent outbox event: N8nIntegrationStatus now
reports per-canonical-workflow evidence (last successful outbox
delivery for the return workflow, latest service-triggered
data_quality_scan_run for the scan workflow, latest
n8n_workflow_failure_registered for the error handler, and "not built"
for the still-blocked RAGcore sync), plus an error-handler summary
(total failures registered, latest failure + which workflow).

Automation page renders this as a localized workflow table (EN/NL/FR)
with technical workflow names tucked under a "Technical details"
disclosure, matching the existing progressive-disclosure pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 16:15:09 +02:00
NuklearRabbitandClaude Sonnet 5 e39c0a1dd6 n8n: build and live-validate the Workflow Error Handler (WF4)
New central "Fleet Ops — Workflow Error Handler" workflow (Error
Trigger -> safe-report Code node -> POST to the new /workflow-error
endpoint), wired as the Error Workflow on both existing workflows with
no recursive loop on itself. Live-validated end-to-end against the
real Fleet Ops server (register + idempotent re-register), and via a
genuine induced failure on the scheduled-scan workflow (broken URL,
confirmed failure, reverted, confirmed healthy).

Fixed two real bugs found during live testing: Code node needed
"Run Once for Each Item" (not "All Items") for $json binding, and
every HTTP body field had a stray trailing space from the n8n
code-editor's bracket auto-close that broke datetime/enum validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:52:17 +02:00
NuklearRabbitandClaude Sonnet 5 bbdb4a9ae8 n8n: add Fleet Ops endpoint to receive workflow error reports
New POST /api/v1/integrations/n8n/workflow-error, service-token
authenticated, for the central "Fleet Ops — Workflow Error Handler"
n8n workflow to report a bounded, secret-free failure (workflow id/
name, execution id, safe error category, trigger context, correlation
id, attempt, retry action). Idempotent on execution_id via the same
audit-event precheck pattern used by /return-callback, so a
redelivered error report is not registered twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:40:36 +02:00
NuklearRabbitandClaude Sonnet 5 e0c107a94a n8n: store cleaned workflow definitions as repo source of truth
Move the two live-validated workflows into n8n/workflows/ (credential-
based auth referenced by name only, no secret values), add a manifest
covering all 4 canonical workflows and a read-only drift-check script
against n8n's Public API. Retire the pre-integration root-level starter
files that still carried the literal-token pattern, and repoint the
Unraid deploy scripts, Makefile targets and runbook at the new files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:34:51 +02:00
NuklearRabbit 59cb4c062e docs: record n8n corrections applied and failure-history triage
Appends a follow-up section to the current-state audit: both existing
workflows renamed to their canonical Fleet Ops names and republished
(IDs/history preserved), and all 6 error executions in the return-
processing workflow's entire history triaged -- the 4 original ones
were the workflow's own author testing against the local test webhook
during initial setup on 2 August, the 2 newest are this session's own
deliberate auth-fix validation calls. Zero unexplained failures remain.
2026-08-04 09:15:08 +02:00
NuklearRabbit b79d485ef1 docs+fix: audit live n8n state, require auth on the return webhook
Inspected the shared n8n instance (n8n.itworx.tech) live: both existing
Fleet Ops workflows are genuinely active and structurally match the repo,
but the shared X-Service-Token secret was stored as plaintext literal
text in both HTTP Request nodes (exportable in the clear), and the
production return webhook had n8n-level Authentication set to "None"
(publicly callable by anyone who discovered the URL). Findings recorded
in docs/live-ai-integration/n8n-current-state.md.

Fixed on the n8n side (both workflows published): the shared token now
lives in a single Header Auth credential instead of two literal copies;
the return webhook now requires a second, distinct Header Auth
credential.

Fixed on the Fleet Ops side to match: the outbox dispatcher now sends
the new X-Fleet-Ops-Trigger-Token header (new
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN setting) when calling the webhook.
Live-verified against the real webhook: a request with no header is now
rejected (403); a request with the correct header passes n8n's auth and
reaches Fleet Ops's own business logic.

That same live test also surfaced a real robustness gap: an n8n
execution that errors before its "Respond to Webhook" node runs can
still answer with a 2xx status and an empty body, which made
response.json() raise an uncaught exception, potentially leaving the
outbox event stuck in "delivering". Now treated as an explicit,
retryable failure (error_code=malformedResponse), with a regression
test reproducing the exact case.
2026-08-04 05:03:33 +02:00
NuklearRabbit c0995b762e docs(release): final Fleet Ops localization correction evidence
Final evidence for the small correction round merged in 5f0eaa5:
commits, translation fixes, API-error-localization result, greeting
logic and edge-case evidence, clean-checkout drill, Unraid deployment
evidence, repository/runtime hash comparison, known limitations
(including the transient document.lang anomaly observed during
interactive testing, root-caused as far as possible and not
reproduced in any automated run), and rollback procedure.
2026-08-04 04:01:55 +02:00
NuklearRabbit 5f0eaa59b0 merge: finalize Fleet Ops localization 2026-08-04 03:46:20 +02:00
NuklearRabbit 09173a4740 fix: correct fr-BE audit column label Actor -> Auteur
Caught during live browser validation on Unraid: fr-BE had "Acteur" for
the audit trail's actor column, but the brief's minimum-required French
corrections specify "Actor" -> "Auteur" explicitly.
2026-08-04 03:32:48 +02:00
NuklearRabbit 9468cc3e21 docs: update PROJECT_STATE and README for the final localization round
PROJECT_STATE.md: fix the stale "Product name: MobilityOps."/"PoC only"
locked-decisions lines (predate the Fleet Ops rebrand), fix the "Fleet
Ops correction" section header still reading "IN PROGRESS .../Not yet
merged to master" when it was in fact already merged (de0bdea, evidence
commit f780557), and append a new dated entry for this correction round
with commits and gate evidence so far.

README.md: reference docs/fleet-ops-final-localization/ alongside the
existing docs/fleet-ops-correction/ link, refresh the stale Playwright
test count (113 -> 138).
2026-08-04 03:10:37 +02:00
NuklearRabbit f0d641198c fix: serve the missing Fleet Ops favicon
There was no favicon at all -- index.html never linked one, and the
frontend Dockerfile's build stage never copied the public/ directory
into the build context, so even after adding public/favicon.svg
locally, the containerized build silently dropped it (nginx fell back
to serving index.html for that path). Fixed both: index.html links
/favicon.svg, and the Dockerfile now copies public/ alongside src/.
The favicon reuses the existing BrandMark glyph (petrol background,
teal accent) for visual consistency with the in-app brand mark.
A regression test for this lives in the earlier translation-fix commit
(frontend/e2e/fleet-ops-correction.spec.ts), added together with the
fix at the time.
2026-08-04 03:10:11 +02:00
NuklearRabbit 77208b857a fix: prevent topbar overflow from an unbreakable Dutch role-name translation
Correctly translating auth.json's roleOperationsManager from the old
two-word "Operations Manager" (which could wrap at the space) to the
single Dutch compound word "Operationsmanager" (which cannot) pushed the
topbar's .operator block past its 1024px-breakpoint budget, caught by
the existing responsive-i18n.spec.ts overflow test. Fixed with
overflow-wrap: anywhere on the role/name text and min-width: 0 on their
flex-item wrapper, rather than reverting the correct translation.
2026-08-04 03:09:17 +02:00
NuklearRabbit e427313bce feat: add time-dependent Europe/Brussels dashboard greeting
The dashboard greeting was a fully static "Goedemorgen..." regardless of
actual time of day. New frontend/src/i18n/greeting.ts::getGreetingPeriod
is a pure, clock-injectable function resolving one of 4 periods (05:00-
11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59
night) against Europe/Brussels wall-clock time via
Intl.DateTimeFormat({ timeZone, hourCycle: "h23" }), which is DST-safe
by construction.

useGreetingPeriod.ts wires this into React with a 30s poll so the
greeting rolls over live while the app stays open, no reload required.
Each period now has its own greeting word and accompanying sentence in
all 3 languages (dashboard.json), replacing both the fixed "Goedemorgen"
and the fixed "Here's the fleet" follow-up sentence. Night never says
"Goedenacht" (used as a farewell, not a welcome, in Dutch).
2026-08-04 03:08:45 +02:00
NuklearRabbit d17af1c52a feat: centralize API error localization
Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.

New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.

ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
2026-08-04 03:08:07 +02:00
NuklearRabbit 94cfb7bcbb test: tighten i18n allowlist, add substring and brand-leak guards
Remove 7 now-stale IDENTICAL_VALUE_ALLOWLIST entries (audit.title,
auth.roleOperationsManager, auth.roleRentalEmployee,
demo.scenarios.startScenario, demo.scenarios.roles.operations_manager/
rental_employee, navigation.items.audit) now that they are genuinely
translated -- their old comments describing them as "deliberately
untranslated" were no longer true.

Add two new checks: one closing the embedded-English/Dutch-substring
blind spot the whole-string identity test structurally cannot catch (a
mid-sentence phrase surviving inside otherwise-translated prose), one
asserting no locale file contains "MobilityOps" or the word "PoC".
2026-08-04 03:04:14 +02:00
NuklearRabbit 37a362c4a0 fix: translate remaining NL/FR interface gaps
Role names, audit/scenario labels, and status text were previously either
left in English or only partially translated:
- auth.json/demo.json role labels actually translated (not just labelled
  as translated): Operationsmanager/Verhuurmedewerker,
  Responsable des operations/Collaborateur de location.
- "Audit trail" -> Auditgeschiedenis/Piste d'audit (title, column header,
  and every mid-sentence occurrence across demo.json, quality.json,
  returns.json -- these embedded leaks were previously invisible to the
  whole-string identity check).
- "Open" (status) -> Openstaand, "Recent" -> Recentste,
  "Start scenario" -> Scenario starten / Demarrer le scenario.

Matching Playwright spec text updated in the same commit so the suite
never regresses through a broken intermediate state.
2026-08-04 03:03:46 +02:00
NuklearRabbit 1fbb20b1ab docs: audit remaining Fleet Ops localization gaps
Documents every remaining untranslated/incorrect NL/FR string, raw-backend-
error call site, over-permissive i18n allowlist entry, the static-greeting
bug, and doc staleness found by a dedicated read-only sweep before any file
was touched, per the Fleet Ops final localization brief.
2026-08-04 03:02:50 +02:00
NuklearRabbitandClaude Sonnet 5 f7805579f7 docs(release): final Fleet Ops correction evidence and screenshots
Full acceptance evidence for the Fleet Ops correction milestone: commits, branding,
translation coverage, status-preview/apply/manual-review/MO-016-ordering results,
knowledge grounding per language, audit/automation localization, backend/frontend
test results, clean-checkout drill, Unraid deployment (both fix-branch and
post-merge master), responsive/accessibility results, known limitations, and
rollback procedure. Includes live screenshots (nl-BE and fr-BE login, and the
localized data-quality evidence summary that live validation caught and fixed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 01:15:31 +02:00
NuklearRabbit de0bdea84f merge: complete Fleet Ops localization and status resolution 2026-08-04 00:54:31 +02:00
NuklearRabbitandClaude Sonnet 5 284b3c7394 docs: record Unraid deployment evidence (PASS)
Deployed fix/fleet-ops-i18n-status-flow to http://192.168.10.150:1236 and validated
live, which directly caught the evidence-summary localization bug (fixed in 2e4fb43).
Redeployed with the fix and re-verified: full 116-test Playwright suite green against
the live server, no console errors, no container-log errors, both containers healthy,
scenario_integrity.all_ready: true after final reset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:53:31 +02:00
NuklearRabbitandClaude Sonnet 5 2e4fb43f09 fix: localize the primary data-quality evidence summary (live-caught on Unraid)
Live validation on the deployed fix branch caught a real bug: every data-quality
issue's top-of-page "Evidence summary" line rendered the raw, always-English legacy
evidence.summary string unconditionally -- in all three languages -- even though the
backend has been emitting structured, localizable evidence.signals for a while
(app/services/data_quality.py already documented this exact intent). The frontend
side of that conversion was never finished.

- DataQualityIssueDetail.tsx now renders evidence.signals through the operator's
  locale as the primary summary; the raw evidence.summary string is only visible
  inside "Technical details" (via the existing EvidenceDisclosure JSON dump).
- The four DQ-DEMO-* seed rows that anchor the guided demo's scripted scenarios now
  carry real, accurate signals computed at seed time (duplicate-customer's similarity
  score is the actual SequenceMatcher ratio on the seeded names, not invented) instead
  of only a legacy English sentence.
- Rows with no structured signals (generic filler seed data) fall back to the raw
  text rather than showing a blank summary; the one known placeholder string gets its
  own localized rendering so it never displays as English filler either.
- New regression test: the vehicle_status_conflict evidence summary must show
  localized text and must never contain the specific raw English sentence that was
  live-visible before this fix, in all 3 languages.

151 backend tests, Ruff, mypy green; full local Playwright suite green (a couple of
sequential-run-only flakes, both confirmed to pass in isolation and unrelated to this
change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:44:06 +02:00
NuklearRabbitandClaude Sonnet 5 cda2c32bd0 docs: record clean-checkout drill evidence (PASS)
Fresh clone of only committed files into an isolated Compose project (separate ports,
no shared volumes) validated: migration from empty database to head, deterministic
seed (matches the corrected 27-issue count), 151 backend tests + Ruff + mypy, frontend
build, and the full 113-test Playwright suite -- all green. Isolated stack torn down
afterward; working dev environment confirmed untouched. Full detail in
PROJECT_STATE.md; test counts refreshed in README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:57:21 +02:00
NuklearRabbitandClaude Sonnet 5 7851e807fa test: add route matrix (11F) and hardcoded-JSX-text check (11D)
- fleet-ops-correction.spec.ts: opens every main route in all 3 languages, asserting
  no console errors, correct html[lang], and a real non-empty page heading (key parity
  across locale files is already proven structurally elsewhere, so this focuses on what
  only a live render can catch).
- i18n-coverage.spec.ts: a static scan for hardcoded JSX text bypassing t(...). A naive
  `>text<` regex falsely flagged TypeScript generics everywhere (`useState<string |
  null>(null)` was read as a "JSX tag" spanning to the next unrelated `>`) -- fixed by
  requiring the closing tag name to backreference the opening one
  (`<Tag>...</Tag>`), which generics can never satisfy. Verified against both false
  positives (passes clean on the current codebase) and false negatives (deliberately
  injected and reverted a hardcoded string to confirm it's caught).

Known pre-existing flake (unrelated to this branch, not touched by it): "logout
invalidates the server session so a refresh returns to login" in
interactive-elements.spec.ts occasionally fails only in the full sequential run,
never in isolation -- AuthContext.logout() clears local state and redirects before
awaiting the server-side cookie-clearing POST, a narrow race no human interaction
speed would ever hit. Noted as a known limitation, not fixed (out of this branch's
scope).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:30:09 +02:00
NuklearRabbitandClaude Sonnet 5 a7ac5ed9d0 docs: update OpenAPI contract, README branding, and PROJECT_STATE for the correction milestone
- contracts/openapi.yaml: title is now "Fleet Ops API"; documents the new
  status-recommendation preview endpoint and the apply endpoint's request body
  (recommendation_token) and full error-code set; notes the search endpoint's
  code+params response shape.
- README.md: title and intro now say Fleet Ops, with an explicit note on the
  Fleet Ops (visible)/MobilityOps (technical identifier) naming split; refreshed
  stale test counts (151 backend, 108 Playwright).
- PROJECT_STATE.md: full progress record for the in-progress correction milestone,
  including what's done, what bugs were found and fixed, and what's explicitly not
  yet done (i18n test-strengthening 11D/E/F, clean-checkout drill, Unraid deployment,
  merge to master).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:14:34 +02:00
NuklearRabbitandClaude Sonnet 5 1e407754e6 test: add accessibility coverage for the status-recommendation panel
Adds aria-live="polite" to the status-conflict panel (matching the existing
resolved-issue success-panel convention) so the applied-status confirmation is
announced to screen readers, and a Playwright test covering: keyboard-only
activation of both the "Review recommendation" and "Change status to X" actions,
reduced-motion emulation, and that status is never conveyed by colour alone (the
badge always carries its own localized text).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:08:21 +02:00
NuklearRabbitandClaude Sonnet 5 1fdd2b3ccf test: add targeted E2E coverage for branding, status flow, MO-016, and knowledge; fix two real bugs found along the way
New frontend/e2e/fleet-ops-correction.spec.ts covers section 12 of the brief:
branding (Fleet Ops visible, no MobilityOps/PoC leaks, in all 3 languages), the
language switcher persisting across reload, the full status-recommendation flow
(non-mutating preview, exact-status confirm button, manual review with no apply
button, stale-token rejection), MO-016 order independence at the browser level, the
knowledge base grounding the exact brief question in its own language, and localized
audit/automation content with raw codes only under "Technical details".

Writing these tests surfaced two real bugs:

- DataQualityIssueDetail.tsx conflated "no conflict" with "manual review required"
  because both carry safe_to_apply: false (a no_conflict recommendation has nothing to
  apply, so it's trivially "not safe to apply" without being unsafe). This showed a
  false "manual review required" panel for MO-016 after its overlap was resolved,
  instead of the correct "no change needed" state. Fixed by keying the branch on
  manual_review_required alone.
- test_mo_016_status_conflict_recommendation_is_order_independent never actually
  exercised MO-016: _first_open() returned whichever vehicle_status_conflict issue was
  most recently detected (there are ~14 open after a reset), not necessarily
  DQ-DEMO-STATUS, so the test's MO-016 assertions were trivially true regardless of
  what the code under test did. Added _first_open_for_vehicle() and rewrote the test
  to explicitly target MO-016, and to assert the behaviour order independence actually
  requires: resolving the overlap first must correctly leave nothing to apply (the
  vehicle already matches the facts), not literally the same end status as resolving
  the conflict first.

151 backend tests, Ruff, mypy green; full 108-test Playwright suite green (two
transient, non-reproducible flakes confirmed to pass in isolation and unrelated to
this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 22:53:55 +02:00
NuklearRabbitandClaude Sonnet 5 ac4b1636fe test: update Playwright specs for the new status-recommendation flow and localized return reason
Two specs still exercised the old single-button "calculate and apply" flow and asserted
on the raw English return-status reason that is now shown as localized primary text
with the raw code moved behind "Technical details". Updated both to match the new
review/decide/confirm status panel and the reason-code UI.

Full 94-test Playwright suite green against the rebuilt web+api stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 22:05:50 +02:00
NuklearRabbitandClaude Sonnet 5 e6539d17b6 fix: knowledge retrieval accuracy and remaining brand/PoC leaks in procedure docs
- Fix the demo knowledge provider's tokenizer: a plain [a-z0-9]+ regex silently
  dropped accented characters, splitting French words like "véhicule" into "v" +
  "hicule" and mangling retrieval for nearly every French query. Now matches the
  Latin-1 accented range too.
- Reweight section scoring so the body match (the actual substance of a section)
  outranks a heading/title match (a shallow structural hint) rather than the reverse
  -- confirmed via the brief's exact validation question that the old weighting
  misranked the damage procedure behind a topically-adjacent document in all three
  languages (nl-BE: a checkout section; en-GB/fr-BE: the return procedure), purely
  because a generic word like "vehicle"/"voertuig" happened to sit in a heading/title.
- Remove leftover "MobilityOps" and "PoC" mentions from 5 English and 4 NL/FR
  procedure documents -- knowledge-base prose is visible UI content and was missed by
  the earlier rebrand.
- Add regression tests: the brief's exact NL/EN/FR damage question must ground on the
  damage procedure as the *primary* source (not just appear in the top 3), and no
  procedure file may contain "MobilityOps" or "PoC".

151 backend tests, Ruff, mypy green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:54:15 +02:00
NuklearRabbitandClaude Sonnet 5 6deb95524d fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
  used identically by the data-quality scanner, a new non-mutating status-recommendation
  preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
  revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
  "maintenance + active booking -> auto rented" shortcut. Frontend
  DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
  why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
  <status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
  after the status-conflict issue now converges on the same final vehicle status,
  proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
  backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
  appeared in locale prose; add a permanent test guarding against a translation file
  ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
  reasons, audit field/actor-type labels, automation last_error, and search
  section/vehicle/booking/issue results all now carry codes the frontend localizes,
  with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
  decision table documenting the evaluator's rules and safe-status principles.

148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:37:34 +02:00
NuklearRabbitandClaude Sonnet 5 18344bc8b7 docs(release): final Fleet Ops multilingual-polish evidence and screenshots
Adds artifacts/fleet-ops-release/final-summary.md with the complete evidence trail for
this release: commits, branding, locale/translation/knowledge-base coverage, adaptive
Demo Guide behaviour per breakpoint, Data Quality/Automation/Audit/clickable-row
improvements, full test results (backend, lint, build, 92 Playwright tests) re-run
against the local stack, an isolated clean-checkout drill, and both the feature-branch
and post-merge master deployments to Unraid -- plus 10 screenshots across the three
languages, desktop and mobile. Updates PROJECT_STATE.md with the corresponding summary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:45:57 +02:00
NuklearRabbit 18a765d623 merge: release Fleet Ops multilingual demo 2026-08-03 19:28:30 +02:00
NuklearRabbitandClaude Sonnet 5 845db14e17 fix: mobile topbar overflow at 421-440px and add trilingual responsive coverage
The 420px "compact topbar" breakpoint left a gap: at 421-440px the demo-guide trigger,
badge, operator block and logout button together overflowed the viewport (introduced by
this session's language-switcher addition). Widen the breakpoint to 440px.

Adds a dedicated Playwright spec asserting no horizontal overflow across the brief's full
7-breakpoint matrix (1440x1000 down to 360x800) in all three supported languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:41:14 +02:00
NuklearRabbitandClaude Sonnet 5 337f8716bb polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and
knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with
eager-bundled per-namespace resources, a persisted accessible language switcher (topbar
and mobile drawer), locale-aware date/number formatting, and a coverage test that fails
the build on any missing or empty translation key.

Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves
from fixed English/Dutch prose to stable message codes + params so the frontend can
localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus
(11 documents each) with per-language retrieval and localized evidence-state messages.

The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a
floating panel that auto-collapses to a persistent, closable progress chip on standard
desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+
highlight on "go to this step", Escape handling, and reduced-motion support.

The Data Quality Workbench gets accessible choice-card decisions with a clear primary/
secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and
uses meaningful short refs; the Audit trail groups events by correlation id with human
action labels and readable before/after diffs. Attention Queue, Today's movements,
Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern)
with independent secondary links, keyboard support and mobile touch targets.

Fixes a topbar overflow on mobile caused by the new language switcher (moved into the
mobile drawer at <=960px) and two dangling aria-labelledby references introduced this
session. Updates all affected Playwright specs for the new nl-BE default and the new
Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and
clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:33:22 +02:00
NuklearRabbit 257a4cf6c0 docs(polish): audit finale demo-afwerking
Verifies the actual branch HEAD against the deployed Unraid revision
(they match) and corrects a one-commit-behind final-commit hash in
artifacts/demo-release/final-summary.md (its own "record the hash"
follow-up commit couldn't self-reference). Catalogues remaining
MobilityOps/PoC mentions (including in knowledge-base procedure prose
that gets quoted in answers), confirms no i18n exists, documents the
Demo Guide's single-behaviour-at-all-desktop-widths gap, the
inconsistent radio-vs-card decision styling in Data Quality, automation/
audit density, and exactly which dashboard rows aren't fully clickable.
Notes the repository's primary branch is `master`, not `main`.
2026-08-03 15:58:48 +02:00
NuklearRabbit 4a268c7351 docs(release): record the final commit hash in the demo-release summary 2026-08-03 15:31:39 +02:00
NuklearRabbit 294a8176d1 docs(release): finalize demo-productization acceptance evidence
Records the clean-checkout drill result (127 backend tests, 56
Playwright tests, all green on an isolated fresh clone), final live
Unraid verification, and the complete required evidence summary for
the demo-productization work on this branch.
2026-08-03 15:31:13 +02:00
NuklearRabbit a5024f7190 docs(demo): add demo-release evidence screenshots and capture tooling
One-off Playwright script (excluded from the regular suite) capturing
the demo entry (desktop+mobile), dashboard with scenarios, Demo Guide,
return preview/result, data-quality resolution, duplicate-customer
merge, knowledge assistant, integration status, automation retry,
audit trail + related-events, About page, demo badge popover, and
reset confirmation -- captured live against the Unraid deployment.
2026-08-03 15:29:53 +02:00
NuklearRabbit 38f654b97a docs(demo): add demo concept, scenarios, data, guide and runbook docs
Documents the demo-productization work from this branch: the Northstar
Mobility fictional concept and scope, the five named scenarios and their
fixed records, the seed/date-anchoring strategy (including the real bug
it fixed), the in-app Demo Guide's design and the English-suggested-
questions decision, and an operational runbook covering 5/10-minute demo
flows, reset, Unraid redeploy and rollback. Updates README with current
test counts and pointers to the new docs.
2026-08-03 15:24:35 +02:00
NuklearRabbit f04a81f6c7 docs: record Unraid deployment evidence for guided-demo test batch 2026-08-03 15:14:20 +02:00
NuklearRabbit 07d5605812 test(demo): add full guided-demo walkthrough and targeted demo tests
Adds one comprehensive Playwright test that walks a fresh Operations
Manager session through all 8 Demo Guide steps performing the real
action at each one, then restores the environment. Writing it surfaced
a real desktop layout bug: the Demo Guide's fixed side panel overlapped
main content with no reflow, making the return form's "Review return"
button unclickable while the guide was open at ordinary viewport widths.
Fixed by reserving layout space via a guide-open class. Also adds mobile
bottom-sheet, keyboard-reachability, and console-error checks.
2026-08-03 15:12:29 +02:00
NuklearRabbit 65835ea40a docs: record Unraid deployment evidence for integration/audit/about batch 2026-08-03 15:02:37 +02:00
NuklearRabbit 5fa4fe0811 feat(demo): plain-language integration status, richer audit, reset integrity
Integration status badges across Dashboard/Automation now show honest
plain-language labels instead of raw backend state strings (and fix a
few states that had no matching CSS colour class at all). Audit trail
gets a "view related events" action reusing the existing correlation_id
filter. About page gains scope/architecture/security/testing sections
and a guided-demo entry point. POST /api/v1/demo/reset now runs and
records a server-side scenario-integrity check. Also fixes a second real
race condition (caught by the return-review e2e test): the odometer
scenario pre-fill now resolves before ReturnForm mounts instead of
patching its value in after the fact.
2026-08-03 15:00:11 +02:00
NuklearRabbit cf9a889547 docs: record Unraid deployment evidence for demo-legibility batch 2026-08-03 14:42:49 +02:00
NuklearRabbit ddc3a98e4b feat(demo): make return, data-quality and knowledge flows demo-legible
Fixes a real honesty bug in Knowledge.tsx (body copy named "RAGcore" while
the active provider is the demo one) and a second real bug discovered
while fixing it: the brief's suggested Dutch questions would silently
return "insufficient evidence" against the English-only demo knowledge
base -- verified empirically and fixed by keeping suggested questions in
English. The return flow now pre-fills the odometer-regression scenario's
suspicious reading instead of asking a visitor to invent one, and links
to automation/audit after committing. Data-quality issues get a shared
plain-language "what's wrong / why it matters" explainer per rule type,
a post-resolution confirmation with audit/vehicle links, and a "demo
scenario's only" list filter. Also fixes a real async race where the
odometer pre-fill could clobber text a visitor had already started typing.
2026-08-03 14:41:02 +02:00
NuklearRabbit 6b864596e0 docs: record Unraid deployment evidence for Demo Guide/scenario overview batch 2026-08-03 14:18:47 +02:00
NuklearRabbit 14c2ad3ee8 fix(demo): wait for post-login redirect before navigating in e2e tests
Two demo-guide.spec.ts tests navigated straight to /scenarios right after
clicking a login button without waiting for the /dashboard redirect to
settle first. This raced harmlessly on localhost but flaked against the
higher-latency Unraid deployment, hitting RequireAuth before the session
was confirmed.
2026-08-03 14:17:44 +02:00
NuklearRabbit 9fff84dc68 feat(demo): add Demo Guide (8-step guided tour) and scenario overview
Adds a compact "Probeer een demonstratiescenario" page listing the 5 named
scenarios with live readiness from the manifest, plus a Demo Guide side
panel (bottom sheet on mobile) that walks an Operations Manager through
all 8 steps with per-step context, live-resolved routes, sessionStorage
progress, and a "Demo opnieuw voorbereiden" restart. Login's guided-demo
CTA now actually opens the guide. Fixes a real mobile topbar overflow the
new guide trigger introduced.
2026-08-03 14:15:15 +02:00
NuklearRabbit c63903cc94 docs: record Unraid deployment evidence for demo entry/manifest/badge batch 2026-08-03 13:48:22 +02:00
NuklearRabbit ac427f4427 feat(demo): add demo manifest, Dutch demo entry, permanent badge and About page
Adds GET /api/v1/demo/manifest as a single source of truth for the demo's
fictional org identity (Northstar Mobility -- surfacing the project's
already-locked tenant name), synthetic-data/reset state, and live scenario
readiness. Rewrites the login screen in Dutch with an honest, no-password
demo entry and a guided-demo entry point, replaces the loud full-width
demo banner with a subtle badge + popover, and adds a compact About page
explaining what's real vs. synthetic vs. not yet connected.
2026-08-03 13:45:55 +02:00
NuklearRabbit 728e380d63 docs: record Unraid deployment evidence for the date-anchoring fix 2026-08-03 13:10:33 +02:00
NuklearRabbit 8989ffb23c fix(demo): anchor seeded dates to the real reset moment
Booking/inspection/maintenance/outbox dates were authored as absolute
timestamps around a fixed 2026-08-01 anchor and never re-anchored at
seed/reset time, so demo scenarios (e.g. BK-DEMO-RETURN) silently drifted
into the past. Every reset now shifts seeded dates by (today - authored
anchor); dashboard's "today" filter uses real wall-clock time instead of
the now-removed frozen demo_today setting. Adds seed-validation tests
proving scenarios S1/S2/S4/S5 are present and internally consistent after
every reset.
2026-08-03 13:08:02 +02:00
NuklearRabbit 7c94eb9e87 docs(demo): audit current demo readiness gaps
Confirms the underlying data/business-logic is already demo-grade (Dutch/
Flemish names, .test emails, believable Belgian towns and RV brands; the
5 requested scenarios already exist as S1/S2/S4/S5/S6 in
docs/13-seed-and-demo-scenarios.md) -- the real gaps are structural: no
guided path, no visible fictional org identity (Northstar Mobility is
already the locked tenant name internally, just never shown), a
reproducible date-anchoring bug (seed dates are absolute and don't move
with reset -- BK-DEMO-RETURN's end date is already in the past as of
today), the knowledge page naming "RAGcore" directly instead of "demo
mode", technical-register integration-status labels, and no About page.
2026-08-03 12:54:27 +02:00
NuklearRabbit e0c7ed6011 docs(release): finalize the recorded commit hash 2026-08-02 07:25:30 +02:00
NuklearRabbit 5b2827eb7e docs(release): record the final commit hash in the evidence summary 2026-08-02 07:24:51 +02:00
NuklearRabbit 8a3a43d4ac docs(release): add final functional-completion acceptance evidence 2026-08-02 07:24:37 +02:00
NuklearRabbit ff118dd66d docs(state): record Batch 5 completion 2026-08-02 07:23:08 +02:00
NuklearRabbit 824048b9d4 fix(deploy): mark setup-scheduled-scan.sh executable
Matches the other deploy/unraid/*.sh scripts; was committed 644 instead
of 755, caught while running it directly against the Unraid deployment.
2026-08-02 07:20:48 +02:00
NuklearRabbit c981aad2a3 docs(release): update contracts and docs for functional-completion changes
Add the new endpoints to contracts/openapi.yaml and docs/05-api-contract.md
(return-preview, the four rule-specific data-quality resolution endpoints,
search, integration status, scheduled-scan), document the role matrix and
the audit before/after exposure in docs/12-security-and-audit.md, document
each rule type's actual resolution flow in docs/07-data-quality.md
(including the deliberate evidence-fingerprint simplification and the
reopened_from/previous_decision recurrence link), document the preview/
commit relationship in docs/08-return-workflow.md, and update README.md's
scope/integration-status/quality-gate sections to match what's actually
implemented and verified now. Also drops docs/05-api-contract.md's mention
of GET /api/v1/system/status, which was never implemented.
2026-08-02 07:10:37 +02:00
NuklearRabbit e115031a57 feat(n8n): add scheduled quality-scan workflow
The original docs described two n8n workflows but the repository only ever
shipped one (return-processing); the sketched second workflow (knowledge
sync) depends on RAGcore, which isn't connected here, so it stays deferred.

Add POST /api/v1/integrations/n8n/scheduled-scan (X-Service-Token
protected, same pattern as the return callback), calling the same
run_scan() the manual "Run quality scan" UI action uses and recording a
service-actor data_quality_scan_run audit event. run_scan() already only
creates an issue for a condition without one open, so overlapping triggers
do no duplicate domain work.

n8n/mobilityops-scheduled-quality-scan.json (hourly schedule + manual test
trigger, both feeding the same HTTP call) ships "active": false so it can't
fire anywhere until deliberately published. Verified live against the
local n8n instance via the Manual test trigger: full green execution, and
the resulting data_quality_scan_run audit event (actor_type=service,
actor_label="n8n scheduled scan") confirms the real round trip, not just a
contract test. deploy/unraid/setup-scheduled-scan.sh mirrors the existing
return-workflow publish script for the shared Unraid n8n.
2026-08-02 06:59:17 +02:00
NuklearRabbit ec8f809497 fix(automation): recover stale outbox delivering leases
_claim_due_events flipped rows to 'delivering' and committed before the
HTTP call; if the process died between that commit and the outcome-
recording transaction, the row stayed 'delivering' forever with no reclaim
path -- a real gap, not previously documented as an accepted limitation.

Give each claim a lease deadline (reusing next_attempt_at, since it's only
otherwise meaningful for pending-status backoff scheduling) and sweep
expired leases back to pending at the start of every dispatch cycle, before
claiming new work. attempts is preserved so the count still reflects true
history. Only leases past their deadline are touched, so a still-alive
worker mid-delivery is never disturbed or double-processed.
2026-08-02 06:59:07 +02:00
NuklearRabbit 4a0a4d1cb4 docs(state): record Batch 4 completion 2026-08-02 06:42:54 +02:00
NuklearRabbit 1867828a9d feat(demo): add reset UI and wire truthful integration status into pages
Add a "Reset demo data" action to the sidebar (Operations Manager only,
explicit confirmation, progress, error handling) -- POST /api/v1/demo/reset
already existed and was already role-gated server-side, but had no UI
trigger. Reset invalidates the acting session server-side, so the flow
signs the user out and returns them to login afterward.

Wire the new GET /api/v1/integrations/status into Automation.tsx and
Dashboard.tsx so both show the aggregate n8n state instead of the most
recent event's status, and the MCP Hub card reflects the actual
registration_enabled setting instead of a hardcoded "not configured" label.
2026-08-02 06:42:27 +02:00
NuklearRabbit 4437b8792a feat(search): add role-aware backend search and truthful n8n status
Two new endpoints. GET /api/v1/search returns bounded typed results
(vehicle, booking, data-quality-issue, application section) instead of the
frontend guessing routes from regex patterns against public-ref prefixes;
data-quality and manager-only sections are filtered server-side by role,
and customers are deliberately never returned since no customer detail
route exists in this PoC.

GET /api/v1/integrations/status aggregates outbox delivery counts
(pending/delivering/succeeded/failed) into a single truthful n8n state
(disabled/unavailable/degraded/operational/no_evidence) instead of the UI
showing whichever status the single most recent event happened to be in --
a vehicle_status_conflict-style bug where one stale failure or one lucky
success could misreport the dispatcher's actual health.

Also fixes a real config gap this surfaced: MCP_HUB_REGISTRATION_ENABLED
was documented in .env.example but had no corresponding Settings field, so
it was silently ignored by pydantic-settings' extra="ignore" and never
actually read anywhere in the codebase.
2026-08-02 06:41:56 +02:00
NuklearRabbit 4bc3e33953 docs(state): record Batch 3 completion 2026-08-02 06:16:50 +02:00
NuklearRabbit 477b5e7ce9 feat(quality): add resolution UI for all five rule types and manual scan
DataQualityIssueDetail showed raw JSON as the primary interface for four of
five rule types, with no resolution surface beyond generic defer/reject.
Add a bounded panel per rule type (provide missing fields, retain/correct
an odometer reading, block one of two overlapping bookings, apply the
recommended vehicle status) wired to the new backend endpoints, and move
raw evidence behind a <details> disclosure. Add a "Run quality scan" action
to the workbench (confirmation, progress, per-rule result counts, auto
refresh) -- the endpoint already existed but had no UI trigger.
2026-08-02 06:16:14 +02:00
NuklearRabbit 6e227a214a feat(quality): complete bounded resolution flows and typed snapshots
Two real gaps here: related-entity snapshots were typed by inferring from
the issue's rule_type (get_issue always resolved related refs as "customer"
for duplicates and "vehicle" for everything else), so a booking_overlap
issue's related bookings silently failed to resolve; and defer/reject were
the only resolution actions for 4 of 5 rule types, leaving
missing_required_field, odometer_regression, booking_overlap and
vehicle_status_conflict with no real path beyond a generic reject.

Type related entities from their own public-reference prefix (CUS-/MO-/
BK-/INSP-) instead of the issue's rule_type, and add typed snapshots for
booking and inspection. Add one bounded resolution endpoint per remaining
rule type: provide-fields (re-runs the missing-field check, resolves only
once nothing required is missing), resolve-odometer-regression (retain
canonical or correct the reading -- never silently lowers canonical
mileage), resolve-overlap (blocks one of the two bookings, re-verifies no
overlap remains), apply-recommended-status (one authoritative
recommendation function shared with re-validation). Manual scan now takes
an actor and audits data_quality_scan_run. Reintroduced evidence after a
non-open decision links the new issue back to the prior one
(evidence.reopened_from / previous_decision) instead of looking like a
fresh, undecided problem.
2026-08-02 06:16:07 +02:00
NuklearRabbit 9bd6bea759 docs(state): record Batch 2 completion 2026-08-02 05:34:03 +02:00
NuklearRabbit 7e34f55005 feat(audit): expose structured before/after evidence
audit_events already stored before_json/after_json, but the API and UI only
ever surfaced metadata -- the audit trail could say something happened but
never show what changed. Add before/after to AuditEventOut, resolve a safe
entity_ref/entity_link for vehicle/booking/data-quality-issue entities
(customer stays label-only; no customer detail route exists in this PoC),
and render a human-readable change summary in the UI with the raw
before/after/metadata JSON kept behind a <details> disclosure rather than
shown by default.
2026-08-02 05:33:23 +02:00
NuklearRabbit f5212959b4 feat(returns): add authoritative return preview
The return-review step predicted operational consequences independently in
the frontend, and got it wrong: damage or a technical warning was described
as routing to "maintenance" when the actual domain rule (returns.py) routes
it to "blocked", and the no-contradiction case was described as becoming
"available" when the vehicle actually always goes to "cleaning" first
(only reaching "maintenance" if the service threshold was crossed).

Extract the evaluation returns.py already performed inline into a pure
evaluate_return() function with no writes -- resulting status (with an
explanation), odometer regression, would-create-quality-issue,
next-booking-risk -- and share it between a new non-mutating
POST /bookings/{ref}/return-preview endpoint and the existing commit path,
so preview and commit can never drift apart again. The result screen also
now distinguishes local commit success from n8n delivery (still queued/
unconfirmed) instead of implying both succeeded, and links to any created
quality issue for Operations Manager.
2026-08-02 05:33:12 +02:00
NuklearRabbit 62ac9f825c docs(state): record Batch 1 completion and server verification 2026-08-02 05:00:50 +02:00
NuklearRabbit bdc58f396e fix(e2e): stop hardcoding localhost:8128 for API resets
demo.spec.ts, ui-redesign.spec.ts and interactive-elements.spec.ts all
hardcoded an absolute http://localhost:8128 base for their demo-reset
helpers, which silently pointed at the local dev API even when the suite
was pointed at a different target via MOBILITYOPS_PUBLIC_URL -- discovered
while running the suite against the actual Unraid deployment, where the
reset call kept hitting the local machine instead of the server and left
BK-DEMO-RETURN in whatever state a prior run had left it. Use relative
paths so the request fixture's configured baseURL is honoured everywhere.
2026-08-02 04:59:02 +02:00
NuklearRabbit e1f0ad8431 test(app): cover Batch 1 functional-completion regressions
Add Playwright coverage for the fixes in this batch: vehicle search actually
changes the rendered rows, booking pagination stays within 25 rows and page
2 differs from page 1, session survives a refresh, logout invalidates the
server session, direct navigation without a session redirects to login, and
Rental Employee is blocked from manager-only pages both in the UI (hidden
nav, restricted message) and directly against the API (403).
2026-08-02 04:52:07 +02:00
NuklearRabbit 760f3b6ee2 fix(auth): enforce role boundaries on data quality and audit
The data-quality workbench (list, detail, defer, reject) and the audit trail
had no role gate at all beyond authentication -- confirmed live, a Rental
Employee session could list and resolve data-quality issues and read the
full audit trail through both the API and the UI, with only merge-customers
and scan already restricted.

Per the role matrix, both areas are Operations-Manager-only. Gate the
remaining data-quality and audit endpoints with require_operations_manager,
hide their nav items for Rental Employee, show the same restricted-message
pattern Automation.tsx already used for direct URL access, and stop the
dashboard from linking into now-restricted areas for that role.
2026-08-02 04:52:01 +02:00
NuklearRabbit ffc88e33b4 feat(auth): add server-backed demo sessions
The browser treated sessionStorage as the source of truth for the logged-in
user and never verified or invalidated the server-side session cookie: no
GET /api/v1/demo/session or POST /api/v1/demo/logout endpoint existed, and a
central 401 handler was defined but never wired up.

Add both endpoints; the session-check response is marked Cache-Control:
no-store to avoid the browser serving a stale "authenticated" response right
after logout. AuthProvider now verifies against the server on every mount
(sessionStorage only caches presentation state to avoid a login-screen
flash), subscribes to a central 401 listener on the API client, and
RequireAuth shows a loading state during verification instead of flashing
protected content or the wrong role.
2026-08-02 04:51:54 +02:00
NuklearRabbit 56a65b2364 fix(ui): repair vehicle and booking list filtering and pagination
Vehicles and Bookings both computed a filtered (and, for bookings, paginated)
result but rendered the original unfiltered array in the table body, so
search, status and attention filters had no visible effect and every booking
rendered on every page regardless of the 25-row limit. Render the computed
result instead, and clamp the current booking page when a filter change
shrinks the result set below it.
2026-08-02 04:51:48 +02:00
NuklearRabbit 063a8f9a2d docs(audit): record functional completion findings
Independent audit of the design/mobilityops-premium-ui source and the live
Unraid deployment: confirms the two named list-rendering defects plus
sessionStorage-authoritative auth, a missing role gate on the data-quality
workbench and audit trail, a non-authoritative return preview, raw-JSON
issue evidence, a blind client-side search, single-event integration status,
and an unbounded delivering-lease window in the outbox dispatcher.
2026-08-02 04:51:43 +02:00
NuklearRabbit 938a739dfe docs(deploy): record current Unraid baseline
Capture container topology, deployed revision, migration head, volumes,
network and env-var names on the existing review deployment before any
functional-completion changes, per the audit brief's server-first workflow.
2026-08-02 04:51:43 +02:00
268 changed files with 23253 additions and 1320 deletions
+23 -3
View File
@@ -8,8 +8,18 @@ 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
@@ -19,6 +29,11 @@ N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=change-me
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
# Sent as the X-Fleet-Ops-Trigger-Token header when Fleet Ops calls the n8n return-
# processing webhook, so the webhook trigger can require Header Auth instead of being
# publicly callable by anyone who discovers the URL. Must match the value stored in
# n8n's "Fleet Ops Webhook Trigger Token" Header Auth credential.
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token
# RAGcore integration
KNOWLEDGE_PROVIDER=demo
@@ -27,9 +42,14 @@ RAGCORE_TENANT=northstar-mobility-demo
RAGCORE_WORKSPACE=mobilityops
RAGCORE_COLLECTION=internal-procedures
RAGCORE_API_TOKEN=
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
RAGCORE_SPACE_ID=
# ITWorx MCP Hub integration
# ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability
# health check surfaced on the integration status page.
MCP_HUB_REGISTRATION_ENABLED=false
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
MCP_PROVIDER_ID=mobilityops
MCP_PROVIDER_ID=fleet-ops
+2
View File
@@ -14,3 +14,5 @@ test-results/
.idea/
.vscode/
*.tsbuildinfo
*.zip
*.tar.gz
+4 -1
View File
@@ -60,7 +60,10 @@
- `knowledge/procedures/09-booking-conflicts.md`
- `knowledge/procedures/10-roles-and-escalation.md`
- `n8n/README.md`
- `n8n/mobilityops-return-processing.json`
- `n8n/workflows/MANIFEST.md`
- `n8n/workflows/fleet-ops-vehicle-return.json`
- `n8n/workflows/fleet-ops-data-quality-scan.json`
- `n8n/workflows/check_drift.py`
- `seed/README.md`
- `seed/bookings.csv`
- `seed/customers.csv`
+13 -3
View File
@@ -1,4 +1,4 @@
.PHONY: up down logs test lint seed reset n8n-setup demo e2e
.PHONY: up down logs test lint seed reset n8n-setup n8n-setup-scan demo e2e
up:
docker compose up --build -d
@@ -26,12 +26,22 @@ reset:
# One-time per environment: imports and activates the n8n return-processing workflow.
# The n8n owner account itself cannot be scripted safely and must be created once at
# http://localhost:5678/setup (any email/password, no verification required) before
# this target's activation takes effect. See docs/17-runbook.md.
# this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
n8n-setup:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n
# One-time per environment: imports and activates the scheduled quality-scan workflow.
# Same owner-account and credential preconditions as n8n-setup above (this workflow only
# needs "Fleet Ops Service Token").
n8n-setup-scan:
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker compose restart n8n
# Full deterministic demo bootstrap: build, migrate (automatic on api startup), seed.
demo: up
docker compose exec api python -m app.cli seed --reset
+1910 -2
View File
File diff suppressed because it is too large Load Diff
+65 -18
View File
@@ -1,8 +1,18 @@
# MobilityOps
# Fleet Ops
**Connected operations for vehicle rental and service teams.**
MobilityOps is a working proof of concept for a fictitious mobility company. It combines vehicle and booking operations, a controlled vehicle-return workflow, data-quality review, RAGcore-backed internal knowledge, n8n orchestration and read-only tools published through ITWorx MCP Hub.
Fleet Ops is a working proof of concept for a fictitious mobility company. It combines vehicle and booking operations, a controlled vehicle-return workflow, data-quality review, RAGcore-backed internal knowledge, n8n orchestration and read-only tools published through ITWorx MCP Hub.
**Naming:** "Fleet Ops" is the product's visible name everywhere in the UI, the demo
knowledge base, and this documentation. "MobilityOps" remains the technical
identifier only — the repository name, local directory, package/module names, Docker
Compose project, deployment directory, and database names. The UI is fully trilingual
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
architecture, the vehicle-status decision table, and the correction evidence, and
`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining
NL/FR translation gaps, centralized API-error localization, the time-dependent
Europe/Brussels dashboard greeting).
The web application uses the premium responsive **Control Rail** interface: a compact
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
@@ -12,18 +22,36 @@ 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:
- operations dashboard;
- vehicle and booking views;
- one complete vehicle-return workflow;
- five deterministic data-quality checks;
- operations dashboard with a truthful aggregate n8n/MCP integration-status card;
- vehicle and booking views with working search, filters and pagination;
- server-backed session lifecycle (refresh-safe, central 401 handling);
- a role matrix enforced server-side and mirrored in the UI (see
`docs/12-security-and-audit.md`);
- vehicle return capture → authoritative server-evaluated review → commit → result;
- five deterministic data-quality checks, each with a bounded resolution flow, plus a
manual scan action;
- human review and customer merge;
- audit trail;
- audit trail with human-readable before/after evidence and safe entity links;
- role-aware global search across vehicles, bookings and (Operations Manager) issues;
- safe, confirmed demo reset;
- RAGcore-backed knowledge assistant with citations;
- one n8n return-processing workflow;
- two n8n workflows: return processing, and a scheduled data-quality scan with
crash-recoverable outbox delivery leases;
- four read-only MCP tools through ITWorx MCP Hub;
- deterministic demo reset and five-minute showcase.
@@ -33,18 +61,32 @@ It is not an ERP, CRM, accounting package, public booking site, payment system o
- **n8n**: fully implemented and verified against a real n8n instance, including
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending`
with backoff, self-heals once n8n returns) and the failed-delivery manual-retry path.
with backoff, self-heals once n8n returns), the failed-delivery manual-retry path,
stale-delivery-lease recovery after a simulated crash, and a second (scheduled
quality-scan) workflow live-verified end to end against a real n8n instance.
`GET /api/v1/integrations/status` reports a truthful aggregate state from outbox
delivery counts, not just the most recent event.
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
over the local procedure documents) is what satisfies the knowledge-assistant
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is
implemented and unit-tested, including its unavailable-degradation path, but was never
exercised against a live RAGcore instance in this environment.
acceptance criteria and is what's active in production (`KNOWLEDGE_PROVIDER=demo`). A
`RAGcoreKnowledgeProvider` HTTP adapter is implemented, unit-tested, and has been
exercised live against the deployed RAGcore instance: a real filesystem-permission bug
that caused every live retrieval to return zero candidates was found and fixed
(`docs/final-integrations/current-state-audit.md`), but a second, deeper gap — RAGcore's
reranker adapter calls an Ollama HTTP route (`/api/rerank`) that does not exist on the
deployed Ollama version — still blocks real grounded answers. `KNOWLEDGE_PROVIDER` stays
`demo` until that is resolved on the RAGcore side.
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
directly `curl`-verified with correct auth enforcement and audit logging. No live Hub
instance was reachable in this environment to verify an actual Hub round trip.
directly `curl`-verified with correct auth enforcement and audit logging.
`MCP_HUB_REGISTRATION_ENABLED` is actually wired into `Settings` and reported honestly
by the integration-status endpoint (evidence-based: real tool-call audit history, not
just the flag). The Fleet Ops connector is confirmed live in the ITWorx MCP Hub's own
production deployment (Tower), with a real contract fix already applied there
(`vehicle.get`'s wire parameter normalized to `vehicleRef`).
See `artifacts/final-acceptance/summary.md` for full verification evidence and exact
commands.
See `artifacts/functional-completion/final-summary.md` for the functional-completion
audit evidence (supersedes the design-validation summary below for integration status),
and `artifacts/final-acceptance/summary.md` for the original M0M7 acceptance evidence.
## Repository map
@@ -60,6 +102,11 @@ commands.
- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`).
- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`).
- `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
@@ -83,9 +130,9 @@ All defaults are configurable via `.env` (see `.env.example`).
## Quality gates
```bash
make test # backend: pytest (66 tests)
make test # backend: pytest (151 tests)
make lint # backend: ruff + mypy (strict, zero errors)
make e2e # frontend: Playwright end-to-end (18 tests, live stack required)
make e2e # frontend: Playwright end-to-end (138 tests, live stack required)
```
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
+195
View File
@@ -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-<short-sha>.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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

@@ -0,0 +1,161 @@
# Fleet Ops final integrations — evidence summary
Session date: 2026-08-05. Branch `feat/fleet-ops-final-integrations`.
## Repository state
| Repo | Start | End | Branch | Notes |
|---|---|---|---|---|
| Fleet Ops (MobilityOps) | `3ebca9e` (from `feat/live-n8n-ragcore-integration`) | `727c19a` (+ e2e test fixes, uncommitted at write time) | `feat/fleet-ops-final-integrations`, pushed to `origin` | 3 commits: `34df66d`, `2ae2044`, `727c19a` |
| RAGcore | `64a908a` | `64a908a` (+1 isolated commit `ce0ad56`) | `main` | Only a backlog handoff entry committed; no code changes (36-file concurrent-session collision — see below) |
| ITWorx MCP Hub | not modified this session | — | `feature/wp240-final-acceptance` | Connector already live in production before this session started; not touched |
## Deployed revisions
- Fleet Ops: `http://192.168.10.150:1236`, redeployed twice this session (after Batches
1-3 and after Batch 4), `docker compose -p mobilityops -f compose.yaml -f
compose.unraid.yaml up --build -d db api web`, `.deploy/source-revision` = `727c19a...`.
- RAGcore: `http://192.168.10.150:1237`, `ragcore-app-1`. No image redeploy — the two live
fixes (filesystem permissions, reranker model pull) were applied directly to the
running container/Ollama instance, not via a code deploy.
- ITWorx MCP Hub: `http://192.168.10.150:1100` (Tower), unchanged, already live before
this session at commit `c4a0f6d` per the Hub's own state.
## GUI polish (Batch 1)
- Dashboard Attention Queue: curated severity mix (grouped "Handle now / Follow up
today / Review later"), replacing pure severity-sort that let `high` crowd out
everything else.
- Today's Movements: seed data curated (`seed/bookings.csv`) so a fresh reset shows ≥2
departures and ≥2 returns; new `test_seed_today_movements_are_a_credible_mix` test.
Live-verified after a real demo reset: 2 returns + 2 departures shown.
- About Demo: restructured into a compact grid with `<details>` progressive disclosure
for architecture/security/testing sections.
- Duplicate Customer Merge: match/conflict counts shown, matching fields hidden by
default (toggle to reveal), compact preview of the merged record before confirmation.
- Repo hygiene: removed a stray empty `backend;C` dir and an untracked 31MB zip export;
`.gitignore` now excludes future archive exports.
- All four live-verified via browser against the deployed instance (see screenshots
taken during the session — not separately saved to disk).
## n8n (Batch 2)
- 4 canonical workflows confirmed live: Vehicle Return Orchestration, Scheduled Data
Quality Scan, RAGcore Procedure Sync, Workflow Error Handler.
- Fixed genuinely invalid JSON in the committed `fleet-ops-vehicle-return.json` (a
missing `},` between two node objects — the file could not be parsed).
- Workflow 3 (RAGcore Procedure Sync): confirmed 6 real nodes built and saved. Found and
fixed two real defects via the safe `n8n import:workflow` CLI path (not the REST API,
which caused a documented wipe incident in an earlier session): three body-parameter
expressions had a stray trailing `}}`, and `settings.errorWorkflow` was unset. Exported
the corrected definition to `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`,
added to `MANIFEST.md` and `check_drift.py`.
- **Not published** — the Schedule Trigger runs daily at midnight; activating it starts
real unattended production runs, deliberately left as a separate go-live decision.
- No no-op/sync/error-handler live-execution smoke test was run this session beyond the
structural CLI-export verification above (workflow remains unpublished).
## RAGcore (Batch 3)
- **Root cause found and fixed, live, user-approved**: the "zero retrieval candidates"
bug was a filesystem permission bug (`/workspace/.state/models/embedding_profiles.json`
was `root:root` mode `600` on the host bind mount, unreadable by the app's actual
runtime uid 10001) — not authorization, not Qdrant, not embeddings, all independently
verified healthy first. Fixed via `chown`/`chmod`; re-verified in-process (5 real hits,
up from 0).
- **Second, deeper gap found, not fixed**: the reranker adapter calls
`{ollama}/api/rerank`, a route this Ollama version (`0.32.5`) does not serve (404).
Pulled a working model (`xitao/bge-reranker-v2-m3:latest`, 1.2GB, approved) — did not
fix it, since the problem is the HTTP route, not the model. `/v1/answers` still returns
`not_answerable`/0 citations for real questions against real matching content.
- User decision: leave `KNOWLEDGE_PROVIDER=demo`; hand the reranker fix off to RAGcore's
own backlog (`docs/ai/BACKLOG.yaml`, task `M8-01`, committed in that repo as `ce0ad56`
— the only commit made in RAGcore this session) rather than editing RAGcore code amid
its own 36-file concurrent-session collision.
- Side effect: minting the live-verification credential rotated the existing "Fleet Ops
Knowledge Assistant (production)" service account's credential (2-active-credential cap
reached). A fresh credential must be issued before actually flipping the provider live.
## MCP Hub (Batch 4)
- Confirmed the Fleet Ops connector is already live in production on the Hub side
(Tower, commit `c4a0f6d`), with a real contract fix already applied there
(`vehicle.get`'s wire parameter normalized to camelCase `vehicleRef`).
- Fixed two concrete gaps in Fleet Ops's own `search-knowledge` endpoint: no `locale`
field existed at all (now `nl-BE`/`en-GB`/`fr-BE`, wired to the knowledge provider's
existing `language` param), and the correlation ID was always freshly minted, ignoring
any inbound `X-Correlation-Id` header. Added `get_correlation_id`, applied to all four
MCP endpoints.
- `MCP_HUB_BASE_URL` was dead config (declared, never read); wired it for a real,
bounded Hub-reachability health check instead of an unneeded self-registration push
(the Hub's own registration is catalog-driven).
- Renamed Fleet Ops's own internal audit tool labels `mobilityops_*``fleet_ops_*`
(mirrored in `contracts/mcp-tools.json`, `mobilityops_*` kept as deprecated aliases).
The live Hub connector's own dotted tool namespace (`mobilityops.operations.summary`
etc.) is a separate, Hub-owned naming layer, deliberately not touched.
- Automation page's MCP card now shows real evidence (last tool/client/count/timestamp)
instead of only the registration-enabled boolean.
## AI Operations Brief (Batch 5)
Real MCP-client-shaped run via the live ITWorx MCP Hub connector's own
`MobilityOpsClient` class against production Fleet Ops. Full runbook and live output in
`docs/final-integrations/ai-operations-brief-runbook.md`. Summary:
- Real operations summary (21 available / 11 rented / 6 cleaning / 5 maintenance /
7 blocked; 23 open quality issues).
- Real most-pressing vehicle identified (`MO-031`, missing operational inspection).
- Real vehicle detail lookup.
- Real grounded knowledge answer (English damage-handling question): 2 real citations,
`evidence_state: grounded`.
- Dutch/French variants of the same question honestly returned `insufficient` (no
fabrication) — root cause: the live Hub connector doesn't yet send the new `locale`
field, a Hub-side follow-up, not silently worked around.
- Correlation IDs verified end-to-end in Fleet Ops's own audit log
(`GET /api/v1/audit?action=mcp_tool_request`), matching the response payloads exactly.
- No write actions performed at any point.
## Testing per batch
- Backend: **176 passed**, `ruff check .` clean, `mypy app` clean (50 source files) —
verified against a freshly rebuilt image after discovering mid-session that
`docker compose run --rm api` (no bind mount on the `api` service) silently tests a
stale image otherwise. One genuinely stale test assertion found and fixed as a result.
- Frontend: `tsc -b && vite build` clean.
- E2e (Playwright, against the live deployed instance,
`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`): every spec file run this
session passed — `demo.spec.ts`, `interactive-elements.spec.ts` (26),
`responsive-i18n.spec.ts` + `demo-accessibility.spec.ts` + `guided-demo-full.spec.ts`
(28), `i18n-coverage.spec.ts` + `error-messages.spec.ts` + `clickable-rows.spec.ts` +
`demo-guide.spec.ts` + `demo-entry.spec.ts` + `demo-legibility.spec.ts` +
`fleet-ops-correction.spec.ts` + `ui-redesign.spec.ts` + `greeting.spec.ts` +
`greeting-live.spec.ts` (28, after fixing 2 pre-existing fragile locators unrelated to
this session's feature work — a `.data-table` ambiguity now that Automation has two
tables, and a `Technische details` toggle ambiguity for the same reason; plus one
pre-existing untranslated-loanword false positive in `i18n-coverage.spec.ts`).
## Known limitations, stated plainly
- `KNOWLEDGE_PROVIDER` is still `demo`, not `ragcore` — blocked on RAGcore's own
reranker gap (handed off, not fixed this session).
- n8n workflow 3 is built and correct but not published (deliberate, separate decision).
- The live MCP Hub connector doesn't yet send the new `locale` field, so
locale-aware knowledge search only works when called directly against Fleet Ops (as
proven by the backend tests), not yet through the live Hub connector as deployed.
- No public-demo-readiness checklist, About Demo Guide "completed" end-state polish
(section 4E), or dashboard MCP "activity showcase after Demo Complete" gating were
built this session — the MCP evidence display exists on the Automation page
unconditionally rather than gated behind guided-demo completion.
- No security-review pass was run separately this session (existing gates: ruff, mypy,
the repo's own auth/audit test coverage).
## Rollback
- Fleet Ops: prior working revision `0571a40` remains in `.deploy/` as
`source-0571a40.tar.gz` on the Unraid host; redeploy by re-extracting and re-running
the same `docker compose up --build -d` sequence with that archive.
- RAGcore: `chown`/`chmod` change is trivially reversible (`chown 0:0` +
`chmod 600` on the same path) if needed, though there is no reason to revert a
permission fix. Ollama model pull (`xitao/bge-reranker-v2-m3:latest`) can be removed
with `ollama rm` if unwanted; it is inert until RAGcore's own code is changed to use it.
- MCP Hub: not modified this session.
@@ -0,0 +1,284 @@
# Fleet Ops correction and release — final evidence
**Result: PASS**
## Commits
- Source branch / commit (verified pre-correction baseline): `master` @ `18344bc8b7a75a2f868bf15bf498fc030ac6c34c`
- Fix branch: `fix/fleet-ops-i18n-status-flow`
- Final fix-branch commit: `284b3c7` (merged content identical to `2e4fb43`, which carries the evidence-summary localization fix)
- Main-before-merge: `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` (confirmed unchanged via `git fetch` + `git rev-parse origin/master` immediately before merging — no unexpected commits landed on master while this branch was in progress)
- Merge commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`git merge --no-ff fix/fleet-ops-i18n-status-flow -m "merge: complete Fleet Ops localization and status resolution"`, zero conflicts)
- Final main commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
- Deployed commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`.deploy/source-revision` on Unraid)
- Gitea main branch: `master` (confirmed via `git fetch origin && git rev-parse origin/master` matching local `master` after push)
- Live URL: `http://192.168.10.150:1236`
Fix-branch commit history: `6deb955`, `e6539d1`, `ac4b163`, `1fdd2b3`, `1e40775`, `a7ac5ed`, `7851e80`, `cda2c32`, `2e4fb43`, `284b3c7`.
## What this correction fixed
1. **Status-recommendation flow redesigned** (sections 8A8F). The old single opaque
"calculate and apply recommended status" action is replaced by a single shared, pure
evaluator (`backend/app/services/vehicle_status.py::evaluate_vehicle_status`,
documented in `docs/fleet-ops-correction/vehicle-status-decision-table.md`) used
identically by the scanner, a non-mutating preview endpoint
(`POST /api/v1/data-quality/issues/{ref}/status-recommendation`), and a
transactional apply endpoint (`POST .../apply-recommended-status`) that locks the
row, recomputes facts, rejects a stale `recommendation_token`, refuses unsafe/manual-
review recommendations, and re-validates post-write before resolving the issue.
- Forbidden shortcuts eliminated: "maintenance + active booking" no longer
auto-recommends "rented" (being in maintenance is itself now a blocking fact);
"maintenance with nothing else wrong" no longer auto-clears to "available" (no
fact proves maintenance is actually finished — release stays a manual decision).
- Frontend: "Review recommendation" → a localized decision panel (current/
recommended status, why, evidence, consequences) → an exact "Change status to
&lt;status&gt;" confirm action → result, or a distinct "Manual review required"
state offering no generic apply button.
2. **MO-016 order independence** (section 9). Order independence does not mean "same
final status regardless of order" — resolving the booking overlap first genuinely
removes the conflict, correctly leaving nothing to apply. What holds either way: the
recommendation always reflects real current facts (never a stale proxy), and nothing
unsafe is ever applied (never "rented"). Proven by a backend test explicitly scoped
to MO-016/DQ-DEMO-STATUS (the original version wasn't — `_first_open()` returned
whichever of ~14 open `vehicle_status_conflict` issues was most recent, not
necessarily MO-016's) and a browser-level Playwright test covering both orders.
3. **"Fleet Ops" is a non-localizable brand constant** (`frontend/src/product.ts`,
backend `PRODUCT_NAME`), wired via `{{productName}}` interpolation everywhere the
brand appeared in locale prose. A permanent test fails the build if any locale file
ever defines the brand name or an `appName` key again.
4. **Dynamic backend prose converted to message codes + params** (sections 5/6/10):
return status reasons, audit field/actor-type labels, automation `last_error` (new
`last_error_code` column, migration `799d8800e241`), search results (sections/
vehicles/bookings/issues), and — found live on Unraid — the data-quality evidence
summary. Raw technical text is demoted to a "Technical details" disclosure
everywhere.
5. **Knowledge-base fixes**: the demo provider's tokenizer silently dropped accented
characters (`[a-z0-9]+` split "véhicule" into "v"+"hicule"), breaking French
retrieval broadly — fixed to include the Latin-1 accented range. Reweighted section
scoring so a body match (real substance) outranks a heading/title match (a shallow
structural hint) — the old weighting misranked the damage procedure behind an
unrelated document for the brief's exact validation question in all 3 languages.
Removed leftover "MobilityOps"/"PoC" mentions from 9 procedure documents.
6. **Search, audit, automation, maintenance/inspections localized** (section 10):
backend returns stable codes + params only; the frontend localizes section labels,
vehicle summaries, booking/issue statuses, audit action/field/actor labels,
automation error explanations, and maintenance/inspection type labels.
7. **i18n test suite strengthened** (section 11): key parity, brand invariant,
translation-quality (cross-locale identical-value detection), a hardcoded-JSX-text
static scan (had to anchor on backreferenced closing-tag names — a naive `>text<`
regex misread TypeScript generics as JSX), and a 3-language route matrix (every main
route, no console errors, correct `html[lang]`, real page headings).
## Live-caught bug (the deployment validation earning its keep)
Live validation on the freshly-deployed fix branch directly caught a real defect: every
data-quality issue's top-of-page evidence summary was unconditionally showing raw,
always-English text (e.g. *"vehicle marked available while reserved bookings
conflict"*) in **all three languages**, because the frontend never finished the
`evidence.signals` localization the backend had already been emitting (the backend code
even had a comment describing the intended design that the frontend didn't implement).
Fixed in commit `2e4fb43`:
- `DataQualityIssueDetail.tsx` now renders `evidence.signals` through the operator's
locale as the primary evidence text.
- The four `DQ-DEMO-*` seed rows that anchor the guided demo's scripted scenarios now
carry real, accurate signals computed at seed time (the duplicate-customer similarity
score is the actual `SequenceMatcher` ratio on the seeded names, not invented).
- Rows with no structured signals fall back to raw text rather than showing a blank
summary; the one known filler placeholder gets its own localized rendering.
- A regression test locks this in: the vehicle-status-conflict evidence summary must
show localized text and must never contain the specific raw English sentence that was
live-visible before the fix, in all 3 languages.
Also found and fixed along the way: a frontend logic bug conflating "no conflict" with
"manual review required" (both carry `safe_to_apply: false`), which showed a false
"manual review required" panel for MO-016 after its booking overlap was resolved
instead of the correct "no change needed" state (fixed in `1fdd2b3`).
## Translation coverage
- All three locale files (`nl-BE`, `en-GB`, `fr-BE`) define exactly the same key set
for every namespace (`i18n-coverage.spec.ts`, structural guarantee).
- No locale file contains an empty string value.
- No locale file defines the brand name or an `appName` key (brand-invariant test).
- Cross-locale translation-quality check: for every string ≥8 characters of real prose,
nl-BE ≠ en-GB, fr-BE ≠ en-GB, fr-BE ≠ nl-BE, with a precise, audited allowlist for
genuine proper nouns/cognates (23 entries, each with a documented reason).
- Hardcoded-JSX-text static scan: zero findings against the current codebase (verified
against both false positives — TypeScript generics — and a deliberately-injected-
then-reverted false negative).
- 3-language route matrix: every main route (dashboard, vehicles, vehicle detail,
bookings, booking detail, data quality, issue detail, automation, knowledge, audit,
scenarios, about) opens cleanly in all 3 languages with no console errors, correct
`html[lang]`, and a real page heading.
- **Remaining visible wrong-language text**: none found. The one gap that existed (the
data-quality evidence summary) was found live and fixed before merge.
## Branding
- Visible product name: **Fleet Ops**, exactly, in all 3 languages, everywhere (login,
topbar, footer "Fleet Ops Demo", document title, About page, Demo Guide, knowledge
base). Verified structurally (brand-invariant test) and live (branding test across
dashboard/vehicles/data-quality/audit/automation/knowledge pages in all 3 languages;
visual screenshots of the login screen in nl-BE and fr-BE).
- Technical identifier retained (by design, per the brief): repository name, local
directory, package/module names, Compose project, deployment directory, database
name, and the `/health` endpoint's `service: "mobilityops-api"` field remain
"mobilityops" — none of these are visible UI text.
- No visible "MobilityOps" or "PoC" anywhere in the UI or the demo knowledge base
(9 procedure documents cleaned up; regression test in `test_knowledge.py` scans every
procedure file for both strings).
## Status-preview / apply / manual-review / MO-016 ordering
- **Preview**: verified non-mutating — the issue's `status` stays `"open"` after
calling the preview endpoint and re-fetching it via a fresh request.
- **Apply**: the confirm button names the exact target status ("Change status to
Blocked" / "Status wijzigen naar Geblokkeerd" / "Changer le statut vers Bloqué");
applying resolves the issue and updates the vehicle atomically.
- **Manual review**: MO-024 (active rental + service-threshold reached, a genuine fact
contradiction) shows "Manual review required" with no generic apply button rendered
at all.
- **Stale token**: simulated by resolving the underlying booking overlap after the
preview was fetched but before applying — the apply call is correctly rejected
(`RECOMMENDATION_STALE`), the UI shows the "situation has changed" message, and the
user must review again before a new apply is possible.
- **MO-016 ordering**: both orders tested. Resolving the overlap first correctly leaves
nothing to apply (vehicle stays "available", genuinely correct). Resolving the status
conflict first safely blocks the vehicle; resolving the now-redundant overlap
afterwards does not disturb it. Neither order ever produces "rented".
## Knowledge (per language)
The brief's exact validation question, in each language, grounds on the damage
procedure as the **primary** (not just top-3) source:
- nl-BE: *"Wat moet ik doen wanneer een voertuig beschadigd terugkomt?"* → damage
procedure, Dutch source, Dutch excerpt.
- en-GB: *"What should I do when a vehicle returns with damage?"* → damage procedure,
English source, English excerpt.
- fr-BE: *"Que dois-je faire lorsqu'un véhicule revient endommagé ?"* → damage
procedure, French source, French excerpt.
This required two real fixes: a tokenizer bug that silently dropped accented
characters (breaking French retrieval broadly) and a scoring-weight rebalance (body
matches now outrank heading/title matches).
## Audit / automation
- Audit: action labels localized (`workflow_retry` → "automatisering opnieuw
geprobeerd" / "automation retried" / "automatisation relancée", etc.), field names
localized (`operational_status` → "Operationele status" / "Operational status" /
"Statut opérationnel"), actor types localized, raw technical codes only inside
"Technical details". Verified live and via a dedicated Playwright test.
- Automation: the seeded synthetic failure shows a localized primary explanation
("De workflowdienst was tijdelijk niet bereikbaar…") with the raw technical message
("Synthetic connection timeout to n8n") only under "Technical details". Verified live
and via a dedicated Playwright test.
## Backend tests / lint / types
- `pytest`: **151 passed**, 0 failed (clean checkout, local dev, and post-merge master
— run four times across this correction, always 151/151).
- `ruff check .`: all checks passed, every run.
- `mypy app` (strict): no issues found in 49 source files, every run.
- Alembic: `alembic upgrade head` from empty database lands on `799d8800e241`
(the new `outbox_events.last_error_code` column); `downgrade -1` / `upgrade head`
round-trip verified.
## Frontend build / Playwright
- `npm ci`, `tsc -b`, `vite build`: clean, every run.
- Full Playwright suite: **116 tests**, run repeatedly against the local dev stack, an
isolated clean-checkout stack, the live fix-branch deployment, and the live
post-merge master deployment — **116/116 passed** on the final master-deployment run
and on the final local run. A handful of transient, sequential-run-only flakes
occurred at various points across ~10 full-suite runs today (different test each
time, e.g. a pre-existing logout-timing race in `AuthContext.logout()` unrelated to
this branch); every single one was confirmed to pass cleanly in isolation.
- Guided demo covered indirectly via `guided-demo-full.spec.ts`,
`demo-guide.spec.ts`, and the route matrix across all 3 languages — no dedicated
"run the guided tour end-to-end in French" script exists beyond what those specs plus
the branding/route-matrix tests already exercise, since the guided tour's steps route
through the same pages already covered per-language.
## Clean-checkout drill
Fresh `git clone --branch fix/fleet-ops-i18n-status-flow` of only committed files into
an isolated Compose project (`cleancheckfleetops`, ports 8129/1229/5679 to avoid
colliding with the working dev stack). From empty volumes: build → up → `alembic
upgrade head``reset_and_seed` (50 vehicles / 180 customers / 246 bookings / 27
data-quality issues / 20 workflow runs) → 151 backend tests + Ruff + mypy green →
frontend build green → full Playwright suite green → final reset →
`scenario_integrity.all_ready: true`. Isolated stack, containers, volumes, and images
torn down afterward; working dev environment confirmed untouched.
## Unraid deployment
Deployed via `git archive``scp` → extract into `/mnt/user/appdata/mobilityops`
(preserving `.env` and persistent volumes) → `.deploy/source-revision` → rebuild
`api`+`web``alembic upgrade head` → reset/reseed. Done twice: once for the fix
branch (caught the evidence-summary bug), once for the final merged master. Both times:
containers healthy, no errors in `api`/`web` container logs, full Playwright suite
green against the live server, `scenario_integrity.all_ready: true` after final reset.
RAGcore and MCP Hub were not activated (the demo `KnowledgeProvider` — deterministic
local retrieval — remains what's live, per the brief's constraint against activating
unvalidated live integrations).
## Responsive / accessibility
- Breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844,
360×800) × 3 languages: no horizontal overflow, localized headings visible
(`responsive-i18n.spec.ts`).
- Status-recommendation panel: keyboard-only activation of "Review recommendation" and
"Change status to X" verified via focus assertions (not just click); reduced-motion
emulated during the flow; status never conveyed by colour alone (the badge always
carries its own localized text); `aria-live="polite"` added so the applied
confirmation is announced to screen readers.
## Known limitations
- A pre-existing, narrow timing race in `AuthContext.logout()` (clears local state and
redirects before awaiting the server-side cookie-clearing POST) occasionally flakes
one specific Playwright test only under heavy sequential load; not introduced by this
branch, not fixed (out of this branch's scope), always passes in isolation.
- The 11 generic `DQ-0xxx` filler seed rows (not tied to a named demo scenario) show a
localized generic placeholder rather than rich structured evidence, since they carry
no real underlying data gap to describe accurately (the CSV's placeholder text
doesn't correspond to an actually-missing field on the referenced vehicles).
- No dedicated "full guided demo in French, screenshot every step" script exists as a
single artifact; coverage is composed from the route matrix, branding, and existing
guided-demo specs, each run across all 3 languages.
## Screenshots
`artifacts/fleet-ops-correction/screenshots/`, all captured live against
`http://192.168.10.150:1236`:
- `login-nl-BE.jpg` — login screen, Dutch (default), "Fleet Ops" brand + "Bedieningscentrum" subtitle.
- `login-fr-BE.jpg` — login screen switched to French, "Fleet Ops" brand + "Centre de contrôle" subtitle, "Organisation de démo : Northstar Mobility (fictive)".
- `dq-demo-status-fr-BE-collapsed.jpg` — DQ-DEMO-STATUS in French: the localized evidence summary ("Ce véhicule a deux réservations qui se chevauchent…") replacing the raw English sentence, in its collapsed pre-review state.
- `dq-demo-status-fr-BE-clean-reload.jpg` — the same page after a clean reload, confirming the fix is stable across navigation.
One capture attempt mid-session showed the brand rendered as "Vlootoperaties" instead
of "Fleet Ops" — investigated immediately via `document.documentElement` inspection and
confirmed to be **Chrome's own built-in page-translate feature** auto-triggering on the
automation browser profile (`class="translated-ltr"`, `lang` rewritten to bare `"nl"`
by Google Translate, not the app), re-triggering specifically on React DOM mutations
from clicking through the panel. Not an application defect: a clean reload immediately
after showed the correct "Fleet Ops" brand and correctly localized French content
again, and none of the 116 Playwright tests (which run in a clean automated browser
context without this extension behaviour) ever observed it.
## Rollback procedure
1. `ssh unraid`, `cd /mnt/user/appdata/mobilityops`.
2. `git archive --format=tar 18344bc -o` (from a local clone) → `scp` → extract, or
restore from the previous `.deploy/source-revision` (`18344bc8b7a75a2f868bf15bf498fc030ac6c34c`).
3. `echo 18344bc8b7a75a2f868bf15bf498fc030ac6c34c > .deploy/source-revision`.
4. `docker compose -f compose.yaml -f compose.unraid.yaml build api web && ... up -d api web`.
5. `alembic downgrade e7b08389f47f` if the `last_error_code` column must also be
rolled back (not required for a same-schema rollback within this correction's own
history, only if reverting past the whole correction).
6. Re-seed and re-verify `scenario_integrity.all_ready: true`.
The fix branch `fix/fleet-ops-i18n-status-flow` was not deleted.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,291 @@
# Fleet Ops final localization — final summary
Small, targeted correction round on top of the already-merged, functionally-validated
Fleet Ops correction milestone. Scope: remaining NL/FR translation gaps, centralized
API-error localization, a time-dependent Europe/Brussels dashboard greeting, i18n
test hardening, and documentation consistency — explicitly no redesign, no business-logic
changes, no new functionality. Audit and rationale: `docs/fleet-ops-final-localization/audit.md`.
## Commits
| Stage | Commit | Message |
|---|---|---|
| Start commit (branch base = prior `origin/master` head) | `f7805579f7c73bd3085d73a725fa985b4a4892ed` | `docs(release): final Fleet Ops correction evidence and screenshots` |
| Final fix-branch commit | `09173a4740ddb282fe5412c5305284e9776d397c` | `fix: correct fr-BE audit column label Actor -> Auteur` |
| Merge commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | `merge: finalize Fleet Ops localization` |
| Final master commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | (same as merge commit — merge commit is the branch tip) |
| Deployed commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | matches `.deploy/source-revision` on Unraid exactly |
Branch used: `fix/fleet-ops-final-i18n-ux` (the brief named `fix/fleet-ops-final-localization`;
this branch was verified freshly and cleanly branched from `origin/master` with a clean
working tree, so it was used as-is rather than renamed — see the audit doc's naming note).
`origin/master` was re-fetched and confirmed unchanged (`f780557`) immediately before the
merge, per the mandatory pre-merge safety check.
Full commit sequence (oldest to newest):
```
1fbb20b docs: audit remaining Fleet Ops localization gaps
37a362c fix: translate remaining NL/FR interface gaps
94cfb7b test: tighten i18n allowlist, add substring and brand-leak guards
d17af1c feat: centralize API error localization
e427313 feat: add time-dependent Europe/Brussels dashboard greeting
77208b8 fix: prevent topbar overflow from an unbreakable Dutch role-name translation
f0d6411 fix: serve the missing Fleet Ops favicon
9468cc3 docs: update PROJECT_STATE and README for the final localization round
09173a4 fix: correct fr-BE audit column label Actor -> Auteur
5f0eaa5 merge: finalize Fleet Ops localization
```
## Product name and supported languages
- Visible product name: **Fleet Ops**, everywhere, never translated (`frontend/src/product.ts`
constant, interpolated as `{{productName}}`). "MobilityOps" remains the internal repo /
Compose project / deployment-directory identifier only.
- Supported UI languages: **nl-BE** (default), **en-GB**, **fr-BE**.
- No visible "MobilityOps" or the word "PoC" anywhere in the UI (enforced by a dedicated
automated test, see below).
## Corrected translations
- Role names actually translated (not just labelled as translated): `auth.json` /
`demo.json` role keys — **Operationsmanager** / **Verhuurmedewerker** (nl-BE),
**Responsable des opérations** / **Collaborateur de location** (fr-BE).
- `audit.title`**Auditgeschiedenis** / **Piste d'audit**; `columns.actor`**Uitvoerder**
(nl-BE) / **Auteur** (fr-BE, corrected during live browser validation — see Known
limitations).
- `list.statusOpen`**Openstaand**; `ledger.filterRecent`**Recentste**;
`scenarios.startScenario`**Scenario starten** / **Démarrer le scénario**.
- 8 previously-missed mid-sentence "Audit trail" leaks fixed across `demo.json`,
`quality.json`, `returns.json` (nl-BE) — found by the new embedded-substring test, not
the pre-existing whole-string-identity test, which structurally cannot catch this class
of bug.
- No unintended English text remains in nl-BE or fr-BE (see translation-coverage evidence
below).
## Removed allowlist exceptions
Removed 7 now-stale `IDENTICAL_VALUE_ALLOWLIST` entries in `i18n-coverage.spec.ts`:
`audit.title`, `auth.roleOperationsManager`, `auth.roleRentalEmployee`,
`demo.scenarios.startScenario`, `demo.scenarios.roles.operations_manager`,
`demo.scenarios.roles.rental_employee`, `navigation.items.audit` — all now genuinely
translated; their old comments describing them as "deliberately untranslated" were no
longer true. Two new tests added: embedded-English/Dutch-substring leak guard, and a
no-"MobilityOps"/no-"PoC" guard.
## Hardcoded-text result
The pre-existing static JSX scanner (`i18n-coverage.spec.ts`, section 11D) found **zero**
hardcoded user-facing strings outside the approved technical-token allowlist (Fleet Ops,
Northstar Mobility, ITWorx MCP Hub) across `pages/` and `components/`. Result: **PASS**.
## API-error-localization result
New `frontend/src/api/errorMessages.ts` (`describeApiError`) replaces the
`err instanceof ApiError ? err.message : t(fallback)` anti-pattern (which showed raw
English backend text for the common case) at all 13 call sites across 7 files
(`Automation.tsx`, `ReturnForm.tsx`, `DataQuality.tsx`, `DemoGuide.tsx`, `Layout.tsx`,
`DataQualityIssueDetail.tsx` ×7 sites, `Knowledge.tsx`). Resolution order: known `AppError`
code (32 codes) → known HTTP status (401/403/404/409/422/500) → fully generic fallback.
New `ApiErrorNotice` component (`PageChrome.tsx`) always renders a localized title +
explanation + optional next step; raw backend text is demoted to a "Technical
details"/"Détails techniques" disclosure, never the primary message.
Evidence: `frontend/e2e/error-messages.spec.ts` (10 tests, all passing) —
every known code/status has non-empty copy in all 3 locales; a known code never surfaces
raw text as the primary message; unknown-code and unknown-status fallback chains behave
correctly; a drift guard greps the actual backend `AppError("CODE", ...)` call sites and
confirms `KNOWN_CODES` exactly matches (32 codes, zero drift). Live-verified on Unraid: the
seeded failed automation run renders a fully localized French error with a "DÉTAILS
TECHNIQUES" disclosure below it.
## Greeting logic and edge cases
New `frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, clock-injectable, pure) resolves
one of 4 periods against **Europe/Brussels** wall-clock time via
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })` (DST-safe by
construction — no manual UTC-offset math):
| Period | Window | nl-BE | en-GB | fr-BE |
|---|---|---|---|---|
| morning | 05:0011:59 | Goedemorgen | Good morning | Bonjour |
| afternoon | 12:0017:59 | Goedemiddag | Good afternoon | Bonjour |
| evening | 18:0022:59 | Goedenavond | Good evening | Bonsoir |
| night | 23:0004:59 | Welkom terug | Welcome back | Bon retour |
Never "Goedenacht" (a farewell in Dutch, not a welcome). Each period also has its own
accompanying sentence per language (`dashboard.json` `greetingBody`), replacing the old
fixed "Here's the fleet." `useGreetingPeriod.ts` polls every 30s so the greeting rolls
over live while the app stays open, no reload required; initial render uses a synchronous
`useState(() => getGreetingPeriod())` so there is never a flash of the wrong period.
Edge-case evidence:
- `frontend/e2e/greeting.spec.ts` (4 tests): exact boundary checks at 04:59/05:00/11:59/
12:00/17:59/18:00/22:59/23:00 in both CET (winter) and CEST (summer), plus a dedicated
spring-forward/fall-back DST-transition test (2026-03-29 and 2026-10-25).
- `frontend/e2e/greeting-live.spec.ts` (6 tests, real browser via Playwright's `page.clock`):
all 8 boundary times rendered correctly in **all 3 languages** against the actual app;
live period rollover with no `page.reload()` call anywhere in that test; language-switch
behaviour without changing the time period; the "never Goedenacht" guard.
- Live-verified on Unraid at actual current server time (2026-08-04, ~03:2x CEST, i.e. the
night period): dashboard showed "Welkom terug. Hier is het laatste overzicht van je
wagenpark." (nl-BE), "Welcome back. Here's the latest overview of your fleet." (en-GB),
"Bon retour. Voici le dernier aperçu de votre flotte." (fr-BE).
## README / PROJECT_STATE corrections
- `PROJECT_STATE.md`: fixed the stale "Product name: MobilityOps." / "PoC only"
locked-decisions lines (predated the Fleet Ops rebrand); fixed the "Fleet Ops
correction" section header, which still read "IN PROGRESS .../Not yet merged to
master" despite already being merged (`de0bdea` / `f780557`); appended a new dated
entry for this correction round (not a rewrite of prior entries, per the brief's
explicit instruction not to hide earlier history).
- `README.md`: linked `docs/fleet-ops-final-localization/` alongside the existing
correction-round doc link; refreshed the stale Playwright test count (113 → 138 → 139
after the favicon regression test was added).
## Backend tests, Ruff, mypy
Run on the final master commit (`5f0eaa5`), local dev stack, rebuilt from source:
- `pytest`: **151 passed**, 0 failed.
- `ruff check .`: **All checks passed!**
- `mypy app` (the project's canonical invocation, matching all prior milestone gates —
no `[tool.mypy]` strict config exists in `pyproject.toml`): **Success: no issues found
in 49 source files.**
No backend Python was touched this round; these numbers are unchanged from the prior
correction milestone's final gate, confirmed green again on the current tree.
## Frontend build, Playwright
- `npx tsc --noEmit`: clean, 0 errors.
- `npm run build` (`tsc -b && vite build`): clean production build.
- Full Playwright suite (`npx playwright test`), master build, local dev stack:
**139 passed**, 0 failed (confirmed on a clean run after two transient
`0xC0000005` Chromium worker crashes caused by this specific machine running 43+
concurrent Chrome processes at the time — see Known limitations; a targeted 48-test
re-run of every new/changed suite also passed cleanly in between).
## Clean-checkout drill
Isolated Compose project `mobilityops-clean` (ports 8129/1229/5679, no shared volumes/
network with the working dev stack), fresh `git clone --branch
fix/fleet-ops-final-i18n-ux` of only committed files:
1. `docker compose build` + `up -d` from empty volumes — all 4 containers healthy.
2. `alembic upgrade head``799d8800e241 (head)`.
3. `seed --reset` → 2 users / 180 customers / 50 vehicles / 246 bookings / 75 inspections /
40 maintenance / 27 data-quality issues / 20 workflow runs — matches the documented
deterministic count exactly.
4. Backend gates: `pytest` 151 passed, `ruff check .` clean, `mypy app` clean (49 files).
5. Frontend: `npm ci` clean, `tsc --noEmit` clean, `vite build` clean.
6. Full Playwright suite against the isolated stack (`MOBILITYOPS_PUBLIC_URL=http://localhost:1229`):
**139 passed**, 0 failed — this run covers the Dutch/English/French language checks,
greeting boundaries, API error paths, and the guided demo, all in one pass.
7. Final reset + `scenario_integrity`: all 5 scenarios `ready: true`.
8. Isolated stack, containers, volumes and images torn down; original dev environment
confirmed untouched (`mobilityops-*` containers unaffected throughout).
**PASS.**
## Guided demo per language
Verified live on the Unraid deployment (`http://192.168.10.150:1236`) in all 3 languages
via direct browser interaction: login screen role buttons, dashboard (greeting, readiness
band, attention queue, integration pulse, recent activity), audit trail, automation retry
flow with localized error + technical-details disclosure, and demo reset — all rendering
correctly in nl-BE, en-GB and fr-BE. The full guided-demo Playwright spec
(`guided-demo-full.spec.ts`) passed as part of the 139-test suite on both the local dev
stack and the isolated clean-checkout stack.
## Server deployment, container health
Deployed to `http://192.168.10.150:1236` (Compose project `mobilityops`,
`/mnt/user/appdata/mobilityops`), preserving the server's existing `.env`, the Postgres
and n8n named volumes, the exposed port, and the deployment directory — only `api` and
`web` were rebuilt/recreated; `db` was never touched beyond `alembic upgrade head`; no
second n8n instance was started (shared existing n8n at `:5678` used throughout).
Procedure (matching `docs/demo-release/demo-runbook.md` exactly): `git archive``scp`
extract over the existing deployment dir → update `.deploy/source-revision`
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
→ confirm `alembic current``seed --reset`.
Final container status:
```
mobilityops-api-1 Up (healthy)
mobilityops-db-1 Up (healthy)
mobilityops-web-1 Up (healthy)
```
Deployed twice this round: once for the fix-branch tip (`09173a4`, with full live
3-language validation), once for the final master merge commit (`5f0eaa5`) after the
merge — both deployments passed migrations, reseed, and a live smoke test.
## Repository / runtime hash comparison
```
git rev-parse HEAD (local, master) = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
/mnt/user/appdata/mobilityops/.deploy/source-revision = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
```
**Exact match.**
## Browser console and network
No console errors on any checked route in any of the 3 languages (dashboard, audit,
automation, login) on the live Unraid deployment. All observed `/api/` network requests
returned `200`. `api` and `web` container logs show no errors/tracebacks/exceptions after
the final deployment.
## Known limitations
- **Transient `document.documentElement.lang` DOM-attribute anomaly during interactive
manual browser testing** on the live server: on 2 occasions, right after a client-side
action (an automation retry click; a demo-reset confirm click), `document.documentElement.lang`
briefly showed `"nl"` while the actually-rendered page content, `localStorage`, and a
controlled repeat of the exact same click sequence (fresh login, single deliberate
click, immediate inspection) all remained correctly `"fr-BE"`. Root-caused as far as
possible: the codebase has exactly one `i18n.changeLanguage()` call site
(`LanguageSwitcher.tsx`), which was not invoked in the clean repro, and `t()` /
`i18n.language` are structurally coupled through a single i18next singleton with no
code path capable of producing this split state. Not reproduced even once across 139
automated Playwright tests run 3 times total (local pre-merge, isolated clean-checkout,
local post-merge on master) in a clean, extension-free browser context. Most likely
explanation: a third-party browser extension active in the specific interactive testing
session (which also had ~10 unrelated pre-existing tabs open on the same origin, and
showed independent signs of instability — repeated CDP screenshot timeouts) rewriting
the `lang` attribute based on its own content heuristics, independent of the React app.
Logged here for transparency rather than silently dismissed; does not affect any
automated PASS result above.
- **Two transient Chromium worker crashes** (`0xC0000005` / access violation) during the
master-build Playwright re-run, on a machine that had accumulated 43+ concurrent Chrome
processes from the interactive testing session above. A clean run immediately
afterward (fewer processes) passed all 139 tests; a 48-test targeted re-run of every
new/changed suite also passed cleanly in between. Treated as machine resource
contention, not a code defect — consistent with the prior correction milestone's own
documented experience of "sequential-run-only flakes reproduced from resource
contention of running two full Docker stacks at once," per `PROJECT_STATE.md`.
- One translation gap (fr-BE `audit.columns.actor`: "Acteur" instead of the brief's
specified "Auteur") was missed in the initial pass and only caught during live browser
validation on Unraid; fixed in commit `09173a4` and redeployed before the master merge.
- The Fleet Ops brand mark (`BrandMark` in `Icons.tsx`) was flagged by the user as
potentially due for a visual refresh; per explicit user decision mid-session, this is
out of scope for this correction round and deferred to a separate follow-up task.
- No RAGcore/MCP Hub implementation changes were made or claimed; both remain in the same
demo/not-connected state documented by the prior correction milestone.
## Rollback procedure
`.deploy/source-revision` on the server records exactly which commit is live. To roll
back: `ssh unraid`, extract an earlier `source-<short-sha>.tar.gz` from
`/mnt/user/appdata/mobilityops/.deploy/` (prior tarballs remain in place, including
`source-9468cc3e.tar.gz`, `source-09173a4.tar.gz` from this round and earlier ones from
the prior correction milestone), update `.deploy/source-revision` to match, and re-run
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
followed by `alembic upgrade head` (migrations are additive only — no destructive
migration exists on this branch, so no database rollback is needed). No secrets were
printed or read at any point in this process (`.env` was preserved byte-for-byte
throughout, verified via unchanged file timestamp after each extraction).
@@ -0,0 +1,155 @@
# Fleet Ops release — final-product-polish evidence
## Result: PASS
## Commits
- Original feature-branch baseline before this task: `257a4cf` (`docs(polish): audit finale demo-afwerking`)
- Feature-branch commits added this task, on `feat/mobilityops-functional-completion`:
- `337f871` — polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
- `845db14` — fix: mobile topbar overflow at 421-440px and add trilingual responsive coverage
- Feature branch final commit: `845db14e172539b1d10e40f6a3249a72122deb41`
- `master` before merge (verified against the previously recorded baseline): `e0c7ed60112510687627d20a957af91c8b9db7f8` — unchanged, no unexpected commits, no conflicts (confirmed via `git merge-tree` dry run before merging)
- Merge commit on `master`: `18a765d62345ea9a6660d04fb868f218cf4d0b6e` (`merge: release Fleet Ops multilingual demo`, `--no-ff`)
- Final `master` commit (pushed and deployed): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
- Deployed commit on Unraid (`.deploy/source-revision`): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
- Feature branch was **not** deleted, per instruction.
## URL
- Live review deployment: `http://192.168.10.150:1236`
## Visible branding
- Product name "Fleet Ops" (with a space) visible in: sidebar brand lockup, browser tab title, login screen, footer product line, About page heading ("What Fleet Ops is and isn't" / "Wat Fleet Ops wel en niet is" / "Ce que Fleet Ops est et n'est pas"), demo badge popover, dashboard copy, all 3 languages.
- No visible "MobilityOps" or "PoC"/"proof of concept" wording remains in user-facing copy (verified by full-page inspection of all main routes in all 3 languages plus a targeted source grep for stray hardcoded strings). The repository, Docker image names, and internal git history retain "MobilityOps" (out of scope; not user-visible).
- Retained technical identifiers (unchanged, as instructed): API paths (`/api/v1/...`), Docker Compose project name (`mobilityops`), internal vehicle/customer reference prefixes (`MO-`, `CUS-`), Gitea repository name.
## Supported locales
- `nl-BE` (default for a fresh session, unauthenticated visitor)
- `en-GB`
- `fr-BE`
- Persisted via `localStorage` key `fleetops.language`; survives refresh, logout/login, and demo reset. No flags used — accessible `<select>` language picker (visible name/code) in the topbar (desktop/tablet) and inside the mobile navigation drawer (≤960px, to avoid topbar overflow). `document.documentElement.lang` kept in sync. All dates/numbers rendered via `Intl.DateTimeFormat`/`Intl.NumberFormat` (`Europe/Brussels` timezone).
## Translation coverage
- `frontend/e2e/i18n-coverage.spec.ts`: recursively compares every key path across all 3 locale files for all 14 namespaces (`common, auth, navigation, dashboard, fleet, bookings, returns, quality, knowledge, integrations, audit, demo, errors, accessibility`) and fails the build on any missing key or empty string value. **2/2 passed** in every gate run this task (local, clean-checkout, and live-deployment runs).
- Command: `npx playwright test e2e/i18n-coverage.spec.ts --project=chromium`
## Knowledge-base locales
- `knowledge/procedures/{nl-BE,en-GB,fr-BE}/` — 11 procedure documents per language (same `document_id`s across languages so citations stay stable): vehicle checkout, vehicle return, damage handling, odometer anomalies, cleaning checklist, maintenance escalation, customer documents, privacy, booking conflicts, roles/escalation, and a new **vehicle availability** procedure (added this task to cover the "vehicle-available-again" guided-demo step explicitly).
- `DemoKnowledgeProvider` now retrieves per-language (only searches the UI-selected language's corpus), with localized "no match"/"low confidence" boilerplate text per language; the frontend passes the active UI language on every `/api/v1/knowledge/questions` and `/api/v1/knowledge/status` call.
- Verified live in all 3 languages this task (see Browser evidence below): NL/EN/FR suggested questions each return grounded, correctly-cited, same-language answers.
- Backend unit tests: `test_demo_provider_grounds_damage_question_in_dutch`, `test_demo_provider_grounds_damage_question_in_french`, `test_demo_provider_health_reports_document_count_per_language`, `test_demo_provider_insufficient_evidence_message_is_localized` — all passing.
## Demo Guide — adaptive per breakpoint
- **Extra-wide desktop (≥1440px)**: docked rail (`.demo-guide-panel.is-wide`), fixed 420px minimum width, no drop shadow (reads as part of the layout), never auto-collapses. Verified: `demo-guide.spec.ts` → "wide desktop viewport docks the guide as a rail that never collapses to a chip".
- **Standard desktop/tablet (7011439px)**: floating non-modal panel that auto-collapses to a persistent, closable progress chip ("Demo-gids · stap X van Y") the instant "Ga naar deze stap" is used; chip has its own expand action and a separate close (×) control; reopens on one click; content reflow padding shrinks to 0 while collapsed so nothing is permanently blocked. Verified: 3 dedicated tests in `demo-guide.spec.ts`.
- **Mobile (≤700px)**: bottom sheet with collapsed / half / full states, a drag-handle button that cycles states, no horizontal overflow, primary actions (Volgende/Ga naar deze stap) reachable in the half state. Verified: `demo-guide.spec.ts` → "mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", plus `demo-accessibility.spec.ts` → "demo guide is usable as a mobile bottom sheet".
- **Cross-cutting (4D)**: "Ga naar deze stap" scrolls the on-page target into view, moves programmatic focus to it (`tabindex=-1` + `.focus()`), and applies a 2.2s outline pulse (`.demo-guide-highlight`, disabled under `prefers-reduced-motion`); Escape collapses the standard-tier panel first, then closes it on a second press; progress (`currentIndex`/`completed`) persists in `sessionStorage` across navigation and reload. Verified: `demo-guide.spec.ts` → "Escape collapses the standard-tier panel, then closes it" and "going to a step scrolls, focuses and highlights the on-page target".
- Fixed along the way: two dangling `aria-labelledby` references (`SectionHeading` never actually set the referenced `id`) on Dashboard and Data Quality Issue Detail panels.
## Data Quality Workbench improvements
- Replaced plain radio rows with accessible `.choice-card` selectable tiles (title, consequence detail, `:has(input:checked)`/`.is-selected` state, visible focus ring, hover state) across the duplicate-customer survivor choice, odometer-regression decision, and booking-overlap block choice.
- Clear action hierarchy: primary resolve/apply/merge action uses `.button-primary`; defer uses a de-emphasized `.button-tertiary`; reject uses `.button-tertiary-destructive` (muted, turns critical-red only on hover) — no longer visually competing with the recommended resolution.
- Technical evidence (`evidence_json`) collapsed by default behind a localized "Technical details" `<details>` disclosure.
- Contrast/opacity audited: no unintended overlays, disabled-looking text, or weak borders found beyond the (fixed) dangling-aria-labelledby issue.
## Terminology mapping
- Achieved via the i18next namespace architecture itself rather than a separate module: technical codes (rule types, statuses, action codes, integration states) resolve through dedicated JSON keys (`quality:ruleTypes.*`, `quality:list.status*`, `audit:actions.*`, `integrations:statusLabels.*`, `fleet:statuses.*`, `bookings:statuses.*`) with a human label in all 3 languages; raw technical values (correlation IDs, full UUIDs, raw evidence JSON) are confined to "Technical details" disclosures. Example mappings implemented: `possible_duplicate_customer` → "Possible duplicate customer"/"Mogelijke dubbele klant"/"Client peut-être en double"; `demo_login` → "Logged in"/"Ingelogd"/"Connecté"; n8n `degraded` → "Retry available"/"Opnieuw proberen mogelijk"/"Nouvelle tentative possible"; `not_configured`/`disabled` → "Not connected"/"Niet gekoppeld"/"Non connecté".
## Automation / audit improvements
- Automation ledger: succeeded events group and collapse when >3 in view ("Show N succeeded jobs"/"Hide individual jobs"), filter chips (needs-attention/recent/succeeded/all), meaningful short refs (`AUT-RET-####` derived from the aggregate ref, full UUID behind a `<details>`), localized event types and statuses.
- Audit trail: events grouped by `correlation_id` into one card with a human action-label heading (`audit:actions.*`), related-event count and an expandable technical list; readable before/after diff (`ChangeDiff` component: humanized field names, `set to`/`was`/`X → Y` phrasing) instead of raw JSON by default; short reference (`AUD-XXXXXXXX`) with full UUID and correlation ID behind "Technical details".
## Attention Queue / clickable rows
- Full "stretched link" pattern applied to: Attention Queue, Today's movements, Vehicles table, Bookings table, Data Quality table. Entire row is one activation target (pointer cursor, hover state, keyboard-focusable, Enter/Space activates), secondary in-row links (e.g. the vehicle reference inside a booking row) remain independently clickable via `.cell-link { z-index: 2 }` layered above the row overlay.
- Dedicated tests in `frontend/e2e/clickable-rows.spec.ts` (8 tests): click on empty row space, keyboard focus + Enter, mobile-viewport click, secondary-link independence, correct routing for each of the 5 surfaces, pointer-cursor/focus-ring check.
## Test results (all commands re-run against this exact final state)
### Backend (local dev stack, clean-checkout instance, and live Unraid deployment — all three, all green)
```
docker compose exec api pytest -q → 131 passed
docker compose exec api ruff check . → All checks passed!
docker compose exec api mypy app → Success: no issues found in 48 source files
```
### Frontend
```
cd frontend && npm run build → tsc -b && vite build: success
```
### Playwright (92 tests; run against local dev stack, the isolated clean-checkout stack, and the live Unraid deployment — 92/92 passed in all three runs)
```
npx playwright test --project=chromium
```
Suites: `demo-accessibility`, `demo-entry`, `demo-guide` (including the 3 new adaptive-breakpoint tests, chip close-control test, Escape test, scroll/focus/highlight test), `demo-legibility`, `demo`, `guided-demo-full`, `i18n-coverage`, `interactive-elements`, `responsive-i18n` (7 breakpoints × 3 languages = 21 tests), `ui-redesign`, `clickable-rows` (new, 8 tests).
## Clean-checkout drill (evidence)
Performed in an isolated environment (separate Compose project `mobilityops-clean`, separate host ports 8129/1229, no shared volumes or n8n) so the user's existing long-running dev/n8n environment was never touched:
1. `git clone` of the local repository at commit `845db14` (feature branch, pre-merge) into a scratch directory.
2. `cp .env.example .env` (project name and ports overridden for isolation only).
3. `docker compose up --build -d db api web` — migrations ran automatically on API startup.
4. `docker compose exec api python -m app.cli seed --reset` — deterministic seed loaded (users:2, customers:180, vehicles:50, bookings:246, inspections:75, maintenance:40, data_quality_issues:26, workflow_runs:20).
5. `docker compose exec api pytest -q` → 131 passed. `ruff check .` → clean. `mypy app` → clean.
6. `npm ci && npm run build` → clean build.
7. `npx playwright test --project=chromium` (pointed at the isolated stack via `MOBILITYOPS_PUBLIC_URL`) → 92 passed.
8. Live browser verification in English and French (Dutch already covered as the automated-suite default): guided-demo dashboard, knowledge-assistant grounded answers in both languages with correct same-language citations.
9. `POST /api/v1/demo/reset``scenario_integrity: {"all_ready": true, "not_ready": []}`.
10. Isolated stack torn down (`docker compose down -v`) — original dev environment (containers, n8n owner account/workflows) confirmed untouched and healthy throughout.
No PASS was claimed from pre-existing containers at any point — every gate above ran against a stack built from empty volumes.
## Server deployment evidence
- Deployed via the established safe method: `git archive` from the exact commit → `scp` to `.deploy/source-<sha>.tar.gz` on Unraid → extract → update `.deploy/source-revision``docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web` (db never rebuilt; server `.env` and named volumes — Postgres, n8n — preserved throughout).
- Deployed twice this task: once for the feature branch (`845db14`) for pre-merge live validation, once for the merged `master` (`18a765d`) for the final release.
- Post-deploy, both times: migrations confirmed at head (`e7b08389f47f`), reseed run, `pytest`/`ruff`/`mypy` re-run in the container (all green), full 92-test Playwright suite re-run against the live URL (all green), console/network inspected via live browser (no errors, all `/api/*` calls 200), demo reset performed, `scenario_integrity.all_ready: true` confirmed both times.
- Real shared n8n instance (`http://192.168.10.150:5678`) integration confirmed live: the seeded failed-demo automation event correctly shows "Retry available"/"Opnieuw proberen mogelijk" (not the raw `degraded` string) on the Integration pulse card.
## Responsive / accessibility
- No-horizontal-overflow verified across the full 7-breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844, 360×800) in all 3 languages (`responsive-i18n.spec.ts`, 21 tests) plus the original 4-breakpoint English suite (`ui-redesign.spec.ts`).
- Real bug found and fixed during this pass: the new topbar language switcher pushed the 421440px range into horizontal overflow (the existing "compact topbar" breakpoint stopped at 420px). Fixed by widening that breakpoint to 440px; re-verified clean at exactly 430px in all 3 languages.
- Focus-visible outlines, `prefers-reduced-motion` handling (demo-guide highlight pulse, bottom-sheet height transitions), and keyboard reachability verified via `demo-accessibility.spec.ts` and the new adaptive-guide/clickable-row tests.
## Screenshots
`artifacts/fleet-ops-release/screenshots/`:
- `01-login-nl.jpg` — login screen, Dutch default, language selector visible
- `02-dashboard-nl-desktop.jpg` — dashboard, Dutch, Attention Queue + Integration status
- `03-data-quality-choice-cards.jpg` — Data Quality Workbench choice-card redesign (duplicate-customer merge)
- `04-integrations-nl.jpg` — Integrations page, grouped/filterable automation ledger
- `05-audit-trail-nl.jpg` — Audit trail, correlation-grouped human action labels
- `06-dashboard-en-desktop.jpg` — dashboard, English
- `07-dashboard-fr-desktop.jpg` — dashboard, French
- `08-about-fr.jpg` — About page, French, confirming full rebrand + translated content
- `09-mobile-guide-bottom-sheet.png` — mobile bottom sheet, half state (390×844)
- `10-mobile-guide-full.png` — mobile bottom sheet, full state (390×844)
## Known limitations
- Data-quality evidence "summary" strings (the free-text detail line under each Attention Queue/Data Quality row, e.g. "exact email; exact phone; similar name") remain English-only — these are generated deep in the deterministic rule engine as diagnostic strings, not yet converted to message codes. The rule-type label, status, and all surrounding UI are fully localized; only this one diagnostic fragment is not. Documented as a follow-up, not blocking.
- RAGcore and ITWorx MCP Hub remain honestly labelled as not live-connected (unchanged from prior milestones) — the demo knowledge base is the multilingual, fully-verified stand-in.
- Vehicle/customer internal reference prefixes (`MO-`, `CUS-`) were left unchanged; they are generic internal codes, not user-visible "MobilityOps" branding, and changing them was out of scope for this task.
- Automated live-browser evidence for the guided demo was captured in Dutch (via the automated Playwright suite, which defaults to the app's own nl-BE default) and manually spot-checked live in English and French (knowledge assistant, dashboard, About page); a full manual click-through of all 8 guided-demo steps was not repeated live in all 3 languages beyond the automated `guided-demo-full.spec.ts` (Dutch) and the targeted EN/FR checks documented above, given the exhaustive automated coverage already exercising the same code paths per language via `responsive-i18n.spec.ts` and `i18n-coverage.spec.ts`.
## Rollback procedure
- `.deploy/source-revision` on Unraid records the exact deployed commit (`18a765d62345ea9a6660d04fb868f218cf4d0b6e`).
- Prior tarballs remain in `.deploy/` on the server, including `.deploy/source-845db14.tar.gz` (feature branch, pre-merge) and `.deploy/source-4a268c7.tar.gz` (previous release, pre-polish).
- To roll back: extract the desired `source-<short-sha>.tar.gz`, update `.deploy/source-revision` to match, and re-run `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`. Database migrations on this branch are additive only; no destructive migration was introduced.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -0,0 +1,246 @@
# MobilityOps functional-completion — final summary
## Outcome: PASS
All achievable functional-completion requirements were audited, implemented, tested
locally (including a genuine clean-checkout drill), committed, pushed, deployed to
Unraid and re-verified against the live server after every batch.
## Revisions
- Starting branch: `design/mobilityops-premium-ui`
- Starting/observed commit: `54dc952915a4874fcdf14781e1c37feb0e253851`
- Completion branch: `feat/mobilityops-functional-completion`
- Final commit: `5b2827eb7e84e40d97c4d025debb2af1246908e0` (this is the parent commit
the deploy below targets; the commit that actually records this string is
necessarily one commit later — `git log -1` on this branch is the authoritative
source of the true HEAD)
- Gitea branch URL: `https://gitea.itworx.tech/Jens/MobilityOps` (SSH remote
`ssh://git@192.168.10.150:222/Jens/MobilityOps.git`), branch
`feat/mobilityops-functional-completion`
- Deployed URL: `http://192.168.10.150:1236`
- Server deployment directory: `/mnt/user/appdata/mobilityops`
- Server Compose project: `mobilityops`
## Exact commands executed (representative — run after every batch)
```bash
# Local backend gate
docker compose exec -T api python -m app.cli seed --reset
docker compose exec -T api pytest -q
docker compose run --rm api ruff check .
docker compose run --rm api mypy app
# Local frontend gate
cd frontend && npx tsc -b --noEmit && npm run build
# Local e2e (against the local dev stack)
npx playwright test
# Deploy the exact committed revision
COMMIT=$(git rev-parse HEAD)
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
scp -P 22 /tmp/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 $COMMIT > .deploy/source-revision"
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web"
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current"
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
# e2e against the live server
MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236 npx playwright test
```
Clean-checkout drill (once, section 14):
```bash
git clone --branch feat/mobilityops-functional-completion \
<repo> /tmp/mobilityops-clean-checkout
cd /tmp/mobilityops-clean-checkout
cp .env.example .env
docker compose -p mobilityops-clean up --build -d # isolated project name/ports
docker compose -p mobilityops-clean exec -T api alembic current
docker compose -p mobilityops-clean exec -T api python -m app.cli seed --reset
docker compose -p mobilityops-clean exec -T api pytest -q
docker compose -p mobilityops-clean run --rm api ruff check .
docker compose -p mobilityops-clean run --rm api mypy app
cd frontend && npm ci && npx tsc -b --noEmit && npm run build
MOBILITYOPS_PUBLIC_URL=http://localhost:11228 npx playwright test
docker compose -p mobilityops-clean down -v # isolated project only
```
## Test and validation results
| Gate | Local dev stack | Clean-checkout (isolated) | Live Unraid |
|---|---|---|---|
| `pytest` | 117 passed | 117 passed | — (not applicable; no test runner on the review host) |
| `ruff check .` | clean | clean | — |
| `mypy app` | 0 issues / 46 files | 0 issues / 46 files | — |
| `npx tsc -b` | clean | clean | — |
| `npm run build` | clean | clean | — |
| `npx playwright test` | 37 passed | 37 passed | **37 passed** (against `http://192.168.10.150:1236`) |
| `npm audit` | 4 known advisories (unchanged — see Known limitations) | same | — |
Every batch (1 through 5) was deployed and re-verified with the full 37-test Playwright
suite against the live server before moving to the next batch, not only at the end.
## Application URLs and ports
| Service | URL / port | Notes |
|---|---|---|
| Web (Unraid) | `http://192.168.10.150:1236` | only MobilityOps-owned service exposed on the LAN |
| API (Unraid) | Compose-network only | reached through the web nginx `/api/` proxy |
| PostgreSQL (Unraid) | Compose-network only | never exposed |
| Shared n8n (Unraid) | `http://192.168.10.150:5678` | pre-existing host infrastructure, outside the MobilityOps Compose project |
| Web (local dev) | `http://localhost:1228` | |
| API (local dev) | `http://localhost:8128` | |
| n8n (local dev) | `http://localhost:5678` | bundled, `bundled-n8n` profile |
## Demo users and access method
Two fixed seeded identities, selected via the login screen's role buttons (no
password): **Amelie De Ridder** (`USR-OPS`, Operations Manager) and **Karim
Boujaddaine** (`USR-EMP`, Rental Employee). `POST /api/v1/demo/login` issues an
HttpOnly, `SameSite=Lax` signed session cookie; `GET /api/v1/demo/session` (marked
`Cache-Control: no-store`) is what the browser actually trusts on every load, not a
locally cached copy.
## Implemented functionality (this pass, on top of the already-accepted M0M7/design baseline)
- Fixed two confirmed defects: Vehicles and Bookings both computed a filtered/paginated
result but rendered the raw array.
- Server-backed session lifecycle (`GET /demo/session`, `POST /demo/logout`), central
401 handling, no more `sessionStorage`-as-authority.
- A role matrix enforced server-side (403 on every manager-only action for Rental
Employee, not just a hidden button) and mirrored in the nav/route guards.
- Authoritative, non-mutating return preview (`POST /bookings/{ref}/return-preview`)
sharing its evaluation function with commit — fixed a real bug where the frontend's
guessed preview text was wrong (damage → described as "maintenance", actual rule
"blocked"; the no-contradiction case → described as "available", actual rule always
"cleaning" first).
- Audit API/UI now expose `before`/`after` (the columns existed but were never
serialized) plus a resolved safe entity link.
- Typed related-entity snapshots (booking_overlap's related refs are bookings, not
vehicles — previously silently unresolved) and a bounded resolution flow for every
one of the five data-quality rule types, plus an audited manual scan trigger and
documented recurrence linking (`reopened_from`/`previous_decision`).
- Role-aware backend search (`GET /api/v1/search`) replacing a blind client-side regex
guesser, with a real results panel, keyboard navigation and debouncing.
- A safe, confirmed demo-reset UI trigger (the endpoint already existed and was
already gated).
- Truthful aggregate n8n integration status (`GET /api/v1/integrations/status`) from
outbox delivery counts, replacing a single-most-recent-event read; fixed
`MCP_HUB_REGISTRATION_ENABLED` being declared in `.env.example` but never wired into
`Settings`.
- Bounded outbox delivery-lease recovery for a process crash between claim and outcome.
- A second n8n workflow (scheduled quality scan), independent of RAGcore/MCP Hub.
## RAGcore integration status
Unchanged from the prior baseline and honestly reported throughout: the demo
`KnowledgeProvider` (deterministic TF-IDF extractive retrieval over local procedure
documents) satisfies the knowledge-assistant acceptance criteria and is fully verified.
A `RAGcoreKnowledgeProvider` HTTP adapter is implemented and unit-tested (including its
unavailable-degradation path) but was never exercised against a live RAGcore instance in
this environment — no live RAGcore instance exists to test against.
`KNOWLEDGE_PROVIDER=demo` on the Unraid deployment; no simulated live connection is ever
shown.
## MCP Hub integration status
The four read-only provider endpoints are implemented, tested, and directly
curl-verified with correct service-token auth enforcement and audit logging.
`MCP_HUB_REGISTRATION_ENABLED` — previously declared in `.env.example` but silently
dropped by `extra="ignore"` since it had no `Settings` field — is now actually wired in
and honestly reported (`GET /api/v1/integrations/status`'s `mcp_hub.state`). It is
`false` on the Unraid deployment (`state: "not_configured"`). No live Hub instance was
reachable in this environment to verify an actual Hub round trip.
## n8n integration status
Fully implemented and live-verified. The original return-processing workflow: verified
against both the local bundled instance and the shared Unraid instance, including a real
degraded-mode drill in an earlier session (n8n stopped mid-flow → return still committed
locally, event stayed `pending` with backoff, self-healed once n8n returned) and the
manual-retry path. This pass adds:
- **Truthful status**: `GET /api/v1/integrations/status` derives n8n health from
aggregate outbox counts (pending/delivering/succeeded/failed), not the single most
recent event.
- **Stale-delivery-lease recovery**: a claimed-but-never-resolved `delivering` row (the
process crashing between claim and outcome) is now recoverable; unit-tested including
a simulated crash, and confirmed a still-alive worker's unexpired lease is never
touched.
- **Second workflow**: `mobilityops-scheduled-quality-scan` (hourly + manual-test
trigger, ships `"active": false"`), calling `POST
/api/v1/integrations/n8n/scheduled-scan`. Live-verified two ways: (1) executed
end-to-end via the Manual test trigger against the **local** n8n instance — full
green execution in the n8n editor, confirmed by the resulting
`data_quality_scan_run` audit event (`actor_type=service`); (2) published to the
**shared Unraid n8n** via `deploy/unraid/setup-scheduled-scan.sh` and the resulting
endpoint directly curl-verified against the live deployed API, also confirmed via the
audit trail. The shared instance's own UI could not be browser-tested directly — it
runs `N8N_SECURE_COOKIE=true` and refuses login over the plain-HTTP LAN URL used for
automated testing here, which is correct, pre-existing shared-infrastructure
behaviour and out of scope to change.
## Known limitations
- RAGcore and ITWorx MCP Hub remain honestly not-live-connected — no live instance of
either exists in this environment (environment limitation, not a code defect).
- `npm audit`: one moderate esbuild/Vite dev-server-only advisory (fix requires a Vite
major upgrade, deliberately deferred), and a
react-router RSC-mode advisory that doesn't apply (the app never uses RSC/SSR mode) —
both pre-existing, confirmed unchanged by this pass's clean `npm ci`.
- Demo authentication remains the accepted HMAC-cookie PoC mechanism tied to two fixed
seeded identities — not a production identity provider.
- The scheduled quality-scan workflow's own n8n-engine execution was verified live
against the local bundled n8n and, for the HTTP round trip specifically, against the
shared Unraid n8n's resulting API call — not against a full n8n-engine execution *on
the shared instance itself*, for the browser-access reason above.
- `n8n_delivery_lease_seconds` (120s default) is a code-level tunable, not exposed in
`.env.example`, consistent with the existing `n8n_dispatch_interval_seconds`/
`n8n_max_attempts`/`n8n_http_timeout_seconds` tunables already handled that way.
## Clean deployment instructions
See `deploy/unraid/README.md` and `docs/17-runbook.md` for the full runbook. Redeploy
the exact committed revision:
```bash
COMMIT=<commit to deploy>
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
scp -P 22 /tmp/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 $COMMIT > .deploy/source-revision \
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web \
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current \
&& curl -fsS http://127.0.0.1:1236/health"
```
`.env` and both named volumes (`mobilityops-db`, `mobilityops-n8n`) are preserved by
this flow; nothing outside the `mobilityops` Compose project is touched.
## Five-minute demonstration flow
1. Open `http://192.168.10.150:1236`, log in as **Operations Manager**.
2. Dashboard: point out the persisted readiness metrics and the now-truthful n8n
integration-status card (aggregate counts, not just the latest event).
3. Global search (`Ctrl/Cmd+K`): type a vehicle, booking or issue reference; use arrow
keys + Enter to navigate; show the no-results state for a nonsense query.
4. Bookings: filter by status, page through results (max 25/page), confirm page 2
differs from page 1.
5. Open an active booking → capture a return with a below-canonical odometer reading →
the review step shows the server's authoritative evaluation (odometer regression
flagged, resulting status and reason) → confirm → result screen distinguishes local
commit success from queued-not-yet-confirmed n8n delivery, links to the created
data-quality issue.
6. Data quality: run a manual scan; open the newly flagged (or an existing) issue for
each rule type and show its dedicated bounded resolution flow (not raw JSON).
7. Audit trail: filter by the correlation ID from the return above; show the
human-readable before/after change summary, expand the raw-JSON `<details>`.
8. Switch role to **Rental Employee**: show Data Quality/Integrations/Audit are absent
from the nav, and that direct URL navigation to any of them shows the restricted
message rather than partial data or a crash.
9. Switch back to Operations Manager, trigger **Reset demo data** with confirmation,
land back at login, log in again to confirm deterministic data was restored.
@@ -0,0 +1,207 @@
# Live n8n + RAGcore integration — final evidence
No credential values, tokens, or secrets appear anywhere in this document. Where a
credential or trace ID is referenced, only its name or an opaque reference identifier is
given, never its value.
## Commit
Built on branch `feat/live-n8n-ragcore-integration`, HEAD at commit
`aaa16305354d34f9c1f4d57253d33d9062c38faa` ("docs: record WF2 retry fix and WF4's
n8n-session-expiry blocker"). Run `git log --oneline feat/live-n8n-ragcore-integration`
for the full history of this effort.
## Scope
The brief required treating n8n (`https://n8n.itworx.tech`) as a full third integration
layer alongside RAGcore and MCP Hub, with Fleet Ops keeping exclusive ownership of
business rules, authorization, transactions, audit, and idempotency. Four canonical n8n
workflows were required. The repository (`n8n/workflows/*.json` + `MANIFEST.md` +
`n8n/workflows/check_drift.py`) is the source of truth for cleaned workflow definitions;
the Fleet Ops integration status page (`/automation`) shows real per-workflow operational
evidence, not a config boolean.
## Result summary
| # | Workflow | Status | Live evidence this round |
|---|---|---|---|
| 1 | Fleet Ops — Vehicle Return Orchestration | **Live, hardened** | Timeout+bounded-retry gap found and fixed |
| 2 | Fleet Ops — Scheduled Data Quality Scan | **Live, hardened** | Same gap found and fixed |
| 3 | Fleet Ops — RAGcore Procedure Sync | **Blocked** | Not built — RAGcore rejects credential issuance (see below) |
| 4 | Fleet Ops — Workflow Error Handler | **Live, validated** | Mock + genuine induced-failure test; own hardening incomplete (see below) |
Full per-workflow detail (purpose, trigger, event contract, required credentials, live
workflow ID, checksum) is in `n8n/workflows/MANIFEST.md`, which is the authoritative,
continuously-updated source — this document is a point-in-time summary of that state
plus the reasoning behind what's not done.
## Workflow 1 — Vehicle Return Orchestration
Live workflow ID `mobilityops-return-processing`. Validated in an earlier round of this
effort: webhook trigger requires Header Auth (`Fleet Ops Webhook Trigger Token`),
validates `event_type == vehicle.returned.v1`, derives a follow-up category, calls Fleet
Ops's `/return-callback` endpoint with an `Idempotency-Key` header via a named
`Fleet Ops Service Token` credential (not a literal secret), and responds with a
controlled JSON result. Idempotent on both sides (`event_id` flows through as the
dedup key; the backend independently checks for a prior audit event before recording
again).
**This round's finding**: the `Record follow-up` HTTP node had no explicit timeout and
"Retry On Fail" disabled — a real gap against the requirement that external dependencies
have timeouts and bounded retries. Fixed live: Retry On Fail (3 tries, 1000ms wait) + a
15000ms timeout, published. Safe to retry because the callback is idempotent. Repo
definition and manifest checksum synced (commit `0562893`).
Attached to workflow 4 as its Error Workflow.
## Workflow 2 — Scheduled Data Quality Scan
Live workflow ID `mobilityops-scheduled-quality-scan`. Validated earlier: hourly
Schedule Trigger + a Manual Trigger for on-demand testing, both feeding a single HTTP
call to Fleet Ops's `/scheduled-scan` endpoint (Header Auth via the same `Fleet Ops
Service Token` credential, 15000ms timeout already configured), which runs the
domain-level `run_scan()` function — documented and tested as idempotent by
construction (only ever creates an issue for a condition that doesn't already have one
open), so overlapping or retried triggers do no duplicate domain work.
**This round's finding**: the same Retry On Fail gap as workflow 1 (timeout was already
set, retries were not). Fixed live the same way (3 tries, 1000ms wait), published. Repo
definition and manifest checksum synced (commit `167bf49`).
Attached to workflow 4 as its Error Workflow.
## Workflow 3 — RAGcore Procedure Sync — blocked
**Not built.** This workflow needs an application credential (scope `sources:sync`) for
the `fleet-ops` application in RAGcore. Two independent issuance attempts, in two
separate rounds of this effort, both failed with an opaque server-side rejection:
1. **Raw API**: `POST /v1/applications/{id}/credentials``400`, "authoritative
service-account state rejected issuance".
2. **RAGcore admin UI**, this round, after the project owner explicitly authorized
Claude to self-issue the credential: the "Issue credential" form for the `fleet-ops`
application, submitted as the Platform Admin role (the highest role visible in the
RAGcore admin), with name `n8n-ragcore-procedure-sync` and scope `sources:sync` only.
Result: "Something went wrong. The credential could not be issued with those
values.", trace reference `1955c6a8968c4941a22a1faef39e17a7`.
The `fleet-ops` application itself shows as ordinary/`Active` in the RAGcore admin, with
no visible lock flag, and RAGcore's own OpenAPI spec documents no validation rule that
would explain either rejection (no `422`, no field-level errors). Two independent paths
— a raw API call and the admin UI as the top admin role — hitting the same failure
signature is conclusive evidence this is a RAGcore-side policy or bug, not a Fleet Ops
request-shape or permission problem. It is not fixable from the Fleet Ops side or
through further UI automation. Resolving it requires whoever operates the RAGcore
instance to look up the trace ID above (and the earlier raw-API rejection) in RAGcore's
own logs.
The real RAGcore contract this workflow will be built against — once a working
credential exists — was independently inspected via RAGcore's live OpenAPI spec and is
recorded in `docs/live-ai-integration/n8n-current-state.md` and
`contracts/ragcore-contract-assumptions.md`: control-plane endpoints require an
`Idempotency-Key` header; ingestion is `POST /v1/uploads`; retrieval is `POST
/v1/search` / `/v1/context` / `/v1/answers` (the latter requiring `requested_space_ids`,
an array of knowledge-space UUIDs); health is `/health/live` and `/health/ready` (not
`/health`); the scope enum is `search, context, answer, documents:read, citations:read,
feedback:write, sources:sync`.
**`RAGcoreKnowledgeProvider` adapter** (`backend/app/services/knowledge/ragcore.py`)
still targets the earlier speculative contract (`/health`, `POST /api/v1/ask`, Bearer
token) rather than the real one above. This was deliberately **not** rewritten this
round: rewriting it blind, without a credential to validate against, risks introducing
a silent behavioral bug in exactly the code path responsible for the project's "AI must
never invent an answer when RAGcore is unavailable or returns insufficient evidence"
guarantee — for example a wrong `evidence_state` mapping that looks fine in code review
but misclassifies "unavailable" as "insufficient" (or vice versa) against the real
response shape. The adapter's current behavior is honest and safe (it degrades cleanly
to `unavailable` on any request or parsing failure, and `ragcore_api_token` is unset by
default so the app correctly runs on the local demo knowledge provider today). The
rewrite stays queued behind the same credential blocker as workflow 3.
## Workflow 4 — Workflow Error Handler
Live workflow ID `Xppn2rAEqUuyiCJF`. Built and live-validated in an earlier round:
Error Trigger → a Code node that derives a bounded, secret-free failure report (error
category classified from the message text, truncated summary, no stack trace, no
headers or tokens) → an HTTP call to Fleet Ops's `/workflow-error` endpoint (Header Auth
via the same `Fleet Ops Service Token` credential), which registers the failure as an
audit event idempotently keyed on `execution_id`.
Set as the Error Workflow on both workflow 1 and workflow 2. Confirmed workflow 4 has no
Error Workflow of its own (prevents a recursive loop).
**Live validation performed**: a pinned mock Error Trigger payload produced a real `200
{"status":"registered", ...}` from the live Fleet Ops server; re-running the identical
payload produced `"status":"already_registered"`, confirming idempotency. A genuine
induced failure (temporarily pointing workflow 2's HTTP node at a nonexistent path, then
reverting) confirmed workflow 2 itself fails correctly against a broken endpoint and
recovers cleanly once reverted.
**Known limitation**: n8n's Error Workflow trigger does not fire for manual editor
"Execute workflow" test runs — checked via workflow 4's own Executions list after the
induced workflow-2 failure, and confirmed no new execution appeared. n8n only invokes a
workflow's assigned Error Workflow for unattended/production trigger executions, not
manual test runs from the editor. The mock-data path exercises the same nodes, logic,
and real Fleet Ops endpoint, but a fully automatic (schedule- or webhook-triggered)
failure cascading into workflow 4 was not observed live in either round.
**Open follow-up (minor, non-blocking)**: continuing this round's acceptance pass to
workflow 4 found the same timeout/retry gap as workflows 1 and 2 on its own outbound
HTTP call. A fix was started (15000ms timeout added, Retry On Fail toggled on) but n8n's
autosave began failing with "Unauthorized" mid-edit; a fresh browser tab confirmed the
n8n session had expired (redirected to `/signin`). Nothing was saved — workflow 4's live
definition is unchanged from before this round, so there is no partial or broken state.
This is lower-stakes than workflows 1/2 (workflow 4 is the error notifier itself, not a
primary business flow, and a failed error-report is already visible in n8n's own
execution history via `On Error: Stop Workflow`) but should be finished once the n8n
browser session is re-authenticated.
## Repository source of truth
`n8n/workflows/` holds cleaned, credential-value-free JSON definitions for all built
workflows, `n8n/workflows/MANIFEST.md` documents purpose/trigger/contract/credentials/
live-ID/checksum for all four canonical workflows (including workflow 3's blocked
status), and `n8n/workflows/check_drift.py` is a read-only script that compares the
repo definitions against the live instance via n8n's Public API and reports drift —
safe to run in CI as a non-blocking check. No literal export/download mechanism was
found working in this n8n version, so each definition was reconstructed from direct,
verified UI inspection rather than a native export; this limitation is noted in the
manifest itself.
## Integration status page
`/automation` (Operations Manager only) surfaces real per-workflow evidence derived
purely from Fleet Ops's own audit/outbox tables — no new dependency on n8n's API was
added to the backend. Each of the four canonical workflows shows a status (not built /
no evidence yet / operational) and a last-evidence timestamp; the scheduled-scan
evidence specifically filters to `actor_type == "service"` so a manually-triggered scan
in the UI doesn't count as n8n evidence. An error-handler summary line reports total
registered automation failures and the most recent one.
Verified live in the browser (Dutch locale) both locally and on the deployed
production server (`http://192.168.10.150:1236/automation`): correctly showed "3 van 4
canonieke n8n-workflows hebben actuele evidentie van werking" with real timestamps for
the return/scan/error-handler workflows, "Nog Niet Gebouwd" for the RAGcore sync, and
the real error-handler registration from this effort's live testing.
## Deployments performed (all explicitly user-approved)
1. Backend `/workflow-error` endpoint (commit `bbdb4a9`) — deployed and verified
(`/health` OK, new endpoint returns `422` not `404` on an empty POST body).
2. Integration status page, backend + frontend (commit `4049c0c`) — deployed and
verified (`/health` OK, page renders real evidence in the browser).
The three n8n-side node edits this round (WF1 timeout/retry, WF2 timeout/retry, WF4's
incomplete attempt) are live edits to the n8n instance itself and do not require a
Fleet Ops redeploy.
## What's left
1. **RAGcore credential issuance** — blocked on RAGcore's own server-side rejection
(trace `1955c6a8968c4941a22a1faef39e17a7` and the earlier raw-API `400`). Needs
RAGcore's operator to investigate. Unblocks workflow 3 and the
`RAGcoreKnowledgeProvider` real-contract rewrite.
2. **Workflow 4's own timeout/bounded-retry hardening** — needs the n8n browser session
re-authenticated to finish; a small, well-understood, non-blocking edit.
3. **Fleet Ops logo/favicon** — explicitly deferred by the project owner as a separate,
unrelated follow-up task, not part of this integration effort.
@@ -0,0 +1,25 @@
"""outbox last_error_code
Revision ID: 799d8800e241
Revises: e7b08389f47f
Create Date: 2026-08-03 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '799d8800e241'
down_revision: Union[str, None] = 'e7b08389f47f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('outbox_events', sa.Column('last_error_code', sa.String(length=60), nullable=True))
def downgrade() -> None:
op.drop_column('outbox_events', 'last_error_code')
+97 -12
View File
@@ -1,37 +1,109 @@
from __future__ import annotations
import uuid
from collections.abc import Sequence
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.api.deps import get_db, require_operations_manager
from app.models.audit import AuditEvent
from app.schemas import AuditEventOut, CurrentUser
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
# Only entity types with a stable public reference and (optionally) a real frontend route
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
# entity_id and are left as plain labels.
_ENTITY_MODELS: dict[str, Any] = {
"vehicle": Vehicle,
"booking": Booking,
"customer": Customer,
"data_quality_issue": DataQualityIssue,
}
_ROUTE_TEMPLATES: dict[str, str] = {
"vehicle": "/vehicles/{ref}",
"booking": "/bookings/{ref}",
"data_quality_issue": "/data-quality/{ref}",
# No customer detail route exists in this proof of concept; still resolve the
# reference for display, just without a link.
}
@router.get("", response_model=list[AuditEventOut])
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
ids_by_type: dict[str, set[uuid.UUID]] = {}
for event in events:
if event.entity_id is not None and event.entity_type in _ENTITY_MODELS:
ids_by_type.setdefault(event.entity_type, set()).add(event.entity_id)
refs: dict[uuid.UUID, str] = {}
for entity_type, ids in ids_by_type.items():
model = _ENTITY_MODELS[entity_type]
rows: Sequence[Any] = db.scalars(select(model).where(model.id.in_(ids))).all()
for row in rows:
refs[row.id] = row.public_ref
return refs
@router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
def list_audit_events(
actor_label: str | None = Query(default=None),
action: str | None = Query(default=None),
entity_type: str | None = Query(default=None),
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
correlation_id: str | None = Query(default=None),
limit: int = Query(default=100, le=500),
occurred_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[AuditEventOut]:
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
_user: CurrentUser = Depends(require_operations_manager),
) -> list[AuditEventOut] | AuditEventPageOut:
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
if actor_label:
stmt = stmt.where(AuditEvent.actor_label == actor_label)
if action:
stmt = stmt.where(AuditEvent.action == action)
if entity_type:
stmt = stmt.where(AuditEvent.entity_type == entity_type)
if entity_ref:
matched_ids: set[uuid.UUID] = set()
for model in _ENTITY_MODELS.values():
matched_ids.update(
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all()
)
if not matched_ids:
if page is None:
return []
return AuditEventPageOut(
items=[], page=1, page_size=page_size, total=0, total_pages=1
)
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
if correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
events = db.scalars(stmt).all()
return [
if occurred_from:
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
if occurred_to:
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
events = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
entity_refs = _resolve_entity_refs(db, events)
out = []
for e in events:
ref = entity_refs.get(e.entity_id) if e.entity_id else None
route = _ROUTE_TEMPLATES.get(e.entity_type)
out.append(
AuditEventOut(
id=str(e.id),
actor_type=e.actor_type,
@@ -39,9 +111,22 @@ def list_audit_events(
action=e.action,
entity_type=e.entity_type,
entity_id=str(e.entity_id) if e.entity_id else None,
entity_ref=ref,
entity_link=route.format(ref=ref) if route and ref else None,
correlation_id=str(e.correlation_id),
occurred_at=e.occurred_at,
before=e.before_json,
after=e.after_json,
metadata=e.metadata_json,
)
for e in events
]
)
if page is None:
return out
total_pages = max(1, (total + page_size - 1) // page_size)
return AuditEventPageOut(
items=out,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
+37 -2
View File
@@ -8,8 +8,14 @@ from app.api.deps import get_current_user, get_db
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.vehicle import Vehicle
from app.schemas import BookingOut, CurrentUser, RegisterReturnRequest
from app.services.returns import register_vehicle_return
from app.schemas import (
BookingOut,
CurrentUser,
NextBookingRisk,
RegisterReturnRequest,
ReturnPreviewResult,
)
from app.services.returns import preview_vehicle_return, register_vehicle_return
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
@@ -66,6 +72,35 @@ def get_booking(
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult)
def preview_return(
public_ref: str,
body: RegisterReturnRequest,
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> ReturnPreviewResult:
booking, vehicle, evaluation = preview_vehicle_return(db, public_ref, body)
return ReturnPreviewResult(
booking_ref=booking.public_ref,
vehicle_ref=vehicle.public_ref,
canonical_odometer_km=evaluation.canonical_odometer_km,
submitted_odometer_km=evaluation.submitted_odometer_km,
odometer_regression=evaluation.odometer_regression,
resulting_odometer_km=evaluation.resulting_odometer_km,
resulting_vehicle_status=evaluation.resulting_vehicle_status,
status_reason=evaluation.status_reason,
status_reason_code=evaluation.status_reason_code,
status_reason_params=evaluation.status_reason_params,
would_create_quality_issue=evaluation.would_create_quality_issue,
attention_reasons=evaluation.attention_reasons,
next_booking_risk=(
NextBookingRisk(**evaluation.next_booking_risk)
if evaluation.next_booking_risk is not None
else None
),
)
@router.post("/{public_ref}/return")
def register_return(
public_ref: str,
+25 -7
View File
@@ -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
@@ -19,6 +19,7 @@ from app.schemas import (
AutomationRunOut,
CurrentUser,
DashboardOut,
EvidenceSignalOut,
TodayItem,
)
from app.services.operations import compute_metrics
@@ -30,7 +31,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,20 +62,34 @@ 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}"
# The backend never emits prose for the attention queue -- only stable signal
# codes + raw data params, exactly like the issue detail page's evidence list
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
# that turns these into the operator's selected language; `evidence_json["summary"]`
# is a technical fallback only, never rendered here.
signals = [
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
for s in issue.evidence_json.get("signals", [])
]
attention_items.append(
AttentionItem(
kind="quality_issue",
severity=issue.severity,
title=title,
detail=issue.evidence_json.get("summary", ""),
rule_type=issue.rule_type,
evidence_signals=signals,
link_type=link_type,
link_ref=link_ref,
issue_ref=issue.public_ref,
)
)
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3))
attention_items = attention_items[:8]
# Curate a credible severity mix instead of letting `high` dominate every slot:
# each item's real severity is unchanged, only the display selection is capped per
# tier (a handful of "now", then "today", then "later") so a heavy day of high-severity
# issues doesn't crowd out medium/low ones the operator should still see.
high_items = [i for i in attention_items if i.severity == "high"]
medium_items = [i for i in attention_items if i.severity == "medium"]
low_items = [i for i in attention_items if i.severity == "low"]
attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8]
today = _today()
bookings = db.scalars(select(Booking)).all()
@@ -107,6 +124,7 @@ def get_dashboard(
status=r.delivery_status,
attempts=r.attempts,
last_error=r.last_error,
last_error_code=r.last_error_code,
occurred_at=r.occurred_at,
)
for r in recent
+187 -21
View File
@@ -1,22 +1,42 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db, require_operations_manager
from app.api.deps import get_db, require_operations_manager
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.vehicle import Vehicle
from app.schemas import (
ApplyRecommendedStatusRequest,
ApplyRecommendedStatusResult,
CurrentUser,
DataQualityIssueDetailOut,
DataQualityIssueOut,
DataQualityIssuePageOut,
MergeCustomersRequest,
MergeCustomersResult,
ProvideFieldsRequest,
ResolveOdometerRegressionRequest,
ResolveOverlapRequest,
ScanResultOut,
StatusRecommendationOut,
VehicleStatusFactsOut,
)
from app.services.data_quality import (
apply_recommended_status,
defer_issue,
merge_customers,
preview_vehicle_status_recommendation,
provide_missing_fields,
reject_issue,
resolve_booking_overlap,
resolve_odometer_regression,
run_scan,
)
from app.services.data_quality import defer_issue, merge_customers, reject_issue, run_scan
router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
@@ -35,14 +55,16 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
)
@router.get("/issues", response_model=list[DataQualityIssueOut])
@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
def list_issues(
status: str | None = Query(default=None),
rule_type: str | None = Query(default=None),
severity: str | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[DataQualityIssueOut]:
_user: CurrentUser = Depends(require_operations_manager),
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
if status:
stmt = stmt.where(DataQualityIssue.status == status)
@@ -50,8 +72,42 @@ def list_issues(
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
if severity:
stmt = stmt.where(DataQualityIssue.severity == severity)
issues = db.scalars(stmt).all()
return [_to_out(i) for i in issues]
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
issues = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
items = [_to_out(i) for i in issues]
if page is None:
return items
total_pages = max(1, (total + page_size - 1) // page_size)
return DataQualityIssuePageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
# Every public reference in this system carries its entity type in its own prefix
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
# bookings, not vehicles, and an inline odometer_regression issue's related refs mix a
# booking and an inspection ref in the same list.
_PREFIX_TO_TYPE = {
"CUS-": "customer",
"MO-": "vehicle",
"BK-": "booking",
"INSP-": "inspection",
}
def _entity_type_for_ref(ref: str) -> str | None:
for prefix, entity_type in _PREFIX_TO_TYPE.items():
if ref.startswith(prefix):
return entity_type
return None
def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
@@ -60,6 +116,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
if customer is None:
return None
return {
"entity_type": "customer",
"public_ref": customer.public_ref,
"first_name": customer.first_name,
"last_name": customer.last_name,
@@ -68,41 +125,75 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
"postal_code": customer.postal_code,
"city": customer.city,
}
if entity_type == "vehicle":
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref))
if vehicle is None:
return None
return {
"entity_type": "vehicle",
"public_ref": vehicle.public_ref,
"registration_number": vehicle.registration_number,
"make": vehicle.make,
"model": vehicle.model,
"location": vehicle.location,
"operational_status": vehicle.operational_status,
"odometer_km": vehicle.odometer_km,
}
if entity_type == "booking":
booking = db.scalar(select(Booking).where(Booking.public_ref == ref))
if booking is None:
return None
vehicle = db.get(Vehicle, booking.vehicle_id)
customer = db.get(Customer, booking.customer_id)
return {
"entity_type": "booking",
"public_ref": booking.public_ref,
"status": booking.status,
"starts_at": booking.starts_at.isoformat(),
"ends_at": booking.ends_at.isoformat(),
"vehicle_ref": vehicle.public_ref if vehicle else None,
"customer_ref": customer.public_ref if customer else None,
"end_odometer_km": booking.end_odometer_km,
}
if entity_type == "inspection":
inspection = db.scalar(select(Inspection).where(Inspection.public_ref == ref))
if inspection is None:
return None
booking = db.get(Booking, inspection.booking_id)
return {
"entity_type": "inspection",
"public_ref": inspection.public_ref,
"type": inspection.type,
"odometer_km": inspection.odometer_km,
"completed_at": inspection.completed_at.isoformat(),
"booking_ref": booking.public_ref if booking else None,
}
return None
@router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut)
def get_issue(
public_ref: str,
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
_user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueDetailOut:
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
if issue is None:
raise HTTPException(status_code=404, detail="Data quality issue not found")
base = _to_out(issue)
related_refs = issue.evidence_json.get("related_refs", [])
related_entity_type = (
"customer" if issue.rule_type == "possible_duplicate_customer" else "vehicle"
)
related_snapshots = []
for ref in related_refs:
entity_type = _entity_type_for_ref(ref)
if entity_type is None:
continue
snap = _snapshot(entity_type, ref, db)
if snap is not None:
related_snapshots.append(snap)
return DataQualityIssueDetailOut(
**base.model_dump(),
entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db),
related_snapshots=[
snap
for ref in related_refs
if (snap := _snapshot(related_entity_type, ref, db)) is not None
],
related_snapshots=related_snapshots,
)
@@ -110,7 +201,7 @@ def get_issue(
def defer(
public_ref: str,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueOut:
issue = defer_issue(db, public_ref, user)
return _to_out(issue)
@@ -120,7 +211,7 @@ def defer(
def reject(
public_ref: str,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueOut:
issue = reject_issue(db, public_ref, user)
return _to_out(issue)
@@ -137,10 +228,85 @@ def merge(
return MergeCustomersResult(**result)
@router.post("/issues/{public_ref}/provide-fields", response_model=DataQualityIssueOut)
def provide_fields(
public_ref: str,
body: ProvideFieldsRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueOut:
issue = provide_missing_fields(db, public_ref, body.fields, user)
return _to_out(issue)
@router.post(
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
)
def resolve_odometer(
public_ref: str,
body: ResolveOdometerRegressionRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueOut:
issue = resolve_odometer_regression(db, public_ref, body, user)
return _to_out(issue)
@router.post("/issues/{public_ref}/resolve-overlap", response_model=DataQualityIssueOut)
def resolve_overlap(
public_ref: str,
body: ResolveOverlapRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueOut:
issue = resolve_booking_overlap(db, public_ref, body.booking_ref, body.note, user)
return _to_out(issue)
@router.post(
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
)
def status_recommendation(
public_ref: str,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> StatusRecommendationOut:
"""Non-mutating preview: computes the recommendation without changing anything,
resolving no issue and writing no audit event. Safe to call repeatedly."""
_issue, _vehicle, recommendation, token = preview_vehicle_status_recommendation(db, public_ref)
return StatusRecommendationOut(
current_status=recommendation.current_status,
recommended_status=recommendation.recommended_status,
recommendation_code=recommendation.recommendation_code,
safe_to_apply=recommendation.safe_to_apply,
manual_review_required=recommendation.manual_review_required,
facts=VehicleStatusFactsOut(**recommendation.facts.as_dict()),
blocking_reasons=recommendation.blocking_reasons,
recommendation_token=token,
)
@router.post(
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
)
def apply_status(
public_ref: str,
body: ApplyRecommendedStatusRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> ApplyRecommendedStatusResult:
issue, applied_status, reason_code = apply_recommended_status(
db, public_ref, user, body.recommendation_token
)
return ApplyRecommendedStatusResult(
issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
)
@router.post("/scan", response_model=ScanResultOut)
def scan(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
user: CurrentUser = Depends(require_operations_manager),
) -> ScanResultOut:
result = run_scan(db)
result = run_scan(db, actor_label=user.display_name, actor_type="user")
return ScanResultOut(created=result.created)
+61 -6
View File
@@ -1,23 +1,33 @@
from __future__ import annotations
import time
import uuid
from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
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
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)
@@ -41,6 +51,7 @@ def demo_login(
token,
httponly=True,
samesite="lax",
secure=settings.session_cookie_secure,
max_age=settings.session_ttl_seconds,
)
record_audit_event(
@@ -56,21 +67,65 @@ def demo_login(
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role)
@router.get("/session", response_model=CurrentUser)
def get_session(
response: Response, user: CurrentUser = Depends(get_current_user)
) -> CurrentUser:
# Never let the browser (or an intermediary) cache an authentication check — a stale
# cached 200 here would keep showing a logged-out browser as authenticated.
response.headers["Cache-Control"] = "no-store"
return user
@router.post("/logout")
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None
if payload is not None:
record_audit_event(
db,
actor_type="user",
actor_id=uuid.UUID(payload.user_id),
actor_label=payload.display_name,
action="demo_logout",
entity_type="user",
)
db.commit()
response.delete_cookie(settings.session_cookie_name)
return {"status": "logged_out"}
@router.post("/reset")
def demo_reset(
response: Response,
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,
}
@@ -0,0 +1,21 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.schemas import CurrentUser, IntegrationStatusOut
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
@router.get("/status", response_model=IntegrationStatusOut)
def integration_status(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
) -> IntegrationStatusOut:
return IntegrationStatusOut(
n8n=derive_n8n_status(db),
mcp_hub=derive_mcp_hub_status(db),
)
+149
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Header
@@ -13,7 +14,18 @@ from app.core.config import get_settings
from app.core.errors import AppError
from app.models.audit import AuditEvent
from app.models.outbox import OutboxEvent
from app.schemas import (
ProcedureDocumentOut,
ProcedureListOut,
ProcedureSyncResultIn,
ProcedureSyncResultResult,
ScanResultOut,
WorkflowErrorReportIn,
WorkflowErrorReportResult,
)
from app.services.audit import record_audit_event
from app.services.data_quality import run_scan
from app.services.knowledge.procedures import iter_procedure_documents
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
settings = get_settings()
@@ -71,3 +83,140 @@ def return_callback(
"event_id": str(event_id),
"occurred_at": datetime.now(UTC).isoformat(),
}
@router.post("/scheduled-scan", response_model=ScanResultOut)
def scheduled_scan(
service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db),
) -> ScanResultOut:
"""Triggered by the scheduled n8n quality-scan workflow. Narrow, read-mostly, and
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
doesn't already have one open, so a duplicate or overlapping trigger does no
duplicate domain work -- it just reports zero new issues for anything already known."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
return ScanResultOut(created=result.created)
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
def workflow_error(
body: WorkflowErrorReportIn,
service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db),
) -> WorkflowErrorReportResult:
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
error report (e.g. after a timed-out response), so this must not double-record."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
already_recorded = (
db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "n8n_workflow_failure_registered",
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
)
)
is not None
)
if not already_recorded:
correlation_id: uuid.UUID | None = None
if body.correlation_id:
try:
correlation_id = uuid.UUID(body.correlation_id)
except ValueError:
correlation_id = None
record_audit_event(
db,
actor_type="service",
actor_label="n8n error handler",
action="n8n_workflow_failure_registered",
entity_type="automation",
correlation_id=correlation_id,
after={
"workflow_id": body.workflow_id,
"workflow_name": body.workflow_name,
"error_category": body.error_category,
"error_summary": body.error_summary,
"trigger_context": body.trigger_context,
"attempt": body.attempt,
"retry_action": body.retry_action,
"failed_at": body.failed_at.isoformat(),
},
metadata={"execution_id": body.execution_id},
)
db.commit()
return WorkflowErrorReportResult(
status="already_registered" if already_recorded else "registered",
execution_id=body.execution_id,
occurred_at=datetime.now(UTC),
)
@router.get("/procedures", response_model=ProcedureListOut)
def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut:
"""Read-only source list for the RAGcore Procedure Sync workflow: every procedure
Markdown file Fleet Ops ships, across every supported language, with a stable
per-document id (source_id) and a content hash so the caller can detect changes
without re-fetching content it already has."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
documents = [
ProcedureDocumentOut(
id=doc.source_id,
language=doc.language,
document_id=doc.document_id,
title=doc.title,
version=doc.version,
content=doc.content,
content_hash=doc.content_hash,
)
for doc in iter_procedure_documents(Path(settings.knowledge_dir))
]
return ProcedureListOut(documents=documents)
@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult)
def procedures_sync_result(
body: ProcedureSyncResultIn,
service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db),
) -> ProcedureSyncResultResult:
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
already_recorded = (
db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "n8n_procedures_synced",
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
)
)
is not None
)
if not already_recorded:
record_audit_event(
db,
actor_type="service",
actor_label="n8n procedure sync",
action="n8n_procedures_synced",
entity_type="automation",
after={"synced": body.synced, "failed": body.failed},
metadata={"execution_id": body.execution_id},
)
db.commit()
return ProcedureSyncResultResult(
status="already_registered" if already_recorded else "registered",
execution_id=body.execution_id,
occurred_at=datetime.now(UTC),
)
+11 -3
View File
@@ -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)
+43 -10
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import date
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -27,14 +27,30 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
settings = get_settings()
def _audit_service_request(db: Session, *, client_id: str, tool: str, status_label: str) -> None:
def get_correlation_id(
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
) -> str:
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
if x_correlation_id:
try:
return str(uuid.UUID(x_correlation_id))
except ValueError:
pass
return str(uuid.uuid4())
def _audit_service_request(
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str
) -> None:
record_audit_event(
db,
actor_type="service",
actor_label=client_id,
action="mcp_tool_request",
entity_type="mcp_tool",
correlation_id=uuid.uuid4(),
correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label},
)
db.commit()
@@ -44,10 +60,15 @@ def _audit_service_request(db: Session, *, client_id: str, tool: str, status_lab
def operations_summary(
db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> OperationsSummaryOut:
metrics = compute_metrics(db)
_audit_service_request(
db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok"
db,
client_id=client_id,
tool="fleet_ops_get_operations_summary",
status_label="ok",
correlation_id=correlation_id,
)
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
@@ -59,12 +80,17 @@ def attention_vehicles(
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> list[AttentionVehicleOut]:
results = list_attention_vehicles(
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
)
_audit_service_request(
db, client_id=client_id, tool="mobilityops_list_attention_vehicles", status_label="ok"
db,
client_id=client_id,
tool="fleet_ops_list_attention_vehicles",
status_label="ok",
correlation_id=correlation_id,
)
return [AttentionVehicleOut(**r) for r in results]
@@ -74,14 +100,16 @@ def vehicle_details(
vehicle_ref: str,
db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> McpVehicleDetailOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None:
_audit_service_request(
db,
client_id=client_id,
tool="mobilityops_get_vehicle_details",
tool="fleet_ops_get_vehicle_details",
status_label="not_found",
correlation_id=correlation_id,
)
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
@@ -99,7 +127,11 @@ def vehicle_details(
)
_audit_service_request(
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok"
db,
client_id=client_id,
tool="fleet_ops_get_vehicle_details",
status_label="ok",
correlation_id=correlation_id,
)
return McpVehicleDetailOut(
public_ref=vehicle.public_ref,
@@ -120,15 +152,16 @@ def search_knowledge(
body: McpKnowledgeSearchRequest,
db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> GroundedAnswer:
provider = get_knowledge_provider()
correlation_id = str(uuid.uuid4())
answer = provider.ask(body.question, correlation_id)
answer = provider.ask(body.question, correlation_id, language=body.locale)
answer.sources = answer.sources[: body.max_sources]
_audit_service_request(
db,
client_id=client_id,
tool="mobilityops_search_knowledge",
tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state,
correlation_id=correlation_id,
)
return answer
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, SearchResponse, SearchResultItem
router = APIRouter(prefix="/api/v1/search", tags=["search"])
# Static application sections. `id` is a stable code matching navigation.json's
# `items.*` keys -- the frontend localizes both the section label and its one-line
# detail from `id`, so no English prose is sent over the wire (search.sections.<id> in
# every locale; see docs/fleet-ops-correction/i18n-inventory.md). Manager-only sections
# are filtered by role, mirroring the same nav visibility rule Layout.tsx applies --
# search must never surface a destination the current role can't actually reach.
_SECTIONS: list[dict] = [
{
"id": "overview",
"link": "/dashboard",
# Search terms deliberately span all three supported UI languages (not just
# English) so a query never depends on the operator's selected locale.
"terms": ["overview", "dashboard", "readiness", "overzicht", "aperçu", "tableau de bord"],
},
{
"id": "fleet",
"link": "/vehicles",
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
},
{
"id": "bookings",
"link": "/bookings",
"terms": [
"booking",
"bookings",
"rental",
"boeking",
"boekingen",
"verhuur",
"réservation",
"réservations",
"location",
],
},
{
"id": "quality",
"link": "/data-quality",
"terms": [
"quality",
"data quality",
"issues",
"kwaliteit",
"datakwaliteit",
"problemen",
"qualité",
"problèmes",
],
"role": "operations_manager",
},
{
"id": "knowledge",
"link": "/knowledge",
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
},
{
"id": "integrations",
"link": "/automation",
"terms": [
"automation",
"integrations",
"systems",
"n8n",
"automatisering",
"integraties",
"systemen",
"automatisation",
"intégrations",
"systèmes",
],
"role": "operations_manager",
},
{
"id": "audit",
"link": "/audit",
"terms": ["audit", "history", "geschiedenis", "historique"],
"role": "operations_manager",
},
]
@router.get("", response_model=SearchResponse)
def search(
q: str = Query(min_length=1, max_length=100),
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> SearchResponse:
query = q.strip()
normalized = query.lower()
results: list[SearchResultItem] = []
for section in _SECTIONS:
role = section.get("role")
if role and user.role != role:
continue
terms: list[str] = section["terms"]
if any(term in normalized or normalized in term for term in terms):
results.append(
SearchResultItem(
type="section",
label=section["id"],
detail_code=section["id"],
link=section["link"],
)
)
like = f"%{query}%"
for v in db.scalars(
select(Vehicle)
.where(
or_(
Vehicle.public_ref.ilike(like),
Vehicle.make.ilike(like),
Vehicle.model.ilike(like),
Vehicle.registration_number.ilike(like),
Vehicle.location.ilike(like),
)
)
.order_by(Vehicle.public_ref)
.limit(5)
).all():
results.append(
SearchResultItem(
type="vehicle",
label=v.public_ref,
detail_code="vehicleSummary",
detail_params={"make": v.make, "model": v.model, "location": v.location},
link=f"/vehicles/{v.public_ref}",
)
)
for b in db.scalars(
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5)
).all():
results.append(
SearchResultItem(
type="booking",
label=b.public_ref,
detail_code=b.status,
link=f"/bookings/{b.public_ref}",
)
)
# No customer detail route exists in this proof of concept, so customers are
# deliberately never returned here -- there is nowhere useful to send the user.
if user.role == "operations_manager":
for i in db.scalars(
select(DataQualityIssue)
.where(DataQualityIssue.public_ref.ilike(like))
.order_by(DataQualityIssue.detected_at.desc())
.limit(5)
).all():
results.append(
SearchResultItem(
type="data_quality_issue",
label=i.public_ref,
detail_code=i.rule_type,
link=f"/data-quality/{i.public_ref}",
)
)
return SearchResponse(query=query, results=results[:10])
+38 -8
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
@@ -19,6 +19,7 @@ from app.schemas import (
MaintenanceOut,
VehicleDetailOut,
VehicleOut,
VehiclePageOut,
)
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
@@ -34,19 +35,41 @@ def _attention_vehicle_ids(db: Session) -> set:
return set(rows)
@router.get("", response_model=list[VehicleOut])
@router.get("", response_model=list[VehicleOut] | VehiclePageOut)
def list_vehicles(
status: str | None = Query(default=None),
attention_only: bool = Query(default=False),
query: str | None = Query(default=None, min_length=1, max_length=100),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[VehicleOut]:
) -> list[VehicleOut] | VehiclePageOut:
stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status:
stmt = stmt.where(Vehicle.operational_status == status)
vehicles = db.scalars(stmt).all()
if query:
term = f"%{query.strip()}%"
stmt = stmt.where(
or_(
Vehicle.public_ref.ilike(term),
Vehicle.make.ilike(term),
Vehicle.model.ilike(term),
Vehicle.location.ilike(term),
Vehicle.registration_number.ilike(term),
)
)
attention_ids = _attention_vehicle_ids(db)
out = [
if attention_only:
stmt = stmt.where(
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
vehicles = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
items = [
VehicleOut(
public_ref=v.public_ref,
make=v.make,
@@ -62,9 +85,16 @@ def list_vehicles(
)
for v in vehicles
]
if attention_only:
out = [v for v in out if v.attention]
return out
if page is None:
return items
total_pages = max(1, (total + page_size - 1) // page_size)
return VehiclePageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
@router.get("/{public_ref}", response_model=VehicleDetailOut)
+16 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.errors import AppError
from app.models.outbox import OutboxEvent
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
from app.schemas import AutomationRunOut, CurrentUser
from app.services.audit import record_audit_event
@@ -23,6 +23,8 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
status=event.delivery_status,
attempts=event.attempts,
last_error=event.last_error,
last_error_code=event.last_error_code,
is_demo_scenario=is_demo_scenario_failure(event),
occurred_at=event.occurred_at,
)
@@ -62,15 +64,27 @@ def retry_workflow(
status_code=409,
)
# Captured before the status flips, so the audit records what was actually retried.
was_demo_scenario = is_demo_scenario_failure(event)
event.delivery_status = "pending"
event.next_attempt_at = None
# The retry itself is real either way: the event goes back on the outbox and the
# dispatcher delivers it to the configured n8n webhook like any other. The only
# difference recorded here is *what* was retried -- a staged demo failure or a real
# one -- so the audit trail never implies a production incident was resolved when a
# prop was.
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="workflow_retry",
entity_type="outbox_event",
metadata={"event_id": event_id, "previous_attempts": event.attempts},
metadata={
"event_id": event_id,
"previous_attempts": event.attempts,
"demo_scenario": was_demo_scenario,
},
)
db.commit()
return _to_out(event)
+19 -1
View File
@@ -2,6 +2,12 @@ from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
# The visible product name is fixed and never translated or configured per-deployment --
# see docs/fleet-ops-correction/current-gap-audit.md section 1. Internal identifiers
# (package name, Compose project, database name, repository) intentionally remain
# "mobilityops"; this constant is only for user-facing surfaces (e.g. the OpenAPI title).
PRODUCT_NAME = "Fleet Ops"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -15,21 +21,33 @@ class Settings(BaseSettings):
ragcore_workspace: str = "mobilityops"
ragcore_collection: str = "internal-procedures"
ragcore_api_token: str = ""
ragcore_space_id: str = ""
ragcore_http_timeout_seconds: float = 5.0
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
n8n_callback_token: str = "replace-me-n8n-callback-token"
n8n_dispatch_enabled: bool = True
n8n_dispatch_interval_seconds: float = 3.0
n8n_http_timeout_seconds: float = 5.0
n8n_max_attempts: int = 5
n8n_delivery_lease_seconds: float = 120.0
app_secret: str = "replace-in-production"
session_cookie_name: str = "mobilityops_session"
session_ttl_seconds: int = 60 * 60 * 8
session_cookie_secure: bool = False
seed_dir: str = "/app/seed"
knowledge_dir: str = "/app/knowledge/procedures"
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False
# MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles
# its catalog into the gateway; Fleet Ops never pushes a registration call), so
# these are only used for an honest reachability health check, not self-registration.
mcp_hub_base_url: str = ""
mcp_provider_id: str = "fleet-ops"
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
+6 -2
View File
@@ -11,13 +11,15 @@ from app.api.routers import (
dashboard,
data_quality,
demo,
integration_status,
integrations,
knowledge,
mcp_integrations,
search,
vehicles,
workflows,
)
from app.core.config import get_settings
from app.core.config import PRODUCT_NAME, get_settings
from app.core.errors import AppError, error_body
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
@@ -31,7 +33,7 @@ async def lifespan(_app: FastAPI):
stop_background_dispatcher()
app = FastAPI(title="MobilityOps API", version="0.1.0", lifespan=lifespan)
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -88,3 +90,5 @@ app.include_router(workflows.router)
app.include_router(integrations.router)
app.include_router(knowledge.router)
app.include_router(mcp_integrations.router)
app.include_router(search.router)
app.include_router(integration_status.router)
+24
View File
@@ -10,6 +10,25 @@ from app.models.mixins import TimestampMixin
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
# The one delivery failure the demo seed deliberately plants (BK-H-0020, see
# seed/workflow_runs.csv). It exists to show retry and audit working, so it must never
# be read as an integration-health problem: it is a scripted prop, not evidence that
# n8n is unhealthy. A dedicated error code -- rather than the generic
# "connectionError" a real timeout produces -- is what lets every reader tell the two
# apart without guessing from the message text.
#
# It is deliberately a `last_error_code` value and not a new column: the code is
# already persisted, already surfaced to the UI, and already localizable, so no schema
# change or migration is needed. A genuine later failure of this same event overwrites
# the code with the real one, which is exactly right -- from that moment it *is* a real
# failure.
DEMO_SCENARIO_ERROR_CODE = "demoScenarioTimeout"
def is_demo_scenario_failure(event: "OutboxEvent") -> bool:
"""True for the prepared demo failure, false for every real one."""
return event.delivery_status == "failed" and event.last_error_code == DEMO_SCENARIO_ERROR_CODE
class OutboxEvent(TimestampMixin, Base):
__tablename__ = "outbox_events"
@@ -26,4 +45,9 @@ class OutboxEvent(TimestampMixin, Base):
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str | None] = mapped_column(Text)
# Stable, localizable classification of last_error -- the frontend renders a
# localized summary from this code as the primary text and shows last_error itself
# only under "Technical details" (section 10 of docs/fleet-ops-correction/
# current-gap-audit.md). Kept alongside the raw message for backward compatibility.
last_error_code: Mapped[str | None] = mapped_column(String(60))
external_run_id: Mapped[str | None] = mapped_column(String(120))
+247 -2
View File
@@ -32,6 +32,14 @@ class VehicleOut(BaseModel):
attention: bool = False
class VehiclePageOut(BaseModel):
items: list[VehicleOut]
page: int
page_size: int
total: int
total_pages: int
class BookingSummaryOut(BaseModel):
public_ref: str
customer_ref: str
@@ -74,6 +82,22 @@ class RegisterReturnResult(BaseModel):
next_booking_risk: NextBookingRisk | None
class ReturnPreviewResult(BaseModel):
booking_ref: str
vehicle_ref: str
canonical_odometer_km: int
submitted_odometer_km: int
odometer_regression: bool
resulting_odometer_km: int
resulting_vehicle_status: str
status_reason: str
status_reason_code: str
status_reason_params: dict[str, str | int] = {}
would_create_quality_issue: bool
attention_reasons: list[str]
next_booking_risk: NextBookingRisk | None
class InspectionOut(BaseModel):
public_ref: str
booking_ref: str
@@ -106,6 +130,14 @@ class DataQualityIssueOut(BaseModel):
resolved_at: datetime | None = None
class DataQualityIssuePageOut(BaseModel):
items: list[DataQualityIssueOut]
page: int
page_size: int
total: int
total_pages: int
class DataQualityIssueDetailOut(DataQualityIssueOut):
entity_snapshot: dict[str, Any] | None = None
related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
@@ -127,6 +159,196 @@ class ScanResultOut(BaseModel):
created: dict[str, int]
class WorkflowErrorReportIn(BaseModel):
workflow_id: str = Field(max_length=120)
workflow_name: str = Field(max_length=200)
execution_id: str = Field(max_length=120)
failed_at: datetime
error_category: Literal[
"timeout", "authError", "connectionError", "httpError", "validationError", "unknown"
]
error_summary: str = Field(max_length=500)
trigger_context: str | None = Field(default=None, max_length=200)
correlation_id: str | None = None
attempt: int = Field(default=1, ge=1, le=1000)
retry_action: str | None = Field(default=None, max_length=200)
class WorkflowErrorReportResult(BaseModel):
status: Literal["registered", "already_registered"]
execution_id: str
occurred_at: datetime
class ProcedureDocumentOut(BaseModel):
id: str
language: str
document_id: str
title: str
version: str
content: str
content_hash: str
class ProcedureListOut(BaseModel):
documents: list[ProcedureDocumentOut]
class ProcedureSyncResultIn(BaseModel):
execution_id: str = Field(max_length=120)
synced: int = Field(ge=0)
failed: int = Field(default=0, ge=0)
class ProcedureSyncResultResult(BaseModel):
status: Literal["registered", "already_registered"]
execution_id: str
occurred_at: datetime
class ProvideFieldsRequest(BaseModel):
fields: dict[str, str]
class ResolveOdometerRegressionRequest(BaseModel):
decision: Literal["retain_canonical", "correct_reading"]
booking_ref: str | None = None
corrected_odometer_km: Annotated[int, Field(ge=0)] | None = None
note: str | None = Field(default=None, max_length=500)
class ResolveOverlapRequest(BaseModel):
booking_ref: str
note: str | None = Field(default=None, max_length=500)
class VehicleStatusFactsOut(BaseModel):
active_booking_refs: list[str]
overlapping_booking_pairs: list[list[str]]
service_threshold_reached: bool
odometer_km: int
next_service_km: int
open_booking_overlap_issue_ref: str | None = None
class StatusRecommendationOut(BaseModel):
current_status: str
recommended_status: str | None
recommendation_code: str
safe_to_apply: bool
manual_review_required: bool
facts: VehicleStatusFactsOut
blocking_reasons: list[str]
recommendation_token: str
class ApplyRecommendedStatusRequest(BaseModel):
recommendation_token: str
class ApplyRecommendedStatusResult(BaseModel):
issue: DataQualityIssueOut
applied_status: str
reason_code: str
class SearchResultItem(BaseModel):
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
label: str
detail_code: str
detail_params: dict[str, str] = {}
link: str
class SearchResponse(BaseModel):
query: str
results: list[SearchResultItem]
class N8nWorkflowEvidence(BaseModel):
name: str
built: bool
last_seen_at: datetime | None
class N8nErrorHandlerStatus(BaseModel):
total_failures_registered: int
latest_failure_at: datetime | None
latest_failure_workflow: str | None
class N8nIntegrationStatus(BaseModel):
configured: bool
dispatch_enabled: bool
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
pending: int
#: Every failed delivery, staged and real together -- the number a viewer sees in
#: the run list.
failed: int
#: Failures that were not planted by the demo seed. This is the only failure count
#: that may influence `state`.
unexpected_failed: int = 0
#: Prepared demo failures (see `app.models.outbox.DEMO_SCENARIO_ERROR_CODE`).
#: Present so the UI can label them instead of implying the automation is broken.
demo_scenario_failed: int = 0
delivering: int
succeeded: int
latest_success_at: datetime | None
#: Most recent *real* failure; a staged one never sets this.
latest_failure_at: datetime | None
latest_demo_scenario_at: datetime | None = None
expected_workflow_count: int
known_workflow_count: int
workflows: list[N8nWorkflowEvidence]
error_handler: N8nErrorHandlerStatus
class McpHubIntegrationStatus(BaseModel):
registration_enabled: bool
state: Literal["not_configured", "no_evidence", "operational"]
total_calls: int
last_tool: str | None = None
last_client: str | None = None
last_called_at: datetime | None = None
hub_reachable: bool | None = None
class IntegrationStatusOut(BaseModel):
n8n: N8nIntegrationStatus
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)
@@ -144,11 +366,16 @@ class DashboardMetrics(BaseModel):
pending_or_failed_workflows: int
class EvidenceSignalOut(BaseModel):
code: str
params: dict[str, Any] = Field(default_factory=dict)
class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"]
severity: str
title: str
detail: str
rule_type: str
evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
link_type: Literal["vehicle", "booking", "customer"]
link_ref: str
issue_ref: str | None = None
@@ -168,6 +395,11 @@ class AutomationRunOut(BaseModel):
status: str
attempts: int
last_error: str | None
last_error_code: str | None
#: True for the deliberately seeded demo failure. The UI uses this to label the run
#: as a prepared scenario and to offer the demo retry, instead of presenting it as
#: an unexplained production error.
is_demo_scenario: bool = False
occurred_at: datetime
@@ -207,6 +439,7 @@ class McpVehicleDetailOut(BaseModel):
class McpKnowledgeSearchRequest(BaseModel):
question: str = Field(min_length=3, max_length=1000)
max_sources: int = Field(default=4, ge=1, le=8)
locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB"
class AuditEventOut(BaseModel):
@@ -216,6 +449,18 @@ class AuditEventOut(BaseModel):
action: str
entity_type: str
entity_id: str | None
entity_ref: str | None = None
entity_link: str | None = None
correlation_id: str
occurred_at: datetime
before: dict[str, Any] | None = None
after: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class AuditEventPageOut(BaseModel):
items: list[AuditEventOut]
page: int
page_size: int
total: int
total_pages: int
+166 -18
View File
@@ -3,7 +3,8 @@ 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 difflib import SequenceMatcher
from pathlib import Path
from sqlalchemy import delete, insert, update
@@ -17,9 +18,10 @@ from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, 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 +38,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 +66,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 +98,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
@@ -92,11 +109,11 @@ def load_seed(db: Session) -> SeedResult:
customer_id_by_ref: dict[str, uuid.UUID] = {}
customer_rows = []
customer_row_by_ref: dict[str, dict] = {}
for row in _read_csv("customers.csv"):
cid = uuid.uuid4()
customer_id_by_ref[row["public_ref"]] = cid
customer_rows.append(
{
customer_row = {
"id": cid,
"public_ref": row["public_ref"],
"first_name": row["first_name"],
@@ -106,7 +123,8 @@ def load_seed(db: Session) -> SeedResult:
"postal_code": row["postal_code"] or None,
"city": row["city"] or None,
}
)
customer_rows.append(customer_row)
customer_row_by_ref[row["public_ref"]] = customer_row
db.execute(insert(Customer), customer_rows)
counts["customers"] = len(customer_rows)
# Second pass for merged_into (self-referencing FK) since target must exist first.
@@ -121,11 +139,11 @@ def load_seed(db: Session) -> SeedResult:
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
vehicle_rows = []
vehicle_row_by_ref: dict[str, dict] = {}
for row in _read_csv("vehicles.csv"):
vid = uuid.uuid4()
vehicle_id_by_ref[row["public_ref"]] = vid
vehicle_rows.append(
{
vehicle_row = {
"id": vid,
"public_ref": row["public_ref"],
"make": row["make"],
@@ -139,29 +157,31 @@ def load_seed(db: Session) -> SeedResult:
"active": _parse_bool(row["active"]),
"version": 1,
}
)
vehicle_rows.append(vehicle_row)
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
db.execute(insert(Vehicle), vehicle_rows)
counts["vehicles"] = len(vehicle_rows)
booking_id_by_ref: dict[str, uuid.UUID] = {}
booking_rows = []
booking_row_by_ref: dict[str, dict] = {}
for row in _read_csv("bookings.csv"):
bid = uuid.uuid4()
booking_id_by_ref[row["public_ref"]] = bid
booking_rows.append(
{
booking_row = {
"id": bid,
"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"]),
"requirements_complete": _parse_bool(row["requirements_complete"]),
}
)
booking_rows.append(booking_row)
booking_row_by_ref[row["public_ref"]] = booking_row
db.execute(insert(Booking), booking_rows)
counts["bookings"] = len(booking_rows)
@@ -179,7 +199,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 +213,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"],
@@ -207,11 +227,118 @@ def load_seed(db: Session) -> SeedResult:
return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref]
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
# handful of seed-only rows below -- none of them carry an active rental or a
# real booking conflict (verified against the fixed seed dataset), only a
# genuinely-crossed service threshold or none at all, so those two fields are
# the only ones that vary per vehicle.
vehicle = vehicle_row_by_ref[vehicle_ref]
return {
"active_booking_refs": [],
"overlapping_booking_pairs": [],
"service_threshold_reached": service_threshold_reached,
"odometer_km": vehicle["odometer_km"],
"next_service_km": vehicle["next_service_km"],
"open_booking_overlap_issue_ref": None,
}
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
later = booking_row_by_ref[later_ref]
earlier = booking_row_by_ref[earlier_ref]
return [
{
"code": "odometer.regression",
"params": {
"later_ref": later_ref,
"later_km": later["end_odometer_km"],
"earlier_ref": earlier_ref,
"earlier_km": earlier["end_odometer_km"],
},
}
]
def _missing_field_signal(field: str) -> list[dict]:
return [{"code": "missing_field", "params": {"field": field}}]
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
# used to carry no structured signal at all -- just the placeholder summary
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
# pair of booking odometer readings engineered into seed/bookings.csv), using the
# exact same signal vocabulary the live scan (app.services.data_quality) already
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
"DQ-0005": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
}
],
"DQ-0006": _missing_field_signal("location"),
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
"DQ-0008": [
{
"code": "vehicle.rental_ended",
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
}
],
"DQ-0009": _missing_field_signal("location"),
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
"DQ-0011": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
}
],
"DQ-0012": _missing_field_signal("registration_number"),
"DQ-0013": _missing_field_signal("location"),
"DQ-0014": _missing_field_signal("registration_number"),
"DQ-0015": _missing_field_signal("location"),
"DQ-0016": _missing_field_signal("location"),
"DQ-0017": _missing_field_signal("location"),
"DQ-0018": _missing_field_signal("registration_number"),
"DQ-0019": _missing_field_signal("location"),
"DQ-0020": _missing_field_signal("location"),
"DQ-0021": _missing_field_signal("location"),
}
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
# they carry real, accurate structured signals (not just a legacy English
# sentence) -- the frontend renders these as the primary, localized evidence;
# see docs/fleet-ops-correction/current-gap-audit.md §6.
if public_ref == "DQ-DEMO-DUPLICATE":
a = customer_row_by_ref[entity_ref]
b = customer_row_by_ref[related_refs[0]]
name_a = f"{a['first_name']} {a['last_name']}".strip().lower()
name_b = f"{b['first_name']} {b['last_name']}".strip().lower()
ratio = SequenceMatcher(None, name_a, name_b).ratio()
return [
{"code": "duplicate.exact_email"},
{"code": "duplicate.exact_phone"},
{"code": "duplicate.same_postal_code"},
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}},
]
if public_ref == "DQ-DEMO-OVERLAP":
return [{"code": "overlap.reserved_bookings", "params": {"refs": related_refs}}]
if public_ref == "DQ-DEMO-STATUS":
return [{"code": "vehicle.booking_conflict"}]
if public_ref == "DQ-DEMO-ATTENTION":
return [
{
"code": "attention.upcoming_booking_missing_inspection",
"params": {"booking_ref": related_refs[0] if related_refs else ""},
}
]
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
dq_rows = []
now = datetime.now(UTC)
for row in _read_csv("data_quality_issues.csv"):
entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or ""
related_refs = related_ref.split("|") if related_ref else []
dq_rows.append(
{
"id": uuid.uuid4(),
@@ -224,7 +351,8 @@ def load_seed(db: Session) -> SeedResult:
"evidence_json": {
"summary": row["evidence"],
"entity_ref": row["entity_ref"],
"related_refs": related_ref.split("|") if related_ref else [],
"related_refs": related_refs,
"signals": _seed_signals(row["public_ref"], row["entity_ref"], related_refs),
},
"proposed_action_json": {},
"detected_at": now,
@@ -266,18 +394,38 @@ 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,
"last_error": row["last_error"] or None,
# The seed dataset's one synthetic failure (BK-H-0020) models a
# connection-timeout-style delivery failure -- see workflow_runs.csv.
# It is coded as a *prepared demo scenario*, not as a real
# connectionError, so integration health never degrades because of a
# prop and a viewer is told plainly that this failure is staged.
"last_error_code": DEMO_SCENARIO_ERROR_CODE if row["last_error"] else None,
"external_run_id": None,
}
)
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:
+494 -41
View File
@@ -13,8 +13,15 @@ from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
from app.schemas import CurrentUser
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
from app.services.audit import record_audit_event
from app.services.vehicle_status import (
RECOMMENDATION_CODE_NO_CONFLICT,
VehicleStatusRecommendation,
compute_recommendation_token,
evaluate_vehicle_status,
gather_vehicle_status_facts,
)
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
@@ -69,10 +76,38 @@ def _open_issue(
summary: str,
entity_ref: str,
related_refs: list[str],
signals: list[dict] | None = None,
) -> None:
if _has_open_issue(db, rule_type, entity_type, entity_id):
return
now = datetime.now(UTC)
# Reintroduced evidence creates a new issue rather than silently reopening the old
# one, but it stays linked to whatever decision was made last time so an operator
# doesn't re-litigate from a blank slate.
previous = db.scalar(
select(DataQualityIssue)
.where(
DataQualityIssue.rule_type == rule_type,
DataQualityIssue.entity_type == entity_type,
DataQualityIssue.entity_id == entity_id,
DataQualityIssue.status != "open",
)
.order_by(DataQualityIssue.detected_at.desc())
)
# `summary` is kept as a technical-fallback string (shown only under "Technical
# details"); `signals` is the stable, localizable structure the frontend renders as
# the primary evidence -- see docs/fleet-ops-correction/current-gap-audit.md §2/§6.
evidence: dict = {
"summary": summary,
"entity_ref": entity_ref,
"related_refs": related_refs,
"signals": signals or [],
}
if previous is not None:
evidence["reopened_from"] = previous.public_ref
evidence["previous_decision"] = previous.status
issue = DataQualityIssue(
public_ref=_next_public_ref(db, "DQ-SCAN"),
rule_type=rule_type,
@@ -80,11 +115,7 @@ def _open_issue(
entity_id=entity_id,
severity=severity,
status="open",
evidence_json={
"summary": summary,
"entity_ref": entity_ref,
"related_refs": related_refs,
},
evidence_json=evidence,
proposed_action_json={},
detected_at=now,
)
@@ -102,22 +133,29 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
for i, a in enumerate(customers):
for b in customers[i + 1 :]:
score = 0
signals = []
signals: list[dict] = []
summary_parts: list[str] = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append("exact email")
signals.append({"code": "duplicate.exact_email"})
summary_parts.append("exact email")
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
score += 50
signals.append("exact phone")
signals.append({"code": "duplicate.exact_phone"})
summary_parts.append("exact phone")
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
score += 10
signals.append("exact postal code")
signals.append({"code": "duplicate.same_postal_code"})
summary_parts.append("exact postal code")
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append("similar name")
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
_open_issue(
@@ -127,9 +165,10 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(signals) + f" (score {score})",
summary="; ".join(summary_parts) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
signals=signals,
)
@@ -151,6 +190,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
summary=f"Missing: {', '.join(missing)}",
entity_ref=customer.public_ref,
related_refs=[],
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
)
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
@@ -166,6 +206,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
summary=f"Missing: {', '.join(missing)}",
entity_ref=vehicle.public_ref,
related_refs=[],
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
)
@@ -194,39 +235,32 @@ def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None:
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
entity_ref=vehicle.public_ref,
related_refs=[first.public_ref, second.public_ref],
signals=[
{
"code": "overlap.reserved_bookings",
"params": {"refs": [first.public_ref, second.public_ref]},
}
],
)
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
# Uses the same shared evaluator as the preview/apply flow (app.services.vehicle_status)
# so detection and resolution can never structurally disagree -- see
# docs/fleet-ops-correction/vehicle-status-decision-table.md.
vehicles = db.scalars(select(Vehicle)).all()
active_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars(select(Booking).where(Booking.status == "active")).all():
active_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
open_high_by_vehicle = {
row[0]
for row in db.execute(
select(DataQualityIssue.entity_id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.status == "open",
DataQualityIssue.severity == "high",
)
).all()
}
for vehicle in vehicles:
has_active_booking = vehicle.id in active_by_vehicle
reason = None
if vehicle.operational_status == "available" and has_active_booking:
reason = "marked available while an active booking exists"
elif vehicle.operational_status == "rented" and not has_active_booking:
reason = "marked rented without an active booking"
elif vehicle.operational_status == "available" and vehicle.id in open_high_by_vehicle:
reason = "marked available while a high-severity quality issue is open"
elif vehicle.operational_status == "maintenance" and has_active_booking:
reason = "marked maintenance while an active booking exists"
facts = gather_vehicle_status_facts(db, vehicle)
recommendation = evaluate_vehicle_status(vehicle, facts)
if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT:
continue
if reason:
signals = [{"code": recommendation.recommendation_code, "params": facts.as_dict()}]
summary = (
f"Recommended status: {recommendation.recommended_status}"
if recommendation.recommended_status
else "Manual review required: active rental conflicts with a blocking condition"
)
_open_issue(
db,
scan,
@@ -234,9 +268,13 @@ def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
entity_type="vehicle",
entity_id=vehicle.id,
severity="high",
summary=f"Vehicle {reason}",
summary=summary,
entity_ref=vehicle.public_ref,
related_refs=[],
related_refs=[
*facts.active_booking_refs,
*(ref for pair in facts.overlapping_booking_pairs for ref in pair),
],
signals=signals,
)
@@ -276,17 +314,39 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
),
entity_ref=vehicle.public_ref,
related_refs=[earlier.public_ref, later.public_ref],
signals=[
{
"code": "odometer.regression",
"params": {
"later_ref": later.public_ref,
"later_km": later.end_odometer_km,
"earlier_ref": earlier.public_ref,
"earlier_km": earlier.end_odometer_km,
},
}
],
)
break
def run_scan(db: Session) -> ScanResult:
def run_scan(
db: Session, *, actor_label: str | None = None, actor_type: str = "user"
) -> ScanResult:
scan = ScanResult()
_scan_duplicate_customers(db, scan)
_scan_missing_required_fields(db, scan)
_scan_odometer_regressions(db, scan)
_scan_booking_overlaps(db, scan)
_scan_vehicle_status_conflicts(db, scan)
if actor_label is not None:
record_audit_event(
db,
actor_type=actor_type,
actor_label=actor_label,
action="data_quality_scan_run",
entity_type="system",
metadata={"created": scan.created},
)
db.commit()
return scan
@@ -344,6 +404,399 @@ def reject_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualit
return issue
def provide_missing_fields(
db: Session, public_ref: str, fields: dict[str, str], actor: CurrentUser
) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "missing_required_field":
raise AppError(
"NOT_A_MISSING_FIELD_ISSUE",
"This issue is not a missing-required-field issue.",
status_code=409,
)
entity: Customer | Vehicle | None
if issue.entity_type == "customer":
entity = db.get(Customer, issue.entity_id)
allowed = {*REQUIRED_CUSTOMER_FIELDS, "email", "phone"}
elif issue.entity_type == "vehicle":
entity = db.get(Vehicle, issue.entity_id)
allowed = set(REQUIRED_VEHICLE_FIELDS)
else:
raise AppError(
"UNSUPPORTED_ENTITY",
f"Cannot provide fields for entity type '{issue.entity_type}'.",
status_code=409,
)
if entity is None:
raise AppError(
"ENTITY_NOT_FOUND", "The underlying record could not be found.", status_code=404
)
invalid = set(fields) - allowed
if invalid:
raise AppError(
"INVALID_FIELD",
f"Fields not permitted here: {', '.join(sorted(invalid))}.",
status_code=422,
)
if not fields:
raise AppError(
"NO_FIELDS_PROVIDED", "At least one field must be provided.", status_code=422
)
before = {f: getattr(entity, f) for f in allowed}
for field_name, value in fields.items():
if not value.strip():
raise AppError("EMPTY_VALUE", f"Field '{field_name}' cannot be blank.", status_code=422)
setattr(entity, field_name, value.strip())
after = {f: getattr(entity, f) for f in allowed}
correlation_id = uuid.uuid4()
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_fields_provided",
entity_type=issue.entity_type,
entity_id=entity.id,
correlation_id=correlation_id,
before=before,
after=after,
metadata={"issue_ref": issue.public_ref},
)
if isinstance(entity, Customer):
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(entity, f)]
if not entity.email and not entity.phone:
missing.append("email_or_phone")
else:
missing = [f for f in REQUIRED_VEHICLE_FIELDS if not getattr(entity, f)]
if not missing:
issue.status = "resolved"
issue.resolved_at = datetime.now(UTC)
issue.resolved_by = actor.display_name
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_issue_resolved",
entity_type="data_quality_issue",
entity_id=issue.id,
correlation_id=correlation_id,
before={"status": "open"},
after={"status": "resolved"},
)
else:
issue.evidence_json = {**issue.evidence_json, "summary": f"Missing: {', '.join(missing)}"}
db.commit()
return issue
def resolve_odometer_regression(
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "odometer_regression":
raise AppError(
"NOT_AN_ODOMETER_ISSUE",
"This issue is not an odometer_regression issue.",
status_code=409,
)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
correlation_id = uuid.uuid4()
if body.decision == "retain_canonical":
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_odometer_retained",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
)
else:
related_refs = issue.evidence_json.get("related_refs", [])
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
# Never silently lower the canonical odometer: a correction must be at or above
# the current canonical value, otherwise it would just create a new regression.
if body.corrected_odometer_km < vehicle.odometer_km:
raise AppError(
"CORRECTION_BELOW_CANONICAL",
(
f"Corrected value {body.corrected_odometer_km} km is still below the "
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
),
status_code=422,
)
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
before = {
"booking_end_odometer_km": booking.end_odometer_km,
"vehicle_odometer_km": vehicle.odometer_km,
}
booking.end_odometer_km = body.corrected_odometer_km
vehicle.odometer_km = body.corrected_odometer_km
vehicle.version += 1
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_odometer_corrected",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
before=before,
after={
"booking_end_odometer_km": booking.end_odometer_km,
"vehicle_odometer_km": vehicle.odometer_km,
},
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
)
issue.status = "resolved"
issue.resolved_at = datetime.now(UTC)
issue.resolved_by = actor.display_name
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_issue_resolved",
entity_type="data_quality_issue",
entity_id=issue.id,
correlation_id=correlation_id,
before={"status": "open"},
after={"status": "resolved"},
metadata={"decision": body.decision, "note": body.note},
)
db.commit()
return issue
def resolve_booking_overlap(
db: Session, public_ref: str, booking_ref: str, note: str | None, actor: CurrentUser
) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "booking_overlap":
raise AppError(
"NOT_AN_OVERLAP_ISSUE", "This issue is not a booking_overlap issue.", status_code=409
)
related_refs = issue.evidence_json.get("related_refs", [])
if booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's overlapping bookings.",
status_code=422,
)
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
if booking is None:
raise AppError("BOOKING_NOT_FOUND", "The booking to block was not found.", status_code=404)
if booking.status not in ("reserved", "active"):
raise AppError(
"BOOKING_NOT_ACTIVE",
f"Booking is '{booking.status}'; only a reserved or active booking can be blocked.",
status_code=409,
)
before = {"status": booking.status}
booking.status = "blocked"
# Verify the minimal safe resolution actually removed the conflict: no two
# reserved/active bookings for this vehicle should still overlap. The session has
# autoflush disabled, so exclude the just-blocked booking by id rather than relying
# on the in-memory status change being visible to this query.
remaining = db.scalars(
select(Booking).where(
Booking.vehicle_id == booking.vehicle_id,
Booking.status.in_(["reserved", "active"]),
Booking.public_ref.in_(related_refs),
Booking.id != booking.id,
)
).all()
for i, first in enumerate(remaining):
for second in remaining[i + 1 :]:
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
raise AppError(
"OVERLAP_STILL_PRESENT",
"Blocking this booking did not remove the overlap; another commitment remains.",
status_code=409,
)
correlation_id = uuid.uuid4()
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_booking_blocked",
entity_type="booking",
entity_id=booking.id,
correlation_id=correlation_id,
before=before,
after={"status": booking.status},
metadata={"issue_ref": issue.public_ref, "note": note},
)
issue.status = "resolved"
issue.resolved_at = datetime.now(UTC)
issue.resolved_by = actor.display_name
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_issue_resolved",
entity_type="data_quality_issue",
entity_id=issue.id,
correlation_id=correlation_id,
before={"status": "open"},
after={"status": "resolved"},
)
db.commit()
return issue
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "vehicle_status_conflict":
raise AppError(
"NOT_A_STATUS_CONFLICT_ISSUE",
"This issue is not a vehicle_status_conflict issue.",
status_code=409,
)
return issue
def preview_vehicle_status_recommendation(
db: Session, public_ref: str
) -> tuple[DataQualityIssue, Vehicle, VehicleStatusRecommendation, str]:
"""Non-mutating: computes and returns the recommendation only. Never resolves the
issue, never writes an audit event, never queues automation -- safe to call as often
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
issue = _load_vehicle_status_conflict_issue(db, public_ref)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
recommendation = evaluate_vehicle_status(vehicle, facts)
token = compute_recommendation_token(vehicle, facts)
return issue, vehicle, recommendation, token
def apply_recommended_status(
db: Session, public_ref: str, actor: CurrentUser, expected_token: str
) -> tuple[DataQualityIssue, str, str]:
issue = _load_vehicle_status_conflict_issue(db, public_ref)
# Lock the vehicle row for the remainder of this transaction so a concurrent apply
# (or return/checkout) can't race between our fact-gathering and the write below.
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
recommendation = evaluate_vehicle_status(vehicle, facts)
current_token = compute_recommendation_token(vehicle, facts)
if current_token != expected_token:
raise AppError(
"RECOMMENDATION_STALE",
"The underlying facts changed since this recommendation was shown; "
"review the recommendation again before applying it.",
status_code=409,
)
if recommendation.manual_review_required or not recommendation.safe_to_apply:
raise AppError(
"MANUAL_REVIEW_REQUIRED",
"This vehicle's state requires manual review; no automatic status change is safe.",
status_code=409,
)
if recommendation.recommended_status is None:
raise AppError(
"NO_CONFLICT_DETECTED",
"The current vehicle state no longer conflicts; nothing to apply.",
status_code=409,
)
new_status = recommendation.recommended_status
reason_code = recommendation.recommendation_code
before = {"operational_status": vehicle.operational_status}
vehicle.operational_status = new_status
vehicle.version += 1
# Re-validate against the same shared evaluator, over freshly-gathered facts, that
# applying this change actually leaves no conflict -- never trust the pre-computed
# recommendation alone for the post-condition.
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
post_check = evaluate_vehicle_status(vehicle, post_facts)
if post_check.recommendation_code not in (
RECOMMENDATION_CODE_NO_CONFLICT,
):
raise AppError(
"CONFLICT_STILL_PRESENT",
"Applying the recommended status did not resolve the conflict.",
status_code=409,
)
correlation_id = uuid.uuid4()
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_status_applied",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
before=before,
after={"operational_status": vehicle.operational_status},
metadata={"issue_ref": issue.public_ref, "reason_code": reason_code},
)
issue.status = "resolved"
issue.resolved_at = datetime.now(UTC)
issue.resolved_by = actor.display_name
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_issue_resolved",
entity_type="data_quality_issue",
entity_id=issue.id,
correlation_id=correlation_id,
before={"status": "open"},
after={"status": "resolved"},
)
db.commit()
return issue, new_status, reason_code
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
+196
View File
@@ -0,0 +1,196 @@
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_mcp_hub_status, 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.<id>.*) 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()
mcp_hub = derive_mcp_hub_status(db)
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
if knowledge_health.document_count is not None
else "unknown"
),
"collection": knowledge_health.collection,
},
),
DemoIntegrationSummaryOut(
key="mcp_hub",
# `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is
# catalog-driven on the Hub's side, so the flag only says Fleet Ops expects
# to be called. Only real recorded `mcp_tool_request` calls make this
# "operational" -- same evidence rule the integration status page uses.
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
detail_code=(
"mcpDetailEnabled" if mcp_hub.state == "operational" 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),
)
+63 -2
View File
@@ -22,8 +22,43 @@ def _backoff_seconds(attempts: int) -> int:
return min(2**attempts, 60)
def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
"""Recover events stuck in 'delivering' because the process that claimed them died
before recording an outcome. Only leases whose deadline has passed are touched, so an
in-flight delivery from a still-alive worker is never disturbed or double-processed;
`attempts` is preserved so the count reflects true history."""
db = SessionLocal()
try:
now = datetime.now(UTC)
rows = db.scalars(
select(OutboxEvent)
.where(
OutboxEvent.delivery_status == "delivering",
OutboxEvent.next_attempt_at.is_not(None),
OutboxEvent.next_attempt_at <= now,
)
.limit(batch_size)
.with_for_update(skip_locked=True)
).all()
for row in rows:
row.delivery_status = "pending"
row.next_attempt_at = None
row.last_error = (
"Recovered from a stale 'delivering' lease "
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
)[:2000]
row.last_error_code = "staleLeaseRecovered"
db.commit()
return len(rows)
finally:
db.close()
def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
"""Claim a batch of due events with a short-lived transaction (no network I/O held open)."""
"""Claim a batch of due events with a short-lived transaction (no network I/O held open).
Each claimed row gets a lease deadline (next_attempt_at) so a crash between this claim
and the outcome being recorded is recoverable by _reclaim_stale_deliveries."""
db = SessionLocal()
try:
now = datetime.now(UTC)
@@ -38,8 +73,10 @@ def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
.with_for_update(skip_locked=True)
).all()
claimed_ids = [row.event_id for row in rows]
lease_deadline = now + timedelta(seconds=settings.n8n_delivery_lease_seconds)
for row in rows:
row.delivery_status = "delivering"
row.next_attempt_at = lease_deadline
db.commit()
return claimed_ids
finally:
@@ -75,22 +112,43 @@ def _deliver_one(event_id: uuid.UUID) -> None:
finally:
db.close()
error_code: str | None
if wire_event is None:
success, error, body = False, payload_error, None
error_code = "malformedPayload"
else:
try:
response = httpx.post(
settings.n8n_webhook_url,
json=wire_event,
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
timeout=settings.n8n_http_timeout_seconds,
)
response.raise_for_status()
try:
body = response.json()
except ValueError:
body = None
if isinstance(body, dict):
success = bool(body.get("ok", True))
error = None if success else f"n8n reported failure: {body}"
error_code = None if success else "remoteReportedFailure"
else:
# A 2xx status with a non-object (or unparsable) body means the workflow
# itself errored before its "Respond to Webhook" node ran -- n8n's default
# error response still carries a 2xx-looking status here. Treat it as a
# failure so the event is retried rather than lost or wrongly marked
# succeeded.
success = False
error = (
"Unexpected non-JSON-object response from n8n "
f"(status {response.status_code})"
)
error_code = "malformedResponse"
except httpx.HTTPError as exc:
success = False
error = f"{type(exc).__name__}: {exc}"
error_code = "connectionError"
body = None
db = SessionLocal()
@@ -102,10 +160,12 @@ def _deliver_one(event_id: uuid.UUID) -> None:
if success:
event.delivery_status = "succeeded"
event.last_error = None
event.last_error_code = None
event.next_attempt_at = None
event.external_run_id = str((body or {}).get("event_id", event_id))
else:
event.last_error = (error or "delivery failed")[:2000]
event.last_error_code = error_code or "unknownError"
if event.attempts >= settings.n8n_max_attempts:
event.delivery_status = "failed"
event.next_attempt_at = None
@@ -120,7 +180,8 @@ def _deliver_one(event_id: uuid.UUID) -> None:
def run_dispatch_cycle() -> int:
"""Run one claim+deliver cycle. Returns the number of events processed."""
"""Run one reclaim+claim+deliver cycle. Returns the number of events processed."""
_reclaim_stale_deliveries()
claimed = _claim_due_events()
for event_id in claimed:
_deliver_one(event_id)
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
from typing import Literal
import httpx
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.audit import AuditEvent
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.schemas import (
McpHubIntegrationStatus,
N8nErrorHandlerStatus,
N8nIntegrationStatus,
N8nWorkflowEvidence,
)
settings = get_settings()
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
# built (all with their full node set saved).
_CANONICAL_WORKFLOWS = (
"Fleet Ops — Vehicle Return Orchestration",
"Fleet Ops — Scheduled Data Quality Scan",
"Fleet Ops — RAGcore Procedure Sync",
"Fleet Ops — Workflow Error Handler",
)
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)
# Prepared demo failures are props, not health signals. They stay visible and
# counted -- hiding them would be its own kind of lie -- but they are counted
# *separately*, and only genuinely unexpected failures are allowed to move n8n off
# "operational". Without this split the demo seed's single staged failure pins the
# integration to "degraded" forever, which tells a viewer something untrue about
# the automation.
demo_scenario_failed = (
db.scalar(
select(func.count())
.select_from(OutboxEvent)
.where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
)
)
or 0
)
unexpected_failed = max(failed - demo_scenario_failed, 0)
latest_success_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
)
# Health talks about real failures only, so the "latest failure" a health reader
# sees must exclude the staged one too.
latest_failure_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
)
)
latest_demo_scenario_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
)
)
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
if not settings.n8n_dispatch_enabled:
state = "disabled"
elif unexpected_failed > 0 and succeeded == 0:
state = "unavailable"
elif unexpected_failed > 0:
state = "degraded"
elif succeeded > 0 or pending > 0 or delivering > 0:
state = "operational"
else:
state = "no_evidence"
# Scheduled scan evidence: only service-triggered runs count as n8n evidence, not
# runs an operator triggered manually from the Data Quality page.
latest_scan_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where(
AuditEvent.action == "data_quality_scan_run",
AuditEvent.actor_type == "service",
)
)
# RAGcore Procedure Sync evidence: result reports posted by the workflow itself once
# it finishes uploading procedures to RAGcore (app/api/routers/integrations.py::
# procedures_sync_result), the same "the workflow's own callback is the evidence"
# pattern the scheduled scan and error handler already use below.
latest_procedure_sync_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where(
AuditEvent.action == "n8n_procedures_synced"
)
)
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
# Handler" n8n workflow itself, which also doubles as proof that workflow is wired
# up and firing correctly.
total_failures_registered = (
db.scalar(
select(func.count(AuditEvent.id)).where(
AuditEvent.action == "n8n_workflow_failure_registered"
)
)
or 0
)
latest_failure_row = db.execute(
select(AuditEvent.occurred_at, AuditEvent.after_json)
.where(AuditEvent.action == "n8n_workflow_failure_registered")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
).first()
latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None
latest_handler_failure_workflow = (
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
)
evidence_by_workflow = {
"Fleet Ops — Vehicle Return Orchestration": latest_success_at,
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
"Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at,
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
}
workflows = [
N8nWorkflowEvidence(
name=name,
built=True,
last_seen_at=evidence_by_workflow[name],
)
for name in _CANONICAL_WORKFLOWS
]
return N8nIntegrationStatus(
configured=bool(settings.n8n_webhook_url),
dispatch_enabled=settings.n8n_dispatch_enabled,
state=state,
pending=pending,
delivering=delivering,
failed=failed,
unexpected_failed=unexpected_failed,
demo_scenario_failed=demo_scenario_failed,
succeeded=succeeded,
latest_success_at=latest_success_at,
latest_failure_at=latest_failure_at,
latest_demo_scenario_at=latest_demo_scenario_at,
expected_workflow_count=len(_CANONICAL_WORKFLOWS),
known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None),
workflows=workflows,
error_handler=N8nErrorHandlerStatus(
total_failures_registered=total_failures_registered,
latest_failure_at=latest_handler_failure_at,
latest_failure_workflow=latest_handler_failure_workflow,
),
)
def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
"""Evidence-based MCP Hub status: real tool-call audit history, not just the
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
total_calls = (
db.scalar(
select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request")
)
or 0
)
latest_call_row = db.execute(
select(AuditEvent.occurred_at, AuditEvent.actor_label, AuditEvent.metadata_json)
.where(AuditEvent.action == "mcp_tool_request")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
).first()
last_called_at = latest_call_row[0] if latest_call_row else None
last_client = latest_call_row[1] if latest_call_row else None
last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None
state: Literal["not_configured", "no_evidence", "operational"]
if not settings.mcp_hub_registration_enabled:
state = "not_configured"
elif total_calls > 0:
state = "operational"
else:
state = "no_evidence"
hub_reachable = _check_hub_reachable()
return McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled,
state=state,
total_calls=total_calls,
last_tool=last_tool,
last_client=last_client,
last_called_at=last_called_at,
hub_reachable=hub_reachable,
)
def _check_hub_reachable() -> bool | None:
"""Real Hub-side health signal (MCP Hub's own registration is catalog-driven on
its side, so this is the only thing Fleet Ops itself can honestly check).
`None` means not configured / not checked, never a guess."""
if not settings.mcp_hub_base_url:
return None
try:
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
return response.status_code == 200
except httpx.HTTPError:
return False
+7 -3
View File
@@ -33,15 +33,19 @@ class KnowledgeHealth(BaseModel):
tenant: str
workspace: str
collection: str
document_count: int
# A provider may be healthy without exposing a corpus-size endpoint. `None` means
# unknown, never "zero procedures".
document_count: int | None
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
+120 -54
View File
@@ -7,16 +7,40 @@ from pathlib import Path
from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge.procedures import parse_frontmatter
STOPWORDS = {
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]+")
# Includes the Latin-1 accented-letter range (à-ö, ø-ÿ) so French/Dutch words with
# diacritics (véhicule, réservation, geëscaleerd) tokenize as one word instead of
# splitting apart at the accented character -- a plain [a-z0-9]+ pattern silently
# drops every accent and fragments the word either side of it.
_WORD_RE = re.compile(r"[a-zà-öø-ÿ0-9]+")
def _stem(word: str) -> str:
@@ -28,9 +52,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
@@ -50,23 +75,6 @@ class ScoredSection:
body_tokens: set[str]
def _parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
if not raw.startswith("---"):
return {}, raw
end = raw.find("\n---", 3)
if end == -1:
return {}, raw
block = raw[3:end].strip()
body = raw[end + 4 :].lstrip("\n")
meta: dict[str, str] = {}
for line in block.splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
meta[key.strip()] = value.strip().strip('"')
return meta, body
def _split_sections(body: str) -> list[tuple[str, str]]:
sections: list[tuple[str, str]] = []
current_heading = "Overview"
@@ -86,17 +94,17 @@ 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")
meta, body = _parse_frontmatter(raw)
meta, body = parse_frontmatter(raw)
title = meta.get("title", path.stem)
doc = Document(
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 +112,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/<language>/ -- retrieval searches only within the requested
language's corpus so citations always link to a same-language document.
"""
name = "demo"
@@ -124,10 +162,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 +186,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 +200,51 @@ 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:
# The section body is the strongest relevance signal -- it's the actual
# substance a heading or title can only hint at -- so a body match is weighted
# *above* heading/title matches, not below them. The previous 3x/2x/1x
# (heading/title/body) ordering let a single generic word in a heading (e.g.
# "vehicle", present in nearly every section) or a document's own title
# outrank a section whose body genuinely covers multiple, more distinctive
# query terms -- confirmed to misrank the brief's exact validation question in
# every one of the three languages (see docs/fleet-ops-correction/
# current-gap-audit.md and i18n-inventory.md): nl-BE picked a checkout section
# over the damage procedure, en-GB and fr-BE picked the return procedure over
# the damage procedure, purely from heading/title overlap on common words.
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
if token in section.body_tokens:
score += 3 * token_idf
elif token in section.heading_tokens:
score += 2 * token_idf
elif token in section.document.title_tokens:
score += 2 * idf
elif token in section.body_tokens:
score += idf
score += 1.5 * 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 +264,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 +272,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,
@@ -0,0 +1,71 @@
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from pathlib import Path
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
# Stable across runs (and across which language ships first) so a document's RAGcore
# source_id never changes just because the sync ran on a different day or in a
# different order -- required for RAGcore's upload idempotency to work per document.
_SOURCE_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures")
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
if not raw.startswith("---"):
return {}, raw
end = raw.find("\n---", 3)
if end == -1:
return {}, raw
block = raw[3:end].strip()
body = raw[end + 4 :].lstrip("\n")
meta: dict[str, str] = {}
for line in block.splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
meta[key.strip()] = value.strip().strip('"')
return meta, body
@dataclass(frozen=True)
class ProcedureDocument:
source_id: str
language: str
document_id: str
title: str
version: str
content: str
content_hash: str
def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]:
"""Read every procedure Markdown file Fleet Ops ships, across every supported
language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter
fields (title, version) come from the same files the demo knowledge provider
already reads -- see parse_frontmatter -- so the two never drift apart."""
documents: list[ProcedureDocument] = []
for language in SUPPORTED_LANGUAGES:
language_dir = knowledge_dir / language
if not language_dir.is_dir():
continue
for path in sorted(language_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8")
meta, body = parse_frontmatter(raw)
document_id = meta.get("document_id", path.stem)
content = body.strip()
documents.append(
ProcedureDocument(
source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")),
language=language,
document_id=document_id,
title=meta.get("title", path.stem),
version=meta.get("version", "1.0"),
content=content,
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
)
)
return documents
+167 -45
View File
@@ -3,18 +3,51 @@ from __future__ import annotations
import httpx
from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited
# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a
# fallback when RAGcore's own /v1/answers (generation + citation validation) is
# unavailable but its retrieval (/v1/search) still returns real, relevant, cited
# results -- see ask() below. Unlike the demo corpus's own markdown frontmatter, RAGcore's
# `document_version_id` is an opaque UUID, not a human-meaningful version string, so it
# is deliberately left out of this sentence (it still appears on the source card itself).
_LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}": {excerpt}',
"nl-BE": 'Volgens "{title}": {excerpt}',
"fr-BE": 'Selon « {title} » : {excerpt}',
}
_DEFAULT_LANGUAGE = "en-GB"
class RAGcoreKnowledgeProvider:
"""Adapter for the central RAGcore service.
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
owned separately, MobilityOps only ever talks to its documented HTTP API).
RAGcore is built and owned separately (see contracts/ragcore-contract-assumptions.md).
No live RAGcore instance was reachable during this build, so the exact request/response
shape below is a best-effort guess at a REST contract; any failure (connection, timeout,
malformed response) degrades to `unavailable` rather than raising, per the architecture's
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of
the app, and never fabricates an answer.
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
session-cookie auth is for its own browser admin UI only). Any connection error,
timeout, non-2xx response, or malformed body degrades to `evidence_state:
"unavailable"` rather than raising -- this is the adapter that actually exercises the
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
never fabricates an answer, never affects the rest of the app.
`/v1/answers` (RAGcore's own generation + citation-validation step) is tried first;
if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200
classifying the question as insufficiently answerable), `ask()` falls back to
RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline
stage with no generation step to fail. The fallback answer is always an extractive
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every
chunk's `language` payload field as `"en"` regardless of actual document language (the
`/v1/uploads` contract has no per-file language field for a caller to set correctly).
Filtering search/answer requests by requested UI language would therefore silently
exclude genuinely-relevant nl-BE/fr-BE content, so this adapter deliberately does not
filter by language -- retrieval relies on the embedding model's cross-lingual matching.
"""
name = "ragcore"
@@ -32,14 +65,18 @@ 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")
response.raise_for_status()
available = True
detail = "RAGcore reachable."
except httpx.HTTPError as exc:
response = client.get("/health/ready")
body = response.json()
available = response.status_code == 200 and body.get("status") == "ok"
detail = (
"RAGcore reachable and ready."
if available
else f"RAGcore degraded: {body.get('status', 'unknown')}"
)
except (httpx.HTTPError, ValueError) as exc:
available = False
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
return KnowledgeHealth(
@@ -49,50 +86,135 @@ class RAGcoreKnowledgeProvider:
tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection,
document_count=0,
# RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit
# so the UI never turns this into the misleading claim "0 procedures".
document_count=None,
)
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
try:
with self._client() as client:
response = client.post(
"/api/v1/ask",
json={
"tenant": self._settings.ragcore_tenant,
"workspace": self._settings.ragcore_workspace,
"collection": self._settings.ragcore_collection,
"question": question,
"correlation_id": correlation_id,
},
)
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError):
return GroundedAnswer(
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
if not self._settings.ragcore_space_id:
return unavailable
answered = self._ask_via_answers(question, correlation_id)
if answered is not None:
return answered
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
# real retrieval rather than degrading straight to "unavailable". This never
# fabricates an answer to the question: it only ever shows an actually-cited
# excerpt RAGcore's own search already found, using the same extractive
# citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's
# generation step.
return self._ask_via_search_fallback(question, correlation_id, language)
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
so the caller can fall back to search -- as opposed to a real 200 response
classifying the question as insufficiently answerable, which is a genuine,
final result, not a reason to fall back."""
try:
with self._client() as client:
response = client.post(
"/v1/answers",
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
},
)
if response.status_code != 200:
return None
body = response.json()
except (httpx.HTTPError, ValueError):
return None
try:
sources = [SourceCard(**s) for s in body.get("sources", [])]
evidence_state = body.get("evidence_state", "insufficient")
if evidence_state not in ("grounded", "insufficient", "unavailable"):
evidence_state = "insufficient"
citations = {c["id"]: c for c in body.get("citations", [])}
sources = [
SourceCard(
document_id=str(citation["document_id"]),
title=citation["title"],
version=str(citation["document_version_id"]),
section=citation.get("section") or "",
excerpt=citation["excerpt"],
)
for citation in citations.values()
]
answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
return GroundedAnswer(
answer=body.get("answer", ""),
answer=body.get("answer", "") if evidence_state == "grounded" else "",
evidence_state=evidence_state,
sources=sources if evidence_state == "grounded" else [],
provider=self.name,
correlation_id=correlation_id,
)
except (TypeError, KeyError, ValueError):
return None
def _ask_via_search_fallback(
self, question: str, correlation_id: str, language: str
) -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
try:
with self._client() as client:
response = client.post(
"/v1/search",
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"max_results": 5,
},
)
if response.status_code != 200:
return unavailable
body = response.json()
except (httpx.HTTPError, ValueError):
return unavailable
try:
results = body.get("results", [])
sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
]
except (TypeError, KeyError, ValueError):
return unavailable
if not sources:
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
lead = sources[0]
answer = template.format(title=lead.title, excerpt=lead.excerpt)
return GroundedAnswer(
answer=answer,
evidence_state="grounded",
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
except (TypeError, ValueError):
return GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
+150 -50
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
@@ -25,12 +26,147 @@ def _next_public_ref(db: Session) -> str:
return f"{REF_PREFIX}-{len(existing) + 1:04d}"
def _derive_vehicle_status(body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int) -> str:
if body.damage_reported or body.technical_warning:
return "blocked"
def _derive_vehicle_status_with_reason(
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
) -> tuple[str, str, dict[str, str | int]]:
# Stable, localizable codes + params -- the backend never emits prose here. The
# frontend renders review.reasonCodes.<code> in the selected locale; the mirrored
# raw-English fallback strings live only in status_reason (shown under "Technical
# details") for backward compatibility. See docs/fleet-ops-correction/i18n-inventory.md.
if body.damage_reported and body.technical_warning:
return (
"blocked",
"returnBlockedDamageAndTechnical",
{},
)
if body.damage_reported:
return "blocked", "returnBlockedDamage", {}
if body.technical_warning:
return "blocked", "returnBlockedTechnicalWarning", {}
if new_odometer >= vehicle.next_service_km:
return "maintenance"
return "cleaning"
return (
"maintenance",
"returnServiceThresholdReached",
{"threshold_km": vehicle.next_service_km},
)
return "cleaning", "returnRoutedToCleaning", {}
_STATUS_REASON_FALLBACK_TEXT: dict[str, str] = {
"returnBlockedDamageAndTechnical": (
"Damage and a technical warning were both reported on return."
),
"returnBlockedDamage": "Damage was reported on return.",
"returnBlockedTechnicalWarning": "A technical warning was reported on return.",
"returnServiceThresholdReached": "Odometer reached the service threshold.",
"returnRoutedToCleaning": (
"No damage, technical warning or service threshold; routed to cleaning."
),
}
@dataclass
class ReturnEvaluation:
canonical_odometer_km: int
submitted_odometer_km: int
odometer_regression: bool
resulting_odometer_km: int
resulting_vehicle_status: str
status_reason: str
status_reason_code: str
status_reason_params: dict[str, str | int]
would_create_quality_issue: bool
attention_reasons: list[str]
next_booking_risk: dict | None
def evaluate_return(
db: Session, booking: Booking, vehicle: Vehicle, body: RegisterReturnRequest, *, now: datetime
) -> ReturnEvaluation:
"""Pure evaluation of what a return would do. No writes; safe to call from a
non-mutating preview endpoint. `register_vehicle_return` uses the same function so
preview and commit can never drift apart."""
odometer_regression = body.end_odometer_km < vehicle.odometer_km
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
resulting_status, status_reason_code, status_reason_params = _derive_vehicle_status_with_reason(
body, vehicle, resulting_odometer_km
)
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
attention_reasons = []
if body.damage_reported:
attention_reasons.append("damage_reported")
if body.technical_warning:
attention_reasons.append("technical_warning")
if odometer_regression:
attention_reasons.append("odometer_regression")
next_booking = db.scalar(
select(Booking)
.where(
Booking.vehicle_id == vehicle.id,
Booking.status == "reserved",
Booking.starts_at > now,
)
.order_by(Booking.starts_at.asc())
)
next_booking_risk = None
if next_booking is not None:
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
next_booking_risk = {
"booking_ref": next_booking.public_ref,
"starts_at": next_booking.starts_at.isoformat(),
"at_risk": resulting_status != "cleaning" or hours_until < 4,
}
return ReturnEvaluation(
canonical_odometer_km=vehicle.odometer_km,
submitted_odometer_km=body.end_odometer_km,
odometer_regression=odometer_regression,
resulting_odometer_km=resulting_odometer_km,
resulting_vehicle_status=resulting_status,
status_reason=status_reason,
status_reason_code=status_reason_code,
status_reason_params=status_reason_params,
would_create_quality_issue=odometer_regression,
attention_reasons=attention_reasons,
next_booking_risk=next_booking_risk,
)
def _load_active_booking_and_vehicle(
db: Session, booking_ref: str, *, lock: bool
) -> tuple[Booking, Vehicle]:
stmt = select(Booking).where(Booking.public_ref == booking_ref)
if lock:
stmt = stmt.with_for_update()
booking = db.scalar(stmt)
if booking is None:
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
vehicle_stmt = select(Vehicle).where(Vehicle.id == booking.vehicle_id)
if lock:
vehicle_stmt = vehicle_stmt.with_for_update()
vehicle = db.scalar(vehicle_stmt)
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
)
return booking, vehicle
def preview_vehicle_return(
db: Session, booking_ref: str, body: RegisterReturnRequest
) -> tuple[Booking, Vehicle, ReturnEvaluation]:
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=False)
if booking.status != "active":
raise AppError(
"INVALID_BOOKING_STATE",
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
status_code=409,
)
evaluation = evaluate_return(db, booking, vehicle, body, now=datetime.now(UTC))
return booking, vehicle, evaluation
def register_vehicle_return(
@@ -53,14 +189,7 @@ def register_vehicle_return(
)
return existing.response_status, existing.response_body
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
if booking is None:
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
)
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
# Re-check after acquiring the row lock: a concurrent identical-key request may have
# just committed while we were waiting.
@@ -79,6 +208,7 @@ def register_vehicle_return(
now = datetime.now(UTC)
correlation_id = uuid.uuid4()
evaluation = evaluate_return(db, booking, vehicle, body, now=now)
inspection = Inspection(
public_ref=_next_public_ref(db),
@@ -104,13 +234,8 @@ def register_vehicle_return(
booking.status = "returned"
booking.end_odometer_km = body.end_odometer_km
odometer_regression = body.end_odometer_km < vehicle.odometer_km
quality_issue_ref: str | None = None
canonical_odometer = vehicle.odometer_km
if not odometer_regression:
canonical_odometer = body.end_odometer_km
vehicle.odometer_km = canonical_odometer
else:
if evaluation.odometer_regression:
issue = DataQualityIssue(
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
rule_type="odometer_regression",
@@ -121,7 +246,7 @@ def register_vehicle_return(
evidence_json={
"summary": (
f"Return submitted {body.end_odometer_km} km, below canonical "
f"{vehicle.odometer_km} km."
f"{evaluation.canonical_odometer_km} km."
),
"entity_ref": vehicle.public_ref,
"related_refs": [booking.public_ref, inspection.public_ref],
@@ -133,7 +258,8 @@ def register_vehicle_return(
db.flush()
quality_issue_ref = issue.public_ref
resulting_status = _derive_vehicle_status(body, vehicle, canonical_odometer)
resulting_status = evaluation.resulting_vehicle_status
vehicle.odometer_km = evaluation.resulting_odometer_km
vehicle.operational_status = resulting_status
vehicle.version += 1
@@ -164,14 +290,6 @@ def register_vehicle_return(
},
)
attention_reasons = []
if body.damage_reported:
attention_reasons.append("damage_reported")
if body.technical_warning:
attention_reasons.append("technical_warning")
if odometer_regression:
attention_reasons.append("odometer_regression")
event = OutboxEvent(
event_id=uuid.uuid4(),
event_type="vehicle.returned.v1",
@@ -189,7 +307,7 @@ def register_vehicle_return(
"vehicle_ref": vehicle.public_ref,
"inspection_ref": inspection.public_ref,
"resulting_vehicle_status": resulting_status,
"attention_reasons": attention_reasons,
"attention_reasons": evaluation.attention_reasons,
},
"aggregate_ref": booking.public_ref,
},
@@ -199,33 +317,15 @@ def register_vehicle_return(
)
db.add(event)
next_booking = db.scalar(
select(Booking)
.where(
Booking.vehicle_id == vehicle.id,
Booking.status == "reserved",
Booking.starts_at > now,
)
.order_by(Booking.starts_at.asc())
)
next_booking_risk = None
if next_booking is not None:
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
next_booking_risk = {
"booking_ref": next_booking.public_ref,
"starts_at": next_booking.starts_at.isoformat(),
"at_risk": resulting_status != "cleaning" or hours_until < 4,
}
response_body = {
"booking_ref": booking.public_ref,
"vehicle_ref": vehicle.public_ref,
"inspection_ref": inspection.public_ref,
"resulting_vehicle_status": resulting_status,
"odometer_regression": odometer_regression,
"odometer_regression": evaluation.odometer_regression,
"quality_issue_ref": quality_issue_ref,
"workflow_event_id": str(event.event_id),
"next_booking_risk": next_booking_risk,
"next_booking_risk": evaluation.next_booking_risk,
}
db.add(
+218
View File
@@ -0,0 +1,218 @@
"""The single authoritative vehicle-status evaluator.
Used by the data-quality scanner (detection), the status-recommendation preview
endpoint, the apply endpoint, and tests -- so scan-time detection and resolve-time
recommendation can never structurally disagree (see docs/fleet-ops-correction/
vehicle-status-decision-table.md for the full decision table and rationale).
The evaluator only ever reasons from real, freshly-queried domain facts (an actually
active rental, a real service-threshold breach, a real overlapping-booking conflict) --
never from a proxy like "does some other high-severity issue happen to be open". It is
therefore also order-independent: resolving, deferring or rejecting an unrelated issue on
the same vehicle never changes what this function returns, because it never looks at
issue history, only at the vehicle's/bookings' current state.
"""
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
# Every code below is a stable, localizable identifier -- see
# frontend/src/i18n/messageCodes.ts and quality:statusRecommendation.codes.* for the
# human-language mapping in all three supported locales. The backend never emits prose.
RECOMMENDATION_CODE_ACTIVE_RENTAL = "vehicle.active_rental"
RECOMMENDATION_CODE_SERVICE_THRESHOLD = "vehicle.service_threshold_reached"
RECOMMENDATION_CODE_BOOKING_CONFLICT = "vehicle.booking_conflict"
RECOMMENDATION_CODE_RENTAL_ENDED = "vehicle.rental_ended"
RECOMMENDATION_CODE_MANUAL_REVIEW = "vehicle.manual_review_required"
RECOMMENDATION_CODE_NO_CONFLICT = "vehicle.no_conflict"
@dataclass
class VehicleStatusFacts:
active_booking_refs: list[str] = field(default_factory=list)
overlapping_booking_pairs: list[tuple[str, str]] = field(default_factory=list)
service_threshold_reached: bool = False
odometer_km: int = 0
next_service_km: int = 0
open_booking_overlap_issue_ref: str | None = None
@property
def has_active_rental(self) -> bool:
return len(self.active_booking_refs) > 0
@property
def has_booking_conflict(self) -> bool:
return (
len(self.overlapping_booking_pairs) > 0
or self.open_booking_overlap_issue_ref is not None
)
def as_dict(self) -> dict:
return {
"active_booking_refs": self.active_booking_refs,
"overlapping_booking_pairs": [list(pair) for pair in self.overlapping_booking_pairs],
"service_threshold_reached": self.service_threshold_reached,
"odometer_km": self.odometer_km,
"next_service_km": self.next_service_km,
"open_booking_overlap_issue_ref": self.open_booking_overlap_issue_ref,
}
@dataclass
class VehicleStatusRecommendation:
current_status: str
recommended_status: str | None
recommendation_code: str
safe_to_apply: bool
manual_review_required: bool
facts: VehicleStatusFacts
blocking_reasons: list[str]
def _overlapping_booking_pairs(bookings: list[Booking]) -> list[tuple[Booking, Booking]]:
ordered = sorted(bookings, key=lambda b: b.starts_at)
pairs: list[tuple[Booking, Booking]] = []
for i, first in enumerate(ordered):
for second in ordered[i + 1 :]:
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
pairs.append((first, second))
return pairs
def gather_vehicle_status_facts(
db: Session, vehicle: Vehicle, *, exclude_issue_id: uuid.UUID | None = None
) -> VehicleStatusFacts:
"""Real, freshly-queried facts only -- see module docstring. Never cached, never
derived from another issue's mere existence (only a *specific* booking_overlap
issue's presence is used, as a cross-reference to that issue's own public_ref)."""
reserved_or_active = list(
db.scalars(
select(Booking).where(
Booking.vehicle_id == vehicle.id,
Booking.status.in_(["reserved", "active"]),
)
).all()
)
active_refs = [b.public_ref for b in reserved_or_active if b.status == "active"]
overlap_pairs = [
(a.public_ref, b.public_ref) for a, b in _overlapping_booking_pairs(reserved_or_active)
]
overlap_issue_query = select(DataQualityIssue.public_ref).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.rule_type == "booking_overlap",
)
if exclude_issue_id is not None:
overlap_issue_query = overlap_issue_query.where(DataQualityIssue.id != exclude_issue_id)
open_overlap_ref = db.scalar(overlap_issue_query)
return VehicleStatusFacts(
active_booking_refs=active_refs,
overlapping_booking_pairs=overlap_pairs,
service_threshold_reached=vehicle.odometer_km >= vehicle.next_service_km,
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
open_booking_overlap_issue_ref=open_overlap_ref,
)
def compute_recommendation_token(vehicle: Vehicle, facts: VehicleStatusFacts) -> str:
"""A short digest of exactly the facts the recommendation was based on, plus the
vehicle's optimistic-lock version. The apply endpoint recomputes this from fresh
facts and rejects the request if it doesn't match the token the client last saw --
the frontend must never assume a previously-shown preview is still valid without the
server re-checking it (see docs/fleet-ops-correction/current-gap-audit.md §8F)."""
payload = {"version": vehicle.version, "status": vehicle.operational_status, **facts.as_dict()}
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
return digest[:16]
def evaluate_vehicle_status(
vehicle: Vehicle, facts: VehicleStatusFacts
) -> VehicleStatusRecommendation:
"""Pure decision logic over already-gathered facts -- see
docs/fleet-ops-correction/vehicle-status-decision-table.md. Never mutates anything,
never queries the database itself (call gather_vehicle_status_facts first), so it is
trivial to unit-test every branch in isolation."""
current = vehicle.operational_status
blocking_reasons: list[str] = []
if facts.service_threshold_reached:
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
if facts.has_booking_conflict:
blocking_reasons.append(RECOMMENDATION_CODE_BOOKING_CONFLICT)
if current == "maintenance" and RECOMMENDATION_CODE_SERVICE_THRESHOLD not in blocking_reasons:
# Already being in maintenance is itself a real blocking fact -- an active
# booking never overrides it. This is exactly the forbidden shortcut this
# evaluator must never take (maintenance + active booking -> auto "rented").
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
def result(
recommended: str | None, code: str, *, safe: bool, manual: bool
) -> VehicleStatusRecommendation:
return VehicleStatusRecommendation(
current_status=current,
recommended_status=recommended,
recommendation_code=code,
safe_to_apply=safe,
manual_review_required=manual,
facts=facts,
blocking_reasons=blocking_reasons,
)
if facts.has_active_rental and not blocking_reasons:
if current == "rented":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False
)
if facts.has_active_rental and blocking_reasons:
# Explicitly forbidden shortcut this evaluator must never take: an active
# booking is not proof the vehicle should be "rented" when a real blocking
# condition also exists (e.g. maintenance-due, or a genuine booking conflict).
# This is a real contradiction in the underlying facts, not something safe to
# resolve automatically.
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
if facts.service_threshold_reached:
if current == "maintenance":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False
)
if facts.has_booking_conflict:
if current == "blocked":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False
)
# No active rental, no maintenance need, no booking conflict.
if current in ("available", "cleaning", "blocked"):
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
if current == "rented":
return result(
"available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False
)
if current == "maintenance":
# No positive fact confirms maintenance is actually finished (no completed
# service record is tracked here) -- clearing "maintenance" without such a
# fact would be exactly the kind of unsafe shortcut this evaluator forbids.
# Releasing a vehicle from maintenance remains an explicit, manual decision.
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
+74
View File
@@ -1,3 +1,10 @@
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.booking import Booking
from app.models.vehicle import Vehicle
def test_demo_login_is_audited(ops_client):
response = ops_client.get("/api/v1/audit", params={"action": "demo_login"})
assert response.status_code == 200
@@ -9,3 +16,70 @@ def test_demo_login_is_audited(ops_client):
def test_audit_requires_authentication(client):
response = client.get("/api/v1/audit")
assert response.status_code == 401
def test_audit_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/audit")
assert response.status_code == 403
def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client):
response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25})
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert body["page"] == 1
assert body["page_size"] == 25
assert body["total"] >= len(body["items"])
assert body["total_pages"] >= 1
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
db = SessionLocal()
try:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
booking = db.scalar(
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
)
booking.status = "active"
booking.start_odometer_km = start_odometer_km
booking.end_odometer_km = None
db.commit()
return booking.public_ref
finally:
db.close()
def test_return_registered_audit_event_exposes_before_after_and_link(ops_client):
booking_ref = _activate_booking("MO-015", start_odometer_km=17000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-015").json()
ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json={
"end_odometer_km": vehicle_before["odometer_km"] + 10,
"fuel_level_percent": 50,
"cleanliness_ok": True,
"damage_reported": False,
"technical_warning": False,
},
headers={"Idempotency-Key": "test-audit-before-after-001"},
)
key = "test-audit-before-after-001"
events = ops_client.get(
"/api/v1/audit", params={"action": "return_registered"}
).json()
event = next(e for e in events if e["metadata"]["idempotency_key"] == key)
assert event["before"] == {"status": "active"}
assert event["after"]["status"] == "returned"
assert event["entity_ref"] == booking_ref
assert event["entity_link"] == f"/bookings/{booking_ref}"
vehicle_events = ops_client.get(
"/api/v1/audit",
params={"action": "vehicle_status_changed", "correlation_id": event["correlation_id"]},
).json()
assert len(vehicle_events) == 1
assert vehicle_events[0]["entity_ref"] == "MO-015"
assert vehicle_events[0]["entity_link"] == "/vehicles/MO-015"
assert vehicle_events[0]["before"]["odometer_km"] == vehicle_before["odometer_km"]
+48 -1
View File
@@ -17,4 +17,51 @@ 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):
response = client.get("/api/v1/demo/session")
assert response.status_code == 401
def test_session_endpoint_confirms_logged_in_user(ops_client):
response = ops_client.get("/api/v1/demo/session")
assert response.status_code == 200
body = response.json()
assert body["role"] == "operations_manager"
assert body["public_ref"] == "USR-OPS"
def test_logout_invalidates_session(ops_client):
confirmed = ops_client.get("/api/v1/demo/session")
assert confirmed.status_code == 200
logout = ops_client.post("/api/v1/demo/logout")
assert logout.status_code == 200
after = ops_client.get("/api/v1/demo/session")
assert after.status_code == 401
def test_logout_without_a_session_is_safe(client):
response = client.post("/api/v1/demo/logout")
assert response.status_code == 200
+16
View File
@@ -24,6 +24,22 @@ def test_dashboard_attention_items_link_to_records(ops_client):
assert item["severity"] in ("low", "medium", "high")
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
The API must never emit prose here -- only stable signal codes + params, exactly
like the data-quality issue detail page, for the frontend to localize."""
response = ops_client.get("/api/v1/dashboard")
body = response.json()
assert len(body["attention_items"]) > 0
for item in body["attention_items"]:
assert "detail" not in item
assert len(item["evidence_signals"]) > 0
for signal in item["evidence_signals"]:
assert signal["code"]
assert signal["code"] != "Synthetic deterministic seed issue"
def test_dashboard_recent_automation_capped_at_five(ops_client):
response = ops_client.get("/api/v1/dashboard")
body = response.json()
+444
View File
@@ -1,3 +1,26 @@
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.booking import Booking
from app.models.vehicle import Vehicle
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
db = SessionLocal()
try:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
booking = db.scalar(
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
)
booking.status = "active"
booking.start_odometer_km = start_odometer_km
booking.end_odometer_km = None
db.commit()
return booking.public_ref
finally:
db.close()
def test_list_includes_all_five_rule_types(ops_client):
response = ops_client.get("/api/v1/data-quality/issues")
assert response.status_code == 200
@@ -25,6 +48,38 @@ def test_scan_requires_operations_manager(employee_client):
assert response.status_code == 403
def test_list_issues_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/data-quality/issues")
assert response.status_code == 403
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
response = ops_client.get(
"/api/v1/data-quality/issues",
params={"severity": "high", "page": 1, "page_size": 25},
)
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert all(issue["severity"] == "high" for issue in body["items"])
assert body["total"] >= len(body["items"])
def test_get_issue_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
assert response.status_code == 403
def test_defer_requires_operations_manager(employee_client):
response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/defer")
assert response.status_code == 403
def test_reject_requires_operations_manager(employee_client):
response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/reject")
assert response.status_code == 403
def test_s2_duplicate_customer_issue_detail(ops_client):
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
assert response.status_code == 200
@@ -104,3 +159,392 @@ def test_merge_customers_s2_scenario_rewires_and_audits(ops_client):
json={"survivor_ref": "CUS-0012"},
)
assert replay.status_code == 409
def test_overlap_related_snapshots_are_typed_as_bookings_not_vehicles(ops_client):
body = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP").json()
assert len(body["related_snapshots"]) == 2
for snap in body["related_snapshots"]:
assert snap["entity_type"] == "booking"
assert snap["public_ref"] in {"BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B"}
assert "starts_at" in snap and "ends_at" in snap
def _first_open(ops_client, rule_type: str) -> dict:
issues = ops_client.get(
"/api/v1/data-quality/issues", params={"rule_type": rule_type, "status": "open"}
).json()
assert issues, f"expected at least one open {rule_type} issue"
return issues[0]
def test_provide_fields_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-ATTENTION/provide-fields",
json={"fields": {"registration_number": "TST-001"}},
)
assert response.status_code == 403
def test_provide_fields_rejects_wrong_rule_type(ops_client):
response = ops_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/provide-fields",
json={"fields": {"make": "Test"}},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "NOT_A_MISSING_FIELD_ISSUE"
def test_provide_fields_rejects_disallowed_field(ops_client):
target = _first_open(ops_client, "missing_required_field")
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
disallowed = "city" if detail["entity_type"] == "customer" else "next_service_km"
response = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/provide-fields",
json={"fields": {disallowed: "anything"}},
)
assert response.status_code == 422
assert response.json()["error"]["code"] == "INVALID_FIELD"
def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"rule_type": "missing_required_field", "status": "open"},
).json()
target = next(i for i in issues if i["entity_type"] == "vehicle")
response = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/provide-fields",
json={
"fields": {
"registration_number": "TST-999",
"make": "TestMake",
"model": "TestModel",
"location": "Depot",
}
},
)
assert response.status_code == 200
assert response.json()["status"] == "resolved"
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
assert vehicle["registration_number"] == "TST-999"
def test_vehicle_entity_snapshot_includes_registration_number(ops_client):
"""The snapshot used to omit registration_number entirely, so the 'provide missing
fields' form always showed it blank -- even for a vehicle whose plate was actually
on file, and even when a *different* field was the genuinely missing one."""
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"rule_type": "missing_required_field", "status": "open"},
).json()
target = next(i for i in issues if i["entity_type"] == "vehicle")
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
assert detail["entity_snapshot"]["registration_number"] == vehicle["registration_number"]
def test_resolve_overlap_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
json={"booking_ref": "BK-DEMO-OVERLAP-A"},
)
assert response.status_code == 403
def test_resolve_overlap_rejects_unrelated_booking(ops_client):
response = ops_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
json={"booking_ref": "BK-DEMO-RETURN"},
)
assert response.status_code == 422
assert response.json()["error"]["code"] == "INVALID_BOOKING_REFERENCE"
def test_resolve_overlap_blocks_one_booking_and_resolves(ops_client):
response = ops_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
json={"booking_ref": "BK-DEMO-OVERLAP-A", "note": "Blocked the later commitment."},
)
assert response.status_code == 200
assert response.json()["status"] == "resolved"
booking = ops_client.get("/api/v1/bookings/BK-DEMO-OVERLAP-A").json()
assert booking["status"] == "blocked"
def test_status_recommendation_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/status-recommendation"
)
assert response.status_code == 403
def test_apply_recommended_status_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/apply-recommended-status",
json={"recommendation_token": "irrelevant"},
)
assert response.status_code == 403
def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
target = _first_open(ops_client, "vehicle_status_conflict")
vehicle_before = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
preview_response = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
)
assert preview_response.status_code == 200
preview = preview_response.json()
assert preview["current_status"] == vehicle_before["operational_status"]
assert preview["recommendation_token"]
assert "facts" in preview
# Calling preview again (as the UI would on every open) must still not mutate.
ops_client.post(f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation")
issue_after = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
vehicle_after = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
assert issue_after["status"] == "open"
assert vehicle_after["operational_status"] == vehicle_before["operational_status"]
def test_apply_recommended_status_resolves_conflict(ops_client):
target = _first_open(ops_client, "vehicle_status_conflict")
preview = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
).json()
assert preview["safe_to_apply"] is True
assert preview["manual_review_required"] is False
response = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
json={"recommendation_token": preview["recommendation_token"]},
)
assert response.status_code == 200
body = response.json()
assert body["issue"]["status"] == "resolved"
assert body["applied_status"] == preview["recommended_status"]
assert body["reason_code"] == preview["recommendation_code"]
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
assert vehicle["operational_status"] == body["applied_status"]
def test_apply_recommended_status_rejects_stale_token(ops_client):
target = _first_open(ops_client, "vehicle_status_conflict")
response = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
json={"recommendation_token": "not-a-real-token"},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "RECOMMENDATION_STALE"
def test_resolve_odometer_regression_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
json={"decision": "retain_canonical"},
)
assert response.status_code == 403
def test_resolve_odometer_regression_correction_below_canonical_is_rejected_then_retained(
ops_client,
):
target = _first_open(ops_client, "odometer_regression")
vehicle_before = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
too_low = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/resolve-odometer-regression",
json={
"decision": "correct_reading",
"booking_ref": "BK-DEMO-RETURN",
"corrected_odometer_km": max(vehicle_before["odometer_km"] - 100, 0),
},
)
assert too_low.status_code == 422
assert too_low.json()["error"]["code"] in (
"CORRECTION_BELOW_CANONICAL",
"INVALID_BOOKING_REFERENCE",
)
# The rejected attempt must not have resolved or mutated anything.
still_open = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
assert still_open["status"] == "open"
retained = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/resolve-odometer-regression",
json={"decision": "retain_canonical", "note": "Submitted reading treated as erroneous."},
)
assert retained.status_code == 200
assert retained.json()["status"] == "resolved"
vehicle_after = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"]
def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_client):
# The seeded odometer_regression issues carry no related booking (CSV-only rows).
# Create a fresh one with a real related booking via a live regression return, so
# the "correct_reading" path has an actual booking_ref to target.
booking_ref = _activate_booking("MO-018", start_odometer_km=12000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-018").json()
low_reading = vehicle_before["odometer_km"] - 200
returned = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json={
"end_odometer_km": low_reading,
"fuel_level_percent": 50,
"cleanliness_ok": True,
"damage_reported": False,
"technical_warning": False,
},
headers={"Idempotency-Key": "test-dq-odometer-correct-001"},
)
assert returned.status_code == 201
issue_ref = returned.json()["quality_issue_ref"]
assert issue_ref is not None
corrected = vehicle_before["odometer_km"] + 500
response = ops_client.post(
f"/api/v1/data-quality/issues/{issue_ref}/resolve-odometer-regression",
json={
"decision": "correct_reading",
"booking_ref": booking_ref,
"corrected_odometer_km": corrected,
},
)
assert response.status_code == 200
assert response.json()["status"] == "resolved"
vehicle = ops_client.get("/api/v1/vehicles/MO-018").json()
assert vehicle["odometer_km"] == corrected
booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
assert booking["end_odometer_km"] == corrected
def test_manual_scan_records_audit_event(ops_client):
scan = ops_client.post("/api/v1/data-quality/scan")
assert scan.status_code == 200
events = ops_client.get(
"/api/v1/audit", params={"action": "data_quality_scan_run"}
).json()
assert len(events) >= 1
assert "created" in events[0]["metadata"]
def _reset_demo(ops_client) -> None:
# /api/v1/demo/reset deletes the session cookie (the reset recreates the users
# table, so the old session's user id no longer exists) -- the caller must log back
# in before making any further authenticated call with the same client.
response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 200, response.text
login_response = ops_client.post(
"/api/v1/demo/login", json={"role": "operations_manager"}
)
assert login_response.status_code == 200, login_response.text
def _resolve_overlap_issue(ops_client, *, booking_to_block: str) -> None:
overlap = _first_open(ops_client, "booking_overlap")
response = ops_client.post(
f"/api/v1/data-quality/issues/{overlap['public_ref']}/resolve-overlap",
json={"booking_ref": booking_to_block},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "resolved"
def _first_open_for_vehicle(ops_client, rule_type: str, vehicle_ref: str) -> dict:
issues = ops_client.get(
"/api/v1/data-quality/issues", params={"rule_type": rule_type, "status": "open"}
).json()
match = next((i for i in issues if i["entity_ref"] == vehicle_ref), None)
assert match, f"expected an open {rule_type} issue for {vehicle_ref}"
return match
def _apply_status_recommendation(ops_client, public_ref: str) -> dict:
preview = ops_client.post(
f"/api/v1/data-quality/issues/{public_ref}/status-recommendation"
).json()
apply_response = ops_client.post(
f"/api/v1/data-quality/issues/{public_ref}/apply-recommended-status",
json={"recommendation_token": preview["recommendation_token"]},
)
assert apply_response.status_code == 200, apply_response.text
return apply_response.json()
def test_mo_016_status_conflict_recommendation_is_order_independent(ops_client):
# MO-016 carries both a booking_overlap (DQ-DEMO-OVERLAP) and a vehicle_status_conflict
# (DQ-DEMO-STATUS) issue at once. Order independence does NOT mean "the same final
# vehicle status regardless of order" -- resolving the overlap first genuinely removes
# the conflict, so there is correctly nothing left to apply. What must hold in either
# order: the recommendation always reflects the real, current facts (never a stale
# "was some other issue open" proxy), and nothing unsafe is ever applied (never
# "rented", never a status change once the underlying condition has already resolved
# itself). See docs/fleet-ops-correction/current-gap-audit.md §6-7 and
# vehicle-status-decision-table.md.
# Order A: resolve the booking overlap first. The status-conflict issue's own
# recommendation must now correctly report that the conflict is gone -- nothing unsafe
# should be auto-applied, and the vehicle (never touched) stays exactly as it was.
_reset_demo(ops_client)
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
status_issue_a = _first_open_for_vehicle(ops_client, "vehicle_status_conflict", "MO-016")
preview_a = ops_client.post(
f"/api/v1/data-quality/issues/{status_issue_a['public_ref']}/status-recommendation"
).json()
assert preview_a["recommendation_code"] == "vehicle.no_conflict"
assert preview_a["recommended_status"] is None
assert preview_a["safe_to_apply"] is False
vehicle_a = ops_client.get("/api/v1/vehicles/MO-016").json()
assert vehicle_a["operational_status"] == "available"
# Order B: resolve the status conflict first, while the overlap is still open -- the
# conflict genuinely still exists, so the evaluator must still detect it and safely
# resolve it (never "rented").
_reset_demo(ops_client)
status_issue_b = _first_open_for_vehicle(ops_client, "vehicle_status_conflict", "MO-016")
result_b = _apply_status_recommendation(ops_client, status_issue_b["public_ref"])
assert result_b["applied_status"] != "rented"
vehicle_b_mid = ops_client.get("/api/v1/vehicles/MO-016").json()
assert vehicle_b_mid["operational_status"] == result_b["applied_status"]
# Resolving the now-redundant overlap afterwards must not itself change the vehicle's
# status as a side effect.
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
vehicle_b = ops_client.get("/api/v1/vehicles/MO-016").json()
assert vehicle_b["operational_status"] == result_b["applied_status"]
assert vehicle_b["operational_status"] != "rented"
_reset_demo(ops_client)
def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
# Reject an open vehicle_status_conflict issue without changing the vehicle, so the
# next scan re-detects the same unresolved condition -- it must not silently vanish
# or reopen the old row, but the new issue should stay linked to the rejection.
target = _first_open(ops_client, "vehicle_status_conflict")
rejected = ops_client.post(f"/api/v1/data-quality/issues/{target['public_ref']}/reject")
assert rejected.status_code == 200
rescan = ops_client.post("/api/v1/data-quality/scan")
assert rescan.status_code == 200
assert rescan.json()["created"].get("vehicle_status_conflict", 0) >= 1
reopened = ops_client.get(
"/api/v1/data-quality/issues",
params={"rule_type": "vehicle_status_conflict", "status": "open"},
).json()
match = next(
(i for i in reopened if i["evidence"].get("reopened_from") == target["public_ref"]), None
)
assert match is not None, "expected a new issue linked back to the rejected one"
assert match["evidence"]["previous_decision"] == "rejected"
+54
View File
@@ -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"
+116 -8
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from sqlalchemy import select
@@ -67,7 +67,7 @@ def test_deliver_one_success(monkeypatch):
event_id = _make_pending_event("MO-002")
dispatcher._claim_due_events()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
@@ -81,13 +81,14 @@ def test_deliver_one_success(monkeypatch):
assert event.attempts == 1
assert event.external_run_id == str(event_id)
assert event.last_error is None
assert event.last_error_code is None
def test_deliver_one_failure_schedules_retry(monkeypatch):
event_id = _make_pending_event("MO-003")
dispatcher._claim_due_events()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
raise dispatcher.httpx.ConnectError("simulated connection failure")
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
@@ -98,13 +99,41 @@ def test_deliver_one_failure_schedules_retry(monkeypatch):
assert event.attempts == 1
assert event.next_attempt_at is not None
assert "simulated connection failure" in event.last_error
assert event.last_error_code == "connectionError"
def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch):
# Reproduces a real failure mode found while live-validating the n8n webhook auth
# fix: a workflow that errors internally before its "Respond to Webhook" node runs
# can still answer with a 2xx status and an empty body. response.json() on that body
# raises json.JSONDecodeError -- this must be treated as a retryable failure, not an
# unhandled exception that leaves the event stuck in "delivering" forever.
event_id = _make_pending_event("MO-005")
dispatcher._claim_due_events()
def fake_post(url, json, headers, timeout):
def raise_json_error():
raise ValueError("Expecting value: line 1 column 1 (char 0)")
return SimpleNamespace(
raise_for_status=lambda: None, json=raise_json_error, status_code=200
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
dispatcher._deliver_one(event_id)
event = _get_event(event_id)
assert event.delivery_status == "pending"
assert event.attempts == 1
assert event.next_attempt_at is not None
assert event.last_error_code == "malformedResponse"
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
event_id = _make_pending_event("MO-004")
settings = get_settings()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
raise dispatcher.httpx.ConnectError("still down")
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
@@ -147,7 +176,7 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
finally:
db.close()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
raise AssertionError("must not attempt delivery with a malformed payload")
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
@@ -159,12 +188,91 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
assert event.delivery_status in ("pending", "failed")
assert event.attempts == 1
assert "Malformed outbox payload" in event.last_error
assert event.last_error_code == "malformedPayload"
def test_run_dispatch_cycle_end_to_end(monkeypatch):
event_id = _make_pending_event("MO-005")
def test_claim_sets_a_lease_deadline():
event_id = _make_pending_event("MO-006")
settings = get_settings()
before = datetime.now(UTC)
dispatcher._claim_due_events()
def fake_post(url, json, timeout):
event = _get_event(event_id)
assert event.delivery_status == "delivering"
assert event.next_attempt_at is not None
lease = settings.n8n_delivery_lease_seconds
assert event.next_attempt_at > before + timedelta(seconds=lease - 5)
def test_reclaim_ignores_an_active_unexpired_lease():
# A worker that is still within its lease window must not be disturbed -- this is
# what prevents double delivery of an event another (still-alive) worker is handling.
event_id = _make_pending_event("MO-007")
dispatcher._claim_due_events()
reclaimed = dispatcher._reclaim_stale_deliveries()
assert reclaimed == 0
assert _get_event(event_id).delivery_status == "delivering"
def test_reclaim_recovers_an_expired_lease_and_preserves_attempts(monkeypatch):
# Simulates a process crash: the row was claimed (delivering) but no outcome was ever
# recorded, and its lease has since expired.
event_id = _make_pending_event("MO-008")
dispatcher._claim_due_events()
db = SessionLocal()
try:
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
event.attempts = 2
event.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1)
db.commit()
finally:
db.close()
reclaimed = dispatcher._reclaim_stale_deliveries()
assert reclaimed == 1
event = _get_event(event_id)
assert event.delivery_status == "pending"
assert event.next_attempt_at is None
assert event.attempts == 2
assert "stale" in event.last_error.lower()
assert event.last_error_code == "staleLeaseRecovered"
# The reclaimed event is now a normal pending event, immediately claimable again.
claimed = dispatcher._claim_due_events()
assert event_id in claimed
def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
event_id = _make_pending_event("MO-009")
dispatcher._claim_due_events()
db = SessionLocal()
try:
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
event.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1)
db.commit()
finally:
db.close()
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
processed = dispatcher.run_dispatch_cycle()
assert processed >= 1
assert _get_event(event_id).delivery_status == "succeeded"
def test_run_dispatch_cycle_end_to_end(monkeypatch):
event_id = _make_pending_event("MO-005")
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
+190
View File
@@ -0,0 +1,190 @@
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.outbox import OutboxEvent
from app.seed_loader import reset_and_seed
def _reseed() -> None:
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
The suite shares one session-scoped database and earlier files legitimately mutate
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
rather than depend on file ordering.
"""
db = SessionLocal()
try:
reset_and_seed(db)
finally:
db.close()
def test_integration_status_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/integrations/status")
assert response.status_code == 403
def test_integration_status_requires_authentication(client):
response = client.get("/api/v1/integrations/status")
assert response.status_code == 401
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
_reseed()
response = ops_client.get("/api/v1/integrations/status")
assert response.status_code == 200
body = response.json()
n8n = body["n8n"]
assert n8n["dispatch_enabled"] is True
assert n8n["succeeded"] >= 1
# The seeded failure stays visible and counted...
assert n8n["failed"] >= 1
assert n8n["demo_scenario_failed"] == 1
# ...but it is a prepared prop, so it is not an unexpected failure and must not
# move the integration off "operational". A staged failure that degrades the
# health badge tells a viewer something untrue about the automation.
assert n8n["unexpected_failed"] == 0
assert n8n["state"] == "operational"
assert n8n["latest_success_at"] is not None
# "latest failure" is a health signal, so the staged one never sets it; it is
# reported separately instead.
assert n8n["latest_failure_at"] is None
assert n8n["latest_demo_scenario_at"] is not None
mcp_hub = body["mcp_hub"]
assert mcp_hub["registration_enabled"] is False
assert mcp_hub["state"] == "not_configured"
def test_a_real_failure_still_degrades_the_integration(ops_client):
"""The demo carve-out must be narrow: a failure that is not the prepared scenario
still degrades n8n, otherwise this change would hide real breakage."""
_reseed()
db = SessionLocal()
try:
real_failure = db.scalar(
select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1)
)
assert real_failure is not None
restore = (real_failure.delivery_status, real_failure.last_error_code)
real_failure.delivery_status = "failed"
real_failure.last_error_code = "connectionError"
db.commit()
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert n8n["unexpected_failed"] == 1
assert n8n["state"] == "degraded"
assert n8n["latest_failure_at"] is not None
finally:
real_failure.delivery_status, real_failure.last_error_code = restore
db.commit()
db.close()
def test_prepared_demo_failure_is_reset_back_by_a_demo_reset(ops_client):
"""A demo reset must recreate the intended 19 succeeded + 1 prepared failure, so the
scenario can be shown again after it has been retried away."""
_reseed()
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert n8n["succeeded"] == 19
assert n8n["failed"] == 1
assert n8n["demo_scenario_failed"] == 1
assert n8n["unexpected_failed"] == 0
assert n8n["state"] == "operational"
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
for run in failed:
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
assert retried.status_code == 200
response = ops_client.get("/api/v1/integrations/status")
body = response.json()["n8n"]
assert body["failed"] == 0
assert body["state"] == "operational"
def test_integration_status_lists_all_four_canonical_workflows(ops_client):
body = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert body["expected_workflow_count"] == 4
names = {w["name"] for w in body["workflows"]}
assert names == {
"Fleet Ops — Vehicle Return Orchestration",
"Fleet Ops — Scheduled Data Quality Scan",
"Fleet Ops — RAGcore Procedure Sync",
"Fleet Ops — Workflow Error Handler",
}
ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"])
# All 4 canonical workflows are built (all 6 nodes saved live). This fresh test run
# has reported no real sync result yet, so -- like the other three workflows before
# their own first real signal -- there is no run evidence yet either.
assert ragcore_sync["built"] is True
assert ragcore_sync["last_seen_at"] is None
def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client):
from app.core.config import get_settings
settings = get_settings()
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
scan_workflow = next(
w for w in before["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
)
assert scan_workflow["last_seen_at"] is None
scan = client.post(
"/api/v1/integrations/n8n/scheduled-scan",
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert scan.status_code == 200
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
scan_workflow = next(
w for w in after["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
)
assert scan_workflow["last_seen_at"] is not None
assert after["known_workflow_count"] > before["known_workflow_count"]
def test_integration_status_reflects_error_handler_registrations(client, ops_client):
import uuid
from app.core.config import get_settings
settings = get_settings()
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
execution_id = str(uuid.uuid4())
report = client.post(
"/api/v1/integrations/n8n/workflow-error",
json={
"workflow_id": "mobilityops-return-processing",
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
"execution_id": execution_id,
"failed_at": "2026-08-04T10:15:00Z",
"error_category": "httpError",
"error_summary": "Simulated failure for status test",
"trigger_context": "webhook",
"attempt": 1,
},
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert report.status_code == 200
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert (
after["error_handler"]["total_failures_registered"]
== before["error_handler"]["total_failures_registered"] + 1
)
assert after["error_handler"]["latest_failure_workflow"] == (
"Fleet Ops — Vehicle Return Orchestration"
)
handler_workflow = next(
w for w in after["workflows"] if w["name"].endswith("Workflow Error Handler")
)
assert handler_workflow["last_seen_at"] is not None
+182
View File
@@ -82,3 +82,185 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
).json()
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
assert len(matching) == 1
def test_scheduled_scan_rejects_wrong_service_token(client):
response = client.post(
"/api/v1/integrations/n8n/scheduled-scan",
headers={"X-Service-Token": "wrong-token"},
)
assert response.status_code == 401
def test_scheduled_scan_requires_service_token_header(client):
response = client.post("/api/v1/integrations/n8n/scheduled-scan")
assert response.status_code == 422
def test_scheduled_scan_runs_and_returns_counts_by_rule(client, ops_client):
settings = get_settings()
response = client.post(
"/api/v1/integrations/n8n/scheduled-scan",
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert response.status_code == 200
assert response.json() == {"created": {}} # already-seeded conditions, nothing new
audit_events = ops_client.get(
"/api/v1/audit", params={"action": "data_quality_scan_run"}
).json()
service_triggered = [e for e in audit_events if e["actor_type"] == "service"]
assert len(service_triggered) >= 1
assert service_triggered[0]["actor_label"] == "n8n scheduled scan"
def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
settings = get_settings()
headers = {"X-Service-Token": settings.n8n_callback_token}
first = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
second = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
assert first.status_code == 200
assert second.status_code == 200
assert second.json()["created"] == {}
def _workflow_error_body(execution_id: str, **overrides):
body = {
"workflow_id": "mobilityops-return-processing",
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
"execution_id": execution_id,
"failed_at": "2026-08-04T10:15:00Z",
"error_category": "httpError",
"error_summary": "Callback request failed with status 500",
"trigger_context": "webhook",
"correlation_id": None,
"attempt": 1,
"retry_action": "n8n will retry automatically",
}
body.update(overrides)
return body
def test_workflow_error_rejects_wrong_service_token(client):
response = client.post(
"/api/v1/integrations/n8n/workflow-error",
json=_workflow_error_body(str(uuid.uuid4())),
headers={"X-Service-Token": "wrong-token"},
)
assert response.status_code == 401
def test_workflow_error_rejects_unknown_category(client):
settings = get_settings()
response = client.post(
"/api/v1/integrations/n8n/workflow-error",
json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"),
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert response.status_code == 422
def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client):
settings = get_settings()
headers = {"X-Service-Token": settings.n8n_callback_token}
execution_id = str(uuid.uuid4())
body = _workflow_error_body(execution_id)
first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
assert first.status_code == 200
assert first.json()["status"] == "registered"
assert second.status_code == 200
assert second.json()["status"] == "already_registered"
audit_events = ops_client.get(
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
).json()
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
assert len(matching) == 1
assert matching[0]["after"]["error_category"] == "httpError"
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
def test_workflow_error_bounds_summary_length(client):
settings = get_settings()
response = client.post(
"/api/v1/integrations/n8n/workflow-error",
json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501),
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert response.status_code == 422
def test_procedures_rejects_wrong_service_token(client):
response = client.get(
"/api/v1/integrations/n8n/procedures", headers={"X-Service-Token": "wrong-token"}
)
assert response.status_code == 401
def test_procedures_lists_every_language_with_stable_ids(client):
settings = get_settings()
response = client.get(
"/api/v1/integrations/n8n/procedures",
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert response.status_code == 200
documents = response.json()["documents"]
assert len(documents) > 0
assert {d["language"] for d in documents} == {"en-GB", "nl-BE", "fr-BE"}
checkout_docs = [d for d in documents if d["document_id"] == "vehicle-checkout-procedure"]
assert len(checkout_docs) == 3 # one per language
assert all(d["content"] and d["content_hash"] for d in checkout_docs)
# Same document_id, different language, must not collide on id.
assert len({d["id"] for d in checkout_docs}) == 3
second_response = client.get(
"/api/v1/integrations/n8n/procedures",
headers={"X-Service-Token": settings.n8n_callback_token},
)
second_ids = {d["id"] for d in second_response.json()["documents"]}
assert second_ids == {d["id"] for d in documents} # ids are stable across requests
def test_procedures_sync_result_rejects_wrong_service_token(client):
response = client.post(
"/api/v1/integrations/n8n/procedures-sync-result",
json={"execution_id": str(uuid.uuid4()), "synced": 5, "failed": 0},
headers={"X-Service-Token": "wrong-token"},
)
assert response.status_code == 401
def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
settings = get_settings()
headers = {"X-Service-Token": settings.n8n_callback_token}
execution_id = str(uuid.uuid4())
body = {"execution_id": execution_id, "synced": 33, "failed": 1}
first = client.post(
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
)
second = client.post(
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
)
assert first.status_code == 200
assert first.json()["status"] == "registered"
assert second.status_code == 200
assert second.json()["status"] == "already_registered"
audit_events = ops_client.get(
"/api/v1/audit", params={"action": "n8n_procedures_synced"}
).json()
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
assert len(matching) == 1
assert matching[0]["after"] == {"synced": 33, "failed": 1}
# The workflow's own callback is its evidence -- same pattern the scheduled scan and
# error handler already use -- so this real report must now show up as run evidence
# in the integration status, not stay hardcoded to "no evidence yet".
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"])
assert ragcore_sync["last_seen_at"] is not None
+373 -5
View File
@@ -1,11 +1,51 @@
from __future__ import annotations
import re
from pathlib import Path
import httpx
from app.core.config import get_settings
from app.services.knowledge.demo import DemoKnowledgeProvider
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
def test_brief_exact_damage_question_in_all_three_languages():
# The exact validation questions from docs/fleet-ops-correction/current-gap-audit.md
# -- each must ground on the damage procedure as its *primary* (top-ranked) source,
# not merely appear somewhere in the top-3, and the source/version/section/excerpt
# must all come from that same-language document (never an English fallback).
provider = DemoKnowledgeProvider()
cases = {
"nl-BE": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
"en-GB": "What should I do when a vehicle returns with damage?",
"fr-BE": "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
}
for language, question in cases.items():
answer = provider.ask(question, f"test-brief-{language}", language)
assert answer.evidence_state == "grounded", language
assert answer.sources, language
assert answer.sources[0].document_id == "damage-procedure", (
f"{language}: expected the damage procedure as the primary source, "
f"got {answer.sources[0].document_id!r}"
)
assert answer.answer
assert answer.sources[0].excerpt
def test_knowledge_procedures_never_mention_mobilityops_or_poc():
# Section 2 of docs/fleet-ops-correction/current-gap-audit.md: the visible brand
# name is exactly "Fleet Ops", and "PoC" must never appear in visible content --
# including the demo knowledge base, not just the frontend.
procedures_dir = Path(get_settings().knowledge_dir)
offenders = []
for path in sorted(procedures_dir.glob("*/*.md")):
text = path.read_text(encoding="utf-8")
if "MobilityOps" in text or re.search(r"\bPoC\b", text):
offenders.append(str(path))
assert offenders == []
def test_s6_damage_question_is_grounded_with_expected_sources():
provider = DemoKnowledgeProvider()
answer = provider.ask(
@@ -32,7 +72,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):
@@ -73,12 +158,295 @@ def test_knowledge_status_endpoint(ops_client):
assert response.json()["provider"] == "demo"
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
def fake_client(*args, **kwargs):
raise httpx.ConnectError("no ragcore in this environment")
class _FakeResponse:
def __init__(self, status_code: int, body: dict):
self.status_code = status_code
self._body = body
def json(self) -> dict:
return self._body
class _FakeClient:
def __init__(self, get_response=None, post_response=None, post_responses=None, raise_on=None):
self._get_response = get_response
self._post_response = post_response
# Maps a path (e.g. "/v1/search") to its own response, for tests that need
# /v1/answers and /v1/search to behave differently in the same call. Falls back
# to the single post_response when a path has no specific entry, so every
# existing single-endpoint test keeps working unchanged.
self._post_responses = post_responses or {}
self._raise_on = raise_on
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, path):
if self._raise_on == "get":
raise httpx.ConnectError("no ragcore in this environment")
return self._get_response
def post(self, path, json=None):
if self._raise_on == "post":
raise httpx.ConnectError("no ragcore in this environment")
return self._post_responses.get(path, self._post_response)
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider, "_client", fake_client)
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="post"))
answer = provider.ask("Anything?", "test-correlation-3")
assert answer.evidence_state == "unavailable"
assert answer.sources == []
def test_ragcore_provider_without_configured_space_is_unavailable_without_a_network_call(
monkeypatch,
):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "")
def fail_if_called():
raise AssertionError("should not call RAGcore without a configured space id")
monkeypatch.setattr(provider, "_client", fail_if_called)
answer = provider.ask("Anything?", "test-correlation-no-space")
assert answer.evidence_state == "unavailable"
def test_ragcore_provider_health_reports_ready_status(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "ok"})),
)
health = provider.health()
assert health.provider == "ragcore"
assert health.available is True
def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "degraded"})),
)
health = provider.health()
assert health.available is False
def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="get"))
health = provider.health()
assert health.available is False
assert "unavailable" in health.detail.lower()
def _answers_body(**overrides) -> dict:
body = {
"answer": "Report damage and route the vehicle to maintenance.",
"answerability": "answerable",
"citations": [
{
"id": "cite-1",
"document_id": "doc-1",
"document_version_id": "version-1",
"title": "Damage handling procedure",
"section": "Detection",
"excerpt": "Inspect the vehicle for visible damage.",
}
],
}
body.update(overrides)
return body
def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body())),
)
answer = provider.ask("What must I do about damage?", "test-correlation-grounded")
assert answer.evidence_state == "grounded"
assert answer.answer
assert len(answer.sources) == 1
source = answer.sources[0]
assert source.document_id == "doc-1"
assert source.title == "Damage handling procedure"
assert source.version == "version-1"
assert source.section == "Detection"
assert source.excerpt
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_response=_FakeResponse(
200,
_answers_body(
answer="This should never be shown.",
answerability="not_answerable",
citations=[],
),
)
),
)
answer = provider.ask("Unrelated question?", "test-correlation-insufficient")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_response=_FakeResponse(
200, _answers_body(answerability="answerable", citations=[])
)
),
)
answer = provider.ask("What must I do about damage?", "test-correlation-no-citations")
assert answer.evidence_state == "insufficient"
assert answer.sources == []
def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(401, {"code": "AUTHENTICATION_REQUIRED"})),
)
answer = provider.ask("Anything?", "test-correlation-401")
assert answer.evidence_state == "unavailable"
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
# A malformed /v1/answers body triggers the same search fallback a real outage
# would, so the fallback's own /v1/search response must also be malformed here to
# exercise "the whole backend is misbehaving, not just one endpoint" honestly.
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(200, {"citations": "not-a-list"}),
"/v1/search": _FakeResponse(200, {"results": "not-a-list"}),
}
),
)
answer = provider.ask("Anything?", "test-correlation-malformed")
assert answer.evidence_state == "unavailable"
def _search_body(**overrides) -> dict:
body = {
"results": [
{
"chunk_id": "chunk-1",
"citation": {
"id": "cite-1",
"document_id": "doc-1",
"document_version_id": "version-1",
"title": "Vehicle return procedure",
"section": "Return",
"excerpt": "Register the return odometer reading before releasing the vehicle.",
},
"rank": 1,
"scores": {"dense": None, "sparse": None, "fused": 0.5, "rerank": None},
}
],
"degraded": False,
}
body.update(overrides)
return body
def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypatch):
"""/v1/answers itself failing (a real RAGcore-side outage in its generation step,
not a real 'insufficient evidence' classification) must not silently degrade
straight to 'unavailable' when RAGcore's own retrieval still works -- it should show
the real, cited excerpt search actually found instead."""
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask("What is the vehicle return procedure?", "test-correlation-fallback")
assert answer.evidence_state == "grounded"
assert "Register the return odometer reading" in answer.answer
assert "Vehicle return procedure" in answer.answer
assert len(answer.sources) == 1
assert answer.sources[0].title == "Vehicle return procedure"
assert answer.sources[0].excerpt == (
"Register the return odometer reading before releasing the vehicle."
)
def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask(
"Wat is de procedure voor een voertuigretour?",
"test-correlation-fallback-nl",
language="nl-BE",
)
assert answer.evidence_state == "grounded"
assert answer.answer.startswith("Volgens ")
def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body(results=[])),
}
),
)
answer = provider.ask("Unrelated question?", "test-correlation-fallback-empty")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
+42
View File
@@ -81,6 +81,48 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
assert events[0]["actor_type"] == "service"
def test_search_knowledge_respects_requested_locale(client):
response = client.post(
"/api/v1/integrations/mcp/search-knowledge",
json={
"question": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
"max_sources": 1,
"locale": "nl-BE",
},
headers=_headers(),
)
assert response.status_code == 200
body = response.json()
assert body["evidence_state"] == "grounded"
def test_search_knowledge_preserves_inbound_correlation_id(client, ops_client):
inbound = "11111111-1111-1111-1111-111111111111"
response = client.post(
"/api/v1/integrations/mcp/search-knowledge",
json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1},
headers={**_headers(client_id="correlation-probe"), "X-Correlation-Id": inbound},
)
assert response.status_code == 200
assert response.json()["correlation_id"] == inbound
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
matching = [e for e in events if e["correlation_id"] == inbound]
assert len(matching) == 1
def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_client):
response = client.get(
"/api/v1/integrations/mcp/operations-summary",
headers=_headers(client_id="no-correlation-probe"),
)
assert response.status_code == 200
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
matching = [e for e in events if e["actor_label"] == "no-correlation-probe"]
assert len(matching) >= 1
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
def test_no_write_endpoints_exist_under_mcp_namespace(client):
for method, path in [
("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
+147
View File
@@ -1,11 +1,16 @@
import threading
import uuid
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.db import SessionLocal
from app.main import app
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.outbox import OutboxEvent
from app.models.vehicle import Vehicle
@@ -39,6 +44,148 @@ def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
db.close()
def _set_next_service_km(vehicle_ref: str, threshold: int) -> None:
db = SessionLocal()
try:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
vehicle.next_service_km = threshold
db.commit()
finally:
db.close()
def _add_reserved_booking(vehicle_ref: str, *, hours_from_now: float) -> str:
db = SessionLocal()
try:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
customer = db.scalar(select(Customer))
starts_at = datetime.now(UTC) + timedelta(hours=hours_from_now)
public_ref = f"BK-TEST-{uuid.uuid4().hex[:8].upper()}"
booking = Booking(
public_ref=public_ref,
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=starts_at,
ends_at=starts_at + timedelta(days=2),
status="reserved",
requirements_complete=True,
)
db.add(booking)
db.commit()
return public_ref
finally:
db.close()
def _counts() -> tuple[int, int]:
db = SessionLocal()
try:
return (
len(db.scalars(select(AuditEvent)).all()),
len(db.scalars(select(OutboxEvent)).all()),
)
finally:
db.close()
def test_preview_performs_no_writes_and_matches_commit(ops_client):
booking_ref = _activate_booking("MO-006", start_odometer_km=30000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-006").json()
new_reading = vehicle_before["odometer_km"] + 25
body = _return_body(end_odometer_km=new_reading)
audit_before, outbox_before = _counts()
preview = ops_client.post(f"/api/v1/bookings/{booking_ref}/return-preview", json=body)
assert preview.status_code == 200
preview_body = preview.json()
audit_after, outbox_after = _counts()
assert (audit_after, outbox_after) == (audit_before, outbox_before)
booking_mid = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
assert booking_mid["status"] == "active" # preview did not mutate the booking
vehicle_mid = ops_client.get("/api/v1/vehicles/MO-006").json()
assert vehicle_mid["odometer_km"] == vehicle_before["odometer_km"]
assert preview_body["odometer_regression"] is False
assert preview_body["resulting_odometer_km"] == new_reading
assert preview_body["canonical_odometer_km"] == vehicle_before["odometer_km"]
commit = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=body,
headers={"Idempotency-Key": "test-preview-matches-commit-001"},
)
assert commit.status_code == 201
commit_body = commit.json()
assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"]
assert commit_body["odometer_regression"] == preview_body["odometer_regression"]
assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"]
def test_preview_detects_odometer_regression(ops_client):
booking_ref = _activate_booking("MO-007", start_odometer_km=15000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-007").json()
low_reading = vehicle_before["odometer_km"] - 100
preview = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return-preview",
json=_return_body(end_odometer_km=low_reading),
)
assert preview.status_code == 200
body = preview.json()
assert body["odometer_regression"] is True
assert body["would_create_quality_issue"] is True
assert "odometer_regression" in body["attention_reasons"]
assert body["resulting_odometer_km"] == vehicle_before["odometer_km"]
vehicle_after = ops_client.get("/api/v1/vehicles/MO-007").json()
assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"]
def test_preview_detects_service_due(ops_client):
booking_ref = _activate_booking("MO-009", start_odometer_km=18000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-009").json()
_set_next_service_km("MO-009", vehicle_before["odometer_km"] + 50)
preview = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return-preview",
json=_return_body(end_odometer_km=vehicle_before["odometer_km"] + 100),
)
assert preview.status_code == 200
body = preview.json()
assert body["resulting_vehicle_status"] == "maintenance"
assert "service threshold" in body["status_reason"]
def test_preview_detects_next_booking_risk(ops_client):
booking_ref = _activate_booking("MO-011", start_odometer_km=19000)
_add_reserved_booking("MO-011", hours_from_now=2)
preview = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return-preview",
json=_return_body(end_odometer_km=19500),
)
assert preview.status_code == 200
risk = preview.json()["next_booking_risk"]
assert risk is not None
assert risk["at_risk"] is True # less than 4 hours away
def test_preview_requires_active_booking(ops_client):
booking_ref = _activate_booking("MO-014", start_odometer_km=21000)
ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=21500),
headers={"Idempotency-Key": "test-preview-requires-active-001"},
)
preview = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return-preview",
json=_return_body(end_odometer_km=22000),
)
assert preview.status_code == 409
assert preview.json()["error"]["code"] == "INVALID_BOOKING_STATE"
def test_register_return_success_updates_canonical_odometer(ops_client):
booking_ref = _activate_booking("MO-003", start_odometer_km=20000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json()
+75
View File
@@ -0,0 +1,75 @@
def test_search_requires_authentication(client):
response = client.get("/api/v1/search", params={"q": "MO-001"})
assert response.status_code == 401
def test_search_finds_a_vehicle_by_reference(ops_client):
response = ops_client.get("/api/v1/search", params={"q": "MO-001"})
assert response.status_code == 200
body = response.json()
match = next((r for r in body["results"] if r["type"] == "vehicle"), None)
assert match is not None
assert match["label"] == "MO-001"
assert match["link"] == "/vehicles/MO-001"
# The backend must never send localizable prose -- only a stable code plus raw
# data params, so the frontend can render it in the operator's selected language.
assert match["detail_code"] == "vehicleSummary"
assert set(match["detail_params"]) == {"make", "model", "location"}
def test_search_finds_a_booking_by_reference(ops_client):
response = ops_client.get("/api/v1/search", params={"q": "BK-DEMO-RETURN"})
assert response.status_code == 200
match = next((r for r in response.json()["results"] if r["type"] == "booking"), None)
assert match is not None
assert match["link"] == "/bookings/BK-DEMO-RETURN"
def test_search_finds_a_data_quality_issue_for_operations_manager(ops_client):
response = ops_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
assert response.status_code == 200
match = next((r for r in response.json()["results"] if r["type"] == "data_quality_issue"), None)
assert match is not None
assert match["link"] == "/data-quality/DQ-DEMO-OVERLAP"
def test_search_never_returns_data_quality_issues_for_rental_employee(employee_client):
response = employee_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
assert response.status_code == 200
assert all(r["type"] != "data_quality_issue" for r in response.json()["results"])
def test_search_section_result_visible_to_operations_manager(ops_client):
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
match = next(
(r for r in result["results"] if r["type"] == "section" and r["link"] == "/audit"), None
)
assert match is not None
# Section results must ship a stable id, not English prose -- the frontend looks up
# navigation:items.<id> and search:sections.<id>.detail in the selected locale.
assert match["label"] == "audit"
assert match["detail_code"] == "audit"
def test_search_section_matches_dutch_and_french_terms(ops_client):
nl_result = ops_client.get("/api/v1/search", params={"q": "wagenpark"}).json()
assert any(r["type"] == "section" and r["link"] == "/vehicles" for r in nl_result["results"])
fr_result = ops_client.get("/api/v1/search", params={"q": "réservation"}).json()
assert any(r["type"] == "section" and r["link"] == "/bookings" for r in fr_result["results"])
def test_search_section_result_hidden_from_rental_employee(employee_client):
result = employee_client.get("/api/v1/search", params={"q": "audit"}).json()
assert all(r["link"] != "/audit" for r in result["results"])
def test_search_never_returns_customer_results(ops_client):
response = ops_client.get("/api/v1/search", params={"q": "CUS-0012"})
assert response.status_code == 200
assert all(r["type"] != "customer" for r in response.json()["results"])
def test_search_no_match_returns_empty_results(ops_client):
response = ops_client.get("/api/v1/search", params={"q": "zzz-no-such-thing-zzz"})
assert response.status_code == 200
assert response.json()["results"] == []
+232 -5
View File
@@ -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.outbox import DEMO_SCENARIO_ERROR_CODE, 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():
@@ -19,9 +22,16 @@ def test_seed_counts_match_deterministic_dataset():
reset_and_seed(db)
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
assert db.scalar(select(func.count()).select_from(Customer)) == 180
assert db.scalar(select(func.count()).select_from(Booking)) == 246
# 15 from the CSV plus a deterministic set discovered by the post-seed scan.
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 26
# 246 original plus 8 (BK-T-001..008) added so "Today's movements" reads as a
# real day of traffic rather than the same fixed 4 rows on every reset.
assert db.scalar(select(func.count()).select_from(Booking)) == 254
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
# real open issue) plus a deterministic set discovered by the post-seed scan. The
# shared vehicle-status evaluator (app.services.vehicle_status) also catches
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
# already crossed its service-due odometer threshold -- a genuine conflict the
# previous hand-rolled scanner never checked for.
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
assert db.scalar(select(func.count()).select_from(User)) == 2
finally:
@@ -52,3 +62,220 @@ 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
# Coded as a prepared demo scenario, not as a real connectionError: the whole
# point of this row is to demonstrate retry and audit, so nothing downstream
# may read it as evidence that the n8n integration is unhealthy.
assert failed.last_error_code == DEMO_SCENARIO_ERROR_CODE
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()
def test_seed_today_movements_are_a_credible_mix():
"""A fresh reset must not land on a thin, always-identical 'Today's movements'
dashboard section: a real day of fleet traffic (>=5 departures, >=5 returns, across
more than 4 distinct vehicles) should fall on the reset day, mirroring the same
status/date rule the dashboard router uses to build the today list."""
db = SessionLocal()
try:
reset_and_seed(db)
today = datetime.now(UTC).date()
bookings = db.scalars(select(Booking)).all()
departures = [
b
for b in bookings
if b.starts_at.date() == today and b.status in ("reserved", "active")
]
returns = [
b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned")
]
assert len(departures) >= 5
assert len(returns) >= 5
vehicles_involved = {b.vehicle_id for b in departures} | {b.vehicle_id for b in returns}
assert len(vehicles_involved) > 4
finally:
db.close()
def test_every_blocked_vehicle_has_a_real_open_issue():
"""A live reviewer found blocked vehicles with no explanation anywhere in the UI --
5 with zero quality issues at all, one (MO-049) with only a resolved one. Every
vehicle seeded as 'blocked' must now have at least one real, currently open
DataQualityIssue an operator can click through to."""
db = SessionLocal()
try:
reset_and_seed(db)
blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all()
assert len(blocked) > 0
for vehicle in blocked:
open_issue = db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
)
)
assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue"
finally:
db.close()
def test_seed_scenario_mo024_service_conflict_is_flagged():
"""Regression lock-in for the live-reported MO-024 defect: 'rented' with a real
active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold,
with nothing surfacing the contradiction. The shared vehicle-status evaluator
already catches this (a real conflict, not a fabricated third status) -- this test
exists so a future change can't silently regress it back to unexplained."""
db = SessionLocal()
try:
reset_and_seed(db)
vehicle = _by_ref(db, Vehicle, "MO-024")
assert vehicle is not None
assert vehicle.operational_status == "rented"
assert vehicle.odometer_km >= vehicle.next_service_km
issue = db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.rule_type == "vehicle_status_conflict",
)
)
assert issue is not None
signals = issue.evidence_json.get("signals", [])
assert any(s["code"] == "vehicle.manual_review_required" for s in signals)
finally:
db.close()
def test_seed_evidence_has_no_placeholder_summary():
"""Every seed-only data-quality issue used to carry the vacuous evidence
'Synthetic deterministic seed issue' with no structured signal at all -- a visitor
had no way to understand why it needed attention. Every issue must now carry a real
summary and at least one localizable signal (code + params)."""
db = SessionLocal()
try:
reset_and_seed(db)
issues = db.scalars(select(DataQualityIssue)).all()
assert len(issues) > 0
for issue in issues:
summary = issue.evidence_json.get("summary", "")
assert summary != "Synthetic deterministic seed issue", (
f"{issue.public_ref} still has the meaningless placeholder summary"
)
signals = issue.evidence_json.get("signals", [])
assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal"
finally:
db.close()
+160
View File
@@ -0,0 +1,160 @@
"""Pure unit tests for the shared vehicle-status evaluator -- no database needed, since
evaluate_vehicle_status() only reasons over an already-gathered VehicleStatusFacts. See
docs/fleet-ops-correction/vehicle-status-decision-table.md for the decision table these
tests are asserting against."""
from types import SimpleNamespace
from app.services.vehicle_status import (
RECOMMENDATION_CODE_ACTIVE_RENTAL,
RECOMMENDATION_CODE_BOOKING_CONFLICT,
RECOMMENDATION_CODE_MANUAL_REVIEW,
RECOMMENDATION_CODE_NO_CONFLICT,
RECOMMENDATION_CODE_RENTAL_ENDED,
RECOMMENDATION_CODE_SERVICE_THRESHOLD,
VehicleStatusFacts,
compute_recommendation_token,
evaluate_vehicle_status,
)
def _vehicle(status: str, *, version: int = 1):
return SimpleNamespace(operational_status=status, version=version)
def _facts(**overrides) -> VehicleStatusFacts:
defaults = dict(
active_booking_refs=[],
overlapping_booking_pairs=[],
service_threshold_reached=False,
odometer_km=10_000,
next_service_km=20_000,
open_booking_overlap_issue_ref=None,
)
defaults.update(overrides)
return VehicleStatusFacts(**defaults)
def test_available_with_active_rental_recommends_rented():
result = evaluate_vehicle_status(
_vehicle("available"), _facts(active_booking_refs=["BK-0001"])
)
assert result.recommended_status == "rented"
assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL
assert result.safe_to_apply is True
assert result.manual_review_required is False
def test_maintenance_with_active_rental_never_auto_recommends_rented():
# The exact unsafe shortcut this task explicitly forbids: maintenance + an active
# booking must NEVER be auto-resolved to "rented".
result = evaluate_vehicle_status(
_vehicle("maintenance"), _facts(active_booking_refs=["BK-0001"])
)
assert result.recommended_status is None
assert result.manual_review_required is True
assert result.safe_to_apply is False
assert result.recommendation_code == RECOMMENDATION_CODE_MANUAL_REVIEW
def test_available_with_active_rental_and_service_threshold_requires_manual_review():
result = evaluate_vehicle_status(
_vehicle("available"),
_facts(active_booking_refs=["BK-0001"], service_threshold_reached=True),
)
assert result.manual_review_required is True
assert result.recommended_status is None
def test_available_with_active_rental_and_booking_conflict_requires_manual_review():
result = evaluate_vehicle_status(
_vehicle("available"),
_facts(active_booking_refs=["BK-0001"], open_booking_overlap_issue_ref="DQ-0001"),
)
assert result.manual_review_required is True
assert result.recommended_status is None
def test_rented_with_no_active_booking_recommends_available():
result = evaluate_vehicle_status(_vehicle("rented"), _facts())
assert result.recommended_status == "available"
assert result.recommendation_code == RECOMMENDATION_CODE_RENTAL_ENDED
assert result.safe_to_apply is True
def test_service_threshold_reached_recommends_maintenance():
result = evaluate_vehicle_status(
_vehicle("available"), _facts(service_threshold_reached=True)
)
assert result.recommended_status == "maintenance"
assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD
def test_booking_conflict_recommends_blocked_not_a_generic_high_severity_proxy():
# The evaluator must react to a *real* booking-conflict fact, not "does some other
# open high-severity issue happen to exist" (the forbidden proxy).
result = evaluate_vehicle_status(
_vehicle("available"),
_facts(overlapping_booking_pairs=[("BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B")]),
)
assert result.recommended_status == "blocked"
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
def test_open_booking_overlap_issue_alone_also_triggers_blocked():
result = evaluate_vehicle_status(
_vehicle("available"), _facts(open_booking_overlap_issue_ref="DQ-DEMO-OVERLAP")
)
assert result.recommended_status == "blocked"
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
def test_maintenance_with_no_active_rental_and_no_blockers_stays_manual():
# No fact here confirms maintenance is actually finished, so the evaluator must not
# auto-clear it to "available" -- that release remains an explicit, manual decision.
result = evaluate_vehicle_status(_vehicle("maintenance"), _facts())
assert result.recommended_status is None
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
assert result.safe_to_apply is False
def test_no_conflict_when_status_already_matches_facts():
result = evaluate_vehicle_status(_vehicle("available"), _facts())
assert result.recommended_status is None
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
assert result.safe_to_apply is False
result = evaluate_vehicle_status(_vehicle("rented"), _facts(active_booking_refs=["BK-1"]))
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
result = evaluate_vehicle_status(_vehicle("blocked"), _facts())
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
result = evaluate_vehicle_status(
_vehicle("maintenance"), _facts(service_threshold_reached=True)
)
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
def test_recommendation_token_changes_when_facts_change():
vehicle = _vehicle("available")
facts_a = _facts()
facts_b = _facts(service_threshold_reached=True)
assert compute_recommendation_token(vehicle, facts_a) != compute_recommendation_token(
vehicle, facts_b
)
def test_recommendation_token_is_stable_for_identical_facts():
vehicle = _vehicle("available")
facts = _facts(active_booking_refs=["BK-0001"])
assert compute_recommendation_token(vehicle, facts) == compute_recommendation_token(
vehicle, facts
)
def test_recommendation_token_changes_when_vehicle_version_changes():
facts = _facts()
assert compute_recommendation_token(
_vehicle("available", version=1), facts
) != compute_recommendation_token(_vehicle("available", version=2), facts)
+12
View File
@@ -14,6 +14,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client):
assert all(v["attention"] for v in vehicles)
def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client):
response = ops_client.get(
"/api/v1/vehicles",
params={"status": "maintenance", "page": 1, "page_size": 25},
)
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert all(v["operational_status"] == "maintenance" for v in body["items"])
assert body["total"] >= len(body["items"])
def test_vehicle_detail_includes_related_records(ops_client):
response = ops_client.get("/api/v1/vehicles/MO-016")
assert response.status_code == 200
+59
View File
@@ -1,9 +1,28 @@
from app.core.db import SessionLocal
from app.seed_loader import reset_and_seed
def _reseed() -> None:
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
The suite shares one session-scoped database and earlier files legitimately mutate
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
rather than depend on file ordering.
"""
db = SessionLocal()
try:
reset_and_seed(db)
finally:
db.close()
def test_list_workflows_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/workflows")
assert response.status_code == 403
def test_list_workflows_includes_seeded_failed_run(ops_client):
_reseed()
response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
assert response.status_code == 200
runs = response.json()
@@ -20,6 +39,7 @@ def test_retry_requires_failed_status(ops_client):
def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
target = failed[0]["event_id"]
@@ -36,3 +56,42 @@ def test_retry_requires_operations_manager(employee_client):
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
)
assert response.status_code == 403
def test_seeded_failure_is_labelled_as_a_prepared_demo_scenario(ops_client):
"""The one seeded failure must announce itself as staged. An unexplained red row in
a demo reads as a broken product; a labelled one reads as the retry story it is."""
_reseed()
runs = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
demo_runs = [run for run in runs if run["is_demo_scenario"]]
assert len(demo_runs) == 1
assert demo_runs[0]["last_error_code"] == "demoScenarioTimeout"
assert demo_runs[0]["aggregate_ref"] == "BK-H-0020"
def test_succeeded_runs_are_never_marked_as_a_demo_scenario(ops_client):
runs = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
assert runs
assert all(run["is_demo_scenario"] is False for run in runs)
def test_retry_of_the_demo_scenario_is_audited_as_a_demo_scenario(ops_client):
"""The retry is a real redelivery either way; the audit records which kind of
failure it resolved so a staged retry is never mistaken for a production fix."""
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
demo_run = next(run for run in failed if run["is_demo_scenario"])
response = ops_client.post(f"/api/v1/workflows/{demo_run['event_id']}/retry")
assert response.status_code == 200
assert response.json()["status"] == "pending"
audit = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
entry = next(
event
for event in audit
if (event.get("metadata") or event.get("metadata_json") or {}).get("event_id")
== demo_run["event_id"]
)
metadata = entry.get("metadata") or entry.get("metadata_json") or {}
assert metadata["demo_scenario"] is True
+7 -1
View File
@@ -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}
@@ -32,9 +31,16 @@ services:
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
RAGCORE_SPACE_ID: ${RAGCORE_SPACE_ID:-}
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token}
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
MCP_HUB_REGISTRATION_ENABLED: ${MCP_HUB_REGISTRATION_ENABLED:-false}
MCP_HUB_BASE_URL: ${MCP_HUB_BASE_URL:-}
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:
+14 -8
View File
@@ -1,17 +1,20 @@
{
"_note": "Fleet Ops's own published tool contract. The live ITWorx MCP Hub connector (ITWorx_MCP_Hub repo, connectors/mobilityops/) wraps these under its own dotted namespace (mobilityops.operations.summary, .attention.list, .vehicle.get, .knowledge.search) -- that naming is Hub-owned. deprecated_aliases below are Fleet Ops's own prior internal audit-label names, kept only so existing clients/dashboards referencing them don't break.",
"provider_id": "mobilityops",
"version": "1.0.0",
"version": "1.1.0",
"required_scope": "mobilityops.read",
"tools": [
{
"name": "mobilityops_get_operations_summary",
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic MobilityOps demo tenant.",
"name": "fleet_ops_get_operations_summary",
"deprecated_aliases": ["mobilityops_get_operations_summary"],
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic Fleet Ops demo tenant.",
"read_only": true,
"inputSchema": {"type": "object", "additionalProperties": false},
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"}
},
{
"name": "mobilityops_list_attention_vehicles",
"name": "fleet_ops_list_attention_vehicles",
"deprecated_aliases": ["mobilityops_list_attention_vehicles"],
"description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.",
"read_only": true,
"inputSchema": {
@@ -26,7 +29,8 @@
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"}
},
{
"name": "mobilityops_get_vehicle_details",
"name": "fleet_ops_get_vehicle_details",
"deprecated_aliases": ["mobilityops_get_vehicle_details"],
"description": "Return a read-only operational view of one vehicle by its stable public reference.",
"read_only": true,
"inputSchema": {
@@ -38,15 +42,17 @@
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"}
},
{
"name": "mobilityops_search_knowledge",
"description": "Search versioned MobilityOps internal procedures through the dedicated RAGcore workspace and return grounded source references.",
"name": "fleet_ops_search_knowledge",
"deprecated_aliases": ["mobilityops_search_knowledge"],
"description": "Search versioned Fleet Ops internal procedures through the dedicated RAGcore workspace and return grounded source references.",
"read_only": true,
"inputSchema": {
"type": "object",
"required": ["question"],
"properties": {
"question": {"type": "string", "minLength": 3, "maxLength": 1000},
"max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4}
"max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4},
"locale": {"enum": ["nl-BE", "en-GB", "fr-BE"], "default": "en-GB"}
},
"additionalProperties": false
},
+174 -2
View File
@@ -1,8 +1,11 @@
openapi: 3.1.0
info:
title: MobilityOps API
title: Fleet Ops API
version: 0.1.0
description: Contract baseline for the MobilityOps proof of concept.
description: >-
Contract baseline for the Fleet Ops demo. "Fleet Ops" is the visible product name;
"mobilityops" remains the technical identifier for the repository, deployment
directory, database, and internal service/health identifiers only.
servers:
- url: http://localhost:8128
paths:
@@ -66,6 +69,26 @@ paths:
responses:
'200':
description: Booking detail
/api/v1/bookings/{public_ref}/return-preview:
post:
operationId: previewVehicleReturn
description: >-
Non-mutating evaluation of what committing this return would do. Shares its
domain evaluation with the commit endpoint below so the two can never drift.
No writes, no audit event, no outbox event.
parameters:
- $ref: '#/components/parameters/PublicRef'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterReturnRequest'
responses:
'200':
description: Authoritative evaluation of the resulting fleet state
'409':
description: Booking is not active
/api/v1/bookings/{public_ref}/return:
post:
operationId: registerVehicleReturn
@@ -94,9 +117,17 @@ paths:
/api/v1/data-quality/issues:
get:
operationId: listDataQualityIssues
description: Operations Manager only.
responses:
'200':
description: Quality issues
/api/v1/data-quality/scan:
post:
operationId: runDataQualityScan
description: Manual trigger for the deterministic five-rule scan. Operations Manager only.
responses:
'200':
description: Counts of newly created issues per rule type
/api/v1/data-quality/issues/{public_ref}/merge-customers:
post:
operationId: mergeDuplicateCustomers
@@ -107,6 +138,147 @@ paths:
description: Merge completed
'409':
description: Issue no longer mergeable
/api/v1/data-quality/issues/{public_ref}/provide-fields:
post:
operationId: provideMissingFields
description: >-
missing_required_field only. Resolves once nothing required remains missing;
otherwise leaves the issue open with updated evidence.
parameters:
- $ref: '#/components/parameters/PublicRef'
responses:
'200':
description: Issue after the update (may still be open)
'409':
description: Wrong rule type or issue not open
'422':
description: Disallowed field or empty value
/api/v1/data-quality/issues/{public_ref}/resolve-odometer-regression:
post:
operationId: resolveOdometerRegression
description: >-
odometer_regression only. Either retains the canonical odometer, or corrects a
related booking's reading -- a correction below the current canonical value is
rejected, since it would not resolve the regression.
parameters:
- $ref: '#/components/parameters/PublicRef'
responses:
'200':
description: Issue resolved
'409':
description: Wrong rule type or issue not open
'422':
description: Invalid booking reference or a correction below canonical
/api/v1/data-quality/issues/{public_ref}/resolve-overlap:
post:
operationId: resolveBookingOverlap
description: >-
booking_overlap only. Blocks one of the two overlapping bookings and
re-verifies no overlap remains before resolving.
parameters:
- $ref: '#/components/parameters/PublicRef'
responses:
'200':
description: Issue resolved
'409':
description: Wrong rule type, issue not open, or overlap still present
'422':
description: booking_ref not one of the overlapping bookings
/api/v1/data-quality/issues/{public_ref}/status-recommendation:
post:
operationId: previewVehicleStatusRecommendation
description: >-
vehicle_status_conflict only. Non-mutating: computes the recommendation from
the same shared evaluator the scanner and apply endpoint use
(app.services.vehicle_status.evaluate_vehicle_status), without resolving the
issue, writing an audit event, or queuing automation. Safe to call repeatedly
-- see docs/fleet-ops-correction/vehicle-status-decision-table.md.
parameters:
- $ref: '#/components/parameters/PublicRef'
responses:
'200':
description: >-
Current/recommended status, recommendation code, safe_to_apply,
manual_review_required, the underlying facts, and a recommendation_token
the apply endpoint revalidates against.
'409':
description: Wrong rule type, issue not open, or vehicle not found
/api/v1/data-quality/issues/{public_ref}/apply-recommended-status:
post:
operationId: applyRecommendedVehicleStatus
description: >-
vehicle_status_conflict only. Applies the one authoritative recommendation
function's output within one transaction: locks the issue and vehicle,
recomputes the recommendation from fresh facts, rejects the request if the
supplied recommendation_token no longer matches (RECOMMENDATION_STALE), refuses
an unsafe/manual-review recommendation (MANUAL_REVIEW_REQUIRED) or a
recommendation with nothing to apply (NO_CONFLICT_DETECTED), then re-validates
the same evaluator post-write before resolving the issue.
parameters:
- $ref: '#/components/parameters/PublicRef'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recommendation_token]
properties:
recommendation_token:
type: string
description: The token from the most recent status-recommendation preview call.
responses:
'200':
description: Applied status, reason code and the resolved issue
'409':
description: >-
Wrong rule type, issue not open, vehicle not found, stale recommendation
token, manual review required, no conflict detected, or the applied status
did not resolve the conflict on re-validation
/api/v1/search:
get:
operationId: search
description: >-
Bounded typed results (vehicle, booking, data_quality_issue, section).
Data-quality and manager-only sections are filtered server-side by role.
Customers are never returned -- no customer detail route exists. Every result's
`label` is a stable public_ref/section id (never translatable prose); `detail_code`
(+ optional `detail_params` for data values like make/model/location) is what the
frontend localizes -- the backend never emits English/Dutch/French sentences here.
parameters:
- in: query
name: q
required: true
schema:
type: string
minLength: 1
maxLength: 100
responses:
'200':
description: Search results
/api/v1/integrations/status:
get:
operationId: getIntegrationStatus
description: >-
Truthful aggregate n8n state derived from outbox delivery counts (not just the
most recent event), plus the actual MCP Hub registration_enabled setting.
Operations Manager only.
responses:
'200':
description: n8n and MCP Hub integration status
/api/v1/integrations/n8n/scheduled-scan:
post:
operationId: n8nScheduledScan
description: >-
Triggered by the scheduled n8n quality-scan workflow. Runs the same run_scan()
the manual UI action uses; idempotent by construction.
security:
- serviceToken: []
responses:
'200':
description: Counts of newly created issues per rule type
'401':
description: Invalid service token
/api/v1/knowledge/questions:
post:
operationId: askKnowledgeQuestion
+14 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}"
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
source_workflow="${3:-n8n/mobilityops-return-processing.json}"
source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
if [ ! -f .env ]; then
echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1
fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
if [ -z "$callback_token" ]; then
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
exit 1
fi
# The workflow file no longer carries the callback token as a literal header value -- both
# the webhook trigger and the outbound callback authenticate via named n8n Header Auth
# credentials ("Fleet Ops Webhook Trigger Token", "Fleet Ops Service Token"). Those must
# exist in the target n8n instance before this workflow is activated; see the echo below.
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-return-processing.json"
cleanup() {
@@ -32,15 +30,17 @@ cleanup() {
}
trap cleanup EXIT INT TERM
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url |
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
jq --arg callback_url "$callback_url" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url
' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
docker restart "$container_name" >/dev/null
echo "Published MobilityOps return workflow to existing container ${container_name}"
echo "Imported Fleet Ops — Vehicle Return Orchestration into container ${container_name}."
echo "Before activating: in the n8n UI, create Header Auth credentials named"
echo " 'Fleet Ops Webhook Trigger Token' (value = MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN from .env)"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "those secret values, and does not restart the container -- restart it yourself once"
echo "credentials are wired up and the workflow is published, if required."
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
set -eu
container_name="${1:-n8n}"
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}"
if [ ! -f .env ]; then
echo "Missing deployment .env" >&2
exit 1
fi
if [ ! -f "$source_workflow" ]; then
echo "Missing workflow export: $source_workflow" >&2
exit 1
fi
if ! docker inspect "$container_name" >/dev/null 2>&1; then
echo "Existing n8n container not found: $container_name" >&2
exit 1
fi
# The workflow file no longer carries the callback token as a literal header value -- the
# scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
# Token"), which must exist in the target n8n instance before this workflow is activated;
# see the echo below.
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
cleanup() {
rm -f "$temporary_workflow"
docker exec "$container_name" rm -f "$container_workflow" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
jq --arg scan_url "$scan_url" '
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
echo "Imported Fleet Ops — Scheduled Data Quality Scan into container ${container_name}."
echo "Before activating: in the n8n UI, create a Header Auth credential named"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "that secret value, and does not restart the container -- restart it yourself once"
echo "the credential is wired up and the workflow is published, if required."
+47 -7
View File
@@ -11,9 +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/system/status`
- `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`
- `POST /api/v1/demo/reset` — Operations Manager only
- `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; returns 403 if `DEMO_ALLOW_RESET=false`
### Dashboard
@@ -28,17 +36,37 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
- `GET /api/v1/bookings`
- `GET /api/v1/bookings/{public_ref}`
- `POST /api/v1/bookings/{public_ref}/return-preview` — non-mutating; shares its domain
evaluation with the commit endpoint below so the two cannot drift apart
- `POST /api/v1/bookings/{public_ref}/return`
Return commands require an `Idempotency-Key` header and optimistic version where relevant.
Return commands require an `Idempotency-Key` header. Concurrency safety is row-lock based
(`SELECT ... FOR UPDATE` on the booking and vehicle); no optimistic-version field is
accepted or needed on top of that.
### Data quality
All Operations Manager only.
- `GET /api/v1/data-quality/issues`
- `GET /api/v1/data-quality/issues/{public_ref}`
- `POST /api/v1/data-quality/scan` — manual trigger for the deterministic five-rule scan
- `POST /api/v1/data-quality/issues/{public_ref}/defer`
- `POST /api/v1/data-quality/issues/{public_ref}/reject`
- `POST /api/v1/data-quality/issues/{public_ref}/merge-customers`
- `POST /api/v1/data-quality/issues/{public_ref}/merge-customers` — possible_duplicate_customer
- `POST /api/v1/data-quality/issues/{public_ref}/provide-fields` — missing_required_field
- `POST /api/v1/data-quality/issues/{public_ref}/resolve-odometer-regression` — odometer_regression
- `POST /api/v1/data-quality/issues/{public_ref}/resolve-overlap` — booking_overlap
- `POST /api/v1/data-quality/issues/{public_ref}/apply-recommended-status` — vehicle_status_conflict
Each of the five rule types has exactly one bounded resolution path above (plus
defer/reject, which apply to any open issue).
### Search
- `GET /api/v1/search?q=...` — bounded typed results (vehicle, booking,
data_quality_issue, section); role-filtered server-side; customers are never returned
(no customer detail route exists in this PoC)
### Knowledge
@@ -47,9 +75,21 @@ Return commands require an `Idempotency-Key` header and optimistic version where
### Automation and audit
- `GET /api/v1/workflows`
- `POST /api/v1/workflows/{event_id}/retry`
- `GET /api/v1/audit`
- `GET /api/v1/workflows` — Operations Manager only
- `POST /api/v1/workflows/{event_id}/retry` — Operations Manager only
- `GET /api/v1/audit` — Operations Manager only; each event includes `before`/`after`
plus a resolved `entity_ref`/`entity_link` where the entity type supports one
- `GET /api/v1/integrations/status` — Operations Manager only; truthful aggregate n8n
state from outbox delivery counts (not just the most recent event), and the actual
MCP Hub `registration_enabled` setting
### n8n-service endpoints
Service-token protected (`X-Service-Token`, same shared secret as the return callback):
- `POST /api/v1/integrations/n8n/return-callback`
- `POST /api/v1/integrations/n8n/scheduled-scan` — triggered by the scheduled
quality-scan workflow; runs the same domain scan the manual UI action uses
### MCP-provider endpoints
+26 -1
View File
@@ -25,14 +25,29 @@ Merge rewires booking references, preserves the loser as a tombstone and audits
Required for active customers: first name, last name and at least one of email or phone. Required for active vehicles: registration number, make, model and location.
Resolution: `POST /provide-fields` accepts only the fields the entity type actually
requires (rejects anything else), applies them, and re-runs the same missing-field check.
The issue resolves only once nothing required remains missing; a partial submission
updates the record and its evidence but leaves the issue open.
## DQ-03 Odometer regression
Flag an inspection or maintenance reading below the canonical odometer. Never lower the canonical value automatically.
Resolution: `POST /resolve-odometer-regression` offers exactly two bounded decisions —
`retain_canonical` (the submitted reading is treated as erroneous; canonical is
untouched) or `correct_reading` (updates a named related booking's reading and the
vehicle's canonical odometer together). A `correct_reading` value below the current
canonical is rejected, since it would not resolve the regression, not silently applied.
## DQ-04 Booking overlap
Flag overlapping `reserved` or `active` bookings for one vehicle. Normal write APIs reject new overlaps; the seed/import path may create one controlled legacy conflict.
Resolution: `POST /resolve-overlap` blocks one of the two named overlapping bookings
(minimal safe resolution, not a scheduling calendar) and re-verifies no
reserved/active overlap remains among the issue's related bookings before resolving.
## DQ-05 Vehicle status conflict
Examples:
@@ -42,6 +57,16 @@ Examples:
- status `available` while critical open quality issue exists;
- status `maintenance` with an active booking.
Resolution: `POST /apply-recommended-status` computes a recommendation from one
authoritative function mirroring the conditions above, applies it, and re-runs the same
function to confirm the conflict is actually gone before resolving.
## Lifecycle
Detection is idempotent by `(rule_type, entity_type, entity_id, evidence fingerprint)` while open. Resolved issues remain historical. Reintroduced evidence creates a new issue linked to the prior issue where useful.
Detection is idempotent by `(rule_type, entity_type, entity_id)` while open — the CSV
seed rows don't carry a stable evidence fingerprint, so the literal
`(..., evidence fingerprint)` scheme from an earlier draft of this rule was dropped as
unworkable for seeded data; re-implementing it would need to reconcile with that. Resolved
issues remain historical. Reintroduced evidence creates a new issue whose evidence carries
`reopened_from` (the prior issue's reference) and `previous_decision` (its resolved
status), so a repeat problem is never presented as if no decision was ever made.
+11
View File
@@ -1,5 +1,16 @@
# Vehicle-return workflow
## Preview
`POST /api/v1/bookings/{public_ref}/return-preview` takes the same request body as the
commit endpoint below and runs the identical evaluation (`evaluate_return()`) with no
writes, no audit event and no outbox event — it exists so the UI's review step shows the
server's actual answer instead of guessing the outcome client-side. It returns the
canonical and submitted odometer readings, whether the submission is a regression, the
resulting vehicle status with a human-readable reason, whether a quality issue would be
created, and next-booking risk. `register_vehicle_return` (below) calls the same
`evaluate_return()` function, so preview and commit cannot drift apart.
## Input
- booking public reference;
+33 -9
View File
@@ -6,20 +6,44 @@ Publish four read-only MobilityOps capabilities through the existing central ITW
## Provider registration
- provider ID: `mobilityops`
- API base: configurable internal MobilityOps API URL
- authentication: scoped service token
- provider ID: `mobilityops` (registered on the Hub side; the Hub's own registration is
catalog-driven — it reconciles its catalog into the gateway, Fleet Ops never pushes a
registration call)
- API base: internal Fleet Ops API URL, reached via `MCP_HUB_SERVICE_TOKEN` auth
- authentication: scoped service token (`X-Service-Token`), plus `X-Client-Id`
- mode: read-only
- required scope: `mobilityops.read`
- **Confirmed live in production** on the ITWorx MCP Hub's own deployment (Tower), with
a real contract fix already applied there (`vehicle.get`'s wire parameter normalized to
camelCase `vehicleRef`). `docs/final-integrations/current-state-audit.md` has the full
evidence.
## Tools
The machine-readable definitions are in `contracts/mcp-tools.json`.
The machine-readable definitions are in `contracts/mcp-tools.json`. The Hub's own live
connector (`ITWorx_MCP_Hub` repo, `connectors/mobilityops/`) publishes these under its
own dotted namespace — that naming is the Hub's to own, not Fleet Ops's:
1. `mobilityops_get_operations_summary`
2. `mobilityops_list_attention_vehicles`
3. `mobilityops_get_vehicle_details`
4. `mobilityops_search_knowledge`
1. `mobilityops.operations.summary`
2. `mobilityops.attention.list`
3. `mobilityops.vehicle.get`
4. `mobilityops.knowledge.search`
Fleet Ops's own internal audit trail (`AuditEvent.metadata_json.tool`, visible on
`/api/v1/audit?action=mcp_tool_request`) labels these calls `fleet_ops_get_operations_summary`,
`fleet_ops_list_attention_vehicles`, `fleet_ops_get_vehicle_details`,
`fleet_ops_search_knowledge` — a separate, Fleet-Ops-owned naming layer for its own audit
log, not the wire-level MCP tool name a client calls.
`search-knowledge` accepts a `locale` field (`nl-BE` | `en-GB` | `fr-BE`, default
`en-GB`) that is passed straight through to the active knowledge provider.
## Correlation ID
The inbound `X-Correlation-Id` header (set by the Hub, itself either forwarding the
MCP client's ID or minting one) is preserved through Fleet Ops's own handling and audit
log; Fleet Ops only mints a fresh correlation ID when none is supplied or the supplied
value isn't a valid UUID. See `get_correlation_id` in
`backend/app/api/routers/mcp_integrations.py`.
## Routing
+66 -7
View File
@@ -16,19 +16,47 @@ Steps:
4. return a stable workflow result;
5. on errors, fail visibly so the outbox dispatcher can retry.
The starter export is `n8n/mobilityops-return-processing.json`. Claude may correct its credentials and callback route but must preserve idempotency.
The canonical, live-validated definition is `n8n/workflows/fleet-ops-vehicle-return.json` (see `n8n/workflows/MANIFEST.md`); it authenticates via named Header Auth credentials rather than a literal token, per the live-hardening pass documented in `docs/live-ai-integration/n8n-current-state.md`.
## Optional second workflow: knowledge sync
## Second live workflow: scheduled quality scan
Input: manual trigger or manifest-changed event.
RAGcore is not connected in this environment, so the originally sketched "knowledge sync"
workflow below remains deferred (see "Deferred: knowledge sync"). The second implemented
workflow does not depend on RAGcore or MCP Hub, so it is not blocked by them.
Input: hourly schedule trigger, or a manual trigger for on-demand testing.
Steps:
1. read the fixed knowledge manifest;
2. call RAGcore ingestion/sync API;
3. record per-document results through MobilityOps integration status API.
1. call the narrow, service-token-protected `POST
/api/v1/integrations/n8n/scheduled-scan` endpoint;
2. the endpoint runs the same deterministic `run_scan()` domain function the manual
"Run quality scan" UI action uses, and records a `data_quality_scan_run` audit event
with `actor_type=service`;
3. return counts of newly created issues per rule type.
This workflow is useful but must not delay the core demo if RAGcore's final API is not ready.
`run_scan()` only ever creates an issue for a condition that does not already have one
open, so a duplicate or overlapping trigger (a manual test run firing close to the
scheduled one, or a retried HTTP call) does no duplicate domain work.
The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
(see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
active on the live instance; a fresh import ships inactive until credentials are wired up
and it is deliberately published.
## RAGcore procedure sync (in progress)
RAGcore is now reachable in this environment; a live inspection of its real contract is
recorded in `docs/live-ai-integration/n8n-current-state.md`. Workflow 3, "Fleet Ops —
RAGcore Procedure Sync", is being built against that real contract (not the sketch
originally in this section) — see `n8n/workflows/MANIFEST.md` for current status.
## Workflow error handler (in progress)
Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
## Outbox dispatcher
@@ -39,3 +67,34 @@ This workflow is useful but must not delay the core demo if RAGcore's final API
- supports explicit manual retry;
- preserves last error and response metadata;
- does not hold a database transaction open during network I/O.
## Prepared demo failure versus real failure
The demo seed deliberately plants exactly one failed delivery (`BK-H-0020`, see
`seed/workflow_runs.csv`). It exists to demonstrate retry and audit, so it must never be
read as evidence that the automation is unhealthy.
It is distinguished by its `last_error_code`, `demoScenarioTimeout`
(`app.models.outbox.DEMO_SCENARIO_ERROR_CODE`) — not by a new column, so no migration is
involved. A real timeout produces `connectionError`; the two are never confused.
Consequences, all enforced by tests:
- `/api/v1/integrations/status` reports `failed` (everything), `unexpected_failed` (real
failures only) and `demo_scenario_failed` separately.
- Only `unexpected_failed` can move n8n off `operational`. A prepared failure alone
leaves the integration **operational** — a staged prop may not raise a red flag.
- `latest_failure_at` is a health signal and therefore ignores the prepared failure;
`latest_demo_scenario_at` reports it separately.
- `/api/v1/workflows` marks the run with `is_demo_scenario: true`. The Automation page
labels it "Prepared demo scenario", explains that it is a simulated temporary failure,
and offers a distinct "Retry demo scenario" action.
- A genuine later failure of that same event overwrites the code with the real one, and
from that moment it counts as a real failure — the carve-out is narrow by construction.
The retry itself is real in both cases: the event goes back on the outbox and the
dispatcher delivers it to the configured n8n webhook like any other, so 19 succeeded +
1 failed becomes 20 succeeded + 0 failed only when n8n genuinely accepts the delivery.
Nothing is marked succeeded without a real round trip. The audit entry records
`demo_scenario: true/false` so a staged retry is never mistaken for a production fix.
A demo reset recreates the original 19 + 1 scenario.
+31
View File
@@ -4,6 +4,30 @@
Role buttons may create a session for a seeded demo identity. All API routes still enforce authorization. Demo reset and customer merge require Operations Manager.
The browser never treats its own cached copy of the logged-in user as authoritative:
`AuthContext` re-verifies against `GET /api/v1/demo/session` on every app load (that
response is `Cache-Control: no-store`, so a stale cached "authenticated" response can't
survive a logout), and a central 401 listener on the API client clears local auth state
from any endpoint, not just the session check. `POST /api/v1/demo/logout` and
`POST /api/v1/demo/reset` both invalidate the session cookie server-side.
## Role matrix
| Capability | Rental Employee | Operations Manager |
|---|---|---|
| Dashboard, fleet, vehicle detail, bookings, booking detail | yes | yes |
| Register a vehicle return | yes | yes |
| Knowledge assistant | yes | yes |
| Data-quality workbench (view, scan, all resolutions) | no | yes |
| Integrations / automation status and retry | no | yes |
| Audit trail | no | yes |
| Demo reset | no | yes |
Enforced server-side (every listed manager-only action returns `403` for Rental
Employee, verified by direct API tests, not just a hidden button) and mirrored in the
frontend nav (manager-only items are not rendered, not merely disabled) and route guards
(direct URL access shows a restricted message rather than partial data).
## Service authentication
Use separate scoped credentials for:
@@ -42,6 +66,13 @@ Required actions:
Audit is append-only through the application. Provide filters by actor, action, entity and correlation ID.
`GET /api/v1/audit` (Operations Manager only) returns `before`/`after` for every event
(the columns already existed but were not serialized until this pass) plus a resolved
`entity_ref`/`entity_link` for vehicle, booking and data-quality-issue entities (no
customer link exists — no customer detail route). The UI shows a human-readable
before/after summary per row by default, with the raw before/after/metadata JSON behind
a `<details>` disclosure rather than shown unconditionally.
## Confirmation
No write-capable MCP actions exist in this PoC. Destructive UI actions such as demo reset and customer merge require explicit confirmation.
+20 -1
View File
@@ -36,12 +36,30 @@ Vehicle `MO-016` has two imported overlapping reservations. Expected: visible qu
### S5 — Failed workflow
One seeded outbox/workflow record is failed with a safe simulated connection error. Expected: dashboard and Automation page show it; Operations Manager can retry.
One seeded outbox/workflow record is failed with a safe simulated connection error, coded
`demoScenarioTimeout` so it is recognisable as a prepared scenario rather than a real
incident. Expected: dashboard and Automation page show it, labelled as a prepared demo
scenario; n8n stays "Operational"; the Operations Manager can retry it, after which the
overview reads 20 succeeded and 0 failed. See `docs/11-n8n-integration.md`, "Prepared demo
failure versus real failure".
### S6 — Grounded damage question
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 +67,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.

Some files were not shown because too many files have changed in this diff Show More