package harness import ( "context" "encoding/json" "fmt" "strings" ) // Result 是一次输出评测的结果。Overall ∈ [0,1]。 type Result struct { Overall float64 // 综合分(有 LLM 评审则 0.4*规则 + 0.6*LLM,否则=规则分) Rule float64 // 规则分 LLM float64 // LLM-as-judge 分(0 表示未评/失败) Flags []string // 命中的规则问题 Reason string // LLM 评语 } // Evaluator 实现 LLM 自动化评测:规则检查(快、always-on)+ LLM-as-judge(模型就绪时)。 // 通过注入 ready/chat 解耦具体 LLM 后端,便于单测。 type Evaluator struct { ready func() bool chat func(ctx context.Context, sys, user string) (string, error) } // NewEvaluator 注入"模型是否就绪"与"对话"两个能力;二者为 nil 时仅做规则评测。 func NewEvaluator(ready func() bool, chat func(ctx context.Context, sys, user string) (string, error)) *Evaluator { return &Evaluator{ready: ready, chat: chat} } // Score 对一次推理输出综合打分。 func (e *Evaluator) Score(ctx context.Context, input, output string) Result { rule, flags := ruleScore(output) res := Result{Rule: rule, Flags: flags, Overall: rule} if e.ready != nil && e.chat != nil && e.ready() { if s, reason, ok := e.llmJudge(ctx, input, output); ok { res.LLM = s res.Reason = reason res.Overall = 0.4*rule + 0.6*s } } return res } // refusalMarkers 是疑似拒答/出错的特征串(小写匹配)。 var refusalMarkers = []string{"抱歉,我无法", "我无法回答", "无法完成", "撰写失败", "本章撰写失败", "i cannot", "i'm unable", "error:"} // ruleScore 纯规则评测:空输出 / 过短 / 疑似拒答 / 重复啰嗦各扣分。返回分与命中标签。 func ruleScore(output string) (float64, []string) { out := strings.TrimSpace(output) if out == "" { return 0, []string{"空输出"} } score := 1.0 var flags []string if len([]rune(out)) < 10 { score -= 0.4 flags = append(flags, "输出过短") } low := strings.ToLower(out) for _, m := range refusalMarkers { if strings.Contains(low, strings.ToLower(m)) { score -= 0.3 flags = append(flags, "疑似拒答/错误") break } } if hasHeavyRepeat(out) { score -= 0.3 flags = append(flags, "重复啰嗦") } if score < 0 { score = 0 } return score, flags } // hasHeavyRepeat 判断是否有非空行重复 ≥3 次(粗略的啰嗦/复读检测)。 func hasHeavyRepeat(s string) bool { counts := map[string]int{} for _, line := range strings.Split(s, "\n") { line = strings.TrimSpace(line) if len([]rune(line)) < 4 { continue } counts[line]++ if counts[line] >= 3 { return true } } return false } // llmJudge 让模型对输出 1–5 打分并给理由,归一化到 [0.2,1]。失败返回 ok=false。 func (e *Evaluator) llmJudge(ctx context.Context, input, output string) (float64, string, bool) { sys := "你是严格的回答质量评审,从相关性、准确性、完整性综合判断。" user := fmt.Sprintf("用户输入:%s\n模型输出:%s\n请打分。只输出 JSON:{\"score\":1到5的整数,\"reason\":\"一句话理由\"},不要任何多余文字。", evalTruncate(input, 500), evalTruncate(output, 1500)) txt, err := e.chat(ctx, sys, user) if err != nil { return 0, "", false } var j struct { Score float64 `json:"score"` Reason string `json:"reason"` } if json.Unmarshal([]byte(evalStripFence(txt)), &j) != nil || j.Score <= 0 { return 0, "", false } s := j.Score / 5.0 if s > 1 { s = 1 } return s, j.Reason, true } func evalTruncate(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" } // evalStripFence 去掉模型可能包裹的 ```json … ``` 围栏。 func evalStripFence(s string) string { s = strings.TrimSpace(s) if strings.HasPrefix(s, "```") { if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[i+1:] } s = strings.TrimSuffix(strings.TrimSpace(s), "```") } return strings.TrimSpace(s) }