From 73ab0f43aa4ee7bfe2d9662737256360146186d8 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Tue, 21 Apr 2026 20:10:16 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.34.dev.260421=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20=E6=97=A7=20Agent=20=E7=AE=A1?= =?UTF-8?q?=E7=BA=BF=EF=BC=88agent/=EF=BC=89=E5=85=A8=E9=9D=A2=E4=B8=8B?= =?UTF-8?q?=E7=BA=BF=EF=BC=8C=E5=85=B1=E4=BA=AB=E9=80=BB=E8=BE=91=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=87=B3=20newAgent/=20-=20=E5=88=A0=E9=99=A4=20backe?= =?UTF-8?q?nd/agent/=20=E6=95=B4=E4=B8=AA=E7=9B=AE=E5=BD=95=EF=BC=8844=20?= =?UTF-8?q?=E4=B8=AA=20Go=20=E6=96=87=E4=BB=B6=EF=BC=89=EF=BC=8C5=20?= =?UTF-8?q?=E6=9D=A1=E6=97=A7=E4=B8=93=E7=94=A8=E6=B5=81=E7=A8=8B=E5=B7=B2?= =?UTF-8?q?=E7=94=B1=20newAgent=20=E7=BB=9F=E4=B8=80=20graph=20=E5=8F=96?= =?UTF-8?q?=E4=BB=A3=20-=20=E5=85=B1=E4=BA=AB=E9=80=BB=E8=BE=91=E8=BF=81?= =?UTF-8?q?=E5=85=A5=20newAgent/=EF=BC=9Aclone=EF=BC=88shared/clone.go?= =?UTF-8?q?=EF=BC=89=E3=80=81=E6=97=B6=E9=97=B4=E8=A7=A3=E6=9E=90=EF=BC=88?= =?UTF-8?q?shared/deadline.go=EF=BC=89=E3=80=81=E4=BC=98=E5=85=88=E7=BA=A7?= =?UTF-8?q?=E5=B8=B8=E9=87=8F=EF=BC=88shared/task=5Fpriority.go=EF=BC=89?= =?UTF-8?q?=E3=80=81TaskQuery=20=E7=B1=BB=E5=9E=8B=EF=BC=88model/taskquery?= =?UTF-8?q?=5Ftypes.go=EF=BC=89=E3=80=81SystemPrompt=EF=BC=88prompt/system?= =?UTF-8?q?.go=EF=BC=89=E3=80=81Usage=20=E5=90=88=E5=B9=B6=EF=BC=88stream/?= =?UTF-8?q?usage.go=EF=BC=89=202.=20service=20=E5=B1=82=E6=B8=85=E9=99=A4?= =?UTF-8?q?=20agent/=20=E5=85=A8=E9=83=A8=E4=BE=9D=E8=B5=96=20-=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=204=20=E4=B8=AA=E6=97=A7=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=96=87=E4=BB=B6=EF=BC=88agent=5Froute=20/?= =?UTF-8?q?=20agent=5Fquick=5Fnote=20/=20agent=5Fschedule=5Fplan=20/=20age?= =?UTF-8?q?nt=5Fschedule=5Frefine=EF=BC=89=20-=20agent=5Ftask=5Fquery.go?= =?UTF-8?q?=20=E5=88=A0=E9=99=A4=20runTaskQueryFlow=EF=BC=8C=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E7=B1=BB=E5=9E=8B=E5=88=87=E5=88=B0=20newagentmodel?= =?UTF-8?q?=20-=20agent.go=20/=20agent=5Fnewagent.go=20/=20agent=5Fschedul?= =?UTF-8?q?e=5Fpreview.go=20/=20agent=5Fschedule=5Fstate.go=20/=20cmd/star?= =?UTF-8?q?t.go=20/=20quicknote.go=EF=BC=9Aagent*=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=9B=BF=E6=8D=A2=E4=B8=BA=20newagent*=203.?= =?UTF-8?q?=20=E6=B5=81=E5=BC=8F=E9=99=8D=E7=BA=A7=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=86=85=E8=81=94=E5=88=B0=20service=20?= =?UTF-8?q?=E5=B1=82=EF=BC=88agent=5Fstream=5Ffallback.go=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E6=9C=80=E5=90=8E=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=20agent/chat=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: 1. ScheduleFineTuneModal 幂等键追加 classId 后缀,修复多任务类并行保存 key 重复 --- backend/agent/entrance.go | 41 - backend/agent/graph/quicknote.go | 122 - backend/agent/graph/schedule.go | 202 - backend/agent/graph/taskquery.go | 126 - backend/agent/llm/ark.go | 83 - backend/agent/llm/client.go | 216 - backend/agent/llm/json.go | 112 - backend/agent/llm/quicknote.go | 170 - backend/agent/llm/route.go | 50 - backend/agent/llm/schedule.go | 175 - backend/agent/llm/schedule_refine.go | 132 - backend/agent/llm/taskquery.go | 83 - backend/agent/model/common.go | 17 - backend/agent/model/quicknote.go | 102 - backend/agent/model/route.go | 20 - backend/agent/model/schedule.go | 200 - backend/agent/model/schedule_refine.go | 344 -- backend/agent/model/taskquery.go | 87 - backend/agent/node/quicknote.go | 504 --- backend/agent/node/quicknote_tool.go | 589 --- backend/agent/node/schedule_plan.go | 2336 ----------- backend/agent/node/schedule_plan_tool.go | 571 --- backend/agent/node/schedule_refine.go | 3526 ----------------- backend/agent/node/schedule_refine_tool.go | 2027 ---------- backend/agent/node/taskquery.go | 729 ---- backend/agent/node/taskquery_tool.go | 286 -- backend/agent/node/tool_common.go | 74 - backend/agent/prompt/quicknote.go | 46 - backend/agent/prompt/route.go | 24 - backend/agent/prompt/schedule.go | 172 - backend/agent/prompt/schedule_refine.go | 188 - backend/agent/prompt/taskquery.go | 79 - backend/agent/router/action_route.go | 272 -- backend/agent/router/route.go | 67 - backend/agent/router/route_model.go | 34 - backend/agent/shared/retry.go | 85 - backend/agent/shared/time.go | 49 - backend/agent/stream/emitter.go | 115 - backend/agent/stream/openai.go | 102 - backend/agent/通用能力接入文档.md | 176 - backend/cmd/start.go | 4 +- backend/newAgent/HANDOFF_优化待办.md | 54 + backend/newAgent/model/taskquery_types.go | 26 + .../prompt.go => newAgent/prompt/system.go} | 6 +- backend/{agent => newAgent}/shared/clone.go | 17 +- backend/newAgent/shared/deadline.go | 366 ++ .../shared}/task_priority.go | 20 +- backend/newAgent/stream/usage.go | 41 + backend/newAgent/tools/quicknote.go | 17 +- backend/service/agentsvc/agent.go | 6 +- backend/service/agentsvc/agent_newagent.go | 4 +- backend/service/agentsvc/agent_quick_note.go | 303 -- backend/service/agentsvc/agent_route.go | 27 - .../service/agentsvc/agent_schedule_plan.go | 162 - .../agentsvc/agent_schedule_preview.go | 115 +- .../service/agentsvc/agent_schedule_refine.go | 175 - .../service/agentsvc/agent_schedule_state.go | 5 +- .../agentsvc/agent_stream_fallback.go} | 92 +- backend/service/agentsvc/agent_task_query.go | 46 +- .../assistant/ScheduleFineTuneModal.vue | 2 +- 60 files changed, 560 insertions(+), 15261 deletions(-) delete mode 100644 backend/agent/entrance.go delete mode 100644 backend/agent/graph/quicknote.go delete mode 100644 backend/agent/graph/schedule.go delete mode 100644 backend/agent/graph/taskquery.go delete mode 100644 backend/agent/llm/ark.go delete mode 100644 backend/agent/llm/client.go delete mode 100644 backend/agent/llm/json.go delete mode 100644 backend/agent/llm/quicknote.go delete mode 100644 backend/agent/llm/route.go delete mode 100644 backend/agent/llm/schedule.go delete mode 100644 backend/agent/llm/schedule_refine.go delete mode 100644 backend/agent/llm/taskquery.go delete mode 100644 backend/agent/model/common.go delete mode 100644 backend/agent/model/quicknote.go delete mode 100644 backend/agent/model/route.go delete mode 100644 backend/agent/model/schedule.go delete mode 100644 backend/agent/model/schedule_refine.go delete mode 100644 backend/agent/model/taskquery.go delete mode 100644 backend/agent/node/quicknote.go delete mode 100644 backend/agent/node/quicknote_tool.go delete mode 100644 backend/agent/node/schedule_plan.go delete mode 100644 backend/agent/node/schedule_plan_tool.go delete mode 100644 backend/agent/node/schedule_refine.go delete mode 100644 backend/agent/node/schedule_refine_tool.go delete mode 100644 backend/agent/node/taskquery.go delete mode 100644 backend/agent/node/taskquery_tool.go delete mode 100644 backend/agent/node/tool_common.go delete mode 100644 backend/agent/prompt/quicknote.go delete mode 100644 backend/agent/prompt/route.go delete mode 100644 backend/agent/prompt/schedule.go delete mode 100644 backend/agent/prompt/schedule_refine.go delete mode 100644 backend/agent/prompt/taskquery.go delete mode 100644 backend/agent/router/action_route.go delete mode 100644 backend/agent/router/route.go delete mode 100644 backend/agent/router/route_model.go delete mode 100644 backend/agent/shared/retry.go delete mode 100644 backend/agent/shared/time.go delete mode 100644 backend/agent/stream/emitter.go delete mode 100644 backend/agent/stream/openai.go delete mode 100644 backend/agent/通用能力接入文档.md create mode 100644 backend/newAgent/HANDOFF_优化待办.md create mode 100644 backend/newAgent/model/taskquery_types.go rename backend/{agent/chat/prompt.go => newAgent/prompt/system.go} (69%) rename backend/{agent => newAgent}/shared/clone.go (66%) create mode 100644 backend/newAgent/shared/deadline.go rename backend/{agent/model => newAgent/shared}/task_priority.go (57%) create mode 100644 backend/newAgent/stream/usage.go delete mode 100644 backend/service/agentsvc/agent_quick_note.go delete mode 100644 backend/service/agentsvc/agent_route.go delete mode 100644 backend/service/agentsvc/agent_schedule_plan.go delete mode 100644 backend/service/agentsvc/agent_schedule_refine.go rename backend/{agent/chat/stream.go => service/agentsvc/agent_stream_fallback.go} (54%) diff --git a/backend/agent/entrance.go b/backend/agent/entrance.go deleted file mode 100644 index 165a8e3..0000000 --- a/backend/agent/entrance.go +++ /dev/null @@ -1,41 +0,0 @@ -package agent - -import ( - "context" - "errors" - - agentrouter "github.com/LoveLosita/smartflow/backend/agent/router" -) - -// Service 是 agent 模块的总入口。 -// -// 职责边界: -// 1. 负责接住一次完整的 Agent 请求,并把请求交给统一路由器分流; -// 2. 负责维护“路由器 + 各 skill handler”的装配关系; -// 3. 不负责具体 skill 的 graph 连线,也不负责节点内部业务实现。 -type Service struct { - dispatcher *agentrouter.Dispatcher -} - -// NewService 创建 agent 总入口服务。 -func NewService(resolver agentrouter.Resolver) *Service { - return &Service{ - dispatcher: agentrouter.NewDispatcher(resolver), - } -} - -// RegisterHandler 注册某个 skill 的执行入口。 -func (s *Service) RegisterHandler(action agentrouter.Action, handler agentrouter.SkillHandler) error { - if s == nil || s.dispatcher == nil { - return errors.New("agent service is not initialized") - } - return s.dispatcher.Register(action, handler) -} - -// Handle 是 agent 的统一处理入口。 -func (s *Service) Handle(ctx context.Context, req *agentrouter.AgentRequest) (*agentrouter.AgentResponse, error) { - if s == nil || s.dispatcher == nil { - return nil, errors.New("agent service is not initialized") - } - return s.dispatcher.Dispatch(ctx, req) -} diff --git a/backend/agent/graph/quicknote.go b/backend/agent/graph/quicknote.go deleted file mode 100644 index fbef54f..0000000 --- a/backend/agent/graph/quicknote.go +++ /dev/null @@ -1,122 +0,0 @@ -package agentgraph - -import ( - "context" - "errors" - "strings" - - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" - agentshared "github.com/LoveLosita/smartflow/backend/agent/shared" - "github.com/cloudwego/eino/compose" -) - -const ( - // QuickNoteGraphName 是随口记图编排的稳定标识。 - // - // 职责边界: - // 1. 仅用于 graph 编译和链路标识,方便日志与排障统一定位。 - // 2. 不参与意图判断,也不承载任务写库的业务语义。 - QuickNoteGraphName = "quick_note" -) - -// RunQuickNoteGraph 负责执行“随口记 -> 判断 -> 提取 -> 落库 -> 收口”的整条图链路。 -// -// 职责边界: -// 1. 负责输入兜底、工具装配、节点注册与 graph 运行。 -// 2. 不负责每个节点的具体业务决策,节点内部逻辑由 node 层实现。 -// 3. 返回的 state 表示整条链路的最终状态,供上层继续拼接响应或写日志。 -func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInput) (*agentmodel.QuickNoteState, error) { - // 1. 先校验最基础依赖,避免图已经启动后才发现模型或状态为空。 - if input.Model == nil { - return nil, errors.New("quick note graph: model is nil") - } - if input.State == nil { - return nil, errors.New("quick note graph: state is nil") - } - if err := input.Deps.Validate(); err != nil { - return nil, err - } - - // 2. 补齐当前请求时间,保证后续提示词、时间解析和落库字段都基于同一时刻。 - if input.State.RequestNow.IsZero() { - input.State.RequestNow = agentshared.NowToMinute() - } - if strings.TrimSpace(input.State.RequestNowText) == "" { - input.State.RequestNowText = agentshared.FormatMinute(input.State.RequestNow) - } - - // 3. 图运行前统一准备工具与节点容器,避免节点内部重复做依赖解析。 - toolBundle, err := agentnode.BuildQuickNoteToolBundle(ctx, input.Deps) - if err != nil { - return nil, err - } - createTaskTool, err := agentnode.GetInvokableToolByName(toolBundle, agentnode.ToolNameQuickNoteCreateTask) - if err != nil { - return nil, err - } - - nodes, err := agentnode.NewQuickNoteNodes(input, createTaskTool) - if err != nil { - return nil, err - } - - // 4. 主链路保持“意图识别 -> 优先级评估 -> 持久化 -> 退出”,中间通过 branch 决定是否提前结束或重试写库。 - graph := compose.NewGraph[*agentmodel.QuickNoteState, *agentmodel.QuickNoteState]() - if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeIntent, compose.InvokableLambda(nodes.Intent)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeRank, compose.InvokableLambda(nodes.Priority)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodePersist, compose.InvokableLambda(nodes.Persist)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeExit, compose.InvokableLambda(nodes.Exit)); err != nil { - return nil, err - } - - if err = graph.AddEdge(compose.START, agentnode.QuickNoteGraphNodeIntent); err != nil { - return nil, err - } - if err = graph.AddBranch(agentnode.QuickNoteGraphNodeIntent, compose.NewGraphBranch( - nodes.NextAfterIntent, - map[string]bool{ - agentnode.QuickNoteGraphNodeRank: true, - agentnode.QuickNoteGraphNodeExit: true, - }, - )); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.QuickNoteGraphNodeExit, compose.END); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.QuickNoteGraphNodeRank, agentnode.QuickNoteGraphNodePersist); err != nil { - return nil, err - } - if err = graph.AddBranch(agentnode.QuickNoteGraphNodePersist, compose.NewGraphBranch( - nodes.NextAfterPersist, - map[string]bool{ - agentnode.QuickNoteGraphNodePersist: true, - compose.END: true, - }, - )); err != nil { - return nil, err - } - - // 5. persist 节点允许有限次重试,因此最大步数要覆盖首次执行与重试回路。 - maxSteps := input.State.MaxToolRetry + 10 - if maxSteps < 12 { - maxSteps = 12 - } - - runnable, err := graph.Compile(ctx, - compose.WithGraphName(QuickNoteGraphName), - compose.WithMaxRunSteps(maxSteps), - compose.WithNodeTriggerMode(compose.AnyPredecessor), - ) - if err != nil { - return nil, err - } - return runnable.Invoke(ctx, input.State) -} diff --git a/backend/agent/graph/schedule.go b/backend/agent/graph/schedule.go deleted file mode 100644 index bbaea19..0000000 --- a/backend/agent/graph/schedule.go +++ /dev/null @@ -1,202 +0,0 @@ -package agentgraph - -import ( - "context" - "errors" - - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" - "github.com/cloudwego/eino/compose" -) - -const ( - SchedulePlanGraphName = "schedule_plan" - ScheduleRefineGraphName = "schedule_refine" -) - -func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraphRunInput) (*agentmodel.SchedulePlanState, error) { - 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 - } - - if input.DailyRefineConcurrency > 0 { - input.State.DailyRefineConcurrency = input.DailyRefineConcurrency - } - if input.WeeklyAdjustBudget > 0 { - input.State.WeeklyAdjustBudget = input.WeeklyAdjustBudget - } - - nodes, err := agentnode.NewSchedulePlanNodes(input) - if err != nil { - return nil, err - } - - graph := compose.NewGraph[*agentmodel.SchedulePlanState, *agentmodel.SchedulePlanState]() - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeRoughBuild, compose.InvokableLambda(nodes.RoughBuild)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeExit, compose.InvokableLambda(nodes.Exit)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeDailySplit, compose.InvokableLambda(nodes.DailySplit)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeQuickRefine, compose.InvokableLambda(nodes.QuickRefine)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeDailyRefine, compose.InvokableLambda(nodes.DailyRefine)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeMerge, compose.InvokableLambda(nodes.Merge)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeWeeklyRefine, compose.InvokableLambda(nodes.WeeklyRefine)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeFinalCheck, compose.InvokableLambda(nodes.FinalCheck)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeReturnPreview, compose.InvokableLambda(nodes.ReturnPreview)); err != nil { - return nil, err - } - - if err = graph.AddEdge(compose.START, agentnode.SchedulePlanGraphNodePlan); err != nil { - return nil, err - } - if err = graph.AddBranch(agentnode.SchedulePlanGraphNodePlan, compose.NewGraphBranch( - nodes.NextAfterPlan, - map[string]bool{ - agentnode.SchedulePlanGraphNodeRoughBuild: true, - agentnode.SchedulePlanGraphNodeExit: true, - }, - )); err != nil { - return nil, err - } - if err = graph.AddBranch(agentnode.SchedulePlanGraphNodeRoughBuild, compose.NewGraphBranch( - nodes.NextAfterRoughBuild, - map[string]bool{ - agentnode.SchedulePlanGraphNodeDailySplit: true, - agentnode.SchedulePlanGraphNodeQuickRefine: true, - agentnode.SchedulePlanGraphNodeWeeklyRefine: true, - agentnode.SchedulePlanGraphNodeExit: true, - }, - )); err != nil { - return nil, err - } - - if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeQuickRefine, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeDailySplit, agentnode.SchedulePlanGraphNodeDailyRefine); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeDailyRefine, agentnode.SchedulePlanGraphNodeMerge); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeMerge, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeWeeklyRefine, agentnode.SchedulePlanGraphNodeFinalCheck); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeFinalCheck, agentnode.SchedulePlanGraphNodeReturnPreview); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeReturnPreview, compose.END); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeExit, compose.END); err != nil { - return nil, err - } - - runnable, err := graph.Compile(ctx, - compose.WithGraphName(SchedulePlanGraphName), - compose.WithMaxRunSteps(20), - compose.WithNodeTriggerMode(compose.AnyPredecessor), - ) - if err != nil { - return nil, err - } - return runnable.Invoke(ctx, input.State) -} - -func RunScheduleRefineGraph(ctx context.Context, input agentnode.ScheduleRefineGraphRunInput) (*agentnode.ScheduleRefineState, error) { - if input.Model == nil { - return nil, errors.New("schedule refine graph: model is nil") - } - if input.State == nil { - return nil, errors.New("schedule refine graph: state is nil") - } - - nodes, err := agentnode.NewScheduleRefineNodes(input) - if err != nil { - return nil, err - } - - graph := compose.NewGraph[*agentmodel.ScheduleRefineState, *agentmodel.ScheduleRefineState]() - if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeContract, compose.InvokableLambda(nodes.Contract)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeSlice, compose.InvokableLambda(nodes.Slice)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeRoute, compose.InvokableLambda(nodes.Route)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeReact, compose.InvokableLambda(nodes.React)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeHardCheck, compose.InvokableLambda(nodes.HardCheck)); err != nil { - return nil, err - } - if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeSummary, compose.InvokableLambda(nodes.Summary)); err != nil { - return nil, err - } - - if err = graph.AddEdge(compose.START, agentnode.ScheduleRefineGraphNodeContract); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeContract, agentnode.ScheduleRefineGraphNodePlan); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodePlan, agentnode.ScheduleRefineGraphNodeSlice); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeSlice, agentnode.ScheduleRefineGraphNodeRoute); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeRoute, agentnode.ScheduleRefineGraphNodeReact); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeReact, agentnode.ScheduleRefineGraphNodeHardCheck); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeHardCheck, agentnode.ScheduleRefineGraphNodeSummary); err != nil { - return nil, err - } - if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeSummary, compose.END); err != nil { - return nil, err - } - - runnable, err := graph.Compile(ctx, - compose.WithGraphName(ScheduleRefineGraphName), - compose.WithMaxRunSteps(20), - compose.WithNodeTriggerMode(compose.AnyPredecessor), - ) - if err != nil { - return nil, err - } - return runnable.Invoke(ctx, input.State) -} diff --git a/backend/agent/graph/taskquery.go b/backend/agent/graph/taskquery.go deleted file mode 100644 index 0b511b9..0000000 --- a/backend/agent/graph/taskquery.go +++ /dev/null @@ -1,126 +0,0 @@ -package agentgraph - -import ( - "context" - "errors" - "strings" - "time" - - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" - "github.com/cloudwego/eino/compose" -) - -const ( - // TaskQueryGraphName 是任务查询图编排的稳定标识。 - // - // 职责边界: - // 1. 仅用于 graph 编译、日志和排障时标识当前链路。 - // 2. 不承载路由判断,也不负责描述具体业务含义。 - TaskQueryGraphName = "task_query" -) - -// RunTaskQueryGraph 负责串起任务查询图,并返回最终给用户的回复文本。 -// -// 职责边界: -// 1. 负责做图运行前的依赖校验、默认值补齐、节点装配与 graph 编译执行。 -// 2. 不负责实现单个节点的业务细节,这些逻辑由 node 层承接。 -// 3. 返回值中的 string 是最终可直接透传给上层的回复;error 仅表示链路级失败。 -func RunTaskQueryGraph(ctx context.Context, input agentnode.TaskQueryGraphRunInput) (string, error) { - // 1. 先拦住空模型、空状态和依赖缺失,避免 graph 运行到一半才出现不可恢复错误。 - if input.Model == nil { - return "", errors.New("task query graph: model is nil") - } - if input.State == nil { - return "", errors.New("task query graph: state is nil") - } - if err := input.Deps.Validate(); err != nil { - return "", err - } - - // 2. 请求时间缺失时补齐当前时间,保证后续时间锚定与提示词上下文稳定。 - if strings.TrimSpace(input.State.RequestNowText) == "" { - input.State.RequestNowText = time.Now().In(time.Local).Format("2006-01-02 15:04") - } - - // 3. 先准备工具,再构造节点容器;这样 graph 中每个节点都能拿到已校验好的依赖。 - toolBundle, err := agentnode.BuildTaskQueryToolBundle(ctx, input.Deps) - if err != nil { - return "", err - } - queryTool, err := agentnode.GetTaskQueryInvokableToolByName(toolBundle, agentnode.ToolNameTaskQueryTasks) - if err != nil { - return "", err - } - nodes, err := agentnode.NewTaskQueryNodes(input, queryTool) - if err != nil { - return "", err - } - - // 4. 注册节点与边,保持“计划 -> 归一化 -> 时间锚定 -> 查询 -> 反思”的单向主链。 - graph := compose.NewGraph[*agentmodel.TaskQueryState, *agentmodel.TaskQueryState]() - if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil { - return "", err - } - if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeQuadrant, compose.InvokableLambda(nodes.NormalizeQuadrant)); err != nil { - return "", err - } - if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeTimeAnchor, compose.InvokableLambda(nodes.AnchorTime)); err != nil { - return "", err - } - if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeQuery, compose.InvokableLambda(nodes.Query)); err != nil { - return "", err - } - if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeReflect, compose.InvokableLambda(nodes.Reflect)); err != nil { - return "", err - } - if err = graph.AddEdge(compose.START, agentnode.TaskQueryGraphNodePlan); err != nil { - return "", err - } - if err = graph.AddEdge(agentnode.TaskQueryGraphNodePlan, agentnode.TaskQueryGraphNodeQuadrant); err != nil { - return "", err - } - if err = graph.AddEdge(agentnode.TaskQueryGraphNodeQuadrant, agentnode.TaskQueryGraphNodeTimeAnchor); err != nil { - return "", err - } - if err = graph.AddEdge(agentnode.TaskQueryGraphNodeTimeAnchor, agentnode.TaskQueryGraphNodeQuery); err != nil { - return "", err - } - if err = graph.AddEdge(agentnode.TaskQueryGraphNodeQuery, agentnode.TaskQueryGraphNodeReflect); err != nil { - return "", err - } - if err = graph.AddBranch(agentnode.TaskQueryGraphNodeReflect, compose.NewGraphBranch(nodes.NextAfterReflect, map[string]bool{ - agentnode.TaskQueryGraphNodeQuery: true, - compose.END: true, - })); err != nil { - return "", err - } - - // 5. 反思节点支持按配置重试,因此最大步数需要覆盖“首次查询 + 多轮回看”的上限。 - maxSteps := 24 + input.State.MaxReflectRetry*4 - if maxSteps < 24 { - maxSteps = 24 - } - runnable, err := graph.Compile(ctx, - compose.WithGraphName(TaskQueryGraphName), - compose.WithMaxRunSteps(maxSteps), - compose.WithNodeTriggerMode(compose.AnyPredecessor), - ) - if err != nil { - return "", err - } - finalState, err := runnable.Invoke(ctx, input.State) - if err != nil { - return "", err - } - if finalState == nil { - return "", errors.New("task query graph: final state is nil") - } - - // 6. 最终回复为空时给一个稳定兜底,避免上层拿到空字符串后再次拼接出异常文案。 - reply := strings.TrimSpace(finalState.FinalReply) - if reply == "" { - reply = "我这边暂时没整理出稳定结果,你可以换一个更具体的筛选条件再试一次。" - } - return reply, nil -} diff --git a/backend/agent/llm/ark.go b/backend/agent/llm/ark.go deleted file mode 100644 index 5db0cc5..0000000 --- a/backend/agent/llm/ark.go +++ /dev/null @@ -1,83 +0,0 @@ -package agentllm - -import ( - "context" - "errors" - "strings" - - "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" -) - -// ArkCallOptions 是基于 ark.ChatModel 的通用调用选项。 -// -// 设计目的: -// 1. 当前 route / quicknote 都还直接持有 *ark.ChatModel; -// 2. 在它们完全收敛到更抽象的 Client 前,先把重复的 ark 调用样板抽成公共层; -// 3. 这样本轮就能先删除 route/quicknote 里那几份重复的 Generate 样板代码。 -type ArkCallOptions struct { - Temperature float64 - MaxTokens int - Thinking ThinkingMode -} - -// CallArkText 调用 ark 模型并返回纯文本。 -// -// 职责边界: -// 1. 负责拼 system + user 两段消息; -// 2. 负责统一配置 thinking / temperature / maxTokens; -// 3. 负责拦截空响应; -// 4. 不负责 JSON 解析,不负责业务字段校验。 -func CallArkText(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (string, error) { - if chatModel == nil { - return "", errors.New("ark model is nil") - } - - messages := []*schema.Message{ - schema.SystemMessage(systemPrompt), - schema.UserMessage(userPrompt), - } - resp, err := chatModel.Generate(ctx, messages, buildArkOptions(options)...) - if err != nil { - return "", err - } - if resp == nil { - return "", errors.New("模型返回为空") - } - - text := strings.TrimSpace(resp.Content) - if text == "" { - return "", errors.New("模型返回内容为空") - } - return text, nil -} - -// CallArkJSON 调用 ark 模型并直接解析 JSON。 -func CallArkJSON[T any](ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (*T, string, error) { - raw, err := CallArkText(ctx, chatModel, systemPrompt, userPrompt, options) - if err != nil { - return nil, "", err - } - parsed, err := ParseJSONObject[T](raw) - if err != nil { - return nil, raw, err - } - return parsed, raw, nil -} - -func buildArkOptions(options ArkCallOptions) []einoModel.Option { - thinkingType := arkModel.ThinkingTypeDisabled - if options.Thinking == ThinkingModeEnabled { - thinkingType = arkModel.ThinkingTypeEnabled - } - opts := []einoModel.Option{ - ark.WithThinking(&arkModel.Thinking{Type: thinkingType}), - einoModel.WithTemperature(float32(options.Temperature)), - } - if options.MaxTokens > 0 { - opts = append(opts, einoModel.WithMaxTokens(options.MaxTokens)) - } - return opts -} diff --git a/backend/agent/llm/client.go b/backend/agent/llm/client.go deleted file mode 100644 index 0c5daa0..0000000 --- a/backend/agent/llm/client.go +++ /dev/null @@ -1,216 +0,0 @@ -package agentllm - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/cloudwego/eino/schema" -) - -// ThinkingMode 描述本次模型调用对 thinking 的期望。 -// -// 职责边界: -// 1. 这里只表达“调用方希望怎样配置推理模式”; -// 2. 不直接绑定某个具体模型厂商的参数枚举; -// 3. 真正如何把它翻译成 ark / OpenAI / 其他 provider 的 option,由后续适配层负责。 -type ThinkingMode string - -const ( - ThinkingModeDefault ThinkingMode = "default" - ThinkingModeEnabled ThinkingMode = "enabled" - ThinkingModeDisabled ThinkingMode = "disabled" -) - -// GenerateOptions 是 Agent 内部统一的模型调用选项。 -// -// 设计目的: -// 1. 先把“每个 skill 都会反复传的参数”收敛成一份结构; -// 2. 让 node 层以后只表达“我要什么”,不再自己重复组织 option; -// 3. 暂时不追求覆盖所有 provider 参数,先把最常用的几个公共位抽出来。 -type GenerateOptions struct { - Temperature float64 - MaxTokens int - Thinking ThinkingMode - Metadata map[string]any -} - -// TextResult 是统一文本生成结果。 -// -// 职责边界: -// 1. Text 保存模型最终返回的纯文本; -// 2. Usage 保存本次调用的 token 使用量,供后续统一统计; -// 3. 不负责 JSON 解析,不负责业务字段映射。 -type TextResult struct { - Text string - Usage *schema.TokenUsage -} - -// StreamReader 抽象了“可逐块 Recv 的流式返回器”。 -// -// 之所以不直接依赖某个具体 SDK 的 reader 类型,是因为 Agent 现在还在建骨架阶段, -// 后续接 ark、OpenAI 兼容层还是别的 provider,都可以往这个最小接口上适配。 -type StreamReader interface { - Recv() (*schema.Message, error) - Close() error -} - -// TextGenerateFunc 是文本生成的统一适配函数签名。 -type TextGenerateFunc func(ctx context.Context, messages []*schema.Message, options GenerateOptions) (*TextResult, error) - -// StreamGenerateFunc 是流式生成的统一适配函数签名。 -type StreamGenerateFunc func(ctx context.Context, messages []*schema.Message, options GenerateOptions) (StreamReader, error) - -// Client 是 Agent 里的统一模型客户端门面。 -// -// 职责边界: -// 1. 负责把 node 层的“模型调用意图”收敛到统一入口; -// 2. 负责统一参数校验、空响应防御、GenerateJSON 复用; -// 3. 不负责写 prompt,不负责业务 fallback,也不直接持有具体厂商 SDK 细节。 -type Client struct { - generateText TextGenerateFunc - streamText StreamGenerateFunc -} - -// NewClient 创建统一模型客户端。 -func NewClient(generateText TextGenerateFunc, streamText StreamGenerateFunc) *Client { - return &Client{ - generateText: generateText, - streamText: streamText, - } -} - -// GenerateText 执行一次统一文本生成。 -// -// 职责边界: -// 1. 负责做最小必要的入参校验; -// 2. 负责统一拦截“模型空响应”这类公共问题; -// 3. 不负责业务 prompt 拼接,也不负责把文本再映射成业务结构。 -func (c *Client) GenerateText(ctx context.Context, messages []*schema.Message, options GenerateOptions) (*TextResult, error) { - if c == nil || c.generateText == nil { - return nil, errors.New("agent llm client is not ready") - } - if len(messages) == 0 { - return nil, errors.New("llm messages is empty") - } - - result, err := c.generateText(ctx, messages, options) - if err != nil { - return nil, err - } - if result == nil { - return nil, errors.New("llm result is nil") - } - if strings.TrimSpace(result.Text) == "" { - return nil, errors.New("llm returned empty text") - } - return result, nil -} - -// GenerateJSON 先走统一文本生成,再走统一 JSON 解析。 -// -// 设计说明: -// 1. 旧 agent 里每个 skill 都各自写了一份“Generate -> 提取 JSON -> 反序列化”; -// 2. 这里先把这一整段收敛成公共链路,后续 quicknote/taskquery/schedule 都直接复用; -// 3. 返回 parsed + rawResult,方便上层既能拿结构化字段,也能在打点/回退时保留原文。 -// 4. 这里做成泛型函数而不是方法,是因为 Go 不支持“方法自带类型参数”。 -func GenerateJSON[T any](ctx context.Context, client *Client, messages []*schema.Message, options GenerateOptions) (*T, *TextResult, error) { - result, err := client.GenerateText(ctx, messages, options) - if err != nil { - return nil, nil, err - } - - parsed, err := ParseJSONObject[T](result.Text) - if err != nil { - return nil, result, err - } - return parsed, result, nil -} - -// Stream 打开统一流式调用入口。 -// -// 职责边界: -// 1. 只负责把“流式生成能力”暴露给上层; -// 2. 不负责 chunk 到 OpenAI 协议的转换,那部分应放在 stream/; -// 3. 不负责累计全文,也不负责 token 统计落库。 -func (c *Client) Stream(ctx context.Context, messages []*schema.Message, options GenerateOptions) (StreamReader, error) { - if c == nil || c.streamText == nil { - return nil, errors.New("agent llm stream client is not ready") - } - if len(messages) == 0 { - return nil, errors.New("llm messages is empty") - } - return c.streamText(ctx, messages, options) -} - -// BuildSystemUserMessages 构造最常见的“system + history + user”消息列表。 -// -// 设计说明: -// 1. 这是旧 agent 中高频重复片段,几乎每个 skill 都会拼一次; -// 2. 这里先把最稳定的消息编排方式沉淀下来,减少 node 层样板代码; -// 3. 只做消息切片装配,不做 prompt 生成。 -func BuildSystemUserMessages(systemPrompt string, history []*schema.Message, userPrompt string) []*schema.Message { - messages := make([]*schema.Message, 0, len(history)+2) - if strings.TrimSpace(systemPrompt) != "" { - messages = append(messages, schema.SystemMessage(systemPrompt)) - } - if len(history) > 0 { - messages = append(messages, history...) - } - if strings.TrimSpace(userPrompt) != "" { - messages = append(messages, schema.UserMessage(userPrompt)) - } - return messages -} - -// CloneUsage 深拷贝 token usage,避免后续多处累加时共享同一指针。 -func CloneUsage(usage *schema.TokenUsage) *schema.TokenUsage { - if usage == nil { - return nil - } - copied := *usage - return &copied -} - -// MergeUsage 合并两段 usage。 -// -// 合并策略: -// 1. 对“同一次调用不同流分片”的场景,取更大值作为最终值; -// 2. 对“多次独立调用累计”的场景,应由上层显式做加法,而不是用这个函数; -// 3. 该函数只适用于“同一次调用的分块 usage 收敛”。 -func MergeUsage(base *schema.TokenUsage, incoming *schema.TokenUsage) *schema.TokenUsage { - if incoming == nil { - return CloneUsage(base) - } - if base == nil { - return CloneUsage(incoming) - } - - merged := *base - if incoming.PromptTokens > merged.PromptTokens { - merged.PromptTokens = incoming.PromptTokens - } - if incoming.CompletionTokens > merged.CompletionTokens { - merged.CompletionTokens = incoming.CompletionTokens - } - if incoming.TotalTokens > merged.TotalTokens { - merged.TotalTokens = incoming.TotalTokens - } - if incoming.PromptTokenDetails.CachedTokens > merged.PromptTokenDetails.CachedTokens { - merged.PromptTokenDetails.CachedTokens = incoming.PromptTokenDetails.CachedTokens - } - if incoming.CompletionTokensDetails.ReasoningTokens > merged.CompletionTokensDetails.ReasoningTokens { - merged.CompletionTokensDetails.ReasoningTokens = incoming.CompletionTokensDetails.ReasoningTokens - } - return &merged -} - -// FormatEmptyResponseError 统一生成“模型返回空结果”的错误文案。 -func FormatEmptyResponseError(scene string) error { - scene = strings.TrimSpace(scene) - if scene == "" { - scene = "unknown" - } - return fmt.Errorf("模型在 %s 场景返回空结果", scene) -} diff --git a/backend/agent/llm/json.go b/backend/agent/llm/json.go deleted file mode 100644 index 1ca6926..0000000 --- a/backend/agent/llm/json.go +++ /dev/null @@ -1,112 +0,0 @@ -package agentllm - -import ( - "encoding/json" - "errors" - "fmt" - "strings" -) - -// ParseJSONObject 解析模型返回中的 JSON 对象。 -// -// 职责边界: -// 1. 负责处理“模型输出前后夹杂解释文字 / markdown 代码块”的常见情况; -// 2. 负责提取最外层 JSON object 并反序列化为目标结构; -// 3. 不负责业务字段合法性校验,例如 priority 是否在 1~4,应由上层 node 再校验。 -func ParseJSONObject[T any](raw string) (*T, error) { - clean := strings.TrimSpace(raw) - if clean == "" { - return nil, errors.New("模型返回为空,无法解析 JSON") - } - - objectText := ExtractJSONObject(clean) - if objectText == "" { - return nil, fmt.Errorf("模型返回中未找到 JSON 对象: %s", truncateForError(clean)) - } - - var out T - if err := json.Unmarshal([]byte(objectText), &out); err != nil { - return nil, fmt.Errorf("JSON 解析失败: %w", err) - } - return &out, nil -} - -// ExtractJSONObject 从混合文本里提取第一个完整 JSON 对象。 -// -// 设计说明: -// 1. LLM 很容易输出“这里是结果:{...}”这种半结构化文本; -// 2. 这里用括号计数而不是正则,避免嵌套对象一多就误截断; -// 3. 目前只提取 object,不提取 array,因为当前 agent 的路由/规划契约基本都是对象。 -func ExtractJSONObject(text string) string { - clean := trimMarkdownCodeFence(strings.TrimSpace(text)) - if clean == "" { - return "" - } - - start := strings.Index(clean, "{") - if start < 0 { - return "" - } - - depth := 0 - inString := false - escaped := false - for idx := start; idx < len(clean); idx++ { - ch := clean[idx] - - if escaped { - escaped = false - continue - } - if ch == '\\' && inString { - escaped = true - continue - } - if ch == '"' { - inString = !inString - continue - } - if inString { - continue - } - - switch ch { - case '{': - depth++ - case '}': - depth-- - if depth == 0 { - return clean[start : idx+1] - } - } - } - return "" -} - -func trimMarkdownCodeFence(text string) string { - trimmed := strings.TrimSpace(text) - if !strings.HasPrefix(trimmed, "```") { - return trimmed - } - - lines := strings.Split(trimmed, "\n") - if len(lines) == 0 { - return trimmed - } - - // 1. 去掉首行 ```json / ```; - // 2. 若末行是 ```,一并去掉; - // 3. 中间正文保持原样,避免破坏 JSON 的换行结构。 - body := lines[1:] - if len(body) > 0 && strings.TrimSpace(body[len(body)-1]) == "```" { - body = body[:len(body)-1] - } - return strings.TrimSpace(strings.Join(body, "\n")) -} - -func truncateForError(text string) string { - if len(text) <= 160 { - return text - } - return text[:160] + "..." -} diff --git a/backend/agent/llm/quicknote.go b/backend/agent/llm/quicknote.go deleted file mode 100644 index a1e33ca..0000000 --- a/backend/agent/llm/quicknote.go +++ /dev/null @@ -1,170 +0,0 @@ -package agentllm - -import ( - "context" - "fmt" - "strings" - - agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt" - "github.com/cloudwego/eino-ext/components/model/ark" -) - -// QuickNoteIntentOutput 是“随口记意图识别”模型契约。 -type QuickNoteIntentOutput struct { - IsQuickNote bool `json:"is_quick_note"` - Title string `json:"title"` - DeadlineAt string `json:"deadline_at"` - Reason string `json:"reason"` -} - -// QuickNotePriorityOutput 是“随口记优先级评估”模型契约。 -type QuickNotePriorityOutput struct { - PriorityGroup int `json:"priority_group"` - Reason string `json:"reason"` - UrgencyThresholdAt string `json:"urgency_threshold_at"` -} - -// QuickNotePlanOutput 是“随口记单请求聚合规划”模型契约。 -type QuickNotePlanOutput struct { - Title string `json:"title"` - DeadlineAt string `json:"deadline_at"` - UrgencyThresholdAt string `json:"urgency_threshold_at"` - PriorityGroup int `json:"priority_group"` - PriorityReason string `json:"priority_reason"` - Banter string `json:"banter"` -} - -// IdentifyQuickNoteIntent 调用模型识别“是否随口记”。 -func IdentifyQuickNoteIntent(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*QuickNoteIntentOutput, error) { - prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s -用户输入:%s -请仅输出 JSON(不要 markdown,不要解释),字段如下: -{ - "is_quick_note": boolean, - "title": string, - "deadline_at": string, - "reason": string -} -字段约束: -1) deadline_at 只允许输出绝对时间,格式必须为 "yyyy-MM-dd HH:mm"。 -2) 如果用户说了“明天/后天/下周一/今晚”等相对时间,必须基于上面的当前时间换算成绝对时间。 -3) 如果用户没有提及时间,deadline_at 输出空字符串。`, - nowText, - userInput, - ) - - parsed, _, err := CallArkJSON[QuickNoteIntentOutput](ctx, chatModel, agentprompt.QuickNoteIntentPrompt, prompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: 256, - Thinking: ThinkingModeDisabled, - }) - return parsed, err -} - -// PlanQuickNotePriority 调用模型评估优先级与紧急分界线。 -func PlanQuickNotePriority(ctx context.Context, chatModel *ark.ChatModel, nowText, title, userInput, deadlineClue, deadlineText string) (*QuickNotePriorityOutput, error) { - prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s -请对以下任务评估优先级: -- 任务标题:%s -- 用户原始输入:%s -- 时间线索原文:%s -- 归一化截止时间:%s - -请仅输出 JSON(不要 markdown,不要解释): -{ - "priority_group": 1|2|3|4, - "reason": "简短理由", - "urgency_threshold_at": "yyyy-MM-dd HH:mm 或空字符串" -} - -额外约束: -1) urgency_threshold_at 表示“何时从不紧急象限自动平移到紧急象限”; -2) 若该任务不需要自动平移,可输出空字符串; -3) 若任务已在紧急象限(priority_group=1 或 3),优先输出空字符串; -4) 若输出非空时间,必须是绝对时间,且不晚于归一化截止时间(若有)。`, - nowText, - title, - userInput, - deadlineClue, - deadlineText, - ) - - parsed, _, err := CallArkJSON[QuickNotePriorityOutput](ctx, chatModel, agentprompt.QuickNotePriorityPrompt, prompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: 256, - Thinking: ThinkingModeDisabled, - }) - return parsed, err -} - -// PlanQuickNoteInSingleCall 一次性完成标题/时间/优先级/banter 聚合规划。 -func PlanQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*QuickNotePlanOutput, error) { - prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s -用户输入:%s - -请仅输出 JSON(不要 markdown,不要解释),字段如下: -{ - "title": string, - "deadline_at": string, - "urgency_threshold_at": string, - "priority_group": 1|2|3|4, - "priority_reason": string, - "banter": string -} - -约束: -1) deadline_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串; -2) urgency_threshold_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串; -3) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间; -4) 若任务不需要自动平移,可让 urgency_threshold_at 为空; -5) banter 只允许一句中文,不超过30字,不得改动任务事实。`, - nowText, - strings.TrimSpace(userInput), - ) - - parsed, _, err := CallArkJSON[QuickNotePlanOutput](ctx, chatModel, agentprompt.QuickNotePlanPrompt, prompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: 220, - Thinking: ThinkingModeDisabled, - }) - return parsed, err -} - -// GenerateQuickNoteBanter 生成成功写入后的轻松跟进句。 -func GenerateQuickNoteBanter(ctx context.Context, chatModel *ark.ChatModel, userMessage, title, priorityText, deadlineText string) (string, error) { - if chatModel == nil { - return "", fmt.Errorf("model is nil") - } - - prompt := fmt.Sprintf(`用户原话:%s -已确认事实: -- 任务标题:%s -- %s -- %s - -请输出一句轻松自然的跟进话术(仅一句)。`, - strings.TrimSpace(userMessage), - strings.TrimSpace(title), - strings.TrimSpace(priorityText), - strings.TrimSpace(deadlineText), - ) - - text, err := CallArkText(ctx, chatModel, agentprompt.QuickNoteReplyBanterPrompt, prompt, ArkCallOptions{ - Temperature: 0.7, - MaxTokens: 72, - Thinking: ThinkingModeDisabled, - }) - if err != nil { - return "", err - } - - text = strings.TrimSpace(text) - text = strings.Trim(text, "\"'“”‘’") - if text == "" { - return "", fmt.Errorf("empty content") - } - if idx := strings.Index(text, "\n"); idx >= 0 { - text = strings.TrimSpace(text[:idx]) - } - return text, nil -} diff --git a/backend/agent/llm/route.go b/backend/agent/llm/route.go deleted file mode 100644 index 235e900..0000000 --- a/backend/agent/llm/route.go +++ /dev/null @@ -1,50 +0,0 @@ -package agentllm - -import ( - "strings" - - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" -) - -// RouteDecisionOutput 是一级路由模型的结构化输出契约。 -// -// 说明: -// 1. 这里只定义“模型应该吐什么 JSON”; -// 2. 真正的 prompt 归 prompt/ 管; -// 3. 真正的业务分发归 router/ 管。 -type RouteDecisionOutput struct { - Action string `json:"action"` - TrustRoute bool `json:"trust_route"` - Detail string `json:"detail"` - Confidence float64 `json:"confidence"` -} - -// ToDecision 把模型契约输出映射成 Agent 内部统一路由结果。 -func (o *RouteDecisionOutput) ToDecision() *agentmodel.RouteDecision { - if o == nil { - return &agentmodel.RouteDecision{Action: agentmodel.ActionChat} - } - - action := normalizeRouteAction(o.Action) - return &agentmodel.RouteDecision{ - Action: action, - TrustRoute: o.TrustRoute, - Detail: strings.TrimSpace(o.Detail), - Confidence: o.Confidence, - } -} - -func normalizeRouteAction(raw string) agentmodel.AgentAction { - switch strings.TrimSpace(strings.ToLower(raw)) { - case "quick_note", "quick_note_create": - return agentmodel.ActionQuickNoteCreate - case "task_query": - return agentmodel.ActionTaskQuery - case "schedule_plan", "schedule_plan_create": - return agentmodel.ActionSchedulePlanCreate - case "schedule_refine", "schedule_plan_refine": - return agentmodel.ActionSchedulePlanRefine - default: - return agentmodel.ActionChat - } -} diff --git a/backend/agent/llm/schedule.go b/backend/agent/llm/schedule.go deleted file mode 100644 index 60126b6..0000000 --- a/backend/agent/llm/schedule.go +++ /dev/null @@ -1,175 +0,0 @@ -package agentllm - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt" - "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" -) - -// ScheduleIntentOutput 是 plan 节点要求模型返回的结构化结果。 -// -// 兼容说明: -// 1. 新主语义是 task_class_ids(数组); -// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id(单值)兜底解析; -// 3. TaskTags 的 key 兼容两种写法: -// 3.1 推荐:task_item_id(例如 "12"); -// 3.2 兼容:任务名称(例如 "高数复习")。 -type ScheduleIntentOutput struct { - Intent string `json:"intent"` - Constraints []string `json:"constraints"` - TaskClassIDs []int `json:"task_class_ids"` - TaskClassID int `json:"task_class_id"` - Strategy string `json:"strategy"` - TaskTags map[string]string `json:"task_tags"` - Restart bool `json:"restart"` - AdjustmentScope string `json:"adjustment_scope"` - Reason string `json:"reason"` - Confidence float64 `json:"confidence"` -} - -// ReactToolCall 是 LLM 输出的单个工具调用。 -type ReactToolCall struct { - Tool string `json:"tool"` - Params map[string]any `json:"params"` -} - -// ReactLLMOutput 是 ReAct 节点要求模型返回的统一 JSON。 -type ReactLLMOutput struct { - Done bool `json:"done"` - Summary string `json:"summary"` - ToolCalls []ReactToolCall `json:"tool_calls"` -} - -// IdentifySchedulePlanIntent 调用模型识别“排程意图 + 约束 + 任务类集合”。 -func IdentifySchedulePlanIntent( - ctx context.Context, - chatModel *ark.ChatModel, - nowText string, - userMessage string, - adjustmentHint string, -) (*ScheduleIntentOutput, error) { - prompt := fmt.Sprintf( - "当前时间(北京时间):%s\n用户输入:%s%s\n\n请提取排程意图与约束。", - strings.TrimSpace(nowText), - strings.TrimSpace(userMessage), - strings.TrimSpace(adjustmentHint), - ) - - parsed, _, err := CallArkJSON[ScheduleIntentOutput](ctx, chatModel, agentprompt.SchedulePlanIntentPrompt, prompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: 256, - Thinking: ThinkingModeDisabled, - }) - return parsed, err -} - -// ParseScheduleReactOutput 解析 ReAct 节点的 JSON 输出。 -func ParseScheduleReactOutput(raw string) (*ReactLLMOutput, error) { - return ParseJSONObject[ReactLLMOutput](raw) -} - -// GenerateScheduleDailyReactRound 调用模型生成“单天日内优化”的一轮决策。 -// -// 职责边界: -// 1. 只负责统一关闭 thinking、设置温度,并返回纯文本; -// 2. 不负责工具执行,不负责结果回灌。 -func GenerateScheduleDailyReactRound( - ctx context.Context, - chatModel *ark.ChatModel, - messages []*schema.Message, -) (string, error) { - resp, err := chatModel.Generate( - ctx, - messages, - ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}), - einoModel.WithTemperature(0), - ) - if err != nil { - return "", err - } - if resp == nil { - return "", fmt.Errorf("日内优化调用返回为空") - } - content := strings.TrimSpace(resp.Content) - if content == "" { - return "", fmt.Errorf("日内优化调用返回内容为空") - } - return content, nil -} - -// GenerateScheduleWeeklyReactRound 调用模型生成“单周单步优化”的一轮决策。 -// -// 职责边界: -// 1. 周级仍保留 thinking,提高复杂排程准确率; -// 2. 仅返回最终 content,是否透出思考流由上层决定。 -func GenerateScheduleWeeklyReactRound( - ctx context.Context, - chatModel *ark.ChatModel, - messages []*schema.Message, -) (string, error) { - resp, err := chatModel.Generate( - ctx, - messages, - ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}), - einoModel.WithTemperature(0.2), - ) - if err != nil { - return "", err - } - if resp == nil { - return "", fmt.Errorf("周级单步调用返回为空") - } - content := strings.TrimSpace(resp.Content) - if content == "" { - return "", fmt.Errorf("周级单步调用返回内容为空") - } - return content, nil -} - -// GenerateScheduleHumanSummary 调用模型生成“用户可读”的最终总结。 -func GenerateScheduleHumanSummary( - ctx context.Context, - chatModel *ark.ChatModel, - entries []model.HybridScheduleEntry, - constraints []string, - actionLogs []string, -) (string, error) { - if chatModel == nil { - return "", fmt.Errorf("final summary model is nil") - } - - entriesJSON, _ := json.Marshal(entries) - constraintText := "无" - if len(constraints) > 0 { - constraintText = strings.Join(constraints, "、") - } - actionLogText := "无" - if len(actionLogs) > 0 { - start := 0 - if len(actionLogs) > 30 { - start = len(actionLogs) - 30 - } - actionLogText = strings.Join(actionLogs[start:], "\n") - } - - userPrompt := fmt.Sprintf( - "以下是最终排程方案(JSON):\n%s\n\n用户约束:%s\n\n以下是本次周级优化动作日志(按时间顺序):\n%s\n\n请基于“结果+过程”输出2-3句自然中文总结,重点说明本方案的优点和改进点。", - string(entriesJSON), - constraintText, - actionLogText, - ) - - return CallArkText(ctx, chatModel, agentprompt.SchedulePlanFinalCheckPrompt, userPrompt, ArkCallOptions{ - Temperature: 0.4, - MaxTokens: 256, - Thinking: ThinkingModeDisabled, - }) -} diff --git a/backend/agent/llm/schedule_refine.go b/backend/agent/llm/schedule_refine.go deleted file mode 100644 index 6b420b2..0000000 --- a/backend/agent/llm/schedule_refine.go +++ /dev/null @@ -1,132 +0,0 @@ -package agentllm - -import ( - "context" - "time" - - "github.com/cloudwego/eino-ext/components/model/ark" -) - -const scheduleRefineNodeTimeout = 120 * time.Second - -type ScheduleRefineContractOutput struct { - Intent string `json:"intent"` - Strategy string `json:"strategy"` - HardRequirements []string `json:"hard_requirements"` - HardAssertions []ScheduleRefineAssertionLite `json:"hard_assertions"` - KeepRelativeOrder bool `json:"keep_relative_order"` - OrderScope string `json:"order_scope"` -} - -type ScheduleRefineAssertionLite struct { - Metric string `json:"metric"` - Operator string `json:"operator"` - Value int `json:"value"` - Min int `json:"min"` - Max int `json:"max"` - Week int `json:"week"` - TargetWeek int `json:"target_week"` -} - -type ScheduleRefinePlannerOutput struct { - Summary string `json:"summary"` - Steps []string `json:"steps"` -} - -type ScheduleRefineToolCall struct { - Tool string `json:"tool"` - Params map[string]any `json:"params"` -} - -type ScheduleRefineReactOutput struct { - Done bool `json:"done"` - Summary string `json:"summary"` - GoalCheck string `json:"goal_check"` - Decision string `json:"decision"` - MissingInfo []string `json:"missing_info,omitempty"` - ToolCalls []ScheduleRefineToolCall `json:"tool_calls"` -} - -type ScheduleRefinePostReflectOutput struct { - Reflection string `json:"reflection"` - NextStrategy string `json:"next_strategy"` - ShouldStop bool `json:"should_stop"` -} - -type ScheduleRefineReviewOutput struct { - Pass bool `json:"pass"` - Reason string `json:"reason"` - Unmet []string `json:"unmet"` -} - -func GenerateScheduleRefineContract(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefineContractOutput, string, error) { - return callScheduleRefineJSON[ScheduleRefineContractOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: 260, - Thinking: ThinkingModeDisabled, - }) -} - -func GenerateScheduleRefinePlanner(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (*ScheduleRefinePlannerOutput, string, error) { - return callScheduleRefineJSON[ScheduleRefinePlannerOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: maxTokens, - Thinking: ThinkingModeDisabled, - }) -} - -func GenerateScheduleRefineReact(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, useThinking bool, maxTokens int) (string, error) { - thinking := ThinkingModeDisabled - if useThinking { - thinking = ThinkingModeEnabled - } - return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: maxTokens, - Thinking: thinking, - }) -} - -func GenerateScheduleRefinePostReflect(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefinePostReflectOutput, string, error) { - return callScheduleRefineJSON[ScheduleRefinePostReflectOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: 220, - Thinking: ThinkingModeDisabled, - }) -} - -func GenerateScheduleRefineReview(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefineReviewOutput, string, error) { - return callScheduleRefineJSON[ScheduleRefineReviewOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: 240, - Thinking: ThinkingModeDisabled, - }) -} - -func GenerateScheduleRefineSummary(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) { - return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ - Temperature: 0.35, - MaxTokens: 280, - Thinking: ThinkingModeDisabled, - }) -} - -func GenerateScheduleRefineRepair(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) { - return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ - Temperature: 0.15, - MaxTokens: 240, - Thinking: ThinkingModeDisabled, - }) -} - -func callScheduleRefineText(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (string, error) { - nodeCtx, cancel := context.WithTimeout(ctx, scheduleRefineNodeTimeout) - defer cancel() - return CallArkText(nodeCtx, chatModel, systemPrompt, userPrompt, options) -} - -func callScheduleRefineJSON[T any](ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (*T, string, error) { - nodeCtx, cancel := context.WithTimeout(ctx, scheduleRefineNodeTimeout) - defer cancel() - return CallArkJSON[T](nodeCtx, chatModel, systemPrompt, userPrompt, options) -} diff --git a/backend/agent/llm/taskquery.go b/backend/agent/llm/taskquery.go deleted file mode 100644 index 5805bc2..0000000 --- a/backend/agent/llm/taskquery.go +++ /dev/null @@ -1,83 +0,0 @@ -package agentllm - -import ( - "context" - - agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt" - "github.com/cloudwego/eino-ext/components/model/ark" -) - -// TaskQueryPlanOutput 描述计划节点返回的结构化查询方案。 -// -// 职责边界: -// 1. 只承接模型输出,不在这里做合法性校验。 -// 2. 字段为空或非法时,由 node 层继续归一化与兜底。 -type TaskQueryPlanOutput struct { - UserGoal string `json:"user_goal"` - Quadrants []int `json:"quadrants"` - SortBy string `json:"sort_by"` - Order string `json:"order"` - Limit int `json:"limit"` - IncludeCompleted *bool `json:"include_completed"` - Keyword string `json:"keyword"` - DeadlineBefore string `json:"deadline_before"` - DeadlineAfter string `json:"deadline_after"` -} - -// TaskQueryRetryPatch 描述反思节点允许回写的计划补丁。 -// -// 输入输出语义: -// 1. 指针字段为 nil 表示“不改这个字段”。 -// 2. 非 nil 但值为空字符串,表示显式清空该条件。 -type TaskQueryRetryPatch struct { - Quadrants *[]int `json:"quadrants,omitempty"` - SortBy *string `json:"sort_by,omitempty"` - Order *string `json:"order,omitempty"` - Limit *int `json:"limit,omitempty"` - IncludeCompleted *bool `json:"include_completed,omitempty"` - Keyword *string `json:"keyword,omitempty"` - DeadlineBefore *string `json:"deadline_before,omitempty"` - DeadlineAfter *string `json:"deadline_after,omitempty"` -} - -// TaskQueryReflectOutput 描述反思节点对本轮查询结果的判定。 -// -// 输入输出语义: -// 1. Satisfied=true 表示当前结果可直接收口。 -// 2. NeedRetry=true 表示建议再跑一轮,但真正是否重试由 node 层结合次数上限决定。 -// 3. Reply 是可直接给用户的候选文案,允许为空。 -type TaskQueryReflectOutput struct { - Satisfied bool `json:"satisfied"` - NeedRetry bool `json:"need_retry"` - Reason string `json:"reason"` - Reply string `json:"reply"` - RetryPatch TaskQueryRetryPatch `json:"retry_patch"` -} - -// PlanTaskQuery 负责调用模型,把自然语言查询规划成结构化检索参数。 -// -// 职责边界: -// 1. 只负责模型调用与 JSON 解析。 -// 2. 不负责结果兜底、限流裁剪或时间归一化。 -func PlanTaskQuery(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*TaskQueryPlanOutput, error) { - parsed, _, err := CallArkJSON[TaskQueryPlanOutput](ctx, chatModel, agentprompt.TaskQueryPlanPrompt, agentprompt.BuildTaskQueryPlanUserPrompt(nowText, userInput), ArkCallOptions{ - Temperature: 0, - MaxTokens: 260, - Thinking: ThinkingModeDisabled, - }) - return parsed, err -} - -// ReflectTaskQuery 负责让模型判断当前查询结果是否满足用户意图。 -// -// 职责边界: -// 1. 只负责反思提示词调用与结构化解析。 -// 2. 不负责实际执行重试,也不负责拼接最终兜底回复。 -func ReflectTaskQuery(ctx context.Context, chatModel *ark.ChatModel, prompt string) (*TaskQueryReflectOutput, error) { - parsed, _, err := CallArkJSON[TaskQueryReflectOutput](ctx, chatModel, agentprompt.TaskQueryReflectPrompt, prompt, ArkCallOptions{ - Temperature: 0, - MaxTokens: 380, - Thinking: ThinkingModeDisabled, - }) - return parsed, err -} diff --git a/backend/agent/model/common.go b/backend/agent/model/common.go deleted file mode 100644 index f897706..0000000 --- a/backend/agent/model/common.go +++ /dev/null @@ -1,17 +0,0 @@ -package agentmodel - -// AgentRequest 是 Agent 总入口接收的统一请求结构。 -type AgentRequest struct { - UserID int - ConversationID string - UserMessage string - ModelName string - Extra map[string]any -} - -// AgentResponse 是 Agent 总入口返回的统一响应结构。 -type AgentResponse struct { - Action AgentAction - Reply string - Meta map[string]any -} diff --git a/backend/agent/model/quicknote.go b/backend/agent/model/quicknote.go deleted file mode 100644 index f394891..0000000 --- a/backend/agent/model/quicknote.go +++ /dev/null @@ -1,102 +0,0 @@ -package agentmodel - -import ( - "time" - - agentshared "github.com/LoveLosita/smartflow/backend/agent/shared" -) - -const ( - // QuickNoteDatetimeMinuteLayout 是随口记链路统一使用的分钟级时间格式。 - QuickNoteDatetimeMinuteLayout = "2006-01-02 15:04" - // QuickNoteTimezoneName 是随口记时间解析与展示优先使用的时区。 - QuickNoteTimezoneName = "Asia/Shanghai" - - QuickNotePriorityImportantUrgent = TaskPriorityImportantUrgent - QuickNotePriorityImportantNotUrgent = TaskPriorityImportantNotUrgent - QuickNotePrioritySimpleNotImportant = TaskPrioritySimpleNotImportant - QuickNotePriorityComplexNotImportant = TaskPriorityComplexNotImportant -) - -// QuickNoteState 是随口记图在节点间流转的完整状态。 -// -// 职责边界: -// 1. 负责保存意图识别、任务提取、工具重试和最终回复所需状态。 -// 2. 不负责图编排,也不直接映射数据库任务实体。 -type QuickNoteState struct { - TraceID string - UserID int - ConversationID string - RequestNow time.Time - RequestNowText string - UserInput string - - IsQuickNoteIntent bool - IntentJudgeReason string - - ExtractedTitle string - ExtractedDeadline *time.Time - ExtractedDeadlineText string - ExtractedUrgencyThreshold *time.Time - ExtractedPriority int - ExtractedBanter string - PlannedBySingleCall bool - ExtractedPriorityReason string - DeadlineValidationError string - - ToolAttemptCount int - MaxToolRetry int - LastToolError string - PersistedTaskID int - Persisted bool - AssistantReply string -} - -// NewQuickNoteState 负责创建随口记图的初始状态。 -// -// 输入输出语义: -// 1. RequestNow 与 RequestNowText 会在创建时同步写入,保证整条链路共用同一时间基准。 -// 2. MaxToolRetry 默认给 3,避免上层未配置时完全失去重试能力。 -func NewQuickNoteState(traceID string, userID int, conversationID, userInput string) *QuickNoteState { - requestNow := agentshared.NowToMinute() - return &QuickNoteState{ - TraceID: traceID, - UserID: userID, - ConversationID: conversationID, - RequestNow: requestNow, - RequestNowText: agentshared.FormatMinute(requestNow), - UserInput: userInput, - MaxToolRetry: 3, - } -} - -// CanRetryTool 返回当前是否还允许再次调用持久化工具。 -// -// 输入输出语义: -// 1. true 表示“尚未达到最大重试次数”,调用方仍可继续重试。 -// 2. false 表示必须收口,避免无限重试。 -func (s *QuickNoteState) CanRetryTool() bool { - return s.ToolAttemptCount < s.MaxToolRetry -} - -// RecordToolError 记录一次工具失败,并推进重试计数。 -// -// 职责边界: -// 1. 只更新与工具失败相关的状态。 -// 2. 不决定是否继续重试,是否重试由节点分支逻辑判断。 -func (s *QuickNoteState) RecordToolError(errMsg string) { - s.ToolAttemptCount++ - s.LastToolError = errMsg -} - -// RecordToolSuccess 记录一次工具成功结果。 -// -// 输入输出语义: -// 1. taskID 必须是持久化后的真实任务 ID。 -// 2. 成功后会清空 LastToolError,表示当前链路已进入稳定态。 -func (s *QuickNoteState) RecordToolSuccess(taskID int) { - s.ToolAttemptCount++ - s.PersistedTaskID = taskID - s.Persisted = true - s.LastToolError = "" -} diff --git a/backend/agent/model/route.go b/backend/agent/model/route.go deleted file mode 100644 index 81e2d58..0000000 --- a/backend/agent/model/route.go +++ /dev/null @@ -1,20 +0,0 @@ -package agentmodel - -// AgentAction 表示一级路由动作。 -type AgentAction string - -const ( - ActionChat AgentAction = "chat" - ActionQuickNoteCreate AgentAction = "quick_note_create" - ActionTaskQuery AgentAction = "task_query" - ActionSchedulePlanCreate AgentAction = "schedule_plan_create" - ActionSchedulePlanRefine AgentAction = "schedule_plan_refine" -) - -// RouteDecision 是统一一级分流结果。 -type RouteDecision struct { - Action AgentAction - TrustRoute bool - Detail string - Confidence float64 -} diff --git a/backend/agent/model/schedule.go b/backend/agent/model/schedule.go deleted file mode 100644 index deb55e3..0000000 --- a/backend/agent/model/schedule.go +++ /dev/null @@ -1,200 +0,0 @@ -package agentmodel - -import ( - "strings" - "time" - - "github.com/LoveLosita/smartflow/backend/model" -) - -const ( - // SchedulePlanTimezoneName 是排程链路默认业务时区。 - // 与随口记保持一致,固定东八区,避免容器运行在 UTC 导致“明天/今晚”偏移。 - SchedulePlanTimezoneName = "Asia/Shanghai" - - // SchedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。 - SchedulePlanDatetimeLayout = "2006-01-02 15:04" - - // SchedulePlanDefaultDailyRefineConcurrency 是日内并发优化默认并发度。 - // 这里给一个保守默认值,避免未配置时直接把模型并发打满导致限流。 - SchedulePlanDefaultDailyRefineConcurrency = 3 - - // SchedulePlanDefaultWeeklyAdjustBudget 是周级配平默认调整额度。 - // 额度存在的目的: - // 1. 防止周级 ReAct 过度调整导致震荡; - // 2. 控制 token 与时延成本; - // 3. 让方案改动更可解释。 - SchedulePlanDefaultWeeklyAdjustBudget = 5 - - // SchedulePlanDefaultWeeklyTotalBudget 是周级“总尝试次数”默认预算。 - // - // 设计意图: - // 1. 总预算统计“动作尝试次数”(成功/失败都记一次); - // 2. 有效预算统计“成功动作次数”(仅成功时记一次); - // 3. 通过双预算把“探索次数”和“有效改动次数”分离,降低模型无效空转成本。 - SchedulePlanDefaultWeeklyTotalBudget = 8 - - // SchedulePlanDefaultWeeklyRefineConcurrency 是周级“按周并发”默认并发度。 - // 说明: - // 1. 周级输入规模通常比单天更大,默认并发度不宜过高,避免触发模型侧限流; - // 2. 可在运行时按请求状态覆盖。 - SchedulePlanDefaultWeeklyRefineConcurrency = 2 - - // SchedulePlanAdjustmentScopeSmall 表示“小改动微调”。 - // 语义:优先走快速路径,只做轻量周级调整。 - SchedulePlanAdjustmentScopeSmall = "small" - // SchedulePlanAdjustmentScopeMedium 表示“中等改动微调”。 - // 语义:跳过日内拆分,直接进入周级配平。 - SchedulePlanAdjustmentScopeMedium = "medium" - // SchedulePlanAdjustmentScopeLarge 表示“大改动重排”。 - // 语义:必要时重新走全量路径(日内并发 + 周级配平)。 - SchedulePlanAdjustmentScopeLarge = "large" -) - -const ( - schedulePlanTimezoneName = SchedulePlanTimezoneName - schedulePlanDatetimeLayout = SchedulePlanDatetimeLayout - schedulePlanDefaultDailyRefineConcurrency = SchedulePlanDefaultDailyRefineConcurrency - schedulePlanDefaultWeeklyAdjustBudget = SchedulePlanDefaultWeeklyAdjustBudget - schedulePlanDefaultWeeklyTotalBudget = SchedulePlanDefaultWeeklyTotalBudget - schedulePlanDefaultWeeklyRefineConcurrency = SchedulePlanDefaultWeeklyRefineConcurrency - schedulePlanAdjustmentScopeSmall = SchedulePlanAdjustmentScopeSmall - schedulePlanAdjustmentScopeMedium = SchedulePlanAdjustmentScopeMedium - schedulePlanAdjustmentScopeLarge = SchedulePlanAdjustmentScopeLarge -) - -// DayGroup 是“按天拆分后”的最小优化单元。 -// -// 设计目的: -// 1. 把全量周视角数据拆成“单天小包”,降低日内 ReAct 输入规模; -// 2. 支持并发优化不同天的数据,缩短整体等待; -// 3. 通过 SkipRefine 让低收益天数直接跳过,节省模型调用成本。 -type DayGroup struct { - Week int - DayOfWeek int - Entries []model.HybridScheduleEntry - SkipRefine bool -} - -// SchedulePlanState 是“智能排程”链路在 graph 节点间传递的统一状态容器。 -// -// 设计目标: -// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散落; -// 2) 支持“粗排 -> 日内并发优化 -> 周级配平 -> 终审校验”的完整链路追踪; -// 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。 -type SchedulePlanState struct { - // ── 基础上下文 ── - TraceID string - UserID int - ConversationID string - RequestNow time.Time - RequestNowText string - - // ── plan 节点输出 ── - UserIntent string - Constraints []string - TaskClassIDs []int - Strategy string - TaskTags map[int]string - TaskTagHintsByName map[string]string - - // ── preview 节点输出 ── - CandidatePlans []model.UserWeekSchedule - AllocatedItems []model.TaskClassItem - HasPlanningWindow bool - PlanStartWeek int - PlanStartDay int - PlanEndWeek int - PlanEndDay int - - // ── 日内并发优化阶段 ── - DailyGroups map[int]map[int]*DayGroup - DailyResults map[int]map[int][]model.HybridScheduleEntry - DailyRefineConcurrency int - - // ── 周级 ReAct 精排阶段 ── - HybridEntries []model.HybridScheduleEntry - MergeSnapshot []model.HybridScheduleEntry - ReactRound int - ReactMaxRound int - ReactSummary string - ReactDone bool - WeeklyAdjustBudget int - WeeklyAdjustUsed int - WeeklyTotalBudget int - WeeklyTotalUsed int - WeeklyRefineConcurrency int - WeeklyActionLogs []string - - // ── 连续对话微调 ── - PreviousPlanJSON string - IsAdjustment bool - RestartRequested bool - AdjustmentScope string - AdjustmentReason string - AdjustmentConfidence float64 - HasPreviousPreview bool - PreviousTaskClassIDs []int - PreviousHybridEntries []model.HybridScheduleEntry - PreviousAllocatedItems []model.TaskClassItem - PreviousCandidatePlans []model.UserWeekSchedule - - // ── 最终输出 ── - FinalSummary string - 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), - Strategy: "steady", - TaskTags: make(map[int]string), - TaskTagHintsByName: make(map[string]string), - DailyRefineConcurrency: schedulePlanDefaultDailyRefineConcurrency, - WeeklyRefineConcurrency: schedulePlanDefaultWeeklyRefineConcurrency, - AdjustmentScope: schedulePlanAdjustmentScopeLarge, - ReactMaxRound: 2, - WeeklyAdjustBudget: schedulePlanDefaultWeeklyAdjustBudget, - WeeklyTotalBudget: schedulePlanDefaultWeeklyTotalBudget, - } -} - -// NormalizeSchedulePlanAdjustmentScope 归一化排程微调力度字段。 -// -// 兜底策略: -// 1. 只接受 small/medium/large; -// 2. 任何未知值都回退为 large,保证不会误走“过轻”路径。 -func NormalizeSchedulePlanAdjustmentScope(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case schedulePlanAdjustmentScopeSmall: - return schedulePlanAdjustmentScopeSmall - case schedulePlanAdjustmentScopeMedium: - return schedulePlanAdjustmentScopeMedium - default: - return schedulePlanAdjustmentScopeLarge - } -} - -// 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) -} - -func normalizeAdjustmentScope(raw string) string { - return NormalizeSchedulePlanAdjustmentScope(raw) -} diff --git a/backend/agent/model/schedule_refine.go b/backend/agent/model/schedule_refine.go deleted file mode 100644 index 56482ce..0000000 --- a/backend/agent/model/schedule_refine.go +++ /dev/null @@ -1,344 +0,0 @@ -package agentmodel - -import ( - "sort" - "strings" - "time" - - agentshared "github.com/LoveLosita/smartflow/backend/agent/shared" - "github.com/LoveLosita/smartflow/backend/model" -) - -const ( - datetimeLayout = agentshared.MinuteLayout - - ScheduleRefineDefaultPlanMax = 2 - ScheduleRefineDefaultExecuteMax = 24 - ScheduleRefineDefaultPerTaskBudget = 4 - ScheduleRefineDefaultReplanMax = 2 - ScheduleRefineDefaultCompositeRetry = 2 - ScheduleRefineDefaultRepairReserve = 1 -) - -const ( - defaultPlanMax = ScheduleRefineDefaultPlanMax - defaultExecuteMax = ScheduleRefineDefaultExecuteMax - defaultPerTaskBudget = ScheduleRefineDefaultPerTaskBudget - defaultReplanMax = ScheduleRefineDefaultReplanMax - defaultCompositeRetry = ScheduleRefineDefaultCompositeRetry - defaultRepairReserve = ScheduleRefineDefaultRepairReserve -) - -// RefineContract 表示本轮微调意图契约。 -type RefineContract struct { - Intent string `json:"intent"` - Strategy string `json:"strategy"` - HardRequirements []string `json:"hard_requirements"` - HardAssertions []RefineAssertion `json:"hard_assertions,omitempty"` - KeepRelativeOrder bool `json:"keep_relative_order"` - OrderScope string `json:"order_scope"` -} - -// RefineAssertion 表示可由后端直接判定的结构化硬断言。 -// -// 字段说明: -// 1. Metric:断言指标名,例如 source_move_ratio_percent; -// 2. Operator:比较操作符,支持 == / <= / >= / between; -// 3. Value/Min/Max:阈值; -// 4. Week/TargetWeek:可选周次上下文。 -type RefineAssertion struct { - Metric string `json:"metric"` - Operator string `json:"operator"` - Value int `json:"value,omitempty"` - Min int `json:"min,omitempty"` - Max int `json:"max,omitempty"` - Week int `json:"week,omitempty"` - TargetWeek int `json:"target_week,omitempty"` -} - -// HardCheckReport 表示终审硬校验结果。 -type HardCheckReport struct { - PhysicsPassed bool `json:"physics_passed"` - PhysicsIssues []string `json:"physics_issues,omitempty"` - - IntentPassed bool `json:"intent_passed"` - IntentReason string `json:"intent_reason,omitempty"` - IntentUnmet []string `json:"intent_unmet,omitempty"` - - OrderPassed bool `json:"order_passed"` - OrderIssues []string `json:"order_issues,omitempty"` - - RepairTried bool `json:"repair_tried"` -} - -// ReactRoundObservation 记录每轮 ReAct 的关键观察。 -type ReactRoundObservation struct { - Round int `json:"round"` - GoalCheck string `json:"goal_check,omitempty"` - Decision string `json:"decision,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolParams map[string]any `json:"tool_params,omitempty"` - ToolSuccess bool `json:"tool_success"` - ToolErrorCode string `json:"tool_error_code,omitempty"` - ToolResult string `json:"tool_result,omitempty"` - Reflect string `json:"reflect,omitempty"` -} - -// PlannerPlan 表示 Planner 生成的阶段执行计划。 -type PlannerPlan struct { - Summary string `json:"summary"` - Steps []string `json:"steps,omitempty"` -} - -// RefineSlicePlan 表示切片节点输出。 -type RefineSlicePlan struct { - WeekFilter []int `json:"week_filter,omitempty"` - SourceDays []int `json:"source_days,omitempty"` - TargetDays []int `json:"target_days,omitempty"` - ExcludeSections []int `json:"exclude_sections,omitempty"` - Reason string `json:"reason,omitempty"` -} - -// RefineObjective 表示"可执行且可校验"的目标约束。 -// -// 设计说明: -// 1. 由 contract/slice 从自然语言编译得到; -// 2. 执行阶段(done 收口)与终审阶段(hard_check)共用同一份约束; -// 3. 避免"执行逻辑与终审逻辑各说各话"。 -type RefineObjective struct { - Mode string `json:"mode,omitempty"` // none | move_all | move_ratio - - SourceWeeks []int `json:"source_weeks,omitempty"` - TargetWeeks []int `json:"target_weeks,omitempty"` - SourceDays []int `json:"source_days,omitempty"` - TargetDays []int `json:"target_days,omitempty"` - - ExcludeSections []int `json:"exclude_sections,omitempty"` - - BaselineSourceTaskCount int `json:"baseline_source_task_count,omitempty"` - RequiredMoveMin int `json:"required_move_min,omitempty"` - RequiredMoveMax int `json:"required_move_max,omitempty"` - - Reason string `json:"reason,omitempty"` -} - -// ScheduleRefineState 是连续微调图的统一状态。 -type ScheduleRefineState struct { - // 1) 请求上下文 - TraceID string - UserID int - ConversationID string - UserMessage string - RequestNow time.Time - RequestNowText string - - // 2) 继承自预览快照的数据 - TaskClassIDs []int - Constraints []string - // InitialHybridEntries 保存本轮微调开始前的基线,用于终审做"前后对比"。 - // 说明: - // 1. 只读语义,不参与执行期改写; - // 2. 终审只基于它判断"来源任务是否真正迁移到目标区域"。 - InitialHybridEntries []model.HybridScheduleEntry - HybridEntries []model.HybridScheduleEntry - AllocatedItems []model.TaskClassItem - CandidatePlans []model.UserWeekSchedule - - // 3) 本轮执行状态 - UserIntent string - Contract RefineContract - - PlanMax int - PerTaskBudget int - ExecuteMax int - ReplanMax int - // CompositeRetryMax 表示复合路由失败后的最大重试次数(不含首次尝试)。 - CompositeRetryMax int - - PlanUsed int - ReplanUsed int - - MaxRounds int - RepairReserve int - RoundUsed int - ActionLogs []string - - ConsecutiveFailures int - ThinkingBoostArmed bool - ObservationHistory []ReactRoundObservation - - CurrentPlan PlannerPlan - BatchMoveAllowed bool - // DisableCompositeTools=true 表示已进入 ReAct 兜底,禁止再调用复合工具。 - DisableCompositeTools bool - // CompositeRouteTried 标记是否尝试过"复合批处理路由"。 - CompositeRouteTried bool - // CompositeRouteSucceeded 标记复合批处理路由是否已完成"复合分支出站"。 - // - // 说明: - // 1. true 表示当前链路可以跳过 ReAct 兜底,直接进入 hard_check; - // 2. 它不等价于"终审已通过",终审是否通过仍以后续 HardCheck 结果为准; - // 3. 这样区分是为了避免"复合工具已成功执行,但业务目标要等终审裁决"时被误判为失败。 - CompositeRouteSucceeded bool - TaskActionUsed map[int]int - EntriesVersion int - SeenSlotQueries map[string]struct{} - - // RequiredCompositeTool 表示本轮策略要求"必须至少成功一次"的复合工具。 - // 取值约定:"" | "SpreadEven" | "MinContextSwitch"。 - RequiredCompositeTool string - // CompositeToolCalled 记录复合工具是否至少调用过一次(不区分成功失败)。 - CompositeToolCalled map[string]bool - // CompositeToolSuccess 记录复合工具是否至少成功过一次。 - CompositeToolSuccess map[string]bool - - SlicePlan RefineSlicePlan - Objective RefineObjective - WorksetTaskIDs []int - WorksetCursor int - CurrentTaskID int - CurrentTaskAttempt int - - LastFailedCallSignature string - OriginOrderMap map[int]int - - // 4) 终审状态 - HardCheck HardCheckReport - - // 5) 最终输出 - FinalSummary string - Completed bool -} - -// NewScheduleRefineState 基于上一版预览快照初始化状态。 -// -// 职责边界: -// 1. 负责初始化预算、上下文字段与可变状态容器; -// 2. 负责拷贝 preview 数据,避免跨请求引用污染; -// 3. 不负责做任何调度动作。 -func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState { - now := nowToMinute() - st := &ScheduleRefineState{ - TraceID: strings.TrimSpace(traceID), - UserID: userID, - ConversationID: strings.TrimSpace(conversationID), - UserMessage: strings.TrimSpace(userMessage), - RequestNow: now, - RequestNowText: now.In(loadLocation()).Format(datetimeLayout), - PlanMax: defaultPlanMax, - PerTaskBudget: defaultPerTaskBudget, - ExecuteMax: defaultExecuteMax, - ReplanMax: defaultReplanMax, - CompositeRetryMax: defaultCompositeRetry, - RepairReserve: defaultRepairReserve, - MaxRounds: defaultExecuteMax + defaultRepairReserve, - ActionLogs: make([]string, 0, 32), - ObservationHistory: make([]ReactRoundObservation, 0, 24), - TaskActionUsed: make(map[int]int), - SeenSlotQueries: make(map[string]struct{}), - OriginOrderMap: make(map[int]int), - CompositeToolCalled: map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - }, - CompositeToolSuccess: map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - }, - CurrentPlan: PlannerPlan{ - Summary: "initialized, waiting for planner output", - }, - SlicePlan: RefineSlicePlan{ - Reason: "尚未切片", - }, - } - if preview == nil { - return st - } - - st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...) - st.InitialHybridEntries = cloneHybridEntries(preview.HybridEntries) - st.HybridEntries = cloneHybridEntries(preview.HybridEntries) - st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems) - st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans) - st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries) - return st -} - -func loadLocation() *time.Location { - return agentshared.ShanghaiLocation() -} - -func nowToMinute() time.Time { - return agentshared.NowToMinute() -} - -func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { - return agentshared.CloneHybridEntries(src) -} - -func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { - return agentshared.CloneTaskClassItems(src) -} - -func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { - return agentshared.CloneWeekSchedules(src) -} - -// buildOriginOrderMap 构建 suggested 任务的初始顺序基线(task_item_id -> rank)。 -func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int { - orderMap := make(map[int]int) - if len(entries) == 0 { - return orderMap - } - suggested := make([]model.HybridScheduleEntry, 0, len(entries)) - for _, entry := range entries { - if isMovableSuggestedTask(entry) { - suggested = append(suggested, entry) - } - } - sort.SliceStable(suggested, func(i, j int) bool { - left := suggested[i] - right := suggested[j] - if left.Week != right.Week { - return left.Week < right.Week - } - if left.DayOfWeek != right.DayOfWeek { - return left.DayOfWeek < right.DayOfWeek - } - if left.SectionFrom != right.SectionFrom { - return left.SectionFrom < right.SectionFrom - } - if left.SectionTo != right.SectionTo { - return left.SectionTo < right.SectionTo - } - return left.TaskItemID < right.TaskItemID - }) - for i, entry := range suggested { - orderMap[entry.TaskItemID] = i + 1 - } - return orderMap -} - -// FinalHardCheckPassed 判断"最终终审"是否整体通过。 -// -// 职责边界: -// 1. 负责聚合 physics/order/intent 三类硬校验结果,给服务层与总结阶段统一复用; -// 2. 不负责触发终审,也不负责推送修复动作; -// 3. nil state 视为未通过,避免上层把缺失结果误判为成功。 -func FinalHardCheckPassed(st *ScheduleRefineState) bool { - if st == nil { - return false - } - return st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed -} - -func isMovableSuggestedTask(entry model.HybridScheduleEntry) bool { - if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 { - return false - } - if strings.EqualFold(strings.TrimSpace(entry.Type), "course") { - return false - } - return true -} diff --git a/backend/agent/model/taskquery.go b/backend/agent/model/taskquery.go deleted file mode 100644 index 77fa164..0000000 --- a/backend/agent/model/taskquery.go +++ /dev/null @@ -1,87 +0,0 @@ -package agentmodel - -import "time" - -const ( - // DefaultTaskQueryLimit 是任务查询默认返回条数。 - DefaultTaskQueryLimit = 5 - // MaxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制模型输出范围。 - MaxTaskQueryLimit = 20 - // DefaultTaskQueryReflectRetry 是任务查询反思节点的默认重试次数。 - DefaultTaskQueryReflectRetry = 2 -) - -// TaskQueryItem 是任务查询链路最终展示给模型和用户的轻量任务视图。 -// -// 职责边界: -// 1. 只承载展示和反思所需字段,避免把底层数据库结构直接暴露给图层。 -// 2. 不负责描述完整任务实体,也不负责持久化。 -type TaskQueryItem struct { - ID int `json:"id"` - Title string `json:"title"` - PriorityGroup int `json:"priority_group"` - PriorityLabel string `json:"priority_label"` - IsCompleted bool `json:"is_completed"` - DeadlineAt string `json:"deadline_at,omitempty"` - UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"` -} - -// TaskQueryPlan 是计划节点产出的内部查询方案。 -// -// 输入输出语义: -// 1. DeadlineBeforeText / DeadlineAfterText 保留原始文本,便于继续透传给工具和日志。 -// 2. DeadlineBefore / DeadlineAfter 是归一化后的时间对象,仅供执行期使用。 -// 3. IncludeCompleted=true 表示允许把已完成任务纳入候选集。 -type TaskQueryPlan struct { - Quadrants []int - SortBy string - Order string - Limit int - - IncludeCompleted bool - Keyword string - DeadlineBeforeText string - DeadlineAfterText string - DeadlineBefore *time.Time - DeadlineAfter *time.Time -} - -// TaskQueryState 是任务查询图在各节点之间流转的完整状态。 -// -// 职责边界: -// 1. 负责保存用户输入、结构化计划、工具结果和反思过程状态。 -// 2. 不负责图编排本身,也不直接绑定外部数据库实体。 -type TaskQueryState struct { - UserMessage string - RequestNowText string - UserGoal string - Plan TaskQueryPlan - ExplicitLimit int - - LastQueryItems []TaskQueryItem - LastQueryTotal int - AutoBroadenApplied bool - RetryCount int - MaxReflectRetry int - NeedRetry bool - ReflectReason string - FinalReply string -} - -// NewTaskQueryState 负责创建任务查询图的初始状态。 -// -// 输入输出语义: -// 1. maxReflectRetry <= 0 时会自动回退到默认值,避免上层遗漏配置导致无法重试。 -// 2. 返回的状态对象已初始化空切片,可直接进入 graph 执行。 -func NewTaskQueryState(userMessage, requestNowText string, maxReflectRetry int) *TaskQueryState { - if maxReflectRetry <= 0 { - maxReflectRetry = DefaultTaskQueryReflectRetry - } - return &TaskQueryState{ - UserMessage: userMessage, - RequestNowText: requestNowText, - MaxReflectRetry: maxReflectRetry, - LastQueryItems: make([]TaskQueryItem, 0), - AutoBroadenApplied: false, - } -} diff --git a/backend/agent/node/quicknote.go b/backend/agent/node/quicknote.go deleted file mode 100644 index 7f9c4de..0000000 --- a/backend/agent/node/quicknote.go +++ /dev/null @@ -1,504 +0,0 @@ -package agentnode - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - agentllm "github.com/LoveLosita/smartflow/backend/agent/llm" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - "github.com/cloudwego/eino-ext/components/model/ark" - "github.com/cloudwego/eino/components/tool" - "github.com/cloudwego/eino/compose" -) - -const ( - // QuickNoteGraphNodeIntent 是随口记图中的“意图识别”节点名。 - QuickNoteGraphNodeIntent = "quick_note_intent" - // QuickNoteGraphNodeRank 是随口记图中的“优先级评估”节点名。 - QuickNoteGraphNodeRank = "quick_note_priority" - // QuickNoteGraphNodePersist 是随口记图中的“持久化写库”节点名。 - QuickNoteGraphNodePersist = "quick_note_persist" - // QuickNoteGraphNodeExit 是随口记图中的“提前退出”节点名。 - QuickNoteGraphNodeExit = "quick_note_exit" -) - -// QuickNoteGraphRunInput 描述一次随口记图运行所需的请求级依赖。 -// -// 职责边界: -// 1. 负责把模型、初始状态、工具依赖和阶段回调打包给 graph 层。 -// 2. 不负责做依赖校验,校验逻辑由 graph/node 构造阶段处理。 -type QuickNoteGraphRunInput struct { - Model *ark.ChatModel - State *agentmodel.QuickNoteState - Deps QuickNoteToolDeps - SkipIntentVerification bool - EmitStage func(stage, detail string) -} - -// QuickNoteNodes 是随口记图的节点容器。 -// -// 职责边界: -// 1. 负责承接节点运行时依赖,并向 graph 暴露可直接挂载的方法。 -// 2. 不负责 graph 编译,也不负责 service 层接口接线。 -type QuickNoteNodes struct { - input QuickNoteGraphRunInput - createTaskTool tool.InvokableTool - emitStage func(stage, detail string) -} - -// NewQuickNoteNodes 负责构造随口记节点容器。 -// -// 输入输出语义: -// 1. createTaskTool 不能为空,否则 persist 节点无法落库。 -// 2. EmitStage 为空时会回退到空实现,避免节点内部到处判空。 -func NewQuickNoteNodes(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool) (*QuickNoteNodes, error) { - if createTaskTool == nil { - return nil, errors.New("quick note nodes: createTaskTool is nil") - } - - emitStage := input.EmitStage - if emitStage == nil { - emitStage = func(stage, detail string) {} - } - - return &QuickNoteNodes{ - input: input, - createTaskTool: createTaskTool, - emitStage: emitStage, - }, nil -} - -// Exit 是图中的显式退出节点。 -// -// 职责边界: -// 1. 仅作为图收口占位,保持状态原样透传。 -// 2. 不做额外业务处理,避免退出节点再引入副作用。 -func (n *QuickNoteNodes) Exit(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { - _ = ctx - return st, nil -} - -// NextAfterIntent 根据意图识别结果决定 intent 节点后的分支走向。 -// -// 步骤说明: -// 1. 非随口记意图时直接退出,避免误把普通聊天写成任务。 -// 2. 截止时间校验失败时同样直接退出,让上层优先把错误提示给用户。 -// 3. 只有意图成立且时间合法,才进入优先级评估节点。 -func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) { - _ = ctx - if st == nil || !st.IsQuickNoteIntent { - return QuickNoteGraphNodeExit, nil - } - if st.DeadlineValidationError != "" { - return QuickNoteGraphNodeExit, nil - } - return QuickNoteGraphNodeRank, nil -} - -// NextAfterPersist 根据持久化结果决定 persist 节点后的分支走向。 -// -// 输入输出语义: -// 1. Persisted=true 表示已经成功写库,可以直接结束。 -// 2. Persisted=false 且 CanRetryTool()=true 表示继续重试写库。 -// 3. 重试用尽后会补齐兜底回复,再结束链路,避免用户拿到空响应。 -func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) { - _ = ctx - if st == nil { - return compose.END, nil - } - if st.Persisted { - return compose.END, nil - } - if st.CanRetryTool() { - return QuickNoteGraphNodePersist, nil - } - if st.AssistantReply == "" { - st.AssistantReply = "抱歉,我已经重试了多次,还是没能成功记录这条任务,请稍后再试。" - } - return compose.END, nil -} - -// Intent 负责“意图识别 + 聚合规划 + 时间校验”。 -// -// 职责边界: -// 1. 负责判断本次请求是否属于随口记; -// 2. 负责把模型规划结果回填到 state; -// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL; -// 4. 不负责真正写库。 -func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { - if st == nil { - return nil, errors.New("quick note graph: nil state in intent node") - } - - // 1. 若上游路由已经高置信命中 quick_note,则直接进入单次聚合规划。 - // 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内; - // 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。 - if n.input.SkipIntentVerification { - n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。") - st.IsQuickNoteIntent = true - st.IntentJudgeReason = "上游路由已命中 quick_note,跳过二次意图判定" - st.PlannedBySingleCall = true - - n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。") - plan, planErr := planQuickNoteInSingleCall(ctx, n.input.Model, st.RequestNowText, st.RequestNow, st.UserInput) - if planErr != nil { - st.IntentJudgeReason += ";聚合规划失败,回退本地兜底" - } else { - if strings.TrimSpace(plan.Title) != "" { - st.ExtractedTitle = strings.TrimSpace(plan.Title) - } - if plan.Deadline != nil { - st.ExtractedDeadline = plan.Deadline - } - st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText) - if plan.UrgencyThreshold != nil { - st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline) - } - if agentmodel.IsValidTaskPriority(plan.PriorityGroup) { - st.ExtractedPriority = plan.PriorityGroup - st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason) - } - st.ExtractedBanter = strings.TrimSpace(plan.Banter) - } - - // 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。 - if strings.TrimSpace(st.ExtractedTitle) == "" { - st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput) - } - - // 1.4 最后一定要做一轮本地时间硬校验。 - // 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间; - // 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。 - n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") - userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow) - if userHasTimeHint && userDeadlineErr != nil { - st.DeadlineValidationError = userDeadlineErr.Error() - st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。" - n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") - return st, nil - } - if userDeadline != nil { - st.ExtractedDeadline = userDeadline - st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) - } - return st, nil - } - - // 2. 常规路径:先做一次意图识别,再做本地时间硬校验。 - n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。") - parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput) - if callErr != nil { - // 2.1 这里不直接返回 error,而是把它视为“本次未能确认是 quick note”,交给上层回退普通聊天。 - st.IsQuickNoteIntent = false - st.IntentJudgeReason = "意图识别失败,回退普通聊天" - return st, nil - } - - st.IsQuickNoteIntent = parsed.IsQuickNote - st.IntentJudgeReason = strings.TrimSpace(parsed.Reason) - if !st.IsQuickNoteIntent { - return st, nil - } - - title := strings.TrimSpace(parsed.Title) - if title == "" { - title = strings.TrimSpace(st.UserInput) - } - st.ExtractedTitle = title - - n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") - - // 2.2 先尝试吃模型返回的 deadline_at,用于减少后续重复推理。 - st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt) - if st.ExtractedDeadlineText != "" { - if deadline, deadlineErr := ParseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil { - st.ExtractedDeadline = deadline - } - } - - // 2.3 再强制对用户原句做一次时间线索校验。 - userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow) - if userHasTimeHint && userDeadlineErr != nil { - st.DeadlineValidationError = userDeadlineErr.Error() - st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。" - n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") - return st, nil - } - - // 2.4 若模型没提到 deadline,但用户原句能解析出来,则以用户原句为准补齐。 - if st.ExtractedDeadline == nil && userDeadline != nil { - st.ExtractedDeadline = userDeadline - if st.ExtractedDeadlineText == "" { - st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) - } - } - return st, nil -} - -// Priority 负责“优先级评估”。 -// -// 职责边界: -// 1. 负责在 intent 节点之后补齐 priority_group; -// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型; -// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走; -// 4. 不负责写库。 -func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { - if st == nil { - return nil, errors.New("quick note graph: nil state in priority node") - } - if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { - return st, nil - } - - // 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。 - if agentmodel.IsValidTaskPriority(st.ExtractedPriority) { - if strings.TrimSpace(st.ExtractedPriorityReason) == "" { - st.ExtractedPriorityReason = "复用聚合规划优先级" - } - n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。") - return st, nil - } - - // 2. 单请求聚合路径若没有给出合法 priority,则直接走本地兜底,优先保证低时延。 - if n.input.SkipIntentVerification || st.PlannedBySingleCall { - st.ExtractedPriority = fallbackPriority(st) - st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底" - n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。") - return st, nil - } - - n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。") - deadlineText := "无" - if st.ExtractedDeadline != nil { - deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline) - } - deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText) - if deadlineClue == "" { - deadlineClue = "无" - } - - parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText) - if callErr != nil { - st.ExtractedPriority = fallbackPriority(st) - st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略" - return st, nil - } - if parsed == nil || !agentmodel.IsValidTaskPriority(parsed.PriorityGroup) { - st.ExtractedPriority = fallbackPriority(st) - st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略" - return st, nil - } - - st.ExtractedPriority = parsed.PriorityGroup - st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason) - if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" { - urgencyThreshold, thresholdErr := ParseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow) - if thresholdErr == nil { - st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline) - } - } - return st, nil -} - -// Persist 负责“调工具写库 + 有限次重试状态回填”。 -// -// 职责边界: -// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参; -// 2. 负责调用 createTaskTool 执行真正写库; -// 3. 负责把成功/失败结果回填到 state,供后续分支与回复使用; -// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。 -func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { - if st == nil { - return nil, errors.New("quick note graph: nil state in persist node") - } - if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { - return st, nil - } - - n.emitStage("quick_note.persisting", "正在写入任务数据。") - priority := st.ExtractedPriority - if !agentmodel.IsValidTaskPriority(priority) { - priority = fallbackPriority(st) - st.ExtractedPriority = priority - } - - deadlineText := "" - if st.ExtractedDeadline != nil { - deadlineText = st.ExtractedDeadline.In(QuickNoteLocation()).Format(time.RFC3339) - } - urgencyThresholdText := "" - if st.ExtractedUrgencyThreshold != nil { - urgencyThresholdText = st.ExtractedUrgencyThreshold.In(QuickNoteLocation()).Format(time.RFC3339) - } - - toolInput := QuickNoteCreateTaskToolInput{ - Title: st.ExtractedTitle, - PriorityGroup: priority, - DeadlineAt: deadlineText, - UrgencyThresholdAt: urgencyThresholdText, - } - rawInput, marshalErr := json.Marshal(toolInput) - if marshalErr != nil { - st.RecordToolError("构造工具参数失败: " + marshalErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。" - n.emitStage("quick_note.failed", "参数构造失败,未完成写入。") - } - return st, nil - } - - rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput)) - if invokeErr != nil { - st.RecordToolError(invokeErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" - n.emitStage("quick_note.failed", "多次重试后仍未完成写入。") - } - return st, nil - } - - toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput) - if parseErr != nil { - st.RecordToolError("解析工具返回失败: " + parseErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。" - n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。") - } - return st, nil - } - if toolOutput.TaskID <= 0 { - st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID)) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。" - n.emitStage("quick_note.failed", "写入结果缺少有效 task_id,已终止成功回包。") - } - return st, nil - } - - // 1. 只有拿到有效 task_id,才视为真正写入成功; - // 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。 - st.RecordToolSuccess(toolOutput.TaskID) - if strings.TrimSpace(toolOutput.Title) != "" { - st.ExtractedTitle = strings.TrimSpace(toolOutput.Title) - } - if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) { - st.ExtractedPriority = toolOutput.PriorityGroup - } - - reply := strings.TrimSpace(toolOutput.Message) - if reply == "" { - reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority)) - } - st.AssistantReply = reply - n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。") - return st, nil -} - -type quickNotePlannedResult struct { - Title string - Deadline *time.Time - DeadlineText string - UrgencyThreshold *time.Time - UrgencyThresholdText string - PriorityGroup int - PriorityReason string - Banter string -} - -// planQuickNoteInSingleCall 在一次模型调用里完成“时间 / 优先级 / banter”聚合规划。 -func planQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText string, now time.Time, userInput string) (*quickNotePlannedResult, error) { - parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput) - if err != nil { - return nil, err - } - - result := &quickNotePlannedResult{ - Title: strings.TrimSpace(parsed.Title), - DeadlineText: strings.TrimSpace(parsed.DeadlineAt), - UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt), - PriorityGroup: parsed.PriorityGroup, - PriorityReason: strings.TrimSpace(parsed.PriorityReason), - Banter: strings.TrimSpace(parsed.Banter), - } - if result.Banter != "" { - if idx := strings.Index(result.Banter, "\n"); idx >= 0 { - result.Banter = strings.TrimSpace(result.Banter[:idx]) - } - } - if result.DeadlineText != "" { - if deadline, deadlineErr := ParseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil { - result.Deadline = deadline - } - } - if result.UrgencyThresholdText != "" { - if urgencyThreshold, thresholdErr := ParseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil { - result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline) - } - } - return result, nil -} - -func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time { - if threshold == nil { - return nil - } - if deadline == nil { - return threshold - } - if threshold.After(*deadline) { - normalized := *deadline - return &normalized - } - return threshold -} - -func fallbackPriority(st *agentmodel.QuickNoteState) int { - if st == nil { - return agentmodel.QuickNotePrioritySimpleNotImportant - } - if st.ExtractedDeadline != nil { - if time.Until(*st.ExtractedDeadline) <= 48*time.Hour { - return agentmodel.QuickNotePriorityImportantUrgent - } - return agentmodel.QuickNotePriorityImportantNotUrgent - } - return agentmodel.QuickNotePrioritySimpleNotImportant -} - -// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。 -func deriveQuickNoteTitleFromInput(userInput string) string { - text := strings.TrimSpace(userInput) - if text == "" { - return "这条任务" - } - - prefixes := []string{ - "请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一个", "记个", "帮我记一个", - } - for _, prefix := range prefixes { - if strings.HasPrefix(text, prefix) { - text = strings.TrimSpace(strings.TrimPrefix(text, prefix)) - break - } - } - - suffixSeparators := []string{ - ",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得", - } - for _, sep := range suffixSeparators { - if idx := strings.Index(text, sep); idx > 0 { - text = strings.TrimSpace(text[:idx]) - break - } - } - - text = strings.Trim(text, ",。?!!? ") - if text == "" { - return strings.TrimSpace(userInput) - } - return text -} diff --git a/backend/agent/node/quicknote_tool.go b/backend/agent/node/quicknote_tool.go deleted file mode 100644 index 2f628e6..0000000 --- a/backend/agent/node/quicknote_tool.go +++ /dev/null @@ -1,589 +0,0 @@ -package agentnode - -import ( - "context" - "errors" - "fmt" - "regexp" - "strconv" - "strings" - "time" - - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentshared "github.com/LoveLosita/smartflow/backend/agent/shared" - "github.com/cloudwego/eino/components/tool" - toolutils "github.com/cloudwego/eino/components/tool/utils" - "github.com/cloudwego/eino/schema" -) - -const ( - // ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的稳定名称。 - ToolNameQuickNoteCreateTask = "quick_note_create_task" - // ToolDescQuickNoteCreateTask 是给大模型看的工具职责说明。 - ToolDescQuickNoteCreateTask = "把用户随口提到的事项落库为任务,支持可选截止时间与优先级" -) - -var ( - quickNoteDeadlineLayouts = []string{ - time.RFC3339, - "2006-01-02T15:04", - "2006-01-02 15:04:05", - "2006-01-02 15:04", - "2006/01/02 15:04:05", - "2006/01/02 15:04", - "2006.01.02 15:04:05", - "2006.01.02 15:04", - "2006-01-02", - "2006/01/02", - "2006.01.02", - } - quickNoteDateOnlyLayouts = map[string]struct{}{ - "2006-01-02": {}, - "2006/01/02": {}, - "2006.01.02": {}, - } - - quickNoteClockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[::]\s*(\d{1,2})`) - quickNoteClockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`) - quickNoteYMDRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`) - quickNoteMDRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`) - quickNoteDateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`) - quickNoteWeekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`) - quickNoteRelativeTokens = []string{ - "今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日", - "早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨", - } -) - -// QuickNoteToolDeps 描述随口记工具所需的外部依赖。 -type QuickNoteToolDeps struct { - ResolveUserID func(ctx context.Context) (int, error) - CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error) -} - -func (d QuickNoteToolDeps) Validate() error { - if d.ResolveUserID == nil { - return errors.New("quick note tool deps: ResolveUserID is nil") - } - if d.CreateTask == nil { - return errors.New("quick note tool deps: CreateTask is nil") - } - return nil -} - -// QuickNoteToolBundle 是随口记工具集合。 -type QuickNoteToolBundle struct { - Tools []tool.BaseTool - ToolInfos []*schema.ToolInfo -} - -// QuickNoteCreateTaskRequest 是工具层传给业务层的内部请求。 -type QuickNoteCreateTaskRequest struct { - UserID int - Title string - PriorityGroup int - DeadlineAt *time.Time - UrgencyThresholdAt *time.Time -} - -// QuickNoteCreateTaskResult 是业务层回给工具层的结构化结果。 -type QuickNoteCreateTaskResult struct { - TaskID int - Title string - PriorityGroup int - DeadlineAt *time.Time - UrgencyThresholdAt *time.Time -} - -// QuickNoteCreateTaskToolInput 是暴露给模型的工具入参。 -type QuickNoteCreateTaskToolInput struct { - Title string `json:"title" jsonschema:"required,description=任务标题,简洁明确"` - // PriorityGroup 与 tasks.priority 保持一致,取值 1~4。 - PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4复杂不重要)"` - // DeadlineAt 支持绝对时间与常见中文相对时间。 - DeadlineAt string `json:"deadline_at,omitempty" jsonschema:"description=可选截止时间,支持RFC3339、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd HH:mm 以及常见中文相对时间"` - // UrgencyThresholdAt 表示何时自动进入紧急象限。 - UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty" jsonschema:"description=可选紧急分界时间,支持与deadline_at相同格式"` -} - -// QuickNoteCreateTaskToolOutput 是返回给模型的结构化结果。 -type QuickNoteCreateTaskToolOutput struct { - TaskID int `json:"task_id"` - Title string `json:"title"` - PriorityGroup int `json:"priority_group"` - PriorityLabel string `json:"priority_label"` - DeadlineAt string `json:"deadline_at,omitempty"` - Message string `json:"message"` -} - -// BuildQuickNoteToolBundle 构建随口记工具包。 -func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) { - if err := deps.Validate(); err != nil { - return nil, err - } - - createTaskTool, err := toolutils.InferTool( - ToolNameQuickNoteCreateTask, - ToolDescQuickNoteCreateTask, - func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) { - if input == nil { - return nil, errors.New("工具参数不能为空") - } - - title := strings.TrimSpace(input.Title) - if title == "" { - return nil, errors.New("title 不能为空") - } - if !agentmodel.IsValidTaskPriority(input.PriorityGroup) { - return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup) - } - - deadline, err := ParseOptionalDeadline(input.DeadlineAt) - if err != nil { - return nil, err - } - urgencyThresholdAt, err := ParseOptionalDeadline(input.UrgencyThresholdAt) - if err != nil { - return nil, err - } - - userID, err := deps.ResolveUserID(ctx) - if err != nil { - return nil, fmt.Errorf("解析用户身份失败: %w", err) - } - if userID <= 0 { - return nil, fmt.Errorf("非法 user_id=%d", userID) - } - - result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{ - UserID: userID, - Title: title, - PriorityGroup: input.PriorityGroup, - DeadlineAt: deadline, - UrgencyThresholdAt: urgencyThresholdAt, - }) - if err != nil { - return nil, err - } - if result == nil || result.TaskID <= 0 { - return nil, errors.New("写入任务后返回结果异常") - } - - finalTitle := title - if strings.TrimSpace(result.Title) != "" { - finalTitle = strings.TrimSpace(result.Title) - } - finalPriority := input.PriorityGroup - if agentmodel.IsValidTaskPriority(result.PriorityGroup) { - finalPriority = result.PriorityGroup - } - - deadlineStr := "" - if result.DeadlineAt != nil { - deadlineStr = result.DeadlineAt.In(QuickNoteLocation()).Format(time.RFC3339) - } else if deadline != nil { - deadlineStr = deadline.In(QuickNoteLocation()).Format(time.RFC3339) - } - - return &QuickNoteCreateTaskToolOutput{ - TaskID: result.TaskID, - Title: finalTitle, - PriorityGroup: finalPriority, - PriorityLabel: agentmodel.PriorityLabelCN(finalPriority), - DeadlineAt: deadlineStr, - Message: fmt.Sprintf("已记录:%s(%s)", finalTitle, agentmodel.PriorityLabelCN(finalPriority)), - }, nil - }, - ) - if err != nil { - return nil, fmt.Errorf("构建随口记工具失败: %w", err) - } - - tools := []tool.BaseTool{createTaskTool} - infos, err := collectToolInfos(ctx, tools) - if err != nil { - return nil, err - } - - return &QuickNoteToolBundle{ - Tools: tools, - ToolInfos: infos, - }, nil -} - -// GetInvokableToolByName 通过工具名提取可执行工具实例。 -func GetInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) { - if bundle == nil { - return nil, errors.New("tool bundle is nil") - } - return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name) -} - -// ParseOptionalDeadline 解析工具输入中的可选截止时间。 -// 调用目的:新链路 quick_note_create 工具复用旧链路成熟的时间解析能力,支持中文相对时间。 -func ParseOptionalDeadline(raw string) (*time.Time, error) { - value := normalizeDeadlineInput(raw) - if value == "" { - return nil, nil - } - - deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute()) - if err != nil { - return nil, err - } - if deadline == nil { - if !hasHint { - return nil, fmt.Errorf("deadline_at 格式不支持: %s", value) - } - return nil, fmt.Errorf("deadline_at 无法解析: %s", value) - } - return deadline, nil -} - -// ParseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。 -// 调用目的:旧链路 intent/priority 节点在已知 now 基准下解析时间,供新链路复用。 -func ParseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) { - value := normalizeDeadlineInput(raw) - if value == "" { - return nil, nil - } - - deadline, _, err := parseOptionalDeadlineFromText(value, now) - if err != nil { - return nil, err - } - if deadline == nil { - return nil, fmt.Errorf("deadline_at 格式不支持: %s", value) - } - return deadline, nil -} - -// parseOptionalDeadlineFromUserInput 是“用户原句解析”的宽松入口。 -func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) { - value := normalizeDeadlineInput(raw) - if value == "" { - return nil, false, nil - } - - deadline, hasHint, err := parseOptionalDeadlineFromText(value, now) - if err != nil { - if hasHint { - return nil, true, err - } - return nil, false, nil - } - if deadline == nil { - if hasHint { - return nil, true, fmt.Errorf("deadline_at 无法解析: %s", value) - } - return nil, false, nil - } - return deadline, true, nil -} - -// parseOptionalDeadlineFromText 是内部通用时间解析器。 -func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) { - if strings.TrimSpace(value) == "" { - return nil, false, nil - } - - loc := QuickNoteLocation() - now = now.In(loc) - hasHint := hasDeadlineHint(value) - - if abs, ok := tryParseAbsoluteDeadline(value, loc); ok { - return abs, true, nil - } - if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized { - if err != nil { - return nil, true, err - } - return rel, true, nil - } - if hasHint { - return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value) - } - return nil, false, nil -} - -func normalizeDeadlineInput(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "" - } - replacer := strings.NewReplacer( - ":", ":", - ",", ",", - "。", ".", - " ", " ", - ) - return strings.TrimSpace(replacer.Replace(trimmed)) -} - -func hasDeadlineHint(value string) bool { - if quickNoteClockHMRegex.MatchString(value) || - quickNoteClockCNRegex.MatchString(value) || - quickNoteYMDRegex.MatchString(value) || - quickNoteMDRegex.MatchString(value) || - quickNoteDateSepRegex.MatchString(value) || - quickNoteWeekdayRegex.MatchString(value) { - return true - } - for _, token := range quickNoteRelativeTokens { - if strings.Contains(value, token) { - return true - } - } - return false -} - -func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) { - for _, layout := range quickNoteDeadlineLayouts { - var ( - parsed time.Time - err error - ) - if layout == time.RFC3339 { - parsed, err = time.Parse(layout, value) - if err == nil { - parsed = parsed.In(loc) - } - } else { - parsed, err = time.ParseInLocation(layout, value, loc) - } - if err != nil { - continue - } - - if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly { - parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc) - } else { - parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc) - } - return &parsed, true - } - return nil, false -} - -func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) { - baseDate, recognized := inferBaseDate(value, now, loc) - if !recognized { - return nil, false, nil - } - - hour, minute, hasExplicitClock, err := extractClock(value) - if err != nil { - return nil, true, err - } - if !hasExplicitClock { - hour, minute = defaultClockByHint(value) - } - - deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc) - return &deadline, true, nil -} - -func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) { - if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 { - year, _ := strconv.Atoi(matched[1]) - month, _ := strconv.Atoi(matched[2]) - day, _ := strconv.Atoi(matched[3]) - if isValidDate(year, month, day) { - return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true - } - } - - if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 { - month, _ := strconv.Atoi(matched[1]) - day, _ := strconv.Atoi(matched[2]) - year := now.Year() - if !isValidDate(year, month, day) { - return time.Time{}, false - } - candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) - if candidate.Before(startOfDay(now)) { - year++ - if !isValidDate(year, month, day) { - return time.Time{}, false - } - candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) - } - return candidate, true - } - - if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 { - prefix := matched[1] - target, ok := toWeekday(matched[2]) - if ok { - return resolveWeekdayDate(now, prefix, target), true - } - } - - today := startOfDay(now) - switch { - case strings.Contains(value, "大后天"): - return today.AddDate(0, 0, 3), true - case strings.Contains(value, "后天"): - return today.AddDate(0, 0, 2), true - case strings.Contains(value, "明天") || strings.Contains(value, "明日"): - return today.AddDate(0, 0, 1), true - case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"): - return today, true - case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"): - return today.AddDate(0, 0, -1), true - default: - return time.Time{}, false - } -} - -func extractClock(value string) (int, int, bool, error) { - hour := 0 - minute := 0 - hasClock := false - - if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 { - h, errH := strconv.Atoi(matched[1]) - m, errM := strconv.Atoi(matched[2]) - if errH != nil || errM != nil { - return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) - } - hour = h - minute = m - hasClock = true - } else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 { - h, errH := strconv.Atoi(matched[1]) - if errH != nil { - return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) - } - hour = h - minute = 0 - hasClock = true - if len(matched) >= 3 { - if matched[2] == "半" { - minute = 30 - } else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" { - m, errM := strconv.Atoi(strings.TrimSpace(matched[3])) - if errM != nil { - return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) - } - minute = m - } - } - } - - if !hasClock { - return 0, 0, false, nil - } - - if isPMHint(value) && hour < 12 { - hour += 12 - } - if isNoonHint(value) && hour >= 1 && hour <= 10 { - hour += 12 - } - if strings.Contains(value, "凌晨") && hour == 12 { - hour = 0 - } - - if hour < 0 || hour > 23 || minute < 0 || minute > 59 { - return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value) - } - return hour, minute, true, nil -} - -func defaultClockByHint(value string) (int, int) { - switch { - case strings.Contains(value, "凌晨"): - return 1, 0 - case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"): - return 9, 0 - case strings.Contains(value, "中午"): - return 12, 0 - case strings.Contains(value, "下午"): - return 15, 0 - case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"): - return 20, 0 - default: - return 23, 59 - } -} - -func isPMHint(value string) bool { - return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") -} - -func isNoonHint(value string) bool { - return strings.Contains(value, "中午") -} - -func startOfDay(t time.Time) time.Time { - loc := t.Location() - return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) -} - -func isValidDate(year, month, day int) bool { - if month < 1 || month > 12 || day < 1 || day > 31 { - return false - } - candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) - return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day -} - -func toWeekday(chinese string) (time.Weekday, bool) { - switch chinese { - case "一": - return time.Monday, true - case "二": - return time.Tuesday, true - case "三": - return time.Wednesday, true - case "四": - return time.Thursday, true - case "五": - return time.Friday, true - case "六": - return time.Saturday, true - case "日", "天": - return time.Sunday, true - default: - return time.Sunday, false - } -} - -func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time { - today := startOfDay(now) - weekdayOffset := (int(today.Weekday()) + 6) % 7 - weekStart := today.AddDate(0, 0, -weekdayOffset) - targetOffset := (int(target) + 6) % 7 - candidateThisWeek := weekStart.AddDate(0, 0, targetOffset) - - switch { - case strings.HasPrefix(prefix, "下"): - return candidateThisWeek.AddDate(0, 0, 7) - case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"): - return candidateThisWeek - default: - if candidateThisWeek.Before(today) { - return candidateThisWeek.AddDate(0, 0, 7) - } - return candidateThisWeek - } -} - -// QuickNoteLocation 返回随口记使用的时区(Asia/Shanghai)。 -// 调用目的:新链路 quick_note_create 工具格式化时间输出时复用。 -func QuickNoteLocation() *time.Location { - loc, err := time.LoadLocation(agentmodel.QuickNoteTimezoneName) - if err != nil { - return time.Local - } - return loc -} - -func quickNoteNowToMinute() time.Time { - return agentshared.NowToMinute() -} - -func formatQuickNoteTimeToMinute(t time.Time) string { - return agentshared.FormatMinute(t.In(QuickNoteLocation())) -} diff --git a/backend/agent/node/schedule_plan.go b/backend/agent/node/schedule_plan.go deleted file mode 100644 index 1571a6c..0000000 --- a/backend/agent/node/schedule_plan.go +++ /dev/null @@ -1,2336 +0,0 @@ -package agentnode - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - agentllm "github.com/LoveLosita/smartflow/backend/agent/llm" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt" - agentshared "github.com/LoveLosita/smartflow/backend/agent/shared" - "github.com/LoveLosita/smartflow/backend/model" - "github.com/cloudwego/eino-ext/components/model/ark" - "github.com/cloudwego/eino/schema" -) - -const ( - // SchedulePlanGraphNodePlan 是“识别排程意图与约束”的节点名。 - SchedulePlanGraphNodePlan = "schedule_plan_plan" - // SchedulePlanGraphNodeRoughBuild 是“粗排构建”的节点名。 - SchedulePlanGraphNodeRoughBuild = "schedule_plan_rough_build" - // SchedulePlanGraphNodeExit 是“提前退出”的节点名。 - SchedulePlanGraphNodeExit = "schedule_plan_exit" - // SchedulePlanGraphNodeDailySplit 是“按天拆分”的节点名。 - SchedulePlanGraphNodeDailySplit = "schedule_plan_daily_split" - // SchedulePlanGraphNodeQuickRefine 是“小改动快速微调”的节点名。 - SchedulePlanGraphNodeQuickRefine = "schedule_plan_quick_refine" - // SchedulePlanGraphNodeDailyRefine 是“并发日内优化”的节点名。 - SchedulePlanGraphNodeDailyRefine = "schedule_plan_daily_refine" - // SchedulePlanGraphNodeMerge 是“合并日内优化结果”的节点名。 - SchedulePlanGraphNodeMerge = "schedule_plan_merge" - // SchedulePlanGraphNodeWeeklyRefine 是“周级配平优化”的节点名。 - SchedulePlanGraphNodeWeeklyRefine = "schedule_plan_weekly_refine" - // SchedulePlanGraphNodeFinalCheck 是“终审校验”的节点名。 - SchedulePlanGraphNodeFinalCheck = "schedule_plan_final_check" - // SchedulePlanGraphNodeReturnPreview 是“返回预览结果”的节点名。 - SchedulePlanGraphNodeReturnPreview = "schedule_plan_return_preview" -) - -const ( - schedulePlanGraphNodePlan = SchedulePlanGraphNodePlan - schedulePlanGraphNodeRoughBuild = SchedulePlanGraphNodeRoughBuild - schedulePlanGraphNodeExit = SchedulePlanGraphNodeExit - schedulePlanGraphNodeDailySplit = SchedulePlanGraphNodeDailySplit - schedulePlanGraphNodeQuickRefine = SchedulePlanGraphNodeQuickRefine - schedulePlanGraphNodeDailyRefine = SchedulePlanGraphNodeDailyRefine - schedulePlanGraphNodeMerge = SchedulePlanGraphNodeMerge - schedulePlanGraphNodeWeeklyRefine = SchedulePlanGraphNodeWeeklyRefine - schedulePlanGraphNodeFinalCheck = SchedulePlanGraphNodeFinalCheck - schedulePlanGraphNodeReturnPreview = SchedulePlanGraphNodeReturnPreview -) - -const ( - schedulePlanDefaultDailyRefineConcurrency = agentmodel.SchedulePlanDefaultDailyRefineConcurrency - schedulePlanDefaultWeeklyAdjustBudget = agentmodel.SchedulePlanDefaultWeeklyAdjustBudget - schedulePlanDefaultWeeklyTotalBudget = agentmodel.SchedulePlanDefaultWeeklyTotalBudget - schedulePlanDefaultWeeklyRefineConcurrency = agentmodel.SchedulePlanDefaultWeeklyRefineConcurrency - schedulePlanAdjustmentScopeSmall = agentmodel.SchedulePlanAdjustmentScopeSmall - schedulePlanAdjustmentScopeMedium = agentmodel.SchedulePlanAdjustmentScopeMedium - schedulePlanAdjustmentScopeLarge = agentmodel.SchedulePlanAdjustmentScopeLarge -) - -type ( - // SchedulePlanState 是 node 层对排程状态的本地别名。 - // 这样做的目的,是让节点文件在迁移期保持旧逻辑可读,不需要把每个类型都写成长前缀。 - SchedulePlanState = agentmodel.SchedulePlanState - // DayGroup 是按天拆分后的最小优化单元别名。 - DayGroup = agentmodel.DayGroup -) - -// SchedulePlanGraphRunInput 是执行“智能排程 graph”所需输入。 -// -// 字段说明: -// 1. Extra:前端附加参数(重点是 task_class_ids); -// 2. ChatHistory:支持连续对话微调; -// 3. OutChan/ModelName:保留兼容字段(当前 weekly refine 主要输出阶段状态); -// 4. DailyRefineConcurrency/WeeklyAdjustBudget:可选运行参数覆盖。 -type SchedulePlanGraphRunInput struct { - Model *ark.ChatModel - State *agentmodel.SchedulePlanState - Deps SchedulePlanToolDeps - UserMessage string - Extra map[string]any - ChatHistory []*schema.Message - EmitStage func(stage, detail string) - - OutChan chan<- string - ModelName string - - DailyRefineConcurrency int - WeeklyAdjustBudget int -} - -// SchedulePlanNodes 是“首次排程”图的节点容器。 -// -// 职责边界: -// 1. 负责收口请求级依赖(model / extra / history / stage emitter); -// 2. 负责向 graph 层暴露可直接挂载的方法; -// 3. 不负责 graph 编译,也不负责 service 层接线。 -type SchedulePlanNodes struct { - input SchedulePlanGraphRunInput - emitStage func(stage, detail string) -} - -// NewSchedulePlanNodes 创建排程节点容器。 -// -// 职责边界: -// 1. 负责校验“图运行的最小依赖”是否齐全; -// 2. 负责把空的阶段回调收敛成 no-op,避免节点内部到处判空; -// 3. 不负责调整 state 业务字段,state 预处理由 graph 层完成。 -func NewSchedulePlanNodes(input SchedulePlanGraphRunInput) (*SchedulePlanNodes, error) { - if input.Model == nil { - return nil, errors.New("schedule plan nodes: model is nil") - } - if input.State == nil { - return nil, errors.New("schedule plan nodes: state is nil") - } - if err := input.Deps.Validate(); err != nil { - return nil, err - } - - emitStage := input.EmitStage - if emitStage == nil { - emitStage = func(stage, detail string) {} - } - return &SchedulePlanNodes{ - input: input, - emitStage: emitStage, - }, nil -} - -// Plan 负责承接“排程意图分析”节点。 -func (n *SchedulePlanNodes) Plan(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runPlanNode(ctx, st, n.input.Model, n.input.UserMessage, n.input.Extra, n.input.ChatHistory, n.emitStage) -} - -// RoughBuild 负责承接“粗排构建”节点。 -func (n *SchedulePlanNodes) RoughBuild(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runRoughBuildNode(ctx, st, n.input.Deps, n.emitStage) -} - -// DailySplit 负责承接“按天拆分”节点。 -func (n *SchedulePlanNodes) DailySplit(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runDailySplitNode(ctx, st, n.emitStage) -} - -// QuickRefine 负责承接“小改动快速微调”节点。 -func (n *SchedulePlanNodes) QuickRefine(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runQuickRefineNode(ctx, st, n.emitStage) -} - -// DailyRefine 负责承接“并发日内优化”节点。 -func (n *SchedulePlanNodes) DailyRefine(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runDailyRefineNode(ctx, st, n.input.Model, n.input.DailyRefineConcurrency, n.emitStage) -} - -// Merge 负责承接“合并日内优化结果”节点。 -func (n *SchedulePlanNodes) Merge(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runMergeNode(ctx, st, n.emitStage) -} - -// WeeklyRefine 负责承接“周级配平优化”节点。 -func (n *SchedulePlanNodes) WeeklyRefine(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runWeeklyRefineNode(ctx, st, n.input.Model, n.input.OutChan, n.input.ModelName, n.emitStage) -} - -// FinalCheck 负责承接“终审校验”节点。 -func (n *SchedulePlanNodes) FinalCheck(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runFinalCheckNode(ctx, st, n.input.Model, n.emitStage) -} - -// ReturnPreview 负责承接“生成结构化预览输出”节点。 -func (n *SchedulePlanNodes) ReturnPreview(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - return runReturnPreviewNode(ctx, st, n.emitStage) -} - -// Exit 是图中的显式退出节点。 -// -// 职责边界: -// 1. 只作为图收口占位,保持状态原样透传; -// 2. 不做额外副作用,避免“退出节点偷偷改状态”。 -func (n *SchedulePlanNodes) Exit(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) { - _ = ctx - return st, nil -} - -// NextAfterPlan 根据 plan 节点结果决定下一步。 -func (n *SchedulePlanNodes) NextAfterPlan(ctx context.Context, st *agentmodel.SchedulePlanState) (string, error) { - _ = ctx - return selectNextAfterPlan(st), nil -} - -// NextAfterRoughBuild 根据粗排构建结果决定后续路径。 -// -// 规则: -// 1. 没有可优化条目 -> exit; -// 2. 连续微调且判定为 small -> quickRefine; -// 3. 连续微调且判定为 medium -> weeklyRefine; -// 4. large 或非微调:多任务类走 dailySplit,单任务类直达 weeklyRefine。 -func (n *SchedulePlanNodes) NextAfterRoughBuild(ctx context.Context, st *agentmodel.SchedulePlanState) (string, error) { - _ = ctx - if st == nil || len(st.HybridEntries) == 0 { - return SchedulePlanGraphNodeExit, nil - } - if st.IsAdjustment && st.AdjustmentScope == schedulePlanAdjustmentScopeSmall { - return SchedulePlanGraphNodeQuickRefine, nil - } - if st.IsAdjustment && st.AdjustmentScope == schedulePlanAdjustmentScopeMedium { - return SchedulePlanGraphNodeWeeklyRefine, nil - } - if len(st.TaskClassIDs) >= 2 { - return SchedulePlanGraphNodeDailySplit, nil - } - return SchedulePlanGraphNodeWeeklyRefine, nil -} - -// normalizeAdjustmentScope 统一把微调力度归一化到 small/medium/large。 -// -// 调用目的: -// 1. 旧 scheduleplan 节点逻辑已经大量直接调用这个函数名; -// 2. 迁到 Agent 后,这里保留同名收口,避免节点层到处散落包前缀; -// 3. 真正的归一化规则仍以下层 model 层为准,避免多处维护。 -func normalizeAdjustmentScope(raw string) string { - return agentmodel.NormalizeSchedulePlanAdjustmentScope(raw) -} - -// schedulePlanIntentOutput 是 plan 节点要求模型返回的结构化结果。 -// -// 兼容说明: -// 1. 新主语义是 task_class_ids(数组); -// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id(单值)兜底解析; -// 3. TaskTags 的 key 兼容两种写法: -// 3.1 推荐:task_item_id(例如 "12"); -// 3.2 兼容:任务名称(例如 "高数复习")。 -type schedulePlanIntentOutput = agentllm.ScheduleIntentOutput - -// runPlanNode 负责“识别排程意图 + 提取约束 + 收敛任务类 ID”。 -// -// 职责边界: -// 1. 负责把用户自然语言和 extra 参数收敛为统一状态; -// 2. 负责输出后续节点需要的最小上下文(TaskClassIDs/约束/策略/标签); -// 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") - } - st.RestartRequested = false - st.AdjustmentReason = "" - st.AdjustmentConfidence = 0 - st.AdjustmentScope = schedulePlanAdjustmentScopeLarge - - emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求。") - - // 1. 先收敛 extra 中显式传入的任务类 ID(优先级高于模型推断)。 - // 1.1 先读 task_class_ids 数组; - // 1.2 再兼容读取单值 task_class_id; - // 1.3 最后统一做过滤 + 去重,防止非法值或重复值污染状态机。 - if extra != nil { - mergedIDs := make([]int, 0, len(st.TaskClassIDs)+2) - mergedIDs = append(mergedIDs, st.TaskClassIDs...) - if tcIDs, ok := ExtraIntSlice(extra, "task_class_ids"); ok { - mergedIDs = append(mergedIDs, tcIDs...) - } - if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 { - mergedIDs = append(mergedIDs, tcID) - } - st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs) - } - // 1.4 若本轮请求没带 task_class_ids,但会话里存在上一次排程快照,则用快照中的任务类兜底。 - // 1.4.1 这样用户可以直接说“把周三晚上的高数挪到周五”,无需每轮都重复传任务类集合; - // 1.4.2 失败兜底:若快照也没有任务类,后续按原逻辑处理(可能提前退出并提示补参)。 - if len(st.TaskClassIDs) == 0 && len(st.PreviousTaskClassIDs) > 0 { - st.TaskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...)) - } - - // 2. 识别“是否为连续对话微调”场景。 - // 2.1 只做历史探测,不做历史改写; - // 2.2 探测失败不影响主链路,只是少一个 prompt hint。 - if st.HasPreviousPreview && len(st.PreviousHybridEntries) > 0 { - st.IsAdjustment = true - st.AdjustmentScope = schedulePlanAdjustmentScopeMedium - } - previousPlan := extractPreviousPlanFromHistory(chatHistory) - if previousPlan != "" { - st.PreviousPlanJSON = previousPlan - st.IsAdjustment = true - st.AdjustmentScope = schedulePlanAdjustmentScopeMedium - } - - // 3. 组装模型提示词。 - adjustmentHint := "" - if st.IsAdjustment { - adjustmentHint = "\n注意:这是对已有排程的微调请求,请重点抽取本次新增或变更的约束。" - } - prompt := fmt.Sprintf( - "当前时间(北京时间):%s\n用户输入:%s%s\n\n请提取排程意图与约束。", - st.RequestNowText, - strings.TrimSpace(userMessage), - adjustmentHint, - ) - - // 4. 调模型拿结构化输出。 - // 4.1 如果失败但已经有 TaskClassIDs,则降级继续; - // 4.2 如果失败且没有任务类 ID,直接给出可执行错误提示。 - raw, callErr := callScheduleModelForJSON(ctx, chatModel, agentprompt.SchedulePlanIntentPrompt, prompt, 256) - if callErr != nil { - if len(st.TaskClassIDs) > 0 { - st.UserIntent = strings.TrimSpace(userMessage) - emitStage("schedule_plan.plan.fallback", "意图识别失败,已使用请求参数兜底继续。") - return st, nil - } - st.FinalSummary = "抱歉,我没拿到有效的任务类信息。请在请求中传入 task_class_ids。" - return st, nil - } - - parsed, parseErr := parseScheduleJSON[schedulePlanIntentOutput](raw) - if parseErr != nil { - if len(st.TaskClassIDs) > 0 { - st.UserIntent = strings.TrimSpace(userMessage) - emitStage("schedule_plan.plan.fallback", "模型返回解析失败,已使用请求参数兜底继续。") - return st, nil - } - st.FinalSummary = "抱歉,我没能解析排程意图。请重试,或直接传入 task_class_ids。" - return st, nil - } - - // 5. 回填基础字段。 - st.UserIntent = strings.TrimSpace(parsed.Intent) - if st.UserIntent == "" { - st.UserIntent = strings.TrimSpace(userMessage) - } - if len(parsed.Constraints) > 0 { - st.Constraints = parsed.Constraints - } - if strings.EqualFold(strings.TrimSpace(parsed.Strategy), "rapid") { - st.Strategy = "rapid" - } - st.RestartRequested = parsed.Restart - st.AdjustmentScope = normalizeAdjustmentScope(parsed.AdjustmentScope) - st.AdjustmentReason = strings.TrimSpace(parsed.Reason) - st.AdjustmentConfidence = clampAdjustmentConfidence(parsed.Confidence) - - // 5.1 分级语义兜底: - // 5.1.1 非微调请求不走 small/medium,强制按 large 进入完整排程; - // 5.1.2 微调请求默认至少走 medium,避免 scope 缺失时误判; - // 5.1.3 restart=true 时强制重排并清空历史快照承接。 - if !st.IsAdjustment { - st.AdjustmentScope = schedulePlanAdjustmentScopeLarge - } else if st.AdjustmentScope == "" { - st.AdjustmentScope = schedulePlanAdjustmentScopeMedium - } - if st.RestartRequested { - st.IsAdjustment = false - st.AdjustmentScope = schedulePlanAdjustmentScopeLarge - clearPreviousPreviewContext(st) - } - - // 6. 合并任务类 ID(新字段 + 旧字段双兼容)。 - // 6.1 先拼接已有值与模型输出; - // 6.2 再统一清洗,保证后续节点使用稳定语义。 - mergedIDs := make([]int, 0, len(st.TaskClassIDs)+len(parsed.TaskClassIDs)+1) - mergedIDs = append(mergedIDs, st.TaskClassIDs...) - mergedIDs = append(mergedIDs, parsed.TaskClassIDs...) - if parsed.TaskClassID > 0 { - mergedIDs = append(mergedIDs, parsed.TaskClassID) - } - st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs) - - // 7. 回填任务标签映射(给 daily_split 注入 context_tag 用)。 - // 7.1 TaskTags(按 task_item_id)优先; - // 7.2 无法转成 ID 的 key 先存到 TaskTagHintsByName,等 roughBuild 阶段再映射; - // 7.3 单条标签解析失败不影响主流程。 - if st.TaskTags == nil { - st.TaskTags = make(map[int]string) - } - if st.TaskTagHintsByName == nil { - st.TaskTagHintsByName = make(map[string]string) - } - for rawKey, rawTag := range parsed.TaskTags { - tag := normalizeContextTag(rawTag) - key := strings.TrimSpace(rawKey) - if key == "" { - continue - } - if id, convErr := strconv.Atoi(key); convErr == nil && id > 0 { - st.TaskTags[id] = tag - continue - } - st.TaskTagHintsByName[key] = tag - } - - emitStage( - "schedule_plan.plan.done", - fmt.Sprintf( - "已识别排程意图,任务类数量=%d,微调=%t,力度=%s,重排=%t。", - len(st.TaskClassIDs), - st.IsAdjustment, - st.AdjustmentScope, - st.RestartRequested, - ), - ) - return st, nil -} - -// selectNextAfterPlan 根据 plan 节点结果决定下一步。 -// -// 分支规则: -// 1. 如果 FinalSummary 已经有内容,说明已确定要提前退出 -> exit; -// 2. 如果任务类为空,说明无法继续构建方案 -> exit; -// 3. 其余情况 -> roughBuild。 -func selectNextAfterPlan(st *SchedulePlanState) string { - if st == nil { - return schedulePlanGraphNodeExit - } - if strings.TrimSpace(st.FinalSummary) != "" { - return schedulePlanGraphNodeExit - } - if len(st.TaskClassIDs) == 0 { - return schedulePlanGraphNodeExit - } - return schedulePlanGraphNodeRoughBuild -} - -// runRoughBuildNode 负责“一次性完成粗排结果构建”。 -// -// 职责边界: -// 1. 调用多任务类混排能力,生成 HybridEntries + AllocatedItems; -// 2. 把 HybridEntries 转成 CandidatePlans,便于后续预览输出; -// 3. 不做 daily/weekly 优化本身,只提供下游输入。 -func runRoughBuildNode( - ctx context.Context, - st *SchedulePlanState, - deps SchedulePlanToolDeps, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - if st == nil { - return nil, errors.New("schedule plan graph: nil state in roughBuild node") - } - if deps.HybridScheduleWithPlanMulti == nil { - return nil, errors.New("schedule plan graph: HybridScheduleWithPlanMulti dependency not injected") - } - - // 1. 清洗并校验任务类 ID。 - // 1.1 统一在节点入口做一次最终收敛,避免上游遗漏导致语义漂移; - // 1.2 若最终仍为空,直接结束,避免无意义调用下游服务。 - taskClassIDs := normalizeTaskClassIDs(st.TaskClassIDs) - // 1.3 连续对话兜底:若本轮任务类为空且命中历史快照,则回退到上轮任务类集合。 - if len(taskClassIDs) == 0 && st.IsAdjustment && len(st.PreviousTaskClassIDs) > 0 { - taskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...)) - } - if len(taskClassIDs) == 0 { - st.FinalSummary = "缺少有效的任务类 ID,无法生成排程方案。请传入 task_class_ids。" - return st, nil - } - st.TaskClassIDs = taskClassIDs - - // 2. 连续对话微调优先复用上一版混合日程作为起点,避免“每轮都重新粗排”。 - // 2.1 触发条件:IsAdjustment=true 且 PreviousHybridEntries 非空; - // 2.2 失败兜底:若快照不完整(例如 AllocatedItems 为空),会构造最小占位任务块,保持下游校验可运行; - // 2.3 回退策略:若没有可复用快照,再走全量粗排构建路径。 - canReusePreviousPlan := st.IsAdjustment && - !st.RestartRequested && - len(st.PreviousHybridEntries) > 0 && - sameTaskClassSet(taskClassIDs, st.PreviousTaskClassIDs) - if canReusePreviousPlan { - emitStage("schedule_plan.rough_build.reuse_previous", "检测到连续对话微调,复用上一版排程作为优化起点。") - st.HybridEntries = deepCopyEntries(st.PreviousHybridEntries) - st.CandidatePlans = deepCopyWeekSchedules(st.PreviousCandidatePlans) - if len(st.CandidatePlans) == 0 { - st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries) - } - st.AllocatedItems = deepCopyTaskClassItems(st.PreviousAllocatedItems) - if len(st.AllocatedItems) == 0 { - st.AllocatedItems = buildAllocatedItemsFromHybridEntries(st.HybridEntries) - } - - // 2.2 复用模式下同样尝试解析窗口边界,保证周级 Move 约束仍然有效。 - if deps.ResolvePlanningWindow != nil { - startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs) - if windowErr != nil { - st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error()) - return st, nil - } - st.HasPlanningWindow = true - st.PlanStartWeek = startWeek - st.PlanStartDay = startDay - st.PlanEndWeek = endWeek - st.PlanEndDay = endDay - } - - st.MergeSnapshot = deepCopyEntries(st.HybridEntries) - suggestedCount := 0 - for _, e := range st.HybridEntries { - if e.Status == "suggested" { - suggestedCount++ - } - } - emitStage( - "schedule_plan.rough_build.done", - fmt.Sprintf("已复用历史方案,条目总数=%d,可优化条目=%d。", len(st.HybridEntries), suggestedCount), - ) - return st, nil - } - - emitStage("schedule_plan.rough_build.building", "正在构建粗排候选方案。") - - // 3. 调用服务层统一能力构建混合日程。 - // 3.1 该能力内部会完成“多任务类粗排 + 既有日程合并”; - // 3.2 这里不再拆成 preview/hybrid 两段,避免跨节点重复计算。 - entries, allocatedItems, err := deps.HybridScheduleWithPlanMulti(ctx, st.UserID, taskClassIDs) - if err != nil { - st.FinalSummary = fmt.Sprintf("构建粗排方案失败:%s。", err.Error()) - return st, nil - } - if len(entries) == 0 { - st.FinalSummary = "没有生成可优化的排程条目,请检查任务类时间范围或课表占用。" - return st, nil - } - - // 4. 回填状态。 - st.HybridEntries = entries - st.AllocatedItems = allocatedItems - st.CandidatePlans = hybridEntriesToWeekSchedules(entries) - - // 4.1 解析全局排程窗口(可选依赖)。 - // 4.1.1 目的:给周级 Move 增加“首尾不足一周”的硬边界校验; - // 4.1.2 失败策略:若依赖已注入但解析失败,直接结束本次排程,避免带着错误窗口继续优化。 - if deps.ResolvePlanningWindow != nil { - startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs) - if windowErr != nil { - st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error()) - return st, nil - } - st.HasPlanningWindow = true - st.PlanStartWeek = startWeek - st.PlanStartDay = startDay - st.PlanEndWeek = endWeek - st.PlanEndDay = endDay - } - - // 4.2 记录 merge 快照: - // 4.2.1 单任务类路径可直接作为 final_check 回退基线; - // 4.2.2 多任务类路径后续 merge 节点会覆盖成“日内优化后快照”。 - st.MergeSnapshot = deepCopyEntries(entries) - - // 5. 把“按名称提示的标签”尽可能映射到 task_item_id。 - // 5.1 目的:后续 daily_split 统一按 task_item_id 维度写入 context_tag; - // 5.2 失败策略:映射不上不报错,后续默认走 General 标签。 - if st.TaskTags == nil { - st.TaskTags = make(map[int]string) - } - if len(st.TaskTagHintsByName) > 0 { - for i := range st.HybridEntries { - entry := &st.HybridEntries[i] - if entry.Status != "suggested" || entry.TaskItemID <= 0 { - continue - } - if _, exists := st.TaskTags[entry.TaskItemID]; exists { - continue - } - if tag, ok := st.TaskTagHintsByName[entry.Name]; ok { - st.TaskTags[entry.TaskItemID] = normalizeContextTag(tag) - } - } - } - - suggestedCount := 0 - for _, e := range entries { - if e.Status == "suggested" { - suggestedCount++ - } - } - emitStage( - "schedule_plan.rough_build.done", - fmt.Sprintf("粗排构建完成,条目总数=%d,可优化条目=%d。", len(entries), suggestedCount), - ) - return st, nil -} - -// callScheduleModelForJSON 调用模型并要求返回 JSON。 -// -// 职责边界: -// 1. 仅负责模型调用参数装配,不做业务字段解释; -// 2. 统一关闭 thinking,减少路由/抽取场景的延迟和 token 开销。 -func callScheduleModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) { - return agentllm.CallArkText(ctx, chatModel, systemPrompt, userPrompt, agentllm.ArkCallOptions{ - Temperature: 0, - MaxTokens: maxTokens, - Thinking: agentllm.ThinkingModeDisabled, - }) -} - -// parseScheduleJSON 解析模型返回的 JSON 内容。 -// -// 兼容策略: -// 1. 兼容 ```json ... ``` 包裹; -// 2. 兼容模型在 JSON 前后带解释文本(提取最外层对象)。 -func parseScheduleJSON[T any](raw string) (*T, error) { - return agentllm.ParseJSONObject[T](raw) -} - -// 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 "" -} - -// runReturnPreviewNode 负责把优化后的 HybridEntries 转成“前端可直接展示”的预览结构。 -// -// 职责边界: -// 1. 把 suggested 结果回填到 AllocatedItems,便于后续确认后直接落库; -// 2. 生成 CandidatePlans; -// 3. 生成最终文案; -// 4. 不执行实际写库。 -func runReturnPreviewNode( - ctx context.Context, - st *SchedulePlanState, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - _ = ctx - if st == nil { - return nil, errors.New("schedule plan graph: nil state in returnPreview node") - } - - emitStage("schedule_plan.preview_return.building", "正在生成优化后的排程预览。") - - // 1. 把 HybridEntries 中 suggested 的最终位置回填到 AllocatedItems。 - 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. 生成前端预览结构。 - st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries) - - // 3. 生成最终摘要: - // 3.1 优先保留 final_check 的输出; - // 3.2 若没有 final_check 输出,则回退 weekly refine 摘要; - // 3.3 都没有时给兜底文案。 - if strings.TrimSpace(st.FinalSummary) == "" { - if strings.TrimSpace(st.ReactSummary) != "" { - st.FinalSummary = st.ReactSummary - } else { - st.FinalSummary = fmt.Sprintf("排程优化完成,共 %d 个任务已安排,请确认后应用。", len(suggestedMap)) - } - } - st.Completed = true - - emitStage("schedule_plan.preview_return.done", "排程预览已生成,等待你确认。") - return st, nil -} - -// buildAllocatedItemsFromHybridEntries 根据 suggested 条目构造最小可用的任务块快照。 -// -// 设计目的: -// 1. 连续微调复用历史方案时,若缓存里没有 AllocatedItems,仍然保证 final_check 的数量核对可运行; -// 2. return_preview 仍可依据 TaskItemID 回填最终 embedded_time; -// 3. 该函数只做“兜底构造”,不替代真实粗排输出。 -func buildAllocatedItemsFromHybridEntries(entries []model.HybridScheduleEntry) []model.TaskClassItem { - if len(entries) == 0 { - return nil - } - items := make([]model.TaskClassItem, 0) - for _, entry := range entries { - if entry.Status != "suggested" { - continue - } - embedded := &model.TargetTime{ - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - SectionTo: entry.SectionTo, - } - taskID := entry.TaskItemID - items = append(items, model.TaskClassItem{ - ID: taskID, - EmbeddedTime: embedded, - }) - } - return items -} - -// deepCopyTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨节点共享引用。 -func deepCopyTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { - return agentshared.CloneTaskClassItems(src) -} - -// normalizeContextTag 归一化任务标签。 -// -// 失败兜底: -// 1. 未识别/空值统一回落到 General; -// 2. 保证后续 prompt 构造不会出现空标签。 -func normalizeContextTag(raw string) string { - tag := strings.TrimSpace(raw) - if tag == "" { - return "General" - } - switch strings.ToLower(tag) { - case "high-logic", "high_logic", "logic": - return "High-Logic" - case "memory": - return "Memory" - case "review": - return "Review" - case "general": - return "General" - default: - return "General" - } -} - -// normalizeTaskClassIDs 清洗 task_class_ids(去重 + 过滤非法值)。 -func normalizeTaskClassIDs(ids []int) []int { - if len(ids) == 0 { - return nil - } - seen := make(map[int]struct{}, len(ids)) - out := make([]int, 0, len(ids)) - for _, id := range ids { - if id <= 0 { - continue - } - if _, exists := seen[id]; exists { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -// clearPreviousPreviewContext 清空会话承接快照字段。 -// -// 触发场景: -// 1. 用户明确要求 restart(重新排); -// 2. 需要强制断开“沿用历史方案”的路径,避免脏状态渗透到新方案。 -func clearPreviousPreviewContext(st *SchedulePlanState) { - if st == nil { - return - } - st.HasPreviousPreview = false - st.PreviousTaskClassIDs = nil - st.PreviousHybridEntries = nil - st.PreviousAllocatedItems = nil - st.PreviousCandidatePlans = nil - st.PreviousPlanJSON = "" -} - -// clampAdjustmentConfidence 约束置信度字段到 [0,1]。 -func clampAdjustmentConfidence(v float64) float64 { - if v < 0 { - return 0 - } - if v > 1 { - return 1 - } - return v -} - -// deepCopyWeekSchedules 深拷贝周视图方案切片,避免跨节点共享引用。 -func deepCopyWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { - return agentshared.CloneWeekSchedules(src) -} - -// sameTaskClassSet 判断两组 task_class_ids 是否表示同一集合(忽略顺序,忽略重复)。 -// -// 语义: -// 1. 两边经清洗后都为空,返回 false(空集合不作为“可复用历史方案”的依据); -// 2. 元素集合完全一致返回 true; -// 3. 任一元素差异返回 false。 -func sameTaskClassSet(left []int, right []int) bool { - l := normalizeTaskClassIDs(left) - r := normalizeTaskClassIDs(right) - if len(l) == 0 || len(r) == 0 { - return false - } - if len(l) != len(r) { - return false - } - seen := make(map[int]struct{}, len(l)) - for _, id := range l { - seen[id] = struct{}{} - } - for _, id := range r { - if _, ok := seen[id]; !ok { - return false - } - } - return true -} - -// hybridEntriesToWeekSchedules 把内存中的混合条目转换成前端周视图格式。 -func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule { - sectionTimeMap := 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 week, events := range weekMap { - result = append(result, model.UserWeekSchedule{ - Week: week, - Events: events, - }) - } - for i := 0; i < len(result); i++ { - for j := i + 1; j < len(result); j++ { - if result[j].Week < result[i].Week { - result[i], result[j] = result[j], result[i] - } - } - } - return result -} - -// runDailySplitNode 负责“按天拆分 + 标签注入 + 跳过判断”。 -// -// 职责边界: -// 1. 负责把全量 HybridEntries 拆成 DayGroup,供后续并发日内优化; -// 2. 负责把 TaskTags(task_item_id -> tag) 注入到条目的 ContextTag; -// 3. 负责识别“低收益天”(suggested<=2)并标记 SkipRefine; -// 4. 不负责调用模型,不负责并发执行,不负责结果合并。 -func runDailySplitNode( - ctx context.Context, - st *SchedulePlanState, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - _ = ctx - if st == nil || len(st.HybridEntries) == 0 { - return st, nil - } - - emitStage("schedule_plan.daily_split.start", "正在按天拆分排程并标记优化单元。") - - // 1. 初始化容器: - // 1.1 groups 以 week/day 二级索引保存 DayGroup; - // 1.2 这么做的目的是后续 daily_refine 可以直接并发遍历,不再重复分组。 - groups := make(map[int]map[int]*DayGroup) - - // 2. 遍历混合条目,执行“标签注入 + 分组”。 - for i := range st.HybridEntries { - entry := &st.HybridEntries[i] - - // 2.1 仅对 suggested 条目注入 ContextTag。 - // 2.1.1 existing 条目是固定课表/已落库任务,不参与认知标签优化。 - // 2.1.2 注入失败时兜底 General,避免后续 prompt 出现空标签。 - if entry.Status == "suggested" && entry.TaskItemID > 0 { - if tag, ok := st.TaskTags[entry.TaskItemID]; ok { - entry.ContextTag = normalizeContextTag(tag) - } else { - entry.ContextTag = "General" - } - } - - // 2.2 建立分组索引。 - if groups[entry.Week] == nil { - groups[entry.Week] = make(map[int]*DayGroup) - } - if groups[entry.Week][entry.DayOfWeek] == nil { - groups[entry.Week][entry.DayOfWeek] = &DayGroup{ - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - } - } - groups[entry.Week][entry.DayOfWeek].Entries = append(groups[entry.Week][entry.DayOfWeek].Entries, *entry) - } - - // 3. 逐天计算 suggested 数量,标记是否跳过日内优化。 - // - // 3.1 为什么阈值设为 <=2: - // 3.1.1 suggested 很少时,模型优化收益通常不足以覆盖请求成本; - // 3.1.2 直接跳过可减少无效模型调用和阶段等待。 - // 3.2 失败策略: - // 3.2.1 这里只做内存标记,不会失败; - // 3.2.2 即使阈值判断不完美,也只影响优化深度,不影响功能正确性。 - totalDays := 0 - skipDays := 0 - for _, dayMap := range groups { - for _, dayGroup := range dayMap { - totalDays++ - suggestedCount := 0 - for _, e := range dayGroup.Entries { - if e.Status == "suggested" { - suggestedCount++ - } - } - if suggestedCount <= 2 { - dayGroup.SkipRefine = true - skipDays++ - } - } - } - - // 4. 回填状态,交给后续节点使用。 - st.DailyGroups = groups - emitStage( - "schedule_plan.daily_split.done", - fmt.Sprintf("已拆分为 %d 天,其中 %d 天跳过日内优化。", totalDays, skipDays), - ) - return st, nil -} - -// runQuickRefineNode 是 small 微调分支的“轻量预算收缩节点”。 -// -// 职责边界: -// 1. 负责在进入 weekly_refine 前收缩预算与并发,避免小改动走重链路; -// 2. 负责保留“可回退”的最低预算,避免直接压成 0 导致无动作可执行; -// 3. 不负责执行任何 Move/Swap(真正动作仍由 weekly_refine 完成)。 -func runQuickRefineNode( - ctx context.Context, - st *SchedulePlanState, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - _ = ctx - if st == nil { - return nil, fmt.Errorf("schedule plan quick refine: nil state") - } - - emitStage("schedule_plan.quick_refine.start", "检测到小幅微调,正在切换到快速优化路径。") - - // 1. 预算收缩策略: - // 1.1 small 场景目标是“快速响应 + 可解释改动”,不追求大规模重排; - // 1.2 因此把总预算压到最多 2 次尝试、有效预算压到最多 1 次成功动作; - // 1.3 如果上游已配置更小预算,则尊重更小值,不做反向放大。 - if st.WeeklyTotalBudget <= 0 { - st.WeeklyTotalBudget = schedulePlanDefaultWeeklyTotalBudget - } - if st.WeeklyAdjustBudget <= 0 { - st.WeeklyAdjustBudget = schedulePlanDefaultWeeklyAdjustBudget - } - st.WeeklyTotalBudget = clampBudgetUpper(st.WeeklyTotalBudget, 2) - st.WeeklyAdjustBudget = clampBudgetUpper(st.WeeklyAdjustBudget, 1) - - // 2. 预算一致性兜底: - // 2.1 总预算至少为 1(否则 weekly worker 无法执行); - // 2.2 有效预算至少为 1(否则所有成功动作都不被允许); - // 2.3 有效预算永远不能超过总预算。 - if st.WeeklyTotalBudget < 1 { - st.WeeklyTotalBudget = 1 - } - if st.WeeklyAdjustBudget < 1 { - st.WeeklyAdjustBudget = 1 - } - if st.WeeklyAdjustBudget > st.WeeklyTotalBudget { - st.WeeklyAdjustBudget = st.WeeklyTotalBudget - } - - // 3. 小改动路径把周级并发收敛到 1,优先保证稳定与可观察性。 - st.WeeklyRefineConcurrency = 1 - - emitStage( - "schedule_plan.quick_refine.done", - fmt.Sprintf( - "快速微调预算已生效:总预算=%d,有效预算=%d,并发=%d。", - st.WeeklyTotalBudget, - st.WeeklyAdjustBudget, - st.WeeklyRefineConcurrency, - ), - ) - return st, nil -} - -// clampBudgetUpper 把预算裁剪到“非负且不超过上限”。 -func clampBudgetUpper(current int, upper int) int { - if current < 0 { - return 0 - } - if current > upper { - return upper - } - return current -} - -const ( - // dailyReactRoundTimeout 是日内单轮模型调用超时。 - // 日内节点走并发调用,超时要比周级更保守,避免占满资源。 - dailyReactRoundTimeout = 3 * time.Minute -) - -// runDailyRefineNode 负责“并发日内优化”。 -// -// 职责边界: -// 1. 负责按 DayGroup 并发调用单日 ReAct; -// 2. 负责输出“按天开始/完成”的阶段状态块(不推 reasoning 细流); -// 3. 负责把单日失败回退到原始数据,确保全链路可继续; -// 4. 不负责跨天配平(交给 weekly_refine),不负责最终总结(交给 final_check)。 -func runDailyRefineNode( - ctx context.Context, - st *SchedulePlanState, - chatModel *ark.ChatModel, - dailyRefineConcurrency int, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - if st == nil || len(st.DailyGroups) == 0 { - return st, nil - } - if chatModel == nil { - return st, fmt.Errorf("schedule plan daily refine: model is nil") - } - - // 1. 并发度兜底: - // 1.1 优先使用注入参数; - // 1.2 若注入参数非法,则回退到 state 值; - // 1.3 state 也非法时,回退到编译期默认值。 - if dailyRefineConcurrency <= 0 { - dailyRefineConcurrency = st.DailyRefineConcurrency - } - if dailyRefineConcurrency <= 0 { - dailyRefineConcurrency = schedulePlanDefaultDailyRefineConcurrency - } - - emitStage( - "schedule_plan.daily_refine.start", - fmt.Sprintf("正在并发优化各天日程,并发度=%d。", dailyRefineConcurrency), - ) - - // 2. 拉平所有 DayGroup 并排序,确保日志与阶段输出稳定可读。 - allGroups := flattenAndSortDayGroups(st.DailyGroups) - if len(allGroups) == 0 { - st.DailyResults = make(map[int]map[int][]model.HybridScheduleEntry) - emitStage("schedule_plan.daily_refine.done", "没有可优化的天,跳过日内优化。") - return st, nil - } - - // 3. 并发执行: - // 3.1 sem 控制并发上限; - // 3.2 wg 等待全部 goroutine 完成; - // 3.3 mu 保护 results/firstErr,避免竞态。 - sem := make(chan struct{}, dailyRefineConcurrency) - var wg sync.WaitGroup - var mu sync.Mutex - totalGroups := int32(len(allGroups)) - var finishedGroups int32 - - results := make(map[int]map[int][]model.HybridScheduleEntry) - var firstErr error - - for _, group := range allGroups { - g := group - wg.Add(1) - go func() { - defer wg.Done() - - // 3.4 先申请并发令牌;若 ctx 已取消,直接回退原始数据并结束。 - select { - case sem <- struct{}{}: - defer func() { <-sem }() - case <-ctx.Done(): - mu.Lock() - if firstErr == nil { - firstErr = ctx.Err() - } - ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries) - mu.Unlock() - // 3.4.1 取消场景也要计入进度,避免前端看到“卡住不动”。 - done := atomic.AddInt32(&finishedGroups, 1) - emitStage( - "schedule_plan.daily_refine.day_done", - fmt.Sprintf("W%dD%d 已取消并回退原方案。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups), - ) - return - } - - emitStage( - "schedule_plan.daily_refine.day_start", - fmt.Sprintf("正在安排 W%dD%d。(当前进度 %d/%d)", g.Week, g.DayOfWeek, atomic.LoadInt32(&finishedGroups), totalGroups), - ) - - // 3.5 低收益天直接跳过模型调用,原样透传。 - if g.SkipRefine { - mu.Lock() - ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries) - mu.Unlock() - done := atomic.AddInt32(&finishedGroups, 1) - emitStage( - "schedule_plan.daily_refine.day_done", - fmt.Sprintf("W%dD%d suggested 较少,已跳过优化。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups), - ) - return - } - - // 3.6 深拷贝输入,避免并发场景下意外修改共享切片。 - localEntries := deepCopyEntries(g.Entries) - - // 3.7 动态轮次: - // 3.7.1 suggested <= 4:1轮足够; - // 3.7.2 suggested > 4:最多2轮,提升复杂天优化质量。 - maxRounds := 1 - if countSuggested(localEntries) > 4 { - maxRounds = 2 - } - - optimized, refineErr := runSingleDayReact(ctx, chatModel, localEntries, maxRounds, g.Week, g.DayOfWeek) - if refineErr != nil { - mu.Lock() - if firstErr == nil { - firstErr = refineErr - } - // 3.8 单天失败回退: - // 3.8.1 保证失败只影响该天; - // 3.8.2 保证总流程可继续推进到 merge/weekly/final。 - ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries) - mu.Unlock() - done := atomic.AddInt32(&finishedGroups, 1) - emitStage( - "schedule_plan.daily_refine.day_done", - fmt.Sprintf("W%dD%d 优化失败,已回退原方案。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups), - ) - return - } - - mu.Lock() - ensureDayResult(results, g.Week, g.DayOfWeek, optimized) - mu.Unlock() - done := atomic.AddInt32(&finishedGroups, 1) - emitStage( - "schedule_plan.daily_refine.day_done", - fmt.Sprintf("W%dD%d 已安排完成。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups), - ) - }() - } - - wg.Wait() - st.DailyResults = results - if firstErr != nil { - emitStage("schedule_plan.daily_refine.partial_error", fmt.Sprintf("部分天优化失败,已自动回退。原因:%s", firstErr.Error())) - } - emitStage("schedule_plan.daily_refine.done", "日内优化阶段完成。") - return st, nil -} - -// runSingleDayReact 执行单天封闭式 ReAct 优化。 -// -// 关键约束: -// 1. prompt 只包含当天数据; -// 2. 代码层再做“Move 不能跨天”硬校验; -// 3. Thinking 默认关闭,优先降低日内并发阶段的长尾时延。 -func runSingleDayReact( - ctx context.Context, - chatModel *ark.ChatModel, - entries []model.HybridScheduleEntry, - maxRounds int, - week int, - dayOfWeek int, -) ([]model.HybridScheduleEntry, error) { - hybridJSON, err := json.Marshal(entries) - if err != nil { - return entries, err - } - - messages := []*schema.Message{ - schema.SystemMessage(agentprompt.SchedulePlanDailyReactPrompt), - schema.UserMessage(fmt.Sprintf( - "以下是今天的日程(JSON):\n%s\n\n仅优化这一天的数据,不要跨天移动。", - string(hybridJSON), - )), - } - - for round := 0; round < maxRounds; round++ { - roundCtx, cancel := context.WithTimeout(ctx, dailyReactRoundTimeout) - // 1. 日内优化只做“单天局部微调”,任务边界清晰,默认关闭 thinking 以降低时延。 - // 2. 周级全局配平仍保留 thinking(在 weekly_refine),这里不承担跨天复杂推理职责。 - // 3. 模型调用细节统一下沉到 llm 层,避免 schedule skill 再维护一份 SDK 样板。 - content, generateErr := agentllm.GenerateScheduleDailyReactRound(roundCtx, chatModel, messages) - cancel() - if generateErr != nil { - return entries, fmt.Errorf("日内 ReAct 第%d轮失败: %w", round+1, generateErr) - } - parsed, parseErr := parseReactLLMOutput(content) - if parseErr != nil { - // 解析失败时回退当前轮,不把异常向上放大成整条链路失败。 - return entries, nil - } - if parsed.Done || len(parsed.ToolCalls) == 0 { - break - } - - // 1. 执行工具调用。 - // 1.1 每个调用都经过“日内策略约束”校验; - // 1.2 任何单次调用失败都只返回 failed result,不中断整轮。 - results := make([]reactToolResult, 0, len(parsed.ToolCalls)) - for _, call := range parsed.ToolCalls { - var result reactToolResult - entries, result = dispatchDailyReactTool(entries, call, week, dayOfWeek) - results = append(results, result) - } - - // 2. 把“本轮模型输出 + 工具执行结果”拼入下一轮上下文。 - // 2.1 这样模型可以看到操作反馈,继续迭代; - // 2.2 若下一轮仍无有效动作,会自然在 done/空 tool_calls 退出。 - messages = append(messages, schema.AssistantMessage(content, nil)) - resultJSON, _ := json.Marshal(results) - messages = append(messages, schema.UserMessage( - fmt.Sprintf("工具执行结果:\n%s\n\n请继续优化或输出 {\"done\":true,\"summary\":\"...\"}。", string(resultJSON)), - )) - } - - return entries, nil -} - -// dispatchDailyReactTool 在通用工具分发前增加“日内硬约束”。 -// -// 职责边界: -// 1. 只负责校验 Move 的目标是否仍在当前天; -// 2. 通过后复用 dispatchReactTool 执行; -// 3. 不负责复杂冲突判定(冲突判定由底层工具函数处理)。 -func dispatchDailyReactTool(entries []model.HybridScheduleEntry, call reactToolCall, week int, dayOfWeek int) ([]model.HybridScheduleEntry, reactToolResult) { - if call.Tool == "Move" { - toWeek, weekOK := paramInt(call.Params, "to_week") - toDay, dayOK := paramInt(call.Params, "to_day") - if !weekOK || !dayOK { - return entries, reactToolResult{ - Tool: "Move", - Success: false, - Result: "参数缺失:to_week/to_day", - } - } - if toWeek != week || toDay != dayOfWeek { - return entries, reactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("日内优化禁止跨天移动:当前仅允许 W%dD%d", week, dayOfWeek), - } - } - } - return dispatchReactTool(entries, call) -} - -// flattenAndSortDayGroups 把 map 结构摊平成有序切片,便于稳定并发调度。 -func flattenAndSortDayGroups(groups map[int]map[int]*DayGroup) []*DayGroup { - out := make([]*DayGroup, 0) - for _, dayMap := range groups { - for _, g := range dayMap { - if g != nil { - out = append(out, g) - } - } - } - sort.Slice(out, func(i, j int) bool { - if out[i].Week != out[j].Week { - return out[i].Week < out[j].Week - } - return out[i].DayOfWeek < out[j].DayOfWeek - }) - return out -} - -// ensureDayResult 确保 results[week][day] 存在并写入值。 -func ensureDayResult(results map[int]map[int][]model.HybridScheduleEntry, week int, day int, entries []model.HybridScheduleEntry) { - if results[week] == nil { - results[week] = make(map[int][]model.HybridScheduleEntry) - } - results[week][day] = entries -} - -// deepCopyEntries 深拷贝 HybridScheduleEntry 切片。 -func deepCopyEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { - dst := make([]model.HybridScheduleEntry, len(src)) - copy(dst, src) - return dst -} - -// runMergeNode 负责“合并日内结果 + 冲突校验 + 回退快照”。 -// -// 职责边界: -// 1. 负责把 DailyResults 合并回全量 HybridEntries; -// 2. 负责执行时间冲突检测; -// 3. 负责在冲突时回退原始数据; -// 4. 负责产出 MergeSnapshot,供 final_check 失败时回退。 -func runMergeNode( - ctx context.Context, - st *SchedulePlanState, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - _ = ctx - if st == nil || len(st.DailyResults) == 0 { - return st, nil - } - - emitStage("schedule_plan.merge.start", "正在合并日内优化结果。") - - // 1. 先保存 merge 前原始数据,作为冲突时的第一层回退兜底。 - originalEntries := deepCopyEntries(st.HybridEntries) - - // 2. 展平 daily results。 - merged := make([]model.HybridScheduleEntry, 0) - for _, dayMap := range st.DailyResults { - for _, dayEntries := range dayMap { - merged = append(merged, dayEntries...) - } - } - - // 3. 冲突校验。 - // - // 3.1 判断依据:同一 (week, day, section) 只能有一个条目占用; - // 3.2 失败处理:一旦冲突,整批回退到 merge 前原始结果; - // 3.3 回退策略:回退后仍继续链路,避免请求直接失败。 - if conflict := detectConflicts(merged); conflict != "" { - st.HybridEntries = originalEntries - emitStage("schedule_plan.merge.conflict", fmt.Sprintf("检测到冲突并回退:%s", conflict)) - } else { - st.HybridEntries = merged - emitStage("schedule_plan.merge.done", fmt.Sprintf("合并完成,共 %d 个条目。", len(merged))) - } - - // 4. 无论是否冲突,都生成“可回退快照”。 - st.MergeSnapshot = deepCopyEntries(st.HybridEntries) - return st, nil -} - -// detectConflicts 检测条目是否存在时间冲突。 -// -// 返回语义: -// 1. 返回空字符串:无冲突; -// 2. 返回非空字符串:冲突描述,可直接用于日志/阶段提示。 -func detectConflicts(entries []model.HybridScheduleEntry) string { - type slotKey struct { - week, day, section int - } - occupied := make(map[slotKey]string) - for _, entry := range entries { - // 1. 仅“阻塞建议任务”的条目参与冲突校验。 - // 2. 可嵌入且当前未占用的课程槽位不应被判定为冲突。 - if !entryBlocksSuggested(entry) { - continue - } - for section := entry.SectionFrom; section <= entry.SectionTo; section++ { - key := slotKey{week: entry.Week, day: entry.DayOfWeek, section: section} - if prevName, exists := occupied[key]; exists { - return fmt.Sprintf( - "W%dD%d 第%d节 冲突:[%s] 与 [%s]", - entry.Week, entry.DayOfWeek, section, prevName, entry.Name, - ) - } - occupied[key] = entry.Name - } - } - return "" -} - -const ( - // weeklyReactRoundTimeout 是周级“单步动作”单轮超时时间。 - // - // 说明: - // 1. 当前周级策略是“每轮只做一个动作”,单轮输入较短,超时可比旧版更保守; - // 2. 过长超时会放大长尾等待,影响并发周优化的整体收口速度。 - weeklyReactRoundTimeout = 4 * time.Minute -) - -// weeklyRefineWorkerResult 是“单周 worker”输出。 -// -// 职责边界: -// 1. 记录该周优化后的 entries; -// 2. 记录预算消耗(总动作/有效动作); -// 3. 记录动作日志,供 final_check 生成“过程可解释”总结; -// 4. 记录该周摘要,便于最终汇总。 -type weeklyRefineWorkerResult struct { - Week int - Entries []model.HybridScheduleEntry - TotalUsed int - EffectiveUsed int - Summary string - ActionLogs []string -} - -// runWeeklyRefineNode 执行“周级单步优化”。 -// -// 新链路目标: -// 1. 把全量周数据拆成“按周并发”执行,降低单次模型输入规模; -// 2. 每轮只允许一个动作(Move/Swap)或 done,减少模型犹豫; -// 3. 使用“双预算”约束迭代: -// 3.1 总动作预算:成功/失败都扣减; -// 3.2 有效动作预算:仅成功动作扣减; -// 4. 不在该阶段输出 reasoning 文本,改为阶段状态 + 动作结果,避免刷屏。 -func runWeeklyRefineNode( - ctx context.Context, - st *SchedulePlanState, - chatModel *ark.ChatModel, - outChan chan<- string, - modelName string, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - _ = outChan - if st == nil { - return nil, fmt.Errorf("schedule plan weekly refine: nil state") - } - if chatModel == nil { - return nil, fmt.Errorf("schedule plan weekly refine: model is nil") - } - if len(st.HybridEntries) == 0 { - st.ReactDone = true - st.ReactSummary = "无可优化的排程条目。" - return st, nil - } - if strings.TrimSpace(modelName) == "" { - modelName = "worker" - } - - // 1. 预算与并发兜底。 - // 1.1 有效预算(旧字段)<=0 时回退默认值; - // 1.2 总预算 <=0 时回退默认值; - // 1.3 为避免“有效预算 > 总预算”的反直觉状态,做一次归一化修正; - // 1.4 周级并发度默认不高于周数,避免空并发浪费。 - if st.WeeklyAdjustBudget <= 0 { - st.WeeklyAdjustBudget = schedulePlanDefaultWeeklyAdjustBudget - } - if st.WeeklyTotalBudget <= 0 { - st.WeeklyTotalBudget = schedulePlanDefaultWeeklyTotalBudget - } - if st.WeeklyAdjustBudget > st.WeeklyTotalBudget { - st.WeeklyAdjustBudget = st.WeeklyTotalBudget - } - if st.WeeklyRefineConcurrency <= 0 { - st.WeeklyRefineConcurrency = schedulePlanDefaultWeeklyRefineConcurrency - } - - // 2. 按周拆分输入。 - weekOrder, weekEntries := splitHybridEntriesByWeek(st.HybridEntries) - if len(weekOrder) == 0 { - st.ReactDone = true - st.ReactSummary = "无可优化的排程条目。" - return st, nil - } - - // 3. 只对“包含 suggested 的周”分配预算,其余周直接透传。 - activeWeeks := make([]int, 0, len(weekOrder)) - for _, week := range weekOrder { - if countSuggested(weekEntries[week]) > 0 { - activeWeeks = append(activeWeeks, week) - } - } - if len(activeWeeks) == 0 { - st.ReactDone = true - st.ReactSummary = "当前方案中没有可调整的 suggested 任务,已跳过周级优化。" - return st, nil - } - - // 3.1 强制“每个有效周至少 1 个总预算 + 1 个有效预算”。 - // 3.1.1 判断依据:任何有效周都必须有机会进入优化,避免出现 0 预算跳过。 - // 3.1.2 实现方式:当全局预算不足时,自动抬升到 activeWeeks 数量。 - // 3.1.3 失败/兜底:该步骤仅做内存字段修正,不依赖外部资源,不会新增失败点。 - minBudgetRequired := len(activeWeeks) - if st.WeeklyTotalBudget < minBudgetRequired { - st.WeeklyTotalBudget = minBudgetRequired - } - if st.WeeklyAdjustBudget < minBudgetRequired { - st.WeeklyAdjustBudget = minBudgetRequired - } - if st.WeeklyAdjustBudget > st.WeeklyTotalBudget { - st.WeeklyAdjustBudget = st.WeeklyTotalBudget - } - - totalBudgetByWeek, effectiveBudgetByWeek, weeklyLoads, coveredWeeks := splitWeeklyBudgetsByLoad( - activeWeeks, - weekEntries, - st.WeeklyTotalBudget, - st.WeeklyAdjustBudget, - ) - budgetIndexByWeek := make(map[int]int, len(activeWeeks)) - for idx, week := range activeWeeks { - budgetIndexByWeek[week] = idx - } - if coveredWeeks < len(activeWeeks) { - emitStage( - "schedule_plan.weekly_refine.budget_fallback", - fmt.Sprintf( - "周级预算不足以覆盖全部有效周(有效周=%d,至少需预算=%d;当前总预算=%d,有效预算=%d)。已按周负载优先覆盖 %d 个周,其余周预算置 0 并透传原方案。", - len(activeWeeks), - len(activeWeeks), - st.WeeklyTotalBudget, - st.WeeklyAdjustBudget, - coveredWeeks, - ), - ) - } - - workerConcurrency := st.WeeklyRefineConcurrency - if workerConcurrency > len(activeWeeks) { - workerConcurrency = len(activeWeeks) - } - if workerConcurrency <= 0 { - workerConcurrency = 1 - } - - emitStage( - "schedule_plan.weekly_refine.start", - fmt.Sprintf( - "周级单步优化开始:周数=%d(可优化=%d),并发度=%d,总动作预算=%d,有效动作预算=%d,覆盖周=%d/%d,周负载=%v。", - len(weekOrder), - len(activeWeeks), - workerConcurrency, - st.WeeklyTotalBudget, - st.WeeklyAdjustBudget, - coveredWeeks, - len(activeWeeks), - weeklyLoads, - ), - ) - - // 4. 并发执行“单周 worker”。 - sem := make(chan struct{}, workerConcurrency) - var wg sync.WaitGroup - var mu sync.Mutex - - workerResults := make(map[int]weeklyRefineWorkerResult, len(weekOrder)) - var firstErr error - completedWeeks := 0 - - for _, week := range weekOrder { - week := week - entries := deepCopyEntries(weekEntries[week]) - - // 4.1 没有 suggested 的周直接透传,不占模型调用预算。 - if countSuggested(entries) == 0 { - workerResults[week] = weeklyRefineWorkerResult{ - Week: week, - Entries: entries, - Summary: fmt.Sprintf("W%d 无 suggested 任务,跳过周级优化。", week), - } - continue - } - - wg.Add(1) - go func() { - defer wg.Done() - - select { - case sem <- struct{}{}: - defer func() { <-sem }() - case <-ctx.Done(): - mu.Lock() - if firstErr == nil { - firstErr = ctx.Err() - } - completedWeeks++ - workerResults[week] = weeklyRefineWorkerResult{ - Week: week, - Entries: entries, - Summary: fmt.Sprintf("W%d 优化取消,已保留原方案。", week), - } - emitStage( - "schedule_plan.weekly_refine.week_done", - fmt.Sprintf("W%d 已取消并回退原方案。(进度 %d/%d)", week, completedWeeks, len(activeWeeks)), - ) - mu.Unlock() - return - } - - idx := budgetIndexByWeek[week] - weekTotalBudget := totalBudgetByWeek[idx] - weekEffectiveBudget := effectiveBudgetByWeek[idx] - emitStage( - "schedule_plan.weekly_refine.week_start", - fmt.Sprintf( - "W%d 开始周级单步优化:总预算=%d,有效预算=%d。", - week, - weekTotalBudget, - weekEffectiveBudget, - ), - ) - - result, workerErr := runSingleWeekRefineWorker( - ctx, - chatModel, - modelName, - week, - entries, - st.Constraints, - weeklyPlanningWindow{ - Enabled: st.HasPlanningWindow, - StartWeek: st.PlanStartWeek, - StartDay: st.PlanStartDay, - EndWeek: st.PlanEndWeek, - EndDay: st.PlanEndDay, - }, - weekTotalBudget, - weekEffectiveBudget, - emitStage, - ) - - mu.Lock() - defer mu.Unlock() - if workerErr != nil && firstErr == nil { - firstErr = workerErr - } - completedWeeks++ - workerResults[week] = result - emitStage( - "schedule_plan.weekly_refine.week_done", - fmt.Sprintf( - "W%d 周级优化完成(总已用=%d/%d,有效已用=%d/%d)。(进度 %d/%d)", - week, - result.TotalUsed, - weekTotalBudget, - result.EffectiveUsed, - weekEffectiveBudget, - completedWeeks, - len(activeWeeks), - ), - ) - }() - } - wg.Wait() - - // 5. 汇总 worker 结果,重建全量 HybridEntries。 - mergedEntries := make([]model.HybridScheduleEntry, 0, len(st.HybridEntries)) - st.WeeklyTotalUsed = 0 - st.WeeklyAdjustUsed = 0 - st.WeeklyActionLogs = st.WeeklyActionLogs[:0] - weekSummaries := make([]string, 0, len(weekOrder)) - - for _, week := range weekOrder { - result, exists := workerResults[week] - if !exists { - // 理论上不会发生;兜底透传该周原始条目。 - result = weeklyRefineWorkerResult{ - Week: week, - Entries: deepCopyEntries(weekEntries[week]), - Summary: fmt.Sprintf("W%d 未拿到 worker 结果,已保留原方案。", week), - } - } - mergedEntries = append(mergedEntries, result.Entries...) - st.WeeklyTotalUsed += result.TotalUsed - st.WeeklyAdjustUsed += result.EffectiveUsed - st.WeeklyActionLogs = append(st.WeeklyActionLogs, result.ActionLogs...) - if strings.TrimSpace(result.Summary) != "" { - weekSummaries = append(weekSummaries, result.Summary) - } - } - sortHybridEntries(mergedEntries) - st.HybridEntries = mergedEntries - - // 6. 生成阶段摘要并收口状态。 - st.ReactDone = true - st.ReactRound = st.WeeklyTotalUsed - if len(weekSummaries) == 0 { - st.ReactSummary = fmt.Sprintf( - "周级优化完成:总动作已用 %d/%d,有效动作已用 %d/%d。", - st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget, - ) - } else { - st.ReactSummary = strings.Join(weekSummaries, ";") - } - if firstErr != nil { - emitStage("schedule_plan.weekly_refine.partial_error", fmt.Sprintf("周级并发优化部分失败,已自动保留失败周原方案。原因:%s", firstErr.Error())) - } - emitStage( - "schedule_plan.weekly_refine.done", - fmt.Sprintf( - "周级单步优化结束:总动作已用 %d/%d,有效动作已用 %d/%d。", - st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget, - ), - ) - return st, nil -} - -// runSingleWeekRefineWorker 执行“单周 + 单步动作”循环。 -// -// 流程说明: -// 1. 每轮只允许 1 个工具调用或 done; -// 2. 每次工具调用都扣“总预算”; -// 3. 仅成功调用再扣“有效预算”; -// 4. 工具结果会回灌到下一轮上下文,驱动“走一步看一步”。 -func runSingleWeekRefineWorker( - ctx context.Context, - chatModel *ark.ChatModel, - modelName string, - week int, - entries []model.HybridScheduleEntry, - constraints []string, - window weeklyPlanningWindow, - totalBudget int, - effectiveBudget int, - emitStage func(stage, detail string), -) (weeklyRefineWorkerResult, error) { - result := weeklyRefineWorkerResult{ - Week: week, - Entries: deepCopyEntries(entries), - } - - if totalBudget <= 0 || effectiveBudget <= 0 { - result.Summary = fmt.Sprintf("W%d 预算为 0,跳过周级优化。", week) - return result, nil - } - - hybridJSON, err := json.Marshal(result.Entries) - if err != nil { - result.Summary = fmt.Sprintf("W%d 序列化失败,已保留原方案。", week) - return result, fmt.Errorf("周级 worker 序列化失败 week=%d: %w", week, err) - } - constraintsText := "无" - if len(constraints) > 0 { - constraintsText = strings.Join(constraints, "、") - } - - messages := []*schema.Message{ - schema.SystemMessage( - renderWeeklyPromptWithBudget( - effectiveBudget-result.EffectiveUsed, - effectiveBudget, - result.EffectiveUsed, - totalBudget-result.TotalUsed, - totalBudget, - result.TotalUsed, - ), - ), - schema.UserMessage(fmt.Sprintf( - "当前处理周次:W%d\n以下是当前周混合日程(JSON):\n%s\n\n用户约束:%s\n\n注意:本 worker 仅允许优化 W%d 内的任务。", - week, - string(hybridJSON), - constraintsText, - week, - )), - } - - for result.TotalUsed < totalBudget && result.EffectiveUsed < effectiveBudget { - remainingTotal := totalBudget - result.TotalUsed - remainingEffective := effectiveBudget - result.EffectiveUsed - emitStage( - "schedule_plan.weekly_refine.round", - fmt.Sprintf( - "W%d 新一轮决策:总预算剩余=%d/%d,有效预算剩余=%d/%d。", - week, - remainingTotal, - totalBudget, - remainingEffective, - effectiveBudget, - ), - ) - - // 1. 每轮更新系统提示中的预算占位符。 - messages[0] = schema.SystemMessage( - renderWeeklyPromptWithBudget( - remainingEffective, - effectiveBudget, - result.EffectiveUsed, - remainingTotal, - totalBudget, - result.TotalUsed, - ), - ) - - roundCtx, cancel := context.WithTimeout(ctx, weeklyReactRoundTimeout) - content, genErr := generateWeeklyRefineRound(roundCtx, chatModel, messages) - cancel() - if genErr != nil { - result.Summary = fmt.Sprintf("W%d 模型调用失败,已保留当前结果。", week) - return result, fmt.Errorf("周级 worker 调用失败 week=%d: %w", week, genErr) - } - - parsed, parseErr := parseReactLLMOutput(content) - if parseErr != nil { - result.Summary = fmt.Sprintf("W%d 输出格式异常,已保留当前结果。", week) - return result, fmt.Errorf("周级 worker 解析失败 week=%d: %w", week, parseErr) - } - - // 2. done=true 直接正常结束,不再消耗预算。 - if parsed.Done { - summary := strings.TrimSpace(parsed.Summary) - if summary == "" { - summary = fmt.Sprintf( - "W%d 优化结束(总动作已用 %d/%d,有效动作已用 %d/%d)。", - week, - result.TotalUsed, totalBudget, - result.EffectiveUsed, effectiveBudget, - ) - } - result.Summary = summary - break - } - - // 3. 只取一个工具调用,强制单步。 - call, warn := pickSingleToolCall(parsed.ToolCalls) - if call == nil { - result.Summary = fmt.Sprintf( - "W%d 无可执行动作,提前结束(总动作已用 %d/%d,有效动作已用 %d/%d)。", - week, - result.TotalUsed, totalBudget, - result.EffectiveUsed, effectiveBudget, - ) - break - } - if warn != "" { - result.ActionLogs = append(result.ActionLogs, fmt.Sprintf("W%d 警告:%s", week, warn)) - } - - // 4. 执行工具:总预算总是扣减;有效预算仅成功时扣减。 - result.TotalUsed++ - nextEntries, toolResult := dispatchWeeklySingleActionTool(result.Entries, *call, week, window) - if toolResult.Success { - result.EffectiveUsed++ - result.Entries = nextEntries - } - - logLine := fmt.Sprintf( - "W%d 动作[%s] 结果=%t,总预算=%d/%d,有效预算=%d/%d,详情=%s", - week, - toolResult.Tool, - toolResult.Success, - result.TotalUsed, - totalBudget, - result.EffectiveUsed, - effectiveBudget, - toolResult.Result, - ) - result.ActionLogs = append(result.ActionLogs, logLine) - statusMark := "FAIL" - if toolResult.Success { - statusMark = "OK" - } - emitStage("schedule_plan.weekly_refine.tool_call", fmt.Sprintf("[%s] %s", statusMark, logLine)) - - // 5. 把“本轮输出 + 工具结果”拼回下一轮上下文,驱动增量推理。 - messages = append(messages, schema.AssistantMessage(content, nil)) - toolResultJSON, _ := json.Marshal([]reactToolResult{toolResult}) - messages = append(messages, schema.UserMessage( - fmt.Sprintf( - "上一轮工具结果:%s\n当前预算:总剩余=%d,有效剩余=%d\n请继续按“单步动作”规则决策(仅一个工具调用或 done)。", - string(toolResultJSON), - totalBudget-result.TotalUsed, - effectiveBudget-result.EffectiveUsed, - ), - )) - } - - if strings.TrimSpace(result.Summary) == "" { - result.Summary = fmt.Sprintf( - "W%d 预算耗尽停止(总动作已用 %d/%d,有效动作已用 %d/%d)。", - week, - result.TotalUsed, totalBudget, - result.EffectiveUsed, effectiveBudget, - ) - } - return result, nil -} - -// generateWeeklyRefineRound 调用模型生成“单周单步”决策输出。 -// -// 说明: -// 1. 周级仍保留 thinking(提高复杂排程准确率); -// 2. 但不把 reasoning 实时透传给前端,避免刷屏; -// 3. 仅返回最终 content,交给 JSON 解析器处理。 -func generateWeeklyRefineRound( - ctx context.Context, - chatModel *ark.ChatModel, - messages []*schema.Message, -) (string, error) { - return agentllm.GenerateScheduleWeeklyReactRound(ctx, chatModel, messages) -} - -// renderWeeklyPromptWithBudget 渲染周级单步优化的预算占位符。 -// -// 1. 保留旧占位符 {{budget*}} 兼容历史模板; -// 2. 新增 action_total/action_effective 占位符表达双预算语义; -// 3. 所有负值都会在这里兜底归零,避免传给模型异常预算。 -func renderWeeklyPromptWithBudget( - remainingEffective int, - effectiveBudget int, - usedEffective int, - remainingTotal int, - totalBudget int, - usedTotal int, -) string { - if effectiveBudget <= 0 { - effectiveBudget = schedulePlanDefaultWeeklyAdjustBudget - } - if totalBudget <= 0 { - totalBudget = schedulePlanDefaultWeeklyTotalBudget - } - if remainingEffective < 0 { - remainingEffective = 0 - } - if remainingTotal < 0 { - remainingTotal = 0 - } - if usedEffective < 0 { - usedEffective = 0 - } - if usedTotal < 0 { - usedTotal = 0 - } - if usedEffective > effectiveBudget { - usedEffective = effectiveBudget - } - if usedTotal > totalBudget { - usedTotal = totalBudget - } - - prompt := agentprompt.SchedulePlanWeeklyReactPrompt - prompt = strings.ReplaceAll(prompt, "{{action_total_remaining}}", fmt.Sprintf("%d", remainingTotal)) - prompt = strings.ReplaceAll(prompt, "{{action_total_budget}}", fmt.Sprintf("%d", totalBudget)) - prompt = strings.ReplaceAll(prompt, "{{action_total_used}}", fmt.Sprintf("%d", usedTotal)) - prompt = strings.ReplaceAll(prompt, "{{action_effective_remaining}}", fmt.Sprintf("%d", remainingEffective)) - prompt = strings.ReplaceAll(prompt, "{{action_effective_budget}}", fmt.Sprintf("%d", effectiveBudget)) - prompt = strings.ReplaceAll(prompt, "{{action_effective_used}}", fmt.Sprintf("%d", usedEffective)) - - // 兼容旧模板占位符,避免历史 prompt 残留时出现未替换文本。 - prompt = strings.ReplaceAll(prompt, "{{budget_remaining}}", fmt.Sprintf("%d", remainingEffective)) - prompt = strings.ReplaceAll(prompt, "{{budget_total}}", fmt.Sprintf("%d", effectiveBudget)) - prompt = strings.ReplaceAll(prompt, "{{budget_used}}", fmt.Sprintf("%d", usedEffective)) - prompt = strings.ReplaceAll(prompt, "{{budget}}", fmt.Sprintf("%d(总额度 %d,已用 %d)", remainingEffective, effectiveBudget, usedEffective)) - return prompt -} - -// pickSingleToolCall 在“单步动作模式”下选择一个工具调用。 -// -// 返回语义: -// 1. call=nil:没有可执行工具; -// 2. warn 非空:模型返回了多个工具,本轮仅执行第一个。 -func pickSingleToolCall(toolCalls []reactToolCall) (*reactToolCall, string) { - if len(toolCalls) == 0 { - return nil, "" - } - call := toolCalls[0] - if len(toolCalls) == 1 { - return &call, "" - } - return &call, fmt.Sprintf("模型返回了 %d 个工具调用,单步模式仅执行第一个:%s", len(toolCalls), call.Tool) -} - -// splitHybridEntriesByWeek 按 week 对混合条目分组并返回稳定周序。 -func splitHybridEntriesByWeek(entries []model.HybridScheduleEntry) ([]int, map[int][]model.HybridScheduleEntry) { - byWeek := make(map[int][]model.HybridScheduleEntry) - for _, entry := range entries { - byWeek[entry.Week] = append(byWeek[entry.Week], entry) - } - weeks := make([]int, 0, len(byWeek)) - for week := range byWeek { - weeks = append(weeks, week) - } - sort.Ints(weeks) - return weeks, byWeek -} - -type weightedBudgetRemainder struct { - Index int - Remainder int - Load int -} - -// splitWeeklyBudgetsByLoad 根据“有效周保底 + 周负载加权”拆分预算。 -// -// 职责边界: -// 1. 负责:返回与 activeWeeks 同索引对齐的总预算/有效预算; -// 2. 负责:在预算不足时按负载优先覆盖高负载周; -// 3. 不负责:执行周级动作与状态落盘(由 runSingleWeekRefineWorker / runWeeklyRefineNode 负责)。 -// -// 输入输出语义: -// 1. coveredWeeks 表示“同时拿到 >=1 总预算和 >=1 有效预算”的周数; -// 2. 当任一全局预算 <=0 时,返回全 0;上游将据此跳过对应周优化; -// 3. 返回的 weeklyLoads 仅用于可观测性,不参与后续状态持久化。 -func splitWeeklyBudgetsByLoad( - activeWeeks []int, - weekEntries map[int][]model.HybridScheduleEntry, - totalBudget int, - effectiveBudget int, -) (totalByWeek []int, effectiveByWeek []int, weeklyLoads []int, coveredWeeks int) { - weekCount := len(activeWeeks) - if weekCount == 0 { - return nil, nil, nil, 0 - } - - if totalBudget < 0 { - totalBudget = 0 - } - if effectiveBudget < 0 { - effectiveBudget = 0 - } - - weeklyLoads = buildWeeklyLoadScores(activeWeeks, weekEntries) - totalByWeek = make([]int, weekCount) - effectiveByWeek = make([]int, weekCount) - if totalBudget == 0 || effectiveBudget == 0 { - return totalByWeek, effectiveByWeek, weeklyLoads, 0 - } - - // 1. 先计算“可保底覆盖周数”。 - // 1.1 目标是每个有效周至少 1 个总预算 + 1 个有效预算; - // 1.2 失败场景:当预算小于有效周数量时,不可能全覆盖; - // 1.3 兜底策略:只覆盖高负载周,避免把预算分散到无法执行的周。 - coveredWeeks = weekCount - if totalBudget < coveredWeeks { - coveredWeeks = totalBudget - } - if effectiveBudget < coveredWeeks { - coveredWeeks = effectiveBudget - } - if coveredWeeks <= 0 { - return totalByWeek, effectiveByWeek, weeklyLoads, 0 - } - - coveredIndexes := pickTopLoadWeekIndexes(weeklyLoads, coveredWeeks) - for _, idx := range coveredIndexes { - totalByWeek[idx]++ - effectiveByWeek[idx]++ - } - - // 2. 再把剩余预算按周负载加权分配。 - // 2.1 判断依据:负载越高,给到的额外预算越多,优先解决高密度周; - // 2.2 失败场景:负载异常(<=0)会导致权重失真; - // 2.3 兜底策略:权重最小按 1 处理,保证分配可持续、不会 panic。 - addWeightedBudget(totalByWeek, weeklyLoads, coveredIndexes, totalBudget-coveredWeeks) - addWeightedBudget(effectiveByWeek, weeklyLoads, coveredIndexes, effectiveBudget-coveredWeeks) - return totalByWeek, effectiveByWeek, weeklyLoads, coveredWeeks -} - -// buildWeeklyLoadScores 计算每个有效周的负载评分。 -// -// 职责边界: -// 1. 负责:以 suggested 任务的节次跨度作为周负载; -// 2. 不负责:预算分配策略与排序决策(由 splitWeeklyBudgetsByLoad/pickTopLoadWeekIndexes 负责)。 -func buildWeeklyLoadScores( - activeWeeks []int, - weekEntries map[int][]model.HybridScheduleEntry, -) []int { - loads := make([]int, len(activeWeeks)) - for idx, week := range activeWeeks { - load := 0 - for _, entry := range weekEntries[week] { - if entry.Status != "suggested" { - continue - } - span := entry.SectionTo - entry.SectionFrom + 1 - if span <= 0 { - span = 1 - } - load += span - } - if load <= 0 { - // 兜底:脏数据或异常节次下仍给该周最小权重,避免被完全饿死。 - load = 1 - } - loads[idx] = load - } - return loads -} - -// pickTopLoadWeekIndexes 选择负载最高的 topN 个周索引。 -func pickTopLoadWeekIndexes(loads []int, topN int) []int { - if topN <= 0 || len(loads) == 0 { - return nil - } - indexes := make([]int, len(loads)) - for i := range loads { - indexes[i] = i - } - sort.SliceStable(indexes, func(i, j int) bool { - left := loads[indexes[i]] - right := loads[indexes[j]] - if left != right { - return left > right - } - return indexes[i] < indexes[j] - }) - if topN > len(indexes) { - topN = len(indexes) - } - selected := append([]int(nil), indexes[:topN]...) - sort.Ints(selected) - return selected -} - -// addWeightedBudget 把剩余预算按权重分配到目标周。 -// -// 说明: -// 1. 先按整数份额分配; -// 2. 再按“最大余数法”分发尾差,保证总和严格守恒; -// 3. 余数相同时优先高负载周,再按索引稳定排序,避免结果抖动。 -func addWeightedBudget( - budgets []int, - loads []int, - targetIndexes []int, - remainingBudget int, -) { - if remainingBudget <= 0 || len(targetIndexes) == 0 { - return - } - - totalLoad := 0 - normalizedLoadByIndex := make(map[int]int, len(targetIndexes)) - for _, idx := range targetIndexes { - load := 1 - if idx >= 0 && idx < len(loads) && loads[idx] > 0 { - load = loads[idx] - } - normalizedLoadByIndex[idx] = load - totalLoad += load - } - if totalLoad <= 0 { - // 理论上不会出现;兜底均匀轮询分配,保证不会丢预算。 - for i := 0; i < remainingBudget; i++ { - budgets[targetIndexes[i%len(targetIndexes)]]++ - } - return - } - - allocated := 0 - remainders := make([]weightedBudgetRemainder, 0, len(targetIndexes)) - for _, idx := range targetIndexes { - load := normalizedLoadByIndex[idx] - shareProduct := remainingBudget * load - share := shareProduct / totalLoad - budgets[idx] += share - allocated += share - remainders = append(remainders, weightedBudgetRemainder{ - Index: idx, - Remainder: shareProduct % totalLoad, - Load: load, - }) - } - - left := remainingBudget - allocated - if left <= 0 { - return - } - sort.SliceStable(remainders, func(i, j int) bool { - if remainders[i].Remainder != remainders[j].Remainder { - return remainders[i].Remainder > remainders[j].Remainder - } - if remainders[i].Load != remainders[j].Load { - return remainders[i].Load > remainders[j].Load - } - return remainders[i].Index < remainders[j].Index - }) - for i := 0; i < left; i++ { - budgets[remainders[i%len(remainders)].Index]++ - } -} - -// sortHybridEntries 对条目做稳定排序,确保后续预览输出稳定。 -func sortHybridEntries(entries []model.HybridScheduleEntry) { - sort.SliceStable(entries, func(i, j int) bool { - left := entries[i] - right := entries[j] - if left.Week != right.Week { - return left.Week < right.Week - } - if left.DayOfWeek != right.DayOfWeek { - return left.DayOfWeek < right.DayOfWeek - } - if left.SectionFrom != right.SectionFrom { - return left.SectionFrom < right.SectionFrom - } - if left.SectionTo != right.SectionTo { - return left.SectionTo < right.SectionTo - } - if left.Status != right.Status { - // existing 放前,suggested 放后,便于观察课表底板与建议层。 - return left.Status < right.Status - } - return left.Name < right.Name - }) -} - -// runFinalCheckNode 负责“终审校验 + 总结生成”。 -// -// 职责边界: -// 1. 负责执行物理校验(冲突、节次越界、数量核对); -// 2. 负责在校验失败时回退到 MergeSnapshot; -// 3. 负责生成最终给用户看的自然语言总结; -// 4. 不负责写库(本期只做预览)。 -func runFinalCheckNode( - ctx context.Context, - st *SchedulePlanState, - chatModel *ark.ChatModel, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - if st == nil { - return nil, fmt.Errorf("schedule plan final check: nil state") - } - - emitStage("schedule_plan.final_check.start", "正在进行终审校验。") - - // 1. 先做物理校验。 - issues := physicsCheck(st) - if len(issues) > 0 { - emitStage("schedule_plan.final_check.issues", fmt.Sprintf("发现 %d 个问题,已回退到日内优化结果。", len(issues))) - // 1.1 回退策略: - // 1.1.1 优先回退到 merge 快照(已经过冲突校验); - // 1.1.2 若快照为空,保留当前结果继续走总结,保证可返回。 - if len(st.MergeSnapshot) > 0 { - st.HybridEntries = deepCopyEntries(st.MergeSnapshot) - } - } - - // 2. 生成人性化总结。 - // - // 2.1 总结失败不影响主流程; - // 2.2 失败时使用兜底文案,保证前端始终有可展示文本。 - summary, err := generateHumanSummary(ctx, chatModel, st.HybridEntries, st.Constraints, st.WeeklyActionLogs) - if err != nil || strings.TrimSpace(summary) == "" { - st.FinalSummary = fmt.Sprintf("排程优化完成,共安排了 %d 个任务。", countSuggested(st.HybridEntries)) - } else { - st.FinalSummary = strings.TrimSpace(summary) - } - - emitStage("schedule_plan.final_check.done", "终审校验完成。") - return st, nil -} - -// physicsCheck 执行物理层面校验。 -// -// 校验项: -// 1. 时间冲突:同一 slot 不允许多任务占用; -// 2. 节次越界:section 必须落在 1..12 且 from<=to; -// 3. 数量核对:suggested 数量应与原始 AllocatedItems 数量一致。 -func physicsCheck(st *SchedulePlanState) []string { - issues := make([]string, 0) - if st == nil { - return append(issues, "state 为空") - } - - // 1. 时间冲突校验。 - if conflict := detectConflicts(st.HybridEntries); conflict != "" { - issues = append(issues, "时间冲突:"+conflict) - } - - // 2. 节次越界校验。 - for _, entry := range st.HybridEntries { - if entry.SectionFrom < 1 || entry.SectionTo > 12 || entry.SectionFrom > entry.SectionTo { - issues = append( - issues, - fmt.Sprintf("节次越界:[%s] W%dD%d 第%d-%d节", entry.Name, entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo), - ) - } - } - - // 3. 数量一致性校验。 - // 3.1 判断依据:suggested 表示“待应用任务块”,应与 allocatedItems 数量匹配; - // 3.2 若不匹配,可能表示工具调用丢失或重复覆盖。 - suggestedCount := countSuggested(st.HybridEntries) - if suggestedCount != len(st.AllocatedItems) { - issues = append( - issues, - fmt.Sprintf("任务数量不匹配:suggested=%d,原始分配=%d", suggestedCount, len(st.AllocatedItems)), - ) - } - - return issues -} - -// countSuggested 统计 suggested 条目数量。 -func countSuggested(entries []model.HybridScheduleEntry) int { - count := 0 - for _, entry := range entries { - if entry.Status == "suggested" { - count++ - } - } - return count -} - -// generateHumanSummary 调用模型生成“用户可读”的总结文案。 -// -// 职责边界: -// 1. 只做读模型,不修改任何 state; -// 2. 输出纯文本; -// 3. 失败时把错误返回给上层,由上层决定兜底文案。 -func generateHumanSummary( - ctx context.Context, - chatModel *ark.ChatModel, - entries []model.HybridScheduleEntry, - constraints []string, - actionLogs []string, -) (string, error) { - return agentllm.GenerateScheduleHumanSummary(ctx, chatModel, entries, constraints, actionLogs) -} diff --git a/backend/agent/node/schedule_plan_tool.go b/backend/agent/node/schedule_plan_tool.go deleted file mode 100644 index cd667ff..0000000 --- a/backend/agent/node/schedule_plan_tool.go +++ /dev/null @@ -1,571 +0,0 @@ -package agentnode - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strconv" - "strings" - - agentllm "github.com/LoveLosita/smartflow/backend/agent/llm" - "github.com/LoveLosita/smartflow/backend/model" -) - -// SchedulePlanToolDeps 描述“智能排程 graph”运行所需的外部业务依赖。 -// -// 职责边界: -// 1. 只负责声明“需要哪些能力”,不负责具体实现(实现由 service 层注入)。 -// 2. 只收口函数签名,不承载业务状态,避免跨请求共享可变数据。 -// 3. 当前统一采用 task_class_ids 语义,不再依赖单 task_class_id 主路径。 -type SchedulePlanToolDeps struct { - // SmartPlanningMultiRaw 是可选依赖: - // 1) 用于需要单独输出“粗排预览”时复用; - // 2) 当前主链路已由 HybridScheduleWithPlanMulti 覆盖,可不注入。 - SmartPlanningMultiRaw func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) - - // HybridScheduleWithPlanMulti 把“既有日程 + 粗排结果”合并成统一的 HybridScheduleEntry 切片, - // 供 daily/weekly ReAct 节点在内存中继续优化。 - HybridScheduleWithPlanMulti func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) - - // ResolvePlanningWindow 根据 task_class_ids 解析“全局排程窗口”的相对周/天边界。 - // - // 返回语义: - // 1. startWeek/startDay:窗口起点(含); - // 2. endWeek/endDay:窗口终点(含); - // 3. error:解析失败(如任务类不存在、日期非法)。 - // - // 用途: - // 1. 给周级 Move 工具加硬边界,避免把任务移动到窗口外的天数; - // 2. 解决“首尾不足一周”场景下的周内越界问题。 - ResolvePlanningWindow func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error) -} - -// Validate 校验依赖完整性。 -// -// 失败处理: -// 1. 任意依赖缺失都直接返回错误,避免 graph 运行到中途才 panic。 -// 2. 调用方(runSchedulePlanFlow)收到错误后会走回退链路,不影响普通聊天可用性。 -func (d SchedulePlanToolDeps) Validate() error { - if d.HybridScheduleWithPlanMulti == nil { - return errors.New("schedule plan tool deps: HybridScheduleWithPlanMulti 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 - } -} - -// ExtraIntSlice 从 extra map 中安全提取整数切片。 -// -// 兼容输入: -// 1) []any(JSON 数组反序列化后的常见类型); -// 2) []int; -// 3) []float64; -// 4) 逗号分隔字符串(例如 "1,2,3")。 -// -// 返回语义: -// 1) ok=true:至少成功解析出一个整数; -// 2) ok=false:字段不存在或全部解析失败。 -func ExtraIntSlice(extra map[string]any, key string) ([]int, bool) { - v, exists := extra[key] - if !exists { - return nil, false - } - - parseOne := func(raw any) (int, error) { - switch n := raw.(type) { - case int: - return n, nil - case float64: - return int(n), nil - case string: - i, err := strconv.Atoi(n) - if err != nil { - return 0, err - } - return i, nil - default: - return 0, fmt.Errorf("unsupported type: %T", raw) - } - } - - out := make([]int, 0) - switch arr := v.(type) { - case []int: - for _, item := range arr { - out = append(out, item) - } - case []float64: - for _, item := range arr { - out = append(out, int(item)) - } - case []any: - for _, item := range arr { - if parsed, err := parseOne(item); err == nil { - out = append(out, parsed) - } - } - case string: - parts := strings.Split(arr, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if parsed, err := strconv.Atoi(part); err == nil { - out = append(out, parsed) - } - } - default: - return nil, false - } - - if len(out) == 0 { - return nil, false - } - return out, true -} - -// ── ReAct Tool 调用/结果结构 ── - -// reactToolCall 是 LLM 输出的单个工具调用。 -type reactToolCall = agentllm.ReactToolCall - -// reactToolResult 是单个工具调用的执行结果。 -type reactToolResult struct { - Tool string `json:"tool"` - Success bool `json:"success"` - Result string `json:"result"` -} - -// reactLLMOutput 是 LLM 输出的完整 JSON 结构。 -type reactLLMOutput = agentllm.ReactLLMOutput - -// weeklyPlanningWindow 表示周级优化可用的全局周/天窗口。 -// -// 语义: -// 1. Enabled=false:不启用窗口硬边界,仅做基础合法性校验; -// 2. Enabled=true:Move 必须落在 [StartWeek/StartDay, EndWeek/EndDay] 内; -// 3. 该窗口用于处理“首尾不足一周”场景下的越界移动问题。 -type weeklyPlanningWindow struct { - Enabled bool - StartWeek int - StartDay int - EndWeek int - EndDay int -} - -// ── 工具分发器 ── - -// dispatchReactTool 根据工具名分发调用,返回(可能修改后的)entries 和执行结果。 -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)} - } -} - -// dispatchWeeklySingleActionTool 是“周级单步动作模式”的专用分发器。 -// -// 职责边界: -// 1. 仅允许 Move / Swap 两个工具,禁止 TimeAvailable / GetAvailableSlots; -// 2. 强制 Move 的目标周必须等于 currentWeek,避免并发周优化时发生跨周写穿; -// 3. 统一返回工具执行结果,供上层决定预算扣减与下一轮上下文拼接。 -func dispatchWeeklySingleActionTool(entries []model.HybridScheduleEntry, call reactToolCall, currentWeek int, window weeklyPlanningWindow) ([]model.HybridScheduleEntry, reactToolResult) { - tool := strings.TrimSpace(call.Tool) - switch tool { - case "Swap": - return reactToolSwap(entries, call.Params) - case "Move": - // 1. 周级并发模式下,每个 worker 只负责单周数据。 - // 2. 为避免“一个 worker 改到别的周”导致并发写冲突,这里做硬约束。 - // 3. 失败时不抛异常,返回工具失败结果,让上层继续下一轮决策。 - toWeek, ok := paramInt(call.Params, "to_week") - if !ok { - return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week"} - } - if toWeek != currentWeek { - return entries, reactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("当前仅允许优化本周:worker_week=%d,目标周=%d", currentWeek, toWeek), - } - } - // 4. 若已配置全局窗口边界,再做“首尾不足一周”硬校验。 - // 4.1 这样可避免把任务移动到窗口外的天数(例如起始周的起始日前、结束周的结束日后)。 - // 4.2 窗口未启用时不阻断,保持兼容旧链路。 - if window.Enabled { - toDay, ok := paramInt(call.Params, "to_day") - if !ok { - return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_day"} - } - allowed, dayFrom, dayTo := isDayWithinPlanningWindow(window, toWeek, toDay) - if !allowed { - return entries, reactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("目标日期超出排程窗口:W%d 仅允许 D%d-D%d,当前目标为 D%d", toWeek, dayFrom, dayTo, toDay), - } - } - } - return reactToolMove(entries, call.Params) - default: - return entries, reactToolResult{ - Tool: tool, - Success: false, - Result: fmt.Sprintf("周级单步模式不支持工具: %s,仅允许 Move/Swap", tool), - } - } -} - -// isDayWithinPlanningWindow 判断目标 week/day 是否落在窗口范围内。 -// -// 返回值: -// 1. allowed:是否允许; -// 2. dayFrom/dayTo:该周允许的 day 区间(用于错误提示)。 -func isDayWithinPlanningWindow(window weeklyPlanningWindow, week int, day int) (allowed bool, dayFrom int, dayTo int) { - // 1. 窗口未启用时默认允许(调用方会跳过此分支,这里是兜底)。 - if !window.Enabled { - return true, 1, 7 - } - // 2. 先做周范围校验。 - if week < window.StartWeek || week > window.EndWeek { - return false, 1, 7 - } - // 3. 计算当前周允许的 day 边界。 - from := 1 - to := 7 - if week == window.StartWeek { - from = window.StartDay - } - if week == window.EndWeek { - to = window.EndDay - } - if day < from || day > to { - return false, from, to - } - return true, from, to -} - -// ── 参数提取辅助 ── - -func paramInt(params map[string]any, key string) (int, bool) { - 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 -} - -// entryBlocksSuggested 判断某条目是否应阻塞 suggested 任务占位。 -// -// 规则: -// 1. suggested 任务永远阻塞(任务之间不能重叠); -// 2. existing 条目按 BlockForSuggested 字段决定; -// 3. 其余场景默认阻塞(保守策略,避免放出脏可用槽)。 -func entryBlocksSuggested(entry model.HybridScheduleEntry) bool { - if entry.Status == "suggested" { - return true - } - // existing 走显式字段语义。 - if entry.Status == "existing" { - return entry.BlockForSuggested - } - // 未知状态兜底:按阻塞处理。 - return true -} - -// hasConflict 检查目标时间段是否与 entries 中任何条目冲突(排除 excludeIdx)。 -func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st, excludeIdx int) (bool, string) { - for i, e := range entries { - if i == excludeIdx { - continue - } - // 1. 可嵌入且未占用的课程槽(BlockForSuggested=false)不参与冲突判断。 - // 2. 这样可以避免把“水课可嵌入位”误判为硬冲突。 - if !entryBlocksSuggested(e) { - continue - } - if e.Week == week && e.DayOfWeek == day && sectionsOverlap(e.SectionFrom, e.SectionTo, sf, st) { - return true, fmt.Sprintf("%s(%s)", e.Name, e.Type) - } - } - 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 { - if !entryBlocksSuggested(e) { - continue - } - 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) { - return agentllm.ParseScheduleReactOutput(raw) -} - -// truncate 截断字符串到指定长度。 -func truncate(s string, maxLen int) string { - if maxLen <= 0 { - return "" - } - runes := []rune(s) - if len(runes) <= maxLen { - return s - } - return string(runes[:maxLen]) + "..." -} diff --git a/backend/agent/node/schedule_refine.go b/backend/agent/node/schedule_refine.go deleted file mode 100644 index 3b312b3..0000000 --- a/backend/agent/node/schedule_refine.go +++ /dev/null @@ -1,3526 +0,0 @@ -package agentnode - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "regexp" - "sort" - "strconv" - "strings" - "time" - - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt" - agentshared "github.com/LoveLosita/smartflow/backend/agent/shared" - "github.com/LoveLosita/smartflow/backend/model" - "github.com/LoveLosita/smartflow/backend/respond" - "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" -) - -const ( - // ScheduleRefineGraphNodeContract 是“抽取微调契约”的节点名。 - ScheduleRefineGraphNodeContract = "schedule_refine_contract" - // ScheduleRefineGraphNodePlan 是“生成执行计划”的节点名。 - ScheduleRefineGraphNodePlan = "schedule_refine_plan" - // ScheduleRefineGraphNodeSlice 是“生成任务切片”的节点名。 - ScheduleRefineGraphNodeSlice = "schedule_refine_slice" - // ScheduleRefineGraphNodeRoute 是“复合路由尝试”的节点名。 - ScheduleRefineGraphNodeRoute = "schedule_refine_route" - // ScheduleRefineGraphNodeReact 是“单任务 ReAct 微调”的节点名。 - ScheduleRefineGraphNodeReact = "schedule_refine_react" - // ScheduleRefineGraphNodeHardCheck 是“终审硬校验”的节点名。 - ScheduleRefineGraphNodeHardCheck = "schedule_refine_hard_check" - // ScheduleRefineGraphNodeSummary 是“生成总结回复”的节点名。 - ScheduleRefineGraphNodeSummary = "schedule_refine_summary" -) - -const ( - defaultExecuteMax = agentmodel.ScheduleRefineDefaultExecuteMax - defaultPerTaskBudget = agentmodel.ScheduleRefineDefaultPerTaskBudget - defaultReplanMax = agentmodel.ScheduleRefineDefaultReplanMax - defaultCompositeRetry = agentmodel.ScheduleRefineDefaultCompositeRetry -) - -type ( - // ScheduleRefineState 是 node 层对连续微调状态的本地别名。 - ScheduleRefineState = agentmodel.ScheduleRefineState - // RefineContract 是连续微调契约的本地别名。 - RefineContract = agentmodel.RefineContract - // RefineAssertion 是结构化硬断言的本地别名。 - RefineAssertion = agentmodel.RefineAssertion - // HardCheckReport 是终审报告的本地别名。 - HardCheckReport = agentmodel.HardCheckReport - // ReactRoundObservation 是 ReAct 轮次观察的本地别名。 - ReactRoundObservation = agentmodel.ReactRoundObservation - // PlannerPlan 是阶段计划的本地别名。 - PlannerPlan = agentmodel.PlannerPlan - // RefineSlicePlan 是切片计划的本地别名。 - RefineSlicePlan = agentmodel.RefineSlicePlan - // RefineObjective 是目标编译结果的本地别名。 - RefineObjective = agentmodel.RefineObjective -) - -func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { - return agentshared.CloneHybridEntries(src) -} - -func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { - return agentshared.CloneTaskClassItems(src) -} - -func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { - return agentshared.CloneWeekSchedules(src) -} - -// ScheduleRefineGraphRunInput 描述“连续微调图”运行所需输入。 -// -// 字段说明: -// 1. Model:本轮图运行使用的聊天模型。 -// 2. State:预先注入的微调状态,通常来自上一版预览快照。 -// 3. EmitStage:阶段回调,允许服务层把进度透传给前端。 -type ScheduleRefineGraphRunInput struct { - Model *ark.ChatModel - State *agentmodel.ScheduleRefineState - EmitStage func(stage, detail string) -} - -// NewScheduleRefineState 基于上一版预览快照初始化连续微调状态。 -func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState { - return agentmodel.NewScheduleRefineState(traceID, userID, conversationID, userMessage, preview) -} - -// FinalHardCheckPassed 判断最终终审是否整体通过。 -func FinalHardCheckPassed(st *ScheduleRefineState) bool { - return agentmodel.FinalHardCheckPassed(st) -} - -// ScheduleRefineNodes 是连续微调图的节点容器。 -// -// 职责边界: -// 1. 负责收口模型与阶段回调。 -// 2. 负责向 graph 层暴露可直接挂载的方法。 -// 3. 不负责 graph 编译与 service 接线。 -type ScheduleRefineNodes struct { - input ScheduleRefineGraphRunInput - emitStage func(stage, detail string) -} - -// NewScheduleRefineNodes 创建连续微调节点容器。 -func NewScheduleRefineNodes(input ScheduleRefineGraphRunInput) (*ScheduleRefineNodes, error) { - if input.Model == nil { - return nil, errors.New("schedule refine nodes: model is nil") - } - if input.State == nil { - return nil, errors.New("schedule refine nodes: state is nil") - } - - emitStage := input.EmitStage - if emitStage == nil { - emitStage = func(stage, detail string) {} - } - - return &ScheduleRefineNodes{ - input: input, - emitStage: emitStage, - }, nil -} - -// Contract 负责承接“契约抽取”节点。 -func (n *ScheduleRefineNodes) Contract(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return scheduleRefineRunContractNode(ctx, n.input.Model, st, n.emitStage) -} - -// Plan 负责承接“执行计划生成”节点。 -func (n *ScheduleRefineNodes) Plan(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return scheduleRefineRunPlanNode(ctx, n.input.Model, st, n.emitStage) -} - -// Slice 负责承接“任务切片”节点。 -func (n *ScheduleRefineNodes) Slice(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return scheduleRefineRunSliceNode(ctx, st, n.emitStage) -} - -// Route 负责承接“复合工具路由”节点。 -func (n *ScheduleRefineNodes) Route(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return scheduleRefineRunCompositeRouteNode(ctx, st, n.emitStage) -} - -// React 负责承接“单任务微步 ReAct”节点。 -func (n *ScheduleRefineNodes) React(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return scheduleRefineRunReactLoopNode(ctx, n.input.Model, st, n.emitStage) -} - -// HardCheck 负责承接“终审硬校验”节点。 -func (n *ScheduleRefineNodes) HardCheck(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return scheduleRefineRunHardCheckNode(ctx, n.input.Model, st, n.emitStage) -} - -// Summary 负责承接“最终总结”节点。 -func (n *ScheduleRefineNodes) Summary(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return scheduleRefineRunSummaryNode(ctx, n.input.Model, st, n.emitStage) -} - -const ( - nodeTimeout = 120 * time.Second - plannerMaxTokens = 420 - reactMaxTokens = 360 -) - -const ( - jsonContractForContract = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: intent,strategy,hard_requirements,hard_assertions,keep_relative_order,order_scope。" - jsonContractForPlanner = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: summary,steps。" - jsonContractForReact = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: done,summary,goal_check,decision,missing_info,tool_calls。" - jsonContractForReview = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: pass,reason,unmet。" - jsonContractForPostReflect = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: reflection,next_strategy,should_stop。" -) - -type scheduleRefineContractOutput struct { - Intent string `json:"intent"` - Strategy string `json:"strategy"` - HardRequirements []string `json:"hard_requirements"` - HardAssertions []scheduleRefineHardAssertionOutput `json:"hard_assertions"` - KeepRelativeOrder bool `json:"keep_relative_order"` - OrderScope string `json:"order_scope"` -} - -type scheduleRefineHardAssertionOutput struct { - Metric string `json:"metric"` - Operator string `json:"operator"` - Value int `json:"value"` - Min int `json:"min"` - Max int `json:"max"` - Week int `json:"week"` - TargetWeek int `json:"target_week"` -} - -type scheduleRefinePostReflectOutput struct { - Reflection string `json:"reflection"` - NextStrategy string `json:"next_strategy"` - ShouldStop bool `json:"should_stop"` -} - -type scheduleRefinePlannerOutput struct { - Summary string `json:"summary"` - Steps []string `json:"steps"` -} - -func scheduleRefineRunContractNode( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in contract node") - } - if chatModel == nil { - return nil, fmt.Errorf("schedule refine: model is nil in contract node") - } - emitStage("schedule_refine.contract.analyzing", "正在抽取本轮微调目标与硬性约束。") - - userPrompt := scheduleRefineWithNearestJSONContract( - fmt.Sprintf( - "当前时间=%s\n用户请求=%s\n当前排程条目数=%d\n可调 suggested 数=%d\n已有约束=%s\n历史摘要=%s", - st.RequestNowText, - strings.TrimSpace(st.UserMessage), - len(st.HybridEntries), - scheduleRefineCountSuggested(st.HybridEntries), - strings.Join(st.Constraints, ";"), - scheduleRefineCondenseSummary(st.CandidatePlans), - ), - jsonContractForContract, - ) - raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineContractPrompt, userPrompt, false, 260, 0) - if err != nil { - st.Contract = scheduleRefineBuildFallbackContract(st) - st.UserIntent = st.Contract.Intent - emitStage("schedule_refine.contract.fallback", "契约抽取失败,已按兜底策略继续微调。") - return st, nil - } - scheduleRefineEmitModelRawDebug(emitStage, "contract", raw) - parsed, parseErr := scheduleRefineParseJSON[scheduleRefineContractOutput](raw) - if parseErr != nil { - st.Contract = scheduleRefineBuildFallbackContract(st) - st.UserIntent = st.Contract.Intent - emitStage("schedule_refine.contract.fallback", fmt.Sprintf("契约解析失败,已按兜底策略继续微调:%s", scheduleRefineTruncate(parseErr.Error(), 180))) - return st, nil - } - - intent := strings.TrimSpace(parsed.Intent) - if intent == "" { - intent = strings.TrimSpace(st.UserMessage) - } - // 1. 顺序策略以用户表达为准:默认保持顺序,明确授权乱序才放开。 - // 2. 不再让模型自行放宽顺序,避免契约漂移导致“默认乱序”。 - keepOrder := scheduleRefineDetectOrderIntent(st.UserMessage) - reqs := append([]string(nil), parsed.HardRequirements...) - if keepOrder { - reqs = append(reqs, "保持任务原始相对顺序不变") - } - assertions := scheduleRefineNormalizeHardAssertions(parsed.HardAssertions) - if len(assertions) == 0 { - // 1. 当模型未给出结构化断言时,后端基于请求做兜底推断。 - // 2. 目标是保证终审一定可落到“可编程判断”的参数层,而不是停留在自然语言。 - assertions = scheduleRefineInferHardAssertionsFromRequest(st.UserMessage, reqs) - } - st.UserIntent = intent - st.Contract = RefineContract{ - Intent: intent, - Strategy: scheduleRefineNormalizeStrategy(parsed.Strategy), - HardRequirements: scheduleRefineUniqueNonEmpty(reqs), - HardAssertions: assertions, - KeepRelativeOrder: keepOrder, - OrderScope: scheduleRefineNormalizeOrderScope(parsed.OrderScope), - } - emitStage("schedule_refine.contract.done", fmt.Sprintf("契约抽取完成:strategy=%s, keep_relative_order=%t。", st.Contract.Strategy, st.Contract.KeepRelativeOrder)) - return st, nil -} - -func scheduleRefineRunPlanNode( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in plan node") - } - if chatModel == nil { - return nil, fmt.Errorf("schedule refine: model is nil in plan node") - } - if err := scheduleRefineRunPlannerNode(ctx, chatModel, st, emitStage, "initial"); err != nil { - return st, err - } - return st, nil -} - -func scheduleRefineRunSliceNode( - ctx context.Context, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - _ = ctx - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in slice node") - } - emitStage("schedule_refine.slice.building", "正在构建本轮微调任务切片。") - slice := scheduleRefineBuildSlicePlan(st) - workset := scheduleRefineCollectWorksetTaskIDs(st.HybridEntries, slice, st.OriginOrderMap) - if len(workset) == 0 { - relaxed := slice - relaxed.SourceDays = nil - workset = scheduleRefineCollectWorksetTaskIDs(st.HybridEntries, relaxed, st.OriginOrderMap) - if len(workset) > 0 { - slice = relaxed - emitStage("schedule_refine.slice.relaxed", "切片首次为空,已放宽来源日过滤。") - } - } - if len(workset) == 0 { - workset = scheduleRefineCollectWorksetTaskIDs(st.HybridEntries, RefineSlicePlan{}, st.OriginOrderMap) - emitStage("schedule_refine.slice.fallback", "切片仍为空,已回退到全量 suggested 任务。") - } - st.SlicePlan = slice - st.Objective = scheduleRefineCompileRefineObjective(st, slice) - st.WorksetTaskIDs = workset - st.WorksetCursor = 0 - st.CurrentTaskID = 0 - st.CurrentTaskAttempt = 0 - emitStage("schedule_refine.slice.done", fmt.Sprintf("切片完成:workset=%d,week_filter=%v,source_days=%v,target_days=%v,exclude_sections=%v。", len(workset), slice.WeekFilter, slice.SourceDays, slice.TargetDays, slice.ExcludeSections)) - if raw, err := json.Marshal(st.Objective); err == nil { - emitStage("schedule_refine.objective.done", fmt.Sprintf("目标编译完成:%s", string(raw))) - } else { - emitStage("schedule_refine.objective.done", "目标编译完成。") - } - return st, nil -} - -// scheduleRefineRunCompositeRouteNode 在 ReAct 之前做一次“全局复合动作直达”分流。 -// -// 职责边界: -// 1. 负责识别是否命中全局复合目标(SpreadEven/MinContextSwitch); -// 2. 负责直接调用一次复合工具并按配置重试,争取在进入 ReAct 前完成收口; -// 3. 不负责语义推理与逐任务细调,失败后仅负责切换到“禁复合”的 ReAct 兜底链路。 -func scheduleRefineRunCompositeRouteNode( - ctx context.Context, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - _ = ctx - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in route node") - } - scheduleRefineEnsureCompositeStateMaps(st) - if st.CompositeRetryMax < 0 { - st.CompositeRetryMax = defaultCompositeRetry - } - // 1. 先由后端判定本轮是否需要复合路由,避免把分流复杂度继续交给主 ReAct。 - // 2. 若已被上游标记为“禁复合兜底”,直接跳过该路由。 - if st.DisableCompositeTools { - emitStage("schedule_refine.route.skip", "当前已处于禁复合兜底模式,跳过复合路由。") - return st, nil - } - if strings.TrimSpace(st.RequiredCompositeTool) == "" { - st.RequiredCompositeTool = scheduleRefineDetectRequiredCompositeTool(st) - } - required := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) - if required == "" { - emitStage("schedule_refine.route.skip", "未命中全局复合目标,直接进入 ReAct 兜底链路。") - return st, nil - } - - taskIDs := scheduleRefineBuildCompositeRouteTaskIDs(st) - if len(taskIDs) == 0 { - // 1. 没有任务可用于复合规划时,复合路由无法落地。 - // 2. 直接降级到 ReAct,并明确禁用复合工具,避免循环重试同一失败路径。 - st.CompositeRouteTried = true - st.DisableCompositeTools = true - st.RequiredCompositeTool = "" - st.CurrentPlan = scheduleRefineBuildFallbackPlan(st) - st.BatchMoveAllowed = false - emitStage("schedule_refine.route.fallback", "复合路由未获取到可执行任务,已切换到禁复合 ReAct 兜底。") - return st, nil - } - - totalAttempts := 1 + st.CompositeRetryMax - emitStage("schedule_refine.route.start", fmt.Sprintf("命中复合路由:tool=%s,task_count=%d,首次1次+重试%d次。", required, len(taskIDs), st.CompositeRetryMax)) - st.CompositeRouteTried = true - - policy := scheduleRefineToolPolicy{ - // 1. 路由阶段只解决“坑位分布”。 - // 2. 顺序归位统一放在终审阶段,避免复合路由被顺序约束提前卡死。 - KeepRelativeOrder: false, - OrderScope: st.Contract.OrderScope, - OriginOrderMap: st.OriginOrderMap, - } - window := scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries) - lastReason := "" - - for attempt := 1; attempt <= totalAttempts; attempt++ { - if st.RoundUsed >= st.ExecuteMax { - lastReason = "动作预算已耗尽,无法继续复合路由重试" - break - } - call := scheduleRefineBuildCompositeRouteCall(st, required, taskIDs) - callJSON, _ := json.Marshal(call.Params) - emitStage("schedule_refine.route.attempt", fmt.Sprintf("复合路由第 %d/%d 次尝试:调用=%s 参数=%s。", attempt, totalAttempts, required, string(callJSON))) - - nextEntries, rawResult := dispatchScheduleRefineTool(cloneHybridEntries(st.HybridEntries), call, window, policy) - result := scheduleRefineNormalizeToolResult(rawResult) - st.RoundUsed++ - scheduleRefineMarkCompositeToolOutcome(st, result.Tool, result.Success) - emitStage("schedule_refine.route.result", fmt.Sprintf("复合路由第 %d 次结果:success=%t,error_code=%s,detail=%s", attempt, result.Success, scheduleRefineFallbackText(result.ErrorCode, "NONE"), scheduleRefineTruncate(result.Result, 160))) - - if !result.Success { - lastReason = scheduleRefineFallbackText(result.Result, scheduleRefineFallbackText(result.ErrorCode, "复合工具执行失败")) - st.LastFailedCallSignature = scheduleRefineBuildToolCallSignature(call) - st.ConsecutiveFailures++ - continue - } - - st.HybridEntries = nextEntries - st.EntriesVersion++ - st.LastFailedCallSignature = "" - st.ConsecutiveFailures = 0 - st.ThinkingBoostArmed = false - window = scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries) - - // 1. 复合动作成功后必须立刻做后端确定性校验,避免“调用成功但目标未达成”被误收口。 - // 2. 仅当业务目标与(若存在)复合门禁同时通过时,才允许跳过 ReAct。 - if pass, reason, unmet, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied { - pass, reason, unmet = scheduleRefineApplyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet) - if pass { - st.CompositeRouteSucceeded = true - emitStage("schedule_refine.route.pass", fmt.Sprintf("复合路由收口成功:%s", scheduleRefineTruncate(reason, 160))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由成功收口:tool=%s,reason=%s", required, reason)) - return st, nil - } - lastReason = scheduleRefineFallbackText(strings.TrimSpace(reason), "确定性目标未达成") - if len(unmet) > 0 { - emitStage("schedule_refine.route.unmet", fmt.Sprintf("复合路由第 %d 次后仍未达成:%s", attempt, scheduleRefineTruncate(strings.Join(unmet, ";"), 180))) - } - continue - } - - // 1. “均匀分散/最少上下文切换”这类复合目标,未必能编译成 deterministic objective; - // 2. 只要本轮要求的复合工具已经成功执行,就允许独立复合分支直接出站并跳过 ReAct; - // 3. 最终是否真正达标,继续交给 hard_check 统一裁决,避免“工具成功却被路由误判失败”。 - if reason, ok := scheduleRefineAllowCompositeRouteExitByToolSuccess(st, result); ok { - st.CompositeRouteSucceeded = true - emitStage("schedule_refine.route.handoff", scheduleRefineTruncate(reason, 180)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由直接出站:tool=%s,reason=%s", required, reason)) - return st, nil - } - - lastReason = "未启用确定性目标,且复合工具门禁未满足,无法在复合路由直接出站" - } - - // 1. 复合路由重试后仍失败,切入 ReAct 兜底并强制禁用复合工具。 - // 2. 禁用后仅允许基础工具逐任务搬运,避免再次回到复合失败路径造成震荡。 - st.DisableCompositeTools = true - st.RequiredCompositeTool = "" - st.CurrentPlan = scheduleRefineBuildFallbackPlan(st) - st.BatchMoveAllowed = false - emitStage("schedule_refine.route.fallback", fmt.Sprintf("复合路由未收口,切换禁复合 ReAct 兜底:%s", scheduleRefineTruncate(scheduleRefineFallbackText(lastReason, "复合路由达到重试上限"), 180))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由失败后降级:%s", scheduleRefineFallbackText(lastReason, "无具体失败原因"))) - return st, nil -} - -func scheduleRefineBuildCompositeRouteTaskIDs(st *ScheduleRefineState) []int { - if st == nil { - return nil - } - ids := scheduleRefineUniquePositiveInts(append([]int(nil), st.WorksetTaskIDs...)) - if len(ids) > 0 { - return ids - } - ids = scheduleRefineCollectSourceTaskIDsForObjective(st.InitialHybridEntries, st.Objective, st.SlicePlan.WeekFilter) - if len(ids) > 0 { - return ids - } - // 兜底:从当前 suggested 中提取一份稳定任务集,避免因切片异常导致路由空跑。 - seen := make(map[int]struct{}, len(st.HybridEntries)) - out := make([]int, 0, len(st.HybridEntries)) - for _, entry := range st.HybridEntries { - if !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - if _, ok := seen[entry.TaskItemID]; ok { - continue - } - seen[entry.TaskItemID] = struct{}{} - out = append(out, entry.TaskItemID) - } - sort.Ints(out) - return out -} - -// scheduleRefineAllowCompositeRouteExitByToolSuccess 判断“复合工具成功后,是否允许跳过 ReAct 直接进入终审”。 -// -// 步骤化说明: -// 1. 仅在当前没有 deterministic objective 时启用,避免覆盖原有“确定性验收优先”策略; -// 2. 只有本轮要求的复合工具已成功、且成功工具名与门禁一致时才放行; -// 3. 放行后并不代表最终成功,后续仍由 hard_check 做统一裁决。 -func scheduleRefineAllowCompositeRouteExitByToolSuccess(st *ScheduleRefineState, result scheduleRefineReactToolResult) (string, bool) { - if st == nil || !result.Success { - return "", false - } - if strings.TrimSpace(st.Objective.Mode) != "" && strings.TrimSpace(st.Objective.Mode) != "none" { - return "", false - } - required := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) - toolName := scheduleRefineNormalizeCompositeToolName(result.Tool) - if required == "" || toolName == "" || required != toolName { - return "", false - } - if !scheduleRefineIsRequiredCompositeSatisfied(st) { - return "", false - } - return fmt.Sprintf("复合工具 %s 已成功执行;当前目标暂不支持确定性收口,跳过 ReAct,交由终审裁决。", required), true -} - -func scheduleRefineBuildCompositeRouteCall(st *ScheduleRefineState, tool string, taskIDs []int) scheduleRefineReactToolCall { - limit := len(taskIDs) * 6 - if limit < 12 { - limit = 12 - } - params := map[string]any{ - "task_item_ids": append([]int(nil), taskIDs...), - "allow_embed": true, - "limit": limit, - } - targetWeeks := append([]int(nil), st.Objective.TargetWeeks...) - if len(targetWeeks) == 0 { - targetWeeks = scheduleRefineKeysOfIntSet(scheduleRefineInferTargetWeekSet(st.SlicePlan)) - } - if len(targetWeeks) == 0 { - targetWeeks = append([]int(nil), st.Objective.SourceWeeks...) - } - if len(targetWeeks) == 1 { - params["week"] = targetWeeks[0] - } else if len(targetWeeks) > 1 { - params["week_filter"] = targetWeeks - } - - targetDays := append([]int(nil), st.Objective.TargetDays...) - if len(targetDays) == 0 { - targetDays = append([]int(nil), st.SlicePlan.TargetDays...) - } - if len(targetDays) > 0 { - params["day_of_week"] = targetDays - } - if len(st.SlicePlan.ExcludeSections) > 0 { - params["exclude_sections"] = append([]int(nil), st.SlicePlan.ExcludeSections...) - } - return scheduleRefineReactToolCall{ - Tool: tool, - Params: params, - } -} - -func scheduleRefineRunReactLoopNode( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in react loop node") - } - if chatModel == nil { - return nil, fmt.Errorf("schedule refine: model is nil in react loop node") - } - if st.CompositeRouteSucceeded { - emitStage("schedule_refine.react.skip", "复合路由已收口成功,跳过 ReAct 兜底循环。") - return st, nil - } - if len(st.HybridEntries) == 0 { - st.ActionLogs = append(st.ActionLogs, "无可微调条目,跳过动作循环。") - return st, nil - } - if len(st.WorksetTaskIDs) == 0 { - st.ActionLogs = append(st.ActionLogs, "workset 为空,跳过动作循环。") - return st, nil - } - if st.PerTaskBudget <= 0 { - st.PerTaskBudget = defaultPerTaskBudget - } - if st.ExecuteMax <= 0 { - st.ExecuteMax = defaultExecuteMax - } - if st.ReplanMax < 0 { - st.ReplanMax = defaultReplanMax - } - if st.RepairReserve < 0 { - st.RepairReserve = 0 - } - st.MaxRounds = st.ExecuteMax + st.RepairReserve - if st.TaskActionUsed == nil { - st.TaskActionUsed = make(map[int]int) - } - if st.SeenSlotQueries == nil { - st.SeenSlotQueries = make(map[string]struct{}) - } - scheduleRefineEnsureCompositeStateMaps(st) - if st.DisableCompositeTools { - st.RequiredCompositeTool = "" - emitStage("schedule_refine.react.fallback_mode", "当前为禁复合兜底模式:仅允许基础工具逐任务调整。") - } else if strings.TrimSpace(st.RequiredCompositeTool) == "" { - st.RequiredCompositeTool = scheduleRefineDetectRequiredCompositeTool(st) - } - if strings.TrimSpace(st.CurrentPlan.Summary) == "" { - st.CurrentPlan = scheduleRefineApplyCompositeHardConditionToPlan(st, scheduleRefineBuildFallbackPlan(st)) - st.BatchMoveAllowed = scheduleRefineShouldAllowBatchMove(st.CurrentPlan) - } - - window := scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries) - sourceWeekSet := scheduleRefineInferSourceWeekSet(st.SlicePlan) - policy := scheduleRefineToolPolicy{ - // 1. 执行期不再用顺序约束卡住 Move/Swap; - // 2. LLM 只负责把坑位排好,顺序由后端在收口阶段统一归位。 - KeepRelativeOrder: false, - OrderScope: st.Contract.OrderScope, - OriginOrderMap: st.OriginOrderMap, - } - emitStage( - "schedule_refine.react.start", - fmt.Sprintf( - "开始执行单任务微步 ReAct,workset=%d,per_task_budget=%d,execute_max=%d,replan_max=%d,required_composite=%s,required_success=%t。", - len(st.WorksetTaskIDs), - st.PerTaskBudget, - st.ExecuteMax, - st.ReplanMax, - scheduleRefineFallbackText(scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool), "无"), - scheduleRefineIsRequiredCompositeSatisfied(st), - ), - ) - -outer: - for st.WorksetCursor < len(st.WorksetTaskIDs) && st.RoundUsed < st.ExecuteMax { - // 1. 每次取下一个任务前先做一次全局目标短路判断。 - // 2. 目标已满足时,直接结束整个 workset 循环,避免“任务6~10 空转”。 - if pass, reason, _, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied && pass { - if scheduleRefineIsRequiredCompositeSatisfied(st) { - emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("全局目标已满足,提前结束任务循环:%s", scheduleRefineTruncate(reason, 160))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标提前达成,触发短路结束:%s", reason)) - break - } - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标看似达成但未满足复合工具门禁:required=%s", scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool))) - } - taskID := st.WorksetTaskIDs[st.WorksetCursor] - current, ok := scheduleRefineFindSuggestedEntryByTaskID(st.HybridEntries, taskID) - if !ok { - st.WorksetCursor++ - continue - } - if len(sourceWeekSet) > 0 { - if _, inSourceWeek := sourceWeekSet[current.Week]; !inSourceWeek { - emitStage("schedule_refine.react.task_skip_scope", fmt.Sprintf("任务 id=%d 当前位于 W%d,不在来源周范围,已跳过。", taskID, current.Week)) - st.WorksetCursor++ - continue - } - } - st.CurrentTaskID = taskID - st.CurrentTaskAttempt = 0 - emitStage("schedule_refine.react.task_start", fmt.Sprintf("开始处理任务 %d/%d:id=%d,%s。", st.WorksetCursor+1, len(st.WorksetTaskIDs), taskID, strings.TrimSpace(current.Name))) - - taskDone := false - for st.CurrentTaskAttempt < st.PerTaskBudget && st.RoundUsed < st.ExecuteMax { - // 1. 每轮开头先刷新“当前任务”的最新位置,避免模型基于旧坐标决策。 - // 2. 若该任务已满足切片目标(例如“已从周末迁出到工作日”),则直接收口当前任务。 - latest, exists := scheduleRefineFindSuggestedEntryByTaskID(st.HybridEntries, taskID) - if !exists { - taskDone = true - emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已不在 suggested 列表,视为当前任务已完成。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:任务条目已不再可调 suggested。", taskID)) - break - } - current = latest - if scheduleRefineIsCurrentTaskSatisfiedBySlice(current, st.SlicePlan) { - // 1. 自动收口前必须通过复合工具门禁。 - // 2. 这样可避免“切片已满足但未执行必需复合工具”直接跳过执行阶段。 - if scheduleRefineIsRequiredCompositeSatisfied(st) { - taskDone = true - emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已满足切片目标,自动收口并切换下一任务。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:已满足切片目标。", taskID)) - break - } - emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 虽满足切片目标,但复合工具门禁未通过,继续执行。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止自动收口:required_composite=%s 尚未成功。", taskID, scheduleRefineFallbackText(scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool), "无"))) - } - - round := st.RoundUsed + 1 - remainingAction := st.ExecuteMax - st.RoundUsed - remainingTotal := st.MaxRounds - st.RoundUsed - useThinking, reason := scheduleRefineShouldEnableRecoveryThinking(st) - st.CurrentTaskAttempt++ - emitStage("schedule_refine.react.round_start", fmt.Sprintf("第 %d 轮微调开始(任务id=%d,第 %d/%d 次尝试),动作剩余=%d,总剩余=%d。", round, taskID, st.CurrentTaskAttempt, st.PerTaskBudget, remainingAction, remainingTotal)) - if useThinking { - emitStage("schedule_refine.react.reasoning_switch", fmt.Sprintf("第 %d 轮已启用恢复态 thinking:%s", round, reason)) - } - - userPrompt := scheduleRefineBuildMicroReactUserPrompt(st, current, remainingAction, remainingTotal) - raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineReactPrompt, userPrompt, useThinking, reactMaxTokens, 0) - if err != nil { - errDetail := scheduleRefineFormatRoundModelErrorDetail(round, err, ctx) - st.ActionLogs = append(st.ActionLogs, errDetail) - emitStage("schedule_refine.react.round_error", errDetail) - if errors.Is(err, context.DeadlineExceeded) && st.RoundUsed > 0 { - st.WorksetCursor = len(st.WorksetTaskIDs) - break - } - return st, err - } - scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.plan", round), raw) - parsed, parseErr := scheduleRefineParseReactOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, round, emitStage, st) - if parseErr != nil { - return st, parseErr - } - - observation := ReactRoundObservation{ - Round: round, - GoalCheck: strings.TrimSpace(parsed.GoalCheck), - Decision: strings.TrimSpace(parsed.Decision), - } - emitStage("schedule_refine.react.plan", scheduleRefineFormatReactPlanStageDetail(round, parsed, remainingAction, useThinking)) - emitStage("schedule_refine.react.need_info", scheduleRefineFormatReactNeedInfoStageDetail(round, parsed.MissingInfo)) - - if parsed.Done { - allowDone := scheduleRefineIsCurrentTaskSatisfiedBySlice(current, st.SlicePlan) - if allowDone && !scheduleRefineIsRequiredCompositeSatisfied(st) { - allowDone = false - } - if !allowDone { - if pass, _, _, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied && pass && scheduleRefineIsRequiredCompositeSatisfied(st) { - allowDone = true - } - } - if !allowDone { - observation.Reflect = fmt.Sprintf("模型返回 done=true,但任务 id=%d 尚未满足切片目标或复合工具门禁未通过,继续执行。", taskID) - st.ObservationHistory = append(st.ObservationHistory, observation) - emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 拒绝提前 done:当前任务未满足目标。", taskID)) - continue - } - reasonText := scheduleRefineFallbackText(strings.TrimSpace(parsed.Summary), "模型判定当前任务已满足目标。") - observation.Reflect = reasonText - st.ObservationHistory = append(st.ObservationHistory, observation) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 完成:%s", taskID, reasonText)) - emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) - taskDone = true - break - } - - call, warn := pickSingleScheduleRefineToolCall(parsed.ToolCalls) - if warn != "" { - emitStage("schedule_refine.react.round_warn", fmt.Sprintf("第 %d 轮告警:%s", round, warn)) - } - if call == nil { - observation.Reflect = "本轮未生成可执行工具动作。" - st.ObservationHistory = append(st.ObservationHistory, observation) - emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) - break - } - normalizedCall := scheduleRefineCanonicalizeToolCall(*call) - call = &normalizedCall - emitStage("schedule_refine.react.tool_call", scheduleRefineFormatToolCallStageDetail(round, *call, remainingAction)) - - callSignature := scheduleRefineBuildToolCallSignature(*call) - taskIDs := scheduleRefineListTaskIDsFromToolCall(*call) - if blockedResult, blocked := scheduleRefinePrecheckCurrentTaskOwnership(*call, taskIDs, taskID); blocked { - if stop, err := scheduleRefineHandleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil { - return st, err - } else if stop { - taskDone = true - break - } - continue - } - if blockedResult, blocked := scheduleRefinePrecheckToolCallPolicy(st, *call, taskIDs); blocked { - if stop, err := scheduleRefineHandleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil { - return st, err - } else if stop { - taskDone = true - break - } - continue - } - if scheduleRefineIsRepeatedFailedCall(st, callSignature) { - repeat := scheduleRefineReactToolResult{Tool: strings.TrimSpace(call.Tool), Success: false, ErrorCode: "REPEAT_FAILED_ACTION", Result: "重复失败动作:与上一轮失败动作完全相同,请更换目标时段或改用 Swap。"} - if stop, err := scheduleRefineHandleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, repeat, &observation); err != nil { - return st, err - } else if stop { - taskDone = true - break - } - continue - } - - for _, id := range taskIDs { - st.TaskActionUsed[id]++ - } - nextEntries, rawResult := dispatchScheduleRefineTool(cloneHybridEntries(st.HybridEntries), *call, window, policy) - result := scheduleRefineNormalizeToolResult(rawResult) - st.RoundUsed++ - scheduleRefineMarkCompositeToolOutcome(st, result.Tool, result.Success) - - observation.ToolName = strings.TrimSpace(result.Tool) - observation.ToolParams = scheduleRefineCloneToolParams(call.Params) - observation.ToolSuccess = result.Success - observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) - observation.ToolResult = strings.TrimSpace(result.Result) - postReflectText, _, shouldStop := scheduleRefineRunPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) - observation.Reflect = postReflectText - st.ObservationHistory = append(st.ObservationHistory, observation) - - emitStage("schedule_refine.react.tool_result", scheduleRefineFormatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) - emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) - if result.Success { - st.HybridEntries = nextEntries - window = scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries) - if scheduleRefineIsMutatingToolName(result.Tool) { - st.EntriesVersion++ - } - st.LastFailedCallSignature = "" - st.ConsecutiveFailures = 0 - st.ThinkingBoostArmed = false - // 1. 动作成功后立即尝试全局短路,避免继续拉着后续任务空转。 - // 2. 只要 deterministic 目标达成,直接收口整个 ReAct 循环。 - if pass, reason, _, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied && pass { - if scheduleRefineIsRequiredCompositeSatisfied(st) { - emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("动作后全局目标达成,提前结束任务循环:%s", scheduleRefineTruncate(reason, 160))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后全局目标达成,触发短路结束:%s", reason)) - taskDone = true - break outer - } - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后目标达成但复合工具门禁未通过:required=%s", scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool))) - } - if latest, exists := scheduleRefineFindSuggestedEntryByTaskID(st.HybridEntries, taskID); exists { - current = latest - if scheduleRefineIsCurrentTaskSatisfiedBySlice(current, st.SlicePlan) { - if scheduleRefineIsRequiredCompositeSatisfied(st) { - taskDone = true - emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已满足切片目标,自动结束当前任务。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:动作后已满足切片目标。", taskID)) - break - } - emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 动作后满足切片目标,但复合工具门禁未通过,继续执行。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止动作后自动收口:required_composite=%s 尚未成功。", taskID, scheduleRefineFallbackText(scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool), "无"))) - } - } else { - taskDone = true - emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已不在 suggested 列表,自动结束当前任务。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:动作后不再可调。", taskID)) - break - } - } else { - st.LastFailedCallSignature = callSignature - st.ConsecutiveFailures++ - if scheduleRefineShouldTriggerReplan(st, result) { - if replanned, err := scheduleRefineTryReplan(ctx, chatModel, st, emitStage); err != nil { - return st, err - } else if replanned { - continue - } - } - } - if shouldStop { - // 1. 模型建议 should_stop 只作为“候选中断信号”,必须经后端目标校验确认。 - // 2. 若全局目标未达成,则继续本地循环,避免模型误停。 - if pass, reason, _, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied && pass { - if scheduleRefineIsRequiredCompositeSatisfied(st) { - emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("模型建议停止且全局目标达成,提前收口:%s", scheduleRefineTruncate(reason, 160))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止且目标达成,触发短路结束:%s", reason)) - taskDone = true - break outer - } - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止但复合工具门禁未通过:required=%s", scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool))) - } - } - } - - emitStage("schedule_refine.react.task_done", fmt.Sprintf("任务 id=%d 处理完成:status=%s。", taskID, scheduleRefineTaskProgressLabel(taskDone, st.CurrentTaskAttempt, st.PerTaskBudget))) - st.WorksetCursor++ - st.CurrentTaskID = 0 - st.CurrentTaskAttempt = 0 - } - emitStage("schedule_refine.react.done", fmt.Sprintf("单任务微步 ReAct 结束:已执行轮次=%d,重规划次数=%d,已处理任务=%d/%d。", st.RoundUsed, st.ReplanUsed, st.WorksetCursor, len(st.WorksetTaskIDs))) - return st, nil -} - -func scheduleRefineRunHardCheckNode( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in hard check node") - } - if chatModel == nil { - return nil, fmt.Errorf("schedule refine: model is nil in hard check node") - } - emitStage("schedule_refine.hard_check.start", "正在执行终审硬校验。") - // 1. 先锁定“业务目标是否达成”的判定结果(未排序前)。 - // 2. 后续顺序归位仅用于最终展示与顺序一致性,不得反向改变业务目标成败。 - intentPassLocked, intentReasonLocked, intentUnmetLocked := scheduleRefineEvaluateIntentForJudgement(ctx, chatModel, st, emitStage) - emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("终审业务目标已锁定:pass=%t,reason=%s", intentPassLocked, scheduleRefineTruncate(intentReasonLocked, 120))) - if changed, skipped := scheduleRefineTryNormalizeMovableTaskOrderByOrigin(st); skipped { - emitStage("schedule_refine.hard_check.order_normalized", "已跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。") - } else if changed { - emitStage("schedule_refine.hard_check.order_normalized", "已在终审前按 origin_rank 对坑位做顺序归位。") - } - report := scheduleRefineEvaluateHardChecks(ctx, chatModel, st, emitStage) - report.IntentPassed = intentPassLocked - report.IntentReason = intentReasonLocked - report.IntentUnmet = append([]string(nil), intentUnmetLocked...) - st.HardCheck = report - if report.PhysicsPassed && report.OrderPassed && report.IntentPassed { - emitStage("schedule_refine.hard_check.pass", "终审通过。") - return st, nil - } - if st.RoundUsed >= st.MaxRounds { - emitStage("schedule_refine.hard_check.fail", "终审未通过,且动作预算已耗尽,无法继续修复。") - return st, nil - } - emitStage("schedule_refine.hard_check.repairing", "终审未通过,正在尝试一次修复动作。") - st.HardCheck.RepairTried = true - if err := scheduleRefineRunSingleRepairAction(ctx, chatModel, st, emitStage); err != nil { - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("修复动作失败:%v", err)) - emitStage("schedule_refine.hard_check.fail", "修复动作失败,保留当前方案。") - return st, nil - } - intentPassLocked, intentReasonLocked, intentUnmetLocked = scheduleRefineEvaluateIntentForJudgement(ctx, chatModel, st, emitStage) - emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("修复后业务目标已锁定:pass=%t,reason=%s", intentPassLocked, scheduleRefineTruncate(intentReasonLocked, 120))) - if changed, skipped := scheduleRefineTryNormalizeMovableTaskOrderByOrigin(st); skipped { - emitStage("schedule_refine.hard_check.order_normalized", "修复后跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。") - } else if changed { - emitStage("schedule_refine.hard_check.order_normalized", "修复后已按 origin_rank 对坑位做顺序归位。") - } - report = scheduleRefineEvaluateHardChecks(ctx, chatModel, st, emitStage) - report.IntentPassed = intentPassLocked - report.IntentReason = intentReasonLocked - report.IntentUnmet = append([]string(nil), intentUnmetLocked...) - report.RepairTried = true - st.HardCheck = report - if report.PhysicsPassed && report.OrderPassed && report.IntentPassed { - emitStage("schedule_refine.hard_check.pass", "修复后终审通过。") - return st, nil - } - emitStage("schedule_refine.hard_check.fail", "修复后仍未完全满足要求,已返回当前最优结果。") - return st, nil -} - -func scheduleRefineRunSummaryNode( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in summary node") - } - if chatModel == nil { - return nil, fmt.Errorf("schedule refine: model is nil in summary node") - } - emitStage("schedule_refine.summary.generating", "正在生成微调结果总结。") - scheduleRefineUpdateAllocatedItemsFromEntries(st) - st.CandidatePlans = scheduleRefineHybridEntriesToWeekSchedules(st.HybridEntries) - reportJSON, _ := json.Marshal(st.HardCheck) - contractJSON, _ := json.Marshal(st.Contract) - userPrompt := fmt.Sprintf("用户请求=%s\n契约=%s\n终审报告=%s\n动作日志=%s", strings.TrimSpace(st.UserMessage), string(contractJSON), string(reportJSON), scheduleRefineSummarizeActionLogs(st.ActionLogs, 24)) - raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineSummaryPrompt, userPrompt, false, 280, 0.35) - summary := strings.TrimSpace(raw) - if err == nil { - scheduleRefineEmitModelRawDebug(emitStage, "summary", raw) - } - if err != nil || summary == "" { - if FinalHardCheckPassed(st) { - summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) - } else { - summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, scheduleRefineFallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) - } - } - summary = scheduleRefineAlignSummaryWithHardCheck(st, summary) - st.FinalSummary = summary - // 1. Completed 只代表“最终终审已通过”,不再把“链路执行完毕”误写成成功; - // 2. 这样外层持久化与展示层可以准确区分“已通过方案”与“当前最优但未达标方案”; - // 3. 若只是返回 best-effort 结果,FinalSummary 仍会保留,但 Completed=false。 - st.Completed = FinalHardCheckPassed(st) - emitStage("schedule_refine.summary.done", "微调总结已生成。") - return st, nil -} - -func scheduleRefineEvaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) HardCheckReport { - report := HardCheckReport{} - report.PhysicsIssues = scheduleRefinePhysicsCheck(st.HybridEntries, len(st.AllocatedItems)) - report.PhysicsPassed = len(report.PhysicsIssues) == 0 - // 1. 顺序校验默认开启:即便执行期放开顺序限制,终审也要验证“后端归位”后的顺序正确性。 - // 2. 但 MinContextSwitch 成功后,重排后的顺序本身就是业务目标,不能再拿 origin_rank 反向判错。 - // 3. 当 origin_order_map 为空时同样降级跳过,避免无基线时误报。 - needOrderCheck := len(st.OriginOrderMap) > 0 && !scheduleRefineShouldSkipOrderConstraintCheck(st) - report.OrderIssues = scheduleRefineValidateRelativeOrder(st.HybridEntries, scheduleRefineToolPolicy{ - KeepRelativeOrder: needOrderCheck, - OrderScope: st.Contract.OrderScope, - OriginOrderMap: st.OriginOrderMap, - }) - report.OrderPassed = len(report.OrderIssues) == 0 - - // 1. 优先使用“契约编译后”的确定性终审,执行与终审共用同一份目标约束。 - // 2. 仅当目标约束不可判定时,才回退语义终审兜底。 - if pass, reason, unmet, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied { - pass, reason, unmet = scheduleRefineApplyCompositeGateToIntentResult(st, pass, reason, unmet) - report.IntentPassed = pass - report.IntentReason = strings.TrimSpace(reason) - report.IntentUnmet = append([]string(nil), unmet...) - return report - } - - review, err := scheduleRefineRunSemanticReview(ctx, chatModel, st, emitStage) - if err != nil { - report.IntentPassed = false - report.IntentReason = fmt.Sprintf("语义校验失败:%v", err) - report.IntentUnmet = []string{"语义校验阶段异常"} - return report - } - pass, reason, unmet := scheduleRefineApplyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet) - report.IntentPassed = pass - report.IntentReason = strings.TrimSpace(reason) - report.IntentUnmet = append([]string(nil), unmet...) - return report -} - -// scheduleRefineEvaluateIntentForJudgement 在“最终排序前”计算业务目标是否达成。 -// -// 说明: -// 1. 优先走 deterministic objective; -// 2. objective 不可判定时退回语义 review; -// 3. 返回值会在 hard_check 中被锁定,避免后置排序反向干扰业务目标判定。 -func scheduleRefineEvaluateIntentForJudgement( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (pass bool, reason string, unmet []string) { - if pass, reason, unmet, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied { - pass, reason, unmet = scheduleRefineApplyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet) - return pass, strings.TrimSpace(reason), append([]string(nil), unmet...) - } - review, err := scheduleRefineRunSemanticReview(ctx, chatModel, st, emitStage) - if err != nil { - return false, fmt.Sprintf("语义校验失败:%v", err), []string{"语义校验阶段异常"} - } - pass, reason, unmet = scheduleRefineApplyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet) - return pass, strings.TrimSpace(reason), append([]string(nil), unmet...) -} - -// scheduleRefineCompileRefineObjective 把自然语言契约编译为“可执行且可校验”的目标参数。 -func scheduleRefineCompileRefineObjective(st *ScheduleRefineState, slice RefineSlicePlan) RefineObjective { - obj := RefineObjective{ - Mode: "none", - SourceWeeks: scheduleRefineKeysOfIntSet(scheduleRefineInferSourceWeekSet(slice)), - TargetWeeks: scheduleRefineKeysOfIntSet(scheduleRefineInferTargetWeekSet(slice)), - SourceDays: scheduleRefineUniquePositiveInts(append([]int(nil), slice.SourceDays...)), - TargetDays: scheduleRefineUniquePositiveInts(append([]int(nil), slice.TargetDays...)), - ExcludeSections: scheduleRefineUniquePositiveInts(append([]int(nil), slice.ExcludeSections...)), - } - // 1. 若契约断言显式给出来源/目标周,优先回填到 objective; - // 2. 避免后续终审只能依赖自然语言猜测。 - for _, assertion := range st.Contract.HardAssertions { - if assertion.Week > 0 && len(obj.SourceWeeks) == 0 { - obj.SourceWeeks = []int{assertion.Week} - } - if assertion.TargetWeek > 0 && len(obj.TargetWeeks) == 0 { - obj.TargetWeeks = []int{assertion.TargetWeek} - } - } - - if len(obj.SourceWeeks) == 0 && len(slice.WeekFilter) == 1 && slice.WeekFilter[0] > 0 { - obj.SourceWeeks = []int{slice.WeekFilter[0]} - } - if len(obj.TargetWeeks) == 0 && len(slice.WeekFilter) == 1 && (len(obj.SourceDays) > 0 || len(obj.TargetDays) > 0) { - obj.TargetWeeks = []int{slice.WeekFilter[0]} - } - - // 来源范围为空时无法构造目标,交给语义终审兜底。 - if len(obj.SourceWeeks) == 0 && len(obj.SourceDays) == 0 { - obj.Reason = "来源范围为空,未启用确定性目标。" - return obj - } - // 目标范围为空时同样不启用确定性目标。 - if len(obj.TargetWeeks) == 0 && len(obj.TargetDays) == 0 { - obj.Reason = "目标范围为空,未启用确定性目标。" - return obj - } - - sourceTaskIDs := scheduleRefineCollectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, slice.WeekFilter) - obj.BaselineSourceTaskCount = len(sourceTaskIDs) - - halfIntent := scheduleRefineHasHalfTransferIntent(st) - if halfIntent && len(obj.SourceWeeks) > 0 && len(obj.TargetWeeks) > 0 && !scheduleRefineIsSameWeeks(obj.SourceWeeks, obj.TargetWeeks) { - obj.Mode = "move_ratio" - obj.RequiredMoveMin = obj.BaselineSourceTaskCount / 2 - obj.RequiredMoveMax = (obj.BaselineSourceTaskCount + 1) / 2 - obj.Reason = "检测到“半数迁移”意图,按比例目标执行与终审。" - return obj - } - - obj.Mode = "move_all" - obj.RequiredMoveMin = obj.BaselineSourceTaskCount - obj.RequiredMoveMax = obj.BaselineSourceTaskCount - obj.Reason = "默认按来源范围任务全部进入目标范围执行与终审。" - return obj -} - -// scheduleRefineEvaluateObjectiveDeterministic 基于编译后的目标做确定性终审。 -func scheduleRefineEvaluateObjectiveDeterministic(st *ScheduleRefineState) (pass bool, reason string, unmet []string, applied bool) { - if st == nil { - return false, "", nil, false - } - obj := st.Objective - if strings.TrimSpace(obj.Mode) == "" || strings.TrimSpace(obj.Mode) == "none" { - return false, "", nil, false - } - - sourceTaskIDs := scheduleRefineCollectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, st.SlicePlan.WeekFilter) - if len(sourceTaskIDs) == 0 { - return true, "确定性校验通过:来源范围无可调任务。", nil, true - } - - byTaskID := scheduleRefineBuildMovableTaskIndex(st.HybridEntries) - movedCount := 0 - violations := make([]string, 0, 8) - for _, taskID := range sourceTaskIDs { - entries := byTaskID[taskID] - if len(entries) == 0 { - violations = append(violations, fmt.Sprintf("任务id=%d 未在结果中找到可移动条目", taskID)) - continue - } - if len(entries) > 1 { - violations = append(violations, fmt.Sprintf("任务id=%d 命中 %d 条可移动条目,状态不唯一", taskID, len(entries))) - continue - } - entry := entries[0] - moved, why := scheduleRefineIsTaskMovedIntoObjectiveTarget(entry, obj) - if moved { - movedCount++ - continue - } - if obj.Mode == "move_all" { - violations = append(violations, fmt.Sprintf("任务id=%d 未满足目标范围:%s", taskID, why)) - continue - } - // 比例模式下,允许部分任务不迁移;但若任务落在来源/目标之外,视为异常。 - if !scheduleRefineIsTaskInObjectiveSource(entry, obj) { - violations = append(violations, fmt.Sprintf("任务id=%d 既不在来源也不在目标范围(W%dD%d)", taskID, entry.Week, entry.DayOfWeek)) - } - } - - if movedCount < obj.RequiredMoveMin || movedCount > obj.RequiredMoveMax { - violations = append(violations, fmt.Sprintf("迁移数量未达标:要求在[%d,%d],实际=%d", obj.RequiredMoveMin, obj.RequiredMoveMax, movedCount)) - } - - if len(violations) == 0 { - return true, fmt.Sprintf("确定性校验通过:迁移数量达标(%d/%d)。", movedCount, len(sourceTaskIDs)), nil, true - } - return false, fmt.Sprintf("确定性校验未通过:仍有 %d 项约束未满足。", len(violations)), violations, true -} - -func scheduleRefineCollectSourceTaskIDsForObjective(entries []model.HybridScheduleEntry, obj RefineObjective, fallbackWeekFilter []int) []int { - if len(entries) == 0 { - return nil - } - sourceWeekSet := scheduleRefineIntSliceToWeekSet(obj.SourceWeeks) - sourceDaySet := scheduleRefineIntSliceToDaySet(obj.SourceDays) - fallbackWeekSet := scheduleRefineIntSliceToWeekSet(fallbackWeekFilter) - - seen := make(map[int]struct{}, len(entries)) - ids := make([]int, 0, len(entries)) - for _, entry := range entries { - if !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - if len(sourceWeekSet) > 0 { - if _, ok := sourceWeekSet[entry.Week]; !ok { - continue - } - } else if len(fallbackWeekSet) > 0 { - if _, ok := fallbackWeekSet[entry.Week]; !ok { - continue - } - } - if len(sourceDaySet) > 0 { - if _, ok := sourceDaySet[entry.DayOfWeek]; !ok { - continue - } - } - if _, exists := seen[entry.TaskItemID]; exists { - continue - } - seen[entry.TaskItemID] = struct{}{} - ids = append(ids, entry.TaskItemID) - } - sort.Ints(ids) - return ids -} - -func scheduleRefineBuildMovableTaskIndex(entries []model.HybridScheduleEntry) map[int][]model.HybridScheduleEntry { - index := make(map[int][]model.HybridScheduleEntry, len(entries)) - for _, entry := range entries { - if !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - index[entry.TaskItemID] = append(index[entry.TaskItemID], entry) - } - return index -} - -func scheduleRefineHasHalfTransferIntent(st *ScheduleRefineState) bool { - if st == nil { - return false - } - if scheduleRefineHasHalfTransferAssertion(st.Contract.HardAssertions) { - return true - } - joined := strings.ToLower(strings.Join(append([]string{st.UserMessage, st.Contract.Intent}, st.Contract.HardRequirements...), " ")) - for _, key := range []string{"一半", "半数", "对半", "50%"} { - if strings.Contains(joined, key) { - return true - } - } - return false -} - -func scheduleRefineHasHalfTransferAssertion(assertions []RefineAssertion) bool { - for _, item := range assertions { - metric := strings.ToLower(strings.TrimSpace(item.Metric)) - if metric == "" { - continue - } - switch metric { - case "source_move_ratio_percent", "move_ratio_percent", "half_transfer_ratio": - switch strings.TrimSpace(item.Operator) { - case "==", ">=", "<=", "between": - if item.Value == 50 || item.Min == 50 || item.Max == 50 { - return true - } - } - case "source_remaining_count": - // 1. 该断言常用于“迁走一半后来源剩余=一半”。 - // 2. 具体阈值是否满足由 objective + deterministic 校验统一判定。 - return true - } - } - return false -} - -func scheduleRefineNormalizeHardAssertions(raw []scheduleRefineHardAssertionOutput) []RefineAssertion { - if len(raw) == 0 { - return nil - } - out := make([]RefineAssertion, 0, len(raw)) - for _, item := range raw { - metric := strings.TrimSpace(item.Metric) - if metric == "" { - continue - } - operator := strings.TrimSpace(item.Operator) - if operator == "" { - operator = "==" - } - assertion := RefineAssertion{ - Metric: metric, - Operator: operator, - Value: item.Value, - Min: item.Min, - Max: item.Max, - Week: item.Week, - TargetWeek: item.TargetWeek, - } - out = append(out, assertion) - } - if len(out) == 0 { - return nil - } - return out -} - -func scheduleRefineInferHardAssertionsFromRequest(message string, requirements []string) []RefineAssertion { - joined := strings.TrimSpace(message + " " + strings.Join(requirements, " ")) - if joined == "" { - return nil - } - weeks := scheduleRefineExtractWeekFilters(joined) - if !scheduleRefineContainsAny(strings.ToLower(joined), []string{"一半", "半数", "对半", "50%"}) { - return nil - } - // 1. 兜底断言:要求来源任务迁移比例为 50%。 - // 2. week/target_week 使用文本中前两个周次,便于后续 objective 编译。 - assertion := RefineAssertion{ - Metric: "source_move_ratio_percent", - Operator: "==", - Value: 50, - } - if len(weeks) > 0 { - assertion.Week = weeks[0] - } - if len(weeks) > 1 { - assertion.TargetWeek = weeks[1] - } - return []RefineAssertion{assertion} -} - -func scheduleRefineIsTaskMovedIntoObjectiveTarget(entry model.HybridScheduleEntry, obj RefineObjective) (bool, string) { - targetWeekSet := scheduleRefineIntSliceToWeekSet(obj.TargetWeeks) - targetDaySet := scheduleRefineIntSliceToDaySet(obj.TargetDays) - excludedSections := scheduleRefineIntSliceToSectionSet(obj.ExcludeSections) - if len(targetWeekSet) > 0 { - if _, ok := targetWeekSet[entry.Week]; !ok { - return false, fmt.Sprintf("week=%d 不在目标周", entry.Week) - } - } - if len(targetDaySet) > 0 { - if _, ok := targetDaySet[entry.DayOfWeek]; !ok { - return false, fmt.Sprintf("day_of_week=%d 不在目标日", entry.DayOfWeek) - } - } - if len(excludedSections) > 0 && scheduleRefineIntersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSections) { - return false, fmt.Sprintf("section=%d-%d 命中排除节次", entry.SectionFrom, entry.SectionTo) - } - return true, "" -} - -func scheduleRefineIsTaskInObjectiveSource(entry model.HybridScheduleEntry, obj RefineObjective) bool { - sourceWeekSet := scheduleRefineIntSliceToWeekSet(obj.SourceWeeks) - sourceDaySet := scheduleRefineIntSliceToDaySet(obj.SourceDays) - if len(sourceWeekSet) > 0 { - if _, ok := sourceWeekSet[entry.Week]; !ok { - return false - } - } - if len(sourceDaySet) > 0 { - if _, ok := sourceDaySet[entry.DayOfWeek]; !ok { - return false - } - } - return true -} - -func scheduleRefineIsSameWeeks(left []int, right []int) bool { - if len(left) == 0 || len(right) == 0 { - return false - } - lset := scheduleRefineIntSliceToWeekSet(left) - rset := scheduleRefineIntSliceToWeekSet(right) - if len(lset) != len(rset) { - return false - } - for w := range lset { - if _, ok := rset[w]; !ok { - return false - } - } - return true -} - -// scheduleRefineNormalizeMovableTaskOrderByOrigin 在“坑位不变”的前提下,按 origin_rank 归位任务顺序。 -// -// 步骤化说明: -// 1. 先提取所有可移动任务的当前坑位(week/day/section); -// 2. 再按任务跨度分组,避免把 2 节任务塞进 3 节坑位; -// 3. 每个跨度组内按坑位时间升序与 origin_rank 升序做一一映射; -// 4. 最终只改“任务身份落到哪个坑位”,不改坑位分布本身。 -func scheduleRefineNormalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) bool { - if st == nil || len(st.HybridEntries) <= 1 || len(st.OriginOrderMap) == 0 { - return false - } - entries := cloneHybridEntries(st.HybridEntries) - indices := make([]int, 0, len(entries)) - for idx, entry := range entries { - if scheduleRefineIsMovableSuggestedTask(entry) { - indices = append(indices, idx) - } - } - if len(indices) <= 1 { - return false - } - - type slot struct { - Week int - DayOfWeek int - SectionFrom int - SectionTo int - } - groupSlots := make(map[int][]slot) // key=span - groupTasks := make(map[int][]model.HybridScheduleEntry) // key=span - for _, idx := range indices { - entry := entries[idx] - span := entry.SectionTo - entry.SectionFrom + 1 - groupSlots[span] = append(groupSlots[span], slot{ - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - SectionTo: entry.SectionTo, - }) - groupTasks[span] = append(groupTasks[span], entry) - } - - changed := false - spanKeys := make([]int, 0, len(groupSlots)) - for span := range groupSlots { - spanKeys = append(spanKeys, span) - } - sort.Ints(spanKeys) - - groupCursor := make(map[int]int, len(groupSlots)) - for _, span := range spanKeys { - slots := groupSlots[span] - tasks := groupTasks[span] - if len(slots) != len(tasks) || len(slots) == 0 { - continue - } - sort.SliceStable(slots, func(i, j int) bool { - if slots[i].Week != slots[j].Week { - return slots[i].Week < slots[j].Week - } - if slots[i].DayOfWeek != slots[j].DayOfWeek { - return slots[i].DayOfWeek < slots[j].DayOfWeek - } - if slots[i].SectionFrom != slots[j].SectionFrom { - return slots[i].SectionFrom < slots[j].SectionFrom - } - return slots[i].SectionTo < slots[j].SectionTo - }) - sort.SliceStable(tasks, func(i, j int) bool { - ri := st.OriginOrderMap[tasks[i].TaskItemID] - rj := st.OriginOrderMap[tasks[j].TaskItemID] - if ri <= 0 { - ri = 1 << 30 - } - if rj <= 0 { - rj = 1 << 30 - } - if ri != rj { - return ri < rj - } - if tasks[i].Week != tasks[j].Week { - return tasks[i].Week < tasks[j].Week - } - if tasks[i].DayOfWeek != tasks[j].DayOfWeek { - return tasks[i].DayOfWeek < tasks[j].DayOfWeek - } - if tasks[i].SectionFrom != tasks[j].SectionFrom { - return tasks[i].SectionFrom < tasks[j].SectionFrom - } - return tasks[i].TaskItemID < tasks[j].TaskItemID - }) - for i := range tasks { - tasks[i].Week = slots[i].Week - tasks[i].DayOfWeek = slots[i].DayOfWeek - tasks[i].SectionFrom = slots[i].SectionFrom - tasks[i].SectionTo = slots[i].SectionTo - } - groupTasks[span] = tasks - } - - for _, idx := range indices { - entry := entries[idx] - span := entry.SectionTo - entry.SectionFrom + 1 - cursor := groupCursor[span] - if cursor >= len(groupTasks[span]) { - continue - } - nextEntry := groupTasks[span][cursor] - groupCursor[span] = cursor + 1 - if entry.TaskItemID != nextEntry.TaskItemID || - entry.Week != nextEntry.Week || - entry.DayOfWeek != nextEntry.DayOfWeek || - entry.SectionFrom != nextEntry.SectionFrom || - entry.SectionTo != nextEntry.SectionTo { - changed = true - } - entries[idx] = nextEntry - } - if !changed { - return false - } - scheduleRefineSortHybridEntries(entries) - st.HybridEntries = entries - return true -} - -// scheduleRefineTryNormalizeMovableTaskOrderByOrigin 决定是否执行“按 origin_rank 顺序归位”。 -// -// 步骤化说明: -// 1. 默认仍保持旧行为,继续在终审前做展示侧顺序归位; -// 2. 但当 MinContextSwitch 已成功执行时,重排后的顺序本身就是业务目标的一部分; -// 3. 此时若再按 origin_rank 归位,会把复合工具效果直接抹掉,因此必须跳过。 -func scheduleRefineTryNormalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) (changed bool, skipped bool) { - if scheduleRefineShouldSkipOriginOrderNormalization(st) { - return false, true - } - return scheduleRefineNormalizeMovableTaskOrderByOrigin(st), false -} - -func scheduleRefineShouldSkipOriginOrderNormalization(st *ScheduleRefineState) bool { - if st == nil { - return false - } - scheduleRefineEnsureCompositeStateMaps(st) - if st.CompositeToolSuccess["MinContextSwitch"] { - return true - } - return false -} - -func scheduleRefineShouldSkipOrderConstraintCheck(st *ScheduleRefineState) bool { - return scheduleRefineShouldSkipOriginOrderNormalization(st) -} - -func scheduleRefineRunSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) error { - if st == nil { - return fmt.Errorf("nil state") - } - if chatModel == nil { - return fmt.Errorf("nil model") - } - if st.RoundUsed >= st.MaxRounds { - return fmt.Errorf("动作预算已耗尽") - } - entriesJSON, _ := json.Marshal(st.HybridEntries) - contractJSON, _ := json.Marshal(st.Contract) - userPrompt := scheduleRefineWithNearestJSONContract( - fmt.Sprintf( - "用户请求=%s\n契约=%s\n未满足点=%s\n当前混合日程JSON=%s\nMove标准Schema={task_item_id,to_week,to_day,to_section_from,to_section_to}\nSwap标准Schema={task_a,task_b}", - strings.TrimSpace(st.UserMessage), - string(contractJSON), - strings.Join(st.HardCheck.IntentUnmet, ";"), - string(entriesJSON), - ), - jsonContractForReact, - ) - raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineRepairPrompt, userPrompt, false, 240, 0.15) - if err != nil { - return err - } - scheduleRefineEmitModelRawDebug(emitStage, "repair", raw) - parsed, parseErr := parseScheduleRefineLLMOutput(raw) - if parseErr != nil { - return parseErr - } - call, warn := pickSingleScheduleRefineToolCall(parsed.ToolCalls) - if warn != "" { - st.ActionLogs = append(st.ActionLogs, "修复阶段告警:"+warn) - } - if call == nil { - return fmt.Errorf("修复阶段未给出可执行动作") - } - normalizedCall := scheduleRefineCanonicalizeToolCall(*call) - call = &normalizedCall - if !scheduleRefineIsMutatingToolName(strings.TrimSpace(call.Tool)) { - return fmt.Errorf("修复阶段工具不允许:%s(仅允许 Move/Swap/BatchMove)", strings.TrimSpace(call.Tool)) - } - emitStage("schedule_refine.hard_check.repair_call", scheduleRefineFormatToolCallStageDetail(st.RoundUsed+1, *call, st.MaxRounds-st.RoundUsed)) - nextEntries, result := dispatchScheduleRefineTool(cloneHybridEntries(st.HybridEntries), *call, scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries), scheduleRefineToolPolicy{ - KeepRelativeOrder: false, - OrderScope: st.Contract.OrderScope, - OriginOrderMap: st.OriginOrderMap, - }) - result = scheduleRefineNormalizeToolResult(result) - st.RoundUsed++ - emitStage("schedule_refine.hard_check.repair_result", scheduleRefineFormatToolResultStageDetail(st.RoundUsed, result, st.RoundUsed, st.MaxRounds)) - if !result.Success { - st.LastFailedCallSignature = scheduleRefineBuildToolCallSignature(*call) - return fmt.Errorf("修复动作执行失败:%s", result.Result) - } - st.LastFailedCallSignature = "" - st.HybridEntries = nextEntries - if scheduleRefineIsMutatingToolName(result.Tool) { - st.EntriesVersion++ - } - return nil -} - -func scheduleRefineRunSemanticReview(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) (*scheduleRefineReviewOutput, error) { - entriesJSON, _ := json.Marshal(st.HybridEntries) - contractJSON, _ := json.Marshal(st.Contract) - userPrompt := scheduleRefineWithNearestJSONContract( - fmt.Sprintf( - "用户请求=%s\n契约=%s\nday_of_week映射=1周一,2周二,3周三,4周四,5周五,6周六,7周日\nsuggested简表=%s\n动作日志=%s\n当前混合日程JSON=%s", - strings.TrimSpace(st.UserMessage), - string(contractJSON), - scheduleRefineBuildSuggestedDigest(st.HybridEntries, 80), - scheduleRefineSummarizeActionLogs(st.ActionLogs, 12), - string(entriesJSON), - ), - jsonContractForReview, - ) - raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineReviewPrompt, userPrompt, false, 240, 0) - if err != nil { - return nil, err - } - scheduleRefineEmitModelRawDebug(emitStage, "review", raw) - return parseScheduleRefineReviewOutput(raw) -} - -func scheduleRefineRunPostReflectAfterTool( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - round int, - plan *scheduleRefineReactLLMOutput, - call *scheduleRefineReactToolCall, - result scheduleRefineReactToolResult, - emitStage func(stage, detail string), -) (string, string, bool) { - if st == nil || chatModel == nil || call == nil { - return scheduleRefineBuildPostReflectFallback(plan, result), "", false - } - emitStage("schedule_refine.react.post_reflect.start", fmt.Sprintf("第 %d 轮|正在基于工具真实结果进行反思。", round)) - contractJSON, _ := json.Marshal(st.Contract) - callJSON, _ := json.Marshal(call) - resultJSON, _ := json.Marshal(result) - planDecision := "" - if plan != nil { - planDecision = strings.TrimSpace(plan.Decision) - } - userPrompt := scheduleRefineWithNearestJSONContract( - fmt.Sprintf( - "用户请求=%s\n契约=%s\n本轮计划.decision=%s\n本轮工具调用=%s\n本轮工具结果=%s\n最近观察=%s", - strings.TrimSpace(st.UserMessage), - string(contractJSON), - planDecision, - string(callJSON), - string(resultJSON), - scheduleRefineBuildObservationPrompt(st.ObservationHistory, 2), - ), - jsonContractForPostReflect, - ) - raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefinePostReflectPrompt, userPrompt, false, 220, 0) - if err != nil { - fallback := scheduleRefineBuildPostReflectFallback(plan, result) - emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思失败,改用后端兜底复盘:%s", round, scheduleRefineTruncate(err.Error(), 160))) - return fallback, "", false - } - scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("post_reflect.round.%d", round), raw) - parsed, parseErr := scheduleRefineParseJSON[scheduleRefinePostReflectOutput](raw) - if parseErr != nil { - fallback := scheduleRefineBuildPostReflectFallback(plan, result) - emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思解析失败,改用后端兜底复盘:%s", round, scheduleRefineTruncate(parseErr.Error(), 160))) - return fallback, "", false - } - reflection := strings.TrimSpace(parsed.Reflection) - if reflection == "" { - reflection = scheduleRefineBuildPostReflectFallback(plan, result) - } - nextStrategy := strings.TrimSpace(parsed.NextStrategy) - if nextStrategy != "" { - reflection = fmt.Sprintf("%s;下一步建议:%s", reflection, nextStrategy) - } - shouldStop := parsed.ShouldStop - emitStage("schedule_refine.react.post_reflect.done", fmt.Sprintf("第 %d 轮|模型反思=%s|下一步=%s|should_stop=%t", round, scheduleRefineTruncate(strings.TrimSpace(parsed.Reflection), 120), scheduleRefineTruncate(nextStrategy, 120), shouldStop)) - return reflection, nextStrategy, shouldStop -} - -func scheduleRefineBuildPostReflectFallback(plan *scheduleRefineReactLLMOutput, result scheduleRefineReactToolResult) string { - modelReflect := "" - if plan != nil { - modelReflect = strings.TrimSpace(plan.Decision) - } - return scheduleRefineBuildRuntimeReflect(modelReflect, result) -} - -func scheduleRefineRunPlannerNode( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), - mode string, -) error { - if st == nil || chatModel == nil { - return fmt.Errorf("planner: invalid input") - } - scheduleRefineEnsureCompositeStateMaps(st) - // 1. 正常模式下由后端判定“本轮必用复合工具”。 - // 2. 若已进入禁复合兜底模式,必须清空该标记,避免规划阶段再次把复合门禁写回去。 - if st.DisableCompositeTools { - st.RequiredCompositeTool = "" - } else { - st.RequiredCompositeTool = scheduleRefineDetectRequiredCompositeTool(st) - } - if st.PlanUsed >= st.PlanMax { - return nil - } - stage := "schedule_refine.plan.generating" - if strings.TrimSpace(mode) == "replan" { - stage = "schedule_refine.plan.regenerating" - } - emitStage(stage, fmt.Sprintf("正在生成执行计划(mode=%s,已用%d/%d)。", mode, st.PlanUsed, st.PlanMax)) - contractJSON, _ := json.Marshal(st.Contract) - userPrompt := scheduleRefineWithNearestJSONContract( - fmt.Sprintf( - "mode=%s\n用户请求=%s\n契约=%s\n上一轮工具观察=%s\n最近观察=%s\nsuggested简表=%s", - mode, - strings.TrimSpace(st.UserMessage), - string(contractJSON), - scheduleRefineBuildLastToolObservationPrompt(st.ObservationHistory), - scheduleRefineBuildObservationPrompt(st.ObservationHistory, 2), - scheduleRefineBuildSuggestedDigest(st.HybridEntries, 40), - ), - jsonContractForPlanner, - ) - raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefinePlannerPrompt, userPrompt, false, plannerMaxTokens, 0) - if err != nil { - st.CurrentPlan = scheduleRefineApplyCompositeHardConditionToPlan(st, scheduleRefineBuildFallbackPlan(st)) - st.BatchMoveAllowed = scheduleRefineShouldAllowBatchMove(st.CurrentPlan) - st.PlanUsed++ - emitStage("schedule_refine.plan.fallback", "Planner 调用失败,已切换后端兜底计划。") - return nil - } - scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("planner.%s", mode), raw) - parsed, parseErr := scheduleRefineParsePlannerOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, mode, emitStage) - if parseErr != nil { - st.CurrentPlan = scheduleRefineApplyCompositeHardConditionToPlan(st, scheduleRefineBuildFallbackPlan(st)) - st.BatchMoveAllowed = scheduleRefineShouldAllowBatchMove(st.CurrentPlan) - st.PlanUsed++ - emitStage("schedule_refine.plan.fallback", fmt.Sprintf("Planner 输出解析失败,已切换后端兜底计划:%s", scheduleRefineTruncate(parseErr.Error(), 180))) - return nil - } - st.CurrentPlan = PlannerPlan{ - Summary: scheduleRefineFallbackText(strings.TrimSpace(parsed.Summary), "已生成可执行计划。"), - Steps: scheduleRefineUniqueNonEmpty(parsed.Steps), - } - st.CurrentPlan = scheduleRefineApplyCompositeHardConditionToPlan(st, st.CurrentPlan) - st.BatchMoveAllowed = scheduleRefineShouldAllowBatchMove(st.CurrentPlan) - if st.DisableCompositeTools { - st.BatchMoveAllowed = false - } - st.PlanUsed++ - emitStage("schedule_refine.plan.done", fmt.Sprintf("规划完成:%s", scheduleRefineTruncate(st.CurrentPlan.Summary, 180))) - return nil -} - -func scheduleRefineHandleBlockedToolResult( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), - round int, - parsed *scheduleRefineReactLLMOutput, - call *scheduleRefineReactToolCall, - callSignature string, - blockedResult scheduleRefineReactToolResult, - observation *ReactRoundObservation, -) (bool, error) { - result := scheduleRefineNormalizeToolResult(blockedResult) - st.RoundUsed++ - st.LastFailedCallSignature = callSignature - st.ConsecutiveFailures++ - observation.ToolName = strings.TrimSpace(result.Tool) - observation.ToolParams = scheduleRefineCloneToolParams(call.Params) - observation.ToolSuccess = result.Success - observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) - observation.ToolResult = strings.TrimSpace(result.Result) - postReflectText, _, shouldStop := scheduleRefineRunPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) - observation.Reflect = postReflectText - st.ObservationHistory = append(st.ObservationHistory, *observation) - emitStage("schedule_refine.react.tool_blocked", fmt.Sprintf("第 %d 轮|动作被后端策略拦截:%s", round, scheduleRefineTruncate(result.Result, 120))) - emitStage("schedule_refine.react.tool_result", scheduleRefineFormatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) - emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) - if scheduleRefineShouldTriggerReplan(st, result) { - if replanned, err := scheduleRefineTryReplan(ctx, chatModel, st, emitStage); err != nil { - return false, err - } else if replanned { - return false, nil - } - } - return shouldStop, nil -} - -func scheduleRefineBuildFallbackPlan(st *ScheduleRefineState) PlannerPlan { - summary := "兜底计划:先取证再动作,优先复合工具,其次 Move,冲突时尝试 Swap。" - if st != nil && st.Contract.KeepRelativeOrder { - summary = "兜底计划:先取证再动作,严格保持相对顺序,优先复合工具,其次 Move,冲突时尝试 Swap。" - } - return PlannerPlan{ - Summary: summary, - Steps: []string{ - "1) QueryTargetTasks 定位目标任务", - "2) QueryAvailableSlots 获取可用空位", - "3) 优先 SpreadEven/MinContextSwitch,其次 Move/Swap 执行动作并复盘", - "4) 收尾前执行 Verify 自检", - }, - } -} - -// scheduleRefineEnsureCompositeStateMaps 确保复合工具状态容器已初始化。 -func scheduleRefineEnsureCompositeStateMaps(st *ScheduleRefineState) { - if st == nil { - return - } - if st.CompositeToolCalled == nil { - st.CompositeToolCalled = map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - } - } - if st.CompositeToolSuccess == nil { - st.CompositeToolSuccess = map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - } - } -} - -// scheduleRefineDetectRequiredCompositeTool 根据请求语义识别本轮必用复合工具。 -// -// 规则: -// 1. “上下文切换最少/同科目连续”优先映射 MinContextSwitch; -// 2. “均匀分散/铺开”映射 SpreadEven; -// 3. 未命中时返回空串,不强制复合工具。 -func scheduleRefineDetectRequiredCompositeTool(st *ScheduleRefineState) string { - if st == nil { - return "" - } - joined := strings.TrimSpace(strings.Join([]string{ - st.UserMessage, - st.Contract.Intent, - strings.Join(st.Contract.HardRequirements, " "), - }, " ")) - if joined == "" { - return "" - } - contextKeys := []string{"上下文切换", "切换最少", "同个科目", "同科目", "连续处理", "连续学习", "min context", "context switch"} - if scheduleRefineContainsAny(strings.ToLower(joined), contextKeys) || scheduleRefineContainsAny(joined, contextKeys) { - return "MinContextSwitch" - } - evenKeys := []string{"均匀", "分散", "铺开", "平摊", "均摊", "spread even", "even spread"} - if scheduleRefineContainsAny(strings.ToLower(joined), evenKeys) || scheduleRefineContainsAny(joined, evenKeys) { - return "SpreadEven" - } - return "" -} - -// scheduleRefineApplyCompositeHardConditionToPlan 把“必用复合工具”硬条件注入计划文本。 -func scheduleRefineApplyCompositeHardConditionToPlan(st *ScheduleRefineState, plan PlannerPlan) PlannerPlan { - required := "" - if st != nil { - required = scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) - } - if required == "" { - return plan - } - - hardStep := fmt.Sprintf("硬条件:必须成功调用 %s(COMPOSITE_SUCCESS[%s]=true)后才允许整体收口", required, required) - hasHardStep := false - for _, step := range plan.Steps { - if strings.Contains(step, required) && strings.Contains(step, "COMPOSITE_SUCCESS") { - hasHardStep = true - break - } - } - if !hasHardStep { - plan.Steps = append([]string{hardStep}, plan.Steps...) - } - if !strings.Contains(plan.Summary, required) { - plan.Summary = strings.TrimSpace(plan.Summary + ";硬条件:" + required + " 成功==true") - } - return plan -} - -func scheduleRefineNormalizeCompositeToolName(name string) string { - switch strings.TrimSpace(name) { - case "SpreadEven": - return "SpreadEven" - case "MinContextSwitch": - return "MinContextSwitch" - default: - return "" - } -} - -func scheduleRefineIsCompositeToolName(toolName string) bool { - switch scheduleRefineNormalizeCompositeToolName(toolName) { - case "SpreadEven", "MinContextSwitch": - return true - default: - return false - } -} - -func scheduleRefineIsBaseMutatingToolName(toolName string) bool { - switch strings.TrimSpace(toolName) { - case "Move", "Swap", "BatchMove": - return true - default: - return false - } -} - -func scheduleRefineIsRequiredCompositeSatisfied(st *ScheduleRefineState) bool { - if st == nil { - return true - } - required := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) - if required == "" { - return true - } - scheduleRefineEnsureCompositeStateMaps(st) - return st.CompositeToolSuccess[required] -} - -// scheduleRefineApplyCompositeGateToIntentResult 把“必用复合工具成功”并入业务目标判定。 -// -// 步骤化说明: -// 1. 先判断原始业务判定是否通过;未通过则原样返回; -// 2. 再判断是否配置了必用复合工具;未配置则原样返回; -// 3. 若配置但未成功,强制改判为失败并补充 unmet 原因。 -func scheduleRefineApplyCompositeGateToIntentResult(st *ScheduleRefineState, pass bool, reason string, unmet []string) (bool, string, []string) { - if !pass { - return pass, reason, append([]string(nil), unmet...) - } - required := scheduleRefineNormalizeCompositeToolName("") - if st != nil { - required = scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) - } - if required == "" { - return pass, reason, append([]string(nil), unmet...) - } - if scheduleRefineIsRequiredCompositeSatisfied(st) { - return pass, reason, append([]string(nil), unmet...) - } - newUnmet := append([]string(nil), unmet...) - newUnmet = append(newUnmet, fmt.Sprintf("复合工具门禁未通过:%s 尚未成功调用", required)) - return false, fmt.Sprintf("复合工具门禁未通过:要求 %s 成功==true。", required), newUnmet -} - -func scheduleRefineMarkCompositeToolOutcome(st *ScheduleRefineState, toolName string, success bool) { - if st == nil { - return - } - tool := scheduleRefineNormalizeCompositeToolName(toolName) - if tool == "" { - return - } - scheduleRefineEnsureCompositeStateMaps(st) - st.CompositeToolCalled[tool] = true - if success { - st.CompositeToolSuccess[tool] = true - } -} - -func scheduleRefineShouldAllowBatchMove(plan PlannerPlan) bool { - text := strings.ToLower(strings.TrimSpace(plan.Summary)) - if strings.Contains(text, "batchmove") || strings.Contains(text, "batch move") { - return true - } - for _, step := range plan.Steps { - s := strings.ToLower(strings.TrimSpace(step)) - if strings.Contains(s, "batchmove") || strings.Contains(s, "batch move") { - return true - } - } - return false -} - -func scheduleRefineShouldEnableRecoveryThinking(st *ScheduleRefineState) (bool, string) { - if st == nil { - return false, "" - } - if st.ConsecutiveFailures < 2 || st.ThinkingBoostArmed { - return false, "" - } - st.ThinkingBoostArmed = true - return true, fmt.Sprintf("连续失败=%d,触发 1 轮恢复态 thinking", st.ConsecutiveFailures) -} - -func scheduleRefineShouldTriggerReplan(st *ScheduleRefineState, result scheduleRefineReactToolResult) bool { - if st == nil { - return false - } - if st.ConsecutiveFailures < 3 { - return false - } - switch strings.TrimSpace(result.ErrorCode) { - case "SLOT_CONFLICT", "ORDER_VIOLATION", "REPEAT_FAILED_ACTION", "PARAM_MISSING", "BATCH_MOVE_FAILED", "VERIFY_FAILED", "TASK_BUDGET_EXCEEDED", "BATCH_MOVE_DISABLED", "CURRENT_TASK_MISMATCH", "QUERY_REDUNDANT", "SLOT_QUERY_FAILED", "PLAN_FAILED", "PLAN_EMPTY", "COMPOSITE_REQUIRED", "COMPOSITE_DISABLED": - return true - default: - return false - } -} - -func scheduleRefineTryReplan( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (bool, error) { - if st == nil { - return false, nil - } - if st.ReplanUsed >= st.ReplanMax || st.PlanUsed >= st.PlanMax { - return false, nil - } - st.ReplanUsed++ - emitStage("schedule_refine.plan.replan_trigger", fmt.Sprintf("连续失败=%d,触发重规划(%d/%d)。", st.ConsecutiveFailures, st.ReplanUsed, st.ReplanMax)) - if err := scheduleRefineRunPlannerNode(ctx, chatModel, st, emitStage, "replan"); err != nil { - return true, err - } - st.ConsecutiveFailures = 0 - st.ThinkingBoostArmed = false - return true, nil -} - -func scheduleRefineCallModelText( - ctx context.Context, - chatModel *ark.ChatModel, - systemPrompt string, - userPrompt string, - useThinking bool, - maxTokens int, - temperature float32, -) (string, error) { - if chatModel == nil { - return "", fmt.Errorf("model is nil") - } - nodeCtx, cancel := context.WithTimeout(ctx, nodeTimeout) - defer cancel() - thinkingType := arkModel.ThinkingTypeDisabled - if useThinking { - thinkingType = arkModel.ThinkingTypeEnabled - } - opts := []einoModel.Option{ - ark.WithThinking(&arkModel.Thinking{Type: thinkingType}), - einoModel.WithTemperature(temperature), - } - if maxTokens > 0 { - opts = append(opts, einoModel.WithMaxTokens(maxTokens)) - } - resp, err := chatModel.Generate(nodeCtx, []*schema.Message{ - schema.SystemMessage(systemPrompt), - schema.UserMessage(userPrompt), - }, opts...) - if err != nil { - if errors.Is(nodeCtx.Err(), context.DeadlineExceeded) { - return "", fmt.Errorf("model call node timeout(%dms): %w", nodeTimeout.Milliseconds(), err) - } - if nodeCtx.Err() != nil { - return "", fmt.Errorf("model call node canceled(%v): %w", nodeCtx.Err(), err) - } - if ctx.Err() != nil { - return "", fmt.Errorf("model call parent canceled(%v): %w", ctx.Err(), err) - } - return "", err - } - if resp == nil { - return "", fmt.Errorf("model response is nil") - } - content := strings.TrimSpace(resp.Content) - if content == "" { - return "", fmt.Errorf("model response content is empty") - } - return content, nil -} - -func scheduleRefineParseJSON[T any](raw string) (*T, error) { - clean := strings.TrimSpace(raw) - if clean == "" { - return nil, fmt.Errorf("empty response") - } - 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 - } - obj, err := scheduleRefineExtractFirstJSONObject(clean) - if err != nil { - return nil, err - } - if err := json.Unmarshal([]byte(obj), &out); err != nil { - return nil, err - } - return &out, nil -} - -func scheduleRefineExtractFirstJSONObject(text string) (string, error) { - start := strings.Index(text, "{") - if start < 0 { - return "", fmt.Errorf("no json object found") - } - depth := 0 - inString := false - escape := false - for i := start; i < len(text); i++ { - ch := text[i] - if inString { - if escape { - escape = false - continue - } - if ch == '\\' { - escape = true - continue - } - if ch == '"' { - inString = false - } - continue - } - if ch == '"' { - inString = true - continue - } - if ch == '{' { - depth++ - continue - } - if ch == '}' { - depth-- - if depth == 0 { - return text[start : i+1], nil - } - } - } - return "", fmt.Errorf("json object not closed") -} - -func scheduleRefineEmitModelRawDebug(emitStage func(stage, detail string), tag string, raw string) { - if emitStage == nil { - return - } - clean := strings.TrimSpace(raw) - if clean == "" { - clean = "" - } - const chunkSize = 1600 - runes := []rune(clean) - if len(runes) <= chunkSize { - emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s] %s", strings.TrimSpace(tag), clean)) - return - } - total := (len(runes) + chunkSize - 1) / chunkSize - for i := 0; i < total; i++ { - start := i * chunkSize - end := start + chunkSize - if end > len(runes) { - end = len(runes) - } - emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s][part %d/%d] %s", strings.TrimSpace(tag), i+1, total, string(runes[start:end]))) - } -} - -func scheduleRefinePhysicsCheck(entries []model.HybridScheduleEntry, allocatedCount int) []string { - issues := make([]string, 0, 8) - slotMap := make(map[string]string, len(entries)*2) - for _, entry := range entries { - if entry.SectionFrom < 1 || entry.SectionTo > 12 || entry.SectionFrom > entry.SectionTo { - issues = append(issues, fmt.Sprintf("节次越界:%s W%dD%d %d-%d", entry.Name, entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo)) - } - if !scheduleRefineEntryBlocksSuggested(entry) { - continue - } - for sec := entry.SectionFrom; sec <= entry.SectionTo; sec++ { - key := fmt.Sprintf("%d-%d-%d", entry.Week, entry.DayOfWeek, sec) - if existed, ok := slotMap[key]; ok { - issues = append(issues, fmt.Sprintf("冲突:%s 与 %s 同时占用 W%dD%d 第%d节", existed, entry.Name, entry.Week, entry.DayOfWeek, sec)) - } else { - slotMap[key] = entry.Name - } - } - } - if allocatedCount > 0 { - suggested := scheduleRefineCountSuggested(entries) - if suggested != allocatedCount { - issues = append(issues, fmt.Sprintf("数量不一致:suggested=%d,allocated_items=%d", suggested, allocatedCount)) - } - } - return issues -} - -func scheduleRefineUpdateAllocatedItemsFromEntries(st *ScheduleRefineState) { - if st == nil || len(st.AllocatedItems) == 0 || len(st.HybridEntries) == 0 { - return - } - byTaskID := make(map[int]model.HybridScheduleEntry, len(st.HybridEntries)) - for _, entry := range st.HybridEntries { - if scheduleRefineIsMovableSuggestedTask(entry) { - byTaskID[entry.TaskItemID] = entry - } - } - for i := range st.AllocatedItems { - item := &st.AllocatedItems[i] - entry, ok := byTaskID[item.ID] - if !ok { - continue - } - if item.EmbeddedTime == nil { - item.EmbeddedTime = &model.TargetTime{} - } - item.EmbeddedTime.Week = entry.Week - item.EmbeddedTime.DayOfWeek = entry.DayOfWeek - item.EmbeddedTime.SectionFrom = entry.SectionFrom - item.EmbeddedTime.SectionTo = entry.SectionTo - } -} - -func scheduleRefineCountSuggested(entries []model.HybridScheduleEntry) int { - count := 0 - for _, entry := range entries { - if scheduleRefineIsMovableSuggestedTask(entry) { - count++ - } - } - return count -} - -func scheduleRefineSummarizeActionLogs(logs []string, tail int) string { - if len(logs) == 0 { - return "无" - } - if tail <= 0 || len(logs) <= tail { - return strings.Join(logs, "\n") - } - return strings.Join(logs[len(logs)-tail:], "\n") -} - -func scheduleRefineFallbackText(text string, fallback string) string { - clean := strings.TrimSpace(text) - if clean == "" { - return fallback - } - return clean -} - -func scheduleRefineWithNearestJSONContract(userPrompt string, jsonContract string) string { - base := strings.TrimSpace(userPrompt) - rule := strings.TrimSpace(jsonContract) - if rule == "" { - return base - } - if base == "" { - return rule - } - return base + "\n\n" + rule -} - -// scheduleRefineAlignSummaryWithHardCheck 对齐总结文案与硬校验事实,避免“通过/失败”口径冲突。 -// -// 步骤化说明: -// 1. 先以 hard_check 最终结果作为唯一真值; -// 2. pass=true 且 round_used=0 时,强制输出“未执行动作但已满足”的口径; -// 3. pass=true 但文案含失败词,或 pass=false 但文案含通过词,统一纠偏。 -func scheduleRefineAlignSummaryWithHardCheck(st *ScheduleRefineState, summary string) string { - clean := strings.TrimSpace(summary) - if st == nil { - return clean - } - passed := FinalHardCheckPassed(st) - if passed { - if st.RoundUsed == 0 { - return "本轮未执行调度动作(0轮),当前排程已满足终审条件。" - } - if clean == "" || scheduleRefineContainsAny(clean, []string{"未完全", "未达标", "未能", "差距", "失败", "未通过"}) { - return fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) - } - return clean - } - - if clean == "" || scheduleRefineContainsAny(clean, []string{"终审通过", "已通过终审", "完全达成", "全部满足"}) { - return fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, scheduleRefineFallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) - } - return clean -} - -func scheduleRefineFormatReactPlanStageDetail(round int, out *scheduleRefineReactLLMOutput, remaining int, useThinking bool) string { - if out == nil { - return fmt.Sprintf("第 %d 轮:缺少计划输出。", round) - } - return fmt.Sprintf("第 %d 轮|thinking=%t|动作剩余=%d|goal_check=%s|decision=%s", round, useThinking, remaining, scheduleRefineTruncate(strings.TrimSpace(out.GoalCheck), 180), scheduleRefineTruncate(strings.TrimSpace(out.Decision), 180)) -} - -func scheduleRefineFormatReactNeedInfoStageDetail(round int, missing []string) string { - if len(missing) == 0 { - return fmt.Sprintf("第 %d 轮|模型缺口信息=无。", round) - } - return fmt.Sprintf("第 %d 轮|模型缺口信息=%s", round, strings.Join(scheduleRefineUniqueNonEmpty(missing), ";")) -} - -func scheduleRefineFormatReactReflectStageDetail(round int, reflect string) string { - return fmt.Sprintf("第 %d 轮|复盘=%s", round, scheduleRefineTruncate(strings.TrimSpace(reflect), 260)) -} - -func scheduleRefineFormatToolCallStageDetail(round int, call scheduleRefineReactToolCall, remaining int) string { - paramsText := "{}" - if len(call.Params) > 0 { - if raw, err := json.Marshal(call.Params); err == nil { - paramsText = string(raw) - } - } - return fmt.Sprintf("第 %d 轮|调用工具=%s|参数=%s|调用前剩余轮次=%d", round, strings.TrimSpace(call.Tool), scheduleRefineTruncate(paramsText, 320), remaining) -} - -func scheduleRefineFormatToolResultStageDetail(round int, result scheduleRefineReactToolResult, used int, total int) string { - errorCode := strings.TrimSpace(result.ErrorCode) - if !result.Success && errorCode == "" { - errorCode = "TOOL_EXEC_FAILED" - } - if errorCode == "" { - errorCode = "NONE" - } - return fmt.Sprintf("第 %d 轮|工具=%s|success=%t|error_code=%s|结果=%s|轮次进度=%d/%d", round, strings.TrimSpace(result.Tool), result.Success, errorCode, scheduleRefineTruncate(strings.TrimSpace(result.Result), 320), used, total) -} - -func scheduleRefineCondenseSummary(plans []model.UserWeekSchedule) string { - if len(plans) == 0 { - return "无历史排程摘要" - } - totalEvents := 0 - startWeek := plans[0].Week - endWeek := plans[0].Week - for _, week := range plans { - totalEvents += len(week.Events) - if week.Week < startWeek { - startWeek = week.Week - } - if week.Week > endWeek { - endWeek = week.Week - } - } - return fmt.Sprintf("共 %d 周,周次范围 W%d~W%d,事件总数 %d。", len(plans), startWeek, endWeek, totalEvents) -} - -func scheduleRefineHybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule { - 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 _, entry := range entries { - start, end := "", "" - if val, ok := sectionTimeMap[entry.SectionFrom]; ok { - start = val[0] - } - if val, ok := sectionTimeMap[entry.SectionTo]; ok { - end = val[1] - } - weekMap[entry.Week] = append(weekMap[entry.Week], model.WeeklyEventBrief{ - ID: entry.EventID, - DayOfWeek: entry.DayOfWeek, - Name: entry.Name, - StartTime: start, - EndTime: end, - Type: entry.Type, - Span: entry.SectionTo - entry.SectionFrom + 1, - Status: entry.Status, - }) - } - result := make([]model.UserWeekSchedule, 0, len(weekMap)) - for week, events := range weekMap { - result = append(result, model.UserWeekSchedule{Week: week, Events: events}) - } - sort.SliceStable(result, func(i, j int) bool { return result[i].Week < result[j].Week }) - return result -} - -func scheduleRefineBuildFallbackContract(st *ScheduleRefineState) RefineContract { - intent := strings.TrimSpace(st.UserMessage) - keepOrder := scheduleRefineDetectOrderIntent(st.UserMessage) - reqs := append([]string(nil), st.Constraints...) - if keepOrder { - reqs = append(reqs, "保持任务原始相对顺序不变") - } - assertions := scheduleRefineInferHardAssertionsFromRequest(st.UserMessage, reqs) - return RefineContract{ - Intent: intent, - Strategy: "local_adjust", - HardRequirements: scheduleRefineUniqueNonEmpty(reqs), - HardAssertions: assertions, - KeepRelativeOrder: keepOrder, - OrderScope: "global", - } -} - -func scheduleRefineNormalizeStrategy(strategy string) string { - switch strings.TrimSpace(strings.ToLower(strategy)) { - case "keep": - return "keep" - default: - return "local_adjust" - } -} - -func scheduleRefineDetectOrderIntent(userMessage string) bool { - msg := strings.TrimSpace(userMessage) - if msg == "" { - return true - } - // 1. 默认启用顺序约束,除非用户明确授权可打乱顺序。 - // 2. 这样可避免“用户没提顺序但结果被打乱”的违和体验。 - for _, k := range []string{"可以打乱顺序", "允许打乱顺序", "顺序无所谓", "不考虑顺序", "不用保持顺序", "无需保持顺序", "随便排顺序", "乱序也行"} { - if strings.Contains(msg, k) { - return false - } - } - return true -} - -func scheduleRefineUniqueNonEmpty(items []string) []string { - if len(items) == 0 { - return nil - } - seen := make(map[string]struct{}, len(items)) - out := make([]string, 0, len(items)) - for _, item := range items { - clean := strings.TrimSpace(item) - if clean == "" { - continue - } - if _, ok := seen[clean]; ok { - continue - } - seen[clean] = struct{}{} - out = append(out, clean) - } - return out -} - -func scheduleRefineBuildObservationPrompt(history []ReactRoundObservation, tail int) string { - if len(history) == 0 { - return "无" - } - start := 0 - if tail > 0 && len(history) > tail { - start = len(history) - tail - } - raw, err := json.Marshal(history[start:]) - if err != nil { - return err.Error() - } - return string(raw) -} - -func scheduleRefineBuildLastToolObservationPrompt(history []ReactRoundObservation) string { - for i := len(history) - 1; i >= 0; i-- { - item := history[i] - if strings.TrimSpace(item.ToolName) == "" { - continue - } - raw, err := json.Marshal(item) - if err != nil { - return "无" - } - return string(raw) - } - return "无" -} - -func scheduleRefineBuildToolCallSignature(call scheduleRefineReactToolCall) string { - paramsText := "{}" - if len(call.Params) > 0 { - if raw, err := json.Marshal(call.Params); err == nil { - paramsText = string(raw) - } - } - return fmt.Sprintf("%s|%s", strings.ToUpper(strings.TrimSpace(call.Tool)), paramsText) -} - -func scheduleRefineBuildSlotQuerySignature(st *ScheduleRefineState, params map[string]any) string { - normalized := scheduleRefineCanonicalizeToolCall(scheduleRefineReactToolCall{Tool: "QueryAvailableSlots", Params: params}) - raw, _ := json.Marshal(normalized.Params) - version := 0 - if st != nil { - version = st.EntriesVersion - } - return fmt.Sprintf("v=%d|%s", version, string(raw)) -} - -func scheduleRefineCanonicalizeToolCall(call scheduleRefineReactToolCall) scheduleRefineReactToolCall { - canonical := scheduleRefineReactToolCall{ - Tool: strings.TrimSpace(call.Tool), - Params: scheduleRefineCloneToolParams(call.Params), - } - switch canonical.Tool { - case "Move": - canonical.Params = scheduleRefineCanonicalizeMoveParams(canonical.Params) - case "BatchMove": - canonical.Params = scheduleRefineCanonicalizeBatchMoveParams(canonical.Params) - case "SpreadEven", "MinContextSwitch": - canonical.Params = scheduleRefineCanonicalizeCompositeMoveParams(canonical.Params) - case "QueryAvailableSlots": - canonical.Params = scheduleRefineCanonicalizeSlotQueryParams(canonical.Params) - } - return canonical -} - -func scheduleRefineCanonicalizeMoveParams(params map[string]any) map[string]any { - out := scheduleRefineCloneToolParams(params) - scheduleRefineSetCanonicalInt(out, "task_item_id", out, "task_item_id", "task_id") - scheduleRefineSetCanonicalInt(out, "to_week", out, "to_week", "target_week", "new_week", "week") - scheduleRefineSetCanonicalInt(out, "to_day", out, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day") - scheduleRefineSetCanonicalInt(out, "to_section_from", out, "to_section_from", "target_section_from", "new_section_from", "section_from") - scheduleRefineSetCanonicalInt(out, "to_section_to", out, "to_section_to", "target_section_to", "new_section_to", "section_to") - return out -} - -func scheduleRefineCanonicalizeBatchMoveParams(params map[string]any) map[string]any { - out := scheduleRefineCloneToolParams(params) - rawMoves, ok := out["moves"] - if !ok { - return out - } - moves, ok := rawMoves.([]any) - if !ok { - return out - } - normalized := make([]any, 0, len(moves)) - for _, item := range moves { - moveMap, ok := item.(map[string]any) - if !ok { - continue - } - normalized = append(normalized, scheduleRefineCanonicalizeMoveParams(moveMap)) - } - out["moves"] = normalized - return out -} - -func scheduleRefineCanonicalizeCompositeMoveParams(params map[string]any) map[string]any { - out := scheduleRefineCloneToolParams(params) - ids := scheduleRefineReadIntSlice(out, "task_item_ids", "task_ids") - if taskID, ok := scheduleRefineParamIntAny(out, "task_item_id", "task_id"); ok { - ids = append(ids, taskID) - } - if len(ids) > 0 { - out["task_item_ids"] = scheduleRefineUniquePositiveInts(ids) - } - - scheduleRefineSetCanonicalInt(out, "week", out, "week", "to_week", "target_week", "new_week") - if day, ok := scheduleRefineParamIntAny(out, "day_of_week", "to_day", "target_day_of_week", "target_day", "new_day", "day"); ok { - out["day_of_week"] = []int{day} - } - if weeks := scheduleRefineReadIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 { - out["week_filter"] = scheduleRefineUniquePositiveInts(weeks) - } - if days := scheduleRefineReadIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 { - out["day_of_week"] = scheduleRefineUniquePositiveInts(days) - } - if sections := scheduleRefineReadIntSlice(out, "exclude_sections", "exclude_section"); len(sections) > 0 { - out["exclude_sections"] = scheduleRefineUniquePositiveInts(sections) - } - return out -} - -func scheduleRefineCanonicalizeSlotQueryParams(params map[string]any) map[string]any { - out := scheduleRefineCloneToolParams(params) - scheduleRefineSetCanonicalInt(out, "week", out, "week") - if weeks := scheduleRefineReadIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 { - out["week_filter"] = scheduleRefineUniquePositiveInts(weeks) - } - if days := scheduleRefineReadIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 { - out["day_filter"] = scheduleRefineUniquePositiveInts(days) - } - scheduleRefineSetCanonicalInt(out, "section_duration", out, "section_duration", "span", "task_duration") - scheduleRefineSetCanonicalInt(out, "section_from", out, "section_from", "target_section_from") - scheduleRefineSetCanonicalInt(out, "section_to", out, "section_to", "target_section_to") - scheduleRefineSetCanonicalInt(out, "limit", out, "limit") - return out -} - -func scheduleRefineSetCanonicalInt(dst map[string]any, dstKey string, src map[string]any, keys ...string) { - if dst == nil || src == nil { - return - } - if value, ok := scheduleRefineParamIntAny(src, keys...); ok { - dst[dstKey] = value - } -} - -func scheduleRefineListTaskIDsFromToolCall(call scheduleRefineReactToolCall) []int { - switch strings.TrimSpace(call.Tool) { - case "Move": - taskID, ok := scheduleRefineParamIntAny(call.Params, "task_item_id", "task_id") - if !ok { - return nil - } - return []int{taskID} - case "Swap": - taskA, okA := scheduleRefineParamIntAny(call.Params, "task_a", "task_item_a", "task_item_id_a") - taskB, okB := scheduleRefineParamIntAny(call.Params, "task_b", "task_item_b", "task_item_id_b") - return scheduleRefineUniquePositiveInts([]int{taskA, taskB}, okA, okB) - case "BatchMove": - rawMoves, ok := call.Params["moves"] - if !ok { - return nil - } - moves, ok := rawMoves.([]any) - if !ok { - return nil - } - ids := make([]int, 0, len(moves)) - for _, item := range moves { - moveMap, ok := item.(map[string]any) - if !ok { - continue - } - if taskID, ok := scheduleRefineParamIntAny(moveMap, "task_item_id", "task_id"); ok { - ids = append(ids, taskID) - } - } - return scheduleRefineUniquePositiveInts(ids) - case "SpreadEven", "MinContextSwitch": - ids := scheduleRefineReadIntSlice(call.Params, "task_item_ids", "task_ids") - if taskID, ok := scheduleRefineParamIntAny(call.Params, "task_item_id", "task_id"); ok { - ids = append(ids, taskID) - } - return scheduleRefineUniquePositiveInts(ids) - default: - return nil - } -} - -func scheduleRefinePrecheckCurrentTaskOwnership(call scheduleRefineReactToolCall, taskIDs []int, currentTaskID int) (scheduleRefineReactToolResult, bool) { - if currentTaskID <= 0 { - return scheduleRefineReactToolResult{}, false - } - if !scheduleRefineIsMutatingToolName(strings.TrimSpace(call.Tool)) { - return scheduleRefineReactToolResult{}, false - } - for _, id := range taskIDs { - if id == currentTaskID { - return scheduleRefineReactToolResult{}, false - } - } - return scheduleRefineReactToolResult{ - Tool: strings.TrimSpace(call.Tool), - Success: false, - ErrorCode: "CURRENT_TASK_MISMATCH", - Result: fmt.Sprintf("当前微循环任务为 id=%d,本轮改写动作未包含该任务,请改为围绕当前任务执行。", currentTaskID), - }, true -} - -func scheduleRefinePrecheckToolCallPolicy(st *ScheduleRefineState, call scheduleRefineReactToolCall, taskIDs []int) (scheduleRefineReactToolResult, bool) { - if st == nil { - return scheduleRefineReactToolResult{}, false - } - toolName := strings.TrimSpace(call.Tool) - if st.DisableCompositeTools && scheduleRefineIsCompositeToolName(toolName) { - return scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "COMPOSITE_DISABLED", - Result: "当前已进入 ReAct 兜底模式,禁止调用复合工具,请使用 Move/Swap 逐步处理。", - }, true - } - if st.DisableCompositeTools && toolName == "BatchMove" { - return scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "BATCH_MOVE_DISABLED", - Result: "当前兜底模式要求逐任务挪动,禁止使用 BatchMove。", - }, true - } - if toolName == "BatchMove" && !st.BatchMoveAllowed { - return scheduleRefineReactToolResult{Tool: toolName, Success: false, ErrorCode: "BATCH_MOVE_DISABLED", Result: "当前计划未显式允许 BatchMove,请改用单步 Move/Swap。"}, true - } - if toolName == "QueryAvailableSlots" { - if st.SeenSlotQueries == nil { - st.SeenSlotQueries = make(map[string]struct{}) - } - signature := scheduleRefineBuildSlotQuerySignature(st, call.Params) - if _, exists := st.SeenSlotQueries[signature]; exists { - return scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "QUERY_REDUNDANT", - Result: "同版本排程下重复查询同一空位范围,已拒绝;请直接基于 ENV_SLOT_HINT 选择落点。", - }, true - } - st.SeenSlotQueries[signature] = struct{}{} - return scheduleRefineReactToolResult{}, false - } - // 1. 当计划声明“必用复合工具”且尚未成功时,先锁住基础写工具。 - // 2. 这样可避免模型绕开复合工具直接 Move,导致“命中率低 + 语义漂移”。 - requiredComposite := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) - if requiredComposite != "" && !scheduleRefineIsRequiredCompositeSatisfied(st) && scheduleRefineIsMutatingToolName(toolName) { - if toolName != requiredComposite { - return scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "COMPOSITE_REQUIRED", - Result: fmt.Sprintf("当前计划要求先成功调用 %s;在其成功前禁止使用 %s。", requiredComposite, toolName), - }, true - } - } - if !scheduleRefineIsMutatingToolName(toolName) { - return scheduleRefineReactToolResult{}, false - } - if st.PerTaskBudget <= 0 || len(taskIDs) == 0 { - return scheduleRefineReactToolResult{}, false - } - for _, taskID := range taskIDs { - if st.TaskActionUsed[taskID] >= st.PerTaskBudget { - return scheduleRefineReactToolResult{Tool: toolName, Success: false, ErrorCode: "TASK_BUDGET_EXCEEDED", Result: fmt.Sprintf("任务 id=%d 已达到单任务动作预算上限=%d,请重规划或更换目标任务。", taskID, st.PerTaskBudget)}, true - } - } - return scheduleRefineReactToolResult{}, false -} - -func scheduleRefineIsMutatingToolName(toolName string) bool { - switch strings.TrimSpace(toolName) { - case "Move", "Swap", "BatchMove", "SpreadEven", "MinContextSwitch": - return true - default: - return false - } -} - -func scheduleRefineUniquePositiveInts(ids []int, oks ...bool) []int { - allowAll := len(oks) == 0 - seen := make(map[int]struct{}, len(ids)) - out := make([]int, 0, len(ids)) - for i, id := range ids { - if !allowAll { - if i >= len(oks) || !oks[i] { - continue - } - } - if id <= 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -func scheduleRefineIsRepeatedFailedCall(st *ScheduleRefineState, signature string) bool { - if st == nil { - return false - } - current := strings.TrimSpace(signature) - last := strings.TrimSpace(st.LastFailedCallSignature) - return current != "" && last != "" && current == last -} - -func scheduleRefineNormalizeToolResult(result scheduleRefineReactToolResult) scheduleRefineReactToolResult { - if result.Success { - return result - } - if strings.TrimSpace(result.ErrorCode) != "" { - return result - } - result.ErrorCode = scheduleRefineClassifyToolFailureCode(result.Result) - return result -} - -func scheduleRefineClassifyToolFailureCode(detail string) string { - text := strings.TrimSpace(detail) - switch { - case strings.Contains(text, "单任务动作预算上限"): - return "TASK_BUDGET_EXCEEDED" - case strings.Contains(text, "未显式允许 BatchMove"): - return "BATCH_MOVE_DISABLED" - case strings.Contains(text, "重复失败动作"): - return "REPEAT_FAILED_ACTION" - case strings.Contains(text, "顺序约束不满足"): - return "ORDER_VIOLATION" - case strings.Contains(text, "参数缺失"): - return "PARAM_MISSING" - case strings.Contains(text, "目标时段已被"): - return "SLOT_CONFLICT" - case strings.Contains(text, "无法唯一定位"): - return "TASK_ID_AMBIGUOUS" - case strings.Contains(text, "任务跨度不一致"): - return "SPAN_MISMATCH" - case strings.Contains(text, "超出允许窗口"): - return "OUT_OF_WINDOW" - case strings.Contains(text, "day_of_week"): - return "DAY_INVALID" - case strings.Contains(text, "节次区间"): - return "SECTION_INVALID" - case strings.Contains(text, "未找到 task_item_id"): - return "TASK_NOT_FOUND" - case strings.Contains(text, "不支持的工具"): - return "TOOL_NOT_ALLOWED" - case strings.Contains(text, "BatchMove"): - return "BATCH_MOVE_FAILED" - case strings.Contains(text, "Verify"): - return "VERIFY_FAILED" - case strings.Contains(text, "序列化查询结果失败"), strings.Contains(text, "序列化空位结果失败"): - return "QUERY_ENCODE_FAILED" - default: - return "TOOL_EXEC_FAILED" - } -} - -func scheduleRefineCloneToolParams(params map[string]any) map[string]any { - if len(params) == 0 { - return nil - } - raw, err := json.Marshal(params) - if err != nil { - dst := make(map[string]any, len(params)) - for k, v := range params { - dst[k] = v - } - return dst - } - var out map[string]any - if err = json.Unmarshal(raw, &out); err != nil { - dst := make(map[string]any, len(params)) - for k, v := range params { - dst[k] = v - } - return dst - } - return out -} - -func scheduleRefineFormatRoundModelErrorDetail(round int, err error, parentCtx context.Context) string { - parentState := "alive" - if parentCtx == nil { - parentState = "nil" - } else if parentCtx.Err() != nil { - parentState = parentCtx.Err().Error() - } - parentDeadline := "none" - if parentCtx != nil { - if deadline, ok := parentCtx.Deadline(); ok { - parentDeadline = fmt.Sprintf("%dms", time.Until(deadline).Milliseconds()) - } - } - return fmt.Sprintf("第 %d 轮模型调用失败:%v | parent_ctx=%s | parent_deadline_in_ms=%s | node_timeout_ms=%d", round, err, parentState, parentDeadline, nodeTimeout.Milliseconds()) -} - -func scheduleRefineBuildRuntimeReflect(modelReflect string, result scheduleRefineReactToolResult) string { - modelText := strings.TrimSpace(modelReflect) - resultText := scheduleRefineTruncate(strings.TrimSpace(result.Result), 220) - if result.Success { - if modelText == "" { - return fmt.Sprintf("后端复盘:工具执行成功。%s", resultText) - } - return fmt.Sprintf("后端复盘:工具执行成功。%s。模型预期(动作前):%s", resultText, scheduleRefineTruncate(modelText, 180)) - } - if modelText == "" { - return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。", resultText) - } - return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。模型预期(动作前,仅供参考):%s", resultText, scheduleRefineTruncate(modelText, 160)) -} - -func scheduleRefineBuildSuggestedDigest(entries []model.HybridScheduleEntry, limit int) string { - if len(entries) == 0 { - return "无" - } - list := make([]model.HybridScheduleEntry, 0, len(entries)) - for _, entry := range entries { - if scheduleRefineIsMovableSuggestedTask(entry) { - list = append(list, entry) - } - } - if len(list) == 0 { - return "无 suggested 条目" - } - scheduleRefineSortHybridEntries(list) - if limit <= 0 { - limit = len(list) - } - if len(list) > limit { - list = list[:limit] - } - lines := make([]string, 0, len(list)) - for _, item := range list { - lines = append(lines, fmt.Sprintf("id=%d|W%d|D%d(%s)|%d-%d|%s", item.TaskItemID, item.Week, item.DayOfWeek, scheduleRefineWeekdayLabel(item.DayOfWeek), item.SectionFrom, item.SectionTo, strings.TrimSpace(item.Name))) - } - return strings.Join(lines, "\n") -} - -func scheduleRefineBuildSuggestedDigestByWeek(entries []model.HybridScheduleEntry, week int, limit int) string { - if week <= 0 { - return scheduleRefineBuildSuggestedDigest(entries, limit) - } - filtered := make([]model.HybridScheduleEntry, 0, len(entries)) - for _, entry := range entries { - if scheduleRefineIsMovableSuggestedTask(entry) && entry.Week == week { - filtered = append(filtered, entry) - } - } - if len(filtered) == 0 { - return "无同周 suggested 条目" - } - return scheduleRefineBuildSuggestedDigest(filtered, limit) -} - -func scheduleRefineWeekdayLabel(day int) string { - switch day { - case 1: - return "周一" - case 2: - return "周二" - case 3: - return "周三" - case 4: - return "周四" - case 5: - return "周五" - case 6: - return "周六" - case 7: - return "周日" - default: - return "未知" - } -} - -func scheduleRefineParseReactOutputWithRetryOnce( - ctx context.Context, - chatModel *ark.ChatModel, - userPrompt string, - firstRaw string, - round int, - emitStage func(stage, detail string), - st *ScheduleRefineState, -) (*scheduleRefineReactLLMOutput, error) { - if st == nil { - return nil, respond.ScheduleRefineOutputParseFailed - } - parsed, parseErr := parseScheduleRefineLLMOutput(firstRaw) - if parseErr == nil { - return parsed, nil - } - emitStage("schedule_refine.react.parse_retry", fmt.Sprintf("第 %d 轮输出解析失败,准备重试1次:%s", round, scheduleRefineTruncate(parseErr.Error(), 260))) - retryRaw, retryErr := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineReactPrompt, userPrompt, false, reactMaxTokens, 0) - if retryErr != nil { - emitStage("schedule_refine.react.round_error", scheduleRefineFormatRoundModelErrorDetail(round, fmt.Errorf("解析重试调用失败: %w", retryErr), ctx)) - return nil, respond.ScheduleRefineOutputParseFailed - } - scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.retry", round), retryRaw) - retryParsed, retryParseErr := parseScheduleRefineLLMOutput(retryRaw) - if retryParseErr != nil { - emitStage("schedule_refine.react.round_error", fmt.Sprintf("第 %d 轮输出二次解析失败:%s", round, scheduleRefineTruncate(retryParseErr.Error(), 260))) - return nil, respond.ScheduleRefineOutputParseFailed - } - emitStage("schedule_refine.react.parse_retry_success", fmt.Sprintf("第 %d 轮输出重试解析成功,继续执行。", round)) - return retryParsed, nil -} - -func scheduleRefineParsePlannerOutputWithRetryOnce( - ctx context.Context, - chatModel *ark.ChatModel, - originUserPrompt string, - firstRaw string, - mode string, - emitStage func(stage, detail string), -) (*scheduleRefinePlannerOutput, error) { - parsed, parseErr := scheduleRefineParseJSON[scheduleRefinePlannerOutput](firstRaw) - if parseErr == nil { - return parsed, nil - } - emitStage("schedule_refine.plan.parse_retry", fmt.Sprintf("Planner 解析失败,准备重试1次(mode=%s):%s", strings.TrimSpace(mode), scheduleRefineTruncate(parseErr.Error(), 160))) - retryPrompt := scheduleRefineWithNearestJSONContract( - fmt.Sprintf("%s\n\n上一轮输出解析失败(原因:JSON 不完整或不闭合)。请缩短内容并严格输出完整 JSON。", originUserPrompt), - jsonContractForPlanner, - ) - retryRaw, retryErr := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefinePlannerPrompt, retryPrompt, false, plannerMaxTokens, 0) - if retryErr != nil { - return nil, retryErr - } - scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("planner.%s.retry", strings.TrimSpace(mode)), retryRaw) - retryParsed, retryParseErr := scheduleRefineParseJSON[scheduleRefinePlannerOutput](retryRaw) - if retryParseErr != nil { - return nil, retryParseErr - } - emitStage("schedule_refine.plan.parse_retry_success", fmt.Sprintf("Planner 重试解析成功(mode=%s)。", strings.TrimSpace(mode))) - return retryParsed, nil -} - -func scheduleRefineBuildSlicePlan(st *ScheduleRefineState) RefineSlicePlan { - msg := strings.TrimSpace(st.UserMessage) - lower := strings.ToLower(msg) - plan := RefineSlicePlan{ - WeekFilter: scheduleRefineExtractWeekFilters(msg), - ExcludeSections: scheduleRefineExtractExcludeSections(msg), - Reason: "根据用户请求抽取得到执行切片", - } - // 1. 优先解析“从A收敛到B”这类方向型表达,防止把 source/target 反向识别。 - // 2. 例如“周四到周五收敛到周一到周三”应得到 source=[4,5], target=[1,2,3]。 - if src, tgt, ok := scheduleRefineExtractDirectionalSourceTargetDays(msg); ok { - plan.SourceDays = src - plan.TargetDays = tgt - return plan - } - if strings.Contains(msg, "工作日") || strings.Contains(msg, "周一到周五") || strings.Contains(msg, "周1到周5") { - plan.TargetDays = []int{1, 2, 3, 4, 5} - } else if scheduleRefineContainsAny(lower, []string{"移到周末", "挪到周末", "安排在周末", "放到周末"}) { - plan.TargetDays = []int{6, 7} - } else if days := scheduleRefineExtractTargetDaysFromMessage(msg); len(days) > 0 { - plan.TargetDays = days - } - if len(plan.TargetDays) == 5 && scheduleRefineIsSameDays(plan.TargetDays, []int{1, 2, 3, 4, 5}) && strings.Contains(msg, "周末") { - plan.SourceDays = []int{6, 7} - } - if day := scheduleRefineDetectOverloadedDay(msg); day > 0 { - plan.SourceDays = scheduleRefineUniquePositiveInts(append(plan.SourceDays, day)) - } - if fromDays := scheduleRefineExtractSourceDaysFromMessage(msg); len(fromDays) > 0 { - plan.SourceDays = scheduleRefineUniquePositiveInts(append(plan.SourceDays, fromDays...)) - } - return plan -} - -// scheduleRefineExtractDirectionalSourceTargetDays 解析“来源日 -> 目标日”表达。 -// -// 规则: -// 1. 以“收敛到/移到/挪到/调整到”等方向词为分割; -// 2. 分割前提取 source days,分割后提取 target days; -// 3. 两侧都提取成功才返回 true,避免误判。 -func scheduleRefineExtractDirectionalSourceTargetDays(text string) ([]int, []int, bool) { - verbIdx := -1 - verbLen := 0 - for _, key := range []string{"收敛到", "移到", "挪到", "调整到", "安排到", "放到", "改到", "迁移到", "分散到"} { - if idx := strings.Index(text, key); idx >= 0 { - verbIdx = idx - verbLen = len(key) - break - } - } - if verbIdx < 0 { - return nil, nil, false - } - left := strings.TrimSpace(text[:verbIdx]) - right := strings.TrimSpace(text[verbIdx+verbLen:]) - if left == "" || right == "" { - return nil, nil, false - } - source := scheduleRefineExtractDayExpr(left) - target := scheduleRefineExtractDayExpr(right) - if len(source) == 0 || len(target) == 0 { - return nil, nil, false - } - return source, target, true -} - -// scheduleRefineExtractDayExpr 提取文本中的“星期表达式”。 -// 优先提取区间(周一到周三),提不到再提取离散天。 -func scheduleRefineExtractDayExpr(text string) []int { - if days := scheduleRefineExtractRangeDays(text); len(days) > 0 { - return days - } - return scheduleRefineExtractDays(text) -} - -// scheduleRefineInferSourceWeekSet 推断“来源周”集合。 -// -// 规则: -// 1. 当 week_filter 至少两个值时,默认第一个值视为来源周(保留用户原话顺序); -// 2. 当 week_filter 少于两个值时,不强制来源周过滤,返回空集合; -// 3. 该规则用于收敛 workset,避免把目标周任务误纳入当前微循环。 -func scheduleRefineInferSourceWeekSet(slice RefineSlicePlan) map[int]struct{} { - if len(slice.WeekFilter) < 2 { - return nil - } - sourceWeek := slice.WeekFilter[0] - if sourceWeek <= 0 { - return nil - } - return map[int]struct{}{sourceWeek: {}} -} - -// scheduleRefineInferTargetWeekSet 推断“目标周”集合。 -// -// 规则: -// 1. 当 week_filter 至少两个值时,除首个来源周外,其余周视为目标周; -// 2. 当 week_filter 少于两个值时,不构造目标周集合,交由其他约束判定; -// 3. 返回升维集合用于 O(1) 命中判断。 -func scheduleRefineInferTargetWeekSet(slice RefineSlicePlan) map[int]struct{} { - if len(slice.WeekFilter) < 2 { - return nil - } - set := make(map[int]struct{}, len(slice.WeekFilter)-1) - for _, week := range slice.WeekFilter[1:] { - if week > 0 { - set[week] = struct{}{} - } - } - if len(set) == 0 { - return nil - } - return set -} - -func scheduleRefineCollectWorksetTaskIDs(entries []model.HybridScheduleEntry, slice RefineSlicePlan, originOrder map[int]int) []int { - type candidate struct { - TaskID int - Week int - Day int - SectionFrom int - Rank int - } - list := make([]candidate, 0, len(entries)) - seen := make(map[int]struct{}, len(entries)) - weekSet := scheduleRefineIntSliceToWeekSet(slice.WeekFilter) - sourceWeekSet := scheduleRefineInferSourceWeekSet(slice) - sourceSet := scheduleRefineIntSliceToDaySet(slice.SourceDays) - for _, entry := range entries { - if !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - // 1. 方向型周次请求(例如“14周挪到13周”)下,只把“来源周”任务放入 workset。 - // 2. 这样做可以避免目标周/其他周任务被误当成当前微循环任务,触发串改。 - if len(sourceWeekSet) > 0 { - if _, ok := sourceWeekSet[entry.Week]; !ok { - continue - } - } - if len(weekSet) > 0 { - if _, ok := weekSet[entry.Week]; !ok { - continue - } - } - if len(sourceSet) > 0 { - if _, ok := sourceSet[entry.DayOfWeek]; !ok { - continue - } - } - if _, ok := seen[entry.TaskItemID]; ok { - continue - } - seen[entry.TaskItemID] = struct{}{} - rank := originOrder[entry.TaskItemID] - if rank <= 0 { - rank = 1 << 30 - } - list = append(list, candidate{ - TaskID: entry.TaskItemID, - Week: entry.Week, - Day: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - Rank: rank, - }) - } - sort.SliceStable(list, func(i, j int) bool { - if list[i].Rank != list[j].Rank { - return list[i].Rank < list[j].Rank - } - if list[i].Week != list[j].Week { - return list[i].Week < list[j].Week - } - if list[i].Day != list[j].Day { - return list[i].Day < list[j].Day - } - if list[i].SectionFrom != list[j].SectionFrom { - return list[i].SectionFrom < list[j].SectionFrom - } - return list[i].TaskID < list[j].TaskID - }) - ids := make([]int, 0, len(list)) - for _, item := range list { - ids = append(ids, item.TaskID) - } - return ids -} - -func scheduleRefineFindSuggestedEntryByTaskID(entries []model.HybridScheduleEntry, taskID int) (model.HybridScheduleEntry, bool) { - for _, entry := range entries { - if scheduleRefineIsMovableSuggestedTask(entry) && entry.TaskItemID == taskID { - return entry, true - } - } - return model.HybridScheduleEntry{}, false -} - -// scheduleRefineIsCurrentTaskSatisfiedBySlice 判断“当前任务”是否已满足本轮切片目标。 -// -// 步骤化说明: -// 1. 该判断只用于“当前任务自动收口”,不替代全局 hard_check; -// 2. 若切片包含 source_days,则任务离开 source_days 视为关键进展; -// 3. 若切片包含 target_days / exclude_sections / week_filter,则需同时满足; -// 4. 若切片没有任何约束,返回 false,避免误判导致提前结束。 -func scheduleRefineIsCurrentTaskSatisfiedBySlice(entry model.HybridScheduleEntry, slice RefineSlicePlan) bool { - if !scheduleRefineIsMovableSuggestedTask(entry) { - return false - } - weekSet := scheduleRefineIntSliceToWeekSet(slice.WeekFilter) - sourceWeekSet := scheduleRefineInferSourceWeekSet(slice) - sourceSet := scheduleRefineIntSliceToDaySet(slice.SourceDays) - targetSet := scheduleRefineIntSliceToDaySet(slice.TargetDays) - excludedSet := scheduleRefineIntSliceToSectionSet(slice.ExcludeSections) - - hasConstraint := len(sourceWeekSet) > 0 || len(weekSet) > 0 || len(sourceSet) > 0 || len(targetSet) > 0 || len(excludedSet) > 0 - if !hasConstraint { - return false - } - if len(sourceWeekSet) > 0 { - if _, stillInSourceWeek := sourceWeekSet[entry.Week]; stillInSourceWeek { - return false - } - } - if len(weekSet) > 0 { - if _, ok := weekSet[entry.Week]; !ok { - return false - } - } - if len(sourceSet) > 0 { - if _, stillInSource := sourceSet[entry.DayOfWeek]; stillInSource { - return false - } - } - if len(targetSet) > 0 { - if _, ok := targetSet[entry.DayOfWeek]; !ok { - return false - } - } - if len(excludedSet) > 0 && scheduleRefineIntersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSet) { - return false - } - return true -} - -func scheduleRefineTaskProgressLabel(done bool, attemptUsed int, perTaskBudget int) string { - if done { - return "done" - } - if perTaskBudget > 0 && attemptUsed >= perTaskBudget { - return "budget_exhausted" - } - return "paused" -} - -func scheduleRefineBuildMicroReactUserPrompt(st *ScheduleRefineState, current model.HybridScheduleEntry, remainingAction int, remainingTotal int) string { - scheduleRefineEnsureCompositeStateMaps(st) - contractJSON, _ := json.Marshal(st.Contract) - planJSON, _ := json.Marshal(st.CurrentPlan) - sliceJSON, _ := json.Marshal(st.SlicePlan) - objectiveJSON, _ := json.Marshal(st.Objective) - currentJSON, _ := json.Marshal(current) - sourceWeeks := scheduleRefineKeysOfIntSet(scheduleRefineInferSourceWeekSet(st.SlicePlan)) - requiredComposite := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) - requiredSuccess := scheduleRefineIsRequiredCompositeSatisfied(st) - compositeToolsAllowed := !st.DisableCompositeTools - compositeCalledJSON, _ := json.Marshal(st.CompositeToolCalled) - compositeSuccessJSON, _ := json.Marshal(st.CompositeToolSuccess) - envSlotHint := scheduleRefineBuildEnvSlotHint(st, current) - userPrompt := fmt.Sprintf( - "用户本轮请求=%s\n契约=%s\n执行计划=%s\n切片=%s\n目标约束=%s\nCURRENT_TASK=%s\nSOURCE_WEEK_FILTER=%v\nBACKEND_GUARD=本轮只允许改写 task_item_id=%d;若该任务已满足切片目标或目标约束已整体达成且复合工具门禁通过,请直接 done=true;下一任务由后端自动切换。\nREQUIRED_COMPOSITE_TOOL=%s\nCOMPOSITE_TOOLS_ALLOWED=%t\nCOMPOSITE_REQUIRED_SUCCESS=%t\nCOMPOSITE_CALLED=%s\nCOMPOSITE_SUCCESS=%s\nCURRENT_TASK_ACTION_USED=%d\nPER_TASK_BUDGET=%d\n动作预算剩余=%d\n总预算剩余=%d\nENV_SLOT_HINT=%s\nLAST_TOOL_OBSERVATION=%s\nLAST_FAILED_CALL_SIGNATURE=%s\n最近观察=%s\n同周suggested摘要=%s", - strings.TrimSpace(st.UserMessage), - string(contractJSON), - string(planJSON), - string(sliceJSON), - string(objectiveJSON), - string(currentJSON), - sourceWeeks, - current.TaskItemID, - scheduleRefineFallbackText(requiredComposite, "无"), - compositeToolsAllowed, - requiredSuccess, - string(compositeCalledJSON), - string(compositeSuccessJSON), - st.TaskActionUsed[current.TaskItemID], - st.PerTaskBudget, - remainingAction, - remainingTotal, - envSlotHint, - scheduleRefineBuildLastToolObservationPrompt(st.ObservationHistory), - scheduleRefineFallbackText(st.LastFailedCallSignature, "无"), - scheduleRefineBuildObservationPrompt(st.ObservationHistory, 2), - scheduleRefineBuildSuggestedDigestByWeek(st.HybridEntries, current.Week, 24), - ) - return scheduleRefineWithNearestJSONContract(userPrompt, jsonContractForReact) -} - -type scheduleRefineSlotHintPayload struct { - Count int `json:"count"` - StrictCount int `json:"strict_count"` - EmbeddedCount int `json:"embedded_count"` - Slots []struct { - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - } `json:"slots"` -} - -func scheduleRefineBuildEnvSlotHint(st *ScheduleRefineState, current model.HybridScheduleEntry) string { - if st == nil || !scheduleRefineIsMovableSuggestedTask(current) { - return "无可用提示" - } - span := current.SectionTo - current.SectionFrom + 1 - if span <= 0 { - span = 2 - } - targetWeeks := append([]int(nil), st.Objective.TargetWeeks...) - if len(targetWeeks) == 0 { - targetWeeks = scheduleRefineKeysOfIntSet(scheduleRefineInferTargetWeekSet(st.SlicePlan)) - } - if len(targetWeeks) == 0 && current.Week > 0 { - targetWeeks = []int{current.Week} - } - targetDays := append([]int(nil), st.Objective.TargetDays...) - if len(targetDays) == 0 { - targetDays = append([]int(nil), st.SlicePlan.TargetDays...) - } - params := map[string]any{ - "week_filter": targetWeeks, - "day_filter": targetDays, - "section_duration": span, - "limit": 8, - "slot_type": "pure", - "exclude_sections": st.SlicePlan.ExcludeSections, - } - _, pureResult := scheduleRefineToolQueryAvailableSlots(st.HybridEntries, params, scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries)) - if !pureResult.Success { - return fmt.Sprintf("pure_slot_query_failed=%s", scheduleRefineTruncate(pureResult.Result, 100)) - } - purePayload, ok := scheduleRefineDecodeSlotHintPayload(pureResult.Result) - if !ok { - return "pure_slot_parse_failed" - } - - embedParams := map[string]any{ - "week_filter": targetWeeks, - "day_filter": targetDays, - "section_duration": span, - "limit": 8, - "exclude_sections": st.SlicePlan.ExcludeSections, - } - _, fallbackResult := scheduleRefineToolQueryAvailableSlots(st.HybridEntries, embedParams, scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries)) - if !fallbackResult.Success { - return fmt.Sprintf("pure=%d fallback_query_failed=%s", purePayload.Count, scheduleRefineTruncate(fallbackResult.Result, 100)) - } - fallbackPayload, ok := scheduleRefineDecodeSlotHintPayload(fallbackResult.Result) - if !ok { - return fmt.Sprintf("pure=%d fallback_parse_failed", purePayload.Count) - } - - top := fallbackPayload.Slots - if len(top) > 3 { - top = top[:3] - } - slotText := make([]string, 0, len(top)) - for _, item := range top { - slotText = append(slotText, fmt.Sprintf("W%dD%d %d-%d", item.Week, item.DayOfWeek, item.SectionFrom, item.SectionTo)) - } - if len(slotText) == 0 { - slotText = append(slotText, "无") - } - return fmt.Sprintf("target_weeks=%v target_days=%v pure=%d embed_candidate=%d top=%s", targetWeeks, targetDays, purePayload.Count, fallbackPayload.EmbeddedCount, strings.Join(slotText, ",")) -} - -func scheduleRefineDecodeSlotHintPayload(raw string) (scheduleRefineSlotHintPayload, bool) { - var payload scheduleRefineSlotHintPayload - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return scheduleRefineSlotHintPayload{}, false - } - return payload, true -} - -func scheduleRefineExtractWeekFilters(text string) []int { - patterns := []string{ - `第\s*(\d{1,2})\s*周`, - `W\s*(\d{1,2})`, - `(\d{1,2})\s*周`, - } - out := make([]int, 0, 8) - for _, p := range patterns { - re := regexp.MustCompile(p) - for _, m := range re.FindAllStringSubmatch(text, -1) { - if len(m) < 2 { - continue - } - v, err := strconv.Atoi(strings.TrimSpace(m[1])) - if err != nil || v <= 0 { - continue - } - out = append(out, v) - } - } - return scheduleRefineUniquePositiveInts(out) -} - -func scheduleRefineExtractExcludeSections(text string) []int { - normalized := strings.ReplaceAll(strings.ToLower(text), " ", "") - if scheduleRefineContainsAny(normalized, []string{ - "不要早八", "避开早八", "不想早八", "别在早八", - "不要1-2", "避开1-2", "不要第一节", "不要一二节", - }) { - return []int{1, 2} - } - return nil -} - -func scheduleRefineExtractTargetDaysFromMessage(text string) []int { - verbIdx := -1 - for _, key := range []string{"移到", "挪到", "改到", "安排到", "放到", "分散到", "调整到", "收敛到", "迁移到"} { - if idx := strings.Index(text, key); idx >= 0 { - verbIdx = idx + len(key) - break - } - } - if verbIdx < 0 || verbIdx >= len(text) { - return nil - } - targetPart := strings.TrimSpace(text[verbIdx:]) - return scheduleRefineExtractDayExpr(targetPart) -} - -func scheduleRefineExtractSourceDaysFromMessage(text string) []int { - source := make([]int, 0, 4) - re := regexp.MustCompile(`从\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) - for _, m := range re.FindAllStringSubmatch(text, -1) { - if len(m) < 2 { - continue - } - if day := scheduleRefineDayTokenToInt(m[1]); day > 0 { - source = append(source, day) - } - } - re2 := regexp.MustCompile(`把\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) - for _, m := range re2.FindAllStringSubmatch(text, -1) { - if len(m) < 2 { - continue - } - if day := scheduleRefineDayTokenToInt(m[1]); day > 0 { - source = append(source, day) - } - } - return scheduleRefineUniquePositiveInts(source) -} - -func scheduleRefineDetectOverloadedDay(text string) int { - re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天]).{0,8}(太多|过多|太满|过满|拥挤|太挤|塞满)`) - m := re.FindStringSubmatch(text) - if len(m) < 2 { - return 0 - } - return scheduleRefineDayTokenToInt(m[1]) -} - -func scheduleRefineExtractRangeDays(text string) []int { - re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天])\s*[到至\-]\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) - m := re.FindStringSubmatch(text) - if len(m) < 3 { - return nil - } - start := scheduleRefineDayTokenToInt(m[1]) - end := scheduleRefineDayTokenToInt(m[2]) - if start <= 0 || end <= 0 { - return nil - } - if start > end { - start, end = end, start - } - out := make([]int, 0, end-start+1) - for day := start; day <= end; day++ { - out = append(out, day) - } - return out -} - -func scheduleRefineExtractDays(text string) []int { - re := regexp.MustCompile(`周[一二三四五六日天]|星期[一二三四五六日天]`) - matches := re.FindAllString(text, -1) - days := make([]int, 0, len(matches)) - for _, token := range matches { - if day := scheduleRefineDayTokenToInt(token); day > 0 { - days = append(days, day) - } - } - return scheduleRefineUniquePositiveInts(days) -} - -func scheduleRefineDayTokenToInt(token string) int { - switch strings.TrimSpace(token) { - case "周一", "星期一": - return 1 - case "周二", "星期二": - return 2 - case "周三", "星期三": - return 3 - case "周四", "星期四": - return 4 - case "周五", "星期五": - return 5 - case "周六", "星期六": - return 6 - case "周日", "周天", "星期日", "星期天": - return 7 - default: - return 0 - } -} - -func scheduleRefineContainsAny(text string, keys []string) bool { - for _, k := range keys { - if strings.Contains(text, k) { - return true - } - } - return false -} - -func scheduleRefineIsSameDays(days []int, target []int) bool { - if len(days) != len(target) { - return false - } - for i := range days { - if days[i] != target[i] { - return false - } - } - return true -} diff --git a/backend/agent/node/schedule_refine_tool.go b/backend/agent/node/schedule_refine_tool.go deleted file mode 100644 index 5a4509d..0000000 --- a/backend/agent/node/schedule_refine_tool.go +++ /dev/null @@ -1,2027 +0,0 @@ -package agentnode - -import ( - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" - - "github.com/LoveLosita/smartflow/backend/logic" - "github.com/LoveLosita/smartflow/backend/model" -) - -// scheduleRefineReactToolCall 表示模型输出的单个工具调用指令。 -type scheduleRefineReactToolCall struct { - Tool string `json:"tool"` - Params map[string]any `json:"params"` -} - -// scheduleRefineReactToolResult 表示工具调用的结构化执行结果。 -type scheduleRefineReactToolResult struct { - Tool string `json:"tool"` - Success bool `json:"success"` - ErrorCode string `json:"error_code,omitempty"` - Result string `json:"result"` -} - -// scheduleRefineReactLLMOutput 表示“强 ReAct”要求的固定 JSON 输出结构。 -// -// 字段语义: -// 1. goal_check:本轮要先验证的目标点; -// 2. decision:本轮动作选择依据; -// 3. tool_calls:本轮工具动作列表(业务侧只取第一条)。 -type scheduleRefineReactLLMOutput struct { - Done bool `json:"done"` - Summary string `json:"summary"` - GoalCheck string `json:"goal_check"` - Decision string `json:"decision"` - MissingInfo []string `json:"missing_info,omitempty"` - ToolCalls []scheduleRefineReactToolCall `json:"tool_calls"` -} - -// scheduleRefineReviewOutput 表示终审节点要求的固定 JSON 输出结构。 -type scheduleRefineReviewOutput struct { - Pass bool `json:"pass"` - Reason string `json:"reason"` - Unmet []string `json:"unmet"` -} - -// scheduleRefinePlanningWindow 表示微调工具允许活动的 week/day 边界窗口。 -// -// 设计说明: -// 1. 这里用已有 HybridEntries 自动推导窗口,避免把任务移动到完全无关的周; -// 2. 若窗口不可用(没有任何 entry),则降级为“仅做基础合法性校验”。 -type scheduleRefinePlanningWindow struct { - Enabled bool - StartWeek int - StartDay int - EndWeek int - EndDay int -} - -// scheduleRefineToolPolicy 是工具层硬约束策略。 -// -// 职责边界: -// 1. 负责承载“是否强制保持相对顺序”的策略开关; -// 2. 负责承载顺序校验需要的 origin_order 映射; -// 3. 不负责语义判定(语义仍由 LLM 终审节点负责)。 -type scheduleRefineToolPolicy struct { - KeepRelativeOrder bool - OrderScope string - OriginOrderMap map[int]int -} - -// dispatchScheduleRefineTool 负责把模型输出的 tool_call 分发到具体工具实现。 -// -// 步骤化说明: -// 1. 先识别工具名并路由到对应实现; -// 2. 工具实现内部负责参数校验、冲突校验、边界校验、顺序校验; -// 3. 任何失败都返回 Success=false 的结构化结果,而不是直接 panic。 -func dispatchScheduleRefineTool(entries []model.HybridScheduleEntry, call scheduleRefineReactToolCall, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - switch strings.TrimSpace(call.Tool) { - case "QueryTargetTasks": - return scheduleRefineToolQueryTargetTasks(entries, call.Params, policy) - case "QueryAvailableSlots": - return scheduleRefineToolQueryAvailableSlots(entries, call.Params, window) - case "Move": - return scheduleRefineToolMove(entries, call.Params, window, policy) - case "Swap": - return scheduleRefineToolSwap(entries, call.Params, window, policy) - case "BatchMove": - return scheduleRefineToolBatchMove(entries, call.Params, window, policy) - case "SpreadEven": - return scheduleRefineToolSpreadEven(entries, call.Params, window, policy) - case "MinContextSwitch": - return scheduleRefineToolMinContextSwitch(entries, call.Params, window, policy) - case "Verify": - return scheduleRefineToolVerify(entries, call.Params, policy) - default: - return entries, scheduleRefineReactToolResult{ - Tool: strings.TrimSpace(call.Tool), - Success: false, - Result: fmt.Sprintf("不支持的工具:%s(仅允许 QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/SpreadEven/MinContextSwitch/Verify)", strings.TrimSpace(call.Tool)), - } - } -} - -// pickSingleScheduleRefineToolCall 在“单步动作”策略下选取一个工具调用。 -// -// 返回语义: -// 1. call=nil:本轮无可执行动作; -// 2. warn 非空:模型返回了多个调用,本轮只执行第一个并记录告警。 -func pickSingleScheduleRefineToolCall(calls []scheduleRefineReactToolCall) (*scheduleRefineReactToolCall, string) { - if len(calls) == 0 { - return nil, "" - } - call := calls[0] - if len(calls) == 1 { - return &call, "" - } - return &call, fmt.Sprintf("模型返回了 %d 个工具调用,本轮仅执行第一个:%s", len(calls), call.Tool) -} - -// parseScheduleRefineLLMOutput 解析模型输出的 ReAct JSON。 -// -// 容错策略: -// 1. 兼容 ```json 代码块包装; -// 2. 兼容 JSON 前后有解释性文字(提取最外层对象)。 -func parseScheduleRefineLLMOutput(raw string) (*scheduleRefineReactLLMOutput, error) { - clean := strings.TrimSpace(raw) - if clean == "" { - return nil, fmt.Errorf("ReAct 输出为空") - } - if strings.HasPrefix(clean, "```") { - clean = strings.TrimPrefix(clean, "```json") - clean = strings.TrimPrefix(clean, "```") - clean = strings.TrimSuffix(clean, "```") - clean = strings.TrimSpace(clean) - } - - var out scheduleRefineReactLLMOutput - if err := json.Unmarshal([]byte(clean), &out); err == nil { - return &out, nil - } - obj, objErr := scheduleRefineExtractFirstJSONObject(clean) - if objErr != nil { - return nil, fmt.Errorf("无法从输出中提取 JSON:%s", scheduleRefineTruncate(clean, 220)) - } - if err := json.Unmarshal([]byte(obj), &out); err != nil { - return nil, err - } - return &out, nil -} - -// parseScheduleRefineReviewOutput 解析终审评估节点输出。 -func parseScheduleRefineReviewOutput(raw string) (*scheduleRefineReviewOutput, error) { - clean := strings.TrimSpace(raw) - if clean == "" { - return nil, fmt.Errorf("review 输出为空") - } - if strings.HasPrefix(clean, "```") { - clean = strings.TrimPrefix(clean, "```json") - clean = strings.TrimPrefix(clean, "```") - clean = strings.TrimSuffix(clean, "```") - clean = strings.TrimSpace(clean) - } - - var out scheduleRefineReviewOutput - if err := json.Unmarshal([]byte(clean), &out); err == nil { - return &out, nil - } - obj, objErr := scheduleRefineExtractFirstJSONObject(clean) - if objErr != nil { - return nil, fmt.Errorf("无法从 review 输出中提取 JSON:%s", scheduleRefineTruncate(clean, 220)) - } - if err := json.Unmarshal([]byte(obj), &out); err != nil { - return nil, err - } - return &out, nil -} - -// scheduleRefineToolMove 执行“移动一个 suggested 任务到指定时段”。 -// -// 步骤化说明: -// 1. 先校验参数完整性与目标时段合法性,避免写入脏坐标; -// 2. 再校验原任务是否存在、跨度是否一致(防止任务长度被模型改坏); -// 3. 再校验窗口边界与冲突,确保不会穿透到不可用位置; -// 4. 若启用顺序硬约束,再校验“移动后是否打乱原相对顺序”; -// 5. 全部通过后才真正修改 entries 并返回 Success=true。 -func scheduleRefineToolMove(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - // 0. task_id 兼容策略: - // 0.1 标准键是 task_item_id; - // 0.2 为了兼容模型偶发输出别名 task_id,这里做兜底兼容,避免“语义正确但参数名不一致”导致整轮白跑; - // 0.3 两者都不存在时,仍按参数缺失返回失败,由上层 ReAct 继续下一轮决策。 - taskID, ok := scheduleRefineParamIntAny(params, "task_item_id", "task_id") - if !ok { - return entries, scheduleRefineReactToolResult{Tool: "Move", Success: false, Result: "参数缺失:task_item_id"} - } - // 1. 参数兼容策略: - // 1.1 优先读取标准键(to_week/to_day/...); - // 1.2 若模型输出了历史别名(target_xxx/day_of_week 等),也兼容解析; - // 1.3 目标是减少“仅参数名不一致导致的无效失败轮次”。 - toWeek, okWeek := scheduleRefineParamIntAny(params, "to_week", "target_week", "new_week", "week") - toDay, okDay := scheduleRefineParamIntAny(params, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day") - toSF, okSF := scheduleRefineParamIntAny(params, "to_section_from", "target_section_from", "new_section_from", "section_from") - toST, okST := scheduleRefineParamIntAny(params, "to_section_to", "target_section_to", "new_section_to", "section_to") - if !okWeek || !okDay || !okSF || !okST { - return entries, scheduleRefineReactToolResult{ - Tool: "Move", - Success: false, - Result: "参数缺失:需要 to_week/to_day/to_section_from/to_section_to", - } - } - if toDay < 1 || toDay > 7 { - return entries, scheduleRefineReactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 非法,必须在 1~7", toDay)} - } - if toSF < 1 || toST > 12 || toSF > toST { - return entries, scheduleRefineReactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次区间 %d-%d 非法", toSF, toST)} - } - allowEmbed := scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - - idx, locateErr := scheduleRefineFindUniqueSuggestedByID(entries, taskID) - if locateErr != nil { - return entries, scheduleRefineReactToolResult{Tool: "Move", Success: false, Result: locateErr.Error()} - } - origSpan := entries[idx].SectionTo - entries[idx].SectionFrom - newSpan := toST - toSF - if origSpan != newSpan { - return entries, scheduleRefineReactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("任务跨度不一致:原跨度=%d,目标跨度=%d", origSpan+1, newSpan+1), - } - } - - if !scheduleRefineIsWithinWindow(window, toWeek, toDay) { - return entries, scheduleRefineReactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("目标 W%dD%d 超出允许窗口", toWeek, toDay), - } - } - - if conflict, name := scheduleRefineHasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}, allowEmbed); conflict { - return entries, scheduleRefineReactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("目标时段已被 %s 占用", name), - } - } - - beforeEntries := cloneHybridEntries(entries) - entry := &entries[idx] - before := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo) - entry.Week = toWeek - entry.DayOfWeek = toDay - entry.SectionFrom = toSF - entry.SectionTo = toST - after := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo) - - scheduleRefineSortHybridEntries(entries) - if issues := scheduleRefineValidateRelativeOrder(entries, policy); len(issues) > 0 { - return beforeEntries, scheduleRefineReactToolResult{ - Tool: "Move", - Success: false, - Result: "顺序约束不满足:" + strings.Join(issues, ";"), - } - } - - return entries, scheduleRefineReactToolResult{ - Tool: "Move", - Success: true, - Result: fmt.Sprintf("已将任务[%s](id=%d,type=%s,status=%s) 从 %s 移动到 %s", entry.Name, taskID, strings.TrimSpace(entry.Type), strings.TrimSpace(entry.Status), before, after), - } -} - -// scheduleRefineToolSwap 执行“交换两个 suggested 任务的位置”。 -// -// 步骤化说明: -// 1. 先校验两端 task_item_id; -// 2. 再双向验证交换后的落点是否与其他条目冲突; -// 3. 若启用顺序硬约束,再校验“交换后是否打乱相对顺序”; -// 4. 校验通过后提交交换并返回成功。 -func scheduleRefineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - // 1. 参数兼容策略同 Move: - // 1.1 兼容 task_a/task_b 与 task_item_a/task_item_b 等常见别名; - // 1.2 目标是减少模型输出字段差异导致的无效失败。 - idA, okA := scheduleRefineParamIntAny(params, "task_a", "task_item_a", "task_item_id_a") - idB, okB := scheduleRefineParamIntAny(params, "task_b", "task_item_b", "task_item_id_b") - if !okA || !okB { - return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:task_a/task_b"} - } - allowEmbed := scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - if idA == idB { - return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: "task_a 与 task_b 不能相同"} - } - - idxA, errA := scheduleRefineFindUniqueSuggestedByID(entries, idA) - if errA != nil { - return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: errA.Error()} - } - idxB, errB := scheduleRefineFindUniqueSuggestedByID(entries, idB) - if errB != nil { - return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: errB.Error()} - } - - a := entries[idxA] - b := entries[idxB] - if !scheduleRefineIsWithinWindow(window, b.Week, b.DayOfWeek) || !scheduleRefineIsWithinWindow(window, a.Week, a.DayOfWeek) { - return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: "交换目标超出允许窗口"} - } - - excludes := map[int]bool{idxA: true, idxB: true} - if conflict, name := scheduleRefineHasConflict(entries, b.Week, b.DayOfWeek, b.SectionFrom, b.SectionTo, excludes, allowEmbed); conflict { - return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务A交换后将与 %s 冲突", name)} - } - if conflict, name := scheduleRefineHasConflict(entries, a.Week, a.DayOfWeek, a.SectionFrom, a.SectionTo, excludes, allowEmbed); conflict { - return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务B交换后将与 %s 冲突", name)} - } - - beforeEntries := cloneHybridEntries(entries) - entries[idxA].Week, entries[idxB].Week = entries[idxB].Week, entries[idxA].Week - entries[idxA].DayOfWeek, entries[idxB].DayOfWeek = entries[idxB].DayOfWeek, entries[idxA].DayOfWeek - entries[idxA].SectionFrom, entries[idxB].SectionFrom = entries[idxB].SectionFrom, entries[idxA].SectionFrom - entries[idxA].SectionTo, entries[idxB].SectionTo = entries[idxB].SectionTo, entries[idxA].SectionTo - - scheduleRefineSortHybridEntries(entries) - if issues := scheduleRefineValidateRelativeOrder(entries, policy); len(issues) > 0 { - return beforeEntries, scheduleRefineReactToolResult{ - Tool: "Swap", - Success: false, - Result: "顺序约束不满足:" + strings.Join(issues, ";"), - } - } - - return entries, scheduleRefineReactToolResult{ - Tool: "Swap", - Success: true, - Result: fmt.Sprintf("已交换任务 id=%d 与 id=%d 的时段", idA, idB), - } -} - -// scheduleRefineToolBatchMove 执行“原子批量移动 suggested 任务”。 -// -// 步骤化说明: -// 1. 参数要求:params.moves 必须是数组,每个元素都满足 Move 的参数格式; -// 2. 执行策略:在 working 副本上按顺序逐条执行 Move; -// 3. 原子语义:任一步失败,整批回滚(返回原 entries);全部成功才一次性提交; -// 4. 适用场景:用户明确希望“同一轮挪多个任务”,减少 ReAct 往返轮次。 -func scheduleRefineToolBatchMove(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - moveParamsList, parseErr := scheduleRefineParseBatchMoveParams(params) - if parseErr != nil { - return entries, scheduleRefineReactToolResult{ - Tool: "BatchMove", - Success: false, - ErrorCode: "PARAM_MISSING", - Result: parseErr.Error(), - } - } - // 2. 批级 allow_embed 默认值: - // 2.1 如果子动作未显式声明 allow_embed/allow_embedding,则继承批级开关; - // 2.2 默认 true,和 Move/Swap 一致:允许嵌入,但由 QueryAvailableSlots 先给纯空位。 - batchAllowEmbed := scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - for i := range moveParamsList { - if _, ok := moveParamsList[i]["allow_embed"]; ok { - continue - } - if _, ok := moveParamsList[i]["allow_embedding"]; ok { - continue - } - moveParamsList[i]["allow_embed"] = batchAllowEmbed - } - - // 1. 在副本上执行,保证原子性: - // 1.1 每一步都复用 scheduleRefineToolMove 的全部校验逻辑(冲突、窗口、顺序、跨度); - // 1.2 只要任一步失败就中止并回滚到原 entries; - // 1.3 全部成功后再返回 working,作为整批提交结果。 - working := cloneHybridEntries(entries) - stepSummary := make([]string, 0, len(moveParamsList)) - currentWindow := scheduleRefineBuildPlanningWindowFromEntries(working) - if !currentWindow.Enabled { - currentWindow = window - } - for idx, moveParams := range moveParamsList { - nextEntries, stepResult := scheduleRefineToolMove(working, moveParams, currentWindow, policy) - if !stepResult.Success { - return entries, scheduleRefineReactToolResult{ - Tool: "BatchMove", - Success: false, - ErrorCode: scheduleRefineClassifyBatchMoveErrorCode(stepResult.Result), - Result: fmt.Sprintf("BatchMove 第%d步失败:%s", idx+1, stepResult.Result), - } - } - working = nextEntries - currentWindow = scheduleRefineBuildPlanningWindowFromEntries(working) - stepSummary = append(stepSummary, fmt.Sprintf("第%d步:%s", idx+1, scheduleRefineTruncate(stepResult.Result, 120))) - } - - return working, scheduleRefineReactToolResult{ - Tool: "BatchMove", - Success: true, - Result: fmt.Sprintf("BatchMove 原子提交成功,共执行%d步。%s", len(moveParamsList), strings.Join(stepSummary, " | ")), - } -} - -type scheduleRefineCompositePlannerFn func( - tasks []logic.RefineTaskCandidate, - slots []logic.RefineSlotCandidate, - options logic.RefineCompositePlanOptions, -) ([]logic.RefineMovePlanItem, error) - -// scheduleRefineToolSpreadEven 执行“均匀铺开”复合动作。 -// -// 职责边界: -// 1. 负责参数解析、候选收集、调用确定性规划器; -// 2. 不直接改写 entries,统一通过 BatchMove 原子落地; -// 3. 规划算法实现位于 logic 包,工具层只负责编排。 -func scheduleRefineToolSpreadEven(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - return scheduleRefineToolCompositeMove(entries, params, window, policy, "SpreadEven", logic.PlanEvenSpreadMoves) -} - -// scheduleRefineToolMinContextSwitch 执行“最少上下文切换”复合动作。 -// -// 职责边界: -// 1. 负责锁定“当前任务已占坑位集合”,避免为了聚类把任务远距离迁移; -// 2. 负责在固定坑位集合内调用确定性规划器,只重排“任务 -> 坑位”的映射; -// 3. 不直接改写 entries,统一通过 BatchMove 原子落地。 -func scheduleRefineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - taskIDs := scheduleRefineCollectCompositeTaskIDs(params) - if len(taskIDs) == 0 { - return entries, scheduleRefineReactToolResult{ - Tool: "MinContextSwitch", - Success: false, - ErrorCode: "PARAM_MISSING", - Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", - } - } - tasks, taskResult, ok := scheduleRefineCollectCompositeTasks(entries, taskIDs, policy, "MinContextSwitch") - if !ok { - return entries, taskResult - } - - // 1. MinContextSwitch 的产品语义是“尽量少切换,同时尽量少折腾坑位”; - // 2. 因此这里不再查询整周新坑位,而是直接复用当前任务已占据的坑位集合; - // 3. 这样最终只会发生“任务之间互换位置”,不会跳到用户意料之外的远处时段。 - currentSlots := scheduleRefineBuildCompositeCurrentTaskSlots(tasks) - plannedMoves, planErr := logic.PlanMinContextSwitchMoves(tasks, currentSlots, logic.RefineCompositePlanOptions{}) - if planErr != nil { - return entries, scheduleRefineReactToolResult{ - Tool: "MinContextSwitch", - Success: false, - ErrorCode: "PLAN_FAILED", - Result: planErr.Error(), - } - } - return scheduleRefineApplyFixedSlotCompositeMoves(entries, policy, "MinContextSwitch", plannedMoves) -} - -// scheduleRefineToolCompositeMove 是复合动作工具的统一执行框架。 -// -// 步骤化说明: -// 1. 先解析“目标任务集合”,确保任务来源明确且可唯一落到 task_item_id; -// 2. 再按任务跨度查询候选坑位,避免跨度不一致导致执行期失败; -// 3. 调用 logic 包的确定性规划函数,得到 moves; -// 4. 最后复用 BatchMove 原子提交,任一步失败整批回滚。 -func scheduleRefineToolCompositeMove( - entries []model.HybridScheduleEntry, - params map[string]any, - window scheduleRefinePlanningWindow, - policy scheduleRefineToolPolicy, - toolName string, - planner scheduleRefineCompositePlannerFn, -) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - taskIDs := scheduleRefineCollectCompositeTaskIDs(params) - if len(taskIDs) == 0 { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PARAM_MISSING", - Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", - } - } - tasks, taskResult, ok := scheduleRefineCollectCompositeTasks(entries, taskIDs, policy, toolName) - if !ok { - return entries, taskResult - } - idSet := scheduleRefineIntSliceToIDSet(taskIDs) - spanNeed := scheduleRefineBuildCompositeSpanNeed(tasks) - - slots, slotErr := scheduleRefineCollectCompositeSlotsBySpan(entries, params, window, spanNeed) - if slotErr != nil { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "SLOT_QUERY_FAILED", - Result: slotErr.Error(), - } - } - options := logic.RefineCompositePlanOptions{ - ExistingDayLoad: scheduleRefineBuildCompositeDayLoadBaseline(entries, idSet, slots), - } - plannedMoves, planErr := planner(tasks, slots, options) - if planErr != nil { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PLAN_FAILED", - Result: planErr.Error(), - } - } - return scheduleRefineApplyCompositePlannedMoves(entries, params, window, policy, toolName, plannedMoves) -} - -// scheduleRefineCollectCompositeTasks 收集复合动作参与的可移动任务,并做唯一性校验。 -// -// 步骤化说明: -// 1. 只收 suggested 且可移动的 task,避免误改 existing/course; -// 2. task_item_id 必须一一命中,命中多条或缺失都直接失败; -// 3. 输出顺序保持 entries 原始遍历顺序,后续再由规划器做稳定排序。 -func scheduleRefineCollectCompositeTasks(entries []model.HybridScheduleEntry, taskIDs []int, policy scheduleRefineToolPolicy, toolName string) ([]logic.RefineTaskCandidate, scheduleRefineReactToolResult, bool) { - idSet := scheduleRefineIntSliceToIDSet(taskIDs) - tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs)) - found := make(map[int]struct{}, len(taskIDs)) - for _, entry := range entries { - if !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - if _, ok := idSet[entry.TaskItemID]; !ok { - continue - } - if _, duplicated := found[entry.TaskItemID]; duplicated { - return nil, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_ID_AMBIGUOUS", - Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID), - }, false - } - found[entry.TaskItemID] = struct{}{} - tasks = append(tasks, logic.RefineTaskCandidate{ - TaskItemID: entry.TaskItemID, - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - SectionTo: entry.SectionTo, - Name: strings.TrimSpace(entry.Name), - ContextTag: strings.TrimSpace(entry.ContextTag), - OriginRank: policy.OriginOrderMap[entry.TaskItemID], - }) - } - if len(tasks) != len(taskIDs) { - missing := make([]int, 0, len(taskIDs)) - for _, id := range taskIDs { - if _, ok := found[id]; !ok { - missing = append(missing, id) - } - } - return nil, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_NOT_FOUND", - Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing), - }, false - } - return tasks, scheduleRefineReactToolResult{}, true -} - -func scheduleRefineBuildCompositeSpanNeed(tasks []logic.RefineTaskCandidate) map[int]int { - spanNeed := make(map[int]int, len(tasks)) - for _, task := range tasks { - spanNeed[task.SectionTo-task.SectionFrom+1]++ - } - return spanNeed -} - -func scheduleRefineBuildCompositeCurrentTaskSlots(tasks []logic.RefineTaskCandidate) []logic.RefineSlotCandidate { - slots := make([]logic.RefineSlotCandidate, 0, len(tasks)) - for _, task := range tasks { - slots = append(slots, logic.RefineSlotCandidate{ - Week: task.Week, - DayOfWeek: task.DayOfWeek, - SectionFrom: task.SectionFrom, - SectionTo: task.SectionTo, - }) - } - return slots -} - -func scheduleRefineApplyCompositePlannedMoves( - entries []model.HybridScheduleEntry, - params map[string]any, - window scheduleRefinePlanningWindow, - policy scheduleRefineToolPolicy, - toolName string, - plannedMoves []logic.RefineMovePlanItem, -) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - if len(plannedMoves) == 0 { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PLAN_EMPTY", - Result: "规划结果为空:未生成任何可执行移动", - } - } - - moveParams := make([]any, 0, len(plannedMoves)) - for _, move := range plannedMoves { - moveParams = append(moveParams, map[string]any{ - "task_item_id": move.TaskItemID, - "to_week": move.ToWeek, - "to_day": move.ToDay, - "to_section_from": move.ToSectionFrom, - "to_section_to": move.ToSectionTo, - }) - } - batchParams := map[string]any{ - "moves": moveParams, - "allow_embed": scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding"), - } - nextEntries, batchResult := scheduleRefineToolBatchMove(entries, batchParams, window, policy) - if !batchResult.Success { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: batchResult.ErrorCode, - Result: fmt.Sprintf("%s 执行失败:%s", toolName, batchResult.Result), - } - } - return nextEntries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: true, - Result: fmt.Sprintf("%s 执行成功:已规划并提交 %d 条移动。", toolName, len(plannedMoves)), - } -} - -// scheduleRefineApplyFixedSlotCompositeMoves 以“同时改写坐标”的方式提交固定坑位重排结果。 -// -// 步骤化说明: -// 1. 该函数专门服务“坑位集合固定”的复合工具,避免 BatchMove 顺序执行时出现互相占位冲突; -// 2. 先在副本上一次性改写所有目标任务的坐标,再统一排序与校验; -// 3. 若发现目标坑位重复、任务缺失、或顺序约束不满足,则整批失败并回滚。 -func scheduleRefineApplyFixedSlotCompositeMoves( - entries []model.HybridScheduleEntry, - policy scheduleRefineToolPolicy, - toolName string, - plannedMoves []logic.RefineMovePlanItem, -) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - if len(plannedMoves) == 0 { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PLAN_EMPTY", - Result: "规划结果为空:未生成任何可执行移动", - } - } - - working := cloneHybridEntries(entries) - indexByTaskID := make(map[int]int, len(working)) - for idx, entry := range working { - if !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - if _, exists := indexByTaskID[entry.TaskItemID]; exists { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_ID_AMBIGUOUS", - Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 命中多条可移动 suggested 任务", toolName, entry.TaskItemID), - } - } - indexByTaskID[entry.TaskItemID] = idx - } - - targetSeen := make(map[string]int, len(plannedMoves)) - for _, move := range plannedMoves { - if _, ok := indexByTaskID[move.TaskItemID]; !ok { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_NOT_FOUND", - Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 未找到可移动 suggested 任务", toolName, move.TaskItemID), - } - } - key := fmt.Sprintf("%d-%d-%d-%d", move.ToWeek, move.ToDay, move.ToSectionFrom, move.ToSectionTo) - if prevID, exists := targetSeen[key]; exists { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PLAN_CONFLICT", - Result: fmt.Sprintf("%s 执行失败:任务 id=%d 与 id=%d 目标坑位重复", toolName, prevID, move.TaskItemID), - } - } - targetSeen[key] = move.TaskItemID - } - - for _, move := range plannedMoves { - idx := indexByTaskID[move.TaskItemID] - working[idx].Week = move.ToWeek - working[idx].DayOfWeek = move.ToDay - working[idx].SectionFrom = move.ToSectionFrom - working[idx].SectionTo = move.ToSectionTo - } - scheduleRefineSortHybridEntries(working) - if issues := scheduleRefineValidateRelativeOrder(working, policy); len(issues) > 0 { - return entries, scheduleRefineReactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "ORDER_CONSTRAINT_VIOLATED", - Result: "顺序约束不满足:" + strings.Join(issues, ";"), - } - } - return working, scheduleRefineReactToolResult{ - Tool: toolName, - Success: true, - Result: fmt.Sprintf("%s 执行成功:已在固定坑位集合内重排 %d 条任务。", toolName, len(plannedMoves)), - } -} - -func scheduleRefineCollectCompositeTaskIDs(params map[string]any) []int { - ids := scheduleRefineReadIntSlice(params, "task_item_ids", "task_ids") - if id, ok := scheduleRefineParamIntAny(params, "task_item_id", "task_id"); ok { - ids = append(ids, id) - } - return scheduleRefineUniquePositiveInts(ids) -} - -func scheduleRefineCollectCompositeSlotsBySpan( - entries []model.HybridScheduleEntry, - params map[string]any, - window scheduleRefinePlanningWindow, - spanNeed map[int]int, -) ([]logic.RefineSlotCandidate, error) { - if len(spanNeed) == 0 { - return nil, fmt.Errorf("未识别到任务跨度需求") - } - - spans := make([]int, 0, len(spanNeed)) - for span := range spanNeed { - spans = append(spans, span) - } - sort.Ints(spans) - - allSlots := make([]logic.RefineSlotCandidate, 0, 16) - for _, span := range spans { - required := spanNeed[span] - queryParams := scheduleRefineBuildCompositeSlotQueryParams(params, span, required) - _, queryResult := scheduleRefineToolQueryAvailableSlots(entries, queryParams, window) - if !queryResult.Success { - return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, queryResult.Result) - } - - var payload struct { - Slots []struct { - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - } `json:"slots"` - } - if err := json.Unmarshal([]byte(queryResult.Result), &payload); err != nil { - return nil, fmt.Errorf("解析跨度=%d 的空位结果失败:%v", span, err) - } - if len(payload.Slots) < required { - return nil, fmt.Errorf("跨度=%d 可用坑位不足:required=%d, got=%d", span, required, len(payload.Slots)) - } - for _, slot := range payload.Slots { - allSlots = append(allSlots, logic.RefineSlotCandidate{ - Week: slot.Week, - DayOfWeek: slot.DayOfWeek, - SectionFrom: slot.SectionFrom, - SectionTo: slot.SectionTo, - }) - } - } - return allSlots, nil -} - -func scheduleRefineBuildCompositeSlotQueryParams(params map[string]any, span int, required int) map[string]any { - query := make(map[string]any, 12) - query["span"] = span - - // 1. limit 以“任务数 * 兜底系数”估算,给规划器保留可选空间; - // 2. 若调用方显式给了 limit,则采用更大的那个,避免被过小 limit 限死。 - limit := required * 6 - if limit < required { - limit = required - } - if customLimit, ok := scheduleRefineParamIntAny(params, "limit"); ok && customLimit > limit { - limit = customLimit - } - query["limit"] = limit - query["allow_embed"] = scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - - for _, key := range []string{"week", "week_from", "week_to", "day_scope", "after_section", "before_section"} { - if value, ok := params[key]; ok { - query[key] = value - } - } - - // 1. 复合路由主链路自身使用的是 week_filter/day_of_week/exclude_sections; - // 2. 这里必须优先透传这些“规范键”,再兼容历史别名; - // 3. 否则会出现复合工具已被调用,但内部查坑位时丢失目标范围,导致规划结果漂移。 - scheduleRefineCopyIntSliceParam(params, query, "week_filter", "week_filter", "weeks") - scheduleRefineCopyIntSliceParam(params, query, "day_of_week", "day_of_week", "days", "day_filter") - scheduleRefineCopyIntSliceParam(params, query, "exclude_sections", "exclude_sections", "exclude_section") - - // 兼容 Move 风格别名,降低模型参数名漂移导致的失败。 - if week, ok := scheduleRefineParamIntAny(params, "to_week", "target_week", "new_week"); ok { - query["week"] = week - } - if day, ok := scheduleRefineParamIntAny(params, "to_day", "target_day", "target_day_of_week", "new_day", "day"); ok { - query["day_of_week"] = []int{day} - } - return query -} - -func scheduleRefineCopyIntSliceParam(src map[string]any, dst map[string]any, dstKey string, srcKeys ...string) { - values := scheduleRefineReadIntSlice(src, srcKeys...) - if len(values) == 0 { - return - } - normalized := scheduleRefineUniquePositiveInts(values) - if len(normalized) == 0 { - return - } - dst[dstKey] = normalized -} - -func scheduleRefineBuildCompositeDayLoadBaseline( - entries []model.HybridScheduleEntry, - excludeTaskIDs map[int]struct{}, - slots []logic.RefineSlotCandidate, -) map[string]int { - if len(slots) == 0 { - return nil - } - targetDays := make(map[string]struct{}, len(slots)) - for _, slot := range slots { - targetDays[fmt.Sprintf("%d-%d", slot.Week, slot.DayOfWeek)] = struct{}{} - } - - load := make(map[string]int, len(targetDays)) - for _, entry := range entries { - if !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - if _, excluded := excludeTaskIDs[entry.TaskItemID]; excluded { - continue - } - key := fmt.Sprintf("%d-%d", entry.Week, entry.DayOfWeek) - if _, inTarget := targetDays[key]; !inTarget { - continue - } - load[key]++ - } - return load -} - -// scheduleRefineToolQueryTargetTasks 查询“本轮潜在目标任务集合”。 -// -// 步骤化说明: -// 1. 支持按 day_scope(weekend/workday/all)、week 范围、limit 过滤; -// 2. 只读查询,不修改 entries; -// 3. 返回结构化 JSON 字符串,供下一轮模型直接消费。 -func scheduleRefineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[string]any, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - scope := scheduleRefineNormalizeDayScope(scheduleRefineReadString(params, "day_scope", "all")) - statusFilter := scheduleRefineNormalizeStatusFilter(scheduleRefineReadString(params, "status", "suggested")) - weekFilter := scheduleRefineIntSliceToWeekSet(scheduleRefineReadIntSlice(params, "week_filter", "weeks")) - weekFrom, hasWeekFrom := scheduleRefineParamIntAny(params, "week_from", "from_week") - weekTo, hasWeekTo := scheduleRefineParamIntAny(params, "week_to", "to_week") - if week, hasWeek := scheduleRefineParamIntAny(params, "week"); hasWeek { - weekFrom, weekTo = week, week - hasWeekFrom, hasWeekTo = true, true - } - if hasWeekFrom && hasWeekTo && weekFrom > weekTo { - weekFrom, weekTo = weekTo, weekFrom - } - if !hasWeekFrom || !hasWeekTo { - startWeek, endWeek := scheduleRefineInferWeekBounds(entries, scheduleRefinePlanningWindow{Enabled: false}) - if !hasWeekFrom { - weekFrom = startWeek - } - if !hasWeekTo { - weekTo = endWeek - } - } - limit, okLimit := scheduleRefineParamIntAny(params, "limit") - if !okLimit || limit <= 0 { - limit = 16 - } - dayFilter := scheduleRefineIntSliceToDaySet(scheduleRefineReadIntSlice(params, "day_of_week", "days", "day_filter")) - taskIDs := scheduleRefineReadIntSlice(params, "task_item_ids", "task_ids") - if taskID, ok := scheduleRefineParamIntAny(params, "task_item_id", "task_id"); ok { - taskIDs = append(taskIDs, taskID) - } - taskIDSet := scheduleRefineIntSliceToIDSet(taskIDs) - - type targetTask struct { - TaskItemID int `json:"task_item_id"` - Name string `json:"name"` - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - OriginRank int `json:"origin_rank,omitempty"` - ContextTag string `json:"context_tag,omitempty"` - CurrentState string `json:"status"` - } - - list := make([]targetTask, 0, 32) - for _, entry := range entries { - if !scheduleRefineMatchStatusFilter(entry.Status, statusFilter) { - continue - } - // suggested 视图只允许看到“可移动任务”,避免把课程类条目当成可调任务暴露给模型。 - if statusFilter == "suggested" && !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - if entry.TaskItemID <= 0 { - continue - } - if len(taskIDSet) > 0 { - if _, ok := taskIDSet[entry.TaskItemID]; !ok { - continue - } - } - if len(dayFilter) > 0 { - if _, ok := dayFilter[entry.DayOfWeek]; !ok { - continue - } - } else if !scheduleRefineMatchDayScope(entry.DayOfWeek, scope) { - continue - } - if len(weekFilter) > 0 { - if _, ok := weekFilter[entry.Week]; !ok { - continue - } - } - if hasWeekFrom && entry.Week < weekFrom { - continue - } - if hasWeekTo && entry.Week > weekTo { - continue - } - list = append(list, targetTask{ - TaskItemID: entry.TaskItemID, - Name: strings.TrimSpace(entry.Name), - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - SectionTo: entry.SectionTo, - OriginRank: policy.OriginOrderMap[entry.TaskItemID], - ContextTag: strings.TrimSpace(entry.ContextTag), - CurrentState: entry.Status, - }) - } - sort.SliceStable(list, func(i, j int) bool { - if list[i].Week != list[j].Week { - return list[i].Week < list[j].Week - } - if list[i].DayOfWeek != list[j].DayOfWeek { - return list[i].DayOfWeek < list[j].DayOfWeek - } - if list[i].SectionFrom != list[j].SectionFrom { - return list[i].SectionFrom < list[j].SectionFrom - } - return list[i].TaskItemID < list[j].TaskItemID - }) - if len(list) > limit { - list = list[:limit] - } - - payload := map[string]any{ - "tool": "QueryTargetTasks", - "count": len(list), - "status": statusFilter, - "day_scope": scope, - "week_filter": scheduleRefineKeysOfIntSet(weekFilter), - "week_from": weekFrom, - "week_to": weekTo, - "day_of_week": scheduleRefineKeysOfIntSet(dayFilter), - "items": list, - } - raw, err := json.Marshal(payload) - if err != nil { - return entries, scheduleRefineReactToolResult{ - Tool: "QueryTargetTasks", - Success: false, - ErrorCode: "QUERY_ENCODE_FAILED", - Result: fmt.Sprintf("序列化查询结果失败:%v", err), - } - } - return entries, scheduleRefineReactToolResult{ - Tool: "QueryTargetTasks", - Success: true, - Result: string(raw), - } -} - -// scheduleRefineToolQueryAvailableSlots 查询“可放置 suggested 的空位”。 -// -// 步骤化说明: -// 1. 根据 day_scope/week 范围/span/exclude_sections 过滤候选时段; -// 2. 默认先收集“纯空位”,不足 limit 再补“可嵌入课程位”(第二优先级); -// 3. 返回结构化 JSON 字符串,不修改 entries。 -func scheduleRefineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - scope := scheduleRefineNormalizeDayScope(scheduleRefineReadString(params, "day_scope", "all")) - dayFilter := scheduleRefineIntSliceToDaySet(scheduleRefineReadIntSlice(params, "day_of_week", "days", "day_filter")) - weekFilter := scheduleRefineIntSliceToWeekSet(scheduleRefineReadIntSlice(params, "week_filter", "weeks")) - // 1. 空位优先策略: - // 1.1 默认 allow_embed=true,但查询分两阶段执行; - // 1.2 第一阶段只收集“纯空白位”(不与 existing 重叠); - // 1.3 第二阶段仅在空白位不足 limit 时,补充“可嵌入课程位”。 - allowEmbed := scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - // 1.4 兼容 slot_type/slot_types: - // 1.4.1 当明确请求 pure/empty/strict 时,强制只查纯空位(关闭嵌入候选)。 - // 1.4.2 当未声明时,维持“空位优先,空位不足再补嵌入候选”的默认策略。 - slotTypeHints := scheduleRefineReadStringSlice(params, "slot_types") - if single := strings.TrimSpace(scheduleRefineReadString(params, "slot_type", "")); single != "" { - slotTypeHints = append(slotTypeHints, single) - } - for _, hint := range slotTypeHints { - normalized := strings.ToLower(strings.TrimSpace(hint)) - if normalized == "pure" || normalized == "empty" || normalized == "strict" { - allowEmbed = false - break - } - } - span, okSpan := scheduleRefineParamIntAny(params, "span", "section_duration", "task_duration") - if !okSpan || span <= 0 { - span = 2 - } - if span > 12 { - return entries, scheduleRefineReactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "SPAN_INVALID", - Result: fmt.Sprintf("span=%d 非法,必须在 1~12", span), - } - } - limit, okLimit := scheduleRefineParamIntAny(params, "limit") - if !okLimit || limit <= 0 { - limit = 12 - } - - weekFrom, hasWeekFrom := scheduleRefineParamIntAny(params, "week_from", "from_week") - weekTo, hasWeekTo := scheduleRefineParamIntAny(params, "week_to", "to_week") - if week, hasWeek := scheduleRefineParamIntAny(params, "week"); hasWeek { - weekFrom, weekTo = week, week - hasWeekFrom, hasWeekTo = true, true - } - if hasWeekFrom && hasWeekTo && weekFrom > weekTo { - weekFrom, weekTo = weekTo, weekFrom - } - if !hasWeekFrom || !hasWeekTo { - startWeek, endWeek := scheduleRefineInferWeekBounds(entries, window) - if !hasWeekFrom { - weekFrom = startWeek - } - if !hasWeekTo { - weekTo = endWeek - } - } - weeksToIterate := scheduleRefineBuildWeekIterList(weekFilter, weekFrom, weekTo) - if len(weeksToIterate) == 0 { - return entries, scheduleRefineReactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "PARAM_MISSING", - Result: "周范围为空:请提供 week / week_filter 或确保排程窗口有效", - } - } - weekFrom = weeksToIterate[0] - weekTo = weeksToIterate[len(weeksToIterate)-1] - - excludedSet := make(map[int]struct{}) - for _, sec := range scheduleRefineReadIntSlice(params, "exclude_sections", "exclude_section") { - if sec >= 1 && sec <= 12 { - excludedSet[sec] = struct{}{} - } - } - afterSection, hasAfter := scheduleRefineParamIntAny(params, "after_section") - beforeSection, hasBefore := scheduleRefineParamIntAny(params, "before_section") - exactSectionFrom, hasExactFrom := scheduleRefineParamIntAny(params, "section_from", "target_section_from") - exactSectionTo, hasExactTo := scheduleRefineParamIntAny(params, "section_to", "target_section_to") - if hasExactFrom != hasExactTo { - return entries, scheduleRefineReactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "PARAM_MISSING", - Result: "精确节次查询需同时提供 section_from 和 section_to", - } - } - if hasExactFrom { - if exactSectionFrom < 1 || exactSectionTo > 12 || exactSectionFrom > exactSectionTo { - return entries, scheduleRefineReactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "SPAN_INVALID", - Result: fmt.Sprintf("精确节次区间非法:%d-%d", exactSectionFrom, exactSectionTo), - } - } - span = exactSectionTo - exactSectionFrom + 1 - } - - type slot struct { - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - SlotType string `json:"slot_type,omitempty"` - } - slots := make([]slot, 0, limit) - seen := make(map[string]struct{}, limit*2) - strictCount := 0 - collect := func(embedAllowed bool, slotType string) { - if len(slots) >= limit { - return - } - for _, week := range weeksToIterate { - for day := 1; day <= 7; day++ { - if len(dayFilter) > 0 { - if _, ok := dayFilter[day]; !ok { - continue - } - } else if !scheduleRefineMatchDayScope(day, scope) { - continue - } - if !scheduleRefineIsWithinWindow(window, week, day) { - continue - } - for sf := 1; sf+span-1 <= 12; sf++ { - st := sf + span - 1 - if hasExactFrom && (sf != exactSectionFrom || st != exactSectionTo) { - continue - } - if hasAfter && sf <= afterSection { - continue - } - if hasBefore && st >= beforeSection { - continue - } - if scheduleRefineIntersectsExcludedSections(sf, st, excludedSet) { - continue - } - if conflict, _ := scheduleRefineHasConflict(entries, week, day, sf, st, nil, embedAllowed); conflict { - continue - } - key := fmt.Sprintf("%d-%d-%d-%d", week, day, sf, st) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - slots = append(slots, slot{ - Week: week, - DayOfWeek: day, - SectionFrom: sf, - SectionTo: st, - SlotType: slotType, - }) - if len(slots) >= limit { - return - } - } - } - } - } - collect(false, "empty") - strictCount = len(slots) - if allowEmbed && len(slots) < limit { - collect(true, "embedded_candidate") - } - embeddedCount := len(slots) - strictCount - - payload := map[string]any{ - "tool": "QueryAvailableSlots", - "count": len(slots), - "strict_count": strictCount, - "embedded_count": embeddedCount, - "fallback_used": embeddedCount > 0, - "day_scope": scope, - "day_of_week": scheduleRefineKeysOfIntSet(dayFilter), - "week_filter": scheduleRefineKeysOfIntSet(weekFilter), - "week_from": weekFrom, - "week_to": weekTo, - "span": span, - "allow_embed": allowEmbed, - "exclude_sections": scheduleRefineKeysOfIntSet(excludedSet), - "slots": slots, - } - if hasAfter { - payload["after_section"] = afterSection - } - if hasBefore { - payload["before_section"] = beforeSection - } - if hasExactFrom { - payload["section_from"] = exactSectionFrom - payload["section_to"] = exactSectionTo - } - raw, err := json.Marshal(payload) - if err != nil { - return entries, scheduleRefineReactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "QUERY_ENCODE_FAILED", - Result: fmt.Sprintf("序列化空位结果失败:%v", err), - } - } - return entries, scheduleRefineReactToolResult{ - Tool: "QueryAvailableSlots", - Success: true, - Result: string(raw), - } -} - -// scheduleRefineToolVerify 进行“轻量确定性自检”。 -// -// 说明: -// 1. 当前只做 deterministic 校验(冲突/顺序),不做语义 LLM 终审; -// 2. 语义层终审仍在 hard_check 节点统一处理; -// 3. 该工具用于给执行阶段一个“可提前自查”的信号。 -func scheduleRefineToolVerify(entries []model.HybridScheduleEntry, params map[string]any, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { - physicsIssues := scheduleRefinePhysicsCheck(entries, 0) - orderIssues := scheduleRefineValidateRelativeOrder(entries, policy) - if len(physicsIssues) > 0 || len(orderIssues) > 0 { - payload := map[string]any{ - "tool": "Verify", - "pass": false, - "physics_issues": physicsIssues, - "order_issues": orderIssues, - } - raw, err := json.Marshal(payload) - if err != nil { - return entries, scheduleRefineReactToolResult{ - Tool: "Verify", - Success: false, - ErrorCode: "VERIFY_FAILED", - Result: "Verify 校验失败且结果无法序列化", - } - } - return entries, scheduleRefineReactToolResult{ - Tool: "Verify", - Success: false, - ErrorCode: "VERIFY_FAILED", - Result: string(raw), - } - } - - // 1. 若携带 task_item_id / 目标坐标参数,则执行“针对性核验”,避免“全局 pass”掩盖当前任务不匹配。 - // 2. 该核验是可选增强:没传 task_id 时仍维持全局 deterministic 行为。 - taskID, hasTaskID := scheduleRefineParamIntAny(params, "task_item_id", "task_id") - if hasTaskID { - idx, locateErr := scheduleRefineFindUniqueSuggestedByID(entries, taskID) - if locateErr != nil { - return entries, scheduleRefineReactToolResult{ - Tool: "Verify", - Success: false, - ErrorCode: "VERIFY_FAILED", - Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"%s"}`, locateErr.Error()), - } - } - target := entries[idx] - verifyWeek, hasWeek := scheduleRefineParamIntAny(params, "week", "to_week", "target_week") - verifyDay, hasDay := scheduleRefineParamIntAny(params, "day_of_week", "to_day", "target_day_of_week") - verifyFrom, hasFrom := scheduleRefineParamIntAny(params, "section_from", "to_section_from", "target_section_from") - verifyTo, hasTo := scheduleRefineParamIntAny(params, "section_to", "to_section_to", "target_section_to") - - mismatch := make([]string, 0, 4) - if hasWeek && target.Week != verifyWeek { - mismatch = append(mismatch, fmt.Sprintf("week=%d(实际=%d)", verifyWeek, target.Week)) - } - if hasDay && target.DayOfWeek != verifyDay { - mismatch = append(mismatch, fmt.Sprintf("day_of_week=%d(实际=%d)", verifyDay, target.DayOfWeek)) - } - if hasFrom && target.SectionFrom != verifyFrom { - mismatch = append(mismatch, fmt.Sprintf("section_from=%d(实际=%d)", verifyFrom, target.SectionFrom)) - } - if hasTo && target.SectionTo != verifyTo { - mismatch = append(mismatch, fmt.Sprintf("section_to=%d(实际=%d)", verifyTo, target.SectionTo)) - } - if len(mismatch) > 0 { - return entries, scheduleRefineReactToolResult{ - Tool: "Verify", - Success: false, - ErrorCode: "VERIFY_FAILED", - Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"任务坐标不匹配:%s"}`, strings.Join(mismatch, ";")), - } - } - return entries, scheduleRefineReactToolResult{ - Tool: "Verify", - Success: true, - Result: `{"tool":"Verify","pass":true,"reason":"task-level deterministic checks passed"}`, - } - } - - return entries, scheduleRefineReactToolResult{ - Tool: "Verify", - Success: true, - Result: `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`, - } -} - -// scheduleRefineValidateRelativeOrder 校验 suggested 任务是否保持“初始相对顺序”。 -// -// 步骤化说明: -// 1. 若策略未启用 keep_relative_order,直接通过; -// 2. 否则按时间位置排序 suggested 任务,并映射到 origin_rank; -// 3. 检查 rank 是否单调不降;一旦逆序即判定失败; -// 4. 支持 week 作用域:仅要求每周内保持相对顺序。 -func scheduleRefineValidateRelativeOrder(entries []model.HybridScheduleEntry, policy scheduleRefineToolPolicy) []string { - if !policy.KeepRelativeOrder { - return nil - } - if len(policy.OriginOrderMap) == 0 { - return []string{"未提供顺序基线(origin_order_map)"} - } - - suggested := make([]model.HybridScheduleEntry, 0, len(entries)) - for _, entry := range entries { - // 1. 顺序校验与执行口径必须一致: - // 1.1 这里只校验“可移动 suggested 任务”,避免把 course 等不可移动条目误纳入顺序约束; - // 1.2 若把不可移动条目纳入,会出现“动作层不允许改、顺序层却报错”的左右脑互搏。 - if scheduleRefineIsMovableSuggestedTask(entry) { - suggested = append(suggested, entry) - } - } - if len(suggested) <= 1 { - return nil - } - sort.SliceStable(suggested, func(i, j int) bool { - left := suggested[i] - right := suggested[j] - if left.Week != right.Week { - return left.Week < right.Week - } - if left.DayOfWeek != right.DayOfWeek { - return left.DayOfWeek < right.DayOfWeek - } - if left.SectionFrom != right.SectionFrom { - return left.SectionFrom < right.SectionFrom - } - if left.SectionTo != right.SectionTo { - return left.SectionTo < right.SectionTo - } - return left.TaskItemID < right.TaskItemID - }) - - scope := scheduleRefineNormalizeOrderScope(policy.OrderScope) - issues := make([]string, 0, 4) - if scope == "week" { - lastRankByWeek := make(map[int]int) - lastNameByWeek := make(map[int]string) - lastIDByWeek := make(map[int]int) - for _, entry := range suggested { - rank, ok := policy.OriginOrderMap[entry.TaskItemID] - if !ok { - issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID)) - continue - } - last, exists := lastRankByWeek[entry.Week] - if exists && rank < last { - issues = append(issues, fmt.Sprintf( - "W%d 出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后", - entry.Week, entry.Name, entry.TaskItemID, rank, lastNameByWeek[entry.Week], lastIDByWeek[entry.Week], last, - )) - } - lastRankByWeek[entry.Week] = rank - lastNameByWeek[entry.Week] = entry.Name - lastIDByWeek[entry.Week] = entry.TaskItemID - } - return issues - } - - lastRank := -1 - lastName := "" - lastID := 0 - for _, entry := range suggested { - rank, ok := policy.OriginOrderMap[entry.TaskItemID] - if !ok { - issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID)) - continue - } - if lastRank >= 0 && rank < lastRank { - issues = append(issues, fmt.Sprintf( - "出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后", - entry.Name, entry.TaskItemID, rank, lastName, lastID, lastRank, - )) - } - lastRank = rank - lastName = entry.Name - lastID = entry.TaskItemID - } - return issues -} - -// scheduleRefineNormalizeOrderScope 规范化顺序约束作用域。 -func scheduleRefineNormalizeOrderScope(scope string) string { - switch strings.TrimSpace(strings.ToLower(scope)) { - case "week": - return "week" - default: - return "global" - } -} - -// scheduleRefineBuildPlanningWindowFromEntries 根据现有条目推导允许移动窗口。 -func scheduleRefineBuildPlanningWindowFromEntries(entries []model.HybridScheduleEntry) scheduleRefinePlanningWindow { - if len(entries) == 0 { - return scheduleRefinePlanningWindow{Enabled: false} - } - startWeek, startDay := entries[0].Week, entries[0].DayOfWeek - endWeek, endDay := entries[0].Week, entries[0].DayOfWeek - for _, entry := range entries { - if scheduleRefineCompareWeekDay(entry.Week, entry.DayOfWeek, startWeek, startDay) < 0 { - startWeek, startDay = entry.Week, entry.DayOfWeek - } - if scheduleRefineCompareWeekDay(entry.Week, entry.DayOfWeek, endWeek, endDay) > 0 { - endWeek, endDay = entry.Week, entry.DayOfWeek - } - } - return scheduleRefinePlanningWindow{ - Enabled: true, - StartWeek: startWeek, - StartDay: startDay, - EndWeek: endWeek, - EndDay: endDay, - } -} - -// scheduleRefineIsWithinWindow 判断目标 week/day 是否落在窗口内。 -func scheduleRefineIsWithinWindow(window scheduleRefinePlanningWindow, week, day int) bool { - if !window.Enabled { - return true - } - if day < 1 || day > 7 { - return false - } - if scheduleRefineCompareWeekDay(week, day, window.StartWeek, window.StartDay) < 0 { - return false - } - if scheduleRefineCompareWeekDay(week, day, window.EndWeek, window.EndDay) > 0 { - return false - } - return true -} - -// scheduleRefineCompareWeekDay 比较两个 week/day 坐标。 -// 返回: -// 1) <0:left 更早; -// 2) =0:相同; -// 3) >0:left 更晚。 -func scheduleRefineCompareWeekDay(leftWeek, leftDay, rightWeek, rightDay int) int { - if leftWeek != rightWeek { - return leftWeek - rightWeek - } - return leftDay - rightDay -} - -// scheduleRefineFindSuggestedByID 在 entries 中查找指定 task_item_id 的 suggested 条目索引。 -func scheduleRefineFindSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int { - for i, entry := range entries { - if scheduleRefineIsMovableSuggestedTask(entry) && entry.TaskItemID == taskItemID { - return i - } - } - return -1 -} - -// scheduleRefineFindUniqueSuggestedByID 查找可唯一定位的可移动 suggested 任务。 -// -// 说明: -// 1. “可移动”定义由 scheduleRefineIsMovableSuggestedTask 统一控制; -// 2. 当 task_item_id 命中 0 条或 >1 条时都返回错误,避免把动作落到错误任务上。 -func scheduleRefineFindUniqueSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) (int, error) { - first := -1 - count := 0 - for idx, entry := range entries { - if !scheduleRefineIsMovableSuggestedTask(entry) { - continue - } - if entry.TaskItemID != taskItemID { - continue - } - if first < 0 { - first = idx - } - count++ - } - if count == 0 { - return -1, fmt.Errorf("未找到 task_item_id=%d 的可移动 suggested 任务", taskItemID) - } - if count > 1 { - return -1, fmt.Errorf("task_item_id=%d 命中 %d 条可移动 suggested 任务,无法唯一定位", taskItemID, count) - } - return first, nil -} - -// scheduleRefineIsMovableSuggestedTask 判断条目是否属于“可被微调工具改写”的任务。 -// -// 规则: -// 1. 必须是 suggested 且 task_item_id>0; -// 2. type=course 明确禁止移动(即便被错误标记为 suggested); -// 3. 其余类型(含空值)按任务处理,兼容历史快照。 -func scheduleRefineIsMovableSuggestedTask(entry model.HybridScheduleEntry) bool { - if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 { - return false - } - if strings.EqualFold(strings.TrimSpace(entry.Type), "course") { - return false - } - return true -} - -// scheduleRefineHasConflict 检查目标时段是否与其他条目冲突。 -// -// 判断规则: -// 1. 仅把“会阻塞 suggested 的条目”纳入冲突判断; -// 2. excludes 中的索引会被跳过(常用于 Move 自身排除或 Swap 双排除)。 -func scheduleRefineHasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, excludes map[int]bool, allowEmbed bool) (bool, string) { - for idx, entry := range entries { - if excludes != nil && excludes[idx] { - continue - } - if !scheduleRefineEntryBlocksSuggestedWithPolicy(entry, allowEmbed) { - continue - } - if entry.Week == week && entry.DayOfWeek == day && scheduleRefineSectionsOverlap(entry.SectionFrom, entry.SectionTo, sf, st) { - return true, fmt.Sprintf("%s(%s)", entry.Name, entry.Type) - } - } - return false, "" -} - -// scheduleRefineEntryBlocksSuggested 判断条目是否会阻塞 suggested 任务落位。 -func scheduleRefineEntryBlocksSuggested(entry model.HybridScheduleEntry) bool { - return scheduleRefineEntryBlocksSuggestedWithPolicy(entry, true) -} - -// scheduleRefineEntryBlocksSuggestedWithPolicy 判断条目是否阻塞 suggested 落位。 -// -// 策略说明: -// 1. allowEmbed=true:沿用 block_for_suggested 语义; -// 2. allowEmbed=false:existing 一律阻塞,只允许纯空白课位; -// 3. unknown status 保守阻塞,防止漏检。 -func scheduleRefineEntryBlocksSuggestedWithPolicy(entry model.HybridScheduleEntry, allowEmbed bool) bool { - if entry.Status == "suggested" { - return true - } - if entry.Status == "existing" { - if !allowEmbed { - return true - } - return entry.BlockForSuggested - } - // 未知状态保守处理为阻塞,避免写入潜在冲突。 - return true -} - -// scheduleRefineSectionsOverlap 判断两个节次区间是否有交叠。 -func scheduleRefineSectionsOverlap(aFrom, aTo, bFrom, bTo int) bool { - return aFrom <= bTo && bFrom <= aTo -} - -// scheduleRefineParamInt 从 map 中提取 int 参数,兼容 JSON 常见数值类型。 -func scheduleRefineParamInt(params map[string]any, key string) (int, bool) { - raw, ok := params[key] - if !ok { - return 0, false - } - switch v := raw.(type) { - case int: - return v, true - case float64: - return int(v), true - case string: - n, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil { - return 0, false - } - return n, true - default: - return 0, false - } -} - -// scheduleRefineParamIntAny 按“候选键优先级”提取 int 参数。 -// -// 步骤化说明: -// 1. 按传入顺序依次尝试每个 key; -// 2. 命中第一个合法值即返回; -// 3. 全部未命中则返回 false,由上层统一抛参数缺失错误。 -func scheduleRefineParamIntAny(params map[string]any, keys ...string) (int, bool) { - for _, key := range keys { - if v, ok := scheduleRefineParamInt(params, key); ok { - return v, true - } - } - return 0, false -} - -// scheduleRefineParamBool 从 map 中提取 bool 参数,兼容 JSON 常见布尔表示。 -func scheduleRefineParamBool(params map[string]any, key string) (bool, bool) { - raw, ok := params[key] - if !ok { - return false, false - } - switch v := raw.(type) { - case bool: - return v, true - case string: - text := strings.TrimSpace(strings.ToLower(v)) - switch text { - case "true", "1", "yes", "y": - return true, true - case "false", "0", "no", "n": - return false, true - default: - return false, false - } - case int: - if v == 1 { - return true, true - } - if v == 0 { - return false, true - } - return false, false - case float64: - if v == 1 { - return true, true - } - if v == 0 { - return false, true - } - return false, false - default: - return false, false - } -} - -// scheduleRefineParamBoolAnyWithDefault 按候选键提取 bool,未命中时返回 fallback。 -func scheduleRefineParamBoolAnyWithDefault(params map[string]any, fallback bool, keys ...string) bool { - for _, key := range keys { - if v, ok := scheduleRefineParamBool(params, key); ok { - return v - } - } - return fallback -} - -// scheduleRefineReadString 读取字符串参数,缺失时返回默认值。 -func scheduleRefineReadString(params map[string]any, key string, fallback string) string { - raw, ok := params[key] - if !ok { - return fallback - } - text := strings.TrimSpace(fmt.Sprintf("%v", raw)) - if text == "" { - return fallback - } - return text -} - -// scheduleRefineNormalizeDayScope 规范化 day_scope 取值。 -func scheduleRefineNormalizeDayScope(scope string) string { - switch strings.ToLower(strings.TrimSpace(scope)) { - case "weekend": - return "weekend" - case "workday": - return "workday" - default: - return "all" - } -} - -// scheduleRefineNormalizeStatusFilter 规范化 status 过滤条件。 -func scheduleRefineNormalizeStatusFilter(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case "existing": - return "existing" - case "all": - return "all" - default: - return "suggested" - } -} - -// scheduleRefineMatchStatusFilter 判断条目状态是否命中 status 过滤。 -func scheduleRefineMatchStatusFilter(entryStatus string, statusFilter string) bool { - switch strings.ToLower(strings.TrimSpace(statusFilter)) { - case "all": - return true - case "existing": - return strings.TrimSpace(entryStatus) == "existing" - default: - return strings.TrimSpace(entryStatus) == "suggested" - } -} - -// scheduleRefineMatchDayScope 判断 day_of_week 是否满足 scope 过滤条件。 -func scheduleRefineMatchDayScope(day int, scope string) bool { - switch scope { - case "weekend": - return day == 6 || day == 7 - case "workday": - return day >= 1 && day <= 5 - default: - return day >= 1 && day <= 7 - } -} - -// scheduleRefineIntSliceToDaySet 把 day 切片转换为 set,并去除非法 day 值。 -func scheduleRefineIntSliceToDaySet(items []int) map[int]struct{} { - if len(items) == 0 { - return nil - } - set := make(map[int]struct{}, len(items)) - for _, item := range items { - if item < 1 || item > 7 { - continue - } - set[item] = struct{}{} - } - if len(set) == 0 { - return nil - } - return set -} - -// scheduleRefineIntSliceToWeekSet 把周次切片转换为 set,并去除非正数。 -func scheduleRefineIntSliceToWeekSet(items []int) map[int]struct{} { - if len(items) == 0 { - return nil - } - set := make(map[int]struct{}, len(items)) - for _, item := range items { - if item <= 0 { - continue - } - set[item] = struct{}{} - } - if len(set) == 0 { - return nil - } - return set -} - -// scheduleRefineIntSliceToSectionSet 把节次切片转换为 set,并去除非法节次。 -func scheduleRefineIntSliceToSectionSet(items []int) map[int]struct{} { - if len(items) == 0 { - return nil - } - set := make(map[int]struct{}, len(items)) - for _, item := range items { - if item < 1 || item > 12 { - continue - } - set[item] = struct{}{} - } - if len(set) == 0 { - return nil - } - return set -} - -// scheduleRefineIntSliceToIDSet 把正整数 ID 切片转换为 set。 -func scheduleRefineIntSliceToIDSet(items []int) map[int]struct{} { - if len(items) == 0 { - return nil - } - set := make(map[int]struct{}, len(items)) - for _, item := range items { - if item <= 0 { - continue - } - set[item] = struct{}{} - } - if len(set) == 0 { - return nil - } - return set -} - -// scheduleRefineInferWeekBounds 推断查询周区间。 -func scheduleRefineInferWeekBounds(entries []model.HybridScheduleEntry, window scheduleRefinePlanningWindow) (int, int) { - if window.Enabled { - return window.StartWeek, window.EndWeek - } - if len(entries) == 0 { - return 1, 1 - } - minWeek, maxWeek := entries[0].Week, entries[0].Week - for _, entry := range entries { - if entry.Week < minWeek { - minWeek = entry.Week - } - if entry.Week > maxWeek { - maxWeek = entry.Week - } - } - return minWeek, maxWeek -} - -// scheduleRefineBuildWeekIterList 构建周次迭代列表。 -// -// 规则: -// 1. weekFilter 非空时,严格按过滤集合遍历; -// 2. weekFilter 为空时,按 weekFrom~weekTo 连续区间遍历; -// 3. 返回结果升序,便于日志与排查。 -func scheduleRefineBuildWeekIterList(weekFilter map[int]struct{}, weekFrom, weekTo int) []int { - if len(weekFilter) > 0 { - return scheduleRefineKeysOfIntSet(weekFilter) - } - if weekFrom <= 0 || weekTo <= 0 || weekFrom > weekTo { - return nil - } - out := make([]int, 0, weekTo-weekFrom+1) - for w := weekFrom; w <= weekTo; w++ { - out = append(out, w) - } - return out -} - -// scheduleRefineReadIntSlice 读取 int 切片参数,兼容 []any / []int / 单个数值。 -func scheduleRefineReadIntSlice(params map[string]any, keys ...string) []int { - for _, key := range keys { - raw, ok := params[key] - if !ok { - continue - } - switch v := raw.(type) { - case []int: - out := make([]int, len(v)) - copy(out, v) - return out - case []any: - out := make([]int, 0, len(v)) - for _, item := range v { - switch n := item.(type) { - case int: - out = append(out, n) - case float64: - out = append(out, int(n)) - case string: - if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil { - out = append(out, parsed) - } - } - } - return out - default: - if n, okNum := scheduleRefineParamInt(params, key); okNum { - return []int{n} - } - } - } - return nil -} - -// scheduleRefineReadStringSlice 读取 string 切片参数,兼容 []any / []string / 单个字符串。 -func scheduleRefineReadStringSlice(params map[string]any, keys ...string) []string { - for _, key := range keys { - raw, ok := params[key] - if !ok || raw == nil { - continue - } - switch vv := raw.(type) { - case []string: - out := make([]string, 0, len(vv)) - for _, item := range vv { - text := strings.TrimSpace(item) - if text != "" { - out = append(out, text) - } - } - return out - case []any: - out := make([]string, 0, len(vv)) - for _, item := range vv { - text := strings.TrimSpace(fmt.Sprintf("%v", item)) - if text != "" { - out = append(out, text) - } - } - return out - case string: - text := strings.TrimSpace(vv) - if text != "" { - return []string{text} - } - default: - text := strings.TrimSpace(fmt.Sprintf("%v", vv)) - if text != "" { - return []string{text} - } - } - } - return nil -} - -// scheduleRefineIntersectsExcludedSections 判断候选区间是否与排除节次有交集。 -func scheduleRefineIntersectsExcludedSections(from, to int, excluded map[int]struct{}) bool { - if len(excluded) == 0 { - return false - } - for sec := from; sec <= to; sec++ { - if _, ok := excluded[sec]; ok { - return true - } - } - return false -} - -// scheduleRefineKeysOfIntSet 返回 int set 的有序键。 -func scheduleRefineKeysOfIntSet(set map[int]struct{}) []int { - if len(set) == 0 { - return nil - } - keys := make([]int, 0, len(set)) - for k := range set { - keys = append(keys, k) - } - sort.Ints(keys) - return keys -} - -// scheduleRefineParseBatchMoveParams 解析 BatchMove 的 moves 参数。 -// -// 步骤化说明: -// 1. 先读取 params["moves"],必须存在且为非空数组; -// 2. 再把数组元素逐条转换成 map[string]any,便于复用 scheduleRefineToolMove; -// 3. 任一元素类型非法即整体失败,避免“部分可执行、部分不可执行”带来的语义歧义。 -func scheduleRefineParseBatchMoveParams(params map[string]any) ([]map[string]any, error) { - rawMoves, ok := params["moves"] - if !ok { - return nil, fmt.Errorf("参数缺失:BatchMove 需要 moves 数组") - } - - var items []any - switch v := rawMoves.(type) { - case []any: - items = v - case []map[string]any: - items = make([]any, 0, len(v)) - for _, item := range v { - items = append(items, item) - } - default: - return nil, fmt.Errorf("参数类型错误:BatchMove 的 moves 必须是数组") - } - if len(items) == 0 { - return nil, fmt.Errorf("参数错误:BatchMove 的 moves 不能为空") - } - - moveParamsList := make([]map[string]any, 0, len(items)) - for idx, item := range items { - paramMap, ok := item.(map[string]any) - if !ok { - return nil, fmt.Errorf("参数类型错误:BatchMove 第%d步不是对象", idx+1) - } - moveParamsList = append(moveParamsList, paramMap) - } - return moveParamsList, nil -} - -// scheduleRefineClassifyBatchMoveErrorCode 把单步 Move 失败原因映射为 BatchMove 层错误码。 -// -// 说明: -// 1. 映射保持与普通 Move 的错误语义一致,便于模型统一处理; -// 2. 这里按失败文案做轻量推断,避免引入跨文件循环依赖。 -func scheduleRefineClassifyBatchMoveErrorCode(detail string) string { - text := strings.TrimSpace(detail) - switch { - case strings.Contains(text, "顺序约束不满足"): - return "ORDER_VIOLATION" - case strings.Contains(text, "参数缺失"): - return "PARAM_MISSING" - case strings.Contains(text, "目标时段已被"): - return "SLOT_CONFLICT" - case strings.Contains(text, "任务跨度不一致"): - return "SPAN_MISMATCH" - case strings.Contains(text, "超出允许窗口"): - return "OUT_OF_WINDOW" - case strings.Contains(text, "day_of_week"): - return "DAY_INVALID" - case strings.Contains(text, "节次区间"): - return "SECTION_INVALID" - case strings.Contains(text, "未找到 task_item_id"): - return "TASK_NOT_FOUND" - default: - return "BATCH_MOVE_FAILED" - } -} - -// scheduleRefineSortHybridEntries 对混合条目做稳定排序,保证日志与预览输出稳定。 -func scheduleRefineSortHybridEntries(entries []model.HybridScheduleEntry) { - sort.SliceStable(entries, func(i, j int) bool { - left := entries[i] - right := entries[j] - if left.Week != right.Week { - return left.Week < right.Week - } - if left.DayOfWeek != right.DayOfWeek { - return left.DayOfWeek < right.DayOfWeek - } - if left.SectionFrom != right.SectionFrom { - return left.SectionFrom < right.SectionFrom - } - if left.SectionTo != right.SectionTo { - return left.SectionTo < right.SectionTo - } - return left.Name < right.Name - }) -} - -// scheduleRefineTruncate 截断日志内容,避免错误信息无上限增长。 -func scheduleRefineTruncate(text string, maxLen int) string { - if maxLen <= 0 { - return "" - } - runes := []rune(text) - if len(runes) <= maxLen { - return text - } - return string(runes[:maxLen]) + "..." -} diff --git a/backend/agent/node/taskquery.go b/backend/agent/node/taskquery.go deleted file mode 100644 index bb5ad70..0000000 --- a/backend/agent/node/taskquery.go +++ /dev/null @@ -1,729 +0,0 @@ -package agentnode - -import ( - "context" - "encoding/json" - "fmt" - "regexp" - "sort" - "strconv" - "strings" - "time" - - agentllm "github.com/LoveLosita/smartflow/backend/agent/llm" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt" - agentstream "github.com/LoveLosita/smartflow/backend/agent/stream" - "github.com/cloudwego/eino-ext/components/model/ark" - "github.com/cloudwego/eino/components/tool" - "github.com/cloudwego/eino/compose" -) - -const ( - TaskQueryGraphNodePlan = "task_query.plan" - TaskQueryGraphNodeQuadrant = "task_query.quadrant" - TaskQueryGraphNodeTimeAnchor = "task_query.time_anchor" - TaskQueryGraphNodeQuery = "task_query.query" - TaskQueryGraphNodeReflect = "task_query.reflect" -) - -var ( - explicitLimitPatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)\btop\s*(\d{1,2})\b`), - regexp.MustCompile(`前\s*(\d{1,2})\s*(个|条|项)?`), - regexp.MustCompile(`(\d{1,2})\s*(个|条|项)?\s*任务`), - regexp.MustCompile(`给我\s*(\d{1,2})\s*(个|条|项)?`), - } - chineseDigitMap = map[rune]int{ - '一': 1, - '二': 2, - '两': 2, - '三': 3, - '四': 4, - '五': 5, - '六': 6, - '七': 7, - '八': 8, - '九': 9, - '十': 10, - } -) - -// TaskQueryGraphRunInput 描述一次任务查询图运行需要的依赖。 -type TaskQueryGraphRunInput struct { - Model *ark.ChatModel - State *agentmodel.TaskQueryState - Deps TaskQueryToolDeps - EmitStage func(stage, detail string) -} - -// TaskQueryNodes 是任务查询图的节点容器。 -// -// 职责边界: -// 1. 负责承接请求级依赖,并向 graph 暴露可直接挂载的方法。 -// 2. 不负责 graph 编译、service 接线和持久化。 -type TaskQueryNodes struct { - input TaskQueryGraphRunInput - queryTool tool.InvokableTool - emitStage agentstream.StageEmitter -} - -func NewTaskQueryNodes(input TaskQueryGraphRunInput, queryTool tool.InvokableTool) (*TaskQueryNodes, error) { - if input.Model == nil { - return nil, fmt.Errorf("task query nodes: model is nil") - } - if input.State == nil { - return nil, fmt.Errorf("task query nodes: state is nil") - } - if err := input.Deps.Validate(); err != nil { - return nil, err - } - if queryTool == nil { - return nil, fmt.Errorf("task query nodes: queryTool is nil") - } - return &TaskQueryNodes{ - input: input, - queryTool: queryTool, - emitStage: agentstream.WrapStageEmitter(input.EmitStage), - }, nil -} - -// Plan 负责把用户原话规划成结构化查询计划。 -func (n *TaskQueryNodes) Plan(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { - if st == nil { - return nil, fmt.Errorf("task query graph: nil state in plan node") - } - - n.emitStage("task_query.plan.generating", "正在一次性规划查询范围、排序和时间条件。") - planned, err := agentllm.PlanTaskQuery(ctx, n.input.Model, st.RequestNowText, st.UserMessage) - if err != nil || planned == nil { - st.UserGoal = "查询任务" - st.Plan = defaultTaskQueryPlan() - return st, nil - } - - st.UserGoal = strings.TrimSpace(planned.UserGoal) - if st.UserGoal == "" { - st.UserGoal = "查询任务" - } - st.Plan = normalizeTaskQueryPlan(*planned) - - // 1. 若用户原话里明确指定了返回条数,则以后端识别结果为准。 - // 2. 这样可以避免规划模型漏掉数量要求,或后续反思 patch 意外改写 limit。 - if explicitLimit, found := extractExplicitLimitFromUser(st.UserMessage); found { - st.ExplicitLimit = explicitLimit - st.Plan.Limit = explicitLimit - } - return st, nil -} - -// NormalizeQuadrant 负责把象限参数去重并统一成稳定顺序。 -func (n *TaskQueryNodes) NormalizeQuadrant(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { - _ = ctx - if st == nil { - return nil, fmt.Errorf("task query graph: nil state in quadrant node") - } - - n.emitStage("task_query.quadrant.routing", "正在归一化象限筛选范围。") - st.Plan.Quadrants = normalizeQuadrants(st.Plan.Quadrants) - return st, nil -} - -// AnchorTime 负责把时间文本边界解析成可执行时间对象。 -func (n *TaskQueryNodes) AnchorTime(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { - _ = ctx - if st == nil { - return nil, fmt.Errorf("task query graph: nil state in time anchor node") - } - - n.emitStage("task_query.time.anchoring", "正在锁定时间过滤边界。") - applyTimeAnchorOnPlan(&st.Plan) - return st, nil -} - -// Query 负责真正调用工具查询任务。 -func (n *TaskQueryNodes) Query(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { - if st == nil { - return nil, fmt.Errorf("task query graph: nil state in query node") - } - - n.emitStage("task_query.tool.querying", "正在查询任务数据。") - items, err := n.executePlanByTool(ctx, st.Plan) - if err != nil { - st.LastQueryItems = make([]agentmodel.TaskQueryItem, 0) - st.LastQueryTotal = 0 - st.ReflectReason = "查询工具执行失败" - return st, nil - } - - st.LastQueryItems = items - st.LastQueryTotal = len(items) - - // 1. 如果首轮为空且还没自动放宽过,则做一次可解释的自动放宽。 - // 2. 放宽范围仅限关键词、完成状态、时间边界,不主动改象限与 limit,避免语义漂移。 - if st.LastQueryTotal == 0 && !st.AutoBroadenApplied { - broadenedPlan, changed := autoBroadenPlan(st.Plan) - if changed { - st.AutoBroadenApplied = true - st.Plan = broadenedPlan - n.emitStage("task_query.tool.broadened", "首次查询为空,已自动放宽条件再试一次。") - retryItems, retryErr := n.executePlanByTool(ctx, st.Plan) - if retryErr == nil { - st.LastQueryItems = retryItems - st.LastQueryTotal = len(retryItems) - } - } - } - - return st, nil -} - -// Reflect 负责判断当前结果是否满足用户诉求,并决定是否重试。 -func (n *TaskQueryNodes) Reflect(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) { - if st == nil { - return nil, fmt.Errorf("task query graph: nil state in reflect node") - } - - n.emitStage("task_query.reflecting", "正在判断结果是否贴合你的需求。") - reflectPrompt := agentprompt.BuildTaskQueryReflectUserPrompt( - st.RequestNowText, - st.UserMessage, - st.UserGoal, - summarizeTaskQueryPlan(st.Plan), - st.RetryCount, - st.MaxReflectRetry, - summarizeTaskQueryItems(st.LastQueryItems, 6), - ) - reflectResult, err := agentllm.ReflectTaskQuery(ctx, n.input.Model, reflectPrompt) - if err != nil || reflectResult == nil { - st.NeedRetry = false - st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems) - return st, nil - } - - st.ReflectReason = strings.TrimSpace(reflectResult.Reason) - - if reflectResult.Satisfied { - st.NeedRetry = false - st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply)) - return st, nil - } - - if reflectResult.NeedRetry && st.RetryCount < st.MaxReflectRetry { - st.Plan = applyRetryPatch(st.Plan, reflectResult.RetryPatch, st.ExplicitLimit) - st.RetryCount++ - st.NeedRetry = true - if reply := strings.TrimSpace(reflectResult.Reply); reply != "" { - st.FinalReply = reply - } - return st, nil - } - - st.NeedRetry = false - st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply)) - return st, nil -} - -func (n *TaskQueryNodes) NextAfterReflect(ctx context.Context, st *agentmodel.TaskQueryState) (string, error) { - _ = ctx - if st != nil && st.NeedRetry { - return TaskQueryGraphNodeQuery, nil - } - return compose.END, nil -} - -func (n *TaskQueryNodes) executePlanByTool(ctx context.Context, plan agentmodel.TaskQueryPlan) ([]agentmodel.TaskQueryItem, error) { - if n.queryTool == nil { - return nil, fmt.Errorf("task query tool is nil") - } - - merged := make([]agentmodel.TaskQueryItem, 0, plan.Limit) - seen := make(map[int]struct{}, plan.Limit*2) - - runOne := func(quadrant *int) error { - input := TaskQueryToolInput{ - Quadrant: quadrant, - SortBy: plan.SortBy, - Order: plan.Order, - Limit: plan.Limit, - Keyword: plan.Keyword, - DeadlineBefore: plan.DeadlineBeforeText, - DeadlineAfter: plan.DeadlineAfterText, - } - includeCompleted := plan.IncludeCompleted - input.IncludeCompleted = &includeCompleted - - rawInput, err := json.Marshal(input) - if err != nil { - return err - } - rawOutput, err := n.queryTool.InvokableRun(ctx, string(rawInput)) - if err != nil { - return err - } - parsed, err := agentllm.ParseJSONObject[TaskQueryToolOutput](rawOutput) - if err != nil { - return err - } - - for _, item := range parsed.Items { - if _, exists := seen[item.ID]; exists { - continue - } - seen[item.ID] = struct{}{} - merged = append(merged, item) - } - return nil - } - - if len(plan.Quadrants) == 0 { - if err := runOne(nil); err != nil { - return nil, err - } - } else { - for _, quadrant := range plan.Quadrants { - q := quadrant - if err := runOne(&q); err != nil { - return nil, err - } - } - } - - sortTaskQueryItems(merged, plan) - if len(merged) > plan.Limit { - merged = merged[:plan.Limit] - } - return merged, nil -} - -func defaultTaskQueryPlan() agentmodel.TaskQueryPlan { - return agentmodel.TaskQueryPlan{ - SortBy: "deadline", - Order: "asc", - Limit: agentmodel.DefaultTaskQueryLimit, - IncludeCompleted: false, - } -} - -func normalizeTaskQueryPlan(raw agentllm.TaskQueryPlanOutput) agentmodel.TaskQueryPlan { - plan := defaultTaskQueryPlan() - plan.Quadrants = normalizeQuadrants(raw.Quadrants) - - if sortBy := strings.ToLower(strings.TrimSpace(raw.SortBy)); sortBy == "deadline" || sortBy == "priority" || sortBy == "id" { - plan.SortBy = sortBy - } - if order := strings.ToLower(strings.TrimSpace(raw.Order)); order == "asc" || order == "desc" { - plan.Order = order - } - if raw.Limit > 0 { - plan.Limit = raw.Limit - } - if plan.Limit > agentmodel.MaxTaskQueryLimit { - plan.Limit = agentmodel.MaxTaskQueryLimit - } - if plan.Limit <= 0 { - plan.Limit = agentmodel.DefaultTaskQueryLimit - } - if raw.IncludeCompleted != nil { - plan.IncludeCompleted = *raw.IncludeCompleted - } - plan.Keyword = strings.TrimSpace(raw.Keyword) - plan.DeadlineBeforeText = strings.TrimSpace(raw.DeadlineBefore) - plan.DeadlineAfterText = strings.TrimSpace(raw.DeadlineAfter) - applyTimeAnchorOnPlan(&plan) - return plan -} - -func normalizeQuadrants(quadrants []int) []int { - if len(quadrants) == 0 { - return nil - } - - seen := make(map[int]struct{}, len(quadrants)) - result := make([]int, 0, len(quadrants)) - for _, quadrant := range quadrants { - if quadrant < 1 || quadrant > 4 { - continue - } - if _, exists := seen[quadrant]; exists { - continue - } - seen[quadrant] = struct{}{} - result = append(result, quadrant) - } - - sort.Ints(result) - if len(result) == 0 || len(result) == 4 { - return nil - } - return result -} - -func applyTimeAnchorOnPlan(plan *agentmodel.TaskQueryPlan) { - if plan == nil { - return - } - - before, errBefore := parseTaskQueryBoundaryTime(plan.DeadlineBeforeText, true) - after, errAfter := parseTaskQueryBoundaryTime(plan.DeadlineAfterText, false) - - if errBefore != nil { - plan.DeadlineBefore = nil - plan.DeadlineBeforeText = "" - } else { - plan.DeadlineBefore = before - } - if errAfter != nil { - plan.DeadlineAfter = nil - plan.DeadlineAfterText = "" - } else { - plan.DeadlineAfter = after - } - - if plan.DeadlineBefore != nil && plan.DeadlineAfter != nil && plan.DeadlineAfter.After(*plan.DeadlineBefore) { - plan.DeadlineBefore = nil - plan.DeadlineAfter = nil - plan.DeadlineBeforeText = "" - plan.DeadlineAfterText = "" - } -} - -func autoBroadenPlan(plan agentmodel.TaskQueryPlan) (agentmodel.TaskQueryPlan, bool) { - broadened := plan - changed := false - - if strings.TrimSpace(broadened.Keyword) != "" { - broadened.Keyword = "" - changed = true - } - if !broadened.IncludeCompleted { - broadened.IncludeCompleted = true - changed = true - } - if broadened.DeadlineBefore != nil || broadened.DeadlineAfter != nil || broadened.DeadlineBeforeText != "" || broadened.DeadlineAfterText != "" { - broadened.DeadlineBefore = nil - broadened.DeadlineAfter = nil - broadened.DeadlineBeforeText = "" - broadened.DeadlineAfterText = "" - changed = true - } - return broadened, changed -} - -func applyRetryPatch(plan agentmodel.TaskQueryPlan, patch agentllm.TaskQueryRetryPatch, explicitLimit int) agentmodel.TaskQueryPlan { - next := plan - changed := false - - if patch.Quadrants != nil { - next.Quadrants = normalizeQuadrants(*patch.Quadrants) - changed = true - } - if patch.SortBy != nil { - sortBy := strings.ToLower(strings.TrimSpace(*patch.SortBy)) - if sortBy == "deadline" || sortBy == "priority" || sortBy == "id" { - next.SortBy = sortBy - changed = true - } - } - if patch.Order != nil { - order := strings.ToLower(strings.TrimSpace(*patch.Order)) - if order == "asc" || order == "desc" { - next.Order = order - changed = true - } - } - if patch.Limit != nil && explicitLimit <= 0 { - limit := *patch.Limit - if limit <= 0 { - limit = agentmodel.DefaultTaskQueryLimit - } - if limit > agentmodel.MaxTaskQueryLimit { - limit = agentmodel.MaxTaskQueryLimit - } - next.Limit = limit - changed = true - } - if patch.IncludeCompleted != nil { - next.IncludeCompleted = *patch.IncludeCompleted - changed = true - } - if patch.Keyword != nil { - next.Keyword = strings.TrimSpace(*patch.Keyword) - changed = true - } - if patch.DeadlineBefore != nil { - next.DeadlineBeforeText = strings.TrimSpace(*patch.DeadlineBefore) - changed = true - } - if patch.DeadlineAfter != nil { - next.DeadlineAfterText = strings.TrimSpace(*patch.DeadlineAfter) - changed = true - } - - if changed { - applyTimeAnchorOnPlan(&next) - } - if explicitLimit > 0 { - next.Limit = explicitLimit - } - return next -} - -func summarizeTaskQueryPlan(plan agentmodel.TaskQueryPlan) string { - quadrants := "全部象限" - if len(plan.Quadrants) > 0 { - parts := make([]string, 0, len(plan.Quadrants)) - for _, quadrant := range plan.Quadrants { - parts = append(parts, strconv.Itoa(quadrant)) - } - quadrants = strings.Join(parts, ",") - } - return fmt.Sprintf( - "quadrants=%s sort=%s/%s limit=%d include_completed=%t keyword=%s before=%s after=%s", - quadrants, - plan.SortBy, - plan.Order, - plan.Limit, - plan.IncludeCompleted, - emptyToDash(plan.Keyword), - emptyToDash(plan.DeadlineBeforeText), - emptyToDash(plan.DeadlineAfterText), - ) -} - -func summarizeTaskQueryItems(items []agentmodel.TaskQueryItem, max int) string { - if len(items) == 0 { - return "无结果" - } - if max <= 0 { - max = 5 - } - if len(items) > max { - items = items[:max] - } - - lines := make([]string, 0, len(items)) - for _, item := range items { - lines = append(lines, fmt.Sprintf( - "- #%d %s | 象限=%d | 完成=%t | 截止=%s", - item.ID, - item.Title, - item.PriorityGroup, - item.IsCompleted, - emptyToDash(item.DeadlineAt), - )) - } - return strings.Join(lines, "\n") -} - -func buildTaskQueryFallbackReply(items []agentmodel.TaskQueryItem) string { - if len(items) == 0 { - return "我这边暂时没找到匹配的任务。你可以再补一句,比如“按截止时间最早的前 3 个”或“只看简单不重要”。" - } - - preview := items - if len(preview) > 3 { - preview = preview[:3] - } - lines := make([]string, 0, len(preview)) - for _, item := range preview { - lines = append(lines, fmt.Sprintf("%s(%s)", item.Title, item.PriorityLabel)) - } - return fmt.Sprintf("我先给你筛到这些:%s。要不要我再按“更紧急”或“更简单”继续细化?", strings.Join(lines, "、")) -} - -func buildTaskQueryFinalReply(items []agentmodel.TaskQueryItem, plan agentmodel.TaskQueryPlan, llmReply string) string { - if len(items) == 0 { - base := buildTaskQueryFallbackReply(items) - if strings.TrimSpace(llmReply) == "" { - return base - } - return strings.TrimSpace(llmReply) + "\n" + base - } - - desired := plan.Limit - if desired <= 0 { - desired = agentmodel.DefaultTaskQueryLimit - } - if desired > agentmodel.MaxTaskQueryLimit { - desired = agentmodel.MaxTaskQueryLimit - } - - showCount := desired - if len(items) < showCount { - showCount = len(items) - } - - preview := items[:showCount] - lines := make([]string, 0, len(preview)) - for idx, item := range preview { - deadline := strings.TrimSpace(item.DeadlineAt) - if deadline == "" { - deadline = "无明确截止时间" - } - status := "未完成" - if item.IsCompleted { - status = "已完成" - } - lines = append(lines, fmt.Sprintf( - "%d. %s(%s,%s,截止:%s)", - idx+1, - item.Title, - item.PriorityLabel, - status, - deadline, - )) - } - - header := fmt.Sprintf("给你整理了 %d 条任务:", showCount) - if lead := extractSafeReplyLead(llmReply); lead != "" { - header = lead + "\n" + header - } - - reply := header + "\n" + strings.Join(lines, "\n") - if len(items) > showCount { - reply += fmt.Sprintf("\n另外还有 %d 条匹配任务,要不要我继续往下列?", len(items)-showCount) - } - return reply -} - -func extractSafeReplyLead(llmReply string) string { - text := strings.TrimSpace(llmReply) - if text == "" { - return "" - } - - lower := strings.ToLower(text) - if strings.Contains(text, "\n") || - strings.Contains(text, "#") || - strings.Contains(lower, "1.") || - strings.Contains(text, "1、") || - strings.Contains(text, "以下是") { - return "" - } - if len([]rune(text)) > 30 { - return "" - } - return text -} - -func sortTaskQueryItems(items []agentmodel.TaskQueryItem, plan agentmodel.TaskQueryPlan) { - if len(items) <= 1 { - return - } - - sortBy := strings.ToLower(strings.TrimSpace(plan.SortBy)) - order := strings.ToLower(strings.TrimSpace(plan.Order)) - if order != "desc" { - order = "asc" - } - - sort.SliceStable(items, func(i, j int) bool { - left := items[i] - right := items[j] - - switch sortBy { - case "priority": - if left.PriorityGroup != right.PriorityGroup { - if order == "desc" { - return left.PriorityGroup > right.PriorityGroup - } - return left.PriorityGroup < right.PriorityGroup - } - return left.ID > right.ID - case "id": - if order == "desc" { - return left.ID > right.ID - } - return left.ID < right.ID - default: - leftTime, leftOK := parseTaskQueryItemDeadline(left.DeadlineAt) - rightTime, rightOK := parseTaskQueryItemDeadline(right.DeadlineAt) - if leftOK && rightOK { - if !leftTime.Equal(rightTime) { - if order == "desc" { - return leftTime.After(rightTime) - } - return leftTime.Before(rightTime) - } - return left.ID > right.ID - } - if leftOK && !rightOK { - return true - } - if !leftOK && rightOK { - return false - } - return left.ID > right.ID - } - }) -} - -func parseTaskQueryItemDeadline(raw string) (time.Time, bool) { - text := strings.TrimSpace(raw) - if text == "" { - return time.Time{}, false - } - parsed, err := time.ParseInLocation("2006-01-02 15:04", text, time.Local) - if err != nil { - return time.Time{}, false - } - return parsed, true -} - -func emptyToDash(text string) string { - if strings.TrimSpace(text) == "" { - return "-" - } - return strings.TrimSpace(text) -} - -// extractExplicitLimitFromUser 从用户原话里提取显式条数要求。 -// -// 步骤说明: -// 1. 先识别阿拉伯数字表达,例如“前3个”“给我5条”“top 10”。 -// 2. 再识别中文数字表达,例如“前五个”“来三个”。 -// 3. 最终统一约束到 1~20 范围内。 -func extractExplicitLimitFromUser(userMessage string) (int, bool) { - text := strings.TrimSpace(userMessage) - if text == "" { - return 0, false - } - - for _, pattern := range explicitLimitPatterns { - matched := pattern.FindStringSubmatch(text) - if len(matched) < 2 { - continue - } - number, err := strconv.Atoi(strings.TrimSpace(matched[1])) - if err != nil { - continue - } - return normalizeExplicitLimit(number) - } - - for _, prefix := range []string{"前", "来", "给我"} { - for digit, number := range chineseDigitMap { - token := prefix + string(digit) - if strings.Contains(text, token) { - return normalizeExplicitLimit(number) - } - for _, suffix := range []string{"个", "条", "项"} { - if strings.Contains(text, token+suffix) { - return normalizeExplicitLimit(number) - } - } - } - } - return 0, false -} - -func normalizeExplicitLimit(number int) (int, bool) { - if number <= 0 { - return 0, false - } - if number > agentmodel.MaxTaskQueryLimit { - number = agentmodel.MaxTaskQueryLimit - } - return number, true -} diff --git a/backend/agent/node/taskquery_tool.go b/backend/agent/node/taskquery_tool.go deleted file mode 100644 index e01c06d..0000000 --- a/backend/agent/node/taskquery_tool.go +++ /dev/null @@ -1,286 +0,0 @@ -package agentnode - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - "github.com/cloudwego/eino/components/tool" - toolutils "github.com/cloudwego/eino/components/tool/utils" - "github.com/cloudwego/eino/schema" -) - -const ( - ToolNameTaskQueryTasks = "query_tasks" - ToolDescTaskQueryTasks = "按象限、关键词、截止时间筛选并排序任务,返回结构化任务列表" -) - -var taskQueryTimeLayouts = []string{ - time.RFC3339, - "2006-01-02 15:04:05", - "2006-01-02 15:04", - "2006-01-02", -} - -// TaskQueryToolDeps 描述任务查询工具依赖的外部查询能力。 -// -// 职责边界: -// 1. QueryTasks 负责读取真实任务数据。 -// 2. 工具层只负责参数校验、归一化和结构化输出,不直接耦合 DAO。 -type TaskQueryToolDeps struct { - QueryTasks func(ctx context.Context, req TaskQueryRequest) ([]TaskQueryTaskRecord, error) -} - -// Validate 负责校验任务查询工具依赖是否齐全。 -func (d TaskQueryToolDeps) Validate() error { - if d.QueryTasks == nil { - return errors.New("task query tool deps: QueryTasks is nil") - } - return nil -} - -// TaskQueryToolBundle 同时返回工具实例和工具元信息。 -// -// 职责边界: -// 1. Tools 给执行节点使用。 -// 2. ToolInfos 给模型注册 schema 使用。 -type TaskQueryToolBundle struct { - Tools []tool.BaseTool - ToolInfos []*schema.ToolInfo -} - -// TaskQueryRequest 是工具层传给业务层的内部查询请求。 -type TaskQueryRequest struct { - UserID int - Quadrant *int - SortBy string - Order string - Limit int - IncludeCompleted bool - Keyword string - DeadlineBefore *time.Time - DeadlineAfter *time.Time -} - -// TaskQueryTaskRecord 是业务层返回给工具层的任务记录。 -type TaskQueryTaskRecord struct { - ID int - Title string - PriorityGroup int - IsCompleted bool - DeadlineAt *time.Time - UrgencyThresholdAt *time.Time -} - -// TaskQueryToolInput 是暴露给大模型的工具入参。 -type TaskQueryToolInput struct { - Quadrant *int `json:"quadrant,omitempty" jsonschema:"description=可选象限(1~4)"` - SortBy string `json:"sort_by,omitempty" jsonschema:"description=排序字段(deadline|priority|id)"` - Order string `json:"order,omitempty" jsonschema:"description=排序方向(asc|desc)"` - Limit int `json:"limit,omitempty" jsonschema:"description=返回条数,默认5,上限20"` - IncludeCompleted *bool `json:"include_completed,omitempty" jsonschema:"description=是否包含已完成任务,默认false"` - Keyword string `json:"keyword,omitempty" jsonschema:"description=可选标题关键词,模糊匹配"` - DeadlineBefore string `json:"deadline_before,omitempty" jsonschema:"description=可选截止时间上界,支持RFC3339或yyyy-MM-dd HH:mm"` - DeadlineAfter string `json:"deadline_after,omitempty" jsonschema:"description=可选截止时间下界,支持RFC3339或yyyy-MM-dd HH:mm"` -} - -// TaskQueryToolOutput 是返回给模型的结构化结果。 -type TaskQueryToolOutput struct { - Total int `json:"total"` - Items []agentmodel.TaskQueryItem `json:"items"` -} - -// BuildTaskQueryToolBundle 负责构建任务查询工具包。 -// -// 步骤说明: -// 1. 先校验依赖是否完整,避免生成一个运行时必定失败的工具。 -// 2. 再把输入归一化成内部请求,调用业务查询函数拿到真实数据。 -// 3. 最后把业务记录转换成统一的轻量任务视图,供模型和反思节点复用。 -func BuildTaskQueryToolBundle(ctx context.Context, deps TaskQueryToolDeps) (*TaskQueryToolBundle, error) { - if err := deps.Validate(); err != nil { - return nil, err - } - - queryTool, err := toolutils.InferTool( - ToolNameTaskQueryTasks, - ToolDescTaskQueryTasks, - func(ctx context.Context, input *TaskQueryToolInput) (*TaskQueryToolOutput, error) { - req, err := normalizeTaskQueryToolInput(input) - if err != nil { - return nil, err - } - - records, err := deps.QueryTasks(ctx, req) - if err != nil { - return nil, err - } - - items := make([]agentmodel.TaskQueryItem, 0, len(records)) - for _, record := range records { - items = append(items, agentmodel.TaskQueryItem{ - ID: record.ID, - Title: record.Title, - PriorityGroup: record.PriorityGroup, - PriorityLabel: agentmodel.PriorityLabelCN(record.PriorityGroup), - IsCompleted: record.IsCompleted, - DeadlineAt: formatTaskQueryTime(record.DeadlineAt), - UrgencyThresholdAt: formatTaskQueryTime(record.UrgencyThresholdAt), - }) - } - - return &TaskQueryToolOutput{ - Total: len(items), - Items: items, - }, nil - }, - ) - if err != nil { - return nil, fmt.Errorf("构建任务查询工具失败: %w", err) - } - - tools := []tool.BaseTool{queryTool} - infos, err := collectToolInfos(ctx, tools) - if err != nil { - return nil, err - } - return &TaskQueryToolBundle{ - Tools: tools, - ToolInfos: infos, - }, nil -} - -// GetTaskQueryInvokableToolByName 按工具名提取可执行工具。 -func GetTaskQueryInvokableToolByName(bundle *TaskQueryToolBundle, name string) (tool.InvokableTool, error) { - if bundle == nil { - return nil, errors.New("task query tool bundle is nil") - } - return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name) -} - -// normalizeTaskQueryToolInput 负责参数默认值回填与合法性校验。 -// -// 步骤说明: -// 1. 先准备默认值,保证空参数也能执行一次合理查询。 -// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。 -// 3. 若上下界冲突,则直接返回错误,避免查出必为空的结果。 -func normalizeTaskQueryToolInput(input *TaskQueryToolInput) (TaskQueryRequest, error) { - req := TaskQueryRequest{ - SortBy: "deadline", - Order: "asc", - Limit: agentmodel.DefaultTaskQueryLimit, - IncludeCompleted: false, - } - if input == nil { - return req, nil - } - - if input.Quadrant != nil { - if *input.Quadrant < 1 || *input.Quadrant > 4 { - return TaskQueryRequest{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", *input.Quadrant) - } - quadrant := *input.Quadrant - req.Quadrant = &quadrant - } - - if sortBy := strings.ToLower(strings.TrimSpace(input.SortBy)); sortBy != "" { - req.SortBy = sortBy - } - switch req.SortBy { - case "deadline", "priority", "id": - default: - return TaskQueryRequest{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", req.SortBy) - } - - if order := strings.ToLower(strings.TrimSpace(input.Order)); order != "" { - req.Order = order - } - switch req.Order { - case "asc", "desc": - default: - return TaskQueryRequest{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", req.Order) - } - - if input.Limit > 0 { - req.Limit = input.Limit - } - if req.Limit > agentmodel.MaxTaskQueryLimit { - req.Limit = agentmodel.MaxTaskQueryLimit - } - if req.Limit <= 0 { - req.Limit = agentmodel.DefaultTaskQueryLimit - } - - if input.IncludeCompleted != nil { - req.IncludeCompleted = *input.IncludeCompleted - } - req.Keyword = strings.TrimSpace(input.Keyword) - - before, err := parseTaskQueryBoundaryTime(input.DeadlineBefore, true) - if err != nil { - return TaskQueryRequest{}, err - } - after, err := parseTaskQueryBoundaryTime(input.DeadlineAfter, false) - if err != nil { - return TaskQueryRequest{}, err - } - req.DeadlineBefore = before - req.DeadlineAfter = after - if req.DeadlineBefore != nil && req.DeadlineAfter != nil && req.DeadlineAfter.After(*req.DeadlineBefore) { - return TaskQueryRequest{}, errors.New("deadline_after 不能晚于 deadline_before") - } - - return req, nil -} - -// parseTaskQueryBoundaryTime 解析截止时间上下界。 -// -// 职责边界: -// 1. isUpper=true 时,纯日期补到当天 23:59:59。 -// 2. isUpper=false 时,纯日期补到当天 00:00:00。 -// 3. 不支持的格式直接返回错误,由调用方决定是否回退。 -func parseTaskQueryBoundaryTime(raw string, isUpper bool) (*time.Time, error) { - text := strings.TrimSpace(raw) - if text == "" { - return nil, nil - } - - loc := time.Local - for _, layout := range taskQueryTimeLayouts { - var ( - parsed time.Time - err error - ) - if layout == time.RFC3339 { - parsed, err = time.Parse(layout, text) - if err == nil { - parsed = parsed.In(loc) - } - } else { - parsed, err = time.ParseInLocation(layout, text, loc) - } - if err != nil { - continue - } - - if layout == "2006-01-02" { - if isUpper { - parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, loc) - } else { - parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc) - } - } - return &parsed, nil - } - return nil, fmt.Errorf("时间格式不支持: %s", text) -} - -// formatTaskQueryTime 负责把内部时间格式化为给模型展示的分钟级文本。 -func formatTaskQueryTime(value *time.Time) string { - if value == nil { - return "" - } - return value.In(time.Local).Format("2006-01-02 15:04") -} diff --git a/backend/agent/node/tool_common.go b/backend/agent/node/tool_common.go deleted file mode 100644 index 969ca76..0000000 --- a/backend/agent/node/tool_common.go +++ /dev/null @@ -1,74 +0,0 @@ -package agentnode - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/cloudwego/eino/components/tool" - "github.com/cloudwego/eino/schema" -) - -// collectToolInfos 负责批量提取工具元信息,供模型注册与工具索引复用。 -// -// 职责边界: -// 1. 只负责调用 tool.Info 并聚合返回结果。 -// 2. 不负责校验工具是否可执行,也不负责按名称检索工具。 -func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) { - infos := make([]*schema.ToolInfo, 0, len(tools)) - for _, currentTool := range tools { - info, err := currentTool.Info(ctx) - if err != nil { - return nil, fmt.Errorf("读取工具信息失败: %w", err) - } - infos = append(infos, info) - } - return infos, nil -} - -// buildInvokableToolMap 负责把工具列表转换成“工具名 -> 可执行工具”的索引表。 -// -// 步骤说明: -// 1. 先校验 tools 与 infos 是否一一对应,避免后续按下标取值时出现错配。 -// 2. 再校验每个工具都带有合法名字,并且确实实现了 InvokableTool 接口。 -// 3. 任一步失败都立即返回错误,避免 graph 在运行期拿到半残缺的工具集合。 -func buildInvokableToolMap(tools []tool.BaseTool, infos []*schema.ToolInfo) (map[string]tool.InvokableTool, error) { - if len(tools) == 0 || len(infos) == 0 { - return nil, errors.New("tool bundle is empty") - } - if len(tools) != len(infos) { - return nil, errors.New("tool bundle mismatch") - } - - result := make(map[string]tool.InvokableTool, len(tools)) - for idx, currentTool := range tools { - info := infos[idx] - if info == nil || strings.TrimSpace(info.Name) == "" { - return nil, errors.New("tool info is invalid") - } - invokable, ok := currentTool.(tool.InvokableTool) - if !ok { - return nil, fmt.Errorf("tool %s is not invokable", info.Name) - } - result[info.Name] = invokable - } - return result, nil -} - -// getInvokableToolByName 负责从工具集合中提取指定名称的可执行工具。 -// -// 职责边界: -// 1. 负责复用统一索引逻辑,避免各业务链路重复写名称查找代码。 -// 2. 不负责兜底选择其他工具;未命中时直接返回错误,由上层决定如何处理。 -func getInvokableToolByName(tools []tool.BaseTool, infos []*schema.ToolInfo, name string) (tool.InvokableTool, error) { - invokableMap, err := buildInvokableToolMap(tools, infos) - if err != nil { - return nil, err - } - invokable, ok := invokableMap[name] - if !ok { - return nil, fmt.Errorf("tool %s not found", name) - } - return invokable, nil -} diff --git a/backend/agent/prompt/quicknote.go b/backend/agent/prompt/quicknote.go deleted file mode 100644 index dbbe497..0000000 --- a/backend/agent/prompt/quicknote.go +++ /dev/null @@ -1,46 +0,0 @@ -package agentprompt - -const ( - // QuickNotePlanPrompt 用于“单请求聚合规划”。 - QuickNotePlanPrompt = `你是 SmartMate 的任务聚合规划器。 -你将基于用户输入,一次性输出任务规划结果,供后端直接写库。 - -必须完成以下五件事: -1) 提取任务标题 title(简洁明确)。 -2) 归一化截止时间 deadline_at(若存在时间线索,必须输出绝对时间)。 -3) 评估紧急分界时间 urgency_threshold_at(当任务被判定为不紧急任务时才会触发:你需要评估何时从不紧急象限自动平移到紧急象限,不可为空)。 -4) 评估优先级 priority_group(1~4)。 -5) 生成一句轻松跟进句 banter(不超过30字)。 - -输出要求: -- 仅输出 JSON,不要 markdown,不要解释。 -- deadline_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。 -- urgency_threshold_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。 -- priority_group 仅允许 1|2|3|4。 -- banter 不得新增或修改任务事实(任务名、时间、优先级)。` - - // QuickNoteIntentPrompt 用于第一阶段:判断用户输入是否属于“随口记”。 - QuickNoteIntentPrompt = `你是 SmartMate 的“随口记分诊器”。 -请判断用户输入是否表达了“帮我记一个任务/日程”的需求。 -- 若是,请提取任务标题与时间线索。 -- 时间处理必须严谨:若出现相对时间(如明天/后天/下周一/今晚),必须基于上文给出的“当前时间”换算为绝对时间。 -- 若不是,请明确返回“非随口记意图”。 -- 不要声称已经写入数据库。` - - // QuickNotePriorityPrompt 用于第二阶段:将任务归类到四象限优先级,并评估紧急分界线。 - QuickNotePriorityPrompt = `你是 SmartMate 的任务优先级评估器。 -根据任务内容、时间约束和执行成本,输出优先级 priority_group: -1=重要且紧急,2=重要不紧急,3=简单不重要,4=不简单不重要。 -请给出简短理由,理由必须可解释。 -若你认为该任务需要后续自动平移,请额外输出 urgency_threshold_at(绝对时间,yyyy-MM-dd HH:mm);否则输出空字符串。` - - // QuickNoteReplyBanterPrompt 用于随口记成功后的“轻松跟进句”生成。 - QuickNoteReplyBanterPrompt = `你是 SmartMate 的中文口语化回复润色助手。 -请根据用户原话生成一句轻松自然的跟进话术,让回复更有温度。 -要求: -- 只输出一句中文,不超过30字。 -- 顺着用户创建提醒的主题延伸,就像聊天时友好的问候一样,记得动用你知道的对应领域的知识。例如(注意,只是例子):用户说提醒他明天早上吃麦当劳,你润色回复应该类似这样:"薯饼记得趁热吃哦~"。 -- 可以轻微调侃,但语气友好,不刻薄。 -- 不得新增或修改任务事实(任务名、时间、优先级)。 -- 不要输出 markdown、编号、引号。` -) diff --git a/backend/agent/prompt/route.go b/backend/agent/prompt/route.go deleted file mode 100644 index d178f2c..0000000 --- a/backend/agent/prompt/route.go +++ /dev/null @@ -1,24 +0,0 @@ -package agentprompt - -import ( - "fmt" - "strings" -) - -const routeSystemPrompt = ` -你是 SmartMate 的一级路由助手。 -你的职责不是回答用户,而是判断这条消息更适合走哪条能力链路。 - -当前 Agent 仍在逐批迁移阶段,因此这里只先保留 prompt 落点与职责说明。 -真正迁移旧 route 提示词时,应把正式版本收敛到这里,而不是散落在 node 或 service 中。 -` - -// BuildRouteSystemPrompt 返回一级路由系统提示词。 -func BuildRouteSystemPrompt() string { - return strings.TrimSpace(routeSystemPrompt) -} - -// BuildRouteUserPrompt 构造一级路由用户提示词。 -func BuildRouteUserPrompt(userInput string) string { - return fmt.Sprintf("用户输入:%s", strings.TrimSpace(userInput)) -} diff --git a/backend/agent/prompt/schedule.go b/backend/agent/prompt/schedule.go deleted file mode 100644 index 2578d36..0000000 --- a/backend/agent/prompt/schedule.go +++ /dev/null @@ -1,172 +0,0 @@ -package agentprompt - -const ( - // SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。 - // - // 职责边界: - // 1. 负责把自然语言转成结构化 JSON,供后端节点分流与执行; - // 2. 负责抽取 task_class_ids / strategy / task_tags 等关键字段; - // 3. 不负责做排程计算,不负责做工具调用。 - SchedulePlanIntentPrompt = `你是 SmartMate 的排程意图分析器。 -请根据用户输入,提取排程意图与约束条件。 - -必须完成以下任务: -1) 用一句话概括用户的排程意图(intent)。 -2) 提取所有硬约束(constraints),如“早八不排”“周末休息”等。 -3) 如果用户明确提到了任务类名称或ID,输出 task_class_ids(整数数组);否则输出空数组 []。 -4) 兼容字段 task_class_id:若 task_class_ids 非空,可填第一个ID;若无法判断填 -1。 -5) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。 -6) 尝试给任务打认知标签 task_tags(可选): - - 推荐键:task_item_id(字符串形式,例如 "12") - - 兼容键:任务名称(例如 "高数复习") - - 值只能是:High-Logic / Memory / Review / General - - 如果无法判断,输出空对象 {} -7) 判定本轮是否要求“强制重排” restart: - - 用户明确表达“重新排/推倒重来/忽略之前方案/全部重来”时,restart=true; - - 否则 restart=false。 -8) 判定微调力度 adjustment_scope(small / medium / large): - - small:局部微调,通常只改少量时段,不需要重建全局。 - - medium:中等调整,需要周级再平衡,但不必全量重粗排。 - - large:大范围调整,或首次创建排程,或约束变化很大,需要完整重排。 -9) 输出 reason(简短中文理由,<=30字)与 confidence(0~1)。 - -输出要求: -- 仅输出 JSON,不要 markdown,不要解释。 -- 格式如下: -{ - "intent": "用户排程意图摘要", - "constraints": ["约束1", "约束2"], - "task_class_ids": [12, 13], - "task_class_id": 12, - "strategy": "steady", - "task_tags": {"12":"High-Logic","英语阅读":"Memory"}, - "restart": false, - "adjustment_scope": "medium", - "reason": "本次只调整局部时段", - "confidence": 0.86 -}` - - // SchedulePlanDailyReactPrompt 用于 daily_refine 节点。 - // - // 职责边界: - // 1. 只处理“单天”数据,避免跨天决策污染; - // 2. 通过工具调用做小步调整; - // 3. 不负责周级配平,不负责最终总结。 - SchedulePlanDailyReactPrompt = `你是 SmartMate 日内排程优化器。 - -你将收到一天内的日程安排(JSON 数组),其中: -- status="existing":已确定的课程或任务,不可移动 -- status="suggested":粗排算法建议的学习任务,你可以调整 -- context_tag:任务认知类型(High-Logic/Memory/Review/General) - -你的目标是优化这一天内 suggested 任务的时间安排。 - -## 优化原则 -1. 上下文切换成本:相同 context_tag 的任务尽量相邻,减少认知切换。 -2. 时段适配性: - - 第1-4节(上午):适合 High-Logic(数学、编程) - - 第5-8节(下午):适合中等强度(专业课、阅读) - - 第9-12节(晚间):适合 Memory 和 Review -3. 学习效率曲线:避免连续超过 4 节高强度学习。 -4. 与 existing 条目衔接:避免高强度课程后立刻接高强度任务。 - -## 可用工具 -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}}]} - -完成优化时: -{"done":true,"summary":"简要说明优化理由"} - -重要:只修改 suggested 任务,不要尝试移动 existing 条目。` - - // SchedulePlanWeeklyReactPrompt 用于 weekly_refine 节点。 - // - // 设计重点: - // 1. 采用“单步动作”模式:每轮只做一个动作(Move/Swap)或直接 done; - // 2. 显式区分总预算与有效预算,避免模型对“次数扣减”产生困惑; - // 3. 明确“输入数据已过后端硬校验”,避免模型把合法嵌入误判为冲突; - // 4. 工具失败结果会回传到下一轮,模型只需“走一步看一步”。 - SchedulePlanWeeklyReactPrompt = `你是 SmartMate 周级排程配平器。 - -单日内的排程已优化完毕,你当前只负责“单周微调”。 - -## 数据可靠性前提(必须接受) -1. 你收到的混合日程 JSON 已经过后端硬冲突检查。 -2. 如果看到课程与任务在同一节次重叠,这表示“任务嵌入课程”的合法状态,不是异常。 -3. 你不需要再次判断“输入本身是否冲突”,只需要在这个可信基线上进行优化。 -4. 工具内部会做可用性与冲突校验;你无需额外调用“检查可用性工具”。 -5. 字段语义补充: - - existing 条目的 block_for_suggested=false:该课程格子允许嵌入 suggested 任务; - - suggested 条目的 block_for_suggested=true:表示该 suggested 本身会占位,防止被其他 suggested 再次重叠覆盖。 - -## 预算语义(必须遵守,且必须严格区分) -1. 总动作预算(剩余):{{action_total_remaining}} -2. 总动作预算(固定):{{action_total_budget}} -3. 总动作预算(已用):{{action_total_used}} -4. 有效动作预算(剩余):{{action_effective_remaining}} -5. 有效动作预算(固定):{{action_effective_budget}} -6. 有效动作预算(已用):{{action_effective_used}} -7. 规则: - - 每次工具调用(无论成功失败)都会消耗 1 次“总动作预算”; - - 仅当工具调用成功时,才会额外消耗 1 次“有效动作预算”。 -8. 你当前看到的是“剩余额度”,不是“总额度”,额度减少是前序动作正常消耗。 - -## 约束 -1. 只允许在当前周内优化(禁止跨周移动)。 -2. 每次回复只能做一件事:要么调用 1 个工具,要么 done。 -3. 严格遵守用户约束(如有)。 -4. 每个任务最多变动一次位置。 - -## 优化目标 -1. 疲劳度均衡:避免某一天堆积过多高强度任务(context_tag=High-Logic)。 -2. 间隔重复:同一科目任务适当分散到不同天。 -3. 科目多样性:尽量避免单一任务类型连续多天占据相同时段。 -4. 总量均衡:各天 suggested 数量大致均匀。 - -## 执行节奏(降低无效思考) -1. 想一步做一步:本轮只做“一个最有价值动作”。 -2. 不要一次规划多步;上一轮工具结果会传给下一轮,你可以继续接力。 -3. 如果当前方案已经足够好,直接 done,不要空转。 -4. 禁止输出多个工具调用;如果需要连续调整,请分多轮逐步完成。 - -## 可用工具 -1. Move — 将一个 suggested 任务移动到当前周的另一天/时段 - 参数:task_item_id, to_week, to_day, to_section_from, to_section_to - 注意:节次跨度必须与原任务一致 -2. Swap — 交换两个 suggested 任务的时间 - 参数:task_a, task_b(task_item_id) - -## 输出格式(严格 JSON,不要 markdown) -调用工具时(注意:tool_calls 里只能有 1 个元素): -{"tool_calls":[{"tool":"Move","params":{"task_item_id":10,"to_week":2,"to_day":3,"to_section_from":5,"to_section_to":6}}]} - -完成优化时: -{"done":true,"summary":"简要说明做了哪些跨天调整及理由"}` - - // SchedulePlanFinalCheckPrompt 用于 final_check 节点的人性化总结。 - // - // 职责边界: - // 1. 只做读数据总结,不参与工具调用与状态修改; - // 2. 输出面向用户的自然语言; - // 3. 失败由上层兜底文案处理。 - SchedulePlanFinalCheckPrompt = `你是 SmartMate 排程方案总结专家。 -你的任务是为用户生成一段友好、自然的排程总结。 - -要求: -1. 用 2-3 句话概括方案亮点。 -2. 提及具体时间安排特征(如“上午安排高强度任务”“周末留出缓冲”)。 -3. 若用户有约束,说明方案如何满足这些约束。 -4. 输入里会包含“周级动作日志”,请结合日志说明优化过程的价值(例如更均衡、冲突更少、切换更顺)。 -5. 语气温暖自然。 -6. 只输出纯文本,不要输出 JSON。` -) diff --git a/backend/agent/prompt/schedule_refine.go b/backend/agent/prompt/schedule_refine.go deleted file mode 100644 index 872d922..0000000 --- a/backend/agent/prompt/schedule_refine.go +++ /dev/null @@ -1,188 +0,0 @@ -package agentprompt - -const ( - // ScheduleRefineContractPrompt 负责把用户自然语言微调请求抽取为结构化契约。 - ScheduleRefineContractPrompt = `你是 SmartMate 的排程微调契约分析器。 -你会收到:当前时间、用户请求、已有排程摘要。 -请只输出 JSON,不要 Markdown,不要解释,不要代码块: -{ - "intent": "一句话概括本轮微调目标", - "strategy": "local_adjust|keep", - "hard_requirements": ["必须满足的硬性要求1","必须满足的硬性要求2"], - "hard_assertions": [ - { - "metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count", - "operator": "==|<=|>=|between", - "value": 50, - "min": 50, - "max": 50, - "week": 17, - "target_week": 16 - } - ], - "keep_relative_order": true, - "order_scope": "global|week" -} - -规则: -1. 除非用户明确表达“允许打乱顺序/顺序无所谓”,keep_relative_order 默认 true。 -2. 仅当用户明确放宽顺序时,keep_relative_order 才允许为 false;order_scope 默认 "global"。 -3. 只要涉及移动任务,strategy 必须是 local_adjust;仅在无需改动时才用 keep。 -4. hard_requirements 必须可验证,避免空泛描述。 -5. hard_assertions 必须尽量结构化,避免只给自然语言目标。` - - // ScheduleRefinePlannerPrompt 只负责生成“执行路径”,不直接执行动作。 - ScheduleRefinePlannerPrompt = `你是 SmartMate 的排程微调 Planner。 -你会收到:用户请求、契约、最近动作观察。 -请只输出 JSON,不要 Markdown,不要解释,不要代码块: -{ - "summary": "本阶段执行策略一句话", - "steps": ["步骤1","步骤2","步骤3"] -} - -规则: -1. steps 保持 3~4 条,优先“先取证再动作”。 -2. summary <= 36 字,单步 <= 28 字。 -3. 若目标是“均匀分散”,steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。 -4. 若目标是“上下文切换最少/同科目连续”,steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。 -5. 不要输出半截 JSON。` - - // ScheduleRefineReactPrompt 用于“单任务微步 ReAct”执行器。 - ScheduleRefineReactPrompt = `你是 SmartMate 的单任务微步 ReAct 执行器。 -当前只处理一个任务(CURRENT_TASK),不能发散到其它任务的主动改动。 -你每轮只能做两件事之一: -1) 调用一个工具(基础工具或复合工具) -2) 输出 done=true 结束当前任务 - -工具分组: -- 基础工具:QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify -- 复合工具:SpreadEven / MinContextSwitch - -工具说明(按职责): -1. QueryTargetTasks:查询候选任务集合(只读)。 - 常用参数:week/week_filter/day_of_week/task_item_ids/status。 - 适用:先摸清“有哪些任务可动、当前在哪”。 -2. QueryAvailableSlots:查询可放置坑位(只读,默认先纯空位,必要时补可嵌入位)。 - 常用参数:week/week_filter/day_of_week/span/limit/allow_embed/exclude_sections。 - 适用:Move 前先拿可落点清单。 -3. Move:移动单个任务到目标坑位(写操作)。 - 必要参数:task_item_id,to_week,to_day,to_section_from,to_section_to。 - 适用:单任务精确挪动。 -4. Swap:交换两个任务坑位(写操作)。 - 必要参数:task_a,task_b。 - 适用:两个任务互换位置比单独 Move 更稳时。 -5. BatchMove:批量原子移动(写操作)。 - 必要参数:{"moves":[{Move参数...},{Move参数...}]}。 - 适用:一轮要改多个任务且要求“要么全成要么全回滚”。 -6. Verify:执行确定性校验(只读)。 - 常用参数:可空;也可传 task_item_id + 目标坐标做定点核验。 - 适用:收尾前快速自检是否符合确定性约束。 -7. SpreadEven(复合):按“均匀铺开”目标一次规划并执行多任务移动(写操作)。 - 必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。 - 可选参数:week/week_filter/day_of_week/allow_embed/limit。 - 适用:目标是“把任务在时间上分散开,避免扎堆”。 -8. MinContextSwitch(复合):按“最少上下文切换”一次规划并执行多任务移动(写操作)。 - 必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。 - 可选参数:week/week_filter/day_of_week/allow_embed/limit。 - 适用:目标是“同科目/同认知标签尽量连续,减少切换成本”。 - -请严格输出 JSON,不要 Markdown,不要解释: -{ - "done": false, - "summary": "", - "goal_check": "本轮先检查什么", - "decision": "本轮为何这么做", - "missing_info": ["缺口信息1","缺口信息2"], - "tool_calls": [ - { - "tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify", - "params": {} - } - ] -} - -硬规则: -1. 每轮最多 1 个 tool_call。 -2. done=true 时,tool_calls 必须为空数组。 -3. done=false 时,tool_calls 必须恰好 1 条。 -4. 只能修改 status="suggested" 的任务,禁止修改 existing。 -5. 不要把“顺序约束”当作执行期阻塞条件;你只需把坑位分布排好,顺序由后端统一收口。 -6. 若上轮失败,必须依据 LAST_TOOL_OBSERVATION.error_code 调整策略,不能重复上轮失败动作。 -7. Move 参数优先使用:task_item_id,to_week,to_day,to_section_from,to_section_to。 -8. BatchMove 参数格式必须是:{"moves":[{...},{...}]};任一步失败会整批回滚。 -9. day_of_week 映射固定:1周一,2周二,3周三,4周四,5周五,6周六,7周日。 -10. 优先使用“纯空位”;仅在空位不足时再考虑可嵌入课程位(第二优先级)。 -11. 如果 SOURCE_WEEK_FILTER 非空,只允许改写这些来源周里的任务,禁止主动改写其它周任务。 -12. CURRENT_TASK 是本轮唯一可改写任务;如果它已满足目标,立刻 done=true,不要提前处理下一个任务。 -13. 禁止发明工具名(如 GetCurrentTask、AdjustTaskTime),只能用白名单工具。 -14. 优先使用后端注入的 ENV_SLOT_HINT 进行落点决策,非必要不要重复 QueryAvailableSlots。 -15. 若 REQUIRED_COMPOSITE_TOOL 非空且 COMPOSITE_REQUIRED_SUCCESS=false,本轮必须优先调用 REQUIRED_COMPOSITE_TOOL,禁止先调用 Move/Swap/BatchMove。 -16. 若使用 SpreadEven/MinContextSwitch,必须在参数中提供 task_item_ids(且包含 CURRENT_TASK.task_item_id)。 -17. 若 COMPOSITE_TOOLS_ALLOWED=false,禁止调用 SpreadEven/MinContextSwitch,只能使用基础工具逐步处理。 -18. 为保证解析稳定:goal_check<=50字,decision<=90字,summary<=60字。` - - // ScheduleRefinePostReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。 - ScheduleRefinePostReflectPrompt = `你是 SmartMate 的 ReAct 复盘器。 -你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。 -请只输出 JSON,不要 Markdown,不要解释: -{ - "reflection": "基于真实结果的复盘", - "next_strategy": "下一轮建议动作", - "should_stop": false -} - -规则: -1. 若 tool_success=false,reflection 必须明确失败原因(优先引用 error_code)。 -2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTION,next_strategy 必须给出规避方法。 -3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。` - - // ScheduleRefineReviewPrompt 用于终审语义校验。 - ScheduleRefineReviewPrompt = `你是 SmartMate 的终审校验器。 -请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。 -只输出 JSON: -{ - "pass": true, - "reason": "中文简短结论", - "unmet": [] -} - -规则: -1. pass=true 时 unmet 必须为空数组。 -2. pass=false 时 reason 必须给出核心差距。` - - // ScheduleRefineSummaryPrompt 用于最终面向用户的自然语言总结。 - ScheduleRefineSummaryPrompt = `你是 SmartMate 的排程结果解读助手。 -请基于输入输出 2~4 句中文总结: -1) 先说明本轮改了什么; -2) 再说明改动收益; -3) 若终审未完全通过,明确还差什么。 -不要输出 JSON。` - - // ScheduleRefineRepairPrompt 用于终审失败后的单次修复动作。 - ScheduleRefineRepairPrompt = `你是 SmartMate 的修复执行器。 -当前方案未通过终审,请根据“未满足点”只做一次修复动作。 -只允许输出一个 tool_call(Move 或 Swap),不允许 done。 - -输出格式(严格 JSON): -{ - "done": false, - "summary": "", - "goal_check": "本轮修复目标", - "decision": "修复决策依据", - "missing_info": [], - "tool_calls": [ - { - "tool": "Move|Swap", - "params": {} - } - ] -} - -Move 参数必须使用标准键: -- task_item_id -- to_week -- to_day -- to_section_from -- to_section_to -禁止使用 new_week/new_day/section_from 等别名。` -) diff --git a/backend/agent/prompt/taskquery.go b/backend/agent/prompt/taskquery.go deleted file mode 100644 index 9e0e40e..0000000 --- a/backend/agent/prompt/taskquery.go +++ /dev/null @@ -1,79 +0,0 @@ -package agentprompt - -import ( - "fmt" - "strings" -) - -const TaskQueryPlanPrompt = `你是 SmartMate 的任务查询规划器。请根据用户原话,输出结构化查询计划 JSON,供后端直接执行。 -只允许输出 JSON,不要输出解释、代码块或多余文字。 - -输出字段: -{ - "user_goal": "一句话总结用户诉求", - "quadrants": [1,2,3,4], - "sort_by": "deadline|priority|id", - "order": "asc|desc", - "limit": 1-20, - "include_completed": false, - "keyword": "可选关键词,或空字符串", - "deadline_before": "yyyy-MM-dd HH:mm 或空字符串", - "deadline_after": "yyyy-MM-dd HH:mm 或空字符串" -} - -规则: -1. quadrants 为空数组表示“全部象限”。 -2. 用户未提排序时,默认 sort_by=deadline 且 order=asc。 -3. 用户未提数量时,limit 默认 5。 -4. 时间字段必须输出绝对时间或空字符串,不要输出“明天”“下周一”这类相对时间。 -5. 如果用户语义更偏向“我还有什么要做”“看看待办”,优先考虑 1、2 象限;如果 1、2 象限为空,再考虑 3、4 象限。 -6. 如果用户语义更偏向“来点事做做”“给我点轻松的任务”,优先考虑 3、4 象限。 -7. 允许多选象限。` - -const TaskQueryReflectPrompt = `你是 SmartMate 的任务查询结果审阅器。你会看到:用户原话、当前查询计划、查询结果摘要、当前重试次数。 -请只输出 JSON,不要输出解释、代码块或多余文字。 - -输出字段: -{ - "satisfied": true, - "need_retry": false, - "reason": "一句话原因", - "reply": "可直接给用户看的中文回复", - "retry_patch": { - "quadrants": [1,2,3,4], - "sort_by": "deadline|priority|id", - "order": "asc|desc", - "limit": 1-20, - "include_completed": true, - "keyword": "可选关键词,或空字符串", - "deadline_before": "yyyy-MM-dd HH:mm 或空字符串", - "deadline_after": "yyyy-MM-dd HH:mm 或空字符串" - } -} - -规则: -1. 如果当前结果已经满足用户诉求,返回 satisfied=true 且 need_retry=false。 -2. 如果当前结果不满足,但仍值得再查一次,返回 need_retry=true,并尽量只给最小必要 patch。 -3. 如果不建议再试,返回 need_retry=false,并在 reply 里说明当前最接近的结果。 -4. reply 应该是自然中文,不要输出表格。` - -func BuildTaskQueryPlanUserPrompt(nowText, userInput string) string { - return fmt.Sprintf( - "当前时间(北京时间,精确到分钟):%s\n用户输入:%s\n\n请输出任务查询计划 JSON。", - strings.TrimSpace(nowText), - strings.TrimSpace(userInput), - ) -} - -func BuildTaskQueryReflectUserPrompt(nowText, userInput, userGoal, planSummary string, retryCount, maxRetry int, resultSummary string) string { - return fmt.Sprintf( - "当前时间:%s\n用户原话:%s\n用户目标:%s\n当前查询计划:%s\n当前重试:%d/%d\n查询结果摘要:\n%s", - strings.TrimSpace(nowText), - strings.TrimSpace(userInput), - strings.TrimSpace(userGoal), - strings.TrimSpace(planSummary), - retryCount, - maxRetry, - strings.TrimSpace(resultSummary), - ) -} diff --git a/backend/agent/router/action_route.go b/backend/agent/router/action_route.go deleted file mode 100644 index cf7a900..0000000 --- a/backend/agent/router/action_route.go +++ /dev/null @@ -1,272 +0,0 @@ -package agentrouter - -import ( - "context" - "fmt" - "log" - "regexp" - "strings" - "time" - - agentllm "github.com/LoveLosita/smartflow/backend/agent/llm" - "github.com/cloudwego/eino-ext/components/model/ark" - "github.com/google/uuid" -) - -const ( - // ControlTimeout 表示“路由控制码”阶段的额外超时预算。 - // 说明: - // 1. 设为 0 表示完全继承父 ctx 的 deadline,不额外截断。 - // 2. 若后续观察到路由阶段偶发超时,可按需配置一个小预算(例如 2s)。 - ControlTimeout = 0 * time.Second -) - -var ( - // routeHeaderRegex 用于解析控制码头部。 - // 支持动作: - // 1. quick_note_create:新增随口记任务。 - // 2. task_query:任务查询。 - // 3. schedule_plan_create:新建排程。 - // 4. schedule_plan_refine:连续对话微调排程。 - // 5. schedule_plan:历史兼容动作(解析后映射到 schedule_plan_create)。 - // 6. quick_note:历史兼容动作(解析后映射到 quick_note_create)。 - // 7. chat:普通聊天。 - 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_create|schedule_plan_refine|schedule_plan|quick_note|chat)["']?[^>]*>`) - // routeReasonRegex 用于提取可选 reason,便于日志排障。 - routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`) -) - -const routeControlPrompt = `你是 SmartMate 的请求分流控制器。 -你的唯一任务是给后端返回“可机读控制码”,不要做用户可见回复,不要解释。 - -动作定义: -1) quick_note_create:用户明确要“帮我记一下/安排一个未来要做的事/提醒我”。 -2) task_query:用户要“查任务、筛任务、按条件列任务”。 -3) schedule_plan_create:用户要“新建/生成一份排程方案”。 -4) schedule_plan_refine:用户要“基于已有排程做连续微调”(如挪动某天、限制某时段、局部改动)。 -5) chat:其余普通聊天与讨论。 - -优先级(冲突时按顺序): -1) quick_note_create -2) task_query -3) schedule_plan_refine -4) schedule_plan_create -5) chat - -输出格式必须严格如下(两行): - -一句不超过30字的中文理由 - -禁止输出任何其他内容。` - -// Action 是 Agent 路由层对业务动作的统一命名。 -// -// 这里直接定义在 router 包,而不是复用旧 route 包: -// 1. 当前这轮迁移要求只有 router 可以保留对旧链路的兼容语义; -// 2. chat / quicknote 已经要完全切到 Agent,自然不该再依赖旧包常量; -// 3. schedule/taskquery 尚未搬迁完成时,也能继续靠这些常量在 service 层做统一分发。 -type Action string - -const ( - ActionChat Action = "chat" - ActionQuickNoteCreate Action = "quick_note_create" - ActionTaskQuery Action = "task_query" - ActionSchedulePlanCreate Action = "schedule_plan_create" - ActionSchedulePlanRefine Action = "schedule_plan_refine" - - // ActionSchedulePlan 是历史兼容动作值。 - // 说明:旧模型可能返回 schedule_plan,解析后统一映射到 schedule_plan_create。 - ActionSchedulePlan Action = "schedule_plan" - // ActionQuickNote 是历史兼容动作值,解析后统一映射到 quick_note_create。 - ActionQuickNote Action = "quick_note" -) - -// ControlDecision 表示“模型控制码解析结果”。 -type ControlDecision struct { - Action Action - Reason string - Raw string -} - -// RoutingDecision 是服务层使用的统一分流结果。 -// 职责边界: -// 1. Action:最终动作(chat/quick_note_create/task_query/schedule_plan_create/schedule_plan_refine)。 -// 2. TrustRoute:是否允许下游跳过二次意图判定。 -// 3. Detail:可选说明,用于阶段提示或日志。 -// 4. RouteFailed:标记“控制码路由是否失败”,供上层决定是否直接报错。 -type RoutingDecision struct { - Action Action - TrustRoute bool - Detail string - RouteFailed bool -} - -// DecideActionRouting 通过“模型控制码”决定本次请求走向。 -// 返回语义: -// 1. Action=quick_note_create:进入随口记链路。 -// 2. Action=task_query:进入任务查询链路。 -// 3. Action=schedule_plan_create:进入新建排程链路。 -// 4. Action=schedule_plan_refine:进入连续微调链路。 -// 5. Action=chat:进入普通聊天链路。 -// 6. 路由失败时标记 RouteFailed=true,由上层统一处理。 -func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision { - decision, err := routeByModelControlTag(ctx, selectedModel, userMessage) - if err != nil { - if deadline, ok := ctx.Deadline(); ok { - log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d", - err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds()) - } else { - log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline=none route_timeout_ms=%d", - err, ControlTimeout.Milliseconds()) - } - return RoutingDecision{ - Action: ActionChat, - TrustRoute: false, - Detail: "", - RouteFailed: true, - } - } - - switch decision.Action { - case ActionQuickNoteCreate: - reason := strings.TrimSpace(decision.Reason) - if reason == "" { - reason = "识别到新增任务请求,准备执行随口记流程。" - } - return RoutingDecision{Action: ActionQuickNoteCreate, TrustRoute: true, Detail: reason, RouteFailed: false} - case ActionTaskQuery: - reason := strings.TrimSpace(decision.Reason) - if reason == "" { - reason = "识别到任务查询请求,准备执行任务查询流程。" - } - return RoutingDecision{Action: ActionTaskQuery, TrustRoute: true, Detail: reason, RouteFailed: false} - case ActionSchedulePlanCreate: - reason := strings.TrimSpace(decision.Reason) - if reason == "" { - reason = "识别到新建排程请求,准备执行智能排程流程。" - } - return RoutingDecision{Action: ActionSchedulePlanCreate, TrustRoute: true, Detail: reason, RouteFailed: false} - case ActionSchedulePlanRefine: - reason := strings.TrimSpace(decision.Reason) - if reason == "" { - reason = "识别到排程微调请求,准备执行连续微调流程。" - } - return RoutingDecision{Action: ActionSchedulePlanRefine, TrustRoute: true, Detail: reason, RouteFailed: false} - case ActionChat: - return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: false} - default: - log.Printf("通用分流出现未知动作,标记路由失败并等待上层报错: action=%s raw=%s", decision.Action, decision.Raw) - return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: true} - } -} - -func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) (*ControlDecision, error) { - if selectedModel == nil { - return nil, fmt.Errorf("model is nil") - } - - nonce := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", "")) - routeCtx, cancel := deriveRouteControlContext(ctx, ControlTimeout) - defer cancel() - - nowText := time.Now().In(time.Local).Format("2006-01-02 15:04") - userPrompt := fmt.Sprintf("nonce=%s\n当前时间=%s\n用户输入=%s", nonce, nowText, strings.TrimSpace(userMessage)) - - // 1. 调用目的:路由场景只需要稳定、短文本、禁用 thinking 的结构化输出。 - // 2. 这里复用 Agent 公共 LLM 封装,删除与 quicknote 重复的 JSON/文本调用样板代码。 - resp, err := agentllm.CallArkText(routeCtx, selectedModel, routeControlPrompt, userPrompt, agentllm.ArkCallOptions{ - Temperature: 0, - MaxTokens: 120, - Thinking: agentllm.ThinkingModeDisabled, - }) - if err != nil { - return nil, err - } - return ParseRouteControlTag(resp, nonce) -} - -// deriveRouteControlContext 为“控制码路由”创建子上下文。 -// 设计要点: -// 1. timeout<=0 时不加额外 deadline,仅继承父上下文。 -// 2. 父 ctx deadline 更紧时,沿用父上下文,避免过早超时误判。 -func deriveRouteControlContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { - if timeout <= 0 { - return context.WithCancel(parent) - } - if deadline, ok := parent.Deadline(); ok { - if time.Until(deadline) <= timeout { - return context.WithCancel(parent) - } - } - return context.WithTimeout(parent, timeout) -} - -// ParseRouteControlTag 解析通用控制码返回。 -// 容错策略: -// 1. 允许大小写、属性顺序、额外属性差异; -// 2. nonce 必须精确匹配; -// 3. 兼容旧 action 值(schedule_plan/quick_note)。 -func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) { - text := strings.TrimSpace(raw) - if text == "" { - return nil, fmt.Errorf("route content is empty") - } - - header := routeHeaderRegex.FindStringSubmatch(text) - if len(header) < 3 { - return nil, fmt.Errorf("route header not found: %s", text) - } - - nonce := strings.ToLower(strings.TrimSpace(header[1])) - if nonce != strings.ToLower(strings.TrimSpace(expectedNonce)) { - return nil, fmt.Errorf("route nonce mismatch") - } - - actionText := strings.ToLower(strings.TrimSpace(header[2])) - action := Action(actionText) - switch action { - case ActionQuickNoteCreate, ActionTaskQuery, ActionSchedulePlanCreate, ActionSchedulePlanRefine, ActionChat: - // 合法动作直接通过。 - case ActionQuickNote: - action = ActionQuickNoteCreate - case ActionSchedulePlan: - action = ActionSchedulePlanCreate - default: - return nil, fmt.Errorf("invalid route action: %s", actionText) - } - - reason := "" - reasonMatch := routeReasonRegex.FindStringSubmatch(text) - if len(reasonMatch) >= 2 { - reason = strings.TrimSpace(reasonMatch[1]) - } - - return &ControlDecision{ - Action: action, - Reason: reason, - Raw: text, - }, nil -} - -// DecideQuickNoteRouting 是历史兼容入口。 -// 说明: -// 1. 旧代码只区分“是否进入 quick_note”; -// 2. 新分流中 task_query/schedule_plan_* 都不应进入 quick_note。 -func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision { - decision := DecideActionRouting(ctx, selectedModel, userMessage) - if decision.Action == ActionQuickNoteCreate { - return decision - } - return RoutingDecision{ - Action: ActionChat, - TrustRoute: false, - Detail: "", - RouteFailed: decision.RouteFailed, - } -} - -// ParseQuickNoteRouteControlTag 是历史兼容解析入口。 -// 说明:旧测试仍使用该方法名,内部统一委托 ParseRouteControlTag。 -func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) { - return ParseRouteControlTag(raw, expectedNonce) -} diff --git a/backend/agent/router/route.go b/backend/agent/router/route.go deleted file mode 100644 index 68904de..0000000 --- a/backend/agent/router/route.go +++ /dev/null @@ -1,67 +0,0 @@ -package agentrouter - -import ( - "context" - "errors" - "fmt" -) - -// Dispatcher 是 Agent 的统一分发器。 -type Dispatcher struct { - resolver Resolver - handlers map[Action]SkillHandler -} - -// NewDispatcher 创建统一分发器。 -func NewDispatcher(resolver Resolver) *Dispatcher { - return &Dispatcher{ - resolver: resolver, - handlers: make(map[Action]SkillHandler), - } -} - -// Register 注册某个动作的处理函数。 -func (d *Dispatcher) Register(action Action, handler SkillHandler) error { - if d == nil { - return errors.New("dispatcher is nil") - } - if action == "" { - return errors.New("route action is empty") - } - if handler == nil { - return fmt.Errorf("handler for action %s is nil", action) - } - if _, exists := d.handlers[action]; exists { - return fmt.Errorf("handler for action %s already registered", action) - } - d.handlers[action] = handler - return nil -} - -// Dispatch 执行“分流 -> skill handler”完整入口。 -func (d *Dispatcher) Dispatch(ctx context.Context, req *AgentRequest) (*AgentResponse, error) { - if d == nil || d.resolver == nil { - return nil, errors.New("route dispatcher is not ready") - } - if req == nil { - return nil, errors.New("agent request is nil") - } - - // 1. 调用目的:统一先走一级路由,让入口层只关心“请求来了”, - // 不需要提前知道这是普通聊天、随口记、任务查询还是后续排程。 - decision, err := d.resolver.Resolve(ctx, req) - if err != nil { - return nil, err - } - if decision == nil { - return nil, errors.New("route decision is nil") - } - - // 2. 路由结果出来后,只根据 action 查找对应 handler。 - // 这里故意不做 skill 级 fallback,避免路由层和 skill 内部职责再次缠在一起。 - handler, exists := d.handlers[decision.Action] - if !exists { - return nil, fmt.Errorf("no handler registered for action %s", decision.Action) - } - return handler(ctx, req) -} diff --git a/backend/agent/router/route_model.go b/backend/agent/router/route_model.go deleted file mode 100644 index 8675a01..0000000 --- a/backend/agent/router/route_model.go +++ /dev/null @@ -1,34 +0,0 @@ -package agentrouter - -import ( - "context" -) - -// Resolver 定义一级路由器能力。 -type Resolver interface { - Resolve(ctx context.Context, req *AgentRequest) (*RoutingDecision, error) -} - -// SkillHandler 是某个 skill 的统一执行入口。 -type SkillHandler func(ctx context.Context, req *AgentRequest) (*AgentResponse, error) - -// AgentRequest 是 Agent 路由层可见的最小请求结构。 -// -// 设计目的: -// 1. 让 router 层只依赖自己真正关心的字段; -// 2. 避免把整份 agentmodel 结构在迁移早期层层透传; -// 3. 后续若总入口还要追加别的字段,只需要在入口层做一次映射。 -type AgentRequest struct { - UserID int - ConversationID string - UserMessage string - ModelName string - Extra map[string]any -} - -// AgentResponse 是路由分发器对 skill handler 的统一响应外壳。 -type AgentResponse struct { - Action Action - Reply string - Meta map[string]any -} diff --git a/backend/agent/shared/retry.go b/backend/agent/shared/retry.go deleted file mode 100644 index 0d3e2a7..0000000 --- a/backend/agent/shared/retry.go +++ /dev/null @@ -1,85 +0,0 @@ -package agentshared - -import ( - "context" - "time" -) - -// RetryOptions 描述公共重试策略。 -// -// 职责边界: -// 1. 这里只定义“是否重试、最多几次、间隔多久”; -// 2. 不关心具体业务是工具调用失败、模型 JSON 失败还是 DB 暂时不可用; -// 3. 真正的业务兜底文案仍应由上层 node 决定。 -type RetryOptions struct { - MaxAttempts int - Interval time.Duration - ShouldRetry func(err error) bool - OnRetry func(attempt int, err error) -} - -// Do 执行一个只返回 error 的重试任务。 -// -// 执行规则: -// 1. 第一次执行也算一次 attempt; -// 2. 任意一次成功即立即返回; -// 3. 上下文取消、达到最大次数、或 ShouldRetry=false 时立即停止。 -func Do(ctx context.Context, options RetryOptions, fn func(attempt int) error) error { - _, err := DoValue[struct{}](ctx, options, func(attempt int) (struct{}, error) { - return struct{}{}, fn(attempt) - }) - return err -} - -// DoValue 执行一个带返回值的通用重试任务。 -// -// 设计说明: -// 1. 旧 agent 里后续很多地方都会出现“失败重试 2~3 次”的模式; -// 2. 这里先把循环骨架统一,避免每个 skill 自己写 for + sleep + ctx.Done; -// 3. 上层只需关心“本轮失败要不要继续”,而不是重复造轮子。 -func DoValue[T any](ctx context.Context, options RetryOptions, fn func(attempt int) (T, error)) (T, error) { - var zero T - - maxAttempts := options.MaxAttempts - if maxAttempts <= 0 { - maxAttempts = 1 - } - - for attempt := 1; attempt <= maxAttempts; attempt++ { - if err := ctx.Err(); err != nil { - return zero, err - } - - value, err := fn(attempt) - if err == nil { - return value, nil - } - - // 1. 到最后一次了,直接返回原错误,避免无意义等待。 - if attempt >= maxAttempts { - return zero, err - } - // 2. 业务显式声明“不值得重试”时,立刻停止。 - if options.ShouldRetry != nil && !options.ShouldRetry(err) { - return zero, err - } - // 3. 把重试钩子留给上层,用于打点或阶段提示。 - if options.OnRetry != nil { - options.OnRetry(attempt, err) - } - // 4. 没有配置间隔则马上下一轮;配置了则等待,同时尊重 ctx 取消。 - if options.Interval <= 0 { - continue - } - - timer := time.NewTimer(options.Interval) - select { - case <-ctx.Done(): - timer.Stop() - return zero, ctx.Err() - case <-timer.C: - } - } - - return zero, nil -} diff --git a/backend/agent/shared/time.go b/backend/agent/shared/time.go deleted file mode 100644 index 8c85cff..0000000 --- a/backend/agent/shared/time.go +++ /dev/null @@ -1,49 +0,0 @@ -package agentshared - -import ( - "sync" - "time" -) - -const ( - // MinuteLayout 是 Agent 内部统一的分钟级时间文本格式。 - // - // 设计原因: - // 1. agent 里大量场景只需要精确到分钟; - // 2. 秒级精度会增加提示词噪声,也容易让“同一请求内的当前时间”出现抖动; - // 3. 先统一成一份常量,后续 quicknote / schedule 都直接复用。 - MinuteLayout = "2006-01-02 15:04" -) - -var ( - shanghaiLocOnce sync.Once - shanghaiLoc *time.Location -) - -// ShanghaiLocation 返回 Agent 内部统一使用的东八区时区。 -func ShanghaiLocation() *time.Location { - shanghaiLocOnce.Do(func() { - loc, err := time.LoadLocation("Asia/Shanghai") - if err != nil { - // 兜底使用固定东八区,避免极端环境下因为系统时区文件缺失导致整个链路失败。 - loc = time.FixedZone("CST", 8*3600) - } - shanghaiLoc = loc - }) - return shanghaiLoc -} - -// NowToMinute 返回当前北京时间,并截断到分钟级。 -func NowToMinute() time.Time { - return time.Now().In(ShanghaiLocation()).Truncate(time.Minute) -} - -// NormalizeToMinute 把任意时间统一到北京时间分钟粒度。 -func NormalizeToMinute(t time.Time) time.Time { - return t.In(ShanghaiLocation()).Truncate(time.Minute) -} - -// FormatMinute 把时间格式化为统一分钟级文本。 -func FormatMinute(t time.Time) string { - return NormalizeToMinute(t).Format(MinuteLayout) -} diff --git a/backend/agent/stream/emitter.go b/backend/agent/stream/emitter.go deleted file mode 100644 index 7af6fa9..0000000 --- a/backend/agent/stream/emitter.go +++ /dev/null @@ -1,115 +0,0 @@ -package agentstream - -import ( - "fmt" - "strings" -) - -// PayloadEmitter 是真正向外层 SSE 管道写 chunk 的最小接口。 -// -// 说明: -// 1. 这里刻意不用 chan/string 绑死实现; -// 2. 上层既可以传“写 channel”的函数,也可以传“写 gin stream”的函数; -// 3. 只要签名是 `func(string) error`,都能接进来。 -type PayloadEmitter func(payload string) error - -// StageEmitter 是 graph/node 对“当前阶段”进行推送的最小接口。 -type StageEmitter func(stage, detail string) - -// NoopPayloadEmitter 返回一个空实现,便于骨架期安全占位。 -func NoopPayloadEmitter() PayloadEmitter { - return func(string) error { return nil } -} - -// NoopStageEmitter 返回一个空实现,避免 graph 在没有接前端时处处判空。 -func NoopStageEmitter() StageEmitter { - return func(stage, detail string) {} -} - -// WrapStageEmitter 把可空函数包装成稳定的 StageEmitter。 -func WrapStageEmitter(fn func(stage, detail string)) StageEmitter { - if fn == nil { - return NoopStageEmitter() - } - return fn -} - -// EmitStageAsReasoning 把“阶段提示”伪装成 reasoning chunk 推给前端。 -// -// 设计背景: -// 1. 你当前 Apifox 只认思考块和正文块,因此阶段提示需要先借 reasoning_content 走通; -// 2. 这样后续真正前端上线时,只需要在这一层换协议,而不必回到各 skill 重改 graph; -// 3. 这里不拼花哨格式,只给出稳定、可读、可 grep 的文本。 -func EmitStageAsReasoning(emit PayloadEmitter, requestID, modelName string, created int64, stage, detail string, includeRole bool) error { - if emit == nil { - return nil - } - - text := BuildStageReasoningText(stage, detail) - payload, err := ToOpenAIReasoningChunk(requestID, modelName, created, text, includeRole) - if err != nil { - return err - } - if payload == "" { - return nil - } - return emit(payload) -} - -// EmitAssistantReply 把一段完整正文作为 assistant chunk 推出。 -// -// 注意: -// 1. 这里是“整段发”,不是把文本强行拆碎; -// 2. 这样后续如果某条链路不需要真流式,也可以复用统一出口; -// 3. 真正按 token/chunk 细粒度流式输出,应由 llm.Stream + 上层循环处理。 -func EmitAssistantReply(emit PayloadEmitter, requestID, modelName string, created int64, content string, includeRole bool) error { - if emit == nil { - return nil - } - payload, err := ToOpenAIAssistantChunk(requestID, modelName, created, content, includeRole) - if err != nil { - return err - } - if payload == "" { - return nil - } - return emit(payload) -} - -// EmitFinish 统一输出 stop 结束块。 -func EmitFinish(emit PayloadEmitter, requestID, modelName string, created int64) error { - if emit == nil { - return nil - } - payload, err := ToOpenAIFinishStream(requestID, modelName, created) - if err != nil { - return err - } - if payload == "" { - return nil - } - return emit(payload) -} - -// EmitDone 统一输出 OpenAI 兼容流式结束标记。 -func EmitDone(emit PayloadEmitter) error { - if emit == nil { - return nil - } - return emit("[DONE]") -} - -// BuildStageReasoningText 生成统一阶段提示文本。 -func BuildStageReasoningText(stage, detail string) string { - stage = strings.TrimSpace(stage) - detail = strings.TrimSpace(detail) - - switch { - case stage != "" && detail != "": - return fmt.Sprintf("阶段:%s\n%s", stage, detail) - case stage != "": - return fmt.Sprintf("阶段:%s", stage) - default: - return detail - } -} diff --git a/backend/agent/stream/openai.go b/backend/agent/stream/openai.go deleted file mode 100644 index bb6676a..0000000 --- a/backend/agent/stream/openai.go +++ /dev/null @@ -1,102 +0,0 @@ -package agentstream - -import ( - "encoding/json" - - "github.com/cloudwego/eino/schema" -) - -// OpenAIChunkResponse 是 OpenAI 兼容的流式 chunk DTO。 -// -// 之所以单独放到 Agent/stream: -// 1. 未来无论 quicknote、taskquery 还是 schedule,只要需要 SSE 都会复用这套协议壳; -// 2. 这样 node/graph 层只关注“我要推什么内容”,不再自己拼 JSON; -// 3. 后续如果前端协议升级,也能在这里集中改。 -type OpenAIChunkResponse struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - Model string `json:"model"` - Choices []OpenAIChunkChoice `json:"choices"` -} - -// OpenAIChunkChoice 对应 OpenAI choices[0]。 -type OpenAIChunkChoice struct { - Index int `json:"index"` - Delta OpenAIChunkDelta `json:"delta"` - FinishReason *string `json:"finish_reason"` -} - -// OpenAIChunkDelta 是真正承载 role/content/reasoning 的位置。 -type OpenAIChunkDelta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` -} - -// ToOpenAIStream 把 Eino message 转成 OpenAI 兼容 chunk。 -// -// 职责边界: -// 1. 负责把 chunk.Content / chunk.ReasoningContent 映射到协议字段; -// 2. 负责按 includeRole 决定是否在首块带上 assistant 角色; -// 3. 不负责发送,也不负责决定“这个 chunk 该不该推”。 -func ToOpenAIStream(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool) (string, error) { - delta := OpenAIChunkDelta{} - if includeRole { - delta.Role = "assistant" - } - if chunk != nil { - delta.Content = chunk.Content - delta.ReasoningContent = chunk.ReasoningContent - } - return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil) -} - -// ToOpenAIReasoningChunk 直接构造一个 reasoning chunk。 -func ToOpenAIReasoningChunk(requestID, modelName string, created int64, reasoning string, includeRole bool) (string, error) { - delta := OpenAIChunkDelta{ReasoningContent: reasoning} - if includeRole { - delta.Role = "assistant" - } - return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil) -} - -// ToOpenAIAssistantChunk 直接构造一个正文 chunk。 -func ToOpenAIAssistantChunk(requestID, modelName string, created int64, content string, includeRole bool) (string, error) { - delta := OpenAIChunkDelta{Content: content} - if includeRole { - delta.Role = "assistant" - } - return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil) -} - -// ToOpenAIFinishStream 生成流式结束 chunk(finish_reason=stop)。 -func ToOpenAIFinishStream(requestID, modelName string, created int64) (string, error) { - stop := "stop" - return buildOpenAIChunkPayload(requestID, modelName, created, OpenAIChunkDelta{}, &stop) -} - -func buildOpenAIChunkPayload(requestID, modelName string, created int64, delta OpenAIChunkDelta, finishReason *string) (string, error) { - // 1. 若既没有 role,也没有正文/思考,也没有 finish_reason,则视为“空块”,直接跳过。 - // 2. 这样可以避免上层每次都自己写一遍空块判断。 - if delta.Role == "" && delta.Content == "" && delta.ReasoningContent == "" && finishReason == nil { - return "", nil - } - - dto := OpenAIChunkResponse{ - ID: requestID, - Object: "chat.completion.chunk", - Created: created, - Model: modelName, - Choices: []OpenAIChunkChoice{{ - Index: 0, - Delta: delta, - FinishReason: finishReason, - }}, - } - data, err := json.Marshal(dto) - if err != nil { - return "", err - } - return string(data), nil -} diff --git a/backend/agent/通用能力接入文档.md b/backend/agent/通用能力接入文档.md deleted file mode 100644 index ac2dda0..0000000 --- a/backend/agent/通用能力接入文档.md +++ /dev/null @@ -1,176 +0,0 @@ -# agent 通用能力接入文档 - -## 1. 文档目的 - -本文用于说明 `backend/agent` 目录下“通用能力”的职责边界、放置位置和接入约束,避免后续继续出现“同一类能力复制三份、四份”的情况。 - -这里的“通用能力”特指: - -1. 会被两个及以上能力域复用,或者已经明确会继续扩散的基础能力。 -2. 与具体业务语义弱耦合,抽出来后不会把某个 skill 的 prompt、状态字段、业务规则污染到其它模块。 -3. 抽出后能显著减少样板代码、降低迁移成本,或者统一链路行为。 - -本文不负责描述某个具体 skill 的业务流程。业务流程、状态机、prompt 细节,仍应放在对应能力域自己的文件中。 - -## 2. 当前目录分层 - -```text -backend/agent/ - entrance.go - chat/ - graph/ - llm/ - model/ - node/ - prompt/ - router/ - shared/ - stream/ -``` - -### 2.1 `entrance.go` - -职责: - -1. 作为 `agent` 模块对上层 service 的统一入口。 -2. 负责装配路由器与各能力 handler。 -3. 不负责具体 graph 逻辑、不负责直接调模型、不负责工具执行。 - -### 2.2 `router/` - -职责: - -1. 负责一级分流,把请求映射到具体能力链路。 -2. 维护统一的请求/响应结构和 action 定义。 -3. 不承载具体 skill 的业务判断细节。 - -适合放入这里的能力: - -1. 路由请求结构。 -2. action 解析与分发。 -3. 对上层稳定暴露的最小门面。 - -### 2.3 `graph/` - -职责: - -1. 只负责组图、连线和节点编排。 -2. 文件里应尽量只出现节点挂载、分支和边定义。 -3. 不直接写复杂业务逻辑、不直接调 DAO、不直接拼 prompt。 - -### 2.4 `node/` - -职责: - -1. 承接能力域的核心业务节点实现。 -2. 按“节点逻辑文件 + 工具文件”的双文件格局组织复杂能力域。 -3. 在确实存在多节点复用时,可下沉少量带业务语义的 node 内部公共 helper。 - -当前约定: - -1. `schedule_plan.go` / `schedule_plan_tool.go` 为一组。 -2. `schedule_refine.go` / `schedule_refine_tool.go` 为一组。 -3. `quicknote.go` / `quicknote_tool.go`、`taskquery.go` / `taskquery_tool.go` 同理。 - -补充说明: - -1. `node/tool_common.go` 是 node 层内部通用工具聚合点。 -2. 这里只放“被两个及以上节点复用、但仍带一点节点上下文语义”的 helper。 -3. 如果某个能力已经弱化到与业务无关,应继续下沉到 `shared/`,而不是长期堆在 `tool_common.go`。 - -### 2.5 `llm/` - -职责: - -1. 统一封装模型调用、JSON 解析、推理参数和模型侧协议。 -2. 让上层节点尽量只关心“要什么结果”,不重复实现 SDK 样板代码。 -3. 不承载具体业务状态流转。 - -### 2.6 `model/` - -职责: - -1. 统一放置 agent 内部状态结构、输入输出 DTO、默认预算等模型无关定义。 -2. 不在这里写业务执行逻辑。 - -### 2.7 `prompt/` - -职责: - -1. 维护系统提示词、结构化输出模板、路由提示词等文本资产。 -2. 不在 prompt 文件中写节点控制流和工具编排。 - -### 2.8 `stream/` - -职责: - -1. 统一承接 SSE chunk 包装、阶段推送、OpenAI/Ark 流式适配。 -2. 保证上层 service 不需要重复拼装流协议。 - -### 2.9 `shared/` - -职责: - -1. 放置跨能力域复用的纯工具能力,例如时间、重试、深拷贝等。 -2. 要求业务语义尽量弱、依赖尽量少。 -3. 一旦某类逻辑已经被第二处复用,必须优先评估是否放到这里。 - -## 3. 什么该抽成通用能力 - -满足以下任一条件时,必须优先评估抽公共层: - -1. 同类逻辑已经出现第二份实现。 -2. 不同 skill 的实现只有参数不同,控制流基本一致。 -3. 上层 service 已经开始出现重复胶水代码。 -4. 继续复制会增加迁移、测试或回归排查成本。 - -常见应优先考虑抽取的方向: - -1. 模型调用门面。 -2. JSON 容错解析。 -3. SSE 阶段推送与 chunk 包装。 -4. 深拷贝与快照转换。 -5. 缓存快照读写辅助逻辑。 - -## 4. 什么不该抽成通用能力 - -以下内容默认不应抽到公共层: - -1. 某个 skill 独有的 prompt 片段。 -2. 只服务单一业务的状态字段映射。 -3. 带强业务语义的 ReAct 决策规则。 -4. 只在一个节点里短期使用、且没有第二处复用证据的 helper。 - -判断原则: - -1. 若抽出来后名字仍然需要带明显业务词,通常说明它还不够通用。 -2. 若抽出来会让其它模块被迫理解某个 skill 的内部规则,说明抽取层级过早。 - -## 5. 新增能力时的落点规则 - -1. 纯工具、弱业务语义、跨域复用:优先放 `shared/`。 -2. 只在路由阶段复用:放 `router/`。 -3. 只与模型协议相关:放 `llm/`。 -4. 只与流式输出相关:放 `stream/`。 -5. 只在 node 层内被多个节点复用,且带少量业务上下文:放 `node/tool_common.go` 或同层 helper。 -6. 仍然明显属于某个能力域:留在对应 `node/`、`prompt/`、`model/` 文件中,不要硬抽。 - -## 6. 变更要求 - -后续若在 `backend/agent` 中新增、下沉、替换任何通用能力,必须同步完成以下动作: - -1. 更新本文档,说明新能力放在哪一层、为什么放这里。 -2. 说明是否替代了旧实现,旧实现是否已经删除。 -3. 检查是否还残留第三份及以上重复实现。 -4. 若本轮只是暂时无法抽公共层,必须在代码注释或文档里写明原因。 - -## 7. 当前结构结论 - -截至当前版本,`backend/agent` 已是唯一正式实现目录,`backend/service/agentsvc` 也已与历史旧路径完全解耦。 - -后续重构优先级建议: - -1. 继续收口 node 层内部重复的查询/校验/移动辅助逻辑。 -2. 持续把 service 层里可复用的旁路读写逻辑下沉到更稳定的公共层。 -3. 保持 graph 只做编排、node 只做业务、shared 只做弱语义公共能力,避免重新堆回大杂烩结构。 - diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 9ad4b67..d431fdc 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -6,7 +6,6 @@ import ( "log" "time" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" "github.com/LoveLosita/smartflow/backend/api" "github.com/LoveLosita/smartflow/backend/dao" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" @@ -20,6 +19,7 @@ import ( "github.com/LoveLosita/smartflow/backend/middleware" "github.com/LoveLosita/smartflow/backend/model" newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv" + newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" "github.com/LoveLosita/smartflow/backend/newAgent/tools/web" "github.com/LoveLosita/smartflow/backend/pkg" @@ -201,7 +201,7 @@ func Start() { TaskQuery: newagenttools.TaskQueryDeps{ // 调用目的:桥接新工具参数到旧 service 层查询能力,复用已有的过滤/排序/紧急度提升逻辑。 QueryTasks: func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) { - req := agentnode.TaskQueryRequest{ + req := newagentmodel.TaskQueryRequest{ UserID: userID, Quadrant: params.Quadrant, SortBy: params.SortBy, diff --git a/backend/newAgent/HANDOFF_优化待办.md b/backend/newAgent/HANDOFF_优化待办.md new file mode 100644 index 0000000..80bf58a --- /dev/null +++ b/backend/newAgent/HANDOFF_优化待办.md @@ -0,0 +1,54 @@ +# newAgent 优化待办 Handoff + +> 日期:2026-04-21 +> 来源:迁移 agent/ → newAgent/ 完成后的架构审视 + +--- + +## 1. TaskQuery 紧急度提升统一 + +### 问题 + +LLM 工具查询任务(`AgentService.QueryTasksForTool`)使用 `applyReadTimeUrgencyPromotion` 只做内存态优先级提升,不触发 outbox 写 MySQL。 +前端查询任务(`TaskService.GetUserTasks`)使用 `deriveTaskUrgencyForRead` + `tryEnqueueTaskUrgencyPromote`,会异步持久化。 + +两条路径行为不一致:LLM 看到的优先级可能比 DB 里的高。 + +### 方案 + +1. `service/task.go` — 从 `GetUserTasks` 中提取公共方法(如 `GetTasksWithUrgencyPromotion`),返回已提升的 `[]model.Task` 并触发 outbox +2. `service/agentsvc/agent.go` — 新增 `taskSvc *service.TaskService` 字段 +3. `service/agentsvc/agent_task_query.go` — 重写 `QueryTasksForTool`,调用 TaskService 公共方法;删除 `applyReadTimeUrgencyPromotion` 死代码 +4. `cmd/start.go` — 注入 TaskService 到 AgentService + +### 涉及文件 + +| 文件 | 改动 | +|------|------| +| `service/task.go` | 提取公共方法 | +| `service/agentsvc/agent.go` | 加 taskSvc 字段 | +| `service/agentsvc/agent_task_query.go` | 重写,删 `applyReadTimeUrgencyPromotion` | +| `cmd/start.go` | 注入 TaskService | + +--- + +## 2. service/agentsvc 层瘦身(低优先级) + +### 现状 + +`service/agentsvc/` 目前 11 个文件,大部分是 HTTP→DB 转接层,职责合理。但有两个纯逻辑文件理论上可下沉: + +| 文件 | 内容 | 可移至 | +|------|------|--------| +| `agent_memory_render.go` | 纯文本转换,零 DB 交互 | `memory/` 包 | +| `agent_task_query.go` 的 `taskMatchesQueryFilter` / `sortTasksForQuery` | 纯过滤/排序 | `newAgent/tools/` | + +### 判断 + +当前体量小(加起来约 200 行纯函数),搬出去收益不大,反而多一层 import 间接。如果未来这些函数膨胀再搬不迟。 + +--- + +## 3. go mod tidy + +迁移完成后 `go.mod` 中有未使用的依赖(如 `github.com/bytedance/mockey`)。建议跑一次 `go mod tidy` 清理。 diff --git a/backend/newAgent/model/taskquery_types.go b/backend/newAgent/model/taskquery_types.go new file mode 100644 index 0000000..6d0077f --- /dev/null +++ b/backend/newAgent/model/taskquery_types.go @@ -0,0 +1,26 @@ +package model + +import "time" + +// TaskQueryRequest 是任务查询工具的请求参数。 +type TaskQueryRequest struct { + UserID int + Quadrant *int + SortBy string + Order string + Limit int + IncludeCompleted bool + Keyword string + DeadlineBefore *time.Time + DeadlineAfter *time.Time +} + +// TaskQueryTaskRecord 是任务查询工具返回的单条任务记录。 +type TaskQueryTaskRecord struct { + ID int + Title string + PriorityGroup int + IsCompleted bool + DeadlineAt *time.Time + UrgencyThresholdAt *time.Time +} diff --git a/backend/agent/chat/prompt.go b/backend/newAgent/prompt/system.go similarity index 69% rename from backend/agent/chat/prompt.go rename to backend/newAgent/prompt/system.go index 27d01fd..d32bbc5 100644 --- a/backend/agent/chat/prompt.go +++ b/backend/newAgent/prompt/system.go @@ -1,4 +1,4 @@ -package agentchat +package newagentprompt const ( // SystemPrompt 全局系统人设:定义 SmartMate 的基本调性 @@ -6,6 +6,6 @@ const ( 你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。 你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。 你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。 -如果用户的问题与日程无关,不要因为“不属于排程”就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。 -重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。` +如果用户的问题与日程无关,不要因为"不属于排程"就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。 +重要约束:你无法直接写入数据库。除非系统明确告知"任务已落库成功",否则禁止使用"已安排/已记录/已帮你记下"等完成态表述。` ) diff --git a/backend/agent/shared/clone.go b/backend/newAgent/shared/clone.go similarity index 66% rename from backend/agent/shared/clone.go rename to backend/newAgent/shared/clone.go index 061484a..37a9c12 100644 --- a/backend/agent/shared/clone.go +++ b/backend/newAgent/shared/clone.go @@ -1,13 +1,7 @@ -package agentshared +package newagentshared import "github.com/LoveLosita/smartflow/backend/model" -// CloneWeekSchedules 深拷贝周视图排程结果。 -// -// 职责边界: -// 1. 负责断开 []UserWeekSchedule 与内部 Events 切片的引用共享; -// 2. 负责服务于“缓存 DTO / graph state / API 响应”之间的安全复制; -// 3. 不负责业务过滤,不负责排序。 func CloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { if len(src) == 0 { return nil @@ -25,7 +19,6 @@ func CloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { return dst } -// CloneHybridEntries 深拷贝混合排程条目切片。 func CloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { if len(src) == 0 { return nil @@ -35,12 +28,6 @@ func CloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleE return dst } -// CloneTaskClassItems 深拷贝任务块切片。 -// -// 这里不能直接 copy: -// 1. 因为 TaskClassItem 内部带若干指针字段; -// 2. 如果浅拷贝,后续某一步修改 EmbeddedTime / Status,会污染原状态; -// 3. 排程 graph 连续微调时,这种共享引用会非常难查,所以必须在公共层兜住。 func CloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { if len(src) == 0 { return nil @@ -74,7 +61,6 @@ func CloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { return dst } -// CloneInts 深拷贝 int 切片。 func CloneInts(src []int) []int { if len(src) == 0 { return nil @@ -84,7 +70,6 @@ func CloneInts(src []int) []int { return dst } -// CloneStrings 深拷贝 string 切片。 func CloneStrings(src []string) []string { if len(src) == 0 { return nil diff --git a/backend/newAgent/shared/deadline.go b/backend/newAgent/shared/deadline.go new file mode 100644 index 0000000..2253523 --- /dev/null +++ b/backend/newAgent/shared/deadline.go @@ -0,0 +1,366 @@ +package newagentshared + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + deadlineLayouts = []string{ + time.RFC3339, + "2006-01-02T15:04", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006/01/02 15:04:05", + "2006/01/02 15:04", + "2006.01.02 15:04:05", + "2006.01.02 15:04", + "2006-01-02", + "2006/01/02", + "2006.01.02", + } + deadlineDateOnlyLayouts = map[string]struct{}{ + "2006-01-02": {}, + "2006/01/02": {}, + "2006.01.02": {}, + } + + clockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[::]\s*(\d{1,2})`) + clockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`) + ymdRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`) + mdRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`) + dateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`) + weekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`) + relativeTokens = []string{ + "今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日", + "早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨", + } +) + +// ParseOptionalDeadline 解析工具输入中的可选截止时间。 +func ParseOptionalDeadline(raw string) (*time.Time, error) { + value := normalizeDeadlineInput(raw) + if value == "" { + return nil, nil + } + + deadline, hasHint, err := parseDeadlineFromText(value, NowToMinute()) + if err != nil { + return nil, err + } + if deadline == nil { + if !hasHint { + return nil, fmt.Errorf("deadline_at 格式不支持: %s", value) + } + return nil, fmt.Errorf("deadline_at 无法解析: %s", value) + } + return deadline, nil +} + +// ParseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。 +func ParseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) { + value := normalizeDeadlineInput(raw) + if value == "" { + return nil, nil + } + + deadline, _, err := parseDeadlineFromText(value, now) + if err != nil { + return nil, err + } + if deadline == nil { + return nil, fmt.Errorf("deadline_at 格式不支持: %s", value) + } + return deadline, nil +} + +func parseDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) { + if strings.TrimSpace(value) == "" { + return nil, false, nil + } + + loc := ShanghaiLocation() + now = now.In(loc) + hasHint := hasDeadlineHint(value) + + if abs, ok := tryParseAbsoluteDeadline(value, loc); ok { + return abs, true, nil + } + if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized { + if err != nil { + return nil, true, err + } + return rel, true, nil + } + if hasHint { + return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value) + } + return nil, false, nil +} + +func normalizeDeadlineInput(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + replacer := strings.NewReplacer( + ":", ":", + ",", ",", + "。", ".", + " ", " ", + ) + return strings.TrimSpace(replacer.Replace(trimmed)) +} + +func hasDeadlineHint(value string) bool { + if clockHMRegex.MatchString(value) || + clockCNRegex.MatchString(value) || + ymdRegex.MatchString(value) || + mdRegex.MatchString(value) || + dateSepRegex.MatchString(value) || + weekdayRegex.MatchString(value) { + return true + } + for _, token := range relativeTokens { + if strings.Contains(value, token) { + return true + } + } + return false +} + +func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) { + for _, layout := range deadlineLayouts { + var ( + parsed time.Time + err error + ) + if layout == time.RFC3339 { + parsed, err = time.Parse(layout, value) + if err == nil { + parsed = parsed.In(loc) + } + } else { + parsed, err = time.ParseInLocation(layout, value, loc) + } + if err != nil { + continue + } + + if _, dateOnly := deadlineDateOnlyLayouts[layout]; dateOnly { + parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc) + } else { + parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc) + } + return &parsed, true + } + return nil, false +} + +func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) { + baseDate, recognized := inferBaseDate(value, now, loc) + if !recognized { + return nil, false, nil + } + + hour, minute, hasExplicitClock, err := extractClock(value) + if err != nil { + return nil, true, err + } + if !hasExplicitClock { + hour, minute = defaultClockByHint(value) + } + + deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc) + return &deadline, true, nil +} + +func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) { + if matched := ymdRegex.FindStringSubmatch(value); len(matched) == 4 { + year, _ := strconv.Atoi(matched[1]) + month, _ := strconv.Atoi(matched[2]) + day, _ := strconv.Atoi(matched[3]) + if isValidDate(year, month, day) { + return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true + } + } + + if matched := mdRegex.FindStringSubmatch(value); len(matched) == 3 { + month, _ := strconv.Atoi(matched[1]) + day, _ := strconv.Atoi(matched[2]) + year := now.Year() + if !isValidDate(year, month, day) { + return time.Time{}, false + } + candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) + if candidate.Before(startOfDay(now)) { + year++ + if !isValidDate(year, month, day) { + return time.Time{}, false + } + candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) + } + return candidate, true + } + + if matched := weekdayRegex.FindStringSubmatch(value); len(matched) == 3 { + prefix := matched[1] + target, ok := toWeekday(matched[2]) + if ok { + return resolveWeekdayDate(now, prefix, target), true + } + } + + today := startOfDay(now) + switch { + case strings.Contains(value, "大后天"): + return today.AddDate(0, 0, 3), true + case strings.Contains(value, "后天"): + return today.AddDate(0, 0, 2), true + case strings.Contains(value, "明天") || strings.Contains(value, "明日"): + return today.AddDate(0, 0, 1), true + case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"): + return today, true + case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"): + return today.AddDate(0, 0, -1), true + default: + return time.Time{}, false + } +} + +func extractClock(value string) (int, int, bool, error) { + hour := 0 + minute := 0 + hasClock := false + + if matched := clockHMRegex.FindStringSubmatch(value); len(matched) == 3 { + h, errH := strconv.Atoi(matched[1]) + m, errM := strconv.Atoi(matched[2]) + if errH != nil || errM != nil { + return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) + } + hour = h + minute = m + hasClock = true + } else if matched := clockCNRegex.FindStringSubmatch(value); len(matched) >= 2 { + h, errH := strconv.Atoi(matched[1]) + if errH != nil { + return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) + } + hour = h + minute = 0 + hasClock = true + if len(matched) >= 3 { + if matched[2] == "半" { + minute = 30 + } else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" { + m, errM := strconv.Atoi(strings.TrimSpace(matched[3])) + if errM != nil { + return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) + } + minute = m + } + } + } + + if !hasClock { + return 0, 0, false, nil + } + + if isPMHint(value) && hour < 12 { + hour += 12 + } + if isNoonHint(value) && hour >= 1 && hour <= 10 { + hour += 12 + } + if strings.Contains(value, "凌晨") && hour == 12 { + hour = 0 + } + + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value) + } + return hour, minute, true, nil +} + +func defaultClockByHint(value string) (int, int) { + switch { + case strings.Contains(value, "凌晨"): + return 1, 0 + case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"): + return 9, 0 + case strings.Contains(value, "中午"): + return 12, 0 + case strings.Contains(value, "下午"): + return 15, 0 + case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"): + return 20, 0 + default: + return 23, 59 + } +} + +func isPMHint(value string) bool { + return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") +} + +func isNoonHint(value string) bool { + return strings.Contains(value, "中午") +} + +func startOfDay(t time.Time) time.Time { + loc := t.Location() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) +} + +func isValidDate(year, month, day int) bool { + if month < 1 || month > 12 || day < 1 || day > 31 { + return false + } + candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) + return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day +} + +func toWeekday(chinese string) (time.Weekday, bool) { + switch chinese { + case "一": + return time.Monday, true + case "二": + return time.Tuesday, true + case "三": + return time.Wednesday, true + case "四": + return time.Thursday, true + case "五": + return time.Friday, true + case "六": + return time.Saturday, true + case "日", "天": + return time.Sunday, true + default: + return time.Sunday, false + } +} + +func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time { + today := startOfDay(now) + weekdayOffset := (int(today.Weekday()) + 6) % 7 + weekStart := today.AddDate(0, 0, -weekdayOffset) + targetOffset := (int(target) + 6) % 7 + candidateThisWeek := weekStart.AddDate(0, 0, targetOffset) + + switch { + case strings.HasPrefix(prefix, "下"): + return candidateThisWeek.AddDate(0, 0, 7) + case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"): + return candidateThisWeek + default: + if candidateThisWeek.Before(today) { + return candidateThisWeek.AddDate(0, 0, 7) + } + return candidateThisWeek + } +} diff --git a/backend/agent/model/task_priority.go b/backend/newAgent/shared/task_priority.go similarity index 57% rename from backend/agent/model/task_priority.go rename to backend/newAgent/shared/task_priority.go index fc40802..3f849e5 100644 --- a/backend/agent/model/task_priority.go +++ b/backend/newAgent/shared/task_priority.go @@ -1,4 +1,4 @@ -package agentmodel +package newagentshared const ( TaskPriorityImportantUrgent = 1 @@ -7,20 +7,18 @@ const ( TaskPriorityComplexNotImportant = 4 ) -// IsValidTaskPriority 用于校验任务优先级是否合法。 -// -// 职责边界: -// 1. 只负责判断 priority 是否落在系统支持的 1~4 范围内。 -// 2. 不负责把自然语言映射成优先级,也不负责做业务兜底推断。 +// QuickNote 优先级别名,保持与旧 agent/model 命名兼容。 +const ( + QuickNotePriorityImportantUrgent = TaskPriorityImportantUrgent + QuickNotePriorityImportantNotUrgent = TaskPriorityImportantNotUrgent + QuickNotePrioritySimpleNotImportant = TaskPrioritySimpleNotImportant + QuickNotePriorityComplexNotImportant = TaskPriorityComplexNotImportant +) + func IsValidTaskPriority(priority int) bool { return priority >= TaskPriorityImportantUrgent && priority <= TaskPriorityComplexNotImportant } -// PriorityLabelCN 返回任务优先级对应的中文标签。 -// -// 职责边界: -// 1. 只负责“优先级枚举 -> 中文展示文案”的稳定映射。 -// 2. 不负责国际化、多语言切换或业务规则解释。 func PriorityLabelCN(priority int) string { switch priority { case TaskPriorityImportantUrgent: diff --git a/backend/newAgent/stream/usage.go b/backend/newAgent/stream/usage.go new file mode 100644 index 0000000..856c96e --- /dev/null +++ b/backend/newAgent/stream/usage.go @@ -0,0 +1,41 @@ +package newagentstream + +import "github.com/cloudwego/eino/schema" + +// CloneUsage 深拷贝 TokenUsage。 +func CloneUsage(usage *schema.TokenUsage) *schema.TokenUsage { + if usage == nil { + return nil + } + copied := *usage + return &copied +} + +// MergeUsage 合并两段 usage,取更大值。 +// 适用于同一次调用不同流分片的 usage 收敛。 +func MergeUsage(base *schema.TokenUsage, incoming *schema.TokenUsage) *schema.TokenUsage { + if incoming == nil { + return CloneUsage(base) + } + if base == nil { + return CloneUsage(incoming) + } + + merged := *base + if incoming.PromptTokens > merged.PromptTokens { + merged.PromptTokens = incoming.PromptTokens + } + if incoming.CompletionTokens > merged.CompletionTokens { + merged.CompletionTokens = incoming.CompletionTokens + } + if incoming.TotalTokens > merged.TotalTokens { + merged.TotalTokens = incoming.TotalTokens + } + if incoming.PromptTokenDetails.CachedTokens > merged.PromptTokenDetails.CachedTokens { + merged.PromptTokenDetails.CachedTokens = incoming.PromptTokenDetails.CachedTokens + } + if incoming.CompletionTokensDetails.ReasoningTokens > merged.CompletionTokensDetails.ReasoningTokens { + merged.CompletionTokensDetails.ReasoningTokens = incoming.CompletionTokensDetails.ReasoningTokens + } + return &merged +} diff --git a/backend/newAgent/tools/quicknote.go b/backend/newAgent/tools/quicknote.go index ada4959..f93b72c 100644 --- a/backend/newAgent/tools/quicknote.go +++ b/backend/newAgent/tools/quicknote.go @@ -6,8 +6,7 @@ import ( "strings" "time" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" + newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared" "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" ) @@ -40,11 +39,11 @@ type QuickNoteCreateResult struct { func quickNoteFallbackPriority(deadline *time.Time) int { if deadline != nil { if time.Until(*deadline) <= 48*time.Hour { - return agentmodel.QuickNotePriorityImportantUrgent + return newagentshared.QuickNotePriorityImportantUrgent } - return agentmodel.QuickNotePriorityImportantNotUrgent + return newagentshared.QuickNotePriorityImportantNotUrgent } - return agentmodel.QuickNotePrioritySimpleNotImportant + return newagentshared.QuickNotePrioritySimpleNotImportant } // NewQuickNoteToolHandler 创建 quick_note_create 工具的 handler 闭包。 @@ -81,7 +80,7 @@ func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler { raw = strings.TrimSpace(raw) if raw != "" { // 调用目的:复用旧链路成熟的中文相对时间解析器,支持"明天下午3点"等格式。 - parsed, err := agentnode.ParseOptionalDeadline(raw) + parsed, err := newagentshared.ParseOptionalDeadline(raw) if err != nil { return fmt.Sprintf("工具调用失败:截止时间格式无法解析(%s)。支持格式:2026-04-20 18:00、明天下午3点、下周一上午9点。", err) } @@ -94,7 +93,7 @@ func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler { if pg, ok := args["priority_group"].(float64); ok { priorityGroup = int(pg) } - if !agentmodel.IsValidTaskPriority(priorityGroup) { + if !newagentshared.IsValidTaskPriority(priorityGroup) { priorityGroup = quickNoteFallbackPriority(deadline) } @@ -108,10 +107,10 @@ func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler { } // 6. 组装结构化返回,包含 banter 提示引导 LLM 自然生成调侃。 - priorityLabel := agentmodel.PriorityLabelCN(priorityGroup) + priorityLabel := newagentshared.PriorityLabelCN(priorityGroup) deadlineStr := "" if deadline != nil { - deadlineStr = deadline.In(agentnode.QuickNoteLocation()).Format("2006-01-02 15:04") + deadlineStr = deadline.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04") } result := QuickNoteCreateResult{ diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index 2368c4b..5cd2f6c 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -8,7 +8,6 @@ import ( "strings" "time" - agentchat "github.com/LoveLosita/smartflow/backend/agent/chat" "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/dao" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" @@ -17,6 +16,7 @@ import ( memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe" "github.com/LoveLosita/smartflow/backend/model" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" + newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt" newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" "github.com/LoveLosita/smartflow/backend/pkg" eventsvc "github.com/LoveLosita/smartflow/backend/service/events" @@ -318,7 +318,7 @@ func (s *AgentService) runNormalChatFlow( // 3. 计算本次请求可用的历史 token 预算,并执行历史裁剪。 // 这样可以在上下文增长时稳定控制模型窗口,避免超长上下文引发报错或高延迟。 - historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, agentchat.SystemPrompt, userMessage) + historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, newagentprompt.SystemPrompt, userMessage) trimmedHistory, totalHistoryTokens, keptHistoryTokens, droppedCount := pkg.TrimHistoryByTokenBudget(chatHistory, historyBudget) chatHistory = trimmedHistory @@ -346,7 +346,7 @@ func (s *AgentService) runNormalChatFlow( // 6. 执行真正的流式聊天。 // fullText 用于后续写 Redis/持久化,outChan 用于把流片段实时推给前端。 - fullText, reasoningText, reasoningDurationSeconds, streamUsage, streamErr := agentchat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart, assistantReasoningStartedAt) + fullText, reasoningText, reasoningDurationSeconds, streamUsage, streamErr := s.streamChatFallback(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, assistantReasoningStartedAt) if streamErr != nil { pushErrNonBlocking(errChan, streamErr) return diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go index 541f3d5..a00281e 100644 --- a/backend/service/agentsvc/agent_newagent.go +++ b/backend/service/agentsvc/agent_newagent.go @@ -18,9 +18,9 @@ import ( "github.com/cloudwego/eino/schema" "github.com/spf13/viper" - agentchat "github.com/LoveLosita/smartflow/backend/agent/chat" "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/model" + newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt" "github.com/LoveLosita/smartflow/backend/pkg" "github.com/LoveLosita/smartflow/backend/respond" eventsvc "github.com/LoveLosita/smartflow/backend/service/events" @@ -393,7 +393,7 @@ func (s *AgentService) loadConversationContext(ctx context.Context, chatID, user } // 构造 ConversationContext。 - conversationContext := newagentmodel.NewConversationContext(agentchat.SystemPrompt) + conversationContext := newagentmodel.NewConversationContext(newagentprompt.SystemPrompt) if history != nil { conversationContext.ReplaceHistory(history) } diff --git a/backend/service/agentsvc/agent_quick_note.go b/backend/service/agentsvc/agent_quick_note.go deleted file mode 100644 index 6eaf0c1..0000000 --- a/backend/service/agentsvc/agent_quick_note.go +++ /dev/null @@ -1,303 +0,0 @@ -package agentsvc - -import ( - "context" - "fmt" - "log" - "strings" - "time" - - agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph" - agentllm "github.com/LoveLosita/smartflow/backend/agent/llm" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" - agentrouter "github.com/LoveLosita/smartflow/backend/agent/router" - agentstream "github.com/LoveLosita/smartflow/backend/agent/stream" - "github.com/LoveLosita/smartflow/backend/model" - "github.com/cloudwego/eino-ext/components/model/ark" - "github.com/google/uuid" -) - -// quickNoteRoutingDecision 只是路由层结果的本地别名。 -// 保留这个别名是为了尽量少改调用侧(agent.go 中的字段访问保持不变)。 -type quickNoteRoutingDecision = agentrouter.RoutingDecision - -// quickNoteProgressEmitter 负责把“链路阶段状态”伪装成 OpenAI 兼容的 reasoning_content chunk。 -// 设计目标: -// 1) 不改现有 OpenAI 兼容协议外壳; -// 2) 让 Apifox 在等待期间也能看到“思考块”,避免用户空等; -// 3) 该 emitter 只负责状态,不负责最终正文回复和 [DONE] 结束块。 -type quickNoteProgressEmitter struct { - outChan chan<- string - modelName string - requestID string - created int64 - enablePush bool - reasoning strings.Builder - startedAt *time.Time -} - -// newQuickNoteProgressEmitter 构造“阶段进度推送器”。 -// 该推送器只负责发 reasoning 块,不负责正文回复。 -func newQuickNoteProgressEmitter(outChan chan<- string, modelName string, enable bool) *quickNoteProgressEmitter { - // 1. 模型名兜底,避免出现空 model 字段导致客户端兼容性问题。 - resolvedModel := strings.TrimSpace(modelName) - if resolvedModel == "" { - resolvedModel = "worker" - } - // 2. 每次请求生成独立 request_id,方便前端或日志侧关联本次流式输出。 - return &quickNoteProgressEmitter{ - outChan: outChan, - modelName: resolvedModel, - requestID: "chatcmpl-" + uuid.NewString(), - created: time.Now().Unix(), - enablePush: enable, - } -} - -// Emit 按“阶段 + 说明”输出 reasoning_content。 -// 注意: -// 1) 这里不输出 role,避免和后续正文 role 块冲突; -// 2) 即使发送失败,也只记录日志,不影响主流程继续执行。 -func (e *quickNoteProgressEmitter) Emit(stage, detail string) { - // 1. 推送器不可用(nil/禁用/无通道)时直接返回,避免 panic。 - if e == nil || !e.enablePush || e.outChan == nil { - return - } - // 2. 统一清理空白,避免日志和输出里出现异常空字符串。 - stage = strings.TrimSpace(stage) - detail = strings.TrimSpace(detail) - if stage == "" && detail == "" { - return - } - if e.startedAt == nil { - now := time.Now() - e.startedAt = &now - } - if e.reasoning.Len() > 0 { - e.reasoning.WriteString("\n\n") - } - if stage != "" { - e.reasoning.WriteString("阶段:") - e.reasoning.WriteString(stage) - } - if detail != "" { - if stage != "" { - e.reasoning.WriteString("\n") - } - e.reasoning.WriteString(detail) - } - - // 3. 调用目的:阶段提示统一走 Agent/stream 的 reasoning chunk 包装, - // 避免 service 层继续自己拼 OpenAI 兼容 JSON。 - err := agentstream.EmitStageAsReasoning(func(payload string) error { - e.outChan <- payload - return nil - }, e.requestID, e.modelName, e.created, stage, detail, false) - if err != nil { - // 3.1 阶段推送失败不应影响主链路,只打日志即可。 - log.Printf("输出随口记阶段状态失败 stage=%s err=%v", stage, err) - return - } -} - -func (e *quickNoteProgressEmitter) HistoryText() string { - if e == nil { - return "" - } - return strings.TrimSpace(e.reasoning.String()) -} - -func (e *quickNoteProgressEmitter) StartedAt() *time.Time { - if e == nil || e.startedAt == nil { - return nil - } - startCopy := *e.startedAt - return &startCopy -} - -func (e *quickNoteProgressEmitter) DurationSeconds(end time.Time) int { - if e == nil || e.startedAt == nil { - return 0 - } - if !end.After(*e.startedAt) { - return 0 - } - return int(end.Sub(*e.startedAt) / time.Second) -} - -// tryHandleQuickNoteWithGraph 尝试用“随口记 graph”处理本次用户输入。 -// 返回值语义: -// 1) handled=true:本次请求已在随口记链路处理完成(成功/失败都会返回文案); -// 2) handled=false:不是随口记意图,调用方应回落普通聊天链路; -// 3) state:用于拼接最终“一次性正文回复”。 -func (s *AgentService) tryHandleQuickNoteWithGraph( - ctx context.Context, - selectedModel *ark.ChatModel, - userMessage string, - userID int, - chatID string, - traceID string, - trustRoute bool, - emitStage func(stage, detail string), -) (handled bool, state *agentmodel.QuickNoteState, err error) { - // 1. 依赖预检:taskRepo 或模型未注入时,不做随口记处理,交给上层回落聊天。 - if s.taskRepo == nil || selectedModel == nil { - return false, nil, nil - } - - // 2. 初始化随口记状态对象(贯穿 graph 全流程的共享上下文)。 - state = agentmodel.NewQuickNoteState(traceID, userID, chatID, userMessage) - - // 3. 执行 quick note graph。 - // 本次依赖注入了两个“工具能力”: - // 3.1 ResolveUserID:从当前请求上下文确定 user_id; - // 3.2 CreateTask:真正执行任务写库。 - finalState, runErr := agentgraph.RunQuickNoteGraph(ctx, agentnode.QuickNoteGraphRunInput{ - Model: selectedModel, - State: state, - Deps: agentnode.QuickNoteToolDeps{ - ResolveUserID: func(ctx context.Context) (int, error) { - // 当前链路 userID 已由上层鉴权拿到,这里直接复用。 - return userID, nil - }, - CreateTask: func(ctx context.Context, req agentnode.QuickNoteCreateTaskRequest) (*agentnode.QuickNoteCreateTaskResult, error) { - // 3.2.1 把 quick note 的工具入参映射成项目 Task 模型。 - taskModel := &model.Task{ - UserID: req.UserID, - Title: req.Title, - Priority: req.PriorityGroup, - IsCompleted: false, - DeadlineAt: req.DeadlineAt, - UrgencyThresholdAt: req.UrgencyThresholdAt, - } - - // 3.2.2 调用 DAO 写库。 - created, createErr := s.taskRepo.AddTask(taskModel) - if createErr != nil { - return nil, createErr - } - - // 3.2.3 把写库结果回填给 graph 状态,用于后续回复拼装。 - return &agentnode.QuickNoteCreateTaskResult{ - TaskID: created.ID, - Title: created.Title, - PriorityGroup: created.Priority, - DeadlineAt: created.DeadlineAt, - UrgencyThresholdAt: created.UrgencyThresholdAt, - }, nil - }, - }, - SkipIntentVerification: trustRoute, - EmitStage: emitStage, - }) - if runErr != nil { - // 4. graph 执行失败由上层统一决定是否回退普通聊天。 - return false, nil, runErr - } - - // 5. graph 正常结束但判定“非随口记”时,明确返回 handled=false。 - if finalState == nil || !finalState.IsQuickNoteIntent { - return false, nil, nil - } - // 6. 走到这里表示随口记链路已完成(含写库成功或业务失败反馈文案)。 - return true, finalState, nil -} - -// emitSingleAssistantCompletion 将单条完整回复包装成 OpenAI 兼容 chunk 流并写入 outChan。 -// 说明: -// 1) 保持现有 OpenAI 兼容格式不变; -// 2) 正文只发一次,不做伪分段。 -func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply string) error { - // 1. 模型名兜底,保持 OpenAI 兼容响应字段完整。 - if strings.TrimSpace(modelName) == "" { - modelName = "worker" - } - requestID := "chatcmpl-" + uuid.NewString() - created := time.Now().Unix() - - emit := func(payload string) error { - outChan <- payload - return nil - } - if err := agentstream.EmitAssistantReply(emit, requestID, modelName, created, reply, true); err != nil { - return err - } - if err := agentstream.EmitFinish(emit, requestID, modelName, created); err != nil { - return err - } - return agentstream.EmitDone(emit) -} - -// buildQuickNoteFinalReply 生成最终的一次性正文回复。 -// 组合策略: -// 1) 任务事实(标题/优先级/截止时间)由后端拼接,确保准确; -// 2) 轻松跟进句交给 AI 生成,贴合用户话题; -// 3) AI 生成失败时自动降级为固定友好文案,保证稳定可用。 -func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *agentmodel.QuickNoteState) string { - // 1. 极端兜底:状态为空时给出稳定失败文案,避免返回空字符串。 - if state == nil { - return "我这次没成功记上,别急,再发我一次我马上补上。" - } - - // 仅当“确实拿到了有效 task_id”时才走成功文案,避免出现“回复成功但库里没数据”的错觉。 - if state.Persisted && state.PersistedTaskID > 0 { - // 2. 组装“事实段”:标题 + 优先级 + 截止时间。 - title := strings.TrimSpace(state.ExtractedTitle) - if title == "" { - title = "这条任务" - } - - priorityText := "已安排优先级" - if agentmodel.IsValidTaskPriority(state.ExtractedPriority) { - priorityText = fmt.Sprintf("优先级:%s", agentmodel.PriorityLabelCN(state.ExtractedPriority)) - } - - deadlineText := "" - if state.ExtractedDeadline != nil { - deadlineText = fmt.Sprintf(";截止时间 %s", state.ExtractedDeadline.In(time.Local).Format("2006-01-02 15:04")) - } - - factLine := fmt.Sprintf("好,给你安排上了:%s(%s%s)。", title, priorityText, deadlineText) - - // 2.1 如果 graph 单次请求已生成 banter,直接使用,避免重复调用模型。 - if strings.TrimSpace(state.ExtractedBanter) != "" { - return factLine + " " + strings.TrimSpace(state.ExtractedBanter) - } - // 2.2 聚合调用模式下,通常已在主流程完成风格化,给稳定文案即可。 - if state.PlannedBySingleCall { - return factLine + " 已帮你稳稳记下,放心推进。" - } - - // 2.3 兜底生成轻松跟进句;失败则降级固定文案,确保体验连续。 - banter, err := agentllm.GenerateQuickNoteBanter(ctx, selectedModel, userMessage, title, priorityText, deadlineText) - if err != nil { - return factLine + " 这下可以先安心推进,不用等 ddl 来敲门了。" - } - if strings.TrimSpace(banter) == "" { - return factLine + " 这下可以先安心推进,不用等 ddl 来敲门了。" - } - return factLine + " " + banter - } - - // 3. 若时间校验失败,优先返回“可执行的修正引导”。 - if strings.TrimSpace(state.DeadlineValidationError) != "" { - return "我识别到你给了时间,但格式不够明确,暂时不敢乱记。你可以改成比如:2026-03-20 18:30、明天下午3点、下周一上午9点,我立刻帮你安排。" - } - - // 4. 若 graph 已给出助手回复(例如非意图/业务失败原因),优先透传。 - if strings.TrimSpace(state.AssistantReply) != "" { - return strings.TrimSpace(state.AssistantReply) - } - // 5. 最终兜底文案。 - return "这次没成功写入任务,我没跑路,再给我一次我就把它稳稳记上。" -} - -// decideQuickNoteRouting 决定当前输入是否进入“随口记 graph”。 -// 该函数只是服务层薄封装,具体控制码解析逻辑已下沉到 Agent/router 包。 -func (s *AgentService) decideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) quickNoteRoutingDecision { - // 这里保留方法是为了让 AgentService 对外语义完整, - // 同时避免上层调用方直接依赖 Agent/router,降低耦合。 - _ = s - return agentrouter.DecideQuickNoteRouting(ctx, selectedModel, userMessage) -} diff --git a/backend/service/agentsvc/agent_route.go b/backend/service/agentsvc/agent_route.go deleted file mode 100644 index 16b0f8c..0000000 --- a/backend/service/agentsvc/agent_route.go +++ /dev/null @@ -1,27 +0,0 @@ -package agentsvc - -import ( - "context" - - agentrouter "github.com/LoveLosita/smartflow/backend/agent/router" - "github.com/cloudwego/eino-ext/components/model/ark" -) - -// actionRoutingDecision 是 route 层分流结果在 agentsvc 的本地别名。 -// -// 设计目的: -// 1. 让 AgentService 对 route 包保持“最小接触面”; -// 2. 后续若 route 包返回结构调整,只需改这个桥接文件。 -type actionRoutingDecision = agentrouter.RoutingDecision - -// decideActionRouting 决定当前请求走向哪条业务链路。 -// -// 职责边界: -// 1. 只负责调用 route 包拿分流结论; -// 2. 不负责执行任何业务节点; -// 3. route 层失败会通过 RoutingDecision.RouteFailed 向上层显式暴露。 -func (s *AgentService) decideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) actionRoutingDecision { - // 这里保留方法封装,是为了避免上层直接依赖 route 包,降低耦合。 - _ = s - return agentrouter.DecideActionRouting(ctx, selectedModel, userMessage) -} diff --git a/backend/service/agentsvc/agent_schedule_plan.go b/backend/service/agentsvc/agent_schedule_plan.go deleted file mode 100644 index 929a085..0000000 --- a/backend/service/agentsvc/agent_schedule_plan.go +++ /dev/null @@ -1,162 +0,0 @@ -package agentsvc - -import ( - "context" - "errors" - "log" - "strings" - - agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" - "github.com/LoveLosita/smartflow/backend/conv" - "github.com/LoveLosita/smartflow/backend/model" - "github.com/LoveLosita/smartflow/backend/pkg" - "github.com/cloudwego/eino-ext/components/model/ark" - "github.com/cloudwego/eino/schema" - "github.com/spf13/viper" -) - -// runSchedulePlanFlow 执行“智能排程”分支。 -// -// 职责边界: -// 1. 负责把本次请求接入 scheduleplan graph,并注入运行依赖。 -// 2. 负责读取对话历史(优先 Redis,未命中再回源 DB)用于连续对话微调。 -// 3. 负责把排程预览快照写入 Redis(供查询接口拉取 JSON)。 -// 4. 负责返回给上层“可直接发给用户的最终文本回复”。 -// 5. 不负责聊天持久化(由 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. 依赖预检:缺硬依赖时直接失败,避免进入 graph 后才出现空指针或半途失败。 - // 1.1 SmartPlanningMultiRaw / HybridScheduleWithPlanMulti / ResolvePlanningWindow 任一缺失都无法继续。 - // 1.2 selectedModel 为空时无法执行 LLM 节点,直接返回错误由上层处理。 - if s.SmartPlanningMultiRawFunc == nil || s.HybridScheduleWithPlanMultiFunc == nil || s.ResolvePlanningWindowFunc == nil { - return "", errors.New("schedule plan service dependencies are not ready") - } - if selectedModel == nil { - return "", errors.New("schedule plan model is nil") - } - - // 2. 连续对话微调前置处理:先尝试读取“上一版预览快照”,再清理旧 key。 - // 2.1 先读后删的原因: - // 2.1.1 若先删再读,会丢失“连续微调起点”; - // 2.1.2 先读可让本轮在内存中复用上轮 HybridEntries。 - // 2.2 清理旧 key 仍然保留,避免前端在本轮进行中误读到旧结果。 - var previousPreview *model.SchedulePlanPreviewCache - if s.cacheDAO != nil { - preview, getErr := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, chatID) - if getErr != nil { - log.Printf("读取上一版排程预览失败 chat_id=%s: %v", chatID, getErr) - } else { - previousPreview = preview - } - if delErr := s.cacheDAO.DeleteSchedulePlanPreviewFromCache(ctx, userID, chatID); delErr != nil { - log.Printf("清理旧排程预览失败 chat_id=%s: %v", chatID, delErr) - } - } - // 2.3 Redis miss 时回落 MySQL 快照: - // 2.3.1 目的:即使 Redis TTL 过期,也能延续同会话微调语境; - // 2.3.2 回填:命中 DB 后尝试回填 Redis,提高后续读取命中率; - // 2.3.3 失败策略:DB 读取异常只打日志,链路继续按“无历史快照”执行。 - if previousPreview == nil && s.repo != nil { - snapshot, snapshotErr := s.repo.GetScheduleStateSnapshot(ctx, userID, chatID) - if snapshotErr != nil { - log.Printf("从 MySQL 读取排程快照失败 chat_id=%s: %v", chatID, snapshotErr) - } else if snapshot != nil { - previousPreview = snapshotToSchedulePlanPreviewCache(snapshot) - if s.cacheDAO != nil && previousPreview != nil { - if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, chatID, previousPreview); setErr != nil { - log.Printf("回填排程预览缓存失败 chat_id=%s: %v", chatID, setErr) - } - } - } - } - - // 3. 读取对话历史:先快后稳。 - // 3.1 先查 Redis,命中则避免回源 DB,降低请求时延。 - // 3.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 - } - } - - // 3.3 Redis 未命中时回源 DB,保证链路在缓存波动时仍可用。 - // 3.4 DB 回源失败同样只记日志并继续,让 graph 按“无历史”降级运行。 - if chatHistory == nil && s.repo != nil { - histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), chatID) - if hisErr != nil { - log.Printf("回源 DB 获取排程对话历史失败 chat_id=%s: %v", chatID, hisErr) - } else { - chatHistory = conv.ToEinoMessages(histories) - } - } - - // 4. 执行 graph 主流程。 - // 4.1 这里只负责参数拼装与调用,不在 service 层重复实现 graph 节点逻辑。 - // 4.2 并发度/预算从配置注入,避免把调优参数写死在代码中。 - state := agentmodel.NewSchedulePlanState(traceID, userID, chatID) - // 4.3 连续对话微调注入: - // 4.3.1 若命中上轮预览,则把任务类/混合条目/分配结果注入 state; - // 4.3.2 这样 rough_build 可按需复用旧底板,避免每轮都重新粗排。 - if previousPreview != nil { - state.HasPreviousPreview = true - state.PreviousTaskClassIDs = append([]int(nil), previousPreview.TaskClassIDs...) - state.PreviousHybridEntries = cloneHybridEntries(previousPreview.HybridEntries) - state.PreviousAllocatedItems = cloneTaskClassItems(previousPreview.AllocatedItems) - state.PreviousCandidatePlans = cloneWeekSchedules(previousPreview.CandidatePlans) - } - finalState, runErr := agentgraph.RunSchedulePlanGraph(ctx, agentnode.SchedulePlanGraphRunInput{ - Model: selectedModel, - State: state, - Deps: agentnode.SchedulePlanToolDeps{ - SmartPlanningMultiRaw: s.SmartPlanningMultiRawFunc, - HybridScheduleWithPlanMulti: s.HybridScheduleWithPlanMultiFunc, - ResolvePlanningWindow: s.ResolvePlanningWindowFunc, - }, - UserMessage: userMessage, - Extra: extra, - ChatHistory: chatHistory, - EmitStage: emitStage, - OutChan: outChan, - ModelName: modelName, - DailyRefineConcurrency: viper.GetInt("agent.dailyRefineConcurrency"), - WeeklyAdjustBudget: viper.GetInt("agent.weeklyAdjustBudget"), - }) - if runErr != nil { - // 4.3 graph 失败直接上抛,由上层决定回落或报错。 - return "", runErr - } - - // 5. 组装最终回复文本。 - // 5.1 明确移除“把排程结果序列化成 JSON 文本直接回传”的抽象, - // 避免在 SSE 聊天链路里吐出原始 JSON,影响前端展示与用户体验。 - // 5.2 当 finalState 为空或 summary 为空时,返回统一兜底文案,保证接口有稳定输出。 - if finalState == nil { - return "排程流程异常,请稍后重试。", nil - } - reply := strings.TrimSpace(finalState.FinalSummary) - if reply == "" { - reply = "排程流程已完成,但未生成结果摘要。" - } - - // 6. 旁路写入排程预览缓存(结构化 JSON),给查询接口拉取。 - // 6.1 失败只记日志,不影响本次对话回复; - // 6.2 成功后前端可通过 conversation_id 获取 candidate_plans。 - s.saveSchedulePlanPreview(ctx, userID, chatID, finalState) - return reply, nil -} diff --git a/backend/service/agentsvc/agent_schedule_preview.go b/backend/service/agentsvc/agent_schedule_preview.go index 7d78da5..21c5fb7 100644 --- a/backend/service/agentsvc/agent_schedule_preview.go +++ b/backend/service/agentsvc/agent_schedule_preview.go @@ -7,63 +7,11 @@ import ( "strings" "time" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentshared "github.com/LoveLosita/smartflow/backend/agent/shared" "github.com/LoveLosita/smartflow/backend/model" + newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared" "github.com/LoveLosita/smartflow/backend/respond" ) -// saveSchedulePlanPreview 负责把排程结果同步写入“查询预览”所需的缓存与快照。 -// -// 职责边界: -// 1. 负责把 graph 最终状态映射为统一预览 DTO,并先写 Redis、再写 MySQL 快照。 -// 2. 负责执行“失败不阻断主回复”的旁路持久化策略,避免影响聊天主链路。 -// 3. 不负责 SSE 输出,不负责聊天消息落库,也不负责 refine 状态到 plan 状态的转换。 -func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) { - // 1. 先做最小前置校验,避免把空状态或空会话写成脏快照。 - if s == nil || finalState == nil { - return - } - normalizedChatID := strings.TrimSpace(chatID) - if normalizedChatID == "" { - return - } - - // 2. 组装统一预览缓存结构。 - // 2.1 summary 为空时使用统一兜底文案,保证查询接口始终有稳定输出。 - // 2.2 所有切片字段都做深拷贝,避免缓存与 graph state 共享底层数组。 - preview := &model.SchedulePlanPreviewCache{ - UserID: userID, - ConversationID: normalizedChatID, - TraceID: strings.TrimSpace(finalState.TraceID), - Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(finalState.FinalSummary)), - CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans), - TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...), - HybridEntries: cloneHybridEntries(finalState.HybridEntries), - AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems), - GeneratedAt: time.Now(), - } - - // 3. 先写 Redis 预览,保证前端查询链路优先命中低时延缓存。 - // 3.1 Redis 写失败只记日志,不中断主流程; - // 3.2 真正兜底由后续 MySQL 快照承担。 - if s.cacheDAO != nil { - if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil { - log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err) - } - } - - // 4. 再写 MySQL 快照,保证缓存失效后仍能恢复预览与连续微调上下文。 - // 4.1 这里继续采用“同步写快照”的策略,因为下一轮 refine 依赖强一致读取; - // 4.2 写库失败同样只记日志,避免让用户侧回复因为旁路持久化失败而中断。 - if s.repo != nil { - snapshot := buildSchedulePlanSnapshotFromState(userID, normalizedChatID, finalState) - if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil { - log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err) - } - } -} - // GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。 // // 职责边界: @@ -81,8 +29,6 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c } // 2. 优先查 Redis。 - // 2.1 命中后立即校验 user_id,避免把别人的会话预览泄露给当前用户; - // 2.2 缓存异常直接上抛,由接口层统一处理错误响应。 if s.cacheDAO != nil { preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID) if err != nil { @@ -92,7 +38,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c if preview.UserID > 0 && preview.UserID != userID { return nil, respond.SchedulePlanPreviewNotFound } - plans := cloneWeekSchedules(preview.CandidatePlans) + plans := newagentshared.CloneWeekSchedules(preview.CandidatePlans) if plans == nil { plans = make([]model.UserWeekSchedule, 0) } @@ -101,7 +47,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c TraceID: strings.TrimSpace(preview.TraceID), Summary: strings.TrimSpace(preview.Summary), CandidatePlans: plans, - HybridEntries: cloneHybridEntries(preview.HybridEntries), + HybridEntries: newagentshared.CloneHybridEntries(preview.HybridEntries), TaskClassIDs: preview.TaskClassIDs, GeneratedAt: preview.GeneratedAt, }, nil @@ -109,8 +55,6 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c } // 3. Redis 未命中时回源 MySQL。 - // 3.1 命中快照后顺手回填 Redis,提高后续命中率; - // 3.2 DB 未命中才真正返回 not found,避免缓存过期造成假阴性。 if s.repo != nil { snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID) if err != nil { @@ -131,49 +75,6 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c return nil, respond.SchedulePlanPreviewNotFound } -// cloneWeekSchedules 负责深拷贝周视图排程,避免缓存与运行态共享底层切片。 -func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { - return agentshared.CloneWeekSchedules(src) -} - -// cloneHybridEntries 负责深拷贝混合排程条目,避免跨请求污染。 -func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { - return agentshared.CloneHybridEntries(src) -} - -// cloneTaskClassItems 负责深拷贝任务项切片,包含内部指针字段的安全复制。 -func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { - return agentshared.CloneTaskClassItems(src) -} - -// buildSchedulePlanSnapshotFromState 把 graph 最终状态映射成可持久化的快照 DTO。 -// -// 职责边界: -// 1. 负责字段归一化、深拷贝和 state_version 补齐。 -// 2. 不负责数据库写入,也不负责生成业务摘要文案。 -func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot { - if st == nil { - return nil - } - return &model.SchedulePlanStateSnapshot{ - UserID: userID, - ConversationID: conversationID, - StateVersion: model.SchedulePlanStateVersionV1, - TaskClassIDs: append([]int(nil), st.TaskClassIDs...), - Constraints: append([]string(nil), st.Constraints...), - HybridEntries: cloneHybridEntries(st.HybridEntries), - AllocatedItems: cloneTaskClassItems(st.AllocatedItems), - CandidatePlans: cloneWeekSchedules(st.CandidatePlans), - UserIntent: strings.TrimSpace(st.UserIntent), - Strategy: strings.TrimSpace(st.Strategy), - AdjustmentScope: strings.TrimSpace(st.AdjustmentScope), - RestartRequested: st.RestartRequested, - FinalSummary: strings.TrimSpace(st.FinalSummary), - Completed: st.Completed, - TraceID: strings.TrimSpace(st.TraceID), - } -} - // snapshotToSchedulePlanPreviewCache 把 MySQL 快照映射成 Redis 预览缓存结构。 func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache { if snapshot == nil { @@ -188,10 +89,10 @@ func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapsho ConversationID: snapshot.ConversationID, TraceID: strings.TrimSpace(snapshot.TraceID), Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)), - CandidatePlans: cloneWeekSchedules(snapshot.CandidatePlans), + CandidatePlans: newagentshared.CloneWeekSchedules(snapshot.CandidatePlans), TaskClassIDs: append([]int(nil), snapshot.TaskClassIDs...), - HybridEntries: cloneHybridEntries(snapshot.HybridEntries), - AllocatedItems: cloneTaskClassItems(snapshot.AllocatedItems), + HybridEntries: newagentshared.CloneHybridEntries(snapshot.HybridEntries), + AllocatedItems: newagentshared.CloneTaskClassItems(snapshot.AllocatedItems), GeneratedAt: generatedAt, } } @@ -201,7 +102,7 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap if snapshot == nil { return nil } - plans := cloneWeekSchedules(snapshot.CandidatePlans) + plans := newagentshared.CloneWeekSchedules(snapshot.CandidatePlans) if plans == nil { plans = make([]model.UserWeekSchedule, 0) } @@ -214,7 +115,7 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap TraceID: strings.TrimSpace(snapshot.TraceID), Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)), CandidatePlans: plans, - HybridEntries: cloneHybridEntries(snapshot.HybridEntries), + HybridEntries: newagentshared.CloneHybridEntries(snapshot.HybridEntries), TaskClassIDs: snapshot.TaskClassIDs, GeneratedAt: generatedAt, } diff --git a/backend/service/agentsvc/agent_schedule_refine.go b/backend/service/agentsvc/agent_schedule_refine.go deleted file mode 100644 index aa468c4..0000000 --- a/backend/service/agentsvc/agent_schedule_refine.go +++ /dev/null @@ -1,175 +0,0 @@ -package agentsvc - -import ( - "context" - "errors" - "log" - "strings" - - agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" - "github.com/LoveLosita/smartflow/backend/model" - "github.com/LoveLosita/smartflow/backend/respond" - "github.com/cloudwego/eino-ext/components/model/ark" -) - -// runScheduleRefineFlow 鎵ц鈥滆繛缁璇濆井璋冩帓绋嬧€濆垎鏀€? -// -// 鑱岃矗杈圭晫锛? -// 1. 璐熻矗璇诲彇鈥滀笂涓€鐗堟帓绋嬮瑙堝揩鐓р€濓紙浼樺厛 Redis锛岀己澶卞啀鍥炴簮 MySQL锛夛紱 -// 2. 璐熻矗璋冪敤鐙珛 schedulerefine 鍥鹃摼璺畬鎴愭湰杞井璋冿紱 -// 3. 璐熻矗鎶婂井璋冪粨鏋滃洖鍐欓瑙堢紦瀛樹笌鐘舵€佸揩鐓э紝渚涘悗缁户缁井璋冿紱 -// 4. 涓嶈礋璐h亰澶╂秷鎭寔涔呭寲锛堟秷鎭寔涔呭寲鐢?AgentChat 涓婚摼璺粺涓€澶勭悊锛夈€? -func (s *AgentService) runScheduleRefineFlow( - ctx context.Context, - selectedModel *ark.ChatModel, - userMessage string, - userID int, - chatID string, - traceID string, - emitStage func(stage, detail string), - outChan chan<- string, - modelName string, -) (string, error) { - _ = outChan - _ = modelName - - // 1. 渚濊禆棰勬锛氭ā鍨嬩负绌烘椂鏃犳硶鎵ц浠讳綍鑺傜偣锛岀洿鎺ュけ璐ラ伩鍏嶇┖鎸囬拡銆? - if selectedModel == nil { - return "", errors.New("schedule refine model is nil") - } - - emitStage("schedule_refine.context.loading", "正在加载上一版排程上下文。") - - // 2. 鍏堟煡 Redis 棰勮蹇収锛屼繚璇佺儹璺緞浣庡欢杩熴€? - // 2.1 濡傛灉 Redis 鏈懡涓紝鍐嶅洖婧?MySQL 蹇収鍏滃簳锛? - // 2.2 濡傛灉涓よ€呴兘娌℃湁锛岃鏄庡綋鍓嶄細璇濇病鏈夊彲寰皟鍩虹锛岀洿鎺ヨ繑鍥炰笟鍔¢敊璇€? - preview := s.loadSchedulePreviewContext(ctx, userID, chatID) - if preview == nil { - return "", respond.SchedulePlanPreviewNotFound - } - - // 3. 鍒濆鍖栧井璋冪姸鎬佸苟杩愯鐙珛鍥俱€? - state := agentnode.NewScheduleRefineState(traceID, userID, chatID, userMessage, preview) - finalState, runErr := agentgraph.RunScheduleRefineGraph(ctx, agentnode.ScheduleRefineGraphRunInput{ - Model: selectedModel, - State: state, - EmitStage: emitStage, - }) - if runErr != nil { - return "", runErr - } - if finalState == nil { - return "", errors.New("schedule refine graph returned nil state") - } - - // 4. 璋冪敤鐩殑锛? - // 4.1 saveSchedulePlanPreview 鐩墠鏄€滈瑙堢紦瀛?+ MySQL 蹇収鈥濈殑缁熶竴鍐欏叆鍙o紱 - // 4.2 杩欓噷鎶?refine state 鏄犲皠涓?scheduleplan state锛屽鐢ㄥ凡鏈夎惤鐩橀摼璺紱 - // 4.3 浣嗚嫢鏄€滅嫭绔嬪鍚堝垎鏀凡鍑虹珯銆佺粓瀹′粛澶辫触鈥濓紝鍒欎笉瑕嗙洊涓婁竴鐗堥瑙堬紝閬垮厤澶栭儴璇互涓烘柊鏂规宸查獙璇侀€氳繃銆? - if shouldPersistScheduleRefinePreview(finalState) { - s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState)) - } else { - emitStage("schedule_refine.preview.skipped", "复合分支终审未通过,本轮结果不覆盖上一版预览。") - } - - reply := strings.TrimSpace(finalState.FinalSummary) - if reply == "" { - reply = "微调已完成,但本轮未生成总结文案。" - } - return reply, nil -} - -// loadSchedulePreviewContext 璇诲彇鈥滃彲鐢ㄤ簬杩炵画寰皟鈥濈殑鎺掔▼涓婁笅鏂囧揩鐓с€? -// -// 姝ラ鍖栬鏄庯細 -// 1. 鍏堟煡 Redis锛氬懡涓垯鐩存帴杩斿洖锛屾椂寤舵渶灏忥紱 -// 2. Redis miss 鍐嶆煡 MySQL锛氫繚璇佺紦瀛樿繃鏈熷悗浠嶅彲缁х画寰皟锛? -// 3. 鑻?MySQL 鍛戒腑涓?Redis 鍙敤锛岄『渚垮洖濉?Redis锛屾彁鍗囧悗缁懡涓巼锛? -// 4. 浠讳竴姝ュけ璐ヤ粎鎵撴棩蹇楋紝涓?panic锛岀敱涓婂眰鏍规嵁杩斿洖 nil 鍋氱粺涓€澶勭悊銆? -func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID int, chatID string) *model.SchedulePlanPreviewCache { - normalizedChatID := strings.TrimSpace(chatID) - if normalizedChatID == "" || userID <= 0 { - return nil - } - - if s.cacheDAO != nil { - preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID) - if err != nil { - log.Printf("璇诲彇鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err) - } else if preview != nil { - return preview - } - } - - if s.repo == nil { - return nil - } - snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID) - if err != nil { - log.Printf("璇诲彇鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err) - return nil - } - if snapshot == nil { - return nil - } - - preview := snapshotToSchedulePlanPreviewCache(snapshot) - if preview != nil && s.cacheDAO != nil { - if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); setErr != nil { - log.Printf("鍥炲~鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, setErr) - } - } - return preview -} - -// convertRefineStateToPlanState 鎶?schedulerefine 鐘舵€佹槧灏勪负 scheduleplan 鐘舵€併€? -// -// 璁捐鎰忓浘锛? -// 1. 澶嶇敤鐜版湁 saveSchedulePlanPreview 鍐欏叆閾捐矾锛屽噺灏戦噸澶嶈惤鐩樹唬鐮侊紱 -// 2. 浠呮槧灏勨€滈瑙堟寔涔呭寲蹇呴』瀛楁鈥濓紝閬垮厤鎶?refine 杩愯鏈熶复鏃跺瓧娈靛甫鍏ュ瓨鍌ㄥ眰锛? -// 3. 鍚庣画濡傝鎵╁睍 refine 涓撳睘蹇収瀛楁锛屽彲鍦ㄨ鏄犲皠澶勯泦涓紨杩涖€? -func convertRefineStateToPlanState(st *agentnode.ScheduleRefineState) *agentmodel.SchedulePlanState { - if st == nil { - return nil - } - adjustmentScope := "medium" - if st.Contract.Strategy == "keep" { - adjustmentScope = "small" - } - return &agentmodel.SchedulePlanState{ - TraceID: strings.TrimSpace(st.TraceID), - UserID: st.UserID, - ConversationID: strings.TrimSpace(st.ConversationID), - UserIntent: strings.TrimSpace(st.UserIntent), - Constraints: append([]string(nil), st.Constraints...), - TaskClassIDs: append([]int(nil), st.TaskClassIDs...), - Strategy: "steady", - AdjustmentScope: adjustmentScope, - IsAdjustment: true, - - HybridEntries: append([]model.HybridScheduleEntry(nil), st.HybridEntries...), - AllocatedItems: cloneTaskClassItems(st.AllocatedItems), - CandidatePlans: cloneWeekSchedules(st.CandidatePlans), - - FinalSummary: strings.TrimSpace(st.FinalSummary), - Completed: st.Completed, - } -} - -// shouldPersistScheduleRefinePreview 鍒ゆ柇鈥滄湰杞井璋冪粨鏋滄槸鍚﹀簲瑕嗙洊涓婁竴鐗堥瑙堚€濄€? -// -// 鑱岃矗杈圭晫锛? -// 1. 榛樿娌跨敤鍘熸湁 refine 鎸佷箙鍖栫瓥鐣ワ紝淇濊瘉鏅€?ReAct 寰皟閾捐矾涓嶅彈褰卞搷锛? -// 2. 浠呭綋鈥滅嫭绔嬪鍚堝垎鏀凡鐩存帴鍑虹珯锛屼絾缁堝鏈€氳繃鈥濇椂锛屾嫆缁濊鐩栦笂涓€鐗堥瑙堬紱 -// 3. 杩欐牱鍙互閬垮厤澶栧眰鎶婃湭缁忛獙璇佺殑澶嶅悎缁撴灉褰撴垚鏂扮殑鍩虹嚎缁х画婊氬姩寰皟銆? -func shouldPersistScheduleRefinePreview(st *agentnode.ScheduleRefineState) bool { - if st == nil { - return false - } - if st.CompositeRouteSucceeded && !agentnode.FinalHardCheckPassed(st) { - return false - } - return true -} diff --git a/backend/service/agentsvc/agent_schedule_state.go b/backend/service/agentsvc/agent_schedule_state.go index 2b00dfb..55a0523 100644 --- a/backend/service/agentsvc/agent_schedule_state.go +++ b/backend/service/agentsvc/agent_schedule_state.go @@ -10,6 +10,7 @@ import ( "github.com/LoveLosita/smartflow/backend/model" newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" + newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared" "github.com/LoveLosita/smartflow/backend/respond" ) @@ -115,10 +116,10 @@ func (s *AgentService) refreshSchedulePreviewAfterStateSave( if existingPreview != nil { preview.TraceID = strings.TrimSpace(existingPreview.TraceID) if len(existingPreview.CandidatePlans) > 0 { - preview.CandidatePlans = cloneWeekSchedules(existingPreview.CandidatePlans) + preview.CandidatePlans = newagentshared.CloneWeekSchedules(existingPreview.CandidatePlans) } if len(existingPreview.AllocatedItems) > 0 { - preview.AllocatedItems = cloneTaskClassItems(existingPreview.AllocatedItems) + preview.AllocatedItems = newagentshared.CloneTaskClassItems(existingPreview.AllocatedItems) } if len(preview.TaskClassIDs) == 0 && len(existingPreview.TaskClassIDs) > 0 { preview.TaskClassIDs = append([]int(nil), existingPreview.TaskClassIDs...) diff --git a/backend/agent/chat/stream.go b/backend/service/agentsvc/agent_stream_fallback.go similarity index 54% rename from backend/agent/chat/stream.go rename to backend/service/agentsvc/agent_stream_fallback.go index ed0da80..01c9171 100644 --- a/backend/agent/chat/stream.go +++ b/backend/service/agentsvc/agent_stream_fallback.go @@ -1,4 +1,4 @@ -package agentchat +package agentsvc import ( "context" @@ -6,19 +6,17 @@ import ( "strings" "time" - agentllm "github.com/LoveLosita/smartflow/backend/agent/llm" - agentstream "github.com/LoveLosita/smartflow/backend/agent/stream" + newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt" + newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream" "github.com/cloudwego/eino-ext/components/model/ark" "github.com/cloudwego/eino/schema" "github.com/google/uuid" arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" ) -// StreamChat 负责模型流式输出,并在关键节点打点: -// 1) 流连接建立(llm.Stream 返回) -// 2) 首包到达(首字延迟) -// 3) 流式输出结束 -func StreamChat( +// streamChatFallback 是 graph 执行失败时的降级流式聊天。 +// 内联了旧 agentchat.StreamChat 的核心逻辑,不再依赖 agent/ 包。 +func (s *AgentService) streamChatFallback( ctx context.Context, llm *ark.ChatModel, modelName string, @@ -26,15 +24,10 @@ func StreamChat( ifThinking bool, chatHistory []*schema.Message, outChan chan<- string, - traceID string, - chatID string, - requestStart time.Time, reasoningStartAt *time.Time, ) (string, string, int, *schema.TokenUsage, error) { - /*callStart := time.Now()*/ - - messages := make([]*schema.Message, 0) - messages = append(messages, schema.SystemMessage(SystemPrompt)) + messages := make([]*schema.Message, 0, len(chatHistory)+2) + messages = append(messages, schema.SystemMessage(newagentprompt.SystemPrompt)) if len(chatHistory) > 0 { messages = append(messages, chatHistory...) } @@ -47,52 +40,40 @@ func StreamChat( thinking = &arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled} } - /*connectStart := time.Now()*/ - reader, err := llm.Stream(ctx, messages, ark.WithThinking(thinking)) - if err != nil { - return "", "", 0, nil, err - } - defer reader.Close() - if strings.TrimSpace(modelName) == "" { modelName = "smartflow-worker" } requestID := "chatcmpl-" + uuid.NewString() created := time.Now().Unix() firstChunk := true - chunkCount := 0 - var tokenUsage *schema.TokenUsage + var localReasoningStartAt *time.Time if reasoningStartAt != nil && !reasoningStartAt.IsZero() { startCopy := reasoningStartAt.In(time.Local) localReasoningStartAt = &startCopy } var reasoningEndAt *time.Time - /*streamRecvStart := time.Now() - log.Printf("打点|流连接建立|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d|history_len=%d", - traceID, - chatID, - requestID, - time.Since(connectStart).Milliseconds(), - time.Since(requestStart).Milliseconds(), - len(chatHistory), - )*/ + reader, err := llm.Stream(ctx, messages, ark.WithThinking(thinking)) + if err != nil { + return "", "", 0, nil, err + } + defer reader.Close() var fullText strings.Builder var reasoningText strings.Builder + var tokenUsage *schema.TokenUsage for { - chunk, err := reader.Recv() - if err == io.EOF { + chunk, recvErr := reader.Recv() + if recvErr == io.EOF { break } - if err != nil { - return "", "", 0, nil, err + if recvErr != nil { + return "", "", 0, nil, recvErr } - // 优先记录模型真实 usage(通常在尾块返回,部分模型也可能中途返回)。 if chunk != nil && chunk.ResponseMeta != nil && chunk.ResponseMeta.Usage != nil { - tokenUsage = agentllm.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage) + tokenUsage = newagentstream.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage) } if chunk != nil { @@ -108,44 +89,23 @@ func StreamChat( reasoningText.WriteString(chunk.ReasoningContent) } - payload, err := agentstream.ToOpenAIStream(chunk, requestID, modelName, created, firstChunk) - if err != nil { - return "", "", 0, nil, err + payload, payloadErr := newagentstream.ToOpenAIStream(chunk, requestID, modelName, created, firstChunk) + if payloadErr != nil { + return "", "", 0, nil, payloadErr } if payload != "" { outChan <- payload - chunkCount++ firstChunk = false - /*if firstChunk { - log.Printf("打点|首包到达|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d", - traceID, - chatID, - requestID, - time.Since(streamRecvStart).Milliseconds(), - time.Since(requestStart).Milliseconds(), - ) - firstChunk = false - }*/ } } - finishChunk, err := agentstream.ToOpenAIFinishStream(requestID, modelName, created) - if err != nil { - return "", "", 0, nil, err + finishChunk, finishErr := newagentstream.ToOpenAIFinishStream(requestID, modelName, created) + if finishErr != nil { + return "", "", 0, nil, finishErr } outChan <- finishChunk outChan <- "[DONE]" - /*log.Printf("打点|流式输出结束|trace_id=%s|chat_id=%s|request_id=%s|chunks=%d|reply_chars=%d|本步耗时_ms=%d|请求累计_ms=%d", - traceID, - chatID, - requestID, - chunkCount, - len(fullText.String()), - time.Since(callStart).Milliseconds(), - time.Since(requestStart).Milliseconds(), - )*/ - reasoningDurationSeconds := 0 if localReasoningStartAt != nil { if reasoningEndAt == nil { diff --git a/backend/service/agentsvc/agent_task_query.go b/backend/service/agentsvc/agent_task_query.go index d979b95..be8f36e 100644 --- a/backend/service/agentsvc/agent_task_query.go +++ b/backend/service/agentsvc/agent_task_query.go @@ -7,44 +7,12 @@ import ( "strings" "time" - agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph" - agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" - agentnode "github.com/LoveLosita/smartflow/backend/agent/node" "github.com/LoveLosita/smartflow/backend/model" + newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" "github.com/LoveLosita/smartflow/backend/respond" - "github.com/cloudwego/eino-ext/components/model/ark" ) -func (s *AgentService) runTaskQueryFlow( - ctx context.Context, - selectedModel *ark.ChatModel, - userMessage string, - userID int, - emitStage func(stage, detail string), -) (string, error) { - if s == nil || s.taskRepo == nil { - return "", errors.New("task query service dependency is not ready") - } - if selectedModel == nil { - return "", errors.New("task query model is nil") - } - - requestNow := time.Now().In(time.Local).Format("2006-01-02 15:04") - state := agentmodel.NewTaskQueryState(strings.TrimSpace(userMessage), requestNow, agentmodel.DefaultTaskQueryReflectRetry) - return agentgraph.RunTaskQueryGraph(ctx, agentnode.TaskQueryGraphRunInput{ - Model: selectedModel, - State: state, - EmitStage: emitStage, - Deps: agentnode.TaskQueryToolDeps{ - QueryTasks: func(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) { - req.UserID = userID - return s.QueryTasksForTool(ctx, req) - }, - }, - }) -} - -func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) { +func (s *AgentService) QueryTasksForTool(ctx context.Context, req newagentmodel.TaskQueryRequest) ([]newagentmodel.TaskQueryTaskRecord, error) { _ = ctx if req.UserID <= 0 { return nil, errors.New("invalid user_id in task query") @@ -56,7 +24,7 @@ func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentnode.Task tasks, err := s.taskRepo.GetTasksByUserID(req.UserID) if err != nil { if errors.Is(err, respond.UserTasksEmpty) { - return make([]agentnode.TaskQueryTaskRecord, 0), nil + return make([]newagentmodel.TaskQueryTaskRecord, 0), nil } return nil, err } @@ -77,9 +45,9 @@ func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentnode.Task filtered = filtered[:req.Limit] } - records := make([]agentnode.TaskQueryTaskRecord, 0, len(filtered)) + records := make([]newagentmodel.TaskQueryTaskRecord, 0, len(filtered)) for _, task := range filtered { - records = append(records, agentnode.TaskQueryTaskRecord{ + records = append(records, newagentmodel.TaskQueryTaskRecord{ ID: task.ID, Title: task.Title, PriorityGroup: task.Priority, @@ -106,7 +74,7 @@ func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) { } } -func taskMatchesQueryFilter(task model.Task, req agentnode.TaskQueryRequest) bool { +func taskMatchesQueryFilter(task model.Task, req newagentmodel.TaskQueryRequest) bool { if !req.IncludeCompleted && task.IsCompleted { return false } @@ -130,7 +98,7 @@ func taskMatchesQueryFilter(task model.Task, req agentnode.TaskQueryRequest) boo return true } -func sortTasksForQuery(tasks []model.Task, req agentnode.TaskQueryRequest) { +func sortTasksForQuery(tasks []model.Task, req newagentmodel.TaskQueryRequest) { if len(tasks) <= 1 { return } diff --git a/frontend/src/components/assistant/ScheduleFineTuneModal.vue b/frontend/src/components/assistant/ScheduleFineTuneModal.vue index 6db3267..d61bb64 100644 --- a/frontend/src/components/assistant/ScheduleFineTuneModal.vue +++ b/frontend/src/components/assistant/ScheduleFineTuneModal.vue @@ -126,7 +126,7 @@ async function handleOfficialSave() { }) const promises = Array.from(groups.entries()).map(([classId, groupItems]) => - applyBatchIntoSchedule(classId, groupItems, officialSaveIdempotencyKey.value) + applyBatchIntoSchedule(classId, groupItems, `${officialSaveIdempotencyKey.value}-${classId}`) ) await Promise.all(promises)