Skip to content

Commit ca555ab

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

4 files changed

Lines changed: 86 additions & 49 deletions

File tree

internal/api/aipiiredact/doc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Package aipiiredact hosts the PII redaction shared exports AI handler.
2+
package aipiiredact
3+
4+
// Layer: handler

internal/api/ai_pii_redaction_shared_exports_handler.go renamed to internal/api/aipiiredact/handler.go

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package api
1+
package aipiiredact
22

33
// Phase-50 / 0052 — P1 Helix export redaction advisor.
44
//
@@ -75,6 +75,7 @@ package api
7575
// this slice.
7676

7777
import (
78+
"context"
7879
"encoding/json"
7980
"fmt"
8081
"io"
@@ -91,49 +92,50 @@ import (
9192
"github.com/ev-dev-labs/teslasync/internal/ai/stream"
9293
"github.com/ev-dev-labs/teslasync/internal/ai/tools"
9394
"github.com/ev-dev-labs/teslasync/internal/ai/tools/export"
95+
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
9496
tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
9597
)
9698

97-
// aiPiiRedactionSharedExportsMaxIterations bounds the
99+
// maxIterations bounds the
98100
// dispatcher's tool-loop. The strategy is exactly
99101
// draft_export_redaction_plan → validate_export_redaction_plan →
100102
// answer (with one optional retry on a transient validator
101103
// rejection that the LLM repairs by tweaking the plan). A hard
102104
// ceiling of 8 is generous, matching the other narrator
103105
// handlers.
104-
const aiPiiRedactionSharedExportsMaxIterations = 8
106+
const maxIterations = 8
105107

106-
// aiPiiRedactionSharedExportsMaxBodyBytes caps the request
108+
// maxBodyBytes caps the request
107109
// body. The body is small (1 string field); bound it cheaply.
108110
// 16 KiB matches the other body-driven AI handlers.
109-
const aiPiiRedactionSharedExportsMaxBodyBytes = 16 * 1024
111+
const maxBodyBytes = 16 * 1024
110112

111-
// aiPiiRedactionSharedExportsRequest is the typed body shape.
113+
// request is the typed body shape.
112114
// The required field is export_type; there are no other fields.
113-
type aiPiiRedactionSharedExportsRequest struct {
115+
type request struct {
114116
// ExportType identifies the export the recommendation
115117
// covers. Required, must be one of the values in
116118
// export.SharedExportTypes() ({account, analytics, backup,
117119
// charging, drives, trips}).
118120
ExportType string `json:"export_type"`
119121
}
120122

121-
// AIPiiRedactionSharedExportsHandler is the HTTP handler for
123+
// Handler is the HTTP handler for
122124
// POST /api/v1/ai/exports/redaction/draft.
123125
//
124126
// Stateless beyond its constructor inputs; safe for concurrent
125127
// use across requests. Construction is in router.go so the
126128
// dispatcher's tool registry + provider registry are wired once
127129
// at boot.
128-
type AIPiiRedactionSharedExportsHandler struct {
130+
type Handler struct {
129131
registry *provider.Registry
130132
tools *tools.Registry
131133
strategy strategy.Strategy
132134
headerName string
133135
maxIters int
134136
}
135137

136-
// NewAIPiiRedactionSharedExportsHandler constructs the handler.
138+
// NewHandler constructs the handler.
137139
// All non-pointer arguments are required; the constructor panics
138140
// on a nil so the wiring bug surfaces at boot, not at first
139141
// request.
@@ -156,79 +158,91 @@ type AIPiiRedactionSharedExportsHandler struct {
156158
// headerName: forward-auth header name; used to extract subject
157159
//
158160
// for audit.
159-
func NewAIPiiRedactionSharedExportsHandler(
161+
func NewHandler(
160162
registry *provider.Registry,
161163
toolReg *tools.Registry,
162164
strat strategy.Strategy,
163165
headerName string,
164-
) *AIPiiRedactionSharedExportsHandler {
166+
) *Handler {
165167
switch {
166168
case registry == nil:
167-
panic("api: NewAIPiiRedactionSharedExportsHandler: nil provider.Registry")
169+
panic("api/aipiiredact: NewHandler: nil provider.Registry")
168170
case toolReg == nil:
169-
panic("api: NewAIPiiRedactionSharedExportsHandler: nil tools.Registry")
171+
panic("api/aipiiredact: NewHandler: nil tools.Registry")
170172
case strat == nil:
171-
panic("api: NewAIPiiRedactionSharedExportsHandler: nil strategy.Strategy")
173+
panic("api/aipiiredact: NewHandler: nil strategy.Strategy")
172174
}
173-
return &AIPiiRedactionSharedExportsHandler{
175+
return &Handler{
174176
registry: registry,
175177
tools: toolReg,
176178
strategy: strat,
177179
headerName: headerName,
178-
maxIters: aiPiiRedactionSharedExportsMaxIterations,
180+
maxIters: maxIterations,
179181
}
180182
}
181183

182-
// parsePiiRedactionSharedExportsRequest drains the body.
184+
// parseRequest drains the body.
183185
// export_type is required and must appear in the canonical
184186
// allow-set export.SharedExportTypes() — the validator catches an
185187
// unknown value before the dispatcher is invoked. Absence /
186188
// invalid values surface as JSON 400 with a stable error key the
187189
// SPA can localise. Returns (req, true) when the body is
188190
// acceptable.
189-
func parsePiiRedactionSharedExportsRequest(w http.ResponseWriter, r *http.Request) (aiPiiRedactionSharedExportsRequest, bool) {
190-
var req aiPiiRedactionSharedExportsRequest
191+
func parseRequest(w http.ResponseWriter, r *http.Request) (request, bool) {
192+
var req request
191193
if r.Body == nil {
192-
writeError(w, http.StatusBadRequest, "missing body")
194+
httpx.WriteError(w, http.StatusBadRequest, "missing body")
193195
return req, false
194196
}
195197
defer r.Body.Close()
196-
bodyBytes, readErr := io.ReadAll(io.LimitReader(r.Body, aiPiiRedactionSharedExportsMaxBodyBytes))
198+
bodyBytes, readErr := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes))
197199
if readErr != nil {
198-
writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to read body: %v", readErr))
200+
httpx.WriteError(w, http.StatusBadRequest, fmt.Sprintf("failed to read body: %v", readErr))
199201
return req, false
200202
}
201203
if len(bytesTrim(bodyBytes)) == 0 {
202-
writeError(w, http.StatusBadRequest, "empty body")
204+
httpx.WriteError(w, http.StatusBadRequest, "empty body")
203205
return req, false
204206
}
205207
dec := json.NewDecoder(strings.NewReader(string(bodyBytes)))
206208
dec.DisallowUnknownFields()
207209
if err := dec.Decode(&req); err != nil {
208-
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON body: %v", err))
210+
httpx.WriteError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON body: %v", err))
209211
return req, false
210212
}
211213
if req.ExportType == "" {
212-
writeError(w, http.StatusBadRequest, "export_type is required")
214+
httpx.WriteError(w, http.StatusBadRequest, "export_type is required")
213215
return req, false
214216
}
215217
allowed := export.SharedExportTypes()
216218
if !slices.Contains(allowed, req.ExportType) {
217-
writeError(w, http.StatusBadRequest, fmt.Sprintf("export_type must be one of %s", strings.Join(allowed, ", ")))
219+
httpx.WriteError(w, http.StatusBadRequest, fmt.Sprintf("export_type must be one of %s", strings.Join(allowed, ", ")))
218220
return req, false
219221
}
220222
return req, true
221223
}
222224

225+
// bytesTrim is a defensive ASCII whitespace trimmer used only by
226+
// the body-empty check. Avoids importing bytes for one call.
227+
func bytesTrim(b []byte) []byte {
228+
for len(b) > 0 && (b[0] == ' ' || b[0] == '\t' || b[0] == '\r' || b[0] == '\n') {
229+
b = b[1:]
230+
}
231+
for len(b) > 0 && (b[len(b)-1] == ' ' || b[len(b)-1] == '\t' || b[len(b)-1] == '\r' || b[len(b)-1] == '\n') {
232+
b = b[:len(b)-1]
233+
}
234+
return b
235+
}
236+
223237
// ServeHTTP implements [http.Handler]. The body is parsed, the
224238
// dispatcher is invoked, and the SSE stream is closed via the
225239
// dispatcher's deferred WriteDone. Every error path either
226240
// writes a structured frame onto the SSE stream (when the
227241
// writer has been opened) or a plain JSON 4xx/5xx (before it
228242
// has).
229-
func (h *AIPiiRedactionSharedExportsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
243+
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
230244
// 1) Parse + validate the request body.
231-
req, ok := parsePiiRedactionSharedExportsRequest(w, r)
245+
req, ok := parseRequest(w, r)
232246
if !ok {
233247
return
234248
}
@@ -240,7 +254,7 @@ func (h *AIPiiRedactionSharedExportsHandler) ServeHTTP(w http.ResponseWriter, r
240254
// falls back gracefully.
241255
if _, err := h.registry.For(r.Context(), piiredactionsharedexports.FeatureID); err != nil {
242256
log.Error().Err(err).Msg("ai pii-redaction-shared-exports: provider.For failed")
243-
writeError(w, http.StatusBadGateway, "ai provider unavailable")
257+
httpx.WriteError(w, http.StatusBadGateway, "ai provider unavailable")
244258
return
245259
}
246260

@@ -258,7 +272,7 @@ func (h *AIPiiRedactionSharedExportsHandler) ServeHTTP(w http.ResponseWriter, r
258272
sseW, ctx, err := stream.New(ctx, w, stream.WithFeatureID(piiredactionsharedexports.FeatureID))
259273
if err != nil {
260274
log.Error().Err(err).Msg("ai pii-redaction-shared-exports: stream.New failed (non-flushable writer)")
261-
writeError(w, http.StatusInternalServerError, "streaming not supported")
275+
httpx.WriteError(w, http.StatusInternalServerError, "streaming not supported")
262276
return
263277
}
264278

@@ -283,7 +297,7 @@ func (h *AIPiiRedactionSharedExportsHandler) ServeHTTP(w http.ResponseWriter, r
283297
// export_type and instructs the tool sequence EXACTLY:
284298
// draft_export_redaction_plan first, then
285299
// validate_export_redaction_plan, then narration.
286-
userMsg := buildPiiRedactionSharedExportsUserMessage(req.ExportType)
300+
userMsg := buildUserMessage(req.ExportType)
287301

288302
// 8) Run the dispatcher.
289303
in := strategy.StrategyInput{
@@ -297,11 +311,11 @@ func (h *AIPiiRedactionSharedExportsHandler) ServeHTTP(w http.ResponseWriter, r
297311
}
298312
}
299313

300-
// buildPiiRedactionSharedExportsUserMessage synthesises the
314+
// buildUserMessage synthesises the
301315
// export_type-scoped user message the LLM sees. The format is
302316
// deterministic so canned goldens and provider prompt-hash
303317
// caches stay stable across boots.
304-
func buildPiiRedactionSharedExportsUserMessage(exportType string) string {
318+
func buildUserMessage(exportType string) string {
305319
return fmt.Sprintf(
306320
"Recommend PII redactions for the %q export I'm about to share. "+
307321
"Follow the tool sequence EXACTLY: "+
@@ -322,9 +336,10 @@ func buildPiiRedactionSharedExportsUserMessage(exportType string) string {
322336
)
323337
}
324338

325-
// containsString is provided by impersonate_handler.go in the
326-
// same package; reuse it rather than redeclare.
339+
// denyAllConfirm rejects mutating tool calls for this read-only strategy.
340+
func denyAllConfirm(_ context.Context, _ dispatch.ConfirmRequest) (dispatch.ConfirmDecision, error) {
341+
return dispatch.ConfirmDenied, nil
342+
}
327343

328-
// Compile-time assertion: AIPiiRedactionSharedExportsHandler
329-
// satisfies http.Handler.
330-
var _ http.Handler = (*AIPiiRedactionSharedExportsHandler)(nil)
344+
// Compile-time assertion: Handler satisfies http.Handler.
345+
var _ http.Handler = (*Handler)(nil)

internal/api/ai_pii_redaction_shared_exports_handler_test.go renamed to internal/api/aipiiredact/handler_test.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
// pii-redaction-shared-exports`); duplicating that here would
1616
// require a live mock-provider stack.
1717

18-
package api
18+
package aipiiredact
1919

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

33+
type stubGuardSettings struct {
34+
mode string
35+
on map[string]bool
36+
}
37+
38+
func (s *stubGuardSettings) AIMode(_ context.Context) (string, error) {
39+
if s.mode == "" {
40+
return "off", nil
41+
}
42+
return s.mode, nil
43+
}
44+
45+
func (s *stubGuardSettings) AIFeatureEnabled(_ context.Context, id string) (bool, error) {
46+
return s.on[id], nil
47+
}
48+
3249
// TestSharedExportRedactionAIOffManualExportWorks is the
3350
// load-bearing off-mode contract proof for slice 0052. It
3451
// mounts the AI pii-redaction-shared-exports route through the
@@ -150,36 +167,36 @@ func TestSharedExportRedactionAIOffManualExportWorks(t *testing.T) {
150167
}
151168
}
152169

153-
// TestAIPiiRedactionSharedExportsHandler_PanicsOnNilWiring
170+
// TestHandler_PanicsOnNilWiring
154171
// asserts the handler constructor refuses zero-valued
155172
// dependencies. A wiring bug at boot must surface as a panic,
156173
// not as a nil-deref on first request.
157-
func TestAIPiiRedactionSharedExportsHandler_PanicsOnNilWiring(t *testing.T) {
174+
func TestHandler_PanicsOnNilWiring(t *testing.T) {
158175
t.Parallel()
159176
cases := []struct {
160177
name string
161178
fn func()
162179
}{
163-
{"all nil", func() { NewAIPiiRedactionSharedExportsHandler(nil, nil, nil, "") }},
180+
{"all nil", func() { NewHandler(nil, nil, nil, "") }},
164181
}
165182
for _, tc := range cases {
166183
t.Run(tc.name, func(t *testing.T) {
167184
defer func() {
168185
if r := recover(); r == nil {
169-
t.Fatalf("NewAIPiiRedactionSharedExportsHandler(%s) did not panic", tc.name)
186+
t.Fatalf("NewHandler(%s) did not panic", tc.name)
170187
}
171188
}()
172189
tc.fn()
173190
})
174191
}
175192
}
176193

177-
// TestAIPiiRedactionSharedExportsHandler_RejectsBadBody asserts
194+
// TestHandler_RejectsBadBody asserts
178195
// the handler validates the body BEFORE opening the SSE stream
179196
// — a missing, unparseable, or out-of-range field must surface
180197
// as a JSON 400, not a half-opened stream that confuses the
181198
// frontend.
182-
func TestAIPiiRedactionSharedExportsHandler_RejectsBadBody(t *testing.T) {
199+
func TestHandler_RejectsBadBody(t *testing.T) {
183200
t.Parallel()
184201

185202
cases := []struct {
@@ -208,7 +225,7 @@ func TestAIPiiRedactionSharedExportsHandler_RejectsBadBody(t *testing.T) {
208225
req := httptest.NewRequest(http.MethodPost, "/api/v1/ai/exports/redaction/draft", strings.NewReader(tc.body))
209226
req.Header.Set("Content-Type", "application/json")
210227

211-
_, ok := parsePiiRedactionSharedExportsRequest(rec, req)
228+
_, ok := parseRequest(rec, req)
212229
if ok != tc.wantOK {
213230
t.Errorf("ok = %v, want %v (body=%q, status=%d, response=%q)", ok, tc.wantOK, tc.body, rec.Code, rec.Body.String())
214231
}
@@ -225,7 +242,7 @@ func TestAIPiiRedactionSharedExportsHandler_RejectsBadBody(t *testing.T) {
225242
// to follow, and the load-bearing honesty directives.
226243
func TestBuildPiiRedactionSharedExportsUserMessage(t *testing.T) {
227244
t.Parallel()
228-
got := buildPiiRedactionSharedExportsUserMessage("account")
245+
got := buildUserMessage("account")
229246
for _, must := range []string{
230247
`export_type="account"`,
231248
"draft_export_redaction_plan",

internal/api/router.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
aidigest "github.com/ev-dev-labs/teslasync/internal/api/aidigest"
3232
aidrivecoach "github.com/ev-dev-labs/teslasync/internal/api/aidrivecoach"
3333
aidrivesearch "github.com/ev-dev-labs/teslasync/internal/api/aidrivesearch"
34+
aipiiredact "github.com/ev-dev-labs/teslasync/internal/api/aipiiredact"
3435
airaghelp "github.com/ev-dev-labs/teslasync/internal/api/airaghelp"
3536
airouteeff "github.com/ev-dev-labs/teslasync/internal/api/airouteeff"
3637
aisearch "github.com/ev-dev-labs/teslasync/internal/api/aisearch"
@@ -2443,7 +2444,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
24432444
// stateless beyond constructor inputs. Must be constructed
24442445
// AFTER the tool registration above so the dispatcher can
24452446
// resolve the strategy's allowedTools at boot.
2446-
aiPiiRedactionSharedExportsHandler := NewAIPiiRedactionSharedExportsHandler(
2447+
aiPiiRedactionSharedExportsHandler := aipiiredact.NewHandler(
24472448
aiRegistry,
24482449
aiToolRegistry,
24492450
piiredactionsharedexports.New(),

0 commit comments

Comments
 (0)