From 525a8b32cbef41f3b63584706b900d43e8db801f Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Sun, 22 Mar 2026 22:38:51 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.7.3.dev.260322=20=E2=99=BB?= =?UTF-8?q?=EF=B8=8F=20refactor(schedule-refine):=20[WIP]=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20Plan-and-Execute=20ReAct=20=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=A2=9E=E5=BC=BA=20JSON=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=85=9C=E5=BA=95=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🧩 重构 `schedulerefine` 主流程,引入 `Planner` / `Replan` 机制,以及执行预算与轮次状态管理 - 🧠 扩展状态与观察上下文,补充工具结果、失败签名、连续失败计数与后置反思策略等信息 - 🔧 增强工具层能力与参数兼容性,补齐 `Query` / `Move` / `Swap` / `BatchMove` / `Verify` 等行为及约束校验 - 🛡️ 提升解析鲁棒性,支持从代码块或混杂文本中提取首个 JSON 对象,并增加单次解析重试机制 - 👀 增强可观测性,补充 `debug raw` 阶段输出与分片透传能力 - ✍️ 优化提示词近端约束,将严格 JSON 输出协议追加到各节点 `userPrompt` 末尾 - 🚧 备注:当前链路仍处于持续调优阶段,稳定性与可用性仍需进一步验证 --- backend/agent/route/route.go | 165 +- backend/agent/route/route_test.go | 45 + backend/agent/scheduleplan/tools_react.go | 8 +- backend/agent/schedulerefine/graph.go | 93 + backend/agent/schedulerefine/nodes.go | 1690 +++++++++++++++++ backend/agent/schedulerefine/prompt.go | 190 ++ backend/agent/schedulerefine/runner.go | 41 + backend/agent/schedulerefine/state.go | 308 +++ backend/agent/schedulerefine/tool.go | 1187 ++++++++++++ backend/respond/respond.go | 5 + backend/service/agentsvc/agent.go | 23 +- .../service/agentsvc/agent_schedule_refine.go | 154 ++ 12 files changed, 3809 insertions(+), 100 deletions(-) create mode 100644 backend/agent/route/route_test.go create mode 100644 backend/agent/schedulerefine/graph.go create mode 100644 backend/agent/schedulerefine/nodes.go create mode 100644 backend/agent/schedulerefine/prompt.go create mode 100644 backend/agent/schedulerefine/runner.go create mode 100644 backend/agent/schedulerefine/state.go create mode 100644 backend/agent/schedulerefine/tool.go create mode 100644 backend/service/agentsvc/agent_schedule_refine.go diff --git a/backend/agent/route/route.go b/backend/agent/route/route.go index ae0aa99..dddf61b 100644 --- a/backend/agent/route/route.go +++ b/backend/agent/route/route.go @@ -16,45 +16,47 @@ import ( ) const ( - // ControlTimeout 是“模型控制码分流”这一步的额外超时预算。 - // - // 约束说明: - // 1. 设为 0 代表完全继承父 ctx 的 deadline,不额外截断; - // 2. 若后续线上观测到分流偶发超时,可再加一个小预算(例如 2s)做隔离。 + // ControlTimeout 表示“路由控制码”阶段的额外超时预算。 + // 说明: + // 1. 设为 0 表示完全继承父 ctx 的 deadline,不额外截断。 + // 2. 若后续观察到路由阶段偶发超时,可按需配置一个小预算(例如 2s)。 ControlTimeout = 0 * time.Second ) var ( // routeHeaderRegex 用于解析控制码头部。 - // // 支持动作: - // 1. quick_note_create:新增随口记任务; - // 2. task_query:查询任务; - // 3. schedule_plan:智能排程(生成/微调排程计划); - // 4. chat:普通聊天; - // 5. quick_note:历史兼容别名,解析后会映射到 quick_note_create。 - routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|schedule_plan|quick_note|chat)["']?[^>]*>`) - // routeReasonRegex 用于提取可选的理由块,方便日志排障。 + // 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 = `你是 SmartFlow 的请求分流控制器。 -你的唯一任务是给后端返回”可机读控制码”,不要做用户可见回复,不要解释。 +你的唯一任务是给后端返回“可机读控制码”,不要做用户可见回复,不要解释。 动作定义: -1) quick_note_create:用户明确希望”记录/安排/提醒某件未来要做的事”。 -2) task_query:用户想”查看/筛选/排序/获取”已有任务(如最紧急、按DDL、某象限、关键词)。 -3) schedule_plan:用户想”生成/调整/微调日程排程”(如”帮我排个学习计划”、”把早八的课调走”、”我不想周末学习”)。 -4) chat:其余全部普通对话(包括闲聊、知识问答、纯讨论”怎么安排任务”但未要求你真的去操作)。 +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。 -4) 其他情况选 chat。 +优先级(冲突时按顺序): +1) quick_note_create +2) task_query +3) schedule_plan_refine +4) schedule_plan_create +5) chat 输出格式必须严格如下(两行): - + 一句不超过30字的中文理由 禁止输出任何其他内容。` @@ -63,16 +65,20 @@ const routeControlPrompt = `你是 SmartFlow 的请求分流控制器。 type Action string const ( - ActionChat Action = "chat" - ActionQuickNoteCreate Action = "quick_note_create" - ActionTaskQuery Action = "task_query" - ActionSchedulePlan Action = "schedule_plan" + ActionChat Action = "chat" + ActionQuickNoteCreate Action = "quick_note_create" + ActionTaskQuery Action = "task_query" + ActionSchedulePlanCreate Action = "schedule_plan_create" + ActionSchedulePlanRefine Action = "schedule_plan_refine" - // ActionQuickNote 是历史兼容别名,只用于解析旧 action 值。 + // ActionSchedulePlan 是历史兼容动作值。 + // 说明:旧模型可能返回 schedule_plan,解析后统一映射到 schedule_plan_create。 + ActionSchedulePlan Action = "schedule_plan" + // ActionQuickNote 是历史兼容动作值,解析后统一映射到 quick_note_create。 ActionQuickNote Action = "quick_note" ) -// ControlDecision 是“模型控制码解析结果”。 +// ControlDecision 表示“模型控制码解析结果”。 type ControlDecision struct { Action Action Reason string @@ -80,34 +86,26 @@ type ControlDecision struct { } // RoutingDecision 是服务层使用的统一分流结果。 -// // 职责边界: -// 1. Action:最终动作(chat/quick_note_create/task_query); -// 2. TrustRoute:是否允许下游跳过二次意图判定; +// 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 标记“控制码路由是否失败”。 - // - // 语义: - // 1. true:路由阶段发生异常(模型调用失败、控制码解析失败等); - // 2. false:路由阶段正常完成(无论最终 action 是 chat 还是其它分支)。 - // - // 说明: - // 1. 该字段用于让上层决定“是否直接报错而不是回落聊天”; - // 2. 历史行为是失败回落 chat,本字段用于支持新的“失败即报错”策略。 + Action Action + TrustRoute bool + Detail string RouteFailed bool } // DecideActionRouting 通过“模型控制码”决定本次请求走向。 -// // 返回语义: -// 1. Action=quick_note_create:进入随口记写入图; -// 2. Action=task_query:进入任务查询 tool-calling; -// 3. Action=chat:进入普通聊天流; -// 4. 路由失败时会标记 RouteFailed=true,由上层直接返回内部错误。 +// 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 { @@ -132,50 +130,30 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user if reason == "" { reason = "识别到新增任务请求,准备执行随口记流程。" } - return RoutingDecision{ - Action: ActionQuickNoteCreate, - TrustRoute: true, - Detail: reason, - RouteFailed: false, - } + return RoutingDecision{Action: ActionQuickNoteCreate, TrustRoute: true, Detail: reason, RouteFailed: false} case ActionTaskQuery: reason := strings.TrimSpace(decision.Reason) if reason == "" { - reason = "识别到任务查询请求,准备调用任务查询工具。" + reason = "识别到任务查询请求,准备执行任务查询流程。" } - return RoutingDecision{ - Action: ActionTaskQuery, - TrustRoute: true, - Detail: reason, - RouteFailed: false, - } - case ActionSchedulePlan: + return RoutingDecision{Action: ActionTaskQuery, TrustRoute: true, Detail: reason, RouteFailed: false} + case ActionSchedulePlanCreate: reason := strings.TrimSpace(decision.Reason) if reason == "" { - reason = "识别到排程请求,准备执行智能排程流程。" + reason = "识别到新建排程请求,准备执行智能排程流程。" } - return RoutingDecision{ - Action: ActionSchedulePlan, - TrustRoute: true, - Detail: reason, - RouteFailed: false, + 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, - } + return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: false} default: - // 兜底:未知动作视为路由异常,标记 RouteFailed 让上层统一报错。 log.Printf("通用分流出现未知动作,标记路由失败并等待上层报错: action=%s raw=%s", decision.Action, decision.Raw) - return RoutingDecision{ - Action: ActionChat, - TrustRoute: false, - Detail: "", - RouteFailed: true, - } + return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: true} } } @@ -215,9 +193,8 @@ func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, u } // deriveRouteControlContext 为“控制码路由”创建子上下文。 -// // 设计要点: -// 1. timeout<=0 时不加额外 deadline,仅继承父上下文; +// 1. timeout<=0 时不加额外 deadline,仅继承父上下文。 // 2. 父 ctx deadline 更紧时,沿用父上下文,避免过早超时误判。 func deriveRouteControlContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { if timeout <= 0 { @@ -232,11 +209,10 @@ func deriveRouteControlContext(parent context.Context, timeout time.Duration) (c } // ParseRouteControlTag 解析通用控制码返回。 -// // 容错策略: // 1. 允许大小写、属性顺序、额外属性差异; // 2. nonce 必须精确匹配; -// 3. action 仅允许 quick_note_create/task_query/chat(兼容 quick_note)。 +// 3. 兼容旧 action 值(schedule_plan/quick_note)。 func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) { text := strings.TrimSpace(raw) if text == "" { @@ -256,11 +232,12 @@ func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) { actionText := strings.ToLower(strings.TrimSpace(header[2])) action := Action(actionText) switch action { - case ActionQuickNoteCreate, ActionTaskQuery, ActionSchedulePlan, ActionChat: + case ActionQuickNoteCreate, ActionTaskQuery, ActionSchedulePlanCreate, ActionSchedulePlanRefine, ActionChat: // 合法动作直接通过。 case ActionQuickNote: - // 兼容旧动作值:统一映射到 quick_note_create。 action = ActionQuickNoteCreate + case ActionSchedulePlan: + action = ActionSchedulePlanCreate default: return nil, fmt.Errorf("invalid route action: %s", actionText) } @@ -279,10 +256,9 @@ func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) { } // DecideQuickNoteRouting 是历史兼容入口。 -// // 说明: -// 1. 旧代码只区分“进不进 quick_note”; -// 2. 新分流里 task_query 不应进入 quick_note,因此这里会映射为 false。 +// 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 { @@ -297,10 +273,7 @@ func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, u } // ParseQuickNoteRouteControlTag 是历史兼容解析入口。 -// -// 说明: -// 1. 旧测试仍调用该函数名; -// 2. 新实现统一委托给 ParseRouteControlTag。 +// 说明:旧测试仍使用该方法名,内部统一委托 ParseRouteControlTag。 func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) { return ParseRouteControlTag(raw, expectedNonce) } diff --git a/backend/agent/route/route_test.go b/backend/agent/route/route_test.go new file mode 100644 index 0000000..f5337ac --- /dev/null +++ b/backend/agent/route/route_test.go @@ -0,0 +1,45 @@ +package route + +import "testing" + +func TestParseRouteControlTag_SchedulePlanCreate(t *testing.T) { + nonce := "nonce-create" + raw := ` +新建排程` + + decision, err := ParseRouteControlTag(raw, nonce) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if decision.Action != ActionSchedulePlanCreate { + t.Fatalf("action 不匹配,期望=%s 实际=%s", ActionSchedulePlanCreate, decision.Action) + } +} + +func TestParseRouteControlTag_SchedulePlanRefine(t *testing.T) { + nonce := "nonce-refine" + raw := ` +微调排程` + + decision, err := ParseRouteControlTag(raw, nonce) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if decision.Action != ActionSchedulePlanRefine { + t.Fatalf("action 不匹配,期望=%s 实际=%s", ActionSchedulePlanRefine, decision.Action) + } +} + +func TestParseRouteControlTag_LegacySchedulePlan(t *testing.T) { + nonce := "nonce-legacy" + raw := ` +兼容旧动作` + + decision, err := ParseRouteControlTag(raw, nonce) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if decision.Action != ActionSchedulePlanCreate { + t.Fatalf("旧动作映射错误,期望=%s 实际=%s", ActionSchedulePlanCreate, decision.Action) + } +} diff --git a/backend/agent/scheduleplan/tools_react.go b/backend/agent/scheduleplan/tools_react.go index 10ca877..a6da5d7 100644 --- a/backend/agent/scheduleplan/tools_react.go +++ b/backend/agent/scheduleplan/tools_react.go @@ -454,8 +454,12 @@ func parseReactLLMOutput(raw string) (*reactLLMOutput, error) { // truncate 截断字符串到指定长度。 func truncate(s string, maxLen int) string { - if len(s) <= maxLen { + if maxLen <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= maxLen { return s } - return s[:maxLen] + "..." + return string(runes[:maxLen]) + "..." } diff --git a/backend/agent/schedulerefine/graph.go b/backend/agent/schedulerefine/graph.go new file mode 100644 index 0000000..ae48d57 --- /dev/null +++ b/backend/agent/schedulerefine/graph.go @@ -0,0 +1,93 @@ +package schedulerefine + +import ( + "context" + "fmt" + + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/compose" +) + +const ( + graphNodeContract = "schedule_refine_contract" + graphNodeReact = "schedule_refine_react" + graphNodeHardCheck = "schedule_refine_hard_check" + graphNodeSummary = "schedule_refine_summary" +) + +// ScheduleRefineGraphRunInput 是“连续微调图”运行参数。 +// +// 字段语义: +// 1. Model:本轮图运行使用的聊天模型。 +// 2. State:预先注入的微调状态(通常来自上一版预览快照)。 +// 3. EmitStage:SSE 阶段回调,允许服务层把阶段进度透传给前端。 +type ScheduleRefineGraphRunInput struct { + Model *ark.ChatModel + State *ScheduleRefineState + EmitStage func(stage, detail string) +} + +// RunScheduleRefineGraph 执行“连续微调”独立图链路。 +// +// 链路顺序: +// START -> contract -> react -> hard_check -> summary -> END +// +// 设计说明: +// 1. 当前链路采用线性图,确保可读性优先; +// 2. “终审失败后单次修复”在 hard_check 节点内部闭环处理,避免图连线分叉过多; +// 3. 若后续需要引入多分支策略(例如大改动转重排),可在 contract 后追加 branch 节点。 +func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInput) (*ScheduleRefineState, error) { + if input.Model == nil { + return nil, fmt.Errorf("schedule refine graph: model is nil") + } + if input.State == nil { + return nil, fmt.Errorf("schedule refine graph: state is nil") + } + + emitStage := func(stage, detail string) { + if input.EmitStage != nil { + input.EmitStage(stage, detail) + } + } + runner := newScheduleRefineRunner(input.Model, emitStage) + + graph := compose.NewGraph[*ScheduleRefineState, *ScheduleRefineState]() + if err := graph.AddLambdaNode(graphNodeContract, compose.InvokableLambda(runner.contractNode)); err != nil { + return nil, err + } + if err := graph.AddLambdaNode(graphNodeReact, compose.InvokableLambda(runner.reactNode)); err != nil { + return nil, err + } + if err := graph.AddLambdaNode(graphNodeHardCheck, compose.InvokableLambda(runner.hardCheckNode)); err != nil { + return nil, err + } + if err := graph.AddLambdaNode(graphNodeSummary, compose.InvokableLambda(runner.summaryNode)); err != nil { + return nil, err + } + + if err := graph.AddEdge(compose.START, graphNodeContract); err != nil { + return nil, err + } + if err := graph.AddEdge(graphNodeContract, graphNodeReact); err != nil { + return nil, err + } + if err := graph.AddEdge(graphNodeReact, graphNodeHardCheck); err != nil { + return nil, err + } + if err := graph.AddEdge(graphNodeHardCheck, graphNodeSummary); err != nil { + return nil, err + } + if err := graph.AddEdge(graphNodeSummary, compose.END); err != nil { + return nil, err + } + + runnable, err := graph.Compile(ctx, + compose.WithGraphName("ScheduleRefineGraph"), + compose.WithMaxRunSteps(12), + compose.WithNodeTriggerMode(compose.AnyPredecessor), + ) + if err != nil { + return nil, err + } + return runnable.Invoke(ctx, input.State) +} diff --git a/backend/agent/schedulerefine/nodes.go b/backend/agent/schedulerefine/nodes.go new file mode 100644 index 0000000..428009c --- /dev/null +++ b/backend/agent/schedulerefine/nodes.go @@ -0,0 +1,1690 @@ +package schedulerefine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "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 ( + // nodeTimeout 是单节点调用模型的超时预算。 + // 说明:这里给到 120s,避免复杂轮次在网络抖动时过早超时。 + nodeTimeout = 120 * time.Second + // plannerMaxTokens 是 Planner 节点输出预算。 + // 说明:Planner 需要输出 steps/success_signals,预算过小会导致 JSON 被截断。 + plannerMaxTokens = 420 + // reactMaxTokens 是执行器单轮计划输出预算。 + // 说明:当 tool_calls 含 BatchMove 时,参数体更长,需要更高预算避免半截 JSON。 + reactMaxTokens = 480 +) + +const ( + // 说明:把 JSON 约束贴到 userPrompt 末尾,降低“系统提示词很长后模型偏离结构”的概率。 + // 1. 每个节点都使用最小必要字段约束,避免提示过重导致上下文负担变大; + // 2. 要求“仅输出 JSON 对象”,减少 markdown/code fence 干扰; + // 3. 放在上下文最后,尽量靠近模型最终解码位置。 + jsonContractForContract = `【输出协议(必须严格遵守)】 +只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 +必须包含键:intent, strategy, hard_requirements, keep_relative_order, order_scope, reason。` + + jsonContractForPlanner = `【输出协议(必须严格遵守)】 +只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 +必须包含键:summary, steps, success_signals, fallback。` + + jsonContractForReact = `【输出协议(必须严格遵守)】 +只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 +必须包含键:done, summary, goal_check, decision, missing_info, reflect, tool_calls。` + + jsonContractForReview = `【输出协议(必须严格遵守)】 +只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 +必须包含键:pass, reason, unmet。` + + jsonContractForPostReflect = `【输出协议(必须严格遵守)】 +只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 +必须包含键:reflection, next_strategy, should_stop, stop_reason。` +) + +type contractOutput struct { + Intent string `json:"intent"` + Strategy string `json:"strategy"` + HardRequirements []string `json:"hard_requirements"` + KeepRelativeOrder bool `json:"keep_relative_order"` + OrderScope string `json:"order_scope"` + Reason string `json:"reason"` +} + +// postReflectOutput 表示“动作执行后真反思”节点的结构化输出。 +// +// 字段语义: +// 1. reflection:基于真实工具结果的复盘; +// 2. next_strategy:下一轮建议策略; +// 3. should_stop:是否建议结束动作循环; +// 4. stop_reason:建议结束的原因。 +type postReflectOutput struct { + Reflection string `json:"reflection"` + NextStrategy string `json:"next_strategy"` + ShouldStop bool `json:"should_stop"` + StopReason string `json:"stop_reason"` +} + +// plannerOutput 表示 Planner 阶段的结构化输出。 +type plannerOutput struct { + Summary string `json:"summary"` + Steps []string `json:"steps"` + SuccessSignals []string `json:"success_signals"` + Fallback string `json:"fallback"` +} + +// runContractNode 执行“微调契约抽取”。 +// +// 步骤化说明: +// 1. 先把用户本轮请求与当前排程摘要打包给模型,抽取结构化目标。 +// 2. 再把模型输出映射到 state.Contract,作为后续动作与终审共同的判断基准。 +// 3. 若模型失败或解析失败,使用保守兜底契约继续流程,避免整链路中断。 +func runContractNode( + 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", "正在抽取本轮微调目标与硬性约束。") + + entryCount := len(st.HybridEntries) + suggestedCount := countSuggested(st.HybridEntries) + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "当前时间(北京时间)=%s\n用户请求=%s\n当前排程条目数=%d\n可调 suggested 数=%d\n已有约束=%s\n历史摘要=%s", + st.RequestNowText, + strings.TrimSpace(st.UserMessage), + entryCount, + suggestedCount, + strings.Join(st.Constraints, ";"), + condenseSummary(st.CandidatePlans), + ), + jsonContractForContract, + ) + + raw, err := callModelText(ctx, chatModel, contractPrompt, userPrompt, false, 260, 0) + if err != nil { + st.Contract = buildFallbackContract(st) + st.UserIntent = st.Contract.Intent + emitStage("schedule_refine.contract.fallback", "契约抽取失败,已按兜底策略继续微调。") + return st, nil + } + emitModelRawDebug(emitStage, "contract", raw) + + parsed, parseErr := parseJSON[contractOutput](raw) + if parseErr != nil { + st.Contract = buildFallbackContract(st) + st.UserIntent = st.Contract.Intent + emitStage("schedule_refine.contract.fallback", fmt.Sprintf("契约解析失败,已按兜底策略继续微调:%s", truncate(parseErr.Error(), 180))) + return st, nil + } + + strategy := normalizeStrategy(parsed.Strategy) + intent := strings.TrimSpace(parsed.Intent) + if intent == "" { + intent = strings.TrimSpace(st.UserMessage) + } + reason := strings.TrimSpace(parsed.Reason) + if reason == "" { + reason = "已根据本轮请求抽取微调契约。" + } + + // 1. keep_relative_order 既接受模型判断,也允许基于用户原话兜底增强。 + // 2. 这样做的目的:避免模型偶发漏判“保持顺序”导致工具层约束缺失。 + keepRelativeOrder := parsed.KeepRelativeOrder || detectOrderIntent(st.UserMessage) + orderScope := normalizeOrderScope(parsed.OrderScope) + hardRequirements := append([]string(nil), parsed.HardRequirements...) + if keepRelativeOrder { + hardRequirements = append(hardRequirements, "保持任务原始相对顺序不变") + } + + st.UserIntent = intent + st.Contract = RefineContract{ + Intent: intent, + Strategy: strategy, + HardRequirements: uniqueNonEmpty(hardRequirements), + KeepRelativeOrder: keepRelativeOrder, + OrderScope: orderScope, + Reason: reason, + } + emitStage("schedule_refine.contract.done", fmt.Sprintf("契约抽取完成:strategy=%s, keep_relative_order=%t。", strategy, keepRelativeOrder)) + return st, nil +} + +// runReactLoopNode 执行“强 ReAct 微调循环”。 +// +// 步骤化说明: +// 1. 严格按 PlanMax/ExecuteMax/ReplanMax 控制规划与执行预算,并把 MaxRounds 对齐为 ExecuteMax+RepairReserve。 +// 2. 每轮先输出“计划/缺口/动作/结果”,再触发一次“动作后真反思(post-reflect)”。 +// 3. 每轮最多一个 tool_call(允许 BatchMove 在单调用内原子多步),失败也写入观察历史,驱动下一轮模型修正策略。 +// 4. 当模型给出 done=true、post-reflect 建议停止、或动作预算耗尽时退出循环。 +func runReactLoopNode( + 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 len(st.HybridEntries) == 0 { + st.ActionLogs = append(st.ActionLogs, "无可微调条目,跳过动作循环。") + return st, nil + } + if st.PlanMax <= 0 { + st.PlanMax = defaultPlanMax + } + 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.RepairReserve >= st.MaxRounds { + st.RepairReserve = 0 + } + + window := buildPlanningWindowFromEntries(st.HybridEntries) + policy := refineToolPolicy{ + KeepRelativeOrder: st.Contract.KeepRelativeOrder, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + } + emitStage( + "schedule_refine.react.start", + fmt.Sprintf("开始执行 Plan-and-Execute 微调,plan_max=%d,execute_max=%d,replan_max=%d,修复预留=%d。", st.PlanMax, st.ExecuteMax, st.ReplanMax, st.RepairReserve), + ) + + // 1. 先规划:Planner 决定“先取证还是先动作”,执行器按计划自由迭代。 + // 2. 规划失败时走后端兜底计划,保证链路可继续。 + if err := runPlannerNode(ctx, chatModel, st, emitStage, "initial"); err != nil { + return st, err + } + + for st.RoundUsed < st.ExecuteMax { + round := st.RoundUsed + 1 + remainingAction := st.ExecuteMax - st.RoundUsed + remainingTotal := st.MaxRounds - st.RoundUsed + + useThinking, reason := shouldEnableRecoveryThinking(st) + emitStage("schedule_refine.react.round_start", fmt.Sprintf("第 %d 轮微调开始,动作剩余=%d,总剩余=%d。", round, remainingAction, remainingTotal)) + if useThinking { + // 用户拍板要求: + // 1. 默认关闭 thinking; + // 2. 连续两次失败后,开启 1 轮 thinking,并把原因通过 SSE 透传给前端。 + emitStage("schedule_refine.react.reasoning_switch", fmt.Sprintf("第 %d 轮|已启用恢复性 thinking:%s", round, reason)) + } + + entriesJSON, _ := json.Marshal(st.HybridEntries) + contractJSON, _ := json.Marshal(st.Contract) + planJSON, _ := json.Marshal(st.CurrentPlan) + observationText := buildObservationPrompt(st.ObservationHistory, 6) + lastObservationText := buildLastToolObservationPrompt(st.ObservationHistory) + lastFailedSignature := fallbackText(st.LastFailedCallSignature, "无") + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "用户本轮请求=%s\n契约=%s\n当前计划=%s\n已有约束=%s\n动作预算剩余=%d\n总预算剩余=%d\nLAST_TOOL_RESULT=%s\nLAST_TOOL_OBSERVATION=%s\nLAST_FAILED_CALL_SIGNATURE=%s\nLAST_POST_STRATEGY=%s\n历史观察=%s\nday_of_week映射=1周一,2周二,3周三,4周四,5周五,6周六,7周日\nsuggested简表=%s\n当前混合日程JSON=%s", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + string(planJSON), + strings.Join(st.Constraints, ";"), + remainingAction, + remainingTotal, + fallbackText(st.LastToolResult, "无"), + lastObservationText, + lastFailedSignature, + fallbackText(st.LastPostStrategy, "无"), + observationText, + buildSuggestedDigest(st.HybridEntries, 80), + string(entriesJSON), + ), + jsonContractForReact, + ) + + // 1. ReAct 节点优先稳定性而非文风多样性: + // 1.1 温度固定 0,降低“同约束下每轮输出漂移”与非结构化长输出概率; + // 1.2 结合 parse_retry,可把“偶发半截 JSON”进一步压低。 + raw, err := callModelText(ctx, chatModel, reactPrompt, userPrompt, useThinking, reactMaxTokens, 0) + if err != nil { + errDetail := formatRoundModelErrorDetail(round, err, ctx) + st.ActionLogs = append(st.ActionLogs, errDetail) + emitStage("schedule_refine.react.round_error", errDetail) + // 1. 若本轮前已产生过有效动作,则超时后不中断整链路。 + // 2. 这样可以避免“前面已调好一部分,后面一轮超时导致全盘失败”。 + if errors.Is(err, context.DeadlineExceeded) && st.RoundUsed > 0 { + emitStage("schedule_refine.react.round_timeout_continue", fmt.Sprintf("第 %d 轮超时,已保留前序结果并继续终审。", round)) + break + } + return st, err + } + emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.plan", round), raw) + + // 1. 解析重试策略: + // 1.1 首次解析失败时,同轮再请求一次模型输出并再次解析; + // 1.2 重试成功则继续后续动作,不影响本轮链路; + // 1.3 二次解析仍失败时,返回统一业务错误码(respond 包),而不是裸 parseErr。 + parsed, parseErr := parseReactOutputWithRetryOnce(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), + MissingInfo: append([]string(nil), parsed.MissingInfo...), + // 这里先记录“计划备注(动作前)”,执行工具后会用 post-reflect 的真反思覆盖。 + Reflect: strings.TrimSpace(parsed.Reflect), + } + + emitStage("schedule_refine.react.plan", formatReactPlanStageDetail(round, parsed, remainingAction, useThinking)) + if useThinking { + emitStage("schedule_refine.react.reasoning_content", fmt.Sprintf("第 %d 轮思考摘要:%s", round, truncate(strings.TrimSpace(parsed.Decision), 180))) + } + emitStage("schedule_refine.react.need_info", formatReactNeedInfoStageDetail(round, parsed.MissingInfo)) + + if parsed.Done { + doneReason := fallbackText(strings.TrimSpace(parsed.Summary), "模型判定当前方案已满足目标。") + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮主动结束:%s", round, doneReason)) + observation.Reflect = fallbackText(observation.Reflect, doneReason) + st.ObservationHistory = append(st.ObservationHistory, observation) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + emitStage("schedule_refine.react.round_done", fmt.Sprintf("第 %d 轮结束:模型返回 done=true。", round)) + break + } + + call, warn := pickSingleToolCall(parsed.ToolCalls) + if warn != "" { + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮告警:%s", round, warn)) + emitStage("schedule_refine.react.round_warn", fmt.Sprintf("第 %d 轮告警:%s", round, warn)) + } + if call == nil { + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮无可执行动作,结束微调。", round)) + observation.Reflect = fallbackText(observation.Reflect, "本轮未生成可执行工具动作。") + st.ObservationHistory = append(st.ObservationHistory, observation) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + emitStage("schedule_refine.react.round_done", fmt.Sprintf("第 %d 轮无动作,流程结束。", round)) + break + } + + emitStage("schedule_refine.react.tool_call", formatToolCallStageDetail(round, *call, remainingAction)) + + callSignature := buildToolCallSignature(*call) + if isRepeatedFailedCall(st, callSignature) { + // 1. 后端硬兜底: + // 1.1 若本轮动作与“上一轮失败动作签名”完全一致,直接拒绝执行,防止模型在同一坑位空转; + // 1.2 该失败会结构化写回上下文,驱动下一轮明确改道(换时段或改用 Swap)。 + result := normalizeToolResult(reactToolResult{ + Tool: strings.TrimSpace(call.Tool), + Success: false, + ErrorCode: "REPEAT_FAILED_ACTION", + Result: "重复失败动作:与上一轮失败动作完全相同,请更换目标时段或改用 Swap。", + }) + st.RoundUsed++ + st.LastToolResult = formatStructuredToolResult(result) + st.LastFailedCallSignature = callSignature + st.ConsecutiveFailures++ + + observation.ToolName = strings.TrimSpace(result.Tool) + observation.ToolParams = cloneToolParams(call.Params) + observation.ToolSuccess = result.Success + observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) + observation.ToolResult = strings.TrimSpace(result.Result) + postReflectText, nextStrategy, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) + observation.Reflect = postReflectText + st.ObservationHistory = append(st.ObservationHistory, observation) + + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮动作被拒绝:tool=%s error_code=%s detail=%s", round, result.Tool, result.ErrorCode, result.Result)) + emitStage("schedule_refine.react.tool_blocked", fmt.Sprintf("第 %d 轮|检测到重复失败动作,已拒绝执行并要求模型改道。", round)) + emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + st.LastPostStrategy = fallbackText(nextStrategy, st.LastPostStrategy) + if shouldTriggerReplan(st, result) { + if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil { + return st, err + } else if replanned { + continue + } + } + if shouldStop { + emitStage("schedule_refine.react.round_done", fmt.Sprintf("第 %d 轮结束:post-reflect 建议停止。", round)) + break + } + continue + } + + nextEntries, rawResult := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, window, policy) + result := normalizeToolResult(rawResult) + st.RoundUsed++ + st.LastToolResult = formatStructuredToolResult(result) + + observation.ToolName = strings.TrimSpace(result.Tool) + observation.ToolParams = cloneToolParams(call.Params) + observation.ToolSuccess = result.Success + observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) + observation.ToolResult = strings.TrimSpace(result.Result) + postReflectText, nextStrategy, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) + observation.Reflect = postReflectText + st.ObservationHistory = append(st.ObservationHistory, observation) + + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮动作:tool=%s success=%t detail=%s", round, result.Tool, result.Success, result.Result)) + emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + st.LastPostStrategy = fallbackText(nextStrategy, st.LastPostStrategy) + + if result.Success { + st.HybridEntries = nextEntries + window = buildPlanningWindowFromEntries(st.HybridEntries) + st.LastFailedCallSignature = "" + st.ConsecutiveFailures = 0 + st.ThinkingBoostArmed = false + } else { + st.LastFailedCallSignature = callSignature + st.ConsecutiveFailures++ + if shouldTriggerReplan(st, result) { + if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil { + return st, err + } else if replanned { + continue + } + } + } + if shouldStop { + emitStage("schedule_refine.react.round_done", fmt.Sprintf("第 %d 轮结束:post-reflect 建议停止。", round)) + break + } + } + + emitStage("schedule_refine.react.done", fmt.Sprintf("Plan-and-Execute 微调结束,已执行动作轮次=%d,重规划次数=%d。", st.RoundUsed, st.ReplanUsed)) + return st, nil +} + +// runPlannerNode 执行一次 Planner 规划。 +// +// 步骤化说明: +// 1. 读取当前约束、最近观察、失败上下文,生成结构化执行计划; +// 2. 规划失败时使用后端兜底计划,保证执行器仍可继续; +// 3. mode=initial/replan 仅用于阶段展示和日志区分。 +func runPlannerNode( + 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") + } + 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) + observationText := buildObservationPrompt(st.ObservationHistory, 6) + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "mode=%s\n用户请求=%s\n契约=%s\n已有约束=%s\n上一轮工具结果=%s\n上一轮策略=%s\n最近观察=%s\nsuggested简表=%s", + mode, + strings.TrimSpace(st.UserMessage), + string(contractJSON), + strings.Join(st.Constraints, ";"), + fallbackText(st.LastToolResult, "无"), + fallbackText(st.LastPostStrategy, "无"), + observationText, + buildSuggestedDigest(st.HybridEntries, 80), + ), + jsonContractForPlanner, + ) + + raw, err := callModelText(ctx, chatModel, plannerPrompt, userPrompt, false, plannerMaxTokens, 0) + if err != nil { + st.CurrentPlan = buildFallbackPlan(st) + st.PlanUsed++ + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("Planner 调用失败,已使用兜底计划:%v", err)) + emitStage("schedule_refine.plan.fallback", "Planner 调用失败,已切换后端兜底计划。") + return nil + } + emitModelRawDebug(emitStage, fmt.Sprintf("planner.%s", mode), raw) + + parsed, parseErr := parsePlannerOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, mode, emitStage) + if parseErr != nil { + st.CurrentPlan = buildFallbackPlan(st) + st.PlanUsed++ + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("Planner 解析失败,已使用兜底计划:%v", parseErr)) + emitStage("schedule_refine.plan.fallback", fmt.Sprintf("Planner 输出解析失败,已切换后端兜底计划:%s", truncate(parseErr.Error(), 180))) + return nil + } + + st.CurrentPlan = PlannerPlan{ + Summary: fallbackText(strings.TrimSpace(parsed.Summary), "已生成可执行计划。"), + Steps: uniqueNonEmpty(parsed.Steps), + SuccessSignals: uniqueNonEmpty(parsed.SuccessSignals), + Fallback: strings.TrimSpace(parsed.Fallback), + } + st.PlanUsed++ + emitStage("schedule_refine.plan.done", fmt.Sprintf("规划完成:%s", truncate(st.CurrentPlan.Summary, 180))) + return nil +} + +// buildFallbackPlan 构造“Planner 失败时兜底计划”。 +func buildFallbackPlan(st *ScheduleRefineState) PlannerPlan { + summary := "兜底计划:先取证再动作,优先原子批量移动,失败后改道。" + if st != nil && st.Contract.KeepRelativeOrder { + summary = "兜底计划:先取证再动作,严格保持相对顺序,优先原子批量移动。" + } + return PlannerPlan{ + Summary: summary, + Steps: []string{ + "1) 调用 QueryTargetTasks 定位目标任务", + "2) 调用 QueryAvailableSlots 获取可用时段", + "3) 优先尝试 BatchMove,失败后改用 Move/Swap", + "4) 收尾前调用 Verify 做确定性自检", + }, + SuccessSignals: []string{ + "工具动作成功且无冲突", + "Verify 通过", + }, + Fallback: "若连续失败,重规划并更换工具路径。", + } +} + +// shouldEnableRecoveryThinking 判断本轮是否触发“失败兜底 thinking”。 +// +// 规则: +// 1. 默认关闭 thinking; +// 2. 连续失败达到 2 次时,仅开启 1 轮 thinking; +// 3. 在同一失败串里只触发一次,直到出现成功再重置。 +func shouldEnableRecoveryThinking(st *ScheduleRefineState) (bool, string) { + if st == nil { + return false, "" + } + if st.ConsecutiveFailures < 2 { + return false, "" + } + if st.ThinkingBoostArmed { + return false, "" + } + st.ThinkingBoostArmed = true + return true, fmt.Sprintf("连续失败=%d,触发1轮恢复性 thinking", st.ConsecutiveFailures) +} + +// shouldTriggerReplan 判断是否应该进入重规划。 +// +// 触发条件: +// 1. 连续失败 >=3; +// 2. 且错误码属于“路径错误类”(冲突/顺序/重复失败/参数缺失/批量失败)。 +func shouldTriggerReplan(st *ScheduleRefineState, result reactToolResult) 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": + return true + default: + return false + } +} + +// tryReplan 在满足条件时触发一次重规划。 +func tryReplan( + 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 { + return false, nil + } + if 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 := runPlannerNode(ctx, chatModel, st, emitStage, "replan"); err != nil { + return true, err + } + // 1. 重规划后重置失败串,避免刚重规划就再次被失败门槛立即打断; + // 2. 同时允许后续再次触发一次 thinking 兜底。 + st.ConsecutiveFailures = 0 + st.ThinkingBoostArmed = false + return true, nil +} + +// runHardCheckNode 执行“物理校验 + 顺序校验 + 语义校验 + 单次修复”。 +func runHardCheckNode( + 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", "正在执行终审硬校验。") + report := evaluateHardChecks(ctx, chatModel, st, emitStage) + 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 := runSingleRepairAction(ctx, chatModel, st, emitStage); err != nil { + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("修复动作失败:%v", err)) + emitStage("schedule_refine.hard_check.fail", "修复动作失败,保留当前方案。") + return st, nil + } + + report = evaluateHardChecks(ctx, chatModel, st, emitStage) + 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 +} + +// runSummaryNode 生成最终用户可读总结,并回填结构化预览字段。 +func runSummaryNode( + 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", "正在生成微调结果总结。") + + updateAllocatedItemsFromEntries(st) + st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries) + + reportJSON, _ := json.Marshal(st.HardCheck) + actionLogText := summarizeActionLogs(st.ActionLogs, 24) + contractJSON, _ := json.Marshal(st.Contract) + userPrompt := fmt.Sprintf( + "用户请求=%s\n契约=%s\n终审报告=%s\n动作日志=%s", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + string(reportJSON), + actionLogText, + ) + + raw, err := callModelText(ctx, chatModel, summaryPrompt, userPrompt, false, 280, 0.35) + summary := strings.TrimSpace(raw) + if err == nil { + emitModelRawDebug(emitStage, "summary", raw) + } + if err != nil || summary == "" { + if st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed { + summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作。当前方案已通过终审校验,可以继续使用。", st.RoundUsed) + } else { + summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确你的微调要求")) + } + } + + st.FinalSummary = summary + st.Completed = true + emitStage("schedule_refine.summary.done", "微调总结已生成。") + return st, nil +} + +// evaluateHardChecks 执行一次完整硬校验(物理 + 顺序 + 语义)。 +func evaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) HardCheckReport { + report := HardCheckReport{} + + report.PhysicsIssues = physicsCheck(st.HybridEntries, len(st.AllocatedItems)) + report.PhysicsPassed = len(report.PhysicsIssues) == 0 + + report.OrderIssues = validateRelativeOrder(st.HybridEntries, refineToolPolicy{ + KeepRelativeOrder: st.Contract.KeepRelativeOrder, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + }) + report.OrderPassed = len(report.OrderIssues) == 0 + + review, err := runSemanticReview(ctx, chatModel, st, emitStage) + if err != nil { + report.IntentPassed = false + report.IntentReason = fmt.Sprintf("语义校验失败:%v", err) + report.IntentUnmet = []string{"语义校验阶段异常"} + return report + } + report.IntentPassed = review.Pass + report.IntentReason = strings.TrimSpace(review.Reason) + report.IntentUnmet = append([]string(nil), review.Unmet...) + return report +} + +// runSingleRepairAction 在终审失败后执行一次修复动作。 +func runSingleRepairAction(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 := withNearestJSONContract( + fmt.Sprintf( + "用户请求=%s\n契约=%s\n未满足点=%s\n当前混合日程JSON=%s", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + strings.Join(st.HardCheck.IntentUnmet, ";"), + string(entriesJSON), + ), + jsonContractForReact, + ) + + raw, err := callModelText(ctx, chatModel, repairPrompt, userPrompt, false, 240, 0.15) + if err != nil { + return err + } + emitModelRawDebug(emitStage, "repair", raw) + parsed, parseErr := parseReactLLMOutput(raw) + if parseErr != nil { + return parseErr + } + + call, warn := pickSingleToolCall(parsed.ToolCalls) + if warn != "" { + st.ActionLogs = append(st.ActionLogs, "修复阶段告警:"+warn) + } + if call == nil { + return fmt.Errorf("修复阶段未给出可执行动作") + } + emitStage("schedule_refine.hard_check.repair_call", formatToolCallStageDetail(st.RoundUsed+1, *call, st.MaxRounds-st.RoundUsed)) + + policy := refineToolPolicy{ + KeepRelativeOrder: st.Contract.KeepRelativeOrder, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + } + nextEntries, result := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, buildPlanningWindowFromEntries(st.HybridEntries), policy) + result = normalizeToolResult(result) + st.RoundUsed++ + st.LastToolResult = formatStructuredToolResult(result) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("修复动作:tool=%s success=%t detail=%s", result.Tool, result.Success, result.Result)) + emitStage("schedule_refine.hard_check.repair_result", formatToolResultStageDetail(st.RoundUsed, result, st.RoundUsed, st.MaxRounds)) + + if !result.Success { + st.LastFailedCallSignature = buildToolCallSignature(*call) + return fmt.Errorf("修复动作执行失败:%s", result.Result) + } + st.LastFailedCallSignature = "" + st.HybridEntries = nextEntries + return nil +} + +// runSemanticReview 通过模型判断“当前方案是否满足用户本轮目标”。 +func runSemanticReview(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) (*reviewOutput, error) { + entriesJSON, _ := json.Marshal(st.HybridEntries) + contractJSON, _ := json.Marshal(st.Contract) + userPrompt := withNearestJSONContract( + 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), + buildSuggestedDigest(st.HybridEntries, 80), + summarizeActionLogs(st.ActionLogs, 12), + string(entriesJSON), + ), + jsonContractForReview, + ) + raw, err := callModelText(ctx, chatModel, reviewPrompt, userPrompt, false, 240, 0) + if err != nil { + return nil, err + } + emitModelRawDebug(emitStage, "review", raw) + return parseReviewOutput(raw) +} + +// runPostReflectAfterTool 执行“工具动作后的真反思”。 +// +// 步骤化说明: +// 1. 输入本轮计划、工具调用参数、后端真实工具结果; +// 2. 调用专用 postReflectPrompt,让模型基于真实结果给出复盘与下一步策略; +// 3. 解析失败时使用后端兜底复盘文本,保证链路不被“反思失败”拖垮; +// 4. 返回反思文本与 shouldStop 标记,供主循环决定是否提前结束。 +func runPostReflectAfterTool( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + round int, + plan *reactLLMOutput, + call *reactToolCall, + result reactToolResult, + emitStage func(stage, detail string), +) (string, string, bool) { + if st == nil || chatModel == nil || call == nil { + return buildPostReflectFallback(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) + planGoal := "" + planDecision := "" + planNote := "" + if plan != nil { + planGoal = strings.TrimSpace(plan.GoalCheck) + planDecision = strings.TrimSpace(plan.Decision) + planNote = strings.TrimSpace(plan.Reflect) + } + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "用户请求=%s\n契约=%s\n本轮计划.goal_check=%s\n本轮计划.decision=%s\n本轮计划.note=%s\n本轮工具调用=%s\n本轮工具结果=%s\n最近观察=%s\n", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + planGoal, + planDecision, + planNote, + string(callJSON), + string(resultJSON), + buildObservationPrompt(st.ObservationHistory, 4), + ), + jsonContractForPostReflect, + ) + + raw, err := callModelText(ctx, chatModel, postReflectPrompt, userPrompt, false, 220, 0) + if err != nil { + fallback := buildPostReflectFallback(plan, result) + emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思失败,改用后端兜底复盘:%s", round, truncate(err.Error(), 160))) + return fallback, "", false + } + emitModelRawDebug(emitStage, fmt.Sprintf("post_reflect.round.%d", round), raw) + + parsed, parseErr := parseJSON[postReflectOutput](raw) + if parseErr != nil { + fallback := buildPostReflectFallback(plan, result) + emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思解析失败,改用后端兜底复盘:%s", round, truncate(parseErr.Error(), 160))) + return fallback, "", false + } + + reflection := strings.TrimSpace(parsed.Reflection) + if reflection == "" { + reflection = buildPostReflectFallback(plan, result) + } + nextStrategy := strings.TrimSpace(parsed.NextStrategy) + if nextStrategy != "" { + reflection = fmt.Sprintf("%s;下一步建议:%s", reflection, nextStrategy) + } + shouldStop := parsed.ShouldStop + stopReason := strings.TrimSpace(parsed.StopReason) + if shouldStop { + if stopReason == "" { + stopReason = "模型判定继续动作收益较低,建议转终审。" + } + reflection = fmt.Sprintf("%s;停止建议:%s", reflection, stopReason) + } + emitStage( + "schedule_refine.react.post_reflect.done", + fmt.Sprintf("第 %d 轮|模型反思=%s|下一步=%s|should_stop=%t", round, truncate(strings.TrimSpace(parsed.Reflection), 120), truncate(nextStrategy, 120), shouldStop), + ) + return reflection, nextStrategy, shouldStop +} + +// buildPostReflectFallback 生成“动作后真反思”的后端兜底文案。 +// +// 说明: +// 1. 当 post-reflect 模型调用/解析失败时,仍需给前端可解释文本; +// 2. 兜底文本以真实工具结果为主,计划备注仅作补充; +// 3. 该函数不决定 shouldStop,只负责生成可读复盘。 +func buildPostReflectFallback(plan *reactLLMOutput, result reactToolResult) string { + planNote := "" + if plan != nil { + planNote = strings.TrimSpace(plan.Reflect) + } + return buildRuntimeReflect(planNote, result) +} + +// callModelText 统一封装模型调用,避免各节点重复拼装参数。 +func callModelText( + 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 +} + +// parseJSON 是通用 JSON 解析器,兼容 markdown code fence。 +func parseJSON[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 := extractFirstJSONObject(clean) + if err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +// extractFirstJSONObject 从文本中提取“第一个完整 JSON 对象”。 +// +// 设计说明: +// 1. 相比“first { + last }”的粗糙截取,这里使用括号配对,避免模型输出多段文本时误截; +// 2. 兼容字符串内大括号(通过字符串状态机跳过); +// 3. 提取失败时返回明确错误,便于上层阶段日志提示。 +func extractFirstJSONObject(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") +} + +// emitModelRawDebug 统一输出模型原始文本到 SSE 调试阶段。 +// +// 规则: +// 1. 所有模型节点都可调用该函数输出原始 raw,帮助定位解析失败; +// 2. detail 统一带 `[debug][tag]` 前缀,满足前端快速筛选; +// 3. 当 raw 过长时,按分片逐条输出,避免“单条截断导致看起来像 JSON 不闭合”的误判。 +func emitModelRawDebug(emitStage func(stage, detail string), tag string, raw string) { + if emitStage == nil { + return + } + clean := strings.TrimSpace(raw) + if clean == "" { + clean = "" + } + + // 1. 这里按 rune 分片而不是按 byte 分片,避免中文被截断后出现乱码。 + // 2. 每片控制在较小体量,降低 SSE 单条过大造成前端展示异常或丢帧。 + // 3. 分片时携带 part 序号,便于前端/日志侧拼接复盘完整 raw。 + const chunkSize = 1600 + tag = strings.TrimSpace(tag) + runes := []rune(clean) + if len(runes) <= chunkSize { + emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s] %s", 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) + } + part := string(runes[start:end]) + emitStage( + "schedule_refine.debug.raw", + fmt.Sprintf("[debug][%s][part %d/%d] %s", tag, i+1, total, part), + ) + } +} + +// physicsCheck 做确定性物理校验。 +func physicsCheck(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 !entryBlocksSuggested(entry) { + continue + } + for section := entry.SectionFrom; section <= entry.SectionTo; section++ { + key := fmt.Sprintf("%d-%d-%d", entry.Week, entry.DayOfWeek, section) + if existed, ok := slotMap[key]; ok { + issues = append(issues, fmt.Sprintf("冲突:%s 与 %s 同时占用 W%dD%d 第%d节", existed, entry.Name, entry.Week, entry.DayOfWeek, section)) + } else { + slotMap[key] = entry.Name + } + } + } + if allocatedCount > 0 { + suggested := countSuggested(entries) + if suggested != allocatedCount { + issues = append(issues, fmt.Sprintf("数量不一致:suggested=%d,allocated_items=%d", suggested, allocatedCount)) + } + } + return issues +} + +func updateAllocatedItemsFromEntries(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 entry.Status == "suggested" && entry.TaskItemID > 0 { + 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 countSuggested(entries []model.HybridScheduleEntry) int { + count := 0 + for _, entry := range entries { + if entry.Status == "suggested" { + count++ + } + } + return count +} + +func summarizeActionLogs(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 fallbackText(text string, fallback string) string { + clean := strings.TrimSpace(text) + if clean == "" { + return fallback + } + return clean +} + +// withNearestJSONContract 把“严格 JSON 输出约束”追加到 userPrompt 末尾。 +// +// 步骤化说明: +// 1. 先做 trim,避免多余空白影响模型对结尾指令的关注; +// 2. 再把结构化约束放在最后两行,确保它离模型输出位置最近; +// 3. 若约束为空则原样返回,避免把空字符串误拼进 prompt。 +func withNearestJSONContract(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 +} + +func formatReactPlanStageDetail(round int, out *reactLLMOutput, 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, + truncate(strings.TrimSpace(out.GoalCheck), 180), + truncate(strings.TrimSpace(out.Decision), 180), + ) +} + +func formatReactNeedInfoStageDetail(round int, missing []string) string { + if len(missing) == 0 { + return fmt.Sprintf("第 %d 轮|模型缺口信息=无。", round) + } + return fmt.Sprintf("第 %d 轮|模型缺口信息=%s", round, truncate(strings.Join(uniqueNonEmpty(missing), ";"), 260)) +} + +func formatReactReflectStageDetail(round int, reflect string) string { + // 这里统一用“复盘”而不是“反思”: + // 1. 当前内容由“后端真实执行结果 + 模型预期说明”拼接而成,不是纯模型自述; + // 2. 用词改为复盘,能更准确表达“以执行结果为准”的定位,减少用户误解为“模型已经真的完成了这一步”。 + return fmt.Sprintf("第 %d 轮|复盘=%s", round, truncate(strings.TrimSpace(reflect), 260)) +} + +func formatToolCallStageDetail(round int, call reactToolCall, 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), truncate(paramsText, 320), remaining) +} + +func formatToolResultStageDetail(round int, result reactToolResult, 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, truncate(strings.TrimSpace(result.Result), 320), used, total, + ) +} + +func condenseSummary(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 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 _, 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}) + } + 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 +} + +func buildFallbackContract(st *ScheduleRefineState) RefineContract { + intent := strings.TrimSpace(st.UserMessage) + keepOrder := detectOrderIntent(st.UserMessage) + hardRequirements := append([]string(nil), st.Constraints...) + if keepOrder { + hardRequirements = append(hardRequirements, "保持任务原始相对顺序不变") + } + return RefineContract{ + Intent: intent, + Strategy: "local_adjust", + HardRequirements: uniqueNonEmpty(hardRequirements), + KeepRelativeOrder: keepOrder, + OrderScope: "global", + Reason: "契约抽取失败,按兜底策略继续。", + } +} + +func normalizeStrategy(strategy string) string { + switch strings.TrimSpace(strings.ToLower(strategy)) { + case "keep": + return "keep" + default: + return "local_adjust" + } +} + +func detectOrderIntent(userMessage string) bool { + msg := strings.TrimSpace(userMessage) + if msg == "" { + return false + } + keywords := []string{"顺序不变", "保持顺序", "按原顺序", "不要打乱顺序", "不打乱顺序", "先后顺序", "原顺序"} + for _, k := range keywords { + if strings.Contains(msg, k) { + return true + } + } + return false +} + +func uniqueNonEmpty(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 buildObservationPrompt(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 summarizeActionLogs([]string{err.Error()}, 1) + } + return string(raw) +} + +// buildLastToolObservationPrompt 返回“上一轮结构化工具观察”。 +// +// 步骤化说明: +// 1. 从观察历史末尾向前找最近一条带工具名的记录,避免把“done轮/无动作轮”误当工具观察; +// 2. 输出 JSON 字符串,供模型按结构化字段读取 success/error_code/params; +// 3. 若不存在工具观察则返回“无”。 +func buildLastToolObservationPrompt(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 "无" +} + +// buildToolCallSignature 构造工具调用签名(tool+params)。 +// +// 说明: +// 1. 用于识别“与上一轮失败动作完全相同”的重复调用; +// 2. 采用 JSON 序列化参数,保证签名稳定、可记录、可回放; +// 3. 签名只用于去重,不用于业务持久化。 +func buildToolCallSignature(call reactToolCall) 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) +} + +// isRepeatedFailedCall 判断当前动作是否重复了“上一轮失败动作”。 +func isRepeatedFailedCall(st *ScheduleRefineState, signature string) bool { + if st == nil { + return false + } + current := strings.TrimSpace(signature) + last := strings.TrimSpace(st.LastFailedCallSignature) + if current == "" || last == "" { + return false + } + return current == last +} + +// normalizeToolResult 对工具结果做统一规范化。 +// +// 步骤化说明: +// 1. 成功结果保留现状; +// 2. 失败结果若未设置 error_code,则按结果文案推断统一错误码; +// 3. 统一错误码后,可被模型下一轮稳定消费,减少“读不懂上一轮失败原因”。 +func normalizeToolResult(result reactToolResult) reactToolResult { + if result.Success { + return result + } + if strings.TrimSpace(result.ErrorCode) != "" { + return result + } + result.ErrorCode = classifyToolFailureCode(result.Result) + return result +} + +// classifyToolFailureCode 把工具失败文案映射为稳定错误码。 +func classifyToolFailureCode(detail string) string { + text := strings.TrimSpace(detail) + switch { + 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 "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" + } +} + +// formatStructuredToolResult 把工具执行结果编码为结构化文本。 +// +// 说明: +// 1. 该字符串会写入 state,并在下一轮 prompt 以 LAST_TOOL_RESULT 透传给模型; +// 2. 采用 JSON 结构,减少模型对自然语言描述的误读; +// 3. 编码失败时降级为简短纯文本,避免链路中断。 +func formatStructuredToolResult(result reactToolResult) string { + obj := map[string]any{ + "tool": strings.TrimSpace(result.Tool), + "success": result.Success, + "error_code": strings.TrimSpace(result.ErrorCode), + "result": strings.TrimSpace(result.Result), + } + raw, err := json.Marshal(obj) + if err != nil { + return fmt.Sprintf("tool=%s success=%t error_code=%s result=%s", result.Tool, result.Success, result.ErrorCode, result.Result) + } + return string(raw) +} + +// cloneToolParams 深拷贝工具参数,避免后续 map 复用造成历史观察污染。 +func cloneToolParams(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 formatRoundModelErrorDetail(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 buildRuntimeReflect(modelReflect string, result reactToolResult) string { + modelText := strings.TrimSpace(modelReflect) + resultText := truncate(strings.TrimSpace(result.Result), 220) + if result.Success { + if modelText == "" { + return fmt.Sprintf("后端复盘:工具执行成功。%s", resultText) + } + // 1. 成功分支下,模型文本仅作为“动作前预期”的补充说明; + // 2. 业务上真正生效的是后端工具结果,因此前缀固定写“后端复盘”。 + return fmt.Sprintf("后端复盘:工具执行成功。%s。模型预期(动作前):%s", resultText, truncate(modelText, 180)) + } + if modelText == "" { + return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。", resultText) + } + // 1. 失败分支必须把“未生效”写死,防止用户把模型话术当成已执行事实; + // 2. 模型文本仅保留为“动作前预期”,用于解释它为什么会选这一步。 + return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。模型预期(动作前,仅供参考):%s", resultText, truncate(modelText, 160)) +} + +func buildSuggestedDigest(entries []model.HybridScheduleEntry, limit int) string { + if len(entries) == 0 { + return "无" + } + list := make([]model.HybridScheduleEntry, 0, len(entries)) + for _, entry := range entries { + if entry.Status == "suggested" && entry.TaskItemID > 0 { + list = append(list, entry) + } + } + if len(list) == 0 { + return "无 suggested 条目" + } + sortHybridEntries(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, + weekdayLabel(item.DayOfWeek), + item.SectionFrom, + item.SectionTo, + strings.TrimSpace(item.Name), + )) + } + return strings.Join(lines, "\n") +} + +func weekdayLabel(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 "未知" + } +} + +// parseReactOutputWithRetryOnce 对 ReAct 输出做“单次重试解析”。 +// +// 步骤化说明: +// 1. 先解析首次模型输出,成功即直接返回。 +// 2. 首次解析失败时,同轮重试一次模型调用(关闭 thinking + 温度置 0),提升结构化稳定性。 +// 3. 若重试后解析成功,则发出成功阶段信号并继续流程。 +// 4. 若重试调用或二次解析仍失败,则返回统一业务错误码,避免前端拿到不可控的原始解析错误。 +func parseReactOutputWithRetryOnce( + ctx context.Context, + chatModel *ark.ChatModel, + userPrompt string, + firstRaw string, + round int, + emitStage func(stage, detail string), + st *ScheduleRefineState, +) (*reactLLMOutput, error) { + if st == nil { + return nil, respond.ScheduleRefineOutputParseFailed + } + + parsed, parseErr := parseReactLLMOutput(firstRaw) + if parseErr == nil { + return parsed, nil + } + + firstFail := fmt.Sprintf("第 %d 轮输出解析失败,准备重试1次:%s", round, truncate(parseErr.Error(), 260)) + st.ActionLogs = append(st.ActionLogs, firstFail) + emitStage("schedule_refine.react.parse_retry", firstFail) + + retryRaw, retryErr := callModelText(ctx, chatModel, reactPrompt, userPrompt, false, reactMaxTokens, 0) + if retryErr != nil { + retryErrDetail := formatRoundModelErrorDetail(round, fmt.Errorf("解析重试调用失败: %w", retryErr), ctx) + st.ActionLogs = append(st.ActionLogs, retryErrDetail) + emitStage("schedule_refine.react.round_error", retryErrDetail) + return nil, respond.ScheduleRefineOutputParseFailed + } + emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.retry", round), retryRaw) + + retryParsed, retryParseErr := parseReactLLMOutput(retryRaw) + if retryParseErr != nil { + secondFail := fmt.Sprintf("第 %d 轮输出二次解析失败:%s", round, truncate(retryParseErr.Error(), 260)) + st.ActionLogs = append(st.ActionLogs, secondFail) + emitStage("schedule_refine.react.round_error", secondFail) + return nil, respond.ScheduleRefineOutputParseFailed + } + + emitStage("schedule_refine.react.parse_retry_success", fmt.Sprintf("第 %d 轮输出重试解析成功,继续执行。", round)) + return retryParsed, nil +} + +// parsePlannerOutputWithRetryOnce 对 Planner 输出做“单次重试解析”。 +// +// 步骤化说明: +// 1. 先解析首次 Planner 输出,成功则直接返回; +// 2. 若失败,触发一次“严格 JSON 重试请求”,并打出 retry raw debug; +// 3. 若重试仍失败,返回错误给上层,由上层走兜底计划。 +func parsePlannerOutputWithRetryOnce( + ctx context.Context, + chatModel *ark.ChatModel, + originUserPrompt string, + firstRaw string, + mode string, + emitStage func(stage, detail string), +) (*plannerOutput, error) { + parsed, parseErr := parseJSON[plannerOutput](firstRaw) + if parseErr == nil { + return parsed, nil + } + + emitStage( + "schedule_refine.plan.parse_retry", + fmt.Sprintf("Planner 解析失败,准备重试1次(mode=%s):%s", strings.TrimSpace(mode), truncate(parseErr.Error(), 160)), + ) + + retryPrompt := withNearestJSONContract( + fmt.Sprintf( + "%s\n\n上一次输出解析失败(原因:JSON 不完整或不闭合)。请缩短内容并严格输出完整 JSON。", + originUserPrompt, + ), + jsonContractForPlanner, + ) + retryRaw, retryErr := callModelText(ctx, chatModel, plannerPrompt, retryPrompt, false, plannerMaxTokens, 0) + if retryErr != nil { + return nil, retryErr + } + emitModelRawDebug(emitStage, fmt.Sprintf("planner.%s.retry", strings.TrimSpace(mode)), retryRaw) + + retryParsed, retryParseErr := parseJSON[plannerOutput](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 +} diff --git a/backend/agent/schedulerefine/prompt.go b/backend/agent/schedulerefine/prompt.go new file mode 100644 index 0000000..cbec335 --- /dev/null +++ b/backend/agent/schedulerefine/prompt.go @@ -0,0 +1,190 @@ +package schedulerefine + +const ( + // contractPrompt 用于“微调契约抽取”节点。 + // + // 目标: + // 1. 把用户自然语言微调请求收敛成结构化契约; + // 2. 明确是否需要“保持相对顺序不变”; + // 3. 严格输出 JSON,降低解析抖动。 + contractPrompt = `你是 SmartFlow 的排程微调契约分析器。 +你会收到:当前时间、用户本轮微调请求、已有排程摘要。 +你的任务是把“用户真正想改什么”转成结构化契约。 + +请只输出 JSON,不要 markdown,不要解释,字段如下: +{ + "intent": "一句话概括用户本轮微调目标", + "strategy": "local_adjust|keep", + "hard_requirements": ["必须满足的硬性要求1","硬性要求2"], + "keep_relative_order": true, + "order_scope": "global|week", + "reason": "简短中文原因,<=40字" +} + +规则: +1) 当用户表达“保持原顺序/不打乱顺序/按原顺序推进”时,keep_relative_order=true。 +2) 若用户没有提顺序要求,keep_relative_order=false,order_scope 固定输出 "global"。 +3) strategy=keep 仅用于“无需改动”的情况;只要要移动任务,就输出 local_adjust。 +4) hard_requirements 要可验证,避免空话。` + + // plannerPrompt 用于“Plan-and-Execute”的规划阶段。 + // + // 目标: + // 1. 让模型按当前请求自动规划“先取证再动作”的执行路径; + // 2. 规划结果要求结构化,便于执行阶段直接引用; + // 3. 不在 Planner 阶段执行工具,只负责产出计划。 + plannerPrompt = `你是 SmartFlow 的排程微调规划器(Planner)。 +你会收到:用户请求、契约、最近动作日志与观察。 +你的职责是生成“下一阶段的执行计划”,而不是直接执行工具。 + +只输出 JSON: +{ + "summary": "本轮计划一句话", + "steps": ["步骤1","步骤2","步骤3"], + "success_signals": ["满足什么算成功1","成功2"], + "fallback": "若连续失败,准备怎么改道" +} + +规则: +1. steps 请优先采用“先取证后动作”的路径:例如 QueryTargetTasks / QueryAvailableSlots / BatchMove / Move / Swap / Verify。 +2. steps 保持 3~4 条,单条不超过 26 字。 +3. summary 不超过 36 字;fallback 不超过 30 字;success_signals 最多 3 条。 +4. 严禁输出半截 JSON;若信息过多,请精简而不是展开解释。 +5. 不要输出 markdown,不要输出额外文本。` + + // reactPrompt 用于“强 ReAct 微调循环”节点。 + // + // 目标: + // 1. 每轮先输出“计划 -> 缺口 -> 工具动作”(不承担执行后反思); + // 2. 每轮最多一个 tool_call,但支持 BatchMove 在一个调用里原子执行多步; + // 3. 明确遵守顺序硬约束与 existing 不可改约束。 + reactPrompt = `你是 SmartFlow 的排程微调执行器,采用“走一步看一步”的 ReAct 风格。 +本轮你只允许做两件事之一: +1) 调用一个工具(QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify); +2) 输出 done=true 结束。 + +你将收到 3 个关键输入: +1) LAST_TOOL_RESULT:上一轮工具结果(结构化 JSON); +2) LAST_TOOL_OBSERVATION:上一轮完整观察(包含 tool_name/tool_params/tool_success/tool_error_code/tool_result); +3) LAST_FAILED_CALL_SIGNATURE:上一轮失败动作签名(tool+params)。 + +硬约束: +1. 每轮最多 1 个 tool_call。 +2. 只能修改 status="suggested" 的任务,禁止修改 existing。 +3. 如果合同中 keep_relative_order=true,任何动作都不能打乱任务原始相对顺序。 +4. 如果当前方案已满足目标,直接 done=true,不要多余动作。 +5. day_of_week 数值映射必须严格按:1周一,2周二,3周三,4周四,5周五,6周六,7周日。 +6. 若上一轮 tool_success=false,你必须先根据 tool_error_code 调整策略,再给新动作。 +7. 禁止重复上一轮失败动作(tool 与 params 完全一致);若重复会被后端拒绝执行并记为失败轮次。 + +你必须只输出 JSON,字段如下: +{ + "done": false, + "summary": "", + "goal_check": "本轮先检查什么", + "decision": "本轮为什么这样决策", + "missing_info": ["如果缺信息就在这里写;不缺则返回空数组"], + "reflect": "本轮计划备注(动作前,不是执行后复盘)", + "tool_calls": [ + { + "tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|Verify", + "params": {} + } + ] +} + +补充规则: +1. 若 done=true,则 tool_calls 必须是空数组。 +2. 若 done=false 且有动作,tool_calls 必须只有一个元素。 +3. QueryTargetTasks 用于“先定位要改哪些任务”,禁止直接猜。 +4. QueryAvailableSlots 用于“先看可用空位”,禁止凭直觉盲移。 +5. Move 参数优先使用标准字段:task_item_id,to_week,to_day,to_section_from,to_section_to。 +6. BatchMove 参数格式必须是:{"moves":[{Move参数1},{Move参数2},...]},后端会按顺序原子执行;任一步失败则整批回滚。 +7. Verify 是终止前自检工具:done=true 前建议先执行一次 Verify。 +8. reflect 只描述“本轮计划备注”,不要把未执行的动作写成已完成事实。 +9. 为保证 JSON 稳定可解析,请控制长度:goal_check<=50字、decision<=90字、reflect<=80字、summary<=60字、missing_info 最多3条。 +10. 你必须显式说明“上一轮失败原因如何影响本轮决策”(写在 decision 里)。 +11. 不要输出代码块,不要输出额外文本。` + + // postReflectPrompt 用于“动作执行后真反思”节点。 + // + // 目标: + // 1. 基于后端返回的真实工具结果做复盘,而不是动作前预期; + // 2. 输出下一轮可执行的改进策略,驱动真正的 Observe -> Think; + // 3. 严格输出 JSON,供后端稳定解析并透传 stage。 + postReflectPrompt = `你是 SmartFlow 的 ReAct 复盘器。 +你会收到:本轮工具调用参数、后端真实执行结果、上一轮上下文。 +请基于“真实结果”复盘,不要把失败说成成功。 + +只输出 JSON: +{ + "reflection": "本轮发生了什么(基于真实结果)", + "next_strategy": "下一轮建议如何改(具体到换时段/换工具/保持)", + "should_stop": false, + "stop_reason": "若应结束,给简短原因" +} + +规则: +1. tool_success=false 时,reflection 必须明确失败原因(优先引用 error_code)。 +2. 若 error_code=ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTION,next_strategy 必须给出“如何避开同类失败”。 +3. should_stop=true 仅在“目标已满足”或“继续动作收益很低”时使用。 +4. next_strategy 只能引用这些工具名:QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/Verify。 +5. 不要输出 markdown,不要输出额外文本。` + + // reviewPrompt 用于“终审语义校验”节点。 + // + // 目标: + // 1. 检查方案是否满足用户本轮请求; + // 2. 给出未满足项列表,供一次修复动作使用; + // 3. 输出结构化 JSON,避免校验结果歧义。 + reviewPrompt = `你是 SmartFlow 的终审校验器。 +请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。 +只输出 JSON: +{ + "pass": true, + "reason": "中文简短结论", + "unmet": ["若不满足,这里列未满足点"] +} + +要求: +1. pass=true 时,unmet 必须为空数组。 +2. pass=false 时,reason 必须给出核心差距。` + + // summaryPrompt 用于“最终回复润色”节点。 + // + // 目标: + // 1. 给用户返回自然语言总结; + // 2. 体现“做了什么调整 + 为什么这样改”; + // 3. 若终审仍有缺口,也要诚实说明。 + summaryPrompt = `你是 SmartFlow 的排程结果解读助手。 +请基于输入输出 2~4 句自然中文总结: +1) 先说本轮改了什么; +2) 再说这样改的收益; +3) 如果终审未完全通过,要明确说明还差什么。 +不要输出 JSON。` + + // repairPrompt 用于“终审失败后的单次修复”节点。 + // + // 目标: + // 1. 在不重跑全链路的前提下做一次局部补救; + // 2. 强制只输出一个工具调用,避免再次拉长思考。 + repairPrompt = `你是 SmartFlow 的修复执行器。 +当前方案未通过终审,请根据“未满足点”只做一次修复动作。 +只允许输出一个 tool_call(Move 或 Swap),不允许 done。 + +输出格式(严格 JSON): +{ + "done": false, + "summary": "", + "goal_check": "本轮修复目标", + "decision": "修复决策依据", + "missing_info": [], + "reflect": "修复动作后的预期", + "tool_calls": [ + { + "tool": "Move|Swap", + "params": {} + } + ] +}` +) diff --git a/backend/agent/schedulerefine/runner.go b/backend/agent/schedulerefine/runner.go new file mode 100644 index 0000000..f0b43dd --- /dev/null +++ b/backend/agent/schedulerefine/runner.go @@ -0,0 +1,41 @@ +package schedulerefine + +import ( + "context" + + "github.com/cloudwego/eino-ext/components/model/ark" +) + +// scheduleRefineRunner 是“单次图运行”的请求级依赖容器。 +// +// 职责边界: +// 1. 负责收口模型与阶段回调,避免 graph.go 出现大量闭包; +// 2. 负责把节点函数适配为统一签名; +// 3. 不负责分支决策(当前链路为线性图)。 +type scheduleRefineRunner struct { + chatModel *ark.ChatModel + emitStage func(stage, detail string) +} + +func newScheduleRefineRunner(chatModel *ark.ChatModel, emitStage func(stage, detail string)) *scheduleRefineRunner { + return &scheduleRefineRunner{ + chatModel: chatModel, + emitStage: emitStage, + } +} + +func (r *scheduleRefineRunner) contractNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { + return runContractNode(ctx, r.chatModel, st, r.emitStage) +} + +func (r *scheduleRefineRunner) reactNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { + return runReactLoopNode(ctx, r.chatModel, st, r.emitStage) +} + +func (r *scheduleRefineRunner) hardCheckNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { + return runHardCheckNode(ctx, r.chatModel, st, r.emitStage) +} + +func (r *scheduleRefineRunner) summaryNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { + return runSummaryNode(ctx, r.chatModel, st, r.emitStage) +} diff --git a/backend/agent/schedulerefine/state.go b/backend/agent/schedulerefine/state.go new file mode 100644 index 0000000..2c6bf04 --- /dev/null +++ b/backend/agent/schedulerefine/state.go @@ -0,0 +1,308 @@ +package schedulerefine + +import ( + "sort" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/model" +) + +const ( + // timezoneName 固定排程链路使用的业务时区,避免容器默认时区导致“明天/今晚”偏移。 + timezoneName = "Asia/Shanghai" + // datetimeLayout 统一使用分钟级时间文本,方便模型理解与日志比对。 + datetimeLayout = "2006-01-02 15:04" + // defaultPlanMax 是 Planner 最大调用次数(包含首次规划 + 重规划)。 + defaultPlanMax = 2 + // defaultExecuteMax 是执行阶段最大工具动作轮次。 + defaultExecuteMax = 16 + // defaultReplanMax 是执行阶段允许触发的重规划次数上限。 + defaultReplanMax = 2 + // defaultRepairReserve 表示为“终审修复”保留的最小动作预算。 + defaultRepairReserve = 1 +) + +// RefineContract 表示“微调意图契约”。 +// +// 职责边界: +// 1. 负责承载“本轮微调到底要满足什么”的结构化目标; +// 2. 负责给后续 ReAct 动作与终审硬校验提供统一语义; +// 3. 不负责实际排程修改动作执行(动作由工具层负责)。 +type RefineContract struct { + Intent string `json:"intent"` + Strategy string `json:"strategy"` + HardRequirements []string `json:"hard_requirements"` + KeepRelativeOrder bool `json:"keep_relative_order"` + OrderScope string `json:"order_scope"` + Reason string `json:"reason"` +} + +// HardCheckReport 表示“终审硬校验报告”。 +// +// 职责边界: +// 1. 记录规则层(物理冲突)是否通过; +// 2. 记录语义层(是否满足用户要求)是否通过; +// 3. 记录顺序层(是否保持相对顺序)是否通过; +// 4. 记录失败原因与修复尝试信息,便于后续持续优化 prompt; +// 5. 不负责直接决定是否落库(落库决策仍由服务层控制)。 +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 的可见观测信息”。 +// +// 职责边界: +// 1. 负责记录每轮“计划 -> 动作 -> 观察 -> 反思”的关键信息; +// 2. 既用于 SSE 透传,也用于下一轮 prompt 的上下文回灌; +// 3. 不承担排程真实数据存储职责(真实排程仍在 HybridEntries)。 +type ReactRoundObservation struct { + Round int `json:"round"` + GoalCheck string `json:"goal_check,omitempty"` + Decision string `json:"decision,omitempty"` + MissingInfo []string `json:"missing_info,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 表示“本轮执行前的结构化计划”。 +// +// 职责边界: +// 1. 负责记录模型当前建议的执行路径(先查什么、再做什么); +// 2. 负责在失败重规划后替换为新版本,供执行器下一轮参考; +// 3. 不直接约束工具执行结果(执行合法性仍由工具层硬校验负责)。 +type PlannerPlan struct { + Summary string `json:"summary"` + Steps []string `json:"steps,omitempty"` + SuccessSignals []string `json:"success_signals,omitempty"` + Fallback string `json:"fallback,omitempty"` +} + +// ScheduleRefineState 是“连续微调图”的统一状态容器。 +// +// 职责边界: +// 1. 负责在图节点间传递“上一版排程快照 + 本轮用户微调请求 + 动作日志 + 终审报告”; +// 2. 负责承载最终对用户可见的 summary 与结构化 candidate_plans; +// 3. 不负责 Redis/MySQL 读写(持久化由 service 层负责)。 +type ScheduleRefineState struct { + // 1. 基础请求上下文。 + TraceID string + UserID int + ConversationID string + UserMessage string + RequestNow time.Time + RequestNowText string + + // 2. 继承自上一版预览快照的可调度数据。 + TaskClassIDs []int + Constraints []string + HybridEntries []model.HybridScheduleEntry + AllocatedItems []model.TaskClassItem + CandidatePlans []model.UserWeekSchedule + + // 3. 本轮微调过程状态。 + UserIntent string + Contract RefineContract + + PlanMax int + ExecuteMax int + ReplanMax int + + PlanUsed int + ReplanUsed int + + // MaxRounds 保留“总预算”语义,供终审修复节点继续复用: + // MaxRounds = ExecuteMax + RepairReserve + MaxRounds int + RepairReserve int + RoundUsed int + ActionLogs []string + + // ConsecutiveFailures 记录执行阶段连续失败次数,用于触发“失败兜底 thinking”。 + ConsecutiveFailures int + // ThinkingBoostArmed 表示“当前失败串已触发过一次 thinking 兜底”。 + ThinkingBoostArmed bool + + LastToolResult string + ObservationHistory []ReactRoundObservation + CurrentPlan PlannerPlan + LastPostStrategy string + // LastFailedCallSignature 记录“上一轮失败动作签名(tool+params)”。用于后端硬拦截重复失败动作。 + LastFailedCallSignature string + OriginOrderMap map[int]int + + // 4. 终审校验状态。 + HardCheck HardCheckReport + + // 5. 最终输出。 + FinalSummary string + Completed bool +} + +// NewScheduleRefineState 基于“上一版排程预览快照”初始化连续微调状态。 +// +// 步骤化说明: +// 1. 先初始化请求基础字段与默认预算,保证图内每个节点都能读取到稳定上下文。 +// 2. 再把 preview 的核心排程数据做深拷贝注入,避免跨请求引用污染。 +// 3. 最后构建 origin_order_map,作为“保持相对顺序”硬约束的判定基线。 +// 4. 若 preview 为空,仍返回可用 state,由上层决定是报错还是降级。 +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, + ExecuteMax: defaultExecuteMax, + ReplanMax: defaultReplanMax, + RepairReserve: defaultRepairReserve, + MaxRounds: defaultExecuteMax + defaultRepairReserve, + ActionLogs: make([]string, 0, 24), + ObservationHistory: make([]ReactRoundObservation, 0, 16), + OriginOrderMap: make(map[int]int), + CurrentPlan: PlannerPlan{ + Summary: "初始化完成,等待 Planner 生成执行计划。", + }, + } + if preview == nil { + return st + } + + st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...) + st.HybridEntries = cloneHybridEntries(preview.HybridEntries) + st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems) + st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans) + st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries) + return st +} + +// loadLocation 返回排程链路使用的业务时区。 +func loadLocation() *time.Location { + loc, err := time.LoadLocation(timezoneName) + if err != nil { + return time.Local + } + return loc +} + +// nowToMinute 返回当前时刻并截断到分钟级,降低 prompt 中秒级噪声。 +func nowToMinute() time.Time { + return time.Now().In(loadLocation()).Truncate(time.Minute) +} + +// cloneHybridEntries 深拷贝混合日程切片。 +func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { + if len(src) == 0 { + return nil + } + dst := make([]model.HybridScheduleEntry, len(src)) + copy(dst, src) + return dst +} + +// cloneTaskClassItems 深拷贝任务块切片(包含指针字段)。 +func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { + if len(src) == 0 { + return nil + } + dst := make([]model.TaskClassItem, 0, len(src)) + for _, item := range src { + copied := item + if item.CategoryID != nil { + v := *item.CategoryID + copied.CategoryID = &v + } + if item.Order != nil { + v := *item.Order + copied.Order = &v + } + if item.Content != nil { + v := *item.Content + copied.Content = &v + } + if item.Status != nil { + v := *item.Status + copied.Status = &v + } + if item.EmbeddedTime != nil { + t := *item.EmbeddedTime + copied.EmbeddedTime = &t + } + dst = append(dst, copied) + } + return dst +} + +// cloneWeekSchedules 深拷贝周视图切片。 +func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { + if len(src) == 0 { + return nil + } + dst := make([]model.UserWeekSchedule, 0, len(src)) + for _, week := range src { + eventsCopy := make([]model.WeeklyEventBrief, len(week.Events)) + copy(eventsCopy, week.Events) + dst = append(dst, model.UserWeekSchedule{ + Week: week.Week, + Events: eventsCopy, + }) + } + return dst +} + +// buildOriginOrderMap 从当前 suggested 排程位置构建“初始相对顺序映射”。 +// +// 步骤化说明: +// 1. 先筛出所有可调的 suggested 任务; +// 2. 按 week/day/section/task_item_id 稳定排序,得到“时间先后基线”; +// 3. 把 task_item_id -> rank 写入 map,后续 Move/Swap 都基于该 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 entry.Status == "suggested" && entry.TaskItemID > 0 { + 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 idx, entry := range suggested { + orderMap[entry.TaskItemID] = idx + 1 + } + return orderMap +} diff --git a/backend/agent/schedulerefine/tool.go b/backend/agent/schedulerefine/tool.go new file mode 100644 index 0000000..ee5882a --- /dev/null +++ b/backend/agent/schedulerefine/tool.go @@ -0,0 +1,1187 @@ +package schedulerefine + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/LoveLosita/smartflow/backend/model" +) + +// reactToolCall 表示模型输出的单个工具调用指令。 +type reactToolCall struct { + Tool string `json:"tool"` + Params map[string]any `json:"params"` +} + +// reactToolResult 表示工具调用的结构化执行结果。 +type reactToolResult struct { + Tool string `json:"tool"` + Success bool `json:"success"` + ErrorCode string `json:"error_code,omitempty"` + Result string `json:"result"` +} + +// reactLLMOutput 表示“强 ReAct”要求的固定 JSON 输出结构。 +// +// 字段语义: +// 1. goal_check:本轮要先验证的目标点; +// 2. decision:本轮动作选择依据; +// 3. missing_info:模型明确缺失的信息,前端可直接展示; +// 4. reflect:本轮动作前的预期说明(不是执行后事实); +// 5. tool_calls:本轮工具动作列表(业务侧只取第一条)。 +type reactLLMOutput struct { + Done bool `json:"done"` + Summary string `json:"summary"` + GoalCheck string `json:"goal_check"` + Decision string `json:"decision"` + MissingInfo []string `json:"missing_info"` + Reflect string `json:"reflect"` + ToolCalls []reactToolCall `json:"tool_calls"` +} + +// reviewOutput 表示终审节点要求的固定 JSON 输出结构。 +type reviewOutput struct { + Pass bool `json:"pass"` + Reason string `json:"reason"` + Unmet []string `json:"unmet"` +} + +// planningWindow 表示微调工具允许活动的 week/day 边界窗口。 +// +// 设计说明: +// 1. 这里用已有 HybridEntries 自动推导窗口,避免把任务移动到完全无关的周; +// 2. 若窗口不可用(没有任何 entry),则降级为“仅做基础合法性校验”。 +type planningWindow struct { + Enabled bool + StartWeek int + StartDay int + EndWeek int + EndDay int +} + +// refineToolPolicy 是工具层硬约束策略。 +// +// 职责边界: +// 1. 负责承载“是否强制保持相对顺序”的策略开关; +// 2. 负责承载顺序校验需要的 origin_order 映射; +// 3. 不负责语义判定(语义仍由 LLM 终审节点负责)。 +type refineToolPolicy struct { + KeepRelativeOrder bool + OrderScope string + OriginOrderMap map[int]int +} + +// dispatchRefineTool 负责把模型输出的 tool_call 分发到具体工具实现。 +// +// 步骤化说明: +// 1. 先识别工具名并路由到对应实现; +// 2. 工具实现内部负责参数校验、冲突校验、边界校验、顺序校验; +// 3. 任何失败都返回 Success=false 的结构化结果,而不是直接 panic。 +func dispatchRefineTool(entries []model.HybridScheduleEntry, call reactToolCall, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + switch strings.TrimSpace(call.Tool) { + case "QueryTargetTasks": + return refineToolQueryTargetTasks(entries, call.Params, policy) + case "QueryAvailableSlots": + return refineToolQueryAvailableSlots(entries, call.Params, window) + case "Move": + return refineToolMove(entries, call.Params, window, policy) + case "Swap": + return refineToolSwap(entries, call.Params, window, policy) + case "BatchMove": + return refineToolBatchMove(entries, call.Params, window, policy) + case "Verify": + return refineToolVerify(entries, policy) + default: + return entries, reactToolResult{ + Tool: strings.TrimSpace(call.Tool), + Success: false, + Result: fmt.Sprintf("不支持的工具:%s(仅允许 QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/Verify)", strings.TrimSpace(call.Tool)), + } + } +} + +// pickSingleToolCall 在“单步动作”策略下选取一个工具调用。 +// +// 返回语义: +// 1. call=nil:本轮无可执行动作; +// 2. warn 非空:模型返回了多个调用,本轮只执行第一个并记录告警。 +func pickSingleToolCall(calls []reactToolCall) (*reactToolCall, 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) +} + +// parseReactLLMOutput 解析模型输出的 ReAct JSON。 +// +// 容错策略: +// 1. 兼容 ```json 代码块包装; +// 2. 兼容 JSON 前后有解释性文字(提取最外层对象)。 +func parseReactLLMOutput(raw string) (*reactLLMOutput, 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 reactLLMOutput + if err := json.Unmarshal([]byte(clean), &out); err == nil { + if out.MissingInfo == nil { + out.MissingInfo = make([]string, 0) + } + return &out, nil + } + obj, objErr := extractFirstJSONObject(clean) + if objErr != nil { + return nil, fmt.Errorf("无法从输出中提取 JSON:%s", truncate(clean, 220)) + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + if out.MissingInfo == nil { + out.MissingInfo = make([]string, 0) + } + return &out, nil +} + +// parseReviewOutput 解析终审评估节点输出。 +func parseReviewOutput(raw string) (*reviewOutput, 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 reviewOutput + if err := json.Unmarshal([]byte(clean), &out); err == nil { + return &out, nil + } + obj, objErr := extractFirstJSONObject(clean) + if objErr != nil { + return nil, fmt.Errorf("无法从 review 输出中提取 JSON:%s", truncate(clean, 220)) + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +// refineToolMove 执行“移动一个 suggested 任务到指定时段”。 +// +// 步骤化说明: +// 1. 先校验参数完整性与目标时段合法性,避免写入脏坐标; +// 2. 再校验原任务是否存在、跨度是否一致(防止任务长度被模型改坏); +// 3. 再校验窗口边界与冲突,确保不会穿透到不可用位置; +// 4. 若启用顺序硬约束,再校验“移动后是否打乱原相对顺序”; +// 5. 全部通过后才真正修改 entries 并返回 Success=true。 +func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + // 0. task_id 兼容策略: + // 0.1 标准键是 task_item_id; + // 0.2 为了兼容模型偶发输出别名 task_id,这里做兜底兼容,避免“语义正确但参数名不一致”导致整轮白跑; + // 0.3 两者都不存在时,仍按参数缺失返回失败,由上层 ReAct 继续下一轮决策。 + taskID, ok := paramIntAny(params, "task_item_id", "task_id") + if !ok { + return entries, reactToolResult{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 := paramIntAny(params, "to_week", "target_week", "week") + toDay, okDay := paramIntAny(params, "to_day", "target_day", "target_day_of_week", "day_of_week", "day") + toSF, okSF := paramIntAny(params, "to_section_from", "target_section_from", "section_from") + toST, okST := paramIntAny(params, "to_section_to", "target_section_to", "section_to") + if !okWeek || !okDay || !okSF || !okST { + 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 非法", toSF, toST)} + } + + idx := findSuggestedByID(entries, taskID) + if idx < 0 { + 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 !isWithinWindow(window, toWeek, toDay) { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("目标 W%dD%d 超出允许窗口", toWeek, toDay), + } + } + + if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}); conflict { + return entries, reactToolResult{ + 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) + + sortHybridEntries(entries) + if issues := validateRelativeOrder(entries, policy); len(issues) > 0 { + return beforeEntries, reactToolResult{ + Tool: "Move", + Success: false, + Result: "顺序约束不满足:" + strings.Join(issues, ";"), + } + } + + return entries, reactToolResult{ + Tool: "Move", + Success: true, + Result: fmt.Sprintf("已将任务[%s](id=%d) 从 %s 移动到 %s", entry.Name, taskID, before, after), + } +} + +// refineToolSwap 执行“交换两个 suggested 任务的位置”。 +// +// 步骤化说明: +// 1. 先校验两端 task_item_id; +// 2. 再双向验证交换后的落点是否与其他条目冲突; +// 3. 若启用顺序硬约束,再校验“交换后是否打乱相对顺序”; +// 4. 校验通过后提交交换并返回成功。 +func refineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + // 1. 参数兼容策略同 Move: + // 1.1 兼容 task_a/task_b 与 task_item_a/task_item_b 等常见别名; + // 1.2 目标是减少模型输出字段差异导致的无效失败。 + idA, okA := paramIntAny(params, "task_a", "task_item_a", "task_item_id_a") + idB, okB := paramIntAny(params, "task_b", "task_item_b", "task_item_id_b") + if !okA || !okB { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:task_a/task_b"} + } + 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 < 0 || idxB < 0 { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: "至少有一个任务不是可交换的 suggested 条目"} + } + + a := entries[idxA] + b := entries[idxB] + if !isWithinWindow(window, b.Week, b.DayOfWeek) || !isWithinWindow(window, a.Week, a.DayOfWeek) { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: "交换目标超出允许窗口"} + } + + excludes := map[int]bool{idxA: true, idxB: true} + if conflict, name := hasConflict(entries, b.Week, b.DayOfWeek, b.SectionFrom, b.SectionTo, excludes); conflict { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务A交换后将与 %s 冲突", name)} + } + if conflict, name := hasConflict(entries, a.Week, a.DayOfWeek, a.SectionFrom, a.SectionTo, excludes); conflict { + return entries, reactToolResult{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 + + sortHybridEntries(entries) + if issues := validateRelativeOrder(entries, policy); len(issues) > 0 { + return beforeEntries, reactToolResult{ + Tool: "Swap", + Success: false, + Result: "顺序约束不满足:" + strings.Join(issues, ";"), + } + } + + return entries, reactToolResult{ + Tool: "Swap", + Success: true, + Result: fmt.Sprintf("已交换任务 id=%d 与 id=%d 的时段", idA, idB), + } +} + +// refineToolBatchMove 执行“原子批量移动 suggested 任务”。 +// +// 步骤化说明: +// 1. 参数要求:params.moves 必须是数组,每个元素都满足 Move 的参数格式; +// 2. 执行策略:在 working 副本上按顺序逐条执行 Move; +// 3. 原子语义:任一步失败,整批回滚(返回原 entries);全部成功才一次性提交; +// 4. 适用场景:用户明确希望“同一轮挪多个任务”,减少 ReAct 往返轮次。 +func refineToolBatchMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + moveParamsList, parseErr := parseBatchMoveParams(params) + if parseErr != nil { + return entries, reactToolResult{ + Tool: "BatchMove", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: parseErr.Error(), + } + } + + // 1. 在副本上执行,保证原子性: + // 1.1 每一步都复用 refineToolMove 的全部校验逻辑(冲突、窗口、顺序、跨度); + // 1.2 只要任一步失败就中止并回滚到原 entries; + // 1.3 全部成功后再返回 working,作为整批提交结果。 + working := cloneHybridEntries(entries) + stepSummary := make([]string, 0, len(moveParamsList)) + currentWindow := buildPlanningWindowFromEntries(working) + if !currentWindow.Enabled { + currentWindow = window + } + for idx, moveParams := range moveParamsList { + nextEntries, stepResult := refineToolMove(working, moveParams, currentWindow, policy) + if !stepResult.Success { + return entries, reactToolResult{ + Tool: "BatchMove", + Success: false, + ErrorCode: classifyBatchMoveErrorCode(stepResult.Result), + Result: fmt.Sprintf("BatchMove 第%d步失败:%s", idx+1, stepResult.Result), + } + } + working = nextEntries + currentWindow = buildPlanningWindowFromEntries(working) + stepSummary = append(stepSummary, fmt.Sprintf("第%d步:%s", idx+1, truncate(stepResult.Result, 120))) + } + + return working, reactToolResult{ + Tool: "BatchMove", + Success: true, + Result: fmt.Sprintf("BatchMove 原子提交成功,共执行%d步。%s", len(moveParamsList), strings.Join(stepSummary, " | ")), + } +} + +// refineToolQueryTargetTasks 查询“本轮潜在目标任务集合”。 +// +// 步骤化说明: +// 1. 支持按 day_scope(weekend/workday/all)、week 范围、limit 过滤; +// 2. 只读查询,不修改 entries; +// 3. 返回结构化 JSON 字符串,供下一轮模型直接消费。 +func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + scope := normalizeDayScope(readString(params, "day_scope", "all")) + statusFilter := normalizeStatusFilter(readString(params, "status", "suggested")) + weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week") + weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week") + if week, hasWeek := paramIntAny(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 := inferWeekBounds(entries, planningWindow{Enabled: false}) + if !hasWeekFrom { + weekFrom = startWeek + } + if !hasWeekTo { + weekTo = endWeek + } + } + limit, okLimit := paramIntAny(params, "limit") + if !okLimit || limit <= 0 { + limit = 16 + } + dayFilter := intSliceToSet(readIntSlice(params, "day_of_week", "days")) + + 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 !matchStatusFilter(entry.Status, statusFilter) { + continue + } + if entry.TaskItemID <= 0 { + continue + } + if len(dayFilter) > 0 { + if _, ok := dayFilter[entry.DayOfWeek]; !ok { + continue + } + } else if !matchDayScope(entry.DayOfWeek, scope) { + 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_from": weekFrom, + "week_to": weekTo, + "day_of_week": keysOfIntSet(dayFilter), + "items": list, + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, reactToolResult{ + Tool: "QueryTargetTasks", + Success: false, + ErrorCode: "QUERY_ENCODE_FAILED", + Result: fmt.Sprintf("序列化查询结果失败:%v", err), + } + } + return entries, reactToolResult{ + Tool: "QueryTargetTasks", + Success: true, + Result: string(raw), + } +} + +// refineToolQueryAvailableSlots 查询“可放置 suggested 的空位”。 +// +// 步骤化说明: +// 1. 根据 day_scope/week 范围/span/exclude_sections 过滤候选时段; +// 2. 使用现有冲突判定(entryBlocksSuggested + sectionsOverlap)确保结果可放置; +// 3. 返回结构化 JSON 字符串,不修改 entries。 +func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow) ([]model.HybridScheduleEntry, reactToolResult) { + scope := normalizeDayScope(readString(params, "day_scope", "all")) + dayFilter := intSliceToSet(readIntSlice(params, "day_of_week", "days")) + span, okSpan := paramIntAny(params, "span") + if !okSpan || span <= 0 { + span = 2 + } + if span > 12 { + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "SPAN_INVALID", + Result: fmt.Sprintf("span=%d 非法,必须在 1~12", span), + } + } + limit, okLimit := paramIntAny(params, "limit") + if !okLimit || limit <= 0 { + limit = 12 + } + + weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week") + weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week") + if week, hasWeek := paramIntAny(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 := inferWeekBounds(entries, window) + if !hasWeekFrom { + weekFrom = startWeek + } + if !hasWeekTo { + weekTo = endWeek + } + } + + excludedSet := make(map[int]struct{}) + for _, sec := range readIntSlice(params, "exclude_sections", "exclude_section") { + if sec >= 1 && sec <= 12 { + excludedSet[sec] = struct{}{} + } + } + afterSection, hasAfter := paramIntAny(params, "after_section") + beforeSection, hasBefore := paramIntAny(params, "before_section") + + type slot struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + } + slots := make([]slot, 0, limit) + for week := weekFrom; week <= weekTo; week++ { + for day := 1; day <= 7; day++ { + if len(dayFilter) > 0 { + if _, ok := dayFilter[day]; !ok { + continue + } + } else if !matchDayScope(day, scope) { + continue + } + if !isWithinWindow(window, week, day) { + continue + } + for sf := 1; sf+span-1 <= 12; sf++ { + st := sf + span - 1 + if hasAfter && sf <= afterSection { + continue + } + if hasBefore && st >= beforeSection { + continue + } + if intersectsExcludedSections(sf, st, excludedSet) { + continue + } + if conflict, _ := hasConflict(entries, week, day, sf, st, nil); conflict { + continue + } + slots = append(slots, slot{ + Week: week, + DayOfWeek: day, + SectionFrom: sf, + SectionTo: st, + }) + if len(slots) >= limit { + break + } + } + if len(slots) >= limit { + break + } + } + if len(slots) >= limit { + break + } + } + + payload := map[string]any{ + "tool": "QueryAvailableSlots", + "count": len(slots), + "day_scope": scope, + "day_of_week": keysOfIntSet(dayFilter), + "week_from": weekFrom, + "week_to": weekTo, + "span": span, + "exclude_sections": keysOfIntSet(excludedSet), + "slots": slots, + } + if hasAfter { + payload["after_section"] = afterSection + } + if hasBefore { + payload["before_section"] = beforeSection + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "QUERY_ENCODE_FAILED", + Result: fmt.Sprintf("序列化空位结果失败:%v", err), + } + } + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: true, + Result: string(raw), + } +} + +// refineToolVerify 进行“轻量确定性自检”。 +// +// 说明: +// 1. 当前只做 deterministic 校验(冲突/顺序),不做语义 LLM 终审; +// 2. 语义层终审仍在 hard_check 节点统一处理; +// 3. 该工具用于给执行阶段一个“可提前自查”的信号。 +func refineToolVerify(entries []model.HybridScheduleEntry, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + physicsIssues := physicsCheck(entries, 0) + orderIssues := validateRelativeOrder(entries, policy) + if len(physicsIssues) == 0 && len(orderIssues) == 0 { + return entries, reactToolResult{ + Tool: "Verify", + Success: true, + Result: `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`, + } + } + payload := map[string]any{ + "tool": "Verify", + "pass": false, + "physics_issues": physicsIssues, + "order_issues": orderIssues, + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, reactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: "Verify 校验失败且结果无法序列化", + } + } + return entries, reactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: string(raw), + } +} + +// validateRelativeOrder 校验 suggested 任务是否保持“初始相对顺序”。 +// +// 步骤化说明: +// 1. 若策略未启用 keep_relative_order,直接通过; +// 2. 否则按时间位置排序 suggested 任务,并映射到 origin_rank; +// 3. 检查 rank 是否单调不降;一旦逆序即判定失败; +// 4. 支持 week 作用域:仅要求每周内保持相对顺序。 +func validateRelativeOrder(entries []model.HybridScheduleEntry, policy refineToolPolicy) []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 { + if entry.Status == "suggested" && entry.TaskItemID > 0 { + 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 := normalizeOrderScope(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 +} + +// normalizeOrderScope 规范化顺序约束作用域。 +func normalizeOrderScope(scope string) string { + switch strings.TrimSpace(strings.ToLower(scope)) { + case "week": + return "week" + default: + return "global" + } +} + +// buildPlanningWindowFromEntries 根据现有条目推导允许移动窗口。 +func buildPlanningWindowFromEntries(entries []model.HybridScheduleEntry) planningWindow { + if len(entries) == 0 { + return planningWindow{Enabled: false} + } + startWeek, startDay := entries[0].Week, entries[0].DayOfWeek + endWeek, endDay := entries[0].Week, entries[0].DayOfWeek + for _, entry := range entries { + if compareWeekDay(entry.Week, entry.DayOfWeek, startWeek, startDay) < 0 { + startWeek, startDay = entry.Week, entry.DayOfWeek + } + if compareWeekDay(entry.Week, entry.DayOfWeek, endWeek, endDay) > 0 { + endWeek, endDay = entry.Week, entry.DayOfWeek + } + } + return planningWindow{ + Enabled: true, + StartWeek: startWeek, + StartDay: startDay, + EndWeek: endWeek, + EndDay: endDay, + } +} + +// isWithinWindow 判断目标 week/day 是否落在窗口内。 +func isWithinWindow(window planningWindow, week, day int) bool { + if !window.Enabled { + return true + } + if day < 1 || day > 7 { + return false + } + if compareWeekDay(week, day, window.StartWeek, window.StartDay) < 0 { + return false + } + if compareWeekDay(week, day, window.EndWeek, window.EndDay) > 0 { + return false + } + return true +} + +// compareWeekDay 比较两个 week/day 坐标。 +// 返回: +// 1) <0:left 更早; +// 2) =0:相同; +// 3) >0:left 更晚。 +func compareWeekDay(leftWeek, leftDay, rightWeek, rightDay int) int { + if leftWeek != rightWeek { + return leftWeek - rightWeek + } + return leftDay - rightDay +} + +// findSuggestedByID 在 entries 中查找指定 task_item_id 的 suggested 条目索引。 +func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int { + for i, entry := range entries { + if entry.Status == "suggested" && entry.TaskItemID == taskItemID { + return i + } + } + return -1 +} + +// hasConflict 检查目标时段是否与其他条目冲突。 +// +// 判断规则: +// 1. 仅把“会阻塞 suggested 的条目”纳入冲突判断; +// 2. excludes 中的索引会被跳过(常用于 Move 自身排除或 Swap 双排除)。 +func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, excludes map[int]bool) (bool, string) { + for idx, entry := range entries { + if excludes != nil && excludes[idx] { + continue + } + if !entryBlocksSuggested(entry) { + continue + } + if entry.Week == week && entry.DayOfWeek == day && sectionsOverlap(entry.SectionFrom, entry.SectionTo, sf, st) { + return true, fmt.Sprintf("%s(%s)", entry.Name, entry.Type) + } + } + return false, "" +} + +// entryBlocksSuggested 判断条目是否会阻塞 suggested 任务落位。 +func entryBlocksSuggested(entry model.HybridScheduleEntry) bool { + if entry.Status == "suggested" { + return true + } + if entry.Status == "existing" { + return entry.BlockForSuggested + } + // 未知状态保守处理为阻塞,避免写入潜在冲突。 + return true +} + +// sectionsOverlap 判断两个节次区间是否有交叠。 +func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool { + return aFrom <= bTo && bFrom <= aTo +} + +// paramInt 从 map 中提取 int 参数,兼容 JSON 常见数值类型。 +func paramInt(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 + } +} + +// paramIntAny 按“候选键优先级”提取 int 参数。 +// +// 步骤化说明: +// 1. 按传入顺序依次尝试每个 key; +// 2. 命中第一个合法值即返回; +// 3. 全部未命中则返回 false,由上层统一抛参数缺失错误。 +func paramIntAny(params map[string]any, keys ...string) (int, bool) { + for _, key := range keys { + if v, ok := paramInt(params, key); ok { + return v, true + } + } + return 0, false +} + +// readString 读取字符串参数,缺失时返回默认值。 +func readString(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 +} + +// normalizeDayScope 规范化 day_scope 取值。 +func normalizeDayScope(scope string) string { + switch strings.ToLower(strings.TrimSpace(scope)) { + case "weekend": + return "weekend" + case "workday": + return "workday" + default: + return "all" + } +} + +// normalizeStatusFilter 规范化 status 过滤条件。 +func normalizeStatusFilter(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "existing": + return "existing" + case "all": + return "all" + default: + return "suggested" + } +} + +// matchStatusFilter 判断条目状态是否命中 status 过滤。 +func matchStatusFilter(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" + } +} + +// matchDayScope 判断 day_of_week 是否满足 scope 过滤条件。 +func matchDayScope(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 + } +} + +// intSliceToSet 把 int 切片转换为 set,并自动去除非法 day 值。 +func intSliceToSet(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 +} + +// inferWeekBounds 推断查询周区间。 +func inferWeekBounds(entries []model.HybridScheduleEntry, window planningWindow) (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 +} + +// readIntSlice 读取 int 切片参数,兼容 []any / []int / 单个数值。 +func readIntSlice(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 := paramInt(params, key); okNum { + return []int{n} + } + } + } + return nil +} + +// intersectsExcludedSections 判断候选区间是否与排除节次有交集。 +func intersectsExcludedSections(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 +} + +// keysOfIntSet 返回 int set 的有序键。 +func keysOfIntSet(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 +} + +// parseBatchMoveParams 解析 BatchMove 的 moves 参数。 +// +// 步骤化说明: +// 1. 先读取 params["moves"],必须存在且为非空数组; +// 2. 再把数组元素逐条转换成 map[string]any,便于复用 refineToolMove; +// 3. 任一元素类型非法即整体失败,避免“部分可执行、部分不可执行”带来的语义歧义。 +func parseBatchMoveParams(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 +} + +// classifyBatchMoveErrorCode 把单步 Move 失败原因映射为 BatchMove 层错误码。 +// +// 说明: +// 1. 映射保持与普通 Move 的错误语义一致,便于模型统一处理; +// 2. 这里按失败文案做轻量推断,避免引入跨文件循环依赖。 +func classifyBatchMoveErrorCode(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" + } +} + +// 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 + } + return left.Name < right.Name + }) +} + +// truncate 截断日志内容,避免错误信息无上限增长。 +func truncate(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/respond/respond.go b/backend/respond/respond.go index 113fbd3..a70df89 100644 --- a/backend/respond/respond.go +++ b/backend/respond/respond.go @@ -338,4 +338,9 @@ var ( //请求相关的响应 Status: "50001", Info: "route control failed", } + + ScheduleRefineOutputParseFailed = Response{ //智能微调输出二次解析失败 + Status: "50002", + Info: "schedule refine output parse failed", + } ) diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index 1392ab5..343ad28 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -393,7 +393,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin } // 3.6 schedule_plan:执行智能排程 graph。 - if routing.Action == route.ActionSchedulePlan { + if routing.Action == route.ActionSchedulePlanCreate { reply, planErr := s.runSchedulePlanFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, extra, progress.Emit, outChan, resolvedModelName) if planErr != nil { log.Printf("智能排程 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, planErr) @@ -412,7 +412,26 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin return } - // 3.7 未知 action 兜底:走普通聊天,保证可用性。 + // 3.7 schedule_plan_refine:执行“连续微调排程”graph。 + if routing.Action == route.ActionSchedulePlanRefine { + reply, refineErr := s.runScheduleRefineFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, progress.Emit, outChan, resolvedModelName) + if refineErr != nil { + // 连续微调失败不再回落普通聊天,直接上报错误。 + pushErrNonBlocking(errChan, refineErr) + return + } + + if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil { + pushErrNonBlocking(errChan, emitErr) + return + } + requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens + s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, 0, requestTotalTokens, errChan) + s.ensureConversationTitleAsync(userID, chatID) + return + } + + // 3.8 未知 action 兜底:走普通聊天,保证可用性。 s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan) }() diff --git a/backend/service/agentsvc/agent_schedule_refine.go b/backend/service/agentsvc/agent_schedule_refine.go new file mode 100644 index 0000000..e7cf8a1 --- /dev/null +++ b/backend/service/agentsvc/agent_schedule_refine.go @@ -0,0 +1,154 @@ +package agentsvc + +import ( + "context" + "errors" + "log" + "strings" + + "github.com/LoveLosita/smartflow/backend/agent/scheduleplan" + "github.com/LoveLosita/smartflow/backend/agent/schedulerefine" + "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. 不负责聊天消息持久化(消息持久化由 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 := schedulerefine.NewScheduleRefineState(traceID, userID, chatID, userMessage, preview) + finalState, runErr := schedulerefine.RunScheduleRefineGraph(ctx, schedulerefine.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 快照”的统一写入口; + // 4.2 这里把 refine state 映射为 scheduleplan state,复用已有落盘链路; + // 4.3 这样可以保证 create/refine 两条链路写入口径一致,便于后续统一维护。 + s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState)) + + 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 *schedulerefine.ScheduleRefineState) *scheduleplan.SchedulePlanState { + if st == nil { + return nil + } + adjustmentScope := "medium" + if st.Contract.Strategy == "keep" { + adjustmentScope = "small" + } + return &scheduleplan.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, + } +}