Version: 0.9.75.dev.260505

后端:
1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent
- 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge
- 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段
- 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流
- 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
Losita
2026-05-05 16:00:57 +08:00
parent e1819c5653
commit d7184b776b
174 changed files with 2189 additions and 1236 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
package schedule
import "strings"
// buildAnalyzeHealthDecisionV2 生成 analyze_health 在主动优化场景下的最终裁决。
//
// 职责边界:
// 1. 先尊重 base 层的判断:只有 base 明确允许继续优化时,才进入候选枚举。
// 2. 候选只来自后端已经验证合法、并且复诊后确实变好的 move/swap 方案。
// 3. 若没有真正改善的候选,则明确返回 close避免把 LLM 推回开放式全窗搜索。
func buildAnalyzeHealthDecisionV2(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) analyzeHealthDecision {
base := buildAnalyzeHealthDecisionBase(state, snapshot)
decision := analyzeHealthDecision{
ShouldContinueOptimize: base.ShouldContinueOptimize,
PrimaryProblem: base.PrimaryProblem,
ProblemScope: base.ProblemScope,
IsForcedImperfection: base.IsForcedImperfection,
RecommendedOperation: base.RecommendedOperation,
ImprovementSignal: buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
base.ProblemScope,
base.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
),
}
if !shouldEnterHealthCandidateLoop(base) {
decision.Candidates = []analyzeHealthCandidate{
buildHealthCloseCandidate("保持当前安排并收口:当前不需要再进入主动优化候选。", snapshot, base),
}
decision.ShouldContinueOptimize = false
return decision
}
bestScan, ok := findBestHealthProblemScanResult(state, snapshot)
if !ok || bestScan.Problem.Kind != healthProblemHeavyAdjacent || bestScan.Problem.Pair == nil {
decision.Candidates = []analyzeHealthCandidate{
buildHealthCloseCandidate("保持当前安排并收口:当前没有值得继续处理的局部认知问题。", snapshot, base),
}
decision.ShouldContinueOptimize = false
decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题"
decision.ProblemScope = nil
decision.RecommendedOperation = "close"
if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
decision.IsForcedImperfection = true
}
decision.ImprovementSignal = buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
decision.ProblemScope,
decision.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
)
return decision
}
decision.PrimaryProblem = bestScan.Problem.Summary
decision.ProblemScope = bestScan.Problem.Scope
decision.Candidates = append(decision.Candidates, bestScan.Candidates...)
decision.Candidates = append(decision.Candidates,
buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base),
)
decision.ShouldContinueOptimize = true
decision.RecommendedOperation = strings.TrimSpace(bestScan.Candidates[0].Tool)
decision.ImprovementSignal = buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
decision.ProblemScope,
decision.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
)
return decision
}
// findBestHealthProblemScanResult 每轮重扫所有 heavy_adjacent 天,并选出当前收益最高的一天。
//
// 步骤化说明:
// 1. 先收集所有仍需关注的 heavy_adjacent 天;这里只扫描问题天,不改候选类型。
// 2. 再对每一天复用现有单天候选试算逻辑,保持“合法且复诊后确实变好”这一过滤语义不变。
// 3. 最后只返回收益最高且达到最小阈值的一天;最终 decision.candidates 仍只来自这一天天然候选集。
func findBestHealthProblemScanResult(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) (analyzeHealthProblemScanResult, bool) {
problems := collectRepairableHeavyAdjacentProblems(state, snapshot)
if len(problems) == 0 {
return analyzeHealthProblemScanResult{}, false
}
results := make([]analyzeHealthProblemScanResult, 0, len(problems))
for _, problem := range problems {
scan, ok := buildHealthProblemScanResult(state, snapshot, problem)
if !ok {
continue
}
results = append(results, scan)
}
return selectBestHealthProblemScanResult(results)
}
// shouldEnterHealthCandidateLoop 判断本轮是否应进入“候选式主动优化”。
//
// 说明:
// 1. 只有 base 已判定“值得继续优化”时才放行。
// 2. 当前主动优化闭环只接受 move / swap 两类操作,其它动作不进入候选生成。
// 3. 这样可以挡住 “ask_user / close / forced imperfection” 被后续枚举误覆盖的问题。
func shouldEnterHealthCandidateLoop(base analyzeHealthDecisionBase) bool {
if !base.ShouldContinueOptimize {
return false
}
switch strings.TrimSpace(base.RecommendedOperation) {
case "move", "swap":
return true
default:
return false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
package schedule
import (
"fmt"
"sort"
"strings"
)
// validateToolArgsStrict 校验工具参数是否全部命中 schema 白名单。
//
// 职责边界:
// 1. 只做“字段名是否允许”的校验,不校验字段值合法性;
// 2. 发现未知字段时直接报错,避免静默忽略导致范围漂移;
// 3. 该函数不做别名兼容,调用方应自行传入 schema 中允许的字段。
func validateToolArgsStrict(args map[string]any, allowedKeys []string) error {
if len(args) == 0 {
return nil
}
allowed := make(map[string]struct{}, len(allowedKeys))
for _, key := range allowedKeys {
allowed[strings.TrimSpace(key)] = struct{}{}
}
unknown := make([]string, 0, len(args))
for key := range args {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
if _, ok := allowed[trimmed]; ok {
continue
}
unknown = append(unknown, trimmed)
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
hint := "请仅使用当前工具 schema 中声明的参数字段。"
if containsAnyUnknownArg(unknown, "day_from", "day_to") {
hint = "请仅使用当前工具 schema 中声明的参数字段day_from/day_to 不受支持,请改用 day_start/day_end。"
}
return fmt.Errorf("参数非法:%s。%s", strings.Join(unknown, "、"), hint)
}
func containsAnyUnknownArg(keys []string, targets ...string) bool {
if len(keys) == 0 || len(targets) == 0 {
return false
}
targetSet := make(map[string]struct{}, len(targets))
for _, target := range targets {
targetSet[strings.TrimSpace(target)] = struct{}{}
}
for _, key := range keys {
if _, ok := targetSet[strings.TrimSpace(key)]; ok {
return true
}
}
return false
}

View File

@@ -0,0 +1,125 @@
package schedule
import "fmt"
// ==================== 参数解析辅助 ====================
// 这些函数专门用于从 LLM 输出的 map[string]any 中提取工具参数。
// JSON 反序列化后数字默认为 float64字符串为 string需要类型断言。
// argsInt 从 map 中提取 int 值。支持 float64JSON 反序列化的默认类型)。
func ArgsInt(args map[string]any, key string) (int, bool) {
v, ok := args[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
}
return 0, false
}
// argsString 从 map 中提取 string 值。
func ArgsString(args map[string]any, key string) (string, bool) {
v, ok := args[key]
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}
// argsIntPtr 从 map 中提取可选 int 值,不存在返回 nil。
func ArgsIntPtr(args map[string]any, key string) *int {
v, ok := ArgsInt(args, key)
if !ok {
return nil
}
return &v
}
// argsStringPtr 从 map 中提取可选 string 值,不存在返回 nil。
func ArgsStringPtr(args map[string]any, key string) *string {
v, ok := ArgsString(args, key)
if !ok {
return nil
}
return &v
}
// argsIntSlice 从 map 中提取 int 数组,支持 []any / []int / []float64。
func ArgsIntSlice(args map[string]any, key string) ([]int, bool) {
v, ok := args[key]
if !ok {
return nil, false
}
switch arr := v.(type) {
case []int:
if len(arr) == 0 {
return []int{}, true
}
result := make([]int, len(arr))
copy(result, arr)
return result, true
case []float64:
result := make([]int, 0, len(arr))
for _, item := range arr {
result = append(result, int(item))
}
return result, true
case []any:
result := make([]int, 0, len(arr))
for _, item := range arr {
switch n := item.(type) {
case float64:
result = append(result, int(n))
case int:
result = append(result, n)
default:
return nil, false
}
}
return result, true
default:
return nil, false
}
}
// argsMoveList 从 map 中提取 batch_move 的 moves 数组。
func ArgsMoveList(args map[string]any) ([]MoveRequest, error) {
v, ok := args["moves"]
if !ok {
return nil, fmt.Errorf("缺少 moves 参数")
}
arr, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("moves 参数必须是数组")
}
moves := make([]MoveRequest, 0, len(arr))
for i, item := range arr {
m, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("moves[%d] 不是有效对象", i)
}
taskID, ok := ArgsInt(m, "task_id")
if !ok {
return nil, fmt.Errorf("moves[%d].task_id 缺失或无效", i)
}
newDay, ok := ArgsInt(m, "new_day")
if !ok {
return nil, fmt.Errorf("moves[%d].new_day 缺失或无效", i)
}
newSlotStart, ok := ArgsInt(m, "new_slot_start")
if !ok {
return nil, fmt.Errorf("moves[%d].new_slot_start 缺失或无效", i)
}
moves = append(moves, MoveRequest{
TaskID: taskID,
NewDay: newDay,
NewSlotStart: newSlotStart,
})
}
return moves, nil
}

View File

@@ -0,0 +1,184 @@
package schedule
import "fmt"
// validateLocalOrderForSinglePlacement 校验单个任务落到目标时段后,是否仍满足同任务类内部顺序约束。
//
// 职责边界:
// 1. 只负责“同任务类内部顺序”这一条规则,不负责冲突、锁定、范围合法性;
// 2. 采用“克隆态 + 假设落位”方式校验,避免直接污染真实 state
// 3. 若任务不属于 task_item / 缺少 task_order / 当前无边界约束,直接放行。
func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targetSlots []TaskSlot) error {
if len(targetSlots) == 0 {
return nil
}
return validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{
taskID: cloneScheduleTaskSlots(targetSlots),
})
}
// validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。
//
// 职责边界:
// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免批量局部调整时出现伪冲突;
// 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序;
// 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。
func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error {
if state == nil || len(proposals) == 0 {
return nil
}
clone := state.Clone()
for taskID, slots := range proposals {
task := clone.TaskByStateID(taskID)
if task == nil {
return fmt.Errorf("顺序约束校验失败任务ID %d 不存在", taskID)
}
task.Slots = cloneScheduleTaskSlots(slots)
}
for taskID := range proposals {
if err := validateTaskLocalOrderOnState(clone, taskID); err != nil {
return err
}
}
return nil
}
// validateTaskLocalOrderOnState 判断某个任务在当前假设态下,是否仍处于同任务类前驱/后继之间。
func validateTaskLocalOrderOnState(state *ScheduleState, taskID int) error {
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Errorf("顺序约束校验失败任务ID %d 不存在", taskID)
}
if !shouldEnforceTaskLocalOrder(*task) || len(task.Slots) == 0 {
return nil
}
prevTask, nextTask := findTaskClassNeighbors(state, *task)
targetStartDay, targetStartSlot, _ := earliestScheduleTaskSlot(task.Slots)
targetEndDay, _, targetEndSlot := latestScheduleTaskSlot(task.Slots)
if prevTask != nil && len(prevTask.Slots) > 0 {
prevEndDay, _, prevEndSlot := latestScheduleTaskSlot(prevTask.Slots)
if !isStrictlyAfter(targetStartDay, targetStartSlot, prevEndDay, prevEndSlot) {
return fmt.Errorf(
"顺序约束不满足:[%d]%s 不能放到%s。它必须晚于同任务类前一个任务 %s 的结束位置(%s。",
task.StateID,
task.Name,
formatTaskSlotsBriefWithState(state, task.Slots),
formatTaskLabel(*prevTask),
formatTaskSlotsBriefWithState(state, prevTask.Slots),
)
}
}
if nextTask != nil && len(nextTask.Slots) > 0 {
nextStartDay, nextStartSlot, _ := earliestScheduleTaskSlot(nextTask.Slots)
if !isStrictlyBefore(targetEndDay, targetEndSlot, nextStartDay, nextStartSlot) {
return fmt.Errorf(
"顺序约束不满足:[%d]%s 不能放到%s。它必须早于同任务类后一个任务 %s 的开始位置(%s。",
task.StateID,
task.Name,
formatTaskSlotsBriefWithState(state, task.Slots),
formatTaskLabel(*nextTask),
formatTaskSlotsBriefWithState(state, nextTask.Slots),
)
}
}
return nil
}
// shouldEnforceTaskLocalOrder 判断任务是否需要参与“同任务类内部顺序”约束。
func shouldEnforceTaskLocalOrder(task ScheduleTask) bool {
return task.Source == "task_item" && task.TaskClassID > 0 && task.TaskOrder > 0
}
// findTaskClassNeighbors 查找同任务类中 order 紧邻当前任务的前驱与后继。
func findTaskClassNeighbors(state *ScheduleState, task ScheduleTask) (prevTask *ScheduleTask, nextTask *ScheduleTask) {
if state == nil || !shouldEnforceTaskLocalOrder(task) {
return nil, nil
}
for i := range state.Tasks {
candidate := &state.Tasks[i]
if candidate.StateID == task.StateID {
continue
}
if !shouldEnforceTaskLocalOrder(*candidate) {
continue
}
if candidate.TaskClassID != task.TaskClassID {
continue
}
if candidate.TaskOrder < task.TaskOrder {
if prevTask == nil || candidate.TaskOrder > prevTask.TaskOrder {
prevTask = candidate
}
continue
}
if candidate.TaskOrder > task.TaskOrder {
if nextTask == nil || candidate.TaskOrder < nextTask.TaskOrder {
nextTask = candidate
}
}
}
return prevTask, nextTask
}
func earliestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) {
if len(slots) == 0 {
return 0, 0, 0
}
best := slots[0]
for i := 1; i < len(slots); i++ {
current := slots[i]
if current.Day < best.Day ||
(current.Day == best.Day && current.SlotStart < best.SlotStart) ||
(current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd) {
best = current
}
}
return best.Day, best.SlotStart, best.SlotEnd
}
func latestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) {
if len(slots) == 0 {
return 0, 0, 0
}
best := slots[0]
for i := 1; i < len(slots); i++ {
current := slots[i]
if current.Day > best.Day ||
(current.Day == best.Day && current.SlotEnd > best.SlotEnd) ||
(current.Day == best.Day && current.SlotEnd == best.SlotEnd && current.SlotStart > best.SlotStart) {
best = current
}
}
return best.Day, best.SlotStart, best.SlotEnd
}
func isStrictlyAfter(dayA, slotA, dayB, slotB int) bool {
if dayA != dayB {
return dayA > dayB
}
return slotA > slotB
}
func isStrictlyBefore(dayA, slotA, dayB, slotB int) bool {
if dayA != dayB {
return dayA < dayB
}
return slotA < slotB
}
func cloneScheduleTaskSlots(src []TaskSlot) []TaskSlot {
if len(src) == 0 {
return nil
}
dst := make([]TaskSlot, len(src))
copy(dst, src)
return dst
}

View File

@@ -0,0 +1,272 @@
package schedule
import (
"encoding/json"
"fmt"
"strings"
)
type queueTaskSlot struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
}
type queueTaskItem struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Status string `json:"status"`
Duration int `json:"duration,omitempty"`
TaskClassID int `json:"task_class_id,omitempty"`
Slots []queueTaskSlot `json:"slots,omitempty"`
}
type queuePopHeadResult struct {
Tool string `json:"tool"`
HasHead bool `json:"has_head"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
Current *queueTaskItem `json:"current,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type queueApplyHeadMoveResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
TaskID int `json:"task_id,omitempty"`
CurrentAttempt int `json:"current_attempt,omitempty"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
Result string `json:"result"`
}
type queueSkipHeadResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
SkippedTaskID int `json:"skipped_task_id,omitempty"`
PendingCount int `json:"pending_count"`
SkippedCount int `json:"skipped_count"`
Reason string `json:"reason,omitempty"`
}
type queueStatusResult struct {
Tool string `json:"tool"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id,omitempty"`
CurrentAttempt int `json:"current_attempt,omitempty"`
LastError string `json:"last_error,omitempty"`
NextTaskIDs []int `json:"next_task_ids,omitempty"`
Current *queueTaskItem `json:"current,omitempty"`
}
// QueuePopHead 从队列弹出队首任务(若已有 current 则复用),并返回当前处理对象。
//
// 步骤化说明:
// 1. 先保证队列容器存在,避免空指针;
// 2. 若 current 已存在,直接复用,确保 apply/skip 前不会切换处理对象;
// 3. 若 current 为空则从 pending 弹出队首;
// 4. 若没有可处理任务,返回 has_head=false由 LLM 收口或重筛选。
func QueuePopHead(state *ScheduleState, _ map[string]any) string {
if state == nil {
return `{"tool":"queue_pop_head","has_head":false,"error":"state is nil"}`
}
queue := ensureTaskProcessingQueue(state)
taskID := popOrGetCurrentTaskID(state)
result := queuePopHeadResult{
Tool: "queue_pop_head",
HasHead: taskID > 0,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
LastError: strings.TrimSpace(queue.LastError),
}
if taskID > 0 {
result.Current = buildQueueTaskItem(state, taskID)
}
return mustJSON(result, "queue_pop_head")
}
// QueueApplyHeadMove 将当前队首任务移动到指定位置,成功后自动完成并出队。
//
// 步骤化说明:
// 1. 只能处理 current 任务,禁止越级指定 task_id避免 LLM 绕过队列直接乱改;
// 2. 成功时标记 completed 并清空 current
// 3. 失败时保留 current 并累加 attempt让 LLM 继续换坑位重试或 skip。
func QueueApplyHeadMove(state *ScheduleState, args map[string]any) string {
if state == nil {
return `{"tool":"queue_apply_head_move","success":false,"result":"state is nil"}`
}
queue := ensureTaskProcessingQueue(state)
currentID := queue.CurrentTaskID
if currentID <= 0 {
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
Success: false,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Result: "队列中没有正在处理的任务。请先调用 queue_pop_head。",
}, "queue_apply_head_move")
}
newDay, ok := ArgsInt(args, "new_day")
if !ok {
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
Success: false,
TaskID: currentID,
CurrentAttempt: queue.CurrentAttempts,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Result: "缺少必填参数 new_day。",
}, "queue_apply_head_move")
}
newSlotStart, ok := ArgsInt(args, "new_slot_start")
if !ok {
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
Success: false,
TaskID: currentID,
CurrentAttempt: queue.CurrentAttempts,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Result: "缺少必填参数 new_slot_start。",
}, "queue_apply_head_move")
}
// 1. 真正执行仍复用既有 move 校验链路,避免重复实现一套冲突判断。
// 2. 失败时仅更新队列 attempt不改 current确保同一任务可继续重试。
resultText := Move(state, currentID, newDay, newSlotStart)
success := !strings.Contains(resultText, "移动失败")
if success {
markCurrentTaskCompleted(state)
} else {
bumpCurrentTaskAttempt(state, resultText)
}
queue = ensureTaskProcessingQueue(state)
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
Success: success,
TaskID: currentID,
CurrentAttempt: queue.CurrentAttempts,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Result: strings.TrimSpace(resultText),
}, "queue_apply_head_move")
}
// QueueSkipHead 跳过当前队首任务。
//
// 职责边界:
// 1. 只修改队列运行态,不改排程结果;
// 2. current 必须存在,否则返回失败提示;
// 3. 跳过后由下一轮 queue_pop_head 继续取下一项。
func QueueSkipHead(state *ScheduleState, args map[string]any) string {
if state == nil {
return `{"tool":"queue_skip_head","success":false,"reason":"state is nil"}`
}
queue := ensureTaskProcessingQueue(state)
currentID := queue.CurrentTaskID
if currentID <= 0 {
return mustJSON(queueSkipHeadResult{
Tool: "queue_skip_head",
Success: false,
PendingCount: len(queue.PendingTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Reason: "没有可跳过的 current 任务,请先 queue_pop_head。",
}, "queue_skip_head")
}
reason := ""
if raw, ok := ArgsString(args, "reason"); ok {
reason = strings.TrimSpace(raw)
}
markCurrentTaskSkipped(state)
queue = ensureTaskProcessingQueue(state)
return mustJSON(queueSkipHeadResult{
Tool: "queue_skip_head",
Success: true,
SkippedTaskID: currentID,
PendingCount: len(queue.PendingTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Reason: reason,
}, "queue_skip_head")
}
// QueueStatus 查询当前队列状态。
func QueueStatus(state *ScheduleState, _ map[string]any) string {
if state == nil {
return `{"tool":"queue_status","pending_count":0,"completed_count":0,"skipped_count":0,"last_error":"state is nil"}`
}
queue := ensureTaskProcessingQueue(state)
nextIDs := queue.PendingTaskIDs
if len(nextIDs) > 5 {
nextIDs = nextIDs[:5]
}
result := queueStatusResult{
Tool: "queue_status",
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
CurrentTaskID: queue.CurrentTaskID,
CurrentAttempt: queue.CurrentAttempts,
LastError: strings.TrimSpace(queue.LastError),
NextTaskIDs: append([]int(nil), nextIDs...),
}
if queue.CurrentTaskID > 0 {
result.Current = buildQueueTaskItem(state, queue.CurrentTaskID)
}
return mustJSON(result, "queue_status")
}
// buildQueueTaskItem 构造队列任务快照,供 pop/status 返回。
func buildQueueTaskItem(state *ScheduleState, taskID int) *queueTaskItem {
task := state.TaskByStateID(taskID)
if task == nil {
return nil
}
item := &queueTaskItem{
TaskID: task.StateID,
Name: strings.TrimSpace(task.Name),
Category: strings.TrimSpace(task.Category),
Status: buildTaskStatusLabel(*task),
Duration: task.Duration,
TaskClassID: task.TaskClassID,
Slots: make([]queueTaskSlot, 0, len(task.Slots)),
}
for _, slot := range task.Slots {
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
if !ok {
continue
}
item.Slots = append(item.Slots, queueTaskSlot{
Day: slot.Day,
Week: week,
DayOfWeek: dayOfWeek,
SlotStart: slot.SlotStart,
SlotEnd: slot.SlotEnd,
})
}
return item
}
func mustJSON(v any, toolName string) string {
raw, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf(`{"tool":"%s","success":false,"error":"json encode failed"}`, toolName)
}
return string(raw)
}

View File

@@ -0,0 +1,988 @@
package schedule
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// queryAvailableSlotsResult 描述 query_available_slots 的结构化返回。
type queryAvailableSlotsResult struct {
Tool string `json:"tool"`
Count int `json:"count"`
StrictCount int `json:"strict_count"`
EmbeddedCount int `json:"embedded_count"`
FallbackUsed bool `json:"fallback_used"`
DayScope string `json:"day_scope"`
DayOfWeek []int `json:"day_of_week"`
WeekFilter []int `json:"week_filter"`
WeekFrom int `json:"week_from"`
WeekTo int `json:"week_to"`
Span int `json:"span"`
AllowEmbed bool `json:"allow_embed"`
ExcludeSections []int `json:"exclude_sections"`
Slots []queryAvailableSlotItem `json:"slots"`
}
// queryAvailableSlotItem 描述单个候选坑位。
type queryAvailableSlotItem struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
SlotType string `json:"slot_type,omitempty"`
}
// queryTargetTasksResult 描述 query_target_tasks 的结构化返回。
type queryTargetTasksResult struct {
Tool string `json:"tool"`
Count int `json:"count"`
Status string `json:"status"`
DayScope string `json:"day_scope"`
DayOfWeek []int `json:"day_of_week"`
WeekFilter []int `json:"week_filter"`
WeekFrom int `json:"week_from"`
WeekTo int `json:"week_to"`
Enqueue bool `json:"enqueue"`
Enqueued int `json:"enqueued"`
Queue *queryTargetQueueInfo `json:"queue,omitempty"`
Items []queryTargetTaskItem `json:"items"`
}
// queryTargetQueueInfo 描述 query_target_tasks 入队后的队列摘要。
type queryTargetQueueInfo struct {
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id,omitempty"`
CurrentAttempt int `json:"current_attempt,omitempty"`
}
// queryTargetTaskItem 描述候选任务。
type queryTargetTaskItem struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Status string `json:"status"`
Duration int `json:"duration,omitempty"`
TaskClassID int `json:"task_class_id,omitempty"`
Slots []queryTargetTaskSlot `json:"slots,omitempty"`
}
// queryTargetTaskSlot 描述任务在工具状态中的坐标。
type queryTargetTaskSlot struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
}
// queryAvailableOptions 是 query_available_slots 的参数快照。
type queryAvailableOptions struct {
DayScope string
DayOfWeekSet map[int]struct{}
WeekSet map[int]struct{}
WeekFrom int
WeekTo int
Span int
Limit int
AllowEmbed bool
ExcludedSection map[int]struct{}
AfterSection *int
BeforeSection *int
ExactFrom *int
ExactTo *int
}
// queryTargetOptions 是 query_target_tasks 的参数快照。
type queryTargetOptions struct {
DayScope string
DayOfWeekSet map[int]struct{}
WeekSet map[int]struct{}
WeekFrom int
WeekTo int
Status string
Limit int
TaskIDSet map[int]struct{}
Category string
Enqueue bool
ResetQueue bool
}
var (
queryAvailableAllowedArgs = []string{
"span",
"duration",
"limit",
"allow_embed",
"day",
"day_start",
"day_end",
"day_scope",
"day_of_week",
"week",
"week_filter",
"week_from",
"week_to",
"slot_type",
"slot_types",
"exclude_sections",
"after_section",
"before_section",
"section_from",
"section_to",
}
queryTargetAllowedArgs = []string{
"status",
"category",
"limit",
"day_scope",
"day",
"day_start",
"day_end",
"day_of_week",
"week",
"week_filter",
"week_from",
"week_to",
"task_ids",
"task_id",
"task_item_ids",
"task_item_id",
"enqueue",
"reset_queue",
}
)
// QueryAvailableSlots 返回“候选坑位池”。
//
// 职责边界:
// 1. 只负责读状态并返回结构化 JSON不做任何写入
// 2. 优先返回纯空位strict不足时再补可嵌入位embedded
// 3. 不负责移动策略决策,最终落点由模型结合目标再选择。
func QueryAvailableSlots(state *ScheduleState, args map[string]any) string {
// 0. 先做字段白名单校验:未知参数直接报错,避免静默忽略造成范围漂移。
if err := validateToolArgsStrict(args, queryAvailableAllowedArgs); err != nil {
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
}
// 1. 解析参数并做合法性校验。
options, err := parseQueryAvailableOptions(state, args)
if err != nil {
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
}
// 2. 解析“可迭代天集合”:先解析 day/day_start/day_end再叠加 week/day_scope/day_of_week 过滤。
candidateDays, err := resolveCandidateDays(state, args, options.DayScope, options.DayOfWeekSet, options.WeekSet, options.WeekFrom, options.WeekTo)
if err != nil {
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
}
// 3. 两阶段收集:
// 3.1 先收集 strict纯空位保证“先空位后嵌入”的默认策略
// 3.2 strict 不足 limit 时,再补 embed 候选(仅在 allow_embed=true 时)。
slots := make([]queryAvailableSlotItem, 0, options.Limit)
seen := make(map[string]struct{}, options.Limit*2)
collect := func(embedAllowed bool, slotType string) {
if len(slots) >= options.Limit {
return
}
for _, day := range candidateDays {
week, dayOfWeek, ok := state.DayToWeekDay(day)
if !ok {
continue
}
for slotStart := 1; slotStart+options.Span-1 <= 12; slotStart++ {
slotEnd := slotStart + options.Span - 1
if !matchSectionRange(slotStart, slotEnd, options.ExcludedSection, options.AfterSection, options.BeforeSection, options.ExactFrom, options.ExactTo) {
continue
}
accepted := false
if !embedAllowed {
accepted = isStrictSlotAvailable(state, day, slotStart, slotEnd)
} else {
accepted = isEmbeddableSlotAvailable(state, day, slotStart, slotEnd)
}
if !accepted {
continue
}
key := fmt.Sprintf("%d-%d-%d", day, slotStart, slotEnd)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
slots = append(slots, queryAvailableSlotItem{
Day: day,
Week: week,
DayOfWeek: dayOfWeek,
SlotStart: slotStart,
SlotEnd: slotEnd,
SlotType: slotType,
})
if len(slots) >= options.Limit {
return
}
}
}
}
collect(false, "empty")
strictCount := len(slots)
if options.AllowEmbed && len(slots) < options.Limit {
collect(true, "embedded_candidate")
}
embeddedCount := len(slots) - strictCount
// 4. 组装结构化返回JSON 字符串)。
result := queryAvailableSlotsResult{
Tool: "query_available_slots",
Count: len(slots),
StrictCount: strictCount,
EmbeddedCount: embeddedCount,
FallbackUsed: embeddedCount > 0,
DayScope: options.DayScope,
DayOfWeek: sortedSetKeys(options.DayOfWeekSet),
WeekFilter: sortedSetKeys(options.WeekSet),
WeekFrom: options.WeekFrom,
WeekTo: options.WeekTo,
Span: options.Span,
AllowEmbed: options.AllowEmbed,
ExcludeSections: sortedSetKeys(options.ExcludedSection),
Slots: slots,
}
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"query_available_slots","success":false,"error":"query encode failed"}`
}
return string(raw)
}
// QueryTargetTasks 返回“候选任务集合”。
//
// 职责边界:
// 1. 只做筛选与结构化返回,不直接执行 move/swap
// 2. 默认 status=suggested减少模型误选 existing/pending
// 3. 仅返回状态事实,不做“该不该移动”的语义判断。
func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
// 0. 先做字段白名单校验:未知参数直接报错,避免静默忽略造成范围漂移。
if err := validateToolArgsStrict(args, queryTargetAllowedArgs); err != nil {
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
}
// 1. 解析参数。
options, err := parseQueryTargetOptions(state, args)
if err != nil {
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
}
// 2. 解析“可迭代天集合”过滤器。
candidateDays, err := resolveCandidateDays(state, args, options.DayScope, options.DayOfWeekSet, options.WeekSet, options.WeekFrom, options.WeekTo)
if err != nil {
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
}
calendarFilterActive := isQueryTargetCalendarFilterActive(args, options)
daySet := make(map[int]struct{}, len(candidateDays))
for _, d := range candidateDays {
daySet[d] = struct{}{}
}
// 3. 扫描任务并按筛选条件收敛。
items := make([]queryTargetTaskItem, 0, options.Limit)
for i := range state.Tasks {
task := state.Tasks[i]
if !matchTaskStatus(task, options.Status) {
continue
}
if len(options.TaskIDSet) > 0 {
if _, ok := options.TaskIDSet[task.StateID]; !ok {
continue
}
}
if options.Category != "" && task.Category != options.Category {
continue
}
taskSlots := make([]queryTargetTaskSlot, 0, len(task.Slots))
for _, slot := range task.Slots {
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
if !ok {
continue
}
// 3.1 若存在日历过滤条件,只保留命中过滤的坐标。
if calendarFilterActive && len(daySet) > 0 {
if _, hit := daySet[slot.Day]; !hit {
continue
}
}
taskSlots = append(taskSlots, queryTargetTaskSlot{
Day: slot.Day,
Week: week,
DayOfWeek: dayOfWeek,
SlotStart: slot.SlotStart,
SlotEnd: slot.SlotEnd,
})
}
// 3.2 pending 任务默认无 slots当存在日历过滤条件时不应混入“未知坐标任务”。
if len(taskSlots) == 0 && calendarFilterActive {
continue
}
sort.Slice(taskSlots, func(i, j int) bool {
if taskSlots[i].Day != taskSlots[j].Day {
return taskSlots[i].Day < taskSlots[j].Day
}
if taskSlots[i].SlotStart != taskSlots[j].SlotStart {
return taskSlots[i].SlotStart < taskSlots[j].SlotStart
}
return taskSlots[i].SlotEnd < taskSlots[j].SlotEnd
})
items = append(items, queryTargetTaskItem{
TaskID: task.StateID,
Name: strings.TrimSpace(task.Name),
Category: strings.TrimSpace(task.Category),
Status: buildTaskStatusLabel(task),
Duration: task.Duration,
TaskClassID: task.TaskClassID,
Slots: taskSlots,
})
}
// 4. 稳定排序:先按最早坐标,再按 task_id。
sort.Slice(items, func(i, j int) bool {
leftHasSlot := len(items[i].Slots) > 0
rightHasSlot := len(items[j].Slots) > 0
if leftHasSlot != rightHasSlot {
return leftHasSlot
}
if leftHasSlot {
left := items[i].Slots[0]
right := items[j].Slots[0]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.SlotStart != right.SlotStart {
return left.SlotStart < right.SlotStart
}
}
return items[i].TaskID < items[j].TaskID
})
if len(items) > options.Limit {
items = items[:options.Limit]
}
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
//
// 步骤化说明:
// 1. 默认保持纯读,不自动入队;只有显式 enqueue=true 时才进入队列链路;
// 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选;
// 3. 入队仅保存 task_id不复制任务全文避免队列状态膨胀。
queueInfo := (*queryTargetQueueInfo)(nil)
enqueued := 0
if options.Enqueue {
taskIDs := make([]int, 0, len(items))
for _, item := range items {
taskIDs = append(taskIDs, item.TaskID)
}
if options.ResetQueue {
enqueued = ReplaceTaskProcessingQueue(state, taskIDs)
} else {
enqueued = appendTaskIDsToQueue(state, taskIDs)
}
queue := ensureTaskProcessingQueue(state)
queueInfo = &queryTargetQueueInfo{
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
CurrentTaskID: queue.CurrentTaskID,
CurrentAttempt: queue.CurrentAttempts,
}
}
// 6. 结构化返回。
result := queryTargetTasksResult{
Tool: "query_target_tasks",
Count: len(items),
Status: options.Status,
DayScope: options.DayScope,
DayOfWeek: sortedSetKeys(options.DayOfWeekSet),
WeekFilter: sortedSetKeys(options.WeekSet),
WeekFrom: options.WeekFrom,
WeekTo: options.WeekTo,
Enqueue: options.Enqueue,
Enqueued: enqueued,
Queue: queueInfo,
Items: items,
}
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"query_target_tasks","success":false,"error":"query encode failed"}`
}
return string(raw)
}
// parseQueryAvailableOptions 解析 query_available_slots 参数。
func parseQueryAvailableOptions(state *ScheduleState, args map[string]any) (queryAvailableOptions, error) {
scope := normalizeDayScope(readStringAny(args, "day_scope", "all"))
allowEmbed := readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding")
slotTypeHints := readStringSliceAny(args, "slot_types")
if single := strings.TrimSpace(readStringAny(args, "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, ok := readIntAny(args, "span", "section_duration", "task_duration", "duration")
if !ok || span <= 0 {
span = 2
}
if span > 12 {
return queryAvailableOptions{}, fmt.Errorf("span=%d 非法,必须在 1~12", span)
}
limit, ok := readIntAny(args, "limit")
if !ok || limit <= 0 {
limit = 12
}
weekSet := intSliceToSet(readIntSliceAny(args, "week_filter", "weeks"))
weekFrom, hasWeekFrom := readIntAny(args, "week_from", "from_week")
weekTo, hasWeekTo := readIntAny(args, "week_to", "to_week")
if week, hasWeek := readIntAny(args, "week"); hasWeek {
weekFrom, weekTo = week, week
hasWeekFrom, hasWeekTo = true, true
}
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
weekFrom, weekTo = weekTo, weekFrom
}
defaultWeekFrom, defaultWeekTo := inferWeekBounds(state)
if !hasWeekFrom {
weekFrom = defaultWeekFrom
}
if !hasWeekTo {
weekTo = defaultWeekTo
}
excluded := intSliceToSet(readIntSliceAny(args, "exclude_sections", "exclude_section"))
afterSection, hasAfter := readIntAny(args, "after_section")
beforeSection, hasBefore := readIntAny(args, "before_section")
exactFrom, hasExactFrom := readIntAny(args, "section_from", "target_section_from")
exactTo, hasExactTo := readIntAny(args, "section_to", "target_section_to")
if hasExactFrom != hasExactTo {
return queryAvailableOptions{}, fmt.Errorf("精确节次查询需要同时提供 section_from 和 section_to")
}
if hasExactFrom {
if exactFrom < 1 || exactTo > 12 || exactFrom > exactTo {
return queryAvailableOptions{}, fmt.Errorf("精确节次区间非法:%d-%d", exactFrom, exactTo)
}
// 不再用 section_from/section_to 覆盖 spanduration
// 两者独立span 控制每段长度section_from/section_to 控制搜索范围。
}
options := queryAvailableOptions{
DayScope: scope,
DayOfWeekSet: intSliceToSet(readIntSliceAny(args, "day_of_week", "days", "day_filter")),
WeekSet: weekSet,
WeekFrom: weekFrom,
WeekTo: weekTo,
Span: span,
Limit: limit,
AllowEmbed: allowEmbed,
ExcludedSection: excluded,
}
if hasAfter {
options.AfterSection = &afterSection
}
if hasBefore {
options.BeforeSection = &beforeSection
}
if hasExactFrom {
options.ExactFrom = &exactFrom
options.ExactTo = &exactTo
}
return options, nil
}
// parseQueryTargetOptions 解析 query_target_tasks 参数。
func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTargetOptions, error) {
scope := normalizeDayScope(readStringAny(args, "day_scope", "all"))
status := strings.ToLower(strings.TrimSpace(readStringAny(args, "status", "suggested")))
if status == "" {
status = "suggested"
}
switch status {
case "all", "existing", "suggested", "pending":
default:
return queryTargetOptions{}, fmt.Errorf("status=%q 非法,仅支持 all/existing/suggested/pending", status)
}
limit, ok := readIntAny(args, "limit")
if !ok || limit <= 0 {
limit = 16
}
weekSet := intSliceToSet(readIntSliceAny(args, "week_filter", "weeks"))
weekFrom, hasWeekFrom := readIntAny(args, "week_from", "from_week")
weekTo, hasWeekTo := readIntAny(args, "week_to", "to_week")
if week, hasWeek := readIntAny(args, "week"); hasWeek {
weekFrom, weekTo = week, week
hasWeekFrom, hasWeekTo = true, true
}
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
weekFrom, weekTo = weekTo, weekFrom
}
defaultWeekFrom, defaultWeekTo := inferWeekBounds(state)
if !hasWeekFrom {
weekFrom = defaultWeekFrom
}
if !hasWeekTo {
weekTo = defaultWeekTo
}
taskIDs := readIntSliceAny(args, "task_ids", "task_item_ids")
if singleTaskID, ok := readIntAny(args, "task_id", "task_item_id"); ok {
taskIDs = append(taskIDs, singleTaskID)
}
return queryTargetOptions{
DayScope: scope,
DayOfWeekSet: intSliceToSet(readIntSliceAny(args, "day_of_week", "days", "day_filter")),
WeekSet: weekSet,
WeekFrom: weekFrom,
WeekTo: weekTo,
Status: status,
Limit: limit,
TaskIDSet: intSliceToSet(taskIDs),
Category: strings.TrimSpace(readStringAny(args, "category", "")),
Enqueue: readBoolAnyWithDefault(args, false, "enqueue"),
ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"),
}, nil
}
// resolveCandidateDays 解析并返回候选 day 列表。
//
// 处理规则:
// 1. 先解析 day / day_start / day_end互斥形成基础集合
// 2. 再叠加 day_scope / day_of_week / week_* 过滤;
// 3. 返回升序去重结果;若过滤后为空,返回空切片但不报错。
func resolveCandidateDays(
state *ScheduleState,
args map[string]any,
dayScope string,
dayOfWeekSet map[int]struct{},
weekSet map[int]struct{},
weekFrom int,
weekTo int,
) ([]int, error) {
if state == nil {
return nil, fmt.Errorf("state 为空")
}
day, hasDay := readIntAny(args, "day")
dayStart, hasDayStart := readIntAny(args, "day_start")
dayEnd, hasDayEnd := readIntAny(args, "day_end")
if hasDay && (hasDayStart || hasDayEnd) {
return nil, fmt.Errorf("day 与 day_start/day_end 不能同时传入")
}
baseDays := make([]int, 0, state.Window.TotalDays)
if hasDay {
if err := validateDay(state, day); err != nil {
return nil, err
}
baseDays = append(baseDays, day)
} else {
start := 1
end := state.Window.TotalDays
if hasDayStart {
start = dayStart
}
if hasDayEnd {
end = dayEnd
}
if start > end {
return nil, fmt.Errorf("day_start=%d 不能大于 day_end=%d", start, end)
}
if err := validateDay(state, start); err != nil {
return nil, err
}
if err := validateDay(state, end); err != nil {
return nil, err
}
for d := start; d <= end; d++ {
baseDays = append(baseDays, d)
}
}
result := make([]int, 0, len(baseDays))
for _, d := range baseDays {
week, dayOfWeek, ok := state.DayToWeekDay(d)
if !ok {
continue
}
if len(dayOfWeekSet) > 0 {
if _, hit := dayOfWeekSet[dayOfWeek]; !hit {
continue
}
} else if !matchDayScope(dayOfWeek, dayScope) {
continue
}
if len(weekSet) > 0 {
if _, hit := weekSet[week]; !hit {
continue
}
}
if week < weekFrom || week > weekTo {
continue
}
result = append(result, d)
}
sort.Ints(result)
return uniqueInts(result), nil
}
// matchSectionRange 判断候选节次是否满足过滤条件。
func matchSectionRange(
slotStart int,
slotEnd int,
excluded map[int]struct{},
after *int,
before *int,
exactFrom *int,
exactTo *int,
) bool {
if exactFrom != nil && exactTo != nil {
// 范围包含语义slot 必须完全落在 [section_from, section_to] 区间内
if slotStart < *exactFrom || slotEnd > *exactTo {
return false
}
}
if after != nil && slotStart <= *after {
return false
}
if before != nil && slotEnd >= *before {
return false
}
for section := slotStart; section <= slotEnd; section++ {
if _, hit := excluded[section]; hit {
return false
}
}
return true
}
// isStrictSlotAvailable 判断某段是否为“纯空位”。
func isStrictSlotAvailable(state *ScheduleState, day int, slotStart int, slotEnd int) bool {
for i := range state.Tasks {
task := state.Tasks[i]
if len(task.Slots) == 0 {
continue
}
if task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
if rangesOverlap(slotStart, slotEnd, slot.SlotStart, slot.SlotEnd) {
return false
}
}
}
return true
}
// isEmbeddableSlotAvailable 判断某段是否可作为“可嵌入候选位”。
//
// 判定规则:
// 1. 该段不能与不可嵌入任务冲突;
// 2. 该段必须完全落在某个 can_embed=true 且未被占用嵌入位的宿主中;
// 3. 若命中 can_embed 但宿主已被嵌入embedded_by!=nil视为不可用。
func isEmbeddableSlotAvailable(state *ScheduleState, day int, slotStart int, slotEnd int) bool {
hostFound := false
for i := range state.Tasks {
task := state.Tasks[i]
if len(task.Slots) == 0 {
continue
}
if task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
if !rangesOverlap(slotStart, slotEnd, slot.SlotStart, slot.SlotEnd) {
continue
}
if !task.CanEmbed {
return false
}
if task.EmbeddedBy != nil {
return false
}
if slotStart >= slot.SlotStart && slotEnd <= slot.SlotEnd {
hostFound = true
continue
}
// 与可嵌入宿主部分重叠但不被完全包含,也不能作为合法嵌入位。
return false
}
}
return hostFound
}
// matchTaskStatus 判断任务是否命中 status 过滤。
func matchTaskStatus(task ScheduleTask, status string) bool {
switch status {
case "all":
return true
case "existing":
return IsExistingTask(task)
case "suggested":
return IsSuggestedTask(task)
case "pending":
return IsPendingTask(task)
default:
return false
}
}
// isQueryTargetCalendarFilterActive 判断是否显式启用了日历坐标过滤。
func isQueryTargetCalendarFilterActive(args map[string]any, options queryTargetOptions) bool {
if _, ok := readIntAny(args, "day"); ok {
return true
}
if _, ok := readIntAny(args, "day_start"); ok {
return true
}
if _, ok := readIntAny(args, "day_end"); ok {
return true
}
if _, ok := readIntAny(args, "week"); ok {
return true
}
if _, ok := readIntAny(args, "week_from", "from_week"); ok {
return true
}
if _, ok := readIntAny(args, "week_to", "to_week"); ok {
return true
}
if len(readIntSliceAny(args, "week_filter", "weeks")) > 0 {
return true
}
if len(options.DayOfWeekSet) > 0 {
return true
}
scopeRaw := strings.TrimSpace(readStringAny(args, "day_scope"))
return normalizeDayScope(scopeRaw) != "all" && scopeRaw != ""
}
// buildTaskStatusLabel 返回任务状态标签。
func buildTaskStatusLabel(task ScheduleTask) string {
if IsPendingTask(task) {
return "pending"
}
if IsSuggestedTask(task) {
return "suggested"
}
return "existing"
}
// rangesOverlap 判断两个闭区间是否重叠。
func rangesOverlap(startA, endA, startB, endB int) bool {
return startA <= endB && endA >= startB
}
// normalizeDayScope 归一化 day_scope。
func normalizeDayScope(scope string) string {
scope = strings.ToLower(strings.TrimSpace(scope))
switch scope {
case "weekend", "workday", "all":
return scope
default:
return "all"
}
}
// matchDayScope 判断 day_of_week 是否命中 day_scope。
func matchDayScope(dayOfWeek int, scope string) bool {
switch scope {
case "weekend":
return dayOfWeek == 6 || dayOfWeek == 7
case "workday":
return dayOfWeek >= 1 && dayOfWeek <= 5
default:
return true
}
}
// inferWeekBounds 推导窗口内的最小/最大周。
func inferWeekBounds(state *ScheduleState) (int, int) {
if state == nil || len(state.Window.DayMapping) == 0 {
return 0, 0
}
minWeek := state.Window.DayMapping[0].Week
maxWeek := state.Window.DayMapping[0].Week
for _, mapping := range state.Window.DayMapping {
if mapping.Week < minWeek {
minWeek = mapping.Week
}
if mapping.Week > maxWeek {
maxWeek = mapping.Week
}
}
return minWeek, maxWeek
}
// readIntAny 按别名顺序读取 int 参数。
func readIntAny(args map[string]any, keys ...string) (int, bool) {
for _, key := range keys {
value, ok := ArgsInt(args, key)
if ok {
return value, true
}
}
return 0, false
}
// readStringAny 按别名顺序读取 string 参数。
func readStringAny(args map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := ArgsString(args, key); ok {
return value
}
}
return ""
}
// readBoolAnyWithDefault 按别名顺序读取 bool 参数。
func readBoolAnyWithDefault(args map[string]any, defaultValue bool, keys ...string) bool {
for _, key := range keys {
raw, exists := args[key]
if !exists {
continue
}
switch value := raw.(type) {
case bool:
return value
case string:
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "true" {
return true
}
if lower == "false" {
return false
}
}
}
return defaultValue
}
// readIntSliceAny 按别名顺序读取 int 列表参数。
func readIntSliceAny(args map[string]any, keys ...string) []int {
for _, key := range keys {
if values, ok := ArgsIntSlice(args, key); ok {
return values
}
}
return nil
}
// readStringSliceAny 按别名顺序读取 string 列表参数。
func readStringSliceAny(args map[string]any, keys ...string) []string {
for _, key := range keys {
raw, exists := args[key]
if !exists {
continue
}
switch values := raw.(type) {
case []string:
out := make([]string, 0, len(values))
for _, item := range values {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
case []any:
out := make([]string, 0, len(values))
for _, item := range values {
text, ok := item.(string)
if !ok {
continue
}
trimmed := strings.TrimSpace(text)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
case string:
trimmed := strings.TrimSpace(values)
if trimmed == "" {
return nil
}
return []string{trimmed}
}
}
return nil
}
// intSliceToSet 将 int 列表转为集合。
func intSliceToSet(values []int) map[int]struct{} {
if len(values) == 0 {
return map[int]struct{}{}
}
set := make(map[int]struct{}, len(values))
for _, value := range values {
set[value] = struct{}{}
}
return set
}
// sortedSetKeys 返回集合的升序 key 切片。
func sortedSetKeys(set map[int]struct{}) []int {
if len(set) == 0 {
return []int{}
}
keys := make([]int, 0, len(set))
for key := range set {
keys = append(keys, key)
}
sort.Ints(keys)
return keys
}
// uniqueInts 对整数切片去重并保持升序。
func uniqueInts(values []int) []int {
if len(values) == 0 {
return values
}
seen := make(map[int]struct{}, len(values))
result := make([]int, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Ints(result)
return result
}

View File

@@ -0,0 +1,249 @@
package schedule
import (
"fmt"
"sort"
"strings"
)
// ==================== 内部辅助类型 ====================
// taskOnDay 表示某个任务在某一天的一个时段占用。
// 一个任务可能出现在多天每天可能有多段占用如周一1-2节 + 周三3-4节
type taskOnDay struct {
task *ScheduleTask
slotStart int
slotEnd int
}
// freeRange 表示一段连续空闲区间。
type freeRange struct {
day int
slotStart int
slotEnd int
}
// ==================== 格式化辅助函数 ====================
// formatSlotRange 将时段范围格式化为人类可读的字符串。
// start == end 时输出 "3节",否则输出 "1-2节"。
func formatSlotRange(start, end int) string {
if start == end {
return fmt.Sprintf("%d节", start)
}
return fmt.Sprintf("%d-%d节", start, end)
}
// formatTaskLabel 输出任务的简短标签,如 "[1]高等数学"。
// LLM 交互时统一使用此格式引用任务。
func formatTaskLabel(task ScheduleTask) string {
return fmt.Sprintf("[%d]%s", task.StateID, task.Name)
}
// formatTaskLabelWithCategory 输出带类别和锁定标记的标签。
// 如 "[1]高等数学(课程,固定)" 或 "[2]英语(课程)"。
// 用于 get_overview 的概要输出。
func formatTaskLabelWithCategory(task ScheduleTask) string {
label := fmt.Sprintf("[%d]%s(%s", task.StateID, task.Name, task.Category)
if task.Locked {
label += ",固定"
}
label += ")"
return label
}
// ==================== 占用计算辅助函数 ====================
// getTasksOnDay 获取某天所有“当前有落位”的任务占用列表。
//
// 说明:
// 1. existing 与 suggested 都属于“有落位”;
// 2. 旧快照里若残留 pending+Slots也会通过 Slots 被兼容识别;
// 3. 嵌入任务(有 EmbedHost 的)也会被返回,因为它们实际共享了该时段。
// 返回值按 slotStart 升序排列。
func getTasksOnDay(state *ScheduleState, day int) []taskOnDay {
var result []taskOnDay
for i := range state.Tasks {
t := &state.Tasks[i]
if !hasSlotOnDay(t, day) {
continue
}
for _, slot := range t.Slots {
if slot.Day == day {
result = append(result, taskOnDay{
task: t,
slotStart: slot.SlotStart,
slotEnd: slot.SlotEnd,
})
}
}
}
// 按 slotStart 升序排列,方便逐段输出。
sort.Slice(result, func(i, j int) bool {
return result[i].slotStart < result[j].slotStart
})
return result
}
// hasSlotOnDay 判断任务是否在某天有时段占用。
func hasSlotOnDay(task *ScheduleTask, day int) bool {
for _, slot := range task.Slots {
if slot.Day == day {
return true
}
}
return false
}
// countDayOccupied 统计某天的已占用时段总数。
// 每个时段slot是独立的节次单位一个 TaskSlot(day=1, start=1, end=2) 占 2 个时段。
// 嵌入任务与宿主共享时段,不重复计算。
func countDayOccupied(state *ScheduleState, day int) int {
occupied := 0
for i := range state.Tasks {
t := &state.Tasks[i]
// 嵌入任务不重复计算占用——它和宿主共享时段。
if t.EmbedHost != nil {
continue
}
for _, slot := range t.Slots {
if slot.Day == day {
occupied += slot.SlotEnd - slot.SlotStart + 1
}
}
}
return occupied
}
// slotOccupiedBy 查询某天某节被哪个任务占用。
// 排除嵌入任务EmbedHost != nil因为嵌入任务与宿主共享时段。
// 返回 nil 表示该节空闲。
func slotOccupiedBy(state *ScheduleState, day, slot int) *ScheduleTask {
for i := range state.Tasks {
t := &state.Tasks[i]
// 嵌入任务不视为独立占用。
if t.EmbedHost != nil {
continue
}
for _, s := range t.Slots {
if s.Day == day && slot >= s.SlotStart && slot <= s.SlotEnd {
return t
}
}
}
return nil
}
// ==================== 空闲区间计算 ====================
// findFreeRangesOnDay 计算某天所有连续空闲区间。
// 算法:
// 1. 构建 12 个时段的占用数组(排除嵌入任务,嵌入任务共享宿主时段)
// 2. 扫描连续空闲段
//
// 返回值按 slotStart 升序排列。
func findFreeRangesOnDay(state *ScheduleState, day int) []freeRange {
// 1. 构建占用数组occupied[slot] = true 表示该节被占用。
occupied := make([]bool, 13) // 下标 1-120 不使用
for i := range state.Tasks {
t := &state.Tasks[i]
// 嵌入任务与宿主共享时段,不算独立占用。
if t.EmbedHost != nil {
continue
}
for _, slot := range t.Slots {
if slot.Day == day {
for s := slot.SlotStart; s <= slot.SlotEnd; s++ {
if s >= 1 && s <= 12 {
occupied[s] = true
}
}
}
}
}
// 2. 扫描连续空闲段。
var ranges []freeRange
start := 0
for s := 1; s <= 12; s++ {
if !occupied[s] {
if start == 0 {
start = s
}
} else {
if start > 0 {
ranges = append(ranges, freeRange{day: day, slotStart: start, slotEnd: s - 1})
start = 0
}
}
}
if start > 0 {
ranges = append(ranges, freeRange{day: day, slotStart: start, slotEnd: 12})
}
return ranges
}
// getEmbeddableTasks 获取所有可嵌入时段的任务列表。
// 条件CanEmbed == true用于 query_available_slots 和 get_overview 输出可嵌入位置。
func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask {
var result []*ScheduleTask
for i := range state.Tasks {
t := &state.Tasks[i]
if t.CanEmbed && len(t.Slots) > 0 {
result = append(result, t)
}
}
return result
}
// ==================== 通用输出构建 ====================
// buildOverviewDayLine 构建某天的概况行。
// 格式如第1天占6/12 — [1]高等数学(1-2节) [2]英语(3-4节)
// 空闲天输出如第3天占0/12
func buildOverviewDayLine(state *ScheduleState, day int) string {
occupied := countDayOccupied(state, day)
tasks := getTasksOnDay(state, day)
dayLabel := formatDayLabel(state, day)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s占%d/12", dayLabel, occupied))
if len(tasks) > 0 {
sb.WriteString(" — ")
for i, td := range tasks {
if i > 0 {
sb.WriteString(" ")
}
label := formatTaskLabel(*td.task)
// 如果任务可嵌入且宿主未被嵌入,标注"可嵌入"。
suffix := ""
if td.task.CanEmbed && td.task.EmbeddedBy == nil {
suffix = ",可嵌入"
}
sb.WriteString(fmt.Sprintf("%s(%s%s)", label, formatSlotRange(td.slotStart, td.slotEnd), suffix))
}
}
return sb.String()
}
// buildFreeRangeLine 格式化空闲区间行。
// 格式如第3天 第1-6节6时段连续空闲
func buildFreeRangeLine(state *ScheduleState, r freeRange) string {
dur := r.slotEnd - r.slotStart + 1
return fmt.Sprintf("%s第%s%d时段连续空闲", formatDayLabel(state, r.day), formatSlotRange(r.slotStart, r.slotEnd), dur)
}
// formatSourceName 将 source 字段转为用户可读的来源名称。
// "event" → "课程表""task_item" → "任务"。
// 不暴露原始 source 字段值,统一使用中文描述。
func formatSourceName(source string) string {
switch source {
case "event":
return "课程表"
case "task_item":
return "任务"
default:
return source
}
}

View File

@@ -0,0 +1,679 @@
package schedule
import (
"fmt"
"sort"
"strings"
)
// ==================== 读工具LLM 只通过这些函数感知日程状态 ====================
// 所有读工具:
// - 只读不改,不修改 state
// - 返回自然语言 + 轻结构缩进、列表LLM 直接理解
// - 只报当前真实状态,不做建议/推荐/假设
// - 不暴露 source、source_id、event_type 内部字段
// GetOverview 获取规划窗口总览(任务视角,全量)。
//
// 设计约束:
// 1. 日内“总占用”保留课程占位影响,避免 LLM 误判可用空间;
// 2. 明细层不展开课程列表,只展开任务(非课程)清单;
// 3. 当前按“窗口不超过 30 天”场景直接全量返回,不做结果截断。
func GetOverview(state *ScheduleState) string {
totalSlots := state.Window.TotalDays * 12
// 1. 统计总占用(含课程占位)与空闲。
totalOccupied := 0
for i := range state.Tasks {
t := &state.Tasks[i]
if t.EmbedHost != nil {
continue // 嵌入任务不重复计算占用
}
for _, slot := range t.Slots {
totalOccupied += slot.SlotEnd - slot.SlotStart + 1
}
}
totalFree := totalSlots - totalOccupied
// 2. 统计“任务视角”状态分布,并单独统计课程条目数。
taskExistingCount := 0
taskSuggestedCount := 0
taskPendingCount := 0
courseExistingCount := 0
for i := range state.Tasks {
task := state.Tasks[i]
if isCourseScheduleTask(task) {
if IsExistingTask(task) {
courseExistingCount++
}
continue
}
switch {
case IsPendingTask(task):
taskPendingCount++
case IsSuggestedTask(task):
taskSuggestedCount++
case IsExistingTask(task):
taskExistingCount++
}
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("规划窗口共%d天每天12个时段总计%d个时段。\n", state.Window.TotalDays, totalSlots))
sb.WriteString(fmt.Sprintf(
"当前已占用%d个空闲%d个。课程占位条目%d个仅用于占位统计任务条目已安排(existing)%d个、已预排(suggested)%d个、待安排(pending)%d个。\n",
totalOccupied, totalFree, courseExistingCount, taskExistingCount, taskSuggestedCount, taskPendingCount,
))
// 3. 逐天总览:保留课程占位计数,但只展示任务明细。
sb.WriteString("\n每日概况\n")
for day := 1; day <= state.Window.TotalDays; day++ {
sb.WriteString(buildTaskOnlyOverviewDayLine(state, day) + "\n")
}
// 4. 任务清单全量展开(不截断)。
sb.WriteString("\n任务清单全量已过滤课程\n")
sb.WriteString(buildTaskOnlyOverviewList(state))
// 5. 任务类约束(排课策略与限制)。
if len(state.TaskClasses) > 0 {
sb.WriteString("\n任务类约束排课时请遵守\n")
for _, tc := range state.TaskClasses {
strategy := formatStrategy(tc.Strategy)
allow := "否"
if tc.AllowFillerCourse {
allow = "是"
}
line := fmt.Sprintf(" [%s] 策略=%s 总预算=%d节 允许嵌水课=%s", tc.Name, strategy, tc.TotalSlots, allow)
if len(tc.ExcludedSlots) > 0 {
parts := make([]string, len(tc.ExcludedSlots))
for i, s := range tc.ExcludedSlots {
parts[i] = fmt.Sprintf("%d", s)
}
line += fmt.Sprintf(" 排除时段=[%s]", strings.Join(parts, ","))
}
if len(tc.ExcludedDaysOfWeek) > 0 {
parts := make([]string, len(tc.ExcludedDaysOfWeek))
for i, d := range tc.ExcludedDaysOfWeek {
parts[i] = fmt.Sprintf("%d", d)
}
line += fmt.Sprintf(" 排除星期=[%s]", strings.Join(parts, ","))
}
sb.WriteString(line + "\n")
}
}
return sb.String()
}
// formatStrategy 将 strategy 字段值转为中文描述。
func formatStrategy(strategy string) string {
switch strategy {
case "steady":
return "均匀分布"
case "rapid":
return "集中突击"
default:
if strategy == "" {
return "默认"
}
return strategy
}
}
// QueryRange 查看某天(或某天某段)的细粒度占用详情。
// day 必填slotStart/slotEnd 选填nil 表示查整天)。
// 整天模式按标准段1-2, 3-4, ..., 11-12分组输出。
// 指定范围模式逐节输出。
func QueryRange(state *ScheduleState, day int, slotStart, slotEnd *int) string {
// 1. 校验 day 是否在有效范围内。
if day < 1 || day > state.Window.TotalDays {
return fmt.Sprintf("查询失败:第%d天不在规划窗口范围内1-%d。", day, state.Window.TotalDays)
}
// 2. 分两种模式:整天查询 vs 指定范围查询。
if slotStart == nil || slotEnd == nil {
return queryRangeFullDay(state, day)
}
return queryRangeSpecific(state, day, *slotStart, *slotEnd)
}
// queryRangeFullDay 整天查询模式:按标准段分组输出。
// 输出格式对齐 SCHEDULE_TOOLS.md 4.2 节示例。
func queryRangeFullDay(state *ScheduleState, day int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s 全天:\n\n", formatDayLabel(state, day)))
// 1. 按 6 个标准段输出1-2, 3-4, 5-6, 7-8, 9-10, 11-12
for start := 1; start <= 11; start += 2 {
end := start + 1
// 查该段的占用情况,找该段内所有占用任务。
occupants := tasksInRange(state, day, start, end)
if len(occupants) == 0 {
sb.WriteString(fmt.Sprintf("第%s空\n", formatSlotRange(start, end)))
} else {
desc := formatOccupants(occupants)
sb.WriteString(fmt.Sprintf("第%s%s\n", formatSlotRange(start, end), desc))
}
}
// 2. 附加连续空闲区摘要。
freeRanges := findFreeRangesOnDay(state, day)
if len(freeRanges) > 0 {
sb.WriteString("\n连续空闲区")
rangeParts := make([]string, 0, len(freeRanges))
for _, r := range freeRanges {
dur := r.slotEnd - r.slotStart + 1
rangeParts = append(rangeParts, fmt.Sprintf("第%s(%d时段)", formatSlotRange(r.slotStart, r.slotEnd), dur))
}
sb.WriteString(strings.Join(rangeParts, "、") + "\n")
}
// 3. 附加可嵌入信息(仅当该天有可嵌入时段时输出)。
embedInfo := formatEmbedInfoForDay(state, day)
if embedInfo != "" {
sb.WriteString("可嵌入:" + embedInfo + "\n")
}
return sb.String()
}
// queryRangeSpecific 指定范围查询模式:逐节输出。
func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s第%s\n\n", formatDayLabel(state, day), formatSlotRange(startSlot, endSlot)))
total := endSlot - startSlot + 1
freeCount := 0
for s := startSlot; s <= endSlot; s++ {
occupant := slotOccupiedBy(state, day, s)
if occupant == nil {
sb.WriteString(fmt.Sprintf("第%d节空\n", s))
freeCount++
} else {
sb.WriteString(fmt.Sprintf("第%d节[%d]%s\n", s, occupant.StateID, occupant.Name))
}
}
if freeCount == total {
sb.WriteString(fmt.Sprintf("\n该范围%d个时段全部空闲。\n", total))
} else {
sb.WriteString(fmt.Sprintf("\n该范围%d个时段中%d个空闲%d个被占用。\n", total, freeCount, total-freeCount))
}
return sb.String()
}
// FindFirstFree 查找首个可用空位,并返回该日详细信息。
//
// 参数说明:
// 1. duration 必填,表示需要的连续时段数;
// 2. day 选填,指定单天搜索;
// 3. dayStart/dayEnd 选填,指定按天范围搜索(闭区间);
// 4. day 与 dayStart/dayEnd 互斥,避免语义冲突。
//
// 说明:
// 1. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策;
// 2. 当前阶段按用户要求全量返回,不做文本截断。
func FindFirstFree(state *ScheduleState, duration int, day, dayStart, dayEnd *int) string {
if duration <= 0 {
return "查询失败duration 必须大于 0。"
}
// 1. 参数互斥校验:单天搜索与范围搜索只能二选一。
if day != nil && (dayStart != nil || dayEnd != nil) {
return "查询失败day 与 day_start/day_end 不能同时传入。"
}
// 2. 确定搜索范围。
days := make([]int, 0)
if day != nil {
if *day < 1 || *day > state.Window.TotalDays {
return fmt.Sprintf("查询失败:第%d天不在规划窗口范围内1-%d。", *day, state.Window.TotalDays)
}
days = append(days, *day)
} else {
startDay := 1
endDay := state.Window.TotalDays
if dayStart != nil {
startDay = *dayStart
}
if dayEnd != nil {
endDay = *dayEnd
}
if startDay < 1 || startDay > state.Window.TotalDays {
return fmt.Sprintf("查询失败day_start=%d 不在规划窗口范围内1-%d。", startDay, state.Window.TotalDays)
}
if endDay < 1 || endDay > state.Window.TotalDays {
return fmt.Sprintf("查询失败day_end=%d 不在规划窗口范围内1-%d。", endDay, state.Window.TotalDays)
}
if startDay > endDay {
return fmt.Sprintf("查询失败day_start=%d 不能大于 day_end=%d。", startDay, endDay)
}
for d := startDay; d <= endDay; d++ {
days = append(days, d)
}
}
// 3. 按天从前往后寻找“首个可直接放置”的空位。
for _, d := range days {
freeRanges := findFreeRangesOnDay(state, d)
for _, r := range freeRanges {
rDur := r.slotEnd - r.slotStart + 1
if rDur < duration {
continue
}
slotStart := r.slotStart
slotEnd := r.slotStart + duration - 1
return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, false, nil)
}
}
// 4. 若没有纯空位,再尝试首个可嵌入宿主时段。
for _, d := range days {
host, slotStart, slotEnd := findFirstEmbeddablePosition(state, d, duration)
if host != nil {
return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, true, host)
}
}
// 5. 无可用位置时返回摘要,辅助 LLM 判断是否需要换天或降时长。
var sb strings.Builder
sb.WriteString(fmt.Sprintf("未找到满足%d个连续时段的可用位置。\n", duration))
sb.WriteString("各天最大连续空闲区前10天\n")
limit := 10
if len(days) < limit {
limit = len(days)
}
for i := 0; i < limit; i++ {
d := days[i]
freeRanges := findFreeRangesOnDay(state, d)
maxDur := 0
for _, r := range freeRanges {
dur := r.slotEnd - r.slotStart + 1
if dur > maxDur {
maxDur = dur
}
}
sb.WriteString(fmt.Sprintf("%s最大连续空闲%d节\n", formatDayLabel(state, d), maxDur))
}
return sb.String()
}
// buildFindFirstFreeReport 构造首个可用位的详细报告。
func buildFindFirstFreeReport(
state *ScheduleState,
day int,
duration int,
slotStart int,
slotEnd int,
isEmbedded bool,
host *ScheduleTask,
) string {
var sb strings.Builder
if isEmbedded && host != nil {
sb.WriteString(fmt.Sprintf("首个可用位置:%s可嵌入宿主 [%d]%s。\n",
formatDaySlotLabel(state, day, slotStart, slotEnd), host.StateID, host.Name))
} else {
sb.WriteString(fmt.Sprintf("首个可用位置:%s可直接放置。\n", formatDaySlotLabel(state, day, slotStart, slotEnd)))
}
sb.WriteString(fmt.Sprintf("匹配条件:需要%d个连续时段。\n", duration))
dayTotalOccupied := countDayOccupied(state, day)
dayTaskOccupied := countDayTaskOccupied(state, day)
dayCourseOccupied := dayTotalOccupied - dayTaskOccupied
sb.WriteString(fmt.Sprintf("当日负载:总占%d/12课程占%d/12任务占%d/12。\n", dayTotalOccupied, dayCourseOccupied, dayTaskOccupied))
sb.WriteString("当日任务明细(全量,已过滤课程):\n")
taskEntries := collectTaskEntriesOnDay(state, day)
if len(taskEntries) == 0 {
sb.WriteString(" 无任务明细。\n")
} else {
for _, td := range taskEntries {
sb.WriteString(fmt.Sprintf(" - [%d]%s | 状态:%s | 类别:%s | 时段:%s\n",
td.task.StateID, td.task.Name, taskStatusLabel(*td.task), td.task.Category, formatSlotRange(td.slotStart, td.slotEnd)))
}
}
sb.WriteString("当日连续空闲区:\n")
freeRanges := findFreeRangesOnDay(state, day)
if len(freeRanges) == 0 {
sb.WriteString(" 无连续空闲区。\n")
} else {
for _, r := range freeRanges {
sb.WriteString(" - " + buildFreeRangeLine(state, r) + "\n")
}
}
return sb.String()
}
// isCourseScheduleTask 判断任务是否属于“课程占位”。
// 用于 get_overview 的任务视角过滤:课程只参与占位统计,不参与任务明细展开。
func isCourseScheduleTask(task ScheduleTask) bool {
if task.Source != "event" {
return false
}
if strings.EqualFold(strings.TrimSpace(task.EventType), "course") {
return true
}
return strings.TrimSpace(task.Category) == "课程"
}
// taskStatusLabel 返回任务状态标签existing/suggested/pending
func taskStatusLabel(task ScheduleTask) string {
switch {
case IsPendingTask(task):
return "pending"
case IsSuggestedTask(task):
return "suggested"
default:
return "existing"
}
}
// collectTaskEntriesOnDay 收集某天的“任务视角”明细(过滤课程)。
func collectTaskEntriesOnDay(state *ScheduleState, day int) []taskOnDay {
all := getTasksOnDay(state, day)
result := make([]taskOnDay, 0, len(all))
for _, item := range all {
if item.task == nil {
continue
}
if isCourseScheduleTask(*item.task) {
continue
}
result = append(result, item)
}
return result
}
// countDayTaskOccupied 统计某天任务(过滤课程)的占用时段数。
func countDayTaskOccupied(state *ScheduleState, day int) int {
occupied := 0
for i := range state.Tasks {
t := state.Tasks[i]
if isCourseScheduleTask(t) {
continue
}
if t.EmbedHost != nil {
continue // 嵌入任务不重复计占用
}
for _, slot := range t.Slots {
if slot.Day == day {
occupied += slot.SlotEnd - slot.SlotStart + 1
}
}
}
return occupied
}
// buildTaskOnlyOverviewDayLine 生成某天“课程占位 + 任务明细”的摘要行。
func buildTaskOnlyOverviewDayLine(state *ScheduleState, day int) string {
totalOccupied := countDayOccupied(state, day)
taskOccupied := countDayTaskOccupied(state, day)
courseOccupied := totalOccupied - taskOccupied
taskEntries := collectTaskEntriesOnDay(state, day)
dayLabel := formatDayLabel(state, day)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s总占%d/12课程占%d/12任务占%d/12", dayLabel, totalOccupied, courseOccupied, taskOccupied))
if len(taskEntries) == 0 {
sb.WriteString(" — 任务:无")
return sb.String()
}
sb.WriteString(" — 任务:")
for i, item := range taskEntries {
if i > 0 {
sb.WriteString(" ")
}
sb.WriteString(fmt.Sprintf("[%d]%s(%s,%s)",
item.task.StateID,
item.task.Name,
taskStatusLabel(*item.task),
formatSlotRange(item.slotStart, item.slotEnd),
))
}
return sb.String()
}
// buildTaskOnlyOverviewList 输出“全量任务清单”(过滤课程)。
func buildTaskOnlyOverviewList(state *ScheduleState) string {
tasks := make([]ScheduleTask, 0, len(state.Tasks))
for i := range state.Tasks {
task := state.Tasks[i]
if isCourseScheduleTask(task) {
continue
}
tasks = append(tasks, task)
}
if len(tasks) == 0 {
return "无任务条目。\n"
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].StateID < tasks[j].StateID })
var sb strings.Builder
for _, t := range tasks {
classID := ""
if t.TaskClassID > 0 {
classID = fmt.Sprintf(" | task_class_id:%d", t.TaskClassID)
}
if IsPendingTask(t) {
sb.WriteString(fmt.Sprintf("[%d]%s | 状态:%s | 类别:%s%s | 需%d个连续时段\n",
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, t.Duration))
continue
}
sb.WriteString(fmt.Sprintf("[%d]%s | 状态:%s | 类别:%s%s | 时段:%s\n",
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, formatTaskSlotsBriefWithState(state, t.Slots)))
}
return sb.String()
}
// findFirstEmbeddablePosition 查找某天首个可嵌入位置。
func findFirstEmbeddablePosition(state *ScheduleState, day, duration int) (*ScheduleTask, int, int) {
type candidate struct {
task *ScheduleTask
slotStart int
slotEnd int
}
candidates := make([]candidate, 0)
for _, host := range getEmbeddableTasks(state) {
if host == nil || host.EmbeddedBy != nil {
continue
}
for _, slot := range host.Slots {
if slot.Day != day {
continue
}
span := slot.SlotEnd - slot.SlotStart + 1
if span < duration {
continue
}
candidates = append(candidates, candidate{
task: host,
slotStart: slot.SlotStart,
slotEnd: slot.SlotStart + duration - 1,
})
}
}
if len(candidates) == 0 {
return nil, 0, 0
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].slotStart < candidates[j].slotStart })
best := candidates[0]
return best.task, best.slotStart, best.slotEnd
}
// GetTaskInfo 查询单个任务的详细信息。
// taskID 必填,为 state 内的 state_id。
// 不存在时返回错误信息字符串。
func GetTaskInfo(state *ScheduleState, taskID int) string {
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("查询失败任务ID %d 不存在。", taskID)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[%d]%s\n", task.StateID, task.Name))
// 1. 类别、状态、来源。
statusLabel := "已安排(existing)"
if IsPendingTask(*task) {
statusLabel = "待安排(pending)"
} else if IsSuggestedTask(*task) {
statusLabel = "已预排(suggested)"
} else if task.Locked {
statusLabel = "已安排(existing,固定)"
}
sb.WriteString(fmt.Sprintf("类别:%s | 状态:%s\n", task.Category, statusLabel))
sb.WriteString(fmt.Sprintf("来源:%s\n", formatSourceName(task.Source)))
// 2. 可嵌入信息(仅 can_embed 任务显示)。
if task.CanEmbed {
sb.WriteString("可嵌入:是(允许在此时段嵌入其他任务)\n")
}
// 3. 占用时段。
if len(task.Slots) > 0 {
sb.WriteString("占用时段:\n")
for _, slot := range task.Slots {
sb.WriteString(fmt.Sprintf(" %s\n", formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd)))
}
}
// 4. 任务时长信息。
if IsPendingTask(*task) {
sb.WriteString(fmt.Sprintf("需要时段:%d个连续时段\n", task.Duration))
} else if IsSuggestedTask(*task) && task.Duration > 0 {
sb.WriteString(fmt.Sprintf("原始需求:%d个连续时段\n", task.Duration))
}
// 5. 嵌入关系信息。
if task.CanEmbed {
if task.EmbeddedBy != nil {
guest := state.TaskByStateID(*task.EmbeddedBy)
if guest != nil {
sb.WriteString(fmt.Sprintf("当前嵌入任务:[%d]%s\n", guest.StateID, guest.Name))
}
} else {
sb.WriteString("当前嵌入任务:无\n")
}
}
if task.EmbedHost != nil {
host := state.TaskByStateID(*task.EmbedHost)
if host != nil {
sb.WriteString(fmt.Sprintf("嵌入宿主:[%d]%s\n", host.StateID, host.Name))
}
}
return sb.String()
}
// ==================== 内部格式化函数 ====================
// tasksInRange 获取某天指定时段范围内的占用任务列表。
// 返回在该范围内有占用的所有任务(去重,按 slotStart 排序)。
func tasksInRange(state *ScheduleState, day, start, end int) []taskOnDay {
tasks := getTasksOnDay(state, day)
var result []taskOnDay
for _, td := range tasks {
// 判断是否有交集:任务的 [slotStart, slotEnd] 与查询范围 [start, end] 有重叠。
if td.slotStart <= end && td.slotEnd >= start {
result = append(result, td)
}
}
return result
}
// formatOccupants 格式化占用任务列表为紧凑描述。
// 如 "[1]高等数学(固定)" 或 "[6]线代"
func formatOccupants(occupants []taskOnDay) string {
parts := make([]string, 0, len(occupants))
for _, o := range occupants {
label := formatTaskLabel(*o.task)
if o.task.Locked {
parts = append(parts, label+"(固定)")
} else if o.task.CanEmbed {
parts = append(parts, label+"(可嵌入)")
} else {
parts = append(parts, label)
}
}
return strings.Join(parts, " ")
}
// formatEmbedInfoForDay 格式化某天的可嵌入信息。
// 返回空字符串表示该天没有可嵌入时段。
func formatEmbedInfoForDay(state *ScheduleState, day int) string {
var parts []string
for i := range state.Tasks {
t := &state.Tasks[i]
if !t.CanEmbed {
continue
}
for _, slot := range t.Slots {
if slot.Day != day {
continue
}
label := formatTaskLabel(*t)
if t.Locked {
parts = append(parts, fmt.Sprintf("第%s已有%s固定不可嵌入", formatSlotRange(slot.SlotStart, slot.SlotEnd), label))
} else {
embedStatus := "可嵌入"
if t.EmbeddedBy != nil {
guest := state.TaskByStateID(*t.EmbeddedBy)
if guest != nil {
embedStatus = fmt.Sprintf("已嵌入[%d]%s", guest.StateID, guest.Name)
}
}
parts = append(parts, fmt.Sprintf("第%s已有%s%s", formatSlotRange(slot.SlotStart, slot.SlotEnd), label, embedStatus))
}
}
}
return strings.Join(parts, "")
}
// formatExistingList 格式化已安排任务列表。
// 格式如: [1]高等数学(课程,固定) — 第1天(1-2节) 第4天(1-2节)
func formatExistingList(state *ScheduleState, tasks []ScheduleTask) string {
var sb strings.Builder
for _, t := range tasks {
label := formatTaskLabelWithCategory(t)
// 格式化所有时段位置。
slotParts := make([]string, 0, len(t.Slots))
for _, slot := range t.Slots {
slotParts = append(slotParts, fmt.Sprintf("%s(%s)", formatDayLabel(state, slot.Day), formatSlotRange(slot.SlotStart, slot.SlotEnd)))
}
sb.WriteString(fmt.Sprintf(" %s — %s\n", label, strings.Join(slotParts, " ")))
}
return sb.String()
}
// formatSuggestedList 格式化已预排任务列表。
// 格式如:[3]复习线代 — 已预排至 第2天第3-4节类别学习
func formatSuggestedList(state *ScheduleState, tasks []ScheduleTask) string {
var sb strings.Builder
if len(tasks) > 0 {
sb.WriteString(fmt.Sprintf("已预排任务共%d个\n\n", len(tasks)))
}
for _, t := range tasks {
sb.WriteString(fmt.Sprintf("[%d]%s — 已预排至 %s类别%s\n", t.StateID, t.Name, formatTaskSlotsBriefWithState(state, t.Slots), t.Category))
}
return sb.String()
}
// formatPendingList 格式化待安排任务列表。
// 格式如:[3]复习线代 — 需3个连续时段类别学习
func formatPendingList(tasks []ScheduleTask) string {
var sb strings.Builder
if len(tasks) > 0 {
sb.WriteString(fmt.Sprintf("待安排任务共%d个\n\n", len(tasks)))
}
for _, t := range tasks {
sb.WriteString(fmt.Sprintf("[%d]%s — 需%d个连续时段类别%s\n", t.StateID, t.Name, t.Duration, t.Category))
}
return sb.String()
}

View File

@@ -0,0 +1,177 @@
package schedule
// TaskProcessingQueue 表示 execute 阶段的“逐项处理队列”运行态。
//
// 职责边界:
// 1. PendingTaskIDs尚未开始处理的候选任务
// 2. CurrentTaskID当前正在处理的队首任务0 表示暂无);
// 3. CompletedTaskIDs / SkippedTaskIDs本轮处理结果归档
// 4. LastError最近一次 apply 失败的原因,供 LLM 下一轮决策参考。
type TaskProcessingQueue struct {
PendingTaskIDs []int `json:"pending_task_ids,omitempty"`
CurrentTaskID int `json:"current_task_id,omitempty"`
CurrentAttempts int `json:"current_attempts,omitempty"`
CompletedTaskIDs []int `json:"completed_task_ids,omitempty"`
SkippedTaskIDs []int `json:"skipped_task_ids,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// ensureTaskProcessingQueue 确保 state 上有可用队列容器。
func ensureTaskProcessingQueue(state *ScheduleState) *TaskProcessingQueue {
if state == nil {
return nil
}
if state.RuntimeQueue == nil {
state.RuntimeQueue = &TaskProcessingQueue{}
}
return state.RuntimeQueue
}
// ResetTaskProcessingQueue 清空本轮临时队列,供“新一轮执行开始”时调用。
func ResetTaskProcessingQueue(state *ScheduleState) {
if state == nil {
return
}
state.RuntimeQueue = nil
}
// ReplaceTaskProcessingQueue 用新的任务 ID 列表覆盖队列。
//
// 步骤化说明:
// 1. 先重置队列,避免上一次处理结果残留;
// 2. 对输入任务 ID 去重,防止 LLM 重复筛选造成同任务重复入队;
// 3. 不自动弹出当前任务,保持“显式 queue_pop_head 才开始处理”的流程约束。
func ReplaceTaskProcessingQueue(state *ScheduleState, taskIDs []int) int {
queue := ensureTaskProcessingQueue(state)
if queue == nil {
return 0
}
queue.PendingTaskIDs = nil
queue.CurrentTaskID = 0
queue.CurrentAttempts = 0
queue.CompletedTaskIDs = nil
queue.SkippedTaskIDs = nil
queue.LastError = ""
return appendTaskIDsToQueue(state, taskIDs)
}
// appendTaskIDsToQueue 将任务追加到队列尾部并做去重,返回本次实际入队数量。
//
// 去重规则:
// 1. 与当前正在处理的任务去重;
// 2. 与 pending / completed / skipped 去重;
// 3. task_id<=0 直接忽略,避免无效数据污染队列。
func appendTaskIDsToQueue(state *ScheduleState, taskIDs []int) int {
queue := ensureTaskProcessingQueue(state)
if queue == nil || len(taskIDs) == 0 {
return 0
}
exists := make(map[int]struct{}, len(queue.PendingTaskIDs)+len(queue.CompletedTaskIDs)+len(queue.SkippedTaskIDs)+1)
if queue.CurrentTaskID > 0 {
exists[queue.CurrentTaskID] = struct{}{}
}
for _, id := range queue.PendingTaskIDs {
exists[id] = struct{}{}
}
for _, id := range queue.CompletedTaskIDs {
exists[id] = struct{}{}
}
for _, id := range queue.SkippedTaskIDs {
exists[id] = struct{}{}
}
added := 0
for _, id := range taskIDs {
if id <= 0 {
continue
}
if _, ok := exists[id]; ok {
continue
}
queue.PendingTaskIDs = append(queue.PendingTaskIDs, id)
exists[id] = struct{}{}
added++
}
return added
}
// popOrGetCurrentTaskID 返回当前可处理任务。
//
// 规则:
// 1. 若已有 CurrentTaskID直接复用保证 apply/skip 前不切换对象);
// 2. 若 current 为空且 pending 非空,则弹出队首并设为 current
// 3. 若队列为空,返回 0。
func popOrGetCurrentTaskID(state *ScheduleState) int {
queue := ensureTaskProcessingQueue(state)
if queue == nil {
return 0
}
if queue.CurrentTaskID > 0 {
return queue.CurrentTaskID
}
if len(queue.PendingTaskIDs) == 0 {
return 0
}
queue.CurrentTaskID = queue.PendingTaskIDs[0]
queue.PendingTaskIDs = queue.PendingTaskIDs[1:]
queue.CurrentAttempts = 0
queue.LastError = ""
return queue.CurrentTaskID
}
// markCurrentTaskCompleted 将 current 任务标记为完成并清空 current。
func markCurrentTaskCompleted(state *ScheduleState) {
queue := ensureTaskProcessingQueue(state)
if queue == nil || queue.CurrentTaskID <= 0 {
return
}
queue.CompletedTaskIDs = append(queue.CompletedTaskIDs, queue.CurrentTaskID)
queue.CurrentTaskID = 0
queue.CurrentAttempts = 0
queue.LastError = ""
}
// markCurrentTaskSkipped 将 current 任务标记为跳过并清空 current。
func markCurrentTaskSkipped(state *ScheduleState) {
queue := ensureTaskProcessingQueue(state)
if queue == nil || queue.CurrentTaskID <= 0 {
return
}
queue.SkippedTaskIDs = append(queue.SkippedTaskIDs, queue.CurrentTaskID)
queue.CurrentTaskID = 0
queue.CurrentAttempts = 0
queue.LastError = ""
}
// bumpCurrentTaskAttempt 记录 current 任务一次失败尝试。
func bumpCurrentTaskAttempt(state *ScheduleState, errText string) {
queue := ensureTaskProcessingQueue(state)
if queue == nil || queue.CurrentTaskID <= 0 {
return
}
queue.CurrentAttempts++
queue.LastError = errText
}
// cloneTaskProcessingQueue 深拷贝 RuntimeQueue。
func cloneTaskProcessingQueue(src *TaskProcessingQueue) *TaskProcessingQueue {
if src == nil {
return nil
}
dst := &TaskProcessingQueue{
CurrentTaskID: src.CurrentTaskID,
CurrentAttempts: src.CurrentAttempts,
LastError: src.LastError,
}
if len(src.PendingTaskIDs) > 0 {
dst.PendingTaskIDs = append([]int(nil), src.PendingTaskIDs...)
}
if len(src.CompletedTaskIDs) > 0 {
dst.CompletedTaskIDs = append([]int(nil), src.CompletedTaskIDs...)
}
if len(src.SkippedTaskIDs) > 0 {
dst.SkippedTaskIDs = append([]int(nil), src.SkippedTaskIDs...)
}
return dst
}

View File

@@ -0,0 +1,154 @@
package schedule
// DayMapping maps a day_index to a real (week, day_of_week) coordinate.
type DayMapping struct {
DayIndex int `json:"day_index"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
}
// ScheduleWindow defines the planning window.
type ScheduleWindow struct {
TotalDays int `json:"total_days"`
DayMapping []DayMapping `json:"day_mapping"`
}
// TaskSlot is a compressed time slot using day_index and section range.
type TaskSlot struct {
Day int `json:"day"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
}
// TaskClassMeta 是任务类级别的调度与认知画像元数据。
//
// 职责边界:
// 1. 负责向 LLM 暴露会影响粗排与主动优化判断的高价值字段;
// 2. 不负责暴露数据库内部细节,也不承载 task_item 级别的数据;
// 3. 这些字段会被 prompt、analyze_health、analyze_rhythm 共同消费,因此要保持轻量且稳定。
type TaskClassMeta struct {
ID int `json:"id"`
Name string `json:"name"`
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
TotalSlots int `json:"total_slots"` // 该任务类总时段预算
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
ExcludedDaysOfWeek []int `json:"excluded_days_of_week"` // 排除的星期几1-7空=无限制)
StartDate string `json:"start_date,omitempty"` // 排程起始日期YYYY-MM-DD
EndDate string `json:"end_date,omitempty"` // 排程截止日期YYYY-MM-DD
SubjectType string `json:"subject_type,omitempty"` // "quantitative" | "memory" | "reading" | "mixed"
DifficultyLevel string `json:"difficulty_level,omitempty"`
CognitiveIntensity string `json:"cognitive_intensity,omitempty"`
}
// ScheduleTask is a unified task representation in the tool state.
// It merges existing schedules (from schedule_events) and pending tasks (from task_items)
// into one flat list that the tool layer operates on.
type ScheduleTask struct {
StateID int `json:"state_id"`
Source string `json:"source"` // "event" | "task_item"
SourceID int `json:"source_id"` // ScheduleEvent.ID or TaskClassItem.ID
Name string `json:"name"`
Category string `json:"category"` // e.g. "课程", "学习", "作业"
Status string `json:"status"` // "existing" | "suggested" | "pending"
Locked bool `json:"locked"`
// Existing / suggested task: compressed slot ranges. Pending task: nil until placed.
Slots []TaskSlot `json:"slots,omitempty"`
// Pending / suggested task: required consecutive slot count.
Duration int `json:"duration,omitempty"`
// source=task_item only: TaskClass.ID用于反查任务类约束。
TaskClassID int `json:"task_class_id,omitempty"`
// source=task_item only: 任务在所属任务类内的稳定顺序。
// 该字段只用于写工具层的“同任务类内部顺序约束”,不直接暴露给 LLM 做决策。
TaskOrder int `json:"task_order,omitempty"`
// source=task_item only: TaskClass.ID for category lookup (internal alias).
CategoryID int `json:"category_id,omitempty"`
// source=event only: whether this slot allows embedding other tasks.
CanEmbed bool `json:"can_embed,omitempty"`
// Embed relationships (resolved after all tasks are loaded).
EmbeddedBy *int `json:"embedded_by,omitempty"` // host: which state_id is embedded into me
EmbedHost *int `json:"embed_host,omitempty"` // guest: which state_id's slot I'm embedded into
// Internal: not exposed to LLM, used for flush/diff logic.
EventType string `json:"event_type,omitempty"` // "course" | "task" (source=event only)
}
// ScheduleState is the full tool operation state.
type ScheduleState struct {
Window ScheduleWindow `json:"window"`
Tasks []ScheduleTask `json:"tasks"`
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束与语义画像,供 LLM 排课参考
// RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。
//
// 职责边界:
// 1. 负责承载 LLM 队列化微调时的运行态(待处理/当前处理/已完成/已跳过);
// 2. 只用于 agent 运行期,不参与数据库持久化;
// 3. 支持随 AgentStateSnapshot 一起快照,便于断线恢复后继续处理队首任务。
RuntimeQueue *TaskProcessingQueue `json:"runtime_queue,omitempty"`
}
// DayToWeekDay converts day_index to (week, day_of_week).
func (s *ScheduleState) DayToWeekDay(day int) (week, dayOfWeek int, ok bool) {
for _, m := range s.Window.DayMapping {
if m.DayIndex == day {
return m.Week, m.DayOfWeek, true
}
}
return 0, 0, false
}
// WeekDayToDay converts (week, day_of_week) to day_index.
func (s *ScheduleState) WeekDayToDay(week, dayOfWeek int) (day int, ok bool) {
for _, m := range s.Window.DayMapping {
if m.Week == week && m.DayOfWeek == dayOfWeek {
return m.DayIndex, true
}
}
return 0, false
}
// TaskByStateID finds a task by state_id. Returns nil if not found.
func (s *ScheduleState) TaskByStateID(stateID int) *ScheduleTask {
for i := range s.Tasks {
if s.Tasks[i].StateID == stateID {
return &s.Tasks[i]
}
}
return nil
}
// Clone returns a deep copy of the ScheduleState.
func (s *ScheduleState) Clone() *ScheduleState {
if s == nil {
return nil
}
clone := &ScheduleState{
Window: ScheduleWindow{
TotalDays: s.Window.TotalDays,
DayMapping: make([]DayMapping, len(s.Window.DayMapping)),
},
Tasks: make([]ScheduleTask, len(s.Tasks)),
TaskClasses: make([]TaskClassMeta, len(s.TaskClasses)),
}
copy(clone.Window.DayMapping, s.Window.DayMapping)
copy(clone.TaskClasses, s.TaskClasses)
for i, t := range s.Tasks {
clone.Tasks[i] = t
if t.Slots != nil {
clone.Tasks[i].Slots = make([]TaskSlot, len(t.Slots))
copy(clone.Tasks[i].Slots, t.Slots)
}
if t.EmbeddedBy != nil {
v := *t.EmbeddedBy
clone.Tasks[i].EmbeddedBy = &v
}
if t.EmbedHost != nil {
v := *t.EmbedHost
clone.Tasks[i].EmbedHost = &v
}
}
clone.RuntimeQueue = cloneTaskProcessingQueue(s.RuntimeQueue)
return clone
}

View File

@@ -0,0 +1,118 @@
package schedule
import "slices"
// 任务状态常量。
//
// 说明:
// 1. existing 表示“数据库里已经存在的已安排事实”,例如课程表事件、已持久化任务块;
// 2. suggested 表示“当前轮内存态里的建议落位”,来源可能是粗排结果,也可能是用户确认后的工具预排;
// 3. pending 表示“仍未落位的真实待安排任务”。
const (
TaskStatusExisting = "existing"
TaskStatusSuggested = "suggested"
TaskStatusPending = "pending"
)
// IsPendingTask 判断任务是否属于“真实待安排”状态。
//
// 并行迁移说明:
// 1. 只有 pending 且没有 Slots才视为真正未落位
// 2. 旧快照里可能存在“pending 但已有 Slots”的粗排遗留形态这类任务不应继续算作待安排
// 3. 这样可以在不强制清洗旧快照的前提下先把新旧语义统一到“pending=无落位”。
func IsPendingTask(task ScheduleTask) bool {
return task.Status == TaskStatusPending && len(task.Slots) == 0
}
// IsSuggestedTask 判断任务是否属于“建议落位 / 可优化”状态。
//
// 并行迁移说明:
// 1. 新语义使用显式 suggested 状态;
// 2. 兼容旧 rough_build 快照pending + Slots 视为 suggested
// 3. 兼容旧 place 快照existing + source=task_item + Duration>0 + Slots 视为 suggested。
func IsSuggestedTask(task ScheduleTask) bool {
if len(task.Slots) == 0 {
return false
}
if task.Status == TaskStatusSuggested {
return true
}
if task.Status == TaskStatusPending {
return true
}
if task.Status == TaskStatusExisting && task.Source == "task_item" && task.Duration > 0 {
return true
}
return false
}
// IsExistingTask 判断任务是否属于“已确定事实层”。
//
// 说明:
// 1. 这里会主动排除 suggested 兼容形态,避免旧快照里的 existing+Duration>0 被误当成已确定任务;
// 2. 这样 get_overview 等工具才能稳定区分”事实层 existing”和”建议层 suggested”。
func IsExistingTask(task ScheduleTask) bool {
return task.Status == TaskStatusExisting && !IsSuggestedTask(task)
}
// IsPlacedTask 判断任务当前是否已经拥有可操作的落位。
//
// 说明:
// 1. existing 和 suggested 都属于“已落位”;
// 2. pending 只有在并行迁移兼容形态pending + Slots才会被 IsSuggestedTask 吸收进来。
func IsPlacedTask(task ScheduleTask) bool {
return IsExistingTask(task) || IsSuggestedTask(task)
}
// IsTaskInRequestedClassScope 判断 task_item 是否属于“本轮请求涉及的任务类范围”。
//
// 说明:
// 1. task_class_ids 为空时,视为不做范围裁剪,统一返回 true
// 2. 仅 source=task_item 才有 task_class_id 语义event 不参与该判断;
// 3. 迁移期若 task_item 缺失 TaskClassID则在有显式 scope 时按“不在范围内”处理,
// 避免把域外 pending 误混进本轮粗排/微调。
func IsTaskInRequestedClassScope(task ScheduleTask, taskClassIDs []int) bool {
if len(taskClassIDs) == 0 {
return true
}
if task.Source != "task_item" {
return false
}
return task.TaskClassID > 0 && slices.Contains(taskClassIDs, task.TaskClassID)
}
// FilterScheduleStateForTaskClassScope 按“本轮请求的任务类范围”裁剪工具态里的域外 pending。
//
// 步骤说明:
// 1. existing / suggested 一律保留,因为它们已经是事实层或建议层落位,会参与冲突判断;
// 2. 仅移除“域外真实 pending”避免粗排校验和读工具把别的任务类误算进来
// 3. TaskClasses 元数据也同步按 scope 裁剪,避免 prompt/工具读到无关约束;
// 4. 这里做就地裁剪,调用方无需再维护第二份 scoped state。
func FilterScheduleStateForTaskClassScope(state *ScheduleState, taskClassIDs []int) {
if state == nil || len(taskClassIDs) == 0 {
return
}
filteredTasks := make([]ScheduleTask, 0, len(state.Tasks))
for _, task := range state.Tasks {
if !IsPendingTask(task) {
filteredTasks = append(filteredTasks, task)
continue
}
if IsTaskInRequestedClassScope(task, taskClassIDs) {
filteredTasks = append(filteredTasks, task)
}
}
state.Tasks = filteredTasks
if len(state.TaskClasses) == 0 {
return
}
filteredMetas := make([]TaskClassMeta, 0, len(state.TaskClasses))
for _, meta := range state.TaskClasses {
if slices.Contains(taskClassIDs, meta.ID) {
filteredMetas = append(filteredMetas, meta)
}
}
state.TaskClasses = filteredMetas
}

View File

@@ -0,0 +1,256 @@
package schedule
import (
"fmt"
"sort"
"strings"
)
// ==================== 写工具专用辅助函数 ====================
// ==================== 校验函数 ====================
// validateDay 校验 day 是否在规划窗口范围内。
func validateDay(state *ScheduleState, day int) error {
if day < 1 || day > state.Window.TotalDays {
return fmt.Errorf("第%d天不在规划窗口范围内1-%d", day, state.Window.TotalDays)
}
return nil
}
// validateSlotRange 校验时段范围是否合法1-12start <= end
func validateSlotRange(start, end int) error {
if start < 1 {
return fmt.Errorf("起始时段 %d 不能小于1", start)
}
if end > 12 {
return fmt.Errorf("结束时段 %d 不能大于12", end)
}
if start > end {
return fmt.Errorf("起始时段 %d 不能大于结束时段 %d", start, end)
}
return nil
}
// checkLocked 检查任务是否被锁定。锁定任务不可移动/交换/移除。
func checkLocked(task ScheduleTask) error {
if task.Locked {
return fmt.Errorf("[%d]%s 是固定课程,不可操作", task.StateID, task.Name)
}
return nil
}
// ==================== 冲突检测 ====================
// findConflict 查找指定范围 [start, end] 内是否有冲突。
// 排除 excludeStateIDs 中的任务(用于 move/swap 排除自身旧位置)。
// 可嵌入宿主can_embed=true不算冲突——嵌入场景由 place 单独处理。
// 返回第一个冲突任务,无冲突返回 nil。
func findConflict(state *ScheduleState, day, start, end int, excludeStateIDs ...int) *ScheduleTask {
// 构建排除集合
exclude := make(map[int]bool, len(excludeStateIDs))
for _, id := range excludeStateIDs {
exclude[id] = true
}
for i := range state.Tasks {
t := &state.Tasks[i]
// 排除指定任务
if exclude[t.StateID] {
continue
}
// 可嵌入宿主不算冲突
if t.CanEmbed {
continue
}
// 嵌入任务与宿主共享时段,不算独立冲突
if t.EmbedHost != nil {
continue
}
// 只检查已安排的任务
if len(t.Slots) == 0 {
continue
}
for _, slot := range t.Slots {
if slot.Day == day {
// 检查范围是否有交集:[start,end] ∩ [slot.SlotStart,slot.SlotEnd]
if start <= slot.SlotEnd && end >= slot.SlotStart {
return t
}
}
}
}
return nil
}
// findEmbedHost 查找指定范围 [start, end] 内是否有可嵌入的宿主。
// 条件can_embed=true 且未被嵌入embedded_by == nil
// 返回第一个匹配的宿主,无匹配返回 nil。
func findEmbedHost(state *ScheduleState, day, start, end int) *ScheduleTask {
for i := range state.Tasks {
t := &state.Tasks[i]
if !t.CanEmbed || t.EmbeddedBy != nil {
continue
}
for _, slot := range t.Slots {
if slot.Day == day {
// 完全包含在宿主时段内才能嵌入
if start >= slot.SlotStart && end <= slot.SlotEnd {
return t
}
}
}
}
return nil
}
// ==================== 计算辅助 ====================
// taskDuration 计算任务所有 Slots 的总时段数。
// 如 Slots = [{1,1,2}, {3,1,2}] → 总时长 = 2+2 = 4。
// 用于 swap 时比较两个任务的时长是否一致。
func taskDuration(task ScheduleTask) int {
total := 0
for _, slot := range task.Slots {
total += slot.SlotEnd - slot.SlotStart + 1
}
return total
}
// countPending 统计当前 state 中“真实待安排”任务数量。
//
// 说明:
// 1. 这里只统计 pending 且无 Slots 的任务;
// 2. 旧快照里 pending+Slots 会被 suggested 兼容层吸收,不再算入待安排。
func countPending(state *ScheduleState) int {
count := 0
for i := range state.Tasks {
if IsPendingTask(state.Tasks[i]) {
count++
}
}
return count
}
// ==================== 任务时段辅助 ====================
// formatDayLabel 将 day_index 格式化为“第N天(星期X)”。
//
// 说明:
// 1. 这是工具层统一的“星期数展示口径”,避免各工具各自拼接导致输出不一致;
// 2. 当 DayMapping 可用时,追加 weekday 数字1~7
// 3. 若 DayMapping 缺失或异常退回原始“第N天”保证工具输出稳定。
func formatDayLabel(state *ScheduleState, day int) string {
base := fmt.Sprintf("第%d天", day)
if state == nil {
return base
}
_, dayOfWeek, ok := state.DayToWeekDay(day)
if !ok || dayOfWeek < 1 || dayOfWeek > 7 {
return base
}
return fmt.Sprintf("%s(星期%d)", base, dayOfWeek)
}
// formatDaySlotLabel 将“天 + 时段”拼成统一格式。
func formatDaySlotLabel(state *ScheduleState, day, slotStart, slotEnd int) string {
return fmt.Sprintf("%s第%s", formatDayLabel(state, day), formatSlotRange(slotStart, slotEnd))
}
// formatTaskSlotsBrief 将任务的时段列表格式化为简短描述。
// 如 "第1天(1-2节) 第4天(3-4节)"。
func formatTaskSlotsBrief(slots []TaskSlot) string {
return formatTaskSlotsBriefWithState(nil, slots)
}
// formatTaskSlotsBriefWithState 在时段描述里补齐星期数。
func formatTaskSlotsBriefWithState(state *ScheduleState, slots []TaskSlot) string {
parts := make([]string, 0, len(slots))
for _, slot := range slots {
parts = append(parts, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd))
}
return strings.Join(parts, " ")
}
// collectAffectedDays 从旧位置和新位置中收集所有涉及的天(去重排序)。
func collectAffectedDays(oldSlots, newSlots []TaskSlot) []int {
days := make(map[int]bool)
for _, s := range oldSlots {
days[s.Day] = true
}
for _, s := range newSlots {
days[s.Day] = true
}
return sortedKeys(days)
}
// collectAffectedDaysFromSlots 从单个 slot 列表中收集涉及的天。
func collectAffectedDaysFromSlots(slots []TaskSlot) []int {
days := make(map[int]bool)
for _, s := range slots {
days[s.Day] = true
}
return sortedKeys(days)
}
// sortedKeys 将 map 的 key 排序后返回。
func sortedKeys(m map[int]bool) []int {
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
return keys
}
// uniqueSorted 对 int 切片去重并排序。
func uniqueSorted(s []int) []int {
seen := make(map[int]bool)
result := make([]int, 0, len(s))
for _, v := range s {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
sort.Ints(result)
return result
}
// ==================== 输出格式化 ====================
// formatDayOccupancy 格式化某天的占用摘要。
// 如 "第5天当前占用[3]复习线代(1-3节)占用3/12。"
// 如 "第4天当前占用0/12。"(空天)
func formatDayOccupancy(state *ScheduleState, day int) string {
tasks := getTasksOnDay(state, day)
occupied := countDayOccupied(state, day)
dayLabel := formatDayLabel(state, day)
if len(tasks) == 0 {
return fmt.Sprintf("%s当前占用0/12。", dayLabel)
}
parts := make([]string, 0, len(tasks))
for _, td := range tasks {
label := formatTaskLabel(*td.task)
parts = append(parts, fmt.Sprintf("%s(%s)", label, formatSlotRange(td.slotStart, td.slotEnd)))
}
return fmt.Sprintf("%s当前占用%s占用%d/12。", dayLabel, strings.Join(parts, " "), occupied)
}
// formatFreeHint 格式化某天的空闲时段提示。
// 如 "空闲时段第5-12节。"
// 无空闲时返回空字符串。
func formatFreeHint(state *ScheduleState, day int) string {
ranges := findFreeRangesOnDay(state, day)
if len(ranges) == 0 {
return ""
}
parts := make([]string, 0, len(ranges))
for _, r := range ranges {
parts = append(parts, formatSlotRange(r.slotStart, r.slotEnd))
}
return fmt.Sprintf("空闲时段:%s。", strings.Join(parts, "、"))
}

View File

@@ -0,0 +1,452 @@
package schedule
import (
"fmt"
"strings"
)
// ==================== 写工具LLM 通过这些函数修改日程状态 ====================
// 所有写工具:
// - 只修改内存中的 ScheduleState不直接写库
// - 先校验后修改,校验失败则 state 不变,返回错误信息
// - 返回自然语言描述变更结果 + 涉及天的占用摘要
// MoveRequest 是 BatchMove 的单条移动请求。
type MoveRequest struct {
TaskID int `json:"task_id"`
NewDay int `json:"new_day"`
NewSlotStart int `json:"new_slot_start"`
}
const (
// maxBatchMoveSize 是 batch_move 的安全上限。
//
// 设计说明:
// 1. 旧链路中 batch_move 容易因组合冲突导致“整批回滚 + 连续重试”;
// 2. 先把批量规模限制在 2作为止血策略降低一次决策的冲突面
// 3. 更大规模的调整应优先走队列化逐项处理queue_pop_head + queue_apply_head_move
maxBatchMoveSize = 2
)
// ==================== Place ====================
// Place 将一个待安排任务预排到指定位置。
// taskID 必须是真实 pending无 Slots状态的任务。
// 如果目标位置有可嵌入宿主can_embed=true 且未被嵌入),自动走嵌入逻辑。
func Place(state *ScheduleState, taskID, day, slotStart int) string {
// 1. 查找任务。
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("放置失败任务ID %d 不存在。", taskID)
}
// 2. 校验状态。
// 2.1 只有“真实 pending”才允许 place
// 2.2 suggested / existing 都说明任务已经有落位,继续 place 会破坏当前方案语义;
// 2.3 旧快照里的 pending+Slots 也会被 IsPendingTask 排除,避免重复补排。
if !IsPendingTask(*task) {
return fmt.Sprintf("放置失败:[%d]%s 不是待安排任务,无法放置。", task.StateID, task.Name)
}
// 3. 计算目标范围并校验。
slotEnd := slotStart + task.Duration - 1
if err := validateDay(state, day); err != nil {
return fmt.Sprintf("放置失败:%s", err.Error())
}
if err := validateSlotRange(slotStart, slotEnd); err != nil {
return fmt.Sprintf("放置失败:%s", err.Error())
}
if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}); err != nil {
return fmt.Sprintf("放置失败:%s", err.Error())
}
// 4. 冲突检测。
conflict := findConflict(state, day, slotStart, slotEnd)
if conflict != nil {
// 锁定任务的冲突给出特殊提示。
if conflict.Locked {
return fmt.Sprintf("放置失败:%s已被 [%d]%s固定占用。\n%s\n%s",
formatDaySlotLabel(state, day, slotStart, slotEnd), conflict.StateID, conflict.Name,
formatDayOccupancy(state, day), formatFreeHint(state, day))
}
return fmt.Sprintf("放置失败:%s已被 [%d]%s 占用。\n%s\n%s",
formatDaySlotLabel(state, day, slotStart, slotEnd), conflict.StateID, conflict.Name,
formatDayOccupancy(state, day), formatFreeHint(state, day))
}
// 5. 检查是否有可嵌入宿主。
host := findEmbedHost(state, day, slotStart, slotEnd)
// 6. 执行变更。
if host != nil {
// 嵌入路径:设置双向嵌入关系,并把任务提升为 suggested。
guestID := task.StateID
hostID := host.StateID
task.EmbedHost = &hostID
host.EmbeddedBy = &guestID
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
task.Status = TaskStatusSuggested
return fmt.Sprintf("已将 [%d]%s 预排并嵌入到%s宿主[%d]%s。\n%s\n待安排任务剩余%d个。",
task.StateID, task.Name, formatDaySlotLabel(state, day, slotStart, slotEnd),
host.StateID, host.Name,
formatDayOccupancy(state, day), countPending(state))
}
// 普通路径:直接放置,并标记为 suggested。
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
task.Status = TaskStatusSuggested
return fmt.Sprintf("已将 [%d]%s 预排到%s。\n%s\n待安排任务剩余%d个。",
task.StateID, task.Name, formatDaySlotLabel(state, day, slotStart, slotEnd),
formatDayOccupancy(state, day), countPending(state))
}
// ==================== Move ====================
// Move 将一个已落位任务移动到新位置。
// taskID 仅允许 suggestedexisting/pending 都不允许移动。
func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
// 1. 查找任务。
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("移动失败任务ID %d 不存在。", taskID)
}
// 2. 校验状态。
if !IsSuggestedTask(*task) {
// 2.1 pending 任务尚未落位,应通过 place 安排;
// 2.2 existing 任务属于已安排事实层,不允许在 execute 微调里直接 move
// 2.3 仅 suggested 属于“本轮可微调建议落位”。
if IsPendingTask(*task) {
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
}
return fmt.Sprintf("移动失败:[%d]%s 当前为已安排existing任务不允许 move仅 suggested 任务可移动。", task.StateID, task.Name)
}
// 3. 校验锁定。
if err := checkLocked(*task); err != nil {
return fmt.Sprintf("移动失败:%s", err.Error())
}
// 4. 计算新范围。
duration := taskDuration(*task)
newSlotEnd := newSlotStart + duration - 1
if err := validateDay(state, newDay); err != nil {
return fmt.Sprintf("移动失败:%s", err.Error())
}
if err := validateSlotRange(newSlotStart, newSlotEnd); err != nil {
return fmt.Sprintf("移动失败:%s", err.Error())
}
if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}); err != nil {
return fmt.Sprintf("移动失败:%s", err.Error())
}
// 5. 冲突检测(排除自身)。
conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID)
if conflict != nil {
return fmt.Sprintf("移动失败:%s已被 [%d]%s 占用。\n%s\n%s",
formatDaySlotLabel(state, newDay, newSlotStart, newSlotEnd), conflict.StateID, conflict.Name,
formatDayOccupancy(state, newDay), formatFreeHint(state, newDay))
}
// 6. 记录旧位置。
oldSlots := make([]TaskSlot, len(task.Slots))
copy(oldSlots, task.Slots)
oldDesc := formatTaskSlotsBriefWithState(state, oldSlots)
// 7. 执行变更。
task.Slots = []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}
// 8. 收集涉及的天(去重)。
affectedDays := collectAffectedDays(oldSlots, task.Slots)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移至%s。\n",
task.StateID, task.Name, oldDesc, formatDaySlotLabel(state, newDay, newSlotStart, newSlotEnd)))
for _, d := range affectedDays {
sb.WriteString(formatDayOccupancy(state, d) + "\n")
}
return sb.String()
}
// ==================== Swap ====================
// Swap 交换两个已落位任务的位置。
// 两个任务都必须是 suggested / existing、非锁定、总时长相同。
func Swap(state *ScheduleState, taskAID, taskBID int) string {
// 1. 查找两个任务。
taskA := state.TaskByStateID(taskAID)
if taskA == nil {
return fmt.Sprintf("交换失败任务ID %d 不存在。", taskAID)
}
taskB := state.TaskByStateID(taskBID)
if taskB == nil {
return fmt.Sprintf("交换失败任务ID %d 不存在。", taskBID)
}
if taskAID == taskBID {
return "交换失败:不能与自己交换。"
}
// 2. 校验状态。
if !IsPlacedTask(*taskA) {
return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskA.StateID, taskA.Name)
}
if !IsPlacedTask(*taskB) {
return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskB.StateID, taskB.Name)
}
// 3. 校验锁定。
if err := checkLocked(*taskA); err != nil {
return fmt.Sprintf("交换失败:%s", err.Error())
}
if err := checkLocked(*taskB); err != nil {
return fmt.Sprintf("交换失败:%s", err.Error())
}
// 4. 校验时长。
durA := taskDuration(*taskA)
durB := taskDuration(*taskB)
if durA != durB {
return fmt.Sprintf("交换失败:[%d]%s 占%d个时段[%d]%s 占%d个时段时长不同无法直接交换。",
taskA.StateID, taskA.Name, durA, taskB.StateID, taskB.Name, durB)
}
// 5. 记录旧位置。
oldSlotsA := make([]TaskSlot, len(taskA.Slots))
copy(oldSlotsA, taskA.Slots)
oldSlotsB := make([]TaskSlot, len(taskB.Slots))
copy(oldSlotsB, taskB.Slots)
if err := validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{
taskAID: cloneScheduleTaskSlots(oldSlotsB),
taskBID: cloneScheduleTaskSlots(oldSlotsA),
}); err != nil {
return fmt.Sprintf("交换失败:%s", err.Error())
}
// 6. 交换 Slots。
taskA.Slots, taskB.Slots = taskB.Slots, taskA.Slots
// 7. 交换后冲突检测A 的新位置(原 B 的位置)是否有第三方冲突。
// 需要排除 B因为 B 现在在 A 的旧位置,已经被 swap 了)。
for _, slot := range taskA.Slots {
conflict := findConflict(state, slot.Day, slot.SlotStart, slot.SlotEnd, taskAID, taskBID)
if conflict != nil {
// 回滚
taskA.Slots = oldSlotsA
taskB.Slots = oldSlotsB
return fmt.Sprintf("交换失败:[%d]%s 的新位置%s与 [%d]%s 冲突。",
taskA.StateID, taskA.Name, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd),
conflict.StateID, conflict.Name)
}
}
for _, slot := range taskB.Slots {
conflict := findConflict(state, slot.Day, slot.SlotStart, slot.SlotEnd, taskAID, taskBID)
if conflict != nil {
// 回滚
taskA.Slots = oldSlotsA
taskB.Slots = oldSlotsB
return fmt.Sprintf("交换失败:[%d]%s 的新位置%s与 [%d]%s 冲突。",
taskB.StateID, taskB.Name, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd),
conflict.StateID, conflict.Name)
}
}
// 8. 成功输出。
affectedDays := collectAffectedDays(oldSlotsA, taskA.Slots)
affectedDays = append(affectedDays, collectAffectedDays(oldSlotsB, taskB.Slots)...)
affectedDays = uniqueSorted(affectedDays)
var sb strings.Builder
sb.WriteString("交换完成:\n")
sb.WriteString(fmt.Sprintf(" [%d]%s%s → %s\n",
taskA.StateID, taskA.Name,
formatTaskSlotsBriefWithState(state, oldSlotsA), formatTaskSlotsBriefWithState(state, taskA.Slots)))
sb.WriteString(fmt.Sprintf(" [%d]%s%s → %s\n",
taskB.StateID, taskB.Name,
formatTaskSlotsBriefWithState(state, oldSlotsB), formatTaskSlotsBriefWithState(state, taskB.Slots)))
for _, d := range affectedDays {
sb.WriteString(formatDayOccupancy(state, d) + "\n")
}
return sb.String()
}
// ==================== BatchMove ====================
// BatchMove 原子性地批量移动多个任务。
// moves 中每个 task_id 都必须是 suggestedexisting/pending 任一命中都会整批失败。
// 全部成功才生效,任一失败则完全回滚。
func BatchMove(state *ScheduleState, moves []MoveRequest) string {
if len(moves) == 0 {
return "批量移动失败:移动列表为空。"
}
if len(moves) > maxBatchMoveSize {
return fmt.Sprintf("批量移动失败:当前最多支持 %d 条移动请求。请改用队列化逐项处理queue_pop_head + queue_apply_head_move。", maxBatchMoveSize)
}
// 1. 全量校验阶段(不改 state
for i, m := range moves {
task := state.TaskByStateID(m.TaskID)
if task == nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求。", m.TaskID, i+1)
}
if !IsSuggestedTask(*task) {
// 1.1 保持与 Move 一致:批量移动仅允许 suggested
// 1.2 pending / existing 任一命中都应整批失败并回滚。
if IsPendingTask(*task) {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place第%d条移动请求。",
task.StateID, task.Name, i+1)
}
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为已安排existing任务不允许 move仅 suggested 任务可移动(第%d条移动请求。",
task.StateID, task.Name, i+1)
}
if err := checkLocked(*task); err != nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s第%d条移动请求", err.Error(), i+1)
}
duration := taskDuration(*task)
newSlotEnd := m.NewSlotStart + duration - 1
if err := validateDay(state, m.NewDay); err != nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s第%d条移动请求", err.Error(), i+1)
}
if err := validateSlotRange(m.NewSlotStart, newSlotEnd); err != nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s第%d条移动请求", err.Error(), i+1)
}
}
proposals := make(map[int][]TaskSlot, len(moves))
for _, m := range moves {
task := state.TaskByStateID(m.TaskID)
if task == nil {
continue
}
duration := taskDuration(*task)
proposals[m.TaskID] = []TaskSlot{{
Day: m.NewDay,
SlotStart: m.NewSlotStart,
SlotEnd: m.NewSlotStart + duration - 1,
}}
}
if err := validateLocalOrderBatchPlacement(state, proposals); err != nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s", err.Error())
}
// 2. 克隆 state在克隆上执行。
clone := state.Clone()
// 收集涉及的天。
affectedDays := make(map[int]bool)
// 3. 逐个应用 + 冲突检测。
for _, m := range moves {
task := clone.TaskByStateID(m.TaskID)
duration := taskDuration(*task)
newSlotEnd := m.NewSlotStart + duration - 1
// 记录旧位置涉及的天。
for _, slot := range task.Slots {
affectedDays[slot.Day] = true
}
// 冲突检测(在 clone 的中间状态上,排除自身)。
conflict := findConflict(clone, m.NewDay, m.NewSlotStart, newSlotEnd, m.TaskID)
if conflict != nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n冲突[%d]%s → %s该位置已被 [%d]%s 占用。",
task.StateID, task.Name, formatDaySlotLabel(state, m.NewDay, m.NewSlotStart, newSlotEnd),
conflict.StateID, conflict.Name)
}
// 应用移动。
task.Slots = []TaskSlot{{Day: m.NewDay, SlotStart: m.NewSlotStart, SlotEnd: newSlotEnd}}
affectedDays[m.NewDay] = true
}
// 4. 全部成功,将 clone 的数据写回原 state。
state.Tasks = clone.Tasks
// 5. 输出结果。
days := sortedKeys(affectedDays)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("批量移动完成,%d个任务全部成功\n", len(moves)))
for _, m := range moves {
task := state.TaskByStateID(m.TaskID)
duration := taskDuration(*task)
sb.WriteString(fmt.Sprintf(" [%d]%s → %s\n",
task.StateID, task.Name,
formatDaySlotLabel(state, m.NewDay, m.NewSlotStart, m.NewSlotStart+duration-1)))
}
for _, d := range days {
sb.WriteString(formatDayOccupancy(state, d) + "\n")
}
return sb.String()
}
// ==================== Unplace ====================
// Unplace 将一个已落位任务移除,恢复为待安排状态。
// taskID 允许是 suggested / existing但不能是真实 pending。
// 如果任务有嵌入关系,会自动清理双向指针。
func Unplace(state *ScheduleState, taskID int) string {
// 1. 查找任务。
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("移除失败任务ID %d 不存在。", taskID)
}
// 2. 校验状态。
if IsPendingTask(*task) {
return fmt.Sprintf("移除失败:[%d]%s 已经是待安排状态。", task.StateID, task.Name)
}
// 3. 校验锁定。
if err := checkLocked(*task); err != nil {
return fmt.Sprintf("移除失败:%s", err.Error())
}
// 4. 记录旧位置。
oldSlots := make([]TaskSlot, len(task.Slots))
copy(oldSlots, task.Slots)
oldDesc := formatTaskSlotsBriefWithState(state, oldSlots)
// 5. 清理嵌入关系。
// 如果该任务嵌入到了某个宿主上,清除宿主的 EmbeddedBy。
if task.EmbedHost != nil {
host := state.TaskByStateID(*task.EmbedHost)
if host != nil {
host.EmbeddedBy = nil
}
task.EmbedHost = nil
}
// 如果该任务是一个宿主且有嵌入客人,将客人也恢复为 pending。
if task.EmbeddedBy != nil {
guest := state.TaskByStateID(*task.EmbeddedBy)
if guest != nil {
// 先从嵌入时设置的 Slots 推算 Duration再清空。
// Place 嵌入时 guest.Slots 被设置为实际占用范围,这里从中恢复时长。
if len(guest.Slots) > 0 {
guest.Duration = taskDuration(*guest)
}
guest.EmbedHost = nil
guest.Slots = nil
guest.Status = TaskStatusPending
}
task.EmbeddedBy = nil
}
// 6. 执行变更。
task.Slots = nil
task.Status = TaskStatusPending
// 7. 收集涉及的天。
affectedDays := collectAffectedDaysFromSlots(oldSlots)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移除恢复为待安排状态。\n",
task.StateID, task.Name, oldDesc))
for _, d := range affectedDays {
sb.WriteString(formatDayOccupancy(state, d) + "\n")
}
sb.WriteString(fmt.Sprintf("待安排任务剩余:%d个。", countPending(state)))
return sb.String()
}