From f3f9902e93de3b7d90b96eaf9c1dd1d2b94a1cd8 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Sat, 21 Mar 2026 22:08:35 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.7.1.dev.260321=20feat(agent):=20?= =?UTF-8?q?=E2=9C=A8=20=E9=87=8D=E6=9E=84=E6=99=BA=E8=83=BD=E6=8E=92?= =?UTF-8?q?=E7=A8=8B=E5=88=86=E6=B5=81=E4=B8=8E=E5=8F=8C=E9=80=9A=E9=81=93?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=EF=BC=8C=E8=A1=A5=E9=BD=90=E5=91=A8=E7=BA=A7?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E5=B9=B6=E6=8E=A5=E5=85=A5=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E5=BE=AE=E8=B0=83=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🔀 通用路由升级为 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(总分流图 + 智能排程流转图)并补充决策文档 - ⚠️ 新增“连续微调复用”链路我尚未完成测试,且文档状态目前较为混乱,待连续对话微调功能真正测试完成后再统一更新 --- README.md | 101 +- backend/agent/route/route.go | 69 +- backend/agent/scheduleplan/daily_refine.go | 315 ++++++ backend/agent/scheduleplan/daily_split.go | 93 ++ backend/agent/scheduleplan/final_check.go | 171 ++++ backend/agent/scheduleplan/graph.go | 151 +-- backend/agent/scheduleplan/merge.go | 86 ++ backend/agent/scheduleplan/nodes.go | 610 +++++++---- backend/agent/scheduleplan/prompt.go | 173 +++- backend/agent/scheduleplan/react.go | 946 +++++++++++++++--- backend/agent/scheduleplan/runner.go | 93 +- backend/agent/scheduleplan/state.go | 168 +++- backend/agent/scheduleplan/tool.go | 122 ++- backend/agent/scheduleplan/tools_react.go | 122 +++ backend/api/agent.go | 46 +- backend/api/schedule.go | 35 +- backend/config.example.yaml | 2 + backend/dao/agent-cache.go | 70 +- backend/dao/task-class.go | 46 + backend/logic/smart_planning.go | 159 ++- backend/model/agent.go | 46 + backend/model/schedule.go | 29 + backend/respond/respond.go | 10 + backend/routers/routers.go | 2 + backend/service/agent_bridge.go | 5 +- backend/service/agentsvc/agent.go | 27 +- backend/service/agentsvc/agent_quick_note.go | 3 + backend/service/agentsvc/agent_route.go | 2 +- .../service/agentsvc/agent_schedule_plan.go | 91 +- .../agentsvc/agent_schedule_preview.go | 162 +++ backend/service/schedule.go | 429 ++++++-- .../智能排程ReAct精排引擎_决策记录.md | 191 ++++ 32 files changed, 3877 insertions(+), 698 deletions(-) create mode 100644 backend/agent/scheduleplan/daily_refine.go create mode 100644 backend/agent/scheduleplan/daily_split.go create mode 100644 backend/agent/scheduleplan/final_check.go create mode 100644 backend/agent/scheduleplan/merge.go create mode 100644 backend/service/agentsvc/agent_schedule_preview.go diff --git a/README.md b/README.md index 8852ee4..9b9ad8f 100644 --- a/README.md +++ b/README.md @@ -356,28 +356,47 @@ $$Gap = \frac{TotalAvailableSlots - (TaskCount \times 2)}{TaskCount + 1}$$ ```mermaid flowchart TD - A["用户消息进入 AgentChat"] --> B["通用控制码分流
action=chat/quick_note_create/task_query"] - B --> C{"路由是否成功解析"} + A["/api/v1/agent/chat
解析请求体 + 规范 conversation_id
Header 写入 X-Conversation-ID"] --> B["AgentService.AgentChat
创建 outChan / errChan"] + B --> C["规范 chat_id + 选择模型(worker/strategist)"] + C --> D["确保会话存在
先查 Redis 状态
未命中回源 DB + 必要时创建"] + D --> E["模型控制码路由
route.DecideActionRouting
action=chat/quick_note_create/task_query/schedule_plan"] + E --> F{"RouteFailed?"} - C -- 否 --> D["兜底普通聊天链路
StreamChat token流式输出"] - C -- 是 --> E{"action 类型"} + F -- "是" --> G["pushErrNonBlocking(errChan, RouteControlInternalError)
API 侧 SSE 输出 error + [DONE]"] + F -- "否" --> H{"action 类型"} - E -- chat --> F["普通聊天链路
StreamChat token流式输出"] + H -- "chat" --> I["runNormalChatFlow
Redis 取历史 -> miss 回源 DB + 回填
裁剪上下文窗口 -> StreamChat 流式输出"] + I --> I2["后置持久化收口
user/assistant 先写 Redis
再 PersistChatHistory(outbox 或同步DB)
异步尝试生成标题"] - E -- quick_note_create --> G["随口记链路
单请求聚合规划 + 本地校验"] - G --> H["写库工具落库
task_id有效校验 + 失败重试"] - H --> I["一次性正文回复"] + H -- "quick_note_create" --> J["发阶段块 request.accepted
tryHandleQuickNoteWithGraph"] + J --> J1{"graph 出错?"} + J1 -- "是" --> J2["记录日志 + 发 fallback 阶段块
回退 runNormalChatFlow"] + J1 -- "否" --> J3{"handled=true?"} + J3 -- "否" --> J2 + J3 -- "是" --> J4["buildQuickNoteFinalReply
emitSingleAssistantCompletion"] + J4 --> J5["persistChatAfterReply
统一后置持久化 + 异步标题"] - E -- task_query --> J["随口问链路
进入 TaskQueryGraph"] - J --> K["plan -> quadrant -> time_anchor"] - K --> L["tool_query 调用 query_tasks"] - L --> M["reflect 判断是否满足
不满足则 patch 重试(<=2)"] - M --> N["后端确定性渲染列表
严格按 limit 输出条数"] + H -- "task_query" --> K["runTaskQueryFlow -> TaskQueryGraph
plan/quadrant/time_anchor/tool_query/reflect"] + K --> K1{"查询链路报错?"} + K1 -- "是" --> K2["记录日志 + 发 fallback 阶段块
回退 runNormalChatFlow"] + K1 -- "否" --> K3["emitSingleAssistantCompletion
persistChatAfterReply + 异步标题"] - D --> Z["后置持久化
Redis + outbox/DB"] - F --> Z - I --> Z - N --> Z + H -- "schedule_plan" --> L["runSchedulePlanFlow -> SchedulePlanGraph
并写入排程预览缓存"] + L --> L1{"排程链路报错?"} + L1 -- "是" --> L2["记录日志 + 发 fallback 阶段块
回退 runNormalChatFlow"] + L1 -- "否" --> L3["emitSingleAssistantCompletion
persistChatAfterReply + 异步标题"] + + H -- "未知 action" --> M["兜底回退 runNormalChatFlow"] + + I2 --> Z["API c.Stream 转发 outChan/errChan
正常收尾或错误收尾"] + J2 --> Z + J5 --> Z + K2 --> Z + K3 --> Z + L2 --> Z + L3 --> Z + M --> Z + G --> Z ``` ### 2) 命中“添加日程/随口记”后的业务流转 @@ -412,7 +431,7 @@ flowchart TD ```mermaid flowchart TD - A["用户消息进入 /agent/chat"] --> B["通用控制码分流
action=chat/quick_note_create/task_query"] + A["用户消息进入 /agent/chat"] --> B["通用控制码分流
action=chat/quick_note_create/task_query/schedule_plan"] B --> C{"action 是否为 task_query"} C -- 否 --> D["走其它分支
普通聊天或随口记"] C -- 是 --> E["进入 TaskQueryGraph"] @@ -433,6 +452,52 @@ flowchart TD Q --> R["后置持久化
user+assistant 写 Redis + outbox/DB"] ``` +### 4) 命中“智能排程”后的业务流转图 + +```mermaid +flowchart TD + A["命中 action=schedule_plan
发 request.accepted 阶段块"] --> B["runSchedulePlanFlow 入口"] + B --> B1{"依赖齐全?
model + 3个函数注入"} + B1 -- "否" --> B2["返回 error 给上层
上层回退普通聊天"] + B1 -- "是" --> C["清理旧预览缓存
DeleteSchedulePlanPreview
失败仅记日志"] + C --> D["加载对话历史
Redis 优先 -> miss 回源 DB
失败降级为空历史继续"] + D --> E["RunSchedulePlanGraph
注入并发度与预算配置"] + + E --> P1["plan 节点
合并 extra.task_class_ids + 模型提取约束/策略/标签
模型失败时可用 extra 兜底"] + P1 --> P1B{"FinalSummary 非空
或 task_class_ids 为空?"} + P1B -- "是" --> PX["exit 节点 -> END
直接返回已有失败文案"] + P1B -- "否" --> P2["rough_build 节点
HybridScheduleWithPlanMulti 构建 HybridEntries
可选解析全局窗口(起止周/天)"] + + P2 --> P2B{"HybridEntries 为空
或构建失败?"} + P2B -- "是" --> PX + P2B -- "否" --> P3{"len(task_class_ids) >= 2 ?"} + + P3 -- "是" --> P4["daily_split
按周天拆 DayGroup + 注入 ContextTag
suggested<=2 标记 SkipRefine"] + P4 --> P5["daily_refine(并发)
按天并发 ReAct
单天失败回退原天结果"] + P5 --> P6["merge
合并 DailyResults
冲突则整体回退 merge 前快照"] + P6 --> P7["weekly_refine(并发按周)
有效周保底预算 + 负载加权分配"] + + P3 -- "否" --> P7 + P7 --> P7A["单周 worker 循环
每轮只允许 1 个 Move/Swap 或 done
总预算(成功/失败都扣) + 有效预算(仅成功扣)
Move 受本周与全局窗口硬约束"] + + P7A --> P8["final_check
physicsCheck(冲突/节次越界/数量核对)
失败回退 MergeSnapshot
再生成自然语言总结"] + P8 --> P9["return_preview
回填 AllocatedItems 嵌入时间
生成 CandidatePlans + FinalSummary + Completed"] + P9 --> F1["saveSchedulePlanPreview
写 Redis 结构化快照
失败仅记日志"] + F1 --> F2["返回 FinalSummary 给 AgentChat"] + + F2 --> G1["emitSingleAssistantCompletion
SSE 输出终审文本"] + G1 --> G2["persistChatAfterReply
user/assistant 写 Redis + outbox/DB"] + G2 --> G3["ensureConversationTitleAsync"] + + F1 --> H1["结构化通道
GET /api/v1/agent/schedule-preview?conversation_id=..."] + H1 --> H2["GetSchedulePlanPreview
按 user_id + conversation_id 读 Redis 快照
未命中返回业务错误码"] + + B2 --> Z["上层发 fallback 阶段块
回退 runNormalChatFlow"] + PX --> F1 + G3 --> Z + H2 --> Z +``` + # 6 前端实现 ## 6.1 设计策略 diff --git a/backend/agent/route/route.go b/backend/agent/route/route.go index 099fbed..ae0aa99 100644 --- a/backend/agent/route/route.go +++ b/backend/agent/route/route.go @@ -89,6 +89,16 @@ type RoutingDecision struct { Action Action TrustRoute bool Detail string + // RouteFailed 标记“控制码路由是否失败”。 + // + // 语义: + // 1. true:路由阶段发生异常(模型调用失败、控制码解析失败等); + // 2. false:路由阶段正常完成(无论最终 action 是 chat 还是其它分支)。 + // + // 说明: + // 1. 该字段用于让上层决定“是否直接报错而不是回落聊天”; + // 2. 历史行为是失败回落 chat,本字段用于支持新的“失败即报错”策略。 + RouteFailed bool } // DecideActionRouting 通过“模型控制码”决定本次请求走向。 @@ -97,21 +107,22 @@ type RoutingDecision struct { // 1. Action=quick_note_create:进入随口记写入图; // 2. Action=task_query:进入任务查询 tool-calling; // 3. Action=chat:进入普通聊天流; -// 4. 路由失败时回落 chat,保证可用性优先。 +// 4. 路由失败时会标记 RouteFailed=true,由上层直接返回内部错误。 func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision { decision, err := routeByModelControlTag(ctx, selectedModel, userMessage) if err != nil { if deadline, ok := ctx.Deadline(); ok { - log.Printf("通用分流控制码失败,回落 chat: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d", + log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d", err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds()) } else { - log.Printf("通用分流控制码失败,回落 chat: err=%v parent_deadline=none route_timeout_ms=%d", + log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline=none route_timeout_ms=%d", err, ControlTimeout.Milliseconds()) } return RoutingDecision{ - Action: ActionChat, - TrustRoute: false, - Detail: "", + Action: ActionChat, + TrustRoute: false, + Detail: "", + RouteFailed: true, } } @@ -122,9 +133,10 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user reason = "识别到新增任务请求,准备执行随口记流程。" } return RoutingDecision{ - Action: ActionQuickNoteCreate, - TrustRoute: true, - Detail: reason, + Action: ActionQuickNoteCreate, + TrustRoute: true, + Detail: reason, + RouteFailed: false, } case ActionTaskQuery: reason := strings.TrimSpace(decision.Reason) @@ -132,9 +144,10 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user reason = "识别到任务查询请求,准备调用任务查询工具。" } return RoutingDecision{ - Action: ActionTaskQuery, - TrustRoute: true, - Detail: reason, + Action: ActionTaskQuery, + TrustRoute: true, + Detail: reason, + RouteFailed: false, } case ActionSchedulePlan: reason := strings.TrimSpace(decision.Reason) @@ -142,23 +155,26 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user reason = "识别到排程请求,准备执行智能排程流程。" } return RoutingDecision{ - Action: ActionSchedulePlan, - TrustRoute: true, - Detail: reason, + Action: ActionSchedulePlan, + TrustRoute: true, + Detail: reason, + RouteFailed: false, } case ActionChat: return RoutingDecision{ - Action: ActionChat, - TrustRoute: false, - Detail: "", + Action: ActionChat, + TrustRoute: false, + Detail: "", + RouteFailed: false, } default: - // 兜底:未知动作一律回落 chat,避免误入错误分支。 - log.Printf("通用分流出现未知动作,回落 chat: action=%s raw=%s", decision.Action, decision.Raw) + // 兜底:未知动作视为路由异常,标记 RouteFailed 让上层统一报错。 + log.Printf("通用分流出现未知动作,标记路由失败并等待上层报错: action=%s raw=%s", decision.Action, decision.Raw) return RoutingDecision{ - Action: ActionChat, - TrustRoute: false, - Detail: "", + Action: ActionChat, + TrustRoute: false, + Detail: "", + RouteFailed: true, } } } @@ -273,9 +289,10 @@ func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, u return decision } return RoutingDecision{ - Action: ActionChat, - TrustRoute: false, - Detail: "", + Action: ActionChat, + TrustRoute: false, + Detail: "", + RouteFailed: decision.RouteFailed, } } diff --git a/backend/agent/scheduleplan/daily_refine.go b/backend/agent/scheduleplan/daily_refine.go new file mode 100644 index 0000000..136826d --- /dev/null +++ b/backend/agent/scheduleplan/daily_refine.go @@ -0,0 +1,315 @@ +package scheduleplan + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/schema" + arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +const ( + // dailyReactRoundTimeout 是日内单轮模型调用超时。 + // 日内节点走并发调用,超时要比周级更保守,避免占满资源。 + dailyReactRoundTimeout = 3 * time.Minute +) + +// runDailyRefineNode 负责“并发日内优化”。 +// +// 职责边界: +// 1. 负责按 DayGroup 并发调用单日 ReAct; +// 2. 负责输出“按天开始/完成”的阶段状态块(不推 reasoning 细流); +// 3. 负责把单日失败回退到原始数据,确保全链路可继续; +// 4. 不负责跨天配平(交给 weekly_refine),不负责最终总结(交给 final_check)。 +func runDailyRefineNode( + ctx context.Context, + st *SchedulePlanState, + chatModel *ark.ChatModel, + dailyRefineConcurrency int, + emitStage func(stage, detail string), +) (*SchedulePlanState, error) { + if st == nil || len(st.DailyGroups) == 0 { + return st, nil + } + if chatModel == nil { + return st, fmt.Errorf("schedule plan daily refine: model is nil") + } + + // 1. 并发度兜底: + // 1.1 优先使用注入参数; + // 1.2 若注入参数非法,则回退到 state 值; + // 1.3 state 也非法时,回退到编译期默认值。 + if dailyRefineConcurrency <= 0 { + dailyRefineConcurrency = st.DailyRefineConcurrency + } + if dailyRefineConcurrency <= 0 { + dailyRefineConcurrency = schedulePlanDefaultDailyRefineConcurrency + } + + emitStage( + "schedule_plan.daily_refine.start", + fmt.Sprintf("正在并发优化各天日程,并发度=%d。", dailyRefineConcurrency), + ) + + // 2. 拉平所有 DayGroup 并排序,确保日志与阶段输出稳定可读。 + allGroups := flattenAndSortDayGroups(st.DailyGroups) + if len(allGroups) == 0 { + st.DailyResults = make(map[int]map[int][]model.HybridScheduleEntry) + emitStage("schedule_plan.daily_refine.done", "没有可优化的天,跳过日内优化。") + return st, nil + } + + // 3. 并发执行: + // 3.1 sem 控制并发上限; + // 3.2 wg 等待全部 goroutine 完成; + // 3.3 mu 保护 results/firstErr,避免竞态。 + sem := make(chan struct{}, dailyRefineConcurrency) + var wg sync.WaitGroup + var mu sync.Mutex + totalGroups := int32(len(allGroups)) + var finishedGroups int32 + + results := make(map[int]map[int][]model.HybridScheduleEntry) + var firstErr error + + for _, group := range allGroups { + g := group + wg.Add(1) + go func() { + defer wg.Done() + + // 3.4 先申请并发令牌;若 ctx 已取消,直接回退原始数据并结束。 + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + mu.Lock() + if firstErr == nil { + firstErr = ctx.Err() + } + ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries) + mu.Unlock() + // 3.4.1 取消场景也要计入进度,避免前端看到“卡住不动”。 + done := atomic.AddInt32(&finishedGroups, 1) + emitStage( + "schedule_plan.daily_refine.day_done", + fmt.Sprintf("W%dD%d 已取消并回退原方案。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups), + ) + return + } + + emitStage( + "schedule_plan.daily_refine.day_start", + fmt.Sprintf("正在安排 W%dD%d。(当前进度 %d/%d)", g.Week, g.DayOfWeek, atomic.LoadInt32(&finishedGroups), totalGroups), + ) + + // 3.5 低收益天直接跳过模型调用,原样透传。 + if g.SkipRefine { + mu.Lock() + ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries) + mu.Unlock() + done := atomic.AddInt32(&finishedGroups, 1) + emitStage( + "schedule_plan.daily_refine.day_done", + fmt.Sprintf("W%dD%d suggested 较少,已跳过优化。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups), + ) + return + } + + // 3.6 深拷贝输入,避免并发场景下意外修改共享切片。 + localEntries := deepCopyEntries(g.Entries) + + // 3.7 动态轮次: + // 3.7.1 suggested <= 4:1轮足够; + // 3.7.2 suggested > 4:最多2轮,提升复杂天优化质量。 + maxRounds := 1 + if countSuggested(localEntries) > 4 { + maxRounds = 2 + } + + optimized, refineErr := runSingleDayReact(ctx, chatModel, localEntries, maxRounds, g.Week, g.DayOfWeek) + if refineErr != nil { + mu.Lock() + if firstErr == nil { + firstErr = refineErr + } + // 3.8 单天失败回退: + // 3.8.1 保证失败只影响该天; + // 3.8.2 保证总流程可继续推进到 merge/weekly/final。 + ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries) + mu.Unlock() + done := atomic.AddInt32(&finishedGroups, 1) + emitStage( + "schedule_plan.daily_refine.day_done", + fmt.Sprintf("W%dD%d 优化失败,已回退原方案。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups), + ) + return + } + + mu.Lock() + ensureDayResult(results, g.Week, g.DayOfWeek, optimized) + mu.Unlock() + done := atomic.AddInt32(&finishedGroups, 1) + emitStage( + "schedule_plan.daily_refine.day_done", + fmt.Sprintf("W%dD%d 已安排完成。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups), + ) + }() + } + + wg.Wait() + st.DailyResults = results + if firstErr != nil { + emitStage("schedule_plan.daily_refine.partial_error", fmt.Sprintf("部分天优化失败,已自动回退。原因:%s", firstErr.Error())) + } + emitStage("schedule_plan.daily_refine.done", "日内优化阶段完成。") + return st, nil +} + +// runSingleDayReact 执行单天封闭式 ReAct 优化。 +// +// 关键约束: +// 1. prompt 只包含当天数据; +// 2. 代码层再做“Move 不能跨天”硬校验; +// 3. Thinking 默认关闭,优先降低日内并发阶段的长尾时延。 +func runSingleDayReact( + ctx context.Context, + chatModel *ark.ChatModel, + entries []model.HybridScheduleEntry, + maxRounds int, + week int, + dayOfWeek int, +) ([]model.HybridScheduleEntry, error) { + hybridJSON, err := json.Marshal(entries) + if err != nil { + return entries, err + } + + messages := []*schema.Message{ + schema.SystemMessage(SchedulePlanDailyReactPrompt), + schema.UserMessage(fmt.Sprintf( + "以下是今天的日程(JSON):\n%s\n\n仅优化这一天的数据,不要跨天移动。", + string(hybridJSON), + )), + } + + for round := 0; round < maxRounds; round++ { + roundCtx, cancel := context.WithTimeout(ctx, dailyReactRoundTimeout) + resp, generateErr := chatModel.Generate( + roundCtx, + messages, + // 1. 日内优化只做“单天局部微调”,任务边界清晰,默认关闭 thinking 以降低时延。 + // 2. 周级全局配平仍保留 thinking(在 weekly_refine),这里不承担跨天复杂推理职责。 + // 3. 若后续观测到质量回退,可只在 suggested 很多时按条件重开 thinking,而不是全量开启。 + ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}), + ) + cancel() + if generateErr != nil { + return entries, fmt.Errorf("日内 ReAct 第%d轮失败: %w", round+1, generateErr) + } + if resp == nil { + return entries, fmt.Errorf("日内 ReAct 第%d轮返回为空", round+1) + } + + content := strings.TrimSpace(resp.Content) + parsed, parseErr := parseReactLLMOutput(content) + if parseErr != nil { + // 解析失败时回退当前轮,不把异常向上放大成整条链路失败。 + return entries, nil + } + if parsed.Done || len(parsed.ToolCalls) == 0 { + break + } + + // 1. 执行工具调用。 + // 1.1 每个调用都经过“日内策略约束”校验; + // 1.2 任何单次调用失败都只返回 failed result,不中断整轮。 + results := make([]reactToolResult, 0, len(parsed.ToolCalls)) + for _, call := range parsed.ToolCalls { + var result reactToolResult + entries, result = dispatchDailyReactTool(entries, call, week, dayOfWeek) + results = append(results, result) + } + + // 2. 把“本轮模型输出 + 工具执行结果”拼入下一轮上下文。 + // 2.1 这样模型可以看到操作反馈,继续迭代; + // 2.2 若下一轮仍无有效动作,会自然在 done/空 tool_calls 退出。 + messages = append(messages, schema.AssistantMessage(content, nil)) + resultJSON, _ := json.Marshal(results) + messages = append(messages, schema.UserMessage( + fmt.Sprintf("工具执行结果:\n%s\n\n请继续优化或输出 {\"done\":true,\"summary\":\"...\"}。", string(resultJSON)), + )) + } + + return entries, nil +} + +// dispatchDailyReactTool 在通用工具分发前增加“日内硬约束”。 +// +// 职责边界: +// 1. 只负责校验 Move 的目标是否仍在当前天; +// 2. 通过后复用 dispatchReactTool 执行; +// 3. 不负责复杂冲突判定(冲突判定由底层工具函数处理)。 +func dispatchDailyReactTool(entries []model.HybridScheduleEntry, call reactToolCall, week int, dayOfWeek int) ([]model.HybridScheduleEntry, reactToolResult) { + if call.Tool == "Move" { + toWeek, weekOK := paramInt(call.Params, "to_week") + toDay, dayOK := paramInt(call.Params, "to_day") + if !weekOK || !dayOK { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: "参数缺失:to_week/to_day", + } + } + if toWeek != week || toDay != dayOfWeek { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("日内优化禁止跨天移动:当前仅允许 W%dD%d", week, dayOfWeek), + } + } + } + return dispatchReactTool(entries, call) +} + +// flattenAndSortDayGroups 把 map 结构摊平成有序切片,便于稳定并发调度。 +func flattenAndSortDayGroups(groups map[int]map[int]*DayGroup) []*DayGroup { + out := make([]*DayGroup, 0) + for _, dayMap := range groups { + for _, g := range dayMap { + if g != nil { + out = append(out, g) + } + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Week != out[j].Week { + return out[i].Week < out[j].Week + } + return out[i].DayOfWeek < out[j].DayOfWeek + }) + return out +} + +// ensureDayResult 确保 results[week][day] 存在并写入值。 +func ensureDayResult(results map[int]map[int][]model.HybridScheduleEntry, week int, day int, entries []model.HybridScheduleEntry) { + if results[week] == nil { + results[week] = make(map[int][]model.HybridScheduleEntry) + } + results[week][day] = entries +} + +// deepCopyEntries 深拷贝 HybridScheduleEntry 切片。 +func deepCopyEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { + dst := make([]model.HybridScheduleEntry, len(src)) + copy(dst, src) + return dst +} diff --git a/backend/agent/scheduleplan/daily_split.go b/backend/agent/scheduleplan/daily_split.go new file mode 100644 index 0000000..1652360 --- /dev/null +++ b/backend/agent/scheduleplan/daily_split.go @@ -0,0 +1,93 @@ +package scheduleplan + +import ( + "context" + "fmt" +) + +// runDailySplitNode 负责“按天拆分 + 标签注入 + 跳过判断”。 +// +// 职责边界: +// 1. 负责把全量 HybridEntries 拆成 DayGroup,供后续并发日内优化; +// 2. 负责把 TaskTags(task_item_id -> tag) 注入到条目的 ContextTag; +// 3. 负责识别“低收益天”(suggested<=2)并标记 SkipRefine; +// 4. 不负责调用模型,不负责并发执行,不负责结果合并。 +func runDailySplitNode( + ctx context.Context, + st *SchedulePlanState, + emitStage func(stage, detail string), +) (*SchedulePlanState, error) { + _ = ctx + if st == nil || len(st.HybridEntries) == 0 { + return st, nil + } + + emitStage("schedule_plan.daily_split.start", "正在按天拆分排程并标记优化单元。") + + // 1. 初始化容器: + // 1.1 groups 以 week/day 二级索引保存 DayGroup; + // 1.2 这么做的目的是后续 daily_refine 可以直接并发遍历,不再重复分组。 + groups := make(map[int]map[int]*DayGroup) + + // 2. 遍历混合条目,执行“标签注入 + 分组”。 + for i := range st.HybridEntries { + entry := &st.HybridEntries[i] + + // 2.1 仅对 suggested 条目注入 ContextTag。 + // 2.1.1 existing 条目是固定课表/已落库任务,不参与认知标签优化。 + // 2.1.2 注入失败时兜底 General,避免后续 prompt 出现空标签。 + if entry.Status == "suggested" && entry.TaskItemID > 0 { + if tag, ok := st.TaskTags[entry.TaskItemID]; ok { + entry.ContextTag = normalizeContextTag(tag) + } else { + entry.ContextTag = "General" + } + } + + // 2.2 建立分组索引。 + if groups[entry.Week] == nil { + groups[entry.Week] = make(map[int]*DayGroup) + } + if groups[entry.Week][entry.DayOfWeek] == nil { + groups[entry.Week][entry.DayOfWeek] = &DayGroup{ + Week: entry.Week, + DayOfWeek: entry.DayOfWeek, + } + } + groups[entry.Week][entry.DayOfWeek].Entries = append(groups[entry.Week][entry.DayOfWeek].Entries, *entry) + } + + // 3. 逐天计算 suggested 数量,标记是否跳过日内优化。 + // + // 3.1 为什么阈值设为 <=2: + // 3.1.1 suggested 很少时,模型优化收益通常不足以覆盖请求成本; + // 3.1.2 直接跳过可减少无效模型调用和阶段等待。 + // 3.2 失败策略: + // 3.2.1 这里只做内存标记,不会失败; + // 3.2.2 即使阈值判断不完美,也只影响优化深度,不影响功能正确性。 + totalDays := 0 + skipDays := 0 + for _, dayMap := range groups { + for _, dayGroup := range dayMap { + totalDays++ + suggestedCount := 0 + for _, e := range dayGroup.Entries { + if e.Status == "suggested" { + suggestedCount++ + } + } + if suggestedCount <= 2 { + dayGroup.SkipRefine = true + skipDays++ + } + } + } + + // 4. 回填状态,交给后续节点使用。 + st.DailyGroups = groups + emitStage( + "schedule_plan.daily_split.done", + fmt.Sprintf("已拆分为 %d 天,其中 %d 天跳过日内优化。", totalDays, skipDays), + ) + return st, nil +} diff --git a/backend/agent/scheduleplan/final_check.go b/backend/agent/scheduleplan/final_check.go new file mode 100644 index 0000000..3823671 --- /dev/null +++ b/backend/agent/scheduleplan/final_check.go @@ -0,0 +1,171 @@ +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 +} diff --git a/backend/agent/scheduleplan/graph.go b/backend/agent/scheduleplan/graph.go index 123d491..5f4010d 100644 --- a/backend/agent/scheduleplan/graph.go +++ b/backend/agent/scheduleplan/graph.go @@ -12,24 +12,31 @@ import ( const ( // 图节点:意图识别与约束提取 schedulePlanGraphNodePlan = "schedule_plan_plan" - // 图节点:调用粗排算法生成候选方案 - schedulePlanGraphNodePreview = "schedule_plan_preview" - // 图节点:退出(用于提前终止分支) + // 图节点:粗排构建(替代旧 preview + hybridBuild) + schedulePlanGraphNodeRoughBuild = "schedule_plan_rough_build" + // 图节点:提前退出 schedulePlanGraphNodeExit = "schedule_plan_exit" - // 图节点:构建混合日程(ReAct 精排前置) - schedulePlanGraphNodeHybridBuild = "schedule_plan_hybrid_build" - // 图节点:ReAct 精排循环 - schedulePlanGraphNodeReactRefine = "schedule_plan_react_refine" - // 图节点:返回精排预览结果(不落库) + // 图节点:按天拆分并注入上下文标签 + schedulePlanGraphNodeDailySplit = "schedule_plan_daily_split" + // 图节点:并发日内优化 + schedulePlanGraphNodeDailyRefine = "schedule_plan_daily_refine" + // 图节点:合并日内优化结果 + schedulePlanGraphNodeMerge = "schedule_plan_merge" + // 图节点:周级配平优化(单步动作模式,输出阶段状态) + schedulePlanGraphNodeWeeklyRefine = "schedule_plan_weekly_refine" + // 图节点:终审校验 + schedulePlanGraphNodeFinalCheck = "schedule_plan_final_check" + // 图节点:返回预览结果(不落库) schedulePlanGraphNodeReturnPreview = "schedule_plan_return_preview" ) -// SchedulePlanGraphRunInput 是运行"智能排程 graph"所需的输入依赖。 +// SchedulePlanGraphRunInput 是执行“智能排程 graph”所需输入。 // -// 说明: -// 1) EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块); -// 2) Extra 传递前端附加参数(如 task_class_id); -// 3) ChatHistory 用于连续对话微调场景。 +// 字段说明: +// 1. Extra:前端附加参数(重点是 task_class_ids); +// 2. ChatHistory:支持连续对话微调; +// 3. OutChan/ModelName:保留兼容字段(当前 weekly refine 主要输出阶段状态); +// 4. DailyRefineConcurrency/WeeklyAdjustBudget:可选运行参数覆盖。 type SchedulePlanGraphRunInput struct { Model *ark.ChatModel State *SchedulePlanState @@ -38,22 +45,30 @@ type SchedulePlanGraphRunInput struct { Extra map[string]any ChatHistory []*schema.Message EmitStage func(stage, detail string) - // ── ReAct 精排所需 ── - OutChan chan<- string // SSE 流式输出通道,用于推送 reasoning_content - ModelName string // 模型名称,用于构造 OpenAI 兼容 chunk + + OutChan chan<- string + ModelName string + + DailyRefineConcurrency int + WeeklyAdjustBudget int } -// RunSchedulePlanGraph 执行"智能排程"图编排。 +// RunSchedulePlanGraph 执行“智能排程”图编排。 // -// 图结构: +// 当前链路: +// START +// -> plan +// -> roughBuild +// -> (len(task_class_ids)>=2 ? dailySplit -> dailyRefine -> merge : weeklyRefine) +// -> finalCheck +// -> returnPreview +// -> END // -// START -> plan -> [branch] -> preview -> [branch] -> hybridBuild -> [branch] -> reactRefine -> returnPreview -> END -// | | | -// exit exit exit -// -// 该文件只负责"连线与分支",节点内部逻辑全部下沉到 nodes.go。 +// 说明: +// 1. exit 分支可从 plan/roughBuild 直接提前终止; +// 2. 本文件只负责“连线与分支”,节点内业务都在 nodes/daily/weekly 文件中。 func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) (*SchedulePlanState, error) { - // 1. 启动前硬校验:模型、状态、依赖缺一不可。 + // 1. 启动前硬校验。 if input.Model == nil { return nil, errors.New("schedule plan graph: model is nil") } @@ -64,14 +79,20 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) return nil, err } - // 2. 统一封装阶段推送函数,避免各节点反复判空。 + // 2. 注入运行时配置(可选覆盖)。 + if input.DailyRefineConcurrency > 0 { + input.State.DailyRefineConcurrency = input.DailyRefineConcurrency + } + if input.WeeklyAdjustBudget > 0 { + input.State.WeeklyAdjustBudget = input.WeeklyAdjustBudget + } + emitStage := func(stage, detail string) { if input.EmitStage != nil { input.EmitStage(stage, detail) } } - // 3. 构造 runner,收口节点依赖。 runner := newSchedulePlanRunner( input.Model, input.Deps, @@ -81,100 +102,100 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) input.ChatHistory, input.OutChan, input.ModelName, + input.State.DailyRefineConcurrency, ) - // 4. 创建状态图容器:输入/输出类型都为 *SchedulePlanState。 graph := compose.NewGraph[*SchedulePlanState, *SchedulePlanState]() - // 5. 注册节点。 + // 3. 注册节点。 if err := graph.AddLambdaNode(schedulePlanGraphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil { return nil, err } - if err := graph.AddLambdaNode(schedulePlanGraphNodePreview, compose.InvokableLambda(runner.previewNode)); err != nil { + if err := graph.AddLambdaNode(schedulePlanGraphNodeRoughBuild, compose.InvokableLambda(runner.roughBuildNode)); err != nil { return nil, err } if err := graph.AddLambdaNode(schedulePlanGraphNodeExit, compose.InvokableLambda(runner.exitNode)); err != nil { return nil, err } - if err := graph.AddLambdaNode(schedulePlanGraphNodeHybridBuild, compose.InvokableLambda(runner.hybridBuildNode)); err != nil { + if err := graph.AddLambdaNode(schedulePlanGraphNodeDailySplit, compose.InvokableLambda(runner.dailySplitNode)); err != nil { return nil, err } - if err := graph.AddLambdaNode(schedulePlanGraphNodeReactRefine, compose.InvokableLambda(runner.reactRefineNode)); err != nil { + if err := graph.AddLambdaNode(schedulePlanGraphNodeDailyRefine, compose.InvokableLambda(runner.dailyRefineNode)); err != nil { + return nil, err + } + if err := graph.AddLambdaNode(schedulePlanGraphNodeMerge, compose.InvokableLambda(runner.mergeNode)); err != nil { + return nil, err + } + if err := graph.AddLambdaNode(schedulePlanGraphNodeWeeklyRefine, compose.InvokableLambda(runner.weeklyRefineNode)); err != nil { + return nil, err + } + if err := graph.AddLambdaNode(schedulePlanGraphNodeFinalCheck, compose.InvokableLambda(runner.finalCheckNode)); err != nil { return nil, err } if err := graph.AddLambdaNode(schedulePlanGraphNodeReturnPreview, compose.InvokableLambda(runner.returnPreviewNode)); err != nil { return nil, err } - // ── 连线 ── - - // 6. START -> plan + // 4. 连线:START -> plan if err := graph.AddEdge(compose.START, schedulePlanGraphNodePlan); err != nil { return nil, err } - // 7. plan -> [branch] -> preview | exit + // 5. plan 分支:roughBuild | exit if err := graph.AddBranch(schedulePlanGraphNodePlan, compose.NewGraphBranch( runner.nextAfterPlan, map[string]bool{ - schedulePlanGraphNodePreview: true, - schedulePlanGraphNodeExit: true, + schedulePlanGraphNodeRoughBuild: true, + schedulePlanGraphNodeExit: true, }, )); err != nil { return nil, err } - // 8. preview -> [branch] -> hybridBuild | exit - if err := graph.AddBranch(schedulePlanGraphNodePreview, compose.NewGraphBranch( - runner.nextAfterPreview, + // 6. roughBuild 分支:dailySplit | weeklyRefine | exit + if err := graph.AddBranch(schedulePlanGraphNodeRoughBuild, compose.NewGraphBranch( + runner.nextAfterRoughBuild, map[string]bool{ - schedulePlanGraphNodeHybridBuild: true, - schedulePlanGraphNodeExit: true, + schedulePlanGraphNodeDailySplit: true, + schedulePlanGraphNodeWeeklyRefine: true, + schedulePlanGraphNodeExit: true, }, )); err != nil { return nil, err } - // 9. hybridBuild -> [branch] -> reactRefine | exit - if err := graph.AddBranch(schedulePlanGraphNodeHybridBuild, compose.NewGraphBranch( - runner.nextAfterHybridBuild, - map[string]bool{ - schedulePlanGraphNodeReactRefine: true, - schedulePlanGraphNodeExit: true, - }, - )); err != nil { + // 7. 固定边:dailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END + if err := graph.AddEdge(schedulePlanGraphNodeDailySplit, schedulePlanGraphNodeDailyRefine); err != nil { return nil, err } - - // 10. reactRefine -> returnPreview(固定边) - if err := graph.AddEdge(schedulePlanGraphNodeReactRefine, schedulePlanGraphNodeReturnPreview); err != nil { + if err := graph.AddEdge(schedulePlanGraphNodeDailyRefine, schedulePlanGraphNodeMerge); err != nil { + return nil, err + } + if err := graph.AddEdge(schedulePlanGraphNodeMerge, schedulePlanGraphNodeWeeklyRefine); err != nil { + return nil, err + } + if err := graph.AddEdge(schedulePlanGraphNodeWeeklyRefine, schedulePlanGraphNodeFinalCheck); err != nil { + return nil, err + } + if err := graph.AddEdge(schedulePlanGraphNodeFinalCheck, schedulePlanGraphNodeReturnPreview); err != nil { return nil, err } - - // 11. returnPreview -> END if err := graph.AddEdge(schedulePlanGraphNodeReturnPreview, compose.END); err != nil { return nil, err } - - // 12. exit -> END if err := graph.AddEdge(schedulePlanGraphNodeExit, compose.END); err != nil { return nil, err } - // 13. 运行步数上限:plan + preview + hybridBuild + reactRefine + returnPreview = 5 步, - // 加余量到 15,防止异常分支导致无限循环。 - maxSteps := 15 - - // 15. 编译图得到可执行实例。 + // 8. 编译并执行。 + // 路径最多约 8~9 个节点,保守预留 20 步避免误判。 runnable, err := graph.Compile(ctx, compose.WithGraphName("SchedulePlanGraph"), - compose.WithMaxRunSteps(maxSteps), + compose.WithMaxRunSteps(20), compose.WithNodeTriggerMode(compose.AnyPredecessor), ) if err != nil { return nil, err } - - // 16. 执行图并返回最终状态。 return runnable.Invoke(ctx, input.State) } diff --git a/backend/agent/scheduleplan/merge.go b/backend/agent/scheduleplan/merge.go new file mode 100644 index 0000000..2d9c2cc --- /dev/null +++ b/backend/agent/scheduleplan/merge.go @@ -0,0 +1,86 @@ +package scheduleplan + +import ( + "context" + "fmt" + + "github.com/LoveLosita/smartflow/backend/model" +) + +// runMergeNode 负责“合并日内结果 + 冲突校验 + 回退快照”。 +// +// 职责边界: +// 1. 负责把 DailyResults 合并回全量 HybridEntries; +// 2. 负责执行时间冲突检测; +// 3. 负责在冲突时回退原始数据; +// 4. 负责产出 MergeSnapshot,供 final_check 失败时回退。 +func runMergeNode( + ctx context.Context, + st *SchedulePlanState, + emitStage func(stage, detail string), +) (*SchedulePlanState, error) { + _ = ctx + if st == nil || len(st.DailyResults) == 0 { + return st, nil + } + + emitStage("schedule_plan.merge.start", "正在合并日内优化结果。") + + // 1. 先保存 merge 前原始数据,作为冲突时的第一层回退兜底。 + originalEntries := deepCopyEntries(st.HybridEntries) + + // 2. 展平 daily results。 + merged := make([]model.HybridScheduleEntry, 0) + for _, dayMap := range st.DailyResults { + for _, dayEntries := range dayMap { + merged = append(merged, dayEntries...) + } + } + + // 3. 冲突校验。 + // + // 3.1 判断依据:同一 (week, day, section) 只能有一个条目占用; + // 3.2 失败处理:一旦冲突,整批回退到 merge 前原始结果; + // 3.3 回退策略:回退后仍继续链路,避免请求直接失败。 + if conflict := detectConflicts(merged); conflict != "" { + st.HybridEntries = originalEntries + emitStage("schedule_plan.merge.conflict", fmt.Sprintf("检测到冲突并回退:%s", conflict)) + } else { + st.HybridEntries = merged + emitStage("schedule_plan.merge.done", fmt.Sprintf("合并完成,共 %d 个条目。", len(merged))) + } + + // 4. 无论是否冲突,都生成“可回退快照”。 + st.MergeSnapshot = deepCopyEntries(st.HybridEntries) + return st, nil +} + +// detectConflicts 检测条目是否存在时间冲突。 +// +// 返回语义: +// 1. 返回空字符串:无冲突; +// 2. 返回非空字符串:冲突描述,可直接用于日志/阶段提示。 +func detectConflicts(entries []model.HybridScheduleEntry) string { + type slotKey struct { + week, day, section int + } + occupied := make(map[slotKey]string) + for _, entry := range entries { + // 1. 仅“阻塞建议任务”的条目参与冲突校验。 + // 2. 可嵌入且当前未占用的课程槽位不应被判定为冲突。 + if !entryBlocksSuggested(entry) { + continue + } + for section := entry.SectionFrom; section <= entry.SectionTo; section++ { + key := slotKey{week: entry.Week, day: entry.DayOfWeek, section: section} + if prevName, exists := occupied[key]; exists { + return fmt.Sprintf( + "W%dD%d 第%d节 冲突:[%s] 与 [%s]", + entry.Week, entry.DayOfWeek, section, prevName, entry.Name, + ) + } + occupied[key] = entry.Name + } + } + return "" +} diff --git a/backend/agent/scheduleplan/nodes.go b/backend/agent/scheduleplan/nodes.go index 183b2bf..8f95692 100644 --- a/backend/agent/scheduleplan/nodes.go +++ b/backend/agent/scheduleplan/nodes.go @@ -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 非空 -> exit(plan 阶段已确定无法继续) -// 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 { diff --git a/backend/agent/scheduleplan/prompt.go b/backend/agent/scheduleplan/prompt.go index 28725dd..fbdde42 100644 --- a/backend/agent/scheduleplan/prompt.go +++ b/backend/agent/scheduleplan/prompt.go @@ -3,18 +3,29 @@ package scheduleplan const ( // SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。 // - // 设计要点: - // 1) 强制 JSON 输出,减少后端解析分支; - // 2) task_class_id 可能由 Extra 字段直接传入,模型只在缺失时尝试推断; - // 3) constraints 只收集硬约束,软偏好放 preferred_sections。 + // 职责边界: + // 1. 负责把自然语言转成结构化 JSON,供后端节点分流与执行; + // 2. 负责抽取 task_class_ids / strategy / task_tags 等关键字段; + // 3. 不负责做排程计算,不负责做工具调用。 + // + // 输出约束: + // 1. 必须只输出 JSON,禁止附加解释文本; + // 2. task_class_ids 是主语义; + // 3. task_class_id 仅作为兼容字段保留,便于老链路平滑过渡。 SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。 请根据用户输入,提取排程意图与约束条件。 必须完成以下任务: 1) 用一句话概括用户的排程意图(intent)。 -2) 提取所有硬约束(constraints),如"早八不排"、"周末休息"等。 -3) 如果用户明确提到了任务类名称或ID,输出 task_class_id(整数);否则输出 -1。 -4) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。 +2) 提取所有硬约束(constraints),如“早八不排”“周末休息”等。 +3) 如果用户明确提到了任务类名称或ID,输出 task_class_ids(整数数组);否则输出空数组 []。 +4) 兼容字段 task_class_id:若 task_class_ids 非空,可填第一个ID;若无法判断填 -1。 +5) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。 +6) 尝试给任务打认知标签 task_tags(可选): + - 推荐键:task_item_id(字符串形式,例如 "12") + - 兼容键:任务名称(例如 "高数复习") + - 值只能是:High-Logic / Memory / Review / General + - 如果无法判断,输出空对象 {} 输出要求: - 仅输出 JSON,不要 markdown,不要解释。 @@ -22,67 +33,133 @@ const ( { "intent": "用户排程意图摘要", "constraints": ["约束1", "约束2"], - "task_class_id": -1, - "strategy": "steady" + "task_class_ids": [12, 13], + "task_class_id": 12, + "strategy": "steady", + "task_tags": {"12":"High-Logic","英语阅读":"Memory"} }` - // SchedulePlanReactSystemPrompt 用于 ReAct 精排节点: - // LLM 开启深度思考,通过 Tool 调用对粗排结果进行语义化优化。 + // SchedulePlanDailyReactPrompt 用于 daily_refine 节点。 // - // 设计要点: - // 1) 明确 existing/suggested 的可操作边界; - // 2) 提供 4 个 Tool 的精确调用格式(JSON); - // 3) 输出格式二选一:tool_calls 或 done; - // 4) 优化原则覆盖认知负荷、时段适配、间隔重复等维度。 - SchedulePlanReactSystemPrompt = `你是 SmartFlow 智能排程精排优化器。 + // 职责边界: + // 1. 只处理“单天”数据,避免跨天决策污染; + // 2. 通过工具调用做小步调整; + // 3. 不负责周级配平,不负责最终总结。 + SchedulePlanDailyReactPrompt = `你是 SmartFlow 日内排程优化器。 -你将收到一份"混合日程表"(JSON 数组),其中每个条目包含: +你将收到一天内的日程安排(JSON 数组),其中: - status="existing":已确定的课程或任务,不可移动 -- status="suggested":粗排算法建议的学习任务,你可以通过工具调整它们的时间 +- status="suggested":粗排算法建议的学习任务,你可以调整 +- context_tag:任务认知类型(High-Logic/Memory/Review/General) -你的目标是优化 suggested 任务的时间安排,使最终方案科学合理。 +你的目标是优化这一天内 suggested 任务的时间安排。 ## 优化原则 - -1. 上下文切换成本:相同或相近科目的任务尽量安排在相邻时段,减少频繁切换带来的认知损耗 +1. 上下文切换成本:相同 context_tag 的任务尽量相邻,减少认知切换。 2. 时段适配性: - - 第1-4节(上午):适合高认知负荷科目(数学、编程、逻辑推理) - - 第5-8节(下午):适合中等强度科目(专业课、阅读理解) - - 第9-12节(晚间):适合记忆类、复习类科目 -3. 学习效率曲线:避免连续安排超过4节高强度学习,适当穿插不同类型的任务 -4. 间隔重复:同一科目的复习任务在时间上适当分散到不同天,符合遗忘曲线规律 -5. 用户约束:严格遵守用户提出的约束条件(如有) + - 第1-4节(上午):适合 High-Logic(数学、编程) + - 第5-8节(下午):适合中等强度(专业课、阅读) + - 第9-12节(晚间):适合 Memory 和 Review +3. 学习效率曲线:避免连续超过 4 节高强度学习。 +4. 与 existing 条目衔接:避免高强度课程后立刻接高强度任务。 ## 可用工具 - -1. Swap — 交换两个 suggested 任务的时间位置 +1. Swap — 交换两个 suggested 任务的时间 参数:task_a(task_item_id),task_b(task_item_id) - -2. Move — 将一个 suggested 任务移动到新的时间位置 +2. Move — 将一个 suggested 任务移动到新时间(仅限当天) 参数:task_item_id, to_week, to_day, to_section_from, to_section_to - 注意:目标位置必须空闲,且节次跨度必须与原任务一致 - -3. TimeAvailable — 检查目标时间段是否可用 +3. TimeAvailable — 检查时段是否可用 参数:week, day_of_week, section_from, section_to - -4. GetAvailableSlots — 获取可用时间段列表 - 参数:week(可选,不传则返回所有周) +4. GetAvailableSlots — 获取可用时段 + 参数:week ## 输出格式(严格 JSON,不要 markdown) - 调用工具时: -{"tool_calls":[{"tool":"Swap","params":{"task_a":10,"task_b":12}},{"tool":"Move","params":{"task_item_id":10,"to_week":1,"to_day":3,"to_section_from":5,"to_section_to":6}}]} +{"tool_calls":[{"tool":"Swap","params":{"task_a":10,"task_b":12}}]} 完成优化时: -{"done":true,"summary":"简要说明做了哪些优化及理由"} +{"done":true,"summary":"简要说明优化理由"} -## 工作流程 +重要:只修改 suggested 任务,不要尝试移动 existing 条目。` -1. 仔细分析当前排程,识别不合理之处 -2. 如需了解可用时间,先调用 GetAvailableSlots -3. 确定调整方案后,调用 Swap 或 Move 执行 -4. 你可以一次输出多个工具调用,后端会按顺序执行 -5. 当你认为排程已经足够合理,或者没有更好的调整空间,输出完成标记 + // SchedulePlanWeeklyReactPrompt 用于 weekly_refine 节点。 + // + // 设计重点: + // 1. 采用“单步动作”模式:每轮只做一个动作(Move/Swap)或直接 done; + // 2. 显式区分总预算与有效预算,避免模型对“次数扣减”产生困惑; + // 3. 明确“输入数据已过后端硬校验”,避免模型把合法嵌入误判为冲突; + // 4. 工具失败结果会回传到下一轮,模型只需“走一步看一步”。 + SchedulePlanWeeklyReactPrompt = `你是 SmartFlow 周级排程配平器。 -重要:只修改 status="suggested" 的任务,不要尝试移动 existing 条目。` +单日内的排程已优化完毕,你当前只负责“单周微调”。 + +## 数据可靠性前提(必须接受) +1. 你收到的混合日程 JSON 已经过后端硬冲突检查。 +2. 如果看到课程与任务在同一节次重叠,这表示“任务嵌入课程”的合法状态,不是异常。 +3. 你不需要再次判断“输入本身是否冲突”,只需要在这个可信基线上进行优化。 +4. 工具内部会做可用性与冲突校验;你无需额外调用“检查可用性工具”。 +5. 字段语义补充: + - existing 条目的 block_for_suggested=false:该课程格子允许嵌入 suggested 任务; + - suggested 条目的 block_for_suggested=true:表示该 suggested 本身会占位,防止被其他 suggested 再次重叠覆盖。 + +## 预算语义(必须遵守,且必须严格区分) +1. 总动作预算(剩余):{{action_total_remaining}} +2. 总动作预算(固定):{{action_total_budget}} +3. 总动作预算(已用):{{action_total_used}} +4. 有效动作预算(剩余):{{action_effective_remaining}} +5. 有效动作预算(固定):{{action_effective_budget}} +6. 有效动作预算(已用):{{action_effective_used}} +7. 规则: + - 每次工具调用(无论成功失败)都会消耗 1 次“总动作预算”; + - 仅当工具调用成功时,才会额外消耗 1 次“有效动作预算”。 +8. 你当前看到的是“剩余额度”,不是“总额度”,额度减少是前序动作正常消耗。 + +## 约束 +1. 只允许在当前周内优化(禁止跨周移动)。 +2. 每次回复只能做一件事:要么调用 1 个工具,要么 done。 +3. 严格遵守用户约束(如有)。 +4. 每个任务最多变动一次位置。 + +## 优化目标 +1. 疲劳度均衡:避免某一天堆积过多高强度任务(context_tag=High-Logic)。 +2. 间隔重复:同一科目任务适当分散到不同天。 +3. 科目多样性:尽量避免单一任务类型连续多天占据相同时段。 +4. 总量均衡:各天 suggested 数量大致均匀。 + +## 执行节奏(降低无效思考) +1. 想一步做一步:本轮只做“一个最有价值动作”。 +2. 不要一次规划多步;上一轮工具结果会传给下一轮,你可以继续接力。 +3. 如果当前方案已经足够好,直接 done,不要空转。 +4. 禁止输出多个工具调用;如果需要连续调整,请分多轮逐步完成。 + +## 可用工具 +1. Move — 将一个 suggested 任务移动到当前周的另一天/时段 + 参数:task_item_id, to_week, to_day, to_section_from, to_section_to + 注意:节次跨度必须与原任务一致 +2. Swap — 交换两个 suggested 任务的时间 + 参数:task_a, task_b(task_item_id) + +## 输出格式(严格 JSON,不要 markdown) +调用工具时(注意:tool_calls 里只能有 1 个元素): +{"tool_calls":[{"tool":"Move","params":{"task_item_id":10,"to_week":2,"to_day":3,"to_section_from":5,"to_section_to":6}}]} + +完成优化时: +{"done":true,"summary":"简要说明做了哪些跨天调整及理由"}` + + // SchedulePlanFinalCheckPrompt 用于 final_check 节点的人性化总结。 + // + // 职责边界: + // 1. 只做读数据总结,不参与工具调用与状态修改; + // 2. 输出面向用户的自然语言; + // 3. 失败由上层兜底文案处理。 + SchedulePlanFinalCheckPrompt = `你是 SmartFlow 排程方案总结专家。 +你的任务是为用户生成一段友好、自然的排程总结。 + +要求: +1. 用 2-3 句话概括方案亮点。 +2. 提及具体时间安排特征(如“上午安排高强度任务”“周末留出缓冲”)。 +3. 若用户有约束,说明方案如何满足这些约束。 +4. 输入里会包含“周级动作日志”,请结合日志说明优化过程的价值(例如更均衡、冲突更少、切换更顺)。 +5. 语气温暖自然。 +6. 只输出纯文本,不要输出 JSON。` ) diff --git a/backend/agent/scheduleplan/react.go b/backend/agent/scheduleplan/react.go index 5746ce6..3bf0ff0 100644 --- a/backend/agent/scheduleplan/react.go +++ b/backend/agent/scheduleplan/react.go @@ -4,29 +4,53 @@ import ( "context" "encoding/json" "fmt" - "io" + "sort" "strings" + "sync" "time" - "github.com/LoveLosita/smartflow/backend/agent/chat" + "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" ) -// reactRoundTimeout 是单轮 ReAct 的超时时间。 -// 深度思考模式下 reasoning 阶段可能耗时较长,需要给足时间。 -const reactRoundTimeout = 15 * time.Minute +const ( + // weeklyReactRoundTimeout 是周级“单步动作”单轮超时时间。 + // + // 说明: + // 1. 当前周级策略是“每轮只做一个动作”,单轮输入较短,超时可比旧版更保守; + // 2. 过长超时会放大长尾等待,影响并发周优化的整体收口速度。 + weeklyReactRoundTimeout = 4 * time.Minute +) -// runReactRefineNode 执行 ReAct 精排循环。 +// weeklyRefineWorkerResult 是“单周 worker”输出。 // -// 核心流程(最多 ReactMaxRound 轮): -// 1. 构造 messages(system prompt + 混合日程 JSON + 上轮 tool 结果) -// 2. 调用 chatModel.Stream() + ThinkingTypeEnabled -// 3. reasoning_content 实时推送到 outChan(前端可见思考过程) -// 4. content 累积后解析:done=true 则退出,tool_calls 则执行 -// 5. tool 结果拼入下一轮 messages -func runReactRefineNode( +// 职责边界: +// 1. 记录该周优化后的 entries; +// 2. 记录预算消耗(总动作/有效动作); +// 3. 记录动作日志,供 final_check 生成“过程可解释”总结; +// 4. 记录该周摘要,便于最终汇总。 +type weeklyRefineWorkerResult struct { + Week int + Entries []model.HybridScheduleEntry + TotalUsed int + EffectiveUsed int + Summary string + ActionLogs []string +} + +// runWeeklyRefineNode 执行“周级单步优化”。 +// +// 新链路目标: +// 1. 把全量周数据拆成“按周并发”执行,降低单次模型输入规模; +// 2. 每轮只允许一个动作(Move/Swap)或 done,减少模型犹豫; +// 3. 使用“双预算”约束迭代: +// 3.1 总动作预算:成功/失败都扣减; +// 3.2 有效动作预算:仅成功动作扣减; +// 4. 不在该阶段输出 reasoning 文本,改为阶段状态 + 动作结果,避免刷屏。 +func runWeeklyRefineNode( ctx context.Context, st *SchedulePlanState, chatModel *ark.ChatModel, @@ -34,176 +58,790 @@ func runReactRefineNode( modelName string, emitStage func(stage, detail string), ) (*SchedulePlanState, error) { + _ = outChan if st == nil { - return nil, fmt.Errorf("schedule plan graph: nil state in reactRefine node") + return nil, fmt.Errorf("schedule plan weekly refine: nil state") } if chatModel == nil { - return nil, fmt.Errorf("schedule plan graph: model is nil in reactRefine node") + return nil, fmt.Errorf("schedule plan weekly refine: model is nil") } if len(st.HybridEntries) == 0 { st.ReactDone = true st.ReactSummary = "无可优化的排程条目。" return st, nil } - - // 准备 SSE 流式输出的基础参数 if strings.TrimSpace(modelName) == "" { - modelName = "smartflow-worker" + modelName = "worker" } - // 构造混合日程 JSON(只在首轮构造,后续轮次复用) - hybridJSON, err := json.Marshal(st.HybridEntries) - if err != nil { - return nil, fmt.Errorf("序列化混合日程失败: %w", err) + // 1. 预算与并发兜底。 + // 1.1 有效预算(旧字段)<=0 时回退默认值; + // 1.2 总预算 <=0 时回退默认值; + // 1.3 为避免“有效预算 > 总预算”的反直觉状态,做一次归一化修正; + // 1.4 周级并发度默认不高于周数,避免空并发浪费。 + if st.WeeklyAdjustBudget <= 0 { + st.WeeklyAdjustBudget = schedulePlanDefaultWeeklyAdjustBudget + } + if st.WeeklyTotalBudget <= 0 { + st.WeeklyTotalBudget = schedulePlanDefaultWeeklyTotalBudget + } + if st.WeeklyAdjustBudget > st.WeeklyTotalBudget { + st.WeeklyAdjustBudget = st.WeeklyTotalBudget + } + if st.WeeklyRefineConcurrency <= 0 { + st.WeeklyRefineConcurrency = schedulePlanDefaultWeeklyRefineConcurrency } - // 用户约束文本 - constraintsText := "无" - if len(st.Constraints) > 0 { - constraintsText = strings.Join(st.Constraints, "、") - } - - // 对话历史:跨轮次累积 - messages := []*schema.Message{ - schema.SystemMessage(SchedulePlanReactSystemPrompt), - schema.UserMessage(fmt.Sprintf( - "以下是当前混合日程(JSON):\n%s\n\n用户约束:%s\n\n请分析并优化 suggested 任务的时间安排。", - string(hybridJSON), constraintsText, - )), - } - - // ── ReAct 主循环 ── - for st.ReactRound < st.ReactMaxRound { - st.ReactRound++ - emitStage("schedule_plan.react.round", fmt.Sprintf("第 %d 轮优化思考...", st.ReactRound)) - - // 1. 带超时的 context - roundCtx, cancel := context.WithTimeout(ctx, reactRoundTimeout) - - // 2. 调用模型(流式 + 深度思考) - content, streamErr := streamReactRound(roundCtx, chatModel, modelName, messages, outChan) - cancel() - - if streamErr != nil { - emitStage("schedule_plan.react.error", fmt.Sprintf("第 %d 轮模型调用失败: %s", st.ReactRound, streamErr.Error())) - // 明确标记为失败,不伪装成功 - st.ReactDone = true - st.ReactSummary = fmt.Sprintf("排程优化未完成:第 %d 轮模型调用超时或失败,使用粗排结果。", st.ReactRound) - break - } - - // 3. 解析 LLM 输出 - parsed, parseErr := parseReactLLMOutput(content) - if parseErr != nil { - // 解析失败,把原始输出当作摘要,结束循环 - emitStage("schedule_plan.react.parse_error", "LLM 输出格式异常,结束优化。") - st.ReactSummary = "排程优化已完成(LLM 输出格式异常,使用当前结果)。" - st.ReactDone = true - break - } - - // 4. 检查是否完成 - if parsed.Done { - st.ReactSummary = parsed.Summary - st.ReactDone = true - emitStage("schedule_plan.react.done", "优化完成。") - break - } - - // 5. 执行 tool calls - if len(parsed.ToolCalls) == 0 { - // 没有 tool 调用也没有 done,视为完成 - st.ReactSummary = "排程优化已完成。" - st.ReactDone = true - break - } - - results := make([]reactToolResult, 0, len(parsed.ToolCalls)) - for _, call := range parsed.ToolCalls { - var result reactToolResult - st.HybridEntries, result = dispatchReactTool(st.HybridEntries, call) - results = append(results, result) - statusMark := "OK" - if !result.Success { - statusMark = "FAIL" - } - emitStage("schedule_plan.react.tool_call", - fmt.Sprintf("[%s] %s: %s", statusMark, result.Tool, result.Result)) - } - - // 6. 将 tool 结果拼入下一轮 messages - // 先追加 assistant 的输出 - messages = append(messages, schema.AssistantMessage(content, nil)) - // 再追加 tool 结果作为 user message - resultsJSON, _ := json.Marshal(results) - messages = append(messages, schema.UserMessage( - fmt.Sprintf("工具执行结果:\n%s\n\n请继续优化,或输出 {\"done\":true,\"summary\":\"...\"} 完成。", string(resultsJSON)), - )) - } - - // 循环结束兜底 - if !st.ReactDone { + // 2. 按周拆分输入。 + weekOrder, weekEntries := splitHybridEntriesByWeek(st.HybridEntries) + if len(weekOrder) == 0 { st.ReactDone = true - if strings.TrimSpace(st.ReactSummary) == "" { - st.ReactSummary = fmt.Sprintf("排程优化已达最大轮次(%d 轮),使用当前结果。", st.ReactRound) - } - emitStage("schedule_plan.react.max_round", "已达最大优化轮次,使用当前结果。") + st.ReactSummary = "无可优化的排程条目。" + return st, nil } - return st, nil -} + // 3. 只对“包含 suggested 的周”分配预算,其余周直接透传。 + activeWeeks := make([]int, 0, len(weekOrder)) + for _, week := range weekOrder { + if countSuggested(weekEntries[week]) > 0 { + activeWeeks = append(activeWeeks, week) + } + } + if len(activeWeeks) == 0 { + st.ReactDone = true + st.ReactSummary = "当前方案中没有可调整的 suggested 任务,已跳过周级优化。" + return st, nil + } -// streamReactRound 执行单轮 ReAct 模型调用: -// - 流式推送 reasoning_content 到 outChan(前端可见思考过程) -// - 累积 content 并返回(包含 tool_calls 或 done 信号) -func streamReactRound( - ctx context.Context, - chatModel *ark.ChatModel, - modelName string, - messages []*schema.Message, - outChan chan<- string, -) (string, error) { - // 开启深度思考 - reader, err := chatModel.Stream(ctx, messages, - ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}), + // 3.1 强制“每个有效周至少 1 个总预算 + 1 个有效预算”。 + // 3.1.1 判断依据:任何有效周都必须有机会进入优化,避免出现 0 预算跳过。 + // 3.1.2 实现方式:当全局预算不足时,自动抬升到 activeWeeks 数量。 + // 3.1.3 失败/兜底:该步骤仅做内存字段修正,不依赖外部资源,不会新增失败点。 + minBudgetRequired := len(activeWeeks) + if st.WeeklyTotalBudget < minBudgetRequired { + st.WeeklyTotalBudget = minBudgetRequired + } + if st.WeeklyAdjustBudget < minBudgetRequired { + st.WeeklyAdjustBudget = minBudgetRequired + } + if st.WeeklyAdjustBudget > st.WeeklyTotalBudget { + st.WeeklyAdjustBudget = st.WeeklyTotalBudget + } + + totalBudgetByWeek, effectiveBudgetByWeek, weeklyLoads, coveredWeeks := splitWeeklyBudgetsByLoad( + activeWeeks, + weekEntries, + st.WeeklyTotalBudget, + st.WeeklyAdjustBudget, ) - if err != nil { - return "", fmt.Errorf("模型 Stream 调用失败: %w", err) + budgetIndexByWeek := make(map[int]int, len(activeWeeks)) + for idx, week := range activeWeeks { + budgetIndexByWeek[week] = idx + } + if coveredWeeks < len(activeWeeks) { + emitStage( + "schedule_plan.weekly_refine.budget_fallback", + fmt.Sprintf( + "周级预算不足以覆盖全部有效周(有效周=%d,至少需预算=%d;当前总预算=%d,有效预算=%d)。已按周负载优先覆盖 %d 个周,其余周预算置 0 并透传原方案。", + len(activeWeeks), + len(activeWeeks), + st.WeeklyTotalBudget, + st.WeeklyAdjustBudget, + coveredWeeks, + ), + ) } - defer reader.Close() - requestID := "react-" + fmt.Sprintf("%d", time.Now().UnixMilli()) - created := time.Now().Unix() - var contentBuilder strings.Builder + workerConcurrency := st.WeeklyRefineConcurrency + if workerConcurrency > len(activeWeeks) { + workerConcurrency = len(activeWeeks) + } + if workerConcurrency <= 0 { + workerConcurrency = 1 + } - for { - chunk, recvErr := reader.Recv() - if recvErr == io.EOF { - break - } - if recvErr != nil { - return contentBuilder.String(), fmt.Errorf("流式接收失败: %w", recvErr) - } - if chunk == nil { + emitStage( + "schedule_plan.weekly_refine.start", + fmt.Sprintf( + "周级单步优化开始:周数=%d(可优化=%d),并发度=%d,总动作预算=%d,有效动作预算=%d,覆盖周=%d/%d,周负载=%v。", + len(weekOrder), + len(activeWeeks), + workerConcurrency, + st.WeeklyTotalBudget, + st.WeeklyAdjustBudget, + coveredWeeks, + len(activeWeeks), + weeklyLoads, + ), + ) + + // 4. 并发执行“单周 worker”。 + sem := make(chan struct{}, workerConcurrency) + var wg sync.WaitGroup + var mu sync.Mutex + + workerResults := make(map[int]weeklyRefineWorkerResult, len(weekOrder)) + var firstErr error + completedWeeks := 0 + + for _, week := range weekOrder { + week := week + entries := deepCopyEntries(weekEntries[week]) + + // 4.1 没有 suggested 的周直接透传,不占模型调用预算。 + if countSuggested(entries) == 0 { + workerResults[week] = weeklyRefineWorkerResult{ + Week: week, + Entries: entries, + Summary: fmt.Sprintf("W%d 无 suggested 任务,跳过周级优化。", week), + } continue } - // 推送 reasoning_content 到前端(实时思考过程) - if chunk.ReasoningContent != "" && outChan != nil { - payload, fmtErr := chat.ToOpenAIStream( - &schema.Message{ReasoningContent: chunk.ReasoningContent}, - requestID, modelName, created, false, + wg.Add(1) + go func() { + defer wg.Done() + + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + mu.Lock() + if firstErr == nil { + firstErr = ctx.Err() + } + completedWeeks++ + workerResults[week] = weeklyRefineWorkerResult{ + Week: week, + Entries: entries, + Summary: fmt.Sprintf("W%d 优化取消,已保留原方案。", week), + } + emitStage( + "schedule_plan.weekly_refine.week_done", + fmt.Sprintf("W%d 已取消并回退原方案。(进度 %d/%d)", week, completedWeeks, len(activeWeeks)), + ) + mu.Unlock() + return + } + + idx := budgetIndexByWeek[week] + weekTotalBudget := totalBudgetByWeek[idx] + weekEffectiveBudget := effectiveBudgetByWeek[idx] + emitStage( + "schedule_plan.weekly_refine.week_start", + fmt.Sprintf( + "W%d 开始周级单步优化:总预算=%d,有效预算=%d。", + week, + weekTotalBudget, + weekEffectiveBudget, + ), ) - if fmtErr == nil && payload != "" { - outChan <- payload + + result, workerErr := runSingleWeekRefineWorker( + ctx, + chatModel, + modelName, + week, + entries, + st.Constraints, + weeklyPlanningWindow{ + Enabled: st.HasPlanningWindow, + StartWeek: st.PlanStartWeek, + StartDay: st.PlanStartDay, + EndWeek: st.PlanEndWeek, + EndDay: st.PlanEndDay, + }, + weekTotalBudget, + weekEffectiveBudget, + emitStage, + ) + + mu.Lock() + defer mu.Unlock() + if workerErr != nil && firstErr == nil { + firstErr = workerErr + } + completedWeeks++ + workerResults[week] = result + emitStage( + "schedule_plan.weekly_refine.week_done", + fmt.Sprintf( + "W%d 周级优化完成(总已用=%d/%d,有效已用=%d/%d)。(进度 %d/%d)", + week, + result.TotalUsed, + weekTotalBudget, + result.EffectiveUsed, + weekEffectiveBudget, + completedWeeks, + len(activeWeeks), + ), + ) + }() + } + wg.Wait() + + // 5. 汇总 worker 结果,重建全量 HybridEntries。 + mergedEntries := make([]model.HybridScheduleEntry, 0, len(st.HybridEntries)) + st.WeeklyTotalUsed = 0 + st.WeeklyAdjustUsed = 0 + st.WeeklyActionLogs = st.WeeklyActionLogs[:0] + weekSummaries := make([]string, 0, len(weekOrder)) + + for _, week := range weekOrder { + result, exists := workerResults[week] + if !exists { + // 理论上不会发生;兜底透传该周原始条目。 + result = weeklyRefineWorkerResult{ + Week: week, + Entries: deepCopyEntries(weekEntries[week]), + Summary: fmt.Sprintf("W%d 未拿到 worker 结果,已保留原方案。", week), } } - - // 累积 content(tool_calls 或 done 信号) - if chunk.Content != "" { - contentBuilder.WriteString(chunk.Content) + mergedEntries = append(mergedEntries, result.Entries...) + st.WeeklyTotalUsed += result.TotalUsed + st.WeeklyAdjustUsed += result.EffectiveUsed + st.WeeklyActionLogs = append(st.WeeklyActionLogs, result.ActionLogs...) + if strings.TrimSpace(result.Summary) != "" { + weekSummaries = append(weekSummaries, result.Summary) } } + sortHybridEntries(mergedEntries) + st.HybridEntries = mergedEntries - return strings.TrimSpace(contentBuilder.String()), nil + // 6. 生成阶段摘要并收口状态。 + st.ReactDone = true + st.ReactRound = st.WeeklyTotalUsed + if len(weekSummaries) == 0 { + st.ReactSummary = fmt.Sprintf( + "周级优化完成:总动作已用 %d/%d,有效动作已用 %d/%d。", + st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget, + ) + } else { + st.ReactSummary = strings.Join(weekSummaries, ";") + } + if firstErr != nil { + emitStage("schedule_plan.weekly_refine.partial_error", fmt.Sprintf("周级并发优化部分失败,已自动保留失败周原方案。原因:%s", firstErr.Error())) + } + emitStage( + "schedule_plan.weekly_refine.done", + fmt.Sprintf( + "周级单步优化结束:总动作已用 %d/%d,有效动作已用 %d/%d。", + st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget, + ), + ) + return st, nil +} + +// runSingleWeekRefineWorker 执行“单周 + 单步动作”循环。 +// +// 流程说明: +// 1. 每轮只允许 1 个工具调用或 done; +// 2. 每次工具调用都扣“总预算”; +// 3. 仅成功调用再扣“有效预算”; +// 4. 工具结果会回灌到下一轮上下文,驱动“走一步看一步”。 +func runSingleWeekRefineWorker( + ctx context.Context, + chatModel *ark.ChatModel, + modelName string, + week int, + entries []model.HybridScheduleEntry, + constraints []string, + window weeklyPlanningWindow, + totalBudget int, + effectiveBudget int, + emitStage func(stage, detail string), +) (weeklyRefineWorkerResult, error) { + result := weeklyRefineWorkerResult{ + Week: week, + Entries: deepCopyEntries(entries), + } + + if totalBudget <= 0 || effectiveBudget <= 0 { + result.Summary = fmt.Sprintf("W%d 预算为 0,跳过周级优化。", week) + return result, nil + } + + hybridJSON, err := json.Marshal(result.Entries) + if err != nil { + result.Summary = fmt.Sprintf("W%d 序列化失败,已保留原方案。", week) + return result, fmt.Errorf("周级 worker 序列化失败 week=%d: %w", week, err) + } + constraintsText := "无" + if len(constraints) > 0 { + constraintsText = strings.Join(constraints, "、") + } + + messages := []*schema.Message{ + schema.SystemMessage( + renderWeeklyPromptWithBudget( + effectiveBudget-result.EffectiveUsed, + effectiveBudget, + result.EffectiveUsed, + totalBudget-result.TotalUsed, + totalBudget, + result.TotalUsed, + ), + ), + schema.UserMessage(fmt.Sprintf( + "当前处理周次:W%d\n以下是当前周混合日程(JSON):\n%s\n\n用户约束:%s\n\n注意:本 worker 仅允许优化 W%d 内的任务。", + week, + string(hybridJSON), + constraintsText, + week, + )), + } + + for result.TotalUsed < totalBudget && result.EffectiveUsed < effectiveBudget { + remainingTotal := totalBudget - result.TotalUsed + remainingEffective := effectiveBudget - result.EffectiveUsed + emitStage( + "schedule_plan.weekly_refine.round", + fmt.Sprintf( + "W%d 新一轮决策:总预算剩余=%d/%d,有效预算剩余=%d/%d。", + week, + remainingTotal, + totalBudget, + remainingEffective, + effectiveBudget, + ), + ) + + // 1. 每轮更新系统提示中的预算占位符。 + messages[0] = schema.SystemMessage( + renderWeeklyPromptWithBudget( + remainingEffective, + effectiveBudget, + result.EffectiveUsed, + remainingTotal, + totalBudget, + result.TotalUsed, + ), + ) + + roundCtx, cancel := context.WithTimeout(ctx, weeklyReactRoundTimeout) + content, genErr := generateWeeklyRefineRound(roundCtx, chatModel, messages) + cancel() + if genErr != nil { + result.Summary = fmt.Sprintf("W%d 模型调用失败,已保留当前结果。", week) + return result, fmt.Errorf("周级 worker 调用失败 week=%d: %w", week, genErr) + } + + parsed, parseErr := parseReactLLMOutput(content) + if parseErr != nil { + result.Summary = fmt.Sprintf("W%d 输出格式异常,已保留当前结果。", week) + return result, fmt.Errorf("周级 worker 解析失败 week=%d: %w", week, parseErr) + } + + // 2. done=true 直接正常结束,不再消耗预算。 + if parsed.Done { + summary := strings.TrimSpace(parsed.Summary) + if summary == "" { + summary = fmt.Sprintf( + "W%d 优化结束(总动作已用 %d/%d,有效动作已用 %d/%d)。", + week, + result.TotalUsed, totalBudget, + result.EffectiveUsed, effectiveBudget, + ) + } + result.Summary = summary + break + } + + // 3. 只取一个工具调用,强制单步。 + call, warn := pickSingleToolCall(parsed.ToolCalls) + if call == nil { + result.Summary = fmt.Sprintf( + "W%d 无可执行动作,提前结束(总动作已用 %d/%d,有效动作已用 %d/%d)。", + week, + result.TotalUsed, totalBudget, + result.EffectiveUsed, effectiveBudget, + ) + break + } + if warn != "" { + result.ActionLogs = append(result.ActionLogs, fmt.Sprintf("W%d 警告:%s", week, warn)) + } + + // 4. 执行工具:总预算总是扣减;有效预算仅成功时扣减。 + result.TotalUsed++ + nextEntries, toolResult := dispatchWeeklySingleActionTool(result.Entries, *call, week, window) + if toolResult.Success { + result.EffectiveUsed++ + result.Entries = nextEntries + } + + logLine := fmt.Sprintf( + "W%d 动作[%s] 结果=%t,总预算=%d/%d,有效预算=%d/%d,详情=%s", + week, + toolResult.Tool, + toolResult.Success, + result.TotalUsed, + totalBudget, + result.EffectiveUsed, + effectiveBudget, + toolResult.Result, + ) + result.ActionLogs = append(result.ActionLogs, logLine) + statusMark := "FAIL" + if toolResult.Success { + statusMark = "OK" + } + emitStage("schedule_plan.weekly_refine.tool_call", fmt.Sprintf("[%s] %s", statusMark, logLine)) + + // 5. 把“本轮输出 + 工具结果”拼回下一轮上下文,驱动增量推理。 + messages = append(messages, schema.AssistantMessage(content, nil)) + toolResultJSON, _ := json.Marshal([]reactToolResult{toolResult}) + messages = append(messages, schema.UserMessage( + fmt.Sprintf( + "上一轮工具结果:%s\n当前预算:总剩余=%d,有效剩余=%d\n请继续按“单步动作”规则决策(仅一个工具调用或 done)。", + string(toolResultJSON), + totalBudget-result.TotalUsed, + effectiveBudget-result.EffectiveUsed, + ), + )) + } + + if strings.TrimSpace(result.Summary) == "" { + result.Summary = fmt.Sprintf( + "W%d 预算耗尽停止(总动作已用 %d/%d,有效动作已用 %d/%d)。", + week, + result.TotalUsed, totalBudget, + result.EffectiveUsed, effectiveBudget, + ) + } + return result, nil +} + +// generateWeeklyRefineRound 调用模型生成“单周单步”决策输出。 +// +// 说明: +// 1. 周级仍保留 thinking(提高复杂排程准确率); +// 2. 但不把 reasoning 实时透传给前端,避免刷屏; +// 3. 仅返回最终 content,交给 JSON 解析器处理。 +func generateWeeklyRefineRound( + ctx context.Context, + chatModel *ark.ChatModel, + messages []*schema.Message, +) (string, error) { + resp, err := chatModel.Generate( + ctx, + messages, + ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}), + einoModel.WithTemperature(0.2), + ) + if err != nil { + return "", err + } + if resp == nil { + return "", fmt.Errorf("周级单步调用返回为空") + } + content := strings.TrimSpace(resp.Content) + if content == "" { + return "", fmt.Errorf("周级单步调用返回内容为空") + } + return content, nil +} + +// renderWeeklyPromptWithBudget 渲染周级单步优化的预算占位符。 +// +// 1. 保留旧占位符 {{budget*}} 兼容历史模板; +// 2. 新增 action_total/action_effective 占位符表达双预算语义; +// 3. 所有负值都会在这里兜底归零,避免传给模型异常预算。 +func renderWeeklyPromptWithBudget( + remainingEffective int, + effectiveBudget int, + usedEffective int, + remainingTotal int, + totalBudget int, + usedTotal int, +) string { + if effectiveBudget <= 0 { + effectiveBudget = schedulePlanDefaultWeeklyAdjustBudget + } + if totalBudget <= 0 { + totalBudget = schedulePlanDefaultWeeklyTotalBudget + } + if remainingEffective < 0 { + remainingEffective = 0 + } + if remainingTotal < 0 { + remainingTotal = 0 + } + if usedEffective < 0 { + usedEffective = 0 + } + if usedTotal < 0 { + usedTotal = 0 + } + if usedEffective > effectiveBudget { + usedEffective = effectiveBudget + } + if usedTotal > totalBudget { + usedTotal = totalBudget + } + + prompt := SchedulePlanWeeklyReactPrompt + prompt = strings.ReplaceAll(prompt, "{{action_total_remaining}}", fmt.Sprintf("%d", remainingTotal)) + prompt = strings.ReplaceAll(prompt, "{{action_total_budget}}", fmt.Sprintf("%d", totalBudget)) + prompt = strings.ReplaceAll(prompt, "{{action_total_used}}", fmt.Sprintf("%d", usedTotal)) + prompt = strings.ReplaceAll(prompt, "{{action_effective_remaining}}", fmt.Sprintf("%d", remainingEffective)) + prompt = strings.ReplaceAll(prompt, "{{action_effective_budget}}", fmt.Sprintf("%d", effectiveBudget)) + prompt = strings.ReplaceAll(prompt, "{{action_effective_used}}", fmt.Sprintf("%d", usedEffective)) + + // 兼容旧模板占位符,避免历史 prompt 残留时出现未替换文本。 + prompt = strings.ReplaceAll(prompt, "{{budget_remaining}}", fmt.Sprintf("%d", remainingEffective)) + prompt = strings.ReplaceAll(prompt, "{{budget_total}}", fmt.Sprintf("%d", effectiveBudget)) + prompt = strings.ReplaceAll(prompt, "{{budget_used}}", fmt.Sprintf("%d", usedEffective)) + prompt = strings.ReplaceAll(prompt, "{{budget}}", fmt.Sprintf("%d(总额度 %d,已用 %d)", remainingEffective, effectiveBudget, usedEffective)) + return prompt +} + +// pickSingleToolCall 在“单步动作模式”下选择一个工具调用。 +// +// 返回语义: +// 1. call=nil:没有可执行工具; +// 2. warn 非空:模型返回了多个工具,本轮仅执行第一个。 +func pickSingleToolCall(toolCalls []reactToolCall) (*reactToolCall, string) { + if len(toolCalls) == 0 { + return nil, "" + } + call := toolCalls[0] + if len(toolCalls) == 1 { + return &call, "" + } + return &call, fmt.Sprintf("模型返回了 %d 个工具调用,单步模式仅执行第一个:%s", len(toolCalls), call.Tool) +} + +// splitHybridEntriesByWeek 按 week 对混合条目分组并返回稳定周序。 +func splitHybridEntriesByWeek(entries []model.HybridScheduleEntry) ([]int, map[int][]model.HybridScheduleEntry) { + byWeek := make(map[int][]model.HybridScheduleEntry) + for _, entry := range entries { + byWeek[entry.Week] = append(byWeek[entry.Week], entry) + } + weeks := make([]int, 0, len(byWeek)) + for week := range byWeek { + weeks = append(weeks, week) + } + sort.Ints(weeks) + return weeks, byWeek +} + +type weightedBudgetRemainder struct { + Index int + Remainder int + Load int +} + +// splitWeeklyBudgetsByLoad 根据“有效周保底 + 周负载加权”拆分预算。 +// +// 职责边界: +// 1. 负责:返回与 activeWeeks 同索引对齐的总预算/有效预算; +// 2. 负责:在预算不足时按负载优先覆盖高负载周; +// 3. 不负责:执行周级动作与状态落盘(由 runSingleWeekRefineWorker / runWeeklyRefineNode 负责)。 +// +// 输入输出语义: +// 1. coveredWeeks 表示“同时拿到 >=1 总预算和 >=1 有效预算”的周数; +// 2. 当任一全局预算 <=0 时,返回全 0;上游将据此跳过对应周优化; +// 3. 返回的 weeklyLoads 仅用于可观测性,不参与后续状态持久化。 +func splitWeeklyBudgetsByLoad( + activeWeeks []int, + weekEntries map[int][]model.HybridScheduleEntry, + totalBudget int, + effectiveBudget int, +) (totalByWeek []int, effectiveByWeek []int, weeklyLoads []int, coveredWeeks int) { + weekCount := len(activeWeeks) + if weekCount == 0 { + return nil, nil, nil, 0 + } + + if totalBudget < 0 { + totalBudget = 0 + } + if effectiveBudget < 0 { + effectiveBudget = 0 + } + + weeklyLoads = buildWeeklyLoadScores(activeWeeks, weekEntries) + totalByWeek = make([]int, weekCount) + effectiveByWeek = make([]int, weekCount) + if totalBudget == 0 || effectiveBudget == 0 { + return totalByWeek, effectiveByWeek, weeklyLoads, 0 + } + + // 1. 先计算“可保底覆盖周数”。 + // 1.1 目标是每个有效周至少 1 个总预算 + 1 个有效预算; + // 1.2 失败场景:当预算小于有效周数量时,不可能全覆盖; + // 1.3 兜底策略:只覆盖高负载周,避免把预算分散到无法执行的周。 + coveredWeeks = weekCount + if totalBudget < coveredWeeks { + coveredWeeks = totalBudget + } + if effectiveBudget < coveredWeeks { + coveredWeeks = effectiveBudget + } + if coveredWeeks <= 0 { + return totalByWeek, effectiveByWeek, weeklyLoads, 0 + } + + coveredIndexes := pickTopLoadWeekIndexes(weeklyLoads, coveredWeeks) + for _, idx := range coveredIndexes { + totalByWeek[idx]++ + effectiveByWeek[idx]++ + } + + // 2. 再把剩余预算按周负载加权分配。 + // 2.1 判断依据:负载越高,给到的额外预算越多,优先解决高密度周; + // 2.2 失败场景:负载异常(<=0)会导致权重失真; + // 2.3 兜底策略:权重最小按 1 处理,保证分配可持续、不会 panic。 + addWeightedBudget(totalByWeek, weeklyLoads, coveredIndexes, totalBudget-coveredWeeks) + addWeightedBudget(effectiveByWeek, weeklyLoads, coveredIndexes, effectiveBudget-coveredWeeks) + return totalByWeek, effectiveByWeek, weeklyLoads, coveredWeeks +} + +// buildWeeklyLoadScores 计算每个有效周的负载评分。 +// +// 职责边界: +// 1. 负责:以 suggested 任务的节次跨度作为周负载; +// 2. 不负责:预算分配策略与排序决策(由 splitWeeklyBudgetsByLoad/pickTopLoadWeekIndexes 负责)。 +func buildWeeklyLoadScores( + activeWeeks []int, + weekEntries map[int][]model.HybridScheduleEntry, +) []int { + loads := make([]int, len(activeWeeks)) + for idx, week := range activeWeeks { + load := 0 + for _, entry := range weekEntries[week] { + if entry.Status != "suggested" { + continue + } + span := entry.SectionTo - entry.SectionFrom + 1 + if span <= 0 { + span = 1 + } + load += span + } + if load <= 0 { + // 兜底:脏数据或异常节次下仍给该周最小权重,避免被完全饿死。 + load = 1 + } + loads[idx] = load + } + return loads +} + +// pickTopLoadWeekIndexes 选择负载最高的 topN 个周索引。 +func pickTopLoadWeekIndexes(loads []int, topN int) []int { + if topN <= 0 || len(loads) == 0 { + return nil + } + indexes := make([]int, len(loads)) + for i := range loads { + indexes[i] = i + } + sort.SliceStable(indexes, func(i, j int) bool { + left := loads[indexes[i]] + right := loads[indexes[j]] + if left != right { + return left > right + } + return indexes[i] < indexes[j] + }) + if topN > len(indexes) { + topN = len(indexes) + } + selected := append([]int(nil), indexes[:topN]...) + sort.Ints(selected) + return selected +} + +// addWeightedBudget 把剩余预算按权重分配到目标周。 +// +// 说明: +// 1. 先按整数份额分配; +// 2. 再按“最大余数法”分发尾差,保证总和严格守恒; +// 3. 余数相同时优先高负载周,再按索引稳定排序,避免结果抖动。 +func addWeightedBudget( + budgets []int, + loads []int, + targetIndexes []int, + remainingBudget int, +) { + if remainingBudget <= 0 || len(targetIndexes) == 0 { + return + } + + totalLoad := 0 + normalizedLoadByIndex := make(map[int]int, len(targetIndexes)) + for _, idx := range targetIndexes { + load := 1 + if idx >= 0 && idx < len(loads) && loads[idx] > 0 { + load = loads[idx] + } + normalizedLoadByIndex[idx] = load + totalLoad += load + } + if totalLoad <= 0 { + // 理论上不会出现;兜底均匀轮询分配,保证不会丢预算。 + for i := 0; i < remainingBudget; i++ { + budgets[targetIndexes[i%len(targetIndexes)]]++ + } + return + } + + allocated := 0 + remainders := make([]weightedBudgetRemainder, 0, len(targetIndexes)) + for _, idx := range targetIndexes { + load := normalizedLoadByIndex[idx] + shareProduct := remainingBudget * load + share := shareProduct / totalLoad + budgets[idx] += share + allocated += share + remainders = append(remainders, weightedBudgetRemainder{ + Index: idx, + Remainder: shareProduct % totalLoad, + Load: load, + }) + } + + left := remainingBudget - allocated + if left <= 0 { + return + } + sort.SliceStable(remainders, func(i, j int) bool { + if remainders[i].Remainder != remainders[j].Remainder { + return remainders[i].Remainder > remainders[j].Remainder + } + if remainders[i].Load != remainders[j].Load { + return remainders[i].Load > remainders[j].Load + } + return remainders[i].Index < remainders[j].Index + }) + for i := 0; i < left; i++ { + budgets[remainders[i%len(remainders)].Index]++ + } +} + +// sortHybridEntries 对条目做稳定排序,确保后续预览输出稳定。 +func sortHybridEntries(entries []model.HybridScheduleEntry) { + sort.SliceStable(entries, func(i, j int) bool { + left := entries[i] + right := entries[j] + if left.Week != right.Week { + return left.Week < right.Week + } + if left.DayOfWeek != right.DayOfWeek { + return left.DayOfWeek < right.DayOfWeek + } + if left.SectionFrom != right.SectionFrom { + return left.SectionFrom < right.SectionFrom + } + if left.SectionTo != right.SectionTo { + return left.SectionTo < right.SectionTo + } + if left.Status != right.Status { + // existing 放前,suggested 放后,便于观察课表底板与建议层。 + return left.Status < right.Status + } + return left.Name < right.Name + }) } diff --git a/backend/agent/scheduleplan/runner.go b/backend/agent/scheduleplan/runner.go index a62c673..e2f99ca 100644 --- a/backend/agent/scheduleplan/runner.go +++ b/backend/agent/scheduleplan/runner.go @@ -7,12 +7,12 @@ import ( "github.com/cloudwego/eino/schema" ) -// schedulePlanRunner 是"单次图运行"的请求级依赖容器。 +// schedulePlanRunner 是“单次图执行”的请求级依赖容器。 // // 设计目标: -// 1) 把节点运行所需依赖(model/deps/emit/extra/history)就近收口; -// 2) 让 graph.go 只保留"节点连线"和"方法引用",提升可读性; -// 3) 避免在 graph.go 里重复出现内联闭包和参数透传。 +// 1. 把节点运行所需依赖(model/deps/emit/extra/history)就近收口; +// 2. 让 graph.go 只保留“节点连线与分支决策”,提升可读性; +// 3. 避免在 graph.go 里重复出现大量闭包和参数透传。 type schedulePlanRunner struct { chatModel *ark.ChatModel deps SchedulePlanToolDeps @@ -20,13 +20,15 @@ type schedulePlanRunner struct { userMessage string extra map[string]any chatHistory []*schema.Message - // ── ReAct 精排所需 ── - outChan chan<- string // SSE 流式输出通道,用于推送 reasoning_content - modelName string // 模型名称,用于构造 OpenAI 兼容 chunk + + // weekly refine 需要的上下文 + outChan chan<- string + modelName string + + // daily refine 并发度 + dailyRefineConcurrency int } -// newSchedulePlanRunner 构造请求级 runner。 -// 生命周期仅限一次 graph invoke,不做跨请求复用。 func newSchedulePlanRunner( chatModel *ark.ChatModel, deps SchedulePlanToolDeps, @@ -36,37 +38,49 @@ func newSchedulePlanRunner( chatHistory []*schema.Message, outChan chan<- string, modelName string, + dailyRefineConcurrency int, ) *schedulePlanRunner { return &schedulePlanRunner{ - chatModel: chatModel, - deps: deps, - emitStage: emitStage, - userMessage: userMessage, - extra: extra, - chatHistory: chatHistory, - outChan: outChan, - modelName: modelName, + chatModel: chatModel, + deps: deps, + emitStage: emitStage, + userMessage: userMessage, + extra: extra, + chatHistory: chatHistory, + outChan: outChan, + modelName: modelName, + dailyRefineConcurrency: dailyRefineConcurrency, } } -// ── 节点方法引用适配层 ── +// 节点方法适配层 func (r *schedulePlanRunner) planNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { return runPlanNode(ctx, st, r.chatModel, r.userMessage, r.extra, r.chatHistory, r.emitStage) } -func (r *schedulePlanRunner) previewNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - return runPreviewNode(ctx, st, r.deps, r.emitStage) +func (r *schedulePlanRunner) roughBuildNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runRoughBuildNode(ctx, st, r.deps, r.emitStage) } -// ── ReAct 精排节点适配层 ── - -func (r *schedulePlanRunner) hybridBuildNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - return runHybridBuildNode(ctx, st, r.deps, r.emitStage) +func (r *schedulePlanRunner) dailySplitNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runDailySplitNode(ctx, st, r.emitStage) } -func (r *schedulePlanRunner) reactRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - return runReactRefineNode(ctx, st, r.chatModel, r.outChan, r.modelName, r.emitStage) +func (r *schedulePlanRunner) dailyRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runDailyRefineNode(ctx, st, r.chatModel, r.dailyRefineConcurrency, r.emitStage) +} + +func (r *schedulePlanRunner) mergeNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runMergeNode(ctx, st, r.emitStage) +} + +func (r *schedulePlanRunner) weeklyRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runWeeklyRefineNode(ctx, st, r.chatModel, r.outChan, r.modelName, r.emitStage) +} + +func (r *schedulePlanRunner) finalCheckNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runFinalCheckNode(ctx, st, r.chatModel, r.emitStage) } func (r *schedulePlanRunner) returnPreviewNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { @@ -74,32 +88,27 @@ func (r *schedulePlanRunner) returnPreviewNode(ctx context.Context, st *Schedule } func (r *schedulePlanRunner) exitNode(_ context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - // exit 节点不做任何业务逻辑,仅把当前状态原样透传到 END。 return st, nil } -// ── 分支决策适配层 ── +// 分支决策适配层 func (r *schedulePlanRunner) nextAfterPlan(_ context.Context, st *SchedulePlanState) (string, error) { return selectNextAfterPlan(st), nil } -// nextAfterPreview 根据 preview 结果决定下一步。 +// nextAfterRoughBuild 根据粗排构建结果决定后续路径。 // -// 分支规则: -// 1) preview 失败(无候选方案)-> exit -// 2) 否则 -> hybridBuild(进入 ReAct 精排路径) -func (r *schedulePlanRunner) nextAfterPreview(_ context.Context, st *SchedulePlanState) (string, error) { - if st == nil || len(st.CandidatePlans) == 0 { - return schedulePlanGraphNodeExit, nil - } - return schedulePlanGraphNodeHybridBuild, nil -} - -// nextAfterHybridBuild 根据 hybridBuild 结果决定下一步。 -func (r *schedulePlanRunner) nextAfterHybridBuild(_ context.Context, st *SchedulePlanState) (string, error) { +// 规则: +// 1. 没有可优化条目 -> exit; +// 2. task_class_ids >= 2 -> dailySplit(多任务类混排,先做日内并发); +// 3. task_class_ids == 1 -> weeklyRefine(单任务类直接周级配平)。 +func (r *schedulePlanRunner) nextAfterRoughBuild(_ context.Context, st *SchedulePlanState) (string, error) { if st == nil || len(st.HybridEntries) == 0 { return schedulePlanGraphNodeExit, nil } - return schedulePlanGraphNodeReactRefine, nil + if len(st.TaskClassIDs) >= 2 { + return schedulePlanGraphNodeDailySplit, nil + } + return schedulePlanGraphNodeWeeklyRefine, nil } diff --git a/backend/agent/scheduleplan/state.go b/backend/agent/scheduleplan/state.go index 7a717d5..2832bd2 100644 --- a/backend/agent/scheduleplan/state.go +++ b/backend/agent/scheduleplan/state.go @@ -13,13 +13,51 @@ const ( // schedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。 schedulePlanDatetimeLayout = "2006-01-02 15:04" + + // schedulePlanDefaultDailyRefineConcurrency 是日内并发优化默认并发度。 + // 这里给一个保守默认值,避免未配置时直接把模型并发打满导致限流。 + schedulePlanDefaultDailyRefineConcurrency = 3 + + // schedulePlanDefaultWeeklyAdjustBudget 是周级配平默认调整额度。 + // 额度存在的目的: + // 1. 防止周级 ReAct 过度调整导致震荡; + // 2. 控制 token 与时延成本; + // 3. 让方案改动更可解释。 + schedulePlanDefaultWeeklyAdjustBudget = 5 + + // schedulePlanDefaultWeeklyTotalBudget 是周级“总尝试次数”默认预算。 + // + // 设计意图: + // 1. 总预算统计“动作尝试次数”(成功/失败都记一次); + // 2. 有效预算统计“成功动作次数”(仅成功时记一次); + // 3. 通过双预算把“探索次数”和“有效改动次数”分离,降低模型无效空转成本。 + schedulePlanDefaultWeeklyTotalBudget = 8 + + // schedulePlanDefaultWeeklyRefineConcurrency 是周级“按周并发”默认并发度。 + // 说明: + // 1. 周级输入规模通常比单天更大,默认并发度不宜过高,避免触发模型侧限流; + // 2. 可在运行时按请求状态覆盖。 + schedulePlanDefaultWeeklyRefineConcurrency = 2 ) -// SchedulePlanState 是"智能排程"链路在 graph 节点间传递的统一状态容器。 +// DayGroup 是“按天拆分后”的最小优化单元。 +// +// 设计目的: +// 1. 把全量周视角数据拆成“单天小包”,降低日内 ReAct 输入规模; +// 2. 支持并发优化不同天的数据,缩短整体等待; +// 3. 通过 SkipRefine 让低收益天数直接跳过,节省模型调用成本。 +type DayGroup struct { + Week int + DayOfWeek int + Entries []model.HybridScheduleEntry + SkipRefine bool +} + +// SchedulePlanState 是“智能排程”链路在 graph 节点间传递的统一状态容器。 // // 设计目标: -// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散���; -// 2) 支持"粗排 -> 校验 -> 修补重试 -> 落库"的完整链路追踪; +// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散落; +// 2) 支持“粗排 -> 日内并发优化 -> 周级配平 -> 终审校验”的完整链路追踪; // 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。 type SchedulePlanState struct { // ── 基础上下文 ── @@ -35,31 +73,93 @@ type SchedulePlanState struct { UserIntent string // Constraints 是用户提出的硬约束列表(如 ["早八不排", "周末休息"])。 Constraints []string - // TaskClassID 是目标任务类 ID,由 Extra 字段或模型抽取获得。 - TaskClassID int + // TaskClassIDs 是本次请求携带的任务类集合(统一主语义)。 + // + // 设计说明: + // 1. 这里明确不再维护单值 task_class_id,避免“单值和切片同时存在”导致语义漂移; + // 2. 分流依据统一为 len(TaskClassIDs): + // 2.1 len==1:跳过 daily 并发,直接进入 weekly refine; + // 2.2 len>=2:进入 daily 并发后再 weekly refine; + // 3. 输入清洗(去重、过滤非法值)由 plan 节点完成,这里只承载最终状态。 + TaskClassIDs []int // Strategy 是排程策略(steady/rapid),默认 steady。 Strategy string + // TaskTags 是“任务项 ID -> 认知类型标签”的映射。 + // 使用 ID 而不是名称,目的是规避“同名任务”带来的映射冲突。 + TaskTags map[int]string + // TaskTagHintsByName 是“任务名称 -> 认知类型标签”的临时映射。 + // 该字段只作为 plan 输出兼容层: + // 1. 若模型暂时给不出 task_item_id,只给名称; + // 2. 后续在 hybridBuild/dailySplit 阶段再转换为 TaskTags(ID 维度)。 + TaskTagHintsByName map[string]string // ── preview 节点输出 ── - // CandidatePlans 是粗排算法生成的候选方案(展示型结构,供 SSE 推送给前端预览)。 + // CandidatePlans 是粗排算法生成的候选方案(展示型结构,供后续节点做预览与总结)。 CandidatePlans []model.UserWeekSchedule // AllocatedItems 是粗排算法已分配的任务项(EmbeddedTime 已回填),供 ReAct 精排使用。 AllocatedItems []model.TaskClassItem + // HasPlanningWindow 标记是否成功解析出“任务类时间窗”的相对周/天边界。 + // + // 语义: + // 1. true:PlanStart*/PlanEnd* 字段可用于 Move 工具的硬边界校验; + // 2. false:表示当前运行未拿到窗口信息(例如依赖未注入),工具层将仅做基础校验。 + HasPlanningWindow bool + // PlanStartWeek / PlanStartDay 表示全局排程窗口起点(相对周/天)。 + PlanStartWeek int + PlanStartDay int + // PlanEndWeek / PlanEndDay 表示全局排程窗口终点(相对周/天)。 + PlanEndWeek int + PlanEndDay int - // ── ReAct 精排阶段 ── + // ── 日内并发优化阶段 ── + + // DailyGroups 是按 (week, day) 拆分后的单日优化输入。 + // 结构:week -> day -> DayGroup。 + DailyGroups map[int]map[int]*DayGroup + // DailyResults 是单日优化输出。 + // 结构:week -> day -> []HybridScheduleEntry。 + DailyResults map[int]map[int][]model.HybridScheduleEntry + // DailyRefineConcurrency 是日内并发优化的并发度。 + // 说明:该值由配置注入,可按环境调节。 + DailyRefineConcurrency int + + // ── 周级 ReAct 精排阶段 ── // HybridEntries 是混合日程条目列表,包含既有日程(existing)和粗排建议(suggested)。 - // ReAct 工具直接在此切片上操作(内存修改,不涉及 DB)。 + // 周级 ReAct 工具直接在此切片上操作(内存修改,不涉及 DB)。 HybridEntries []model.HybridScheduleEntry - // ReactRound 当前 ReAct 循环轮次。 + // MergeSnapshot 是 merge 后快照。 + // 终审失败时回退到该快照,确保至少保留“日内优化成果”。 + MergeSnapshot []model.HybridScheduleEntry + // ReactRound 当前周级 ReAct 循环轮次。 ReactRound int - // ReactMaxRound 最大循环轮次(建议 3)。 + // ReactMaxRound 周级 ReAct 最大循环轮次。 ReactMaxRound int - // ReactSummary LLM 输出的优化摘要。 + // ReactSummary 周级 ReAct 输出的优化摘要。 ReactSummary string - // ReactDone 标记 ReAct 是否已完成。 + // ReactDone 标记周级 ReAct 是否已完成。 ReactDone bool + // WeeklyAdjustBudget 是周级跨天调整额度上限。 + // 语义:有效动作预算(仅工具调用成功时扣减)。 + WeeklyAdjustBudget int + // WeeklyAdjustUsed 是周级跨天调整已使用额度。 + // 语义:有效动作已使用次数(仅成功调用时递增)。 + WeeklyAdjustUsed int + // WeeklyTotalBudget 是周级总动作预算。 + // 语义:总尝试次数预算(成功/失败都扣减)。 + WeeklyTotalBudget int + // WeeklyTotalUsed 是周级总动作已使用次数。 + // 语义:成功/失败每执行一次工具调用都递增。 + WeeklyTotalUsed int + // WeeklyRefineConcurrency 是周级“按周并发”并发度。 + WeeklyRefineConcurrency int + // WeeklyActionLogs 记录周级优化阶段的关键动作流水。 + // + // 设计目的: + // 1. 供 final_check 的总结模型理解“优化过程”,而非只看最终静态结果; + // 2. 供调试排查时快速回放“每轮做了什么动作、是否成功、为何失败”。 + WeeklyActionLogs []string // ── 连续对话微调 ── @@ -68,6 +168,30 @@ type SchedulePlanState struct { PreviousPlanJSON string // IsAdjustment 标记本次是否为微调请求(而非全新排程)。 IsAdjustment bool + // HasPreviousPreview 标记是否命中“同会话上一次排程预览快照”。 + // + // 语义: + // 1. true:可以尝试复用上次 HybridEntries 作为本轮优化起点; + // 2. false:按全新排程路径构建粗排底板。 + HasPreviousPreview bool + // PreviousTaskClassIDs 是上一次预览对应的任务类集合。 + // + // 用途: + // 1. 本轮未显式传 task_class_ids 时作为兜底; + // 2. 仅会话内承接,不改动数据库。 + PreviousTaskClassIDs []int + // PreviousHybridEntries 是上一次预览保存的混合日程条目。 + // + // 用途: + // 1. 连续对话微调时直接复用,避免重新粗排; + // 2. 若为空则回退到粗排构建路径。 + PreviousHybridEntries []model.HybridScheduleEntry + // PreviousAllocatedItems 是上一次预览保存的任务块分配结果。 + // + // 用途: + // 1. 保持 final_check 的数量核对口径稳定; + // 2. return_preview 阶段可继续回填 embedded_time。 + PreviousAllocatedItems []model.TaskClassItem // ── 最终输出 ── @@ -81,13 +205,19 @@ type SchedulePlanState struct { func NewSchedulePlanState(traceID string, userID int, conversationID string) *SchedulePlanState { now := schedulePlanNowToMinute() return &SchedulePlanState{ - TraceID: traceID, - UserID: userID, - ConversationID: conversationID, - RequestNow: now, - RequestNowText: now.In(schedulePlanLocation()).Format(schedulePlanDatetimeLayout), - Strategy: "steady", - ReactMaxRound: 3, + TraceID: traceID, + UserID: userID, + ConversationID: conversationID, + RequestNow: now, + RequestNowText: now.In(schedulePlanLocation()).Format(schedulePlanDatetimeLayout), + Strategy: "steady", + TaskTags: make(map[int]string), + TaskTagHintsByName: make(map[string]string), + DailyRefineConcurrency: schedulePlanDefaultDailyRefineConcurrency, + WeeklyRefineConcurrency: schedulePlanDefaultWeeklyRefineConcurrency, + ReactMaxRound: 2, + WeeklyAdjustBudget: schedulePlanDefaultWeeklyAdjustBudget, + WeeklyTotalBudget: schedulePlanDefaultWeeklyTotalBudget, } } diff --git a/backend/agent/scheduleplan/tool.go b/backend/agent/scheduleplan/tool.go index 94f7ec9..63fbe35 100644 --- a/backend/agent/scheduleplan/tool.go +++ b/backend/agent/scheduleplan/tool.go @@ -3,35 +3,50 @@ package scheduleplan import ( "context" "errors" + "fmt" "strconv" + "strings" "github.com/LoveLosita/smartflow/backend/model" ) -// SchedulePlanToolDeps 描述"智能排程工具包"需要的外部依赖。 +// SchedulePlanToolDeps 描述“智能排程 graph”运行所需的外部业务依赖。 // -// 设计目标: -// 1) 通过函数注入把 agent 包与 service/dao 解耦,避免循环依赖; -// 2) 每个函数对应一个可独立 mock 的业务能力; -// 3) 后续可按需扩展(如局部修补、任务类自动生成等)。 +// 职责边界: +// 1. 只负责声明“需要哪些能力”,不负责具体实现(实现由 service 层注入)。 +// 2. 只收口函数签名,不承载业务状态,避免跨请求共享可变数据。 +// 3. 当前统一采用 task_class_ids 语义,不再依赖单 task_class_id 主路径。 type SchedulePlanToolDeps struct { - // SmartPlanningRaw 调用粗排算法,同时返回展示结构和已分配的任务项。 - // 返回值: - // - []UserWeekSchedule:展示型结构,供 SSE 阶段推送给前端预览; - // - []TaskClassItem:已分配的任务项(EmbeddedTime 已回填),供 ReAct 精排使用。 - SmartPlanningRaw func(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) + // SmartPlanningMultiRaw 是可选依赖: + // 1) 用于需要单独输出“粗排预览”时复用; + // 2) 当前主链路已由 HybridScheduleWithPlanMulti 覆盖,可不注入。 + SmartPlanningMultiRaw func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) - // HybridScheduleWithPlan 构建混合日程(既有日程 + 粗排建议),供 ReAct 精排使用。 - HybridScheduleWithPlan func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) + // HybridScheduleWithPlanMulti 把“既有日程 + 粗排结果”合并成统一的 HybridScheduleEntry 切片, + // 供 daily/weekly ReAct 节点在内存中继续优化。 + HybridScheduleWithPlanMulti func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) + + // ResolvePlanningWindow 根据 task_class_ids 解析“全局排程窗口”的相对周/天边界。 + // + // 返回语义: + // 1. startWeek/startDay:窗口起点(含); + // 2. endWeek/endDay:窗口终点(含); + // 3. error:解析失败(如任务类不存在、日期非法)。 + // + // 用途: + // 1. 给周级 Move 工具加硬边界,避免把任务移动到窗口外的天数; + // 2. 解决“首尾不足一周”场景下的周内越界问题。 + ResolvePlanningWindow func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error) } -// validate 校验依赖完整性,缺失任意一个都无法完成排程链路。 +// validate 校验依赖完整性。 +// +// 失败处理: +// 1. 任意依赖缺失都直接返回错误,避免 graph 运行到中途才 panic。 +// 2. 调用方(runSchedulePlanFlow)收到错误后会走回退链路,不影响普通聊天可用性。 func (d SchedulePlanToolDeps) validate() error { - if d.SmartPlanningRaw == nil { - return errors.New("schedule plan tool deps: SmartPlanningRaw is nil") - } - if d.HybridScheduleWithPlan == nil { - return errors.New("schedule plan tool deps: HybridScheduleWithPlan is nil") + if d.HybridScheduleWithPlanMulti == nil { + return errors.New("schedule plan tool deps: HybridScheduleWithPlanMulti is nil") } return nil } @@ -59,3 +74,74 @@ func ExtraInt(extra map[string]any, key string) (int, bool) { return 0, false } } + +// ExtraIntSlice 从 extra map 中安全提取整数切片。 +// +// 兼容输入: +// 1) []any(JSON 数组反序列化后的常见类型); +// 2) []int; +// 3) []float64; +// 4) 逗号分隔字符串(例如 "1,2,3")。 +// +// 返回语义: +// 1) ok=true:至少成功解析出一个整数; +// 2) ok=false:字段不存在或全部解析失败。 +func ExtraIntSlice(extra map[string]any, key string) ([]int, bool) { + v, exists := extra[key] + if !exists { + return nil, false + } + + parseOne := func(raw any) (int, error) { + switch n := raw.(type) { + case int: + return n, nil + case float64: + return int(n), nil + case string: + i, err := strconv.Atoi(n) + if err != nil { + return 0, err + } + return i, nil + default: + return 0, fmt.Errorf("unsupported type: %T", raw) + } + } + + out := make([]int, 0) + switch arr := v.(type) { + case []int: + for _, item := range arr { + out = append(out, item) + } + case []float64: + for _, item := range arr { + out = append(out, int(item)) + } + case []any: + for _, item := range arr { + if parsed, err := parseOne(item); err == nil { + out = append(out, parsed) + } + } + case string: + parts := strings.Split(arr, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if parsed, err := strconv.Atoi(part); err == nil { + out = append(out, parsed) + } + } + default: + return nil, false + } + + if len(out) == 0 { + return nil, false + } + return out, true +} diff --git a/backend/agent/scheduleplan/tools_react.go b/backend/agent/scheduleplan/tools_react.go index 607e9bc..10ca877 100644 --- a/backend/agent/scheduleplan/tools_react.go +++ b/backend/agent/scheduleplan/tools_react.go @@ -31,6 +31,20 @@ type reactLLMOutput struct { ToolCalls []reactToolCall `json:"tool_calls"` } +// weeklyPlanningWindow 表示周级优化可用的全局周/天窗口。 +// +// 语义: +// 1. Enabled=false:不启用窗口硬边界,仅做基础合法性校验; +// 2. Enabled=true:Move 必须落在 [StartWeek/StartDay, EndWeek/EndDay] 内; +// 3. 该窗口用于处理“首尾不足一周”场景下的越界移动问题。 +type weeklyPlanningWindow struct { + Enabled bool + StartWeek int + StartDay int + EndWeek int + EndDay int +} + // ── 工具分发器 ── // dispatchReactTool 根据工具名分发调用,返回(可能修改后的)entries 和执行结果。 @@ -49,6 +63,88 @@ func dispatchReactTool(entries []model.HybridScheduleEntry, call reactToolCall) } } +// dispatchWeeklySingleActionTool 是“周级单步动作模式”的专用分发器。 +// +// 职责边界: +// 1. 仅允许 Move / Swap 两个工具,禁止 TimeAvailable / GetAvailableSlots; +// 2. 强制 Move 的目标周必须等于 currentWeek,避免并发周优化时发生跨周写穿; +// 3. 统一返回工具执行结果,供上层决定预算扣减与下一轮上下文拼接。 +func dispatchWeeklySingleActionTool(entries []model.HybridScheduleEntry, call reactToolCall, currentWeek int, window weeklyPlanningWindow) ([]model.HybridScheduleEntry, reactToolResult) { + tool := strings.TrimSpace(call.Tool) + switch tool { + case "Swap": + return reactToolSwap(entries, call.Params) + case "Move": + // 1. 周级并发模式下,每个 worker 只负责单周数据。 + // 2. 为避免“一个 worker 改到别的周”导致并发写冲突,这里做硬约束。 + // 3. 失败时不抛异常,返回工具失败结果,让上层继续下一轮决策。 + toWeek, ok := paramInt(call.Params, "to_week") + if !ok { + return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week"} + } + if toWeek != currentWeek { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("当前仅允许优化本周:worker_week=%d,目标周=%d", currentWeek, toWeek), + } + } + // 4. 若已配置全局窗口边界,再做“首尾不足一周”硬校验。 + // 4.1 这样可避免把任务移动到窗口外的天数(例如起始周的起始日前、结束周的结束日后)。 + // 4.2 窗口未启用时不阻断,保持兼容旧链路。 + if window.Enabled { + toDay, ok := paramInt(call.Params, "to_day") + if !ok { + return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_day"} + } + allowed, dayFrom, dayTo := isDayWithinPlanningWindow(window, toWeek, toDay) + if !allowed { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("目标日期超出排程窗口:W%d 仅允许 D%d-D%d,当前目标为 D%d", toWeek, dayFrom, dayTo, toDay), + } + } + } + return reactToolMove(entries, call.Params) + default: + return entries, reactToolResult{ + Tool: tool, + Success: false, + Result: fmt.Sprintf("周级单步模式不支持工具: %s,仅允许 Move/Swap", tool), + } + } +} + +// isDayWithinPlanningWindow 判断目标 week/day 是否落在窗口范围内。 +// +// 返回值: +// 1. allowed:是否允许; +// 2. dayFrom/dayTo:该周允许的 day 区间(用于错误提示)。 +func isDayWithinPlanningWindow(window weeklyPlanningWindow, week int, day int) (allowed bool, dayFrom int, dayTo int) { + // 1. 窗口未启用时默认允许(调用方会跳过此分支,这里是兜底)。 + if !window.Enabled { + return true, 1, 7 + } + // 2. 先做周范围校验。 + if week < window.StartWeek || week > window.EndWeek { + return false, 1, 7 + } + // 3. 计算当前周允许的 day 边界。 + from := 1 + to := 7 + if week == window.StartWeek { + from = window.StartDay + } + if week == window.EndWeek { + to = window.EndDay + } + if day < from || day > to { + return false, from, to + } + return true, from, to +} + // ── 参数提取辅助 ── func paramInt(params map[string]any, key string) (int, bool) { @@ -81,12 +177,35 @@ func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool { return aFrom <= bTo && bFrom <= aTo } +// entryBlocksSuggested 判断某条目是否应阻塞 suggested 任务占位。 +// +// 规则: +// 1. suggested 任务永远阻塞(任务之间不能重叠); +// 2. existing 条目按 BlockForSuggested 字段决定; +// 3. 其余场景默认阻塞(保守策略,避免放出脏可用槽)。 +func entryBlocksSuggested(entry model.HybridScheduleEntry) bool { + if entry.Status == "suggested" { + return true + } + // existing 走显式字段语义。 + if entry.Status == "existing" { + return entry.BlockForSuggested + } + // 未知状态兜底:按阻塞处理。 + return true +} + // hasConflict 检查目标时间段是否与 entries 中任何条目冲突(排除 excludeIdx)。 func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st, excludeIdx int) (bool, string) { for i, e := range entries { if i == excludeIdx { continue } + // 1. 可嵌入且未占用的课程槽(BlockForSuggested=false)不参与冲突判断。 + // 2. 这样可以避免把“水课可嵌入位”误判为硬冲突。 + if !entryBlocksSuggested(e) { + continue + } if e.Week == week && e.DayOfWeek == day && sectionsOverlap(e.SectionFrom, e.SectionTo, sf, st) { return true, fmt.Sprintf("%s(%s)", e.Name, e.Type) } @@ -231,6 +350,9 @@ func reactToolGetAvailableSlots(entries []model.HybridScheduleEntry, params map[ type slotKey struct{ W, D, S int } occupied := make(map[slotKey]bool) for _, e := range entries { + if !entryBlocksSuggested(e) { + continue + } for s := e.SectionFrom; s <= e.SectionTo; s++ { occupied[slotKey{e.Week, e.DayOfWeek, s}] = true } diff --git a/backend/api/agent.go b/backend/api/agent.go index 3d33fc8..7fe6366 100644 --- a/backend/api/agent.go +++ b/backend/api/agent.go @@ -64,11 +64,19 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) { select { case err, ok := <-errChan: if ok && err != nil { + // 4.1 统一 SSE 错误体: + // 4.1.1 默认按内部错误输出 message/type; + // 4.1.2 若是 respond.Response(含业务码),额外透传 code,便于前端识别 5xxxx 等自定义错误。 + errorBody := map[string]any{ + "message": err.Error(), + "type": "server_error", + } + var respErr respond.Response + if errors.As(err, &respErr) { + errorBody["code"] = respErr.Status + } errPayload, _ := json.Marshal(map[string]any{ - "error": map[string]any{ - "message": err.Error(), - "type": "server_error", - }, + "error": errorBody, }) _ = writeSSEData(w, string(errPayload)) _ = writeSSEData(w, "[DONE]") @@ -172,3 +180,33 @@ func (api *AgentHandler) GetConversationList(c *gin.Context) { } c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) } + +// GetSchedulePlanPreview 返回“指定会话”的排程结构化预览。 +// +// 设计说明: +// 1) 该接口只读 Redis 预览快照,不修改聊天主链路协议; +// 2) 按 conversation_id + user_id 读取,避免跨用户越权访问; +// 3) 预览受 TTL 影响,若不存在会返回业务错误码。 +func (api *AgentHandler) GetSchedulePlanPreview(c *gin.Context) { + // 1. 参数校验:conversation_id 必填。 + conversationID := strings.TrimSpace(c.Query("conversation_id")) + if conversationID == "" { + c.JSON(http.StatusBadRequest, respond.MissingParam) + return + } + + // 2. 从鉴权上下文取当前用户 ID,保证查询范围只在“本人会话”内。 + userID := c.GetInt("user_id") + + // 3. 设置短超时,防止缓存抖动时占用连接过久。 + ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) + defer cancel() + + // 4. 调 service 查询并返回统一响应结构。 + preview, err := api.svc.GetSchedulePlanPreview(ctx, userID, conversationID) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, preview)) +} diff --git a/backend/api/schedule.go b/backend/api/schedule.go index 75e0f45..5a5d17f 100644 --- a/backend/api/schedule.go +++ b/backend/api/schedule.go @@ -26,7 +26,6 @@ func (s *ScheduleAPI) GetUserTodaySchedule(c *gin.Context) { // 1. 从请求上下文中获取用户ID userID := c.GetInt("user_id") //2.调用服务层方法获取用户当天的日程安排 - // 创建一个带 1 秒超时的上下文 ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) defer cancel() // 记得释放资源 todaySchedules, err := s.scheduleService.GetUserTodaySchedule(ctx, userID) @@ -133,8 +132,6 @@ func (s *ScheduleAPI) UserRevocateTaskItemFromSchedule(c *gin.Context) { return } //3.调用服务层方法撤销任务块的安排 - /*ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) - defer cancel() // 记得释放资源*/ err = s.scheduleService.RevocateUserTaskClassItem(context.Background(), userID, intEventID) if err != nil { respond.DealWithError(c, err) @@ -147,7 +144,7 @@ func (s *ScheduleAPI) UserRevocateTaskItemFromSchedule(c *gin.Context) { func (s *ScheduleAPI) SmartPlanning(c *gin.Context) { // 1. 从请求上下文中获取用户ID userID := c.GetInt("user_id") - // 2. 从请求体中获取智能规划的参数 + // 2. 从请求参数中获取智能规划的 task_class_id taskClassID := c.Query("task_class_id") intTaskClassID, err := strconv.Atoi(taskClassID) if err != nil { @@ -165,3 +162,33 @@ func (s *ScheduleAPI) SmartPlanning(c *gin.Context) { //4.返回智能规划成功的响应给前端 c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, res)) } + +// SmartPlanningMulti 处理“多任务类智能粗排”请求。 +// +// 职责边界: +// 1. 只负责参数绑定、超时控制、错误透传; +// 2. 具体业务校验与排序策略由 service 层统一处理; +// 3. 保留已有单任务类接口,不与其互斥。 +func (s *ScheduleAPI) SmartPlanningMulti(c *gin.Context) { + // 1. 从请求上下文中读取登录用户 ID。 + userID := c.GetInt("user_id") + + // 2. 绑定多任务类请求体。 + var req model.UserSmartPlanningMultiRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + // 3. 调用服务层执行多任务类粗排。 + ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) + defer cancel() + res, err := s.scheduleService.SmartPlanningMulti(ctx, userID, req.TaskClassIDs) + if err != nil { + respond.DealWithError(c, err) + return + } + + // 4. 返回成功响应。 + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, res)) +} diff --git a/backend/config.example.yaml b/backend/config.example.yaml index 3b99e15..0d16992 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -52,3 +52,5 @@ agent: workerModel: "doubao-seed-1-6-lite-251015" # 智能体使用的Worker模型,需根据实际情况调整 strategistModel: "deepseek-v3-2-251201" # 策略师使用的Worker模型,需根据实际情况调整 baseURL: "https://ark.cn-beijing.volces.com/api/v3" # Worker服务的基础URL,需根据实际情况调整 + dailyRefineConcurrency: 3 # 日内并发优化并发度,建议按模型配额调整 + weeklyAdjustBudget: 5 # 周级跨天配平额度上限,防止过度调整 diff --git a/backend/dao/agent-cache.go b/backend/dao/agent-cache.go index f37f6f9..6fe5667 100644 --- a/backend/dao/agent-cache.go +++ b/backend/dao/agent-cache.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/LoveLosita/smartflow/backend/model" "github.com/cloudwego/eino/schema" "github.com/go-redis/redis/v8" ) @@ -40,6 +41,10 @@ func (m *AgentCache) historyWindowKey(sessionID string) string { return fmt.Sprintf("smartflow:history_window:%s", sessionID) } +func (m *AgentCache) schedulePreviewKey(userID int, sessionID string) string { + return fmt.Sprintf("smartflow:schedule_preview:u:%d:c:%s", userID, sessionID) +} + func (m *AgentCache) normalizeWindowSize(size int) int { if size < minHistoryWindowSize { return minHistoryWindowSize @@ -94,17 +99,15 @@ func (m *AgentCache) PushMessage(ctx context.Context, sessionID string, msg *sch return err } - // 1. 序列化 Eino 消息 + // 1. 序列化 Eino 消息。 data, err := json.Marshal(msg) if err != nil { return fmt.Errorf("marshal message failed: %w", err) } - // 2. 利用 Pipeline 保证原子操作 + // 2. 使用 Pipeline 保证“写入+裁剪+续期”原子执行。 pipe := m.client.Pipeline() - // 往左侧推入最新消息(LIFO) pipe.LPush(ctx, key, data) - // 只保留最新 size 条 pipe.LTrim(ctx, key, 0, int64(size-1)) pipe.Expire(ctx, key, m.expiration) @@ -129,10 +132,9 @@ func (m *AgentCache) GetHistory(ctx context.Context, sessionID string) ([]*schem if err := json.Unmarshal([]byte(val), &msg); err != nil { return nil, err } - // LRANGE 返回 [最新..最旧],这里反转成 [最旧..最新] + // LRANGE 返回 [最新...最旧],这里反转成 [最旧...最新] messages[len(vals)-1-i] = &msg } - return messages, nil } @@ -159,11 +161,9 @@ func (m *AgentCache) BackfillHistory(ctx context.Context, sessionID string, mess pipe := m.client.Pipeline() pipe.Del(ctx, key) - // 输入是 [最旧..最新],LPUSH 后变成 [最新..最旧] pipe.LPush(ctx, key, values...) pipe.LTrim(ctx, key, 0, int64(size-1)) pipe.Expire(ctx, key, m.expiration) - _, err = pipe.Exec(ctx) return err } @@ -185,7 +185,7 @@ func (m *AgentCache) GetConversationStatus(ctx context.Context, sessionID string func (m *AgentCache) SetConversationStatus(ctx context.Context, sessionID string) error { key := fmt.Sprintf("smartflow:conversation_status:%s", sessionID) - // 仅用于“存在性”标记:只有不存在时才写入,避免重复写 + // 仅用于“存在性”标记:只有不存在时才写入,避免重复写。 return m.client.SetNX(ctx, key, 1, m.expiration).Err() } @@ -193,3 +193,55 @@ func (m *AgentCache) DeleteConversationStatus(ctx context.Context, sessionID str key := fmt.Sprintf("smartflow:conversation_status:%s", sessionID) return m.client.Del(ctx, key).Err() } + +// SetSchedulePlanPreview 写入“排程预览”缓存。 +// +// 步骤化说明: +// 1. 先把结构化预览序列化成 JSON,避免缓存层结构漂移。 +// 2. 再按 user_id + conversation_id 写入,确保用户间数据隔离。 +// 3. 最后带 TTL 写入,保证预览是短期临时态而非长期状态。 +// +// 失败处理: +// 1. preview 为空时直接返回错误,避免写入无意义空值。 +// 2. 序列化失败或 Redis 写入失败都返回 error,由上层决定是否降级。 +func (m *AgentCache) SetSchedulePlanPreview(ctx context.Context, userID int, sessionID string, preview *model.SchedulePlanPreviewCache) error { + if preview == nil { + return fmt.Errorf("schedule preview is nil") + } + data, err := json.Marshal(preview) + if err != nil { + return fmt.Errorf("marshal schedule preview failed: %w", err) + } + return m.client.Set(ctx, m.schedulePreviewKey(userID, sessionID), data, m.expiration).Err() +} + +// GetSchedulePlanPreview 读取“排程预览”缓存。 +// +// 语义约定: +// 1. 未命中返回 (nil, nil),上层可区分“未生成”与“已过期”。 +// 2. 反序列化失败返回 error,避免把脏缓存当成正常结果。 +// 3. 不做 DB 回源,预览缓存失效后由业务侧重新生成。 +func (m *AgentCache) GetSchedulePlanPreview(ctx context.Context, userID int, sessionID string) (*model.SchedulePlanPreviewCache, error) { + raw, err := m.client.Get(ctx, m.schedulePreviewKey(userID, sessionID)).Result() + if err == redis.Nil { + return nil, nil + } + if err != nil { + return nil, err + } + + var preview model.SchedulePlanPreviewCache + if err = json.Unmarshal([]byte(raw), &preview); err != nil { + return nil, fmt.Errorf("unmarshal schedule preview failed: %w", err) + } + return &preview, nil +} + +// DeleteSchedulePlanPreview 删除“排程预览”缓存。 +// +// 说明: +// 1. 删除是幂等操作,key 不存在也视为成功。 +// 2. 用于新一轮排程前清理旧快照,避免前端读到过期结果。 +func (m *AgentCache) DeleteSchedulePlanPreview(ctx context.Context, userID int, sessionID string) error { + return m.client.Del(ctx, m.schedulePreviewKey(userID, sessionID)).Err() +} diff --git a/backend/dao/task-class.go b/backend/dao/task-class.go index 9ba7d90..37f132f 100644 --- a/backend/dao/task-class.go +++ b/backend/dao/task-class.go @@ -146,6 +146,52 @@ func (dao *TaskClassDAO) GetCompleteTaskClassByID(ctx context.Context, id int, u return &taskClass, nil } +// GetCompleteTaskClassesByIDs 批量获取“完整任务类”(含 Items)。 +// +// 职责边界: +// 1. 负责按 user_id + ids 过滤,保证数据归属安全; +// 2. 负责预加载 Items,供智能粗排直接使用; +// 3. 不负责排序策略,返回结果顺序由 service 层决定; +// 4. 若存在任一 id 不存在或不属于该用户,返回 WrongTaskClassID。 +func (dao *TaskClassDAO) GetCompleteTaskClassesByIDs(ctx context.Context, userID int, ids []int) ([]model.TaskClass, error) { + if len(ids) == 0 { + return []model.TaskClass{}, nil + } + + // 1. 先做去重与合法值过滤,避免无效 ID 放大数据库压力。 + uniqueIDs := make([]int, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + uniqueIDs = append(uniqueIDs, id) + } + if len(uniqueIDs) == 0 { + return nil, respond.WrongTaskClassID + } + + // 2. 批量查询并预加载任务项。 + var taskClasses []model.TaskClass + err := dao.db.WithContext(ctx). + Preload("Items"). + Where("user_id = ? AND id IN ?", userID, uniqueIDs). + Find(&taskClasses).Error + if err != nil { + return nil, err + } + + // 3. 数量校验:少一条都视为“存在非法/越权 ID”,统一按业务错误返回。 + if len(taskClasses) != len(uniqueIDs) { + return nil, respond.WrongTaskClassID + } + return taskClasses, nil +} + func (dao *TaskClassDAO) GetTaskClassItemByID(ctx context.Context, id int) (*model.TaskClassItem, error) { var item model.TaskClassItem err := dao.db.WithContext(ctx). diff --git a/backend/logic/smart_planning.go b/backend/logic/smart_planning.go index 0e30c5c..47008bd 100644 --- a/backend/logic/smart_planning.go +++ b/backend/logic/smart_planning.go @@ -1,6 +1,8 @@ package logic import ( + "fmt" + "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" @@ -188,6 +190,131 @@ func SmartPlanningRawItems(schedules []model.Schedule, taskClass *model.TaskClas return computeAllocation(g, taskClass.Items, *taskClass.Strategy) } +// SmartPlanningRawItemsMulti 执行“多任务类共享资源池”粗排。 +// +// 职责边界: +// 1. 复用现有 SmartPlanningRawItems 的单任务类分配能力,不重写核心算法; +// 2. 通过“增量占位”把前一个任务类的建议结果写入共享工作日程,供后续任务类避让; +// 3. 返回聚合后的 allocatedItems(每项 EmbeddedTime 已回填); +// 4. 不负责展示结构转换(由 service/conv 层处理)。 +func SmartPlanningRawItemsMulti(schedules []model.Schedule, taskClasses []*model.TaskClass) ([]model.TaskClassItem, error) { + if len(taskClasses) == 0 { + return []model.TaskClassItem{}, nil + } + + // 1. 构建“工作副本”: + // 1.1 原始 schedules 不直接修改,避免污染调用方数据; + // 1.2 后续每完成一个任务类分配,就把结果增量写入 workingSchedules。 + workingSchedules := cloneSchedulesForPlanning(schedules) + allAllocated := make([]model.TaskClassItem, 0) + + // 2. syntheticEventID 用于给“虚拟占位任务”分配唯一 EventID。 + // 2.1 采用负数区间,避免和数据库自增正数 EventID 冲突; + // 2.2 每个任务块占用一个 synthetic event,跨节次共享同一 eventID。 + nextSyntheticEventID := -1 + + for _, taskClass := range taskClasses { + if taskClass == nil { + continue + } + if taskClass.Strategy == nil { + return nil, fmt.Errorf("task_class_id=%d 缺少 strategy 配置", taskClass.ID) + } + + // 3. 复用单任务类粗排。 + allocatedItems, err := SmartPlanningRawItems(workingSchedules, taskClass) + if err != nil { + // 3.1 明确标注失败任务类,便于上层快速定位。 + return nil, fmt.Errorf("task_class_id=%d 粗排失败: %w", taskClass.ID, err) + } + allAllocated = append(allAllocated, allocatedItems...) + + // 4. 把本任务类分配结果转成“虚拟 Schedule 占位”追加回工作副本。 + // 4.1 目的:让后续任务类把这些已分配任务当成 Occupied,避免重叠; + // 4.2 若某任务块没有 EmbeddedTime,直接跳过,不阻断后续。 + virtualSchedules, nextID := buildVirtualSchedulesFromAllocated(allocatedItems, taskClass, nextSyntheticEventID) + nextSyntheticEventID = nextID + if len(virtualSchedules) > 0 { + workingSchedules = append(workingSchedules, virtualSchedules...) + } + } + + return allAllocated, nil +} + +// cloneSchedulesForPlanning 深拷贝 schedules,确保后续在算法中安全修改。 +// +// 说明: +// 1. 主要拷贝 Schedule 结构体本身; +// 2. Event 指针做浅字段复制,避免共享同一 Event 指针导致意外改写; +// 3. EmbeddedTask 在粗排阶段不参与状态写入,保留原值即可。 +func cloneSchedulesForPlanning(src []model.Schedule) []model.Schedule { + if len(src) == 0 { + return []model.Schedule{} + } + dst := make([]model.Schedule, len(src)) + for i := range src { + dst[i] = src[i] + if src[i].Event != nil { + eventCopy := *src[i].Event + dst[i].Event = &eventCopy + } + } + return dst +} + +// buildVirtualSchedulesFromAllocated 将已分配任务块转成“虚拟占位 schedules”。 +// +// 设计目的: +// 1. 让后续任务类在共享资源池里自动避让已分配任务; +// 2. 不落库,仅用于内存中的粗排冲突控制; +// 3. 通过 Type=task + CanBeEmbedded=false 强制标记为不可再嵌入。 +func buildVirtualSchedulesFromAllocated(allocatedItems []model.TaskClassItem, taskClass *model.TaskClass, eventIDStart int) ([]model.Schedule, int) { + if len(allocatedItems) == 0 { + return []model.Schedule{}, eventIDStart + } + + userID := 0 + if taskClass != nil && taskClass.UserID != nil { + userID = *taskClass.UserID + } + + virtual := make([]model.Schedule, 0) + nextEventID := eventIDStart + for _, item := range allocatedItems { + if item.EmbeddedTime == nil { + continue + } + + taskName := "未命名任务" + if item.Content != nil && *item.Content != "" { + taskName = *item.Content + } + location := "" + event := &model.ScheduleEvent{ + ID: nextEventID, + UserID: userID, + Name: taskName, + Location: &location, + Type: "task", + CanBeEmbedded: false, + } + for section := item.EmbeddedTime.SectionFrom; section <= item.EmbeddedTime.SectionTo; section++ { + virtual = append(virtual, model.Schedule{ + EventID: nextEventID, + UserID: userID, + Week: item.EmbeddedTime.Week, + DayOfWeek: item.EmbeddedTime.DayOfWeek, + Section: section, + Event: event, + Status: "normal", + }) + } + nextEventID-- + } + return virtual, nextEventID +} + // buildTimeGrid 构建一个时间格子,标记出哪些时间段被占用、哪些被屏蔽、哪些是水课 func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid { @@ -376,33 +503,51 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([ startLoc := coords[cursor] w, d, s := startLoc.w, startLoc.d, startLoc.s - // 4. 容器长度探测 (顺着你的逻辑) + // 4. 计算本次任务块的落点区间。 + // 4.1 默认按 2 节处理(普通空闲位优先遵循“每任务2节”的主策略); + // 4.2 命中 Filler(可嵌入课程)时,必须先回溯到同课程块起点,再计算完整连续跨度; + // 4.3 失败兜底:若普通空闲位后继不可用,只能退化为 1 节,避免越界或覆盖占用位。 node := g.getNode(w, d, s) + sectionFrom := s slotLen := 2 if node.Status == Filler { - slotLen = 1 + // 4.2.1 先向左回溯到“同一课程块”的起点。 + // 目的:修复“指针落在课程中间节次时被错误切成 1 节”的问题。 + // 例如课程占 9-10 节,若 cursor 命中 10 节,必须回溯到 9 节再整体计算。 currID := node.EventID - for checkS := s + 1; checkS <= 12; checkS++ { + for checkS := s - 1; checkS >= 1; checkS-- { + prev := g.getNode(w, d, checkS) + if prev.Status == Filler && prev.EventID == currID { + sectionFrom = checkS + continue + } + break + } + + // 4.2.2 再从起点向右扩展,拿到同一课程块的完整连续节次长度。 + sectionTo := sectionFrom + for checkS := sectionFrom + 1; checkS <= 12; checkS++ { if next := g.getNode(w, d, checkS); next.Status == Filler && next.EventID == currID { - slotLen++ + sectionTo = checkS } else { break } } + slotLen = sectionTo - sectionFrom + 1 } else if s == 12 || !g.isAvailable(w, d, s+1) { // 如果是 Free 区域,但下一节不可用,则被迫设为 1 节 slotLen = 1 } // 回填时间 - endS := s + slotLen - 1 + endS := sectionFrom + slotLen - 1 items[i].EmbeddedTime = &model.TargetTime{ - SectionFrom: s, SectionTo: endS, + SectionFrom: sectionFrom, SectionTo: endS, Week: w, DayOfWeek: d, } // 标记占用 (物理网格) - for sec := s; sec <= endS; sec++ { + for sec := sectionFrom; sec <= endS; sec++ { g.setNode(w, d, sec, slotNode{Status: Occupied}) } diff --git a/backend/model/agent.go b/backend/model/agent.go index 3c7cc45..49112f7 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -82,6 +82,52 @@ type GetConversationListResponse struct { HasMore bool `json:"has_more"` } +// SchedulePlanPreviewCache 是“排程预览”在 Redis 中的缓存结构。 +// +// 职责边界: +// 1. 负责承载排程完成后的结构化预览快照(summary + candidate_plans); +// 2. 通过 user_id 做查询归属校验,避免跨用户越权读取; +// 3. 仅用于缓存层读写,不表示已落库或已应用到正式日程; +// 4. 通过 trace_id 标识本次预览来源,便于排查链路问题。 +type SchedulePlanPreviewCache struct { + UserID int `json:"user_id"` + ConversationID string `json:"conversation_id"` + TraceID string `json:"trace_id,omitempty"` + Summary string `json:"summary"` + CandidatePlans []UserWeekSchedule `json:"candidate_plans"` + // TaskClassIDs 记录本次预览对应的任务类集合。 + // 作用: + // 1. 连续对话微调时,若本轮请求未显式传 task_class_ids,可用该字段兜底; + // 2. 仅用于会话内上下文承接,不表示用户最终确认后的持久化状态。 + TaskClassIDs []int `json:"task_class_ids,omitempty"` + // HybridEntries 保存“可优化的混合日程底板”。 + // 作用: + // 1. 连续对话微调时复用上轮结果作为起点,避免每轮都从粗排重算; + // 2. 仅缓存态,生命周期受 Redis TTL 控制。 + HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"` + // AllocatedItems 保存建议任务块的当前分配状态。 + // 作用: + // 1. 保证 final_check 的数量核对口径在连续微调场景下可持续; + // 2. return_preview 节点可继续回填 embedded_time。 + AllocatedItems []TaskClassItem `json:"allocated_items,omitempty"` + GeneratedAt time.Time `json:"generated_at"` +} + +// GetSchedulePlanPreviewResponse 是“按会话查询排程预览”接口返回结构。 +// +// 职责边界: +// 1. conversation_id:标识该预览属于哪个会话; +// 2. summary:给用户展示的终审自然语言总结; +// 3. candidate_plans:给前端渲染课表/时间轴用的结构化 JSON; +// 4. generated_at:预览生成时间,便于前端判断是否是最新结果。 +type GetSchedulePlanPreviewResponse struct { + ConversationID string `json:"conversation_id"` + TraceID string `json:"trace_id,omitempty"` + Summary string `json:"summary"` + CandidatePlans []UserWeekSchedule `json:"candidate_plans"` + GeneratedAt time.Time `json:"generated_at"` +} + type SSEResponse struct { Event string `json:"event"` ID int `json:"id,omitempty"` diff --git a/backend/model/schedule.go b/backend/model/schedule.go index 17fe6fc..2571618 100644 --- a/backend/model/schedule.go +++ b/backend/model/schedule.go @@ -97,6 +97,16 @@ type UserDeleteScheduleEvent struct { DeleteEmbeddedTask bool `json:"delete_embedded_task"` } +// UserSmartPlanningMultiRequest 是“多任务类智能粗排”接口的请求体。 +// +// 设计说明: +// 1. TaskClassIDs 至少包含 1 个任务类 ID; +// 2. 实际业务建议传入 >=2 个,用于多任务类混排; +// 3. 服务层会做去重与合法值过滤,接口层只做基础绑定校验。 +type UserSmartPlanningMultiRequest struct { + TaskClassIDs []int `json:"task_class_ids" binding:"required,min=1,dive,min=1"` +} + type UserRecentCompletedScheduleResponse struct { Events []RecentCompletedEventBrief `json:"events"` } @@ -137,6 +147,25 @@ type HybridScheduleEntry struct { Status string `json:"status"` // "existing" | "suggested" TaskItemID int `json:"task_item_id,omitempty"` // 仅 suggested 的 task 有值 EventID int `json:"event_id,omitempty"` // 仅 existing 有值 + // CanBeEmbedded 表示该条 existing 课程块是否允许嵌入任务。 + // 仅课程条目有意义,task 条目默认 false。 + CanBeEmbedded bool `json:"can_be_embedded,omitempty"` + // BlockForSuggested 表示该条目是否应当阻塞 suggested 任务占位。 + // + // 语义说明: + // 1. suggested 条目默认 true(任务之间不能重叠); + // 2. existing 课程若是“可嵌入且当前格子未被嵌入任务占用”,则为 false; + // 3. existing 课程若不可嵌入,或该格子已有嵌入任务,则为 true。 + // + // 该字段用于工具层冲突判断,避免把“可嵌入课位”误判为硬冲突。 + BlockForSuggested bool `json:"block_for_suggested,omitempty"` + // ContextTag 是任务认知类型标签,仅在 suggested 任务中使用。 + // 该标签用于日内优化时的“认知负荷分配”,例如: + // 1. High-Logic:数学、编程、逻辑推理; + // 2. Memory:记忆/背诵类; + // 3. Review:复习/回顾类; + // 4. General:通用任务。 + ContextTag string `json:"context_tag,omitempty"` } func (ScheduleEvent) TableName() string { return "schedule_events" } diff --git a/backend/respond/respond.go b/backend/respond/respond.go index 4bf1202..113fbd3 100644 --- a/backend/respond/respond.go +++ b/backend/respond/respond.go @@ -328,4 +328,14 @@ var ( //请求相关的响应 Status: "40052", Info: "task is not completed", } + + SchedulePlanPreviewNotFound = Response{ //排程预览不存在或已过期 + Status: "40053", + Info: "schedule plan preview not found", + } + + RouteControlInternalError = Response{ //路由控制码内部错误 + Status: "50001", + Info: "route control failed", + } ) diff --git a/backend/routers/routers.go b/backend/routers/routers.go index 131edbb..2ef48a8 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -85,6 +85,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d scheduleGroup.GET("/current", handlers.ScheduleHandler.GetUserOngoingSchedule) scheduleGroup.DELETE("/undo-task-item", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.UserRevocateTaskItemFromSchedule) scheduleGroup.GET("/smart-planning", handlers.ScheduleHandler.SmartPlanning) + scheduleGroup.POST("/smart-planning-multi", handlers.ScheduleHandler.SmartPlanningMulti) } agentGroup := apiGroup.Group("/agent") { @@ -92,6 +93,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d agentGroup.POST("/chat", middleware.TokenQuotaGuard(cache, userRepo), handlers.AgentHandler.ChatAgent) agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta) agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList) + agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview) } } // 初始化Gin引擎 diff --git a/backend/service/agent_bridge.go b/backend/service/agent_bridge.go index ebc42d3..e6f760b 100644 --- a/backend/service/agent_bridge.go +++ b/backend/service/agent_bridge.go @@ -40,8 +40,9 @@ func NewAgentServiceWithSchedule( // 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。 if scheduleSvc != nil { - svc.SmartPlanningRawFunc = scheduleSvc.SmartPlanningRaw - svc.HybridScheduleWithPlanFunc = scheduleSvc.HybridScheduleWithPlan + svc.SmartPlanningMultiRawFunc = scheduleSvc.SmartPlanningMultiRaw + svc.HybridScheduleWithPlanMultiFunc = scheduleSvc.HybridScheduleWithPlanMulti + svc.ResolvePlanningWindowFunc = scheduleSvc.ResolvePlanningWindowByTaskClasses } return svc diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index 356dd4b..f40a335 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -14,6 +14,7 @@ import ( "github.com/LoveLosita/smartflow/backend/inits" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/pkg" + "github.com/LoveLosita/smartflow/backend/respond" eventsvc "github.com/LoveLosita/smartflow/backend/service/events" "github.com/cloudwego/eino-ext/components/model/ark" "github.com/cloudwego/eino/schema" @@ -29,12 +30,20 @@ type AgentService struct { // ── 排程计划依赖(函数注入,避免 service 包循环依赖)── - // SmartPlanningRawFunc 调用粗排算法,同时返回展示结构和已分配的任务项。 - // 由 service/agent_bridge.go 在构造时注入 ScheduleService.SmartPlanningRaw。 - SmartPlanningRawFunc func(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) - // HybridScheduleWithPlanFunc 构建混合日程(既有日程 + 粗排建议),供 ReAct 精排使用。 - // 由 service/agent_bridge.go 在构造时注入。 - HybridScheduleWithPlanFunc func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) + // SmartPlanningMultiRawFunc 是可选注入能力: + // 1. 负责多任务类粗排; + // 2. 当前主链路主要依赖 HybridScheduleWithPlanMultiFunc,可不强制使用。 + SmartPlanningMultiRawFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) + // HybridScheduleWithPlanMultiFunc 是排程链路核心依赖: + // 1. 负责把“多任务类粗排结果 + 既有日程”合并成 HybridEntries; + // 2. daily/weekly ReAct 全部基于这个结果继续优化。 + HybridScheduleWithPlanMultiFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) + // ResolvePlanningWindowFunc 负责把 task_class_ids 解析成“全局排程窗口”的相对周/天边界。 + // + // 作用: + // 1. 给周级 Move 增加硬边界,避免首尾不足一周时移出有效日期范围; + // 2. 该函数只做“窗口解析”,不负责粗排与混排计算。 + ResolvePlanningWindowFunc func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error) } // NewAgentService 构造 AgentService。 @@ -302,6 +311,12 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin // 3.1 先走轻量路由,拿到统一 action。 routing := s.decideActionRouting(requestCtx, selectedModel, userMessage) + if routing.RouteFailed { + // 3.1.1 路由码失败不再回落聊天。 + // 3.1.2 直接返回内部错误,避免误进入业务分支导致“吐错内容”(例如吐排程 JSON)。 + pushErrNonBlocking(errChan, respond.RouteControlInternalError) + return + } // 3.2 chat:直接走普通聊天主链路。 if routing.Action == route.ActionChat { diff --git a/backend/service/agentsvc/agent_quick_note.go b/backend/service/agentsvc/agent_quick_note.go index ddcc08f..9859bc3 100644 --- a/backend/service/agentsvc/agent_quick_note.go +++ b/backend/service/agentsvc/agent_quick_note.go @@ -73,6 +73,9 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) { if detail != "" { reasoning += "\n" + detail } + // 2.1 每条阶段消息末尾补双换行,避免客户端把多条 chunk 紧贴在同一行显示。 + // 这里统一在 emitter 层处理,所有接入 emitStage 的链路都会受益。 + reasoning += "\n\n" // 3. 复用 OpenAI 兼容封装:把阶段文本伪装成 reasoning_content。 chunk, err := chat.ToOpenAIStream(&schema.Message{ReasoningContent: reasoning}, e.requestID, e.modelName, e.created, false) diff --git a/backend/service/agentsvc/agent_route.go b/backend/service/agentsvc/agent_route.go index a575436..249160b 100644 --- a/backend/service/agentsvc/agent_route.go +++ b/backend/service/agentsvc/agent_route.go @@ -19,7 +19,7 @@ type actionRoutingDecision = route.RoutingDecision // 职责边界: // 1. 只负责调用 route 包拿分流结论; // 2. 不负责执行任何业务节点; -// 3. route 层失败时的兜底策略由 route 包内部统一处理(当前为回落 chat)。 +// 3. route 层失败会通过 RoutingDecision.RouteFailed 向上层显式暴露。 func (s *AgentService) decideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) actionRoutingDecision { // 这里保留方法封装,是为了避免上层直接依赖 route 包,降低耦合。 _ = s diff --git a/backend/service/agentsvc/agent_schedule_plan.go b/backend/service/agentsvc/agent_schedule_plan.go index d3b9957..3ecde38 100644 --- a/backend/service/agentsvc/agent_schedule_plan.go +++ b/backend/service/agentsvc/agent_schedule_plan.go @@ -8,18 +8,21 @@ import ( "github.com/LoveLosita/smartflow/backend/agent/scheduleplan" "github.com/LoveLosita/smartflow/backend/conv" + "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/pkg" "github.com/cloudwego/eino-ext/components/model/ark" "github.com/cloudwego/eino/schema" + "github.com/spf13/viper" ) -// runSchedulePlanFlow 执行"智能排程"分支。 +// runSchedulePlanFlow 执行“智能排程”分支。 // // 职责边界: -// 1. 负责把本次请求接入 scheduleplan 执行器; -// 2. 负责注入排程依赖(SmartPlanning / BatchApplyPlans / GetTaskClassByID); -// 3. 负责对话历史获取,支持连续对话微调; -// 4. 不负责聊天持久化(由 AgentChat 主流程统一收口)。 +// 1. 负责把本次请求接入 scheduleplan graph,并注入运行依赖。 +// 2. 负责读取对话历史(优先 Redis,未命中再回源 DB)用于连续对话微调。 +// 3. 负责把排程预览快照写入 Redis(供查询接口拉取 JSON)。 +// 4. 负责返回给上层“可直接发给用户的最终文本回复”。 +// 5. 不负责聊天持久化(由 AgentChat 主链路统一处理)。 func (s *AgentService) runSchedulePlanFlow( ctx context.Context, selectedModel *ark.ChatModel, @@ -32,16 +35,37 @@ func (s *AgentService) runSchedulePlanFlow( outChan chan<- string, modelName string, ) (string, error) { - // 1. 依赖预检:排程依赖函数必须注入,否则无法完成排程链路。 - if s.SmartPlanningRawFunc == nil || s.HybridScheduleWithPlanFunc == nil { + // 1. 依赖预检:缺硬依赖时直接失败,避免进入 graph 后才出现空指针或半途失败。 + // 1.1 SmartPlanningMultiRaw / HybridScheduleWithPlanMulti / ResolvePlanningWindow 任一缺失都无法继续。 + // 1.2 selectedModel 为空时无法执行 LLM 节点,直接返回错误由上层处理。 + if s.SmartPlanningMultiRawFunc == nil || s.HybridScheduleWithPlanMultiFunc == nil || s.ResolvePlanningWindowFunc == nil { return "", errors.New("schedule plan service dependencies are not ready") } if selectedModel == nil { return "", errors.New("schedule plan model is nil") } - // 2. 获取对话历史,用于连续对话微调场景。 - // 优先从 Redis 读取,未命中时回源 DB。 + // 2. 连续对话微调前置处理:先尝试读取“上一版预览快照”,再清理旧 key。 + // 2.1 先读后删的原因: + // 2.1.1 若先删再读,会丢失“连续微调起点”; + // 2.1.2 先读可让本轮在内存中复用上轮 HybridEntries。 + // 2.2 清理旧 key 仍然保留,避免前端在本轮进行中误读到旧结果。 + var previousPreview *model.SchedulePlanPreviewCache + if s.agentCache != nil { + preview, getErr := s.agentCache.GetSchedulePlanPreview(ctx, userID, chatID) + if getErr != nil { + log.Printf("读取上一版排程预览失败 chat_id=%s: %v", chatID, getErr) + } else { + previousPreview = preview + } + if delErr := s.agentCache.DeleteSchedulePlanPreview(ctx, userID, chatID); delErr != nil { + log.Printf("清理旧排程预览失败 chat_id=%s: %v", chatID, delErr) + } + } + + // 3. 读取对话历史:先快后稳。 + // 3.1 先查 Redis,命中则避免回源 DB,降低请求时延。 + // 3.2 Redis 异常仅记录日志,不中断主流程(回源 DB 兜底)。 var chatHistory []*schema.Message if s.agentCache != nil { history, err := s.agentCache.GetHistory(ctx, chatID) @@ -52,7 +76,8 @@ func (s *AgentService) runSchedulePlanFlow( } } - // 2.1 缓存未命中时回源 DB。 + // 3.3 Redis 未命中时回源 DB,保证链路在缓存波动时仍可用。 + // 3.4 DB 回源失败同样只记日志并继续,让 graph 按“无历史”降级运行。 if chatHistory == nil && s.repo != nil { histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), chatID) if hisErr != nil { @@ -62,38 +87,56 @@ func (s *AgentService) runSchedulePlanFlow( } } - // 3. 初始化排程状态对象。 + // 4. 执行 graph 主流程。 + // 4.1 这里只负责参数拼装与调用,不在 service 层重复实现 graph 节点逻辑。 + // 4.2 并发度/预算从配置注入,避免把调优参数写死在代码中。 state := scheduleplan.NewSchedulePlanState(traceID, userID, chatID) - - // 4. 构建依赖注入并执行 graph。 + // 4.3 连续对话微调注入: + // 4.3.1 若命中上轮预览,则把任务类/混合条目/分配结果注入 state; + // 4.3.2 这样 rough_build 可按需复用旧底板,避免每轮都重新粗排。 + if previousPreview != nil { + state.HasPreviousPreview = true + state.PreviousTaskClassIDs = append([]int(nil), previousPreview.TaskClassIDs...) + state.PreviousHybridEntries = cloneHybridEntries(previousPreview.HybridEntries) + state.PreviousAllocatedItems = cloneTaskClassItems(previousPreview.AllocatedItems) + } finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{ Model: selectedModel, State: state, Deps: scheduleplan.SchedulePlanToolDeps{ - SmartPlanningRaw: s.SmartPlanningRawFunc, - HybridScheduleWithPlan: s.HybridScheduleWithPlanFunc, + SmartPlanningMultiRaw: s.SmartPlanningMultiRawFunc, + HybridScheduleWithPlanMulti: s.HybridScheduleWithPlanMultiFunc, + ResolvePlanningWindow: s.ResolvePlanningWindowFunc, }, - UserMessage: userMessage, - Extra: extra, - ChatHistory: chatHistory, - EmitStage: emitStage, - OutChan: outChan, - ModelName: modelName, + UserMessage: userMessage, + Extra: extra, + ChatHistory: chatHistory, + EmitStage: emitStage, + OutChan: outChan, + ModelName: modelName, + DailyRefineConcurrency: viper.GetInt("agent.dailyRefineConcurrency"), + WeeklyAdjustBudget: viper.GetInt("agent.weeklyAdjustBudget"), }) - if runErr != nil { + // 4.3 graph 失败直接上抛,由上层决定回落或报错。 return "", runErr } - // 5. 提取最终回复。 + // 5. 组装最终回复文本。 + // 5.1 明确移除“把排程结果序列化成 JSON 文本直接回传”的抽象, + // 避免在 SSE 聊天链路里吐出原始 JSON,影响前端展示与用户体验。 + // 5.2 当 finalState 为空或 summary 为空时,返回统一兜底文案,保证接口有稳定输出。 if finalState == nil { return "排程流程异常,请稍后重试。", nil } - reply := strings.TrimSpace(finalState.FinalSummary) if reply == "" { reply = "排程流程已完成,但未生成结果摘要。" } + // 6. 旁路写入排程预览缓存(结构化 JSON),给查询接口拉取。 + // 6.1 失败只记日志,不影响本次对话回复; + // 6.2 成功后前端可通过 conversation_id 获取 candidate_plans。 + s.saveSchedulePlanPreview(ctx, userID, chatID, finalState) return reply, nil } diff --git a/backend/service/agentsvc/agent_schedule_preview.go b/backend/service/agentsvc/agent_schedule_preview.go new file mode 100644 index 0000000..1eec7b9 --- /dev/null +++ b/backend/service/agentsvc/agent_schedule_preview.go @@ -0,0 +1,162 @@ +package agentsvc + +import ( + "context" + "errors" + "log" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/agent/scheduleplan" + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" +) + +// saveSchedulePlanPreview 把排程结果以结构化 JSON 快照写入 Redis。 +// +// 职责边界: +// 1. 负责把 finalState 中的 summary + candidate_plans 收敛为缓存 DTO; +// 2. 负责以“失败不阻断聊天主链路”的策略执行写入; +// 3. 不负责 SSE 返回协议,不负责数据库落库。 +func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *scheduleplan.SchedulePlanState) { + // 1. 基础前置校验:任何关键依赖缺失都直接返回,避免产生无意义错误日志。 + if s == nil || s.agentCache == nil || finalState == nil { + return + } + normalizedChatID := strings.TrimSpace(chatID) + if normalizedChatID == "" { + return + } + + // 2. 组装缓存快照: + // 2.1 summary 优先取 final summary,空值时使用统一兜底文案; + // 2.2 candidate_plans 做切片拷贝,避免后续引用共享导致意外覆盖; + // 2.3 generated_at 用于前端判断“当前预览的新鲜度”。 + summary := strings.TrimSpace(finalState.FinalSummary) + if summary == "" { + summary = "排程流程已完成,但未生成结果摘要。" + } + preview := &model.SchedulePlanPreviewCache{ + UserID: userID, + ConversationID: normalizedChatID, + TraceID: strings.TrimSpace(finalState.TraceID), + Summary: summary, + CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans), + TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...), + HybridEntries: cloneHybridEntries(finalState.HybridEntries), + AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems), + GeneratedAt: time.Now(), + } + + // 3. 尝试写入缓存: + // 3.1 写入失败仅打日志,不上抛错误,保证聊天接口协议与可用性不受影响; + // 3.2 兜底策略是“用户仍可收到文本摘要”,只是暂时无法通过新接口拉取结构化预览。 + if err := s.agentCache.SetSchedulePlanPreview(ctx, userID, normalizedChatID, preview); err != nil { + log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err) + } +} + +// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。 +// +// 职责边界: +// 1. 负责参数归一化、缓存读取与会话归属校验; +// 2. 负责把缓存 DTO 转成 API 响应 DTO; +// 3. 不负责触发排程,不负责补算缓存。 +func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, chatID string) (*model.GetSchedulePlanPreviewResponse, error) { + // 1. 参数校验:conversation_id 为空直接返回参数错误,避免无效 Redis 请求。 + normalizedChatID := strings.TrimSpace(chatID) + if normalizedChatID == "" { + return nil, respond.MissingParam + } + if s == nil || s.agentCache == nil { + return nil, errors.New("agent cache is not initialized") + } + + // 2. 查询缓存并校验归属: + // 2.1 缓存未命中:统一返回“预览不存在/已过期”; + // 2.2 命中但 user_id 不一致:按未命中处理,避免泄露他人会话信息; + // 2.3 失败兜底:缓存读异常直接上抛,由 API 层统一错误处理。 + preview, err := s.agentCache.GetSchedulePlanPreview(ctx, userID, normalizedChatID) + if err != nil { + return nil, err + } + if preview == nil { + return nil, respond.SchedulePlanPreviewNotFound + } + if preview.UserID > 0 && preview.UserID != userID { + return nil, respond.SchedulePlanPreviewNotFound + } + + // 3. 映射响应结构,保证输出字段稳定。 + plans := cloneWeekSchedules(preview.CandidatePlans) + if plans == nil { + plans = make([]model.UserWeekSchedule, 0) + } + return &model.GetSchedulePlanPreviewResponse{ + ConversationID: normalizedChatID, + TraceID: strings.TrimSpace(preview.TraceID), + Summary: strings.TrimSpace(preview.Summary), + CandidatePlans: plans, + GeneratedAt: preview.GeneratedAt, + }, nil +} + +// cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。 +func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { + if len(src) == 0 { + return nil + } + dst := make([]model.UserWeekSchedule, 0, len(src)) + for _, week := range src { + eventsCopy := make([]model.WeeklyEventBrief, len(week.Events)) + copy(eventsCopy, week.Events) + dst = append(dst, model.UserWeekSchedule{ + Week: week.Week, + Events: eventsCopy, + }) + } + return dst +} + +// cloneHybridEntries 深拷贝混合条目切片,避免缓存/状态之间相互污染。 +func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { + if len(src) == 0 { + return nil + } + dst := make([]model.HybridScheduleEntry, len(src)) + copy(dst, src) + return dst +} + +// cloneTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨请求引用共享。 +func cloneTaskClassItems(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 +} diff --git a/backend/service/schedule.go b/backend/service/schedule.go index 1ea7e3e..9b3ad86 100644 --- a/backend/service/schedule.go +++ b/backend/service/schedule.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log" + "sort" "strings" "time" @@ -412,9 +413,9 @@ func (ss *ScheduleService) SmartPlanning(ctx context.Context, userID, taskClassI // SmartPlanningRaw 执行粗排算法并同时返回展示结构和已分配的任务项。 // // 职责边界: -// 1) 与 SmartPlanning 共享完全相同的前置校验和粗排逻辑; -// 2) 额外返回 allocatedItems(每项的 EmbeddedTime 已由算法回填), -// 供 Agent 排程链路直接转换为 BatchApplyPlans 请求,无需再让模型"二次分配"。 +// 1. 与 SmartPlanning 共享完全相同的前置校验和粗排逻辑; +// 2. 额外返回 allocatedItems(每项的 EmbeddedTime 已由算法回填), +// 供 Agent 排程链路直接转换为 BatchApplyPlans 请求,无需再让模型"二次分配"。 func (ss *ScheduleService) SmartPlanningRaw(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) { // 1. 获取任务类详情。 taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID) @@ -445,21 +446,222 @@ func (ss *ScheduleService) SmartPlanningRaw(ctx context.Context, userID, taskCla return displayResult, allocatedItems, nil } -// HybridScheduleWithPlan 构建"混合日程":将既有日程与粗排建议合并为统一结构。 +// SmartPlanningMulti 执行“多任务类智能粗排”,仅返回前端展示结构。 // // 职责边界: -// 1) 获取 TaskClass 时间范围内的既有日程(课程 + 已落库任务); -// 2) 调用粗排算法获取建议分配; -// 3) 将两者合并为 []HybridScheduleEntry,供 ReAct 精排引擎在内存中操作。 +// 1. 负责把多任务类请求收口到统一粗排流程; +// 2. 负责返回展示结构; +// 3. 不返回底层分配细节(由 SmartPlanningMultiRaw 提供)。 +func (ss *ScheduleService) SmartPlanningMulti(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, error) { + displayResult, _, err := ss.SmartPlanningMultiRaw(ctx, userID, taskClassIDs) + if err != nil { + return nil, err + } + return displayResult, nil +} + +// SmartPlanningMultiRaw 执行“多任务类智能粗排”,同时返回展示结构和已分配任务项。 // -// 返回值: -// - entries:混合日程条目(existing + suggested) -// - allocatedItems:粗排已分配的任务项(用于后续落库) -// - error +// 职责边界: +// 1. 负责多任务类请求的完整前置处理(归一化/校验/排序/时间窗收敛); +// 2. 负责调用多任务类粗排主逻辑(共享资源池); +// 3. 只计算建议,不负责落库。 +func (ss *ScheduleService) SmartPlanningMultiRaw(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) { + // 1. 输入归一化。 + normalizedIDs := normalizeTaskClassIDsForMultiPlanning(taskClassIDs) + if len(normalizedIDs) == 0 { + return nil, nil, respond.WrongTaskClassID + } + + // 2. 批量读取完整任务类(含 Items)。 + taskClasses, err := ss.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, normalizedIDs) + if err != nil { + return nil, nil, err + } + + // 3. 校验任务类并计算全局时间窗。 + orderedTaskClasses, globalStartDate, globalEndDate, err := prepareTaskClassesForMultiPlanning(taskClasses, normalizedIDs) + if err != nil { + return nil, nil, err + } + + // 4. 拉取全局时间窗内的既有日程底板。 + schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange( + ctx, + userID, + conv.CalculateFirstDayOfWeek(globalStartDate), + conv.CalculateLastDayOfWeek(globalEndDate), + ) + if err != nil { + return nil, nil, err + } + + // 5. 执行多任务类粗排(共享资源池 + 增量占位)。 + allocatedItems, err := logic.SmartPlanningRawItemsMulti(schedules, orderedTaskClasses) + if err != nil { + return nil, nil, err + } + + // 6. 转换前端展示结构。 + displayResult := conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems) + return displayResult, allocatedItems, nil +} + +// ResolvePlanningWindowByTaskClasses 解析“多任务类排程窗口”的相对周/天边界。 +// +// 职责边界: +// 1. 只负责根据 task_class_ids 计算全局起止日期并转换成相对周/天; +// 2. 不执行粗排、不查询课表、不生成 HybridEntries; +// 3. 供 Agent 周级 Move 工具做硬边界校验,防止越界移动。 +// +// 返回语义: +// 1. startWeek/startDay:允许排程的起点(含); +// 2. endWeek/endDay:允许排程的终点(含); +// 3. error:任何校验或日期转换失败都返回错误。 +func (ss *ScheduleService) ResolvePlanningWindowByTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (int, int, int, int, error) { + // 1. 输入归一化:过滤非法值并去重。 + normalizedIDs := normalizeTaskClassIDsForMultiPlanning(taskClassIDs) + if len(normalizedIDs) == 0 { + return 0, 0, 0, 0, respond.WrongTaskClassID + } + + // 2. 批量查询任务类并复用统一校验逻辑,拿到全局起止日期。 + taskClasses, err := ss.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, normalizedIDs) + if err != nil { + return 0, 0, 0, 0, err + } + _, globalStartDate, globalEndDate, err := prepareTaskClassesForMultiPlanning(taskClasses, normalizedIDs) + if err != nil { + return 0, 0, 0, 0, err + } + + // 3. 把绝对日期转换为“相对周/天”。 + // 3.1 这里统一复用 conv.RealDateToRelativeDate,确保和现有排程口径一致; + // 3.2 若日期超出学期配置范围,直接返回错误,避免错误边界进入工具层。 + startWeek, startDay, err := conv.RealDateToRelativeDate(globalStartDate.Format(conv.DateFormat)) + if err != nil { + return 0, 0, 0, 0, err + } + endWeek, endDay, err := conv.RealDateToRelativeDate(globalEndDate.Format(conv.DateFormat)) + if err != nil { + return 0, 0, 0, 0, err + } + if endWeek < startWeek || (endWeek == startWeek && endDay < startDay) { + return 0, 0, 0, 0, respond.InvalidDateRange + } + return startWeek, startDay, endWeek, endDay, nil +} + +// normalizeTaskClassIDsForMultiPlanning 归一化 task_class_ids(过滤非法值、去重并保序)。 +func normalizeTaskClassIDsForMultiPlanning(ids []int) []int { + if len(ids) == 0 { + return []int{} + } + normalized := make([]int, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + normalized = append(normalized, id) + } + return normalized +} + +// prepareTaskClassesForMultiPlanning 把 DAO 结果转成可直接粗排的数据集。 +// +// 职责边界: +// 1. 校验每个任务类可参与自动排程; +// 2. 计算全局时间窗(最早开始 ~ 最晚结束); +// 3. 执行多任务类排序策略。 +func prepareTaskClassesForMultiPlanning(taskClasses []model.TaskClass, orderedIDs []int) ([]*model.TaskClass, time.Time, time.Time, error) { + if len(orderedIDs) == 0 { + return nil, time.Time{}, time.Time{}, respond.WrongTaskClassID + } + + classByID := make(map[int]*model.TaskClass, len(taskClasses)) + for i := range taskClasses { + tc := &taskClasses[i] + classByID[tc.ID] = tc + } + + ordered := make([]*model.TaskClass, 0, len(orderedIDs)) + var globalStart time.Time + var globalEnd time.Time + for idx, id := range orderedIDs { + taskClass, exists := classByID[id] + if !exists || taskClass == nil { + return nil, time.Time{}, time.Time{}, respond.WrongTaskClassID + } + if taskClass.Mode == nil || *taskClass.Mode != "auto" { + return nil, time.Time{}, time.Time{}, respond.TaskClassModeNotAuto + } + if taskClass.StartDate == nil || taskClass.EndDate == nil { + return nil, time.Time{}, time.Time{}, respond.InvalidDateRange + } + start := *taskClass.StartDate + end := *taskClass.EndDate + if end.Before(start) { + return nil, time.Time{}, time.Time{}, respond.InvalidDateRange + } + if idx == 0 || start.Before(globalStart) { + globalStart = start + } + if idx == 0 || end.After(globalEnd) { + globalEnd = end + } + ordered = append(ordered, taskClass) + } + + sortTaskClassesForMultiPlanning(ordered, orderedIDs) + return ordered, globalStart, globalEnd, nil +} + +// sortTaskClassesForMultiPlanning 执行稳定排序: +// 1. end_date 早优先; +// 2. rapid 优先于 steady; +// 3. 输入顺序兜底。 +func sortTaskClassesForMultiPlanning(taskClasses []*model.TaskClass, inputOrder []int) { + if len(taskClasses) <= 1 { + return + } + orderIndex := make(map[int]int, len(inputOrder)) + for idx, id := range inputOrder { + orderIndex[id] = idx + } + + sort.SliceStable(taskClasses, func(i, j int) bool { + left := taskClasses[i] + right := taskClasses[j] + if left == nil || right == nil { + return left != nil + } + if left.EndDate != nil && right.EndDate != nil && !left.EndDate.Equal(*right.EndDate) { + return left.EndDate.Before(*right.EndDate) + } + leftRapid := left.Strategy != nil && *left.Strategy == "rapid" + rightRapid := right.Strategy != nil && *right.Strategy == "rapid" + if leftRapid != rightRapid { + return leftRapid + } + leftOrder, leftOK := orderIndex[left.ID] + rightOrder, rightOK := orderIndex[right.ID] + if leftOK && rightOK && leftOrder != rightOrder { + return leftOrder < rightOrder + } + return left.ID < right.ID + }) +} + +// HybridScheduleWithPlan 构建“单任务类”混合日程(existing + suggested)。 func (ss *ScheduleService) HybridScheduleWithPlan( ctx context.Context, userID, taskClassID int, ) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) { - // 1. 获取任务类详情。 + // 1. 校验并读取任务类。 taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID) if err != nil { return nil, nil, err @@ -467,11 +669,14 @@ func (ss *ScheduleService) HybridScheduleWithPlan( if taskClass == nil { return nil, nil, respond.WrongTaskClassID } - if *taskClass.Mode != "auto" { + if taskClass.Mode == nil || *taskClass.Mode != "auto" { return nil, nil, respond.TaskClassModeNotAuto } + if taskClass.StartDate == nil || taskClass.EndDate == nil { + return nil, nil, respond.InvalidDateRange + } - // 2. 获取时间范围内的既有日程。 + // 2. 拉取时间窗内既有日程。 schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange( ctx, userID, conv.CalculateFirstDayOfWeek(*taskClass.StartDate), @@ -481,20 +686,79 @@ func (ss *ScheduleService) HybridScheduleWithPlan( return nil, nil, err } - // 3. 执行粗排算法。 + // 3. 执行粗排。 allocatedItems, err := logic.SmartPlanningRawItems(schedules, taskClass) if err != nil { return nil, nil, err } - // 4. 合并为 HybridScheduleEntry 切片。 + // 4. 统一合并。 + entries := buildHybridEntriesFromSchedulesAndAllocated(schedules, allocatedItems) + return entries, allocatedItems, nil +} + +// HybridScheduleWithPlanMulti 构建“多任务类”混合日程(existing + suggested)。 +func (ss *ScheduleService) HybridScheduleWithPlanMulti( + ctx context.Context, + userID int, + taskClassIDs []int, +) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) { + // 1. 归一化任务类 ID。 + normalizedIDs := normalizeTaskClassIDsForMultiPlanning(taskClassIDs) + if len(normalizedIDs) == 0 { + return nil, nil, respond.WrongTaskClassID + } + + // 2. 拉取任务类并做校验/排序。 + taskClasses, err := ss.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, normalizedIDs) + if err != nil { + return nil, nil, err + } + orderedTaskClasses, globalStartDate, globalEndDate, err := prepareTaskClassesForMultiPlanning(taskClasses, normalizedIDs) + if err != nil { + return nil, nil, err + } + + // 3. 拉取全局时间窗内既有日程。 + schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange( + ctx, + userID, + conv.CalculateFirstDayOfWeek(globalStartDate), + conv.CalculateLastDayOfWeek(globalEndDate), + ) + if err != nil { + return nil, nil, err + } + + // 4. 多任务类粗排。 + allocatedItems, err := logic.SmartPlanningRawItemsMulti(schedules, orderedTaskClasses) + if err != nil { + return nil, nil, err + } + + // 5. 统一合并。 + entries := buildHybridEntriesFromSchedulesAndAllocated(schedules, allocatedItems) + return entries, allocatedItems, nil +} + +// buildHybridEntriesFromSchedulesAndAllocated 合并 existing/suggested 条目。 +// +// 说明: +// 1. existing 按“事件 + 天 + 可嵌入语义 + 阻塞语义”分组,再按连续节次切块; +// 2. suggested 直接根据 allocatedItems 生成; +// 3. 仅做内存组装,不做数据库操作。 +func buildHybridEntriesFromSchedulesAndAllocated( + schedules []model.Schedule, + allocatedItems []model.TaskClassItem, +) []model.HybridScheduleEntry { entries := make([]model.HybridScheduleEntry, 0, len(schedules)/2+len(allocatedItems)) - // 4.1 既有日程:按 EventID+Week+DayOfWeek 分组,合并连续节次。 type eventGroupKey struct { - EventID int - Week int - DayOfWeek int + EventID int + Week int + DayOfWeek int + CanBeEmbedded bool + BlockForSuggested bool } type eventGroup struct { Key eventGroupKey @@ -503,48 +767,82 @@ func (ss *ScheduleService) HybridScheduleWithPlan( Sections []int } groupMap := make(map[eventGroupKey]*eventGroup) + + // 1. 先处理 existing。 for _, s := range schedules { - key := eventGroupKey{EventID: s.EventID, Week: s.Week, DayOfWeek: s.DayOfWeek} - g, ok := groupMap[key] + name := "未知" + typ := "course" + canBeEmbedded := false + if s.Event != nil { + name = s.Event.Name + typ = s.Event.Type + canBeEmbedded = s.Event.CanBeEmbedded + } + + // 1.1 阻塞语义: + // 1.1.1 task 默认阻塞; + // 1.1.2 course 且不可嵌入时阻塞; + // 1.1.3 course 且可嵌入时,若当前原子格未被 embedded_task 占用,则不阻塞。 + blockForSuggested := true + if typ == "course" && canBeEmbedded && s.EmbeddedTaskID == nil { + blockForSuggested = false + } + + key := eventGroupKey{ + EventID: s.EventID, + Week: s.Week, + DayOfWeek: s.DayOfWeek, + CanBeEmbedded: canBeEmbedded, + BlockForSuggested: blockForSuggested, + } + group, ok := groupMap[key] if !ok { - name := "未知" - typ := "course" - if s.Event != nil { - name = s.Event.Name - typ = s.Event.Type + group = &eventGroup{ + Key: key, + Name: name, + Type: typ, } - g = &eventGroup{Key: key, Name: name, Type: typ} - groupMap[key] = g + groupMap[key] = group } - g.Sections = append(g.Sections, s.Section) - } - for _, g := range groupMap { - if len(g.Sections) == 0 { - continue - } - // 排序后取首尾作为 SectionFrom/SectionTo - minS, maxS := g.Sections[0], g.Sections[0] - for _, s := range g.Sections[1:] { - if s < minS { - minS = s - } - if s > maxS { - maxS = s - } - } - entries = append(entries, model.HybridScheduleEntry{ - Week: g.Key.Week, - DayOfWeek: g.Key.DayOfWeek, - SectionFrom: minS, - SectionTo: maxS, - Name: g.Name, - Type: g.Type, - Status: "existing", - EventID: g.Key.EventID, - }) + group.Sections = append(group.Sections, s.Section) } - // 4.2 粗排建议:每个已分配的 TaskClassItem 转为一条 suggested 条目。 + for _, group := range groupMap { + if len(group.Sections) == 0 { + continue + } + sort.Ints(group.Sections) + + runStart := group.Sections[0] + prev := group.Sections[0] + flushRun := func(from, to int) { + entries = append(entries, model.HybridScheduleEntry{ + Week: group.Key.Week, + DayOfWeek: group.Key.DayOfWeek, + SectionFrom: from, + SectionTo: to, + Name: group.Name, + Type: group.Type, + Status: "existing", + EventID: group.Key.EventID, + CanBeEmbedded: group.Key.CanBeEmbedded, + BlockForSuggested: group.Key.BlockForSuggested, + }) + } + for i := 1; i < len(group.Sections); i++ { + cur := group.Sections[i] + if cur == prev+1 { + prev = cur + continue + } + flushRun(runStart, prev) + runStart = cur + prev = cur + } + flushRun(runStart, prev) + } + + // 2. 再处理 suggested。 for _, item := range allocatedItems { if item.EmbeddedTime == nil { continue @@ -554,16 +852,17 @@ func (ss *ScheduleService) HybridScheduleWithPlan( name = strings.TrimSpace(*item.Content) } entries = append(entries, model.HybridScheduleEntry{ - Week: item.EmbeddedTime.Week, - DayOfWeek: item.EmbeddedTime.DayOfWeek, - SectionFrom: item.EmbeddedTime.SectionFrom, - SectionTo: item.EmbeddedTime.SectionTo, - Name: name, - Type: "task", - Status: "suggested", - TaskItemID: item.ID, + Week: item.EmbeddedTime.Week, + DayOfWeek: item.EmbeddedTime.DayOfWeek, + SectionFrom: item.EmbeddedTime.SectionFrom, + SectionTo: item.EmbeddedTime.SectionTo, + Name: name, + Type: "task", + Status: "suggested", + TaskItemID: item.ID, + BlockForSuggested: true, }) } - return entries, allocatedItems, nil + return entries } diff --git a/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md b/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md index 9ecacd1..468d98e 100644 --- a/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md +++ b/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md @@ -1,3 +1,194 @@ +# 智能排程 Agent — ReAct 精排引擎 决策记录(2026-03-21 更新版) + +## 0. 文档说明(先看这里) +- 本文档分为两部分: + 1. **上半部分**:当前线上代码对应的“最终链路决策”(2026-03-21 版本)。 + 2. **下半部分**:2026-03-19 的原始决策内容,**原样保留**作为发展历程。 +- 这样做的目的: + 1. 评审时先看“现在系统到底怎么跑”; + 2. 复盘时还能追踪“从旧方案到现方案”的演进路径。 + +## 1. 基本信息(当前版本) +- 记录编号:FDR-008B +- 功能名称:智能排程 ReAct 精排引擎(阶段 2:多任务类分流 + 日级并发 + 周级并发单步优化 + 双通道交付) +- 记录日期:2026-03-21 +- 决策状态:已采纳,已落地 +- 负责人:SmartFlow 团队 +- 关联需求:FDR-008(初版)、FDR-007(智能排程 Agent 阶段 1) + +## 2. 本轮最终决策(A->B 最终态) +### 2.1 决策摘要 +- 决策 1:智能排程入口继续走统一 Agent 分流(`action=schedule_plan`),不新增独立聊天接口。 +- 决策 2:图内分流改为“**按 task_class_ids 长度**”: + 1. `len(task_class_ids) == 1`:跳过 `daily_split/daily_refine/merge`,直接周级优化; + 2. `len(task_class_ids) >= 2`:先日级并发优化,再周级并发优化。 +- 决策 3:周级优化改为“**单步动作模式**”(每轮只允许 1 个 `Move/Swap` 或 `done`)。 +- 决策 4:周级预算改为“双预算 + 每有效周保底 + 负载加权”: + 1. 总预算:成功/失败都扣; + 2. 有效预算:仅成功动作扣; + 3. 每个有效周至少 1 个总预算和 1 个有效预算; + 4. 额外预算按周负载加权分配。 +- 决策 5:输出采用双通道: + 1. SSE 主通道仅返回终审自然语言(`FinalSummary`); + 2. 结构化 `candidate_plans` 走 `/api/v1/agent/schedule-preview` 查询(Redis 快照)。 + +### 2.2 为什么这样改 +- 目标 1:把“单任务类”和“多任务类混排”拆开治理,减少无效模型调用。 +- 目标 2:把周级优化从“模型大段犹豫”改成“走一步看一步”,缩短长尾。 +- 目标 3:前端同时拿到“可读结论”和“结构化预览”,避免 SSE 里吐 JSON 影响体验。 +- 目标 4:预算治理可解释,避免某些有效周被分配为 0 导致“看起来没优化”。 + +## 3. 当前全链路(代码真实流程) +### 3.1 Agent 总分流(与 schedule_plan 的关系) +1. `POST /api/v1/agent/chat` 进入 `AgentService.AgentChat`。 +2. 先做会话存在性检查(Redis -> DB -> 必要时创建)。 +3. 调 `route.DecideActionRouting` 获取 action。 +4. 若 `RouteFailed=true`:直接返回内部错误(不再回落聊天)。 +5. 若 `action=schedule_plan`:进入 `runSchedulePlanFlow`。 +6. 若 `runSchedulePlanFlow` 报错:当前策略是记录日志 + 发 fallback 阶段块 + 回退普通聊天(可用性优先)。 + +### 3.2 schedule_plan 图编排(当前版本) +```mermaid +flowchart TD + START([START]) --> plan["plan
提取意图/约束/strategy/task_class_ids/task_tags"] + plan --> p1{"FinalSummary 非空
或 task_class_ids 为空?"} + p1 -- "是" --> exit["exit -> END"] + p1 -- "否" --> rough["rough_build
HybridScheduleWithPlanMulti
+ 可选 ResolvePlanningWindow"] + + rough --> p2{"构建失败或 HybridEntries 为空?"} + p2 -- "是" --> exit + p2 -- "否" --> p3{"len(task_class_ids) >= 2 ?"} + + p3 -- "是" --> split["daily_split
按天拆组 + ContextTag 注入 + SkipRefine 标记"] + split --> drefine["daily_refine(并发)
单天 ReAct,失败回退原天"] + drefine --> merge["merge
合并结果,冲突回退"] + merge --> wrefine["weekly_refine(并发按周)
单步 Move/Swap + 双预算"] + + p3 -- "否" --> wrefine + wrefine --> final["final_check
物理校验 + 总结生成"] + final --> preview["return_preview
回填 AllocatedItems + 产出 CandidatePlans"] + preview --> END([END]) +``` + +## 4. 节点级职责边界(当前实现) +### 4.1 `plan` +1. 先合并 `extra.task_class_ids`(显式参数优先); +2. 再用模型抽取 `intent/constraints/strategy/task_tags`; +3. 模型失败但有 `task_class_ids` 时,使用参数兜底继续; +4. 最终仍无任务类时,写入 `FinalSummary` 并提前退出。 + +### 4.2 `rough_build` +1. 调 `HybridScheduleWithPlanMulti` 统一构建混合条目(existing + suggested); +2. 生成 `CandidatePlans`(预览结构); +3. 可选解析全局窗口边界(`PlanStartWeek/Day` ~ `PlanEndWeek/Day`),供周级 Move 硬校验; +4. 失败时写 `FinalSummary` 并提前退出。 + +### 4.3 `daily_split` +1. 按 `(week, day)` 拆成 `DayGroup`; +2. 按 `task_item_id -> tag` 注入 `ContextTag`; +3. `suggested <= 2` 标记 `SkipRefine=true`,减少低收益模型调用。 + +### 4.4 `daily_refine`(并发) +1. 按天并发执行单天 ReAct; +2. 单天失败只回退该天,不拖垮全局; +3. 发“day_start/day_done”阶段块,并携带进度; +4. Thinking 默认关闭(降低并发阶段长尾)。 + +### 4.5 `merge` +1. 合并 `DailyResults`; +2. 做冲突校验(同一 `(week,day,section)` 不能被阻塞条目重复占用); +3. 有冲突则整体回退到 merge 前快照; +4. 产出 `MergeSnapshot`,供 `final_check` 二次回退。 + +### 4.6 `weekly_refine`(并发按周 + 单步动作) +1. 先按周拆数据,仅对“有 suggested 的周”分配预算; +2. 强制每个有效周至少 1 总预算 + 1 有效预算; +3. 剩余预算按周负载加权分配; +4. 单周 worker 循环: + 1. 每轮仅 1 个工具调用(`Move/Swap`)或 `done`; + 2. 总预算:调用即扣; + 3. 有效预算:成功才扣; + 4. Move 必须留在 worker 当前周,且受全局窗口 day 边界约束; + 5. 工具结果回灌到下一轮上下文,形成“走一步看一步”。 + +### 4.7 `final_check` +1. 物理校验三项: + 1. 时间冲突; + 2. 节次越界; + 3. suggested 数量与 `AllocatedItems` 数量一致性。 +2. 校验失败时回退 `MergeSnapshot`; +3. 再由模型生成 2-3 句最终总结(thinking 关闭)。 + +### 4.8 `return_preview` +1. 把 `HybridEntries` 中 suggested 的最终位置回填到 `AllocatedItems`; +2. 转换为 `CandidatePlans`; +3. 若 `FinalSummary` 为空则兜底填充; +4. 仅返回预览,不直接落库。 + +## 5. 工具与约束(当前版本) +### 5.1 工具集合 +1. `Swap`:交换两个 suggested 任务时间; +2. `Move`:移动一个 suggested 任务到新时间; +3. `TimeAvailable`:检查时间段可用性; +4. `GetAvailableSlots`:列出可用槽位。 + +### 5.2 周级单步模式硬约束 +1. 仅允许 `Move/Swap`; +2. 不允许跨 worker 周移动; +3. 若启用全局窗口,`Move` 必须落在窗口允许的 day 区间; +4. 失败返回工具失败结果,不抛异常中断整周。 + +## 6. 输出协议与接口契约(当前版本) +### 6.1 SSE 主通道 +1. 阶段块(伪装 reasoning chunk)用于进度反馈; +2. 最终正文为 `FinalSummary`(不再吐 JSON); +3. 结束块遵循 OpenAI 兼容流式格式。 + +### 6.2 结构化预览通道 +1. 排程结束后把快照写 Redis(`user_id + conversation_id` 作用域); +2. 查询接口:`GET /api/v1/agent/schedule-preview?conversation_id=...`; +3. 未命中返回业务错误码(预览不存在/过期); +4. 写预览失败只记日志,不阻塞聊天主链路。 + +## 7. 失败策略与回退策略(当前版本) +1. 路由控制码失败:直接报内部错误(不回落 chat)。 +2. schedule_plan 分支内部失败:上层当前策略仍回落普通聊天(可用性优先)。 +3. daily 单天失败:回退该天原方案。 +4. merge 冲突:回退 merge 前快照。 +5. final_check 失败:回退 `MergeSnapshot`。 +6. 预览缓存写失败:仅影响结构化查询,不影响 SSE 文本回复。 + +## 8. 影响范围(当前版本) +### 8.1 主要模块 +1. `backend/agent/scheduleplan/*`(图编排、日级并发、周级并发、工具、预算、终审) +2. `backend/service/agentsvc/agent_schedule_plan.go`(服务层接入 graph) +3. `backend/service/agentsvc/agent_schedule_preview.go`(预览缓存读写) +4. `backend/dao/agent-cache.go`(预览 Redis DAO) +5. `backend/api/agent.go` + `backend/routers/routers.go`(预览查询接口) +6. `backend/agent/route/route.go`(统一 action 分流) + +### 8.2 数据与存储 +1. 主排程流程不直接写最终排程库; +2. 新增 Redis 预览快照读写; +3. 聊天消息仍走统一后置持久化(Redis + outbox/DB)。 + +## 9. 验证与回滚(当前版本) +### 9.1 验证要点 +1. `task_class_ids=1` 时应跳过日级并发,直接周级优化; +2. `task_class_ids>=2` 时应进入日级并发 + merge + 周级并发; +3. SSE 正文应是自然语言,不应出现粗排 JSON; +4. `/agent/schedule-preview` 能按 `conversation_id` 读到 `candidate_plans`; +5. 预算日志应能解释每周预算分配和消耗。 + +### 9.2 回滚策略 +1. 软回滚:关闭 schedule_plan 路由命中(临时回落聊天解释); +2. 逻辑回滚:周级并发退回单线程或关掉 daily_refine; +3. 通道回滚:保留 SSE 文本,临时下线 `schedule-preview` 查询。 + +--- + +## 附录 A:2026-03-19 原始内容(原文保留,作为发展历程) + # 智能排程 Agent — ReAct 精排引擎 决策记录 ## 1. 基本信息