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
7777import (
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 )
0 commit comments