Files
smartmate/backend/services/agent/tools/SCHEDULE_TOOLS.md
Losita d7184b776b 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 迁移面
2026-05-05 16:00:57 +08:00

755 lines
22 KiB
Markdown
Raw Permalink 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.
# 日程工具设计文档
> 本文档定义了 agent 日程调度场景下的工具层设计。
> 工具是 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
获取规划窗口总览(任务视角,全量返回)。
行为约束:
- 保留课程占位统计例如“第1天占2/12”避免误判可用空间。
- 每日明细只展开任务(非课程),课程不进入任务明细列表。
- 在当前阶段(窗口通常不超过 30 天)直接全量返回,不做截断。
**入参:**
**返回示例:**
```
规划窗口共13天每天12个时段总计156个时段。
当前已占用48个空闲108个。课程占位条目7个仅用于占位统计任务条目已安排(existing)1个、已预排(suggested)2个、待安排(pending)3个。
每日概况:
第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节)
...
任务清单(全量,已过滤课程):
[35]第一章随机事件与概率 | 状态:suggested | 类别:概率论 | 时段:第3天(5-6节)
[36]第二章随机变量 | 状态:suggested | 类别:概率论 | 时段:第4天(7-8节)
[37]第三章多维随机变量 | 状态:pending | 类别:概率论 | 需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 query_available_slots
查询候选坑位池(结构化返回):默认先返回“纯空位”,不足时再补“可嵌入位”。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| span / duration | int | 否 | 目标连续时段长度,默认 2 |
| limit | int | 否 | 返回候选上限,默认 12 |
| allow_embed | bool | 否 | 是否允许补可嵌入位,默认 true |
| day / day_start / day_end | int | 否 | 天级范围过滤(`day` 与区间互斥) |
| day_scope | string | 否 | `all` / `workday` / `weekend` |
| day_of_week | []int | 否 | 星期过滤1-7 |
| week / week_filter / week_from / week_to | int / []int | 否 | 周级过滤 |
| slot_type / slot_types | string / []string | 否 | `pure/empty/strict` 会强制只返回纯空位 |
| exclude_sections | []int | 否 | 排除节次1-12 |
| after_section / before_section | int | 否 | 只返回区间之后/之前的候选 |
| section_from + section_to | int | 否 | 精确节次区间查询(需同时提供) |
**返回示例:**
```json
{
"tool": "query_available_slots",
"count": 12,
"strict_count": 8,
"embedded_count": 4,
"fallback_used": true,
"day_scope": "all",
"day_of_week": [],
"week_filter": [12],
"week_from": 12,
"week_to": 12,
"span": 2,
"allow_embed": true,
"exclude_sections": [],
"slots": [
{
"day": 5,
"week": 12,
"day_of_week": 3,
"slot_start": 1,
"slot_end": 2,
"slot_type": "empty"
}
]
}
```
---
### 4.4 list_tasks
列出任务清单,可按类别和状态过滤。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| category | string | 否 | 过滤类别(对应 TaskClass.Name如"课程"、"学习";不支持 task_class_ids 列表) |
| status | string | 否 | existing / suggested / pending / all默认 all仅支持单值不支持 `existing,suggested` 这类拼接) |
**返回示例(待安排):**
```
待安排任务共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
移动已预排任务(仅 suggested到新位置。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 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 放置。
```
```
移动失败:[2]英语 当前为已安排existing任务不允许 move仅 suggested 任务可移动。
```
---
### 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
批量原子移动多个任务(仅 suggested**单次最多 2 条**),要么全部成功,要么全部回滚。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| moves | array | 是 | 每项包含 task_id, new_day, new_slot_start |
**成功返回:**
```
批量移动完成2个任务全部成功
[2]英语 → 第3天第1-2节
[6]线代 → 第5天第3-4节
第3天当前占用[2]英语(1-2节)占用2/12。
第5天当前占用[6]线代(3-4节)占用2/12。
```
**失败返回(超出上限):**
```
批量移动失败:当前最多支持 2 条移动请求。请改用队列化逐项处理queue_pop_head + queue_apply_head_move
```
**失败返回:**
```
批量移动失败,全部回滚,无任何变更。
冲突:[6]线代 → 第5天第3-4节该位置已被 [3]复习线代(1-3节) 占用。
```
---
### 5.5 unplace
将已落位任务恢复为待安排状态。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_id | int | 是 | 任务 ID |
**成功返回:**
```
已将 [3]复习线代 从第5天第1-3节移除恢复为待安排状态。
第5天当前占用0/12。
待安排任务剩余1个。
```
**失败返回(锁定):**
```
移除失败:[1]高等数学 是固定课程,不可移除。
```
---
### 5.6 min_context_switch
在给定任务集合内重排 suggested 任务,尽量把同类任务排成连续块,以减少上下文切换。
使用约束:
- 仅在用户明确说明“允许打乱顺序”时调用。
- 仅支持 suggested 且已落位任务。
- 工具只在传入集合内部重排,不会主动改动集合外任务。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_ids | array[int] | 是 | 参与重排的任务 ID 列表(至少 2 个) |
| task_id | int | 否 | 兼容单值参数,不建议新调用使用 |
**成功返回:**
```
最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。
本次调整:
[35]概率第一章第3天(星期3)第1-2节 -> 第2天(星期2)第5-6节
[41]概率第二章第4天(星期4)第1-2节 -> 第3天(星期3)第1-2节
第2天当前占用...
第3天当前占用...
第4天当前占用...
```
**失败返回(未授权顺序重排时应由上层拦截):**
```
已拒绝执行 min_context_switch当前未授权打乱顺序。如需使用该工具请先由用户明确说明“允许打乱顺序”。
```
---
### 5.7 queue_pop_head
弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。
**入参:**
**返回示例:**
```json
{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}
```
---
### 5.8 queue_apply_head_move
将当前队首任务移动到指定位置并自动出队。仅作用于 current不接受 task_id。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| new_day | int | 是 | 目标 day |
| new_slot_start | int | 是 | 目标起始节次 |
**返回示例:**
```json
{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}
```
---
### 5.9 queue_skip_head
跳过当前队首任务(不改日程),标记为 skipped 并继续后续队列。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| reason | string | 否 | 跳过原因 |
**返回示例:**
```json
{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
```
---
### 5.10 queue_status
查看当前待处理队列状态pending/current/completed/skipped
**入参:**
**返回示例:**
```json
{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}
```
---
### 5.11 spread_even
在给定任务集合内执行“均匀化铺开”:
先按筛选条件收集候选坑位,再用确定性规划器生成移动方案并原子提交。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_ids | array[int] | 是 | 参与均匀化的任务 ID 列表(至少 2 个) |
| task_id | int | 否 | 兼容单值参数,不建议新调用使用 |
| day/day_start/day_end | int | 否 | 天级范围过滤 |
| day_scope | string | 否 | `all` / `workday` / `weekend` |
| day_of_week | array[int] | 否 | 星期过滤1~7 |
| week/week_filter/week_from/week_to | int/array[int] | 否 | 周级过滤 |
| limit | int | 否 | 每个跨度的候选坑位上限(内部会按任务数自动放大) |
| allow_embed | bool | 否 | 是否允许补充可嵌入位,默认 true |
| exclude_sections | array[int] | 否 | 排除节次 |
| after_section/before_section | int | 否 | 节次边界过滤 |
**成功返回:**
```
均匀化调整完成:共处理 6 个任务,候选坑位 24 个。
本次调整:
[35]第一章复习第3天(星期3)第5-6节 -> 第5天(星期5)第1-2节
[41]第二章练习第4天(星期4)第5-6节 -> 第6天(星期6)第1-2节
第5天当前占用...
第6天当前占用...
```
**失败返回(候选不足):**
```
均匀化调整失败:跨度=2 可用坑位不足required=4, got=2。
```
---
## 6. 公共规则
### 冲突检测
- 所有写操作执行前自动检测目标位置是否冲突
- 冲突时拒绝操作,返回冲突任务名称和占用节次
- state 保持不变
### 锁定保护
- locked=true 的任务move / swap / unplace 直接拒绝
- place 新任务到锁定时段同样拒绝
### 状态约束
- pending 任务只能 place不能 move / swap / unplace
- suggested 任务可以 move / swap / unplace / spread_even / min_context_switch
- existing 任务不能 move / batch_move / spread_even / min_context_switch仅作已安排事实层
- 状态不符时返回明确错误信息
### 返回格式
- 返回值为自然语言 + 轻结构(缩进、列表)
- 占用信息始终附带每个任务的具体节次范围
- 读工具只报当前真实状态,不做假设
- 写工具只报变更后的事实,不附建议
### ID 规范
- LLM 可见的任务 ID 为 `state_id`(递增整数),不暴露 source/source_id
- `state_id` 由工具层在加载 state 时分配,不区分来源
- `source` + `source_id` 为内部字段,仅在写库时使用,不对 LLM 可见
### 嵌入任务规则
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
- 嵌入任务占位时不触发冲突检测(与宿主共存)
- `query_available_slots` 返回候选坑位池(先纯空位,必要时补可嵌入位)
- `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 记录