Skip to content

Commit ea1c526

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.131): carve internal/api/aitirepress subpackage
Move ai_tire_pressure_trend_handler.go + test (LLM tire-pressure trend reasoning) into aitirepress subpkg, rename AITirePressureTrendHandler -> Handler. Distinct from non-AI internal/api/tirepressure subpkg. Router uses aitirepress.NewHandler; AIHandlers.TirePressureTrendReasoning field accepts it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 97dc24e commit ea1c526

4 files changed

Lines changed: 92 additions & 42 deletions

File tree

internal/api/aitirepress/doc.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Package aitirepress serves the AI tire-pressure trend reasoning handler.
2+
//
3+
// It owns the LLM narration endpoint at
4+
// POST /api/v1/ai/tire-pressure/trends/explain and its read-only
5+
// TirePressureTrend tool source. It is intentionally distinct from the
6+
// non-AI internal/api/tirepressure package, which owns the deterministic TPMS
7+
// resource endpoints.
8+
//
9+
// Layer: handler
10+
package aitirepress

internal/api/ai_tire_pressure_trend_handler.go renamed to internal/api/aitirepress/handler.go

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package api
1+
package aitirepress
22

33
// Phase-50 / 0033 — T3 Tire-pressure trend reasoning.
44
//
@@ -64,24 +64,25 @@ 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/maintenance"
67+
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
6768
tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
6869
"github.com/ev-dev-labs/teslasync/internal/signal"
6970
)
7071

71-
// aiTirePressureTrendMaxIterations bounds the dispatcher's
72+
// maxIterations bounds the dispatcher's
7273
// tool-loop. The strategy is at most query_tire_pressure_trend →
7374
// answer (with optional retries). A hard ceiling of 8 is
7475
// generous, matching aiCabinTemperatureImpactMaxIterations /
7576
// aiCostForecastNarrationMaxIterations.
76-
const aiTirePressureTrendMaxIterations = 8
77+
const maxIterations = 8
7778

78-
// aiTirePressureTrendWindowDays is the trailing-window length
79+
// windowDays is the trailing-window length
7980
// the production adapter projects through signal.StateReader.
8081
// 30 days mirrors the slice prompt's "30-day trend" framing AND
8182
// the SPA's default preset on TirePressurePage.
82-
const aiTirePressureTrendWindowDays = 30
83+
const windowDays = 30
8384

84-
// aiTirePressureTrendMinReadings is the minimum total
85+
// minReadings is the minimum total
8586
// TpmsPressure* emission count (across all four corners) the
8687
// adapter requires before it lets the narrator quote a
8788
// per-tire trend. Below this threshold has_enough_data flips
@@ -90,7 +91,7 @@ const aiTirePressureTrendWindowDays = 30
9091
// across a 30-day window is too sparse to fit a meaningful
9192
// linear trend (TPMS re-emits on the order of once per drive,
9293
// sometimes only once per week for a parked vehicle).
93-
const aiTirePressureTrendMinReadings = 20
94+
const minReadings = 20
9495

9596
// Pressure thresholds in Pascals (SI). Mirror the SPA's
9697
// TirePressurePage (web/src/features/vehicle-systems/pages/
@@ -110,55 +111,77 @@ const (
110111
// correlation hint.
111112
const tireOutsideTempSignal = "OutsideTemp"
112113

113-
// aiTirePressureTrendRequest is the JSON body shape this
114+
// Signal → JSON field mappings for TPMS timeline / state projection.
115+
// Field names are snake_case; the frontend camelCaseKeys transform produces
116+
// matching camelCase keys (e.g. front_left → frontLeft).
117+
var tirePressureMappings = []signal.FieldMapping{
118+
{Signal: "TpmsPressureFl", Field: "front_left"},
119+
{Signal: "TpmsPressureFr", Field: "front_right"},
120+
{Signal: "TpmsPressureRl", Field: "rear_left"},
121+
{Signal: "TpmsPressureRr", Field: "rear_right"},
122+
{Signal: "TpmsLastSeenPressureTimeFl", Field: "last_seen_fl"},
123+
{Signal: "TpmsLastSeenPressureTimeFr", Field: "last_seen_fr"},
124+
{Signal: "TpmsLastSeenPressureTimeRl", Field: "last_seen_rl"},
125+
{Signal: "TpmsLastSeenPressureTimeRr", Field: "last_seen_rr"},
126+
}
127+
128+
func writeError(w http.ResponseWriter, status int, msg string) {
129+
httpx.WriteError(w, status, msg)
130+
}
131+
132+
func denyAllConfirm(_ context.Context, _ dispatch.ConfirmRequest) (dispatch.ConfirmDecision, error) {
133+
return dispatch.ConfirmDenied, nil
134+
}
135+
136+
// request is the JSON body shape this
114137
// handler accepts. Mirrors the
115138
// /api/v1/tire-pressure?vehicle_id= query-string contract —
116139
// vehicle_id is required, no other params — kept as a JSON body
117140
// so the SPA can post from the same form state the
118141
// tire-pressure page already uses.
119-
type aiTirePressureTrendRequest struct {
142+
type request struct {
120143
VehicleID int64 `json:"vehicle_id"`
121144
}
122145

123-
// AITirePressureTrendHandler is the HTTP handler for
146+
// Handler is the HTTP handler for
124147
// POST /api/v1/ai/tire-pressure/trends/explain.
125148
//
126149
// Stateless beyond its constructor inputs; safe for concurrent
127150
// use across requests. Construction is in router.go so the
128151
// dispatcher's tool registry + provider registry are wired once
129152
// at boot.
130-
type AITirePressureTrendHandler struct {
153+
type Handler struct {
131154
registry *provider.Registry
132155
tools *tools.Registry
133156
strategy strategy.Strategy
134157
headerName string
135158
maxIters int
136159
}
137160

138-
// NewAITirePressureTrendHandler constructs the handler. All
161+
// NewHandler constructs the handler. All
139162
// non-pointer arguments are required; the constructor panics on
140163
// a nil so the wiring bug surfaces at boot, not at first
141164
// request.
142-
func NewAITirePressureTrendHandler(
165+
func NewHandler(
143166
registry *provider.Registry,
144167
toolReg *tools.Registry,
145168
strat strategy.Strategy,
146169
headerName string,
147-
) *AITirePressureTrendHandler {
170+
) *Handler {
148171
switch {
149172
case registry == nil:
150-
panic("api: NewAITirePressureTrendHandler: nil provider.Registry")
173+
panic("aitirepress: NewHandler: nil provider.Registry")
151174
case toolReg == nil:
152-
panic("api: NewAITirePressureTrendHandler: nil tools.Registry")
175+
panic("aitirepress: NewHandler: nil tools.Registry")
153176
case strat == nil:
154-
panic("api: NewAITirePressureTrendHandler: nil strategy.Strategy")
177+
panic("aitirepress: NewHandler: nil strategy.Strategy")
155178
}
156-
return &AITirePressureTrendHandler{
179+
return &Handler{
157180
registry: registry,
158181
tools: toolReg,
159182
strategy: strat,
160183
headerName: headerName,
161-
maxIters: aiTirePressureTrendMaxIterations,
184+
maxIters: maxIterations,
162185
}
163186
}
164187

@@ -167,13 +190,13 @@ func NewAITirePressureTrendHandler(
167190
// same parsing without constructing a full handler with stub
168191
// deps. The function writes a 400 on failure and returns the
169192
// (req, ok) pair so the caller can early-return.
170-
func parseTirePressureTrendBody(w http.ResponseWriter, r *http.Request) (*aiTirePressureTrendRequest, bool) {
193+
func parseTirePressureTrendBody(w http.ResponseWriter, r *http.Request) (*request, bool) {
171194
if r.Body == nil {
172195
writeError(w, http.StatusBadRequest, "request body is required")
173196
return nil, false
174197
}
175198
defer r.Body.Close()
176-
var req aiTirePressureTrendRequest
199+
var req request
177200
dec := json.NewDecoder(r.Body)
178201
dec.DisallowUnknownFields()
179202
if err := dec.Decode(&req); err != nil {
@@ -193,7 +216,7 @@ func parseTirePressureTrendBody(w http.ResponseWriter, r *http.Request) (*aiTire
193216
// writes a structured frame onto the SSE stream (when the
194217
// writer has been opened) or a plain JSON 4xx/5xx (before it
195218
// has).
196-
func (h *AITirePressureTrendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
219+
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
197220
body, ok := parseTirePressureTrendBody(w, r)
198221
if !ok {
199222
return
@@ -262,9 +285,9 @@ func (h *AITirePressureTrendHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
262285
}
263286
}
264287

265-
// Compile-time assertion: AITirePressureTrendHandler satisfies
288+
// Compile-time assertion: Handler satisfies
266289
// http.Handler.
267-
var _ http.Handler = (*AITirePressureTrendHandler)(nil)
290+
var _ http.Handler = (*Handler)(nil)
268291

269292
// ---------------------------------------------------------------------
270293
// Production wiring for the tool interface declared by
@@ -292,7 +315,7 @@ type AITirePressureTrendSource struct {
292315
// rather than as a nil-deref on first AI request.
293316
func NewAITirePressureTrendSource(state signal.StateReader) *AITirePressureTrendSource {
294317
if state == nil {
295-
panic("api: NewAITirePressureTrendSource: nil signal.StateReader")
318+
panic("aitirepress: NewAITirePressureTrendSource: nil signal.StateReader")
296319
}
297320
return &AITirePressureTrendSource{state: state}
298321
}
@@ -327,7 +350,7 @@ func (a *AITirePressureTrendSource) QueryTirePressureTrend(ctx context.Context,
327350
}
328351

329352
to := time.Now()
330-
from := to.AddDate(0, 0, -aiTirePressureTrendWindowDays)
353+
from := to.AddDate(0, 0, -windowDays)
331354

332355
// Project the 4 TPMS corners + OutsideTemp across the
333356
// 30-day window in chart mode (one row per emission, no
@@ -342,13 +365,13 @@ func (a *AITirePressureTrendSource) QueryTirePressureTrend(ctx context.Context,
342365

343366
envelope := &maintenance.TirePressureTrend{
344367
VehicleID: vehicleID,
345-
WindowDays: aiTirePressureTrendWindowDays,
346-
MinRequiredReadings: aiTirePressureTrendMinReadings,
368+
WindowDays: windowDays,
369+
MinRequiredReadings: minReadings,
347370
Method: "Linear least-squares slope across the 30-day TpmsPressure* change-feed window per corner; corner status is assigned by the deterministic soft-low / normal-min / normal-max / soft-high thresholds; outside-ambient summary is the rolling 30-day average / min / max of the OutsideTemp signal.",
348371
Assumptions: []string{
349372
"Per-corner trend is a descriptive linear slope across the recent change-feed window; it is NOT a forecast or regression model.",
350373
"Outside ambient correlation is a heuristic: when all four corners trend down together AND the rolling average outside temperature dropped materially across the same window, seasonal contraction is the most likely deterministic driver rather than a puncture.",
351-
fmt.Sprintf("Minimum total TpmsPressure emission count across all four corners for a meaningful narrative is %d readings; below this threshold has_enough_data is false.", aiTirePressureTrendMinReadings),
374+
fmt.Sprintf("Minimum total TpmsPressure emission count across all four corners for a meaningful narrative is %d readings; below this threshold has_enough_data is false.", minReadings),
352375
},
353376
Thresholds: maintenance.TirePressureThresholds{
354377
SoftLowPa: tirePressureSoftLowPa,
@@ -387,7 +410,7 @@ func (a *AITirePressureTrendSource) QueryTirePressureTrend(ctx context.Context,
387410
}
388411

389412
envelope.SampleSize = totalReadings
390-
envelope.HasEnoughData = totalReadings >= aiTirePressureTrendMinReadings
413+
envelope.HasEnoughData = totalReadings >= minReadings
391414

392415
// Outside-temperature summary across the same window.
393416
outside := extractOutsideTempSummary(rows)

internal/api/ai_tire_pressure_trend_handler_test.go renamed to internal/api/aitirepress/handler_test.go

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
// (`go run ./cmd/ai-eval -feature tire-pressure-trend-reasoning`);
1616
// duplicating that here would require a live database fixture.
1717

18-
package api
18+
package aitirepress
1919

2020
import (
2121
"bytes"
@@ -33,6 +33,22 @@ import (
3333
"github.com/ev-dev-labs/teslasync/internal/signal"
3434
)
3535

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+
3652
// TestTirePressureReasoningAIOffShowsThresholdsOnly is the
3753
// load-bearing off-mode contract proof for slice 0033. It mounts
3854
// the AI tire-pressure-trend-reasoning route through the guard
@@ -133,35 +149,35 @@ func TestTirePressureReasoningAIOffShowsThresholdsOnly(t *testing.T) {
133149
}
134150
}
135151

136-
// TestAITirePressureTrendHandler_PanicsOnNilWiring asserts the
152+
// TestHandler_PanicsOnNilWiring asserts the
137153
// handler constructor refuses zero-valued dependencies. A wiring
138154
// bug at boot must surface as a panic, not as a nil-deref on
139155
// first request.
140-
func TestAITirePressureTrendHandler_PanicsOnNilWiring(t *testing.T) {
156+
func TestHandler_PanicsOnNilWiring(t *testing.T) {
141157
t.Parallel()
142158
cases := []struct {
143159
name string
144160
fn func()
145161
}{
146-
{"all nil", func() { NewAITirePressureTrendHandler(nil, nil, nil, "") }},
162+
{"all nil", func() { NewHandler(nil, nil, nil, "") }},
147163
}
148164
for _, tc := range cases {
149165
t.Run(tc.name, func(t *testing.T) {
150166
defer func() {
151167
if r := recover(); r == nil {
152-
t.Fatalf("NewAITirePressureTrendHandler(%s) did not panic", tc.name)
168+
t.Fatalf("NewHandler(%s) did not panic", tc.name)
153169
}
154170
}()
155171
tc.fn()
156172
})
157173
}
158174
}
159175

160-
// TestAITirePressureTrendHandler_RejectsBadBody asserts the
176+
// TestHandler_RejectsBadBody asserts the
161177
// handler validates the JSON body BEFORE opening the SSE stream
162178
// — a missing or unparseable body must surface as a JSON 400,
163179
// not a half-opened stream that confuses the frontend.
164-
func TestAITirePressureTrendHandler_RejectsBadBody(t *testing.T) {
180+
func TestHandler_RejectsBadBody(t *testing.T) {
165181
t.Parallel()
166182

167183
cases := []struct {
@@ -191,9 +207,9 @@ func TestAITirePressureTrendHandler_RejectsBadBody(t *testing.T) {
191207
}
192208
}
193209

194-
// TestAITirePressureTrendHandler_AcceptsCanonicalBody proves the
210+
// TestHandler_AcceptsCanonicalBody proves the
195211
// parser does NOT bounce the happy-path shapes.
196-
func TestAITirePressureTrendHandler_AcceptsCanonicalBody(t *testing.T) {
212+
func TestHandler_AcceptsCanonicalBody(t *testing.T) {
197213
t.Parallel()
198214

199215
cases := []struct {
@@ -267,7 +283,7 @@ func (f *fakeTirePressureState) Timeline(_ context.Context, _ int64, _ []signal.
267283

268284
// TestQueryTirePressureTrend_HasEnoughDataFalseClearsPerCornerStatus
269285
// proves that when the total reading count across all four
270-
// corners is below aiTirePressureTrendMinReadings the adapter
286+
// corners is below minReadings the adapter
271287
// clears per-corner Status / RatePaPerDay / DaysUntilSoftLowEstimate
272288
// AND returns empty LikelyCauses + Insights — defence in depth so
273289
// the narrator cannot quote a noisy classification from a 3-row

internal/api/router.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
apiadminfb "github.com/ev-dev-labs/teslasync/internal/api/adminfeedback"
1818
apiadminls "github.com/ev-dev-labs/teslasync/internal/api/adminlogstream"
1919
apiadminmnt "github.com/ev-dev-labs/teslasync/internal/api/adminmaintenance"
20+
aitirepress "github.com/ev-dev-labs/teslasync/internal/api/aitirepress"
2021
apialertmsg "github.com/ev-dev-labs/teslasync/internal/api/alertmsg"
2122
apialerts "github.com/ev-dev-labs/teslasync/internal/api/alerts"
2223
apianalytics "github.com/ev-dev-labs/teslasync/internal/api/analytics"
@@ -1669,13 +1670,13 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
16691670
// TirePressureHandler.List already runs — no parallel write
16701671
// path; the LLM never persists.
16711672
maintenance.RegisterTirePressureTrendReasoningTools(aiToolRegistry, maintenance.TirePressureTrendReasoningSources{
1672-
Source: NewAITirePressureTrendSource(stateReader),
1673+
Source: aitirepress.NewAITirePressureTrendSource(stateReader),
16731674
})
16741675
// tire-pressure-trend-reasoning handler. One per process;
16751676
// stateless beyond constructor inputs. Must be constructed
16761677
// AFTER the tool registration above so the dispatcher can
16771678
// resolve the strategy's allowedTools at boot.
1678-
aiTirePressureTrendReasoningHandler := NewAITirePressureTrendHandler(
1679+
aiTirePressureTrendReasoningHandler := aitirepress.NewHandler(
16791680
aiRegistry,
16801681
aiToolRegistry,
16811682
tirepressuretrendreasoning.New(),

0 commit comments

Comments
 (0)