Skip to content

Commit 6b32b8e

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.127): carve internal/api/aicostfcst subpackage
Move ai_cost_forecast_narration_handler.go + test into aicostfcst subpkg, rename AICostForecastNarrationHandler -> Handler. Router uses aicostfcst.NewHandler; AIHandlers.CostForecastNarration field accepts it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 97dc24e commit 6b32b8e

6 files changed

Lines changed: 259 additions & 216 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package api
2+
3+
import (
4+
"testing"
5+
6+
"github.com/ev-dev-labs/teslasync/internal/ai/tools/forecast"
7+
)
8+
9+
// TestAICostForecaster_PanicsOnNilDB asserts the production
10+
// adapter constructor refuses a nil *database.DB — a wiring bug
11+
// at boot must surface as a panic, not as a nil-deref on first
12+
// AI request.
13+
func TestAICostForecaster_PanicsOnNilDB(t *testing.T) {
14+
t.Parallel()
15+
defer func() {
16+
if r := recover(); r == nil {
17+
t.Fatalf("NewAICostForecaster(nil db) did not panic")
18+
}
19+
}()
20+
NewAICostForecaster(nil)
21+
}
22+
23+
// TestAICostForecaster_SatisfiesInterface is a compile-time +
24+
// runtime assertion that the production adapter implements
25+
// forecast.CostForecaster. The compile-time `var _` line in the
26+
// forecaster file gives the same guarantee, but this test fails with
27+
// a clear message if a future refactor accidentally narrows the
28+
// interface contract.
29+
func TestAICostForecaster_SatisfiesInterface(t *testing.T) {
30+
t.Parallel()
31+
var iface forecast.CostForecaster = (*AICostForecaster)(nil)
32+
if iface == nil {
33+
t.Logf("AICostForecaster satisfies forecast.CostForecaster (nil cast)")
34+
}
35+
}

internal/api/aicostfcst/doc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Package aicostfcst serves POST /api/v1/ai/charging/costs/forecast/narrate,
2+
// the opt-in AI narration layer for the deterministic charging cost forecast.
3+
// It owns request validation, provider dispatch, and SSE streaming while the
4+
// canonical forecast computation remains in package api for baseline reuse.
5+
//
6+
// Layer: handler
7+
package aicostfcst
Lines changed: 36 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package api
1+
package aicostfcst
22

33
// Phase-50 / 0029 — C4 Cost forecast narration.
44
//
@@ -50,7 +50,6 @@ package api
5050
import (
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

Comments
 (0)