Files
smartmate/backend/agent/schedulerefine/nodes.go
Losita 525a8b32cb 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` 末尾

- 🚧 备注:当前链路仍处于持续调优阶段,稳定性与可用性仍需进一步验证
2026-03-22 22:38:51 +08:00

1691 lines
61 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=%dexecute_max=%dreplan_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 = "<empty>"
}
// 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=%dallocated_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
}