197 lines
7.5 KiB
Python
197 lines
7.5 KiB
Python
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),
|
|
)
|