1- package api
1+ package aiperiodcmp
22
33// Phase-50 / 0040 — X1 Period compare narration.
44//
@@ -64,61 +64,62 @@ import (
6464 "github.com/ev-dev-labs/teslasync/internal/ai/stream"
6565 "github.com/ev-dev-labs/teslasync/internal/ai/tools"
6666 "github.com/ev-dev-labs/teslasync/internal/ai/tools/forecast"
67+ "github.com/ev-dev-labs/teslasync/internal/api/httpx"
6768 apiperiod "github.com/ev-dev-labs/teslasync/internal/api/periodstats"
6869 tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
6970 "github.com/ev-dev-labs/teslasync/internal/database"
7071)
7172
72- // aiPeriodCompareNarrationMaxIterations bounds the dispatcher's
73+ // maxIterations bounds the dispatcher's
7374// tool-loop. The strategy is at most query_period_compare → answer
7475// (with optional retries). A hard ceiling of 8 is generous,
7576// matching aiCostForecastNarrationMaxIterations.
76- const aiPeriodCompareNarrationMaxIterations = 8
77+ const maxIterations = 8
7778
78- // aiPeriodCompareNarrationDefaultDaysA / DaysB are the default
79+ // defaultDaysA / DaysB are the default
7980// trailing-day windows when the request body omits them. Mirrors
8081// the SPA's PeriodComparePage selector defaults (Period A=30d,
8182// Period B=90d). Kept as named constants so a future tuning lives
8283// in one place rather than duplicated across the parser + the
8384// tool's Execute default.
84- const aiPeriodCompareNarrationDefaultDaysA = 30
85- const aiPeriodCompareNarrationDefaultDaysB = 90
85+ const defaultDaysA = 30
86+ const defaultDaysB = 90
8687
87- // aiPeriodCompareNarrationMaxDays is the upper bound on the
88+ // maxDays is the upper bound on the
8889// trailing-day window. Mirrors the canonical handler's lack of an
8990// explicit cap (the SPA selectors top out at 365 + a "all time"
9091// option which sends days=0); 3650 ≈ 10 years caps an LLM
9192// nonsense value before any SQL runs.
92- const aiPeriodCompareNarrationMaxDays = 3650
93+ const maxDays = 3650
9394
94- // aiPeriodCompareNarrationRequest is the JSON body shape this
95+ // request is the JSON body shape this
9596// handler accepts. The shape mirrors the
9697// /api/v1/analytics/period-stats?vehicle_id=&days= query-string
9798// contract — vehicle_id is required, days_a / days_b are optional
9899// — kept as a JSON body so the SPA can post from the same form
99100// state the period-compare page already uses.
100- type aiPeriodCompareNarrationRequest struct {
101+ type request struct {
101102 VehicleID int64 `json:"vehicle_id"`
102103 DaysA int `json:"days_a,omitempty"`
103104 DaysB int `json:"days_b,omitempty"`
104105}
105106
106- // AIPeriodCompareNarrationHandler is the HTTP handler for
107+ // Handler is the HTTP handler for
107108// POST /api/v1/ai/analytics/period-compare/narrate.
108109//
109110// Stateless beyond its constructor inputs; safe for concurrent
110111// use across requests. Construction is in router.go so the
111112// dispatcher's tool registry + provider registry are wired once
112113// at boot.
113- type AIPeriodCompareNarrationHandler struct {
114+ type Handler struct {
114115 registry * provider.Registry
115116 tools * tools.Registry
116117 strategy strategy.Strategy
117118 headerName string
118119 maxIters int
119120}
120121
121- // NewAIPeriodCompareNarrationHandler constructs the handler. All
122+ // NewHandler constructs the handler. All
122123// non-pointer arguments are required; the constructor panics on
123124// a nil so the wiring bug surfaces at boot, not at first request.
124125//
@@ -130,26 +131,26 @@ type AIPeriodCompareNarrationHandler struct {
130131//
131132// strat: the period-compare-narration Strategy (one per process).
132133// headerName: forward-auth header name; used to extract subject for audit.
133- func NewAIPeriodCompareNarrationHandler (
134+ func NewHandler (
134135 registry * provider.Registry ,
135136 toolReg * tools.Registry ,
136137 strat strategy.Strategy ,
137138 headerName string ,
138- ) * AIPeriodCompareNarrationHandler {
139+ ) * Handler {
139140 switch {
140141 case registry == nil :
141- panic ("api: NewAIPeriodCompareNarrationHandler : nil provider.Registry" )
142+ panic ("aiperiodcmp: NewHandler : nil provider.Registry" )
142143 case toolReg == nil :
143- panic ("api: NewAIPeriodCompareNarrationHandler : nil tools.Registry" )
144+ panic ("aiperiodcmp: NewHandler : nil tools.Registry" )
144145 case strat == nil :
145- panic ("api: NewAIPeriodCompareNarrationHandler : nil strategy.Strategy" )
146+ panic ("aiperiodcmp: NewHandler : nil strategy.Strategy" )
146147 }
147- return & AIPeriodCompareNarrationHandler {
148+ return & Handler {
148149 registry : registry ,
149150 tools : toolReg ,
150151 strategy : strat ,
151152 headerName : headerName ,
152- maxIters : aiPeriodCompareNarrationMaxIterations ,
153+ maxIters : maxIterations ,
153154 }
154155}
155156
@@ -160,11 +161,11 @@ func NewAIPeriodCompareNarrationHandler(
160161// (req, ok) pair so the caller can early-return.
161162//
162163// The days_a / days_b fields default to
163- // aiPeriodCompareNarrationDefaultDaysA / DaysB when omitted (or
164+ // defaultDaysA / DaysB when omitted (or
164165// zero AND omitted; an explicit "0" means "all time" — mirrored
165166// by the underlying ComputePeriodStats helper that drops the
166167// date filter when days <= 0). The bound is [0,
167- // aiPeriodCompareNarrationMaxDays ] so an out-of-range value
168+ // maxDays ] so an out-of-range value
168169// lands as a 400 before any SSE stream is opened.
169170//
170171// Note on zero handling: because the SPA selectors include a
@@ -173,7 +174,7 @@ func NewAIPeriodCompareNarrationHandler(
173174// backed approach: if the field is OMITTED entirely (raw bytes
174175// don't include "days_a"), default to 30; if explicitly set to
175176// 0, treat as "all time" and pass through.
176- func parsePeriodCompareNarrationBody (w http.ResponseWriter , r * http.Request ) (* aiPeriodCompareNarrationRequest , bool ) {
177+ func parsePeriodCompareNarrationBody (w http.ResponseWriter , r * http.Request ) (* request , bool ) {
177178 if r .Body == nil {
178179 writeError (w , http .StatusBadRequest , "request body is required" )
179180 return nil , false
@@ -191,9 +192,9 @@ func parsePeriodCompareNarrationBody(w http.ResponseWriter, r *http.Request) (*a
191192 return nil , false
192193 }
193194
194- req := aiPeriodCompareNarrationRequest {
195- DaysA : aiPeriodCompareNarrationDefaultDaysA ,
196- DaysB : aiPeriodCompareNarrationDefaultDaysB ,
195+ req := request {
196+ DaysA : defaultDaysA ,
197+ DaysB : defaultDaysB ,
197198 }
198199
199200 if v , ok := raw ["vehicle_id" ]; ok {
@@ -231,12 +232,12 @@ func parsePeriodCompareNarrationBody(w http.ResponseWriter, r *http.Request) (*a
231232 writeError (w , http .StatusBadRequest , "vehicle_id must be > 0" )
232233 return nil , false
233234 }
234- if req .DaysA < 0 || req .DaysA > aiPeriodCompareNarrationMaxDays {
235- writeError (w , http .StatusBadRequest , fmt .Sprintf ("days_a must be between 0 and %d" , aiPeriodCompareNarrationMaxDays ))
235+ if req .DaysA < 0 || req .DaysA > maxDays {
236+ writeError (w , http .StatusBadRequest , fmt .Sprintf ("days_a must be between 0 and %d" , maxDays ))
236237 return nil , false
237238 }
238- if req .DaysB < 0 || req .DaysB > aiPeriodCompareNarrationMaxDays {
239- writeError (w , http .StatusBadRequest , fmt .Sprintf ("days_b must be between 0 and %d" , aiPeriodCompareNarrationMaxDays ))
239+ if req .DaysB < 0 || req .DaysB > maxDays {
240+ writeError (w , http .StatusBadRequest , fmt .Sprintf ("days_b must be between 0 and %d" , maxDays ))
240241 return nil , false
241242 }
242243 return & req , true
@@ -247,7 +248,7 @@ func parsePeriodCompareNarrationBody(w http.ResponseWriter, r *http.Request) (*a
247248// dispatcher's deferred WriteDone. Every error path either writes
248249// a structured frame onto the SSE stream (when the writer has
249250// been opened) or a plain JSON 4xx/5xx (before it has).
250- func (h * AIPeriodCompareNarrationHandler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
251+ func (h * Handler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
251252 // 1) Parse + validate the JSON body.
252253 body , ok := parsePeriodCompareNarrationBody (w , r )
253254 if ! ok {
@@ -326,9 +327,9 @@ func (h *AIPeriodCompareNarrationHandler) ServeHTTP(w http.ResponseWriter, r *ht
326327 }
327328}
328329
329- // Compile-time assertion: AIPeriodCompareNarrationHandler
330+ // Compile-time assertion: Handler
330331// satisfies http.Handler.
331- var _ http.Handler = (* AIPeriodCompareNarrationHandler )(nil )
332+ var _ http.Handler = (* Handler )(nil )
332333
333334// ---------------------------------------------------------------------
334335// Production wiring for the tool interface declared by
@@ -338,7 +339,7 @@ var _ http.Handler = (*AIPeriodCompareNarrationHandler)(nil)
338339// pattern.
339340// ---------------------------------------------------------------------
340341
341- // AIPeriodCompareSource is the production forecast.PeriodComparator.
342+ // PeriodCompareSource is the production forecast.PeriodComparator.
342343// It delegates to the SHARED apiperiod.ComputePeriodStats helper that
343344// also backs the canonical GET /api/v1/analytics/period-stats
344345// handler so the AI narration is grounded in the SAME
@@ -352,18 +353,18 @@ var _ http.Handler = (*AIPeriodCompareNarrationHandler)(nil)
352353//
353354// The struct holds *database.DB; the constructor panics on a
354355// nil so a wiring bug surfaces at boot.
355- type AIPeriodCompareSource struct {
356+ type PeriodCompareSource struct {
356357 db * database.DB
357358}
358359
359- // NewAIPeriodCompareSource constructs the adapter. Panics on a
360+ // NewPeriodCompareSource constructs the adapter. Panics on a
360361// nil *database.DB so a wiring mistake surfaces at boot rather
361362// than as a nil-deref on first AI request.
362- func NewAIPeriodCompareSource (db * database.DB ) * AIPeriodCompareSource {
363+ func NewPeriodCompareSource (db * database.DB ) * PeriodCompareSource {
363364 if db == nil {
364- panic ("api: NewAIPeriodCompareSource : nil *database.DB" )
365+ panic ("aiperiodcmp: NewPeriodCompareSource : nil *database.DB" )
365366 }
366- return & AIPeriodCompareSource {db : db }
367+ return & PeriodCompareSource {db : db }
367368}
368369
369370// ComparePeriods implements forecast.PeriodComparator. Composes the
@@ -378,7 +379,7 @@ func NewAIPeriodCompareSource(db *database.DB) *AIPeriodCompareSource {
378379// output into the typed [forecast.PeriodCompare] envelope the LLM
379380// can quote, and computes the per-metric deltas via the shared
380381// forecast.ComputePeriodCompareDeltas helper.
381- func (a * AIPeriodCompareSource ) ComparePeriods (ctx context.Context , vehicleID int64 , daysA , daysB int ) (* forecast.PeriodCompare , error ) {
382+ func (a * PeriodCompareSource ) ComparePeriods (ctx context.Context , vehicleID int64 , daysA , daysB int ) (* forecast.PeriodCompare , error ) {
382383 if vehicleID <= 0 {
383384 return nil , errors .New ("api ai period-compare-narration: vehicle_id must be > 0" )
384385 }
@@ -419,6 +420,14 @@ func (a *AIPeriodCompareSource) ComparePeriods(ctx context.Context, vehicleID in
419420 }, nil
420421}
421422
422- // Compile-time assertion: AIPeriodCompareSource satisfies
423+ // Compile-time assertion: PeriodCompareSource satisfies
423424// forecast.PeriodComparator.
424- var _ forecast.PeriodComparator = (* AIPeriodCompareSource )(nil )
425+ var _ forecast.PeriodComparator = (* PeriodCompareSource )(nil )
426+
427+ func denyAllConfirm (_ context.Context , _ dispatch.ConfirmRequest ) (dispatch.ConfirmDecision , error ) {
428+ return dispatch .ConfirmDenied , nil
429+ }
430+
431+ func writeError (w http.ResponseWriter , status int , msg string ) {
432+ httpx .WriteError (w , status , msg )
433+ }
0 commit comments