1- package api
1+ package ailogtrace
22
33// Phase-50 / 0045 — S4 Log and trace summarization.
44//
@@ -89,35 +89,36 @@ import (
8989 "github.com/ev-dev-labs/teslasync/internal/ai/stream"
9090 "github.com/ev-dev-labs/teslasync/internal/ai/tools"
9191 "github.com/ev-dev-labs/teslasync/internal/ai/tools/summary"
92+ "github.com/ev-dev-labs/teslasync/internal/api/httpx"
9293 tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
9394)
9495
95- // aiLogTraceSummarizationMaxIterations bounds the dispatcher's
96+ // maxIterations bounds the dispatcher's
9697// tool-loop. The strategy is at most query_trace_window →
9798// (optional) retrieve_log_chunks → answer (with optional retries
9899// on transient tool error). A hard ceiling of 8 is generous,
99100// matching the other narrator handlers.
100- const aiLogTraceSummarizationMaxIterations = 8
101+ const maxIterations = 8
101102
102- // aiLogTraceSummarizationMaxBodyBytes caps the request body. The
103+ // maxBodyBytes caps the request body. The
103104// body is small (3 numeric fields); bound it cheaply. 16 KiB
104105// matches the other body-driven AI handlers.
105- const aiLogTraceSummarizationMaxBodyBytes = 16 * 1024
106+ const maxBodyBytes = 16 * 1024
106107
107- // aiLogTraceSummarizationMaxWindowSeconds caps the window the
108+ // maxWindowSeconds caps the window the
108109// caller may request. 24 hours is generous for an operator log-
109110// triage workflow and bounds the size of the envelope the source
110111// has to compute.
111- const aiLogTraceSummarizationMaxWindowSeconds = 24 * 60 * 60
112+ const maxWindowSeconds = 24 * 60 * 60
112113
113- // aiLogTraceSummarizationMaxFromUnix is a sanity upper bound on
114+ // maxFromUnix is a sanity upper bound on
114115// from_unix to reject obvious garbage (e.g. epoch year 9999). Set
115116// to year 2100 in Unix seconds.
116- const aiLogTraceSummarizationMaxFromUnix = int64 (4102444800 )
117+ const maxFromUnix = int64 (4102444800 )
117118
118- // aiLogTraceSummarizationRequest is the typed body shape. Only
119+ // summarizationRequest is the typed body shape. Only
119120// from_unix / to_unix are required; vehicle_id is optional.
120- type aiLogTraceSummarizationRequest struct {
121+ type summarizationRequest struct {
121122 // FromUnix is the inclusive start of the window in Unix
122123 // seconds. Required + positive.
123124 FromUnix int64 `json:"from_unix"`
@@ -133,13 +134,13 @@ type aiLogTraceSummarizationRequest struct {
133134 VehicleID int64 `json:"vehicle_id,omitempty"`
134135}
135136
136- // AILogTraceSummarizationHandler is the HTTP handler for
137+ // Handler is the HTTP handler for
137138// POST /api/v1/ai/system/logs/summarize.
138139//
139140// Stateless beyond its constructor inputs; safe for concurrent use
140141// across requests. Construction is in router.go so the dispatcher's
141142// tool registry + provider registry are wired once at boot.
142- type AILogTraceSummarizationHandler struct {
143+ type Handler struct {
143144 registry * provider.Registry
144145 tools * tools.Registry
145146 strategy strategy.Strategy
@@ -148,7 +149,7 @@ type AILogTraceSummarizationHandler struct {
148149 maxIters int
149150}
150151
151- // NewAILogTraceSummarizationHandler constructs the handler. All
152+ // NewHandler constructs the handler. All
152153// non-pointer arguments are required; the constructor panics on a
153154// nil so the wiring bug surfaces at boot, not at first request.
154155//
@@ -168,98 +169,108 @@ type AILogTraceSummarizationHandler struct {
168169//
169170// source: the production summary.TraceWindowSource (currently
170171//
171- // AILogTraceWindowSource — a deterministic empty
172+ // TraceWindowSource — a deterministic empty
172173// adapter; the operator-facing log surface is
173174// stream-only and has no historical reader yet).
174175//
175176// headerName: forward-auth header name; used to extract subject
176177//
177178// for audit.
178- func NewAILogTraceSummarizationHandler (
179+ func NewHandler (
179180 registry * provider.Registry ,
180181 toolReg * tools.Registry ,
181182 strat strategy.Strategy ,
182183 source summary.TraceWindowSource ,
183184 headerName string ,
184- ) * AILogTraceSummarizationHandler {
185+ ) * Handler {
185186 switch {
186187 case registry == nil :
187- panic ("api: NewAILogTraceSummarizationHandler : nil provider.Registry" )
188+ panic ("ailogtrace: NewHandler : nil provider.Registry" )
188189 case toolReg == nil :
189- panic ("api: NewAILogTraceSummarizationHandler : nil tools.Registry" )
190+ panic ("ailogtrace: NewHandler : nil tools.Registry" )
190191 case strat == nil :
191- panic ("api: NewAILogTraceSummarizationHandler : nil strategy.Strategy" )
192+ panic ("ailogtrace: NewHandler : nil strategy.Strategy" )
192193 case source == nil :
193- panic ("api: NewAILogTraceSummarizationHandler : nil summary.TraceWindowSource" )
194+ panic ("ailogtrace: NewHandler : nil summary.TraceWindowSource" )
194195 }
195- return & AILogTraceSummarizationHandler {
196+ return & Handler {
196197 registry : registry ,
197198 tools : toolReg ,
198199 strategy : strat ,
199200 source : source ,
200201 headerName : headerName ,
201- maxIters : aiLogTraceSummarizationMaxIterations ,
202+ maxIters : maxIterations ,
202203 }
203204}
204205
205- // parseLogTraceSummarizationRequest drains the body. Both
206+ // parseRequest drains the body. Both
206207// from_unix / to_unix are required; vehicle_id is optional.
207208// Absence or invalid values surface as JSON 400 with a stable
208209// error key the SPA can localise. Returns (req, true) when the
209210// body is acceptable.
210- func parseLogTraceSummarizationRequest (w http.ResponseWriter , r * http.Request ) (aiLogTraceSummarizationRequest , bool ) {
211- var req aiLogTraceSummarizationRequest
211+ func parseRequest (w http.ResponseWriter , r * http.Request ) (summarizationRequest , bool ) {
212+ var req summarizationRequest
212213 if r .Body == nil {
213- writeError (w , http .StatusBadRequest , "missing body" )
214+ httpx . WriteError (w , http .StatusBadRequest , "missing body" )
214215 return req , false
215216 }
216217 defer r .Body .Close ()
217- bodyBytes , readErr := io .ReadAll (io .LimitReader (r .Body , aiLogTraceSummarizationMaxBodyBytes ))
218+ bodyBytes , readErr := io .ReadAll (io .LimitReader (r .Body , maxBodyBytes ))
218219 if readErr != nil {
219- writeError (w , http .StatusBadRequest , fmt .Sprintf ("failed to read body: %v" , readErr ))
220+ httpx . WriteError (w , http .StatusBadRequest , fmt .Sprintf ("failed to read body: %v" , readErr ))
220221 return req , false
221222 }
222- if len (bytesTrim (bodyBytes )) == 0 {
223- writeError (w , http .StatusBadRequest , "empty body" )
223+ if len (trimSpace (bodyBytes )) == 0 {
224+ httpx . WriteError (w , http .StatusBadRequest , "empty body" )
224225 return req , false
225226 }
226227 dec := json .NewDecoder (strings .NewReader (string (bodyBytes )))
227228 dec .DisallowUnknownFields ()
228229 if err := dec .Decode (& req ); err != nil {
229- writeError (w , http .StatusBadRequest , fmt .Sprintf ("invalid JSON body: %v" , err ))
230+ httpx . WriteError (w , http .StatusBadRequest , fmt .Sprintf ("invalid JSON body: %v" , err ))
230231 return req , false
231232 }
232233 if req .FromUnix <= 0 {
233- writeError (w , http .StatusBadRequest , "from_unix must be > 0" )
234+ httpx . WriteError (w , http .StatusBadRequest , "from_unix must be > 0" )
234235 return req , false
235236 }
236- if req .FromUnix > aiLogTraceSummarizationMaxFromUnix {
237- writeError (w , http .StatusBadRequest , fmt .Sprintf ("from_unix exceeds upper bound %d" , aiLogTraceSummarizationMaxFromUnix ))
237+ if req .FromUnix > maxFromUnix {
238+ httpx . WriteError (w , http .StatusBadRequest , fmt .Sprintf ("from_unix exceeds upper bound %d" , maxFromUnix ))
238239 return req , false
239240 }
240241 if req .ToUnix <= req .FromUnix {
241- writeError (w , http .StatusBadRequest , "to_unix must be > from_unix" )
242+ httpx . WriteError (w , http .StatusBadRequest , "to_unix must be > from_unix" )
242243 return req , false
243244 }
244- if req .ToUnix - req .FromUnix > aiLogTraceSummarizationMaxWindowSeconds {
245- writeError (w , http .StatusBadRequest , fmt .Sprintf ("window (%d s) exceeds cap %d s" , req .ToUnix - req .FromUnix , aiLogTraceSummarizationMaxWindowSeconds ))
245+ if req .ToUnix - req .FromUnix > maxWindowSeconds {
246+ httpx . WriteError (w , http .StatusBadRequest , fmt .Sprintf ("window (%d s) exceeds cap %d s" , req .ToUnix - req .FromUnix , maxWindowSeconds ))
246247 return req , false
247248 }
248249 if req .VehicleID < 0 {
249- writeError (w , http .StatusBadRequest , "vehicle_id must be >= 0" )
250+ httpx . WriteError (w , http .StatusBadRequest , "vehicle_id must be >= 0" )
250251 return req , false
251252 }
252253 return req , true
253254}
254255
256+ func trimSpace (b []byte ) []byte {
257+ for len (b ) > 0 && (b [0 ] == ' ' || b [0 ] == '\t' || b [0 ] == '\r' || b [0 ] == '\n' ) {
258+ b = b [1 :]
259+ }
260+ for len (b ) > 0 && (b [len (b )- 1 ] == ' ' || b [len (b )- 1 ] == '\t' || b [len (b )- 1 ] == '\r' || b [len (b )- 1 ] == '\n' ) {
261+ b = b [:len (b )- 1 ]
262+ }
263+ return b
264+ }
265+
255266// ServeHTTP implements [http.Handler]. The body is parsed, the
256267// dispatcher is invoked, and the SSE stream is closed via the
257268// dispatcher's deferred WriteDone. Every error path either writes
258269// a structured frame onto the SSE stream (when the writer has
259270// been opened) or a plain JSON 4xx/5xx (before it has).
260- func (h * AILogTraceSummarizationHandler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
271+ func (h * Handler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
261272 // 1) Parse + validate the request body.
262- req , ok := parseLogTraceSummarizationRequest (w , r )
273+ req , ok := parseRequest (w , r )
263274 if ! ok {
264275 return
265276 }
@@ -270,7 +281,7 @@ func (h *AILogTraceSummarizationHandler) ServeHTTP(w http.ResponseWriter, r *htt
270281 // stream — emit JSON 502 so the frontend falls back gracefully.
271282 if _ , err := h .registry .For (r .Context (), logtracesummarization .FeatureID ); err != nil {
272283 log .Error ().Err (err ).Msg ("ai log-trace-summarization: provider.For failed" )
273- writeError (w , http .StatusBadGateway , "ai provider unavailable" )
284+ httpx . WriteError (w , http .StatusBadGateway , "ai provider unavailable" )
274285 return
275286 }
276287
@@ -290,7 +301,7 @@ func (h *AILogTraceSummarizationHandler) ServeHTTP(w http.ResponseWriter, r *htt
290301 sseW , ctx , err := stream .New (ctx , w , stream .WithFeatureID (logtracesummarization .FeatureID ))
291302 if err != nil {
292303 log .Error ().Err (err ).Msg ("ai log-trace-summarization: stream.New failed (non-flushable writer)" )
293- writeError (w , http .StatusInternalServerError , "streaming not supported" )
304+ httpx . WriteError (w , http .StatusInternalServerError , "streaming not supported" )
294305 return
295306 }
296307
@@ -314,7 +325,7 @@ func (h *AILogTraceSummarizationHandler) ServeHTTP(w http.ResponseWriter, r *htt
314325 // window and instructs the tool sequence EXACTLY:
315326 // query_trace_window first, then OPTIONALLY
316327 // retrieve_log_chunks, then summary.
317- userMsg := buildLogTraceSummarizationUserMessage (req .FromUnix , req .ToUnix , req .VehicleID )
328+ userMsg := buildUserMessage (req .FromUnix , req .ToUnix , req .VehicleID )
318329
319330 // 8) Run the dispatcher.
320331 in := strategy.StrategyInput {
@@ -330,11 +341,11 @@ func (h *AILogTraceSummarizationHandler) ServeHTTP(w http.ResponseWriter, r *htt
330341 }
331342}
332343
333- // buildLogTraceSummarizationUserMessage synthesises the window-
344+ // buildUserMessage synthesises the window-
334345// scoped user message the LLM sees. The format is deterministic
335346// (RFC3339 UTC time strings) so canned goldens and provider
336347// prompt-hash caches stay stable across boots.
337- func buildLogTraceSummarizationUserMessage (fromUnix , toUnix , vehicleID int64 ) string {
348+ func buildUserMessage (fromUnix , toUnix , vehicleID int64 ) string {
338349 fromStr := time .Unix (fromUnix , 0 ).UTC ().Format (time .RFC3339 )
339350 toStr := time .Unix (toUnix , 0 ).UTC ().Format (time .RFC3339 )
340351 var vehicleClause string
@@ -362,9 +373,14 @@ func buildLogTraceSummarizationUserMessage(fromUnix, toUnix, vehicleID int64) st
362373 )
363374}
364375
365- // Compile-time assertion: AILogTraceSummarizationHandler satisfies
376+ // denyAllConfirm is the dispatch confirm hook for this read-only AI surface.
377+ func denyAllConfirm (_ context.Context , _ dispatch.ConfirmRequest ) (dispatch.ConfirmDecision , error ) {
378+ return dispatch .ConfirmDenied , nil
379+ }
380+
381+ // Compile-time assertion: Handler satisfies
366382// http.Handler.
367- var _ http.Handler = (* AILogTraceSummarizationHandler )(nil )
383+ var _ http.Handler = (* Handler )(nil )
368384
369385// ---------------------------------------------------------------------
370386// Production wiring for the tool interface declared by
@@ -374,7 +390,7 @@ var _ http.Handler = (*AILogTraceSummarizationHandler)(nil)
374390// pattern.
375391// ---------------------------------------------------------------------
376392
377- // AILogTraceWindowSource is the production
393+ // TraceWindowSource is the production
378394// summary.TraceWindowSource. The operator-facing log surface is
379395// stream-only — there is NO historical log persistence beyond
380396// zerolog's stdout — so this adapter intentionally returns a
@@ -388,13 +404,13 @@ var _ http.Handler = (*AILogTraceSummarizationHandler)(nil)
388404// handler installed and stringifies them so the LLM sees a
389405// recognisable window without having to format Unix seconds
390406// itself.
391- type AILogTraceWindowSource struct {}
407+ type TraceWindowSource struct {}
392408
393- // NewAILogTraceWindowSource constructs the deterministic empty
409+ // NewTraceWindowSource constructs the deterministic empty
394410// adapter. No deps. Returned by-pointer for symmetry with the
395411// other AI* source types.
396- func NewAILogTraceWindowSource () * AILogTraceWindowSource {
397- return & AILogTraceWindowSource {}
412+ func NewTraceWindowSource () * TraceWindowSource {
413+ return & TraceWindowSource {}
398414}
399415
400416// TraceWindow implements summary.TraceWindowSource. Returns a
@@ -404,7 +420,7 @@ func NewAILogTraceWindowSource() *AILogTraceWindowSource {
404420// The envelope's slices are non-nil (empty-but-allocated) so JSON
405421// marshalling renders [] rather than null — keeping the LLM's
406422// tool-reply parsing predictable.
407- func (a * AILogTraceWindowSource ) TraceWindow (_ context.Context , fromUnix , toUnix , vehicleID int64 ) (* summary.TraceWindowEnvelope , error ) {
423+ func (a * TraceWindowSource ) TraceWindow (_ context.Context , fromUnix , toUnix , vehicleID int64 ) (* summary.TraceWindowEnvelope , error ) {
408424 if fromUnix <= 0 {
409425 return nil , fmt .Errorf ("api ai log-trace-summarization: from_unix must be > 0" )
410426 }
@@ -428,6 +444,6 @@ func (a *AILogTraceWindowSource) TraceWindow(_ context.Context, fromUnix, toUnix
428444 }, nil
429445}
430446
431- // Compile-time assertion: AILogTraceWindowSource satisfies
447+ // Compile-time assertion: TraceWindowSource satisfies
432448// summary.TraceWindowSource.
433- var _ summary.TraceWindowSource = (* AILogTraceWindowSource )(nil )
449+ var _ summary.TraceWindowSource = (* TraceWindowSource )(nil )
0 commit comments