diff --git a/backend/agent2/graph/schedule.go b/backend/agent2/graph/schedule.go index c47f23d..8ed5969 100644 --- a/backend/agent2/graph/schedule.go +++ b/backend/agent2/graph/schedule.go @@ -136,5 +136,67 @@ func RunScheduleRefineGraph(ctx context.Context, input agentnode.ScheduleRefineG if input.State == nil { return nil, errors.New("schedule refine graph: state is nil") } - return agentnode.RunScheduleRefineGraph(ctx, input) + + nodes, err := agentnode.NewScheduleRefineNodes(input) + if err != nil { + return nil, err + } + + graph := compose.NewGraph[*agentmodel.ScheduleRefineState, *agentmodel.ScheduleRefineState]() + if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeContract, compose.InvokableLambda(nodes.Contract)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeSlice, compose.InvokableLambda(nodes.Slice)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeRoute, compose.InvokableLambda(nodes.Route)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeReact, compose.InvokableLambda(nodes.React)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeHardCheck, compose.InvokableLambda(nodes.HardCheck)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeSummary, compose.InvokableLambda(nodes.Summary)); err != nil { + return nil, err + } + + if err = graph.AddEdge(compose.START, agentnode.ScheduleRefineGraphNodeContract); err != nil { + return nil, err + } + if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeContract, agentnode.ScheduleRefineGraphNodePlan); err != nil { + return nil, err + } + if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodePlan, agentnode.ScheduleRefineGraphNodeSlice); err != nil { + return nil, err + } + if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeSlice, agentnode.ScheduleRefineGraphNodeRoute); err != nil { + return nil, err + } + if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeRoute, agentnode.ScheduleRefineGraphNodeReact); err != nil { + return nil, err + } + if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeReact, agentnode.ScheduleRefineGraphNodeHardCheck); err != nil { + return nil, err + } + if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeHardCheck, agentnode.ScheduleRefineGraphNodeSummary); err != nil { + return nil, err + } + if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeSummary, compose.END); err != nil { + return nil, err + } + + runnable, err := graph.Compile(ctx, + compose.WithGraphName(ScheduleRefineGraphName), + compose.WithMaxRunSteps(20), + compose.WithNodeTriggerMode(compose.AnyPredecessor), + ) + if err != nil { + return nil, err + } + return runnable.Invoke(ctx, input.State) } diff --git a/backend/agent2/node/schedule_refine.go b/backend/agent2/node/schedule_refine.go index 9706d87..39c5bc3 100644 --- a/backend/agent2/node/schedule_refine.go +++ b/backend/agent2/node/schedule_refine.go @@ -2,68 +2,3525 @@ package agentnode import ( "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" - agentrefine "github.com/LoveLosita/smartflow/backend/agent2/node/schedule_refine_impl" + agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt" + agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared" "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" ) -// ScheduleRefineState is the node-layer alias for refine state. -type ScheduleRefineState = agentrefine.ScheduleRefineState +const ( + // ScheduleRefineGraphNodeContract 是“抽取微调契约”的节点名。 + ScheduleRefineGraphNodeContract = "schedule_refine_contract" + // ScheduleRefineGraphNodePlan 是“生成执行计划”的节点名。 + ScheduleRefineGraphNodePlan = "schedule_refine_plan" + // ScheduleRefineGraphNodeSlice 是“生成任务切片”的节点名。 + ScheduleRefineGraphNodeSlice = "schedule_refine_slice" + // ScheduleRefineGraphNodeRoute 是“复合路由尝试”的节点名。 + ScheduleRefineGraphNodeRoute = "schedule_refine_route" + // ScheduleRefineGraphNodeReact 是“单任务 ReAct 微调”的节点名。 + ScheduleRefineGraphNodeReact = "schedule_refine_react" + // ScheduleRefineGraphNodeHardCheck 是“终审硬校验”的节点名。 + ScheduleRefineGraphNodeHardCheck = "schedule_refine_hard_check" + // ScheduleRefineGraphNodeSummary 是“生成总结回复”的节点名。 + ScheduleRefineGraphNodeSummary = "schedule_refine_summary" +) -// ScheduleRefineGraphRunInput is the node-layer alias for refine graph input. -type ScheduleRefineGraphRunInput = agentrefine.ScheduleRefineGraphRunInput +const ( + defaultExecuteMax = agentmodel.ScheduleRefineDefaultExecuteMax + defaultPerTaskBudget = agentmodel.ScheduleRefineDefaultPerTaskBudget + defaultReplanMax = agentmodel.ScheduleRefineDefaultReplanMax + defaultCompositeRetry = agentmodel.ScheduleRefineDefaultCompositeRetry +) -// NewScheduleRefineState creates refine state from the previous preview snapshot. +type ( + // ScheduleRefineState 是 node 层对连续微调状态的本地别名。 + ScheduleRefineState = agentmodel.ScheduleRefineState + // RefineContract 是连续微调契约的本地别名。 + RefineContract = agentmodel.RefineContract + // RefineAssertion 是结构化硬断言的本地别名。 + RefineAssertion = agentmodel.RefineAssertion + // HardCheckReport 是终审报告的本地别名。 + HardCheckReport = agentmodel.HardCheckReport + // ReactRoundObservation 是 ReAct 轮次观察的本地别名。 + ReactRoundObservation = agentmodel.ReactRoundObservation + // PlannerPlan 是阶段计划的本地别名。 + PlannerPlan = agentmodel.PlannerPlan + // RefineSlicePlan 是切片计划的本地别名。 + RefineSlicePlan = agentmodel.RefineSlicePlan + // RefineObjective 是目标编译结果的本地别名。 + RefineObjective = agentmodel.RefineObjective +) + +func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { + return agentshared.CloneHybridEntries(src) +} + +func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { + return agentshared.CloneTaskClassItems(src) +} + +func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { + return agentshared.CloneWeekSchedules(src) +} + +// ScheduleRefineGraphRunInput 描述“连续微调图”运行所需输入。 +// +// 字段说明: +// 1. Model:本轮图运行使用的聊天模型。 +// 2. State:预先注入的微调状态,通常来自上一版预览快照。 +// 3. EmitStage:阶段回调,允许服务层把进度透传给前端。 +type ScheduleRefineGraphRunInput struct { + Model *ark.ChatModel + State *agentmodel.ScheduleRefineState + EmitStage func(stage, detail string) +} + +// NewScheduleRefineState 基于上一版预览快照初始化连续微调状态。 func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState { - return agentrefine.NewScheduleRefineState(traceID, userID, conversationID, userMessage, preview) + return agentmodel.NewScheduleRefineState(traceID, userID, conversationID, userMessage, preview) } -// FinalHardCheckPassed reports whether the final refine hard check passed. +// FinalHardCheckPassed 判断最终终审是否整体通过。 func FinalHardCheckPassed(st *ScheduleRefineState) bool { - return agentrefine.FinalHardCheckPassed(st) + return agentmodel.FinalHardCheckPassed(st) } -// ScheduleRefineNodes is a temporary compatibility facade. -// The real refine implementation still lives in schedule_refine_impl until the next split round lands. +// ScheduleRefineNodes 是连续微调图的节点容器。 +// +// 职责边界: +// 1. 负责收口模型与阶段回调。 +// 2. 负责向 graph 层暴露可直接挂载的方法。 +// 3. 不负责 graph 编译与 service 接线。 type ScheduleRefineNodes struct { - input ScheduleRefineGraphRunInput + input ScheduleRefineGraphRunInput + emitStage func(stage, detail string) } -// NewScheduleRefineNodes stores the refine graph input. +// NewScheduleRefineNodes 创建连续微调节点容器。 func NewScheduleRefineNodes(input ScheduleRefineGraphRunInput) (*ScheduleRefineNodes, error) { - return &ScheduleRefineNodes{input: input}, nil + if input.Model == nil { + return nil, errors.New("schedule refine nodes: model is nil") + } + if input.State == nil { + return nil, errors.New("schedule refine nodes: state is nil") + } + + emitStage := input.EmitStage + if emitStage == nil { + emitStage = func(stage, detail string) {} + } + + return &ScheduleRefineNodes{ + input: input, + emitStage: emitStage, + }, nil } +// Contract 负责承接“契约抽取”节点。 func (n *ScheduleRefineNodes) Contract(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return st, nil + return scheduleRefineRunContractNode(ctx, n.input.Model, st, n.emitStage) } +// Plan 负责承接“执行计划生成”节点。 func (n *ScheduleRefineNodes) Plan(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return st, nil + return scheduleRefineRunPlanNode(ctx, n.input.Model, st, n.emitStage) } +// Slice 负责承接“任务切片”节点。 func (n *ScheduleRefineNodes) Slice(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return st, nil + return scheduleRefineRunSliceNode(ctx, st, n.emitStage) } +// Route 负责承接“复合工具路由”节点。 func (n *ScheduleRefineNodes) Route(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return st, nil + return scheduleRefineRunCompositeRouteNode(ctx, st, n.emitStage) } +// React 负责承接“单任务微步 ReAct”节点。 func (n *ScheduleRefineNodes) React(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return st, nil + return scheduleRefineRunReactLoopNode(ctx, n.input.Model, st, n.emitStage) } +// HardCheck 负责承接“终审硬校验”节点。 func (n *ScheduleRefineNodes) HardCheck(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { - return st, nil + return scheduleRefineRunHardCheckNode(ctx, n.input.Model, st, n.emitStage) } +// Summary 负责承接“最终总结”节点。 func (n *ScheduleRefineNodes) Summary(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { + return scheduleRefineRunSummaryNode(ctx, n.input.Model, st, n.emitStage) +} + +const ( + nodeTimeout = 120 * time.Second + plannerMaxTokens = 420 + reactMaxTokens = 360 +) + +const ( + jsonContractForContract = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: intent,strategy,hard_requirements,hard_assertions,keep_relative_order,order_scope。" + jsonContractForPlanner = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: summary,steps。" + jsonContractForReact = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: done,summary,goal_check,decision,missing_info,tool_calls。" + jsonContractForReview = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: pass,reason,unmet。" + jsonContractForPostReflect = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: reflection,next_strategy,should_stop。" +) + +type scheduleRefineContractOutput struct { + Intent string `json:"intent"` + Strategy string `json:"strategy"` + HardRequirements []string `json:"hard_requirements"` + HardAssertions []scheduleRefineHardAssertionOutput `json:"hard_assertions"` + KeepRelativeOrder bool `json:"keep_relative_order"` + OrderScope string `json:"order_scope"` +} + +type scheduleRefineHardAssertionOutput struct { + Metric string `json:"metric"` + Operator string `json:"operator"` + Value int `json:"value"` + Min int `json:"min"` + Max int `json:"max"` + Week int `json:"week"` + TargetWeek int `json:"target_week"` +} + +type scheduleRefinePostReflectOutput struct { + Reflection string `json:"reflection"` + NextStrategy string `json:"next_strategy"` + ShouldStop bool `json:"should_stop"` +} + +type scheduleRefinePlannerOutput struct { + Summary string `json:"summary"` + Steps []string `json:"steps"` +} + +func scheduleRefineRunContractNode( + 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", "正在抽取本轮微调目标与硬性约束。") + + userPrompt := scheduleRefineWithNearestJSONContract( + fmt.Sprintf( + "当前时间=%s\n用户请求=%s\n当前排程条目数=%d\n可调 suggested 数=%d\n已有约束=%s\n历史摘要=%s", + st.RequestNowText, + strings.TrimSpace(st.UserMessage), + len(st.HybridEntries), + scheduleRefineCountSuggested(st.HybridEntries), + strings.Join(st.Constraints, ";"), + scheduleRefineCondenseSummary(st.CandidatePlans), + ), + jsonContractForContract, + ) + raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineContractPrompt, userPrompt, false, 260, 0) + if err != nil { + st.Contract = scheduleRefineBuildFallbackContract(st) + st.UserIntent = st.Contract.Intent + emitStage("schedule_refine.contract.fallback", "契约抽取失败,已按兜底策略继续微调。") + return st, nil + } + scheduleRefineEmitModelRawDebug(emitStage, "contract", raw) + parsed, parseErr := scheduleRefineParseJSON[scheduleRefineContractOutput](raw) + if parseErr != nil { + st.Contract = scheduleRefineBuildFallbackContract(st) + st.UserIntent = st.Contract.Intent + emitStage("schedule_refine.contract.fallback", fmt.Sprintf("契约解析失败,已按兜底策略继续微调:%s", scheduleRefineTruncate(parseErr.Error(), 180))) + return st, nil + } + + intent := strings.TrimSpace(parsed.Intent) + if intent == "" { + intent = strings.TrimSpace(st.UserMessage) + } + // 1. 顺序策略以用户表达为准:默认保持顺序,明确授权乱序才放开。 + // 2. 不再让模型自行放宽顺序,避免契约漂移导致“默认乱序”。 + keepOrder := scheduleRefineDetectOrderIntent(st.UserMessage) + reqs := append([]string(nil), parsed.HardRequirements...) + if keepOrder { + reqs = append(reqs, "保持任务原始相对顺序不变") + } + assertions := scheduleRefineNormalizeHardAssertions(parsed.HardAssertions) + if len(assertions) == 0 { + // 1. 当模型未给出结构化断言时,后端基于请求做兜底推断。 + // 2. 目标是保证终审一定可落到“可编程判断”的参数层,而不是停留在自然语言。 + assertions = scheduleRefineInferHardAssertionsFromRequest(st.UserMessage, reqs) + } + st.UserIntent = intent + st.Contract = RefineContract{ + Intent: intent, + Strategy: scheduleRefineNormalizeStrategy(parsed.Strategy), + HardRequirements: scheduleRefineUniqueNonEmpty(reqs), + HardAssertions: assertions, + KeepRelativeOrder: keepOrder, + OrderScope: scheduleRefineNormalizeOrderScope(parsed.OrderScope), + } + emitStage("schedule_refine.contract.done", fmt.Sprintf("契约抽取完成:strategy=%s, keep_relative_order=%t。", st.Contract.Strategy, st.Contract.KeepRelativeOrder)) return st, nil } -// RunScheduleRefineGraph is kept as the single executable entry for refine. -func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInput) (*ScheduleRefineState, error) { - return agentrefine.RunScheduleRefineGraph(ctx, input) +func scheduleRefineRunPlanNode( + 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 plan node") + } + if chatModel == nil { + return nil, fmt.Errorf("schedule refine: model is nil in plan node") + } + if err := scheduleRefineRunPlannerNode(ctx, chatModel, st, emitStage, "initial"); err != nil { + return st, err + } + return st, nil +} + +func scheduleRefineRunSliceNode( + ctx context.Context, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + _ = ctx + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in slice node") + } + emitStage("schedule_refine.slice.building", "正在构建本轮微调任务切片。") + slice := scheduleRefineBuildSlicePlan(st) + workset := scheduleRefineCollectWorksetTaskIDs(st.HybridEntries, slice, st.OriginOrderMap) + if len(workset) == 0 { + relaxed := slice + relaxed.SourceDays = nil + workset = scheduleRefineCollectWorksetTaskIDs(st.HybridEntries, relaxed, st.OriginOrderMap) + if len(workset) > 0 { + slice = relaxed + emitStage("schedule_refine.slice.relaxed", "切片首次为空,已放宽来源日过滤。") + } + } + if len(workset) == 0 { + workset = scheduleRefineCollectWorksetTaskIDs(st.HybridEntries, RefineSlicePlan{}, st.OriginOrderMap) + emitStage("schedule_refine.slice.fallback", "切片仍为空,已回退到全量 suggested 任务。") + } + st.SlicePlan = slice + st.Objective = scheduleRefineCompileRefineObjective(st, slice) + st.WorksetTaskIDs = workset + st.WorksetCursor = 0 + st.CurrentTaskID = 0 + st.CurrentTaskAttempt = 0 + emitStage("schedule_refine.slice.done", fmt.Sprintf("切片完成:workset=%d,week_filter=%v,source_days=%v,target_days=%v,exclude_sections=%v。", len(workset), slice.WeekFilter, slice.SourceDays, slice.TargetDays, slice.ExcludeSections)) + if raw, err := json.Marshal(st.Objective); err == nil { + emitStage("schedule_refine.objective.done", fmt.Sprintf("目标编译完成:%s", string(raw))) + } else { + emitStage("schedule_refine.objective.done", "目标编译完成。") + } + return st, nil +} + +// scheduleRefineRunCompositeRouteNode 在 ReAct 之前做一次“全局复合动作直达”分流。 +// +// 职责边界: +// 1. 负责识别是否命中全局复合目标(SpreadEven/MinContextSwitch); +// 2. 负责直接调用一次复合工具并按配置重试,争取在进入 ReAct 前完成收口; +// 3. 不负责语义推理与逐任务细调,失败后仅负责切换到“禁复合”的 ReAct 兜底链路。 +func scheduleRefineRunCompositeRouteNode( + ctx context.Context, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + _ = ctx + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in route node") + } + scheduleRefineEnsureCompositeStateMaps(st) + if st.CompositeRetryMax < 0 { + st.CompositeRetryMax = defaultCompositeRetry + } + // 1. 先由后端判定本轮是否需要复合路由,避免把分流复杂度继续交给主 ReAct。 + // 2. 若已被上游标记为“禁复合兜底”,直接跳过该路由。 + if st.DisableCompositeTools { + emitStage("schedule_refine.route.skip", "当前已处于禁复合兜底模式,跳过复合路由。") + return st, nil + } + if strings.TrimSpace(st.RequiredCompositeTool) == "" { + st.RequiredCompositeTool = scheduleRefineDetectRequiredCompositeTool(st) + } + required := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) + if required == "" { + emitStage("schedule_refine.route.skip", "未命中全局复合目标,直接进入 ReAct 兜底链路。") + return st, nil + } + + taskIDs := scheduleRefineBuildCompositeRouteTaskIDs(st) + if len(taskIDs) == 0 { + // 1. 没有任务可用于复合规划时,复合路由无法落地。 + // 2. 直接降级到 ReAct,并明确禁用复合工具,避免循环重试同一失败路径。 + st.CompositeRouteTried = true + st.DisableCompositeTools = true + st.RequiredCompositeTool = "" + st.CurrentPlan = scheduleRefineBuildFallbackPlan(st) + st.BatchMoveAllowed = false + emitStage("schedule_refine.route.fallback", "复合路由未获取到可执行任务,已切换到禁复合 ReAct 兜底。") + return st, nil + } + + totalAttempts := 1 + st.CompositeRetryMax + emitStage("schedule_refine.route.start", fmt.Sprintf("命中复合路由:tool=%s,task_count=%d,首次1次+重试%d次。", required, len(taskIDs), st.CompositeRetryMax)) + st.CompositeRouteTried = true + + policy := scheduleRefineToolPolicy{ + // 1. 路由阶段只解决“坑位分布”。 + // 2. 顺序归位统一放在终审阶段,避免复合路由被顺序约束提前卡死。 + KeepRelativeOrder: false, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + } + window := scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries) + lastReason := "" + + for attempt := 1; attempt <= totalAttempts; attempt++ { + if st.RoundUsed >= st.ExecuteMax { + lastReason = "动作预算已耗尽,无法继续复合路由重试" + break + } + call := scheduleRefineBuildCompositeRouteCall(st, required, taskIDs) + callJSON, _ := json.Marshal(call.Params) + emitStage("schedule_refine.route.attempt", fmt.Sprintf("复合路由第 %d/%d 次尝试:调用=%s 参数=%s。", attempt, totalAttempts, required, string(callJSON))) + + nextEntries, rawResult := dispatchScheduleRefineTool(cloneHybridEntries(st.HybridEntries), call, window, policy) + result := scheduleRefineNormalizeToolResult(rawResult) + st.RoundUsed++ + scheduleRefineMarkCompositeToolOutcome(st, result.Tool, result.Success) + emitStage("schedule_refine.route.result", fmt.Sprintf("复合路由第 %d 次结果:success=%t,error_code=%s,detail=%s", attempt, result.Success, scheduleRefineFallbackText(result.ErrorCode, "NONE"), scheduleRefineTruncate(result.Result, 160))) + + if !result.Success { + lastReason = scheduleRefineFallbackText(result.Result, scheduleRefineFallbackText(result.ErrorCode, "复合工具执行失败")) + st.LastFailedCallSignature = scheduleRefineBuildToolCallSignature(call) + st.ConsecutiveFailures++ + continue + } + + st.HybridEntries = nextEntries + st.EntriesVersion++ + st.LastFailedCallSignature = "" + st.ConsecutiveFailures = 0 + st.ThinkingBoostArmed = false + window = scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries) + + // 1. 复合动作成功后必须立刻做后端确定性校验,避免“调用成功但目标未达成”被误收口。 + // 2. 仅当业务目标与(若存在)复合门禁同时通过时,才允许跳过 ReAct。 + if pass, reason, unmet, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied { + pass, reason, unmet = scheduleRefineApplyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet) + if pass { + st.CompositeRouteSucceeded = true + emitStage("schedule_refine.route.pass", fmt.Sprintf("复合路由收口成功:%s", scheduleRefineTruncate(reason, 160))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由成功收口:tool=%s,reason=%s", required, reason)) + return st, nil + } + lastReason = scheduleRefineFallbackText(strings.TrimSpace(reason), "确定性目标未达成") + if len(unmet) > 0 { + emitStage("schedule_refine.route.unmet", fmt.Sprintf("复合路由第 %d 次后仍未达成:%s", attempt, scheduleRefineTruncate(strings.Join(unmet, ";"), 180))) + } + continue + } + + // 1. “均匀分散/最少上下文切换”这类复合目标,未必能编译成 deterministic objective; + // 2. 只要本轮要求的复合工具已经成功执行,就允许独立复合分支直接出站并跳过 ReAct; + // 3. 最终是否真正达标,继续交给 hard_check 统一裁决,避免“工具成功却被路由误判失败”。 + if reason, ok := scheduleRefineAllowCompositeRouteExitByToolSuccess(st, result); ok { + st.CompositeRouteSucceeded = true + emitStage("schedule_refine.route.handoff", scheduleRefineTruncate(reason, 180)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由直接出站:tool=%s,reason=%s", required, reason)) + return st, nil + } + + lastReason = "未启用确定性目标,且复合工具门禁未满足,无法在复合路由直接出站" + } + + // 1. 复合路由重试后仍失败,切入 ReAct 兜底并强制禁用复合工具。 + // 2. 禁用后仅允许基础工具逐任务搬运,避免再次回到复合失败路径造成震荡。 + st.DisableCompositeTools = true + st.RequiredCompositeTool = "" + st.CurrentPlan = scheduleRefineBuildFallbackPlan(st) + st.BatchMoveAllowed = false + emitStage("schedule_refine.route.fallback", fmt.Sprintf("复合路由未收口,切换禁复合 ReAct 兜底:%s", scheduleRefineTruncate(scheduleRefineFallbackText(lastReason, "复合路由达到重试上限"), 180))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由失败后降级:%s", scheduleRefineFallbackText(lastReason, "无具体失败原因"))) + return st, nil +} + +func scheduleRefineBuildCompositeRouteTaskIDs(st *ScheduleRefineState) []int { + if st == nil { + return nil + } + ids := scheduleRefineUniquePositiveInts(append([]int(nil), st.WorksetTaskIDs...)) + if len(ids) > 0 { + return ids + } + ids = scheduleRefineCollectSourceTaskIDsForObjective(st.InitialHybridEntries, st.Objective, st.SlicePlan.WeekFilter) + if len(ids) > 0 { + return ids + } + // 兜底:从当前 suggested 中提取一份稳定任务集,避免因切片异常导致路由空跑。 + seen := make(map[int]struct{}, len(st.HybridEntries)) + out := make([]int, 0, len(st.HybridEntries)) + for _, entry := range st.HybridEntries { + if !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + if _, ok := seen[entry.TaskItemID]; ok { + continue + } + seen[entry.TaskItemID] = struct{}{} + out = append(out, entry.TaskItemID) + } + sort.Ints(out) + return out +} + +// scheduleRefineAllowCompositeRouteExitByToolSuccess 判断“复合工具成功后,是否允许跳过 ReAct 直接进入终审”。 +// +// 步骤化说明: +// 1. 仅在当前没有 deterministic objective 时启用,避免覆盖原有“确定性验收优先”策略; +// 2. 只有本轮要求的复合工具已成功、且成功工具名与门禁一致时才放行; +// 3. 放行后并不代表最终成功,后续仍由 hard_check 做统一裁决。 +func scheduleRefineAllowCompositeRouteExitByToolSuccess(st *ScheduleRefineState, result scheduleRefineReactToolResult) (string, bool) { + if st == nil || !result.Success { + return "", false + } + if strings.TrimSpace(st.Objective.Mode) != "" && strings.TrimSpace(st.Objective.Mode) != "none" { + return "", false + } + required := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) + toolName := scheduleRefineNormalizeCompositeToolName(result.Tool) + if required == "" || toolName == "" || required != toolName { + return "", false + } + if !scheduleRefineIsRequiredCompositeSatisfied(st) { + return "", false + } + return fmt.Sprintf("复合工具 %s 已成功执行;当前目标暂不支持确定性收口,跳过 ReAct,交由终审裁决。", required), true +} + +func scheduleRefineBuildCompositeRouteCall(st *ScheduleRefineState, tool string, taskIDs []int) scheduleRefineReactToolCall { + limit := len(taskIDs) * 6 + if limit < 12 { + limit = 12 + } + params := map[string]any{ + "task_item_ids": append([]int(nil), taskIDs...), + "allow_embed": true, + "limit": limit, + } + targetWeeks := append([]int(nil), st.Objective.TargetWeeks...) + if len(targetWeeks) == 0 { + targetWeeks = scheduleRefineKeysOfIntSet(scheduleRefineInferTargetWeekSet(st.SlicePlan)) + } + if len(targetWeeks) == 0 { + targetWeeks = append([]int(nil), st.Objective.SourceWeeks...) + } + if len(targetWeeks) == 1 { + params["week"] = targetWeeks[0] + } else if len(targetWeeks) > 1 { + params["week_filter"] = targetWeeks + } + + targetDays := append([]int(nil), st.Objective.TargetDays...) + if len(targetDays) == 0 { + targetDays = append([]int(nil), st.SlicePlan.TargetDays...) + } + if len(targetDays) > 0 { + params["day_of_week"] = targetDays + } + if len(st.SlicePlan.ExcludeSections) > 0 { + params["exclude_sections"] = append([]int(nil), st.SlicePlan.ExcludeSections...) + } + return scheduleRefineReactToolCall{ + Tool: tool, + Params: params, + } +} + +func scheduleRefineRunReactLoopNode( + 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 st.CompositeRouteSucceeded { + emitStage("schedule_refine.react.skip", "复合路由已收口成功,跳过 ReAct 兜底循环。") + return st, nil + } + if len(st.HybridEntries) == 0 { + st.ActionLogs = append(st.ActionLogs, "无可微调条目,跳过动作循环。") + return st, nil + } + if len(st.WorksetTaskIDs) == 0 { + st.ActionLogs = append(st.ActionLogs, "workset 为空,跳过动作循环。") + return st, nil + } + if st.PerTaskBudget <= 0 { + st.PerTaskBudget = defaultPerTaskBudget + } + 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.TaskActionUsed == nil { + st.TaskActionUsed = make(map[int]int) + } + if st.SeenSlotQueries == nil { + st.SeenSlotQueries = make(map[string]struct{}) + } + scheduleRefineEnsureCompositeStateMaps(st) + if st.DisableCompositeTools { + st.RequiredCompositeTool = "" + emitStage("schedule_refine.react.fallback_mode", "当前为禁复合兜底模式:仅允许基础工具逐任务调整。") + } else if strings.TrimSpace(st.RequiredCompositeTool) == "" { + st.RequiredCompositeTool = scheduleRefineDetectRequiredCompositeTool(st) + } + if strings.TrimSpace(st.CurrentPlan.Summary) == "" { + st.CurrentPlan = scheduleRefineApplyCompositeHardConditionToPlan(st, scheduleRefineBuildFallbackPlan(st)) + st.BatchMoveAllowed = scheduleRefineShouldAllowBatchMove(st.CurrentPlan) + } + + window := scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries) + sourceWeekSet := scheduleRefineInferSourceWeekSet(st.SlicePlan) + policy := scheduleRefineToolPolicy{ + // 1. 执行期不再用顺序约束卡住 Move/Swap; + // 2. LLM 只负责把坑位排好,顺序由后端在收口阶段统一归位。 + KeepRelativeOrder: false, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + } + emitStage( + "schedule_refine.react.start", + fmt.Sprintf( + "开始执行单任务微步 ReAct,workset=%d,per_task_budget=%d,execute_max=%d,replan_max=%d,required_composite=%s,required_success=%t。", + len(st.WorksetTaskIDs), + st.PerTaskBudget, + st.ExecuteMax, + st.ReplanMax, + scheduleRefineFallbackText(scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool), "无"), + scheduleRefineIsRequiredCompositeSatisfied(st), + ), + ) + +outer: + for st.WorksetCursor < len(st.WorksetTaskIDs) && st.RoundUsed < st.ExecuteMax { + // 1. 每次取下一个任务前先做一次全局目标短路判断。 + // 2. 目标已满足时,直接结束整个 workset 循环,避免“任务6~10 空转”。 + if pass, reason, _, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied && pass { + if scheduleRefineIsRequiredCompositeSatisfied(st) { + emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("全局目标已满足,提前结束任务循环:%s", scheduleRefineTruncate(reason, 160))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标提前达成,触发短路结束:%s", reason)) + break + } + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标看似达成但未满足复合工具门禁:required=%s", scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool))) + } + taskID := st.WorksetTaskIDs[st.WorksetCursor] + current, ok := scheduleRefineFindSuggestedEntryByTaskID(st.HybridEntries, taskID) + if !ok { + st.WorksetCursor++ + continue + } + if len(sourceWeekSet) > 0 { + if _, inSourceWeek := sourceWeekSet[current.Week]; !inSourceWeek { + emitStage("schedule_refine.react.task_skip_scope", fmt.Sprintf("任务 id=%d 当前位于 W%d,不在来源周范围,已跳过。", taskID, current.Week)) + st.WorksetCursor++ + continue + } + } + st.CurrentTaskID = taskID + st.CurrentTaskAttempt = 0 + emitStage("schedule_refine.react.task_start", fmt.Sprintf("开始处理任务 %d/%d:id=%d,%s。", st.WorksetCursor+1, len(st.WorksetTaskIDs), taskID, strings.TrimSpace(current.Name))) + + taskDone := false + for st.CurrentTaskAttempt < st.PerTaskBudget && st.RoundUsed < st.ExecuteMax { + // 1. 每轮开头先刷新“当前任务”的最新位置,避免模型基于旧坐标决策。 + // 2. 若该任务已满足切片目标(例如“已从周末迁出到工作日”),则直接收口当前任务。 + latest, exists := scheduleRefineFindSuggestedEntryByTaskID(st.HybridEntries, taskID) + if !exists { + taskDone = true + emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已不在 suggested 列表,视为当前任务已完成。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:任务条目已不再可调 suggested。", taskID)) + break + } + current = latest + if scheduleRefineIsCurrentTaskSatisfiedBySlice(current, st.SlicePlan) { + // 1. 自动收口前必须通过复合工具门禁。 + // 2. 这样可避免“切片已满足但未执行必需复合工具”直接跳过执行阶段。 + if scheduleRefineIsRequiredCompositeSatisfied(st) { + taskDone = true + emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已满足切片目标,自动收口并切换下一任务。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:已满足切片目标。", taskID)) + break + } + emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 虽满足切片目标,但复合工具门禁未通过,继续执行。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止自动收口:required_composite=%s 尚未成功。", taskID, scheduleRefineFallbackText(scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool), "无"))) + } + + round := st.RoundUsed + 1 + remainingAction := st.ExecuteMax - st.RoundUsed + remainingTotal := st.MaxRounds - st.RoundUsed + useThinking, reason := scheduleRefineShouldEnableRecoveryThinking(st) + st.CurrentTaskAttempt++ + emitStage("schedule_refine.react.round_start", fmt.Sprintf("第 %d 轮微调开始(任务id=%d,第 %d/%d 次尝试),动作剩余=%d,总剩余=%d。", round, taskID, st.CurrentTaskAttempt, st.PerTaskBudget, remainingAction, remainingTotal)) + if useThinking { + emitStage("schedule_refine.react.reasoning_switch", fmt.Sprintf("第 %d 轮已启用恢复态 thinking:%s", round, reason)) + } + + userPrompt := scheduleRefineBuildMicroReactUserPrompt(st, current, remainingAction, remainingTotal) + raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineReactPrompt, userPrompt, useThinking, reactMaxTokens, 0) + if err != nil { + errDetail := scheduleRefineFormatRoundModelErrorDetail(round, err, ctx) + st.ActionLogs = append(st.ActionLogs, errDetail) + emitStage("schedule_refine.react.round_error", errDetail) + if errors.Is(err, context.DeadlineExceeded) && st.RoundUsed > 0 { + st.WorksetCursor = len(st.WorksetTaskIDs) + break + } + return st, err + } + scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.plan", round), raw) + parsed, parseErr := scheduleRefineParseReactOutputWithRetryOnce(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), + } + emitStage("schedule_refine.react.plan", scheduleRefineFormatReactPlanStageDetail(round, parsed, remainingAction, useThinking)) + emitStage("schedule_refine.react.need_info", scheduleRefineFormatReactNeedInfoStageDetail(round, parsed.MissingInfo)) + + if parsed.Done { + allowDone := scheduleRefineIsCurrentTaskSatisfiedBySlice(current, st.SlicePlan) + if allowDone && !scheduleRefineIsRequiredCompositeSatisfied(st) { + allowDone = false + } + if !allowDone { + if pass, _, _, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied && pass && scheduleRefineIsRequiredCompositeSatisfied(st) { + allowDone = true + } + } + if !allowDone { + observation.Reflect = fmt.Sprintf("模型返回 done=true,但任务 id=%d 尚未满足切片目标或复合工具门禁未通过,继续执行。", taskID) + st.ObservationHistory = append(st.ObservationHistory, observation) + emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 拒绝提前 done:当前任务未满足目标。", taskID)) + continue + } + reasonText := scheduleRefineFallbackText(strings.TrimSpace(parsed.Summary), "模型判定当前任务已满足目标。") + observation.Reflect = reasonText + st.ObservationHistory = append(st.ObservationHistory, observation) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 完成:%s", taskID, reasonText)) + emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) + taskDone = true + break + } + + call, warn := pickSingleScheduleRefineToolCall(parsed.ToolCalls) + if warn != "" { + emitStage("schedule_refine.react.round_warn", fmt.Sprintf("第 %d 轮告警:%s", round, warn)) + } + if call == nil { + observation.Reflect = "本轮未生成可执行工具动作。" + st.ObservationHistory = append(st.ObservationHistory, observation) + emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) + break + } + normalizedCall := scheduleRefineCanonicalizeToolCall(*call) + call = &normalizedCall + emitStage("schedule_refine.react.tool_call", scheduleRefineFormatToolCallStageDetail(round, *call, remainingAction)) + + callSignature := scheduleRefineBuildToolCallSignature(*call) + taskIDs := scheduleRefineListTaskIDsFromToolCall(*call) + if blockedResult, blocked := scheduleRefinePrecheckCurrentTaskOwnership(*call, taskIDs, taskID); blocked { + if stop, err := scheduleRefineHandleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil { + return st, err + } else if stop { + taskDone = true + break + } + continue + } + if blockedResult, blocked := scheduleRefinePrecheckToolCallPolicy(st, *call, taskIDs); blocked { + if stop, err := scheduleRefineHandleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil { + return st, err + } else if stop { + taskDone = true + break + } + continue + } + if scheduleRefineIsRepeatedFailedCall(st, callSignature) { + repeat := scheduleRefineReactToolResult{Tool: strings.TrimSpace(call.Tool), Success: false, ErrorCode: "REPEAT_FAILED_ACTION", Result: "重复失败动作:与上一轮失败动作完全相同,请更换目标时段或改用 Swap。"} + if stop, err := scheduleRefineHandleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, repeat, &observation); err != nil { + return st, err + } else if stop { + taskDone = true + break + } + continue + } + + for _, id := range taskIDs { + st.TaskActionUsed[id]++ + } + nextEntries, rawResult := dispatchScheduleRefineTool(cloneHybridEntries(st.HybridEntries), *call, window, policy) + result := scheduleRefineNormalizeToolResult(rawResult) + st.RoundUsed++ + scheduleRefineMarkCompositeToolOutcome(st, result.Tool, result.Success) + + observation.ToolName = strings.TrimSpace(result.Tool) + observation.ToolParams = scheduleRefineCloneToolParams(call.Params) + observation.ToolSuccess = result.Success + observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) + observation.ToolResult = strings.TrimSpace(result.Result) + postReflectText, _, shouldStop := scheduleRefineRunPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) + observation.Reflect = postReflectText + st.ObservationHistory = append(st.ObservationHistory, observation) + + emitStage("schedule_refine.react.tool_result", scheduleRefineFormatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) + emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) + if result.Success { + st.HybridEntries = nextEntries + window = scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries) + if scheduleRefineIsMutatingToolName(result.Tool) { + st.EntriesVersion++ + } + st.LastFailedCallSignature = "" + st.ConsecutiveFailures = 0 + st.ThinkingBoostArmed = false + // 1. 动作成功后立即尝试全局短路,避免继续拉着后续任务空转。 + // 2. 只要 deterministic 目标达成,直接收口整个 ReAct 循环。 + if pass, reason, _, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied && pass { + if scheduleRefineIsRequiredCompositeSatisfied(st) { + emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("动作后全局目标达成,提前结束任务循环:%s", scheduleRefineTruncate(reason, 160))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后全局目标达成,触发短路结束:%s", reason)) + taskDone = true + break outer + } + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后目标达成但复合工具门禁未通过:required=%s", scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool))) + } + if latest, exists := scheduleRefineFindSuggestedEntryByTaskID(st.HybridEntries, taskID); exists { + current = latest + if scheduleRefineIsCurrentTaskSatisfiedBySlice(current, st.SlicePlan) { + if scheduleRefineIsRequiredCompositeSatisfied(st) { + taskDone = true + emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已满足切片目标,自动结束当前任务。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:动作后已满足切片目标。", taskID)) + break + } + emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 动作后满足切片目标,但复合工具门禁未通过,继续执行。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止动作后自动收口:required_composite=%s 尚未成功。", taskID, scheduleRefineFallbackText(scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool), "无"))) + } + } else { + taskDone = true + emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已不在 suggested 列表,自动结束当前任务。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:动作后不再可调。", taskID)) + break + } + } else { + st.LastFailedCallSignature = callSignature + st.ConsecutiveFailures++ + if scheduleRefineShouldTriggerReplan(st, result) { + if replanned, err := scheduleRefineTryReplan(ctx, chatModel, st, emitStage); err != nil { + return st, err + } else if replanned { + continue + } + } + } + if shouldStop { + // 1. 模型建议 should_stop 只作为“候选中断信号”,必须经后端目标校验确认。 + // 2. 若全局目标未达成,则继续本地循环,避免模型误停。 + if pass, reason, _, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied && pass { + if scheduleRefineIsRequiredCompositeSatisfied(st) { + emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("模型建议停止且全局目标达成,提前收口:%s", scheduleRefineTruncate(reason, 160))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止且目标达成,触发短路结束:%s", reason)) + taskDone = true + break outer + } + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止但复合工具门禁未通过:required=%s", scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool))) + } + } + } + + emitStage("schedule_refine.react.task_done", fmt.Sprintf("任务 id=%d 处理完成:status=%s。", taskID, scheduleRefineTaskProgressLabel(taskDone, st.CurrentTaskAttempt, st.PerTaskBudget))) + st.WorksetCursor++ + st.CurrentTaskID = 0 + st.CurrentTaskAttempt = 0 + } + emitStage("schedule_refine.react.done", fmt.Sprintf("单任务微步 ReAct 结束:已执行轮次=%d,重规划次数=%d,已处理任务=%d/%d。", st.RoundUsed, st.ReplanUsed, st.WorksetCursor, len(st.WorksetTaskIDs))) + return st, nil +} + +func scheduleRefineRunHardCheckNode( + 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", "正在执行终审硬校验。") + // 1. 先锁定“业务目标是否达成”的判定结果(未排序前)。 + // 2. 后续顺序归位仅用于最终展示与顺序一致性,不得反向改变业务目标成败。 + intentPassLocked, intentReasonLocked, intentUnmetLocked := scheduleRefineEvaluateIntentForJudgement(ctx, chatModel, st, emitStage) + emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("终审业务目标已锁定:pass=%t,reason=%s", intentPassLocked, scheduleRefineTruncate(intentReasonLocked, 120))) + if changed, skipped := scheduleRefineTryNormalizeMovableTaskOrderByOrigin(st); skipped { + emitStage("schedule_refine.hard_check.order_normalized", "已跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。") + } else if changed { + emitStage("schedule_refine.hard_check.order_normalized", "已在终审前按 origin_rank 对坑位做顺序归位。") + } + report := scheduleRefineEvaluateHardChecks(ctx, chatModel, st, emitStage) + report.IntentPassed = intentPassLocked + report.IntentReason = intentReasonLocked + report.IntentUnmet = append([]string(nil), intentUnmetLocked...) + 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 := scheduleRefineRunSingleRepairAction(ctx, chatModel, st, emitStage); err != nil { + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("修复动作失败:%v", err)) + emitStage("schedule_refine.hard_check.fail", "修复动作失败,保留当前方案。") + return st, nil + } + intentPassLocked, intentReasonLocked, intentUnmetLocked = scheduleRefineEvaluateIntentForJudgement(ctx, chatModel, st, emitStage) + emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("修复后业务目标已锁定:pass=%t,reason=%s", intentPassLocked, scheduleRefineTruncate(intentReasonLocked, 120))) + if changed, skipped := scheduleRefineTryNormalizeMovableTaskOrderByOrigin(st); skipped { + emitStage("schedule_refine.hard_check.order_normalized", "修复后跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。") + } else if changed { + emitStage("schedule_refine.hard_check.order_normalized", "修复后已按 origin_rank 对坑位做顺序归位。") + } + report = scheduleRefineEvaluateHardChecks(ctx, chatModel, st, emitStage) + report.IntentPassed = intentPassLocked + report.IntentReason = intentReasonLocked + report.IntentUnmet = append([]string(nil), intentUnmetLocked...) + 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 +} + +func scheduleRefineRunSummaryNode( + 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", "正在生成微调结果总结。") + scheduleRefineUpdateAllocatedItemsFromEntries(st) + st.CandidatePlans = scheduleRefineHybridEntriesToWeekSchedules(st.HybridEntries) + reportJSON, _ := json.Marshal(st.HardCheck) + contractJSON, _ := json.Marshal(st.Contract) + userPrompt := fmt.Sprintf("用户请求=%s\n契约=%s\n终审报告=%s\n动作日志=%s", strings.TrimSpace(st.UserMessage), string(contractJSON), string(reportJSON), scheduleRefineSummarizeActionLogs(st.ActionLogs, 24)) + raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineSummaryPrompt, userPrompt, false, 280, 0.35) + summary := strings.TrimSpace(raw) + if err == nil { + scheduleRefineEmitModelRawDebug(emitStage, "summary", raw) + } + if err != nil || summary == "" { + if FinalHardCheckPassed(st) { + summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) + } else { + summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, scheduleRefineFallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) + } + } + summary = scheduleRefineAlignSummaryWithHardCheck(st, summary) + st.FinalSummary = summary + // 1. Completed 只代表“最终终审已通过”,不再把“链路执行完毕”误写成成功; + // 2. 这样外层持久化与展示层可以准确区分“已通过方案”与“当前最优但未达标方案”; + // 3. 若只是返回 best-effort 结果,FinalSummary 仍会保留,但 Completed=false。 + st.Completed = FinalHardCheckPassed(st) + emitStage("schedule_refine.summary.done", "微调总结已生成。") + return st, nil +} + +func scheduleRefineEvaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) HardCheckReport { + report := HardCheckReport{} + report.PhysicsIssues = scheduleRefinePhysicsCheck(st.HybridEntries, len(st.AllocatedItems)) + report.PhysicsPassed = len(report.PhysicsIssues) == 0 + // 1. 顺序校验默认开启:即便执行期放开顺序限制,终审也要验证“后端归位”后的顺序正确性。 + // 2. 但 MinContextSwitch 成功后,重排后的顺序本身就是业务目标,不能再拿 origin_rank 反向判错。 + // 3. 当 origin_order_map 为空时同样降级跳过,避免无基线时误报。 + needOrderCheck := len(st.OriginOrderMap) > 0 && !scheduleRefineShouldSkipOrderConstraintCheck(st) + report.OrderIssues = scheduleRefineValidateRelativeOrder(st.HybridEntries, scheduleRefineToolPolicy{ + KeepRelativeOrder: needOrderCheck, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + }) + report.OrderPassed = len(report.OrderIssues) == 0 + + // 1. 优先使用“契约编译后”的确定性终审,执行与终审共用同一份目标约束。 + // 2. 仅当目标约束不可判定时,才回退语义终审兜底。 + if pass, reason, unmet, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied { + pass, reason, unmet = scheduleRefineApplyCompositeGateToIntentResult(st, pass, reason, unmet) + report.IntentPassed = pass + report.IntentReason = strings.TrimSpace(reason) + report.IntentUnmet = append([]string(nil), unmet...) + return report + } + + review, err := scheduleRefineRunSemanticReview(ctx, chatModel, st, emitStage) + if err != nil { + report.IntentPassed = false + report.IntentReason = fmt.Sprintf("语义校验失败:%v", err) + report.IntentUnmet = []string{"语义校验阶段异常"} + return report + } + pass, reason, unmet := scheduleRefineApplyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet) + report.IntentPassed = pass + report.IntentReason = strings.TrimSpace(reason) + report.IntentUnmet = append([]string(nil), unmet...) + return report +} + +// scheduleRefineEvaluateIntentForJudgement 在“最终排序前”计算业务目标是否达成。 +// +// 说明: +// 1. 优先走 deterministic objective; +// 2. objective 不可判定时退回语义 review; +// 3. 返回值会在 hard_check 中被锁定,避免后置排序反向干扰业务目标判定。 +func scheduleRefineEvaluateIntentForJudgement( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (pass bool, reason string, unmet []string) { + if pass, reason, unmet, applied := scheduleRefineEvaluateObjectiveDeterministic(st); applied { + pass, reason, unmet = scheduleRefineApplyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet) + return pass, strings.TrimSpace(reason), append([]string(nil), unmet...) + } + review, err := scheduleRefineRunSemanticReview(ctx, chatModel, st, emitStage) + if err != nil { + return false, fmt.Sprintf("语义校验失败:%v", err), []string{"语义校验阶段异常"} + } + pass, reason, unmet = scheduleRefineApplyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet) + return pass, strings.TrimSpace(reason), append([]string(nil), unmet...) +} + +// scheduleRefineCompileRefineObjective 把自然语言契约编译为“可执行且可校验”的目标参数。 +func scheduleRefineCompileRefineObjective(st *ScheduleRefineState, slice RefineSlicePlan) RefineObjective { + obj := RefineObjective{ + Mode: "none", + SourceWeeks: scheduleRefineKeysOfIntSet(scheduleRefineInferSourceWeekSet(slice)), + TargetWeeks: scheduleRefineKeysOfIntSet(scheduleRefineInferTargetWeekSet(slice)), + SourceDays: scheduleRefineUniquePositiveInts(append([]int(nil), slice.SourceDays...)), + TargetDays: scheduleRefineUniquePositiveInts(append([]int(nil), slice.TargetDays...)), + ExcludeSections: scheduleRefineUniquePositiveInts(append([]int(nil), slice.ExcludeSections...)), + } + // 1. 若契约断言显式给出来源/目标周,优先回填到 objective; + // 2. 避免后续终审只能依赖自然语言猜测。 + for _, assertion := range st.Contract.HardAssertions { + if assertion.Week > 0 && len(obj.SourceWeeks) == 0 { + obj.SourceWeeks = []int{assertion.Week} + } + if assertion.TargetWeek > 0 && len(obj.TargetWeeks) == 0 { + obj.TargetWeeks = []int{assertion.TargetWeek} + } + } + + if len(obj.SourceWeeks) == 0 && len(slice.WeekFilter) == 1 && slice.WeekFilter[0] > 0 { + obj.SourceWeeks = []int{slice.WeekFilter[0]} + } + if len(obj.TargetWeeks) == 0 && len(slice.WeekFilter) == 1 && (len(obj.SourceDays) > 0 || len(obj.TargetDays) > 0) { + obj.TargetWeeks = []int{slice.WeekFilter[0]} + } + + // 来源范围为空时无法构造目标,交给语义终审兜底。 + if len(obj.SourceWeeks) == 0 && len(obj.SourceDays) == 0 { + obj.Reason = "来源范围为空,未启用确定性目标。" + return obj + } + // 目标范围为空时同样不启用确定性目标。 + if len(obj.TargetWeeks) == 0 && len(obj.TargetDays) == 0 { + obj.Reason = "目标范围为空,未启用确定性目标。" + return obj + } + + sourceTaskIDs := scheduleRefineCollectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, slice.WeekFilter) + obj.BaselineSourceTaskCount = len(sourceTaskIDs) + + halfIntent := scheduleRefineHasHalfTransferIntent(st) + if halfIntent && len(obj.SourceWeeks) > 0 && len(obj.TargetWeeks) > 0 && !scheduleRefineIsSameWeeks(obj.SourceWeeks, obj.TargetWeeks) { + obj.Mode = "move_ratio" + obj.RequiredMoveMin = obj.BaselineSourceTaskCount / 2 + obj.RequiredMoveMax = (obj.BaselineSourceTaskCount + 1) / 2 + obj.Reason = "检测到“半数迁移”意图,按比例目标执行与终审。" + return obj + } + + obj.Mode = "move_all" + obj.RequiredMoveMin = obj.BaselineSourceTaskCount + obj.RequiredMoveMax = obj.BaselineSourceTaskCount + obj.Reason = "默认按来源范围任务全部进入目标范围执行与终审。" + return obj +} + +// scheduleRefineEvaluateObjectiveDeterministic 基于编译后的目标做确定性终审。 +func scheduleRefineEvaluateObjectiveDeterministic(st *ScheduleRefineState) (pass bool, reason string, unmet []string, applied bool) { + if st == nil { + return false, "", nil, false + } + obj := st.Objective + if strings.TrimSpace(obj.Mode) == "" || strings.TrimSpace(obj.Mode) == "none" { + return false, "", nil, false + } + + sourceTaskIDs := scheduleRefineCollectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, st.SlicePlan.WeekFilter) + if len(sourceTaskIDs) == 0 { + return true, "确定性校验通过:来源范围无可调任务。", nil, true + } + + byTaskID := scheduleRefineBuildMovableTaskIndex(st.HybridEntries) + movedCount := 0 + violations := make([]string, 0, 8) + for _, taskID := range sourceTaskIDs { + entries := byTaskID[taskID] + if len(entries) == 0 { + violations = append(violations, fmt.Sprintf("任务id=%d 未在结果中找到可移动条目", taskID)) + continue + } + if len(entries) > 1 { + violations = append(violations, fmt.Sprintf("任务id=%d 命中 %d 条可移动条目,状态不唯一", taskID, len(entries))) + continue + } + entry := entries[0] + moved, why := scheduleRefineIsTaskMovedIntoObjectiveTarget(entry, obj) + if moved { + movedCount++ + continue + } + if obj.Mode == "move_all" { + violations = append(violations, fmt.Sprintf("任务id=%d 未满足目标范围:%s", taskID, why)) + continue + } + // 比例模式下,允许部分任务不迁移;但若任务落在来源/目标之外,视为异常。 + if !scheduleRefineIsTaskInObjectiveSource(entry, obj) { + violations = append(violations, fmt.Sprintf("任务id=%d 既不在来源也不在目标范围(W%dD%d)", taskID, entry.Week, entry.DayOfWeek)) + } + } + + if movedCount < obj.RequiredMoveMin || movedCount > obj.RequiredMoveMax { + violations = append(violations, fmt.Sprintf("迁移数量未达标:要求在[%d,%d],实际=%d", obj.RequiredMoveMin, obj.RequiredMoveMax, movedCount)) + } + + if len(violations) == 0 { + return true, fmt.Sprintf("确定性校验通过:迁移数量达标(%d/%d)。", movedCount, len(sourceTaskIDs)), nil, true + } + return false, fmt.Sprintf("确定性校验未通过:仍有 %d 项约束未满足。", len(violations)), violations, true +} + +func scheduleRefineCollectSourceTaskIDsForObjective(entries []model.HybridScheduleEntry, obj RefineObjective, fallbackWeekFilter []int) []int { + if len(entries) == 0 { + return nil + } + sourceWeekSet := scheduleRefineIntSliceToWeekSet(obj.SourceWeeks) + sourceDaySet := scheduleRefineIntSliceToDaySet(obj.SourceDays) + fallbackWeekSet := scheduleRefineIntSliceToWeekSet(fallbackWeekFilter) + + seen := make(map[int]struct{}, len(entries)) + ids := make([]int, 0, len(entries)) + for _, entry := range entries { + if !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + if len(sourceWeekSet) > 0 { + if _, ok := sourceWeekSet[entry.Week]; !ok { + continue + } + } else if len(fallbackWeekSet) > 0 { + if _, ok := fallbackWeekSet[entry.Week]; !ok { + continue + } + } + if len(sourceDaySet) > 0 { + if _, ok := sourceDaySet[entry.DayOfWeek]; !ok { + continue + } + } + if _, exists := seen[entry.TaskItemID]; exists { + continue + } + seen[entry.TaskItemID] = struct{}{} + ids = append(ids, entry.TaskItemID) + } + sort.Ints(ids) + return ids +} + +func scheduleRefineBuildMovableTaskIndex(entries []model.HybridScheduleEntry) map[int][]model.HybridScheduleEntry { + index := make(map[int][]model.HybridScheduleEntry, len(entries)) + for _, entry := range entries { + if !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + index[entry.TaskItemID] = append(index[entry.TaskItemID], entry) + } + return index +} + +func scheduleRefineHasHalfTransferIntent(st *ScheduleRefineState) bool { + if st == nil { + return false + } + if scheduleRefineHasHalfTransferAssertion(st.Contract.HardAssertions) { + return true + } + joined := strings.ToLower(strings.Join(append([]string{st.UserMessage, st.Contract.Intent}, st.Contract.HardRequirements...), " ")) + for _, key := range []string{"一半", "半数", "对半", "50%"} { + if strings.Contains(joined, key) { + return true + } + } + return false +} + +func scheduleRefineHasHalfTransferAssertion(assertions []RefineAssertion) bool { + for _, item := range assertions { + metric := strings.ToLower(strings.TrimSpace(item.Metric)) + if metric == "" { + continue + } + switch metric { + case "source_move_ratio_percent", "move_ratio_percent", "half_transfer_ratio": + switch strings.TrimSpace(item.Operator) { + case "==", ">=", "<=", "between": + if item.Value == 50 || item.Min == 50 || item.Max == 50 { + return true + } + } + case "source_remaining_count": + // 1. 该断言常用于“迁走一半后来源剩余=一半”。 + // 2. 具体阈值是否满足由 objective + deterministic 校验统一判定。 + return true + } + } + return false +} + +func scheduleRefineNormalizeHardAssertions(raw []scheduleRefineHardAssertionOutput) []RefineAssertion { + if len(raw) == 0 { + return nil + } + out := make([]RefineAssertion, 0, len(raw)) + for _, item := range raw { + metric := strings.TrimSpace(item.Metric) + if metric == "" { + continue + } + operator := strings.TrimSpace(item.Operator) + if operator == "" { + operator = "==" + } + assertion := RefineAssertion{ + Metric: metric, + Operator: operator, + Value: item.Value, + Min: item.Min, + Max: item.Max, + Week: item.Week, + TargetWeek: item.TargetWeek, + } + out = append(out, assertion) + } + if len(out) == 0 { + return nil + } + return out +} + +func scheduleRefineInferHardAssertionsFromRequest(message string, requirements []string) []RefineAssertion { + joined := strings.TrimSpace(message + " " + strings.Join(requirements, " ")) + if joined == "" { + return nil + } + weeks := scheduleRefineExtractWeekFilters(joined) + if !scheduleRefineContainsAny(strings.ToLower(joined), []string{"一半", "半数", "对半", "50%"}) { + return nil + } + // 1. 兜底断言:要求来源任务迁移比例为 50%。 + // 2. week/target_week 使用文本中前两个周次,便于后续 objective 编译。 + assertion := RefineAssertion{ + Metric: "source_move_ratio_percent", + Operator: "==", + Value: 50, + } + if len(weeks) > 0 { + assertion.Week = weeks[0] + } + if len(weeks) > 1 { + assertion.TargetWeek = weeks[1] + } + return []RefineAssertion{assertion} +} + +func scheduleRefineIsTaskMovedIntoObjectiveTarget(entry model.HybridScheduleEntry, obj RefineObjective) (bool, string) { + targetWeekSet := scheduleRefineIntSliceToWeekSet(obj.TargetWeeks) + targetDaySet := scheduleRefineIntSliceToDaySet(obj.TargetDays) + excludedSections := scheduleRefineIntSliceToSectionSet(obj.ExcludeSections) + if len(targetWeekSet) > 0 { + if _, ok := targetWeekSet[entry.Week]; !ok { + return false, fmt.Sprintf("week=%d 不在目标周", entry.Week) + } + } + if len(targetDaySet) > 0 { + if _, ok := targetDaySet[entry.DayOfWeek]; !ok { + return false, fmt.Sprintf("day_of_week=%d 不在目标日", entry.DayOfWeek) + } + } + if len(excludedSections) > 0 && scheduleRefineIntersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSections) { + return false, fmt.Sprintf("section=%d-%d 命中排除节次", entry.SectionFrom, entry.SectionTo) + } + return true, "" +} + +func scheduleRefineIsTaskInObjectiveSource(entry model.HybridScheduleEntry, obj RefineObjective) bool { + sourceWeekSet := scheduleRefineIntSliceToWeekSet(obj.SourceWeeks) + sourceDaySet := scheduleRefineIntSliceToDaySet(obj.SourceDays) + if len(sourceWeekSet) > 0 { + if _, ok := sourceWeekSet[entry.Week]; !ok { + return false + } + } + if len(sourceDaySet) > 0 { + if _, ok := sourceDaySet[entry.DayOfWeek]; !ok { + return false + } + } + return true +} + +func scheduleRefineIsSameWeeks(left []int, right []int) bool { + if len(left) == 0 || len(right) == 0 { + return false + } + lset := scheduleRefineIntSliceToWeekSet(left) + rset := scheduleRefineIntSliceToWeekSet(right) + if len(lset) != len(rset) { + return false + } + for w := range lset { + if _, ok := rset[w]; !ok { + return false + } + } + return true +} + +// scheduleRefineNormalizeMovableTaskOrderByOrigin 在“坑位不变”的前提下,按 origin_rank 归位任务顺序。 +// +// 步骤化说明: +// 1. 先提取所有可移动任务的当前坑位(week/day/section); +// 2. 再按任务跨度分组,避免把 2 节任务塞进 3 节坑位; +// 3. 每个跨度组内按坑位时间升序与 origin_rank 升序做一一映射; +// 4. 最终只改“任务身份落到哪个坑位”,不改坑位分布本身。 +func scheduleRefineNormalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) bool { + if st == nil || len(st.HybridEntries) <= 1 || len(st.OriginOrderMap) == 0 { + return false + } + entries := cloneHybridEntries(st.HybridEntries) + indices := make([]int, 0, len(entries)) + for idx, entry := range entries { + if scheduleRefineIsMovableSuggestedTask(entry) { + indices = append(indices, idx) + } + } + if len(indices) <= 1 { + return false + } + + type slot struct { + Week int + DayOfWeek int + SectionFrom int + SectionTo int + } + groupSlots := make(map[int][]slot) // key=span + groupTasks := make(map[int][]model.HybridScheduleEntry) // key=span + for _, idx := range indices { + entry := entries[idx] + span := entry.SectionTo - entry.SectionFrom + 1 + groupSlots[span] = append(groupSlots[span], slot{ + Week: entry.Week, + DayOfWeek: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + SectionTo: entry.SectionTo, + }) + groupTasks[span] = append(groupTasks[span], entry) + } + + changed := false + spanKeys := make([]int, 0, len(groupSlots)) + for span := range groupSlots { + spanKeys = append(spanKeys, span) + } + sort.Ints(spanKeys) + + groupCursor := make(map[int]int, len(groupSlots)) + for _, span := range spanKeys { + slots := groupSlots[span] + tasks := groupTasks[span] + if len(slots) != len(tasks) || len(slots) == 0 { + continue + } + sort.SliceStable(slots, func(i, j int) bool { + if slots[i].Week != slots[j].Week { + return slots[i].Week < slots[j].Week + } + if slots[i].DayOfWeek != slots[j].DayOfWeek { + return slots[i].DayOfWeek < slots[j].DayOfWeek + } + if slots[i].SectionFrom != slots[j].SectionFrom { + return slots[i].SectionFrom < slots[j].SectionFrom + } + return slots[i].SectionTo < slots[j].SectionTo + }) + sort.SliceStable(tasks, func(i, j int) bool { + ri := st.OriginOrderMap[tasks[i].TaskItemID] + rj := st.OriginOrderMap[tasks[j].TaskItemID] + if ri <= 0 { + ri = 1 << 30 + } + if rj <= 0 { + rj = 1 << 30 + } + if ri != rj { + return ri < rj + } + if tasks[i].Week != tasks[j].Week { + return tasks[i].Week < tasks[j].Week + } + if tasks[i].DayOfWeek != tasks[j].DayOfWeek { + return tasks[i].DayOfWeek < tasks[j].DayOfWeek + } + if tasks[i].SectionFrom != tasks[j].SectionFrom { + return tasks[i].SectionFrom < tasks[j].SectionFrom + } + return tasks[i].TaskItemID < tasks[j].TaskItemID + }) + for i := range tasks { + tasks[i].Week = slots[i].Week + tasks[i].DayOfWeek = slots[i].DayOfWeek + tasks[i].SectionFrom = slots[i].SectionFrom + tasks[i].SectionTo = slots[i].SectionTo + } + groupTasks[span] = tasks + } + + for _, idx := range indices { + entry := entries[idx] + span := entry.SectionTo - entry.SectionFrom + 1 + cursor := groupCursor[span] + if cursor >= len(groupTasks[span]) { + continue + } + nextEntry := groupTasks[span][cursor] + groupCursor[span] = cursor + 1 + if entry.TaskItemID != nextEntry.TaskItemID || + entry.Week != nextEntry.Week || + entry.DayOfWeek != nextEntry.DayOfWeek || + entry.SectionFrom != nextEntry.SectionFrom || + entry.SectionTo != nextEntry.SectionTo { + changed = true + } + entries[idx] = nextEntry + } + if !changed { + return false + } + scheduleRefineSortHybridEntries(entries) + st.HybridEntries = entries + return true +} + +// scheduleRefineTryNormalizeMovableTaskOrderByOrigin 决定是否执行“按 origin_rank 顺序归位”。 +// +// 步骤化说明: +// 1. 默认仍保持旧行为,继续在终审前做展示侧顺序归位; +// 2. 但当 MinContextSwitch 已成功执行时,重排后的顺序本身就是业务目标的一部分; +// 3. 此时若再按 origin_rank 归位,会把复合工具效果直接抹掉,因此必须跳过。 +func scheduleRefineTryNormalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) (changed bool, skipped bool) { + if scheduleRefineShouldSkipOriginOrderNormalization(st) { + return false, true + } + return scheduleRefineNormalizeMovableTaskOrderByOrigin(st), false +} + +func scheduleRefineShouldSkipOriginOrderNormalization(st *ScheduleRefineState) bool { + if st == nil { + return false + } + scheduleRefineEnsureCompositeStateMaps(st) + if st.CompositeToolSuccess["MinContextSwitch"] { + return true + } + return false +} + +func scheduleRefineShouldSkipOrderConstraintCheck(st *ScheduleRefineState) bool { + return scheduleRefineShouldSkipOriginOrderNormalization(st) +} + +func scheduleRefineRunSingleRepairAction(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 := scheduleRefineWithNearestJSONContract( + fmt.Sprintf( + "用户请求=%s\n契约=%s\n未满足点=%s\n当前混合日程JSON=%s\nMove标准Schema={task_item_id,to_week,to_day,to_section_from,to_section_to}\nSwap标准Schema={task_a,task_b}", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + strings.Join(st.HardCheck.IntentUnmet, ";"), + string(entriesJSON), + ), + jsonContractForReact, + ) + raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineRepairPrompt, userPrompt, false, 240, 0.15) + if err != nil { + return err + } + scheduleRefineEmitModelRawDebug(emitStage, "repair", raw) + parsed, parseErr := parseScheduleRefineLLMOutput(raw) + if parseErr != nil { + return parseErr + } + call, warn := pickSingleScheduleRefineToolCall(parsed.ToolCalls) + if warn != "" { + st.ActionLogs = append(st.ActionLogs, "修复阶段告警:"+warn) + } + if call == nil { + return fmt.Errorf("修复阶段未给出可执行动作") + } + normalizedCall := scheduleRefineCanonicalizeToolCall(*call) + call = &normalizedCall + if !scheduleRefineIsMutatingToolName(strings.TrimSpace(call.Tool)) { + return fmt.Errorf("修复阶段工具不允许:%s(仅允许 Move/Swap/BatchMove)", strings.TrimSpace(call.Tool)) + } + emitStage("schedule_refine.hard_check.repair_call", scheduleRefineFormatToolCallStageDetail(st.RoundUsed+1, *call, st.MaxRounds-st.RoundUsed)) + nextEntries, result := dispatchScheduleRefineTool(cloneHybridEntries(st.HybridEntries), *call, scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries), scheduleRefineToolPolicy{ + KeepRelativeOrder: false, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + }) + result = scheduleRefineNormalizeToolResult(result) + st.RoundUsed++ + emitStage("schedule_refine.hard_check.repair_result", scheduleRefineFormatToolResultStageDetail(st.RoundUsed, result, st.RoundUsed, st.MaxRounds)) + if !result.Success { + st.LastFailedCallSignature = scheduleRefineBuildToolCallSignature(*call) + return fmt.Errorf("修复动作执行失败:%s", result.Result) + } + st.LastFailedCallSignature = "" + st.HybridEntries = nextEntries + if scheduleRefineIsMutatingToolName(result.Tool) { + st.EntriesVersion++ + } + return nil +} + +func scheduleRefineRunSemanticReview(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) (*scheduleRefineReviewOutput, error) { + entriesJSON, _ := json.Marshal(st.HybridEntries) + contractJSON, _ := json.Marshal(st.Contract) + userPrompt := scheduleRefineWithNearestJSONContract( + 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), + scheduleRefineBuildSuggestedDigest(st.HybridEntries, 80), + scheduleRefineSummarizeActionLogs(st.ActionLogs, 12), + string(entriesJSON), + ), + jsonContractForReview, + ) + raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineReviewPrompt, userPrompt, false, 240, 0) + if err != nil { + return nil, err + } + scheduleRefineEmitModelRawDebug(emitStage, "review", raw) + return parseScheduleRefineReviewOutput(raw) +} + +func scheduleRefineRunPostReflectAfterTool( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + round int, + plan *scheduleRefineReactLLMOutput, + call *scheduleRefineReactToolCall, + result scheduleRefineReactToolResult, + emitStage func(stage, detail string), +) (string, string, bool) { + if st == nil || chatModel == nil || call == nil { + return scheduleRefineBuildPostReflectFallback(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) + planDecision := "" + if plan != nil { + planDecision = strings.TrimSpace(plan.Decision) + } + userPrompt := scheduleRefineWithNearestJSONContract( + fmt.Sprintf( + "用户请求=%s\n契约=%s\n本轮计划.decision=%s\n本轮工具调用=%s\n本轮工具结果=%s\n最近观察=%s", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + planDecision, + string(callJSON), + string(resultJSON), + scheduleRefineBuildObservationPrompt(st.ObservationHistory, 2), + ), + jsonContractForPostReflect, + ) + raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefinePostReflectPrompt, userPrompt, false, 220, 0) + if err != nil { + fallback := scheduleRefineBuildPostReflectFallback(plan, result) + emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思失败,改用后端兜底复盘:%s", round, scheduleRefineTruncate(err.Error(), 160))) + return fallback, "", false + } + scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("post_reflect.round.%d", round), raw) + parsed, parseErr := scheduleRefineParseJSON[scheduleRefinePostReflectOutput](raw) + if parseErr != nil { + fallback := scheduleRefineBuildPostReflectFallback(plan, result) + emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思解析失败,改用后端兜底复盘:%s", round, scheduleRefineTruncate(parseErr.Error(), 160))) + return fallback, "", false + } + reflection := strings.TrimSpace(parsed.Reflection) + if reflection == "" { + reflection = scheduleRefineBuildPostReflectFallback(plan, result) + } + nextStrategy := strings.TrimSpace(parsed.NextStrategy) + if nextStrategy != "" { + reflection = fmt.Sprintf("%s;下一步建议:%s", reflection, nextStrategy) + } + shouldStop := parsed.ShouldStop + emitStage("schedule_refine.react.post_reflect.done", fmt.Sprintf("第 %d 轮|模型反思=%s|下一步=%s|should_stop=%t", round, scheduleRefineTruncate(strings.TrimSpace(parsed.Reflection), 120), scheduleRefineTruncate(nextStrategy, 120), shouldStop)) + return reflection, nextStrategy, shouldStop +} + +func scheduleRefineBuildPostReflectFallback(plan *scheduleRefineReactLLMOutput, result scheduleRefineReactToolResult) string { + modelReflect := "" + if plan != nil { + modelReflect = strings.TrimSpace(plan.Decision) + } + return scheduleRefineBuildRuntimeReflect(modelReflect, result) +} + +func scheduleRefineRunPlannerNode( + 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") + } + scheduleRefineEnsureCompositeStateMaps(st) + // 1. 正常模式下由后端判定“本轮必用复合工具”。 + // 2. 若已进入禁复合兜底模式,必须清空该标记,避免规划阶段再次把复合门禁写回去。 + if st.DisableCompositeTools { + st.RequiredCompositeTool = "" + } else { + st.RequiredCompositeTool = scheduleRefineDetectRequiredCompositeTool(st) + } + 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) + userPrompt := scheduleRefineWithNearestJSONContract( + fmt.Sprintf( + "mode=%s\n用户请求=%s\n契约=%s\n上一轮工具观察=%s\n最近观察=%s\nsuggested简表=%s", + mode, + strings.TrimSpace(st.UserMessage), + string(contractJSON), + scheduleRefineBuildLastToolObservationPrompt(st.ObservationHistory), + scheduleRefineBuildObservationPrompt(st.ObservationHistory, 2), + scheduleRefineBuildSuggestedDigest(st.HybridEntries, 40), + ), + jsonContractForPlanner, + ) + raw, err := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefinePlannerPrompt, userPrompt, false, plannerMaxTokens, 0) + if err != nil { + st.CurrentPlan = scheduleRefineApplyCompositeHardConditionToPlan(st, scheduleRefineBuildFallbackPlan(st)) + st.BatchMoveAllowed = scheduleRefineShouldAllowBatchMove(st.CurrentPlan) + st.PlanUsed++ + emitStage("schedule_refine.plan.fallback", "Planner 调用失败,已切换后端兜底计划。") + return nil + } + scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("planner.%s", mode), raw) + parsed, parseErr := scheduleRefineParsePlannerOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, mode, emitStage) + if parseErr != nil { + st.CurrentPlan = scheduleRefineApplyCompositeHardConditionToPlan(st, scheduleRefineBuildFallbackPlan(st)) + st.BatchMoveAllowed = scheduleRefineShouldAllowBatchMove(st.CurrentPlan) + st.PlanUsed++ + emitStage("schedule_refine.plan.fallback", fmt.Sprintf("Planner 输出解析失败,已切换后端兜底计划:%s", scheduleRefineTruncate(parseErr.Error(), 180))) + return nil + } + st.CurrentPlan = PlannerPlan{ + Summary: scheduleRefineFallbackText(strings.TrimSpace(parsed.Summary), "已生成可执行计划。"), + Steps: scheduleRefineUniqueNonEmpty(parsed.Steps), + } + st.CurrentPlan = scheduleRefineApplyCompositeHardConditionToPlan(st, st.CurrentPlan) + st.BatchMoveAllowed = scheduleRefineShouldAllowBatchMove(st.CurrentPlan) + if st.DisableCompositeTools { + st.BatchMoveAllowed = false + } + st.PlanUsed++ + emitStage("schedule_refine.plan.done", fmt.Sprintf("规划完成:%s", scheduleRefineTruncate(st.CurrentPlan.Summary, 180))) + return nil +} + +func scheduleRefineHandleBlockedToolResult( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), + round int, + parsed *scheduleRefineReactLLMOutput, + call *scheduleRefineReactToolCall, + callSignature string, + blockedResult scheduleRefineReactToolResult, + observation *ReactRoundObservation, +) (bool, error) { + result := scheduleRefineNormalizeToolResult(blockedResult) + st.RoundUsed++ + st.LastFailedCallSignature = callSignature + st.ConsecutiveFailures++ + observation.ToolName = strings.TrimSpace(result.Tool) + observation.ToolParams = scheduleRefineCloneToolParams(call.Params) + observation.ToolSuccess = result.Success + observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) + observation.ToolResult = strings.TrimSpace(result.Result) + postReflectText, _, shouldStop := scheduleRefineRunPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) + observation.Reflect = postReflectText + st.ObservationHistory = append(st.ObservationHistory, *observation) + emitStage("schedule_refine.react.tool_blocked", fmt.Sprintf("第 %d 轮|动作被后端策略拦截:%s", round, scheduleRefineTruncate(result.Result, 120))) + emitStage("schedule_refine.react.tool_result", scheduleRefineFormatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) + emitStage("schedule_refine.react.reflect", scheduleRefineFormatReactReflectStageDetail(round, observation.Reflect)) + if scheduleRefineShouldTriggerReplan(st, result) { + if replanned, err := scheduleRefineTryReplan(ctx, chatModel, st, emitStage); err != nil { + return false, err + } else if replanned { + return false, nil + } + } + return shouldStop, nil +} + +func scheduleRefineBuildFallbackPlan(st *ScheduleRefineState) PlannerPlan { + summary := "兜底计划:先取证再动作,优先复合工具,其次 Move,冲突时尝试 Swap。" + if st != nil && st.Contract.KeepRelativeOrder { + summary = "兜底计划:先取证再动作,严格保持相对顺序,优先复合工具,其次 Move,冲突时尝试 Swap。" + } + return PlannerPlan{ + Summary: summary, + Steps: []string{ + "1) QueryTargetTasks 定位目标任务", + "2) QueryAvailableSlots 获取可用空位", + "3) 优先 SpreadEven/MinContextSwitch,其次 Move/Swap 执行动作并复盘", + "4) 收尾前执行 Verify 自检", + }, + } +} + +// scheduleRefineEnsureCompositeStateMaps 确保复合工具状态容器已初始化。 +func scheduleRefineEnsureCompositeStateMaps(st *ScheduleRefineState) { + if st == nil { + return + } + if st.CompositeToolCalled == nil { + st.CompositeToolCalled = map[string]bool{ + "SpreadEven": false, + "MinContextSwitch": false, + } + } + if st.CompositeToolSuccess == nil { + st.CompositeToolSuccess = map[string]bool{ + "SpreadEven": false, + "MinContextSwitch": false, + } + } +} + +// scheduleRefineDetectRequiredCompositeTool 根据请求语义识别本轮必用复合工具。 +// +// 规则: +// 1. “上下文切换最少/同科目连续”优先映射 MinContextSwitch; +// 2. “均匀分散/铺开”映射 SpreadEven; +// 3. 未命中时返回空串,不强制复合工具。 +func scheduleRefineDetectRequiredCompositeTool(st *ScheduleRefineState) string { + if st == nil { + return "" + } + joined := strings.TrimSpace(strings.Join([]string{ + st.UserMessage, + st.Contract.Intent, + strings.Join(st.Contract.HardRequirements, " "), + }, " ")) + if joined == "" { + return "" + } + contextKeys := []string{"上下文切换", "切换最少", "同个科目", "同科目", "连续处理", "连续学习", "min context", "context switch"} + if scheduleRefineContainsAny(strings.ToLower(joined), contextKeys) || scheduleRefineContainsAny(joined, contextKeys) { + return "MinContextSwitch" + } + evenKeys := []string{"均匀", "分散", "铺开", "平摊", "均摊", "spread even", "even spread"} + if scheduleRefineContainsAny(strings.ToLower(joined), evenKeys) || scheduleRefineContainsAny(joined, evenKeys) { + return "SpreadEven" + } + return "" +} + +// scheduleRefineApplyCompositeHardConditionToPlan 把“必用复合工具”硬条件注入计划文本。 +func scheduleRefineApplyCompositeHardConditionToPlan(st *ScheduleRefineState, plan PlannerPlan) PlannerPlan { + required := "" + if st != nil { + required = scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) + } + if required == "" { + return plan + } + + hardStep := fmt.Sprintf("硬条件:必须成功调用 %s(COMPOSITE_SUCCESS[%s]=true)后才允许整体收口", required, required) + hasHardStep := false + for _, step := range plan.Steps { + if strings.Contains(step, required) && strings.Contains(step, "COMPOSITE_SUCCESS") { + hasHardStep = true + break + } + } + if !hasHardStep { + plan.Steps = append([]string{hardStep}, plan.Steps...) + } + if !strings.Contains(plan.Summary, required) { + plan.Summary = strings.TrimSpace(plan.Summary + ";硬条件:" + required + " 成功==true") + } + return plan +} + +func scheduleRefineNormalizeCompositeToolName(name string) string { + switch strings.TrimSpace(name) { + case "SpreadEven": + return "SpreadEven" + case "MinContextSwitch": + return "MinContextSwitch" + default: + return "" + } +} + +func scheduleRefineIsCompositeToolName(toolName string) bool { + switch scheduleRefineNormalizeCompositeToolName(toolName) { + case "SpreadEven", "MinContextSwitch": + return true + default: + return false + } +} + +func scheduleRefineIsBaseMutatingToolName(toolName string) bool { + switch strings.TrimSpace(toolName) { + case "Move", "Swap", "BatchMove": + return true + default: + return false + } +} + +func scheduleRefineIsRequiredCompositeSatisfied(st *ScheduleRefineState) bool { + if st == nil { + return true + } + required := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) + if required == "" { + return true + } + scheduleRefineEnsureCompositeStateMaps(st) + return st.CompositeToolSuccess[required] +} + +// scheduleRefineApplyCompositeGateToIntentResult 把“必用复合工具成功”并入业务目标判定。 +// +// 步骤化说明: +// 1. 先判断原始业务判定是否通过;未通过则原样返回; +// 2. 再判断是否配置了必用复合工具;未配置则原样返回; +// 3. 若配置但未成功,强制改判为失败并补充 unmet 原因。 +func scheduleRefineApplyCompositeGateToIntentResult(st *ScheduleRefineState, pass bool, reason string, unmet []string) (bool, string, []string) { + if !pass { + return pass, reason, append([]string(nil), unmet...) + } + required := scheduleRefineNormalizeCompositeToolName("") + if st != nil { + required = scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) + } + if required == "" { + return pass, reason, append([]string(nil), unmet...) + } + if scheduleRefineIsRequiredCompositeSatisfied(st) { + return pass, reason, append([]string(nil), unmet...) + } + newUnmet := append([]string(nil), unmet...) + newUnmet = append(newUnmet, fmt.Sprintf("复合工具门禁未通过:%s 尚未成功调用", required)) + return false, fmt.Sprintf("复合工具门禁未通过:要求 %s 成功==true。", required), newUnmet +} + +func scheduleRefineMarkCompositeToolOutcome(st *ScheduleRefineState, toolName string, success bool) { + if st == nil { + return + } + tool := scheduleRefineNormalizeCompositeToolName(toolName) + if tool == "" { + return + } + scheduleRefineEnsureCompositeStateMaps(st) + st.CompositeToolCalled[tool] = true + if success { + st.CompositeToolSuccess[tool] = true + } +} + +func scheduleRefineShouldAllowBatchMove(plan PlannerPlan) bool { + text := strings.ToLower(strings.TrimSpace(plan.Summary)) + if strings.Contains(text, "batchmove") || strings.Contains(text, "batch move") { + return true + } + for _, step := range plan.Steps { + s := strings.ToLower(strings.TrimSpace(step)) + if strings.Contains(s, "batchmove") || strings.Contains(s, "batch move") { + return true + } + } + return false +} + +func scheduleRefineShouldEnableRecoveryThinking(st *ScheduleRefineState) (bool, string) { + if st == nil { + return false, "" + } + if st.ConsecutiveFailures < 2 || st.ThinkingBoostArmed { + return false, "" + } + st.ThinkingBoostArmed = true + return true, fmt.Sprintf("连续失败=%d,触发 1 轮恢复态 thinking", st.ConsecutiveFailures) +} + +func scheduleRefineShouldTriggerReplan(st *ScheduleRefineState, result scheduleRefineReactToolResult) 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", "TASK_BUDGET_EXCEEDED", "BATCH_MOVE_DISABLED", "CURRENT_TASK_MISMATCH", "QUERY_REDUNDANT", "SLOT_QUERY_FAILED", "PLAN_FAILED", "PLAN_EMPTY", "COMPOSITE_REQUIRED", "COMPOSITE_DISABLED": + return true + default: + return false + } +} + +func scheduleRefineTryReplan( + 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 || 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 := scheduleRefineRunPlannerNode(ctx, chatModel, st, emitStage, "replan"); err != nil { + return true, err + } + st.ConsecutiveFailures = 0 + st.ThinkingBoostArmed = false + return true, nil +} + +func scheduleRefineCallModelText( + 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 +} + +func scheduleRefineParseJSON[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 := scheduleRefineExtractFirstJSONObject(clean) + if err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +func scheduleRefineExtractFirstJSONObject(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") +} + +func scheduleRefineEmitModelRawDebug(emitStage func(stage, detail string), tag string, raw string) { + if emitStage == nil { + return + } + clean := strings.TrimSpace(raw) + if clean == "" { + clean = "" + } + const chunkSize = 1600 + runes := []rune(clean) + if len(runes) <= chunkSize { + emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s] %s", strings.TrimSpace(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) + } + emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s][part %d/%d] %s", strings.TrimSpace(tag), i+1, total, string(runes[start:end]))) + } +} + +func scheduleRefinePhysicsCheck(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 !scheduleRefineEntryBlocksSuggested(entry) { + continue + } + for sec := entry.SectionFrom; sec <= entry.SectionTo; sec++ { + key := fmt.Sprintf("%d-%d-%d", entry.Week, entry.DayOfWeek, sec) + if existed, ok := slotMap[key]; ok { + issues = append(issues, fmt.Sprintf("冲突:%s 与 %s 同时占用 W%dD%d 第%d节", existed, entry.Name, entry.Week, entry.DayOfWeek, sec)) + } else { + slotMap[key] = entry.Name + } + } + } + if allocatedCount > 0 { + suggested := scheduleRefineCountSuggested(entries) + if suggested != allocatedCount { + issues = append(issues, fmt.Sprintf("数量不一致:suggested=%d,allocated_items=%d", suggested, allocatedCount)) + } + } + return issues +} + +func scheduleRefineUpdateAllocatedItemsFromEntries(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 scheduleRefineIsMovableSuggestedTask(entry) { + 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 scheduleRefineCountSuggested(entries []model.HybridScheduleEntry) int { + count := 0 + for _, entry := range entries { + if scheduleRefineIsMovableSuggestedTask(entry) { + count++ + } + } + return count +} + +func scheduleRefineSummarizeActionLogs(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 scheduleRefineFallbackText(text string, fallback string) string { + clean := strings.TrimSpace(text) + if clean == "" { + return fallback + } + return clean +} + +func scheduleRefineWithNearestJSONContract(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 +} + +// scheduleRefineAlignSummaryWithHardCheck 对齐总结文案与硬校验事实,避免“通过/失败”口径冲突。 +// +// 步骤化说明: +// 1. 先以 hard_check 最终结果作为唯一真值; +// 2. pass=true 且 round_used=0 时,强制输出“未执行动作但已满足”的口径; +// 3. pass=true 但文案含失败词,或 pass=false 但文案含通过词,统一纠偏。 +func scheduleRefineAlignSummaryWithHardCheck(st *ScheduleRefineState, summary string) string { + clean := strings.TrimSpace(summary) + if st == nil { + return clean + } + passed := FinalHardCheckPassed(st) + if passed { + if st.RoundUsed == 0 { + return "本轮未执行调度动作(0轮),当前排程已满足终审条件。" + } + if clean == "" || scheduleRefineContainsAny(clean, []string{"未完全", "未达标", "未能", "差距", "失败", "未通过"}) { + return fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) + } + return clean + } + + if clean == "" || scheduleRefineContainsAny(clean, []string{"终审通过", "已通过终审", "完全达成", "全部满足"}) { + return fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, scheduleRefineFallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) + } + return clean +} + +func scheduleRefineFormatReactPlanStageDetail(round int, out *scheduleRefineReactLLMOutput, 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, scheduleRefineTruncate(strings.TrimSpace(out.GoalCheck), 180), scheduleRefineTruncate(strings.TrimSpace(out.Decision), 180)) +} + +func scheduleRefineFormatReactNeedInfoStageDetail(round int, missing []string) string { + if len(missing) == 0 { + return fmt.Sprintf("第 %d 轮|模型缺口信息=无。", round) + } + return fmt.Sprintf("第 %d 轮|模型缺口信息=%s", round, strings.Join(scheduleRefineUniqueNonEmpty(missing), ";")) +} + +func scheduleRefineFormatReactReflectStageDetail(round int, reflect string) string { + return fmt.Sprintf("第 %d 轮|复盘=%s", round, scheduleRefineTruncate(strings.TrimSpace(reflect), 260)) +} + +func scheduleRefineFormatToolCallStageDetail(round int, call scheduleRefineReactToolCall, 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), scheduleRefineTruncate(paramsText, 320), remaining) +} + +func scheduleRefineFormatToolResultStageDetail(round int, result scheduleRefineReactToolResult, 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, scheduleRefineTruncate(strings.TrimSpace(result.Result), 320), used, total) +} + +func scheduleRefineCondenseSummary(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 scheduleRefineHybridEntriesToWeekSchedules(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}) + } + sort.SliceStable(result, func(i, j int) bool { return result[i].Week < result[j].Week }) + return result +} + +func scheduleRefineBuildFallbackContract(st *ScheduleRefineState) RefineContract { + intent := strings.TrimSpace(st.UserMessage) + keepOrder := scheduleRefineDetectOrderIntent(st.UserMessage) + reqs := append([]string(nil), st.Constraints...) + if keepOrder { + reqs = append(reqs, "保持任务原始相对顺序不变") + } + assertions := scheduleRefineInferHardAssertionsFromRequest(st.UserMessage, reqs) + return RefineContract{ + Intent: intent, + Strategy: "local_adjust", + HardRequirements: scheduleRefineUniqueNonEmpty(reqs), + HardAssertions: assertions, + KeepRelativeOrder: keepOrder, + OrderScope: "global", + } +} + +func scheduleRefineNormalizeStrategy(strategy string) string { + switch strings.TrimSpace(strings.ToLower(strategy)) { + case "keep": + return "keep" + default: + return "local_adjust" + } +} + +func scheduleRefineDetectOrderIntent(userMessage string) bool { + msg := strings.TrimSpace(userMessage) + if msg == "" { + return true + } + // 1. 默认启用顺序约束,除非用户明确授权可打乱顺序。 + // 2. 这样可避免“用户没提顺序但结果被打乱”的违和体验。 + for _, k := range []string{"可以打乱顺序", "允许打乱顺序", "顺序无所谓", "不考虑顺序", "不用保持顺序", "无需保持顺序", "随便排顺序", "乱序也行"} { + if strings.Contains(msg, k) { + return false + } + } + return true +} + +func scheduleRefineUniqueNonEmpty(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 scheduleRefineBuildObservationPrompt(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 err.Error() + } + return string(raw) +} + +func scheduleRefineBuildLastToolObservationPrompt(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 "无" +} + +func scheduleRefineBuildToolCallSignature(call scheduleRefineReactToolCall) 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) +} + +func scheduleRefineBuildSlotQuerySignature(st *ScheduleRefineState, params map[string]any) string { + normalized := scheduleRefineCanonicalizeToolCall(scheduleRefineReactToolCall{Tool: "QueryAvailableSlots", Params: params}) + raw, _ := json.Marshal(normalized.Params) + version := 0 + if st != nil { + version = st.EntriesVersion + } + return fmt.Sprintf("v=%d|%s", version, string(raw)) +} + +func scheduleRefineCanonicalizeToolCall(call scheduleRefineReactToolCall) scheduleRefineReactToolCall { + canonical := scheduleRefineReactToolCall{ + Tool: strings.TrimSpace(call.Tool), + Params: scheduleRefineCloneToolParams(call.Params), + } + switch canonical.Tool { + case "Move": + canonical.Params = scheduleRefineCanonicalizeMoveParams(canonical.Params) + case "BatchMove": + canonical.Params = scheduleRefineCanonicalizeBatchMoveParams(canonical.Params) + case "SpreadEven", "MinContextSwitch": + canonical.Params = scheduleRefineCanonicalizeCompositeMoveParams(canonical.Params) + case "QueryAvailableSlots": + canonical.Params = scheduleRefineCanonicalizeSlotQueryParams(canonical.Params) + } + return canonical +} + +func scheduleRefineCanonicalizeMoveParams(params map[string]any) map[string]any { + out := scheduleRefineCloneToolParams(params) + scheduleRefineSetCanonicalInt(out, "task_item_id", out, "task_item_id", "task_id") + scheduleRefineSetCanonicalInt(out, "to_week", out, "to_week", "target_week", "new_week", "week") + scheduleRefineSetCanonicalInt(out, "to_day", out, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day") + scheduleRefineSetCanonicalInt(out, "to_section_from", out, "to_section_from", "target_section_from", "new_section_from", "section_from") + scheduleRefineSetCanonicalInt(out, "to_section_to", out, "to_section_to", "target_section_to", "new_section_to", "section_to") + return out +} + +func scheduleRefineCanonicalizeBatchMoveParams(params map[string]any) map[string]any { + out := scheduleRefineCloneToolParams(params) + rawMoves, ok := out["moves"] + if !ok { + return out + } + moves, ok := rawMoves.([]any) + if !ok { + return out + } + normalized := make([]any, 0, len(moves)) + for _, item := range moves { + moveMap, ok := item.(map[string]any) + if !ok { + continue + } + normalized = append(normalized, scheduleRefineCanonicalizeMoveParams(moveMap)) + } + out["moves"] = normalized + return out +} + +func scheduleRefineCanonicalizeCompositeMoveParams(params map[string]any) map[string]any { + out := scheduleRefineCloneToolParams(params) + ids := scheduleRefineReadIntSlice(out, "task_item_ids", "task_ids") + if taskID, ok := scheduleRefineParamIntAny(out, "task_item_id", "task_id"); ok { + ids = append(ids, taskID) + } + if len(ids) > 0 { + out["task_item_ids"] = scheduleRefineUniquePositiveInts(ids) + } + + scheduleRefineSetCanonicalInt(out, "week", out, "week", "to_week", "target_week", "new_week") + if day, ok := scheduleRefineParamIntAny(out, "day_of_week", "to_day", "target_day_of_week", "target_day", "new_day", "day"); ok { + out["day_of_week"] = []int{day} + } + if weeks := scheduleRefineReadIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 { + out["week_filter"] = scheduleRefineUniquePositiveInts(weeks) + } + if days := scheduleRefineReadIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 { + out["day_of_week"] = scheduleRefineUniquePositiveInts(days) + } + if sections := scheduleRefineReadIntSlice(out, "exclude_sections", "exclude_section"); len(sections) > 0 { + out["exclude_sections"] = scheduleRefineUniquePositiveInts(sections) + } + return out +} + +func scheduleRefineCanonicalizeSlotQueryParams(params map[string]any) map[string]any { + out := scheduleRefineCloneToolParams(params) + scheduleRefineSetCanonicalInt(out, "week", out, "week") + if weeks := scheduleRefineReadIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 { + out["week_filter"] = scheduleRefineUniquePositiveInts(weeks) + } + if days := scheduleRefineReadIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 { + out["day_filter"] = scheduleRefineUniquePositiveInts(days) + } + scheduleRefineSetCanonicalInt(out, "section_duration", out, "section_duration", "span", "task_duration") + scheduleRefineSetCanonicalInt(out, "section_from", out, "section_from", "target_section_from") + scheduleRefineSetCanonicalInt(out, "section_to", out, "section_to", "target_section_to") + scheduleRefineSetCanonicalInt(out, "limit", out, "limit") + return out +} + +func scheduleRefineSetCanonicalInt(dst map[string]any, dstKey string, src map[string]any, keys ...string) { + if dst == nil || src == nil { + return + } + if value, ok := scheduleRefineParamIntAny(src, keys...); ok { + dst[dstKey] = value + } +} + +func scheduleRefineListTaskIDsFromToolCall(call scheduleRefineReactToolCall) []int { + switch strings.TrimSpace(call.Tool) { + case "Move": + taskID, ok := scheduleRefineParamIntAny(call.Params, "task_item_id", "task_id") + if !ok { + return nil + } + return []int{taskID} + case "Swap": + taskA, okA := scheduleRefineParamIntAny(call.Params, "task_a", "task_item_a", "task_item_id_a") + taskB, okB := scheduleRefineParamIntAny(call.Params, "task_b", "task_item_b", "task_item_id_b") + return scheduleRefineUniquePositiveInts([]int{taskA, taskB}, okA, okB) + case "BatchMove": + rawMoves, ok := call.Params["moves"] + if !ok { + return nil + } + moves, ok := rawMoves.([]any) + if !ok { + return nil + } + ids := make([]int, 0, len(moves)) + for _, item := range moves { + moveMap, ok := item.(map[string]any) + if !ok { + continue + } + if taskID, ok := scheduleRefineParamIntAny(moveMap, "task_item_id", "task_id"); ok { + ids = append(ids, taskID) + } + } + return scheduleRefineUniquePositiveInts(ids) + case "SpreadEven", "MinContextSwitch": + ids := scheduleRefineReadIntSlice(call.Params, "task_item_ids", "task_ids") + if taskID, ok := scheduleRefineParamIntAny(call.Params, "task_item_id", "task_id"); ok { + ids = append(ids, taskID) + } + return scheduleRefineUniquePositiveInts(ids) + default: + return nil + } +} + +func scheduleRefinePrecheckCurrentTaskOwnership(call scheduleRefineReactToolCall, taskIDs []int, currentTaskID int) (scheduleRefineReactToolResult, bool) { + if currentTaskID <= 0 { + return scheduleRefineReactToolResult{}, false + } + if !scheduleRefineIsMutatingToolName(strings.TrimSpace(call.Tool)) { + return scheduleRefineReactToolResult{}, false + } + for _, id := range taskIDs { + if id == currentTaskID { + return scheduleRefineReactToolResult{}, false + } + } + return scheduleRefineReactToolResult{ + Tool: strings.TrimSpace(call.Tool), + Success: false, + ErrorCode: "CURRENT_TASK_MISMATCH", + Result: fmt.Sprintf("当前微循环任务为 id=%d,本轮改写动作未包含该任务,请改为围绕当前任务执行。", currentTaskID), + }, true +} + +func scheduleRefinePrecheckToolCallPolicy(st *ScheduleRefineState, call scheduleRefineReactToolCall, taskIDs []int) (scheduleRefineReactToolResult, bool) { + if st == nil { + return scheduleRefineReactToolResult{}, false + } + toolName := strings.TrimSpace(call.Tool) + if st.DisableCompositeTools && scheduleRefineIsCompositeToolName(toolName) { + return scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "COMPOSITE_DISABLED", + Result: "当前已进入 ReAct 兜底模式,禁止调用复合工具,请使用 Move/Swap 逐步处理。", + }, true + } + if st.DisableCompositeTools && toolName == "BatchMove" { + return scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "BATCH_MOVE_DISABLED", + Result: "当前兜底模式要求逐任务挪动,禁止使用 BatchMove。", + }, true + } + if toolName == "BatchMove" && !st.BatchMoveAllowed { + return scheduleRefineReactToolResult{Tool: toolName, Success: false, ErrorCode: "BATCH_MOVE_DISABLED", Result: "当前计划未显式允许 BatchMove,请改用单步 Move/Swap。"}, true + } + if toolName == "QueryAvailableSlots" { + if st.SeenSlotQueries == nil { + st.SeenSlotQueries = make(map[string]struct{}) + } + signature := scheduleRefineBuildSlotQuerySignature(st, call.Params) + if _, exists := st.SeenSlotQueries[signature]; exists { + return scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "QUERY_REDUNDANT", + Result: "同版本排程下重复查询同一空位范围,已拒绝;请直接基于 ENV_SLOT_HINT 选择落点。", + }, true + } + st.SeenSlotQueries[signature] = struct{}{} + return scheduleRefineReactToolResult{}, false + } + // 1. 当计划声明“必用复合工具”且尚未成功时,先锁住基础写工具。 + // 2. 这样可避免模型绕开复合工具直接 Move,导致“命中率低 + 语义漂移”。 + requiredComposite := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) + if requiredComposite != "" && !scheduleRefineIsRequiredCompositeSatisfied(st) && scheduleRefineIsMutatingToolName(toolName) { + if toolName != requiredComposite { + return scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "COMPOSITE_REQUIRED", + Result: fmt.Sprintf("当前计划要求先成功调用 %s;在其成功前禁止使用 %s。", requiredComposite, toolName), + }, true + } + } + if !scheduleRefineIsMutatingToolName(toolName) { + return scheduleRefineReactToolResult{}, false + } + if st.PerTaskBudget <= 0 || len(taskIDs) == 0 { + return scheduleRefineReactToolResult{}, false + } + for _, taskID := range taskIDs { + if st.TaskActionUsed[taskID] >= st.PerTaskBudget { + return scheduleRefineReactToolResult{Tool: toolName, Success: false, ErrorCode: "TASK_BUDGET_EXCEEDED", Result: fmt.Sprintf("任务 id=%d 已达到单任务动作预算上限=%d,请重规划或更换目标任务。", taskID, st.PerTaskBudget)}, true + } + } + return scheduleRefineReactToolResult{}, false +} + +func scheduleRefineIsMutatingToolName(toolName string) bool { + switch strings.TrimSpace(toolName) { + case "Move", "Swap", "BatchMove", "SpreadEven", "MinContextSwitch": + return true + default: + return false + } +} + +func scheduleRefineUniquePositiveInts(ids []int, oks ...bool) []int { + allowAll := len(oks) == 0 + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for i, id := range ids { + if !allowAll { + if i >= len(oks) || !oks[i] { + continue + } + } + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func scheduleRefineIsRepeatedFailedCall(st *ScheduleRefineState, signature string) bool { + if st == nil { + return false + } + current := strings.TrimSpace(signature) + last := strings.TrimSpace(st.LastFailedCallSignature) + return current != "" && last != "" && current == last +} + +func scheduleRefineNormalizeToolResult(result scheduleRefineReactToolResult) scheduleRefineReactToolResult { + if result.Success { + return result + } + if strings.TrimSpace(result.ErrorCode) != "" { + return result + } + result.ErrorCode = scheduleRefineClassifyToolFailureCode(result.Result) + return result +} + +func scheduleRefineClassifyToolFailureCode(detail string) string { + text := strings.TrimSpace(detail) + switch { + case strings.Contains(text, "单任务动作预算上限"): + return "TASK_BUDGET_EXCEEDED" + case strings.Contains(text, "未显式允许 BatchMove"): + return "BATCH_MOVE_DISABLED" + 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 "TASK_ID_AMBIGUOUS" + 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" + } +} + +func scheduleRefineCloneToolParams(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 scheduleRefineFormatRoundModelErrorDetail(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 scheduleRefineBuildRuntimeReflect(modelReflect string, result scheduleRefineReactToolResult) string { + modelText := strings.TrimSpace(modelReflect) + resultText := scheduleRefineTruncate(strings.TrimSpace(result.Result), 220) + if result.Success { + if modelText == "" { + return fmt.Sprintf("后端复盘:工具执行成功。%s", resultText) + } + return fmt.Sprintf("后端复盘:工具执行成功。%s。模型预期(动作前):%s", resultText, scheduleRefineTruncate(modelText, 180)) + } + if modelText == "" { + return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。", resultText) + } + return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。模型预期(动作前,仅供参考):%s", resultText, scheduleRefineTruncate(modelText, 160)) +} + +func scheduleRefineBuildSuggestedDigest(entries []model.HybridScheduleEntry, limit int) string { + if len(entries) == 0 { + return "无" + } + list := make([]model.HybridScheduleEntry, 0, len(entries)) + for _, entry := range entries { + if scheduleRefineIsMovableSuggestedTask(entry) { + list = append(list, entry) + } + } + if len(list) == 0 { + return "无 suggested 条目" + } + scheduleRefineSortHybridEntries(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, scheduleRefineWeekdayLabel(item.DayOfWeek), item.SectionFrom, item.SectionTo, strings.TrimSpace(item.Name))) + } + return strings.Join(lines, "\n") +} + +func scheduleRefineBuildSuggestedDigestByWeek(entries []model.HybridScheduleEntry, week int, limit int) string { + if week <= 0 { + return scheduleRefineBuildSuggestedDigest(entries, limit) + } + filtered := make([]model.HybridScheduleEntry, 0, len(entries)) + for _, entry := range entries { + if scheduleRefineIsMovableSuggestedTask(entry) && entry.Week == week { + filtered = append(filtered, entry) + } + } + if len(filtered) == 0 { + return "无同周 suggested 条目" + } + return scheduleRefineBuildSuggestedDigest(filtered, limit) +} + +func scheduleRefineWeekdayLabel(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 "未知" + } +} + +func scheduleRefineParseReactOutputWithRetryOnce( + ctx context.Context, + chatModel *ark.ChatModel, + userPrompt string, + firstRaw string, + round int, + emitStage func(stage, detail string), + st *ScheduleRefineState, +) (*scheduleRefineReactLLMOutput, error) { + if st == nil { + return nil, respond.ScheduleRefineOutputParseFailed + } + parsed, parseErr := parseScheduleRefineLLMOutput(firstRaw) + if parseErr == nil { + return parsed, nil + } + emitStage("schedule_refine.react.parse_retry", fmt.Sprintf("第 %d 轮输出解析失败,准备重试1次:%s", round, scheduleRefineTruncate(parseErr.Error(), 260))) + retryRaw, retryErr := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefineReactPrompt, userPrompt, false, reactMaxTokens, 0) + if retryErr != nil { + emitStage("schedule_refine.react.round_error", scheduleRefineFormatRoundModelErrorDetail(round, fmt.Errorf("解析重试调用失败: %w", retryErr), ctx)) + return nil, respond.ScheduleRefineOutputParseFailed + } + scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.retry", round), retryRaw) + retryParsed, retryParseErr := parseScheduleRefineLLMOutput(retryRaw) + if retryParseErr != nil { + emitStage("schedule_refine.react.round_error", fmt.Sprintf("第 %d 轮输出二次解析失败:%s", round, scheduleRefineTruncate(retryParseErr.Error(), 260))) + return nil, respond.ScheduleRefineOutputParseFailed + } + emitStage("schedule_refine.react.parse_retry_success", fmt.Sprintf("第 %d 轮输出重试解析成功,继续执行。", round)) + return retryParsed, nil +} + +func scheduleRefineParsePlannerOutputWithRetryOnce( + ctx context.Context, + chatModel *ark.ChatModel, + originUserPrompt string, + firstRaw string, + mode string, + emitStage func(stage, detail string), +) (*scheduleRefinePlannerOutput, error) { + parsed, parseErr := scheduleRefineParseJSON[scheduleRefinePlannerOutput](firstRaw) + if parseErr == nil { + return parsed, nil + } + emitStage("schedule_refine.plan.parse_retry", fmt.Sprintf("Planner 解析失败,准备重试1次(mode=%s):%s", strings.TrimSpace(mode), scheduleRefineTruncate(parseErr.Error(), 160))) + retryPrompt := scheduleRefineWithNearestJSONContract( + fmt.Sprintf("%s\n\n上一轮输出解析失败(原因:JSON 不完整或不闭合)。请缩短内容并严格输出完整 JSON。", originUserPrompt), + jsonContractForPlanner, + ) + retryRaw, retryErr := scheduleRefineCallModelText(ctx, chatModel, agentprompt.ScheduleRefinePlannerPrompt, retryPrompt, false, plannerMaxTokens, 0) + if retryErr != nil { + return nil, retryErr + } + scheduleRefineEmitModelRawDebug(emitStage, fmt.Sprintf("planner.%s.retry", strings.TrimSpace(mode)), retryRaw) + retryParsed, retryParseErr := scheduleRefineParseJSON[scheduleRefinePlannerOutput](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 +} + +func scheduleRefineBuildSlicePlan(st *ScheduleRefineState) RefineSlicePlan { + msg := strings.TrimSpace(st.UserMessage) + lower := strings.ToLower(msg) + plan := RefineSlicePlan{ + WeekFilter: scheduleRefineExtractWeekFilters(msg), + ExcludeSections: scheduleRefineExtractExcludeSections(msg), + Reason: "根据用户请求抽取得到执行切片", + } + // 1. 优先解析“从A收敛到B”这类方向型表达,防止把 source/target 反向识别。 + // 2. 例如“周四到周五收敛到周一到周三”应得到 source=[4,5], target=[1,2,3]。 + if src, tgt, ok := scheduleRefineExtractDirectionalSourceTargetDays(msg); ok { + plan.SourceDays = src + plan.TargetDays = tgt + return plan + } + if strings.Contains(msg, "工作日") || strings.Contains(msg, "周一到周五") || strings.Contains(msg, "周1到周5") { + plan.TargetDays = []int{1, 2, 3, 4, 5} + } else if scheduleRefineContainsAny(lower, []string{"移到周末", "挪到周末", "安排在周末", "放到周末"}) { + plan.TargetDays = []int{6, 7} + } else if days := scheduleRefineExtractTargetDaysFromMessage(msg); len(days) > 0 { + plan.TargetDays = days + } + if len(plan.TargetDays) == 5 && scheduleRefineIsSameDays(plan.TargetDays, []int{1, 2, 3, 4, 5}) && strings.Contains(msg, "周末") { + plan.SourceDays = []int{6, 7} + } + if day := scheduleRefineDetectOverloadedDay(msg); day > 0 { + plan.SourceDays = scheduleRefineUniquePositiveInts(append(plan.SourceDays, day)) + } + if fromDays := scheduleRefineExtractSourceDaysFromMessage(msg); len(fromDays) > 0 { + plan.SourceDays = scheduleRefineUniquePositiveInts(append(plan.SourceDays, fromDays...)) + } + return plan +} + +// scheduleRefineExtractDirectionalSourceTargetDays 解析“来源日 -> 目标日”表达。 +// +// 规则: +// 1. 以“收敛到/移到/挪到/调整到”等方向词为分割; +// 2. 分割前提取 source days,分割后提取 target days; +// 3. 两侧都提取成功才返回 true,避免误判。 +func scheduleRefineExtractDirectionalSourceTargetDays(text string) ([]int, []int, bool) { + verbIdx := -1 + verbLen := 0 + for _, key := range []string{"收敛到", "移到", "挪到", "调整到", "安排到", "放到", "改到", "迁移到", "分散到"} { + if idx := strings.Index(text, key); idx >= 0 { + verbIdx = idx + verbLen = len(key) + break + } + } + if verbIdx < 0 { + return nil, nil, false + } + left := strings.TrimSpace(text[:verbIdx]) + right := strings.TrimSpace(text[verbIdx+verbLen:]) + if left == "" || right == "" { + return nil, nil, false + } + source := scheduleRefineExtractDayExpr(left) + target := scheduleRefineExtractDayExpr(right) + if len(source) == 0 || len(target) == 0 { + return nil, nil, false + } + return source, target, true +} + +// scheduleRefineExtractDayExpr 提取文本中的“星期表达式”。 +// 优先提取区间(周一到周三),提不到再提取离散天。 +func scheduleRefineExtractDayExpr(text string) []int { + if days := scheduleRefineExtractRangeDays(text); len(days) > 0 { + return days + } + return scheduleRefineExtractDays(text) +} + +// scheduleRefineInferSourceWeekSet 推断“来源周”集合。 +// +// 规则: +// 1. 当 week_filter 至少两个值时,默认第一个值视为来源周(保留用户原话顺序); +// 2. 当 week_filter 少于两个值时,不强制来源周过滤,返回空集合; +// 3. 该规则用于收敛 workset,避免把目标周任务误纳入当前微循环。 +func scheduleRefineInferSourceWeekSet(slice RefineSlicePlan) map[int]struct{} { + if len(slice.WeekFilter) < 2 { + return nil + } + sourceWeek := slice.WeekFilter[0] + if sourceWeek <= 0 { + return nil + } + return map[int]struct{}{sourceWeek: {}} +} + +// scheduleRefineInferTargetWeekSet 推断“目标周”集合。 +// +// 规则: +// 1. 当 week_filter 至少两个值时,除首个来源周外,其余周视为目标周; +// 2. 当 week_filter 少于两个值时,不构造目标周集合,交由其他约束判定; +// 3. 返回升维集合用于 O(1) 命中判断。 +func scheduleRefineInferTargetWeekSet(slice RefineSlicePlan) map[int]struct{} { + if len(slice.WeekFilter) < 2 { + return nil + } + set := make(map[int]struct{}, len(slice.WeekFilter)-1) + for _, week := range slice.WeekFilter[1:] { + if week > 0 { + set[week] = struct{}{} + } + } + if len(set) == 0 { + return nil + } + return set +} + +func scheduleRefineCollectWorksetTaskIDs(entries []model.HybridScheduleEntry, slice RefineSlicePlan, originOrder map[int]int) []int { + type candidate struct { + TaskID int + Week int + Day int + SectionFrom int + Rank int + } + list := make([]candidate, 0, len(entries)) + seen := make(map[int]struct{}, len(entries)) + weekSet := scheduleRefineIntSliceToWeekSet(slice.WeekFilter) + sourceWeekSet := scheduleRefineInferSourceWeekSet(slice) + sourceSet := scheduleRefineIntSliceToDaySet(slice.SourceDays) + for _, entry := range entries { + if !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + // 1. 方向型周次请求(例如“14周挪到13周”)下,只把“来源周”任务放入 workset。 + // 2. 这样做可以避免目标周/其他周任务被误当成当前微循环任务,触发串改。 + if len(sourceWeekSet) > 0 { + if _, ok := sourceWeekSet[entry.Week]; !ok { + continue + } + } + if len(weekSet) > 0 { + if _, ok := weekSet[entry.Week]; !ok { + continue + } + } + if len(sourceSet) > 0 { + if _, ok := sourceSet[entry.DayOfWeek]; !ok { + continue + } + } + if _, ok := seen[entry.TaskItemID]; ok { + continue + } + seen[entry.TaskItemID] = struct{}{} + rank := originOrder[entry.TaskItemID] + if rank <= 0 { + rank = 1 << 30 + } + list = append(list, candidate{ + TaskID: entry.TaskItemID, + Week: entry.Week, + Day: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + Rank: rank, + }) + } + sort.SliceStable(list, func(i, j int) bool { + if list[i].Rank != list[j].Rank { + return list[i].Rank < list[j].Rank + } + if list[i].Week != list[j].Week { + return list[i].Week < list[j].Week + } + if list[i].Day != list[j].Day { + return list[i].Day < list[j].Day + } + if list[i].SectionFrom != list[j].SectionFrom { + return list[i].SectionFrom < list[j].SectionFrom + } + return list[i].TaskID < list[j].TaskID + }) + ids := make([]int, 0, len(list)) + for _, item := range list { + ids = append(ids, item.TaskID) + } + return ids +} + +func scheduleRefineFindSuggestedEntryByTaskID(entries []model.HybridScheduleEntry, taskID int) (model.HybridScheduleEntry, bool) { + for _, entry := range entries { + if scheduleRefineIsMovableSuggestedTask(entry) && entry.TaskItemID == taskID { + return entry, true + } + } + return model.HybridScheduleEntry{}, false +} + +// scheduleRefineIsCurrentTaskSatisfiedBySlice 判断“当前任务”是否已满足本轮切片目标。 +// +// 步骤化说明: +// 1. 该判断只用于“当前任务自动收口”,不替代全局 hard_check; +// 2. 若切片包含 source_days,则任务离开 source_days 视为关键进展; +// 3. 若切片包含 target_days / exclude_sections / week_filter,则需同时满足; +// 4. 若切片没有任何约束,返回 false,避免误判导致提前结束。 +func scheduleRefineIsCurrentTaskSatisfiedBySlice(entry model.HybridScheduleEntry, slice RefineSlicePlan) bool { + if !scheduleRefineIsMovableSuggestedTask(entry) { + return false + } + weekSet := scheduleRefineIntSliceToWeekSet(slice.WeekFilter) + sourceWeekSet := scheduleRefineInferSourceWeekSet(slice) + sourceSet := scheduleRefineIntSliceToDaySet(slice.SourceDays) + targetSet := scheduleRefineIntSliceToDaySet(slice.TargetDays) + excludedSet := scheduleRefineIntSliceToSectionSet(slice.ExcludeSections) + + hasConstraint := len(sourceWeekSet) > 0 || len(weekSet) > 0 || len(sourceSet) > 0 || len(targetSet) > 0 || len(excludedSet) > 0 + if !hasConstraint { + return false + } + if len(sourceWeekSet) > 0 { + if _, stillInSourceWeek := sourceWeekSet[entry.Week]; stillInSourceWeek { + return false + } + } + if len(weekSet) > 0 { + if _, ok := weekSet[entry.Week]; !ok { + return false + } + } + if len(sourceSet) > 0 { + if _, stillInSource := sourceSet[entry.DayOfWeek]; stillInSource { + return false + } + } + if len(targetSet) > 0 { + if _, ok := targetSet[entry.DayOfWeek]; !ok { + return false + } + } + if len(excludedSet) > 0 && scheduleRefineIntersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSet) { + return false + } + return true +} + +func scheduleRefineTaskProgressLabel(done bool, attemptUsed int, perTaskBudget int) string { + if done { + return "done" + } + if perTaskBudget > 0 && attemptUsed >= perTaskBudget { + return "budget_exhausted" + } + return "paused" +} + +func scheduleRefineBuildMicroReactUserPrompt(st *ScheduleRefineState, current model.HybridScheduleEntry, remainingAction int, remainingTotal int) string { + scheduleRefineEnsureCompositeStateMaps(st) + contractJSON, _ := json.Marshal(st.Contract) + planJSON, _ := json.Marshal(st.CurrentPlan) + sliceJSON, _ := json.Marshal(st.SlicePlan) + objectiveJSON, _ := json.Marshal(st.Objective) + currentJSON, _ := json.Marshal(current) + sourceWeeks := scheduleRefineKeysOfIntSet(scheduleRefineInferSourceWeekSet(st.SlicePlan)) + requiredComposite := scheduleRefineNormalizeCompositeToolName(st.RequiredCompositeTool) + requiredSuccess := scheduleRefineIsRequiredCompositeSatisfied(st) + compositeToolsAllowed := !st.DisableCompositeTools + compositeCalledJSON, _ := json.Marshal(st.CompositeToolCalled) + compositeSuccessJSON, _ := json.Marshal(st.CompositeToolSuccess) + envSlotHint := scheduleRefineBuildEnvSlotHint(st, current) + userPrompt := fmt.Sprintf( + "用户本轮请求=%s\n契约=%s\n执行计划=%s\n切片=%s\n目标约束=%s\nCURRENT_TASK=%s\nSOURCE_WEEK_FILTER=%v\nBACKEND_GUARD=本轮只允许改写 task_item_id=%d;若该任务已满足切片目标或目标约束已整体达成且复合工具门禁通过,请直接 done=true;下一任务由后端自动切换。\nREQUIRED_COMPOSITE_TOOL=%s\nCOMPOSITE_TOOLS_ALLOWED=%t\nCOMPOSITE_REQUIRED_SUCCESS=%t\nCOMPOSITE_CALLED=%s\nCOMPOSITE_SUCCESS=%s\nCURRENT_TASK_ACTION_USED=%d\nPER_TASK_BUDGET=%d\n动作预算剩余=%d\n总预算剩余=%d\nENV_SLOT_HINT=%s\nLAST_TOOL_OBSERVATION=%s\nLAST_FAILED_CALL_SIGNATURE=%s\n最近观察=%s\n同周suggested摘要=%s", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + string(planJSON), + string(sliceJSON), + string(objectiveJSON), + string(currentJSON), + sourceWeeks, + current.TaskItemID, + scheduleRefineFallbackText(requiredComposite, "无"), + compositeToolsAllowed, + requiredSuccess, + string(compositeCalledJSON), + string(compositeSuccessJSON), + st.TaskActionUsed[current.TaskItemID], + st.PerTaskBudget, + remainingAction, + remainingTotal, + envSlotHint, + scheduleRefineBuildLastToolObservationPrompt(st.ObservationHistory), + scheduleRefineFallbackText(st.LastFailedCallSignature, "无"), + scheduleRefineBuildObservationPrompt(st.ObservationHistory, 2), + scheduleRefineBuildSuggestedDigestByWeek(st.HybridEntries, current.Week, 24), + ) + return scheduleRefineWithNearestJSONContract(userPrompt, jsonContractForReact) +} + +type scheduleRefineSlotHintPayload struct { + Count int `json:"count"` + StrictCount int `json:"strict_count"` + EmbeddedCount int `json:"embedded_count"` + Slots []struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + } `json:"slots"` +} + +func scheduleRefineBuildEnvSlotHint(st *ScheduleRefineState, current model.HybridScheduleEntry) string { + if st == nil || !scheduleRefineIsMovableSuggestedTask(current) { + return "无可用提示" + } + span := current.SectionTo - current.SectionFrom + 1 + if span <= 0 { + span = 2 + } + targetWeeks := append([]int(nil), st.Objective.TargetWeeks...) + if len(targetWeeks) == 0 { + targetWeeks = scheduleRefineKeysOfIntSet(scheduleRefineInferTargetWeekSet(st.SlicePlan)) + } + if len(targetWeeks) == 0 && current.Week > 0 { + targetWeeks = []int{current.Week} + } + targetDays := append([]int(nil), st.Objective.TargetDays...) + if len(targetDays) == 0 { + targetDays = append([]int(nil), st.SlicePlan.TargetDays...) + } + params := map[string]any{ + "week_filter": targetWeeks, + "day_filter": targetDays, + "section_duration": span, + "limit": 8, + "slot_type": "pure", + "exclude_sections": st.SlicePlan.ExcludeSections, + } + _, pureResult := scheduleRefineToolQueryAvailableSlots(st.HybridEntries, params, scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries)) + if !pureResult.Success { + return fmt.Sprintf("pure_slot_query_failed=%s", scheduleRefineTruncate(pureResult.Result, 100)) + } + purePayload, ok := scheduleRefineDecodeSlotHintPayload(pureResult.Result) + if !ok { + return "pure_slot_parse_failed" + } + + embedParams := map[string]any{ + "week_filter": targetWeeks, + "day_filter": targetDays, + "section_duration": span, + "limit": 8, + "exclude_sections": st.SlicePlan.ExcludeSections, + } + _, fallbackResult := scheduleRefineToolQueryAvailableSlots(st.HybridEntries, embedParams, scheduleRefineBuildPlanningWindowFromEntries(st.HybridEntries)) + if !fallbackResult.Success { + return fmt.Sprintf("pure=%d fallback_query_failed=%s", purePayload.Count, scheduleRefineTruncate(fallbackResult.Result, 100)) + } + fallbackPayload, ok := scheduleRefineDecodeSlotHintPayload(fallbackResult.Result) + if !ok { + return fmt.Sprintf("pure=%d fallback_parse_failed", purePayload.Count) + } + + top := fallbackPayload.Slots + if len(top) > 3 { + top = top[:3] + } + slotText := make([]string, 0, len(top)) + for _, item := range top { + slotText = append(slotText, fmt.Sprintf("W%dD%d %d-%d", item.Week, item.DayOfWeek, item.SectionFrom, item.SectionTo)) + } + if len(slotText) == 0 { + slotText = append(slotText, "无") + } + return fmt.Sprintf("target_weeks=%v target_days=%v pure=%d embed_candidate=%d top=%s", targetWeeks, targetDays, purePayload.Count, fallbackPayload.EmbeddedCount, strings.Join(slotText, ",")) +} + +func scheduleRefineDecodeSlotHintPayload(raw string) (scheduleRefineSlotHintPayload, bool) { + var payload scheduleRefineSlotHintPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return scheduleRefineSlotHintPayload{}, false + } + return payload, true +} + +func scheduleRefineExtractWeekFilters(text string) []int { + patterns := []string{ + `第\s*(\d{1,2})\s*周`, + `W\s*(\d{1,2})`, + `(\d{1,2})\s*周`, + } + out := make([]int, 0, 8) + for _, p := range patterns { + re := regexp.MustCompile(p) + for _, m := range re.FindAllStringSubmatch(text, -1) { + if len(m) < 2 { + continue + } + v, err := strconv.Atoi(strings.TrimSpace(m[1])) + if err != nil || v <= 0 { + continue + } + out = append(out, v) + } + } + return scheduleRefineUniquePositiveInts(out) +} + +func scheduleRefineExtractExcludeSections(text string) []int { + normalized := strings.ReplaceAll(strings.ToLower(text), " ", "") + if scheduleRefineContainsAny(normalized, []string{ + "不要早八", "避开早八", "不想早八", "别在早八", + "不要1-2", "避开1-2", "不要第一节", "不要一二节", + }) { + return []int{1, 2} + } + return nil +} + +func scheduleRefineExtractTargetDaysFromMessage(text string) []int { + verbIdx := -1 + for _, key := range []string{"移到", "挪到", "改到", "安排到", "放到", "分散到", "调整到", "收敛到", "迁移到"} { + if idx := strings.Index(text, key); idx >= 0 { + verbIdx = idx + len(key) + break + } + } + if verbIdx < 0 || verbIdx >= len(text) { + return nil + } + targetPart := strings.TrimSpace(text[verbIdx:]) + return scheduleRefineExtractDayExpr(targetPart) +} + +func scheduleRefineExtractSourceDaysFromMessage(text string) []int { + source := make([]int, 0, 4) + re := regexp.MustCompile(`从\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) + for _, m := range re.FindAllStringSubmatch(text, -1) { + if len(m) < 2 { + continue + } + if day := scheduleRefineDayTokenToInt(m[1]); day > 0 { + source = append(source, day) + } + } + re2 := regexp.MustCompile(`把\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) + for _, m := range re2.FindAllStringSubmatch(text, -1) { + if len(m) < 2 { + continue + } + if day := scheduleRefineDayTokenToInt(m[1]); day > 0 { + source = append(source, day) + } + } + return scheduleRefineUniquePositiveInts(source) +} + +func scheduleRefineDetectOverloadedDay(text string) int { + re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天]).{0,8}(太多|过多|太满|过满|拥挤|太挤|塞满)`) + m := re.FindStringSubmatch(text) + if len(m) < 2 { + return 0 + } + return scheduleRefineDayTokenToInt(m[1]) +} + +func scheduleRefineExtractRangeDays(text string) []int { + re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天])\s*[到至\-]\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) + m := re.FindStringSubmatch(text) + if len(m) < 3 { + return nil + } + start := scheduleRefineDayTokenToInt(m[1]) + end := scheduleRefineDayTokenToInt(m[2]) + if start <= 0 || end <= 0 { + return nil + } + if start > end { + start, end = end, start + } + out := make([]int, 0, end-start+1) + for day := start; day <= end; day++ { + out = append(out, day) + } + return out +} + +func scheduleRefineExtractDays(text string) []int { + re := regexp.MustCompile(`周[一二三四五六日天]|星期[一二三四五六日天]`) + matches := re.FindAllString(text, -1) + days := make([]int, 0, len(matches)) + for _, token := range matches { + if day := scheduleRefineDayTokenToInt(token); day > 0 { + days = append(days, day) + } + } + return scheduleRefineUniquePositiveInts(days) +} + +func scheduleRefineDayTokenToInt(token string) int { + switch strings.TrimSpace(token) { + case "周一", "星期一": + return 1 + case "周二", "星期二": + return 2 + case "周三", "星期三": + return 3 + case "周四", "星期四": + return 4 + case "周五", "星期五": + return 5 + case "周六", "星期六": + return 6 + case "周日", "周天", "星期日", "星期天": + return 7 + default: + return 0 + } +} + +func scheduleRefineContainsAny(text string, keys []string) bool { + for _, k := range keys { + if strings.Contains(text, k) { + return true + } + } + return false +} + +func scheduleRefineIsSameDays(days []int, target []int) bool { + if len(days) != len(target) { + return false + } + for i := range days { + if days[i] != target[i] { + return false + } + } + return true } diff --git a/backend/agent2/node/schedule_refine_impl/composite_route_test.go b/backend/agent2/node/schedule_refine_impl/composite_route_test.go deleted file mode 100644 index baafbe3..0000000 --- a/backend/agent2/node/schedule_refine_impl/composite_route_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package schedulerefine - -import ( - "context" - "testing" - - "github.com/LoveLosita/smartflow/backend/model" -) - -func TestRefineToolSpreadEvenRespectsCanonicalRouteFilters(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 1, Name: "任务1", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "A"}, - // 1. 这里放一个更早周次的 existing 条目,用来把可查询窗口拉到 W11; - // 2. 若复合工具内部丢了 week_filter/day_of_week,就会优先落到更早的 W11D1,而不是目标 W12D3。 - {TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 11, DayOfWeek: 5, SectionFrom: 11, SectionTo: 12, BlockForSuggested: true}, - } - params := map[string]any{ - "task_item_ids": []int{1}, - "week_filter": []int{12}, - "day_of_week": []int{3}, - "allow_embed": false, - } - - nextEntries, result := refineToolSpreadEven(entries, params, planningWindow{Enabled: false}, refineToolPolicy{ - OriginOrderMap: map[int]int{1: 1}, - }) - if !result.Success { - t.Fatalf("SpreadEven 执行失败: %s", result.Result) - } - - idx := findSuggestedByID(nextEntries, 1) - if idx < 0 { - t.Fatalf("未找到 task_item_id=1") - } - got := nextEntries[idx] - if got.Week != 12 || got.DayOfWeek != 3 { - t.Fatalf("期望复合工具严格遵守 week_filter/day_of_week,实际落点=W%dD%d", got.Week, got.DayOfWeek) - } -} - -func TestRunCompositeRouteNodeAllowsHandoffWithoutDeterministicObjective(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 11, Name: "任务11", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学"}, - {TaskItemID: 12, Name: "任务12", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法"}, - {TaskItemID: 13, Name: "任务13", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学"}, - } - st := &ScheduleRefineState{ - UserMessage: "把这些任务按最少上下文切换整理一下", - HybridEntries: cloneHybridEntries(entries), - InitialHybridEntries: cloneHybridEntries(entries), - WorksetTaskIDs: []int{11, 12, 13}, - RequiredCompositeTool: "MinContextSwitch", - CompositeRetryMax: 0, - ExecuteMax: 4, - OriginOrderMap: map[int]int{11: 1, 12: 2, 13: 3}, - CompositeToolCalled: map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - }, - CompositeToolSuccess: map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - }, - } - - stageLogs := make([]string, 0, 8) - nextState, err := runCompositeRouteNode(context.Background(), st, func(stage, detail string) { - stageLogs = append(stageLogs, stage+"|"+detail) - }) - if err != nil { - t.Fatalf("runCompositeRouteNode 返回错误: %v", err) - } - if nextState == nil { - t.Fatalf("runCompositeRouteNode 返回 nil state") - } - if !nextState.CompositeRouteSucceeded { - t.Fatalf("期望复合分支在缺少 deterministic objective 时直接出站,实际 CompositeRouteSucceeded=false, stages=%v, action_logs=%v", stageLogs, nextState.ActionLogs) - } - if nextState.DisableCompositeTools { - t.Fatalf("期望复合分支直接进入终审,不应降级为禁复合 ReAct") - } - if !nextState.CompositeToolSuccess["MinContextSwitch"] { - t.Fatalf("期望 MinContextSwitch 成功状态被记录") - } -} diff --git a/backend/agent2/node/schedule_refine_impl/composite_tools_test.go b/backend/agent2/node/schedule_refine_impl/composite_tools_test.go deleted file mode 100644 index 28e99a9..0000000 --- a/backend/agent2/node/schedule_refine_impl/composite_tools_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package schedulerefine - -import ( - "fmt" - "sort" - "testing" - - "github.com/LoveLosita/smartflow/backend/model" -) - -func TestRefineToolSpreadEvenSuccess(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 1, Name: "任务1", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "A"}, - {TaskItemID: 2, Name: "任务2", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "B"}, - {TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, BlockForSuggested: true}, - } - params := map[string]any{ - "task_item_ids": []any{1.0, 2.0}, - "week": 12, - "day_of_week": []any{1.0, 2.0, 3.0}, - "allow_embed": false, - } - policy := refineToolPolicy{OriginOrderMap: map[int]int{1: 1, 2: 2}} - - nextEntries, result := refineToolSpreadEven(entries, params, planningWindow{Enabled: false}, policy) - if !result.Success { - t.Fatalf("SpreadEven 执行失败: %s", result.Result) - } - if result.Tool != "SpreadEven" { - t.Fatalf("工具名错误,期望 SpreadEven,实际=%s", result.Tool) - } - - idx1 := findSuggestedByID(nextEntries, 1) - idx2 := findSuggestedByID(nextEntries, 2) - if idx1 < 0 || idx2 < 0 { - t.Fatalf("移动后未找到目标任务: idx1=%d idx2=%d", idx1, idx2) - } - task1 := nextEntries[idx1] - task2 := nextEntries[idx2] - if task1.Week != 12 || task2.Week != 12 { - t.Fatalf("期望任务被移动到 W12,实际 task1=%d task2=%d", task1.Week, task2.Week) - } - if task1.DayOfWeek < 1 || task1.DayOfWeek > 3 || task2.DayOfWeek < 1 || task2.DayOfWeek > 3 { - t.Fatalf("期望任务被移动到周一到周三,实际 task1=%d task2=%d", task1.DayOfWeek, task2.DayOfWeek) - } - if task1.DayOfWeek == task2.DayOfWeek && sectionsOverlap(task1.SectionFrom, task1.SectionTo, task2.SectionFrom, task2.SectionTo) { - t.Fatalf("复合工具不应产出重叠坑位: task1=%+v task2=%+v", task1, task2) - } -} - -func TestRefineToolMinContextSwitchGroupsContext(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 11, Name: "任务11", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学"}, - {TaskItemID: 12, Name: "任务12", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法"}, - {TaskItemID: 13, Name: "任务13", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学"}, - {TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 12, DayOfWeek: 1, SectionFrom: 11, SectionTo: 12, BlockForSuggested: true}, - } - params := map[string]any{ - "task_item_ids": []any{11.0, 12.0, 13.0}, - "week": 12, - "day_of_week": []any{1.0}, - } - policy := refineToolPolicy{OriginOrderMap: map[int]int{11: 1, 12: 2, 13: 3}} - - nextEntries, result := refineToolMinContextSwitch(entries, params, planningWindow{Enabled: false}, policy) - if !result.Success { - t.Fatalf("MinContextSwitch 执行失败: %s", result.Result) - } - if result.Tool != "MinContextSwitch" { - t.Fatalf("工具名错误,期望 MinContextSwitch,实际=%s", result.Tool) - } - - selected := make([]model.HybridScheduleEntry, 0, 3) - for _, id := range []int{11, 12, 13} { - idx := findSuggestedByID(nextEntries, id) - if idx < 0 { - t.Fatalf("未找到任务 id=%d", id) - } - selected = append(selected, nextEntries[idx]) - } - sort.SliceStable(selected, func(i, j int) bool { - if selected[i].Week != selected[j].Week { - return selected[i].Week < selected[j].Week - } - if selected[i].DayOfWeek != selected[j].DayOfWeek { - return selected[i].DayOfWeek < selected[j].DayOfWeek - } - return selected[i].SectionFrom < selected[j].SectionFrom - }) - - switches := 0 - for i := 1; i < len(selected); i++ { - if selected[i].ContextTag != selected[i-1].ContextTag { - switches++ - } - } - if switches > 1 { - t.Fatalf("期望最少上下文切换(<=1),实际 switches=%d, tasks=%+v", switches, selected) - } - if selected[0].TaskItemID != 11 || selected[1].TaskItemID != 13 || selected[2].TaskItemID != 12 { - t.Fatalf("期望在原坑位集合内重排为 11,13,12,实际=%+v", selected) - } - for _, task := range selected { - if task.Week != 16 || task.DayOfWeek != 1 { - t.Fatalf("MinContextSwitch 不应跳出原坑位集合,实际 task=%+v", task) - } - } -} - -func TestRefineToolMinContextSwitchKeepsCurrentSlotSet(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 21, Name: "随机事件与概率基础概念复习", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "General"}, - {TaskItemID: 22, Name: "数制、码制与逻辑代数基础", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 1, SectionFrom: 11, SectionTo: 12, ContextTag: "General"}, - {TaskItemID: 23, Name: "第二章 条件概率与全概率公式", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 3, SectionFrom: 3, SectionTo: 4, ContextTag: "General"}, - } - params := map[string]any{ - "task_item_ids": []any{21.0, 22.0, 23.0}, - "week": 14, - "limit": 48, - "allow_embed": true, - } - policy := refineToolPolicy{OriginOrderMap: map[int]int{21: 1, 22: 2, 23: 3}} - - nextEntries, result := refineToolMinContextSwitch(entries, params, planningWindow{Enabled: false}, policy) - if !result.Success { - t.Fatalf("MinContextSwitch 执行失败: %s", result.Result) - } - - selected := make([]model.HybridScheduleEntry, 0, 3) - for _, id := range []int{21, 22, 23} { - idx := findSuggestedByID(nextEntries, id) - if idx < 0 { - t.Fatalf("未找到任务 id=%d", id) - } - selected = append(selected, nextEntries[idx]) - } - sort.SliceStable(selected, func(i, j int) bool { - if selected[i].Week != selected[j].Week { - return selected[i].Week < selected[j].Week - } - if selected[i].DayOfWeek != selected[j].DayOfWeek { - return selected[i].DayOfWeek < selected[j].DayOfWeek - } - return selected[i].SectionFrom < selected[j].SectionFrom - }) - - if selected[0].TaskItemID != 21 || selected[1].TaskItemID != 23 || selected[2].TaskItemID != 22 { - t.Fatalf("期望按原坑位集合重排为概率, 概率, 数电,实际=%+v", selected) - } - expectedSlots := map[int]string{ - 21: "14-1-1-2", - 23: "14-1-11-12", - 22: "14-3-3-4", - } - for _, task := range selected { - got := fmt.Sprintf("%d-%d-%d-%d", task.Week, task.DayOfWeek, task.SectionFrom, task.SectionTo) - if got != expectedSlots[task.TaskItemID] { - t.Fatalf("任务 id=%d 应仅在原坑位集合内换位,期望=%s 实际=%s", task.TaskItemID, expectedSlots[task.TaskItemID], got) - } - } -} - -func TestListTaskIDsFromToolCallComposite(t *testing.T) { - call := reactToolCall{ - Tool: "SpreadEven", - Params: map[string]any{ - "task_item_ids": []any{1.0, 2.0, 2.0}, - "task_item_id": 3, - }, - } - ids := listTaskIDsFromToolCall(call) - if len(ids) != 3 { - t.Fatalf("期望提取 3 个去重 ID,实际=%v", ids) - } - sort.Ints(ids) - if ids[0] != 1 || ids[1] != 2 || ids[2] != 3 { - t.Fatalf("提取结果错误,实际=%v", ids) - } -} diff --git a/backend/agent2/node/schedule_refine_impl/graph.go b/backend/agent2/node/schedule_refine_impl/graph.go deleted file mode 100644 index 98d071e..0000000 --- a/backend/agent2/node/schedule_refine_impl/graph.go +++ /dev/null @@ -1,114 +0,0 @@ -package schedulerefine - -import ( - "context" - "fmt" - - "github.com/cloudwego/eino-ext/components/model/ark" - "github.com/cloudwego/eino/compose" -) - -const ( - graphNodeContract = "schedule_refine_contract" - graphNodePlan = "schedule_refine_plan" - graphNodeSlice = "schedule_refine_slice" - graphNodeRoute = "schedule_refine_route" - graphNodeReact = "schedule_refine_react" - graphNodeHardCheck = "schedule_refine_hard_check" - graphNodeSummary = "schedule_refine_summary" -) - -// ScheduleRefineGraphRunInput 是“连续微调图”运行参数。 -// -// 字段语义: -// 1. Model:本轮图运行使用的聊天模型。 -// 2. State:预先注入的微调状态(通常来自上一版预览快照)。 -// 3. EmitStage:SSE 阶段回调,允许服务层把阶段进度透传给前端。 -type ScheduleRefineGraphRunInput struct { - Model *ark.ChatModel - State *ScheduleRefineState - EmitStage func(stage, detail string) -} - -// RunScheduleRefineGraph 执行“连续微调”独立图链路。 -// -// 链路顺序: -// START -> contract -> plan -> slice -> route -> react -> hard_check -> summary -> END -// -// 设计说明: -// 1. 当前链路采用线性图,确保可读性优先; -// 2. “终审失败后单次修复”在 hard_check 节点内部闭环处理,避免图连线分叉过多; -// 3. 若后续需要引入多分支策略(例如大改动转重排),可在 contract 后追加 branch 节点。 -func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInput) (*ScheduleRefineState, error) { - if input.Model == nil { - return nil, fmt.Errorf("schedule refine graph: model is nil") - } - if input.State == nil { - return nil, fmt.Errorf("schedule refine graph: state is nil") - } - - emitStage := func(stage, detail string) { - if input.EmitStage != nil { - input.EmitStage(stage, detail) - } - } - runner := newScheduleRefineRunner(input.Model, emitStage) - - graph := compose.NewGraph[*ScheduleRefineState, *ScheduleRefineState]() - if err := graph.AddLambdaNode(graphNodeContract, compose.InvokableLambda(runner.contractNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(graphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(graphNodeSlice, compose.InvokableLambda(runner.sliceNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(graphNodeRoute, compose.InvokableLambda(runner.routeNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(graphNodeReact, compose.InvokableLambda(runner.reactNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(graphNodeHardCheck, compose.InvokableLambda(runner.hardCheckNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(graphNodeSummary, compose.InvokableLambda(runner.summaryNode)); err != nil { - return nil, err - } - - if err := graph.AddEdge(compose.START, graphNodeContract); err != nil { - return nil, err - } - if err := graph.AddEdge(graphNodeContract, graphNodePlan); err != nil { - return nil, err - } - if err := graph.AddEdge(graphNodePlan, graphNodeSlice); err != nil { - return nil, err - } - if err := graph.AddEdge(graphNodeSlice, graphNodeRoute); err != nil { - return nil, err - } - if err := graph.AddEdge(graphNodeRoute, graphNodeReact); err != nil { - return nil, err - } - if err := graph.AddEdge(graphNodeReact, graphNodeHardCheck); err != nil { - return nil, err - } - if err := graph.AddEdge(graphNodeHardCheck, graphNodeSummary); err != nil { - return nil, err - } - if err := graph.AddEdge(graphNodeSummary, compose.END); err != nil { - return nil, err - } - - runnable, err := graph.Compile(ctx, - compose.WithGraphName("ScheduleRefineGraph"), - compose.WithMaxRunSteps(20), - compose.WithNodeTriggerMode(compose.AnyPredecessor), - ) - if err != nil { - return nil, err - } - return runnable.Invoke(ctx, input.State) -} diff --git a/backend/agent2/node/schedule_refine_impl/nodes.go b/backend/agent2/node/schedule_refine_impl/nodes.go deleted file mode 100644 index b8ad94a..0000000 --- a/backend/agent2/node/schedule_refine_impl/nodes.go +++ /dev/null @@ -1,3380 +0,0 @@ -package schedulerefine - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "regexp" - "sort" - "strconv" - "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 = 120 * time.Second - plannerMaxTokens = 420 - reactMaxTokens = 360 -) - -const ( - jsonContractForContract = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: intent,strategy,hard_requirements,hard_assertions,keep_relative_order,order_scope。" - jsonContractForPlanner = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: summary,steps。" - jsonContractForReact = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: done,summary,goal_check,decision,missing_info,tool_calls。" - jsonContractForReview = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: pass,reason,unmet。" - jsonContractForPostReflect = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: reflection,next_strategy,should_stop。" -) - -type contractOutput struct { - Intent string `json:"intent"` - Strategy string `json:"strategy"` - HardRequirements []string `json:"hard_requirements"` - HardAssertions []hardAssertionOutput `json:"hard_assertions"` - KeepRelativeOrder bool `json:"keep_relative_order"` - OrderScope string `json:"order_scope"` -} - -type hardAssertionOutput struct { - Metric string `json:"metric"` - Operator string `json:"operator"` - Value int `json:"value"` - Min int `json:"min"` - Max int `json:"max"` - Week int `json:"week"` - TargetWeek int `json:"target_week"` -} - -type postReflectOutput struct { - Reflection string `json:"reflection"` - NextStrategy string `json:"next_strategy"` - ShouldStop bool `json:"should_stop"` -} - -type plannerOutput struct { - Summary string `json:"summary"` - Steps []string `json:"steps"` -} - -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", "正在抽取本轮微调目标与硬性约束。") - - userPrompt := withNearestJSONContract( - fmt.Sprintf( - "当前时间=%s\n用户请求=%s\n当前排程条目数=%d\n可调 suggested 数=%d\n已有约束=%s\n历史摘要=%s", - st.RequestNowText, - strings.TrimSpace(st.UserMessage), - len(st.HybridEntries), - countSuggested(st.HybridEntries), - 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 - } - - intent := strings.TrimSpace(parsed.Intent) - if intent == "" { - intent = strings.TrimSpace(st.UserMessage) - } - // 1. 顺序策略以用户表达为准:默认保持顺序,明确授权乱序才放开。 - // 2. 不再让模型自行放宽顺序,避免契约漂移导致“默认乱序”。 - keepOrder := detectOrderIntent(st.UserMessage) - reqs := append([]string(nil), parsed.HardRequirements...) - if keepOrder { - reqs = append(reqs, "保持任务原始相对顺序不变") - } - assertions := normalizeHardAssertions(parsed.HardAssertions) - if len(assertions) == 0 { - // 1. 当模型未给出结构化断言时,后端基于请求做兜底推断。 - // 2. 目标是保证终审一定可落到“可编程判断”的参数层,而不是停留在自然语言。 - assertions = inferHardAssertionsFromRequest(st.UserMessage, reqs) - } - st.UserIntent = intent - st.Contract = RefineContract{ - Intent: intent, - Strategy: normalizeStrategy(parsed.Strategy), - HardRequirements: uniqueNonEmpty(reqs), - HardAssertions: assertions, - KeepRelativeOrder: keepOrder, - OrderScope: normalizeOrderScope(parsed.OrderScope), - } - emitStage("schedule_refine.contract.done", fmt.Sprintf("契约抽取完成:strategy=%s, keep_relative_order=%t。", st.Contract.Strategy, st.Contract.KeepRelativeOrder)) - return st, nil -} - -func runPlanNode( - 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 plan node") - } - if chatModel == nil { - return nil, fmt.Errorf("schedule refine: model is nil in plan node") - } - if err := runPlannerNode(ctx, chatModel, st, emitStage, "initial"); err != nil { - return st, err - } - return st, nil -} - -func runSliceNode( - ctx context.Context, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - _ = ctx - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in slice node") - } - emitStage("schedule_refine.slice.building", "正在构建本轮微调任务切片。") - slice := buildSlicePlan(st) - workset := collectWorksetTaskIDs(st.HybridEntries, slice, st.OriginOrderMap) - if len(workset) == 0 { - relaxed := slice - relaxed.SourceDays = nil - workset = collectWorksetTaskIDs(st.HybridEntries, relaxed, st.OriginOrderMap) - if len(workset) > 0 { - slice = relaxed - emitStage("schedule_refine.slice.relaxed", "切片首次为空,已放宽来源日过滤。") - } - } - if len(workset) == 0 { - workset = collectWorksetTaskIDs(st.HybridEntries, RefineSlicePlan{}, st.OriginOrderMap) - emitStage("schedule_refine.slice.fallback", "切片仍为空,已回退到全量 suggested 任务。") - } - st.SlicePlan = slice - st.Objective = compileRefineObjective(st, slice) - st.WorksetTaskIDs = workset - st.WorksetCursor = 0 - st.CurrentTaskID = 0 - st.CurrentTaskAttempt = 0 - emitStage("schedule_refine.slice.done", fmt.Sprintf("切片完成:workset=%d,week_filter=%v,source_days=%v,target_days=%v,exclude_sections=%v。", len(workset), slice.WeekFilter, slice.SourceDays, slice.TargetDays, slice.ExcludeSections)) - if raw, err := json.Marshal(st.Objective); err == nil { - emitStage("schedule_refine.objective.done", fmt.Sprintf("目标编译完成:%s", string(raw))) - } else { - emitStage("schedule_refine.objective.done", "目标编译完成。") - } - return st, nil -} - -// runCompositeRouteNode 在 ReAct 之前做一次“全局复合动作直达”分流。 -// -// 职责边界: -// 1. 负责识别是否命中全局复合目标(SpreadEven/MinContextSwitch); -// 2. 负责直接调用一次复合工具并按配置重试,争取在进入 ReAct 前完成收口; -// 3. 不负责语义推理与逐任务细调,失败后仅负责切换到“禁复合”的 ReAct 兜底链路。 -func runCompositeRouteNode( - ctx context.Context, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (*ScheduleRefineState, error) { - _ = ctx - if st == nil { - return nil, fmt.Errorf("schedule refine: nil state in route node") - } - ensureCompositeStateMaps(st) - if st.CompositeRetryMax < 0 { - st.CompositeRetryMax = defaultCompositeRetry - } - // 1. 先由后端判定本轮是否需要复合路由,避免把分流复杂度继续交给主 ReAct。 - // 2. 若已被上游标记为“禁复合兜底”,直接跳过该路由。 - if st.DisableCompositeTools { - emitStage("schedule_refine.route.skip", "当前已处于禁复合兜底模式,跳过复合路由。") - return st, nil - } - if strings.TrimSpace(st.RequiredCompositeTool) == "" { - st.RequiredCompositeTool = detectRequiredCompositeTool(st) - } - required := normalizeCompositeToolName(st.RequiredCompositeTool) - if required == "" { - emitStage("schedule_refine.route.skip", "未命中全局复合目标,直接进入 ReAct 兜底链路。") - return st, nil - } - - taskIDs := buildCompositeRouteTaskIDs(st) - if len(taskIDs) == 0 { - // 1. 没有任务可用于复合规划时,复合路由无法落地。 - // 2. 直接降级到 ReAct,并明确禁用复合工具,避免循环重试同一失败路径。 - st.CompositeRouteTried = true - st.DisableCompositeTools = true - st.RequiredCompositeTool = "" - st.CurrentPlan = buildFallbackPlan(st) - st.BatchMoveAllowed = false - emitStage("schedule_refine.route.fallback", "复合路由未获取到可执行任务,已切换到禁复合 ReAct 兜底。") - return st, nil - } - - totalAttempts := 1 + st.CompositeRetryMax - emitStage("schedule_refine.route.start", fmt.Sprintf("命中复合路由:tool=%s,task_count=%d,首次1次+重试%d次。", required, len(taskIDs), st.CompositeRetryMax)) - st.CompositeRouteTried = true - - policy := refineToolPolicy{ - // 1. 路由阶段只解决“坑位分布”。 - // 2. 顺序归位统一放在终审阶段,避免复合路由被顺序约束提前卡死。 - KeepRelativeOrder: false, - OrderScope: st.Contract.OrderScope, - OriginOrderMap: st.OriginOrderMap, - } - window := buildPlanningWindowFromEntries(st.HybridEntries) - lastReason := "" - - for attempt := 1; attempt <= totalAttempts; attempt++ { - if st.RoundUsed >= st.ExecuteMax { - lastReason = "动作预算已耗尽,无法继续复合路由重试" - break - } - call := buildCompositeRouteCall(st, required, taskIDs) - callJSON, _ := json.Marshal(call.Params) - emitStage("schedule_refine.route.attempt", fmt.Sprintf("复合路由第 %d/%d 次尝试:调用=%s 参数=%s。", attempt, totalAttempts, required, string(callJSON))) - - nextEntries, rawResult := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), call, window, policy) - result := normalizeToolResult(rawResult) - st.RoundUsed++ - markCompositeToolOutcome(st, result.Tool, result.Success) - emitStage("schedule_refine.route.result", fmt.Sprintf("复合路由第 %d 次结果:success=%t,error_code=%s,detail=%s", attempt, result.Success, fallbackText(result.ErrorCode, "NONE"), truncate(result.Result, 160))) - - if !result.Success { - lastReason = fallbackText(result.Result, fallbackText(result.ErrorCode, "复合工具执行失败")) - st.LastFailedCallSignature = buildToolCallSignature(call) - st.ConsecutiveFailures++ - continue - } - - st.HybridEntries = nextEntries - st.EntriesVersion++ - st.LastFailedCallSignature = "" - st.ConsecutiveFailures = 0 - st.ThinkingBoostArmed = false - window = buildPlanningWindowFromEntries(st.HybridEntries) - - // 1. 复合动作成功后必须立刻做后端确定性校验,避免“调用成功但目标未达成”被误收口。 - // 2. 仅当业务目标与(若存在)复合门禁同时通过时,才允许跳过 ReAct。 - if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied { - pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet) - if pass { - st.CompositeRouteSucceeded = true - emitStage("schedule_refine.route.pass", fmt.Sprintf("复合路由收口成功:%s", truncate(reason, 160))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由成功收口:tool=%s,reason=%s", required, reason)) - return st, nil - } - lastReason = fallbackText(strings.TrimSpace(reason), "确定性目标未达成") - if len(unmet) > 0 { - emitStage("schedule_refine.route.unmet", fmt.Sprintf("复合路由第 %d 次后仍未达成:%s", attempt, truncate(strings.Join(unmet, ";"), 180))) - } - continue - } - - // 1. “均匀分散/最少上下文切换”这类复合目标,未必能编译成 deterministic objective; - // 2. 只要本轮要求的复合工具已经成功执行,就允许独立复合分支直接出站并跳过 ReAct; - // 3. 最终是否真正达标,继续交给 hard_check 统一裁决,避免“工具成功却被路由误判失败”。 - if reason, ok := allowCompositeRouteExitByToolSuccess(st, result); ok { - st.CompositeRouteSucceeded = true - emitStage("schedule_refine.route.handoff", truncate(reason, 180)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由直接出站:tool=%s,reason=%s", required, reason)) - return st, nil - } - - lastReason = "未启用确定性目标,且复合工具门禁未满足,无法在复合路由直接出站" - } - - // 1. 复合路由重试后仍失败,切入 ReAct 兜底并强制禁用复合工具。 - // 2. 禁用后仅允许基础工具逐任务搬运,避免再次回到复合失败路径造成震荡。 - st.DisableCompositeTools = true - st.RequiredCompositeTool = "" - st.CurrentPlan = buildFallbackPlan(st) - st.BatchMoveAllowed = false - emitStage("schedule_refine.route.fallback", fmt.Sprintf("复合路由未收口,切换禁复合 ReAct 兜底:%s", truncate(fallbackText(lastReason, "复合路由达到重试上限"), 180))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由失败后降级:%s", fallbackText(lastReason, "无具体失败原因"))) - return st, nil -} - -func buildCompositeRouteTaskIDs(st *ScheduleRefineState) []int { - if st == nil { - return nil - } - ids := uniquePositiveInts(append([]int(nil), st.WorksetTaskIDs...)) - if len(ids) > 0 { - return ids - } - ids = collectSourceTaskIDsForObjective(st.InitialHybridEntries, st.Objective, st.SlicePlan.WeekFilter) - if len(ids) > 0 { - return ids - } - // 兜底:从当前 suggested 中提取一份稳定任务集,避免因切片异常导致路由空跑。 - seen := make(map[int]struct{}, len(st.HybridEntries)) - out := make([]int, 0, len(st.HybridEntries)) - for _, entry := range st.HybridEntries { - if !isMovableSuggestedTask(entry) { - continue - } - if _, ok := seen[entry.TaskItemID]; ok { - continue - } - seen[entry.TaskItemID] = struct{}{} - out = append(out, entry.TaskItemID) - } - sort.Ints(out) - return out -} - -// allowCompositeRouteExitByToolSuccess 判断“复合工具成功后,是否允许跳过 ReAct 直接进入终审”。 -// -// 步骤化说明: -// 1. 仅在当前没有 deterministic objective 时启用,避免覆盖原有“确定性验收优先”策略; -// 2. 只有本轮要求的复合工具已成功、且成功工具名与门禁一致时才放行; -// 3. 放行后并不代表最终成功,后续仍由 hard_check 做统一裁决。 -func allowCompositeRouteExitByToolSuccess(st *ScheduleRefineState, result reactToolResult) (string, bool) { - if st == nil || !result.Success { - return "", false - } - if strings.TrimSpace(st.Objective.Mode) != "" && strings.TrimSpace(st.Objective.Mode) != "none" { - return "", false - } - required := normalizeCompositeToolName(st.RequiredCompositeTool) - toolName := normalizeCompositeToolName(result.Tool) - if required == "" || toolName == "" || required != toolName { - return "", false - } - if !isRequiredCompositeSatisfied(st) { - return "", false - } - return fmt.Sprintf("复合工具 %s 已成功执行;当前目标暂不支持确定性收口,跳过 ReAct,交由终审裁决。", required), true -} - -func buildCompositeRouteCall(st *ScheduleRefineState, tool string, taskIDs []int) reactToolCall { - limit := len(taskIDs) * 6 - if limit < 12 { - limit = 12 - } - params := map[string]any{ - "task_item_ids": append([]int(nil), taskIDs...), - "allow_embed": true, - "limit": limit, - } - targetWeeks := append([]int(nil), st.Objective.TargetWeeks...) - if len(targetWeeks) == 0 { - targetWeeks = keysOfIntSet(inferTargetWeekSet(st.SlicePlan)) - } - if len(targetWeeks) == 0 { - targetWeeks = append([]int(nil), st.Objective.SourceWeeks...) - } - if len(targetWeeks) == 1 { - params["week"] = targetWeeks[0] - } else if len(targetWeeks) > 1 { - params["week_filter"] = targetWeeks - } - - targetDays := append([]int(nil), st.Objective.TargetDays...) - if len(targetDays) == 0 { - targetDays = append([]int(nil), st.SlicePlan.TargetDays...) - } - if len(targetDays) > 0 { - params["day_of_week"] = targetDays - } - if len(st.SlicePlan.ExcludeSections) > 0 { - params["exclude_sections"] = append([]int(nil), st.SlicePlan.ExcludeSections...) - } - return reactToolCall{ - Tool: tool, - Params: params, - } -} - -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 st.CompositeRouteSucceeded { - emitStage("schedule_refine.react.skip", "复合路由已收口成功,跳过 ReAct 兜底循环。") - return st, nil - } - if len(st.HybridEntries) == 0 { - st.ActionLogs = append(st.ActionLogs, "无可微调条目,跳过动作循环。") - return st, nil - } - if len(st.WorksetTaskIDs) == 0 { - st.ActionLogs = append(st.ActionLogs, "workset 为空,跳过动作循环。") - return st, nil - } - if st.PerTaskBudget <= 0 { - st.PerTaskBudget = defaultPerTaskBudget - } - 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.TaskActionUsed == nil { - st.TaskActionUsed = make(map[int]int) - } - if st.SeenSlotQueries == nil { - st.SeenSlotQueries = make(map[string]struct{}) - } - ensureCompositeStateMaps(st) - if st.DisableCompositeTools { - st.RequiredCompositeTool = "" - emitStage("schedule_refine.react.fallback_mode", "当前为禁复合兜底模式:仅允许基础工具逐任务调整。") - } else if strings.TrimSpace(st.RequiredCompositeTool) == "" { - st.RequiredCompositeTool = detectRequiredCompositeTool(st) - } - if strings.TrimSpace(st.CurrentPlan.Summary) == "" { - st.CurrentPlan = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st)) - st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan) - } - - window := buildPlanningWindowFromEntries(st.HybridEntries) - sourceWeekSet := inferSourceWeekSet(st.SlicePlan) - policy := refineToolPolicy{ - // 1. 执行期不再用顺序约束卡住 Move/Swap; - // 2. LLM 只负责把坑位排好,顺序由后端在收口阶段统一归位。 - KeepRelativeOrder: false, - OrderScope: st.Contract.OrderScope, - OriginOrderMap: st.OriginOrderMap, - } - emitStage( - "schedule_refine.react.start", - fmt.Sprintf( - "开始执行单任务微步 ReAct,workset=%d,per_task_budget=%d,execute_max=%d,replan_max=%d,required_composite=%s,required_success=%t。", - len(st.WorksetTaskIDs), - st.PerTaskBudget, - st.ExecuteMax, - st.ReplanMax, - fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无"), - isRequiredCompositeSatisfied(st), - ), - ) - -outer: - for st.WorksetCursor < len(st.WorksetTaskIDs) && st.RoundUsed < st.ExecuteMax { - // 1. 每次取下一个任务前先做一次全局目标短路判断。 - // 2. 目标已满足时,直接结束整个 workset 循环,避免“任务6~10 空转”。 - if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass { - if isRequiredCompositeSatisfied(st) { - emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("全局目标已满足,提前结束任务循环:%s", truncate(reason, 160))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标提前达成,触发短路结束:%s", reason)) - break - } - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标看似达成但未满足复合工具门禁:required=%s", normalizeCompositeToolName(st.RequiredCompositeTool))) - } - taskID := st.WorksetTaskIDs[st.WorksetCursor] - current, ok := findSuggestedEntryByTaskID(st.HybridEntries, taskID) - if !ok { - st.WorksetCursor++ - continue - } - if len(sourceWeekSet) > 0 { - if _, inSourceWeek := sourceWeekSet[current.Week]; !inSourceWeek { - emitStage("schedule_refine.react.task_skip_scope", fmt.Sprintf("任务 id=%d 当前位于 W%d,不在来源周范围,已跳过。", taskID, current.Week)) - st.WorksetCursor++ - continue - } - } - st.CurrentTaskID = taskID - st.CurrentTaskAttempt = 0 - emitStage("schedule_refine.react.task_start", fmt.Sprintf("开始处理任务 %d/%d:id=%d,%s。", st.WorksetCursor+1, len(st.WorksetTaskIDs), taskID, strings.TrimSpace(current.Name))) - - taskDone := false - for st.CurrentTaskAttempt < st.PerTaskBudget && st.RoundUsed < st.ExecuteMax { - // 1. 每轮开头先刷新“当前任务”的最新位置,避免模型基于旧坐标决策。 - // 2. 若该任务已满足切片目标(例如“已从周末迁出到工作日”),则直接收口当前任务。 - latest, exists := findSuggestedEntryByTaskID(st.HybridEntries, taskID) - if !exists { - taskDone = true - emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已不在 suggested 列表,视为当前任务已完成。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:任务条目已不再可调 suggested。", taskID)) - break - } - current = latest - if isCurrentTaskSatisfiedBySlice(current, st.SlicePlan) { - // 1. 自动收口前必须通过复合工具门禁。 - // 2. 这样可避免“切片已满足但未执行必需复合工具”直接跳过执行阶段。 - if isRequiredCompositeSatisfied(st) { - taskDone = true - emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已满足切片目标,自动收口并切换下一任务。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:已满足切片目标。", taskID)) - break - } - emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 虽满足切片目标,但复合工具门禁未通过,继续执行。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止自动收口:required_composite=%s 尚未成功。", taskID, fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无"))) - } - - round := st.RoundUsed + 1 - remainingAction := st.ExecuteMax - st.RoundUsed - remainingTotal := st.MaxRounds - st.RoundUsed - useThinking, reason := shouldEnableRecoveryThinking(st) - st.CurrentTaskAttempt++ - emitStage("schedule_refine.react.round_start", fmt.Sprintf("第 %d 轮微调开始(任务id=%d,第 %d/%d 次尝试),动作剩余=%d,总剩余=%d。", round, taskID, st.CurrentTaskAttempt, st.PerTaskBudget, remainingAction, remainingTotal)) - if useThinking { - emitStage("schedule_refine.react.reasoning_switch", fmt.Sprintf("第 %d 轮已启用恢复态 thinking:%s", round, reason)) - } - - userPrompt := buildMicroReactUserPrompt(st, current, remainingAction, remainingTotal) - 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) - if errors.Is(err, context.DeadlineExceeded) && st.RoundUsed > 0 { - st.WorksetCursor = len(st.WorksetTaskIDs) - break - } - return st, err - } - emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.plan", round), raw) - 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), - } - emitStage("schedule_refine.react.plan", formatReactPlanStageDetail(round, parsed, remainingAction, useThinking)) - emitStage("schedule_refine.react.need_info", formatReactNeedInfoStageDetail(round, parsed.MissingInfo)) - - if parsed.Done { - allowDone := isCurrentTaskSatisfiedBySlice(current, st.SlicePlan) - if allowDone && !isRequiredCompositeSatisfied(st) { - allowDone = false - } - if !allowDone { - if pass, _, _, applied := evaluateObjectiveDeterministic(st); applied && pass && isRequiredCompositeSatisfied(st) { - allowDone = true - } - } - if !allowDone { - observation.Reflect = fmt.Sprintf("模型返回 done=true,但任务 id=%d 尚未满足切片目标或复合工具门禁未通过,继续执行。", taskID) - st.ObservationHistory = append(st.ObservationHistory, observation) - emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 拒绝提前 done:当前任务未满足目标。", taskID)) - continue - } - reasonText := fallbackText(strings.TrimSpace(parsed.Summary), "模型判定当前任务已满足目标。") - observation.Reflect = reasonText - st.ObservationHistory = append(st.ObservationHistory, observation) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 完成:%s", taskID, reasonText)) - emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - taskDone = true - break - } - - call, warn := pickSingleToolCall(parsed.ToolCalls) - if warn != "" { - emitStage("schedule_refine.react.round_warn", fmt.Sprintf("第 %d 轮告警:%s", round, warn)) - } - if call == nil { - observation.Reflect = "本轮未生成可执行工具动作。" - st.ObservationHistory = append(st.ObservationHistory, observation) - emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - break - } - normalizedCall := canonicalizeToolCall(*call) - call = &normalizedCall - emitStage("schedule_refine.react.tool_call", formatToolCallStageDetail(round, *call, remainingAction)) - - callSignature := buildToolCallSignature(*call) - taskIDs := listTaskIDsFromToolCall(*call) - if blockedResult, blocked := precheckCurrentTaskOwnership(*call, taskIDs, taskID); blocked { - if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil { - return st, err - } else if stop { - taskDone = true - break - } - continue - } - if blockedResult, blocked := precheckToolCallPolicy(st, *call, taskIDs); blocked { - if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil { - return st, err - } else if stop { - taskDone = true - break - } - continue - } - if isRepeatedFailedCall(st, callSignature) { - repeat := reactToolResult{Tool: strings.TrimSpace(call.Tool), Success: false, ErrorCode: "REPEAT_FAILED_ACTION", Result: "重复失败动作:与上一轮失败动作完全相同,请更换目标时段或改用 Swap。"} - if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, repeat, &observation); err != nil { - return st, err - } else if stop { - taskDone = true - break - } - continue - } - - for _, id := range taskIDs { - st.TaskActionUsed[id]++ - } - nextEntries, rawResult := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, window, policy) - result := normalizeToolResult(rawResult) - st.RoundUsed++ - markCompositeToolOutcome(st, result.Tool, result.Success) - - 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, _, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) - observation.Reflect = postReflectText - st.ObservationHistory = append(st.ObservationHistory, observation) - - emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) - emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - if result.Success { - st.HybridEntries = nextEntries - window = buildPlanningWindowFromEntries(st.HybridEntries) - if isMutatingToolName(result.Tool) { - st.EntriesVersion++ - } - st.LastFailedCallSignature = "" - st.ConsecutiveFailures = 0 - st.ThinkingBoostArmed = false - // 1. 动作成功后立即尝试全局短路,避免继续拉着后续任务空转。 - // 2. 只要 deterministic 目标达成,直接收口整个 ReAct 循环。 - if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass { - if isRequiredCompositeSatisfied(st) { - emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("动作后全局目标达成,提前结束任务循环:%s", truncate(reason, 160))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后全局目标达成,触发短路结束:%s", reason)) - taskDone = true - break outer - } - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后目标达成但复合工具门禁未通过:required=%s", normalizeCompositeToolName(st.RequiredCompositeTool))) - } - if latest, exists := findSuggestedEntryByTaskID(st.HybridEntries, taskID); exists { - current = latest - if isCurrentTaskSatisfiedBySlice(current, st.SlicePlan) { - if isRequiredCompositeSatisfied(st) { - taskDone = true - emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已满足切片目标,自动结束当前任务。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:动作后已满足切片目标。", taskID)) - break - } - emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 动作后满足切片目标,但复合工具门禁未通过,继续执行。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止动作后自动收口:required_composite=%s 尚未成功。", taskID, fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无"))) - } - } else { - taskDone = true - emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已不在 suggested 列表,自动结束当前任务。", taskID)) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:动作后不再可调。", taskID)) - break - } - } 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 { - // 1. 模型建议 should_stop 只作为“候选中断信号”,必须经后端目标校验确认。 - // 2. 若全局目标未达成,则继续本地循环,避免模型误停。 - if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass { - if isRequiredCompositeSatisfied(st) { - emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("模型建议停止且全局目标达成,提前收口:%s", truncate(reason, 160))) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止且目标达成,触发短路结束:%s", reason)) - taskDone = true - break outer - } - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止但复合工具门禁未通过:required=%s", normalizeCompositeToolName(st.RequiredCompositeTool))) - } - } - } - - emitStage("schedule_refine.react.task_done", fmt.Sprintf("任务 id=%d 处理完成:status=%s。", taskID, taskProgressLabel(taskDone, st.CurrentTaskAttempt, st.PerTaskBudget))) - st.WorksetCursor++ - st.CurrentTaskID = 0 - st.CurrentTaskAttempt = 0 - } - emitStage("schedule_refine.react.done", fmt.Sprintf("单任务微步 ReAct 结束:已执行轮次=%d,重规划次数=%d,已处理任务=%d/%d。", st.RoundUsed, st.ReplanUsed, st.WorksetCursor, len(st.WorksetTaskIDs))) - return st, nil -} - -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", "正在执行终审硬校验。") - // 1. 先锁定“业务目标是否达成”的判定结果(未排序前)。 - // 2. 后续顺序归位仅用于最终展示与顺序一致性,不得反向改变业务目标成败。 - intentPassLocked, intentReasonLocked, intentUnmetLocked := evaluateIntentForJudgement(ctx, chatModel, st, emitStage) - emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("终审业务目标已锁定:pass=%t,reason=%s", intentPassLocked, truncate(intentReasonLocked, 120))) - if changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st); skipped { - emitStage("schedule_refine.hard_check.order_normalized", "已跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。") - } else if changed { - emitStage("schedule_refine.hard_check.order_normalized", "已在终审前按 origin_rank 对坑位做顺序归位。") - } - report := evaluateHardChecks(ctx, chatModel, st, emitStage) - report.IntentPassed = intentPassLocked - report.IntentReason = intentReasonLocked - report.IntentUnmet = append([]string(nil), intentUnmetLocked...) - 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 - } - intentPassLocked, intentReasonLocked, intentUnmetLocked = evaluateIntentForJudgement(ctx, chatModel, st, emitStage) - emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("修复后业务目标已锁定:pass=%t,reason=%s", intentPassLocked, truncate(intentReasonLocked, 120))) - if changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st); skipped { - emitStage("schedule_refine.hard_check.order_normalized", "修复后跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。") - } else if changed { - emitStage("schedule_refine.hard_check.order_normalized", "修复后已按 origin_rank 对坑位做顺序归位。") - } - report = evaluateHardChecks(ctx, chatModel, st, emitStage) - report.IntentPassed = intentPassLocked - report.IntentReason = intentReasonLocked - report.IntentUnmet = append([]string(nil), intentUnmetLocked...) - 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 -} - -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) - contractJSON, _ := json.Marshal(st.Contract) - userPrompt := fmt.Sprintf("用户请求=%s\n契约=%s\n终审报告=%s\n动作日志=%s", strings.TrimSpace(st.UserMessage), string(contractJSON), string(reportJSON), summarizeActionLogs(st.ActionLogs, 24)) - 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 FinalHardCheckPassed(st) { - summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) - } else { - summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) - } - } - summary = alignSummaryWithHardCheck(st, summary) - st.FinalSummary = summary - // 1. Completed 只代表“最终终审已通过”,不再把“链路执行完毕”误写成成功; - // 2. 这样外层持久化与展示层可以准确区分“已通过方案”与“当前最优但未达标方案”; - // 3. 若只是返回 best-effort 结果,FinalSummary 仍会保留,但 Completed=false。 - st.Completed = FinalHardCheckPassed(st) - emitStage("schedule_refine.summary.done", "微调总结已生成。") - return st, nil -} - -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 - // 1. 顺序校验默认开启:即便执行期放开顺序限制,终审也要验证“后端归位”后的顺序正确性。 - // 2. 但 MinContextSwitch 成功后,重排后的顺序本身就是业务目标,不能再拿 origin_rank 反向判错。 - // 3. 当 origin_order_map 为空时同样降级跳过,避免无基线时误报。 - needOrderCheck := len(st.OriginOrderMap) > 0 && !shouldSkipOrderConstraintCheck(st) - report.OrderIssues = validateRelativeOrder(st.HybridEntries, refineToolPolicy{ - KeepRelativeOrder: needOrderCheck, - OrderScope: st.Contract.OrderScope, - OriginOrderMap: st.OriginOrderMap, - }) - report.OrderPassed = len(report.OrderIssues) == 0 - - // 1. 优先使用“契约编译后”的确定性终审,执行与终审共用同一份目标约束。 - // 2. 仅当目标约束不可判定时,才回退语义终审兜底。 - if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied { - pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, reason, unmet) - report.IntentPassed = pass - report.IntentReason = strings.TrimSpace(reason) - report.IntentUnmet = append([]string(nil), unmet...) - return report - } - - review, err := runSemanticReview(ctx, chatModel, st, emitStage) - if err != nil { - report.IntentPassed = false - report.IntentReason = fmt.Sprintf("语义校验失败:%v", err) - report.IntentUnmet = []string{"语义校验阶段异常"} - return report - } - pass, reason, unmet := applyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet) - report.IntentPassed = pass - report.IntentReason = strings.TrimSpace(reason) - report.IntentUnmet = append([]string(nil), unmet...) - return report -} - -// evaluateIntentForJudgement 在“最终排序前”计算业务目标是否达成。 -// -// 说明: -// 1. 优先走 deterministic objective; -// 2. objective 不可判定时退回语义 review; -// 3. 返回值会在 hard_check 中被锁定,避免后置排序反向干扰业务目标判定。 -func evaluateIntentForJudgement( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (pass bool, reason string, unmet []string) { - if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied { - pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet) - return pass, strings.TrimSpace(reason), append([]string(nil), unmet...) - } - review, err := runSemanticReview(ctx, chatModel, st, emitStage) - if err != nil { - return false, fmt.Sprintf("语义校验失败:%v", err), []string{"语义校验阶段异常"} - } - pass, reason, unmet = applyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet) - return pass, strings.TrimSpace(reason), append([]string(nil), unmet...) -} - -// compileRefineObjective 把自然语言契约编译为“可执行且可校验”的目标参数。 -func compileRefineObjective(st *ScheduleRefineState, slice RefineSlicePlan) RefineObjective { - obj := RefineObjective{ - Mode: "none", - SourceWeeks: keysOfIntSet(inferSourceWeekSet(slice)), - TargetWeeks: keysOfIntSet(inferTargetWeekSet(slice)), - SourceDays: uniquePositiveInts(append([]int(nil), slice.SourceDays...)), - TargetDays: uniquePositiveInts(append([]int(nil), slice.TargetDays...)), - ExcludeSections: uniquePositiveInts(append([]int(nil), slice.ExcludeSections...)), - } - // 1. 若契约断言显式给出来源/目标周,优先回填到 objective; - // 2. 避免后续终审只能依赖自然语言猜测。 - for _, assertion := range st.Contract.HardAssertions { - if assertion.Week > 0 && len(obj.SourceWeeks) == 0 { - obj.SourceWeeks = []int{assertion.Week} - } - if assertion.TargetWeek > 0 && len(obj.TargetWeeks) == 0 { - obj.TargetWeeks = []int{assertion.TargetWeek} - } - } - - if len(obj.SourceWeeks) == 0 && len(slice.WeekFilter) == 1 && slice.WeekFilter[0] > 0 { - obj.SourceWeeks = []int{slice.WeekFilter[0]} - } - if len(obj.TargetWeeks) == 0 && len(slice.WeekFilter) == 1 && (len(obj.SourceDays) > 0 || len(obj.TargetDays) > 0) { - obj.TargetWeeks = []int{slice.WeekFilter[0]} - } - - // 来源范围为空时无法构造目标,交给语义终审兜底。 - if len(obj.SourceWeeks) == 0 && len(obj.SourceDays) == 0 { - obj.Reason = "来源范围为空,未启用确定性目标。" - return obj - } - // 目标范围为空时同样不启用确定性目标。 - if len(obj.TargetWeeks) == 0 && len(obj.TargetDays) == 0 { - obj.Reason = "目标范围为空,未启用确定性目标。" - return obj - } - - sourceTaskIDs := collectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, slice.WeekFilter) - obj.BaselineSourceTaskCount = len(sourceTaskIDs) - - halfIntent := hasHalfTransferIntent(st) - if halfIntent && len(obj.SourceWeeks) > 0 && len(obj.TargetWeeks) > 0 && !isSameWeeks(obj.SourceWeeks, obj.TargetWeeks) { - obj.Mode = "move_ratio" - obj.RequiredMoveMin = obj.BaselineSourceTaskCount / 2 - obj.RequiredMoveMax = (obj.BaselineSourceTaskCount + 1) / 2 - obj.Reason = "检测到“半数迁移”意图,按比例目标执行与终审。" - return obj - } - - obj.Mode = "move_all" - obj.RequiredMoveMin = obj.BaselineSourceTaskCount - obj.RequiredMoveMax = obj.BaselineSourceTaskCount - obj.Reason = "默认按来源范围任务全部进入目标范围执行与终审。" - return obj -} - -// evaluateObjectiveDeterministic 基于编译后的目标做确定性终审。 -func evaluateObjectiveDeterministic(st *ScheduleRefineState) (pass bool, reason string, unmet []string, applied bool) { - if st == nil { - return false, "", nil, false - } - obj := st.Objective - if strings.TrimSpace(obj.Mode) == "" || strings.TrimSpace(obj.Mode) == "none" { - return false, "", nil, false - } - - sourceTaskIDs := collectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, st.SlicePlan.WeekFilter) - if len(sourceTaskIDs) == 0 { - return true, "确定性校验通过:来源范围无可调任务。", nil, true - } - - byTaskID := buildMovableTaskIndex(st.HybridEntries) - movedCount := 0 - violations := make([]string, 0, 8) - for _, taskID := range sourceTaskIDs { - entries := byTaskID[taskID] - if len(entries) == 0 { - violations = append(violations, fmt.Sprintf("任务id=%d 未在结果中找到可移动条目", taskID)) - continue - } - if len(entries) > 1 { - violations = append(violations, fmt.Sprintf("任务id=%d 命中 %d 条可移动条目,状态不唯一", taskID, len(entries))) - continue - } - entry := entries[0] - moved, why := isTaskMovedIntoObjectiveTarget(entry, obj) - if moved { - movedCount++ - continue - } - if obj.Mode == "move_all" { - violations = append(violations, fmt.Sprintf("任务id=%d 未满足目标范围:%s", taskID, why)) - continue - } - // 比例模式下,允许部分任务不迁移;但若任务落在来源/目标之外,视为异常。 - if !isTaskInObjectiveSource(entry, obj) { - violations = append(violations, fmt.Sprintf("任务id=%d 既不在来源也不在目标范围(W%dD%d)", taskID, entry.Week, entry.DayOfWeek)) - } - } - - if movedCount < obj.RequiredMoveMin || movedCount > obj.RequiredMoveMax { - violations = append(violations, fmt.Sprintf("迁移数量未达标:要求在[%d,%d],实际=%d", obj.RequiredMoveMin, obj.RequiredMoveMax, movedCount)) - } - - if len(violations) == 0 { - return true, fmt.Sprintf("确定性校验通过:迁移数量达标(%d/%d)。", movedCount, len(sourceTaskIDs)), nil, true - } - return false, fmt.Sprintf("确定性校验未通过:仍有 %d 项约束未满足。", len(violations)), violations, true -} - -func collectSourceTaskIDsForObjective(entries []model.HybridScheduleEntry, obj RefineObjective, fallbackWeekFilter []int) []int { - if len(entries) == 0 { - return nil - } - sourceWeekSet := intSliceToWeekSet(obj.SourceWeeks) - sourceDaySet := intSliceToDaySet(obj.SourceDays) - fallbackWeekSet := intSliceToWeekSet(fallbackWeekFilter) - - seen := make(map[int]struct{}, len(entries)) - ids := make([]int, 0, len(entries)) - for _, entry := range entries { - if !isMovableSuggestedTask(entry) { - continue - } - if len(sourceWeekSet) > 0 { - if _, ok := sourceWeekSet[entry.Week]; !ok { - continue - } - } else if len(fallbackWeekSet) > 0 { - if _, ok := fallbackWeekSet[entry.Week]; !ok { - continue - } - } - if len(sourceDaySet) > 0 { - if _, ok := sourceDaySet[entry.DayOfWeek]; !ok { - continue - } - } - if _, exists := seen[entry.TaskItemID]; exists { - continue - } - seen[entry.TaskItemID] = struct{}{} - ids = append(ids, entry.TaskItemID) - } - sort.Ints(ids) - return ids -} - -func buildMovableTaskIndex(entries []model.HybridScheduleEntry) map[int][]model.HybridScheduleEntry { - index := make(map[int][]model.HybridScheduleEntry, len(entries)) - for _, entry := range entries { - if !isMovableSuggestedTask(entry) { - continue - } - index[entry.TaskItemID] = append(index[entry.TaskItemID], entry) - } - return index -} - -func hasHalfTransferIntent(st *ScheduleRefineState) bool { - if st == nil { - return false - } - if hasHalfTransferAssertion(st.Contract.HardAssertions) { - return true - } - joined := strings.ToLower(strings.Join(append([]string{st.UserMessage, st.Contract.Intent}, st.Contract.HardRequirements...), " ")) - for _, key := range []string{"一半", "半数", "对半", "50%"} { - if strings.Contains(joined, key) { - return true - } - } - return false -} - -func hasHalfTransferAssertion(assertions []RefineAssertion) bool { - for _, item := range assertions { - metric := strings.ToLower(strings.TrimSpace(item.Metric)) - if metric == "" { - continue - } - switch metric { - case "source_move_ratio_percent", "move_ratio_percent", "half_transfer_ratio": - switch strings.TrimSpace(item.Operator) { - case "==", ">=", "<=", "between": - if item.Value == 50 || item.Min == 50 || item.Max == 50 { - return true - } - } - case "source_remaining_count": - // 1. 该断言常用于“迁走一半后来源剩余=一半”。 - // 2. 具体阈值是否满足由 objective + deterministic 校验统一判定。 - return true - } - } - return false -} - -func normalizeHardAssertions(raw []hardAssertionOutput) []RefineAssertion { - if len(raw) == 0 { - return nil - } - out := make([]RefineAssertion, 0, len(raw)) - for _, item := range raw { - metric := strings.TrimSpace(item.Metric) - if metric == "" { - continue - } - operator := strings.TrimSpace(item.Operator) - if operator == "" { - operator = "==" - } - assertion := RefineAssertion{ - Metric: metric, - Operator: operator, - Value: item.Value, - Min: item.Min, - Max: item.Max, - Week: item.Week, - TargetWeek: item.TargetWeek, - } - out = append(out, assertion) - } - if len(out) == 0 { - return nil - } - return out -} - -func inferHardAssertionsFromRequest(message string, requirements []string) []RefineAssertion { - joined := strings.TrimSpace(message + " " + strings.Join(requirements, " ")) - if joined == "" { - return nil - } - weeks := extractWeekFilters(joined) - if !containsAny(strings.ToLower(joined), []string{"一半", "半数", "对半", "50%"}) { - return nil - } - // 1. 兜底断言:要求来源任务迁移比例为 50%。 - // 2. week/target_week 使用文本中前两个周次,便于后续 objective 编译。 - assertion := RefineAssertion{ - Metric: "source_move_ratio_percent", - Operator: "==", - Value: 50, - } - if len(weeks) > 0 { - assertion.Week = weeks[0] - } - if len(weeks) > 1 { - assertion.TargetWeek = weeks[1] - } - return []RefineAssertion{assertion} -} - -func isTaskMovedIntoObjectiveTarget(entry model.HybridScheduleEntry, obj RefineObjective) (bool, string) { - targetWeekSet := intSliceToWeekSet(obj.TargetWeeks) - targetDaySet := intSliceToDaySet(obj.TargetDays) - excludedSections := intSliceToSectionSet(obj.ExcludeSections) - if len(targetWeekSet) > 0 { - if _, ok := targetWeekSet[entry.Week]; !ok { - return false, fmt.Sprintf("week=%d 不在目标周", entry.Week) - } - } - if len(targetDaySet) > 0 { - if _, ok := targetDaySet[entry.DayOfWeek]; !ok { - return false, fmt.Sprintf("day_of_week=%d 不在目标日", entry.DayOfWeek) - } - } - if len(excludedSections) > 0 && intersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSections) { - return false, fmt.Sprintf("section=%d-%d 命中排除节次", entry.SectionFrom, entry.SectionTo) - } - return true, "" -} - -func isTaskInObjectiveSource(entry model.HybridScheduleEntry, obj RefineObjective) bool { - sourceWeekSet := intSliceToWeekSet(obj.SourceWeeks) - sourceDaySet := intSliceToDaySet(obj.SourceDays) - if len(sourceWeekSet) > 0 { - if _, ok := sourceWeekSet[entry.Week]; !ok { - return false - } - } - if len(sourceDaySet) > 0 { - if _, ok := sourceDaySet[entry.DayOfWeek]; !ok { - return false - } - } - return true -} - -func isSameWeeks(left []int, right []int) bool { - if len(left) == 0 || len(right) == 0 { - return false - } - lset := intSliceToWeekSet(left) - rset := intSliceToWeekSet(right) - if len(lset) != len(rset) { - return false - } - for w := range lset { - if _, ok := rset[w]; !ok { - return false - } - } - return true -} - -// normalizeMovableTaskOrderByOrigin 在“坑位不变”的前提下,按 origin_rank 归位任务顺序。 -// -// 步骤化说明: -// 1. 先提取所有可移动任务的当前坑位(week/day/section); -// 2. 再按任务跨度分组,避免把 2 节任务塞进 3 节坑位; -// 3. 每个跨度组内按坑位时间升序与 origin_rank 升序做一一映射; -// 4. 最终只改“任务身份落到哪个坑位”,不改坑位分布本身。 -func normalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) bool { - if st == nil || len(st.HybridEntries) <= 1 || len(st.OriginOrderMap) == 0 { - return false - } - entries := cloneHybridEntries(st.HybridEntries) - indices := make([]int, 0, len(entries)) - for idx, entry := range entries { - if isMovableSuggestedTask(entry) { - indices = append(indices, idx) - } - } - if len(indices) <= 1 { - return false - } - - type slot struct { - Week int - DayOfWeek int - SectionFrom int - SectionTo int - } - groupSlots := make(map[int][]slot) // key=span - groupTasks := make(map[int][]model.HybridScheduleEntry) // key=span - for _, idx := range indices { - entry := entries[idx] - span := entry.SectionTo - entry.SectionFrom + 1 - groupSlots[span] = append(groupSlots[span], slot{ - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - SectionTo: entry.SectionTo, - }) - groupTasks[span] = append(groupTasks[span], entry) - } - - changed := false - spanKeys := make([]int, 0, len(groupSlots)) - for span := range groupSlots { - spanKeys = append(spanKeys, span) - } - sort.Ints(spanKeys) - - groupCursor := make(map[int]int, len(groupSlots)) - for _, span := range spanKeys { - slots := groupSlots[span] - tasks := groupTasks[span] - if len(slots) != len(tasks) || len(slots) == 0 { - continue - } - sort.SliceStable(slots, func(i, j int) bool { - if slots[i].Week != slots[j].Week { - return slots[i].Week < slots[j].Week - } - if slots[i].DayOfWeek != slots[j].DayOfWeek { - return slots[i].DayOfWeek < slots[j].DayOfWeek - } - if slots[i].SectionFrom != slots[j].SectionFrom { - return slots[i].SectionFrom < slots[j].SectionFrom - } - return slots[i].SectionTo < slots[j].SectionTo - }) - sort.SliceStable(tasks, func(i, j int) bool { - ri := st.OriginOrderMap[tasks[i].TaskItemID] - rj := st.OriginOrderMap[tasks[j].TaskItemID] - if ri <= 0 { - ri = 1 << 30 - } - if rj <= 0 { - rj = 1 << 30 - } - if ri != rj { - return ri < rj - } - if tasks[i].Week != tasks[j].Week { - return tasks[i].Week < tasks[j].Week - } - if tasks[i].DayOfWeek != tasks[j].DayOfWeek { - return tasks[i].DayOfWeek < tasks[j].DayOfWeek - } - if tasks[i].SectionFrom != tasks[j].SectionFrom { - return tasks[i].SectionFrom < tasks[j].SectionFrom - } - return tasks[i].TaskItemID < tasks[j].TaskItemID - }) - for i := range tasks { - tasks[i].Week = slots[i].Week - tasks[i].DayOfWeek = slots[i].DayOfWeek - tasks[i].SectionFrom = slots[i].SectionFrom - tasks[i].SectionTo = slots[i].SectionTo - } - groupTasks[span] = tasks - } - - for _, idx := range indices { - entry := entries[idx] - span := entry.SectionTo - entry.SectionFrom + 1 - cursor := groupCursor[span] - if cursor >= len(groupTasks[span]) { - continue - } - nextEntry := groupTasks[span][cursor] - groupCursor[span] = cursor + 1 - if entry.TaskItemID != nextEntry.TaskItemID || - entry.Week != nextEntry.Week || - entry.DayOfWeek != nextEntry.DayOfWeek || - entry.SectionFrom != nextEntry.SectionFrom || - entry.SectionTo != nextEntry.SectionTo { - changed = true - } - entries[idx] = nextEntry - } - if !changed { - return false - } - sortHybridEntries(entries) - st.HybridEntries = entries - return true -} - -// tryNormalizeMovableTaskOrderByOrigin 决定是否执行“按 origin_rank 顺序归位”。 -// -// 步骤化说明: -// 1. 默认仍保持旧行为,继续在终审前做展示侧顺序归位; -// 2. 但当 MinContextSwitch 已成功执行时,重排后的顺序本身就是业务目标的一部分; -// 3. 此时若再按 origin_rank 归位,会把复合工具效果直接抹掉,因此必须跳过。 -func tryNormalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) (changed bool, skipped bool) { - if shouldSkipOriginOrderNormalization(st) { - return false, true - } - return normalizeMovableTaskOrderByOrigin(st), false -} - -func shouldSkipOriginOrderNormalization(st *ScheduleRefineState) bool { - if st == nil { - return false - } - ensureCompositeStateMaps(st) - if st.CompositeToolSuccess["MinContextSwitch"] { - return true - } - return false -} - -func shouldSkipOrderConstraintCheck(st *ScheduleRefineState) bool { - return shouldSkipOriginOrderNormalization(st) -} - -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\nMove标准Schema={task_item_id,to_week,to_day,to_section_from,to_section_to}\nSwap标准Schema={task_a,task_b}", - 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("修复阶段未给出可执行动作") - } - normalizedCall := canonicalizeToolCall(*call) - call = &normalizedCall - if !isMutatingToolName(strings.TrimSpace(call.Tool)) { - return fmt.Errorf("修复阶段工具不允许:%s(仅允许 Move/Swap/BatchMove)", strings.TrimSpace(call.Tool)) - } - emitStage("schedule_refine.hard_check.repair_call", formatToolCallStageDetail(st.RoundUsed+1, *call, st.MaxRounds-st.RoundUsed)) - nextEntries, result := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, buildPlanningWindowFromEntries(st.HybridEntries), refineToolPolicy{ - KeepRelativeOrder: false, - OrderScope: st.Contract.OrderScope, - OriginOrderMap: st.OriginOrderMap, - }) - result = normalizeToolResult(result) - st.RoundUsed++ - 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 - if isMutatingToolName(result.Tool) { - st.EntriesVersion++ - } - return nil -} - -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) -} - -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) - planDecision := "" - if plan != nil { - planDecision = strings.TrimSpace(plan.Decision) - } - userPrompt := withNearestJSONContract( - fmt.Sprintf( - "用户请求=%s\n契约=%s\n本轮计划.decision=%s\n本轮工具调用=%s\n本轮工具结果=%s\n最近观察=%s", - strings.TrimSpace(st.UserMessage), - string(contractJSON), - planDecision, - string(callJSON), - string(resultJSON), - buildObservationPrompt(st.ObservationHistory, 2), - ), - 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 - 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 -} - -func buildPostReflectFallback(plan *reactLLMOutput, result reactToolResult) string { - modelReflect := "" - if plan != nil { - modelReflect = strings.TrimSpace(plan.Decision) - } - return buildRuntimeReflect(modelReflect, result) -} - -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") - } - ensureCompositeStateMaps(st) - // 1. 正常模式下由后端判定“本轮必用复合工具”。 - // 2. 若已进入禁复合兜底模式,必须清空该标记,避免规划阶段再次把复合门禁写回去。 - if st.DisableCompositeTools { - st.RequiredCompositeTool = "" - } else { - st.RequiredCompositeTool = detectRequiredCompositeTool(st) - } - 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) - userPrompt := withNearestJSONContract( - fmt.Sprintf( - "mode=%s\n用户请求=%s\n契约=%s\n上一轮工具观察=%s\n最近观察=%s\nsuggested简表=%s", - mode, - strings.TrimSpace(st.UserMessage), - string(contractJSON), - buildLastToolObservationPrompt(st.ObservationHistory), - buildObservationPrompt(st.ObservationHistory, 2), - buildSuggestedDigest(st.HybridEntries, 40), - ), - jsonContractForPlanner, - ) - raw, err := callModelText(ctx, chatModel, plannerPrompt, userPrompt, false, plannerMaxTokens, 0) - if err != nil { - st.CurrentPlan = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st)) - st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan) - st.PlanUsed++ - 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 = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st)) - st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan) - st.PlanUsed++ - 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), - } - st.CurrentPlan = applyCompositeHardConditionToPlan(st, st.CurrentPlan) - st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan) - if st.DisableCompositeTools { - st.BatchMoveAllowed = false - } - st.PlanUsed++ - emitStage("schedule_refine.plan.done", fmt.Sprintf("规划完成:%s", truncate(st.CurrentPlan.Summary, 180))) - return nil -} - -func handleBlockedToolResult( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), - round int, - parsed *reactLLMOutput, - call *reactToolCall, - callSignature string, - blockedResult reactToolResult, - observation *ReactRoundObservation, -) (bool, error) { - result := normalizeToolResult(blockedResult) - st.RoundUsed++ - 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, _, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) - observation.Reflect = postReflectText - st.ObservationHistory = append(st.ObservationHistory, *observation) - emitStage("schedule_refine.react.tool_blocked", fmt.Sprintf("第 %d 轮|动作被后端策略拦截:%s", round, truncate(result.Result, 120))) - emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) - emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - if shouldTriggerReplan(st, result) { - if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil { - return false, err - } else if replanned { - return false, nil - } - } - return shouldStop, nil -} - -func buildFallbackPlan(st *ScheduleRefineState) PlannerPlan { - summary := "兜底计划:先取证再动作,优先复合工具,其次 Move,冲突时尝试 Swap。" - if st != nil && st.Contract.KeepRelativeOrder { - summary = "兜底计划:先取证再动作,严格保持相对顺序,优先复合工具,其次 Move,冲突时尝试 Swap。" - } - return PlannerPlan{ - Summary: summary, - Steps: []string{ - "1) QueryTargetTasks 定位目标任务", - "2) QueryAvailableSlots 获取可用空位", - "3) 优先 SpreadEven/MinContextSwitch,其次 Move/Swap 执行动作并复盘", - "4) 收尾前执行 Verify 自检", - }, - } -} - -// ensureCompositeStateMaps 确保复合工具状态容器已初始化。 -func ensureCompositeStateMaps(st *ScheduleRefineState) { - if st == nil { - return - } - if st.CompositeToolCalled == nil { - st.CompositeToolCalled = map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - } - } - if st.CompositeToolSuccess == nil { - st.CompositeToolSuccess = map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - } - } -} - -// detectRequiredCompositeTool 根据请求语义识别本轮必用复合工具。 -// -// 规则: -// 1. “上下文切换最少/同科目连续”优先映射 MinContextSwitch; -// 2. “均匀分散/铺开”映射 SpreadEven; -// 3. 未命中时返回空串,不强制复合工具。 -func detectRequiredCompositeTool(st *ScheduleRefineState) string { - if st == nil { - return "" - } - joined := strings.TrimSpace(strings.Join([]string{ - st.UserMessage, - st.Contract.Intent, - strings.Join(st.Contract.HardRequirements, " "), - }, " ")) - if joined == "" { - return "" - } - contextKeys := []string{"上下文切换", "切换最少", "同个科目", "同科目", "连续处理", "连续学习", "min context", "context switch"} - if containsAny(strings.ToLower(joined), contextKeys) || containsAny(joined, contextKeys) { - return "MinContextSwitch" - } - evenKeys := []string{"均匀", "分散", "铺开", "平摊", "均摊", "spread even", "even spread"} - if containsAny(strings.ToLower(joined), evenKeys) || containsAny(joined, evenKeys) { - return "SpreadEven" - } - return "" -} - -// applyCompositeHardConditionToPlan 把“必用复合工具”硬条件注入计划文本。 -func applyCompositeHardConditionToPlan(st *ScheduleRefineState, plan PlannerPlan) PlannerPlan { - required := "" - if st != nil { - required = normalizeCompositeToolName(st.RequiredCompositeTool) - } - if required == "" { - return plan - } - - hardStep := fmt.Sprintf("硬条件:必须成功调用 %s(COMPOSITE_SUCCESS[%s]=true)后才允许整体收口", required, required) - hasHardStep := false - for _, step := range plan.Steps { - if strings.Contains(step, required) && strings.Contains(step, "COMPOSITE_SUCCESS") { - hasHardStep = true - break - } - } - if !hasHardStep { - plan.Steps = append([]string{hardStep}, plan.Steps...) - } - if !strings.Contains(plan.Summary, required) { - plan.Summary = strings.TrimSpace(plan.Summary + ";硬条件:" + required + " 成功==true") - } - return plan -} - -func normalizeCompositeToolName(name string) string { - switch strings.TrimSpace(name) { - case "SpreadEven": - return "SpreadEven" - case "MinContextSwitch": - return "MinContextSwitch" - default: - return "" - } -} - -func isCompositeToolName(toolName string) bool { - switch normalizeCompositeToolName(toolName) { - case "SpreadEven", "MinContextSwitch": - return true - default: - return false - } -} - -func isBaseMutatingToolName(toolName string) bool { - switch strings.TrimSpace(toolName) { - case "Move", "Swap", "BatchMove": - return true - default: - return false - } -} - -func isRequiredCompositeSatisfied(st *ScheduleRefineState) bool { - if st == nil { - return true - } - required := normalizeCompositeToolName(st.RequiredCompositeTool) - if required == "" { - return true - } - ensureCompositeStateMaps(st) - return st.CompositeToolSuccess[required] -} - -// applyCompositeGateToIntentResult 把“必用复合工具成功”并入业务目标判定。 -// -// 步骤化说明: -// 1. 先判断原始业务判定是否通过;未通过则原样返回; -// 2. 再判断是否配置了必用复合工具;未配置则原样返回; -// 3. 若配置但未成功,强制改判为失败并补充 unmet 原因。 -func applyCompositeGateToIntentResult(st *ScheduleRefineState, pass bool, reason string, unmet []string) (bool, string, []string) { - if !pass { - return pass, reason, append([]string(nil), unmet...) - } - required := normalizeCompositeToolName("") - if st != nil { - required = normalizeCompositeToolName(st.RequiredCompositeTool) - } - if required == "" { - return pass, reason, append([]string(nil), unmet...) - } - if isRequiredCompositeSatisfied(st) { - return pass, reason, append([]string(nil), unmet...) - } - newUnmet := append([]string(nil), unmet...) - newUnmet = append(newUnmet, fmt.Sprintf("复合工具门禁未通过:%s 尚未成功调用", required)) - return false, fmt.Sprintf("复合工具门禁未通过:要求 %s 成功==true。", required), newUnmet -} - -func markCompositeToolOutcome(st *ScheduleRefineState, toolName string, success bool) { - if st == nil { - return - } - tool := normalizeCompositeToolName(toolName) - if tool == "" { - return - } - ensureCompositeStateMaps(st) - st.CompositeToolCalled[tool] = true - if success { - st.CompositeToolSuccess[tool] = true - } -} - -func shouldAllowBatchMove(plan PlannerPlan) bool { - text := strings.ToLower(strings.TrimSpace(plan.Summary)) - if strings.Contains(text, "batchmove") || strings.Contains(text, "batch move") { - return true - } - for _, step := range plan.Steps { - s := strings.ToLower(strings.TrimSpace(step)) - if strings.Contains(s, "batchmove") || strings.Contains(s, "batch move") { - return true - } - } - return false -} - -func shouldEnableRecoveryThinking(st *ScheduleRefineState) (bool, string) { - if st == nil { - return false, "" - } - if st.ConsecutiveFailures < 2 || st.ThinkingBoostArmed { - return false, "" - } - st.ThinkingBoostArmed = true - return true, fmt.Sprintf("连续失败=%d,触发 1 轮恢复态 thinking", st.ConsecutiveFailures) -} - -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", "TASK_BUDGET_EXCEEDED", "BATCH_MOVE_DISABLED", "CURRENT_TASK_MISMATCH", "QUERY_REDUNDANT", "SLOT_QUERY_FAILED", "PLAN_FAILED", "PLAN_EMPTY", "COMPOSITE_REQUIRED", "COMPOSITE_DISABLED": - return true - default: - return false - } -} - -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 || 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 - } - st.ConsecutiveFailures = 0 - st.ThinkingBoostArmed = false - return true, nil -} - -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 -} - -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 -} - -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") -} - -func emitModelRawDebug(emitStage func(stage, detail string), tag string, raw string) { - if emitStage == nil { - return - } - clean := strings.TrimSpace(raw) - if clean == "" { - clean = "" - } - const chunkSize = 1600 - runes := []rune(clean) - if len(runes) <= chunkSize { - emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s] %s", strings.TrimSpace(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) - } - emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s][part %d/%d] %s", strings.TrimSpace(tag), i+1, total, string(runes[start:end]))) - } -} - -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 sec := entry.SectionFrom; sec <= entry.SectionTo; sec++ { - key := fmt.Sprintf("%d-%d-%d", entry.Week, entry.DayOfWeek, sec) - if existed, ok := slotMap[key]; ok { - issues = append(issues, fmt.Sprintf("冲突:%s 与 %s 同时占用 W%dD%d 第%d节", existed, entry.Name, entry.Week, entry.DayOfWeek, sec)) - } 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 isMovableSuggestedTask(entry) { - 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 isMovableSuggestedTask(entry) { - 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 -} - -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 -} - -// alignSummaryWithHardCheck 对齐总结文案与硬校验事实,避免“通过/失败”口径冲突。 -// -// 步骤化说明: -// 1. 先以 hard_check 最终结果作为唯一真值; -// 2. pass=true 且 round_used=0 时,强制输出“未执行动作但已满足”的口径; -// 3. pass=true 但文案含失败词,或 pass=false 但文案含通过词,统一纠偏。 -func alignSummaryWithHardCheck(st *ScheduleRefineState, summary string) string { - clean := strings.TrimSpace(summary) - if st == nil { - return clean - } - passed := FinalHardCheckPassed(st) - if passed { - if st.RoundUsed == 0 { - return "本轮未执行调度动作(0轮),当前排程已满足终审条件。" - } - if clean == "" || containsAny(clean, []string{"未完全", "未达标", "未能", "差距", "失败", "未通过"}) { - return fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) - } - return clean - } - - if clean == "" || containsAny(clean, []string{"终审通过", "已通过终审", "完全达成", "全部满足"}) { - return fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) - } - return clean -} - -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, strings.Join(uniqueNonEmpty(missing), ";")) -} - -func formatReactReflectStageDetail(round int, reflect string) string { - 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}) - } - sort.SliceStable(result, func(i, j int) bool { return result[i].Week < result[j].Week }) - return result -} - -func buildFallbackContract(st *ScheduleRefineState) RefineContract { - intent := strings.TrimSpace(st.UserMessage) - keepOrder := detectOrderIntent(st.UserMessage) - reqs := append([]string(nil), st.Constraints...) - if keepOrder { - reqs = append(reqs, "保持任务原始相对顺序不变") - } - assertions := inferHardAssertionsFromRequest(st.UserMessage, reqs) - return RefineContract{ - Intent: intent, - Strategy: "local_adjust", - HardRequirements: uniqueNonEmpty(reqs), - HardAssertions: assertions, - KeepRelativeOrder: keepOrder, - OrderScope: "global", - } -} - -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 true - } - // 1. 默认启用顺序约束,除非用户明确授权可打乱顺序。 - // 2. 这样可避免“用户没提顺序但结果被打乱”的违和体验。 - for _, k := range []string{"可以打乱顺序", "允许打乱顺序", "顺序无所谓", "不考虑顺序", "不用保持顺序", "无需保持顺序", "随便排顺序", "乱序也行"} { - if strings.Contains(msg, k) { - return false - } - } - return true -} - -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 err.Error() - } - return string(raw) -} - -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 "无" -} - -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) -} - -func buildSlotQuerySignature(st *ScheduleRefineState, params map[string]any) string { - normalized := canonicalizeToolCall(reactToolCall{Tool: "QueryAvailableSlots", Params: params}) - raw, _ := json.Marshal(normalized.Params) - version := 0 - if st != nil { - version = st.EntriesVersion - } - return fmt.Sprintf("v=%d|%s", version, string(raw)) -} - -func canonicalizeToolCall(call reactToolCall) reactToolCall { - canonical := reactToolCall{ - Tool: strings.TrimSpace(call.Tool), - Params: cloneToolParams(call.Params), - } - switch canonical.Tool { - case "Move": - canonical.Params = canonicalizeMoveParams(canonical.Params) - case "BatchMove": - canonical.Params = canonicalizeBatchMoveParams(canonical.Params) - case "SpreadEven", "MinContextSwitch": - canonical.Params = canonicalizeCompositeMoveParams(canonical.Params) - case "QueryAvailableSlots": - canonical.Params = canonicalizeSlotQueryParams(canonical.Params) - } - return canonical -} - -func canonicalizeMoveParams(params map[string]any) map[string]any { - out := cloneToolParams(params) - setCanonicalInt(out, "task_item_id", out, "task_item_id", "task_id") - setCanonicalInt(out, "to_week", out, "to_week", "target_week", "new_week", "week") - setCanonicalInt(out, "to_day", out, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day") - setCanonicalInt(out, "to_section_from", out, "to_section_from", "target_section_from", "new_section_from", "section_from") - setCanonicalInt(out, "to_section_to", out, "to_section_to", "target_section_to", "new_section_to", "section_to") - return out -} - -func canonicalizeBatchMoveParams(params map[string]any) map[string]any { - out := cloneToolParams(params) - rawMoves, ok := out["moves"] - if !ok { - return out - } - moves, ok := rawMoves.([]any) - if !ok { - return out - } - normalized := make([]any, 0, len(moves)) - for _, item := range moves { - moveMap, ok := item.(map[string]any) - if !ok { - continue - } - normalized = append(normalized, canonicalizeMoveParams(moveMap)) - } - out["moves"] = normalized - return out -} - -func canonicalizeCompositeMoveParams(params map[string]any) map[string]any { - out := cloneToolParams(params) - ids := readIntSlice(out, "task_item_ids", "task_ids") - if taskID, ok := paramIntAny(out, "task_item_id", "task_id"); ok { - ids = append(ids, taskID) - } - if len(ids) > 0 { - out["task_item_ids"] = uniquePositiveInts(ids) - } - - setCanonicalInt(out, "week", out, "week", "to_week", "target_week", "new_week") - if day, ok := paramIntAny(out, "day_of_week", "to_day", "target_day_of_week", "target_day", "new_day", "day"); ok { - out["day_of_week"] = []int{day} - } - if weeks := readIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 { - out["week_filter"] = uniquePositiveInts(weeks) - } - if days := readIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 { - out["day_of_week"] = uniquePositiveInts(days) - } - if sections := readIntSlice(out, "exclude_sections", "exclude_section"); len(sections) > 0 { - out["exclude_sections"] = uniquePositiveInts(sections) - } - return out -} - -func canonicalizeSlotQueryParams(params map[string]any) map[string]any { - out := cloneToolParams(params) - setCanonicalInt(out, "week", out, "week") - if weeks := readIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 { - out["week_filter"] = uniquePositiveInts(weeks) - } - if days := readIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 { - out["day_filter"] = uniquePositiveInts(days) - } - setCanonicalInt(out, "section_duration", out, "section_duration", "span", "task_duration") - setCanonicalInt(out, "section_from", out, "section_from", "target_section_from") - setCanonicalInt(out, "section_to", out, "section_to", "target_section_to") - setCanonicalInt(out, "limit", out, "limit") - return out -} - -func setCanonicalInt(dst map[string]any, dstKey string, src map[string]any, keys ...string) { - if dst == nil || src == nil { - return - } - if value, ok := paramIntAny(src, keys...); ok { - dst[dstKey] = value - } -} - -func listTaskIDsFromToolCall(call reactToolCall) []int { - switch strings.TrimSpace(call.Tool) { - case "Move": - taskID, ok := paramIntAny(call.Params, "task_item_id", "task_id") - if !ok { - return nil - } - return []int{taskID} - case "Swap": - taskA, okA := paramIntAny(call.Params, "task_a", "task_item_a", "task_item_id_a") - taskB, okB := paramIntAny(call.Params, "task_b", "task_item_b", "task_item_id_b") - return uniquePositiveInts([]int{taskA, taskB}, okA, okB) - case "BatchMove": - rawMoves, ok := call.Params["moves"] - if !ok { - return nil - } - moves, ok := rawMoves.([]any) - if !ok { - return nil - } - ids := make([]int, 0, len(moves)) - for _, item := range moves { - moveMap, ok := item.(map[string]any) - if !ok { - continue - } - if taskID, ok := paramIntAny(moveMap, "task_item_id", "task_id"); ok { - ids = append(ids, taskID) - } - } - return uniquePositiveInts(ids) - case "SpreadEven", "MinContextSwitch": - ids := readIntSlice(call.Params, "task_item_ids", "task_ids") - if taskID, ok := paramIntAny(call.Params, "task_item_id", "task_id"); ok { - ids = append(ids, taskID) - } - return uniquePositiveInts(ids) - default: - return nil - } -} - -func precheckCurrentTaskOwnership(call reactToolCall, taskIDs []int, currentTaskID int) (reactToolResult, bool) { - if currentTaskID <= 0 { - return reactToolResult{}, false - } - if !isMutatingToolName(strings.TrimSpace(call.Tool)) { - return reactToolResult{}, false - } - for _, id := range taskIDs { - if id == currentTaskID { - return reactToolResult{}, false - } - } - return reactToolResult{ - Tool: strings.TrimSpace(call.Tool), - Success: false, - ErrorCode: "CURRENT_TASK_MISMATCH", - Result: fmt.Sprintf("当前微循环任务为 id=%d,本轮改写动作未包含该任务,请改为围绕当前任务执行。", currentTaskID), - }, true -} - -func precheckToolCallPolicy(st *ScheduleRefineState, call reactToolCall, taskIDs []int) (reactToolResult, bool) { - if st == nil { - return reactToolResult{}, false - } - toolName := strings.TrimSpace(call.Tool) - if st.DisableCompositeTools && isCompositeToolName(toolName) { - return reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "COMPOSITE_DISABLED", - Result: "当前已进入 ReAct 兜底模式,禁止调用复合工具,请使用 Move/Swap 逐步处理。", - }, true - } - if st.DisableCompositeTools && toolName == "BatchMove" { - return reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "BATCH_MOVE_DISABLED", - Result: "当前兜底模式要求逐任务挪动,禁止使用 BatchMove。", - }, true - } - if toolName == "BatchMove" && !st.BatchMoveAllowed { - return reactToolResult{Tool: toolName, Success: false, ErrorCode: "BATCH_MOVE_DISABLED", Result: "当前计划未显式允许 BatchMove,请改用单步 Move/Swap。"}, true - } - if toolName == "QueryAvailableSlots" { - if st.SeenSlotQueries == nil { - st.SeenSlotQueries = make(map[string]struct{}) - } - signature := buildSlotQuerySignature(st, call.Params) - if _, exists := st.SeenSlotQueries[signature]; exists { - return reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "QUERY_REDUNDANT", - Result: "同版本排程下重复查询同一空位范围,已拒绝;请直接基于 ENV_SLOT_HINT 选择落点。", - }, true - } - st.SeenSlotQueries[signature] = struct{}{} - return reactToolResult{}, false - } - // 1. 当计划声明“必用复合工具”且尚未成功时,先锁住基础写工具。 - // 2. 这样可避免模型绕开复合工具直接 Move,导致“命中率低 + 语义漂移”。 - requiredComposite := normalizeCompositeToolName(st.RequiredCompositeTool) - if requiredComposite != "" && !isRequiredCompositeSatisfied(st) && isMutatingToolName(toolName) { - if toolName != requiredComposite { - return reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "COMPOSITE_REQUIRED", - Result: fmt.Sprintf("当前计划要求先成功调用 %s;在其成功前禁止使用 %s。", requiredComposite, toolName), - }, true - } - } - if !isMutatingToolName(toolName) { - return reactToolResult{}, false - } - if st.PerTaskBudget <= 0 || len(taskIDs) == 0 { - return reactToolResult{}, false - } - for _, taskID := range taskIDs { - if st.TaskActionUsed[taskID] >= st.PerTaskBudget { - return reactToolResult{Tool: toolName, Success: false, ErrorCode: "TASK_BUDGET_EXCEEDED", Result: fmt.Sprintf("任务 id=%d 已达到单任务动作预算上限=%d,请重规划或更换目标任务。", taskID, st.PerTaskBudget)}, true - } - } - return reactToolResult{}, false -} - -func isMutatingToolName(toolName string) bool { - switch strings.TrimSpace(toolName) { - case "Move", "Swap", "BatchMove", "SpreadEven", "MinContextSwitch": - return true - default: - return false - } -} - -func uniquePositiveInts(ids []int, oks ...bool) []int { - allowAll := len(oks) == 0 - seen := make(map[int]struct{}, len(ids)) - out := make([]int, 0, len(ids)) - for i, id := range ids { - if !allowAll { - if i >= len(oks) || !oks[i] { - continue - } - } - if id <= 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -func isRepeatedFailedCall(st *ScheduleRefineState, signature string) bool { - if st == nil { - return false - } - current := strings.TrimSpace(signature) - last := strings.TrimSpace(st.LastFailedCallSignature) - return current != "" && last != "" && current == last -} - -func normalizeToolResult(result reactToolResult) reactToolResult { - if result.Success { - return result - } - if strings.TrimSpace(result.ErrorCode) != "" { - return result - } - result.ErrorCode = classifyToolFailureCode(result.Result) - return result -} - -func classifyToolFailureCode(detail string) string { - text := strings.TrimSpace(detail) - switch { - case strings.Contains(text, "单任务动作预算上限"): - return "TASK_BUDGET_EXCEEDED" - case strings.Contains(text, "未显式允许 BatchMove"): - return "BATCH_MOVE_DISABLED" - 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 "TASK_ID_AMBIGUOUS" - 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" - } -} - -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) - } - return fmt.Sprintf("后端复盘:工具执行成功。%s。模型预期(动作前):%s", resultText, truncate(modelText, 180)) - } - if modelText == "" { - return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。", resultText) - } - 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 isMovableSuggestedTask(entry) { - 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 buildSuggestedDigestByWeek(entries []model.HybridScheduleEntry, week int, limit int) string { - if week <= 0 { - return buildSuggestedDigest(entries, limit) - } - filtered := make([]model.HybridScheduleEntry, 0, len(entries)) - for _, entry := range entries { - if isMovableSuggestedTask(entry) && entry.Week == week { - filtered = append(filtered, entry) - } - } - if len(filtered) == 0 { - return "无同周 suggested 条目" - } - return buildSuggestedDigest(filtered, limit) -} - -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 "未知" - } -} - -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 - } - emitStage("schedule_refine.react.parse_retry", fmt.Sprintf("第 %d 轮输出解析失败,准备重试1次:%s", round, truncate(parseErr.Error(), 260))) - retryRaw, retryErr := callModelText(ctx, chatModel, reactPrompt, userPrompt, false, reactMaxTokens, 0) - if retryErr != nil { - emitStage("schedule_refine.react.round_error", formatRoundModelErrorDetail(round, fmt.Errorf("解析重试调用失败: %w", retryErr), ctx)) - return nil, respond.ScheduleRefineOutputParseFailed - } - emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.retry", round), retryRaw) - retryParsed, retryParseErr := parseReactLLMOutput(retryRaw) - if retryParseErr != nil { - emitStage("schedule_refine.react.round_error", fmt.Sprintf("第 %d 轮输出二次解析失败:%s", round, truncate(retryParseErr.Error(), 260))) - return nil, respond.ScheduleRefineOutputParseFailed - } - emitStage("schedule_refine.react.parse_retry_success", fmt.Sprintf("第 %d 轮输出重试解析成功,继续执行。", round)) - return retryParsed, nil -} - -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 -} - -func buildSlicePlan(st *ScheduleRefineState) RefineSlicePlan { - msg := strings.TrimSpace(st.UserMessage) - lower := strings.ToLower(msg) - plan := RefineSlicePlan{ - WeekFilter: extractWeekFilters(msg), - ExcludeSections: extractExcludeSections(msg), - Reason: "根据用户请求抽取得到执行切片", - } - // 1. 优先解析“从A收敛到B”这类方向型表达,防止把 source/target 反向识别。 - // 2. 例如“周四到周五收敛到周一到周三”应得到 source=[4,5], target=[1,2,3]。 - if src, tgt, ok := extractDirectionalSourceTargetDays(msg); ok { - plan.SourceDays = src - plan.TargetDays = tgt - return plan - } - if strings.Contains(msg, "工作日") || strings.Contains(msg, "周一到周五") || strings.Contains(msg, "周1到周5") { - plan.TargetDays = []int{1, 2, 3, 4, 5} - } else if containsAny(lower, []string{"移到周末", "挪到周末", "安排在周末", "放到周末"}) { - plan.TargetDays = []int{6, 7} - } else if days := extractTargetDaysFromMessage(msg); len(days) > 0 { - plan.TargetDays = days - } - if len(plan.TargetDays) == 5 && isSameDays(plan.TargetDays, []int{1, 2, 3, 4, 5}) && strings.Contains(msg, "周末") { - plan.SourceDays = []int{6, 7} - } - if day := detectOverloadedDay(msg); day > 0 { - plan.SourceDays = uniquePositiveInts(append(plan.SourceDays, day)) - } - if fromDays := extractSourceDaysFromMessage(msg); len(fromDays) > 0 { - plan.SourceDays = uniquePositiveInts(append(plan.SourceDays, fromDays...)) - } - return plan -} - -// extractDirectionalSourceTargetDays 解析“来源日 -> 目标日”表达。 -// -// 规则: -// 1. 以“收敛到/移到/挪到/调整到”等方向词为分割; -// 2. 分割前提取 source days,分割后提取 target days; -// 3. 两侧都提取成功才返回 true,避免误判。 -func extractDirectionalSourceTargetDays(text string) ([]int, []int, bool) { - verbIdx := -1 - verbLen := 0 - for _, key := range []string{"收敛到", "移到", "挪到", "调整到", "安排到", "放到", "改到", "迁移到", "分散到"} { - if idx := strings.Index(text, key); idx >= 0 { - verbIdx = idx - verbLen = len(key) - break - } - } - if verbIdx < 0 { - return nil, nil, false - } - left := strings.TrimSpace(text[:verbIdx]) - right := strings.TrimSpace(text[verbIdx+verbLen:]) - if left == "" || right == "" { - return nil, nil, false - } - source := extractDayExpr(left) - target := extractDayExpr(right) - if len(source) == 0 || len(target) == 0 { - return nil, nil, false - } - return source, target, true -} - -// extractDayExpr 提取文本中的“星期表达式”。 -// 优先提取区间(周一到周三),提不到再提取离散天。 -func extractDayExpr(text string) []int { - if days := extractRangeDays(text); len(days) > 0 { - return days - } - return extractDays(text) -} - -// inferSourceWeekSet 推断“来源周”集合。 -// -// 规则: -// 1. 当 week_filter 至少两个值时,默认第一个值视为来源周(保留用户原话顺序); -// 2. 当 week_filter 少于两个值时,不强制来源周过滤,返回空集合; -// 3. 该规则用于收敛 workset,避免把目标周任务误纳入当前微循环。 -func inferSourceWeekSet(slice RefineSlicePlan) map[int]struct{} { - if len(slice.WeekFilter) < 2 { - return nil - } - sourceWeek := slice.WeekFilter[0] - if sourceWeek <= 0 { - return nil - } - return map[int]struct{}{sourceWeek: {}} -} - -// inferTargetWeekSet 推断“目标周”集合。 -// -// 规则: -// 1. 当 week_filter 至少两个值时,除首个来源周外,其余周视为目标周; -// 2. 当 week_filter 少于两个值时,不构造目标周集合,交由其他约束判定; -// 3. 返回升维集合用于 O(1) 命中判断。 -func inferTargetWeekSet(slice RefineSlicePlan) map[int]struct{} { - if len(slice.WeekFilter) < 2 { - return nil - } - set := make(map[int]struct{}, len(slice.WeekFilter)-1) - for _, week := range slice.WeekFilter[1:] { - if week > 0 { - set[week] = struct{}{} - } - } - if len(set) == 0 { - return nil - } - return set -} - -func collectWorksetTaskIDs(entries []model.HybridScheduleEntry, slice RefineSlicePlan, originOrder map[int]int) []int { - type candidate struct { - TaskID int - Week int - Day int - SectionFrom int - Rank int - } - list := make([]candidate, 0, len(entries)) - seen := make(map[int]struct{}, len(entries)) - weekSet := intSliceToWeekSet(slice.WeekFilter) - sourceWeekSet := inferSourceWeekSet(slice) - sourceSet := intSliceToDaySet(slice.SourceDays) - for _, entry := range entries { - if !isMovableSuggestedTask(entry) { - continue - } - // 1. 方向型周次请求(例如“14周挪到13周”)下,只把“来源周”任务放入 workset。 - // 2. 这样做可以避免目标周/其他周任务被误当成当前微循环任务,触发串改。 - if len(sourceWeekSet) > 0 { - if _, ok := sourceWeekSet[entry.Week]; !ok { - continue - } - } - if len(weekSet) > 0 { - if _, ok := weekSet[entry.Week]; !ok { - continue - } - } - if len(sourceSet) > 0 { - if _, ok := sourceSet[entry.DayOfWeek]; !ok { - continue - } - } - if _, ok := seen[entry.TaskItemID]; ok { - continue - } - seen[entry.TaskItemID] = struct{}{} - rank := originOrder[entry.TaskItemID] - if rank <= 0 { - rank = 1 << 30 - } - list = append(list, candidate{ - TaskID: entry.TaskItemID, - Week: entry.Week, - Day: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - Rank: rank, - }) - } - sort.SliceStable(list, func(i, j int) bool { - if list[i].Rank != list[j].Rank { - return list[i].Rank < list[j].Rank - } - if list[i].Week != list[j].Week { - return list[i].Week < list[j].Week - } - if list[i].Day != list[j].Day { - return list[i].Day < list[j].Day - } - if list[i].SectionFrom != list[j].SectionFrom { - return list[i].SectionFrom < list[j].SectionFrom - } - return list[i].TaskID < list[j].TaskID - }) - ids := make([]int, 0, len(list)) - for _, item := range list { - ids = append(ids, item.TaskID) - } - return ids -} - -func findSuggestedEntryByTaskID(entries []model.HybridScheduleEntry, taskID int) (model.HybridScheduleEntry, bool) { - for _, entry := range entries { - if isMovableSuggestedTask(entry) && entry.TaskItemID == taskID { - return entry, true - } - } - return model.HybridScheduleEntry{}, false -} - -// isCurrentTaskSatisfiedBySlice 判断“当前任务”是否已满足本轮切片目标。 -// -// 步骤化说明: -// 1. 该判断只用于“当前任务自动收口”,不替代全局 hard_check; -// 2. 若切片包含 source_days,则任务离开 source_days 视为关键进展; -// 3. 若切片包含 target_days / exclude_sections / week_filter,则需同时满足; -// 4. 若切片没有任何约束,返回 false,避免误判导致提前结束。 -func isCurrentTaskSatisfiedBySlice(entry model.HybridScheduleEntry, slice RefineSlicePlan) bool { - if !isMovableSuggestedTask(entry) { - return false - } - weekSet := intSliceToWeekSet(slice.WeekFilter) - sourceWeekSet := inferSourceWeekSet(slice) - sourceSet := intSliceToDaySet(slice.SourceDays) - targetSet := intSliceToDaySet(slice.TargetDays) - excludedSet := intSliceToSectionSet(slice.ExcludeSections) - - hasConstraint := len(sourceWeekSet) > 0 || len(weekSet) > 0 || len(sourceSet) > 0 || len(targetSet) > 0 || len(excludedSet) > 0 - if !hasConstraint { - return false - } - if len(sourceWeekSet) > 0 { - if _, stillInSourceWeek := sourceWeekSet[entry.Week]; stillInSourceWeek { - return false - } - } - if len(weekSet) > 0 { - if _, ok := weekSet[entry.Week]; !ok { - return false - } - } - if len(sourceSet) > 0 { - if _, stillInSource := sourceSet[entry.DayOfWeek]; stillInSource { - return false - } - } - if len(targetSet) > 0 { - if _, ok := targetSet[entry.DayOfWeek]; !ok { - return false - } - } - if len(excludedSet) > 0 && intersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSet) { - return false - } - return true -} - -func taskProgressLabel(done bool, attemptUsed int, perTaskBudget int) string { - if done { - return "done" - } - if perTaskBudget > 0 && attemptUsed >= perTaskBudget { - return "budget_exhausted" - } - return "paused" -} - -func buildMicroReactUserPrompt(st *ScheduleRefineState, current model.HybridScheduleEntry, remainingAction int, remainingTotal int) string { - ensureCompositeStateMaps(st) - contractJSON, _ := json.Marshal(st.Contract) - planJSON, _ := json.Marshal(st.CurrentPlan) - sliceJSON, _ := json.Marshal(st.SlicePlan) - objectiveJSON, _ := json.Marshal(st.Objective) - currentJSON, _ := json.Marshal(current) - sourceWeeks := keysOfIntSet(inferSourceWeekSet(st.SlicePlan)) - requiredComposite := normalizeCompositeToolName(st.RequiredCompositeTool) - requiredSuccess := isRequiredCompositeSatisfied(st) - compositeToolsAllowed := !st.DisableCompositeTools - compositeCalledJSON, _ := json.Marshal(st.CompositeToolCalled) - compositeSuccessJSON, _ := json.Marshal(st.CompositeToolSuccess) - envSlotHint := buildEnvSlotHint(st, current) - userPrompt := fmt.Sprintf( - "用户本轮请求=%s\n契约=%s\n执行计划=%s\n切片=%s\n目标约束=%s\nCURRENT_TASK=%s\nSOURCE_WEEK_FILTER=%v\nBACKEND_GUARD=本轮只允许改写 task_item_id=%d;若该任务已满足切片目标或目标约束已整体达成且复合工具门禁通过,请直接 done=true;下一任务由后端自动切换。\nREQUIRED_COMPOSITE_TOOL=%s\nCOMPOSITE_TOOLS_ALLOWED=%t\nCOMPOSITE_REQUIRED_SUCCESS=%t\nCOMPOSITE_CALLED=%s\nCOMPOSITE_SUCCESS=%s\nCURRENT_TASK_ACTION_USED=%d\nPER_TASK_BUDGET=%d\n动作预算剩余=%d\n总预算剩余=%d\nENV_SLOT_HINT=%s\nLAST_TOOL_OBSERVATION=%s\nLAST_FAILED_CALL_SIGNATURE=%s\n最近观察=%s\n同周suggested摘要=%s", - strings.TrimSpace(st.UserMessage), - string(contractJSON), - string(planJSON), - string(sliceJSON), - string(objectiveJSON), - string(currentJSON), - sourceWeeks, - current.TaskItemID, - fallbackText(requiredComposite, "无"), - compositeToolsAllowed, - requiredSuccess, - string(compositeCalledJSON), - string(compositeSuccessJSON), - st.TaskActionUsed[current.TaskItemID], - st.PerTaskBudget, - remainingAction, - remainingTotal, - envSlotHint, - buildLastToolObservationPrompt(st.ObservationHistory), - fallbackText(st.LastFailedCallSignature, "无"), - buildObservationPrompt(st.ObservationHistory, 2), - buildSuggestedDigestByWeek(st.HybridEntries, current.Week, 24), - ) - return withNearestJSONContract(userPrompt, jsonContractForReact) -} - -type slotHintPayload struct { - Count int `json:"count"` - StrictCount int `json:"strict_count"` - EmbeddedCount int `json:"embedded_count"` - Slots []struct { - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - } `json:"slots"` -} - -func buildEnvSlotHint(st *ScheduleRefineState, current model.HybridScheduleEntry) string { - if st == nil || !isMovableSuggestedTask(current) { - return "无可用提示" - } - span := current.SectionTo - current.SectionFrom + 1 - if span <= 0 { - span = 2 - } - targetWeeks := append([]int(nil), st.Objective.TargetWeeks...) - if len(targetWeeks) == 0 { - targetWeeks = keysOfIntSet(inferTargetWeekSet(st.SlicePlan)) - } - if len(targetWeeks) == 0 && current.Week > 0 { - targetWeeks = []int{current.Week} - } - targetDays := append([]int(nil), st.Objective.TargetDays...) - if len(targetDays) == 0 { - targetDays = append([]int(nil), st.SlicePlan.TargetDays...) - } - params := map[string]any{ - "week_filter": targetWeeks, - "day_filter": targetDays, - "section_duration": span, - "limit": 8, - "slot_type": "pure", - "exclude_sections": st.SlicePlan.ExcludeSections, - } - _, pureResult := refineToolQueryAvailableSlots(st.HybridEntries, params, buildPlanningWindowFromEntries(st.HybridEntries)) - if !pureResult.Success { - return fmt.Sprintf("pure_slot_query_failed=%s", truncate(pureResult.Result, 100)) - } - purePayload, ok := decodeSlotHintPayload(pureResult.Result) - if !ok { - return "pure_slot_parse_failed" - } - - embedParams := map[string]any{ - "week_filter": targetWeeks, - "day_filter": targetDays, - "section_duration": span, - "limit": 8, - "exclude_sections": st.SlicePlan.ExcludeSections, - } - _, fallbackResult := refineToolQueryAvailableSlots(st.HybridEntries, embedParams, buildPlanningWindowFromEntries(st.HybridEntries)) - if !fallbackResult.Success { - return fmt.Sprintf("pure=%d fallback_query_failed=%s", purePayload.Count, truncate(fallbackResult.Result, 100)) - } - fallbackPayload, ok := decodeSlotHintPayload(fallbackResult.Result) - if !ok { - return fmt.Sprintf("pure=%d fallback_parse_failed", purePayload.Count) - } - - top := fallbackPayload.Slots - if len(top) > 3 { - top = top[:3] - } - slotText := make([]string, 0, len(top)) - for _, item := range top { - slotText = append(slotText, fmt.Sprintf("W%dD%d %d-%d", item.Week, item.DayOfWeek, item.SectionFrom, item.SectionTo)) - } - if len(slotText) == 0 { - slotText = append(slotText, "无") - } - return fmt.Sprintf("target_weeks=%v target_days=%v pure=%d embed_candidate=%d top=%s", targetWeeks, targetDays, purePayload.Count, fallbackPayload.EmbeddedCount, strings.Join(slotText, ",")) -} - -func decodeSlotHintPayload(raw string) (slotHintPayload, bool) { - var payload slotHintPayload - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return slotHintPayload{}, false - } - return payload, true -} - -func extractWeekFilters(text string) []int { - patterns := []string{ - `第\s*(\d{1,2})\s*周`, - `W\s*(\d{1,2})`, - `(\d{1,2})\s*周`, - } - out := make([]int, 0, 8) - for _, p := range patterns { - re := regexp.MustCompile(p) - for _, m := range re.FindAllStringSubmatch(text, -1) { - if len(m) < 2 { - continue - } - v, err := strconv.Atoi(strings.TrimSpace(m[1])) - if err != nil || v <= 0 { - continue - } - out = append(out, v) - } - } - return uniquePositiveInts(out) -} - -func extractExcludeSections(text string) []int { - normalized := strings.ReplaceAll(strings.ToLower(text), " ", "") - if containsAny(normalized, []string{ - "不要早八", "避开早八", "不想早八", "别在早八", - "不要1-2", "避开1-2", "不要第一节", "不要一二节", - }) { - return []int{1, 2} - } - return nil -} - -func extractTargetDaysFromMessage(text string) []int { - verbIdx := -1 - for _, key := range []string{"移到", "挪到", "改到", "安排到", "放到", "分散到", "调整到", "收敛到", "迁移到"} { - if idx := strings.Index(text, key); idx >= 0 { - verbIdx = idx + len(key) - break - } - } - if verbIdx < 0 || verbIdx >= len(text) { - return nil - } - targetPart := strings.TrimSpace(text[verbIdx:]) - return extractDayExpr(targetPart) -} - -func extractSourceDaysFromMessage(text string) []int { - source := make([]int, 0, 4) - re := regexp.MustCompile(`从\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) - for _, m := range re.FindAllStringSubmatch(text, -1) { - if len(m) < 2 { - continue - } - if day := dayTokenToInt(m[1]); day > 0 { - source = append(source, day) - } - } - re2 := regexp.MustCompile(`把\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) - for _, m := range re2.FindAllStringSubmatch(text, -1) { - if len(m) < 2 { - continue - } - if day := dayTokenToInt(m[1]); day > 0 { - source = append(source, day) - } - } - return uniquePositiveInts(source) -} - -func detectOverloadedDay(text string) int { - re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天]).{0,8}(太多|过多|太满|过满|拥挤|太挤|塞满)`) - m := re.FindStringSubmatch(text) - if len(m) < 2 { - return 0 - } - return dayTokenToInt(m[1]) -} - -func extractRangeDays(text string) []int { - re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天])\s*[到至\-]\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) - m := re.FindStringSubmatch(text) - if len(m) < 3 { - return nil - } - start := dayTokenToInt(m[1]) - end := dayTokenToInt(m[2]) - if start <= 0 || end <= 0 { - return nil - } - if start > end { - start, end = end, start - } - out := make([]int, 0, end-start+1) - for day := start; day <= end; day++ { - out = append(out, day) - } - return out -} - -func extractDays(text string) []int { - re := regexp.MustCompile(`周[一二三四五六日天]|星期[一二三四五六日天]`) - matches := re.FindAllString(text, -1) - days := make([]int, 0, len(matches)) - for _, token := range matches { - if day := dayTokenToInt(token); day > 0 { - days = append(days, day) - } - } - return uniquePositiveInts(days) -} - -func dayTokenToInt(token string) int { - switch strings.TrimSpace(token) { - case "周一", "星期一": - return 1 - case "周二", "星期二": - return 2 - case "周三", "星期三": - return 3 - case "周四", "星期四": - return 4 - case "周五", "星期五": - return 5 - case "周六", "星期六": - return 6 - case "周日", "周天", "星期日", "星期天": - return 7 - default: - return 0 - } -} - -func containsAny(text string, keys []string) bool { - for _, k := range keys { - if strings.Contains(text, k) { - return true - } - } - return false -} - -func isSameDays(days []int, target []int) bool { - if len(days) != len(target) { - return false - } - for i := range days { - if days[i] != target[i] { - return false - } - } - return true -} diff --git a/backend/agent2/node/schedule_refine_impl/prompt.go b/backend/agent2/node/schedule_refine_impl/prompt.go deleted file mode 100644 index e0be09d..0000000 --- a/backend/agent2/node/schedule_refine_impl/prompt.go +++ /dev/null @@ -1,188 +0,0 @@ -package schedulerefine - -const ( - // contractPrompt 负责把用户自然语言微调请求抽取为结构化契约。 - contractPrompt = `你是 SmartFlow 的排程微调契约分析器。 -你会收到:当前时间、用户请求、已有排程摘要。 -请只输出 JSON,不要 Markdown,不要解释,不要代码块: -{ - "intent": "一句话概括本轮微调目标", - "strategy": "local_adjust|keep", - "hard_requirements": ["必须满足的硬性要求1","必须满足的硬性要求2"], - "hard_assertions": [ - { - "metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count", - "operator": "==|<=|>=|between", - "value": 50, - "min": 50, - "max": 50, - "week": 17, - "target_week": 16 - } - ], - "keep_relative_order": true, - "order_scope": "global|week" -} - -规则: -1. 除非用户明确表达“允许打乱顺序/顺序无所谓”,keep_relative_order 默认 true。 -2. 仅当用户明确放宽顺序时,keep_relative_order 才允许为 false;order_scope 默认 "global"。 -3. 只要涉及移动任务,strategy 必须是 local_adjust;仅在无需改动时才用 keep。 -4. hard_requirements 必须可验证,避免空泛描述。 -5. hard_assertions 必须尽量结构化,避免只给自然语言目标。` - - // plannerPrompt 只负责生成“执行路径”,不直接执行动作。 - plannerPrompt = `你是 SmartFlow 的排程微调 Planner。 -你会收到:用户请求、契约、最近动作观察。 -请只输出 JSON,不要 Markdown,不要解释,不要代码块: -{ - "summary": "本阶段执行策略一句话", - "steps": ["步骤1","步骤2","步骤3"] -} - -规则: -1. steps 保持 3~4 条,优先“先取证再动作”。 -2. summary <= 36 字,单步 <= 28 字。 -3. 若目标是“均匀分散”,steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。 -4. 若目标是“上下文切换最少/同科目连续”,steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。 -5. 不要输出半截 JSON。` - - // reactPrompt 用于“单任务微步 ReAct”执行器。 - reactPrompt = `你是 SmartFlow 的单任务微步 ReAct 执行器。 -当前只处理一个任务(CURRENT_TASK),不能发散到其它任务的主动改动。 -你每轮只能做两件事之一: -1) 调用一个工具(基础工具或复合工具) -2) 输出 done=true 结束当前任务 - -工具分组: -- 基础工具:QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify -- 复合工具:SpreadEven / MinContextSwitch - -工具说明(按职责): -1. QueryTargetTasks:查询候选任务集合(只读)。 - 常用参数:week/week_filter/day_of_week/task_item_ids/status。 - 适用:先摸清“有哪些任务可动、当前在哪”。 -2. QueryAvailableSlots:查询可放置坑位(只读,默认先纯空位,必要时补可嵌入位)。 - 常用参数:week/week_filter/day_of_week/span/limit/allow_embed/exclude_sections。 - 适用:Move 前先拿可落点清单。 -3. Move:移动单个任务到目标坑位(写操作)。 - 必要参数:task_item_id,to_week,to_day,to_section_from,to_section_to。 - 适用:单任务精确挪动。 -4. Swap:交换两个任务坑位(写操作)。 - 必要参数:task_a,task_b。 - 适用:两个任务互换位置比单独 Move 更稳时。 -5. BatchMove:批量原子移动(写操作)。 - 必要参数:{"moves":[{Move参数...},{Move参数...}]}。 - 适用:一轮要改多个任务且要求“要么全成要么全回滚”。 -6. Verify:执行确定性校验(只读)。 - 常用参数:可空;也可传 task_item_id + 目标坐标做定点核验。 - 适用:收尾前快速自检是否符合确定性约束。 -7. SpreadEven(复合):按“均匀铺开”目标一次规划并执行多任务移动(写操作)。 - 必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。 - 可选参数:week/week_filter/day_of_week/allow_embed/limit。 - 适用:目标是“把任务在时间上分散开,避免扎堆”。 -8. MinContextSwitch(复合):按“最少上下文切换”一次规划并执行多任务移动(写操作)。 - 必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。 - 可选参数:week/week_filter/day_of_week/allow_embed/limit。 - 适用:目标是“同科目/同认知标签尽量连续,减少切换成本”。 - -请严格输出 JSON,不要 Markdown,不要解释: -{ - "done": false, - "summary": "", - "goal_check": "本轮先检查什么", - "decision": "本轮为何这么做", - "missing_info": ["缺口信息1","缺口信息2"], - "tool_calls": [ - { - "tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify", - "params": {} - } - ] -} - -硬规则: -1. 每轮最多 1 个 tool_call。 -2. done=true 时,tool_calls 必须为空数组。 -3. done=false 时,tool_calls 必须恰好 1 条。 -4. 只能修改 status="suggested" 的任务,禁止修改 existing。 -5. 不要把“顺序约束”当作执行期阻塞条件;你只需把坑位分布排好,顺序由后端统一收口。 -6. 若上轮失败,必须依据 LAST_TOOL_OBSERVATION.error_code 调整策略,不能重复上轮失败动作。 -7. Move 参数优先使用:task_item_id,to_week,to_day,to_section_from,to_section_to。 -8. BatchMove 参数格式必须是:{"moves":[{...},{...}]};任一步失败会整批回滚。 -9. day_of_week 映射固定:1周一,2周二,3周三,4周四,5周五,6周六,7周日。 -10. 优先使用“纯空位”;仅在空位不足时再考虑可嵌入课程位(第二优先级)。 -11. 如果 SOURCE_WEEK_FILTER 非空,只允许改写这些来源周里的任务,禁止主动改写其它周任务。 -12. CURRENT_TASK 是本轮唯一可改写任务;如果它已满足目标,立刻 done=true,不要提前处理下一个任务。 -13. 禁止发明工具名(如 GetCurrentTask、AdjustTaskTime),只能用白名单工具。 -14. 优先使用后端注入的 ENV_SLOT_HINT 进行落点决策,非必要不要重复 QueryAvailableSlots。 -15. 若 REQUIRED_COMPOSITE_TOOL 非空且 COMPOSITE_REQUIRED_SUCCESS=false,本轮必须优先调用 REQUIRED_COMPOSITE_TOOL,禁止先调用 Move/Swap/BatchMove。 -16. 若使用 SpreadEven/MinContextSwitch,必须在参数中提供 task_item_ids(且包含 CURRENT_TASK.task_item_id)。 -17. 若 COMPOSITE_TOOLS_ALLOWED=false,禁止调用 SpreadEven/MinContextSwitch,只能使用基础工具逐步处理。 -18. 为保证解析稳定:goal_check<=50字,decision<=90字,summary<=60字。` - - // postReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。 - postReflectPrompt = `你是 SmartFlow 的 ReAct 复盘器。 -你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。 -请只输出 JSON,不要 Markdown,不要解释: -{ - "reflection": "基于真实结果的复盘", - "next_strategy": "下一轮建议动作", - "should_stop": false -} - -规则: -1. 若 tool_success=false,reflection 必须明确失败原因(优先引用 error_code)。 -2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTION,next_strategy 必须给出规避方法。 -3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。` - - // reviewPrompt 用于终审语义校验。 - reviewPrompt = `你是 SmartFlow 的终审校验器。 -请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。 -只输出 JSON: -{ - "pass": true, - "reason": "中文简短结论", - "unmet": [] -} - -规则: -1. pass=true 时 unmet 必须为空数组。 -2. pass=false 时 reason 必须给出核心差距。` - - // summaryPrompt 用于最终面向用户的自然语言总结。 - summaryPrompt = `你是 SmartFlow 的排程结果解读助手。 -请基于输入输出 2~4 句中文总结: -1) 先说明本轮改了什么; -2) 再说明改动收益; -3) 若终审未完全通过,明确还差什么。 -不要输出 JSON。` - - // repairPrompt 用于终审失败后的单次修复动作。 - repairPrompt = `你是 SmartFlow 的修复执行器。 -当前方案未通过终审,请根据“未满足点”只做一次修复动作。 -只允许输出一个 tool_call(Move 或 Swap),不允许 done。 - -输出格式(严格 JSON): -{ - "done": false, - "summary": "", - "goal_check": "本轮修复目标", - "decision": "修复决策依据", - "missing_info": [], - "tool_calls": [ - { - "tool": "Move|Swap", - "params": {} - } - ] -} - -Move 参数必须使用标准键: -- task_item_id -- to_week -- to_day -- to_section_from -- to_section_to -禁止使用 new_week/new_day/section_from 等别名。` -) diff --git a/backend/agent2/node/schedule_refine_impl/refine_filters_test.go b/backend/agent2/node/schedule_refine_impl/refine_filters_test.go deleted file mode 100644 index 1f63867..0000000 --- a/backend/agent2/node/schedule_refine_impl/refine_filters_test.go +++ /dev/null @@ -1,637 +0,0 @@ -package schedulerefine - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/LoveLosita/smartflow/backend/model" -) - -func TestQueryTargetTasksWeekFilterAndTaskID(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 1, Name: "task-w12", Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"}, - {TaskItemID: 2, Name: "task-w13", Week: 13, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"}, - {TaskItemID: 3, Name: "task-w14", Week: 14, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"}, - } - policy := refineToolPolicy{OriginOrderMap: map[int]int{1: 1, 2: 2, 3: 3}} - - paramsWeek := map[string]any{ - "week_filter": []any{13.0, 14.0}, - } - _, resultWeek := refineToolQueryTargetTasks(entries, paramsWeek, policy) - if !resultWeek.Success { - t.Fatalf("week_filter 查询失败: %s", resultWeek.Result) - } - var payloadWeek struct { - Count int `json:"count"` - Items []struct { - TaskItemID int `json:"task_item_id"` - Week int `json:"week"` - } `json:"items"` - } - if err := json.Unmarshal([]byte(resultWeek.Result), &payloadWeek); err != nil { - t.Fatalf("解析 week_filter 结果失败: %v", err) - } - if payloadWeek.Count != 2 { - t.Fatalf("week_filter 期望返回 2 条,实际=%d", payloadWeek.Count) - } - for _, item := range payloadWeek.Items { - if item.Week != 13 && item.Week != 14 { - t.Fatalf("week_filter 过滤失败,出现非法周次=%d", item.Week) - } - } - - paramsTaskID := map[string]any{ - "week_filter": []any{13.0, 14.0}, - "task_item_id": 2, - } - _, resultTaskID := refineToolQueryTargetTasks(entries, paramsTaskID, policy) - if !resultTaskID.Success { - t.Fatalf("task_item_id 查询失败: %s", resultTaskID.Result) - } - var payloadTaskID struct { - Count int `json:"count"` - Items []struct { - TaskItemID int `json:"task_item_id"` - Week int `json:"week"` - } `json:"items"` - } - if err := json.Unmarshal([]byte(resultTaskID.Result), &payloadTaskID); err != nil { - t.Fatalf("解析 task_item_id 结果失败: %v", err) - } - if payloadTaskID.Count != 1 { - t.Fatalf("task_item_id 期望返回 1 条,实际=%d", payloadTaskID.Count) - } - if payloadTaskID.Items[0].TaskItemID != 2 || payloadTaskID.Items[0].Week != 13 { - t.Fatalf("task_item_id 过滤错误: %+v", payloadTaskID.Items[0]) - } -} - -func TestQueryAvailableSlotsExactSectionAlias(t *testing.T) { - params := map[string]any{ - "week": 13, - "section_duration": 2, - "section_from": 1, - "section_to": 2, - "limit": 5, - } - _, result := refineToolQueryAvailableSlots(nil, params, planningWindow{Enabled: false}) - if !result.Success { - t.Fatalf("QueryAvailableSlots 失败: %s", result.Result) - } - var payload struct { - Count int `json:"count"` - Slots []struct { - Week int `json:"week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - } `json:"slots"` - } - if err := json.Unmarshal([]byte(result.Result), &payload); err != nil { - t.Fatalf("解析 QueryAvailableSlots 结果失败: %v", err) - } - if payload.Count == 0 { - t.Fatalf("期望至少返回一个可用时段,实际=0") - } - for _, slot := range payload.Slots { - if slot.Week != 13 { - t.Fatalf("返回了错误周次: %+v", slot) - } - if slot.SectionFrom != 1 || slot.SectionTo != 2 { - t.Fatalf("精确节次过滤失败: %+v", slot) - } - } -} - -func TestQueryAvailableSlotsWeekFilterDayFilterAlias(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 1, Name: "task-w12", Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"}, - {TaskItemID: 2, Name: "task-w17", Week: 17, DayOfWeek: 4, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"}, - } - params := map[string]any{ - "week_filter": []any{17.0}, - "day_filter": []any{1.0, 2.0, 3.0}, - "limit": 20, - } - - _, result := refineToolQueryAvailableSlots(entries, params, planningWindow{Enabled: false}) - if !result.Success { - t.Fatalf("QueryAvailableSlots 别名查询失败: %s", result.Result) - } - var payload struct { - Count int `json:"count"` - Slots []struct { - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - } `json:"slots"` - } - if err := json.Unmarshal([]byte(result.Result), &payload); err != nil { - t.Fatalf("解析 week/day 过滤结果失败: %v", err) - } - if payload.Count == 0 { - t.Fatalf("week_filter/day_filter 查询应返回 W17 周一到周三空位,实际为空") - } - for _, slot := range payload.Slots { - if slot.Week != 17 { - t.Fatalf("week_filter 失效,出现 week=%d", slot.Week) - } - if slot.DayOfWeek < 1 || slot.DayOfWeek > 3 { - t.Fatalf("day_filter 失效,出现 day_of_week=%d", slot.DayOfWeek) - } - } -} - -func TestCollectWorksetTaskIDsSourceWeekOnly(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 1, Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"}, - {TaskItemID: 2, Week: 14, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"}, - {TaskItemID: 3, Week: 13, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"}, - {TaskItemID: 4, Week: 14, DayOfWeek: 2, SectionFrom: 7, SectionTo: 8, Status: "suggested", Type: "task"}, - } - slice := RefineSlicePlan{WeekFilter: []int{14, 13}} - originOrder := map[int]int{1: 1, 2: 2, 3: 3, 4: 4} - - got := collectWorksetTaskIDs(entries, slice, originOrder) - if len(got) != 2 { - t.Fatalf("来源周收敛失败,期望 2 条,实际=%d, got=%v", len(got), got) - } - if got[0] != 2 || got[1] != 4 { - t.Fatalf("来源周结果错误,期望 [2 4],实际=%v", got) - } -} - -func TestBuildSlicePlanDirectionalSourceTarget(t *testing.T) { - st := &ScheduleRefineState{ - UserMessage: "帮我把第17周周四到周五的任务都收敛到17周的周一到周三,优先放空位,空位不够了再嵌入", - } - plan := buildSlicePlan(st) - if len(plan.WeekFilter) == 0 || plan.WeekFilter[0] != 17 { - t.Fatalf("week_filter 解析错误: %+v", plan.WeekFilter) - } - expectSource := []int{4, 5} - expectTarget := []int{1, 2, 3} - if len(plan.SourceDays) != len(expectSource) { - t.Fatalf("source_days 长度错误: got=%v", plan.SourceDays) - } - for i := range expectSource { - if plan.SourceDays[i] != expectSource[i] { - t.Fatalf("source_days 错误: got=%v", plan.SourceDays) - } - } - if len(plan.TargetDays) != len(expectTarget) { - t.Fatalf("target_days 长度错误: got=%v", plan.TargetDays) - } - for i := range expectTarget { - if plan.TargetDays[i] != expectTarget[i] { - t.Fatalf("target_days 错误: got=%v", plan.TargetDays) - } - } -} - -func TestVerifyTaskCoordinateMismatch(t *testing.T) { - entries := []model.HybridScheduleEntry{ - {TaskItemID: 28, Name: "task-w17-d4", Week: 17, DayOfWeek: 4, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"}, - } - policy := refineToolPolicy{OriginOrderMap: map[int]int{28: 1}} - params := map[string]any{ - "task_item_id": 28, - "week": 17, - "day_of_week": 1, - "section_from": 1, - "section_to": 2, - } - - _, result := refineToolVerify(entries, params, policy) - if result.Success { - t.Fatalf("期望 Verify 在任务坐标不匹配时失败,实际 success=true, result=%s", result.Result) - } - if result.ErrorCode != "VERIFY_FAILED" { - t.Fatalf("期望错误码 VERIFY_FAILED,实际=%s", result.ErrorCode) - } - if !strings.Contains(result.Result, "不匹配") { - t.Fatalf("期望结果包含“不匹配”提示,实际=%s", result.Result) - } -} - -func TestMoveRejectsSuggestedCourseEntry(t *testing.T) { - entries := []model.HybridScheduleEntry{ - { - TaskItemID: 39, - Name: "面向对象程序设计-C++", - Type: "course", - Status: "suggested", - Week: 17, - DayOfWeek: 4, - SectionFrom: 7, - SectionTo: 8, - }, - } - params := map[string]any{ - "task_item_id": 39, - "to_week": 17, - "to_day": 1, - "to_section_from": 7, - "to_section_to": 8, - } - _, result := refineToolMove(entries, params, planningWindow{Enabled: false}, refineToolPolicy{OriginOrderMap: map[int]int{39: 1}}) - if result.Success { - t.Fatalf("期望 course 类型的 suggested 条目不可移动,实际 success=true, result=%s", result.Result) - } - if !strings.Contains(result.Result, "可移动 suggested 任务") { - t.Fatalf("期望返回不可移动提示,实际=%s", result.Result) - } -} - -func TestQueryAvailableSlotsSlotTypePureDisablesEmbed(t *testing.T) { - entries := []model.HybridScheduleEntry{ - { - Name: "可嵌入课程", - Type: "course", - Status: "existing", - Week: 17, - DayOfWeek: 1, - SectionFrom: 1, - SectionTo: 2, - BlockForSuggested: false, - }, - } - - pureParams := map[string]any{ - "week": 17, - "day_of_week": 1, - "section_from": 1, - "section_to": 2, - "slot_type": "pure", - } - _, pureResult := refineToolQueryAvailableSlots(entries, pureParams, planningWindow{Enabled: false}) - if !pureResult.Success { - t.Fatalf("pure 查询失败: %s", pureResult.Result) - } - var purePayload struct { - Count int `json:"count"` - EmbeddedCount int `json:"embedded_count"` - FallbackUsed bool `json:"fallback_used"` - } - if err := json.Unmarshal([]byte(pureResult.Result), &purePayload); err != nil { - t.Fatalf("解析 pure 查询结果失败: %v", err) - } - if purePayload.Count != 0 || purePayload.EmbeddedCount != 0 || purePayload.FallbackUsed { - t.Fatalf("slot_type=pure 应禁用嵌入兜底,实际 payload=%+v", purePayload) - } - - defaultParams := map[string]any{ - "week": 17, - "day_of_week": 1, - "section_from": 1, - "section_to": 2, - } - _, defaultResult := refineToolQueryAvailableSlots(entries, defaultParams, planningWindow{Enabled: false}) - if !defaultResult.Success { - t.Fatalf("default 查询失败: %s", defaultResult.Result) - } - var defaultPayload struct { - Count int `json:"count"` - EmbeddedCount int `json:"embedded_count"` - FallbackUsed bool `json:"fallback_used"` - } - if err := json.Unmarshal([]byte(defaultResult.Result), &defaultPayload); err != nil { - t.Fatalf("解析 default 查询结果失败: %v", err) - } - if defaultPayload.Count == 0 || defaultPayload.EmbeddedCount == 0 || !defaultPayload.FallbackUsed { - t.Fatalf("默认查询应允许嵌入候选,实际 payload=%+v", defaultPayload) - } -} - -func TestCompileObjectiveAndEvaluateMoveAllPass(t *testing.T) { - initial := []model.HybridScheduleEntry{ - {TaskItemID: 39, Name: "任务39", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 4, SectionFrom: 7, SectionTo: 8}, - {TaskItemID: 51, Name: "任务51", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 9, SectionTo: 10}, - } - final := []model.HybridScheduleEntry{ - {TaskItemID: 39, Name: "任务39", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 7, SectionTo: 8}, - {TaskItemID: 51, Name: "任务51", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 2, SectionFrom: 9, SectionTo: 10}, - } - st := &ScheduleRefineState{ - UserMessage: "把17周周四到周五任务收敛到周一到周三", - InitialHybridEntries: initial, - HybridEntries: final, - SlicePlan: RefineSlicePlan{ - WeekFilter: []int{17}, - SourceDays: []int{4, 5}, - TargetDays: []int{1, 2, 3}, - }, - } - st.Objective = compileRefineObjective(st, st.SlicePlan) - if st.Objective.Mode != "move_all" { - t.Fatalf("期望目标模式 move_all,实际=%s", st.Objective.Mode) - } - - pass, _, unmet, applied := evaluateObjectiveDeterministic(st) - if !applied { - t.Fatalf("期望命中确定性终审") - } - if !pass { - t.Fatalf("期望确定性终审通过,unmet=%v", unmet) - } -} - -func TestCompileObjectiveAndEvaluateMoveAllFail(t *testing.T) { - initial := []model.HybridScheduleEntry{ - {TaskItemID: 26, Name: "任务26", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 7, SectionTo: 8}, - } - final := []model.HybridScheduleEntry{ - {TaskItemID: 26, Name: "任务26", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 7, SectionTo: 8}, - } - st := &ScheduleRefineState{ - UserMessage: "把17周周四到周五任务收敛到周一到周三", - InitialHybridEntries: initial, - HybridEntries: final, - SlicePlan: RefineSlicePlan{ - WeekFilter: []int{17}, - SourceDays: []int{4, 5}, - TargetDays: []int{1, 2, 3}, - }, - } - st.Objective = compileRefineObjective(st, st.SlicePlan) - - pass, _, unmet, applied := evaluateObjectiveDeterministic(st) - if !applied { - t.Fatalf("期望命中确定性终审") - } - if pass { - t.Fatalf("期望确定性终审失败") - } - if len(unmet) == 0 { - t.Fatalf("期望返回未满足项") - } -} - -func TestCompileObjectiveMoveRatioFromContractAndEvaluatePass(t *testing.T) { - initial, final := buildHalfTransferEntries(10, 5) - st := &ScheduleRefineState{ - UserMessage: "17周任务太多,帮我调整到16周", - InitialHybridEntries: initial, - HybridEntries: final, - SlicePlan: RefineSlicePlan{ - WeekFilter: []int{17, 16}, - }, - Contract: RefineContract{ - Intent: "将第17周任务匀一半到第16周", - HardRequirements: []string{"原第17周任务数调整为原来的一半", "调整到第16周的任务数为原第17周任务数的一半"}, - }, - } - st.Objective = compileRefineObjective(st, st.SlicePlan) - if st.Objective.Mode != "move_ratio" { - t.Fatalf("期望目标模式 move_ratio,实际=%s", st.Objective.Mode) - } - if st.Objective.RequiredMoveMin != 5 || st.Objective.RequiredMoveMax != 5 { - t.Fatalf("半数迁移阈值错误: min=%d max=%d", st.Objective.RequiredMoveMin, st.Objective.RequiredMoveMax) - } - - pass, _, unmet, applied := evaluateObjectiveDeterministic(st) - if !applied { - t.Fatalf("期望命中确定性终审") - } - if !pass { - t.Fatalf("期望半数迁移通过,unmet=%v", unmet) - } -} - -func TestCompileObjectiveMoveRatioFromContractAndEvaluateFail(t *testing.T) { - initial, final := buildHalfTransferEntries(10, 4) - st := &ScheduleRefineState{ - UserMessage: "17周任务太多,帮我调整到16周", - InitialHybridEntries: initial, - HybridEntries: final, - SlicePlan: RefineSlicePlan{ - WeekFilter: []int{17, 16}, - }, - Contract: RefineContract{ - Intent: "将第17周任务匀一半到第16周", - HardRequirements: []string{"原第17周任务数调整为原来的一半", "调整到第16周的任务数为原第17周任务数的一半"}, - }, - } - st.Objective = compileRefineObjective(st, st.SlicePlan) - - pass, _, unmet, applied := evaluateObjectiveDeterministic(st) - if !applied { - t.Fatalf("期望命中确定性终审") - } - if pass { - t.Fatalf("期望半数迁移失败") - } - if len(unmet) == 0 { - t.Fatalf("期望返回未满足项") - } -} - -func TestCompileObjectiveMoveRatioFromStructuredAssertion(t *testing.T) { - initial, final := buildHalfTransferEntries(10, 5) - st := &ScheduleRefineState{ - UserMessage: "请把任务重新分配", - InitialHybridEntries: initial, - HybridEntries: final, - SlicePlan: RefineSlicePlan{ - WeekFilter: []int{17, 16}, - }, - Contract: RefineContract{ - Intent: "任务重新分配", - HardAssertions: []RefineAssertion{ - { - Metric: "source_move_ratio_percent", - Operator: "==", - Value: 50, - Week: 17, - TargetWeek: 16, - }, - }, - }, - } - st.Objective = compileRefineObjective(st, st.SlicePlan) - if st.Objective.Mode != "move_ratio" { - t.Fatalf("结构化断言未生效,期望 move_ratio,实际=%s", st.Objective.Mode) - } -} - -func buildHalfTransferEntries(total int, moved int) ([]model.HybridScheduleEntry, []model.HybridScheduleEntry) { - initial := make([]model.HybridScheduleEntry, 0, total) - final := make([]model.HybridScheduleEntry, 0, total) - for i := 1; i <= total; i++ { - initial = append(initial, model.HybridScheduleEntry{ - TaskItemID: i, - Name: "task", - Type: "task", - Status: "suggested", - Week: 17, - DayOfWeek: 1, - SectionFrom: 1, - SectionTo: 2, - }) - week := 17 - if i <= moved { - week = 16 - } - final = append(final, model.HybridScheduleEntry{ - TaskItemID: i, - Name: "task", - Type: "task", - Status: "suggested", - Week: week, - DayOfWeek: 1, - SectionFrom: 1, - SectionTo: 2, - }) - } - return initial, final -} - -func TestNormalizeMovableTaskOrderByOrigin(t *testing.T) { - st := &ScheduleRefineState{ - OriginOrderMap: map[int]int{ - 101: 1, - 202: 2, - }, - HybridEntries: []model.HybridScheduleEntry{ - {TaskItemID: 202, Name: "task-202", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, - {TaskItemID: 101, Name: "task-101", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2}, - }, - } - changed := normalizeMovableTaskOrderByOrigin(st) - if !changed { - t.Fatalf("期望发生顺序归位") - } - sortHybridEntries(st.HybridEntries) - if st.HybridEntries[0].TaskItemID != 101 || st.HybridEntries[1].TaskItemID != 202 { - t.Fatalf("顺序归位失败: %+v", st.HybridEntries) - } -} - -func TestTryNormalizeMovableTaskOrderByOriginSkipsAfterMinContextSwitch(t *testing.T) { - st := &ScheduleRefineState{ - OriginOrderMap: map[int]int{ - 101: 1, - 202: 2, - }, - CompositeToolSuccess: map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": true, - }, - HybridEntries: []model.HybridScheduleEntry{ - {TaskItemID: 202, Name: "task-202", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, - {TaskItemID: 101, Name: "task-101", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2}, - }, - } - changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st) - if !skipped { - t.Fatalf("期望 MinContextSwitch 成功后跳过顺序归位") - } - if changed { - t.Fatalf("跳过顺序归位时不应报告 changed=true") - } - if st.HybridEntries[0].TaskItemID != 202 || st.HybridEntries[1].TaskItemID != 101 { - t.Fatalf("跳过顺序归位后不应改写任务顺序: %+v", st.HybridEntries) - } -} - -func TestEvaluateHardChecksSkipsOrderConstraintAfterMinContextSwitch(t *testing.T) { - st := &ScheduleRefineState{ - UserMessage: "减少第15周科目切换", - OriginOrderMap: map[int]int{ - 101: 1, - 202: 2, - }, - CompositeToolSuccess: map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": true, - }, - InitialHybridEntries: []model.HybridScheduleEntry{ - {TaskItemID: 101, Name: "概率任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, - {TaskItemID: 202, Name: "数电任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4}, - }, - HybridEntries: []model.HybridScheduleEntry{ - {TaskItemID: 202, Name: "数电任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, - {TaskItemID: 101, Name: "概率任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4}, - }, - Objective: RefineObjective{ - Mode: "move_all", - SourceWeeks: []int{15}, - TargetWeeks: []int{15}, - BaselineSourceTaskCount: 2, - RequiredMoveMin: 2, - RequiredMoveMax: 2, - }, - SlicePlan: RefineSlicePlan{ - WeekFilter: []int{15}, - }, - } - report := evaluateHardChecks(nil, nil, st, nil) - if !report.OrderPassed { - t.Fatalf("期望 MinContextSwitch 成功后跳过顺序终审,实际 issues=%v", report.OrderIssues) - } -} - -func TestPrecheckToolCallPolicyRejectsRedundantSlotQuery(t *testing.T) { - st := &ScheduleRefineState{ - SeenSlotQueries: make(map[string]struct{}), - EntriesVersion: 0, - } - call := reactToolCall{ - Tool: "QueryAvailableSlots", - Params: map[string]any{ - "week": 16, - "day_of_week": 1, - }, - } - - if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); blocked { - t.Fatalf("首次查询不应被拒绝: %+v", blockedResult) - } - if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); !blocked { - t.Fatalf("重复查询应被拒绝") - } else if blockedResult.ErrorCode != "QUERY_REDUNDANT" { - t.Fatalf("错误码不符合预期: %+v", blockedResult) - } - st.EntriesVersion++ - if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); blocked { - t.Fatalf("排程版本变化后应允许再次查询: %+v", blockedResult) - } -} - -func TestCanonicalizeMoveParamsFromRepairAliases(t *testing.T) { - call := reactToolCall{ - Tool: "Move", - Params: map[string]any{ - "task_item_id": 16, - "new_week": 16, - "day_of_week": 1, - "section_from": 1, - "section_to": 2, - }, - } - normalized := canonicalizeToolCall(call) - if _, ok := paramIntAny(normalized.Params, "to_week"); !ok { - t.Fatalf("to_week 规范化失败: %+v", normalized.Params) - } - if _, ok := paramIntAny(normalized.Params, "to_day"); !ok { - t.Fatalf("to_day 规范化失败: %+v", normalized.Params) - } - if _, ok := paramIntAny(normalized.Params, "to_section_from"); !ok { - t.Fatalf("to_section_from 规范化失败: %+v", normalized.Params) - } - if _, ok := paramIntAny(normalized.Params, "to_section_to"); !ok { - t.Fatalf("to_section_to 规范化失败: %+v", normalized.Params) - } -} - -func TestDetectOrderIntentDefaultsToKeep(t *testing.T) { - if !detectOrderIntent("16周总体任务太多了,帮我移动一半到12周") { - t.Fatalf("未显式放宽顺序时,默认应保持顺序") - } -} - -func TestDetectOrderIntentExplicitAllowReorder(t *testing.T) { - if detectOrderIntent("这次顺序无所谓,可以打乱顺序") { - t.Fatalf("用户明确允许乱序时,应关闭顺序约束") - } -} diff --git a/backend/agent2/node/schedule_refine_impl/runner.go b/backend/agent2/node/schedule_refine_impl/runner.go deleted file mode 100644 index 851f554..0000000 --- a/backend/agent2/node/schedule_refine_impl/runner.go +++ /dev/null @@ -1,53 +0,0 @@ -package schedulerefine - -import ( - "context" - - "github.com/cloudwego/eino-ext/components/model/ark" -) - -// scheduleRefineRunner 是“单次图运行”的请求级依赖容器。 -// -// 职责边界: -// 1. 负责收口模型与阶段回调,避免 graph.go 出现大量闭包; -// 2. 负责把节点函数适配为统一签名; -// 3. 不负责分支决策(当前链路为线性图)。 -type scheduleRefineRunner struct { - chatModel *ark.ChatModel - emitStage func(stage, detail string) -} - -func newScheduleRefineRunner(chatModel *ark.ChatModel, emitStage func(stage, detail string)) *scheduleRefineRunner { - return &scheduleRefineRunner{ - chatModel: chatModel, - emitStage: emitStage, - } -} - -func (r *scheduleRefineRunner) contractNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { - return runContractNode(ctx, r.chatModel, st, r.emitStage) -} - -func (r *scheduleRefineRunner) planNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { - return runPlanNode(ctx, r.chatModel, st, r.emitStage) -} - -func (r *scheduleRefineRunner) sliceNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { - return runSliceNode(ctx, st, r.emitStage) -} - -func (r *scheduleRefineRunner) routeNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { - return runCompositeRouteNode(ctx, st, r.emitStage) -} - -func (r *scheduleRefineRunner) reactNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { - return runReactLoopNode(ctx, r.chatModel, st, r.emitStage) -} - -func (r *scheduleRefineRunner) hardCheckNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { - return runHardCheckNode(ctx, r.chatModel, st, r.emitStage) -} - -func (r *scheduleRefineRunner) summaryNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) { - return runSummaryNode(ctx, r.chatModel, st, r.emitStage) -} diff --git a/backend/agent2/node/schedule_refine_impl/state.go b/backend/agent2/node/schedule_refine_impl/state.go deleted file mode 100644 index 604520b..0000000 --- a/backend/agent2/node/schedule_refine_impl/state.go +++ /dev/null @@ -1,377 +0,0 @@ -package schedulerefine - -import ( - "sort" - "strings" - "time" - - "github.com/LoveLosita/smartflow/backend/model" -) - -const ( - // 固定业务时区,避免“今天/明天”在容器默认时区下偏移。 - timezoneName = "Asia/Shanghai" - // 统一分钟级时间文本格式。 - datetimeLayout = "2006-01-02 15:04" - - // 预算默认值。 - defaultPlanMax = 2 - defaultExecuteMax = 24 - defaultPerTaskBudget = 4 - defaultReplanMax = 2 - defaultCompositeRetry = 2 - defaultRepairReserve = 1 -) - -// RefineContract 表示本轮微调意图契约。 -type RefineContract struct { - Intent string `json:"intent"` - Strategy string `json:"strategy"` - HardRequirements []string `json:"hard_requirements"` - HardAssertions []RefineAssertion `json:"hard_assertions,omitempty"` - KeepRelativeOrder bool `json:"keep_relative_order"` - OrderScope string `json:"order_scope"` -} - -// RefineAssertion 表示可由后端直接判定的结构化硬断言。 -// -// 字段说明: -// 1. Metric:断言指标名,例如 source_move_ratio_percent; -// 2. Operator:比较操作符,支持 == / <= / >= / between; -// 3. Value/Min/Max:阈值; -// 4. Week/TargetWeek:可选周次上下文。 -type RefineAssertion struct { - Metric string `json:"metric"` - Operator string `json:"operator"` - Value int `json:"value,omitempty"` - Min int `json:"min,omitempty"` - Max int `json:"max,omitempty"` - Week int `json:"week,omitempty"` - TargetWeek int `json:"target_week,omitempty"` -} - -// HardCheckReport 表示终审硬校验结果。 -type HardCheckReport struct { - PhysicsPassed bool `json:"physics_passed"` - PhysicsIssues []string `json:"physics_issues,omitempty"` - - IntentPassed bool `json:"intent_passed"` - IntentReason string `json:"intent_reason,omitempty"` - IntentUnmet []string `json:"intent_unmet,omitempty"` - - OrderPassed bool `json:"order_passed"` - OrderIssues []string `json:"order_issues,omitempty"` - - RepairTried bool `json:"repair_tried"` -} - -// ReactRoundObservation 记录每轮 ReAct 的关键观察。 -type ReactRoundObservation struct { - Round int `json:"round"` - GoalCheck string `json:"goal_check,omitempty"` - Decision string `json:"decision,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolParams map[string]any `json:"tool_params,omitempty"` - ToolSuccess bool `json:"tool_success"` - ToolErrorCode string `json:"tool_error_code,omitempty"` - ToolResult string `json:"tool_result,omitempty"` - Reflect string `json:"reflect,omitempty"` -} - -// PlannerPlan 表示 Planner 生成的阶段执行计划。 -type PlannerPlan struct { - Summary string `json:"summary"` - Steps []string `json:"steps,omitempty"` -} - -// RefineSlicePlan 表示切片节点输出。 -type RefineSlicePlan struct { - WeekFilter []int `json:"week_filter,omitempty"` - SourceDays []int `json:"source_days,omitempty"` - TargetDays []int `json:"target_days,omitempty"` - ExcludeSections []int `json:"exclude_sections,omitempty"` - Reason string `json:"reason,omitempty"` -} - -// RefineObjective 表示“可执行且可校验”的目标约束。 -// -// 设计说明: -// 1. 由 contract/slice 从自然语言编译得到; -// 2. 执行阶段(done 收口)与终审阶段(hard_check)共用同一份约束; -// 3. 避免“执行逻辑与终审逻辑各说各话”。 -type RefineObjective struct { - Mode string `json:"mode,omitempty"` // none | move_all | move_ratio - - SourceWeeks []int `json:"source_weeks,omitempty"` - TargetWeeks []int `json:"target_weeks,omitempty"` - SourceDays []int `json:"source_days,omitempty"` - TargetDays []int `json:"target_days,omitempty"` - - ExcludeSections []int `json:"exclude_sections,omitempty"` - - BaselineSourceTaskCount int `json:"baseline_source_task_count,omitempty"` - RequiredMoveMin int `json:"required_move_min,omitempty"` - RequiredMoveMax int `json:"required_move_max,omitempty"` - - Reason string `json:"reason,omitempty"` -} - -// ScheduleRefineState 是连续微调图的统一状态。 -type ScheduleRefineState struct { - // 1) 请求上下文 - TraceID string - UserID int - ConversationID string - UserMessage string - RequestNow time.Time - RequestNowText string - - // 2) 继承自预览快照的数据 - TaskClassIDs []int - Constraints []string - // InitialHybridEntries 保存本轮微调开始前的基线,用于终审做“前后对比”。 - // 说明: - // 1. 只读语义,不参与执行期改写; - // 2. 终审可基于它判断“来源任务是否真正迁移到目标区域”。 - InitialHybridEntries []model.HybridScheduleEntry - HybridEntries []model.HybridScheduleEntry - AllocatedItems []model.TaskClassItem - CandidatePlans []model.UserWeekSchedule - - // 3) 本轮执行状态 - UserIntent string - Contract RefineContract - - PlanMax int - PerTaskBudget int - ExecuteMax int - ReplanMax int - // CompositeRetryMax 表示复合路由失败后的最大重试次数(不含首次尝试)。 - CompositeRetryMax int - - PlanUsed int - ReplanUsed int - - MaxRounds int - RepairReserve int - RoundUsed int - ActionLogs []string - - ConsecutiveFailures int - ThinkingBoostArmed bool - ObservationHistory []ReactRoundObservation - - CurrentPlan PlannerPlan - BatchMoveAllowed bool - // DisableCompositeTools=true 表示已进入 ReAct 兜底,禁止再调用复合工具。 - DisableCompositeTools bool - // CompositeRouteTried 标记是否尝试过“复合批处理路由”。 - CompositeRouteTried bool - // CompositeRouteSucceeded 标记复合批处理路由是否已完成“复合分支出站”。 - // - // 说明: - // 1. true 表示当前链路可以跳过 ReAct 兜底,直接进入 hard_check; - // 2. 它不等价于“终审已通过”,终审是否通过仍以后续 HardCheck 结果为准; - // 3. 这样区分是为了避免“复合工具已成功执行,但业务目标要等终审裁决”时被误判为失败。 - CompositeRouteSucceeded bool - TaskActionUsed map[int]int - EntriesVersion int - SeenSlotQueries map[string]struct{} - - // RequiredCompositeTool 表示本轮策略要求“必须至少成功一次”的复合工具。 - // 取值约定:"" | "SpreadEven" | "MinContextSwitch"。 - RequiredCompositeTool string - // CompositeToolCalled 记录复合工具是否至少调用过一次(不区分成功失败)。 - CompositeToolCalled map[string]bool - // CompositeToolSuccess 记录复合工具是否至少成功过一次。 - CompositeToolSuccess map[string]bool - - SlicePlan RefineSlicePlan - Objective RefineObjective - WorksetTaskIDs []int - WorksetCursor int - CurrentTaskID int - CurrentTaskAttempt int - - LastFailedCallSignature string - OriginOrderMap map[int]int - - // 4) 终审状态 - HardCheck HardCheckReport - - // 5) 最终输出 - FinalSummary string - Completed bool -} - -// NewScheduleRefineState 基于上一版预览快照初始化状态。 -// -// 职责边界: -// 1. 负责初始化预算、上下文字段与可变状态容器; -// 2. 负责拷贝 preview 数据,避免跨请求引用污染; -// 3. 不负责做任何调度动作。 -func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState { - now := nowToMinute() - st := &ScheduleRefineState{ - TraceID: strings.TrimSpace(traceID), - UserID: userID, - ConversationID: strings.TrimSpace(conversationID), - UserMessage: strings.TrimSpace(userMessage), - RequestNow: now, - RequestNowText: now.In(loadLocation()).Format(datetimeLayout), - PlanMax: defaultPlanMax, - PerTaskBudget: defaultPerTaskBudget, - ExecuteMax: defaultExecuteMax, - ReplanMax: defaultReplanMax, - CompositeRetryMax: defaultCompositeRetry, - RepairReserve: defaultRepairReserve, - MaxRounds: defaultExecuteMax + defaultRepairReserve, - ActionLogs: make([]string, 0, 32), - ObservationHistory: make([]ReactRoundObservation, 0, 24), - TaskActionUsed: make(map[int]int), - SeenSlotQueries: make(map[string]struct{}), - OriginOrderMap: make(map[int]int), - CompositeToolCalled: map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - }, - CompositeToolSuccess: map[string]bool{ - "SpreadEven": false, - "MinContextSwitch": false, - }, - CurrentPlan: PlannerPlan{ - Summary: "初始化完成,等待 Planner 生成执行计划。", - }, - SlicePlan: RefineSlicePlan{ - Reason: "尚未切片", - }, - } - if preview == nil { - return st - } - - st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...) - st.InitialHybridEntries = cloneHybridEntries(preview.HybridEntries) - st.HybridEntries = cloneHybridEntries(preview.HybridEntries) - st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems) - st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans) - st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries) - return st -} - -func loadLocation() *time.Location { - loc, err := time.LoadLocation(timezoneName) - if err != nil { - return time.Local - } - return loc -} - -func nowToMinute() time.Time { - return time.Now().In(loadLocation()).Truncate(time.Minute) -} - -func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { - if len(src) == 0 { - return nil - } - dst := make([]model.HybridScheduleEntry, len(src)) - copy(dst, src) - return dst -} - -func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { - if len(src) == 0 { - return nil - } - dst := make([]model.TaskClassItem, 0, len(src)) - for _, item := range src { - copied := item - if item.CategoryID != nil { - v := *item.CategoryID - copied.CategoryID = &v - } - if item.Order != nil { - v := *item.Order - copied.Order = &v - } - if item.Content != nil { - v := *item.Content - copied.Content = &v - } - if item.Status != nil { - v := *item.Status - copied.Status = &v - } - if item.EmbeddedTime != nil { - t := *item.EmbeddedTime - copied.EmbeddedTime = &t - } - dst = append(dst, copied) - } - return dst -} - -func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { - if len(src) == 0 { - return nil - } - dst := make([]model.UserWeekSchedule, 0, len(src)) - for _, week := range src { - eventsCopy := make([]model.WeeklyEventBrief, len(week.Events)) - copy(eventsCopy, week.Events) - dst = append(dst, model.UserWeekSchedule{ - Week: week.Week, - Events: eventsCopy, - }) - } - return dst -} - -// buildOriginOrderMap 构建 suggested 任务的初始顺序基线(task_item_id -> rank)。 -func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int { - orderMap := make(map[int]int) - if len(entries) == 0 { - return orderMap - } - suggested := make([]model.HybridScheduleEntry, 0, len(entries)) - for _, entry := range entries { - if isMovableSuggestedTask(entry) { - suggested = append(suggested, entry) - } - } - sort.SliceStable(suggested, func(i, j int) bool { - left := suggested[i] - right := suggested[j] - if left.Week != right.Week { - return left.Week < right.Week - } - if left.DayOfWeek != right.DayOfWeek { - return left.DayOfWeek < right.DayOfWeek - } - if left.SectionFrom != right.SectionFrom { - return left.SectionFrom < right.SectionFrom - } - if left.SectionTo != right.SectionTo { - return left.SectionTo < right.SectionTo - } - return left.TaskItemID < right.TaskItemID - }) - for i, entry := range suggested { - orderMap[entry.TaskItemID] = i + 1 - } - return orderMap -} - -// FinalHardCheckPassed 判断“最终终审”是否整体通过。 -// -// 职责边界: -// 1. 负责聚合 physics/order/intent 三类硬校验结果,给服务层与总结阶段统一复用; -// 2. 不负责触发终审,也不负责推导修复动作; -// 3. nil state 视为未通过,避免上层把缺失结果误判为成功。 -func FinalHardCheckPassed(st *ScheduleRefineState) bool { - if st == nil { - return false - } - return st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed -} diff --git a/backend/agent2/node/schedule_refine_impl/tool.go b/backend/agent2/node/schedule_refine_impl/tool.go deleted file mode 100644 index 21f3e79..0000000 --- a/backend/agent2/node/schedule_refine_impl/tool.go +++ /dev/null @@ -1,2027 +0,0 @@ -package schedulerefine - -import ( - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" - - "github.com/LoveLosita/smartflow/backend/logic" - "github.com/LoveLosita/smartflow/backend/model" -) - -// reactToolCall 表示模型输出的单个工具调用指令。 -type reactToolCall struct { - Tool string `json:"tool"` - Params map[string]any `json:"params"` -} - -// reactToolResult 表示工具调用的结构化执行结果。 -type reactToolResult struct { - Tool string `json:"tool"` - Success bool `json:"success"` - ErrorCode string `json:"error_code,omitempty"` - Result string `json:"result"` -} - -// reactLLMOutput 表示“强 ReAct”要求的固定 JSON 输出结构。 -// -// 字段语义: -// 1. goal_check:本轮要先验证的目标点; -// 2. decision:本轮动作选择依据; -// 3. tool_calls:本轮工具动作列表(业务侧只取第一条)。 -type reactLLMOutput struct { - Done bool `json:"done"` - Summary string `json:"summary"` - GoalCheck string `json:"goal_check"` - Decision string `json:"decision"` - MissingInfo []string `json:"missing_info,omitempty"` - ToolCalls []reactToolCall `json:"tool_calls"` -} - -// reviewOutput 表示终审节点要求的固定 JSON 输出结构。 -type reviewOutput struct { - Pass bool `json:"pass"` - Reason string `json:"reason"` - Unmet []string `json:"unmet"` -} - -// planningWindow 表示微调工具允许活动的 week/day 边界窗口。 -// -// 设计说明: -// 1. 这里用已有 HybridEntries 自动推导窗口,避免把任务移动到完全无关的周; -// 2. 若窗口不可用(没有任何 entry),则降级为“仅做基础合法性校验”。 -type planningWindow struct { - Enabled bool - StartWeek int - StartDay int - EndWeek int - EndDay int -} - -// refineToolPolicy 是工具层硬约束策略。 -// -// 职责边界: -// 1. 负责承载“是否强制保持相对顺序”的策略开关; -// 2. 负责承载顺序校验需要的 origin_order 映射; -// 3. 不负责语义判定(语义仍由 LLM 终审节点负责)。 -type refineToolPolicy struct { - KeepRelativeOrder bool - OrderScope string - OriginOrderMap map[int]int -} - -// dispatchRefineTool 负责把模型输出的 tool_call 分发到具体工具实现。 -// -// 步骤化说明: -// 1. 先识别工具名并路由到对应实现; -// 2. 工具实现内部负责参数校验、冲突校验、边界校验、顺序校验; -// 3. 任何失败都返回 Success=false 的结构化结果,而不是直接 panic。 -func dispatchRefineTool(entries []model.HybridScheduleEntry, call reactToolCall, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - switch strings.TrimSpace(call.Tool) { - case "QueryTargetTasks": - return refineToolQueryTargetTasks(entries, call.Params, policy) - case "QueryAvailableSlots": - return refineToolQueryAvailableSlots(entries, call.Params, window) - case "Move": - return refineToolMove(entries, call.Params, window, policy) - case "Swap": - return refineToolSwap(entries, call.Params, window, policy) - case "BatchMove": - return refineToolBatchMove(entries, call.Params, window, policy) - case "SpreadEven": - return refineToolSpreadEven(entries, call.Params, window, policy) - case "MinContextSwitch": - return refineToolMinContextSwitch(entries, call.Params, window, policy) - case "Verify": - return refineToolVerify(entries, call.Params, policy) - default: - return entries, reactToolResult{ - Tool: strings.TrimSpace(call.Tool), - Success: false, - Result: fmt.Sprintf("不支持的工具:%s(仅允许 QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/SpreadEven/MinContextSwitch/Verify)", strings.TrimSpace(call.Tool)), - } - } -} - -// pickSingleToolCall 在“单步动作”策略下选取一个工具调用。 -// -// 返回语义: -// 1. call=nil:本轮无可执行动作; -// 2. warn 非空:模型返回了多个调用,本轮只执行第一个并记录告警。 -func pickSingleToolCall(calls []reactToolCall) (*reactToolCall, string) { - if len(calls) == 0 { - return nil, "" - } - call := calls[0] - if len(calls) == 1 { - return &call, "" - } - return &call, fmt.Sprintf("模型返回了 %d 个工具调用,本轮仅执行第一个:%s", len(calls), call.Tool) -} - -// parseReactLLMOutput 解析模型输出的 ReAct JSON。 -// -// 容错策略: -// 1. 兼容 ```json 代码块包装; -// 2. 兼容 JSON 前后有解释性文字(提取最外层对象)。 -func parseReactLLMOutput(raw string) (*reactLLMOutput, error) { - clean := strings.TrimSpace(raw) - if clean == "" { - return nil, fmt.Errorf("ReAct 输出为空") - } - if strings.HasPrefix(clean, "```") { - clean = strings.TrimPrefix(clean, "```json") - clean = strings.TrimPrefix(clean, "```") - clean = strings.TrimSuffix(clean, "```") - clean = strings.TrimSpace(clean) - } - - var out reactLLMOutput - if err := json.Unmarshal([]byte(clean), &out); err == nil { - return &out, nil - } - obj, objErr := extractFirstJSONObject(clean) - if objErr != nil { - return nil, fmt.Errorf("无法从输出中提取 JSON:%s", truncate(clean, 220)) - } - if err := json.Unmarshal([]byte(obj), &out); err != nil { - return nil, err - } - return &out, nil -} - -// parseReviewOutput 解析终审评估节点输出。 -func parseReviewOutput(raw string) (*reviewOutput, error) { - clean := strings.TrimSpace(raw) - if clean == "" { - return nil, fmt.Errorf("review 输出为空") - } - if strings.HasPrefix(clean, "```") { - clean = strings.TrimPrefix(clean, "```json") - clean = strings.TrimPrefix(clean, "```") - clean = strings.TrimSuffix(clean, "```") - clean = strings.TrimSpace(clean) - } - - var out reviewOutput - if err := json.Unmarshal([]byte(clean), &out); err == nil { - return &out, nil - } - obj, objErr := extractFirstJSONObject(clean) - if objErr != nil { - return nil, fmt.Errorf("无法从 review 输出中提取 JSON:%s", truncate(clean, 220)) - } - if err := json.Unmarshal([]byte(obj), &out); err != nil { - return nil, err - } - return &out, nil -} - -// refineToolMove 执行“移动一个 suggested 任务到指定时段”。 -// -// 步骤化说明: -// 1. 先校验参数完整性与目标时段合法性,避免写入脏坐标; -// 2. 再校验原任务是否存在、跨度是否一致(防止任务长度被模型改坏); -// 3. 再校验窗口边界与冲突,确保不会穿透到不可用位置; -// 4. 若启用顺序硬约束,再校验“移动后是否打乱原相对顺序”; -// 5. 全部通过后才真正修改 entries 并返回 Success=true。 -func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - // 0. task_id 兼容策略: - // 0.1 标准键是 task_item_id; - // 0.2 为了兼容模型偶发输出别名 task_id,这里做兜底兼容,避免“语义正确但参数名不一致”导致整轮白跑; - // 0.3 两者都不存在时,仍按参数缺失返回失败,由上层 ReAct 继续下一轮决策。 - taskID, ok := paramIntAny(params, "task_item_id", "task_id") - if !ok { - return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:task_item_id"} - } - // 1. 参数兼容策略: - // 1.1 优先读取标准键(to_week/to_day/...); - // 1.2 若模型输出了历史别名(target_xxx/day_of_week 等),也兼容解析; - // 1.3 目标是减少“仅参数名不一致导致的无效失败轮次”。 - toWeek, okWeek := paramIntAny(params, "to_week", "target_week", "new_week", "week") - toDay, okDay := paramIntAny(params, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day") - toSF, okSF := paramIntAny(params, "to_section_from", "target_section_from", "new_section_from", "section_from") - toST, okST := paramIntAny(params, "to_section_to", "target_section_to", "new_section_to", "section_to") - if !okWeek || !okDay || !okSF || !okST { - return entries, reactToolResult{ - Tool: "Move", - Success: false, - Result: "参数缺失:需要 to_week/to_day/to_section_from/to_section_to", - } - } - if toDay < 1 || toDay > 7 { - return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 非法,必须在 1~7", toDay)} - } - if toSF < 1 || toST > 12 || toSF > toST { - return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次区间 %d-%d 非法", toSF, toST)} - } - allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - - idx, locateErr := findUniqueSuggestedByID(entries, taskID) - if locateErr != nil { - return entries, reactToolResult{Tool: "Move", Success: false, Result: locateErr.Error()} - } - origSpan := entries[idx].SectionTo - entries[idx].SectionFrom - newSpan := toST - toSF - if origSpan != newSpan { - return entries, reactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("任务跨度不一致:原跨度=%d,目标跨度=%d", origSpan+1, newSpan+1), - } - } - - if !isWithinWindow(window, toWeek, toDay) { - return entries, reactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("目标 W%dD%d 超出允许窗口", toWeek, toDay), - } - } - - if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}, allowEmbed); conflict { - return entries, reactToolResult{ - Tool: "Move", - Success: false, - Result: fmt.Sprintf("目标时段已被 %s 占用", name), - } - } - - beforeEntries := cloneHybridEntries(entries) - entry := &entries[idx] - before := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo) - entry.Week = toWeek - entry.DayOfWeek = toDay - entry.SectionFrom = toSF - entry.SectionTo = toST - after := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo) - - sortHybridEntries(entries) - if issues := validateRelativeOrder(entries, policy); len(issues) > 0 { - return beforeEntries, reactToolResult{ - Tool: "Move", - Success: false, - Result: "顺序约束不满足:" + strings.Join(issues, ";"), - } - } - - return entries, reactToolResult{ - Tool: "Move", - Success: true, - Result: fmt.Sprintf("已将任务[%s](id=%d,type=%s,status=%s) 从 %s 移动到 %s", entry.Name, taskID, strings.TrimSpace(entry.Type), strings.TrimSpace(entry.Status), before, after), - } -} - -// refineToolSwap 执行“交换两个 suggested 任务的位置”。 -// -// 步骤化说明: -// 1. 先校验两端 task_item_id; -// 2. 再双向验证交换后的落点是否与其他条目冲突; -// 3. 若启用顺序硬约束,再校验“交换后是否打乱相对顺序”; -// 4. 校验通过后提交交换并返回成功。 -func refineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - // 1. 参数兼容策略同 Move: - // 1.1 兼容 task_a/task_b 与 task_item_a/task_item_b 等常见别名; - // 1.2 目标是减少模型输出字段差异导致的无效失败。 - idA, okA := paramIntAny(params, "task_a", "task_item_a", "task_item_id_a") - idB, okB := paramIntAny(params, "task_b", "task_item_b", "task_item_id_b") - if !okA || !okB { - return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:task_a/task_b"} - } - allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - if idA == idB { - return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 与 task_b 不能相同"} - } - - idxA, errA := findUniqueSuggestedByID(entries, idA) - if errA != nil { - return entries, reactToolResult{Tool: "Swap", Success: false, Result: errA.Error()} - } - idxB, errB := findUniqueSuggestedByID(entries, idB) - if errB != nil { - return entries, reactToolResult{Tool: "Swap", Success: false, Result: errB.Error()} - } - - a := entries[idxA] - b := entries[idxB] - if !isWithinWindow(window, b.Week, b.DayOfWeek) || !isWithinWindow(window, a.Week, a.DayOfWeek) { - return entries, reactToolResult{Tool: "Swap", Success: false, Result: "交换目标超出允许窗口"} - } - - excludes := map[int]bool{idxA: true, idxB: true} - if conflict, name := hasConflict(entries, b.Week, b.DayOfWeek, b.SectionFrom, b.SectionTo, excludes, allowEmbed); conflict { - return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务A交换后将与 %s 冲突", name)} - } - if conflict, name := hasConflict(entries, a.Week, a.DayOfWeek, a.SectionFrom, a.SectionTo, excludes, allowEmbed); conflict { - return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务B交换后将与 %s 冲突", name)} - } - - beforeEntries := cloneHybridEntries(entries) - entries[idxA].Week, entries[idxB].Week = entries[idxB].Week, entries[idxA].Week - entries[idxA].DayOfWeek, entries[idxB].DayOfWeek = entries[idxB].DayOfWeek, entries[idxA].DayOfWeek - entries[idxA].SectionFrom, entries[idxB].SectionFrom = entries[idxB].SectionFrom, entries[idxA].SectionFrom - entries[idxA].SectionTo, entries[idxB].SectionTo = entries[idxB].SectionTo, entries[idxA].SectionTo - - sortHybridEntries(entries) - if issues := validateRelativeOrder(entries, policy); len(issues) > 0 { - return beforeEntries, reactToolResult{ - Tool: "Swap", - Success: false, - Result: "顺序约束不满足:" + strings.Join(issues, ";"), - } - } - - return entries, reactToolResult{ - Tool: "Swap", - Success: true, - Result: fmt.Sprintf("已交换任务 id=%d 与 id=%d 的时段", idA, idB), - } -} - -// refineToolBatchMove 执行“原子批量移动 suggested 任务”。 -// -// 步骤化说明: -// 1. 参数要求:params.moves 必须是数组,每个元素都满足 Move 的参数格式; -// 2. 执行策略:在 working 副本上按顺序逐条执行 Move; -// 3. 原子语义:任一步失败,整批回滚(返回原 entries);全部成功才一次性提交; -// 4. 适用场景:用户明确希望“同一轮挪多个任务”,减少 ReAct 往返轮次。 -func refineToolBatchMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - moveParamsList, parseErr := parseBatchMoveParams(params) - if parseErr != nil { - return entries, reactToolResult{ - Tool: "BatchMove", - Success: false, - ErrorCode: "PARAM_MISSING", - Result: parseErr.Error(), - } - } - // 2. 批级 allow_embed 默认值: - // 2.1 如果子动作未显式声明 allow_embed/allow_embedding,则继承批级开关; - // 2.2 默认 true,和 Move/Swap 一致:允许嵌入,但由 QueryAvailableSlots 先给纯空位。 - batchAllowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - for i := range moveParamsList { - if _, ok := moveParamsList[i]["allow_embed"]; ok { - continue - } - if _, ok := moveParamsList[i]["allow_embedding"]; ok { - continue - } - moveParamsList[i]["allow_embed"] = batchAllowEmbed - } - - // 1. 在副本上执行,保证原子性: - // 1.1 每一步都复用 refineToolMove 的全部校验逻辑(冲突、窗口、顺序、跨度); - // 1.2 只要任一步失败就中止并回滚到原 entries; - // 1.3 全部成功后再返回 working,作为整批提交结果。 - working := cloneHybridEntries(entries) - stepSummary := make([]string, 0, len(moveParamsList)) - currentWindow := buildPlanningWindowFromEntries(working) - if !currentWindow.Enabled { - currentWindow = window - } - for idx, moveParams := range moveParamsList { - nextEntries, stepResult := refineToolMove(working, moveParams, currentWindow, policy) - if !stepResult.Success { - return entries, reactToolResult{ - Tool: "BatchMove", - Success: false, - ErrorCode: classifyBatchMoveErrorCode(stepResult.Result), - Result: fmt.Sprintf("BatchMove 第%d步失败:%s", idx+1, stepResult.Result), - } - } - working = nextEntries - currentWindow = buildPlanningWindowFromEntries(working) - stepSummary = append(stepSummary, fmt.Sprintf("第%d步:%s", idx+1, truncate(stepResult.Result, 120))) - } - - return working, reactToolResult{ - Tool: "BatchMove", - Success: true, - Result: fmt.Sprintf("BatchMove 原子提交成功,共执行%d步。%s", len(moveParamsList), strings.Join(stepSummary, " | ")), - } -} - -type compositePlannerFn func( - tasks []logic.RefineTaskCandidate, - slots []logic.RefineSlotCandidate, - options logic.RefineCompositePlanOptions, -) ([]logic.RefineMovePlanItem, error) - -// refineToolSpreadEven 执行“均匀铺开”复合动作。 -// -// 职责边界: -// 1. 负责参数解析、候选收集、调用确定性规划器; -// 2. 不直接改写 entries,统一通过 BatchMove 原子落地; -// 3. 规划算法实现位于 logic 包,工具层只负责编排。 -func refineToolSpreadEven(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - return refineToolCompositeMove(entries, params, window, policy, "SpreadEven", logic.PlanEvenSpreadMoves) -} - -// refineToolMinContextSwitch 执行“最少上下文切换”复合动作。 -// -// 职责边界: -// 1. 负责锁定“当前任务已占坑位集合”,避免为了聚类把任务远距离迁移; -// 2. 负责在固定坑位集合内调用确定性规划器,只重排“任务 -> 坑位”的映射; -// 3. 不直接改写 entries,统一通过 BatchMove 原子落地。 -func refineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - taskIDs := collectCompositeTaskIDs(params) - if len(taskIDs) == 0 { - return entries, reactToolResult{ - Tool: "MinContextSwitch", - Success: false, - ErrorCode: "PARAM_MISSING", - Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", - } - } - tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, "MinContextSwitch") - if !ok { - return entries, taskResult - } - - // 1. MinContextSwitch 的产品语义是“尽量少切换,同时尽量少折腾坑位”; - // 2. 因此这里不再查询整周新坑位,而是直接复用当前任务已占据的坑位集合; - // 3. 这样最终只会发生“任务之间互换位置”,不会跳到用户意料之外的远处时段。 - currentSlots := buildCompositeCurrentTaskSlots(tasks) - plannedMoves, planErr := logic.PlanMinContextSwitchMoves(tasks, currentSlots, logic.RefineCompositePlanOptions{}) - if planErr != nil { - return entries, reactToolResult{ - Tool: "MinContextSwitch", - Success: false, - ErrorCode: "PLAN_FAILED", - Result: planErr.Error(), - } - } - return applyFixedSlotCompositeMoves(entries, policy, "MinContextSwitch", plannedMoves) -} - -// refineToolCompositeMove 是复合动作工具的统一执行框架。 -// -// 步骤化说明: -// 1. 先解析“目标任务集合”,确保任务来源明确且可唯一落到 task_item_id; -// 2. 再按任务跨度查询候选坑位,避免跨度不一致导致执行期失败; -// 3. 调用 logic 包的确定性规划函数,得到 moves; -// 4. 最后复用 BatchMove 原子提交,任一步失败整批回滚。 -func refineToolCompositeMove( - entries []model.HybridScheduleEntry, - params map[string]any, - window planningWindow, - policy refineToolPolicy, - toolName string, - planner compositePlannerFn, -) ([]model.HybridScheduleEntry, reactToolResult) { - taskIDs := collectCompositeTaskIDs(params) - if len(taskIDs) == 0 { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PARAM_MISSING", - Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", - } - } - tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, toolName) - if !ok { - return entries, taskResult - } - idSet := intSliceToIDSet(taskIDs) - spanNeed := buildCompositeSpanNeed(tasks) - - slots, slotErr := collectCompositeSlotsBySpan(entries, params, window, spanNeed) - if slotErr != nil { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "SLOT_QUERY_FAILED", - Result: slotErr.Error(), - } - } - options := logic.RefineCompositePlanOptions{ - ExistingDayLoad: buildCompositeDayLoadBaseline(entries, idSet, slots), - } - plannedMoves, planErr := planner(tasks, slots, options) - if planErr != nil { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PLAN_FAILED", - Result: planErr.Error(), - } - } - return applyCompositePlannedMoves(entries, params, window, policy, toolName, plannedMoves) -} - -// collectCompositeTasks 收集复合动作参与的可移动任务,并做唯一性校验。 -// -// 步骤化说明: -// 1. 只收 suggested 且可移动的 task,避免误改 existing/course; -// 2. task_item_id 必须一一命中,命中多条或缺失都直接失败; -// 3. 输出顺序保持 entries 原始遍历顺序,后续再由规划器做稳定排序。 -func collectCompositeTasks(entries []model.HybridScheduleEntry, taskIDs []int, policy refineToolPolicy, toolName string) ([]logic.RefineTaskCandidate, reactToolResult, bool) { - idSet := intSliceToIDSet(taskIDs) - tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs)) - found := make(map[int]struct{}, len(taskIDs)) - for _, entry := range entries { - if !isMovableSuggestedTask(entry) { - continue - } - if _, ok := idSet[entry.TaskItemID]; !ok { - continue - } - if _, duplicated := found[entry.TaskItemID]; duplicated { - return nil, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_ID_AMBIGUOUS", - Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID), - }, false - } - found[entry.TaskItemID] = struct{}{} - tasks = append(tasks, logic.RefineTaskCandidate{ - TaskItemID: entry.TaskItemID, - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - SectionTo: entry.SectionTo, - Name: strings.TrimSpace(entry.Name), - ContextTag: strings.TrimSpace(entry.ContextTag), - OriginRank: policy.OriginOrderMap[entry.TaskItemID], - }) - } - if len(tasks) != len(taskIDs) { - missing := make([]int, 0, len(taskIDs)) - for _, id := range taskIDs { - if _, ok := found[id]; !ok { - missing = append(missing, id) - } - } - return nil, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_NOT_FOUND", - Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing), - }, false - } - return tasks, reactToolResult{}, true -} - -func buildCompositeSpanNeed(tasks []logic.RefineTaskCandidate) map[int]int { - spanNeed := make(map[int]int, len(tasks)) - for _, task := range tasks { - spanNeed[task.SectionTo-task.SectionFrom+1]++ - } - return spanNeed -} - -func buildCompositeCurrentTaskSlots(tasks []logic.RefineTaskCandidate) []logic.RefineSlotCandidate { - slots := make([]logic.RefineSlotCandidate, 0, len(tasks)) - for _, task := range tasks { - slots = append(slots, logic.RefineSlotCandidate{ - Week: task.Week, - DayOfWeek: task.DayOfWeek, - SectionFrom: task.SectionFrom, - SectionTo: task.SectionTo, - }) - } - return slots -} - -func applyCompositePlannedMoves( - entries []model.HybridScheduleEntry, - params map[string]any, - window planningWindow, - policy refineToolPolicy, - toolName string, - plannedMoves []logic.RefineMovePlanItem, -) ([]model.HybridScheduleEntry, reactToolResult) { - if len(plannedMoves) == 0 { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PLAN_EMPTY", - Result: "规划结果为空:未生成任何可执行移动", - } - } - - moveParams := make([]any, 0, len(plannedMoves)) - for _, move := range plannedMoves { - moveParams = append(moveParams, map[string]any{ - "task_item_id": move.TaskItemID, - "to_week": move.ToWeek, - "to_day": move.ToDay, - "to_section_from": move.ToSectionFrom, - "to_section_to": move.ToSectionTo, - }) - } - batchParams := map[string]any{ - "moves": moveParams, - "allow_embed": paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding"), - } - nextEntries, batchResult := refineToolBatchMove(entries, batchParams, window, policy) - if !batchResult.Success { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: batchResult.ErrorCode, - Result: fmt.Sprintf("%s 执行失败:%s", toolName, batchResult.Result), - } - } - return nextEntries, reactToolResult{ - Tool: toolName, - Success: true, - Result: fmt.Sprintf("%s 执行成功:已规划并提交 %d 条移动。", toolName, len(plannedMoves)), - } -} - -// applyFixedSlotCompositeMoves 以“同时改写坐标”的方式提交固定坑位重排结果。 -// -// 步骤化说明: -// 1. 该函数专门服务“坑位集合固定”的复合工具,避免 BatchMove 顺序执行时出现互相占位冲突; -// 2. 先在副本上一次性改写所有目标任务的坐标,再统一排序与校验; -// 3. 若发现目标坑位重复、任务缺失、或顺序约束不满足,则整批失败并回滚。 -func applyFixedSlotCompositeMoves( - entries []model.HybridScheduleEntry, - policy refineToolPolicy, - toolName string, - plannedMoves []logic.RefineMovePlanItem, -) ([]model.HybridScheduleEntry, reactToolResult) { - if len(plannedMoves) == 0 { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PLAN_EMPTY", - Result: "规划结果为空:未生成任何可执行移动", - } - } - - working := cloneHybridEntries(entries) - indexByTaskID := make(map[int]int, len(working)) - for idx, entry := range working { - if !isMovableSuggestedTask(entry) { - continue - } - if _, exists := indexByTaskID[entry.TaskItemID]; exists { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_ID_AMBIGUOUS", - Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 命中多条可移动 suggested 任务", toolName, entry.TaskItemID), - } - } - indexByTaskID[entry.TaskItemID] = idx - } - - targetSeen := make(map[string]int, len(plannedMoves)) - for _, move := range plannedMoves { - if _, ok := indexByTaskID[move.TaskItemID]; !ok { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_NOT_FOUND", - Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 未找到可移动 suggested 任务", toolName, move.TaskItemID), - } - } - key := fmt.Sprintf("%d-%d-%d-%d", move.ToWeek, move.ToDay, move.ToSectionFrom, move.ToSectionTo) - if prevID, exists := targetSeen[key]; exists { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "PLAN_CONFLICT", - Result: fmt.Sprintf("%s 执行失败:任务 id=%d 与 id=%d 目标坑位重复", toolName, prevID, move.TaskItemID), - } - } - targetSeen[key] = move.TaskItemID - } - - for _, move := range plannedMoves { - idx := indexByTaskID[move.TaskItemID] - working[idx].Week = move.ToWeek - working[idx].DayOfWeek = move.ToDay - working[idx].SectionFrom = move.ToSectionFrom - working[idx].SectionTo = move.ToSectionTo - } - sortHybridEntries(working) - if issues := validateRelativeOrder(working, policy); len(issues) > 0 { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "ORDER_CONSTRAINT_VIOLATED", - Result: "顺序约束不满足:" + strings.Join(issues, ";"), - } - } - return working, reactToolResult{ - Tool: toolName, - Success: true, - Result: fmt.Sprintf("%s 执行成功:已在固定坑位集合内重排 %d 条任务。", toolName, len(plannedMoves)), - } -} - -func collectCompositeTaskIDs(params map[string]any) []int { - ids := readIntSlice(params, "task_item_ids", "task_ids") - if id, ok := paramIntAny(params, "task_item_id", "task_id"); ok { - ids = append(ids, id) - } - return uniquePositiveInts(ids) -} - -func collectCompositeSlotsBySpan( - entries []model.HybridScheduleEntry, - params map[string]any, - window planningWindow, - spanNeed map[int]int, -) ([]logic.RefineSlotCandidate, error) { - if len(spanNeed) == 0 { - return nil, fmt.Errorf("未识别到任务跨度需求") - } - - spans := make([]int, 0, len(spanNeed)) - for span := range spanNeed { - spans = append(spans, span) - } - sort.Ints(spans) - - allSlots := make([]logic.RefineSlotCandidate, 0, 16) - for _, span := range spans { - required := spanNeed[span] - queryParams := buildCompositeSlotQueryParams(params, span, required) - _, queryResult := refineToolQueryAvailableSlots(entries, queryParams, window) - if !queryResult.Success { - return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, queryResult.Result) - } - - var payload struct { - Slots []struct { - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - } `json:"slots"` - } - if err := json.Unmarshal([]byte(queryResult.Result), &payload); err != nil { - return nil, fmt.Errorf("解析跨度=%d 的空位结果失败:%v", span, err) - } - if len(payload.Slots) < required { - return nil, fmt.Errorf("跨度=%d 可用坑位不足:required=%d, got=%d", span, required, len(payload.Slots)) - } - for _, slot := range payload.Slots { - allSlots = append(allSlots, logic.RefineSlotCandidate{ - Week: slot.Week, - DayOfWeek: slot.DayOfWeek, - SectionFrom: slot.SectionFrom, - SectionTo: slot.SectionTo, - }) - } - } - return allSlots, nil -} - -func buildCompositeSlotQueryParams(params map[string]any, span int, required int) map[string]any { - query := make(map[string]any, 12) - query["span"] = span - - // 1. limit 以“任务数 * 兜底系数”估算,给规划器保留可选空间; - // 2. 若调用方显式给了 limit,则采用更大的那个,避免被过小 limit 限死。 - limit := required * 6 - if limit < required { - limit = required - } - if customLimit, ok := paramIntAny(params, "limit"); ok && customLimit > limit { - limit = customLimit - } - query["limit"] = limit - query["allow_embed"] = paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - - for _, key := range []string{"week", "week_from", "week_to", "day_scope", "after_section", "before_section"} { - if value, ok := params[key]; ok { - query[key] = value - } - } - - // 1. 复合路由主链路自身使用的是 week_filter/day_of_week/exclude_sections; - // 2. 这里必须优先透传这些“规范键”,再兼容历史别名; - // 3. 否则会出现复合工具已被调用,但内部查坑位时丢失目标范围,导致规划结果漂移。 - copyIntSliceParam(params, query, "week_filter", "week_filter", "weeks") - copyIntSliceParam(params, query, "day_of_week", "day_of_week", "days", "day_filter") - copyIntSliceParam(params, query, "exclude_sections", "exclude_sections", "exclude_section") - - // 兼容 Move 风格别名,降低模型参数名漂移导致的失败。 - if week, ok := paramIntAny(params, "to_week", "target_week", "new_week"); ok { - query["week"] = week - } - if day, ok := paramIntAny(params, "to_day", "target_day", "target_day_of_week", "new_day", "day"); ok { - query["day_of_week"] = []int{day} - } - return query -} - -func copyIntSliceParam(src map[string]any, dst map[string]any, dstKey string, srcKeys ...string) { - values := readIntSlice(src, srcKeys...) - if len(values) == 0 { - return - } - normalized := uniquePositiveInts(values) - if len(normalized) == 0 { - return - } - dst[dstKey] = normalized -} - -func buildCompositeDayLoadBaseline( - entries []model.HybridScheduleEntry, - excludeTaskIDs map[int]struct{}, - slots []logic.RefineSlotCandidate, -) map[string]int { - if len(slots) == 0 { - return nil - } - targetDays := make(map[string]struct{}, len(slots)) - for _, slot := range slots { - targetDays[fmt.Sprintf("%d-%d", slot.Week, slot.DayOfWeek)] = struct{}{} - } - - load := make(map[string]int, len(targetDays)) - for _, entry := range entries { - if !isMovableSuggestedTask(entry) { - continue - } - if _, excluded := excludeTaskIDs[entry.TaskItemID]; excluded { - continue - } - key := fmt.Sprintf("%d-%d", entry.Week, entry.DayOfWeek) - if _, inTarget := targetDays[key]; !inTarget { - continue - } - load[key]++ - } - return load -} - -// refineToolQueryTargetTasks 查询“本轮潜在目标任务集合”。 -// -// 步骤化说明: -// 1. 支持按 day_scope(weekend/workday/all)、week 范围、limit 过滤; -// 2. 只读查询,不修改 entries; -// 3. 返回结构化 JSON 字符串,供下一轮模型直接消费。 -func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - scope := normalizeDayScope(readString(params, "day_scope", "all")) - statusFilter := normalizeStatusFilter(readString(params, "status", "suggested")) - weekFilter := intSliceToWeekSet(readIntSlice(params, "week_filter", "weeks")) - weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week") - weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week") - if week, hasWeek := paramIntAny(params, "week"); hasWeek { - weekFrom, weekTo = week, week - hasWeekFrom, hasWeekTo = true, true - } - if hasWeekFrom && hasWeekTo && weekFrom > weekTo { - weekFrom, weekTo = weekTo, weekFrom - } - if !hasWeekFrom || !hasWeekTo { - startWeek, endWeek := inferWeekBounds(entries, planningWindow{Enabled: false}) - if !hasWeekFrom { - weekFrom = startWeek - } - if !hasWeekTo { - weekTo = endWeek - } - } - limit, okLimit := paramIntAny(params, "limit") - if !okLimit || limit <= 0 { - limit = 16 - } - dayFilter := intSliceToDaySet(readIntSlice(params, "day_of_week", "days", "day_filter")) - taskIDs := readIntSlice(params, "task_item_ids", "task_ids") - if taskID, ok := paramIntAny(params, "task_item_id", "task_id"); ok { - taskIDs = append(taskIDs, taskID) - } - taskIDSet := intSliceToIDSet(taskIDs) - - type targetTask struct { - TaskItemID int `json:"task_item_id"` - Name string `json:"name"` - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - OriginRank int `json:"origin_rank,omitempty"` - ContextTag string `json:"context_tag,omitempty"` - CurrentState string `json:"status"` - } - - list := make([]targetTask, 0, 32) - for _, entry := range entries { - if !matchStatusFilter(entry.Status, statusFilter) { - continue - } - // suggested 视图只允许看到“可移动任务”,避免把课程类条目当成可调任务暴露给模型。 - if statusFilter == "suggested" && !isMovableSuggestedTask(entry) { - continue - } - if entry.TaskItemID <= 0 { - continue - } - if len(taskIDSet) > 0 { - if _, ok := taskIDSet[entry.TaskItemID]; !ok { - continue - } - } - if len(dayFilter) > 0 { - if _, ok := dayFilter[entry.DayOfWeek]; !ok { - continue - } - } else if !matchDayScope(entry.DayOfWeek, scope) { - continue - } - if len(weekFilter) > 0 { - if _, ok := weekFilter[entry.Week]; !ok { - continue - } - } - if hasWeekFrom && entry.Week < weekFrom { - continue - } - if hasWeekTo && entry.Week > weekTo { - continue - } - list = append(list, targetTask{ - TaskItemID: entry.TaskItemID, - Name: strings.TrimSpace(entry.Name), - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - SectionTo: entry.SectionTo, - OriginRank: policy.OriginOrderMap[entry.TaskItemID], - ContextTag: strings.TrimSpace(entry.ContextTag), - CurrentState: entry.Status, - }) - } - sort.SliceStable(list, func(i, j int) bool { - if list[i].Week != list[j].Week { - return list[i].Week < list[j].Week - } - if list[i].DayOfWeek != list[j].DayOfWeek { - return list[i].DayOfWeek < list[j].DayOfWeek - } - if list[i].SectionFrom != list[j].SectionFrom { - return list[i].SectionFrom < list[j].SectionFrom - } - return list[i].TaskItemID < list[j].TaskItemID - }) - if len(list) > limit { - list = list[:limit] - } - - payload := map[string]any{ - "tool": "QueryTargetTasks", - "count": len(list), - "status": statusFilter, - "day_scope": scope, - "week_filter": keysOfIntSet(weekFilter), - "week_from": weekFrom, - "week_to": weekTo, - "day_of_week": keysOfIntSet(dayFilter), - "items": list, - } - raw, err := json.Marshal(payload) - if err != nil { - return entries, reactToolResult{ - Tool: "QueryTargetTasks", - Success: false, - ErrorCode: "QUERY_ENCODE_FAILED", - Result: fmt.Sprintf("序列化查询结果失败:%v", err), - } - } - return entries, reactToolResult{ - Tool: "QueryTargetTasks", - Success: true, - Result: string(raw), - } -} - -// refineToolQueryAvailableSlots 查询“可放置 suggested 的空位”。 -// -// 步骤化说明: -// 1. 根据 day_scope/week 范围/span/exclude_sections 过滤候选时段; -// 2. 默认先收集“纯空位”,不足 limit 再补“可嵌入课程位”(第二优先级); -// 3. 返回结构化 JSON 字符串,不修改 entries。 -func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow) ([]model.HybridScheduleEntry, reactToolResult) { - scope := normalizeDayScope(readString(params, "day_scope", "all")) - dayFilter := intSliceToDaySet(readIntSlice(params, "day_of_week", "days", "day_filter")) - weekFilter := intSliceToWeekSet(readIntSlice(params, "week_filter", "weeks")) - // 1. 空位优先策略: - // 1.1 默认 allow_embed=true,但查询分两阶段执行; - // 1.2 第一阶段只收集“纯空白位”(不与 existing 重叠); - // 1.3 第二阶段仅在空白位不足 limit 时,补充“可嵌入课程位”。 - allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") - // 1.4 兼容 slot_type/slot_types: - // 1.4.1 当明确请求 pure/empty/strict 时,强制只查纯空位(关闭嵌入候选)。 - // 1.4.2 当未声明时,维持“空位优先,空位不足再补嵌入候选”的默认策略。 - slotTypeHints := readStringSlice(params, "slot_types") - if single := strings.TrimSpace(readString(params, "slot_type", "")); single != "" { - slotTypeHints = append(slotTypeHints, single) - } - for _, hint := range slotTypeHints { - normalized := strings.ToLower(strings.TrimSpace(hint)) - if normalized == "pure" || normalized == "empty" || normalized == "strict" { - allowEmbed = false - break - } - } - span, okSpan := paramIntAny(params, "span", "section_duration", "task_duration") - if !okSpan || span <= 0 { - span = 2 - } - if span > 12 { - return entries, reactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "SPAN_INVALID", - Result: fmt.Sprintf("span=%d 非法,必须在 1~12", span), - } - } - limit, okLimit := paramIntAny(params, "limit") - if !okLimit || limit <= 0 { - limit = 12 - } - - weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week") - weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week") - if week, hasWeek := paramIntAny(params, "week"); hasWeek { - weekFrom, weekTo = week, week - hasWeekFrom, hasWeekTo = true, true - } - if hasWeekFrom && hasWeekTo && weekFrom > weekTo { - weekFrom, weekTo = weekTo, weekFrom - } - if !hasWeekFrom || !hasWeekTo { - startWeek, endWeek := inferWeekBounds(entries, window) - if !hasWeekFrom { - weekFrom = startWeek - } - if !hasWeekTo { - weekTo = endWeek - } - } - weeksToIterate := buildWeekIterList(weekFilter, weekFrom, weekTo) - if len(weeksToIterate) == 0 { - return entries, reactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "PARAM_MISSING", - Result: "周范围为空:请提供 week / week_filter 或确保排程窗口有效", - } - } - weekFrom = weeksToIterate[0] - weekTo = weeksToIterate[len(weeksToIterate)-1] - - excludedSet := make(map[int]struct{}) - for _, sec := range readIntSlice(params, "exclude_sections", "exclude_section") { - if sec >= 1 && sec <= 12 { - excludedSet[sec] = struct{}{} - } - } - afterSection, hasAfter := paramIntAny(params, "after_section") - beforeSection, hasBefore := paramIntAny(params, "before_section") - exactSectionFrom, hasExactFrom := paramIntAny(params, "section_from", "target_section_from") - exactSectionTo, hasExactTo := paramIntAny(params, "section_to", "target_section_to") - if hasExactFrom != hasExactTo { - return entries, reactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "PARAM_MISSING", - Result: "精确节次查询需同时提供 section_from 和 section_to", - } - } - if hasExactFrom { - if exactSectionFrom < 1 || exactSectionTo > 12 || exactSectionFrom > exactSectionTo { - return entries, reactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "SPAN_INVALID", - Result: fmt.Sprintf("精确节次区间非法:%d-%d", exactSectionFrom, exactSectionTo), - } - } - span = exactSectionTo - exactSectionFrom + 1 - } - - type slot struct { - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - SectionFrom int `json:"section_from"` - SectionTo int `json:"section_to"` - SlotType string `json:"slot_type,omitempty"` - } - slots := make([]slot, 0, limit) - seen := make(map[string]struct{}, limit*2) - strictCount := 0 - collect := func(embedAllowed bool, slotType string) { - if len(slots) >= limit { - return - } - for _, week := range weeksToIterate { - for day := 1; day <= 7; day++ { - if len(dayFilter) > 0 { - if _, ok := dayFilter[day]; !ok { - continue - } - } else if !matchDayScope(day, scope) { - continue - } - if !isWithinWindow(window, week, day) { - continue - } - for sf := 1; sf+span-1 <= 12; sf++ { - st := sf + span - 1 - if hasExactFrom && (sf != exactSectionFrom || st != exactSectionTo) { - continue - } - if hasAfter && sf <= afterSection { - continue - } - if hasBefore && st >= beforeSection { - continue - } - if intersectsExcludedSections(sf, st, excludedSet) { - continue - } - if conflict, _ := hasConflict(entries, week, day, sf, st, nil, embedAllowed); conflict { - continue - } - key := fmt.Sprintf("%d-%d-%d-%d", week, day, sf, st) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - slots = append(slots, slot{ - Week: week, - DayOfWeek: day, - SectionFrom: sf, - SectionTo: st, - SlotType: slotType, - }) - if len(slots) >= limit { - return - } - } - } - } - } - collect(false, "empty") - strictCount = len(slots) - if allowEmbed && len(slots) < limit { - collect(true, "embedded_candidate") - } - embeddedCount := len(slots) - strictCount - - payload := map[string]any{ - "tool": "QueryAvailableSlots", - "count": len(slots), - "strict_count": strictCount, - "embedded_count": embeddedCount, - "fallback_used": embeddedCount > 0, - "day_scope": scope, - "day_of_week": keysOfIntSet(dayFilter), - "week_filter": keysOfIntSet(weekFilter), - "week_from": weekFrom, - "week_to": weekTo, - "span": span, - "allow_embed": allowEmbed, - "exclude_sections": keysOfIntSet(excludedSet), - "slots": slots, - } - if hasAfter { - payload["after_section"] = afterSection - } - if hasBefore { - payload["before_section"] = beforeSection - } - if hasExactFrom { - payload["section_from"] = exactSectionFrom - payload["section_to"] = exactSectionTo - } - raw, err := json.Marshal(payload) - if err != nil { - return entries, reactToolResult{ - Tool: "QueryAvailableSlots", - Success: false, - ErrorCode: "QUERY_ENCODE_FAILED", - Result: fmt.Sprintf("序列化空位结果失败:%v", err), - } - } - return entries, reactToolResult{ - Tool: "QueryAvailableSlots", - Success: true, - Result: string(raw), - } -} - -// refineToolVerify 进行“轻量确定性自检”。 -// -// 说明: -// 1. 当前只做 deterministic 校验(冲突/顺序),不做语义 LLM 终审; -// 2. 语义层终审仍在 hard_check 节点统一处理; -// 3. 该工具用于给执行阶段一个“可提前自查”的信号。 -func refineToolVerify(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - physicsIssues := physicsCheck(entries, 0) - orderIssues := validateRelativeOrder(entries, policy) - if len(physicsIssues) > 0 || len(orderIssues) > 0 { - payload := map[string]any{ - "tool": "Verify", - "pass": false, - "physics_issues": physicsIssues, - "order_issues": orderIssues, - } - raw, err := json.Marshal(payload) - if err != nil { - return entries, reactToolResult{ - Tool: "Verify", - Success: false, - ErrorCode: "VERIFY_FAILED", - Result: "Verify 校验失败且结果无法序列化", - } - } - return entries, reactToolResult{ - Tool: "Verify", - Success: false, - ErrorCode: "VERIFY_FAILED", - Result: string(raw), - } - } - - // 1. 若携带 task_item_id / 目标坐标参数,则执行“针对性核验”,避免“全局 pass”掩盖当前任务不匹配。 - // 2. 该核验是可选增强:没传 task_id 时仍维持全局 deterministic 行为。 - taskID, hasTaskID := paramIntAny(params, "task_item_id", "task_id") - if hasTaskID { - idx, locateErr := findUniqueSuggestedByID(entries, taskID) - if locateErr != nil { - return entries, reactToolResult{ - Tool: "Verify", - Success: false, - ErrorCode: "VERIFY_FAILED", - Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"%s"}`, locateErr.Error()), - } - } - target := entries[idx] - verifyWeek, hasWeek := paramIntAny(params, "week", "to_week", "target_week") - verifyDay, hasDay := paramIntAny(params, "day_of_week", "to_day", "target_day_of_week") - verifyFrom, hasFrom := paramIntAny(params, "section_from", "to_section_from", "target_section_from") - verifyTo, hasTo := paramIntAny(params, "section_to", "to_section_to", "target_section_to") - - mismatch := make([]string, 0, 4) - if hasWeek && target.Week != verifyWeek { - mismatch = append(mismatch, fmt.Sprintf("week=%d(实际=%d)", verifyWeek, target.Week)) - } - if hasDay && target.DayOfWeek != verifyDay { - mismatch = append(mismatch, fmt.Sprintf("day_of_week=%d(实际=%d)", verifyDay, target.DayOfWeek)) - } - if hasFrom && target.SectionFrom != verifyFrom { - mismatch = append(mismatch, fmt.Sprintf("section_from=%d(实际=%d)", verifyFrom, target.SectionFrom)) - } - if hasTo && target.SectionTo != verifyTo { - mismatch = append(mismatch, fmt.Sprintf("section_to=%d(实际=%d)", verifyTo, target.SectionTo)) - } - if len(mismatch) > 0 { - return entries, reactToolResult{ - Tool: "Verify", - Success: false, - ErrorCode: "VERIFY_FAILED", - Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"任务坐标不匹配:%s"}`, strings.Join(mismatch, ";")), - } - } - return entries, reactToolResult{ - Tool: "Verify", - Success: true, - Result: `{"tool":"Verify","pass":true,"reason":"task-level deterministic checks passed"}`, - } - } - - return entries, reactToolResult{ - Tool: "Verify", - Success: true, - Result: `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`, - } -} - -// validateRelativeOrder 校验 suggested 任务是否保持“初始相对顺序”。 -// -// 步骤化说明: -// 1. 若策略未启用 keep_relative_order,直接通过; -// 2. 否则按时间位置排序 suggested 任务,并映射到 origin_rank; -// 3. 检查 rank 是否单调不降;一旦逆序即判定失败; -// 4. 支持 week 作用域:仅要求每周内保持相对顺序。 -func validateRelativeOrder(entries []model.HybridScheduleEntry, policy refineToolPolicy) []string { - if !policy.KeepRelativeOrder { - return nil - } - if len(policy.OriginOrderMap) == 0 { - return []string{"未提供顺序基线(origin_order_map)"} - } - - suggested := make([]model.HybridScheduleEntry, 0, len(entries)) - for _, entry := range entries { - // 1. 顺序校验与执行口径必须一致: - // 1.1 这里只校验“可移动 suggested 任务”,避免把 course 等不可移动条目误纳入顺序约束; - // 1.2 若把不可移动条目纳入,会出现“动作层不允许改、顺序层却报错”的左右脑互搏。 - if isMovableSuggestedTask(entry) { - suggested = append(suggested, entry) - } - } - if len(suggested) <= 1 { - return nil - } - sort.SliceStable(suggested, func(i, j int) bool { - left := suggested[i] - right := suggested[j] - if left.Week != right.Week { - return left.Week < right.Week - } - if left.DayOfWeek != right.DayOfWeek { - return left.DayOfWeek < right.DayOfWeek - } - if left.SectionFrom != right.SectionFrom { - return left.SectionFrom < right.SectionFrom - } - if left.SectionTo != right.SectionTo { - return left.SectionTo < right.SectionTo - } - return left.TaskItemID < right.TaskItemID - }) - - scope := normalizeOrderScope(policy.OrderScope) - issues := make([]string, 0, 4) - if scope == "week" { - lastRankByWeek := make(map[int]int) - lastNameByWeek := make(map[int]string) - lastIDByWeek := make(map[int]int) - for _, entry := range suggested { - rank, ok := policy.OriginOrderMap[entry.TaskItemID] - if !ok { - issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID)) - continue - } - last, exists := lastRankByWeek[entry.Week] - if exists && rank < last { - issues = append(issues, fmt.Sprintf( - "W%d 出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后", - entry.Week, entry.Name, entry.TaskItemID, rank, lastNameByWeek[entry.Week], lastIDByWeek[entry.Week], last, - )) - } - lastRankByWeek[entry.Week] = rank - lastNameByWeek[entry.Week] = entry.Name - lastIDByWeek[entry.Week] = entry.TaskItemID - } - return issues - } - - lastRank := -1 - lastName := "" - lastID := 0 - for _, entry := range suggested { - rank, ok := policy.OriginOrderMap[entry.TaskItemID] - if !ok { - issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID)) - continue - } - if lastRank >= 0 && rank < lastRank { - issues = append(issues, fmt.Sprintf( - "出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后", - entry.Name, entry.TaskItemID, rank, lastName, lastID, lastRank, - )) - } - lastRank = rank - lastName = entry.Name - lastID = entry.TaskItemID - } - return issues -} - -// normalizeOrderScope 规范化顺序约束作用域。 -func normalizeOrderScope(scope string) string { - switch strings.TrimSpace(strings.ToLower(scope)) { - case "week": - return "week" - default: - return "global" - } -} - -// buildPlanningWindowFromEntries 根据现有条目推导允许移动窗口。 -func buildPlanningWindowFromEntries(entries []model.HybridScheduleEntry) planningWindow { - if len(entries) == 0 { - return planningWindow{Enabled: false} - } - startWeek, startDay := entries[0].Week, entries[0].DayOfWeek - endWeek, endDay := entries[0].Week, entries[0].DayOfWeek - for _, entry := range entries { - if compareWeekDay(entry.Week, entry.DayOfWeek, startWeek, startDay) < 0 { - startWeek, startDay = entry.Week, entry.DayOfWeek - } - if compareWeekDay(entry.Week, entry.DayOfWeek, endWeek, endDay) > 0 { - endWeek, endDay = entry.Week, entry.DayOfWeek - } - } - return planningWindow{ - Enabled: true, - StartWeek: startWeek, - StartDay: startDay, - EndWeek: endWeek, - EndDay: endDay, - } -} - -// isWithinWindow 判断目标 week/day 是否落在窗口内。 -func isWithinWindow(window planningWindow, week, day int) bool { - if !window.Enabled { - return true - } - if day < 1 || day > 7 { - return false - } - if compareWeekDay(week, day, window.StartWeek, window.StartDay) < 0 { - return false - } - if compareWeekDay(week, day, window.EndWeek, window.EndDay) > 0 { - return false - } - return true -} - -// compareWeekDay 比较两个 week/day 坐标。 -// 返回: -// 1) <0:left 更早; -// 2) =0:相同; -// 3) >0:left 更晚。 -func compareWeekDay(leftWeek, leftDay, rightWeek, rightDay int) int { - if leftWeek != rightWeek { - return leftWeek - rightWeek - } - return leftDay - rightDay -} - -// findSuggestedByID 在 entries 中查找指定 task_item_id 的 suggested 条目索引。 -func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int { - for i, entry := range entries { - if isMovableSuggestedTask(entry) && entry.TaskItemID == taskItemID { - return i - } - } - return -1 -} - -// findUniqueSuggestedByID 查找可唯一定位的可移动 suggested 任务。 -// -// 说明: -// 1. “可移动”定义由 isMovableSuggestedTask 统一控制; -// 2. 当 task_item_id 命中 0 条或 >1 条时都返回错误,避免把动作落到错误任务上。 -func findUniqueSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) (int, error) { - first := -1 - count := 0 - for idx, entry := range entries { - if !isMovableSuggestedTask(entry) { - continue - } - if entry.TaskItemID != taskItemID { - continue - } - if first < 0 { - first = idx - } - count++ - } - if count == 0 { - return -1, fmt.Errorf("未找到 task_item_id=%d 的可移动 suggested 任务", taskItemID) - } - if count > 1 { - return -1, fmt.Errorf("task_item_id=%d 命中 %d 条可移动 suggested 任务,无法唯一定位", taskItemID, count) - } - return first, nil -} - -// isMovableSuggestedTask 判断条目是否属于“可被微调工具改写”的任务。 -// -// 规则: -// 1. 必须是 suggested 且 task_item_id>0; -// 2. type=course 明确禁止移动(即便被错误标记为 suggested); -// 3. 其余类型(含空值)按任务处理,兼容历史快照。 -func isMovableSuggestedTask(entry model.HybridScheduleEntry) bool { - if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 { - return false - } - if strings.EqualFold(strings.TrimSpace(entry.Type), "course") { - return false - } - return true -} - -// hasConflict 检查目标时段是否与其他条目冲突。 -// -// 判断规则: -// 1. 仅把“会阻塞 suggested 的条目”纳入冲突判断; -// 2. excludes 中的索引会被跳过(常用于 Move 自身排除或 Swap 双排除)。 -func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, excludes map[int]bool, allowEmbed bool) (bool, string) { - for idx, entry := range entries { - if excludes != nil && excludes[idx] { - continue - } - if !entryBlocksSuggestedWithPolicy(entry, allowEmbed) { - continue - } - if entry.Week == week && entry.DayOfWeek == day && sectionsOverlap(entry.SectionFrom, entry.SectionTo, sf, st) { - return true, fmt.Sprintf("%s(%s)", entry.Name, entry.Type) - } - } - return false, "" -} - -// entryBlocksSuggested 判断条目是否会阻塞 suggested 任务落位。 -func entryBlocksSuggested(entry model.HybridScheduleEntry) bool { - return entryBlocksSuggestedWithPolicy(entry, true) -} - -// entryBlocksSuggestedWithPolicy 判断条目是否阻塞 suggested 落位。 -// -// 策略说明: -// 1. allowEmbed=true:沿用 block_for_suggested 语义; -// 2. allowEmbed=false:existing 一律阻塞,只允许纯空白课位; -// 3. unknown status 保守阻塞,防止漏检。 -func entryBlocksSuggestedWithPolicy(entry model.HybridScheduleEntry, allowEmbed bool) bool { - if entry.Status == "suggested" { - return true - } - if entry.Status == "existing" { - if !allowEmbed { - return true - } - return entry.BlockForSuggested - } - // 未知状态保守处理为阻塞,避免写入潜在冲突。 - return true -} - -// sectionsOverlap 判断两个节次区间是否有交叠。 -func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool { - return aFrom <= bTo && bFrom <= aTo -} - -// paramInt 从 map 中提取 int 参数,兼容 JSON 常见数值类型。 -func paramInt(params map[string]any, key string) (int, bool) { - raw, ok := params[key] - if !ok { - return 0, false - } - switch v := raw.(type) { - case int: - return v, true - case float64: - return int(v), true - case string: - n, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil { - return 0, false - } - return n, true - default: - return 0, false - } -} - -// paramIntAny 按“候选键优先级”提取 int 参数。 -// -// 步骤化说明: -// 1. 按传入顺序依次尝试每个 key; -// 2. 命中第一个合法值即返回; -// 3. 全部未命中则返回 false,由上层统一抛参数缺失错误。 -func paramIntAny(params map[string]any, keys ...string) (int, bool) { - for _, key := range keys { - if v, ok := paramInt(params, key); ok { - return v, true - } - } - return 0, false -} - -// paramBool 从 map 中提取 bool 参数,兼容 JSON 常见布尔表示。 -func paramBool(params map[string]any, key string) (bool, bool) { - raw, ok := params[key] - if !ok { - return false, false - } - switch v := raw.(type) { - case bool: - return v, true - case string: - text := strings.TrimSpace(strings.ToLower(v)) - switch text { - case "true", "1", "yes", "y": - return true, true - case "false", "0", "no", "n": - return false, true - default: - return false, false - } - case int: - if v == 1 { - return true, true - } - if v == 0 { - return false, true - } - return false, false - case float64: - if v == 1 { - return true, true - } - if v == 0 { - return false, true - } - return false, false - default: - return false, false - } -} - -// paramBoolAnyWithDefault 按候选键提取 bool,未命中时返回 fallback。 -func paramBoolAnyWithDefault(params map[string]any, fallback bool, keys ...string) bool { - for _, key := range keys { - if v, ok := paramBool(params, key); ok { - return v - } - } - return fallback -} - -// readString 读取字符串参数,缺失时返回默认值。 -func readString(params map[string]any, key string, fallback string) string { - raw, ok := params[key] - if !ok { - return fallback - } - text := strings.TrimSpace(fmt.Sprintf("%v", raw)) - if text == "" { - return fallback - } - return text -} - -// normalizeDayScope 规范化 day_scope 取值。 -func normalizeDayScope(scope string) string { - switch strings.ToLower(strings.TrimSpace(scope)) { - case "weekend": - return "weekend" - case "workday": - return "workday" - default: - return "all" - } -} - -// normalizeStatusFilter 规范化 status 过滤条件。 -func normalizeStatusFilter(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case "existing": - return "existing" - case "all": - return "all" - default: - return "suggested" - } -} - -// matchStatusFilter 判断条目状态是否命中 status 过滤。 -func matchStatusFilter(entryStatus string, statusFilter string) bool { - switch strings.ToLower(strings.TrimSpace(statusFilter)) { - case "all": - return true - case "existing": - return strings.TrimSpace(entryStatus) == "existing" - default: - return strings.TrimSpace(entryStatus) == "suggested" - } -} - -// matchDayScope 判断 day_of_week 是否满足 scope 过滤条件。 -func matchDayScope(day int, scope string) bool { - switch scope { - case "weekend": - return day == 6 || day == 7 - case "workday": - return day >= 1 && day <= 5 - default: - return day >= 1 && day <= 7 - } -} - -// intSliceToDaySet 把 day 切片转换为 set,并去除非法 day 值。 -func intSliceToDaySet(items []int) map[int]struct{} { - if len(items) == 0 { - return nil - } - set := make(map[int]struct{}, len(items)) - for _, item := range items { - if item < 1 || item > 7 { - continue - } - set[item] = struct{}{} - } - if len(set) == 0 { - return nil - } - return set -} - -// intSliceToWeekSet 把周次切片转换为 set,并去除非正数。 -func intSliceToWeekSet(items []int) map[int]struct{} { - if len(items) == 0 { - return nil - } - set := make(map[int]struct{}, len(items)) - for _, item := range items { - if item <= 0 { - continue - } - set[item] = struct{}{} - } - if len(set) == 0 { - return nil - } - return set -} - -// intSliceToSectionSet 把节次切片转换为 set,并去除非法节次。 -func intSliceToSectionSet(items []int) map[int]struct{} { - if len(items) == 0 { - return nil - } - set := make(map[int]struct{}, len(items)) - for _, item := range items { - if item < 1 || item > 12 { - continue - } - set[item] = struct{}{} - } - if len(set) == 0 { - return nil - } - return set -} - -// intSliceToIDSet 把正整数 ID 切片转换为 set。 -func intSliceToIDSet(items []int) map[int]struct{} { - if len(items) == 0 { - return nil - } - set := make(map[int]struct{}, len(items)) - for _, item := range items { - if item <= 0 { - continue - } - set[item] = struct{}{} - } - if len(set) == 0 { - return nil - } - return set -} - -// inferWeekBounds 推断查询周区间。 -func inferWeekBounds(entries []model.HybridScheduleEntry, window planningWindow) (int, int) { - if window.Enabled { - return window.StartWeek, window.EndWeek - } - if len(entries) == 0 { - return 1, 1 - } - minWeek, maxWeek := entries[0].Week, entries[0].Week - for _, entry := range entries { - if entry.Week < minWeek { - minWeek = entry.Week - } - if entry.Week > maxWeek { - maxWeek = entry.Week - } - } - return minWeek, maxWeek -} - -// buildWeekIterList 构建周次迭代列表。 -// -// 规则: -// 1. weekFilter 非空时,严格按过滤集合遍历; -// 2. weekFilter 为空时,按 weekFrom~weekTo 连续区间遍历; -// 3. 返回结果升序,便于日志与排查。 -func buildWeekIterList(weekFilter map[int]struct{}, weekFrom, weekTo int) []int { - if len(weekFilter) > 0 { - return keysOfIntSet(weekFilter) - } - if weekFrom <= 0 || weekTo <= 0 || weekFrom > weekTo { - return nil - } - out := make([]int, 0, weekTo-weekFrom+1) - for w := weekFrom; w <= weekTo; w++ { - out = append(out, w) - } - return out -} - -// readIntSlice 读取 int 切片参数,兼容 []any / []int / 单个数值。 -func readIntSlice(params map[string]any, keys ...string) []int { - for _, key := range keys { - raw, ok := params[key] - if !ok { - continue - } - switch v := raw.(type) { - case []int: - out := make([]int, len(v)) - copy(out, v) - return out - case []any: - out := make([]int, 0, len(v)) - for _, item := range v { - switch n := item.(type) { - case int: - out = append(out, n) - case float64: - out = append(out, int(n)) - case string: - if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil { - out = append(out, parsed) - } - } - } - return out - default: - if n, okNum := paramInt(params, key); okNum { - return []int{n} - } - } - } - return nil -} - -// readStringSlice 读取 string 切片参数,兼容 []any / []string / 单个字符串。 -func readStringSlice(params map[string]any, keys ...string) []string { - for _, key := range keys { - raw, ok := params[key] - if !ok || raw == nil { - continue - } - switch vv := raw.(type) { - case []string: - out := make([]string, 0, len(vv)) - for _, item := range vv { - text := strings.TrimSpace(item) - if text != "" { - out = append(out, text) - } - } - return out - case []any: - out := make([]string, 0, len(vv)) - for _, item := range vv { - text := strings.TrimSpace(fmt.Sprintf("%v", item)) - if text != "" { - out = append(out, text) - } - } - return out - case string: - text := strings.TrimSpace(vv) - if text != "" { - return []string{text} - } - default: - text := strings.TrimSpace(fmt.Sprintf("%v", vv)) - if text != "" { - return []string{text} - } - } - } - return nil -} - -// intersectsExcludedSections 判断候选区间是否与排除节次有交集。 -func intersectsExcludedSections(from, to int, excluded map[int]struct{}) bool { - if len(excluded) == 0 { - return false - } - for sec := from; sec <= to; sec++ { - if _, ok := excluded[sec]; ok { - return true - } - } - return false -} - -// keysOfIntSet 返回 int set 的有序键。 -func keysOfIntSet(set map[int]struct{}) []int { - if len(set) == 0 { - return nil - } - keys := make([]int, 0, len(set)) - for k := range set { - keys = append(keys, k) - } - sort.Ints(keys) - return keys -} - -// parseBatchMoveParams 解析 BatchMove 的 moves 参数。 -// -// 步骤化说明: -// 1. 先读取 params["moves"],必须存在且为非空数组; -// 2. 再把数组元素逐条转换成 map[string]any,便于复用 refineToolMove; -// 3. 任一元素类型非法即整体失败,避免“部分可执行、部分不可执行”带来的语义歧义。 -func parseBatchMoveParams(params map[string]any) ([]map[string]any, error) { - rawMoves, ok := params["moves"] - if !ok { - return nil, fmt.Errorf("参数缺失:BatchMove 需要 moves 数组") - } - - var items []any - switch v := rawMoves.(type) { - case []any: - items = v - case []map[string]any: - items = make([]any, 0, len(v)) - for _, item := range v { - items = append(items, item) - } - default: - return nil, fmt.Errorf("参数类型错误:BatchMove 的 moves 必须是数组") - } - if len(items) == 0 { - return nil, fmt.Errorf("参数错误:BatchMove 的 moves 不能为空") - } - - moveParamsList := make([]map[string]any, 0, len(items)) - for idx, item := range items { - paramMap, ok := item.(map[string]any) - if !ok { - return nil, fmt.Errorf("参数类型错误:BatchMove 第%d步不是对象", idx+1) - } - moveParamsList = append(moveParamsList, paramMap) - } - return moveParamsList, nil -} - -// classifyBatchMoveErrorCode 把单步 Move 失败原因映射为 BatchMove 层错误码。 -// -// 说明: -// 1. 映射保持与普通 Move 的错误语义一致,便于模型统一处理; -// 2. 这里按失败文案做轻量推断,避免引入跨文件循环依赖。 -func classifyBatchMoveErrorCode(detail string) string { - text := strings.TrimSpace(detail) - switch { - case strings.Contains(text, "顺序约束不满足"): - return "ORDER_VIOLATION" - case strings.Contains(text, "参数缺失"): - return "PARAM_MISSING" - case strings.Contains(text, "目标时段已被"): - return "SLOT_CONFLICT" - case strings.Contains(text, "任务跨度不一致"): - return "SPAN_MISMATCH" - case strings.Contains(text, "超出允许窗口"): - return "OUT_OF_WINDOW" - case strings.Contains(text, "day_of_week"): - return "DAY_INVALID" - case strings.Contains(text, "节次区间"): - return "SECTION_INVALID" - case strings.Contains(text, "未找到 task_item_id"): - return "TASK_NOT_FOUND" - default: - return "BATCH_MOVE_FAILED" - } -} - -// sortHybridEntries 对混合条目做稳定排序,保证日志与预览输出稳定。 -func sortHybridEntries(entries []model.HybridScheduleEntry) { - sort.SliceStable(entries, func(i, j int) bool { - left := entries[i] - right := entries[j] - if left.Week != right.Week { - return left.Week < right.Week - } - if left.DayOfWeek != right.DayOfWeek { - return left.DayOfWeek < right.DayOfWeek - } - if left.SectionFrom != right.SectionFrom { - return left.SectionFrom < right.SectionFrom - } - if left.SectionTo != right.SectionTo { - return left.SectionTo < right.SectionTo - } - return left.Name < right.Name - }) -} - -// truncate 截断日志内容,避免错误信息无上限增长。 -func truncate(text string, maxLen int) string { - if maxLen <= 0 { - return "" - } - runes := []rune(text) - if len(runes) <= maxLen { - return text - } - return string(runes[:maxLen]) + "..." -} diff --git a/backend/agent2/node/schedule_refine_tool.go b/backend/agent2/node/schedule_refine_tool.go index 3585821..5a4509d 100644 --- a/backend/agent2/node/schedule_refine_tool.go +++ b/backend/agent2/node/schedule_refine_tool.go @@ -1,4 +1,2027 @@ package agentnode -// schedule_refine_tool.go keeps the dual-file layout stable during migration. -// The concrete refine tool implementation remains in schedule_refine_impl for now. +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/LoveLosita/smartflow/backend/logic" + "github.com/LoveLosita/smartflow/backend/model" +) + +// scheduleRefineReactToolCall 表示模型输出的单个工具调用指令。 +type scheduleRefineReactToolCall struct { + Tool string `json:"tool"` + Params map[string]any `json:"params"` +} + +// scheduleRefineReactToolResult 表示工具调用的结构化执行结果。 +type scheduleRefineReactToolResult struct { + Tool string `json:"tool"` + Success bool `json:"success"` + ErrorCode string `json:"error_code,omitempty"` + Result string `json:"result"` +} + +// scheduleRefineReactLLMOutput 表示“强 ReAct”要求的固定 JSON 输出结构。 +// +// 字段语义: +// 1. goal_check:本轮要先验证的目标点; +// 2. decision:本轮动作选择依据; +// 3. tool_calls:本轮工具动作列表(业务侧只取第一条)。 +type scheduleRefineReactLLMOutput struct { + Done bool `json:"done"` + Summary string `json:"summary"` + GoalCheck string `json:"goal_check"` + Decision string `json:"decision"` + MissingInfo []string `json:"missing_info,omitempty"` + ToolCalls []scheduleRefineReactToolCall `json:"tool_calls"` +} + +// scheduleRefineReviewOutput 表示终审节点要求的固定 JSON 输出结构。 +type scheduleRefineReviewOutput struct { + Pass bool `json:"pass"` + Reason string `json:"reason"` + Unmet []string `json:"unmet"` +} + +// scheduleRefinePlanningWindow 表示微调工具允许活动的 week/day 边界窗口。 +// +// 设计说明: +// 1. 这里用已有 HybridEntries 自动推导窗口,避免把任务移动到完全无关的周; +// 2. 若窗口不可用(没有任何 entry),则降级为“仅做基础合法性校验”。 +type scheduleRefinePlanningWindow struct { + Enabled bool + StartWeek int + StartDay int + EndWeek int + EndDay int +} + +// scheduleRefineToolPolicy 是工具层硬约束策略。 +// +// 职责边界: +// 1. 负责承载“是否强制保持相对顺序”的策略开关; +// 2. 负责承载顺序校验需要的 origin_order 映射; +// 3. 不负责语义判定(语义仍由 LLM 终审节点负责)。 +type scheduleRefineToolPolicy struct { + KeepRelativeOrder bool + OrderScope string + OriginOrderMap map[int]int +} + +// dispatchScheduleRefineTool 负责把模型输出的 tool_call 分发到具体工具实现。 +// +// 步骤化说明: +// 1. 先识别工具名并路由到对应实现; +// 2. 工具实现内部负责参数校验、冲突校验、边界校验、顺序校验; +// 3. 任何失败都返回 Success=false 的结构化结果,而不是直接 panic。 +func dispatchScheduleRefineTool(entries []model.HybridScheduleEntry, call scheduleRefineReactToolCall, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + switch strings.TrimSpace(call.Tool) { + case "QueryTargetTasks": + return scheduleRefineToolQueryTargetTasks(entries, call.Params, policy) + case "QueryAvailableSlots": + return scheduleRefineToolQueryAvailableSlots(entries, call.Params, window) + case "Move": + return scheduleRefineToolMove(entries, call.Params, window, policy) + case "Swap": + return scheduleRefineToolSwap(entries, call.Params, window, policy) + case "BatchMove": + return scheduleRefineToolBatchMove(entries, call.Params, window, policy) + case "SpreadEven": + return scheduleRefineToolSpreadEven(entries, call.Params, window, policy) + case "MinContextSwitch": + return scheduleRefineToolMinContextSwitch(entries, call.Params, window, policy) + case "Verify": + return scheduleRefineToolVerify(entries, call.Params, policy) + default: + return entries, scheduleRefineReactToolResult{ + Tool: strings.TrimSpace(call.Tool), + Success: false, + Result: fmt.Sprintf("不支持的工具:%s(仅允许 QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/SpreadEven/MinContextSwitch/Verify)", strings.TrimSpace(call.Tool)), + } + } +} + +// pickSingleScheduleRefineToolCall 在“单步动作”策略下选取一个工具调用。 +// +// 返回语义: +// 1. call=nil:本轮无可执行动作; +// 2. warn 非空:模型返回了多个调用,本轮只执行第一个并记录告警。 +func pickSingleScheduleRefineToolCall(calls []scheduleRefineReactToolCall) (*scheduleRefineReactToolCall, string) { + if len(calls) == 0 { + return nil, "" + } + call := calls[0] + if len(calls) == 1 { + return &call, "" + } + return &call, fmt.Sprintf("模型返回了 %d 个工具调用,本轮仅执行第一个:%s", len(calls), call.Tool) +} + +// parseScheduleRefineLLMOutput 解析模型输出的 ReAct JSON。 +// +// 容错策略: +// 1. 兼容 ```json 代码块包装; +// 2. 兼容 JSON 前后有解释性文字(提取最外层对象)。 +func parseScheduleRefineLLMOutput(raw string) (*scheduleRefineReactLLMOutput, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return nil, fmt.Errorf("ReAct 输出为空") + } + if strings.HasPrefix(clean, "```") { + clean = strings.TrimPrefix(clean, "```json") + clean = strings.TrimPrefix(clean, "```") + clean = strings.TrimSuffix(clean, "```") + clean = strings.TrimSpace(clean) + } + + var out scheduleRefineReactLLMOutput + if err := json.Unmarshal([]byte(clean), &out); err == nil { + return &out, nil + } + obj, objErr := scheduleRefineExtractFirstJSONObject(clean) + if objErr != nil { + return nil, fmt.Errorf("无法从输出中提取 JSON:%s", scheduleRefineTruncate(clean, 220)) + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +// parseScheduleRefineReviewOutput 解析终审评估节点输出。 +func parseScheduleRefineReviewOutput(raw string) (*scheduleRefineReviewOutput, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return nil, fmt.Errorf("review 输出为空") + } + if strings.HasPrefix(clean, "```") { + clean = strings.TrimPrefix(clean, "```json") + clean = strings.TrimPrefix(clean, "```") + clean = strings.TrimSuffix(clean, "```") + clean = strings.TrimSpace(clean) + } + + var out scheduleRefineReviewOutput + if err := json.Unmarshal([]byte(clean), &out); err == nil { + return &out, nil + } + obj, objErr := scheduleRefineExtractFirstJSONObject(clean) + if objErr != nil { + return nil, fmt.Errorf("无法从 review 输出中提取 JSON:%s", scheduleRefineTruncate(clean, 220)) + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +// scheduleRefineToolMove 执行“移动一个 suggested 任务到指定时段”。 +// +// 步骤化说明: +// 1. 先校验参数完整性与目标时段合法性,避免写入脏坐标; +// 2. 再校验原任务是否存在、跨度是否一致(防止任务长度被模型改坏); +// 3. 再校验窗口边界与冲突,确保不会穿透到不可用位置; +// 4. 若启用顺序硬约束,再校验“移动后是否打乱原相对顺序”; +// 5. 全部通过后才真正修改 entries 并返回 Success=true。 +func scheduleRefineToolMove(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + // 0. task_id 兼容策略: + // 0.1 标准键是 task_item_id; + // 0.2 为了兼容模型偶发输出别名 task_id,这里做兜底兼容,避免“语义正确但参数名不一致”导致整轮白跑; + // 0.3 两者都不存在时,仍按参数缺失返回失败,由上层 ReAct 继续下一轮决策。 + taskID, ok := scheduleRefineParamIntAny(params, "task_item_id", "task_id") + if !ok { + return entries, scheduleRefineReactToolResult{Tool: "Move", Success: false, Result: "参数缺失:task_item_id"} + } + // 1. 参数兼容策略: + // 1.1 优先读取标准键(to_week/to_day/...); + // 1.2 若模型输出了历史别名(target_xxx/day_of_week 等),也兼容解析; + // 1.3 目标是减少“仅参数名不一致导致的无效失败轮次”。 + toWeek, okWeek := scheduleRefineParamIntAny(params, "to_week", "target_week", "new_week", "week") + toDay, okDay := scheduleRefineParamIntAny(params, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day") + toSF, okSF := scheduleRefineParamIntAny(params, "to_section_from", "target_section_from", "new_section_from", "section_from") + toST, okST := scheduleRefineParamIntAny(params, "to_section_to", "target_section_to", "new_section_to", "section_to") + if !okWeek || !okDay || !okSF || !okST { + return entries, scheduleRefineReactToolResult{ + Tool: "Move", + Success: false, + Result: "参数缺失:需要 to_week/to_day/to_section_from/to_section_to", + } + } + if toDay < 1 || toDay > 7 { + return entries, scheduleRefineReactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 非法,必须在 1~7", toDay)} + } + if toSF < 1 || toST > 12 || toSF > toST { + return entries, scheduleRefineReactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次区间 %d-%d 非法", toSF, toST)} + } + allowEmbed := scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + + idx, locateErr := scheduleRefineFindUniqueSuggestedByID(entries, taskID) + if locateErr != nil { + return entries, scheduleRefineReactToolResult{Tool: "Move", Success: false, Result: locateErr.Error()} + } + origSpan := entries[idx].SectionTo - entries[idx].SectionFrom + newSpan := toST - toSF + if origSpan != newSpan { + return entries, scheduleRefineReactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("任务跨度不一致:原跨度=%d,目标跨度=%d", origSpan+1, newSpan+1), + } + } + + if !scheduleRefineIsWithinWindow(window, toWeek, toDay) { + return entries, scheduleRefineReactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("目标 W%dD%d 超出允许窗口", toWeek, toDay), + } + } + + if conflict, name := scheduleRefineHasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}, allowEmbed); conflict { + return entries, scheduleRefineReactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("目标时段已被 %s 占用", name), + } + } + + beforeEntries := cloneHybridEntries(entries) + entry := &entries[idx] + before := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo) + entry.Week = toWeek + entry.DayOfWeek = toDay + entry.SectionFrom = toSF + entry.SectionTo = toST + after := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo) + + scheduleRefineSortHybridEntries(entries) + if issues := scheduleRefineValidateRelativeOrder(entries, policy); len(issues) > 0 { + return beforeEntries, scheduleRefineReactToolResult{ + Tool: "Move", + Success: false, + Result: "顺序约束不满足:" + strings.Join(issues, ";"), + } + } + + return entries, scheduleRefineReactToolResult{ + Tool: "Move", + Success: true, + Result: fmt.Sprintf("已将任务[%s](id=%d,type=%s,status=%s) 从 %s 移动到 %s", entry.Name, taskID, strings.TrimSpace(entry.Type), strings.TrimSpace(entry.Status), before, after), + } +} + +// scheduleRefineToolSwap 执行“交换两个 suggested 任务的位置”。 +// +// 步骤化说明: +// 1. 先校验两端 task_item_id; +// 2. 再双向验证交换后的落点是否与其他条目冲突; +// 3. 若启用顺序硬约束,再校验“交换后是否打乱相对顺序”; +// 4. 校验通过后提交交换并返回成功。 +func scheduleRefineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + // 1. 参数兼容策略同 Move: + // 1.1 兼容 task_a/task_b 与 task_item_a/task_item_b 等常见别名; + // 1.2 目标是减少模型输出字段差异导致的无效失败。 + idA, okA := scheduleRefineParamIntAny(params, "task_a", "task_item_a", "task_item_id_a") + idB, okB := scheduleRefineParamIntAny(params, "task_b", "task_item_b", "task_item_id_b") + if !okA || !okB { + return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:task_a/task_b"} + } + allowEmbed := scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + if idA == idB { + return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: "task_a 与 task_b 不能相同"} + } + + idxA, errA := scheduleRefineFindUniqueSuggestedByID(entries, idA) + if errA != nil { + return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: errA.Error()} + } + idxB, errB := scheduleRefineFindUniqueSuggestedByID(entries, idB) + if errB != nil { + return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: errB.Error()} + } + + a := entries[idxA] + b := entries[idxB] + if !scheduleRefineIsWithinWindow(window, b.Week, b.DayOfWeek) || !scheduleRefineIsWithinWindow(window, a.Week, a.DayOfWeek) { + return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: "交换目标超出允许窗口"} + } + + excludes := map[int]bool{idxA: true, idxB: true} + if conflict, name := scheduleRefineHasConflict(entries, b.Week, b.DayOfWeek, b.SectionFrom, b.SectionTo, excludes, allowEmbed); conflict { + return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务A交换后将与 %s 冲突", name)} + } + if conflict, name := scheduleRefineHasConflict(entries, a.Week, a.DayOfWeek, a.SectionFrom, a.SectionTo, excludes, allowEmbed); conflict { + return entries, scheduleRefineReactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务B交换后将与 %s 冲突", name)} + } + + beforeEntries := cloneHybridEntries(entries) + entries[idxA].Week, entries[idxB].Week = entries[idxB].Week, entries[idxA].Week + entries[idxA].DayOfWeek, entries[idxB].DayOfWeek = entries[idxB].DayOfWeek, entries[idxA].DayOfWeek + entries[idxA].SectionFrom, entries[idxB].SectionFrom = entries[idxB].SectionFrom, entries[idxA].SectionFrom + entries[idxA].SectionTo, entries[idxB].SectionTo = entries[idxB].SectionTo, entries[idxA].SectionTo + + scheduleRefineSortHybridEntries(entries) + if issues := scheduleRefineValidateRelativeOrder(entries, policy); len(issues) > 0 { + return beforeEntries, scheduleRefineReactToolResult{ + Tool: "Swap", + Success: false, + Result: "顺序约束不满足:" + strings.Join(issues, ";"), + } + } + + return entries, scheduleRefineReactToolResult{ + Tool: "Swap", + Success: true, + Result: fmt.Sprintf("已交换任务 id=%d 与 id=%d 的时段", idA, idB), + } +} + +// scheduleRefineToolBatchMove 执行“原子批量移动 suggested 任务”。 +// +// 步骤化说明: +// 1. 参数要求:params.moves 必须是数组,每个元素都满足 Move 的参数格式; +// 2. 执行策略:在 working 副本上按顺序逐条执行 Move; +// 3. 原子语义:任一步失败,整批回滚(返回原 entries);全部成功才一次性提交; +// 4. 适用场景:用户明确希望“同一轮挪多个任务”,减少 ReAct 往返轮次。 +func scheduleRefineToolBatchMove(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + moveParamsList, parseErr := scheduleRefineParseBatchMoveParams(params) + if parseErr != nil { + return entries, scheduleRefineReactToolResult{ + Tool: "BatchMove", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: parseErr.Error(), + } + } + // 2. 批级 allow_embed 默认值: + // 2.1 如果子动作未显式声明 allow_embed/allow_embedding,则继承批级开关; + // 2.2 默认 true,和 Move/Swap 一致:允许嵌入,但由 QueryAvailableSlots 先给纯空位。 + batchAllowEmbed := scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + for i := range moveParamsList { + if _, ok := moveParamsList[i]["allow_embed"]; ok { + continue + } + if _, ok := moveParamsList[i]["allow_embedding"]; ok { + continue + } + moveParamsList[i]["allow_embed"] = batchAllowEmbed + } + + // 1. 在副本上执行,保证原子性: + // 1.1 每一步都复用 scheduleRefineToolMove 的全部校验逻辑(冲突、窗口、顺序、跨度); + // 1.2 只要任一步失败就中止并回滚到原 entries; + // 1.3 全部成功后再返回 working,作为整批提交结果。 + working := cloneHybridEntries(entries) + stepSummary := make([]string, 0, len(moveParamsList)) + currentWindow := scheduleRefineBuildPlanningWindowFromEntries(working) + if !currentWindow.Enabled { + currentWindow = window + } + for idx, moveParams := range moveParamsList { + nextEntries, stepResult := scheduleRefineToolMove(working, moveParams, currentWindow, policy) + if !stepResult.Success { + return entries, scheduleRefineReactToolResult{ + Tool: "BatchMove", + Success: false, + ErrorCode: scheduleRefineClassifyBatchMoveErrorCode(stepResult.Result), + Result: fmt.Sprintf("BatchMove 第%d步失败:%s", idx+1, stepResult.Result), + } + } + working = nextEntries + currentWindow = scheduleRefineBuildPlanningWindowFromEntries(working) + stepSummary = append(stepSummary, fmt.Sprintf("第%d步:%s", idx+1, scheduleRefineTruncate(stepResult.Result, 120))) + } + + return working, scheduleRefineReactToolResult{ + Tool: "BatchMove", + Success: true, + Result: fmt.Sprintf("BatchMove 原子提交成功,共执行%d步。%s", len(moveParamsList), strings.Join(stepSummary, " | ")), + } +} + +type scheduleRefineCompositePlannerFn func( + tasks []logic.RefineTaskCandidate, + slots []logic.RefineSlotCandidate, + options logic.RefineCompositePlanOptions, +) ([]logic.RefineMovePlanItem, error) + +// scheduleRefineToolSpreadEven 执行“均匀铺开”复合动作。 +// +// 职责边界: +// 1. 负责参数解析、候选收集、调用确定性规划器; +// 2. 不直接改写 entries,统一通过 BatchMove 原子落地; +// 3. 规划算法实现位于 logic 包,工具层只负责编排。 +func scheduleRefineToolSpreadEven(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + return scheduleRefineToolCompositeMove(entries, params, window, policy, "SpreadEven", logic.PlanEvenSpreadMoves) +} + +// scheduleRefineToolMinContextSwitch 执行“最少上下文切换”复合动作。 +// +// 职责边界: +// 1. 负责锁定“当前任务已占坑位集合”,避免为了聚类把任务远距离迁移; +// 2. 负责在固定坑位集合内调用确定性规划器,只重排“任务 -> 坑位”的映射; +// 3. 不直接改写 entries,统一通过 BatchMove 原子落地。 +func scheduleRefineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + taskIDs := scheduleRefineCollectCompositeTaskIDs(params) + if len(taskIDs) == 0 { + return entries, scheduleRefineReactToolResult{ + Tool: "MinContextSwitch", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", + } + } + tasks, taskResult, ok := scheduleRefineCollectCompositeTasks(entries, taskIDs, policy, "MinContextSwitch") + if !ok { + return entries, taskResult + } + + // 1. MinContextSwitch 的产品语义是“尽量少切换,同时尽量少折腾坑位”; + // 2. 因此这里不再查询整周新坑位,而是直接复用当前任务已占据的坑位集合; + // 3. 这样最终只会发生“任务之间互换位置”,不会跳到用户意料之外的远处时段。 + currentSlots := scheduleRefineBuildCompositeCurrentTaskSlots(tasks) + plannedMoves, planErr := logic.PlanMinContextSwitchMoves(tasks, currentSlots, logic.RefineCompositePlanOptions{}) + if planErr != nil { + return entries, scheduleRefineReactToolResult{ + Tool: "MinContextSwitch", + Success: false, + ErrorCode: "PLAN_FAILED", + Result: planErr.Error(), + } + } + return scheduleRefineApplyFixedSlotCompositeMoves(entries, policy, "MinContextSwitch", plannedMoves) +} + +// scheduleRefineToolCompositeMove 是复合动作工具的统一执行框架。 +// +// 步骤化说明: +// 1. 先解析“目标任务集合”,确保任务来源明确且可唯一落到 task_item_id; +// 2. 再按任务跨度查询候选坑位,避免跨度不一致导致执行期失败; +// 3. 调用 logic 包的确定性规划函数,得到 moves; +// 4. 最后复用 BatchMove 原子提交,任一步失败整批回滚。 +func scheduleRefineToolCompositeMove( + entries []model.HybridScheduleEntry, + params map[string]any, + window scheduleRefinePlanningWindow, + policy scheduleRefineToolPolicy, + toolName string, + planner scheduleRefineCompositePlannerFn, +) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + taskIDs := scheduleRefineCollectCompositeTaskIDs(params) + if len(taskIDs) == 0 { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PARAM_MISSING", + Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", + } + } + tasks, taskResult, ok := scheduleRefineCollectCompositeTasks(entries, taskIDs, policy, toolName) + if !ok { + return entries, taskResult + } + idSet := scheduleRefineIntSliceToIDSet(taskIDs) + spanNeed := scheduleRefineBuildCompositeSpanNeed(tasks) + + slots, slotErr := scheduleRefineCollectCompositeSlotsBySpan(entries, params, window, spanNeed) + if slotErr != nil { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "SLOT_QUERY_FAILED", + Result: slotErr.Error(), + } + } + options := logic.RefineCompositePlanOptions{ + ExistingDayLoad: scheduleRefineBuildCompositeDayLoadBaseline(entries, idSet, slots), + } + plannedMoves, planErr := planner(tasks, slots, options) + if planErr != nil { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PLAN_FAILED", + Result: planErr.Error(), + } + } + return scheduleRefineApplyCompositePlannedMoves(entries, params, window, policy, toolName, plannedMoves) +} + +// scheduleRefineCollectCompositeTasks 收集复合动作参与的可移动任务,并做唯一性校验。 +// +// 步骤化说明: +// 1. 只收 suggested 且可移动的 task,避免误改 existing/course; +// 2. task_item_id 必须一一命中,命中多条或缺失都直接失败; +// 3. 输出顺序保持 entries 原始遍历顺序,后续再由规划器做稳定排序。 +func scheduleRefineCollectCompositeTasks(entries []model.HybridScheduleEntry, taskIDs []int, policy scheduleRefineToolPolicy, toolName string) ([]logic.RefineTaskCandidate, scheduleRefineReactToolResult, bool) { + idSet := scheduleRefineIntSliceToIDSet(taskIDs) + tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs)) + found := make(map[int]struct{}, len(taskIDs)) + for _, entry := range entries { + if !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + if _, ok := idSet[entry.TaskItemID]; !ok { + continue + } + if _, duplicated := found[entry.TaskItemID]; duplicated { + return nil, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_ID_AMBIGUOUS", + Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID), + }, false + } + found[entry.TaskItemID] = struct{}{} + tasks = append(tasks, logic.RefineTaskCandidate{ + TaskItemID: entry.TaskItemID, + Week: entry.Week, + DayOfWeek: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + SectionTo: entry.SectionTo, + Name: strings.TrimSpace(entry.Name), + ContextTag: strings.TrimSpace(entry.ContextTag), + OriginRank: policy.OriginOrderMap[entry.TaskItemID], + }) + } + if len(tasks) != len(taskIDs) { + missing := make([]int, 0, len(taskIDs)) + for _, id := range taskIDs { + if _, ok := found[id]; !ok { + missing = append(missing, id) + } + } + return nil, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_NOT_FOUND", + Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing), + }, false + } + return tasks, scheduleRefineReactToolResult{}, true +} + +func scheduleRefineBuildCompositeSpanNeed(tasks []logic.RefineTaskCandidate) map[int]int { + spanNeed := make(map[int]int, len(tasks)) + for _, task := range tasks { + spanNeed[task.SectionTo-task.SectionFrom+1]++ + } + return spanNeed +} + +func scheduleRefineBuildCompositeCurrentTaskSlots(tasks []logic.RefineTaskCandidate) []logic.RefineSlotCandidate { + slots := make([]logic.RefineSlotCandidate, 0, len(tasks)) + for _, task := range tasks { + slots = append(slots, logic.RefineSlotCandidate{ + Week: task.Week, + DayOfWeek: task.DayOfWeek, + SectionFrom: task.SectionFrom, + SectionTo: task.SectionTo, + }) + } + return slots +} + +func scheduleRefineApplyCompositePlannedMoves( + entries []model.HybridScheduleEntry, + params map[string]any, + window scheduleRefinePlanningWindow, + policy scheduleRefineToolPolicy, + toolName string, + plannedMoves []logic.RefineMovePlanItem, +) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + if len(plannedMoves) == 0 { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PLAN_EMPTY", + Result: "规划结果为空:未生成任何可执行移动", + } + } + + moveParams := make([]any, 0, len(plannedMoves)) + for _, move := range plannedMoves { + moveParams = append(moveParams, map[string]any{ + "task_item_id": move.TaskItemID, + "to_week": move.ToWeek, + "to_day": move.ToDay, + "to_section_from": move.ToSectionFrom, + "to_section_to": move.ToSectionTo, + }) + } + batchParams := map[string]any{ + "moves": moveParams, + "allow_embed": scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding"), + } + nextEntries, batchResult := scheduleRefineToolBatchMove(entries, batchParams, window, policy) + if !batchResult.Success { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: batchResult.ErrorCode, + Result: fmt.Sprintf("%s 执行失败:%s", toolName, batchResult.Result), + } + } + return nextEntries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: true, + Result: fmt.Sprintf("%s 执行成功:已规划并提交 %d 条移动。", toolName, len(plannedMoves)), + } +} + +// scheduleRefineApplyFixedSlotCompositeMoves 以“同时改写坐标”的方式提交固定坑位重排结果。 +// +// 步骤化说明: +// 1. 该函数专门服务“坑位集合固定”的复合工具,避免 BatchMove 顺序执行时出现互相占位冲突; +// 2. 先在副本上一次性改写所有目标任务的坐标,再统一排序与校验; +// 3. 若发现目标坑位重复、任务缺失、或顺序约束不满足,则整批失败并回滚。 +func scheduleRefineApplyFixedSlotCompositeMoves( + entries []model.HybridScheduleEntry, + policy scheduleRefineToolPolicy, + toolName string, + plannedMoves []logic.RefineMovePlanItem, +) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + if len(plannedMoves) == 0 { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PLAN_EMPTY", + Result: "规划结果为空:未生成任何可执行移动", + } + } + + working := cloneHybridEntries(entries) + indexByTaskID := make(map[int]int, len(working)) + for idx, entry := range working { + if !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + if _, exists := indexByTaskID[entry.TaskItemID]; exists { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_ID_AMBIGUOUS", + Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 命中多条可移动 suggested 任务", toolName, entry.TaskItemID), + } + } + indexByTaskID[entry.TaskItemID] = idx + } + + targetSeen := make(map[string]int, len(plannedMoves)) + for _, move := range plannedMoves { + if _, ok := indexByTaskID[move.TaskItemID]; !ok { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_NOT_FOUND", + Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 未找到可移动 suggested 任务", toolName, move.TaskItemID), + } + } + key := fmt.Sprintf("%d-%d-%d-%d", move.ToWeek, move.ToDay, move.ToSectionFrom, move.ToSectionTo) + if prevID, exists := targetSeen[key]; exists { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PLAN_CONFLICT", + Result: fmt.Sprintf("%s 执行失败:任务 id=%d 与 id=%d 目标坑位重复", toolName, prevID, move.TaskItemID), + } + } + targetSeen[key] = move.TaskItemID + } + + for _, move := range plannedMoves { + idx := indexByTaskID[move.TaskItemID] + working[idx].Week = move.ToWeek + working[idx].DayOfWeek = move.ToDay + working[idx].SectionFrom = move.ToSectionFrom + working[idx].SectionTo = move.ToSectionTo + } + scheduleRefineSortHybridEntries(working) + if issues := scheduleRefineValidateRelativeOrder(working, policy); len(issues) > 0 { + return entries, scheduleRefineReactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "ORDER_CONSTRAINT_VIOLATED", + Result: "顺序约束不满足:" + strings.Join(issues, ";"), + } + } + return working, scheduleRefineReactToolResult{ + Tool: toolName, + Success: true, + Result: fmt.Sprintf("%s 执行成功:已在固定坑位集合内重排 %d 条任务。", toolName, len(plannedMoves)), + } +} + +func scheduleRefineCollectCompositeTaskIDs(params map[string]any) []int { + ids := scheduleRefineReadIntSlice(params, "task_item_ids", "task_ids") + if id, ok := scheduleRefineParamIntAny(params, "task_item_id", "task_id"); ok { + ids = append(ids, id) + } + return scheduleRefineUniquePositiveInts(ids) +} + +func scheduleRefineCollectCompositeSlotsBySpan( + entries []model.HybridScheduleEntry, + params map[string]any, + window scheduleRefinePlanningWindow, + spanNeed map[int]int, +) ([]logic.RefineSlotCandidate, error) { + if len(spanNeed) == 0 { + return nil, fmt.Errorf("未识别到任务跨度需求") + } + + spans := make([]int, 0, len(spanNeed)) + for span := range spanNeed { + spans = append(spans, span) + } + sort.Ints(spans) + + allSlots := make([]logic.RefineSlotCandidate, 0, 16) + for _, span := range spans { + required := spanNeed[span] + queryParams := scheduleRefineBuildCompositeSlotQueryParams(params, span, required) + _, queryResult := scheduleRefineToolQueryAvailableSlots(entries, queryParams, window) + if !queryResult.Success { + return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, queryResult.Result) + } + + var payload struct { + Slots []struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + } `json:"slots"` + } + if err := json.Unmarshal([]byte(queryResult.Result), &payload); err != nil { + return nil, fmt.Errorf("解析跨度=%d 的空位结果失败:%v", span, err) + } + if len(payload.Slots) < required { + return nil, fmt.Errorf("跨度=%d 可用坑位不足:required=%d, got=%d", span, required, len(payload.Slots)) + } + for _, slot := range payload.Slots { + allSlots = append(allSlots, logic.RefineSlotCandidate{ + Week: slot.Week, + DayOfWeek: slot.DayOfWeek, + SectionFrom: slot.SectionFrom, + SectionTo: slot.SectionTo, + }) + } + } + return allSlots, nil +} + +func scheduleRefineBuildCompositeSlotQueryParams(params map[string]any, span int, required int) map[string]any { + query := make(map[string]any, 12) + query["span"] = span + + // 1. limit 以“任务数 * 兜底系数”估算,给规划器保留可选空间; + // 2. 若调用方显式给了 limit,则采用更大的那个,避免被过小 limit 限死。 + limit := required * 6 + if limit < required { + limit = required + } + if customLimit, ok := scheduleRefineParamIntAny(params, "limit"); ok && customLimit > limit { + limit = customLimit + } + query["limit"] = limit + query["allow_embed"] = scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + + for _, key := range []string{"week", "week_from", "week_to", "day_scope", "after_section", "before_section"} { + if value, ok := params[key]; ok { + query[key] = value + } + } + + // 1. 复合路由主链路自身使用的是 week_filter/day_of_week/exclude_sections; + // 2. 这里必须优先透传这些“规范键”,再兼容历史别名; + // 3. 否则会出现复合工具已被调用,但内部查坑位时丢失目标范围,导致规划结果漂移。 + scheduleRefineCopyIntSliceParam(params, query, "week_filter", "week_filter", "weeks") + scheduleRefineCopyIntSliceParam(params, query, "day_of_week", "day_of_week", "days", "day_filter") + scheduleRefineCopyIntSliceParam(params, query, "exclude_sections", "exclude_sections", "exclude_section") + + // 兼容 Move 风格别名,降低模型参数名漂移导致的失败。 + if week, ok := scheduleRefineParamIntAny(params, "to_week", "target_week", "new_week"); ok { + query["week"] = week + } + if day, ok := scheduleRefineParamIntAny(params, "to_day", "target_day", "target_day_of_week", "new_day", "day"); ok { + query["day_of_week"] = []int{day} + } + return query +} + +func scheduleRefineCopyIntSliceParam(src map[string]any, dst map[string]any, dstKey string, srcKeys ...string) { + values := scheduleRefineReadIntSlice(src, srcKeys...) + if len(values) == 0 { + return + } + normalized := scheduleRefineUniquePositiveInts(values) + if len(normalized) == 0 { + return + } + dst[dstKey] = normalized +} + +func scheduleRefineBuildCompositeDayLoadBaseline( + entries []model.HybridScheduleEntry, + excludeTaskIDs map[int]struct{}, + slots []logic.RefineSlotCandidate, +) map[string]int { + if len(slots) == 0 { + return nil + } + targetDays := make(map[string]struct{}, len(slots)) + for _, slot := range slots { + targetDays[fmt.Sprintf("%d-%d", slot.Week, slot.DayOfWeek)] = struct{}{} + } + + load := make(map[string]int, len(targetDays)) + for _, entry := range entries { + if !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + if _, excluded := excludeTaskIDs[entry.TaskItemID]; excluded { + continue + } + key := fmt.Sprintf("%d-%d", entry.Week, entry.DayOfWeek) + if _, inTarget := targetDays[key]; !inTarget { + continue + } + load[key]++ + } + return load +} + +// scheduleRefineToolQueryTargetTasks 查询“本轮潜在目标任务集合”。 +// +// 步骤化说明: +// 1. 支持按 day_scope(weekend/workday/all)、week 范围、limit 过滤; +// 2. 只读查询,不修改 entries; +// 3. 返回结构化 JSON 字符串,供下一轮模型直接消费。 +func scheduleRefineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[string]any, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + scope := scheduleRefineNormalizeDayScope(scheduleRefineReadString(params, "day_scope", "all")) + statusFilter := scheduleRefineNormalizeStatusFilter(scheduleRefineReadString(params, "status", "suggested")) + weekFilter := scheduleRefineIntSliceToWeekSet(scheduleRefineReadIntSlice(params, "week_filter", "weeks")) + weekFrom, hasWeekFrom := scheduleRefineParamIntAny(params, "week_from", "from_week") + weekTo, hasWeekTo := scheduleRefineParamIntAny(params, "week_to", "to_week") + if week, hasWeek := scheduleRefineParamIntAny(params, "week"); hasWeek { + weekFrom, weekTo = week, week + hasWeekFrom, hasWeekTo = true, true + } + if hasWeekFrom && hasWeekTo && weekFrom > weekTo { + weekFrom, weekTo = weekTo, weekFrom + } + if !hasWeekFrom || !hasWeekTo { + startWeek, endWeek := scheduleRefineInferWeekBounds(entries, scheduleRefinePlanningWindow{Enabled: false}) + if !hasWeekFrom { + weekFrom = startWeek + } + if !hasWeekTo { + weekTo = endWeek + } + } + limit, okLimit := scheduleRefineParamIntAny(params, "limit") + if !okLimit || limit <= 0 { + limit = 16 + } + dayFilter := scheduleRefineIntSliceToDaySet(scheduleRefineReadIntSlice(params, "day_of_week", "days", "day_filter")) + taskIDs := scheduleRefineReadIntSlice(params, "task_item_ids", "task_ids") + if taskID, ok := scheduleRefineParamIntAny(params, "task_item_id", "task_id"); ok { + taskIDs = append(taskIDs, taskID) + } + taskIDSet := scheduleRefineIntSliceToIDSet(taskIDs) + + type targetTask struct { + TaskItemID int `json:"task_item_id"` + Name string `json:"name"` + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + OriginRank int `json:"origin_rank,omitempty"` + ContextTag string `json:"context_tag,omitempty"` + CurrentState string `json:"status"` + } + + list := make([]targetTask, 0, 32) + for _, entry := range entries { + if !scheduleRefineMatchStatusFilter(entry.Status, statusFilter) { + continue + } + // suggested 视图只允许看到“可移动任务”,避免把课程类条目当成可调任务暴露给模型。 + if statusFilter == "suggested" && !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + if entry.TaskItemID <= 0 { + continue + } + if len(taskIDSet) > 0 { + if _, ok := taskIDSet[entry.TaskItemID]; !ok { + continue + } + } + if len(dayFilter) > 0 { + if _, ok := dayFilter[entry.DayOfWeek]; !ok { + continue + } + } else if !scheduleRefineMatchDayScope(entry.DayOfWeek, scope) { + continue + } + if len(weekFilter) > 0 { + if _, ok := weekFilter[entry.Week]; !ok { + continue + } + } + if hasWeekFrom && entry.Week < weekFrom { + continue + } + if hasWeekTo && entry.Week > weekTo { + continue + } + list = append(list, targetTask{ + TaskItemID: entry.TaskItemID, + Name: strings.TrimSpace(entry.Name), + Week: entry.Week, + DayOfWeek: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + SectionTo: entry.SectionTo, + OriginRank: policy.OriginOrderMap[entry.TaskItemID], + ContextTag: strings.TrimSpace(entry.ContextTag), + CurrentState: entry.Status, + }) + } + sort.SliceStable(list, func(i, j int) bool { + if list[i].Week != list[j].Week { + return list[i].Week < list[j].Week + } + if list[i].DayOfWeek != list[j].DayOfWeek { + return list[i].DayOfWeek < list[j].DayOfWeek + } + if list[i].SectionFrom != list[j].SectionFrom { + return list[i].SectionFrom < list[j].SectionFrom + } + return list[i].TaskItemID < list[j].TaskItemID + }) + if len(list) > limit { + list = list[:limit] + } + + payload := map[string]any{ + "tool": "QueryTargetTasks", + "count": len(list), + "status": statusFilter, + "day_scope": scope, + "week_filter": scheduleRefineKeysOfIntSet(weekFilter), + "week_from": weekFrom, + "week_to": weekTo, + "day_of_week": scheduleRefineKeysOfIntSet(dayFilter), + "items": list, + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, scheduleRefineReactToolResult{ + Tool: "QueryTargetTasks", + Success: false, + ErrorCode: "QUERY_ENCODE_FAILED", + Result: fmt.Sprintf("序列化查询结果失败:%v", err), + } + } + return entries, scheduleRefineReactToolResult{ + Tool: "QueryTargetTasks", + Success: true, + Result: string(raw), + } +} + +// scheduleRefineToolQueryAvailableSlots 查询“可放置 suggested 的空位”。 +// +// 步骤化说明: +// 1. 根据 day_scope/week 范围/span/exclude_sections 过滤候选时段; +// 2. 默认先收集“纯空位”,不足 limit 再补“可嵌入课程位”(第二优先级); +// 3. 返回结构化 JSON 字符串,不修改 entries。 +func scheduleRefineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any, window scheduleRefinePlanningWindow) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + scope := scheduleRefineNormalizeDayScope(scheduleRefineReadString(params, "day_scope", "all")) + dayFilter := scheduleRefineIntSliceToDaySet(scheduleRefineReadIntSlice(params, "day_of_week", "days", "day_filter")) + weekFilter := scheduleRefineIntSliceToWeekSet(scheduleRefineReadIntSlice(params, "week_filter", "weeks")) + // 1. 空位优先策略: + // 1.1 默认 allow_embed=true,但查询分两阶段执行; + // 1.2 第一阶段只收集“纯空白位”(不与 existing 重叠); + // 1.3 第二阶段仅在空白位不足 limit 时,补充“可嵌入课程位”。 + allowEmbed := scheduleRefineParamBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + // 1.4 兼容 slot_type/slot_types: + // 1.4.1 当明确请求 pure/empty/strict 时,强制只查纯空位(关闭嵌入候选)。 + // 1.4.2 当未声明时,维持“空位优先,空位不足再补嵌入候选”的默认策略。 + slotTypeHints := scheduleRefineReadStringSlice(params, "slot_types") + if single := strings.TrimSpace(scheduleRefineReadString(params, "slot_type", "")); single != "" { + slotTypeHints = append(slotTypeHints, single) + } + for _, hint := range slotTypeHints { + normalized := strings.ToLower(strings.TrimSpace(hint)) + if normalized == "pure" || normalized == "empty" || normalized == "strict" { + allowEmbed = false + break + } + } + span, okSpan := scheduleRefineParamIntAny(params, "span", "section_duration", "task_duration") + if !okSpan || span <= 0 { + span = 2 + } + if span > 12 { + return entries, scheduleRefineReactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "SPAN_INVALID", + Result: fmt.Sprintf("span=%d 非法,必须在 1~12", span), + } + } + limit, okLimit := scheduleRefineParamIntAny(params, "limit") + if !okLimit || limit <= 0 { + limit = 12 + } + + weekFrom, hasWeekFrom := scheduleRefineParamIntAny(params, "week_from", "from_week") + weekTo, hasWeekTo := scheduleRefineParamIntAny(params, "week_to", "to_week") + if week, hasWeek := scheduleRefineParamIntAny(params, "week"); hasWeek { + weekFrom, weekTo = week, week + hasWeekFrom, hasWeekTo = true, true + } + if hasWeekFrom && hasWeekTo && weekFrom > weekTo { + weekFrom, weekTo = weekTo, weekFrom + } + if !hasWeekFrom || !hasWeekTo { + startWeek, endWeek := scheduleRefineInferWeekBounds(entries, window) + if !hasWeekFrom { + weekFrom = startWeek + } + if !hasWeekTo { + weekTo = endWeek + } + } + weeksToIterate := scheduleRefineBuildWeekIterList(weekFilter, weekFrom, weekTo) + if len(weeksToIterate) == 0 { + return entries, scheduleRefineReactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: "周范围为空:请提供 week / week_filter 或确保排程窗口有效", + } + } + weekFrom = weeksToIterate[0] + weekTo = weeksToIterate[len(weeksToIterate)-1] + + excludedSet := make(map[int]struct{}) + for _, sec := range scheduleRefineReadIntSlice(params, "exclude_sections", "exclude_section") { + if sec >= 1 && sec <= 12 { + excludedSet[sec] = struct{}{} + } + } + afterSection, hasAfter := scheduleRefineParamIntAny(params, "after_section") + beforeSection, hasBefore := scheduleRefineParamIntAny(params, "before_section") + exactSectionFrom, hasExactFrom := scheduleRefineParamIntAny(params, "section_from", "target_section_from") + exactSectionTo, hasExactTo := scheduleRefineParamIntAny(params, "section_to", "target_section_to") + if hasExactFrom != hasExactTo { + return entries, scheduleRefineReactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: "精确节次查询需同时提供 section_from 和 section_to", + } + } + if hasExactFrom { + if exactSectionFrom < 1 || exactSectionTo > 12 || exactSectionFrom > exactSectionTo { + return entries, scheduleRefineReactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "SPAN_INVALID", + Result: fmt.Sprintf("精确节次区间非法:%d-%d", exactSectionFrom, exactSectionTo), + } + } + span = exactSectionTo - exactSectionFrom + 1 + } + + type slot struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + SlotType string `json:"slot_type,omitempty"` + } + slots := make([]slot, 0, limit) + seen := make(map[string]struct{}, limit*2) + strictCount := 0 + collect := func(embedAllowed bool, slotType string) { + if len(slots) >= limit { + return + } + for _, week := range weeksToIterate { + for day := 1; day <= 7; day++ { + if len(dayFilter) > 0 { + if _, ok := dayFilter[day]; !ok { + continue + } + } else if !scheduleRefineMatchDayScope(day, scope) { + continue + } + if !scheduleRefineIsWithinWindow(window, week, day) { + continue + } + for sf := 1; sf+span-1 <= 12; sf++ { + st := sf + span - 1 + if hasExactFrom && (sf != exactSectionFrom || st != exactSectionTo) { + continue + } + if hasAfter && sf <= afterSection { + continue + } + if hasBefore && st >= beforeSection { + continue + } + if scheduleRefineIntersectsExcludedSections(sf, st, excludedSet) { + continue + } + if conflict, _ := scheduleRefineHasConflict(entries, week, day, sf, st, nil, embedAllowed); conflict { + continue + } + key := fmt.Sprintf("%d-%d-%d-%d", week, day, sf, st) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + slots = append(slots, slot{ + Week: week, + DayOfWeek: day, + SectionFrom: sf, + SectionTo: st, + SlotType: slotType, + }) + if len(slots) >= limit { + return + } + } + } + } + } + collect(false, "empty") + strictCount = len(slots) + if allowEmbed && len(slots) < limit { + collect(true, "embedded_candidate") + } + embeddedCount := len(slots) - strictCount + + payload := map[string]any{ + "tool": "QueryAvailableSlots", + "count": len(slots), + "strict_count": strictCount, + "embedded_count": embeddedCount, + "fallback_used": embeddedCount > 0, + "day_scope": scope, + "day_of_week": scheduleRefineKeysOfIntSet(dayFilter), + "week_filter": scheduleRefineKeysOfIntSet(weekFilter), + "week_from": weekFrom, + "week_to": weekTo, + "span": span, + "allow_embed": allowEmbed, + "exclude_sections": scheduleRefineKeysOfIntSet(excludedSet), + "slots": slots, + } + if hasAfter { + payload["after_section"] = afterSection + } + if hasBefore { + payload["before_section"] = beforeSection + } + if hasExactFrom { + payload["section_from"] = exactSectionFrom + payload["section_to"] = exactSectionTo + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, scheduleRefineReactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "QUERY_ENCODE_FAILED", + Result: fmt.Sprintf("序列化空位结果失败:%v", err), + } + } + return entries, scheduleRefineReactToolResult{ + Tool: "QueryAvailableSlots", + Success: true, + Result: string(raw), + } +} + +// scheduleRefineToolVerify 进行“轻量确定性自检”。 +// +// 说明: +// 1. 当前只做 deterministic 校验(冲突/顺序),不做语义 LLM 终审; +// 2. 语义层终审仍在 hard_check 节点统一处理; +// 3. 该工具用于给执行阶段一个“可提前自查”的信号。 +func scheduleRefineToolVerify(entries []model.HybridScheduleEntry, params map[string]any, policy scheduleRefineToolPolicy) ([]model.HybridScheduleEntry, scheduleRefineReactToolResult) { + physicsIssues := scheduleRefinePhysicsCheck(entries, 0) + orderIssues := scheduleRefineValidateRelativeOrder(entries, policy) + if len(physicsIssues) > 0 || len(orderIssues) > 0 { + payload := map[string]any{ + "tool": "Verify", + "pass": false, + "physics_issues": physicsIssues, + "order_issues": orderIssues, + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, scheduleRefineReactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: "Verify 校验失败且结果无法序列化", + } + } + return entries, scheduleRefineReactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: string(raw), + } + } + + // 1. 若携带 task_item_id / 目标坐标参数,则执行“针对性核验”,避免“全局 pass”掩盖当前任务不匹配。 + // 2. 该核验是可选增强:没传 task_id 时仍维持全局 deterministic 行为。 + taskID, hasTaskID := scheduleRefineParamIntAny(params, "task_item_id", "task_id") + if hasTaskID { + idx, locateErr := scheduleRefineFindUniqueSuggestedByID(entries, taskID) + if locateErr != nil { + return entries, scheduleRefineReactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"%s"}`, locateErr.Error()), + } + } + target := entries[idx] + verifyWeek, hasWeek := scheduleRefineParamIntAny(params, "week", "to_week", "target_week") + verifyDay, hasDay := scheduleRefineParamIntAny(params, "day_of_week", "to_day", "target_day_of_week") + verifyFrom, hasFrom := scheduleRefineParamIntAny(params, "section_from", "to_section_from", "target_section_from") + verifyTo, hasTo := scheduleRefineParamIntAny(params, "section_to", "to_section_to", "target_section_to") + + mismatch := make([]string, 0, 4) + if hasWeek && target.Week != verifyWeek { + mismatch = append(mismatch, fmt.Sprintf("week=%d(实际=%d)", verifyWeek, target.Week)) + } + if hasDay && target.DayOfWeek != verifyDay { + mismatch = append(mismatch, fmt.Sprintf("day_of_week=%d(实际=%d)", verifyDay, target.DayOfWeek)) + } + if hasFrom && target.SectionFrom != verifyFrom { + mismatch = append(mismatch, fmt.Sprintf("section_from=%d(实际=%d)", verifyFrom, target.SectionFrom)) + } + if hasTo && target.SectionTo != verifyTo { + mismatch = append(mismatch, fmt.Sprintf("section_to=%d(实际=%d)", verifyTo, target.SectionTo)) + } + if len(mismatch) > 0 { + return entries, scheduleRefineReactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"任务坐标不匹配:%s"}`, strings.Join(mismatch, ";")), + } + } + return entries, scheduleRefineReactToolResult{ + Tool: "Verify", + Success: true, + Result: `{"tool":"Verify","pass":true,"reason":"task-level deterministic checks passed"}`, + } + } + + return entries, scheduleRefineReactToolResult{ + Tool: "Verify", + Success: true, + Result: `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`, + } +} + +// scheduleRefineValidateRelativeOrder 校验 suggested 任务是否保持“初始相对顺序”。 +// +// 步骤化说明: +// 1. 若策略未启用 keep_relative_order,直接通过; +// 2. 否则按时间位置排序 suggested 任务,并映射到 origin_rank; +// 3. 检查 rank 是否单调不降;一旦逆序即判定失败; +// 4. 支持 week 作用域:仅要求每周内保持相对顺序。 +func scheduleRefineValidateRelativeOrder(entries []model.HybridScheduleEntry, policy scheduleRefineToolPolicy) []string { + if !policy.KeepRelativeOrder { + return nil + } + if len(policy.OriginOrderMap) == 0 { + return []string{"未提供顺序基线(origin_order_map)"} + } + + suggested := make([]model.HybridScheduleEntry, 0, len(entries)) + for _, entry := range entries { + // 1. 顺序校验与执行口径必须一致: + // 1.1 这里只校验“可移动 suggested 任务”,避免把 course 等不可移动条目误纳入顺序约束; + // 1.2 若把不可移动条目纳入,会出现“动作层不允许改、顺序层却报错”的左右脑互搏。 + if scheduleRefineIsMovableSuggestedTask(entry) { + suggested = append(suggested, entry) + } + } + if len(suggested) <= 1 { + return nil + } + sort.SliceStable(suggested, func(i, j int) bool { + left := suggested[i] + right := suggested[j] + if left.Week != right.Week { + return left.Week < right.Week + } + if left.DayOfWeek != right.DayOfWeek { + return left.DayOfWeek < right.DayOfWeek + } + if left.SectionFrom != right.SectionFrom { + return left.SectionFrom < right.SectionFrom + } + if left.SectionTo != right.SectionTo { + return left.SectionTo < right.SectionTo + } + return left.TaskItemID < right.TaskItemID + }) + + scope := scheduleRefineNormalizeOrderScope(policy.OrderScope) + issues := make([]string, 0, 4) + if scope == "week" { + lastRankByWeek := make(map[int]int) + lastNameByWeek := make(map[int]string) + lastIDByWeek := make(map[int]int) + for _, entry := range suggested { + rank, ok := policy.OriginOrderMap[entry.TaskItemID] + if !ok { + issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID)) + continue + } + last, exists := lastRankByWeek[entry.Week] + if exists && rank < last { + issues = append(issues, fmt.Sprintf( + "W%d 出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后", + entry.Week, entry.Name, entry.TaskItemID, rank, lastNameByWeek[entry.Week], lastIDByWeek[entry.Week], last, + )) + } + lastRankByWeek[entry.Week] = rank + lastNameByWeek[entry.Week] = entry.Name + lastIDByWeek[entry.Week] = entry.TaskItemID + } + return issues + } + + lastRank := -1 + lastName := "" + lastID := 0 + for _, entry := range suggested { + rank, ok := policy.OriginOrderMap[entry.TaskItemID] + if !ok { + issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID)) + continue + } + if lastRank >= 0 && rank < lastRank { + issues = append(issues, fmt.Sprintf( + "出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后", + entry.Name, entry.TaskItemID, rank, lastName, lastID, lastRank, + )) + } + lastRank = rank + lastName = entry.Name + lastID = entry.TaskItemID + } + return issues +} + +// scheduleRefineNormalizeOrderScope 规范化顺序约束作用域。 +func scheduleRefineNormalizeOrderScope(scope string) string { + switch strings.TrimSpace(strings.ToLower(scope)) { + case "week": + return "week" + default: + return "global" + } +} + +// scheduleRefineBuildPlanningWindowFromEntries 根据现有条目推导允许移动窗口。 +func scheduleRefineBuildPlanningWindowFromEntries(entries []model.HybridScheduleEntry) scheduleRefinePlanningWindow { + if len(entries) == 0 { + return scheduleRefinePlanningWindow{Enabled: false} + } + startWeek, startDay := entries[0].Week, entries[0].DayOfWeek + endWeek, endDay := entries[0].Week, entries[0].DayOfWeek + for _, entry := range entries { + if scheduleRefineCompareWeekDay(entry.Week, entry.DayOfWeek, startWeek, startDay) < 0 { + startWeek, startDay = entry.Week, entry.DayOfWeek + } + if scheduleRefineCompareWeekDay(entry.Week, entry.DayOfWeek, endWeek, endDay) > 0 { + endWeek, endDay = entry.Week, entry.DayOfWeek + } + } + return scheduleRefinePlanningWindow{ + Enabled: true, + StartWeek: startWeek, + StartDay: startDay, + EndWeek: endWeek, + EndDay: endDay, + } +} + +// scheduleRefineIsWithinWindow 判断目标 week/day 是否落在窗口内。 +func scheduleRefineIsWithinWindow(window scheduleRefinePlanningWindow, week, day int) bool { + if !window.Enabled { + return true + } + if day < 1 || day > 7 { + return false + } + if scheduleRefineCompareWeekDay(week, day, window.StartWeek, window.StartDay) < 0 { + return false + } + if scheduleRefineCompareWeekDay(week, day, window.EndWeek, window.EndDay) > 0 { + return false + } + return true +} + +// scheduleRefineCompareWeekDay 比较两个 week/day 坐标。 +// 返回: +// 1) <0:left 更早; +// 2) =0:相同; +// 3) >0:left 更晚。 +func scheduleRefineCompareWeekDay(leftWeek, leftDay, rightWeek, rightDay int) int { + if leftWeek != rightWeek { + return leftWeek - rightWeek + } + return leftDay - rightDay +} + +// scheduleRefineFindSuggestedByID 在 entries 中查找指定 task_item_id 的 suggested 条目索引。 +func scheduleRefineFindSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int { + for i, entry := range entries { + if scheduleRefineIsMovableSuggestedTask(entry) && entry.TaskItemID == taskItemID { + return i + } + } + return -1 +} + +// scheduleRefineFindUniqueSuggestedByID 查找可唯一定位的可移动 suggested 任务。 +// +// 说明: +// 1. “可移动”定义由 scheduleRefineIsMovableSuggestedTask 统一控制; +// 2. 当 task_item_id 命中 0 条或 >1 条时都返回错误,避免把动作落到错误任务上。 +func scheduleRefineFindUniqueSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) (int, error) { + first := -1 + count := 0 + for idx, entry := range entries { + if !scheduleRefineIsMovableSuggestedTask(entry) { + continue + } + if entry.TaskItemID != taskItemID { + continue + } + if first < 0 { + first = idx + } + count++ + } + if count == 0 { + return -1, fmt.Errorf("未找到 task_item_id=%d 的可移动 suggested 任务", taskItemID) + } + if count > 1 { + return -1, fmt.Errorf("task_item_id=%d 命中 %d 条可移动 suggested 任务,无法唯一定位", taskItemID, count) + } + return first, nil +} + +// scheduleRefineIsMovableSuggestedTask 判断条目是否属于“可被微调工具改写”的任务。 +// +// 规则: +// 1. 必须是 suggested 且 task_item_id>0; +// 2. type=course 明确禁止移动(即便被错误标记为 suggested); +// 3. 其余类型(含空值)按任务处理,兼容历史快照。 +func scheduleRefineIsMovableSuggestedTask(entry model.HybridScheduleEntry) bool { + if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 { + return false + } + if strings.EqualFold(strings.TrimSpace(entry.Type), "course") { + return false + } + return true +} + +// scheduleRefineHasConflict 检查目标时段是否与其他条目冲突。 +// +// 判断规则: +// 1. 仅把“会阻塞 suggested 的条目”纳入冲突判断; +// 2. excludes 中的索引会被跳过(常用于 Move 自身排除或 Swap 双排除)。 +func scheduleRefineHasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, excludes map[int]bool, allowEmbed bool) (bool, string) { + for idx, entry := range entries { + if excludes != nil && excludes[idx] { + continue + } + if !scheduleRefineEntryBlocksSuggestedWithPolicy(entry, allowEmbed) { + continue + } + if entry.Week == week && entry.DayOfWeek == day && scheduleRefineSectionsOverlap(entry.SectionFrom, entry.SectionTo, sf, st) { + return true, fmt.Sprintf("%s(%s)", entry.Name, entry.Type) + } + } + return false, "" +} + +// scheduleRefineEntryBlocksSuggested 判断条目是否会阻塞 suggested 任务落位。 +func scheduleRefineEntryBlocksSuggested(entry model.HybridScheduleEntry) bool { + return scheduleRefineEntryBlocksSuggestedWithPolicy(entry, true) +} + +// scheduleRefineEntryBlocksSuggestedWithPolicy 判断条目是否阻塞 suggested 落位。 +// +// 策略说明: +// 1. allowEmbed=true:沿用 block_for_suggested 语义; +// 2. allowEmbed=false:existing 一律阻塞,只允许纯空白课位; +// 3. unknown status 保守阻塞,防止漏检。 +func scheduleRefineEntryBlocksSuggestedWithPolicy(entry model.HybridScheduleEntry, allowEmbed bool) bool { + if entry.Status == "suggested" { + return true + } + if entry.Status == "existing" { + if !allowEmbed { + return true + } + return entry.BlockForSuggested + } + // 未知状态保守处理为阻塞,避免写入潜在冲突。 + return true +} + +// scheduleRefineSectionsOverlap 判断两个节次区间是否有交叠。 +func scheduleRefineSectionsOverlap(aFrom, aTo, bFrom, bTo int) bool { + return aFrom <= bTo && bFrom <= aTo +} + +// scheduleRefineParamInt 从 map 中提取 int 参数,兼容 JSON 常见数值类型。 +func scheduleRefineParamInt(params map[string]any, key string) (int, bool) { + raw, ok := params[key] + if !ok { + return 0, false + } + switch v := raw.(type) { + case int: + return v, true + case float64: + return int(v), true + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return 0, false + } + return n, true + default: + return 0, false + } +} + +// scheduleRefineParamIntAny 按“候选键优先级”提取 int 参数。 +// +// 步骤化说明: +// 1. 按传入顺序依次尝试每个 key; +// 2. 命中第一个合法值即返回; +// 3. 全部未命中则返回 false,由上层统一抛参数缺失错误。 +func scheduleRefineParamIntAny(params map[string]any, keys ...string) (int, bool) { + for _, key := range keys { + if v, ok := scheduleRefineParamInt(params, key); ok { + return v, true + } + } + return 0, false +} + +// scheduleRefineParamBool 从 map 中提取 bool 参数,兼容 JSON 常见布尔表示。 +func scheduleRefineParamBool(params map[string]any, key string) (bool, bool) { + raw, ok := params[key] + if !ok { + return false, false + } + switch v := raw.(type) { + case bool: + return v, true + case string: + text := strings.TrimSpace(strings.ToLower(v)) + switch text { + case "true", "1", "yes", "y": + return true, true + case "false", "0", "no", "n": + return false, true + default: + return false, false + } + case int: + if v == 1 { + return true, true + } + if v == 0 { + return false, true + } + return false, false + case float64: + if v == 1 { + return true, true + } + if v == 0 { + return false, true + } + return false, false + default: + return false, false + } +} + +// scheduleRefineParamBoolAnyWithDefault 按候选键提取 bool,未命中时返回 fallback。 +func scheduleRefineParamBoolAnyWithDefault(params map[string]any, fallback bool, keys ...string) bool { + for _, key := range keys { + if v, ok := scheduleRefineParamBool(params, key); ok { + return v + } + } + return fallback +} + +// scheduleRefineReadString 读取字符串参数,缺失时返回默认值。 +func scheduleRefineReadString(params map[string]any, key string, fallback string) string { + raw, ok := params[key] + if !ok { + return fallback + } + text := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if text == "" { + return fallback + } + return text +} + +// scheduleRefineNormalizeDayScope 规范化 day_scope 取值。 +func scheduleRefineNormalizeDayScope(scope string) string { + switch strings.ToLower(strings.TrimSpace(scope)) { + case "weekend": + return "weekend" + case "workday": + return "workday" + default: + return "all" + } +} + +// scheduleRefineNormalizeStatusFilter 规范化 status 过滤条件。 +func scheduleRefineNormalizeStatusFilter(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "existing": + return "existing" + case "all": + return "all" + default: + return "suggested" + } +} + +// scheduleRefineMatchStatusFilter 判断条目状态是否命中 status 过滤。 +func scheduleRefineMatchStatusFilter(entryStatus string, statusFilter string) bool { + switch strings.ToLower(strings.TrimSpace(statusFilter)) { + case "all": + return true + case "existing": + return strings.TrimSpace(entryStatus) == "existing" + default: + return strings.TrimSpace(entryStatus) == "suggested" + } +} + +// scheduleRefineMatchDayScope 判断 day_of_week 是否满足 scope 过滤条件。 +func scheduleRefineMatchDayScope(day int, scope string) bool { + switch scope { + case "weekend": + return day == 6 || day == 7 + case "workday": + return day >= 1 && day <= 5 + default: + return day >= 1 && day <= 7 + } +} + +// scheduleRefineIntSliceToDaySet 把 day 切片转换为 set,并去除非法 day 值。 +func scheduleRefineIntSliceToDaySet(items []int) map[int]struct{} { + if len(items) == 0 { + return nil + } + set := make(map[int]struct{}, len(items)) + for _, item := range items { + if item < 1 || item > 7 { + continue + } + set[item] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// scheduleRefineIntSliceToWeekSet 把周次切片转换为 set,并去除非正数。 +func scheduleRefineIntSliceToWeekSet(items []int) map[int]struct{} { + if len(items) == 0 { + return nil + } + set := make(map[int]struct{}, len(items)) + for _, item := range items { + if item <= 0 { + continue + } + set[item] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// scheduleRefineIntSliceToSectionSet 把节次切片转换为 set,并去除非法节次。 +func scheduleRefineIntSliceToSectionSet(items []int) map[int]struct{} { + if len(items) == 0 { + return nil + } + set := make(map[int]struct{}, len(items)) + for _, item := range items { + if item < 1 || item > 12 { + continue + } + set[item] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// scheduleRefineIntSliceToIDSet 把正整数 ID 切片转换为 set。 +func scheduleRefineIntSliceToIDSet(items []int) map[int]struct{} { + if len(items) == 0 { + return nil + } + set := make(map[int]struct{}, len(items)) + for _, item := range items { + if item <= 0 { + continue + } + set[item] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// scheduleRefineInferWeekBounds 推断查询周区间。 +func scheduleRefineInferWeekBounds(entries []model.HybridScheduleEntry, window scheduleRefinePlanningWindow) (int, int) { + if window.Enabled { + return window.StartWeek, window.EndWeek + } + if len(entries) == 0 { + return 1, 1 + } + minWeek, maxWeek := entries[0].Week, entries[0].Week + for _, entry := range entries { + if entry.Week < minWeek { + minWeek = entry.Week + } + if entry.Week > maxWeek { + maxWeek = entry.Week + } + } + return minWeek, maxWeek +} + +// scheduleRefineBuildWeekIterList 构建周次迭代列表。 +// +// 规则: +// 1. weekFilter 非空时,严格按过滤集合遍历; +// 2. weekFilter 为空时,按 weekFrom~weekTo 连续区间遍历; +// 3. 返回结果升序,便于日志与排查。 +func scheduleRefineBuildWeekIterList(weekFilter map[int]struct{}, weekFrom, weekTo int) []int { + if len(weekFilter) > 0 { + return scheduleRefineKeysOfIntSet(weekFilter) + } + if weekFrom <= 0 || weekTo <= 0 || weekFrom > weekTo { + return nil + } + out := make([]int, 0, weekTo-weekFrom+1) + for w := weekFrom; w <= weekTo; w++ { + out = append(out, w) + } + return out +} + +// scheduleRefineReadIntSlice 读取 int 切片参数,兼容 []any / []int / 单个数值。 +func scheduleRefineReadIntSlice(params map[string]any, keys ...string) []int { + for _, key := range keys { + raw, ok := params[key] + if !ok { + continue + } + switch v := raw.(type) { + case []int: + out := make([]int, len(v)) + copy(out, v) + return out + case []any: + out := make([]int, 0, len(v)) + for _, item := range v { + switch n := item.(type) { + case int: + out = append(out, n) + case float64: + out = append(out, int(n)) + case string: + if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil { + out = append(out, parsed) + } + } + } + return out + default: + if n, okNum := scheduleRefineParamInt(params, key); okNum { + return []int{n} + } + } + } + return nil +} + +// scheduleRefineReadStringSlice 读取 string 切片参数,兼容 []any / []string / 单个字符串。 +func scheduleRefineReadStringSlice(params map[string]any, keys ...string) []string { + for _, key := range keys { + raw, ok := params[key] + if !ok || raw == nil { + continue + } + switch vv := raw.(type) { + case []string: + out := make([]string, 0, len(vv)) + for _, item := range vv { + text := strings.TrimSpace(item) + if text != "" { + out = append(out, text) + } + } + return out + case []any: + out := make([]string, 0, len(vv)) + for _, item := range vv { + text := strings.TrimSpace(fmt.Sprintf("%v", item)) + if text != "" { + out = append(out, text) + } + } + return out + case string: + text := strings.TrimSpace(vv) + if text != "" { + return []string{text} + } + default: + text := strings.TrimSpace(fmt.Sprintf("%v", vv)) + if text != "" { + return []string{text} + } + } + } + return nil +} + +// scheduleRefineIntersectsExcludedSections 判断候选区间是否与排除节次有交集。 +func scheduleRefineIntersectsExcludedSections(from, to int, excluded map[int]struct{}) bool { + if len(excluded) == 0 { + return false + } + for sec := from; sec <= to; sec++ { + if _, ok := excluded[sec]; ok { + return true + } + } + return false +} + +// scheduleRefineKeysOfIntSet 返回 int set 的有序键。 +func scheduleRefineKeysOfIntSet(set map[int]struct{}) []int { + if len(set) == 0 { + return nil + } + keys := make([]int, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Ints(keys) + return keys +} + +// scheduleRefineParseBatchMoveParams 解析 BatchMove 的 moves 参数。 +// +// 步骤化说明: +// 1. 先读取 params["moves"],必须存在且为非空数组; +// 2. 再把数组元素逐条转换成 map[string]any,便于复用 scheduleRefineToolMove; +// 3. 任一元素类型非法即整体失败,避免“部分可执行、部分不可执行”带来的语义歧义。 +func scheduleRefineParseBatchMoveParams(params map[string]any) ([]map[string]any, error) { + rawMoves, ok := params["moves"] + if !ok { + return nil, fmt.Errorf("参数缺失:BatchMove 需要 moves 数组") + } + + var items []any + switch v := rawMoves.(type) { + case []any: + items = v + case []map[string]any: + items = make([]any, 0, len(v)) + for _, item := range v { + items = append(items, item) + } + default: + return nil, fmt.Errorf("参数类型错误:BatchMove 的 moves 必须是数组") + } + if len(items) == 0 { + return nil, fmt.Errorf("参数错误:BatchMove 的 moves 不能为空") + } + + moveParamsList := make([]map[string]any, 0, len(items)) + for idx, item := range items { + paramMap, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("参数类型错误:BatchMove 第%d步不是对象", idx+1) + } + moveParamsList = append(moveParamsList, paramMap) + } + return moveParamsList, nil +} + +// scheduleRefineClassifyBatchMoveErrorCode 把单步 Move 失败原因映射为 BatchMove 层错误码。 +// +// 说明: +// 1. 映射保持与普通 Move 的错误语义一致,便于模型统一处理; +// 2. 这里按失败文案做轻量推断,避免引入跨文件循环依赖。 +func scheduleRefineClassifyBatchMoveErrorCode(detail string) string { + text := strings.TrimSpace(detail) + switch { + case strings.Contains(text, "顺序约束不满足"): + return "ORDER_VIOLATION" + case strings.Contains(text, "参数缺失"): + return "PARAM_MISSING" + case strings.Contains(text, "目标时段已被"): + return "SLOT_CONFLICT" + case strings.Contains(text, "任务跨度不一致"): + return "SPAN_MISMATCH" + case strings.Contains(text, "超出允许窗口"): + return "OUT_OF_WINDOW" + case strings.Contains(text, "day_of_week"): + return "DAY_INVALID" + case strings.Contains(text, "节次区间"): + return "SECTION_INVALID" + case strings.Contains(text, "未找到 task_item_id"): + return "TASK_NOT_FOUND" + default: + return "BATCH_MOVE_FAILED" + } +} + +// scheduleRefineSortHybridEntries 对混合条目做稳定排序,保证日志与预览输出稳定。 +func scheduleRefineSortHybridEntries(entries []model.HybridScheduleEntry) { + sort.SliceStable(entries, func(i, j int) bool { + left := entries[i] + right := entries[j] + if left.Week != right.Week { + return left.Week < right.Week + } + if left.DayOfWeek != right.DayOfWeek { + return left.DayOfWeek < right.DayOfWeek + } + if left.SectionFrom != right.SectionFrom { + return left.SectionFrom < right.SectionFrom + } + if left.SectionTo != right.SectionTo { + return left.SectionTo < right.SectionTo + } + return left.Name < right.Name + }) +} + +// scheduleRefineTruncate 截断日志内容,避免错误信息无上限增长。 +func scheduleRefineTruncate(text string, maxLen int) string { + if maxLen <= 0 { + return "" + } + runes := []rune(text) + if len(runes) <= maxLen { + return text + } + return string(runes[:maxLen]) + "..." +} diff --git a/backend/agent2/prompt/schedule_refine.go b/backend/agent2/prompt/schedule_refine.go index f05da85..9307473 100644 --- a/backend/agent2/prompt/schedule_refine.go +++ b/backend/agent2/prompt/schedule_refine.go @@ -1,14 +1,14 @@ package agentprompt const ( - ScheduleRefineContractPrompt = `You are SmartFlow's schedule refine contract analyzer. -Return exactly one JSON object. - -Schema: + // ScheduleRefineContractPrompt 负责把用户自然语言微调请求抽取为结构化契约。 + ScheduleRefineContractPrompt = `你是 SmartFlow 的排程微调契约分析器。 +你会收到:当前时间、用户请求、已有排程摘要。 +请只输出 JSON,不要 Markdown,不要解释,不要代码块: { - "intent": "short summary", + "intent": "一句话概括本轮微调目标", "strategy": "local_adjust|keep", - "hard_requirements": ["..."], + "hard_requirements": ["必须满足的硬性要求1","必须满足的硬性要求2"], "hard_assertions": [ { "metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count", @@ -24,41 +24,75 @@ Schema: "order_scope": "global|week" } -Rules: -- Default keep_relative_order=true unless the user explicitly allows reordering. -- If tasks are being moved, strategy must be local_adjust. -- hard_requirements must be concrete and verifiable. -- hard_assertions should be as structured as possible.` +规则: +1. 除非用户明确表达“允许打乱顺序/顺序无所谓”,keep_relative_order 默认 true。 +2. 仅当用户明确放宽顺序时,keep_relative_order 才允许为 false;order_scope 默认 "global"。 +3. 只要涉及移动任务,strategy 必须是 local_adjust;仅在无需改动时才用 keep。 +4. hard_requirements 必须可验证,避免空泛描述。 +5. hard_assertions 必须尽量结构化,避免只给自然语言目标。` - ScheduleRefinePlannerPrompt = `You are SmartFlow's schedule refine planner. -Return exactly one JSON object: + // ScheduleRefinePlannerPrompt 只负责生成“执行路径”,不直接执行动作。 + ScheduleRefinePlannerPrompt = `你是 SmartFlow 的排程微调 Planner。 +你会收到:用户请求、契约、最近动作观察。 +请只输出 JSON,不要 Markdown,不要解释,不要代码块: { - "summary": "one sentence", - "steps": ["step1","step2","step3"] + "summary": "本阶段执行策略一句话", + "steps": ["步骤1","步骤2","步骤3"] } -Rules: -- Keep 3-4 steps. -- Prefer "inspect first, then act". -- If the goal is even spreading, the steps must mention SpreadEven and success gating. -- If the goal is minimizing context switching, the steps must mention MinContextSwitch and success gating.` +规则: +1. steps 保持 3~4 条,优先“先取证再动作”。 +2. summary <= 36 字,单步 <= 28 字。 +3. 若目标是“均匀分散”,steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。 +4. 若目标是“上下文切换最少/同科目连续”,steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。 +5. 不要输出半截 JSON。` - ScheduleRefineReactPrompt = `You are SmartFlow's single-task micro ReAct executor. -You may do exactly one thing each round: -1. call one tool -2. return done=true + // ScheduleRefineReactPrompt 用于“单任务微步 ReAct”执行器。 + ScheduleRefineReactPrompt = `你是 SmartFlow 的单任务微步 ReAct 执行器。 +当前只处理一个任务(CURRENT_TASK),不能发散到其它任务的主动改动。 +你每轮只能做两件事之一: +1) 调用一个工具(基础工具或复合工具) +2) 输出 done=true 结束当前任务 -Tool groups: -- Basic: QueryTargetTasks, QueryAvailableSlots, Move, Swap, BatchMove, Verify -- Composite: SpreadEven, MinContextSwitch +工具分组: +- 基础工具:QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify +- 复合工具:SpreadEven / MinContextSwitch -Return exactly one JSON object: +工具说明(按职责): +1. QueryTargetTasks:查询候选任务集合(只读)。 + 常用参数:week/week_filter/day_of_week/task_item_ids/status。 + 适用:先摸清“有哪些任务可动、当前在哪”。 +2. QueryAvailableSlots:查询可放置坑位(只读,默认先纯空位,必要时补可嵌入位)。 + 常用参数:week/week_filter/day_of_week/span/limit/allow_embed/exclude_sections。 + 适用:Move 前先拿可落点清单。 +3. Move:移动单个任务到目标坑位(写操作)。 + 必要参数:task_item_id,to_week,to_day,to_section_from,to_section_to。 + 适用:单任务精确挪动。 +4. Swap:交换两个任务坑位(写操作)。 + 必要参数:task_a,task_b。 + 适用:两个任务互换位置比单独 Move 更稳时。 +5. BatchMove:批量原子移动(写操作)。 + 必要参数:{"moves":[{Move参数...},{Move参数...}]}。 + 适用:一轮要改多个任务且要求“要么全成要么全回滚”。 +6. Verify:执行确定性校验(只读)。 + 常用参数:可空;也可传 task_item_id + 目标坐标做定点核验。 + 适用:收尾前快速自检是否符合确定性约束。 +7. SpreadEven(复合):按“均匀铺开”目标一次规划并执行多任务移动(写操作)。 + 必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。 + 可选参数:week/week_filter/day_of_week/allow_embed/limit。 + 适用:目标是“把任务在时间上分散开,避免扎堆”。 +8. MinContextSwitch(复合):按“最少上下文切换”一次规划并执行多任务移动(写操作)。 + 必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。 + 可选参数:week/week_filter/day_of_week/allow_embed/limit。 + 适用:目标是“同科目/同认知标签尽量连续,减少切换成本”。 + +请严格输出 JSON,不要 Markdown,不要解释: { "done": false, "summary": "", - "goal_check": "", - "decision": "", - "missing_info": [], + "goal_check": "本轮先检查什么", + "decision": "本轮为何这么做", + "missing_info": ["缺口信息1","缺口信息2"], "tool_calls": [ { "tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify", @@ -67,52 +101,74 @@ Return exactly one JSON object: ] } -Rules: -- At most one tool call. -- If done=true, tool_calls must be []. -- Only modify suggested tasks. -- Do not invent tools. -- Respect REQUIRED_COMPOSITE_TOOL and COMPOSITE_TOOLS_ALLOWED.` +硬规则: +1. 每轮最多 1 个 tool_call。 +2. done=true 时,tool_calls 必须为空数组。 +3. done=false 时,tool_calls 必须恰好 1 条。 +4. 只能修改 status="suggested" 的任务,禁止修改 existing。 +5. 不要把“顺序约束”当作执行期阻塞条件;你只需把坑位分布排好,顺序由后端统一收口。 +6. 若上轮失败,必须依据 LAST_TOOL_OBSERVATION.error_code 调整策略,不能重复上轮失败动作。 +7. Move 参数优先使用:task_item_id,to_week,to_day,to_section_from,to_section_to。 +8. BatchMove 参数格式必须是:{"moves":[{...},{...}]};任一步失败会整批回滚。 +9. day_of_week 映射固定:1周一,2周二,3周三,4周四,5周五,6周六,7周日。 +10. 优先使用“纯空位”;仅在空位不足时再考虑可嵌入课程位(第二优先级)。 +11. 如果 SOURCE_WEEK_FILTER 非空,只允许改写这些来源周里的任务,禁止主动改写其它周任务。 +12. CURRENT_TASK 是本轮唯一可改写任务;如果它已满足目标,立刻 done=true,不要提前处理下一个任务。 +13. 禁止发明工具名(如 GetCurrentTask、AdjustTaskTime),只能用白名单工具。 +14. 优先使用后端注入的 ENV_SLOT_HINT 进行落点决策,非必要不要重复 QueryAvailableSlots。 +15. 若 REQUIRED_COMPOSITE_TOOL 非空且 COMPOSITE_REQUIRED_SUCCESS=false,本轮必须优先调用 REQUIRED_COMPOSITE_TOOL,禁止先调用 Move/Swap/BatchMove。 +16. 若使用 SpreadEven/MinContextSwitch,必须在参数中提供 task_item_ids(且包含 CURRENT_TASK.task_item_id)。 +17. 若 COMPOSITE_TOOLS_ALLOWED=false,禁止调用 SpreadEven/MinContextSwitch,只能使用基础工具逐步处理。 +18. 为保证解析稳定:goal_check<=50字,decision<=90字,summary<=60字。` - ScheduleRefinePostReflectPrompt = `You are SmartFlow's post-tool reflector. -Return exactly one JSON object: + // ScheduleRefinePostReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。 + ScheduleRefinePostReflectPrompt = `你是 SmartFlow 的 ReAct 复盘器。 +你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。 +请只输出 JSON,不要 Markdown,不要解释: { - "reflection": "", - "next_strategy": "", + "reflection": "基于真实结果的复盘", + "next_strategy": "下一轮建议动作", "should_stop": false } -Rules: -- Base the reflection on the real tool result only. -- If the tool failed, explain the failure reason. -- If should_stop=true, it must mean the goal is already met or further work has low value.` +规则: +1. 若 tool_success=false,reflection 必须明确失败原因(优先引用 error_code)。 +2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTION,next_strategy 必须给出规避方法。 +3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。` - ScheduleRefineReviewPrompt = `You are SmartFlow's final refine reviewer. -Return exactly one JSON object: + // ScheduleRefineReviewPrompt 用于终审语义校验。 + ScheduleRefineReviewPrompt = `你是 SmartFlow 的终审校验器。 +请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。 +只输出 JSON: { "pass": true, - "reason": "", + "reason": "中文简短结论", "unmet": [] } -Rules: -- If pass=true, unmet must be []. -- If pass=false, reason must state the core gap.` +规则: +1. pass=true 时 unmet 必须为空数组。 +2. pass=false 时 reason 必须给出核心差距。` - ScheduleRefineSummaryPrompt = `You are SmartFlow's result summarizer. -Write a short user-facing summary in 2-4 Chinese sentences: -1. what changed -2. what benefit was achieved -3. if final review still failed, what remains` + // ScheduleRefineSummaryPrompt 用于最终面向用户的自然语言总结。 + ScheduleRefineSummaryPrompt = `你是 SmartFlow 的排程结果解读助手。 +请基于输入输出 2~4 句中文总结: +1) 先说明本轮改了什么; +2) 再说明改动收益; +3) 若终审未完全通过,明确还差什么。 +不要输出 JSON。` - ScheduleRefineRepairPrompt = `You are SmartFlow's one-step repair executor. -The current plan failed final review. -Return exactly one JSON object with exactly one tool call: + // ScheduleRefineRepairPrompt 用于终审失败后的单次修复动作。 + ScheduleRefineRepairPrompt = `你是 SmartFlow 的修复执行器。 +当前方案未通过终审,请根据“未满足点”只做一次修复动作。 +只允许输出一个 tool_call(Move 或 Swap),不允许 done。 + +输出格式(严格 JSON): { "done": false, "summary": "", - "goal_check": "", - "decision": "", + "goal_check": "本轮修复目标", + "decision": "修复决策依据", "missing_info": [], "tool_calls": [ { @@ -122,10 +178,11 @@ Return exactly one JSON object with exactly one tool call: ] } -Use standard Move keys only: +Move 参数必须使用标准键: - task_item_id - to_week - to_day - to_section_from -- to_section_to` +- to_section_to +禁止使用 new_week/new_day/section_from 等别名。` ) diff --git a/backend/agent2/通用能力接入文档.md b/backend/agent2/通用能力接入文档.md index cb65f5d..a85cc07 100644 --- a/backend/agent2/通用能力接入文档.md +++ b/backend/agent2/通用能力接入文档.md @@ -344,3 +344,13 @@ 1. 绉婚櫎 `agent2/node/schedule_refine_impl` 鏍圭洰褰曞疄鐜帮紝鏀逛负鏀惧埌 `agent2/node/schedule_refine_impl`銆?2. `agent2/node/schedule_refine.go` 缁х画淇濈暀缁熶竴闂ㄩ潰鑱岃矗锛岄伩鍏?service/graph 鐩存帴渚濊禆缁嗚妭瀹炵幇銆?3. `agent2/node/schedule_refine_tool.go` 淇濈暀鍙屾枃浠舵牸灞€锛屽伐鍏峰疄鐜颁綅缃敼涓?`agent2/node/schedule_refine_impl/tool.go`銆?4. `agent2/graph/schedule.go` 娉ㄩ噴宸叉竻鐞嗕贡鐮侊紝graph 浠呰礋璐f牎楠屼笌缂栨帓銆?5. `service/agentsvc/agent_schedule_refine.go` 鍏ュ彛淇濇寔涓嶅彉锛屼粛瀹屽叏涓庢棫 `backend/agent/*` 瑙h€︺€? +## 10. 2026-03-26 schedule_refine 正式落地记录 + +1. `agent2/node/schedule_refine.go` 已从“兼容门面”升级为正式节点实现,直接承载 contract / plan / slice / route / react / hard_check / summary 全链路逻辑。 +2. `agent2/node/schedule_refine_tool.go` 已承接全部微调工具实现,当前 `schedule_refine` 在 `node` 层落为“双文件结构”,不再依赖 `_impl` 子目录。 +3. `agent2/model/schedule_refine.go` 继续作为 refine 状态与默认预算的正式归属,`node` 层仅复用状态别名与初始化入口,避免再维护第二份 state。 +4. `agent2/prompt/schedule_refine.go` 已同步承接 refine 的正式 prompt,删除了 `_impl/prompt.go` 这一份重复定义。 +5. `agent2/graph/schedule.go` 已改为像 `schedule_plan` 一样在 graph 层真实组图,调用 `NewScheduleRefineNodes` 挂载节点,不再绕回 `_impl` 的独立运行入口。 +6. 当前生产切流点保持不变:`service/agentsvc/agent_schedule_refine.go` 仍从 agent2 入口进入,但底层已完全切到新架构实现。 +7. 本轮评估过把“模型调用 / JSON 解析 / ReAct 输出恢复 / 截断文本”等 helper 继续上提到更高公共层;暂未抽出的原因是 `schedule_refine` 与 `schedule_plan` 在输出契约、错误恢复、工具门禁、终审收口上仍存在较强领域差异,当前强行抽象会把公共层做成“带业务分支的半成品”,因此先保留在各自能力域内,等待下一轮出现更稳定的第三处复用后再统一抽象。 +