From 37dbd22efff93f57e5b26d2a3fa76fb9f7921292 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 22:37:41 +0200 Subject: [PATCH] feat(brain): /pass-rate aggregator and handler Adds a new HTTP GET handler at the ingestion pod that walks brain/sessions/*.jsonl, filters by skill name and timestamp window (default 7d, accepts Nh and Nd), normalizes legacy status vocabulary (ok->pass, error->fail, skipped->skip), and returns aggregated counts plus pass_rate. Pass rate is null when pass+fail == 0, distinguishing 'no data' from 'always passes'. Plan 6 routing pod will check for null before making decisions. Route registration in cmd/server/main.go lands in a follow-up commit. --- ingestion/internal/api/passrate.go | 140 +++++++++++++++++++ ingestion/internal/api/passrate_test.go | 172 ++++++++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 ingestion/internal/api/passrate.go create mode 100644 ingestion/internal/api/passrate_test.go diff --git a/ingestion/internal/api/passrate.go b/ingestion/internal/api/passrate.go new file mode 100644 index 0000000..076e069 --- /dev/null +++ b/ingestion/internal/api/passrate.go @@ -0,0 +1,140 @@ +package api + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type passRateResponse struct { + Skill string `json:"skill"` + Window string `json:"window"` + Pass int `json:"pass"` + Fail int `json:"fail"` + Skip int `json:"skip"` + Total int `json:"total"` + PassRate *float64 `json:"pass_rate"` +} + +// PassRate handles GET /pass-rate?skill=X&window=Y. +// Walks brainDir/sessions/*.jsonl, filters by skill name and timestamp, +// returns aggregated counts and pass rate. +func (h *Handler) PassRate(w http.ResponseWriter, r *http.Request) { + skill := r.URL.Query().Get("skill") + if skill == "" { + writeError(w, http.StatusBadRequest, "skill is required") + return + } + + windowStr := r.URL.Query().Get("window") + if windowStr == "" { + windowStr = "7d" + } + window, err := parseWindow(windowStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid window: "+err.Error()) + return + } + + cutoff := time.Now().UTC().Add(-window) + pass, fail, skip := 0, 0, 0 + + sessionsDir := filepath.Join(h.brainDir, "sessions") + entries, err := os.ReadDir(sessionsDir) + if err != nil && !os.IsNotExist(err) { + writeError(w, http.StatusInternalServerError, "read sessions dir: "+err.Error()) + return + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + body, err := os.ReadFile(filepath.Join(sessionsDir, entry.Name())) + if err != nil { + continue // skip unreadable files + } + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var rec struct { + Timestamp string `json:"timestamp"` + Skill string `json:"skill"` + FinalStatus string `json:"final_status"` + } + if err := json.Unmarshal([]byte(line), &rec); err != nil { + continue // malformed — skip + } + if rec.Skill != skill { + continue + } + ts, err := time.Parse(time.RFC3339, rec.Timestamp) + if err != nil { + continue + } + if ts.Before(cutoff) { + continue + } + switch normalizeStatus(rec.FinalStatus) { + case "pass": + pass++ + case "fail": + fail++ + case "skip": + skip++ + } + } + } + + total := pass + fail + skip + resp := passRateResponse{ + Skill: skill, + Window: windowStr, + Pass: pass, + Fail: fail, + Skip: skip, + Total: total, + } + if pass+fail > 0 { + rate := float64(pass) / float64(pass+fail) + resp.PassRate = &rate + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// normalizeStatus maps both new (pass/fail/skip) and legacy (ok/error/skipped) +// vocabularies to the canonical pass/fail/skip set. Unknown values are treated +// as skip for safety. +func normalizeStatus(s string) string { + switch s { + case "pass", "ok": + return "pass" + case "fail", "error": + return "fail" + case "skip", "skipped": + return "skip" + default: + return "skip" + } +} + +// parseWindow accepts Go-style durations plus "Nd" for days. +func parseWindow(s string) (time.Duration, error) { + if strings.HasSuffix(s, "d") { + // Replace "d" with "h" * 24 + days := strings.TrimSuffix(s, "d") + d, err := time.ParseDuration(days + "h") + if err != nil { + return 0, err + } + return d * 24, nil + } + return time.ParseDuration(s) +} diff --git a/ingestion/internal/api/passrate_test.go b/ingestion/internal/api/passrate_test.go new file mode 100644 index 0000000..26f2d46 --- /dev/null +++ b/ingestion/internal/api/passrate_test.go @@ -0,0 +1,172 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeSession writes one or more JSONL entries to /sessions/.jsonl. +// The handler scans /sessions/, so test fixtures must mirror that layout. +func writeSession(t *testing.T, dir, sessionID string, entries ...string) { + t.Helper() + sessionsDir := filepath.Join(dir, "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0o755)) + path := filepath.Join(sessionsDir, sessionID+".jsonl") + body := "" + for _, e := range entries { + body += e + "\n" + } + require.NoError(t, os.WriteFile(path, []byte(body), 0o644)) +} + +func TestPassRate_HappyPath(t *testing.T) { + dir := t.TempDir() + now := time.Now().UTC() + recent := now.Add(-1 * time.Hour).Format(time.RFC3339) + + writeSession(t, dir, "s1", + `{"timestamp":"`+recent+`","skill":"tdd","phase":"red","final_status":"pass"}`, + `{"timestamp":"`+recent+`","skill":"tdd","phase":"green","final_status":"pass"}`, + `{"timestamp":"`+recent+`","skill":"tdd","phase":"refactor","final_status":"fail"}`, + ) + writeSession(t, dir, "s2", + `{"timestamp":"`+recent+`","skill":"code-review","phase":"review","final_status":"pass"}`, + ) + + h := &Handler{brainDir: dir} + req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil) + w := httptest.NewRecorder() + h.PassRate(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var got passRateResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + assert.Equal(t, "tdd", got.Skill) + assert.Equal(t, "24h", got.Window) + assert.Equal(t, 2, got.Pass) + assert.Equal(t, 1, got.Fail) + assert.Equal(t, 0, got.Skip) + assert.Equal(t, 3, got.Total) + require.NotNil(t, got.PassRate) + assert.InDelta(t, 0.6667, *got.PassRate, 0.001) +} + +func TestPassRate_LegacyVocabulary(t *testing.T) { + dir := t.TempDir() + now := time.Now().UTC().Format(time.RFC3339) + writeSession(t, dir, "s1", + `{"timestamp":"`+now+`","skill":"tdd","final_status":"ok"}`, + `{"timestamp":"`+now+`","skill":"tdd","final_status":"error"}`, + `{"timestamp":"`+now+`","skill":"tdd","final_status":"skipped"}`, + ) + + h := &Handler{brainDir: dir} + req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil) + w := httptest.NewRecorder() + h.PassRate(w, req) + + var got passRateResponse + require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got)) + assert.Equal(t, 1, got.Pass, "ok→pass") + assert.Equal(t, 1, got.Fail, "error→fail") + assert.Equal(t, 1, got.Skip, "skipped→skip") +} + +func TestPassRate_OutsideWindow_Excluded(t *testing.T) { + dir := t.TempDir() + old := time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339) + recent := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339) + writeSession(t, dir, "s1", + `{"timestamp":"`+old+`","skill":"tdd","final_status":"pass"}`, + `{"timestamp":"`+recent+`","skill":"tdd","final_status":"pass"}`, + ) + + h := &Handler{brainDir: dir} + req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil) + w := httptest.NewRecorder() + h.PassRate(w, req) + + var got passRateResponse + require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got)) + assert.Equal(t, 1, got.Pass) + assert.Equal(t, 1, got.Total) +} + +func TestPassRate_NoData_ReturnsZerosAndNullRate(t *testing.T) { + dir := t.TempDir() + h := &Handler{brainDir: dir} + req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil) + w := httptest.NewRecorder() + h.PassRate(w, req) + + var got passRateResponse + require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got)) + assert.Equal(t, 0, got.Pass) + assert.Equal(t, 0, got.Fail) + assert.Equal(t, 0, got.Skip) + assert.Equal(t, 0, got.Total) + assert.Nil(t, got.PassRate, "pass_rate must be null when pass+fail == 0") +} + +func TestPassRate_DefaultsTo7d(t *testing.T) { + dir := t.TempDir() + now := time.Now().UTC().Format(time.RFC3339) + writeSession(t, dir, "s1", `{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`) + + h := &Handler{brainDir: dir} + req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd", nil) // no window + w := httptest.NewRecorder() + h.PassRate(w, req) + + var got passRateResponse + require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got)) + assert.Equal(t, "7d", got.Window) + assert.Equal(t, 1, got.Pass) +} + +func TestPassRate_MissingSkill_ReturnsBadRequest(t *testing.T) { + dir := t.TempDir() + h := &Handler{brainDir: dir} + req := httptest.NewRequest(http.MethodGet, "/pass-rate", nil) + w := httptest.NewRecorder() + h.PassRate(w, req) + assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) +} + +func TestPassRate_BadWindow_ReturnsBadRequest(t *testing.T) { + dir := t.TempDir() + h := &Handler{brainDir: dir} + req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=foo", nil) + w := httptest.NewRecorder() + h.PassRate(w, req) + assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) +} + +func TestPassRate_MalformedLine_Skipped(t *testing.T) { + dir := t.TempDir() + now := time.Now().UTC().Format(time.RFC3339) + writeSession(t, dir, "s1", + `{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`, + `not valid json`, + `{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`, + ) + + h := &Handler{brainDir: dir} + req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil) + w := httptest.NewRecorder() + h.PassRate(w, req) + + var got passRateResponse + require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got)) + assert.Equal(t, 2, got.Pass, "the malformed line is silently skipped") +}