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>
This commit is contained in:
@@ -2,10 +2,103 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user