homelabby/.planning/phases/06-lab-advisor/06-02-PLAN.md

246 lines
11 KiB
Markdown

---
phase: 06-lab-advisor
plan: "02"
type: execute
wave: 2
depends_on: [06-01]
files_modified:
- internal/advisor/handler.go
- internal/advisor/context.go
- internal/api/handlers/advisor.go
- internal/api/router.go
- cmd/hwlab/main.go
autonomous: true
requirements: [ADV-01, ADV-02, ADV-03, ADV-05]
must_haves:
truths:
- "POST /api/advisor/chat streams tokens from Claude Opus via SSE"
- "Each chat request includes a pre-assembled NetBox inventory summary in the system prompt"
- "Inventory context is cached 60s to avoid hammering NetBox on every message"
- "Every message and conversation is persisted in PostgreSQL via the store package"
- "GET /api/advisor/conversations returns list of past conversations"
- "GET /api/advisor/conversations/:id returns full message thread"
- "Model is configurable per-request via model field; defaults to anthropic/claude-opus-4"
- "Model switch takes effect on the next request without restarting the server"
artifacts:
- path: "internal/advisor/context.go"
provides: "InventoryContextBuilder — assembles compact NetBox summary with 60s cache"
exports: [InventoryContextBuilder, NewInventoryContextBuilder, BuildContext]
- path: "internal/advisor/handler.go"
provides: "AdvisorHandler — POST /chat streaming SSE, GET /conversations, GET /conversations/:id"
exports: [AdvisorHandler, NewAdvisorHandler]
- path: "internal/api/handlers/advisor.go"
provides: "Thin glue wiring AdvisorHandler to chi routes (or inline in router.go)"
- path: "internal/api/router.go"
provides: "Routes for /api/advisor/chat, /api/advisor/conversations, /api/advisor/conversations/:id"
key_links:
- from: "internal/advisor/handler.go"
to: "go-openai CreateChatCompletionStream"
via: "TierClient extended with StreamChat method or direct openai.Client use"
pattern: "CreateChatCompletionStream"
- from: "internal/advisor/context.go"
to: "internal/netbox.Client.ListDevices"
via: "sync.Mutex + time.Time expiry for 60s cache"
pattern: "ListDevices"
- from: "internal/advisor/handler.go"
to: "internal/store.Store"
via: "CreateConversation + AddMessage on each turn"
pattern: "store.CreateConversation"
---
<objective>
Build the advisor backend: context assembly with NetBox inventory, SSE streaming chat
endpoint backed by Claude Opus via OpenRouter, and PostgreSQL persistence of every
conversation and message.
Purpose: ADV-01 (streaming chat), ADV-02 (inventory context), ADV-03 (persistence),
ADV-05 (model switch without restart).
Output: internal/advisor package + router wiring exposing three endpoints.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/06-lab-advisor/06-01-SUMMARY.md
# Key interfaces from prior plans (no exploration needed):
# internal/ai/client.go — AIClient + TierClient
# TierClient has: client *openai.Client, model string, timeout time.Duration
# NewTierClient(cfg TierConfig) *TierClient
# TierConfig: BaseURL, APIKey, Model, TimeoutSeconds
# go-openai streaming: client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{Stream: true, ...})
# returns (openai.ChatCompletionStream, error) — call .Recv() in loop until io.EOF
# internal/netbox/client.go
# ListDevices(ctx, limit int) ([]Device, error)
# Device struct has: ID int, Name string, AssetTag string, CustomFields map
# internal/store (from Plan 01):
# NewStore(ctx, dsn) (*Store, error)
# Store.CreateConversation(ctx, model) (string, error)
# Store.AddMessage(ctx, conversationID, role, content) (string, error)
# Store.GetConversation(ctx, id) (*Conversation, error) — returns ErrNotFound if missing
# Store.ListConversations(ctx) ([]ConversationSummary, error)
# ai_config.json tier3 config (OpenRouter):
# BaseURL: https://openrouter.ai/api/v1
# Default model: anthropic/claude-opus-4
# APIKey: from ai_config.json (loaded by config package)
# SSE pattern (from Phase 4/5 USBEventsHandler):
# w.Header().Set("Content-Type", "text/event-stream")
# w.Header().Set("Cache-Control", "no-cache")
# w.Header().Set("Connection", "keep-alive")
# fmt.Fprintf(w, "data: %s\n\n", token)
# flusher.Flush()
# Module path: git.georgsen.dk/hwlab
# Router uses chi, parameter extraction: chi.URLParam(r, "id")
</context>
<tasks>
<task type="auto">
<name>Task 1: InventoryContextBuilder with 60s cache</name>
<files>internal/advisor/context.go</files>
<action>
Create internal/advisor/context.go.
InventoryContextBuilder struct:
- nb *netbox.Client
- mu sync.Mutex
- cached string
- cachedAt time.Time
- ttl time.Duration (default 60s)
NewInventoryContextBuilder(nb *netbox.Client) *InventoryContextBuilder
BuildContext(ctx context.Context) (string, error):
- Under mutex: if time.Since(cachedAt) < ttl, return cached
- Call nb.ListDevices(ctx, 200)
- Build compact text summary:
- First line: "Inventory: N items total"
- Count by category (use CustomFields["category"] if present)
- List recent 20 items: "- HW-ID name (category)" from device.AssetTag + device.Name + CustomFields["category"]
- Aim for < 2000 chars; truncate item list if needed
- Store in cached + set cachedAt = time.Now()
- Return the summary string
The system prompt prefix for each chat: "You are a homelab advisor. Here is the current inventory:\n\n" + context
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./internal/advisor/...</automated>
</verify>
<done>internal/advisor/context.go compiles; BuildContext returns a non-empty string when given a mock netbox client returning sample devices</done>
</task>
<task type="auto">
<name>Task 2: AdvisorHandler (SSE streaming + persistence) and router wiring</name>
<files>internal/advisor/handler.go, internal/api/router.go, cmd/hwlab/main.go</files>
<action>
Create internal/advisor/handler.go.
ChatRequest struct (JSON):
- ConversationID string `json:"conversation_id"` — empty = new conversation
- Message string `json:"message"`
- Model string `json:"model"` — empty = "anthropic/claude-opus-4"
AdvisorHandler struct:
- store *store.Store
- ctx *InventoryContextBuilder
- aiCfg ai.AIConfig (for Tier3 config: BaseURL + APIKey)
NewAdvisorHandler(s *store.Store, ctxBuilder *InventoryContextBuilder, aiCfg ai.AIConfig) *AdvisorHandler
StreamChat(w http.ResponseWriter, r *http.Request):
1. Decode ChatRequest from body
2. If ConversationID empty: call store.CreateConversation(r.Context(), model) to get new ID
3. Call store.AddMessage(r.Context(), convID, "user", req.Message) to persist user turn
4. Call ctxBuilder.BuildContext to get inventory summary
5. Build []openai.ChatCompletionMessage: system (inventory summary), user (req.Message)
6. Build openai.ClientConfig from aiCfg.Tier3 (BaseURL, APIKey) but override Model to req.Model
— use openai.DefaultConfig(apiKey); cfg.BaseURL = baseURL; openai.NewClientWithConfig(cfg)
7. Call client.CreateChatCompletionStream(ctx, req{Model: model, Stream: true, Messages: msgs})
8. Set SSE headers; write "data: {"conversation_id":"...","token":"..."}\n\n" for each Recv() token
9. Flush after each write (cast w to http.Flusher)
10. On io.EOF: write final "data: [DONE]\n\n"; collect full response text from accumulated tokens
11. Call store.AddMessage(r.Context(), convID, "assistant", fullContent) to persist assistant turn
12. Error during stream: write "data: {"error":"..."}\n\n" and return
GetConversations(w http.ResponseWriter, r *http.Request):
- Call store.ListConversations(r.Context()); JSON encode list; 200
GetConversation(w http.ResponseWriter, r *http.Request):
- id := chi.URLParam(r, "id")
- Call store.GetConversation; if errors.Is(err, store.ErrNotFound) → 404; else JSON 200
Update internal/api/router.go:
- Add *advisor.AdvisorHandler parameter to NewRouter signature
- Add routes under r.Route("/api/advisor", ...):
- POST /chat → advisorHandler.StreamChat
- GET /conversations → advisorHandler.GetConversations
- GET /conversations/{id} → advisorHandler.GetConversation
Update cmd/hwlab/main.go:
- Read HWLAB_DATABASE_URL from env (already loaded via godotenv)
- Call store.NewStore(ctx, os.Getenv("HWLAB_DATABASE_URL"))
- Call store.RunMigrations(ctx, s.Pool())
- Create InventoryContextBuilder with netboxClient
- Create AdvisorHandler
- Pass to NewRouter
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./... && curl -s -N -X POST http://localhost:8080/api/advisor/chat -H "Content-Type: application/json" -d '{"message":"hello"}' | head -5</automated>
</verify>
<done>go build ./... passes; POST /api/advisor/chat returns SSE stream with data: lines; GET /api/advisor/conversations returns JSON array; GET /api/advisor/conversations/:id returns 404 for unknown ID</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP client → POST /api/advisor/chat | Untrusted JSON body; message content forwarded to OpenRouter |
| AdvisorHandler → OpenRouter API | API key in memory from ai_config.json; never echoed to client |
| SSE stream → browser | Token data flows back; no user data echoed except conversation_id |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-02-01 | Injection | StreamChat — message forwarded to OpenRouter | mitigate | Message is passed as user role content — OpenRouter's model, not our SQL. No SQL built from message content. |
| T-06-02-02 | Information Disclosure | StreamChat — OpenRouter APIKey | mitigate | Key read from ai_config.json / ai_config.local.json; never logged, never written to SSE stream |
| T-06-02-03 | Denial of Service | StreamChat — unbounded message length | mitigate | Truncate req.Message to 8000 chars before sending to OpenRouter; return 400 if body > 64KB |
| T-06-02-04 | Information Disclosure | SSE stream — conversation_id exposed | accept | Single-operator homelab; no multi-user auth; all conversations are the operator's own |
| T-06-02-05 | Spoofing | Model field in ChatRequest — caller can specify arbitrary model | accept | Single-operator tool; operator controls their OpenRouter account/spend; no model allowlist needed for homelab |
</threat_model>
<verification>
- `go build ./...` passes
- POST /api/advisor/chat with `{"message":"ping"}` returns SSE with at least one `data:` line
- GET /api/advisor/conversations returns `[]` or list of prior conversations
- GET /api/advisor/conversations/nonexistent returns HTTP 404
- psql confirms a row in conversations and rows in messages after a chat request
</verification>
<success_criteria>
- Three /api/advisor/* endpoints registered and responding
- Streaming response delivers tokens as SSE `data:` events
- NetBox inventory context appears in the system prompt (verify via log or test)
- Conversation and messages rows created in PostgreSQL on each chat
- Model override works: pass "anthropic/claude-3-5-sonnet" and it uses that model without restart
</success_criteria>
<output>
After completion, create .planning/phases/06-lab-advisor/06-02-SUMMARY.md
</output>