Version: 0.7.3.dev.260322

♻️ refactor(schedule-refine): [WIP] 重构 Plan-and-Execute ReAct 链路,并增强 JSON 解析兜底能力

- 🧩 重构 `schedulerefine` 主流程,引入 `Planner` / `Replan` 机制,以及执行预算与轮次状态管理
- 🧠 扩展状态与观察上下文,补充工具结果、失败签名、连续失败计数与后置反思策略等信息
- 🔧 增强工具层能力与参数兼容性,补齐 `Query` / `Move` / `Swap` / `BatchMove` / `Verify` 等行为及约束校验
- 🛡️ 提升解析鲁棒性,支持从代码块或混杂文本中提取首个 JSON 对象,并增加单次解析重试机制
- 👀 增强可观测性,补充 `debug raw` 阶段输出与分片透传能力
- ✍️ 优化提示词近端约束,将严格 JSON 输出协议追加到各节点 `userPrompt` 末尾

- 🚧 备注:当前链路仍处于持续调优阶段,稳定性与可用性仍需进一步验证
This commit is contained in:
Losita
2026-03-22 22:38:51 +08:00
parent e5b27df80d
commit 525a8b32cb
12 changed files with 3809 additions and 100 deletions

View File

@@ -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
输出格式必须严格如下(两行):
<SMARTFLOW_ROUTE nonce=给定nonce action=quick_note_create|task_query|schedule_plan|chat></SMARTFLOW_ROUTE>
<SMARTFLOW_ROUTE nonce="给定nonce" action="quick_note_create|task_query|schedule_plan_create|schedule_plan_refine|chat"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>一句不超过30字的中文理由</SMARTFLOW_REASON>
禁止输出任何其他内容。`
@@ -66,13 +68,17 @@ const (
ActionChat Action = "chat"
ActionQuickNoteCreate Action = "quick_note_create"
ActionTaskQuery Action = "task_query"
ActionSchedulePlan Action = "schedule_plan"
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本字段用于支持新的“失败即报错”策略。
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)
}

View File

@@ -0,0 +1,45 @@
package route
import "testing"
func TestParseRouteControlTag_SchedulePlanCreate(t *testing.T) {
nonce := "nonce-create"
raw := `<SMARTFLOW_ROUTE nonce="nonce-create" action="schedule_plan_create"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>新建排程</SMARTFLOW_REASON>`
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 := `<SMARTFLOW_ROUTE nonce="nonce-refine" action="schedule_plan_refine"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>微调排程</SMARTFLOW_REASON>`
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 := `<SMARTFLOW_ROUTE nonce="nonce-legacy" action="schedule_plan"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>兼容旧动作</SMARTFLOW_REASON>`
decision, err := ParseRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision.Action != ActionSchedulePlanCreate {
t.Fatalf("旧动作映射错误,期望=%s 实际=%s", ActionSchedulePlanCreate, decision.Action)
}
}

View File

@@ -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]) + "..."
}

View File

@@ -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. EmitStageSSE 阶段回调,允许服务层把阶段进度透传给前端。
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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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=falseorder_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_ACTIONnext_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_callMove 或 Swap不允许 done。
输出格式(严格 JSON
{
"done": false,
"summary": "",
"goal_check": "本轮修复目标",
"decision": "修复决策依据",
"missing_info": [],
"reflect": "修复动作后的预期",
"tool_calls": [
{
"tool": "Move|Swap",
"params": {}
}
]
}`
)

View File

@@ -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)
}

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View File

@@ -338,4 +338,9 @@ var ( //请求相关的响应
Status: "50001",
Info: "route control failed",
}
ScheduleRefineOutputParseFailed = Response{ //智能微调输出二次解析失败
Status: "50002",
Info: "schedule refine output parse failed",
}
)

View File

@@ -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)
}()

View File

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