11 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-lab-advisor | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/06-lab-advisor/06-01-SUMMARY.mdKey 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):
- Decode ChatRequest from body
- If ConversationID empty: call store.CreateConversation(r.Context(), model) to get new ID
- Call store.AddMessage(r.Context(), convID, "user", req.Message) to persist user turn
- Call ctxBuilder.BuildContext to get inventory summary
- Build []openai.ChatCompletionMessage: system (inventory summary), user (req.Message)
- Build openai.ClientConfig from aiCfg.Tier3 (BaseURL, APIKey) but override Model to req.Model — use openai.DefaultConfig(apiKey); cfg.BaseURL = baseURL; openai.NewClientWithConfig(cfg)
- Call client.CreateChatCompletionStream(ctx, req{Model: model, Stream: true, Messages: msgs})
- Set SSE headers; write "data: {"conversation_id":"...","token":"..."}\n\n" for each Recv() token
- Flush after each write (cast w to http.Flusher)
- On io.EOF: write final "data: [DONE]\n\n"; collect full response text from accumulated tokens
- Call store.AddMessage(r.Context(), convID, "assistant", fullContent) to persist assistant turn
- 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
<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> |
<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>