feat(admin): 任务观测支持下钻 —— 轨迹/输出/评测/DSL
全平台任务页此前只能看列表,点进去什么都没有。现在点一行开抽屉,四个页签:
执行轨迹、最终输出、评测明细、提交时的 DSL。不含审批操作——审批是客户端
用户的行为(桌面端 ApprovalBar),管理端只做观测。
不复用用户面的 /tasks/:id/replay:Task/Eval 都在租户插件作用域内,用请求 ctx
查别的租户的任务不会报错,而是静默返回空输出/空轨迹,UI 上表现为"这任务没
产出",比报错难查得多。新增 admin 端点走 WithoutTenant。
数据取自 sundynix_task 收尾落库的 output/trace 列,不依赖 Redis 流(10min TTL)。
所以这是复盘视图,运行中的任务轨迹为空——UI 里明确写出来,免得被当成轨迹丢了。
修的两处与测试环境失真有关(写测试时暴露的):
- 测试库没配 NamingStrategy,与 OpenPostgres 不一致:多数模型有显式
TableName() 碰巧对得上,但 Task 这类没有的会退化成 "tasks",导致写裸
SQL 的查询在测试里查无此表。现已对齐 sundynix_ 前缀 + 单数表名。
- graph 的 ::text 换成标准 cast(... as text):前者是 Postgres 专有,
换掉后这条查询才能被内存库覆盖。
新增 5 组测试,其中一组专门先证明"租户过滤在测试环境里确实开着"——否则
"跨租户能读到"的断言可能只是因为插件没装,属于假过。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -73,3 +73,60 @@ func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminTaskDetail 是管理端任务下钻:基本信息 + 持久化的输出/轨迹 + 评测。
|
||||
// 轨迹与输出取自 sundynix_task 的收尾落库列(Redis 流只有 10min TTL,历史任务只能靠它们)。
|
||||
type AdminTaskDetail struct {
|
||||
AdminTaskRow
|
||||
Graph string `json:"graph"` // 提交时的 DSL 原文
|
||||
Output string `json:"output"` // 最终模型输出
|
||||
Trace string `json:"trace"` // 执行轨迹事件 JSON 数组(原文透传,前端解析)
|
||||
// gorm:"-":这是查完再单独填的组合字段,不是关联;不标记的话 Scan 会当成关系报错。
|
||||
Eval *AdminEval `gorm:"-" json:"eval"` // 无评测时为 null
|
||||
}
|
||||
|
||||
// AdminEval 是下钻里的评测明细(比列表页的 level/overall 多出评语与命中项)。
|
||||
type AdminEval struct {
|
||||
Overall float64 `json:"overall"`
|
||||
Rule float64 `json:"rule"`
|
||||
LLM float64 `json:"llm"`
|
||||
Faithful float64 `json:"faithful"`
|
||||
Level string `json:"level"`
|
||||
Flags string `json:"flags"`
|
||||
Reason string `json:"reason"`
|
||||
Sources int `json:"sources"`
|
||||
Corrected bool `json:"corrected"`
|
||||
}
|
||||
|
||||
// TaskDetail 按 task_id 取单条任务的完整下钻数据(管理端,跨租户)。
|
||||
// 必须 WithoutTenant:Task/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务
|
||||
// 不会报错,而是静默返回空——看起来像"这任务没产出",比报错更难排查。
|
||||
func (p *Postgres) TaskDetail(ctx context.Context, taskID string) *AdminTaskDetail {
|
||||
if p.db == nil || taskID == "" {
|
||||
return nil
|
||||
}
|
||||
ctx = WithoutTenant(ctx)
|
||||
var d AdminTaskDetail
|
||||
err := p.db.WithContext(ctx).Table("sundynix_task as t").
|
||||
Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, "+
|
||||
"t.output, t.trace, coalesce(cast(t.graph as text),'') as graph, "+
|
||||
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, "+
|
||||
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, "+
|
||||
"coalesce(t.graph->>'topic','') as topic").
|
||||
Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
|
||||
Joins("left join sundynix_user u on u.id = t.owner").
|
||||
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
||||
Where("t.task_id = ? and t.deleted_at is null", taskID).
|
||||
Scan(&d).Error
|
||||
if err != nil || d.TaskID == "" {
|
||||
return nil
|
||||
}
|
||||
var e Eval
|
||||
if p.db.WithContext(ctx).Where("task_id = ?", taskID).First(&e).Error == nil {
|
||||
d.Eval = &AdminEval{
|
||||
Overall: e.Overall, Rule: e.Rule, LLM: e.LLM, Faithful: e.Faithful,
|
||||
Level: e.Level, Flags: e.Flags, Reason: e.Reason, Sources: e.Sources, Corrected: e.Corrected,
|
||||
}
|
||||
}
|
||||
return &d
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user