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

View File

@@ -0,0 +1,754 @@
# 日程工具设计文档
> 本文档定义了 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 记录

View File

@@ -0,0 +1,37 @@
package agenttools
import "strings"
var activeOptimizeAllowedTools = map[string]struct{}{
ToolNameContextToolsAdd: {},
ToolNameContextToolsRemove: {},
"analyze_health": {},
"move": {},
"swap": {},
}
// IsToolAllowedInActiveOptimize 判定工具是否允许出现在“粗排后主动优化专用模式”里。
//
// 职责边界:
// 1. 这里只做场景级白名单裁剪,不参与工具是否已注册、是否被临时禁用、是否需要 confirm 的判断;
// 2. 该白名单只服务于“首次粗排后自动微调”链路,避免 LLM 在主动优化时重新暴露大量读工具;
// 3. context_tools_add/remove 仍保留,是为了兼容系统级动态区协议,但不代表会重新放开其它业务工具。
func IsToolAllowedInActiveOptimize(name string) bool {
_, ok := activeOptimizeAllowedTools[strings.TrimSpace(name)]
return ok
}
// FilterSchemasForActiveOptimize 过滤出主动优化专用模式允许暴露给 LLM 的工具 schema。
func FilterSchemasForActiveOptimize(schemas []ToolSchemaEntry) []ToolSchemaEntry {
if len(schemas) == 0 {
return nil
}
filtered := make([]ToolSchemaEntry, 0, len(schemas))
for _, item := range schemas {
if !IsToolAllowedInActiveOptimize(item.Name) {
continue
}
filtered = append(filtered, item)
}
return filtered
}

View File

@@ -0,0 +1,407 @@
package agenttools
import (
"encoding/json"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
toolcontextresult "github.com/LoveLosita/smartflow/backend/services/agent/tools/tool_context_result"
)
type contextToolsAddResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Action string `json:"action"`
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
Mode string `json:"mode,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
type contextToolsRemoveResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Action string `json:"action"`
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
All bool `json:"all,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// NewContextToolsAddHandler 创建 context_tools_add 工具。
//
// 职责边界:
// 1. 这里只负责校验 domain / packs / mode并产出结构化结果
// 2. 不直接改 CommonState真正的激活切流仍由 execute 层读取 observation 后更新快照;
// 3. 因为这里拿不到 CommonState所以卡片展示的是“本次工具结果返回的 domain/packs/mode”不是全局最终快照。
func NewContextToolsAddHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
domain := NormalizeToolDomain(readContextToolString(args["domain"]))
if domain == "" {
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Error: "参数非法domain 仅支持 schedule/taskclass",
ErrorCode: "invalid_domain",
})
}
mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"])))
if mode == "" {
mode = "replace"
}
if mode != "replace" && mode != "merge" {
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Domain: domain,
Error: "参数非法mode 仅支持 replace/merge",
ErrorCode: "invalid_mode",
})
}
packsRaw := readContextToolStringSlice(args["packs"])
packs, errCode, errText := validateContextPacks(domain, packsRaw, false)
if errCode != "" {
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Domain: domain,
Error: errText,
ErrorCode: errCode,
})
}
// 1. schedule 未显式传 packs 时,默认激活最小可用包 mutation+analyze。
// 2. taskclass 当前没有可选包,所以这里会保持空切片,由 execute 层只保留固定 core。
// 3. 这样做可以让 observation 直接表达“本次实际生效的可选包集合”,减少展示层再二次猜测。
if domain == ToolDomainSchedule && len(packsRaw) == 0 {
packs = ResolveEffectiveToolPacks(domain, nil)
}
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: true,
Action: "activate",
Domain: domain,
Packs: packs,
Mode: mode,
Message: "已激活目标工具域,可继续调用对应业务工具。",
})
}
}
// NewContextToolsRemoveHandler 创建 context_tools_remove 工具。
//
// 职责边界:
// 1. 这里只解释 domain / packs / all 的语义,并返回结构化结果;
// 2. all=true 表示清空全部业务工具域domain+packs 表示移除某域下的可选包;
// 3. 实际 CommonState 的域/包更新,仍由 execute 层统一消费 observation 完成。
func NewContextToolsRemoveHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
all := readContextToolBool(args["all"])
domainRaw := strings.ToLower(strings.TrimSpace(readContextToolString(args["domain"])))
packsRaw := readContextToolStringSlice(args["packs"])
// 兼容旧写法domain=all 也视为清空全部业务工具域。
if domainRaw == "all" {
all = true
}
if all {
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "clear_all",
All: true,
Message: "已移除全部业务工具域,仅保留 context 管理工具。",
})
}
domain := NormalizeToolDomain(domainRaw)
if domain == "" {
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: false,
Action: "reject",
Error: "参数非法:需要提供 domain=schedule/taskclass 或 all=true",
ErrorCode: "invalid_domain",
})
}
packs, errCode, errText := validateContextPacks(domain, packsRaw, true)
if errCode != "" {
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: false,
Action: "reject",
Domain: domain,
Error: errText,
ErrorCode: errCode,
})
}
if len(packs) > 0 {
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "deactivate_packs",
Domain: domain,
Packs: packs,
Message: "已移除指定工具包。",
})
}
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "deactivate",
Domain: domain,
Message: "已移除指定工具域。",
})
}
}
func buildContextToolsAddExecutionResult(args map[string]any, payload contextToolsAddResult) ToolExecutionResult {
observation := marshalContextToolsAddResult(payload)
legacy := LegacyResult(ToolNameContextToolsAdd, args, observation)
view := toolcontextresult.BuildAddView(toContextToolsAddPayload(payload), observation)
return buildContextToolExecutionResult(legacy, args, view)
}
func buildContextToolsRemoveExecutionResult(args map[string]any, payload contextToolsRemoveResult) ToolExecutionResult {
observation := marshalContextToolsRemoveResult(payload)
legacy := LegacyResult(ToolNameContextToolsRemove, args, observation)
view := toolcontextresult.BuildRemoveView(toContextToolsRemovePayload(payload), observation)
return buildContextToolExecutionResult(legacy, args, view)
}
// buildContextToolExecutionResult 负责把子包纯展示结构包回 ToolExecutionResult。
//
// 职责边界:
// 1. 只做 ContextResultView -> ToolDisplayView 的协议桥接;
// 2. 不改写 ObservationText确保模型侧仍消费原始 observation JSON
// 3. 错误码与错误文案继续复用父包现有 JSON/text 解析逻辑,避免多套失败判定分叉。
func buildContextToolExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view toolcontextresult.ContextResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = result.ObservationText
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: strings.TrimSpace(view.ViewType),
Version: view.Version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func toContextToolsAddPayload(payload contextToolsAddResult) toolcontextresult.ContextToolsAddPayload {
return toolcontextresult.ContextToolsAddPayload{
Tool: payload.Tool,
Success: payload.Success,
Action: payload.Action,
Domain: payload.Domain,
Packs: append([]string(nil), payload.Packs...),
Mode: payload.Mode,
Message: payload.Message,
Error: payload.Error,
ErrorCode: payload.ErrorCode,
}
}
func toContextToolsRemovePayload(payload contextToolsRemoveResult) toolcontextresult.ContextToolsRemovePayload {
return toolcontextresult.ContextToolsRemovePayload{
Tool: payload.Tool,
Success: payload.Success,
Action: payload.Action,
Domain: payload.Domain,
Packs: append([]string(nil), payload.Packs...),
All: payload.All,
Message: payload.Message,
Error: payload.Error,
ErrorCode: payload.ErrorCode,
}
}
func validateContextPacks(domain string, packs []string, forRemove bool) ([]string, string, string) {
normalizedDomain := NormalizeToolDomain(domain)
if normalizedDomain == "" {
return nil, "invalid_domain", "参数非法domain 非法"
}
if len(packs) == 0 {
return nil, "", ""
}
if normalizedDomain == ToolDomainTaskClass {
return nil, "unsupported_packs_for_domain", "参数非法taskclass 暂不支持 packs"
}
normalized := make([]string, 0, len(packs))
seen := make(map[string]struct{}, len(packs))
for _, raw := range packs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
pack := NormalizeToolPack(normalizedDomain, trimmed)
if pack == "" {
return nil, "invalid_pack", "参数非法:存在不支持的 pack"
}
if IsFixedToolPack(normalizedDomain, pack) {
if forRemove {
return nil, "fixed_pack_forbidden", "参数非法core 为固定包,不允许 remove"
}
return nil, "fixed_pack_forbidden", "参数非法core 为固定包,不允许 add"
}
if _, exists := seen[pack]; exists {
continue
}
seen[pack] = struct{}{}
normalized = append(normalized, pack)
}
if len(normalized) == 0 {
return nil, "invalid_pack", "参数非法packs 为空或无效"
}
return normalized, "", ""
}
func readContextToolString(raw any) string {
text, _ := raw.(string)
return strings.TrimSpace(text)
}
func readContextToolStringSlice(raw any) []string {
switch typed := raw.(type) {
case []string:
out := make([]string, 0, len(typed))
for _, item := range typed {
text := strings.TrimSpace(item)
if text == "" {
continue
}
out = append(out, text)
}
return out
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
text, ok := item.(string)
if !ok {
continue
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
out = append(out, text)
}
return out
case string:
text := strings.TrimSpace(typed)
if text == "" {
return nil
}
parts := strings.Split(text, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
default:
return nil
}
}
func readContextToolBool(raw any) bool {
switch v := raw.(type) {
case bool:
return v
case string:
value := strings.ToLower(strings.TrimSpace(v))
return value == "1" || value == "true" || value == "yes"
case float64:
return v != 0
case float32:
return v != 0
case int:
return v != 0
case int8:
return v != 0
case int16:
return v != 0
case int32:
return v != 0
case int64:
return v != 0
default:
return false
}
}
func marshalContextToolsAddResult(result contextToolsAddResult) string {
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"context_tools_add","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
}
return string(raw)
}
func marshalContextToolsRemoveResult(result contextToolsRemoveResult) string {
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"context_tools_remove","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
}
return string(raw)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,431 @@
package agenttools
import (
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/web"
ragservice "github.com/LoveLosita/smartflow/backend/services/rag"
)
// ToolHandler 约定所有工具的统一执行签名。
//
// 职责边界:
// 1. 负责消费当前 ScheduleState 与模型传入参数;
// 2. 返回 ToolExecutionResult供 execute 节点写回 observation 与结构化事件;
// 3. 不负责 confirm、上下文注入、轮次控制这些由上层节点处理。
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult
// ToolSchemaEntry 描述注入给模型的工具快照。
type ToolSchemaEntry struct {
Name string
Desc string
SchemaText string
}
// DefaultRegistryDeps 描述默认注册表需要的外部依赖。
//
// 职责边界:
// 1. 这里只承载工具层需要的依赖注入,不承载业务状态;
// 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new
// 3. 具体依赖缺失时由对应工具自行返回结构化失败结果。
type DefaultRegistryDeps struct {
RAGRuntime ragservice.Runtime
// WebSearchProvider 为 nil 时web_search / web_fetch 仍会注册,
// 但 handler 会返回“暂未启用”的只读 observation不阻断主流程。
WebSearchProvider web.SearchProvider
// TaskClassWriteDeps 供 upsert_task_class 调用持久化层。
TaskClassWriteDeps TaskClassWriteDeps
}
// ToolRegistry 管理工具注册、过滤与执行。
type ToolRegistry struct {
handlers map[string]ToolHandler
schemas []ToolSchemaEntry
deps DefaultRegistryDeps
}
// temporaryDisabledTools 描述“已注册但当前阶段临时禁用”的工具。
//
// 设计说明:
// 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字;
// 2. execute 会在调用前统一阻断,并向模型返回纠错提示;
// 3. ToolNames / Schemas 也会默认隐藏它们,避免继续污染 msg0。
var temporaryDisabledTools = map[string]bool{}
// IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。
func IsTemporarilyDisabledTool(name string) bool {
return temporaryDisabledTools[strings.TrimSpace(name)]
}
// NewToolRegistry 创建空注册表。
func NewToolRegistry() *ToolRegistry {
return NewToolRegistryWithDeps(DefaultRegistryDeps{})
}
// NewToolRegistryWithDeps 创建带依赖的空注册表。
func NewToolRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return &ToolRegistry{
handlers: make(map[string]ToolHandler),
schemas: make([]ToolSchemaEntry, 0),
deps: deps,
}
}
// Register 注册一个工具及其 schema。
func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandler) {
r.handlers[name] = handler
r.schemas = append(r.schemas, ToolSchemaEntry{
Name: name,
Desc: desc,
SchemaText: schemaText,
})
}
// Execute 执行指定工具。
//
// 职责边界:
// 1. 这里只负责找到 handler 并调用;
// 2. 工具临时禁用时直接返回 blocked 结构化结果,不进入 handler
// 3. 参数 schema 级纠错仍由 handler 内处理。
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) ToolExecutionResult {
if r.IsToolTemporarilyDisabled(toolName) {
observation := fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName))
return BlockedResult(toolName, args, observation, "tool_temporarily_disabled", observation)
}
handler, ok := r.handlers[toolName]
if !ok {
observation := fmt.Sprintf("工具调用失败:未知工具 %q。可用工具%s", toolName, strings.Join(r.ToolNames(), "、"))
result := LegacyResult(toolName, args, observation)
result.Status = ToolStatusFailed
result.Success = false
result.ErrorCode = "unknown_tool"
result.ErrorMessage = observation
return EnsureToolResultDefaults(result, args)
}
return EnsureToolResultDefaults(handler(state, args), args)
}
// HasTool 判断工具是否已注册且当前可见。
func (r *ToolRegistry) HasTool(name string) bool {
if r.IsToolTemporarilyDisabled(name) {
return false
}
_, ok := r.handlers[name]
return ok
}
// IsToolTemporarilyDisabled 判断工具是否处于“已注册但暂不允许调用”状态。
func (r *ToolRegistry) IsToolTemporarilyDisabled(name string) bool {
return IsTemporarilyDisabledTool(name)
}
// ToolNames 返回当前可暴露给模型的工具名。
func (r *ToolRegistry) ToolNames() []string {
names := make([]string, 0, len(r.schemas))
for _, item := range r.schemas {
if r.IsToolTemporarilyDisabled(item.Name) {
continue
}
names = append(names, item.Name)
}
return names
}
// Schemas 返回当前可暴露给模型的 schema 快照。
func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
result := make([]ToolSchemaEntry, 0, len(r.schemas))
for _, item := range r.schemas {
if r.IsToolTemporarilyDisabled(item.Name) {
continue
}
result = append(result, item)
}
return result
}
// SchemasForActiveDomain 返回某业务域当前真正可见的工具 schema。
//
// 职责边界:
// 1. context_tools_add/remove 始终保留,用于动态区协议;
// 2. 仅当工具域已激活时,才暴露该域下可见工具;
// 3. schedule 域支持按 pack 过滤taskclass 目前只有 core。
func (r *ToolRegistry) SchemasForActiveDomain(activeDomain string, activePacks []string) []ToolSchemaEntry {
normalizedDomain := NormalizeToolDomain(activeDomain)
effectivePacks := ResolveEffectiveToolPacks(normalizedDomain, activePacks)
effectivePackSet := make(map[string]struct{}, len(effectivePacks))
for _, pack := range effectivePacks {
effectivePackSet[pack] = struct{}{}
}
selected := make([]ToolSchemaEntry, 0, len(r.schemas))
for _, item := range r.schemas {
name := strings.TrimSpace(item.Name)
if r.IsToolTemporarilyDisabled(name) {
continue
}
if IsContextManagementTool(name) {
selected = append(selected, item)
continue
}
if normalizedDomain == "" {
continue
}
domain, pack, ok := ResolveToolDomainPack(name)
if !ok {
// 兼容历史未建档工具:仅在 schedule 域下继续暴露,避免突然失联。
if normalizedDomain == ToolDomainSchedule {
selected = append(selected, item)
}
continue
}
if domain != normalizedDomain {
continue
}
if IsFixedToolPack(domain, pack) {
selected = append(selected, item)
continue
}
if _, exists := effectivePackSet[pack]; exists {
selected = append(selected, item)
}
}
result := make([]ToolSchemaEntry, len(selected))
copy(result, selected)
return result
}
// IsToolVisibleInDomain 判断某工具在当前动态区下是否应对模型可见。
func (r *ToolRegistry) IsToolVisibleInDomain(activeDomain string, activePacks []string, toolName string) bool {
name := strings.TrimSpace(toolName)
if name == "" {
return false
}
for _, item := range r.SchemasForActiveDomain(activeDomain, activePacks) {
if strings.TrimSpace(item.Name) == name {
return true
}
}
return false
}
// IsWriteTool 判断工具是否属于写工具。
func (r *ToolRegistry) IsWriteTool(name string) bool {
return writeTools[strings.TrimSpace(name)]
}
// IsScheduleMutationTool 判断工具是否会真实修改 ScheduleState 中的日程布局。
// upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。
func (r *ToolRegistry) IsScheduleMutationTool(name string) bool {
return scheduleMutationTools[strings.TrimSpace(name)]
}
// RequiresScheduleState 判断工具是否依赖 ScheduleState。
func (r *ToolRegistry) RequiresScheduleState(name string) bool {
return !scheduleFreeTools[strings.TrimSpace(name)]
}
var writeTools = map[string]bool{
"place": true,
"move": true,
"swap": true,
"batch_move": true,
"queue_apply_head_move": true,
"unplace": true,
"upsert_task_class": true,
}
var scheduleMutationTools = map[string]bool{
"place": true,
"move": true,
"swap": true,
"batch_move": true,
"queue_apply_head_move": true,
"unplace": true,
}
// scheduleFreeTools 描述“即使没有 ScheduleState 也能安全执行”的工具。
var scheduleFreeTools = map[string]bool{
"web_search": true,
"web_fetch": true,
"upsert_task_class": true,
ToolNameContextToolsAdd: true,
ToolNameContextToolsRemove: true,
}
// NewDefaultRegistry 创建默认注册表。
func NewDefaultRegistry() *ToolRegistry {
return NewDefaultRegistryWithDeps(DefaultRegistryDeps{})
}
// NewDefaultRegistryWithDeps 创建带依赖的默认注册表。
//
// 步骤化说明:
// 1. 先注册上下文管理工具,保证动态区协议随时可用;
// 2. 再注册 schedule 域的读、诊断、写工具;
// 3. 最后注册 taskclass 与 web 工具,并统一按 name 排序,保证 prompt 输出稳定。
func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
r := NewToolRegistryWithDeps(deps)
registerContextTools(r)
registerScheduleReadTools(r)
registerScheduleAnalyzeTools(r)
registerScheduleMutationTools(r)
registerTaskClassTools(r, deps)
registerWebTools(r, deps)
sort.Slice(r.schemas, func(i, j int) bool {
return r.schemas[i].Name < r.schemas[j].Name
})
return r
}
func registerContextTools(r *ToolRegistry) {
r.Register(
ToolNameContextToolsAdd,
"激活指定工具域,并可附带 schedule 二级包 packs。core 固定注入。",
`{"name":"context_tools_add","parameters":{"domain":{"type":"string","required":true,"enum":["schedule","taskclass"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"mode":{"type":"string","enum":["replace","merge"]}}}`,
NewContextToolsAddHandler(),
)
r.Register(
ToolNameContextToolsRemove,
"移除指定工具域、指定二级包或清空全部业务工具域all=true。core 固定包不支持 remove。",
`{"name":"context_tools_remove","parameters":{"domain":{"type":"string","enum":["schedule","taskclass","all"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"all":{"type":"bool"}}}`,
NewContextToolsRemoveHandler(),
)
}
func registerScheduleReadTools(r *ToolRegistry) {
r.Register(
"get_overview",
"获取当前窗口总览:保留课程占位统计,展开任务清单。",
`{"name":"get_overview","parameters":{}}`,
NewGetOverviewToolHandler(),
)
r.Register(
"query_range",
"查看某天或某时段的占用详情。day 必填slot_start/slot_end 选填。",
`{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`,
NewQueryRangeToolHandler(),
)
r.Register(
"query_available_slots",
"查询候选空位池,适合 move 前筛落点。",
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
NewQueryAvailableSlotsToolHandler(),
)
r.Register(
"query_target_tasks",
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。",
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
NewQueryTargetTasksToolHandler(),
)
r.Register(
"queue_pop_head",
"弹出并返回当前队首任务;若已有 current 则复用。",
`{"name":"queue_pop_head","parameters":{}}`,
NewQueuePopHeadToolHandler(),
)
r.Register(
"queue_status",
"查看当前队列状态pending/current/completed/skipped。",
`{"name":"queue_status","parameters":{}}`,
NewQueueStatusToolHandler(),
)
r.Register(
"get_task_info",
"查看单个任务详情,包括类别、状态与落位。",
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
NewGetTaskInfoToolHandler(),
)
}
func registerScheduleAnalyzeTools(r *ToolRegistry) {
r.Register(
"analyze_rhythm",
"分析学习节奏与切换情况。",
`{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
NewAnalyzeRhythmToolHandler(),
)
r.Register(
"analyze_health",
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness判断当前是否还值得继续优化并给出候选。",
`{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`,
NewAnalyzeHealthToolHandler(),
)
}
func registerScheduleMutationTools(r *ToolRegistry) {
r.Register(
"place",
"将一个待安排任务预排到指定位置。task_id/day/slot_start 必填。",
`{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`,
NewPlaceToolHandler(),
)
r.Register(
"move",
"将一个已预排任务(仅 suggested移动到新位置。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}}}`,
NewMoveToolHandler(),
)
r.Register(
"swap",
"交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。",
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
NewSwapToolHandler(),
)
r.Register(
"batch_move",
"原子性批量移动多个任务。moves 必填。",
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
NewBatchMoveToolHandler(),
)
r.Register(
"queue_apply_head_move",
"将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。",
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
NewQueueApplyHeadMoveToolHandler(),
)
r.Register(
"queue_skip_head",
"跳过当前队首任务,将其标记为 skipped。",
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
NewQueueSkipHeadToolHandler(),
)
r.Register(
"unplace",
"将一个已落位任务移除恢复为待安排状态。task_id 必填。",
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
NewUnplaceToolHandler(),
)
}
func registerTaskClassTools(r *ToolRegistry, deps DefaultRegistryDeps) {
r.Register(
"upsert_task_class",
"创建或更新任务类(统一写入口,必须 confirm。auto 模式下 start_date/end_date 必须在 task_class 顶层字段。",
`{"name":"upsert_task_class","parameters":{"id":{"type":"int"},"task_class":{"type":"object","required":true},"items":{"type":"array","items":{"type":"object"}},"source":{"type":"string"}}}`,
NewTaskClassUpsertToolHandler(deps.TaskClassWriteDeps),
)
}
func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) {
r.Register(
"web_search",
"Web 搜索:根据 query 返回结构化检索结果。query 必填。",
`{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`,
NewWebSearchToolHandler(deps.WebSearchProvider),
)
r.Register(
"web_fetch",
"抓取指定 URL 的正文内容并做最小清洗。url 必填。",
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
NewWebFetchToolHandler(web.NewFetcher()),
)
}

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()
}

View File

@@ -0,0 +1,517 @@
package schedule_analysis
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// BuildResultView 统一封装 schedule.analysis_result 结构。
//
// 职责边界:
// 1. 负责把已经计算好的折叠态/展开态内容组装成标准视图;
// 2. 负责在子包内补齐 status / status_label避免依赖父包常量
// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。
func BuildResultView(input BuildResultViewInput) AnalysisResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusDone
}
collapsed := map[string]any{
"title": strings.TrimSpace(input.Title),
"subtitle": strings.TrimSpace(input.Subtitle),
"status": status,
"status_label": resolveStatusLabelCN(status),
"metrics": metricListToMaps(input.Metrics),
}
expanded := map[string]any{
"items": itemListToMaps(input.Items),
"sections": cloneSectionList(input.Sections),
"raw_text": input.Observation,
}
if len(input.MachinePayload) > 0 {
expanded["machine_payload"] = cloneAnyMap(input.MachinePayload)
}
return AnalysisResultView{
ViewType: ViewTypeAnalysisResult,
Version: ViewVersionAnalysisResult,
Collapsed: collapsed,
Expanded: expanded,
}
}
// BuildFailureView 统一生成 analysis 工具失败卡片视图。
//
// 职责边界:
// 1. 只从 observation 中提炼失败文案和参数回显;
// 2. 不负责判断失败条件,调用方需要先确认 observation 失败;
// 3. raw_text 仍保留原始 observation方便 debug 与下游排查。
func BuildFailureView(input BuildFailureViewInput) AnalysisResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusFailed
}
title := strings.TrimSpace(input.Title)
if title == "" {
title = fmt.Sprintf("%s失败", resolveToolLabelCN(input.ToolName))
}
subtitle := strings.TrimSpace(input.Subtitle)
if subtitle == "" {
subtitle = failureText(input.Observation, "诊断分析失败,请检查当前日程状态后重试。")
}
sections := []map[string]any{
BuildCalloutSection("执行失败", subtitle, "danger", []string{subtitle}),
}
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: status,
Title: title,
Subtitle: subtitle,
Sections: sections,
Observation: input.Observation,
})
}
func BuildMetric(label string, value string) MetricField {
return MetricField{Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)}
}
func BuildKVField(label string, value string) KVField {
return KVField{Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)}
}
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func BuildItemsSection(title string, items []ItemView) map[string]any {
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": itemListToMaps(items),
}
}
func BuildKVSection(title string, fields []KVField) map[string]any {
rows := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
rows = append(rows, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": rows,
}
}
func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
// BuildArgsSection 把父包已经本地化的参数字段拼成展示 section。
//
// 职责边界:
// 1. 只接受纯 KVField不依赖父包 ToolArgumentView
// 2. 不解释 detail / threshold / hard_categories 是否真实参与计算;
// 3. 没有有效字段时返回 nil避免空 section 干扰前端。
func BuildArgsSection(title string, fields []KVField) map[string]any {
if len(fields) == 0 {
return nil
}
valid := make([]KVField, 0, len(fields))
for _, field := range fields {
if strings.TrimSpace(field.Label) == "" || strings.TrimSpace(field.Value) == "" {
continue
}
valid = append(valid, BuildKVField(field.Label, field.Value))
}
if len(valid) == 0 {
return nil
}
return BuildKVSection(title, valid)
}
func parseObservationJSON(observation string) (map[string]any, bool) {
trimmed := strings.TrimSpace(observation)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return nil, false
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, false
}
return payload, true
}
func isSuccessPayload(payload map[string]any) bool {
success, ok := readBool(payload, "success")
return ok && success
}
func failureText(observation string, fallback string) string {
if payload, ok := parseObservationJSON(observation); ok {
if message := firstString(payload, "error", "message", "reason", "err"); message != "" {
return message
}
}
if strings.TrimSpace(observation) != "" {
return strings.TrimSpace(observation)
}
return strings.TrimSpace(fallback)
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func resolveToolLabelCN(toolName string) string {
switch strings.TrimSpace(toolName) {
case "analyze_health":
return "综合体检"
case "analyze_rhythm":
return "学习节律分析"
default:
return "诊断分析"
}
}
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
if section == nil {
return
}
*target = append(*target, section)
}
func metricListToMaps(metrics []MetricField) []map[string]any {
if len(metrics) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, map[string]any{"label": label, "value": value})
}
if len(out) == 0 {
return make([]map[string]any, 0)
}
return out
}
func itemListToMaps(items []ItemView) []map[string]any {
if len(items) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
row := map[string]any{
"title": strings.TrimSpace(item.Title),
"subtitle": strings.TrimSpace(item.Subtitle),
"tags": normalizeStringSlice(item.Tags),
"detail_lines": normalizeStringSlice(item.DetailLines),
}
if len(item.Meta) > 0 {
row["meta"] = cloneAnyMap(item.Meta)
}
out = append(out, row)
}
return out
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneAnyMap(typed)
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyValue(item))
}
return out
case []map[string]any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyMap(item))
}
return out
case []string:
out := make([]string, len(typed))
copy(out, typed)
return out
default:
return typed
}
}
func readMap(input map[string]any, key string) map[string]any {
if len(input) == 0 {
return nil
}
value, ok := input[key]
if !ok {
return nil
}
row, _ := value.(map[string]any)
return row
}
func readList(input map[string]any, key string) []any {
if len(input) == 0 {
return nil
}
value, ok := input[key]
if !ok {
return nil
}
rows, _ := value.([]any)
return rows
}
func readString(input map[string]any, key string) string {
if len(input) == 0 {
return ""
}
value, ok := input[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
text := strings.TrimSpace(fmt.Sprintf("%v", typed))
if text == "<nil>" {
return ""
}
return text
}
}
func firstString(input map[string]any, keys ...string) string {
for _, key := range keys {
if value := readString(input, key); value != "" {
return value
}
}
return ""
}
func readBool(input map[string]any, key string) (bool, bool) {
if len(input) == 0 {
return false, false
}
value, ok := input[key]
if !ok {
return false, false
}
typed, ok := value.(bool)
return typed, ok
}
func readInt(input map[string]any, key string) int {
value := readFloat(input, key)
return int(value)
}
func readFloat(input map[string]any, key string) float64 {
if len(input) == 0 {
return 0
}
value, ok := input[key]
if !ok || value == nil {
return 0
}
switch typed := value.(type) {
case float64:
return typed
case float32:
return float64(typed)
case int:
return float64(typed)
case int64:
return float64(typed)
default:
return 0
}
}
func severityRank(severity string) int {
switch strings.ToLower(strings.TrimSpace(severity)) {
case "critical":
return 0
case "warning":
return 1
default:
return 2
}
}
func formatSeverityCN(severity string) string {
switch strings.ToLower(strings.TrimSpace(severity)) {
case "critical":
return "高风险"
case "warning":
return "需关注"
default:
return "提示"
}
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatFloat(value float64) string {
return fmt.Sprintf("%.2f", value)
}
func formatPercent(value float64) string {
return fmt.Sprintf("%.0f%%", value*100)
}
func formatOperationCN(operation string) string {
switch strings.TrimSpace(operation) {
case "move":
return "移动"
case "swap":
return "交换"
case "close":
return "收口"
case "ask_user":
return "询问用户"
default:
if strings.TrimSpace(operation) == "" {
return "未指定"
}
return strings.TrimSpace(operation)
}
}
func formatEffectCN(effect string) string {
switch strings.TrimSpace(effect) {
case "improve":
return "明显改善"
case "partial_improve":
return "部分改善"
case "shift":
return "问题转移"
case "no_gain":
return "收益不足"
case "regress":
return "变差"
case "close":
return "收口"
default:
if strings.TrimSpace(effect) == "" {
return "未标注"
}
return strings.TrimSpace(effect)
}
}
func sortedKeys(input map[string]any) []string {
keys := make([]string, 0, len(input))
for key := range input {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func compactJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value)
}
return string(raw)
}

View File

@@ -0,0 +1,267 @@
package schedule_analysis
import (
"fmt"
"strings"
)
// BuildAnalyzeHealthView 把 analyze_health 的原始 JSON observation 转成诊断卡片。
//
// 步骤化说明:
// 1. 只解析 observation 的现有 JSON 字段,不改变字段名、层级或内容;
// 2. 展示层优先读取 feasibility / decision / metrics避免依赖自然语言摘要
// 3. 解析失败或 success=false 时返回失败卡片raw_text 仍保留原始 observation。
func BuildAnalyzeHealthView(input AnalyzeHealthViewInput) AnalysisResultView {
payload, ok := parseObservationJSON(input.Observation)
if !ok || !isSuccessPayload(payload) {
return BuildFailureView(BuildFailureViewInput{
ToolName: "analyze_health",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
metricsMap := readMap(payload, "metrics")
rhythm := readMap(metricsMap, "rhythm")
tightness := readMap(metricsMap, "tightness")
profile := readMap(metricsMap, "profile")
feasibility := readMap(payload, "feasibility")
decision := readMap(payload, "decision")
title := buildHealthTitle(feasibility, decision)
subtitle := buildHealthSubtitle(feasibility, decision)
metrics := buildHealthMetrics(rhythm, tightness, profile, feasibility)
candidateItems := buildHealthCandidateItems(decision)
issueItems := buildIssueItems(readList(payload, "issues"))
sections := []map[string]any{
BuildKVSection("裁决结论", buildHealthDecisionFields(feasibility, decision, metricsMap)),
BuildKVSection("关键指标", buildHealthMetricFields(rhythm, tightness, profile, metricsMap)),
}
if len(issueItems) > 0 {
sections = append(sections, BuildItemsSection("问题清单", issueItems))
} else {
sections = append(sections, BuildCalloutSection("问题清单", "当前没有结构化问题项。", "info", nil))
}
if len(candidateItems) > 0 {
sections = append(sections, BuildItemsSection("候选操作", candidateItems))
} else {
sections = append(sections, BuildCalloutSection("候选操作", "当前没有可执行候选。", "info", nil))
}
sections = append(sections, buildHealthNextStepSection(feasibility, decision, candidateItems))
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: subtitle,
Metrics: metrics,
Items: candidateItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: payload,
})
}
func buildHealthTitle(feasibility map[string]any, decision map[string]any) string {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
return "综合体检:当前约束不可行"
}
if shouldContinue, ok := readBool(decision, "should_continue_optimize"); ok && shouldContinue {
return "综合体检:建议继续微调"
}
return "综合体检:可以收口"
}
func buildHealthSubtitle(feasibility map[string]any, decision map[string]any) string {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
gap := readInt(feasibility, "capacity_gap")
reason := readString(feasibility, "reason_code")
if reason == "" {
reason = "capacity_insufficient"
}
return fmt.Sprintf("容量仍缺 %d 节,原因:%s。", gap, reason)
}
if problem := readString(decision, "primary_problem"); problem != "" {
return problem
}
return "当前没有发现需要继续处理的结构化问题。"
}
func buildHealthMetrics(rhythm, tightness, profile, feasibility map[string]any) []MetricField {
metrics := []MetricField{
BuildMetric("高认知相邻", fmt.Sprintf("%d 天", readInt(rhythm, "heavy_adjacent_days"))),
BuildMetric("最大切换", fmt.Sprintf("%d 次", readInt(rhythm, "max_switch_count"))),
BuildMetric("可局部移动", fmt.Sprintf("%d 项", readInt(tightness, "locally_movable_task_count"))),
BuildMetric("紧度", fallbackLabel(readString(tightness, "tightness_level"), "未标注")),
}
if gap := readInt(feasibility, "capacity_gap"); gap > 0 {
metrics = append(metrics, BuildMetric("容量缺口", fmt.Sprintf("%d 节", gap)))
return metrics
}
if missing := readInt(profile, "missing_complete_profile_count"); missing > 0 {
metrics = append(metrics, BuildMetric("画像缺失", fmt.Sprintf("%d 门", missing)))
}
return metrics
}
func buildHealthDecisionFields(feasibility map[string]any, decision map[string]any, metrics map[string]any) []KVField {
shouldContinue, _ := readBool(decision, "should_continue_optimize")
forced, _ := readBool(decision, "is_forced_imperfection")
canClose, _ := readBool(metrics, "can_close")
feasible, feasibleOK := readBool(feasibility, "is_feasible")
feasibleText := "未返回"
if feasibleOK {
feasibleText = formatBoolCN(feasible)
}
return []KVField{
BuildKVField("是否继续优化", formatBoolCN(shouldContinue)),
BuildKVField("当前可收口", formatBoolCN(canClose)),
BuildKVField("推荐动作", formatOperationCN(readString(decision, "recommended_operation"))),
BuildKVField("主问题", fallbackLabel(readString(decision, "primary_problem"), "当前没有发现值得继续处理的局部认知问题")),
BuildKVField("约束代价", formatBoolCN(forced)),
BuildKVField("约束可行", feasibleText),
BuildKVField("容量缺口", fmt.Sprintf("%d 节", readInt(feasibility, "capacity_gap"))),
BuildKVField("可行性原因", fallbackLabel(readString(feasibility, "reason_code"), "未返回")),
}
}
func buildHealthMetricFields(rhythm, tightness, profile, metrics map[string]any) []KVField {
canClose, _ := readBool(metrics, "can_close")
return []KVField{
BuildKVField("认知块平衡", fmt.Sprintf("%d", readInt(rhythm, "block_balance"))),
BuildKVField("偏碎天数", fmt.Sprintf("%d 天", readInt(rhythm, "fragmented_count"))),
BuildKVField("偏压缩天数", fmt.Sprintf("%d 天", readInt(rhythm, "compressed_run_count"))),
BuildKVField("平均每日切换", fmt.Sprintf("%.1f 次", readFloat(rhythm, "avg_switches_per_day"))),
BuildKVField("同类型切换占比", formatPercent(readFloat(rhythm, "same_type_transition_ratio"))),
BuildKVField("局部候选均值", fmt.Sprintf("%.1f 个", readFloat(tightness, "avg_local_alternative_slots"))),
BuildKVField("跨任务类交换机会", fmt.Sprintf("%d 个", readInt(tightness, "cross_class_swap_options"))),
BuildKVField("被迫高认知相邻", fmt.Sprintf("%d 天", readInt(tightness, "forced_heavy_adjacent_days"))),
BuildKVField("语义画像缺失", fmt.Sprintf("%d 门", readInt(profile, "missing_complete_profile_count"))),
BuildKVField("当前可收口", formatBoolCN(canClose)),
}
}
func buildHealthCandidateItems(decision map[string]any) []ItemView {
candidates := readList(decision, "candidates")
if len(candidates) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(candidates))
for _, raw := range candidates {
candidate, ok := raw.(map[string]any)
if !ok {
continue
}
after := readMap(candidate, "after")
canClose, _ := readBool(after, "can_close")
tool := readString(candidate, "tool")
effect := readString(candidate, "effect")
title := readString(candidate, "summary")
if title == "" {
title = fallbackLabel(readString(candidate, "candidate_id"), "候选操作")
}
subtitle := readString(after, "primary_problem")
if subtitle == "" {
subtitle = fmt.Sprintf("效果:%s", formatEffectCN(effect))
}
tags := []string{formatOperationCN(tool), formatEffectCN(effect)}
if canClose {
tags = append(tags, "执行后可收口")
}
detailLines := []string{
"候选 ID" + fallbackLabel(readString(candidate, "candidate_id"), "未返回"),
"参数:" + compactJSON(candidate["arguments"]),
fmt.Sprintf("执行后高认知相邻:%d 天", readInt(after, "heavy_adjacent_days")),
fmt.Sprintf("执行后最大切换:%d 次", readInt(after, "max_switch_count")),
"执行后同类型切换占比:" + formatPercent(readFloat(after, "same_type_transition_ratio")),
}
items = append(items, BuildItem(title, subtitle, tags, detailLines, candidate))
}
return items
}
func buildIssueItems(rows []any) []ItemView {
if len(rows) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(rows))
for _, raw := range rows {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
trigger := readMap(issue, "trigger")
severity := readString(issue, "severity")
dimension := readString(issue, "dimension")
title := describeIssue(issue)
detailLines := make([]string, 0, 3)
if metric := readString(trigger, "metric"); metric != "" {
detailLines = append(detailLines, fmt.Sprintf("触发指标:%s %s %.2f,实际 %.2f", metric, readString(trigger, "operator"), readFloat(trigger, "threshold"), readFloat(trigger, "actual")))
}
detailLines = append(detailLines, "问题 ID"+fallbackLabel(readString(issue, "issue_id"), "未返回"))
items = append(items, BuildItem(
title,
fmt.Sprintf("%s%s", fallbackLabel(dimension, "未标注维度"), formatSeverityCN(severity)),
[]string{formatSeverityCN(severity), fallbackLabel(dimension, "未标注维度")},
detailLines,
issue,
))
}
return items
}
func buildHealthNextStepSection(feasibility map[string]any, decision map[string]any, candidateItems []ItemView) map[string]any {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
return BuildCalloutSection(
"建议后续动作",
"当前先不要继续写操作,应先与用户协商时间窗、约束或任务范围。",
"warning",
[]string{"可选方向:扩展时间窗、放宽排除约束、缩减任务量,或确认接受风险收口。"},
)
}
if shouldContinue, ok := readBool(decision, "should_continue_optimize"); ok && shouldContinue {
return BuildCalloutSection(
"建议后续动作",
"优先从候选操作里选择收益明确的一项执行。",
"info",
[]string{fmt.Sprintf("当前共有 %d 个候选项;执行后建议再次调用 analyze_health 复诊。", len(candidateItems))},
)
}
return BuildCalloutSection(
"建议后续动作",
"当前可以收口;如用户仍要求微调,再按具体偏好追加读取或局部调整。",
"info",
nil,
)
}
func describeIssue(issue map[string]any) string {
issueID := readString(issue, "issue_id")
dimension := readString(issue, "dimension")
switch {
case strings.Contains(issueID, "feasibility"):
return "容量可行性不足"
case strings.Contains(issueID, "semantic_profile"):
return "任务类语义画像不完整"
case strings.Contains(issueID, "heavy_adjacent"):
return "存在高认知任务相邻"
case strings.Contains(issueID, "switch"):
return "单日任务切换偏多"
case strings.Contains(issueID, "long_block"):
return "同类任务连续块偏长"
case strings.Contains(issueID, "info"):
return "节奏整体提示"
default:
return fallbackLabel(dimension, "诊断问题")
}
}
func fallbackLabel(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(value)
}

View File

@@ -0,0 +1,326 @@
package schedule_analysis
import (
"fmt"
"strings"
)
// BuildAnalyzeRhythmView 把 analyze_rhythm 的原始 JSON observation 转成诊断卡片。
//
// 步骤化说明:
// 1. 只读取现有 metrics / issues / next_actions不改变 observation JSON
// 2. collapsed 聚焦节律结论和关键指标expanded 展开问题日、问题清单和建议动作;
// 3. detail / hard_categories 等参数只在父包参数区回显,不在这里声明它们已影响算法。
func BuildAnalyzeRhythmView(input AnalyzeRhythmViewInput) AnalysisResultView {
payload, ok := parseObservationJSON(input.Observation)
if !ok || !isSuccessPayload(payload) {
return BuildFailureView(BuildFailureViewInput{
ToolName: "analyze_rhythm",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
metricsMap := readMap(payload, "metrics")
overview := readMap(metricsMap, "overview")
days := readList(metricsMap, "days")
issues := readList(payload, "issues")
actions := readList(payload, "next_actions")
actionItems := buildRhythmActionItems(actions)
problemDayItems := buildRhythmProblemDayItems(days)
issueItems := buildIssueItems(issues)
sections := []map[string]any{
BuildKVSection("节律概览", buildRhythmOverviewFields(overview)),
}
if len(problemDayItems) > 0 {
sections = append(sections, BuildItemsSection("问题日", problemDayItems))
} else {
sections = append(sections, BuildCalloutSection("问题日", "当前没有命中的高风险问题日。", "info", nil))
}
if len(issueItems) > 0 {
sections = append(sections, BuildItemsSection("问题清单", issueItems))
}
if len(actionItems) > 0 {
sections = append(sections, BuildItemsSection("建议动作", actionItems))
} else {
sections = append(sections, BuildCalloutSection("建议动作", "当前节律诊断没有返回候选动作。", "info", nil))
}
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: buildRhythmTitle(issues),
Subtitle: buildRhythmSubtitle(issues, overview),
Metrics: buildRhythmMetrics(overview),
Items: actionItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: payload,
})
}
func buildRhythmTitle(issues []any) string {
severity := highestSeverity(issues)
switch severity {
case "critical":
return "学习节律分析:存在高风险"
case "warning":
return "学习节律分析:有待微调"
default:
return "学习节律分析:整体平稳"
}
}
func buildRhythmSubtitle(issues []any, overview map[string]any) string {
if issue := firstHighPriorityIssue(issues); issue != nil {
return describeIssue(issue)
}
maxDay := readInt(overview, "max_switch_day")
maxSwitch := readInt(overview, "max_switch_count")
if maxDay > 0 {
return fmt.Sprintf("最大切换出现在第 %d 天,共 %d 次。", maxDay, maxSwitch)
}
return "当前学习节律没有明显异常信号。"
}
func buildRhythmMetrics(overview map[string]any) []MetricField {
return []MetricField{
BuildMetric("平均切换", fmt.Sprintf("%.1f 次/天", readFloat(overview, "avg_switches_per_day"))),
BuildMetric("最大切换", fmt.Sprintf("%d 次", readInt(overview, "max_switch_count"))),
BuildMetric("高认知相邻", fmt.Sprintf("%d 天", readInt(overview, "heavy_adjacent_days"))),
BuildMetric("块平衡", fmt.Sprintf("%d", readInt(overview, "block_balance"))),
BuildMetric("同类型占比", formatPercent(readFloat(overview, "same_type_transition_ratio"))),
}
}
func buildRhythmOverviewFields(overview map[string]any) []KVField {
return []KVField{
BuildKVField("平均每日切换", fmt.Sprintf("%.1f 次", readFloat(overview, "avg_switches_per_day"))),
BuildKVField("最大切换日", formatDayIndex(readInt(overview, "max_switch_day"))),
BuildKVField("最大切换次数", fmt.Sprintf("%d 次", readInt(overview, "max_switch_count"))),
BuildKVField("平均块长度", fmt.Sprintf("%.1f 节", readFloat(overview, "avg_block_size"))),
BuildKVField("最长同科连续", fmt.Sprintf("%d 节", readInt(overview, "longest_same_subject_run"))),
BuildKVField("高认知相邻", fmt.Sprintf("%d 天", readInt(overview, "heavy_adjacent_days"))),
BuildKVField("高强度连续过长", fmt.Sprintf("%d 天", readInt(overview, "long_high_intensity_days"))),
BuildKVField("偏碎天数", fmt.Sprintf("%d 天", readInt(overview, "fragmented_count"))),
BuildKVField("偏压缩天数", fmt.Sprintf("%d 天", readInt(overview, "compressed_run_count"))),
BuildKVField("同类型切换占比", formatPercent(readFloat(overview, "same_type_transition_ratio"))),
}
}
func buildRhythmProblemDayItems(days []any) []ItemView {
if len(days) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0)
for _, raw := range days {
day, ok := raw.(map[string]any)
if !ok || !isProblemRhythmDay(day) {
continue
}
dayIndex := readInt(day, "day_index")
switchCount := readInt(day, "switch_count")
fragmentation := readFloat(day, "fragmentation")
maxBlock := readInt(day, "max_block")
heavyAdjacent, _ := readBool(day, "heavy_adjacent")
tags := []string{}
if switchCount >= 3 || fragmentation >= 0.55 {
tags = append(tags, "偏碎")
}
if heavyAdjacent {
tags = append(tags, "高认知相邻")
}
if maxBlock >= 5 {
tags = append(tags, "连续块偏长")
}
detailLines := []string{
fmt.Sprintf("切换次数:%d 次", switchCount),
"碎片化程度:" + formatFloat(fragmentation),
fmt.Sprintf("最长连续块:%d 节", maxBlock),
"科目序列:" + formatSequence(readList(day, "sequence")),
}
items = append(items, BuildItem(
formatDayIndex(dayIndex),
fmt.Sprintf("切换 %d 次,最长连续 %d 节", switchCount, maxBlock),
tags,
detailLines,
day,
))
}
return items
}
func buildRhythmActionItems(actions []any) []ItemView {
if len(actions) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(actions))
for _, raw := range actions {
action, ok := raw.(map[string]any)
if !ok {
continue
}
scope := readMap(action, "candidate_scope")
title := formatIntentCN(readString(action, "intent_code"))
if title == "" {
title = fallbackLabel(readString(action, "action_id"), "建议动作")
}
reads := formatStringList(readList(action, "required_reads"))
writes := formatStringList(readList(action, "candidate_write_tools"))
tags := []string{
fmt.Sprintf("优先级 %d", readInt(action, "priority")),
fallbackLabel(writes, "无写工具"),
}
detailLines := []string{
"需要读取:" + fallbackLabel(reads, "无"),
"候选写工具:" + fallbackLabel(writes, "无"),
"作用范围:" + formatCandidateScope(scope),
"成功标准:" + compactJSON(action["success_criteria"]),
}
items = append(items, BuildItem(
title,
fmt.Sprintf("先读 %s再考虑 %s", fallbackLabel(reads, "相关事实"), fallbackLabel(writes, "局部调整")),
tags,
detailLines,
action,
))
}
return items
}
func highestSeverity(issues []any) string {
best := "info"
bestRank := severityRank(best)
for _, raw := range issues {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
severity := readString(issue, "severity")
if rank := severityRank(severity); rank < bestRank {
best = severity
bestRank = rank
}
}
return strings.ToLower(strings.TrimSpace(best))
}
func firstHighPriorityIssue(issues []any) map[string]any {
var best map[string]any
bestRank := 99
for _, raw := range issues {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
rank := severityRank(readString(issue, "severity"))
if best == nil || rank < bestRank {
best = issue
bestRank = rank
}
}
return best
}
func isProblemRhythmDay(day map[string]any) bool {
heavyAdjacent, _ := readBool(day, "heavy_adjacent")
return readInt(day, "switch_count") >= 3 ||
readFloat(day, "fragmentation") >= 0.55 ||
readInt(day, "max_block") >= 5 ||
heavyAdjacent
}
func formatDayIndex(day int) string {
if day <= 0 {
return "未知日期"
}
return fmt.Sprintf("第 %d 天", day)
}
func formatSequence(rows []any) string {
if len(rows) == 0 {
return "无"
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
continue
}
parts = append(parts, text)
}
if len(parts) == 0 {
return "无"
}
return strings.Join(parts, " -> ")
}
func formatStringList(rows []any) string {
if len(rows) == 0 {
return ""
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
continue
}
parts = append(parts, text)
}
return strings.Join(parts, "、")
}
func formatCandidateScope(scope map[string]any) string {
if len(scope) == 0 {
return "未返回"
}
parts := make([]string, 0, 3)
if days := formatNumberList(readList(scope, "day_range"), "第 %d 天"); days != "" {
parts = append(parts, "日期:"+days)
}
if categories := formatStringList(readList(scope, "categories")); categories != "" {
parts = append(parts, "类别:"+categories)
}
if pool := readString(scope, "task_pool"); pool != "" {
parts = append(parts, "任务池:"+pool)
}
if len(parts) == 0 {
return compactJSON(scope)
}
return strings.Join(parts, "")
}
func formatNumberList(rows []any, pattern string) string {
if len(rows) == 0 {
return ""
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
number := 0
switch typed := raw.(type) {
case float64:
number = int(typed)
case int:
number = typed
default:
continue
}
parts = append(parts, fmt.Sprintf(pattern, number))
}
return strings.Join(parts, "、")
}
func formatIntentCN(intent string) string {
switch strings.TrimSpace(intent) {
case "reduce_switch":
return "减少同日切换"
case "smooth_rhythm":
return "平滑高认知相邻"
case "prefer_swap":
return "优先寻找交换机会"
default:
return strings.TrimSpace(intent)
}
}

View File

@@ -0,0 +1,87 @@
package schedule_analysis
const (
// ViewTypeAnalysisResult 是第三批诊断分析结果卡片的前端识别类型。
ViewTypeAnalysisResult = "schedule.analysis_result"
// ViewVersionAnalysisResult 是当前诊断分析结果结构版本。
ViewVersionAnalysisResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// AnalysisResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据;
// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议;
// 3. collapsed / expanded 保持 map 形态,方便父包直接桥接到现有展示协议。
type AnalysisResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// KVField 是展开态 kv section 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是 expanded.items / section.items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// BuildResultViewInput 是通用 analysis 结果视图 builder 的输入。
//
// 职责边界:
// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区;
// 2. 不负责执行分析工具observation 必须由父包 adapter 传入;
// 3. observation 会原样写入 raw_text不能在这里改写给下游消费的 JSON。
type BuildResultViewInput struct {
Status string
Title string
Subtitle string
Metrics []MetricField
Items []ItemView
Sections []map[string]any
Observation string
MachinePayload map[string]any
}
// BuildFailureViewInput 是失败视图 builder 的输入。
type BuildFailureViewInput struct {
ToolName string
Status string
Title string
Subtitle string
Observation string
ArgFields []KVField
}
// AnalyzeHealthViewInput 是 analyze_health 视图构造输入。
type AnalyzeHealthViewInput struct {
Observation string
ArgFields []KVField
}
// AnalyzeRhythmViewInput 是 analyze_rhythm 视图构造输入。
type AnalyzeRhythmViewInput struct {
Observation string
ArgFields []KVField
}

View File

@@ -0,0 +1,196 @@
package agenttools
import (
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
scheduleanalysis "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule_analysis"
)
type scheduleAnalyzeObserveFunc func(state *schedule.ScheduleState, args map[string]any) string
type scheduleAnalyzeViewBuilder func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView
// scheduleAnalysisAdapterInput 是父包传给 schedule_analysis 子包前的最小上下文。
//
// 职责边界:
// 1. 只携带展示构造需要的 observation 与已本地化参数字段;
// 2. 不把 ToolExecutionResult / ToolArgumentView 传入子包,避免反向依赖父包;
// 3. ObservationText 必须原样来自底层 schedule.AnalyzeXxx不在 adapter 层改写。
type scheduleAnalysisAdapterInput struct {
ToolName string
Args map[string]any
State *schedule.ScheduleState
ObservationText string
ArgFields []scheduleanalysis.KVField
}
// NewAnalyzeHealthToolHandler 为 analyze_health 生成结构化诊断结果。
func NewAnalyzeHealthToolHandler() ToolHandler {
return newScheduleAnalyzeToolHandler(
"analyze_health",
schedule.AnalyzeHealth,
func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView {
return scheduleanalysis.BuildAnalyzeHealthView(scheduleanalysis.AnalyzeHealthViewInput{
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// NewAnalyzeRhythmToolHandler 为 analyze_rhythm 生成结构化诊断结果。
func NewAnalyzeRhythmToolHandler() ToolHandler {
return newScheduleAnalyzeToolHandler(
"analyze_rhythm",
schedule.AnalyzeRhythm,
func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView {
return scheduleanalysis.BuildAnalyzeRhythmView(scheduleanalysis.AnalyzeRhythmViewInput{
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// newScheduleAnalyzeToolHandler 统一构造父包 analysis adapter。
//
// 步骤化说明:
// 1. 先调用现有 schedule.AnalyzeXxx确保 state_snapshot / prompt 摘要消费的 JSON 完全不变;
// 2. 再用 LegacyResultWithState 复用父包参数展示、状态判断和错误信息提取;
// 3. 最后调用 schedule_analysis 子包生成纯展示视图,并包回 ToolExecutionResult。
func newScheduleAnalyzeToolHandler(
toolName string,
observe scheduleAnalyzeObserveFunc,
buildView scheduleAnalyzeViewBuilder,
) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := observe(state, args)
legacy := LegacyResultWithState(toolName, args, state, observation)
input := scheduleAnalysisAdapterInput{
ToolName: toolName,
Args: cloneAnyMap(args),
State: state,
ObservationText: observation,
ArgFields: extractScheduleAnalysisArgumentFields(legacy.ArgumentView),
}
return buildScheduleAnalysisExecutionResult(legacy, args, buildView(input))
}
}
// buildScheduleAnalysisExecutionResult 负责把子包纯展示视图包回父包统一协议。
//
// 职责边界:
// 1. 只做 AnalysisResultView -> ToolDisplayView 的协议桥接;
// 2. 不改写 ObservationText确保主动优化状态快照仍读取原始 JSON
// 3. 错误码与错误文案继续复用父包既有 JSON / 文本解析逻辑。
func buildScheduleAnalysisExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view scheduleanalysis.AnalysisResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = strings.TrimSpace(result.ObservationText)
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = scheduleanalysis.ViewTypeAnalysisResult
}
version := view.Version
if version <= 0 {
version = scheduleanalysis.ViewVersionAnalysisResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
// extractScheduleAnalysisArgumentFields 把父包 ToolArgumentView 投影成子包可消费的 KVField。
//
// 说明:
// 1. 参数字段只做回显,尤其 detail / threshold / hard_categories 不在这里解释为真实生效;
// 2. 子包只接收中文 label/display避免理解父包参数 view 结构;
// 3. 字段缺失时返回空切片,由子包跳过参数 section。
func extractScheduleAnalysisArgumentFields(view *ToolArgumentView) []scheduleanalysis.KVField {
if view == nil || view.Expanded == nil {
return make([]scheduleanalysis.KVField, 0)
}
rawFields, exists := view.Expanded["fields"]
if !exists {
return make([]scheduleanalysis.KVField, 0)
}
fields := make([]scheduleanalysis.KVField, 0)
appendField := func(row map[string]any) {
label, _ := row["label"].(string)
display, _ := row["display"].(string)
label = strings.TrimSpace(label)
display = strings.TrimSpace(display)
if label == "" || display == "" {
return
}
fields = append(fields, scheduleanalysis.BuildKVField(label, display))
}
switch typed := rawFields.(type) {
case []map[string]any:
for _, row := range typed {
appendField(row)
}
case []any:
for _, item := range typed {
row, ok := item.(map[string]any)
if !ok {
continue
}
appendField(row)
}
}
if len(fields) == 0 {
return make([]scheduleanalysis.KVField, 0)
}
return fields
}

View File

@@ -0,0 +1,143 @@
package agenttools
import (
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
)
// formatScheduleDayCN 为参数展示生成中文日期标签。
//
// 职责边界:
// 1. 只服务父包 ArgumentView 的中文展示;
// 2. 不参与 schedule.read_result 卡片构造read 卡片已切到 schedule_read 子包;
// 3. 当 state 缺失或日期映射不存在时,退回稳定的“第 N 天”文本。
func formatScheduleDayCN(state *schedule.ScheduleState, day int) string {
if day <= 0 {
return "未知日期"
}
if state != nil {
if week, dayOfWeek, ok := state.DayToWeekDay(day); ok {
return fmt.Sprintf("第%d天第%d周 %s", day, week, formatScheduleWeekdayCN(dayOfWeek))
}
}
return fmt.Sprintf("第%d天", day)
}
func formatScheduleWeekdayCN(dayOfWeek int) string {
switch dayOfWeek {
case 1:
return "周一"
case 2:
return "周二"
case 3:
return "周三"
case 4:
return "周四"
case 5:
return "周五"
case 6:
return "周六"
case 7:
return "周日"
default:
return fmt.Sprintf("周%d", dayOfWeek)
}
}
func formatScheduleWeekListCN(weeks []int) string {
if len(weeks) == 0 {
return "不限周次"
}
parts := make([]string, 0, len(weeks))
for _, week := range weeks {
if week <= 0 {
continue
}
parts = append(parts, fmt.Sprintf("第%d周", week))
}
if len(parts) == 0 {
return "不限周次"
}
return strings.Join(parts, "、")
}
func formatScheduleSectionListCN(sections []int) string {
if len(sections) == 0 {
return "无"
}
parts := make([]string, 0, len(sections))
for _, section := range sections {
if section <= 0 {
continue
}
parts = append(parts, fmt.Sprintf("第%d节", section))
}
if len(parts) == 0 {
return "无"
}
return strings.Join(parts, "、")
}
func formatTargetPoolStatusCN(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "all":
return "全部任务"
case "existing":
return "已安排任务"
case "suggested":
return "已预排任务"
case "pending":
return "待安排任务"
default:
return fallbackText(status, "任务池")
}
}
func formatSlotTypeLabelCN(slotType string) string {
switch strings.ToLower(strings.TrimSpace(slotType)) {
case "", "empty", "strict":
return "纯空位"
case "embedded_candidate", "embedded", "embed":
return "可嵌入候选"
default:
return strings.TrimSpace(slotType)
}
}
func formatDayScopeLabelCN(scope string) string {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "workday":
return "工作日"
case "weekend":
return "周末"
default:
return "全部日期"
}
}
func formatBoolLabelCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatWeekdayListCN(days []int) string {
if len(days) == 0 {
return "不限星期"
}
parts := make([]string, 0, len(days))
for _, day := range days {
parts = append(parts, formatScheduleWeekdayCN(day))
}
return strings.Join(parts, "、")
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}

View File

@@ -0,0 +1,730 @@
package agenttools
import (
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
)
type scheduleTaskSnapshot struct {
Exists bool
TaskID int
Name string
Status string
Slots []schedule.TaskSlot
DayInfo map[int]schedule.DayMapping
}
type scheduleQueueSnapshot struct {
PendingCount int
CompletedCount int
SkippedCount int
CurrentTaskID int
CurrentAttempt int
LastError string
}
// NewPlaceToolHandler 返回 place 的结构化结果 handler第一轮真实 result_view
func NewPlaceToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return buildScheduleArgErrorResult("place", args, "缺少必填参数 task_id。", state)
}
day, ok := schedule.ArgsInt(args, "day")
if !ok {
return buildScheduleArgErrorResult("place", args, "缺少必填参数 day。", state)
}
slotStart, ok := schedule.ArgsInt(args, "slot_start")
if !ok {
return buildScheduleArgErrorResult("place", args, "缺少必填参数 slot_start。", state)
}
if state == nil {
return buildScheduleArgErrorResult("place", args, "日程状态为空,无法执行预排。", nil)
}
beforeState := state.Clone()
observation := schedule.Place(state, taskID, day, slotStart)
afterState := state.Clone()
before := snapshotTask(beforeState, taskID)
after := snapshotTask(afterState, taskID)
success := after.Exists && taskHasSlotAt(after, day, slotStart) && !sameSlots(before.Slots, after.Slots)
changes := []map[string]any{
buildTaskChange("place", before, after),
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("place", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewMoveToolHandler 返回 move 的结构化结果 handler第一轮真实 result_view
func NewMoveToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return buildScheduleArgErrorResult("move", args, "缺少必填参数 task_id。", state)
}
newDay, ok := schedule.ArgsInt(args, "new_day")
if !ok {
return buildScheduleArgErrorResult("move", args, "缺少必填参数 new_day。", state)
}
newSlotStart, ok := schedule.ArgsInt(args, "new_slot_start")
if !ok {
return buildScheduleArgErrorResult("move", args, "缺少必填参数 new_slot_start。", state)
}
if state == nil {
return buildScheduleArgErrorResult("move", args, "日程状态为空,无法执行移动。", nil)
}
beforeState := state.Clone()
observation := schedule.Move(state, taskID, newDay, newSlotStart)
afterState := state.Clone()
before := snapshotTask(beforeState, taskID)
after := snapshotTask(afterState, taskID)
success := before.Exists && after.Exists && taskHasSlotAt(after, newDay, newSlotStart) && !sameSlots(before.Slots, after.Slots)
changes := []map[string]any{
buildTaskChange("move", before, after),
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("move", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewSwapToolHandler 返回 swap 的结构化结果 handler第一轮真实 result_view
func NewSwapToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskA, ok := schedule.ArgsInt(args, "task_a")
if !ok {
return buildScheduleArgErrorResult("swap", args, "缺少必填参数 task_a。", state)
}
taskB, ok := schedule.ArgsInt(args, "task_b")
if !ok {
return buildScheduleArgErrorResult("swap", args, "缺少必填参数 task_b。", state)
}
if state == nil {
return buildScheduleArgErrorResult("swap", args, "日程状态为空,无法执行交换。", nil)
}
beforeState := state.Clone()
observation := schedule.Swap(state, taskA, taskB)
afterState := state.Clone()
beforeA := snapshotTask(beforeState, taskA)
afterA := snapshotTask(afterState, taskA)
beforeB := snapshotTask(beforeState, taskB)
afterB := snapshotTask(afterState, taskB)
success := beforeA.Exists &&
beforeB.Exists &&
afterA.Exists &&
afterB.Exists &&
sameSlots(beforeA.Slots, afterB.Slots) &&
sameSlots(beforeB.Slots, afterA.Slots)
changes := []map[string]any{
buildTaskChange("swap", beforeA, afterA),
buildTaskChange("swap", beforeB, afterB),
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("swap", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewBatchMoveToolHandler 返回 batch_move 的结构化结果 handler第一轮真实 result_view
func NewBatchMoveToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
if state == nil {
return buildScheduleArgErrorResult("batch_move", args, "日程状态为空,无法执行批量移动。", nil)
}
moves, err := schedule.ArgsMoveList(args)
if err != nil {
return buildScheduleArgErrorResult("batch_move", args, err.Error(), state)
}
beforeState := state.Clone()
observation := schedule.BatchMove(state, moves)
afterState := state.Clone()
changes := make([]map[string]any, 0, len(moves))
success := len(moves) > 0
for _, move := range moves {
before := snapshotTask(beforeState, move.TaskID)
after := snapshotTask(afterState, move.TaskID)
changes = append(changes, buildTaskChange("batch_move", before, after))
if !after.Exists || !taskHasSlotAt(after, move.NewDay, move.NewSlotStart) {
success = false
}
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("batch_move", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewUnplaceToolHandler 返回 unplace 的结构化结果 handler第一轮真实 result_view
func NewUnplaceToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return buildScheduleArgErrorResult("unplace", args, "缺少必填参数 task_id。", state)
}
if state == nil {
return buildScheduleArgErrorResult("unplace", args, "日程状态为空,无法执行移出。", nil)
}
beforeState := state.Clone()
observation := schedule.Unplace(state, taskID)
afterState := state.Clone()
before := snapshotTask(beforeState, taskID)
after := snapshotTask(afterState, taskID)
success := before.Exists && len(before.Slots) > 0 && len(after.Slots) == 0 && strings.EqualFold(strings.TrimSpace(after.Status), schedule.TaskStatusPending)
changes := []map[string]any{
buildTaskChange("unplace", before, after),
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("unplace", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewQueueApplyHeadMoveToolHandler 返回 queue_apply_head_move 的结构化结果 handler第一轮真实 result_view
func NewQueueApplyHeadMoveToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
newDay, dayOK := schedule.ArgsInt(args, "new_day")
newSlotStart, slotOK := schedule.ArgsInt(args, "new_slot_start")
if state == nil {
return buildScheduleArgErrorResult("queue_apply_head_move", args, "日程状态为空,无法执行队首任务应用。", nil)
}
// 1. 执行前先记录 current 任务与队列快照,保证成功/失败都可构造稳定结构化视图。
// 2. 再执行工具并抓取执行后快照,基于 before/after 计算差异,不依赖自然语言解析。
// 3. 如果快照构造异常,外层仍会回退 LegacyResult保证工具主链路不被展示层影响。
beforeState := state.Clone()
beforeQueue := snapshotQueue(beforeState)
currentTaskID := 0
if beforeState != nil && beforeState.RuntimeQueue != nil {
currentTaskID = beforeState.RuntimeQueue.CurrentTaskID
}
beforeTask := snapshotTask(beforeState, currentTaskID)
observation := schedule.QueueApplyHeadMove(state, args)
afterState := state.Clone()
afterQueue := snapshotQueue(afterState)
afterTask := snapshotTask(afterState, currentTaskID)
success := false
if payload, ok := parseObservationJSON(strings.TrimSpace(observation)); ok {
if parsedSuccess, exists := payload["success"].(bool); exists {
success = parsedSuccess
}
}
if !success {
success = currentTaskID > 0 &&
(afterQueue.CompletedCount > beforeQueue.CompletedCount) &&
(afterQueue.CurrentTaskID != currentTaskID)
if dayOK && slotOK && success {
success = taskHasSlotAt(afterTask, newDay, newSlotStart)
}
}
changes := []map[string]any{
buildTaskChange("queue_apply_head_move", beforeTask, afterTask),
}
affectedDays := collectAffectedDays(changes)
queueSnapshot := buildQueueSnapshotWithLabels(beforeQueue, afterQueue)
return buildScheduleOperationResult(
"queue_apply_head_move",
args,
afterState,
observation,
success,
affectedDays,
changes,
queueSnapshot,
pickFailureReason(observation, success),
)
}
}
func buildScheduleArgErrorResult(toolName string, args map[string]any, reason string, state *schedule.ScheduleState) ToolExecutionResult {
observation := fmt.Sprintf("%s失败%s", scheduleOperationFailurePrefix(toolName), strings.TrimSpace(reason))
return buildScheduleOperationResult(
toolName,
args,
state,
observation,
false,
nil,
nil,
nil,
strings.TrimSpace(reason),
)
}
func buildScheduleOperationResult(
toolName string,
args map[string]any,
displayState *schedule.ScheduleState,
observation string,
success bool,
affectedDays []int,
changes []map[string]any,
queueSnapshot map[string]any,
failureReason string,
) ToolExecutionResult {
result := LegacyResultWithState(toolName, args, displayState, observation)
status := ToolStatusFailed
if success {
status = ToolStatusDone
}
operationLabel := resolveOperationLabelCN(toolName)
title := fmt.Sprintf("%s%s", operationLabel, resolveResultTitleSuffix(status))
subtitle := buildScheduleSubtitle(toolName, changes, success)
metrics := []map[string]any{
{"label": "任务数量", "value": fmt.Sprintf("%d个", maxInt(len(changes), countMovesFromArgs(args)))},
{"label": "影响天数", "value": fmt.Sprintf("%d天", len(affectedDays))},
}
collapsed := map[string]any{
"title": title,
"subtitle": subtitle,
"status": status,
"status_label": resolveToolStatusLabelCN(status),
"operation": strings.TrimSpace(toolName),
"operation_label": operationLabel,
"task_count": maxInt(len(changes), countMovesFromArgs(args)),
"affected_days_count": len(affectedDays),
"metrics": metrics,
}
expanded := map[string]any{
"operation": strings.TrimSpace(toolName),
"operation_label": operationLabel,
"changes": changes,
"affected_days": affectedDays,
"affected_days_label": formatAffectedDaysLabel(affectedDays),
"raw_text": observation,
}
if queueSnapshot != nil {
expanded["queue_snapshot"] = queueSnapshot
}
if !success {
expanded["failure_reason"] = strings.TrimSpace(pickFailureReason(failureReason, false))
}
result.Status = status
result.Success = success
result.Summary = title
result.ArgumentsPreview = readArgumentSummary(result.ArgumentView)
result.ResultView = &ToolDisplayView{
ViewType: "schedule.operation_result",
Version: 1,
Collapsed: collapsed,
Expanded: expanded,
}
if !success {
result.ErrorCode = "schedule_operation_failed"
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(pickFailureReason(failureReason, false))
}
}
return EnsureToolResultDefaults(result, args)
}
func buildScheduleSubtitle(operation string, changes []map[string]any, success bool) string {
if len(changes) == 0 {
if success {
return fmt.Sprintf("%s已完成", resolveOperationLabelCN(operation))
}
return fmt.Sprintf("%s执行失败", resolveOperationLabelCN(operation))
}
firstTask := readStringMap(changes[0], "task_label")
firstBefore := readStringMap(changes[0], "before_label")
firstAfter := readStringMap(changes[0], "after_label")
switch strings.TrimSpace(operation) {
case "move":
return fmt.Sprintf("%s从%s移动到%s", firstTask, firstBefore, firstAfter)
case "place":
return fmt.Sprintf("%s预排到%s", firstTask, firstAfter)
case "unplace":
return fmt.Sprintf("%s已从%s移出", firstTask, firstBefore)
case "swap":
if len(changes) >= 2 {
secondTask := readStringMap(changes[1], "task_label")
return fmt.Sprintf("%s 与 %s 已交换位置", firstTask, secondTask)
}
return fmt.Sprintf("%s已交换位置", firstTask)
case "batch_move":
return fmt.Sprintf("批量移动 %d 个任务", len(changes))
case "queue_apply_head_move":
if success {
return fmt.Sprintf("队首任务已移动到%s", firstAfter)
}
return fmt.Sprintf("队首任务移动失败:%s", firstTask)
default:
return fmt.Sprintf("%s%s", resolveOperationLabelCN(operation), firstTask)
}
}
func resolveResultTitleSuffix(status string) string {
switch normalizeToolStatus(status) {
case ToolStatusDone:
return "成功"
case ToolStatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func pickFailureReason(raw string, success bool) string {
if success {
return ""
}
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "操作失败,请查看原始结果。"
}
if payload, ok := parseObservationJSON(trimmed); ok {
if text, ok := readStringFromMap(payload, "result", "error", "reason", "message", "err"); ok {
return strings.TrimSpace(text)
}
}
return trimmed
}
func snapshotTask(state *schedule.ScheduleState, taskID int) scheduleTaskSnapshot {
if state == nil || taskID <= 0 {
return scheduleTaskSnapshot{
Exists: false,
TaskID: taskID,
DayInfo: buildDayInfo(state),
}
}
task := state.TaskByStateID(taskID)
if task == nil {
return scheduleTaskSnapshot{
Exists: false,
TaskID: taskID,
DayInfo: buildDayInfo(state),
}
}
return scheduleTaskSnapshot{
Exists: true,
TaskID: task.StateID,
Name: strings.TrimSpace(task.Name),
Status: strings.TrimSpace(task.Status),
Slots: cloneSlots(task.Slots),
DayInfo: buildDayInfo(state),
}
}
func snapshotQueue(state *schedule.ScheduleState) scheduleQueueSnapshot {
if state == nil || state.RuntimeQueue == nil {
return scheduleQueueSnapshot{}
}
return scheduleQueueSnapshot{
PendingCount: len(state.RuntimeQueue.PendingTaskIDs),
CompletedCount: len(state.RuntimeQueue.CompletedTaskIDs),
SkippedCount: len(state.RuntimeQueue.SkippedTaskIDs),
CurrentTaskID: state.RuntimeQueue.CurrentTaskID,
CurrentAttempt: state.RuntimeQueue.CurrentAttempts,
LastError: strings.TrimSpace(state.RuntimeQueue.LastError),
}
}
func buildQueueSnapshotWithLabels(before scheduleQueueSnapshot, after scheduleQueueSnapshot) map[string]any {
return map[string]any{
"before": queueSnapshotToMap(before),
"after": queueSnapshotToMap(after),
"before_label": queueSummaryLabel(before),
"after_label": queueSummaryLabel(after),
"summary_label": queueSummaryLabel(after),
"last_error_label": strings.TrimSpace(after.LastError),
}
}
func queueSnapshotToMap(s scheduleQueueSnapshot) map[string]any {
return map[string]any{
"pending_count": s.PendingCount,
"completed_count": s.CompletedCount,
"skipped_count": s.SkippedCount,
"current_task_id": s.CurrentTaskID,
"current_attempt": s.CurrentAttempt,
"last_error": s.LastError,
}
}
func queueSummaryLabel(s scheduleQueueSnapshot) string {
return fmt.Sprintf(
"待处理%d个已完成%d个已跳过%d个当前任务%d尝试%d次",
s.PendingCount,
s.CompletedCount,
s.SkippedCount,
s.CurrentTaskID,
s.CurrentAttempt,
)
}
func buildTaskChange(operation string, before scheduleTaskSnapshot, after scheduleTaskSnapshot) map[string]any {
taskLabel := resolveChangeTaskLabel(before, after)
beforeStatusLabel := resolveTaskStatusLabelCN(before.Status)
afterStatusLabel := resolveTaskStatusLabelCN(after.Status)
beforeLabel := formatPlacementLabel(operation, before.Slots, before.Status, false, false)
afterLabel := formatPlacementLabel(operation, after.Slots, after.Status, true, len(before.Slots) > 0)
change := map[string]any{
"task_id": before.TaskID,
"name": firstNonEmpty(before.Name, after.Name),
"status": map[string]any{
"before": before.Status,
"after": after.Status,
},
"before_slots": slotsToView(before.Slots, before.DayInfo),
"after_slots": slotsToView(after.Slots, after.DayInfo),
"task_label": taskLabel,
"before_label": beforeLabel,
"after_label": afterLabel,
"status_label": fmt.Sprintf("%s -> %s", beforeStatusLabel, afterStatusLabel),
"operation_key": operation,
}
return change
}
func resolveChangeTaskLabel(before scheduleTaskSnapshot, after scheduleTaskSnapshot) string {
name := firstNonEmpty(before.Name, after.Name)
if name == "" {
if before.TaskID > 0 {
return fmt.Sprintf("[%d]任务", before.TaskID)
}
return "任务"
}
if before.TaskID > 0 {
return fmt.Sprintf("[%d]%s", before.TaskID, name)
}
return name
}
func resolveTaskStatusLabelCN(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case schedule.TaskStatusPending:
return "待安排"
case schedule.TaskStatusSuggested:
return "已预排"
case schedule.TaskStatusExisting:
return "已安排"
default:
return "未知状态"
}
}
func formatPlacementLabel(operation string, slots []schedule.TaskSlot, status string, isAfter bool, hadBefore bool) string {
if len(slots) > 0 {
return formatSlotsLabelCN(slots)
}
if isAfter && strings.TrimSpace(operation) == "unplace" && hadBefore {
return "已移出"
}
if strings.EqualFold(strings.TrimSpace(status), schedule.TaskStatusPending) {
return "未安排"
}
return "未安排"
}
func formatSlotsLabelCN(slots []schedule.TaskSlot) string {
if len(slots) == 0 {
return "未安排"
}
parts := make([]string, 0, len(slots))
for _, slot := range slots {
parts = append(parts, fmt.Sprintf("%s %s", formatDayLabelCN(slot.Day), formatSlotRangeCN(slot.SlotStart, slot.SlotEnd)))
}
return strings.Join(parts, "、")
}
func formatAffectedDaysLabel(affectedDays []int) string {
if len(affectedDays) == 0 {
return "无"
}
parts := make([]string, 0, len(affectedDays))
for _, day := range affectedDays {
parts = append(parts, formatDayLabelCN(day))
}
return strings.Join(parts, "、")
}
func slotsToView(slots []schedule.TaskSlot, dayInfo map[int]schedule.DayMapping) []map[string]any {
if len(slots) == 0 {
return make([]map[string]any, 0)
}
result := make([]map[string]any, 0, len(slots))
for _, slot := range slots {
entry := map[string]any{
"day": slot.Day,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
}
if info, ok := dayInfo[slot.Day]; ok {
entry["week"] = info.Week
entry["day_of_week"] = info.DayOfWeek
}
result = append(result, entry)
}
return result
}
func collectAffectedDays(changes []map[string]any) []int {
if len(changes) == 0 {
return make([]int, 0)
}
set := make(map[int]struct{})
for _, change := range changes {
collectDaysFromSlotView(set, change["before_slots"])
collectDaysFromSlotView(set, change["after_slots"])
}
days := make([]int, 0, len(set))
for day := range set {
days = append(days, day)
}
sort.Ints(days)
return days
}
func collectDaysFromSlotView(target map[int]struct{}, raw any) {
list, ok := raw.([]map[string]any)
if ok {
for _, item := range list {
day, ok := item["day"].(int)
if ok {
target[day] = struct{}{}
}
}
return
}
anyList, ok := raw.([]any)
if !ok {
return
}
for _, item := range anyList {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
switch day := itemMap["day"].(type) {
case int:
target[day] = struct{}{}
case float64:
target[int(day)] = struct{}{}
}
}
}
func buildDayInfo(state *schedule.ScheduleState) map[int]schedule.DayMapping {
if state == nil || len(state.Window.DayMapping) == 0 {
return map[int]schedule.DayMapping{}
}
info := make(map[int]schedule.DayMapping, len(state.Window.DayMapping))
for _, item := range state.Window.DayMapping {
info[item.DayIndex] = item
}
return info
}
func cloneSlots(slots []schedule.TaskSlot) []schedule.TaskSlot {
if len(slots) == 0 {
return nil
}
out := make([]schedule.TaskSlot, len(slots))
copy(out, slots)
return out
}
func sameSlots(a []schedule.TaskSlot, b []schedule.TaskSlot) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Day != b[i].Day || a[i].SlotStart != b[i].SlotStart || a[i].SlotEnd != b[i].SlotEnd {
return false
}
}
return true
}
func taskHasSlotAt(snapshot scheduleTaskSnapshot, day int, slotStart int) bool {
for _, slot := range snapshot.Slots {
if slot.Day == day && slot.SlotStart == slotStart {
return true
}
}
return false
}
func readStringMap(input map[string]any, key string) string {
value, ok := input[key]
if !ok || value == nil {
return ""
}
text, _ := value.(string)
return strings.TrimSpace(text)
}
func countMovesFromArgs(args map[string]any) int {
moves, ok := args["moves"].([]any)
if !ok {
return 0
}
return len(moves)
}
func maxInt(values ...int) int {
if len(values) == 0 {
return 0
}
maxValue := values[0]
for _, value := range values[1:] {
if value > maxValue {
maxValue = value
}
}
return maxValue
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func scheduleOperationFailurePrefix(toolName string) string {
switch strings.TrimSpace(toolName) {
case "place":
return "放置"
case "move", "queue_apply_head_move":
return "移动"
case "swap":
return "交换"
case "batch_move":
return "批量移动"
case "unplace":
return "移除"
default:
return strings.TrimSpace(toolName)
}
}

View File

@@ -0,0 +1,486 @@
package agenttools
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
scheduleread "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule_read"
)
type queueTaskSlotSnapshot 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 queueTaskSnapshotPayload 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 []queueTaskSlotSnapshot `json:"slots,omitempty"`
}
type queuePopHeadPayload 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 *queueTaskSnapshotPayload `json:"current,omitempty"`
LastError string `json:"last_error,omitempty"`
Error string `json:"error,omitempty"`
}
type queueSkipHeadPayload 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"`
Error string `json:"error,omitempty"`
}
// NewQueuePopHeadToolHandler 返回 queue_pop_head 的结构化读卡片。
//
// 设计说明:
// 1. 这个工具本质是“读取当前队首处理对象”,因此继续走 schedule.read_result
// 2. 不修改 schedule_read 子包,只在父包做一个轻量 adapter复用既有 read 卡片协议;
// 3. 原始 ObservationText 继续保留 JSON 字符串,供 execute/timeline/模型链路复用。
func NewQueuePopHeadToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := schedule.QueuePopHead(state, args)
legacy := LegacyResultWithState("queue_pop_head", args, state, observation)
argFields := extractScheduleReadArgumentFields(legacy.ArgumentView)
payload, machinePayload, ok := decodeQueuePopHeadPayload(observation)
if !ok || normalizeToolStatus(legacy.Status) != ToolStatusDone {
view := scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{
ToolName: "queue_pop_head",
Status: legacy.Status,
Observation: observation,
ArgFields: argFields,
})
return buildScheduleReadExecutionResult(legacy, args, view)
}
view := buildQueuePopHeadReadView(state, observation, payload, machinePayload, argFields)
return buildScheduleReadExecutionResult(legacy, args, view)
}
}
// NewQueueSkipHeadToolHandler 返回 queue_skip_head 的结构化操作卡片。
//
// 设计说明:
// 1. 这个工具会改变 RuntimeQueue因此继续落在 schedule.operation_result 语义下;
// 2. 但它不涉及日程位移,所以这里不强行复用 task change 列表,只展示队列前后快照;
// 3. 这样能去掉 legacy wrapper同时避免把 queue 小尾巴抽成新的大协议。
func NewQueueSkipHeadToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
beforeState := cloneScheduleStateOrNil(state)
beforeQueue := snapshotQueue(beforeState)
currentTaskID := beforeQueue.CurrentTaskID
currentTask := snapshotTask(beforeState, currentTaskID)
observation := schedule.QueueSkipHead(state, args)
afterState := cloneScheduleStateOrNil(state)
afterQueue := snapshotQueue(afterState)
legacy := LegacyResultWithState("queue_skip_head", args, afterState, observation)
payload, machinePayload, ok := decodeQueueSkipHeadPayload(observation)
success := false
if ok {
success = payload.Success
}
if !success {
success = currentTaskID > 0 &&
(afterQueue.SkippedCount > beforeQueue.SkippedCount) &&
(afterQueue.CurrentTaskID != currentTaskID)
}
return buildQueueSkipHeadExecutionResult(
legacy,
args,
observation,
success,
beforeQueue,
afterQueue,
currentTask,
payload,
machinePayload,
)
}
}
func buildQueuePopHeadReadView(
state *schedule.ScheduleState,
observation string,
payload queuePopHeadPayload,
machinePayload map[string]any,
argFields []scheduleread.KVField,
) scheduleread.ReadResultView {
items := make([]scheduleread.ItemView, 0, 1)
sections := make([]map[string]any, 0, 4)
if payload.Current != nil {
currentItem := buildQueuePopHeadCurrentItem(state, payload.Current)
items = append(items, currentItem)
sections = append(sections, scheduleread.BuildItemsSection("当前处理", []scheduleread.ItemView{currentItem}))
}
sections = append(sections, scheduleread.BuildKVSection("队列快照", []scheduleread.KVField{
scheduleread.BuildKVField("待处理", fmt.Sprintf("%d 项", payload.PendingCount)),
scheduleread.BuildKVField("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)),
scheduleread.BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)),
scheduleread.BuildKVField("当前队首", buildQueuePopHeadCurrentLabel(payload.Current)),
}))
if payload.HasHead {
sections = append(sections, buildQueueReadCalloutSection(
"队首任务已就位",
"可以继续调用 queue_apply_head_move 或 queue_skip_head。",
"info",
buildQueuePopHeadHintLines(payload),
))
} else {
sections = append(sections, buildQueueReadCalloutSection(
"当前没有可处理任务",
"队列里没有 pending/current 任务,可以结束队列链路或重新 enqueue。",
"warning",
buildQueuePopHeadHintLines(payload),
))
}
if strings.TrimSpace(payload.LastError) != "" {
sections = append(sections, buildQueueReadCalloutSection(
"最近一次失败原因",
strings.TrimSpace(payload.LastError),
"warning",
[]string{strings.TrimSpace(payload.LastError)},
))
}
if argsSection := scheduleread.BuildArgsSection("查询条件", argFields); argsSection != nil {
sections = append(sections, argsSection)
}
return scheduleread.BuildResultView(scheduleread.BuildResultViewInput{
Status: scheduleread.StatusDone,
Title: buildQueuePopHeadTitle(payload),
Subtitle: buildQueuePopHeadSubtitle(payload),
Metrics: buildQueuePopHeadMetrics(payload),
Items: items,
Sections: sections,
Observation: observation,
MachinePayload: machinePayload,
})
}
func buildQueueSkipHeadExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
observation string,
success bool,
beforeQueue scheduleQueueSnapshot,
afterQueue scheduleQueueSnapshot,
currentTask scheduleTaskSnapshot,
payload queueSkipHeadPayload,
machinePayload map[string]any,
) ToolExecutionResult {
result := legacy
status := ToolStatusFailed
if success {
status = ToolStatusDone
}
taskLabel := resolveChangeTaskLabel(currentTask, currentTask)
queueSnapshot := buildQueueSnapshotWithLabels(beforeQueue, afterQueue)
if len(queueSnapshot) == 0 {
queueSnapshot = make(map[string]any)
}
queueSnapshot["summary_label"] = buildQueueSkipSnapshotTitle(success, taskLabel)
if strings.TrimSpace(payload.Reason) != "" {
queueSnapshot["skip_reason"] = strings.TrimSpace(payload.Reason)
}
if strings.TrimSpace(taskLabel) != "" {
queueSnapshot["skipped_task_label"] = strings.TrimSpace(taskLabel)
}
title := buildQueueSkipHeadTitle(success)
subtitle := buildQueueSkipHeadSubtitle(success, taskLabel, payload.Reason)
collapsed := map[string]any{
"title": title,
"subtitle": subtitle,
"status": status,
"status_label": resolveToolStatusLabelCN(status),
"operation": "queue_skip_head",
"operation_label": resolveToolLabelCN("queue_skip_head"),
"metrics": []map[string]any{
{"label": "待处理", "value": fmt.Sprintf("%d 项", afterQueue.PendingCount)},
{"label": "已跳过", "value": fmt.Sprintf("%d 项", afterQueue.SkippedCount)},
{"label": "当前队首", "value": buildQueueCurrentMetricValue(afterQueue.CurrentTaskID)},
},
}
expanded := map[string]any{
"operation": "queue_skip_head",
"operation_label": resolveToolLabelCN("queue_skip_head"),
"queue_snapshot": queueSnapshot,
"raw_text": observation,
}
if len(machinePayload) > 0 {
expanded["machine_payload"] = machinePayload
}
if !success {
expanded["failure_reason"] = strings.TrimSpace(pickFailureReason(observation, false))
}
result.Status = status
result.Success = success
result.Summary = title
result.ResultView = &ToolDisplayView{
ViewType: "schedule.operation_result",
Version: 1,
Collapsed: collapsed,
Expanded: expanded,
}
if !success {
errorCode, errorMessage := extractToolErrorInfo(observation, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func decodeQueuePopHeadPayload(observation string) (queuePopHeadPayload, map[string]any, bool) {
var payload queuePopHeadPayload
trimmed := strings.TrimSpace(observation)
if trimmed == "" {
return payload, nil, false
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return payload, nil, false
}
raw, ok := parseObservationJSON(trimmed)
return payload, raw, ok
}
func decodeQueueSkipHeadPayload(observation string) (queueSkipHeadPayload, map[string]any, bool) {
var payload queueSkipHeadPayload
trimmed := strings.TrimSpace(observation)
if trimmed == "" {
return payload, nil, false
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return payload, nil, false
}
raw, ok := parseObservationJSON(trimmed)
return payload, raw, ok
}
func buildQueuePopHeadTitle(payload queuePopHeadPayload) string {
if payload.HasHead {
return "已获取队首任务"
}
return "当前队列无可处理任务"
}
func buildQueuePopHeadSubtitle(payload queuePopHeadPayload) string {
if payload.Current != nil {
return fmt.Sprintf("%s待处理 %d 项。", buildQueueTaskLabel(payload.Current), payload.PendingCount)
}
if strings.TrimSpace(payload.LastError) != "" {
return "当前没有队首任务,最近一次失败原因已保留。"
}
return "没有 pending/current 任务,可结束队列链路或重新入队。"
}
func buildQueuePopHeadMetrics(payload queuePopHeadPayload) []scheduleread.MetricField {
return []scheduleread.MetricField{
scheduleread.BuildMetric("待处理", fmt.Sprintf("%d 项", payload.PendingCount)),
scheduleread.BuildMetric("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)),
scheduleread.BuildMetric("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)),
}
}
func buildQueuePopHeadCurrentItem(state *schedule.ScheduleState, payload *queueTaskSnapshotPayload) scheduleread.ItemView {
if payload == nil {
return scheduleread.BuildItem("当前无队首任务", "", nil, nil, nil)
}
tags := []string{"当前处理", resolveTaskStatusLabelCN(payload.Status)}
if payload.Duration > 0 {
tags = append(tags, fmt.Sprintf("%d 节", payload.Duration))
}
return scheduleread.BuildItem(
buildQueueTaskLabel(payload),
buildQueueTaskSubtitle(payload),
tags,
buildQueueTaskDetailLines(state, payload),
map[string]any{
"task_id": payload.TaskID,
"task_class_id": payload.TaskClassID,
"status": payload.Status,
},
)
}
func buildQueuePopHeadCurrentLabel(payload *queueTaskSnapshotPayload) string {
if payload == nil {
return "无"
}
return buildQueueTaskLabel(payload)
}
func buildQueuePopHeadHintLines(payload queuePopHeadPayload) []string {
lines := []string{
fmt.Sprintf("待处理:%d 项", payload.PendingCount),
fmt.Sprintf("已完成:%d 项", payload.CompletedCount),
fmt.Sprintf("已跳过:%d 项", payload.SkippedCount),
}
if payload.Current != nil {
lines = append(lines, fmt.Sprintf("当前队首:%s", buildQueueTaskLabel(payload.Current)))
}
return lines
}
func buildQueueSkipHeadTitle(success bool) string {
if success {
return "已跳过队首任务"
}
return "跳过队首任务失败"
}
func buildQueueSkipHeadSubtitle(success bool, taskLabel string, reason string) string {
if success {
if strings.TrimSpace(taskLabel) != "" {
return fmt.Sprintf("已将 %s 标记为 skipped可继续 queue_pop_head。", strings.TrimSpace(taskLabel))
}
return "已跳过当前队首任务,可继续 queue_pop_head。"
}
if strings.TrimSpace(reason) != "" {
return strings.TrimSpace(reason)
}
return "当前没有可跳过的队首任务。"
}
func buildQueueSkipSnapshotTitle(success bool, taskLabel string) string {
if success && strings.TrimSpace(taskLabel) != "" {
return fmt.Sprintf("已跳过 %s", strings.TrimSpace(taskLabel))
}
if success {
return "队列已跳过当前队首"
}
return "队列状态未变更"
}
func buildQueueCurrentMetricValue(taskID int) string {
if taskID <= 0 {
return "无"
}
return fmt.Sprintf("%d", taskID)
}
func buildQueueTaskLabel(payload *queueTaskSnapshotPayload) string {
if payload == nil {
return "任务"
}
name := strings.TrimSpace(payload.Name)
if name == "" {
return fmt.Sprintf("[%d]任务", payload.TaskID)
}
return fmt.Sprintf("[%d]%s", payload.TaskID, name)
}
func buildQueueTaskSubtitle(payload *queueTaskSnapshotPayload) string {
if payload == nil {
return ""
}
category := strings.TrimSpace(payload.Category)
status := resolveTaskStatusLabelCN(payload.Status)
if category == "" {
return status
}
return fmt.Sprintf("%s%s", category, status)
}
func buildQueueTaskDetailLines(state *schedule.ScheduleState, payload *queueTaskSnapshotPayload) []string {
if payload == nil {
return nil
}
lines := make([]string, 0, 3)
if len(payload.Slots) > 0 {
slotParts := make([]string, 0, len(payload.Slots))
for _, slot := range payload.Slots {
slotParts = append(slotParts, buildQueueSlotLabel(state, slot))
}
lines = append(lines, "时段:"+strings.Join(slotParts, ""))
} else {
lines = append(lines, "时段:当前还未落位")
}
if payload.TaskClassID > 0 {
lines = append(lines, fmt.Sprintf("任务类 ID%d", payload.TaskClassID))
}
if payload.Duration > 0 {
lines = append(lines, fmt.Sprintf("时长需求:%d 节", payload.Duration))
}
return lines
}
func buildQueueSlotLabel(state *schedule.ScheduleState, slot queueTaskSlotSnapshot) string {
dayLabel := formatDayLabelCN(slot.Day)
if state != nil {
if week, dayOfWeek, ok := state.DayToWeekDay(slot.Day); ok {
dayLabel = fmt.Sprintf("%s第%d周 周%d", formatDayLabelCN(slot.Day), week, dayOfWeek)
}
}
if slot.Week > 0 && slot.DayOfWeek > 0 {
dayLabel = fmt.Sprintf("%s第%d周 周%d", formatDayLabelCN(slot.Day), slot.Week, slot.DayOfWeek)
}
return fmt.Sprintf("%s %s", dayLabel, formatSlotRangeCN(slot.SlotStart, slot.SlotEnd))
}
func buildQueueReadCalloutSection(title string, summary string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"summary": strings.TrimSpace(summary),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeQueueDetailLines(detailLines),
}
}
func normalizeQueueDetailLines(lines []string) []string {
if len(lines) == 0 {
return nil
}
out := make([]string, 0, len(lines))
for _, line := range lines {
text := strings.TrimSpace(line)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneScheduleStateOrNil(state *schedule.ScheduleState) *schedule.ScheduleState {
if state == nil {
return nil
}
return state.Clone()
}

View File

@@ -0,0 +1,796 @@
package schedule_read
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
)
// BuildResultView 统一封装 schedule.read_result 结构。
//
// 职责边界:
// 1. 负责把已经计算好的折叠态/展开态内容组装成标准视图。
// 2. 负责在子包内补齐 status / status_label避免依赖父包常量。
// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。
func BuildResultView(input BuildResultViewInput) ReadResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusDone
}
collapsed := CollapsedView{
Title: input.Title,
Subtitle: input.Subtitle,
Status: status,
StatusLabel: resolveStatusLabelCN(status),
Metrics: appendMetricCopy(input.Metrics),
}
expanded := ExpandedView{
Items: appendItemCopy(input.Items),
Sections: cloneSectionList(input.Sections),
RawText: input.Observation,
MachinePayload: cloneAnyMap(input.MachinePayload),
}
return ReadResultView{
ViewType: ViewTypeReadResult,
Version: ViewVersionReadResult,
Collapsed: collapsed.Map(),
Expanded: expanded.Map(),
}
}
// BuildFailureView 统一生成 read 工具失败卡片视图。
//
// 职责边界:
// 1. 负责把失败 observation 提炼成展开态提示与参数回显。
// 2. 不负责决定是否要失败;调用方需要在进入这里前确认失败条件。
// 3. 若标题、副标题未显式传入,则按工具名与 observation 兜底生成。
func BuildFailureView(input BuildFailureViewInput) ReadResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusFailed
}
title := strings.TrimSpace(input.Title)
if title == "" {
title = fmt.Sprintf("%s失败", resolveToolLabelCN(input.ToolName))
}
subtitle := strings.TrimSpace(input.Subtitle)
if subtitle == "" {
subtitle = trimFailureText(input.Observation, "请检查筛选条件后重试。")
}
return BuildResultView(BuildResultViewInput{
Status: status,
Title: title,
Subtitle: subtitle,
Sections: buildReadFailureSections(input.ArgFields, input.Observation),
Observation: input.Observation,
})
}
// BuildMetric 是 collapsed.metrics 的便捷构造器。
func BuildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
// BuildKVField 是 kv section 的便捷构造器。
func BuildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
// BuildItem 是 items 的便捷构造器。
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
// BuildItemsSection 把条目列表包装成 items section。
func BuildItemsSection(title string, items []ItemView) map[string]any {
normalized := make([]map[string]any, 0, len(items))
for _, item := range items {
normalized = append(normalized, item.Map())
}
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": normalized,
}
}
// BuildKVSection 把 kv 列表包装成 kv section。
func BuildKVSection(title string, fields []KVField) map[string]any {
normalized := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
normalized = append(normalized, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": normalized,
}
}
// BuildCalloutSection 把提示块包装成 callout section。
func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
// BuildArgsSection 负责把父包已经格式化好的参数字段拼成查询条件 section。
//
// 职责边界:
// 1. 这里只接受纯 KVField不依赖父包 ToolArgumentView。
// 2. 只过滤空 label / value不补充额外解释文案。
// 3. 没有有效字段时返回 nil交给调用方决定是否追加 section。
func BuildArgsSection(title string, fields []KVField) map[string]any {
if len(fields) == 0 {
return nil
}
valid := make([]KVField, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
valid = append(valid, BuildKVField(label, value))
}
if len(valid) == 0 {
return nil
}
return BuildKVSection(title, valid)
}
func buildReadFailureSections(argFields []KVField, observation string) []map[string]any {
message := trimFailureText(observation, "读取结果失败,请检查参数后重试。")
sections := []map[string]any{
BuildCalloutSection("执行失败", message, "danger", []string{message}),
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", argFields))
return sections
}
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
if section == nil {
return
}
*target = append(*target, section)
}
func appendMetricCopy(metrics []MetricField) []MetricField {
if len(metrics) == 0 {
return make([]MetricField, 0)
}
out := make([]MetricField, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, MetricField{Label: label, Value: value})
}
if len(out) == 0 {
return make([]MetricField, 0)
}
return out
}
func appendItemCopy(items []ItemView) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for _, item := range items {
out = append(out, BuildItem(item.Title, item.Subtitle, item.Tags, item.DetailLines, item.Meta))
}
return out
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func resolveToolLabelCN(toolName string) string {
switch strings.TrimSpace(toolName) {
case "query_available_slots":
return "查询可用时段"
case "query_range":
return "查询范围"
case "query_target_tasks":
return "查询目标任务"
case "get_task_info":
return "查询任务详情"
case "get_overview":
return "查看排程总览"
case "queue_status":
return "查看队列状态"
default:
return "读取结果"
}
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func trimFailureText(observation string, fallback string) string {
if payload, ok := parseObservationJSON(observation); ok {
if message, ok := readStringFromMap(payload, "error", "err", "message", "reason"); ok && strings.TrimSpace(message) != "" {
return strings.TrimSpace(message)
}
}
if strings.TrimSpace(observation) != "" {
return strings.TrimSpace(observation)
}
return strings.TrimSpace(fallback)
}
func formatScheduleDayCN(state *schedule.ScheduleState, day int) string {
if day <= 0 {
return "未知日期"
}
if state != nil {
if week, dayOfWeek, ok := state.DayToWeekDay(day); ok {
return fmt.Sprintf("第%d天第%d周 %s", day, week, formatScheduleWeekdayCN(dayOfWeek))
}
}
return fmt.Sprintf("第%d天", day)
}
func formatScheduleWeekdayCN(dayOfWeek int) string {
switch dayOfWeek {
case 1:
return "周一"
case 2:
return "周二"
case 3:
return "周三"
case 4:
return "周四"
case 5:
return "周五"
case 6:
return "周六"
case 7:
return "周日"
default:
return fmt.Sprintf("周%d", dayOfWeek)
}
}
func formatScheduleSlotRangeCN(start int, end int) string {
if start <= 0 {
return "未知节次"
}
if end <= 0 || end < start {
end = start
}
return fmt.Sprintf("第%d-%d节", start, end)
}
func formatScheduleDaySlotCN(state *schedule.ScheduleState, day int, start int, end int) string {
return fmt.Sprintf("%s %s", formatScheduleDayCN(state, day), formatScheduleSlotRangeCN(start, end))
}
func formatScheduleWeekListCN(weeks []int) string {
if len(weeks) == 0 {
return "不限周次"
}
parts := make([]string, 0, len(weeks))
for _, week := range weeks {
if week <= 0 {
continue
}
parts = append(parts, fmt.Sprintf("第%d周", week))
}
if len(parts) == 0 {
return "不限周次"
}
return strings.Join(parts, "、")
}
func formatScheduleSectionListCN(sections []int) string {
if len(sections) == 0 {
return "无"
}
parts := make([]string, 0, len(sections))
for _, section := range sections {
if section <= 0 {
continue
}
parts = append(parts, fmt.Sprintf("第%d节", section))
}
if len(parts) == 0 {
return "无"
}
return strings.Join(parts, "、")
}
func formatScheduleTaskStatusCN(task schedule.ScheduleTask) string {
switch {
case schedule.IsPendingTask(task):
return "待安排"
case schedule.IsSuggestedTask(task):
return "已预排"
default:
if task.Locked {
return "已安排(固定)"
}
return "已安排"
}
}
func formatTargetTaskStatusCN(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "existing":
return "已安排"
case "suggested":
return "已预排"
case "pending":
return "待安排"
default:
return fallbackText(status, "未标注")
}
}
func formatTargetPoolStatusCN(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "all":
return "全部任务"
case "existing":
return "已安排任务"
case "suggested":
return "已预排任务"
case "pending":
return "待安排任务"
default:
return fallbackText(status, "任务池")
}
}
func formatSlotTypeLabelCN(slotType string) string {
switch strings.ToLower(strings.TrimSpace(slotType)) {
case "", "empty", "strict":
return "纯空位"
case "embedded_candidate", "embedded", "embed":
return "可嵌入候选"
default:
return strings.TrimSpace(slotType)
}
}
func formatDayScopeLabelCN(scope string) string {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "workday":
return "工作日"
case "weekend":
return "周末"
default:
return "全部日期"
}
}
func buildWeekRangeLabelCN(weekFrom int, weekTo int, weekFilter []int) string {
if len(weekFilter) > 0 {
return formatScheduleWeekListCN(weekFilter)
}
if weekFrom > 0 && weekTo > 0 {
if weekFrom == weekTo {
return fmt.Sprintf("第%d周", weekFrom)
}
return fmt.Sprintf("第%d-%d周", weekFrom, weekTo)
}
return "全部周次"
}
func formatBoolLabelCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatWeekdayListCN(days []int) string {
if len(days) == 0 {
return "不限星期"
}
parts := make([]string, 0, len(days))
for _, day := range days {
parts = append(parts, formatScheduleWeekdayCN(day))
}
return strings.Join(parts, "、")
}
func formatScheduleTaskSourceCN(task schedule.ScheduleTask) string {
switch strings.TrimSpace(task.Source) {
case "event":
if isCourseScheduleTaskForRead(task) {
return "课程表"
}
return "日程事件"
case "task_item":
return "任务项"
default:
return fallbackText(task.Source, "未知来源")
}
}
func formatScheduleTaskSlotsBriefCN(state *schedule.ScheduleState, slots []schedule.TaskSlot) string {
if len(slots) == 0 {
return "尚未落位"
}
parts := make([]string, 0, len(slots))
for _, slot := range cloneAndSortTaskSlots(slots) {
parts = append(parts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd))
}
return strings.Join(parts, "")
}
func countScheduleDayOccupiedForRead(state *schedule.ScheduleState, day int) int {
if state == nil {
return 0
}
occupied := 0
for i := range state.Tasks {
task := state.Tasks[i]
if task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day == day {
occupied += slot.SlotEnd - slot.SlotStart + 1
}
}
}
return occupied
}
func countScheduleDayTaskOccupiedForRead(state *schedule.ScheduleState, day int) int {
if state == nil {
return 0
}
occupied := 0
for i := range state.Tasks {
task := state.Tasks[i]
if isCourseScheduleTaskForRead(task) || task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day == day {
occupied += slot.SlotEnd - slot.SlotStart + 1
}
}
}
return occupied
}
type taskOnDay struct {
Task *schedule.ScheduleTask
SlotStart int
SlotEnd int
}
type freeRange struct {
Day int
SlotStart int
SlotEnd int
}
func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, includeCourse bool) []taskOnDay {
if state == nil {
return nil
}
items := make([]taskOnDay, 0)
for i := range state.Tasks {
task := &state.Tasks[i]
if !includeCourse && isCourseScheduleTaskForRead(*task) {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
items = append(items, taskOnDay{
Task: task,
SlotStart: slot.SlotStart,
SlotEnd: slot.SlotEnd,
})
}
}
sort.Slice(items, func(i, j int) bool {
if items[i].SlotStart != items[j].SlotStart {
return items[i].SlotStart < items[j].SlotStart
}
if items[i].SlotEnd != items[j].SlotEnd {
return items[i].SlotEnd < items[j].SlotEnd
}
return items[i].Task.StateID < items[j].Task.StateID
})
return items
}
func listScheduleTasksInRangeForRead(state *schedule.ScheduleState, day int, start int, end int, includeCourse bool) []taskOnDay {
items := listScheduleTasksOnDayForRead(state, day, includeCourse)
filtered := make([]taskOnDay, 0, len(items))
for _, item := range items {
if item.SlotStart <= end && item.SlotEnd >= start {
filtered = append(filtered, item)
}
}
return filtered
}
func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int) []freeRange {
if state == nil {
return nil
}
occupied := make([]bool, 13)
for i := range state.Tasks {
task := state.Tasks[i]
if task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
for section := slot.SlotStart; section <= slot.SlotEnd; section++ {
if section >= 1 && section <= 12 {
occupied[section] = true
}
}
}
}
ranges := make([]freeRange, 0)
start := 0
for section := 1; section <= 12; section++ {
if !occupied[section] {
if start == 0 {
start = section
}
continue
}
if start > 0 {
ranges = append(ranges, freeRange{Day: day, SlotStart: start, SlotEnd: section - 1})
start = 0
}
}
if start > 0 {
ranges = append(ranges, freeRange{Day: day, SlotStart: start, SlotEnd: 12})
}
return ranges
}
func findScheduleHostTaskBySlotForRead(state *schedule.ScheduleState, day int, section int) *schedule.ScheduleTask {
if state == nil {
return nil
}
for i := range state.Tasks {
task := &state.Tasks[i]
if task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day == day && section >= slot.SlotStart && section <= slot.SlotEnd {
return task
}
}
}
return nil
}
func isCourseScheduleTaskForRead(task schedule.ScheduleTask) bool {
if strings.TrimSpace(task.Source) != "event" {
return false
}
if strings.EqualFold(strings.TrimSpace(task.EventType), "course") {
return true
}
return strings.TrimSpace(task.Category) == "课程"
}
func cloneAndSortTaskSlots(slots []schedule.TaskSlot) []schedule.TaskSlot {
if len(slots) == 0 {
return nil
}
out := make([]schedule.TaskSlot, len(slots))
copy(out, slots)
sort.Slice(out, func(i, j int) bool {
if out[i].Day != out[j].Day {
return out[i].Day < out[j].Day
}
if out[i].SlotStart != out[j].SlotStart {
return out[i].SlotStart < out[j].SlotStart
}
return out[i].SlotEnd < out[j].SlotEnd
})
return out
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}
func parseObservationJSON(text string) (map[string]any, bool) {
trimmed := strings.TrimSpace(text)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return nil, false
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, false
}
return payload, true
}
func readStringFromMap(payload map[string]any, keys ...string) (string, bool) {
if len(payload) == 0 {
return "", false
}
for _, key := range keys {
raw, ok := payload[key]
if !ok {
continue
}
value, ok := raw.(string)
if ok {
return value, true
}
}
return "", false
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch current := value.(type) {
case map[string]any:
return cloneAnyMap(current)
case []map[string]any:
out := make([]map[string]any, 0, len(current))
for _, item := range current {
out = append(out, cloneAnyMap(item))
}
return out
case []any:
out := make([]any, 0, len(current))
for _, item := range current {
out = append(out, cloneAnyValue(item))
}
return out
case []string:
out := make([]string, len(current))
copy(out, current)
return out
case []int:
out := make([]int, len(current))
copy(out, current)
return out
default:
return current
}
}
func maxInt(values ...int) int {
if len(values) == 0 {
return 0
}
best := values[0]
for _, value := range values[1:] {
if value > best {
best = value
}
}
return best
}
func toInt(value any) (int, bool) {
switch current := value.(type) {
case int:
return current, true
case int32:
return int(current), true
case int64:
return int(current), true
case float64:
return int(current), true
default:
return 0, false
}
}
func optionalIntValue(value *int) any {
if value == nil {
return nil
}
return *value
}

View File

@@ -0,0 +1,392 @@
package schedule_read
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
)
// BuildOverviewView 构造 get_overview 的纯展示视图。
func BuildOverviewView(input OverviewViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "get_overview",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
totalSlots := input.State.Window.TotalDays * 12
totalOccupied := 0
taskExistingCount := 0
taskSuggestedCount := 0
taskPendingCount := 0
courseExistingCount := 0
for i := range input.State.Tasks {
task := input.State.Tasks[i]
if task.EmbedHost == nil {
for _, slot := range task.Slots {
totalOccupied += slot.SlotEnd - slot.SlotStart + 1
}
}
if isCourseScheduleTaskForRead(task) {
if schedule.IsExistingTask(task) {
courseExistingCount++
}
continue
}
switch {
case schedule.IsPendingTask(task):
taskPendingCount++
case schedule.IsSuggestedTask(task):
taskSuggestedCount++
default:
taskExistingCount++
}
}
dailyItems := make([]ItemView, 0, input.State.Window.TotalDays)
for day := 1; day <= input.State.Window.TotalDays; day++ {
totalDayOccupied := countScheduleDayOccupiedForRead(input.State, day)
taskDayOccupied := countScheduleDayTaskOccupiedForRead(input.State, day)
taskEntries := listScheduleTasksOnDayForRead(input.State, day, false)
detailLines := make([]string, 0, len(taskEntries))
for _, entry := range taskEntries {
detailLines = append(detailLines, fmt.Sprintf(
"[%d]%s%s%s",
entry.Task.StateID,
fallbackText(entry.Task.Name, "未命名任务"),
formatScheduleTaskStatusCN(*entry.Task),
formatScheduleSlotRangeCN(entry.SlotStart, entry.SlotEnd),
))
}
if len(detailLines) == 0 {
detailLines = append(detailLines, "当天没有任务明细。")
}
dailyItems = append(dailyItems, BuildItem(
formatScheduleDayCN(input.State, day),
fmt.Sprintf("总占用 %d/12 节,任务占用 %d/12 节", totalDayOccupied, taskDayOccupied),
[]string{fmt.Sprintf("任务 %d 项", len(taskEntries))},
detailLines,
map[string]any{"day": day},
))
}
taskItems := make([]ItemView, 0, len(input.State.Tasks))
for i := range input.State.Tasks {
task := input.State.Tasks[i]
if isCourseScheduleTaskForRead(task) {
continue
}
detailLines := []string{
"时段:" + formatScheduleTaskSlotsBriefCN(input.State, task.Slots),
"来源:" + formatScheduleTaskSourceCN(task),
}
if task.TaskClassID > 0 {
detailLines = append(detailLines, fmt.Sprintf("任务类 ID%d", task.TaskClassID))
}
taskItems = append(taskItems, BuildItem(
fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
fmt.Sprintf("%s%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(task)),
[]string{formatScheduleTaskStatusCN(task)},
detailLines,
map[string]any{
"task_id": task.StateID,
"task_class_id": task.TaskClassID,
"status": task.Status,
},
))
}
sort.Slice(taskItems, func(i, j int) bool {
leftID, _ := toInt(taskItems[i].Meta["task_id"])
rightID, _ := toInt(taskItems[j].Meta["task_id"])
return leftID < rightID
})
taskClassItems := make([]ItemView, 0, len(input.State.TaskClasses))
for _, meta := range input.State.TaskClasses {
detailLines := []string{
fmt.Sprintf("排程策略:%s", formatTaskClassStrategyCN(meta.Strategy)),
fmt.Sprintf("总预算:%d 节", meta.TotalSlots),
fmt.Sprintf("允许嵌入水课:%s", formatBoolLabelCN(meta.AllowFillerCourse)),
}
if len(meta.ExcludedSlots) > 0 {
detailLines = append(detailLines, "排除节次:"+formatScheduleSectionListCN(meta.ExcludedSlots))
}
if len(meta.ExcludedDaysOfWeek) > 0 {
detailLines = append(detailLines, "排除星期:"+formatWeekdayListCN(meta.ExcludedDaysOfWeek))
}
taskClassItems = append(taskClassItems, BuildItem(
fallbackText(meta.Name, "未命名任务类"),
formatTaskClassStrategyCN(meta.Strategy),
nil,
detailLines,
map[string]any{
"task_class_id": meta.ID,
"strategy": meta.Strategy,
},
))
}
totalFree := totalSlots - totalOccupied
if totalFree < 0 {
totalFree = 0
}
sections := []map[string]any{
BuildKVSection("窗口概况", []KVField{
BuildKVField("规划天数", fmt.Sprintf("%d 天", input.State.Window.TotalDays)),
BuildKVField("总时段", fmt.Sprintf("%d 节", totalSlots)),
BuildKVField("已占用", fmt.Sprintf("%d 节", totalOccupied)),
BuildKVField("空闲", fmt.Sprintf("%d 节", totalFree)),
BuildKVField("课程占位", fmt.Sprintf("%d 项", courseExistingCount)),
BuildKVField("已安排任务", fmt.Sprintf("%d 项", taskExistingCount)),
BuildKVField("已预排任务", fmt.Sprintf("%d 项", taskSuggestedCount)),
BuildKVField("待安排任务", fmt.Sprintf("%d 项", taskPendingCount)),
}),
BuildItemsSection("每日概况", dailyItems),
BuildItemsSection("任务清单", taskItems),
}
if len(taskClassItems) > 0 {
sections = append(sections, BuildItemsSection("任务类约束", taskClassItems))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: "当前排程总览",
Subtitle: fmt.Sprintf("%d 天窗口,已占用 %d/%d 节,待安排 %d 项。", input.State.Window.TotalDays, totalOccupied, totalSlots, taskPendingCount),
Metrics: []MetricField{
BuildMetric("已占用", fmt.Sprintf("%d 节", totalOccupied)),
BuildMetric("空闲", fmt.Sprintf("%d 节", totalFree)),
BuildMetric("待安排", fmt.Sprintf("%d 项", taskPendingCount)),
BuildMetric("课程占位", fmt.Sprintf("%d 项", courseExistingCount)),
},
Items: dailyItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: map[string]any{
"total_days": input.State.Window.TotalDays,
"total_slots": totalSlots,
"total_occupied": totalOccupied,
"task_existing_count": taskExistingCount,
"task_suggested_count": taskSuggestedCount,
"task_pending_count": taskPendingCount,
"course_existing_count": courseExistingCount,
},
})
}
// BuildQueueStatusView 构造 queue_status 的纯展示视图。
func BuildQueueStatusView(input QueueStatusViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "queue_status",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
payload, machinePayload, ok := DecodeQueueStatusPayload(input.Observation)
if !ok {
return BuildFailureView(BuildFailureViewInput{
ToolName: "queue_status",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
items := make([]ItemView, 0, 1+len(payload.NextTaskIDs))
sections := make([]map[string]any, 0, 4)
if payload.Current != nil {
currentItem := buildQueueCurrentItem(input.State, payload.Current, payload.CurrentAttempt)
items = append(items, currentItem)
sections = append(sections, BuildItemsSection("当前处理", []ItemView{currentItem}))
}
nextItems := make([]ItemView, 0, len(payload.NextTaskIDs))
for index, taskID := range payload.NextTaskIDs {
nextItems = append(nextItems, buildQueuePendingItem(input.State, taskID, index))
}
items = append(items, nextItems...)
if len(nextItems) > 0 {
sections = append(sections, BuildItemsSection("待处理队列", nextItems))
}
sections = append(sections, BuildKVSection("运行概况", []KVField{
BuildKVField("待处理", fmt.Sprintf("%d 项", payload.PendingCount)),
BuildKVField("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)),
BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)),
BuildKVField("当前任务", resolveTaskQueueLabelByID(input.State, payload.CurrentTaskID)),
}))
if strings.TrimSpace(payload.LastError) != "" {
sections = append(sections, BuildCalloutSection(
"最近一次失败",
"队列中保留了上一轮 apply 的失败原因。",
"warning",
[]string{strings.TrimSpace(payload.LastError)},
))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
title := fmt.Sprintf("队列待处理 %d 项", payload.PendingCount)
if payload.PendingCount == 0 && payload.CurrentTaskID == 0 {
title = "当前队列为空"
}
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: buildQueueStatusSubtitle(payload),
Metrics: []MetricField{BuildMetric("待处理", fmt.Sprintf("%d 项", payload.PendingCount)), BuildMetric("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)), BuildMetric("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount))},
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: machinePayload,
})
}
// DecodeQueueStatusPayload 解析 queue_status 的 JSON observation。
func DecodeQueueStatusPayload(observation string) (QueueStatusPayload, map[string]any, bool) {
var payload QueueStatusPayload
trimmed := strings.TrimSpace(observation)
if trimmed == "" {
return payload, nil, false
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return payload, nil, false
}
raw, ok := parseObservationJSON(trimmed)
return payload, raw, ok
}
func buildQueueStatusSubtitle(payload QueueStatusPayload) string {
if payload.Current != nil {
return fmt.Sprintf(
"当前处理:[%d]%s第 %d 次尝试。",
payload.Current.TaskID,
fallbackText(payload.Current.Name, "未命名任务"),
maxInt(payload.CurrentAttempt, 1),
)
}
if payload.PendingCount > 0 {
return fmt.Sprintf("队列里还有 %d 项待处理,尚未弹出当前任务。", payload.PendingCount)
}
return "没有待处理任务,也没有正在处理的任务。"
}
// 1. 这里没有强抽成通用 task builder因为 queue_status 既要兼容 payload 快照,
// 2. 也要兼容通过 state 按 task_id 兜底,两类输入结构不同,硬抽反而会增加适配噪音。
func buildQueueCurrentItem(state *schedule.ScheduleState, payload *QueueTaskSnapshot, attempt int) ItemView {
detailLines := buildQueueCurrentDetailLines(state, payload)
detailLines = append(detailLines, fmt.Sprintf("当前尝试:第 %d 次", maxInt(attempt, 1)))
return BuildItem(
fmt.Sprintf("[%d]%s", payload.TaskID, fallbackText(payload.Name, "未命名任务")),
buildQueueCurrentSubtitle(payload),
[]string{"当前处理"},
detailLines,
map[string]any{
"task_id": payload.TaskID,
"status": payload.Status,
"task_class_id": payload.TaskClassID,
},
)
}
func buildQueuePendingItem(state *schedule.ScheduleState, taskID int, index int) ItemView {
task := state.TaskByStateID(taskID)
if task == nil {
return BuildItem(
fmt.Sprintf("[%d]任务", taskID),
fmt.Sprintf("队列第 %d 位", index+1),
[]string{"待处理"},
[]string{"当前状态快照中未找到更多任务详情。"},
map[string]any{"task_id": taskID, "queue_index": index},
)
}
return BuildItem(
fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
buildQueueTaskSubtitle(task),
buildQueueTaskTags(task, false),
buildQueueTaskDetailLines(state, task),
map[string]any{
"task_id": task.StateID,
"queue_index": index,
"status": task.Status,
},
)
}
func buildQueueTaskSubtitle(task *schedule.ScheduleTask) string {
if task == nil {
return "待处理"
}
return fmt.Sprintf("%s%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(*task))
}
func buildQueueTaskTags(task *schedule.ScheduleTask, isCurrent bool) []string {
tags := make([]string, 0, 2)
if isCurrent {
tags = append(tags, "当前处理")
} else {
tags = append(tags, "待处理")
}
if task != nil && task.Duration > 0 {
tags = append(tags, fmt.Sprintf("%d 节", task.Duration))
}
return tags
}
func buildQueueTaskDetailLines(state *schedule.ScheduleState, task *schedule.ScheduleTask) []string {
if task == nil {
return nil
}
lines := []string{"时段:" + formatScheduleTaskSlotsBriefCN(state, task.Slots)}
if task.TaskClassID > 0 {
lines = append(lines, fmt.Sprintf("任务类 ID%d", task.TaskClassID))
}
return lines
}
func buildQueueCurrentSubtitle(payload *QueueTaskSnapshot) string {
if payload == nil {
return "当前处理"
}
return fmt.Sprintf("%s%s", fallbackText(payload.Category, "未分类"), formatTargetTaskStatusCN(payload.Status))
}
func buildQueueCurrentDetailLines(state *schedule.ScheduleState, payload *QueueTaskSnapshot) []string {
if payload == nil {
return nil
}
lines := make([]string, 0, 3)
if len(payload.Slots) > 0 {
slotParts := make([]string, 0, len(payload.Slots))
for _, slot := range payload.Slots {
slotParts = append(slotParts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd))
}
lines = append(lines, "时段:"+strings.Join(slotParts, ""))
} else {
lines = append(lines, "当前还未落位。")
}
if payload.TaskClassID > 0 {
lines = append(lines, fmt.Sprintf("任务类 ID%d", payload.TaskClassID))
}
if payload.Duration > 0 {
lines = append(lines, fmt.Sprintf("时长需求:%d 节", payload.Duration))
}
return lines
}
func formatTaskClassStrategyCN(strategy string) string {
switch strings.TrimSpace(strategy) {
case "steady":
return "均匀分布"
case "rapid":
return "集中突击"
default:
return fallbackText(strategy, "默认")
}
}

View File

@@ -0,0 +1,427 @@
package schedule_read
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
)
// BuildAvailableSlotsView 构造 query_available_slots 的纯展示视图。
func BuildAvailableSlotsView(input AvailableSlotsViewInput) ReadResultView {
payload, machinePayload, ok := DecodeAvailableSlotsPayload(input.Observation)
if !ok || !payload.Success {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_available_slots",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
items := make([]ItemView, 0, len(payload.Slots))
for _, slot := range payload.Slots {
tags := []string{
fmt.Sprintf("第%d周", slot.Week),
formatScheduleWeekdayCN(slot.DayOfWeek),
formatSlotTypeLabelCN(slot.SlotType),
}
detailLines := []string{
fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(input.State, slot.Day, slot.SlotStart, slot.SlotEnd)),
fmt.Sprintf("跨度:%d 节", slot.SlotEnd-slot.SlotStart+1),
}
if strings.Contains(strings.ToLower(strings.TrimSpace(slot.SlotType)), "embed") {
if host := findScheduleHostTaskBySlotForRead(input.State, slot.Day, slot.SlotStart); host != nil {
detailLines = append(detailLines, fmt.Sprintf(
"宿主:[%d]%s%s",
host.StateID,
fallbackText(host.Name, "未命名任务"),
formatScheduleTaskStatusCN(*host),
))
}
}
items = append(items, BuildItem(
formatScheduleDaySlotCN(input.State, slot.Day, slot.SlotStart, slot.SlotEnd),
formatSlotTypeLabelCN(slot.SlotType),
tags,
detailLines,
map[string]any{
"day": slot.Day,
"week": slot.Week,
"day_of_week": slot.DayOfWeek,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
"slot_type": slot.SlotType,
},
))
}
metrics := []MetricField{
BuildMetric("候选时段", fmt.Sprintf("%d 个", payload.Count)),
BuildMetric("纯空位", fmt.Sprintf("%d 个", payload.StrictCount)),
}
if payload.AllowEmbed {
metrics = append(metrics, BuildMetric("可嵌入候选", fmt.Sprintf("%d 个", payload.EmbeddedCount)))
}
sections := []map[string]any{
BuildKVSection("查询概况", []KVField{
BuildKVField("查询跨度", fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1))),
BuildKVField("日期范围", formatDayScopeLabelCN(payload.DayScope)),
BuildKVField("星期过滤", formatWeekdayListCN(payload.DayOfWeek)),
BuildKVField("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)),
BuildKVField("允许嵌入补位", formatBoolLabelCN(payload.AllowEmbed)),
BuildKVField("排除节次", formatScheduleSectionListCN(payload.ExcludeSections)),
}),
}
appendSectionIfPresent(&sections, BuildArgsSection("筛选条件", input.ArgFields))
if len(items) > 0 {
sections = append(sections, BuildItemsSection("候选时段", items))
} else {
sections = append(sections, BuildCalloutSection(
"没有找到可用时段",
"当前筛选条件下没有命中的候选落点。",
"info",
[]string{"可以调整周次、星期、节次范围,或修改是否允许嵌入补位。"},
))
}
title := fmt.Sprintf("找到 %d 个可用时段", payload.Count)
if payload.Count == 0 {
title = "未找到可用时段"
}
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: buildAvailableSlotsSubtitle(payload),
Metrics: metrics,
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: machinePayload,
})
}
// BuildRangeView 根据是否传入 slot_start / slot_end 选择整天或指定范围视图。
func BuildRangeView(input RangeViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_range",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
if input.SlotStart == nil || input.SlotEnd == nil {
return BuildRangeFullDayView(RangeFullDayViewInput{
State: input.State,
Observation: input.Observation,
Day: input.Day,
ArgFields: input.ArgFields,
})
}
return BuildRangeSpecificView(RangeSpecificViewInput{
State: input.State,
Observation: input.Observation,
Day: input.Day,
SlotStart: *input.SlotStart,
SlotEnd: *input.SlotEnd,
ArgFields: input.ArgFields,
})
}
// BuildRangeFullDayView 构造 query_range 整天模式视图。
func BuildRangeFullDayView(input RangeFullDayViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_range",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
totalOccupied := countScheduleDayOccupiedForRead(input.State, input.Day)
taskOccupied := countScheduleDayTaskOccupiedForRead(input.State, input.Day)
freeRanges := findScheduleFreeRangesOnDayForRead(input.State, input.Day)
bandItems := make([]ItemView, 0, 6)
for start := 1; start <= 11; start += 2 {
end := start + 1
occupants := listScheduleTasksInRangeForRead(input.State, input.Day, start, end, true)
detailLines := make([]string, 0, len(occupants))
for _, occupant := range occupants {
detailLines = append(detailLines, buildRangeOccupantLine(*occupant.Task))
}
subtitle := "空闲"
tags := []string{"2 节"}
if len(occupants) > 0 {
subtitle = fmt.Sprintf("%d 个事项", len(occupants))
tags = append(tags, "已占用")
} else {
tags = append(tags, "空闲")
detailLines = append(detailLines, "这一段当前可直接安排任务。")
}
bandItems = append(bandItems, BuildItem(
formatScheduleSlotRangeCN(start, end),
subtitle,
tags,
detailLines,
map[string]any{
"day": input.Day,
"slot_start": start,
"slot_end": end,
},
))
}
freeItems := make([]ItemView, 0, len(freeRanges))
for _, freeRange := range freeRanges {
freeItems = append(freeItems, BuildItem(
formatScheduleSlotRangeCN(freeRange.SlotStart, freeRange.SlotEnd),
fmt.Sprintf("%d 节连续空闲", freeRange.SlotEnd-freeRange.SlotStart+1),
[]string{"连续空闲"},
[]string{fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(input.State, input.Day, freeRange.SlotStart, freeRange.SlotEnd))},
map[string]any{
"day": input.Day,
"slot_start": freeRange.SlotStart,
"slot_end": freeRange.SlotEnd,
},
))
}
taskEntries := listScheduleTasksOnDayForRead(input.State, input.Day, false)
taskItems := make([]ItemView, 0, len(taskEntries))
for _, entry := range taskEntries {
taskItems = append(taskItems, BuildItem(
fmt.Sprintf("[%d]%s", entry.Task.StateID, fallbackText(entry.Task.Name, "未命名任务")),
formatScheduleTaskStatusCN(*entry.Task),
[]string{fallbackText(entry.Task.Category, "未分类")},
[]string{
fmt.Sprintf("时段:%s", formatScheduleSlotRangeCN(entry.SlotStart, entry.SlotEnd)),
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*entry.Task)),
},
map[string]any{
"task_id": entry.Task.StateID,
"slot_start": entry.SlotStart,
"slot_end": entry.SlotEnd,
"task_status": entry.Task.Status,
},
))
}
sections := []map[string]any{
BuildKVSection("当日概况", []KVField{
BuildKVField("总占用", fmt.Sprintf("%d/12 节", totalOccupied)),
BuildKVField("任务占用", fmt.Sprintf("%d/12 节", taskOccupied)),
BuildKVField("连续空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
}),
BuildItemsSection("时段分布", bandItems),
}
if len(freeItems) > 0 {
sections = append(sections, BuildItemsSection("连续空闲区", freeItems))
}
if embeddableItems := buildEmbeddableItemsForDay(input.State, input.Day); len(embeddableItems) > 0 {
sections = append(sections, BuildItemsSection("可嵌入时段", embeddableItems))
}
if len(taskItems) > 0 {
sections = append(sections, BuildItemsSection("当日任务", taskItems))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: fmt.Sprintf("%s全日概况", formatScheduleDayCN(input.State, input.Day)),
Subtitle: fmt.Sprintf("已占用 %d/12 节,连续空闲 %d 段。", totalOccupied, len(freeRanges)),
Metrics: []MetricField{
BuildMetric("总占用", fmt.Sprintf("%d/12", totalOccupied)),
BuildMetric("任务占用", fmt.Sprintf("%d/12", taskOccupied)),
BuildMetric("空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
},
Items: bandItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: map[string]any{
"mode": "full_day",
"day": input.Day,
"occupied_slots": totalOccupied,
"task_occupied_slots": taskOccupied,
"free_range_count": len(freeRanges),
},
})
}
// BuildRangeSpecificView 构造 query_range 指定范围模式视图。
func BuildRangeSpecificView(input RangeSpecificViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_range",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
total := input.SlotEnd - input.SlotStart + 1
freeCount := 0
slotItems := make([]ItemView, 0, total)
for section := input.SlotStart; section <= input.SlotEnd; section++ {
occupants := listScheduleTasksInRangeForRead(input.State, input.Day, section, section, true)
detailLines := make([]string, 0, len(occupants))
for _, occupant := range occupants {
detailLines = append(detailLines, buildRangeOccupantLine(*occupant.Task))
}
subtitle := "空闲"
tags := []string{"空闲"}
if len(occupants) > 0 {
subtitle = fmt.Sprintf("%d 个事项", len(occupants))
tags = []string{"已占用"}
} else {
freeCount++
detailLines = append(detailLines, "这一节当前为空。")
}
slotItems = append(slotItems, BuildItem(
fmt.Sprintf("第%d节", section),
subtitle,
tags,
detailLines,
map[string]any{
"day": input.Day,
"slot_start": section,
"slot_end": section,
},
))
}
seen := make(map[int]struct{})
rangeTaskItems := make([]ItemView, 0)
for _, occupant := range listScheduleTasksInRangeForRead(input.State, input.Day, input.SlotStart, input.SlotEnd, true) {
if _, exists := seen[occupant.Task.StateID]; exists {
continue
}
seen[occupant.Task.StateID] = struct{}{}
rangeTaskItems = append(rangeTaskItems, BuildItem(
fmt.Sprintf("[%d]%s", occupant.Task.StateID, fallbackText(occupant.Task.Name, "未命名任务")),
formatScheduleTaskStatusCN(*occupant.Task),
[]string{fallbackText(occupant.Task.Category, "未分类")},
[]string{
fmt.Sprintf("覆盖范围:%s", formatScheduleDaySlotCN(input.State, input.Day, occupant.SlotStart, occupant.SlotEnd)),
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*occupant.Task)),
},
map[string]any{
"task_id": occupant.Task.StateID,
"slot_start": occupant.SlotStart,
"slot_end": occupant.SlotEnd,
"task_status": occupant.Task.Status,
},
))
}
sections := []map[string]any{
BuildKVSection("范围概况", []KVField{
BuildKVField("查询范围", formatScheduleSlotRangeCN(input.SlotStart, input.SlotEnd)),
BuildKVField("总节数", fmt.Sprintf("%d 节", total)),
BuildKVField("空闲节数", fmt.Sprintf("%d 节", freeCount)),
BuildKVField("占用节数", fmt.Sprintf("%d 节", total-freeCount)),
}),
BuildItemsSection("逐节情况", slotItems),
}
if len(rangeTaskItems) > 0 {
sections = append(sections, BuildItemsSection("范围内事项", rangeTaskItems))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: fmt.Sprintf("%s %s", formatScheduleDayCN(input.State, input.Day), formatScheduleSlotRangeCN(input.SlotStart, input.SlotEnd)),
Subtitle: fmt.Sprintf("共 %d 节,空闲 %d 节,占用 %d 节。", total, freeCount, total-freeCount),
Metrics: []MetricField{
BuildMetric("总节数", fmt.Sprintf("%d 节", total)),
BuildMetric("空闲", fmt.Sprintf("%d 节", freeCount)),
BuildMetric("事项", fmt.Sprintf("%d 个", len(rangeTaskItems))),
},
Items: slotItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: map[string]any{
"mode": "specific_range",
"day": input.Day,
"slot_start": input.SlotStart,
"slot_end": input.SlotEnd,
"free_count": freeCount,
"occupied_count": total - freeCount,
},
})
}
// DecodeAvailableSlotsPayload 解析 query_available_slots 的 JSON observation。
func DecodeAvailableSlotsPayload(observation string) (AvailableSlotsPayload, map[string]any, bool) {
var payload AvailableSlotsPayload
trimmed := strings.TrimSpace(observation)
if trimmed == "" {
return payload, nil, false
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return payload, nil, false
}
raw, ok := parseObservationJSON(trimmed)
return payload, raw, ok
}
func buildAvailableSlotsSubtitle(payload AvailableSlotsPayload) string {
parts := []string{
fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1)),
formatDayScopeLabelCN(payload.DayScope),
buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter),
}
if len(payload.DayOfWeek) > 0 {
parts = append(parts, formatWeekdayListCN(payload.DayOfWeek))
}
if payload.AllowEmbed {
parts = append(parts, "允许补充可嵌入候选")
} else {
parts = append(parts, "仅查看纯空位")
}
return strings.Join(parts, "")
}
func buildEmbeddableItemsForDay(state *schedule.ScheduleState, day int) []ItemView {
if state == nil {
return nil
}
items := make([]ItemView, 0)
for i := range state.Tasks {
task := state.Tasks[i]
if !task.CanEmbed || task.EmbeddedBy != nil || task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
items = append(items, BuildItem(
formatScheduleSlotRangeCN(slot.SlotStart, slot.SlotEnd),
fmt.Sprintf("可嵌入到 [%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
[]string{fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(task)},
[]string{
fmt.Sprintf("宿主时段:%s", formatScheduleDaySlotCN(state, day, slot.SlotStart, slot.SlotEnd)),
"该时段允许放入更短的嵌入任务。",
},
map[string]any{
"host_task_id": task.StateID,
"day": day,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
},
))
}
}
return items
}
func buildRangeOccupantLine(task schedule.ScheduleTask) string {
return fmt.Sprintf(
"[%d]%s%s%s",
task.StateID,
fallbackText(task.Name, "未命名任务"),
formatScheduleTaskStatusCN(task),
fallbackText(task.Category, "未分类"),
)
}

View File

@@ -0,0 +1,301 @@
package schedule_read
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
)
// BuildTargetTasksView 构造 query_target_tasks 的纯展示视图。
func BuildTargetTasksView(input TargetTasksViewInput) ReadResultView {
payload, machinePayload, ok := DecodeTargetTasksPayload(input.Observation)
if !ok || !payload.Success {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_target_tasks",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
items := make([]ItemView, 0, len(payload.Items))
for _, item := range payload.Items {
items = append(items, BuildItem(
fmt.Sprintf("[%d]%s", item.TaskID, fallbackText(item.Name, "未命名任务")),
buildTargetTaskSubtitle(item),
buildTargetTaskTags(item),
buildTargetTaskDetailLines(input.State, item),
map[string]any{
"task_id": item.TaskID,
"category": item.Category,
"status": item.Status,
"duration": item.Duration,
"task_class_id": item.TaskClassID,
},
))
}
metrics := []MetricField{
BuildMetric("候选任务", fmt.Sprintf("%d 项", payload.Count)),
BuildMetric("任务池", formatTargetPoolStatusCN(payload.Status)),
}
if payload.Enqueue {
metrics = append(metrics, BuildMetric("已入队", fmt.Sprintf("%d 项", payload.Enqueued)))
}
sections := []map[string]any{
BuildKVSection("筛选概况", []KVField{
BuildKVField("任务池", formatTargetPoolStatusCN(payload.Status)),
BuildKVField("日期范围", formatDayScopeLabelCN(payload.DayScope)),
BuildKVField("星期过滤", formatWeekdayListCN(payload.DayOfWeek)),
BuildKVField("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)),
BuildKVField("是否入队", formatBoolLabelCN(payload.Enqueue)),
}),
}
appendSectionIfPresent(&sections, BuildArgsSection("筛选条件", input.ArgFields))
if payload.Queue != nil {
sections = append(sections, BuildKVSection("队列状态", []KVField{
BuildKVField("待处理", fmt.Sprintf("%d 项", payload.Queue.PendingCount)),
BuildKVField("已完成", fmt.Sprintf("%d 项", payload.Queue.CompletedCount)),
BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.Queue.SkippedCount)),
BuildKVField("当前任务", resolveTaskQueueLabelByID(input.State, payload.Queue.CurrentTaskID)),
}))
}
if len(items) > 0 {
sections = append(sections, BuildItemsSection("候选任务", items))
} else {
sections = append(sections, BuildCalloutSection(
"没有命中任务",
"当前筛选条件下没有找到候选任务。",
"info",
[]string{"可以放宽状态、日期或任务 ID 过滤条件后再试。"},
))
}
title := fmt.Sprintf("找到 %d 个候选任务", payload.Count)
if payload.Count == 0 {
title = "未找到候选任务"
}
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: buildTargetTasksSummarySubtitle(payload),
Metrics: metrics,
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: machinePayload,
})
}
// BuildTaskInfoView 构造 get_task_info 的纯展示视图。
func BuildTaskInfoView(input TaskInfoViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "get_task_info",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
task := input.State.TaskByStateID(input.TaskID)
if task == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "get_task_info",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
slotItems := make([]ItemView, 0, len(task.Slots))
for _, slot := range cloneAndSortTaskSlots(task.Slots) {
slotItems = append(slotItems, BuildItem(
formatScheduleDaySlotCN(input.State, slot.Day, slot.SlotStart, slot.SlotEnd),
formatScheduleTaskStatusCN(*task),
[]string{fallbackText(task.Category, "未分类")},
[]string{
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*task)),
fmt.Sprintf("时长:%d 节", slot.SlotEnd-slot.SlotStart+1),
},
map[string]any{
"day": slot.Day,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
},
))
}
fields := []KVField{
BuildKVField("类别", fallbackText(task.Category, "未分类")),
BuildKVField("状态", formatScheduleTaskStatusCN(*task)),
BuildKVField("来源", formatScheduleTaskSourceCN(*task)),
BuildKVField("落位情况", buildTaskPlacementLabel(task)),
BuildKVField("时长需求", buildTaskDurationLabel(task)),
}
if task.TaskClassID > 0 {
fields = append(fields, BuildKVField("任务类 ID", fmt.Sprintf("%d", task.TaskClassID)))
}
if task.CanEmbed {
fields = append(fields, BuildKVField("可作为宿主", "是"))
}
sections := []map[string]any{
BuildKVSection("基本信息", fields),
}
if len(slotItems) > 0 {
sections = append(sections, BuildItemsSection("占用时段", slotItems))
}
if relationLines := buildTaskRelationLines(input.State, task); len(relationLines) > 0 {
sections = append(sections, BuildCalloutSection("嵌入关系", "当前任务存在宿主或宿体关系。", "info", relationLines))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
Subtitle: fmt.Sprintf("%s%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(*task)),
Metrics: []MetricField{
BuildMetric("状态", formatScheduleTaskStatusCN(*task)),
BuildMetric("时长", buildTaskDurationLabel(task)),
BuildMetric("落位", buildTaskPlacementLabel(task)),
},
Items: slotItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: map[string]any{
"task_id": task.StateID,
"source": task.Source,
"status": task.Status,
"task_class_id": task.TaskClassID,
"can_embed": task.CanEmbed,
"embedded_by": optionalIntValue(task.EmbeddedBy),
"embed_host": optionalIntValue(task.EmbedHost),
},
})
}
// DecodeTargetTasksPayload 解析 query_target_tasks 的 JSON observation。
func DecodeTargetTasksPayload(observation string) (TargetTasksPayload, map[string]any, bool) {
var payload TargetTasksPayload
trimmed := strings.TrimSpace(observation)
if trimmed == "" {
return payload, nil, false
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return payload, nil, false
}
raw, ok := parseObservationJSON(trimmed)
return payload, raw, ok
}
func buildTargetTasksSummarySubtitle(payload TargetTasksPayload) string {
parts := []string{
formatTargetPoolStatusCN(payload.Status),
formatDayScopeLabelCN(payload.DayScope),
buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter),
}
if len(payload.DayOfWeek) > 0 {
parts = append(parts, formatWeekdayListCN(payload.DayOfWeek))
}
if payload.Enqueue {
parts = append(parts, fmt.Sprintf("已入队 %d 项", payload.Enqueued))
}
return strings.Join(parts, "")
}
func buildTargetTaskSubtitle(item TargetTaskRecord) string {
return fmt.Sprintf("%s%s", fallbackText(item.Category, "未分类"), formatTargetTaskStatusCN(item.Status))
}
func buildTargetTaskTags(item TargetTaskRecord) []string {
tags := []string{formatTargetTaskStatusCN(item.Status)}
if item.Duration > 0 {
tags = append(tags, fmt.Sprintf("%d 节", item.Duration))
}
if item.TaskClassID > 0 {
tags = append(tags, fmt.Sprintf("任务类 %d", item.TaskClassID))
}
return tags
}
func buildTargetTaskDetailLines(state *schedule.ScheduleState, item TargetTaskRecord) []string {
lines := make([]string, 0, 3)
if len(item.Slots) == 0 {
lines = append(lines, fmt.Sprintf("当前未落位,仍需要 %s。", buildTaskDurationText(item.Duration)))
} else {
slotParts := make([]string, 0, len(item.Slots))
for _, slot := range item.Slots {
slotParts = append(slotParts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd))
}
lines = append(lines, "时段:"+strings.Join(slotParts, ""))
}
if item.TaskClassID > 0 {
lines = append(lines, fmt.Sprintf("任务类 ID%d", item.TaskClassID))
}
return lines
}
func resolveTaskQueueLabelByID(state *schedule.ScheduleState, taskID int) string {
if taskID <= 0 {
return "无"
}
if state == nil {
return fmt.Sprintf("[%d]任务", taskID)
}
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("[%d]任务", taskID)
}
return fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务"))
}
func buildTaskDurationLabel(task *schedule.ScheduleTask) string {
if task == nil {
return "未标注"
}
if task.Duration > 0 {
return fmt.Sprintf("%d 节", task.Duration)
}
total := 0
for _, slot := range task.Slots {
total += slot.SlotEnd - slot.SlotStart + 1
}
if total <= 0 {
return "未标注"
}
return fmt.Sprintf("%d 节", total)
}
func buildTaskDurationText(duration int) string {
if duration <= 0 {
return "未标注时长"
}
return fmt.Sprintf("%d 节连续时段", duration)
}
func buildTaskPlacementLabel(task *schedule.ScheduleTask) string {
if task == nil || len(task.Slots) == 0 {
return "尚未落位"
}
if len(task.Slots) == 1 {
slot := task.Slots[0]
return fmt.Sprintf("1 段(第%d天 第%d-%d节", slot.Day, slot.SlotStart, slot.SlotEnd)
}
return fmt.Sprintf("%d 段", len(task.Slots))
}
func buildTaskRelationLines(state *schedule.ScheduleState, task *schedule.ScheduleTask) []string {
if task == nil {
return nil
}
lines := make([]string, 0, 2)
if task.EmbeddedBy != nil {
lines = append(lines, "当前已嵌入任务:"+resolveTaskQueueLabelByID(state, *task.EmbeddedBy))
} else if task.CanEmbed {
lines = append(lines, "当前没有嵌入其他任务。")
}
if task.EmbedHost != nil {
lines = append(lines, "嵌入宿主:"+resolveTaskQueueLabelByID(state, *task.EmbedHost))
}
return lines
}

View File

@@ -0,0 +1,312 @@
package schedule_read
import (
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
)
const (
// ViewTypeReadResult 固定为第二批 read 结果卡片的前端识别类型。
ViewTypeReadResult = "schedule.read_result"
// ViewVersionReadResult 固定为当前 read 结果结构版本。
ViewVersionReadResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// ReadResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据。
// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议。
// 3. collapsed / expanded 继续保留 map 形态,方便父包直接桥接到现有展示协议。
type ReadResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// CollapsedView 表示折叠态卡片数据。
type CollapsedView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
Metrics []MetricField `json:"metrics"`
}
// ExpandedView 表示展开态卡片数据。
type ExpandedView struct {
Items []ItemView `json:"items"`
Sections []map[string]any `json:"sections"`
RawText string `json:"raw_text"`
MachinePayload map[string]any `json:"machine_payload,omitempty"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// KVField 是展开态 kv section 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是展开态 items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// BuildResultViewInput 是通用 read 结果视图 builder 的输入。
//
// 职责边界:
// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区。
// 2. 不负责判断工具是否执行成功;调用方需要在进入这里前确定 status。
// 3. observation 会原样写入 raw_text不能在这里改写给 LLM 的观察文本语义。
type BuildResultViewInput struct {
Status string
Title string
Subtitle string
Metrics []MetricField
Items []ItemView
Sections []map[string]any
Observation string
MachinePayload map[string]any
}
// BuildFailureViewInput 是通用失败视图 builder 的输入。
type BuildFailureViewInput struct {
ToolName string
Status string
Title string
Subtitle string
Observation string
ArgFields []KVField
}
// AvailableSlotsViewInput 是 query_available_slots 视图构造输入。
type AvailableSlotsViewInput struct {
State *schedule.ScheduleState
Observation string
ArgFields []KVField
}
// RangeViewInput 是 query_range 统一入口输入。
type RangeViewInput struct {
State *schedule.ScheduleState
Observation string
Day int
SlotStart *int
SlotEnd *int
ArgFields []KVField
}
// RangeFullDayViewInput 是 query_range 整天模式输入。
type RangeFullDayViewInput struct {
State *schedule.ScheduleState
Observation string
Day int
ArgFields []KVField
}
// RangeSpecificViewInput 是 query_range 指定时段模式输入。
type RangeSpecificViewInput struct {
State *schedule.ScheduleState
Observation string
Day int
SlotStart int
SlotEnd int
ArgFields []KVField
}
// TargetTasksViewInput 是 query_target_tasks 视图构造输入。
type TargetTasksViewInput struct {
State *schedule.ScheduleState
Observation string
ArgFields []KVField
}
// TaskInfoViewInput 是 get_task_info 视图构造输入。
type TaskInfoViewInput struct {
State *schedule.ScheduleState
Observation string
TaskID int
ArgFields []KVField
}
// OverviewViewInput 是 get_overview 视图构造输入。
type OverviewViewInput struct {
State *schedule.ScheduleState
Observation string
ArgFields []KVField
}
// QueueStatusViewInput 是 queue_status 视图构造输入。
type QueueStatusViewInput struct {
State *schedule.ScheduleState
Observation string
ArgFields []KVField
}
// AvailableSlotsPayload 是 query_available_slots 的结构化结果。
type AvailableSlotsPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
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 []AvailableSlotRecord `json:"slots"`
}
// AvailableSlotRecord 是 query_available_slots 单条时段记录。
type AvailableSlotRecord 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"`
}
// TargetTasksPayload 是 query_target_tasks 的结构化结果。
type TargetTasksPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
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 *TargetTasksQueueRecord `json:"queue"`
Items []TargetTaskRecord `json:"items"`
}
// TargetTasksQueueRecord 是目标任务查询里的队列快照。
type TargetTasksQueueRecord struct {
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id"`
CurrentAttempt int `json:"current_attempt"`
}
// TargetTaskRecord 是 query_target_tasks 单条任务记录。
type TargetTaskRecord struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category"`
Status string `json:"status"`
Duration int `json:"duration"`
TaskClassID int `json:"task_class_id"`
Slots []TargetTaskSlotInfo `json:"slots"`
}
// TargetTaskSlotInfo 是目标任务时段信息。
type TargetTaskSlotInfo 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"`
}
// QueueStatusPayload 是 queue_status 的结构化结果。
type QueueStatusPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id"`
CurrentAttempt int `json:"current_attempt"`
LastError string `json:"last_error"`
NextTaskIDs []int `json:"next_task_ids"`
Current *QueueTaskSnapshot `json:"current"`
}
// QueueTaskSnapshot 是 queue_status 当前任务快照。
type QueueTaskSnapshot struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category"`
Status string `json:"status"`
Duration int `json:"duration"`
TaskClassID int `json:"task_class_id"`
Slots []TargetTaskSlotInfo `json:"slots"`
}
func (view CollapsedView) Map() map[string]any {
metrics := make([]map[string]any, 0, len(view.Metrics))
for _, metric := range view.Metrics {
metrics = append(metrics, map[string]any{
"label": strings.TrimSpace(metric.Label),
"value": strings.TrimSpace(metric.Value),
})
}
return map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"status": normalizeStatus(view.Status),
"status_label": strings.TrimSpace(view.StatusLabel),
"metrics": metrics,
}
}
func (view ExpandedView) Map() map[string]any {
items := make([]map[string]any, 0, len(view.Items))
for _, item := range view.Items {
items = append(items, item.Map())
}
out := map[string]any{
"items": items,
"sections": cloneSectionList(view.Sections),
"raw_text": view.RawText,
}
if len(view.MachinePayload) > 0 {
out["machine_payload"] = cloneAnyMap(view.MachinePayload)
}
return out
}
func (view ItemView) Map() map[string]any {
item := map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"tags": normalizeStringSlice(view.Tags),
"detail_lines": normalizeStringSlice(view.DetailLines),
}
if len(view.Meta) > 0 {
item["meta"] = cloneAnyMap(view.Meta)
}
return item
}

View File

@@ -0,0 +1,345 @@
package agenttools
import (
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
scheduleread "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule_read"
)
type scheduleReadObserveFunc func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult)
type scheduleReadViewBuilder func(input scheduleReadAdapterInput) scheduleread.ReadResultView
// scheduleReadAdapterInput 是父包传给 schedule_read 子包前的最小上下文。
//
// 职责边界:
// 1. 只携带展示构造需要的 state、args、observation 与已本地化参数字段;
// 2. 不把 ToolExecutionResult / ToolArgumentView 传入子包,避免反向依赖父包;
// 3. ObservationText 必须原样来自底层 schedule 工具,不在 adapter 层改写。
type scheduleReadAdapterInput struct {
ToolName string
Args map[string]any
State *schedule.ScheduleState
ObservationText string
ArgFields []scheduleread.KVField
}
// NewQueryAvailableSlotsToolHandler 为 query_available_slots 生成结构化读结果。
func NewQueryAvailableSlotsToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"query_available_slots",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
return schedule.QueryAvailableSlots(state, args), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
return scheduleread.BuildAvailableSlotsView(scheduleread.AvailableSlotsViewInput{
State: input.State,
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// NewQueryRangeToolHandler 为 query_range 生成结构化读结果。
func NewQueryRangeToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"query_range",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
day, ok := schedule.ArgsInt(args, "day")
if !ok {
result := buildScheduleReadFailureResult("query_range", args, state, "查询失败:缺少必填参数 day。")
return "", &result
}
if state == nil {
result := buildScheduleReadFailureResult("query_range", args, nil, "查询失败:日程状态为空,无法读取时间范围。")
return "", &result
}
return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end")), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
day, _ := schedule.ArgsInt(input.Args, "day")
return scheduleread.BuildRangeView(scheduleread.RangeViewInput{
State: input.State,
Observation: input.ObservationText,
Day: day,
SlotStart: schedule.ArgsIntPtr(input.Args, "slot_start"),
SlotEnd: schedule.ArgsIntPtr(input.Args, "slot_end"),
ArgFields: input.ArgFields,
})
},
)
}
// NewQueryTargetTasksToolHandler 为 query_target_tasks 生成结构化读结果。
func NewQueryTargetTasksToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"query_target_tasks",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
return schedule.QueryTargetTasks(state, args), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
return scheduleread.BuildTargetTasksView(scheduleread.TargetTasksViewInput{
State: input.State,
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// NewGetTaskInfoToolHandler 为 get_task_info 生成结构化读结果。
func NewGetTaskInfoToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"get_task_info",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
result := buildScheduleReadFailureResult("get_task_info", args, state, "查询失败:缺少必填参数 task_id。")
return "", &result
}
if state == nil {
result := buildScheduleReadFailureResult("get_task_info", args, nil, "查询失败:日程状态为空,无法读取任务详情。")
return "", &result
}
return schedule.GetTaskInfo(state, taskID), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
taskID, _ := schedule.ArgsInt(input.Args, "task_id")
return scheduleread.BuildTaskInfoView(scheduleread.TaskInfoViewInput{
State: input.State,
Observation: input.ObservationText,
TaskID: taskID,
ArgFields: input.ArgFields,
})
},
)
}
// NewGetOverviewToolHandler 为 get_overview 生成结构化读结果。
func NewGetOverviewToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"get_overview",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
if state == nil {
result := buildScheduleReadFailureResult("get_overview", args, nil, "查看总览失败:日程状态为空,无法读取总览。")
return "", &result
}
return schedule.GetOverview(state), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
return scheduleread.BuildOverviewView(scheduleread.OverviewViewInput{
State: input.State,
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// NewQueueStatusToolHandler 为 queue_status 生成结构化读结果。
func NewQueueStatusToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"queue_status",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
observation := schedule.QueueStatus(state, args)
if state == nil {
result := buildScheduleReadFailureResult("queue_status", args, nil, observation)
return "", &result
}
return observation, nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
return scheduleread.BuildQueueStatusView(scheduleread.QueueStatusViewInput{
State: input.State,
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// newScheduleReadToolHandler 统一构造父包 read adapter。
//
// 步骤化说明:
// 1. 先执行底层 schedule 工具,拿到原始 observation保证 LLM 观察文本不变;
// 2. 再用 LegacyResultWithState 复用父包状态判断、参数中文展示与默认字段;
// 3. 最后调用 schedule_read 子包生成纯展示视图,并包回 ToolExecutionResult。
func newScheduleReadToolHandler(
toolName string,
observe scheduleReadObserveFunc,
buildView scheduleReadViewBuilder,
) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation, earlyResult := observe(state, args)
if earlyResult != nil {
return EnsureToolResultDefaults(*earlyResult, args)
}
legacy := LegacyResultWithState(toolName, args, state, observation)
input := scheduleReadAdapterInput{
ToolName: toolName,
Args: cloneAnyMap(args),
State: state,
ObservationText: observation,
ArgFields: extractScheduleReadArgumentFields(legacy.ArgumentView),
}
view := buildView(input)
if normalizeToolStatus(legacy.Status) != ToolStatusDone {
view = scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{
ToolName: toolName,
Status: legacy.Status,
Observation: observation,
ArgFields: input.ArgFields,
})
}
return buildScheduleReadExecutionResult(legacy, args, view)
}
}
// buildScheduleReadFailureResult 用于底层工具执行前即可确定失败的参数/状态场景。
func buildScheduleReadFailureResult(
toolName string,
args map[string]any,
state *schedule.ScheduleState,
observation string,
) ToolExecutionResult {
legacy := LegacyResultWithState(toolName, args, state, observation)
view := scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{
ToolName: toolName,
Status: ToolStatusFailed,
Observation: observation,
ArgFields: extractScheduleReadArgumentFields(legacy.ArgumentView),
})
return buildScheduleReadExecutionResult(legacy, args, view)
}
// buildScheduleReadExecutionResult 负责把子包纯展示视图包回父包统一协议。
//
// 职责边界:
// 1. 只做 ReadResultView -> ToolDisplayView 的协议桥接;
// 2. 不改写 ObservationText确保 execute / SSE / timeline 仍使用同一份 observation
// 3. 错误码与错误文案继续复用父包既有 JSON / 文本解析逻辑。
func buildScheduleReadExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view scheduleread.ReadResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = strings.TrimSpace(result.ObservationText)
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = scheduleread.ViewTypeReadResult
}
version := view.Version
if version <= 0 {
version = scheduleread.ViewVersionReadResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
// extractScheduleReadArgumentFields 把父包 ToolArgumentView 投影成子包可消费的 KVField。
func extractScheduleReadArgumentFields(view *ToolArgumentView) []scheduleread.KVField {
if view == nil || view.Expanded == nil {
return make([]scheduleread.KVField, 0)
}
rawFields, exists := view.Expanded["fields"]
if !exists {
return make([]scheduleread.KVField, 0)
}
fields := make([]scheduleread.KVField, 0)
appendField := func(row map[string]any) {
label, _ := row["label"].(string)
display, _ := row["display"].(string)
label = strings.TrimSpace(label)
display = strings.TrimSpace(display)
if label == "" || display == "" {
return
}
fields = append(fields, scheduleread.BuildKVField(label, display))
}
switch typed := rawFields.(type) {
case []map[string]any:
for _, row := range typed {
appendField(row)
}
case []any:
for _, item := range typed {
row, ok := item.(map[string]any)
if !ok {
continue
}
appendField(row)
}
}
if len(fields) == 0 {
return make([]scheduleread.KVField, 0)
}
return fields
}
func readStringAnyMap(payload map[string]any, key string) (string, bool) {
if len(payload) == 0 {
return "", false
}
raw, exists := payload[key]
if !exists || raw == nil {
return "", false
}
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
return "", false
}
return text, true
}

View File

@@ -0,0 +1,408 @@
package agenttools
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
)
// TaskClassUpsertInput 描述任务类写库工具的标准化入参。
//
// 职责边界:
// 1. ID=0 表示创建ID>0 表示更新;
// 2. Request 直接复用现有 UserAddTaskClassRequest 语义,避免多套字段定义漂移;
// 3. Source 用于记录字段来源chat/memory/web不参与业务校验。
type TaskClassUpsertInput struct {
ID int
Request model.UserAddTaskClassRequest
Source string
}
// TaskClassUpsertPersistResult 描述任务类写入持久层后的结果。
type TaskClassUpsertPersistResult struct {
TaskClassID int
Created bool
}
// TaskClassWriteDeps 描述任务类写库工具依赖。
//
// 职责边界:
// 1. 工具层只负责参数标准化与结果包装,不直接依赖 DAO
// 2. UpsertTaskClass 由启动层注入,便于后续替换为 service/DAO 统一实现。
type TaskClassWriteDeps struct {
UpsertTaskClass func(userID int, input TaskClassUpsertInput) (TaskClassUpsertPersistResult, error)
}
type taskClassValidationResult struct {
OK bool `json:"ok"`
Issues []string `json:"issues"`
}
type taskClassUpsertToolResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
TaskClassID int `json:"task_class_id,omitempty"`
Created bool `json:"created,omitempty"`
Validation taskClassValidationResult `json:"validation"`
Error string `json:"error"`
ErrorCode string `json:"error_code"`
}
func parseTaskClassUpsertInput(args map[string]any) (TaskClassUpsertInput, error) {
id := 0
if rawID, exists := args["id"]; exists {
parsedID, ok := readUpsertInt(rawID)
if !ok {
return TaskClassUpsertInput{}, fmt.Errorf("id 参数类型非法,必须为整数")
}
id = parsedID
}
taskClassRaw, ok := args["task_class"]
if !ok {
return TaskClassUpsertInput{}, fmt.Errorf("缺少必填参数 task_class")
}
taskClassMap, ok := taskClassRaw.(map[string]any)
if !ok {
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数类型非法,必须是对象")
}
// 允许顶层 items 覆盖 task_class.items便于 LLM 在生成参数时拆分表达。
if rawItems, exists := args["items"]; exists {
taskClassMap["items"] = rawItems
}
normalizeTaskClassPayload(taskClassMap)
rawJSON, err := json.Marshal(taskClassMap)
if err != nil {
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数序列化失败")
}
var request model.UserAddTaskClassRequest
if err := json.Unmarshal(rawJSON, &request); err != nil {
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数解析失败:%v", err)
}
source := ""
if rawSource, exists := args["source"]; exists {
if text, ok := rawSource.(string); ok {
source = strings.TrimSpace(text)
}
}
normalizeTaskClassSemanticRequest(&request)
return TaskClassUpsertInput{
ID: id,
Request: request,
Source: source,
}, nil
}
// normalizeTaskClassPayload 对 LLM 常见“近义字段/错层字段”做轻量兼容归一化。
//
// 职责边界:
// 1. 负责把已知等价字段映射到后端真实契约,减少“明明填了却校验失败”的误伤;
// 2. 不负责兜底补齐业务必填项(如 mode/config这些仍由校验层决定是否报错
// 3. 仅处理本工具已观测到的高频偏差,避免过度“自动纠错”掩盖真实输入问题。
func normalizeTaskClassPayload(taskClassMap map[string]any) {
if len(taskClassMap) == 0 {
return
}
// 1. 兼容日期字段错层:
// 1.1 若顶层 start_date/end_date 缺失;
// 1.2 且 config.start_date/config.end_date 有值;
// 1.3 则抬升到顶层,匹配 UserAddTaskClassRequest 契约。
configMap, _ := readAnyMap(taskClassMap["config"])
promoteStringField(taskClassMap, configMap, "start_date")
promoteStringField(taskClassMap, configMap, "end_date")
// 2. 兼容 items 的语义字段:
// 2.1 content 缺失时,尝试从 description/title/name 回填;
// 2.2 order 缺失或非法时,按当前顺序补 1..N
// 2.3 失败时不抛错,留给校验层输出明确问题。
normalizeTaskClassItems(taskClassMap)
}
func promoteStringField(top map[string]any, config map[string]any, key string) {
if top == nil {
return
}
if strings.TrimSpace(readAnyString(top[key])) != "" {
return
}
if strings.TrimSpace(readAnyString(config[key])) == "" {
return
}
top[key] = strings.TrimSpace(readAnyString(config[key]))
}
func normalizeTaskClassItems(taskClassMap map[string]any) {
rawItems, exists := taskClassMap["items"]
if !exists {
return
}
itemList, ok := rawItems.([]any)
if !ok {
return
}
for idx := range itemList {
itemMap, ok := itemList[idx].(map[string]any)
if !ok {
continue
}
if !hasPositiveInt(itemMap["order"]) {
itemMap["order"] = idx + 1
}
if strings.TrimSpace(readAnyString(itemMap["content"])) == "" {
content := firstNonEmptyString(
readAnyString(itemMap["content"]),
readAnyString(itemMap["description"]),
readAnyString(itemMap["title"]),
readAnyString(itemMap["name"]),
)
if strings.TrimSpace(content) != "" {
itemMap["content"] = strings.TrimSpace(content)
}
}
itemList[idx] = itemMap
}
taskClassMap["items"] = itemList
}
func readAnyMap(raw any) (map[string]any, bool) {
if raw == nil {
return nil, false
}
value, ok := raw.(map[string]any)
return value, ok
}
func readAnyString(raw any) string {
switch value := raw.(type) {
case string:
return value
default:
return ""
}
}
// normalizeTaskClassSemanticRequest 归一化任务类语义画像字段。
//
// 职责边界:
// 1. 负责把 LLM 或用户可能给出的中文/近义值收口成稳定枚举;
// 2. 不负责补默认值,字段缺失仍由上层决定是否接受;
// 3. 归一化失败时保留原值,交给校验层输出明确错误。
func normalizeTaskClassSemanticRequest(req *model.UserAddTaskClassRequest) {
if req == nil {
return
}
if normalized := normalizeSubjectType(req.SubjectType); normalized != "" {
req.SubjectType = normalized
}
if normalized := normalizeLevelValue(req.DifficultyLevel); normalized != "" {
req.DifficultyLevel = normalized
}
if normalized := normalizeLevelValue(req.CognitiveIntensity); normalized != "" {
req.CognitiveIntensity = normalized
}
}
func normalizeSubjectType(raw string) string {
value := strings.TrimSpace(strings.ToLower(raw))
switch value {
case "quantitative", "计算型", "计算", "理工", "理工型":
return "quantitative"
case "memory", "记忆型", "记忆", "背诵型", "背诵":
return "memory"
case "reading", "阅读型", "阅读":
return "reading"
case "mixed", "混合型", "混合":
return "mixed"
default:
return ""
}
}
func normalizeLevelValue(raw string) string {
value := strings.TrimSpace(strings.ToLower(raw))
switch value {
case "low", "低":
return "low"
case "medium", "中", "中等":
return "medium"
case "high", "高":
return "high"
default:
return ""
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func hasPositiveInt(raw any) bool {
switch value := raw.(type) {
case int:
return value > 0
case int8:
return value > 0
case int16:
return value > 0
case int32:
return value > 0
case int64:
return value > 0
case float32:
return int(value) > 0
case float64:
return int(value) > 0
default:
return false
}
}
func validateTaskClassUpsertRequest(req model.UserAddTaskClassRequest, id int) []string {
issues := make([]string, 0)
if id < 0 {
issues = append(issues, "id 不能小于 0")
}
if strings.TrimSpace(req.Name) == "" {
issues = append(issues, "name 不能为空")
}
mode := strings.TrimSpace(strings.ToLower(req.Mode))
if mode != "auto" && mode != "manual" {
issues = append(issues, "mode 仅支持 auto/manual")
}
if mode == "auto" {
if strings.TrimSpace(req.StartDate) == "" || strings.TrimSpace(req.EndDate) == "" {
issues = append(issues, "auto 模式必须提供 start_date/end_date")
} else {
startDate, err1 := time.ParseInLocation("2006-01-02", strings.TrimSpace(req.StartDate), time.Local)
endDate, err2 := time.ParseInLocation("2006-01-02", strings.TrimSpace(req.EndDate), time.Local)
if err1 != nil || err2 != nil {
issues = append(issues, "start_date/end_date 日期格式非法,需为 YYYY-MM-DD")
} else if startDate.After(endDate) {
issues = append(issues, "start_date 不能晚于 end_date")
}
}
}
if strings.TrimSpace(req.SubjectType) != "" && normalizeSubjectType(req.SubjectType) == "" {
issues = append(issues, "subject_type 仅支持 quantitative/memory/reading/mixed")
}
if strings.TrimSpace(req.DifficultyLevel) != "" && normalizeLevelValue(req.DifficultyLevel) == "" {
issues = append(issues, "difficulty_level 仅支持 low/medium/high")
}
if strings.TrimSpace(req.CognitiveIntensity) != "" && normalizeLevelValue(req.CognitiveIntensity) == "" {
issues = append(issues, "cognitive_intensity 仅支持 low/medium/high")
}
if strings.TrimSpace(req.SubjectType) == "" {
issues = append(issues, "subject_type 不能为空")
}
if strings.TrimSpace(req.DifficultyLevel) == "" {
issues = append(issues, "difficulty_level 不能为空")
}
if strings.TrimSpace(req.CognitiveIntensity) == "" {
issues = append(issues, "cognitive_intensity 不能为空")
}
if req.Config.TotalSlots <= 0 {
issues = append(issues, "config.total_slots 必须大于 0")
}
strategy := strings.TrimSpace(strings.ToLower(req.Config.Strategy))
if strategy != "steady" && strategy != "rapid" {
issues = append(issues, "config.strategy 仅支持 steady/rapid")
}
for _, section := range req.Config.ExcludedSlots {
// 1. excluded_slots 在粗排算法中按“半天块索引”解释,而不是原子节次;
// 2. 每个块固定映射 2 节1->1-22->3-4...6->11-12
// 3. 若放行 7~12会在 buildTimeGrid 扩展时生成 13~24 节,触发数组越界。
if section < 1 || section > 6 {
issues = append(issues, "config.excluded_slots 仅允许 1~6半天块索引每块=2节")
break
}
}
for _, dayOfWeek := range req.Config.ExcludedDaysOfWeek {
// 1. excluded_days_of_week 属于“整天不可排”的硬约束;
// 2. 仅允许 1~7对应周一到周日
// 3. 非法值会导致粗排过滤口径失真,因此统一在写入口拦截。
if dayOfWeek < 1 || dayOfWeek > 7 {
issues = append(issues, "config.excluded_days_of_week 仅允许 1~7周一到周日")
break
}
}
if len(req.Items) == 0 {
issues = append(issues, "items 不能为空")
}
for index, item := range req.Items {
if item.Order <= 0 {
issues = append(issues, fmt.Sprintf("items[%d].order 必须大于 0", index))
}
if strings.TrimSpace(item.Content) == "" {
issues = append(issues, fmt.Sprintf("items[%d].content 不能为空", index))
}
}
return uniqueTaskClassIssues(issues)
}
func uniqueTaskClassIssues(issues []string) []string {
if len(issues) == 0 {
return issues
}
seen := make(map[string]struct{}, len(issues))
out := make([]string, 0, len(issues))
for _, issue := range issues {
trimmed := strings.TrimSpace(issue)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
func readUpsertUserID(raw any) (int, bool) {
return readUpsertInt(raw)
}
func readUpsertInt(raw any) (int, bool) {
switch value := raw.(type) {
case int:
return value, true
case int8:
return int(value), true
case int16:
return int(value), true
case int32:
return int(value), true
case int64:
return int(value), true
case float64:
return int(value), true
case float32:
return int(value), true
default:
return 0, false
}
}
func marshalTaskClassUpsertResult(result taskClassUpsertToolResult) string {
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"upsert_task_class","success":false,"error":"result encode failed","error_code":"encode_failed"}`
}
return string(raw)
}

View File

@@ -0,0 +1,397 @@
package taskclass_result
import (
"fmt"
"strings"
)
// 说明:
// 1. schedule_read / schedule_analysis 已经各自带有一套卡片 helper
// 2. 这一轮只迁 taskclass 写入结果,如果现在强行把前三批 helper 回抽成公共层,会扩大回归面;
// 3. 因此本包只保留 taskclass.write_result 所需的最小 helper待非 schedule 主链稳定后再统一评估抽象。
func buildWriteResultView(
status string,
title string,
subtitle string,
metrics []MetricField,
items []ItemView,
sections []map[string]any,
observation string,
machinePayload map[string]any,
) WriteResultView {
normalizedStatus := normalizeStatus(status)
if normalizedStatus == "" {
normalizedStatus = StatusDone
}
collapsed := map[string]any{
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"status": normalizedStatus,
"status_label": resolveStatusLabelCN(normalizedStatus),
"metrics": metricListToMaps(metrics),
}
expanded := map[string]any{
"items": itemListToMaps(items),
"sections": cloneSectionList(sections),
"raw_text": observation,
}
if len(machinePayload) > 0 {
expanded["machine_payload"] = cloneAnyMap(machinePayload)
}
return WriteResultView{
ViewType: ViewTypeWriteResult,
Version: ViewVersionWriteResult,
Collapsed: collapsed,
Expanded: expanded,
}
}
func buildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func buildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func buildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func buildItemsSection(title string, items []ItemView) map[string]any {
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": itemListToMaps(items),
}
}
func buildKVSection(title string, fields []KVField) map[string]any {
rows := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
rows = append(rows, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": rows,
}
}
func buildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func formatSourceCN(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "chat":
return "对话"
case "memory":
return "记忆"
case "web":
return "网页"
case "":
return "未标注"
default:
return strings.TrimSpace(source)
}
}
func formatModeCN(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "auto":
return "自动排布"
case "manual":
return "手动维护"
default:
return fallbackText(mode, "未标注")
}
}
func formatStrategyCN(strategy string) string {
switch strings.ToLower(strings.TrimSpace(strategy)) {
case "steady":
return "稳态推进"
case "rapid":
return "快速推进"
default:
return fallbackText(strategy, "未标注")
}
}
func formatSubjectTypeCN(subjectType string) string {
switch strings.ToLower(strings.TrimSpace(subjectType)) {
case "quantitative":
return "计算型"
case "memory":
return "记忆型"
case "reading":
return "阅读型"
case "mixed":
return "混合型"
default:
return fallbackText(subjectType, "未标注")
}
}
func formatLevelCN(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "low":
return "低"
case "medium":
return "中"
case "high":
return "高"
default:
return fallbackText(level, "未标注")
}
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatDateRangeCN(start string, end string) string {
start = strings.TrimSpace(start)
end = strings.TrimSpace(end)
switch {
case start != "" && end != "":
return fmt.Sprintf("%s 至 %s", start, end)
case start != "":
return start
case end != "":
return end
default:
return "未标注"
}
}
func formatIntListCN(values []int, emptyText string, formatFn func(int) string) string {
if len(values) == 0 {
return strings.TrimSpace(emptyText)
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, formatFn(value))
}
return strings.Join(parts, "、")
}
func formatWeekdayCN(day int) string {
switch day {
case 1:
return "周一"
case 2:
return "周二"
case 3:
return "周三"
case 4:
return "周四"
case 5:
return "周五"
case 6:
return "周六"
case 7:
return "周日"
default:
return fmt.Sprintf("星期%d", day)
}
}
func formatEmbeddedTimeCN(item TaskClassItemSummary) string {
if item.EmbeddedWeek <= 0 || item.EmbeddedDay <= 0 || item.EmbeddedSectionFrom <= 0 || item.EmbeddedSectionTo <= 0 {
return "未指定"
}
return fmt.Sprintf(
"第%d周 %s 第%d-%d节",
item.EmbeddedWeek,
formatWeekdayCN(item.EmbeddedDay),
item.EmbeddedSectionFrom,
item.EmbeddedSectionTo,
)
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func truncateText(text string, limit int) string {
runes := []rune(strings.TrimSpace(text))
if len(runes) == 0 {
return "未填写内容"
}
if limit <= 0 || len(runes) <= limit {
return string(runes)
}
return string(runes[:limit]) + "..."
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}
func metricListToMaps(metrics []MetricField) []map[string]any {
if len(metrics) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, map[string]any{
"label": label,
"value": value,
})
}
if len(out) == 0 {
return make([]map[string]any, 0)
}
return out
}
func itemListToMaps(items []ItemView) []map[string]any {
if len(items) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
row := map[string]any{
"title": strings.TrimSpace(item.Title),
"subtitle": strings.TrimSpace(item.Subtitle),
"tags": normalizeStringSlice(item.Tags),
"detail_lines": normalizeStringSlice(item.DetailLines),
}
if len(item.Meta) > 0 {
row["meta"] = cloneAnyMap(item.Meta)
}
out = append(out, row)
}
return out
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneAnyMap(typed)
case []map[string]any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyMap(item))
}
return out
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyValue(item))
}
return out
case []string:
out := make([]string, len(typed))
copy(out, typed)
return out
case []int:
out := make([]int, len(typed))
copy(out, typed)
return out
default:
return typed
}
}

View File

@@ -0,0 +1,114 @@
package taskclass_result
const (
// ViewTypeWriteResult 固定为任务类写入结果卡片的前端识别类型。
ViewTypeWriteResult = "taskclass.write_result"
// ViewVersionWriteResult 固定为当前任务类写入结果结构版本。
ViewVersionWriteResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// WriteResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 只承载 view_type / version / collapsed / expanded 四段展示数据;
// 2. 不负责 ToolExecutionResult、SSE、timeline 等父包协议;
// 3. collapsed / expanded 继续保留 map 形态,便于父包直接桥接。
type WriteResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// KVField 是 expanded.kv section 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是 expanded.items / section.items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// UpsertResult 承载写入 observation 里可稳定提取的结果字段。
//
// 职责边界:
// 1. 只描述 upsert_task_class 的结果,不承载请求参数;
// 2. ValidationIssues 仅用于展示校验失败原因,不负责重新校验;
// 3. Error / ErrorCode 保持和 observation 一致,避免展示层发明新语义。
type UpsertResult struct {
Tool string
Success bool
TaskClassID int
Created bool
Error string
ErrorCode string
ValidationOK bool
ValidationIssues []string
}
// RequestSummary 描述写入请求中适合前端展示的稳定字段摘要。
//
// 职责边界:
// 1. 只保留卡片展示稳定需要的信息,不回传完整原始 args
// 2. RequestedID 表示调用方请求更新的任务类 ID不等同于持久化后的真实 ID
// 3. Items 已经是展示层可直接消费的扁平摘要,不再承担业务校验职责。
type RequestSummary struct {
RequestedID int
Name string
Mode string
StartDate string
EndDate string
SubjectType string
DifficultyLevel string
CognitiveIntensity string
TotalSlots int
AllowFillerCourse bool
Strategy string
ExcludedSlots []int
ExcludedDaysOfWeek []int
Source string
Items []TaskClassItemSummary
}
// TaskClassItemSummary 描述单个任务项的展示摘要。
type TaskClassItemSummary struct {
ID int
Order int
Content string
EmbeddedWeek int
EmbeddedDay int
EmbeddedSectionFrom int
EmbeddedSectionTo int
}
// BuildUpsertTaskClassViewInput 是 upsert_task_class 卡片 builder 的输入。
//
// 职责边界:
// 1. Observation 必须原样传入,供 raw_text 保留原始结果;
// 2. MachinePayload 只作为调试/后续交互的隐藏字段,不参与标题摘要计算;
// 3. Status 由父包 adapter 传入,子包只负责标准化,不重新推断工具执行链路。
type BuildUpsertTaskClassViewInput struct {
Status string
Observation string
Result UpsertResult
Request RequestSummary
MachinePayload map[string]any
}

View File

@@ -0,0 +1,242 @@
package taskclass_result
import "fmt"
// BuildUpsertTaskClassView 把 upsert_task_class 的稳定结果摘要转成任务类写入卡片。
//
// 步骤化说明:
// 1. 先基于父包传入的 Status/Result 生成折叠态标题、摘要和稳定指标,保证成功/失败都能快速扫读;
// 2. 再把任务类字段、配置、任务项列表和失败原因拆成 kv/items/callout section避免前端继续回退 raw_text
// 3. raw_text 与 machine_payload 始终保留,便于模型链路、调试链路和后续交互共用同一份 observation 语义。
func BuildUpsertTaskClassView(input BuildUpsertTaskClassViewInput) WriteResultView {
status := normalizeStatus(input.Status)
if status == "" {
if input.Result.Success {
status = StatusDone
} else {
status = StatusFailed
}
}
items := buildTaskClassItemViews(input.Request.Items)
sections := buildUpsertSections(input.Result, input.Request, items, status)
return buildWriteResultView(
status,
buildUpsertTitle(input.Result, status),
buildUpsertSubtitle(input.Result, input.Request, status),
buildUpsertMetrics(input.Result, input.Request),
items,
sections,
input.Observation,
input.MachinePayload,
)
}
func buildUpsertTitle(result UpsertResult, status string) string {
if normalizeStatus(status) != StatusDone {
return "任务类写入失败"
}
if result.Created {
return "任务类已创建"
}
return "任务类已更新"
}
func buildUpsertSubtitle(result UpsertResult, request RequestSummary, status string) string {
name := fallbackText(request.Name, "未命名任务类")
itemCount := len(request.Items)
if normalizeStatus(status) == StatusDone {
action := "更新"
if result.Created {
action = "创建"
}
return fmt.Sprintf("已%s「%s」共 %d 项任务", action, name, itemCount)
}
if len(result.ValidationIssues) > 0 {
return fmt.Sprintf("「%s」校验未通过%s", name, result.ValidationIssues[0])
}
if result.Error != "" {
return fmt.Sprintf("「%s」写入失败%s", name, result.Error)
}
return fmt.Sprintf("「%s」写入失败请查看详情", name)
}
func buildUpsertMetrics(result UpsertResult, request RequestSummary) []MetricField {
action := "更新"
if result.Created {
action = "创建"
}
if !result.Success && request.RequestedID == 0 {
action = "创建尝试"
}
if !result.Success && request.RequestedID > 0 {
action = "更新尝试"
}
return []MetricField{
buildMetric("任务类数量", "1 个"),
buildMetric("任务项数量", fmt.Sprintf("%d 项", len(request.Items))),
buildMetric("来源", formatSourceCN(request.Source)),
buildMetric("写入方式", action),
}
}
func buildUpsertSections(
result UpsertResult,
request RequestSummary,
items []ItemView,
status string,
) []map[string]any {
sections := []map[string]any{
buildResultCallout(result, request, status),
buildKVSection("任务类字段", buildTaskClassFields(result, request)),
buildKVSection("排程配置", buildTaskClassConfigFields(request)),
}
if len(items) > 0 {
sections = append(sections, buildItemsSection("任务项列表", items))
} else {
sections = append(sections, buildCalloutSection(
"任务项列表",
"当前没有可展示的任务项。",
"info",
[]string{"如果这是一次失败写入,请优先检查 task_class.items 或顶层 items 入参是否完整。"},
))
}
if len(result.ValidationIssues) > 0 {
sections = append(sections, buildCalloutSection(
"校验失败原因",
"请求参数未通过后端校验。",
"warning",
normalizeStringSlice(result.ValidationIssues),
))
}
return sections
}
func buildResultCallout(result UpsertResult, request RequestSummary, status string) map[string]any {
if normalizeStatus(status) == StatusDone {
action := "更新"
if result.Created {
action = "创建"
}
detailLines := []string{
fmt.Sprintf("任务类:%s", fallbackText(request.Name, "未命名任务类")),
fmt.Sprintf("任务类 ID%d", resolveDisplayTaskClassID(result, request)),
fmt.Sprintf("任务项数量:%d 项", len(request.Items)),
}
return buildCalloutSection(
"写入结果",
fmt.Sprintf("已%s任务类结果可直接用于后续排程。", action),
"success",
detailLines,
)
}
reason := result.Error
if len(result.ValidationIssues) > 0 {
reason = result.ValidationIssues[0]
}
if reason == "" {
reason = "写入流程未返回明确失败原因,请查看原始 observation。"
}
return buildCalloutSection(
"写入失败",
reason,
"danger",
[]string{
fmt.Sprintf("来源:%s", formatSourceCN(request.Source)),
fmt.Sprintf("任务类:%s", fallbackText(request.Name, "未命名任务类")),
fmt.Sprintf("任务项数量:%d 项", len(request.Items)),
},
)
}
func buildTaskClassFields(result UpsertResult, request RequestSummary) []KVField {
return []KVField{
buildKVField("任务类 ID", fmt.Sprintf("%d", resolveDisplayTaskClassID(result, request))),
buildKVField("名称", fallbackText(request.Name, "未命名任务类")),
buildKVField("模式", formatModeCN(request.Mode)),
buildKVField("日期范围", formatDateRangeCN(request.StartDate, request.EndDate)),
buildKVField("学科类型", formatSubjectTypeCN(request.SubjectType)),
buildKVField("难度等级", formatLevelCN(request.DifficultyLevel)),
buildKVField("认知强度", formatLevelCN(request.CognitiveIntensity)),
buildKVField("来源", formatSourceCN(request.Source)),
}
}
func buildTaskClassConfigFields(request RequestSummary) []KVField {
return []KVField{
buildKVField("总节数", fmt.Sprintf("%d", request.TotalSlots)),
buildKVField("允许补位课程", formatBoolCN(request.AllowFillerCourse)),
buildKVField("推进策略", formatStrategyCN(request.Strategy)),
buildKVField("排除半天块", formatIntListCN(request.ExcludedSlots, "无", func(value int) string {
return fmt.Sprintf("第%d块", value)
})),
buildKVField("排除星期", formatIntListCN(request.ExcludedDaysOfWeek, "无", formatWeekdayCN)),
}
}
func buildTaskClassItemViews(items []TaskClassItemSummary) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for _, item := range items {
detailLines := []string{
"内容:" + fallbackText(item.Content, "未填写内容"),
"嵌入时间:" + formatEmbeddedTimeCN(item),
}
if item.ID > 0 {
detailLines = append(detailLines, fmt.Sprintf("任务项 ID%d", item.ID))
}
out = append(out, buildItem(
truncateText(item.Content, 28),
fmt.Sprintf("第 %d 项", maxInt(item.Order, 0)),
buildTaskClassItemTags(item),
detailLines,
map[string]any{
"id": item.ID,
"order": item.Order,
"embedded_week": item.EmbeddedWeek,
"embedded_day": item.EmbeddedDay,
"section_from": item.EmbeddedSectionFrom,
"section_to": item.EmbeddedSectionTo,
},
))
}
return out
}
func buildTaskClassItemTags(item TaskClassItemSummary) []string {
tags := []string{fmt.Sprintf("顺序 %d", maxInt(item.Order, 0))}
if item.EmbeddedWeek > 0 && item.EmbeddedDay > 0 {
tags = append(tags, formatEmbeddedTimeCN(item))
} else {
tags = append(tags, "未指定嵌入时间")
}
return tags
}
func resolveDisplayTaskClassID(result UpsertResult, request RequestSummary) int {
if result.TaskClassID > 0 {
return result.TaskClassID
}
return request.RequestedID
}
func maxInt(values ...int) int {
if len(values) == 0 {
return 0
}
best := values[0]
for _, value := range values[1:] {
if value > best {
best = value
}
}
return best
}

View File

@@ -0,0 +1,458 @@
package agenttools
import (
"strings"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
taskclassresult "github.com/LoveLosita/smartflow/backend/services/agent/tools/taskclass_result"
)
type taskClassUpsertExecutionInput struct {
Result taskClassUpsertToolResult
Normalized *TaskClassUpsertInput
}
// NewTaskClassUpsertToolHandler 返回 upsert_task_class 的结构化结果 handler。
//
// 职责边界:
// 1. 只负责参数解析、校验、调用依赖与包装结构化结果;
// 2. 不改变既有写库语义、confirm 语义与 observation JSON 合约;
// 3. 老实现暂以 legacy 函数保留,便于本轮并行迁移后回溯与对照。
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
if deps.UpsertTaskClass == nil {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}},
Error: "任务类写库依赖未注入",
ErrorCode: "dependency_missing",
},
})
}
userID, ok := readUpsertUserID(args["_user_id"])
if !ok || userID <= 0 {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}},
Error: "工具调用失败:无法识别用户身份",
ErrorCode: "missing_user_id",
},
})
}
input, parseErr := parseTaskClassUpsertInput(args)
if parseErr != nil {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}},
Error: parseErr.Error(),
ErrorCode: "invalid_args",
},
})
}
issues := validateTaskClassUpsertRequest(input.Request, input.ID)
if len(issues) > 0 {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: issues},
Error: strings.Join(issues, ""),
ErrorCode: "validation_failed",
},
Normalized: &input,
})
}
result, err := deps.UpsertTaskClass(userID, input)
if err != nil {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
Error: err.Error(),
ErrorCode: "persist_failed",
},
Normalized: &input,
})
}
if result.TaskClassID <= 0 {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
Error: "写入后未返回有效 task_class_id",
ErrorCode: "invalid_persist_result",
},
Normalized: &input,
})
}
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: true,
TaskClassID: result.TaskClassID,
Created: result.Created,
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
Error: "",
ErrorCode: "",
},
Normalized: &input,
})
}
}
// buildTaskClassUpsertExecutionResult 负责把 upsert_task_class 的原始 observation 包装成结构化卡片。
//
// 步骤化说明:
// 1. 先沿用 LegacyResult 保留 observation、参数预览、错误码提取等既有链路能力
// 2. 再把规范化请求摘要和写入结果投影到 taskclass_result 子包,避免展示层反向依赖父包;
// 3. 最后只替换 ResultView / Summary不改写写库语义、confirm 语义和原始错误文本。
func buildTaskClassUpsertExecutionResult(
args map[string]any,
input taskClassUpsertExecutionInput,
) ToolExecutionResult {
observation := marshalTaskClassUpsertResult(input.Result)
legacy := LegacyResult("upsert_task_class", args, observation)
requestSummary := buildTaskClassRequestSummary(args, input.Normalized)
view := taskclassresult.BuildUpsertTaskClassView(taskclassresult.BuildUpsertTaskClassViewInput{
Status: legacy.Status,
Observation: observation,
Result: taskclassresult.UpsertResult{
Tool: strings.TrimSpace(input.Result.Tool),
Success: input.Result.Success,
TaskClassID: input.Result.TaskClassID,
Created: input.Result.Created,
Error: strings.TrimSpace(input.Result.Error),
ErrorCode: strings.TrimSpace(input.Result.ErrorCode),
ValidationOK: input.Result.Validation.OK,
ValidationIssues: append([]string(nil), input.Result.Validation.Issues...),
},
Request: requestSummary,
MachinePayload: buildTaskClassMachinePayload(input.Result, requestSummary),
})
return buildTaskClassWriteExecutionResult(legacy, args, view)
}
// buildTaskClassWriteExecutionResult 负责把子包纯展示视图包回父包统一协议。
func buildTaskClassWriteExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view taskclassresult.WriteResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = result.ObservationText
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = taskclassresult.ViewTypeWriteResult
}
version := view.Version
if version <= 0 {
version = taskclassresult.ViewVersionWriteResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func buildTaskClassRequestSummary(
args map[string]any,
normalized *TaskClassUpsertInput,
) taskclassresult.RequestSummary {
summary := buildTaskClassRequestSummaryFromArgs(args)
if normalized == nil {
return summary
}
summary.RequestedID = normalized.ID
summary.Name = strings.TrimSpace(normalized.Request.Name)
summary.Mode = strings.TrimSpace(normalized.Request.Mode)
summary.StartDate = strings.TrimSpace(normalized.Request.StartDate)
summary.EndDate = strings.TrimSpace(normalized.Request.EndDate)
summary.SubjectType = strings.TrimSpace(normalized.Request.SubjectType)
summary.DifficultyLevel = strings.TrimSpace(normalized.Request.DifficultyLevel)
summary.CognitiveIntensity = strings.TrimSpace(normalized.Request.CognitiveIntensity)
summary.TotalSlots = normalized.Request.Config.TotalSlots
summary.AllowFillerCourse = normalized.Request.Config.AllowFillerCourse
summary.Strategy = strings.TrimSpace(normalized.Request.Config.Strategy)
summary.ExcludedSlots = cloneIntSlice(normalized.Request.Config.ExcludedSlots)
summary.ExcludedDaysOfWeek = cloneIntSlice(normalized.Request.Config.ExcludedDaysOfWeek)
summary.Source = strings.TrimSpace(normalized.Source)
summary.Items = buildTaskClassItemsSummary(normalized.Request.Items)
return summary
}
func buildTaskClassRequestSummaryFromArgs(args map[string]any) taskclassresult.RequestSummary {
summary := taskclassresult.RequestSummary{
RequestedID: readOptionalInt(args, "id"),
Source: readOptionalString(args, "source"),
Items: make([]taskclassresult.TaskClassItemSummary, 0),
ExcludedSlots: make([]int, 0),
ExcludedDaysOfWeek: make([]int, 0),
}
taskClassMap, _ := readAnyMap(args["task_class"])
if taskClassMap == nil {
return summary
}
summary.Name = strings.TrimSpace(readAnyString(taskClassMap["name"]))
summary.Mode = strings.TrimSpace(readAnyString(taskClassMap["mode"]))
summary.StartDate = strings.TrimSpace(readAnyString(taskClassMap["start_date"]))
summary.EndDate = strings.TrimSpace(readAnyString(taskClassMap["end_date"]))
summary.SubjectType = strings.TrimSpace(readAnyString(taskClassMap["subject_type"]))
summary.DifficultyLevel = strings.TrimSpace(readAnyString(taskClassMap["difficulty_level"]))
summary.CognitiveIntensity = strings.TrimSpace(readAnyString(taskClassMap["cognitive_intensity"]))
configMap, _ := readAnyMap(taskClassMap["config"])
summary.TotalSlots = readAnyInt(configMap["total_slots"])
summary.AllowFillerCourse = readAnyBool(configMap["allow_filler_course"])
summary.Strategy = strings.TrimSpace(readAnyString(configMap["strategy"]))
summary.ExcludedSlots = readAnyIntSlice(configMap["excluded_slots"])
summary.ExcludedDaysOfWeek = readAnyIntSlice(configMap["excluded_days_of_week"])
rawItems := taskClassMap["items"]
if topLevelItems, exists := args["items"]; exists {
rawItems = topLevelItems
}
summary.Items = buildTaskClassItemsSummaryFromRaw(rawItems)
return summary
}
func buildTaskClassItemsSummary(items []model.UserAddTaskClassItemRequest) []taskclassresult.TaskClassItemSummary {
if len(items) == 0 {
return make([]taskclassresult.TaskClassItemSummary, 0)
}
out := make([]taskclassresult.TaskClassItemSummary, 0, len(items))
for _, item := range items {
summary := taskclassresult.TaskClassItemSummary{
ID: item.ID,
Order: item.Order,
Content: strings.TrimSpace(item.Content),
}
if item.EmbeddedTime != nil {
summary.EmbeddedWeek = item.EmbeddedTime.Week
summary.EmbeddedDay = item.EmbeddedTime.DayOfWeek
summary.EmbeddedSectionFrom = item.EmbeddedTime.SectionFrom
summary.EmbeddedSectionTo = item.EmbeddedTime.SectionTo
}
out = append(out, summary)
}
return out
}
func buildTaskClassItemsSummaryFromRaw(raw any) []taskclassresult.TaskClassItemSummary {
rawList, ok := raw.([]any)
if !ok || len(rawList) == 0 {
return make([]taskclassresult.TaskClassItemSummary, 0)
}
out := make([]taskclassresult.TaskClassItemSummary, 0, len(rawList))
for index, row := range rawList {
itemMap, ok := row.(map[string]any)
if !ok {
continue
}
summary := taskclassresult.TaskClassItemSummary{
ID: readAnyInt(itemMap["id"]),
Order: maxPositiveInt(readAnyInt(itemMap["order"]), index+1),
Content: strings.TrimSpace(firstNonEmptyString(
readAnyString(itemMap["content"]),
readAnyString(itemMap["description"]),
readAnyString(itemMap["title"]),
readAnyString(itemMap["name"]),
)),
}
if embeddedMap, ok := readAnyMap(itemMap["embedded_time"]); ok {
summary.EmbeddedWeek = readAnyInt(embeddedMap["week"])
summary.EmbeddedDay = readAnyInt(embeddedMap["day_of_week"])
summary.EmbeddedSectionFrom = readAnyInt(embeddedMap["section_from"])
summary.EmbeddedSectionTo = readAnyInt(embeddedMap["section_to"])
}
out = append(out, summary)
}
return out
}
func buildTaskClassMachinePayload(
result taskClassUpsertToolResult,
request taskclassresult.RequestSummary,
) map[string]any {
return map[string]any{
"parsed_result": map[string]any{
"tool": strings.TrimSpace(result.Tool),
"success": result.Success,
"task_class_id": result.TaskClassID,
"created": result.Created,
"validation": map[string]any{
"ok": result.Validation.OK,
"issues": append([]string(nil), result.Validation.Issues...),
},
"error": strings.TrimSpace(result.Error),
"error_code": strings.TrimSpace(result.ErrorCode),
},
"input_summary": map[string]any{
"requested_id": request.RequestedID,
"name": request.Name,
"mode": request.Mode,
"start_date": request.StartDate,
"end_date": request.EndDate,
"subject_type": request.SubjectType,
"difficulty_level": request.DifficultyLevel,
"cognitive_intensity": request.CognitiveIntensity,
"total_slots": request.TotalSlots,
"allow_filler_course": request.AllowFillerCourse,
"strategy": request.Strategy,
"excluded_slots": cloneIntSlice(request.ExcludedSlots),
"excluded_days_of_week": cloneIntSlice(request.ExcludedDaysOfWeek),
"source": request.Source,
"items": buildTaskClassItemMachinePayload(request.Items),
},
}
}
func buildTaskClassItemMachinePayload(items []taskclassresult.TaskClassItemSummary) []map[string]any {
if len(items) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, map[string]any{
"id": item.ID,
"order": item.Order,
"content": item.Content,
"embedded_week": item.EmbeddedWeek,
"embedded_day": item.EmbeddedDay,
"section_from": item.EmbeddedSectionFrom,
"section_to": item.EmbeddedSectionTo,
})
}
return out
}
func readOptionalString(args map[string]any, key string) string {
if len(args) == 0 {
return ""
}
return strings.TrimSpace(readAnyString(args[key]))
}
func readOptionalInt(args map[string]any, key string) int {
if len(args) == 0 {
return 0
}
return readAnyInt(args[key])
}
func readAnyInt(raw any) int {
value, ok := readUpsertInt(raw)
if !ok {
return 0
}
return value
}
func readAnyBool(raw any) bool {
value, ok := raw.(bool)
return ok && value
}
func readAnyIntSlice(raw any) []int {
switch typed := raw.(type) {
case []int:
return cloneIntSlice(typed)
case []any:
out := make([]int, 0, len(typed))
for _, item := range typed {
value, ok := readUpsertInt(item)
if !ok {
continue
}
out = append(out, value)
}
return out
default:
return make([]int, 0)
}
}
func cloneIntSlice(values []int) []int {
if len(values) == 0 {
return make([]int, 0)
}
out := make([]int, len(values))
copy(out, values)
return out
}
func maxPositiveInt(left int, right int) int {
if left <= 0 {
return right
}
if right <= 0 {
return left
}
if left > right {
return left
}
return right
}

View File

@@ -0,0 +1,653 @@
package toolcontextresult
import (
"fmt"
"strings"
)
const (
ViewTypeContextResult = "tool.context_result"
ViewVersionContextResult = 1
StatusDone = "done"
StatusFailed = "failed"
)
// ContextResultView 仅承载 context 工具卡片的纯展示数据。
//
// 职责边界:
// 1. 负责输出 view_type / version / collapsed / expanded 四段展示结构;
// 2. 不依赖父包 ToolExecutionResult避免形成反向 import
// 3. 不改写 ObservationText原始文本由父包原样挂到 expanded.raw_text。
type ContextResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// ContextToolsAddPayload 对齐 context_tools_add observation 的机器字段。
type ContextToolsAddPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Action string `json:"action"`
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
Mode string `json:"mode,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// ContextToolsRemovePayload 对齐 context_tools_remove observation 的机器字段。
type ContextToolsRemovePayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Action string `json:"action"`
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
All bool `json:"all,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// BuildAddView 生成 context_tools_add 的结构化卡片。
func BuildAddView(payload ContextToolsAddPayload, observation string) ContextResultView {
status := statusFromSuccess(payload.Success)
summary := buildAddSummary(payload)
detailLines := buildAddDetailLines(payload)
item := BuildItem(
fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域"),
summary,
buildAddTags(payload),
detailLines,
map[string]any{
"action": payload.Action,
"domain": strings.TrimSpace(payload.Domain),
"mode": strings.TrimSpace(payload.Mode),
},
)
sections := []map[string]any{
buildContextCalloutSection(
fallbackText(buildAddCalloutTitle(payload), "工具域变更"),
summary,
toneFromSuccess(payload.Success),
detailLines,
),
BuildKVSection("当前工具区参数", []KVField{
BuildKVField("工具域", fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定")),
BuildKVField("工具包", buildAddPackField(payload)),
BuildKVField("注入模式", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")),
BuildKVField("清空全部", "否"),
BuildKVField("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))),
}),
BuildItemsSection("变更摘要", "", []ItemView{item}),
}
if !payload.Success {
appendErrorSection(&sections, payload.Error, payload.ErrorCode)
}
return ContextResultView{
ViewType: ViewTypeContextResult,
Version: ViewVersionContextResult,
Collapsed: map[string]any{
"title": buildAddCollapsedTitle(payload),
"subtitle": summary,
"status": status,
"status_label": resolveStatusLabelCN(status),
"metrics": buildMetrics(
BuildMetric("域", fallbackText(shortDomainLabel(payload.Domain), "未指定")),
BuildMetric("包", fmt.Sprintf("%d 个", len(payload.Packs))),
BuildMetric("模式", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")),
),
},
Expanded: map[string]any{
"items": []map[string]any{item.Map()},
"sections": sections,
"raw_text": observation,
"machine_payload": buildAddMachinePayload(payload),
},
}
}
// BuildRemoveView 生成 context_tools_remove 的结构化卡片。
func BuildRemoveView(payload ContextToolsRemovePayload, observation string) ContextResultView {
status := statusFromSuccess(payload.Success)
summary := buildRemoveSummary(payload)
detailLines := buildRemoveDetailLines(payload)
item := BuildItem(
buildRemoveItemTitle(payload),
summary,
buildRemoveTags(payload),
detailLines,
map[string]any{
"action": payload.Action,
"domain": strings.TrimSpace(payload.Domain),
"all": payload.All,
},
)
sections := []map[string]any{
buildContextCalloutSection(
fallbackText(buildRemoveCalloutTitle(payload), "工具域变更"),
summary,
toneFromSuccess(payload.Success),
detailLines,
),
BuildKVSection("当前工具区参数", []KVField{
BuildKVField("工具域", buildRemoveDomainField(payload)),
BuildKVField("工具包", buildRemovePackField(payload)),
BuildKVField("注入模式", "不适用"),
BuildKVField("清空全部", formatBoolCN(payload.All)),
BuildKVField("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))),
}),
BuildItemsSection("变更摘要", "", []ItemView{item}),
}
if !payload.Success {
appendErrorSection(&sections, payload.Error, payload.ErrorCode)
}
return ContextResultView{
ViewType: ViewTypeContextResult,
Version: ViewVersionContextResult,
Collapsed: map[string]any{
"title": buildRemoveCollapsedTitle(payload),
"subtitle": summary,
"status": status,
"status_label": resolveStatusLabelCN(status),
"metrics": buildMetrics(
BuildMetric("范围", buildRemoveMetricScope(payload)),
BuildMetric("包", fmt.Sprintf("%d 个", len(payload.Packs))),
BuildMetric("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))),
),
},
Expanded: map[string]any{
"items": []map[string]any{item.Map()},
"sections": sections,
"raw_text": observation,
"machine_payload": buildRemoveMachinePayload(payload),
},
}
}
func ResolveDomainLabelCN(domain string) string {
switch strings.ToLower(strings.TrimSpace(domain)) {
case "schedule":
return "排程工具域"
case "taskclass":
return "任务类工具域"
default:
return ""
}
}
func ResolvePackLabelCN(pack string) string {
switch strings.ToLower(strings.TrimSpace(pack)) {
case "core":
return "固定 core"
case "mutation":
return "排程改写"
case "analyze":
return "健康分析"
case "detail_read":
return "明细读取"
case "deep_analyze":
return "深度分析"
case "queue":
return "队列微调"
case "web":
return "网页能力"
default:
return strings.TrimSpace(pack)
}
}
func FormatPacksCN(packs []string) string {
if len(packs) == 0 {
return "无"
}
parts := make([]string, 0, len(packs))
for _, pack := range packs {
label := strings.TrimSpace(ResolvePackLabelCN(pack))
if label == "" {
continue
}
parts = append(parts, label)
}
if len(parts) == 0 {
return "无"
}
return strings.Join(parts, "、")
}
func ResolveModeLabelCN(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "replace":
return "替换"
case "merge":
return "合并"
default:
return strings.TrimSpace(mode)
}
}
func ResolveActionLabelCN(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "activate":
return "激活"
case "deactivate":
return "移除域"
case "deactivate_packs":
return "移除包"
case "clear_all":
return "清空全部"
case "reject":
return "拒绝"
default:
return strings.TrimSpace(action)
}
}
func BuildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func BuildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func BuildItemsSection(title string, summary string, items []ItemView) map[string]any {
normalized := make([]map[string]any, 0, len(items))
for _, item := range items {
normalized = append(normalized, item.Map())
}
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"summary": strings.TrimSpace(summary),
"items": normalized,
}
}
func BuildKVSection(title string, fields []KVField) map[string]any {
normalized := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
normalized = append(normalized, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": normalized,
}
}
func buildContextCalloutSection(title string, summary string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"summary": strings.TrimSpace(summary),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
func appendErrorSection(target *[]map[string]any, reason string, errorCode string) {
lines := make([]string, 0, 2)
if strings.TrimSpace(reason) != "" {
lines = append(lines, strings.TrimSpace(reason))
}
if strings.TrimSpace(errorCode) != "" {
lines = append(lines, fmt.Sprintf("错误码:%s", strings.TrimSpace(errorCode)))
}
*target = append(*target, buildContextCalloutSection("失败原因", fallbackText(reason, "工具调用失败"), "danger", lines))
}
func buildAddCollapsedTitle(payload ContextToolsAddPayload) string {
if !payload.Success {
return "激活工具域失败"
}
label := ResolveDomainLabelCN(payload.Domain)
if label == "" {
return "已激活工具域"
}
return fmt.Sprintf("已激活%s", label)
}
func buildRemoveCollapsedTitle(payload ContextToolsRemovePayload) string {
if !payload.Success {
return "移除工具域失败"
}
if payload.All {
return "已清空业务工具域"
}
if len(payload.Packs) > 0 {
return "已移除工具包"
}
label := ResolveDomainLabelCN(payload.Domain)
if label == "" {
return "已移除工具域"
}
return fmt.Sprintf("已移除%s", label)
}
func buildAddCalloutTitle(payload ContextToolsAddPayload) string {
if payload.Success {
return "动态工具区已更新"
}
return "激活失败"
}
func buildRemoveCalloutTitle(payload ContextToolsRemovePayload) string {
if payload.Success {
return "动态工具区已更新"
}
return "移除失败"
}
func buildAddSummary(payload ContextToolsAddPayload) string {
if !payload.Success {
return fallbackText(payload.Error, "激活工具域失败")
}
domainLabel := fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域")
packsText := formatPackFieldText(payload.Packs)
modeLabel := fallbackText(ResolveModeLabelCN(payload.Mode), "替换")
if len(payload.Packs) == 0 {
return fmt.Sprintf("%s已激活模式=%s仅保留固定 core。", domainLabel, modeLabel)
}
return fmt.Sprintf("%s已激活模式=%s启用 %s。", domainLabel, modeLabel, packsText)
}
func buildRemoveSummary(payload ContextToolsRemovePayload) string {
if !payload.Success {
return fallbackText(payload.Error, "移除工具域失败")
}
if payload.All {
return "已清空全部业务工具域,仅保留 context 管理工具。"
}
domainLabel := fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域")
if len(payload.Packs) > 0 {
return fmt.Sprintf("已从%s移除 %s。", domainLabel, FormatPacksCN(payload.Packs))
}
return fmt.Sprintf("已移除%s。", domainLabel)
}
func buildAddDetailLines(payload ContextToolsAddPayload) []string {
lines := []string{
fmt.Sprintf("工具域:%s", fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定")),
fmt.Sprintf("工具包:%s", buildAddPackField(payload)),
fmt.Sprintf("注入模式:%s", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")),
}
if strings.TrimSpace(payload.Message) != "" {
lines = append(lines, strings.TrimSpace(payload.Message))
}
if !payload.Success && strings.TrimSpace(payload.Error) != "" {
lines = append(lines, fmt.Sprintf("失败原因:%s", strings.TrimSpace(payload.Error)))
}
return lines
}
func buildRemoveDetailLines(payload ContextToolsRemovePayload) []string {
lines := []string{
fmt.Sprintf("工具域:%s", buildRemoveDomainField(payload)),
fmt.Sprintf("工具包:%s", buildRemovePackField(payload)),
fmt.Sprintf("清空全部:%s", formatBoolCN(payload.All)),
}
if strings.TrimSpace(payload.Message) != "" {
lines = append(lines, strings.TrimSpace(payload.Message))
}
if !payload.Success && strings.TrimSpace(payload.Error) != "" {
lines = append(lines, fmt.Sprintf("失败原因:%s", strings.TrimSpace(payload.Error)))
}
return lines
}
func buildAddTags(payload ContextToolsAddPayload) []string {
tags := []string{
fallbackText(ResolveActionLabelCN(payload.Action), "激活"),
}
if modeLabel := strings.TrimSpace(ResolveModeLabelCN(payload.Mode)); modeLabel != "" {
tags = append(tags, modeLabel)
}
if len(payload.Packs) > 0 {
tags = append(tags, fmt.Sprintf("%d 个包", len(payload.Packs)))
}
return normalizeStringSlice(tags)
}
func buildRemoveTags(payload ContextToolsRemovePayload) []string {
tags := []string{
fallbackText(ResolveActionLabelCN(payload.Action), "移除"),
}
if payload.All {
tags = append(tags, "全部")
}
if len(payload.Packs) > 0 {
tags = append(tags, fmt.Sprintf("%d 个包", len(payload.Packs)))
}
return normalizeStringSlice(tags)
}
func buildRemoveItemTitle(payload ContextToolsRemovePayload) string {
if payload.All {
return "全部业务工具域"
}
return fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域")
}
func buildRemoveDomainField(payload ContextToolsRemovePayload) string {
if payload.All {
return "全部业务工具域"
}
return fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定")
}
func buildRemoveMetricScope(payload ContextToolsRemovePayload) string {
if payload.All {
return "全部"
}
return fallbackText(shortDomainLabel(payload.Domain), "单域")
}
func buildAddMachinePayload(payload ContextToolsAddPayload) map[string]any {
return map[string]any{
"tool": payload.Tool,
"success": payload.Success,
"action": strings.TrimSpace(payload.Action),
"domain": strings.TrimSpace(payload.Domain),
"packs": append([]string(nil), payload.Packs...),
"mode": strings.TrimSpace(payload.Mode),
"message": strings.TrimSpace(payload.Message),
"error": strings.TrimSpace(payload.Error),
"error_code": strings.TrimSpace(payload.ErrorCode),
}
}
func buildRemoveMachinePayload(payload ContextToolsRemovePayload) map[string]any {
return map[string]any{
"tool": payload.Tool,
"success": payload.Success,
"action": strings.TrimSpace(payload.Action),
"domain": strings.TrimSpace(payload.Domain),
"packs": append([]string(nil), payload.Packs...),
"all": payload.All,
"message": strings.TrimSpace(payload.Message),
"error": strings.TrimSpace(payload.Error),
"error_code": strings.TrimSpace(payload.ErrorCode),
}
}
func buildMetrics(metrics ...MetricField) []map[string]any {
normalized := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
normalized = append(normalized, map[string]any{
"label": label,
"value": value,
})
}
return normalized
}
func toneFromSuccess(success bool) string {
if success {
return "info"
}
return "danger"
}
func statusFromSuccess(success bool) string {
if success {
return StatusDone
}
return StatusFailed
}
func resolveStatusLabelCN(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return "已完成"
default:
return "失败"
}
}
func shortDomainLabel(domain string) string {
switch strings.ToLower(strings.TrimSpace(domain)) {
case "schedule":
return "排程"
case "taskclass":
return "任务类"
default:
return strings.TrimSpace(domain)
}
}
func formatPackFieldText(packs []string) string {
if len(packs) == 0 {
return "无"
}
return FormatPacksCN(packs)
}
func buildAddPackField(payload ContextToolsAddPayload) string {
if len(payload.Packs) == 0 {
return "仅固定 core"
}
return FormatPacksCN(payload.Packs)
}
func buildRemovePackField(payload ContextToolsRemovePayload) string {
if len(payload.Packs) == 0 {
if payload.All {
return "全部清空"
}
if strings.EqualFold(strings.TrimSpace(payload.Action), "deactivate") {
return "未指定(按整域处理)"
}
return "无"
}
return FormatPacksCN(payload.Packs)
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = value
}
return out
}
func (view ItemView) Map() map[string]any {
item := map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"tags": normalizeStringSlice(view.Tags),
"detail_lines": normalizeStringSlice(view.DetailLines),
}
if len(view.Meta) > 0 {
item["meta"] = cloneAnyMap(view.Meta)
}
return item
}

View File

@@ -0,0 +1,249 @@
package agenttools
import "strings"
const (
// ToolDomainSchedule 表示“排程调整”工具域。
ToolDomainSchedule = "schedule"
// ToolDomainTaskClass 表示“任务类定义”工具域。
ToolDomainTaskClass = "taskclass"
)
const (
// ToolNameContextToolsAdd 表示“向 msg0 动态区注入目标工具域定义”工具。
ToolNameContextToolsAdd = "context_tools_add"
// ToolNameContextToolsRemove 表示“从 msg0 动态区移除目标工具域定义”工具。
ToolNameContextToolsRemove = "context_tools_remove"
)
const (
// ToolPackCore 是固定包:始终注入,不允许显式 add/remove。
ToolPackCore = "core"
// schedule 二级包(可选)。
ToolPackQueue = "queue"
ToolPackMutation = "mutation"
ToolPackAnalyze = "analyze"
ToolPackDetailRead = "detail_read"
ToolPackDeepAnalyze = "deep_analyze"
ToolPackWeb = "web"
)
type toolProfile struct {
Domain string
Pack string
}
// toolProfileByName 维护“工具名 -> 域/二级包”映射。
//
// 设计说明:
// 1. context 管理工具不参与域/包映射;
// 2. schedule 的 core 包是固定注入;其余能力按二级包按需注入;
// 3. taskclass 目前只有 core 包(固定注入)。
var toolProfileByName = map[string]toolProfile{
"get_overview": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
"query_available_slots": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
"query_target_tasks": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
"analyze_health": {Domain: ToolDomainSchedule, Pack: ToolPackAnalyze},
"query_range": {Domain: ToolDomainSchedule, Pack: ToolPackDetailRead},
"get_task_info": {Domain: ToolDomainSchedule, Pack: ToolPackDetailRead},
"queue_status": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"queue_pop_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"queue_apply_head_move": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"queue_skip_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
"web_search": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
"web_fetch": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
"upsert_task_class": {Domain: ToolDomainTaskClass, Pack: ToolPackCore},
}
// NormalizeToolDomain 统一规范化工具域字符串。
func NormalizeToolDomain(domain string) string {
switch strings.ToLower(strings.TrimSpace(domain)) {
case ToolDomainSchedule:
return ToolDomainSchedule
case ToolDomainTaskClass:
return ToolDomainTaskClass
default:
return ""
}
}
// IsSupportedToolDomain 判断是否为当前支持的业务工具域。
func IsSupportedToolDomain(domain string) bool {
return NormalizeToolDomain(domain) != ""
}
// NormalizeToolPack 统一规范化指定域下的二级包名。
func NormalizeToolPack(domain, pack string) string {
normalizedDomain := NormalizeToolDomain(domain)
normalizedPack := strings.ToLower(strings.TrimSpace(pack))
if normalizedDomain == "" || normalizedPack == "" {
return ""
}
switch normalizedDomain {
case ToolDomainSchedule:
switch normalizedPack {
case ToolPackCore, ToolPackQueue, ToolPackMutation, ToolPackAnalyze, ToolPackDetailRead, ToolPackDeepAnalyze, ToolPackWeb:
return normalizedPack
default:
return ""
}
case ToolDomainTaskClass:
if normalizedPack == ToolPackCore {
return ToolPackCore
}
return ""
default:
return ""
}
}
// IsSupportedToolPack 判断某域下某二级包是否受支持。
func IsSupportedToolPack(domain, pack string) bool {
return NormalizeToolPack(domain, pack) != ""
}
// IsFixedToolPack 判断某域下某二级包是否属于固定注入包。
func IsFixedToolPack(domain, pack string) bool {
normalizedPack := NormalizeToolPack(domain, pack)
return normalizedPack == ToolPackCore
}
// ListOptionalToolPacks 返回某域可选二级包列表(不含 core
func ListOptionalToolPacks(domain string) []string {
switch NormalizeToolDomain(domain) {
case ToolDomainSchedule:
return []string{
ToolPackMutation,
ToolPackAnalyze,
ToolPackDetailRead,
ToolPackDeepAnalyze,
ToolPackQueue,
ToolPackWeb,
}
default:
return nil
}
}
// ListDefaultToolPacks 返回某域“默认注入”的可选包集合。
//
// 说明:
// 1. 仅用于 packs 为空时的兜底,目的是降低 msg0 噪声;
// 2. schedule 默认只开 mutation+analyze其他包按需 add
// 3. taskclass 当前无可选包。
func ListDefaultToolPacks(domain string) []string {
switch NormalizeToolDomain(domain) {
case ToolDomainSchedule:
return []string{ToolPackMutation, ToolPackAnalyze}
default:
return nil
}
}
// NormalizeToolPacks 规范化 pack 列表,并去重。
//
// 1. 仅返回受支持的 pack
// 2. 自动剔除固定包 corecore 不接受显式管理);
// 3. 顺序保持第一次出现顺序,便于日志和 prompt 可读性。
func NormalizeToolPacks(domain string, packs []string) []string {
normalizedDomain := NormalizeToolDomain(domain)
if normalizedDomain == "" || len(packs) == 0 {
return nil
}
seen := make(map[string]struct{}, len(packs))
result := make([]string, 0, len(packs))
for _, rawPack := range packs {
pack := NormalizeToolPack(normalizedDomain, rawPack)
if pack == "" || IsFixedToolPack(normalizedDomain, pack) {
continue
}
if _, exists := seen[pack]; exists {
continue
}
seen[pack] = struct{}{}
result = append(result, pack)
}
if len(result) == 0 {
return nil
}
return result
}
// ResolveEffectiveToolPacks 返回某域下“真正生效”的可选包集合。
//
// 兼容策略:
// 1. schedule 域且 packs 为空时默认启用最小可用包mutation+analyze
// 2. taskclass 目前无可选包,统一返回 nil
// 3. 非法域统一返回 nil。
func ResolveEffectiveToolPacks(domain string, packs []string) []string {
normalizedDomain := NormalizeToolDomain(domain)
if normalizedDomain == "" {
return nil
}
if normalizedDomain == ToolDomainTaskClass {
return nil
}
normalizedPacks := NormalizeToolPacks(normalizedDomain, packs)
if len(normalizedPacks) > 0 {
return normalizedPacks
}
defaultPacks := ListDefaultToolPacks(normalizedDomain)
if len(defaultPacks) == 0 {
return nil
}
result := make([]string, len(defaultPacks))
copy(result, defaultPacks)
return result
}
// IsContextManagementTool 判断工具是否属于上下文管理工具。
func IsContextManagementTool(name string) bool {
switch strings.TrimSpace(name) {
case ToolNameContextToolsAdd, ToolNameContextToolsRemove:
return true
default:
return false
}
}
// ResolveToolDomain 返回工具所属业务域。
func ResolveToolDomain(name string) (string, bool) {
domain, _, ok := ResolveToolDomainPack(name)
return domain, ok
}
// ResolveToolDomainPack 返回工具所属域与二级包。
//
// 返回语义:
// 1. 命中映射返回 (domain, pack, true)
// 2. 未命中返回 ("", "", false)
// 3. context 管理工具统一返回 ("", "", false)。
func ResolveToolDomainPack(name string) (string, string, bool) {
toolName := strings.TrimSpace(name)
if IsContextManagementTool(toolName) {
return "", "", false
}
profile, ok := toolProfileByName[toolName]
if !ok {
return "", "", false
}
return profile.Domain, profile.Pack, true
}

View File

@@ -0,0 +1,175 @@
package web
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// Fetcher 抓取指定 URL 正文并做最小 HTML 清洗。
//
// 职责:
// 1. 发起 HTTP GET 请求并读取响应体;
// 2. 剥离 HTML 标签,保留纯文本内容;
// 3. 按 MaxChars 截断,避免超长正文占用模型上下文。
//
// 不负责:
// 1. 不负责 JS 渲染(无法处理 SPA 页面);
// 2. 不负责反爬绕过(遇到 403 直接返回错误);
// 3. 不负责正文提取算法优化(仅做粗粒度标签剥离)。
type Fetcher struct {
// Client 带超时的 HTTP 客户端,由调用方注入。
Client *http.Client
// MaxChars 正文最大字符数。超出时截断并标记 truncated=true。0 使用默认值 4000。
MaxChars int
}
// NewFetcher 创建默认 Fetcher。
//
// 1. 超时默认 10 秒,足够覆盖大多数静态页面;
// 2. MaxChars 默认 4000 字符,约占 1000~2000 token不会挤占过多上下文。
func NewFetcher() *Fetcher {
return &Fetcher{
Client: &http.Client{
Timeout: 10 * time.Second,
},
MaxChars: 4000,
}
}
// FetchResult 抓取结果。
type FetchResult struct {
// Title 页面标题(从 <title> 标签提取)。
Title string
// Content 清洗后的纯文本正文。
Content string
// Truncated 正文是否被截断。
Truncated bool
}
// Fetch 抓取指定 URL 并返回清洗后的正文。
//
// 流程:
// 1. 构建带超时的 HTTP GET 请求;
// 2. 检查状态码,非 2xx 直接返回可读错误;
// 3. 读取响应体,提取 <title>
// 4. 剥离 HTML 标签,按 MaxChars 截断;
// 5. 所有失败场景返回 error由工具层兜底组装 observation。
func (f *Fetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
// 1. 构建请求,注入 ctx 用于超时与取消。
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("构建请求失败:%w", err)
}
// 2. 模拟浏览器 User-Agent避免部分站点直接拒绝。
req.Header.Set("User-Agent", "SmartFlow-Agent/1.0 (compatible; web_fetch)")
resp, err := f.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败:%w", err)
}
defer resp.Body.Close()
// 3. 非 2xx 返回明确状态码,方便工具层区分 4xx/5xx。
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d%s", resp.StatusCode, resp.Status)
}
// 4. 限制读取量(最多 1MB防止恶意超长响应撑爆内存。
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("读取响应体失败:%w", err)
}
htmlStr := string(body)
// 5. 提取 <title> 内容。
title := extractHTMLTitle(htmlStr)
// 6. 剥离 HTML 标签,得到纯文本。
text := stripHTMLTags(htmlStr)
// 7. 清理多余空白(连续换行、行首行尾空格)。
text = cleanWhitespace(text)
// 8. 按 MaxChars 截断。
maxChars := f.MaxChars
if maxChars <= 0 {
maxChars = 4000
}
truncated := false
runes := []rune(text)
if len(runes) > maxChars {
truncated = true
runes = runes[:maxChars]
}
return &FetchResult{
Title: title,
Content: string(runes),
Truncated: truncated,
}, nil
}
// extractHTMLTitle 从 HTML 中提取 <title> 标签内容。
//
// 1. 使用正则匹配,不做 DOM 解析(兼顾性能与简单性);
// 2. 找不到时返回空字符串,不报错。
func extractHTMLTitle(htmlStr string) string {
re := regexp.MustCompile("(?i)<title[^>]*>(.*?)</title>")
matches := re.FindStringSubmatch(htmlStr)
if len(matches) >= 2 {
return strings.TrimSpace(matches[1])
}
return ""
}
// stripHTMLTags 剥离所有 HTML 标签,保留纯文本。
//
// 1. 先移除 <script> / <style> 块(避免 JS/CSS 内容污染正文);
// 2. 再移除所有 HTML 标签;
// 3. 解码常见 HTML 实体(&amp; &lt; &gt; &quot;)。
func stripHTMLTags(htmlStr string) string {
// 1. 移除 script/style 块
re := regexp.MustCompile("(?is)<(script|style)[^>]*>.*?</\\1>")
text := re.ReplaceAllString(htmlStr, " ")
// 2. 移除所有 HTML 标签
reTag := regexp.MustCompile("<[^>]+>")
text = reTag.ReplaceAllString(text, " ")
// 3. 解码常见 HTML 实体
text = strings.ReplaceAll(text, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
text = strings.ReplaceAll(text, "&nbsp;", " ")
return text
}
// cleanWhitespace 清理多余空白:连续空行合并为单个换行,去除行首行尾空格。
func cleanWhitespace(text string) string {
// 1. 连续换行压缩为最多两个换行(保留段落分隔感)。
re := regexp.MustCompile("\\n{3,}")
text = re.ReplaceAllString(text, "\n\n")
// 2. 按行去除首尾空白后重新拼装。
lines := strings.Split(text, "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
cleaned = append(cleaned, trimmed)
}
return strings.Join(cleaned, "\n")
}

View File

@@ -0,0 +1,82 @@
package web
import (
"context"
"time"
)
// SearchProvider 搜索供应商抽象接口。
//
// 职责:
// 1. 接收检索查询与选项,返回结构化搜索结果;
// 2. 实现方负责 HTTP 调用、错误重试、限流兜底;
// 3. 调用方不感知底层是 Bocha / Mock 还是其他供应商。
//
// 不负责:
// 1. 不负责 URL 正文抓取(由 Fetcher 承担);
// 2. 不负责结果缓存(由上层工具决定)。
type SearchProvider interface {
// Name 返回供应商名称(如 "mock"、"bocha"),用于日志与降级标识。
Name() string
// Search 执行一次检索。
//
// 1. ctx 用于超时控制与取消;
// 2. opts.TopK 默认 5上限 20超出自动截断
// 3. 失败时返回 error调用方负责兜底 observation 组装。
Search(ctx context.Context, query string, opts SearchOptions) (*SearchResponse, error)
}
// SearchOptions 搜索可选参数。
type SearchOptions struct {
// TopK 返回结果数上限。0 表示使用供应商默认值(通常为 5
TopK int
// DomainAllow 仅返回指定域名下的结果。空表示不限。
DomainAllow []string
// RecencyDays 仅返回最近 N 天内的结果。0 表示不限时间。
RecencyDays int
}
// SearchResponse 搜索结果集合。
type SearchResponse struct {
// Query 原始查询文本,用于日志追踪。
Query string
// Items 搜索结果条目,按相关性降序排列。
Items []SearchItem
}
// SearchItem 单条搜索结果。
type SearchItem struct {
// Title 页面标题。
Title string
// URL 页面链接。
URL string
// Snippet 搜索引擎返回的摘要片段。
Snippet string
// Domain 来源域名(如 "example.com"),由实现方从 URL 提取。
Domain string
// PublishedAt 页面发布时间(若供应商可提供)。零值表示未知。
PublishedAt time.Time
// Raw 供应商原始响应字段,供调试用,不传给模型。
Raw map[string]any
}
// normalizeTopK 将用户传入的 topK 归一化到 [1, max] 区间。
// 默认值 5上限 20防止模型传入异常值导致 API 爆炸。
func normalizeTopK(topK, defaultVal, maxVal int) int {
if topK <= 0 {
return defaultVal
}
if topK > maxVal {
return maxVal
}
return topK
}

View File

@@ -0,0 +1,217 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// BochaProvider 博查Bocha搜索供应商实现。
//
// 职责:
// 1. 将 SearchOptions 映射为博查 API 请求参数;
// 2. 发起 HTTP POST 调用博查 web-search 端点;
// 3. 将博查响应转换为统一的 SearchResponse 结构。
//
// 不负责:
// 1. 不负责 API Key 管理(由调用方注入);
// 2. 不负责重试(单次调用失败直接返回 error
// 3. 不负责 URL 正文抓取(由 Fetcher 承担)。
//
// 博查 API 文档https://open.bochaai.com/
type BochaProvider struct {
// apiKey 博查 API Key从配置注入。
apiKey string
// httpClient 带超时的 HTTP 客户端。
httpClient *http.Client
// baseURL 博查 API 基础地址,默认 https://api.bochaai.com/v1。
baseURL string
}
// NewBochaProvider 创建博查搜索供应商。
//
// 1. apiKey 必填,为空时 Search 会返回明确错误;
// 2. 超时默认 10 秒,与工具层 ctx 超时对齐;
// 3. baseURL 留空则使用默认地址。
func NewBochaProvider(apiKey, baseURL string) *BochaProvider {
if baseURL == "" {
baseURL = "https://api.bochaai.com/v1"
}
return &BochaProvider{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
baseURL: baseURL,
}
}
// Name 返回供应商标识。
func (b *BochaProvider) Name() string { return "bocha" }
// Search 调用博查 web-search API 执行检索。
//
// 流程:
// 1. 参数校验apiKey 非空、query 非空);
// 2. 将 SearchOptions 映射为博查请求体count / freshness / summary
// 3. 发起 HTTP POST读取响应
// 4. 解析博查 JSON 响应,提取 webPages.value 数组;
// 5. 转换为统一 SearchItem 结构返回。
//
// 错误处理:
// - apiKey 为空 → 返回明确错误;
// - HTTP 非 2xx → 返回带状态码的错误;
// - 响应解析失败 → 返回原始响应片段供排查。
func (b *BochaProvider) Search(ctx context.Context, query string, opts SearchOptions) (*SearchResponse, error) {
// 1. 参数校验。
if b.apiKey == "" {
return nil, fmt.Errorf("博查 API Key 未配置")
}
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("查询关键词为空")
}
// 2. 组装请求体。
// 2.1 count博查支持 1~50默认 10。
count := normalizeTopK(opts.TopK, 10, 50)
// 2.2 freshness将 RecencyDays 映射为博查的时间过滤枚举。
freshness := mapRecencyDaysToFreshness(opts.RecencyDays)
reqBody := bochaSearchRequest{
Query: query,
Count: count,
Freshness: freshness,
Summary: true, // 开启 AI 摘要,提升结果信息密度
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求体失败:%w", err)
}
// 3. 发起 HTTP POST。
url := b.baseURL + "/web-search"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("构建请求失败:%w", err)
}
req.Header.Set("Authorization", "Bearer "+b.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求博查 API 失败:%w", err)
}
defer resp.Body.Close()
// 4. 读取响应体(限制 2MB防止异常响应撑爆内存
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return nil, fmt.Errorf("读取博查响应失败:%w", err)
}
// 5. 检查 HTTP 状态码。
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// 截取前 200 字符作为错误上下文,避免日志过长。
snippet := string(respBody)
if len(snippet) > 200 {
snippet = snippet[:200]
}
return nil, fmt.Errorf("博查 API 返回 HTTP %d%s", resp.StatusCode, snippet)
}
// 6. 解析响应 JSON。
var bochaResp bochaSearchAPIResponse
if err := json.Unmarshal(respBody, &bochaResp); err != nil {
return nil, fmt.Errorf("解析博查响应失败:%w", err)
}
// 7. 提取搜索结果。
items := make([]SearchItem, 0, len(bochaResp.Data.WebPages.Value))
for _, v := range bochaResp.Data.WebPages.Value {
item := SearchItem{
Title: v.Name,
URL: v.URL,
Snippet: v.Summary, // 优先使用 AI 摘要;若为空则回退到 snippet
Domain: v.SiteName,
}
if item.Snippet == "" {
item.Snippet = v.Snippet
}
// 解析发布时间博查格式2024-07-22T00:00:00+08:00
if v.DatePublished != "" {
if t, err := time.Parse(time.RFC3339, v.DatePublished); err == nil {
item.PublishedAt = t
}
}
items = append(items, item)
}
return &SearchResponse{
Query: query,
Items: items,
}, nil
}
// mapRecencyDaysToFreshness 将 RecencyDays 映射为博查 freshness 枚举值。
//
// 映射规则:
// - 0 → noLimit不限时间
// - 1 → oneDay
// - 2~7 → oneWeek
// - 8~30 → oneMonth
// - 31~365 → oneYear
// - >365 → noLimit
func mapRecencyDaysToFreshness(days int) string {
switch {
case days <= 0:
return "noLimit"
case days <= 1:
return "oneDay"
case days <= 7:
return "oneWeek"
case days <= 30:
return "oneMonth"
case days <= 365:
return "oneYear"
default:
return "noLimit"
}
}
// ==================== 博查 API 请求/响应结构体 ====================
// bochaSearchRequest 博查 web-search 请求体。
type bochaSearchRequest struct {
Query string `json:"query"`
Count int `json:"count"`
Freshness string `json:"freshness"`
Summary bool `json:"summary"`
}
// bochaSearchAPIResponse 博查 web-search 响应体(只提取需要的字段)。
type bochaSearchAPIResponse struct {
Data struct {
WebPages struct {
Value []bochaWebPageItem `json:"value"`
} `json:"webPages"`
} `json:"data"`
}
// bochaWebPageItem 博查单条搜索结果。
type bochaWebPageItem struct {
Name string `json:"name"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Summary string `json:"summary"`
SiteName string `json:"siteName"`
DatePublished string `json:"datePublished"`
}

View File

@@ -0,0 +1,58 @@
package web
import (
"context"
"fmt"
"time"
)
// MockProvider 空实现搜索供应商,返回硬编码结果。
//
// 用途:
// 1. 在真实 API Key 到手前先跑通工具注册→调用→observation 写回的完整链路;
// 2. 后续替换为 Tavily/Brave 实现后Mock 保留用于单元测试。
//
// 不负责:
// 1. 不负责真实 HTTP 调用;
// 2. 不负责网络错误模拟(如需测试超时可另行实现 TimeoutMockProvider
type MockProvider struct{}
// Name 返回供应商标识。
func (m *MockProvider) Name() string { return "mock" }
// Search 返回 2 条硬编码搜索结果,模拟正常检索链路。
//
// 1. 无论 query 内容如何,始终返回相同结果;
// 2. ctx 仅做形式兼容,不检查超时;
// 3. 永远不返回 errorMock 不模拟失败场景)。
func (m *MockProvider) Search(_ context.Context, query string, opts SearchOptions) (*SearchResponse, error) {
topK := normalizeTopK(opts.TopK, 5, 20)
// 1. 准备 2 条模拟数据覆盖核心字段title/url/snippet/domain/published_at
// 2. 若调用方 topK=1 则只返回第一条。
mockItems := []SearchItem{
{
Title: fmt.Sprintf("搜索结果示例 - %s", query),
URL: "https://example.com/search-result-1",
Snippet: "这是 MockProvider 返回的模拟搜索摘要,用于验证工具链路是否通畅。",
Domain: "example.com",
PublishedAt: time.Now().Add(-24 * time.Hour),
},
{
Title: fmt.Sprintf("相关资料 - %s", query),
URL: "https://example.com/related-resource-2",
Snippet: "这是第二条 Mock 结果,模拟同主题下的补充信息来源。",
Domain: "example.com",
PublishedAt: time.Now().Add(-48 * time.Hour),
},
}
if topK < len(mockItems) {
mockItems = mockItems[:topK]
}
return &SearchResponse{
Query: query,
Items: mockItems,
}, nil
}

View File

@@ -0,0 +1,227 @@
package web
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
)
// SearchToolHandler web_search 工具 handler。
//
// 职责:
// 1. 解析 argsquery / top_k / domain_allow / recency_days
// 2. 调用 SearchProvider 执行检索;
// 3. 组装结构化 JSON observation 返回给模型。
//
// 不负责:
// 1. 不负责 provider 生命周期管理(由注册层注入);
// 2. 不负责重试provider 内部处理)。
type SearchToolHandler struct {
provider SearchProvider
}
// NewSearchToolHandler 创建 web_search 工具 handler。
//
// 1. provider 为 nil 时Handle 返回"搜索暂未启用"的 observation
// 2. 这样做的好处是:即使未配置 provider也不会阻断主流程。
func NewSearchToolHandler(provider SearchProvider) *SearchToolHandler {
return &SearchToolHandler{provider: provider}
}
// searchToolArgs web_search 工具的参数定义。
type searchToolArgs struct {
Query string `json:"query"`
TopK int `json:"top_k"`
DomainAllow []string `json:"domain_allow"`
RecencyDays int `json:"recency_days"`
}
// searchToolResult web_search 工具的输出结构。
type searchToolResult struct {
Tool string `json:"tool"`
Query string `json:"query"`
Count int `json:"count"`
Items []searchItem `json:"items"`
}
// searchItem 输出给模型的单条搜索结果。
type searchItem struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Domain string `json:"domain"`
PublishedAt string `json:"published_at,omitempty"`
}
// Handle 执行 web_search 工具。
//
// 1. 解析参数query 为必填,缺失时直接返回错误 observation
// 2. 调用 provider.Search超时上限 10 秒;
// 3. 失败时返回可恢复 observation包含错误原因不 panic、不阻断主流程。
func (h *SearchToolHandler) Handle(args map[string]any) string {
// 1. provider 为 nil 说明未启用,直接返回提示。
if h.provider == nil {
return `{"tool":"web_search","error":"搜索暂未启用,请跳过 web_search 继续执行其他操作。"}`
}
// 2. 提取必填参数 query。
query, _ := args["query"].(string)
query = strings.TrimSpace(query)
if query == "" {
return `{"tool":"web_search","error":"参数错误:缺少必填参数 query。"}`
}
// 3. 提取可选参数。
topK, _ := args["top_k"].(float64)
var domainAllow []string
if raw, ok := args["domain_allow"].([]any); ok {
for _, v := range raw {
if s, ok := v.(string); ok {
domainAllow = append(domainAllow, s)
}
}
}
recencyDays, _ := args["recency_days"].(float64)
// 4. 构建带超时的 context防止搜索请求卡死。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 5. 调用 provider。
start := time.Now()
resp, err := h.provider.Search(ctx, query, SearchOptions{
TopK: int(topK),
DomainAllow: domainAllow,
RecencyDays: int(recencyDays),
})
elapsed := time.Since(start)
// 6. 记录日志,方便排查搜索耗时、结果数、失败原因。
log.Printf("[web_search] provider=%s query=%q topK=%d elapsed=%s results=%d err=%v",
h.provider.Name(), query, int(topK), elapsed, len(resp.Items), err)
if err != nil {
// 7. 失败时返回可恢复 observation模型看到后可选择换 query 或跳过。
return fmt.Sprintf(`{"tool":"web_search","error":"搜索失败:%s","query":%q}`, err.Error(), query)
}
// 8. 组装输出 JSON。
items := make([]searchItem, 0, len(resp.Items))
for _, item := range resp.Items {
si := searchItem{
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Domain: item.Domain,
}
if !item.PublishedAt.IsZero() {
si.PublishedAt = item.PublishedAt.Format("2006-01-02")
}
items = append(items, si)
}
result := searchToolResult{
Tool: "web_search",
Query: query,
Count: len(items),
Items: items,
}
out, err := json.Marshal(result)
if err != nil {
return fmt.Sprintf(`{"tool":"web_search","error":"序列化结果失败:%s"}`, err.Error())
}
return string(out)
}
// FetchToolHandler web_fetch 工具 handler。
//
// 职责:
// 1. 解析 argsurl / max_chars
// 2. 调用 Fetcher 抓取并清洗正文;
// 3. 组装 JSON observation 返回给模型。
type FetchToolHandler struct {
fetcher *Fetcher
}
// NewFetchToolHandler 创建 web_fetch 工具 handler。
func NewFetchToolHandler(fetcher *Fetcher) *FetchToolHandler {
return &FetchToolHandler{fetcher: fetcher}
}
// fetchToolResult web_fetch 工具的输出结构。
type fetchToolResult struct {
Tool string `json:"tool"`
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
}
// Handle 执行 web_fetch 工具。
//
// 1. 解析参数url 为必填;
// 2. max_chars 可选,为 0 时使用 Fetcher 默认值4000
// 3. 所有失败场景返回结构化错误 observation不 panic。
func (h *FetchToolHandler) Handle(args map[string]any) string {
// 1. fetcher 为 nil 说明未初始化。
if h.fetcher == nil {
return `{"tool":"web_fetch","error":"抓取服务暂未初始化,请跳过 web_fetch 继续执行。"}`
}
// 2. 提取必填参数 url。
url, _ := args["url"].(string)
url = strings.TrimSpace(url)
if url == "" {
return `{"tool":"web_fetch","error":"参数错误:缺少必填参数 url。"}`
}
// 3. 提取可选参数 max_chars覆盖 Fetcher 默认值。
maxChars := 0
if v, ok := args["max_chars"].(float64); ok {
maxChars = int(v)
}
// 4. 若调用方指定 max_chars临时覆盖 Fetcher 配置。
savedMaxChars := h.fetcher.MaxChars
if maxChars > 0 {
h.fetcher.MaxChars = maxChars
}
defer func() {
h.fetcher.MaxChars = savedMaxChars
}()
// 5. 构建带超时的 context。
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// 6. 调用 Fetcher。
start := time.Now()
result, err := h.fetcher.Fetch(ctx, url)
elapsed := time.Since(start)
log.Printf("[web_fetch] url=%q elapsed=%s truncated=%v err=%v", url, elapsed, result != nil && result.Truncated, err)
if err != nil {
// 7. 失败时返回可恢复 observation。
return fmt.Sprintf(`{"tool":"web_fetch","error":"抓取失败:%s","url":%q}`, err.Error(), url)
}
// 8. 组装输出 JSON。
out := fetchToolResult{
Tool: "web_fetch",
URL: url,
Title: result.Title,
Content: result.Content,
Truncated: result.Truncated,
}
raw, err := json.Marshal(out)
if err != nil {
return fmt.Sprintf(`{"tool":"web_fetch","error":"序列化结果失败:%s"}`, err.Error())
}
return string(raw)
}

View File

@@ -0,0 +1,432 @@
package web_result
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
// 设计说明:
// 1. 本轮只处理 web 工具卡片,按 AGENTS.md 的迁移约束避免同一轮跨多个能力域抽公共 toolview 层。
// 2. 因此这里先在 web_result 包内保留最小公共 helper保证 web_search / web_fetch 先完成切流。
// 3. 若后续 taskclass / context 也出现同类卡片 helper再由主代理统一评估是否下沉成公共层。
// BuildResultView 统一封装 web 结果卡片结构。
//
// 职责边界:
// 1. 负责把已经计算好的折叠态、展开态内容组装成标准视图。
// 2. 负责在子包内补齐 status / status_label避免依赖父包状态常量。
// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。
func BuildResultView(input BuildResultViewInput) ResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusDone
}
collapsed := CollapsedView{
Title: input.Title,
Subtitle: input.Subtitle,
Status: status,
StatusLabel: resolveStatusLabelCN(status),
Metrics: appendMetricCopy(input.Metrics),
}
expanded := ExpandedView{
Items: appendItemCopy(input.Items),
Sections: cloneSectionList(input.Sections),
RawText: input.Observation,
MachinePayload: cloneAnyMap(input.MachinePayload),
}
return ResultView{
ViewType: normalizeViewType(input.ViewType),
Version: ViewVersionResult,
Collapsed: collapsed.Map(),
Expanded: expanded.Map(),
}
}
func BuildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func BuildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func BuildKVSection(title string, fields []KVField) map[string]any {
rows := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
rows = append(rows, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": rows,
}
}
func BuildItemsSection(title string, items []ItemView) map[string]any {
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, item.Map())
}
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": rows,
}
}
func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
func BuildArgsSection(title string, fields []KVField) map[string]any {
if len(fields) == 0 {
return nil
}
valid := make([]KVField, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
valid = append(valid, BuildKVField(label, value))
}
if len(valid) == 0 {
return nil
}
return BuildKVSection(title, valid)
}
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
if section == nil {
return
}
*target = append(*target, section)
}
func appendMetricCopy(metrics []MetricField) []MetricField {
if len(metrics) == 0 {
return make([]MetricField, 0)
}
out := make([]MetricField, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, MetricField{Label: label, Value: value})
}
if len(out) == 0 {
return make([]MetricField, 0)
}
return out
}
func appendItemCopy(items []ItemView) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for _, item := range items {
out = append(out, BuildItem(item.Title, item.Subtitle, item.Tags, item.DetailLines, item.Meta))
}
return out
}
func normalizeViewType(viewType string) string {
switch strings.TrimSpace(viewType) {
case ViewTypeFetchResult:
return ViewTypeFetchResult
case ViewTypeSearchResult:
return ViewTypeSearchResult
default:
return ViewTypeSearchResult
}
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func parseObservationJSON(observation string) (map[string]any, bool) {
trimmed := strings.TrimSpace(observation)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return nil, false
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, false
}
return payload, true
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneAnyMap(typed)
case []map[string]any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyMap(item))
}
return out
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyValue(item))
}
return out
case []string:
out := make([]string, len(typed))
copy(out, typed)
return out
default:
return typed
}
}
func firstString(input map[string]any, keys ...string) string {
for _, key := range keys {
if value := readString(input, key); value != "" {
return value
}
}
return ""
}
func readString(input map[string]any, key string) string {
if len(input) == 0 {
return ""
}
value, exists := input[key]
if !exists || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
text := strings.TrimSpace(fmt.Sprintf("%v", typed))
if text == "" || text == "<nil>" {
return ""
}
return text
}
}
func readBool(input map[string]any, key string) (bool, bool) {
if len(input) == 0 {
return false, false
}
value, exists := input[key]
if !exists {
return false, false
}
typed, ok := value.(bool)
return typed, ok
}
func readInt(input map[string]any, key string) int {
if len(input) == 0 {
return 0
}
value, exists := input[key]
if !exists || value == nil {
return 0
}
switch typed := value.(type) {
case int:
return typed
case int32:
return int(typed)
case int64:
return int(typed)
case float64:
return int(typed)
default:
return 0
}
}
func previewText(text string, limit int) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
if limit <= 0 || len(runes) <= limit {
return string(runes)
}
return string(runes[:limit]) + "..."
}
func previewLines(text string, maxLines int, maxChars int) []string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return make([]string, 0)
}
lines := strings.Split(trimmed, "\n")
out := make([]string, 0, maxLines)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
out = append(out, previewText(line, maxChars))
if maxLines > 0 && len(out) >= maxLines {
break
}
}
if len(out) == 0 {
out = append(out, previewText(trimmed, maxChars))
}
return out
}
func formatStringSliceCN(items []string, limit int) string {
normalized := normalizeStringSlice(items)
if len(normalized) == 0 {
return ""
}
if limit <= 0 || len(normalized) <= limit {
return strings.Join(normalized, "、")
}
return fmt.Sprintf("%s 等 %d 个", strings.Join(normalized[:limit], "、"), len(normalized))
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func classifyUnavailableStatus(message string) string {
trimmed := strings.TrimSpace(message)
lower := strings.ToLower(trimmed)
switch {
case strings.Contains(trimmed, "暂未启用"),
strings.Contains(trimmed, "未启用"),
strings.Contains(trimmed, "暂未初始化"),
strings.Contains(trimmed, "未初始化"),
strings.Contains(trimmed, "未配置"),
strings.Contains(lower, "not enabled"),
strings.Contains(lower, "not configured"),
strings.Contains(lower, "unavailable"):
return StatusBlocked
default:
return StatusFailed
}
}
func buildRawPreviewSection(rawText string) map[string]any {
preview := previewText(rawText, 160)
if preview == "" {
return nil
}
return BuildCalloutSection("原始结果预览", preview, "info", previewLines(rawText, 3, 120))
}
func hostnameFromURL(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
return strings.TrimSpace(parsed.Hostname())
}

View File

@@ -0,0 +1,232 @@
package web_result
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
)
type fetchObservation struct {
Tool string `json:"tool"`
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
Error string `json:"error"`
Err string `json:"err"`
Reason string `json:"reason"`
}
// BuildFetchView 负责把 web_fetch observation 构造成前端可直接消费的结果卡片。
//
// 职责边界:
// 1. 负责解析成功 / 失败 / provider 未启用 / 非 JSON 回退四类场景。
// 2. 负责保留 raw_text 与 machine_payload便于前端调试与后续交互。
// 3. 不负责真正抓取网页,也不改写传入 observation 原文。
func BuildFetchView(input FetchViewInput) ResultView {
payloadMap, ok := parseObservationJSON(input.Observation)
if !ok {
return buildFetchTextFallbackView(input)
}
payload := fetchObservation{}
if err := json.Unmarshal([]byte(strings.TrimSpace(input.Observation)), &payload); err != nil {
return buildFetchTextFallbackView(input)
}
rawURL := strings.TrimSpace(payload.URL)
if rawURL == "" {
rawURL = strings.TrimSpace(input.URL)
}
errorMessage := firstNonEmpty(payload.Error, payload.Err, payload.Reason)
if errorMessage == "" {
errorMessage = firstString(payloadMap, "message")
}
if errorMessage != "" {
return buildFetchFailureView(input, rawURL, errorMessage, payloadMap)
}
title := strings.TrimSpace(payload.Title)
content := strings.TrimSpace(payload.Content)
host := hostnameFromURL(rawURL)
contentChars := utf8.RuneCountInString(content)
if title == "" {
title = "网页正文"
if host != "" {
title = host
}
}
itemTags := make([]string, 0, 2)
if host != "" {
itemTags = append(itemTags, host)
}
if payload.Truncated {
itemTags = append(itemTags, "已截断")
}
items := []ItemView{
BuildItem(
title,
rawURL,
itemTags,
buildFetchPreviewLines(content),
map[string]any{
"url": rawURL,
"title": strings.TrimSpace(payload.Title),
"content_len": contentChars,
"truncated": payload.Truncated,
},
),
}
sections := []map[string]any{
BuildKVSection("页面信息", []KVField{
BuildKVField("链接", rawURL),
BuildKVField("标题", fallbackText(payload.Title, "未提取到标题")),
BuildKVField("正文长度", fmt.Sprintf("%d 字", contentChars)),
BuildKVField("是否截断", formatBoolCN(payload.Truncated)),
}),
BuildCalloutSection(
"正文预览",
previewText(content, 120),
"info",
buildFetchPreviewLines(content),
),
}
appendSectionIfPresent(&sections, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeFetchResult,
Status: StatusDone,
Title: buildFetchTitle(title),
Subtitle: buildFetchSubtitle(rawURL, host),
Metrics: buildFetchMetrics(contentChars, payload.Truncated, host),
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: payloadMap,
})
}
func buildFetchTextFallbackView(input FetchViewInput) ResultView {
subtitle := "抓取结果不是合法 JSON已回退为文本预览。"
if strings.TrimSpace(input.Observation) == "" {
subtitle = "抓取工具没有返回结构化结果,已回退为文本预览。"
}
sections := []map[string]any{
BuildCalloutSection("结果不可解析", subtitle, "danger", []string{subtitle}),
}
appendSectionIfPresent(&sections, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
appendSectionIfPresent(&sections, buildRawPreviewSection(input.Observation))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeFetchResult,
Status: StatusFailed,
Title: "网页抓取结果不可解析",
Subtitle: subtitle,
Metrics: buildFetchMetrics(0, false, hostnameFromURL(input.URL)),
Items: make([]ItemView, 0),
Sections: sections,
Observation: input.Observation,
})
}
func buildFetchFailureView(
input FetchViewInput,
rawURL string,
errorMessage string,
payloadMap map[string]any,
) ResultView {
status := classifyUnavailableStatus(errorMessage)
title := "网页抓取失败"
calloutTitle := "抓取执行失败"
tone := "danger"
if status == StatusBlocked {
title = "网页抓取未启用"
calloutTitle = "抓取服务未启用"
tone = "warning"
}
sections := []map[string]any{
BuildCalloutSection(calloutTitle, errorMessage, tone, []string{errorMessage}),
}
appendSectionIfPresent(&sections, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeFetchResult,
Status: status,
Title: title,
Subtitle: buildFetchFailureSubtitle(rawURL, errorMessage),
Metrics: buildFetchMetrics(0, false, hostnameFromURL(rawURL)),
Items: make([]ItemView, 0),
Sections: sections,
Observation: input.Observation,
MachinePayload: payloadMap,
})
}
func buildFetchMetrics(contentChars int, truncated bool, host string) []MetricField {
metrics := []MetricField{
BuildMetric("正文长度", fmt.Sprintf("%d 字", contentChars)),
BuildMetric("是否截断", formatBoolCN(truncated)),
}
if strings.TrimSpace(host) != "" {
metrics = append(metrics, BuildMetric("来源", host))
}
return metrics
}
func buildFetchArgFields(input FetchViewInput) []KVField {
fields := make([]KVField, 0, 2)
if rawURL := strings.TrimSpace(input.URL); rawURL != "" {
fields = append(fields, BuildKVField("链接", rawURL))
}
if input.MaxChars > 0 {
fields = append(fields, BuildKVField("截断上限", fmt.Sprintf("%d 字", input.MaxChars)))
}
return fields
}
func buildFetchPreviewLines(content string) []string {
lines := previewLines(content, 3, 120)
if len(lines) > 0 {
return lines
}
return []string{"正文为空"}
}
func buildFetchTitle(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "已抓取网页正文"
}
return fmt.Sprintf("已抓取:%s", previewText(title, 36))
}
func buildFetchSubtitle(rawURL string, host string) string {
if strings.TrimSpace(host) != "" {
return fmt.Sprintf("来源:%s", host)
}
if strings.TrimSpace(rawURL) != "" {
return fmt.Sprintf("来源:%s", previewText(rawURL, 48))
}
return "已返回网页正文。"
}
func buildFetchFailureSubtitle(rawURL string, errorMessage string) string {
if strings.TrimSpace(rawURL) == "" {
return strings.TrimSpace(errorMessage)
}
return fmt.Sprintf("链接:%s", previewText(rawURL, 48))
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}

View File

@@ -0,0 +1,241 @@
package web_result
import (
"encoding/json"
"fmt"
"strings"
)
type searchObservation struct {
Tool string `json:"tool"`
Query string `json:"query"`
Count int `json:"count"`
Items []searchObservationItem `json:"items"`
Error string `json:"error"`
Err string `json:"err"`
Reason string `json:"reason"`
}
type searchObservationItem struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Domain string `json:"domain"`
PublishedAt string `json:"published_at"`
}
// BuildSearchView 负责把 web_search observation 构造成前端可直接消费的结果卡片。
//
// 职责边界:
// 1. 负责解析成功 / 失败 / provider 未启用 / 非 JSON 回退四类场景。
// 2. 负责保留 raw_text 与 machine_payload方便前端调试与后续交互。
// 3. 不负责执行搜索,也不改写传入 observation 原文。
func BuildSearchView(input SearchViewInput) ResultView {
payloadMap, ok := parseObservationJSON(input.Observation)
if !ok {
return buildSearchTextFallbackView(input)
}
payload := searchObservation{}
if err := json.Unmarshal([]byte(strings.TrimSpace(input.Observation)), &payload); err != nil {
return buildSearchTextFallbackView(input)
}
query := strings.TrimSpace(payload.Query)
if query == "" {
query = strings.TrimSpace(input.Query)
}
errorMessage := firstNonEmpty(payload.Error, payload.Err, payload.Reason)
if errorMessage == "" {
errorMessage = firstString(payloadMap, "message")
}
if errorMessage != "" {
return buildSearchFailureView(input, query, errorMessage, payloadMap)
}
items := buildSearchItems(payload.Items)
count := payload.Count
if count < len(items) {
count = len(items)
}
title := fmt.Sprintf("找到 %d 条网页结果", count)
subtitle := buildSearchSubtitle(query)
sections := make([]map[string]any, 0, 3)
appendSectionIfPresent(&sections, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
if len(items) > 0 {
sections = append(sections, BuildItemsSection("搜索结果", items))
} else {
sections = append(sections, BuildCalloutSection(
"没有命中结果",
"当前关键词没有返回可展示结果,可以尝试缩短关键词或放宽筛选条件。",
"info",
[]string{"建议优先调整关键词,再决定是否继续抓取具体页面。"},
))
}
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeSearchResult,
Status: StatusDone,
Title: title,
Subtitle: subtitle,
Metrics: buildSearchMetrics(count, input.DomainAllow, input.RecencyDays),
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: payloadMap,
})
}
func buildSearchTextFallbackView(input SearchViewInput) ResultView {
subtitle := "搜索结果不是合法 JSON已回退为文本预览。"
if strings.TrimSpace(input.Observation) == "" {
subtitle = "搜索工具没有返回结构化结果,已回退为文本预览。"
}
sections := []map[string]any{
BuildCalloutSection("结果不可解析", subtitle, "danger", []string{subtitle}),
}
appendSectionIfPresent(&sections, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
appendSectionIfPresent(&sections, buildRawPreviewSection(input.Observation))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeSearchResult,
Status: StatusFailed,
Title: "网页搜索结果不可解析",
Subtitle: subtitle,
Metrics: buildSearchMetrics(0, input.DomainAllow, input.RecencyDays),
Items: make([]ItemView, 0),
Sections: sections,
Observation: input.Observation,
})
}
func buildSearchFailureView(
input SearchViewInput,
query string,
errorMessage string,
payloadMap map[string]any,
) ResultView {
status := classifyUnavailableStatus(errorMessage)
title := "网页搜索失败"
calloutTitle := "搜索执行失败"
tone := "danger"
if status == StatusBlocked {
title = "网页搜索未启用"
calloutTitle = "搜索 Provider 未启用"
tone = "warning"
}
sections := []map[string]any{
BuildCalloutSection(calloutTitle, errorMessage, tone, []string{errorMessage}),
}
appendSectionIfPresent(&sections, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeSearchResult,
Status: status,
Title: title,
Subtitle: buildSearchFailureSubtitle(query, errorMessage),
Metrics: buildSearchMetrics(0, input.DomainAllow, input.RecencyDays),
Items: make([]ItemView, 0),
Sections: sections,
Observation: input.Observation,
MachinePayload: payloadMap,
})
}
func buildSearchItems(items []searchObservationItem) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for index, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
title = fmt.Sprintf("结果 %d", index+1)
}
subtitle := strings.TrimSpace(item.URL)
if domain := strings.TrimSpace(item.Domain); domain != "" {
subtitle = domain
}
tags := make([]string, 0, 2)
if domain := strings.TrimSpace(item.Domain); domain != "" {
tags = append(tags, domain)
}
if publishedAt := strings.TrimSpace(item.PublishedAt); publishedAt != "" {
tags = append(tags, publishedAt)
}
detailLines := make([]string, 0, 2)
if snippet := strings.TrimSpace(item.Snippet); snippet != "" {
detailLines = append(detailLines, previewText(snippet, 120))
}
if rawURL := strings.TrimSpace(item.URL); rawURL != "" {
detailLines = append(detailLines, rawURL)
}
out = append(out, BuildItem(title, subtitle, tags, detailLines, map[string]any{
"url": strings.TrimSpace(item.URL),
"domain": strings.TrimSpace(item.Domain),
"published_at": strings.TrimSpace(item.PublishedAt),
}))
}
return out
}
func buildSearchMetrics(count int, domainAllow []string, recencyDays int) []MetricField {
metrics := []MetricField{
BuildMetric("结果数", fmt.Sprintf("%d", count)),
}
if len(domainAllow) > 0 {
metrics = append(metrics, BuildMetric("域名过滤", formatStringSliceCN(domainAllow, 2)))
}
if recencyDays > 0 {
metrics = append(metrics, BuildMetric("时效", fmt.Sprintf("近 %d 天", recencyDays)))
}
return metrics
}
func buildSearchArgFields(input SearchViewInput) []KVField {
fields := make([]KVField, 0, 4)
if query := strings.TrimSpace(input.Query); query != "" {
fields = append(fields, BuildKVField("关键词", query))
}
if input.TopK > 0 {
fields = append(fields, BuildKVField("结果上限", fmt.Sprintf("%d", input.TopK)))
}
if len(input.DomainAllow) > 0 {
fields = append(fields, BuildKVField("域名过滤", formatStringSliceCN(input.DomainAllow, 4)))
}
if input.RecencyDays > 0 {
fields = append(fields, BuildKVField("时效范围", fmt.Sprintf("近 %d 天", input.RecencyDays)))
}
return fields
}
func buildSearchSubtitle(query string) string {
if strings.TrimSpace(query) == "" {
return "已返回网页搜索结果。"
}
return fmt.Sprintf("关键词:%s", previewText(query, 40))
}
func buildSearchFailureSubtitle(query string, errorMessage string) string {
if strings.TrimSpace(query) == "" {
return strings.TrimSpace(errorMessage)
}
return fmt.Sprintf("关键词:%s", previewText(query, 40))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@@ -0,0 +1,169 @@
package web_result
import "strings"
const (
// ViewTypeSearchResult 是 web_search 结果卡片的前端识别类型。
ViewTypeSearchResult = "web.search_result"
// ViewTypeFetchResult 是 web_fetch 结果卡片的前端识别类型。
ViewTypeFetchResult = "web.fetch_result"
// ViewVersionResult 固定为当前 web 结果卡片结构版本。
ViewVersionResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// ResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据。
// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议。
// 3. collapsed / expanded 保持 map 形态,便于父包直接桥接现有展示协议。
type ResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// CollapsedView 表示卡片折叠态数据。
type CollapsedView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
Metrics []MetricField `json:"metrics"`
}
// ExpandedView 表示卡片展开态数据。
type ExpandedView struct {
Items []ItemView `json:"items"`
Sections []map[string]any `json:"sections"`
RawText string `json:"raw_text"`
MachinePayload map[string]any `json:"machine_payload"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// KVField 是 section.type=kv 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是 expanded.items / section.items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// BuildResultViewInput 是通用 web 结果视图 builder 的输入。
//
// 职责边界:
// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区。
// 2. 不负责执行 web 工具observation 必须由父包 adapter 传入。
// 3. observation 会原样写入 raw_text不能在这里改写给模型的观察文本。
type BuildResultViewInput struct {
ViewType string
Status string
Title string
Subtitle string
Metrics []MetricField
Items []ItemView
Sections []map[string]any
Observation string
MachinePayload map[string]any
}
// SearchViewInput 是 web_search 视图构造输入。
type SearchViewInput struct {
Observation string
Query string
TopK int
DomainAllow []string
RecencyDays int
}
// FetchViewInput 是 web_fetch 视图构造输入。
type FetchViewInput struct {
Observation string
URL string
MaxChars int
}
func (view CollapsedView) Map() map[string]any {
metrics := make([]map[string]any, 0, len(view.Metrics))
for _, metric := range view.Metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
metrics = append(metrics, map[string]any{
"label": label,
"value": value,
})
}
if len(metrics) == 0 {
metrics = make([]map[string]any, 0)
}
return map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"status": normalizeStatus(view.Status),
"status_label": strings.TrimSpace(view.StatusLabel),
"metrics": metrics,
}
}
func (view ExpandedView) Map() map[string]any {
items := make([]map[string]any, 0, len(view.Items))
for _, item := range view.Items {
items = append(items, item.Map())
}
if len(items) == 0 {
items = make([]map[string]any, 0)
}
sections := cloneSectionList(view.Sections)
if len(sections) == 0 {
sections = make([]map[string]any, 0)
}
machinePayload := cloneAnyMap(view.MachinePayload)
if machinePayload == nil {
machinePayload = make(map[string]any)
}
return map[string]any{
"items": items,
"sections": sections,
"raw_text": view.RawText,
"machine_payload": machinePayload,
}
}
func (view ItemView) Map() map[string]any {
out := map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"tags": normalizeStringSlice(view.Tags),
"detail_lines": normalizeStringSlice(view.DetailLines),
}
if len(view.Meta) > 0 {
out["meta"] = cloneAnyMap(view.Meta)
}
return out
}

View File

@@ -0,0 +1,191 @@
package agenttools
import (
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/web"
webresult "github.com/LoveLosita/smartflow/backend/services/agent/tools/web_result"
)
// NewWebSearchToolHandler 返回 web_search 的结构化结果 handler。
//
// 职责边界:
// 1. 负责执行底层 web_search 工具,并保留原始 ObservationText 给模型。
// 2. 负责把工具参数投影成 web_result 子包需要的最小输入。
// 3. 不负责注册接线registry.go 由主代理统一切流。
func NewWebSearchToolHandler(provider web.SearchProvider) ToolHandler {
searchHandler := web.NewSearchToolHandler(provider)
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := searchHandler.Handle(args)
legacy := LegacyResultWithState("web_search", args, state, observation)
view := webresult.BuildSearchView(webresult.SearchViewInput{
Observation: observation,
Query: readStringArg(args, "query"),
TopK: readIntArg(args, "top_k"),
DomainAllow: readStringSliceArg(args, "domain_allow"),
RecencyDays: readIntArg(args, "recency_days"),
})
return buildWebExecutionResult(legacy, args, view)
}
}
// NewWebFetchToolHandler 返回 web_fetch 的结构化结果 handler。
//
// 职责边界:
// 1. 负责执行底层 web_fetch 工具,并保留原始 ObservationText 给模型。
// 2. 负责把抓取参数投影成 web_result 子包需要的最小输入。
// 3. 不负责注册接线registry.go 由主代理统一切流。
func NewWebFetchToolHandler(fetcher *web.Fetcher) ToolHandler {
fetchHandler := web.NewFetchToolHandler(fetcher)
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := fetchHandler.Handle(args)
legacy := LegacyResultWithState("web_fetch", args, state, observation)
view := webresult.BuildFetchView(webresult.FetchViewInput{
Observation: observation,
URL: readStringArg(args, "url"),
MaxChars: readIntArg(args, "max_chars"),
})
return buildWebExecutionResult(legacy, args, view)
}
}
// buildWebExecutionResult 负责把子包纯展示视图包回父包统一协议。
//
// 步骤化说明:
// 1. 先以 legacy 结果为基础,复用父包现有的参数预览、错误抽取与兜底字段。
// 2. 再用子包 collapsed.status 覆盖最终状态,支持“未启用 provider -> blocked”的卡片语义。
// 3. 最后补齐 raw_text / status_label保证 execute、SSE、timeline 都消费同一份 observation。
func buildWebExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view webresult.ResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = result.ObservationText
}
if _, exists := expanded["machine_payload"]; !exists {
expanded["machine_payload"] = map[string]any{}
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = webresult.ViewTypeSearchResult
}
version := view.Version
if version <= 0 {
version = webresult.ViewVersionResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func readStringArg(args map[string]any, key string) string {
if len(args) == 0 {
return ""
}
raw, exists := args[strings.TrimSpace(key)]
if !exists || raw == nil {
return ""
}
text, ok := raw.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
func readIntArg(args map[string]any, key string) int {
if len(args) == 0 {
return 0
}
value, ok := toInt(args[strings.TrimSpace(key)])
if !ok {
return 0
}
return value
}
func readStringSliceArg(args map[string]any, key string) []string {
if len(args) == 0 {
return nil
}
raw, exists := args[strings.TrimSpace(key)]
if !exists || raw == nil {
return nil
}
switch typed := raw.(type) {
case []string:
out := make([]string, 0, len(typed))
for _, item := range typed {
item = strings.TrimSpace(item)
if item == "" {
continue
}
out = append(out, item)
}
return out
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
text, ok := item.(string)
if !ok {
continue
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
out = append(out, text)
}
return out
default:
return nil
}
}

View File

@@ -0,0 +1,366 @@
# 工具结果结构化交接文档
## 最新负责人验收结论
本轮已经按“直接切流”完成 read 结果构造迁移6 个 `schedule.read_result` 工具仍由父包注册入口暴露,但父包只保留薄 adapter真实展示数据构造已经切到 `backend/services/agent/tools/schedule_read/` 子包。
第三批 `schedule.analysis_result` 也已经完成直接切流:`analyze_health` / `analyze_rhythm` 仍然保留原始 JSON `ObservationText`,前端展示新增走 `ResultView`
第四批非 schedule read/analysis 主链也已经完成直接切流:`web_search``web_fetch``upsert_task_class``context_tools_add``context_tools_remove` 已经分别切到专属结构化卡片;队列尾巴 `queue_pop_head` / `queue_skip_head` 也已经脱离 `legacy_text`
第四批当前切流点:
1. `registry.go`
- `web_search` 已切到 `NewWebSearchToolHandler()`
- `web_fetch` 已切到 `NewWebFetchToolHandler()`
- `upsert_task_class` 继续注册 `NewTaskClassUpsertToolHandler()`,但该入口已由 `taskclass_result_handlers.go` 承接结构化包装。
- `context_tools_add` / `context_tools_remove` 已在原 handler 内直接返回 `tool.context_result`
- `queue_pop_head` 已切到 `NewQueuePopHeadToolHandler()`
- `queue_skip_head` 已切到 `NewQueueSkipHeadToolHandler()`
- `wrapLegacyToolHandler` 已删除,当前默认注册表不再依赖该 legacy wrapper。
2. `backend/services/agent/tools/web_result/**` + `web_result_handlers.go`
- `web_search` 输出 `result_view.view_type = "web.search_result"`
- `web_fetch` 输出 `result_view.view_type = "web.fetch_result"`
- 原始 observation 继续写入 `ObservationText` / `expanded.raw_text`
3. `backend/services/agent/tools/taskclass_result/**` + `taskclass_result_handlers.go`
- `upsert_task_class` 输出 `result_view.view_type = "taskclass.write_result"`
- 写库、confirm、校验、错误处理语义不变只替换展示层。
4. `backend/services/agent/tools/tool_context_result/**` + `context_tools.go`
- `context_tools_add` / `context_tools_remove` 输出 `result_view.view_type = "tool.context_result"`
- 卡片展示 domain、packs、mode、all 和失败原因。
5. `backend/services/agent/tools/schedule_queue_handlers.go`
- `queue_pop_head` 复用 `schedule.read_result`
- `queue_skip_head` 复用 `schedule.operation_result`
第四批验收结果:
1. `go test ./services/agent/tools/...` 通过。
2. `go test ./...` 通过。
3. 根目录 `.gocache` 已清理。
4. 没有遗留临时 `*_test.go`
5. `ObservationText` 均保持原始工具 observation不被展示层改写。
analysis 当前切流点:
1. `registry.go`
- `analyze_health` 已切到 `NewAnalyzeHealthToolHandler()`
- `analyze_rhythm` 已切到 `NewAnalyzeRhythmToolHandler()`
2. `backend/services/agent/tools/schedule_analysis_handlers.go`
- 父包唯一 analysis adapter 文件。
- 负责执行 `schedule.AnalyzeHealth` / `schedule.AnalyzeRhythm`,保留原始 `ObservationText`,生成中文 `ArgumentView`,再调用 `schedule_analysis.BuildAnalyzeHealthView()` / `BuildAnalyzeRhythmView()`
3. `backend/services/agent/tools/schedule_analysis/**`
- 子包负责纯 analysis 展示数据构造。
- 不 import 父包 `newagenttools`,不返回 `ToolExecutionResult`
analysis 验收结果:
1. `result_view.view_type = "schedule.analysis_result"``version = 1`
2. `expanded.raw_text` 保留原始 observation JSON。
3. `expanded.machine_payload` 保留解析后的完整机器字段,供调试和后续交互使用。
4. `analyze_health` 的 observation JSON 契约未改动,仍可被 `state_snapshot` / prompt 摘要消费。
5. `go test ./services/agent/tools/...` 通过。
6. `go test ./...` 通过。
7. 根目录 `.gocache` 已清理。
8. 没有遗留临时 `*_test.go`
当前切流点:
1. `registry.go`
- 继续注册 `NewQueryAvailableSlotsToolHandler()``NewQueryRangeToolHandler()``NewQueryTargetTasksToolHandler()``NewGetTaskInfoToolHandler()``NewGetOverviewToolHandler()``NewQueueStatusToolHandler()`
2. `backend/services/agent/tools/schedule_read_handlers.go`
- 父包唯一 read adapter 文件。
- 负责执行底层 `schedule.*` 工具,保留原始 `ObservationText`,生成中文 `ArgumentView`,再调用 `schedule_read.BuildXxxView()`
3. `backend/services/agent/tools/schedule_read/**`
- 子包负责纯 read 展示数据构造。
- 不 import 父包 `newagenttools`,不返回 `ToolExecutionResult`
已删除旧实现:
1. `schedule_read_result_types.go`
2. `schedule_read_result_common.go`
3. `schedule_read_slots_handlers.go`
4. `schedule_read_tasks_handlers.go`
5. `schedule_read_overview_queue_handlers.go`
仍保留的父包能力:
1. `schedule_argument_format_helpers.go`
- 只服务 `execution_result.go` 的参数中文展示。
- 不再参与 `schedule.read_result` 卡片构造。
验收结果:
1. `go test ./services/agent/tools/...` 通过。
2. `go test ./...` 通过。
3. 根目录 `.gocache` 已清理。
4. 没有遗留临时 `*_test.go`
## 当前状态
本轮已经完成第二批 read 事实域工具的后端结构化结果改造。外层协议仍然是 `ToolExecutionResult`LLM 观察文本继续走 `ObservationText`,前端展示信息走 `ResultView` / `ArgumentView`
已经直接切到 `result_view.view_type = "schedule.read_result"` 的工具:
1. `query_available_slots`
2. `query_target_tasks`
3. `query_range`
4. `get_overview`
5. `get_task_info`
6. `queue_status`
尚未进入本轮的工具继续走 `LegacyResult`,不要为了“统一外观”在本轮顺手迁移其它工具。
当前新增/修改文件:
1. `backend/services/agent/tools/schedule_read_result_types.go`
- read 结果常量、payload 结构、轻量内部结构。
2. `backend/services/agent/tools/schedule_read_result_common.go`
- 统一 `schedule.read_result` builder、失败卡片、中文格式化、跨工具统计 helper。
- 当前文件偏重,约 600 行,是下一轮整理的重点。
3. `backend/services/agent/tools/schedule_read_slots_handlers.go`
- `query_available_slots``query_range`
4. `backend/services/agent/tools/schedule_read_tasks_handlers.go`
- `query_target_tasks``get_task_info`
5. `backend/services/agent/tools/schedule_read_overview_queue_handlers.go`
- `get_overview``queue_status`
6. `backend/services/agent/tools/execution_result.go`
- 补齐 read/analyze 相关参数的中文 `argument_view` 标签和展示值。
7. `backend/services/agent/tools/registry.go`
- 只把 6 个 read 工具的注册入口替换成新的 `NewXxxToolHandler()`
## 第二批协议
第二批新增的前端 view type 只有一个:
```json
{
"view_type": "schedule.read_result",
"version": 1,
"collapsed": {},
"expanded": {}
}
```
`collapsed` 面向卡片折叠态:
```json
{
"title": "找到 6 个目标任务",
"subtitle": "建议安排任务,已入队 6 个",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "任务数", "value": "6 个" }
]
}
```
`expanded` 面向卡片展开态:
```json
{
"items": [
{
"title": "[12]英语作文",
"subtitle": "待安排2 节",
"tags": ["待安排", "学习"],
"detail_lines": ["位置:未安排"],
"meta": {
"task_id": 12
}
}
],
"sections": [
{
"type": "items",
"title": "候选任务",
"items": []
}
],
"raw_text": "原始 observation 文本",
"machine_payload": {}
}
```
C 端默认展示字段:
1. `collapsed.title`
2. `collapsed.subtitle`
3. `collapsed.status_label`
4. `collapsed.metrics[].label/value`
5. `expanded.items[].title/subtitle/tags/detail_lines`
6. `expanded.sections[].title/summary/items`
默认不要展示的机器字段:
1. `expanded.machine_payload`
2. `expanded.raw_text`
3. `items[].meta`
4. `task_id``day``slot_start``week_filter` 等机器参数名
这些字段只给调试、回传、后续交互使用。
## 整理任务
当前第二批代码虽然已经拆成多个文件,但仍然平铺在 `backend/services/agent/tools` 根包里。后续整理目标是把 read 结果构造逻辑收到子目录,避免根目录继续膨胀。
不要直接把现有 `.go` 文件机械移动到子目录。Go 里子目录就是新 package当前文件依赖父包里的 `ToolHandler``ToolExecutionResult``ToolDisplayView``LegacyResultWithState` 等类型,直接移动会造成 import cycle。
推荐整理结构:
```text
backend/services/agent/tools/
registry.go
schedule_read_handlers.go
schedule_read/
types.go
common.go
slots.go
tasks.go
overview_queue.go
```
职责边界:
1. `backend/services/agent/tools/schedule_read/**`
- 子包只做纯 read 展示数据构造。
- 不 import 父包 `newagenttools`
- 不返回 `ToolExecutionResult`
- 返回类似 `ReadResultView` 的纯数据结构:`ViewType``Collapsed``Expanded``MachinePayload`
2. `backend/services/agent/tools/schedule_read_handlers.go`
- 留在父包 `newagenttools`
- 只做薄 adapter调用 `schedule_read` 子包构造展示数据,再包成 `ToolExecutionResult`
- 继续保证 `ObservationText` 原样给 LLM。
3. `registry.go`
- 只保留注册入口,不放业务逻辑。
如果整理时发现 `schedule_read_result_common.go` 里的 helper 同时被第三批 analysis 使用,再考虑抽更中性的公共包:
```text
backend/services/agent/tools/toolview/
```
但不要提前大抽象;只有 read 和 analysis 都真实复用同一批结构后再抽。
## 第三批计划
第三批建议处理 schedule 诊断分析域:
1. `analyze_health`
2. `analyze_rhythm`
建议新增:
```text
backend/services/agent/tools/schedule_analysis/
```
建议新增 view type
```text
schedule.analysis_result
```
外层协议继续不变:
```json
{
"result_view": {
"view_type": "schedule.analysis_result",
"version": 1,
"collapsed": {},
"expanded": {}
}
}
```
`schedule.analysis_result` 仍复用通用卡片结构,但语义上区别于 read
1. `collapsed.title`
- 例:`综合体检:建议继续微调``学习节律分析`
2. `collapsed.subtitle`
- 例:主问题、裁决摘要、风险摘要。
3. `collapsed.metrics`
- 例:高认知相邻天数、可局部移动任务数、推荐动作。
4. `expanded.sections`
- `裁决结论`
- `关键指标`
- `问题清单`
- `候选操作`
- `建议后续动作`
5. `expanded.items`
- 候选动作或风险日列表。
6. `expanded.machine_payload`
- 原始 JSON、候选动作参数、required_reads 等机器字段,只给调试/交互。
第三批不要和 read 整理同时改同一个公共 helper 文件。推荐顺序:
1. 先完成 read 整理,确定子包边界。
2. 再做 `schedule_analysis` 子包。
3. 最后只在父包 adapter 和 `registry.go` 接入 `analyze_health/analyze_rhythm`
## 后续批次
当前文档计划内的四批结构化结果已经完成。默认注册表里主链工具已经不再通过 `wrapLegacyToolHandler` 接入。
当前稳定 view type
1. `schedule.operation_result`
2. `schedule.read_result`
3. `schedule.analysis_result`
4. `web.search_result`
5. `web.fetch_result`
6. `taskclass.write_result`
7. `tool.context_result`
8. `legacy_text`
`legacy_text` 仍作为未知工具、兜底结果或未来新增工具的保底协议保留,不建议删除。
若后续继续整理,建议只做两类小收尾:
1. 前端补齐 `web.search_result``web.fetch_result``taskclass.write_result``tool.context_result` 的专项 mock 与视觉验收。
2. 评估是否把各子包重复的 `kv/items/callout` helper 下沉到中性公共包,例如 `backend/services/agent/tools/toolview/`;只有在确认 read、analysis、web、taskclass、context 都稳定后再抽,避免提前扩大回归面。
## 前端补丁提示
后端当前已经输出多种结构化 `result_view`。前端最低要求:
1. 头部继续优先读:
- `result_view.collapsed.title`
- `result_view.collapsed.subtitle`
- `result_view.collapsed.status_label`
- `result_view.collapsed.metrics`
2. 展开态新增通用 sections/items renderer
- 支持 `expanded.items`
- 支持 `expanded.sections`
- section 类型至少兼容 `items``kv``callout`
- `callout` 需要兼容 `subtitle``summary`
3. 默认不展示:
- `expanded.machine_payload`
- `items[].meta`
- 原始英文 key/value
4. `raw_text` 只放 debug 折叠区。
前端需要至少识别以下新协议:
1. `schedule.read_result`
2. `schedule.analysis_result`
3. `web.search_result`
4. `web.fetch_result`
5. `taskclass.write_result`
6. `tool.context_result`
前端如果暂时不识别某个专属 view type至少要显示折叠态但展开态退回 raw text 不符合 C 端目标。
## 验收清单
后续代理继续处理前必须先确认:
1. 不回滚、覆盖、删除用户或其它代理的工作区改动。
2. 不碰前端,除非用户明确要求。
3. `ObservationText` 不能被展示层改写。
4. 工具结果必须继续通过 `ToolExecutionResult -> SSE extra -> timeline payload` 传递。
5. 每次跑 `go test` 后必须删除根目录 `.gocache`
6. 如果新增临时 `*_test.go`,跑完测试后必须删除。
7. 结构迁移最终答复要说明:迁了什么、旧实现保留什么、切流点在哪里、下一轮建议迁什么。