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