♻️ refactor(schedule-refine): [WIP] 重构 Plan-and-Execute ReAct 链路,并增强 JSON 解析兜底能力 - 🧩 重构 `schedulerefine` 主流程,引入 `Planner` / `Replan` 机制,以及执行预算与轮次状态管理 - 🧠 扩展状态与观察上下文,补充工具结果、失败签名、连续失败计数与后置反思策略等信息 - 🔧 增强工具层能力与参数兼容性,补齐 `Query` / `Move` / `Swap` / `BatchMove` / `Verify` 等行为及约束校验 - 🛡️ 提升解析鲁棒性,支持从代码块或混杂文本中提取首个 JSON 对象,并增加单次解析重试机制 - 👀 增强可观测性,补充 `debug raw` 阶段输出与分片透传能力 - ✍️ 优化提示词近端约束,将严格 JSON 输出协议追加到各节点 `userPrompt` 末尾 - 🚧 备注:当前链路仍处于持续调优阶段,稳定性与可用性仍需进一步验证
1691 lines
61 KiB
Go
1691 lines
61 KiB
Go
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 = "<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=%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
|
||
}
|