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

11 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
06-lab-advisor 02 execute 2
06-01
internal/advisor/handler.go
internal/advisor/context.go
internal/api/handlers/advisor.go
internal/api/router.go
cmd/hwlab/main.go
true
ADV-01
ADV-02
ADV-03
ADV-05
truths artifacts key_links
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
path provides exports
internal/advisor/context.go InventoryContextBuilder — assembles compact NetBox summary with 60s cache
InventoryContextBuilder
NewInventoryContextBuilder
BuildContext
path provides exports
internal/advisor/handler.go AdvisorHandler — POST /chat streaming SSE, GET /conversations, GET /conversations/:id
AdvisorHandler
NewAdvisorHandler
path provides
internal/api/handlers/advisor.go Thin glue wiring AdvisorHandler to chi routes (or inline in router.go)
path provides
internal/api/router.go Routes for /api/advisor/chat, /api/advisor/conversations, /api/advisor/conversations/:id
from to via pattern
internal/advisor/handler.go go-openai CreateChatCompletionStream TierClient extended with StreamChat method or direct openai.Client use CreateChatCompletionStream
from to via pattern
internal/advisor/context.go internal/netbox.Client.ListDevices sync.Mutex + time.Time expiry for 60s cache ListDevices
from to via pattern
internal/advisor/handler.go internal/store.Store CreateConversation + AddMessage on each turn 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.

<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.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

<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>
- `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

<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>
After completion, create .planning/phases/06-lab-advisor/06-02-SUMMARY.md