From ec41c0b8283c734657d21487707c197826b45d8b Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 10 Jul 2026 19:22:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20learning=20view=20=E2=80=94=20make=20th?= =?UTF-8?q?e=20growing=20knowledge=20base=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/httpapi/knowledge.go | 93 ++++++++++++++++ internal/httpapi/server.go | 5 + web/src/lib/api.ts | 30 ++++++ web/src/pages/Knowledge.svelte | 191 ++++++++++++++++++++++++--------- 4 files changed, 266 insertions(+), 53 deletions(-) diff --git a/internal/httpapi/knowledge.go b/internal/httpapi/knowledge.go index 764aa5f..ed3735e 100644 --- a/internal/httpapi/knowledge.go +++ b/internal/httpapi/knowledge.go @@ -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) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 0f81519..bbaef88 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -139,6 +139,11 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler // inherits the router's base middleware and applies auth via With(). r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE) + // Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the + // Knowledge page's "what the system has learned" view. Registered after + // HandlerWithOptions so it wins over any generated catch-all. + r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge) + // Mount MCP at /mcp (plan R3-10) nomosAgentID := uuid.Nil if cfg.NomosAgentID != "" { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c518004..cda5e7d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -378,6 +378,36 @@ export interface KnowledgeHit { slug: string type: 'document' | 'runbook' | 'investigation' title: string + snippet?: string + linked_entities?: string[] +} + +export interface KnowledgeItem { + slug: string + title: string + kind: 'document' | 'runbook' | 'investigation' + source: string + tags: string[] + updated_at: string + agent_authored: boolean +} + +export interface RecentKnowledge { + stats: { + total: number + by_kind: Record + agent_authored: number + last_7d: number + } + items: KnowledgeItem[] +} + +export async function fetchRecentKnowledge(source?: string): Promise { + const params = new URLSearchParams() + if (source) params.set('source', source) + const res = await fetch(`${API}/knowledge/recent?${params}`) + if (!res.ok) return { stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] } + return res.json() } export async function fetchEntityKnowledge(entityId: string): Promise { diff --git a/web/src/pages/Knowledge.svelte b/web/src/pages/Knowledge.svelte index 5c9cf5c..73608ad 100644 --- a/web/src/pages/Knowledge.svelte +++ b/web/src/pages/Knowledge.svelte @@ -1,19 +1,37 @@
-

Knowledge search

+
+

Knowledge

+
-
{ - e.preventDefault() - search() - }} - class="flex gap-2" - > + +
+ + + Total notes + {recent.stats.total} + + + + + Written by Nomos + {recent.stats.agent_authored} + + + + + Learned this week + {recent.stats.last_7d} + + + + + Runbooks / investigations + {(recent.stats.by_kind.runbook ?? 0)} / {(recent.stats.by_kind.investigation ?? 0)} + + +
+ + + { e.preventDefault(); search() }} class="flex gap-2">
- +
- + + {#if searched} + + {/if}
{#if searched} -

{results.length} result{results.length === 1 ? '' : 's'}{query ? ` for "${query}"` : ''}

- {/if} - - -
- {#each results as hit (hit.id)} - - -
- {hit.title} - {hit.type} -
- {#if hit.snippet} - {@html hit.snippet} - {/if} - {#if hit.linked_entities?.length} -
- {#each hit.linked_entities as slug} - - {/each} + +

{results.length} result{results.length === 1 ? '' : 's'} for "{query}"

+ +
+ {#each results as hit (hit.id)} + + +
+ {hit.title} + {hit.type}
- {/if} -
-
- {:else} - {#if searched && !loading} -

No results found.

- {/if} - {/each} + {#if hit.snippet} + + {@html hit.snippet} + {/if} + {#if hit.linked_entities?.length} +
+ {#each hit.linked_entities as slug} + + {/each} +
+ {/if} + + + {:else} + {#if !loading}

No results found.

{/if} + {/each} +
+
+ {:else} + +
+

Recently learned

+
- + +
+ {#each recent.items as it (it.slug)} +
+
+ {#if it.agent_authored}{:else}{/if} +
+
+
+ {it.title} + {it.kind} + {#if it.agent_authored}learned by Nomos{/if} +
+ {#if it.tags.length} +
+ {#each it.tags as t}{t}{/each} +
+ {/if} +
+ {relTime(it.updated_at)} +
+ {:else} + {#if !loadingRecent} +

+ {agentOnly ? 'Nomos hasn’t recorded any learnings yet — it will write them here as it solves problems.' : 'No knowledge yet.'} +

+ {/if} + {/each} +
+
+ {/if}