Files
hyperguild/ingestion/internal/api/passrate.go
Mathias Bergqvist 37dbd22eff 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.
2026-05-03 22:37:41 +02:00

141 lines
3.2 KiB
Go

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)
}