Files
smartmate/backend/newAgent/tools/SCHEDULE_TOOLS.md
Losita cdedd3c968 Version: 0.9.5.dev.260407
后端:
1.粗排链路收口(按 task_class_ids 精确加载 ScheduleState + 规划窗口抗脏数据)
  - 更新conv/schedule_provider.go:新增 LoadScheduleStateForTaskClasses;优先按本轮任务类加载窗口;buildWindowFromTaskClasses 改为逐条过滤坏日期,避免 DayMapping 被全量任务类污染
  - 更新model/state_store.go:新增 ScopedScheduleStateProvider 可选接口
  - 更新model/graph_run_state.go:EnsureScheduleState 首次加载时优先走 scoped provider,再做 scope 裁剪
2.粗排建议态语义统一(pending/existing → pending/suggested/existing)
  - 新建tools/status.go:统一 IsPendingTask / IsSuggestedTask / IsExistingTask / scope 过滤逻辑
  - 更新node/rough_build.go:粗排回写后任务显式转 suggested;pending 统计仅看“真实 pending”
  - 更新tools/state.go:ScheduleTask.Status/Slots/Duration 注释补齐 suggested 语义
  - 更新tools/read_helpers.go + read_tools.go:overview/list_tasks/task_info 支持 suggested 展示;占用计算按“已落位任务”统一处理
  - 更新tools/write_helpers.go + write_tools.go:place/move/swap/unplace 全量切到 suggested/existing/pending 新语义
  - 更新tools/registry.go + SCHEDULE_TOOLS.md:工具描述、参数枚举、文档口径同步到 suggested 语义
  - 更新conv/schedule_preview.go:预览层统一通过 IsSuggestedTask 输出 suggested,兼容旧快照
  - 更新service/agentsvc/agent_newagent.go:预览 debug 摘要改为 pending/suggested/existing 三态统计
3.粗排调试增强
  - 更新node/rough_build.go:新增 applied/day_mapping_miss/task_item_match_miss 统计及样本日志,便于排查 placement 未落回 state 的根因
前端:无 仓库:无
2026-04-07 23:58:00 +08:00

582 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 日程工具设计文档
> 本文档定义了 newAgent 日程调度场景下的工具层设计。
> 工具是 LLM 与日程数据之间的唯一边界——LLM 只能通过工具的输入/输出与日程交互,永远不直接接触原始数据。
---
## 1. 设计原则
1. **工具即边界**LLM 通过工具感知和修改日程,不直接接触 state 或数据库
2. **自然语言返回**:工具返回值为自然语言 + 轻结构缩进、列表LLM 直接理解
3. **只报事实,不做判断**:读工具只报当前真实状态,不附建议/推荐/假设;写工具只报变更后的事实
4. **操作前自动校验**:写工具在执行前自动检测冲突和锁定,失败时 state 不变
5. **State 内操作**:所有写工具只修改内存中的 state不直接写库整个方案完成后由 Confirm 节点统一写库
---
## 2. 索引体系
LLM 不接触真实的日期和星期,使用两级整数索引:
- **天索引day**:规划窗口内的天数编号,从 1 开始连续递增
- 例如规划窗口为第1周周三至第3周周一共13天编号为第1天~第13天
- 工具层负责 day ↔ 真实日期 的映射
- 规划窗口由 Plan 节点向用户确认,不明确时走 ask_user
- **时段索引slot**:每天内的节课编号,范围 1-12
- 标准节次1-2, 3-4, 5-6, 7-8, 9-10, 11-12共6个标准段
- 连堂课可能跨越1-33连堂、1-44连堂、9-124连堂
- 任务时段用 (slot_start, slot_end) 表示,例如 (1, 4) = 第1-4节
- **任务定位**day + slot_start + slot_end例如 (3, 1, 4) = 第3天第1-4节
---
## 3. State 数据结构
State 是工具层的操作对象,存在于内存中,不直接暴露给 LLM。
### 3.1 整体结构
```json
{
"window": {
"total_days": 13,
"day_mapping": [
{ "day_index": 1, "week": 5, "day_of_week": 1 },
{ "day_index": 2, "week": 5, "day_of_week": 2 },
{ "day_index": 3, "week": 5, "day_of_week": 3 },
{ "day_index": 4, "week": 5, "day_of_week": 4 },
{ "day_index": 5, "week": 5, "day_of_week": 5 },
{ "day_index": 6, "week": 5, "day_of_week": 6 },
{ "day_index": 7, "week": 5, "day_of_week": 7 },
{ "day_index": 8, "week": 6, "day_of_week": 1 },
{ "day_index": 9, "week": 6, "day_of_week": 2 },
{ "day_index": 10, "week": 6, "day_of_week": 3 },
{ "day_index": 11, "week": 6, "day_of_week": 4 },
{ "day_index": 12, "week": 6, "day_of_week": 5 },
{ "day_index": 13, "week": 6, "day_of_week": 6 }
]
},
"tasks": [
{
"state_id": 1,
"source": "event",
"source_id": 101,
"name": "高等数学",
"category": "课程",
"status": "existing",
"locked": true,
"slots": [
{ "day": 1, "slot_start": 1, "slot_end": 2 },
{ "day": 4, "slot_start": 1, "slot_end": 2 },
{ "day": 8, "slot_start": 1, "slot_end": 2 }
]
},
{
"state_id": 2,
"source": "event",
"source_id": 102,
"name": "思政(水课)",
"category": "课程",
"status": "existing",
"locked": false,
"can_embed": true,
"slots": [
{ "day": 2, "slot_start": 1, "slot_end": 2 }
]
},
{
"state_id": 3,
"source": "task_item",
"source_id": 201,
"name": "复习线代",
"category": "学习",
"status": "pending",
"duration": 3,
"category_id": 10
}
]
}
```
### 3.2 字段说明
**任务通用字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `state_id` | int | State 内唯一 ID递增工具层和 LLM 使用此 ID 交互 |
| `source` | string | 数据来源:`"event"` = 来自 ScheduleEvent`"task_item"` = 来自 TaskClassItem |
| `source_id` | int | 原表主键ScheduleEvent.ID 或 TaskClassItem.ID写库时用于反查 |
| `name` | string | 任务名称,来自 ScheduleEvent.Name 或 TaskClassItem.Content |
| `category` | string | 类别名,来自 TaskClass.Name如"课程"、"学习"、"作业" |
| `status` | string | `"existing"`(已安排/已确定)| `"suggested"`(已预排/可优化)| `"pending"`(待安排)|
| `locked` | bool | 是否锁定。推导规则ScheduleEvent.Type="course" 且 CanBeEmbed=false 时为 true |
| `slots` | array | 已安排任务的时段列表,每项含 day/slot_start/slot_end |
| `duration` | int | 待安排/已预排任务需要的连续时段数pending / suggested 任务常见) |
| `category_id` | int | 所属 TaskClass 的 ID仅 source=task_item 时有值) |
**嵌入任务相关字段(仅 can_embed=true 的任务):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `can_embed` | bool | 该时段是否允许嵌入其他任务,来自 ScheduleEvent.CanBeEmbedded |
| `embedded_by` | int | 被哪个 state_id 的任务嵌入(宿主视角) |
| `embed_host` | int | 嵌入到哪个 state_id 的时段里(嵌入任务视角) |
### 3.3 数据来源与映射
**existing 任务(从数据库加载):**
| State 字段 | 数据库来源 |
|-----------|-----------|
| source_id | ScheduleEvent.ID |
| name | ScheduleEvent.Name |
| category | ScheduleEvent.Type"course"→"课程""task"→取关联 TaskClass.Name |
| locked | ScheduleEvent.Type="course" 且 CanBeEmbedded=false |
| can_embed | ScheduleEvent.CanBeEmbedded |
| slots | 查 Schedule 表WHERE event_id=? AND week/day_of_week IN 窗口范围),按 section 连续段压缩 |
**pending 任务(从数据库加载):**
| State 字段 | 数据库来源 |
|-----------|-----------|
| source_id | TaskClassItem.ID |
| name | TaskClassItem.Content |
| category | 关联 TaskClass.Name通过 CategoryID |
| duration | 由 TaskClass.TotalSlots / Item 数量推算,或固定为 2 |
| category_id | TaskClassItem.CategoryID |
### 3.4 Section 压缩/解压
数据库中 Schedule 表逐节存储每节一条记录State 中压缩为连续范围:
```
DB 记录:
Schedule(event_id=101, week=5, day_of_week=1, section=1)
Schedule(event_id=101, week=5, day_of_week=1, section=2)
压缩为 State
{ "day": 1, "slot_start": 1, "slot_end": 2 }
```
反向操作(写库时):将 slot_start/slot_end 展开为逐条 Schedule 记录插入。
### 3.5 Day 映射
工具层通过 day_mapping 数组完成 day_index ↔ (week, day_of_week) 的双向转换:
- **读操作**:从 Schedule 表查到 (week=5, day_of_week=1, section=1),通过 day_mapping 反查 day_index=1
- **写操作**LLM 指定 day=3通过 day_mapping 查到 (week=5, day_of_week=3),用于构造 Schedule 记录
- **规划窗口**:由 Plan 节点确认范围,工具层初始化时生成 day_mapping
---
## 4. 读工具
### 4.1 get_overview
获取规划窗口的粗粒度总览,用于建立全局感知。
**入参:**
**返回示例:**
```
规划窗口共13天每天12个时段总计156个时段。
当前已占用48个空闲108个。待安排任务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
可嵌入时段第7天 [10]思政(1-2节)
待安排:[3]复习线代(需3时段) [7]写实验报告(需2时段) [9]小组讨论(需2时段)
```
---
### 4.2 query_range
查看某天(或某天某段)的细粒度占用详情。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| day | int | 是 | 天索引 |
| slot_start | int | 否 | 起始节次,不传则返回整天 |
| slot_end | int | 否 | 结束节次,不传则返回整天 |
**返回示例(查整天):**
```
第4天 全天:
第1-2节[1]高等数学(固定)
第3-4节[6]线代
第5-6节
第7-8节
第9-10节[8]程序设计
第11-12节
连续空闲区第5-8节(4时段)、第11-12节(2时段)
可嵌入第1-2节已有[1]高等数学(固定,不可嵌入)
```
**返回示例(查具体范围):**
```
第4天 第5-8节
第5节
第6节
第7节
第8节
该范围4个时段全部空闲。
```
---
### 4.3 find_free
查找满足指定连续时段长度的空闲位置。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| duration | int | 是 | 需要的连续时段数 |
| day | int | 否 | 限定某天,不传则搜索全部天 |
**返回示例:**
```
满足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]思政,当前无嵌入任务)
```
---
### 4.4 list_tasks
列出任务清单,可按类别和状态过滤。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| category | string | 否 | 过滤类别(对应 TaskClass.Name如"课程"、"学习" |
| status | string | 否 | existing / suggested / pending / all默认 all |
**返回示例(待安排):**
```
待安排任务共3个
[3]复习线代 — 需3个连续时段类别学习
[7]写实验报告 — 需2个连续时段类别作业
[9]小组讨论 — 需2个连续时段类别学习
```
**返回示例(全部):**
```
共9个任务已安排6个待安排3个。
已安排:
[1]高等数学(课程,固定) — 第1天(1-2节) 第4天(1-2节) 第8天(1-2节)
[2]英语(课程) — 第1天(3-4节) 第6天(1-2节)
[4]体育(课程) — 第1天(5-6节)
[5]物理(课程) — 第2天(3-4节) 第8天(3-4节)
[6]线代(学习) — 第4天(3-4节)
[8]程序设计(课程) — 第4天(9-10节)
[10]思政(课程,可嵌入) — 第7天(1-2节)
待安排:
[3]复习线代(学习) — 需3时段
[7]写实验报告(作业) — 需2时段
[9]小组讨论(学习) — 需2时段
```
---
### 4.5 get_task_info
查询单个任务的详细信息。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_id | int | 是 | 任务 ID |
**返回示例(普通任务):**
```
[1]高等数学
类别:课程 | 状态:已安排(固定)
来源:课程表
占用时段:
第1天 第1-2节
第4天 第1-2节
第8天 第1-2节
```
**返回示例(可嵌入任务):**
```
[10]思政
类别:课程 | 状态:已安排
来源:课程表
可嵌入:是(允许在此时段嵌入其他任务)
占用时段:
第7天 第1-2节
当前嵌入任务:无
```
---
## 5. 写工具
### 5.1 place
将待安排任务预排到指定位置。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_id | int | 是 | 待安排任务的 ID |
| day | int | 是 | 目标天索引 |
| slot_start | int | 是 | 目标起始节次 |
**成功返回:**
```
已将 [3]复习线代 放到第5天第1-3节。
第5天当前占用[3]复习线代(1-3节)占用3/12。
待安排任务剩余2个。
```
**失败返回(冲突):**
```
放置失败第5天第1-2节已被 [4]体育 占用。
第5天当前占用[4]体育(1-4节)占用4/12。空闲时段第5-12节。
```
**失败返回(状态错误):**
```
放置失败:[1]高等数学 不是待安排任务,无法放置。
```
**成功返回(嵌入到水课):**
```
已将 [7]写实验报告 嵌入到第7天第1-2节宿主[10]思政)。
第7天当前占用[10]思政(1-2节) [7]写实验报告(嵌入1-2节)占用2/12。
待安排任务剩余2个。
```
---
### 5.2 move
移动已落位任务到新位置。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_id | int | 是 | 任务 ID |
| new_day | int | 是 | 目标天索引 |
| new_slot_start | int | 是 | 目标起始节次 |
**成功返回:**
```
已将 [6]线代 从第4天第3-4节移至第9天第1-2节。
第4天当前占用[1]高等数学(1-2节) [8]程序设计(9-10节)占用4/12。
第9天当前占用[6]线代(1-2节)占用2/12。
```
**失败返回(冲突):**
```
移动失败第9天第1-2节已被 [9]小组讨论 占用。
第9天当前占用[9]小组讨论(1-2节)占用2/12。空闲时段第3-12节。
```
**失败返回(锁定):**
```
移动失败:[1]高等数学 是固定课程,不可移动。
```
**失败返回(状态错误):**
```
移动失败:[3]复习线代 当前为待安排状态,请使用 place 放置。
```
---
### 5.3 swap
交换两个已落位任务的位置。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_a | int | 是 | 任务 A 的 ID |
| task_b | int | 是 | 任务 B 的 ID |
**成功返回:**
```
交换完成:
[2]英语第1天第3-4节 → 第6天第1-2节
[6]线代第6天第1-2节 → 第1天第3-4节
第1天当前占用[1]高等数学(1-2节) [6]线代(3-4节) [4]体育(5-6节)占用6/12。
第6天当前占用[2]英语(1-2节)占用2/12。
```
**失败返回(时长不匹配):**
```
交换失败:[5]物理 占4个时段[2]英语 占2个时段时长不同无法直接交换。
```
**失败返回(任一任务锁定):**
```
交换失败:[1]高等数学 是固定课程,不可交换。
```
---
### 5.4 batch_move
批量原子移动多个任务,要么全部成功,要么全部回滚。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| moves | array | 是 | 每项包含 task_id, new_day, new_slot_start |
**成功返回:**
```
批量移动完成3个任务全部成功
[2]英语 → 第3天第1-2节
[6]线代 → 第5天第3-4节
[8]程序设计 → 第9天第5-6节
第3天当前占用[2]英语(1-2节)占用2/12。
第5天当前占用[6]线代(3-4节)占用2/12。
第9天当前占用[8]程序设计(5-6节)占用2/12。
```
**失败返回:**
```
批量移动失败,全部回滚,无任何变更。
冲突:[6]线代 → 第5天第3-4节该位置已被 [3]复习线代(1-3节) 占用。
```
---
### 5.5 unplace
将已落位任务恢复为待安排状态。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_id | int | 是 | 任务 ID |
**成功返回:**
```
已将 [3]复习线代 从第5天第1-3节移除恢复为待安排状态。
第5天当前占用0/12。
待安排任务剩余1个。
```
**失败返回(锁定):**
```
移除失败:[1]高等数学 是固定课程,不可移除。
```
---
## 6. 公共规则
### 冲突检测
- 所有写操作执行前自动检测目标位置是否冲突
- 冲突时拒绝操作,返回冲突任务名称和占用节次
- state 保持不变
### 锁定保护
- locked=true 的任务move / swap / unplace 直接拒绝
- place 新任务到锁定时段同样拒绝
### 状态约束
- pending 任务只能 place不能 move / swap / unplace
- suggested 任务可以 move / swap / unplace
- existing 任务可以 move / swap / unplace
- 状态不符时返回明确错误信息
### 返回格式
- 返回值为自然语言 + 轻结构(缩进、列表)
- 占用信息始终附带每个任务的具体节次范围
- 读工具只报当前真实状态,不做假设
- 写工具只报变更后的事实,不附建议
### ID 规范
- LLM 可见的任务 ID 为 `state_id`(递增整数),不暴露 source/source_id
- `state_id` 由工具层在加载 state 时分配,不区分来源
- `source` + `source_id` 为内部字段,仅在写库时使用,不对 LLM 可见
### 嵌入任务规则
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
- 嵌入任务占位时不触发冲突检测(与宿主共存)
- `find_free` 返回结果中标注可嵌入时段,让 LLM 知道哪里可以叠加
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动
### 数据库交互
- State 初始化:从 Schedule + ScheduleEvent 加载 existing 任务,从 TaskClassItem 加载 pending 任务;粗排或工具预排成功后,任务转为 suggested
- State 落库Confirm 节点统一处理,将 state 变更转换为 Schedule/ScheduleEvent/TaskClassItem 的增删改
- 落库时使用 source + source_id 定位原记录,使用 day_mapping 将 day_index 转回 (week, day_of_week)
- 落库时将 (slot_start, slot_end) 展开为逐条 Schedule 记录