1- package api
1+ package aicostfcst
22
33// Phase-50 / 0029 — C4 Cost forecast narration.
44//
@@ -50,7 +50,6 @@ package api
5050import (
5151 "context"
5252 "encoding/json"
53- "errors"
5453 "fmt"
5554 "net/http"
5655
@@ -62,60 +61,59 @@ import (
6261 "github.com/ev-dev-labs/teslasync/internal/ai/strategy"
6362 "github.com/ev-dev-labs/teslasync/internal/ai/stream"
6463 "github.com/ev-dev-labs/teslasync/internal/ai/tools"
65- "github.com/ev-dev-labs/teslasync/internal/ai/tools/forecast "
64+ "github.com/ev-dev-labs/teslasync/internal/api/httpx "
6665 tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
67- "github.com/ev-dev-labs/teslasync/internal/database"
6866)
6967
70- // aiCostForecastNarrationMaxIterations bounds the dispatcher's
68+ // maxIterations bounds the dispatcher's
7169// tool-loop. The strategy is at most query_cost_forecast → answer
7270// (with optional retries). A hard ceiling of 8 is generous,
7371// matching aiBatteryHealthMaxIterations /
7472// aiSmartChargeScheduleMaxIterations.
75- const aiCostForecastNarrationMaxIterations = 8
73+ const maxIterations = 8
7674
77- // aiCostForecastNarrationDefaultMonths is the default forecast
75+ // defaultMonths is the default forecast
7876// horizon when the request body omits the months field. Mirrors
7977// the canonical GET /api/v1/analytics/cost-forecast?months=
8078// default. Kept as a named constant so a future tuning lives in
8179// one place rather than duplicated across the parser + the tool's
8280// Execute default.
83- const aiCostForecastNarrationDefaultMonths = 6
81+ const defaultMonths = 6
8482
85- // aiCostForecastNarrationMaxMonths is the upper bound on the
83+ // maxMonths is the upper bound on the
8684// months horizon. Mirrors the canonical handler's parameter
8785// validation in cost_forecast_handler.go (months > 0 && months <=
8886// 24); requests outside this window land as a 400 before any SQL
8987// runs.
90- const aiCostForecastNarrationMaxMonths = 24
88+ const maxMonths = 24
9189
92- // aiCostForecastNarrationRequest is the JSON body shape this
90+ // request is the JSON body shape this
9391// handler accepts. The shape mirrors the
9492// /api/v1/analytics/cost-forecast?vehicle_id=&months= query-
9593// string contract — vehicle_id is required, months is optional —
9694// kept as a JSON body so the SPA can post from the same form
9795// state the cost-analysis page already uses.
98- type aiCostForecastNarrationRequest struct {
96+ type request struct {
9997 VehicleID int64 `json:"vehicle_id"`
10098 Months int `json:"months,omitempty"`
10199}
102100
103- // AICostForecastNarrationHandler is the HTTP handler for
101+ // Handler is the HTTP handler for
104102// POST /api/v1/ai/charging/costs/forecast/narrate.
105103//
106104// Stateless beyond its constructor inputs; safe for concurrent
107105// use across requests. Construction is in router.go so the
108106// dispatcher's tool registry + provider registry are wired once
109107// at boot.
110- type AICostForecastNarrationHandler struct {
108+ type Handler struct {
111109 registry * provider.Registry
112110 tools * tools.Registry
113111 strategy strategy.Strategy
114112 headerName string
115113 maxIters int
116114}
117115
118- // NewAICostForecastNarrationHandler constructs the handler. All
116+ // NewHandler constructs the handler. All
119117// non-pointer arguments are required; the constructor panics on
120118// a nil so the wiring bug surfaces at boot, not at first request.
121119//
@@ -127,47 +125,47 @@ type AICostForecastNarrationHandler struct {
127125//
128126// strat: the cost-forecast-narration Strategy (one per process).
129127// headerName: forward-auth header name; used to extract subject for audit.
130- func NewAICostForecastNarrationHandler (
128+ func NewHandler (
131129 registry * provider.Registry ,
132130 toolReg * tools.Registry ,
133131 strat strategy.Strategy ,
134132 headerName string ,
135- ) * AICostForecastNarrationHandler {
133+ ) * Handler {
136134 switch {
137135 case registry == nil :
138- panic ("api: NewAICostForecastNarrationHandler : nil provider.Registry" )
136+ panic ("aicostfcst: NewHandler : nil provider.Registry" )
139137 case toolReg == nil :
140- panic ("api: NewAICostForecastNarrationHandler : nil tools.Registry" )
138+ panic ("aicostfcst: NewHandler : nil tools.Registry" )
141139 case strat == nil :
142- panic ("api: NewAICostForecastNarrationHandler : nil strategy.Strategy" )
140+ panic ("aicostfcst: NewHandler : nil strategy.Strategy" )
143141 }
144- return & AICostForecastNarrationHandler {
142+ return & Handler {
145143 registry : registry ,
146144 tools : toolReg ,
147145 strategy : strat ,
148146 headerName : headerName ,
149- maxIters : aiCostForecastNarrationMaxIterations ,
147+ maxIters : maxIterations ,
150148 }
151149}
152150
153- // parseCostForecastNarrationBody decodes + validates the JSON
151+ // parseBody decodes + validates the JSON
154152// body. Pulled out so the validator-only test can exercise the
155153// same parsing without constructing a full handler with stub
156154// deps. The function writes a 400 on failure and returns the
157155// (req, ok) pair so the caller can early-return.
158156//
159157// The months field defaults to
160- // aiCostForecastNarrationDefaultMonths when omitted (or zero) and
161- // is bounded to [1, aiCostForecastNarrationMaxMonths ] so an
158+ // defaultMonths when omitted (or zero) and
159+ // is bounded to [1, maxMonths ] so an
162160// out-of-range value lands as a 400 before any SSE stream is
163161// opened.
164- func parseCostForecastNarrationBody (w http.ResponseWriter , r * http.Request ) (* aiCostForecastNarrationRequest , bool ) {
162+ func parseBody (w http.ResponseWriter , r * http.Request ) (* request , bool ) {
165163 if r .Body == nil {
166164 writeError (w , http .StatusBadRequest , "request body is required" )
167165 return nil , false
168166 }
169167 defer r .Body .Close ()
170- var req aiCostForecastNarrationRequest
168+ var req request
171169 dec := json .NewDecoder (r .Body )
172170 dec .DisallowUnknownFields ()
173171 if err := dec .Decode (& req ); err != nil {
@@ -179,10 +177,10 @@ func parseCostForecastNarrationBody(w http.ResponseWriter, r *http.Request) (*ai
179177 return nil , false
180178 }
181179 if req .Months == 0 {
182- req .Months = aiCostForecastNarrationDefaultMonths
180+ req .Months = defaultMonths
183181 }
184- if req .Months < 1 || req .Months > aiCostForecastNarrationMaxMonths {
185- writeError (w , http .StatusBadRequest , fmt .Sprintf ("months must be between 1 and %d" , aiCostForecastNarrationMaxMonths ))
182+ if req .Months < 1 || req .Months > maxMonths {
183+ writeError (w , http .StatusBadRequest , fmt .Sprintf ("months must be between 1 and %d" , maxMonths ))
186184 return nil , false
187185 }
188186 return & req , true
@@ -193,9 +191,9 @@ func parseCostForecastNarrationBody(w http.ResponseWriter, r *http.Request) (*ai
193191// dispatcher's deferred WriteDone. Every error path either writes
194192// a structured frame onto the SSE stream (when the writer has
195193// been opened) or a plain JSON 4xx/5xx (before it has).
196- func (h * AICostForecastNarrationHandler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
194+ func (h * Handler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
197195 // 1) Parse + validate the JSON body.
198- body , ok := parseCostForecastNarrationBody (w , r )
196+ body , ok := parseBody (w , r )
199197 if ! ok {
200198 return
201199 }
@@ -271,148 +269,14 @@ func (h *AICostForecastNarrationHandler) ServeHTTP(w http.ResponseWriter, r *htt
271269 }
272270}
273271
274- // Compile-time assertion: AICostForecastNarrationHandler
272+ // Compile-time assertion: Handler
275273// satisfies http.Handler.
276- var _ http.Handler = (* AICostForecastNarrationHandler )(nil )
274+ var _ http.Handler = (* Handler )(nil )
277275
278- // ---------------------------------------------------------------------
279- // Production wiring for the tool interface declared by
280- // internal/ai/tools/cost_forecast.go. Kept in the same file as
281- // the handler so the wiring intent is local to the slice;
282- // mirrors the battery-health-forecast-narrative slice's
283- // AIBatteryHealthForecaster pattern.
284- // ---------------------------------------------------------------------
285-
286- // AICostForecaster is the production forecast.CostForecaster. It
287- // delegates to the SHARED api.ComputeCostForecast helper that
288- // also backs the canonical GET /api/v1/analytics/cost-forecast
289- // handler so the AI narration is grounded in the SAME
290- // deterministic forecast model the chart on /cost-analysis
291- // renders. No new SQL is added by this slice.
292- //
293- // Refactoring the existing CostForecastHandler.GetForecast to
294- // pull its core into the package-level ComputeCostForecast helper
295- // (and having both call sites use it) was the deliberate choice
296- // over duplicating the SQL/math here — the slice 0029 rubber-duck
297- // critique flagged duplicated SQL as a blocking issue.
298- //
299- // The struct holds *database.DB; the constructor panics on a
300- // nil so a wiring bug surfaces at boot.
301- type AICostForecaster struct {
302- db * database.DB
276+ func denyAllConfirm (_ context.Context , _ dispatch.ConfirmRequest ) (dispatch.ConfirmDecision , error ) {
277+ return dispatch .ConfirmDenied , nil
303278}
304279
305- // NewAICostForecaster constructs the adapter. Panics on a nil
306- // *database.DB so a wiring mistake surfaces at boot rather than
307- // as a nil-deref on first AI request.
308- func NewAICostForecaster (db * database.DB ) * AICostForecaster {
309- if db == nil {
310- panic ("api: NewAICostForecaster: nil *database.DB" )
311- }
312- return & AICostForecaster {db : db }
280+ func writeError (w http.ResponseWriter , status int , msg string ) {
281+ httpx .WriteError (w , status , msg )
313282}
314-
315- // ForecastCosts implements forecast.CostForecaster. Composes the
316- // SAME api.ComputeCostForecast helper *CostForecastHandler.GetForecast
317- // uses so the returned envelope is numerically identical (modulo
318- // rounding) to what GET /api/v1/analytics/cost-forecast produces
319- // — the AI surface is grounded in the SAME deterministic model
320- // the chart renders.
321- //
322- // The function does NOT recompute or override anything the
323- // canonical handler computes; it only reshapes the existing
324- // output into the typed [forecast.CostForecast] envelope the LLM
325- // can quote.
326- //
327- // Currency is left empty for now: the existing baseline response
328- // does not surface a currency code, and Phase-48's SI-canonical
329- // migration left cost_currency on charging_sessions but the
330- // aggregated `cost_decimal` already mixes currencies at the row
331- // level. Surfacing a single currency for the aggregate would
332- // require a separate query + assumption layer that lives outside
333- // this slice. The narrator's system prompt does not assume any
334- // currency code; it quotes raw dollar figures consistent with
335- // the chart.
336- func (a * AICostForecaster ) ForecastCosts (ctx context.Context , vehicleID int64 , months int ) (* forecast.CostForecast , error ) {
337- if vehicleID <= 0 {
338- return nil , errors .New ("api ai cost-forecast-narration: vehicle_id must be > 0" )
339- }
340- if months <= 0 {
341- months = aiCostForecastNarrationDefaultMonths
342- }
343-
344- resp , meta , err := ComputeCostForecast (ctx , a .db , vehicleID , months )
345- if err != nil {
346- return nil , fmt .Errorf ("api ai cost-forecast-narration: ComputeCostForecast: %w" , err )
347- }
348-
349- // Reshape the wire-shape response + metadata into the
350- // typed AI envelope. Field-by-field copy keeps the AI
351- // envelope decoupled from any future widening of the
352- // internal historicalMonth / forecastMonth structs (the
353- // narrator should remain pinned to a stable shape).
354- historical := make ([]forecast.CostForecastHistoricalMonth , 0 , len (resp .Historical ))
355- for _ , m := range resp .Historical {
356- historical = append (historical , forecast.CostForecastHistoricalMonth {
357- Month : m .Month ,
358- Cost : m .Cost ,
359- KWh : m .KWh ,
360- Sessions : m .Sessions ,
361- CostPerKWh : m .CostPerKWh ,
362- })
363- }
364- forecastMonths := make ([]forecast.CostForecastFutureMonth , 0 , len (resp .Forecast ))
365- for _ , m := range resp .Forecast {
366- forecastMonths = append (forecastMonths , forecast.CostForecastFutureMonth {
367- Month : m .Month ,
368- Cost : m .Cost ,
369- CostLow : m .CostLow ,
370- CostHigh : m .CostHigh ,
371- KWh : m .KWh ,
372- })
373- }
374-
375- insights := append ([]string (nil ), resp .Insights ... )
376- assumptions := append ([]string (nil ), meta .Assumptions ... )
377-
378- return & forecast.CostForecast {
379- VehicleID : vehicleID ,
380- Currency : "" , // see method-level doc comment
381- HistoricalMonthCount : meta .HistoricalMonthCount ,
382- MinRequiredMonths : meta .MinRequiredMonths ,
383- HasEnoughData : meta .HasEnoughData ,
384- DataThroughMonth : meta .DataThroughMonth ,
385- ForecastMonths : meta .ForecastMonths ,
386- ForecastMethod : meta .ForecastMethod ,
387- UncertaintyMethod : meta .UncertaintyMethod ,
388- UncertaintyLevel : meta .UncertaintyLevel ,
389- Assumptions : assumptions ,
390- Historical : historical ,
391- Forecast : forecastMonths ,
392- Breakdown : forecast.CostForecastBreakdown {
393- Home : forecast.CostForecastChargerCategory {
394- Pct : resp .Breakdown .Home .Pct ,
395- AvgCostPerKWh : resp .Breakdown .Home .AvgCostPerKWh ,
396- MonthlyAvg : resp .Breakdown .Home .MonthlyAvg ,
397- },
398- Supercharger : forecast.CostForecastChargerCategory {
399- Pct : resp .Breakdown .Supercharger .Pct ,
400- AvgCostPerKWh : resp .Breakdown .Supercharger .AvgCostPerKWh ,
401- MonthlyAvg : resp .Breakdown .Supercharger .MonthlyAvg ,
402- },
403- },
404- GasComparison : forecast.CostForecastGasComparison {
405- AvgKmPerMonth : resp .GasComparison .AvgKmPerMonth ,
406- GasCostPerMonth : resp .GasComparison .GasCostPerMonth ,
407- EvCostPerMonth : resp .GasComparison .EvCostPerMonth ,
408- MonthlySavings : resp .GasComparison .MonthlySavings ,
409- AnnualSavings : resp .GasComparison .AnnualSavings ,
410- LifetimeSavings : resp .GasComparison .LifetimeSavings ,
411- },
412- Insights : insights ,
413- }, nil
414- }
415-
416- // Compile-time assertion: AICostForecaster satisfies
417- // forecast.CostForecaster.
418- var _ forecast.CostForecaster = (* AICostForecaster )(nil )
0 commit comments