homelabby/internal/advisor/handler.go
Mikkel Georgsen 0190e8583c feat(06-lab-advisor-02): AdvisorHandler SSE streaming + router wiring
- internal/advisor/handler.go: StreamChat (SSE, token-by-token),
  GetConversations, GetConversation; body limited to 64KB, message
  truncated to 8000 chars (T-06-02-03); API key never echoed (T-06-02-02)
- internal/api/router.go: /api/advisor/{chat,conversations,conversations/{id}}
  with nil-guard returning 503 when DB not configured
- internal/config/config.go: Tier3 defaults + HWLAB_AI_TIER3_* env bindings
- cmd/hwlab/main.go: store init from HWLAB_DATABASE_URL, RunMigrations,
  InventoryContextBuilder, AdvisorHandler wired into NewRouter
2026-04-10 07:36:16 +00:00

225 lines
6.3 KiB
Go

package advisor
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
openai "github.com/sashabaranov/go-openai"
"git.georgsen.dk/hwlab/internal/ai"
"git.georgsen.dk/hwlab/internal/store"
)
const (
defaultModel = "anthropic/claude-opus-4"
maxMessageBytes = 64 * 1024 // 64 KB body limit (T-06-02-03)
maxMessageChars = 8000 // truncate message content to 8000 chars (T-06-02-03)
)
// ChatRequest is the JSON body for POST /api/advisor/chat.
type ChatRequest struct {
ConversationID string `json:"conversation_id"` // empty = new conversation
Message string `json:"message"`
Model string `json:"model"` // empty = defaultModel
}
// AdvisorHandler implements the three advisor endpoints.
type AdvisorHandler struct {
store *store.Store
ctx *InventoryContextBuilder
aiCfg ai.AIConfig
}
// NewAdvisorHandler creates an AdvisorHandler.
func NewAdvisorHandler(s *store.Store, ctxBuilder *InventoryContextBuilder, aiCfg ai.AIConfig) *AdvisorHandler {
return &AdvisorHandler{
store: s,
ctx: ctxBuilder,
aiCfg: aiCfg,
}
}
// StreamChat handles POST /api/advisor/chat.
// It streams tokens from the configured AI model back to the client via SSE.
// Each token is sent as: data: {"conversation_id":"...","token":"..."}\n\n
// The final event is: data: [DONE]\n\n
//
// Security:
// - Body is limited to 64 KB (T-06-02-03)
// - Message content is truncated to 8000 chars (T-06-02-03)
// - API key is never written to the SSE stream (T-06-02-02)
func (h *AdvisorHandler) StreamChat(w http.ResponseWriter, r *http.Request) {
// Enforce body size limit (T-06-02-03).
r.Body = http.MaxBytesReader(w, r.Body, maxMessageBytes)
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Message == "" {
http.Error(w, "message is required", http.StatusBadRequest)
return
}
// Truncate message to guard against overly long inputs (T-06-02-03).
if len(req.Message) > maxMessageChars {
req.Message = req.Message[:maxMessageChars]
}
model := req.Model
if model == "" {
model = defaultModel
}
ctx := r.Context()
// Create or reuse conversation.
convID := req.ConversationID
if convID == "" {
id, err := h.store.CreateConversation(ctx, model)
if err != nil {
log.Printf("advisor: create conversation: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
convID = id
}
// Persist user message.
if _, err := h.store.AddMessage(ctx, convID, "user", req.Message); err != nil {
log.Printf("advisor: add user message: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Build inventory context for system prompt.
invCtx, err := h.ctx.BuildContext(ctx)
if err != nil {
log.Printf("advisor: build inventory context: %v (proceeding without inventory)", err)
invCtx = "Inventory: unavailable\n"
}
systemPrompt := "You are a homelab advisor. Here is the current inventory:\n\n" + invCtx
messages := []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: req.Message},
}
// Build an OpenAI-compatible client pointed at Tier3 (OpenRouter).
// API key is read from config only — never from the request (T-06-02-02).
oCfg := openai.DefaultConfig(h.aiCfg.Tier3.APIKey)
oCfg.BaseURL = h.aiCfg.Tier3.BaseURL
client := openai.NewClientWithConfig(oCfg)
stream, err := client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: model,
Messages: messages,
Stream: true,
})
if err != nil {
log.Printf("advisor: create stream: %v", err)
http.Error(w, "upstream AI error", http.StatusBadGateway)
return
}
defer stream.Close()
// Set SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
type tokenEvent struct {
ConversationID string `json:"conversation_id"`
Token string `json:"token,omitempty"`
}
type errorEvent struct {
Error string `json:"error"`
}
var fullContent strings.Builder
for {
resp, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Printf("advisor: stream recv: %v", err)
errPayload, _ := json.Marshal(errorEvent{Error: "stream interrupted"})
fmt.Fprintf(w, "data: %s\n\n", errPayload)
flusher.Flush()
return
}
if len(resp.Choices) == 0 {
continue
}
token := resp.Choices[0].Delta.Content
if token == "" {
continue
}
fullContent.WriteString(token)
payload, _ := json.Marshal(tokenEvent{ConversationID: convID, Token: token})
fmt.Fprintf(w, "data: %s\n\n", payload)
flusher.Flush()
}
// Send DONE sentinel.
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
// Persist assistant response.
if _, err := h.store.AddMessage(ctx, convID, "assistant", fullContent.String()); err != nil {
log.Printf("advisor: add assistant message: %v", err)
}
}
// GetConversations handles GET /api/advisor/conversations.
func (h *AdvisorHandler) GetConversations(w http.ResponseWriter, r *http.Request) {
list, err := h.store.ListConversations(r.Context())
if err != nil {
log.Printf("advisor: list conversations: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(list); err != nil {
log.Printf("advisor: encode conversations: %v", err)
}
}
// GetConversation handles GET /api/advisor/conversations/{id}.
func (h *AdvisorHandler) GetConversation(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
conv, err := h.store.GetConversation(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "conversation not found", http.StatusNotFound)
return
}
log.Printf("advisor: get conversation %s: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(conv); err != nil {
log.Printf("advisor: encode conversation: %v", err)
}
}