Files
llmgw/internal/cli/tui_quality.go
Ilker ManapandClaude Opus 5.5 f0477f140d Quality suites, and new weights held to one: ctl eval, model update/promote --eval, model gate
A suite is a set of cases in YAML or JSON lines, each scored by exact,
contains, regex, a number within a tolerance, a JSON Schema subset, or a
judge model with a rubric; weighed into one score against a threshold.
internal/bench/eval runs one on the control plane through Router.Replay, as
the gateway's own requests, and keeps every run and its cases in the store
(migration 0028: eval_runs, eval_cases).

- llmgw ctl eval MODEL --suite FILE|NAME, --history, --run, --suites; exits
  non-zero below the score. Suites by name from control.eval_dir, or the
  two built in from deploy/eval (arithmetic, json-format).
- model update and promote take --eval SUITE --min-score N, or a model's
  standing gate (ctl model gate, eval: in the catalog): a promote asks the
  canary first and leaves it below the score; an update tries the new
  weights as a canary given no share, then promotes or removes it.
- The console's model menu: ask a suite with its progress, its runs, its
  gate.
- The audit middleware no longer hands a handler a body cut off at 64 KiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:04:43 +02:00

292 lines
9.1 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// Quality suites from the model menu: ask a model one of the suites the
// control plane has (its eval directory's, and those built in) and watch
// its score come in; list its past runs; hold its updates to one — the same
// as `llmgw ctl eval` and `llmgw ctl model gate`.
// evalSuitesMsg is the suites the control plane can run, fetched to open
// the form that runs one (or, with gate, the one that sets a model's).
type evalSuitesMsg struct {
model adminapi.ModelView
suites []eval.SuiteInfo
gate bool
err error
}
// evalPollMsg is a run still going, to be asked about again.
type evalPollMsg struct {
title string
live eval.Live
err error
}
// gateNote says, in an update's or a promotion's question, that the new
// weights are asked the model's suite first.
func gateNote(md adminapi.ModelView) string {
g := md.Eval
if g == nil {
return ""
}
at := "its threshold"
if g.MinScore > 0 {
at = fmt.Sprintf("%.2f", g.MinScore)
}
return fmt.Sprintf("\n\nFirst the new weights are asked the quality suite %s, and must score %s;\n"+
"below it nothing moves.", g.Suite, at)
}
// evalItems are the model menu's quality entries.
func (m consoleModel) evalItems(md adminapi.ModelView) []menuItem {
gate := "Hold its updates to a quality suite…"
if g := md.Eval; g != nil {
gate = "Its updates' quality suite (" + g.Suite + ")…"
}
return []menuItem{
{"Ask it a quality suite…", func(m *consoleModel) tea.Cmd { return m.fetchSuites(md, false) }},
{"Its quality runs", func(m *consoleModel) tea.Cmd {
return m.call("Quality runs of "+md.Name, http.MethodGet,
"/api/v1/eval/runs?limit=15&model="+url.QueryEscape(md.Name), nil, func(raw json.RawMessage) string {
var runs []eval.Live
if err := json.Unmarshal(raw, &runs); err != nil {
return string(raw)
}
return evalRunsText(runs)
})
}},
{gate, func(m *consoleModel) tea.Cmd { return m.fetchSuites(md, true) }},
}
}
func (m *consoleModel) fetchSuites(md adminapi.ModelView, gate bool) tea.Cmd {
m.busy = "Quality suites"
c := m.client
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var list []eval.SuiteInfo
err := c.get(ctx, "/api/v1/eval/suites", &list)
return evalSuitesMsg{model: md, suites: list, gate: gate, err: err}
}
}
func (m consoleModel) onEvalSuites(msg evalSuitesMsg) (tea.Model, tea.Cmd) {
m.busy = ""
md := msg.model
var names []string
for _, s := range msg.suites {
if s.Error == "" {
names = append(names, s.Name)
}
}
if msg.err == nil && len(names) == 0 {
msg.err = errors.New("the control plane has no suite it can run: put one in its control.eval_dir " +
"(/etc/llmgw/eval); docs/install.md, \"Quality suites\", says how one is written")
}
if msg.err != nil {
m.dialog, m.dialogTitle = "error", "Quality suites"
m.dialogText = sBad.Render("✗ ") + msg.err.Error() + "\n\n" + sDim.Render("any key")
return m, nil
}
about := func(name string) string {
for _, s := range msg.suites {
if s.Name == name {
return fmt.Sprintf("%s: %d cases, passed at %.2f, %s", s.Name, s.Cases, s.Threshold, s.From)
}
}
return ""
}
path := "/api/v1/models/" + url.PathEscape(md.Name)
if msg.gate {
current, minScore := "none", ""
if g := md.Eval; g != nil {
current = g.Suite
if g.MinScore > 0 {
minScore = strconv.FormatFloat(g.MinScore, 'f', -1, 64)
}
if !slices.Contains(names, current) {
names = append(names, current)
}
}
m.openForm(&formDialog{title: "Quality gate of " + md.Name,
note: "An update or a promotion asks the new weights this suite first, and stops below its score;\n" +
"an update tries them as a canary given none of " + md.Name + "'s requests, and takes it out again.",
fields: []formField{
choiceF("suite", append([]string{"none"}, names...), current),
textF("min score", minScore, "0 to 1; empty is the suite's own threshold"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[0] == "none" {
return m.call("Quality gate of "+md.Name, http.MethodDelete, path+"/eval-gate", nil,
func(json.RawMessage) string { return md.Name + "'s updates are no longer held to a suite." }), nil
}
score, err := minScoreOf(v[1])
if err != nil {
return nil, err
}
return m.call("Quality gate of "+md.Name, http.MethodPut, path+"/eval-gate",
adminapi.EvalGateRequest{Suite: v[0], MinScore: score}, func(json.RawMessage) string {
return fmt.Sprintf("%s's new weights must pass %s before they replace the old.", md.Name, v[0])
}), nil
}})
return m, nil
}
m.openForm(&formDialog{title: "Ask " + md.Name + " a quality suite",
note: "Asked on the control plane, through the router as its own requests: past quotas and guardrails.\n" +
about(names[0]),
fields: []formField{
choiceF("suite", names, names[0]),
textF("at once", "4", "cases asked at the same time"),
textF("min score", "", "0 to 1; empty is the suite's own threshold"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
n, err := strconv.Atoi(v[1])
if err != nil || n < 1 {
return nil, fmt.Errorf("at once is a whole number, 1 or more")
}
score, err := minScoreOf(v[2])
if err != nil {
return nil, err
}
return m.startEval(md.Name, eval.Request{Suite: v[0], Concurrency: n, MinScore: score}), nil
}})
return m, nil
}
func minScoreOf(s string) (float64, error) {
if s == "" {
return 0, nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || f < 0 || f > 1 {
return 0, fmt.Errorf("the min score is a number from 0 to 1")
}
return f, nil
}
// startEval starts a run and follows it.
func (m *consoleModel) startEval(model string, req eval.Request) tea.Cmd {
title := model + ": " + req.Suite
m.busy, m.busyNote = title, "starting…"
c := m.client
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var l eval.Live
err := c.do(ctx, http.MethodPost, "/api/v1/models/"+url.PathEscape(model)+"/eval", req, &l)
if err != nil {
return actionDoneMsg{title: title, err: err}
}
return evalPollMsg{title: title, live: l}
}
}
func (m consoleModel) onEvalPoll(msg evalPollMsg) (tea.Model, tea.Cmd) {
if m.evalWatch != "" && m.evalWatch != msg.live.ID {
return m, nil
}
if m.evalWatch == "" && m.busy != msg.title {
// Esc: no longer followed here; it carries on on the control plane.
return m, nil
}
m.evalWatch = msg.live.ID
m.busy = msg.title
m.busyNote = fmt.Sprintf("%d of %d answered · Esc stops watching (it carries on)", msg.live.Done, msg.live.Cases)
c, id, title := m.client, msg.live.ID, msg.title
return m, func() tea.Msg {
time.Sleep(time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var l eval.Live
if err := c.get(ctx, "/api/v1/eval/runs/"+url.PathEscape(id), &l); err != nil {
return actionDoneMsg{title: title, err: err}
}
if l.State == eval.Running {
return evalPollMsg{title: title, live: l}
}
var runs []eval.Live
_ = c.get(ctx, "/api/v1/eval/runs?limit=6&model="+url.QueryEscape(l.Model), &runs)
text := evalRunText(l)
if len(runs) > 1 {
text += "\n\nIts last runs:\n" + evalRunsText(runs)
}
if l.State == eval.Failed || !l.Passed {
return actionDoneMsg{title: title, err: errors.New(text)}
}
return actionDoneMsg{title: title, text: text}
}
}
// evalRunText is a finished run: its score, then each case.
func evalRunText(l eval.Live) string {
if l.State == eval.Failed {
return "The run could not be scored: " + l.Error
}
var b strings.Builder
b.WriteString(evalSummary(l) + "\n")
if l.Error != "" {
b.WriteString(l.Error + "\n")
}
b.WriteString("\n")
shown := min(len(l.Results), 14)
for _, r := range l.Results[:shown] {
mark := sOK.Render("✓")
if !r.Passed {
mark = sBad.Render("✗")
}
why := r.Detail
if r.Error != "" {
why = r.Error
}
fmt.Fprintf(&b, "%s %-18s %4.2f %s\n", mark, truncate(r.ID, 18), r.Score, truncate(why, 60))
}
if len(l.Results) > shown {
fmt.Fprintf(&b, " and %d more: llmgw ctl eval --run %s\n", len(l.Results)-shown, l.ID)
}
return strings.TrimRight(b.String(), "\n")
}
// evalRunsText is a model's runs, one a line.
func evalRunsText(runs []eval.Live) string {
if len(runs) == 0 {
return "No runs kept yet: \"Ask it a quality suite\" on this menu makes one."
}
var b strings.Builder
for _, r := range runs {
result := sOK.Render("passed")
switch {
case r.State == eval.Running:
result = fmt.Sprintf("running, %d of %d", r.Done, r.Cases)
case r.State == eval.Failed:
result = sBad.Render("could not run")
case !r.Passed:
result = sBad.Render("failed")
}
who := r.Model
if r.Target != "" {
who = r.Target
}
fmt.Fprintf(&b, "%-9s %-14s %-16s %4.2f of %4.2f %s\n", ageShort(r.At), truncate(r.Suite, 14),
truncate(who, 16), r.Score, r.Threshold, result)
}
return strings.TrimRight(b.String(), "\n")
}