// Package testkit defines the Test type shared by the catalog in // internal/tests, a Registry to collect them, and response normalization // applied before evaluation. package testkit import ( "fmt" "regexp" "sort" "strings" "github.com/lukaszraczylo/llm-testbench/internal/eval" ) // Test is one catalog entry: a fully self-contained prompt scored by a // deterministic Evaluator. type Test struct { Eval eval.Evaluator ID string Category string Subcategory string Description string System string Prompt string MaxTokens int } // thinkBlockPattern strips a leading ... or // ... block, case-insensitive, before the real // answer. Models observed on this endpoint prepend one. var thinkBlockPattern = regexp.MustCompile(`(?is)^\s*<(think|reasoning)>.*?\s*`) // thinkCloseTagPattern and thinkOpenTagPattern locate a closing/opening // / tag anywhere in the text, used to detect an orphan // closing tag (S7): some models on this endpoint truncate the opening tag // (e.g. it falls outside a context/streaming window) but still emit the // closing tag, leaving stray reasoning text with no opener in front of it. var thinkCloseTagPattern = regexp.MustCompile(`(?i)`) var thinkOpenTagPattern = regexp.MustCompile(`(?i)<(?:think|reasoning)>`) // stripOrphanThinkClose removes everything up to and including a closing // / tag that has no matching opening tag earlier in s. // A tag pair with a real opener is left alone (thinkBlockPattern already // handles the well-formed, anchored-at-start case). func stripOrphanThinkClose(s string) string { closeLoc := thinkCloseTagPattern.FindStringIndex(s) if closeLoc == nil { return s } if thinkOpenTagPattern.MatchString(s[:closeLoc[0]]) { return s } return s[closeLoc[1]:] } // Normalize prepares a raw model response for evaluation: it strips a // leading / block (or, failing that, an orphan closing // tag with no opener), collapses Windows line endings, and trims // surrounding whitespace. func Normalize(response string) string { s := thinkBlockPattern.ReplaceAllString(response, "") s = stripOrphanThinkClose(s) s = strings.ReplaceAll(s, "\r\n", "\n") return strings.TrimSpace(s) } // Registry collects Tests and provides lookup/filtering for the runner and // CLI. type Registry struct { byID map[string]Test tests []Test } // NewRegistry builds an empty Registry. func NewRegistry() *Registry { return &Registry{byID: make(map[string]Test)} } // Register adds t to the registry. It panics on a duplicate ID since the // catalog is assembled once at program startup via init()-time registration, // making a duplicate a programming error, not a runtime condition. func (r *Registry) Register(t Test) { if t.ID == "" { panic("testkit: Test.ID must not be empty") } if _, exists := r.byID[t.ID]; exists { panic(fmt.Sprintf("testkit: duplicate test ID %q", t.ID)) } if t.Eval == nil { panic(fmt.Sprintf("testkit: test %q has a nil Evaluator", t.ID)) } r.tests = append(r.tests, t) r.byID[t.ID] = t } // All returns every registered test, sorted by ID for deterministic output. func (r *Registry) All() []Test { out := make([]Test, len(r.tests)) copy(out, r.tests) sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } // Filter returns registered tests whose Category/Subcategory match the // given filters. Empty filters match everything. func (r *Registry) Filter(category, subcategory string) []Test { all := r.All() if category == "" && subcategory == "" { return all } var out []Test for _, t := range all { if category != "" && !strings.EqualFold(t.Category, category) { continue } if subcategory != "" && !strings.EqualFold(t.Subcategory, subcategory) { continue } out = append(out, t) } return out } // Get returns the test with the given ID. func (r *Registry) Get(id string) (Test, bool) { t, ok := r.byID[id] return t, ok } // Len returns the number of registered tests. func (r *Registry) Len() int { return len(r.tests) }