Skip to content

Commit dc13f69

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.129): carve internal/api/aiclimate subpackage
Move ai_climate_schedule_handler.go + test (preheat/precool recommender) into aiclimate subpkg, rename AIClimateScheduleHandler -> Handler. Router uses aiclimate.NewHandler; AIHandlers.PreheatPrecoolRecommender field accepts it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 97dc24e commit dc13f69

4 files changed

Lines changed: 102 additions & 72 deletions

File tree

internal/api/aiclimate/doc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Package aiclimate handles AI-assisted climate schedule draft endpoints.
2+
//
3+
// Layer: handler
4+
package aiclimate

internal/api/ai_climate_schedule_handler.go renamed to internal/api/aiclimate/handler.go

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package api
1+
package aiclimate
22

33
// Phase-50 / 0031 — T1 Preheat and precool recommender.
44
//
@@ -61,18 +61,19 @@ import (
6161
"github.com/ev-dev-labs/teslasync/internal/ai/stream"
6262
"github.com/ev-dev-labs/teslasync/internal/ai/tools"
6363
"github.com/ev-dev-labs/teslasync/internal/ai/tools/schedule"
64+
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
6465
tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
6566
)
6667

67-
// aiClimateScheduleMaxIterations bounds the dispatcher's
68+
// maxIterations bounds the dispatcher's
6869
// tool-loop. The strategy is at most draft_climate_schedule →
6970
// validate_climate_schedule → answer (with optional retries). A
7071
// hard ceiling of 8 is generous. Mirrors
7172
// aiSmartChargeScheduleMaxIterations.
72-
const aiClimateScheduleMaxIterations = 8
73+
const maxIterations = 8
7374

7475
// preheatRateCelsiusPerMinute is the deterministic warm-up rate
75-
// the AIClimateScheduleAdvisor uses to draft a preheat window.
76+
// the Advisor uses to draft a preheat window.
7677
// 0.5°C / minute matches the empirical Tesla cabin warm-up rate
7778
// from a cold soak (-2°C outside, 4°C cabin → 21°C cabin in ~30
7879
// minutes). Kept as a single named constant so a future revision
@@ -81,7 +82,7 @@ const aiClimateScheduleMaxIterations = 8
8182
const preheatRateCelsiusPerMinute = 0.5
8283

8384
// precoolRateCelsiusPerMinute is the deterministic cool-down
84-
// rate the AIClimateScheduleAdvisor uses to draft a precool
85+
// rate the Advisor uses to draft a precool
8586
// window. 0.6°C / minute matches the empirical Tesla cabin
8687
// cool-down rate from a hot soak (34°C outside, 38°C cabin →
8788
// 22°C cabin in ~25 minutes). Kept separate from the preheat
@@ -112,36 +113,36 @@ const climateTargetMinC = 10.0
112113
// upper control limit (32°C / ~89°F).
113114
const climateTargetMaxC = 32.0
114115

115-
// aiClimateScheduleDraftRequest is the JSON body shape this
116+
// draftRequest is the JSON body shape this
116117
// handler accepts. Temperatures are Celsius (SI canonical —
117118
// Phase-48); time fields are RFC3339. The shape mirrors the
118119
// *typed* surface area of the existing manual climate-controls
119120
// form so a SPA call site can construct the AI draft request from
120121
// the same form state.
121-
type aiClimateScheduleDraftRequest struct {
122+
type draftRequest struct {
122123
VehicleID int64 `json:"vehicle_id"`
123124
DepartBy string `json:"depart_by"` // RFC3339
124125
CurrentCabinTempC float64 `json:"current_cabin_temp_c"`
125126
OutsideTempC float64 `json:"outside_temp_c"`
126127
TargetCabinTempC float64 `json:"target_cabin_temp_c"`
127128
}
128129

129-
// AIClimateScheduleHandler is the HTTP handler for
130+
// Handler is the HTTP handler for
130131
// POST /api/v1/ai/climate/schedule/draft.
131132
//
132133
// Stateless beyond its constructor inputs; safe for concurrent use
133134
// across requests. Construction is in router.go so the
134135
// dispatcher's tool registry + provider registry are wired once at
135136
// boot.
136-
type AIClimateScheduleHandler struct {
137+
type Handler struct {
137138
registry *provider.Registry
138139
tools *tools.Registry
139140
strategy strategy.Strategy
140141
headerName string
141142
maxIters int
142143
}
143144

144-
// NewAIClimateScheduleHandler constructs the handler. All
145+
// NewHandler constructs the handler. All
145146
// non-pointer arguments are required; the constructor panics on a
146147
// nil so the wiring bug surfaces at boot, not at first request.
147148
//
@@ -154,26 +155,26 @@ type AIClimateScheduleHandler struct {
154155
//
155156
// strat: the preheat-precool-recommender Strategy (one per process).
156157
// headerName: forward-auth header name; used to extract subject for audit.
157-
func NewAIClimateScheduleHandler(
158+
func NewHandler(
158159
registry *provider.Registry,
159160
toolReg *tools.Registry,
160161
strat strategy.Strategy,
161162
headerName string,
162-
) *AIClimateScheduleHandler {
163+
) *Handler {
163164
switch {
164165
case registry == nil:
165-
panic("api: NewAIClimateScheduleHandler: nil provider.Registry")
166+
panic("aiclimate: NewHandler: nil provider.Registry")
166167
case toolReg == nil:
167-
panic("api: NewAIClimateScheduleHandler: nil tools.Registry")
168+
panic("aiclimate: NewHandler: nil tools.Registry")
168169
case strat == nil:
169-
panic("api: NewAIClimateScheduleHandler: nil strategy.Strategy")
170+
panic("aiclimate: NewHandler: nil strategy.Strategy")
170171
}
171-
return &AIClimateScheduleHandler{
172+
return &Handler{
172173
registry: registry,
173174
tools: toolReg,
174175
strategy: strat,
175176
headerName: headerName,
176-
maxIters: aiClimateScheduleMaxIterations,
177+
maxIters: maxIterations,
177178
}
178179
}
179180

@@ -182,41 +183,41 @@ func NewAIClimateScheduleHandler(
182183
// same parsing without constructing a full handler with stub deps.
183184
// The function writes a 400 on failure and returns the (req, ok)
184185
// pair so the caller can early-return.
185-
func parseClimateScheduleDraftBody(w http.ResponseWriter, r *http.Request) (*aiClimateScheduleDraftRequest, bool) {
186+
func parseClimateScheduleDraftBody(w http.ResponseWriter, r *http.Request) (*draftRequest, bool) {
186187
if r.Body == nil {
187-
writeError(w, http.StatusBadRequest, "request body is required")
188+
httpx.WriteError(w, http.StatusBadRequest, "request body is required")
188189
return nil, false
189190
}
190191
defer r.Body.Close()
191-
var req aiClimateScheduleDraftRequest
192+
var req draftRequest
192193
dec := json.NewDecoder(r.Body)
193194
dec.DisallowUnknownFields()
194195
if err := dec.Decode(&req); err != nil {
195-
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON body: %v", err))
196+
httpx.WriteError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON body: %v", err))
196197
return nil, false
197198
}
198199
if req.VehicleID <= 0 {
199-
writeError(w, http.StatusBadRequest, "vehicle_id must be > 0")
200+
httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be > 0")
200201
return nil, false
201202
}
202203
if req.DepartBy == "" {
203-
writeError(w, http.StatusBadRequest, "depart_by is required")
204+
httpx.WriteError(w, http.StatusBadRequest, "depart_by is required")
204205
return nil, false
205206
}
206207
if _, err := time.Parse(time.RFC3339, req.DepartBy); err != nil {
207-
writeError(w, http.StatusBadRequest, fmt.Sprintf("depart_by must be RFC3339: %v", err))
208+
httpx.WriteError(w, http.StatusBadRequest, fmt.Sprintf("depart_by must be RFC3339: %v", err))
208209
return nil, false
209210
}
210211
if req.CurrentCabinTempC < -40 || req.CurrentCabinTempC > 80 {
211-
writeError(w, http.StatusBadRequest, "current_cabin_temp_c must be in [-40, 80] °C")
212+
httpx.WriteError(w, http.StatusBadRequest, "current_cabin_temp_c must be in [-40, 80] °C")
212213
return nil, false
213214
}
214215
if req.OutsideTempC < -50 || req.OutsideTempC > 60 {
215-
writeError(w, http.StatusBadRequest, "outside_temp_c must be in [-50, 60] °C")
216+
httpx.WriteError(w, http.StatusBadRequest, "outside_temp_c must be in [-50, 60] °C")
216217
return nil, false
217218
}
218219
if req.TargetCabinTempC < climateTargetMinC || req.TargetCabinTempC > climateTargetMaxC {
219-
writeError(w, http.StatusBadRequest, fmt.Sprintf("target_cabin_temp_c must be in [%.0f, %.0f] °C", climateTargetMinC, climateTargetMaxC))
220+
httpx.WriteError(w, http.StatusBadRequest, fmt.Sprintf("target_cabin_temp_c must be in [%.0f, %.0f] °C", climateTargetMinC, climateTargetMaxC))
220221
return nil, false
221222
}
222223
return &req, true
@@ -227,7 +228,7 @@ func parseClimateScheduleDraftBody(w http.ResponseWriter, r *http.Request) (*aiC
227228
// dispatcher's deferred WriteDone. Every error path either writes
228229
// a structured frame onto the SSE stream (when the writer has been
229230
// opened) or a plain JSON 4xx/5xx (before it has).
230-
func (h *AIClimateScheduleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
231+
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
231232
// 1) Parse + validate the JSON body.
232233
body, ok := parseClimateScheduleDraftBody(w, r)
233234
if !ok {
@@ -240,7 +241,7 @@ func (h *AIClimateScheduleHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
240241
// stream — emit JSON 502 so the frontend falls back gracefully.
241242
if _, err := h.registry.For(r.Context(), preheatprecoolrecommender.FeatureID); err != nil {
242243
log.Error().Err(err).Msg("ai preheat-precool-recommender: provider.For failed")
243-
writeError(w, http.StatusBadGateway, "ai provider unavailable")
244+
httpx.WriteError(w, http.StatusBadGateway, "ai provider unavailable")
244245
return
245246
}
246247

@@ -253,7 +254,7 @@ func (h *AIClimateScheduleHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
253254
sseW, ctx, err := stream.New(ctx, w, stream.WithFeatureID(preheatprecoolrecommender.FeatureID))
254255
if err != nil {
255256
log.Error().Err(err).Msg("ai preheat-precool-recommender: stream.New failed (non-flushable writer)")
256-
writeError(w, http.StatusInternalServerError, "streaming not supported")
257+
httpx.WriteError(w, http.StatusInternalServerError, "streaming not supported")
257258
return
258259
}
259260

@@ -308,8 +309,14 @@ func (h *AIClimateScheduleHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
308309
}
309310
}
310311

311-
// Compile-time assertion: AIClimateScheduleHandler satisfies http.Handler.
312-
var _ http.Handler = (*AIClimateScheduleHandler)(nil)
312+
// denyAllConfirm is the dispatcher's user-confirm hook. The preheat/precool
313+
// recommender is propose-only, so any mutating tool call is rejected.
314+
func denyAllConfirm(_ context.Context, _ dispatch.ConfirmRequest) (dispatch.ConfirmDecision, error) {
315+
return dispatch.ConfirmDenied, nil
316+
}
317+
318+
// Compile-time assertion: Handler satisfies http.Handler.
319+
var _ http.Handler = (*Handler)(nil)
313320

314321
// ---------------------------------------------------------------------
315322
// Production wiring for the tool interface declared by
@@ -328,7 +335,7 @@ var _ http.Handler = (*AIClimateScheduleHandler)(nil)
328335
// climate-controls Apply button to persist.
329336
// ---------------------------------------------------------------------
330337

331-
// AIClimateScheduleAdvisor is the production
338+
// Advisor is the production
332339
// schedule.ClimateScheduleAdvisor. It runs a pure-Go deterministic
333340
// departure heuristic over the typed inputs and returns a
334341
// proposed window.
@@ -337,17 +344,17 @@ var _ http.Handler = (*AIClimateScheduleHandler)(nil)
337344
// stable timestamp without monkey-patching time.Now. Production
338345
// uses time.Now().UTC() implicitly via the zero-value sentinel
339346
// (Now.IsZero() ⇒ time.Now().UTC()).
340-
type AIClimateScheduleAdvisor struct {
347+
type Advisor struct {
341348
// Now is the wall-clock function. Defaults to
342349
// time.Now().UTC() when nil; tests inject a stable clock.
343350
Now func() time.Time
344351
}
345352

346-
// NewAIClimateScheduleAdvisor constructs the production advisor.
353+
// NewAdvisor constructs the production advisor.
347354
// The constructor takes no required arguments (the heuristic is
348355
// pure-Go) — present for symmetry with NewAIChargeScheduleComputer.
349-
func NewAIClimateScheduleAdvisor() *AIClimateScheduleAdvisor {
350-
return &AIClimateScheduleAdvisor{}
356+
func NewAdvisor() *Advisor {
357+
return &Advisor{}
351358
}
352359

353360
// DraftClimateSchedule implements schedule.ClimateScheduleAdvisor.
@@ -365,7 +372,7 @@ func NewAIClimateScheduleAdvisor() *AIClimateScheduleAdvisor {
365372
//
366373
// Errors are returned as Go errors; the tool wraps them into the
367374
// {status: "invalid"} envelope.
368-
func (a *AIClimateScheduleAdvisor) DraftClimateSchedule(_ context.Context, req schedule.ClimateScheduleDraftRequest) (*schedule.ClimateScheduleDraftResult, error) {
375+
func (a *Advisor) DraftClimateSchedule(_ context.Context, req schedule.ClimateScheduleDraftRequest) (*schedule.ClimateScheduleDraftResult, error) {
369376
depart, err := time.Parse(time.RFC3339, req.DepartBy)
370377
if err != nil {
371378
return nil, fmt.Errorf("ai preheat-precool-recommender: depart_by parse: %w", err)
@@ -432,12 +439,12 @@ func (a *AIClimateScheduleAdvisor) DraftClimateSchedule(_ context.Context, req s
432439

433440
// now returns the wall clock with the tests-inject-a-stable-clock
434441
// fallback. Kept private so external callers cannot bypass it.
435-
func (a *AIClimateScheduleAdvisor) now() time.Time {
442+
func (a *Advisor) now() time.Time {
436443
if a.Now != nil {
437444
return a.Now().UTC()
438445
}
439446
return time.Now().UTC()
440447
}
441448

442-
// Compile-time assertion: AIClimateScheduleAdvisor satisfies schedule.ClimateScheduleAdvisor.
443-
var _ schedule.ClimateScheduleAdvisor = (*AIClimateScheduleAdvisor)(nil)
449+
// Compile-time assertion: Advisor satisfies schedule.ClimateScheduleAdvisor.
450+
var _ schedule.ClimateScheduleAdvisor = (*Advisor)(nil)

0 commit comments

Comments
 (0)