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 精排引擎决策记录

  ⚠️ 已知问题:深度思考模式耗时较长,超时策略待优化
This commit is contained in:
Losita
2026-03-19 23:16:35 +08:00
parent cd95aeeaaa
commit d3cec2a5b9
24 changed files with 2737 additions and 24 deletions

4
.gitignore vendored
View File

@@ -23,4 +23,6 @@ backend/config.yaml
.vscode/
.DS_Store # Mac 用户必加
.gocache/
.gomodcache/
.gomodcache/
.claude/
.omc/

View File

@@ -5,6 +5,7 @@
1. 默认语言规则:所有注释、接口文案、说明、评审反馈均使用中文。
2. 请勤加注释,尤其是复杂逻辑部分,确保代码易于理解和维护。
3. 每次在本地执行测试命令(如 `go test`)后,必须清理项目根目录下的 `.gocache` 目录,避免缓存文件长期堆积。
4. 文件编码统一使用 UTF-8无 BOM禁止使用 GBK、GB2312 等其他编码,避免中文内容出现乱码。
## 注释规范(强制)

View File

@@ -30,28 +30,31 @@ var (
// 支持动作:
// 1. quick_note_create新增随口记任务
// 2. task_query查询任务
// 3. chat普通聊天
// 4. quick_note历史兼容别名解析后会映射到 quick_note_create。
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|quick_note|chat)["']?[^>]*>`)
// 3. schedule_plan智能排程生成/微调排程计划)
// 4. chat普通聊天
// 5. quick_note历史兼容别名解析后会映射到 quick_note_create。
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|schedule_plan|quick_note|chat)["']?[^>]*>`)
// routeReasonRegex 用于提取可选的理由块,方便日志排障。
routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`)
)
const routeControlPrompt = `你是 SmartFlow 的请求分流控制器。
你的唯一任务是给后端返回可机读控制码”,不要做用户可见回复,不要解释。
你的唯一任务是给后端返回可机读控制码”,不要做用户可见回复,不要解释。
动作定义:
1) quick_note_create用户明确希望记录/安排/提醒某件未来要做的事”。
2) task_query用户想查看/筛选/排序/获取”已有任务如最紧急、按DDL、某象限、关键词
3) chat其余全部普通对话包括闲聊、知识问答、纯讨论“怎么安排任务”但未要求你真的去操作)。
1) quick_note_create用户明确希望记录/安排/提醒某件未来要做的事”。
2) task_query用户想查看/筛选/排序/获取”已有任务如最紧急、按DDL、某象限、关键词
3) schedule_plan用户想”生成/调整/微调日程排程”(如”帮我排个学习计划”、”把早八的课调走”、”我不想周末学习”)。
4) chat其余全部普通对话包括闲聊、知识问答、纯讨论”怎么安排任务”但未要求你真的去操作
判定优先级(冲突时按顺序):
1) 若句子核心诉求是帮我记一件事”,选 quick_note_create。
2) 若核心诉求是帮我查任务列表/某类任务”,选 task_query。
3) 其他情况选 chat
1) 若句子核心诉求是帮我记一件事”,选 quick_note_create。
2) 若核心诉求是帮我查任务列表/某类任务”,选 task_query。
3) 若核心诉求是”帮我排日程/调整日程/生成学习计划/修改排程”,schedule_plan
4) 其他情况选 chat。
输出格式必须严格如下(两行):
<SMARTFLOW_ROUTE nonce="给定nonce" action="quick_note_create|task_query|chat"></SMARTFLOW_ROUTE>
<SMARTFLOW_ROUTE nonce=给定nonce action=quick_note_create|task_query|schedule_plan|chat></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>一句不超过30字的中文理由</SMARTFLOW_REASON>
禁止输出任何其他内容。`
@@ -63,6 +66,7 @@ const (
ActionChat Action = "chat"
ActionQuickNoteCreate Action = "quick_note_create"
ActionTaskQuery Action = "task_query"
ActionSchedulePlan Action = "schedule_plan"
// ActionQuickNote 是历史兼容别名,只用于解析旧 action 值。
ActionQuickNote Action = "quick_note"
@@ -132,6 +136,16 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user
TrustRoute: true,
Detail: reason,
}
case ActionSchedulePlan:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到排程请求,准备执行智能排程流程。"
}
return RoutingDecision{
Action: ActionSchedulePlan,
TrustRoute: true,
Detail: reason,
}
case ActionChat:
return RoutingDecision{
Action: ActionChat,
@@ -226,7 +240,7 @@ func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
actionText := strings.ToLower(strings.TrimSpace(header[2]))
action := Action(actionText)
switch action {
case ActionQuickNoteCreate, ActionTaskQuery, ActionChat:
case ActionQuickNoteCreate, ActionTaskQuery, ActionSchedulePlan, ActionChat:
// 合法动作直接通过。
case ActionQuickNote:
// 兼容旧动作值:统一映射到 quick_note_create。

View File

@@ -0,0 +1,243 @@
package scheduleplan
import (
"context"
"errors"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
)
const (
// 图节点:意图识别与约束提取
schedulePlanGraphNodePlan = "schedule_plan_plan"
// 图节点:调用粗排算法生成候选方案
schedulePlanGraphNodePreview = "schedule_plan_preview"
// 图节点:将候选方案转换为可落库结构
schedulePlanGraphNodeMaterialize = "schedule_plan_materialize"
// 图节点:执行落库
schedulePlanGraphNodeApply = "schedule_plan_apply"
// 图节点:分析失败原因并生成修补方案
schedulePlanGraphNodeReflect = "schedule_plan_reflect"
// 图节点:生成最终回复文案
schedulePlanGraphNodeFinalize = "schedule_plan_finalize"
// 图节点:退出(用于提前终止分支)
schedulePlanGraphNodeExit = "schedule_plan_exit"
// 图节点构建混合日程ReAct 精排前置)
schedulePlanGraphNodeHybridBuild = "schedule_plan_hybrid_build"
// 图节点ReAct 精排循环
schedulePlanGraphNodeReactRefine = "schedule_plan_react_refine"
// 图节点:返回精排预览结果(不落库)
schedulePlanGraphNodeReturnPreview = "schedule_plan_return_preview"
)
// SchedulePlanGraphRunInput 是运行"智能排程 graph"所需的输入依赖。
//
// 说明:
// 1) EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块);
// 2) Extra 传递前端附加参数(如 task_class_id
// 3) ChatHistory 用于连续对话微调场景。
type SchedulePlanGraphRunInput struct {
Model *ark.ChatModel
State *SchedulePlanState
Deps SchedulePlanToolDeps
UserMessage string
Extra map[string]any
ChatHistory []*schema.Message
EmitStage func(stage, detail string)
// ── ReAct 精排所需 ──
OutChan chan<- string // SSE 流式输出通道,用于推送 reasoning_content
ModelName string // 模型名称,用于构造 OpenAI 兼容 chunk
}
// RunSchedulePlanGraph 执行"智能排程"图编排。
//
// 图结构:
//
// START -> plan -> [branch] -> preview -> [branch] -> materialize -> [branch] -> apply -> [branch]
// | | | |
// exit exit exit finalize (成功)
// |
// reflect -> [branch] -> apply (重试)
// |
// finalize (放弃)
//
// 该文件只负责"连线与分支",节点内部逻辑全部下沉到 nodes.go。
func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) (*SchedulePlanState, error) {
// 1. 启动前硬校验:模型、状态、依赖缺一不可。
if input.Model == nil {
return nil, errors.New("schedule plan graph: model is nil")
}
if input.State == nil {
return nil, errors.New("schedule plan graph: state is nil")
}
if err := input.Deps.validate(); err != nil {
return nil, err
}
// 2. 统一封装阶段推送函数,避免各节点反复判空。
emitStage := func(stage, detail string) {
if input.EmitStage != nil {
input.EmitStage(stage, detail)
}
}
// 3. 构造 runner收口节点依赖。
runner := newSchedulePlanRunner(
input.Model,
input.Deps,
emitStage,
input.UserMessage,
input.Extra,
input.ChatHistory,
input.OutChan,
input.ModelName,
)
// 4. 创建状态图容器:输入/输出类型都为 *SchedulePlanState。
graph := compose.NewGraph[*SchedulePlanState, *SchedulePlanState]()
// 5. 注册节点。
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 {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeMaterialize, compose.InvokableLambda(runner.materializeNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeApply, compose.InvokableLambda(runner.applyNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeReflect, compose.InvokableLambda(runner.reflectNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeFinalize, compose.InvokableLambda(runner.finalizeNode)); 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 {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeReactRefine, compose.InvokableLambda(runner.reactRefineNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeReturnPreview, compose.InvokableLambda(runner.returnPreviewNode)); err != nil {
return nil, err
}
// ── 连线 ──
// 6. START -> plan
if err := graph.AddEdge(compose.START, schedulePlanGraphNodePlan); err != nil {
return nil, err
}
// 7. plan -> [branch] -> preview | exit
if err := graph.AddBranch(schedulePlanGraphNodePlan, compose.NewGraphBranch(
runner.nextAfterPlan,
map[string]bool{
schedulePlanGraphNodePreview: true,
schedulePlanGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
// 8. preview -> [branch] -> hybridBuild | materialize | exit
if err := graph.AddBranch(schedulePlanGraphNodePreview, compose.NewGraphBranch(
runner.nextAfterPreview,
map[string]bool{
schedulePlanGraphNodeHybridBuild: true,
schedulePlanGraphNodeMaterialize: true,
schedulePlanGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
// 8.1 hybridBuild -> [branch] -> reactRefine | exit
if err := graph.AddBranch(schedulePlanGraphNodeHybridBuild, compose.NewGraphBranch(
runner.nextAfterHybridBuild,
map[string]bool{
schedulePlanGraphNodeReactRefine: true,
schedulePlanGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
// 8.2 reactRefine -> returnPreview固定边
if err := graph.AddEdge(schedulePlanGraphNodeReactRefine, schedulePlanGraphNodeReturnPreview); err != nil {
return nil, err
}
// 8.3 returnPreview -> END
if err := graph.AddEdge(schedulePlanGraphNodeReturnPreview, compose.END); err != nil {
return nil, err
}
// 9. materialize -> [branch] -> apply | exit
if err := graph.AddBranch(schedulePlanGraphNodeMaterialize, compose.NewGraphBranch(
runner.nextAfterMaterialize,
map[string]bool{
schedulePlanGraphNodeApply: true,
schedulePlanGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
// 10. apply -> [branch] -> finalize | reflect
if err := graph.AddBranch(schedulePlanGraphNodeApply, compose.NewGraphBranch(
runner.nextAfterApply,
map[string]bool{
schedulePlanGraphNodeFinalize: true,
schedulePlanGraphNodeReflect: true,
},
)); err != nil {
return nil, err
}
// 11. reflect -> [branch] -> apply (重试) | finalize (放弃)
if err := graph.AddBranch(schedulePlanGraphNodeReflect, compose.NewGraphBranch(
runner.nextAfterReflect,
map[string]bool{
schedulePlanGraphNodeApply: true,
schedulePlanGraphNodeFinalize: true,
},
)); err != nil {
return nil, err
}
// 12. finalize -> END
if err := graph.AddEdge(schedulePlanGraphNodeFinalize, compose.END); err != nil {
return nil, err
}
// 13. exit -> END
if err := graph.AddEdge(schedulePlanGraphNodeExit, compose.END); err != nil {
return nil, err
}
// 14. 运行步数上限:原有链路 ~10 步 + ReAct 精排hybridBuild + reactRefine + returnPreview = 3
// 加余量到 25防止异常分支导致无限循环。
maxSteps := 25
// 15. 编译图得到可执行实例。
runnable, err := graph.Compile(ctx,
compose.WithGraphName("SchedulePlanGraph"),
compose.WithMaxRunSteps(maxSteps),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return nil, err
}
// 16. 执行图并返回最终状态。
return runnable.Invoke(ctx, input.State)
}

View File

@@ -0,0 +1,817 @@
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
}

View File

@@ -0,0 +1,175 @@
package scheduleplan
const (
// SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。
//
// 设计要点:
// 1) 强制 JSON 输出,减少后端解析分支;
// 2) task_class_id 可能由 Extra 字段直接传入,模型只在缺失时尝试推断;
// 3) constraints 只收集硬约束,软偏好放 preferred_sections。
SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。
请根据用户输入,提取排程意图与约束条件。
必须完成以下任务:
1) 用一句话概括用户的排程意图intent
2) 提取所有硬约束constraints如"早八不排"、"周末休息"等。
3) 如果用户明确提到了任务类名称或ID输出 task_class_id整数否则输出 -1。
4) 判断排程策略 strategy均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。
输出要求:
- 仅输出 JSON不要 markdown不要解释。
- 格式如下:
{
"intent": "用户排程意图摘要",
"constraints": ["约束1", "约束2"],
"task_class_id": -1,
"strategy": "steady"
}`
// SchedulePlanMaterializePrompt 用于 materialize 节点:
// 将粗排候选方案与任务项列表匹配,生成可落库的结构。
//
// 设计要点:
// 1) 模型负责"选择哪些任务项放到哪些时间槽"
// 2) 后端负责最终校验(冲突检测在 BatchApplyPlans 中执行);
// 3) 输出必须是严格 JSON 数组,每项包含 task_item_id + 时间坐标。
SchedulePlanMaterializePrompt = `你是 SmartFlow 的排程方案转换器。
你将收到两组数据:
1) 粗排算法推荐的可用时间槽列表(按周分组)。
2) 需要安排的任务项列表(每项有 ID 和内容)。
你的任务是把每个任务项分配到一个可用时间槽中。
约束规则:
1) 每个任务项只能分配到一个时间槽。
2) 同一个时间槽不能分配多个任务项。
3) 必须尊重用户约束(如有)。
4) 如果可用槽位不足,优先安排靠前的任务项,剩余的标记为 unassigned。
输出要求:
- 仅输出 JSON不要 markdown不要解释。
- 格式如下:
{
"assignments": [
{
"task_item_id": 1,
"week": 1,
"day_of_week": 1,
"start_section": 3,
"end_section": 4,
"embed_course_event_id": 0
}
],
"unassigned_item_ids": [5, 6]
}`
// SchedulePlanReflectPrompt 用于 reflect 节点:分析落库失败原因并生成修补方案。
//
// 设计要点:
// 1) 模型收到后端错误信息,决定修补策略;
// 2) 可选动作retry_with_patch换槽位重试、partial_apply跳过冲突项、give_up放弃
// 3) 修补方案必须是结构化 JSON后端直接消费。
SchedulePlanReflectPrompt = `你是 SmartFlow 的排程修补分析器。
排程方案落库失败了,请分析失败原因并给出修补方案。
你可以选择以下动作之一:
1) "retry_with_patch":修改冲突项的时间槽后重试。
2) "partial_apply":跳过冲突项,只落库不冲突的部分。
3) "give_up":放弃本次排程,向用户解释原因。
输出要求:
- 仅输出 JSON不要 markdown不要解释。
- 格式如下:
{
"action": "retry_with_patch|partial_apply|give_up",
"reason": "简短原因",
"patched_assignments": [
{
"task_item_id": 1,
"week": 1,
"day_of_week": 2,
"start_section": 5,
"end_section": 6,
"embed_course_event_id": 0
}
],
"remove_item_ids": [3]
}`
// SchedulePlanFinalizePrompt 用于 finalize 节点:生成用户友好的排程结果摘要。
//
// 设计要点:
// 1) 以事实为主(成功安排了几项、哪些时间段);
// 2) 提及用户约束是否被满足;
// 3) 若有未安排的项目,给出原因和建议。
SchedulePlanFinalizePrompt = `你是 SmartFlow 的排程结果播报员。
请根据排程结果,生成一段简洁友好的中文摘要回复给用户。
要求:
1) 说明成功安排了多少个任务项。
2) 简要描述时间分布(如"分布在第1~3周主要集中在工作日下午")。
3) 如果有未安排的项目,说明原因。
4) 如果用户有约束(如"早八不排"),确认是否已遵守。
5) 语气自然友好不超过100字。
6) 不要输出 markdown 或列表格式,只输出纯文本。`
// SchedulePlanReactSystemPrompt 用于 ReAct 精排节点:
// LLM 开启深度思考,通过 Tool 调用对粗排结果进行语义化优化。
//
// 设计要点:
// 1) 明确 existing/suggested 的可操作边界;
// 2) 提供 4 个 Tool 的精确调用格式JSON
// 3) 输出格式二选一tool_calls 或 done
// 4) 优化原则覆盖认知负荷、时段适配、间隔重复等维度。
SchedulePlanReactSystemPrompt = `你是 SmartFlow 智能排程精排优化器。
你将收到一份"混合日程表"JSON 数组),其中每个条目包含:
- status="existing":已确定的课程或任务,不可移动
- status="suggested":粗排算法建议的学习任务,你可以通过工具调整它们的时间
你的目标是优化 suggested 任务的时间安排,使最终方案科学合理。
## 优化原则
1. 上下文切换成本:相同或相近科目的任务尽量安排在相邻时段,减少频繁切换带来的认知损耗
2. 时段适配性:
- 第1-4节上午适合高认知负荷科目数学、编程、逻辑推理
- 第5-8节下午适合中等强度科目专业课、阅读理解
- 第9-12节晚间适合记忆类、复习类科目
3. 学习效率曲线避免连续安排超过4节高强度学习适当穿插不同类型的任务
4. 间隔重复:同一科目的复习任务在时间上适当分散到不同天,符合遗忘曲线规律
5. 用户约束:严格遵守用户提出的约束条件(如有)
## 可用工具
1. Swap — 交换两个 suggested 任务的时间位置
参数task_atask_item_idtask_btask_item_id
2. Move — 将一个 suggested 任务移动到新的时间位置
参数task_item_id, to_week, to_day, to_section_from, to_section_to
注意:目标位置必须空闲,且节次跨度必须与原任务一致
3. TimeAvailable — 检查目标时间段是否可用
参数week, day_of_week, section_from, section_to
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}}]}
完成优化时:
{"done":true,"summary":"简要说明做了哪些优化及理由"}
## 工作流程
1. 仔细分析当前排程,识别不合理之处
2. 如需了解可用时间,先调用 GetAvailableSlots
3. 确定调整方案后,调用 Swap 或 Move 执行
4. 你可以一次输出多个工具调用,后端会按顺序执行
5. 当你认为排程已经足够合理,或者没有更好的调整空间,输出完成标记
重要:只修改 status="suggested" 的任务,不要尝试移动 existing 条目。`
)

View File

@@ -0,0 +1,209 @@
package scheduleplan
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/agent/chat"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// reactRoundTimeout 是单轮 ReAct 的超时时间。
// 深度思考模式下 reasoning 阶段可能耗时较长,需要给足时间。
const reactRoundTimeout = 5 * time.Minute
// runReactRefineNode 执行 ReAct 精排循环。
//
// 核心流程(最多 ReactMaxRound 轮):
// 1. 构造 messagessystem prompt + 混合日程 JSON + 上轮 tool 结果)
// 2. 调用 chatModel.Stream() + ThinkingTypeEnabled
// 3. reasoning_content 实时推送到 outChan前端可见思考过程
// 4. content 累积后解析done=true 则退出tool_calls 则执行
// 5. tool 结果拼入下一轮 messages
func runReactRefineNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
outChan chan<- string,
modelName string,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, fmt.Errorf("schedule plan graph: nil state in reactRefine node")
}
if chatModel == nil {
return nil, fmt.Errorf("schedule plan graph: model is nil in reactRefine node")
}
if len(st.HybridEntries) == 0 {
st.ReactDone = true
st.ReactSummary = "无可优化的排程条目。"
return st, nil
}
// 准备 SSE 流式输出的基础参数
if strings.TrimSpace(modelName) == "" {
modelName = "smartflow-worker"
}
// 构造混合日程 JSON只在首轮构造后续轮次复用
hybridJSON, err := json.Marshal(st.HybridEntries)
if err != nil {
return nil, fmt.Errorf("序列化混合日程失败: %w", err)
}
// 用户约束文本
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 {
st.ReactDone = true
if strings.TrimSpace(st.ReactSummary) == "" {
st.ReactSummary = fmt.Sprintf("排程优化已达最大轮次(%d 轮),使用当前结果。", st.ReactRound)
}
emitStage("schedule_plan.react.max_round", "已达最大优化轮次,使用当前结果。")
}
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}),
)
if err != nil {
return "", fmt.Errorf("模型 Stream 调用失败: %w", err)
}
defer reader.Close()
requestID := "react-" + fmt.Sprintf("%d", time.Now().UnixMilli())
created := time.Now().Unix()
var contentBuilder strings.Builder
for {
chunk, recvErr := reader.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
return contentBuilder.String(), fmt.Errorf("流式接收失败: %w", recvErr)
}
if chunk == nil {
continue
}
// 推送 reasoning_content 到前端(实时思考过程)
if chunk.ReasoningContent != "" && outChan != nil {
payload, fmtErr := chat.ToOpenAIStream(
&schema.Message{ReasoningContent: chunk.ReasoningContent},
requestID, modelName, created, false,
)
if fmtErr == nil && payload != "" {
outChan <- payload
}
}
// 累积 contenttool_calls 或 done 信号)
if chunk.Content != "" {
contentBuilder.WriteString(chunk.Content)
}
}
return strings.TrimSpace(contentBuilder.String()), nil
}

View File

@@ -0,0 +1,147 @@
package scheduleplan
import (
"context"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
)
// schedulePlanRunner 是"单次图运行"的请求级依赖容器。
//
// 设计目标:
// 1) 把节点运行所需依赖model/deps/emit/extra/history就近收口
// 2) 让 graph.go 只保留"节点连线"和"方法引用",提升可读性;
// 3) 避免在 graph.go 里重复出现内联闭包和参数透传。
type schedulePlanRunner struct {
chatModel *ark.ChatModel
deps SchedulePlanToolDeps
emitStage func(stage, detail string)
userMessage string
extra map[string]any
chatHistory []*schema.Message
// ── ReAct 精排所需 ──
outChan chan<- string // SSE 流式输出通道,用于推送 reasoning_content
modelName string // 模型名称,用于构造 OpenAI 兼容 chunk
}
// newSchedulePlanRunner 构造请求级 runner。
// 生命周期仅限一次 graph invoke不做跨请求复用。
func newSchedulePlanRunner(
chatModel *ark.ChatModel,
deps SchedulePlanToolDeps,
emitStage func(stage, detail string),
userMessage string,
extra map[string]any,
chatHistory []*schema.Message,
outChan chan<- string,
modelName string,
) *schedulePlanRunner {
return &schedulePlanRunner{
chatModel: chatModel,
deps: deps,
emitStage: emitStage,
userMessage: userMessage,
extra: extra,
chatHistory: chatHistory,
outChan: outChan,
modelName: modelName,
}
}
// ── 节点方法引用适配层 ──
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) materializeNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runMaterializeNode(ctx, st, r.chatModel, r.deps, r.emitStage)
}
func (r *schedulePlanRunner) applyNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runApplyNode(ctx, st, r.deps, r.emitStage)
}
func (r *schedulePlanRunner) reflectNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runReflectNode(ctx, st, r.chatModel, r.emitStage)
}
func (r *schedulePlanRunner) finalizeNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runFinalizeNode(ctx, st, r.chatModel, r.emitStage)
}
func (r *schedulePlanRunner) exitNode(_ context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
// exit 节点不做任何业务逻辑,仅把当前状态原样透传到 END。
return st, nil
}
// ── ReAct 精排节点适配层 ──
func (r *schedulePlanRunner) hybridBuildNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runHybridBuildNode(ctx, st, r.deps, 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) returnPreviewNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runReturnPreviewNode(ctx, st, r.emitStage)
}
// ── 分支决策适配层 ──
func (r *schedulePlanRunner) nextAfterPlan(_ context.Context, st *SchedulePlanState) (string, error) {
return selectNextAfterPlan(st), nil
}
func (r *schedulePlanRunner) nextAfterMaterialize(_ context.Context, st *SchedulePlanState) (string, error) {
// materialize 后:有 ApplyRequest 则去 apply否则去 exit。
if st == nil || st.ApplyRequest == nil || len(st.ApplyRequest.Items) == 0 {
return schedulePlanGraphNodeExit, nil
}
return schedulePlanGraphNodeApply, nil
}
func (r *schedulePlanRunner) nextAfterApply(_ context.Context, st *SchedulePlanState) (string, error) {
return selectNextAfterApply(st), nil
}
func (r *schedulePlanRunner) nextAfterReflect(_ context.Context, st *SchedulePlanState) (string, error) {
return selectNextAfterReflect(st), nil
}
// nextAfterPreview 根据 preview 结果决定下一步。
//
// 分支规则:
// 1) preview 失败(无候选方案)-> exit
// 2) HybridScheduleWithPlan 已注入 -> hybridBuild走 ReAct 精排路径)
// 3) 否则 -> materialize走原有落库路径向后兼容
func (r *schedulePlanRunner) nextAfterPreview(_ context.Context, st *SchedulePlanState) (string, error) {
if st == nil || len(st.CandidatePlans) == 0 {
return schedulePlanGraphNodeExit, nil
}
if r.deps.HybridScheduleWithPlan != nil {
return schedulePlanGraphNodeHybridBuild, nil
}
return schedulePlanGraphNodeMaterialize, nil
}
// nextAfterHybridBuild 根据 hybridBuild 结果决定下一步。
func (r *schedulePlanRunner) nextAfterHybridBuild(_ context.Context, st *SchedulePlanState) (string, error) {
if st == nil || len(st.HybridEntries) == 0 {
return schedulePlanGraphNodeExit, nil
}
return schedulePlanGraphNodeReactRefine, nil
}
// nextAfterFinalize 用于 finalize 分支——固定结束。
func (r *schedulePlanRunner) nextAfterFinalize(_ context.Context, _ *SchedulePlanState) (string, error) {
return compose.END, nil
}

View File

@@ -0,0 +1,139 @@
package scheduleplan
import (
"time"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
// schedulePlanTimezoneName 是排程链路默认业务时区。
// 与随口记保持一致,固定东八区,避免容器运行在 UTC 导致"明天/今晚"偏移。
schedulePlanTimezoneName = "Asia/Shanghai"
// schedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。
schedulePlanDatetimeLayout = "2006-01-02 15:04"
)
// SchedulePlanState 是"智能排程"链路在 graph 节点间传递的统一状态容器。
//
// 设计目标:
// 1) 收拢排程请求全生命周期的上下文降低节点间参数散<E695B0><E695A3><EFBFBD>
// 2) 支持"粗排 -> 校验 -> 修补重试 -> 落库"的完整链路追踪;
// 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。
type SchedulePlanState struct {
// ── 基础上下文 ──
TraceID string
UserID int
ConversationID string
RequestNow time.Time
RequestNowText string
// ── plan 节点输出 ──
// UserIntent 是模型对用户排程意图的结构化摘要(如"帮我安排高数复习计划")。
UserIntent string
// Constraints 是用户提出的硬约束列表(如 ["早八不排", "周末休息"])。
Constraints []string
// TaskClassID 是目标任务类 ID由 Extra 字段或模型抽取获得。
TaskClassID int
// Strategy 是排程策略steady/rapid默认 steady。
Strategy string
// ── preview 节点输出 ──
// CandidatePlans 是粗排算法生成的候选方案(展示型结构,供 SSE 推送给前端预览)。
CandidatePlans []model.UserWeekSchedule
// AllocatedItems 是粗排算法已分配的任务项EmbeddedTime 已回填),供 materialize 直接转换。
AllocatedItems []model.TaskClassItem
// ── ReAct 精排阶段 ──
// HybridEntries 是混合日程条目列表包含既有日程existing和粗排建议suggested
// ReAct 工具直接在此切片上操作(内存修改,不涉及 DB
HybridEntries []model.HybridScheduleEntry
// ReactRound 当前 ReAct 循环轮次。
ReactRound int
// ReactMaxRound 最大循环轮次(建议 3
ReactMaxRound int
// ReactSummary LLM 输出的优化摘要。
ReactSummary string
// ReactDone 标记 ReAct 是否已完成。
ReactDone bool
// ── materialize 节点输出 ──
// ApplyRequest 是转换后的落库请求体。
ApplyRequest *model.UserInsertTaskClassItemToScheduleRequestBatch
// ── apply 节点输出 ──
// Applied 标记是否落库成功。
Applied bool
// ApplyError 记录落库失败的错误信息,供 reflect 节点分析。
ApplyError string
// ── reflect 节点状态 ──
// RetryCount 记录当前重试次数。
RetryCount int
// MaxRetry 是最大重试次数(建议 = 2
MaxRetry int
// ReflectAction 记录模型给出的修补动作retry_with_patch / partial_apply / give_up
ReflectAction string
// ── 连续对话微调 ──
// PreviousPlanJSON 是上一版已落库方案的 JSON 序列化,用于增量微调。
// 从对话历史中提取,不做持久化。
PreviousPlanJSON string
// IsAdjustment 标记本次是否为微调请求(而非全新排程)。
IsAdjustment bool
// ── 最终输出 ──
// FinalSummary 是 graph 最终给用户的回复文案。
FinalSummary string
// Completed 标记整个排程链路是否成功完成。
Completed bool
}
// NewSchedulePlanState 创建排程状态对象并初始化默认值。
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),
MaxRetry: 2,
Strategy: "steady",
ReactMaxRound: 3,
}
}
// CanRetry 判断当前是否还能继续重试落库。
func (s *SchedulePlanState) CanRetry() bool {
return s.RetryCount < s.MaxRetry
}
// RecordApplyError 记录一次落库失败。
func (s *SchedulePlanState) RecordApplyError(errMsg string) {
s.RetryCount++
s.ApplyError = errMsg
}
// schedulePlanLocation 返回排程链路使用的业务时区。
func schedulePlanLocation() *time.Location {
loc, err := time.LoadLocation(schedulePlanTimezoneName)
if err != nil {
return time.Local
}
return loc
}
// schedulePlanNowToMinute 返回当前时间并截断到分钟级。
func schedulePlanNowToMinute() time.Time {
return time.Now().In(schedulePlanLocation()).Truncate(time.Minute)
}

View File

@@ -0,0 +1,76 @@
package scheduleplan
import (
"context"
"errors"
"strconv"
"github.com/LoveLosita/smartflow/backend/model"
)
// SchedulePlanToolDeps 描述"智能排程工具包"需要的外部依赖。
//
// 设计目标:
// 1) 通过函数注入把 agent 包与 service/dao 解耦,避免循环依赖;
// 2) 每个函数对应一个可独立 mock 的业务能力;
// 3) 后续可按需扩展(如局部修补、任务类自动生成等)。
type SchedulePlanToolDeps struct {
// SmartPlanningRaw 调用粗排算法,同时返回展示结构和已分配的任务项。
// 返回值:
// - []UserWeekSchedule展示型结构供 SSE 阶段推送给前端预览;
// - []TaskClassItem已分配的任务项EmbeddedTime 已回填),供 materialize 直接转换。
SmartPlanningRaw func(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
// BatchApplyPlans 将排程方案批量落库。
// 输入taskClassID、userID、落库请求体。
// 输出errornil 表示全部成功)。
BatchApplyPlans func(ctx context.Context, taskClassID, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error
// GetTaskClassByID 获取任务类详情(含关联的 Items
// 用于:
// 1) 校验 task_class_id 合法性;
// 2) 获取 Items 列表,为连续对话微调提供上下文。
GetTaskClassByID func(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error)
// HybridScheduleWithPlan 构建混合日程(既有日程 + 粗排建议),供 ReAct 精排使用。
// 可选依赖:未注入时 ReAct 精排阶段不可用,走原有 materialize 路径。
HybridScheduleWithPlan func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
}
// validate 校验依赖完整性,缺失任意一个都无法完成排程链路。
func (d SchedulePlanToolDeps) validate() error {
if d.SmartPlanningRaw == nil {
return errors.New("schedule plan tool deps: SmartPlanningRaw is nil")
}
if d.BatchApplyPlans == nil {
return errors.New("schedule plan tool deps: BatchApplyPlans is nil")
}
if d.GetTaskClassByID == nil {
return errors.New("schedule plan tool deps: GetTaskClassByID is nil")
}
return nil
}
// ExtraInt 从 extra map 中安全提取整数值。
//
// 兼容策略:
// 1) JSON 数字默认解析为 float64做 int 转换;
// 2) 兼容字符串形式(如 "42"),用 Atoi 解析;
// 3) 其余类型返回 false由调用方决定后续处理。
func ExtraInt(extra map[string]any, key string) (int, bool) {
v, ok := extra[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case string:
i, err := strconv.Atoi(n)
return i, err == nil
default:
return 0, false
}
}

View File

@@ -0,0 +1,339 @@
package scheduleplan
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
)
// ── ReAct Tool 调用/结果结构 ──
// reactToolCall 是 LLM 输出的单个工具调用。
type reactToolCall struct {
Tool string `json:"tool"`
Params map[string]any `json:"params"`
}
// reactToolResult 是单个工具调用的执行结果。
type reactToolResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Result string `json:"result"`
}
// reactLLMOutput 是 LLM 输出的完整 JSON 结构。
type reactLLMOutput struct {
Done bool `json:"done"`
Summary string `json:"summary"`
ToolCalls []reactToolCall `json:"tool_calls"`
}
// ── 工具分发器 ──
// dispatchReactTool 根据工具名分发调用返回可能修改后的entries 和执行结果。
func dispatchReactTool(entries []model.HybridScheduleEntry, call reactToolCall) ([]model.HybridScheduleEntry, reactToolResult) {
switch call.Tool {
case "Swap":
return reactToolSwap(entries, call.Params)
case "Move":
return reactToolMove(entries, call.Params)
case "TimeAvailable":
return entries, reactToolTimeAvailable(entries, call.Params)
case "GetAvailableSlots":
return entries, reactToolGetAvailableSlots(entries, call.Params)
default:
return entries, reactToolResult{Tool: call.Tool, Success: false, Result: fmt.Sprintf("未知工具: %s", call.Tool)}
}
}
// ── 参数提取辅助 ──
func paramInt(params map[string]any, key string) (int, bool) {
v, ok := params[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
default:
return 0, false
}
}
// findSuggestedByID 在 entries 中查找指定 TaskItemID 的 suggested 条目索引。
func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int {
for i, e := range entries {
if e.TaskItemID == taskItemID && e.Status == "suggested" {
return i
}
}
return -1
}
// sectionsOverlap 判断两个节次区间是否有交集。
func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool {
return aFrom <= bTo && bFrom <= aTo
}
// 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
}
if e.Week == week && e.DayOfWeek == day && sectionsOverlap(e.SectionFrom, e.SectionTo, sf, st) {
return true, fmt.Sprintf("%s(%s)", e.Name, e.Type)
}
}
return false, ""
}
// ══════════════════════════════════════════════════════════════
// Tool 1: Swap — 交换两个 suggested 任务的时间
// ══════════════════════════════════════════════════════════════
func reactToolSwap(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) {
idA, okA := paramInt(params, "task_a")
idB, okB := paramInt(params, "task_b")
if !okA || !okB {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:需要 task_a 和 task_btask_item_id"}
}
if idA == idB {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 和 task_b 不能相同"}
}
idxA := findSuggestedByID(entries, idA)
idxB := findSuggestedByID(entries, idB)
if idxA == -1 {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idA)}
}
if idxB == -1 {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idB)}
}
// 交换时间坐标
a, b := &entries[idxA], &entries[idxB]
a.Week, b.Week = b.Week, a.Week
a.DayOfWeek, b.DayOfWeek = b.DayOfWeek, a.DayOfWeek
a.SectionFrom, b.SectionFrom = b.SectionFrom, a.SectionFrom
a.SectionTo, b.SectionTo = b.SectionTo, a.SectionTo
return entries, reactToolResult{
Tool: "Swap", Success: true,
Result: fmt.Sprintf("已交换 [%s](id=%d) 和 [%s](id=%d) 的时间", a.Name, idA, b.Name, idB),
}
}
// ══════════════════════════════════════════════════════════════
// Tool 2: Move — 将一个 suggested 任务移动到新时间
// ══════════════════════════════════════════════════════════════
func reactToolMove(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) {
taskID, ok := paramInt(params, "task_item_id")
if !ok {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 task_item_id"}
}
toWeek, ok1 := paramInt(params, "to_week")
toDay, ok2 := paramInt(params, "to_day")
toSF, ok3 := paramInt(params, "to_section_from")
toST, ok4 := paramInt(params, "to_section_to")
if !ok1 || !ok2 || !ok3 || !ok4 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week, to_day, to_section_from, to_section_to"}
}
// 基础校验
if toDay < 1 || toDay > 7 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 不合法,应为 1-7", toDay)}
}
if toSF < 1 || toST > 12 || toSF > toST {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次范围 %d-%d 不合法,应为 1-12 且 from<=to", toSF, toST)}
}
idx := findSuggestedByID(entries, taskID)
if idx == -1 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", taskID)}
}
// 节次跨度必须一致
origSpan := entries[idx].SectionTo - entries[idx].SectionFrom
newSpan := toST - toSF
if origSpan != newSpan {
return entries, reactToolResult{Tool: "Move", Success: false,
Result: fmt.Sprintf("节次跨度不一致:原任务占 %d 节,目标占 %d 节", origSpan+1, newSpan+1)}
}
// 冲突检测(排除自身)
if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, idx); conflict {
return entries, reactToolResult{Tool: "Move", Success: false,
Result: fmt.Sprintf("目标时间 W%dD%d 第%d-%d节 已被 %s 占用", toWeek, toDay, toSF, toST, name)}
}
// 执行移动
e := &entries[idx]
oldDesc := fmt.Sprintf("W%dD%d 第%d-%d节", e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo)
e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo = toWeek, toDay, toSF, toST
newDesc := fmt.Sprintf("W%dD%d 第%d-%d节", toWeek, toDay, toSF, toST)
return entries, reactToolResult{
Tool: "Move", Success: true,
Result: fmt.Sprintf("已将 [%s](id=%d) 从 %s 移动到 %s", e.Name, taskID, oldDesc, newDesc),
}
}
// ══════════════════════════════════════════════════════════════
// Tool 3: TimeAvailable — 检查目标时间段是否可用
// ══════════════════════════════════════════════════════════════
func reactToolTimeAvailable(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult {
week, ok1 := paramInt(params, "week")
day, ok2 := paramInt(params, "day_of_week")
sf, ok3 := paramInt(params, "section_from")
st, ok4 := paramInt(params, "section_to")
if !ok1 || !ok2 || !ok3 || !ok4 {
return reactToolResult{Tool: "TimeAvailable", Success: false, Result: "参数缺失:需要 week, day_of_week, section_from, section_to"}
}
if conflict, name := hasConflict(entries, week, day, sf, st, -1); conflict {
return reactToolResult{Tool: "TimeAvailable", Success: true,
Result: fmt.Sprintf(`{"available":false,"conflict_with":"%s"}`, name)}
}
return reactToolResult{Tool: "TimeAvailable", Success: true, Result: `{"available":true}`}
}
// ══════════════════════════════════════════════════════════════
// Tool 4: GetAvailableSlots — 返回可用时间段列表
// ══════════════════════════════════════════════════════════════
func reactToolGetAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult {
filterWeek, _ := paramInt(params, "week") // 0 表示不过滤
// 1. 收集所有周次范围
minW, maxW := 999, 0
for _, e := range entries {
if e.Week < minW {
minW = e.Week
}
if e.Week > maxW {
maxW = e.Week
}
}
if minW > maxW {
return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: "[]"}
}
// 2. 构建占用集合
type slotKey struct{ W, D, S int }
occupied := make(map[slotKey]bool)
for _, e := range entries {
for s := e.SectionFrom; s <= e.SectionTo; s++ {
occupied[slotKey{e.Week, e.DayOfWeek, s}] = true
}
}
// 3. 遍历所有时间格,找出空闲并合并连续节次
type availSlot struct {
Week, Day, From, To int
}
var slots []availSlot
startW, endW := minW, maxW
if filterWeek > 0 {
startW, endW = filterWeek, filterWeek
}
for w := startW; w <= endW; w++ {
for d := 1; d <= 7; d++ {
runStart := 0
for s := 1; s <= 12; s++ {
if !occupied[slotKey{w, d, s}] {
if runStart == 0 {
runStart = s
}
} else {
if runStart > 0 {
slots = append(slots, availSlot{w, d, runStart, s - 1})
runStart = 0
}
}
}
if runStart > 0 {
slots = append(slots, availSlot{w, d, runStart, 12})
}
}
}
// 4. 按自然顺序排序(已经是了,但确保)
sort.Slice(slots, func(i, j int) bool {
if slots[i].Week != slots[j].Week {
return slots[i].Week < slots[j].Week
}
if slots[i].Day != slots[j].Day {
return slots[i].Day < slots[j].Day
}
return slots[i].From < slots[j].From
})
// 5. 序列化
type slotJSON struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
}
out := make([]slotJSON, 0, len(slots))
for _, s := range slots {
out = append(out, slotJSON{s.Week, s.Day, s.From, s.To})
}
data, _ := json.Marshal(out)
return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: string(data)}
}
// ── 辅助:解析 LLM 输出 ──
// parseReactLLMOutput 解析 LLM 的 JSON 输出。
// 兼容 ```json ... ``` 包裹。
func parseReactLLMOutput(raw string) (*reactLLMOutput, error) {
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, fmt.Errorf("LLM 输出为空")
}
// 兼容 markdown 包裹
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
var out reactLLMOutput
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("无法从 LLM 输出中提取 JSON: %s", truncate(clean, 200))
}
obj := clean[start : end+1]
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %w", err)
}
return &out, nil
}
// truncate 截断字符串到指定长度。
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View File

@@ -57,7 +57,7 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
c.Writer.Header().Set("X-Conversation-ID", conversationID)
userID := c.GetInt("user_id")
outChan, errChan := api.svc.AgentChat(c.Request.Context(), req.Message, req.Thinking, req.Model, userID, conversationID)
outChan, errChan := api.svc.AgentChat(c.Request.Context(), req.Message, req.Thinking, req.Model, userID, conversationID, req.Extra)
// 4) 转发 SSE 流
c.Stream(func(w io.Writer) bool {

View File

@@ -54,7 +54,7 @@ func (sa *CourseHandler) AddUserCourses(c *gin.Context) {
userIDInterface := c.GetInt("user_id")
//3.调用 service 层的 AddUserCoursesIntoSchedule 方法添加课程
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel() // 记得释放资源
conflicts, err := sa.service.AddUserCourses(ctx, req, userIDInterface)
if err != nil {

View File

@@ -98,7 +98,7 @@ func Start() {
courseService := service.NewCourseService(courseRepo, scheduleRepo)
taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager)
scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo)
agentService := service.NewAgentService(aiHub, agentRepo, taskRepo, agentCacheRepo, eventBus)
agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, agentCacheRepo, eventBus, scheduleService, taskClassService)
// API 层初始化。
userApi := api.NewUserHandler(userService)

View File

@@ -174,10 +174,20 @@ func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskCla
if err != nil {
return nil, err
}
//3.把这些时间通过DTO函数回填到涉周的 UserWeekSchedule 结构中,供前端展示
//3.把这些时间通过DTO函数回填到涉<EFBFBD><EFBFBD>周的 UserWeekSchedule 结构中,供前端展示
return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil
}
// SmartPlanningRawItems 执行粗排算法并直接返回已分配的任务项列表。
//
// 与 SmartPlanningMainLogic 共享完全相同的构建网格和分配逻辑,
// 但不做展示格式转换,直接返回 allocatedItems每项的 EmbeddedTime 已回填)。
// 供 Agent 排程链路使用,避免从展示结构反向解析导致信息丢失。
func SmartPlanningRawItems(schedules []model.Schedule, taskClass *model.TaskClass) ([]model.TaskClassItem, error) {
g := buildTimeGrid(schedules, taskClass)
return computeAllocation(g, taskClass.Items, *taskClass.Strategy)
}
// buildTimeGrid 构建一个时间格子,标记出哪些时间段被占用、哪些被屏蔽、哪些是水课
func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid {

View File

@@ -3,10 +3,11 @@ package model
import "time"
type UserSendMessageRequest struct {
ConversationID string `json:"conversation_id,omitempty"`
Message string `json:"message" binding:"required"`
Model string `json:"model,omitempty"`
Thinking bool `json:"thinking,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
Message string `json:"message" binding:"required"`
Model string `json:"model,omitempty"`
Thinking bool `json:"thinking,omitempty"`
Extra map[string]any `json:"extra,omitempty"` // 附加参数(如 task_class_id供 agent 分支链路使用
}
// ChatHistoryPersistPayload 是“聊天消息持久化请求”业务 DTO。

View File

@@ -118,6 +118,27 @@ type OngoingSchedule struct {
EndTime time.Time `json:"end_time"`
}
// HybridScheduleEntry 表示"混合日程"中的一个时间块。
//
// 设计目标:
// 将既有日程(课程/已落库任务)与粗排建议的任务统一到同一结构中,
// 供 ReAct 精排引擎在内存中操作。
//
// Status 语义:
// - "existing"已确定的日程LLM 不可移动;
// - "suggested"粗排建议的任务LLM 可通过 Tool 调整时间。
type HybridScheduleEntry struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
Name string `json:"name"`
Type string `json:"type"` // "course" | "task"
Status string `json:"status"` // "existing" | "suggested"
TaskItemID int `json:"task_item_id,omitempty"` // 仅 suggested 的 task 有值
EventID int `json:"event_id,omitempty"` // 仅 existing 有值
}
func (ScheduleEvent) TableName() string { return "schedule_events" }
func (Schedule) TableName() string { return "schedules" }

View File

@@ -1,9 +1,12 @@
package service
import (
"context"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
"github.com/LoveLosita/smartflow/backend/inits"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/service/agentsvc"
)
@@ -14,9 +17,43 @@ import (
type AgentService = agentsvc.AgentService
// NewAgentService 是迁移期兼容构造函数。
//
// 说明:
// 1) 外部调用签名保持不变;
// 1) 外部调用签名不变,新增排程依赖通过可选方式注入(见 NewAgentServiceWithSchedule
// 2) 真实构造逻辑已下沉到 service/agentsvc 包。
func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService {
return agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher)
}
// NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。
//
// 设计目的:
// 1) 通过函数注入避免 agentsvc 包直接依赖 service 层的 ScheduleService / TaskClassService
// 2) 排程依赖为可选:未注入时排程路由自动回退到普通聊天;
// 3) 保持 NewAgentService 签名不变,向下兼容。
func NewAgentServiceWithSchedule(
aiHub *inits.AIHub,
repo *dao.AgentDAO,
taskRepo *dao.TaskDAO,
agentRedis *dao.AgentCache,
eventPublisher outboxinfra.EventPublisher,
scheduleSvc *ScheduleService,
taskClassSvc *TaskClassService,
) *AgentService {
svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher)
// 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。
if scheduleSvc != nil {
svc.SmartPlanningRawFunc = scheduleSvc.SmartPlanningRaw
svc.HybridScheduleWithPlanFunc = scheduleSvc.HybridScheduleWithPlan
}
if taskClassSvc != nil {
svc.BatchApplyPlansFunc = taskClassSvc.BatchApplyPlans
// GetTaskClassByID 复用 TaskClassService 内部的 DAO 调用。
svc.GetTaskClassByIDFunc = func(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) {
return taskClassSvc.GetCompleteTaskClassByID(ctx, taskClassID, userID)
}
}
return svc
}

View File

@@ -26,6 +26,21 @@ type AgentService struct {
taskRepo *dao.TaskDAO
agentCache *dao.AgentCache
eventPublisher outboxinfra.EventPublisher
// ── 排程计划依赖(函数注入,避免 service 包循环依赖)──
// SmartPlanningRawFunc 调用粗排算法,同时返回展示结构和已分配的任务项。
// 由 service/agent_bridge.go 在构造时注入 ScheduleService.SmartPlanningRaw。
SmartPlanningRawFunc func(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
// BatchApplyPlansFunc 将排程方案批量落库。
// 由 service/agent_bridge.go 在构造时注入 TaskClassService.BatchApplyPlans。
BatchApplyPlansFunc func(ctx context.Context, taskClassID, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error
// GetTaskClassByIDFunc 获取任务类详情(含 Items
// 由 service/agent_bridge.go 在构造时注入。
GetTaskClassByIDFunc func(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error)
// HybridScheduleWithPlanFunc 构建混合日程(既有日程 + 粗排建议),供 ReAct 精排使用。
// 由 service/agent_bridge.go 在构造时注入。可选:未注入时走原有 materialize 路径。
HybridScheduleWithPlanFunc func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
}
// NewAgentService 构造 AgentService。
@@ -233,7 +248,7 @@ func (s *AgentService) runNormalChatFlow(
s.ensureConversationTitleAsync(userID, chatID)
}
func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string) (<-chan string, <-chan error) {
func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string, extra map[string]any) (<-chan string, <-chan error) {
requestStart := time.Now()
traceID := uuid.NewString()
@@ -366,7 +381,27 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
return
}
// 3.6 未知 action 兜底:走普通聊天,保证可用性
// 3.6 schedule_plan执行智能排程 graph
if routing.Action == route.ActionSchedulePlan {
reply, planErr := s.runSchedulePlanFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, extra, progress.Emit, outChan, resolvedModelName)
if planErr != nil {
log.Printf("智能排程 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, planErr)
progress.Emit("schedule_plan.fallback", "智能排程暂不可用,先切回普通对话。")
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
return
}
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, 0, requestTotalTokens, errChan)
s.ensureConversationTitleAsync(userID, chatID)
return
}
// 3.7 未知 action 兜底:走普通聊天,保证可用性。
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
}()

View File

@@ -0,0 +1,101 @@
package agentsvc
import (
"context"
"errors"
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
)
// runSchedulePlanFlow 执行"智能排程"分支。
//
// 职责边界:
// 1. 负责把本次请求接入 scheduleplan 执行器;
// 2. 负责注入排程依赖SmartPlanning / BatchApplyPlans / GetTaskClassByID
// 3. 负责对话历史获取,支持连续对话微调;
// 4. 不负责聊天持久化(由 AgentChat 主流程统一收口)。
func (s *AgentService) runSchedulePlanFlow(
ctx context.Context,
selectedModel *ark.ChatModel,
userMessage string,
userID int,
chatID string,
traceID string,
extra map[string]any,
emitStage func(stage, detail string),
outChan chan<- string,
modelName string,
) (string, error) {
// 1. 依赖预检:排程依赖函数必须注入,否则无法完成排程链路。
if s.SmartPlanningRawFunc == nil || s.BatchApplyPlansFunc == nil || s.GetTaskClassByIDFunc == 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。
var chatHistory []*schema.Message
if s.agentCache != nil {
history, err := s.agentCache.GetHistory(ctx, chatID)
if err != nil {
log.Printf("获取排程对话历史失败 chat_id=%s: %v", chatID, err)
} else if history != nil {
chatHistory = history
}
}
// 2.1 缓存未命中时回源 DB。
if chatHistory == nil && s.repo != nil {
histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), chatID)
if hisErr != nil {
log.Printf("回源 DB 获取排程对话历史失败 chat_id=%s: %v", chatID, hisErr)
} else {
chatHistory = conv.ToEinoMessages(histories)
}
}
// 3. 初始化排程状态对象。
state := scheduleplan.NewSchedulePlanState(traceID, userID, chatID)
// 4. 构建依赖注入并执行 graph。
finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{
Model: selectedModel,
State: state,
Deps: scheduleplan.SchedulePlanToolDeps{
SmartPlanningRaw: s.SmartPlanningRawFunc,
BatchApplyPlans: s.BatchApplyPlansFunc,
GetTaskClassByID: s.GetTaskClassByIDFunc,
HybridScheduleWithPlan: s.HybridScheduleWithPlanFunc,
},
UserMessage: userMessage,
Extra: extra,
ChatHistory: chatHistory,
EmitStage: emitStage,
OutChan: outChan,
ModelName: modelName,
})
if runErr != nil {
return "", runErr
}
// 5. 提取最终回复。
if finalState == nil {
return "排程流程异常,请稍后重试。", nil
}
reply := strings.TrimSpace(finalState.FinalSummary)
if reply == "" {
reply = "排程流程已完成,但未生成结果摘要。"
}
return reply, nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/conv"
@@ -407,3 +408,162 @@ func (ss *ScheduleService) SmartPlanning(ctx context.Context, userID, taskClassI
//5.将推荐的时间安排转换为前端需要的格式返回
return result, nil
}
// SmartPlanningRaw 执行粗排算法并同时返回展示结构和已分配的任务项。
//
// 职责边界:
// 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)
if err != nil {
return nil, nil, err
}
if taskClass == nil {
return nil, nil, respond.WrongTaskClassID
}
if *taskClass.Mode != "auto" {
return nil, nil, respond.TaskClassModeNotAuto
}
// 2. 获取时间范围内的全部日程。
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(ctx, userID, conv.CalculateFirstDayOfWeek(*taskClass.StartDate), conv.CalculateLastDayOfWeek(*taskClass.EndDate))
if err != nil {
return nil, nil, err
}
// 3. 执行粗排算法,拿到已分配的 itemsEmbeddedTime 已回填)。
allocatedItems, err := logic.SmartPlanningRawItems(schedules, taskClass)
if err != nil {
return nil, nil, err
}
// 4. 同时生成展示结构,供 SSE 阶段推送给前端预览。
displayResult := conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems)
return displayResult, allocatedItems, nil
}
// HybridScheduleWithPlan 构建"混合日程":将既有日程与粗排建议合并为统一结构。
//
// 职责边界:
// 1) 获取 TaskClass 时间范围内的既有日程(课程 + 已落库任务);
// 2) 调用粗排算法获取建议分配;
// 3) 将两者合并为 []HybridScheduleEntry供 ReAct 精排引擎在内存中操作。
//
// 返回值:
// - entries混合日程条目existing + suggested
// - allocatedItems粗排已分配的任务项用于后续落库
// - error
func (ss *ScheduleService) HybridScheduleWithPlan(
ctx context.Context, userID, taskClassID int,
) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) {
// 1. 获取任务类详情。
taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID)
if err != nil {
return nil, nil, err
}
if taskClass == nil {
return nil, nil, respond.WrongTaskClassID
}
if *taskClass.Mode != "auto" {
return nil, nil, respond.TaskClassModeNotAuto
}
// 2. 获取时间范围内的既有日程。
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(
ctx, userID,
conv.CalculateFirstDayOfWeek(*taskClass.StartDate),
conv.CalculateLastDayOfWeek(*taskClass.EndDate),
)
if err != nil {
return nil, nil, err
}
// 3. 执行粗排算法。
allocatedItems, err := logic.SmartPlanningRawItems(schedules, taskClass)
if err != nil {
return nil, nil, err
}
// 4. 合并为 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
}
type eventGroup struct {
Key eventGroupKey
Name string
Type string
Sections []int
}
groupMap := make(map[eventGroupKey]*eventGroup)
for _, s := range schedules {
key := eventGroupKey{EventID: s.EventID, Week: s.Week, DayOfWeek: s.DayOfWeek}
g, ok := groupMap[key]
if !ok {
name := "未知"
typ := "course"
if s.Event != nil {
name = s.Event.Name
typ = s.Event.Type
}
g = &eventGroup{Key: key, Name: name, Type: typ}
groupMap[key] = g
}
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,
})
}
// 4.2 粗排建议:每个已分配的 TaskClassItem 转为一条 suggested 条目。
for _, item := range allocatedItems {
if item.EmbeddedTime == nil {
continue
}
name := "未命名任务"
if item.Content != nil && strings.TrimSpace(*item.Content) != "" {
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,
})
}
return entries, allocatedItems, nil
}

View File

@@ -315,6 +315,15 @@ func (sv *TaskClassService) DeleteTaskClass(ctx context.Context, userID int, tas
return nil
}
// GetCompleteTaskClassByID 获取任务类完整详情(含关联的 TaskClassItem 列表)。
//
// 职责边界:
// 1) 直接委托 DAO 层查询,不做额外业务逻辑;
// 2) 主要供 Agent 排程链路使用,获取 Items 用于 materialize 节点映射。
func (sv *TaskClassService) GetCompleteTaskClassByID(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) {
return sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID)
}
func (sv *TaskClassService) BatchApplyPlans(ctx context.Context, taskClassID int, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error {
//1.通过任务类id获取任务类详情
taskClass, err := sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID)

View File

@@ -0,0 +1,150 @@
# 智能排程 Agent — ReAct 精排引擎 决策记录
## 1. 基本信息
- 记录编号FDR-008
- 功能名称:智能排程 ReAct 精排引擎(阶段 1.5:粗排 + AI 语义化微调)
- 记录日期2026-03-19
- 决策状态:已采纳,开发中
- 负责人SmartFlow 团队
- 关联需求FDR-007智能排程 Agent 阶段 1
## 2. 背景与问题
- 业务背景:阶段 1 已打通"意图识别 → 粗排 → 落库"全链路但粗排算法是纯规则的线性分配cursor-based不考虑科目特性、学习效率曲线、上下文切换成本等语义因素。
- 现状问题:
1. 粗排结果机械化:高认知负荷科目可能被安排在低效时段(如晚间安排数学);
2. 缺乏科目间协调:同类任务可能被分散到不连贯的时间段,增加上下文切换成本;
3. 用户无法感知 AI 的"思考过程",排程结果缺乏可解释性。
- 不做此决策的后果:排程质量停留在"能用但不好用"阶段,无法体现 AI 的语义理解能力。
## 3. 决策目标
- 目标 1在粗排之后引入 LLM 精排环节,通过 ReAct 范式对任务时间进行语义化优化。
- 目标 2精排过程中 LLM 的深度思考reasoning实时流式推送到前端用户可见。
- 目标 3精排结果仅作为预览返回不自动落库用户确认后再持久化。
- 目标 4向后兼容——未注入精排依赖时自动走原有 materialize 路径。
- 非目标:
- 本阶段不做用户确认后的落库链路(后续阶段);
- 本阶段不做 RAG 规则注入(阶段 3
- 本阶段不做多方案对比(只输出一个优化后的方案)。
## 4. 备选方案
### 方案 A后处理脚本规则引擎
- 描述:在粗排之后用硬编码规则(如"数学只排上午")做二次调整。
- 优点:确定性强,无 LLM 调用开销。
- 缺点:规则维护成本高,无法处理复杂的多科目协调;不可解释。
- 复杂度 / 成本:低,但扩展性极差。
### 方案 BReAct 范式 + 手动 Tool 调用(采纳)
- 描述LLM 开启深度思考,分析粗排结果后通过 ToolSwap/Move/TimeAvailable/GetAvailableSlots在内存中调整任务时间多轮循环直到满意。
- 优点:
1. 充分利用 LLM 的语义理解能力,优化维度丰富;
2. reasoning_content 实时推送,用户可见思考过程;
3. Tool 操作内存数据,天然支持预览模式(不落库);
4. 手动 ReAct 循环给予完全的流式控制权。
- 缺点:依赖 LLM 输出质量;深度思考模式耗时较长。
- 复杂度 / 成本:中高,约 1 周。
### 方案 CEino 内置 ToolsNode
- 描述:使用 Eino 框架的 ToolsNode + function_calling 原生能力。
- 优点:框架原生支持,代码量少。
- 缺点:无法在 Tool 执行过程中流式推送 reasoning_content无法精细控制每轮 SSE 输出;项目中无现有 function_calling 基础设施。
- 复杂度 / 成本:中,但灵活性不足。
## 5. 最终决策
- 采纳方案:方案 BReAct 范式 + 手动 Tool 调用)
- 关键理由:
1. 手动 ReAct 循环可以精确控制 SSE 流式输出reasoning + stage + tool_call 穿插);
2. Tool 操作纯内存数据,预览模式零风险;
3. 与现有 graph 架构无缝集成(新增 3 个节点,不破坏原有链路)。
## 6. 技术方案
### 6.1 新流程graph 结构)
```
plan → preview(粗排) → hybridBuild(混合日程) → reactRefine(ReAct循环) → returnPreview → END
↑ |
└────────────────────────┘ (tool失败重试最多N轮)
```
`HybridScheduleWithPlan` 依赖未注入时preview 后自动走原有 materialize → apply 路径。
### 6.2 混合日程HybridScheduleEntry
将既有日程existing和粗排建议suggested统一到同一结构
- `existing` + `course/task`:不可移动
- `suggested` + `task`LLM 可通过 Tool 调整
### 6.3 ReAct Tool 设计
| Tool | 功能 | 操作对象 |
|------|------|---------|
| Swap | 交换两个 suggested 任务的时间 | 内存 HybridEntries |
| Move | 移动一个 suggested 任务到新时间 | 内存 HybridEntries |
| TimeAvailable | 检查目标时间是否可用 | 只读查询 |
| GetAvailableSlots | 返回可用时间段列表 | 只读查询 |
### 6.4 SSE 推送设计
- `schedule_plan.hybrid.building/done` — 混合日程构建阶段
- `schedule_plan.react.round` — 第 N 轮优化开始
- `reasoning_content` 流式 chunk — LLM 深度思考过程(实时推送)
- `schedule_plan.react.tool_call` — Tool 执行结果
- `schedule_plan.react.done` — 优化完成
- `schedule_plan.preview_return.done` — 预览生成完成
## 7. 影响范围
- 新增文件:
- `backend/agent/scheduleplan/tools_react.go`4 个 Tool + dispatcher + LLM 输出解析
- `backend/agent/scheduleplan/react.go`ReAct 循环核心 + 流式推送
- 修改文件:
- `backend/model/schedule.go`+HybridScheduleEntry
- `backend/agent/scheduleplan/state.go`+ReAct 字段
- `backend/agent/scheduleplan/prompt.go`+ReAct system prompt
- `backend/agent/scheduleplan/nodes.go`+hybridBuild/returnPreview 节点
- `backend/agent/scheduleplan/runner.go`+outChan/modelName/新节点适配
- `backend/agent/scheduleplan/graph.go`+3 节点/重新连线
- `backend/agent/scheduleplan/tool.go`+HybridScheduleWithPlan 依赖
- `backend/service/schedule.go`+HybridScheduleWithPlan 方法
- `backend/service/agent_bridge.go`+注入新依赖
- `backend/service/agentsvc/agent.go`+字段/传参
- `backend/service/agentsvc/agent_schedule_plan.go`+outChan/modelName/新依赖
- 数据与存储影响:无。所有 Tool 操作纯内存,不涉及 DB。
- 接口 / 协议影响无新增接口。SSE 新增 react 相关阶段推送(向下兼容)。
## 8. 已知问题与后续优化
### 8.1 深度思考超时(当前)
- 现象:模型开启深度思考后 reasoning 阶段耗时较长,当前 5 分钟超时仍可能不够。
- 影响:超时后使用粗排结果,精排未生效。
- 后续方案:
- [ ] 调整超时策略(按模型实际耗时动态设置,或改为不设超时由父 context 控制)
- [ ] 优化 prompt引导模型减少冗余推理
- [ ] 评估是否关闭深度思考,改用普通模式 + 多轮调用换取稳定性
### 8.2 模型输出质量
- 现象:模型思考过程较啰嗦,可能输出无效的 tool 调用。
- 后续方案:
- [ ] 精炼 ReAct system prompt加入 few-shot 示例
- [ ] 对 tool_calls 做预校验,过滤明显无效的调用
- [ ] 收集真实案例建立评测集
### 8.3 用户确认落库链路
- 现象:当前精排结果仅预览,用户确认后的落库链路尚未实现。
- 后续方案:
- [ ] 新增"确认落库"接口或对话指令
- [ ] 复用现有 materialize → apply 路径,从 HybridEntries 转换
### 8.4 连续对话微调
- 现象:精排后的连续对话微调(如"把数学挪到上午")尚未与 ReAct 引擎打通。
- 后续方案:
- [ ] 将上一轮 HybridEntries 序列化到对话历史
- [ ] 支持增量 ReAct只调整用户指定的部分
## 9. 验证与回滚
- 验证方式:
1. `go build ./...` + `go vet ./...` 编译通过
2. 发送排程请求,验证 SSE 流中出现 react 阶段推送和 reasoning_content
3. 验证不落库:数据库 schedules 表无新增记录
4. 向后兼容:不注入 HybridScheduleWithPlan 时走原有 materialize 路径
- 回滚方案:在 `agent_bridge.go` 中注释掉 `HybridScheduleWithPlanFunc` 注入即可preview 后自动回退到 materialize 路径。
## 10. 复盘结论(上线后补充)
- 实际效果:待补充
- 与预期偏差:待补充
- 后续是否需要二次决策:待补充

View File

@@ -63,6 +63,33 @@
- 应对:严格 JSON Schema 校验,失败直接走默认修补/人工规则。
- 回滚:关闭 `ENABLE_SCHEDULE_PLAN_AGENT`,回退到原接口链路。
### 4.6 总流程规划
```
任务目标:实现一个基于 ReAct 范式的智能排程微调引擎,将“粗排结果”与“既有日程”混合,并通过 AI 进行语义化优化。
1.需要新建的前置函数:
(1)HybridScheduleWithPlan从数据库中提取和排程时间范围相同的日程放在sv/schedules.go里面通过回调来作为一个节点然后调用(如果你有更好的结构建议,欢迎告诉我)
2.需要新建的tool(直接改State)
(1)Swap:LLM传入带交换两个任务的相对时间(从json中获取第x周第x-x节),这个工具会自动寻找并交换时间(通过修改Schedule结构体内部数据实现的),找不到就报错。
(2)Move:同上,传入一个任务的相对时间(第x周第x-x节)直接寻找并修改State中的Schedule中的时间。
注意,上述(1)和(2)都必须带合法性检验。
(3)timeAvailable:检测目标时间在当前日程中是否可用,用于服务(2)。
(4)GetAvailableSlots:反馈给AI(json格式)可用时间的列表用于让AI选择挪动过去的时间。
3.基本流程如下:
(1)获取用户智能排程意图提取task_class_id调用SmartPlanning进行粗排然后再通过上面的前置函数(1)将日程和已经安排好的任务混合并传入State。
(2)LLM启动深度思考(必须开深度思考)告诉它上述工具及其作用让它自由选择调用。prompt你自己写差不多就是
考虑不同科目的"上下文切换成本"某科目更加适合学习的时间段以及人一天的学习效率曲线等因素修改上述json中status为suggested且type为task的任务最终形成无论从复习效果还是学习体感上来看都十分科学合理的学习方案。
(3)此时模型开启深度思考推送reasoning stream到前端和既定的状态chunk穿插。
(4)在思考中,模型一次看好改动逻辑(这就是为啥要开深度思考的原因,逻辑有点绕)然后思考结束出结果后一次性调用这些tool。
注意这里有备选方案如果模型逻辑不够那就一次只调一次tool多调用几次llm这样用时间换正确率。
(4.1)若调用成功,则直接返回排程结果到前端(禁止落库用户得看效果再决定是否正式落库而正式落库用不着agent)
(4.2)若失败则把失败原因返回LLMLLM再看情况自己思考并重试。
```
---
## 阶段 2从“我想复习概率论”自动生成任务类并接入阶段 1