fix: address review P1+P2 — sort alignment, failure sentinel, score parser

- P1: Replace hand-rolled sortByRank with sort.Slice (ascending, best
  first) matching eval.go's EvalSeahorse — ensures BudgetTruncate keeps
  best-ranked messages when truncation occurs
- P2: Use -1.0 sentinel for LLM API failures and parse errors, distinct
  from genuine 0.0 score; aggregateMetrics skips -1.0 entries for F1
  averaging while still counting HitRate
- P2: Use regexp \b([1-5])\b for judge score extraction instead of
  first-digit scan — avoids misparses on '5/5', 'Score: 3' etc.
This commit is contained in:
BeaconCat 2026-04-13 10:25:22 +08:00
parent d3fb51ee82
commit 90a57642b0
2 changed files with 67 additions and 54 deletions

View file

@ -201,38 +201,59 @@ func EvalSeahorse(
// aggregateMetrics computes overall and per-category metrics. // aggregateMetrics computes overall and per-category metrics.
func aggregateMetrics(qaResults []QAResult) AggMetrics { func aggregateMetrics(qaResults []QAResult) AggMetrics {
byCat := map[int]*CatMetrics{} type catAccum struct {
f1Sum float64
f1Count int
hitRateSum float64
hitRateCount int
}
byCatAcc := map[int]*catAccum{}
totalF1 := 0.0 totalF1 := 0.0
totalHitRate := 0.0 totalHitRate := 0.0
validF1Count := 0
for _, qr := range qaResults { for _, qr := range qaResults {
// Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging.
if qr.TokenF1 >= 0 {
totalF1 += qr.TokenF1 totalF1 += qr.TokenF1
validF1Count++
}
totalHitRate += qr.HitRate totalHitRate += qr.HitRate
cat, ok := byCat[qr.Category] acc, ok := byCatAcc[qr.Category]
if !ok { if !ok {
cat = &CatMetrics{} acc = &catAccum{}
byCat[qr.Category] = cat byCatAcc[qr.Category] = acc
} }
cat.F1 += qr.TokenF1 if qr.TokenF1 >= 0 {
cat.HitRate += qr.HitRate acc.f1Sum += qr.TokenF1
cat.QuestionCount++ acc.f1Count++
} }
n := len(qaResults) acc.hitRateSum += qr.HitRate
if n == 0 { acc.hitRateCount++
n = 1
} }
agg := AggMetrics{ nHit := len(qaResults)
OverallF1: totalF1 / float64(n), if nHit == 0 {
OverallHitRate: totalHitRate / float64(n), nHit = 1
}
if validF1Count == 0 {
validF1Count = 1
}
byCat := map[int]*CatMetrics{}
for cat, acc := range byCatAcc {
cm := &CatMetrics{QuestionCount: acc.hitRateCount}
if acc.f1Count > 0 {
cm.F1 = acc.f1Sum / float64(acc.f1Count)
}
if acc.hitRateCount > 0 {
cm.HitRate = acc.hitRateSum / float64(acc.hitRateCount)
}
byCat[cat] = cm
}
return AggMetrics{
OverallF1: totalF1 / float64(validF1Count),
OverallHitRate: totalHitRate / float64(nHit),
ByCategory: byCat, ByCategory: byCat,
TotalQuestions: len(qaResults), TotalQuestions: len(qaResults),
} }
for _, cat := range agg.ByCategory {
if cat.QuestionCount > 0 {
cat.F1 /= float64(cat.QuestionCount)
cat.HitRate /= float64(cat.QuestionCount)
}
}
return agg
} }
// SaveResults writes per-sample eval results to JSON files. // SaveResults writes per-sample eval results to JSON files.

View file

@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
@ -37,8 +39,11 @@ func generateAnswer(ctx context.Context, client *LLMClient, contextText, questio
return client.Complete(ctx, answerSystemPrompt, userPrompt) return client.Complete(ctx, answerSystemPrompt, userPrompt)
} }
// scoreRe matches the first standalone integer 1-5 in the judge response.
var scoreRe = regexp.MustCompile(`\b([1-5])\b`)
// judgeAnswer asks the LLM to score the candidate answer vs the gold answer. // judgeAnswer asks the LLM to score the candidate answer vs the gold answer.
// Returns a score from 0.0 to 1.0. // Returns a score from 0.0 to 1.0, or -1.0 on parse failure.
func judgeAnswer( func judgeAnswer(
ctx context.Context, ctx context.Context,
client *LLMClient, client *LLMClient,
@ -51,20 +56,16 @@ func judgeAnswer(
response, err := client.Complete(ctx, judgeSystemPrompt, userPrompt) response, err := client.Complete(ctx, judgeSystemPrompt, userPrompt)
if err != nil { if err != nil {
return 0.0, err return -1.0, err
} }
// Parse score from response
response = strings.TrimSpace(response) response = strings.TrimSpace(response)
// Extract first digit found if m := scoreRe.FindStringSubmatch(response); len(m) == 2 {
for _, ch := range response { score, _ := strconv.Atoi(m[1])
if ch >= '1' && ch <= '5' {
score, _ := strconv.Atoi(string(ch))
return float64(score-1) / 4.0, nil // Normalize 1-5 to 0.0-1.0 return float64(score-1) / 4.0, nil // Normalize 1-5 to 0.0-1.0
} }
} log.Printf("WARNING: could not parse judge score from: %q, returning -1", response)
log.Printf("WARNING: could not parse judge score from: %q, defaulting to 0.0", response) return -1.0, nil
return 0.0, nil
} }
// EvalLegacyLLM evaluates legacy store using LLM generation + LLM-as-Judge. // EvalLegacyLLM evaluates legacy store using LLM generation + LLM-as-Judge.
@ -101,8 +102,8 @@ func EvalLegacyLLM(
llmAnswer = "" llmAnswer = ""
} }
// Judge the answer // Judge the answer; -1.0 = API/parse failure.
score := 0.0 score := -1.0
if llmAnswer != "" { if llmAnswer != "" {
score, err = judgeAnswer(ctx, client, qa.Question, qa.AnswerString(), llmAnswer) score, err = judgeAnswer(ctx, client, qa.Question, qa.AnswerString(), llmAnswer)
if err != nil { if err != nil {
@ -187,7 +188,11 @@ func EvalSeahorseLLM(
for id := range bestRank { for id := range bestRank {
messageIDs = append(messageIDs, id) messageIDs = append(messageIDs, id)
} }
sortByRank(messageIDs, bestRank) // Sort ascending: best (most-negative) rank first.
// BudgetTruncate walks front-to-back, so best-ranked messages are kept.
sort.Slice(messageIDs, func(i, j int) bool {
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
})
var contentParts []string var contentParts []string
if len(messageIDs) > 0 { if len(messageIDs) > 0 {
@ -206,10 +211,10 @@ func EvalSeahorseLLM(
Question: qa.Question, Question: qa.Question,
Category: qa.Category, Category: qa.Category,
GoldAnswer: qa.AnswerString(), GoldAnswer: qa.AnswerString(),
TokenF1: 0.0, TokenF1: -1.0,
HitRate: 0.0, HitRate: 0.0,
}) })
log.Printf("[seahorse-llm] sample=%s q=%d/%d score=0.00 answer=(no context)", log.Printf("[seahorse-llm] sample=%s q=%d/%d score=-1.00 answer=(no context)",
sample.SampleID, total, totalQA) sample.SampleID, total, totalQA)
continue continue
} }
@ -219,7 +224,7 @@ func EvalSeahorseLLM(
// Generate answer with LLM // Generate answer with LLM
llmAnswer := "" llmAnswer := ""
score := 0.0 score := -1.0
if contextText != "" { if contextText != "" {
var err error var err error
llmAnswer, err = generateAnswer(ctx, client, contextText, qa.Question) llmAnswer, err = generateAnswer(ctx, client, contextText, qa.Question)
@ -228,7 +233,7 @@ func EvalSeahorseLLM(
} }
} }
// Judge the answer // Judge the answer; -1.0 = API/parse failure.
if llmAnswer != "" { if llmAnswer != "" {
var err error var err error
score, err = judgeAnswer(ctx, client, qa.Question, qa.AnswerString(), llmAnswer) score, err = judgeAnswer(ctx, client, qa.Question, qa.AnswerString(), llmAnswer)
@ -276,16 +281,3 @@ func truncateStr(s string, maxLen int) string {
} }
return s return s
} }
// sortByRank sorts message IDs by BM25 rank (more negative = better).
func sortByRank(ids []int64, ranks map[int64]float64) {
for i := 1; i < len(ids); i++ {
key := ids[i]
j := i - 1
for j >= 0 && ranks[ids[j]] > ranks[key] {
ids[j+1] = ids[j]
j--
}
ids[j+1] = key
}
}