Skip to content

Commit 749844b

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.157): carve internal/api/aiquiethrs subpackage
Move the AI quiet-hours suggestion handler and tests into the aiquiethrs subpackage, rename the handler constructor to NewHandler, and update router wiring while preserving existing AIHandlers fields. Add the package doc.go required by the internal architecture gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent edf40c1 commit 749844b

4 files changed

Lines changed: 99 additions & 64 deletions

File tree

internal/api/aiquiethrs/doc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Package aiquiethrs serves the AI quiet-hours suggestion route.
2+
//
3+
// It is distinct from internal/api/quiethours, which owns the deterministic
4+
// non-AI quiet-hours CRUD endpoints.
5+
//
6+
// Layer: handler
7+
package aiquiethrs

internal/api/ai_quiet_hours_suggestion_handler.go renamed to internal/api/aiquiethrs/handler.go

Lines changed: 61 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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

7676
import (
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".
130132
const (
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.
528538
var (
529-
_ http.Handler = (*AIQuietHoursSuggestionHandler)(nil)
530-
_ schedule.QuietHoursSuggestionSource = (*AIQuietHoursSuggestionSource)(nil)
539+
_ http.Handler = (*Handler)(nil)
540+
_ schedule.QuietHoursSuggestionSource = (*Source)(nil)
531541
)

0 commit comments

Comments
 (0)