feat(harness): 评测闭环 —— 评测结果落库 + 分级 + 告警 + 可查(测温计→恒温器)

此前 eval 只打日志、不闭环。现在:
- 分级:evalLevel 据综合分+忠实度 → ok(≥0.75) / warn(≥0.5 或忠实<0.6) / poor(<0.5);poor 出 slog.Warn 告警。
- 落库:dispatcher 评完经 NATS(SubjectEval) 广播 EvalEvent → 网关订阅写 PG(新表 sundynix_eval,
  按 task_id upsert)。沿用任务状态回写那套(dispatcher 无 DB,经 bus→gateway 落库)。
- 可查:GET /api/v1/tasks/:id/eval 返回 overall/rule/llm/faithful/level/flags/reason/sources。
- 契约 EvalEvent + EvalOK/Warn/Poor;bus PublishEval/SubscribeEval;dispatcher EvalSink(NewOrchestrator 第9参)。

验证:三模块 build+vet+test 全绿;live RAG 任务评测落库,端点返回 overall~1.0 / level=ok / faithful=1 / sources=1。
剩:桌面端质量面板、低分自动重试(P3)。project_analysis 勾掉该项。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-25 15:26:19 +08:00
parent 446b784fc6
commit 10f08ffb14
12 changed files with 173 additions and 10 deletions
+2 -1
View File
@@ -112,7 +112,8 @@ Harness = 围绕 LLM 的可靠性 / 安全 / 质量治理层。4 个组件均为
**已知短板 / 优化清单**(按性价比,逐项推进): **已知短板 / 优化清单**(按性价比,逐项推进):
- [ ] **P1 评测闭环**eval 结果落库 + 阈值门控(低分标记/触发重试)+ 桌面端质量面板。现状只打日志、不闭环。 - [x] **P1 评测闭环**eval 结果经 NATS 落 PGsundynix_eval+ 分级(ok/warn/poor) + poor 告警 +
`GET /tasks/:id/eval` 可查。dispatcher 评→广播→网关落库。剩:桌面端质量面板、低分自动重试(P3)。
- [x] **P1 RAG 忠实度评测** ✅:检索原文喂给 judge,一次评质量+忠实度,未被来源支持的说法进 Flags。 - [x] **P1 RAG 忠实度评测** ✅:检索原文喂给 judge,一次评质量+忠实度,未被来源支持的说法进 Flags。
综合分(有来源)=0.3规则+0.35质量+0.35忠实。runGraph 透传 refs → evaluate。live 实测忠实 1.00/来源 1,单测覆盖。 综合分(有来源)=0.3规则+0.35质量+0.35忠实。runGraph 透传 refs → evaluate。live 实测忠实 1.00/来源 1,单测覆盖。
- [ ] **P2 输出脱敏增强**:滑动窗口跨片检测(现流式逐片会漏跨 token 的密钥)+ PII 模式(手机号/邮箱/身份证)。 - [ ] **P2 输出脱敏增强**:滑动窗口跨片检测(现流式逐片会漏跨 token 的密钥)+ PII 模式(手机号/邮箱/身份证)。
+2 -2
View File
@@ -46,8 +46,8 @@ func main() {
go sub.FetchModelConfigWithRetry(context.Background(), pool.SetConfig) go sub.FetchModelConfigWithRetry(context.Background(), pool.SetConfig)
// sub 同时作为 Token 回流(TokenSink)、MCP 工具调用(ToolCaller)、执行事件(ExecSink)、 // sub 同时作为 Token 回流(TokenSink)、MCP 工具调用(ToolCaller)、执行事件(ExecSink)、
// 任务状态回写(StatusSink)HITL 审批等待(ApprovalWaiter)出口。 // 任务状态回写(StatusSink)HITL 审批等待(ApprovalWaiter)与评测落库(EvalSink)出口。
orch, err := eino.NewOrchestrator(pool, breaker, eval, sub, sub, sub, sub, sub) orch, err := eino.NewOrchestrator(pool, breaker, eval, sub, sub, sub, sub, sub, sub)
if err != nil { if err != nil {
log.Fatalf("[dispatcher] build eino graph: %v", err) log.Fatalf("[dispatcher] build eino graph: %v", err)
} }
@@ -46,6 +46,11 @@ type ApprovalWaiter interface {
WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error) WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error)
} }
// EvalSink 回写自动化评测结果供网关落库(由 NATS bus 实现;可为 nil → 仅打日志不落库)。
type EvalSink interface {
PublishEval(ev *contract.EvalEvent) error
}
// errRejected 是审批节点拒绝(或超时)时图执行返回的哨兵错误:它是合法终态而非故障, // errRejected 是审批节点拒绝(或超时)时图执行返回的哨兵错误:它是合法终态而非故障,
// Handle 据此判 rejected 并优雅收尾(不计熔断失败)。 // Handle 据此判 rejected 并优雅收尾(不计熔断失败)。
var errRejected = errors.New("approval rejected") var errRejected = errors.New("approval rejected")
@@ -82,16 +87,17 @@ type Orchestrator struct {
exec ExecSink exec ExecSink
status StatusSink // 任务生命周期状态回写(可为 nil) status StatusSink // 任务生命周期状态回写(可为 nil)
approval ApprovalWaiter // HITL 审批等待(可为 nil → 审批节点自动放行) approval ApprovalWaiter // HITL 审批等待(可为 nil → 审批节点自动放行)
evalSink EvalSink // 评测结果回写落库(可为 nil → 仅打日志)
turnMu sync.Mutex // 保护 turns(攒批计数,多任务 goroutine 共享) turnMu sync.Mutex // 保护 turns(攒批计数,多任务 goroutine 共享)
turns map[string]int // sessionID → 累计轮次,用于每 N 轮触发 consolidate turns map[string]int // sessionID → 累计轮次,用于每 N 轮触发 consolidate
} }
// NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。 // NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。
// exec 为执行可视化事件出口(可为 nil,则不发轨迹事件);eval 为自动化评测(可为 nil); // exec 为执行可视化事件出口(可为 nil);eval 为自动化评测(可为 nil);status 为状态回写(可为 nil);
// status 为任务生命周期状态回写出口(可为 nil);approval 为 HITL 审批等待(可为 nil)。 // approval 为 HITL 审批等待(可为 nil);evalSink 为评测结果落库出口(可为 nil)。
func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Evaluator, sink TokenSink, tools ToolCaller, exec ExecSink, status StatusSink, approval ApprovalWaiter) (*Orchestrator, error) { func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Evaluator, sink TokenSink, tools ToolCaller, exec ExecSink, status StatusSink, approval ApprovalWaiter, evalSink EvalSink) (*Orchestrator, error) {
return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status, approval: approval}, nil return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status, approval: approval, evalSink: evalSink}, nil
} }
// setStatus 回写一次任务状态流转(status 为 nil 时静默跳过)。 // setStatus 回写一次任务状态流转(status 为 nil 时静默跳过)。
@@ -203,8 +209,33 @@ func (o *Orchestrator) evaluate(t *contract.Task, input, output string, sources
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
r := o.eval.Score(ctx, input, output, sources) r := o.eval.Score(ctx, input, output, sources)
log.Printf("[eval] task %s 综合 %.2f(规则 %.2f / LLM %.2f / 忠实 %.2f,来源 %dflags=%v %s", level := evalLevel(r)
t.ID, r.Overall, r.Rule, r.LLM, r.Faithful, len(sources), r.Flags, r.Reason) log.Printf("[eval] task %s 综合 %.2f(规则 %.2f / LLM %.2f / 忠实 %.2f,来源 %dlevel=%s flags=%v %s",
t.ID, r.Overall, r.Rule, r.LLM, r.Faithful, len(sources), level, r.Flags, r.Reason)
if level == contract.EvalPoor { // 低质量告警(可观测 + 后续可挂报警)
slog.Warn("eval poor quality", "task_id", t.ID, "overall", r.Overall, "faithful", r.Faithful, "flags", r.Flags)
}
// 闭环:评测结果落库(供 UI 查询 / 质量趋势 / 门控)。
if o.evalSink != nil {
if err := o.evalSink.PublishEval(&contract.EvalEvent{
TaskID: t.ID, Overall: r.Overall, Rule: r.Rule, LLM: r.LLM, Faithful: r.Faithful,
Level: level, Flags: r.Flags, Reason: r.Reason, Sources: len(sources), TS: time.Now().UnixMilli(),
}); err != nil {
log.Printf("[eval] 回写评测结果失败 task=%s: %v", t.ID, err)
}
}
}
// evalLevel 据综合分 + 忠实度把评测分级(闭环门控/告警用)。
func evalLevel(r harness.Result) string {
switch {
case r.Overall < 0.5:
return contract.EvalPoor
case r.Overall < 0.75 || (r.Faithful > 0 && r.Faithful < 0.6):
return contract.EvalWarn
default:
return contract.EvalOK
}
} }
// fetchMemory 经 MCP memory_get 工具召回用户常驻画像。 // fetchMemory 经 MCP memory_get 工具召回用户常驻画像。
@@ -81,6 +81,11 @@ func (s *Subscriber) PublishTaskStatus(taskID, status, detail string) error {
}) })
} }
// PublishEval 让 Subscriber 满足 eino.EvalSink,把自动化评测结果回写给网关落库。
func (s *Subscriber) PublishEval(ev *contract.EvalEvent) error {
return s.inner.PublishEval(ev)
}
// WaitApproval 让 Subscriber 满足 eino.ApprovalWaiter,阻塞等待审批节点的人工决定。 // WaitApproval 让 Subscriber 满足 eino.ApprovalWaiter,阻塞等待审批节点的人工决定。
func (s *Subscriber) WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error) { func (s *Subscriber) WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error) {
return s.inner.WaitApproval(ctx, taskID, timeout) return s.inner.WaitApproval(ctx, taskID, timeout)
+14
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"log" "log"
"os" "os"
@@ -64,6 +65,19 @@ func main() {
log.Printf("[gateway] subscribe task status: %v", err) log.Printf("[gateway] subscribe task status: %v", err)
} }
// 评测闭环:订阅 dispatcher 回写的自动化评测结果,落 PG 供 UI 查询 / 质量趋势 / 门控。
if _, err := bus.SubscribeEval(func(ev *contract.EvalEvent) {
flags, _ := json.Marshal(ev.Flags)
if err := db.SaveEval(context.Background(), &store.Eval{
TaskID: ev.TaskID, Overall: ev.Overall, Rule: ev.Rule, LLM: ev.LLM, Faithful: ev.Faithful,
Level: ev.Level, Flags: string(flags), Reason: ev.Reason, Sources: ev.Sources,
}); err != nil {
log.Printf("[gateway] 落库评测 %s 失败: %v", ev.TaskID, err)
}
}); err != nil {
log.Printf("[gateway] subscribe eval: %v", err)
}
r := router.New(db, cache, bus, blobStore) r := router.New(db, cache, bus, blobStore)
addr := envOr("GATEWAY_ADDR", ":8080") addr := envOr("GATEWAY_ADDR", ":8080")
log.Printf("[gateway] listening on %s", addr) log.Printf("[gateway] listening on %s", addr)
@@ -89,6 +89,21 @@ func (h *Handler) TaskStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"task_id": id, "status": status, "detail": detail}) c.JSON(http.StatusOK, gin.H{"task_id": id, "status": status, "detail": detail})
} }
// TaskEval: GET /api/v1/tasks/:id/eval —— 取一次任务的自动化评测结果(综合/质量/忠实度/分级/flags)。
func (h *Handler) TaskEval(c *gin.Context) {
e := h.db.GetEval(c.Request.Context(), c.Param("id"))
if e == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "尚无评测结果(任务未完成或评测进行中)"})
return
}
var flags []string
_ = json.Unmarshal([]byte(e.Flags), &flags)
c.JSON(http.StatusOK, gin.H{
"task_id": e.TaskID, "overall": e.Overall, "rule": e.Rule, "llm": e.LLM,
"faithful": e.Faithful, "level": e.Level, "flags": flags, "reason": e.Reason, "sources": e.Sources,
})
}
// ApproveTask: POST /api/v1/tasks/:id/approve {approved, node?, note?} —— 人工审批决定(HITL)。 // ApproveTask: POST /api/v1/tasks/:id/approve {approved, node?, note?} —— 人工审批决定(HITL)。
// 把决定经 NATS 发给 dispatcher,解除审批节点的阻塞(批准放行 / 拒绝中止)。 // 把决定经 NATS 发给 dispatcher,解除审批节点的阻塞(批准放行 / 拒绝中止)。
func (h *Handler) ApproveTask(c *gin.Context) { func (h *Handler) ApproveTask(c *gin.Context) {
@@ -69,6 +69,11 @@ func (b *Bus) PublishApproval(dec *contract.ApprovalDecision) error {
return b.inner.PublishApproval(dec) return b.inner.PublishApproval(dec)
} }
// SubscribeEval 订阅 dispatcher 回写的自动化评测结果(落 PG)。
func (b *Bus) SubscribeEval(onEvent func(*contract.EvalEvent)) (func() error, error) {
return b.inner.SubscribeEval(onEvent)
}
// ServeConfig 让网关作为配置控制面,响应某 kind 的配置请求。 // ServeConfig 让网关作为配置控制面,响应某 kind 的配置请求。
func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (func() error, error) { func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (func() error, error) {
return b.inner.ServeConfig(kind, provide) return b.inner.ServeConfig(kind, provide)
@@ -53,6 +53,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected
p.POST("/tasks/:id/approve", h.ApproveTask) // HITL 人工审批决定(批准/拒绝) p.POST("/tasks/:id/approve", h.ApproveTask) // HITL 人工审批决定(批准/拒绝)
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板) p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key= p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=
+16
View File
@@ -22,3 +22,19 @@ type Task struct {
Status string `gorm:"size:32"` // submitted / running / done / failed / timeout Status string `gorm:"size:32"` // submitted / running / done / failed / timeout
Detail string `gorm:"type:text"` // 失败/超时原因等(状态机回写) Detail string `gorm:"type:text"` // 失败/超时原因等(状态机回写)
} }
// Eval 是一次任务的自动化评测结果(dispatcher 评完经 NATS 回写,每任务一条,按 task_id upsert)。
type Eval struct {
BaseModel
TaskID string `gorm:"uniqueIndex;size:64"`
Overall float64 // 综合分 [0,1]
Rule float64 // 规则分
LLM float64 // LLM 质量分
Faithful float64 // RAG 忠实度分(0=无来源未评)
Level string `gorm:"size:16"` // ok / warn / poor
Flags string `gorm:"type:text"` // 命中问题(JSON 数组字符串)
Reason string `gorm:"type:text"` // 评语
Sources int // 检索来源数
}
func (Eval) TableName() string { return "sundynix_eval" }
+27 -1
View File
@@ -11,6 +11,7 @@ import (
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema" "gorm.io/gorm/schema"
"github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/contract"
@@ -65,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
migrateLegacyIntIDs(db) migrateLegacyIntIDs(db)
migrateDocLinkToID(db) migrateDocLinkToID(db)
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}); err != nil { if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}); err != nil {
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err) log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
return &Postgres{} return &Postgres{}
} }
@@ -144,6 +145,31 @@ func (p *Postgres) GetTaskStatus(ctx context.Context, id string) (status, detail
return t.Status, t.Detail return t.Status, t.Detail
} }
// SaveEval 落库一条评测结果(按 task_id upsert:重评覆盖)。
func (p *Postgres) SaveEval(ctx context.Context, e *Eval) error {
if p.db == nil {
return nil
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "task_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"overall", "rule", "llm", "faithful", "level", "flags", "reason", "sources", "updated_at",
}),
}).Create(e).Error
}
// GetEval 取一条任务的评测结果(不存在返回 nil)。
func (p *Postgres) GetEval(ctx context.Context, taskID string) *Eval {
if p.db == nil {
return nil
}
var e Eval
if err := p.db.WithContext(ctx).Where("task_id = ?", taskID).First(&e).Error; err != nil {
return nil
}
return &e
}
// CountTasks 返回已提交任务数(降级模式返回 0)。 // CountTasks 返回已提交任务数(降级模式返回 0)。
func (p *Postgres) CountTasks(ctx context.Context) (int64, error) { func (p *Postgres) CountTasks(ctx context.Context) (int64, error) {
if p.db == nil { if p.db == nil {
+25
View File
@@ -338,6 +338,31 @@ func (b *Bus) SubscribeTaskStatus(onEvent func(*contract.TaskStatusEvent)) (unsu
return sub.Unsubscribe, nil return sub.Unsubscribe, nil
} }
// ---- 自动化评测结果回写(core NATS pub-sub----
// PublishEval 广播一次评测结果(dispatcher 调用)。
func (b *Bus) PublishEval(ev *contract.EvalEvent) error {
data, err := json.Marshal(ev)
if err != nil {
return err
}
return b.nc.Publish(contract.SubjectEval, data)
}
// SubscribeEval 订阅评测结果(网关调用,落 PG)。
func (b *Bus) SubscribeEval(onEvent func(*contract.EvalEvent)) (unsub func() error, err error) {
sub, err := b.nc.Subscribe(contract.SubjectEval, func(m *nats.Msg) {
var ev contract.EvalEvent
if json.Unmarshal(m.Data, &ev) == nil {
onEvent(&ev)
}
})
if err != nil {
return nil, fmt.Errorf("subscribe eval: %w", err)
}
return sub.Unsubscribe, nil
}
// ---- 人工审批(HITLcore NATS pub-sub---- // ---- 人工审批(HITLcore NATS pub-sub----
// PublishApproval 广播一次人工审批决定(网关在收到 UI 的批准/拒绝后调用)。 // PublishApproval 广播一次人工审批决定(网关在收到 UI 的批准/拒绝后调用)。
+24
View File
@@ -37,8 +37,32 @@ const (
// 人工审批(HITL)决定回传前缀:实际 sundynix.approval.<task_id>。 // 人工审批(HITL)决定回传前缀:实际 sundynix.approval.<task_id>。
// core NATS pub-subUI 点批准/拒绝 → 网关发到此 → dispatcher 解除审批节点的阻塞。 // core NATS pub-subUI 点批准/拒绝 → 网关发到此 → dispatcher 解除审批节点的阻塞。
SubjectApproval = "sundynix.approval" SubjectApproval = "sundynix.approval"
// 自动化评测结果回写:dispatcher 评完经此广播,网关订阅落 PG 并供 UI 查询。core NATS pub-sub。
SubjectEval = "sundynix.eval.task"
) )
// 评测质量分级(据综合分 + 忠实度阈值,闭环门控/告警用)。
const (
EvalOK = "ok" // 综合 ≥ 0.75 且无忠实度风险
EvalWarn = "warn" // 0.5 ≤ 综合 < 0.75,或忠实度偏低
EvalPoor = "poor" // 综合 < 0.5
)
// EvalEvent 是一次自动化评测的结果(经 SubjectEval 回流给网关落库)。
type EvalEvent struct {
TaskID string `json:"task_id"`
Overall float64 `json:"overall"` // 综合分 [0,1]
Rule float64 `json:"rule"` // 规则分
LLM float64 `json:"llm"` // LLM 质量分
Faithful float64 `json:"faithful"` // RAG 忠实度分(0=无来源未评)
Level string `json:"level"` // ok / warn / poor
Flags []string `json:"flags,omitempty"` // 命中问题(规则 + 未被来源支持)
Reason string `json:"reason,omitempty"` // 评语
Sources int `json:"sources,omitempty"` // 检索来源数
TS int64 `json:"ts"` // unix 毫秒
}
// 任务生命周期状态机:submitted(网关建任务)→ runningdispatcher 开跑) // 任务生命周期状态机:submitted(网关建任务)→ runningdispatcher 开跑)
// → done / failed / timeoutdispatcher 收尾)。 // → done / failed / timeoutdispatcher 收尾)。
// HITL:执行到审批节点 → waiting(等人工决定)→ 批准回 running / 拒绝→rejected。 // HITL:执行到审批节点 → waiting(等人工决定)→ 批准回 running / 拒绝→rejected。