Files
smartmate/backend/agent/schedulerefine/tool.go
Losita 525a8b32cb Version: 0.7.3.dev.260322
♻️ refactor(schedule-refine): [WIP] 重构 Plan-and-Execute ReAct 链路,并增强 JSON 解析兜底能力

- 🧩 重构 `schedulerefine` 主流程,引入 `Planner` / `Replan` 机制,以及执行预算与轮次状态管理
- 🧠 扩展状态与观察上下文,补充工具结果、失败签名、连续失败计数与后置反思策略等信息
- 🔧 增强工具层能力与参数兼容性,补齐 `Query` / `Move` / `Swap` / `BatchMove` / `Verify` 等行为及约束校验
- 🛡️ 提升解析鲁棒性,支持从代码块或混杂文本中提取首个 JSON 对象,并增加单次解析重试机制
- 👀 增强可观测性,补充 `debug raw` 阶段输出与分片透传能力
- ✍️ 优化提示词近端约束,将严格 JSON 输出协议追加到各节点 `userPrompt` 末尾

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

1188 lines
37 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/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. missing_info模型明确缺失的信息前端可直接展示
// 4. reflect本轮动作前的预期说明不是执行后事实
// 5. 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"`
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 "Verify":
return refineToolVerify(entries, 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)),
}
}
}
// 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 {
if out.MissingInfo == nil {
out.MissingInfo = make([]string, 0)
}
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
}
if out.MissingInfo == nil {
out.MissingInfo = make([]string, 0)
}
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", "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")
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)}
}
idx := findSuggestedByID(entries, taskID)
if idx < 0 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("未找到 task_item_id=%d 的 suggested 任务", taskID)}
}
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}); 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) 从 %s 移动到 %s", entry.Name, taskID, 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"}
}
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 条目"}
}
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); 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 {
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(),
}
}
// 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, " | ")),
}
}
// 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"))
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 := intSliceToSet(readIntSlice(params, "day_of_week", "days"))
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
}
if entry.TaskItemID <= 0 {
continue
}
if len(dayFilter) > 0 {
if _, ok := dayFilter[entry.DayOfWeek]; !ok {
continue
}
} else if !matchDayScope(entry.DayOfWeek, scope) {
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_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. 使用现有冲突判定entryBlocksSuggested + sectionsOverlap确保结果可放置
// 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")
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
}
}
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")
type slot struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
}
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
}
}
if len(slots) >= limit {
break
}
}
payload := map[string]any{
"tool": "QueryAvailableSlots",
"count": len(slots),
"day_scope": scope,
"day_of_week": keysOfIntSet(dayFilter),
"week_from": weekFrom,
"week_to": weekTo,
"span": span,
"exclude_sections": keysOfIntSet(excludedSet),
"slots": slots,
}
if hasAfter {
payload["after_section"] = afterSection
}
if hasBefore {
payload["before_section"] = beforeSection
}
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, 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"}`,
}
}
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),
}
}
// 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 {
if entry.Status == "suggested" && entry.TaskItemID > 0 {
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 entry.Status == "suggested" && entry.TaskItemID == taskItemID {
return i
}
}
return -1
}
// hasConflict 检查目标时段是否与其他条目冲突。
//
// 判断规则:
// 1. 仅把“会阻塞 suggested 的条目”纳入冲突判断;
// 2. excludes 中的索引会被跳过(常用于 Move 自身排除或 Swap 双排除)。
func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, excludes map[int]bool) (bool, string) {
for idx, entry := range entries {
if excludes != nil && excludes[idx] {
continue
}
if !entryBlocksSuggested(entry) {
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 {
if entry.Status == "suggested" {
return true
}
if entry.Status == "existing" {
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
}
// 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
}
}
// intSliceToSet 把 int 切片转换为 set并自动去除非法 day 值。
func intSliceToSet(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
}
// 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
}
// 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
}
// 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]) + "..."
}