Skip to content

Commit a50c9f5

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.139): carve internal/api/aiperiodcmp subpackage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent edf40c1 commit a50c9f5

4 files changed

Lines changed: 97 additions & 61 deletions

File tree

internal/api/aiperiodcmp/doc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Package aiperiodcmp serves POST /api/v1/ai/analytics/period-compare/narrate,
2+
// the opt-in AI narration layer for deterministic period comparison analytics.
3+
// It owns request validation, provider dispatch, and SSE streaming while the
4+
// canonical period-stat computation remains in package periodstats for baseline reuse.
5+
//
6+
// Layer: handler
7+
package aiperiodcmp

internal/api/ai_period_compare_narration_handler.go renamed to internal/api/aiperiodcmp/handler.go

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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+
}

internal/api/ai_period_compare_narration_handler_test.go renamed to internal/api/aiperiodcmp/handler_test.go

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
// (`go run ./cmd/ai-eval -feature period-compare-narration`);
1616
// duplicating that here would require a live database fixture.
1717

18-
package api
18+
package aiperiodcmp
1919

2020
import (
2121
"bytes"
22+
"context"
2223
"net/http"
2324
"net/http/httptest"
2425
"strings"
@@ -30,6 +31,24 @@ import (
3031
"github.com/ev-dev-labs/teslasync/internal/ai/tools/forecast"
3132
)
3233

34+
// stubGuardSettings is a minimal in-memory guard.Settings used to
35+
// drive the off-mode contract test without a real DB.
36+
type stubGuardSettings struct {
37+
mode string
38+
on map[string]bool
39+
}
40+
41+
func (s *stubGuardSettings) AIMode(_ context.Context) (string, error) {
42+
if s.mode == "" {
43+
return "off", nil
44+
}
45+
return s.mode, nil
46+
}
47+
48+
func (s *stubGuardSettings) AIFeatureEnabled(_ context.Context, id string) (bool, error) {
49+
return s.on[id], nil
50+
}
51+
3352
// TestPeriodCompareNarrationAIOffShowsCardsOnly is the
3453
// load-bearing off-mode contract proof for slice 0040. It mounts
3554
// the AI period-compare-narration route through the guard with
@@ -129,36 +148,36 @@ func TestPeriodCompareNarrationAIOffShowsCardsOnly(t *testing.T) {
129148
}
130149
}
131150

132-
// TestAIPeriodCompareNarrationHandler_PanicsOnNilWiring asserts the
151+
// TestHandler_PanicsOnNilWiring asserts the
133152
// handler constructor refuses zero-valued dependencies. A wiring
134153
// bug at boot must surface as a panic, not as a nil-deref on
135154
// first request.
136-
func TestAIPeriodCompareNarrationHandler_PanicsOnNilWiring(t *testing.T) {
155+
func TestHandler_PanicsOnNilWiring(t *testing.T) {
137156
t.Parallel()
138157
cases := []struct {
139158
name string
140159
fn func()
141160
}{
142-
{"all nil", func() { NewAIPeriodCompareNarrationHandler(nil, nil, nil, "") }},
161+
{"all nil", func() { NewHandler(nil, nil, nil, "") }},
143162
}
144163
for _, tc := range cases {
145164
t.Run(tc.name, func(t *testing.T) {
146165
defer func() {
147166
if r := recover(); r == nil {
148-
t.Fatalf("NewAIPeriodCompareNarrationHandler(%s) did not panic", tc.name)
167+
t.Fatalf("NewHandler(%s) did not panic", tc.name)
149168
}
150169
}()
151170
tc.fn()
152171
})
153172
}
154173
}
155174

156-
// TestAIPeriodCompareNarrationHandler_RejectsBadBody asserts the
175+
// TestHandler_RejectsBadBody asserts the
157176
// handler validates the JSON body BEFORE opening the SSE stream
158177
// — a missing, unparseable, or out-of-range body must surface as
159178
// a JSON 400, not a half-opened stream that confuses the
160179
// frontend.
161-
func TestAIPeriodCompareNarrationHandler_RejectsBadBody(t *testing.T) {
180+
func TestHandler_RejectsBadBody(t *testing.T) {
162181
t.Parallel()
163182

164183
cases := []struct {
@@ -192,12 +211,12 @@ func TestAIPeriodCompareNarrationHandler_RejectsBadBody(t *testing.T) {
192211
}
193212
}
194213

195-
// TestAIPeriodCompareNarrationHandler_AcceptsCanonicalBody proves
214+
// TestHandler_AcceptsCanonicalBody proves
196215
// the parser does NOT bounce the happy-path shapes. Includes a
197216
// vehicle-id-only shape (days defaults applied) AND
198217
// vehicle-id+days_a+days_b explicit, AND the explicit days=0
199218
// "all time" shape the SPA selectors emit.
200-
func TestAIPeriodCompareNarrationHandler_AcceptsCanonicalBody(t *testing.T) {
219+
func TestHandler_AcceptsCanonicalBody(t *testing.T) {
201220
t.Parallel()
202221

203222
cases := []struct {
@@ -237,30 +256,30 @@ func TestAIPeriodCompareNarrationHandler_AcceptsCanonicalBody(t *testing.T) {
237256
}
238257
}
239258

240-
// TestAIPeriodCompareSource_PanicsOnNilDB asserts the production
259+
// TestPeriodCompareSource_PanicsOnNilDB asserts the production
241260
// adapter constructor refuses a nil *database.DB — a wiring bug
242261
// at boot must surface as a panic, not as a nil-deref on first
243262
// AI request.
244-
func TestAIPeriodCompareSource_PanicsOnNilDB(t *testing.T) {
263+
func TestPeriodCompareSource_PanicsOnNilDB(t *testing.T) {
245264
t.Parallel()
246265
defer func() {
247266
if r := recover(); r == nil {
248-
t.Fatalf("NewAIPeriodCompareSource(nil db) did not panic")
267+
t.Fatalf("NewPeriodCompareSource(nil db) did not panic")
249268
}
250269
}()
251-
NewAIPeriodCompareSource(nil)
270+
NewPeriodCompareSource(nil)
252271
}
253272

254-
// TestAIPeriodCompareSource_SatisfiesInterface is a compile-time +
273+
// TestPeriodCompareSource_SatisfiesInterface is a compile-time +
255274
// runtime assertion that the production adapter implements
256275
// forecast.PeriodComparator. The compile-time `var _` line in the
257276
// handler file gives the same guarantee, but this test fails with
258277
// a clear message if a future refactor accidentally narrows the
259278
// interface contract.
260-
func TestAIPeriodCompareSource_SatisfiesInterface(t *testing.T) {
279+
func TestPeriodCompareSource_SatisfiesInterface(t *testing.T) {
261280
t.Parallel()
262-
var iface forecast.PeriodComparator = (*AIPeriodCompareSource)(nil)
281+
var iface forecast.PeriodComparator = (*PeriodCompareSource)(nil)
263282
if iface == nil {
264-
t.Logf("AIPeriodCompareSource satisfies forecast.PeriodComparator (nil cast)")
283+
t.Logf("PeriodCompareSource satisfies forecast.PeriodComparator (nil cast)")
265284
}
266285
}

0 commit comments

Comments
 (0)