Skip to content

Commit 5b94631

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.158): carve internal/api/aivehpaint subpackage
Move the vehicle paint preview AI handler into the aivehpaint API subpackage and rename its exported handler type and constructor for package-local clarity. Wire router.go to construct the carved handler through aivehpaint.NewHandler while preserving the existing router variable and AIHandlers field names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent edf40c1 commit 5b94631

4 files changed

Lines changed: 92 additions & 60 deletions

File tree

internal/api/aivehpaint/doc.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Package aivehpaint serves the LLM-backed vehicle paint-preview draft route
2+
// at POST /api/v1/ai/vehicles/{vehicleID}/paint-preview/draft. It proposes
3+
// review-only paint preview prompts without persisting vehicle configuration.
4+
//
5+
// Layer: handler
6+
package aivehpaint

internal/api/ai_vehicle_paint_preview_handler.go renamed to internal/api/aivehpaint/handler.go

Lines changed: 49 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
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

4949
import (
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)

internal/api/ai_vehicle_paint_preview_handler_test.go renamed to internal/api/aivehpaint/handler_test.go

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
// vehicle-paint-preview`); duplicating that here would require a
1616
// live vehicles fixture.
1717

18-
package api
18+
package aivehpaint
1919

2020
import (
2121
"bytes"
22+
"context"
2223
"net/http"
2324
"net/http/httptest"
2425
"strings"
@@ -29,6 +30,24 @@ import (
2930
"github.com/ev-dev-labs/teslasync/internal/ai/guard"
3031
)
3132

33+
// stubGuardSettings is a minimal in-memory guard.Settings used to
34+
// drive the off-mode contract test without a real DB.
35+
type stubGuardSettings struct {
36+
mode string
37+
on map[string]bool
38+
}
39+
40+
func (s *stubGuardSettings) AIMode(_ context.Context) (string, error) {
41+
if s.mode == "" {
42+
return "off", nil
43+
}
44+
return s.mode, nil
45+
}
46+
47+
func (s *stubGuardSettings) AIFeatureEnabled(_ context.Context, id string) (bool, error) {
48+
return s.on[id], nil
49+
}
50+
3251
// TestVehiclePaintPreviewAIOffHidesPreviewTool is the load-bearing
3352
// off-mode contract proof for slice 0061. It mounts the AI
3453
// vehicle-paint-preview route through the guard with ai_mode='off'
@@ -147,43 +166,43 @@ func TestVehiclePaintPreviewAIOffHidesPreviewTool(t *testing.T) {
147166
}
148167
}
149168

150-
// TestAIVehiclePaintPreviewHandler_PanicsOnNilWiring asserts the
169+
// TestHandler_PanicsOnNilWiring asserts the
151170
// handler constructor refuses zero-valued dependencies. A wiring
152171
// bug at boot must surface as a panic, not as a nil-deref on first
153172
// request.
154-
func TestAIVehiclePaintPreviewHandler_PanicsOnNilWiring(t *testing.T) {
173+
func TestHandler_PanicsOnNilWiring(t *testing.T) {
155174
t.Parallel()
156175
cases := []struct {
157176
name string
158177
fn func()
159178
}{
160-
{"all nil", func() { NewAIVehiclePaintPreviewHandler(nil, nil, nil, "") }},
179+
{"all nil", func() { NewHandler(nil, nil, nil, "") }},
161180
}
162181
for _, tc := range cases {
163182
t.Run(tc.name, func(t *testing.T) {
164183
defer func() {
165184
if r := recover(); r == nil {
166-
t.Fatalf("NewAIVehiclePaintPreviewHandler(%s) did not panic", tc.name)
185+
t.Fatalf("NewHandler(%s) did not panic", tc.name)
167186
}
168187
}()
169188
tc.fn()
170189
})
171190
}
172191
}
173192

174-
// TestAIVehiclePaintPreviewHandler_RejectsBadVehicleID asserts the
193+
// TestHandler_RejectsBadVehicleID asserts the
175194
// URL parser refuses non-positive / non-numeric vehicleID values
176195
// BEFORE opening the SSE stream — a malformed URL must surface as
177196
// a JSON 400, not a half-opened stream that confuses the frontend.
178-
func TestAIVehiclePaintPreviewHandler_RejectsBadVehicleID(t *testing.T) {
197+
func TestHandler_RejectsBadVehicleID(t *testing.T) {
179198
t.Parallel()
180199

181200
router := chi.NewRouter()
182201
// Mount the URL parser through a minimal handler so chi's
183-
// URLParam plumbing is in scope (parseAIVehiclePaintPreviewURL
202+
// URLParam plumbing is in scope (parseURL
184203
// calls chi.URLParam, which requires the route to be mounted).
185204
router.Post("/api/v1/ai/vehicles/{vehicleID}/paint-preview/draft", func(w http.ResponseWriter, r *http.Request) {
186-
_, _ = parseAIVehiclePaintPreviewURL(w, r)
205+
_, _ = parseURL(w, r)
187206
})
188207

189208
cases := []struct {
@@ -210,11 +229,11 @@ func TestAIVehiclePaintPreviewHandler_RejectsBadVehicleID(t *testing.T) {
210229
}
211230
}
212231

213-
// TestAIVehiclePaintPreviewHandler_RejectsBadBody asserts the body
232+
// TestHandler_RejectsBadBody asserts the body
214233
// parser validates the optional body BEFORE opening the SSE stream.
215234
// Empty body is allowed; malformed JSON or oversized style_hint
216235
// surfaces as a JSON 4xx.
217-
func TestAIVehiclePaintPreviewHandler_RejectsBadBody(t *testing.T) {
236+
func TestHandler_RejectsBadBody(t *testing.T) {
218237
t.Parallel()
219238

220239
cases := []struct {
@@ -236,7 +255,7 @@ func TestAIVehiclePaintPreviewHandler_RejectsBadBody(t *testing.T) {
236255
req := httptest.NewRequest(http.MethodPost, "/api/v1/ai/vehicles/7/paint-preview/draft", strings.NewReader(tc.body))
237256
req.Header.Set("Content-Type", "application/json")
238257

239-
_, ok := parseAIVehiclePaintPreviewBody(rec, req)
258+
_, ok := parseBody(rec, req)
240259
if ok != tc.wantOK {
241260
t.Errorf("ok = %v, want %v (body=%q, status=%d, response=%q)", ok, tc.wantOK, tc.body, rec.Code, rec.Body.String())
242261
}
@@ -247,18 +266,18 @@ func TestAIVehiclePaintPreviewHandler_RejectsBadBody(t *testing.T) {
247266
}
248267
}
249268

250-
// TestAIVehiclePaintPreviewHandler_RejectsOversizedBody asserts the
269+
// TestHandler_RejectsOversizedBody asserts the
251270
// 16 KiB body cap is enforced before any further parsing. A request
252271
// body that exceeds the cap surfaces as 413.
253-
func TestAIVehiclePaintPreviewHandler_RejectsOversizedBody(t *testing.T) {
272+
func TestHandler_RejectsOversizedBody(t *testing.T) {
254273
t.Parallel()
255274

256-
huge := `{"style_hint":"` + strings.Repeat("X", aiVehiclePaintPreviewMaxBodyBytes+128) + `"}`
275+
huge := `{"style_hint":"` + strings.Repeat("X", maxBodyBytes+128) + `"}`
257276
rec := httptest.NewRecorder()
258277
req := httptest.NewRequest(http.MethodPost, "/api/v1/ai/vehicles/7/paint-preview/draft", strings.NewReader(huge))
259278
req.Header.Set("Content-Type", "application/json")
260279

261-
_, ok := parseAIVehiclePaintPreviewBody(rec, req)
280+
_, ok := parseBody(rec, req)
262281
if ok {
263282
t.Fatal("ok = true; want false for oversized body")
264283
}

internal/api/router.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import (
4040
aitirepress "github.com/ev-dev-labs/teslasync/internal/api/aitirepress"
4141
aitripplanllm "github.com/ev-dev-labs/teslasync/internal/api/aitripplanllm"
4242
aivampire "github.com/ev-dev-labs/teslasync/internal/api/aivampire"
43+
"github.com/ev-dev-labs/teslasync/internal/api/aivehpaint"
4344
aiyir "github.com/ev-dev-labs/teslasync/internal/api/aiyir"
4445
apialertmsg "github.com/ev-dev-labs/teslasync/internal/api/alertmsg"
4546
apialerts "github.com/ev-dev-labs/teslasync/internal/api/alerts"
@@ -2726,7 +2727,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
27262727
// beyond constructor inputs. Must be constructed AFTER the
27272728
// tool registration above so the dispatcher can resolve the
27282729
// strategy's allowedTools at boot.
2729-
aiVehiclePaintPreviewHandler := NewAIVehiclePaintPreviewHandler(
2730+
aiVehiclePaintPreviewHandler := aivehpaint.NewHandler(
27302731
aiRegistry,
27312732
aiToolRegistry,
27322733
vehiclepaintpreview.New(),

0 commit comments

Comments
 (0)