Files
smartmate/backend/agent/scheduleplan/final_check.go
Losita f3f9902e93 Version: 0.7.1.dev.260321
feat(agent):  重构智能排程分流与双通道交付,补齐周级预算并接入连续微调复用

- 🔀 通用路由升级为 action 分流(chat/quick_note_create/task_query/schedule_plan),路由失败直接返回内部错误,不再回落聊天
- 🧭 智能排程链路重构:统一图编排与节点职责,完善日级/周级调优协作与提示词约束
- 📊 周级预算改为“有效周保底 + 负载加权分配”,避免有效周零预算并提升资源利用率
- ⚙️ 日级并发优化细化:按天拆分 DayGroup 并发执行,低收益天(suggested<=2)跳过,单天失败仅回退该天结果并继续全局
- 🧵 周级并发优化细化:按周并发 worker 执行,单周“单步动作”循环(每轮仅 1 个 Move/Swap 或 done),失败周保留原方案不影响其它周
- 🛰️ 新增排程预览双通道:聊天主链路输出终审文本,结构化 candidate_plans 通过 /api/v1/agent/schedule-preview 拉取
- 🗃️ 增补 Redis 预览缓存读写与清理逻辑,新增对应 API、路由、模型与错误码支持
- ♻️ 接入连续对话微调复用:命中同会话历史预览时复用上轮 HybridEntries,避免每轮重跑粗排
- 🛡️ 增加复用保护:仅当本轮与上轮 task_class_ids 集合一致才复用;不一致回退全量粗排
- 🧰 扩展预览缓存字段(task_class_ids/hybrid_entries/allocated_items),支撑微调承接链路
- 🗺️ 更新 README 5.4 Mermaid(总分流图 + 智能排程流转图)并补充决策文档

- ⚠️ 新增“连续微调复用”链路我尚未完成测试,且文档状态目前较为混乱,待连续对话微调功能真正测试完成后再统一更新
2026-03-21 22:08:35 +08:00

172 lines
5.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 scheduleplan
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// runFinalCheckNode 负责“终审校验 + 总结生成”。
//
// 职责边界:
// 1. 负责执行物理校验(冲突、节次越界、数量核对);
// 2. 负责在校验失败时回退到 MergeSnapshot
// 3. 负责生成最终给用户看的自然语言总结;
// 4. 不负责写库(本期只做预览)。
func runFinalCheckNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, fmt.Errorf("schedule plan final check: nil state")
}
emitStage("schedule_plan.final_check.start", "正在进行终审校验。")
// 1. 先做物理校验。
issues := physicsCheck(st)
if len(issues) > 0 {
emitStage("schedule_plan.final_check.issues", fmt.Sprintf("发现 %d 个问题,已回退到日内优化结果。", len(issues)))
// 1.1 回退策略:
// 1.1.1 优先回退到 merge 快照(已经过冲突校验);
// 1.1.2 若快照为空,保留当前结果继续走总结,保证可返回。
if len(st.MergeSnapshot) > 0 {
st.HybridEntries = deepCopyEntries(st.MergeSnapshot)
}
}
// 2. 生成人性化总结。
//
// 2.1 总结失败不影响主流程;
// 2.2 失败时使用兜底文案,保证前端始终有可展示文本。
summary, err := generateHumanSummary(ctx, chatModel, st.HybridEntries, st.Constraints, st.WeeklyActionLogs)
if err != nil || strings.TrimSpace(summary) == "" {
st.FinalSummary = fmt.Sprintf("排程优化完成,共安排了 %d 个任务。", countSuggested(st.HybridEntries))
} else {
st.FinalSummary = strings.TrimSpace(summary)
}
emitStage("schedule_plan.final_check.done", "终审校验完成。")
return st, nil
}
// physicsCheck 执行物理层面校验。
//
// 校验项:
// 1. 时间冲突:同一 slot 不允许多任务占用;
// 2. 节次越界section 必须落在 1..12 且 from<=to
// 3. 数量核对suggested 数量应与原始 AllocatedItems 数量一致。
func physicsCheck(st *SchedulePlanState) []string {
issues := make([]string, 0)
if st == nil {
return append(issues, "state 为空")
}
// 1. 时间冲突校验。
if conflict := detectConflicts(st.HybridEntries); conflict != "" {
issues = append(issues, "时间冲突:"+conflict)
}
// 2. 节次越界校验。
for _, entry := range st.HybridEntries {
if entry.SectionFrom < 1 || entry.SectionTo > 12 || entry.SectionFrom > entry.SectionTo {
issues = append(
issues,
fmt.Sprintf("节次越界:[%s] W%dD%d 第%d-%d节", entry.Name, entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo),
)
}
}
// 3. 数量一致性校验。
// 3.1 判断依据suggested 表示“待应用任务块”,应与 allocatedItems 数量匹配;
// 3.2 若不匹配,可能表示工具调用丢失或重复覆盖。
suggestedCount := countSuggested(st.HybridEntries)
if suggestedCount != len(st.AllocatedItems) {
issues = append(
issues,
fmt.Sprintf("任务数量不匹配suggested=%d原始分配=%d", suggestedCount, len(st.AllocatedItems)),
)
}
return issues
}
// countSuggested 统计 suggested 条目数量。
func countSuggested(entries []model.HybridScheduleEntry) int {
count := 0
for _, entry := range entries {
if entry.Status == "suggested" {
count++
}
}
return count
}
// generateHumanSummary 调用模型生成“用户可读”的总结文案。
//
// 职责边界:
// 1. 只做读模型,不修改任何 state
// 2. 输出纯文本;
// 3. 失败时把错误返回给上层,由上层决定兜底文案。
func generateHumanSummary(
ctx context.Context,
chatModel *ark.ChatModel,
entries []model.HybridScheduleEntry,
constraints []string,
actionLogs []string,
) (string, error) {
if chatModel == nil {
return "", fmt.Errorf("final summary model is nil")
}
entriesJSON, _ := json.Marshal(entries)
constraintText := "无"
if len(constraints) > 0 {
constraintText = strings.Join(constraints, "、")
}
actionLogText := "无"
if len(actionLogs) > 0 {
// 1. 只取最后 30 条动作日志,避免上下文无限膨胀。
// 2. 周级优化是“渐进式动作链”,取尾部更能体现最终收敛过程。
// 3. 这里仅做展示收敛,不改原日志,保证调试信息完整保留在 state 中。
start := 0
if len(actionLogs) > 30 {
start = len(actionLogs) - 30
}
actionLogText = strings.Join(actionLogs[start:], "\n")
}
userPrompt := fmt.Sprintf(
"以下是最终排程方案JSON\n%s\n\n用户约束%s\n\n以下是本次周级优化动作日志按时间顺序\n%s\n\n请基于“结果+过程”输出2-3句自然中文总结重点说明本方案的优点和改进点。",
string(entriesJSON),
constraintText,
actionLogText,
)
resp, err := chatModel.Generate(
ctx,
[]*schema.Message{
schema.SystemMessage(SchedulePlanFinalCheckPrompt),
schema.UserMessage(userPrompt),
},
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0.4),
einoModel.WithMaxTokens(256),
)
if err != nil {
return "", err
}
if resp == nil {
return "", fmt.Errorf("final summary response is nil")
}
return strings.TrimSpace(resp.Content), nil
}