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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,4 +23,6 @@ backend/config.yaml
|
||||
.vscode/
|
||||
.DS_Store # Mac 用户必加
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
.gomodcache/
|
||||
.claude/
|
||||
.omc/
|
||||
@@ -5,6 +5,7 @@
|
||||
1. 默认语言规则:所有注释、接口文案、说明、评审反馈均使用中文。
|
||||
2. 请勤加注释,尤其是复杂逻辑部分,确保代码易于理解和维护。
|
||||
3. 每次在本地执行测试命令(如 `go test`)后,必须清理项目根目录下的 `.gocache` 目录,避免缓存文件长期堆积。
|
||||
4. 文件编码统一使用 UTF-8(无 BOM),禁止使用 GBK、GB2312 等其他编码,避免中文内容出现乱码。
|
||||
|
||||
## 注释规范(强制)
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
243
backend/agent/scheduleplan/graph.go
Normal file
243
backend/agent/scheduleplan/graph.go
Normal 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)
|
||||
}
|
||||
817
backend/agent/scheduleplan/nodes.go
Normal file
817
backend/agent/scheduleplan/nodes.go
Normal 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 非空 -> exit(plan 阶段已确定无法继续)
|
||||
// 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
|
||||
}
|
||||
175
backend/agent/scheduleplan/prompt.go
Normal file
175
backend/agent/scheduleplan/prompt.go
Normal 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_a(task_item_id),task_b(task_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 条目。`
|
||||
)
|
||||
209
backend/agent/scheduleplan/react.go
Normal file
209
backend/agent/scheduleplan/react.go
Normal 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. 构造 messages(system 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
|
||||
}
|
||||
}
|
||||
|
||||
// 累积 content(tool_calls 或 done 信号)
|
||||
if chunk.Content != "" {
|
||||
contentBuilder.WriteString(chunk.Content)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(contentBuilder.String()), nil
|
||||
}
|
||||
147
backend/agent/scheduleplan/runner.go
Normal file
147
backend/agent/scheduleplan/runner.go
Normal 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
|
||||
}
|
||||
139
backend/agent/scheduleplan/state.go
Normal file
139
backend/agent/scheduleplan/state.go
Normal 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)
|
||||
}
|
||||
76
backend/agent/scheduleplan/tool.go
Normal file
76
backend/agent/scheduleplan/tool.go
Normal 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、落库请求体。
|
||||
// 输出:error(nil 表示全部成功)。
|
||||
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
|
||||
}
|
||||
}
|
||||
339
backend/agent/scheduleplan/tools_react.go
Normal file
339
backend/agent/scheduleplan/tools_react.go
Normal 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_b(task_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] + "..."
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
|
||||
|
||||
101
backend/service/agentsvc/agent_schedule_plan.go
Normal file
101
backend/service/agentsvc/agent_schedule_plan.go
Normal 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
|
||||
}
|
||||
@@ -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. 执行粗排算法,拿到已分配的 items(EmbeddedTime 已回填)。
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
150
docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md
Normal file
150
docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md
Normal 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 调用开销。
|
||||
- 缺点:规则维护成本高,无法处理复杂的多科目协调;不可解释。
|
||||
- 复杂度 / 成本:低,但扩展性极差。
|
||||
|
||||
### 方案 B:ReAct 范式 + 手动 Tool 调用(采纳)
|
||||
- 描述:LLM 开启深度思考,分析粗排结果后通过 Tool(Swap/Move/TimeAvailable/GetAvailableSlots)在内存中调整任务时间,多轮循环直到满意。
|
||||
- 优点:
|
||||
1. 充分利用 LLM 的语义理解能力,优化维度丰富;
|
||||
2. reasoning_content 实时推送,用户可见思考过程;
|
||||
3. Tool 操作内存数据,天然支持预览模式(不落库);
|
||||
4. 手动 ReAct 循环给予完全的流式控制权。
|
||||
- 缺点:依赖 LLM 输出质量;深度思考模式耗时较长。
|
||||
- 复杂度 / 成本:中高,约 1 周。
|
||||
|
||||
### 方案 C:Eino 内置 ToolsNode
|
||||
- 描述:使用 Eino 框架的 ToolsNode + function_calling 原生能力。
|
||||
- 优点:框架原生支持,代码量少。
|
||||
- 缺点:无法在 Tool 执行过程中流式推送 reasoning_content;无法精细控制每轮 SSE 输出;项目中无现有 function_calling 基础设施。
|
||||
- 复杂度 / 成本:中,但灵活性不足。
|
||||
|
||||
## 5. 最终决策
|
||||
- 采纳方案:方案 B(ReAct 范式 + 手动 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. 复盘结论(上线后补充)
|
||||
- 实际效果:待补充
|
||||
- 与预期偏差:待补充
|
||||
- 后续是否需要二次决策:待补充
|
||||
@@ -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)若失败,则把失败原因返回LLM,LLM再看情况自己思考并重试。
|
||||
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 阶段 2:从“我想复习概率论”自动生成任务类,并接入阶段 1
|
||||
|
||||
Reference in New Issue
Block a user