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>
130 lines
3.8 KiB
Go
130 lines
3.8 KiB
Go
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})
|
|
}
|