From e6941f98f2da78d1c99984bd173567bfb886cf0b Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Mon, 23 Mar 2026 23:14:19 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.7.4.dev.260323=20=E2=9C=A8=20feat(?= =?UTF-8?q?schedulerefine):=20=E6=96=B0=E5=A2=9E=20refine=20=E5=AD=90?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=EF=BC=8C=E4=BC=98=E5=85=88=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=A4=8D=E5=90=88=E6=93=8D=E4=BD=9C=EF=BC=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=90=8E=E9=99=8D=E7=BA=A7=E8=87=B3=E7=A6=81=E5=A4=8D=E5=90=88?= =?UTF-8?q?=20ReAct=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReAct 升级 - ♻️ 将原有链路升级为真正的 ReAct 执行模式,进一步增强整体调度过程的可靠性 Refine 子路由 - 🧭 在 refine 主链路中新增 `route` 节点,整体流程调整为 `contract -> plan -> slice -> route -> react -> hard_check -> summary` - ⚡ 当 `route` 命中全局复合目标时,优先尝试一次调用 `SpreadEven` / `MinContextSwitch`,失败后最多重试 2 次 - 🔀 `route` 成功后直接跳过 `ReAct`;若执行失败,则自动切换至 `fallback` 模式 - 🛡️ 在 `fallback` 模式下增加后端硬约束:禁用 `SpreadEven` / `MinContextSwitch` / `BatchMove`,仅允许使用 `Move` / `Swap` 逐任务处理 - 🧠 在 `ReAct` 的 prompt 与上下文中新增 `COMPOSITE_TOOLS_ALLOWED`,显式告知当前是否允许使用复合工具 - 🧩 扩展状态字段以承载路由与降级状态:`CompositeRetryMax` / `DisableCompositeTools` / `CompositeRouteTried` / `CompositeRouteSucceeded` - 👀 增加 `route` 相关阶段日志,便于排查命中、重试、收口与降级原因 修复 - 🐛 修复 JWT Token 过期时间未按 `config.yaml` 配置生效的问题 备注 - 🚧 当前 ReAct 逐步微排链路已趋于稳定,但两个复合操作函数仍未恢复可用,后续将继续排查 --- .../schedulerefine/composite_tools_test.go | 117 + backend/agent/schedulerefine/graph.go | 27 +- backend/agent/schedulerefine/nodes.go | 2982 +++++++++++++---- backend/agent/schedulerefine/prompt.go | 244 +- .../schedulerefine/refine_filters_test.go | 573 ++++ backend/agent/schedulerefine/runner.go | 12 + backend/agent/schedulerefine/state.go | 243 +- backend/agent/schedulerefine/tool.go | 891 ++++- backend/auth/jwt_handler.go | 258 +- backend/auth/jwt_handler_test.go | 128 + backend/logic/refine_compound_ops.go | 373 +++ backend/logic/refine_compound_ops_test.go | 95 + backend/middleware/token_handler.go | 61 +- 13 files changed, 4924 insertions(+), 1080 deletions(-) create mode 100644 backend/agent/schedulerefine/composite_tools_test.go create mode 100644 backend/agent/schedulerefine/refine_filters_test.go create mode 100644 backend/auth/jwt_handler_test.go create mode 100644 backend/logic/refine_compound_ops.go create mode 100644 backend/logic/refine_compound_ops_test.go diff --git a/backend/agent/schedulerefine/composite_tools_test.go b/backend/agent/schedulerefine/composite_tools_test.go new file mode 100644 index 0000000..d0fbd9c --- /dev/null +++ b/backend/agent/schedulerefine/composite_tools_test.go @@ -0,0 +1,117 @@ +package schedulerefine + +import ( + "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) + } +} + +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/agent/schedulerefine/graph.go b/backend/agent/schedulerefine/graph.go index ae48d57..98d071e 100644 --- a/backend/agent/schedulerefine/graph.go +++ b/backend/agent/schedulerefine/graph.go @@ -10,6 +10,9 @@ import ( 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" @@ -30,7 +33,7 @@ type ScheduleRefineGraphRunInput struct { // RunScheduleRefineGraph 执行“连续微调”独立图链路。 // // 链路顺序: -// START -> contract -> react -> hard_check -> summary -> END +// START -> contract -> plan -> slice -> route -> react -> hard_check -> summary -> END // // 设计说明: // 1. 当前链路采用线性图,确保可读性优先; @@ -55,6 +58,15 @@ func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInp 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 } @@ -68,7 +80,16 @@ func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInp if err := graph.AddEdge(compose.START, graphNodeContract); err != nil { return nil, err } - if err := graph.AddEdge(graphNodeContract, graphNodeReact); err != nil { + 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 { @@ -83,7 +104,7 @@ func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInp runnable, err := graph.Compile(ctx, compose.WithGraphName("ScheduleRefineGraph"), - compose.WithMaxRunSteps(12), + compose.WithMaxRunSteps(20), compose.WithNodeTriggerMode(compose.AnyPredecessor), ) if err != nil { diff --git a/backend/agent/schedulerefine/nodes.go b/backend/agent/schedulerefine/nodes.go index 428009c..75c255b 100644 --- a/backend/agent/schedulerefine/nodes.go +++ b/backend/agent/schedulerefine/nodes.go @@ -5,6 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "regexp" + "sort" + "strconv" "strings" "time" @@ -17,80 +20,49 @@ import ( ) const ( - // nodeTimeout 是单节点调用模型的超时预算。 - // 说明:这里给到 120s,避免复杂轮次在网络抖动时过早超时。 - nodeTimeout = 120 * time.Second - // plannerMaxTokens 是 Planner 节点输出预算。 - // 说明:Planner 需要输出 steps/success_signals,预算过小会导致 JSON 被截断。 + nodeTimeout = 120 * time.Second plannerMaxTokens = 420 - // reactMaxTokens 是执行器单轮计划输出预算。 - // 说明:当 tool_calls 含 BatchMove 时,参数体更长,需要更高预算避免半截 JSON。 - reactMaxTokens = 480 + reactMaxTokens = 360 ) const ( - // 说明:把 JSON 约束贴到 userPrompt 末尾,降低“系统提示词很长后模型偏离结构”的概率。 - // 1. 每个节点都使用最小必要字段约束,避免提示过重导致上下文负担变大; - // 2. 要求“仅输出 JSON 对象”,减少 markdown/code fence 干扰; - // 3. 放在上下文最后,尽量靠近模型最终解码位置。 - jsonContractForContract = `【输出协议(必须严格遵守)】 -只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 -必须包含键:intent, strategy, hard_requirements, keep_relative_order, order_scope, reason。` - - jsonContractForPlanner = `【输出协议(必须严格遵守)】 -只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 -必须包含键:summary, steps, success_signals, fallback。` - - jsonContractForReact = `【输出协议(必须严格遵守)】 -只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 -必须包含键:done, summary, goal_check, decision, missing_info, reflect, tool_calls。` - - jsonContractForReview = `【输出协议(必须严格遵守)】 -只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 -必须包含键:pass, reason, unmet。` - - jsonContractForPostReflect = `【输出协议(必须严格遵守)】 -只输出单个 JSON 对象,不要输出 Markdown、代码块、解释文字。 -必须包含键:reflection, next_strategy, should_stop, stop_reason。` + 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"` - KeepRelativeOrder bool `json:"keep_relative_order"` - OrderScope string `json:"order_scope"` - Reason string `json:"reason"` + 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"` } -// postReflectOutput 表示“动作执行后真反思”节点的结构化输出。 -// -// 字段语义: -// 1. reflection:基于真实工具结果的复盘; -// 2. next_strategy:下一轮建议策略; -// 3. should_stop:是否建议结束动作循环; -// 4. stop_reason:建议结束的原因。 type postReflectOutput struct { Reflection string `json:"reflection"` NextStrategy string `json:"next_strategy"` ShouldStop bool `json:"should_stop"` - StopReason string `json:"stop_reason"` } -// plannerOutput 表示 Planner 阶段的结构化输出。 type plannerOutput struct { - Summary string `json:"summary"` - Steps []string `json:"steps"` - SuccessSignals []string `json:"success_signals"` - Fallback string `json:"fallback"` + Summary string `json:"summary"` + Steps []string `json:"steps"` } -// runContractNode 执行“微调契约抽取”。 -// -// 步骤化说明: -// 1. 先把用户本轮请求与当前排程摘要打包给模型,抽取结构化目标。 -// 2. 再把模型输出映射到 state.Contract,作为后续动作与终审共同的判断基准。 -// 3. 若模型失败或解析失败,使用保守兜底契约继续流程,避免整链路中断。 func runContractNode( ctx context.Context, chatModel *ark.ChatModel, @@ -103,24 +75,20 @@ func runContractNode( if chatModel == nil { return nil, fmt.Errorf("schedule refine: model is nil in contract node") } - emitStage("schedule_refine.contract.analyzing", "正在抽取本轮微调目标与硬性约束。") - entryCount := len(st.HybridEntries) - suggestedCount := countSuggested(st.HybridEntries) userPrompt := withNearestJSONContract( fmt.Sprintf( - "当前时间(北京时间)=%s\n用户请求=%s\n当前排程条目数=%d\n可调 suggested 数=%d\n已有约束=%s\n历史摘要=%s", + "当前时间=%s\n用户请求=%s\n当前排程条目数=%d\n可调 suggested 数=%d\n已有约束=%s\n历史摘要=%s", st.RequestNowText, strings.TrimSpace(st.UserMessage), - entryCount, - suggestedCount, + 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) @@ -129,7 +97,6 @@ func runContractNode( return st, nil } emitModelRawDebug(emitStage, "contract", raw) - parsed, parseErr := parseJSON[contractOutput](raw) if parseErr != nil { st.Contract = buildFallbackContract(st) @@ -138,45 +105,283 @@ func runContractNode( return st, nil } - strategy := normalizeStrategy(parsed.Strategy) intent := strings.TrimSpace(parsed.Intent) if intent == "" { intent = strings.TrimSpace(st.UserMessage) } - reason := strings.TrimSpace(parsed.Reason) - if reason == "" { - reason = "已根据本轮请求抽取微调契约。" + // 1. 顺序策略以用户表达为准:默认保持顺序,明确授权乱序才放开。 + // 2. 不再让模型自行放宽顺序,避免契约漂移导致“默认乱序”。 + keepOrder := detectOrderIntent(st.UserMessage) + reqs := append([]string(nil), parsed.HardRequirements...) + if keepOrder { + reqs = append(reqs, "保持任务原始相对顺序不变") } - - // 1. keep_relative_order 既接受模型判断,也允许基于用户原话兜底增强。 - // 2. 这样做的目的:避免模型偶发漏判“保持顺序”导致工具层约束缺失。 - keepRelativeOrder := parsed.KeepRelativeOrder || detectOrderIntent(st.UserMessage) - orderScope := normalizeOrderScope(parsed.OrderScope) - hardRequirements := append([]string(nil), parsed.HardRequirements...) - if keepRelativeOrder { - hardRequirements = append(hardRequirements, "保持任务原始相对顺序不变") + assertions := normalizeHardAssertions(parsed.HardAssertions) + if len(assertions) == 0 { + // 1. 当模型未给出结构化断言时,后端基于请求做兜底推断。 + // 2. 目标是保证终审一定可落到“可编程判断”的参数层,而不是停留在自然语言。 + assertions = inferHardAssertionsFromRequest(st.UserMessage, reqs) } - st.UserIntent = intent st.Contract = RefineContract{ Intent: intent, - Strategy: strategy, - HardRequirements: uniqueNonEmpty(hardRequirements), - KeepRelativeOrder: keepRelativeOrder, - OrderScope: orderScope, - Reason: reason, + 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。", strategy, keepRelativeOrder)) + emitStage("schedule_refine.contract.done", fmt.Sprintf("契约抽取完成:strategy=%s, keep_relative_order=%t。", st.Contract.Strategy, st.Contract.KeepRelativeOrder)) return st, nil } -// runReactLoopNode 执行“强 ReAct 微调循环”。 +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. 严格按 PlanMax/ExecuteMax/ReplanMax 控制规划与执行预算,并把 MaxRounds 对齐为 ExecuteMax+RepairReserve。 -// 2. 每轮先输出“计划/缺口/动作/结果”,再触发一次“动作后真反思(post-reflect)”。 -// 3. 每轮最多一个 tool_call(允许 BatchMove 在单调用内原子多步),失败也写入观察历史,驱动下一轮模型修正策略。 -// 4. 当模型给出 done=true、post-reflect 建议停止、或动作预算耗尽时退出循环。 +// 职责边界: +// 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 + } + + 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 +} + +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, @@ -189,12 +394,20 @@ func runReactLoopNode( 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 st.PlanMax <= 0 { - st.PlanMax = defaultPlanMax + 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 @@ -206,388 +419,299 @@ func runReactLoopNode( st.RepairReserve = 0 } st.MaxRounds = st.ExecuteMax + st.RepairReserve - if st.RepairReserve >= st.MaxRounds { - st.RepairReserve = 0 + 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{ - KeepRelativeOrder: st.Contract.KeepRelativeOrder, + // 1. 执行期不再用顺序约束卡住 Move/Swap; + // 2. LLM 只负责把坑位排好,顺序由后端在收口阶段统一归位。 + KeepRelativeOrder: false, OrderScope: st.Contract.OrderScope, OriginOrderMap: st.OriginOrderMap, } emitStage( "schedule_refine.react.start", - fmt.Sprintf("开始执行 Plan-and-Execute 微调,plan_max=%d,execute_max=%d,replan_max=%d,修复预留=%d。", st.PlanMax, st.ExecuteMax, st.ReplanMax, st.RepairReserve), + 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), + ), ) - // 1. 先规划:Planner 决定“先取证还是先动作”,执行器按计划自由迭代。 - // 2. 规划失败时走后端兜底计划,保证链路可继续。 - if err := runPlannerNode(ctx, chatModel, st, emitStage, "initial"); err != nil { - return st, err - } - - for st.RoundUsed < st.ExecuteMax { - round := st.RoundUsed + 1 - remainingAction := st.ExecuteMax - st.RoundUsed - remainingTotal := st.MaxRounds - st.RoundUsed - - useThinking, reason := shouldEnableRecoveryThinking(st) - emitStage("schedule_refine.react.round_start", fmt.Sprintf("第 %d 轮微调开始,动作剩余=%d,总剩余=%d。", round, remainingAction, remainingTotal)) - if useThinking { - // 用户拍板要求: - // 1. 默认关闭 thinking; - // 2. 连续两次失败后,开启 1 轮 thinking,并把原因通过 SSE 透传给前端。 - emitStage("schedule_refine.react.reasoning_switch", fmt.Sprintf("第 %d 轮|已启用恢复性 thinking:%s", round, reason)) - } - - entriesJSON, _ := json.Marshal(st.HybridEntries) - contractJSON, _ := json.Marshal(st.Contract) - planJSON, _ := json.Marshal(st.CurrentPlan) - observationText := buildObservationPrompt(st.ObservationHistory, 6) - lastObservationText := buildLastToolObservationPrompt(st.ObservationHistory) - lastFailedSignature := fallbackText(st.LastFailedCallSignature, "无") - userPrompt := withNearestJSONContract( - fmt.Sprintf( - "用户本轮请求=%s\n契约=%s\n当前计划=%s\n已有约束=%s\n动作预算剩余=%d\n总预算剩余=%d\nLAST_TOOL_RESULT=%s\nLAST_TOOL_OBSERVATION=%s\nLAST_FAILED_CALL_SIGNATURE=%s\nLAST_POST_STRATEGY=%s\n历史观察=%s\nday_of_week映射=1周一,2周二,3周三,4周四,5周五,6周六,7周日\nsuggested简表=%s\n当前混合日程JSON=%s", - strings.TrimSpace(st.UserMessage), - string(contractJSON), - string(planJSON), - strings.Join(st.Constraints, ";"), - remainingAction, - remainingTotal, - fallbackText(st.LastToolResult, "无"), - lastObservationText, - lastFailedSignature, - fallbackText(st.LastPostStrategy, "无"), - observationText, - buildSuggestedDigest(st.HybridEntries, 80), - string(entriesJSON), - ), - jsonContractForReact, - ) - - // 1. ReAct 节点优先稳定性而非文风多样性: - // 1.1 温度固定 0,降低“同约束下每轮输出漂移”与非结构化长输出概率; - // 1.2 结合 parse_retry,可把“偶发半截 JSON”进一步压低。 - raw, err := callModelText(ctx, chatModel, reactPrompt, userPrompt, useThinking, reactMaxTokens, 0) - if err != nil { - errDetail := formatRoundModelErrorDetail(round, err, ctx) - st.ActionLogs = append(st.ActionLogs, errDetail) - emitStage("schedule_refine.react.round_error", errDetail) - // 1. 若本轮前已产生过有效动作,则超时后不中断整链路。 - // 2. 这样可以避免“前面已调好一部分,后面一轮超时导致全盘失败”。 - if errors.Is(err, context.DeadlineExceeded) && st.RoundUsed > 0 { - emitStage("schedule_refine.react.round_timeout_continue", fmt.Sprintf("第 %d 轮超时,已保留前序结果并继续终审。", round)) +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 } - return st, err + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标看似达成但未满足复合工具门禁:required=%s", normalizeCompositeToolName(st.RequiredCompositeTool))) } - emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.plan", round), raw) - - // 1. 解析重试策略: - // 1.1 首次解析失败时,同轮再请求一次模型输出并再次解析; - // 1.2 重试成功则继续后续动作,不影响本轮链路; - // 1.3 二次解析仍失败时,返回统一业务错误码(respond 包),而不是裸 parseErr。 - parsed, parseErr := parseReactOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, round, emitStage, st) - if parseErr != nil { - return st, parseErr + taskID := st.WorksetTaskIDs[st.WorksetCursor] + current, ok := findSuggestedEntryByTaskID(st.HybridEntries, taskID) + if !ok { + st.WorksetCursor++ + continue } - - observation := ReactRoundObservation{ - Round: round, - GoalCheck: strings.TrimSpace(parsed.GoalCheck), - Decision: strings.TrimSpace(parsed.Decision), - MissingInfo: append([]string(nil), parsed.MissingInfo...), - // 这里先记录“计划备注(动作前)”,执行工具后会用 post-reflect 的真反思覆盖。 - Reflect: strings.TrimSpace(parsed.Reflect), + 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))) - emitStage("schedule_refine.react.plan", formatReactPlanStageDetail(round, parsed, remainingAction, useThinking)) - if useThinking { - emitStage("schedule_refine.react.reasoning_content", fmt.Sprintf("第 %d 轮思考摘要:%s", round, truncate(strings.TrimSpace(parsed.Decision), 180))) - } - emitStage("schedule_refine.react.need_info", formatReactNeedInfoStageDetail(round, parsed.MissingInfo)) + 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), "无"))) + } - if parsed.Done { - doneReason := fallbackText(strings.TrimSpace(parsed.Summary), "模型判定当前方案已满足目标。") - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮主动结束:%s", round, doneReason)) - observation.Reflect = fallbackText(observation.Reflect, doneReason) - st.ObservationHistory = append(st.ObservationHistory, observation) - emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - emitStage("schedule_refine.react.round_done", fmt.Sprintf("第 %d 轮结束:模型返回 done=true。", round)) - break - } + 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)) + } - call, warn := pickSingleToolCall(parsed.ToolCalls) - if warn != "" { - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮告警:%s", round, warn)) - emitStage("schedule_refine.react.round_warn", fmt.Sprintf("第 %d 轮告警:%s", round, warn)) - } - if call == nil { - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮无可执行动作,结束微调。", round)) - observation.Reflect = fallbackText(observation.Reflect, "本轮未生成可执行工具动作。") - st.ObservationHistory = append(st.ObservationHistory, observation) - emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - emitStage("schedule_refine.react.round_done", fmt.Sprintf("第 %d 轮无动作,流程结束。", round)) - break - } + 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 + } - emitStage("schedule_refine.react.tool_call", formatToolCallStageDetail(round, *call, remainingAction)) + 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)) - callSignature := buildToolCallSignature(*call) - if isRepeatedFailedCall(st, callSignature) { - // 1. 后端硬兜底: - // 1.1 若本轮动作与“上一轮失败动作签名”完全一致,直接拒绝执行,防止模型在同一坑位空转; - // 1.2 该失败会结构化写回上下文,驱动下一轮明确改道(换时段或改用 Swap)。 - result := normalizeToolResult(reactToolResult{ - Tool: strings.TrimSpace(call.Tool), - Success: false, - ErrorCode: "REPEAT_FAILED_ACTION", - Result: "重复失败动作:与上一轮失败动作完全相同,请更换目标时段或改用 Swap。", - }) + 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++ - st.LastToolResult = formatStructuredToolResult(result) - st.LastFailedCallSignature = callSignature - st.ConsecutiveFailures++ + 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, nextStrategy, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) + postReflectText, _, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) observation.Reflect = postReflectText st.ObservationHistory = append(st.ObservationHistory, observation) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮动作被拒绝:tool=%s error_code=%s detail=%s", round, result.Tool, result.ErrorCode, result.Result)) - emitStage("schedule_refine.react.tool_blocked", fmt.Sprintf("第 %d 轮|检测到重复失败动作,已拒绝执行并要求模型改道。", round)) emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - st.LastPostStrategy = fallbackText(nextStrategy, st.LastPostStrategy) - if shouldTriggerReplan(st, result) { - if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil { - return st, err - } else if replanned { - continue + if 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 { - emitStage("schedule_refine.react.round_done", fmt.Sprintf("第 %d 轮结束:post-reflect 建议停止。", round)) - break - } - continue - } - - nextEntries, rawResult := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, window, policy) - result := normalizeToolResult(rawResult) - st.RoundUsed++ - st.LastToolResult = formatStructuredToolResult(result) - - observation.ToolName = strings.TrimSpace(result.Tool) - observation.ToolParams = cloneToolParams(call.Params) - observation.ToolSuccess = result.Success - observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) - observation.ToolResult = strings.TrimSpace(result.Result) - postReflectText, nextStrategy, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) - observation.Reflect = postReflectText - st.ObservationHistory = append(st.ObservationHistory, observation) - - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("第 %d 轮动作:tool=%s success=%t detail=%s", round, result.Tool, result.Success, result.Result)) - emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) - emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) - st.LastPostStrategy = fallbackText(nextStrategy, st.LastPostStrategy) - - if result.Success { - st.HybridEntries = nextEntries - window = buildPlanningWindowFromEntries(st.HybridEntries) - st.LastFailedCallSignature = "" - st.ConsecutiveFailures = 0 - st.ThinkingBoostArmed = false - } else { - st.LastFailedCallSignature = callSignature - st.ConsecutiveFailures++ - if shouldTriggerReplan(st, result) { - if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil { - return st, err - } else if replanned { - continue + // 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))) } } } - if shouldStop { - emitStage("schedule_refine.react.round_done", fmt.Sprintf("第 %d 轮结束:post-reflect 建议停止。", round)) - break - } - } - emitStage("schedule_refine.react.done", fmt.Sprintf("Plan-and-Execute 微调结束,已执行动作轮次=%d,重规划次数=%d。", st.RoundUsed, st.ReplanUsed)) + 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 } -// runPlannerNode 执行一次 Planner 规划。 -// -// 步骤化说明: -// 1. 读取当前约束、最近观察、失败上下文,生成结构化执行计划; -// 2. 规划失败时使用后端兜底计划,保证执行器仍可继续; -// 3. mode=initial/replan 仅用于阶段展示和日志区分。 -func runPlannerNode( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), - mode string, -) error { - if st == nil || chatModel == nil { - return fmt.Errorf("planner: invalid input") - } - if st.PlanUsed >= st.PlanMax { - return nil - } - stage := "schedule_refine.plan.generating" - if strings.TrimSpace(mode) == "replan" { - stage = "schedule_refine.plan.regenerating" - } - emitStage(stage, fmt.Sprintf("正在生成执行计划(mode=%s,已用%d/%d)。", mode, st.PlanUsed, st.PlanMax)) - - contractJSON, _ := json.Marshal(st.Contract) - observationText := buildObservationPrompt(st.ObservationHistory, 6) - userPrompt := withNearestJSONContract( - fmt.Sprintf( - "mode=%s\n用户请求=%s\n契约=%s\n已有约束=%s\n上一轮工具结果=%s\n上一轮策略=%s\n最近观察=%s\nsuggested简表=%s", - mode, - strings.TrimSpace(st.UserMessage), - string(contractJSON), - strings.Join(st.Constraints, ";"), - fallbackText(st.LastToolResult, "无"), - fallbackText(st.LastPostStrategy, "无"), - observationText, - buildSuggestedDigest(st.HybridEntries, 80), - ), - jsonContractForPlanner, - ) - - raw, err := callModelText(ctx, chatModel, plannerPrompt, userPrompt, false, plannerMaxTokens, 0) - if err != nil { - st.CurrentPlan = buildFallbackPlan(st) - st.PlanUsed++ - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("Planner 调用失败,已使用兜底计划:%v", err)) - emitStage("schedule_refine.plan.fallback", "Planner 调用失败,已切换后端兜底计划。") - return nil - } - emitModelRawDebug(emitStage, fmt.Sprintf("planner.%s", mode), raw) - - parsed, parseErr := parsePlannerOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, mode, emitStage) - if parseErr != nil { - st.CurrentPlan = buildFallbackPlan(st) - st.PlanUsed++ - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("Planner 解析失败,已使用兜底计划:%v", parseErr)) - emitStage("schedule_refine.plan.fallback", fmt.Sprintf("Planner 输出解析失败,已切换后端兜底计划:%s", truncate(parseErr.Error(), 180))) - return nil - } - - st.CurrentPlan = PlannerPlan{ - Summary: fallbackText(strings.TrimSpace(parsed.Summary), "已生成可执行计划。"), - Steps: uniqueNonEmpty(parsed.Steps), - SuccessSignals: uniqueNonEmpty(parsed.SuccessSignals), - Fallback: strings.TrimSpace(parsed.Fallback), - } - st.PlanUsed++ - emitStage("schedule_refine.plan.done", fmt.Sprintf("规划完成:%s", truncate(st.CurrentPlan.Summary, 180))) - return nil -} - -// buildFallbackPlan 构造“Planner 失败时兜底计划”。 -func buildFallbackPlan(st *ScheduleRefineState) PlannerPlan { - summary := "兜底计划:先取证再动作,优先原子批量移动,失败后改道。" - if st != nil && st.Contract.KeepRelativeOrder { - summary = "兜底计划:先取证再动作,严格保持相对顺序,优先原子批量移动。" - } - return PlannerPlan{ - Summary: summary, - Steps: []string{ - "1) 调用 QueryTargetTasks 定位目标任务", - "2) 调用 QueryAvailableSlots 获取可用时段", - "3) 优先尝试 BatchMove,失败后改用 Move/Swap", - "4) 收尾前调用 Verify 做确定性自检", - }, - SuccessSignals: []string{ - "工具动作成功且无冲突", - "Verify 通过", - }, - Fallback: "若连续失败,重规划并更换工具路径。", - } -} - -// shouldEnableRecoveryThinking 判断本轮是否触发“失败兜底 thinking”。 -// -// 规则: -// 1. 默认关闭 thinking; -// 2. 连续失败达到 2 次时,仅开启 1 轮 thinking; -// 3. 在同一失败串里只触发一次,直到出现成功再重置。 -func shouldEnableRecoveryThinking(st *ScheduleRefineState) (bool, string) { - if st == nil { - return false, "" - } - if st.ConsecutiveFailures < 2 { - return false, "" - } - if st.ThinkingBoostArmed { - return false, "" - } - st.ThinkingBoostArmed = true - return true, fmt.Sprintf("连续失败=%d,触发1轮恢复性 thinking", st.ConsecutiveFailures) -} - -// shouldTriggerReplan 判断是否应该进入重规划。 -// -// 触发条件: -// 1. 连续失败 >=3; -// 2. 且错误码属于“路径错误类”(冲突/顺序/重复失败/参数缺失/批量失败)。 -func shouldTriggerReplan(st *ScheduleRefineState, result reactToolResult) bool { - if st == nil { - return false - } - if st.ConsecutiveFailures < 3 { - return false - } - switch strings.TrimSpace(result.ErrorCode) { - case "SLOT_CONFLICT", "ORDER_VIOLATION", "REPEAT_FAILED_ACTION", "PARAM_MISSING", "BATCH_MOVE_FAILED", "VERIFY_FAILED": - return true - default: - return false - } -} - -// tryReplan 在满足条件时触发一次重规划。 -func tryReplan( - ctx context.Context, - chatModel *ark.ChatModel, - st *ScheduleRefineState, - emitStage func(stage, detail string), -) (bool, error) { - if st == nil { - return false, nil - } - if st.ReplanUsed >= st.ReplanMax { - return false, nil - } - if st.PlanUsed >= st.PlanMax { - return false, nil - } - st.ReplanUsed++ - emitStage("schedule_refine.plan.replan_trigger", fmt.Sprintf("连续失败=%d,触发重规划(%d/%d)。", st.ConsecutiveFailures, st.ReplanUsed, st.ReplanMax)) - if err := runPlannerNode(ctx, chatModel, st, emitStage, "replan"); err != nil { - return true, err - } - // 1. 重规划后重置失败串,避免刚重规划就再次被失败门槛立即打断; - // 2. 同时允许后续再次触发一次 thinking 兜底。 - st.ConsecutiveFailures = 0 - st.ThinkingBoostArmed = false - return true, nil -} - -// runHardCheckNode 执行“物理校验 + 顺序校验 + 语义校验 + 单次修复”。 func runHardCheckNode( ctx context.Context, chatModel *ark.ChatModel, @@ -600,21 +724,27 @@ func runHardCheckNode( 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 := normalizeMovableTaskOrderByOrigin(st); 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 { @@ -622,20 +752,25 @@ func runHardCheckNode( 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 := normalizeMovableTaskOrderByOrigin(st); 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 } -// runSummaryNode 生成最终用户可读总结,并回填结构化预览字段。 func runSummaryNode( ctx context.Context, chatModel *ark.ChatModel, @@ -648,23 +783,12 @@ func runSummaryNode( if chatModel == nil { return nil, fmt.Errorf("schedule refine: model is nil in summary node") } - emitStage("schedule_refine.summary.generating", "正在生成微调结果总结。") - updateAllocatedItemsFromEntries(st) st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries) - reportJSON, _ := json.Marshal(st.HardCheck) - actionLogText := summarizeActionLogs(st.ActionLogs, 24) contractJSON, _ := json.Marshal(st.Contract) - userPrompt := fmt.Sprintf( - "用户请求=%s\n契约=%s\n终审报告=%s\n动作日志=%s", - strings.TrimSpace(st.UserMessage), - string(contractJSON), - string(reportJSON), - actionLogText, - ) - + 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 { @@ -672,32 +796,42 @@ func runSummaryNode( } if err != nil || summary == "" { if st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed { - summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作。当前方案已通过终审校验,可以继续使用。", st.RoundUsed) + summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) } else { - summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确你的微调要求")) + summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) } } - + summary = alignSummaryWithHardCheck(st, summary) st.FinalSummary = summary st.Completed = true emitStage("schedule_refine.summary.done", "微调总结已生成。") return st, nil } -// evaluateHardChecks 执行一次完整硬校验(物理 + 顺序 + 语义)。 func evaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) HardCheckReport { report := HardCheckReport{} - report.PhysicsIssues = physicsCheck(st.HybridEntries, len(st.AllocatedItems)) report.PhysicsPassed = len(report.PhysicsIssues) == 0 - + // 1. 顺序校验默认开启:即便执行期放开顺序限制,终审也要验证“后端归位”后的顺序正确性。 + // 2. 当 origin_order_map 为空时降级跳过,避免无基线时误报。 + needOrderCheck := len(st.OriginOrderMap) > 0 report.OrderIssues = validateRelativeOrder(st.HybridEntries, refineToolPolicy{ - KeepRelativeOrder: st.Contract.KeepRelativeOrder, + 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 @@ -705,13 +839,472 @@ func evaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *Sched report.IntentUnmet = []string{"语义校验阶段异常"} return report } - report.IntentPassed = review.Pass - report.IntentReason = strings.TrimSpace(review.Reason) - report.IntentUnmet = append([]string(nil), review.Unmet...) + 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 } -// runSingleRepairAction 在终审失败后执行一次修复动作。 +// 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 +} + func runSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) error { if st == nil { return fmt.Errorf("nil state") @@ -722,12 +1315,11 @@ func runSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *Sc 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", + "用户请求=%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, ";"), @@ -735,7 +1327,6 @@ func runSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *Sc ), jsonContractForReact, ) - raw, err := callModelText(ctx, chatModel, repairPrompt, userPrompt, false, 240, 0.15) if err != nil { return err @@ -745,7 +1336,6 @@ func runSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *Sc if parseErr != nil { return parseErr } - call, warn := pickSingleToolCall(parsed.ToolCalls) if warn != "" { st.ActionLogs = append(st.ActionLogs, "修复阶段告警:"+warn) @@ -753,30 +1343,32 @@ func runSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *Sc 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)) - - policy := refineToolPolicy{ - KeepRelativeOrder: st.Contract.KeepRelativeOrder, + nextEntries, result := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, buildPlanningWindowFromEntries(st.HybridEntries), refineToolPolicy{ + KeepRelativeOrder: false, OrderScope: st.Contract.OrderScope, OriginOrderMap: st.OriginOrderMap, - } - nextEntries, result := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, buildPlanningWindowFromEntries(st.HybridEntries), policy) + }) result = normalizeToolResult(result) st.RoundUsed++ - st.LastToolResult = formatStructuredToolResult(result) - st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("修复动作:tool=%s success=%t detail=%s", result.Tool, result.Success, result.Result)) emitStage("schedule_refine.hard_check.repair_result", formatToolResultStageDetail(st.RoundUsed, result, st.RoundUsed, st.MaxRounds)) - if !result.Success { st.LastFailedCallSignature = buildToolCallSignature(*call) return fmt.Errorf("修复动作执行失败:%s", result.Result) } st.LastFailedCallSignature = "" st.HybridEntries = nextEntries + if isMutatingToolName(result.Tool) { + st.EntriesVersion++ + } return nil } -// runSemanticReview 通过模型判断“当前方案是否满足用户本轮目标”。 func runSemanticReview(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) (*reviewOutput, error) { entriesJSON, _ := json.Marshal(st.HybridEntries) contractJSON, _ := json.Marshal(st.Contract) @@ -799,13 +1391,6 @@ func runSemanticReview(ctx context.Context, chatModel *ark.ChatModel, st *Schedu return parseReviewOutput(raw) } -// runPostReflectAfterTool 执行“工具动作后的真反思”。 -// -// 步骤化说明: -// 1. 输入本轮计划、工具调用参数、后端真实工具结果; -// 2. 调用专用 postReflectPrompt,让模型基于真实结果给出复盘与下一步策略; -// 3. 解析失败时使用后端兜底复盘文本,保证链路不被“反思失败”拖垮; -// 4. 返回反思文本与 shouldStop 标记,供主循环决定是否提前结束。 func runPostReflectAfterTool( ctx context.Context, chatModel *ark.ChatModel, @@ -819,35 +1404,26 @@ func runPostReflectAfterTool( if st == nil || chatModel == nil || call == nil { return buildPostReflectFallback(plan, result), "", false } - emitStage("schedule_refine.react.post_reflect.start", fmt.Sprintf("第 %d 轮|正在基于工具真实结果进行反思。", round)) - contractJSON, _ := json.Marshal(st.Contract) callJSON, _ := json.Marshal(call) resultJSON, _ := json.Marshal(result) - planGoal := "" planDecision := "" - planNote := "" if plan != nil { - planGoal = strings.TrimSpace(plan.GoalCheck) planDecision = strings.TrimSpace(plan.Decision) - planNote = strings.TrimSpace(plan.Reflect) } userPrompt := withNearestJSONContract( fmt.Sprintf( - "用户请求=%s\n契约=%s\n本轮计划.goal_check=%s\n本轮计划.decision=%s\n本轮计划.note=%s\n本轮工具调用=%s\n本轮工具结果=%s\n最近观察=%s\n", + "用户请求=%s\n契约=%s\n本轮计划.decision=%s\n本轮工具调用=%s\n本轮工具结果=%s\n最近观察=%s", strings.TrimSpace(st.UserMessage), string(contractJSON), - planGoal, planDecision, - planNote, string(callJSON), string(resultJSON), - buildObservationPrompt(st.ObservationHistory, 4), + buildObservationPrompt(st.ObservationHistory, 2), ), jsonContractForPostReflect, ) - raw, err := callModelText(ctx, chatModel, postReflectPrompt, userPrompt, false, 220, 0) if err != nil { fallback := buildPostReflectFallback(plan, result) @@ -855,14 +1431,12 @@ func runPostReflectAfterTool( 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) @@ -872,35 +1446,359 @@ func runPostReflectAfterTool( reflection = fmt.Sprintf("%s;下一步建议:%s", reflection, nextStrategy) } shouldStop := parsed.ShouldStop - stopReason := strings.TrimSpace(parsed.StopReason) - if shouldStop { - if stopReason == "" { - stopReason = "模型判定继续动作收益较低,建议转终审。" - } - reflection = fmt.Sprintf("%s;停止建议:%s", reflection, stopReason) - } - emitStage( - "schedule_refine.react.post_reflect.done", - fmt.Sprintf("第 %d 轮|模型反思=%s|下一步=%s|should_stop=%t", round, truncate(strings.TrimSpace(parsed.Reflection), 120), truncate(nextStrategy, 120), shouldStop), - ) + emitStage("schedule_refine.react.post_reflect.done", fmt.Sprintf("第 %d 轮|模型反思=%s|下一步=%s|should_stop=%t", round, truncate(strings.TrimSpace(parsed.Reflection), 120), truncate(nextStrategy, 120), shouldStop)) return reflection, nextStrategy, shouldStop } -// buildPostReflectFallback 生成“动作后真反思”的后端兜底文案。 -// -// 说明: -// 1. 当 post-reflect 模型调用/解析失败时,仍需给前端可解释文本; -// 2. 兜底文本以真实工具结果为主,计划备注仅作补充; -// 3. 该函数不决定 shouldStop,只负责生成可读复盘。 func buildPostReflectFallback(plan *reactLLMOutput, result reactToolResult) string { - planNote := "" + modelReflect := "" if plan != nil { - planNote = strings.TrimSpace(plan.Reflect) + modelReflect = strings.TrimSpace(plan.Decision) } - return buildRuntimeReflect(planNote, result) + 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 } -// callModelText 统一封装模型调用,避免各节点重复拼装参数。 func callModelText( ctx context.Context, chatModel *ark.ChatModel, @@ -915,7 +1813,6 @@ func callModelText( } nodeCtx, cancel := context.WithTimeout(ctx, nodeTimeout) defer cancel() - thinkingType := arkModel.ThinkingTypeDisabled if useThinking { thinkingType = arkModel.ThinkingTypeEnabled @@ -927,7 +1824,6 @@ func callModelText( if maxTokens > 0 { opts = append(opts, einoModel.WithMaxTokens(maxTokens)) } - resp, err := chatModel.Generate(nodeCtx, []*schema.Message{ schema.SystemMessage(systemPrompt), schema.UserMessage(userPrompt), @@ -954,7 +1850,6 @@ func callModelText( return content, nil } -// parseJSON 是通用 JSON 解析器,兼容 markdown code fence。 func parseJSON[T any](raw string) (*T, error) { clean := strings.TrimSpace(raw) if clean == "" { @@ -980,12 +1875,6 @@ func parseJSON[T any](raw string) (*T, error) { return &out, nil } -// extractFirstJSONObject 从文本中提取“第一个完整 JSON 对象”。 -// -// 设计说明: -// 1. 相比“first { + last }”的粗糙截取,这里使用括号配对,避免模型输出多段文本时误截; -// 2. 兼容字符串内大括号(通过字符串状态机跳过); -// 3. 提取失败时返回明确错误,便于上层阶段日志提示。 func extractFirstJSONObject(text string) (string, error) { start := strings.Index(text, "{") if start < 0 { @@ -1028,12 +1917,6 @@ func extractFirstJSONObject(text string) (string, error) { return "", fmt.Errorf("json object not closed") } -// emitModelRawDebug 统一输出模型原始文本到 SSE 调试阶段。 -// -// 规则: -// 1. 所有模型节点都可调用该函数输出原始 raw,帮助定位解析失败; -// 2. detail 统一带 `[debug][tag]` 前缀,满足前端快速筛选; -// 3. 当 raw 过长时,按分片逐条输出,避免“单条截断导致看起来像 JSON 不闭合”的误判。 func emitModelRawDebug(emitStage func(stage, detail string), tag string, raw string) { if emitStage == nil { return @@ -1042,15 +1925,10 @@ func emitModelRawDebug(emitStage func(stage, detail string), tag string, raw str if clean == "" { clean = "" } - - // 1. 这里按 rune 分片而不是按 byte 分片,避免中文被截断后出现乱码。 - // 2. 每片控制在较小体量,降低 SSE 单条过大造成前端展示异常或丢帧。 - // 3. 分片时携带 part 序号,便于前端/日志侧拼接复盘完整 raw。 const chunkSize = 1600 - tag = strings.TrimSpace(tag) runes := []rune(clean) if len(runes) <= chunkSize { - emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s] %s", tag, clean)) + emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s] %s", strings.TrimSpace(tag), clean)) return } total := (len(runes) + chunkSize - 1) / chunkSize @@ -1060,15 +1938,10 @@ func emitModelRawDebug(emitStage func(stage, detail string), tag string, raw str if end > len(runes) { end = len(runes) } - part := string(runes[start:end]) - emitStage( - "schedule_refine.debug.raw", - fmt.Sprintf("[debug][%s][part %d/%d] %s", tag, i+1, total, part), - ) + emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s][part %d/%d] %s", strings.TrimSpace(tag), i+1, total, string(runes[start:end]))) } } -// physicsCheck 做确定性物理校验。 func physicsCheck(entries []model.HybridScheduleEntry, allocatedCount int) []string { issues := make([]string, 0, 8) slotMap := make(map[string]string, len(entries)*2) @@ -1079,10 +1952,10 @@ func physicsCheck(entries []model.HybridScheduleEntry, allocatedCount int) []str if !entryBlocksSuggested(entry) { continue } - for section := entry.SectionFrom; section <= entry.SectionTo; section++ { - key := fmt.Sprintf("%d-%d-%d", entry.Week, entry.DayOfWeek, section) + 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, section)) + issues = append(issues, fmt.Sprintf("冲突:%s 与 %s 同时占用 W%dD%d 第%d节", existed, entry.Name, entry.Week, entry.DayOfWeek, sec)) } else { slotMap[key] = entry.Name } @@ -1103,7 +1976,7 @@ func updateAllocatedItemsFromEntries(st *ScheduleRefineState) { } byTaskID := make(map[int]model.HybridScheduleEntry, len(st.HybridEntries)) for _, entry := range st.HybridEntries { - if entry.Status == "suggested" && entry.TaskItemID > 0 { + if isMovableSuggestedTask(entry) { byTaskID[entry.TaskItemID] = entry } } @@ -1126,7 +1999,7 @@ func updateAllocatedItemsFromEntries(st *ScheduleRefineState) { func countSuggested(entries []model.HybridScheduleEntry) int { count := 0 for _, entry := range entries { - if entry.Status == "suggested" { + if isMovableSuggestedTask(entry) { count++ } } @@ -1151,12 +2024,6 @@ func fallbackText(text string, fallback string) string { return clean } -// withNearestJSONContract 把“严格 JSON 输出约束”追加到 userPrompt 末尾。 -// -// 步骤化说明: -// 1. 先做 trim,避免多余空白影响模型对结尾指令的关注; -// 2. 再把结构化约束放在最后两行,确保它离模型输出位置最近; -// 3. 若约束为空则原样返回,避免把空字符串误拼进 prompt。 func withNearestJSONContract(userPrompt string, jsonContract string) string { base := strings.TrimSpace(userPrompt) rule := strings.TrimSpace(jsonContract) @@ -1169,29 +2036,49 @@ func withNearestJSONContract(userPrompt string, jsonContract string) string { 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 := st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed + 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), - ) + return fmt.Sprintf("第 %d 轮|thinking=%t|动作剩余=%d|goal_check=%s|decision=%s", round, useThinking, remaining, truncate(strings.TrimSpace(out.GoalCheck), 180), truncate(strings.TrimSpace(out.Decision), 180)) } func formatReactNeedInfoStageDetail(round int, missing []string) string { if len(missing) == 0 { return fmt.Sprintf("第 %d 轮|模型缺口信息=无。", round) } - return fmt.Sprintf("第 %d 轮|模型缺口信息=%s", round, truncate(strings.Join(uniqueNonEmpty(missing), ";"), 260)) + return fmt.Sprintf("第 %d 轮|模型缺口信息=%s", round, strings.Join(uniqueNonEmpty(missing), ";")) } func formatReactReflectStageDetail(round int, reflect string) string { - // 这里统一用“复盘”而不是“反思”: - // 1. 当前内容由“后端真实执行结果 + 模型预期说明”拼接而成,不是纯模型自述; - // 2. 用词改为复盘,能更准确表达“以执行结果为准”的定位,减少用户误解为“模型已经真的完成了这一步”。 return fmt.Sprintf("第 %d 轮|复盘=%s", round, truncate(strings.TrimSpace(reflect), 260)) } @@ -1213,10 +2100,7 @@ func formatToolResultStageDetail(round int, result reactToolResult, used int, to 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, - ) + 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 { @@ -1271,30 +2155,25 @@ func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.U for week, events := range weekMap { result = append(result, model.UserWeekSchedule{Week: week, Events: events}) } - for i := 0; i < len(result); i++ { - for j := i + 1; j < len(result); j++ { - if result[j].Week < result[i].Week { - result[i], result[j] = result[j], result[i] - } - } - } + 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) - hardRequirements := append([]string(nil), st.Constraints...) + reqs := append([]string(nil), st.Constraints...) if keepOrder { - hardRequirements = append(hardRequirements, "保持任务原始相对顺序不变") + reqs = append(reqs, "保持任务原始相对顺序不变") } + assertions := inferHardAssertionsFromRequest(st.UserMessage, reqs) return RefineContract{ Intent: intent, Strategy: "local_adjust", - HardRequirements: uniqueNonEmpty(hardRequirements), + HardRequirements: uniqueNonEmpty(reqs), + HardAssertions: assertions, KeepRelativeOrder: keepOrder, OrderScope: "global", - Reason: "契约抽取失败,按兜底策略继续。", } } @@ -1310,15 +2189,16 @@ func normalizeStrategy(strategy string) string { func detectOrderIntent(userMessage string) bool { msg := strings.TrimSpace(userMessage) if msg == "" { - return false + return true } - keywords := []string{"顺序不变", "保持顺序", "按原顺序", "不要打乱顺序", "不打乱顺序", "先后顺序", "原顺序"} - for _, k := range keywords { + // 1. 默认启用顺序约束,除非用户明确授权可打乱顺序。 + // 2. 这样可避免“用户没提顺序但结果被打乱”的违和体验。 + for _, k := range []string{"可以打乱顺序", "允许打乱顺序", "顺序无所谓", "不考虑顺序", "不用保持顺序", "无需保持顺序", "随便排顺序", "乱序也行"} { if strings.Contains(msg, k) { - return true + return false } } - return false + return true } func uniqueNonEmpty(items []string) []string { @@ -1351,17 +2231,11 @@ func buildObservationPrompt(history []ReactRoundObservation, tail int) string { } raw, err := json.Marshal(history[start:]) if err != nil { - return summarizeActionLogs([]string{err.Error()}, 1) + return err.Error() } return string(raw) } -// buildLastToolObservationPrompt 返回“上一轮结构化工具观察”。 -// -// 步骤化说明: -// 1. 从观察历史末尾向前找最近一条带工具名的记录,避免把“done轮/无动作轮”误当工具观察; -// 2. 输出 JSON 字符串,供模型按结构化字段读取 success/error_code/params; -// 3. 若不存在工具观察则返回“无”。 func buildLastToolObservationPrompt(history []ReactRoundObservation) string { for i := len(history) - 1; i >= 0; i-- { item := history[i] @@ -1377,12 +2251,6 @@ func buildLastToolObservationPrompt(history []ReactRoundObservation) string { return "无" } -// buildToolCallSignature 构造工具调用签名(tool+params)。 -// -// 说明: -// 1. 用于识别“与上一轮失败动作完全相同”的重复调用; -// 2. 采用 JSON 序列化参数,保证签名稳定、可记录、可回放; -// 3. 签名只用于去重,不用于业务持久化。 func buildToolCallSignature(call reactToolCall) string { paramsText := "{}" if len(call.Params) > 0 { @@ -1393,25 +2261,287 @@ func buildToolCallSignature(call reactToolCall) string { return fmt.Sprintf("%s|%s", strings.ToUpper(strings.TrimSpace(call.Tool)), paramsText) } -// isRepeatedFailedCall 判断当前动作是否重复了“上一轮失败动作”。 +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) - if current == "" || last == "" { - return false - } - return current == last + return current != "" && last != "" && current == last } -// normalizeToolResult 对工具结果做统一规范化。 -// -// 步骤化说明: -// 1. 成功结果保留现状; -// 2. 失败结果若未设置 error_code,则按结果文案推断统一错误码; -// 3. 统一错误码后,可被模型下一轮稳定消费,减少“读不懂上一轮失败原因”。 func normalizeToolResult(result reactToolResult) reactToolResult { if result.Success { return result @@ -1423,10 +2553,13 @@ func normalizeToolResult(result reactToolResult) reactToolResult { return result } -// classifyToolFailureCode 把工具失败文案映射为稳定错误码。 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, "顺序约束不满足"): @@ -1435,6 +2568,8 @@ func classifyToolFailureCode(detail string) string { 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, "超出允许窗口"): @@ -1458,27 +2593,6 @@ func classifyToolFailureCode(detail string) string { } } -// formatStructuredToolResult 把工具执行结果编码为结构化文本。 -// -// 说明: -// 1. 该字符串会写入 state,并在下一轮 prompt 以 LAST_TOOL_RESULT 透传给模型; -// 2. 采用 JSON 结构,减少模型对自然语言描述的误读; -// 3. 编码失败时降级为简短纯文本,避免链路中断。 -func formatStructuredToolResult(result reactToolResult) string { - obj := map[string]any{ - "tool": strings.TrimSpace(result.Tool), - "success": result.Success, - "error_code": strings.TrimSpace(result.ErrorCode), - "result": strings.TrimSpace(result.Result), - } - raw, err := json.Marshal(obj) - if err != nil { - return fmt.Sprintf("tool=%s success=%t error_code=%s result=%s", result.Tool, result.Success, result.ErrorCode, result.Result) - } - return string(raw) -} - -// cloneToolParams 深拷贝工具参数,避免后续 map 复用造成历史观察污染。 func cloneToolParams(params map[string]any) map[string]any { if len(params) == 0 { return nil @@ -1525,15 +2639,11 @@ func buildRuntimeReflect(modelReflect string, result reactToolResult) string { if modelText == "" { return fmt.Sprintf("后端复盘:工具执行成功。%s", resultText) } - // 1. 成功分支下,模型文本仅作为“动作前预期”的补充说明; - // 2. 业务上真正生效的是后端工具结果,因此前缀固定写“后端复盘”。 return fmt.Sprintf("后端复盘:工具执行成功。%s。模型预期(动作前):%s", resultText, truncate(modelText, 180)) } if modelText == "" { return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。", resultText) } - // 1. 失败分支必须把“未生效”写死,防止用户把模型话术当成已执行事实; - // 2. 模型文本仅保留为“动作前预期”,用于解释它为什么会选这一步。 return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。模型预期(动作前,仅供参考):%s", resultText, truncate(modelText, 160)) } @@ -1543,7 +2653,7 @@ func buildSuggestedDigest(entries []model.HybridScheduleEntry, limit int) string } list := make([]model.HybridScheduleEntry, 0, len(entries)) for _, entry := range entries { - if entry.Status == "suggested" && entry.TaskItemID > 0 { + if isMovableSuggestedTask(entry) { list = append(list, entry) } } @@ -1559,20 +2669,27 @@ func buildSuggestedDigest(entries []model.HybridScheduleEntry, limit int) string } 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), - )) + 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: @@ -1594,13 +2711,6 @@ func weekdayLabel(day int) string { } } -// parseReactOutputWithRetryOnce 对 ReAct 输出做“单次重试解析”。 -// -// 步骤化说明: -// 1. 先解析首次模型输出,成功即直接返回。 -// 2. 首次解析失败时,同轮重试一次模型调用(关闭 thinking + 温度置 0),提升结构化稳定性。 -// 3. 若重试后解析成功,则发出成功阶段信号并继续流程。 -// 4. 若重试调用或二次解析仍失败,则返回统一业务错误码,避免前端拿到不可控的原始解析错误。 func parseReactOutputWithRetryOnce( ctx context.Context, chatModel *ark.ChatModel, @@ -1613,43 +2723,26 @@ func parseReactOutputWithRetryOnce( if st == nil { return nil, respond.ScheduleRefineOutputParseFailed } - parsed, parseErr := parseReactLLMOutput(firstRaw) if parseErr == nil { return parsed, nil } - - firstFail := fmt.Sprintf("第 %d 轮输出解析失败,准备重试1次:%s", round, truncate(parseErr.Error(), 260)) - st.ActionLogs = append(st.ActionLogs, firstFail) - emitStage("schedule_refine.react.parse_retry", firstFail) - + 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 { - retryErrDetail := formatRoundModelErrorDetail(round, fmt.Errorf("解析重试调用失败: %w", retryErr), ctx) - st.ActionLogs = append(st.ActionLogs, retryErrDetail) - emitStage("schedule_refine.react.round_error", retryErrDetail) + 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 { - secondFail := fmt.Sprintf("第 %d 轮输出二次解析失败:%s", round, truncate(retryParseErr.Error(), 260)) - st.ActionLogs = append(st.ActionLogs, secondFail) - emitStage("schedule_refine.react.round_error", secondFail) + 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 } -// parsePlannerOutputWithRetryOnce 对 Planner 输出做“单次重试解析”。 -// -// 步骤化说明: -// 1. 先解析首次 Planner 输出,成功则直接返回; -// 2. 若失败,触发一次“严格 JSON 重试请求”,并打出 retry raw debug; -// 3. 若重试仍失败,返回错误给上层,由上层走兜底计划。 func parsePlannerOutputWithRetryOnce( ctx context.Context, chatModel *ark.ChatModel, @@ -1662,17 +2755,9 @@ func parsePlannerOutputWithRetryOnce( 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)), - ) - + 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, - ), + fmt.Sprintf("%s\n\n上一轮输出解析失败(原因:JSON 不完整或不闭合)。请缩短内容并严格输出完整 JSON。", originUserPrompt), jsonContractForPlanner, ) retryRaw, retryErr := callModelText(ctx, chatModel, plannerPrompt, retryPrompt, false, plannerMaxTokens, 0) @@ -1680,7 +2765,6 @@ func parsePlannerOutputWithRetryOnce( 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 @@ -1688,3 +2772,539 @@ func parsePlannerOutputWithRetryOnce( 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/agent/schedulerefine/prompt.go b/backend/agent/schedulerefine/prompt.go index cbec335..e0be09d 100644 --- a/backend/agent/schedulerefine/prompt.go +++ b/backend/agent/schedulerefine/prompt.go @@ -1,173 +1,164 @@ package schedulerefine const ( - // contractPrompt 用于“微调契约抽取”节点。 - // - // 目标: - // 1. 把用户自然语言微调请求收敛成结构化契约; - // 2. 明确是否需要“保持相对顺序不变”; - // 3. 严格输出 JSON,降低解析抖动。 + // contractPrompt 负责把用户自然语言微调请求抽取为结构化契约。 contractPrompt = `你是 SmartFlow 的排程微调契约分析器。 -你会收到:当前时间、用户本轮微调请求、已有排程摘要。 -你的任务是把“用户真正想改什么”转成结构化契约。 - -请只输出 JSON,不要 markdown,不要解释,字段如下: +你会收到:当前时间、用户请求、已有排程摘要。 +请只输出 JSON,不要 Markdown,不要解释,不要代码块: { - "intent": "一句话概括用户本轮微调目标", + "intent": "一句话概括本轮微调目标", "strategy": "local_adjust|keep", - "hard_requirements": ["必须满足的硬性要求1","硬性要求2"], + "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", - "reason": "简短中文原因,<=40字" + "order_scope": "global|week" } 规则: -1) 当用户表达“保持原顺序/不打乱顺序/按原顺序推进”时,keep_relative_order=true。 -2) 若用户没有提顺序要求,keep_relative_order=false,order_scope 固定输出 "global"。 -3) strategy=keep 仅用于“无需改动”的情况;只要要移动任务,就输出 local_adjust。 -4) hard_requirements 要可验证,避免空话。` +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 用于“Plan-and-Execute”的规划阶段。 - // - // 目标: - // 1. 让模型按当前请求自动规划“先取证再动作”的执行路径; - // 2. 规划结果要求结构化,便于执行阶段直接引用; - // 3. 不在 Planner 阶段执行工具,只负责产出计划。 - plannerPrompt = `你是 SmartFlow 的排程微调规划器(Planner)。 -你会收到:用户请求、契约、最近动作日志与观察。 -你的职责是生成“下一阶段的执行计划”,而不是直接执行工具。 - -只输出 JSON: + // plannerPrompt 只负责生成“执行路径”,不直接执行动作。 + plannerPrompt = `你是 SmartFlow 的排程微调 Planner。 +你会收到:用户请求、契约、最近动作观察。 +请只输出 JSON,不要 Markdown,不要解释,不要代码块: { - "summary": "本轮计划一句话", - "steps": ["步骤1","步骤2","步骤3"], - "success_signals": ["满足什么算成功1","成功2"], - "fallback": "若连续失败,准备怎么改道" + "summary": "本阶段执行策略一句话", + "steps": ["步骤1","步骤2","步骤3"] } 规则: -1. steps 请优先采用“先取证后动作”的路径:例如 QueryTargetTasks / QueryAvailableSlots / BatchMove / Move / Swap / Verify。 -2. steps 保持 3~4 条,单条不超过 26 字。 -3. summary 不超过 36 字;fallback 不超过 30 字;success_signals 最多 3 条。 -4. 严禁输出半截 JSON;若信息过多,请精简而不是展开解释。 -5. 不要输出 markdown,不要输出额外文本。` +1. steps 保持 3~4 条,优先“先取证再动作”。 +2. summary <= 36 字,单步 <= 28 字。 +3. 若目标是“均匀分散”,steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。 +4. 若目标是“上下文切换最少/同科目连续”,steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。 +5. 不要输出半截 JSON。` - // reactPrompt 用于“强 ReAct 微调循环”节点。 - // - // 目标: - // 1. 每轮先输出“计划 -> 缺口 -> 工具动作”(不承担执行后反思); - // 2. 每轮最多一个 tool_call,但支持 BatchMove 在一个调用里原子执行多步; - // 3. 明确遵守顺序硬约束与 existing 不可改约束。 - reactPrompt = `你是 SmartFlow 的排程微调执行器,采用“走一步看一步”的 ReAct 风格。 -本轮你只允许做两件事之一: -1) 调用一个工具(QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify); -2) 输出 done=true 结束。 + // reactPrompt 用于“单任务微步 ReAct”执行器。 + reactPrompt = `你是 SmartFlow 的单任务微步 ReAct 执行器。 +当前只处理一个任务(CURRENT_TASK),不能发散到其它任务的主动改动。 +你每轮只能做两件事之一: +1) 调用一个工具(基础工具或复合工具) +2) 输出 done=true 结束当前任务 -你将收到 3 个关键输入: -1) LAST_TOOL_RESULT:上一轮工具结果(结构化 JSON); -2) LAST_TOOL_OBSERVATION:上一轮完整观察(包含 tool_name/tool_params/tool_success/tool_error_code/tool_result); -3) LAST_FAILED_CALL_SIGNATURE:上一轮失败动作签名(tool+params)。 +工具分组: +- 基础工具:QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify +- 复合工具:SpreadEven / MinContextSwitch -硬约束: -1. 每轮最多 1 个 tool_call。 -2. 只能修改 status="suggested" 的任务,禁止修改 existing。 -3. 如果合同中 keep_relative_order=true,任何动作都不能打乱任务原始相对顺序。 -4. 如果当前方案已满足目标,直接 done=true,不要多余动作。 -5. day_of_week 数值映射必须严格按:1周一,2周二,3周三,4周四,5周五,6周六,7周日。 -6. 若上一轮 tool_success=false,你必须先根据 tool_error_code 调整策略,再给新动作。 -7. 禁止重复上一轮失败动作(tool 与 params 完全一致);若重复会被后端拒绝执行并记为失败轮次。 +工具说明(按职责): +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,字段如下: +请严格输出 JSON,不要 Markdown,不要解释: { "done": false, "summary": "", "goal_check": "本轮先检查什么", - "decision": "本轮为什么这样决策", - "missing_info": ["如果缺信息就在这里写;不缺则返回空数组"], - "reflect": "本轮计划备注(动作前,不是执行后复盘)", + "decision": "本轮为何这么做", + "missing_info": ["缺口信息1","缺口信息2"], "tool_calls": [ { - "tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|Verify", + "tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify", "params": {} } ] } -补充规则: -1. 若 done=true,则 tool_calls 必须是空数组。 -2. 若 done=false 且有动作,tool_calls 必须只有一个元素。 -3. QueryTargetTasks 用于“先定位要改哪些任务”,禁止直接猜。 -4. QueryAvailableSlots 用于“先看可用空位”,禁止凭直觉盲移。 -5. Move 参数优先使用标准字段:task_item_id,to_week,to_day,to_section_from,to_section_to。 -6. BatchMove 参数格式必须是:{"moves":[{Move参数1},{Move参数2},...]},后端会按顺序原子执行;任一步失败则整批回滚。 -7. Verify 是终止前自检工具:done=true 前建议先执行一次 Verify。 -8. reflect 只描述“本轮计划备注”,不要把未执行的动作写成已完成事实。 -9. 为保证 JSON 稳定可解析,请控制长度:goal_check<=50字、decision<=90字、reflect<=80字、summary<=60字、missing_info 最多3条。 -10. 你必须显式说明“上一轮失败原因如何影响本轮决策”(写在 decision 里)。 -11. 不要输出代码块,不要输出额外文本。` +硬规则: +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 用于“动作执行后真反思”节点。 - // - // 目标: - // 1. 基于后端返回的真实工具结果做复盘,而不是动作前预期; - // 2. 输出下一轮可执行的改进策略,驱动真正的 Observe -> Think; - // 3. 严格输出 JSON,供后端稳定解析并透传 stage。 + // postReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。 postReflectPrompt = `你是 SmartFlow 的 ReAct 复盘器。 -你会收到:本轮工具调用参数、后端真实执行结果、上一轮上下文。 -请基于“真实结果”复盘,不要把失败说成成功。 - -只输出 JSON: +你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。 +请只输出 JSON,不要 Markdown,不要解释: { - "reflection": "本轮发生了什么(基于真实结果)", - "next_strategy": "下一轮建议如何改(具体到换时段/换工具/保持)", - "should_stop": false, - "stop_reason": "若应结束,给简短原因" + "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 仅在“目标已满足”或“继续动作收益很低”时使用。 -4. next_strategy 只能引用这些工具名:QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/Verify。 -5. 不要输出 markdown,不要输出额外文本。` +1. 若 tool_success=false,reflection 必须明确失败原因(优先引用 error_code)。 +2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTION,next_strategy 必须给出规避方法。 +3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。` - // reviewPrompt 用于“终审语义校验”节点。 - // - // 目标: - // 1. 检查方案是否满足用户本轮请求; - // 2. 给出未满足项列表,供一次修复动作使用; - // 3. 输出结构化 JSON,避免校验结果歧义。 + // reviewPrompt 用于终审语义校验。 reviewPrompt = `你是 SmartFlow 的终审校验器。 请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。 只输出 JSON: { "pass": true, "reason": "中文简短结论", - "unmet": ["若不满足,这里列未满足点"] + "unmet": [] } -要求: -1. pass=true 时,unmet 必须为空数组。 -2. pass=false 时,reason 必须给出核心差距。` +规则: +1. pass=true 时 unmet 必须为空数组。 +2. pass=false 时 reason 必须给出核心差距。` - // summaryPrompt 用于“最终回复润色”节点。 - // - // 目标: - // 1. 给用户返回自然语言总结; - // 2. 体现“做了什么调整 + 为什么这样改”; - // 3. 若终审仍有缺口,也要诚实说明。 + // summaryPrompt 用于最终面向用户的自然语言总结。 summaryPrompt = `你是 SmartFlow 的排程结果解读助手。 -请基于输入输出 2~4 句自然中文总结: -1) 先说本轮改了什么; -2) 再说这样改的收益; -3) 如果终审未完全通过,要明确说明还差什么。 +请基于输入输出 2~4 句中文总结: +1) 先说明本轮改了什么; +2) 再说明改动收益; +3) 若终审未完全通过,明确还差什么。 不要输出 JSON。` - // repairPrompt 用于“终审失败后的单次修复”节点。 - // - // 目标: - // 1. 在不重跑全链路的前提下做一次局部补救; - // 2. 强制只输出一个工具调用,避免再次拉长思考。 + // repairPrompt 用于终审失败后的单次修复动作。 repairPrompt = `你是 SmartFlow 的修复执行器。 当前方案未通过终审,请根据“未满足点”只做一次修复动作。 只允许输出一个 tool_call(Move 或 Swap),不允许 done。 @@ -179,12 +170,19 @@ const ( "goal_check": "本轮修复目标", "decision": "修复决策依据", "missing_info": [], - "reflect": "修复动作后的预期", "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/agent/schedulerefine/refine_filters_test.go b/backend/agent/schedulerefine/refine_filters_test.go new file mode 100644 index 0000000..e637a32 --- /dev/null +++ b/backend/agent/schedulerefine/refine_filters_test.go @@ -0,0 +1,573 @@ +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 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/agent/schedulerefine/runner.go b/backend/agent/schedulerefine/runner.go index f0b43dd..851f554 100644 --- a/backend/agent/schedulerefine/runner.go +++ b/backend/agent/schedulerefine/runner.go @@ -28,6 +28,18 @@ func (r *scheduleRefineRunner) contractNode(ctx context.Context, st *ScheduleRef 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) } diff --git a/backend/agent/schedulerefine/state.go b/backend/agent/schedulerefine/state.go index 2c6bf04..d249204 100644 --- a/backend/agent/schedulerefine/state.go +++ b/backend/agent/schedulerefine/state.go @@ -9,43 +9,48 @@ import ( ) const ( - // timezoneName 固定排程链路使用的业务时区,避免容器默认时区导致“明天/今晚”偏移。 + // 固定业务时区,避免“今天/明天”在容器默认时区下偏移。 timezoneName = "Asia/Shanghai" - // datetimeLayout 统一使用分钟级时间文本,方便模型理解与日志比对。 + // 统一分钟级时间文本格式。 datetimeLayout = "2006-01-02 15:04" - // defaultPlanMax 是 Planner 最大调用次数(包含首次规划 + 重规划)。 - defaultPlanMax = 2 - // defaultExecuteMax 是执行阶段最大工具动作轮次。 - defaultExecuteMax = 16 - // defaultReplanMax 是执行阶段允许触发的重规划次数上限。 - defaultReplanMax = 2 - // defaultRepairReserve 表示为“终审修复”保留的最小动作预算。 - defaultRepairReserve = 1 + + // 预算默认值。 + defaultPlanMax = 2 + defaultExecuteMax = 24 + defaultPerTaskBudget = 4 + defaultReplanMax = 2 + defaultCompositeRetry = 2 + defaultRepairReserve = 1 ) -// RefineContract 表示“微调意图契约”。 -// -// 职责边界: -// 1. 负责承载“本轮微调到底要满足什么”的结构化目标; -// 2. 负责给后续 ReAct 动作与终审硬校验提供统一语义; -// 3. 不负责实际排程修改动作执行(动作由工具层负责)。 +// RefineContract 表示本轮微调意图契约。 type RefineContract struct { - Intent string `json:"intent"` - Strategy string `json:"strategy"` - HardRequirements []string `json:"hard_requirements"` - KeepRelativeOrder bool `json:"keep_relative_order"` - OrderScope string `json:"order_scope"` - Reason string `json:"reason"` + 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"` } -// HardCheckReport 表示“终审硬校验报告”。 +// RefineAssertion 表示可由后端直接判定的结构化硬断言。 // -// 职责边界: -// 1. 记录规则层(物理冲突)是否通过; -// 2. 记录语义层(是否满足用户要求)是否通过; -// 3. 记录顺序层(是否保持相对顺序)是否通过; -// 4. 记录失败原因与修复尝试信息,便于后续持续优化 prompt; -// 5. 不负责直接决定是否落库(落库决策仍由服务层控制)。 +// 字段说明: +// 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"` @@ -60,17 +65,11 @@ type HardCheckReport struct { RepairTried bool `json:"repair_tried"` } -// ReactRoundObservation 用于沉淀“每轮 ReAct 的可见观测信息”。 -// -// 职责边界: -// 1. 负责记录每轮“计划 -> 动作 -> 观察 -> 反思”的关键信息; -// 2. 既用于 SSE 透传,也用于下一轮 prompt 的上下文回灌; -// 3. 不承担排程真实数据存储职责(真实排程仍在 HybridEntries)。 +// ReactRoundObservation 记录每轮 ReAct 的关键观察。 type ReactRoundObservation struct { Round int `json:"round"` GoalCheck string `json:"goal_check,omitempty"` Decision string `json:"decision,omitempty"` - MissingInfo []string `json:"missing_info,omitempty"` ToolName string `json:"tool_name,omitempty"` ToolParams map[string]any `json:"tool_params,omitempty"` ToolSuccess bool `json:"tool_success"` @@ -79,27 +78,47 @@ type ReactRoundObservation struct { Reflect string `json:"reflect,omitempty"` } -// PlannerPlan 表示“本轮执行前的结构化计划”。 -// -// 职责边界: -// 1. 负责记录模型当前建议的执行路径(先查什么、再做什么); -// 2. 负责在失败重规划后替换为新版本,供执行器下一轮参考; -// 3. 不直接约束工具执行结果(执行合法性仍由工具层硬校验负责)。 +// PlannerPlan 表示 Planner 生成的阶段执行计划。 type PlannerPlan struct { - Summary string `json:"summary"` - Steps []string `json:"steps,omitempty"` - SuccessSignals []string `json:"success_signals,omitempty"` - Fallback string `json:"fallback,omitempty"` + Summary string `json:"summary"` + Steps []string `json:"steps,omitempty"` } -// ScheduleRefineState 是“连续微调图”的统一状态容器。 +// 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. 负责在图节点间传递“上一版排程快照 + 本轮用户微调请求 + 动作日志 + 终审报告”; -// 2. 负责承载最终对用户可见的 summary 与结构化 candidate_plans; -// 3. 不负责 Redis/MySQL 读写(持久化由 service 层负责)。 +// 设计说明: +// 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. 基础请求上下文。 + // 1) 请求上下文 TraceID string UserID int ConversationID string @@ -107,59 +126,85 @@ type ScheduleRefineState struct { RequestNow time.Time RequestNowText string - // 2. 继承自上一版预览快照的可调度数据。 - TaskClassIDs []int - Constraints []string - HybridEntries []model.HybridScheduleEntry - AllocatedItems []model.TaskClassItem - CandidatePlans []model.UserWeekSchedule + // 2) 继承自预览快照的数据 + TaskClassIDs []int + Constraints []string + // InitialHybridEntries 保存本轮微调开始前的基线,用于终审做“前后对比”。 + // 说明: + // 1. 只读语义,不参与执行期改写; + // 2. 终审可基于它判断“来源任务是否真正迁移到目标区域”。 + InitialHybridEntries []model.HybridScheduleEntry + HybridEntries []model.HybridScheduleEntry + AllocatedItems []model.TaskClassItem + CandidatePlans []model.UserWeekSchedule - // 3. 本轮微调过程状态。 + // 3) 本轮执行状态 UserIntent string Contract RefineContract - PlanMax int - ExecuteMax int - ReplanMax int + PlanMax int + PerTaskBudget int + ExecuteMax int + ReplanMax int + // CompositeRetryMax 表示复合路由失败后的最大重试次数(不含首次尝试)。 + CompositeRetryMax int PlanUsed int ReplanUsed int - // MaxRounds 保留“总预算”语义,供终审修复节点继续复用: - // MaxRounds = ExecuteMax + RepairReserve MaxRounds int RepairReserve int RoundUsed int ActionLogs []string - // ConsecutiveFailures 记录执行阶段连续失败次数,用于触发“失败兜底 thinking”。 ConsecutiveFailures int - // ThinkingBoostArmed 表示“当前失败串已触发过一次 thinking 兜底”。 - ThinkingBoostArmed bool + ThinkingBoostArmed bool + ObservationHistory []ReactRoundObservation + + CurrentPlan PlannerPlan + BatchMoveAllowed bool + // DisableCompositeTools=true 表示已进入 ReAct 兜底,禁止再调用复合工具。 + DisableCompositeTools bool + // CompositeRouteTried 标记是否尝试过“复合批处理路由”。 + CompositeRouteTried bool + // CompositeRouteSucceeded 标记复合批处理路由是否成功收口。 + 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 - LastToolResult string - ObservationHistory []ReactRoundObservation - CurrentPlan PlannerPlan - LastPostStrategy string - // LastFailedCallSignature 记录“上一轮失败动作签名(tool+params)”。用于后端硬拦截重复失败动作。 LastFailedCallSignature string OriginOrderMap map[int]int - // 4. 终审校验状态。 + // 4) 终审状态 HardCheck HardCheckReport - // 5. 最终输出。 + // 5) 最终输出 FinalSummary string Completed bool } -// NewScheduleRefineState 基于“上一版排程预览快照”初始化连续微调状态。 +// NewScheduleRefineState 基于上一版预览快照初始化状态。 // -// 步骤化说明: -// 1. 先初始化请求基础字段与默认预算,保证图内每个节点都能读取到稳定上下文。 -// 2. 再把 preview 的核心排程数据做深拷贝注入,避免跨请求引用污染。 -// 3. 最后构建 origin_order_map,作为“保持相对顺序”硬约束的判定基线。 -// 4. 若 preview 为空,仍返回可用 state,由上层决定是报错还是降级。 +// 职责边界: +// 1. 负责初始化预算、上下文字段与可变状态容器; +// 2. 负责拷贝 preview 数据,避免跨请求引用污染; +// 3. 不负责做任何调度动作。 func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState { now := nowToMinute() st := &ScheduleRefineState{ @@ -170,22 +215,38 @@ func NewScheduleRefineState(traceID string, userID int, conversationID string, u 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, 24), - ObservationHistory: make([]ReactRoundObservation, 0, 16), + 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) @@ -193,7 +254,6 @@ func NewScheduleRefineState(traceID string, userID int, conversationID string, u return st } -// loadLocation 返回排程链路使用的业务时区。 func loadLocation() *time.Location { loc, err := time.LoadLocation(timezoneName) if err != nil { @@ -202,12 +262,10 @@ func loadLocation() *time.Location { return loc } -// nowToMinute 返回当前时刻并截断到分钟级,降低 prompt 中秒级噪声。 func nowToMinute() time.Time { return time.Now().In(loadLocation()).Truncate(time.Minute) } -// cloneHybridEntries 深拷贝混合日程切片。 func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { if len(src) == 0 { return nil @@ -217,7 +275,6 @@ func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleE return dst } -// cloneTaskClassItems 深拷贝任务块切片(包含指针字段)。 func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { if len(src) == 0 { return nil @@ -250,7 +307,6 @@ func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { return dst } -// cloneWeekSchedules 深拷贝周视图切片。 func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { if len(src) == 0 { return nil @@ -267,12 +323,7 @@ func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { return dst } -// buildOriginOrderMap 从当前 suggested 排程位置构建“初始相对顺序映射”。 -// -// 步骤化说明: -// 1. 先筛出所有可调的 suggested 任务; -// 2. 按 week/day/section/task_item_id 稳定排序,得到“时间先后基线”; -// 3. 把 task_item_id -> rank 写入 map,后续 Move/Swap 都基于该 rank 做顺序硬校验。 +// buildOriginOrderMap 构建 suggested 任务的初始顺序基线(task_item_id -> rank)。 func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int { orderMap := make(map[int]int) if len(entries) == 0 { @@ -280,7 +331,7 @@ func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int { } suggested := make([]model.HybridScheduleEntry, 0, len(entries)) for _, entry := range entries { - if entry.Status == "suggested" && entry.TaskItemID > 0 { + if isMovableSuggestedTask(entry) { suggested = append(suggested, entry) } } @@ -301,8 +352,8 @@ func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int { } return left.TaskItemID < right.TaskItemID }) - for idx, entry := range suggested { - orderMap[entry.TaskItemID] = idx + 1 + for i, entry := range suggested { + orderMap[entry.TaskItemID] = i + 1 } return orderMap } diff --git a/backend/agent/schedulerefine/tool.go b/backend/agent/schedulerefine/tool.go index ee5882a..484f62f 100644 --- a/backend/agent/schedulerefine/tool.go +++ b/backend/agent/schedulerefine/tool.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/LoveLosita/smartflow/backend/logic" "github.com/LoveLosita/smartflow/backend/model" ) @@ -29,16 +30,13 @@ type reactToolResult struct { // 字段语义: // 1. goal_check:本轮要先验证的目标点; // 2. decision:本轮动作选择依据; -// 3. missing_info:模型明确缺失的信息,前端可直接展示; -// 4. reflect:本轮动作前的预期说明(不是执行后事实); -// 5. tool_calls:本轮工具动作列表(业务侧只取第一条)。 +// 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"` - Reflect string `json:"reflect"` + MissingInfo []string `json:"missing_info,omitempty"` ToolCalls []reactToolCall `json:"tool_calls"` } @@ -92,13 +90,17 @@ func dispatchRefineTool(entries []model.HybridScheduleEntry, call reactToolCall, 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, policy) + 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/Verify)", strings.TrimSpace(call.Tool)), + Result: fmt.Sprintf("不支持的工具:%s(仅允许 QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/SpreadEven/MinContextSwitch/Verify)", strings.TrimSpace(call.Tool)), } } } @@ -138,9 +140,6 @@ func parseReactLLMOutput(raw string) (*reactLLMOutput, error) { var out reactLLMOutput if err := json.Unmarshal([]byte(clean), &out); err == nil { - if out.MissingInfo == nil { - out.MissingInfo = make([]string, 0) - } return &out, nil } obj, objErr := extractFirstJSONObject(clean) @@ -150,9 +149,6 @@ func parseReactLLMOutput(raw string) (*reactLLMOutput, error) { if err := json.Unmarshal([]byte(obj), &out); err != nil { return nil, err } - if out.MissingInfo == nil { - out.MissingInfo = make([]string, 0) - } return &out, nil } @@ -204,10 +200,10 @@ func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, // 1.1 优先读取标准键(to_week/to_day/...); // 1.2 若模型输出了历史别名(target_xxx/day_of_week 等),也兼容解析; // 1.3 目标是减少“仅参数名不一致导致的无效失败轮次”。 - toWeek, okWeek := paramIntAny(params, "to_week", "target_week", "week") - toDay, okDay := paramIntAny(params, "to_day", "target_day", "target_day_of_week", "day_of_week", "day") - toSF, okSF := paramIntAny(params, "to_section_from", "target_section_from", "section_from") - toST, okST := paramIntAny(params, "to_section_to", "target_section_to", "section_to") + 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", @@ -221,10 +217,11 @@ func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, 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 := findSuggestedByID(entries, taskID) - if idx < 0 { - return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("未找到 task_item_id=%d 的 suggested 任务", taskID)} + 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 @@ -244,7 +241,7 @@ func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, } } - if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}); conflict { + if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}, allowEmbed); conflict { return entries, reactToolResult{ Tool: "Move", Success: false, @@ -273,7 +270,7 @@ func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, return entries, reactToolResult{ Tool: "Move", Success: true, - Result: fmt.Sprintf("已将任务[%s](id=%d) 从 %s 移动到 %s", entry.Name, taskID, before, after), + 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), } } @@ -293,14 +290,18 @@ func refineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, 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 := findSuggestedByID(entries, idA) - idxB := findSuggestedByID(entries, idB) - if idxA < 0 || idxB < 0 { - return entries, reactToolResult{Tool: "Swap", Success: false, Result: "至少有一个任务不是可交换的 suggested 条目"} + 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] @@ -310,10 +311,10 @@ func refineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, } excludes := map[int]bool{idxA: true, idxB: true} - if conflict, name := hasConflict(entries, b.Week, b.DayOfWeek, b.SectionFrom, b.SectionTo, excludes); conflict { + 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); conflict { + 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)} } @@ -356,6 +357,19 @@ func refineToolBatchMove(entries []model.HybridScheduleEntry, params map[string] 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 的全部校验逻辑(冲突、窗口、顺序、跨度); @@ -389,6 +403,304 @@ func refineToolBatchMove(entries []model.HybridScheduleEntry, params map[string] } } +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. 不直接改写 entries,统一通过 BatchMove 原子落地; +// 3. 规划算法实现位于 logic 包,工具层只负责编排。 +func refineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + return refineToolCompositeMove(entries, params, window, policy, "MinContextSwitch", logic.PlanMinContextSwitchMoves) +} + +// 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", + } + } + idSet := intSliceToIDSet(taskIDs) + + // 1. 先筛选任务候选,并校验 task_item_id 是否全部可定位。 + // 2. 只允许可移动 suggested 任务参与,避免误改 existing/course 条目。 + tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs)) + found := make(map[int]struct{}, len(taskIDs)) + spanNeed := make(map[int]int) + for _, entry := range entries { + if !isMovableSuggestedTask(entry) { + continue + } + if _, ok := idSet[entry.TaskItemID]; !ok { + continue + } + if _, duplicated := found[entry.TaskItemID]; duplicated { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_ID_AMBIGUOUS", + Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID), + } + } + found[entry.TaskItemID] = struct{}{} + task := 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], + } + tasks = append(tasks, task) + spanNeed[entry.SectionTo-entry.SectionFrom+1]++ + } + 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 entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_NOT_FOUND", + Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing), + } + } + + 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(), + } + } + 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)), + } +} + +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 + } + } + + copyIntSliceParam(params, query, "week_filter", "weeks") + copyIntSliceParam(params, query, "day_of_week", "days", "day_filter") + copyIntSliceParam(params, query, "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 查询“本轮潜在目标任务集合”。 // // 步骤化说明: @@ -398,6 +710,7 @@ func refineToolBatchMove(entries []model.HybridScheduleEntry, params map[string] 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 { @@ -420,7 +733,12 @@ func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[ if !okLimit || limit <= 0 { limit = 16 } - dayFilter := intSliceToSet(readIntSlice(params, "day_of_week", "days")) + 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"` @@ -439,9 +757,18 @@ func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[ 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 @@ -449,6 +776,11 @@ func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[ } else if !matchDayScope(entry.DayOfWeek, scope) { continue } + if len(weekFilter) > 0 { + if _, ok := weekFilter[entry.Week]; !ok { + continue + } + } if hasWeekFrom && entry.Week < weekFrom { continue } @@ -488,6 +820,7 @@ func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[ "count": len(list), "status": statusFilter, "day_scope": scope, + "week_filter": keysOfIntSet(weekFilter), "week_from": weekFrom, "week_to": weekTo, "day_of_week": keysOfIntSet(dayFilter), @@ -513,12 +846,32 @@ func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[ // // 步骤化说明: // 1. 根据 day_scope/week 范围/span/exclude_sections 过滤候选时段; -// 2. 使用现有冲突判定(entryBlocksSuggested + sectionsOverlap)确保结果可放置; +// 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 := intSliceToSet(readIntSlice(params, "day_of_week", "days")) - span, okSpan := paramIntAny(params, "span") + 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 } @@ -553,6 +906,17 @@ func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params m 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") { @@ -562,67 +926,110 @@ func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params m } 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"` + 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) - for week := weekFrom; week <= weekTo; week++ { - for day := 1; day <= 7; day++ { - if len(dayFilter) > 0 { - if _, ok := dayFilter[day]; !ok { - continue - } - } else if !matchDayScope(day, scope) { - continue - } - if !isWithinWindow(window, week, day) { - continue - } - for sf := 1; sf+span-1 <= 12; sf++ { - st := sf + span - 1 - if hasAfter && sf <= afterSection { - continue - } - if hasBefore && st >= beforeSection { - continue - } - if intersectsExcludedSections(sf, st, excludedSet) { - continue - } - if conflict, _ := hasConflict(entries, week, day, sf, st, nil); conflict { - continue - } - slots = append(slots, slot{ - Week: week, - DayOfWeek: day, - SectionFrom: sf, - SectionTo: st, - }) - if len(slots) >= limit { - break - } - } - if len(slots) >= limit { - break - } - } + seen := make(map[string]struct{}, limit*2) + strictCount := 0 + collect := func(embedAllowed bool, slotType string) { if len(slots) >= limit { - break + 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, } @@ -632,6 +1039,10 @@ func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params m 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{ @@ -654,36 +1065,84 @@ func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params m // 1. 当前只做 deterministic 校验(冲突/顺序),不做语义 LLM 终审; // 2. 语义层终审仍在 hard_check 节点统一处理; // 3. 该工具用于给执行阶段一个“可提前自查”的信号。 -func refineToolVerify(entries []model.HybridScheduleEntry, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { +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 { - return entries, reactToolResult{ - Tool: "Verify", - Success: true, - Result: `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`, + 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 校验失败且结果无法序列化", + } } - } - 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 校验失败且结果无法序列化", + 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: false, - ErrorCode: "VERIFY_FAILED", - Result: string(raw), + Tool: "Verify", + Success: true, + Result: `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`, } } @@ -704,7 +1163,10 @@ func validateRelativeOrder(entries []model.HybridScheduleEntry, policy refineToo suggested := make([]model.HybridScheduleEntry, 0, len(entries)) for _, entry := range entries { - if entry.Status == "suggested" && entry.TaskItemID > 0 { + // 1. 顺序校验与执行口径必须一致: + // 1.1 这里只校验“可移动 suggested 任务”,避免把 course 等不可移动条目误纳入顺序约束; + // 1.2 若把不可移动条目纳入,会出现“动作层不允许改、顺序层却报错”的左右脑互搏。 + if isMovableSuggestedTask(entry) { suggested = append(suggested, entry) } } @@ -843,24 +1305,69 @@ func compareWeekDay(leftWeek, leftDay, rightWeek, rightDay int) int { // findSuggestedByID 在 entries 中查找指定 task_item_id 的 suggested 条目索引。 func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int { for i, entry := range entries { - if entry.Status == "suggested" && entry.TaskItemID == taskItemID { + 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) (bool, string) { +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 !entryBlocksSuggested(entry) { + if !entryBlocksSuggestedWithPolicy(entry, allowEmbed) { continue } if entry.Week == week && entry.DayOfWeek == day && sectionsOverlap(entry.SectionFrom, entry.SectionTo, sf, st) { @@ -872,10 +1379,23 @@ func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, exc // 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 } // 未知状态保守处理为阻塞,避免写入潜在冲突。 @@ -924,6 +1444,56 @@ func paramIntAny(params map[string]any, keys ...string) (int, bool) { 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] @@ -985,8 +1555,8 @@ func matchDayScope(day int, scope string) bool { } } -// intSliceToSet 把 int 切片转换为 set,并自动去除非法 day 值。 -func intSliceToSet(items []int) map[int]struct{} { +// intSliceToDaySet 把 day 切片转换为 set,并去除非法 day 值。 +func intSliceToDaySet(items []int) map[int]struct{} { if len(items) == 0 { return nil } @@ -1003,6 +1573,60 @@ func intSliceToSet(items []int) map[int]struct{} { 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 { @@ -1023,6 +1647,26 @@ func inferWeekBounds(entries []model.HybridScheduleEntry, window planningWindow) 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 { @@ -1059,6 +1703,47 @@ func readIntSlice(params map[string]any, keys ...string) []int { 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 { diff --git a/backend/auth/jwt_handler.go b/backend/auth/jwt_handler.go index 778ae2c..04278c6 100644 --- a/backend/auth/jwt_handler.go +++ b/backend/auth/jwt_handler.go @@ -2,6 +2,9 @@ package auth import ( "errors" + "fmt" + "strconv" + "strings" "time" "github.com/LoveLosita/smartflow/backend/dao" @@ -12,41 +15,204 @@ import ( "github.com/spf13/viper" ) -var RefreshKey = []byte(viper.GetString("jwt.refreshSecret")) // 用于签名和验证刷新Token的密钥 -var AccessKey = []byte(viper.GetString("jwt.accessSecret")) // 用于签名和验证访问Token的密钥 +const ( + accessSecretConfigKey = "jwt.accessSecret" + refreshSecretConfigKey = "jwt.refreshSecret" + accessExpireConfigKey = "jwt.accessTokenExpire" + refreshExpireConfigKey = "jwt.refreshTokenExpire" -// generateJTI 生成唯一的 JWT ID + defaultAccessTokenExpire = 15 * time.Minute + defaultRefreshTokenExpire = 7 * 24 * time.Hour +) + +type jwtRuntimeConfig struct { + AccessKey []byte + RefreshKey []byte + AccessExpire time.Duration + RefreshExpire time.Duration +} + +// AccessSigningKey 负责提供访问令牌签名/验签密钥。 +// 职责边界: +// 1. 负责从运行时配置读取 accessSecret 并做空值校验。 +// 2. 不负责 token 解析、业务鉴权与错误码映射。 +// 3. 返回值语义:[]byte 为签名密钥;error 非空表示配置不可用。 +func AccessSigningKey() ([]byte, error) { + cfg, err := loadJWTConfig() + if err != nil { + return nil, err + } + return cfg.AccessKey, nil +} + +// generateJTI 生成唯一的 JWT ID。 func generateJTI() string { return uuid.New().String() } -// GenerateTokens 生成访问令牌和刷新令牌 -func GenerateTokens(userID int) (string, string, error) { - // 创建访问令牌 - sid := generateJTI() - accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "user_id": userID, // 获取用户ID - "exp": time.Now().Add(15 * time.Minute).Unix(), // 设置访问令牌过期时间为 15 分钟 - "token_type": "access_token", // 令牌类型为访问令牌 - "jti": sid, // 亲子共用的 JWT ID - }) +// loadJWTConfig 负责聚合 JWT 运行时配置。 +// 职责边界: +// 1. 负责读取密钥与过期时间配置,并转换为可直接使用的结构。 +// 2. 不负责持久化配置,也不负责降级到“不安全默认密钥”。 +// 3. 返回值语义:cfg 可直接用于签发/校验;error 非空表示配置不合法。 +func loadJWTConfig() (*jwtRuntimeConfig, error) { + accessKey, err := readJWTSecret(accessSecretConfigKey) + if err != nil { + return nil, err + } + refreshKey, err := readJWTSecret(refreshSecretConfigKey) + if err != nil { + return nil, err + } - // 使用密钥签名访问令牌 - accessTokenString, err := accessToken.SignedString(AccessKey) + accessExpire, err := readJWTExpireDuration(accessExpireConfigKey, defaultAccessTokenExpire) + if err != nil { + return nil, err + } + refreshExpire, err := readJWTExpireDuration(refreshExpireConfigKey, defaultRefreshTokenExpire) + if err != nil { + return nil, err + } + + return &jwtRuntimeConfig{ + AccessKey: accessKey, + RefreshKey: refreshKey, + AccessExpire: accessExpire, + RefreshExpire: refreshExpire, + }, nil +} + +// readJWTSecret 负责读取并校验 JWT 密钥配置。 +// 职责边界: +// 1. 负责“读配置 + 去空白 + 空值校验”。 +// 2. 不负责任何默认值回退,避免静默使用弱配置。 +// 3. 返回值语义:[]byte 为密钥;error 非空表示该配置项不可用。 +func readJWTSecret(configKey string) ([]byte, error) { + secret := strings.TrimSpace(viper.GetString(configKey)) + if secret == "" { + return nil, fmt.Errorf("jwt 配置缺失: %s", configKey) + } + return []byte(secret), nil +} + +// readJWTExpireDuration 负责读取并解析 JWT 过期时间配置。 +// 职责边界: +// 1. 负责把字符串配置解析成 time.Duration,并保证结果大于 0。 +// 2. 不负责签发 token;仅提供“可计算”的过期时长。 +// 3. 返回值语义:duration 为最终时长;error 非空表示格式非法。 +func readJWTExpireDuration(configKey string, fallback time.Duration) (time.Duration, error) { + raw := strings.TrimSpace(viper.GetString(configKey)) + if raw == "" { + return fallback, nil + } + d, err := parseFlexibleDuration(raw) + if err != nil { + return 0, fmt.Errorf("jwt 配置项 %s 非法: %w", configKey, err) + } + if d <= 0 { + return 0, fmt.Errorf("jwt 配置项 %s 必须大于 0", configKey) + } + return d, nil +} + +// parseFlexibleDuration 负责解析项目内常见时长格式。 +// 职责边界: +// 1. 负责兼容 Go 标准格式(如 15m、168h)与项目常见格式(如 15min、7d)。 +// 2. 不负责读取配置键名;仅解析输入字符串。 +// 3. 输入输出语义:raw 为原始时长文本;返回解析后的正时长或错误。 +func parseFlexibleDuration(raw string) (time.Duration, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" { + return 0, errors.New("时长不能为空") + } + + // 1. 先走 Go 原生解析,优先兼容标准写法(如 15m/168h)。 + if d, err := time.ParseDuration(normalized); err == nil { + return d, nil + } + + // 2. 原生解析失败后,兼容项目常见简写(如 15min、7d)。 + type unitDef struct { + Suffix string + Multiplier time.Duration + } + unitDefs := []unitDef{ + {Suffix: "minutes", Multiplier: time.Minute}, + {Suffix: "minute", Multiplier: time.Minute}, + {Suffix: "mins", Multiplier: time.Minute}, + {Suffix: "min", Multiplier: time.Minute}, + {Suffix: "days", Multiplier: 24 * time.Hour}, + {Suffix: "day", Multiplier: 24 * time.Hour}, + {Suffix: "d", Multiplier: 24 * time.Hour}, + {Suffix: "hours", Multiplier: time.Hour}, + {Suffix: "hour", Multiplier: time.Hour}, + {Suffix: "h", Multiplier: time.Hour}, + {Suffix: "seconds", Multiplier: time.Second}, + {Suffix: "second", Multiplier: time.Second}, + {Suffix: "secs", Multiplier: time.Second}, + {Suffix: "sec", Multiplier: time.Second}, + {Suffix: "m", Multiplier: time.Minute}, + {Suffix: "s", Multiplier: time.Second}, + } + + for _, unit := range unitDefs { + if !strings.HasSuffix(normalized, unit.Suffix) { + continue + } + numberPart := strings.TrimSpace(strings.TrimSuffix(normalized, unit.Suffix)) + value, err := strconv.Atoi(numberPart) + if err != nil { + return 0, fmt.Errorf("时长数值非法: %q", numberPart) + } + if value <= 0 { + return 0, fmt.Errorf("时长数值必须大于 0: %d", value) + } + return time.Duration(value) * unit.Multiplier, nil + } + + return 0, fmt.Errorf("不支持的时长格式: %s", raw) +} + +// GenerateTokens 负责按配置签发访问令牌与刷新令牌。 +// 职责边界: +// 1. 负责根据配置生成 exp,并签发 access/refresh 双 token。 +// 2. 不负责登录鉴权(用户名/密码验证在 service 层处理)。 +// 3. 返回值语义:第一个为 access token,第二个为 refresh token,error 非空表示签发失败。 +func GenerateTokens(userID int) (string, string, error) { + cfg, err := loadJWTConfig() if err != nil { return "", "", err } - // 创建刷新令牌 - refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "user_id": userID, // 获取用户ID - "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), // 设置刷新令牌过期时间为 7 天 - "token_type": "refresh_token", // 令牌类型为刷新令牌 - "jti": sid, // 亲子共用的 JWT ID - }) + now := time.Now() + sid := generateJTI() - // 使用密钥签名刷新令牌 - refreshTokenString, err := refreshToken.SignedString(RefreshKey) + // 1. 先签 access token:短期有效,面向接口访问。 + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, model.MyCustomClaims{ + UserID: userID, + TokenType: "access_token", + Jti: sid, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(cfg.AccessExpire)), + }, + }) + accessTokenString, err := accessToken.SignedString(cfg.AccessKey) + if err != nil { + return "", "", err + } + + // 2. 再签 refresh token:长期有效,仅用于换发新 token。 + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, model.MyCustomClaims{ + UserID: userID, + TokenType: "refresh_token", + Jti: sid, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(cfg.RefreshExpire)), + }, + }) + refreshTokenString, err := refreshToken.SignedString(cfg.RefreshKey) if err != nil { return "", "", err } @@ -54,71 +220,45 @@ func GenerateTokens(userID int) (string, string, error) { return accessTokenString, refreshTokenString, nil } -// ValidateRefreshToken 验证刷新令牌的有效性 -/*func ValidateRefreshToken(tokenString string) (*jwt.Token, error) { - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - // 检查签名方法是否为 HMAC - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, respond.InvalidTokenSingingMethod - } - // 返回用于验证的密钥 - return RefreshKey, nil - }) +// ValidateRefreshToken 验证刷新令牌的有效性,并增加 Redis 黑名单检查。 +func ValidateRefreshToken(tokenString string, cache *dao.CacheDAO) (*jwt.Token, error) { + cfg, err := loadJWTConfig() if err != nil { return nil, err } - // 进一步检查载荷中 token_type 是否正确 - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, respond.InvalidClaims - } - // 检查 token_type 是否是 refresh_token - if claimType, ok := claims["token_type"].(string); !ok || claimType != "refresh_token" { - return nil, respond.WrongTokenType - } - return token, nil -} -*/ - -// ValidateRefreshToken 验证刷新令牌的有效性,并增加 Redis 黑名单检查 -func ValidateRefreshToken(tokenString string, cache *dao.CacheDAO) (*jwt.Token, error) { - // 1. 解析 Token 并直接绑定到你的自定义结构体 + // 1. 解析 refresh token,并强制校验签名算法与密钥来源。 token, err := jwt.ParseWithClaims(tokenString, &model.MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, respond.InvalidTokenSingingMethod } - return RefreshKey, nil + return cfg.RefreshKey, nil }) - if err != nil { return nil, respond.InvalidRefreshToken } - if !token.Valid { return nil, respond.InvalidRefreshToken } - // 2. 断言获取 Claims + // 2. 断言 claims 类型,后续业务字段都从结构体读取。 claims, ok := token.Claims.(*model.MyCustomClaims) if !ok { return nil, respond.InvalidClaims } - // 3. 核心“设卡”:检查 token_type 是否是 refresh_token + // 3. 校验 token_type,防止把 access token 当 refresh token 用。 if claims.TokenType != "refresh_token" { return nil, respond.WrongTokenType } - // 4. --- 🛡️ 终极关卡:检查 Redis 黑名单 --- - // 即使签名没过期,如果 jti 在黑名单里(用户已登出),也视为无效 + // 4. 黑名单校验:签名合法也要确认 jti 未被主动注销。 isBlack, err := cache.IsBlacklisted(claims.Jti) if err != nil { - // Redis 出错时的处理逻辑,建议报错以防“漏网之鱼” return nil, errors.New("无法验证令牌状态") } if isBlack { - return nil, respond.UserLoggedOut // 返回你定义的“用户已登出”错误 + return nil, respond.UserLoggedOut } return token, nil diff --git a/backend/auth/jwt_handler_test.go b/backend/auth/jwt_handler_test.go new file mode 100644 index 0000000..b78a160 --- /dev/null +++ b/backend/auth/jwt_handler_test.go @@ -0,0 +1,128 @@ +package auth + +import ( + "testing" + "time" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/golang-jwt/jwt/v4" + "github.com/spf13/viper" +) + +func TestParseFlexibleDuration(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + raw string + want time.Duration + wantFail bool + }{ + {name: "标准格式", raw: "15m", want: 15 * time.Minute}, + {name: "项目分钟简写", raw: "15min", want: 15 * time.Minute}, + {name: "项目天简写", raw: "7d", want: 7 * 24 * time.Hour}, + {name: "非法格式", raw: "abc", wantFail: true}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parseFlexibleDuration(tc.raw) + if tc.wantFail { + if err == nil { + t.Fatalf("期望解析失败,但得到成功: %s", tc.raw) + } + return + } + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if got != tc.want { + t.Fatalf("解析结果不符合预期,got=%v want=%v", got, tc.want) + } + }) + } +} + +func TestGenerateTokens_UseConfigExpire(t *testing.T) { + const ( + accessSecret = "unit-test-access-secret" + refreshSecret = "unit-test-refresh-secret" + accessExpire = "2h" + refreshExpire = "3d" + ) + + originAccessSecret := viper.GetString(accessSecretConfigKey) + originRefreshSecret := viper.GetString(refreshSecretConfigKey) + originAccessExpire := viper.GetString(accessExpireConfigKey) + originRefreshExpire := viper.GetString(refreshExpireConfigKey) + + viper.Set(accessSecretConfigKey, accessSecret) + viper.Set(refreshSecretConfigKey, refreshSecret) + viper.Set(accessExpireConfigKey, accessExpire) + viper.Set(refreshExpireConfigKey, refreshExpire) + + t.Cleanup(func() { + viper.Set(accessSecretConfigKey, originAccessSecret) + viper.Set(refreshSecretConfigKey, originRefreshSecret) + viper.Set(accessExpireConfigKey, originAccessExpire) + viper.Set(refreshExpireConfigKey, originRefreshExpire) + }) + + start := time.Now() + accessTokenString, refreshTokenString, err := GenerateTokens(9527) + if err != nil { + t.Fatalf("签发 token 失败: %v", err) + } + + accessClaims := parseTokenClaimsForTest(t, accessTokenString, []byte(accessSecret)) + refreshClaims := parseTokenClaimsForTest(t, refreshTokenString, []byte(refreshSecret)) + + if accessClaims.TokenType != "access_token" { + t.Fatalf("access token_type 不符合预期: %s", accessClaims.TokenType) + } + if refreshClaims.TokenType != "refresh_token" { + t.Fatalf("refresh token_type 不符合预期: %s", refreshClaims.TokenType) + } + if accessClaims.Jti == "" || refreshClaims.Jti == "" { + t.Fatalf("jti 不能为空") + } + if accessClaims.Jti != refreshClaims.Jti { + t.Fatalf("access/refresh 应共享同一个 jti") + } + + assertExpireNear(t, accessClaims.ExpiresAt.Time, start.Add(2*time.Hour), 3*time.Second) + assertExpireNear(t, refreshClaims.ExpiresAt.Time, start.Add(3*24*time.Hour), 3*time.Second) +} + +func parseTokenClaimsForTest(t *testing.T, tokenString string, key []byte) *model.MyCustomClaims { + t.Helper() + + token, err := jwt.ParseWithClaims(tokenString, &model.MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return key, nil + }) + if err != nil { + t.Fatalf("解析 token 失败: %v", err) + } + if !token.Valid { + t.Fatalf("token 无效") + } + + claims, ok := token.Claims.(*model.MyCustomClaims) + if !ok { + t.Fatalf("claims 类型断言失败") + } + return claims +} + +func assertExpireNear(t *testing.T, got time.Time, want time.Time, tolerance time.Duration) { + t.Helper() + delta := got.Sub(want) + if delta < 0 { + delta = -delta + } + if delta > tolerance { + t.Fatalf("exp 偏差超出容忍范围,got=%s want=%s delta=%s tolerance=%s", got.Format(time.RFC3339), want.Format(time.RFC3339), delta, tolerance) + } +} diff --git a/backend/logic/refine_compound_ops.go b/backend/logic/refine_compound_ops.go new file mode 100644 index 0000000..73e2d69 --- /dev/null +++ b/backend/logic/refine_compound_ops.go @@ -0,0 +1,373 @@ +package logic + +import ( + "fmt" + "sort" + "strings" +) + +// RefineTaskCandidate 表示复合工具规划阶段可移动的任务候选。 +// +// 职责边界: +// 1. 只承载“任务当前坐标 + 规划所需标签”; +// 2. 不承载冲突判断、窗口判断等执行期逻辑; +// 3. 由调用方保证 task_item_id 唯一且为正数。 +type RefineTaskCandidate struct { + TaskItemID int + Week int + DayOfWeek int + SectionFrom int + SectionTo int + Name string + ContextTag string + OriginRank int +} + +// RefineSlotCandidate 表示复合工具可选落点(坑位)。 +// +// 职责边界: +// 1. 只描述可候选的时段坐标; +// 2. 不描述“为什么可用”,可用性由调用方预先筛好; +// 3. Span 由 SectionFrom/SectionTo 推导,不单独存储。 +type RefineSlotCandidate struct { + Week int + DayOfWeek int + SectionFrom int + SectionTo int +} + +// RefineMovePlanItem 表示“任务 -> 目标坑位”的确定性规划结果。 +type RefineMovePlanItem struct { + TaskItemID int + ToWeek int + ToDay int + ToSectionFrom int + ToSectionTo int +} + +// RefineCompositePlanOptions 是复合规划器的可选辅助输入。 +// +// 说明: +// 1. ExistingDayLoad 用于提供“目标范围内的既有负载基线”,用于均匀铺开打分; +// 2. key 约定为 "week-day",例如 "16-3"; +// 3. 未提供时,规划器按 0 基线处理。 +type RefineCompositePlanOptions struct { + ExistingDayLoad map[string]int +} + +// PlanEvenSpreadMoves 规划“均匀铺开”的确定性移动方案。 +// +// 步骤化说明: +// 1. 先按稳定顺序归一化任务与坑位,保证同输入必同输出; +// 2. 逐任务选择“投放后日负载最小”的坑位,主目标是降低日负载离散度; +// 3. 同分时按时间更早优先,进一步保证确定性; +// 4. 若某任务不存在同跨度坑位,直接失败并返回明确错误。 +func PlanEvenSpreadMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, options RefineCompositePlanOptions) ([]RefineMovePlanItem, error) { + normalizedTasks, err := normalizeRefineTasks(tasks) + if err != nil { + return nil, err + } + normalizedSlots, err := normalizeRefineSlots(slots) + if err != nil { + return nil, err + } + if len(normalizedSlots) < len(normalizedTasks) { + return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots)) + } + + // 1. dayLoad 记录“当前已占 + 本次规划已分配”的日负载。 + // 2. 这里先写入调用方提供的既有基线,再在循环中动态递增。 + dayLoad := make(map[string]int, len(options.ExistingDayLoad)+len(normalizedSlots)) + for key, value := range options.ExistingDayLoad { + if value <= 0 { + continue + } + dayLoad[strings.TrimSpace(key)] = value + } + + used := make([]bool, len(normalizedSlots)) + moves := make([]RefineMovePlanItem, 0, len(normalizedTasks)) + selectedSlots := make([]RefineSlotCandidate, 0, len(normalizedTasks)) + + for _, task := range normalizedTasks { + taskSpan := sectionSpan(task.SectionFrom, task.SectionTo) + bestIdx := -1 + bestScore := int(^uint(0) >> 1) // max int + + for idx, slot := range normalizedSlots { + if used[idx] { + continue + } + if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan { + continue + } + if slotOverlapsAny(slot, selectedSlots) { + continue + } + dayKey := composeDayKey(slot.Week, slot.DayOfWeek) + projectedLoad := dayLoad[dayKey] + 1 + // 1. projectedLoad 是主目标(越小越均衡); + // 2. idx 是次级目标(越早的坑位越优先,保证稳定)。 + score := projectedLoad*10000 + idx + if score < bestScore { + bestScore = score + bestIdx = idx + } + } + if bestIdx < 0 { + return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID) + } + + chosen := normalizedSlots[bestIdx] + used[bestIdx] = true + selectedSlots = append(selectedSlots, chosen) + dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++ + moves = append(moves, RefineMovePlanItem{ + TaskItemID: task.TaskItemID, + ToWeek: chosen.Week, + ToDay: chosen.DayOfWeek, + ToSectionFrom: chosen.SectionFrom, + ToSectionTo: chosen.SectionTo, + }) + } + return moves, nil +} + +// PlanMinContextSwitchMoves 规划“同科目上下文切换最少”的确定性移动方案。 +// +// 步骤化说明: +// 1. 先把任务按 context_tag 分组,目标是让同组任务尽量连续; +// 2. 分组顺序按“组大小降序 + 最早 origin_rank + 标签字典序”稳定排序; +// 3. 组内按任务稳定顺序排,再顺序填入时间上最早可用同跨度坑位; +// 4. 若某任务不存在同跨度坑位,立即失败并返回明确错误。 +func PlanMinContextSwitchMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, _ RefineCompositePlanOptions) ([]RefineMovePlanItem, error) { + normalizedTasks, err := normalizeRefineTasks(tasks) + if err != nil { + return nil, err + } + normalizedSlots, err := normalizeRefineSlots(slots) + if err != nil { + return nil, err + } + if len(normalizedSlots) < len(normalizedTasks) { + return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots)) + } + + type taskGroup struct { + ContextKey string + Tasks []RefineTaskCandidate + MinRank int + } + groupMap := make(map[string]*taskGroup) + groupOrder := make([]string, 0, len(normalizedTasks)) + + for _, task := range normalizedTasks { + key := normalizeContextKey(task.ContextTag) + group, exists := groupMap[key] + if !exists { + group = &taskGroup{ + ContextKey: key, + MinRank: normalizedOriginRank(task), + } + groupMap[key] = group + groupOrder = append(groupOrder, key) + } + group.Tasks = append(group.Tasks, task) + if rank := normalizedOriginRank(task); rank < group.MinRank { + group.MinRank = rank + } + } + + groups := make([]taskGroup, 0, len(groupMap)) + for _, key := range groupOrder { + group := groupMap[key] + sort.SliceStable(group.Tasks, func(i, j int) bool { + return compareTaskOrder(group.Tasks[i], group.Tasks[j]) < 0 + }) + groups = append(groups, *group) + } + sort.SliceStable(groups, func(i, j int) bool { + if len(groups[i].Tasks) != len(groups[j].Tasks) { + return len(groups[i].Tasks) > len(groups[j].Tasks) + } + if groups[i].MinRank != groups[j].MinRank { + return groups[i].MinRank < groups[j].MinRank + } + return groups[i].ContextKey < groups[j].ContextKey + }) + + orderedTasks := make([]RefineTaskCandidate, 0, len(normalizedTasks)) + for _, group := range groups { + orderedTasks = append(orderedTasks, group.Tasks...) + } + + used := make([]bool, len(normalizedSlots)) + moves := make([]RefineMovePlanItem, 0, len(orderedTasks)) + selectedSlots := make([]RefineSlotCandidate, 0, len(orderedTasks)) + for _, task := range orderedTasks { + taskSpan := sectionSpan(task.SectionFrom, task.SectionTo) + chosenIdx := -1 + for idx, slot := range normalizedSlots { + if used[idx] { + continue + } + if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan { + continue + } + if slotOverlapsAny(slot, selectedSlots) { + continue + } + chosenIdx = idx + break + } + if chosenIdx < 0 { + return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID) + } + chosen := normalizedSlots[chosenIdx] + used[chosenIdx] = true + selectedSlots = append(selectedSlots, chosen) + moves = append(moves, RefineMovePlanItem{ + TaskItemID: task.TaskItemID, + ToWeek: chosen.Week, + ToDay: chosen.DayOfWeek, + ToSectionFrom: chosen.SectionFrom, + ToSectionTo: chosen.SectionTo, + }) + } + return moves, nil +} + +func normalizeRefineTasks(tasks []RefineTaskCandidate) ([]RefineTaskCandidate, error) { + if len(tasks) == 0 { + return nil, fmt.Errorf("任务列表为空") + } + normalized := make([]RefineTaskCandidate, 0, len(tasks)) + seen := make(map[int]struct{}, len(tasks)) + for _, task := range tasks { + if task.TaskItemID <= 0 { + return nil, fmt.Errorf("存在非法 task_item_id=%d", task.TaskItemID) + } + if _, exists := seen[task.TaskItemID]; exists { + return nil, fmt.Errorf("任务 id=%d 重复", task.TaskItemID) + } + if !isValidDay(task.DayOfWeek) { + return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskItemID, task.DayOfWeek) + } + if !isValidSection(task.SectionFrom, task.SectionTo) { + return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskItemID, task.SectionFrom, task.SectionTo) + } + seen[task.TaskItemID] = struct{}{} + normalized = append(normalized, task) + } + sort.SliceStable(normalized, func(i, j int) bool { + return compareTaskOrder(normalized[i], normalized[j]) < 0 + }) + return normalized, nil +} + +func normalizeRefineSlots(slots []RefineSlotCandidate) ([]RefineSlotCandidate, error) { + if len(slots) == 0 { + return nil, fmt.Errorf("可用坑位为空") + } + normalized := make([]RefineSlotCandidate, 0, len(slots)) + seen := make(map[string]struct{}, len(slots)) + for _, slot := range slots { + if slot.Week <= 0 { + return nil, fmt.Errorf("存在非法 week=%d", slot.Week) + } + if !isValidDay(slot.DayOfWeek) { + return nil, fmt.Errorf("存在非法 day_of_week=%d", slot.DayOfWeek) + } + if !isValidSection(slot.SectionFrom, slot.SectionTo) { + return nil, fmt.Errorf("存在非法节次区间=%d-%d", slot.SectionFrom, slot.SectionTo) + } + key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SectionFrom, slot.SectionTo) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, slot) + } + sort.SliceStable(normalized, func(i, j int) bool { + if normalized[i].Week != normalized[j].Week { + return normalized[i].Week < normalized[j].Week + } + if normalized[i].DayOfWeek != normalized[j].DayOfWeek { + return normalized[i].DayOfWeek < normalized[j].DayOfWeek + } + if normalized[i].SectionFrom != normalized[j].SectionFrom { + return normalized[i].SectionFrom < normalized[j].SectionFrom + } + return normalized[i].SectionTo < normalized[j].SectionTo + }) + return normalized, nil +} + +func compareTaskOrder(a, b RefineTaskCandidate) int { + rankA := normalizedOriginRank(a) + rankB := normalizedOriginRank(b) + if rankA != rankB { + return rankA - rankB + } + if a.Week != b.Week { + return a.Week - b.Week + } + if a.DayOfWeek != b.DayOfWeek { + return a.DayOfWeek - b.DayOfWeek + } + if a.SectionFrom != b.SectionFrom { + return a.SectionFrom - b.SectionFrom + } + if a.SectionTo != b.SectionTo { + return a.SectionTo - b.SectionTo + } + return a.TaskItemID - b.TaskItemID +} + +func normalizedOriginRank(task RefineTaskCandidate) int { + if task.OriginRank > 0 { + return task.OriginRank + } + // 1. 无 origin_rank 时回退到较大稳定值,避免把“未知顺序”抢到前面。 + // 2. 叠加 task_id 作为细粒度稳定因子,保证排序可复现。 + return 1_000_000 + task.TaskItemID +} + +func normalizeContextKey(tag string) string { + text := strings.TrimSpace(tag) + if text == "" { + return "General" + } + return text +} + +func composeDayKey(week, day int) string { + return fmt.Sprintf("%d-%d", week, day) +} + +func sectionSpan(from, to int) int { + return to - from + 1 +} + +func isValidDay(day int) bool { + return day >= 1 && day <= 7 +} + +func isValidSection(from, to int) bool { + if from < 1 || to > 12 { + return false + } + return from <= to +} + +func slotOverlapsAny(candidate RefineSlotCandidate, selected []RefineSlotCandidate) bool { + for _, current := range selected { + if current.Week != candidate.Week || current.DayOfWeek != candidate.DayOfWeek { + continue + } + if current.SectionFrom <= candidate.SectionTo && candidate.SectionFrom <= current.SectionTo { + return true + } + } + return false +} diff --git a/backend/logic/refine_compound_ops_test.go b/backend/logic/refine_compound_ops_test.go new file mode 100644 index 0000000..a28535a --- /dev/null +++ b/backend/logic/refine_compound_ops_test.go @@ -0,0 +1,95 @@ +package logic + +import ( + "sort" + "testing" +) + +func TestPlanEvenSpreadMovesPrefersLowerLoadDay(t *testing.T) { + tasks := []RefineTaskCandidate{ + {TaskItemID: 101, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, OriginRank: 1}, + {TaskItemID: 102, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, OriginRank: 2}, + } + slots := []RefineSlotCandidate{ + {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, + {Week: 12, DayOfWeek: 2, SectionFrom: 1, SectionTo: 2}, + {Week: 12, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2}, + } + moves, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{ + ExistingDayLoad: map[string]int{ + composeDayKey(12, 1): 5, + composeDayKey(12, 2): 1, + composeDayKey(12, 3): 0, + }, + }) + if err != nil { + t.Fatalf("PlanEvenSpreadMoves 返回错误: %v", err) + } + if len(moves) != 2 { + t.Fatalf("期望移动 2 条,实际=%d", len(moves)) + } + + // 1. 低负载日(周三)应优先被填充; + // 2. 第二条应落在次低负载日(周二),而不是高负载日(周一)。 + weekDayByID := make(map[int][2]int, len(moves)) + for _, move := range moves { + weekDayByID[move.TaskItemID] = [2]int{move.ToWeek, move.ToDay} + } + if got := weekDayByID[101]; got != [2]int{12, 3} { + t.Fatalf("任务101应优先落到 W12D3,实际=%v", got) + } + if got := weekDayByID[102]; got != [2]int{12, 2} { + t.Fatalf("任务102应落到 W12D2,实际=%v", got) + } +} + +func TestPlanMinContextSwitchMovesGroupsSameContext(t *testing.T) { + tasks := []RefineTaskCandidate{ + {TaskItemID: 201, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学", OriginRank: 1}, + {TaskItemID: 202, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法", OriginRank: 2}, + {TaskItemID: 203, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学", OriginRank: 3}, + } + slots := []RefineSlotCandidate{ + {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, + {Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4}, + {Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6}, + } + moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{}) + if err != nil { + t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err) + } + if len(moves) != 3 { + t.Fatalf("期望移动 3 条,实际=%d", len(moves)) + } + + // 1. “数学”有 2 条,分组后应先连续落在最早两个坑位; + // 2. 因此 201 与 203 对应的目标节次应是 1-2 与 3-4(顺序由 origin_rank 决定)。 + sort.SliceStable(moves, func(i, j int) bool { + if moves[i].ToWeek != moves[j].ToWeek { + return moves[i].ToWeek < moves[j].ToWeek + } + if moves[i].ToDay != moves[j].ToDay { + return moves[i].ToDay < moves[j].ToDay + } + return moves[i].ToSectionFrom < moves[j].ToSectionFrom + }) + if moves[0].TaskItemID != 201 || moves[1].TaskItemID != 203 { + t.Fatalf("期望前两个坑位由同上下文任务占据,实际=%+v", moves) + } + if moves[2].TaskItemID != 202 { + t.Fatalf("期望最后一个坑位为算法任务,实际=%+v", moves[2]) + } +} + +func TestPlanEvenSpreadMovesReturnsErrorWhenSpanNotMatched(t *testing.T) { + tasks := []RefineTaskCandidate{ + {TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 3, OriginRank: 1}, // span=3 + } + slots := []RefineSlotCandidate{ + {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, // span=2 + } + _, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{}) + if err == nil { + t.Fatalf("期望 span 不匹配时报错,实际 err=nil") + } +} diff --git a/backend/middleware/token_handler.go b/backend/middleware/token_handler.go index be89b70..f0f2040 100644 --- a/backend/middleware/token_handler.go +++ b/backend/middleware/token_handler.go @@ -3,57 +3,89 @@ package middleware import ( "errors" "net/http" + "strings" "github.com/LoveLosita/smartflow/backend/auth" "github.com/LoveLosita/smartflow/backend/dao" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" - "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v4" ) -// JWTTokenAuth 接收 cache 实例,体现依赖注入 +// extractTokenFromAuthorization 负责解析 Authorization 头中的 token。 +// 职责边界: +// 1. 兼容“裸 token”和“Bearer ”两种传参方式。 +// 2. 不负责 token 合法性校验,只做字符串提取。 +// 3. 输入输出语义:header 为空或格式非法时返回空字符串。 +func extractTokenFromAuthorization(header string) string { + trimmed := strings.TrimSpace(header) + if trimmed == "" { + return "" + } + + parts := strings.Fields(trimmed) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return strings.TrimSpace(parts[1]) + } + + if len(parts) == 1 { + return parts[0] + } + + return "" +} + +// JWTTokenAuth 负责 access token 的鉴权拦截。 +// 职责边界: +// 1. 负责解析 token、验签、校验 token_type 与黑名单状态。 +// 2. 不负责签发 token,也不负责用户登录逻辑。 +// 3. 输出语义:校验通过时写入 user_id/claims 到上下文并放行;失败则中断请求。 func JWTTokenAuth(cache *dao.CacheDAO) gin.HandlerFunc { return func(c *gin.Context) { - // 1. 获取 Token (Gin 的 GetHeader 直接返回 string) - tokenString := c.GetHeader("Authorization") + tokenString := extractTokenFromAuthorization(c.GetHeader("Authorization")) if tokenString == "" { c.JSON(http.StatusUnauthorized, respond.MissingToken) c.Abort() return } - // 2. 改动:使用 ParseWithClaims 直接解析到你的结构体 - // 假设你的结构体叫 model.MyCustomClaims - token, err := jwt.ParseWithClaims(tokenString, &model.MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { - return auth.AccessKey, nil - }) + accessKey, err := auth.AccessSigningKey() + if err != nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(err)) + c.Abort() + return + } + // 1. 先验签并由 jwt 库统一校验 exp 等标准声明。 + token, err := jwt.ParseWithClaims(tokenString, &model.MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, respond.InvalidTokenSingingMethod + } + return accessKey, nil + }) if err != nil || !token.Valid { c.JSON(http.StatusUnauthorized, respond.InvalidToken) c.Abort() return } - // 3. 校验 Claims + // 2. 再做业务声明校验,防止 refresh token 越权访问业务接口。 claims, ok := token.Claims.(*model.MyCustomClaims) if !ok { c.JSON(http.StatusUnauthorized, respond.InvalidClaims) c.Abort() return } - // --- 🛡️ 核心改造:设卡检查 --- if claims.TokenType != "access_token" { c.JSON(http.StatusUnauthorized, respond.WrongTokenType) c.Abort() return } - // 拿着 jti 去 Redis 查一下 + // 3. 最后查黑名单,兜住“用户已登出但 token 仍未到期”的场景。 isBlack, err := cache.IsBlacklisted(claims.Jti) if err != nil { - // 如果 Redis 挂了,为了安全通常选择报错,或者降级放行(取决于你的业务) c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("无法验证令牌状态"))) c.Abort() return @@ -64,9 +96,8 @@ func JWTTokenAuth(cache *dao.CacheDAO) gin.HandlerFunc { return } - // 4. 存入上下文 c.Set("user_id", claims.UserID) c.Set("claims", claims) - c.Next() // 只有所有关卡都过了,才放行 + c.Next() } }