Version: 0.9.8.dev.260408
后端:
1.execute 上下文瘦身第一版落地(固定 4 消息骨架 + ReAct 窗口压缩 + JSON 输出约束)
- 新建 prompt/execute_context.go:
execute 阶段改为 message[0..3] 固定结构;
加入历史摘要、当轮 ReAct 绑定展示、同工具 observation 压缩(保留最新)与工具简表返回示例提示
- 更新 prompt/execute.go:
重写 plan/ReAct 执行提示词;
补齐“可做/不可做”约束;
统一严格 JSON 指令;
补充 tool_call.arguments/abort/speak 非空等格式护栏
- 更新 model/execute_contract.go:
新增 ExecuteDecision/ToolCallIntent 自定义 Unmarshal;
兼容空字符串占位与 tool_call.parameters→arguments 回退解析
- 更新 node/correction.go:
为 correction 注入 history kind 标记,避免被当作真实用户输入污染摘要
- 更新 node/execute.go:
补齐 continue/ask_user/confirm 的 speak 兜底;
移除工具结果写入前 3000 字截断
2.工具层微调语义重构(任务视角概览 + 首个空位查询 + 移动权限收紧)
- 更新 tools/read_tools.go:
get_overview 改为任务视角全量输出(课程仅占位统计);
新增 find_first_free(首个命中位 + 当日负载明细);
find_free 保留兼容别名;
list_tasks 增加 status/category 校验与空结果纠偏文案
- 更新 tools/registry.go:
注册 find_first_free;
find_free 改兼容别名;
同步 get_overview/list_tasks/move/batch_move 描述语义
- 更新 tools/write_tools.go:
move/batch_move 仅允许 suggested,existing/pending 明确拒绝并返回可读错误
- 更新 tools/SCHEDULE_TOOLS.md:
同步 get_overview/find_first_free/list_tasks/move/batch_move 的最新入参与返回示例
- 更新 prompt/plan.go:
读工具示例由 find_free 调整为 find_first_free
3.交接文档与阶段说明同步
- 更新 newAgent/HANDOFF_粗排修复与Prompt重构.md:
更新为 2026-04-08;
补充“最新增量交接”章节(当前主矛盾、P0/P1、验证清单)
- 更新 newAgent/阶段3_上下文瘦身设计.md:
同步 existing/suggested 的 move/batch_move 约束口径
- 更新 newAgent/Log.txt:
追加本轮 execute 调试日志快照
前端:无
仓库:无
This commit is contained in:
@@ -178,7 +178,12 @@ DB 记录:
|
||||
|
||||
### 4.1 get_overview
|
||||
|
||||
获取规划窗口的粗粒度总览,用于建立全局感知。
|
||||
获取规划窗口总览(任务视角,全量返回)。
|
||||
|
||||
行为约束:
|
||||
- 保留课程占位统计(例如“第1天:占2/12”),避免误判可用空间。
|
||||
- 每日明细只展开任务(非课程),课程不进入任务明细列表。
|
||||
- 在当前阶段(窗口通常不超过 30 天)直接全量返回,不做截断。
|
||||
|
||||
**入参:** 无
|
||||
|
||||
@@ -186,25 +191,19 @@ DB 记录:
|
||||
|
||||
```
|
||||
规划窗口共13天,每天12个时段,总计156个时段。
|
||||
当前已占用48个,空闲108个。待安排任务3个。
|
||||
当前已占用48个,空闲108个。课程占位条目7个(仅用于占位统计);任务条目:已安排(existing)1个、已预排(suggested)2个、待安排(pending)3个。
|
||||
|
||||
每日概况:
|
||||
第1天:占6/12 — [1]高等数学(1-2节) [2]英语(3-4节) [4]体育(5-6节)
|
||||
第2天:占2/12 — [5]物理(3-4节)
|
||||
第3天:占0/12
|
||||
第4天:占8/12 — [1]高等数学(1-2节) [6]线代(3-4节) [8]程序设计(9-10节)
|
||||
第5天:占0/12
|
||||
第6天:占2/12 — [2]英语(1-2节)
|
||||
第7天:占2/12 — [10]思政(1-2节,可嵌入)
|
||||
第8天:占4/12 — [1]高等数学(1-2节) [5]物理(3-4节)
|
||||
第9天:占0/12
|
||||
第10天:占0/12
|
||||
第11天:占0/12
|
||||
第12天:占0/12
|
||||
第13天:占0/12
|
||||
第1天:总占6/12(课程占6/12,任务占0/12) — 任务:无
|
||||
第2天:总占2/12(课程占2/12,任务占0/12) — 任务:无
|
||||
第3天:总占2/12(课程占0/12,任务占2/12) — 任务:[35]第一章随机事件与概率(suggested,第5-6节)
|
||||
第4天:总占4/12(课程占2/12,任务占2/12) — 任务:[36]第二章随机变量(suggested,第7-8节)
|
||||
...
|
||||
|
||||
可嵌入时段:第7天 [10]思政(1-2节)
|
||||
待安排:[3]复习线代(需3时段) [7]写实验报告(需2时段) [9]小组讨论(需2时段)
|
||||
任务清单(全量,已过滤课程):
|
||||
[35]第一章随机事件与概率 | 状态:suggested | 类别:概率论 | 时段:第3天(5-6节)
|
||||
[36]第二章随机变量 | 状态:suggested | 类别:概率论 | 时段:第4天(7-8节)
|
||||
[37]第三章多维随机变量 | 状态:pending | 类别:概率论 | 需2个连续时段
|
||||
```
|
||||
|
||||
---
|
||||
@@ -252,9 +251,12 @@ DB 记录:
|
||||
|
||||
---
|
||||
|
||||
### 4.3 find_free
|
||||
### 4.3 find_first_free
|
||||
|
||||
查找满足指定连续时段长度的空闲位置。
|
||||
按天顺序查找“首个可用位”(先纯空位,再可嵌入位),并返回该日详细信息。
|
||||
|
||||
兼容说明:
|
||||
- `find_free` 仍保留为兼容别名,行为与 `find_first_free` 完全一致。
|
||||
|
||||
**入参:**
|
||||
|
||||
@@ -266,18 +268,16 @@ DB 记录:
|
||||
**返回示例:**
|
||||
|
||||
```
|
||||
满足3个连续空闲时段的位置:
|
||||
|
||||
第2天 第5-8节(4时段连续空闲)
|
||||
第3天 第1-6节(6时段连续空闲)
|
||||
第3天 第7-12节(6时段连续空闲)
|
||||
第5天 第1-12节(12时段连续空闲)
|
||||
第6天 第3-5节(3时段连续空闲)
|
||||
第9天 第1-3节(3时段连续空闲)
|
||||
第10天 第5-7节(3时段连续空闲)
|
||||
|
||||
可嵌入位置(水课时段,可叠加任务):
|
||||
第7天 第1-2节([10]思政,当前无嵌入任务)
|
||||
首个可用位置:第5天第1-2节(可直接放置)。
|
||||
匹配条件:需要2个连续时段。
|
||||
当日负载:总占6/12(课程占2/12,任务占4/12)。
|
||||
当日任务明细(全量,已过滤课程):
|
||||
- [35]第一章随机事件与概率 | 状态:suggested | 类别:概率论 | 时段:第3-4节
|
||||
- [36]第二章随机变量 | 状态:suggested | 类别:概率论 | 时段:第7-8节
|
||||
当日连续空闲区:
|
||||
- 第1-2节(2时段连续空闲)
|
||||
- 第5-6节(2时段连续空闲)
|
||||
- 第9-12节(4时段连续空闲)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -290,8 +290,8 @@ DB 记录:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| category | string | 否 | 过滤类别(对应 TaskClass.Name,如"课程"、"学习") |
|
||||
| status | string | 否 | existing / suggested / pending / all,默认 all |
|
||||
| category | string | 否 | 过滤类别(对应 TaskClass.Name,如"课程"、"学习";不支持 task_class_ids 列表) |
|
||||
| status | string | 否 | existing / suggested / pending / all,默认 all(仅支持单值,不支持 `existing,suggested` 这类拼接) |
|
||||
|
||||
**返回示例(待安排):**
|
||||
|
||||
@@ -408,7 +408,7 @@ DB 记录:
|
||||
|
||||
### 5.2 move
|
||||
|
||||
移动已落位任务到新位置。
|
||||
移动已预排任务(仅 suggested)到新位置。
|
||||
|
||||
**入参:**
|
||||
|
||||
@@ -445,6 +445,10 @@ DB 记录:
|
||||
移动失败:[3]复习线代 当前为待安排状态,请使用 place 放置。
|
||||
```
|
||||
|
||||
```
|
||||
移动失败:[2]英语 当前为已安排(existing)任务,不允许 move;仅 suggested 任务可移动。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 swap
|
||||
@@ -484,7 +488,7 @@ DB 记录:
|
||||
|
||||
### 5.4 batch_move
|
||||
|
||||
批量原子移动多个任务,要么全部成功,要么全部回滚。
|
||||
批量原子移动多个任务(仅 suggested),要么全部成功,要么全部回滚。
|
||||
|
||||
**入参:**
|
||||
|
||||
@@ -553,7 +557,7 @@ DB 记录:
|
||||
### 状态约束
|
||||
- pending 任务只能 place,不能 move / swap / unplace
|
||||
- suggested 任务可以 move / swap / unplace
|
||||
- existing 任务可以 move / swap / unplace
|
||||
- existing 任务不能 move / batch_move(仅作已安排事实层)
|
||||
- 状态不符时返回明确错误信息
|
||||
|
||||
### 返回格式
|
||||
@@ -570,7 +574,7 @@ DB 记录:
|
||||
### 嵌入任务规则
|
||||
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
|
||||
- 嵌入任务占位时不触发冲突检测(与宿主共存)
|
||||
- `find_free` 返回结果中标注可嵌入时段,让 LLM 知道哪里可以叠加
|
||||
- `find_first_free` 返回首个命中位,并附当日详细负载;`find_free` 为兼容别名
|
||||
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
|
||||
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动
|
||||
|
||||
|
||||
@@ -13,12 +13,16 @@ import (
|
||||
// - 只报当前真实状态,不做建议/推荐/假设
|
||||
// - 不暴露 source、source_id、event_type 内部字段
|
||||
|
||||
// GetOverview 获取规划窗口的粗粒度总览,用于建立全局感知。
|
||||
// 无参数,返回整个窗口的占用统计 + 每日概况 + 可嵌入时段 + 待安排任务。
|
||||
// GetOverview 获取规划窗口总览(任务视角,全量)。
|
||||
//
|
||||
// 设计约束:
|
||||
// 1. 日内“总占用”保留课程占位影响,避免 LLM 误判可用空间;
|
||||
// 2. 明细层不展开课程列表,只展开任务(非课程)清单;
|
||||
// 3. 当前按“窗口不超过 30 天”场景直接全量返回,不做结果截断。
|
||||
func GetOverview(state *ScheduleState) string {
|
||||
totalSlots := state.Window.TotalDays * 12
|
||||
|
||||
// 1. 统计总占用时段数(排除嵌入任务,嵌入与宿主共享时段)。
|
||||
// 1. 统计总占用(含课程占位)与空闲。
|
||||
totalOccupied := 0
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
@@ -31,80 +35,47 @@ func GetOverview(state *ScheduleState) string {
|
||||
}
|
||||
totalFree := totalSlots - totalOccupied
|
||||
|
||||
// 2. 统计任务状态分布。
|
||||
existingCount := 0
|
||||
suggestedCount := 0
|
||||
pendingCount := 0
|
||||
// 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):
|
||||
pendingCount++
|
||||
taskPendingCount++
|
||||
case IsSuggestedTask(task):
|
||||
suggestedCount++
|
||||
taskSuggestedCount++
|
||||
case IsExistingTask(task):
|
||||
existingCount++
|
||||
taskExistingCount++
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("规划窗口共%d天,每天12个时段,总计%d个时段。\n", state.Window.TotalDays, totalSlots))
|
||||
sb.WriteString(fmt.Sprintf("当前已占用%d个,空闲%d个。已确定任务%d个,已预排任务%d个,待安排任务%d个。\n", totalOccupied, totalFree, existingCount, suggestedCount, pendingCount))
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"当前已占用%d个,空闲%d个。课程占位条目%d个(仅用于占位统计);任务条目:已安排(existing)%d个、已预排(suggested)%d个、待安排(pending)%d个。\n",
|
||||
totalOccupied, totalFree, courseExistingCount, taskExistingCount, taskSuggestedCount, taskPendingCount,
|
||||
))
|
||||
|
||||
// 3. 逐天概况。
|
||||
// 3. 逐天总览:保留课程占位计数,但只展示任务明细。
|
||||
sb.WriteString("\n每日概况:\n")
|
||||
for day := 1; day <= state.Window.TotalDays; day++ {
|
||||
sb.WriteString(buildOverviewDayLine(state, day) + "\n")
|
||||
sb.WriteString(buildTaskOnlyOverviewDayLine(state, day) + "\n")
|
||||
}
|
||||
|
||||
// 4. 可嵌入时段汇总(单独列出,方便 LLM 快速定位)。
|
||||
embeddable := getEmbeddableTasks(state)
|
||||
if len(embeddable) > 0 {
|
||||
sb.WriteString("\n可嵌入时段:")
|
||||
parts := make([]string, 0, len(embeddable))
|
||||
for _, t := range embeddable {
|
||||
for _, slot := range t.Slots {
|
||||
label := formatTaskLabel(*t)
|
||||
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("第%d天 %s(%s)", slot.Day, label, embedStatus))
|
||||
}
|
||||
}
|
||||
sb.WriteString(strings.Join(parts, ";") + "\n")
|
||||
}
|
||||
// 4. 任务清单全量展开(不截断)。
|
||||
sb.WriteString("\n任务清单(全量,已过滤课程):\n")
|
||||
sb.WriteString(buildTaskOnlyOverviewList(state))
|
||||
|
||||
// 5. 已预排任务汇总。
|
||||
if suggestedCount > 0 {
|
||||
sb.WriteString("已预排:")
|
||||
suggestedParts := make([]string, 0, suggestedCount)
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if IsSuggestedTask(*t) {
|
||||
suggestedParts = append(suggestedParts, fmt.Sprintf("[%d]%s(%s)", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots)))
|
||||
}
|
||||
}
|
||||
sb.WriteString(strings.Join(suggestedParts, " ") + "\n")
|
||||
}
|
||||
|
||||
// 6. 待安排任务汇总。
|
||||
if pendingCount > 0 {
|
||||
sb.WriteString("待安排:")
|
||||
pendingParts := make([]string, 0, pendingCount)
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if IsPendingTask(*t) {
|
||||
pendingParts = append(pendingParts, fmt.Sprintf("[%d]%s(需%d时段)", t.StateID, t.Name, t.Duration))
|
||||
}
|
||||
}
|
||||
sb.WriteString(strings.Join(pendingParts, " ") + "\n")
|
||||
}
|
||||
|
||||
// 7. 任务类约束(排课策略与限制)。
|
||||
// 5. 任务类约束(排课策略与限制)。
|
||||
if len(state.TaskClasses) > 0 {
|
||||
sb.WriteString("\n任务类约束(排课时请遵守):\n")
|
||||
for _, tc := range state.TaskClasses {
|
||||
@@ -226,12 +197,16 @@ func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) strin
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FindFree 查找满足指定连续时段长度的空闲位置。
|
||||
// duration 必填,day 选填(nil 表示搜索全部天)。
|
||||
// 返回所有 >= duration 的空闲连续区间 + 可嵌入位置。
|
||||
func FindFree(state *ScheduleState, duration int, day *int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("满足%d个连续空闲时段的位置:\n\n", duration))
|
||||
// FindFirstFree 查找首个可用空位,并返回该日详细信息。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 参数与旧 find_free 保持一致(duration/day);
|
||||
// 2. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策;
|
||||
// 3. 当前阶段按用户要求全量返回,不做文本截断。
|
||||
func FindFirstFree(state *ScheduleState, duration int, day *int) string {
|
||||
if duration <= 0 {
|
||||
return "查询失败:duration 必须大于 0。"
|
||||
}
|
||||
|
||||
// 1. 确定搜索范围。
|
||||
days := make([]int, 0)
|
||||
@@ -246,48 +221,262 @@ func FindFree(state *ScheduleState, duration int, day *int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 逐天查找满足条件的空闲区间。
|
||||
found := 0
|
||||
// 2. 按天从前往后寻找“首个可直接放置”的空位。
|
||||
for _, d := range days {
|
||||
freeRanges := findFreeRangesOnDay(state, d)
|
||||
for _, r := range freeRanges {
|
||||
rDur := r.slotEnd - r.slotStart + 1
|
||||
if rDur >= duration {
|
||||
sb.WriteString(fmt.Sprintf("第%d天 第%s(%d时段连续空闲)\n", d, formatSlotRange(r.slotStart, r.slotEnd), rDur))
|
||||
found++
|
||||
if rDur < duration {
|
||||
continue
|
||||
}
|
||||
slotStart := r.slotStart
|
||||
slotEnd := r.slotStart + duration - 1
|
||||
return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, false, nil)
|
||||
}
|
||||
}
|
||||
|
||||
if found == 0 {
|
||||
sb.WriteString("未找到满足条件的空闲时段。\n")
|
||||
}
|
||||
|
||||
// 3. 可嵌入位置单独列出(水课时段,可叠加任务)。
|
||||
embeddable := getEmbeddableTasks(state)
|
||||
if len(embeddable) > 0 {
|
||||
sb.WriteString("\n可嵌入位置(水课时段,可叠加任务):\n")
|
||||
for _, t := range embeddable {
|
||||
for _, slot := range t.Slots {
|
||||
// 检查是否在搜索范围内。
|
||||
if day != nil && slot.Day != *day {
|
||||
continue
|
||||
}
|
||||
embedStatus := "当前无嵌入任务"
|
||||
if t.EmbeddedBy != nil {
|
||||
guest := state.TaskByStateID(*t.EmbeddedBy)
|
||||
if guest != nil {
|
||||
embedStatus = fmt.Sprintf("已嵌入[%d]%s", guest.StateID, guest.Name)
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("第%d天 第%s([%d]%s,%s)\n", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd), t.StateID, t.Name, embedStatus))
|
||||
}
|
||||
// 3. 若没有纯空位,再尝试首个可嵌入宿主时段。
|
||||
for _, d := range days {
|
||||
host, slotStart, slotEnd := findFirstEmbeddablePosition(state, d, duration)
|
||||
if host != nil {
|
||||
return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, true, host)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 无可用位置时返回摘要,辅助 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("第%d天:最大连续空闲%d节\n", d, maxDur))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FindFree 是 find_first_free 的兼容别名。
|
||||
// 保留该入口可避免旧提示词和历史轨迹中的工具名失效。
|
||||
func FindFree(state *ScheduleState, duration int, day *int) string {
|
||||
return FindFirstFree(state, duration, day)
|
||||
}
|
||||
|
||||
// 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("首个可用位置:第%d天第%s(可嵌入宿主 [%d]%s)。\n",
|
||||
day, formatSlotRange(slotStart, slotEnd), host.StateID, host.Name))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("首个可用位置:第%d天第%s(可直接放置)。\n", day, formatSlotRange(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(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)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("第%d天:总占%d/12(课程占%d/12,任务占%d/12)", day, 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, formatTaskSlotsBrief(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
|
||||
}
|
||||
|
||||
// ListTasks 列出任务清单,可按类别和状态过滤。
|
||||
// category 选填(nil 不过滤),status 选填(nil 默认 "all")。
|
||||
// 输出按状态分组:已安排 -> 已预排 -> 待安排,组内按 stateID 升序。
|
||||
@@ -297,13 +486,25 @@ func ListTasks(state *ScheduleState, category, status *string) string {
|
||||
if status != nil {
|
||||
statusFilter = *status
|
||||
}
|
||||
statusFilter = strings.ToLower(strings.TrimSpace(statusFilter))
|
||||
if statusFilter == "" {
|
||||
statusFilter = "all"
|
||||
}
|
||||
if err := validateListTasksStatus(statusFilter); err != nil {
|
||||
return fmt.Sprintf("查询失败:%s", err.Error())
|
||||
}
|
||||
categoryFilter := ""
|
||||
if category != nil {
|
||||
categoryFilter = strings.TrimSpace(*category)
|
||||
}
|
||||
hasCategoryFilter := categoryFilter != ""
|
||||
|
||||
// 2. 过滤 + 分组。
|
||||
var existingTasks, suggestedTasks, pendingTasks []ScheduleTask
|
||||
for i := range state.Tasks {
|
||||
t := state.Tasks[i]
|
||||
// 类别过滤。
|
||||
if category != nil && t.Category != *category {
|
||||
if hasCategoryFilter && t.Category != categoryFilter {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -333,40 +534,119 @@ func ListTasks(state *ScheduleState, category, status *string) string {
|
||||
|
||||
// 4. 纯待安排模式:只输出待安排任务。
|
||||
if statusFilter == "pending" {
|
||||
if len(pendingTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatPendingList(pendingTasks)
|
||||
}
|
||||
|
||||
// 5. 纯已预排模式:只输出已预排任务。
|
||||
if statusFilter == "suggested" {
|
||||
if len(suggestedTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatSuggestedList(suggestedTasks)
|
||||
}
|
||||
|
||||
// 6. 纯已安排模式:只输出已安排任务。
|
||||
if statusFilter == "existing" {
|
||||
if len(existingTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatExistingList(existingTasks)
|
||||
}
|
||||
|
||||
// 7. 全部模式:统计 + 分组输出。
|
||||
total := len(existingTasks) + len(suggestedTasks) + len(pendingTasks)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("共%d个任务,已安排%d个,已预排%d个,待安排%d个。\n", total, len(existingTasks), len(suggestedTasks), len(pendingTasks)))
|
||||
sb.WriteString(fmt.Sprintf("共%d个任务,已安排(existing)%d个,已预排(suggested)%d个,待安排(pending)%d个。\n", total, len(existingTasks), len(suggestedTasks), len(pendingTasks)))
|
||||
|
||||
if len(existingTasks) > 0 {
|
||||
sb.WriteString("\n已安排:\n")
|
||||
sb.WriteString("\n已安排(existing):\n")
|
||||
sb.WriteString(formatExistingList(existingTasks))
|
||||
}
|
||||
if len(suggestedTasks) > 0 {
|
||||
sb.WriteString("\n已预排:\n")
|
||||
sb.WriteString("\n已预排(suggested):\n")
|
||||
sb.WriteString(formatSuggestedList(suggestedTasks))
|
||||
}
|
||||
if len(pendingTasks) > 0 {
|
||||
sb.WriteString("\n待安排:\n")
|
||||
sb.WriteString("\n待安排(pending):\n")
|
||||
sb.WriteString(formatPendingList(pendingTasks))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatListTasksEmptyResult 统一构造 list_tasks 空结果文案。
|
||||
//
|
||||
// 设计意图:
|
||||
// 1. 明确告诉模型“为什么为空”,避免把空字符串误解为工具异常或上下文缺失;
|
||||
// 2. 对常见误用 category=ID 列表给出直接纠偏提示,减少死循环重试。
|
||||
func formatListTasksEmptyResult(statusFilter, categoryFilter string) string {
|
||||
statusLabel := map[string]string{
|
||||
"all": "任意状态",
|
||||
"existing": "已安排(existing)",
|
||||
"suggested": "已预排(suggested)",
|
||||
"pending": "待安排(pending)",
|
||||
}
|
||||
target := statusLabel[statusFilter]
|
||||
if target == "" {
|
||||
target = statusFilter
|
||||
}
|
||||
|
||||
if strings.TrimSpace(categoryFilter) == "" {
|
||||
return fmt.Sprintf("查询结果为空:当前没有%s任务。", target)
|
||||
}
|
||||
if looksLikeTaskClassIDList(categoryFilter) {
|
||||
return fmt.Sprintf("查询结果为空:category=%q 未匹配到任务。category 参数按任务类名称匹配,不支持 task_class_ids 列表。", categoryFilter)
|
||||
}
|
||||
return fmt.Sprintf("查询结果为空:category=%q 下没有%s任务。", categoryFilter, target)
|
||||
}
|
||||
|
||||
// looksLikeTaskClassIDList 判断 category 文本是否像“逗号分隔的数字 ID 列表”。
|
||||
func looksLikeTaskClassIDList(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validateListTasksStatus 校验 list_tasks.status 的输入值。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责拦截非法 status,避免“静默返回 0 条”误导模型;
|
||||
// 2. 不负责自动拆分或容错纠偏(如 existing,suggested),统一要求调用方改成合法单值。
|
||||
func validateListTasksStatus(status string) error {
|
||||
// 1. status 已在调用方归一化为小写并去空格。
|
||||
// 2. 合法值仅允许 all / existing / suggested / pending。
|
||||
switch status {
|
||||
case "all", "existing", "suggested", "pending":
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 对最常见误用给出明确修复建议,避免模型继续循环错误调用。
|
||||
if strings.Contains(status, ",") {
|
||||
return fmt.Errorf("status 只支持单值 all/existing/suggested/pending,不支持 \"%s\"。如需同时查看 existing+suggested,请使用 all", status)
|
||||
}
|
||||
return fmt.Errorf("status=%q 非法,仅支持 all/existing/suggested/pending", status)
|
||||
}
|
||||
|
||||
// GetTaskInfo 查询单个任务的详细信息。
|
||||
// taskID 必填,为 state 内的 state_id。
|
||||
// 不存在时返回错误信息字符串。
|
||||
@@ -380,13 +660,13 @@ func GetTaskInfo(state *ScheduleState, taskID int) string {
|
||||
sb.WriteString(fmt.Sprintf("[%d]%s\n", task.StateID, task.Name))
|
||||
|
||||
// 1. 类别、状态、来源。
|
||||
statusLabel := "已安排"
|
||||
statusLabel := "已安排(existing)"
|
||||
if IsPendingTask(*task) {
|
||||
statusLabel = "待安排"
|
||||
statusLabel = "待安排(pending)"
|
||||
} else if IsSuggestedTask(*task) {
|
||||
statusLabel = "已预排"
|
||||
statusLabel = "已预排(suggested)"
|
||||
} else if task.Locked {
|
||||
statusLabel = "已安排(固定)"
|
||||
statusLabel = "已安排(existing,固定)"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("类别:%s | 状态:%s\n", task.Category, statusLabel))
|
||||
sb.WriteString(fmt.Sprintf("来源:%s\n", formatSourceName(task.Source)))
|
||||
|
||||
@@ -97,13 +97,13 @@ var writeTools = map[string]bool{
|
||||
|
||||
// ==================== 默认注册表 ====================
|
||||
|
||||
// NewDefaultRegistry 创建包含全部 10 个日程工具的注册表。
|
||||
// NewDefaultRegistry 创建默认日程工具注册表。
|
||||
func NewDefaultRegistry() *ToolRegistry {
|
||||
r := NewToolRegistry()
|
||||
|
||||
// --- 读工具 ---
|
||||
r.Register("get_overview",
|
||||
"获取规划窗口的粗粒度总览,包括每日占用、可嵌入时段和待安排任务。",
|
||||
"获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。",
|
||||
`{"name":"get_overview","parameters":{}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return GetOverview(state)
|
||||
@@ -122,20 +122,33 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("find_first_free",
|
||||
"查找首个满足时长条件的可用位置,并返回该日详细负载信息。duration 必填,day 选填(不填按天顺序搜索)。",
|
||||
`{"name":"find_first_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
duration, ok := argsInt(args, "duration")
|
||||
if !ok {
|
||||
return "查询失败:缺少必填参数 duration。"
|
||||
}
|
||||
return FindFirstFree(state, duration, argsIntPtr(args, "day"))
|
||||
},
|
||||
)
|
||||
|
||||
// 兼容别名:保留 find_free,避免旧历史轨迹中的工具调用失效。
|
||||
r.Register("find_free",
|
||||
"查找满足指定连续时段长度的空闲位置。duration 必填,day 选填(不填搜全部天)。",
|
||||
"兼容别名,行为同 find_first_free。",
|
||||
`{"name":"find_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
duration, ok := argsInt(args, "duration")
|
||||
if !ok {
|
||||
return "查询失败:缺少必填参数 duration。"
|
||||
}
|
||||
return FindFree(state, duration, argsIntPtr(args, "day"))
|
||||
return FindFirstFree(state, duration, argsIntPtr(args, "day"))
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("list_tasks",
|
||||
"列出任务清单,可按类别和状态过滤。category 选填,status 选填(默认 all,支持 existing/suggested/pending)。",
|
||||
"列出任务清单,可按类别和状态过滤。category 传任务类名称(非 ID 列表)可选,status 选填(默认 all,仅支持单值 all/existing/suggested/pending)。",
|
||||
`{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["all","existing","suggested","pending"]}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status"))
|
||||
@@ -176,7 +189,7 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
)
|
||||
|
||||
r.Register("move",
|
||||
"将一个已落位任务(existing 或 suggested)移动到新位置。task_id/new_day/new_slot_start 必填。",
|
||||
"将一个已预排任务(仅 suggested)移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。",
|
||||
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
taskID, ok := argsInt(args, "task_id")
|
||||
@@ -212,7 +225,7 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
)
|
||||
|
||||
r.Register("batch_move",
|
||||
"原子性批量移动多个任务,全部成功才生效。moves 数组必填。",
|
||||
"原子性批量移动多个任务(仅 suggested),全部成功才生效。若含 existing/pending 将整批失败回滚。moves 数组必填。",
|
||||
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
moves, err := argsMoveList(args)
|
||||
|
||||
@@ -92,7 +92,7 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||||
// ==================== Move ====================
|
||||
|
||||
// Move 将一个已落位任务移动到新位置。
|
||||
// taskID 允许是 suggested / existing,但不能是真实 pending。
|
||||
// taskID 仅允许 suggested;existing/pending 都不允许移动。
|
||||
func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||||
// 1. 查找任务。
|
||||
task := state.TaskByStateID(taskID)
|
||||
@@ -101,8 +101,14 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||||
}
|
||||
|
||||
// 2. 校验状态。
|
||||
if IsPendingTask(*task) {
|
||||
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
|
||||
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. 校验锁定。
|
||||
@@ -248,6 +254,7 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||||
// ==================== BatchMove ====================
|
||||
|
||||
// BatchMove 原子性地批量移动多个任务。
|
||||
// moves 中每个 task_id 都必须是 suggested;existing/pending 任一命中都会整批失败。
|
||||
// 全部成功才生效,任一失败则完全回滚。
|
||||
func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
if len(moves) == 0 {
|
||||
@@ -260,8 +267,14 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
if task == nil {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求)。", m.TaskID, i+1)
|
||||
}
|
||||
if IsPendingTask(*task) {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place(第%d条移动请求)。",
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user