Files
smartmate/backend/agent2/node/schedule_refine_impl/tool.go
Losita a243154e23 Version: 0.7.9.dev.260326
后端:
1.把最后一块拼图:schedule_refine也搬迁到了agent2,此时agent已经完全解耦。但是它没融入新架构,Codex只尝试把它调整了一部分,回退了一些错误的更改,保持着现在的可运行状态。下次继续改。
2.agent目录先保留,直到refine彻底融入新架构。
3.改善Codex主导的新史山结构:node文件夹里面大量文件,转而改成了module.go+module_tool.go的双文件格局,极大提升架构整洁度和代码可读性。
前端:
1.新开了日历界面,正在保持往前推进。做了很多更改,感觉越来越好了。
2026-03-26 00:38:17 +08:00

2028 lines
66 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package schedulerefine
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/LoveLosita/smartflow/backend/logic"
"github.com/LoveLosita/smartflow/backend/model"
)
// reactToolCall 表示模型输出的单个工具调用指令。
type reactToolCall struct {
Tool string `json:"tool"`
Params map[string]any `json:"params"`
}
// reactToolResult 表示工具调用的结构化执行结果。
type reactToolResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
ErrorCode string `json:"error_code,omitempty"`
Result string `json:"result"`
}
// reactLLMOutput 表示“强 ReAct”要求的固定 JSON 输出结构。
//
// 字段语义:
// 1. goal_check本轮要先验证的目标点
// 2. decision本轮动作选择依据
// 3. tool_calls本轮工具动作列表业务侧只取第一条
type reactLLMOutput struct {
Done bool `json:"done"`
Summary string `json:"summary"`
GoalCheck string `json:"goal_check"`
Decision string `json:"decision"`
MissingInfo []string `json:"missing_info,omitempty"`
ToolCalls []reactToolCall `json:"tool_calls"`
}
// reviewOutput 表示终审节点要求的固定 JSON 输出结构。
type reviewOutput struct {
Pass bool `json:"pass"`
Reason string `json:"reason"`
Unmet []string `json:"unmet"`
}
// planningWindow 表示微调工具允许活动的 week/day 边界窗口。
//
// 设计说明:
// 1. 这里用已有 HybridEntries 自动推导窗口,避免把任务移动到完全无关的周;
// 2. 若窗口不可用(没有任何 entry则降级为“仅做基础合法性校验”。
type planningWindow struct {
Enabled bool
StartWeek int
StartDay int
EndWeek int
EndDay int
}
// refineToolPolicy 是工具层硬约束策略。
//
// 职责边界:
// 1. 负责承载“是否强制保持相对顺序”的策略开关;
// 2. 负责承载顺序校验需要的 origin_order 映射;
// 3. 不负责语义判定(语义仍由 LLM 终审节点负责)。
type refineToolPolicy struct {
KeepRelativeOrder bool
OrderScope string
OriginOrderMap map[int]int
}
// dispatchRefineTool 负责把模型输出的 tool_call 分发到具体工具实现。
//
// 步骤化说明:
// 1. 先识别工具名并路由到对应实现;
// 2. 工具实现内部负责参数校验、冲突校验、边界校验、顺序校验;
// 3. 任何失败都返回 Success=false 的结构化结果,而不是直接 panic。
func dispatchRefineTool(entries []model.HybridScheduleEntry, call reactToolCall, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
switch strings.TrimSpace(call.Tool) {
case "QueryTargetTasks":
return refineToolQueryTargetTasks(entries, call.Params, policy)
case "QueryAvailableSlots":
return refineToolQueryAvailableSlots(entries, call.Params, window)
case "Move":
return refineToolMove(entries, call.Params, window, policy)
case "Swap":
return refineToolSwap(entries, call.Params, window, policy)
case "BatchMove":
return refineToolBatchMove(entries, call.Params, window, policy)
case "SpreadEven":
return refineToolSpreadEven(entries, call.Params, window, policy)
case "MinContextSwitch":
return refineToolMinContextSwitch(entries, call.Params, window, policy)
case "Verify":
return refineToolVerify(entries, call.Params, policy)
default:
return entries, reactToolResult{
Tool: strings.TrimSpace(call.Tool),
Success: false,
Result: fmt.Sprintf("不支持的工具:%s仅允许 QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/SpreadEven/MinContextSwitch/Verify", strings.TrimSpace(call.Tool)),
}
}
}
// pickSingleToolCall 在“单步动作”策略下选取一个工具调用。
//
// 返回语义:
// 1. call=nil本轮无可执行动作
// 2. warn 非空:模型返回了多个调用,本轮只执行第一个并记录告警。
func pickSingleToolCall(calls []reactToolCall) (*reactToolCall, string) {
if len(calls) == 0 {
return nil, ""
}
call := calls[0]
if len(calls) == 1 {
return &call, ""
}
return &call, fmt.Sprintf("模型返回了 %d 个工具调用,本轮仅执行第一个:%s", len(calls), call.Tool)
}
// parseReactLLMOutput 解析模型输出的 ReAct JSON。
//
// 容错策略:
// 1. 兼容 ```json 代码块包装;
// 2. 兼容 JSON 前后有解释性文字(提取最外层对象)。
func parseReactLLMOutput(raw string) (*reactLLMOutput, error) {
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, fmt.Errorf("ReAct 输出为空")
}
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
var out reactLLMOutput
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
obj, objErr := extractFirstJSONObject(clean)
if objErr != nil {
return nil, fmt.Errorf("无法从输出中提取 JSON%s", truncate(clean, 220))
}
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, err
}
return &out, nil
}
// parseReviewOutput 解析终审评估节点输出。
func parseReviewOutput(raw string) (*reviewOutput, error) {
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, fmt.Errorf("review 输出为空")
}
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
var out reviewOutput
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
obj, objErr := extractFirstJSONObject(clean)
if objErr != nil {
return nil, fmt.Errorf("无法从 review 输出中提取 JSON%s", truncate(clean, 220))
}
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, err
}
return &out, nil
}
// refineToolMove 执行“移动一个 suggested 任务到指定时段”。
//
// 步骤化说明:
// 1. 先校验参数完整性与目标时段合法性,避免写入脏坐标;
// 2. 再校验原任务是否存在、跨度是否一致(防止任务长度被模型改坏);
// 3. 再校验窗口边界与冲突,确保不会穿透到不可用位置;
// 4. 若启用顺序硬约束,再校验“移动后是否打乱原相对顺序”;
// 5. 全部通过后才真正修改 entries 并返回 Success=true。
func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
// 0. task_id 兼容策略:
// 0.1 标准键是 task_item_id
// 0.2 为了兼容模型偶发输出别名 task_id这里做兜底兼容避免“语义正确但参数名不一致”导致整轮白跑
// 0.3 两者都不存在时,仍按参数缺失返回失败,由上层 ReAct 继续下一轮决策。
taskID, ok := paramIntAny(params, "task_item_id", "task_id")
if !ok {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失task_item_id"}
}
// 1. 参数兼容策略:
// 1.1 优先读取标准键to_week/to_day/...
// 1.2 若模型输出了历史别名target_xxx/day_of_week 等),也兼容解析;
// 1.3 目标是减少“仅参数名不一致导致的无效失败轮次”。
toWeek, okWeek := paramIntAny(params, "to_week", "target_week", "new_week", "week")
toDay, okDay := paramIntAny(params, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day")
toSF, okSF := paramIntAny(params, "to_section_from", "target_section_from", "new_section_from", "section_from")
toST, okST := paramIntAny(params, "to_section_to", "target_section_to", "new_section_to", "section_to")
if !okWeek || !okDay || !okSF || !okST {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: "参数缺失:需要 to_week/to_day/to_section_from/to_section_to",
}
}
if toDay < 1 || toDay > 7 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 非法,必须在 1~7", toDay)}
}
if toSF < 1 || toST > 12 || toSF > toST {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次区间 %d-%d 非法", toSF, toST)}
}
allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")
idx, locateErr := findUniqueSuggestedByID(entries, taskID)
if locateErr != nil {
return entries, reactToolResult{Tool: "Move", Success: false, Result: locateErr.Error()}
}
origSpan := entries[idx].SectionTo - entries[idx].SectionFrom
newSpan := toST - toSF
if origSpan != newSpan {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: fmt.Sprintf("任务跨度不一致:原跨度=%d目标跨度=%d", origSpan+1, newSpan+1),
}
}
if !isWithinWindow(window, toWeek, toDay) {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: fmt.Sprintf("目标 W%dD%d 超出允许窗口", toWeek, toDay),
}
}
if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}, allowEmbed); conflict {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: fmt.Sprintf("目标时段已被 %s 占用", name),
}
}
beforeEntries := cloneHybridEntries(entries)
entry := &entries[idx]
before := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo)
entry.Week = toWeek
entry.DayOfWeek = toDay
entry.SectionFrom = toSF
entry.SectionTo = toST
after := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo)
sortHybridEntries(entries)
if issues := validateRelativeOrder(entries, policy); len(issues) > 0 {
return beforeEntries, reactToolResult{
Tool: "Move",
Success: false,
Result: "顺序约束不满足:" + strings.Join(issues, ""),
}
}
return entries, reactToolResult{
Tool: "Move",
Success: true,
Result: fmt.Sprintf("已将任务[%s](id=%d,type=%s,status=%s) 从 %s 移动到 %s", entry.Name, taskID, strings.TrimSpace(entry.Type), strings.TrimSpace(entry.Status), before, after),
}
}
// refineToolSwap 执行“交换两个 suggested 任务的位置”。
//
// 步骤化说明:
// 1. 先校验两端 task_item_id
// 2. 再双向验证交换后的落点是否与其他条目冲突;
// 3. 若启用顺序硬约束,再校验“交换后是否打乱相对顺序”;
// 4. 校验通过后提交交换并返回成功。
func refineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
// 1. 参数兼容策略同 Move
// 1.1 兼容 task_a/task_b 与 task_item_a/task_item_b 等常见别名;
// 1.2 目标是减少模型输出字段差异导致的无效失败。
idA, okA := paramIntAny(params, "task_a", "task_item_a", "task_item_id_a")
idB, okB := paramIntAny(params, "task_b", "task_item_b", "task_item_id_b")
if !okA || !okB {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失task_a/task_b"}
}
allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")
if idA == idB {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 与 task_b 不能相同"}
}
idxA, errA := findUniqueSuggestedByID(entries, idA)
if errA != nil {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: errA.Error()}
}
idxB, errB := findUniqueSuggestedByID(entries, idB)
if errB != nil {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: errB.Error()}
}
a := entries[idxA]
b := entries[idxB]
if !isWithinWindow(window, b.Week, b.DayOfWeek) || !isWithinWindow(window, a.Week, a.DayOfWeek) {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "交换目标超出允许窗口"}
}
excludes := map[int]bool{idxA: true, idxB: true}
if conflict, name := hasConflict(entries, b.Week, b.DayOfWeek, b.SectionFrom, b.SectionTo, excludes, allowEmbed); conflict {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务A交换后将与 %s 冲突", name)}
}
if conflict, name := hasConflict(entries, a.Week, a.DayOfWeek, a.SectionFrom, a.SectionTo, excludes, allowEmbed); conflict {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务B交换后将与 %s 冲突", name)}
}
beforeEntries := cloneHybridEntries(entries)
entries[idxA].Week, entries[idxB].Week = entries[idxB].Week, entries[idxA].Week
entries[idxA].DayOfWeek, entries[idxB].DayOfWeek = entries[idxB].DayOfWeek, entries[idxA].DayOfWeek
entries[idxA].SectionFrom, entries[idxB].SectionFrom = entries[idxB].SectionFrom, entries[idxA].SectionFrom
entries[idxA].SectionTo, entries[idxB].SectionTo = entries[idxB].SectionTo, entries[idxA].SectionTo
sortHybridEntries(entries)
if issues := validateRelativeOrder(entries, policy); len(issues) > 0 {
return beforeEntries, reactToolResult{
Tool: "Swap",
Success: false,
Result: "顺序约束不满足:" + strings.Join(issues, ""),
}
}
return entries, reactToolResult{
Tool: "Swap",
Success: true,
Result: fmt.Sprintf("已交换任务 id=%d 与 id=%d 的时段", idA, idB),
}
}
// refineToolBatchMove 执行“原子批量移动 suggested 任务”。
//
// 步骤化说明:
// 1. 参数要求params.moves 必须是数组,每个元素都满足 Move 的参数格式;
// 2. 执行策略:在 working 副本上按顺序逐条执行 Move
// 3. 原子语义:任一步失败,整批回滚(返回原 entries全部成功才一次性提交
// 4. 适用场景:用户明确希望“同一轮挪多个任务”,减少 ReAct 往返轮次。
func refineToolBatchMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
moveParamsList, parseErr := parseBatchMoveParams(params)
if parseErr != nil {
return entries, reactToolResult{
Tool: "BatchMove",
Success: false,
ErrorCode: "PARAM_MISSING",
Result: parseErr.Error(),
}
}
// 2. 批级 allow_embed 默认值:
// 2.1 如果子动作未显式声明 allow_embed/allow_embedding则继承批级开关
// 2.2 默认 true和 Move/Swap 一致:允许嵌入,但由 QueryAvailableSlots 先给纯空位。
batchAllowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")
for i := range moveParamsList {
if _, ok := moveParamsList[i]["allow_embed"]; ok {
continue
}
if _, ok := moveParamsList[i]["allow_embedding"]; ok {
continue
}
moveParamsList[i]["allow_embed"] = batchAllowEmbed
}
// 1. 在副本上执行,保证原子性:
// 1.1 每一步都复用 refineToolMove 的全部校验逻辑(冲突、窗口、顺序、跨度);
// 1.2 只要任一步失败就中止并回滚到原 entries
// 1.3 全部成功后再返回 working作为整批提交结果。
working := cloneHybridEntries(entries)
stepSummary := make([]string, 0, len(moveParamsList))
currentWindow := buildPlanningWindowFromEntries(working)
if !currentWindow.Enabled {
currentWindow = window
}
for idx, moveParams := range moveParamsList {
nextEntries, stepResult := refineToolMove(working, moveParams, currentWindow, policy)
if !stepResult.Success {
return entries, reactToolResult{
Tool: "BatchMove",
Success: false,
ErrorCode: classifyBatchMoveErrorCode(stepResult.Result),
Result: fmt.Sprintf("BatchMove 第%d步失败%s", idx+1, stepResult.Result),
}
}
working = nextEntries
currentWindow = buildPlanningWindowFromEntries(working)
stepSummary = append(stepSummary, fmt.Sprintf("第%d步:%s", idx+1, truncate(stepResult.Result, 120)))
}
return working, reactToolResult{
Tool: "BatchMove",
Success: true,
Result: fmt.Sprintf("BatchMove 原子提交成功,共执行%d步。%s", len(moveParamsList), strings.Join(stepSummary, " | ")),
}
}
type compositePlannerFn func(
tasks []logic.RefineTaskCandidate,
slots []logic.RefineSlotCandidate,
options logic.RefineCompositePlanOptions,
) ([]logic.RefineMovePlanItem, error)
// refineToolSpreadEven 执行“均匀铺开”复合动作。
//
// 职责边界:
// 1. 负责参数解析、候选收集、调用确定性规划器;
// 2. 不直接改写 entries统一通过 BatchMove 原子落地;
// 3. 规划算法实现位于 logic 包,工具层只负责编排。
func refineToolSpreadEven(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
return refineToolCompositeMove(entries, params, window, policy, "SpreadEven", logic.PlanEvenSpreadMoves)
}
// refineToolMinContextSwitch 执行“最少上下文切换”复合动作。
//
// 职责边界:
// 1. 负责锁定“当前任务已占坑位集合”,避免为了聚类把任务远距离迁移;
// 2. 负责在固定坑位集合内调用确定性规划器,只重排“任务 -> 坑位”的映射;
// 3. 不直接改写 entries统一通过 BatchMove 原子落地。
func refineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
taskIDs := collectCompositeTaskIDs(params)
if len(taskIDs) == 0 {
return entries, reactToolResult{
Tool: "MinContextSwitch",
Success: false,
ErrorCode: "PARAM_MISSING",
Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id",
}
}
tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, "MinContextSwitch")
if !ok {
return entries, taskResult
}
// 1. MinContextSwitch 的产品语义是“尽量少切换,同时尽量少折腾坑位”;
// 2. 因此这里不再查询整周新坑位,而是直接复用当前任务已占据的坑位集合;
// 3. 这样最终只会发生“任务之间互换位置”,不会跳到用户意料之外的远处时段。
currentSlots := buildCompositeCurrentTaskSlots(tasks)
plannedMoves, planErr := logic.PlanMinContextSwitchMoves(tasks, currentSlots, logic.RefineCompositePlanOptions{})
if planErr != nil {
return entries, reactToolResult{
Tool: "MinContextSwitch",
Success: false,
ErrorCode: "PLAN_FAILED",
Result: planErr.Error(),
}
}
return applyFixedSlotCompositeMoves(entries, policy, "MinContextSwitch", plannedMoves)
}
// refineToolCompositeMove 是复合动作工具的统一执行框架。
//
// 步骤化说明:
// 1. 先解析“目标任务集合”,确保任务来源明确且可唯一落到 task_item_id
// 2. 再按任务跨度查询候选坑位,避免跨度不一致导致执行期失败;
// 3. 调用 logic 包的确定性规划函数,得到 moves
// 4. 最后复用 BatchMove 原子提交,任一步失败整批回滚。
func refineToolCompositeMove(
entries []model.HybridScheduleEntry,
params map[string]any,
window planningWindow,
policy refineToolPolicy,
toolName string,
planner compositePlannerFn,
) ([]model.HybridScheduleEntry, reactToolResult) {
taskIDs := collectCompositeTaskIDs(params)
if len(taskIDs) == 0 {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "PARAM_MISSING",
Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id",
}
}
tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, toolName)
if !ok {
return entries, taskResult
}
idSet := intSliceToIDSet(taskIDs)
spanNeed := buildCompositeSpanNeed(tasks)
slots, slotErr := collectCompositeSlotsBySpan(entries, params, window, spanNeed)
if slotErr != nil {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "SLOT_QUERY_FAILED",
Result: slotErr.Error(),
}
}
options := logic.RefineCompositePlanOptions{
ExistingDayLoad: buildCompositeDayLoadBaseline(entries, idSet, slots),
}
plannedMoves, planErr := planner(tasks, slots, options)
if planErr != nil {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "PLAN_FAILED",
Result: planErr.Error(),
}
}
return applyCompositePlannedMoves(entries, params, window, policy, toolName, plannedMoves)
}
// collectCompositeTasks 收集复合动作参与的可移动任务,并做唯一性校验。
//
// 步骤化说明:
// 1. 只收 suggested 且可移动的 task避免误改 existing/course
// 2. task_item_id 必须一一命中,命中多条或缺失都直接失败;
// 3. 输出顺序保持 entries 原始遍历顺序,后续再由规划器做稳定排序。
func collectCompositeTasks(entries []model.HybridScheduleEntry, taskIDs []int, policy refineToolPolicy, toolName string) ([]logic.RefineTaskCandidate, reactToolResult, bool) {
idSet := intSliceToIDSet(taskIDs)
tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs))
found := make(map[int]struct{}, len(taskIDs))
for _, entry := range entries {
if !isMovableSuggestedTask(entry) {
continue
}
if _, ok := idSet[entry.TaskItemID]; !ok {
continue
}
if _, duplicated := found[entry.TaskItemID]; duplicated {
return nil, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "TASK_ID_AMBIGUOUS",
Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID),
}, false
}
found[entry.TaskItemID] = struct{}{}
tasks = append(tasks, logic.RefineTaskCandidate{
TaskItemID: entry.TaskItemID,
Week: entry.Week,
DayOfWeek: entry.DayOfWeek,
SectionFrom: entry.SectionFrom,
SectionTo: entry.SectionTo,
Name: strings.TrimSpace(entry.Name),
ContextTag: strings.TrimSpace(entry.ContextTag),
OriginRank: policy.OriginOrderMap[entry.TaskItemID],
})
}
if len(tasks) != len(taskIDs) {
missing := make([]int, 0, len(taskIDs))
for _, id := range taskIDs {
if _, ok := found[id]; !ok {
missing = append(missing, id)
}
}
return nil, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "TASK_NOT_FOUND",
Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing),
}, false
}
return tasks, reactToolResult{}, true
}
func buildCompositeSpanNeed(tasks []logic.RefineTaskCandidate) map[int]int {
spanNeed := make(map[int]int, len(tasks))
for _, task := range tasks {
spanNeed[task.SectionTo-task.SectionFrom+1]++
}
return spanNeed
}
func buildCompositeCurrentTaskSlots(tasks []logic.RefineTaskCandidate) []logic.RefineSlotCandidate {
slots := make([]logic.RefineSlotCandidate, 0, len(tasks))
for _, task := range tasks {
slots = append(slots, logic.RefineSlotCandidate{
Week: task.Week,
DayOfWeek: task.DayOfWeek,
SectionFrom: task.SectionFrom,
SectionTo: task.SectionTo,
})
}
return slots
}
func applyCompositePlannedMoves(
entries []model.HybridScheduleEntry,
params map[string]any,
window planningWindow,
policy refineToolPolicy,
toolName string,
plannedMoves []logic.RefineMovePlanItem,
) ([]model.HybridScheduleEntry, reactToolResult) {
if len(plannedMoves) == 0 {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "PLAN_EMPTY",
Result: "规划结果为空:未生成任何可执行移动",
}
}
moveParams := make([]any, 0, len(plannedMoves))
for _, move := range plannedMoves {
moveParams = append(moveParams, map[string]any{
"task_item_id": move.TaskItemID,
"to_week": move.ToWeek,
"to_day": move.ToDay,
"to_section_from": move.ToSectionFrom,
"to_section_to": move.ToSectionTo,
})
}
batchParams := map[string]any{
"moves": moveParams,
"allow_embed": paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding"),
}
nextEntries, batchResult := refineToolBatchMove(entries, batchParams, window, policy)
if !batchResult.Success {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: batchResult.ErrorCode,
Result: fmt.Sprintf("%s 执行失败:%s", toolName, batchResult.Result),
}
}
return nextEntries, reactToolResult{
Tool: toolName,
Success: true,
Result: fmt.Sprintf("%s 执行成功:已规划并提交 %d 条移动。", toolName, len(plannedMoves)),
}
}
// applyFixedSlotCompositeMoves 以“同时改写坐标”的方式提交固定坑位重排结果。
//
// 步骤化说明:
// 1. 该函数专门服务“坑位集合固定”的复合工具,避免 BatchMove 顺序执行时出现互相占位冲突;
// 2. 先在副本上一次性改写所有目标任务的坐标,再统一排序与校验;
// 3. 若发现目标坑位重复、任务缺失、或顺序约束不满足,则整批失败并回滚。
func applyFixedSlotCompositeMoves(
entries []model.HybridScheduleEntry,
policy refineToolPolicy,
toolName string,
plannedMoves []logic.RefineMovePlanItem,
) ([]model.HybridScheduleEntry, reactToolResult) {
if len(plannedMoves) == 0 {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "PLAN_EMPTY",
Result: "规划结果为空:未生成任何可执行移动",
}
}
working := cloneHybridEntries(entries)
indexByTaskID := make(map[int]int, len(working))
for idx, entry := range working {
if !isMovableSuggestedTask(entry) {
continue
}
if _, exists := indexByTaskID[entry.TaskItemID]; exists {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "TASK_ID_AMBIGUOUS",
Result: fmt.Sprintf("%s 执行失败task_item_id=%d 命中多条可移动 suggested 任务", toolName, entry.TaskItemID),
}
}
indexByTaskID[entry.TaskItemID] = idx
}
targetSeen := make(map[string]int, len(plannedMoves))
for _, move := range plannedMoves {
if _, ok := indexByTaskID[move.TaskItemID]; !ok {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "TASK_NOT_FOUND",
Result: fmt.Sprintf("%s 执行失败task_item_id=%d 未找到可移动 suggested 任务", toolName, move.TaskItemID),
}
}
key := fmt.Sprintf("%d-%d-%d-%d", move.ToWeek, move.ToDay, move.ToSectionFrom, move.ToSectionTo)
if prevID, exists := targetSeen[key]; exists {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "PLAN_CONFLICT",
Result: fmt.Sprintf("%s 执行失败:任务 id=%d 与 id=%d 目标坑位重复", toolName, prevID, move.TaskItemID),
}
}
targetSeen[key] = move.TaskItemID
}
for _, move := range plannedMoves {
idx := indexByTaskID[move.TaskItemID]
working[idx].Week = move.ToWeek
working[idx].DayOfWeek = move.ToDay
working[idx].SectionFrom = move.ToSectionFrom
working[idx].SectionTo = move.ToSectionTo
}
sortHybridEntries(working)
if issues := validateRelativeOrder(working, policy); len(issues) > 0 {
return entries, reactToolResult{
Tool: toolName,
Success: false,
ErrorCode: "ORDER_CONSTRAINT_VIOLATED",
Result: "顺序约束不满足:" + strings.Join(issues, ""),
}
}
return working, reactToolResult{
Tool: toolName,
Success: true,
Result: fmt.Sprintf("%s 执行成功:已在固定坑位集合内重排 %d 条任务。", toolName, len(plannedMoves)),
}
}
func collectCompositeTaskIDs(params map[string]any) []int {
ids := readIntSlice(params, "task_item_ids", "task_ids")
if id, ok := paramIntAny(params, "task_item_id", "task_id"); ok {
ids = append(ids, id)
}
return uniquePositiveInts(ids)
}
func collectCompositeSlotsBySpan(
entries []model.HybridScheduleEntry,
params map[string]any,
window planningWindow,
spanNeed map[int]int,
) ([]logic.RefineSlotCandidate, error) {
if len(spanNeed) == 0 {
return nil, fmt.Errorf("未识别到任务跨度需求")
}
spans := make([]int, 0, len(spanNeed))
for span := range spanNeed {
spans = append(spans, span)
}
sort.Ints(spans)
allSlots := make([]logic.RefineSlotCandidate, 0, 16)
for _, span := range spans {
required := spanNeed[span]
queryParams := buildCompositeSlotQueryParams(params, span, required)
_, queryResult := refineToolQueryAvailableSlots(entries, queryParams, window)
if !queryResult.Success {
return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, queryResult.Result)
}
var payload struct {
Slots []struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
} `json:"slots"`
}
if err := json.Unmarshal([]byte(queryResult.Result), &payload); err != nil {
return nil, fmt.Errorf("解析跨度=%d 的空位结果失败:%v", span, err)
}
if len(payload.Slots) < required {
return nil, fmt.Errorf("跨度=%d 可用坑位不足required=%d, got=%d", span, required, len(payload.Slots))
}
for _, slot := range payload.Slots {
allSlots = append(allSlots, logic.RefineSlotCandidate{
Week: slot.Week,
DayOfWeek: slot.DayOfWeek,
SectionFrom: slot.SectionFrom,
SectionTo: slot.SectionTo,
})
}
}
return allSlots, nil
}
func buildCompositeSlotQueryParams(params map[string]any, span int, required int) map[string]any {
query := make(map[string]any, 12)
query["span"] = span
// 1. limit 以“任务数 * 兜底系数”估算,给规划器保留可选空间;
// 2. 若调用方显式给了 limit则采用更大的那个避免被过小 limit 限死。
limit := required * 6
if limit < required {
limit = required
}
if customLimit, ok := paramIntAny(params, "limit"); ok && customLimit > limit {
limit = customLimit
}
query["limit"] = limit
query["allow_embed"] = paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")
for _, key := range []string{"week", "week_from", "week_to", "day_scope", "after_section", "before_section"} {
if value, ok := params[key]; ok {
query[key] = value
}
}
// 1. 复合路由主链路自身使用的是 week_filter/day_of_week/exclude_sections
// 2. 这里必须优先透传这些“规范键”,再兼容历史别名;
// 3. 否则会出现复合工具已被调用,但内部查坑位时丢失目标范围,导致规划结果漂移。
copyIntSliceParam(params, query, "week_filter", "week_filter", "weeks")
copyIntSliceParam(params, query, "day_of_week", "day_of_week", "days", "day_filter")
copyIntSliceParam(params, query, "exclude_sections", "exclude_sections", "exclude_section")
// 兼容 Move 风格别名,降低模型参数名漂移导致的失败。
if week, ok := paramIntAny(params, "to_week", "target_week", "new_week"); ok {
query["week"] = week
}
if day, ok := paramIntAny(params, "to_day", "target_day", "target_day_of_week", "new_day", "day"); ok {
query["day_of_week"] = []int{day}
}
return query
}
func copyIntSliceParam(src map[string]any, dst map[string]any, dstKey string, srcKeys ...string) {
values := readIntSlice(src, srcKeys...)
if len(values) == 0 {
return
}
normalized := uniquePositiveInts(values)
if len(normalized) == 0 {
return
}
dst[dstKey] = normalized
}
func buildCompositeDayLoadBaseline(
entries []model.HybridScheduleEntry,
excludeTaskIDs map[int]struct{},
slots []logic.RefineSlotCandidate,
) map[string]int {
if len(slots) == 0 {
return nil
}
targetDays := make(map[string]struct{}, len(slots))
for _, slot := range slots {
targetDays[fmt.Sprintf("%d-%d", slot.Week, slot.DayOfWeek)] = struct{}{}
}
load := make(map[string]int, len(targetDays))
for _, entry := range entries {
if !isMovableSuggestedTask(entry) {
continue
}
if _, excluded := excludeTaskIDs[entry.TaskItemID]; excluded {
continue
}
key := fmt.Sprintf("%d-%d", entry.Week, entry.DayOfWeek)
if _, inTarget := targetDays[key]; !inTarget {
continue
}
load[key]++
}
return load
}
// refineToolQueryTargetTasks 查询“本轮潜在目标任务集合”。
//
// 步骤化说明:
// 1. 支持按 day_scope(weekend/workday/all)、week 范围、limit 过滤;
// 2. 只读查询,不修改 entries
// 3. 返回结构化 JSON 字符串,供下一轮模型直接消费。
func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
scope := normalizeDayScope(readString(params, "day_scope", "all"))
statusFilter := normalizeStatusFilter(readString(params, "status", "suggested"))
weekFilter := intSliceToWeekSet(readIntSlice(params, "week_filter", "weeks"))
weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week")
weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week")
if week, hasWeek := paramIntAny(params, "week"); hasWeek {
weekFrom, weekTo = week, week
hasWeekFrom, hasWeekTo = true, true
}
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
weekFrom, weekTo = weekTo, weekFrom
}
if !hasWeekFrom || !hasWeekTo {
startWeek, endWeek := inferWeekBounds(entries, planningWindow{Enabled: false})
if !hasWeekFrom {
weekFrom = startWeek
}
if !hasWeekTo {
weekTo = endWeek
}
}
limit, okLimit := paramIntAny(params, "limit")
if !okLimit || limit <= 0 {
limit = 16
}
dayFilter := intSliceToDaySet(readIntSlice(params, "day_of_week", "days", "day_filter"))
taskIDs := readIntSlice(params, "task_item_ids", "task_ids")
if taskID, ok := paramIntAny(params, "task_item_id", "task_id"); ok {
taskIDs = append(taskIDs, taskID)
}
taskIDSet := intSliceToIDSet(taskIDs)
type targetTask struct {
TaskItemID int `json:"task_item_id"`
Name string `json:"name"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
OriginRank int `json:"origin_rank,omitempty"`
ContextTag string `json:"context_tag,omitempty"`
CurrentState string `json:"status"`
}
list := make([]targetTask, 0, 32)
for _, entry := range entries {
if !matchStatusFilter(entry.Status, statusFilter) {
continue
}
// suggested 视图只允许看到“可移动任务”,避免把课程类条目当成可调任务暴露给模型。
if statusFilter == "suggested" && !isMovableSuggestedTask(entry) {
continue
}
if entry.TaskItemID <= 0 {
continue
}
if len(taskIDSet) > 0 {
if _, ok := taskIDSet[entry.TaskItemID]; !ok {
continue
}
}
if len(dayFilter) > 0 {
if _, ok := dayFilter[entry.DayOfWeek]; !ok {
continue
}
} else if !matchDayScope(entry.DayOfWeek, scope) {
continue
}
if len(weekFilter) > 0 {
if _, ok := weekFilter[entry.Week]; !ok {
continue
}
}
if hasWeekFrom && entry.Week < weekFrom {
continue
}
if hasWeekTo && entry.Week > weekTo {
continue
}
list = append(list, targetTask{
TaskItemID: entry.TaskItemID,
Name: strings.TrimSpace(entry.Name),
Week: entry.Week,
DayOfWeek: entry.DayOfWeek,
SectionFrom: entry.SectionFrom,
SectionTo: entry.SectionTo,
OriginRank: policy.OriginOrderMap[entry.TaskItemID],
ContextTag: strings.TrimSpace(entry.ContextTag),
CurrentState: entry.Status,
})
}
sort.SliceStable(list, func(i, j int) bool {
if list[i].Week != list[j].Week {
return list[i].Week < list[j].Week
}
if list[i].DayOfWeek != list[j].DayOfWeek {
return list[i].DayOfWeek < list[j].DayOfWeek
}
if list[i].SectionFrom != list[j].SectionFrom {
return list[i].SectionFrom < list[j].SectionFrom
}
return list[i].TaskItemID < list[j].TaskItemID
})
if len(list) > limit {
list = list[:limit]
}
payload := map[string]any{
"tool": "QueryTargetTasks",
"count": len(list),
"status": statusFilter,
"day_scope": scope,
"week_filter": keysOfIntSet(weekFilter),
"week_from": weekFrom,
"week_to": weekTo,
"day_of_week": keysOfIntSet(dayFilter),
"items": list,
}
raw, err := json.Marshal(payload)
if err != nil {
return entries, reactToolResult{
Tool: "QueryTargetTasks",
Success: false,
ErrorCode: "QUERY_ENCODE_FAILED",
Result: fmt.Sprintf("序列化查询结果失败:%v", err),
}
}
return entries, reactToolResult{
Tool: "QueryTargetTasks",
Success: true,
Result: string(raw),
}
}
// refineToolQueryAvailableSlots 查询“可放置 suggested 的空位”。
//
// 步骤化说明:
// 1. 根据 day_scope/week 范围/span/exclude_sections 过滤候选时段;
// 2. 默认先收集“纯空位”,不足 limit 再补“可嵌入课程位”(第二优先级);
// 3. 返回结构化 JSON 字符串,不修改 entries。
func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow) ([]model.HybridScheduleEntry, reactToolResult) {
scope := normalizeDayScope(readString(params, "day_scope", "all"))
dayFilter := intSliceToDaySet(readIntSlice(params, "day_of_week", "days", "day_filter"))
weekFilter := intSliceToWeekSet(readIntSlice(params, "week_filter", "weeks"))
// 1. 空位优先策略:
// 1.1 默认 allow_embed=true但查询分两阶段执行
// 1.2 第一阶段只收集“纯空白位”(不与 existing 重叠);
// 1.3 第二阶段仅在空白位不足 limit 时,补充“可嵌入课程位”。
allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")
// 1.4 兼容 slot_type/slot_types
// 1.4.1 当明确请求 pure/empty/strict 时,强制只查纯空位(关闭嵌入候选)。
// 1.4.2 当未声明时,维持“空位优先,空位不足再补嵌入候选”的默认策略。
slotTypeHints := readStringSlice(params, "slot_types")
if single := strings.TrimSpace(readString(params, "slot_type", "")); single != "" {
slotTypeHints = append(slotTypeHints, single)
}
for _, hint := range slotTypeHints {
normalized := strings.ToLower(strings.TrimSpace(hint))
if normalized == "pure" || normalized == "empty" || normalized == "strict" {
allowEmbed = false
break
}
}
span, okSpan := paramIntAny(params, "span", "section_duration", "task_duration")
if !okSpan || span <= 0 {
span = 2
}
if span > 12 {
return entries, reactToolResult{
Tool: "QueryAvailableSlots",
Success: false,
ErrorCode: "SPAN_INVALID",
Result: fmt.Sprintf("span=%d 非法,必须在 1~12", span),
}
}
limit, okLimit := paramIntAny(params, "limit")
if !okLimit || limit <= 0 {
limit = 12
}
weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week")
weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week")
if week, hasWeek := paramIntAny(params, "week"); hasWeek {
weekFrom, weekTo = week, week
hasWeekFrom, hasWeekTo = true, true
}
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
weekFrom, weekTo = weekTo, weekFrom
}
if !hasWeekFrom || !hasWeekTo {
startWeek, endWeek := inferWeekBounds(entries, window)
if !hasWeekFrom {
weekFrom = startWeek
}
if !hasWeekTo {
weekTo = endWeek
}
}
weeksToIterate := buildWeekIterList(weekFilter, weekFrom, weekTo)
if len(weeksToIterate) == 0 {
return entries, reactToolResult{
Tool: "QueryAvailableSlots",
Success: false,
ErrorCode: "PARAM_MISSING",
Result: "周范围为空:请提供 week / week_filter 或确保排程窗口有效",
}
}
weekFrom = weeksToIterate[0]
weekTo = weeksToIterate[len(weeksToIterate)-1]
excludedSet := make(map[int]struct{})
for _, sec := range readIntSlice(params, "exclude_sections", "exclude_section") {
if sec >= 1 && sec <= 12 {
excludedSet[sec] = struct{}{}
}
}
afterSection, hasAfter := paramIntAny(params, "after_section")
beforeSection, hasBefore := paramIntAny(params, "before_section")
exactSectionFrom, hasExactFrom := paramIntAny(params, "section_from", "target_section_from")
exactSectionTo, hasExactTo := paramIntAny(params, "section_to", "target_section_to")
if hasExactFrom != hasExactTo {
return entries, reactToolResult{
Tool: "QueryAvailableSlots",
Success: false,
ErrorCode: "PARAM_MISSING",
Result: "精确节次查询需同时提供 section_from 和 section_to",
}
}
if hasExactFrom {
if exactSectionFrom < 1 || exactSectionTo > 12 || exactSectionFrom > exactSectionTo {
return entries, reactToolResult{
Tool: "QueryAvailableSlots",
Success: false,
ErrorCode: "SPAN_INVALID",
Result: fmt.Sprintf("精确节次区间非法:%d-%d", exactSectionFrom, exactSectionTo),
}
}
span = exactSectionTo - exactSectionFrom + 1
}
type slot struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
SlotType string `json:"slot_type,omitempty"`
}
slots := make([]slot, 0, limit)
seen := make(map[string]struct{}, limit*2)
strictCount := 0
collect := func(embedAllowed bool, slotType string) {
if len(slots) >= limit {
return
}
for _, week := range weeksToIterate {
for day := 1; day <= 7; day++ {
if len(dayFilter) > 0 {
if _, ok := dayFilter[day]; !ok {
continue
}
} else if !matchDayScope(day, scope) {
continue
}
if !isWithinWindow(window, week, day) {
continue
}
for sf := 1; sf+span-1 <= 12; sf++ {
st := sf + span - 1
if hasExactFrom && (sf != exactSectionFrom || st != exactSectionTo) {
continue
}
if hasAfter && sf <= afterSection {
continue
}
if hasBefore && st >= beforeSection {
continue
}
if intersectsExcludedSections(sf, st, excludedSet) {
continue
}
if conflict, _ := hasConflict(entries, week, day, sf, st, nil, embedAllowed); conflict {
continue
}
key := fmt.Sprintf("%d-%d-%d-%d", week, day, sf, st)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
slots = append(slots, slot{
Week: week,
DayOfWeek: day,
SectionFrom: sf,
SectionTo: st,
SlotType: slotType,
})
if len(slots) >= limit {
return
}
}
}
}
}
collect(false, "empty")
strictCount = len(slots)
if allowEmbed && len(slots) < limit {
collect(true, "embedded_candidate")
}
embeddedCount := len(slots) - strictCount
payload := map[string]any{
"tool": "QueryAvailableSlots",
"count": len(slots),
"strict_count": strictCount,
"embedded_count": embeddedCount,
"fallback_used": embeddedCount > 0,
"day_scope": scope,
"day_of_week": keysOfIntSet(dayFilter),
"week_filter": keysOfIntSet(weekFilter),
"week_from": weekFrom,
"week_to": weekTo,
"span": span,
"allow_embed": allowEmbed,
"exclude_sections": keysOfIntSet(excludedSet),
"slots": slots,
}
if hasAfter {
payload["after_section"] = afterSection
}
if hasBefore {
payload["before_section"] = beforeSection
}
if hasExactFrom {
payload["section_from"] = exactSectionFrom
payload["section_to"] = exactSectionTo
}
raw, err := json.Marshal(payload)
if err != nil {
return entries, reactToolResult{
Tool: "QueryAvailableSlots",
Success: false,
ErrorCode: "QUERY_ENCODE_FAILED",
Result: fmt.Sprintf("序列化空位结果失败:%v", err),
}
}
return entries, reactToolResult{
Tool: "QueryAvailableSlots",
Success: true,
Result: string(raw),
}
}
// refineToolVerify 进行“轻量确定性自检”。
//
// 说明:
// 1. 当前只做 deterministic 校验(冲突/顺序),不做语义 LLM 终审;
// 2. 语义层终审仍在 hard_check 节点统一处理;
// 3. 该工具用于给执行阶段一个“可提前自查”的信号。
func refineToolVerify(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
physicsIssues := physicsCheck(entries, 0)
orderIssues := validateRelativeOrder(entries, policy)
if len(physicsIssues) > 0 || len(orderIssues) > 0 {
payload := map[string]any{
"tool": "Verify",
"pass": false,
"physics_issues": physicsIssues,
"order_issues": orderIssues,
}
raw, err := json.Marshal(payload)
if err != nil {
return entries, reactToolResult{
Tool: "Verify",
Success: false,
ErrorCode: "VERIFY_FAILED",
Result: "Verify 校验失败且结果无法序列化",
}
}
return entries, reactToolResult{
Tool: "Verify",
Success: false,
ErrorCode: "VERIFY_FAILED",
Result: string(raw),
}
}
// 1. 若携带 task_item_id / 目标坐标参数,则执行“针对性核验”,避免“全局 pass”掩盖当前任务不匹配。
// 2. 该核验是可选增强:没传 task_id 时仍维持全局 deterministic 行为。
taskID, hasTaskID := paramIntAny(params, "task_item_id", "task_id")
if hasTaskID {
idx, locateErr := findUniqueSuggestedByID(entries, taskID)
if locateErr != nil {
return entries, reactToolResult{
Tool: "Verify",
Success: false,
ErrorCode: "VERIFY_FAILED",
Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"%s"}`, locateErr.Error()),
}
}
target := entries[idx]
verifyWeek, hasWeek := paramIntAny(params, "week", "to_week", "target_week")
verifyDay, hasDay := paramIntAny(params, "day_of_week", "to_day", "target_day_of_week")
verifyFrom, hasFrom := paramIntAny(params, "section_from", "to_section_from", "target_section_from")
verifyTo, hasTo := paramIntAny(params, "section_to", "to_section_to", "target_section_to")
mismatch := make([]string, 0, 4)
if hasWeek && target.Week != verifyWeek {
mismatch = append(mismatch, fmt.Sprintf("week=%d(实际=%d)", verifyWeek, target.Week))
}
if hasDay && target.DayOfWeek != verifyDay {
mismatch = append(mismatch, fmt.Sprintf("day_of_week=%d(实际=%d)", verifyDay, target.DayOfWeek))
}
if hasFrom && target.SectionFrom != verifyFrom {
mismatch = append(mismatch, fmt.Sprintf("section_from=%d(实际=%d)", verifyFrom, target.SectionFrom))
}
if hasTo && target.SectionTo != verifyTo {
mismatch = append(mismatch, fmt.Sprintf("section_to=%d(实际=%d)", verifyTo, target.SectionTo))
}
if len(mismatch) > 0 {
return entries, reactToolResult{
Tool: "Verify",
Success: false,
ErrorCode: "VERIFY_FAILED",
Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"任务坐标不匹配:%s"}`, strings.Join(mismatch, "")),
}
}
return entries, reactToolResult{
Tool: "Verify",
Success: true,
Result: `{"tool":"Verify","pass":true,"reason":"task-level deterministic checks passed"}`,
}
}
return entries, reactToolResult{
Tool: "Verify",
Success: true,
Result: `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`,
}
}
// validateRelativeOrder 校验 suggested 任务是否保持“初始相对顺序”。
//
// 步骤化说明:
// 1. 若策略未启用 keep_relative_order直接通过
// 2. 否则按时间位置排序 suggested 任务,并映射到 origin_rank
// 3. 检查 rank 是否单调不降;一旦逆序即判定失败;
// 4. 支持 week 作用域:仅要求每周内保持相对顺序。
func validateRelativeOrder(entries []model.HybridScheduleEntry, policy refineToolPolicy) []string {
if !policy.KeepRelativeOrder {
return nil
}
if len(policy.OriginOrderMap) == 0 {
return []string{"未提供顺序基线(origin_order_map)"}
}
suggested := make([]model.HybridScheduleEntry, 0, len(entries))
for _, entry := range entries {
// 1. 顺序校验与执行口径必须一致:
// 1.1 这里只校验“可移动 suggested 任务”,避免把 course 等不可移动条目误纳入顺序约束;
// 1.2 若把不可移动条目纳入,会出现“动作层不允许改、顺序层却报错”的左右脑互搏。
if isMovableSuggestedTask(entry) {
suggested = append(suggested, entry)
}
}
if len(suggested) <= 1 {
return nil
}
sort.SliceStable(suggested, func(i, j int) bool {
left := suggested[i]
right := suggested[j]
if left.Week != right.Week {
return left.Week < right.Week
}
if left.DayOfWeek != right.DayOfWeek {
return left.DayOfWeek < right.DayOfWeek
}
if left.SectionFrom != right.SectionFrom {
return left.SectionFrom < right.SectionFrom
}
if left.SectionTo != right.SectionTo {
return left.SectionTo < right.SectionTo
}
return left.TaskItemID < right.TaskItemID
})
scope := normalizeOrderScope(policy.OrderScope)
issues := make([]string, 0, 4)
if scope == "week" {
lastRankByWeek := make(map[int]int)
lastNameByWeek := make(map[int]string)
lastIDByWeek := make(map[int]int)
for _, entry := range suggested {
rank, ok := policy.OriginOrderMap[entry.TaskItemID]
if !ok {
issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID))
continue
}
last, exists := lastRankByWeek[entry.Week]
if exists && rank < last {
issues = append(issues, fmt.Sprintf(
"W%d 出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后",
entry.Week, entry.Name, entry.TaskItemID, rank, lastNameByWeek[entry.Week], lastIDByWeek[entry.Week], last,
))
}
lastRankByWeek[entry.Week] = rank
lastNameByWeek[entry.Week] = entry.Name
lastIDByWeek[entry.Week] = entry.TaskItemID
}
return issues
}
lastRank := -1
lastName := ""
lastID := 0
for _, entry := range suggested {
rank, ok := policy.OriginOrderMap[entry.TaskItemID]
if !ok {
issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID))
continue
}
if lastRank >= 0 && rank < lastRank {
issues = append(issues, fmt.Sprintf(
"出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后",
entry.Name, entry.TaskItemID, rank, lastName, lastID, lastRank,
))
}
lastRank = rank
lastName = entry.Name
lastID = entry.TaskItemID
}
return issues
}
// normalizeOrderScope 规范化顺序约束作用域。
func normalizeOrderScope(scope string) string {
switch strings.TrimSpace(strings.ToLower(scope)) {
case "week":
return "week"
default:
return "global"
}
}
// buildPlanningWindowFromEntries 根据现有条目推导允许移动窗口。
func buildPlanningWindowFromEntries(entries []model.HybridScheduleEntry) planningWindow {
if len(entries) == 0 {
return planningWindow{Enabled: false}
}
startWeek, startDay := entries[0].Week, entries[0].DayOfWeek
endWeek, endDay := entries[0].Week, entries[0].DayOfWeek
for _, entry := range entries {
if compareWeekDay(entry.Week, entry.DayOfWeek, startWeek, startDay) < 0 {
startWeek, startDay = entry.Week, entry.DayOfWeek
}
if compareWeekDay(entry.Week, entry.DayOfWeek, endWeek, endDay) > 0 {
endWeek, endDay = entry.Week, entry.DayOfWeek
}
}
return planningWindow{
Enabled: true,
StartWeek: startWeek,
StartDay: startDay,
EndWeek: endWeek,
EndDay: endDay,
}
}
// isWithinWindow 判断目标 week/day 是否落在窗口内。
func isWithinWindow(window planningWindow, week, day int) bool {
if !window.Enabled {
return true
}
if day < 1 || day > 7 {
return false
}
if compareWeekDay(week, day, window.StartWeek, window.StartDay) < 0 {
return false
}
if compareWeekDay(week, day, window.EndWeek, window.EndDay) > 0 {
return false
}
return true
}
// compareWeekDay 比较两个 week/day 坐标。
// 返回:
// 1) <0left 更早;
// 2) =0相同
// 3) >0left 更晚。
func compareWeekDay(leftWeek, leftDay, rightWeek, rightDay int) int {
if leftWeek != rightWeek {
return leftWeek - rightWeek
}
return leftDay - rightDay
}
// findSuggestedByID 在 entries 中查找指定 task_item_id 的 suggested 条目索引。
func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int {
for i, entry := range entries {
if isMovableSuggestedTask(entry) && entry.TaskItemID == taskItemID {
return i
}
}
return -1
}
// findUniqueSuggestedByID 查找可唯一定位的可移动 suggested 任务。
//
// 说明:
// 1. “可移动”定义由 isMovableSuggestedTask 统一控制;
// 2. 当 task_item_id 命中 0 条或 >1 条时都返回错误,避免把动作落到错误任务上。
func findUniqueSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) (int, error) {
first := -1
count := 0
for idx, entry := range entries {
if !isMovableSuggestedTask(entry) {
continue
}
if entry.TaskItemID != taskItemID {
continue
}
if first < 0 {
first = idx
}
count++
}
if count == 0 {
return -1, fmt.Errorf("未找到 task_item_id=%d 的可移动 suggested 任务", taskItemID)
}
if count > 1 {
return -1, fmt.Errorf("task_item_id=%d 命中 %d 条可移动 suggested 任务,无法唯一定位", taskItemID, count)
}
return first, nil
}
// isMovableSuggestedTask 判断条目是否属于“可被微调工具改写”的任务。
//
// 规则:
// 1. 必须是 suggested 且 task_item_id>0
// 2. type=course 明确禁止移动(即便被错误标记为 suggested
// 3. 其余类型(含空值)按任务处理,兼容历史快照。
func isMovableSuggestedTask(entry model.HybridScheduleEntry) bool {
if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 {
return false
}
if strings.EqualFold(strings.TrimSpace(entry.Type), "course") {
return false
}
return true
}
// hasConflict 检查目标时段是否与其他条目冲突。
//
// 判断规则:
// 1. 仅把“会阻塞 suggested 的条目”纳入冲突判断;
// 2. excludes 中的索引会被跳过(常用于 Move 自身排除或 Swap 双排除)。
func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, excludes map[int]bool, allowEmbed bool) (bool, string) {
for idx, entry := range entries {
if excludes != nil && excludes[idx] {
continue
}
if !entryBlocksSuggestedWithPolicy(entry, allowEmbed) {
continue
}
if entry.Week == week && entry.DayOfWeek == day && sectionsOverlap(entry.SectionFrom, entry.SectionTo, sf, st) {
return true, fmt.Sprintf("%s(%s)", entry.Name, entry.Type)
}
}
return false, ""
}
// entryBlocksSuggested 判断条目是否会阻塞 suggested 任务落位。
func entryBlocksSuggested(entry model.HybridScheduleEntry) bool {
return entryBlocksSuggestedWithPolicy(entry, true)
}
// entryBlocksSuggestedWithPolicy 判断条目是否阻塞 suggested 落位。
//
// 策略说明:
// 1. allowEmbed=true沿用 block_for_suggested 语义;
// 2. allowEmbed=falseexisting 一律阻塞,只允许纯空白课位;
// 3. unknown status 保守阻塞,防止漏检。
func entryBlocksSuggestedWithPolicy(entry model.HybridScheduleEntry, allowEmbed bool) bool {
if entry.Status == "suggested" {
return true
}
if entry.Status == "existing" {
if !allowEmbed {
return true
}
return entry.BlockForSuggested
}
// 未知状态保守处理为阻塞,避免写入潜在冲突。
return true
}
// sectionsOverlap 判断两个节次区间是否有交叠。
func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool {
return aFrom <= bTo && bFrom <= aTo
}
// paramInt 从 map 中提取 int 参数,兼容 JSON 常见数值类型。
func paramInt(params map[string]any, key string) (int, bool) {
raw, ok := params[key]
if !ok {
return 0, false
}
switch v := raw.(type) {
case int:
return v, true
case float64:
return int(v), true
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, false
}
return n, true
default:
return 0, false
}
}
// paramIntAny 按“候选键优先级”提取 int 参数。
//
// 步骤化说明:
// 1. 按传入顺序依次尝试每个 key
// 2. 命中第一个合法值即返回;
// 3. 全部未命中则返回 false由上层统一抛参数缺失错误。
func paramIntAny(params map[string]any, keys ...string) (int, bool) {
for _, key := range keys {
if v, ok := paramInt(params, key); ok {
return v, true
}
}
return 0, false
}
// paramBool 从 map 中提取 bool 参数,兼容 JSON 常见布尔表示。
func paramBool(params map[string]any, key string) (bool, bool) {
raw, ok := params[key]
if !ok {
return false, false
}
switch v := raw.(type) {
case bool:
return v, true
case string:
text := strings.TrimSpace(strings.ToLower(v))
switch text {
case "true", "1", "yes", "y":
return true, true
case "false", "0", "no", "n":
return false, true
default:
return false, false
}
case int:
if v == 1 {
return true, true
}
if v == 0 {
return false, true
}
return false, false
case float64:
if v == 1 {
return true, true
}
if v == 0 {
return false, true
}
return false, false
default:
return false, false
}
}
// paramBoolAnyWithDefault 按候选键提取 bool未命中时返回 fallback。
func paramBoolAnyWithDefault(params map[string]any, fallback bool, keys ...string) bool {
for _, key := range keys {
if v, ok := paramBool(params, key); ok {
return v
}
}
return fallback
}
// readString 读取字符串参数,缺失时返回默认值。
func readString(params map[string]any, key string, fallback string) string {
raw, ok := params[key]
if !ok {
return fallback
}
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" {
return fallback
}
return text
}
// normalizeDayScope 规范化 day_scope 取值。
func normalizeDayScope(scope string) string {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "weekend":
return "weekend"
case "workday":
return "workday"
default:
return "all"
}
}
// normalizeStatusFilter 规范化 status 过滤条件。
func normalizeStatusFilter(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "existing":
return "existing"
case "all":
return "all"
default:
return "suggested"
}
}
// matchStatusFilter 判断条目状态是否命中 status 过滤。
func matchStatusFilter(entryStatus string, statusFilter string) bool {
switch strings.ToLower(strings.TrimSpace(statusFilter)) {
case "all":
return true
case "existing":
return strings.TrimSpace(entryStatus) == "existing"
default:
return strings.TrimSpace(entryStatus) == "suggested"
}
}
// matchDayScope 判断 day_of_week 是否满足 scope 过滤条件。
func matchDayScope(day int, scope string) bool {
switch scope {
case "weekend":
return day == 6 || day == 7
case "workday":
return day >= 1 && day <= 5
default:
return day >= 1 && day <= 7
}
}
// intSliceToDaySet 把 day 切片转换为 set并去除非法 day 值。
func intSliceToDaySet(items []int) map[int]struct{} {
if len(items) == 0 {
return nil
}
set := make(map[int]struct{}, len(items))
for _, item := range items {
if item < 1 || item > 7 {
continue
}
set[item] = struct{}{}
}
if len(set) == 0 {
return nil
}
return set
}
// intSliceToWeekSet 把周次切片转换为 set并去除非正数。
func intSliceToWeekSet(items []int) map[int]struct{} {
if len(items) == 0 {
return nil
}
set := make(map[int]struct{}, len(items))
for _, item := range items {
if item <= 0 {
continue
}
set[item] = struct{}{}
}
if len(set) == 0 {
return nil
}
return set
}
// intSliceToSectionSet 把节次切片转换为 set并去除非法节次。
func intSliceToSectionSet(items []int) map[int]struct{} {
if len(items) == 0 {
return nil
}
set := make(map[int]struct{}, len(items))
for _, item := range items {
if item < 1 || item > 12 {
continue
}
set[item] = struct{}{}
}
if len(set) == 0 {
return nil
}
return set
}
// intSliceToIDSet 把正整数 ID 切片转换为 set。
func intSliceToIDSet(items []int) map[int]struct{} {
if len(items) == 0 {
return nil
}
set := make(map[int]struct{}, len(items))
for _, item := range items {
if item <= 0 {
continue
}
set[item] = struct{}{}
}
if len(set) == 0 {
return nil
}
return set
}
// inferWeekBounds 推断查询周区间。
func inferWeekBounds(entries []model.HybridScheduleEntry, window planningWindow) (int, int) {
if window.Enabled {
return window.StartWeek, window.EndWeek
}
if len(entries) == 0 {
return 1, 1
}
minWeek, maxWeek := entries[0].Week, entries[0].Week
for _, entry := range entries {
if entry.Week < minWeek {
minWeek = entry.Week
}
if entry.Week > maxWeek {
maxWeek = entry.Week
}
}
return minWeek, maxWeek
}
// buildWeekIterList 构建周次迭代列表。
//
// 规则:
// 1. weekFilter 非空时,严格按过滤集合遍历;
// 2. weekFilter 为空时,按 weekFrom~weekTo 连续区间遍历;
// 3. 返回结果升序,便于日志与排查。
func buildWeekIterList(weekFilter map[int]struct{}, weekFrom, weekTo int) []int {
if len(weekFilter) > 0 {
return keysOfIntSet(weekFilter)
}
if weekFrom <= 0 || weekTo <= 0 || weekFrom > weekTo {
return nil
}
out := make([]int, 0, weekTo-weekFrom+1)
for w := weekFrom; w <= weekTo; w++ {
out = append(out, w)
}
return out
}
// readIntSlice 读取 int 切片参数,兼容 []any / []int / 单个数值。
func readIntSlice(params map[string]any, keys ...string) []int {
for _, key := range keys {
raw, ok := params[key]
if !ok {
continue
}
switch v := raw.(type) {
case []int:
out := make([]int, len(v))
copy(out, v)
return out
case []any:
out := make([]int, 0, len(v))
for _, item := range v {
switch n := item.(type) {
case int:
out = append(out, n)
case float64:
out = append(out, int(n))
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
out = append(out, parsed)
}
}
}
return out
default:
if n, okNum := paramInt(params, key); okNum {
return []int{n}
}
}
}
return nil
}
// readStringSlice 读取 string 切片参数,兼容 []any / []string / 单个字符串。
func readStringSlice(params map[string]any, keys ...string) []string {
for _, key := range keys {
raw, ok := params[key]
if !ok || raw == nil {
continue
}
switch vv := raw.(type) {
case []string:
out := make([]string, 0, len(vv))
for _, item := range vv {
text := strings.TrimSpace(item)
if text != "" {
out = append(out, text)
}
}
return out
case []any:
out := make([]string, 0, len(vv))
for _, item := range vv {
text := strings.TrimSpace(fmt.Sprintf("%v", item))
if text != "" {
out = append(out, text)
}
}
return out
case string:
text := strings.TrimSpace(vv)
if text != "" {
return []string{text}
}
default:
text := strings.TrimSpace(fmt.Sprintf("%v", vv))
if text != "" {
return []string{text}
}
}
}
return nil
}
// intersectsExcludedSections 判断候选区间是否与排除节次有交集。
func intersectsExcludedSections(from, to int, excluded map[int]struct{}) bool {
if len(excluded) == 0 {
return false
}
for sec := from; sec <= to; sec++ {
if _, ok := excluded[sec]; ok {
return true
}
}
return false
}
// keysOfIntSet 返回 int set 的有序键。
func keysOfIntSet(set map[int]struct{}) []int {
if len(set) == 0 {
return nil
}
keys := make([]int, 0, len(set))
for k := range set {
keys = append(keys, k)
}
sort.Ints(keys)
return keys
}
// parseBatchMoveParams 解析 BatchMove 的 moves 参数。
//
// 步骤化说明:
// 1. 先读取 params["moves"],必须存在且为非空数组;
// 2. 再把数组元素逐条转换成 map[string]any便于复用 refineToolMove
// 3. 任一元素类型非法即整体失败,避免“部分可执行、部分不可执行”带来的语义歧义。
func parseBatchMoveParams(params map[string]any) ([]map[string]any, error) {
rawMoves, ok := params["moves"]
if !ok {
return nil, fmt.Errorf("参数缺失BatchMove 需要 moves 数组")
}
var items []any
switch v := rawMoves.(type) {
case []any:
items = v
case []map[string]any:
items = make([]any, 0, len(v))
for _, item := range v {
items = append(items, item)
}
default:
return nil, fmt.Errorf("参数类型错误BatchMove 的 moves 必须是数组")
}
if len(items) == 0 {
return nil, fmt.Errorf("参数错误BatchMove 的 moves 不能为空")
}
moveParamsList := make([]map[string]any, 0, len(items))
for idx, item := range items {
paramMap, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("参数类型错误BatchMove 第%d步不是对象", idx+1)
}
moveParamsList = append(moveParamsList, paramMap)
}
return moveParamsList, nil
}
// classifyBatchMoveErrorCode 把单步 Move 失败原因映射为 BatchMove 层错误码。
//
// 说明:
// 1. 映射保持与普通 Move 的错误语义一致,便于模型统一处理;
// 2. 这里按失败文案做轻量推断,避免引入跨文件循环依赖。
func classifyBatchMoveErrorCode(detail string) string {
text := strings.TrimSpace(detail)
switch {
case strings.Contains(text, "顺序约束不满足"):
return "ORDER_VIOLATION"
case strings.Contains(text, "参数缺失"):
return "PARAM_MISSING"
case strings.Contains(text, "目标时段已被"):
return "SLOT_CONFLICT"
case strings.Contains(text, "任务跨度不一致"):
return "SPAN_MISMATCH"
case strings.Contains(text, "超出允许窗口"):
return "OUT_OF_WINDOW"
case strings.Contains(text, "day_of_week"):
return "DAY_INVALID"
case strings.Contains(text, "节次区间"):
return "SECTION_INVALID"
case strings.Contains(text, "未找到 task_item_id"):
return "TASK_NOT_FOUND"
default:
return "BATCH_MOVE_FAILED"
}
}
// sortHybridEntries 对混合条目做稳定排序,保证日志与预览输出稳定。
func sortHybridEntries(entries []model.HybridScheduleEntry) {
sort.SliceStable(entries, func(i, j int) bool {
left := entries[i]
right := entries[j]
if left.Week != right.Week {
return left.Week < right.Week
}
if left.DayOfWeek != right.DayOfWeek {
return left.DayOfWeek < right.DayOfWeek
}
if left.SectionFrom != right.SectionFrom {
return left.SectionFrom < right.SectionFrom
}
if left.SectionTo != right.SectionTo {
return left.SectionTo < right.SectionTo
}
return left.Name < right.Name
})
}
// truncate 截断日志内容,避免错误信息无上限增长。
func truncate(text string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= maxLen {
return text
}
return string(runes[:maxLen]) + "..."
}