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.
This commit is contained in:
140
ingestion/internal/api/passrate.go
Normal file
140
ingestion/internal/api/passrate.go
Normal file
@@ -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)
|
||||
}
|
||||
172
ingestion/internal/api/passrate_test.go
Normal file
172
ingestion/internal/api/passrate_test.go
Normal file
@@ -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 <dir>/sessions/<sessionID>.jsonl.
|
||||
// The handler scans <brainDir>/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")
|
||||
}
|
||||
Reference in New Issue
Block a user