Files
smartmate/backend/agent/scheduleplan/nodes.go
Losita d3cec2a5b9 Version: 0.7.0.dev.260319
 feat(agent): 新增智能排程 Agent 全链路 + ReAct 精排引擎

  🏗️ 智能排程 Graph 编排(阶段 1 基础链路)
  - 新增 scheduleplan 包:state / tool / prompt / nodes / runner / graph 六件套
  - 实现 plan → preview → materialize → apply → reflect → finalize 完整图编排
  - 通过函数注入解耦 agent 层与 service 层,避免循环依赖
  - 路由层新增 schedule_plan 动作,复用现有 SSE + 持久化链路

  🧠 ReAct 精排引擎(阶段 1.5 语义化微调)
  - 粗排后构建"混合日程"(既有课程 + 建议任务),统一为 HybridScheduleEntry
  - LLM 开启深度思考,通过 Swap / Move / TimeAvailable / GetAvailableSlots 四个 Tool 在内存中优化任务时间
  - reasoning_content 实时流式推送前端,用户可见 AI 思考过程
  - 精排结果仅预览不落库,向后兼容(未注入依赖时走原有 materialize 路径)

  📝 文档
  - 新增 ReAct 精排引擎决策记录

  ⚠️ 已知问题:深度思考模式耗时较长,超时策略待优化
2026-03-19 23:18:56 +08:00

818 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package scheduleplan
import (
"context"
"encoding/json"
"errors"
"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"
)
// ── plan 节点模型输出结构 ──
type schedulePlanIntentOutput struct {
Intent string `json:"intent"`
Constraints []string `json:"constraints"`
TaskClassID int `json:"task_class_id"`
Strategy string `json:"strategy"`
}
// ── materialize 节点模型输出结构 ──
type schedulePlanMaterializeOutput struct {
Assignments []materializeAssignment `json:"assignments"`
UnassignedItemIDs []int `json:"unassigned_item_ids"`
}
type materializeAssignment struct {
TaskItemID int `json:"task_item_id"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
StartSection int `json:"start_section"`
EndSection int `json:"end_section"`
EmbedCourseEventID int `json:"embed_course_event_id"`
}
// ── reflect 节点模型输出结构 ──
type schedulePlanReflectOutput struct {
Action string `json:"action"`
Reason string `json:"reason"`
PatchedAssignments []materializeAssignment `json:"patched_assignments"`
RemoveItemIDs []int `json:"remove_item_ids"`
}
// ══════════════════════════════════════════════════════════════
// plan 节点
// ══════════════════════════════════════════════════════════════
// runPlanNode 负责"意图识别 + 约束提取"。
//
// 职责边界:
// 1) 从用户消息中提取排程意图、约束条件、策略;
// 2) task_class_id 优先从 Extra 字段获取,模型推断作为兜底;
// 3) 不负责调用粗排算法,只做意图分析。
func runPlanNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
userMessage string,
extra map[string]any,
chatHistory []*schema.Message,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in plan node")
}
emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求...")
// 1. 优先从 Extra 字段获取 task_class_id避免依赖模型推断。
if extra != nil {
if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 {
st.TaskClassID = tcID
}
}
// 2. 检查对话历史中是否包含上版排程方案(用于连续对话微调)。
previousPlan := extractPreviousPlanFromHistory(chatHistory)
if previousPlan != "" {
st.PreviousPlanJSON = previousPlan
st.IsAdjustment = true
}
// 3. 构造 prompt 让模型分析意图和约束。
adjustmentHint := ""
if st.IsAdjustment {
adjustmentHint = "\n注意这是对已有排程的微调请求。用户可能只想调整部分内容如'早八不想学习'),请只提取变更部分的约束。"
}
prompt := fmt.Sprintf(`当前时间(北京时间):%s
用户输入:%s%s
请分析用户的排程意图并提取约束条件。`,
st.RequestNowText,
strings.TrimSpace(userMessage),
adjustmentHint,
)
// 3.1 模型调用失败时保守处理:只要有 task_class_id 就继续,否则报错。
raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanIntentPrompt, prompt, 256)
if callErr != nil {
if st.TaskClassID > 0 {
// 有 task_class_id 就可以继续,意图用兜底值。
st.UserIntent = strings.TrimSpace(userMessage)
emitStage("schedule_plan.plan.fallback", "意图分析失败,使用默认配置继续。")
return st, nil
}
st.FinalSummary = "抱歉,我没能理解你的排程需求,请再描述一下或直接传入任务类 ID。"
return st, nil
}
// 3.2 解析模型输出。
parsed, parseErr := parseScheduleJSON[schedulePlanIntentOutput](raw)
if parseErr != nil {
if st.TaskClassID > 0 {
st.UserIntent = strings.TrimSpace(userMessage)
return st, nil
}
st.FinalSummary = "抱歉,我没能解析排程意图,请再试一次。"
return st, nil
}
// 4. 回填状态。
st.UserIntent = strings.TrimSpace(parsed.Intent)
if st.UserIntent == "" {
st.UserIntent = strings.TrimSpace(userMessage)
}
if len(parsed.Constraints) > 0 {
st.Constraints = parsed.Constraints
}
if st.TaskClassID <= 0 && parsed.TaskClassID > 0 {
st.TaskClassID = parsed.TaskClassID
}
if parsed.Strategy == "rapid" {
st.Strategy = "rapid"
}
emitStage("schedule_plan.plan.done", fmt.Sprintf("已理解排程意图:%s", st.UserIntent))
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
}
// ══════════════════════════════════════════════════════════════
// materialize 节点
// ══════════════════════════════════════════════════════════════
// runMaterializeNode 负责将粗排已分配的任务项转换为可落库结构。
//
// 职责边界:
// 1) 纯代码转换,不调用模型——粗排算法已完成分配,每个 item 的 EmbeddedTime 已回填;
// 2) 直接将 AllocatedItems 转为 BatchApplyPlans 可消费的 SingleTaskClassItem 数组;
// 3) 跳过 EmbeddedTime 为空的项(未成功分配的任务项),并在回复中说明。
func runMaterializeNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
deps SchedulePlanToolDeps,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in materialize node")
}
if len(st.AllocatedItems) == 0 {
// 无已分配项preview 已设置了 FinalSummary直接透传。
return st, nil
}
emitStage("schedule_plan.materialize.converting", "正在将排程方案转换为可执行计划...")
// 1. 将已分配的任务项直接转换为 BatchApplyPlans 请求结构。
// 粗排算法已在 EmbeddedTime 中回填了 Week/DayOfWeek/SectionFrom/SectionTo
// 这里只做格式映射,不做二次分配。
items := make([]model.SingleTaskClassItem, 0, len(st.AllocatedItems))
skippedCount := 0
for _, allocated := range st.AllocatedItems {
if allocated.EmbeddedTime == nil {
// EmbeddedTime 为空说明粗排未能为该项找到可用槽位,跳过。
skippedCount++
continue
}
items = append(items, model.SingleTaskClassItem{
TaskItemID: allocated.ID,
Week: allocated.EmbeddedTime.Week,
DayOfWeek: allocated.EmbeddedTime.DayOfWeek,
StartSection: allocated.EmbeddedTime.SectionFrom,
EndSection: allocated.EmbeddedTime.SectionTo,
EmbedCourseEventID: 0, // 阶段 1 暂不支持嵌入水课,后续可扩展
})
}
if len(items) == 0 {
st.FinalSummary = "所有任务项均未能分配到可用时间槽,请检查课表或调整时间范围。"
return st, nil
}
st.ApplyRequest = &model.UserInsertTaskClassItemToScheduleRequestBatch{
TaskClassID: st.TaskClassID,
Items: items,
}
detail := fmt.Sprintf("已生成 %d 项排程安排。", len(items))
if skippedCount > 0 {
detail += fmt.Sprintf("%d 项因槽位不足未能安排)", skippedCount)
}
emitStage("schedule_plan.materialize.done", detail)
return st, nil
}
// ══════════════════════════════════════════════════════════════
// apply 节点
// ══════════════════════════════════════════════════════════════
// runApplyNode 负责将排程方案落库。
//
// 职责边界:
// 1) 调用 BatchApplyPlans 服务执行写库;
// 2) 成功时标记 Applied=true
// 3) 失败时记录错误信息,由分支逻辑决定是否进入 reflect 重试。
func runApplyNode(
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 apply node")
}
if st.ApplyRequest == nil || len(st.ApplyRequest.Items) == 0 {
return st, nil
}
emitStage("schedule_plan.apply.persisting", "正在检查冲突并落库排程方案...")
err := deps.BatchApplyPlans(ctx, st.TaskClassID, st.UserID, st.ApplyRequest)
if err != nil {
st.RecordApplyError(err.Error())
if st.CanRetry() {
emitStage("schedule_plan.apply.conflict", fmt.Sprintf("落库失败(第%d次准备调整方案...", st.RetryCount))
} else {
emitStage("schedule_plan.apply.failed", "多次尝试后仍无法落库,请手动调整。")
}
return st, nil
}
st.Applied = true
st.ApplyError = ""
emitStage("schedule_plan.apply.done", "排程方案已成功落库!")
return st, nil
}
// ══════════════════════════════════════════════════════════════
// reflect 节点
// ══════════════════════════════════════════════════════════════
// runReflectNode 负责分析落库失败原因并生成修补方案。
//
// 职责边界:
// 1) 把后端错误信息喂给模型,让模型决定修补策略;
// 2) retry_with_patch重新构建 ApplyRequest 并回到 apply
// 3) partial_apply移除冲突项后重新构建 ApplyRequest
// 4) give_up设置 FinalSummary 并退出。
func runReflectNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in reflect node")
}
emitStage("schedule_plan.reflect.analyzing", "正在分析失败原因并调整方案...")
// 1. 构造 prompt包含错误信息和当前方案。
currentPlanJSON, _ := json.Marshal(st.ApplyRequest)
prompt := fmt.Sprintf(`排程落库失败,错误信息:%s
当前排程方案(%d 个任务项):
%s
请分析失败原因并给出修补方案。`,
st.ApplyError,
len(st.ApplyRequest.Items),
string(currentPlanJSON),
)
raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanReflectPrompt, prompt, 1024)
if callErr != nil {
// 模型调用失败,直接放弃。
st.ReflectAction = "give_up"
st.FinalSummary = fmt.Sprintf("排程落库失败且无法自动修补:%s。请手动调整排程。", st.ApplyError)
return st, nil
}
parsed, parseErr := parseScheduleJSON[schedulePlanReflectOutput](raw)
if parseErr != nil {
st.ReflectAction = "give_up"
st.FinalSummary = fmt.Sprintf("排程落库失败:%s。请手动调整。", st.ApplyError)
return st, nil
}
st.ReflectAction = strings.TrimSpace(parsed.Action)
switch st.ReflectAction {
case "retry_with_patch":
// 2. 用模型给出的修补方案替换当前请求。
if len(parsed.PatchedAssignments) > 0 {
items := make([]model.SingleTaskClassItem, 0, len(parsed.PatchedAssignments))
for _, a := range parsed.PatchedAssignments {
items = append(items, model.SingleTaskClassItem{
TaskItemID: a.TaskItemID,
Week: a.Week,
DayOfWeek: a.DayOfWeek,
StartSection: a.StartSection,
EndSection: a.EndSection,
EmbedCourseEventID: a.EmbedCourseEventID,
})
}
st.ApplyRequest.Items = items
}
emitStage("schedule_plan.reflect.patched", "已调整方案,准备重新落库。")
case "partial_apply":
// 3. 移除冲突项后重试。
if len(parsed.RemoveItemIDs) > 0 {
removeSet := make(map[int]bool)
for _, id := range parsed.RemoveItemIDs {
removeSet[id] = true
}
filtered := make([]model.SingleTaskClassItem, 0)
for _, item := range st.ApplyRequest.Items {
if !removeSet[item.TaskItemID] {
filtered = append(filtered, item)
}
}
st.ApplyRequest.Items = filtered
}
if len(st.ApplyRequest.Items) == 0 {
st.ReflectAction = "give_up"
st.FinalSummary = "移除冲突项后没有剩余可安排的任务,请检查课表或调整时间范围。"
return st, nil
}
emitStage("schedule_plan.reflect.partial", fmt.Sprintf("已移除冲突项,剩余 %d 项准备落库。", len(st.ApplyRequest.Items)))
default:
// 4. give_up 或未知动作。
reason := strings.TrimSpace(parsed.Reason)
if reason == "" {
reason = st.ApplyError
}
st.FinalSummary = fmt.Sprintf("排程无法自动完成:%s。建议手动调整。", reason)
}
return st, nil
}
// ══════════════════════════════════════════════════════════════
// finalize 节点
// ══════════════════════════════════════════════════════════════
// runFinalizeNode 负责生成最终回复文案。
//
// 职责边界:
// 1) 落库成功时调用模型生成友好摘要;
// 2) 落库失败时透传已有的 FinalSummary
// 3) 将上版方案信息嵌入回复,支持前端在连续对话中回传。
func runFinalizeNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in finalize node")
}
// 1. 如果已有 FinalSummary失败场景直接使用。
if strings.TrimSpace(st.FinalSummary) != "" {
return st, nil
}
// 2. 落库未成功时给兜底文案。
if !st.Applied {
st.FinalSummary = "本次排程未能成功落库,请检查任务类配置后重试。"
return st, nil
}
emitStage("schedule_plan.finalize.summarizing", "正在生成排程结果摘要...")
// 3. 调用模型生成友好摘要。
planJSON, _ := json.Marshal(st.ApplyRequest)
constraintsText := "无"
if len(st.Constraints) > 0 {
constraintsText = strings.Join(st.Constraints, "、")
}
prompt := fmt.Sprintf(`排程结果:
- 成功安排 %d 个任务项
- 排程方案:%s
- 用户约束:%s
- 排程意图:%s
请生成结果摘要。`,
len(st.ApplyRequest.Items),
string(planJSON),
constraintsText,
st.UserIntent,
)
raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanFinalizePrompt, prompt, 256)
if callErr != nil {
// 模型生成摘要失败,使用固定文案。
st.FinalSummary = fmt.Sprintf("排程完成!已成功安排 %d 个任务项。", len(st.ApplyRequest.Items))
} else {
summary := strings.TrimSpace(raw)
// 移除可能的 JSON 包裹或 markdown。
summary = strings.Trim(summary, "\"'`")
if summary == "" {
summary = fmt.Sprintf("排程完成!已成功安排 %d 个任务项。", len(st.ApplyRequest.Items))
}
st.FinalSummary = summary
}
st.Completed = true
emitStage("schedule_plan.finalize.done", "排程完成!")
return st, nil
}
// ══════════════════════════════════════════════════════════════
// 分支决策函数
// ══════════════════════════════════════════════════════════════
// selectNextAfterPlan 根据 plan 节点结果决定下一步。
//
// 分支规则:
// 1) FinalSummary 非空 -> exitplan 阶段已确定无法继续)
// 2) TaskClassID 无效 -> exit
// 3) 其余 -> preview
func selectNextAfterPlan(st *SchedulePlanState) string {
if st == nil {
return schedulePlanGraphNodeExit
}
if strings.TrimSpace(st.FinalSummary) != "" {
return schedulePlanGraphNodeExit
}
if st.TaskClassID <= 0 {
return schedulePlanGraphNodeExit
}
return schedulePlanGraphNodePreview
}
// selectNextAfterApply 根据 apply 节点结果决定下一步。
//
// 分支规则:
// 1) Applied=true -> finalize成功落库
// 2) CanRetry=true -> reflect尝试修补
// 3) CanRetry=false -> finalize重试耗尽由 finalize 输出失败文案)
func selectNextAfterApply(st *SchedulePlanState) string {
if st == nil {
return schedulePlanGraphNodeFinalize
}
if st.Applied {
return schedulePlanGraphNodeFinalize
}
if st.CanRetry() {
return schedulePlanGraphNodeReflect
}
// 重试耗尽,设置失败文案后进入 finalize。
if strings.TrimSpace(st.FinalSummary) == "" {
st.FinalSummary = fmt.Sprintf("排程落库多次失败:%s。请手动调整。", st.ApplyError)
}
return schedulePlanGraphNodeFinalize
}
// selectNextAfterReflect 根据 reflect 节点结果决定下一步。
//
// 分支规则:
// 1) give_up -> finalize
// 2) retry_with_patch / partial_apply -> apply重新落库
func selectNextAfterReflect(st *SchedulePlanState) string {
if st == nil {
return schedulePlanGraphNodeFinalize
}
if st.ReflectAction == "give_up" {
return schedulePlanGraphNodeFinalize
}
if st.ApplyRequest == nil || len(st.ApplyRequest.Items) == 0 {
return schedulePlanGraphNodeFinalize
}
return schedulePlanGraphNodeApply
}
// ══════════════════════════════════════════════════════════════
// 工具函数
// ══════════════════════════════════════════════════════════════
// callScheduleModelForJSON 调用模型并期望返回 JSON 结果。
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),
}
opts := []einoModel.Option{
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0),
}
if maxTokens > 0 {
opts = append(opts, einoModel.WithMaxTokens(maxTokens))
}
resp, err := chatModel.Generate(ctx, messages, opts...)
if err != nil {
return "", err
}
if resp == nil {
return "", errors.New("模型返回为空")
}
content := strings.TrimSpace(resp.Content)
if content == "" {
return "", errors.New("模型返回内容为空")
}
return content, nil
}
// parseScheduleJSON 解析模型返回的 JSON 内容。
// 兼容 ```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, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
var out T
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
// 提取最外层 JSON 对象。
start := strings.Index(clean, "{")
end := strings.LastIndex(clean, "}")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("no json object found in: %s", clean)
}
obj := clean[start : end+1]
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, err
}
return &out, nil
}
// buildTaskItemsInfo 将任务项列表格式化为模型可读的文本。
func buildTaskItemsInfo(items []model.TaskClassItem) string {
if len(items) == 0 {
return "无任务项"
}
var sb strings.Builder
for i, item := range items {
content := "未命名"
if item.Content != nil && strings.TrimSpace(*item.Content) != "" {
content = strings.TrimSpace(*item.Content)
}
order := i + 1
if item.Order != nil {
order = *item.Order
}
sb.WriteString(fmt.Sprintf("- ID=%d, 序号=%d, 内容=%s\n", item.ID, order, content))
}
return sb.String()
}
// 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 {
continue
}
content := strings.TrimSpace(msg.Content)
if strings.Contains(content, "排程完成") || strings.Contains(content, "已成功安排") {
return content
}
}
return ""
}
// ══════════════════════════════════════════════════════════════
// hybridBuild 节点
// ══════════════════════════════════════════════════════════════
// runHybridBuildNode 负责构建"混合日程":将既有日程与粗排建议合并。
//
// 职责边界:
// 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) 不落库——用户需确认后再走落库链路。
func runReturnPreviewNode(
ctx context.Context,
st *SchedulePlanState,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in returnPreview node")
}
emitStage("schedule_plan.preview_return.building", "正在生成优化后的排程预览...")
// 1. 将 HybridEntries 中 suggested 的任务回写到 AllocatedItems 的 EmbeddedTime。
// 这样后续如果用户确认,可以直接走 materialize → apply 落库。
suggestedMap := make(map[int]*model.HybridScheduleEntry)
for i := range st.HybridEntries {
e := &st.HybridEntries[i]
if e.Status == "suggested" && e.TaskItemID > 0 {
suggestedMap[e.TaskItemID] = e
}
}
for i := range st.AllocatedItems {
item := &st.AllocatedItems[i]
if entry, ok := suggestedMap[item.ID]; ok && item.EmbeddedTime != nil {
item.EmbeddedTime.Week = entry.Week
item.EmbeddedTime.DayOfWeek = entry.DayOfWeek
item.EmbeddedTime.SectionFrom = entry.SectionFrom
item.EmbeddedTime.SectionTo = entry.SectionTo
}
}
// 2. 将 HybridEntries 转为 CandidatePlans[]UserWeekSchedule供前端展示。
st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
// 3. 设置最终摘要。
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", "排程预览已生成,等待确认。")
return st, nil
}
// 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"},
5: {"14:00", "14:45"}, 6: {"14:55", "15:40"},
7: {"16:15", "17:00"}, 8: {"17:10", "17:55"},
9: {"19:00", "19:45"}, 10: {"19:55", "20:40"},
11: {"20:50", "21:35"}, 12: {"21:45", "22:30"},
}
// 按周分组
weekMap := make(map[int][]model.WeeklyEventBrief)
for _, e := range entries {
startTime := ""
endTime := ""
if t, ok := sectionTimeMap[e.SectionFrom]; ok {
startTime = t[0]
}
if t, ok := sectionTimeMap[e.SectionTo]; ok {
endTime = t[1]
}
brief := model.WeeklyEventBrief{
DayOfWeek: e.DayOfWeek,
Name: e.Name,
StartTime: startTime,
EndTime: endTime,
Type: e.Type,
Span: e.SectionTo - e.SectionFrom + 1,
Status: e.Status,
}
if e.EventID > 0 {
brief.ID = e.EventID
}
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 i := 0; i < len(result); i++ {
for j := i + 1; j < len(result); j++ {
if result[j].Week < result[i].Week {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}