Files
oikos/internal/httpapi/knowledge.go
dtoro ec41c0b828
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: learning view — make the growing knowledge base visible
First slice of the observability/learning UI (the "see the system come alive
and learn" ask). The Knowledge page was search-only — blank until you typed —
so the knowledge Nomos now writes via upsert_knowledge was invisible unless
you knew to search for it. Now the page LEADS with what the system knows and
is learning:

- internal/httpapi/knowledge.go: GET /api/v1/knowledge/recent — recency-ordered
  knowledge + a stats header (total, agent-authored, learned-this-week,
  by-kind). Custom route (not OpenAPI-generated), same auth as the rest.
- web Knowledge page rewrite: stat cards up top (Total / Written by Nomos /
  Learned this week / runbooks-investigations), then a "Recently learned" feed
  with agent-authored notes highlighted and badged "learned by Nomos", tags,
  and relative timestamps. A toggle filters to Nomos-only. Search still works,
  now as a mode you enter/clear rather than the whole page.

This turns "the system is getting smarter" from a claim into something you
watch fill up: every gotcha the agent records shows here within seconds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:22:35 +02:00

227 lines
6.3 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"strconv"
"github.com/dtoro/oikos/internal/httpapi/gen"
)
// 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"`
}
rows, err := s.pool.Query(ctx, `
SELECT e.slug, ke.title, e.type, COALESCE(ke.source,''), ke.tags, ke.updated_at
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 {
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.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 slug, eType, title, source string
var tags []string
var rank float32
var snippet *string
if err := rows.Scan(&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{
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.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.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 1`,
entitySlug)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.KnowledgeHit{}
for rows.Next() {
var slug, eType, title, source string
var tags []string
if err := rows.Scan(&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{
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
}