mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
Reasoning models burn thousands of completion tokens before the answer. Small per-test caps caused silent truncation and zero scores. The runner now floors max_tokens at the config default (12000). The client parses finish_reason. Reports mark truncated cells and the JSON format carries response text for debugging. A stderr progress reporter prints one line per completed request; --quiet disables it.
188 lines
5.2 KiB
Go
188 lines
5.2 KiB
Go
package eval
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// pathSegment is one step of a dot/bracket JSON path, e.g. "a.b[0]" is
|
|
// [{key:"a"} {key:"b"} {index:0, isIndex:true}].
|
|
type pathSegment struct {
|
|
key string
|
|
index int
|
|
isIndex bool
|
|
}
|
|
|
|
// parsePath splits a path like "a.b[0].c" into segments. An empty path
|
|
// yields no segments (navigate returns the root value unchanged).
|
|
func parsePath(path string) ([]pathSegment, error) {
|
|
var segments []pathSegment
|
|
if path == "" {
|
|
return segments, nil
|
|
}
|
|
for _, rawPart := range strings.Split(path, ".") {
|
|
part := rawPart
|
|
for part != "" {
|
|
if idx := strings.IndexByte(part, '['); idx >= 0 {
|
|
if idx > 0 {
|
|
segments = append(segments, pathSegment{key: part[:idx]})
|
|
}
|
|
end := strings.IndexByte(part, ']')
|
|
if end < idx {
|
|
return nil, fmt.Errorf("malformed path %q: unmatched '['", path)
|
|
}
|
|
n, err := strconv.Atoi(part[idx+1 : end])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("malformed path %q: bad index: %w", path, err)
|
|
}
|
|
segments = append(segments, pathSegment{index: n, isIndex: true})
|
|
part = part[end+1:]
|
|
continue
|
|
}
|
|
segments = append(segments, pathSegment{key: part})
|
|
part = ""
|
|
}
|
|
}
|
|
return segments, nil
|
|
}
|
|
|
|
// navigate walks v (as decoded by encoding/json into any) following segments.
|
|
func navigate(v any, segments []pathSegment) (any, error) {
|
|
cur := v
|
|
for _, seg := range segments {
|
|
if seg.isIndex {
|
|
arr, ok := cur.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected array at index [%d], got %T", seg.index, cur)
|
|
}
|
|
if seg.index < 0 || seg.index >= len(arr) {
|
|
return nil, fmt.Errorf("index [%d] out of range (len %d)", seg.index, len(arr))
|
|
}
|
|
cur = arr[seg.index]
|
|
continue
|
|
}
|
|
m, ok := cur.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected object at field %q, got %T", seg.key, cur)
|
|
}
|
|
val, ok := m[seg.key]
|
|
if !ok {
|
|
return nil, fmt.Errorf("field %q not found", seg.key)
|
|
}
|
|
cur = val
|
|
}
|
|
return cur, nil
|
|
}
|
|
|
|
// jsonValueAs converts a decoded JSON value (string/float64/bool/etc.) to T,
|
|
// dispatching on the runtime type of the zero value of T.
|
|
func jsonValueAs[T comparable](v any) (T, error) {
|
|
var zero T
|
|
switch any(zero).(type) {
|
|
case string:
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return zero, fmt.Errorf("expected string, got %T (%v)", v, v)
|
|
}
|
|
return any(s).(T), nil
|
|
case bool:
|
|
b, ok := v.(bool)
|
|
if !ok {
|
|
return zero, fmt.Errorf("expected bool, got %T (%v)", v, v)
|
|
}
|
|
return any(b).(T), nil
|
|
case float64:
|
|
f, err := asFloat64(v)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
return any(f).(T), nil
|
|
case int:
|
|
f, err := asFloat64(v)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
return any(int(f)).(T), nil
|
|
default:
|
|
return zero, fmt.Errorf("unsupported JSONField target type %T", zero)
|
|
}
|
|
}
|
|
|
|
func asFloat64(v any) (float64, error) {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n, nil
|
|
case json.Number:
|
|
return n.Float64()
|
|
default:
|
|
return 0, fmt.Errorf("expected number, got %T (%v)", v, v)
|
|
}
|
|
}
|
|
|
|
// jsonFieldEval extracts one field by path from the response's JSON value
|
|
// (see ExtractJSON: the first ```json fenced block if present, otherwise
|
|
// the last balanced JSON value in the response) and compares it to want.
|
|
type jsonFieldEval[T comparable] struct {
|
|
want T
|
|
path string
|
|
}
|
|
|
|
// JSONField returns an Evaluator that extracts the response's JSON
|
|
// object/array via ExtractJSON (stripping surrounding prose and code
|
|
// fences; the first fenced ```json block wins if present, otherwise the
|
|
// last balanced JSON value in the text - see ExtractJSON), navigates path
|
|
// (dot/bracket syntax, e.g. "bump" or "steps[0]"), and awards full credit
|
|
// when the field equals want. T is inferred from want; supported types are
|
|
// string, bool, float64, and int. String comparison is case-insensitive
|
|
// and trims whitespace.
|
|
func JSONField[T comparable](path string, want T) Evaluator {
|
|
return jsonFieldEval[T]{path: path, want: want}
|
|
}
|
|
|
|
func (j jsonFieldEval[T]) Evaluate(_ context.Context, response string) Score {
|
|
raw, err := ExtractJSON(response)
|
|
if err != nil {
|
|
return Score{Value: 0, Detail: err.Error()}
|
|
}
|
|
|
|
dec := json.NewDecoder(strings.NewReader(raw))
|
|
dec.UseNumber()
|
|
var parsed any
|
|
if decodeErr := dec.Decode(&parsed); decodeErr != nil {
|
|
return Score{Value: 0, Detail: fmt.Sprintf("invalid JSON: %v", decodeErr)}
|
|
}
|
|
|
|
segments, err := parsePath(j.path)
|
|
if err != nil {
|
|
return Score{Value: 0, Detail: err.Error()}
|
|
}
|
|
|
|
val, err := navigate(parsed, segments)
|
|
if err != nil {
|
|
return Score{Value: 0, Detail: fmt.Sprintf("path %q: %v", j.path, err)}
|
|
}
|
|
|
|
got, err := jsonValueAs[T](val)
|
|
if err != nil {
|
|
return Score{Value: 0, Detail: fmt.Sprintf("path %q: %v", j.path, err)}
|
|
}
|
|
|
|
if equalJSONValue(got, j.want) {
|
|
return Score{Value: 1, Detail: fmt.Sprintf("%s = %v", j.path, got)}
|
|
}
|
|
return Score{Value: 0, Detail: fmt.Sprintf("%s: got %v, want %v", j.path, got, j.want)}
|
|
}
|
|
|
|
// equalJSONValue compares two values of the same comparable type T, folding
|
|
// case and whitespace when T is string.
|
|
func equalJSONValue[T comparable](got, want T) bool {
|
|
if gs, ok := any(got).(string); ok {
|
|
ws := any(want).(string)
|
|
return strings.EqualFold(strings.TrimSpace(gs), strings.TrimSpace(ws))
|
|
}
|
|
return got == want
|
|
}
|