Skip to main content

AI features

Measured Cloud uses an LLM for explaining validation results and anomalies, narrating root-cause analysis, and translating natural-language descriptions into custom SQL rules. Driving rule authoring from your profile statistics is done through your own MCP-connected LLM — see Rule suggestions below.

There are two ways to supply that LLM, and you pick between them per organisation (Settings → AI → AI engine):

  • MeasuredCloud AI (native AI) — the deployment runs Google Gemini for you, on Vertex AI, with no API key to manage. This is only offered when the operator has enabled it on the deployment (NATIVE_LLM_ENABLED, see below); on a deployment that hasn't, the engine selector doesn't appear and you're on BYOLLM. When native is enabled it is the default engine for every org — you don't have to configure anything for the AI surfaces to work.
  • BYOLLM ("bring your own LLM") — you configure a provider yourself — OpenAI, Anthropic, Google Gemini (your own Google AI Studio API key), Azure OpenAI, Ollama, or any OpenAI-compatible endpoint — via LLMConfig rows. This is always available, and is the only option on a self-hosted deployment without Vertex AI access. API keys for hosted LLMs are AES-256-GCM-encrypted at rest. (The BYO Google Gemini key uses the Gemini Developer API — Google AI Studio — and is distinct from Native AI, which is managed Gemini on Vertex with no key; see the two rows in the data model below.)

Which engine an org uses (ai_mode). Each org has an ai_mode setting: native, byollm, or unset (auto). Unset defers to the deployment default — native when the operator enabled it, BYOLLM otherwise. Setting it to byollm on a native-enabled deployment makes that org use its own LLMConfig instead (the escape hatch); setting it to native on a deployment where native isn't enabled harmlessly falls back to BYOLLM. So the AI surfaces "just work" with zero configuration on a native deployment, while a self-hoster with no GCP stays entirely on BYOLLM.

Configuring a provider​

This section is about BYOLLM. When MeasuredCloud AI is in effect for your org you don't need any of it: the AI surfaces work out of the box, and Settings → AI folds the provider cards, form and list away behind one Advanced: use your own LLM disclosure. They show in full when the engine is set to BYOLLM or an override config already exists.

Don't confuse this with Use your own AI assistant at the bottom of the same page: that connects an outside assistant such as Claude or Cursor through the MCP server, and doesn't change which engine powers the features in the app.

Settings → AI opens with one-click setup cards for Anthropic, OpenAI, Google Gemini (your API key), Azure OpenAI, and a local Ollama install. Each card lists that provider's supported models, links out to where you get an API key (or, for Ollama, the download page), and shows a Connected check once at least one saved config uses that provider. Clicking Configure opens the add-config form pre-selected to that provider. Any other OpenAI-compatible endpoint is reachable through the form's provider selector (provider = "custom" — see Self-hosted / private LLMs).

On a BYOLLM org with no provider configured yet, the AI surfaces are inert but never dead ends: the Rules card's Describe it to AI control is disabled with an inline link to Settings → AI, and the Suggestions page shows an empty state that explains AI suggestions require a provider and links you to the setup cards. When native AI is in effect those same surfaces are enabled with no LLMConfig at all — the deployment's Gemini engine backs them.

What the LLM is asked to do​

AI-augmented profiling (profile summary)​

Status: shipped (Phase 2) — native, server-side.

From an asset's latest completed profile run, the platform produces an AI profile summary: a plain-language, markdown data-quality assessment of the table plus structured findings — one per notable column, each with a severity (info / warning / critical), a plain-English observation, a suggested check, and PII / freshness / completeness flags. It's grounded entirely in the profiled column statistics (the LLM sees the same null-rates, cardinalities, and sample values the profile recorded — nothing else). It's a read-only artefact: there's nothing to accept.

In the app the summary sits at the top of the asset's page and always generates (a Regenerate button re-runs it). A profile is preferred, not required: with no profile yet the summary is written from the asset's metadata alone (its name, table, source type, description, tags and any column names already known — never invented statistics) and carries a "No profile yet — run a profile for a better summary" note with a Run a profile button. While a profile run is in flight the panel shows "Profiling…" and waits for it. The worker also regenerates the summary in the background each time a profile completes (see Auto-run), so it's usually ready when you open the page. A viewer, who can't generate one, sees the latest summary that was generated.

POST /api/v1/organizations/{org}/workspaces/{ws}/ai/profile-summary (body {"asset_id": …}) enqueues the job and returns a suggestion id; poll GET .../ai-suggestions/{id} until status is completed, then read payload.summary + payload.findings. payload.profile_based is false (and payload.note carries the caveat) when the summary was written without a profile. Add "reuse_existing": true to get back a still-fresh summary — one newer than the asset's latest profile, or one already in flight — instead of paying for a new one.

Rule suggestions​

Status: shipped (Phase 2) — native, server-side. The old in-app rule-generation flow was retired in #133; this is a new capability under a new endpoint and a new suggestion kind (rule_suggestions), not a re-opening of that contract. The MCP-driven authoring path below is still available and unchanged.

Ask the platform to author concrete rule proposals for an asset from its latest profile. The LLM is handed the profiled columns, the asset's existing rules (so it doesn't duplicate them), and the rule gallery (the catalog of rule types + their kwargs schemas), and returns a batch of {rule_type, kwargs, rationale, confidence} proposals. Every proposal is validated server-side against the same gallery kwargs schema and custom-SQL safety rules a hand-authored rule must satisfy — invalid proposals are dropped, never written, so an accepted proposal is guaranteed to pass the same checks as a rule you write by hand.

In the app this runs from the asset's Rules card. For an asset with no rules yet the ✨ Suggested lane generates a batch automatically (the fresh-asset default) — and the worker already starts one in the background when that asset's profile completes (see Auto-run). Once the asset already has rules it does not auto-spend — a Generate rules with AI button opens a small dialog where you can optionally steer the run: free-text guidance ("focus on null / uniqueness checks on id columns") and a severity emphasis. Proposals drain into the rule table — Add drops one in as an editable draft, Accept all adds every surviving proposal — and each new row is persisted with its own Save on the line. A viewer sees the lane too, with its buttons disabled, and it never generates for them.

POST /api/v1/organizations/{org}/workspaces/{ws}/ai/suggest-rules (body {"asset_id": …}, plus an optional "guidance" string — the dialog's composed steering, capped at 1000 chars — threaded into the prompt as a preference the model honours only where the statistics support it) enqueues the job and returns a suggestion id; poll GET .../ai-suggestions/{id} until completed, then read payload.suggestions (and payload.dropped, the count of invalid proposals). Accept the proposals via POST .../ai-suggestions/{id}/accept (body {"data_asset_id": …}, optional: it defaults to the suggestion's own asset), which adds each validated proposal as a rule on the asset. The accepted rules join the asset's default validation, plus any validation_ids you send. The older rule_suite_id body field is deprecated.

Each proposal carries a relative severity (info / warning / critical), folded into its kwargs. When the model gives none, the proposal's severity is null: it is unrated, never silently critical. The Suggested lane shows it as Unrated and makes you pick one before Add or Accept all, and POST .../ai-suggestions/{id}/accept refuses a batch that still holds an unrated proposal (422).

Both new endpoints work under native AI with zero configuration (the worker resolves the deployment's Gemini engine) and under BYOLLM (the org's configured provider), spend the org's LLM budget, are workspace-scoped, gated on can_create_rule (viewers can't fire them), and rate-limited on the shared AI-suggestions per-org bucket. Unlike the Custom SQL endpoint they do not require step-up (fresh-password) auth, so a personal API key — including one driving an MCP client — can call them.

Severity review​

Re-evaluate severities on an asset's Rules card asks the AI to propose a relative severity for each of the asset's existing rules, using the same rubric rule generation uses (a key column's null / uniqueness check is critical, a range on an optional metric is a warning, a cosmetic check is info). It reads the saved rules and, when there is one, the asset's latest profile. The result is a list of current → proposed severities with a one-line reason each. Nothing changes until you apply them, all or some; the apply saves through the ordinary rule edit.

POST /api/v1/organizations/{org}/workspaces/{ws}/ai/severity-review (body {"asset_id": …}) enqueues the job and returns {"data": {"suggestion_id": …}}; poll GET .../ai-suggestions/{id} until completed, then read payload.reviews — one {rule_id, rule_type, kwargs, current_severity, proposed_severity, reason} per rule — and payload.dropped (model entries thrown away). It has no accept endpoint: apply a proposal by editing the rule (PATCH /api/v2/.../data-assets/{asset_id}/rules/{rule_id} with the new kwargs.severity). Same gates as rule generation: can_create_rule, workspace membership, the shared AI-suggestions rate bucket, no step-up. An asset with no rules is a 422.

Validation drafts (generate a validation with AI)​

Status: shipped (validations epic PR 6) — native, server-side.

On the Validations page, Generate with AI drafts a whole validation from a description of what you want checked. Naming the target assets is optional:

  • With assets, the AI proposes rules for exactly those assets. Their latest profiles help, but aren't required.
  • Without assets, the AI first picks the relevant assets from your workspace's catalog: their names, tables, descriptions, tags and column names and types. Sample values are never sent. Then it proposes rules for those assets.

The draft is a name, a one-line description and, per asset, the existing rules to include plus new rules to create. New rules pass the same gallery and custom-SQL checks as rule suggestions, plus a check that each named column exists. Invalid ones are dropped and counted. A proposed rule that checks the same thing as one the asset already has is flagged Already on asset — reused and included instead of being duplicated. An asset that doesn't retain samples sends no sample values to the model.

You review the draft in the modal. You can rename it, edit the description, remove assets, untick rules and re-grade new ones. Nothing is saved until you click Create validation. That single call:

  • creates the validation;
  • creates the new rules, reusing any existing rule that checks the same thing;
  • adds exactly the rules you kept to the validation.

If any part fails, nothing is written. A team-restricted asset makes the validation restricted to that team. Two restricted teams' assets never share a validation: the draft leaves the odd one out with a note, and accepting such a mix is a 422.

The draft runs as a background job, like the other AI suggestions, on an ai_suggestions row (kind = "validation_draft"). It runs with your workspace and team access, so it never sees an asset you can't. It is scoped to your workspace, and to the team when it covers a team-restricted asset. An identical request you made in the last hour that you haven't accepted is reused rather than paid for twice. Generating needs an AI engine (400 otherwise). It counts against the organisation's hourly AI cap (AI_AUTORUN_MAX_JOBS_PER_HOUR, 429 when spent) and the per-minute AI rate limit. Both generating and accepting need the editor tier (can_manage_validation and can_edit_rule); a viewer sees the button disabled.

  • POST /api/v1/organizations/{org}/workspaces/{ws}/ai/validation-drafts with the body {"guidance": "…", "data_asset_ids": [...]} (the asset ids are optional). It returns {"suggestion_id", "reused"}. Poll GET .../ai-suggestions/{id} until the status is completed, then read payload.name, payload.description, payload.items, payload.notes and payload.dropped.
  • POST .../ai/validation-drafts/{id}/accept with the body {"name", "description", "items": [{"data_asset_id", "existing_rule_ids": [...], "new_rules": [{"rule_type", "kwargs"}]}]} creates the validation. It returns validation_id, created_rule_ids and reused_rule_ids.

The MCP server exposes the same pair as mc_validation_generate and mc_validation_draft_accept. See Generate a validation with AI.

MCP-driven rule authoring (alternative)​

Independently of the server-side flow above, you can also drive rule authoring from your own MCP-connected LLM: connect your own LLM client to the hosted MCP server, pull an asset's profile statistics into the model with the rule-suggestion context tool, and have the model draft rules you review and write back through the rules API. This spends your LLM budget (not the org's) and lets you iterate conversationally rather than accepting a one-shot batch.

Earlier suggestions are still listed on the legacy Suggestions page (/suggestions, no longer in the navigation) with accept / dismiss buttons; accept appends the rule(s) to the chosen asset's rules. Bulk accept / dismiss (FEATURE-39) works there too: select multiple rows and accept them onto one asset (or dismiss with an optional reason) in a single call. See Generate rules with AI.

Anomaly explanations​

When you tap an anomaly, the Explain panel asks the LLM to summarise the deviation in plain language ("the row count dropped from a 14-day baseline of 412k to 89k — a 78 % drop classified as critical"). The explanation is cached on the AnomalyEvent after the first generation; a Regenerate button forces a fresh call.

Status: partial (PARITY-9) — UI is in flight; backend hooks are in place.

Result explanation​

A failed validation result's page has an AI explanation card: a plain-English account of which checks failed and what that likely means. The card stays collapsed, but its first lines show in the card header, so you can read it without opening it; expand it for the full text and a Regenerate button. It auto-runs when the result explanation is switched on in auto-run (the default): the worker writes it in the background as the run finishes, so it's usually already there when you open the result, and the page asks for it as it opens if not. With the switch off, an explanation that was already written is still shown; otherwise the card offers Explain with AI. Explanations are cached against their exact prompt, so opening the result again doesn't pay for another one, and Regenerate asks for a fresh one.

Root cause analysis narrative​

On a failed validation result, the Root cause analysis panel ranks likely causes deterministically (no LLM needed), then the LLM narrates the ranked causes in plain English and suggests a next step. The narrative auto-runs when RCA is switched on in auto-run (the default); otherwise a Generate AI summary button requests it. The worker narrates each failed result in the background as the run finishes, and the narrative is cached against its exact prompt, so opening the page doesn't pay for it again. It degrades gracefully to a Settings → AI link when no provider is set. Shipped in FEATURE-410 (first cut).

Failures review (Results list)​

The Results list — the main review screen for failures — opens with a Failures review card. It starts collapsed to one line: how many runs are failing in the current filter slice (date range and the Validation filter) and the top issue (the validation failing most, or the newest failure). Click it to expand the two parts:

  • AI summary — a one- to three-sentence review of the current failures. It leads with the single most impactful problem — the worst-performing validation and its failing dimension — and, when there's room, the recommended next action. It's deliberately terse: the earlier version wrote a multi-sentence "patterns / priority order / next actions" wall that was more than a steward wanted at a glance, so the summary is clamped to a tight headline (#141). It's grounded entirely in the deterministic failures aggregate (failed count, pass rate, the worst validations) plus a small sample of recent failing runs. It auto-runs when the failures review is in the deployment's auto-run set (the default); otherwise it waits for a Generate AI summary click. An unchanged slice is served from a cache, and the worker pre-generates the unfiltered review after a run with failures.
  • Root cause — latest failure — the root cause analysis of the newest failing run in the same slice, with its AI narrative and an Open run link. This part needs no AI engine, so it shows even when no provider is set.

With no failures in view, the card says there's nothing to review. With no AI engine, the summary part is replaced by a Settings → AI link.

Auto-run​

These AI features generate without a click, both when their page opens and in the background when the data they interpret changes, so they're usually ready when you arrive:

FeatureAI_AUTORUN_FEATURES valueBackground trigger
Asset AI summaryasset_summaryA profile completes.
AI rule suggestionsrule_suggestionsA profile completes for an asset with no rules yet.
Failures reviewfailures_reviewA run produces a failed result (one review per workspace, about two minutes after the burst).
Root-cause narrativercaA run produces a failed result.
Result explanationexplainA run produces a failed result.

Settings → AI → Auto-run has a switch per feature. Every member can see them; only a member with the can_manage_llm_configs permission (the roles that manage the AI engine) can change them, and the choice applies to the whole organisation. Until one of them saves a choice, the organisation follows the deployment default, AI_AUTORUN_FEATURES (a comma-separated list; the default is all five, and an empty value turns auto-run off). A switched-off feature still works — its surface falls back to a button.

Spend is bounded three ways: nothing is regenerated while a fresh result exists (a summary newer than the latest profile, a cached narrative for the same prompt); the background jobs are capped per org per hour (AI_AUTORUN_MAX_JOBS_PER_HOUR, default 60 — past it the background job is skipped and the page generates on demand instead); and the page-open requests stay behind the per-org AI rate limit. Background jobs only run when the org has an AI engine (native, or a default BYOLLM config). Results that come back through an on-prem agent get the same background jobs: the API enqueues them when the agent reports a failed result. (Profiling through the agent isn't supported yet, so there's no agent-side summary or rule-suggestion trigger.)

Custom SQL generation​

You describe the rule you want in English; the LLM produces a parameterised SQL query that returns rows where the rule is broken. Shipped in FEATURE-23. The Custom SQL editor on an asset's Rules card exposes a Generate from description button — provide a one-line goal ("orders that were shipped before they were paid for"), pick the target asset, and the LLM returns a {batch}-templated SELECT plus a description string the same validator that fronts hand-written queries will accept. Audit each generation: the LLM doesn't know your schema beyond the column-name list the prompt carries, and the safety allowlist on the resulting SQL is the same one described in Rules → Custom SQL rules.

Data model​

The org's engine choice lives on the organisation itself:

ColumnWhat it stores
organisations.ai_modenative / byollm / NULL (auto). Selects the engine for the org; NULL defers to the deployment default.

LLMConfig (public schema) — the BYOLLM providers:

ColumnWhat it stores
org_idWhich org this config belongs to.
nameA label.
provideropenai / anthropic / google_ai / azure_openai / ollama / custom / gemini. google_ai is a BYO Google Gemini key (Google AI Studio / the Gemini Developer API, api_key-based); gemini is the internal identifier for the native Vertex engine (see the note below).
modelThe model identifier per provider (claude-3-5-sonnet, gpt-4o-mini, etc.).
api_key_encryptedEncrypted bearer credential.
base_urlOverride for self-hosted or Azure endpoints.
is_defaultIf true, used when nothing else is specified.

Native AI has no LLMConfig row — it isn't a per-org credential, it's the deployment's own Gemini-on-Vertex engine, authed by the runtime service account. The gemini provider value in the table above is the internal client identifier the native path also builds against; a BYOLLM org does not create a gemini LLMConfig for native mode.

The two Gemini paths are distinct, and both run on Google's Gen AI SDK:

  • Native Gemini (gemini) — managed, on Vertex AI, authed by the deployment's service account (ADC). No LLMConfig, no key to manage. This is the default engine when the operator enables native AI.
  • BYO Gemini (google_ai) — a LLMConfig row carrying your own Google AI Studio API key. It calls the Gemini Developer API (vertexai=False) with that key, encrypted at rest exactly like the OpenAI / Anthropic keys, and spends your own Gemini quota. Use it when you want Gemini but manage the key yourself, or on a deployment without native AI.

AISuggestion (per-tenant) carries the request and the response — see Anomalies and Rules for the consumer side. Since tenant migration 061 it also carries a workspace_id (stamped from its asset or result; NULL = organisation-wide) and a team_id (set only on a validation draft that covers a team-restricted asset), so a suggestion is only visible where its source is.

Cost controls​

Per-org LLM cost budgets are tracked in HARDEN-25. When that lands, a super admin can set a monthly budget; Measured Cloud refuses further LLM calls once the budget is consumed.

Native AI (Gemini on Vertex) — operator setup​

Native AI is off by default. An operator whose deployment runs on Google Cloud (Vertex AI reachable) turns it on so every org gets AI out of the box without managing a key.

What it needs:

  • NATIVE_LLM_ENABLED=true on the api and worker (the worker is where the AI jobs actually run their completions). This flips the deployment default engine to native; an org can still opt out to BYOLLM per-org.
  • roles/aiplatform.user on the api + worker runtime service accounts. The Google Gen AI SDK authenticates to Vertex with Application Default Credentials — on Cloud Run that's the service account, no API key or key file. Without this role the native calls 403.
  • Project + endpoint for the SDK. The SDK reads GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION. Measured Cloud also derives them at startup from GCP_PROJECT_ID + VERTEX_LOCATION (ensure_vertex_env), so on a self-managed box you can set just the DQ knobs; an explicitly-set GOOGLE_CLOUD_* always wins. VERTEX_LOCATION defaults to global — Gemini 3.x is served on the Vertex global endpoint, and the regional endpoints (e.g. us-central1) return 404 for Gemini 3.x models. Leave it global unless you're pinning an older regional-only model.
  • NATIVE_LLM_MODEL — the Gemini model id, default gemini-3.8-flash (the newest stable Flash model). It's a config value, not a hard-coded constant, on purpose: model ids retire (the Gemini 2.5 ids retire on 2026-10-16), so you bump the id here (an env-var change, no redeploy of new code) rather than shipping a patch. If output cost matters, gemini-3.5-flash-lite is the cost-flat ($0.30/$2.50 per 1M in/out tokens, against 3.8 Flash's $0.75/$3.75) alternative.
  • NATIVE_LLM_MODEL_RCA — the model for the root-cause narrative, default gemini-3.1-pro-preview (the newest Pro model; still a preview id). Set it to an empty string to use NATIVE_LLM_MODEL instead. If the model is retired or not served in your region (a "not found" error), the narrative retries once on NATIVE_LLM_MODEL. NATIVE_LLM_MODEL_INTERPRETATION and NATIVE_LLM_MODEL_SUGGESTION do the same for the explanations / summaries and the rule suggestions; both default to empty (use NATIVE_LLM_MODEL).

See Configuration reference → AI features for the exact variables and defaults.

Self-hosters without Google Cloud simply leave native off and use BYOLLM below — self-hostability is unaffected.

Self-hosted / private LLMs​

You can point Measured Cloud at any OpenAI-compatible endpoint by setting provider = "custom" and a base_url. The most common pattern is Ollama running on a private host (base_url = http://ollama.internal:11434/v1). The traffic stays inside your network; Measured Cloud never proxies LLM calls through itself. This is BYOLLM; it needs no GCP and works on any deployment.