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:
129
internal/httpapi/learning_view.go
Normal file
129
internal/httpapi/learning_view.go
Normal 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})
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user