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