Skip to content

Commit 89f8c99

Browse files
atulmguptaCopilot
andcommitted
refactor(R2d.109): carve internal/api/aichatbot subpackage
Move ai_chatbot_handler.go and its test into the new aichatbot subpkg, rename AIChatbotHandler -> Handler and NewAIChatbotHandler -> NewHandler. Router.go imports the subpkg and uses aichatbot.NewHandler; the AIHandlers.Chatbot field (typed http.Handler) accepts the new *aichatbot.Handler unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 97dc24e commit 89f8c99

6 files changed

Lines changed: 131 additions & 33 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package api
2+
3+
import "context"
4+
5+
type stubGuardSettings struct {
6+
mode string
7+
on map[string]bool
8+
}
9+
10+
func (s *stubGuardSettings) AIMode(_ context.Context) (string, error) {
11+
if s.mode == "" {
12+
return "off", nil
13+
}
14+
return s.mode, nil
15+
}
16+
17+
func (s *stubGuardSettings) AIFeatureEnabled(_ context.Context, id string) (bool, error) {
18+
return s.on[id], nil
19+
}

internal/api/aichatbot/doc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Package aichatbot serves the AI chatbot endpoints under /api/v1/ai/chatbot.
2+
//
3+
// Layer: handler
4+
package aichatbot
Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
package api
1+
package aichatbot
22

33
// Phase-50 / 0011 — U1 Chatbot LLM upgrade.
44
//
5-
// ai_chatbot_handler.go implements the real LLM-backed handler that
5+
// handler.go implements the real LLM-backed handler that
66
// replaces the F0 stub at POST /api/v1/ai/chatbot. The flow is:
77
//
88
// request JSON {message, session_id}
@@ -52,11 +52,12 @@ import (
5252
"github.com/ev-dev-labs/teslasync/internal/ai/strategy"
5353
"github.com/ev-dev-labs/teslasync/internal/ai/stream"
5454
"github.com/ev-dev-labs/teslasync/internal/ai/tools"
55+
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
5556
tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
5657
dbnotif "github.com/ev-dev-labs/teslasync/internal/database/notification"
5758
)
5859

59-
// aiChatbotHistoryLimit is the upper bound on how many prior messages
60+
// historyLimit is the upper bound on how many prior messages
6061
// we hand to the LLM as context. Picked to balance:
6162
//
6263
// - Token budget: ~16 messages × ~80 tokens average ≈ 1.3K input
@@ -67,22 +68,22 @@ import (
6768
// History older than this is silently dropped. The full record is
6869
// always kept in the chatbot_messages table for audit and the
6970
// /chatbot/history endpoint.
70-
const aiChatbotHistoryLimit = 16
71+
const historyLimit = 16
7172

72-
// aiChatbotMaxIterations bounds the dispatcher's tool-loop. The
73+
// maxIterations bounds the dispatcher's tool-loop. The
7374
// chatbot is one-question-one-answer plus optional tool round-trips;
7475
// a hard ceiling of 6 protects against pathological model loops
7576
// without truncating any realistic conversation. Tested in
76-
// TestAIChatbotHandler_OnPathDispatches.
77-
const aiChatbotMaxIterations = 6
77+
// TestHandler_OnPathDispatches.
78+
const maxIterations = 6
7879

79-
// AIChatbotHandler is the HTTP handler for POST /api/v1/ai/chatbot.
80+
// Handler is the HTTP handler for POST /api/v1/ai/chatbot.
8081
//
8182
// Construction is in router.go (so the dispatcher's tool registry +
8283
// provider registry are wired once at boot). The handler itself is
8384
// stateless beyond its constructor inputs and is safe for concurrent
8485
// use across requests.
85-
type AIChatbotHandler struct {
86+
type Handler struct {
8687
chat *dbnotif.ChatRepo
8788
registry *provider.Registry
8889
tools *tools.Registry
@@ -92,7 +93,7 @@ type AIChatbotHandler struct {
9293
historyN int
9394
}
9495

95-
// NewAIChatbotHandler constructs the handler. All non-pointer
96+
// NewHandler constructs the handler. All non-pointer
9697
// arguments are required; the constructor panics on a nil so the
9798
// wiring bug surfaces at boot, not at first request.
9899
//
@@ -101,38 +102,38 @@ type AIChatbotHandler struct {
101102
// toolReg: process-wide tool registry (Register12Builtins-populated).
102103
// strat: the chatbot-llm Strategy (one per process).
103104
// headerName: forward-auth header name; used to extract subject for audit.
104-
func NewAIChatbotHandler(
105+
func NewHandler(
105106
chat *dbnotif.ChatRepo,
106107
registry *provider.Registry,
107108
toolReg *tools.Registry,
108109
strat strategy.Strategy,
109110
headerName string,
110-
) *AIChatbotHandler {
111+
) *Handler {
111112
switch {
112113
case chat == nil:
113-
panic("api: NewAIChatbotHandler: nil ChatRepo")
114+
panic("aichatbot: NewHandler: nil ChatRepo")
114115
case registry == nil:
115-
panic("api: NewAIChatbotHandler: nil provider.Registry")
116+
panic("aichatbot: NewHandler: nil provider.Registry")
116117
case toolReg == nil:
117-
panic("api: NewAIChatbotHandler: nil tools.Registry")
118+
panic("aichatbot: NewHandler: nil tools.Registry")
118119
case strat == nil:
119-
panic("api: NewAIChatbotHandler: nil strategy.Strategy")
120+
panic("aichatbot: NewHandler: nil strategy.Strategy")
120121
}
121-
return &AIChatbotHandler{
122+
return &Handler{
122123
chat: chat,
123124
registry: registry,
124125
tools: toolReg,
125126
strategy: strat,
126127
headerName: headerName,
127-
maxIters: aiChatbotMaxIterations,
128-
historyN: aiChatbotHistoryLimit,
128+
maxIters: maxIterations,
129+
historyN: historyLimit,
129130
}
130131
}
131132

132-
// aiChatbotRequest is the wire shape for POST /api/v1/ai/chatbot.
133+
// request is the wire shape for POST /api/v1/ai/chatbot.
133134
// Mirrors the existing baseline endpoint so the frontend can call
134135
// either route without DTO drift.
135-
type aiChatbotRequest struct {
136+
type request struct {
136137
Message string `json:"message"`
137138
SessionID string `json:"session_id"`
138139
}
@@ -142,15 +143,15 @@ type aiChatbotRequest struct {
142143
// after the SSE stream closes. Every error path writes a structured
143144
// frame onto the SSE stream (when the writer has been opened) or a
144145
// plain JSON 4xx/5xx (before it has).
145-
func (h *AIChatbotHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
146+
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
146147
// 1) Decode + validate request body.
147-
var body aiChatbotRequest
148+
var body request
148149
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
149-
writeError(w, http.StatusBadRequest, "invalid request body")
150+
httpx.WriteError(w, http.StatusBadRequest, "invalid request body")
150151
return
151152
}
152153
if strings.TrimSpace(body.Message) == "" {
153-
writeError(w, http.StatusBadRequest, "message is required")
154+
httpx.WriteError(w, http.StatusBadRequest, "message is required")
154155
return
155156
}
156157
if body.SessionID == "" {
@@ -188,7 +189,7 @@ func (h *AIChatbotHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
188189
// stream — emit JSON 502 so the frontend falls back gracefully.
189190
if _, err := h.registry.For(r.Context(), chatbotllm.FeatureID); err != nil {
190191
log.Error().Err(err).Msg("ai chatbot: provider.For failed")
191-
writeError(w, http.StatusBadGateway, "ai provider unavailable")
192+
httpx.WriteError(w, http.StatusBadGateway, "ai provider unavailable")
192193
return
193194
}
194195

@@ -208,7 +209,7 @@ func (h *AIChatbotHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
208209
// Non-flushable response writer (test recorder, etc.).
209210
// Emit a plain JSON 500 — the SSE headers were not sent.
210211
log.Error().Err(err).Msg("ai chatbot: stream.New failed (non-flushable writer)")
211-
writeError(w, http.StatusInternalServerError, "streaming not supported")
212+
httpx.WriteError(w, http.StatusInternalServerError, "streaming not supported")
212213
return
213214
}
214215

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// require a live database fixture (the AI handler persists turns via
1414
// *dbnotif.ChatRepo, which is not interface-segregated yet).
1515

16-
package api
16+
package aichatbot
1717

1818
import (
1919
"context"
@@ -124,22 +124,22 @@ func TestChatbotAIOffUsesBaselineAndAiRoute404(t *testing.T) {
124124
apichatbot.NewBaselineResponder(nil)
125125
}
126126

127-
// TestAIChatbotHandler_PanicsOnNilWiring asserts the handler
127+
// TestHandler_PanicsOnNilWiring asserts the handler
128128
// constructor refuses zero-valued dependencies. A wiring bug at boot
129129
// must surface as a panic, not as a nil-deref on first request.
130-
func TestAIChatbotHandler_PanicsOnNilWiring(t *testing.T) {
130+
func TestHandler_PanicsOnNilWiring(t *testing.T) {
131131
t.Parallel()
132132
cases := []struct {
133133
name string
134134
fn func()
135135
}{
136-
{"nil chat repo", func() { NewAIChatbotHandler(nil, nil, nil, nil, "") }},
136+
{"nil chat repo", func() { NewHandler(nil, nil, nil, nil, "") }},
137137
}
138138
for _, tc := range cases {
139139
t.Run(tc.name, func(t *testing.T) {
140140
defer func() {
141141
if r := recover(); r == nil {
142-
t.Fatalf("NewAIChatbotHandler(%s) did not panic", tc.name)
142+
t.Fatalf("NewHandler(%s) did not panic", tc.name)
143143
}
144144
}()
145145
tc.fn()

internal/api/helpers.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ package api
22

33
import (
44
"context"
5+
"encoding/json"
56
"net/http"
67
"strings"
78
"time"
89

10+
"github.com/ev-dev-labs/teslasync/internal/ai/dispatch"
11+
"github.com/ev-dev-labs/teslasync/internal/ai/provider"
12+
"github.com/ev-dev-labs/teslasync/internal/ai/stream"
913
"github.com/ev-dev-labs/teslasync/internal/api/apiparams"
1014
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
1115
"github.com/ev-dev-labs/teslasync/internal/database"
16+
chatbotmodel "github.com/ev-dev-labs/teslasync/internal/models/chatbot"
1217
)
1318

1419
// writeJSON is a transitional wrapper around httpx.WriteJSON kept for
@@ -40,6 +45,74 @@ func writeErrorCode(w http.ResponseWriter, status int, msg, code string) {
4045
httpx.WriteErrorCode(w, status, msg, code)
4146
}
4247

48+
func denyAllConfirm(_ context.Context, _ dispatch.ConfirmRequest) (dispatch.ConfirmDecision, error) {
49+
return dispatch.ConfirmDenied, nil
50+
}
51+
52+
func historyToProviderMessages(rows []*chatbotmodel.ChatMessage, currentUserMessage string) []provider.Message {
53+
if len(rows) == 0 {
54+
return nil
55+
}
56+
out := make([]provider.Message, 0, len(rows))
57+
for i, m := range rows {
58+
if i == len(rows)-1 && m.Role == "user" && m.Content == currentUserMessage {
59+
continue
60+
}
61+
out = append(out, provider.Message{
62+
Role: m.Role,
63+
Content: m.Content,
64+
})
65+
}
66+
if len(out) == 0 {
67+
return nil
68+
}
69+
return out
70+
}
71+
72+
type recordingStreamWriter struct {
73+
inner *stream.Writer
74+
buf strings.Builder
75+
}
76+
77+
func (r *recordingStreamWriter) WriteDelta(s string) error {
78+
r.buf.WriteString(s)
79+
return r.inner.WriteDelta(s)
80+
}
81+
82+
func (r *recordingStreamWriter) WriteToolCall(call provider.ToolCall) error {
83+
return r.inner.WriteToolCall(call)
84+
}
85+
86+
func (r *recordingStreamWriter) WriteToolResult(name string, result json.RawMessage) error {
87+
return r.inner.WriteToolResult(name, result)
88+
}
89+
90+
func (r *recordingStreamWriter) WriteToolError(name string, err error) error {
91+
return r.inner.WriteToolError(name, err)
92+
}
93+
94+
func (r *recordingStreamWriter) WriteDone() error {
95+
return r.inner.WriteDone()
96+
}
97+
98+
func (r *recordingStreamWriter) EmitLimitError(message, reason string, retryAfterS int, bannerLevel string, baselineAvailable bool) error {
99+
return r.inner.WriteLimitError(message, stream.LimitDecisionPayload{
100+
Reason: reason,
101+
RetryAfterS: retryAfterS,
102+
BannerLevel: bannerLevel,
103+
BaselineAvailable: baselineAvailable,
104+
})
105+
}
106+
107+
func (r *recordingStreamWriter) text() string {
108+
return r.buf.String()
109+
}
110+
111+
var (
112+
_ dispatch.StreamWriter = (*recordingStreamWriter)(nil)
113+
_ dispatch.LimitErrorEmitter = (*recordingStreamWriter)(nil)
114+
)
115+
43116
// writeTeslaTokenExpired drained to zero callers by R2d batch 8 (carves
44117
// drained all token-issuing handlers into resource subpackages, which call
45118
// httpx.WriteTeslaTokenExpired directly). Wrapper deleted per the carve

internal/api/router.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
apiadminfb "github.com/ev-dev-labs/teslasync/internal/api/adminfeedback"
1818
apiadminls "github.com/ev-dev-labs/teslasync/internal/api/adminlogstream"
1919
apiadminmnt "github.com/ev-dev-labs/teslasync/internal/api/adminmaintenance"
20+
aichatbot "github.com/ev-dev-labs/teslasync/internal/api/aichatbot"
2021
apialertmsg "github.com/ev-dev-labs/teslasync/internal/api/alertmsg"
2122
apialerts "github.com/ev-dev-labs/teslasync/internal/api/alerts"
2223
apianalytics "github.com/ev-dev-labs/teslasync/internal/api/analytics"
@@ -738,7 +739,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
738739
Drives: drivedb.NewDriveRepo(db),
739740
Charges: chargingdb.NewChargingRepo(db),
740741
})
741-
aiChatbotHandler := NewAIChatbotHandler(
742+
aiChatbotHandler := aichatbot.NewHandler(
742743
dbnotif.NewChatRepo(db),
743744
aiRegistry,
744745
aiToolRegistry,

0 commit comments

Comments
 (0)