feat: Learning page — capability timeline + trend, built on real data
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:
@@ -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 }
|
||||
]
|
||||
</script>
|
||||
@@ -210,6 +213,8 @@
|
||||
<Agent />
|
||||
{:else if page === 'knowledge'}
|
||||
<Knowledge />
|
||||
{:else if page === 'learning'}
|
||||
<Learning />
|
||||
{:else if page === 'audit'}
|
||||
<Audit />
|
||||
{:else}
|
||||
|
||||
@@ -277,6 +277,72 @@ export async function fetchSessionDigest(sessionId: string): Promise<SessionDige
|
||||
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 {
|
||||
id: string
|
||||
slug: string
|
||||
|
||||
174
web/src/pages/Learning.svelte
Normal file
174
web/src/pages/Learning.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user