feat(ai): Helix Alert Studio message template suggestion - #117
Conversation
Add a propose-only Helix advisor on Alert Studio next to the message template editor. Suggestions are grounded in the selected alert dimensions via draft/validate tools against the existing alertmsg catalog. Apply copies the draft into the editor; Save remains the canonical write path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b104204-65b7-4d04-90a4-e2d5897b8e2e
The Phase-48 identifier gate treats json:"value_min" as a duration-in-minutes field. Keep range bounds in the raw request JSON for the Helix prompt without a ValueMin struct tag on the handler DTO. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b104204-65b7-4d04-90a4-e2d5897b8e2e
|
One or more custom setup steps configured for this repository failed during this Copilot code review run: Setup steps run before each review. If the review above is missing context, or no review was posted at all, the failing step above may be the cause. See the workflow run for failure details, fix your setup steps configuration, and re-request a review. Note You can configure setup steps for Copilot code review separately from Copilot cloud agent with a |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate findings affect route and wiring correctness, validation, request binding, and stale UI proposals.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds an opt-in Helix assistant for Alert Studio that proposes validated notification templates while preserving the existing Save flow.
Changes:
- Adds feature-gated UI, translations, streaming, and Apply behavior.
- Adds guarded API routing, tools, strategy, feature wiring, and registry metadata.
- Adds placeholder extraction, tests, goldens, and canned responses.
File summaries
| File | Summary |
|---|---|
web/src/i18n/en/locale-detail-notifications.json |
Generated notification translations. |
web/src/i18n/en.json |
English source translations. |
web/src/features/notifications/components/AlertMessageEditor.tsx |
Embeds the suggestion card. |
web/src/features/notifications/components/AlertMessageEditor.test.tsx |
Tests editor integration with the AI card. |
web/src/components/ai/AIAlertMessageTemplateSuggestion.tsx |
Implements streaming suggestions and Apply handoff. Moderate (3 votes): scope key omits dimensions. Moderate (1 vote): stale proposals are not reset on body changes. Moderate (1 vote): Apply ignores the disabled state. |
web/src/components/ai/AIAlertMessageTemplateSuggestion.test.tsx |
Tests card behavior and streaming. |
web/src/ai/spaWiring.ts |
Generated frontend wiring metadata. |
web/src/ai/features.ts |
Frontend feature registration. |
internal/api/router.go |
Backend dependency wiring. |
internal/api/aialertmsg/handler.go |
Implements the suggestion SSE handler. Moderate (2 votes): request dimensions are not bound to tool execution. Nit (3 votes): API tracing, error recording, and trace-ID logging are missing. |
internal/api/aialertmsg/handler_test.go |
Tests parsing, gating, and construction. |
internal/api/aialertmsg/doc.go |
Documents the handler package. |
internal/api/ai_routes.go |
Mounts the guarded route. |
internal/alertmsg/formatter.go |
Adds placeholder extraction. |
internal/alertmsg/formatter_test.go |
Tests placeholder extraction. |
internal/ai/tools/alert/message_template.go |
Adds draft and validation tools. Moderate (1 vote): signal operators are not checked against the canonical allowlist. Moderate (1 vote): metric IDs are not checked against the registry. Moderate (1 vote): metric windows and operators are not validated. |
internal/ai/tools/alert/message_template_test.go |
Tests tool behavior and registration. |
internal/ai/tools/alert/doc.go |
Documents the alert tool package. |
internal/ai/strategies/alert-message-template-suggestion/strategy.go |
Defines prompts and allowed tools. |
internal/ai/strategies/alert-message-template-suggestion/strategy_test.go |
Tests strategy contracts. |
internal/ai/strategies/alert-message-template-suggestion/goldens.yaml |
Defines evaluation scenarios. |
internal/ai/strategies/alert-message-template-suggestion/doc.go |
Documents the strategy package. |
internal/ai/strategies/alert-message-template-suggestion/canned/validation_failure_unknown_placeholder.yaml |
Canned validation-failure response. |
internal/ai/strategies/alert-message-template-suggestion/canned/signal_threshold_template.yaml |
Canned signal response. |
internal/ai/strategies/alert-message-template-suggestion/canned/computed_metric_template.yaml |
Canned computed-metric response. |
internal/ai/features/spa_wiring.go |
Backend SPA wiring metadata. Moderate (2 votes): classify the feature as RenderSuggestion and populate the canonical handoff. |
internal/ai/features/registry.go |
Backend feature metadata. Moderate (1 vote): route metadata points to /alerts/studio instead of /notifications/studio. |
Review details
Suppressed comments (6)
internal/ai/features/registry.go:1607
- The registry points this feature at
/alerts/studio, but the SPA registers/notifications/studioand only has the singular/alert-studiolegacy redirect (web/src/App.tsx:586-597). The PR's own registry comment identifies/notifications/studio; leaving this value stale makes the feature's route metadata point to a nonexistent path.
Frontend: []string{"/alerts/studio"},
internal/ai/tools/alert/message_template.go:277
- The signal branch only checks that
opis non-empty, so values such asboguspass both tool schemas and produce a status=ok proposal even though the canonical AlertRule validator rejects them (internal/api/alerts/alert_rules.go:991-1026). Validate the operator against the canonical allowlist before building the rule; otherwise this propose-only endpoint can spend an AI request on dimensions that Alert Studio cannot save.
if rule.Op == "" {
return nil, fmt.Errorf("op is required for kind=signal")
internal/ai/tools/alert/message_template.go:285
- A computed-metric request with an unknown
metric_idis accepted here and then exposed as an allowed placeholder, so the tool can return status=ok for a metric that the canonical validator rejects (validateComputedMetricRulechecks the registry atinternal/api/alerts/alert_rules.go:912-937). Check the selected ID against the computed-metric registry before returning a grounded catalog.
metricID := strings.TrimSpace(in.MetricID)
if metricID == "" {
return nil, fmt.Errorf("metric_id is required for kind=computed_metric")
}
rule.MetricID = &metricID
internal/ai/tools/alert/message_template.go:290
- When
metric_windowormetric_opis supplied, this code accepts any non-empty string instead of checking the metric-specific window and supported computed-metric operators. That lets validation succeed for dimensions the canonical Alert Studio rule path rejects; validate these optional fields whenever present before constructing the rule.
if w := strings.TrimSpace(in.MetricWindow); w != "" {
rule.MetricWindow = &w
}
if op := strings.TrimSpace(in.MetricOp); op != "" {
rule.MetricOp = &op
web/src/components/ai/AIAlertMessageTemplateSuggestion.tsx:162
- This cleanup only runs on unmount because
cancelStreamis stable; it does not clear the locally capturedproposalwhen any draft dimension changes. Even after the stream scope is corrected, a template validated for the previous threshold/signal remains available through Apply in the new editor state. Reset the proposal when the memoized request body changes, while keeping a separate unmount cleanup for cancellation.
useEffect(() => {
return () => {
cancelStream()
setProposal(null)
}
web/src/components/ai/AIAlertMessageTemplateSuggestion.tsx:217
- The parent passes
disabledwhile Alert Studio is saving, but this Apply button ignores it and remains able to callonApplyTemplateduring that mutation. That lets the AI surface modify the editor while the rest of the editor is intentionally locked. Includedisabledin both the button's disabled expression and itsaria-disabledvalue.
disabled={proposal == null || isBusy}
aria-disabled={proposal == null || isBusy ? 'true' : 'false'}
- Files reviewed: 27/27 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| Render: RenderNarrative, | ||
| }, |
| d := dispatch.New(h.tools, prov, denyAllConfirm, h.maxIters) | ||
| userMsg := fmt.Sprintf( | ||
| "Suggest a message template for this alert. "+ | ||
| "kind=%s signal_name=%q op=%q severity=%q metric_id=%q metric_op=%q. "+ | ||
| "Caller JSON (copy numeric operands and range bounds exactly): %s. "+ | ||
| "Call draft_alert_message_template FIRST with these exact dimensions, "+ | ||
| "then compose a template using only allowed_placeholders, "+ | ||
| "then call validate_alert_message_template. "+ | ||
| "Do NOT save the template; the user applies it in Alert Studio.", |
| } | ||
| }, []) | ||
|
|
||
| const scopeKey = `${draft.kind ?? ''}:${draft.signal_name ?? ''}:${draft.op ?? ''}:${draft.metric_id ?? ''}` |
| func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| body, raw, err := parseRequest(r) |
TestHandlerV1Thinness forbids internal/api in handler/v1. Keep the
unwrapped JSON payload (request() does not unwrap {data}) using
encoding/json instead of httpx.WriteJSON.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5b104204-65b7-4d04-90a4-e2d5897b8e2e
|
One or more custom setup steps configured for this repository failed during this Copilot code review run: Setup steps run before each review. If the review above is missing context, or no review was posted at all, the failing step above may be the cause. See the workflow run for failure details, fix your setup steps configuration, and re-request a review. Note You can configure setup steps for Copilot code review separately from Copilot cloud agent with a |
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved issues affect route coverage, request-dimension binding, observability, prompt validation, and frontend state safety.
Review details
Suppressed comments (7)
Previously missed (2) — in code that hasn't changed since the last review.
internal/ai/tools/alert/message_template.go:258
- Both tool paths build the rule entirely from model-supplied dimensions here, while the HTTP handler only places the selected dimensions in prompt text. A model or prompt-injected field can call these tools for a different signal/metric/operator, receive
status=ok, and hand the SPA a template for dimensions the user did not select. Bind the request dimensions in the dispatcher context and reject mismatches (and missing scope) in this shared path, as the scoped AI tools do; prompts alone are not an enforcement boundary.
web/src/components/ai/AIAlertMessageTemplateSuggestion.tsx:217 - The
disabledprop is included incanStart, so Suggest is blocked, but Apply only checksproposalandisBusy. When this editor is rendered disabled, an already-captured proposal can still mutate the form throughonApplyTemplate; includedisabledin the Apply button's disabled and aria-disabled conditions (and guardhandleApply).
internal/ai/features/registry.go:1607
Routes.Frontendis consumed by the AI-off walker, but/alerts/studiois not an SPA route:App.tsxmounts/notifications/studio, while the legacy redirect is/alert-studio. The new feature's coverage visit will therefore land on the NotFound route and contradict the advertised host path; point this entry at/notifications/studio.
Frontend: []string{"/alerts/studio"},
internal/api/aialertmsg/handler.go:128
- This new HTTP handler has no
otel.Tracer("api")span, so its parse/provider/stream/dispatcher failures cannot be recorded on a span or correlated with atrace_idin error logs. Start a span at entry, propagate its context through the stream and dispatcher, record each error, and include the span trace ID in the error logs to match the API observability contract.
// ServeHTTP streams a propose-only template suggestion.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, raw, err := parseRequest(r)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
internal/api/aialertmsg/handler.go:163
- The parser decodes into a typed struct but then appends the entire raw request, including arbitrary unknown keys, to the LLM user message. A caller can add instruction-like fields outside this endpoint's contract and steer the model; reject unknown fields or serialize only the validated
bodybefore building the prompt.
"Caller JSON (copy numeric operands and range bounds exactly): %s. "+
"Call draft_alert_message_template FIRST with these exact dimensions, "+
"then compose a template using only allowed_placeholders, "+
"then call validate_alert_message_template. "+
"Do NOT save the template; the user applies it in Alert Studio.",
web/src/components/ai/AIAlertMessageTemplateSuggestion.tsx:148
scopeKeyonly includes kind, signal_name, op, and metric_id, so changing severity, name, thresholds, value operands, or metric window/op does not reset the in-flight stream or the capturedproposal. A user can therefore change the selected alert dimensions and still apply a template generated for the previous draft. Include the full request body (or otherwise all template dimensions) in the scope identity and clear/cancel the local proposal when it changes.
const scopeKey = `${draft.kind ?? ''}:${draft.signal_name ?? ''}:${draft.op ?? ''}:${draft.metric_id ?? ''}`
web/src/components/ai/AIAlertMessageTemplateSuggestion.tsx:261
- This new component is 261 lines, exceeding the frontend component limit of 200 lines. Extract the proposal preview/apply block or the stream/body helpers into sibling files so the feature remains a thin orchestrator and stays within the repository's decomposition rule.
export const AIAlertMessageTemplateSuggestion = withAiFeature(
'alert-message-template-suggestion',
InnerSection,
)
AIAlertMessageTemplateSuggestion.displayName = 'AIAlertMessageTemplateSuggestion'
- Files reviewed: 28/28 changed files
- Comments generated: 0 new
- Review effort level: Lite
Resolve analysis_handler.go comment conflict; both sides already wrote unwrapped JSON via encoding/json (no httpx). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b104204-65b7-4d04-90a4-e2d5897b8e2e
Summary
Adds an opt-in Helix advisor on
/notifications/studionext to Pick a preset. When the user clicks Ask Helix, it proposes one notification body grounded in the currently selected alert dimensions (kind, signal or computed metric, operator, severity, thresholds).How it works
alert-message-template-suggestion(default off, ADR-015)POST /api/v1/ai/alerts/message-template/draft(404 when AI is off)draft_alert_message_template— same placeholder catalog + related presets as the deterministic editorvalidate_alert_message_template— rejects unknown{{tokens}}and over-long bodiesTest plan
=rule → template names the signal and uses allowed placeholdersPUT/POST /api/v1/alerts/rulesVerification
go testfor tools, strategy, handler, features,internal/apigo run ./tools/aigen+go run ./tools/aivetnpx tsc --noEmitdocker compose build teslasync-api web