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.
141 lines
3.2 KiB
Go
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)
|
|
}
|