/(console) routes in the frontend repo) never computes an insight on request. Every report a tenant user sees — loops, issues, severity, fishbone causes, LLM narratives — is precomputed by a background job into a Postgres cache table (insight_collections, tenant-schema) and the console only ever reads that cache. This page covers the read contract (GET /insights/snapshot), the job that fills it (materialize_insights), and how the four report-backed views (Home, Loops, Issues, Remediation) consume the result.
The ten collections
packages/causegraph/src/causegraph/onboarding/insights.py defines two groups, and services/api/insights_api.py imports both:
model_checkpoints row and at least one committed dataset, no LLM required:
LLM collections are generated per theme by the tenant’s configured LLM provider (
llm_settings) and are skipped, not failed, when no provider is configured:
cause_remediation is computed and stored like every other collection, but insights_api.py deliberately excludes it from GET /insights/snapshot’s response (_EXPORT_ONLY_COLLECTIONS) — it feeds the branded XLSX export only. The console still reads its content indirectly: the export uses load_visible_snapshot() to include it, and the frontend’s own client-adapter.ts on the browser side does read a cause_remediation key from the raw snapshot response for CAPA rationale/evidence — but the deployed contract (below) only ever hands the browser 9 collections, so that field is always absent on the wire today.GET /insights/snapshot
Defined in services/api/insights_api.py, requiring an authenticated tenant session. This is the only insights endpoint in the deployed product contract — services/api/product_app.py’s INSIGHT_OPERATIONS allowlist admits exactly ("GET", "/insights/snapshot"). The router also implements GET /insights/{collection} (single-collection read, 202 while generating) and GET /insights (per-collection ready/not-ready status used by the lock-screen decision), but neither is in the current allowlist and neither is called by the shipped console — the frontend’s fetchClientInsights() (src/lib/api/clientInsights.ts) only ever calls /insights/snapshot.
status per collection is one of:
ready— a row exists ininsight_collectionsfor the chosen(dataset_version, model_version)pair;payloadis populated.generating— no row exists yet for this collection at the chosen version pair. This covers any of the 5MODEL_COLLECTIONSbefore the firstmaterialize_insightsrun lands (a fresh tenant’ssummaryreportsgenerating, not absent), and anyLLM_COLLECTIONSentry whose provider is configured but hasn’t produced that collection yet (or is mid-run).disabled— an LLM collection with nollm_settingsrow, or an empty/disabledprovider, for this tenant.payloadis alwaysnullhere; the console renders a fixed placeholder string instead (“Narrative enrichment disabled.”, “No generated recommendation.”) rather than an empty state.
model_ready is true once all 5 MODEL_COLLECTIONS have a row; all_ready is true once every requested collection is anything other than generating (i.e. ready or disabled both count as settled).
One coherent snapshot per read
load_visible_snapshot() — the function both /insights/snapshot and the remediation export share — first picks a single (dataset_version, model_version) pair (the newest one with at least one client_visible row) and only then reads every collection for that exact pair. This is deliberate: an older per-collection “latest” read could combine a themes collection from one materialize run with a fishbone collection from a different, later run — silently showing loops that don’t match the fishbone under them. Picking the version pair up front makes that impossible; a page load is always looking at output from one pipeline run.
client_visible and share_onboarding_outputs
Every insight_collections row carries a client_visible boolean and a source ("ingest", "onboarding", or "manual"). The snapshot query filters to client_visible = true, and additionally excludes source = "onboarding" rows unless the tenant’s share_onboarding_outputs flag is on:
- Rows from a real post-go-live ingest (
source = "ingest") are alwaysclient_visible— a live tenant always sees its own new data. - Rows produced during onboarding (
source = "onboarding") are gated byshare_onboarding_outputs, a per-tenant setting the staff onboarding flow controls (see Client onboarding pipeline) — some tenants are deliberately onboarded with training output hidden until formal go-live.
The materialize_insights job
services/pipeline/materialize.py’s materialize_insights(tenant, dataset_id, dataset_version, source, client_visible) is the only code path that ever writes insight_collections. It:
- Loads the tenant’s active
model_checkpointsrow (raisesNoActiveCheckpointErrorif none exists). - Loads every
DatasetRecordrow for the givendataset_id. - Computes and upserts all 5
MODEL_COLLECTIONSunconditionally, viacompute_model_collection_from_data()(the same storage-agnostic function the file-based research workbench uses). - Loads the tenant’s
llm_settings. If none is configured (or the model field is empty), everyLLM_COLLECTIONSentry is recorded as skipped with a reason (“No LLM configured for this tenant.”) and the function returns — the model collections are already written, so the portal is usable either way. - Otherwise, computes and upserts each of the 5
LLM_COLLECTIONSin turn viacompute_llm_collection_from_data(), catchingLlmUnavailableErrorper-collection so one unreachable provider call doesn’t take down the other four.
(tenant_id, collection, model_version, dataset_version) — re-materializing the same version pair overwrites in place rather than accumulating duplicate rows.
When a snapshot refreshes
materialize_insights is enqueued (never run inline) from three places, each with its own idempotency_key so a retried request can’t double-enqueue the same logical job:
- A source sync or file upload commits a new dataset version (
services/ingest/pipeline.py) —client_visibleis set totenant.lifecycle_state == "live",source="ingest". - Staff activates a model checkpoint (
POST /admin/tenants/{tenant_id}/activate,services/api/onboarding_api.py) — re-materializes the latest dataset against the newly active checkpoint,source="onboarding",client_visiblefrom the request body’sshare_onboarding_outputs. - The onboarding ingest-commit flow (
POST /admin/tenants/{tenant_id}/ingest/commit,services/api/onboarding_api.py) — committing a staged upload into a new dataset version,source="onboarding",client_visiblefrom the tenant’s currentshare_onboarding_outputsvalue,idempotency_key f"materialize:{tenant.id}:{dataset_id}:{next_version}".POST /admin/tenants/{tenant_id}/trainonly enqueues a separatetrain_tenant_modeljob (services/pipeline/training_job.py) and never touchesmaterialize_insights.
Console views and their collections
All four report-backed views (src/components/views/{Home,Loops,Issues,Remediation}View.tsx) call the same useConsoleData() hook (src/lib/data/useConsoleData.ts), which wraps fetchClientInsights() and — once modelReady is true — runs adaptClientInsights() (src/lib/data/client-adapter.ts) to turn the 9 raw collections into one ConsoleData object every view shares. No view fetches a collection directly.
Every view is built to tolerate the 4 LLM collections still
generating once the 5 model collections are ready: adaptClientInsights() returns a pending: CollectionName[] list, and dependent fields fall back to fixed placeholder strings ("Summary generating…", "Recommendation generating…") rather than blank space.
Loading, locked, and error states
useConsoleData() returns one of four statuses, and every report-backed view switches on all of them before rendering its normal body:
loading— the query is pending, or the model collections aren’t ready yet. Views renderViewSkeleton(src/components/shell/Skeletons.tsx), a layout-matching shimmer so the loading→ready transition doesn’t jump the page.locked— a real tenant onboarded withshare_onboarding_outputsoff, with nothingclient_visibleyet. RendersLockView(src/components/shell/LockView.tsx): “Training outputs are hidden for this workspace,” with a single CTA to/sources— deliberately no self-service “run analysis” action, since this tenant was never meant to trigger the pipeline itself.ready—data, optionalpendingcollection names, and optionalnarrativeare available; the view renders normally.error— a genuine (non-404) fetch or parse failure surviving TanStack Query’s retries, kept deliberately distinct from “no report yet” so a real backend outage doesn’t read as an empty workspace. RendersErrorState(src/components/shell/ErrorState.tsx) with a “Retry” button wired to the hook’s ownrefetch.
"empty" status in the current type (ConsoleDataResult’s own comment notes the brief specified loading | empty | ready, but a fetch failure needed its own state, and locked was added for the real, non-demo case) — a tenant with genuinely zero data but share_onboarding_outputs on simply stays in loading until the first materialize_insights run lands.
Theme toggle
Independent of any of the above:src/lib/stores/theme.ts is a small Zustand store (useTheme) holding "light" | "dark", defaulting to "light" on every render (including SSR) for hydration safety. ThemeToggle (src/components/shell/ThemeToggle.tsx) calls toggleTheme(), which flips document.documentElement.dataset.theme and persists the choice to localStorage (cl-theme) — wrapped in a try/catch since private-mode or quota-restricted storage can throw. ThemeInit reads the persisted value client-side, after mount, and applies it once; there is no flash-of-wrong-theme guard beyond that.