Version: 0.8.3.dev.260328

后端:
1.彻底删除原agent文件夹,并将现agent2文件夹全量重命名为agent(包括全部涉及到的文件以及文档、注释),迁移工作完美结束
2.修复了重试消息的相关逻辑问题

前端:
1.改善了一些交互体验,修复了一些bug,现在只剩少的功能了,现存的bug基本都修复完毕

全仓库:
1.更新了决策记录和README文档
This commit is contained in:
Losita
2026-03-28 18:00:31 +08:00
parent 5fc9548420
commit 468367d617
108 changed files with 1910 additions and 17173 deletions

View File

@@ -1,4 +1,4 @@
# AGENTS.md
# AGENTS.md
## 协作偏好(逐条追加)
@@ -13,7 +13,7 @@
9. 对于明显过大的文件(尤其是同时承载编排、业务、模型交互、工具分发的文件),后续重构时必须拆分职责,禁止继续向单文件堆砌新逻辑。
10. Prompt、State、模型交互、Graph 连线应尽量分目录/分文件管理,禁止把大段 prompt、节点逻辑、模型 helper 长期混写在同一文件中。
11. 若本轮任务包含“结构迁移”,最终答复中必须明确说明:本轮迁了什么、哪些旧实现仍保留、当前切流点在哪里、下一轮建议迁什么。
12. 若后续在 `backend/agent2` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent2/通用能力接入文档.md`,否则视为重构信息不完整。
12. 若后续在 `backend/agent` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent/通用能力接入文档.md`,否则视为重构信息不完整。
13. 跑完单元测试后必须删除单元测试的test.go文件禁止把测试文件长期留在项目中。
## 注释规范(强制)
@@ -45,3 +45,4 @@
- `// 查询缓存`
- `// 调用 DAO`
- `// 返回结果`

260
README.md
View File

@@ -1,4 +1,4 @@
# 1 项目概览
# 1 项目概览
## 1.1 总体介绍
@@ -359,7 +359,7 @@ flowchart TD
A["/api/v1/agent/chat<br/>解析请求体 + 规范 conversation_id<br/>Header 写入 X-Conversation-ID"] --> B["AgentService.AgentChat<br/>创建 outChan / errChan"]
B --> C["规范 chat_id + 选择模型(worker/strategist)"]
C --> D["确保会话存在<br/>先查 Redis 状态<br/>未命中回源 DB + 必要时创建"]
D --> E["模型控制码路由<br/>route.DecideActionRouting<br/>action=chat/quick_note_create/task_query/schedule_plan"]
D --> E["模型控制码路由<br/>route.DecideActionRouting<br/>action=chat/quick_note_create/task_query/schedule_plan_create/schedule_plan_refine"]
E --> F{"RouteFailed?"}
F -- "是" --> G["pushErrNonBlocking(errChan, RouteControlInternalError)<br/>API 侧 SSE 输出 error + [DONE]"]
@@ -381,12 +381,17 @@ flowchart TD
K1 -- "是" --> K2["记录日志 + 发 fallback 阶段块<br/>回退 runNormalChatFlow"]
K1 -- "否" --> K3["emitSingleAssistantCompletion<br/>persistChatAfterReply + 异步标题"]
H -- "schedule_plan" --> L["runSchedulePlanFlow -> SchedulePlanGraph<br/>并写入排程预览缓存"]
H -- "schedule_plan_create" --> L["runSchedulePlanFlow -> SchedulePlanGraph<br/>并写入排程预览缓存"]
L --> L1{"排程链路报错?"}
L1 -- "是" --> L2["记录日志 + 发 fallback 阶段块<br/>回退 runNormalChatFlow"]
L1 -- "否" --> L3["emitSingleAssistantCompletion<br/>persistChatAfterReply + 异步标题"]
H -- "未知 action" --> M["兜底回退 runNormalChatFlow"]
H -- "schedule_plan_refine" --> M["runScheduleRefineFlow -> ScheduleRefineGraph<br/>读取上一版排程预览上下文"]
M --> M1{"连续微调链路报错?"}
M1 -- "是" --> M2["直接上报错误<br/>不回退普通聊天"]
M1 -- "否" --> M3["emitSingleAssistantCompletion<br/>persistChatAfterReply + 异步标题"]
H -- "未知 action" --> N["兜底回退 runNormalChatFlow"]
I2 --> Z["API c.Stream 转发 outChan/errChan<br/>正常收尾或错误收尾"]
J2 --> Z
@@ -395,7 +400,9 @@ flowchart TD
K3 --> Z
L2 --> Z
L3 --> Z
M --> Z
M2 --> Z
M3 --> Z
N --> Z
G --> Z
```
@@ -431,7 +438,7 @@ flowchart TD
```mermaid
flowchart TD
A["用户消息进入 /agent/chat"] --> B["通用控制码分流<br/>action=chat/quick_note_create/task_query/schedule_plan"]
A["用户消息进入 /agent/chat"] --> B["通用控制码分流<br/>action=chat/quick_note_create/task_query/schedule_plan_create/schedule_plan_refine"]
B --> C{"action 是否为 task_query"}
C -- 否 --> D["走其它分支<br/>普通聊天或随口记"]
C -- 是 --> E["进入 TaskQueryGraph"]
@@ -452,11 +459,11 @@ flowchart TD
Q --> R["后置持久化<br/>user+assistant 写 Redis + outbox/DB"]
```
### 4) 命中“智能排程”后的业务流转图
### 4) 命中新建“智能排程”后的业务流转图
```mermaid
flowchart TD
A["命中 action=schedule_plan<br/>发 request.accepted 阶段块"] --> B["runSchedulePlanFlow 入口"]
A["命中 action=schedule_plan_create<br/>发 request.accepted 阶段块"] --> B["runSchedulePlanFlow 入口"]
B --> B1{"依赖齐全?<br/>model + 3个函数注入"}
B1 -- "否" --> B2["返回 error 给上层<br/>上层回退普通聊天"]
B1 -- "是" --> C["清理旧预览缓存<br/>DeleteSchedulePlanPreview<br/>失败仅记日志"]
@@ -498,15 +505,211 @@ flowchart TD
H2 --> Z
```
### 5) 命中“排程连续微调”后的业务流转图
```mermaid
flowchart TD
A["命中 action=schedule_plan_refine<br/>发 request.accepted 阶段块"] --> B["runScheduleRefineFlow 入口"]
B --> C{"selectedModel 非空?"}
C -- "否" --> C1["直接返回错误<br/>不回退普通聊天"]
C -- "是" --> D["loadSchedulePreviewContext<br/>Redis 预览优先 -> miss 回源 MySQL 快照"]
D --> E{"上一版预览存在?"}
E -- "否" --> E1["返回 SchedulePlanPreviewNotFound<br/>直接上报错误"]
E -- "是" --> F["NewScheduleRefineState<br/>注入 HybridEntries / AllocatedItems / CandidatePlans / OriginOrderMap"]
F --> G["RunScheduleRefineGraph"]
G --> H["contract<br/>抽取 intent / strategy / hard_assertions<br/>默认 keep_relative_order=true"]
H --> I["plan<br/>生成 3~4 步执行计划<br/>必要时注入复合工具硬条件"]
I --> J["slice<br/>提取 week/source_days/target_days<br/>编译 objective + workset"]
J --> K["route<br/>命中 SpreadEven / MinContextSwitch 时先走复合路由<br/>首次 + 最多2次重试"]
K --> K1{"CompositeRouteSucceeded?"}
K1 -- "是" --> L["react<br/>检测到已收口,直接 skip"]
K1 -- "否" --> M["react<br/>单任务微步循环<br/>失败后禁复合,只用基础工具"]
L --> N["hard_check<br/>先锁定业务目标<br/>再按需顺序归位 / 一次修复"]
M --> N
N --> O["summary<br/>回填 AllocatedItems + CandidatePlans<br/>Completed 仅由终审是否通过决定"]
O --> P{"shouldPersistScheduleRefinePreview?"}
P -- "是" --> Q["saveSchedulePlanPreview<br/>覆盖 Redis + MySQL 快照"]
P -- "否" --> R["emit schedule_refine.preview.skipped<br/>保留上一版预览基线"]
Q --> S["emitSingleAssistantCompletion<br/>输出 FinalSummary"]
R --> S
S --> T["persistChatAfterReply<br/>统一后置持久化 + 异步标题"]
C1 --> Z["错误直接返回前端"]
E1 --> Z
T --> Z
```
# 6 前端实现
## 6.1 设计策略
## 6.1 当前前端技术栈与工程约定
当前前端位于 `frontend/` 目录,已经落地为一个可独立运行的 Vue 单页应用。
技术栈如下:
## 6.2 组件拆解
| 分类 | 当前选型 | 说明 |
| --- | --- | --- |
| 前端框架 | Vue 3 | 统一使用 Composition API 与 `<script setup>` 编写页面与组件 |
| 构建工具 | Vite 6 | 本地开发、代理联调、生产构建均由 Vite 提供 |
| 语言 | TypeScript | 页面、接口层、类型定义均已类型化 |
| UI 组件库 | Element Plus | 用于表单、输入框、下拉框、消息提示、弹窗等基础交互 |
| 状态管理 | Pinia | 当前主要承载登录态与 token 持久化 |
| 路由 | Vue Router 4 | 已配置鉴权守卫、访客页守卫与页面跳转 |
| HTTP | Axios + 原生 fetch | 常规 JSON 接口走 AxiosAI 对话流式 SSE 走原生 `fetch` |
| Markdown 渲染 | markdown-it + highlight.js | AI 回复正文支持 Markdown 渲染与代码高亮 |
当前前端工程结构如下:
```text
frontend/src
├─ api/ # 接口封装auth / task / schedule / scheduleCenter / agent
├─ components/
│ ├─ dashboard/ # 首页与 AI 面板相关组件
│ └─ schedule/ # 智能排程页相关组件
├─ router/ # 路由定义与前置守卫
├─ stores/ # Pinia store当前主要是 auth
├─ types/ # 页面与接口类型定义
├─ utils/ # 日期、HTTP 错误、Markdown、幂等 key 等工具
└─ views/ # Auth / Dashboard / Assistant / Schedule 四个主视图
```
工程约定如下:
1. 所有业务请求默认走 `/api/v1` 前缀。
2. 本地开发通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8080`
3. 常规接口统一走 `frontend/src/api/http.ts`,内置 `401 -> refresh token -> 原请求重放`
4. 对话流接口 `POST /api/v1/agent/chat` 因为要消费 SSE所以单独用原生 `fetch`
5. 写操作尽量补 `X-Idempotency-Key`,当前任务创建、日程应用、日程删除、任务块删除都已经这样处理。
## 6.2 当前页面与路由状态
当前前端已经接通的页面路由如下:
| 路由 | 页面状态 | 说明 |
| --- | --- | --- |
| `/auth` | 已完成 | 登录/注册同页切换,登录成功后按 `redirect` 返回目标页 |
| `/dashboard` | 已完成 | 首页工作台,展示四象限任务、今日日程、快捷创建任务、左侧导航 |
| `/assistant` | 已完成 | 独立 AI 对话页,复用同一套 AI 面板组件,支持历史会话与流式消息 |
| `/schedule` | 已完成 | 周课表与任务编排中心,支持任务类、粗排、预览、拖拽、应用 |
当前仍处于占位/未独立成页的入口:
1. 左侧导航里的“任务”按钮目前还是占位提示,没有独立路由页。
2. 侧边栏底部“设置”按钮目前也是视觉占位,没有接出 `/settings`
## 6.3 认证页 `/auth`
对应文件:
- `frontend/src/views/AuthView.vue`
- `frontend/src/stores/auth.ts`
- `frontend/src/api/auth.ts`
当前行为:
1. 登录与注册共用一页,通过 tab 切换。
2. 登录成功后会写入 `access_token``refresh_token` 与最近一次登录用户名。
3. 路由守卫会阻止未登录用户进入 `/dashboard``/assistant``/schedule`
4. 登录态失效时Axios 拦截器会自动尝试刷新 token刷新失败则清空本地登录态并跳回登录页。
## 6.4 首页工作台 `/dashboard`
对应文件:
- `frontend/src/views/DashboardView.vue`
- `frontend/src/components/dashboard/TaskQuadrantCard.vue`
- `frontend/src/components/dashboard/TodayTimeline.vue`
当前已实现能力:
1. 左侧导航栏与顶部欢迎区已经落地,首页与 `/assistant` 使用统一的主视觉语言。
2. 中心区展示四象限任务卡片,支持获取任务列表、创建任务、完成任务、撤销完成任务。
3. 右侧展示“今日日程”,通过 `/schedule/today` 拉取当天事件。
4. 首页整体做了缩放适配,目标是在 100% 缩放下尽量完整展示主要内容,而不是依赖用户手动缩放浏览器。
5. 首页右侧已经不再承载完整 AI 对话页AI 对话已收口到独立 `/assistant` 页面。
## 6.5 AI 对话页 `/assistant`
对应文件:
- `frontend/src/views/AssistantView.vue`
- `frontend/src/components/dashboard/AssistantPanel.vue`
- `frontend/src/api/agent.ts`
当前已实现能力:
1. 页面拆成“左侧主导航 + 右侧 AI 面板”两部分,最左侧侧栏样式已与首页统一。
2. AI 面板同时支持嵌入态和独立页态;`/assistant` 使用独立页态。
3. 已接通的对话相关接口包括:
- `POST /api/v1/agent/chat`
- `GET /api/v1/agent/conversation-list`
- `GET /api/v1/agent/conversation-meta`
- `GET /api/v1/agent/conversation-history`
4. 支持流式 SSE 回复,并区分深度思考内容、正文内容,以及刷新后从历史接口恢复的 `reasoning_duration_seconds`
5. 历史消息支持重试分页元数据:`retry_group_id``retry_index``retry_total`
6. 助手消息底部已支持复制、重新生成、版本分页切换。
7. 用户消息底部已支持复制、修改消息;当前“修改消息”的语义是“复制到输入框后重新发送一条新消息”,不会覆盖旧消息。
8. “重新生成”按钮当前的前端策略是:
- 先尝试直接使用当前消息上的持久化 ID
- 若命中的是本地乐观态消息,则先静默调用一次历史接口补抓真实 ID
- 仍然拿不到时,再提示:`消息正在处理,请稍后再重试,或者直接复制消息重新发送`
9. 历史消息与本地乐观态消息会做合并,避免刷新历史时把当前页内正在看的消息直接抹掉。
10. 消息区实现了“自动跟随到底部 / 用户手动上滚后停止跟随”的双态滚动策略。
## 6.6 日程编排页 `/schedule`
对应文件:
- `frontend/src/views/ScheduleView.vue`
- `frontend/src/components/schedule/TaskClassSidebar.vue`
- `frontend/src/components/schedule/WeekPlanningBoard.vue`
- `frontend/src/components/schedule/CreateTaskClassDialog.vue`
- `frontend/src/api/scheduleCenter.ts`
当前已实现能力:
1. 左侧为任务类侧栏,右侧为周课表/排程画板。
2. 任务类侧栏支持获取任务类列表、展开任务类详情、删除单个任务块、新建任务类弹窗、单选与批量多选模式切换。
3. 当任务类较多时,左侧侧栏改为固定卡片高度 + 列表滚动,不再通过压缩卡片高度硬塞。
4. 单个任务类展开后,其内部任务块列表也支持独立滚动,避免详情直接溢出容器。
5. 周课表支持周次切换,当前前端限制为 `1 ~ 24` 周,不允许继续越界请求。
6. 已接通的课表/编排相关接口包括:
- `GET /api/v1/schedule/week`
- `GET /api/v1/task-class/list`
- `GET /api/v1/task-class/get`
- `POST /api/v1/task-class/add`
- `DELETE /api/v1/task-class/delete-item`
- `GET /api/v1/schedule/smart-planning`
- `POST /api/v1/schedule/smart-planning-multi`
- `PUT /api/v1/task-class/apply-batch-into-schedule`
- `DELETE /api/v1/schedule/delete`
7. 智能编排结果当前分为单任务类粗排和多任务类批量粗排。
8. 预览态结果不会立刻写入正式课表,而是先保存在前端运行时内存中;用户确认后再应用到后端。
9. 预览态结果的生命周期是“当前单页应用存活期间”,刷新页面会丢失,因此页面已挂载 `beforeunload` 原生拦截提示。
10. 已请求过的周课表会缓存在前端内存中,当前页切换周次时优先复用缓存,避免反复打后端。
11. 周请求增加了序列号保护,快速切周时只认最后一次请求结果,用来降低闪动。
12. 预览态 `suggested` 任务支持拖拽调整位置,并且拖拽会同步修改前端持有的预览 JSON保证“用户看到的布局”和“最终提交给后端的布局”一致。
13. 对于嵌入课程中的预览任务,只有拖到嵌入任务本身时才允许把它单独拖出来,不会整块课程卡一起被拖动。
14. 预览态支持批量应用;如果是多任务类批量粗排,前端会先把预览结果按任务类分桶,再逐桶调用现有应用接口。
## 6.7 当前前后端衔接边界
当前前端已经覆盖的主业务链路:
1. 登录 / 注册 / 自动续签
2. 首页任务获取、创建、完成、撤销
3. 今日日程展示
4. AI 对话、历史会话、深度思考展示、重新生成、消息复制、消息修改
5. 任务类管理、智能粗排、批量粗排、预览拖拽、正式应用、删除日程
当前仍明确留给后续迭代的部分:
1. “任务”独立页面与“设置”独立页面尚未接出。
2. 课表导入流程入口已在首页预留,但还没有完整的导入页与导入向导。
3. 用户消息“修改后原地提交并替换旧消息”的真正后端语义尚未实现,目前按“发送一条新消息”处理。
4. 更多 AI 工具态页面(例如结构化预览页、设置面板、统计页)尚未独立拆页。
# 7 部署与监控
@@ -520,5 +723,42 @@ flowchart TD
# 8 快速开始
## 8.1 启动前端开发环境
前端目录在 `frontend/`,本地开发步骤如下:
```bash
cd frontend
npm install
npm run dev
```
默认启动信息:
1. Vite 开发端口:`5173`
2. 开发代理目标:`http://127.0.0.1:8080`
3. 因此前端本地联调前,需要先确保后端服务已经启动在 `8080`
## 8.2 前端生产构建
```bash
cd frontend
npm run build
npm run preview
```
说明:
1. `npm run build` 会先执行 `vue-tsc -b` 做类型检查,再执行 `vite build`
2. 当前构建是可通过的但由于主包仍然偏大Vite 会给出 chunk size warning这属于现阶段可接受状态。
## 8.3 建议的前后端联调顺序
建议按下面顺序启动和验证:
1. 启动后端服务,确认 `http://127.0.0.1:8080` 可用。
2. 启动前端 `npm run dev`
3. 先验证 `/auth` 的登录注册链路。
4. 再验证 `/dashboard` 的任务与今日日程。
5. 再验证 `/assistant` 的 SSE 对话、历史消息、重试分页与深度思考展示。
6. 最后验证 `/schedule` 的任务类、周课表、智能粗排、拖拽预览与正式应用。

View File

@@ -1,388 +0,0 @@
# agent2 逐批搬迁实施细节
## 1. 文档目的
- 本文档用于指导 `backend/agent -> backend/agent2` 的渐进式重构。
- 目标是“在逻辑不变的前提下重整结构、消除冗余、逐步切流”,而不是一次性重写。
- 该文档优先服务于施工过程,强调“怎么搬、先搬什么、如何验证、何时删除旧代码”。
## 2. 总体迁移原则
### 2.1 不直接改老目录,先并行维护
- 保持现有 `backend/agent` 不动,作为稳定对照组。
- 新建 `backend/agent2`,所有重构都先落在新目录。
- `backend/service/agentsvc` 在迁移期充当“总开关层”,决定某条链路接旧目录还是新目录。
### 2.2 先搬结构,再收冗余
- 第一优先级不是“顺手优化业务逻辑”,而是把职责边界搬顺。
- 迁移时允许短期存在“代码原样搬过去”的情况,但必须同步记录哪些地方后续要抽公共层。
- 严禁一边大改业务逻辑、一边改目录结构,否则回归问题无法定位。
### 2.3 新旧链路必须可对照
- 每搬完一个 skill都要保证
- 老链路还能跑;
- 新链路能独立接入;
- service 层可以随时切回旧实现。
- 迁移期间所有新入口都必须保留 feature flag 或切换点。
### 2.4 先做“能收敛冗余”的迁移
- 优先搬那些已经出现明显重复代码的能力,不要先搬边缘功能。
- 迁移不是“把史山平移到新目录”,而是“借搬迁之机把重复公共件抽出来”。
---
## 3. agent2 目标结构
```text
backend/agent2/
entrance.go
router/
route.go
route_model.go
graph/
quicknote.go
taskquery.go
schedule.go
node/
quicknote.go
taskquery.go
schedule_plan.go
schedule_refine.go
llm/
client.go
json.go
route.go
quicknote.go
taskquery.go
schedule.go
model/
common.go
route.go
quicknote.go
taskquery.go
schedule.go
prompt/
route.go
quicknote.go
taskquery.go
schedule.go
stream/
emitter.go
openai.go
shared/
clone.go
retry.go
time.go
```
## 4. 各层职责约束
### 4.1 `entrance.go`
- 是整个 `agent2` 模块唯一总入口。
- 负责:
- 接收请求上下文;
- 调路由;
- 调 skill graph
- 收口 token、SSE、持久化。
- 不负责:
- 某个 skill 内部节点的业务实现;
- 直接写 prompt
- 直接调用模型。
### 4.2 `router/`
- 只负责一级分流。
- 输出统一路由结果,例如:
- `chat`
- `quick_note_create`
- `task_query`
- `schedule_plan_create`
- `schedule_plan_refine`
- 不负责 skill 内部二次判断。
### 4.3 `graph/`
- 只负责画 graph。
- 文件里应尽量只出现:
- `AddLambdaNode`
- `AddEdge`
- `AddBranch`
- 不允许在 `graph/` 内直接写复杂业务逻辑、模型调用或数据转换。
### 4.4 `node/`
- 只负责每个节点内部的业务逻辑。
- 可以调用:
- `llm/`
- `shared/`
- service/dao 注入依赖
- 不负责:
- Graph 连线;
- OpenAI chunk 输出格式;
- 大段 prompt 常量定义。
### 4.5 `llm/`
- 专门负责和模型交互。
- 统一收口:
- `GenerateText`
- `GenerateJSON`
- `Stream`
- JSON 提取与解析
- thinking/temperature/maxTokens 等参数
- 这一层的定位,等价于“模型数据访问层”。
### 4.6 `model/`
- 存放 agent 域模型,而不是数据库模型。
- 包括:
- state
- dto
- route decision
- tool result
### 4.7 `prompt/`
- 统一收口 prompt 文本。
- 禁止在 `node/``llm/` 中内联大段 prompt。
### 4.8 `stream/`
- 统一收口 SSE/OpenAI 兼容块输出。
- 所有阶段推送、单条 assistant 回复、finish chunk 都从这里走。
### 4.9 `shared/`
- 只放纯工具,不放业务编排。
- 当前预计优先放:
- 深拷贝
- 重试策略
- 时间解析
---
## 5. 逐批搬迁顺序
## 第 0 批:先搭骨架,不接流量
### 目标
- 先把 `agent2` 目录骨架建起来。
- 不迁任何业务,只搭结构。
### 产物
- `backend/agent2/` 目录
- `entrance.go`
- `router/graph/node/llm/model/prompt/stream/shared` 空壳
### 验证
- 能编译通过。
- 不接任何线上/实际业务入口。
---
## 第 1 批:先迁公共能力,不迁 skill
### 目标
- 先抽最明显的公共重复件,给后续 skill 迁移打基础。
### 优先抽取的公共件
- `llm/json.go`
- 统一 `GenerateJSON`
- 统一 JSON object 提取
- 统一空响应/解析失败错误
- `llm/client.go`
- 统一 `GenerateText`
- 统一 thinking/maxTokens/temperature 参数装配
- `stream/openai.go`
- 迁入现有 `ToOpenAIStream`
- 迁入 `ToOpenAIFinishStream`
- `stream/emitter.go`
- 统一阶段推送
- 统一单条 assistant completion 输出
- `shared/clone.go`
-`cloneWeekSchedules`
-`cloneHybridEntries`
-`cloneTaskClassItems`
### 说明
- 这一批不碰 quicknote/taskquery/schedule 的业务语义。
- 只把公共重复代码先搬进 `agent2`
---
## 第 2 批:迁 `quicknote`,作为样板 skill
### 目标
- 用最小 skill 验证三层结构是否顺手。
### 对应关系
- 旧:
- `backend/agent/quicknote/graph.go`
- `backend/agent/quicknote/nodes.go`
- `backend/agent/quicknote/tool.go`
- `backend/agent/quicknote/state.go`
- `backend/agent/quicknote/prompt.go`
- 新:
- `backend/agent2/graph/quicknote.go`
- `backend/agent2/node/quicknote.go`
- `backend/agent2/llm/quicknote.go`
- `backend/agent2/model/quicknote.go`
- `backend/agent2/prompt/quicknote.go`
### 迁移要求
- 逻辑不变。
- 原有测试优先复用。
- 如果出现可直接删除的冗余函数,必须当场删除,不允许“先复制一份以后再说”。
### 接入方式
-`agentsvc` 里增加切换开关:
- `run quicknote with agent`
- `run quicknote with agent2`
---
## 第 3 批:迁 `taskquery`
### 目标
- 用第二个 skill 验证公共层抽取是否真的通用,而不是只服务于 quicknote。
### 核心检查点
- `taskquery` 是否能完全复用第 1 批抽出的 `llm/json.go`
- fallback/retry 是否还能保持一致。
- 阶段推送是否已经不需要 skill 自己再包一层。
### 完成标准
- `taskquery` 不再保留自己私有的 JSON 调用封装。
---
## 第 4 批:迁 `route`
### 目标
- 把统一分流也纳入 `agent2`
### 要求
- `entrance.go` 以后只依赖 `agent2/router`
- `router` 只做一级分流,不再夹杂 skill 级 fallback。
---
## 第 5 批:迁 `scheduleplan`
### 目标
- 把排程首次创建链路迁入新结构。
### 迁移策略
- 不直接优化复杂业务。
- 先把它拆成:
- `graph/schedule.go`
- `node/schedule_plan.go`
- `llm/schedule.go`
- `model/schedule.go`
- `prompt/schedule.go`
### 强制要求
- 不允许继续保留“节点文件里夹杂模型调用帮助函数”的写法。
- 不允许在 `node/` 里再内联 JSON parse helper。
---
## 第 6 批:迁 `schedulerefine`
### 目标
- 处理当前最重的史山。
### 原则
- 这一步不是“原样平移”。
- 这是第一次允许在迁移中顺手拆大文件。
### 最低拆分要求
-`nodes.go` 至少拆成:
- `schedule_refine_contract`
- `schedule_refine_plan`
- `schedule_refine_react`
- `schedule_refine_review`
- `schedule_refine_summary`
-`tool.go` 至少拆成:
- `tool_defs`
- `tool_dispatch`
- `tool_payload`
- `tool_policy`
---
## 第 7 批:切流与删除旧目录
### 前提条件
- `quicknote`
- `taskquery`
- `route`
- `scheduleplan`
- `schedulerefine`
以上链路都已经切到 `agent2`,并经过回归验证。
### 操作顺序
- 1. service 层入口全部切到 `agent2`
- 2. 跑一轮完整回归
- 3. 删除旧 `backend/agent`
- 4. `backend/agent2` 改名为 `backend/agent`
### 注意
- 第 4 步必须最后做。
- 在此之前不要提前重命名,避免 review 和 diff 变得混乱。
---
## 6. 迁移过程中的强约束
### 6.1 逻辑不变优先于结构完美
- 只要旧逻辑还能清晰搬进新层次,就先搬。
- 不要在同一轮同时追求“换架构 + 换策略 + 换 prompt”。
### 6.2 每次只迁一个能力域
- 一次只动一个 skill 或一类公共件。
- 严禁“这一轮顺手把三个 skill 一起改了”。
### 6.3 旧代码不删前,新代码必须已接入验证
- 先搬
- 再切
- 再验
- 最后删
### 6.4 公共能力一旦发现第二处重复,就必须考虑抽层
- 不允许在 `agent2` 里继续复制第三份、第四份。
- `agent2` 的目标不是“新目录复刻旧史山”,而是“迁移过程中顺手收口公共能力”。
---
## 7. 每一批迁移完成后的检查项
- 是否出现了新的重复模型调用 helper
- 是否出现了新的重复 JSON parse helper
- 是否又把 prompt 写回节点文件里了?
- 是否又在 service 层偷偷加了一层业务桥接?
- 是否还能一眼看出:
- 入口在哪
- 分流在哪
- 编排在哪
- 节点逻辑在哪
- 模型交互在哪
---
## 8. 当前最值得优先搬的公共件
- `GenerateJSON/GenerateText`
- JSON 提取与解析
- OpenAI 兼容 SSE 包装
- 阶段推送 emitter
- schedule 相关深拷贝工具
- schedule preview / snapshot 读写逻辑
---
## 9. 一句话版本
- 先建 `agent2` 骨架。
- 先抽公共层,再迁 skill。
- 先迁 `quicknote` 做样板,再迁 `taskquery` 验证复用,再动 `schedule`
- 迁移期间新旧并存、随时可切回。
- 删除旧 `agent` 只能发生在所有 skill 全部切流并验证通过之后。

View File

@@ -1,448 +0,0 @@
# Agent 代码复用清单(施工备忘)
## 1. 文档目的
- 这份文档只做一件事:记录当前 `backend/agent``backend/service/agentsvc` 中“明明可以复用、却被重复实现”的代码点。
- 目标不是立刻改代码,而是防止后续压缩上下文后忘记哪些地方最值得先收口。
- 这里优先关注“公共能力重复实现”,不讨论具体业务对错。
## 2. 当前最明显的重复结论
- 重复最严重的不是某一个 skill而是“每个 skill 都在各自实现一套模型调用、JSON 解析、阶段推送、兜底、深拷贝、缓存快照读写、工具分发”。
- `quicknote``taskquery``scheduleplan``schedulerefine` 已经形成了“四套相似但不统一的 agent 基建”。
- `service/agentsvc` 里也开始出现重复胶水层逻辑,尤其是排程预览和连续微调相关读写。
---
## 3. 高优先级复用点
## 3.1 模型调用封装重复
### 现状
- `quicknote` 自己实现了 `callModelForJSON` / `callModelForJSONWithMaxTokens`
- `taskquery` 自己实现了 `callTaskQueryModelForJSON`
- `scheduleplan` 自己实现了 `callScheduleModelForJSON`
- `schedulerefine` 自己实现了 `callModelText`,而且附带了一整套 JSON contract 变体。
### 证据
- [quicknote/nodes.go](E:/SmartFlow-Agent/backend/agent/quicknote/nodes.go)
- [taskquery/nodes.go](E:/SmartFlow-Agent/backend/agent/taskquery/nodes.go)
- [scheduleplan/nodes.go](E:/SmartFlow-Agent/backend/agent/scheduleplan/nodes.go)
- [schedulerefine/nodes.go](E:/SmartFlow-Agent/backend/agent/schedulerefine/nodes.go)
### 问题
- 都在做同一类事情:
-`system + user` 消息
- 关闭/开启 thinking
- 设置 `temperature/maxTokens`
-`Generate`
- 判空响应
- 返回文本
- 现在每个模块一套,导致:
- 参数风格不统一
- 错误文案不统一
- token 统计与 callback 复用也难进一步规范
### 建议抽取
- 抽到统一的 `agent/core/modelx` 或类似目录。
- 至少统一三个入口:
- `GenerateText`
- `GenerateJSON`
- `GenerateJSONWithContract`
### 优先级
- `P0`
---
## 3.2 JSON 解析与对象提取重复
### 现状
- `quicknote` 自己实现了 `parseJSONPayload` + `extractJSONObject`
- `taskquery` 有自己的 `parseTaskQueryJSON`
- `scheduleplan` 有自己的 `parseScheduleJSON`
- `schedulerefine` 有自己的 `parseJSON`,并且带重试解析逻辑。
### 证据
- [quicknote/nodes.go](E:/SmartFlow-Agent/backend/agent/quicknote/nodes.go)
- [taskquery/nodes.go](E:/SmartFlow-Agent/backend/agent/taskquery/nodes.go)
- [scheduleplan/nodes.go](E:/SmartFlow-Agent/backend/agent/scheduleplan/nodes.go)
- [schedulerefine/nodes.go](E:/SmartFlow-Agent/backend/agent/schedulerefine/nodes.go)
### 问题
- 都在解决“模型返回不是纯 JSON需要做容错提取”。
- 解析失败后的兜底策略也高度相似,只是文案不同。
### 建议抽取
- 抽统一的 `agent/core/jsonx`
- `ParseJSONObject[T]`
- `ExtractJSONObject`
- `ParseWithRetryHint`(后续可选)
### 优先级
- `P0`
---
## 3.3 阶段推送EmitStage重复
### 现状
- `quicknote/graph.go` 自己封装 `EmitStage` 判空。
- `taskquery/graph.go` 自己写 `runner.emit`
- `scheduleplan/graph.go` 自己包一层 `emitStage`
- `schedulerefine/graph.go` 也自带一套。
### 证据
- [quicknote/graph.go](E:/SmartFlow-Agent/backend/agent/quicknote/graph.go)
- [taskquery/graph.go](E:/SmartFlow-Agent/backend/agent/taskquery/graph.go)
- [scheduleplan/graph.go](E:/SmartFlow-Agent/backend/agent/scheduleplan/graph.go)
- [schedulerefine/graph.go](E:/SmartFlow-Agent/backend/agent/schedulerefine/graph.go)
### 问题
- 所有 graph 都在做相同事情:封装一个 `func(stage, detail string)`,把 nil 判断藏起来。
- 这一层目前很轻,但 skill 一多就会持续复制。
### 建议抽取
- 抽统一的 `agent/core/progress`
- `type Emitter func(stage, detail string)`
- `func WrapEmitter(fn func(string, string)) Emitter`
- `func NoopEmitter() Emitter`
### 优先级
- `P1`
---
## 3.4 OpenAI 兼容流式包装能力重复调用方式不统一
### 现状
- 统一实现其实已经有了,在 [chat/stream.go](E:/SmartFlow-Agent/backend/agent/chat/stream.go) 里:
- `ToOpenAIStream`
- `ToOpenAIFinishStream`
-`agentsvc` 侧又额外自己实现了“阶段推送器”和“单条 assistant completion 包装”。
### 证据
- [chat/stream.go](E:/SmartFlow-Agent/backend/agent/chat/stream.go)
- [agent_quick_note.go](E:/SmartFlow-Agent/backend/service/agentsvc/agent_quick_note.go)
### 问题
- 现在虽然底层函数复用了,但“如何构造阶段消息 / 一次性正文 / finish chunk”的协议层仍散落在 service 层。
- 后续 schedule、memory、websearch 也大概率继续复制。
### 建议抽取
- 抽统一的 `agent/core/streamx`
- `EmitReasoningStage`
- `EmitSingleAssistantReply`
- `EmitErrorChunk`
### 优先级
- `P1`
---
## 3.5 深拷贝函数重复
### 现状
- `agentsvc/agent_schedule_preview.go` 有:
- `cloneWeekSchedules`
- `cloneHybridEntries`
- `cloneTaskClassItems`
- `schedulerefine/state.go` 又复制了一份同类深拷贝函数。
- `scheduleplan` 侧也多处依赖同样的拷贝语义。
### 证据
- [agent_schedule_preview.go](E:/SmartFlow-Agent/backend/service/agentsvc/agent_schedule_preview.go)
- [schedulerefine/state.go](E:/SmartFlow-Agent/backend/agent/schedulerefine/state.go)
### 问题
- 这是非常典型的“纯工具函数”重复实现。
- 后面只要结构体字段一变,就容易出现一边更新一边漏更。
### 建议抽取
- 抽统一的 `agent/core/clone``agent/core/snapshot`
### 优先级
- `P0`
---
## 3.6 预览快照读取/回填逻辑重复
### 现状
- `runSchedulePlanFlow` 自己在做:
- 查 Redis 预览
- miss 后查 MySQL snapshot
- 回填 Redis
- 清旧 preview
- `runScheduleRefineFlow` 又通过 `loadSchedulePreviewContext` 再做一套类似的逻辑。
- `GetSchedulePlanPreview` 也重复做:
- 查 Redis
- miss 查 MySQL
- 回填 Redis
### 证据
- [agent_schedule_plan.go](E:/SmartFlow-Agent/backend/service/agentsvc/agent_schedule_plan.go)
- [agent_schedule_refine.go](E:/SmartFlow-Agent/backend/service/agentsvc/agent_schedule_refine.go)
- [agent_schedule_preview.go](E:/SmartFlow-Agent/backend/service/agentsvc/agent_schedule_preview.go)
### 问题
- 这是同一个“排程预览仓储语义”,却被 service 层复制成了三种读法。
- 长期会导致缓存行为不一致。
### 建议抽取
- 抽成统一 `schedule preview repository/gateway`
- `LoadPreviewContext`
- `SavePreviewContext`
- `DeletePreviewContext`
- `LoadPreviewForRead`
### 优先级
- `P0`
---
## 3.7 fallback 文案与早退逻辑重复
### 现状
- `quicknote` 有自己的 fallback reply / persisted 判定。
- `taskquery``buildTaskQueryFallbackReply`
- `scheduleplan``schedulerefine` 里也散落大量“解析失败/模型失败/回退继续”的逻辑。
### 证据
- [quicknote/nodes.go](E:/SmartFlow-Agent/backend/agent/quicknote/nodes.go)
- [taskquery/nodes.go](E:/SmartFlow-Agent/backend/agent/taskquery/nodes.go)
- [scheduleplan/nodes.go](E:/SmartFlow-Agent/backend/agent/scheduleplan/nodes.go)
- [schedulerefine/nodes.go](E:/SmartFlow-Agent/backend/agent/schedulerefine/nodes.go)
### 问题
- 不是所有 fallback 文案都能共用,但 fallback 模式本身是共性的:
- 模型失败 -> 本地兜底
- JSON 失败 -> 本地兜底
- 工具失败 -> 重试 / 降级
### 建议抽取
- 不建议直接抽“文案”。
- 但建议抽“失败策略框架”:
- `FallbackPolicy`
- `RetryOrFallback`
- `ParseOrFallback`
### 优先级
- `P1`
---
## 3.8 时间解析能力重复 / 分散
### 现状
- `quicknote/tool.go` 内有一整套时间解析:
- 相对时间
- 中文时间
- deadline hint
- 默认时刻
- 排程链路后续也非常可能要继续用类似时间语义。
### 证据
- [quicknote/tool.go](E:/SmartFlow-Agent/backend/agent/quicknote/tool.go)
### 问题
- 目前还没出现“多份复制”,但这是非常明确的“已形成公共能力、却还放在 skill 私有目录”的情况。
- 如果不提前抽schedule / memory / websearch 接进来后一定复制。
### 建议抽取
- 提前迁出到 `agent/core/timeparse`
### 优先级
- `P1`
---
## 3.9 runner 形态重复
### 现状
- `quicknote/runner.go`
- `scheduleplan/runner.go`
- `schedulerefine/runner.go`
### 问题
- 本质都在做同一件事:把 graph 输入依赖注入到每个步骤函数。
- 结构相似,但没有统一约定。
### 建议抽取
- 不一定要抽通用基类。
- 但至少要统一 runner 模板风格,减少 skill 间阅读切换成本。
### 优先级
- `P2`
---
## 3.10 route 控制码能力与 skill 判定方式分裂
### 现状
- 统一路由已经在 [route/route.go](E:/SmartFlow-Agent/backend/agent/route/route.go)。
- 但 skill 内部仍有不少二次意图确认 / fallback 判定。
### 问题
- 这不是完全重复代码问题,而是“判定责任分裂”。
- 结果是:
- route 做一遍
- skill 首节点再做一遍
- service 再决定回不回聊天
### 建议抽取
- 统一成:
- `route` 只做一级分流
- skill 只做 skill 内业务判定
- service 不再写 skill 特有 fallback 判定
### 优先级
- `P1`
---
## 4. 中优先级复用点
## 4.1 Extra 参数解析工具只在 scheduleplan 私有
### 现状
- `scheduleplan/tool.go``ExtraInt``ExtraIntSlice`
### 问题
- 这明显属于通用 agent 请求参数解析能力,不该锁死在某个 skill 下。
### 建议抽取
- 抽到 `agent/core/extrax`
### 优先级
- `P2`
---
## 4.2 工具分发 dispatch 逻辑重复
### 现状
- `scheduleplan/tools_react.go``dispatchReactTool``dispatchWeeklySingleActionTool`
- `scheduleplan/daily_refine.go``dispatchDailyReactTool`
- `schedulerefine/tool.go``dispatchRefineTool`
### 问题
- 都在做“执行工具调用 -> 应用结果 -> 返回标准结果”的模式。
- 虽然业务不同,但骨架高度类似。
### 建议抽取
- 抽公共的 `tool dispatcher` 框架,业务只实现 apply。
### 优先级
- `P2`
---
## 4.3 preview/snapshot DTO 映射重复
### 现状
- `snapshotToSchedulePlanPreviewCache`
- `snapshotToSchedulePlanPreviewResponse`
- `buildSchedulePlanSnapshotFromState`
- `convertRefineStateToPlanState`
### 问题
- 同一批结构在 service 层到处转来转去。
- 说明“排程预览状态”还没有独立成一个稳定模型层。
### 建议抽取
-`schedule snapshot mapper`
### 优先级
- `P2`
---
## 5. 已经出现“文件承担过多职责”的区域
### 5.1 `schedulerefine/nodes.go`
- 3140 行。
- 当前同时承担:
- 模型调用
- JSON 解析
- 规则归一化
- planner
- react loop
- hard check
- summary
- 这是最先需要拆的文件。
### 5.2 `schedulerefine/tool.go`
- 1768 行。
- 当前同时承担:
- tool 定义
- tool 分发
- payload 解析
- policy
- fallback query
- slot hint 构造
- 应拆成:
- `tool_defs`
- `tool_dispatch`
- `tool_payload`
- `tool_policy`
### 5.3 `scheduleplan/nodes.go`
- 713 行。
- 当前已经开始混:
- plan
- rough build
- preview return
- model call helper
- 现在还来得及拆。
---
## 6. 建议的第一批收口顺序
### 第一批(先抽公共层,不碰业务语义)
- `P0-1` 抽模型调用公共层。
- `P0-2` 抽 JSON 解析公共层。
- `P0-3` 抽深拷贝公共层。
- `P0-4` 抽排程预览加载/保存 gateway。
### 第二批(统一 skill 编排骨架)
- `P1-1` 统一 `EmitStage`
- `P1-2` 统一流式阶段输出辅助。
- `P1-3` 统一 fallback/retry 框架。
### 第三批(拆超大文件)
- `P2-1``schedulerefine/nodes.go`
- `P2-2``schedulerefine/tool.go`
- `P2-3``scheduleplan/nodes.go`
---
## 7. 可以考虑直接删除/合并的嫌疑区域
### 7.1 `scheduleplan` 与 `schedulerefine` 的边界可能切得过细
- 这两个包共享大量状态与工具语义。
- 后续大概率应该合并成一个 `schedule` 能力域,内部再区分 `create/refine` flow。
### 7.2 `agentsvc` 侧的排程桥接文件可能过多
- 当前已有:
- [agent_schedule_plan.go](E:/SmartFlow-Agent/backend/service/agentsvc/agent_schedule_plan.go)
- [agent_schedule_refine.go](E:/SmartFlow-Agent/backend/service/agentsvc/agent_schedule_refine.go)
- [agent_schedule_preview.go](E:/SmartFlow-Agent/backend/service/agentsvc/agent_schedule_preview.go)
- 这些文件后续应下沉部分逻辑到 gateway / repository / snapshot service。
---
## 8. 后续重构时的硬约束
- 先抽公共能力,再动 skill 业务逻辑,避免一边搬家一边改语义导致回归难查。
- 每抽一类公共件,都优先让 `quicknote``taskquery` 先接一次,确认不是只为 `schedule` 定制。
- 抽公共层时,不要先追求“最优抽象”,先追求“把四份重复收成一份”。
## 9. 一句话版本
- 当前 agent 代码最该先收口的 4 个点是:
- 模型调用
- JSON 解析
- 深拷贝/快照
- 排程预览缓存与快照读写
- 当前最该先拆的 2 个大文件是:
- [nodes.go](E:/SmartFlow-Agent/backend/agent/schedulerefine/nodes.go)
- [tool.go](E:/SmartFlow-Agent/backend/agent/schedulerefine/tool.go)

View File

@@ -1,23 +0,0 @@
# backend/agent 目录说明
该目录已按“路由 / 聊天 / 随口记”三层拆分,便于阅读、调试与扩展:
1. `route/`
- `route.go`:只负责模型控制码分流(`quick_note` / `chat`)。
- 提供控制码解析、nonce 校验、路由兜底,不参与写库与回复拼装。
2. `chat/`
- `stream.go`普通聊天流式输出封装SSE/OpenAI 兼容 chunk 转换)。
- `prompt.go`:聊天主系统提示词。
3. `quicknote/`
- `graph.go`:只负责图编排连线与分支,不承载节点内部实现。
- `nodes.go`:节点实现(意图识别、优先级评估、持久化、分支选择)。
- `tool.go`工具定义、参数校验、deadline 解析、写库工具打包。
- `state.go`:随口记状态容器与重试状态记录。
- `prompt.go`:随口记提示词(控制码路由、聚合规划、优先级评估、回复润色)。
4. `README.md`(当前文件)
- 记录目录职责边界,帮助后续继续按同样范式扩展 `query/update` 等技能链路。
> 说明:服务层仍通过 `RunQuickNoteGraph` 调用随口记图;若判定为非随口记意图,会自动回落到普通流式聊天链路。

View File

@@ -1,4 +1,4 @@
package chat
package agentchat
const (
// SystemPrompt 全局系统人设:定义 SmartFlow 的基本调性

View File

@@ -1,93 +1,19 @@
package chat
package agentchat
import (
"context"
"encoding/json"
"io"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentstream "github.com/LoveLosita/smartflow/backend/agent/stream"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// StreamResponse 是 OpenAI/DeepSeek 兼容的流式 chunk 结构。
type StreamResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []StreamChoice `json:"choices"`
}
type StreamChoice struct {
Index int `json:"index"`
Delta StreamDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type StreamDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
// ToOpenAIStream 将单个 Eino chunk 转为 OpenAI 兼容 JSON。
func ToOpenAIStream(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool) (string, error) {
delta := StreamDelta{}
if includeRole {
delta.Role = "assistant"
}
if chunk != nil {
delta.Content = chunk.Content
delta.ReasoningContent = chunk.ReasoningContent
}
if delta.Role == "" && delta.Content == "" && delta.ReasoningContent == "" {
return "", nil
}
dto := StreamResponse{
ID: requestID,
Object: "chat.completion.chunk",
Created: created,
Model: modelName,
Choices: []StreamChoice{{
Index: 0,
Delta: delta,
FinishReason: nil,
}},
}
jsonBytes, err := json.Marshal(dto)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// ToOpenAIFinishStream 生成结束 chunkfinish_reason=stop
func ToOpenAIFinishStream(requestID, modelName string, created int64) (string, error) {
stop := "stop"
dto := StreamResponse{
ID: requestID,
Object: "chat.completion.chunk",
Created: created,
Model: modelName,
Choices: []StreamChoice{{
Index: 0,
Delta: StreamDelta{},
FinishReason: &stop,
}},
}
jsonBytes, err := json.Marshal(dto)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// StreamChat 负责模型流式输出,并在关键节点打点:
// 1) 流连接建立llm.Stream 返回)
// 2) 首包到达(首字延迟)
@@ -103,7 +29,8 @@ func StreamChat(
traceID string,
chatID string,
requestStart time.Time,
) (string, *schema.TokenUsage, error) {
reasoningStartAt *time.Time,
) (string, string, int, *schema.TokenUsage, error) {
/*callStart := time.Now()*/
messages := make([]*schema.Message, 0)
@@ -123,7 +50,7 @@ func StreamChat(
/*connectStart := time.Now()*/
reader, err := llm.Stream(ctx, messages, ark.WithThinking(thinking))
if err != nil {
return "", nil, err
return "", "", 0, nil, err
}
defer reader.Close()
@@ -135,6 +62,12 @@ func StreamChat(
firstChunk := true
chunkCount := 0
var tokenUsage *schema.TokenUsage
var localReasoningStartAt *time.Time
if reasoningStartAt != nil && !reasoningStartAt.IsZero() {
startCopy := reasoningStartAt.In(time.Local)
localReasoningStartAt = &startCopy
}
var reasoningEndAt *time.Time
/*streamRecvStart := time.Now()
log.Printf("打点|流连接建立|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d|history_len=%d",
@@ -147,29 +80,42 @@ func StreamChat(
)*/
var fullText strings.Builder
var reasoningText strings.Builder
for {
chunk, err := reader.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", nil, err
return "", "", 0, nil, err
}
// 优先记录模型真实 usage通常在尾块返回部分模型也可能中途返回
if chunk != nil && chunk.ResponseMeta != nil && chunk.ResponseMeta.Usage != nil {
tokenUsage = mergeTokenUsage(tokenUsage, chunk.ResponseMeta.Usage)
tokenUsage = agentllm.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage)
}
fullText.WriteString(chunk.Content)
if chunk != nil {
if strings.TrimSpace(chunk.ReasoningContent) != "" && localReasoningStartAt == nil {
now := time.Now()
localReasoningStartAt = &now
}
if strings.TrimSpace(chunk.Content) != "" && localReasoningStartAt != nil && reasoningEndAt == nil {
now := time.Now()
reasoningEndAt = &now
}
fullText.WriteString(chunk.Content)
reasoningText.WriteString(chunk.ReasoningContent)
}
payload, err := ToOpenAIStream(chunk, requestID, modelName, created, firstChunk)
payload, err := agentstream.ToOpenAIStream(chunk, requestID, modelName, created, firstChunk)
if err != nil {
return "", nil, err
return "", "", 0, nil, err
}
if payload != "" {
outChan <- payload
chunkCount++
firstChunk = false
/*if firstChunk {
log.Printf("打点|首包到达|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d",
traceID,
@@ -183,9 +129,9 @@ func StreamChat(
}
}
finishChunk, err := ToOpenAIFinishStream(requestID, modelName, created)
finishChunk, err := agentstream.ToOpenAIFinishStream(requestID, modelName, created)
if err != nil {
return "", nil, err
return "", "", 0, nil, err
}
outChan <- finishChunk
outChan <- "[DONE]"
@@ -200,39 +146,16 @@ func StreamChat(
time.Since(requestStart).Milliseconds(),
)*/
return fullText.String(), tokenUsage, nil
}
// mergeTokenUsage 合并流式分片中的 usage。
//
// 设计说明:
// 1. 不同模型的 usage 回传时机不同(中间块/尾块);
// 2. 这里按“更大值覆盖”合并,确保最终拿到完整统计;
// 3. 只用于统计,不影响流式正文输出。
func mergeTokenUsage(base *schema.TokenUsage, incoming *schema.TokenUsage) *schema.TokenUsage {
if incoming == nil {
return base
}
if base == nil {
copied := *incoming
return &copied
reasoningDurationSeconds := 0
if localReasoningStartAt != nil {
if reasoningEndAt == nil {
now := time.Now()
reasoningEndAt = &now
}
if reasoningEndAt.After(*localReasoningStartAt) {
reasoningDurationSeconds = int(reasoningEndAt.Sub(*localReasoningStartAt) / time.Second)
}
}
merged := *base
if incoming.PromptTokens > merged.PromptTokens {
merged.PromptTokens = incoming.PromptTokens
}
if incoming.CompletionTokens > merged.CompletionTokens {
merged.CompletionTokens = incoming.CompletionTokens
}
if incoming.TotalTokens > merged.TotalTokens {
merged.TotalTokens = incoming.TotalTokens
}
if incoming.PromptTokenDetails.CachedTokens > merged.PromptTokenDetails.CachedTokens {
merged.PromptTokenDetails.CachedTokens = incoming.PromptTokenDetails.CachedTokens
}
if incoming.CompletionTokensDetails.ReasoningTokens > merged.CompletionTokensDetails.ReasoningTokens {
merged.CompletionTokensDetails.ReasoningTokens = incoming.CompletionTokensDetails.ReasoningTokens
}
return &merged
return fullText.String(), reasoningText.String(), reasoningDurationSeconds, tokenUsage, nil
}

View File

@@ -1,13 +1,13 @@
package agent2
package agent
import (
"context"
"errors"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
agentrouter "github.com/LoveLosita/smartflow/backend/agent/router"
)
// Service 是 agent2 模块的总入口。
// Service 是 agent 模块的总入口。
//
// 职责边界:
// 1. 负责接住一次完整的 Agent 请求,并把请求交给统一路由器分流;
@@ -17,7 +17,7 @@ type Service struct {
dispatcher *agentrouter.Dispatcher
}
// NewService 创建 agent2 总入口服务。
// NewService 创建 agent 总入口服务。
func NewService(resolver agentrouter.Resolver) *Service {
return &Service{
dispatcher: agentrouter.NewDispatcher(resolver),
@@ -27,15 +27,15 @@ func NewService(resolver agentrouter.Resolver) *Service {
// RegisterHandler 注册某个 skill 的执行入口。
func (s *Service) RegisterHandler(action agentrouter.Action, handler agentrouter.SkillHandler) error {
if s == nil || s.dispatcher == nil {
return errors.New("agent2 service is not initialized")
return errors.New("agent service is not initialized")
}
return s.dispatcher.Register(action, handler)
}
// Handle 是 agent2 的统一处理入口。
// Handle 是 agent 的统一处理入口。
func (s *Service) Handle(ctx context.Context, req *agentrouter.AgentRequest) (*agentrouter.AgentResponse, error) {
if s == nil || s.dispatcher == nil {
return nil, errors.New("agent2 service is not initialized")
return nil, errors.New("agent service is not initialized")
}
return s.dispatcher.Dispatch(ctx, req)
}

View File

@@ -5,9 +5,9 @@ import (
"errors"
"strings"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/cloudwego/eino/compose"
)

View File

@@ -4,8 +4,8 @@ import (
"context"
"errors"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/cloudwego/eino/compose"
)

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/cloudwego/eino/compose"
)

View File

@@ -23,7 +23,7 @@ const (
ThinkingModeDisabled ThinkingMode = "disabled"
)
// GenerateOptions 是 agent2 内部统一的模型调用选项。
// GenerateOptions 是 Agent 内部统一的模型调用选项。
//
// 设计目的:
// 1. 先把“每个 skill 都会反复传的参数”收敛成一份结构;
@@ -49,7 +49,7 @@ type TextResult struct {
// StreamReader 抽象了“可逐块 Recv 的流式返回器”。
//
// 之所以不直接依赖某个具体 SDK 的 reader 类型,是因为 agent2 现在还在建骨架阶段,
// 之所以不直接依赖某个具体 SDK 的 reader 类型,是因为 Agent 现在还在建骨架阶段,
// 后续接 ark、OpenAI 兼容层还是别的 provider都可以往这个最小接口上适配。
type StreamReader interface {
Recv() (*schema.Message, error)
@@ -62,7 +62,7 @@ type TextGenerateFunc func(ctx context.Context, messages []*schema.Message, opti
// StreamGenerateFunc 是流式生成的统一适配函数签名。
type StreamGenerateFunc func(ctx context.Context, messages []*schema.Message, options GenerateOptions) (StreamReader, error)
// Client 是 agent2 里的统一模型客户端门面。
// Client 是 Agent 里的统一模型客户端门面。
//
// 职责边界:
// 1. 负责把 node 层的“模型调用意图”收敛到统一入口;

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"strings"
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
"github.com/cloudwego/eino-ext/components/model/ark"
)

View File

@@ -3,7 +3,7 @@ package agentllm
import (
"strings"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
)
// RouteDecisionOutput 是一级路由模型的结构化输出契约。
@@ -19,7 +19,7 @@ type RouteDecisionOutput struct {
Confidence float64 `json:"confidence"`
}
// ToDecision 把模型契约输出映射成 agent2 内部统一路由结果。
// ToDecision 把模型契约输出映射成 Agent 内部统一路由结果。
func (o *RouteDecisionOutput) ToDecision() *agentmodel.RouteDecision {
if o == nil {
return &agentmodel.RouteDecision{Action: agentmodel.ActionChat}

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"strings"
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"

View File

@@ -3,7 +3,7 @@ package agentllm
import (
"context"
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
"github.com/cloudwego/eino-ext/components/model/ark"
)

View File

@@ -1,6 +1,6 @@
package agentmodel
// AgentRequest 是 agent2 总入口接收的统一请求结构。
// AgentRequest 是 Agent 总入口接收的统一请求结构。
type AgentRequest struct {
UserID int
ConversationID string
@@ -9,7 +9,7 @@ type AgentRequest struct {
Extra map[string]any
}
// AgentResponse 是 agent2 总入口返回的统一响应结构。
// AgentResponse 是 Agent 总入口返回的统一响应结构。
type AgentResponse struct {
Action AgentAction
Reply string

View File

@@ -3,7 +3,7 @@ package agentmodel
import (
"time"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
)
const (

View File

@@ -5,7 +5,7 @@ import (
"strings"
"time"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/LoveLosita/smartflow/backend/model"
)

View File

@@ -8,8 +8,8 @@ import (
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"

View File

@@ -9,8 +9,8 @@ import (
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"

View File

@@ -12,10 +12,10 @@ import (
"sync/atomic"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
@@ -225,7 +225,7 @@ func (n *SchedulePlanNodes) NextAfterRoughBuild(ctx context.Context, st *agentmo
//
// 调用目的:
// 1. 旧 scheduleplan 节点逻辑已经大量直接调用这个函数名;
// 2. 迁到 agent2 后,这里保留同名收口,避免节点层到处散落包前缀;
// 2. 迁到 Agent 后,这里保留同名收口,避免节点层到处散落包前缀;
// 3. 真正的归一化规则仍以下层 model 层为准,避免多处维护。
func normalizeAdjustmentScope(raw string) string {
return agentmodel.NormalizeSchedulePlanAdjustmentScope(raw)

View File

@@ -9,7 +9,7 @@ import (
"strconv"
"strings"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
"github.com/LoveLosita/smartflow/backend/model"
)

View File

@@ -11,9 +11,9 @@ import (
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/cloudwego/eino-ext/components/model/ark"

View File

@@ -10,10 +10,10 @@ import (
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
agentstream "github.com/LoveLosita/smartflow/backend/agent/stream"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"

View File

@@ -7,7 +7,7 @@ import (
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"

View File

@@ -9,7 +9,7 @@ const routeSystemPrompt = `
你是 SmartFlow 的一级路由助手
你的职责不是回答用户而是判断这条消息更适合走哪条能力链路
当前 agent2 仍在逐批迁移阶段因此这里只先保留 prompt 落点与职责说明
当前 Agent 仍在逐批迁移阶段因此这里只先保留 prompt 落点与职责说明
真正迁移旧 route 提示词时应把正式版本收敛到这里而不是散落在 node service
`

View File

@@ -1,163 +0,0 @@
package quicknote
import (
"context"
"errors"
"strings"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/compose"
)
const (
// 图节点:意图识别(含聚合规划与时间校验)
quickNoteGraphNodeIntent = "quick_note_intent"
// 图节点:优先级评估(或本地兜底)
quickNoteGraphNodeRank = "quick_note_priority"
// 图节点:持久化(调用写库工具)
quickNoteGraphNodePersist = "quick_note_persist"
// 图节点:退出(用于非随口记/校验失败分支)
quickNoteGraphNodeExit = "quick_note_exit"
)
// QuickNoteGraphRunInput 是运行“随口记 graph”所需的输入依赖。
// 说明:
// 1) EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块);
// 2) 不传 EmitStage 时,图逻辑保持静默执行;
// 3) SkipIntentVerification=true 时,表示上游路由已信任 quick_note可跳过二次意图判定。
type QuickNoteGraphRunInput struct {
Model *ark.ChatModel
State *QuickNoteState
Deps QuickNoteToolDeps
SkipIntentVerification bool
EmitStage func(stage, detail string)
}
// RunQuickNoteGraph 执行“随口记”图编排。
// 该文件只负责“连线与分支”,节点内部逻辑全部下沉到 nodes.go。
func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*QuickNoteState, error) {
// 1. 启动前硬校验:模型、状态、依赖缺一不可。
if input.Model == nil {
return nil, errors.New("quick note graph: model is nil")
}
if input.State == nil {
return nil, errors.New("quick note graph: state is nil")
}
if err := input.Deps.validate(); err != nil {
return nil, err
}
// 2. 统一封装阶段推送函数,避免各节点反复判空。
emitStage := func(stage, detail string) {
if input.EmitStage != nil {
input.EmitStage(stage, detail)
}
}
// 统一初始化“当前时间基准”,避免同一请求内相对时间口径漂移。
// 2.1 若上游未设置 RequestNow这里补齐。
if input.State.RequestNow.IsZero() {
input.State.RequestNow = quickNoteNowToMinute()
}
// 2.2 若上游未设置文本基准,这里按统一格式补齐。
if strings.TrimSpace(input.State.RequestNowText) == "" {
input.State.RequestNowText = formatQuickNoteTimeToMinute(input.State.RequestNow)
}
// 3. 构建工具包并取出写库工具。
// 这样 graph 运行时只关心“调用工具”,不关心工具如何注册。
toolBundle, err := BuildQuickNoteToolBundle(ctx, input.Deps)
if err != nil {
return nil, err
}
createTaskTool, err := getInvokableToolByName(toolBundle, ToolNameQuickNoteCreateTask)
if err != nil {
return nil, err
}
// 4. runner 负责把依赖收口graph 只保留连线定义。
runner := newQuickNoteRunner(input, createTaskTool, emitStage)
// 5. 创建状态图容器:输入/输出类型都为 *QuickNoteState。
graph := compose.NewGraph[*QuickNoteState, *QuickNoteState]()
// 6. 注册节点(意图 -> 优先级 -> 持久化 -> 退出)。
if err = graph.AddLambdaNode(quickNoteGraphNodeIntent, compose.InvokableLambda(runner.intentNode)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(quickNoteGraphNodeRank, compose.InvokableLambda(runner.priorityNode)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(quickNoteGraphNodePersist, compose.InvokableLambda(runner.persistNode)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(quickNoteGraphNodeExit, compose.InvokableLambda(runner.exitNode)); err != nil {
return nil, err
}
// 连线START -> intent
// 7. 所有请求统一先过 intent 节点,确保意图和时间校验在前。
if err = graph.AddEdge(compose.START, quickNoteGraphNodeIntent); err != nil {
return nil, err
}
// 分支intent 后决定去 priority 还是 exit。
// 8. 非随口记或时间非法时直接 exit避免进入后续写库路径。
if err = graph.AddBranch(quickNoteGraphNodeIntent, compose.NewGraphBranch(
runner.nextAfterIntent,
map[string]bool{
quickNoteGraphNodeRank: true,
quickNoteGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
// exit 直接结束。
// 9. exit 是显式终点前节点,方便后续插入“统一收尾逻辑”。
if err = graph.AddEdge(quickNoteGraphNodeExit, compose.END); err != nil {
return nil, err
}
// priority -> persist。
// 10. 通过优先级节点后,进入持久化节点。
if err = graph.AddEdge(quickNoteGraphNodeRank, quickNoteGraphNodePersist); err != nil {
return nil, err
}
// persist 后决定“重试 persist”还是结束。
// 11. 重试策略由状态字段驱动,不在 graph 层写重试计数逻辑。
if err = graph.AddBranch(quickNoteGraphNodePersist, compose.NewGraphBranch(
runner.nextAfterPersist,
map[string]bool{
quickNoteGraphNodePersist: true,
compose.END: true,
},
)); err != nil {
return nil, err
}
// 12. 运行步数上限:至少 12 步,并根据 MaxToolRetry 预留重试步数。
// 防止异常分支导致无限循环。
maxSteps := input.State.MaxToolRetry + 10
if maxSteps < 12 {
maxSteps = 12
}
// 13. 编译图得到可执行实例。
runnable, err := graph.Compile(ctx,
compose.WithGraphName("QuickNoteGraph"),
compose.WithMaxRunSteps(maxSteps),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return nil, err
}
// 14. 执行图并返回最终状态。
return runnable.Invoke(ctx, input.State)
}

View File

@@ -1,670 +0,0 @@
package quicknote
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
type quickNoteIntentModelOutput struct {
IsQuickNote bool `json:"is_quick_note"`
Title string `json:"title"`
DeadlineAt string `json:"deadline_at"`
Reason string `json:"reason"`
}
type quickNotePriorityModelOutput struct {
PriorityGroup int `json:"priority_group"`
Reason string `json:"reason"`
UrgencyThresholdAt string `json:"urgency_threshold_at"`
}
// quickNotePlanModelOutput 是“单请求聚合规划”节点的模型输出。
type quickNotePlanModelOutput struct {
Title string `json:"title"`
DeadlineAt string `json:"deadline_at"`
UrgencyThresholdAt string `json:"urgency_threshold_at"`
PriorityGroup int `json:"priority_group"`
PriorityReason string `json:"priority_reason"`
Banter string `json:"banter"`
}
// runQuickNoteIntentNode 负责“意图识别 + 聚合规划 + 时间校验”。
// 说明:
// 1) trustRoute 命中时,直接走单请求聚合规划,跳过二次意图识别;
// 2) 无论是否走快路径,最终都要走本地时间硬校验,防止脏时间落库。
func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
// 0. 基础防御state 为空直接返回错误,避免后续节点空指针。
if st == nil {
return nil, errors.New("quick note graph: nil state in intent node")
}
// 1. 如果上游路由已高置信命中 quick_note则走“单请求聚合快路径”。
if input.SkipIntentVerification {
emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
st.IsQuickNoteIntent = true
st.IntentJudgeReason = "上游路由已命中 quick_note跳过二次意图判定"
st.PlannedBySingleCall = true
// 1.1 一次调用里尽量拿齐 title/deadline/priority/banter减少串行模型开销。
emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
plan, planErr := planQuickNoteInSingleCall(ctx, input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
if planErr != nil {
// 1.2 聚合规划失败不终止链路,改为后续本地兜底。
st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
} else {
// 1.3 仅在字段有效时回填,避免无效值污染状态。
if strings.TrimSpace(plan.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(plan.Title)
}
if plan.Deadline != nil {
st.ExtractedDeadline = plan.Deadline
}
st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText)
if plan.UrgencyThreshold != nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline)
}
if IsValidTaskPriority(plan.PriorityGroup) {
st.ExtractedPriority = plan.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
}
st.ExtractedBanter = strings.TrimSpace(plan.Banter)
}
// 1.4 如果模型没给标题,基于原句做本地标题提取兜底。
if strings.TrimSpace(st.ExtractedTitle) == "" {
st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
}
// 1.5 无论是否聚合成功,都要进行本地时间硬校验,防止脏时间写库。
emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if userDeadline != nil {
// 用户原句能解析出时间时,以原句解析结果为准(更贴近真实输入)。
st.ExtractedDeadline = userDeadline
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
return st, nil
}
// 2. 常规路径:先让模型做意图识别 + 初步抽取。
emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
请仅输出 JSON不要 markdown不要解释字段如下
{
"is_quick_note": boolean,
"title": string,
"deadline_at": string,
"reason": string
}
字段约束:
1) deadline_at 只允许输出绝对时间,格式必须为 "yyyy-MM-dd HH:mm"。
2) 如果用户说了“明天/后天/下周一/今晚”等相对时间,必须基于上面的当前时间换算成绝对时间。
3) 如果用户没有提及时间deadline_at 输出空字符串。`,
st.RequestNowText,
st.UserInput,
)
// 2.1 模型调用失败时,保守回退普通聊天,避免误写任务。
raw, callErr := callModelForJSON(ctx, input.Model, QuickNoteIntentPrompt, prompt)
if callErr != nil {
st.IsQuickNoteIntent = false
st.IntentJudgeReason = "意图识别失败,回退普通聊天"
return st, nil
}
// 2.2 解析失败同样回退普通聊天,保证稳定性优先。
parsed, parseErr := parseJSONPayload[quickNoteIntentModelOutput](raw)
if parseErr != nil {
st.IsQuickNoteIntent = false
st.IntentJudgeReason = "意图识别结果不可解析,回退普通聊天"
return st, nil
}
st.IsQuickNoteIntent = parsed.IsQuickNote
st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
if !st.IsQuickNoteIntent {
// 非随口记:后续通过分支直接退出 graph。
return st, nil
}
// 2.3 处理标题字段:为空时回退到用户原句。
title := strings.TrimSpace(parsed.Title)
if title == "" {
title = strings.TrimSpace(st.UserInput)
}
st.ExtractedTitle = title
emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
// Step A优先尝试解析模型抽取出来的 deadline。
// 这样可利用模型“结构化理解”能力先拿一次候选时间。
st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
if st.ExtractedDeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
st.ExtractedDeadline = deadline
}
}
// Step B基于用户原句执行“本地时间解析 + 合法性校验”。
// 本地校验是最终硬门槛,确保“用户给错时间不会被静默写成 NULL”。
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if st.ExtractedDeadline == nil && userDeadline != nil {
// 当模型未提取出时间,但原句能解析时,补写时间结果。
st.ExtractedDeadline = userDeadline
if st.ExtractedDeadlineText == "" {
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
}
return st, nil
}
// runQuickNotePriorityNode 负责“优先级评估”。
// 说明:
// 1) 聚合规划已给出合法优先级时直接复用;
// 2) 快路径下缺失优先级时直接本地兜底,避免额外模型调用;
// 3) 其余场景走独立评估模型,失败再兜底。
func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in priority node")
}
// 1. 非随口记或时间校验失败时,不做优先级评估。
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 2. 已有合法优先级则直接复用,避免重复调用模型。
if IsValidTaskPriority(st.ExtractedPriority) {
if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
st.ExtractedPriorityReason = "复用聚合规划优先级"
}
emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
return st, nil
}
// 3. 快路径下若缺失优先级,直接本地兜底,追求低延迟。
if input.SkipIntentVerification || st.PlannedBySingleCall {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
return st, nil
}
// 4. 常规路径才调用独立优先级模型。
emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
deadlineText := "无"
if st.ExtractedDeadline != nil {
deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
}
deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
if deadlineClue == "" {
deadlineClue = "无"
}
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
请对以下任务评估优先级:
- 任务标题:%s
- 用户原始输入:%s
- 时间线索原文:%s
- 归一化截止时间:%s
请仅输出 JSON不要 markdown不要解释
{
"priority_group": 1|2|3|4,
"reason": "简短理由",
"urgency_threshold_at": "yyyy-MM-dd HH:mm 或空字符串"
}
额外约束:
1) urgency_threshold_at 表示“何时从不紧急象限自动平移到紧急象限”;
2) 若该任务不需要自动平移,可输出空字符串;
3) 若任务已在紧急象限priority_group=1 或 3优先输出空字符串
4) 若输出非空时间,必须是绝对时间,且不晚于归一化截止时间(若有)。`,
st.RequestNowText,
st.ExtractedTitle,
st.UserInput,
deadlineClue,
deadlineText,
)
// 4.1 调用失败:使用本地兜底,不中断主链路。
raw, callErr := callModelForJSON(ctx, input.Model, QuickNotePriorityPrompt, prompt)
if callErr != nil {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
return st, nil
}
// 4.2 解析失败或非法值:同样兜底。
parsed, parseErr := parseJSONPayload[quickNotePriorityModelOutput](raw)
if parseErr != nil || !IsValidTaskPriority(parsed.PriorityGroup) {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略"
return st, nil
}
st.ExtractedPriority = parsed.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" {
urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow)
if thresholdErr == nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline)
}
}
return st, nil
}
// runQuickNotePersistNodeInternal 负责“写库工具调用 + 重试态回填”。
func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, createTaskTool tool.InvokableTool, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
_ = input // 保留参数形状,后续若需要基于输入开关扩展可直接使用。
if st == nil {
return nil, errors.New("quick note graph: nil state in persist node")
}
// 1. 非随口记或时间非法时不允许落库。
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 2. 准备工具入参:优先使用已评估优先级,缺失则兜底。
emitStage("quick_note.persisting", "正在写入任务数据。")
priority := st.ExtractedPriority
if !IsValidTaskPriority(priority) {
priority = fallbackPriority(st)
st.ExtractedPriority = priority
}
deadlineText := ""
if st.ExtractedDeadline != nil {
deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
}
urgencyThresholdText := ""
if st.ExtractedUrgencyThreshold != nil {
urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339)
}
// 3. 工具参数序列化失败视作一次失败尝试,交由重试分支处理。
toolInput := QuickNoteCreateTaskToolInput{
Title: st.ExtractedTitle,
PriorityGroup: priority,
DeadlineAt: deadlineText,
UrgencyThresholdAt: urgencyThresholdText,
}
rawInput, marshalErr := json.Marshal(toolInput)
if marshalErr != nil {
st.RecordToolError("构造工具参数失败: " + marshalErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。"
emitStage("quick_note.failed", "参数构造失败,未完成写入。")
}
return st, nil
}
// 4. 调用写库工具。
rawOutput, invokeErr := createTaskTool.InvokableRun(ctx, string(rawInput))
if invokeErr != nil {
st.RecordToolError(invokeErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
emitStage("quick_note.failed", "多次重试后仍未完成写入。")
}
return st, nil
}
// 5. 工具返回解析失败同样按“可重试错误”处理。
toolOutput, parseErr := parseJSONPayload[QuickNoteCreateTaskToolOutput](rawOutput)
if parseErr != nil {
st.RecordToolError("解析工具返回失败: " + parseErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
}
return st, nil
}
// 成功判定硬门槛:必须拿到有效 task_id防止“假成功”。
if toolOutput.TaskID <= 0 {
st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
emitStage("quick_note.failed", "写入结果缺少有效 task_id已终止成功回包。")
}
return st, nil
}
// 6. 写库成功后回填状态,并准备最终回复内容。
st.RecordToolSuccess(toolOutput.TaskID)
if strings.TrimSpace(toolOutput.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
}
if IsValidTaskPriority(toolOutput.PriorityGroup) {
st.ExtractedPriority = toolOutput.PriorityGroup
}
reply := strings.TrimSpace(toolOutput.Message)
if reply == "" {
reply = fmt.Sprintf("已为你记录:%s%s", st.ExtractedTitle, PriorityLabelCN(st.ExtractedPriority))
}
st.AssistantReply = reply
emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
return st, nil
}
// selectQuickNoteNextAfterIntent 根据意图与时间校验结果决定 intent 后分支。
func selectQuickNoteNextAfterIntent(st *QuickNoteState) string {
// 1) 非随口记 -> exit
// 2) 时间校验失败 -> exit
// 3) 其余 -> priority 节点。
if st == nil || !st.IsQuickNoteIntent {
return quickNoteGraphNodeExit
}
if strings.TrimSpace(st.DeadlineValidationError) != "" {
return quickNoteGraphNodeExit
}
return quickNoteGraphNodeRank
}
// selectQuickNoteNextAfterPersist 根据持久化状态决定 persist 后分支。
func selectQuickNoteNextAfterPersist(st *QuickNoteState) string {
// 分支规则:
// 1) state=nil防御式结束
// 2) 已持久化:结束;
// 3) 可重试:回到 persist 重试;
// 4) 不可重试:写失败文案并结束。
if st == nil {
return compose.END
}
if st.Persisted {
return compose.END
}
if st.CanRetryTool() {
return quickNoteGraphNodePersist
}
if strings.TrimSpace(st.AssistantReply) == "" {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
}
return compose.END
}
func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
// 1. 校验工具包有效性。
if bundle == nil {
return nil, errors.New("tool bundle is nil")
}
if len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 {
return nil, errors.New("tool bundle is empty")
}
// 2. 通过 ToolInfo 名称定位并拿到同索引的 Tool 实例。
for idx, info := range bundle.ToolInfos {
if info == nil || info.Name != name {
continue
}
invokable, ok := bundle.Tools[idx].(tool.InvokableTool)
if !ok {
return nil, fmt.Errorf("tool %s is not invokable", name)
}
return invokable, nil
}
return nil, fmt.Errorf("tool %s not found", name)
}
func callModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
// 默认 JSON 输出场景 token 足够小,使用 256 作为保守上限。
return callModelForJSONWithMaxTokens(ctx, chatModel, systemPrompt, userPrompt, 256)
}
func callModelForJSONWithMaxTokens(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
// 1. 构造 system + user 两段消息。
messages := []*schema.Message{
schema.SystemMessage(systemPrompt),
schema.UserMessage(userPrompt),
}
// 2. 统一关闭 thinking降低额外延迟并用温度 0 提升结构化稳定性。
opts := []einoModel.Option{
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0),
}
if maxTokens > 0 {
opts = append(opts, einoModel.WithMaxTokens(maxTokens))
}
// 3. 调模型并对空响应做防御校验。
resp, err := chatModel.Generate(ctx, messages, opts...)
if err != nil {
return "", err
}
if resp == nil {
return "", errors.New("模型返回为空")
}
content := strings.TrimSpace(resp.Content)
if content == "" {
return "", errors.New("模型返回内容为空")
}
return content, nil
}
type quickNotePlannedResult struct {
Title string
Deadline *time.Time
DeadlineText string
UrgencyThreshold *time.Time
UrgencyThresholdText string
PriorityGroup int
PriorityReason string
Banter string
}
// planQuickNoteInSingleCall 在一次模型调用里完成“时间/优先级/banter”聚合规划。
func planQuickNoteInSingleCall(
ctx context.Context,
chatModel *ark.ChatModel,
nowText string,
now time.Time,
userInput string,
) (*quickNotePlannedResult, error) {
// 1. 构造聚合 prompt一次返回所有结构化字段减少多次 LLM 往返。
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
请仅输出 JSON不要 markdown不要解释字段如下
{
"title": string,
"deadline_at": string,
"urgency_threshold_at": string,
"priority_group": 1|2|3|4,
"priority_reason": string,
"banter": string
}
约束:
1) deadline_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
2) urgency_threshold_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
3) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间;
4) 若任务不需要自动平移,可让 urgency_threshold_at 为空;
5) banter 只允许一句中文不超过30字不得改动任务事实。`,
nowText,
strings.TrimSpace(userInput),
)
// 2. 控制 maxTokens避免模型冗长输出导致延迟上升。
raw, err := callModelForJSONWithMaxTokens(ctx, chatModel, QuickNotePlanPrompt, prompt, 220)
if err != nil {
return nil, err
}
// 3. 解析模型输出 JSON。
parsed, parseErr := parseJSONPayload[quickNotePlanModelOutput](raw)
if parseErr != nil {
return nil, parseErr
}
result := &quickNotePlannedResult{
Title: strings.TrimSpace(parsed.Title),
DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt),
PriorityGroup: parsed.PriorityGroup,
PriorityReason: strings.TrimSpace(parsed.PriorityReason),
Banter: strings.TrimSpace(parsed.Banter),
}
// 4. banter 只保留首行,防止模型输出多行破坏最终回复风格。
if result.Banter != "" {
if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
result.Banter = strings.TrimSpace(result.Banter[:idx])
}
}
// 5. 对 deadline 做本地二次校验,确保可落库。
if result.DeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
result.Deadline = deadline
}
}
// 6. 对 urgency_threshold_at 做本地二次校验,并与 deadline 做上界约束。
if result.UrgencyThresholdText != "" {
if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline)
}
}
return result, nil
}
func parseJSONPayload[T any](raw string) (*T, error) {
// 1. 空字符串直接失败。
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, errors.New("empty response")
}
// 2. 兼容 ```json ... ``` 包裹输出。
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
// 3. 先尝试整体反序列化(最快路径)。
var out T
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
// 4. 若模型附带额外文本,则提取最外层 JSON 对象再解析。
obj := extractJSONObject(clean)
if obj == "" {
return nil, fmt.Errorf("no json object found in: %s", clean)
}
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, err
}
return &out, nil
}
func extractJSONObject(text string) string {
// 简化提取策略:取首个“{”到最后“}”的片段。
// 对当前 prompt 场景足够稳定,且实现成本低。
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start == -1 || end == -1 || end <= start {
return ""
}
return text[start : end+1]
}
// normalizeUrgencyThreshold 归一化“紧急分界线时间”。
//
// 规则:
// 1. 分界线为空时直接返回空;
// 2. 存在 deadline 且分界线晚于 deadline 时,收敛到 deadline
// 3. 其余情况保持原值。
func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time {
if threshold == nil {
return nil
}
if deadline == nil {
return threshold
}
if threshold.After(*deadline) {
normalized := *deadline
return &normalized
}
return threshold
}
func fallbackPriority(st *QuickNoteState) int {
// 兜底规则:
// 1) 有截止时间且 <=48h重要且紧急
// 2) 有截止时间但较远:重要不紧急;
// 3) 无截止时间:简单不重要。
if st == nil {
return QuickNotePrioritySimpleNotImportant
}
if st.ExtractedDeadline != nil {
if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
return QuickNotePriorityImportantUrgent
}
return QuickNotePriorityImportantNotUrgent
}
return QuickNotePrioritySimpleNotImportant
}
// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
func deriveQuickNoteTitleFromInput(userInput string) string {
// 1. 先清理空白。
text := strings.TrimSpace(userInput)
if text == "" {
return "这条任务"
}
// 2. 去掉常见指令前缀,保留核心任务语义。
prefixes := []string{
"请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一下", "记个", "帮我记一下",
}
for _, prefix := range prefixes {
if strings.HasPrefix(text, prefix) {
text = strings.TrimSpace(strings.TrimPrefix(text, prefix))
break
}
}
// 3. 截断“记得/到时候”等尾部提醒语,避免标题过长。
suffixSeparators := []string{
",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
}
for _, sep := range suffixSeparators {
if idx := strings.Index(text, sep); idx > 0 {
text = strings.TrimSpace(text[:idx])
break
}
}
// 4. 收尾清理标点;若清理后为空则回退原句。
text = strings.Trim(text, ",。.!; ")
if text == "" {
return strings.TrimSpace(userInput)
}
return text
}

View File

@@ -1,86 +0,0 @@
package quicknote
const (
// QuickNoteRouteControlPrompt 用于“首段控制码分流”:
// - 仅负责判断用户输入应走 quick_note 还是 chat
// - 不直接回答用户问题;
// - 必须输出可机读控制码,便于后端无歧义解析。
// 额外说明:
// 1) 这里要求固定 XML 结构,是为了让后端做严格字符串/标签解析,而不是模糊关键词匹配;
// 2) 增加 reason 标签,主要用于日志排障(看模型为何判到 quick_note/chat
// 3) 明确“禁止输出其他内容”,是为了减少模型附加寒暄导致解析失败。
QuickNoteRouteControlPrompt = `你是 SmartFlow 的请求分流控制器。
你的唯一任务是给后端返回可机读控制码,不要做用户可见回复,不要解释。
判定规则:
1) 若用户表达“希望你在将来提醒/记录/安排某件事”,输出 quick_note。
2) 其余情况输出 chat包括闲聊、知识问答、纯讨论、观点交流
3) 口语变体如“d我/q我/戳我/到点喊我/记得提醒我”)也属于 quick_note。
输出格式必须严格如下(两行,大小写不敏感):
<SMARTFLOW_ROUTE nonce="给定nonce" action="quick_note|chat"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>一句不超过30字的中文理由</SMARTFLOW_REASON>
禁止输出任何其他内容。`
// QuickNotePlanPrompt 用于“单请求聚合规划”:
// - 在一次调用内完成标题抽取、时间归一化、紧急分界线评估、优先级评估、跟进句生成;
// - 主要用于路由已明确命中 quick_note 的场景,以降低串行 LLM 调用次数。
// 额外说明:
// 1) 强制 JSON 输出,减少后端解析分支复杂度;
// 2) deadline_at / urgency_threshold_at 统一分钟级,方便直接映射到数据库时间字段;
// 3) banter 与事实分离,避免润色文案污染结构化字段。
QuickNotePlanPrompt = `你是 SmartFlow 的任务聚合规划器。
你将基于用户输入,一次性输出任务规划结果,供后端直接写库。
必须完成以下五件事:
1) 提取任务标题 title简洁明确
2) 归一化截止时间 deadline_at若存在时间线索必须输出绝对时间
3) 评估紧急分界时间 urgency_threshold_at当任务被判定为不紧急任务时才会触发:你需要评估何时从不紧急象限自动平移到紧急象限,不可为空)。
4) 评估优先级 priority_group1~4
5) 生成一句轻松跟进句 banter不超过30字
输出要求:
- 仅输出 JSON不要 markdown不要解释。
- deadline_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。
- urgency_threshold_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。
- priority_group 仅允许 1|2|3|4。
- banter 不得新增或修改任务事实(任务名、时间、优先级)。`
// QuickNoteIntentPrompt 用于第一阶段:判断用户输入是否属于“随口记”。
// 设计约束:
// 1) 只做识别与抽取,不允许模型宣称“已写库”;
// 2) 遇到相对时间必须先换算成绝对时间,减少后续工具层歧义;
// 3) 若无时间信息必须返回空字符串,避免幻觉时间污染数据库。
// 4) 把“当前时间”明确注入 prompt保证相对时间换算有统一基准。
QuickNoteIntentPrompt = `你是 SmartFlow 的“随口记分诊器”。
请判断用户输入是否表达了“帮我记一个任务/日程”的需求。
- 若是,请提取任务标题与时间线索。
- 时间处理必须严谨:若出现相对时间(如明天/后天/下周一/今晚),必须基于上文给出的“当前时间”换算为绝对时间。
- 若不是,请明确返回“非随口记意图”。
- 不要声称已经写入数据库。`
// QuickNotePriorityPrompt 用于第二阶段:将任务归类到四象限优先级,并评估紧急分界线。
// 输出会直接映射到 tasks.priority1~4因此要求结果必须可解释。
// 这里强调“理由必须可解释”,是为了后续日志复盘时能看懂模型为何这么判。
QuickNotePriorityPrompt = `你是 SmartFlow 的任务优先级评估器。
根据任务内容、时间约束和执行成本,输出优先级 priority_group
1=重要且紧急2=重要不紧急3=简单不重要4=不简单不重要。
请给出简短理由,理由必须可解释。
若你认为该任务需要后续自动平移,请额外输出 urgency_threshold_at绝对时间yyyy-MM-dd HH:mm否则输出空字符串。`
// QuickNoteReplyBanterPrompt 用于随口记成功后的“轻松跟进句”生成。
// 约束重点:
// 1) 只输出一句自然中文;
// 2) 贴合用户原话题(例如吃早餐、开会、写报告);
// 3) 禁止新增事实(尤其不能改时间、优先级、任务内容);
// 4) 不要 markdown不要列表不要引号包裹。
QuickNoteReplyBanterPrompt = `你是 SmartFlow 的中文口语化回复润色助手。
请根据用户原话生成一句轻松自然的跟进话术,让回复更有温度。
要求:
- 只输出一句中文不超过30字。
- 顺着用户创建提醒的主题延伸,就像聊天时友好的问候一样,记得动用你知道的对应领域的知识。例如(注意,只是例子):用户说提醒他明天早上吃麦当劳,你润色回复应该类似这样:"薯饼记得趁热吃哦~"。
- 可以轻微调侃,但语气友好,不刻薄。
- 不得新增或修改任务事实(任务名、时间、优先级)。
- 不要输出 markdown、编号、引号。`
)

View File

@@ -1,36 +0,0 @@
package quicknote
import "testing"
func TestDeriveQuickNoteTitleFromInput(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "保留核心事项并去掉尾部提醒口头语",
input: "明天上午12点我要去取快递到时候记得q我",
want: "明天上午12点我要去取快递",
},
{
name: "去掉常见前缀口头语",
input: "提醒我周五下午三点交实验报告",
want: "周五下午三点交实验报告",
},
{
name: "空输入兜底",
input: " ",
want: "这条任务",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := deriveQuickNoteTitleFromInput(tc.input)
if got != tc.want {
t.Fatalf("title 提取不符合预期got=%q want=%q", got, tc.want)
}
})
}
}

View File

@@ -1,61 +0,0 @@
package quicknote
import (
"context"
"github.com/cloudwego/eino/components/tool"
)
// quickNoteRunner 是“单次图运行”的请求级依赖容器。
// 设计目标:
// 1) 把节点运行所需依赖input/tool/emit就近收口
// 2) 让 graph.go 只保留“节点连线”和“方法引用”,提升可读性;
// 3) 避免在 graph.go 里重复出现内联闭包和参数透传。
type quickNoteRunner struct {
input QuickNoteGraphRunInput
createTaskTool tool.InvokableTool
emitStage func(stage, detail string)
}
// newQuickNoteRunner 构造请求级 runner。
// 说明runner 生命周期仅限一次 graph invoke不做跨请求复用。
func newQuickNoteRunner(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool, emitStage func(stage, detail string)) *quickNoteRunner {
return &quickNoteRunner{
input: input,
createTaskTool: createTaskTool,
emitStage: emitStage,
}
}
func (r *quickNoteRunner) intentNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
// 方法引用适配层:把 runner 内部依赖透传到纯函数节点实现。
return runQuickNoteIntentNode(ctx, st, r.input, r.emitStage)
}
func (r *quickNoteRunner) priorityNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
// 方法引用适配层:让 graph.go 保持“只连线,不写业务细节”。
return runQuickNotePriorityNode(ctx, st, r.input, r.emitStage)
}
func (r *quickNoteRunner) persistNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
// 这里注入 createTaskTool是为了让 persist 节点不直接依赖外部容器对象。
return runQuickNotePersistNodeInternal(ctx, st, r.createTaskTool, r.input, r.emitStage)
}
func (r *quickNoteRunner) nextAfterIntent(ctx context.Context, st *QuickNoteState) (string, error) {
// 当前分支决策是纯状态函数,不依赖 context保留参数仅为适配 GraphBranch 签名。
_ = ctx
return selectQuickNoteNextAfterIntent(st), nil
}
func (r *quickNoteRunner) nextAfterPersist(ctx context.Context, st *QuickNoteState) (string, error) {
// 当前分支决策是纯状态函数,不依赖 context保留参数仅为适配 GraphBranch 签名。
_ = ctx
return selectQuickNoteNextAfterPersist(st), nil
}
func (r *quickNoteRunner) exitNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
// exit 节点不做任何业务逻辑,仅把当前状态原样透传到 END。
_ = ctx
return st, nil
}

View File

@@ -1,174 +0,0 @@
package quicknote
import "time"
const (
// QuickNoteDatetimeMinuteLayout 是“随口记”链路内部统一的分钟级时间格式。
// 说明:
// 1) 用于把“当前时间基准”传给模型,避免模型在相对时间推断时出现秒级抖动。
// 2) 用于日志和调试,读起来比 RFC3339 更直观。
QuickNoteDatetimeMinuteLayout = "2006-01-02 15:04"
// quickNoteTimezoneName 是随口记链路默认业务时区。
// 这里固定为东八区,避免容器运行在 UTC 时把“明天/今晚”解释偏移到错误日期。
quickNoteTimezoneName = "Asia/Shanghai"
// QuickNotePriorityImportantUrgent 对应四象限里的“重要且紧急”。
// 在当前 tasks 表中映射为 priority=1数值越小优先级越高
QuickNotePriorityImportantUrgent = 1
// QuickNotePriorityImportantNotUrgent 对应“重要不紧急”。
QuickNotePriorityImportantNotUrgent = 2
// QuickNotePrioritySimpleNotImportant 对应“简单不重要”。
QuickNotePrioritySimpleNotImportant = 3
// QuickNotePriorityComplexNotImportant 对应“不简单不重要”。
QuickNotePriorityComplexNotImportant = 4
)
// IsValidTaskPriority 判断优先级是否合法。
// 目前后端任务模型限定为 1~4。
func IsValidTaskPriority(priority int) bool {
return priority >= QuickNotePriorityImportantUrgent && priority <= QuickNotePriorityComplexNotImportant
}
// PriorityLabelCN 把优先级数值转换为中文标签,便于拼接给用户的自然语言回复。
func PriorityLabelCN(priority int) string {
switch priority {
case QuickNotePriorityImportantUrgent:
return "重要且紧急"
case QuickNotePriorityImportantNotUrgent:
return "重要不紧急"
case QuickNotePrioritySimpleNotImportant:
return "简单不重要"
case QuickNotePriorityComplexNotImportant:
return "不简单不重要"
default:
return "未知优先级"
}
}
// QuickNoteState 是“AI随口记”链路在 graph 节点间传递的统一状态容器。
// 设计目标:
// 1) 把本次请求的上下文收拢到一个结构里,降低节点函数参数散落;
// 2) 让“识别、评估、写库、重试、回复”每一步都可追踪;
// 3) 便于后续扩展打点和可观测字段(例如时间解析失败原因)。
type QuickNoteState struct {
// 基础上下文:用于日志关联与用户隔离。
TraceID string
UserID int
ConversationID string
// RequestNow 记录“请求进入随口记链路时”的时间基准(分钟级)。
// 所有相对时间(明天/后天/下周一)都必须基于这个时间计算,
// 这样同一次请求内不会因为时间流逝产生口径漂移。
RequestNow time.Time
// RequestNowText 是 RequestNow 的字符串形式,主要用于 prompt 注入。
RequestNowText string
// 用户原始输入(例如:提醒我下周日之前完成大作业)。
UserInput string
// 意图判定结果。
IsQuickNoteIntent bool
IntentJudgeReason string
// 结构化抽取结果:由“意图识别/信息抽取”节点写入。
ExtractedTitle string
ExtractedDeadline *time.Time
ExtractedDeadlineText string
// ExtractedUrgencyThreshold 表示“进入紧急象限的分界时间”。
//
// 语义说明:
// 1. 该时间由模型规划后给出,并在后端做解析校验;
// 2. 到达该时间后,任务可在“读时派生 + 异步落库”链路中被自动平移;
// 3. 为空表示该任务不参与自动平移。
ExtractedUrgencyThreshold *time.Time
ExtractedPriority int
// ExtractedBanter 是聚合规划阶段生成的“轻松跟进句”。
// 该字段非空时,最终回复阶段可直接复用,避免再触发一次独立润色模型调用。
ExtractedBanter string
// PlannedBySingleCall 标记本次是否走了“单请求聚合规划”快路径。
// 用于在后续节点做更激进的性能策略(例如缺失字段时直接本地兜底,避免再触发模型调用)。
PlannedBySingleCall bool
// ExtractedPriorityReason 记录优先级评估理由,便于后续排查模型判断是否符合预期。
ExtractedPriorityReason string
// DeadlineValidationError 记录时间校验失败原因。
// 只要该字段非空,就说明用户提供了无法解析的时间表达,本次请求不应落库。
DeadlineValidationError string
// 工具调用过程状态:用于重试与故障回溯。
ToolAttemptCount int
MaxToolRetry int
LastToolError string
// 最终持久化结果:由“写库工具”节点回填。
PersistedTaskID int
Persisted bool
// AssistantReply 是 graph 最终给用户的回复文案。
AssistantReply string
}
// NewQuickNoteState 创建随口记状态对象并初始化默认重试次数。
func NewQuickNoteState(traceID string, userID int, conversationID, userInput string) *QuickNoteState {
// 1. 在“进入链路”这一刻固化时间基准,后续所有相对时间都以它为准。
requestNow := quickNoteNowToMinute()
return &QuickNoteState{
TraceID: traceID,
UserID: userID,
ConversationID: conversationID,
RequestNow: requestNow,
RequestNowText: formatQuickNoteTimeToMinute(requestNow),
UserInput: userInput,
MaxToolRetry: 3,
}
}
// CanRetryTool 判断当前是否还能继续重试工具调用。
func (s *QuickNoteState) CanRetryTool() bool {
// 规则:已尝试次数 < 最大重试次数 才允许继续。
// 这里不做 <=,是为了让“第 MaxToolRetry 次失败后”及时停机并给用户明确反馈。
return s.ToolAttemptCount < s.MaxToolRetry
}
// RecordToolError 记录一次工具调用失败。
func (s *QuickNoteState) RecordToolError(errMsg string) {
// 1. 每失败一次都要累加计数,供分支节点判断是否继续重试。
s.ToolAttemptCount++
// 2. 保留最后一次错误,便于日志与排障定位“最终失败原因”。
s.LastToolError = errMsg
}
// RecordToolSuccess 记录一次工具调用成功。
func (s *QuickNoteState) RecordToolSuccess(taskID int) {
// 1. 成功同样计入尝试次数,便于还原完整调用轨迹。
s.ToolAttemptCount++
// 2. 回填 task_id 和成功标志,供后续节点拼接成功回复。
s.PersistedTaskID = taskID
s.Persisted = true
// 3. 成功后清空错误,避免后续误读历史失败信息。
s.LastToolError = ""
}
// quickNoteLocation 返回随口记链路使用的业务时区。
func quickNoteLocation() *time.Location {
// 1. 优先加载业务固定时区,保证“明天/今晚”等语义与用户预期一致。
loc, err := time.LoadLocation(quickNoteTimezoneName)
if err != nil {
// 2. 极端情况下回退到系统本地时区,避免因时区加载失败导致链路整体不可用。
return time.Local
}
return loc
}
// quickNoteNowToMinute 返回当前时间并截断到分钟级。
func quickNoteNowToMinute() time.Time {
// 统一截断到分钟,避免秒级抖动导致“同一次请求前后解析口径不一致”。
return time.Now().In(quickNoteLocation()).Truncate(time.Minute)
}
// formatQuickNoteTimeToMinute 将时间格式化为分钟级字符串。
func formatQuickNoteTimeToMinute(t time.Time) string {
// 输出前统一转换到业务时区,避免日志和 prompt 出现跨时区混淆。
return t.In(quickNoteLocation()).Format(QuickNoteDatetimeMinuteLayout)
}

View File

@@ -1,681 +0,0 @@
package quicknote
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"
)
const (
// ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的标准名称。
// 该名称会直接暴露给大模型,因此建议保持稳定,避免后续提示词和历史上下文失配。
ToolNameQuickNoteCreateTask = "quick_note_create_task"
// ToolDescQuickNoteCreateTask 是工具的简要职责说明。
ToolDescQuickNoteCreateTask = "把用户随口提到的事项落库为任务,支持可选截止时间与优先级"
)
var (
// quickNoteDeadlineLayouts 是“绝对时间”白名单格式。
// 只要命中任意一个 layout就会被归一化为分钟级时间并进入写库流程。
quickNoteDeadlineLayouts = []string{
time.RFC3339,
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006/01/02 15:04:05",
"2006/01/02 15:04",
"2006.01.02 15:04:05",
"2006.01.02 15:04",
"2006-01-02",
"2006/01/02",
"2006.01.02",
}
quickNoteDateOnlyLayouts = map[string]struct{}{
"2006-01-02": {},
"2006/01/02": {},
"2006.01.02": {},
}
// 正则区:
// 1) 用于解析明确时间表达;
// 2) 用于“是否存在时间线索”的判定(即使格式错误,也会触发校验失败而非静默忽略)。
quickNoteClockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[:]\s*(\d{1,2})`)
quickNoteClockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`)
quickNoteYMDRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
quickNoteMDRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
quickNoteDateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`)
quickNoteWeekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`)
quickNoteRelativeTokens = []string{
"今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日",
"早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨",
}
)
// QuickNoteToolDeps 描述“随口记工具包”需要的外部依赖。
// 这里采用函数注入的方式,避免 agent 包和 service/dao 强耦合,后续更容易演进为 mock 测试或多实现切换。
type QuickNoteToolDeps struct {
// ResolveUserID 从上下文中解析当前登录用户 ID。
ResolveUserID func(ctx context.Context) (int, error)
// CreateTask 执行真实写库动作。
CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error)
}
func (d QuickNoteToolDeps) validate() error {
// 1. ResolveUserID 为空会导致工具无法绑定当前用户,必须提前失败。
if d.ResolveUserID == nil {
return errors.New("quick note tool deps: ResolveUserID is nil")
}
// 2. CreateTask 为空说明没有真实写库实现,工具无法完成核心职责。
if d.CreateTask == nil {
return errors.New("quick note tool deps: CreateTask is nil")
}
return nil
}
// QuickNoteToolBundle 是随口记工具集合的打包结果。
// - Tools: 给 ToolsNode 使用
// - ToolInfos: 给 ChatModel 绑定工具 schema 使用
// 两者分开返回,可以适配你后面用 chain、graph、react 的不同挂载姿势。
type QuickNoteToolBundle struct {
Tools []tool.BaseTool
ToolInfos []*schema.ToolInfo
}
// QuickNoteCreateTaskRequest 是工具层到业务层的内部请求结构。
// 与模型输入解耦,避免模型字段变化直接影响业务签名。
type QuickNoteCreateTaskRequest struct {
UserID int
Title string
PriorityGroup int
DeadlineAt *time.Time
// UrgencyThresholdAt 是“进入紧急象限”的分界时间,允许为空。
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskResult 是业务层返回给工具层的结构化结果。
type QuickNoteCreateTaskResult struct {
TaskID int
Title string
PriorityGroup int
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskToolInput 是提供给大模型的工具参数定义。
// 注意user_id 不对模型暴露,统一从鉴权上下文提取,避免越权写入。
type QuickNoteCreateTaskToolInput struct {
Title string `json:"title" jsonschema:"required,description=任务标题,简洁明确"`
// PriorityGroup 使用 1~4和后端 tasks.priority 保持一致。
PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4不简单不重要)"`
// DeadlineAt 支持绝对时间与常见相对时间(如明天/后天/下周一/今晚),内部会归一化为绝对时间。
DeadlineAt string `json:"deadline_at,omitempty" jsonschema:"description=可选截止时间支持RFC3339、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd HH:mm 以及常见中文相对时间"`
// UrgencyThresholdAt 表示“何时从不紧急象限自动平移到紧急象限”。
// 允许为空;非空时会走同样的时间解析与合法性校验。
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty" jsonschema:"description=可选紧急分界时间支持与deadline_at相同格式"`
}
// QuickNoteCreateTaskToolOutput 是返回给大模型的工具结果。
// 该结构可直接给模型用于“向用户解释已记录到哪个优先级”。
type QuickNoteCreateTaskToolOutput struct {
TaskID int `json:"task_id"`
Title string `json:"title"`
PriorityGroup int `json:"priority_group"`
PriorityLabel string `json:"priority_label"`
DeadlineAt string `json:"deadline_at,omitempty"`
Message string `json:"message"`
}
// BuildQuickNoteToolBundle 构建“AI随口记”工具包。
// 这是 agent 目录给上层编排层chain/graph/react提供的统一入口。
func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) {
// 1. 启动期做依赖校验,尽早暴露 wiring 问题,避免运行时才 panic。
if err := deps.validate(); err != nil {
return nil, err
}
// 2. 通过 InferTool 把 Go 函数声明成“模型可调用工具”。
// 该闭包函数是工具的真实执行体,后续所有参数校验都在这里兜底。
createTaskTool, err := toolutils.InferTool(
ToolNameQuickNoteCreateTask,
ToolDescQuickNoteCreateTask,
func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) {
// 2.1 防御式检查:工具调用参数不能为 nil。
if input == nil {
return nil, errors.New("工具参数不能为空")
}
// 2.2 标题与优先级是写库硬条件,必须先校验。
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("title 不能为空")
}
if !IsValidTaskPriority(input.PriorityGroup) {
return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup)
}
// 这里对 deadline_at 做“强校验”:
// - 空值允许(代表没有截止时间);
// - 非空但无法解析直接报错,避免把有问题的时间静默写成 NULL。
deadline, err := parseOptionalDeadline(input.DeadlineAt)
if err != nil {
return nil, err
}
urgencyThresholdAt, err := parseOptionalDeadline(input.UrgencyThresholdAt)
if err != nil {
return nil, err
}
// 2.3 user_id 一律来自鉴权上下文,不信任模型侧入参,防止越权写别人的任务。
userID, err := deps.ResolveUserID(ctx)
if err != nil {
return nil, fmt.Errorf("解析用户身份失败: %w", err)
}
if userID <= 0 {
return nil, fmt.Errorf("非法 user_id=%d", userID)
}
// 2.4 走业务层写库。
result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{
UserID: userID,
Title: title,
PriorityGroup: input.PriorityGroup,
DeadlineAt: deadline,
UrgencyThresholdAt: urgencyThresholdAt,
})
if err != nil {
return nil, err
}
if result == nil || result.TaskID <= 0 {
return nil, errors.New("写入任务后返回结果异常")
}
// 2.5 结果归一化:优先使用业务层返回值,其次回退到入参,保证输出稳定可读。
finalTitle := title
if strings.TrimSpace(result.Title) != "" {
finalTitle = strings.TrimSpace(result.Title)
}
finalPriority := input.PriorityGroup
if IsValidTaskPriority(result.PriorityGroup) {
finalPriority = result.PriorityGroup
}
// 2.6 截止时间输出统一为 RFC3339便于跨系统传输与调试。
deadlineStr := ""
if result.DeadlineAt != nil {
deadlineStr = result.DeadlineAt.In(quickNoteLocation()).Format(time.RFC3339)
} else if deadline != nil {
deadlineStr = deadline.In(quickNoteLocation()).Format(time.RFC3339)
}
// 2.7 组装给模型的结构化结果,包含可直接面向用户的 message 草稿。
return &QuickNoteCreateTaskToolOutput{
TaskID: result.TaskID,
Title: finalTitle,
PriorityGroup: finalPriority,
PriorityLabel: PriorityLabelCN(finalPriority),
DeadlineAt: deadlineStr,
Message: fmt.Sprintf("已记录:%s%s", finalTitle, PriorityLabelCN(finalPriority)),
}, nil
},
)
if err != nil {
return nil, fmt.Errorf("构建随口记工具失败: %w", err)
}
// 3. Tools 给执行节点使用ToolInfos 给模型注册 schema 使用,二者都要返回。
tools := []tool.BaseTool{createTaskTool}
infos, err := collectToolInfos(ctx, tools)
if err != nil {
return nil, err
}
return &QuickNoteToolBundle{
Tools: tools,
ToolInfos: infos,
}, nil
}
func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) {
// 按工具列表顺序提取 ToolInfo确保“tools[idx] <-> infos[idx]”一一对应。
infos := make([]*schema.ToolInfo, 0, len(tools))
for _, t := range tools {
info, err := t.Info(ctx)
if err != nil {
return nil, fmt.Errorf("读取工具信息失败: %w", err)
}
infos = append(infos, info)
}
return infos, nil
}
// parseOptionalDeadline 解析工具输入中的可选截止时间。
// 该入口用于“工具参数强校验”:只要调用方给了非空 deadline_at就必须能被解析。
func parseOptionalDeadline(raw string) (*time.Time, error) {
// 1. 先做标点与空白归一化,避免中文输入噪声影响解析。
value := normalizeDeadlineInput(raw)
if value == "" {
// 2. 空字符串合法,表示任务无截止时间。
return nil, nil
}
// 3. 统一按“严格模式”解析:给了时间就必须成功解析。
deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute())
if err != nil {
return nil, err
}
if deadline == nil {
// 4. 区分“无时间线索”和“有线索但不支持”,返回更准确错误信息。
if !hasHint {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return deadline, nil
}
// parseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
// 该函数保持“严格模式”:非空字符串无法解析时会直接返回 error。
func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
// 场景:模型已给出 deadline_at需要基于同一 requestNow 再次硬校验。
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
}
deadline, _, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
return nil, err
}
if deadline == nil {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return deadline, nil
}
// parseOptionalDeadlineFromUserInput 是“用户原句解析”的宽松入口。
// 返回值说明:
// - deadline != nil成功解析出时间
// - hasHint=false 且 err=nil文本里没有明显时间线索应视为“用户没给时间”
// - hasHint=true 且 err!=nil用户给了时间但格式非法应提示用户修正不应落库。
func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) {
// 场景:解析用户原始句子时,允许“没给时间”,但不允许“给了错误时间却静默通过”。
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, false, nil
}
deadline, hasHint, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
if hasHint {
// 有时间线索 + 解析失败:上层应明确提示用户改时间格式。
return nil, true, err
}
// 无明显时间线索:按“未提供时间”处理。
return nil, false, nil
}
if deadline == nil {
if hasHint {
return nil, true, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return nil, false, nil
}
return deadline, true, nil
}
// parseOptionalDeadlineFromText 是内部通用解析器。
// 解析顺序:
// 1) 绝对时间(明确年月日时分);
// 2) 相对时间(明天/下周一/今晚);
// 3) 若识别到时间线索但仍失败,返回 hasHint=true + error交给上层决定是否拦截。
func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) {
if strings.TrimSpace(value) == "" {
return nil, false, nil
}
// 1. 统一时区与时间基准,保证相对时间可重复计算。
loc := quickNoteLocation()
now = now.In(loc)
hasHint := hasDeadlineHint(value)
// 2. 先尝试绝对时间(优先级更高,歧义更小)。
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
return abs, true, nil
}
// 3. 再尝试相对时间(明天/下周一/今晚)。
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
if err != nil {
return nil, true, err
}
return rel, true, nil
}
// 4. 到这里仍失败时,根据 hasHint 决定返回“软失败”还是“硬失败”。
if hasHint {
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, false, nil
}
// normalizeDeadlineInput 把中文标点和空白先归一化,降低格式解析的噪声。
func normalizeDeadlineInput(raw string) string {
// 先 trim避免纯空格输入影响后续逻辑。
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
// 将中文标点统一成英文形态,降低正则和 layout 解析复杂度。
replacer := strings.NewReplacer(
"", ":",
"", ",",
"。", ".",
" ", " ",
)
return strings.TrimSpace(replacer.Replace(trimmed))
}
// hasDeadlineHint 判断文本里是否存在“时间相关线索”。
// 该函数的意义是区分两种情况:
// 1) 用户根本没给时间(允许 deadline 为空);
// 2) 用户给了时间但写错(必须提示修正,不能静默写 NULL
func hasDeadlineHint(value string) bool {
// 1. 先用结构化正则快速判断(时间格式、日期格式、周几格式)。
if quickNoteClockHMRegex.MatchString(value) ||
quickNoteClockCNRegex.MatchString(value) ||
quickNoteYMDRegex.MatchString(value) ||
quickNoteMDRegex.MatchString(value) ||
quickNoteDateSepRegex.MatchString(value) ||
quickNoteWeekdayRegex.MatchString(value) {
return true
}
// 2. 再用词元判断“明天/今晚”等语义线索。
for _, token := range quickNoteRelativeTokens {
if strings.Contains(value, token) {
return true
}
}
return false
}
// tryParseAbsoluteDeadline 尝试按绝对时间格式解析。
// 若只提供日期(无时分),默认归一到当天 23:59表示“当日截止”。
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
// 逐个 layout 尝试,命中即返回。
for _, layout := range quickNoteDeadlineLayouts {
var (
t time.Time
err error
)
if layout == time.RFC3339 {
t, err = time.Parse(layout, value)
if err == nil {
t = t.In(loc)
}
} else {
t, err = time.ParseInLocation(layout, value, loc)
}
if err != nil {
continue
}
// Date-only 输入(例如 2026-03-20默认补到 23:59。
if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly {
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 0, 0, loc)
} else {
// 非 date-only 则统一清零秒级,保持分钟粒度一致。
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc)
}
return &t, true
}
return nil, false
}
// tryParseRelativeDeadline 尝试解析“相对时间 + 可选时刻”。
// 例子:
// - 明天交报告(默认 23:59
// - 下周一上午9点开会解析为下周一 09:00
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
// 1. 先确定“哪一天”。
baseDate, recognized := inferBaseDate(value, now, loc)
if !recognized {
return nil, false, nil
}
// 2. 再解析“几点几分”,若缺失则按语义默认时刻兜底。
hour, minute, hasExplicitClock, err := extractClock(value)
if err != nil {
return nil, true, err
}
if !hasExplicitClock {
hour, minute = defaultClockByHint(value)
}
deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc)
return &deadline, true, nil
}
// inferBaseDate 负责先确定“哪一天”。
// 解析优先级:
// 1) 明确年月日;
// 2) 月日(自动推断年份);
// 3) 周几表达(本周/下周);
// 4) 明天/后天/今晚等相对词。
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
// 1) yyyy年MM月dd日
if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 {
year, _ := strconv.Atoi(matched[1])
month, _ := strconv.Atoi(matched[2])
day, _ := strconv.Atoi(matched[3])
if isValidDate(year, month, day) {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true
}
}
// 2) MM月dd日自动推断年份若今年已过则滚到明年
if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 {
month, _ := strconv.Atoi(matched[1])
day, _ := strconv.Atoi(matched[2])
year := now.Year()
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
if candidate.Before(startOfDay(now)) {
year++
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
}
return candidate, true
}
// 3) 本周/下周 + 周几
if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
prefix := matched[1]
target, ok := toWeekday(matched[2])
if ok {
return resolveWeekdayDate(now, prefix, target), true
}
}
// 4) 今天/明天/后天/大后天/昨天等相对词
today := startOfDay(now)
switch {
case strings.Contains(value, "大后天"):
return today.AddDate(0, 0, 3), true
case strings.Contains(value, "后天"):
return today.AddDate(0, 0, 2), true
case strings.Contains(value, "明天") || strings.Contains(value, "明日"):
return today.AddDate(0, 0, 1), true
case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"):
return today, true
case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"):
return today.AddDate(0, 0, -1), true
default:
return time.Time{}, false
}
}
// extractClock 从文本提取时刻(时/分)。
// 支持:
// - 24h 表达18:30
// - 中文表达3点、3点半、3点20分
func extractClock(value string) (int, int, bool, error) {
// hour/minute 最终会用于 time.Date需要先做范围约束。
hour := 0
minute := 0
hasClock := false
// 1) 24 小时制18:30
if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
h, errH := strconv.Atoi(matched[1])
m, errM := strconv.Atoi(matched[2])
if errH != nil || errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = m
hasClock = true
} else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
// 2) 中文时刻3点 / 3点半 / 3点20分
h, errH := strconv.Atoi(matched[1])
if errH != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = 0
hasClock = true
if len(matched) >= 3 {
if matched[2] == "半" {
minute = 30
} else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" {
m, errM := strconv.Atoi(strings.TrimSpace(matched[3]))
if errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
minute = m
}
}
}
if !hasClock {
// 没有显式时刻并不是错误,交给默认时刻策略处理。
return 0, 0, false, nil
}
// 3) 根据“下午/晚上/中午/凌晨”等语义修正 12/24 小时制。
if isPMHint(value) && hour < 12 {
hour += 12
}
if isNoonHint(value) && hour >= 1 && hour <= 10 {
hour += 12
}
if strings.Contains(value, "凌晨") && hour == 12 {
hour = 0
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value)
}
return hour, minute, true, nil
}
// defaultClockByHint 当文本只给了“日期/相对日”但没给具体时刻时,按语义兜底。
func defaultClockByHint(value string) (int, int) {
// 没有明确时刻时按中文语义设置一个“可解释的默认值”。
switch {
case strings.Contains(value, "凌晨"):
return 1, 0
case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"):
return 9, 0
case strings.Contains(value, "中午"):
return 12, 0
case strings.Contains(value, "下午"):
return 15, 0
case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"):
return 20, 0
default:
// 只给了日期没有具体时刻时,默认当天结束前。
return 23, 59
}
}
func isPMHint(value string) bool {
// 下午/晚上/傍晚通常应映射到 12:00 之后。
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
}
func isNoonHint(value string) bool {
// “中午 1 点”这类表达通常是 13:00 而非 01:00。
return strings.Contains(value, "中午")
}
func startOfDay(t time.Time) time.Time {
// 保留原时区,只把时分秒归零。
loc := t.Location()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
func isValidDate(year, month, day int) bool {
// 先做快速范围筛,再用 time.Date 回填校验闰月闰年和越界日期。
if month < 1 || month > 12 || day < 1 || day > 31 {
return false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day
}
func toWeekday(chinese string) (time.Weekday, bool) {
// 把中文周几映射到 Go 的 Weekday 枚举。
switch chinese {
case "一":
return time.Monday, true
case "二":
return time.Tuesday, true
case "三":
return time.Wednesday, true
case "四":
return time.Thursday, true
case "五":
return time.Friday, true
case "六":
return time.Saturday, true
case "日", "天":
return time.Sunday, true
default:
return time.Sunday, false
}
}
// resolveWeekdayDate 根据“本周/下周 + 周几”换算目标日期。
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
// 1. 先定位本周周一。
today := startOfDay(now)
weekdayOffset := (int(today.Weekday()) + 6) % 7
weekStart := today.AddDate(0, 0, -weekdayOffset)
targetOffset := (int(target) + 6) % 7
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
// 2. 再根据“本周/下周/无前缀”选择最终日期。
switch {
case strings.HasPrefix(prefix, "下"):
return candidateThisWeek.AddDate(0, 0, 7)
case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"):
return candidateThisWeek
default:
if candidateThisWeek.Before(today) {
return candidateThisWeek.AddDate(0, 0, 7)
}
return candidateThisWeek
}
}

View File

@@ -1,123 +0,0 @@
package quicknote
import (
"testing"
"time"
)
func TestParseOptionalDeadlineWithNow_Absolute(t *testing.T) {
loc := quickNoteLocation()
now := time.Date(2026, 3, 12, 10, 15, 0, 0, loc)
deadline, err := parseOptionalDeadlineWithNow("2026-03-20 18:30", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if deadline == nil {
t.Fatalf("deadline should not be nil")
}
want := time.Date(2026, 3, 20, 18, 30, 0, 0, loc)
if !deadline.Equal(want) {
t.Fatalf("unexpected deadline, got=%s want=%s", deadline.Format(time.RFC3339), want.Format(time.RFC3339))
}
}
func TestParseOptionalDeadlineWithNow_RelativeTomorrowWithoutClock(t *testing.T) {
loc := quickNoteLocation()
now := time.Date(2026, 3, 12, 10, 15, 0, 0, loc)
deadline, err := parseOptionalDeadlineWithNow("明天交计网实验报告", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if deadline == nil {
t.Fatalf("deadline should not be nil")
}
want := time.Date(2026, 3, 13, 23, 59, 0, 0, loc)
if !deadline.Equal(want) {
t.Fatalf("unexpected deadline, got=%s want=%s", deadline.Format(time.RFC3339), want.Format(time.RFC3339))
}
}
func TestParseOptionalDeadlineWithNow_RelativeTomorrowWithClock(t *testing.T) {
loc := quickNoteLocation()
now := time.Date(2026, 3, 12, 10, 15, 0, 0, loc)
deadline, err := parseOptionalDeadlineWithNow("明天下午3点交计网实验报告", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if deadline == nil {
t.Fatalf("deadline should not be nil")
}
want := time.Date(2026, 3, 13, 15, 0, 0, 0, loc)
if !deadline.Equal(want) {
t.Fatalf("unexpected deadline, got=%s want=%s", deadline.Format(time.RFC3339), want.Format(time.RFC3339))
}
}
func TestParseOptionalDeadlineWithNow_RelativeWeekday(t *testing.T) {
loc := quickNoteLocation()
now := time.Date(2026, 3, 12, 10, 15, 0, 0, loc) // 周四
deadline, err := parseOptionalDeadlineWithNow("下周一上午9点开组会", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if deadline == nil {
t.Fatalf("deadline should not be nil")
}
want := time.Date(2026, 3, 16, 9, 0, 0, 0, loc)
if !deadline.Equal(want) {
t.Fatalf("unexpected deadline, got=%s want=%s", deadline.Format(time.RFC3339), want.Format(time.RFC3339))
}
}
func TestParseOptionalDeadlineFromUserInput_NoHint(t *testing.T) {
loc := quickNoteLocation()
now := time.Date(2026, 3, 12, 10, 15, 0, 0, loc)
deadline, hasHint, err := parseOptionalDeadlineFromUserInput("帮我记一下要复习计网", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasHint {
t.Fatalf("expected no time hint")
}
if deadline != nil {
t.Fatalf("deadline should be nil when no time hint")
}
}
func TestParseOptionalDeadlineFromUserInput_InvalidDate(t *testing.T) {
loc := quickNoteLocation()
now := time.Date(2026, 3, 12, 10, 15, 0, 0, loc)
deadline, hasHint, err := parseOptionalDeadlineFromUserInput("2026-13-45 25:99 交实验", now)
if err == nil {
t.Fatalf("expected error but got nil")
}
if !hasHint {
t.Fatalf("expected hasHint=true")
}
if deadline != nil {
t.Fatalf("deadline should be nil for invalid date")
}
}
func TestParseOptionalDeadlineWithNow_Invalid(t *testing.T) {
loc := quickNoteLocation()
now := time.Date(2026, 3, 12, 10, 15, 0, 0, loc)
deadline, err := parseOptionalDeadlineWithNow("记得尽快处理", now)
if err == nil {
t.Fatalf("expected error but got nil")
}
if deadline != nil {
t.Fatalf("deadline should be nil for invalid input")
}
}

View File

@@ -1,279 +0,0 @@
package route
import (
"context"
"fmt"
"log"
"regexp"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
const (
// ControlTimeout 表示“路由控制码”阶段的额外超时预算。
// 说明:
// 1. 设为 0 表示完全继承父 ctx 的 deadline不额外截断。
// 2. 若后续观察到路由阶段偶发超时,可按需配置一个小预算(例如 2s
ControlTimeout = 0 * time.Second
)
var (
// routeHeaderRegex 用于解析控制码头部。
// 支持动作:
// 1. quick_note_create新增随口记任务。
// 2. task_query任务查询。
// 3. schedule_plan_create新建排程。
// 4. schedule_plan_refine连续对话微调排程。
// 5. schedule_plan历史兼容动作解析后映射到 schedule_plan_create
// 6. quick_note历史兼容动作解析后映射到 quick_note_create
// 7. chat普通聊天。
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|schedule_plan_create|schedule_plan_refine|schedule_plan|quick_note|chat)["']?[^>]*>`)
// routeReasonRegex 用于提取可选 reason便于日志排障。
routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`)
)
const routeControlPrompt = `你是 SmartFlow 的请求分流控制器。
你的唯一任务是给后端返回“可机读控制码”,不要做用户可见回复,不要解释。
动作定义:
1) quick_note_create用户明确要“帮我记一下/安排一个未来要做的事/提醒我”。
2) task_query用户要“查任务、筛任务、按条件列任务”。
3) schedule_plan_create用户要“新建/生成一份排程方案”。
4) schedule_plan_refine用户要“基于已有排程做连续微调”如挪动某天、限制某时段、局部改动
5) chat其余普通聊天与讨论。
优先级(冲突时按顺序):
1) quick_note_create
2) task_query
3) schedule_plan_refine
4) schedule_plan_create
5) chat
输出格式必须严格如下(两行):
<SMARTFLOW_ROUTE nonce="给定nonce" action="quick_note_create|task_query|schedule_plan_create|schedule_plan_refine|chat"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>一句不超过30字的中文理由</SMARTFLOW_REASON>
禁止输出任何其他内容。`
// Action 表示分流动作。
type Action string
const (
ActionChat Action = "chat"
ActionQuickNoteCreate Action = "quick_note_create"
ActionTaskQuery Action = "task_query"
ActionSchedulePlanCreate Action = "schedule_plan_create"
ActionSchedulePlanRefine Action = "schedule_plan_refine"
// ActionSchedulePlan 是历史兼容动作值。
// 说明:旧模型可能返回 schedule_plan解析后统一映射到 schedule_plan_create。
ActionSchedulePlan Action = "schedule_plan"
// ActionQuickNote 是历史兼容动作值,解析后统一映射到 quick_note_create。
ActionQuickNote Action = "quick_note"
)
// ControlDecision 表示“模型控制码解析结果”。
type ControlDecision struct {
Action Action
Reason string
Raw string
}
// RoutingDecision 是服务层使用的统一分流结果。
// 职责边界:
// 1. Action最终动作chat/quick_note_create/task_query/schedule_plan_create/schedule_plan_refine
// 2. TrustRoute是否允许下游跳过二次意图判定。
// 3. Detail可选说明用于阶段提示或日志。
// 4. RouteFailed标记“控制码路由是否失败”供上层决定是否直接报错。
type RoutingDecision struct {
Action Action
TrustRoute bool
Detail string
RouteFailed bool
}
// DecideActionRouting 通过“模型控制码”决定本次请求走向。
// 返回语义:
// 1. Action=quick_note_create进入随口记链路。
// 2. Action=task_query进入任务查询链路。
// 3. Action=schedule_plan_create进入新建排程链路。
// 4. Action=schedule_plan_refine进入连续微调链路。
// 5. Action=chat进入普通聊天链路。
// 6. 路由失败时标记 RouteFailed=true由上层统一处理。
func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
decision, err := routeByModelControlTag(ctx, selectedModel, userMessage)
if err != nil {
if deadline, ok := ctx.Deadline(); ok {
log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d",
err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds())
} else {
log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline=none route_timeout_ms=%d",
err, ControlTimeout.Milliseconds())
}
return RoutingDecision{
Action: ActionChat,
TrustRoute: false,
Detail: "",
RouteFailed: true,
}
}
switch decision.Action {
case ActionQuickNoteCreate:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到新增任务请求,准备执行随口记流程。"
}
return RoutingDecision{Action: ActionQuickNoteCreate, TrustRoute: true, Detail: reason, RouteFailed: false}
case ActionTaskQuery:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到任务查询请求,准备执行任务查询流程。"
}
return RoutingDecision{Action: ActionTaskQuery, TrustRoute: true, Detail: reason, RouteFailed: false}
case ActionSchedulePlanCreate:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到新建排程请求,准备执行智能排程流程。"
}
return RoutingDecision{Action: ActionSchedulePlanCreate, TrustRoute: true, Detail: reason, RouteFailed: false}
case ActionSchedulePlanRefine:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到排程微调请求,准备执行连续微调流程。"
}
return RoutingDecision{Action: ActionSchedulePlanRefine, TrustRoute: true, Detail: reason, RouteFailed: false}
case ActionChat:
return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: false}
default:
log.Printf("通用分流出现未知动作,标记路由失败并等待上层报错: action=%s raw=%s", decision.Action, decision.Raw)
return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: true}
}
}
func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) (*ControlDecision, error) {
if selectedModel == nil {
return nil, fmt.Errorf("model is nil")
}
nonce := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", ""))
routeCtx, cancel := deriveRouteControlContext(ctx, ControlTimeout)
defer cancel()
nowText := time.Now().In(time.Local).Format("2006-01-02 15:04")
userPrompt := fmt.Sprintf("nonce=%s\n当前时间=%s\n用户输入=%s", nonce, nowText, strings.TrimSpace(userMessage))
resp, err := selectedModel.Generate(routeCtx, []*schema.Message{
schema.SystemMessage(routeControlPrompt),
schema.UserMessage(userPrompt),
},
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0),
einoModel.WithMaxTokens(120),
)
if err != nil {
return nil, err
}
if resp == nil {
return nil, fmt.Errorf("empty route response")
}
raw := strings.TrimSpace(resp.Content)
if raw == "" {
return nil, fmt.Errorf("empty route content")
}
return ParseRouteControlTag(raw, nonce)
}
// deriveRouteControlContext 为“控制码路由”创建子上下文。
// 设计要点:
// 1. timeout<=0 时不加额外 deadline仅继承父上下文。
// 2. 父 ctx deadline 更紧时,沿用父上下文,避免过早超时误判。
func deriveRouteControlContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout <= 0 {
return context.WithCancel(parent)
}
if deadline, ok := parent.Deadline(); ok {
if time.Until(deadline) <= timeout {
return context.WithCancel(parent)
}
}
return context.WithTimeout(parent, timeout)
}
// ParseRouteControlTag 解析通用控制码返回。
// 容错策略:
// 1. 允许大小写、属性顺序、额外属性差异;
// 2. nonce 必须精确匹配;
// 3. 兼容旧 action 值schedule_plan/quick_note
func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, fmt.Errorf("route content is empty")
}
header := routeHeaderRegex.FindStringSubmatch(text)
if len(header) < 3 {
return nil, fmt.Errorf("route header not found: %s", text)
}
nonce := strings.ToLower(strings.TrimSpace(header[1]))
if nonce != strings.ToLower(strings.TrimSpace(expectedNonce)) {
return nil, fmt.Errorf("route nonce mismatch")
}
actionText := strings.ToLower(strings.TrimSpace(header[2]))
action := Action(actionText)
switch action {
case ActionQuickNoteCreate, ActionTaskQuery, ActionSchedulePlanCreate, ActionSchedulePlanRefine, ActionChat:
// 合法动作直接通过。
case ActionQuickNote:
action = ActionQuickNoteCreate
case ActionSchedulePlan:
action = ActionSchedulePlanCreate
default:
return nil, fmt.Errorf("invalid route action: %s", actionText)
}
reason := ""
reasonMatch := routeReasonRegex.FindStringSubmatch(text)
if len(reasonMatch) >= 2 {
reason = strings.TrimSpace(reasonMatch[1])
}
return &ControlDecision{
Action: action,
Reason: reason,
Raw: text,
}, nil
}
// DecideQuickNoteRouting 是历史兼容入口。
// 说明:
// 1. 旧代码只区分“是否进入 quick_note”
// 2. 新分流中 task_query/schedule_plan_* 都不应进入 quick_note。
func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
decision := DecideActionRouting(ctx, selectedModel, userMessage)
if decision.Action == ActionQuickNoteCreate {
return decision
}
return RoutingDecision{
Action: ActionChat,
TrustRoute: false,
Detail: "",
RouteFailed: decision.RouteFailed,
}
}
// ParseQuickNoteRouteControlTag 是历史兼容解析入口。
// 说明:旧测试仍使用该方法名,内部统一委托 ParseRouteControlTag。
func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
return ParseRouteControlTag(raw, expectedNonce)
}

View File

@@ -1,45 +0,0 @@
package route
import "testing"
func TestParseRouteControlTag_SchedulePlanCreate(t *testing.T) {
nonce := "nonce-create"
raw := `<SMARTFLOW_ROUTE nonce="nonce-create" action="schedule_plan_create"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>新建排程</SMARTFLOW_REASON>`
decision, err := ParseRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision.Action != ActionSchedulePlanCreate {
t.Fatalf("action 不匹配,期望=%s 实际=%s", ActionSchedulePlanCreate, decision.Action)
}
}
func TestParseRouteControlTag_SchedulePlanRefine(t *testing.T) {
nonce := "nonce-refine"
raw := `<SMARTFLOW_ROUTE nonce="nonce-refine" action="schedule_plan_refine"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>微调排程</SMARTFLOW_REASON>`
decision, err := ParseRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision.Action != ActionSchedulePlanRefine {
t.Fatalf("action 不匹配,期望=%s 实际=%s", ActionSchedulePlanRefine, decision.Action)
}
}
func TestParseRouteControlTag_LegacySchedulePlan(t *testing.T) {
nonce := "nonce-legacy"
raw := `<SMARTFLOW_ROUTE nonce="nonce-legacy" action="schedule_plan"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>兼容旧动作</SMARTFLOW_REASON>`
decision, err := ParseRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision.Action != ActionSchedulePlanCreate {
t.Fatalf("旧动作映射错误,期望=%s 实际=%s", ActionSchedulePlanCreate, decision.Action)
}
}

View File

@@ -8,7 +8,7 @@ import (
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/google/uuid"
)
@@ -59,11 +59,11 @@ const routeControlPrompt = `你是 SmartFlow 的请求分流控制器。
禁止输出任何其他内容`
// Action 是 agent2 路由层对业务动作的统一命名。
// Action 是 Agent 路由层对业务动作的统一命名。
//
// 这里直接定义在 router 包,而不是复用旧 route 包:
// 1. 当前这轮迁移要求只有 router 可以保留对旧链路的兼容语义;
// 2. chat / quicknote 已经要完全切到 agent2,自然不该再依赖旧包常量;
// 2. chat / quicknote 已经要完全切到 Agent自然不该再依赖旧包常量
// 3. schedule/taskquery 尚未搬迁完成时,也能继续靠这些常量在 service 层做统一分发。
type Action string
@@ -173,7 +173,7 @@ func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, u
userPrompt := fmt.Sprintf("nonce=%s\n当前时间=%s\n用户输入=%s", nonce, nowText, strings.TrimSpace(userMessage))
// 1. 调用目的:路由场景只需要稳定、短文本、禁用 thinking 的结构化输出。
// 2. 这里复用 agent2 公共 LLM 封装,删除与 quicknote 重复的 JSON/文本调用样板代码。
// 2. 这里复用 Agent 公共 LLM 封装,删除与 quicknote 重复的 JSON/文本调用样板代码。
resp, err := agentllm.CallArkText(routeCtx, selectedModel, routeControlPrompt, userPrompt, agentllm.ArkCallOptions{
Temperature: 0,
MaxTokens: 120,

View File

@@ -6,7 +6,7 @@ import (
"fmt"
)
// Dispatcher 是 agent2 的统一分发器。
// Dispatcher 是 Agent 的统一分发器。
type Dispatcher struct {
resolver Resolver
handlers map[Action]SkillHandler

View File

@@ -12,7 +12,7 @@ type Resolver interface {
// SkillHandler 是某个 skill 的统一执行入口。
type SkillHandler func(ctx context.Context, req *AgentRequest) (*AgentResponse, error)
// AgentRequest 是 agent2 路由层可见的最小请求结构。
// AgentRequest 是 Agent 路由层可见的最小请求结构。
//
// 设计目的:
// 1. 让 router 层只依赖自己真正关心的字段;

View File

@@ -1,315 +0,0 @@
package scheduleplan
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
const (
// dailyReactRoundTimeout 是日内单轮模型调用超时。
// 日内节点走并发调用,超时要比周级更保守,避免占满资源。
dailyReactRoundTimeout = 3 * time.Minute
)
// runDailyRefineNode 负责“并发日内优化”。
//
// 职责边界:
// 1. 负责按 DayGroup 并发调用单日 ReAct
// 2. 负责输出“按天开始/完成”的阶段状态块(不推 reasoning 细流);
// 3. 负责把单日失败回退到原始数据,确保全链路可继续;
// 4. 不负责跨天配平(交给 weekly_refine不负责最终总结交给 final_check
func runDailyRefineNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
dailyRefineConcurrency int,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil || len(st.DailyGroups) == 0 {
return st, nil
}
if chatModel == nil {
return st, fmt.Errorf("schedule plan daily refine: model is nil")
}
// 1. 并发度兜底:
// 1.1 优先使用注入参数;
// 1.2 若注入参数非法,则回退到 state 值;
// 1.3 state 也非法时,回退到编译期默认值。
if dailyRefineConcurrency <= 0 {
dailyRefineConcurrency = st.DailyRefineConcurrency
}
if dailyRefineConcurrency <= 0 {
dailyRefineConcurrency = schedulePlanDefaultDailyRefineConcurrency
}
emitStage(
"schedule_plan.daily_refine.start",
fmt.Sprintf("正在并发优化各天日程,并发度=%d。", dailyRefineConcurrency),
)
// 2. 拉平所有 DayGroup 并排序,确保日志与阶段输出稳定可读。
allGroups := flattenAndSortDayGroups(st.DailyGroups)
if len(allGroups) == 0 {
st.DailyResults = make(map[int]map[int][]model.HybridScheduleEntry)
emitStage("schedule_plan.daily_refine.done", "没有可优化的天,跳过日内优化。")
return st, nil
}
// 3. 并发执行:
// 3.1 sem 控制并发上限;
// 3.2 wg 等待全部 goroutine 完成;
// 3.3 mu 保护 results/firstErr避免竞态。
sem := make(chan struct{}, dailyRefineConcurrency)
var wg sync.WaitGroup
var mu sync.Mutex
totalGroups := int32(len(allGroups))
var finishedGroups int32
results := make(map[int]map[int][]model.HybridScheduleEntry)
var firstErr error
for _, group := range allGroups {
g := group
wg.Add(1)
go func() {
defer wg.Done()
// 3.4 先申请并发令牌;若 ctx 已取消,直接回退原始数据并结束。
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
mu.Lock()
if firstErr == nil {
firstErr = ctx.Err()
}
ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
mu.Unlock()
// 3.4.1 取消场景也要计入进度,避免前端看到“卡住不动”。
done := atomic.AddInt32(&finishedGroups, 1)
emitStage(
"schedule_plan.daily_refine.day_done",
fmt.Sprintf("W%dD%d 已取消并回退原方案。(进度 %d/%d", g.Week, g.DayOfWeek, done, totalGroups),
)
return
}
emitStage(
"schedule_plan.daily_refine.day_start",
fmt.Sprintf("正在安排 W%dD%d。当前进度 %d/%d", g.Week, g.DayOfWeek, atomic.LoadInt32(&finishedGroups), totalGroups),
)
// 3.5 低收益天直接跳过模型调用,原样透传。
if g.SkipRefine {
mu.Lock()
ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
mu.Unlock()
done := atomic.AddInt32(&finishedGroups, 1)
emitStage(
"schedule_plan.daily_refine.day_done",
fmt.Sprintf("W%dD%d suggested 较少,已跳过优化。(进度 %d/%d", g.Week, g.DayOfWeek, done, totalGroups),
)
return
}
// 3.6 深拷贝输入,避免并发场景下意外修改共享切片。
localEntries := deepCopyEntries(g.Entries)
// 3.7 动态轮次:
// 3.7.1 suggested <= 41轮足够
// 3.7.2 suggested > 4最多2轮提升复杂天优化质量。
maxRounds := 1
if countSuggested(localEntries) > 4 {
maxRounds = 2
}
optimized, refineErr := runSingleDayReact(ctx, chatModel, localEntries, maxRounds, g.Week, g.DayOfWeek)
if refineErr != nil {
mu.Lock()
if firstErr == nil {
firstErr = refineErr
}
// 3.8 单天失败回退:
// 3.8.1 保证失败只影响该天;
// 3.8.2 保证总流程可继续推进到 merge/weekly/final。
ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
mu.Unlock()
done := atomic.AddInt32(&finishedGroups, 1)
emitStage(
"schedule_plan.daily_refine.day_done",
fmt.Sprintf("W%dD%d 优化失败,已回退原方案。(进度 %d/%d", g.Week, g.DayOfWeek, done, totalGroups),
)
return
}
mu.Lock()
ensureDayResult(results, g.Week, g.DayOfWeek, optimized)
mu.Unlock()
done := atomic.AddInt32(&finishedGroups, 1)
emitStage(
"schedule_plan.daily_refine.day_done",
fmt.Sprintf("W%dD%d 已安排完成。(进度 %d/%d", g.Week, g.DayOfWeek, done, totalGroups),
)
}()
}
wg.Wait()
st.DailyResults = results
if firstErr != nil {
emitStage("schedule_plan.daily_refine.partial_error", fmt.Sprintf("部分天优化失败,已自动回退。原因:%s", firstErr.Error()))
}
emitStage("schedule_plan.daily_refine.done", "日内优化阶段完成。")
return st, nil
}
// runSingleDayReact 执行单天封闭式 ReAct 优化。
//
// 关键约束:
// 1. prompt 只包含当天数据;
// 2. 代码层再做“Move 不能跨天”硬校验;
// 3. Thinking 默认关闭,优先降低日内并发阶段的长尾时延。
func runSingleDayReact(
ctx context.Context,
chatModel *ark.ChatModel,
entries []model.HybridScheduleEntry,
maxRounds int,
week int,
dayOfWeek int,
) ([]model.HybridScheduleEntry, error) {
hybridJSON, err := json.Marshal(entries)
if err != nil {
return entries, err
}
messages := []*schema.Message{
schema.SystemMessage(SchedulePlanDailyReactPrompt),
schema.UserMessage(fmt.Sprintf(
"以下是今天的日程JSON\n%s\n\n仅优化这一天的数据不要跨天移动。",
string(hybridJSON),
)),
}
for round := 0; round < maxRounds; round++ {
roundCtx, cancel := context.WithTimeout(ctx, dailyReactRoundTimeout)
resp, generateErr := chatModel.Generate(
roundCtx,
messages,
// 1. 日内优化只做“单天局部微调”,任务边界清晰,默认关闭 thinking 以降低时延。
// 2. 周级全局配平仍保留 thinking在 weekly_refine这里不承担跨天复杂推理职责。
// 3. 若后续观测到质量回退,可只在 suggested 很多时按条件重开 thinking而不是全量开启。
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
)
cancel()
if generateErr != nil {
return entries, fmt.Errorf("日内 ReAct 第%d轮失败: %w", round+1, generateErr)
}
if resp == nil {
return entries, fmt.Errorf("日内 ReAct 第%d轮返回为空", round+1)
}
content := strings.TrimSpace(resp.Content)
parsed, parseErr := parseReactLLMOutput(content)
if parseErr != nil {
// 解析失败时回退当前轮,不把异常向上放大成整条链路失败。
return entries, nil
}
if parsed.Done || len(parsed.ToolCalls) == 0 {
break
}
// 1. 执行工具调用。
// 1.1 每个调用都经过“日内策略约束”校验;
// 1.2 任何单次调用失败都只返回 failed result不中断整轮。
results := make([]reactToolResult, 0, len(parsed.ToolCalls))
for _, call := range parsed.ToolCalls {
var result reactToolResult
entries, result = dispatchDailyReactTool(entries, call, week, dayOfWeek)
results = append(results, result)
}
// 2. 把“本轮模型输出 + 工具执行结果”拼入下一轮上下文。
// 2.1 这样模型可以看到操作反馈,继续迭代;
// 2.2 若下一轮仍无有效动作,会自然在 done/空 tool_calls 退出。
messages = append(messages, schema.AssistantMessage(content, nil))
resultJSON, _ := json.Marshal(results)
messages = append(messages, schema.UserMessage(
fmt.Sprintf("工具执行结果:\n%s\n\n请继续优化或输出 {\"done\":true,\"summary\":\"...\"}。", string(resultJSON)),
))
}
return entries, nil
}
// dispatchDailyReactTool 在通用工具分发前增加“日内硬约束”。
//
// 职责边界:
// 1. 只负责校验 Move 的目标是否仍在当前天;
// 2. 通过后复用 dispatchReactTool 执行;
// 3. 不负责复杂冲突判定(冲突判定由底层工具函数处理)。
func dispatchDailyReactTool(entries []model.HybridScheduleEntry, call reactToolCall, week int, dayOfWeek int) ([]model.HybridScheduleEntry, reactToolResult) {
if call.Tool == "Move" {
toWeek, weekOK := paramInt(call.Params, "to_week")
toDay, dayOK := paramInt(call.Params, "to_day")
if !weekOK || !dayOK {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: "参数缺失to_week/to_day",
}
}
if toWeek != week || toDay != dayOfWeek {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: fmt.Sprintf("日内优化禁止跨天移动:当前仅允许 W%dD%d", week, dayOfWeek),
}
}
}
return dispatchReactTool(entries, call)
}
// flattenAndSortDayGroups 把 map 结构摊平成有序切片,便于稳定并发调度。
func flattenAndSortDayGroups(groups map[int]map[int]*DayGroup) []*DayGroup {
out := make([]*DayGroup, 0)
for _, dayMap := range groups {
for _, g := range dayMap {
if g != nil {
out = append(out, g)
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Week != out[j].Week {
return out[i].Week < out[j].Week
}
return out[i].DayOfWeek < out[j].DayOfWeek
})
return out
}
// ensureDayResult 确保 results[week][day] 存在并写入值。
func ensureDayResult(results map[int]map[int][]model.HybridScheduleEntry, week int, day int, entries []model.HybridScheduleEntry) {
if results[week] == nil {
results[week] = make(map[int][]model.HybridScheduleEntry)
}
results[week][day] = entries
}
// deepCopyEntries 深拷贝 HybridScheduleEntry 切片。
func deepCopyEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
dst := make([]model.HybridScheduleEntry, len(src))
copy(dst, src)
return dst
}

View File

@@ -1,93 +0,0 @@
package scheduleplan
import (
"context"
"fmt"
)
// runDailySplitNode 负责“按天拆分 + 标签注入 + 跳过判断”。
//
// 职责边界:
// 1. 负责把全量 HybridEntries 拆成 DayGroup供后续并发日内优化
// 2. 负责把 TaskTags(task_item_id -> tag) 注入到条目的 ContextTag
// 3. 负责识别“低收益天”suggested<=2并标记 SkipRefine
// 4. 不负责调用模型,不负责并发执行,不负责结果合并。
func runDailySplitNode(
ctx context.Context,
st *SchedulePlanState,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
_ = ctx
if st == nil || len(st.HybridEntries) == 0 {
return st, nil
}
emitStage("schedule_plan.daily_split.start", "正在按天拆分排程并标记优化单元。")
// 1. 初始化容器:
// 1.1 groups 以 week/day 二级索引保存 DayGroup
// 1.2 这么做的目的是后续 daily_refine 可以直接并发遍历,不再重复分组。
groups := make(map[int]map[int]*DayGroup)
// 2. 遍历混合条目,执行“标签注入 + 分组”。
for i := range st.HybridEntries {
entry := &st.HybridEntries[i]
// 2.1 仅对 suggested 条目注入 ContextTag。
// 2.1.1 existing 条目是固定课表/已落库任务,不参与认知标签优化。
// 2.1.2 注入失败时兜底 General避免后续 prompt 出现空标签。
if entry.Status == "suggested" && entry.TaskItemID > 0 {
if tag, ok := st.TaskTags[entry.TaskItemID]; ok {
entry.ContextTag = normalizeContextTag(tag)
} else {
entry.ContextTag = "General"
}
}
// 2.2 建立分组索引。
if groups[entry.Week] == nil {
groups[entry.Week] = make(map[int]*DayGroup)
}
if groups[entry.Week][entry.DayOfWeek] == nil {
groups[entry.Week][entry.DayOfWeek] = &DayGroup{
Week: entry.Week,
DayOfWeek: entry.DayOfWeek,
}
}
groups[entry.Week][entry.DayOfWeek].Entries = append(groups[entry.Week][entry.DayOfWeek].Entries, *entry)
}
// 3. 逐天计算 suggested 数量,标记是否跳过日内优化。
//
// 3.1 为什么阈值设为 <=2
// 3.1.1 suggested 很少时,模型优化收益通常不足以覆盖请求成本;
// 3.1.2 直接跳过可减少无效模型调用和阶段等待。
// 3.2 失败策略:
// 3.2.1 这里只做内存标记,不会失败;
// 3.2.2 即使阈值判断不完美,也只影响优化深度,不影响功能正确性。
totalDays := 0
skipDays := 0
for _, dayMap := range groups {
for _, dayGroup := range dayMap {
totalDays++
suggestedCount := 0
for _, e := range dayGroup.Entries {
if e.Status == "suggested" {
suggestedCount++
}
}
if suggestedCount <= 2 {
dayGroup.SkipRefine = true
skipDays++
}
}
}
// 4. 回填状态,交给后续节点使用。
st.DailyGroups = groups
emitStage(
"schedule_plan.daily_split.done",
fmt.Sprintf("已拆分为 %d 天,其中 %d 天跳过日内优化。", totalDays, skipDays),
)
return st, nil
}

View File

@@ -1,171 +0,0 @@
package scheduleplan
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// runFinalCheckNode 负责“终审校验 + 总结生成”。
//
// 职责边界:
// 1. 负责执行物理校验(冲突、节次越界、数量核对);
// 2. 负责在校验失败时回退到 MergeSnapshot
// 3. 负责生成最终给用户看的自然语言总结;
// 4. 不负责写库(本期只做预览)。
func runFinalCheckNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, fmt.Errorf("schedule plan final check: nil state")
}
emitStage("schedule_plan.final_check.start", "正在进行终审校验。")
// 1. 先做物理校验。
issues := physicsCheck(st)
if len(issues) > 0 {
emitStage("schedule_plan.final_check.issues", fmt.Sprintf("发现 %d 个问题,已回退到日内优化结果。", len(issues)))
// 1.1 回退策略:
// 1.1.1 优先回退到 merge 快照(已经过冲突校验);
// 1.1.2 若快照为空,保留当前结果继续走总结,保证可返回。
if len(st.MergeSnapshot) > 0 {
st.HybridEntries = deepCopyEntries(st.MergeSnapshot)
}
}
// 2. 生成人性化总结。
//
// 2.1 总结失败不影响主流程;
// 2.2 失败时使用兜底文案,保证前端始终有可展示文本。
summary, err := generateHumanSummary(ctx, chatModel, st.HybridEntries, st.Constraints, st.WeeklyActionLogs)
if err != nil || strings.TrimSpace(summary) == "" {
st.FinalSummary = fmt.Sprintf("排程优化完成,共安排了 %d 个任务。", countSuggested(st.HybridEntries))
} else {
st.FinalSummary = strings.TrimSpace(summary)
}
emitStage("schedule_plan.final_check.done", "终审校验完成。")
return st, nil
}
// physicsCheck 执行物理层面校验。
//
// 校验项:
// 1. 时间冲突:同一 slot 不允许多任务占用;
// 2. 节次越界section 必须落在 1..12 且 from<=to
// 3. 数量核对suggested 数量应与原始 AllocatedItems 数量一致。
func physicsCheck(st *SchedulePlanState) []string {
issues := make([]string, 0)
if st == nil {
return append(issues, "state 为空")
}
// 1. 时间冲突校验。
if conflict := detectConflicts(st.HybridEntries); conflict != "" {
issues = append(issues, "时间冲突:"+conflict)
}
// 2. 节次越界校验。
for _, entry := range st.HybridEntries {
if entry.SectionFrom < 1 || entry.SectionTo > 12 || entry.SectionFrom > entry.SectionTo {
issues = append(
issues,
fmt.Sprintf("节次越界:[%s] W%dD%d 第%d-%d节", entry.Name, entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo),
)
}
}
// 3. 数量一致性校验。
// 3.1 判断依据suggested 表示“待应用任务块”,应与 allocatedItems 数量匹配;
// 3.2 若不匹配,可能表示工具调用丢失或重复覆盖。
suggestedCount := countSuggested(st.HybridEntries)
if suggestedCount != len(st.AllocatedItems) {
issues = append(
issues,
fmt.Sprintf("任务数量不匹配suggested=%d原始分配=%d", suggestedCount, len(st.AllocatedItems)),
)
}
return issues
}
// countSuggested 统计 suggested 条目数量。
func countSuggested(entries []model.HybridScheduleEntry) int {
count := 0
for _, entry := range entries {
if entry.Status == "suggested" {
count++
}
}
return count
}
// generateHumanSummary 调用模型生成“用户可读”的总结文案。
//
// 职责边界:
// 1. 只做读模型,不修改任何 state
// 2. 输出纯文本;
// 3. 失败时把错误返回给上层,由上层决定兜底文案。
func generateHumanSummary(
ctx context.Context,
chatModel *ark.ChatModel,
entries []model.HybridScheduleEntry,
constraints []string,
actionLogs []string,
) (string, error) {
if chatModel == nil {
return "", fmt.Errorf("final summary model is nil")
}
entriesJSON, _ := json.Marshal(entries)
constraintText := "无"
if len(constraints) > 0 {
constraintText = strings.Join(constraints, "、")
}
actionLogText := "无"
if len(actionLogs) > 0 {
// 1. 只取最后 30 条动作日志,避免上下文无限膨胀。
// 2. 周级优化是“渐进式动作链”,取尾部更能体现最终收敛过程。
// 3. 这里仅做展示收敛,不改原日志,保证调试信息完整保留在 state 中。
start := 0
if len(actionLogs) > 30 {
start = len(actionLogs) - 30
}
actionLogText = strings.Join(actionLogs[start:], "\n")
}
userPrompt := fmt.Sprintf(
"以下是最终排程方案JSON\n%s\n\n用户约束%s\n\n以下是本次周级优化动作日志按时间顺序\n%s\n\n请基于“结果+过程”输出2-3句自然中文总结重点说明本方案的优点和改进点。",
string(entriesJSON),
constraintText,
actionLogText,
)
resp, err := chatModel.Generate(
ctx,
[]*schema.Message{
schema.SystemMessage(SchedulePlanFinalCheckPrompt),
schema.UserMessage(userPrompt),
},
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0.4),
einoModel.WithMaxTokens(256),
)
if err != nil {
return "", err
}
if resp == nil {
return "", fmt.Errorf("final summary response is nil")
}
return strings.TrimSpace(resp.Content), nil
}

View File

@@ -1,210 +0,0 @@
package scheduleplan
import (
"context"
"errors"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
)
const (
// 图节点:意图识别与约束提取
schedulePlanGraphNodePlan = "schedule_plan_plan"
// 图节点:粗排构建(替代旧 preview + hybridBuild
schedulePlanGraphNodeRoughBuild = "schedule_plan_rough_build"
// 图节点:提前退出
schedulePlanGraphNodeExit = "schedule_plan_exit"
// 图节点:按天拆分并注入上下文标签
schedulePlanGraphNodeDailySplit = "schedule_plan_daily_split"
// 图节点:小改动快速微调(用于 small scope
schedulePlanGraphNodeQuickRefine = "schedule_plan_quick_refine"
// 图节点:并发日内优化
schedulePlanGraphNodeDailyRefine = "schedule_plan_daily_refine"
// 图节点:合并日内优化结果
schedulePlanGraphNodeMerge = "schedule_plan_merge"
// 图节点:周级配平优化(单步动作模式,输出阶段状态)
schedulePlanGraphNodeWeeklyRefine = "schedule_plan_weekly_refine"
// 图节点:终审校验
schedulePlanGraphNodeFinalCheck = "schedule_plan_final_check"
// 图节点:返回预览结果(不落库)
schedulePlanGraphNodeReturnPreview = "schedule_plan_return_preview"
)
// SchedulePlanGraphRunInput 是执行“智能排程 graph”所需输入。
//
// 字段说明:
// 1. Extra前端附加参数重点是 task_class_ids
// 2. ChatHistory支持连续对话微调
// 3. OutChan/ModelName保留兼容字段当前 weekly refine 主要输出阶段状态);
// 4. DailyRefineConcurrency/WeeklyAdjustBudget可选运行参数覆盖。
type SchedulePlanGraphRunInput struct {
Model *ark.ChatModel
State *SchedulePlanState
Deps SchedulePlanToolDeps
UserMessage string
Extra map[string]any
ChatHistory []*schema.Message
EmitStage func(stage, detail string)
OutChan chan<- string
ModelName string
DailyRefineConcurrency int
WeeklyAdjustBudget int
}
// RunSchedulePlanGraph 执行“智能排程”图编排。
//
// 当前链路:
// START
// -> plan
// -> roughBuild
// -> (len(task_class_ids)>=2 ? dailySplit -> dailyRefine -> merge : weeklyRefine)
// -> finalCheck
// -> returnPreview
// -> END
//
// 说明:
// 1. exit 分支可从 plan/roughBuild 直接提前终止;
// 2. 本文件只负责“连线与分支”,节点内业务都在 nodes/daily/weekly 文件中。
func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) (*SchedulePlanState, error) {
// 1. 启动前硬校验。
if input.Model == nil {
return nil, errors.New("schedule plan graph: model is nil")
}
if input.State == nil {
return nil, errors.New("schedule plan graph: state is nil")
}
if err := input.Deps.validate(); err != nil {
return nil, err
}
// 2. 注入运行时配置(可选覆盖)。
if input.DailyRefineConcurrency > 0 {
input.State.DailyRefineConcurrency = input.DailyRefineConcurrency
}
if input.WeeklyAdjustBudget > 0 {
input.State.WeeklyAdjustBudget = input.WeeklyAdjustBudget
}
emitStage := func(stage, detail string) {
if input.EmitStage != nil {
input.EmitStage(stage, detail)
}
}
runner := newSchedulePlanRunner(
input.Model,
input.Deps,
emitStage,
input.UserMessage,
input.Extra,
input.ChatHistory,
input.OutChan,
input.ModelName,
input.State.DailyRefineConcurrency,
)
graph := compose.NewGraph[*SchedulePlanState, *SchedulePlanState]()
// 3. 注册节点。
if err := graph.AddLambdaNode(schedulePlanGraphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeRoughBuild, compose.InvokableLambda(runner.roughBuildNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeExit, compose.InvokableLambda(runner.exitNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeDailySplit, compose.InvokableLambda(runner.dailySplitNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeQuickRefine, compose.InvokableLambda(runner.quickRefineNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeDailyRefine, compose.InvokableLambda(runner.dailyRefineNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeMerge, compose.InvokableLambda(runner.mergeNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeWeeklyRefine, compose.InvokableLambda(runner.weeklyRefineNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeFinalCheck, compose.InvokableLambda(runner.finalCheckNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(schedulePlanGraphNodeReturnPreview, compose.InvokableLambda(runner.returnPreviewNode)); err != nil {
return nil, err
}
// 4. 连线START -> plan
if err := graph.AddEdge(compose.START, schedulePlanGraphNodePlan); err != nil {
return nil, err
}
// 5. plan 分支roughBuild | exit
if err := graph.AddBranch(schedulePlanGraphNodePlan, compose.NewGraphBranch(
runner.nextAfterPlan,
map[string]bool{
schedulePlanGraphNodeRoughBuild: true,
schedulePlanGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
// 6. roughBuild 分支dailySplit | weeklyRefine | exit
if err := graph.AddBranch(schedulePlanGraphNodeRoughBuild, compose.NewGraphBranch(
runner.nextAfterRoughBuild,
map[string]bool{
schedulePlanGraphNodeDailySplit: true,
schedulePlanGraphNodeQuickRefine: true,
schedulePlanGraphNodeWeeklyRefine: true,
schedulePlanGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
// 7. 固定边quickRefine -> weeklyRefinedailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END
if err := graph.AddEdge(schedulePlanGraphNodeQuickRefine, schedulePlanGraphNodeWeeklyRefine); err != nil {
return nil, err
}
if err := graph.AddEdge(schedulePlanGraphNodeDailySplit, schedulePlanGraphNodeDailyRefine); err != nil {
return nil, err
}
if err := graph.AddEdge(schedulePlanGraphNodeDailyRefine, schedulePlanGraphNodeMerge); err != nil {
return nil, err
}
if err := graph.AddEdge(schedulePlanGraphNodeMerge, schedulePlanGraphNodeWeeklyRefine); err != nil {
return nil, err
}
if err := graph.AddEdge(schedulePlanGraphNodeWeeklyRefine, schedulePlanGraphNodeFinalCheck); err != nil {
return nil, err
}
if err := graph.AddEdge(schedulePlanGraphNodeFinalCheck, schedulePlanGraphNodeReturnPreview); err != nil {
return nil, err
}
if err := graph.AddEdge(schedulePlanGraphNodeReturnPreview, compose.END); err != nil {
return nil, err
}
if err := graph.AddEdge(schedulePlanGraphNodeExit, compose.END); err != nil {
return nil, err
}
// 8. 编译并执行。
// 路径最多约 8~9 个节点,保守预留 20 步避免误判。
runnable, err := graph.Compile(ctx,
compose.WithGraphName("SchedulePlanGraph"),
compose.WithMaxRunSteps(20),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return nil, err
}
return runnable.Invoke(ctx, input.State)
}

View File

@@ -1,86 +0,0 @@
package scheduleplan
import (
"context"
"fmt"
"github.com/LoveLosita/smartflow/backend/model"
)
// runMergeNode 负责“合并日内结果 + 冲突校验 + 回退快照”。
//
// 职责边界:
// 1. 负责把 DailyResults 合并回全量 HybridEntries
// 2. 负责执行时间冲突检测;
// 3. 负责在冲突时回退原始数据;
// 4. 负责产出 MergeSnapshot供 final_check 失败时回退。
func runMergeNode(
ctx context.Context,
st *SchedulePlanState,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
_ = ctx
if st == nil || len(st.DailyResults) == 0 {
return st, nil
}
emitStage("schedule_plan.merge.start", "正在合并日内优化结果。")
// 1. 先保存 merge 前原始数据,作为冲突时的第一层回退兜底。
originalEntries := deepCopyEntries(st.HybridEntries)
// 2. 展平 daily results。
merged := make([]model.HybridScheduleEntry, 0)
for _, dayMap := range st.DailyResults {
for _, dayEntries := range dayMap {
merged = append(merged, dayEntries...)
}
}
// 3. 冲突校验。
//
// 3.1 判断依据:同一 (week, day, section) 只能有一个条目占用;
// 3.2 失败处理:一旦冲突,整批回退到 merge 前原始结果;
// 3.3 回退策略:回退后仍继续链路,避免请求直接失败。
if conflict := detectConflicts(merged); conflict != "" {
st.HybridEntries = originalEntries
emitStage("schedule_plan.merge.conflict", fmt.Sprintf("检测到冲突并回退:%s", conflict))
} else {
st.HybridEntries = merged
emitStage("schedule_plan.merge.done", fmt.Sprintf("合并完成,共 %d 个条目。", len(merged)))
}
// 4. 无论是否冲突,都生成“可回退快照”。
st.MergeSnapshot = deepCopyEntries(st.HybridEntries)
return st, nil
}
// detectConflicts 检测条目是否存在时间冲突。
//
// 返回语义:
// 1. 返回空字符串:无冲突;
// 2. 返回非空字符串:冲突描述,可直接用于日志/阶段提示。
func detectConflicts(entries []model.HybridScheduleEntry) string {
type slotKey struct {
week, day, section int
}
occupied := make(map[slotKey]string)
for _, entry := range entries {
// 1. 仅“阻塞建议任务”的条目参与冲突校验。
// 2. 可嵌入且当前未占用的课程槽位不应被判定为冲突。
if !entryBlocksSuggested(entry) {
continue
}
for section := entry.SectionFrom; section <= entry.SectionTo; section++ {
key := slotKey{week: entry.Week, day: entry.DayOfWeek, section: section}
if prevName, exists := occupied[key]; exists {
return fmt.Sprintf(
"W%dD%d 第%d节 冲突:[%s] 与 [%s]",
entry.Week, entry.DayOfWeek, section, prevName, entry.Name,
)
}
occupied[key] = entry.Name
}
}
return ""
}

View File

@@ -1,767 +0,0 @@
package scheduleplan
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// schedulePlanIntentOutput 是 plan 节点要求模型返回的结构化结果。
//
// 兼容说明:
// 1. 新主语义是 task_class_ids数组
// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id单值兜底解析
// 3. TaskTags 的 key 兼容两种写法:
// 3.1 推荐task_item_id例如 "12"
// 3.2 兼容:任务名称(例如 "高数复习")。
type schedulePlanIntentOutput struct {
Intent string `json:"intent"`
Constraints []string `json:"constraints"`
TaskClassIDs []int `json:"task_class_ids"`
TaskClassID int `json:"task_class_id"`
Strategy string `json:"strategy"`
TaskTags map[string]string `json:"task_tags"`
Restart bool `json:"restart"`
AdjustmentScope string `json:"adjustment_scope"`
Reason string `json:"reason"`
Confidence float64 `json:"confidence"`
}
// runPlanNode 负责“识别排程意图 + 提取约束 + 收敛任务类 ID”。
//
// 职责边界:
// 1. 负责把用户自然语言和 extra 参数收敛为统一状态;
// 2. 负责输出后续节点需要的最小上下文TaskClassIDs/约束/策略/标签);
// 3. 不负责调用粗排算法,不负责写库。
func runPlanNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
userMessage string,
extra map[string]any,
chatHistory []*schema.Message,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in plan node")
}
st.RestartRequested = false
st.AdjustmentReason = ""
st.AdjustmentConfidence = 0
st.AdjustmentScope = schedulePlanAdjustmentScopeLarge
emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求。")
// 1. 先收敛 extra 中显式传入的任务类 ID优先级高于模型推断
// 1.1 先读 task_class_ids 数组;
// 1.2 再兼容读取单值 task_class_id
// 1.3 最后统一做过滤 + 去重,防止非法值或重复值污染状态机。
if extra != nil {
mergedIDs := make([]int, 0, len(st.TaskClassIDs)+2)
mergedIDs = append(mergedIDs, st.TaskClassIDs...)
if tcIDs, ok := ExtraIntSlice(extra, "task_class_ids"); ok {
mergedIDs = append(mergedIDs, tcIDs...)
}
if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 {
mergedIDs = append(mergedIDs, tcID)
}
st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs)
}
// 1.4 若本轮请求没带 task_class_ids但会话里存在上一次排程快照则用快照中的任务类兜底。
// 1.4.1 这样用户可以直接说“把周三晚上的高数挪到周五”,无需每轮都重复传任务类集合;
// 1.4.2 失败兜底:若快照也没有任务类,后续按原逻辑处理(可能提前退出并提示补参)。
if len(st.TaskClassIDs) == 0 && len(st.PreviousTaskClassIDs) > 0 {
st.TaskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...))
}
// 2. 识别“是否为连续对话微调”场景。
// 2.1 只做历史探测,不做历史改写;
// 2.2 探测失败不影响主链路,只是少一个 prompt hint。
if st.HasPreviousPreview && len(st.PreviousHybridEntries) > 0 {
st.IsAdjustment = true
st.AdjustmentScope = schedulePlanAdjustmentScopeMedium
}
previousPlan := extractPreviousPlanFromHistory(chatHistory)
if previousPlan != "" {
st.PreviousPlanJSON = previousPlan
st.IsAdjustment = true
st.AdjustmentScope = schedulePlanAdjustmentScopeMedium
}
// 3. 组装模型提示词。
adjustmentHint := ""
if st.IsAdjustment {
adjustmentHint = "\n注意这是对已有排程的微调请求请重点抽取本次新增或变更的约束。"
}
prompt := fmt.Sprintf(
"当前时间(北京时间):%s\n用户输入%s%s\n\n请提取排程意图与约束。",
st.RequestNowText,
strings.TrimSpace(userMessage),
adjustmentHint,
)
// 4. 调模型拿结构化输出。
// 4.1 如果失败但已经有 TaskClassIDs则降级继续
// 4.2 如果失败且没有任务类 ID直接给出可执行错误提示。
raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanIntentPrompt, prompt, 256)
if callErr != nil {
if len(st.TaskClassIDs) > 0 {
st.UserIntent = strings.TrimSpace(userMessage)
emitStage("schedule_plan.plan.fallback", "意图识别失败,已使用请求参数兜底继续。")
return st, nil
}
st.FinalSummary = "抱歉,我没拿到有效的任务类信息。请在请求中传入 task_class_ids。"
return st, nil
}
parsed, parseErr := parseScheduleJSON[schedulePlanIntentOutput](raw)
if parseErr != nil {
if len(st.TaskClassIDs) > 0 {
st.UserIntent = strings.TrimSpace(userMessage)
emitStage("schedule_plan.plan.fallback", "模型返回解析失败,已使用请求参数兜底继续。")
return st, nil
}
st.FinalSummary = "抱歉,我没能解析排程意图。请重试,或直接传入 task_class_ids。"
return st, nil
}
// 5. 回填基础字段。
st.UserIntent = strings.TrimSpace(parsed.Intent)
if st.UserIntent == "" {
st.UserIntent = strings.TrimSpace(userMessage)
}
if len(parsed.Constraints) > 0 {
st.Constraints = parsed.Constraints
}
if strings.EqualFold(strings.TrimSpace(parsed.Strategy), "rapid") {
st.Strategy = "rapid"
}
st.RestartRequested = parsed.Restart
st.AdjustmentScope = normalizeAdjustmentScope(parsed.AdjustmentScope)
st.AdjustmentReason = strings.TrimSpace(parsed.Reason)
st.AdjustmentConfidence = clampAdjustmentConfidence(parsed.Confidence)
// 5.1 分级语义兜底:
// 5.1.1 非微调请求不走 small/medium强制按 large 进入完整排程;
// 5.1.2 微调请求默认至少走 medium避免 scope 缺失时误判;
// 5.1.3 restart=true 时强制重排并清空历史快照承接。
if !st.IsAdjustment {
st.AdjustmentScope = schedulePlanAdjustmentScopeLarge
} else if st.AdjustmentScope == "" {
st.AdjustmentScope = schedulePlanAdjustmentScopeMedium
}
if st.RestartRequested {
st.IsAdjustment = false
st.AdjustmentScope = schedulePlanAdjustmentScopeLarge
st.clearPreviousPreviewContext()
}
// 6. 合并任务类 ID新字段 + 旧字段双兼容)。
// 6.1 先拼接已有值与模型输出;
// 6.2 再统一清洗,保证后续节点使用稳定语义。
mergedIDs := make([]int, 0, len(st.TaskClassIDs)+len(parsed.TaskClassIDs)+1)
mergedIDs = append(mergedIDs, st.TaskClassIDs...)
mergedIDs = append(mergedIDs, parsed.TaskClassIDs...)
if parsed.TaskClassID > 0 {
mergedIDs = append(mergedIDs, parsed.TaskClassID)
}
st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs)
// 7. 回填任务标签映射(给 daily_split 注入 context_tag 用)。
// 7.1 TaskTags按 task_item_id优先
// 7.2 无法转成 ID 的 key 先存到 TaskTagHintsByName等 roughBuild 阶段再映射;
// 7.3 单条标签解析失败不影响主流程。
if st.TaskTags == nil {
st.TaskTags = make(map[int]string)
}
if st.TaskTagHintsByName == nil {
st.TaskTagHintsByName = make(map[string]string)
}
for rawKey, rawTag := range parsed.TaskTags {
tag := normalizeContextTag(rawTag)
key := strings.TrimSpace(rawKey)
if key == "" {
continue
}
if id, convErr := strconv.Atoi(key); convErr == nil && id > 0 {
st.TaskTags[id] = tag
continue
}
st.TaskTagHintsByName[key] = tag
}
emitStage(
"schedule_plan.plan.done",
fmt.Sprintf(
"已识别排程意图,任务类数量=%d微调=%t力度=%s重排=%t。",
len(st.TaskClassIDs),
st.IsAdjustment,
st.AdjustmentScope,
st.RestartRequested,
),
)
return st, nil
}
// selectNextAfterPlan 根据 plan 节点结果决定下一步。
//
// 分支规则:
// 1. 如果 FinalSummary 已经有内容,说明已确定要提前退出 -> exit
// 2. 如果任务类为空,说明无法继续构建方案 -> exit
// 3. 其余情况 -> roughBuild。
func selectNextAfterPlan(st *SchedulePlanState) string {
if st == nil {
return schedulePlanGraphNodeExit
}
if strings.TrimSpace(st.FinalSummary) != "" {
return schedulePlanGraphNodeExit
}
if len(st.TaskClassIDs) == 0 {
return schedulePlanGraphNodeExit
}
return schedulePlanGraphNodeRoughBuild
}
// runRoughBuildNode 负责“一次性完成粗排结果构建”。
//
// 职责边界:
// 1. 调用多任务类混排能力,生成 HybridEntries + AllocatedItems
// 2. 把 HybridEntries 转成 CandidatePlans便于后续预览输出
// 3. 不做 daily/weekly 优化本身,只提供下游输入。
func runRoughBuildNode(
ctx context.Context,
st *SchedulePlanState,
deps SchedulePlanToolDeps,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
if st == nil {
return nil, errors.New("schedule plan graph: nil state in roughBuild node")
}
if deps.HybridScheduleWithPlanMulti == nil {
return nil, errors.New("schedule plan graph: HybridScheduleWithPlanMulti dependency not injected")
}
// 1. 清洗并校验任务类 ID。
// 1.1 统一在节点入口做一次最终收敛,避免上游遗漏导致语义漂移;
// 1.2 若最终仍为空,直接结束,避免无意义调用下游服务。
taskClassIDs := normalizeTaskClassIDs(st.TaskClassIDs)
// 1.3 连续对话兜底:若本轮任务类为空且命中历史快照,则回退到上轮任务类集合。
if len(taskClassIDs) == 0 && st.IsAdjustment && len(st.PreviousTaskClassIDs) > 0 {
taskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...))
}
if len(taskClassIDs) == 0 {
st.FinalSummary = "缺少有效的任务类 ID无法生成排程方案。请传入 task_class_ids。"
return st, nil
}
st.TaskClassIDs = taskClassIDs
// 2. 连续对话微调优先复用上一版混合日程作为起点,避免“每轮都重新粗排”。
// 2.1 触发条件IsAdjustment=true 且 PreviousHybridEntries 非空;
// 2.2 失败兜底:若快照不完整(例如 AllocatedItems 为空),会构造最小占位任务块,保持下游校验可运行;
// 2.3 回退策略:若没有可复用快照,再走全量粗排构建路径。
canReusePreviousPlan := st.IsAdjustment &&
!st.RestartRequested &&
len(st.PreviousHybridEntries) > 0 &&
sameTaskClassSet(taskClassIDs, st.PreviousTaskClassIDs)
if canReusePreviousPlan {
emitStage("schedule_plan.rough_build.reuse_previous", "检测到连续对话微调,复用上一版排程作为优化起点。")
st.HybridEntries = deepCopyEntries(st.PreviousHybridEntries)
st.CandidatePlans = deepCopyWeekSchedules(st.PreviousCandidatePlans)
if len(st.CandidatePlans) == 0 {
st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
}
st.AllocatedItems = deepCopyTaskClassItems(st.PreviousAllocatedItems)
if len(st.AllocatedItems) == 0 {
st.AllocatedItems = buildAllocatedItemsFromHybridEntries(st.HybridEntries)
}
// 2.2 复用模式下同样尝试解析窗口边界,保证周级 Move 约束仍然有效。
if deps.ResolvePlanningWindow != nil {
startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs)
if windowErr != nil {
st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error())
return st, nil
}
st.HasPlanningWindow = true
st.PlanStartWeek = startWeek
st.PlanStartDay = startDay
st.PlanEndWeek = endWeek
st.PlanEndDay = endDay
}
st.MergeSnapshot = deepCopyEntries(st.HybridEntries)
suggestedCount := 0
for _, e := range st.HybridEntries {
if e.Status == "suggested" {
suggestedCount++
}
}
emitStage(
"schedule_plan.rough_build.done",
fmt.Sprintf("已复用历史方案,条目总数=%d可优化条目=%d。", len(st.HybridEntries), suggestedCount),
)
return st, nil
}
emitStage("schedule_plan.rough_build.building", "正在构建粗排候选方案。")
// 3. 调用服务层统一能力构建混合日程。
// 3.1 该能力内部会完成“多任务类粗排 + 既有日程合并”;
// 3.2 这里不再拆成 preview/hybrid 两段,避免跨节点重复计算。
entries, allocatedItems, err := deps.HybridScheduleWithPlanMulti(ctx, st.UserID, taskClassIDs)
if err != nil {
st.FinalSummary = fmt.Sprintf("构建粗排方案失败:%s。", err.Error())
return st, nil
}
if len(entries) == 0 {
st.FinalSummary = "没有生成可优化的排程条目,请检查任务类时间范围或课表占用。"
return st, nil
}
// 4. 回填状态。
st.HybridEntries = entries
st.AllocatedItems = allocatedItems
st.CandidatePlans = hybridEntriesToWeekSchedules(entries)
// 4.1 解析全局排程窗口(可选依赖)。
// 4.1.1 目的:给周级 Move 增加“首尾不足一周”的硬边界校验;
// 4.1.2 失败策略:若依赖已注入但解析失败,直接结束本次排程,避免带着错误窗口继续优化。
if deps.ResolvePlanningWindow != nil {
startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs)
if windowErr != nil {
st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error())
return st, nil
}
st.HasPlanningWindow = true
st.PlanStartWeek = startWeek
st.PlanStartDay = startDay
st.PlanEndWeek = endWeek
st.PlanEndDay = endDay
}
// 4.2 记录 merge 快照:
// 4.2.1 单任务类路径可直接作为 final_check 回退基线;
// 4.2.2 多任务类路径后续 merge 节点会覆盖成“日内优化后快照”。
st.MergeSnapshot = deepCopyEntries(entries)
// 5. 把“按名称提示的标签”尽可能映射到 task_item_id。
// 5.1 目的:后续 daily_split 统一按 task_item_id 维度写入 context_tag
// 5.2 失败策略:映射不上不报错,后续默认走 General 标签。
if st.TaskTags == nil {
st.TaskTags = make(map[int]string)
}
if len(st.TaskTagHintsByName) > 0 {
for i := range st.HybridEntries {
entry := &st.HybridEntries[i]
if entry.Status != "suggested" || entry.TaskItemID <= 0 {
continue
}
if _, exists := st.TaskTags[entry.TaskItemID]; exists {
continue
}
if tag, ok := st.TaskTagHintsByName[entry.Name]; ok {
st.TaskTags[entry.TaskItemID] = normalizeContextTag(tag)
}
}
}
suggestedCount := 0
for _, e := range entries {
if e.Status == "suggested" {
suggestedCount++
}
}
emitStage(
"schedule_plan.rough_build.done",
fmt.Sprintf("粗排构建完成,条目总数=%d可优化条目=%d。", len(entries), suggestedCount),
)
return st, nil
}
// callScheduleModelForJSON 调用模型并要求返回 JSON。
//
// 职责边界:
// 1. 仅负责模型调用参数装配,不做业务字段解释;
// 2. 统一关闭 thinking减少路由/抽取场景的延迟和 token 开销。
func callScheduleModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
if chatModel == nil {
return "", errors.New("schedule plan: model is nil")
}
messages := []*schema.Message{
schema.SystemMessage(systemPrompt),
schema.UserMessage(userPrompt),
}
opts := []einoModel.Option{
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0),
}
if maxTokens > 0 {
opts = append(opts, einoModel.WithMaxTokens(maxTokens))
}
resp, err := chatModel.Generate(ctx, messages, opts...)
if err != nil {
return "", err
}
if resp == nil {
return "", errors.New("模型返回为空")
}
content := strings.TrimSpace(resp.Content)
if content == "" {
return "", errors.New("模型返回内容为空")
}
return content, nil
}
// parseScheduleJSON 解析模型返回的 JSON 内容。
//
// 兼容策略:
// 1. 兼容 ```json ... ``` 包裹;
// 2. 兼容模型在 JSON 前后带解释文本(提取最外层对象)。
func parseScheduleJSON[T any](raw string) (*T, error) {
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, errors.New("empty response")
}
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
var out T
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
start := strings.Index(clean, "{")
end := strings.LastIndex(clean, "}")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("no json object found in: %s", clean)
}
obj := clean[start : end+1]
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, err
}
return &out, nil
}
// extractPreviousPlanFromHistory 从对话历史中提取最近一次排程结果文本。
func extractPreviousPlanFromHistory(history []*schema.Message) string {
if len(history) == 0 {
return ""
}
for i := len(history) - 1; i >= 0; i-- {
msg := history[i]
if msg == nil || msg.Role != schema.Assistant {
continue
}
content := strings.TrimSpace(msg.Content)
if strings.Contains(content, "排程完成") || strings.Contains(content, "已成功安排") {
return content
}
}
return ""
}
// runReturnPreviewNode 负责把优化后的 HybridEntries 转成“前端可直接展示”的预览结构。
//
// 职责边界:
// 1. 把 suggested 结果回填到 AllocatedItems便于后续确认后直接落库
// 2. 生成 CandidatePlans
// 3. 生成最终文案;
// 4. 不执行实际写库。
func runReturnPreviewNode(
ctx context.Context,
st *SchedulePlanState,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
_ = ctx
if st == nil {
return nil, errors.New("schedule plan graph: nil state in returnPreview node")
}
emitStage("schedule_plan.preview_return.building", "正在生成优化后的排程预览。")
// 1. 把 HybridEntries 中 suggested 的最终位置回填到 AllocatedItems。
suggestedMap := make(map[int]*model.HybridScheduleEntry)
for i := range st.HybridEntries {
e := &st.HybridEntries[i]
if e.Status == "suggested" && e.TaskItemID > 0 {
suggestedMap[e.TaskItemID] = e
}
}
for i := range st.AllocatedItems {
item := &st.AllocatedItems[i]
if entry, ok := suggestedMap[item.ID]; ok && item.EmbeddedTime != nil {
item.EmbeddedTime.Week = entry.Week
item.EmbeddedTime.DayOfWeek = entry.DayOfWeek
item.EmbeddedTime.SectionFrom = entry.SectionFrom
item.EmbeddedTime.SectionTo = entry.SectionTo
}
}
// 2. 生成前端预览结构。
st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
// 3. 生成最终摘要:
// 3.1 优先保留 final_check 的输出;
// 3.2 若没有 final_check 输出,则回退 weekly refine 摘要;
// 3.3 都没有时给兜底文案。
if strings.TrimSpace(st.FinalSummary) == "" {
if strings.TrimSpace(st.ReactSummary) != "" {
st.FinalSummary = st.ReactSummary
} else {
st.FinalSummary = fmt.Sprintf("排程优化完成,共 %d 个任务已安排,请确认后应用。", len(suggestedMap))
}
}
st.Completed = true
emitStage("schedule_plan.preview_return.done", "排程预览已生成,等待你确认。")
return st, nil
}
// buildAllocatedItemsFromHybridEntries 根据 suggested 条目构造最小可用的任务块快照。
//
// 设计目的:
// 1. 连续微调复用历史方案时,若缓存里没有 AllocatedItems仍然保证 final_check 的数量核对可运行;
// 2. return_preview 仍可依据 TaskItemID 回填最终 embedded_time
// 3. 该函数只做“兜底构造”,不替代真实粗排输出。
func buildAllocatedItemsFromHybridEntries(entries []model.HybridScheduleEntry) []model.TaskClassItem {
if len(entries) == 0 {
return nil
}
items := make([]model.TaskClassItem, 0)
for _, entry := range entries {
if entry.Status != "suggested" {
continue
}
embedded := &model.TargetTime{
Week: entry.Week,
DayOfWeek: entry.DayOfWeek,
SectionFrom: entry.SectionFrom,
SectionTo: entry.SectionTo,
}
taskID := entry.TaskItemID
items = append(items, model.TaskClassItem{
ID: taskID,
EmbeddedTime: embedded,
})
}
return items
}
// deepCopyTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨节点共享引用。
func deepCopyTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
if len(src) == 0 {
return nil
}
dst := make([]model.TaskClassItem, 0, len(src))
for _, item := range src {
copied := item
if item.CategoryID != nil {
v := *item.CategoryID
copied.CategoryID = &v
}
if item.Order != nil {
v := *item.Order
copied.Order = &v
}
if item.Content != nil {
v := *item.Content
copied.Content = &v
}
if item.Status != nil {
v := *item.Status
copied.Status = &v
}
if item.EmbeddedTime != nil {
t := *item.EmbeddedTime
copied.EmbeddedTime = &t
}
dst = append(dst, copied)
}
return dst
}
// normalizeContextTag 归一化任务标签。
//
// 失败兜底:
// 1. 未识别/空值统一回落到 General
// 2. 保证后续 prompt 构造不会出现空标签。
func normalizeContextTag(raw string) string {
tag := strings.TrimSpace(raw)
if tag == "" {
return "General"
}
switch strings.ToLower(tag) {
case "high-logic", "high_logic", "logic":
return "High-Logic"
case "memory":
return "Memory"
case "review":
return "Review"
case "general":
return "General"
default:
return "General"
}
}
// normalizeTaskClassIDs 清洗 task_class_ids去重 + 过滤非法值)。
func normalizeTaskClassIDs(ids []int) []int {
if len(ids) == 0 {
return nil
}
seen := make(map[int]struct{}, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// clearPreviousPreviewContext 清空会话承接快照字段。
//
// 触发场景:
// 1. 用户明确要求 restart重新排
// 2. 需要强制断开“沿用历史方案”的路径,避免脏状态渗透到新方案。
func (st *SchedulePlanState) clearPreviousPreviewContext() {
if st == nil {
return
}
st.HasPreviousPreview = false
st.PreviousTaskClassIDs = nil
st.PreviousHybridEntries = nil
st.PreviousAllocatedItems = nil
st.PreviousCandidatePlans = nil
st.PreviousPlanJSON = ""
}
// clampAdjustmentConfidence 约束置信度字段到 [0,1]。
func clampAdjustmentConfidence(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// deepCopyWeekSchedules 深拷贝周视图方案切片,避免跨节点共享引用。
func deepCopyWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
if len(src) == 0 {
return nil
}
dst := make([]model.UserWeekSchedule, 0, len(src))
for _, week := range src {
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
copy(eventsCopy, week.Events)
dst = append(dst, model.UserWeekSchedule{
Week: week.Week,
Events: eventsCopy,
})
}
return dst
}
// sameTaskClassSet 判断两组 task_class_ids 是否表示同一集合(忽略顺序,忽略重复)。
//
// 语义:
// 1. 两边经清洗后都为空,返回 false空集合不作为“可复用历史方案”的依据
// 2. 元素集合完全一致返回 true
// 3. 任一元素差异返回 false。
func sameTaskClassSet(left []int, right []int) bool {
l := normalizeTaskClassIDs(left)
r := normalizeTaskClassIDs(right)
if len(l) == 0 || len(r) == 0 {
return false
}
if len(l) != len(r) {
return false
}
seen := make(map[int]struct{}, len(l))
for _, id := range l {
seen[id] = struct{}{}
}
for _, id := range r {
if _, ok := seen[id]; !ok {
return false
}
}
return true
}
// hybridEntriesToWeekSchedules 把内存中的混合条目转换成前端周视图格式。
func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule {
sectionTimeMap := map[int][2]string{
1: {"08:00", "08:45"}, 2: {"08:55", "09:40"},
3: {"10:15", "11:00"}, 4: {"11:10", "11:55"},
5: {"14:00", "14:45"}, 6: {"14:55", "15:40"},
7: {"16:15", "17:00"}, 8: {"17:10", "17:55"},
9: {"19:00", "19:45"}, 10: {"19:55", "20:40"},
11: {"20:50", "21:35"}, 12: {"21:45", "22:30"},
}
weekMap := make(map[int][]model.WeeklyEventBrief)
for _, e := range entries {
startTime := ""
endTime := ""
if t, ok := sectionTimeMap[e.SectionFrom]; ok {
startTime = t[0]
}
if t, ok := sectionTimeMap[e.SectionTo]; ok {
endTime = t[1]
}
brief := model.WeeklyEventBrief{
DayOfWeek: e.DayOfWeek,
Name: e.Name,
StartTime: startTime,
EndTime: endTime,
Type: e.Type,
Span: e.SectionTo - e.SectionFrom + 1,
Status: e.Status,
}
if e.EventID > 0 {
brief.ID = e.EventID
}
weekMap[e.Week] = append(weekMap[e.Week], brief)
}
result := make([]model.UserWeekSchedule, 0, len(weekMap))
for week, events := range weekMap {
result = append(result, model.UserWeekSchedule{
Week: week,
Events: events,
})
}
for i := 0; i < len(result); i++ {
for j := i + 1; j < len(result); j++ {
if result[j].Week < result[i].Week {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}

View File

@@ -1,178 +0,0 @@
package scheduleplan
const (
// SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。
//
// 职责边界:
// 1. 负责把自然语言转成结构化 JSON供后端节点分流与执行
// 2. 负责抽取 task_class_ids / strategy / task_tags 等关键字段;
// 3. 不负责做排程计算,不负责做工具调用。
//
// 输出约束:
// 1. 必须只输出 JSON禁止附加解释文本
// 2. task_class_ids 是主语义;
// 3. task_class_id 仅作为兼容字段保留,便于老链路平滑过渡;
// 4. 需要额外给出 restart + adjustment_scope用于图分流。
SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。
请根据用户输入,提取排程意图与约束条件。
必须完成以下任务:
1) 用一句话概括用户的排程意图intent
2) 提取所有硬约束constraints如“早八不排”“周末休息”等。
3) 如果用户明确提到了任务类名称或ID输出 task_class_ids整数数组否则输出空数组 []。
4) 兼容字段 task_class_id若 task_class_ids 非空可填第一个ID若无法判断填 -1。
5) 判断排程策略 strategy均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。
6) 尝试给任务打认知标签 task_tags可选
- 推荐键task_item_id字符串形式例如 "12"
- 兼容键:任务名称(例如 "高数复习"
- 值只能是High-Logic / Memory / Review / General
- 如果无法判断,输出空对象 {}
7) 判定本轮是否要求“强制重排” restart
- 用户明确表达“重新排/推倒重来/忽略之前方案/全部重来”时restart=true
- 否则 restart=false。
8) 判定微调力度 adjustment_scopesmall / medium / large
- small局部微调通常只改少量时段不需要重建全局。
- medium中等调整需要周级再平衡但不必全量重粗排。
- large大范围调整或首次创建排程或约束变化很大需要完整重排。
9) 输出 reason简短中文理由<=30字与 confidence0~1
输出要求:
- 仅输出 JSON不要 markdown不要解释。
- 格式如下:
{
"intent": "用户排程意图摘要",
"constraints": ["约束1", "约束2"],
"task_class_ids": [12, 13],
"task_class_id": 12,
"strategy": "steady",
"task_tags": {"12":"High-Logic","英语阅读":"Memory"},
"restart": false,
"adjustment_scope": "medium",
"reason": "本次只调整局部时段",
"confidence": 0.86
}`
// SchedulePlanDailyReactPrompt 用于 daily_refine 节点。
//
// 职责边界:
// 1. 只处理“单天”数据,避免跨天决策污染;
// 2. 通过工具调用做小步调整;
// 3. 不负责周级配平,不负责最终总结。
SchedulePlanDailyReactPrompt = `你是 SmartFlow 日内排程优化器。
你将收到一天内的日程安排JSON 数组),其中:
- status="existing":已确定的课程或任务,不可移动
- status="suggested":粗排算法建议的学习任务,你可以调整
- context_tag任务认知类型High-Logic/Memory/Review/General
你的目标是优化这一天内 suggested 任务的时间安排。
## 优化原则
1. 上下文切换成本:相同 context_tag 的任务尽量相邻,减少认知切换。
2. 时段适配性:
- 第1-4节上午适合 High-Logic数学、编程
- 第5-8节下午适合中等强度专业课、阅读
- 第9-12节晚间适合 Memory 和 Review
3. 学习效率曲线:避免连续超过 4 节高强度学习。
4. 与 existing 条目衔接:避免高强度课程后立刻接高强度任务。
## 可用工具
1. Swap — 交换两个 suggested 任务的时间
参数task_atask_item_idtask_btask_item_id
2. Move — 将一个 suggested 任务移动到新时间(仅限当天)
参数task_item_id, to_week, to_day, to_section_from, to_section_to
3. TimeAvailable — 检查时段是否可用
参数week, day_of_week, section_from, section_to
4. GetAvailableSlots — 获取可用时段
参数week
## 输出格式(严格 JSON不要 markdown
调用工具时:
{"tool_calls":[{"tool":"Swap","params":{"task_a":10,"task_b":12}}]}
完成优化时:
{"done":true,"summary":"简要说明优化理由"}
重要:只修改 suggested 任务,不要尝试移动 existing 条目。`
// SchedulePlanWeeklyReactPrompt 用于 weekly_refine 节点。
//
// 设计重点:
// 1. 采用“单步动作”模式每轮只做一个动作Move/Swap或直接 done
// 2. 显式区分总预算与有效预算,避免模型对“次数扣减”产生困惑;
// 3. 明确“输入数据已过后端硬校验”,避免模型把合法嵌入误判为冲突;
// 4. 工具失败结果会回传到下一轮,模型只需“走一步看一步”。
SchedulePlanWeeklyReactPrompt = `你是 SmartFlow 周级排程配平器。
单日内的排程已优化完毕,你当前只负责“单周微调”。
## 数据可靠性前提(必须接受)
1. 你收到的混合日程 JSON 已经过后端硬冲突检查。
2. 如果看到课程与任务在同一节次重叠,这表示“任务嵌入课程”的合法状态,不是异常。
3. 你不需要再次判断“输入本身是否冲突”,只需要在这个可信基线上进行优化。
4. 工具内部会做可用性与冲突校验;你无需额外调用“检查可用性工具”。
5. 字段语义补充:
- existing 条目的 block_for_suggested=false该课程格子允许嵌入 suggested 任务;
- suggested 条目的 block_for_suggested=true表示该 suggested 本身会占位,防止被其他 suggested 再次重叠覆盖。
## 预算语义(必须遵守,且必须严格区分)
1. 总动作预算(剩余):{{action_total_remaining}}
2. 总动作预算(固定):{{action_total_budget}}
3. 总动作预算(已用):{{action_total_used}}
4. 有效动作预算(剩余):{{action_effective_remaining}}
5. 有效动作预算(固定):{{action_effective_budget}}
6. 有效动作预算(已用):{{action_effective_used}}
7. 规则:
- 每次工具调用(无论成功失败)都会消耗 1 次“总动作预算”;
- 仅当工具调用成功时,才会额外消耗 1 次“有效动作预算”。
8. 你当前看到的是“剩余额度”,不是“总额度”,额度减少是前序动作正常消耗。
## 约束
1. 只允许在当前周内优化(禁止跨周移动)。
2. 每次回复只能做一件事:要么调用 1 个工具,要么 done。
3. 严格遵守用户约束(如有)。
4. 每个任务最多变动一次位置。
## 优化目标
1. 疲劳度均衡避免某一天堆积过多高强度任务context_tag=High-Logic
2. 间隔重复:同一科目任务适当分散到不同天。
3. 科目多样性:尽量避免单一任务类型连续多天占据相同时段。
4. 总量均衡:各天 suggested 数量大致均匀。
## 执行节奏(降低无效思考)
1. 想一步做一步:本轮只做“一个最有价值动作”。
2. 不要一次规划多步;上一轮工具结果会传给下一轮,你可以继续接力。
3. 如果当前方案已经足够好,直接 done不要空转。
4. 禁止输出多个工具调用;如果需要连续调整,请分多轮逐步完成。
## 可用工具
1. Move — 将一个 suggested 任务移动到当前周的另一天/时段
参数task_item_id, to_week, to_day, to_section_from, to_section_to
注意:节次跨度必须与原任务一致
2. Swap — 交换两个 suggested 任务的时间
参数task_a, task_btask_item_id
## 输出格式(严格 JSON不要 markdown
调用工具时注意tool_calls 里只能有 1 个元素):
{"tool_calls":[{"tool":"Move","params":{"task_item_id":10,"to_week":2,"to_day":3,"to_section_from":5,"to_section_to":6}}]}
完成优化时:
{"done":true,"summary":"简要说明做了哪些跨天调整及理由"}`
// SchedulePlanFinalCheckPrompt 用于 final_check 节点的人性化总结。
//
// 职责边界:
// 1. 只做读数据总结,不参与工具调用与状态修改;
// 2. 输出面向用户的自然语言;
// 3. 失败由上层兜底文案处理。
SchedulePlanFinalCheckPrompt = `你是 SmartFlow 排程方案总结专家。
你的任务是为用户生成一段友好、自然的排程总结。
要求:
1. 用 2-3 句话概括方案亮点。
2. 提及具体时间安排特征(如“上午安排高强度任务”“周末留出缓冲”)。
3. 若用户有约束,说明方案如何满足这些约束。
4. 输入里会包含“周级动作日志”,请结合日志说明优化过程的价值(例如更均衡、冲突更少、切换更顺)。
5. 语气温暖自然。
6. 只输出纯文本,不要输出 JSON。`
)

View File

@@ -1,77 +0,0 @@
package scheduleplan
import (
"context"
"fmt"
)
// runQuickRefineNode 是 small 微调分支的“轻量预算收缩节点”。
//
// 职责边界:
// 1. 负责在进入 weekly_refine 前收缩预算与并发,避免小改动走重链路;
// 2. 负责保留“可回退”的最低预算,避免直接压成 0 导致无动作可执行;
// 3. 不负责执行任何 Move/Swap真正动作仍由 weekly_refine 完成)。
func runQuickRefineNode(
ctx context.Context,
st *SchedulePlanState,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
_ = ctx
if st == nil {
return nil, fmt.Errorf("schedule plan quick refine: nil state")
}
emitStage("schedule_plan.quick_refine.start", "检测到小幅微调,正在切换到快速优化路径。")
// 1. 预算收缩策略:
// 1.1 small 场景目标是“快速响应 + 可解释改动”,不追求大规模重排;
// 1.2 因此把总预算压到最多 2 次尝试、有效预算压到最多 1 次成功动作;
// 1.3 如果上游已配置更小预算,则尊重更小值,不做反向放大。
if st.WeeklyTotalBudget <= 0 {
st.WeeklyTotalBudget = schedulePlanDefaultWeeklyTotalBudget
}
if st.WeeklyAdjustBudget <= 0 {
st.WeeklyAdjustBudget = schedulePlanDefaultWeeklyAdjustBudget
}
st.WeeklyTotalBudget = clampBudgetUpper(st.WeeklyTotalBudget, 2)
st.WeeklyAdjustBudget = clampBudgetUpper(st.WeeklyAdjustBudget, 1)
// 2. 预算一致性兜底:
// 2.1 总预算至少为 1否则 weekly worker 无法执行);
// 2.2 有效预算至少为 1否则所有成功动作都不被允许
// 2.3 有效预算永远不能超过总预算。
if st.WeeklyTotalBudget < 1 {
st.WeeklyTotalBudget = 1
}
if st.WeeklyAdjustBudget < 1 {
st.WeeklyAdjustBudget = 1
}
if st.WeeklyAdjustBudget > st.WeeklyTotalBudget {
st.WeeklyAdjustBudget = st.WeeklyTotalBudget
}
// 3. 小改动路径把周级并发收敛到 1优先保证稳定与可观察性。
st.WeeklyRefineConcurrency = 1
emitStage(
"schedule_plan.quick_refine.done",
fmt.Sprintf(
"快速微调预算已生效:总预算=%d有效预算=%d并发=%d。",
st.WeeklyTotalBudget,
st.WeeklyAdjustBudget,
st.WeeklyRefineConcurrency,
),
)
return st, nil
}
// clampBudgetUpper 把预算裁剪到“非负且不超过上限”。
func clampBudgetUpper(current int, upper int) int {
if current < 0 {
return 0
}
if current > upper {
return upper
}
return current
}

View File

@@ -1,847 +0,0 @@
package scheduleplan
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
const (
// weeklyReactRoundTimeout 是周级“单步动作”单轮超时时间。
//
// 说明:
// 1. 当前周级策略是“每轮只做一个动作”,单轮输入较短,超时可比旧版更保守;
// 2. 过长超时会放大长尾等待,影响并发周优化的整体收口速度。
weeklyReactRoundTimeout = 4 * time.Minute
)
// weeklyRefineWorkerResult 是“单周 worker”输出。
//
// 职责边界:
// 1. 记录该周优化后的 entries
// 2. 记录预算消耗(总动作/有效动作);
// 3. 记录动作日志,供 final_check 生成“过程可解释”总结;
// 4. 记录该周摘要,便于最终汇总。
type weeklyRefineWorkerResult struct {
Week int
Entries []model.HybridScheduleEntry
TotalUsed int
EffectiveUsed int
Summary string
ActionLogs []string
}
// runWeeklyRefineNode 执行“周级单步优化”。
//
// 新链路目标:
// 1. 把全量周数据拆成“按周并发”执行,降低单次模型输入规模;
// 2. 每轮只允许一个动作Move/Swap或 done减少模型犹豫
// 3. 使用“双预算”约束迭代:
// 3.1 总动作预算:成功/失败都扣减;
// 3.2 有效动作预算:仅成功动作扣减;
// 4. 不在该阶段输出 reasoning 文本,改为阶段状态 + 动作结果,避免刷屏。
func runWeeklyRefineNode(
ctx context.Context,
st *SchedulePlanState,
chatModel *ark.ChatModel,
outChan chan<- string,
modelName string,
emitStage func(stage, detail string),
) (*SchedulePlanState, error) {
_ = outChan
if st == nil {
return nil, fmt.Errorf("schedule plan weekly refine: nil state")
}
if chatModel == nil {
return nil, fmt.Errorf("schedule plan weekly refine: model is nil")
}
if len(st.HybridEntries) == 0 {
st.ReactDone = true
st.ReactSummary = "无可优化的排程条目。"
return st, nil
}
if strings.TrimSpace(modelName) == "" {
modelName = "worker"
}
// 1. 预算与并发兜底。
// 1.1 有效预算(旧字段)<=0 时回退默认值;
// 1.2 总预算 <=0 时回退默认值;
// 1.3 为避免“有效预算 > 总预算”的反直觉状态,做一次归一化修正;
// 1.4 周级并发度默认不高于周数,避免空并发浪费。
if st.WeeklyAdjustBudget <= 0 {
st.WeeklyAdjustBudget = schedulePlanDefaultWeeklyAdjustBudget
}
if st.WeeklyTotalBudget <= 0 {
st.WeeklyTotalBudget = schedulePlanDefaultWeeklyTotalBudget
}
if st.WeeklyAdjustBudget > st.WeeklyTotalBudget {
st.WeeklyAdjustBudget = st.WeeklyTotalBudget
}
if st.WeeklyRefineConcurrency <= 0 {
st.WeeklyRefineConcurrency = schedulePlanDefaultWeeklyRefineConcurrency
}
// 2. 按周拆分输入。
weekOrder, weekEntries := splitHybridEntriesByWeek(st.HybridEntries)
if len(weekOrder) == 0 {
st.ReactDone = true
st.ReactSummary = "无可优化的排程条目。"
return st, nil
}
// 3. 只对“包含 suggested 的周”分配预算,其余周直接透传。
activeWeeks := make([]int, 0, len(weekOrder))
for _, week := range weekOrder {
if countSuggested(weekEntries[week]) > 0 {
activeWeeks = append(activeWeeks, week)
}
}
if len(activeWeeks) == 0 {
st.ReactDone = true
st.ReactSummary = "当前方案中没有可调整的 suggested 任务,已跳过周级优化。"
return st, nil
}
// 3.1 强制“每个有效周至少 1 个总预算 + 1 个有效预算”。
// 3.1.1 判断依据:任何有效周都必须有机会进入优化,避免出现 0 预算跳过。
// 3.1.2 实现方式:当全局预算不足时,自动抬升到 activeWeeks 数量。
// 3.1.3 失败/兜底:该步骤仅做内存字段修正,不依赖外部资源,不会新增失败点。
minBudgetRequired := len(activeWeeks)
if st.WeeklyTotalBudget < minBudgetRequired {
st.WeeklyTotalBudget = minBudgetRequired
}
if st.WeeklyAdjustBudget < minBudgetRequired {
st.WeeklyAdjustBudget = minBudgetRequired
}
if st.WeeklyAdjustBudget > st.WeeklyTotalBudget {
st.WeeklyAdjustBudget = st.WeeklyTotalBudget
}
totalBudgetByWeek, effectiveBudgetByWeek, weeklyLoads, coveredWeeks := splitWeeklyBudgetsByLoad(
activeWeeks,
weekEntries,
st.WeeklyTotalBudget,
st.WeeklyAdjustBudget,
)
budgetIndexByWeek := make(map[int]int, len(activeWeeks))
for idx, week := range activeWeeks {
budgetIndexByWeek[week] = idx
}
if coveredWeeks < len(activeWeeks) {
emitStage(
"schedule_plan.weekly_refine.budget_fallback",
fmt.Sprintf(
"周级预算不足以覆盖全部有效周(有效周=%d至少需预算=%d当前总预算=%d有效预算=%d。已按周负载优先覆盖 %d 个周,其余周预算置 0 并透传原方案。",
len(activeWeeks),
len(activeWeeks),
st.WeeklyTotalBudget,
st.WeeklyAdjustBudget,
coveredWeeks,
),
)
}
workerConcurrency := st.WeeklyRefineConcurrency
if workerConcurrency > len(activeWeeks) {
workerConcurrency = len(activeWeeks)
}
if workerConcurrency <= 0 {
workerConcurrency = 1
}
emitStage(
"schedule_plan.weekly_refine.start",
fmt.Sprintf(
"周级单步优化开始:周数=%d可优化=%d并发度=%d总动作预算=%d有效动作预算=%d覆盖周=%d/%d周负载=%v。",
len(weekOrder),
len(activeWeeks),
workerConcurrency,
st.WeeklyTotalBudget,
st.WeeklyAdjustBudget,
coveredWeeks,
len(activeWeeks),
weeklyLoads,
),
)
// 4. 并发执行“单周 worker”。
sem := make(chan struct{}, workerConcurrency)
var wg sync.WaitGroup
var mu sync.Mutex
workerResults := make(map[int]weeklyRefineWorkerResult, len(weekOrder))
var firstErr error
completedWeeks := 0
for _, week := range weekOrder {
week := week
entries := deepCopyEntries(weekEntries[week])
// 4.1 没有 suggested 的周直接透传,不占模型调用预算。
if countSuggested(entries) == 0 {
workerResults[week] = weeklyRefineWorkerResult{
Week: week,
Entries: entries,
Summary: fmt.Sprintf("W%d 无 suggested 任务,跳过周级优化。", week),
}
continue
}
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
mu.Lock()
if firstErr == nil {
firstErr = ctx.Err()
}
completedWeeks++
workerResults[week] = weeklyRefineWorkerResult{
Week: week,
Entries: entries,
Summary: fmt.Sprintf("W%d 优化取消,已保留原方案。", week),
}
emitStage(
"schedule_plan.weekly_refine.week_done",
fmt.Sprintf("W%d 已取消并回退原方案。(进度 %d/%d", week, completedWeeks, len(activeWeeks)),
)
mu.Unlock()
return
}
idx := budgetIndexByWeek[week]
weekTotalBudget := totalBudgetByWeek[idx]
weekEffectiveBudget := effectiveBudgetByWeek[idx]
emitStage(
"schedule_plan.weekly_refine.week_start",
fmt.Sprintf(
"W%d 开始周级单步优化:总预算=%d有效预算=%d。",
week,
weekTotalBudget,
weekEffectiveBudget,
),
)
result, workerErr := runSingleWeekRefineWorker(
ctx,
chatModel,
modelName,
week,
entries,
st.Constraints,
weeklyPlanningWindow{
Enabled: st.HasPlanningWindow,
StartWeek: st.PlanStartWeek,
StartDay: st.PlanStartDay,
EndWeek: st.PlanEndWeek,
EndDay: st.PlanEndDay,
},
weekTotalBudget,
weekEffectiveBudget,
emitStage,
)
mu.Lock()
defer mu.Unlock()
if workerErr != nil && firstErr == nil {
firstErr = workerErr
}
completedWeeks++
workerResults[week] = result
emitStage(
"schedule_plan.weekly_refine.week_done",
fmt.Sprintf(
"W%d 周级优化完成(总已用=%d/%d有效已用=%d/%d进度 %d/%d",
week,
result.TotalUsed,
weekTotalBudget,
result.EffectiveUsed,
weekEffectiveBudget,
completedWeeks,
len(activeWeeks),
),
)
}()
}
wg.Wait()
// 5. 汇总 worker 结果,重建全量 HybridEntries。
mergedEntries := make([]model.HybridScheduleEntry, 0, len(st.HybridEntries))
st.WeeklyTotalUsed = 0
st.WeeklyAdjustUsed = 0
st.WeeklyActionLogs = st.WeeklyActionLogs[:0]
weekSummaries := make([]string, 0, len(weekOrder))
for _, week := range weekOrder {
result, exists := workerResults[week]
if !exists {
// 理论上不会发生;兜底透传该周原始条目。
result = weeklyRefineWorkerResult{
Week: week,
Entries: deepCopyEntries(weekEntries[week]),
Summary: fmt.Sprintf("W%d 未拿到 worker 结果,已保留原方案。", week),
}
}
mergedEntries = append(mergedEntries, result.Entries...)
st.WeeklyTotalUsed += result.TotalUsed
st.WeeklyAdjustUsed += result.EffectiveUsed
st.WeeklyActionLogs = append(st.WeeklyActionLogs, result.ActionLogs...)
if strings.TrimSpace(result.Summary) != "" {
weekSummaries = append(weekSummaries, result.Summary)
}
}
sortHybridEntries(mergedEntries)
st.HybridEntries = mergedEntries
// 6. 生成阶段摘要并收口状态。
st.ReactDone = true
st.ReactRound = st.WeeklyTotalUsed
if len(weekSummaries) == 0 {
st.ReactSummary = fmt.Sprintf(
"周级优化完成:总动作已用 %d/%d有效动作已用 %d/%d。",
st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget,
)
} else {
st.ReactSummary = strings.Join(weekSummaries, "")
}
if firstErr != nil {
emitStage("schedule_plan.weekly_refine.partial_error", fmt.Sprintf("周级并发优化部分失败,已自动保留失败周原方案。原因:%s", firstErr.Error()))
}
emitStage(
"schedule_plan.weekly_refine.done",
fmt.Sprintf(
"周级单步优化结束:总动作已用 %d/%d有效动作已用 %d/%d。",
st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget,
),
)
return st, nil
}
// runSingleWeekRefineWorker 执行“单周 + 单步动作”循环。
//
// 流程说明:
// 1. 每轮只允许 1 个工具调用或 done
// 2. 每次工具调用都扣“总预算”;
// 3. 仅成功调用再扣“有效预算”;
// 4. 工具结果会回灌到下一轮上下文,驱动“走一步看一步”。
func runSingleWeekRefineWorker(
ctx context.Context,
chatModel *ark.ChatModel,
modelName string,
week int,
entries []model.HybridScheduleEntry,
constraints []string,
window weeklyPlanningWindow,
totalBudget int,
effectiveBudget int,
emitStage func(stage, detail string),
) (weeklyRefineWorkerResult, error) {
result := weeklyRefineWorkerResult{
Week: week,
Entries: deepCopyEntries(entries),
}
if totalBudget <= 0 || effectiveBudget <= 0 {
result.Summary = fmt.Sprintf("W%d 预算为 0跳过周级优化。", week)
return result, nil
}
hybridJSON, err := json.Marshal(result.Entries)
if err != nil {
result.Summary = fmt.Sprintf("W%d 序列化失败,已保留原方案。", week)
return result, fmt.Errorf("周级 worker 序列化失败 week=%d: %w", week, err)
}
constraintsText := "无"
if len(constraints) > 0 {
constraintsText = strings.Join(constraints, "、")
}
messages := []*schema.Message{
schema.SystemMessage(
renderWeeklyPromptWithBudget(
effectiveBudget-result.EffectiveUsed,
effectiveBudget,
result.EffectiveUsed,
totalBudget-result.TotalUsed,
totalBudget,
result.TotalUsed,
),
),
schema.UserMessage(fmt.Sprintf(
"当前处理周次W%d\n以下是当前周混合日程JSON\n%s\n\n用户约束%s\n\n注意本 worker 仅允许优化 W%d 内的任务。",
week,
string(hybridJSON),
constraintsText,
week,
)),
}
for result.TotalUsed < totalBudget && result.EffectiveUsed < effectiveBudget {
remainingTotal := totalBudget - result.TotalUsed
remainingEffective := effectiveBudget - result.EffectiveUsed
emitStage(
"schedule_plan.weekly_refine.round",
fmt.Sprintf(
"W%d 新一轮决策:总预算剩余=%d/%d有效预算剩余=%d/%d。",
week,
remainingTotal,
totalBudget,
remainingEffective,
effectiveBudget,
),
)
// 1. 每轮更新系统提示中的预算占位符。
messages[0] = schema.SystemMessage(
renderWeeklyPromptWithBudget(
remainingEffective,
effectiveBudget,
result.EffectiveUsed,
remainingTotal,
totalBudget,
result.TotalUsed,
),
)
roundCtx, cancel := context.WithTimeout(ctx, weeklyReactRoundTimeout)
content, genErr := generateWeeklyRefineRound(roundCtx, chatModel, messages)
cancel()
if genErr != nil {
result.Summary = fmt.Sprintf("W%d 模型调用失败,已保留当前结果。", week)
return result, fmt.Errorf("周级 worker 调用失败 week=%d: %w", week, genErr)
}
parsed, parseErr := parseReactLLMOutput(content)
if parseErr != nil {
result.Summary = fmt.Sprintf("W%d 输出格式异常,已保留当前结果。", week)
return result, fmt.Errorf("周级 worker 解析失败 week=%d: %w", week, parseErr)
}
// 2. done=true 直接正常结束,不再消耗预算。
if parsed.Done {
summary := strings.TrimSpace(parsed.Summary)
if summary == "" {
summary = fmt.Sprintf(
"W%d 优化结束(总动作已用 %d/%d有效动作已用 %d/%d。",
week,
result.TotalUsed, totalBudget,
result.EffectiveUsed, effectiveBudget,
)
}
result.Summary = summary
break
}
// 3. 只取一个工具调用,强制单步。
call, warn := pickSingleToolCall(parsed.ToolCalls)
if call == nil {
result.Summary = fmt.Sprintf(
"W%d 无可执行动作,提前结束(总动作已用 %d/%d有效动作已用 %d/%d。",
week,
result.TotalUsed, totalBudget,
result.EffectiveUsed, effectiveBudget,
)
break
}
if warn != "" {
result.ActionLogs = append(result.ActionLogs, fmt.Sprintf("W%d 警告:%s", week, warn))
}
// 4. 执行工具:总预算总是扣减;有效预算仅成功时扣减。
result.TotalUsed++
nextEntries, toolResult := dispatchWeeklySingleActionTool(result.Entries, *call, week, window)
if toolResult.Success {
result.EffectiveUsed++
result.Entries = nextEntries
}
logLine := fmt.Sprintf(
"W%d 动作[%s] 结果=%t总预算=%d/%d有效预算=%d/%d详情=%s",
week,
toolResult.Tool,
toolResult.Success,
result.TotalUsed,
totalBudget,
result.EffectiveUsed,
effectiveBudget,
toolResult.Result,
)
result.ActionLogs = append(result.ActionLogs, logLine)
statusMark := "FAIL"
if toolResult.Success {
statusMark = "OK"
}
emitStage("schedule_plan.weekly_refine.tool_call", fmt.Sprintf("[%s] %s", statusMark, logLine))
// 5. 把“本轮输出 + 工具结果”拼回下一轮上下文,驱动增量推理。
messages = append(messages, schema.AssistantMessage(content, nil))
toolResultJSON, _ := json.Marshal([]reactToolResult{toolResult})
messages = append(messages, schema.UserMessage(
fmt.Sprintf(
"上一轮工具结果:%s\n当前预算总剩余=%d有效剩余=%d\n请继续按“单步动作”规则决策仅一个工具调用或 done。",
string(toolResultJSON),
totalBudget-result.TotalUsed,
effectiveBudget-result.EffectiveUsed,
),
))
}
if strings.TrimSpace(result.Summary) == "" {
result.Summary = fmt.Sprintf(
"W%d 预算耗尽停止(总动作已用 %d/%d有效动作已用 %d/%d。",
week,
result.TotalUsed, totalBudget,
result.EffectiveUsed, effectiveBudget,
)
}
return result, nil
}
// generateWeeklyRefineRound 调用模型生成“单周单步”决策输出。
//
// 说明:
// 1. 周级仍保留 thinking提高复杂排程准确率
// 2. 但不把 reasoning 实时透传给前端,避免刷屏;
// 3. 仅返回最终 content交给 JSON 解析器处理。
func generateWeeklyRefineRound(
ctx context.Context,
chatModel *ark.ChatModel,
messages []*schema.Message,
) (string, error) {
resp, err := chatModel.Generate(
ctx,
messages,
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}),
einoModel.WithTemperature(0.2),
)
if err != nil {
return "", err
}
if resp == nil {
return "", fmt.Errorf("周级单步调用返回为空")
}
content := strings.TrimSpace(resp.Content)
if content == "" {
return "", fmt.Errorf("周级单步调用返回内容为空")
}
return content, nil
}
// renderWeeklyPromptWithBudget 渲染周级单步优化的预算占位符。
//
// 1. 保留旧占位符 {{budget*}} 兼容历史模板;
// 2. 新增 action_total/action_effective 占位符表达双预算语义;
// 3. 所有负值都会在这里兜底归零,避免传给模型异常预算。
func renderWeeklyPromptWithBudget(
remainingEffective int,
effectiveBudget int,
usedEffective int,
remainingTotal int,
totalBudget int,
usedTotal int,
) string {
if effectiveBudget <= 0 {
effectiveBudget = schedulePlanDefaultWeeklyAdjustBudget
}
if totalBudget <= 0 {
totalBudget = schedulePlanDefaultWeeklyTotalBudget
}
if remainingEffective < 0 {
remainingEffective = 0
}
if remainingTotal < 0 {
remainingTotal = 0
}
if usedEffective < 0 {
usedEffective = 0
}
if usedTotal < 0 {
usedTotal = 0
}
if usedEffective > effectiveBudget {
usedEffective = effectiveBudget
}
if usedTotal > totalBudget {
usedTotal = totalBudget
}
prompt := SchedulePlanWeeklyReactPrompt
prompt = strings.ReplaceAll(prompt, "{{action_total_remaining}}", fmt.Sprintf("%d", remainingTotal))
prompt = strings.ReplaceAll(prompt, "{{action_total_budget}}", fmt.Sprintf("%d", totalBudget))
prompt = strings.ReplaceAll(prompt, "{{action_total_used}}", fmt.Sprintf("%d", usedTotal))
prompt = strings.ReplaceAll(prompt, "{{action_effective_remaining}}", fmt.Sprintf("%d", remainingEffective))
prompt = strings.ReplaceAll(prompt, "{{action_effective_budget}}", fmt.Sprintf("%d", effectiveBudget))
prompt = strings.ReplaceAll(prompt, "{{action_effective_used}}", fmt.Sprintf("%d", usedEffective))
// 兼容旧模板占位符,避免历史 prompt 残留时出现未替换文本。
prompt = strings.ReplaceAll(prompt, "{{budget_remaining}}", fmt.Sprintf("%d", remainingEffective))
prompt = strings.ReplaceAll(prompt, "{{budget_total}}", fmt.Sprintf("%d", effectiveBudget))
prompt = strings.ReplaceAll(prompt, "{{budget_used}}", fmt.Sprintf("%d", usedEffective))
prompt = strings.ReplaceAll(prompt, "{{budget}}", fmt.Sprintf("%d总额度 %d已用 %d", remainingEffective, effectiveBudget, usedEffective))
return prompt
}
// pickSingleToolCall 在“单步动作模式”下选择一个工具调用。
//
// 返回语义:
// 1. call=nil没有可执行工具
// 2. warn 非空:模型返回了多个工具,本轮仅执行第一个。
func pickSingleToolCall(toolCalls []reactToolCall) (*reactToolCall, string) {
if len(toolCalls) == 0 {
return nil, ""
}
call := toolCalls[0]
if len(toolCalls) == 1 {
return &call, ""
}
return &call, fmt.Sprintf("模型返回了 %d 个工具调用,单步模式仅执行第一个:%s", len(toolCalls), call.Tool)
}
// splitHybridEntriesByWeek 按 week 对混合条目分组并返回稳定周序。
func splitHybridEntriesByWeek(entries []model.HybridScheduleEntry) ([]int, map[int][]model.HybridScheduleEntry) {
byWeek := make(map[int][]model.HybridScheduleEntry)
for _, entry := range entries {
byWeek[entry.Week] = append(byWeek[entry.Week], entry)
}
weeks := make([]int, 0, len(byWeek))
for week := range byWeek {
weeks = append(weeks, week)
}
sort.Ints(weeks)
return weeks, byWeek
}
type weightedBudgetRemainder struct {
Index int
Remainder int
Load int
}
// splitWeeklyBudgetsByLoad 根据“有效周保底 + 周负载加权”拆分预算。
//
// 职责边界:
// 1. 负责:返回与 activeWeeks 同索引对齐的总预算/有效预算;
// 2. 负责:在预算不足时按负载优先覆盖高负载周;
// 3. 不负责:执行周级动作与状态落盘(由 runSingleWeekRefineWorker / runWeeklyRefineNode 负责)。
//
// 输入输出语义:
// 1. coveredWeeks 表示“同时拿到 >=1 总预算和 >=1 有效预算”的周数;
// 2. 当任一全局预算 <=0 时,返回全 0上游将据此跳过对应周优化
// 3. 返回的 weeklyLoads 仅用于可观测性,不参与后续状态持久化。
func splitWeeklyBudgetsByLoad(
activeWeeks []int,
weekEntries map[int][]model.HybridScheduleEntry,
totalBudget int,
effectiveBudget int,
) (totalByWeek []int, effectiveByWeek []int, weeklyLoads []int, coveredWeeks int) {
weekCount := len(activeWeeks)
if weekCount == 0 {
return nil, nil, nil, 0
}
if totalBudget < 0 {
totalBudget = 0
}
if effectiveBudget < 0 {
effectiveBudget = 0
}
weeklyLoads = buildWeeklyLoadScores(activeWeeks, weekEntries)
totalByWeek = make([]int, weekCount)
effectiveByWeek = make([]int, weekCount)
if totalBudget == 0 || effectiveBudget == 0 {
return totalByWeek, effectiveByWeek, weeklyLoads, 0
}
// 1. 先计算“可保底覆盖周数”。
// 1.1 目标是每个有效周至少 1 个总预算 + 1 个有效预算;
// 1.2 失败场景:当预算小于有效周数量时,不可能全覆盖;
// 1.3 兜底策略:只覆盖高负载周,避免把预算分散到无法执行的周。
coveredWeeks = weekCount
if totalBudget < coveredWeeks {
coveredWeeks = totalBudget
}
if effectiveBudget < coveredWeeks {
coveredWeeks = effectiveBudget
}
if coveredWeeks <= 0 {
return totalByWeek, effectiveByWeek, weeklyLoads, 0
}
coveredIndexes := pickTopLoadWeekIndexes(weeklyLoads, coveredWeeks)
for _, idx := range coveredIndexes {
totalByWeek[idx]++
effectiveByWeek[idx]++
}
// 2. 再把剩余预算按周负载加权分配。
// 2.1 判断依据:负载越高,给到的额外预算越多,优先解决高密度周;
// 2.2 失败场景:负载异常(<=0会导致权重失真
// 2.3 兜底策略:权重最小按 1 处理,保证分配可持续、不会 panic。
addWeightedBudget(totalByWeek, weeklyLoads, coveredIndexes, totalBudget-coveredWeeks)
addWeightedBudget(effectiveByWeek, weeklyLoads, coveredIndexes, effectiveBudget-coveredWeeks)
return totalByWeek, effectiveByWeek, weeklyLoads, coveredWeeks
}
// buildWeeklyLoadScores 计算每个有效周的负载评分。
//
// 职责边界:
// 1. 负责:以 suggested 任务的节次跨度作为周负载;
// 2. 不负责:预算分配策略与排序决策(由 splitWeeklyBudgetsByLoad/pickTopLoadWeekIndexes 负责)。
func buildWeeklyLoadScores(
activeWeeks []int,
weekEntries map[int][]model.HybridScheduleEntry,
) []int {
loads := make([]int, len(activeWeeks))
for idx, week := range activeWeeks {
load := 0
for _, entry := range weekEntries[week] {
if entry.Status != "suggested" {
continue
}
span := entry.SectionTo - entry.SectionFrom + 1
if span <= 0 {
span = 1
}
load += span
}
if load <= 0 {
// 兜底:脏数据或异常节次下仍给该周最小权重,避免被完全饿死。
load = 1
}
loads[idx] = load
}
return loads
}
// pickTopLoadWeekIndexes 选择负载最高的 topN 个周索引。
func pickTopLoadWeekIndexes(loads []int, topN int) []int {
if topN <= 0 || len(loads) == 0 {
return nil
}
indexes := make([]int, len(loads))
for i := range loads {
indexes[i] = i
}
sort.SliceStable(indexes, func(i, j int) bool {
left := loads[indexes[i]]
right := loads[indexes[j]]
if left != right {
return left > right
}
return indexes[i] < indexes[j]
})
if topN > len(indexes) {
topN = len(indexes)
}
selected := append([]int(nil), indexes[:topN]...)
sort.Ints(selected)
return selected
}
// addWeightedBudget 把剩余预算按权重分配到目标周。
//
// 说明:
// 1. 先按整数份额分配;
// 2. 再按“最大余数法”分发尾差,保证总和严格守恒;
// 3. 余数相同时优先高负载周,再按索引稳定排序,避免结果抖动。
func addWeightedBudget(
budgets []int,
loads []int,
targetIndexes []int,
remainingBudget int,
) {
if remainingBudget <= 0 || len(targetIndexes) == 0 {
return
}
totalLoad := 0
normalizedLoadByIndex := make(map[int]int, len(targetIndexes))
for _, idx := range targetIndexes {
load := 1
if idx >= 0 && idx < len(loads) && loads[idx] > 0 {
load = loads[idx]
}
normalizedLoadByIndex[idx] = load
totalLoad += load
}
if totalLoad <= 0 {
// 理论上不会出现;兜底均匀轮询分配,保证不会丢预算。
for i := 0; i < remainingBudget; i++ {
budgets[targetIndexes[i%len(targetIndexes)]]++
}
return
}
allocated := 0
remainders := make([]weightedBudgetRemainder, 0, len(targetIndexes))
for _, idx := range targetIndexes {
load := normalizedLoadByIndex[idx]
shareProduct := remainingBudget * load
share := shareProduct / totalLoad
budgets[idx] += share
allocated += share
remainders = append(remainders, weightedBudgetRemainder{
Index: idx,
Remainder: shareProduct % totalLoad,
Load: load,
})
}
left := remainingBudget - allocated
if left <= 0 {
return
}
sort.SliceStable(remainders, func(i, j int) bool {
if remainders[i].Remainder != remainders[j].Remainder {
return remainders[i].Remainder > remainders[j].Remainder
}
if remainders[i].Load != remainders[j].Load {
return remainders[i].Load > remainders[j].Load
}
return remainders[i].Index < remainders[j].Index
})
for i := 0; i < left; i++ {
budgets[remainders[i%len(remainders)].Index]++
}
}
// sortHybridEntries 对条目做稳定排序,确保后续预览输出稳定。
func sortHybridEntries(entries []model.HybridScheduleEntry) {
sort.SliceStable(entries, func(i, j int) bool {
left := entries[i]
right := entries[j]
if left.Week != right.Week {
return left.Week < right.Week
}
if left.DayOfWeek != right.DayOfWeek {
return left.DayOfWeek < right.DayOfWeek
}
if left.SectionFrom != right.SectionFrom {
return left.SectionFrom < right.SectionFrom
}
if left.SectionTo != right.SectionTo {
return left.SectionTo < right.SectionTo
}
if left.Status != right.Status {
// existing 放前suggested 放后,便于观察课表底板与建议层。
return left.Status < right.Status
}
return left.Name < right.Name
})
}

View File

@@ -1,128 +0,0 @@
package scheduleplan
import (
"context"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
)
// schedulePlanRunner 是“单次图执行”的请求级依赖容器。
//
// 设计目标:
// 1. 把节点运行所需依赖model/deps/emit/extra/history就近收口
// 2. 让 graph.go 只保留“节点连线与分支决策”,提升可读性;
// 3. 避免在 graph.go 里重复出现大量闭包和参数透传。
type schedulePlanRunner struct {
chatModel *ark.ChatModel
deps SchedulePlanToolDeps
emitStage func(stage, detail string)
userMessage string
extra map[string]any
chatHistory []*schema.Message
// weekly refine 需要的上下文
outChan chan<- string
modelName string
// daily refine 并发度
dailyRefineConcurrency int
}
func newSchedulePlanRunner(
chatModel *ark.ChatModel,
deps SchedulePlanToolDeps,
emitStage func(stage, detail string),
userMessage string,
extra map[string]any,
chatHistory []*schema.Message,
outChan chan<- string,
modelName string,
dailyRefineConcurrency int,
) *schedulePlanRunner {
return &schedulePlanRunner{
chatModel: chatModel,
deps: deps,
emitStage: emitStage,
userMessage: userMessage,
extra: extra,
chatHistory: chatHistory,
outChan: outChan,
modelName: modelName,
dailyRefineConcurrency: dailyRefineConcurrency,
}
}
// 节点方法适配层
func (r *schedulePlanRunner) planNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runPlanNode(ctx, st, r.chatModel, r.userMessage, r.extra, r.chatHistory, r.emitStage)
}
func (r *schedulePlanRunner) roughBuildNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runRoughBuildNode(ctx, st, r.deps, r.emitStage)
}
func (r *schedulePlanRunner) dailySplitNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runDailySplitNode(ctx, st, r.emitStage)
}
func (r *schedulePlanRunner) quickRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runQuickRefineNode(ctx, st, r.emitStage)
}
func (r *schedulePlanRunner) dailyRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runDailyRefineNode(ctx, st, r.chatModel, r.dailyRefineConcurrency, r.emitStage)
}
func (r *schedulePlanRunner) mergeNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runMergeNode(ctx, st, r.emitStage)
}
func (r *schedulePlanRunner) weeklyRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runWeeklyRefineNode(ctx, st, r.chatModel, r.outChan, r.modelName, r.emitStage)
}
func (r *schedulePlanRunner) finalCheckNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runFinalCheckNode(ctx, st, r.chatModel, r.emitStage)
}
func (r *schedulePlanRunner) returnPreviewNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return runReturnPreviewNode(ctx, st, r.emitStage)
}
func (r *schedulePlanRunner) exitNode(_ context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
return st, nil
}
// 分支决策适配层
func (r *schedulePlanRunner) nextAfterPlan(_ context.Context, st *SchedulePlanState) (string, error) {
return selectNextAfterPlan(st), nil
}
// nextAfterRoughBuild 根据粗排构建结果决定后续路径。
//
// 规则:
// 1. 没有可优化条目 -> exit
// 2. task_class_ids >= 2 -> dailySplit多任务类混排先做日内并发
// 3. task_class_ids == 1 -> weeklyRefine单任务类直接周级配平
func (r *schedulePlanRunner) nextAfterRoughBuild(_ context.Context, st *SchedulePlanState) (string, error) {
if st == nil || len(st.HybridEntries) == 0 {
return schedulePlanGraphNodeExit, nil
}
// 1. 连续微调且判定为 small先走快速微调节点收缩预算后再进 weekly。
if st.IsAdjustment && st.AdjustmentScope == schedulePlanAdjustmentScopeSmall {
return schedulePlanGraphNodeQuickRefine, nil
}
// 2. 连续微调且判定为 medium直接走 weekly跳过 daily。
if st.IsAdjustment && st.AdjustmentScope == schedulePlanAdjustmentScopeMedium {
return schedulePlanGraphNodeWeeklyRefine, nil
}
// 3. large 或非微调:保持原有逻辑,多任务类走 daily单任务类直达 weekly。
if len(st.TaskClassIDs) >= 2 {
return schedulePlanGraphNodeDailySplit, nil
}
return schedulePlanGraphNodeWeeklyRefine, nil
}

View File

@@ -1,287 +0,0 @@
package scheduleplan
import (
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
// schedulePlanTimezoneName 是排程链路默认业务时区。
// 与随口记保持一致,固定东八区,避免容器运行在 UTC 导致"明天/今晚"偏移。
schedulePlanTimezoneName = "Asia/Shanghai"
// schedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。
schedulePlanDatetimeLayout = "2006-01-02 15:04"
// schedulePlanDefaultDailyRefineConcurrency 是日内并发优化默认并发度。
// 这里给一个保守默认值,避免未配置时直接把模型并发打满导致限流。
schedulePlanDefaultDailyRefineConcurrency = 3
// schedulePlanDefaultWeeklyAdjustBudget 是周级配平默认调整额度。
// 额度存在的目的:
// 1. 防止周级 ReAct 过度调整导致震荡;
// 2. 控制 token 与时延成本;
// 3. 让方案改动更可解释。
schedulePlanDefaultWeeklyAdjustBudget = 5
// schedulePlanDefaultWeeklyTotalBudget 是周级“总尝试次数”默认预算。
//
// 设计意图:
// 1. 总预算统计“动作尝试次数”(成功/失败都记一次);
// 2. 有效预算统计“成功动作次数”(仅成功时记一次);
// 3. 通过双预算把“探索次数”和“有效改动次数”分离,降低模型无效空转成本。
schedulePlanDefaultWeeklyTotalBudget = 8
// schedulePlanDefaultWeeklyRefineConcurrency 是周级“按周并发”默认并发度。
// 说明:
// 1. 周级输入规模通常比单天更大,默认并发度不宜过高,避免触发模型侧限流;
// 2. 可在运行时按请求状态覆盖。
schedulePlanDefaultWeeklyRefineConcurrency = 2
// schedulePlanAdjustmentScopeSmall 表示“小改动微调”。
// 语义:优先走快速路径,只做轻量周级调整。
schedulePlanAdjustmentScopeSmall = "small"
// schedulePlanAdjustmentScopeMedium 表示“中等改动微调”。
// 语义:跳过日内拆分,直接进入周级配平。
schedulePlanAdjustmentScopeMedium = "medium"
// schedulePlanAdjustmentScopeLarge 表示“大改动重排”。
// 语义:必要时重新走全量路径(日内并发 + 周级配平)。
schedulePlanAdjustmentScopeLarge = "large"
)
// DayGroup 是“按天拆分后”的最小优化单元。
//
// 设计目的:
// 1. 把全量周视角数据拆成“单天小包”,降低日内 ReAct 输入规模;
// 2. 支持并发优化不同天的数据,缩短整体等待;
// 3. 通过 SkipRefine 让低收益天数直接跳过,节省模型调用成本。
type DayGroup struct {
Week int
DayOfWeek int
Entries []model.HybridScheduleEntry
SkipRefine bool
}
// SchedulePlanState 是“智能排程”链路在 graph 节点间传递的统一状态容器。
//
// 设计目标:
// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散落;
// 2) 支持“粗排 -> 日内并发优化 -> 周级配平 -> 终审校验”的完整链路追踪;
// 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。
type SchedulePlanState struct {
// ── 基础上下文 ──
TraceID string
UserID int
ConversationID string
RequestNow time.Time
RequestNowText string
// ── plan 节点输出 ──
// UserIntent 是模型对用户排程意图的结构化摘要(如"帮我安排高数复习计划")。
UserIntent string
// Constraints 是用户提出的硬约束列表(如 ["早八不排", "周末休息"])。
Constraints []string
// TaskClassIDs 是本次请求携带的任务类集合(统一主语义)。
//
// 设计说明:
// 1. 这里明确不再维护单值 task_class_id避免“单值和切片同时存在”导致语义漂移
// 2. 分流依据统一为 len(TaskClassIDs)
// 2.1 len==1跳过 daily 并发,直接进入 weekly refine
// 2.2 len>=2进入 daily 并发后再 weekly refine
// 3. 输入清洗(去重、过滤非法值)由 plan 节点完成,这里只承载最终状态。
TaskClassIDs []int
// Strategy 是排程策略steady/rapid默认 steady。
Strategy string
// TaskTags 是“任务项 ID -> 认知类型标签”的映射。
// 使用 ID 而不是名称,目的是规避“同名任务”带来的映射冲突。
TaskTags map[int]string
// TaskTagHintsByName 是“任务名称 -> 认知类型标签”的临时映射。
// 该字段只作为 plan 输出兼容层:
// 1. 若模型暂时给不出 task_item_id只给名称
// 2. 后续在 hybridBuild/dailySplit 阶段再转换为 TaskTags(ID 维度)。
TaskTagHintsByName map[string]string
// ── preview 节点输出 ──
// CandidatePlans 是粗排算法生成的候选方案(展示型结构,供后续节点做预览与总结)。
CandidatePlans []model.UserWeekSchedule
// AllocatedItems 是粗排算法已分配的任务项EmbeddedTime 已回填),供 ReAct 精排使用。
AllocatedItems []model.TaskClassItem
// HasPlanningWindow 标记是否成功解析出“任务类时间窗”的相对周/天边界。
//
// 语义:
// 1. truePlanStart*/PlanEnd* 字段可用于 Move 工具的硬边界校验;
// 2. false表示当前运行未拿到窗口信息例如依赖未注入工具层将仅做基础校验。
HasPlanningWindow bool
// PlanStartWeek / PlanStartDay 表示全局排程窗口起点(相对周/天)。
PlanStartWeek int
PlanStartDay int
// PlanEndWeek / PlanEndDay 表示全局排程窗口终点(相对周/天)。
PlanEndWeek int
PlanEndDay int
// ── 日内并发优化阶段 ──
// DailyGroups 是按 (week, day) 拆分后的单日优化输入。
// 结构week -> day -> DayGroup。
DailyGroups map[int]map[int]*DayGroup
// DailyResults 是单日优化输出。
// 结构week -> day -> []HybridScheduleEntry。
DailyResults map[int]map[int][]model.HybridScheduleEntry
// DailyRefineConcurrency 是日内并发优化的并发度。
// 说明:该值由配置注入,可按环境调节。
DailyRefineConcurrency int
// ── 周级 ReAct 精排阶段 ──
// HybridEntries 是混合日程条目列表包含既有日程existing和粗排建议suggested
// 周级 ReAct 工具直接在此切片上操作(内存修改,不涉及 DB
HybridEntries []model.HybridScheduleEntry
// MergeSnapshot 是 merge 后快照。
// 终审失败时回退到该快照,确保至少保留“日内优化成果”。
MergeSnapshot []model.HybridScheduleEntry
// ReactRound 当前周级 ReAct 循环轮次。
ReactRound int
// ReactMaxRound 周级 ReAct 最大循环轮次。
ReactMaxRound int
// ReactSummary 周级 ReAct 输出的优化摘要。
ReactSummary string
// ReactDone 标记周级 ReAct 是否已完成。
ReactDone bool
// WeeklyAdjustBudget 是周级跨天调整额度上限。
// 语义:有效动作预算(仅工具调用成功时扣减)。
WeeklyAdjustBudget int
// WeeklyAdjustUsed 是周级跨天调整已使用额度。
// 语义:有效动作已使用次数(仅成功调用时递增)。
WeeklyAdjustUsed int
// WeeklyTotalBudget 是周级总动作预算。
// 语义:总尝试次数预算(成功/失败都扣减)。
WeeklyTotalBudget int
// WeeklyTotalUsed 是周级总动作已使用次数。
// 语义:成功/失败每执行一次工具调用都递增。
WeeklyTotalUsed int
// WeeklyRefineConcurrency 是周级“按周并发”并发度。
WeeklyRefineConcurrency int
// WeeklyActionLogs 记录周级优化阶段的关键动作流水。
//
// 设计目的:
// 1. 供 final_check 的总结模型理解“优化过程”,而非只看最终静态结果;
// 2. 供调试排查时快速回放“每轮做了什么动作、是否成功、为何失败”。
WeeklyActionLogs []string
// ── 连续对话微调 ──
// PreviousPlanJSON 是上一版已落库方案的 JSON 序列化,用于增量微调。
// 从对话历史中提取,不做持久化。
PreviousPlanJSON string
// IsAdjustment 标记本次是否为微调请求(而非全新排程)。
IsAdjustment bool
// RestartRequested 标记本轮是否要求“放弃历史快照并重新排程”。
//
// 语义:
// 1. true强制清空 Previous* 并走全新构建;
// 2. false允许按同会话历史快照做增量微调。
RestartRequested bool
// AdjustmentScope 表示本轮改动力度分级small/medium/large
//
// 分流语义:
// 1. small走快速微调节点再进入周级优化
// 2. medium跳过 daily直接周级优化
// 3. large优先走全量路径多任务类时会经过 daily 并发)。
AdjustmentScope string
// AdjustmentReason 是模型给出的力度判定理由,用于日志排障与 review。
AdjustmentReason string
// AdjustmentConfidence 是模型给出的力度判定置信度0-1
AdjustmentConfidence float64
// HasPreviousPreview 标记是否命中“同会话上一次排程预览快照”。
//
// 语义:
// 1. true可以尝试复用上次 HybridEntries 作为本轮优化起点;
// 2. false按全新排程路径构建粗排底板。
HasPreviousPreview bool
// PreviousTaskClassIDs 是上一次预览对应的任务类集合。
//
// 用途:
// 1. 本轮未显式传 task_class_ids 时作为兜底;
// 2. 仅会话内承接,不改动数据库。
PreviousTaskClassIDs []int
// PreviousHybridEntries 是上一次预览保存的混合日程条目。
//
// 用途:
// 1. 连续对话微调时直接复用,避免重新粗排;
// 2. 若为空则回退到粗排构建路径。
PreviousHybridEntries []model.HybridScheduleEntry
// PreviousAllocatedItems 是上一次预览保存的任务块分配结果。
//
// 用途:
// 1. 保持 final_check 的数量核对口径稳定;
// 2. return_preview 阶段可继续回填 embedded_time。
PreviousAllocatedItems []model.TaskClassItem
// PreviousCandidatePlans 是上一版预览保存的周视图结构化结果。
//
// 用途:
// 1. 连续微调时可直接复用,避免重复转换;
// 2. 兜底展示层(即使本轮未走全量粗排,仍可给前端稳定结构)。
PreviousCandidatePlans []model.UserWeekSchedule
// ── 最终输出 ──
// FinalSummary 是 graph 最终给用户的回复文案。
FinalSummary string
// Completed 标记整个排程链路是否成功完成。
Completed bool
}
// NewSchedulePlanState 创建排程状态对象并初始化默认值。
func NewSchedulePlanState(traceID string, userID int, conversationID string) *SchedulePlanState {
now := schedulePlanNowToMinute()
return &SchedulePlanState{
TraceID: traceID,
UserID: userID,
ConversationID: conversationID,
RequestNow: now,
RequestNowText: now.In(schedulePlanLocation()).Format(schedulePlanDatetimeLayout),
Strategy: "steady",
TaskTags: make(map[int]string),
TaskTagHintsByName: make(map[string]string),
DailyRefineConcurrency: schedulePlanDefaultDailyRefineConcurrency,
WeeklyRefineConcurrency: schedulePlanDefaultWeeklyRefineConcurrency,
AdjustmentScope: schedulePlanAdjustmentScopeLarge,
ReactMaxRound: 2,
WeeklyAdjustBudget: schedulePlanDefaultWeeklyAdjustBudget,
WeeklyTotalBudget: schedulePlanDefaultWeeklyTotalBudget,
}
}
// schedulePlanLocation 返回排程链路使用的业务时区。
func schedulePlanLocation() *time.Location {
loc, err := time.LoadLocation(schedulePlanTimezoneName)
if err != nil {
return time.Local
}
return loc
}
// schedulePlanNowToMinute 返回当前时间并截断到分钟级。
func schedulePlanNowToMinute() time.Time {
return time.Now().In(schedulePlanLocation()).Truncate(time.Minute)
}
// normalizeAdjustmentScope 归一化排程微调力度字段。
//
// 兜底策略:
// 1. 只接受 small/medium/large
// 2. 任何未知值都回退为 large保证不会误走“过轻”路径。
func normalizeAdjustmentScope(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case schedulePlanAdjustmentScopeSmall:
return schedulePlanAdjustmentScopeSmall
case schedulePlanAdjustmentScopeMedium:
return schedulePlanAdjustmentScopeMedium
default:
return schedulePlanAdjustmentScopeLarge
}
}

View File

@@ -1,147 +0,0 @@
package scheduleplan
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
)
// SchedulePlanToolDeps 描述“智能排程 graph”运行所需的外部业务依赖。
//
// 职责边界:
// 1. 只负责声明“需要哪些能力”,不负责具体实现(实现由 service 层注入)。
// 2. 只收口函数签名,不承载业务状态,避免跨请求共享可变数据。
// 3. 当前统一采用 task_class_ids 语义,不再依赖单 task_class_id 主路径。
type SchedulePlanToolDeps struct {
// SmartPlanningMultiRaw 是可选依赖:
// 1) 用于需要单独输出“粗排预览”时复用;
// 2) 当前主链路已由 HybridScheduleWithPlanMulti 覆盖,可不注入。
SmartPlanningMultiRaw func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
// HybridScheduleWithPlanMulti 把“既有日程 + 粗排结果”合并成统一的 HybridScheduleEntry 切片,
// 供 daily/weekly ReAct 节点在内存中继续优化。
HybridScheduleWithPlanMulti func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
// ResolvePlanningWindow 根据 task_class_ids 解析“全局排程窗口”的相对周/天边界。
//
// 返回语义:
// 1. startWeek/startDay窗口起点
// 2. endWeek/endDay窗口终点
// 3. error解析失败如任务类不存在、日期非法
//
// 用途:
// 1. 给周级 Move 工具加硬边界,避免把任务移动到窗口外的天数;
// 2. 解决“首尾不足一周”场景下的周内越界问题。
ResolvePlanningWindow func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error)
}
// validate 校验依赖完整性。
//
// 失败处理:
// 1. 任意依赖缺失都直接返回错误,避免 graph 运行到中途才 panic。
// 2. 调用方runSchedulePlanFlow收到错误后会走回退链路不影响普通聊天可用性。
func (d SchedulePlanToolDeps) validate() error {
if d.HybridScheduleWithPlanMulti == nil {
return errors.New("schedule plan tool deps: HybridScheduleWithPlanMulti is nil")
}
return nil
}
// ExtraInt 从 extra map 中安全提取整数值。
//
// 兼容策略:
// 1) JSON 数字默认解析为 float64做 int 转换;
// 2) 兼容字符串形式(如 "42"),用 Atoi 解析;
// 3) 其余类型返回 false由调用方决定后续处理。
func ExtraInt(extra map[string]any, key string) (int, bool) {
v, ok := extra[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case string:
i, err := strconv.Atoi(n)
return i, err == nil
default:
return 0, false
}
}
// ExtraIntSlice 从 extra map 中安全提取整数切片。
//
// 兼容输入:
// 1) []anyJSON 数组反序列化后的常见类型);
// 2) []int
// 3) []float64
// 4) 逗号分隔字符串(例如 "1,2,3")。
//
// 返回语义:
// 1) ok=true至少成功解析出一个整数
// 2) ok=false字段不存在或全部解析失败。
func ExtraIntSlice(extra map[string]any, key string) ([]int, bool) {
v, exists := extra[key]
if !exists {
return nil, false
}
parseOne := func(raw any) (int, error) {
switch n := raw.(type) {
case int:
return n, nil
case float64:
return int(n), nil
case string:
i, err := strconv.Atoi(n)
if err != nil {
return 0, err
}
return i, nil
default:
return 0, fmt.Errorf("unsupported type: %T", raw)
}
}
out := make([]int, 0)
switch arr := v.(type) {
case []int:
for _, item := range arr {
out = append(out, item)
}
case []float64:
for _, item := range arr {
out = append(out, int(item))
}
case []any:
for _, item := range arr {
if parsed, err := parseOne(item); err == nil {
out = append(out, parsed)
}
}
case string:
parts := strings.Split(arr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if parsed, err := strconv.Atoi(part); err == nil {
out = append(out, parsed)
}
}
default:
return nil, false
}
if len(out) == 0 {
return nil, false
}
return out, true
}

View File

@@ -1,465 +0,0 @@
package scheduleplan
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
)
// ── ReAct Tool 调用/结果结构 ──
// reactToolCall 是 LLM 输出的单个工具调用。
type reactToolCall struct {
Tool string `json:"tool"`
Params map[string]any `json:"params"`
}
// reactToolResult 是单个工具调用的执行结果。
type reactToolResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Result string `json:"result"`
}
// reactLLMOutput 是 LLM 输出的完整 JSON 结构。
type reactLLMOutput struct {
Done bool `json:"done"`
Summary string `json:"summary"`
ToolCalls []reactToolCall `json:"tool_calls"`
}
// weeklyPlanningWindow 表示周级优化可用的全局周/天窗口。
//
// 语义:
// 1. Enabled=false不启用窗口硬边界仅做基础合法性校验
// 2. Enabled=trueMove 必须落在 [StartWeek/StartDay, EndWeek/EndDay] 内;
// 3. 该窗口用于处理“首尾不足一周”场景下的越界移动问题。
type weeklyPlanningWindow struct {
Enabled bool
StartWeek int
StartDay int
EndWeek int
EndDay int
}
// ── 工具分发器 ──
// dispatchReactTool 根据工具名分发调用返回可能修改后的entries 和执行结果。
func dispatchReactTool(entries []model.HybridScheduleEntry, call reactToolCall) ([]model.HybridScheduleEntry, reactToolResult) {
switch call.Tool {
case "Swap":
return reactToolSwap(entries, call.Params)
case "Move":
return reactToolMove(entries, call.Params)
case "TimeAvailable":
return entries, reactToolTimeAvailable(entries, call.Params)
case "GetAvailableSlots":
return entries, reactToolGetAvailableSlots(entries, call.Params)
default:
return entries, reactToolResult{Tool: call.Tool, Success: false, Result: fmt.Sprintf("未知工具: %s", call.Tool)}
}
}
// dispatchWeeklySingleActionTool 是“周级单步动作模式”的专用分发器。
//
// 职责边界:
// 1. 仅允许 Move / Swap 两个工具,禁止 TimeAvailable / GetAvailableSlots
// 2. 强制 Move 的目标周必须等于 currentWeek避免并发周优化时发生跨周写穿
// 3. 统一返回工具执行结果,供上层决定预算扣减与下一轮上下文拼接。
func dispatchWeeklySingleActionTool(entries []model.HybridScheduleEntry, call reactToolCall, currentWeek int, window weeklyPlanningWindow) ([]model.HybridScheduleEntry, reactToolResult) {
tool := strings.TrimSpace(call.Tool)
switch tool {
case "Swap":
return reactToolSwap(entries, call.Params)
case "Move":
// 1. 周级并发模式下,每个 worker 只负责单周数据。
// 2. 为避免“一个 worker 改到别的周”导致并发写冲突,这里做硬约束。
// 3. 失败时不抛异常,返回工具失败结果,让上层继续下一轮决策。
toWeek, ok := paramInt(call.Params, "to_week")
if !ok {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week"}
}
if toWeek != currentWeek {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: fmt.Sprintf("当前仅允许优化本周worker_week=%d目标周=%d", currentWeek, toWeek),
}
}
// 4. 若已配置全局窗口边界,再做“首尾不足一周”硬校验。
// 4.1 这样可避免把任务移动到窗口外的天数(例如起始周的起始日前、结束周的结束日后)。
// 4.2 窗口未启用时不阻断,保持兼容旧链路。
if window.Enabled {
toDay, ok := paramInt(call.Params, "to_day")
if !ok {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_day"}
}
allowed, dayFrom, dayTo := isDayWithinPlanningWindow(window, toWeek, toDay)
if !allowed {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: fmt.Sprintf("目标日期超出排程窗口W%d 仅允许 D%d-D%d当前目标为 D%d", toWeek, dayFrom, dayTo, toDay),
}
}
}
return reactToolMove(entries, call.Params)
default:
return entries, reactToolResult{
Tool: tool,
Success: false,
Result: fmt.Sprintf("周级单步模式不支持工具: %s仅允许 Move/Swap", tool),
}
}
}
// isDayWithinPlanningWindow 判断目标 week/day 是否落在窗口范围内。
//
// 返回值:
// 1. allowed是否允许
// 2. dayFrom/dayTo该周允许的 day 区间(用于错误提示)。
func isDayWithinPlanningWindow(window weeklyPlanningWindow, week int, day int) (allowed bool, dayFrom int, dayTo int) {
// 1. 窗口未启用时默认允许(调用方会跳过此分支,这里是兜底)。
if !window.Enabled {
return true, 1, 7
}
// 2. 先做周范围校验。
if week < window.StartWeek || week > window.EndWeek {
return false, 1, 7
}
// 3. 计算当前周允许的 day 边界。
from := 1
to := 7
if week == window.StartWeek {
from = window.StartDay
}
if week == window.EndWeek {
to = window.EndDay
}
if day < from || day > to {
return false, from, to
}
return true, from, to
}
// ── 参数提取辅助 ──
func paramInt(params map[string]any, key string) (int, bool) {
v, ok := params[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
default:
return 0, false
}
}
// findSuggestedByID 在 entries 中查找指定 TaskItemID 的 suggested 条目索引。
func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int {
for i, e := range entries {
if e.TaskItemID == taskItemID && e.Status == "suggested" {
return i
}
}
return -1
}
// sectionsOverlap 判断两个节次区间是否有交集。
func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool {
return aFrom <= bTo && bFrom <= aTo
}
// entryBlocksSuggested 判断某条目是否应阻塞 suggested 任务占位。
//
// 规则:
// 1. suggested 任务永远阻塞(任务之间不能重叠);
// 2. existing 条目按 BlockForSuggested 字段决定;
// 3. 其余场景默认阻塞(保守策略,避免放出脏可用槽)。
func entryBlocksSuggested(entry model.HybridScheduleEntry) bool {
if entry.Status == "suggested" {
return true
}
// existing 走显式字段语义。
if entry.Status == "existing" {
return entry.BlockForSuggested
}
// 未知状态兜底:按阻塞处理。
return true
}
// hasConflict 检查目标时间段是否与 entries 中任何条目冲突(排除 excludeIdx
func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st, excludeIdx int) (bool, string) {
for i, e := range entries {
if i == excludeIdx {
continue
}
// 1. 可嵌入且未占用的课程槽BlockForSuggested=false不参与冲突判断。
// 2. 这样可以避免把“水课可嵌入位”误判为硬冲突。
if !entryBlocksSuggested(e) {
continue
}
if e.Week == week && e.DayOfWeek == day && sectionsOverlap(e.SectionFrom, e.SectionTo, sf, st) {
return true, fmt.Sprintf("%s(%s)", e.Name, e.Type)
}
}
return false, ""
}
// ══════════════════════════════════════════════════════════════
// Tool 1: Swap — 交换两个 suggested 任务的时间
// ══════════════════════════════════════════════════════════════
func reactToolSwap(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) {
idA, okA := paramInt(params, "task_a")
idB, okB := paramInt(params, "task_b")
if !okA || !okB {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:需要 task_a 和 task_btask_item_id"}
}
if idA == idB {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 和 task_b 不能相同"}
}
idxA := findSuggestedByID(entries, idA)
idxB := findSuggestedByID(entries, idB)
if idxA == -1 {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idA)}
}
if idxB == -1 {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idB)}
}
// 交换时间坐标
a, b := &entries[idxA], &entries[idxB]
a.Week, b.Week = b.Week, a.Week
a.DayOfWeek, b.DayOfWeek = b.DayOfWeek, a.DayOfWeek
a.SectionFrom, b.SectionFrom = b.SectionFrom, a.SectionFrom
a.SectionTo, b.SectionTo = b.SectionTo, a.SectionTo
return entries, reactToolResult{
Tool: "Swap", Success: true,
Result: fmt.Sprintf("已交换 [%s](id=%d) 和 [%s](id=%d) 的时间", a.Name, idA, b.Name, idB),
}
}
// ══════════════════════════════════════════════════════════════
// Tool 2: Move — 将一个 suggested 任务移动到新时间
// ══════════════════════════════════════════════════════════════
func reactToolMove(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) {
taskID, ok := paramInt(params, "task_item_id")
if !ok {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 task_item_id"}
}
toWeek, ok1 := paramInt(params, "to_week")
toDay, ok2 := paramInt(params, "to_day")
toSF, ok3 := paramInt(params, "to_section_from")
toST, ok4 := paramInt(params, "to_section_to")
if !ok1 || !ok2 || !ok3 || !ok4 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week, to_day, to_section_from, to_section_to"}
}
// 基础校验
if toDay < 1 || toDay > 7 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 不合法,应为 1-7", toDay)}
}
if toSF < 1 || toST > 12 || toSF > toST {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次范围 %d-%d 不合法,应为 1-12 且 from<=to", toSF, toST)}
}
idx := findSuggestedByID(entries, taskID)
if idx == -1 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", taskID)}
}
// 节次跨度必须一致
origSpan := entries[idx].SectionTo - entries[idx].SectionFrom
newSpan := toST - toSF
if origSpan != newSpan {
return entries, reactToolResult{Tool: "Move", Success: false,
Result: fmt.Sprintf("节次跨度不一致:原任务占 %d 节,目标占 %d 节", origSpan+1, newSpan+1)}
}
// 冲突检测(排除自身)
if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, idx); conflict {
return entries, reactToolResult{Tool: "Move", Success: false,
Result: fmt.Sprintf("目标时间 W%dD%d 第%d-%d节 已被 %s 占用", toWeek, toDay, toSF, toST, name)}
}
// 执行移动
e := &entries[idx]
oldDesc := fmt.Sprintf("W%dD%d 第%d-%d节", e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo)
e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo = toWeek, toDay, toSF, toST
newDesc := fmt.Sprintf("W%dD%d 第%d-%d节", toWeek, toDay, toSF, toST)
return entries, reactToolResult{
Tool: "Move", Success: true,
Result: fmt.Sprintf("已将 [%s](id=%d) 从 %s 移动到 %s", e.Name, taskID, oldDesc, newDesc),
}
}
// ══════════════════════════════════════════════════════════════
// Tool 3: TimeAvailable — 检查目标时间段是否可用
// ══════════════════════════════════════════════════════════════
func reactToolTimeAvailable(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult {
week, ok1 := paramInt(params, "week")
day, ok2 := paramInt(params, "day_of_week")
sf, ok3 := paramInt(params, "section_from")
st, ok4 := paramInt(params, "section_to")
if !ok1 || !ok2 || !ok3 || !ok4 {
return reactToolResult{Tool: "TimeAvailable", Success: false, Result: "参数缺失:需要 week, day_of_week, section_from, section_to"}
}
if conflict, name := hasConflict(entries, week, day, sf, st, -1); conflict {
return reactToolResult{Tool: "TimeAvailable", Success: true,
Result: fmt.Sprintf(`{"available":false,"conflict_with":"%s"}`, name)}
}
return reactToolResult{Tool: "TimeAvailable", Success: true, Result: `{"available":true}`}
}
// ══════════════════════════════════════════════════════════════
// Tool 4: GetAvailableSlots — 返回可用时间段列表
// ══════════════════════════════════════════════════════════════
func reactToolGetAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult {
filterWeek, _ := paramInt(params, "week") // 0 表示不过滤
// 1. 收集所有周次范围
minW, maxW := 999, 0
for _, e := range entries {
if e.Week < minW {
minW = e.Week
}
if e.Week > maxW {
maxW = e.Week
}
}
if minW > maxW {
return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: "[]"}
}
// 2. 构建占用集合
type slotKey struct{ W, D, S int }
occupied := make(map[slotKey]bool)
for _, e := range entries {
if !entryBlocksSuggested(e) {
continue
}
for s := e.SectionFrom; s <= e.SectionTo; s++ {
occupied[slotKey{e.Week, e.DayOfWeek, s}] = true
}
}
// 3. 遍历所有时间格,找出空闲并合并连续节次
type availSlot struct {
Week, Day, From, To int
}
var slots []availSlot
startW, endW := minW, maxW
if filterWeek > 0 {
startW, endW = filterWeek, filterWeek
}
for w := startW; w <= endW; w++ {
for d := 1; d <= 7; d++ {
runStart := 0
for s := 1; s <= 12; s++ {
if !occupied[slotKey{w, d, s}] {
if runStart == 0 {
runStart = s
}
} else {
if runStart > 0 {
slots = append(slots, availSlot{w, d, runStart, s - 1})
runStart = 0
}
}
}
if runStart > 0 {
slots = append(slots, availSlot{w, d, runStart, 12})
}
}
}
// 4. 按自然顺序排序(已经是了,但确保)
sort.Slice(slots, func(i, j int) bool {
if slots[i].Week != slots[j].Week {
return slots[i].Week < slots[j].Week
}
if slots[i].Day != slots[j].Day {
return slots[i].Day < slots[j].Day
}
return slots[i].From < slots[j].From
})
// 5. 序列化
type slotJSON struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
}
out := make([]slotJSON, 0, len(slots))
for _, s := range slots {
out = append(out, slotJSON{s.Week, s.Day, s.From, s.To})
}
data, _ := json.Marshal(out)
return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: string(data)}
}
// ── 辅助:解析 LLM 输出 ──
// parseReactLLMOutput 解析 LLM 的 JSON 输出。
// 兼容 ```json ... ``` 包裹。
func parseReactLLMOutput(raw string) (*reactLLMOutput, error) {
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, fmt.Errorf("LLM 输出为空")
}
// 兼容 markdown 包裹
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
var out reactLLMOutput
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
// 提取最外层 JSON 对象
start := strings.Index(clean, "{")
end := strings.LastIndex(clean, "}")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("无法从 LLM 输出中提取 JSON: %s", truncate(clean, 200))
}
obj := clean[start : end+1]
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %w", err)
}
return &out, nil
}
// truncate 截断字符串到指定长度。
func truncate(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}

View File

@@ -1,85 +0,0 @@
package schedulerefine
import (
"context"
"testing"
"github.com/LoveLosita/smartflow/backend/model"
)
func TestRefineToolSpreadEvenRespectsCanonicalRouteFilters(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Name: "任务1", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "A"},
// 1. 这里放一个更早周次的 existing 条目,用来把可查询窗口拉到 W11
// 2. 若复合工具内部丢了 week_filter/day_of_week就会优先落到更早的 W11D1而不是目标 W12D3。
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 11, DayOfWeek: 5, SectionFrom: 11, SectionTo: 12, BlockForSuggested: true},
}
params := map[string]any{
"task_item_ids": []int{1},
"week_filter": []int{12},
"day_of_week": []int{3},
"allow_embed": false,
}
nextEntries, result := refineToolSpreadEven(entries, params, planningWindow{Enabled: false}, refineToolPolicy{
OriginOrderMap: map[int]int{1: 1},
})
if !result.Success {
t.Fatalf("SpreadEven 执行失败: %s", result.Result)
}
idx := findSuggestedByID(nextEntries, 1)
if idx < 0 {
t.Fatalf("未找到 task_item_id=1")
}
got := nextEntries[idx]
if got.Week != 12 || got.DayOfWeek != 3 {
t.Fatalf("期望复合工具严格遵守 week_filter/day_of_week实际落点=W%dD%d", got.Week, got.DayOfWeek)
}
}
func TestRunCompositeRouteNodeAllowsHandoffWithoutDeterministicObjective(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 11, Name: "任务11", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学"},
{TaskItemID: 12, Name: "任务12", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法"},
{TaskItemID: 13, Name: "任务13", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学"},
}
st := &ScheduleRefineState{
UserMessage: "把这些任务按最少上下文切换整理一下",
HybridEntries: cloneHybridEntries(entries),
InitialHybridEntries: cloneHybridEntries(entries),
WorksetTaskIDs: []int{11, 12, 13},
RequiredCompositeTool: "MinContextSwitch",
CompositeRetryMax: 0,
ExecuteMax: 4,
OriginOrderMap: map[int]int{11: 1, 12: 2, 13: 3},
CompositeToolCalled: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
}
stageLogs := make([]string, 0, 8)
nextState, err := runCompositeRouteNode(context.Background(), st, func(stage, detail string) {
stageLogs = append(stageLogs, stage+"|"+detail)
})
if err != nil {
t.Fatalf("runCompositeRouteNode 返回错误: %v", err)
}
if nextState == nil {
t.Fatalf("runCompositeRouteNode 返回 nil state")
}
if !nextState.CompositeRouteSucceeded {
t.Fatalf("期望复合分支在缺少 deterministic objective 时直接出站,实际 CompositeRouteSucceeded=false, stages=%v, action_logs=%v", stageLogs, nextState.ActionLogs)
}
if nextState.DisableCompositeTools {
t.Fatalf("期望复合分支直接进入终审,不应降级为禁复合 ReAct")
}
if !nextState.CompositeToolSuccess["MinContextSwitch"] {
t.Fatalf("期望 MinContextSwitch 成功状态被记录")
}
}

View File

@@ -1,179 +0,0 @@
package schedulerefine
import (
"fmt"
"sort"
"testing"
"github.com/LoveLosita/smartflow/backend/model"
)
func TestRefineToolSpreadEvenSuccess(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Name: "任务1", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "A"},
{TaskItemID: 2, Name: "任务2", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "B"},
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, BlockForSuggested: true},
}
params := map[string]any{
"task_item_ids": []any{1.0, 2.0},
"week": 12,
"day_of_week": []any{1.0, 2.0, 3.0},
"allow_embed": false,
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{1: 1, 2: 2}}
nextEntries, result := refineToolSpreadEven(entries, params, planningWindow{Enabled: false}, policy)
if !result.Success {
t.Fatalf("SpreadEven 执行失败: %s", result.Result)
}
if result.Tool != "SpreadEven" {
t.Fatalf("工具名错误,期望 SpreadEven实际=%s", result.Tool)
}
idx1 := findSuggestedByID(nextEntries, 1)
idx2 := findSuggestedByID(nextEntries, 2)
if idx1 < 0 || idx2 < 0 {
t.Fatalf("移动后未找到目标任务: idx1=%d idx2=%d", idx1, idx2)
}
task1 := nextEntries[idx1]
task2 := nextEntries[idx2]
if task1.Week != 12 || task2.Week != 12 {
t.Fatalf("期望任务被移动到 W12实际 task1=%d task2=%d", task1.Week, task2.Week)
}
if task1.DayOfWeek < 1 || task1.DayOfWeek > 3 || task2.DayOfWeek < 1 || task2.DayOfWeek > 3 {
t.Fatalf("期望任务被移动到周一到周三,实际 task1=%d task2=%d", task1.DayOfWeek, task2.DayOfWeek)
}
if task1.DayOfWeek == task2.DayOfWeek && sectionsOverlap(task1.SectionFrom, task1.SectionTo, task2.SectionFrom, task2.SectionTo) {
t.Fatalf("复合工具不应产出重叠坑位: task1=%+v task2=%+v", task1, task2)
}
}
func TestRefineToolMinContextSwitchGroupsContext(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 11, Name: "任务11", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学"},
{TaskItemID: 12, Name: "任务12", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法"},
{TaskItemID: 13, Name: "任务13", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学"},
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 12, DayOfWeek: 1, SectionFrom: 11, SectionTo: 12, BlockForSuggested: true},
}
params := map[string]any{
"task_item_ids": []any{11.0, 12.0, 13.0},
"week": 12,
"day_of_week": []any{1.0},
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{11: 1, 12: 2, 13: 3}}
nextEntries, result := refineToolMinContextSwitch(entries, params, planningWindow{Enabled: false}, policy)
if !result.Success {
t.Fatalf("MinContextSwitch 执行失败: %s", result.Result)
}
if result.Tool != "MinContextSwitch" {
t.Fatalf("工具名错误,期望 MinContextSwitch实际=%s", result.Tool)
}
selected := make([]model.HybridScheduleEntry, 0, 3)
for _, id := range []int{11, 12, 13} {
idx := findSuggestedByID(nextEntries, id)
if idx < 0 {
t.Fatalf("未找到任务 id=%d", id)
}
selected = append(selected, nextEntries[idx])
}
sort.SliceStable(selected, func(i, j int) bool {
if selected[i].Week != selected[j].Week {
return selected[i].Week < selected[j].Week
}
if selected[i].DayOfWeek != selected[j].DayOfWeek {
return selected[i].DayOfWeek < selected[j].DayOfWeek
}
return selected[i].SectionFrom < selected[j].SectionFrom
})
switches := 0
for i := 1; i < len(selected); i++ {
if selected[i].ContextTag != selected[i-1].ContextTag {
switches++
}
}
if switches > 1 {
t.Fatalf("期望最少上下文切换(<=1实际 switches=%d, tasks=%+v", switches, selected)
}
if selected[0].TaskItemID != 11 || selected[1].TaskItemID != 13 || selected[2].TaskItemID != 12 {
t.Fatalf("期望在原坑位集合内重排为 11,13,12实际=%+v", selected)
}
for _, task := range selected {
if task.Week != 16 || task.DayOfWeek != 1 {
t.Fatalf("MinContextSwitch 不应跳出原坑位集合,实际 task=%+v", task)
}
}
}
func TestRefineToolMinContextSwitchKeepsCurrentSlotSet(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 21, Name: "随机事件与概率基础概念复习", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "General"},
{TaskItemID: 22, Name: "数制、码制与逻辑代数基础", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 1, SectionFrom: 11, SectionTo: 12, ContextTag: "General"},
{TaskItemID: 23, Name: "第二章 条件概率与全概率公式", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 3, SectionFrom: 3, SectionTo: 4, ContextTag: "General"},
}
params := map[string]any{
"task_item_ids": []any{21.0, 22.0, 23.0},
"week": 14,
"limit": 48,
"allow_embed": true,
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{21: 1, 22: 2, 23: 3}}
nextEntries, result := refineToolMinContextSwitch(entries, params, planningWindow{Enabled: false}, policy)
if !result.Success {
t.Fatalf("MinContextSwitch 执行失败: %s", result.Result)
}
selected := make([]model.HybridScheduleEntry, 0, 3)
for _, id := range []int{21, 22, 23} {
idx := findSuggestedByID(nextEntries, id)
if idx < 0 {
t.Fatalf("未找到任务 id=%d", id)
}
selected = append(selected, nextEntries[idx])
}
sort.SliceStable(selected, func(i, j int) bool {
if selected[i].Week != selected[j].Week {
return selected[i].Week < selected[j].Week
}
if selected[i].DayOfWeek != selected[j].DayOfWeek {
return selected[i].DayOfWeek < selected[j].DayOfWeek
}
return selected[i].SectionFrom < selected[j].SectionFrom
})
if selected[0].TaskItemID != 21 || selected[1].TaskItemID != 23 || selected[2].TaskItemID != 22 {
t.Fatalf("期望按原坑位集合重排为概率, 概率, 数电,实际=%+v", selected)
}
expectedSlots := map[int]string{
21: "14-1-1-2",
23: "14-1-11-12",
22: "14-3-3-4",
}
for _, task := range selected {
got := fmt.Sprintf("%d-%d-%d-%d", task.Week, task.DayOfWeek, task.SectionFrom, task.SectionTo)
if got != expectedSlots[task.TaskItemID] {
t.Fatalf("任务 id=%d 应仅在原坑位集合内换位,期望=%s 实际=%s", task.TaskItemID, expectedSlots[task.TaskItemID], got)
}
}
}
func TestListTaskIDsFromToolCallComposite(t *testing.T) {
call := reactToolCall{
Tool: "SpreadEven",
Params: map[string]any{
"task_item_ids": []any{1.0, 2.0, 2.0},
"task_item_id": 3,
},
}
ids := listTaskIDsFromToolCall(call)
if len(ids) != 3 {
t.Fatalf("期望提取 3 个去重 ID实际=%v", ids)
}
sort.Ints(ids)
if ids[0] != 1 || ids[1] != 2 || ids[2] != 3 {
t.Fatalf("提取结果错误,实际=%v", ids)
}
}

View File

@@ -1,114 +0,0 @@
package schedulerefine
import (
"context"
"fmt"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/compose"
)
const (
graphNodeContract = "schedule_refine_contract"
graphNodePlan = "schedule_refine_plan"
graphNodeSlice = "schedule_refine_slice"
graphNodeRoute = "schedule_refine_route"
graphNodeReact = "schedule_refine_react"
graphNodeHardCheck = "schedule_refine_hard_check"
graphNodeSummary = "schedule_refine_summary"
)
// ScheduleRefineGraphRunInput 是“连续微调图”运行参数。
//
// 字段语义:
// 1. Model本轮图运行使用的聊天模型。
// 2. State预先注入的微调状态通常来自上一版预览快照
// 3. EmitStageSSE 阶段回调,允许服务层把阶段进度透传给前端。
type ScheduleRefineGraphRunInput struct {
Model *ark.ChatModel
State *ScheduleRefineState
EmitStage func(stage, detail string)
}
// RunScheduleRefineGraph 执行“连续微调”独立图链路。
//
// 链路顺序:
// START -> contract -> plan -> slice -> route -> react -> hard_check -> summary -> END
//
// 设计说明:
// 1. 当前链路采用线性图,确保可读性优先;
// 2. “终审失败后单次修复”在 hard_check 节点内部闭环处理,避免图连线分叉过多;
// 3. 若后续需要引入多分支策略(例如大改动转重排),可在 contract 后追加 branch 节点。
func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInput) (*ScheduleRefineState, error) {
if input.Model == nil {
return nil, fmt.Errorf("schedule refine graph: model is nil")
}
if input.State == nil {
return nil, fmt.Errorf("schedule refine graph: state is nil")
}
emitStage := func(stage, detail string) {
if input.EmitStage != nil {
input.EmitStage(stage, detail)
}
}
runner := newScheduleRefineRunner(input.Model, emitStage)
graph := compose.NewGraph[*ScheduleRefineState, *ScheduleRefineState]()
if err := graph.AddLambdaNode(graphNodeContract, compose.InvokableLambda(runner.contractNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeSlice, compose.InvokableLambda(runner.sliceNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeRoute, compose.InvokableLambda(runner.routeNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeReact, compose.InvokableLambda(runner.reactNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeHardCheck, compose.InvokableLambda(runner.hardCheckNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeSummary, compose.InvokableLambda(runner.summaryNode)); err != nil {
return nil, err
}
if err := graph.AddEdge(compose.START, graphNodeContract); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeContract, graphNodePlan); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodePlan, graphNodeSlice); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeSlice, graphNodeRoute); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeRoute, graphNodeReact); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeReact, graphNodeHardCheck); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeHardCheck, graphNodeSummary); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeSummary, compose.END); err != nil {
return nil, err
}
runnable, err := graph.Compile(ctx,
compose.WithGraphName("ScheduleRefineGraph"),
compose.WithMaxRunSteps(20),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return nil, err
}
return runnable.Invoke(ctx, input.State)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,188 +0,0 @@
package schedulerefine
const (
// contractPrompt 负责把用户自然语言微调请求抽取为结构化契约。
contractPrompt = `你是 SmartFlow 的排程微调契约分析器。
你会收到:当前时间、用户请求、已有排程摘要。
请只输出 JSON不要 Markdown不要解释不要代码块
{
"intent": "一句话概括本轮微调目标",
"strategy": "local_adjust|keep",
"hard_requirements": ["必须满足的硬性要求1","必须满足的硬性要求2"],
"hard_assertions": [
{
"metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count",
"operator": "==|<=|>=|between",
"value": 50,
"min": 50,
"max": 50,
"week": 17,
"target_week": 16
}
],
"keep_relative_order": true,
"order_scope": "global|week"
}
规则:
1. 除非用户明确表达“允许打乱顺序/顺序无所谓”keep_relative_order 默认 true。
2. 仅当用户明确放宽顺序时keep_relative_order 才允许为 falseorder_scope 默认 "global"。
3. 只要涉及移动任务strategy 必须是 local_adjust仅在无需改动时才用 keep。
4. hard_requirements 必须可验证,避免空泛描述。
5. hard_assertions 必须尽量结构化,避免只给自然语言目标。`
// plannerPrompt 只负责生成“执行路径”,不直接执行动作。
plannerPrompt = `你是 SmartFlow 的排程微调 Planner。
你会收到:用户请求、契约、最近动作观察。
请只输出 JSON不要 Markdown不要解释不要代码块
{
"summary": "本阶段执行策略一句话",
"steps": ["步骤1","步骤2","步骤3"]
}
规则:
1. steps 保持 3~4 条,优先“先取证再动作”。
2. summary <= 36 字,单步 <= 28 字。
3. 若目标是“均匀分散”steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。
4. 若目标是“上下文切换最少/同科目连续”steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。
5. 不要输出半截 JSON。`
// reactPrompt 用于“单任务微步 ReAct”执行器。
reactPrompt = `你是 SmartFlow 的单任务微步 ReAct 执行器。
当前只处理一个任务CURRENT_TASK不能发散到其它任务的主动改动。
你每轮只能做两件事之一:
1) 调用一个工具(基础工具或复合工具)
2) 输出 done=true 结束当前任务
工具分组:
- 基础工具QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify
- 复合工具SpreadEven / MinContextSwitch
工具说明(按职责):
1. QueryTargetTasks查询候选任务集合只读
常用参数week/week_filter/day_of_week/task_item_ids/status。
适用:先摸清“有哪些任务可动、当前在哪”。
2. QueryAvailableSlots查询可放置坑位只读默认先纯空位必要时补可嵌入位
常用参数week/week_filter/day_of_week/span/limit/allow_embed/exclude_sections。
适用Move 前先拿可落点清单。
3. Move移动单个任务到目标坑位写操作
必要参数task_item_id,to_week,to_day,to_section_from,to_section_to。
适用:单任务精确挪动。
4. Swap交换两个任务坑位写操作
必要参数task_a,task_b。
适用:两个任务互换位置比单独 Move 更稳时。
5. BatchMove批量原子移动写操作
必要参数:{"moves":[{Move参数...},{Move参数...}]}。
适用:一轮要改多个任务且要求“要么全成要么全回滚”。
6. Verify执行确定性校验只读
常用参数:可空;也可传 task_item_id + 目标坐标做定点核验。
适用:收尾前快速自检是否符合确定性约束。
7. SpreadEven复合按“均匀铺开”目标一次规划并执行多任务移动写操作
必要参数task_item_ids必须包含 CURRENT_TASK.task_item_id
可选参数week/week_filter/day_of_week/allow_embed/limit。
适用:目标是“把任务在时间上分散开,避免扎堆”。
8. MinContextSwitch复合按“最少上下文切换”一次规划并执行多任务移动写操作
必要参数task_item_ids必须包含 CURRENT_TASK.task_item_id
可选参数week/week_filter/day_of_week/allow_embed/limit。
适用:目标是“同科目/同认知标签尽量连续,减少切换成本”。
请严格输出 JSON不要 Markdown不要解释
{
"done": false,
"summary": "",
"goal_check": "本轮先检查什么",
"decision": "本轮为何这么做",
"missing_info": ["缺口信息1","缺口信息2"],
"tool_calls": [
{
"tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify",
"params": {}
}
]
}
硬规则:
1. 每轮最多 1 个 tool_call。
2. done=true 时tool_calls 必须为空数组。
3. done=false 时tool_calls 必须恰好 1 条。
4. 只能修改 status="suggested" 的任务,禁止修改 existing。
5. 不要把“顺序约束”当作执行期阻塞条件;你只需把坑位分布排好,顺序由后端统一收口。
6. 若上轮失败,必须依据 LAST_TOOL_OBSERVATION.error_code 调整策略,不能重复上轮失败动作。
7. Move 参数优先使用task_item_id,to_week,to_day,to_section_from,to_section_to。
8. BatchMove 参数格式必须是:{"moves":[{...},{...}]};任一步失败会整批回滚。
9. day_of_week 映射固定1周一,2周二,3周三,4周四,5周五,6周六,7周日。
10. 优先使用“纯空位”;仅在空位不足时再考虑可嵌入课程位(第二优先级)。
11. 如果 SOURCE_WEEK_FILTER 非空,只允许改写这些来源周里的任务,禁止主动改写其它周任务。
12. CURRENT_TASK 是本轮唯一可改写任务;如果它已满足目标,立刻 done=true不要提前处理下一个任务。
13. 禁止发明工具名(如 GetCurrentTask、AdjustTaskTime只能用白名单工具。
14. 优先使用后端注入的 ENV_SLOT_HINT 进行落点决策,非必要不要重复 QueryAvailableSlots。
15. 若 REQUIRED_COMPOSITE_TOOL 非空且 COMPOSITE_REQUIRED_SUCCESS=false本轮必须优先调用 REQUIRED_COMPOSITE_TOOL禁止先调用 Move/Swap/BatchMove。
16. 若使用 SpreadEven/MinContextSwitch必须在参数中提供 task_item_ids且包含 CURRENT_TASK.task_item_id
17. 若 COMPOSITE_TOOLS_ALLOWED=false禁止调用 SpreadEven/MinContextSwitch只能使用基础工具逐步处理。
18. 为保证解析稳定goal_check<=50字decision<=90字summary<=60字。`
// postReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。
postReflectPrompt = `你是 SmartFlow 的 ReAct 复盘器。
你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。
请只输出 JSON不要 Markdown不要解释
{
"reflection": "基于真实结果的复盘",
"next_strategy": "下一轮建议动作",
"should_stop": false
}
规则:
1. 若 tool_success=falsereflection 必须明确失败原因(优先引用 error_code
2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTIONnext_strategy 必须给出规避方法。
3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。`
// reviewPrompt 用于终审语义校验。
reviewPrompt = `你是 SmartFlow 的终审校验器。
请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。
只输出 JSON
{
"pass": true,
"reason": "中文简短结论",
"unmet": []
}
规则:
1. pass=true 时 unmet 必须为空数组。
2. pass=false 时 reason 必须给出核心差距。`
// summaryPrompt 用于最终面向用户的自然语言总结。
summaryPrompt = `你是 SmartFlow 的排程结果解读助手。
请基于输入输出 2~4 句中文总结:
1) 先说明本轮改了什么;
2) 再说明改动收益;
3) 若终审未完全通过,明确还差什么。
不要输出 JSON。`
// repairPrompt 用于终审失败后的单次修复动作。
repairPrompt = `你是 SmartFlow 的修复执行器。
当前方案未通过终审,请根据“未满足点”只做一次修复动作。
只允许输出一个 tool_callMove 或 Swap不允许 done。
输出格式(严格 JSON
{
"done": false,
"summary": "",
"goal_check": "本轮修复目标",
"decision": "修复决策依据",
"missing_info": [],
"tool_calls": [
{
"tool": "Move|Swap",
"params": {}
}
]
}
Move 参数必须使用标准键:
- task_item_id
- to_week
- to_day
- to_section_from
- to_section_to
禁止使用 new_week/new_day/section_from 等别名。`
)

View File

@@ -1,637 +0,0 @@
package schedulerefine
import (
"encoding/json"
"strings"
"testing"
"github.com/LoveLosita/smartflow/backend/model"
)
func TestQueryTargetTasksWeekFilterAndTaskID(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Name: "task-w12", Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
{TaskItemID: 2, Name: "task-w13", Week: 13, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
{TaskItemID: 3, Name: "task-w14", Week: 14, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{1: 1, 2: 2, 3: 3}}
paramsWeek := map[string]any{
"week_filter": []any{13.0, 14.0},
}
_, resultWeek := refineToolQueryTargetTasks(entries, paramsWeek, policy)
if !resultWeek.Success {
t.Fatalf("week_filter 查询失败: %s", resultWeek.Result)
}
var payloadWeek struct {
Count int `json:"count"`
Items []struct {
TaskItemID int `json:"task_item_id"`
Week int `json:"week"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(resultWeek.Result), &payloadWeek); err != nil {
t.Fatalf("解析 week_filter 结果失败: %v", err)
}
if payloadWeek.Count != 2 {
t.Fatalf("week_filter 期望返回 2 条,实际=%d", payloadWeek.Count)
}
for _, item := range payloadWeek.Items {
if item.Week != 13 && item.Week != 14 {
t.Fatalf("week_filter 过滤失败,出现非法周次=%d", item.Week)
}
}
paramsTaskID := map[string]any{
"week_filter": []any{13.0, 14.0},
"task_item_id": 2,
}
_, resultTaskID := refineToolQueryTargetTasks(entries, paramsTaskID, policy)
if !resultTaskID.Success {
t.Fatalf("task_item_id 查询失败: %s", resultTaskID.Result)
}
var payloadTaskID struct {
Count int `json:"count"`
Items []struct {
TaskItemID int `json:"task_item_id"`
Week int `json:"week"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(resultTaskID.Result), &payloadTaskID); err != nil {
t.Fatalf("解析 task_item_id 结果失败: %v", err)
}
if payloadTaskID.Count != 1 {
t.Fatalf("task_item_id 期望返回 1 条,实际=%d", payloadTaskID.Count)
}
if payloadTaskID.Items[0].TaskItemID != 2 || payloadTaskID.Items[0].Week != 13 {
t.Fatalf("task_item_id 过滤错误: %+v", payloadTaskID.Items[0])
}
}
func TestQueryAvailableSlotsExactSectionAlias(t *testing.T) {
params := map[string]any{
"week": 13,
"section_duration": 2,
"section_from": 1,
"section_to": 2,
"limit": 5,
}
_, result := refineToolQueryAvailableSlots(nil, params, planningWindow{Enabled: false})
if !result.Success {
t.Fatalf("QueryAvailableSlots 失败: %s", result.Result)
}
var payload struct {
Count int `json:"count"`
Slots []struct {
Week int `json:"week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
} `json:"slots"`
}
if err := json.Unmarshal([]byte(result.Result), &payload); err != nil {
t.Fatalf("解析 QueryAvailableSlots 结果失败: %v", err)
}
if payload.Count == 0 {
t.Fatalf("期望至少返回一个可用时段,实际=0")
}
for _, slot := range payload.Slots {
if slot.Week != 13 {
t.Fatalf("返回了错误周次: %+v", slot)
}
if slot.SectionFrom != 1 || slot.SectionTo != 2 {
t.Fatalf("精确节次过滤失败: %+v", slot)
}
}
}
func TestQueryAvailableSlotsWeekFilterDayFilterAlias(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Name: "task-w12", Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
{TaskItemID: 2, Name: "task-w17", Week: 17, DayOfWeek: 4, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
}
params := map[string]any{
"week_filter": []any{17.0},
"day_filter": []any{1.0, 2.0, 3.0},
"limit": 20,
}
_, result := refineToolQueryAvailableSlots(entries, params, planningWindow{Enabled: false})
if !result.Success {
t.Fatalf("QueryAvailableSlots 别名查询失败: %s", result.Result)
}
var payload struct {
Count int `json:"count"`
Slots []struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
} `json:"slots"`
}
if err := json.Unmarshal([]byte(result.Result), &payload); err != nil {
t.Fatalf("解析 week/day 过滤结果失败: %v", err)
}
if payload.Count == 0 {
t.Fatalf("week_filter/day_filter 查询应返回 W17 周一到周三空位,实际为空")
}
for _, slot := range payload.Slots {
if slot.Week != 17 {
t.Fatalf("week_filter 失效,出现 week=%d", slot.Week)
}
if slot.DayOfWeek < 1 || slot.DayOfWeek > 3 {
t.Fatalf("day_filter 失效,出现 day_of_week=%d", slot.DayOfWeek)
}
}
}
func TestCollectWorksetTaskIDsSourceWeekOnly(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
{TaskItemID: 2, Week: 14, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
{TaskItemID: 3, Week: 13, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
{TaskItemID: 4, Week: 14, DayOfWeek: 2, SectionFrom: 7, SectionTo: 8, Status: "suggested", Type: "task"},
}
slice := RefineSlicePlan{WeekFilter: []int{14, 13}}
originOrder := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
got := collectWorksetTaskIDs(entries, slice, originOrder)
if len(got) != 2 {
t.Fatalf("来源周收敛失败,期望 2 条,实际=%d, got=%v", len(got), got)
}
if got[0] != 2 || got[1] != 4 {
t.Fatalf("来源周结果错误,期望 [2 4],实际=%v", got)
}
}
func TestBuildSlicePlanDirectionalSourceTarget(t *testing.T) {
st := &ScheduleRefineState{
UserMessage: "帮我把第17周周四到周五的任务都收敛到17周的周一到周三优先放空位空位不够了再嵌入",
}
plan := buildSlicePlan(st)
if len(plan.WeekFilter) == 0 || plan.WeekFilter[0] != 17 {
t.Fatalf("week_filter 解析错误: %+v", plan.WeekFilter)
}
expectSource := []int{4, 5}
expectTarget := []int{1, 2, 3}
if len(plan.SourceDays) != len(expectSource) {
t.Fatalf("source_days 长度错误: got=%v", plan.SourceDays)
}
for i := range expectSource {
if plan.SourceDays[i] != expectSource[i] {
t.Fatalf("source_days 错误: got=%v", plan.SourceDays)
}
}
if len(plan.TargetDays) != len(expectTarget) {
t.Fatalf("target_days 长度错误: got=%v", plan.TargetDays)
}
for i := range expectTarget {
if plan.TargetDays[i] != expectTarget[i] {
t.Fatalf("target_days 错误: got=%v", plan.TargetDays)
}
}
}
func TestVerifyTaskCoordinateMismatch(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 28, Name: "task-w17-d4", Week: 17, DayOfWeek: 4, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{28: 1}}
params := map[string]any{
"task_item_id": 28,
"week": 17,
"day_of_week": 1,
"section_from": 1,
"section_to": 2,
}
_, result := refineToolVerify(entries, params, policy)
if result.Success {
t.Fatalf("期望 Verify 在任务坐标不匹配时失败,实际 success=true, result=%s", result.Result)
}
if result.ErrorCode != "VERIFY_FAILED" {
t.Fatalf("期望错误码 VERIFY_FAILED实际=%s", result.ErrorCode)
}
if !strings.Contains(result.Result, "不匹配") {
t.Fatalf("期望结果包含“不匹配”提示,实际=%s", result.Result)
}
}
func TestMoveRejectsSuggestedCourseEntry(t *testing.T) {
entries := []model.HybridScheduleEntry{
{
TaskItemID: 39,
Name: "面向对象程序设计-C++",
Type: "course",
Status: "suggested",
Week: 17,
DayOfWeek: 4,
SectionFrom: 7,
SectionTo: 8,
},
}
params := map[string]any{
"task_item_id": 39,
"to_week": 17,
"to_day": 1,
"to_section_from": 7,
"to_section_to": 8,
}
_, result := refineToolMove(entries, params, planningWindow{Enabled: false}, refineToolPolicy{OriginOrderMap: map[int]int{39: 1}})
if result.Success {
t.Fatalf("期望 course 类型的 suggested 条目不可移动,实际 success=true, result=%s", result.Result)
}
if !strings.Contains(result.Result, "可移动 suggested 任务") {
t.Fatalf("期望返回不可移动提示,实际=%s", result.Result)
}
}
func TestQueryAvailableSlotsSlotTypePureDisablesEmbed(t *testing.T) {
entries := []model.HybridScheduleEntry{
{
Name: "可嵌入课程",
Type: "course",
Status: "existing",
Week: 17,
DayOfWeek: 1,
SectionFrom: 1,
SectionTo: 2,
BlockForSuggested: false,
},
}
pureParams := map[string]any{
"week": 17,
"day_of_week": 1,
"section_from": 1,
"section_to": 2,
"slot_type": "pure",
}
_, pureResult := refineToolQueryAvailableSlots(entries, pureParams, planningWindow{Enabled: false})
if !pureResult.Success {
t.Fatalf("pure 查询失败: %s", pureResult.Result)
}
var purePayload struct {
Count int `json:"count"`
EmbeddedCount int `json:"embedded_count"`
FallbackUsed bool `json:"fallback_used"`
}
if err := json.Unmarshal([]byte(pureResult.Result), &purePayload); err != nil {
t.Fatalf("解析 pure 查询结果失败: %v", err)
}
if purePayload.Count != 0 || purePayload.EmbeddedCount != 0 || purePayload.FallbackUsed {
t.Fatalf("slot_type=pure 应禁用嵌入兜底,实际 payload=%+v", purePayload)
}
defaultParams := map[string]any{
"week": 17,
"day_of_week": 1,
"section_from": 1,
"section_to": 2,
}
_, defaultResult := refineToolQueryAvailableSlots(entries, defaultParams, planningWindow{Enabled: false})
if !defaultResult.Success {
t.Fatalf("default 查询失败: %s", defaultResult.Result)
}
var defaultPayload struct {
Count int `json:"count"`
EmbeddedCount int `json:"embedded_count"`
FallbackUsed bool `json:"fallback_used"`
}
if err := json.Unmarshal([]byte(defaultResult.Result), &defaultPayload); err != nil {
t.Fatalf("解析 default 查询结果失败: %v", err)
}
if defaultPayload.Count == 0 || defaultPayload.EmbeddedCount == 0 || !defaultPayload.FallbackUsed {
t.Fatalf("默认查询应允许嵌入候选,实际 payload=%+v", defaultPayload)
}
}
func TestCompileObjectiveAndEvaluateMoveAllPass(t *testing.T) {
initial := []model.HybridScheduleEntry{
{TaskItemID: 39, Name: "任务39", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 4, SectionFrom: 7, SectionTo: 8},
{TaskItemID: 51, Name: "任务51", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 9, SectionTo: 10},
}
final := []model.HybridScheduleEntry{
{TaskItemID: 39, Name: "任务39", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 7, SectionTo: 8},
{TaskItemID: 51, Name: "任务51", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 2, SectionFrom: 9, SectionTo: 10},
}
st := &ScheduleRefineState{
UserMessage: "把17周周四到周五任务收敛到周一到周三",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17},
SourceDays: []int{4, 5},
TargetDays: []int{1, 2, 3},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
if st.Objective.Mode != "move_all" {
t.Fatalf("期望目标模式 move_all实际=%s", st.Objective.Mode)
}
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
if !applied {
t.Fatalf("期望命中确定性终审")
}
if !pass {
t.Fatalf("期望确定性终审通过unmet=%v", unmet)
}
}
func TestCompileObjectiveAndEvaluateMoveAllFail(t *testing.T) {
initial := []model.HybridScheduleEntry{
{TaskItemID: 26, Name: "任务26", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 7, SectionTo: 8},
}
final := []model.HybridScheduleEntry{
{TaskItemID: 26, Name: "任务26", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 7, SectionTo: 8},
}
st := &ScheduleRefineState{
UserMessage: "把17周周四到周五任务收敛到周一到周三",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17},
SourceDays: []int{4, 5},
TargetDays: []int{1, 2, 3},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
if !applied {
t.Fatalf("期望命中确定性终审")
}
if pass {
t.Fatalf("期望确定性终审失败")
}
if len(unmet) == 0 {
t.Fatalf("期望返回未满足项")
}
}
func TestCompileObjectiveMoveRatioFromContractAndEvaluatePass(t *testing.T) {
initial, final := buildHalfTransferEntries(10, 5)
st := &ScheduleRefineState{
UserMessage: "17周任务太多帮我调整到16周",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17, 16},
},
Contract: RefineContract{
Intent: "将第17周任务匀一半到第16周",
HardRequirements: []string{"原第17周任务数调整为原来的一半", "调整到第16周的任务数为原第17周任务数的一半"},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
if st.Objective.Mode != "move_ratio" {
t.Fatalf("期望目标模式 move_ratio实际=%s", st.Objective.Mode)
}
if st.Objective.RequiredMoveMin != 5 || st.Objective.RequiredMoveMax != 5 {
t.Fatalf("半数迁移阈值错误: min=%d max=%d", st.Objective.RequiredMoveMin, st.Objective.RequiredMoveMax)
}
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
if !applied {
t.Fatalf("期望命中确定性终审")
}
if !pass {
t.Fatalf("期望半数迁移通过unmet=%v", unmet)
}
}
func TestCompileObjectiveMoveRatioFromContractAndEvaluateFail(t *testing.T) {
initial, final := buildHalfTransferEntries(10, 4)
st := &ScheduleRefineState{
UserMessage: "17周任务太多帮我调整到16周",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17, 16},
},
Contract: RefineContract{
Intent: "将第17周任务匀一半到第16周",
HardRequirements: []string{"原第17周任务数调整为原来的一半", "调整到第16周的任务数为原第17周任务数的一半"},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
if !applied {
t.Fatalf("期望命中确定性终审")
}
if pass {
t.Fatalf("期望半数迁移失败")
}
if len(unmet) == 0 {
t.Fatalf("期望返回未满足项")
}
}
func TestCompileObjectiveMoveRatioFromStructuredAssertion(t *testing.T) {
initial, final := buildHalfTransferEntries(10, 5)
st := &ScheduleRefineState{
UserMessage: "请把任务重新分配",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17, 16},
},
Contract: RefineContract{
Intent: "任务重新分配",
HardAssertions: []RefineAssertion{
{
Metric: "source_move_ratio_percent",
Operator: "==",
Value: 50,
Week: 17,
TargetWeek: 16,
},
},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
if st.Objective.Mode != "move_ratio" {
t.Fatalf("结构化断言未生效,期望 move_ratio实际=%s", st.Objective.Mode)
}
}
func buildHalfTransferEntries(total int, moved int) ([]model.HybridScheduleEntry, []model.HybridScheduleEntry) {
initial := make([]model.HybridScheduleEntry, 0, total)
final := make([]model.HybridScheduleEntry, 0, total)
for i := 1; i <= total; i++ {
initial = append(initial, model.HybridScheduleEntry{
TaskItemID: i,
Name: "task",
Type: "task",
Status: "suggested",
Week: 17,
DayOfWeek: 1,
SectionFrom: 1,
SectionTo: 2,
})
week := 17
if i <= moved {
week = 16
}
final = append(final, model.HybridScheduleEntry{
TaskItemID: i,
Name: "task",
Type: "task",
Status: "suggested",
Week: week,
DayOfWeek: 1,
SectionFrom: 1,
SectionTo: 2,
})
}
return initial, final
}
func TestNormalizeMovableTaskOrderByOrigin(t *testing.T) {
st := &ScheduleRefineState{
OriginOrderMap: map[int]int{
101: 1,
202: 2,
},
HybridEntries: []model.HybridScheduleEntry{
{TaskItemID: 202, Name: "task-202", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{TaskItemID: 101, Name: "task-101", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
},
}
changed := normalizeMovableTaskOrderByOrigin(st)
if !changed {
t.Fatalf("期望发生顺序归位")
}
sortHybridEntries(st.HybridEntries)
if st.HybridEntries[0].TaskItemID != 101 || st.HybridEntries[1].TaskItemID != 202 {
t.Fatalf("顺序归位失败: %+v", st.HybridEntries)
}
}
func TestTryNormalizeMovableTaskOrderByOriginSkipsAfterMinContextSwitch(t *testing.T) {
st := &ScheduleRefineState{
OriginOrderMap: map[int]int{
101: 1,
202: 2,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": true,
},
HybridEntries: []model.HybridScheduleEntry{
{TaskItemID: 202, Name: "task-202", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{TaskItemID: 101, Name: "task-101", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
},
}
changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st)
if !skipped {
t.Fatalf("期望 MinContextSwitch 成功后跳过顺序归位")
}
if changed {
t.Fatalf("跳过顺序归位时不应报告 changed=true")
}
if st.HybridEntries[0].TaskItemID != 202 || st.HybridEntries[1].TaskItemID != 101 {
t.Fatalf("跳过顺序归位后不应改写任务顺序: %+v", st.HybridEntries)
}
}
func TestEvaluateHardChecksSkipsOrderConstraintAfterMinContextSwitch(t *testing.T) {
st := &ScheduleRefineState{
UserMessage: "减少第15周科目切换",
OriginOrderMap: map[int]int{
101: 1,
202: 2,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": true,
},
InitialHybridEntries: []model.HybridScheduleEntry{
{TaskItemID: 101, Name: "概率任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{TaskItemID: 202, Name: "数电任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
},
HybridEntries: []model.HybridScheduleEntry{
{TaskItemID: 202, Name: "数电任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{TaskItemID: 101, Name: "概率任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
},
Objective: RefineObjective{
Mode: "move_all",
SourceWeeks: []int{15},
TargetWeeks: []int{15},
BaselineSourceTaskCount: 2,
RequiredMoveMin: 2,
RequiredMoveMax: 2,
},
SlicePlan: RefineSlicePlan{
WeekFilter: []int{15},
},
}
report := evaluateHardChecks(nil, nil, st, nil)
if !report.OrderPassed {
t.Fatalf("期望 MinContextSwitch 成功后跳过顺序终审,实际 issues=%v", report.OrderIssues)
}
}
func TestPrecheckToolCallPolicyRejectsRedundantSlotQuery(t *testing.T) {
st := &ScheduleRefineState{
SeenSlotQueries: make(map[string]struct{}),
EntriesVersion: 0,
}
call := reactToolCall{
Tool: "QueryAvailableSlots",
Params: map[string]any{
"week": 16,
"day_of_week": 1,
},
}
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); blocked {
t.Fatalf("首次查询不应被拒绝: %+v", blockedResult)
}
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); !blocked {
t.Fatalf("重复查询应被拒绝")
} else if blockedResult.ErrorCode != "QUERY_REDUNDANT" {
t.Fatalf("错误码不符合预期: %+v", blockedResult)
}
st.EntriesVersion++
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); blocked {
t.Fatalf("排程版本变化后应允许再次查询: %+v", blockedResult)
}
}
func TestCanonicalizeMoveParamsFromRepairAliases(t *testing.T) {
call := reactToolCall{
Tool: "Move",
Params: map[string]any{
"task_item_id": 16,
"new_week": 16,
"day_of_week": 1,
"section_from": 1,
"section_to": 2,
},
}
normalized := canonicalizeToolCall(call)
if _, ok := paramIntAny(normalized.Params, "to_week"); !ok {
t.Fatalf("to_week 规范化失败: %+v", normalized.Params)
}
if _, ok := paramIntAny(normalized.Params, "to_day"); !ok {
t.Fatalf("to_day 规范化失败: %+v", normalized.Params)
}
if _, ok := paramIntAny(normalized.Params, "to_section_from"); !ok {
t.Fatalf("to_section_from 规范化失败: %+v", normalized.Params)
}
if _, ok := paramIntAny(normalized.Params, "to_section_to"); !ok {
t.Fatalf("to_section_to 规范化失败: %+v", normalized.Params)
}
}
func TestDetectOrderIntentDefaultsToKeep(t *testing.T) {
if !detectOrderIntent("16周总体任务太多了帮我移动一半到12周") {
t.Fatalf("未显式放宽顺序时,默认应保持顺序")
}
}
func TestDetectOrderIntentExplicitAllowReorder(t *testing.T) {
if detectOrderIntent("这次顺序无所谓,可以打乱顺序") {
t.Fatalf("用户明确允许乱序时,应关闭顺序约束")
}
}

View File

@@ -1,53 +0,0 @@
package schedulerefine
import (
"context"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// scheduleRefineRunner 是“单次图运行”的请求级依赖容器。
//
// 职责边界:
// 1. 负责收口模型与阶段回调,避免 graph.go 出现大量闭包;
// 2. 负责把节点函数适配为统一签名;
// 3. 不负责分支决策(当前链路为线性图)。
type scheduleRefineRunner struct {
chatModel *ark.ChatModel
emitStage func(stage, detail string)
}
func newScheduleRefineRunner(chatModel *ark.ChatModel, emitStage func(stage, detail string)) *scheduleRefineRunner {
return &scheduleRefineRunner{
chatModel: chatModel,
emitStage: emitStage,
}
}
func (r *scheduleRefineRunner) contractNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runContractNode(ctx, r.chatModel, st, r.emitStage)
}
func (r *scheduleRefineRunner) planNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runPlanNode(ctx, r.chatModel, st, r.emitStage)
}
func (r *scheduleRefineRunner) sliceNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runSliceNode(ctx, st, r.emitStage)
}
func (r *scheduleRefineRunner) routeNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runCompositeRouteNode(ctx, st, r.emitStage)
}
func (r *scheduleRefineRunner) reactNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runReactLoopNode(ctx, r.chatModel, st, r.emitStage)
}
func (r *scheduleRefineRunner) hardCheckNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runHardCheckNode(ctx, r.chatModel, st, r.emitStage)
}
func (r *scheduleRefineRunner) summaryNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runSummaryNode(ctx, r.chatModel, st, r.emitStage)
}

View File

@@ -1,377 +0,0 @@
package schedulerefine
import (
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
// 固定业务时区,避免“今天/明天”在容器默认时区下偏移。
timezoneName = "Asia/Shanghai"
// 统一分钟级时间文本格式。
datetimeLayout = "2006-01-02 15:04"
// 预算默认值。
defaultPlanMax = 2
defaultExecuteMax = 24
defaultPerTaskBudget = 4
defaultReplanMax = 2
defaultCompositeRetry = 2
defaultRepairReserve = 1
)
// RefineContract 表示本轮微调意图契约。
type RefineContract struct {
Intent string `json:"intent"`
Strategy string `json:"strategy"`
HardRequirements []string `json:"hard_requirements"`
HardAssertions []RefineAssertion `json:"hard_assertions,omitempty"`
KeepRelativeOrder bool `json:"keep_relative_order"`
OrderScope string `json:"order_scope"`
}
// RefineAssertion 表示可由后端直接判定的结构化硬断言。
//
// 字段说明:
// 1. Metric断言指标名例如 source_move_ratio_percent
// 2. Operator比较操作符支持 == / <= / >= / between
// 3. Value/Min/Max阈值
// 4. Week/TargetWeek可选周次上下文。
type RefineAssertion struct {
Metric string `json:"metric"`
Operator string `json:"operator"`
Value int `json:"value,omitempty"`
Min int `json:"min,omitempty"`
Max int `json:"max,omitempty"`
Week int `json:"week,omitempty"`
TargetWeek int `json:"target_week,omitempty"`
}
// HardCheckReport 表示终审硬校验结果。
type HardCheckReport struct {
PhysicsPassed bool `json:"physics_passed"`
PhysicsIssues []string `json:"physics_issues,omitempty"`
IntentPassed bool `json:"intent_passed"`
IntentReason string `json:"intent_reason,omitempty"`
IntentUnmet []string `json:"intent_unmet,omitempty"`
OrderPassed bool `json:"order_passed"`
OrderIssues []string `json:"order_issues,omitempty"`
RepairTried bool `json:"repair_tried"`
}
// ReactRoundObservation 记录每轮 ReAct 的关键观察。
type ReactRoundObservation struct {
Round int `json:"round"`
GoalCheck string `json:"goal_check,omitempty"`
Decision string `json:"decision,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolParams map[string]any `json:"tool_params,omitempty"`
ToolSuccess bool `json:"tool_success"`
ToolErrorCode string `json:"tool_error_code,omitempty"`
ToolResult string `json:"tool_result,omitempty"`
Reflect string `json:"reflect,omitempty"`
}
// PlannerPlan 表示 Planner 生成的阶段执行计划。
type PlannerPlan struct {
Summary string `json:"summary"`
Steps []string `json:"steps,omitempty"`
}
// RefineSlicePlan 表示切片节点输出。
type RefineSlicePlan struct {
WeekFilter []int `json:"week_filter,omitempty"`
SourceDays []int `json:"source_days,omitempty"`
TargetDays []int `json:"target_days,omitempty"`
ExcludeSections []int `json:"exclude_sections,omitempty"`
Reason string `json:"reason,omitempty"`
}
// RefineObjective 表示“可执行且可校验”的目标约束。
//
// 设计说明:
// 1. 由 contract/slice 从自然语言编译得到;
// 2. 执行阶段done 收口与终审阶段hard_check共用同一份约束
// 3. 避免“执行逻辑与终审逻辑各说各话”。
type RefineObjective struct {
Mode string `json:"mode,omitempty"` // none | move_all | move_ratio
SourceWeeks []int `json:"source_weeks,omitempty"`
TargetWeeks []int `json:"target_weeks,omitempty"`
SourceDays []int `json:"source_days,omitempty"`
TargetDays []int `json:"target_days,omitempty"`
ExcludeSections []int `json:"exclude_sections,omitempty"`
BaselineSourceTaskCount int `json:"baseline_source_task_count,omitempty"`
RequiredMoveMin int `json:"required_move_min,omitempty"`
RequiredMoveMax int `json:"required_move_max,omitempty"`
Reason string `json:"reason,omitempty"`
}
// ScheduleRefineState 是连续微调图的统一状态。
type ScheduleRefineState struct {
// 1) 请求上下文
TraceID string
UserID int
ConversationID string
UserMessage string
RequestNow time.Time
RequestNowText string
// 2) 继承自预览快照的数据
TaskClassIDs []int
Constraints []string
// InitialHybridEntries 保存本轮微调开始前的基线,用于终审做“前后对比”。
// 说明:
// 1. 只读语义,不参与执行期改写;
// 2. 终审可基于它判断“来源任务是否真正迁移到目标区域”。
InitialHybridEntries []model.HybridScheduleEntry
HybridEntries []model.HybridScheduleEntry
AllocatedItems []model.TaskClassItem
CandidatePlans []model.UserWeekSchedule
// 3) 本轮执行状态
UserIntent string
Contract RefineContract
PlanMax int
PerTaskBudget int
ExecuteMax int
ReplanMax int
// CompositeRetryMax 表示复合路由失败后的最大重试次数(不含首次尝试)。
CompositeRetryMax int
PlanUsed int
ReplanUsed int
MaxRounds int
RepairReserve int
RoundUsed int
ActionLogs []string
ConsecutiveFailures int
ThinkingBoostArmed bool
ObservationHistory []ReactRoundObservation
CurrentPlan PlannerPlan
BatchMoveAllowed bool
// DisableCompositeTools=true 表示已进入 ReAct 兜底,禁止再调用复合工具。
DisableCompositeTools bool
// CompositeRouteTried 标记是否尝试过“复合批处理路由”。
CompositeRouteTried bool
// CompositeRouteSucceeded 标记复合批处理路由是否已完成“复合分支出站”。
//
// 说明:
// 1. true 表示当前链路可以跳过 ReAct 兜底,直接进入 hard_check
// 2. 它不等价于“终审已通过”,终审是否通过仍以后续 HardCheck 结果为准;
// 3. 这样区分是为了避免“复合工具已成功执行,但业务目标要等终审裁决”时被误判为失败。
CompositeRouteSucceeded bool
TaskActionUsed map[int]int
EntriesVersion int
SeenSlotQueries map[string]struct{}
// RequiredCompositeTool 表示本轮策略要求“必须至少成功一次”的复合工具。
// 取值约定:"" | "SpreadEven" | "MinContextSwitch"。
RequiredCompositeTool string
// CompositeToolCalled 记录复合工具是否至少调用过一次(不区分成功失败)。
CompositeToolCalled map[string]bool
// CompositeToolSuccess 记录复合工具是否至少成功过一次。
CompositeToolSuccess map[string]bool
SlicePlan RefineSlicePlan
Objective RefineObjective
WorksetTaskIDs []int
WorksetCursor int
CurrentTaskID int
CurrentTaskAttempt int
LastFailedCallSignature string
OriginOrderMap map[int]int
// 4) 终审状态
HardCheck HardCheckReport
// 5) 最终输出
FinalSummary string
Completed bool
}
// NewScheduleRefineState 基于上一版预览快照初始化状态。
//
// 职责边界:
// 1. 负责初始化预算、上下文字段与可变状态容器;
// 2. 负责拷贝 preview 数据,避免跨请求引用污染;
// 3. 不负责做任何调度动作。
func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState {
now := nowToMinute()
st := &ScheduleRefineState{
TraceID: strings.TrimSpace(traceID),
UserID: userID,
ConversationID: strings.TrimSpace(conversationID),
UserMessage: strings.TrimSpace(userMessage),
RequestNow: now,
RequestNowText: now.In(loadLocation()).Format(datetimeLayout),
PlanMax: defaultPlanMax,
PerTaskBudget: defaultPerTaskBudget,
ExecuteMax: defaultExecuteMax,
ReplanMax: defaultReplanMax,
CompositeRetryMax: defaultCompositeRetry,
RepairReserve: defaultRepairReserve,
MaxRounds: defaultExecuteMax + defaultRepairReserve,
ActionLogs: make([]string, 0, 32),
ObservationHistory: make([]ReactRoundObservation, 0, 24),
TaskActionUsed: make(map[int]int),
SeenSlotQueries: make(map[string]struct{}),
OriginOrderMap: make(map[int]int),
CompositeToolCalled: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CurrentPlan: PlannerPlan{
Summary: "初始化完成,等待 Planner 生成执行计划。",
},
SlicePlan: RefineSlicePlan{
Reason: "尚未切片",
},
}
if preview == nil {
return st
}
st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...)
st.InitialHybridEntries = cloneHybridEntries(preview.HybridEntries)
st.HybridEntries = cloneHybridEntries(preview.HybridEntries)
st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems)
st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans)
st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries)
return st
}
func loadLocation() *time.Location {
loc, err := time.LoadLocation(timezoneName)
if err != nil {
return time.Local
}
return loc
}
func nowToMinute() time.Time {
return time.Now().In(loadLocation()).Truncate(time.Minute)
}
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
if len(src) == 0 {
return nil
}
dst := make([]model.HybridScheduleEntry, len(src))
copy(dst, src)
return dst
}
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
if len(src) == 0 {
return nil
}
dst := make([]model.TaskClassItem, 0, len(src))
for _, item := range src {
copied := item
if item.CategoryID != nil {
v := *item.CategoryID
copied.CategoryID = &v
}
if item.Order != nil {
v := *item.Order
copied.Order = &v
}
if item.Content != nil {
v := *item.Content
copied.Content = &v
}
if item.Status != nil {
v := *item.Status
copied.Status = &v
}
if item.EmbeddedTime != nil {
t := *item.EmbeddedTime
copied.EmbeddedTime = &t
}
dst = append(dst, copied)
}
return dst
}
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
if len(src) == 0 {
return nil
}
dst := make([]model.UserWeekSchedule, 0, len(src))
for _, week := range src {
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
copy(eventsCopy, week.Events)
dst = append(dst, model.UserWeekSchedule{
Week: week.Week,
Events: eventsCopy,
})
}
return dst
}
// buildOriginOrderMap 构建 suggested 任务的初始顺序基线task_item_id -> rank
func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int {
orderMap := make(map[int]int)
if len(entries) == 0 {
return orderMap
}
suggested := make([]model.HybridScheduleEntry, 0, len(entries))
for _, entry := range entries {
if isMovableSuggestedTask(entry) {
suggested = append(suggested, entry)
}
}
sort.SliceStable(suggested, func(i, j int) bool {
left := suggested[i]
right := suggested[j]
if left.Week != right.Week {
return left.Week < right.Week
}
if left.DayOfWeek != right.DayOfWeek {
return left.DayOfWeek < right.DayOfWeek
}
if left.SectionFrom != right.SectionFrom {
return left.SectionFrom < right.SectionFrom
}
if left.SectionTo != right.SectionTo {
return left.SectionTo < right.SectionTo
}
return left.TaskItemID < right.TaskItemID
})
for i, entry := range suggested {
orderMap[entry.TaskItemID] = i + 1
}
return orderMap
}
// FinalHardCheckPassed 判断“最终终审”是否整体通过。
//
// 职责边界:
// 1. 负责聚合 physics/order/intent 三类硬校验结果,给服务层与总结阶段统一复用;
// 2. 不负责触发终审,也不负责推导修复动作;
// 3. nil state 视为未通过,避免上层把缺失结果误判为成功。
func FinalHardCheckPassed(st *ScheduleRefineState) bool {
if st == nil {
return false
}
return st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import (
)
const (
// MinuteLayout 是 agent2 内部统一的分钟级时间文本格式。
// MinuteLayout 是 Agent 内部统一的分钟级时间文本格式。
//
// 设计原因:
// 1. agent 里大量场景只需要精确到分钟;
@@ -20,7 +20,7 @@ var (
shanghaiLoc *time.Location
)
// ShanghaiLocation 返回 agent2 内部统一使用的东八区时区。
// ShanghaiLocation 返回 Agent 内部统一使用的东八区时区。
func ShanghaiLocation() *time.Location {
shanghaiLocOnce.Do(func() {
loc, err := time.LoadLocation("Asia/Shanghai")

View File

@@ -8,7 +8,7 @@ import (
// OpenAIChunkResponse 是 OpenAI 兼容的流式 chunk DTO。
//
// 之所以单独放到 agent2/stream
// 之所以单独放到 Agent/stream
// 1. 未来无论 quicknote、taskquery 还是 schedule只要需要 SSE 都会复用这套协议壳;
// 2. 这样 node/graph 层只关注“我要推什么内容”,不再自己拼 JSON
// 3. 后续如果前端协议升级,也能在这里集中改。

View File

@@ -1,183 +0,0 @@
package taskquery
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
)
const (
// 图节点:意图规划(一次模型调用,产出结构化查询计划)
taskQueryGraphNodePlan = "task_query_plan"
// 图节点:象限归一化(不调模型,只做参数规整)
taskQueryGraphNodeQuadrant = "task_query_quadrant"
// 图节点:时间锚定(不调模型,锁定绝对时间边界)
taskQueryGraphNodeTime = "task_query_time_anchor"
// 图节点:工具查询(调用 query_tasks 工具)
taskQueryGraphNodeQuery = "task_query_tool_query"
// 图节点:结果反思与回复(模型判断是否满足并产出回复/重试补丁)
taskQueryGraphNodeReflect = "task_query_reflect"
)
// QueryGraphRunInput 是任务查询图运行输入。
//
// 职责边界:
// 1. Model/Deps 提供图运行依赖;
// 2. UserMessage/RequestNowText 提供本次请求上下文;
// 3. MaxReflectRetry 控制“反思重试”上限;
// 4. EmitStage 是可选阶段推送钩子,不影响主链路成功与否。
type QueryGraphRunInput struct {
Model *ark.ChatModel
UserMessage string
RequestNowText string
Deps TaskQueryToolDeps
MaxReflectRetry int
EmitStage func(stage, detail string)
}
// RunTaskQueryGraph 执行“任务查询图编排”。
//
// 关键策略:
// 1. 规划节点只调用一次模型,统一产出查询计划;
// 2. 查询节点优先按计划查,若为空先自动放宽一次(无额外模型调用);
// 3. 反思节点最多重试 2 次,每次决定“是否满足、是否继续、如何补丁”。
func RunTaskQueryGraph(ctx context.Context, input QueryGraphRunInput) (string, error) {
// 1. 启动前硬校验。
if input.Model == nil {
return "", errors.New("task query graph: model is nil")
}
if err := input.Deps.validate(); err != nil {
return "", err
}
// 2. 构建工具包,并拿到 query_tasks 可执行工具。
toolBundle, err := BuildTaskQueryToolBundle(ctx, input.Deps)
if err != nil {
return "", err
}
toolMap, err := buildInvokableToolMap(toolBundle)
if err != nil {
return "", err
}
queryTool, exists := toolMap[ToolNameTaskQueryTasks]
if !exists {
return "", fmt.Errorf("task query graph: tool %s not found", ToolNameTaskQueryTasks)
}
// 3. 初始化状态:请求时间为空时做本地兜底。
requestNow := strings.TrimSpace(input.RequestNowText)
if requestNow == "" {
requestNow = time.Now().In(time.Local).Format("2006-01-02 15:04")
}
state := NewTaskQueryState(strings.TrimSpace(input.UserMessage), requestNow, input.MaxReflectRetry)
// 4. 封装 runner把“依赖注入”和“节点逻辑”解耦。
runner := newTaskQueryGraphRunner(input, queryTool)
// 5. 只在本次请求内构图并执行,避免跨请求共享状态。
graph := compose.NewGraph[*TaskQueryState, *TaskQueryState]()
if err = graph.AddLambdaNode(taskQueryGraphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil {
return "", err
}
if err = graph.AddLambdaNode(taskQueryGraphNodeQuadrant, compose.InvokableLambda(runner.quadrantNode)); err != nil {
return "", err
}
if err = graph.AddLambdaNode(taskQueryGraphNodeTime, compose.InvokableLambda(runner.timeAnchorNode)); err != nil {
return "", err
}
if err = graph.AddLambdaNode(taskQueryGraphNodeQuery, compose.InvokableLambda(runner.queryNode)); err != nil {
return "", err
}
if err = graph.AddLambdaNode(taskQueryGraphNodeReflect, compose.InvokableLambda(runner.reflectNode)); err != nil {
return "", err
}
// 连线START -> plan -> quadrant -> time -> query -> reflect
if err = graph.AddEdge(compose.START, taskQueryGraphNodePlan); err != nil {
return "", err
}
if err = graph.AddEdge(taskQueryGraphNodePlan, taskQueryGraphNodeQuadrant); err != nil {
return "", err
}
if err = graph.AddEdge(taskQueryGraphNodeQuadrant, taskQueryGraphNodeTime); err != nil {
return "", err
}
if err = graph.AddEdge(taskQueryGraphNodeTime, taskQueryGraphNodeQuery); err != nil {
return "", err
}
if err = graph.AddEdge(taskQueryGraphNodeQuery, taskQueryGraphNodeReflect); err != nil {
return "", err
}
// 分支reflect 后要么结束,要么回到 query 重试。
if err = graph.AddBranch(taskQueryGraphNodeReflect, compose.NewGraphBranch(
runner.nextAfterReflect,
map[string]bool{
taskQueryGraphNodeQuery: true,
compose.END: true,
},
)); err != nil {
return "", err
}
maxRunSteps := 24 + state.MaxReflectRetry*4
if maxRunSteps < 24 {
maxRunSteps = 24
}
runnable, err := graph.Compile(ctx,
compose.WithGraphName("TaskQueryGraph"),
compose.WithMaxRunSteps(maxRunSteps),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return "", err
}
finalState, err := runnable.Invoke(ctx, state)
if err != nil {
return "", err
}
if finalState == nil {
return "", errors.New("task query graph: final state is nil")
}
reply := strings.TrimSpace(finalState.FinalReply)
if reply == "" {
reply = buildTaskQueryFallbackReply(finalState.LastQueryItems)
}
return reply, nil
}
type taskQueryGraphRunner struct {
input QueryGraphRunInput
queryTool tool.InvokableTool
}
func newTaskQueryGraphRunner(input QueryGraphRunInput, queryTool tool.InvokableTool) *taskQueryGraphRunner {
return &taskQueryGraphRunner{
input: input,
queryTool: queryTool,
}
}
func (r *taskQueryGraphRunner) emit(stage, detail string) {
if r.input.EmitStage == nil {
return
}
r.input.EmitStage(stage, detail)
}
func (r *taskQueryGraphRunner) nextAfterReflect(ctx context.Context, st *TaskQueryState) (string, error) {
_ = ctx
if st != nil && st.NeedRetry {
return taskQueryGraphNodeQuery, nil
}
return compose.END, nil
}

View File

@@ -1,839 +0,0 @@
package taskquery
import (
"context"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
type taskQueryPlanOutput struct {
UserGoal string `json:"user_goal"`
Quadrants []int `json:"quadrants"`
SortBy string `json:"sort_by"`
Order string `json:"order"`
Limit int `json:"limit"`
IncludeCompleted *bool `json:"include_completed"`
Keyword string `json:"keyword"`
DeadlineBefore string `json:"deadline_before"`
DeadlineAfter string `json:"deadline_after"`
}
type taskQueryReflectOutput struct {
Satisfied bool `json:"satisfied"`
NeedRetry bool `json:"need_retry"`
Reason string `json:"reason"`
Reply string `json:"reply"`
RetryPatch taskQueryRetryPatch `json:"retry_patch"`
}
type taskQueryRetryPatch struct {
Quadrants *[]int `json:"quadrants,omitempty"`
SortBy *string `json:"sort_by,omitempty"`
Order *string `json:"order,omitempty"`
Limit *int `json:"limit,omitempty"`
IncludeCompleted *bool `json:"include_completed,omitempty"`
Keyword *string `json:"keyword,omitempty"`
DeadlineBefore *string `json:"deadline_before,omitempty"`
DeadlineAfter *string `json:"deadline_after,omitempty"`
}
var (
// explicitLimitPatterns 用于从用户原话提取“显式数量要求”。
//
// 例子:
// 1. 前3个任务
// 2. 给我5条
// 3. top 10
explicitLimitPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\btop\s*(\d{1,2})\b`),
regexp.MustCompile(`前\s*(\d{1,2})\s*(个|条|项)?`),
regexp.MustCompile(`(\d{1,2})\s*(个|条|项)\s*任务?`),
regexp.MustCompile(`给我\s*(\d{1,2})\s*(个|条|项)?`),
}
// chineseDigitMap 支持常见中文数字(用于“前五个”“来三个”这类口语)。
chineseDigitMap = map[rune]int{
'一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5,
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
}
)
func (r *taskQueryGraphRunner) planNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
// 1. 防御校验state 为空时直接返回,避免后续节点空指针。
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in plan node")
}
// 2. 规划节点只调用一次模型,把查询意图打包成结构化计划。
r.emit("task_query.plan.generating", "正在一次性规划查询范围、排序和时间条件。")
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
请输出任务查询计划 JSON。`, st.RequestNowText, st.UserMessage)
raw, err := callTaskQueryModelForJSON(ctx, r.input.Model, TaskQueryPlanPrompt, prompt, 260)
if err != nil {
// 3. 模型失败时不直接终止:回退到默认计划,保证可用性。
st.UserGoal = "查询任务"
st.Plan = defaultTaskQueryPlan()
return st, nil
}
planned, parseErr := parseTaskQueryJSON[taskQueryPlanOutput](raw)
if parseErr != nil {
// 4. JSON 异常同样回退默认计划,避免用户请求直接失败。
st.UserGoal = "查询任务"
st.Plan = defaultTaskQueryPlan()
return st, nil
}
// 5. 规划结果统一规范化,保证后续节点拿到稳定参数。
st.UserGoal = strings.TrimSpace(planned.UserGoal)
if st.UserGoal == "" {
st.UserGoal = "查询任务"
}
st.Plan = normalizePlan(taskQueryPlanOutput{
UserGoal: planned.UserGoal,
Quadrants: planned.Quadrants,
SortBy: planned.SortBy,
Order: planned.Order,
Limit: planned.Limit,
IncludeCompleted: planned.IncludeCompleted,
Keyword: planned.Keyword,
DeadlineBefore: planned.DeadlineBefore,
DeadlineAfter: planned.DeadlineAfter,
})
// 6. 若用户原话里有明确数量要求例如“给我3个”强制覆盖 plan.limit。
// 这样即使规划模型漏掉 limit也不会影响最终返回条数预期。
if explicitLimit, found := extractExplicitLimitFromUser(st.UserMessage); found {
st.ExplicitLimit = explicitLimit
st.Plan.Limit = explicitLimit
}
return st, nil
}
func (r *taskQueryGraphRunner) quadrantNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
_ = ctx
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in quadrant node")
}
// 1. 象限节点不调用模型,只做“象限参数兜底与去重”。
// 2. 为空表示全象限,非空表示指定象限。
r.emit("task_query.quadrant.routing", "正在归一化象限筛选范围。")
st.Plan.Quadrants = normalizeQuadrants(st.Plan.Quadrants)
return st, nil
}
func (r *taskQueryGraphRunner) timeAnchorNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
_ = ctx
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in time anchor node")
}
// 1. 时间节点不再调用模型,只负责把规划中的时间文本解析为绝对时间对象。
// 2. 解析失败时清空该边界,避免非法时间导致整条查询失败。
r.emit("task_query.time.anchoring", "正在锁定时间过滤边界。")
applyTimeAnchorOnPlan(&st.Plan)
return st, nil
}
func (r *taskQueryGraphRunner) queryNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in query node")
}
// 1. 按当前计划执行工具查询。
r.emit("task_query.tool.querying", "正在查询任务数据。")
items, err := r.executePlanByTool(ctx, st.Plan)
if err != nil {
// 查询失败不抛出硬错误,交给反思节点决定如何回复用户。
st.LastQueryItems = make([]TaskQueryToolRecord, 0)
st.LastQueryTotal = 0
st.ReflectReason = "查询工具执行失败"
return st, nil
}
st.LastQueryItems = items
st.LastQueryTotal = len(items)
// 2. 额外优化:若结果为空且还没自动放宽过,则先放宽一次再查询(无额外模型调用)。
if st.LastQueryTotal == 0 && !st.AutoBroadenApplied {
plan, broadened := autoBroadenPlan(st.Plan)
if broadened {
st.AutoBroadenApplied = true
st.Plan = plan
r.emit("task_query.tool.broadened", "首次查询为空,已自动放宽条件再试一次。")
retryItems, retryErr := r.executePlanByTool(ctx, st.Plan)
if retryErr == nil {
st.LastQueryItems = retryItems
st.LastQueryTotal = len(retryItems)
}
}
}
return st, nil
}
func (r *taskQueryGraphRunner) reflectNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in reflect node")
}
// 1. 反思节点负责三件事:
// 1.1 判断当前结果是否满足用户诉求;
// 1.2 需要重试时给出最小 patch
// 1.3 同时给出可直接返回用户的中文回复。
r.emit("task_query.reflecting", "正在判断结果是否贴合你的需求。")
reflectPrompt := buildReflectUserPrompt(st)
raw, err := callTaskQueryModelForJSON(ctx, r.input.Model, TaskQueryReflectPrompt, reflectPrompt, 380)
if err != nil {
// 2. 反思调用失败时直接收束,避免无限等待。
st.NeedRetry = false
st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems)
return st, nil
}
reflectResult, parseErr := parseTaskQueryJSON[taskQueryReflectOutput](raw)
if parseErr != nil {
st.NeedRetry = false
st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems)
return st, nil
}
st.ReflectReason = strings.TrimSpace(reflectResult.Reason)
// 3. 满足需求时直接结束。
if reflectResult.Satisfied {
st.NeedRetry = false
st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply))
return st, nil
}
// 4. 不满足且允许重试时,应用 patch 并回到查询节点。
if reflectResult.NeedRetry && st.RetryCount < st.MaxReflectRetry {
st.Plan = applyRetryPatch(st.Plan, reflectResult.RetryPatch, st.ExplicitLimit)
st.RetryCount++
st.NeedRetry = true
if strings.TrimSpace(reflectResult.Reply) != "" {
// 4.1 这里先缓存中间回复,最终是否使用取决于后续是否成功命中。
st.FinalReply = strings.TrimSpace(reflectResult.Reply)
}
return st, nil
}
// 5. 不再重试:输出最终回复并结束。
st.NeedRetry = false
st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply))
return st, nil
}
func (r *taskQueryGraphRunner) executePlanByTool(ctx context.Context, plan QueryPlan) ([]TaskQueryToolRecord, error) {
// 1. 这里强制通过工具执行查询,而不是直接读 DAO。
// 目的:保持“工具边界”一致,后续迁移多工具编排时可复用同一协议。
if r.queryTool == nil {
return nil, fmt.Errorf("task query tool is nil")
}
merged := make([]TaskQueryToolRecord, 0, plan.Limit)
seen := make(map[int]struct{}, plan.Limit*2)
runOne := func(quadrant *int) error {
input := TaskQueryToolInput{
Quadrant: quadrant,
SortBy: plan.SortBy,
Order: plan.Order,
Limit: plan.Limit,
Keyword: plan.Keyword,
DeadlineBefore: plan.DeadlineBeforeText,
DeadlineAfter: plan.DeadlineAfterText,
}
includeCompleted := plan.IncludeCompleted
input.IncludeCompleted = &includeCompleted
rawInput, err := json.Marshal(input)
if err != nil {
return err
}
rawOutput, err := r.queryTool.InvokableRun(ctx, string(rawInput))
if err != nil {
return err
}
parsed, err := parseTaskQueryJSON[TaskQueryToolOutput](rawOutput)
if err != nil {
return err
}
for _, item := range parsed.Items {
if _, exists := seen[item.ID]; exists {
continue
}
seen[item.ID] = struct{}{}
merged = append(merged, item)
}
return nil
}
// 2. Quadrants 为空表示全象限,执行一次无象限过滤查询。
if len(plan.Quadrants) == 0 {
if err := runOne(nil); err != nil {
return nil, err
}
} else {
// 3. 指定象限时逐个调用工具并合并去重。
for _, quadrant := range plan.Quadrants {
q := quadrant
if err := runOne(&q); err != nil {
return nil, err
}
}
}
// 4. 合并后再按计划统一排序,保证跨象限结果顺序稳定。
sortTaskQueryToolRecords(merged, plan)
if len(merged) > plan.Limit {
merged = merged[:plan.Limit]
}
return merged, nil
}
func normalizePlan(raw taskQueryPlanOutput) QueryPlan {
plan := defaultTaskQueryPlan()
plan.Quadrants = normalizeQuadrants(raw.Quadrants)
sortBy := strings.ToLower(strings.TrimSpace(raw.SortBy))
switch sortBy {
case "deadline", "priority", "id":
plan.SortBy = sortBy
}
order := strings.ToLower(strings.TrimSpace(raw.Order))
switch order {
case "asc", "desc":
plan.Order = order
}
if raw.Limit > 0 {
plan.Limit = raw.Limit
}
if plan.Limit > MaxTaskQueryLimit {
plan.Limit = MaxTaskQueryLimit
}
if plan.Limit <= 0 {
plan.Limit = DefaultTaskQueryLimit
}
if raw.IncludeCompleted != nil {
plan.IncludeCompleted = *raw.IncludeCompleted
}
plan.Keyword = strings.TrimSpace(raw.Keyword)
plan.DeadlineBeforeText = strings.TrimSpace(raw.DeadlineBefore)
plan.DeadlineAfterText = strings.TrimSpace(raw.DeadlineAfter)
applyTimeAnchorOnPlan(&plan)
return plan
}
func defaultTaskQueryPlan() QueryPlan {
return QueryPlan{
Quadrants: nil,
SortBy: "deadline",
Order: "asc",
Limit: DefaultTaskQueryLimit,
IncludeCompleted: false,
Keyword: "",
}
}
func normalizeQuadrants(quadrants []int) []int {
if len(quadrants) == 0 {
return nil
}
seen := make(map[int]struct{}, len(quadrants))
result := make([]int, 0, len(quadrants))
for _, q := range quadrants {
if q < 1 || q > 4 {
continue
}
if _, exists := seen[q]; exists {
continue
}
seen[q] = struct{}{}
result = append(result, q)
}
sort.Ints(result)
if len(result) == 0 {
return nil
}
if len(result) == 4 {
// 指定了全部象限时与“空=全象限”等价,统一归一化为 nil。
return nil
}
return result
}
func applyTimeAnchorOnPlan(plan *QueryPlan) {
if plan == nil {
return
}
before, errBefore := parseOptionalBoundaryTime(plan.DeadlineBeforeText, true)
after, errAfter := parseOptionalBoundaryTime(plan.DeadlineAfterText, false)
if errBefore != nil {
plan.DeadlineBefore = nil
plan.DeadlineBeforeText = ""
} else {
plan.DeadlineBefore = before
}
if errAfter != nil {
plan.DeadlineAfter = nil
plan.DeadlineAfterText = ""
} else {
plan.DeadlineAfter = after
}
// 边界冲突时清空,防止构造出“必为空结果”的死条件。
if plan.DeadlineBefore != nil && plan.DeadlineAfter != nil && plan.DeadlineAfter.After(*plan.DeadlineBefore) {
plan.DeadlineBefore = nil
plan.DeadlineAfter = nil
plan.DeadlineBeforeText = ""
plan.DeadlineAfterText = ""
}
}
func autoBroadenPlan(plan QueryPlan) (QueryPlan, bool) {
// 1. 仅允许自动放宽一次,且放宽必须“可解释”:
// 1.1 清空关键词;
// 1.2 放开完成状态;
// 1.3 清空时间边界;
// 1.4 不主动改象限和 limit避免语义漂移例如“简单任务”被放宽成全象限
changed := false
broadened := plan
if strings.TrimSpace(broadened.Keyword) != "" {
broadened.Keyword = ""
changed = true
}
if !broadened.IncludeCompleted {
broadened.IncludeCompleted = true
changed = true
}
if broadened.DeadlineBefore != nil || broadened.DeadlineAfter != nil ||
broadened.DeadlineBeforeText != "" || broadened.DeadlineAfterText != "" {
broadened.DeadlineBefore = nil
broadened.DeadlineAfter = nil
broadened.DeadlineBeforeText = ""
broadened.DeadlineAfterText = ""
changed = true
}
return broadened, changed
}
func applyRetryPatch(plan QueryPlan, patch taskQueryRetryPatch, explicitLimit int) QueryPlan {
next := plan
changed := false
if patch.Quadrants != nil {
next.Quadrants = normalizeQuadrants(*patch.Quadrants)
changed = true
}
if patch.SortBy != nil {
sortBy := strings.ToLower(strings.TrimSpace(*patch.SortBy))
if sortBy == "deadline" || sortBy == "priority" || sortBy == "id" {
next.SortBy = sortBy
changed = true
}
}
if patch.Order != nil {
order := strings.ToLower(strings.TrimSpace(*patch.Order))
if order == "asc" || order == "desc" {
next.Order = order
changed = true
}
}
if patch.Limit != nil {
// 用户显式指定数量时,锁定 limit不允许反思补丁改写。
if explicitLimit <= 0 {
limit := *patch.Limit
if limit <= 0 {
limit = DefaultTaskQueryLimit
}
if limit > MaxTaskQueryLimit {
limit = MaxTaskQueryLimit
}
next.Limit = limit
changed = true
}
}
if patch.IncludeCompleted != nil {
next.IncludeCompleted = *patch.IncludeCompleted
changed = true
}
if patch.Keyword != nil {
next.Keyword = strings.TrimSpace(*patch.Keyword)
changed = true
}
if patch.DeadlineBefore != nil {
next.DeadlineBeforeText = strings.TrimSpace(*patch.DeadlineBefore)
changed = true
}
if patch.DeadlineAfter != nil {
next.DeadlineAfterText = strings.TrimSpace(*patch.DeadlineAfter)
changed = true
}
if changed {
applyTimeAnchorOnPlan(&next)
}
// 双保险:显式数量存在时再次锁定,避免其他路径误改。
if explicitLimit > 0 {
next.Limit = explicitLimit
}
return next
}
func buildReflectUserPrompt(st *TaskQueryState) string {
planSummary := summarizePlan(st.Plan)
resultSummary := summarizeQueryItems(st.LastQueryItems, 6)
return fmt.Sprintf(`当前时间:%s
用户原话:%s
用户目标:%s
当前查询计划:%s
当前重试:%d/%d
查询结果摘要:
%s`,
st.RequestNowText,
st.UserMessage,
st.UserGoal,
planSummary,
st.RetryCount,
st.MaxReflectRetry,
resultSummary,
)
}
func summarizePlan(plan QueryPlan) string {
quadrants := "全部象限"
if len(plan.Quadrants) > 0 {
parts := make([]string, 0, len(plan.Quadrants))
for _, q := range plan.Quadrants {
parts = append(parts, strconv.Itoa(q))
}
quadrants = strings.Join(parts, ",")
}
return fmt.Sprintf("quadrants=%s sort=%s/%s limit=%d include_completed=%t keyword=%s before=%s after=%s",
quadrants, plan.SortBy, plan.Order, plan.Limit, plan.IncludeCompleted,
emptyToDash(plan.Keyword), emptyToDash(plan.DeadlineBeforeText), emptyToDash(plan.DeadlineAfterText))
}
func summarizeQueryItems(items []TaskQueryToolRecord, max int) string {
if len(items) == 0 {
return "无结果"
}
if max <= 0 {
max = 5
}
if len(items) > max {
items = items[:max]
}
lines := make([]string, 0, len(items))
for _, item := range items {
line := fmt.Sprintf("- #%d %s | 象限=%d | 完成=%t | 截止=%s",
item.ID, item.Title, item.PriorityGroup, item.IsCompleted, emptyToDash(item.DeadlineAt))
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
func buildTaskQueryFallbackReply(items []TaskQueryToolRecord) string {
if len(items) == 0 {
return "我这边暂时没找到匹配的任务。你可以再补一句比如“按截止时间最早的前3个”或“只看简单不重要”。"
}
// 1. 用最多 3 条摘要拼一个稳态回复,避免模型异常时空白返回。
preview := items
if len(preview) > 3 {
preview = preview[:3]
}
lines := make([]string, 0, len(preview))
for _, item := range preview {
lines = append(lines, fmt.Sprintf("%s%s", item.Title, item.PriorityLabel))
}
return fmt.Sprintf("我先给你筛到这些:%s。要不要我再按“更紧急”或“更简单”继续细化", strings.Join(lines, "、"))
}
// buildTaskQueryFinalReply 构建“确定性条数”的最终回复。
//
// 设计目的:
// 1. 让返回条数严格受 plan.limit 约束,避免 LLM 自由发挥导致“只说1条”
// 2. 仍可保留 LLM 的语气前缀,但清单主体由后端稳定渲染;
// 3. 无结果时统一走兜底文案。
func buildTaskQueryFinalReply(items []TaskQueryToolRecord, plan QueryPlan, llmReply string) string {
if len(items) == 0 {
base := buildTaskQueryFallbackReply(items)
if strings.TrimSpace(llmReply) == "" {
return base
}
return strings.TrimSpace(llmReply) + "\n" + base
}
desired := plan.Limit
if desired <= 0 {
desired = DefaultTaskQueryLimit
}
if desired > MaxTaskQueryLimit {
desired = MaxTaskQueryLimit
}
showCount := desired
if len(items) < showCount {
showCount = len(items)
}
preview := items[:showCount]
lines := make([]string, 0, len(preview))
for idx, item := range preview {
deadline := strings.TrimSpace(item.DeadlineAt)
if deadline == "" {
deadline = "无明确截止时间"
}
status := "未完成"
if item.IsCompleted {
status = "已完成"
}
lines = append(lines, fmt.Sprintf("%d. %s%s%s截止%s",
idx+1, item.Title, item.PriorityLabel, status, deadline))
}
header := fmt.Sprintf("给你整理了 %d 条任务:", showCount)
if lead := extractSafeReplyLead(llmReply); lead != "" {
header = lead + "\n" + header
}
reply := header + "\n" + strings.Join(lines, "\n")
if len(items) > showCount {
reply += fmt.Sprintf("\n另外还有 %d 条匹配任务,要不要我继续往下列?", len(items)-showCount)
}
return reply
}
// extractSafeReplyLead 从 LLM 回复中提取“安全前缀句”。
//
// 目的:
// 1. 防止 LLM 已经输出一整段列表时再次和后端列表拼接,造成双重输出;
// 2. 仅保留单行短句语气前缀,正文列表始终以后端确定性渲染为准。
func extractSafeReplyLead(llmReply string) string {
text := strings.TrimSpace(llmReply)
if text == "" {
return ""
}
// 有明显列表迹象时直接丢弃,避免重复列举。
lower := strings.ToLower(text)
if strings.Contains(text, "\n") || strings.Contains(text, "#") ||
strings.Contains(lower, "1.") || strings.Contains(text, "1、") || strings.Contains(text, "以下是") {
return ""
}
// 太长也不保留,避免把冗长模型输出混进最终回复。
if len([]rune(text)) > 30 {
return ""
}
return text
}
func sortTaskQueryToolRecords(items []TaskQueryToolRecord, plan QueryPlan) {
if len(items) <= 1 {
return
}
sortBy := strings.ToLower(strings.TrimSpace(plan.SortBy))
order := strings.ToLower(strings.TrimSpace(plan.Order))
if order != "desc" {
order = "asc"
}
sort.SliceStable(items, func(i, j int) bool {
left := items[i]
right := items[j]
switch sortBy {
case "priority":
if left.PriorityGroup != right.PriorityGroup {
if order == "desc" {
return left.PriorityGroup > right.PriorityGroup
}
return left.PriorityGroup < right.PriorityGroup
}
return left.ID > right.ID
case "id":
if order == "desc" {
return left.ID > right.ID
}
return left.ID < right.ID
default:
lTime, lOK := parseRecordDeadline(left.DeadlineAt)
rTime, rOK := parseRecordDeadline(right.DeadlineAt)
if lOK && rOK {
if !lTime.Equal(rTime) {
if order == "desc" {
return lTime.After(rTime)
}
return lTime.Before(rTime)
}
return left.ID > right.ID
}
if lOK && !rOK {
return true
}
if !lOK && rOK {
return false
}
return left.ID > right.ID
}
})
}
func parseRecordDeadline(raw string) (time.Time, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return time.Time{}, false
}
t, err := time.ParseInLocation("2006-01-02 15:04", text, time.Local)
if err != nil {
return time.Time{}, false
}
return t, true
}
func emptyToDash(text string) string {
if strings.TrimSpace(text) == "" {
return "-"
}
return strings.TrimSpace(text)
}
// extractExplicitLimitFromUser 从用户原话提取显式数量诉求。
//
// 解析策略:
// 1. 先匹配阿拉伯数字前3个/top 5/给我2条
// 2. 再匹配常见中文数字(前五个/来三个);
// 3. 统一限制在 1~20 之间。
func extractExplicitLimitFromUser(userMessage string) (int, bool) {
text := strings.TrimSpace(userMessage)
if text == "" {
return 0, false
}
for _, pattern := range explicitLimitPatterns {
matches := pattern.FindStringSubmatch(text)
if len(matches) < 2 {
continue
}
number, err := strconv.Atoi(strings.TrimSpace(matches[1]))
if err != nil {
continue
}
return normalizeExplicitLimit(number)
}
// 中文数字兜底:覆盖高频口语模式。
chinesePatterns := []string{"前", "来", "给我"}
for _, prefix := range chinesePatterns {
for digitRune, number := range chineseDigitMap {
token := prefix + string(digitRune)
if strings.Contains(text, token) {
return normalizeExplicitLimit(number)
}
// “前五个”“来三个”这类再补一个“个/条/项”尾缀判断。
for _, suffix := range []string{"个", "条", "项"} {
if strings.Contains(text, token+suffix) {
return normalizeExplicitLimit(number)
}
}
}
}
return 0, false
}
func normalizeExplicitLimit(number int) (int, bool) {
if number <= 0 {
return 0, false
}
if number > MaxTaskQueryLimit {
number = MaxTaskQueryLimit
}
return number, true
}
func callTaskQueryModelForJSON(
ctx context.Context,
model *ark.ChatModel,
systemPrompt string,
userPrompt string,
maxTokens int,
) (string, error) {
if model == nil {
return "", fmt.Errorf("task query model is nil")
}
messages := []*schema.Message{
schema.SystemMessage(systemPrompt),
schema.UserMessage(userPrompt),
}
opts := []einoModel.Option{
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0),
}
if maxTokens > 0 {
opts = append(opts, einoModel.WithMaxTokens(maxTokens))
}
resp, err := model.Generate(ctx, messages, opts...)
if err != nil {
return "", err
}
if resp == nil {
return "", fmt.Errorf("task query model returned nil")
}
text := strings.TrimSpace(resp.Content)
if text == "" {
return "", fmt.Errorf("task query model returned empty content")
}
return text, nil
}
func parseTaskQueryJSON[T any](raw string) (*T, error) {
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, fmt.Errorf("empty response")
}
// 1. 兼容 ```json 包裹格式。
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
// 2. 先尝试整体解析。
var out T
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
// 3. 若模型前后带了额外文本,则提取最外层对象再解析。
start := strings.Index(clean, "{")
end := strings.LastIndex(clean, "}")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("no json object found")
}
obj := clean[start : end+1]
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@@ -1,86 +0,0 @@
package taskquery
import (
"strings"
"testing"
)
// TestExtractExplicitLimitFromUser_Number
// 目的:验证用户原话里的阿拉伯数字数量诉求可以被正确提取。
func TestExtractExplicitLimitFromUser_Number(t *testing.T) {
limit, ok := extractExplicitLimitFromUser("给我3个优先级低的任务")
if !ok {
t.Fatalf("期望识别到显式数量")
}
if limit != 3 {
t.Fatalf("数量识别错误,期望=3 实际=%d", limit)
}
}
// TestExtractExplicitLimitFromUser_ChineseNumber
// 目的:验证常见中文数字(如“前五个”)也能识别数量。
func TestExtractExplicitLimitFromUser_ChineseNumber(t *testing.T) {
limit, ok := extractExplicitLimitFromUser("前五个简单任务给我看看")
if !ok {
t.Fatalf("期望识别到中文数量")
}
if limit != 5 {
t.Fatalf("数量识别错误,期望=5 实际=%d", limit)
}
}
// TestExtractExplicitLimitFromUser_LaiYiGe
// 目的:验证“来一个...”这种口语数量表达也能识别为 1。
func TestExtractExplicitLimitFromUser_LaiYiGe(t *testing.T) {
limit, ok := extractExplicitLimitFromUser("来一个我的简单任务")
if !ok {
t.Fatalf("期望识别到“来一个”的显式数量")
}
if limit != 1 {
t.Fatalf("数量识别错误,期望=1 实际=%d", limit)
}
}
// TestBuildTaskQueryFinalReply_RespectsLimit
// 目的:验证最终回复会按 plan.limit 输出对应条数,而不是由 LLM 自由决定条数。
func TestBuildTaskQueryFinalReply_RespectsLimit(t *testing.T) {
items := []TaskQueryToolRecord{
{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"},
{ID: 2, Title: "任务2", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-17 10:00"},
{ID: 3, Title: "任务3", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-18 10:00"},
}
reply := buildTaskQueryFinalReply(items, QueryPlan{Limit: 2}, "好的")
if !strings.Contains(reply, "整理了 2 条任务") {
t.Fatalf("回复未体现 limit=2reply=%s", reply)
}
if strings.Contains(reply, "3. ") {
t.Fatalf("回复不应出现第3条reply=%s", reply)
}
}
// TestBuildTaskQueryFinalReply_NoDuplicateList
// 目的:验证当 llmReply 已带列表内容时,不会和后端确定性列表重复拼接。
func TestBuildTaskQueryFinalReply_NoDuplicateList(t *testing.T) {
items := []TaskQueryToolRecord{
{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"},
}
llmReply := "以下是你的任务:\n#1 任务1"
reply := buildTaskQueryFinalReply(items, QueryPlan{Limit: 1}, llmReply)
if strings.Contains(reply, "以下是你的任务") {
t.Fatalf("不应保留 llm 列表头reply=%s", reply)
}
if !strings.Contains(reply, "整理了 1 条任务") {
t.Fatalf("应保留后端确定性列表头reply=%s", reply)
}
}
// TestApplyRetryPatch_RespectExplicitLimit
// 目的:验证用户显式数量存在时,反思补丁不能改写 limit。
func TestApplyRetryPatch_RespectExplicitLimit(t *testing.T) {
plan := QueryPlan{Limit: 1, SortBy: "deadline", Order: "asc"}
limit := 10
next := applyRetryPatch(plan, taskQueryRetryPatch{Limit: &limit}, 1)
if next.Limit != 1 {
t.Fatalf("显式数量锁应生效,期望=1 实际=%d", next.Limit)
}
}

View File

@@ -1,81 +0,0 @@
package taskquery
const (
// TaskQueryAssistantPrompt 是“任务查询”分支的系统提示词。
//
// 设计目标:
// 1. 把“先查工具再回答”的约束写死,减少模型直接编造任务的风险;
// 2. 约束输出风格:简洁、可执行、可追问;
// 3. 当用户需求不完整时,引导模型先做合理默认,再补充可选澄清。
TaskQueryAssistantPrompt = `你是 SmartFlow 的任务查询助手。
你的职责是:根据用户的问题,从任务工具中检索真实任务,再给出中文回复。
强约束:
1) 只要用户在“查任务/筛任务/排序任务/找任务”,必须优先调用 query_tasks 工具,不要凭空回答。
2) 工具返回为空时,直接说明“当前没有匹配任务”,并给一个简短下一步建议。
3) 结果较多时,默认展示前 3~5 条关键信息(标题、象限、截止时间、完成状态)。
4) 用户指令不完整时可先用默认参数查一次,再补一句澄清建议,不要反复追问。
5) 回复必须自然口语化,禁止输出 markdown 表格。`
// TaskQueryPlanPrompt 是“任务查询规划节点”的系统提示词。
//
// 设计目标:
// 1. 只调用一次模型,把“象限选择 + 排序 + 时间过滤 + 结果规模”统一规划出来;
// 2. 输出强约束 JSON便于后端节点稳定解析
// 3. 不要求模型直接生成最终回复,避免规划阶段混入废话。
TaskQueryPlanPrompt = `你是 SmartFlow 的任务查询规划器。
请根据用户原话输出“结构化查询计划”JSON供后端直接执行。
输出字段(只允许 JSON不要解释
{
"user_goal": "一句话总结用户诉求",
"quadrants": [1,2,3,4],
"sort_by": "deadline|priority|id",
"order": "asc|desc",
"limit": 1-20,
"include_completed": false,
"keyword": "可选关键词,或空字符串",
"deadline_before": "yyyy-MM-dd HH:mm 或空字符串",
"deadline_after": "yyyy-MM-dd HH:mm 或空字符串"
}
规则:
1) quadrants 为空数组表示“全部象限”。
2) 若用户没提排序,默认 deadline + asc。
3) 若用户没提数量limit 默认 5。
4) 时间字段必须是绝对时间或空字符串,不得输出相对时间。
5) 只有用户的语义偏向"我还有啥事要做"即了解自己待办的请求才优先1,2象限即重要并紧急或者重要不紧急若1,2象限没任务则自动退至3,4象限如果用户语义偏向"来点事情做做"那就说明用户需要无关紧要的事情做做则优先3,4象限即简单不重要或者不简单不重要。
6) 允许多选象限。`
// TaskQueryReflectPrompt 是“查询结果反思节点”的系统提示词。
//
// 设计目标:
// 1. 让模型判断“当前结果是否满足用户诉求”;
// 2. 若不满足,给出可执行的轻量 patch最多改几个关键条件
// 3. 同时输出可直接返回给用户的 reply减少额外生成调用。
TaskQueryReflectPrompt = `你是 SmartFlow 的任务查询结果审阅器。
你会看到:用户原话、当前查询计划、查询结果摘要、当前重试次数。
请仅输出 JSON
{
"satisfied": true/false,
"need_retry": true/false,
"reason": "一句话原因",
"reply": "可直接给用户看的中文回复",
"retry_patch": {
"quadrants": [1,2,3,4],
"sort_by": "deadline|priority|id",
"order": "asc|desc",
"limit": 1-20,
"include_completed": true/false,
"keyword": "字符串",
"deadline_before": "yyyy-MM-dd HH:mm 或空字符串",
"deadline_after": "yyyy-MM-dd HH:mm 或空字符串"
}
}
规则:
1) 若结果已满足satisfied=true 且 need_retry=false。
2) 若结果不满足且仍可尝试need_retry=true并给最小必要 patch。
3) 若不建议再试need_retry=false并在 reply 中说明当前最接近结果。`
)

View File

@@ -1,88 +0,0 @@
package taskquery
import "time"
const (
// DefaultTaskQueryLimit 是任务查询默认返回条数。
DefaultTaskQueryLimit = 5
// MaxTaskQueryLimit 是任务查询最大返回条数。
MaxTaskQueryLimit = 20
// DefaultReflectRetryMax 是反思重试默认上限。
DefaultReflectRetryMax = 2
)
// TaskQueryState 是任务查询图在节点间传递的统一状态容器。
//
// 职责边界:
// 1. 保存“规划参数、查询结果、反思决策、最终回复”;
// 2. 控制“是否重试 + 已重试次数”状态机;
// 3. 不负责真正查库,查库由工具执行。
type TaskQueryState struct {
// 请求上下文
UserMessage string
RequestNowText string
// 规划结果
UserGoal string
Plan QueryPlan
// ExplicitLimit 表示“用户原话中明确指定的数量”。
//
// 语义说明:
// 1. 0 代表未显式指定;
// 2. >0 时应锁定该数量,不允许反思补丁或自动放宽改写。
ExplicitLimit int
// 上一轮查询结果
LastQueryItems []TaskQueryToolRecord
LastQueryTotal int
// 自动放宽状态
AutoBroadenApplied bool
// 反思状态
RetryCount int
MaxReflectRetry int
NeedRetry bool
ReflectReason string
// 最终输出
FinalReply string
}
// QueryPlan 是“任务查询计划”的统一结构。
//
// 语义说明:
// 1. Quadrants 为空表示“查全部象限”;非空表示“只查这些象限”;
// 2. DeadlineBefore/AfterText 保留原始文本,方便日志和反思 prompt
// 3. DeadlineBefore/After 是解析后的时间对象,供工具调用使用。
type QueryPlan struct {
Quadrants []int
SortBy string
Order string
Limit int
IncludeCompleted bool
Keyword string
DeadlineBeforeText string
DeadlineAfterText string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// NewTaskQueryState 创建任务查询初始状态。
func NewTaskQueryState(userMessage, requestNowText string, maxReflectRetry int) *TaskQueryState {
if maxReflectRetry <= 0 {
maxReflectRetry = DefaultReflectRetryMax
}
return &TaskQueryState{
UserMessage: userMessage,
RequestNowText: requestNowText,
MaxReflectRetry: maxReflectRetry,
LastQueryItems: make([]TaskQueryToolRecord, 0),
NeedRetry: false,
ReflectReason: "",
AutoBroadenApplied: false,
}
}

View File

@@ -1,344 +0,0 @@
package taskquery
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"
)
const (
// ToolNameTaskQueryTasks 是“任务查询工具”对模型暴露的标准名称。
ToolNameTaskQueryTasks = "query_tasks"
// ToolDescTaskQueryTasks 是工具职责说明,给模型理解参数语义。
ToolDescTaskQueryTasks = "按象限/关键字/截止时间筛选并排序任务,返回结构化任务列表"
)
var (
// taskQueryTimeLayouts 是任务查询工具允许的时间输入格式白名单。
taskQueryTimeLayouts = []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02",
}
)
// TaskQueryToolDeps 描述任务查询工具依赖的外部能力。
//
// 职责边界:
// 1. QueryTasks 负责真实数据读取;
// 2. 工具层只负责参数校验与结果封装,不直接耦合 DAO 实现。
type TaskQueryToolDeps struct {
QueryTasks func(ctx context.Context, req TaskQueryRequest) ([]TaskRecord, error)
}
func (d TaskQueryToolDeps) validate() error {
// 1. 工具没有 QueryTasks 依赖就无法提供任何真实结果,启动时直接失败。
if d.QueryTasks == nil {
return errors.New("task query tool deps: QueryTasks is nil")
}
return nil
}
// TaskQueryToolBundle 是任务查询工具包输出。
//
// 说明:
// 1. Tools 用于实际执行;
// 2. ToolInfos 用于模型注册工具 schema。
type TaskQueryToolBundle struct {
Tools []tool.BaseTool
ToolInfos []*schema.ToolInfo
}
// TaskQueryRequest 是工具层到业务层的内部查询请求。
//
// 职责边界:
// 1. 只承载“查询条件”,不承载数据库/缓存实现细节;
// 2. UserID 不由模型提供,必须由服务层上下文注入。
type TaskQueryRequest struct {
UserID int
Quadrant *int
SortBy string
Order string
Limit int
IncludeCompleted bool
Keyword string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// TaskRecord 是业务层返回给工具层的任务记录。
type TaskRecord struct {
ID int
Title string
PriorityGroup int
IsCompleted bool
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// TaskQueryToolInput 是对模型暴露的工具输入结构。
//
// 参数语义:
// 1. quadrant 可选1~4
// 2. sort_by 可选deadline/priority/id
// 3. order 可选asc/desc
// 4. limit 可选:默认 5上限 20
// 5. include_completed 可选:默认 false。
type TaskQueryToolInput struct {
Quadrant *int `json:"quadrant,omitempty" jsonschema:"description=可选象限(1~4)"`
SortBy string `json:"sort_by,omitempty" jsonschema:"description=排序字段(deadline|priority|id)"`
Order string `json:"order,omitempty" jsonschema:"description=排序方向(asc|desc)"`
Limit int `json:"limit,omitempty" jsonschema:"description=返回条数默认5上限20"`
IncludeCompleted *bool `json:"include_completed,omitempty" jsonschema:"description=是否包含已完成任务默认false"`
Keyword string `json:"keyword,omitempty" jsonschema:"description=可选标题关键词,模糊匹配"`
DeadlineBefore string `json:"deadline_before,omitempty" jsonschema:"description=可选截止上界支持RFC3339或yyyy-MM-dd HH:mm"`
DeadlineAfter string `json:"deadline_after,omitempty" jsonschema:"description=可选截止下界支持RFC3339或yyyy-MM-dd HH:mm"`
}
// TaskQueryToolOutput 是返回给模型的结构化结果。
type TaskQueryToolOutput struct {
Total int `json:"total"`
Items []TaskQueryToolRecord `json:"items"`
}
// TaskQueryToolRecord 是单条任务输出结构。
type TaskQueryToolRecord struct {
ID int `json:"id"`
Title string `json:"title"`
PriorityGroup int `json:"priority_group"`
PriorityLabel string `json:"priority_label"`
IsCompleted bool `json:"is_completed"`
DeadlineAt string `json:"deadline_at,omitempty"`
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"`
}
// BuildTaskQueryToolBundle 构建任务查询工具包。
//
// 步骤化说明:
// 1. 先校验依赖,确保工具具备真实查询能力;
// 2. 通过 InferTool 声明工具 schema并在闭包内做全部参数校验
// 3. 输出 Tools + ToolInfos供模型与执行器分别使用。
func BuildTaskQueryToolBundle(ctx context.Context, deps TaskQueryToolDeps) (*TaskQueryToolBundle, error) {
if err := deps.validate(); err != nil {
return nil, err
}
queryTool, err := toolutils.InferTool(
ToolNameTaskQueryTasks,
ToolDescTaskQueryTasks,
func(ctx context.Context, input *TaskQueryToolInput) (*TaskQueryToolOutput, error) {
// 1. 允许 input 为空,统一按默认参数执行一次查询。
normalized, normalizeErr := normalizeToolInput(input)
if normalizeErr != nil {
return nil, normalizeErr
}
// 2. 执行真实查询。
records, queryErr := deps.QueryTasks(ctx, normalized)
if queryErr != nil {
return nil, queryErr
}
// 3. 把业务记录映射成模型友好的结构化输出。
items := make([]TaskQueryToolRecord, 0, len(records))
for _, record := range records {
items = append(items, TaskQueryToolRecord{
ID: record.ID,
Title: record.Title,
PriorityGroup: record.PriorityGroup,
PriorityLabel: priorityLabelCN(record.PriorityGroup),
IsCompleted: record.IsCompleted,
DeadlineAt: formatOptionalTime(record.DeadlineAt),
UrgencyThresholdAt: formatOptionalTime(record.UrgencyThresholdAt),
})
}
return &TaskQueryToolOutput{
Total: len(items),
Items: items,
}, nil
},
)
if err != nil {
return nil, fmt.Errorf("构建任务查询工具失败: %w", err)
}
tools := []tool.BaseTool{queryTool}
infos, err := collectToolInfos(ctx, tools)
if err != nil {
return nil, err
}
return &TaskQueryToolBundle{
Tools: tools,
ToolInfos: infos,
}, nil
}
// normalizeToolInput 负责参数清洗、默认值填充与合法性校验。
//
// 失败策略:
// 1. 参数非法直接返回 error阻止错误查询落到数据层
// 2. 参数缺失走默认值,优先保证“可用”。
func normalizeToolInput(input *TaskQueryToolInput) (TaskQueryRequest, error) {
// 1. 先准备默认值,保证“空参数”也能查到结果。
req := TaskQueryRequest{
SortBy: "deadline",
Order: "asc",
Limit: 5,
IncludeCompleted: false,
}
if input == nil {
return req, nil
}
// 2. 象限校验:若提供则必须在 1~4。
if input.Quadrant != nil {
if *input.Quadrant < 1 || *input.Quadrant > 4 {
return TaskQueryRequest{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", *input.Quadrant)
}
quadrant := *input.Quadrant
req.Quadrant = &quadrant
}
// 3. 排序字段校验。
if strings.TrimSpace(input.SortBy) != "" {
req.SortBy = strings.ToLower(strings.TrimSpace(input.SortBy))
}
switch req.SortBy {
case "deadline", "priority", "id":
// 允许字段。
default:
return TaskQueryRequest{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", req.SortBy)
}
// 4. 排序方向校验。
if strings.TrimSpace(input.Order) != "" {
req.Order = strings.ToLower(strings.TrimSpace(input.Order))
}
switch req.Order {
case "asc", "desc":
// 允许方向。
default:
return TaskQueryRequest{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", req.Order)
}
// 5. limit 校验与上限保护。
if input.Limit > 0 {
req.Limit = input.Limit
}
if req.Limit > 20 {
req.Limit = 20
}
if req.Limit <= 0 {
req.Limit = 5
}
// 6. include_completed 默认 false明确传入时才覆盖。
if input.IncludeCompleted != nil {
req.IncludeCompleted = *input.IncludeCompleted
}
// 7. keyword 清洗:去首尾空格,空串视为未设置。
req.Keyword = strings.TrimSpace(input.Keyword)
// 8. 截止时间上下界解析。
before, err := parseOptionalBoundaryTime(input.DeadlineBefore, true)
if err != nil {
return TaskQueryRequest{}, err
}
after, err := parseOptionalBoundaryTime(input.DeadlineAfter, false)
if err != nil {
return TaskQueryRequest{}, err
}
req.DeadlineBefore = before
req.DeadlineAfter = after
// 9. 上下界合法性检查after 不能晚于 before。
if req.DeadlineBefore != nil && req.DeadlineAfter != nil && req.DeadlineAfter.After(*req.DeadlineBefore) {
return TaskQueryRequest{}, errors.New("deadline_after 不能晚于 deadline_before")
}
return req, nil
}
func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) {
infos := make([]*schema.ToolInfo, 0, len(tools))
for _, t := range tools {
info, err := t.Info(ctx)
if err != nil {
return nil, fmt.Errorf("读取工具信息失败: %w", err)
}
infos = append(infos, info)
}
return infos, nil
}
// parseOptionalBoundaryTime 解析时间上下界。
//
// 参数语义:
// 1. isUpper=true按“上界”解析若输入仅日期则补到 23:59
// 2. isUpper=false按“下界”解析若输入仅日期则补到 00:00。
func parseOptionalBoundaryTime(raw string, isUpper bool) (*time.Time, error) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, nil
}
loc := time.Local
for _, layout := range taskQueryTimeLayouts {
var (
t time.Time
err error
)
if layout == time.RFC3339 {
t, err = time.Parse(layout, text)
if err == nil {
t = t.In(loc)
}
} else {
t, err = time.ParseInLocation(layout, text, loc)
}
if err != nil {
continue
}
// 仅日期输入时,按上下界补齐时分。
if layout == "2006-01-02" {
if isUpper {
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, loc)
} else {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
}
return &t, nil
}
return nil, fmt.Errorf("时间格式不支持: %s", text)
}
func priorityLabelCN(priority int) string {
switch priority {
case 1:
return "重要且紧急"
case 2:
return "重要不紧急"
case 3:
return "简单不重要"
case 4:
return "不简单不重要"
default:
return "未知优先级"
}
}
func formatOptionalTime(t *time.Time) string {
if t == nil {
return ""
}
return t.In(time.Local).Format("2006-01-02 15:04")
}

View File

@@ -1,37 +0,0 @@
package taskquery
import (
"fmt"
"strings"
"github.com/cloudwego/eino/components/tool"
)
// buildInvokableToolMap 把工具包转换成“工具名 -> 可执行工具”映射。
//
// 职责边界:
// 1. 只做工具元数据到执行器的映射,不做业务逻辑;
// 2. 若工具包结构异常(数量不一致/信息缺失)直接返回 error
// 3. 供图节点在运行时快速按工具名取执行器。
func buildInvokableToolMap(bundle *TaskQueryToolBundle) (map[string]tool.InvokableTool, error) {
if bundle == nil || len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 {
return nil, fmt.Errorf("task query tool bundle is empty")
}
if len(bundle.Tools) != len(bundle.ToolInfos) {
return nil, fmt.Errorf("task query tool bundle mismatch")
}
result := make(map[string]tool.InvokableTool, len(bundle.Tools))
for idx, baseTool := range bundle.Tools {
info := bundle.ToolInfos[idx]
if info == nil || strings.TrimSpace(info.Name) == "" {
return nil, fmt.Errorf("task query tool info is invalid")
}
invokableTool, ok := baseTool.(tool.InvokableTool)
if !ok {
return nil, fmt.Errorf("task query tool %s is not invokable", info.Name)
}
result[info.Name] = invokableTool
}
return result, nil
}

View File

@@ -1,45 +0,0 @@
package taskquery
import "testing"
// TestNormalizeToolInput_Default
// 目的:验证空入参会回填默认查询参数,保证工具在“参数缺失”场景仍可执行。
func TestNormalizeToolInput_Default(t *testing.T) {
req, err := normalizeToolInput(nil)
if err != nil {
t.Fatalf("不应报错: %v", err)
}
if req.SortBy != "deadline" || req.Order != "asc" || req.Limit != 5 || req.IncludeCompleted {
t.Fatalf("默认值异常: %+v", req)
}
}
// TestNormalizeToolInput_InvalidQuadrant
// 目的:验证 quadrant 越界时会被拦截,避免无效过滤条件进入业务层。
func TestNormalizeToolInput_InvalidQuadrant(t *testing.T) {
invalid := 6
_, err := normalizeToolInput(&TaskQueryToolInput{
Quadrant: &invalid,
})
if err == nil {
t.Fatalf("期望 quadrant 越界时报错")
}
}
// TestNormalizeToolInput_DateRange
// 目的:验证时间上下界可解析并正确落入请求结构。
func TestNormalizeToolInput_DateRange(t *testing.T) {
req, err := normalizeToolInput(&TaskQueryToolInput{
DeadlineAfter: "2026-03-01 08:00",
DeadlineBefore: "2026-03-31",
})
if err != nil {
t.Fatalf("不应报错: %v", err)
}
if req.DeadlineAfter == nil || req.DeadlineBefore == nil {
t.Fatalf("时间上下界不应为空: %+v", req)
}
if req.DeadlineAfter.After(*req.DeadlineBefore) {
t.Fatalf("时间上下界关系异常: after=%v before=%v", req.DeadlineAfter, req.DeadlineBefore)
}
}

View File

@@ -0,0 +1,176 @@
# agent 通用能力接入文档
## 1. 文档目的
本文用于说明 `backend/agent` 目录下“通用能力”的职责边界、放置位置和接入约束,避免后续继续出现“同一类能力复制三份、四份”的情况。
这里的“通用能力”特指:
1. 会被两个及以上能力域复用,或者已经明确会继续扩散的基础能力。
2. 与具体业务语义弱耦合,抽出来后不会把某个 skill 的 prompt、状态字段、业务规则污染到其它模块。
3. 抽出后能显著减少样板代码、降低迁移成本,或者统一链路行为。
本文不负责描述某个具体 skill 的业务流程。业务流程、状态机、prompt 细节,仍应放在对应能力域自己的文件中。
## 2. 当前目录分层
```text
backend/agent/
entrance.go
chat/
graph/
llm/
model/
node/
prompt/
router/
shared/
stream/
```
### 2.1 `entrance.go`
职责:
1. 作为 `agent` 模块对上层 service 的统一入口。
2. 负责装配路由器与各能力 handler。
3. 不负责具体 graph 逻辑、不负责直接调模型、不负责工具执行。
### 2.2 `router/`
职责:
1. 负责一级分流,把请求映射到具体能力链路。
2. 维护统一的请求/响应结构和 action 定义。
3. 不承载具体 skill 的业务判断细节。
适合放入这里的能力:
1. 路由请求结构。
2. action 解析与分发。
3. 对上层稳定暴露的最小门面。
### 2.3 `graph/`
职责:
1. 只负责组图、连线和节点编排。
2. 文件里应尽量只出现节点挂载、分支和边定义。
3. 不直接写复杂业务逻辑、不直接调 DAO、不直接拼 prompt。
### 2.4 `node/`
职责:
1. 承接能力域的核心业务节点实现。
2. 按“节点逻辑文件 + 工具文件”的双文件格局组织复杂能力域。
3. 在确实存在多节点复用时,可下沉少量带业务语义的 node 内部公共 helper。
当前约定:
1. `schedule_plan.go` / `schedule_plan_tool.go` 为一组。
2. `schedule_refine.go` / `schedule_refine_tool.go` 为一组。
3. `quicknote.go` / `quicknote_tool.go``taskquery.go` / `taskquery_tool.go` 同理。
补充说明:
1. `node/tool_common.go` 是 node 层内部通用工具聚合点。
2. 这里只放“被两个及以上节点复用、但仍带一点节点上下文语义”的 helper。
3. 如果某个能力已经弱化到与业务无关,应继续下沉到 `shared/`,而不是长期堆在 `tool_common.go`
### 2.5 `llm/`
职责:
1. 统一封装模型调用、JSON 解析、推理参数和模型侧协议。
2. 让上层节点尽量只关心“要什么结果”,不重复实现 SDK 样板代码。
3. 不承载具体业务状态流转。
### 2.6 `model/`
职责:
1. 统一放置 agent 内部状态结构、输入输出 DTO、默认预算等模型无关定义。
2. 不在这里写业务执行逻辑。
### 2.7 `prompt/`
职责:
1. 维护系统提示词、结构化输出模板、路由提示词等文本资产。
2. 不在 prompt 文件中写节点控制流和工具编排。
### 2.8 `stream/`
职责:
1. 统一承接 SSE chunk 包装、阶段推送、OpenAI/Ark 流式适配。
2. 保证上层 service 不需要重复拼装流协议。
### 2.9 `shared/`
职责:
1. 放置跨能力域复用的纯工具能力,例如时间、重试、深拷贝等。
2. 要求业务语义尽量弱、依赖尽量少。
3. 一旦某类逻辑已经被第二处复用,必须优先评估是否放到这里。
## 3. 什么该抽成通用能力
满足以下任一条件时,必须优先评估抽公共层:
1. 同类逻辑已经出现第二份实现。
2. 不同 skill 的实现只有参数不同,控制流基本一致。
3. 上层 service 已经开始出现重复胶水代码。
4. 继续复制会增加迁移、测试或回归排查成本。
常见应优先考虑抽取的方向:
1. 模型调用门面。
2. JSON 容错解析。
3. SSE 阶段推送与 chunk 包装。
4. 深拷贝与快照转换。
5. 缓存快照读写辅助逻辑。
## 4. 什么不该抽成通用能力
以下内容默认不应抽到公共层:
1. 某个 skill 独有的 prompt 片段。
2. 只服务单一业务的状态字段映射。
3. 带强业务语义的 ReAct 决策规则。
4. 只在一个节点里短期使用、且没有第二处复用证据的 helper。
判断原则:
1. 若抽出来后名字仍然需要带明显业务词,通常说明它还不够通用。
2. 若抽出来会让其它模块被迫理解某个 skill 的内部规则,说明抽取层级过早。
## 5. 新增能力时的落点规则
1. 纯工具、弱业务语义、跨域复用:优先放 `shared/`
2. 只在路由阶段复用:放 `router/`
3. 只与模型协议相关:放 `llm/`
4. 只与流式输出相关:放 `stream/`
5. 只在 node 层内被多个节点复用,且带少量业务上下文:放 `node/tool_common.go` 或同层 helper。
6. 仍然明显属于某个能力域:留在对应 `node/``prompt/``model/` 文件中,不要硬抽。
## 6. 变更要求
后续若在 `backend/agent` 中新增、下沉、替换任何通用能力,必须同步完成以下动作:
1. 更新本文档,说明新能力放在哪一层、为什么放这里。
2. 说明是否替代了旧实现,旧实现是否已经删除。
3. 检查是否还残留第三份及以上重复实现。
4. 若本轮只是暂时无法抽公共层,必须在代码注释或文档里写明原因。
## 7. 当前结构结论
截至当前版本,`backend/agent` 已是唯一正式实现目录,`backend/service/agentsvc` 也已与历史旧路径完全解耦。
后续重构优先级建议:
1. 继续收口 node 层内部重复的查询/校验/移动辅助逻辑。
2. 持续把 service 层里可复用的旁路读写逻辑下沉到更稳定的公共层。
3. 保持 graph 只做编排、node 只做业务、shared 只做弱语义公共能力,避免重新堆回大杂烩结构。

View File

@@ -1,8 +0,0 @@
package agentchat
const (
// SystemPrompt 全局系统人设:定义 SmartFlow 的基本调性
SystemPrompt = `你叫 SmartFlow是专为重邮CQUPT学子打造的智能排程专家。
你的回复应当专业、干练,偶尔可以带一点程序员式的冷幽默。
重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。`
)

View File

@@ -1,161 +0,0 @@
package agentchat
import (
"context"
"io"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// StreamChat 负责模型流式输出,并在关键节点打点:
// 1) 流连接建立llm.Stream 返回)
// 2) 首包到达(首字延迟)
// 3) 流式输出结束
func StreamChat(
ctx context.Context,
llm *ark.ChatModel,
modelName string,
userInput string,
ifThinking bool,
chatHistory []*schema.Message,
outChan chan<- string,
traceID string,
chatID string,
requestStart time.Time,
reasoningStartAt *time.Time,
) (string, string, int, *schema.TokenUsage, error) {
/*callStart := time.Now()*/
messages := make([]*schema.Message, 0)
messages = append(messages, schema.SystemMessage(SystemPrompt))
if len(chatHistory) > 0 {
messages = append(messages, chatHistory...)
}
messages = append(messages, schema.UserMessage(userInput))
var thinking *ark.Thinking
if ifThinking {
thinking = &arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}
} else {
thinking = &arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}
}
/*connectStart := time.Now()*/
reader, err := llm.Stream(ctx, messages, ark.WithThinking(thinking))
if err != nil {
return "", "", 0, nil, err
}
defer reader.Close()
if strings.TrimSpace(modelName) == "" {
modelName = "smartflow-worker"
}
requestID := "chatcmpl-" + uuid.NewString()
created := time.Now().Unix()
firstChunk := true
chunkCount := 0
var tokenUsage *schema.TokenUsage
var localReasoningStartAt *time.Time
if reasoningStartAt != nil && !reasoningStartAt.IsZero() {
startCopy := reasoningStartAt.In(time.Local)
localReasoningStartAt = &startCopy
}
var reasoningEndAt *time.Time
/*streamRecvStart := time.Now()
log.Printf("打点|流连接建立|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d|history_len=%d",
traceID,
chatID,
requestID,
time.Since(connectStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
len(chatHistory),
)*/
var fullText strings.Builder
var reasoningText strings.Builder
for {
chunk, err := reader.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", "", 0, nil, err
}
// 优先记录模型真实 usage通常在尾块返回部分模型也可能中途返回
if chunk != nil && chunk.ResponseMeta != nil && chunk.ResponseMeta.Usage != nil {
tokenUsage = agentllm.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage)
}
if chunk != nil {
if strings.TrimSpace(chunk.ReasoningContent) != "" && localReasoningStartAt == nil {
now := time.Now()
localReasoningStartAt = &now
}
if strings.TrimSpace(chunk.Content) != "" && localReasoningStartAt != nil && reasoningEndAt == nil {
now := time.Now()
reasoningEndAt = &now
}
fullText.WriteString(chunk.Content)
reasoningText.WriteString(chunk.ReasoningContent)
}
payload, err := agentstream.ToOpenAIStream(chunk, requestID, modelName, created, firstChunk)
if err != nil {
return "", "", 0, nil, err
}
if payload != "" {
outChan <- payload
chunkCount++
firstChunk = false
/*if firstChunk {
log.Printf("打点|首包到达|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d",
traceID,
chatID,
requestID,
time.Since(streamRecvStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
)
firstChunk = false
}*/
}
}
finishChunk, err := agentstream.ToOpenAIFinishStream(requestID, modelName, created)
if err != nil {
return "", "", 0, nil, err
}
outChan <- finishChunk
outChan <- "[DONE]"
/*log.Printf("打点|流式输出结束|trace_id=%s|chat_id=%s|request_id=%s|chunks=%d|reply_chars=%d|本步耗时_ms=%d|请求累计_ms=%d",
traceID,
chatID,
requestID,
chunkCount,
len(fullText.String()),
time.Since(callStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
)*/
reasoningDurationSeconds := 0
if localReasoningStartAt != nil {
if reasoningEndAt == nil {
now := time.Now()
reasoningEndAt = &now
}
if reasoningEndAt.After(*localReasoningStartAt) {
reasoningDurationSeconds = int(reasoningEndAt.Sub(*localReasoningStartAt) / time.Second)
}
}
return fullText.String(), reasoningText.String(), reasoningDurationSeconds, tokenUsage, nil
}

View File

@@ -1,356 +0,0 @@
# agent2 閫氱敤鑳藉姏鎺ュ叆鏂囨。
## 1. 鏂囨。鐩殑
鏈枃妗g敤浜庤鏄?`agent2` 鐩綍涓嬧€滈€氱敤鑳藉姏鈥濈殑杈圭晫銆佹斁缃綅缃€佹帴鍏ユ柟寮忎笌缁存姢瑕佹眰銆?
杩欓噷鐨勨€滈€氱敤鑳藉姏鈥濈壒鎸囷細
1. 涓嶅彧鏈嶅姟浜庢煇涓€涓妧鑳介摼璺紝鑰屾槸鍙兘琚?`chat`銆乣quicknote`銆乣taskquery`銆乣schedule` 绛夊涓ā鍧楀叡鍚屽鐢ㄧ殑鑳藉姏銆?
2. 涓庡叿浣撲笟鍔¤涔夊急鑰﹀悎锛屾娊鍑烘潵鍚庝笉浼氬己琛屾妸鏌愪釜鍗曚竴鎶€鑳界殑 prompt銆佺姸鎬佸瓧娈点€佷笟鍔¤鍒欐薄鏌撳埌鍏跺畠妯″潡銆?
3. 鎶藉嚭鏉ュ悗锛岃兘澶熸槑鏄惧噺灏戞牱鏉夸唬鐮併€侀檷浣庨噸澶嶅疄鐜板拰鍚庣画杩佺Щ鎴愭湰銆?
鏈枃妗笉璐熻矗鎻忚堪鏌愪釜鍏蜂綋鎶€鑳界殑涓氬姟娴佺▼锛屾妧鑳借嚜韬殑鍥剧紪鎺掋€佺姸鎬佸瓧娈点€乸rompt 缁嗚妭锛屽簲缁х画鏀惧湪瀵瑰簲鎶€鑳界洰褰曟垨瀵瑰簲鍐崇瓥璁板綍涓淮鎶ゃ€?
## 2. 褰撳墠鐩綍鍒嗗眰
### 2.1 鎬诲叆鍙e眰
鏂囦欢锛?
- `entrance.go`
鑱岃矗锛?
1. 浣滀负 `agent2` 妯″潡瀵逛笂灞傛湇鍔$殑缁熶竴鍏ュ彛銆?
2. 璐熻矗鎶娾€滆矾鐢卞櫒 + 鍚勬妧鑳?handler鈥濊閰嶅埌涓€璧枫€?
3. 涓嶈礋璐e叿浣撴妧鑳介€昏緫锛屼笉璐熻矗鐩存帴璋冩ā鍨嬶紝涔熶笉璐熻矗宸ュ叿鎵ц銆?
閫傚悎鏀句粈涔堬細
1. 妯″潡绾у叆鍙e璞°€?
2. 閫氱敤娉ㄥ唽鏂规硶銆?
3. 涓庘€滄€诲垎鍙戔€濇湁鍏崇殑鏈€灏忛棬闈㈠皝瑁呫€?
涓嶉€傚悎鏀句粈涔堬細
1. 鏌愪釜鍏蜂綋鎶€鑳界殑鑺傜偣閫昏緫銆?
2. 鍏蜂綋涓氬姟 DAO 璋冪敤銆?
3. 鏌愪釜鎶€鑳界嫭鍗犵殑 prompt 鎴栫姸鎬佹満銆?
### 2.2 璺敱灞?
鐩綍锛?
- `router/`
褰撳墠閫氱敤鑳藉姏锛?
1. `Dispatcher`
2. `Resolver`
3. `AgentRequest / AgentResponse`
4. `Action` 涓庤矾鐢辨帶鍒剁爜瑙f瀽
鑱岃矗锛?
1. 缁熶竴澶勭悊鈥滆姹傝璧板摢鏉℃妧鑳介摼璺€濈殑鍒嗘祦闂銆?
2. 鎻愪緵瀵逛笂灞傜ǔ瀹氱殑鍔ㄤ綔鏋氫妇涓庤姹傚3缁撴瀯銆?
3. 鍏煎杩佺Щ鏈熺殑鏂版棫 action 璇箟锛岄伩鍏嶄笂灞傛湇鍔$洿鎺ヤ緷璧栨棫鐩綍銆?
閫傚悎鏀句粈涔堬細
1. 閫氱敤璺敱鍗忚銆?
2. 鎺у埗鐮佽В鏋愩€?
3. 鍒嗗彂鍣ㄣ€?
4. 鎵€鏈夋妧鑳藉叡鐢ㄧ殑璺敱璇锋眰/鍝嶅簲缁撴瀯銆?
涓嶉€傚悎鏀句粈涔堬細
1. 鏌愪釜鎶€鑳藉唴閮ㄧ殑浜屾鍒ゆ柇銆?
2. 鏌愪釜鎶€鑳戒笓灞炵殑 prompt銆?
3. 鎶€鑳藉唴閮ㄩ噸璇曟垨鐘舵€佹祦杞€昏緫銆?
### 2.3 妯″瀷浜や簰灞?
鐩綍锛?
- `llm/`
褰撳墠閫氱敤鑳藉姏锛?
1. `Client`
2. `GenerateOptions`
3. `TextResult`
4. `BuildSystemUserMessages`
5. `GenerateJSON`
6. `ParseJSONObject`
7. `MergeUsage / CloneUsage`
8. `ark.go` 涓殑 Ark 閫傞厤瀹炵幇
鑱岃矗锛?
1. 缁熶竴鏀跺彛妯″瀷璋冪敤鎺ュ彛锛屽噺灏戝悇鎶€鑳介噸澶嶆嫾瑁?`messages`銆乣thinking`銆乣temperature`銆乣max_tokens`銆?
2. 鎻愪緵閫氱敤 JSON 瑙f瀽涓?usage 鍚堝苟鑳藉姏銆?
3. 鎶婂叿浣撳巶鍟?SDK 缁嗚妭灏介噺鍘嬪湪閫傞厤灞傦紝涓嶅悜涓婂眰鑺傜偣鎵╂暎銆?
閫傚悎鏀句粈涔堬細
1. 鎵€鏈夋妧鑳介兘鍙兘澶嶇敤鐨勬ā鍨嬭皟鐢ㄥ3銆?
2. 閫氱敤 JSON 鎻愬彇涓庡弽搴忓垪鍖栥€?
3. 娴佸紡/闈炴祦寮忚皟鐢ㄧ殑缁熶竴閫傞厤鎺ュ彛銆?
4. usage 鏀舵暃銆佺┖鍝嶅簲閿欒鏍煎紡鍖栥€?
涓嶉€傚悎鏀句粈涔堬細
1. 鍙湇鍔′簬鏌愪竴涓妧鑳界殑 prompt 鏂囨銆?
2. 鏌愪竴涓妧鑳界壒鏈夌殑杈撳嚭缁撴瀯浣撱€?
3. 鏌愪竴涓妧鑳界嫭鍗犵殑鈥滄垚鍔熸枃妗堟鼎鑹测€濊鍒欍€?
璇存槑锛?
1. 濡傛灉鍙槸鈥滃熀浜庨€氱敤 `Client` 鍐嶅寘涓€灞傛妧鑳戒笓鐢ㄥ嚱鏁扳€濓紝渚嬪 quicknote 鐨勮仛鍚堣鍒掕皟鐢紝杩欑浠g爜鍙互鏀惧湪 `llm/`锛屼絾鏂囦欢鍚嶅簲鏄庣‘甯︽妧鑳借涔夛紝閬垮厤璇涓哄畬鍏ㄩ€氱敤鑳藉姏銆?
2. 鐪熸璺ㄦ妧鑳藉鐢ㄧ殑鍐呭锛屼紭鍏堟矇鍒?`client.go`銆乣ark.go`銆乣json.go` 杩欑被鍏叡鏂囦欢銆?
### 2.4 娴佽緭鍑哄崗璁眰
鐩綍锛?
- `stream/`
褰撳墠閫氱敤鑳藉姏锛?
1. OpenAI 鍏煎 chunk DTO
2. reasoning chunk 鏋勯€?
3. assistant chunk 鏋勯€?
4. finish / done 杈撳嚭
5. 闃舵鎺ㄩ€?emitter
鑱岃矗锛?
1. 缁熶竴澶勭悊 SSE / OpenAI 鍏煎杈撳嚭鏍煎紡銆?
2. 璁?service銆乬raph銆乶ode 鍙叧蹇冣€滄垜瑕佹帹浠€涔堝唴瀹光€濓紝鑰屼笉鏄嚜宸辨嫾 JSON銆?
3. 涓哄悗缁墠绔崗璁崌绾ч鐣欑粺涓€淇敼鐐广€?
閫傚悎鏀句粈涔堬細
1. chunk DTO銆?
2. reasoning / content / finish 鐨勭粺涓€灏佽銆?
3. 闃舵娑堟伅鎺ㄩ€佸櫒鎺ュ彛銆?
涓嶉€傚悎鏀句粈涔堬細
1. 鏌愪釜鎶€鑳界殑闃舵鍛藉悕琛ㄣ€?
2. 鏌愪釜鎶€鑳戒笓灞炵殑姝f枃鏂囨銆?
3. 鍏蜂綋涓氬姟鐘舵€佸璞°€?
### 2.5 鍏变韩宸ュ叿灞?
鐩綍锛?
- `shared/`
褰撳墠閫氱敤鑳藉姏锛?
1. 鏃堕棿鏍煎紡鍖栦笌鍒嗛挓绾у綊涓€鍖?
2. 娣辨嫹璐?
3. 閫氱敤閲嶈瘯杈呭姪
鑱岃矗锛?
1. 鎵胯浇绾伐鍏峰瀷銆佹棤涓氬姟璇箟銆佹棤鎶€鑳借€﹀悎鐨勮緟鍔╁嚱鏁般€?
2. 浣滀负澶氫釜鎶€鑳介兘鑳界洿鎺ュ鐢ㄧ殑鏈€搴曞眰宸ュ叿灞傘€?
閫傚悎鏀句粈涔堬細
1. 鏃堕棿宸ュ叿銆?
2. clone 宸ュ叿銆?
3. retry 甯姪鍑芥暟銆?
4. 绾嚱鏁板瀷灏忓伐鍏枫€?
涓嶉€傚悎鏀句粈涔堬細
1. 澶瑰甫鍏蜂綋鎶€鑳藉瓧娈靛悕鐨勫伐鍏枫€?
2. 渚濊禆鏁版嵁搴撱€佺紦瀛樸€佹ā鍨嬨€佽矾鐢卞姩浣滅殑閫昏緫銆?
### 2.6 鎶€鑳藉唴閮ㄥ眰
鐩綍锛?
- `graph/`
- `node/`
- `prompt/`
- `model/`
- `chat/`
鑱岃矗锛?
1. 杩欏嚑灞備富瑕佹壙杞芥妧鑳藉唴閮ㄨ兘鍔涖€?
2. 鍗充娇鍏朵腑鏌愪釜鏂囦欢鐜板湪浣嶄簬 `agent2` 鏍逛綋绯诲唴锛屽彧瑕佸畠甯︽槑鏄炬妧鑳借涔夛紝灏变笉瑕佽鍒ゆ垚鈥滈€氱敤鑳藉姏鈥濄€?
鍒ゆ柇鏍囧噯锛?
1. 濡傛灉浠g爜閲屽ぉ鐒剁粦瀹氭煇涓妧鑳界姸鎬佺粨鏋勩€佹煇涓妧鑳介樁娈靛悕銆佹煇涓妧鑳?prompt 杈撳嚭濂戠害锛屼竴鑸笉搴旂‖鎶芥垚閫氱敤鑳藉姏銆?
2. 濡傛灉鍙槸澶氫釜鎶€鑳介兘閲嶅浜嗗悓涓€娈垫牱鏉夸唬鐮侊紝涓旀娊鍑哄悗涓嶄細璁╂娊璞″彉褰紝灏卞簲璇ヨ瘎浼颁笅娌夈€?
### 2.7 鍥惧眰涓庤妭鐐瑰眰鐨勫崗浣滅害瀹?
杩欐槸褰撳墠 `agent2` 宸茬粡鏄庣‘涓嬫潵鐨勭粨鏋勭害鏉燂細
1. `graph/` 鍙礋璐b€滅敾鍥锯€濓細
- 娉ㄥ唽鑺傜偣
- 娣诲姞杈?
- 娣诲姞鍒嗘敮
- 缂栬瘧涓庤繍琛屽浘
2. `graph/` 涓嶅啀璐熻矗锛?
- 棰濆鍒涘缓 runner 閫傞厤灞?
- 鍦ㄥ浘鍐呯户缁爢璇锋眰绾т緷璧栬浆鍙戦€昏緫
- 鐩存帴瀹炵幇鑺傜偣涓氬姟
3. `node/` 璐熻矗锛?
- 瀹氫箟鑺傜偣瀹瑰櫒锛堜緥濡?`QuickNoteNodes`锛?
- 閫氳繃瀵硅薄鏂规硶鐩存帴鍚?graph 鏆撮湶鍙寕杞借妭鐐?
- 鍦ㄨ妭鐐规柟娉曞唴閮ㄦ秷璐硅姹傜骇渚濊禆
鎺ㄨ崘褰㈡€侊細
1. `graph` 閲岀洿鎺ユ寕锛?
- `nodes.Intent`
- `nodes.Priority`
- `nodes.Persist`
- `nodes.Exit`
2. 鍒嗘敮涔熺洿鎺ユ寕锛?
- `nodes.NextAfterIntent`
- `nodes.NextAfterPersist`
3. 涓嶆帹鑽愬啀棰濆寮曞叆 `runner -> node` 杩欑杞帴灞傘€?
杩欐牱璁捐鐨勭洰鐨勶細
1. 閬垮厤 graph 鏂囦欢闅忕潃妯″潡鍙樺鍐嶆闀挎垚鈥滃ぇ瑁呴厤鏂囦欢鈥濄€?
2. 璁┾€滆姹傜骇渚濊禆娉ㄥ叆鈥濆洖鍒?node 灞傝嚜宸辩殑鑺傜偣瀹瑰櫒閲屻€?
3. 璁╅槄璇昏矾寰勭ǔ瀹氭垚锛?
- 鍏堢湅 graph 鐭ラ亾娴佺▼鍥?
- 鍐嶈烦 node 鐪嬭妭鐐规柟娉曞疄鐜?
- 涓嶉渶瑕佸湪 graph 鍜?runner 涓ゅ眰涔嬮棿鏉ュ洖璺炽€?
## 3. 浠€涔堝簲璇ユ娊鎴愰€氱敤鑳藉姏
婊¤冻浠ヤ笅浠绘剰涓ゆ潯锛屼竴鑸氨搴旇璁ょ湡璇勪及鎶藉叕鍏卞眰锛?
1. 鍦ㄧ浜屼釜鎶€鑳介噷鍑虹幇浜嗘槑鏄鹃噸澶嶅疄鐜般€?
2. 杩欐閫昏緫鏈川涓婁笉渚濊禆鏌愪釜鎶€鑳界嫭鍗犵姸鎬併€?
3. 鎶藉嚭鏉ュ悗鎺ュ彛鍙互鍋氬埌鈥滃叆鍙傚皯銆佽亴璐f竻銆佽涔夌ǔ瀹氣€濄€?
4. 涓婂眰閲嶅浠g爜涓昏鏄湪鍋氭牱鏉胯閰嶏紝鑰屼笉鏄笟鍔″喅绛栥€?
鍏稿瀷渚嬪瓙锛?
1. 妯″瀷娑堟伅鎷艰銆?
2. JSON 鎻愬彇涓庤В鏋愩€?
3. usage 鍚堝苟銆?
4. OpenAI chunk 鏋勯€犮€?
5. 鏃堕棿褰掍竴鍖栥€?
6. 娣辨嫹璐濅笌閲嶈瘯宸ュ叿銆?
7. 鎬诲叆鍙h矾鐢变笌鎶€鑳藉垎鍙戙€?
## 4. 浠€涔堜笉搴旇寮鸿鎶藉叕鍏卞眰
鍑虹幇浠ヤ笅鎯呭喌鏃讹紝涓嶈涓轰簡鈥滅湅璧锋潵澶嶇敤鈥濊€岀‖鎶斤細
1. 鎶藉畬涔嬪悗鍑芥暟绛惧悕鍙嶈€岃濉炰竴鍫嗘妧鑳戒笓灞炲弬鏁般€?
2. 鍏叡灞傚紑濮嬬煡閬撴煇涓妧鑳界殑鐘舵€佸瓧娈点€侀樁娈靛悕銆侀敊璇枃妗堛€?
3. 琛ㄩ潰鐩镐技锛屽疄鍒欐瘡涓妧鑳界殑涓氬姟绾︽潫瀹屽叏涓嶅悓銆?
4. 涓轰簡澶嶇敤鑰屽紩鍏ュぇ閲?`if action == xxx`銆乣switch skill` 杩欑被鍒嗘敮銆?
鍏稿瀷渚嬪瓙锛?
1. quicknote 鐨勪紭鍏堢骇鍒ゅ畾杈撳嚭缁撴瀯銆?
2. taskquery 鐨勬煡璇㈣鍒掑瓧娈点€?
3. schedule 鐨勬帓绋嬬姸鎬佸揩鐓с€?
4. 鏌愪釜鎶€鑳界壒鏈夌殑 prompt 妯℃澘銆?
## 5. 鏂板閫氱敤鑳藉姏鐨勬帴鍏ユ楠?
### 5.1 鍏堝垽鏂槸鍚﹀€煎緱鎶?
1. 鍏堢‘璁よ繖娈甸€昏緫鏄惁宸茬粡鍦ㄧ浜屽鍑虹幇閲嶅銆?
2. 鍐嶇‘璁ゅ畠鏄笉鏄彲浠ヨ劚绂诲崟涓€鎶€鑳借涔夌嫭绔嬪瓨鍦ㄣ€?
3. 濡傛灉鏆傛椂杩樹笉鑳芥娊锛屼篃瑕佸湪浠g爜娉ㄩ噴鎴栧喅绛栬褰曢噷鍐欐槑鍘熷洜锛岄伩鍏嶅悗闈㈢涓夋閲嶅鏃跺繕璁般€?
### 5.2 閫夋嫨钀界偣鐩綍
鎸夎亴璐d紭鍏堣惤鍒颁互涓嬬洰褰曪細
1. 璺敱鍗忚涓庡垎鍙戯細`router/`
2. 妯″瀷璋冪敤涓?JSON 瑙瀽锛歚llm/`
3. 娴佽緭鍑哄崗璁細`stream/`
4. 绾伐鍏凤細`shared/`
5. 鎶€鑳戒笓灞炰絾鍙鐢ㄧ殑澹筹細鏀惧搴旀妧鑳借涔夋枃浠讹紝涓嶈浼鎴愬畬鍏ㄥ叕鍏卞眰
### 5.3 瀹氫箟鏈€灏忔帴鍙?
1. 鍏堝畾涔夋渶灏忓彲澶嶇敤鎺ュ彛锛屽彧鏆撮湶涓婂眰鐪熸闇€瑕佺殑鑳藉姏銆?
2. 涓嶈鎶婁笅灞?SDK銆丏AO銆佺紦瀛樺疄鐜扮粏鑺傜洿鎺ラ€忎紶鍒版墍鏈夎皟鐢ㄦ柟銆?
3. 浼樺厛璁┾€滃叕鍏卞眰鐭ラ亾寰楁洿灏戔€濓紝鑰屼笉鏄鈥滀笂灞備负浜嗗鐢ㄨ杩煡閬撴洿澶氣€濄€?
### 5.4 琛ユ敞閲?
蹇呴』鍐欐竻妤氾細
1. 杩欎釜閫氱敤鑳藉姏璐熻矗浠€涔堛€?
2. 涓嶈礋璐d粈涔堛€?
3. 涓轰粈涔堝畠閫傚悎鎶藉埌鍏叡灞傘€?
4. 澶辫触鏃剁敱璋佸厹搴曘€?
### 5.5 琛ユ祴璇?
鑷冲皯瑕嗙洊锛?
1. 姝e父璺緞銆?
2. 鍏抽敭杈圭晫銆?
3. 鏄庣‘鐨勫け璐ヨ矾寰勩€?
濡傛灉杩佺Щ鏈熸殏鏃舵病娉曞畬鏁磋ˉ榻愶紝涔熻浼樺厛淇濊瘉鍏叡鍑芥暟鏈韩鏈夋渶灏忓洖褰掓祴璇曘€?
### 5.6 鏇存柊鏈枃妗?
鍙鍑虹幇浠ヤ笅浠讳竴鎯呭喌锛屽繀椤诲悓姝ユ洿鏂版湰鏂囨。锛?
1. 鏂板浜嗕竴涓€氱敤鑳藉姏銆?
2. 璋冩暣浜嗘煇涓€氱敤鑳藉姏鐨勮惤鐐圭洰褰曘€?
3. 淇敼浜嗘煇涓叕鍏辨帴鍙g殑鑱岃矗杈圭晫銆?
4. 鍒犳帀浜嗘煇涓棫鐨勫叕鍏卞疄鐜帮紝骞剁敱鏂板疄鐜版浛浠c€?
## 6. 鎺ㄨ崘鎺ュ叆妯℃澘
鍙互鎸変笅闈㈣繖涓€濊矾鎺ュ叆锛?
1. 鍏堝湪鎶€鑳戒唬鐮侀噷璇嗗埆閲嶅鐗囨銆?
2. 鎻愮偧鍑衡€滄渶灏忓叕鍏卞嚱鏁?/ 鏈€灏忓叕鍏辩粨鏋勪綋 / 鏈€灏忓叕鍏辨帴鍙b€濄€?
3. 鏀捐繘 `router / llm / stream / shared` 涔嬩竴銆?
4. 鍏堣鏂版妧鑳芥帴杩欎釜鍏叡瀹炵幇銆?
5. 鍐嶉€愭鍥炴敹鏃ф妧鑳介噷閲嶅鐨勮€佷唬鐮併€?
6. 鏈€鍚庤ˉ鏈枃妗o紝璇存槑杩欎釜鑳藉姏鐜板湪褰掕皝绠°€佷笂灞傝鎬庝箞鐢ㄣ€?
## 7. 褰撳墠缁存姢瑕佹眰
1. `agent2` 鐨勫叕鍏卞眰瑕佸敖閲忎繚鎸佲€滀綆鑰﹀悎銆佸己娉ㄩ噴銆佹槗杩佺Щ鈥濄€?
2. 鏂版妧鑳藉紑鍙戞椂锛屼紭鍏堝鐢ㄨ繖閲屽凡鏈夌殑鍏叡鑳藉姏锛岃€屼笉鏄洿鎺ュ鍒舵棫鎶€鑳戒唬鐮併€?
3. 濡傛灉鍙戠幇鏌愭閫昏緫宸茬粡鍑虹幇绗簩浠藉疄鐜帮紝搴斾紭鍏堣瘎浼版娊鍏叡灞傦紝鑰屼笉鏄户缁啓绗笁浠姐€?
4. 鍚庣画鍙鎵╁睍閫氱敤鑳藉姏锛屽繀椤诲悓姝ユ洿鏂版湰鏂囨。锛屽惁鍒欒涓鸿縼绉绘垨閲嶆瀯鏈畬鎴愩€?
## 8. 2026-03-25 schedule_refine 鎺ュ叆璁板綍
1. 鏂板 `agent2/node/schedule_refine_impl` 鐩綍锛屽鍒舵壙鎺ュ師 `agent/schedulerefine` 鍏ㄩ噺杩炵画寰皟瀹炵幇锛坓raph + runner + state + nodes + tool + prompt锛夈€?2. `agent2/node/schedule_refine.go` 浣滀负 node 灞傜粺涓€闂ㄩ潰锛氬澶栨毚闇?`ScheduleRefineState`銆乣ScheduleRefineGraphRunInput`銆乣NewScheduleRefineState`銆乣RunScheduleRefineGraph`銆乣FinalHardCheckPassed`銆?3. `agent2/node/schedule_refine_tool.go` 淇濈暀鍙屾枃浠舵牸灞€涓殑宸ュ叿鎵胯浇浣嶏紝褰撳墠宸ュ叿鍏蜂綋瀹炵幇涓嬫矇鍦?`agent2/node/schedule_refine_impl/tool.go`銆?4. `agent2/graph/schedule.go` 宸叉柊澧?`RunScheduleRefineGraph`锛岄€氳繃 node 闂ㄩ潰杩涘叆杩炵画寰皟鍥俱€?5. `service/agentsvc/agent_schedule_refine.go` 宸插垏娴佸埌 agent2锛氱姸鎬佸垵濮嬪寲銆佸浘鎵ц銆佺粓瀹¢€氳繃鍒ゅ畾鍧囦笉鍐嶄緷璧栨棫 `agent/schedulerefine`銆?6. 鍏煎璇存槑锛氭棫 `agent/schedulerefine` 鐩綍鏆備繚鐣欙紝浣滀负杩佺Щ鏈熷苟琛屽疄鐜帮紝褰撳墠鐢熶骇鍏ュ彛宸叉寚鍚?agent2 閾捐矾銆?
## 9. 2026-03-26 schedule_refine 缁撴瀯淇锛堣ˉ璁帮級
1. 绉婚櫎 `agent2/node/schedule_refine_impl` 鏍圭洰褰曞疄鐜帮紝鏀逛负鏀惧埌 `agent2/node/schedule_refine_impl`銆?2. `agent2/node/schedule_refine.go` 缁х画淇濈暀缁熶竴闂ㄩ潰鑱岃矗锛岄伩鍏?service/graph 鐩存帴渚濊禆缁嗚妭瀹炵幇銆?3. `agent2/node/schedule_refine_tool.go` 淇濈暀鍙屾枃浠舵牸灞€锛屽伐鍏峰疄鐜颁綅缃敼涓?`agent2/node/schedule_refine_impl/tool.go`銆?4. `agent2/graph/schedule.go` 娉ㄩ噴宸叉竻鐞嗕贡鐮侊紝graph 浠呰礋璐f牎楠屼笌缂栨帓銆?5. `service/agentsvc/agent_schedule_refine.go` 鍏ュ彛淇濇寔涓嶅彉锛屼粛瀹屽叏涓庢棫 `backend/agent/*` 瑙h€︺€?
## 10. 2026-03-26 schedule_refine 正式落地记录
1. `agent2/node/schedule_refine.go` 已从“兼容门面”升级为正式节点实现,直接承载 contract / plan / slice / route / react / hard_check / summary 全链路逻辑。
2. `agent2/node/schedule_refine_tool.go` 已承接全部微调工具实现,当前 `schedule_refine``node` 层落为“双文件结构”,不再依赖 `_impl` 子目录。
3. `agent2/model/schedule_refine.go` 继续作为 refine 状态与默认预算的正式归属,`node` 层仅复用状态别名与初始化入口,避免再维护第二份 state。
4. `agent2/prompt/schedule_refine.go` 已同步承接 refine 的正式 prompt删除了 `_impl/prompt.go` 这一份重复定义。
5. `agent2/graph/schedule.go` 已改为像 `schedule_plan` 一样在 graph 层真实组图,调用 `NewScheduleRefineNodes` 挂载节点,不再绕回 `_impl` 的独立运行入口。
6. 当前生产切流点保持不变:`service/agentsvc/agent_schedule_refine.go` 仍从 agent2 入口进入,但底层已完全切到新架构实现。
7. 本轮评估过把“模型调用 / JSON 解析 / ReAct 输出恢复 / 截断文本”等 helper 继续上提到更高公共层;暂未抽出的原因是 `schedule_refine``schedule_plan` 在输出契约、错误恢复、工具门禁、终审收口上仍存在较强领域差异,当前强行抽象会把公共层做成“带业务分支的半成品”,因此先保留在各自能力域内,等待下一轮出现更稳定的第三处复用后再统一抽象。

View File

@@ -235,6 +235,55 @@ func (a *AgentDAO) EnsureRetryGroupSeed(ctx context.Context, userID int, chatID,
}).Error
}
// ValidateRetrySourceMessages 校验重试父消息是否真实存在且角色匹配。
//
// 职责边界:
// 1. 负责校验 retry 请求引用的父 user/assistant 消息是否属于当前用户、当前会话。
// 2. 负责校验两条父消息的角色语义,避免把占位 id、串号 id 或交换角色的 id 写进数据库。
// 3. 不负责补种 retry_group_id分组补种仍由 EnsureRetryGroupSeed 负责。
func (a *AgentDAO) ValidateRetrySourceMessages(ctx context.Context, userID int, chatID string, sourceUserMessageID, sourceAssistantMessageID int) error {
// 1. retry 是“基于既有一问一答重新生成”,因此两条父消息 id 必须同时有效。
// 2. 只要任意一个缺失,就直接返回错误,禁止继续写出 index=1 的脏重试数据。
if sourceUserMessageID <= 0 || sourceAssistantMessageID <= 0 {
return errors.New("retry source message ids are invalid")
}
type retrySourceRow struct {
ID int
Role *string
}
ids := []int{sourceUserMessageID, sourceAssistantMessageID}
rows := make([]retrySourceRow, 0, len(ids))
if err := a.db.WithContext(ctx).
Model(&model.ChatHistory{}).
Select("id", "role").
Where("user_id = ? AND chat_id = ? AND id IN ?", userID, chatID, ids).
Find(&rows).Error; err != nil {
return err
}
if len(rows) != len(ids) {
return errors.New("retry source messages not found in current conversation")
}
roleByID := make(map[int]string, len(rows))
for _, row := range rows {
if row.Role == nil {
roleByID[row.ID] = ""
continue
}
roleByID[row.ID] = strings.ToLower(strings.TrimSpace(*row.Role))
}
if roleByID[sourceUserMessageID] != "user" {
return errors.New("retry source user message is invalid")
}
if roleByID[sourceAssistantMessageID] != "assistant" {
return errors.New("retry source assistant message is invalid")
}
return nil
}
func (a *AgentDAO) GetRetryGroupNextIndex(ctx context.Context, userID int, chatID, retryGroupID string) (int, error) {
normalizedGroupID := strings.TrimSpace(retryGroupID)
if normalizedGroupID == "" {

View File

@@ -3,13 +3,14 @@ package agentsvc
import (
"context"
"encoding/json"
"errors"
"log"
"strconv"
"strings"
"time"
agentchat "github.com/LoveLosita/smartflow/backend/agent2/chat"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
agentchat "github.com/LoveLosita/smartflow/backend/agent/chat"
agentrouter "github.com/LoveLosita/smartflow/backend/agent/router"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
@@ -213,6 +214,17 @@ func (s *AgentService) buildChatRetryMeta(ctx context.Context, userID int, chatI
sourceUserMessageID := readAgentExtraInt(extra, "retry_from_user_message_id")
sourceAssistantMessageID := readAgentExtraInt(extra, "retry_from_assistant_message_id")
// 1. retry 请求必须明确指向“被重试的那一轮 user + assistant”。
// 2. 若这里拿不到有效父消息 id继续写库只会生成一组孤立的 index=1 重试消息。
// 3. 因此直接拒绝本次请求,让前端刷新历史后重试,比静默写脏数据更安全。
if sourceUserMessageID <= 0 || sourceAssistantMessageID <= 0 {
return nil, errors.New("重试请求缺少有效的父消息ID请刷新会话后重试")
}
// 4. 再进一步校验父消息确实属于当前用户与当前会话,且角色语义正确。
// 5. 这样即便前端误把占位 id 或串号 id 发过来,后端也不会继续落错库。
if err := s.repo.ValidateRetrySourceMessages(ctx, userID, chatID, sourceUserMessageID, sourceAssistantMessageID); err != nil {
return nil, errors.New("重试引用的父消息无效,请刷新会话后重试")
}
if err := s.repo.EnsureRetryGroupSeed(ctx, userID, chatID, groupID, sourceUserMessageID, sourceAssistantMessageID); err != nil {
return nil, err

View File

@@ -46,7 +46,7 @@ func (s *AgentService) GetConversationHistory(ctx context.Context, userID int, c
items, cacheErr := s.cacheDAO.GetConversationHistoryFromCache(ctx, userID, normalizedChatID)
if cacheErr != nil {
log.Printf("读取会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr)
} else if items != nil {
} else if conversationHistoryCacheCanServe(items) {
return items, nil
}
}
@@ -228,6 +228,18 @@ func normalizeConversationHistoryRole(role string) string {
}
}
func conversationHistoryCacheCanServe(items []model.GetConversationHistoryItem) bool {
// 1. 历史接口一旦被前端用于“重试/编辑”等二次动作,消息 id 就必须稳定可追溯。
// 2. 乐观缓存里的新消息在 DB 落库前没有自增主键,若直接返回,会让前端拿到占位 id。
// 3. 因此只有“缓存里的每条消息都带稳定 DB id”时才允许直接命中缓存否则强制回源 DB。
for _, item := range items {
if item.ID <= 0 {
return false
}
}
return items != nil
}
func buildOptimisticConversationHistoryItem(
role string,
content string,

View File

@@ -7,12 +7,12 @@ import (
"strings"
"time"
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
agentrouter "github.com/LoveLosita/smartflow/backend/agent/router"
agentstream "github.com/LoveLosita/smartflow/backend/agent/stream"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
@@ -89,7 +89,7 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) {
e.reasoning.WriteString(detail)
}
// 3. 调用目的:阶段提示统一走 agent2/stream 的 reasoning chunk 包装,
// 3. 调用目的:阶段提示统一走 Agent/stream 的 reasoning chunk 包装,
// 避免 service 层继续自己拼 OpenAI 兼容 JSON。
err := agentstream.EmitStageAsReasoning(func(payload string) error {
e.outChan <- payload
@@ -295,10 +295,10 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel,
}
// decideQuickNoteRouting 决定当前输入是否进入“随口记 graph”。
// 该函数只是服务层薄封装,具体控制码解析逻辑已下沉到 agent/route 包。
// 该函数只是服务层薄封装,具体控制码解析逻辑已下沉到 Agent/router 包。
func (s *AgentService) decideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) quickNoteRoutingDecision {
// 这里保留方法是为了让 AgentService 对外语义完整,
// 同时避免上层调用方直接依赖 route,降低耦合。
// 同时避免上层调用方直接依赖 Agent/router,降低耦合。
_ = s
return agentrouter.DecideQuickNoteRouting(ctx, selectedModel, userMessage)
}

View File

@@ -3,7 +3,7 @@ package agentsvc
import (
"context"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
agentrouter "github.com/LoveLosita/smartflow/backend/agent/router"
"github.com/cloudwego/eino-ext/components/model/ark"
)

View File

@@ -6,9 +6,9 @@ import (
"log"
"strings"
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/pkg"
@@ -157,6 +157,6 @@ func (s *AgentService) runSchedulePlanFlow(
// 6. 旁路写入排程预览缓存(结构化 JSON给查询接口拉取。
// 6.1 失败只记日志,不影响本次对话回复;
// 6.2 成功后前端可通过 conversation_id 获取 candidate_plans。
s.saveSchedulePlanPreviewAgent2(ctx, userID, chatID, finalState)
s.saveSchedulePlanPreview(ctx, userID, chatID, finalState)
return reply, nil
}

View File

@@ -7,20 +7,20 @@ import (
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
)
// saveSchedulePlanPreview 鎶婃帓绋嬬粨鏋滀互缁撴瀯鍖?JSON 蹇収鍐欏叆 Redis銆?
// saveSchedulePlanPreview 负责把排程结果同步写入“查询预览”所需的缓存与快照。
//
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗鎶?finalState 涓殑 summary + candidate_plans 鏀舵暃涓虹紦瀛?DTO锛?
// 2. 璐熻矗浠モ€滃け璐ヤ笉闃绘柇鑱婂ぉ涓婚摼璺€濈殑绛栫暐鎵ц鍐欏叆锛?
// 3. 涓嶈礋璐?SSE 杩斿洖鍗忚锛屼笉璐熻矗鏁版嵁搴撹惤搴撱€?
// 职责边界:
// 1. 负责把 graph 最终状态映射为统一预览 DTO并先写 Redis、再写 MySQL 快照。
// 2. 负责执行“失败不阻断主回复”的旁路持久化策略,避免影响聊天主链路。
// 3. 不负责 SSE 输出,不负责聊天消息落库,也不负责 refine 状态到 plan 状态的转换。
func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
// 1. 鍩虹鍓嶇疆鏍¢獙锛歴tate 涓虹┖鏃剁洿鎺ヨ繑鍥烇紝閬垮厤鍐欏叆鍗婃垚鍝佸揩鐓с€?
// 1. 先做最小前置校验,避免把空状态或空会话写成脏快照。
if s == nil || finalState == nil {
return
}
@@ -29,19 +29,14 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int,
return
}
// 2. 缁勮缂撳瓨蹇収锛?
// 2.1 summary 浼樺厛鍙?final summary锛岀┖鍊兼椂浣跨敤缁熶竴鍏滃簳鏂囨锛?
// 2.2 candidate_plans 鍋氬垏鐗囨嫹璐濓紝閬垮厤鍚庣画寮曠敤鍏变韩瀵艰嚧鎰忓瑕嗙洊锛?
// 2.3 generated_at 鐢ㄤ簬鍓嶇鍒ゆ柇鈥滃綋鍓嶉瑙堢殑鏂伴矞搴︹€濄€?
summary := strings.TrimSpace(finalState.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
}
// 2. 组装统一预览缓存结构。
// 2.1 summary 为空时使用统一兜底文案,保证查询接口始终有稳定输出。
// 2.2 所有切片字段都做深拷贝,避免缓存与 graph state 共享底层数组。
preview := &model.SchedulePlanPreviewCache{
UserID: userID,
ConversationID: normalizedChatID,
TraceID: strings.TrimSpace(finalState.TraceID),
Summary: summary,
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(finalState.FinalSummary)),
CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans),
TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...),
HybridEntries: cloneHybridEntries(finalState.HybridEntries),
@@ -49,91 +44,34 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int,
GeneratedAt: time.Now(),
}
// 3. 璋冪敤鐩殑锛氬厛鍐?Redis 棰勮锛屼繚璇佸墠绔煡璇㈡帴鍙h兘蹇€熻鍙栫粨鏋勫寲缁撴灉銆?
// 3.1 Redis 鏄€滃揩璺緞鈥濓紱澶辫触鍙褰曟棩蹇楋紝涓嶄腑鏂富閾捐矾锛?
// 3.2 澶辫触鍏滃簳鐢卞悗缁?MySQL 蹇収鎵挎帴銆?
// 3. 先写 Redis 预览,保证前端查询链路优先命中低时延缓存。
// 3.1 Redis 写失败只记日志,不中断主流程;
// 3.2 真正兜底由后续 MySQL 快照承担。
if s.cacheDAO != nil {
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
log.Printf("鍐欏叆鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err)
log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
}
}
// 4. 璋冪敤鐩殑锛氬悓姝ュ啓 MySQL 鐘舵€佸揩鐓э紝淇濊瘉 Redis 澶辨晥鍚庝粛鍙繛缁井璋冦€?
// 4.1 杩欓噷閲囩敤鈥滃悓姝ュ啓搴撯€濊€屼笉鏄?outbox锛氬洜涓轰笅涓€杞井璋冭寮哄疄鏃惰鍙栵紱
// 4.2 蹇収鍐欏叆澶辫触鍙墦鏃ュ織锛屼笉闃绘柇鏈疆鐢ㄦ埛鍥炲锛岄伩鍏嶄綋楠屾姈鍔紱
// 4.3 revision 鑷鐢?DAO 鐨?upsert 鍐茬獊鏇存柊璐熻矗銆?
// 4. 再写 MySQL 快照,保证缓存失效后仍能恢复预览与连续微调上下文。
// 4.1 这里继续采用“同步写快照”的策略,因为下一轮 refine 依赖强一致读取;
// 4.2 写库失败同样只记日志,避免让用户侧回复因为旁路持久化失败而中断。
if s.repo != nil {
snapshot := buildSchedulePlanSnapshotFromState(userID, normalizedChatID, finalState)
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
log.Printf("鍐欏叆鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err)
log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
}
}
}
// saveSchedulePlanPreviewAgent2 鎶?agent2 鐨?schedule_plan 缁撴灉鍐欏叆 Redis 棰勮涓?MySQL 蹇収銆?
// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。
//
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗鎵挎帴鈥滄柊 agent2 棣栨鎺掔▼閾捐矾鈥濈殑鏈€缁堢姸鎬侊紱
// 2. 璐熻矗娌跨敤鐜版湁棰勮缂撳瓨/鐘舵€佸揩鐓у崗璁紝淇濊瘉鏌ヨ鎺ュ彛涓?refine 璇诲彇閫昏緫涓嶉渶瑕佽窡鐫€閲嶅啓锛?
// 3. 涓嶈礋璐?refine 鐘舵€佽浆鎹紝refine 浠嶇户缁蛋鏃ч摼璺殑 saveSchedulePlanPreview銆?
func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
// 1. 鍩虹鍓嶇疆鏍¢獙锛歴tate 涓虹┖鏃剁洿鎺ヨ繑鍥烇紝閬垮厤鍐欏叆鍗婃垚鍝佸揩鐓с€?
if s == nil || finalState == nil {
return
}
normalizedChatID := strings.TrimSpace(chatID)
if normalizedChatID == "" {
return
}
// 2. 缁勮缂撳瓨蹇収銆?
// 2.1 summary 浼樺厛鍙?final summary锛岀┖鍊兼椂浣跨敤缁熶竴鍏滃簳鏂囨锛?
// 2.2 candidate_plans / hybrid_entries / allocated_items 缁熶竴娣辨嫹璐濓紝閬垮厤缂撳瓨涓?graph state 鍏辩敤搴曞眰鍒囩墖锛?
// 2.3 generated_at 鐢ㄤ簬鍓嶇鍒ゆ柇鈥滃綋鍓嶉瑙堟槸鍚︿负鏈€鏂版柟妗堚€濄€?
summary := strings.TrimSpace(finalState.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
}
preview := &model.SchedulePlanPreviewCache{
UserID: userID,
ConversationID: normalizedChatID,
TraceID: strings.TrimSpace(finalState.TraceID),
Summary: summary,
CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans),
TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...),
HybridEntries: cloneHybridEntries(finalState.HybridEntries),
AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems),
GeneratedAt: time.Now(),
}
// 3. 鍏堝啓 Redis 棰勮锛屼繚璇佸墠绔煡璇㈡帴鍙h兘绔嬪嵆璇诲彇缁撴瀯鍖栫粨鏋溿€?
// 3.1 Redis 鏄€滃揩璺緞鈥濓紱
// 3.2 澶辫触鍙褰曟棩蹇楋紝涓嶄腑鏂亰澶╀富閾捐矾銆?
if s.cacheDAO != nil {
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
log.Printf("鍐欏叆鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err)
}
}
// 4. 鍚屾鍐?MySQL 蹇収锛屼繚璇?Redis 澶辨晥鍚庝粛鑳芥仮澶嶉瑙堜笌杩炵画寰皟涓婁笅鏂囥€?
// 4.1 杩欓噷缁х画淇濇寔鈥滃悓姝ュ啓搴撯€濈瓥鐣ワ紝鍥犱负涓嬩竴杞井璋冨蹇収璇诲彇鏄己瀹炴椂渚濊禆锛?
// 4.2 鍐欏簱澶辫触鍙墦鏃ュ織锛屼笉闃绘柇鏈疆缁欑敤鎴风殑鏂囨湰鍥炲銆?
if s.repo != nil {
snapshot := buildSchedulePlanSnapshotFromAgent2State(userID, normalizedChatID, finalState)
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
log.Printf("鍐欏叆鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err)
}
}
}
// GetSchedulePlanPreview 鎸?conversation_id 璇诲彇缁撴瀯鍖栨帓绋嬮瑙堛€?
//
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗鍙傛暟褰掍竴鍖栥€佺紦瀛樿鍙栦笌浼氳瘽褰掑睘鏍¢獙锛?
// 2. 璐熻矗鎶婄紦瀛?DTO 杞垚 API 鍝嶅簲 DTO锛?
// 3. 涓嶈礋璐hЕ鍙戞帓绋嬶紝涓嶈礋璐hˉ绠楃紦瀛樸€?
// 职责边界:
// 1. 负责参数归一化、缓存优先读取、会话归属校验和 DB 兜底。
// 2. 负责把缓存/快照 DTO 转成接口响应 DTO。
// 3. 不负责触发排程,不负责补算结果,也不负责消息链路落库。
func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, chatID string) (*model.GetSchedulePlanPreviewResponse, error) {
// 1. 鍙傛暟鏍¢獙锛歝onversation_id 涓虹┖鐩存帴杩斿洖鍙傛暟閿欒锛岄伩鍏嶆棤鏁?Redis 璇锋眰銆?
// 1. 先校验会话参数,避免无效请求打到缓存或数据库。
normalizedChatID := strings.TrimSpace(chatID)
if normalizedChatID == "" {
return nil, respond.MissingParam
@@ -142,10 +80,9 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
return nil, errors.New("agent service is not initialized")
}
// 2. 鏌ヨ缂撳瓨骞舵牎楠屽綊灞烇細
// 2.1 缂撳瓨鏈懡涓細缁熶竴杩斿洖鈥滈瑙堜笉瀛樺湪/宸茶繃鏈熲€濓紱
// 2.2 鍛戒腑浣?user_id 涓嶄竴鑷达細鎸夋湭鍛戒腑澶勭悊锛岄伩鍏嶆硠闇蹭粬浜轰細璇濅俊鎭紱
// 2.3 澶辫触鍏滃簳锛氱紦瀛樿寮傚父鐩存帴涓婃姏锛岀敱 API 灞傜粺涓€閿欒澶勭悊銆?
// 2. 优先查 Redis。
// 2.1 命中后立即校验 user_id避免把别人的会话预览泄露给当前用户
// 2.2 缓存异常直接上抛,由接口层统一处理错误响应。
if s.cacheDAO != nil {
preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID)
if err != nil {
@@ -169,10 +106,9 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
}
}
// 3. Redis 鏈懡涓椂鍥炶惤 MySQL 蹇収锛?
// 3.1 璇诲彇鎴愬姛鍚庣洿鎺ヨ繑鍥烇紝閬垮厤鐢ㄦ埛鐪嬪埌鈥滈瑙堜笉瀛樺湪鈥濈殑鍋囬槾鎬э紱
// 3.2 鑻ユ湰娆″懡涓?DB 涓旂紦瀛樺彲鐢紝鍒欓『鎵嬪洖濉?Redis锛屾彁鍗囧悗缁懡涓巼锛?
// 3.3 DB 涔熸湭鍛戒腑鏃跺啀杩斿洖 not found銆?
// 3. Redis 未命中时回源 MySQL。
// 3.1 命中快照后顺手回填 Redis提高后续命中率
// 3.2 DB 未命中才真正返回 not found避免缓存过期造成假阴性。
if s.repo != nil {
snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID)
if err != nil {
@@ -183,36 +119,36 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
if s.cacheDAO != nil {
cachePreview := snapshotToSchedulePlanPreviewCache(snapshot)
if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, cachePreview); setErr != nil {
log.Printf("鍥炲~鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, setErr)
log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
}
}
return response, nil
}
}
return nil, respond.SchedulePlanPreviewNotFound
}
// cloneWeekSchedules 瀵瑰懆瑙嗗浘鎺掔▼缁撴灉鍋氭繁鎷疯礉锛岄伩鍏嶅垏鐗囧紩鐢ㄥ叡浜€?
// cloneWeekSchedules 负责深拷贝周视图排程,避免缓存与运行态共享底层切片。
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
return agentshared.CloneWeekSchedules(src)
}
// cloneHybridEntries 娣辨嫹璐濇贩鍚堟潯鐩垏鐗囷紝閬垮厤缂撳瓨/鐘舵€佷箣闂寸浉浜掓薄鏌撱€?
// cloneHybridEntries 负责深拷贝混合排程条目,避免跨请求污染。
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
return agentshared.CloneHybridEntries(src)
}
// cloneTaskClassItems 娣辨嫹璐濅换鍔″潡鍒囩墖锛堝寘鍚寚閽堝瓧娈碉級锛岄伩鍏嶈法璇锋眰寮曠敤鍏变韩銆?
// cloneTaskClassItems 负责深拷贝任务项切片,包含内部指针字段的安全复制。
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
return agentshared.CloneTaskClassItems(src)
}
// buildSchedulePlanSnapshotFromState 鎶?graph 杩愯缁撴灉鏄犲皠鎴愬彲鎸佷箙鍖栧揩鐓?DTO銆?
// buildSchedulePlanSnapshotFromState graph 最终状态映射成可持久化的快照 DTO
//
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗瀛楁鏄犲皠涓庢繁鎷疯礉锛岄伩鍏嶈法灞傚叡浜彲鍙樺垏鐗囷紱
// 2. 璐熻矗琛ラ綈 state_version 榛樿鍊硷紱
// 3. 涓嶈礋璐f暟鎹簱鍐欏叆锛堝啓鍏ョ敱 DAO 鎵挎媴锛夈€?
// 职责边界:
// 1. 负责字段归一化、深拷贝和 state_version 补齐。
// 2. 不负责数据库写入,也不负责生成业务摘要文案。
func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot {
if st == nil {
return nil
@@ -236,44 +172,11 @@ func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *a
}
}
// buildSchedulePlanSnapshotFromAgent2State 鎶?agent2 鐨勬帓绋嬬姸鎬佹槧灏勬垚鍙寔涔呭寲蹇収 DTO銆?
//
// 璋冪敤鐩殑锛?
// 1. 杩欒疆鍙縼绉?schedule_plan锛屼笉鍔?refine锛?
// 2. 鍥犳 preview/蹇収鍗忚缁х画澶嶇敤鑰佺粨鏋勶紝浣嗚琛ヤ竴涓€渁gent2 state -> snapshot DTO鈥濈殑鏄犲皠灞傦紱
// 3. 杩欐牱鍙互鍋氬埌锛氳鍒掑垱寤洪摼璺垏鍒?agent2锛岃€?refine / 棰勮鏌ヨ閾捐矾鏆傛椂鏃犻渶澶ф敼銆?
func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot {
if st == nil {
return nil
}
return &model.SchedulePlanStateSnapshot{
UserID: userID,
ConversationID: conversationID,
StateVersion: model.SchedulePlanStateVersionV1,
TaskClassIDs: append([]int(nil), st.TaskClassIDs...),
Constraints: append([]string(nil), st.Constraints...),
HybridEntries: cloneHybridEntries(st.HybridEntries),
AllocatedItems: cloneTaskClassItems(st.AllocatedItems),
CandidatePlans: cloneWeekSchedules(st.CandidatePlans),
UserIntent: strings.TrimSpace(st.UserIntent),
Strategy: strings.TrimSpace(st.Strategy),
AdjustmentScope: strings.TrimSpace(st.AdjustmentScope),
RestartRequested: st.RestartRequested,
FinalSummary: strings.TrimSpace(st.FinalSummary),
Completed: st.Completed,
TraceID: strings.TrimSpace(st.TraceID),
}
}
// snapshotToSchedulePlanPreviewCache 鎶?MySQL 蹇収杞崲涓?Redis 棰勮缂撳瓨缁撴瀯銆?
// snapshotToSchedulePlanPreviewCache 把 MySQL 快照映射成 Redis 预览缓存结构。
func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache {
if snapshot == nil {
return nil
}
summary := strings.TrimSpace(snapshot.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
}
generatedAt := snapshot.UpdatedAt
if generatedAt.IsZero() {
generatedAt = time.Now()
@@ -282,7 +185,7 @@ func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapsho
UserID: snapshot.UserID,
ConversationID: snapshot.ConversationID,
TraceID: strings.TrimSpace(snapshot.TraceID),
Summary: summary,
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)),
CandidatePlans: cloneWeekSchedules(snapshot.CandidatePlans),
TaskClassIDs: append([]int(nil), snapshot.TaskClassIDs...),
HybridEntries: cloneHybridEntries(snapshot.HybridEntries),
@@ -291,7 +194,7 @@ func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapsho
}
}
// snapshotToSchedulePlanPreviewResponse 鎶?MySQL 蹇収杞崲涓烘煡璇㈡帴鍙e搷搴斻€?
// snapshotToSchedulePlanPreviewResponse MySQL 快照映射成查询接口响应结构。
func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnapshot) *model.GetSchedulePlanPreviewResponse {
if snapshot == nil {
return nil
@@ -300,10 +203,6 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap
if plans == nil {
plans = make([]model.UserWeekSchedule, 0)
}
summary := strings.TrimSpace(snapshot.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
}
generatedAt := snapshot.UpdatedAt
if generatedAt.IsZero() {
generatedAt = time.Now()
@@ -311,8 +210,16 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap
return &model.GetSchedulePlanPreviewResponse{
ConversationID: snapshot.ConversationID,
TraceID: strings.TrimSpace(snapshot.TraceID),
Summary: summary,
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)),
CandidatePlans: plans,
GeneratedAt: generatedAt,
}
}
// schedulePlanSummaryOrFallback 统一收口排程摘要兜底文案,避免各处重复维护默认值。
func schedulePlanSummaryOrFallback(summary string) string {
if strings.TrimSpace(summary) == "" {
return "排程流程已完成,但未生成结果摘要。"
}
return summary
}

View File

@@ -6,9 +6,9 @@ import (
"log"
"strings"
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/cloudwego/eino-ext/components/model/ark"
@@ -69,7 +69,7 @@ func (s *AgentService) runScheduleRefineFlow(
// 4.2 杩欓噷鎶?refine state 鏄犲皠涓?scheduleplan state锛屽鐢ㄥ凡鏈夎惤鐩橀摼璺紱
// 4.3 浣嗚嫢鏄€滅嫭绔嬪鍚堝垎鏀凡鍑虹珯銆佺粓瀹′粛澶辫触鈥濓紝鍒欎笉瑕嗙洊涓婁竴鐗堥瑙堬紝閬垮厤澶栭儴璇互涓烘柊鏂规宸查獙璇侀€氳繃銆?
if shouldPersistScheduleRefinePreview(finalState) {
s.saveSchedulePlanPreviewAgent2(ctx, userID, chatID, convertRefineStateToPlanState(finalState))
s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState))
} else {
emitStage("schedule_refine.preview.skipped", "复合分支终审未通过,本轮结果不覆盖上一版预览。")
}

Some files were not shown because too many files have changed in this diff Show More