Skip to content

Commit 6b986bc

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.114): carve internal/api/aiautomation subpackage
Move ai_automation_handler.go + test (nl-automation-builder) into aiautomation subpkg. Distinct from existing automation subpkg (non-AI). Router uses aiautomation.NewHandler; AIHandlers.Automation field accepts it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 97dc24e commit 6b986bc

4 files changed

Lines changed: 89 additions & 53 deletions

File tree

internal/api/aiautomation/doc.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Package aiautomation owns the AI automation builder HTTP handler and its
2+
// nl-automation-builder graph validator.
3+
//
4+
// Layer: handler
5+
package aiautomation
Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
package api
1+
package aiautomation
22

33
// Phase-50 / 0016 — N2 Natural-language automation builder.
44
//
5-
// ai_automation_handler.go implements the LLM-backed handler at
5+
// handler.go implements the LLM-backed handler at
66
// POST /api/v1/ai/automations/draft. The flow mirrors the
77
// nl-alert-builder handler from slice 0015 — same dispatch+stream
88
// loop, same propose-only contract, no persistence (one-shot
@@ -53,6 +53,7 @@ package api
5353

5454
import (
5555
"bytes"
56+
"context"
5657
"encoding/json"
5758
"fmt"
5859
"net/http"
@@ -67,39 +68,40 @@ import (
6768
"github.com/ev-dev-labs/teslasync/internal/ai/stream"
6869
"github.com/ev-dev-labs/teslasync/internal/ai/tools"
6970
apiautomation "github.com/ev-dev-labs/teslasync/internal/api/automation"
71+
apihttpx "github.com/ev-dev-labs/teslasync/internal/api/httpx"
7072
tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
7173
)
7274

73-
// aiAutomationBuilderMaxIterations bounds the dispatcher's tool-loop.
75+
// builderMaxIterations bounds the dispatcher's tool-loop.
7476
// The nl-automation-builder strategy is a two-tool sequence (draft,
7577
// then validate), with at most one retry if validate rejects the
7678
// first draft — a hard ceiling of 6 is generous for an LLM that
7779
// occasionally re-drafts twice before settling. Mirrors
7880
// aiAlertBuilderMaxIterations from slice 0015.
79-
const aiAutomationBuilderMaxIterations = 6
81+
const builderMaxIterations = 6
8082

81-
// aiAutomationBuilderMaxPromptChars bounds the user-supplied
83+
// builderMaxPromptChars bounds the user-supplied
8284
// natural-language prompt at the HTTP boundary. Generous for a
8385
// multi-sentence rule description; defensive against an enormous
8486
// payload that would inflate the LLM's context window cost without
8587
// any plausible legitimate use.
86-
const aiAutomationBuilderMaxPromptChars = 4096
88+
const builderMaxPromptChars = 4096
8789

88-
// AIAutomationHandler is the HTTP handler for
90+
// Handler is the HTTP handler for
8991
// POST /api/v1/ai/automations/draft.
9092
//
9193
// Stateless beyond its constructor inputs; safe for concurrent use
9294
// across requests. Construction is in router.go so the dispatcher's
9395
// tool registry + provider registry are wired once at boot.
94-
type AIAutomationHandler struct {
96+
type Handler struct {
9597
registry *provider.Registry
9698
tools *tools.Registry
9799
strategy strategy.Strategy
98100
headerName string
99101
maxIters int
100102
}
101103

102-
// NewAIAutomationHandler constructs the handler. All non-pointer
104+
// NewHandler constructs the handler. All non-pointer
103105
// arguments are required; the constructor panics on a nil so the
104106
// wiring bug surfaces at boot, not at first request.
105107
//
@@ -111,37 +113,37 @@ type AIAutomationHandler struct {
111113
//
112114
// strat: the nl-automation-builder Strategy (one per process).
113115
// headerName: forward-auth header name; used to extract subject for audit.
114-
func NewAIAutomationHandler(
116+
func NewHandler(
115117
registry *provider.Registry,
116118
toolReg *tools.Registry,
117119
strat strategy.Strategy,
118120
headerName string,
119-
) *AIAutomationHandler {
121+
) *Handler {
120122
switch {
121123
case registry == nil:
122-
panic("api: NewAIAutomationHandler: nil provider.Registry")
124+
panic("aiautomation: NewHandler: nil provider.Registry")
123125
case toolReg == nil:
124-
panic("api: NewAIAutomationHandler: nil tools.Registry")
126+
panic("aiautomation: NewHandler: nil tools.Registry")
125127
case strat == nil:
126-
panic("api: NewAIAutomationHandler: nil strategy.Strategy")
128+
panic("aiautomation: NewHandler: nil strategy.Strategy")
127129
}
128-
return &AIAutomationHandler{
130+
return &Handler{
129131
registry: registry,
130132
tools: toolReg,
131133
strategy: strat,
132134
headerName: headerName,
133-
maxIters: aiAutomationBuilderMaxIterations,
135+
maxIters: builderMaxIterations,
134136
}
135137
}
136138

137-
// aiAutomationBuilderRequest is the wire shape for
139+
// builderRequest is the wire shape for
138140
// POST /api/v1/ai/automations/draft.
139141
//
140142
// VehicleID is required and must be > 0 — the AI handler scopes the
141143
// drafting to a single vehicle. Prompt is the user's plain-language
142144
// description of the automation they want, capped at
143-
// aiAutomationBuilderMaxPromptChars.
144-
type aiAutomationBuilderRequest struct {
145+
// builderMaxPromptChars.
146+
type builderRequest struct {
145147
VehicleID int64 `json:"vehicle_id"`
146148
Prompt string `json:"prompt"`
147149
}
@@ -151,9 +153,9 @@ type aiAutomationBuilderRequest struct {
151153
// dispatcher's deferred WriteDone. Every error path either writes a
152154
// structured frame onto the SSE stream (when the writer has been
153155
// opened) or a plain JSON 4xx/5xx (before it has).
154-
func (h *AIAutomationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
156+
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
155157
// 1) Decode + validate request body.
156-
var body aiAutomationBuilderRequest
158+
var body builderRequest
157159
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
158160
writeError(w, http.StatusBadRequest, "invalid request body")
159161
return
@@ -167,8 +169,8 @@ func (h *AIAutomationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
167169
writeError(w, http.StatusBadRequest, "prompt is required")
168170
return
169171
}
170-
if len(prompt) > aiAutomationBuilderMaxPromptChars {
171-
writeError(w, http.StatusBadRequest, fmt.Sprintf("prompt must be at most %d characters", aiAutomationBuilderMaxPromptChars))
172+
if len(prompt) > builderMaxPromptChars {
173+
writeError(w, http.StatusBadRequest, fmt.Sprintf("prompt must be at most %d characters", builderMaxPromptChars))
172174
return
173175
}
174176

@@ -255,31 +257,40 @@ func (h *AIAutomationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
255257
}
256258
}
257259

258-
// Compile-time assertion: AIAutomationHandler satisfies http.Handler.
259-
var _ http.Handler = (*AIAutomationHandler)(nil)
260+
// Compile-time assertion: Handler satisfies http.Handler.
261+
var _ http.Handler = (*Handler)(nil)
260262

261-
// AIAutomationGraphValidator is the production implementation of
263+
func writeError(w http.ResponseWriter, status int, msg string) {
264+
apihttpx.WriteError(w, status, msg)
265+
}
266+
267+
// denyAllConfirm rejects every mutating tool as defence-in-depth.
268+
func denyAllConfirm(_ context.Context, _ dispatch.ConfirmRequest) (dispatch.ConfirmDecision, error) {
269+
return dispatch.ConfirmDenied, nil
270+
}
271+
272+
// GraphValidator is the production implementation of
262273
// automationtool.AutomationGraphValidator. It is a thin wrapper around the
263274
// automation subpackage's canonical typed payload validator so the AI tool
264275
// registration path can wire the same validation path used by the manual
265276
// save endpoint.
266277
//
267278
// One per process; stateless.
268-
type AIAutomationGraphValidator struct{}
279+
type GraphValidator struct{}
269280

270-
// NewAIAutomationGraphValidator returns a ready-to-use validator
281+
// NewGraphValidator returns a ready-to-use validator
271282
// wrapper. Exists as a constructor (rather than a value) so a future
272283
// change that adds wiring (e.g. a custom signal allowlist) does not
273284
// force every call site to update.
274-
func NewAIAutomationGraphValidator() *AIAutomationGraphValidator {
275-
return &AIAutomationGraphValidator{}
285+
func NewGraphValidator() *GraphValidator {
286+
return &GraphValidator{}
276287
}
277288

278289
// ValidateAutomationWire implements [automationtool.AutomationGraphValidator].
279290
// Delegates to the canonical automation input validator — same code path the
280291
// POST /api/v1/automations handler runs, so a draft accepted here is
281292
// byte-equivalent to a draft accepted by the canonical handler.
282-
func (v *AIAutomationGraphValidator) ValidateAutomationWire(wireJSON json.RawMessage) error {
293+
func (v *GraphValidator) ValidateAutomationWire(wireJSON json.RawMessage) error {
283294
if len(wireJSON) == 0 {
284295
return fmt.Errorf("empty wire payload")
285296
}

internal/api/ai_automation_handler_test.go renamed to internal/api/aiautomation/handler_test.go

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
// F6 eval harness (`go run ./cmd/ai-eval --feature nl-automation-builder`);
1414
// duplicating that here would require a live database fixture.
1515

16-
package api
16+
package aiautomation
1717

1818
import (
19+
"context"
1920
"encoding/json"
2021
"fmt"
2122
"net/http"
@@ -28,6 +29,24 @@ import (
2829
"github.com/ev-dev-labs/teslasync/internal/ai/guard"
2930
)
3031

32+
// stubGuardSettings is a minimal in-memory guard.Settings used to
33+
// drive the off-mode contract test without a real DB.
34+
type stubGuardSettings struct {
35+
mode string
36+
on map[string]bool
37+
}
38+
39+
func (s *stubGuardSettings) AIMode(_ context.Context) (string, error) {
40+
if s.mode == "" {
41+
return "off", nil
42+
}
43+
return s.mode, nil
44+
}
45+
46+
func (s *stubGuardSettings) AIFeatureEnabled(_ context.Context, id string) (bool, error) {
47+
return s.on[id], nil
48+
}
49+
3150
// TestNLAutomationBuilderAIOffHidesPanelAndManualBuilderWorks is the
3251
// load-bearing off-mode contract proof for slice 0016. It mounts the
3352
// AI automation builder route through the guard with ai_mode='off'
@@ -136,35 +155,35 @@ func TestNLAutomationBuilderAIOffHidesPanelAndManualBuilderWorks(t *testing.T) {
136155
}
137156
}
138157

139-
// TestAIAutomationHandler_PanicsOnNilWiring asserts the handler
158+
// TestHandler_PanicsOnNilWiring asserts the handler
140159
// constructor refuses zero-valued dependencies. A wiring bug at boot
141160
// must surface as a panic, not as a nil-deref on first request.
142-
func TestAIAutomationHandler_PanicsOnNilWiring(t *testing.T) {
161+
func TestHandler_PanicsOnNilWiring(t *testing.T) {
143162
t.Parallel()
144163
cases := []struct {
145164
name string
146165
fn func()
147166
}{
148-
{"all nil", func() { NewAIAutomationHandler(nil, nil, nil, "") }},
167+
{"all nil", func() { NewHandler(nil, nil, nil, "") }},
149168
}
150169
for _, tc := range cases {
151170
t.Run(tc.name, func(t *testing.T) {
152171
defer func() {
153172
if r := recover(); r == nil {
154-
t.Fatalf("NewAIAutomationHandler(%s) did not panic", tc.name)
173+
t.Fatalf("NewHandler(%s) did not panic", tc.name)
155174
}
156175
}()
157176
tc.fn()
158177
})
159178
}
160179
}
161180

162-
// TestAIAutomationHandler_RejectsBadInput asserts the handler
181+
// TestHandler_RejectsBadInput asserts the handler
163182
// validates the request body BEFORE opening the SSE stream — a
164183
// missing vehicle_id, missing prompt, or oversized prompt must
165184
// surface as a JSON 400, not a half-opened stream that confuses the
166185
// frontend.
167-
func TestAIAutomationHandler_RejectsBadInput(t *testing.T) {
186+
func TestHandler_RejectsBadInput(t *testing.T) {
168187
t.Parallel()
169188

170189
cases := []struct {
@@ -179,7 +198,7 @@ func TestAIAutomationHandler_RejectsBadInput(t *testing.T) {
179198
{"missing prompt", `{"vehicle_id":1}`},
180199
{"empty prompt", `{"vehicle_id":1,"prompt":""}`},
181200
{"whitespace prompt", `{"vehicle_id":1,"prompt":" "}`},
182-
{"prompt too large", fmt.Sprintf(`{"vehicle_id":1,"prompt":%q}`, strings.Repeat("a", aiAutomationBuilderMaxPromptChars+1))},
201+
{"prompt too large", fmt.Sprintf(`{"vehicle_id":1,"prompt":%q}`, strings.Repeat("a", builderMaxPromptChars+1))},
183202
}
184203
for _, tc := range cases {
185204
t.Run(tc.name, func(t *testing.T) {
@@ -193,16 +212,16 @@ func TestAIAutomationHandler_RejectsBadInput(t *testing.T) {
193212
}
194213
}
195214

196-
// TestAIAutomationHandler_AcceptsCanonicalInput proves the validator
215+
// TestHandler_AcceptsCanonicalInput proves the validator
197216
// does NOT bounce the happy-path shapes — vehicle_id + a
198217
// normal-length prompt, plus a prompt at the size boundary.
199-
func TestAIAutomationHandler_AcceptsCanonicalInput(t *testing.T) {
218+
func TestHandler_AcceptsCanonicalInput(t *testing.T) {
200219
t.Parallel()
201220

202221
cases := []string{
203222
`{"vehicle_id":1,"prompt":"start charging when I get home"}`,
204223
`{"vehicle_id":1,"prompt":"a"}`,
205-
fmt.Sprintf(`{"vehicle_id":1,"prompt":%q}`, strings.Repeat("a", aiAutomationBuilderMaxPromptChars)),
224+
fmt.Sprintf(`{"vehicle_id":1,"prompt":%q}`, strings.Repeat("a", builderMaxPromptChars)),
206225
}
207226
for _, body := range cases {
208227
t.Run(body[:min(len(body), 60)], func(t *testing.T) {
@@ -216,17 +235,17 @@ func TestAIAutomationHandler_AcceptsCanonicalInput(t *testing.T) {
216235
}
217236
}
218237

219-
// TestAIAutomationGraphValidator_DelegatesToCanonical asserts the
238+
// TestGraphValidator_DelegatesToCanonical asserts the
220239
// production wrapper delegates to the canonical
221240
// decodeAutomationInputDTO function — same code path the typed
222241
// handler uses, so a draft accepted by the AI tool is byte-equivalent
223242
// to a draft accepted by the canonical handler. We pass an
224243
// obviously-bad payload and confirm the wrapper surfaces the
225244
// canonical layer's diagnostic; we then pass a known-good payload
226245
// and confirm acceptance.
227-
func TestAIAutomationGraphValidator_DelegatesToCanonical(t *testing.T) {
246+
func TestGraphValidator_DelegatesToCanonical(t *testing.T) {
228247
t.Parallel()
229-
v := NewAIAutomationGraphValidator()
248+
v := NewGraphValidator()
230249

231250
if err := v.ValidateAutomationWire(nil); err == nil {
232251
t.Error("ValidateAutomationWire(nil) err = nil, want non-nil")
@@ -251,11 +270,11 @@ func TestAIAutomationGraphValidator_DelegatesToCanonical(t *testing.T) {
251270
}
252271

253272
// validateAutomationBuilderOnly mirrors the pre-stream validator
254-
// branch of AIAutomationHandler.ServeHTTP. Kept as a same-package
273+
// branch of Handler.ServeHTTP. Kept as a same-package
255274
// helper so the test does not need to construct a full handler with
256275
// stub deps.
257276
func validateAutomationBuilderOnly(w http.ResponseWriter, r *http.Request) {
258-
var body aiAutomationBuilderRequest
277+
var body builderRequest
259278
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
260279
writeError(w, http.StatusBadRequest, "invalid request body")
261280
return
@@ -269,8 +288,8 @@ func validateAutomationBuilderOnly(w http.ResponseWriter, r *http.Request) {
269288
writeError(w, http.StatusBadRequest, "prompt is required")
270289
return
271290
}
272-
if len(prompt) > aiAutomationBuilderMaxPromptChars {
273-
writeError(w, http.StatusBadRequest, fmt.Sprintf("prompt must be at most %d characters", aiAutomationBuilderMaxPromptChars))
291+
if len(prompt) > builderMaxPromptChars {
292+
writeError(w, http.StatusBadRequest, fmt.Sprintf("prompt must be at most %d characters", builderMaxPromptChars))
274293
return
275294
}
276295
w.WriteHeader(http.StatusOK)

internal/api/router.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
apiadminfb "github.com/ev-dev-labs/teslasync/internal/api/adminfeedback"
1818
apiadminls "github.com/ev-dev-labs/teslasync/internal/api/adminlogstream"
1919
apiadminmnt "github.com/ev-dev-labs/teslasync/internal/api/adminmaintenance"
20+
aiautomation "github.com/ev-dev-labs/teslasync/internal/api/aiautomation"
2021
apialertmsg "github.com/ev-dev-labs/teslasync/internal/api/alertmsg"
2122
apialerts "github.com/ev-dev-labs/teslasync/internal/api/alerts"
2223
apianalytics "github.com/ev-dev-labs/teslasync/internal/api/analytics"
@@ -857,16 +858,16 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
857858
cfg.Auth.ForwardAuthHeader,
858859
)
859860
// Phase-50 / N2 (slice 0016) nl-automation-builder. Mirrors the
860-
// alert-builder wiring above. AIAutomationGraphValidator is a
861+
// alert-builder wiring above. aiautomation.GraphValidator is a
861862
// thin wrapper around the automation subpackage validator in
862863
// internal/api/automation/decode.go — same code path the canonical
863864
// POST /api/v1/automations handler uses. Drafts
864865
// accepted by the AI tool are byte-equivalent to drafts accepted
865866
// by the canonical handler (ADR-015 §I3 baseline-intact).
866867
automationtool.RegisterAutomationBuilderTools(aiToolRegistry, automationtool.AutomationBuilderSources{
867-
Validator: NewAIAutomationGraphValidator(),
868+
Validator: aiautomation.NewGraphValidator(),
868869
})
869-
aiAutomationHandler := NewAIAutomationHandler(
870+
aiAutomationHandler := aiautomation.NewHandler(
870871
aiRegistry,
871872
aiToolRegistry,
872873
nlautomationbuilder.New(),
@@ -1813,7 +1814,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
18131814
// handler. The strategy REUSES the slice-0016
18141815
// nl-automation-builder tool pair (draft_automation_graph +
18151816
// validate_automation_graph) registered earlier in this file
1816-
// for the AIAutomationHandler — re-registering would panic on
1817+
// for the aiautomation.Handler — re-registering would panic on
18171818
// duplicate name. The handler ALSO needs read access to the
18181819
// user's existing geofence catalog so it can inject a
18191820
// deterministic id+name+category list into the synthesised

0 commit comments

Comments
 (0)