Files
sundynix-agentix/sundynix-dispatcher/internal/harness/eval_test.go
T
Blizzard 3ae009db38 feat(dispatcher): LLM 自动化评测落地(规则 + LLM-as-judge)+ 单测
Evaluator 此前是空桩(Score 恒返 0)且未接线。落地为真实自动化评测并接入:

- 规则评测(always-on,纯函数):空输出/过短/疑似拒答/重复啰嗦各扣分 → 0–1 分 + 标签。
- LLM-as-judge(模型就绪时):让模型对(输入,输出)按相关性/准确性/完整性 1–5 打分给理由,
  归一化后与规则分加权(0.4 规则 + 0.6 LLM);解析失败/无模型则回退纯规则分。
- 经注入 ready/chat 解耦 LLM 后端,便于单测(无需真实模型)。
- 接线:orchestrator 在答复产出后 `go o.evaluate(...)` 异步评分并记日志(off 热路径,
  不影响响应与流式);main.go 用 pool.Ready/pool.Chat 构造 Evaluator。

测试:规则各情形(正常/空/过短/拒答/重复)、纯规则模式、LLM-judge(带围栏 JSON 解析 +
归一化 + 加权)、坏 JSON 回退 —— 全过。

至此 Harness 三件:熔断降级  · 输入护栏  · LLM 自动化评测 (输出护栏待 emit 层)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:32:02 +08:00

92 lines
3.0 KiB
Go

package harness
import (
"context"
"strings"
"testing"
)
func TestRuleScore(t *testing.T) {
cases := []struct {
name string
output string
wantMax float64 // 期望分 ≤ 此值
wantMin float64 // 期望分 ≥ 此值
wantFlag string // 期望命中的标签(空=不校验)
}{
{"正常", "杭州是浙江省会,历史悠久,有西湖等名胜,是著名的旅游与电商之城。", 1.0, 1.0, ""},
{"空", " ", 0, 0, "空输出"},
{"过短", "好的", 0.7, 0, "输出过短"},
{"拒答", "抱歉,我无法回答这个问题,因为信息不足,请谅解理解支持。", 0.8, 0, "疑似拒答/错误"},
}
for _, c := range cases {
s, flags := ruleScore(c.output)
if s > c.wantMax+1e-9 || s < c.wantMin-1e-9 {
t.Errorf("%s: 分 %.2f 不在 [%.2f,%.2f]", c.name, s, c.wantMin, c.wantMax)
}
if c.wantFlag != "" && !contains(flags, c.wantFlag) {
t.Errorf("%s: 期望命中标签 %q, got %v", c.name, c.wantFlag, flags)
}
}
}
func TestRuleScore_HeavyRepeat(t *testing.T) {
out := strings.Repeat("这是一句会重复很多次的废话\n", 4)
_, flags := ruleScore(out)
if !contains(flags, "重复啰嗦") {
t.Errorf("应命中重复啰嗦, got %v", flags)
}
}
func TestScore_RuleOnly(t *testing.T) {
e := NewEvaluator(nil, nil) // 无 LLM → 仅规则
r := e.Score(context.Background(), "问题", "一段质量不错的较完整回答内容,长度足够,没有任何问题。")
if r.LLM != 0 || r.Overall != r.Rule {
t.Errorf("无 LLM 时 Overall 应等于规则分, got overall=%.2f rule=%.2f llm=%.2f", r.Overall, r.Rule, r.LLM)
}
}
func TestScore_WithLLMJudge(t *testing.T) {
// 注入假评审:返回带围栏的 JSON,验证解析 + 归一化 + 综合权重。
e := NewEvaluator(
func() bool { return true },
func(ctx context.Context, sys, user string) (string, error) {
return "```json\n{\"score\":4,\"reason\":\"相关且较完整\"}\n```", nil
},
)
r := e.Score(context.Background(), "介绍杭州", "杭州是浙江省会,西湖闻名,历史与现代交融,电商发达。")
if r.LLM <= 0 {
t.Fatalf("应有 LLM 分, got %.2f", r.LLM)
}
if r.LLM < 0.79 || r.LLM > 0.81 { // 4/5 = 0.8
t.Errorf("LLM 分应归一化为 0.8, got %.2f", r.LLM)
}
if r.Reason != "相关且较完整" {
t.Errorf("应解析出 reason, got %q", r.Reason)
}
want := 0.4*r.Rule + 0.6*r.LLM
if r.Overall < want-1e-9 || r.Overall > want+1e-9 {
t.Errorf("综合分应为 0.4*规则+0.6*LLM=%.3f, got %.3f", want, r.Overall)
}
}
func TestScore_LLMJudgeBadJSONFallsBack(t *testing.T) {
e := NewEvaluator(
func() bool { return true },
func(ctx context.Context, sys, user string) (string, error) { return "我觉得还行吧", nil },
)
r := e.Score(context.Background(), "q", "一段足够长且正常的回答内容用于评测。")
if r.LLM != 0 || r.Overall != r.Rule {
t.Errorf("LLM 返回非 JSON 应回退到规则分, got overall=%.2f llm=%.2f", r.Overall, r.LLM)
}
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}