feat: Learning page — capability timeline + trend, built on real data
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 21:28:19 +02:00
parent 6192c35c10
commit 52e16e04ca
5 changed files with 380 additions and 0 deletions

View File

@@ -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})
}

View File

@@ -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/recent", s.serveRecentActivity)
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest) 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) // Mount MCP at /mcp (plan R3-10)
nomosAgentID := uuid.Nil nomosAgentID := uuid.Nil
if cfg.NomosAgentID != "" { if cfg.NomosAgentID != "" {

View File

@@ -10,6 +10,7 @@
import EntityDetail from './pages/EntityDetail.svelte' import EntityDetail from './pages/EntityDetail.svelte'
import Agent from './pages/Agent.svelte' import Agent from './pages/Agent.svelte'
import Knowledge from './pages/Knowledge.svelte' import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte'
import Audit from './pages/Audit.svelte' import Audit from './pages/Audit.svelte'
import { newChat } from '$lib/stores/chat' import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context' import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
@@ -33,6 +34,7 @@
import BotIcon from '@lucide/svelte/icons/bot' import BotIcon from '@lucide/svelte/icons/bot'
import SearchIcon from '@lucide/svelte/icons/search' import SearchIcon from '@lucide/svelte/icons/search'
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text' import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
let page = $state('chat') let page = $state('chat')
let routeParam = $state('') let routeParam = $state('')
@@ -71,6 +73,7 @@
{ id: 'events', label: 'Events', icon: ActivityIcon }, { id: 'events', label: 'Events', icon: ActivityIcon },
{ id: 'agent', label: 'Agent', icon: BotIcon }, { id: 'agent', label: 'Agent', icon: BotIcon },
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon }, { id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon },
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon } { id: 'audit', label: 'Audit', icon: ScrollTextIcon }
] ]
</script> </script>
@@ -210,6 +213,8 @@
<Agent /> <Agent />
{:else if page === 'knowledge'} {:else if page === 'knowledge'}
<Knowledge /> <Knowledge />
{:else if page === 'learning'}
<Learning />
{:else if page === 'audit'} {:else if page === 'audit'}
<Audit /> <Audit />
{:else} {:else}

View File

@@ -277,6 +277,72 @@ export async function fetchSessionDigest(sessionId: string): Promise<SessionDige
return res.json() return res.json()
} }
export interface CapabilityTimelineItem {
verb: string
first_success: string | null
successes: number
total: number
}
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
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<TrendBucket[]> {
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<Pattern[]> {
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<Skill[]> {
const res = await fetch(`${API}/skills`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export interface Signal { export interface Signal {
id: string id: string
slug: string slug: string

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { onMount, tick } from 'svelte'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
import {
fetchLearningTimeline,
fetchLearningTrend,
fetchPatterns,
fetchSkills,
type CapabilityTimelineItem,
type TrendBucket,
type Pattern,
type Skill
} from '$lib/api'
import * as Card from '$lib/components/ui/card'
import { Badge } from '$lib/components/ui/badge'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
let timeline = $state<CapabilityTimelineItem[]>([])
let trend = $state<TrendBucket[]>([])
let patterns = $state<Pattern[]>([])
let skills = $state<Skill[]>([])
let loading = $state(true)
let chartEl = $state<HTMLDivElement | null>(null)
async function load() {
loading = true
const [t, tr, p, s] = await Promise.all([
fetchLearningTimeline(),
fetchLearningTrend(),
fetchPatterns(),
fetchSkills()
])
timeline = t
trend = tr
patterns = p
skills = s
loading = false
await tick()
renderChart()
}
onMount(load)
function renderChart() {
if (!chartEl || trend.length === 0) return
chartEl.innerHTML = ''
const xs = trend.map((b) => new Date(b.day).getTime() / 1000)
const succ = trend.map((b) => b.successes)
const fail = trend.map((b) => b.failures)
new uPlot(
{
width: chartEl.clientWidth || 600,
height: 180,
series: [
{},
{ label: 'succeeded', stroke: '#3fb950', width: 2 },
{ label: 'failed', stroke: '#f85149', width: 2 }
],
axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
scales: { x: { time: true } },
legend: { show: true }
},
[xs, succ, fail],
chartEl
)
}
function fmtDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function timelineVariant(item: CapabilityTimelineItem): 'default' | 'secondary' | 'destructive' {
if (item.total === 0 || item.successes === 0) return 'destructive'
if (item.successes === item.total) return 'default'
return 'secondary'
}
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
<h1 class="text-lg font-semibold">Learning</h1>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Execution outcomes — last 30 days</Card.Title>
<Card.Description class="text-xs">Every gated action, by day it ran, succeeded vs failed.</Card.Description>
</Card.Header>
<Card.Content>
{#if trend.length === 0}
{#if !loading}<p class="py-8 text-center text-sm text-muted-foreground">No executions in the last 30 days yet.</p>{/if}
{:else}
<div bind:this={chartEl} class="w-full"></div>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-1.5 text-sm"><TrendingUpIcon class="size-4" /> Capability timeline</Card.Title>
<Card.Description class="text-xs">What Nomos has learned to do, ordered by when it first succeeded.</Card.Description>
</Card.Header>
<Card.Content>
<div class="flex flex-col gap-2">
{#each timeline as item (item.verb)}
<div class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div>
<span class="font-mono text-sm">{item.verb}</span>
<span class="ml-2 text-xs text-muted-foreground">
{item.first_success ? `first succeeded ${fmtDate(item.first_success)}` : 'no successes yet'}
</span>
</div>
<Badge variant={timelineVariant(item)}>{item.successes}/{item.total}</Badge>
</div>
{:else}
{#if !loading}<p class="py-8 text-center text-sm text-muted-foreground">No executions yet.</p>{/if}
{/each}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Patterns</Card.Title>
<Card.Description class="text-xs">Statistically validated behaviors, extracted from outcome feedback.</Card.Description>
</Card.Header>
<Card.Content>
{#if patterns.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">
No patterns learned yet — patterns emerge once outcome feedback is recorded for repeated actions.
</p>
{:else}
<div class="flex flex-col gap-2">
{#each patterns as p (p.id)}
<div class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div>
<span class="text-sm">{p.pattern}</span>
<span class="ml-2 text-xs text-muted-foreground">{p.applies_type} · {p.action}</span>
</div>
<Badge variant="outline">{(p.confidence * 100).toFixed(0)}% conf.</Badge>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-1.5 text-sm"><SparklesIcon class="size-4" /> Promoted skills</Card.Title>
<Card.Description class="text-xs">Procedures promoted from validated patterns.</Card.Description>
</Card.Header>
<Card.Content>
{#if skills.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">No skills promoted yet.</p>
{:else}
<div class="flex flex-col gap-2">
{#each skills as s (s.id)}
<div class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div>
<span class="text-sm">{s.name}</span>
<span class="ml-2 text-xs text-muted-foreground">{s.status}</span>
</div>
{#if s.success_rate != null}
<Badge variant="outline">{(s.success_rate * 100).toFixed(0)}% success</Badge>
{/if}
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>