Files
sundynix-agentix/sundynix-dispatcher/internal/harness/eval.go
T
Blizzard 9a3a816c80 feat(prompts): prompt 版本化地基 —— 注册表 + 运行期文件覆盖
把散落各服务的硬编码 system prompt 收口为受管注册表,不重编译即可改/回滚/对比:
- shared/prompts:内置默认(随代码) + 运行期覆盖(PROMPTS_FILE) + Get/Keys,并发安全,含单测
- 接入 9 处:mcp-go(graph.extract);dispatcher(eval.quality/eval.refine/guard.jailbreak/
  coordinator.lead/memory.extract,按引用登记默认、无文本重复)
- main 启动调 LoadFile 加载 PROMPTS_FILE 覆盖
- live A/B:覆盖 graph.extract → 图谱抽取 2 条→0 条、向量仍正常(覆盖生效、管道未坏)
- v2(DB 控制面热切换 + 灰度)留后续

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:36:04 +08:00

216 lines
8.5 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"
"github.com/sundynix/sundynix-shared/prompts"
)
// 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
}
// 评测提示词(校准版):默认怀疑、先挑毛病再打分、给死评分基准并要求用满 1–5 区间。
// 旧版只说"严格"但无基准 → 模型恒锚定 4–5,恒温器从不触发。
const (
evalQualitySys = "你是严苛的回答质量评审。默认怀疑:先找出回答的具体缺陷(跑题/不准确/不完整/空泛套话/啰嗦),再据此打分。" +
"评分要吝啬——不要因为回答看起来认真或篇幅长就给高分。"
evalQualityRubric = "评分基准(务必用满 1–5 区间,5 分极少给):\n" +
"5=准确、完整、切题,几乎无可挑剔\n4=好,但有可改进的小处\n3=基本可用但平庸/有遗漏/偏空泛\n" +
"2=有明显缺陷(部分跑题/不准确/敷衍)\n1=跑题/答非所问/明显错误"
)
// normJudge 把 15 的 judge 评分归一到 [0,1](v-1)/4。
// 关键校准:最低分 1 → 0(而非旧的 0.2)。否则 0.4*rule 的底(流畅输出 rule≈1)叠加 0.2 的分下限,
// 会让无来源 Overall 恒 ≥0.52、poor(<0.5) 永不可达 —— "流畅但跑题/错误"也压不到纠偏区间。
func normJudge(v float64) float64 {
s := (v - 1) / 4
if s < 0 {
s = 0
}
if s > 1 {
s = 1
}
return s
}
// llmJudge 让模型对输出按基准 1–5 打分并给理由,归一化到 [0,1]。失败返回 ok=false。
func (e *Evaluator) llmJudge(ctx context.Context, input, output string) (float64, string, bool) {
user := fmt.Sprintf("【用户输入】%s\n\n【模型输出】%s\n\n%s\n\n"+
"只输出 JSON{\"score\":1到5整数,\"reason\":\"先点最主要的缺陷(没有就说无明显缺陷)再给结论,一句话\"},不要多余文字。",
evalTruncate(input, 500), evalReviewOutput(output), evalQualityRubric)
txt, err := e.chat(ctx, prompts.Get(prompts.EvalQuality), 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
}
return normJudge(j.Score), 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"+
"评估两项,务必用满 15 区间:\n"+
"quality(质量)5=准确完整切题;3=平庸/有遗漏;1=跑题或错误。\n"+
"faithfulness(忠实度):5=每个关键说法都有资料依据;分数随无依据/编造的说法增多而降;只要有一处明显编造,faithfulness 不得高于 2。\n"+
"unsupported:列出回答中未被检索资料支持的具体说法(无则空数组)。\n"+
"只输出 JSON{\"quality\":1到5整数,\"faithfulness\":1到5整数,\"unsupported\":[\"...\"],\"reason\":\"一句话\"},不要多余文字。",
evalTruncate(input, 400), src, evalReviewOutput(output))
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
}
return normJudge(j.Quality), normJudge(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]) + "…"
}
// evalReviewLimit 是喂给 judge 的「模型回答」最长字数。不能太短:报告/多智能体综合等长答案
// 普遍 2000+ 字,截太短会让 judge 误判「回答不完整/截断」(而那是评审自己截的)。
const evalReviewLimit = 6000
// evalReviewOutput 把待评审的模型回答按 evalReviewLimit 截断;若确被截断,明确标注是评审所为,
// 让 judge 不要因结尾不完整而扣分(杜绝长答案被误判 warn/poor 的假阴性)。
func evalReviewOutput(s string) string {
r := []rune(s)
if len(r) <= evalReviewLimit {
return s
}
return string(r[:evalReviewLimit]) + "\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)
}