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,
+ }
+}