Files
sundynix-agentix/sundynix-dispatcher/internal/harness/eval.go
T
Blizzard 446b784fc6 feat(harness): RAG 忠实度评测 —— judge 拿检索原文评幻觉
补 Harness 已知洞:此前 LLM-judge 只看 input+output,看不到检索来源,幻觉其实没评。

- Evaluator.Score 增 sources 参数;有来源时走 llmJudgeGrounded:一次调用同时评 quality 质量 +
  faithfulness 忠实度,并列出 unsupported(未被来源支持的说法)→ 进 Flags。
  综合分(有来源)=0.3规则+0.35质量+0.35忠实;无来源时维持原 0.4规则+0.6质量。Result 增 Faithful 字段。
- 透传检索来源:runGraph 返回 (answer, refs, err),executeGraph 同步;Handle→evaluate(input,output,refs);
  refsOf(board)=检索资料+工具产出。compose 路径暂返回 nil refs(不评忠实度)。
- eval 日志增「忠实 X.XX,来源 N」。

测试:单测覆盖 grounded(quality/faithfulness/unsupported 解析 + 加权 + flags)与无来源跳过;
3 处测试 runGraph 三返回值更新。live 实测 RAG 任务忠实 1.00/来源 1,judge 正确判定无编造。
project_analysis Harness 清单勾掉该项。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:03:17 +08:00

185 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package harness
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// Result 是一次输出评测的结果。各分 ∈ [0,1]。
type Result struct {
Overall float64 // 综合分(见 Score 的加权)
Rule float64 // 规则分
LLM float64 // LLM-as-judge 质量分(0=未评/失败)
Faithful float64 // RAG 忠实度分(有检索来源时才评;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 对一次推理输出综合打分。sources 为检索到的资料(RAG 来源):非空时额外评忠实度
// (回答是否基于来源、有无编造),并把"未被来源支持的说法"加进 Flags。
func (e *Evaluator) Score(ctx context.Context, input, output string, sources []string) Result {
rule, flags := ruleScore(output)
res := Result{Rule: rule, Flags: flags, Overall: rule}
if e.ready == nil || e.chat == nil || !e.ready() {
return res
}
if len(sources) > 0 {
// RAG 路径:一次 judge 同时评质量 + 忠实度 + 列未支持说法。
if q, f, unsupported, reason, ok := e.llmJudgeGrounded(ctx, input, output, sources); ok {
res.LLM, res.Faithful, res.Reason = q, f, reason
res.Overall = 0.3*rule + 0.35*q + 0.35*f
for _, u := range unsupported {
res.Flags = append(res.Flags, "未被来源支持:"+u)
}
}
return res
}
// 无来源:仅评质量(相关/准确/完整)。
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
}
// llmJudgeGrounded 给 judge 同时喂用户问题、检索资料、模型回答,一次返回:
// quality 质量分、faithful 忠实度分(均归一到 [0,1])、unsupported 未被资料支持的说法、reason 评语。
func (e *Evaluator) llmJudgeGrounded(ctx context.Context, input, output string, sources []string) (quality, faithful float64, unsupported []string, reason string, ok bool) {
sys := "你是严格的 RAG 回答评审,重点核查回答是否严格基于检索资料、有无编造(幻觉)。"
src := evalTruncate(strings.Join(sources, "\n---\n"), 3000)
user := fmt.Sprintf(
"【用户问题】%s\n\n【检索资料】\n%s\n\n【模型回答】%s\n\n"+
"请评估两项:quality=回答质量(相关/准确/完整)faithfulness=忠实度(回答是否严格基于检索资料、有无编造)。"+
"并列出 unsupported:回答中未被检索资料支持的具体说法(无则空数组)。"+
"只输出 JSON{\"quality\":1到5整数,\"faithfulness\":1到5整数,\"unsupported\":[\"...\"],\"reason\":\"一句话\"},不要多余文字。",
evalTruncate(input, 400), src, evalTruncate(output, 1500))
txt, err := e.chat(ctx, sys, user)
if err != nil {
return 0, 0, nil, "", false
}
var j struct {
Quality float64 `json:"quality"`
Faithfulness float64 `json:"faithfulness"`
Unsupported []string `json:"unsupported"`
Reason string `json:"reason"`
}
if json.Unmarshal([]byte(evalStripFence(txt)), &j) != nil || j.Quality <= 0 || j.Faithfulness <= 0 {
return 0, 0, nil, "", false
}
norm := func(v float64) float64 {
s := v / 5.0
if s > 1 {
s = 1
}
return s
}
return norm(j.Quality), norm(j.Faithfulness), j.Unsupported, 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)
}