# 日程工具设计文档 > 本文档定义了 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-3(3连堂)、1-4(4连堂)、9-12(4连堂)等 - 任务时段用 (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 记录