Add a local benchmark for /tldr with a dashboard

`go test -tags bench ./internal/tldr -run TestBench` runs the real /tldr
pipeline against one or more Ollama models and prompt variants on a chat dump
from disk (text copied from Discord, "Author: message" lines, or a
DiscordChatExporter JSON export) and writes a self-contained HTML dashboard
plus a JSON of the results. Scoring uses an optional facts file (a fact
passes only when one bullet holds all its details) and automatic checks:
numbers not in the chat, copying the chat, an English intro, cut-off output.

TestFactsSkeleton proposes candidate facts from the chat to prune by hand.
Chat dumps and results stay local: bench-out/ and the private folders are
git-ignored. See internal/tldr/tldrbench/README.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Opus 5.5 committed 2026-10-20 18:46:18 +01:00
1 parent 7ae1fa5747
commit 17548bbf7d
10 files changed
+1495

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
package tldrbench
import (
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
"unicode"
)
// Fact is one thing a good overview must contain. It passes when a single
// bullet matches every pattern, so a bullet that mentions the date of one
// event next to the name of another does not count. Patterns are
// case-insensitive regular expressions; use "a|b" for alternatives.
type Fact struct {
Name string `json:"name"`
All []string `json:"all"`
// Note is free text for you (SuggestFacts puts the chat line it came from
// here); it is not used for scoring.
Note string `json:"note,omitempty"`
res []*regexp.Regexp
}
// LoadFacts reads a JSON list of facts and compiles their patterns.
func LoadFacts(path string) ([]Fact, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var facts []Fact
if err := json.Unmarshal(raw, &facts); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for i := range facts {
if facts[i].Name == "" || len(facts[i].All) == 0 {
return nil, fmt.Errorf("%s: fact %d needs a name and at least one pattern", path, i+1)
}
for _, p := range facts[i].All {
re, err := regexp.Compile("(?i)" + p)
if err != nil {
return nil, fmt.Errorf("%s: fact %q: %w", path, facts[i].Name, err)
}
facts[i].res = append(facts[i].res, re)
}
}
return facts, nil
}
// Score is how one overview did. Only the fact counts need a facts file; the
// rest is judged from the overview and the chat alone.
type Score struct {
FactsHit int `json:"factsHit"`
FactsTotal int `json:"factsTotal"`
Hit []string `json:"hit,omitempty"`
Missed []string `json:"missed,omitempty"`
Bullets int `json:"bullets"`
LongestChars int `json:"longestChars"`
// Invented lists numbers in the overview that appear nowhere in the chat.
Invented []string `json:"invented,omitempty"`
Copied bool `json:"copied"` // looks like the chat pasted back, not a summary
EnglishIntro bool `json:"englishIntro"` // opens with "Here is an overview..."
CutOff bool `json:"cutOff"` // ends mid-sentence, usually the token limit
}
var (
bulletStart = regexp.MustCompile(`^([-*•]|\d+[.)])\s`)
numberRe = regexp.MustCompile(`\d+(?:[.,:]\d+)*`)
introRe = regexp.MustCompile(`(?i)^(here( i|')?s|here is|here are|sure|certainly|of course|this is an overview)`)
)
// Bullets groups an overview into its bullets; lines that do not start one
// continue the previous bullet.
func Bullets(out string) []string {
var bs []string
for _, ln := range strings.Split(out, "\n") {
s := strings.TrimSpace(ln)
if s == "" {
continue
}
if bulletStart.MatchString(s) || len(bs) == 0 {
bs = append(bs, s)
} else {
bs[len(bs)-1] += " " + s
}
}
return bs
}
// normNumber makes "02", "2" and "2,0" compare equal enough for "was this
// number in the chat".
func normNumber(n string) string {
n = strings.ReplaceAll(n, ",", ".")
n = strings.TrimLeft(n, "0")
if n == "" || n[0] == '.' || n[0] == ':' {
n = "0" + n
}
return n
}
func numbers(s string) map[string]bool {
set := map[string]bool{}
for _, n := range numberRe.FindAllString(s, -1) {
set[normNumber(n)] = true
// "14:30" also makes "14" and "30" legitimate on their own.
for _, part := range regexp.MustCompile(`[.,:]`).Split(n, -1) {
set[normNumber(part)] = true
}
}
return set
}
// Evaluate scores an overview. chat is the transcript the model was given.
func Evaluate(out, chat string, facts []Fact) Score {
bullets := Bullets(out)
var sc Score
sc.Bullets = len(bullets)
for _, b := range bullets {
if n := len([]rune(b)); n > sc.LongestChars {
sc.LongestChars = n
}
}
sc.FactsTotal = len(facts)
for _, f := range facts {
ok := false
for _, b := range bullets {
all := true
for _, re := range f.res {
if !re.MatchString(b) {
all = false
break
}
}
if all {
ok = true
break
}
}
if ok {
sc.FactsHit++
sc.Hit = append(sc.Hit, f.Name)
} else {
sc.Missed = append(sc.Missed, f.Name)
}
}
known := numbers(chat)
seen := map[string]bool{}
for _, b := range bullets {
b = bulletStart.ReplaceAllString(b, "")
for _, n := range numberRe.FindAllString(b, -1) {
if !known[normNumber(n)] && !seen[n] {
seen[n] = true
sc.Invented = append(sc.Invented, n)
}
}
}
trimmed := strings.TrimSpace(out)
sc.Copied = (sc.Bullets <= 2 && len([]rune(trimmed)) > 600) || sc.LongestChars > 700
sc.EnglishIntro = introRe.MatchString(trimmed)
if r := []rune(trimmed); len(r) > 0 {
last := r[len(r)-1]
sc.CutOff = unicode.IsLetter(last) || unicode.IsDigit(last) || last == ',' || last == ';' || last == ':'
}
return sc
}