--- 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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") Task 1: InventoryContextBuilder with 60s cache internal/advisor/context.go 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 cd /home/mikkel/homelabby && go build ./internal/advisor/... internal/advisor/context.go compiles; BuildContext returns a non-empty string when given a mock netbox client returning sample devices Task 2: AdvisorHandler (SSE streaming + persistence) and router wiring internal/advisor/handler.go, internal/api/router.go, cmd/hwlab/main.go 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 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 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 ## 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 | - `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 - 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 After completion, create .planning/phases/06-lab-advisor/06-02-SUMMARY.md