1- package api
1+ package aiquiethrs
22
33// Phase-50 / 0053 — P2 Helix quiet-hours suggestion advisor.
44//
@@ -74,6 +74,7 @@ package api
7474// modified by this slice.
7575
7676import (
77+ "bytes"
7778 "context"
7879 "encoding/json"
7980 "fmt"
@@ -91,61 +92,62 @@ 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/schedule"
95+ "github.com/ev-dev-labs/teslasync/internal/api/httpx"
9496 tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
9597 dbnotif "github.com/ev-dev-labs/teslasync/internal/database/notification"
9698 quiethoursdb "github.com/ev-dev-labs/teslasync/internal/database/quiethours"
9799)
98100
99- // aiQuietHoursSuggestionMaxIterations bounds the dispatcher's
101+ // maxIterations bounds the dispatcher's
100102// tool-loop. The strategy is exactly draft_quiet_hours_window →
101103// validate_quiet_hours_window → answer (with one optional retry
102104// on a transient validator rejection that the LLM repairs by
103105// tweaking the candidate). A hard ceiling of 8 is generous,
104106// matching the other narrator handlers.
105- const aiQuietHoursSuggestionMaxIterations = 8
107+ const maxIterations = 8
106108
107- // aiQuietHoursSuggestionMaxBodyBytes caps the request body. The
109+ // maxBodyBytes caps the request body. The
108110// body has at most two small fields; bound it cheaply. 16 KiB
109111// matches the other body-driven AI handlers.
110- const aiQuietHoursSuggestionMaxBodyBytes = 16 * 1024
112+ const maxBodyBytes = 16 * 1024
111113
112- // aiQuietHoursSuggestionDefaultTimezone is the IANA timezone
114+ // defaultTimezone is the IANA timezone
113115// installed in the scope when the body does not set one. UTC is
114116// the safest universal default — the validator + tool refuse
115117// invalid timezones, and the SPA can always POST a more
116118// appropriate one (e.g. the browser's
117119// Intl.DateTimeFormat().resolvedOptions().timeZone).
118- const aiQuietHoursSuggestionDefaultTimezone = "UTC"
120+ const defaultTimezone = "UTC"
119121
120- // aiQuietHoursSuggestionDefaultWindowDays is how many trailing
122+ // defaultWindowDays is how many trailing
121123// days of notification_logs the candidate-finder aggregates by
122124// default. 30d is a sensible balance between data availability
123125// (enough events to find a pattern) and recency (the user's
124126// current usage, not last quarter's).
125- const aiQuietHoursSuggestionDefaultWindowDays = 30
127+ const defaultWindowDays = 30
126128
127- // aiQuietHoursSuggestionMinWindowDays / MaxWindowDays bound the
129+ // minWindowDays / MaxWindowDays bound the
128130// trailing window. < 7 is too short to find a weekly pattern;
129131// > 90 is too long to remain "recent".
130132const (
131- aiQuietHoursSuggestionMinWindowDays = 7
132- aiQuietHoursSuggestionMaxWindowDays = 90
133+ minWindowDays = 7
134+ maxWindowDays = 90
133135)
134136
135- // aiQuietHoursSuggestionMinRequiredEvents is the
137+ // minRequiredEvents is the
136138// HasEnoughHistory threshold. Below this the candidate-finder
137139// returns the conservative default (22:00-07:00) and the LLM
138140// MUST disclose that the candidate is a default, not a
139141// derivation. 14 ≈ "at least one notification every other day
140142// across a 30-day window" — small enough to be hit on most
141143// production installs, large enough to avoid pathological
142144// candidates from a 1-event sample.
143- const aiQuietHoursSuggestionMinRequiredEvents = 14
145+ const minRequiredEvents = 14
144146
145- // aiQuietHoursSuggestionRequest is the typed body shape. Both
147+ // request is the typed body shape. Both
146148// fields are optional; the handler falls back to deterministic
147149// defaults so the SPA can POST {} for the most common case.
148- type aiQuietHoursSuggestionRequest struct {
150+ type request struct {
149151 // Timezone is the IANA name the candidate-finder
150152 // bucketizes per-hour counts in. Optional; defaults to
151153 // UTC when absent. The SPA typically posts the browser's
@@ -158,22 +160,30 @@ type aiQuietHoursSuggestionRequest struct {
158160 WindowDays int `json:"window_days,omitempty"`
159161}
160162
161- // AIQuietHoursSuggestionHandler is the HTTP handler for
163+ func writeError (w http.ResponseWriter , status int , msg string ) {
164+ httpx .WriteError (w , status , msg )
165+ }
166+
167+ func denyAllConfirm (_ context.Context , _ dispatch.ConfirmRequest ) (dispatch.ConfirmDecision , error ) {
168+ return dispatch .ConfirmDenied , nil
169+ }
170+
171+ // Handler is the HTTP handler for
162172// POST /api/v1/ai/settings/quiet-hours/draft.
163173//
164174// Stateless beyond its constructor inputs; safe for concurrent
165175// use across requests. Construction is in router.go so the
166176// dispatcher's tool registry + provider registry are wired once
167177// at boot.
168- type AIQuietHoursSuggestionHandler struct {
178+ type Handler struct {
169179 registry * provider.Registry
170180 tools * tools.Registry
171181 strategy strategy.Strategy
172182 headerName string
173183 maxIters int
174184}
175185
176- // NewAIQuietHoursSuggestionHandler constructs the handler. All
186+ // NewHandler constructs the handler. All
177187// non-pointer arguments are required; the constructor panics on
178188// a nil so the wiring bug surfaces at boot, not at first
179189// request.
@@ -198,26 +208,26 @@ type AIQuietHoursSuggestionHandler struct {
198208// for audit AND for the per-request user scope
199209// binding (the candidate-finder reads only this
200210// user's notification_logs).
201- func NewAIQuietHoursSuggestionHandler (
211+ func NewHandler (
202212 registry * provider.Registry ,
203213 toolReg * tools.Registry ,
204214 strat strategy.Strategy ,
205215 headerName string ,
206- ) * AIQuietHoursSuggestionHandler {
216+ ) * Handler {
207217 switch {
208218 case registry == nil :
209- panic ("api: NewAIQuietHoursSuggestionHandler : nil provider.Registry" )
219+ panic ("aiquiethrs: NewHandler : nil provider.Registry" )
210220 case toolReg == nil :
211- panic ("api: NewAIQuietHoursSuggestionHandler : nil tools.Registry" )
221+ panic ("aiquiethrs: NewHandler : nil tools.Registry" )
212222 case strat == nil :
213- panic ("api: NewAIQuietHoursSuggestionHandler : nil strategy.Strategy" )
223+ panic ("aiquiethrs: NewHandler : nil strategy.Strategy" )
214224 }
215- return & AIQuietHoursSuggestionHandler {
225+ return & Handler {
216226 registry : registry ,
217227 tools : toolReg ,
218228 strategy : strat ,
219229 headerName : headerName ,
220- maxIters : aiQuietHoursSuggestionMaxIterations ,
230+ maxIters : maxIterations ,
221231 }
222232}
223233
@@ -227,19 +237,19 @@ func NewAIQuietHoursSuggestionHandler(
227237// acceptable. Unknown fields are rejected so a future schema
228238// drift surfaces explicitly. Returns (req, true) when the body
229239// is acceptable.
230- func parseQuietHoursSuggestionRequest (w http.ResponseWriter , r * http.Request ) (aiQuietHoursSuggestionRequest , bool ) {
231- var req aiQuietHoursSuggestionRequest
240+ func parseQuietHoursSuggestionRequest (w http.ResponseWriter , r * http.Request ) (request , bool ) {
241+ var req request
232242 if r .Body == nil {
233243 // Missing body is the same as "{}" — apply defaults.
234244 return req , true
235245 }
236246 defer r .Body .Close ()
237- bodyBytes , readErr := io .ReadAll (io .LimitReader (r .Body , aiQuietHoursSuggestionMaxBodyBytes ))
247+ bodyBytes , readErr := io .ReadAll (io .LimitReader (r .Body , maxBodyBytes ))
238248 if readErr != nil {
239249 writeError (w , http .StatusBadRequest , fmt .Sprintf ("failed to read body: %v" , readErr ))
240250 return req , false
241251 }
242- if len (bytesTrim (bodyBytes )) == 0 {
252+ if len (bytes . TrimSpace (bodyBytes )) == 0 {
243253 // Empty body is the same as "{}" — apply defaults.
244254 return req , true
245255 }
@@ -257,10 +267,10 @@ func parseQuietHoursSuggestionRequest(w http.ResponseWriter, r *http.Request) (a
257267 req .Timezone = tz
258268 }
259269 if req .WindowDays != 0 {
260- if req .WindowDays < aiQuietHoursSuggestionMinWindowDays || req .WindowDays > aiQuietHoursSuggestionMaxWindowDays {
270+ if req .WindowDays < minWindowDays || req .WindowDays > maxWindowDays {
261271 writeError (w , http .StatusBadRequest , fmt .Sprintf (
262272 "window_days %d is out of range [%d,%d]" ,
263- req .WindowDays , aiQuietHoursSuggestionMinWindowDays , aiQuietHoursSuggestionMaxWindowDays ))
273+ req .WindowDays , minWindowDays , maxWindowDays ))
264274 return req , false
265275 }
266276 }
@@ -273,7 +283,7 @@ func parseQuietHoursSuggestionRequest(w http.ResponseWriter, r *http.Request) (a
273283// writes a structured frame onto the SSE stream (when the
274284// writer has been opened) or a plain JSON 4xx/5xx (before it
275285// has).
276- func (h * AIQuietHoursSuggestionHandler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
286+ func (h * Handler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
277287 // 1) Parse + validate the request body.
278288 req , ok := parseQuietHoursSuggestionRequest (w , r )
279289 if ! ok {
@@ -292,11 +302,11 @@ func (h *AIQuietHoursSuggestionHandler) ServeHTTP(w http.ResponseWriter, r *http
292302 // 3) Apply defaults to the body fields.
293303 tz := req .Timezone
294304 if tz == "" {
295- tz = aiQuietHoursSuggestionDefaultTimezone
305+ tz = defaultTimezone
296306 }
297307 windowDays := req .WindowDays
298308 if windowDays == 0 {
299- windowDays = aiQuietHoursSuggestionDefaultWindowDays
309+ windowDays = defaultWindowDays
300310 }
301311
302312 // 4) Resolve provider via the registry. Per-request
@@ -390,10 +400,10 @@ func buildQuietHoursSuggestionUserMessage(userID, timezone string, windowDays in
390400}
391401
392402// ---------------------------------------------------------------------------
393- // Production source adapter: AIQuietHoursSuggestionSource
403+ // Production source adapter: Source
394404// ---------------------------------------------------------------------------
395405
396- // AIQuietHoursSuggestionSource is the production adapter
406+ // Source is the production adapter
397407// satisfying schedule.QuietHoursSuggestionSource. It composes the
398408// canonical NotificationRepo + QuietHoursRepo aggregations so
399409// the AI tool reads from the SAME data source the deterministic
@@ -403,29 +413,29 @@ func buildQuietHoursSuggestionUserMessage(userID, timezone string, windowDays in
403413// The adapter performs ONE query against notification_logs +
404414// ONE query against notification_quiet_hours per request. Both
405415// are read-only.
406- type AIQuietHoursSuggestionSource struct {
416+ type Source struct {
407417 notifs * dbnotif.NotificationRepo
408418 quietHours * quiethoursdb.QuietHoursRepo
409419 minRequired int
410420}
411421
412- // NewAIQuietHoursSuggestionSource constructs the production
422+ // NewSource constructs the production
413423// adapter. Both repos are required; the constructor panics on a
414424// nil so the wiring bug surfaces at boot, not at first request.
415- func NewAIQuietHoursSuggestionSource (
425+ func NewSource (
416426 notifs * dbnotif.NotificationRepo ,
417427 quietHours * quiethoursdb.QuietHoursRepo ,
418- ) * AIQuietHoursSuggestionSource {
428+ ) * Source {
419429 switch {
420430 case notifs == nil :
421- panic ("api: NewAIQuietHoursSuggestionSource : nil notifs *dbnotif.NotificationRepo" )
431+ panic ("aiquiethrs: NewSource : nil notifs *dbnotif.NotificationRepo" )
422432 case quietHours == nil :
423- panic ("api: NewAIQuietHoursSuggestionSource : nil quietHours *quiethoursdb.QuietHoursRepo" )
433+ panic ("aiquiethrs: NewSource : nil quietHours *quiethoursdb.QuietHoursRepo" )
424434 }
425- return & AIQuietHoursSuggestionSource {
435+ return & Source {
426436 notifs : notifs ,
427437 quietHours : quietHours ,
428- minRequired : aiQuietHoursSuggestionMinRequiredEvents ,
438+ minRequired : minRequiredEvents ,
429439 }
430440}
431441
@@ -453,7 +463,7 @@ func NewAIQuietHoursSuggestionSource(
453463// notification_channels table; this slice does NOT modify
454464// the canonical reader's signature to avoid widening the
455465// repo surface for an OPT-IN AI feature.
456- func (a * AIQuietHoursSuggestionSource ) LoadHistory (
466+ func (a * Source ) LoadHistory (
457467 ctx context.Context ,
458468 userID string ,
459469 timezone string ,
@@ -511,7 +521,7 @@ func (a *AIQuietHoursSuggestionSource) LoadHistory(
511521// individual windows are NOT surfaced to the LLM — only the
512522// count. The narrator may say "you already have N quiet-hours
513523// windows" without ever quoting one of them.
514- func (a * AIQuietHoursSuggestionSource ) CountExistingWindows (ctx context.Context , userID string ) (int , error ) {
524+ func (a * Source ) CountExistingWindows (ctx context.Context , userID string ) (int , error ) {
515525 if strings .TrimSpace (userID ) == "" {
516526 return 0 , nil
517527 }
@@ -522,10 +532,10 @@ func (a *AIQuietHoursSuggestionSource) CountExistingWindows(ctx context.Context,
522532 return len (rows ), nil
523533}
524534
525- // Compile-time assertions: AIQuietHoursSuggestionHandler
526- // satisfies http.Handler and AIQuietHoursSuggestionSource
535+ // Compile-time assertions: Handler
536+ // satisfies http.Handler and Source
527537// satisfies schedule.QuietHoursSuggestionSource.
528538var (
529- _ http.Handler = (* AIQuietHoursSuggestionHandler )(nil )
530- _ schedule.QuietHoursSuggestionSource = (* AIQuietHoursSuggestionSource )(nil )
539+ _ http.Handler = (* Handler )(nil )
540+ _ schedule.QuietHoursSuggestionSource = (* Source )(nil )
531541)
0 commit comments