From 52e16e04cabe190c21cfd71c5a995f78d9a28076 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 10 Jul 2026 21:28:19 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Learning=20page=20=E2=80=94=20capabilit?= =?UTF-8?q?y=20timeline=20+=20trend,=20built=20on=20real=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's "learning view" (runbook success-rate trends, promoted skills, capability timeline) assumes the patterns/skills/feedback pipeline is populated. It isn't: all three tables are empty in production and nothing in the codebase ever writes to feedback, so building the UI against them today would ship a permanently-empty page. Scoped instead around data that's real and growing — executions — while still wiring up /patterns and /skills so the page needs no rework once that pipeline exists. New /api/v1/learning/timeline (per-verb first-success date + success rate, parsed via the existing splitAction helper) and /api/v1/learning/trend (30-day daily success/fail counts), both read-only queries against executions. Patterns and skills sections call the existing (untouched) ListPatterns/ListSkills endpoints and render an explanatory empty state instead of nothing. Co-Authored-By: Claude Sonnet 5 --- internal/httpapi/learning_view.go | 129 ++++++++++++++++++++++ internal/httpapi/server.go | 6 ++ web/src/App.svelte | 5 + web/src/lib/api.ts | 66 ++++++++++++ web/src/pages/Learning.svelte | 174 ++++++++++++++++++++++++++++++ 5 files changed, 380 insertions(+) create mode 100644 internal/httpapi/learning_view.go create mode 100644 web/src/pages/Learning.svelte diff --git a/internal/httpapi/learning_view.go b/internal/httpapi/learning_view.go new file mode 100644 index 0000000..8ec5e1a --- /dev/null +++ b/internal/httpapi/learning_view.go @@ -0,0 +1,129 @@ +package httpapi + +import ( + "encoding/json" + "log/slog" + "net/http" + "sort" +) + +// capabilityTimelineItem summarizes one verb's track record — when the +// agent first succeeded at it, and how reliable it's been since. Derived +// directly from executions (which has real, growing data) rather than the +// patterns/skills tables, which are correctly modeled but have zero writers +// anywhere in the codebase today — building against them now would ship a +// permanently empty page. See plans/2026-07-10-general-gated-execution.md +// step 8 evaluation. +type capabilityTimelineItem struct { + Verb string `json:"verb"` + FirstSuccess *string `json:"first_success"` + Successes int `json:"successes"` + Total int `json:"total"` +} + +// serveLearningTimeline backs the Learning page's capability timeline: one +// row per distinct verb (parsed via splitAction, same helper the activity +// feed uses), ordered by when it first succeeded — an honest "the system +// learned to do X" signal without depending on the unpopulated patterns +// table. +func (s *Server) serveLearningTimeline(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + rows, err := s.pool.Query(ctx, ` + SELECT action, status, created_at::text + FROM executions + ORDER BY created_at`) + if err != nil { + writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) + return + } + defer rows.Close() + + type agg struct { + firstSuccess *string + successes int + total int + } + byVerb := map[string]*agg{} + for rows.Next() { + var action, status, createdAt string + if err := rows.Scan(&action, &status, &createdAt); err != nil { + slog.Error("httpapi: learning/timeline row scan failed", "error", err) + continue + } + verb, _ := splitAction(action) + a, ok := byVerb[verb] + if !ok { + a = &agg{} + byVerb[verb] = a + } + a.total++ + if status == "completed" { + a.successes++ + if a.firstSuccess == nil { + ca := createdAt + a.firstSuccess = &ca + } + } + } + + items := make([]capabilityTimelineItem, 0, len(byVerb)) + for verb, a := range byVerb { + items = append(items, capabilityTimelineItem{ + Verb: verb, FirstSuccess: a.firstSuccess, Successes: a.successes, Total: a.total, + }) + } + // Verbs with at least one success sort by when that first happened; + // verbs that have never succeeded sort last (nothing to celebrate yet). + sort.Slice(items, func(i, j int) bool { + fi, fj := items[i].FirstSuccess, items[j].FirstSuccess + if fi == nil { + return false + } + if fj == nil { + return true + } + return *fi < *fj + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"items": items}) +} + +type trendBucket struct { + Day string `json:"day"` + Successes int `json:"successes"` + Failures int `json:"failures"` +} + +// serveLearningTrend backs the Learning page's 30-day success/fail trend +// chart — a daily bucket of execution outcomes, straight off the executions +// table. +func (s *Server) serveLearningTrend(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + rows, err := s.pool.Query(ctx, ` + SELECT date_trunc('day', created_at)::date::text AS day, + COUNT(*) FILTER (WHERE status = 'completed') AS successes, + COUNT(*) FILTER (WHERE status = 'failed') AS failures + FROM executions + WHERE created_at > now() - interval '30 days' + GROUP BY day + ORDER BY day`) + if err != nil { + writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) + return + } + defer rows.Close() + + items := []trendBucket{} + for rows.Next() { + var b trendBucket + if err := rows.Scan(&b.Day, &b.Successes, &b.Failures); err != nil { + slog.Error("httpapi: learning/trend row scan failed", "error", err) + continue + } + items = append(items, b) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"items": items}) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 780ddb3..fdd0968 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -150,6 +150,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity) r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest) + // Learning view: capability timeline + success trend, both derived from + // executions (real, growing data) rather than the patterns/skills tables, + // which are correctly modeled but have no writers anywhere yet. + r.With(combinedAuth(cfg)).Get("/api/v1/learning/timeline", s.serveLearningTimeline) + r.With(combinedAuth(cfg)).Get("/api/v1/learning/trend", s.serveLearningTrend) + // Mount MCP at /mcp (plan R3-10) nomosAgentID := uuid.Nil if cfg.NomosAgentID != "" { diff --git a/web/src/App.svelte b/web/src/App.svelte index 00e6678..f3cceeb 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -10,6 +10,7 @@ import EntityDetail from './pages/EntityDetail.svelte' import Agent from './pages/Agent.svelte' import Knowledge from './pages/Knowledge.svelte' + import Learning from './pages/Learning.svelte' import Audit from './pages/Audit.svelte' import { newChat } from '$lib/stores/chat' import { summary, subscribeContext, openSignalCount } from '$lib/stores/context' @@ -33,6 +34,7 @@ import BotIcon from '@lucide/svelte/icons/bot' import SearchIcon from '@lucide/svelte/icons/search' import ScrollTextIcon from '@lucide/svelte/icons/scroll-text' + import TrendingUpIcon from '@lucide/svelte/icons/trending-up' let page = $state('chat') let routeParam = $state('') @@ -71,6 +73,7 @@ { id: 'events', label: 'Events', icon: ActivityIcon }, { id: 'agent', label: 'Agent', icon: BotIcon }, { id: 'knowledge', label: 'Knowledge', icon: SearchIcon }, + { id: 'learning', label: 'Learning', icon: TrendingUpIcon }, { id: 'audit', label: 'Audit', icon: ScrollTextIcon } ] @@ -210,6 +213,8 @@ {:else if page === 'knowledge'} + {:else if page === 'learning'} + {:else if page === 'audit'} {:else} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5076a19..26ed289 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -277,6 +277,72 @@ export async function fetchSessionDigest(sessionId: string): Promise { + const res = await fetch(`${API}/learning/timeline`) + if (!res.ok) return [] + const data = await res.json() + return data.items ?? [] +} + +export interface TrendBucket { + day: string + successes: number + failures: number +} + +export async function fetchLearningTrend(): Promise { + const res = await fetch(`${API}/learning/trend`) + if (!res.ok) return [] + const data = await res.json() + return data.items ?? [] +} + +export interface Pattern { + id: string + slug: string + applies_type: string + action: string + pattern: string + confidence: number + evidence_count: number + success_count?: number + failure_count?: number + status: string + quarantined?: boolean +} + +export async function fetchPatterns(): Promise { + const res = await fetch(`${API}/patterns`) + if (!res.ok) return [] + const data = await res.json() + return data.items ?? [] +} + +export interface Skill { + id: string + slug: string + name: string + applies_type?: string | null + action: string + status: string + success_rate?: number | null + last_used_at?: string | null +} + +export async function fetchSkills(): Promise { + const res = await fetch(`${API}/skills`) + if (!res.ok) return [] + const data = await res.json() + return data.items ?? [] +} + export interface Signal { id: string slug: string diff --git a/web/src/pages/Learning.svelte b/web/src/pages/Learning.svelte new file mode 100644 index 0000000..4e8e858 --- /dev/null +++ b/web/src/pages/Learning.svelte @@ -0,0 +1,174 @@ + + +
+

Learning

+ + + + Execution outcomes — last 30 days + Every gated action, by day it ran, succeeded vs failed. + + + {#if trend.length === 0} + {#if !loading}

No executions in the last 30 days yet.

{/if} + {:else} +
+ {/if} +
+
+ + + + Capability timeline + What Nomos has learned to do, ordered by when it first succeeded. + + +
+ {#each timeline as item (item.verb)} +
+
+ {item.verb} + + {item.first_success ? `first succeeded ${fmtDate(item.first_success)}` : 'no successes yet'} + +
+ {item.successes}/{item.total} +
+ {:else} + {#if !loading}

No executions yet.

{/if} + {/each} +
+
+
+ + + + Patterns + Statistically validated behaviors, extracted from outcome feedback. + + + {#if patterns.length === 0} +

+ No patterns learned yet — patterns emerge once outcome feedback is recorded for repeated actions. +

+ {:else} +
+ {#each patterns as p (p.id)} +
+
+ {p.pattern} + {p.applies_type} · {p.action} +
+ {(p.confidence * 100).toFixed(0)}% conf. +
+ {/each} +
+ {/if} +
+
+ + + + Promoted skills + Procedures promoted from validated patterns. + + + {#if skills.length === 0} +

No skills promoted yet.

+ {:else} +
+ {#each skills as s (s.id)} +
+
+ {s.name} + {s.status} +
+ {#if s.success_rate != null} + {(s.success_rate * 100).toFixed(0)}% success + {/if} +
+ {/each} +
+ {/if} +
+
+