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
8182const 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).
113114const 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