1- package api
1+ package aivehpaint
22
33// Phase-50 / 0061 — GEN2 Vehicle paint preview.
44//
5- // ai_vehicle_paint_preview_handler .go implements the LLM-backed
6- // handler at POST /api/v1/ai/vehicles/{vehicleID}/paint-preview/draft.
5+ // handler .go implements the LLM-backed handler at
6+ // POST /api/v1/ai/vehicles/{vehicleID}/paint-preview/draft.
77// The flow mirrors auto-trip-naming / route-efficiency-
88// suggestions / drive-coaching narration handlers — same
99// dispatch+stream loop, no persistence (one-shot proposal; no
@@ -47,6 +47,7 @@ package api
4747// is added or modified by this slice.
4848
4949import (
50+ "context"
5051 "encoding/json"
5152 "errors"
5253 "fmt"
@@ -63,49 +64,50 @@ import (
6364 "github.com/ev-dev-labs/teslasync/internal/ai/strategy"
6465 "github.com/ev-dev-labs/teslasync/internal/ai/stream"
6566 "github.com/ev-dev-labs/teslasync/internal/ai/tools"
67+ "github.com/ev-dev-labs/teslasync/internal/api/httpx"
6668 tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
6769)
6870
69- // aiVehiclePaintPreviewMaxIterations bounds the dispatcher's
71+ // maxIterations bounds the dispatcher's
7072// tool-loop. The strategy is at most draft-then-answer (with
7173// optional retries on validator rejection) — a hard ceiling of 6 is
7274// generous and matches the aiAutoTripNamingMaxIterations precedent.
73- const aiVehiclePaintPreviewMaxIterations = 6
75+ const maxIterations = 6
7476
75- // aiVehiclePaintPreviewMaxBodyBytes is the hard cap on the request
77+ // maxBodyBytes is the hard cap on the request
7678// body. 16 KiB is generous for an optional {style_hint} envelope
7779// and defends against amplification or accidental payload bombs.
78- const aiVehiclePaintPreviewMaxBodyBytes = 16 * 1024
80+ const maxBodyBytes = 16 * 1024
7981
80- // aiVehiclePaintPreviewMaxStyleHintLen mirrors the tool's
82+ // maxStyleHintLen mirrors the tool's
8183// paintPreviewMaxStyleHintLen so a body that would be rejected by
8284// the tool is rejected by the handler first (faster failure mode +
8385// no SSE stream opened for a doomed request).
84- const aiVehiclePaintPreviewMaxStyleHintLen = 80
86+ const maxStyleHintLen = 80
8587
86- // aiVehiclePaintPreviewRequest is the JSON body shape the handler
88+ // request is the JSON body shape the handler
8789// accepts. Body is optional (empty body is accepted). StyleHint is
8890// optional free-text the LLM may quote when seeding the
8991// draft_paint_preview_prompt tool.
90- type aiVehiclePaintPreviewRequest struct {
92+ type request struct {
9193 StyleHint string `json:"style_hint,omitempty"`
9294}
9395
94- // AIVehiclePaintPreviewHandler is the HTTP handler for
96+ // Handler is the HTTP handler for
9597// POST /api/v1/ai/vehicles/{vehicleID}/paint-preview/draft.
9698//
9799// Stateless beyond its constructor inputs; safe for concurrent use
98100// across requests. Construction is in router.go so the dispatcher's
99101// tool registry + provider registry are wired once at boot.
100- type AIVehiclePaintPreviewHandler struct {
102+ type Handler struct {
101103 registry * provider.Registry
102104 tools * tools.Registry
103105 strategy strategy.Strategy
104106 headerName string
105107 maxIters int
106108}
107109
108- // NewAIVehiclePaintPreviewHandler constructs the handler. All
110+ // NewHandler constructs the handler. All
109111// non-pointer arguments are required; the constructor panics on a
110112// nil so the wiring bug surfaces at boot, not at first request.
111113//
@@ -117,80 +119,84 @@ type AIVehiclePaintPreviewHandler struct {
117119//
118120// strat: the vehicle-paint-preview Strategy (one per process).
119121// headerName: forward-auth header name; used to extract subject for audit.
120- func NewAIVehiclePaintPreviewHandler (
122+ func NewHandler (
121123 registry * provider.Registry ,
122124 toolReg * tools.Registry ,
123125 strat strategy.Strategy ,
124126 headerName string ,
125- ) * AIVehiclePaintPreviewHandler {
127+ ) * Handler {
126128 switch {
127129 case registry == nil :
128- panic ("api: NewAIVehiclePaintPreviewHandler : nil provider.Registry" )
130+ panic ("aivehpaint: NewHandler : nil provider.Registry" )
129131 case toolReg == nil :
130- panic ("api: NewAIVehiclePaintPreviewHandler : nil tools.Registry" )
132+ panic ("aivehpaint: NewHandler : nil tools.Registry" )
131133 case strat == nil :
132- panic ("api: NewAIVehiclePaintPreviewHandler : nil strategy.Strategy" )
134+ panic ("aivehpaint: NewHandler : nil strategy.Strategy" )
133135 }
134- return & AIVehiclePaintPreviewHandler {
136+ return & Handler {
135137 registry : registry ,
136138 tools : toolReg ,
137139 strategy : strat ,
138140 headerName : headerName ,
139- maxIters : aiVehiclePaintPreviewMaxIterations ,
141+ maxIters : maxIterations ,
140142 }
141143}
142144
143- // parseAIVehiclePaintPreviewURL extracts and validates the
145+ func denyAllConfirm (_ context.Context , _ dispatch.ConfirmRequest ) (dispatch.ConfirmDecision , error ) {
146+ return dispatch .ConfirmDenied , nil
147+ }
148+
149+ // parseURL extracts and validates the
144150// vehicleID URL parameter. Pulled out so the unit tests can
145151// exercise the parser without constructing a full handler.
146152//
147153// vehicleID MUST be a positive integer; zero or negative values are
148154// rejected with a 400.
149- func parseAIVehiclePaintPreviewURL (w http.ResponseWriter , r * http.Request ) (int64 , bool ) {
155+ func parseURL (w http.ResponseWriter , r * http.Request ) (int64 , bool ) {
150156 raw := chi .URLParam (r , "vehicleID" )
151157 if raw == "" {
152- writeError (w , http .StatusBadRequest , "vehicleID URL parameter is required" )
158+ httpx . WriteError (w , http .StatusBadRequest , "vehicleID URL parameter is required" )
153159 return 0 , false
154160 }
155161 id , err := strconv .ParseInt (raw , 10 , 64 )
156162 if err != nil {
157- writeError (w , http .StatusBadRequest , fmt .Sprintf ("vehicleID must be a positive integer (got %q)" , raw ))
163+ httpx . WriteError (w , http .StatusBadRequest , fmt .Sprintf ("vehicleID must be a positive integer (got %q)" , raw ))
158164 return 0 , false
159165 }
160166 if id <= 0 {
161- writeError (w , http .StatusBadRequest , "vehicleID must be > 0" )
167+ httpx . WriteError (w , http .StatusBadRequest , "vehicleID must be > 0" )
162168 return 0 , false
163169 }
164170 return id , true
165171}
166172
167- // parseAIVehiclePaintPreviewBody extracts and validates the
173+ // parseBody extracts and validates the
168174// optional request JSON body. Empty body is accepted (returns the
169175// zero request). On parse failure writes a 4xx and returns false.
170- func parseAIVehiclePaintPreviewBody (w http.ResponseWriter , r * http.Request ) (aiVehiclePaintPreviewRequest , bool ) {
171- var req aiVehiclePaintPreviewRequest
176+ func parseBody (w http.ResponseWriter , r * http.Request ) (request , bool ) {
177+ var req request
172178 if r .Body == nil {
173179 return req , true
174180 }
175- limited := io .LimitReader (r .Body , aiVehiclePaintPreviewMaxBodyBytes + 1 )
181+ limited := io .LimitReader (r .Body , maxBodyBytes + 1 )
176182 body , err := io .ReadAll (limited )
177183 if err != nil {
178- writeError (w , http .StatusBadRequest , fmt .Sprintf ("failed to read request body: %v" , err ))
184+ httpx . WriteError (w , http .StatusBadRequest , fmt .Sprintf ("failed to read request body: %v" , err ))
179185 return req , false
180186 }
181- if int64 (len (body )) > aiVehiclePaintPreviewMaxBodyBytes {
182- writeError (w , http .StatusRequestEntityTooLarge , fmt .Sprintf ("request body exceeds %d byte cap" , aiVehiclePaintPreviewMaxBodyBytes ))
187+ if int64 (len (body )) > maxBodyBytes {
188+ httpx . WriteError (w , http .StatusRequestEntityTooLarge , fmt .Sprintf ("request body exceeds %d byte cap" , maxBodyBytes ))
183189 return req , false
184190 }
185191 if len (body ) == 0 {
186192 return req , true
187193 }
188194 if err := json .Unmarshal (body , & req ); err != nil {
189- writeError (w , http .StatusBadRequest , fmt .Sprintf ("invalid JSON body: %v" , err ))
195+ httpx . WriteError (w , http .StatusBadRequest , fmt .Sprintf ("invalid JSON body: %v" , err ))
190196 return req , false
191197 }
192- if len ([]rune (req .StyleHint )) > aiVehiclePaintPreviewMaxStyleHintLen {
193- writeError (w , http .StatusBadRequest , fmt .Sprintf ("style_hint must be at most %d characters" , aiVehiclePaintPreviewMaxStyleHintLen ))
198+ if len ([]rune (req .StyleHint )) > maxStyleHintLen {
199+ httpx . WriteError (w , http .StatusBadRequest , fmt .Sprintf ("style_hint must be at most %d characters" , maxStyleHintLen ))
194200 return req , false
195201 }
196202 return req , true
@@ -202,13 +208,13 @@ func parseAIVehiclePaintPreviewBody(w http.ResponseWriter, r *http.Request) (aiV
202208// WriteDone. Every error path either writes a structured frame
203209// onto the SSE stream (when the writer has been opened) or a plain
204210// JSON 4xx/5xx (before it has).
205- func (h * AIVehiclePaintPreviewHandler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
211+ func (h * Handler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
206212 // 1) Parse + validate URL parameters and optional body.
207- vehicleID , ok := parseAIVehiclePaintPreviewURL (w , r )
213+ vehicleID , ok := parseURL (w , r )
208214 if ! ok {
209215 return
210216 }
211- req , ok := parseAIVehiclePaintPreviewBody (w , r )
217+ req , ok := parseBody (w , r )
212218 if ! ok {
213219 return
214220 }
@@ -220,7 +226,7 @@ func (h *AIVehiclePaintPreviewHandler) ServeHTTP(w http.ResponseWriter, r *http.
220226 // gracefully.
221227 if _ , err := h .registry .For (r .Context (), vehiclepaintpreview .FeatureID ); err != nil {
222228 log .Error ().Err (err ).Msg ("ai vehicle-paint-preview: provider.For failed" )
223- writeError (w , http .StatusBadGateway , "ai provider unavailable" )
229+ httpx . WriteError (w , http .StatusBadGateway , "ai provider unavailable" )
224230 return
225231 }
226232
@@ -233,7 +239,7 @@ func (h *AIVehiclePaintPreviewHandler) ServeHTTP(w http.ResponseWriter, r *http.
233239 sseW , ctx , err := stream .New (ctx , w , stream .WithFeatureID (vehiclepaintpreview .FeatureID ))
234240 if err != nil {
235241 log .Error ().Err (err ).Msg ("ai vehicle-paint-preview: stream.New failed (non-flushable writer)" )
236- writeError (w , http .StatusInternalServerError , "streaming not supported" )
242+ httpx . WriteError (w , http .StatusInternalServerError , "streaming not supported" )
237243 return
238244 }
239245
@@ -290,6 +296,6 @@ func (h *AIVehiclePaintPreviewHandler) ServeHTTP(w http.ResponseWriter, r *http.
290296 }
291297}
292298
293- // Compile-time assertion: AIVehiclePaintPreviewHandler satisfies
299+ // Compile-time assertion: Handler satisfies
294300// http.Handler.
295- var _ http.Handler = (* AIVehiclePaintPreviewHandler )(nil )
301+ var _ http.Handler = (* Handler )(nil )
0 commit comments