Skip to content

Commit d5036b9

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.151): carve internal/api/aigeofautom subpackage
Move the geofence-aware automation AI handler and tests into internal/api/aigeofautom, rename the public handler surface to Handler/NewHandler, and wire router.go through the new package. Keep the aiGeofenceAwareAutomationHandler router variable and AIHandlers.GeofenceAwareAutomation field unchanged while adding package docs for the arch gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent edf40c1 commit d5036b9

5 files changed

Lines changed: 102 additions & 66 deletions

File tree

internal/ai/strategies/geofence-aware-automation-suggestions/strategy.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
// flow through tools, narrative is identifier-free.
2626
//
2727
// The strategy is consumed by the AI HTTP handler at
28-
// `internal/api/ai_geofence_aware_automation_handler.go` which
29-
// builds a dispatcher, a stream.Writer (SSE), and runs a one-shot
28+
// `internal/api/aigeofautom/handler.go` which builds a dispatcher,
29+
// a stream.Writer (SSE), and runs a one-shot
3030
// generation loop. The non-AI baseline at POST /api/v1/automations
3131
// (the canonical typed AutomationHandler.Create + decode path with
3232
// per-step validators at `internal/api/automation_handler_decode.go`)

internal/api/aigeofautom/doc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Package aigeofautom contains the AI geofence-aware automation suggestion handler.
2+
//
3+
// It serves POST /api/v1/ai/geofences/automations/draft through the shared AI guard
4+
// and injects the user's existing geofence catalog into propose-only automation drafts.
5+
//
6+
// Layer: handler
7+
package aigeofautom

internal/api/ai_geofence_aware_automation_handler.go renamed to internal/api/aigeofautom/handler.go

Lines changed: 53 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
package api
1+
package aigeofautom
22

33
// Phase-50 / 0039 — G3 Geofence-aware automation suggestions.
44
//
5-
// ai_geofence_aware_automation_handler.go implements the LLM-backed
6-
// handler at POST /api/v1/ai/geofences/automations/draft. The flow
5+
// handler.go implements the LLM-backed handler at
6+
// POST /api/v1/ai/geofences/automations/draft. The flow
77
// mirrors ai_automation_handler.go (slice 0016 nl-automation-builder
88
// — same dispatch+stream loop, no persistence — one-shot proposal)
99
// BUT with a deterministic geofence catalog injected into the
@@ -32,7 +32,7 @@ package api
3232
// so a malformed input surfaces as a plain JSON 400 (rather than a
3333
// streamed error frame the SPA's QueryError will struggle to render
3434
// meaningfully). vehicle_id MUST be > 0; prompt MUST be non-empty
35-
// after trimming and ≤ aiGeofenceAwareAutomationMaxPromptLen runes.
35+
// after trimming and ≤ maxPromptLen runes.
3636
//
3737
// ADR-015 alignment:
3838
//
@@ -75,42 +75,51 @@ import (
7575
"github.com/ev-dev-labs/teslasync/internal/ai/strategy"
7676
"github.com/ev-dev-labs/teslasync/internal/ai/stream"
7777
"github.com/ev-dev-labs/teslasync/internal/ai/tools"
78+
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
7879
tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
7980
geofencedb "github.com/ev-dev-labs/teslasync/internal/database/geofence"
8081
)
8182

82-
// aiGeofenceAwareAutomationMaxIterations bounds the dispatcher's
83+
// maxIterations bounds the dispatcher's
8384
// tool-loop. The strategy is at most draft-then-validate-then-answer
8485
// (with optional retries) — a hard ceiling of 8 is generous and
8586
// matches the other propose-only N/G-tier handlers.
86-
const aiGeofenceAwareAutomationMaxIterations = 8
87+
const maxIterations = 8
8788

88-
// aiGeofenceAwareAutomationMaxBodyBytes caps the JSON body. The
89+
// maxBodyBytes caps the JSON body. The
8990
// prompt text is the only non-trivial field; 8 KiB allows a
9091
// reasonably descriptive natural-language request without inviting
9192
// abuse. Mirrors the cap nl-automation-builder uses.
92-
const aiGeofenceAwareAutomationMaxBodyBytes = 1 << 13 // 8 KiB
93+
const maxBodyBytes = 1 << 13 // 8 KiB
9394

94-
// aiGeofenceAwareAutomationMaxPromptLen is the rune-length cap on
95+
// maxPromptLen is the rune-length cap on
9596
// the user prompt itself. Below the body cap so a body that is
9697
// mostly padding still bounces. 4096 runes covers every realistic
9798
// natural-language request the AutomationBuilderPage UI surfaces.
98-
const aiGeofenceAwareAutomationMaxPromptLen = 4096
99+
const maxPromptLen = 4096
99100

100-
// aiGeofenceAwareAutomationMaxCatalogEntries caps the number of
101+
// maxCatalogEntries caps the number of
101102
// geofences the handler injects into the synthesised user message.
102103
// The canonical GeofenceRepo.GetAll already caps at 500 rows; this
103104
// secondary cap protects against catastrophic prompt-bloat if the
104105
// upstream cap is ever raised. Mirrors the in-tool default.
105-
const aiGeofenceAwareAutomationMaxCatalogEntries = 50
106+
const maxCatalogEntries = 50
106107

107-
// AIGeofenceAwareAutomationHandler is the HTTP handler for
108+
func writeError(w http.ResponseWriter, status int, msg string) {
109+
httpx.WriteError(w, status, msg)
110+
}
111+
112+
func denyAllConfirm(_ context.Context, _ dispatch.ConfirmRequest) (dispatch.ConfirmDecision, error) {
113+
return dispatch.ConfirmDenied, nil
114+
}
115+
116+
// Handler is the HTTP handler for
108117
// POST /api/v1/ai/geofences/automations/draft.
109118
//
110119
// Stateless beyond its constructor inputs; safe for concurrent use
111120
// across requests. Construction is in router.go so the dispatcher's
112121
// tool registry + provider registry are wired once at boot.
113-
type AIGeofenceAwareAutomationHandler struct {
122+
type Handler struct {
114123
registry *provider.Registry
115124
tools *tools.Registry
116125
strategy strategy.Strategy
@@ -129,40 +138,40 @@ type GeofenceLister interface {
129138
GetAll(ctx context.Context) ([]*systemmodel.Geofence, error)
130139
}
131140

132-
// NewAIGeofenceAwareAutomationHandler constructs the handler. All
141+
// NewHandler constructs the handler. All
133142
// non-pointer arguments are required; the constructor panics on a
134143
// nil so the wiring bug surfaces at boot, not at first request.
135-
func NewAIGeofenceAwareAutomationHandler(
144+
func NewHandler(
136145
registry *provider.Registry,
137146
toolReg *tools.Registry,
138147
strat strategy.Strategy,
139148
geofenceRepo *geofencedb.GeofenceRepo,
140149
headerName string,
141-
) *AIGeofenceAwareAutomationHandler {
150+
) *Handler {
142151
switch {
143152
case registry == nil:
144-
panic("api: NewAIGeofenceAwareAutomationHandler: nil provider.Registry")
153+
panic("aigeofautom: NewHandler: nil provider.Registry")
145154
case toolReg == nil:
146-
panic("api: NewAIGeofenceAwareAutomationHandler: nil tools.Registry")
155+
panic("aigeofautom: NewHandler: nil tools.Registry")
147156
case strat == nil:
148-
panic("api: NewAIGeofenceAwareAutomationHandler: nil strategy.Strategy")
157+
panic("aigeofautom: NewHandler: nil strategy.Strategy")
149158
case geofenceRepo == nil:
150-
panic("api: NewAIGeofenceAwareAutomationHandler: nil *geofencedb.GeofenceRepo")
159+
panic("aigeofautom: NewHandler: nil *geofencedb.GeofenceRepo")
151160
}
152-
return &AIGeofenceAwareAutomationHandler{
161+
return &Handler{
153162
registry: registry,
154163
tools: toolReg,
155164
strategy: strat,
156165
geofenceRepo: geofenceRepo,
157166
headerName: headerName,
158-
maxIters: aiGeofenceAwareAutomationMaxIterations,
167+
maxIters: maxIterations,
159168
}
160169
}
161170

162-
// aiGeofenceAwareAutomationRequest is the wire shape the SPA POSTs.
171+
// request is the wire shape the SPA POSTs.
163172
// vehicle_id and prompt are required; future fields MAY be added
164173
// without changing the off-mode contract.
165-
type aiGeofenceAwareAutomationRequest struct {
174+
type request struct {
166175
// VehicleID is the vehicle the proposed automation will apply
167176
// to. Required and positive. The handler clamps it before
168177
// the LLM sees it, so a missing or nonsense ID is a wiring
@@ -171,45 +180,45 @@ type aiGeofenceAwareAutomationRequest struct {
171180

172181
// Prompt is the user's natural-language description of the
173182
// automation they want. Required, trimmed; rune-length
174-
// bounded by aiGeofenceAwareAutomationMaxPromptLen.
183+
// bounded by maxPromptLen.
175184
Prompt string `json:"prompt"`
176185
}
177186

178-
// parseGeofenceAwareAutomationBody decodes + validates the request
187+
// parseBody decodes + validates the request
179188
// body. Pulled out so the off-mode test can exercise the parsing
180189
// without constructing a full handler with stub deps. The function
181190
// writes a 400 on failure and returns the (req, ok) pair so the
182191
// caller can early-return.
183192
//
184193
// Rules:
185194
//
186-
// - body MUST be valid JSON capped at aiGeofenceAwareAutomationMaxBodyBytes;
195+
// - body MUST be valid JSON capped at maxBodyBytes;
187196
// - vehicle_id MUST be a positive integer;
188-
// - prompt MUST be non-empty after trim and ≤ aiGeofenceAwareAutomationMaxPromptLen runes.
197+
// - prompt MUST be non-empty after trim and ≤ maxPromptLen runes.
189198
//
190199
// An empty / nil body is REJECTED — the SPA always carries the
191200
// scope; a missing field is a wiring bug, not a default.
192-
func parseGeofenceAwareAutomationBody(w http.ResponseWriter, r *http.Request) (*aiGeofenceAwareAutomationRequest, bool) {
201+
func parseBody(w http.ResponseWriter, r *http.Request) (*request, bool) {
193202
if r.Body == nil {
194203
writeError(w, http.StatusBadRequest, "request body is required (vehicle_id + prompt)")
195204
return nil, false
196205
}
197206
defer r.Body.Close()
198-
limited := io.LimitReader(r.Body, aiGeofenceAwareAutomationMaxBodyBytes+1)
207+
limited := io.LimitReader(r.Body, maxBodyBytes+1)
199208
raw, err := io.ReadAll(limited)
200209
if err != nil {
201210
writeError(w, http.StatusBadRequest, "failed to read request body")
202211
return nil, false
203212
}
204-
if int64(len(raw)) > aiGeofenceAwareAutomationMaxBodyBytes {
205-
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("request body exceeds %d bytes", aiGeofenceAwareAutomationMaxBodyBytes))
213+
if int64(len(raw)) > maxBodyBytes {
214+
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("request body exceeds %d bytes", maxBodyBytes))
206215
return nil, false
207216
}
208217
if len(raw) == 0 {
209218
writeError(w, http.StatusBadRequest, "request body is required (vehicle_id + prompt)")
210219
return nil, false
211220
}
212-
var body aiGeofenceAwareAutomationRequest
221+
var body request
213222
dec := json.NewDecoder(strings.NewReader(string(raw)))
214223
dec.DisallowUnknownFields()
215224
if err := dec.Decode(&body); err != nil {
@@ -229,8 +238,8 @@ func parseGeofenceAwareAutomationBody(w http.ResponseWriter, r *http.Request) (*
229238
writeError(w, http.StatusBadRequest, "prompt must be non-empty")
230239
return nil, false
231240
}
232-
if runes := []rune(body.Prompt); len(runes) > aiGeofenceAwareAutomationMaxPromptLen {
233-
writeError(w, http.StatusBadRequest, fmt.Sprintf("prompt must be ≤ %d characters", aiGeofenceAwareAutomationMaxPromptLen))
241+
if runes := []rune(body.Prompt); len(runes) > maxPromptLen {
242+
writeError(w, http.StatusBadRequest, fmt.Sprintf("prompt must be ≤ %d characters", maxPromptLen))
234243
return nil, false
235244
}
236245
return &body, true
@@ -242,9 +251,9 @@ func parseGeofenceAwareAutomationBody(w http.ResponseWriter, r *http.Request) (*
242251
// Every error path either writes a structured frame onto the SSE
243252
// stream (when the writer has been opened) or a plain JSON 4xx/5xx
244253
// (before it has).
245-
func (h *AIGeofenceAwareAutomationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
254+
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
246255
// 1) Parse + validate the body.
247-
body, ok := parseGeofenceAwareAutomationBody(w, r)
256+
body, ok := parseBody(w, r)
248257
if !ok {
249258
return
250259
}
@@ -266,14 +275,14 @@ func (h *AIGeofenceAwareAutomationHandler) ServeHTTP(w http.ResponseWriter, r *h
266275
// cannot recover from. The 500-row cap inside
267276
// GeofenceRepo.GetAll is the primary defence; the secondary
268277
// in-handler trim caps the LLM prompt at
269-
// aiGeofenceAwareAutomationMaxCatalogEntries entries.
278+
// maxCatalogEntries entries.
270279
geofences, err := h.geofenceRepo.GetAll(r.Context())
271280
if err != nil {
272281
log.Error().Err(err).Msg("ai geofence-aware-automation-suggestions: GeofenceRepo.GetAll failed")
273282
writeError(w, http.StatusBadGateway, "geofence catalog unavailable")
274283
return
275284
}
276-
catalog := buildGeofenceCatalogLine(geofences, aiGeofenceAwareAutomationMaxCatalogEntries)
285+
catalog := buildCatalogLine(geofences, maxCatalogEntries)
277286

278287
// 4) Subject + feature-id annotations for audit/rate-limit.
279288
subject, _ := tsauth.SubjectFromRequest(r, h.headerName)
@@ -334,7 +343,7 @@ func (h *AIGeofenceAwareAutomationHandler) ServeHTTP(w http.ResponseWriter, r *h
334343
}
335344
}
336345

337-
// buildGeofenceCatalogLine renders the geofence catalog as a
346+
// buildCatalogLine renders the geofence catalog as a
338347
// single-line deterministic string the LLM can read and pick
339348
// place_id values from. Sorted by id ASC for byte-stable goldens.
340349
// Capped at maxEntries so a misconfigured deployment with thousands
@@ -348,7 +357,7 @@ func (h *AIGeofenceAwareAutomationHandler) ServeHTTP(w http.ResponseWriter, r *h
348357
// its purpose). The LLM only needs id + name + category to pick
349358
// the right place_id; the canonical typed automation handler reads
350359
// the geometry from the database when the saved automation runs.
351-
func buildGeofenceCatalogLine(geofences []*systemmodel.Geofence, maxEntries int) string {
360+
func buildCatalogLine(geofences []*systemmodel.Geofence, maxEntries int) string {
352361
if len(geofences) == 0 {
353362
return "Geofence catalog is empty for this user (no place_ids to reference). Refuse the request politely and explain that the user must add at least one geofence at /geofences before this assistant can propose a geofence-aware automation."
354363
}
@@ -381,6 +390,6 @@ func buildGeofenceCatalogLine(geofences []*systemmodel.Geofence, maxEntries int)
381390
return b.String()
382391
}
383392

384-
// Compile-time assertion: AIGeofenceAwareAutomationHandler satisfies
393+
// Compile-time assertion: Handler satisfies
385394
// http.Handler.
386-
var _ http.Handler = (*AIGeofenceAwareAutomationHandler)(nil)
395+
var _ http.Handler = (*Handler)(nil)

0 commit comments

Comments
 (0)