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(总分流图 + 智能排程流转图)并补充决策文档

- ⚠️ 新增“连续微调复用”链路我尚未完成测试,且文档状态目前较为混乱,待连续对话微调功能真正测试完成后再统一更新
This commit is contained in:
Losita
2026-03-21 22:08:35 +08:00
parent 059b25872a
commit f3f9902e93
32 changed files with 3877 additions and 698 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
@@ -14,25 +15,29 @@ import (
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// ── plan 节点模型输出结构 ──
// schedulePlanIntentOutput 是 plan 节点要求模型返回的结构化结果。
//
// 兼容说明:
// 1. 新主语义是 task_class_ids数组
// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id单值兜底解析
// 3. TaskTags 的 key 兼容两种写法:
// 3.1 推荐task_item_id例如 "12"
// 3.2 兼容:任务名称(例如 "高数复习")。
type schedulePlanIntentOutput struct {
Intent string `json:"intent"`
Constraints []string `json:"constraints"`
TaskClassID int `json:"task_class_id"`
Strategy string `json:"strategy"`
Intent string `json:"intent"`
Constraints []string `json:"constraints"`
TaskClassIDs []int `json:"task_class_ids"`
TaskClassID int `json:"task_class_id"`
Strategy string `json:"strategy"`
TaskTags map[string]string `json:"task_tags"`
}
// ══════════════════════════════════════════════════════════════
// plan 节点
// ══════════════════════════════════════════════════════════════
// runPlanNode 负责"意图识别 + 约束提取"。
// runPlanNode 负责“识别排程意图 + 提取约束 + 收敛任务类 ID”。
//
// 职责边界:
// 1) 从用户消息中提取排程意图、约束条件、策略
// 2) task_class_id 优先从 Extra 字段获取,模型推断作为兜底
// 3) 不负责调用粗排算法,只做意图分析
// 1. 负责把用户自然语言和 extra 参数收敛为统一状态
// 2. 负责输出后续节点需要的最小上下文TaskClassIDs/约束/策略/标签)
// 3. 不负责调用粗排算法,不负责写库
func runPlanNode(
ctx context.Context,
st *SchedulePlanState,
@@ -46,62 +51,80 @@ func runPlanNode(
return nil, errors.New("schedule plan graph: nil state in plan node")
}
emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求...")
emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求")
// 1. 优先从 Extra 字段获取 task_class_id避免依赖模型推断。
// 1. 先收敛 extra 中显式传入的任务类 ID优先级高于模型推断
// 1.1 先读 task_class_ids 数组;
// 1.2 再兼容读取单值 task_class_id
// 1.3 最后统一做过滤 + 去重,防止非法值或重复值污染状态机。
if extra != nil {
if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 {
st.TaskClassID = tcID
mergedIDs := make([]int, 0, len(st.TaskClassIDs)+2)
mergedIDs = append(mergedIDs, st.TaskClassIDs...)
if tcIDs, ok := ExtraIntSlice(extra, "task_class_ids"); ok {
mergedIDs = append(mergedIDs, tcIDs...)
}
if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 {
mergedIDs = append(mergedIDs, tcID)
}
st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs)
}
// 1.4 若本轮请求没带 task_class_ids但会话里存在上一次排程快照则用快照中的任务类兜底。
// 1.4.1 这样用户可以直接说“把周三晚上的高数挪到周五”,无需每轮都重复传任务类集合;
// 1.4.2 失败兜底:若快照也没有任务类,后续按原逻辑处理(可能提前退出并提示补参)。
if len(st.TaskClassIDs) == 0 && len(st.PreviousTaskClassIDs) > 0 {
st.TaskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...))
}
// 2. 检查对话历史中是否包含上版排程方案(用于连续对话微调
// 2. 识别“是否为连续对话微调”场景
// 2.1 只做历史探测,不做历史改写;
// 2.2 探测失败不影响主链路,只是少一个 prompt hint。
if st.HasPreviousPreview && len(st.PreviousHybridEntries) > 0 {
st.IsAdjustment = true
}
previousPlan := extractPreviousPlanFromHistory(chatHistory)
if previousPlan != "" {
st.PreviousPlanJSON = previousPlan
st.IsAdjustment = true
}
// 3. 构造 prompt 让模型分析意图和约束
// 3. 组装模型提示词
adjustmentHint := ""
if st.IsAdjustment {
adjustmentHint = "\n注意这是对已有排程的微调请求。用户可能只想调整部分内容(如'早八不想学习'),请只提取变更部分的约束。"
adjustmentHint = "\n注意这是对已有排程的微调请求,请重点抽取本次新增或变更的约束。"
}
prompt := fmt.Sprintf(`当前时间(北京时间):%s
用户输入:%s%s
请分析用户的排程意图并提取约束条件。`,
prompt := fmt.Sprintf(
"当前时间(北京时间):%s\n用户输入%s%s\n\n请提取排程意图与约束。",
st.RequestNowText,
strings.TrimSpace(userMessage),
adjustmentHint,
)
// 3.1 模型调用失败时保守处理:只要有 task_class_id 就继续,否则报错
// 4. 调模型拿结构化输出
// 4.1 如果失败但已经有 TaskClassIDs则降级继续
// 4.2 如果失败且没有任务类 ID直接给出可执行错误提示。
raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanIntentPrompt, prompt, 256)
if callErr != nil {
if st.TaskClassID > 0 {
// 有 task_class_id 就可以继续,意图用兜底值。
if len(st.TaskClassIDs) > 0 {
st.UserIntent = strings.TrimSpace(userMessage)
emitStage("schedule_plan.plan.fallback", "意图分析失败,使用默认配置继续。")
emitStage("schedule_plan.plan.fallback", "意图识别失败,使用请求参数兜底继续。")
return st, nil
}
st.FinalSummary = "抱歉,我没能理解你的排程需求,请再描述一下或直接传入任务类 ID。"
st.FinalSummary = "抱歉,我没拿到有效的任务类信息。请在请求中传入 task_class_ids。"
return st, nil
}
// 3.2 解析模型输出。
parsed, parseErr := parseScheduleJSON[schedulePlanIntentOutput](raw)
if parseErr != nil {
if st.TaskClassID > 0 {
if len(st.TaskClassIDs) > 0 {
st.UserIntent = strings.TrimSpace(userMessage)
emitStage("schedule_plan.plan.fallback", "模型返回解析失败,已使用请求参数兜底继续。")
return st, nil
}
st.FinalSummary = "抱歉,我没能解析排程意图,请再试一次。"
st.FinalSummary = "抱歉,我没能解析排程意图。请重试,或直接传入 task_class_ids。"
return st, nil
}
// 4. 回填状态
// 5. 回填基础字段
st.UserIntent = strings.TrimSpace(parsed.Intent)
if st.UserIntent == "" {
st.UserIntent = strings.TrimSpace(userMessage)
@@ -109,73 +132,57 @@ func runPlanNode(
if len(parsed.Constraints) > 0 {
st.Constraints = parsed.Constraints
}
if st.TaskClassID <= 0 && parsed.TaskClassID > 0 {
st.TaskClassID = parsed.TaskClassID
}
if parsed.Strategy == "rapid" {
if strings.EqualFold(strings.TrimSpace(parsed.Strategy), "rapid") {
st.Strategy = "rapid"
}
emitStage("schedule_plan.plan.done", fmt.Sprintf("已理解排程意图:%s", st.UserIntent))
// 6. 合并任务类 ID新字段 + 旧字段双兼容)。
// 6.1 先拼接已有值与模型输出;
// 6.2 再统一清洗,保证后续节点使用稳定语义。
mergedIDs := make([]int, 0, len(st.TaskClassIDs)+len(parsed.TaskClassIDs)+1)
mergedIDs = append(mergedIDs, st.TaskClassIDs...)
mergedIDs = append(mergedIDs, parsed.TaskClassIDs...)
if parsed.TaskClassID > 0 {
mergedIDs = append(mergedIDs, parsed.TaskClassID)
}
st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs)
// 7. 回填任务标签映射(给 daily_split 注入 context_tag 用)。
// 7.1 TaskTags按 task_item_id优先
// 7.2 无法转成 ID 的 key 先存到 TaskTagHintsByName等 roughBuild 阶段再映射;
// 7.3 单条标签解析失败不影响主流程。
if st.TaskTags == nil {
st.TaskTags = make(map[int]string)
}
if st.TaskTagHintsByName == nil {
st.TaskTagHintsByName = make(map[string]string)
}
for rawKey, rawTag := range parsed.TaskTags {
tag := normalizeContextTag(rawTag)
key := strings.TrimSpace(rawKey)
if key == "" {
continue
}
if id, convErr := strconv.Atoi(key); convErr == nil && id > 0 {
st.TaskTags[id] = tag
continue
}
st.TaskTagHintsByName[key] = tag
}
emitStage(
"schedule_plan.plan.done",
fmt.Sprintf("已识别排程意图,任务类数量=%d。", len(st.TaskClassIDs)),
)
return st, nil
}
// ══════════════════════════════════════════════════════════════
// preview 节点
// ══════════════════════════════════════════════════════════════
// runPreviewNode 负责调用粗排算法生成候选方案。
//
// 职责边界:
// 1) 调用 SmartPlanningRaw 服务,同时获取展示结构和已分配的任务项;
// 2) 展示结构供 SSE 阶段推送给前端预览;
// 3) 已分配的任务项供 materialize 节点直接转换为落库请求,无需模型介入。
func runPreviewNode(
ctx context.Context,
st *SchedulePlanState,
deps SchedulePlanToolDeps,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in preview node")
}
// 1. 校验 task_class_id 必须有效。
if st.TaskClassID <= 0 {
st.FinalSummary = "缺少任务类 ID无法生成排程方案。请在请求中传入 task_class_id。"
return st, nil
}
emitStage("schedule_plan.preview.generating", "正在调用排程算法生成候选方案...")
// 2. 调用粗排服务,同时拿到展示结构和已分配的任务项。
displayPlans, allocatedItems, err := deps.SmartPlanningRaw(ctx, st.UserID, st.TaskClassID)
if err != nil {
st.FinalSummary = fmt.Sprintf("排程算法执行失败:%s。请检查任务类配置是否正确。", err.Error())
return st, nil
}
if len(allocatedItems) == 0 {
st.FinalSummary = "排程算法未找到可用时间槽,可能是课表已排满或任务类时间范围内无空闲。"
return st, nil
}
st.CandidatePlans = displayPlans
st.AllocatedItems = allocatedItems
emitStage("schedule_plan.preview.done", fmt.Sprintf("已生成候选方案,共 %d 个任务项已分配。", len(allocatedItems)))
return st, nil
}
// ══════════════════════════════════════════════════════════════
// 分支决策函数
// ══════════════════════════════════════════════════════════════
// selectNextAfterPlan 根据 plan 节点结果决定下一步。
//
// 分支规则:
// 1) FinalSummary 非空 -> exitplan 阶段已确定无法继续)
// 2) TaskClassID 无效 -> exit
// 3) 其余 -> preview
// 1. 如果 FinalSummary 已经有内容,说明已确定要提前退出 -> exit
// 2. 如果任务类为空,说明无法继续构建方案 -> exit
// 3. 其余情况 -> roughBuild。
func selectNextAfterPlan(st *SchedulePlanState) string {
if st == nil {
return schedulePlanGraphNodeExit
@@ -183,21 +190,174 @@ func selectNextAfterPlan(st *SchedulePlanState) string {
if strings.TrimSpace(st.FinalSummary) != "" {
return schedulePlanGraphNodeExit
}
if st.TaskClassID <= 0 {
if len(st.TaskClassIDs) == 0 {
return schedulePlanGraphNodeExit
}
return schedulePlanGraphNodePreview
return schedulePlanGraphNodeRoughBuild
}
// ══════════════════════════════════════════════════════════════
// 工具函数
// ══════════════════════════════════════════════════════════════
// runRoughBuildNode 负责“一次性完成粗排结果构建”。
//
// 职责边界:
// 1. 调用多任务类混排能力,生成 HybridEntries + AllocatedItems
// 2. 把 HybridEntries 转成 CandidatePlans便于后续预览输出
// 3. 不做 daily/weekly 优化本身,只提供下游输入。
func runRoughBuildNode(
ctx context.Context,
st *SchedulePlanState,
deps SchedulePlanToolDeps,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in roughBuild node")
}
if deps.HybridScheduleWithPlanMulti == nil {
return nil, errors.New("schedule plan graph: HybridScheduleWithPlanMulti dependency not injected")
}
// callScheduleModelForJSON 调用模型并期望返回 JSON 结果
// 1. 清洗并校验任务类 ID
// 1.1 统一在节点入口做一次最终收敛,避免上游遗漏导致语义漂移;
// 1.2 若最终仍为空,直接结束,避免无意义调用下游服务。
taskClassIDs := normalizeTaskClassIDs(st.TaskClassIDs)
// 1.3 连续对话兜底:若本轮任务类为空且命中历史快照,则回退到上轮任务类集合。
if len(taskClassIDs) == 0 && st.IsAdjustment && len(st.PreviousTaskClassIDs) > 0 {
taskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...))
}
if len(taskClassIDs) == 0 {
st.FinalSummary = "缺少有效的任务类 ID无法生成排程方案。请传入 task_class_ids。"
return st, nil
}
st.TaskClassIDs = taskClassIDs
// 2. 连续对话微调优先复用上一版混合日程作为起点,避免“每轮都重新粗排”。
// 2.1 触发条件IsAdjustment=true 且 PreviousHybridEntries 非空;
// 2.2 失败兜底:若快照不完整(例如 AllocatedItems 为空),会构造最小占位任务块,保持下游校验可运行;
// 2.3 回退策略:若没有可复用快照,再走全量粗排构建路径。
canReusePreviousPlan := st.IsAdjustment &&
len(st.PreviousHybridEntries) > 0 &&
sameTaskClassSet(taskClassIDs, st.PreviousTaskClassIDs)
if canReusePreviousPlan {
emitStage("schedule_plan.rough_build.reuse_previous", "检测到连续对话微调,复用上一版排程作为优化起点。")
st.HybridEntries = deepCopyEntries(st.PreviousHybridEntries)
st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
st.AllocatedItems = deepCopyTaskClassItems(st.PreviousAllocatedItems)
if len(st.AllocatedItems) == 0 {
st.AllocatedItems = buildAllocatedItemsFromHybridEntries(st.HybridEntries)
}
// 2.2 复用模式下同样尝试解析窗口边界,保证周级 Move 约束仍然有效。
if deps.ResolvePlanningWindow != nil {
startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs)
if windowErr != nil {
st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error())
return st, nil
}
st.HasPlanningWindow = true
st.PlanStartWeek = startWeek
st.PlanStartDay = startDay
st.PlanEndWeek = endWeek
st.PlanEndDay = endDay
}
st.MergeSnapshot = deepCopyEntries(st.HybridEntries)
suggestedCount := 0
for _, e := range st.HybridEntries {
if e.Status == "suggested" {
suggestedCount++
}
}
emitStage(
"schedule_plan.rough_build.done",
fmt.Sprintf("已复用历史方案,条目总数=%d可优化条目=%d。", len(st.HybridEntries), suggestedCount),
)
return st, nil
}
emitStage("schedule_plan.rough_build.building", "正在构建粗排候选方案。")
// 3. 调用服务层统一能力构建混合日程。
// 3.1 该能力内部会完成“多任务类粗排 + 既有日程合并”;
// 3.2 这里不再拆成 preview/hybrid 两段,避免跨节点重复计算。
entries, allocatedItems, err := deps.HybridScheduleWithPlanMulti(ctx, st.UserID, taskClassIDs)
if err != nil {
st.FinalSummary = fmt.Sprintf("构建粗排方案失败:%s。", err.Error())
return st, nil
}
if len(entries) == 0 {
st.FinalSummary = "没有生成可优化的排程条目,请检查任务类时间范围或课表占用。"
return st, nil
}
// 4. 回填状态。
st.HybridEntries = entries
st.AllocatedItems = allocatedItems
st.CandidatePlans = hybridEntriesToWeekSchedules(entries)
// 4.1 解析全局排程窗口(可选依赖)。
// 4.1.1 目的:给周级 Move 增加“首尾不足一周”的硬边界校验;
// 4.1.2 失败策略:若依赖已注入但解析失败,直接结束本次排程,避免带着错误窗口继续优化。
if deps.ResolvePlanningWindow != nil {
startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs)
if windowErr != nil {
st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error())
return st, nil
}
st.HasPlanningWindow = true
st.PlanStartWeek = startWeek
st.PlanStartDay = startDay
st.PlanEndWeek = endWeek
st.PlanEndDay = endDay
}
// 4.2 记录 merge 快照:
// 4.2.1 单任务类路径可直接作为 final_check 回退基线;
// 4.2.2 多任务类路径后续 merge 节点会覆盖成“日内优化后快照”。
st.MergeSnapshot = deepCopyEntries(entries)
// 5. 把“按名称提示的标签”尽可能映射到 task_item_id。
// 5.1 目的:后续 daily_split 统一按 task_item_id 维度写入 context_tag
// 5.2 失败策略:映射不上不报错,后续默认走 General 标签。
if st.TaskTags == nil {
st.TaskTags = make(map[int]string)
}
if len(st.TaskTagHintsByName) > 0 {
for i := range st.HybridEntries {
entry := &st.HybridEntries[i]
if entry.Status != "suggested" || entry.TaskItemID <= 0 {
continue
}
if _, exists := st.TaskTags[entry.TaskItemID]; exists {
continue
}
if tag, ok := st.TaskTagHintsByName[entry.Name]; ok {
st.TaskTags[entry.TaskItemID] = normalizeContextTag(tag)
}
}
}
suggestedCount := 0
for _, e := range entries {
if e.Status == "suggested" {
suggestedCount++
}
}
emitStage(
"schedule_plan.rough_build.done",
fmt.Sprintf("粗排构建完成,条目总数=%d可优化条目=%d。", len(entries), suggestedCount),
)
return st, nil
}
// callScheduleModelForJSON 调用模型并要求返回 JSON。
//
// 职责边界:
// 1. 仅负责模型调用参数装配,不做业务字段解释;
// 2. 统一关闭 thinking减少路由/抽取场景的延迟和 token 开销。
func callScheduleModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
if chatModel == nil {
return "", errors.New("schedule plan: model is nil")
}
messages := []*schema.Message{
schema.SystemMessage(systemPrompt),
schema.UserMessage(userPrompt),
@@ -225,14 +385,16 @@ func callScheduleModelForJSON(ctx context.Context, chatModel *ark.ChatModel, sys
}
// parseScheduleJSON 解析模型返回的 JSON 内容。
// 兼容 ```json ... ``` 包裹和额外文本。
//
// 兼容策略:
// 1. 兼容 ```json ... ``` 包裹;
// 2. 兼容模型在 JSON 前后带解释文本(提取最外层对象)。
func parseScheduleJSON[T any](raw string) (*T, error) {
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, errors.New("empty response")
}
// 兼容 ```json ... ``` 包裹。
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
@@ -245,7 +407,6 @@ func parseScheduleJSON[T any](raw string) (*T, error) {
return &out, nil
}
// 提取最外层 JSON 对象。
start := strings.Index(clean, "{")
end := strings.LastIndex(clean, "}")
if start == -1 || end == -1 || end <= start {
@@ -258,16 +419,11 @@ func parseScheduleJSON[T any](raw string) (*T, error) {
return &out, nil
}
// extractPreviousPlanFromHistory 从对话历史中提取上版排程方案
//
// 策略:
// 在助手消息中查找包含"排程完成"标记的最近一条,提取其中的方案信息。
// 当前版本使用简单的文本匹配,后续可升级为结构化存储。
// extractPreviousPlanFromHistory 从对话历史中提取最近一次排程结果文本
func extractPreviousPlanFromHistory(history []*schema.Message) string {
if len(history) == 0 {
return ""
}
// 从后往前遍历,找最近的排程成功消息。
for i := len(history) - 1; i >= 0; i-- {
msg := history[i]
if msg == nil || msg.Role != schema.Assistant {
@@ -281,82 +437,26 @@ func extractPreviousPlanFromHistory(history []*schema.Message) string {
return ""
}
// ══════════════════════════════════════════════════════════════
// hybridBuild 节点
// ══════════════════════════════════════════════════════════════
// runHybridBuildNode 负责构建"混合日程":将既有日程与粗排建议合并。
// runReturnPreviewNode 负责把优化后的 HybridEntries 转成“前端可直接展示”的预览结构。
//
// 职责边界:
// 1) 调用 HybridScheduleWithPlan 服务方法
// 2) 将结果写入 State.HybridEntries供 ReAct 精排节点操作
// 3) 同时保留 AllocatedItems供后续可能的落库使用。
func runHybridBuildNode(
ctx context.Context,
st *SchedulePlanState,
deps SchedulePlanToolDeps,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in hybridBuild node")
}
if deps.HybridScheduleWithPlan == nil {
return nil, errors.New("schedule plan graph: HybridScheduleWithPlan dependency not injected")
}
if st.TaskClassID <= 0 {
st.FinalSummary = "缺少任务类 ID无法构建混合日程。"
return st, nil
}
emitStage("schedule_plan.hybrid.building", "正在构建混合日程...")
entries, allocatedItems, err := deps.HybridScheduleWithPlan(ctx, st.UserID, st.TaskClassID)
if err != nil {
st.FinalSummary = fmt.Sprintf("构建混合日程失败:%s", err.Error())
return st, nil
}
if len(entries) == 0 {
st.FinalSummary = "混合日程为空,无可优化内容。"
return st, nil
}
st.HybridEntries = entries
st.AllocatedItems = allocatedItems
suggestedCount := 0
for _, e := range entries {
if e.Status == "suggested" {
suggestedCount++
}
}
emitStage("schedule_plan.hybrid.done", fmt.Sprintf("混合日程已构建,共 %d 个条目(%d 个可优化)。", len(entries), suggestedCount))
return st, nil
}
// ══════════════════════════════════════════════════════════════
// returnPreview 节点
// ══════════════════════════════════════════════════════════════
// runReturnPreviewNode 负责将 ReAct 优化后的混合日程转为前端预览格式。
//
// 职责边界:
// 1) 从 HybridEntries 中提取最终排程结果;
// 2) 转换为 []UserWeekSchedule 格式(复用 sectionTimeMap
// 3) 设置 FinalSummary 为 ReAct 的优化摘要;
// 4) 不落库——用户需确认后再走落库链路。
// 1. 把 suggested 结果回填到 AllocatedItems便于后续确认后直接落库
// 2. 生成 CandidatePlans
// 3. 生成最终文案;
// 4. 不执行实际写库。
func runReturnPreviewNode(
ctx context.Context,
st *SchedulePlanState,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
_ = ctx
if st == nil {
return nil, errors.New("schedule plan graph: nil state in returnPreview node")
}
emitStage("schedule_plan.preview_return.building", "正在生成优化后的排程预览...")
emitStage("schedule_plan.preview_return.building", "正在生成优化后的排程预览")
// 1. HybridEntries 中 suggested 的任务回写到 AllocatedItems 的 EmbeddedTime
// 这样后续如果用户确认,可以直接走 materialize → apply 落库。
// 1. HybridEntries 中 suggested 的最终位置回填到 AllocatedItems。
suggestedMap := make(map[int]*model.HybridScheduleEntry)
for i := range st.HybridEntries {
e := &st.HybridEntries[i]
@@ -374,24 +474,162 @@ func runReturnPreviewNode(
}
}
// 2. 将 HybridEntries 转为 CandidatePlans[]UserWeekSchedule供前端展示
// 2. 生成前端预览结构
st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
// 3. 设置最终摘要
if strings.TrimSpace(st.ReactSummary) != "" {
st.FinalSummary = st.ReactSummary
} else {
st.FinalSummary = fmt.Sprintf("排程优化完成,共 %d 个任务已安排。请确认后落库。", len(suggestedMap))
// 3. 生成最终摘要
// 3.1 优先保留 final_check 的输出;
// 3.2 若没有 final_check 输出,则回退 weekly refine 摘要;
// 3.3 都没有时给兜底文案。
if strings.TrimSpace(st.FinalSummary) == "" {
if strings.TrimSpace(st.ReactSummary) != "" {
st.FinalSummary = st.ReactSummary
} else {
st.FinalSummary = fmt.Sprintf("排程优化完成,共 %d 个任务已安排,请确认后应用。", len(suggestedMap))
}
}
st.Completed = true
emitStage("schedule_plan.preview_return.done", "排程预览已生成,等待确认。")
emitStage("schedule_plan.preview_return.done", "排程预览已生成,等待确认。")
return st, nil
}
// hybridEntriesToWeekSchedules 将混合日程条目转为前端展示格式
// buildAllocatedItemsFromHybridEntries 根据 suggested 条目构造最小可用的任务块快照
//
// 设计目的:
// 1. 连续微调复用历史方案时,若缓存里没有 AllocatedItems仍然保证 final_check 的数量核对可运行;
// 2. return_preview 仍可依据 TaskItemID 回填最终 embedded_time
// 3. 该函数只做“兜底构造”,不替代真实粗排输出。
func buildAllocatedItemsFromHybridEntries(entries []model.HybridScheduleEntry) []model.TaskClassItem {
if len(entries) == 0 {
return nil
}
items := make([]model.TaskClassItem, 0)
for _, entry := range entries {
if entry.Status != "suggested" {
continue
}
embedded := &model.TargetTime{
Week: entry.Week,
DayOfWeek: entry.DayOfWeek,
SectionFrom: entry.SectionFrom,
SectionTo: entry.SectionTo,
}
taskID := entry.TaskItemID
items = append(items, model.TaskClassItem{
ID: taskID,
EmbeddedTime: embedded,
})
}
return items
}
// deepCopyTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨节点共享引用。
func deepCopyTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
if len(src) == 0 {
return nil
}
dst := make([]model.TaskClassItem, 0, len(src))
for _, item := range src {
copied := item
if item.CategoryID != nil {
v := *item.CategoryID
copied.CategoryID = &v
}
if item.Order != nil {
v := *item.Order
copied.Order = &v
}
if item.Content != nil {
v := *item.Content
copied.Content = &v
}
if item.Status != nil {
v := *item.Status
copied.Status = &v
}
if item.EmbeddedTime != nil {
t := *item.EmbeddedTime
copied.EmbeddedTime = &t
}
dst = append(dst, copied)
}
return dst
}
// normalizeContextTag 归一化任务标签。
//
// 失败兜底:
// 1. 未识别/空值统一回落到 General
// 2. 保证后续 prompt 构造不会出现空标签。
func normalizeContextTag(raw string) string {
tag := strings.TrimSpace(raw)
if tag == "" {
return "General"
}
switch strings.ToLower(tag) {
case "high-logic", "high_logic", "logic":
return "High-Logic"
case "memory":
return "Memory"
case "review":
return "Review"
case "general":
return "General"
default:
return "General"
}
}
// normalizeTaskClassIDs 清洗 task_class_ids去重 + 过滤非法值)。
func normalizeTaskClassIDs(ids []int) []int {
if len(ids) == 0 {
return nil
}
seen := make(map[int]struct{}, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// sameTaskClassSet 判断两组 task_class_ids 是否表示同一集合(忽略顺序,忽略重复)。
//
// 语义:
// 1. 两边经清洗后都为空,返回 false空集合不作为“可复用历史方案”的依据
// 2. 元素集合完全一致返回 true
// 3. 任一元素差异返回 false。
func sameTaskClassSet(left []int, right []int) bool {
l := normalizeTaskClassIDs(left)
r := normalizeTaskClassIDs(right)
if len(l) == 0 || len(r) == 0 {
return false
}
if len(l) != len(r) {
return false
}
seen := make(map[int]struct{}, len(l))
for _, id := range l {
seen[id] = struct{}{}
}
for _, id := range r {
if _, ok := seen[id]; !ok {
return false
}
}
return true
}
// hybridEntriesToWeekSchedules 把内存中的混合条目转换成前端周视图格式。
func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule {
// sectionTimeMap 与 conv/schedule.go 保持一致。
sectionTimeMap := map[int][2]string{
1: {"08:00", "08:45"}, 2: {"08:55", "09:40"},
3: {"10:15", "11:00"}, 4: {"11:10", "11:55"},
@@ -401,7 +639,6 @@ func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.U
11: {"20:50", "21:35"}, 12: {"21:45", "22:30"},
}
// 按周分组
weekMap := make(map[int][]model.WeeklyEventBrief)
for _, e := range entries {
startTime := ""
@@ -428,12 +665,13 @@ func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.U
weekMap[e.Week] = append(weekMap[e.Week], brief)
}
// 排序输出
result := make([]model.UserWeekSchedule, 0, len(weekMap))
for w, events := range weekMap {
result = append(result, model.UserWeekSchedule{Week: w, Events: events})
for week, events := range weekMap {
result = append(result, model.UserWeekSchedule{
Week: week,
Events: events,
})
}
// 按周次排序
for i := 0; i < len(result); i++ {
for j := i + 1; j < len(result); j++ {
if result[j].Week < result[i].Week {