246 lines
11 KiB
Markdown
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>
|