docs(06): create phase 6 lab advisor plans (3 plans, 3 waves)
This commit is contained in:
parent
ef20f8e83f
commit
7a304c8cc4
4 changed files with 704 additions and 3 deletions
|
|
@ -122,8 +122,12 @@ Plans:
|
|||
2. Each conversation automatically includes a pre-loaded NetBox inventory context summary
|
||||
3. Conversation history persists across browser sessions and is viewable in the chat UI
|
||||
4. Model can be switched from Opus to any OpenRouter-compatible model via the dropdown without restarting the server
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Plans**: 3 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 06-01-PLAN.md — PostgreSQL store: pgx/v5 dep, conversations + messages tables, RunMigrations, typed CRUD methods
|
||||
- [ ] 06-02-PLAN.md — Advisor backend: InventoryContextBuilder (60s cache), AdvisorHandler SSE streaming, router wiring, main.go wiring
|
||||
- [ ] 06-03-PLAN.md — Frontend: AdvisorPage at /advisor, conversation sidebar, streaming chat UI, model dropdown, TopBar link
|
||||
|
||||
### Phase 7: Research Agent & Search
|
||||
**Goal**: Items flagged needs_research are automatically enriched by a SearXNG research agent, and any inventory question can be answered via natural language search
|
||||
|
|
@ -149,5 +153,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|
|||
| 3. Dashboard & Intake UI | 5/5 | Complete | 2026-04-10 |
|
||||
| 4. USB Manager & Label Printing | 5/5 | Complete | 2026-04-10 |
|
||||
| 5. Cable Test Integration | 3/3 | Complete | 2026-04-10 |
|
||||
| 6. Lab Advisor | 0/TBD | Not started | - |
|
||||
| 6. Lab Advisor | 0/3 | Not started | - |
|
||||
| 7. Research Agent & Search | 0/TBD | Not started | - |
|
||||
|
|
|
|||
195
.planning/phases/06-lab-advisor/06-01-PLAN.md
Normal file
195
.planning/phases/06-lab-advisor/06-01-PLAN.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
---
|
||||
phase: 06-lab-advisor
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/store/store.go
|
||||
- internal/store/migrations.go
|
||||
- internal/store/conversations.go
|
||||
- internal/store/store_test.go
|
||||
- go.mod
|
||||
- go.sum
|
||||
autonomous: true
|
||||
requirements: [ADV-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "PostgreSQL connection is established and verified at startup"
|
||||
- "conversations table exists with id, started_at, model columns"
|
||||
- "messages table exists with id, conversation_id, role, content, created_at columns"
|
||||
- "Store can create a conversation, append messages, and retrieve full thread"
|
||||
- "Store can list all conversations ordered by started_at DESC"
|
||||
artifacts:
|
||||
- path: "internal/store/store.go"
|
||||
provides: "Store struct, NewStore constructor, DB connection via pgx/v5"
|
||||
exports: [Store, NewStore]
|
||||
- path: "internal/store/migrations.go"
|
||||
provides: "RunMigrations — idempotent CREATE TABLE IF NOT EXISTS for conversations + messages"
|
||||
exports: [RunMigrations]
|
||||
- path: "internal/store/conversations.go"
|
||||
provides: "CreateConversation, AddMessage, GetConversation, ListConversations"
|
||||
exports: [CreateConversation, AddMessage, GetConversation, ListConversations]
|
||||
- path: "internal/store/store_test.go"
|
||||
provides: "Integration tests against real PostgreSQL"
|
||||
key_links:
|
||||
- from: "internal/store/conversations.go"
|
||||
to: "PostgreSQL homelabby DB"
|
||||
via: "pgx/v5 pgxpool"
|
||||
pattern: "pgxpool.New"
|
||||
- from: "internal/store/migrations.go"
|
||||
to: "conversations, messages tables"
|
||||
via: "CREATE TABLE IF NOT EXISTS"
|
||||
pattern: "CREATE TABLE IF NOT EXISTS conversations"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the internal/store package that manages chat persistence in PostgreSQL.
|
||||
RunMigrations creates conversations and messages tables idempotently; the Store
|
||||
struct exposes typed methods for the advisor handler to use.
|
||||
|
||||
Purpose: ADV-03 requires conversation history to persist across browser sessions.
|
||||
SQLite is already used for other local state but PostgreSQL is the designated store
|
||||
here (already running at 10.5.0.109, connection string in HWLAB_DATABASE_URL env).
|
||||
|
||||
Output: internal/store package with pgx/v5, auto-migrating tables, typed CRUD methods,
|
||||
integration tests.
|
||||
</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/STATE.md
|
||||
|
||||
# Module path: git.georgsen.dk/hwlab
|
||||
# PostgreSQL DSN is in env var HWLAB_DATABASE_URL (set in .env):
|
||||
# postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby
|
||||
# pgx/v5 driver: github.com/jackc/pgx/v5
|
||||
# Pattern: use pgxpool for the connection pool (pgxpool.New), not single conn
|
||||
# Pattern: all other packages use database/sql + sqlx — advisor uses pgx directly
|
||||
# (pgx/v5 is preferred for new code, avoids cgo unlike mattn/go-sqlite3)
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add pgx/v5 dep and create Store with RunMigrations</name>
|
||||
<files>internal/store/store.go, internal/store/migrations.go, go.mod, go.sum</files>
|
||||
<behavior>
|
||||
- NewStore("invalid-dsn") returns non-nil error
|
||||
- NewStore(validDSN) returns *Store with open pool, no error
|
||||
- Store.Close() does not panic
|
||||
- RunMigrations(ctx, pool) on empty DB creates conversations and messages tables
|
||||
- RunMigrations(ctx, pool) called twice is idempotent (no error, no duplicate tables)
|
||||
- conversations schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), started_at TIMESTAMPTZ NOT NULL DEFAULT now(), model TEXT NOT NULL DEFAULT ''
|
||||
- messages schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user','assistant','system')), content TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
</behavior>
|
||||
<action>
|
||||
Run `go get github.com/jackc/pgx/v5` to add the dependency.
|
||||
|
||||
Create internal/store/store.go:
|
||||
- Package store
|
||||
- Import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
- Store struct holds *pgxpool.Pool
|
||||
- NewStore(ctx context.Context, dsn string) (*Store, error) — calls pgxpool.New(ctx, dsn), pings pool, returns error on failure
|
||||
- Close() — calls pool.Close()
|
||||
- Expose Pool() *pgxpool.Pool accessor for use by repository methods
|
||||
|
||||
Create internal/store/migrations.go:
|
||||
- RunMigrations(ctx context.Context, pool *pgxpool.Pool) error
|
||||
- Execute the two CREATE TABLE IF NOT EXISTS statements in sequence using pool.Exec
|
||||
- conversations first (messages references it)
|
||||
- Return first non-nil error encountered
|
||||
|
||||
Write tests first (RED), confirm they fail, then implement (GREEN).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -run TestNewStore -v</automated>
|
||||
</verify>
|
||||
<done>TestNewStore passes; go build ./... succeeds; pgx/v5 present in go.mod</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Conversation and message CRUD methods</name>
|
||||
<files>internal/store/conversations.go, internal/store/store_test.go</files>
|
||||
<behavior>
|
||||
- CreateConversation(ctx, model string) returns (conversationID string, err error)
|
||||
- AddMessage(ctx, conversationID, role, content string) returns (messageID string, err error)
|
||||
- AddMessage with invalid role returns error (CHECK constraint violation from DB)
|
||||
- GetConversation(ctx, conversationID string) returns (*Conversation, error) where Conversation has ID, StartedAt, Model, Messages []Message
|
||||
- GetConversation on unknown ID returns nil, ErrNotFound (sentinel)
|
||||
- ListConversations(ctx) returns []ConversationSummary ordered by started_at DESC (no messages, just id + started_at + model + message_count)
|
||||
- Full round-trip: create conv → add 2 messages → GetConversation → Messages has 2 items in order
|
||||
</behavior>
|
||||
<action>
|
||||
Create internal/store/conversations.go:
|
||||
|
||||
Types:
|
||||
- Message struct { ID, ConversationID, Role, Content string; CreatedAt time.Time }
|
||||
- Conversation struct { ID, Model string; StartedAt time.Time; Messages []Message }
|
||||
- ConversationSummary struct { ID, Model string; StartedAt time.Time; MessageCount int }
|
||||
- var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
Methods on *Store:
|
||||
- CreateConversation(ctx, model) — INSERT INTO conversations(model) VALUES($1) RETURNING id
|
||||
- AddMessage(ctx, conversationID, role, content) — INSERT INTO messages(conversation_id, role, content) VALUES($1,$2,$3) RETURNING id
|
||||
- GetConversation(ctx, id) — SELECT from conversations JOIN messages ORDER BY messages.created_at ASC; if 0 rows return nil, ErrNotFound
|
||||
- ListConversations(ctx) — SELECT c.id, c.started_at, c.model, COUNT(m.id) AS message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id GROUP BY c.id ORDER BY c.started_at DESC
|
||||
|
||||
Write tests in internal/store/store_test.go using build tag `//go:build integration`
|
||||
so they run only with -tags integration. Tests connect to the real PostgreSQL.
|
||||
Each test creates its own conversation and cleans up (DELETE FROM conversations WHERE id = $1).
|
||||
|
||||
Write tests first (RED), confirm compile error or test failure, then implement (GREEN).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -tags integration -v</automated>
|
||||
</verify>
|
||||
<done>All store integration tests pass; GetConversation returns ErrNotFound for unknown ID; ListConversations returns summaries with message counts; go build ./... passes</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| App → PostgreSQL | DSN from env; pool executes parameterized queries |
|
||||
| Messages table | role column has CHECK constraint; content is free text |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-01-01 | Injection | conversations.go — all queries | mitigate | All queries use $1/$2 parameterized args via pgx; no string interpolation in SQL |
|
||||
| T-06-01-02 | Information Disclosure | store.go — DSN in logs | mitigate | NewStore logs only connection error text, not the DSN; DSN sourced from env, not config file |
|
||||
| T-06-01-03 | Tampering | messages.role column | mitigate | DB-level CHECK (role IN ('user','assistant','system')) — invalid role returns pgx constraint error |
|
||||
| T-06-01-04 | Denial of Service | ListConversations with unbounded growth | accept | Single-operator homelab tool; conversation count bounded in practice; add LIMIT 100 as soft guard |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go build ./...` passes
|
||||
- `go test ./internal/store/...` passes (non-integration tests)
|
||||
- `go test ./internal/store/... -tags integration` passes against live PostgreSQL
|
||||
- `psql $HWLAB_DATABASE_URL -c "\d conversations"` shows the schema
|
||||
- `psql $HWLAB_DATABASE_URL -c "\d messages"` shows the schema with FK to conversations
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- pgx/v5 added to go.mod
|
||||
- internal/store package compiles with no errors
|
||||
- RunMigrations creates both tables idempotently
|
||||
- Full CRUD round-trip (create conv, add messages, get conv, list convs) passes integration tests
|
||||
- ErrNotFound returned for unknown conversation ID
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create .planning/phases/06-lab-advisor/06-01-SUMMARY.md
|
||||
</output>
|
||||
246
.planning/phases/06-lab-advisor/06-02-PLAN.md
Normal file
246
.planning/phases/06-lab-advisor/06-02-PLAN.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
---
|
||||
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>
|
||||
256
.planning/phases/06-lab-advisor/06-03-PLAN.md
Normal file
256
.planning/phases/06-lab-advisor/06-03-PLAN.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
---
|
||||
phase: 06-lab-advisor
|
||||
plan: "03"
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [06-02]
|
||||
files_modified:
|
||||
- web/src/pages/AdvisorPage.tsx
|
||||
- web/src/api/advisor.ts
|
||||
- web/src/router.tsx
|
||||
- web/src/components/layout/TopBar.tsx
|
||||
autonomous: true
|
||||
requirements: [ADV-01, ADV-03, ADV-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can navigate to /advisor from the TopBar"
|
||||
- "Left sidebar lists all past conversations ordered newest-first"
|
||||
- "User can start a new conversation or select a prior one from the sidebar"
|
||||
- "User types a message, presses Send, and sees streaming tokens appear in real time"
|
||||
- "Conversation history (prior messages) is displayed above the input"
|
||||
- "Model dropdown shows current model; changing it takes effect on the next message"
|
||||
- "conversation_id is tracked across messages so all turns land in the same thread"
|
||||
artifacts:
|
||||
- path: "web/src/api/advisor.ts"
|
||||
provides: "Typed API wrappers: streamChat (SSE EventSource), fetchConversations, fetchConversation"
|
||||
exports: [streamChat, fetchConversations, fetchConversation, Conversation, ConversationSummary, ChatMessage]
|
||||
- path: "web/src/pages/AdvisorPage.tsx"
|
||||
provides: "AdvisorPage component: two-panel layout (sidebar + chat)"
|
||||
- path: "web/src/router.tsx"
|
||||
provides: "/advisor route added"
|
||||
- path: "web/src/components/layout/TopBar.tsx"
|
||||
provides: "Advisor nav link added to header"
|
||||
key_links:
|
||||
- from: "AdvisorPage.tsx — Send button"
|
||||
to: "POST /api/advisor/chat via EventSource/fetch SSE"
|
||||
via: "streamChat() in api/advisor.ts"
|
||||
pattern: "EventSource|fetch.*text/event-stream"
|
||||
- from: "AdvisorPage.tsx — sidebar"
|
||||
to: "GET /api/advisor/conversations"
|
||||
via: "useQuery + fetchConversations"
|
||||
pattern: "fetchConversations"
|
||||
- from: "AdvisorPage.tsx — conversation selection"
|
||||
to: "GET /api/advisor/conversations/:id"
|
||||
via: "useQuery + fetchConversation keyed by selected ID"
|
||||
pattern: "fetchConversation"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the AdvisorPage React component at /advisor: two-panel chat UI with a conversation
|
||||
sidebar, streaming message display, and model selection dropdown.
|
||||
|
||||
Purpose: ADV-01 (streaming chat UI), ADV-03 (history viewable in UI), ADV-04 (model
|
||||
dropdown, conversation list).
|
||||
|
||||
Output: AdvisorPage.tsx, api/advisor.ts, router + TopBar updates.
|
||||
</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/phases/06-lab-advisor/06-02-SUMMARY.md
|
||||
|
||||
# Design system: ClickHouse-inspired dark theme (see ~/.claude/DESIGN.md)
|
||||
# Canvas: #000000, accent/volt: #faff69, charcoal border: #222, muted text: #888
|
||||
# Tailwind tokens already in project: bg-canvas, text-volt, border-charcoal
|
||||
# All existing pages use: AppShell wrapping, lucide-react icons, shadcn/ui Button/Input
|
||||
# Router: TanStack Router v1 — lazy import pattern from router.tsx (see below)
|
||||
# API pattern: useQuery from @tanstack/react-query for GET, fetch/EventSource for SSE
|
||||
|
||||
# Backend endpoints (from Plan 02):
|
||||
# POST /api/advisor/chat
|
||||
# Body: { conversation_id?: string, message: string, model?: string }
|
||||
# Response: SSE stream, each event: data: {"conversation_id":"...","token":"..."}\n\n
|
||||
# Final event: data: [DONE]\n\n
|
||||
# GET /api/advisor/conversations
|
||||
# Response: [{id, started_at, model, message_count}]
|
||||
# GET /api/advisor/conversations/:id
|
||||
# Response: {id, started_at, model, messages: [{id, role, content, created_at}]}
|
||||
|
||||
# Router lazy import pattern (match existing pages):
|
||||
# const AdvisorPage = lazy(() => import('./pages/AdvisorPage').then((m) => ({ default: m.AdvisorPage })))
|
||||
# createRoute({ path: '/advisor', component: () => <Suspense fallback={<Spinner />}><AdvisorPage /></Suspense> })
|
||||
|
||||
# TopBar pattern: Button variant="outline" size="sm" asChild wrapping Link to="/advisor"
|
||||
# Icon to use: MessageSquare from lucide-react
|
||||
|
||||
# SSE streaming: use fetch() with ReadableStream (not EventSource — POST body needed)
|
||||
# Pattern: const res = await fetch('/api/advisor/chat', {method:'POST', body:JSON.stringify(...)})
|
||||
# const reader = res.body!.getReader(); const decoder = new TextDecoder()
|
||||
# loop: read chunk, decode, split on \n\n, parse data: lines
|
||||
|
||||
# Available OpenRouter models for dropdown (hardcoded list, no API call needed):
|
||||
# ["anthropic/claude-opus-4", "anthropic/claude-sonnet-4-5", "anthropic/claude-3-5-haiku", "openai/gpt-4o"]
|
||||
# Default: "anthropic/claude-opus-4"
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: API wrappers for advisor endpoints</name>
|
||||
<files>web/src/api/advisor.ts</files>
|
||||
<action>
|
||||
Create web/src/api/advisor.ts with typed wrappers.
|
||||
|
||||
Types:
|
||||
```typescript
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string
|
||||
started_at: string
|
||||
model: string
|
||||
message_count: number
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
started_at: string
|
||||
model: string
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
```
|
||||
|
||||
fetchConversations(): Promise<ConversationSummary[]>
|
||||
— GET /api/advisor/conversations; returns [] on 404
|
||||
|
||||
fetchConversation(id: string): Promise<Conversation>
|
||||
— GET /api/advisor/conversations/${id}
|
||||
|
||||
streamChat(params: { conversationId?: string; message: string; model: string },
|
||||
onToken: (token: string, conversationId: string) => void,
|
||||
onDone: () => void,
|
||||
onError: (err: string) => void): Promise<void>
|
||||
— POST /api/advisor/chat with Content-Type: application/json
|
||||
— Read response.body via getReader() + TextDecoder
|
||||
— Split chunks by '\n\n'; each 'data: ' prefixed line is an event
|
||||
— If data === '[DONE]' call onDone() and return
|
||||
— Else parse JSON { token, conversation_id }; call onToken(token, conversation_id)
|
||||
— On JSON parse error or fetch error call onError(message)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | grep advisor</automated>
|
||||
</verify>
|
||||
<done>web/src/api/advisor.ts compiles with no TypeScript errors; all three exported functions present</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: AdvisorPage component, route, and TopBar link</name>
|
||||
<files>web/src/pages/AdvisorPage.tsx, web/src/router.tsx, web/src/components/layout/TopBar.tsx</files>
|
||||
<action>
|
||||
Create web/src/pages/AdvisorPage.tsx.
|
||||
|
||||
Layout: AppShell > two-panel flex (lg: sidebar 280px fixed + main flex-1; mobile: main only, sidebar hidden):
|
||||
- Left sidebar (bg-[#0a0a0a] border-r border-charcoal/60):
|
||||
- "Lab Advisor" heading (text-volt font-bold)
|
||||
- "New Chat" button (full width, variant outline)
|
||||
- Conversation list from useQuery(['conversations'], fetchConversations, { refetchInterval: 5000 })
|
||||
Each item: truncated first message time + model name; selected item highlighted with bg-[#1a1a1a]
|
||||
Click: set selectedConversationId, load messages via useQuery(['conversation', id], ...)
|
||||
- Main chat panel (flex-col):
|
||||
- Messages area (flex-1 overflow-y-auto, scroll to bottom on new message via useEffect + ref):
|
||||
- Empty state: centered text "Ask anything about your homelab"
|
||||
- User messages: right-aligned, bg-[#1a1a1a] rounded-lg p-3 max-w-[80%]
|
||||
- Assistant messages: left-aligned, bg-[#111] rounded-lg p-3 max-w-[80%], text-[#e0e0e0]
|
||||
- Streaming assistant message shown inline as tokens arrive (append to streamingContent state)
|
||||
- Input row (border-t border-charcoal/60 p-4 flex gap-2):
|
||||
- Model select (shadcn/ui Select or native <select> styled): 4 options (see context)
|
||||
- Textarea (rows=2, resize-none, flex-1): onKeyDown Enter (no shift) triggers send
|
||||
- Send button (variant forest, disabled when streaming or empty input): Send icon
|
||||
|
||||
State management (local useState):
|
||||
- messages: ChatMessage[] — loaded from selected conversation or built from streaming turns
|
||||
- streamingContent: string — accumulates tokens during active stream
|
||||
- isStreaming: boolean — disables input during stream
|
||||
- currentConversationId: string | undefined — tracks active thread
|
||||
- selectedModel: string — default "anthropic/claude-opus-4"
|
||||
- input: string — textarea value
|
||||
|
||||
handleSend():
|
||||
1. If input empty or isStreaming, return
|
||||
2. Append user message to messages immediately (optimistic)
|
||||
3. Clear input, set isStreaming=true, streamingContent=""
|
||||
4. Call streamChat({conversationId: currentConversationId, message: input, model: selectedModel},
|
||||
onToken: (token, convId) => { setStreamingContent(p => p + token); setCurrentConversationId(convId) },
|
||||
onDone: () => { setMessages(p => [...p, {role:'assistant', content: streamingContent, ...}]); setStreamingContent(''); setIsStreaming(false) },
|
||||
onError: (err) => { toast.error(err); setIsStreaming(false) })
|
||||
|
||||
New Chat button: reset messages=[], currentConversationId=undefined, streamingContent="", input=""
|
||||
|
||||
Update web/src/router.tsx:
|
||||
- Add lazy import for AdvisorPage (same pattern as other pages)
|
||||
- Add advisorRoute: createRoute({ path: '/advisor', component: ... })
|
||||
- Add advisorRoute to routeTree
|
||||
|
||||
Update web/src/components/layout/TopBar.tsx:
|
||||
- Add MessageSquare import from lucide-react
|
||||
- Add Button variant="outline" size="sm" asChild wrapping Link to="/advisor" with MessageSquare icon
|
||||
- Place it between the Test button and Scan button
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<done>npx tsc --noEmit passes with no errors; /advisor route registered in router; TopBar shows Advisor link; AdvisorPage renders without runtime errors</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| User input → POST /api/advisor/chat | Free-text message; sanitized by backend before OpenRouter |
|
||||
| SSE token stream → DOM | Tokens rendered as text content, not innerHTML |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-03-01 | Injection (XSS) | AdvisorPage — assistant message rendering | mitigate | Render message content as React text node (JSX children), never dangerouslySetInnerHTML; streaming tokens appended to string state, displayed as text |
|
||||
| T-06-03-02 | Information Disclosure | advisor.ts — fetch error details | accept | Error messages from backend shown in toast; backend returns generic messages, not stack traces; single-operator tool |
|
||||
| T-06-03-03 | Spoofing | model dropdown — arbitrary model string | accept | Value sent to backend which passes to OpenRouter; single-operator tool controls their own spend; no impact beyond the operator |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd web && npx tsc --noEmit` exits 0
|
||||
- Visit http://localhost:8080/advisor — page renders with sidebar + chat panel
|
||||
- TopBar shows Advisor link (MessageSquare icon)
|
||||
- "New Chat" → type message → Send → streaming tokens appear token by token
|
||||
- After stream completes, message appears in history
|
||||
- Reload page, select prior conversation from sidebar → messages load
|
||||
- Change model in dropdown → next message uses selected model (verify in psql: SELECT model FROM conversations ORDER BY started_at DESC LIMIT 1)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- TypeScript compiles cleanly
|
||||
- /advisor route accessible from TopBar
|
||||
- Streaming works: tokens appear progressively, not all at once
|
||||
- Conversation persists: prior conversations visible in sidebar on page reload
|
||||
- Model dropdown changes the model used for next message
|
||||
- No XSS vector: assistant content rendered as text, not HTML
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create .planning/phases/06-lab-advisor/06-03-SUMMARY.md
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue