Adds a self-hosted (Ollama, qwen2.5-coder) model provider abstraction with a pluggable opt-in cloud adapter, schema grounding, and a shared cost/safety guard every AI-suggested query is assessed against -- compiling to and executing through the same unchanged Phase 2 IR/ compiler and Phase 4 tenant scoping as a hand-written query, no parallel execution path. Track A (built into the query bar): inline ghost-text autocomplete, "Explain this query", "Fix this query" with a diff view, and a rule-based "Optimize" suggestion. Track B: natural-language-to-query translation, always a separate review step from execution, with `sentryctl query --nl` requiring explicit confirmation to run. Every accepted/dismissed translate-fix-optimize interaction is logged into the same append-only audit_log table Phase 4 built. Two real product bugs were found and fixed via live browser verification (a Svelte effect re-running on every keystroke that silently cancelled the ghost-text debounce; a ghost-text widget positioned at document offset 0 instead of the cursor), and a real costguard logic bug (unbounded-aggregation vs. raw-row) was caught by its own test suite. New integration tests wire a real Ollama client through the real HTTP handler against a mock server matching Ollama's wire contract (hack/mock-ollama), keeping model-quality verification out of CI as a disclosed, periodic human-run check instead. See /docs/phase-7-ai-design.md and /docs/phase-7-runbook.md.
38 KiB
Phase 7 AI design: model provider architecture
Task 2 deliverable — the model-provider abstraction, presented for
review before any implementation is built against it, per the phase
brief's explicit stop point. Nothing past the interface itself
(api/ai/provider/provider.go) has been built yet.
Provider interface
api/ai/provider/provider.go (written, compiles, not yet consumed by
anything). Four operations — Translate, Complete, Explain, Fix —
one Provider interface, same narrow-interface-plus-fake pattern
querylang/executor's SQLRunner/SearchClient already established in
this codebase, so a production implementation and a test fake both
satisfy the same small surface.
Design choices worth calling out explicitly:
- Every result that produces a query returns text, never executes
anything. The package has no dependency on
executororplannerat all — aTranslateResult/FixResult's query text is handed back to the caller, which is responsible for running it through the unchangedplanner.Compile→ (new) cost guard →executor.Executepipeline. This is the mechanical enforcement of the phase's non-negotiable principle: there is no code path by which aProviderimplementation could execute a query itself, because the interface doesn't give it the means to. Confidenceis a three-value enum (high/medium/low), not a raw float. A model's self-reported numeric confidence isn't a calibrated probability; treating it as one (thresholding at some specific float) would be false precision. Three bands are enough to drive real UI behavior — task 10 wants low confidence stated plainly, and aLowband with a requiredLowConfidenceReasonis how that's enforced at the type level rather than left to prompt-following.Completereturns a suggestion, not a full requery. Ghost-text needs exactly the continuation to render after the cursor; making the caller diff the model's output against its own input to find the new part would be fragile and unnecessary.Explainis reused for Track B's "explain the translation," not duplicated.ExplainRequest.OriginalIntentis optional — empty for Track A's "explain this query I wrote" affordance, populated when Track B calls it right afterTranslateto describe how the NL became this query rather than only describing the query in isolation. One operation, two contexts, matching task 10's explicit instruction not to build a second explanation mechanism.Fixnever has a silent-apply path.FixResultis a suggestion + explanation; the diff rendering and accept/dismiss decision belong to the caller (the UI), matching task 7's explicit requirement.- No streaming in this interface, deliberately. A streamed
token-by-token response would help perceived latency for
Completeespecially, but adds real complexity (partial-JSON handling, cancellation semantics) this design doesn't take on in v1. Flagged as a candidate follow-up ifComplete's latency budget (task 5) turns out not to be met by a small model's normal non-streamed response time — not preemptively built.
Primary provider: Ollama, not vLLM
| Ollama | vLLM | |
|---|---|---|
| Hardware floor | Runs on CPU (slow) or a single consumer GPU via quantized (GGUF) models | Needs a real GPU; not practically CPU-viable |
| Deployment complexity | Single binary/container, ollama pull <model>, built-in REST API |
Python server, CUDA/driver management, more moving parts |
| Throughput under concurrency | Adequate for one-user-at-a-time interactive use; not built for high concurrent QPS | Purpose-built for high-throughput serving (continuous batching, PagedAttention) |
| Fit for this project | Matches docker-compose for local/homelab (CLAUDE.md's stated deployment target) — most self-hosters won't have a dedicated inference GPU |
Fits a provisioned-GPU SaaS inference tier — not this phase's target (cloud is the opt-in secondary path, not primary) |
Sentry's actual AI workload shape is one interactive query bar per user
at a time, not a high-QPS inference-serving problem — vLLM's real
advantage (batched throughput at scale) isn't the bottleneck this phase
has. Ollama's lower hardware floor and much simpler operational story
directly serve the "self-hostable, homelab-friendly default" requirement
task 2 sets. Decision: Ollama, reachable over its REST API
(http://localhost:11434 by default), matching every other
external-service integration in this codebase (network boundary, not a
linked library).
Model recommendation
Selection criteria per the brief: license (this project just finished an entire phase auditing for OSI-approved-only licenses — recommending a model under a restricted custom license here would directly contradict that), hardware footprint (must have a genuinely homelab-viable size, not just a large flagship variant), and code/structured-output quality (pipe-syntax generation is closer to code/DSL generation than prose).
Ruled out, with reasons, rather than silently skipped:
- Llama 3.x (Meta): strong quality, but Meta's Llama Community License is a custom license with a usage restriction (a >700M MAU clause) — not OSI-approved open source. Inconsistent with this project's own just-completed license posture.
- Gemma 2/CodeGemma (Google): same shape of problem — a custom license with usage restrictions, not pure Apache/MIT.
- DeepSeek-Coder-V2: strong at code, but its license also carries use restrictions beyond a standard permissive grant.
- StarCoder2 (BigCode): OpenRAIL-M is a "responsible AI license" with behavioral-use restrictions — again not a clean permissive grant.
Recommended: the Qwen2.5-Coder family (Alibaba), Apache-2.0 licensed — genuinely OSI-approved, no usage restrictions, strong benchmarked performance on code/SQL/structured-output generation specifically (not just general chat), and available in a real size range so the hardware floor is a deployment choice, not a fixed cost:
| Model | Approx. footprint (4-bit quantized via Ollama) | Suggested role |
|---|---|---|
qwen2.5-coder:1.5b |
~1-2GB RAM/VRAM, CPU-viable | The fast/small end of task 2's per-operation config (see below) — candidate for Complete's tight latency budget |
qwen2.5-coder:7b |
~5-6GB VRAM recommended, CPU possible but slow | Default recommendation for Translate/Explain/Fix — the balance point between quality and a realistic self-host minimum spec |
qwen2.5-coder:14b / 32b |
~10-20GB+ VRAM | Optional upsell for deployments with more GPU headroom wanting better translation quality; not the default |
Proposed default deployment: qwen2.5-coder:7b for every operation
unless per-operation config (below) is explicitly set otherwise. This is
the one item the brief says needs your confirmation before finalizing,
since it sets the minimum hardware bar every self-hosting operator reads
as "what do I need to run this."
Secondary provider: cloud adapter
A single, vendor-neutral adapter implementing Provider against an
OpenAI-compatible chat-completions HTTP API (covers OpenAI itself and
the several other providers — including some serving open-weight models
— that expose the same wire contract), rather than a bespoke adapter per
vendor. Concretely:
- Off by default, opt-in per-tenant. Enablement is a tenant-level
setting (Phase 4's tenant/org model,
enterprise/'s config surface — the same "core defines the interface, enterprise/ owns tenant-scoped policy over a network call" shapeapi/authz.HTTPAuthorizeralready uses), not a global deployment flag. A single-tenant deployment with noenterprise/configured never has cloud access available at all, matching the "no cloud dependency required for the default deployment" exit criterion. - Visible warning when enabled. The settings UI surface that toggles this (extending Phase 5's Settings page) shows an explicit, un-dismissable-by-default notice that enabling this sends query content to a third-party API — not a one-time toast, a persistent visual indicator wherever cloud is active, so it isn't forgotten after the initial toggle.
- API key stored server-side only (
enterprise/'s existing credential-storage conventions — same posture as notification-target webhook secrets from Phase 3), never exposed to the browser.
This is architecture, not implementation — no code for this adapter is built in this task; it's described here so task 3/4 and the tracks can be designed against a stable shape.
Per-operation provider/model configuration: building it in now
Decision: yes, build the routing layer now, not deferred. Reasoning:
Complete's latency budget is a first-class requirement of task 5
("low enough latency to feel responsive") — the brief itself flags this
as the case where a fast small model matters most. If the interface only
supported one model for every operation, satisfying Complete's latency
target would force either a small model for everything (hurting
Translate/Fix quality) or a large model for everything (breaking
autocomplete's responsiveness) — a real, immediate conflict, not a
hypothetical future one.
What "building it in" actually means, scoped narrowly: a small
config-driven routing table —
map[Operation]ProviderConfig{Provider, Model} — resolved once at
startup, defaulting every operation to the same provider/model unless a
deployment explicitly overrides one. This is a routing/config concern
sitting above the Provider interface (a thin dispatcher choosing
which configured Provider instance to call per operation), not a
change to the interface itself, and not a general multi-model
orchestration system. A deployment that wants one model for everything
sets one config value and never thinks about this again; a deployment
that wants qwen2.5-coder:1.5b for Complete and qwen2.5-coder:7b for
everything else sets two.
Schema grounding (task 3)
Built: api/ai/grounding (core -- Service wraps one executor.SQLRunner,
samples ClickHouse via that runner, caches one provider.SchemaContext
snapshot, refreshed on an interval) and
enterprise/internal/groundingregistry (multi-tenant wiring -- one
cached snapshot per active tenant, all sampled through the same shared
chrunner.Registry, which resolves the actual per-tenant ClickHouse
connection from a context stamped via authz.WithIdentity -- the same
mechanism chrunner's own doc comment names this exact kind of non-HTTP
caller as being for). Sourced by periodic sampling against logs
(service names by frequency, mapKeys(attributes) for common attribute
keys, DISTINCT-with-a-cap per candidate field for enum-like examples)
-- nothing hand-maintained. Tenant scoping is structural: a grounding
query is just another RunSQL call through the exact same tenant-scoped
connection Phase 4 already isolates query execution behind, not a
separate mechanism that could drift out of sync with it.
Delivery mechanism: embedded in-prompt, not retrieved per-query.
| Embedded in-prompt | Retrieved per-query (RAG-style) | |
|---|---|---|
| Latency | One model call, no extra round trip | An added retrieval/ranking step before every call |
| Complexity | Grounding data is just serialized into the request | Needs a relevance-ranking step matching partial/NL input against a larger corpus |
| Fit for this project's scale | A tenant's own service/field vocabulary is small (tens of services, capped at 100 attribute keys) -- full embedding doesn't meaningfully bloat the prompt | Solves a problem (a corpus too large to embed) this project doesn't have yet |
Decision: embed the full (capped) SchemaContext in every operation's
prompt. The latency cost of an extra retrieval step would hit Complete
hardest -- exactly the operation with the tightest budget (task 5) -- for
a problem (prompt bloat from an oversized schema) this project's actual
scale doesn't have. grounding.go's caps (50 services, 100 attribute
keys, 15 of those get real example-value queries, 20 examples max per
field) exist specifically so this stays true even for a tenant with an
unusually sprawling schema. If real usage ever shows a tenant blowing
past these caps in a way that matters, per-query retrieval is the
natural fallback design -- not built now, since nothing today needs it.
Cost/safety guard (task 4)
Flagging this as larger than expected, per the brief's own invitation
to do so: no cost-estimation mechanism existed anywhere in Phase 2/3
before this task. Confirmed by reading the compiler, not assumed --
ir.Plan.TimeRange can be entirely unset (both bounds zero), and
nothing between the planner and ClickHouse rejects that; a bare stats count by host with no earliest= scans the table's full history today.
Building this from scratch, plus deciding how it applies to existing
hand-written queries (not just new AI ones), was real, unplanned design
work beyond "check a number against a threshold."
Built: api/ai/costguard, a pure function (Assess(*ir.Plan) Assessment)
with three levels (ok/warn/reject) and human-readable reasons:
- No time bound + aggregation → reject. An aggregation gets no
implicit row cap the way a raw-row fetch does -- confirmed by reading
executor/sql.go'sbuildSQLdirectly: itsdefaultRowLimit=100safety net only applieselse if plan.Aggregation == nil. An unbounded aggregation must scan every matching row regardless of output size, with nothing downstream capping that scan. - No time bound, no aggregation (raw-row fetch), explicit
Limitor not → warn, not reject, either way. Real bug caught by this package's own tests, not shipped as originally written: an earlier version of this rule treated "no explicitLimit" as automatically worse (reject) than "an explicitLimit" (warn) -- wrong, becausebuildSQLapplies its owndefaultRowLimit=100to any non-aggregation query with no explicitLimit, so both cases already have the exact same real row cap and therefore the same risk level. This is also the common "just show me recent logs" pattern the query language's own documented default (head 100) already treats as normal -- rejecting it outright would have flagged a large fraction of legitimate, currently-working queries, not just a genuinely dangerous new class. - Time range spans over 90 days → warn.
- Raw SQL → a best-effort regex check for a
timestampcomparison anywhere in the text, not a real parse. Explicitly documented as lower-confidence than the IR-based checks, which get a structural guarantee raw SQL fundamentally can't (same reason Phase 2's raw-SQL escape hatch was always opaque to compiler-level enforcement). - 90-day span and every other numeric threshold here are first-pass heuristics, not benchmarked against a production-scale cluster -- this environment's own ClickHouse instance holds nowhere near enough data to validate them against. Flagged plainly rather than presented as tuned.
How the two callers apply it differently, both wired now:
- AI-suggested queries (Translate/Fix output, and the Optimize
suggestion, tracks A/B, not yet built): a
reject-level assessment means the suggestion is not offered as a normal accept-and-run action. This is the mechanism task 4 asked for -- "reject or flag ... before it's ever offered to the user." - The existing
/queryhandler: now runs every query (hand-written or not) through the samecostguard.Assessand surfaces the result as a new, additivewarningsfield on the response (queryapi/handler.go) -- never a block. This is a deliberate interpretation of the phase's design principle ("the same ... cost guardrails as a hand-written query"), decided here rather than left ambiguous: hand-written queries get the identical assessment an AI-generated one would, so there's real parity, but retroactively hard-blocking existing dashboard/sentryctlquery patterns that happen to have no time bound is a behavioral change this phase didn't set out to make and could break real existing usage.warningsisomitempty-- a client that doesn't look for it sees no shape change at all. All existingqueryapitests still pass unmodified.
Decisions confirmed 2026-08-16
All four items below were confirmed as proposed, no changes:
qwen2.5-coder:7bas the default model (Translate/Explain/Fix),qwen2.5-coder:1.5bas the fast-path option forComplete.- Ollama over vLLM as the primary inference runtime.
- The cloud-adapter shape: single OpenAI-compatible adapter, per-tenant opt-in, off by default.
- Per-operation provider/model config built in now, not deferred.
Proceeding to task 3 (schema grounding) and task 4 (cost/safety guard).
Ollama provider implementation (shared foundation, completion)
The last piece of shared foundation before either track: task 2 designed
provider.Provider's shape, but nothing implemented it until now.
Built, tested, all green:
api/ai/provider/ollama-- a thinnet/http+encoding/jsonclient against Ollama'sPOST /api/chat, same shape asalerting/internal/queryclient(this codebase's existing precedent for a small internal HTTP client, no new dependency). Uses Ollama'sformat: "json"constrained-output mode for the three operations that return structured data (Translate/Complete/Fix);Explainasks for prose directly since its result is a single string with nothing else to parse.prompts.go-- each operation's system prompt embeds a condensed copy of/docs/query-language-reference.md's grammar (kept in sync by hand, same as every other place the language is described outside its own parser) plus the caller'sSchemaContext, rendered inline per the embedded-in-prompt decision above.- Handles the common small-model habit of wrapping JSON in a markdown
code fence despite being told not to (
stripCodeFence) -- a best-effort cleanup, not a guarantee; genuinely malformed output still surfaces as a real parse error to the caller rather than being silently papered over. Confidenceparsing fails towardLowon anything unrecognized, never toward assumed correctness -- an empty or garbled confidence field from the model is itself a signal something's off.api/ai/router-- the per-operation dispatch layer task 2 decided to build now: a lookup fromOperationto whicheverprovider.Providerwas configured for it, falling back to one default. A deployment that wants one model for everything configures one; one that wantsqwen2.5-coder:1.5bforCompleteand:7bfor everything else configures two -- exactly the scope described in task 2's writeup, nothing more.
Tested against a real httptest.Server standing in for Ollama's actual
wire contract (request shape, JSON-mode response parsing, the
code-fence-stripping fallback, non-200 error surfacing) -- not just
type-checked. Not yet tested against a real running Ollama server or a
real qwen2.5-coder model in this environment; that's real, disclosed
verification work for /docs/phase-7-runbook.md once there's a live
stack to test against, same "written but not run against the live thing"
caveat this project applies throughout. Nothing in main.go wires any
of this up yet -- that's deferred to when the actual AI HTTP endpoints
(Track A/B) exist to need it, so there's no dead, unconsumed
configuration sitting in a running binary in the meantime.
All shared foundation (tasks 1-4, plus this provider implementation) is
now built and verified: go build/go vet/go test clean across api
and enterprise, hack/check-tenant-boundary.sh still passes. Per the
CHECKPOINT scope discussion, Track A is next.
Track A (tasks 5-8): built and live-verified
Backend: api/ai/aiapi (POST /ai/complete/explain/fix/optimize),
wired into both api/cmd/api and enterprise/cmd/enterprise-api, gated
on OLLAMA_BASE_URL (empty by default -- routes aren't even registered
when unset, matching "no cloud dependency required for the default
deployment" and, by the same reasoning, no forced local model
dependency either). Fix's suggested query and Optimize's mechanical
rewrite both run through costguard.Assess before being returned --
a reject-level assessment sets blocked: true, and the frontend
disables the accept action rather than silently offering it.
Frontend: ghost-text completion built directly into
QueryEditor.svelte on CodeMirror's own primitives (StateField +
Decoration.widget, no new dependency), debounced (300ms) and only
triggered when the cursor is at the document end. Explain/Fix/Optimize
added to QueryBar.svelte (shared by every consumer -- Search page,
dashboard panel editor, alert rule editor -- though only the Search page
was wired with the full errorMessage/warnings props in this pass;
the other two get the feature for free whenever they're updated to pass
them, a non-breaking follow-up, not done here).
Verified live in a real browser, not just type-checked -- a mock
Ollama server (matching its real /api/chat wire contract) run as a
container on the compose network, api rebuilt with OLLAMA_BASE_URL
pointed at it. All four operations confirmed working end-to-end through
actual UI interaction: Explain's modal, Fix's real diff view with a
genuine parse error and a working Accept that replaced the query bar's
content, Optimize's real cost-guard finding (severity=ERROR | stats count by host against the live seeded ClickHouse data, showing the
actual inline warning and populating the Optimize modal with a real
mechanical rewrite), and ghost-text completion rendering and
accepting correctly on Tab. Graceful degradation confirmed too: with the
mock server stopped, ghost text silently doesn't appear (no error), and
Explain shows a plain "not available right now" message instead of
crashing.
Two real bugs found and fixed by this live-verification pass --
neither would have been caught by svelte-check/npm run build, both
type-correct code:
- The CodeMirror view was being destroyed and recreated on every
keystroke.
QueryEditor.svelte's view-creation$effectreadvalue(needed for the initialdoc:content), which made it a reactive dependent ofvalue-- but the editor's ownupdateListenerwritesvalueon every keystroke to keep the bindable prop in sync. Every keystroke therefore re-ran the whole effect, tearing down and rebuilding the entireEditorView. This predates Phase 7 (the pattern existed since Phase 5) but never manifested as a visible symptom until ghost-text's debounce timer gave it something to silently cancel: the effect's cleanup function (clearTimeout(completeTimer); view?.destroy()) fired moments afterscheduleCompletionset the timer, cancelling it before its 300ms elapsed --Completelooked like it was doing nothing, every time. Fixed by wrapping the initialvalueread in Svelte 5'suntrack(), so the effect now genuinely only depends oncontainer(runs once, on mount) -- matching what the component's own second "external sync" effect was already documented as assuming. - Ghost text rendered at the start of the query, not after it. The
Decoration.widgetwas hardcoded at document position0(with a comment reasoning that position didn't matter since ghost text is only ever shown at the document end) -- true for the field's ownnull-vs-suggestion state, but the position still needs to be the current end of document, not literally position 0. Confirmed visually (a screenshot showing the suggestion prepended before typed text, not appended after it). Fixed by storing a positionedDecorationSetdirectly in the state field, computed insideupdate()usingtr.state.doc.length-- the one place the field'supdatefunction has access to the actual current document.
Both fixes are in the same commit-sized unit as the rest of Track A -- no separate patch, since neither bug shipped anywhere before this pass caught it.
Track B (tasks 9-11): built and live-verified
Backend: POST /ai/translate in api/ai/aiapi, same file and same
patterns as Track A's endpoints. Every translation is compiled
(planner.Compile, always planner.SPL -- pipe syntax only, per task
9's "narrower, safer surface" choice) and run through costguard.Assess
before the response goes out, exactly like Fix's suggested query --
task 9's explicit requirement, not a lesser treatment for a different
track. Three honestly-distinct failure shapes, not collapsed into one:
low confidence (query empty, a reason given), a confident answer that
doesn't compile (compiles: false, a real and different outcome from
low confidence -- a model can be sure of itself and still wrong about
syntax), and a confident, compiling answer the cost guard blocks.
Detection mechanism (task 10), decided and documented, not just
implemented: waiting for a parse error, as the task's own phrasing
suggests, would miss the main case this feature exists for. The pipe
grammar's free-text rule means a plain-English question like "show me
errors from the last day" parses successfully -- it becomes a
free-text AND-search for those literal words, not a syntax error, and
silently returns an unhelpful result instead of failing loudly. Built
instead: a cheap client-side heuristic (looksLikeNaturalLanguage in
QueryBar.svelte) that flags text with none of the pipe syntax's
structural markers (|, a comparison operator, :) and four or more
words -- long enough that it's very unlikely to be an intentional short
free-text search, which stays untouched. Confirmed live: a real query
like show me errors from the last day grouped by service correctly
surfaced the "Interpret as natural language" affordance.
Frontend: a review modal (QueryBar.svelte) pre-filled with the current
query bar text (since that's exactly what triggered the detection),
auto-translates on open, shows the generated query in an editable
textarea (task 10's explicit "editable inline" requirement) alongside
an auto-fetched explanation -- reusing Explain via
ExplainRequest.OriginalIntent rather than a separate mechanism, task
10's explicit instruction, already built for Track A's own translation-
review use in the provider interface. "Use this query" inserts into the
query bar and closes the modal; it never runs anything -- the existing
"Run query" button, calling the unchanged runQuery/POST /query, is
the only confirm-to-run action anywhere in this flow, task 9's
non-negotiable separation. A blocked suggestion the user has since
edited in the textarea is treated as their own text, not the original
flagged one -- re-blocking an edit made specifically to address the
concern would be unhelpful, and /query's own warnings field still
assesses whatever they actually end up running regardless.
Verified live end-to-end: typed a natural-language-shaped query into
the bar, clicked the affordance, got a real generated query
(earliest=-1h severity=ERROR | stats count by service | sort -count)
and a real auto-fetched explanation back from the mock provider, clicked
"Use this query" (confirmed it replaced the query bar content without
running anything -- no results table appeared), then manually clicked
"Run query" and confirmed it executed cleanly through the unchanged
/query endpoint. Low-confidence and non-compiling-suggestion rendering
were verified via the Go-level handler tests
(TestHandleTranslateLowConfidenceCarriesReason,
TestHandleTranslateNonCompilingQueryIsHonestlyReported) and code
review rather than a separate live click -- the frontend branch that
renders them is structurally the same conditional-message pattern
already live-verified repeatedly for Explain/Fix/Optimize's own
"unavailable" states, not new untested UI shape.
CLI (task 11): sentryctl query --nl "..." in cli/cmd/sentryctl/cmd_query.go.
Same posture as the UI, enforced identically regardless of how the
result was produced: a low-confidence, non-compiling, or cost-guard-blocked
translation is never run, even with --execute -- confirmed by
TestCmdQueryNLBlockedIsNotRunEvenWithExecute and
TestCmdQueryNLNonCompilingDoesNotRun, which fail the test itself if
/query is ever called in those cases. Without --execute, a real
terminal gets a y/N confirmation prompt; a non-interactive invocation
(piped, scripted, CI) prints the translation and exits without running
rather than hanging on a prompt nobody can answer -- detected via
os.Stdin's ModeCharDevice bit, no new dependency. runAndPrintQuery
is shared between the plain-query path and the post-translation
execute path, so both go through byte-for-byte the same request code
this command already had -- not a parallel implementation.
All three shared-foundation guarantees hold identically for both
tracks, confirmed by inspection of the actual code paths, not asserted:
every generated or suggested query flows through planner.Compile (the
unchanged Phase 2 compiler) and costguard.Assess before a human ever
sees an offer to run it, and actual execution -- web, CLI, or a
hand-written query -- is always the same POST /query handler with the
same tenant-scoped SQLRunner and the same audit-logging hook Phase 4
established. No AI code path constructs a SQLRunner, calls
executor.Execute, or bypasses authz.RequireRoleOrService anywhere in
either track.
Audit logging for AI interactions (task 12): built
Reuses Phase 4's existing audit_log table rather than adding a new
one: that table's detail JSONB column and extensible event_type
CHECK constraint were already designed to carry event shapes other than
"query" (role_change/grant_change/etc. already skip
query_text/row_count/duration_ms in favor of detail) --
ai_interaction (metadata/migrations/0036_add_ai_interaction_event_type.sql)
is the same shape of extension, not a new table/role/trigger set. Same
append-only, hash-chained, audit_writer-role-restricted protections
apply for free.
Scoped to only translate/fix/optimize -- the three flows that
produce a suggestion a user explicitly accepts or dismisses. Deliberately
excludes complete (ghost-text fires on every keystroke pause; logging
each one at the same weight as a deliberate review would drown the
signal) and explain (produces no suggestion to accept/reject, so the
concept doesn't apply). Both exclusions are named design boundaries, not
oversights.
Chose a single frontend-reported event at the moment of a terminal user action (accept-and-use or dismiss/cancel) over a two-phase generation-plus-outcome design correlated by an ID -- simpler, and avoids threading interaction IDs through every generation response just to correlate them later.
api/ai/aiapi.InteractionLogger is a small interface
(LogInteraction(ctx, InteractionEntry) error), nil-by-default on
Handler -- same shape as queryapi.AuditLogger: a single-tenant
deployment with no enterprise/ configured simply doesn't log these,
same as it doesn't log query executions today. POST /ai/log-interaction
is fail-open, same posture as queryapi.Handler's own audit logging: a
write failure is logged server-side and never surfaced to the user who
just clicked a button.
enterprise/internal/audit.AIInteractionLogger
(ai_interaction_adapter.go) is the real implementation, mirroring
QueryAPILogger's exact shape: resolves tenant/user identity from ctx
via authz.IdentityFromContext, refuses to write an unattributable
entry, and writes through the same *Store/pool
enterprise-api/main.go already opens for query auditing (one dedicated
audit_writer-role pool, reused for both loggers). Operation, input,
output, confidence, and the accepted/edited flags go into Detail as
JSON; FinalQuery -- the suggested query, whether or not it was
actually used -- goes into the table's existing QueryText column,
since that's the one field a security reviewer scanning the audit log
would expect to search on directly.
Frontend: web/src/lib/api.ts's logInteraction is fire-and-forget
(.catch(() => {}) at each call site) -- an audit-write failure, like
every other AI-operation failure in this phase, must never block or
surface an error on the UI action that triggered it. Wired into
QueryBar.svelte's acceptFix/dismissFix, acceptOptimize/
dismissOptimize, and useTranslatedQuery/cancelTranslate. Translate
is the one flow with an edit affordance (the generated-query textarea),
so it's the only one where edited can be true -- computed by
comparing the textarea's current content against the original suggested
query at the moment of acceptance, not tracked keystroke-by-keystroke.
Genuinely verified against a live Postgres, not just unit-tested
against a fake InteractionLogger: metadata/migrations/0036 was
applied to the running dev stack's sentry-metadata-postgres
(docker compose up -d --build metadata-migrate, confirmed via \d+ audit_log before/after showing ai_interaction added to the
event_type CHECK constraint), and two new tests in
enterprise/internal/audit/integration_test.go --
TestAIInteractionLoggerWritesAttributedToContextIdentity and
TestAIInteractionLoggerRefusesWithoutIdentity, the same pattern
TestQueryAPILoggerWritesAttributedToContextIdentity already
established -- ran against that real database, through the real
audit_writer-role pool, and passed: a real row lands with
event_type='ai_interaction', query_text carrying FinalQuery, and
detail carrying a JSON blob whose operation/accepted fields
round-trip correctly. This closes the one live-infrastructure gap task
12's backend work would otherwise have shared with Phase 4's own
disclosed "compiles and is unit-tested, never run against a real
database" caveat.
Integration tests and CI testability (task 13)
Before this task, coverage had a real seam nothing exercised: unit tests
work at two separate layers that never actually touch each other in a
test. aiapi/handler_test.go's fakeProvider satisfies
provider.Provider directly, bypassing HTTP, JSON, and prompt
construction entirely; ollama/ollama_test.go exercises
ollama.Client's wire-format parsing against a stub server, but never
through aiapi.Handler's actual registered routes. Neither proves the
seam between them -- a real *ollama.Client, wired through a real
*router.Router into a real *Handler, reached over real HTTP -- was
ever exercised end to end.
api/ai/aiapi/integration_test.go (new) closes that gap: mockOllamaServer
stands in for Ollama's real /api/chat endpoint (matching its wire
contract byte-for-byte, same technique this phase's live browser
verification used, just returning one fixed canned JSON body per test
instead of one selected by inspecting the prompt), wired into a real
ollama.New(...) client, a real router.New(...), and a real
NewHandler(...), then driven by real HTTP requests via httptest.Server
against /ai/translate, /ai/fix, /ai/complete, and
/ai/log-interaction. TestIntegrationTranslateBlockedByCostGuard
specifically proves costguard.Assess is actually reached through the
full HTTP stack for an AI-suggested query (an unbounded aggregation
comes back blocked: true), not just correct in costguard_test.go's
own isolated unit tests.
Why this suite is CI-safe and a real model is not: no live Ollama
server or model weights are needed anywhere in this repo's test suite --
every AI-related test (unit and integration) is deterministic, runs in
milliseconds, and needs no network egress beyond localhost. Testing
against a real Ollama server running the actual pinned
qwen2.5-coder:7b model is deliberately kept out of this suite and
out of CI entirely: a multi-gigabyte model download on every run, no
determinism guarantee even at temperature 0 across Ollama/driver
versions, and minutes of inference time per test would make the whole
suite both slow and flaky in a way that erodes trust in CI failures
generally -- the same "boring, well-understood, and fast" bar this
project already holds its dependencies to. The test pyramid this phase
ends up with:
- Unit tests (existing, unchanged by this task):
costguard,grounding,router,ollama's wire-format parsing,aiapi's handler/routing logic viafakeProvider-- fast, deterministic, no network, all run in CI today. - Integration tests (this task, new): the mock-Ollama-server suite
above, plus
cli/cmd/sentryctl/cmd_query_test.go's existinghttptest.Server-backed coverage of--nl/--execute(already written during Track B, task 11) -- proves the plumbing (HTTP routing, JSON contracts,planner.Compile/costguard.Assessintegration, audit-log dispatch) without needing real model inference. This is what actually runs in CI. - Model-quality verification (not CI, not automated, disclosed as a
deliberate gap rather than silently skipped): whether the actual
pinned model reliably produces valid pipe syntax for realistic
questions, whether its confidence self-reporting is well-calibrated,
whether Explain's prose is actually useful -- these are inherently
non-deterministic, model-quality questions that a wire-contract mock
cannot answer and that would make CI flaky if it tried. This project's
established "actually run it" discipline already produced exactly
this kind of check once (this phase's live browser verification
against
mock_ollama.py, and per/docs/phase-7-runbook.mdonce written, against a real local Ollama); the recommendation is to keep that as a periodic, human-run pre-release checklist item, not a CI gate -- the same posture this repo already takes toward the ClickHouse/Postgres-backed pieces of Phase 4 that only "compile and skip cleanly" in an environment without live infrastructure, rather than pretending a flaky or infeasible-to-automate check is covered when it isn't.
No new frontend test framework (vitest, Playwright, etc.) was introduced
for this task -- web/'s AI-feature verification stays the same live,
manual browser verification already used for Track A/B (see those
sections above), consistent with this project's established frontend
verification discipline rather than adding a new tooling dependency
whose payoff (catching regressions in ghost-text positioning, modal
flows) is already covered by that discipline today.