Search hits and entity-knowledge hits never selected an id column, so
every KnowledgeHit.Id defaulted to the zero UUID. The frontend's keyed
{#each results as hit (hit.id)} then had all-duplicate keys, which
silently broke Svelte 5's if-block branch swap for the results panel —
search would set searched=true (Clear button appeared) but the view
never switched away from "Recently learned". Select e.id in both
queries and key the each block on hit.slug (guaranteed unique) instead.
239 lines
6.8 KiB
Go
239 lines
6.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// serveRecentKnowledge backs the Knowledge page's "what the system knows / has
|
|
// learned" view (a custom route, not part of the generated OpenAPI surface).
|
|
// It returns recency-ordered knowledge with a small stats header so the
|
|
// operator can literally watch the knowledge base grow — especially the notes
|
|
// Nomos writes itself via upsert_knowledge (source='nomos-agent'), which is
|
|
// the concrete evidence of "the system is getting better." Optional ?source=
|
|
// and ?limit= query params.
|
|
func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request) {
|
|
ctx := req.Context()
|
|
limit := 50
|
|
if l := req.URL.Query().Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
|
|
limit = n
|
|
}
|
|
}
|
|
source := req.URL.Query().Get("source") // "" = all, "nomos-agent" = agent-authored only
|
|
|
|
type item struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Kind string `json:"kind"`
|
|
Source string `json:"source"`
|
|
Tags []string `json:"tags"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
AgentAuthored bool `json:"agent_authored"`
|
|
}
|
|
|
|
// updated_at is cast to text in SQL — pgx v5 can't scan a timestamptz
|
|
// directly into a Go string (needs time.Time or an explicit cast), and
|
|
// that scan error was being silently swallowed below (every row skipped,
|
|
// endpoint returned 200 with an empty list and correct-looking stats
|
|
// since the stats query doesn't scan any timestamp column — found live).
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT e.slug, ke.title, e.type, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
WHERE ($1 = '' OR ke.source = $1)
|
|
ORDER BY ke.updated_at DESC
|
|
LIMIT $2`, source, limit)
|
|
if err != nil {
|
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []item{}
|
|
for rows.Next() {
|
|
var it item
|
|
var src string
|
|
if err := rows.Scan(&it.Slug, &it.Title, &it.Kind, &src, &it.Tags, &it.UpdatedAt); err != nil {
|
|
slog.Error("httpapi: knowledge/recent row scan failed", "error", err)
|
|
continue
|
|
}
|
|
it.Source = src
|
|
it.AgentAuthored = src == "nomos-agent"
|
|
if it.Tags == nil {
|
|
it.Tags = []string{}
|
|
}
|
|
items = append(items, it)
|
|
}
|
|
|
|
// Stats header: total, by kind, agent-authored, and how many changed in the
|
|
// last 7 days (the "still learning" signal).
|
|
var total, agentAuthored, last7d int
|
|
byKind := map[string]int{}
|
|
srows, err := s.pool.Query(ctx, `
|
|
SELECT e.type, COUNT(*),
|
|
COUNT(*) FILTER (WHERE ke.source = 'nomos-agent'),
|
|
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
|
|
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
|
|
GROUP BY e.type`)
|
|
if err == nil {
|
|
defer srows.Close()
|
|
for srows.Next() {
|
|
var kind string
|
|
var c, a, l int
|
|
if srows.Scan(&kind, &c, &a, &l) == nil {
|
|
byKind[kind] = c
|
|
total += c
|
|
agentAuthored += a
|
|
last7d += l
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"stats": map[string]any{
|
|
"total": total,
|
|
"by_kind": byKind,
|
|
"agent_authored": agentAuthored,
|
|
"last_7d": last7d,
|
|
},
|
|
"items": items,
|
|
})
|
|
}
|
|
|
|
func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) {
|
|
q := request.Params.Q
|
|
limit := clampLimit(request.Params.Limit)
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags,
|
|
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
|
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
|
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
|
|
FragmentDelimiter=" ... "') AS snippet
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
JOIN entity_types et ON et.name = e.type
|
|
WHERE ke.search @@ plainto_tsquery('english', $1)
|
|
ORDER BY rank DESC
|
|
LIMIT $2`,
|
|
q, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.KnowledgeHit{}
|
|
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var slug, eType, title, source string
|
|
var tags []string
|
|
var rank float32
|
|
var snippet *string
|
|
|
|
if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hitType := gen.Document
|
|
switch eType {
|
|
case "investigation":
|
|
hitType = gen.Investigation
|
|
case "runbook":
|
|
hitType = gen.Runbook
|
|
}
|
|
|
|
items = append(items, gen.KnowledgeHit{
|
|
Id: id,
|
|
Slug: slug,
|
|
Title: title,
|
|
Type: hitType,
|
|
Rank: &rank,
|
|
Snippet: snippet,
|
|
SourcePath: &source,
|
|
})
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
if items == nil {
|
|
items = []gen.KnowledgeHit{}
|
|
}
|
|
|
|
return gen.SearchKnowledge200JSONResponse{Items: items}, nil
|
|
}
|
|
|
|
func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKnowledgeRequestObject) (gen.GetEntityKnowledgeResponseObject, error) {
|
|
entitySlug := request.EntityId
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
JOIN entity_types et ON et.name = e.type
|
|
JOIN relationships r ON r.source_id = ke.entity_id
|
|
JOIN entities target ON target.id = r.target_id
|
|
WHERE target.slug = $1
|
|
AND r.valid_to IS NULL
|
|
AND r.type IN ('documents', 'about')
|
|
UNION
|
|
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
JOIN entity_types et ON et.name = e.type
|
|
JOIN relationships r ON r.source_id = ke.entity_id
|
|
JOIN entity_types target_type ON target_type.name = r.target_id::text
|
|
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
|
WHERE r.valid_to IS NULL
|
|
AND r.type = 'procedure-for'
|
|
ORDER BY 2`,
|
|
entitySlug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.KnowledgeHit{}
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var slug, eType, title, source string
|
|
var tags []string
|
|
|
|
if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hitType := gen.Document
|
|
switch eType {
|
|
case "investigation":
|
|
hitType = gen.Investigation
|
|
case "runbook":
|
|
hitType = gen.Runbook
|
|
}
|
|
|
|
items = append(items, gen.KnowledgeHit{
|
|
Id: id,
|
|
Slug: slug,
|
|
Title: title,
|
|
Type: hitType,
|
|
SourcePath: &source,
|
|
})
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
if items == nil {
|
|
items = []gen.KnowledgeHit{}
|
|
}
|
|
|
|
return gen.GetEntityKnowledge200JSONResponse{Items: items}, nil
|
|
} |