docs(07): create phase 7 plans — research agent and NL search
2 plans, 2 waves: SearXNG client + ResearchAgent (wave 1), NL search endpoint + dashboard search bar (wave 2). Covers AI-04 + UI-03. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
987dc4b97c
commit
34e0803661
3 changed files with 627 additions and 2 deletions
|
|
@ -139,7 +139,11 @@ Plans:
|
|||
3. SearXNG queries are sanitized before dispatch — no raw AI output reaches the search engine
|
||||
|
||||
**Note:** AI-04 is the sole unmapped requirement from Phase 2 that belongs here — it requires the full orchestrator, SearXNG client, and NetBox inventory all in place. The other Phase 2 requirements cover Tier 1 intake; this requirement covers the Tier 2 research loop.
|
||||
**Plans**: TBD
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 07-01-PLAN.md — SearXNG client, ResearchAgent worker, POST /api/research/trigger
|
||||
- [ ] 07-02-PLAN.md — NL search endpoint GET /api/search, dashboard search bar
|
||||
|
||||
## Progress
|
||||
|
||||
|
|
@ -154,4 +158,4 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|
|||
| 4. USB Manager & Label Printing | 5/5 | Complete | 2026-04-10 |
|
||||
| 5. Cable Test Integration | 3/3 | Complete | 2026-04-10 |
|
||||
| 6. Lab Advisor | 3/3 | Complete | 2026-04-10 |
|
||||
| 7. Research Agent & Search | 0/TBD | Not started | - |
|
||||
| 7. Research Agent & Search | 0/2 | Not started | - |
|
||||
|
|
|
|||
319
.planning/phases/07-research-agent-search/07-01-PLAN.md
Normal file
319
.planning/phases/07-research-agent-search/07-01-PLAN.md
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
---
|
||||
phase: 07-research-agent-search
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/config/config.go
|
||||
- internal/netbox/client.go
|
||||
- internal/research/searxng.go
|
||||
- internal/research/agent.go
|
||||
- internal/ai/research.go
|
||||
- cmd/hwlab/main.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- AI-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A SearXNG HTTP GET to http://10.5.0.129:8080/search?q=...&format=json returns parsed results"
|
||||
- "Items with catalog_status=needs_research are polled from NetBox every 10 minutes"
|
||||
- "Each needs_research item is enriched by SearXNG + Tier 2 LLM and updated to catalog_status=researched in NetBox"
|
||||
- "POST /api/research/trigger fires an immediate research cycle (does not wait for the 10-min ticker)"
|
||||
artifacts:
|
||||
- path: "internal/research/searxng.go"
|
||||
provides: "SearXNGClient implementing ai.ResearchClient"
|
||||
exports: ["SearXNGClient", "NewSearXNGClient"]
|
||||
- path: "internal/research/agent.go"
|
||||
provides: "ResearchAgent background goroutine"
|
||||
exports: ["Agent", "NewAgent", "RunOnce", "Start"]
|
||||
key_links:
|
||||
- from: "internal/research/searxng.go"
|
||||
to: "http://10.5.0.129:8080/search"
|
||||
via: "net/http GET with q and format=json query params"
|
||||
pattern: "http\\.Get.*search.*format=json"
|
||||
- from: "internal/research/agent.go"
|
||||
to: "internal/netbox/client.go"
|
||||
via: "ListDevicesWithStatus(ctx, \"needs_research\")"
|
||||
pattern: "ListDevicesWithStatus"
|
||||
- from: "internal/research/agent.go"
|
||||
to: "internal/ai/client.go"
|
||||
via: "tier2.AnalyzePhotos (text-only prompt, no photos)"
|
||||
pattern: "AnalyzePhotos"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the real SearXNG research client and the ResearchAgent background worker that
|
||||
closes the AI-04 research loop: items at needs_research are enriched automatically.
|
||||
|
||||
Purpose: Replace the Phase 2 NoOpResearchClient stub and deliver the automated
|
||||
enrichment cycle that advances items from needs_research to researched in NetBox.
|
||||
|
||||
Output:
|
||||
- internal/research/searxng.go — real HTTP client implementing ai.ResearchClient
|
||||
- internal/research/agent.go — background worker with ticker + on-demand trigger
|
||||
- Config additions for SearXNG URL
|
||||
- main.go goroutine start + POST /api/research/trigger handler
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/mikkel/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
|
||||
@internal/ai/research.go
|
||||
@internal/ai/client.go
|
||||
@internal/ai/orchestrator.go
|
||||
@internal/netbox/client.go
|
||||
@internal/netbox/custom_fields.go
|
||||
@internal/netbox/types.go
|
||||
@internal/inventory/catalog_updater.go
|
||||
@internal/config/config.go
|
||||
@cmd/hwlab/main.go
|
||||
@internal/api/router.go
|
||||
|
||||
<interfaces>
|
||||
<!-- Key contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From internal/ai/research.go:
|
||||
```go
|
||||
type SearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
type ResearchClient interface {
|
||||
Search(ctx context.Context, query string) ([]SearchResult, error)
|
||||
}
|
||||
|
||||
type NoOpResearchClient struct{}
|
||||
// Replace this with SearXNGClient in this plan.
|
||||
```
|
||||
|
||||
From internal/ai/client.go:
|
||||
```go
|
||||
type AIClient interface {
|
||||
AnalyzePhotos(ctx context.Context, req IntakeRequest) (*IntakeResult, error)
|
||||
}
|
||||
// IntakeRequest.PhotosBase64 may be empty — the Tier 2 model accepts text-only
|
||||
// if the prompt is placed in a separate system message; use a text-only prompt
|
||||
// for research enrichment (no photos).
|
||||
```
|
||||
|
||||
From internal/netbox/client.go (method to ADD):
|
||||
```go
|
||||
// ListDevicesWithStatus returns devices whose catalog_status custom field equals status.
|
||||
// Use status="needs_research" to find items needing enrichment.
|
||||
func (c *Client) ListDevicesWithStatus(ctx context.Context, status string) ([]Device, error)
|
||||
```
|
||||
|
||||
From internal/inventory/catalog_updater.go:
|
||||
```go
|
||||
func (u *CatalogUpdater) UpdateCatalogStatus(ctx context.Context, deviceID int64, current, next CatalogStatus) (CatalogStatus, error)
|
||||
```
|
||||
|
||||
From internal/inventory (quality_gate.go constants):
|
||||
```go
|
||||
const StatusNeedsResearch CatalogStatus = "needs_research"
|
||||
const StatusResearched CatalogStatus = "researched"
|
||||
```
|
||||
|
||||
From internal/config/config.go (field to ADD):
|
||||
```go
|
||||
SearXNGURL string `mapstructure:"searxng_url"`
|
||||
// default: "http://10.5.0.129:8080"
|
||||
// env: HWLAB_SEARXNG_URL
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: SearXNG client + netbox.ListDevicesWithStatus</name>
|
||||
<files>
|
||||
internal/research/searxng.go,
|
||||
internal/research/searxng_test.go,
|
||||
internal/netbox/client.go,
|
||||
internal/config/config.go
|
||||
</files>
|
||||
<behavior>
|
||||
- SearXNGClient.Search(ctx, "Intel NIC i350") sends GET http://10.5.0.129:8080/search?q=Intel+NIC+i350&format=json
|
||||
- HTTP 200 with JSON body {"results":[{"title":"...","url":"...","content":"..."},...]} parses into []ai.SearchResult (map content->Snippet)
|
||||
- HTTP non-200 returns error with status code
|
||||
- Empty results array returns empty slice, no error
|
||||
- Query is URL-encoded (url.QueryEscape or url.Values)
|
||||
- ListDevicesWithStatus filters via custom_fields cf_catalog_status in go-netbox list call; falls back to client-side filter if API param unavailable
|
||||
- ListDevicesWithStatus("needs_research") returns only devices with that catalog_status
|
||||
</behavior>
|
||||
<action>
|
||||
Create package internal/research.
|
||||
|
||||
internal/research/searxng.go:
|
||||
- Struct SearXNGClient with baseURL string and httpClient *http.Client (timeout 15s)
|
||||
- NewSearXNGClient(baseURL string) *SearXNGClient — if baseURL empty, use "http://10.5.0.129:8080"
|
||||
- Implements ai.ResearchClient interface
|
||||
- Search method: build GET {baseURL}/search?q={url-encoded query}&format=json, execute, decode JSON
|
||||
- SearXNG JSON response shape: {"results":[{"title":"","url":"","content":""},...]}
|
||||
Map content field to SearchResult.Snippet (SearXNG uses "content" not "snippet")
|
||||
- Return ([]ai.SearchResult, error). Never panic on empty results.
|
||||
|
||||
internal/research/searxng_test.go:
|
||||
- Use httptest.NewServer to mock SearXNG responses
|
||||
- Test: valid response parses correctly (2 results)
|
||||
- Test: HTTP 500 returns error
|
||||
- Test: empty results returns empty slice
|
||||
|
||||
internal/netbox/client.go — add ListDevicesWithStatus:
|
||||
- List all devices (up to 200), filter client-side where CustomFields.CatalogStatus == status
|
||||
- (go-netbox v4 custom field filtering via query param is schema-dependent; client-side is safer)
|
||||
|
||||
internal/config/config.go — add SearXNGURL:
|
||||
- Field: SearXNGURL string `mapstructure:"searxng_url"`
|
||||
- Default: v.SetDefault("searxng_url", "http://10.5.0.129:8080")
|
||||
- Env binding: v.BindEnv("searxng_url", "HWLAB_SEARXNG_URL")
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go test ./internal/research/... ./internal/config/... -v -count=1 -run TestSearXNG 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
SearXNGClient implements ai.ResearchClient. Tests pass with httptest mock server.
|
||||
ListDevicesWithStatus added to netbox.Client. Config loads SearXNGURL with default.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: ResearchAgent worker + main.go wiring + trigger endpoint</name>
|
||||
<files>
|
||||
internal/research/agent.go,
|
||||
internal/research/agent_test.go,
|
||||
internal/api/handlers/research.go,
|
||||
internal/api/router.go,
|
||||
cmd/hwlab/main.go
|
||||
</files>
|
||||
<behavior>
|
||||
- Agent.RunOnce(ctx) polls NetBox for needs_research items, for each: builds a text-only search query from item Name, calls SearXNGClient.Search, sends results to Tier 2 LLM with a research prompt, patches NetBox custom fields (ai_notes, product_url from first result URL), transitions status to researched via CatalogUpdater
|
||||
- Agent.Start(ctx, interval) runs RunOnce on ticker; logs "research agent: cycle complete, enriched N items"
|
||||
- If SearXNG returns 0 results for an item, log warning and skip (do not change status)
|
||||
- Tier 2 LLM research prompt: "You are enriching a hardware inventory record. Item: {name}. Search results: {formatted snippets}. Return JSON: {\"ai_notes\": \"...\", \"product_url\": \"...\"}"
|
||||
- POST /api/research/trigger responds 202 Accepted and fires RunOnce in a goroutine (non-blocking)
|
||||
- Query sanitization: strip characters outside [a-zA-Z0-9 .-_] before passing to SearXNG
|
||||
</behavior>
|
||||
<action>
|
||||
internal/research/agent.go:
|
||||
- Struct Agent with fields: nbClient *netbox.Client, researchClient ai.ResearchClient,
|
||||
tier2 ai.AIClient, updater *inventory.CatalogUpdater
|
||||
- NewAgent(nb *netbox.Client, rc ai.ResearchClient, tier2 ai.AIClient, updater *inventory.CatalogUpdater) *Agent
|
||||
- sanitizeQuery(s string) string — regexp [^a-zA-Z0-9 .\-_]+ replaced with space, strings.TrimSpace
|
||||
- RunOnce(ctx context.Context) (enriched int, err error):
|
||||
1. ListDevicesWithStatus(ctx, "needs_research")
|
||||
2. For each device:
|
||||
a. query = sanitizeQuery(device.Name)
|
||||
b. results = researchClient.Search(ctx, query) — skip if 0 results
|
||||
c. Build text prompt with top 3 results (title + snippet)
|
||||
d. tier2.AnalyzePhotos(ctx, IntakeRequest{PhotosBase64: nil, SystemPrompt: researchPrompt})
|
||||
NOTE: IntakeRequest may not have SystemPrompt; build the research prompt as the
|
||||
text part of the multimodal request by putting it in a single text-only message.
|
||||
Check IntakeRequest fields; if no SystemPrompt, use a wrapper: set PhotosBase64 to
|
||||
nil and pass the assembled prompt text in a way the TierClient accepts.
|
||||
ALTERNATIVE if IntakeRequest does not support text-only: use go-openai directly
|
||||
via a new ResearchTierClient method — add TextComplete(ctx, prompt) (*IntakeResult, error)
|
||||
that posts a simple text ChatCompletion (no images). Prefer this approach for clarity.
|
||||
e. Parse response for ai_notes and product_url
|
||||
f. Patch NetBox: PatchCustomFields with ai_notes + product_url (if non-empty)
|
||||
g. UpdateCatalogStatus(ctx, id, StatusNeedsResearch, StatusResearched)
|
||||
h. enriched++
|
||||
3. Return enriched count
|
||||
- Start(ctx context.Context, interval time.Duration):
|
||||
log.Printf("research agent: starting, interval=%v", interval)
|
||||
RunOnce immediately, then ticker loop until ctx.Done()
|
||||
|
||||
For the text-only LLM call: add TextComplete to TierClient in internal/ai/client.go:
|
||||
```go
|
||||
func (c *TierClient) TextComplete(ctx context.Context, prompt string) (string, error)
|
||||
```
|
||||
This does a simple non-vision ChatCompletion with a single user message. Agent uses this.
|
||||
|
||||
internal/research/agent_test.go:
|
||||
- Mock ResearchClient returning 2 fake SearchResults
|
||||
- Mock AIClient (use existing MockAIClient pattern if available, else minimal struct)
|
||||
- Mock NetBox (or use a stub struct) — test RunOnce returns enriched=1 for a fake device
|
||||
- Test sanitizeQuery strips special chars
|
||||
|
||||
internal/api/handlers/research.go:
|
||||
- ResearchHandler struct with agent *research.Agent
|
||||
- NewResearchHandler(agent *research.Agent) *ResearchHandler
|
||||
- TriggerResearch(w http.ResponseWriter, r *http.Request):
|
||||
go func() { agent.RunOnce(context.Background()) }()
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "accepted"})
|
||||
|
||||
internal/api/router.go:
|
||||
- Add researchHandler *handlers.ResearchHandler parameter to NewRouter signature
|
||||
- Add r.Post("/research/trigger", researchHandler.TriggerResearch) inside r.Route("/api", ...)
|
||||
- If researchHandler is nil, register an unavailable handler (same pattern as advisorHandler)
|
||||
|
||||
cmd/hwlab/main.go:
|
||||
- Import internal/research
|
||||
- After config load: searxngClient := research.NewSearXNGClient(cfg.SearXNGURL)
|
||||
- researchAgent := research.NewAgent(nbClient, searxngClient, tier2, catalogUpdater)
|
||||
- go researchAgent.Start(ctx, 10*time.Minute)
|
||||
- researchHandler := handlers.NewResearchHandler(researchAgent)
|
||||
- Pass researchHandler to api.NewRouter(...)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/research/... -v -count=1 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
go build passes. Agent tests pass. POST /api/research/trigger wired in router.
|
||||
Research agent goroutine starts on server launch with 10-minute interval.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| agent → SearXNG | AI-generated query text leaves the process and reaches the search engine |
|
||||
| SearXNG → agent | External search results (HTML snippets) enter the process and are forwarded to LLM |
|
||||
| trigger endpoint → agent | HTTP request from frontend triggers a research cycle |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-01 | Tampering | sanitizeQuery | mitigate | Strip [^a-zA-Z0-9 .\-_]+ before dispatch; test with adversarial input in unit test |
|
||||
| T-07-02 | Information Disclosure | SearXNG response snippets | accept | SearXNG is self-hosted LAN service; snippets never stored, only passed to LLM |
|
||||
| T-07-03 | Denial of Service | POST /api/research/trigger | mitigate | Trigger fires goroutine but RunOnce is bounded per item; no queuing needed for MVP rate |
|
||||
| T-07-04 | Spoofing | SearXNG base URL in config | accept | LAN-only service at fixed IP; no auth required by design |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `go build ./...` passes with no errors
|
||||
2. `go test ./internal/research/...` all pass
|
||||
3. SearXNG integration (manual): `curl "http://10.5.0.129:8080/search?q=Intel+i350&format=json"` returns JSON
|
||||
4. Trigger endpoint: `curl -X POST http://localhost:8080/api/research/trigger` returns 202
|
||||
5. Log line "research agent: starting, interval=10m0s" appears on server start
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SearXNGClient.Search returns parsed []ai.SearchResult from live SearXNG instance
|
||||
- ResearchAgent.RunOnce enriches needs_research items end-to-end: search → LLM → NetBox patch → status transition
|
||||
- Research cycle runs every 10 minutes automatically and on demand via POST /api/research/trigger
|
||||
- All queries sanitized before SearXNG dispatch
|
||||
- go build clean, all new tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-research-agent-search/07-01-SUMMARY.md`
|
||||
</output>
|
||||
302
.planning/phases/07-research-agent-search/07-02-PLAN.md
Normal file
302
.planning/phases/07-research-agent-search/07-02-PLAN.md
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
---
|
||||
phase: 07-research-agent-search
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "07-01-PLAN.md"
|
||||
files_modified:
|
||||
- internal/api/handlers/search.go
|
||||
- internal/api/router.go
|
||||
- web/src/lib/api.ts
|
||||
- web/src/pages/DashboardPage.tsx
|
||||
- web/src/components/inventory/FilterBar.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- AI-04
|
||||
- UI-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /api/search?q=show+me+free+10GbE+NICs returns matching inventory items"
|
||||
- "Tier 1 (Gemma 4) translates the natural language query to NetBox filter params before the inventory lookup"
|
||||
- "Dashboard has a natural language search input; submitting it calls GET /api/search and displays results using existing ItemCard/ItemRow"
|
||||
- "Sanitized query — no raw NL text reaches NetBox filter params; only structured extracted values"
|
||||
artifacts:
|
||||
- path: "internal/api/handlers/search.go"
|
||||
provides: "SearchHandler: GET /api/search?q=..."
|
||||
exports: ["SearchHandler", "NewSearchHandler"]
|
||||
- path: "web/src/lib/api.ts"
|
||||
provides: "fetchSearch(q) function"
|
||||
exports: ["fetchSearch", "SearchResponse"]
|
||||
key_links:
|
||||
- from: "web/src/pages/DashboardPage.tsx"
|
||||
to: "/api/search"
|
||||
via: "TanStack Query useQuery on nlQuery state"
|
||||
pattern: "useQuery.*search"
|
||||
- from: "internal/api/handlers/search.go"
|
||||
to: "internal/netbox/client.go"
|
||||
via: "ListDevices or ListDevicesWithStatus filtered by extracted params"
|
||||
pattern: "nbClient\\.List"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Natural language inventory search: a Tier 1 LLM translates the user's query to
|
||||
structured NetBox filter params, fetches matching devices, and returns them.
|
||||
The dashboard gains an NL search input wired to GET /api/search.
|
||||
|
||||
Purpose: Delivers UI-03 (natural language search) and closes the remaining AI-04
|
||||
surface (research loop query path).
|
||||
|
||||
Output:
|
||||
- internal/api/handlers/search.go — SearchHandler with NL→filter translation
|
||||
- Router wired with GET /api/search
|
||||
- web/src/lib/api.ts — fetchSearch function
|
||||
- DashboardPage NL search bar replaces/augments the existing local text filter
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/mikkel/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/07-research-agent-search/07-01-SUMMARY.md
|
||||
|
||||
@internal/api/router.go
|
||||
@internal/api/handlers/search.go
|
||||
@internal/netbox/client.go
|
||||
@internal/netbox/types.go
|
||||
@internal/ai/client.go
|
||||
@internal/config/config.go
|
||||
@web/src/lib/api.ts
|
||||
@web/src/pages/DashboardPage.tsx
|
||||
@web/src/components/inventory/FilterBar.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- Key contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From internal/netbox/client.go:
|
||||
```go
|
||||
func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error)
|
||||
func (c *Client) ListDevicesWithStatus(ctx context.Context, status string) ([]Device, error)
|
||||
// Added in Plan 01
|
||||
```
|
||||
|
||||
From internal/netbox/types.go:
|
||||
```go
|
||||
type Device struct {
|
||||
ID int
|
||||
Name string
|
||||
AssetTag string
|
||||
CustomFields CustomFields
|
||||
Created time.Time
|
||||
LastUpdated time.Time
|
||||
}
|
||||
type CustomFields struct {
|
||||
HWID string
|
||||
CatalogStatus string
|
||||
ProductURL string
|
||||
FirmwareVersion string
|
||||
TestDate string
|
||||
TestData string
|
||||
AINotes string
|
||||
PhotoURLs []string
|
||||
}
|
||||
```
|
||||
|
||||
From internal/ai/client.go:
|
||||
```go
|
||||
type TierClient struct { ... }
|
||||
// TextComplete added in Plan 01:
|
||||
func (c *TierClient) TextComplete(ctx context.Context, prompt string) (string, error)
|
||||
```
|
||||
|
||||
From web/src/lib/api.ts:
|
||||
```typescript
|
||||
export interface InventoryItem {
|
||||
id: number; name: string; asset_tag: string | null; hw_id: string | null;
|
||||
catalog_status: string | null; product_url: string | null;
|
||||
firmware_version: string | null; test_date: string | null;
|
||||
test_data: string | null; ai_notes: string | null; photo_urls: string[];
|
||||
}
|
||||
export const fetchInventory = (): Promise<InventoryItem[]>
|
||||
// Add fetchSearch here — returns InventoryItem[] with same shape
|
||||
```
|
||||
|
||||
From web/src/pages/DashboardPage.tsx:
|
||||
```typescript
|
||||
// Existing local text filter:
|
||||
const [search, setSearch] = useState('')
|
||||
// Filtered locally via useMemo. NL search should add a separate nlQuery state.
|
||||
// When nlQuery is non-empty: show NL results (from GET /api/search) instead of local filter.
|
||||
// When nlQuery is empty: use existing local search behavior unchanged.
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: SearchHandler — NL query → NetBox filter → device list</name>
|
||||
<files>
|
||||
internal/api/handlers/search.go,
|
||||
internal/api/handlers/search_test.go,
|
||||
internal/api/router.go
|
||||
</files>
|
||||
<behavior>
|
||||
- GET /api/search?q=show+me+free+10GbE+NICs → 200 JSON array of InventoryItem
|
||||
- GET /api/search with empty q → 400 {"error":"q parameter required"}
|
||||
- Tier 1 LLM receives: "Extract NetBox filter parameters from this inventory search query. Return JSON only: {\"catalog_status\": \"...\", \"name_contains\": \"...\", \"tag\": \"...\"}. All fields optional. Query: {user query}"
|
||||
- LLM response parsed; client-side filter applied to ListDevices(ctx, 200) results
|
||||
- name_contains: case-insensitive substring match on device.Name
|
||||
- catalog_status: exact match on CustomFields.CatalogStatus (available → "available", etc.)
|
||||
- tag: ignored for MVP (NetBox tag filtering requires separate API; log "tag filter not implemented")
|
||||
- If LLM parse fails, fall back to simple substring match on device Name against raw query
|
||||
- Result serialized as []map[string]interface{} matching InventoryItem TypeScript shape
|
||||
</behavior>
|
||||
<action>
|
||||
internal/api/handlers/search.go:
|
||||
- SearchHandler struct: nbClient *netbox.Client, tier1 *ai.TierClient
|
||||
- NewSearchHandler(nb *netbox.Client, tier1 *ai.TierClient) *SearchHandler
|
||||
- SearchDevices(w http.ResponseWriter, r *http.Request):
|
||||
1. q := r.URL.Query().Get("q"); if empty → 400
|
||||
2. Sanitize q: strip non-printable chars, trim to 200 chars max
|
||||
3. Call tier1.TextComplete(ctx, nlFilterPrompt(q)) — 5s timeout
|
||||
4. Parse JSON response into struct { CatalogStatus string `json:"catalog_status"`, NameContains string `json:"name_contains"`, Tag string `json:"tag"` }
|
||||
5. If parse fails: log warning, set NameContains = q (fallback)
|
||||
6. devices, _ = nbClient.ListDevices(ctx, 200)
|
||||
7. Apply filters: CatalogStatus match + NameContains match (both case-insensitive)
|
||||
8. Convert filtered devices to response slice using deviceToResponseMap helper
|
||||
9. json.NewEncoder(w).Encode(result)
|
||||
|
||||
deviceToResponseMap converts netbox.Device to map[string]interface{} matching InventoryItem shape:
|
||||
{ "id", "name", "asset_tag" (from AssetTag or nil), "hw_id", "catalog_status",
|
||||
"product_url", "firmware_version", "test_date", "test_data", "ai_notes", "photo_urls" }
|
||||
|
||||
nlFilterPrompt(q string) string — returns the extraction prompt.
|
||||
|
||||
internal/api/handlers/search_test.go:
|
||||
- Use a mock netbox client (simple struct implementing a ListDevices method via interface,
|
||||
or just test the filter logic separately)
|
||||
- Test: empty q returns 400
|
||||
- Test: nlFilter parse failure falls back to name substring match
|
||||
- Test: catalog_status filter correctly narrows results
|
||||
|
||||
internal/api/router.go:
|
||||
- Add searchHandler *handlers.SearchHandler param to NewRouter signature
|
||||
- Add r.Get("/search", searchHandler.SearchDevices) inside r.Route("/api", ...)
|
||||
- nil guard: if searchHandler is nil, return 503 (same pattern as advisorHandler)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/api/handlers/... -v -count=1 -run TestSearch 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
GET /api/search?q=... handler compiles and handler tests pass.
|
||||
Router wired. go build clean.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Frontend NL search bar + main.go wiring</name>
|
||||
<files>
|
||||
web/src/lib/api.ts,
|
||||
web/src/pages/DashboardPage.tsx,
|
||||
web/src/components/inventory/FilterBar.tsx,
|
||||
cmd/hwlab/main.go
|
||||
</files>
|
||||
<action>
|
||||
web/src/lib/api.ts — add fetchSearch:
|
||||
```typescript
|
||||
export const fetchSearch = (q: string): Promise<InventoryItem[]> =>
|
||||
fetchJSON<InventoryItem[]>(`${BASE}/search?q=${encodeURIComponent(q)}`)
|
||||
```
|
||||
|
||||
web/src/pages/DashboardPage.tsx changes:
|
||||
1. Add state: const [nlQuery, setNlQuery] = useState('')
|
||||
2. Add TanStack Query hook:
|
||||
```typescript
|
||||
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
||||
queryKey: ['search', nlQuery],
|
||||
queryFn: () => fetchSearch(nlQuery),
|
||||
enabled: nlQuery.trim().length > 2,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
```
|
||||
3. Display logic: when nlQuery.length > 2, show searchResults (or empty state)
|
||||
instead of the existing `filtered` local results.
|
||||
4. When nlQuery is empty, existing local filter behavior is unchanged.
|
||||
5. Pass nlQuery + setNlQuery + searchLoading to FilterBar as new props.
|
||||
|
||||
web/src/components/inventory/FilterBar.tsx changes:
|
||||
- Add nlQuery string prop + onNlQueryChange (string) => void + nlSearchLoading boolean
|
||||
- Add a second input below (or inline with) the existing search input:
|
||||
- Placeholder: "Ask anything: show me free 10GbE NICs…"
|
||||
- Tailwind: full-width, border-volt/40, bg-[#0a0a0a], text-white, focus:border-volt,
|
||||
rounded-card, px-3 py-2 text-sm
|
||||
- Right side: if nlSearchLoading show <Loader2 className="w-4 h-4 animate-spin text-volt" />
|
||||
- onBlur / onChange with 400ms debounce: call onNlQueryChange
|
||||
- Use a local useState for the input value; debounce via useEffect + setTimeout clearing pattern
|
||||
- Keep existing search + status filter inputs intact — NL search is additive
|
||||
|
||||
cmd/hwlab/main.go:
|
||||
- Import internal/api/handlers (already imported)
|
||||
- After building tier1 TierClient: searchHandler := handlers.NewSearchHandler(nbClient, tier1)
|
||||
- Pass searchHandler to api.NewRouter(...) — add as new final param
|
||||
- NOTE: tier1 is already constructed as `ai.NewTierClient(cfg.AI.Tier1)` — pass it directly
|
||||
but SearchHandler needs *ai.TierClient not ai.AIClient; adjust if needed (TierClient is a concrete type)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go build ./... && cd web && npm run build 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
go build and npm run build both pass with no errors.
|
||||
Dashboard FilterBar renders NL search input. Typing a query with > 2 chars triggers
|
||||
GET /api/search and displays results using existing ItemCard/ItemRow components.
|
||||
Existing local search filter still works when nlQuery is empty.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser → GET /api/search | User-supplied NL query crosses HTTP boundary |
|
||||
| search handler → Tier 1 LLM | Sanitized query forwarded to local oMLX |
|
||||
| LLM output → NetBox filter | Structured JSON from LLM used to filter devices |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-05 | Tampering | NL query → LLM prompt | mitigate | Strip non-printable chars, truncate to 200 chars before building prompt |
|
||||
| T-07-06 | Tampering | LLM output → filter params | mitigate | Parse only known fields (catalog_status, name_contains, tag); ignore unknown keys; fallback on parse failure |
|
||||
| T-07-07 | Denial of Service | GET /api/search fanout | accept | ListDevices(200) is bounded; Tier 1 local inference is fast; no per-user rate limiting needed for single-operator tool |
|
||||
| T-07-08 | Information Disclosure | search results | accept | All results are local NetBox inventory; no cross-tenant risk in single-operator homelab |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `go build ./...` passes
|
||||
2. `cd web && npm run build` passes
|
||||
3. Manual: GET http://localhost:8080/api/search?q=show+me+available+NICs returns JSON array
|
||||
4. Manual: GET http://localhost:8080/api/search (no q) returns 400
|
||||
5. Dashboard: NL search input visible below existing filter bar; typing triggers spinner then results
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- GET /api/search?q=... returns filtered InventoryItem array using Tier 1 NL→filter translation
|
||||
- Query sanitized (non-printable stripped, 200 char max) before LLM
|
||||
- LLM parse failure falls back to name substring match (never 500)
|
||||
- Dashboard NL search bar triggers live search; existing local filter unchanged when NL query empty
|
||||
- go build and npm run build both clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-research-agent-search/07-02-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue