From f4ef6fb256710e5b2dd4a0dd58b38a03efba0ec0 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Tue, 24 Mar 2026 21:35:22 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.7.5.dev.260324=20=F0=9F=90=9B=20fi?= =?UTF-8?q?x(agent/schedulerefine):=20=E4=BF=AE=E5=A4=8D=E5=A4=8D=E5=90=88?= =?UTF-8?q?=E5=BE=AE=E8=B0=83=E5=88=86=E6=94=AF=E9=93=BE=E8=B7=AF=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E5=B9=B6=E5=B0=86=20MinContextSwitch=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=BA=E5=9B=BA=E5=AE=9A=E5=9D=91=E4=BD=8D?= =?UTF-8?q?=E9=87=8D=E6=8E=92=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🔧 修复 `schedulerefine` 复合路由中参数透传不完整、缺少 deterministic objective 时错误降级,以及“复合工具执行成功”与“终审通过”语义混淆的问题 - ✅ 保证新的独立复合分支能够正确执行、正确出站,并统一交由 `hard_check` 裁决最终结果 - 🔍 排查时发现 `MinContextSwitch` 上游 `context_tag` 存在整体退化为 `General` 的风险,影响MinContextSwitch - 🛡️ 为 `MinContextSwitch` 增加兜底策略:当标签整体退化时,按任务名关键词推断学科分组,避免分组能力失效 - ♻️ 将 `MinContextSwitch` 从“整周重新寻找新坑位”调整为“坑位不变,任务顺序改变” - 🎯 将落地方式从顺序 `BatchMove` 改为固定坑位原子重写,避免出现远距离跳位、跨天错迁、异常嵌入课位及循环换位冲突 - 🧹 修复 `hard_check` 在 `MinContextSwitch` 成功后仍执行 `origin_rank` 顺序归位、并导致逆序终审误判的问题 - 🚦 命中该分支后跳过顺序归位与顺序硬校验,避免 `summary` / `hard_check` 将有效重排结果误判为失败 📈 当前连续微调规划涉及的全部功能已可以稳定运行;下一步将继续扩展能力边界,并进一步优化 `schedule_plan` 流程 ♻️ refactor: 重整 agent2 架构,并迁移 quicknote/chat 新链路,目前还剩3个模块未迁移,后续迁移完成后会删除原agent并将此目录命名为agent - 🏗️ 明确 `agent2` 采用“统一分层目录 + 文件分层 + 依赖注入”的重构方案,不再沿用模块目录多层嵌套结构 - 🧩 完善 `agent2` 基础骨架,统一收口 `entrance` / `router` / `llm` / `stream` / `shared` / `model` / `prompt` / `node` / `graph` 等层级职责 - 🚚 将通用路由能力迁移至 `agent2/router`,沉淀统一的 `Action`、`RoutingDecision`、控制码解析,以及 `Dispatcher` / `Resolver` 抽象 - 💬 将普通聊天链路迁移至 `agent2/chat`,复用 `stream` 的 OpenAI 兼容输出协议与 LLM usage 聚合能力 - 📝 将 `quicknote` 链路迁移到 `agent2` 新结构,拆分为 `model` / `prompt` / `llm` / `node` / `graph` 多层实现,替换对旧 `agent/quicknote` 的直接依赖 - 🔌 调整 `agentsvc` 对 `agent2` 的引用,普通聊天、通用分流与 `quicknote` 全部切换到新链路 - ✂️ 去除 graph 内部 `runner` 转接层,改为由 node 层直接持有请求级依赖,并向 graph 暴露节点方法 - 🧹 合并 `graph/quicknote` 与 `graph/quicknote_run`,删除冗余骨架文件,收敛为单一 `quicknote graph` 文件 - 📚 新增 `agent2`《通用能力接入文档》,明确公共能力边界、接入方式以及 graph/node 协作约定 - 📝 更新 `AGENTS.md`,要求后续扩展 `agent2` 通用能力时必须同步维护接入文档 ♻️ refactor: 删除了现Agent目录内Chat模块的两条冗余Prompt --- AGENTS.md | 9 + agent2逐批搬迁实施细节.md | 388 ++++++++++ agent代码复用清单.md | 448 +++++++++++ backend/agent/chat/prompt.go | 26 - .../schedulerefine/composite_route_test.go | 85 ++ .../schedulerefine/composite_tools_test.go | 62 ++ backend/agent/schedulerefine/nodes.go | 86 ++- .../schedulerefine/refine_filters_test.go | 64 ++ backend/agent/schedulerefine/state.go | 20 +- backend/agent/schedulerefine/tool.go | 267 +++++-- backend/agent2/chat/prompt.go | 8 + backend/agent2/chat/stream.go | 131 ++++ backend/agent2/entrance.go | 41 + backend/agent2/graph/quicknote.go | 149 ++++ backend/agent2/graph/schedule.go | 35 + backend/agent2/graph/taskquery.go | 22 + backend/agent2/llm/ark.go | 83 ++ backend/agent2/llm/client.go | 216 ++++++ backend/agent2/llm/json.go | 112 +++ backend/agent2/llm/quicknote.go | 170 ++++ backend/agent2/llm/route.go | 50 ++ backend/agent2/llm/schedule.go | 22 + backend/agent2/llm/taskquery.go | 19 + backend/agent2/model/common.go | 17 + backend/agent2/model/quicknote.go | 123 +++ backend/agent2/model/route.go | 20 + backend/agent2/model/schedule.go | 23 + backend/agent2/model/taskquery.go | 11 + backend/agent2/node/quicknote.go | 132 ++++ backend/agent2/node/quicknote_flow.go | 395 ++++++++++ backend/agent2/node/quicknote_tool.go | 723 ++++++++++++++++++ backend/agent2/node/schedule_plan.go | 25 + backend/agent2/node/schedule_refine.go | 25 + backend/agent2/node/taskquery.go | 25 + backend/agent2/prompt/quicknote.go | 46 ++ backend/agent2/prompt/route.go | 24 + backend/agent2/prompt/schedule.go | 24 + backend/agent2/prompt/taskquery.go | 23 + backend/agent2/router/action_route.go | 272 +++++++ backend/agent2/router/route.go | 67 ++ backend/agent2/router/route_model.go | 34 + backend/agent2/shared/clone.go | 95 +++ backend/agent2/shared/retry.go | 85 ++ backend/agent2/shared/time.go | 49 ++ backend/agent2/stream/emitter.go | 115 +++ backend/agent2/stream/openai.go | 102 +++ backend/agent2/通用能力接入文档.md | 337 ++++++++ backend/logic/refine_compound_ops.go | 102 ++- backend/logic/refine_compound_ops_test.go | 36 + backend/service/agentsvc/agent.go | 18 +- backend/service/agentsvc/agent_quick_note.go | 142 +--- .../agentsvc/agent_quick_note_route_test.go | 42 +- backend/service/agentsvc/agent_route.go | 6 +- .../service/agentsvc/agent_schedule_refine.go | 24 +- .../agentsvc/agent_schedule_refine_test.go | 52 ++ 55 files changed, 5492 insertions(+), 235 deletions(-) create mode 100644 agent2逐批搬迁实施细节.md create mode 100644 agent代码复用清单.md create mode 100644 backend/agent/schedulerefine/composite_route_test.go create mode 100644 backend/agent2/chat/prompt.go create mode 100644 backend/agent2/chat/stream.go create mode 100644 backend/agent2/entrance.go create mode 100644 backend/agent2/graph/quicknote.go create mode 100644 backend/agent2/graph/schedule.go create mode 100644 backend/agent2/graph/taskquery.go create mode 100644 backend/agent2/llm/ark.go create mode 100644 backend/agent2/llm/client.go create mode 100644 backend/agent2/llm/json.go create mode 100644 backend/agent2/llm/quicknote.go create mode 100644 backend/agent2/llm/route.go create mode 100644 backend/agent2/llm/schedule.go create mode 100644 backend/agent2/llm/taskquery.go create mode 100644 backend/agent2/model/common.go create mode 100644 backend/agent2/model/quicknote.go create mode 100644 backend/agent2/model/route.go create mode 100644 backend/agent2/model/schedule.go create mode 100644 backend/agent2/model/taskquery.go create mode 100644 backend/agent2/node/quicknote.go create mode 100644 backend/agent2/node/quicknote_flow.go create mode 100644 backend/agent2/node/quicknote_tool.go create mode 100644 backend/agent2/node/schedule_plan.go create mode 100644 backend/agent2/node/schedule_refine.go create mode 100644 backend/agent2/node/taskquery.go create mode 100644 backend/agent2/prompt/quicknote.go create mode 100644 backend/agent2/prompt/route.go create mode 100644 backend/agent2/prompt/schedule.go create mode 100644 backend/agent2/prompt/taskquery.go create mode 100644 backend/agent2/router/action_route.go create mode 100644 backend/agent2/router/route.go create mode 100644 backend/agent2/router/route_model.go create mode 100644 backend/agent2/shared/clone.go create mode 100644 backend/agent2/shared/retry.go create mode 100644 backend/agent2/shared/time.go create mode 100644 backend/agent2/stream/emitter.go create mode 100644 backend/agent2/stream/openai.go create mode 100644 backend/agent2/通用能力接入文档.md create mode 100644 backend/service/agentsvc/agent_schedule_refine_test.go diff --git a/AGENTS.md b/AGENTS.md index 739c699..bb402cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,15 @@ 2. 请勤加注释,尤其是复杂逻辑部分,确保代码易于理解和维护。 3. 每次在本地执行测试命令(如 `go test`)后,必须清理项目根目录下的 `.gocache` 目录,避免缓存文件长期堆积。 4. 文件编码统一使用 UTF-8(无 BOM),禁止使用 GBK、GB2312 等其他编码,避免中文内容出现乱码。 +5. 进行结构重构时,优先采用“并行迁移”策略:允许新旧目录并存,先迁移、再切流、再验证、最后删除旧实现,禁止一步到位式重命名大改。 +6. 遇到公共能力(如模型调用、JSON 解析、阶段推送、深拷贝、缓存快照读写)时,若在第二处出现重复实现,必须优先评估是否抽公共层,禁止无脑复制第三份。 +7. 迁移期每一轮只允许处理一个能力域或一类公共件,禁止同一轮同时改多个 skill 的结构与逻辑,避免回归问题无法定位。 +8. 新增代码时,必须优先复用已有公共能力;如果暂时无法复用,必须在注释或文档中写明“为什么这次不能抽公共层”,禁止默认复制粘贴旧实现。 +9. 对于明显过大的文件(尤其是同时承载编排、业务、模型交互、工具分发的文件),后续重构时必须拆分职责,禁止继续向单文件堆砌新逻辑。 +10. Prompt、State、模型交互、Graph 连线应尽量分目录/分文件管理,禁止把大段 prompt、节点逻辑、模型 helper 长期混写在同一文件中。 +11. 若本轮任务包含“结构迁移”,最终答复中必须明确说明:本轮迁了什么、哪些旧实现仍保留、当前切流点在哪里、下一轮建议迁什么。 + +12. 若后续在 `backend/agent2` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent2/通用能力接入文档.md`,否则视为重构信息不完整。 ## 注释规范(强制) diff --git a/agent2逐批搬迁实施细节.md b/agent2逐批搬迁实施细节.md new file mode 100644 index 0000000..f9f007c --- /dev/null +++ b/agent2逐批搬迁实施细节.md @@ -0,0 +1,388 @@ +# 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 全部切流并验证通过之后。 + diff --git a/agent代码复用清单.md b/agent代码复用清单.md new file mode 100644 index 0000000..656d0d2 --- /dev/null +++ b/agent代码复用清单.md @@ -0,0 +1,448 @@ +# 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) + diff --git a/backend/agent/chat/prompt.go b/backend/agent/chat/prompt.go index 67dce94..0ce4165 100644 --- a/backend/agent/chat/prompt.go +++ b/backend/agent/chat/prompt.go @@ -5,30 +5,4 @@ const ( SystemPrompt = `你叫 SmartFlow,是专为重邮(CQUPT)学子打造的智能排程专家。 你的回复应当专业、干练,偶尔可以带一点程序员式的冷幽默。 重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。` - - // SmartAssistantPrompt 合并了分诊与对话能力的超级提示词 - SmartAssistantPrompt = `你叫 SmartFlow,是专为重邮(CQUPT)学子打造的智能排程专家。 -### 你的双重职责: -1. **直接对话**:如果用户是闲聊、查询简单信息或进行通用问答,请直接以专业且幽默的口吻回复。 -2. **决策路由**:如果用户提出需要“安排日程”、“解决冲突”或涉及“3D Atomic TimeGrid”的操作,请在回复中明确你的计划,并准备调用相应的排程工具。 -### 核心约束: -- 始终保持对“稳扎稳打(Steady)模式”的敬畏,压缩率不得超过 15%。 -- 针对重邮场景(如:红岩网校、南山教学楼)提供有温度的建议。 -### 输出格式: -- 如果涉及排程工具调用,请先简要说明你的调整思路,再执行动作。` - - // SchedulerPromptTemplate 排程专家 (Scheduler):核心算法 Agent - // 这里注入 3D Grid 和 Steady 模式的约束 - SchedulerPromptTemplate = `你是一位精通“三维原子时间网格(3D Atomic TimeGrid)”的顶级排程架构师。 -在处理用户的排程请求时,你必须遵循以下硬性逻辑约束: -1. 稳扎稳打(Steady)模式:任务步长(Step)的动态分配必须保守,压缩率严禁超过原始时长的 15%。 -2. 逻辑空间投影(Logical Space Mapping):当发生时空重叠时,优先尝试在逻辑向量维度平移,而非直接删除冲突任务。 -3. 冲突自愈:若发现网格冲突,请主动提出“缩放任务块”或“重新锚定时间点”的自愈方案。 - -请以极其严谨的态度处理每一秒钟的分配。` - - // DefaultPromptTemplate 通用助手 (Assistant):也就是你之前占位的那个 - DefaultPromptTemplate = `你是一位时间管理大师、日程安排专家兼个人助理。 -你的目标是协助用户高效安排日程。请确保你的回答简洁明了,直接针对用户的需求进行回复。 -如果用户提到重邮(CQUPT)相关内容(如:南山、红岩网校、卓越工程师班),请表现出你的亲切感。` ) diff --git a/backend/agent/schedulerefine/composite_route_test.go b/backend/agent/schedulerefine/composite_route_test.go new file mode 100644 index 0000000..baafbe3 --- /dev/null +++ b/backend/agent/schedulerefine/composite_route_test.go @@ -0,0 +1,85 @@ +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 成功状态被记录") + } +} diff --git a/backend/agent/schedulerefine/composite_tools_test.go b/backend/agent/schedulerefine/composite_tools_test.go index d0fbd9c..28e99a9 100644 --- a/backend/agent/schedulerefine/composite_tools_test.go +++ b/backend/agent/schedulerefine/composite_tools_test.go @@ -1,6 +1,7 @@ package schedulerefine import ( + "fmt" "sort" "testing" @@ -96,6 +97,67 @@ func TestRefineToolMinContextSwitchGroupsContext(t *testing.T) { 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) { diff --git a/backend/agent/schedulerefine/nodes.go b/backend/agent/schedulerefine/nodes.go index 75c255b..b8ad94a 100644 --- a/backend/agent/schedulerefine/nodes.go +++ b/backend/agent/schedulerefine/nodes.go @@ -300,7 +300,17 @@ func runCompositeRouteNode( continue } - lastReason = "未启用确定性目标,无法在复合路由直接收口" + // 1. “均匀分散/最少上下文切换”这类复合目标,未必能编译成 deterministic objective; + // 2. 只要本轮要求的复合工具已经成功执行,就允许独立复合分支直接出站并跳过 ReAct; + // 3. 最终是否真正达标,继续交给 hard_check 统一裁决,避免“工具成功却被路由误判失败”。 + if reason, ok := allowCompositeRouteExitByToolSuccess(st, result); ok { + st.CompositeRouteSucceeded = true + emitStage("schedule_refine.route.handoff", truncate(reason, 180)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由直接出站:tool=%s,reason=%s", required, reason)) + return st, nil + } + + lastReason = "未启用确定性目标,且复合工具门禁未满足,无法在复合路由直接出站" } // 1. 复合路由重试后仍失败,切入 ReAct 兜底并强制禁用复合工具。 @@ -343,6 +353,30 @@ func buildCompositeRouteTaskIDs(st *ScheduleRefineState) []int { return out } +// allowCompositeRouteExitByToolSuccess 判断“复合工具成功后,是否允许跳过 ReAct 直接进入终审”。 +// +// 步骤化说明: +// 1. 仅在当前没有 deterministic objective 时启用,避免覆盖原有“确定性验收优先”策略; +// 2. 只有本轮要求的复合工具已成功、且成功工具名与门禁一致时才放行; +// 3. 放行后并不代表最终成功,后续仍由 hard_check 做统一裁决。 +func allowCompositeRouteExitByToolSuccess(st *ScheduleRefineState, result reactToolResult) (string, bool) { + if st == nil || !result.Success { + return "", false + } + if strings.TrimSpace(st.Objective.Mode) != "" && strings.TrimSpace(st.Objective.Mode) != "none" { + return "", false + } + required := normalizeCompositeToolName(st.RequiredCompositeTool) + toolName := normalizeCompositeToolName(result.Tool) + if required == "" || toolName == "" || required != toolName { + return "", false + } + if !isRequiredCompositeSatisfied(st) { + return "", false + } + return fmt.Sprintf("复合工具 %s 已成功执行;当前目标暂不支持确定性收口,跳过 ReAct,交由终审裁决。", required), true +} + func buildCompositeRouteCall(st *ScheduleRefineState, tool string, taskIDs []int) reactToolCall { limit := len(taskIDs) * 6 if limit < 12 { @@ -729,7 +763,9 @@ func runHardCheckNode( // 2. 后续顺序归位仅用于最终展示与顺序一致性,不得反向改变业务目标成败。 intentPassLocked, intentReasonLocked, intentUnmetLocked := evaluateIntentForJudgement(ctx, chatModel, st, emitStage) emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("终审业务目标已锁定:pass=%t,reason=%s", intentPassLocked, truncate(intentReasonLocked, 120))) - if changed := normalizeMovableTaskOrderByOrigin(st); changed { + if changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st); skipped { + emitStage("schedule_refine.hard_check.order_normalized", "已跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。") + } else if changed { emitStage("schedule_refine.hard_check.order_normalized", "已在终审前按 origin_rank 对坑位做顺序归位。") } report := evaluateHardChecks(ctx, chatModel, st, emitStage) @@ -754,7 +790,9 @@ func runHardCheckNode( } intentPassLocked, intentReasonLocked, intentUnmetLocked = evaluateIntentForJudgement(ctx, chatModel, st, emitStage) emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("修复后业务目标已锁定:pass=%t,reason=%s", intentPassLocked, truncate(intentReasonLocked, 120))) - if changed := normalizeMovableTaskOrderByOrigin(st); changed { + if changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st); skipped { + emitStage("schedule_refine.hard_check.order_normalized", "修复后跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。") + } else if changed { emitStage("schedule_refine.hard_check.order_normalized", "修复后已按 origin_rank 对坑位做顺序归位。") } report = evaluateHardChecks(ctx, chatModel, st, emitStage) @@ -795,7 +833,7 @@ func runSummaryNode( emitModelRawDebug(emitStage, "summary", raw) } if err != nil || summary == "" { - if st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed { + if FinalHardCheckPassed(st) { summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) } else { summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) @@ -803,7 +841,10 @@ func runSummaryNode( } summary = alignSummaryWithHardCheck(st, summary) st.FinalSummary = summary - st.Completed = true + // 1. Completed 只代表“最终终审已通过”,不再把“链路执行完毕”误写成成功; + // 2. 这样外层持久化与展示层可以准确区分“已通过方案”与“当前最优但未达标方案”; + // 3. 若只是返回 best-effort 结果,FinalSummary 仍会保留,但 Completed=false。 + st.Completed = FinalHardCheckPassed(st) emitStage("schedule_refine.summary.done", "微调总结已生成。") return st, nil } @@ -813,8 +854,9 @@ func evaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *Sched report.PhysicsIssues = physicsCheck(st.HybridEntries, len(st.AllocatedItems)) report.PhysicsPassed = len(report.PhysicsIssues) == 0 // 1. 顺序校验默认开启:即便执行期放开顺序限制,终审也要验证“后端归位”后的顺序正确性。 - // 2. 当 origin_order_map 为空时降级跳过,避免无基线时误报。 - needOrderCheck := len(st.OriginOrderMap) > 0 + // 2. 但 MinContextSwitch 成功后,重排后的顺序本身就是业务目标,不能再拿 origin_rank 反向判错。 + // 3. 当 origin_order_map 为空时同样降级跳过,避免无基线时误报。 + needOrderCheck := len(st.OriginOrderMap) > 0 && !shouldSkipOrderConstraintCheck(st) report.OrderIssues = validateRelativeOrder(st.HybridEntries, refineToolPolicy{ KeepRelativeOrder: needOrderCheck, OrderScope: st.Contract.OrderScope, @@ -1305,6 +1347,34 @@ func normalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) bool { return true } +// tryNormalizeMovableTaskOrderByOrigin 决定是否执行“按 origin_rank 顺序归位”。 +// +// 步骤化说明: +// 1. 默认仍保持旧行为,继续在终审前做展示侧顺序归位; +// 2. 但当 MinContextSwitch 已成功执行时,重排后的顺序本身就是业务目标的一部分; +// 3. 此时若再按 origin_rank 归位,会把复合工具效果直接抹掉,因此必须跳过。 +func tryNormalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) (changed bool, skipped bool) { + if shouldSkipOriginOrderNormalization(st) { + return false, true + } + return normalizeMovableTaskOrderByOrigin(st), false +} + +func shouldSkipOriginOrderNormalization(st *ScheduleRefineState) bool { + if st == nil { + return false + } + ensureCompositeStateMaps(st) + if st.CompositeToolSuccess["MinContextSwitch"] { + return true + } + return false +} + +func shouldSkipOrderConstraintCheck(st *ScheduleRefineState) bool { + return shouldSkipOriginOrderNormalization(st) +} + func runSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) error { if st == nil { return fmt.Errorf("nil state") @@ -2047,7 +2117,7 @@ func alignSummaryWithHardCheck(st *ScheduleRefineState, summary string) string { if st == nil { return clean } - passed := st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed + passed := FinalHardCheckPassed(st) if passed { if st.RoundUsed == 0 { return "本轮未执行调度动作(0轮),当前排程已满足终审条件。" diff --git a/backend/agent/schedulerefine/refine_filters_test.go b/backend/agent/schedulerefine/refine_filters_test.go index e637a32..1f63867 100644 --- a/backend/agent/schedulerefine/refine_filters_test.go +++ b/backend/agent/schedulerefine/refine_filters_test.go @@ -507,6 +507,70 @@ func TestNormalizeMovableTaskOrderByOrigin(t *testing.T) { } } +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{}), diff --git a/backend/agent/schedulerefine/state.go b/backend/agent/schedulerefine/state.go index d249204..604520b 100644 --- a/backend/agent/schedulerefine/state.go +++ b/backend/agent/schedulerefine/state.go @@ -167,7 +167,12 @@ type ScheduleRefineState struct { DisableCompositeTools bool // CompositeRouteTried 标记是否尝试过“复合批处理路由”。 CompositeRouteTried bool - // CompositeRouteSucceeded 标记复合批处理路由是否成功收口。 + // CompositeRouteSucceeded 标记复合批处理路由是否已完成“复合分支出站”。 + // + // 说明: + // 1. true 表示当前链路可以跳过 ReAct 兜底,直接进入 hard_check; + // 2. 它不等价于“终审已通过”,终审是否通过仍以后续 HardCheck 结果为准; + // 3. 这样区分是为了避免“复合工具已成功执行,但业务目标要等终审裁决”时被误判为失败。 CompositeRouteSucceeded bool TaskActionUsed map[int]int EntriesVersion int @@ -357,3 +362,16 @@ func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int { } 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 +} diff --git a/backend/agent/schedulerefine/tool.go b/backend/agent/schedulerefine/tool.go index 484f62f..21f3e79 100644 --- a/backend/agent/schedulerefine/tool.go +++ b/backend/agent/schedulerefine/tool.go @@ -422,11 +422,38 @@ func refineToolSpreadEven(entries []model.HybridScheduleEntry, params map[string // refineToolMinContextSwitch 执行“最少上下文切换”复合动作。 // // 职责边界: -// 1. 负责参数解析、候选收集、调用确定性规划器; -// 2. 不直接改写 entries,统一通过 BatchMove 原子落地; -// 3. 规划算法实现位于 logic 包,工具层只负责编排。 +// 1. 负责锁定“当前任务已占坑位集合”,避免为了聚类把任务远距离迁移; +// 2. 负责在固定坑位集合内调用确定性规划器,只重排“任务 -> 坑位”的映射; +// 3. 不直接改写 entries,统一通过 BatchMove 原子落地。 func refineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { - return refineToolCompositeMove(entries, params, window, policy, "MinContextSwitch", logic.PlanMinContextSwitchMoves) + taskIDs := collectCompositeTaskIDs(params) + if len(taskIDs) == 0 { + return entries, reactToolResult{ + Tool: "MinContextSwitch", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", + } + } + tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, "MinContextSwitch") + if !ok { + return entries, taskResult + } + + // 1. MinContextSwitch 的产品语义是“尽量少切换,同时尽量少折腾坑位”; + // 2. 因此这里不再查询整周新坑位,而是直接复用当前任务已占据的坑位集合; + // 3. 这样最终只会发生“任务之间互换位置”,不会跳到用户意料之外的远处时段。 + currentSlots := buildCompositeCurrentTaskSlots(tasks) + plannedMoves, planErr := logic.PlanMinContextSwitchMoves(tasks, currentSlots, logic.RefineCompositePlanOptions{}) + if planErr != nil { + return entries, reactToolResult{ + Tool: "MinContextSwitch", + Success: false, + ErrorCode: "PLAN_FAILED", + Result: planErr.Error(), + } + } + return applyFixedSlotCompositeMoves(entries, policy, "MinContextSwitch", plannedMoves) } // refineToolCompositeMove 是复合动作工具的统一执行框架。 @@ -453,56 +480,12 @@ func refineToolCompositeMove( Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", } } + tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, toolName) + if !ok { + return entries, taskResult + } idSet := intSliceToIDSet(taskIDs) - - // 1. 先筛选任务候选,并校验 task_item_id 是否全部可定位。 - // 2. 只允许可移动 suggested 任务参与,避免误改 existing/course 条目。 - tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs)) - found := make(map[int]struct{}, len(taskIDs)) - spanNeed := make(map[int]int) - for _, entry := range entries { - if !isMovableSuggestedTask(entry) { - continue - } - if _, ok := idSet[entry.TaskItemID]; !ok { - continue - } - if _, duplicated := found[entry.TaskItemID]; duplicated { - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_ID_AMBIGUOUS", - Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID), - } - } - found[entry.TaskItemID] = struct{}{} - task := logic.RefineTaskCandidate{ - TaskItemID: entry.TaskItemID, - Week: entry.Week, - DayOfWeek: entry.DayOfWeek, - SectionFrom: entry.SectionFrom, - SectionTo: entry.SectionTo, - Name: strings.TrimSpace(entry.Name), - ContextTag: strings.TrimSpace(entry.ContextTag), - OriginRank: policy.OriginOrderMap[entry.TaskItemID], - } - tasks = append(tasks, task) - spanNeed[entry.SectionTo-entry.SectionFrom+1]++ - } - if len(tasks) != len(taskIDs) { - missing := make([]int, 0, len(taskIDs)) - for _, id := range taskIDs { - if _, ok := found[id]; !ok { - missing = append(missing, id) - } - } - return entries, reactToolResult{ - Tool: toolName, - Success: false, - ErrorCode: "TASK_NOT_FOUND", - Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing), - } - } + spanNeed := buildCompositeSpanNeed(tasks) slots, slotErr := collectCompositeSlotsBySpan(entries, params, window, spanNeed) if slotErr != nil { @@ -525,6 +508,92 @@ func refineToolCompositeMove( Result: planErr.Error(), } } + return applyCompositePlannedMoves(entries, params, window, policy, toolName, plannedMoves) +} + +// collectCompositeTasks 收集复合动作参与的可移动任务,并做唯一性校验。 +// +// 步骤化说明: +// 1. 只收 suggested 且可移动的 task,避免误改 existing/course; +// 2. task_item_id 必须一一命中,命中多条或缺失都直接失败; +// 3. 输出顺序保持 entries 原始遍历顺序,后续再由规划器做稳定排序。 +func collectCompositeTasks(entries []model.HybridScheduleEntry, taskIDs []int, policy refineToolPolicy, toolName string) ([]logic.RefineTaskCandidate, reactToolResult, bool) { + idSet := intSliceToIDSet(taskIDs) + tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs)) + found := make(map[int]struct{}, len(taskIDs)) + for _, entry := range entries { + if !isMovableSuggestedTask(entry) { + continue + } + if _, ok := idSet[entry.TaskItemID]; !ok { + continue + } + if _, duplicated := found[entry.TaskItemID]; duplicated { + return nil, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_ID_AMBIGUOUS", + Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID), + }, false + } + found[entry.TaskItemID] = struct{}{} + tasks = append(tasks, logic.RefineTaskCandidate{ + TaskItemID: entry.TaskItemID, + Week: entry.Week, + DayOfWeek: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + SectionTo: entry.SectionTo, + Name: strings.TrimSpace(entry.Name), + ContextTag: strings.TrimSpace(entry.ContextTag), + OriginRank: policy.OriginOrderMap[entry.TaskItemID], + }) + } + if len(tasks) != len(taskIDs) { + missing := make([]int, 0, len(taskIDs)) + for _, id := range taskIDs { + if _, ok := found[id]; !ok { + missing = append(missing, id) + } + } + return nil, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_NOT_FOUND", + Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing), + }, false + } + return tasks, reactToolResult{}, true +} + +func buildCompositeSpanNeed(tasks []logic.RefineTaskCandidate) map[int]int { + spanNeed := make(map[int]int, len(tasks)) + for _, task := range tasks { + spanNeed[task.SectionTo-task.SectionFrom+1]++ + } + return spanNeed +} + +func buildCompositeCurrentTaskSlots(tasks []logic.RefineTaskCandidate) []logic.RefineSlotCandidate { + slots := make([]logic.RefineSlotCandidate, 0, len(tasks)) + for _, task := range tasks { + slots = append(slots, logic.RefineSlotCandidate{ + Week: task.Week, + DayOfWeek: task.DayOfWeek, + SectionFrom: task.SectionFrom, + SectionTo: task.SectionTo, + }) + } + return slots +} + +func applyCompositePlannedMoves( + entries []model.HybridScheduleEntry, + params map[string]any, + window planningWindow, + policy refineToolPolicy, + toolName string, + plannedMoves []logic.RefineMovePlanItem, +) ([]model.HybridScheduleEntry, reactToolResult) { if len(plannedMoves) == 0 { return entries, reactToolResult{ Tool: toolName, @@ -564,6 +633,89 @@ func refineToolCompositeMove( } } +// applyFixedSlotCompositeMoves 以“同时改写坐标”的方式提交固定坑位重排结果。 +// +// 步骤化说明: +// 1. 该函数专门服务“坑位集合固定”的复合工具,避免 BatchMove 顺序执行时出现互相占位冲突; +// 2. 先在副本上一次性改写所有目标任务的坐标,再统一排序与校验; +// 3. 若发现目标坑位重复、任务缺失、或顺序约束不满足,则整批失败并回滚。 +func applyFixedSlotCompositeMoves( + entries []model.HybridScheduleEntry, + policy refineToolPolicy, + toolName string, + plannedMoves []logic.RefineMovePlanItem, +) ([]model.HybridScheduleEntry, reactToolResult) { + if len(plannedMoves) == 0 { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PLAN_EMPTY", + Result: "规划结果为空:未生成任何可执行移动", + } + } + + working := cloneHybridEntries(entries) + indexByTaskID := make(map[int]int, len(working)) + for idx, entry := range working { + if !isMovableSuggestedTask(entry) { + continue + } + if _, exists := indexByTaskID[entry.TaskItemID]; exists { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_ID_AMBIGUOUS", + Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 命中多条可移动 suggested 任务", toolName, entry.TaskItemID), + } + } + indexByTaskID[entry.TaskItemID] = idx + } + + targetSeen := make(map[string]int, len(plannedMoves)) + for _, move := range plannedMoves { + if _, ok := indexByTaskID[move.TaskItemID]; !ok { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "TASK_NOT_FOUND", + Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 未找到可移动 suggested 任务", toolName, move.TaskItemID), + } + } + key := fmt.Sprintf("%d-%d-%d-%d", move.ToWeek, move.ToDay, move.ToSectionFrom, move.ToSectionTo) + if prevID, exists := targetSeen[key]; exists { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PLAN_CONFLICT", + Result: fmt.Sprintf("%s 执行失败:任务 id=%d 与 id=%d 目标坑位重复", toolName, prevID, move.TaskItemID), + } + } + targetSeen[key] = move.TaskItemID + } + + for _, move := range plannedMoves { + idx := indexByTaskID[move.TaskItemID] + working[idx].Week = move.ToWeek + working[idx].DayOfWeek = move.ToDay + working[idx].SectionFrom = move.ToSectionFrom + working[idx].SectionTo = move.ToSectionTo + } + sortHybridEntries(working) + if issues := validateRelativeOrder(working, policy); len(issues) > 0 { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "ORDER_CONSTRAINT_VIOLATED", + Result: "顺序约束不满足:" + strings.Join(issues, ";"), + } + } + return working, reactToolResult{ + Tool: toolName, + Success: true, + Result: fmt.Sprintf("%s 执行成功:已在固定坑位集合内重排 %d 条任务。", toolName, len(plannedMoves)), + } +} + func collectCompositeTaskIDs(params map[string]any) []int { ids := readIntSlice(params, "task_item_ids", "task_ids") if id, ok := paramIntAny(params, "task_item_id", "task_id"); ok { @@ -645,9 +797,12 @@ func buildCompositeSlotQueryParams(params map[string]any, span int, required int } } - copyIntSliceParam(params, query, "week_filter", "weeks") - copyIntSliceParam(params, query, "day_of_week", "days", "day_filter") - copyIntSliceParam(params, query, "exclude_sections", "exclude_section") + // 1. 复合路由主链路自身使用的是 week_filter/day_of_week/exclude_sections; + // 2. 这里必须优先透传这些“规范键”,再兼容历史别名; + // 3. 否则会出现复合工具已被调用,但内部查坑位时丢失目标范围,导致规划结果漂移。 + copyIntSliceParam(params, query, "week_filter", "week_filter", "weeks") + copyIntSliceParam(params, query, "day_of_week", "day_of_week", "days", "day_filter") + copyIntSliceParam(params, query, "exclude_sections", "exclude_sections", "exclude_section") // 兼容 Move 风格别名,降低模型参数名漂移导致的失败。 if week, ok := paramIntAny(params, "to_week", "target_week", "new_week"); ok { diff --git a/backend/agent2/chat/prompt.go b/backend/agent2/chat/prompt.go new file mode 100644 index 0000000..d8a376c --- /dev/null +++ b/backend/agent2/chat/prompt.go @@ -0,0 +1,8 @@ +package agentchat + +const ( + // SystemPrompt 全局系统人设:定义 SmartFlow 的基本调性 + SystemPrompt = `你叫 SmartFlow,是专为重邮(CQUPT)学子打造的智能排程专家。 +你的回复应当专业、干练,偶尔可以带一点程序员式的冷幽默。 +重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。` +) diff --git a/backend/agent2/chat/stream.go b/backend/agent2/chat/stream.go new file mode 100644 index 0000000..beef18e --- /dev/null +++ b/backend/agent2/chat/stream.go @@ -0,0 +1,131 @@ +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, +) (string, *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 "", 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 + /*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 + for { + chunk, err := reader.Recv() + if err == io.EOF { + break + } + if err != nil { + return "", nil, err + } + + // 优先记录模型真实 usage(通常在尾块返回,部分模型也可能中途返回)。 + if chunk != nil && chunk.ResponseMeta != nil && chunk.ResponseMeta.Usage != nil { + tokenUsage = agentllm.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage) + } + + fullText.WriteString(chunk.Content) + + payload, err := agentstream.ToOpenAIStream(chunk, requestID, modelName, created, firstChunk) + if err != nil { + return "", 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 "", 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(), + )*/ + + return fullText.String(), tokenUsage, nil +} diff --git a/backend/agent2/entrance.go b/backend/agent2/entrance.go new file mode 100644 index 0000000..e410c82 --- /dev/null +++ b/backend/agent2/entrance.go @@ -0,0 +1,41 @@ +package agent2 + +import ( + "context" + "errors" + + agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router" +) + +// Service 是 agent2 模块的总入口。 +// +// 职责边界: +// 1. 负责接住一次完整的 Agent 请求,并把请求交给统一路由器分流; +// 2. 负责维护“路由器 + 各 skill handler”的装配关系; +// 3. 不负责具体 skill 的 graph 连线,也不负责节点内部业务实现。 +type Service struct { + dispatcher *agentrouter.Dispatcher +} + +// NewService 创建 agent2 总入口服务。 +func NewService(resolver agentrouter.Resolver) *Service { + return &Service{ + dispatcher: agentrouter.NewDispatcher(resolver), + } +} + +// 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 s.dispatcher.Register(action, handler) +} + +// Handle 是 agent2 的统一处理入口。 +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 s.dispatcher.Dispatch(ctx, req) +} diff --git a/backend/agent2/graph/quicknote.go b/backend/agent2/graph/quicknote.go new file mode 100644 index 0000000..ee5b03b --- /dev/null +++ b/backend/agent2/graph/quicknote.go @@ -0,0 +1,149 @@ +package agentgraph + +import ( + "context" + "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" + "github.com/cloudwego/eino/compose" +) + +const ( + // QuickNoteGraphName 是“随口记”图编排的稳定标识。 + // 保留这个名字的目的: + // 1. 让 compile 后的 graph 名称在日志、调试、可视化工具里有固定口径; + // 2. 后续如果接入更多技能图,可以统一按技能名识别。 + QuickNoteGraphName = "quick_note" +) + +// RunQuickNoteGraph 执行“随口记”图编排。 +// +// 职责边界: +// 1. 这里只负责 graph 连线与运行时装配,不负责节点内部业务细节; +// 2. graph 层只挂 node 层对外暴露的方法,不再维护额外 runner 适配层; +// 3. 工具注册、时间基准补齐、compile 参数收口都在这里统一完成。 +func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInput) (*agentmodel.QuickNoteState, error) { + // 1. 启动前先做硬校验。 + // 1.1 model 为空时无法调模型,直接失败; + // 1.2 state 为空时图无法承载共享上下文,也必须直接拦截; + // 1.3 tool deps 不完整时,后续 persist 节点必然失败,因此这里提前收口。 + 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. 统一补齐本次请求的时间基准。 + // 2.1 RequestNow 只在整条 quicknote 链路入口确定一次,避免同一次请求里相对时间口径漂移; + // 2.2 RequestNowText 是 prompt 注入用文本,缺失时也在这里统一补齐。 + if input.State.RequestNow.IsZero() { + input.State.RequestNow = agentshared.NowToMinute() + } + if strings.TrimSpace(input.State.RequestNowText) == "" { + input.State.RequestNowText = agentshared.FormatMinute(input.State.RequestNow) + } + + // 3. 构建工具包并提取“创建任务”工具。 + // 3.1 graph 层只关心“拿到一个可执行工具”,不关心工具内部如何注册; + // 3.2 失败时直接返回,避免把半残依赖继续交给 node 层。 + toolBundle, err := agentnode.BuildQuickNoteToolBundle(ctx, input.Deps) + if err != nil { + return nil, err + } + createTaskTool, err := agentnode.GetInvokableToolByName(toolBundle, agentnode.ToolNameQuickNoteCreateTask) + if err != nil { + return nil, err + } + + // 4. 在 node 层创建节点容器。 + // 4.1 这一步就是“请求级依赖注入”的唯一收口点; + // 4.2 graph 后续只认 `nodes.Intent / nodes.Priority / nodes.Persist` 这些方法,不再额外造 runner。 + nodes, err := agentnode.NewQuickNoteNodes(input, createTaskTool) + if err != nil { + return nil, err + } + + // 5. 创建状态图容器,输入输出统一都是 *QuickNoteState。 + graph := compose.NewGraph[*agentmodel.QuickNoteState, *agentmodel.QuickNoteState]() + + // 6. 注册节点。 + if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeIntent, compose.InvokableLambda(nodes.Intent)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeRank, compose.InvokableLambda(nodes.Priority)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodePersist, compose.InvokableLambda(nodes.Persist)); err != nil { + return nil, err + } + if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeExit, compose.InvokableLambda(nodes.Exit)); err != nil { + return nil, err + } + + // 7. 所有请求统一从 intent 节点开始。 + if err = graph.AddEdge(compose.START, agentnode.QuickNoteGraphNodeIntent); err != nil { + return nil, err + } + + // 8. intent 后分支: + // 8.1 命中随口记且时间合法 -> priority; + // 8.2 非随口记,或时间校验失败 -> exit。 + if err = graph.AddBranch(agentnode.QuickNoteGraphNodeIntent, compose.NewGraphBranch( + nodes.NextAfterIntent, + map[string]bool{ + agentnode.QuickNoteGraphNodeRank: true, + agentnode.QuickNoteGraphNodeExit: true, + }, + )); err != nil { + return nil, err + } + + // 9. 显式 exit 节点仍然保留。 + // 这样后续若要统一加日志、埋点、收尾逻辑,不需要再改 branch 结构。 + if err = graph.AddEdge(agentnode.QuickNoteGraphNodeExit, compose.END); err != nil { + return nil, err + } + + // 10. priority 后固定进入 persist。 + if err = graph.AddEdge(agentnode.QuickNoteGraphNodeRank, agentnode.QuickNoteGraphNodePersist); err != nil { + return nil, err + } + + // 11. persist 后分支: + // 11.1 已成功写入 -> END; + // 11.2 仍可重试 -> 回到 persist; + // 11.3 重试耗尽 -> END,由 state 中的失败文案兜底。 + if err = graph.AddBranch(agentnode.QuickNoteGraphNodePersist, compose.NewGraphBranch( + nodes.NextAfterPersist, + map[string]bool{ + agentnode.QuickNoteGraphNodePersist: true, + compose.END: true, + }, + )); err != nil { + return nil, err + } + + // 12. 为 persist 重试预留运行步数余量,避免异常状态把图跑成死循环。 + maxSteps := input.State.MaxToolRetry + 10 + if maxSteps < 12 { + maxSteps = 12 + } + + // 13. 编译并执行图。 + runnable, err := graph.Compile(ctx, + compose.WithGraphName(QuickNoteGraphName), + compose.WithMaxRunSteps(maxSteps), + compose.WithNodeTriggerMode(compose.AnyPredecessor), + ) + if err != nil { + return nil, err + } + return runnable.Invoke(ctx, input.State) +} diff --git a/backend/agent2/graph/schedule.go b/backend/agent2/graph/schedule.go new file mode 100644 index 0000000..bd5a78c --- /dev/null +++ b/backend/agent2/graph/schedule.go @@ -0,0 +1,35 @@ +package agentgraph + +import agentnode "github.com/LoveLosita/smartflow/backend/agent2/node" + +const ( + SchedulePlanGraphName = "schedule_plan" + ScheduleRefineGraphName = "schedule_refine" + + ScheduleNodeIntentRoute = "schedule.intent.route" + ScheduleNodePlan = "schedule.plan" + ScheduleNodeRoughBuild = "schedule.rough_build" + ScheduleNodeReact = "schedule.react" + ScheduleNodeHardCheck = "schedule.hard_check" + ScheduleNodeReply = "schedule.reply" +) + +// SchedulePlanGraph 是“首次排程”图编排骨架。 +type SchedulePlanGraph struct { + Nodes *agentnode.SchedulePlanNodes +} + +// NewSchedulePlanGraph 创建首次排程图骨架。 +func NewSchedulePlanGraph(nodes *agentnode.SchedulePlanNodes) *SchedulePlanGraph { + return &SchedulePlanGraph{Nodes: nodes} +} + +// ScheduleRefineGraph 是“连续微调排程”图编排骨架。 +type ScheduleRefineGraph struct { + Nodes *agentnode.ScheduleRefineNodes +} + +// NewScheduleRefineGraph 创建连续微调图骨架。 +func NewScheduleRefineGraph(nodes *agentnode.ScheduleRefineNodes) *ScheduleRefineGraph { + return &ScheduleRefineGraph{Nodes: nodes} +} diff --git a/backend/agent2/graph/taskquery.go b/backend/agent2/graph/taskquery.go new file mode 100644 index 0000000..67067d2 --- /dev/null +++ b/backend/agent2/graph/taskquery.go @@ -0,0 +1,22 @@ +package agentgraph + +import agentnode "github.com/LoveLosita/smartflow/backend/agent2/node" + +const ( + TaskQueryGraphName = "task_query" + + TaskQueryNodePlan = "task_query.plan" + TaskQueryNodeTool = "task_query.tool.query" + TaskQueryNodeReflect = "task_query.reflect" + TaskQueryNodeReply = "task_query.reply" +) + +// TaskQueryGraph 是“随口问任务”图编排骨架。 +type TaskQueryGraph struct { + Nodes *agentnode.TaskQueryNodes +} + +// NewTaskQueryGraph 创建任务查询图骨架。 +func NewTaskQueryGraph(nodes *agentnode.TaskQueryNodes) *TaskQueryGraph { + return &TaskQueryGraph{Nodes: nodes} +} diff --git a/backend/agent2/llm/ark.go b/backend/agent2/llm/ark.go new file mode 100644 index 0000000..5db0cc5 --- /dev/null +++ b/backend/agent2/llm/ark.go @@ -0,0 +1,83 @@ +package agentllm + +import ( + "context" + "errors" + "strings" + + "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" +) + +// ArkCallOptions 是基于 ark.ChatModel 的通用调用选项。 +// +// 设计目的: +// 1. 当前 route / quicknote 都还直接持有 *ark.ChatModel; +// 2. 在它们完全收敛到更抽象的 Client 前,先把重复的 ark 调用样板抽成公共层; +// 3. 这样本轮就能先删除 route/quicknote 里那几份重复的 Generate 样板代码。 +type ArkCallOptions struct { + Temperature float64 + MaxTokens int + Thinking ThinkingMode +} + +// CallArkText 调用 ark 模型并返回纯文本。 +// +// 职责边界: +// 1. 负责拼 system + user 两段消息; +// 2. 负责统一配置 thinking / temperature / maxTokens; +// 3. 负责拦截空响应; +// 4. 不负责 JSON 解析,不负责业务字段校验。 +func CallArkText(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (string, error) { + if chatModel == nil { + return "", errors.New("ark model is nil") + } + + messages := []*schema.Message{ + schema.SystemMessage(systemPrompt), + schema.UserMessage(userPrompt), + } + resp, err := chatModel.Generate(ctx, messages, buildArkOptions(options)...) + if err != nil { + return "", err + } + if resp == nil { + return "", errors.New("模型返回为空") + } + + text := strings.TrimSpace(resp.Content) + if text == "" { + return "", errors.New("模型返回内容为空") + } + return text, nil +} + +// CallArkJSON 调用 ark 模型并直接解析 JSON。 +func CallArkJSON[T any](ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (*T, string, error) { + raw, err := CallArkText(ctx, chatModel, systemPrompt, userPrompt, options) + if err != nil { + return nil, "", err + } + parsed, err := ParseJSONObject[T](raw) + if err != nil { + return nil, raw, err + } + return parsed, raw, nil +} + +func buildArkOptions(options ArkCallOptions) []einoModel.Option { + thinkingType := arkModel.ThinkingTypeDisabled + if options.Thinking == ThinkingModeEnabled { + thinkingType = arkModel.ThinkingTypeEnabled + } + opts := []einoModel.Option{ + ark.WithThinking(&arkModel.Thinking{Type: thinkingType}), + einoModel.WithTemperature(float32(options.Temperature)), + } + if options.MaxTokens > 0 { + opts = append(opts, einoModel.WithMaxTokens(options.MaxTokens)) + } + return opts +} diff --git a/backend/agent2/llm/client.go b/backend/agent2/llm/client.go new file mode 100644 index 0000000..eccb059 --- /dev/null +++ b/backend/agent2/llm/client.go @@ -0,0 +1,216 @@ +package agentllm + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/cloudwego/eino/schema" +) + +// ThinkingMode 描述本次模型调用对 thinking 的期望。 +// +// 职责边界: +// 1. 这里只表达“调用方希望怎样配置推理模式”; +// 2. 不直接绑定某个具体模型厂商的参数枚举; +// 3. 真正如何把它翻译成 ark / OpenAI / 其他 provider 的 option,由后续适配层负责。 +type ThinkingMode string + +const ( + ThinkingModeDefault ThinkingMode = "default" + ThinkingModeEnabled ThinkingMode = "enabled" + ThinkingModeDisabled ThinkingMode = "disabled" +) + +// GenerateOptions 是 agent2 内部统一的模型调用选项。 +// +// 设计目的: +// 1. 先把“每个 skill 都会反复传的参数”收敛成一份结构; +// 2. 让 node 层以后只表达“我要什么”,不再自己重复组织 option; +// 3. 暂时不追求覆盖所有 provider 参数,先把最常用的几个公共位抽出来。 +type GenerateOptions struct { + Temperature float64 + MaxTokens int + Thinking ThinkingMode + Metadata map[string]any +} + +// TextResult 是统一文本生成结果。 +// +// 职责边界: +// 1. Text 保存模型最终返回的纯文本; +// 2. Usage 保存本次调用的 token 使用量,供后续统一统计; +// 3. 不负责 JSON 解析,不负责业务字段映射。 +type TextResult struct { + Text string + Usage *schema.TokenUsage +} + +// StreamReader 抽象了“可逐块 Recv 的流式返回器”。 +// +// 之所以不直接依赖某个具体 SDK 的 reader 类型,是因为 agent2 现在还在建骨架阶段, +// 后续接 ark、OpenAI 兼容层还是别的 provider,都可以往这个最小接口上适配。 +type StreamReader interface { + Recv() (*schema.Message, error) + Close() error +} + +// TextGenerateFunc 是文本生成的统一适配函数签名。 +type TextGenerateFunc func(ctx context.Context, messages []*schema.Message, options GenerateOptions) (*TextResult, error) + +// StreamGenerateFunc 是流式生成的统一适配函数签名。 +type StreamGenerateFunc func(ctx context.Context, messages []*schema.Message, options GenerateOptions) (StreamReader, error) + +// Client 是 agent2 里的统一模型客户端门面。 +// +// 职责边界: +// 1. 负责把 node 层的“模型调用意图”收敛到统一入口; +// 2. 负责统一参数校验、空响应防御、GenerateJSON 复用; +// 3. 不负责写 prompt,不负责业务 fallback,也不直接持有具体厂商 SDK 细节。 +type Client struct { + generateText TextGenerateFunc + streamText StreamGenerateFunc +} + +// NewClient 创建统一模型客户端。 +func NewClient(generateText TextGenerateFunc, streamText StreamGenerateFunc) *Client { + return &Client{ + generateText: generateText, + streamText: streamText, + } +} + +// GenerateText 执行一次统一文本生成。 +// +// 职责边界: +// 1. 负责做最小必要的入参校验; +// 2. 负责统一拦截“模型空响应”这类公共问题; +// 3. 不负责业务 prompt 拼接,也不负责把文本再映射成业务结构。 +func (c *Client) GenerateText(ctx context.Context, messages []*schema.Message, options GenerateOptions) (*TextResult, error) { + if c == nil || c.generateText == nil { + return nil, errors.New("agent llm client is not ready") + } + if len(messages) == 0 { + return nil, errors.New("llm messages is empty") + } + + result, err := c.generateText(ctx, messages, options) + if err != nil { + return nil, err + } + if result == nil { + return nil, errors.New("llm result is nil") + } + if strings.TrimSpace(result.Text) == "" { + return nil, errors.New("llm returned empty text") + } + return result, nil +} + +// GenerateJSON 先走统一文本生成,再走统一 JSON 解析。 +// +// 设计说明: +// 1. 旧 agent 里每个 skill 都各自写了一份“Generate -> 提取 JSON -> 反序列化”; +// 2. 这里先把这一整段收敛成公共链路,后续 quicknote/taskquery/schedule 都直接复用; +// 3. 返回 parsed + rawResult,方便上层既能拿结构化字段,也能在打点/回退时保留原文。 +// 4. 这里做成泛型函数而不是方法,是因为 Go 不支持“方法自带类型参数”。 +func GenerateJSON[T any](ctx context.Context, client *Client, messages []*schema.Message, options GenerateOptions) (*T, *TextResult, error) { + result, err := client.GenerateText(ctx, messages, options) + if err != nil { + return nil, nil, err + } + + parsed, err := ParseJSONObject[T](result.Text) + if err != nil { + return nil, result, err + } + return parsed, result, nil +} + +// Stream 打开统一流式调用入口。 +// +// 职责边界: +// 1. 只负责把“流式生成能力”暴露给上层; +// 2. 不负责 chunk 到 OpenAI 协议的转换,那部分应放在 stream/; +// 3. 不负责累计全文,也不负责 token 统计落库。 +func (c *Client) Stream(ctx context.Context, messages []*schema.Message, options GenerateOptions) (StreamReader, error) { + if c == nil || c.streamText == nil { + return nil, errors.New("agent llm stream client is not ready") + } + if len(messages) == 0 { + return nil, errors.New("llm messages is empty") + } + return c.streamText(ctx, messages, options) +} + +// BuildSystemUserMessages 构造最常见的“system + history + user”消息列表。 +// +// 设计说明: +// 1. 这是旧 agent 中高频重复片段,几乎每个 skill 都会拼一次; +// 2. 这里先把最稳定的消息编排方式沉淀下来,减少 node 层样板代码; +// 3. 只做消息切片装配,不做 prompt 生成。 +func BuildSystemUserMessages(systemPrompt string, history []*schema.Message, userPrompt string) []*schema.Message { + messages := make([]*schema.Message, 0, len(history)+2) + if strings.TrimSpace(systemPrompt) != "" { + messages = append(messages, schema.SystemMessage(systemPrompt)) + } + if len(history) > 0 { + messages = append(messages, history...) + } + if strings.TrimSpace(userPrompt) != "" { + messages = append(messages, schema.UserMessage(userPrompt)) + } + return messages +} + +// CloneUsage 深拷贝 token usage,避免后续多处累加时共享同一指针。 +func CloneUsage(usage *schema.TokenUsage) *schema.TokenUsage { + if usage == nil { + return nil + } + copied := *usage + return &copied +} + +// MergeUsage 合并两段 usage。 +// +// 合并策略: +// 1. 对“同一次调用不同流分片”的场景,取更大值作为最终值; +// 2. 对“多次独立调用累计”的场景,应由上层显式做加法,而不是用这个函数; +// 3. 该函数只适用于“同一次调用的分块 usage 收敛”。 +func MergeUsage(base *schema.TokenUsage, incoming *schema.TokenUsage) *schema.TokenUsage { + if incoming == nil { + return CloneUsage(base) + } + if base == nil { + return CloneUsage(incoming) + } + + 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 +} + +// FormatEmptyResponseError 统一生成“模型返回空结果”的错误文案。 +func FormatEmptyResponseError(scene string) error { + scene = strings.TrimSpace(scene) + if scene == "" { + scene = "unknown" + } + return fmt.Errorf("模型在 %s 场景返回空结果", scene) +} diff --git a/backend/agent2/llm/json.go b/backend/agent2/llm/json.go new file mode 100644 index 0000000..1ca6926 --- /dev/null +++ b/backend/agent2/llm/json.go @@ -0,0 +1,112 @@ +package agentllm + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// ParseJSONObject 解析模型返回中的 JSON 对象。 +// +// 职责边界: +// 1. 负责处理“模型输出前后夹杂解释文字 / markdown 代码块”的常见情况; +// 2. 负责提取最外层 JSON object 并反序列化为目标结构; +// 3. 不负责业务字段合法性校验,例如 priority 是否在 1~4,应由上层 node 再校验。 +func ParseJSONObject[T any](raw string) (*T, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return nil, errors.New("模型返回为空,无法解析 JSON") + } + + objectText := ExtractJSONObject(clean) + if objectText == "" { + return nil, fmt.Errorf("模型返回中未找到 JSON 对象: %s", truncateForError(clean)) + } + + var out T + if err := json.Unmarshal([]byte(objectText), &out); err != nil { + return nil, fmt.Errorf("JSON 解析失败: %w", err) + } + return &out, nil +} + +// ExtractJSONObject 从混合文本里提取第一个完整 JSON 对象。 +// +// 设计说明: +// 1. LLM 很容易输出“这里是结果:{...}”这种半结构化文本; +// 2. 这里用括号计数而不是正则,避免嵌套对象一多就误截断; +// 3. 目前只提取 object,不提取 array,因为当前 agent 的路由/规划契约基本都是对象。 +func ExtractJSONObject(text string) string { + clean := trimMarkdownCodeFence(strings.TrimSpace(text)) + if clean == "" { + return "" + } + + start := strings.Index(clean, "{") + if start < 0 { + return "" + } + + depth := 0 + inString := false + escaped := false + for idx := start; idx < len(clean); idx++ { + ch := clean[idx] + + if escaped { + escaped = false + continue + } + if ch == '\\' && inString { + escaped = true + continue + } + if ch == '"' { + inString = !inString + continue + } + if inString { + continue + } + + switch ch { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return clean[start : idx+1] + } + } + } + return "" +} + +func trimMarkdownCodeFence(text string) string { + trimmed := strings.TrimSpace(text) + if !strings.HasPrefix(trimmed, "```") { + return trimmed + } + + lines := strings.Split(trimmed, "\n") + if len(lines) == 0 { + return trimmed + } + + // 1. 去掉首行 ```json / ```; + // 2. 若末行是 ```,一并去掉; + // 3. 中间正文保持原样,避免破坏 JSON 的换行结构。 + body := lines[1:] + if len(body) > 0 && strings.TrimSpace(body[len(body)-1]) == "```" { + body = body[:len(body)-1] + } + return strings.TrimSpace(strings.Join(body, "\n")) +} + +func truncateForError(text string) string { + if len(text) <= 160 { + return text + } + return text[:160] + "..." +} diff --git a/backend/agent2/llm/quicknote.go b/backend/agent2/llm/quicknote.go new file mode 100644 index 0000000..18ff5cc --- /dev/null +++ b/backend/agent2/llm/quicknote.go @@ -0,0 +1,170 @@ +package agentllm + +import ( + "context" + "fmt" + "strings" + + agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt" + "github.com/cloudwego/eino-ext/components/model/ark" +) + +// QuickNoteIntentOutput 是“随口记意图识别”模型契约。 +type QuickNoteIntentOutput struct { + IsQuickNote bool `json:"is_quick_note"` + Title string `json:"title"` + DeadlineAt string `json:"deadline_at"` + Reason string `json:"reason"` +} + +// QuickNotePriorityOutput 是“随口记优先级评估”模型契约。 +type QuickNotePriorityOutput struct { + PriorityGroup int `json:"priority_group"` + Reason string `json:"reason"` + UrgencyThresholdAt string `json:"urgency_threshold_at"` +} + +// QuickNotePlanOutput 是“随口记单请求聚合规划”模型契约。 +type QuickNotePlanOutput 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"` +} + +// IdentifyQuickNoteIntent 调用模型识别“是否随口记”。 +func IdentifyQuickNoteIntent(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*QuickNoteIntentOutput, error) { + 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 输出空字符串。`, + nowText, + userInput, + ) + + parsed, _, err := CallArkJSON[QuickNoteIntentOutput](ctx, chatModel, agentprompt.QuickNoteIntentPrompt, prompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: 256, + Thinking: ThinkingModeDisabled, + }) + return parsed, err +} + +// PlanQuickNotePriority 调用模型评估优先级与紧急分界线。 +func PlanQuickNotePriority(ctx context.Context, chatModel *ark.ChatModel, nowText, title, userInput, deadlineClue, deadlineText string) (*QuickNotePriorityOutput, error) { + 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) 若输出非空时间,必须是绝对时间,且不晚于归一化截止时间(若有)。`, + nowText, + title, + userInput, + deadlineClue, + deadlineText, + ) + + parsed, _, err := CallArkJSON[QuickNotePriorityOutput](ctx, chatModel, agentprompt.QuickNotePriorityPrompt, prompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: 256, + Thinking: ThinkingModeDisabled, + }) + return parsed, err +} + +// PlanQuickNoteInSingleCall 一次性完成标题/时间/优先级/banter 聚合规划。 +func PlanQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*QuickNotePlanOutput, error) { + 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), + ) + + parsed, _, err := CallArkJSON[QuickNotePlanOutput](ctx, chatModel, agentprompt.QuickNotePlanPrompt, prompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: 220, + Thinking: ThinkingModeDisabled, + }) + return parsed, err +} + +// GenerateQuickNoteBanter 生成成功写入后的轻松跟进句。 +func GenerateQuickNoteBanter(ctx context.Context, chatModel *ark.ChatModel, userMessage, title, priorityText, deadlineText string) (string, error) { + if chatModel == nil { + return "", fmt.Errorf("model is nil") + } + + prompt := fmt.Sprintf(`用户原话:%s +已确认事实: +- 任务标题:%s +- %s +- %s + +请输出一句轻松自然的跟进话术(仅一句)。`, + strings.TrimSpace(userMessage), + strings.TrimSpace(title), + strings.TrimSpace(priorityText), + strings.TrimSpace(deadlineText), + ) + + text, err := CallArkText(ctx, chatModel, agentprompt.QuickNoteReplyBanterPrompt, prompt, ArkCallOptions{ + Temperature: 0.7, + MaxTokens: 72, + Thinking: ThinkingModeDisabled, + }) + if err != nil { + return "", err + } + + text = strings.TrimSpace(text) + text = strings.Trim(text, "\"'“”‘’") + if text == "" { + return "", fmt.Errorf("empty content") + } + if idx := strings.Index(text, "\n"); idx >= 0 { + text = strings.TrimSpace(text[:idx]) + } + return text, nil +} diff --git a/backend/agent2/llm/route.go b/backend/agent2/llm/route.go new file mode 100644 index 0000000..f24d61d --- /dev/null +++ b/backend/agent2/llm/route.go @@ -0,0 +1,50 @@ +package agentllm + +import ( + "strings" + + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" +) + +// RouteDecisionOutput 是一级路由模型的结构化输出契约。 +// +// 说明: +// 1. 这里只定义“模型应该吐什么 JSON”; +// 2. 真正的 prompt 归 prompt/ 管; +// 3. 真正的业务分发归 router/ 管。 +type RouteDecisionOutput struct { + Action string `json:"action"` + TrustRoute bool `json:"trust_route"` + Detail string `json:"detail"` + Confidence float64 `json:"confidence"` +} + +// ToDecision 把模型契约输出映射成 agent2 内部统一路由结果。 +func (o *RouteDecisionOutput) ToDecision() *agentmodel.RouteDecision { + if o == nil { + return &agentmodel.RouteDecision{Action: agentmodel.ActionChat} + } + + action := normalizeRouteAction(o.Action) + return &agentmodel.RouteDecision{ + Action: action, + TrustRoute: o.TrustRoute, + Detail: strings.TrimSpace(o.Detail), + Confidence: o.Confidence, + } +} + +func normalizeRouteAction(raw string) agentmodel.AgentAction { + switch strings.TrimSpace(strings.ToLower(raw)) { + case "quick_note", "quick_note_create": + return agentmodel.ActionQuickNoteCreate + case "task_query": + return agentmodel.ActionTaskQuery + case "schedule_plan", "schedule_plan_create": + return agentmodel.ActionSchedulePlanCreate + case "schedule_refine", "schedule_plan_refine": + return agentmodel.ActionSchedulePlanRefine + default: + return agentmodel.ActionChat + } +} diff --git a/backend/agent2/llm/schedule.go b/backend/agent2/llm/schedule.go new file mode 100644 index 0000000..03ae545 --- /dev/null +++ b/backend/agent2/llm/schedule.go @@ -0,0 +1,22 @@ +package agentllm + +// ScheduleIntentOutput 是智能排程一级意图识别的模型契约草案。 +type ScheduleIntentOutput struct { + Intent string `json:"intent"` + NeedRefine bool `json:"need_refine"` + AdjustmentScope string `json:"adjustment_scope"` +} + +// SchedulePlanOutput 是首次排程规划节点的模型契约草案。 +type SchedulePlanOutput struct { + Goal string `json:"goal"` + Constraints []string `json:"constraints"` + Strategy string `json:"strategy"` +} + +// ScheduleRefineOutput 是连续微调阶段的模型契约草案。 +type ScheduleRefineOutput struct { + Decision string `json:"decision"` + HardAssertions []string `json:"hard_assertions"` + NextAction string `json:"next_action"` +} diff --git a/backend/agent2/llm/taskquery.go b/backend/agent2/llm/taskquery.go new file mode 100644 index 0000000..a095ca7 --- /dev/null +++ b/backend/agent2/llm/taskquery.go @@ -0,0 +1,19 @@ +package agentllm + +// TaskQueryPlanOutput 是“随口问任务”聚合规划的模型契约草案。 +type TaskQueryPlanOutput struct { + Intent string `json:"intent"` + Quadrants []int `json:"quadrants"` + SortBy string `json:"sort_by"` + Limit int `json:"limit"` + TimeRange string `json:"time_range"` + NeedBroadening bool `json:"need_broadening"` + Keywords []string `json:"keywords"` +} + +// TaskQueryReflectOutput 是查询结果反思节点的模型契约草案。 +type TaskQueryReflectOutput struct { + Satisfied bool `json:"satisfied"` + NeedRetry bool `json:"need_retry"` + RetrySuggestion string `json:"retry_suggestion"` +} diff --git a/backend/agent2/model/common.go b/backend/agent2/model/common.go new file mode 100644 index 0000000..86a83af --- /dev/null +++ b/backend/agent2/model/common.go @@ -0,0 +1,17 @@ +package agentmodel + +// AgentRequest 是 agent2 总入口接收的统一请求结构。 +type AgentRequest struct { + UserID int + ConversationID string + UserMessage string + ModelName string + Extra map[string]any +} + +// AgentResponse 是 agent2 总入口返回的统一响应结构。 +type AgentResponse struct { + Action AgentAction + Reply string + Meta map[string]any +} diff --git a/backend/agent2/model/quicknote.go b/backend/agent2/model/quicknote.go new file mode 100644 index 0000000..e7cd7d5 --- /dev/null +++ b/backend/agent2/model/quicknote.go @@ -0,0 +1,123 @@ +package agentmodel + +import ( + "time" + + agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared" +) + +const ( + // QuickNoteDatetimeMinuteLayout 是“随口记”链路内部统一的分钟级时间格式。 + // 说明: + // 1) 用于把“当前时间基准”传给模型,避免模型在相对时间推断时出现秒级抖动。 + // 2) 用于日志和调试,读起来比 RFC3339 更直观。 + QuickNoteDatetimeMinuteLayout = "2006-01-02 15:04" + + // QuickNoteTimezoneName 是随口记链路默认业务时区。 + // 这里固定为东八区,避免容器运行在 UTC 时把“明天/今晚”解释偏移到错误日期。 + QuickNoteTimezoneName = "Asia/Shanghai" + + // QuickNotePriorityImportantUrgent 对应四象限里的“重要且紧急”。 + QuickNotePriorityImportantUrgent = 1 + // QuickNotePriorityImportantNotUrgent 对应“重要不紧急”。 + QuickNotePriorityImportantNotUrgent = 2 + // QuickNotePrioritySimpleNotImportant 对应“简单不重要”。 + QuickNotePrioritySimpleNotImportant = 3 + // QuickNotePriorityComplexNotImportant 对应“不简单不重要”。 + QuickNotePriorityComplexNotImportant = 4 +) + +// IsValidTaskPriority 判断优先级是否合法。 +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 节点间传递的统一状态容器。 +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 表示“进入紧急象限的分界时间”。 + ExtractedUrgencyThreshold *time.Time + ExtractedPriority int + // ExtractedBanter 是聚合规划阶段生成的“轻松跟进句”。 + ExtractedBanter string + // PlannedBySingleCall 标记本次是否走了“单请求聚合规划”快路径。 + PlannedBySingleCall bool + + ExtractedPriorityReason string + // DeadlineValidationError 记录时间校验失败原因。 + DeadlineValidationError string + + ToolAttemptCount int + MaxToolRetry int + LastToolError string + + PersistedTaskID int + Persisted bool + + AssistantReply string +} + +// NewQuickNoteState 创建随口记状态对象并初始化默认重试次数。 +func NewQuickNoteState(traceID string, userID int, conversationID, userInput string) *QuickNoteState { + requestNow := agentshared.NowToMinute() + return &QuickNoteState{ + TraceID: traceID, + UserID: userID, + ConversationID: conversationID, + RequestNow: requestNow, + RequestNowText: agentshared.FormatMinute(requestNow), + UserInput: userInput, + MaxToolRetry: 3, + } +} + +// CanRetryTool 判断当前是否还能继续重试工具调用。 +func (s *QuickNoteState) CanRetryTool() bool { + return s.ToolAttemptCount < s.MaxToolRetry +} + +// RecordToolError 记录一次工具调用失败。 +func (s *QuickNoteState) RecordToolError(errMsg string) { + s.ToolAttemptCount++ + s.LastToolError = errMsg +} + +// RecordToolSuccess 记录一次工具调用成功。 +func (s *QuickNoteState) RecordToolSuccess(taskID int) { + s.ToolAttemptCount++ + s.PersistedTaskID = taskID + s.Persisted = true + s.LastToolError = "" +} diff --git a/backend/agent2/model/route.go b/backend/agent2/model/route.go new file mode 100644 index 0000000..81e2d58 --- /dev/null +++ b/backend/agent2/model/route.go @@ -0,0 +1,20 @@ +package agentmodel + +// AgentAction 表示一级路由动作。 +type AgentAction string + +const ( + ActionChat AgentAction = "chat" + ActionQuickNoteCreate AgentAction = "quick_note_create" + ActionTaskQuery AgentAction = "task_query" + ActionSchedulePlanCreate AgentAction = "schedule_plan_create" + ActionSchedulePlanRefine AgentAction = "schedule_plan_refine" +) + +// RouteDecision 是统一一级分流结果。 +type RouteDecision struct { + Action AgentAction + TrustRoute bool + Detail string + Confidence float64 +} diff --git a/backend/agent2/model/schedule.go b/backend/agent2/model/schedule.go new file mode 100644 index 0000000..6b305f8 --- /dev/null +++ b/backend/agent2/model/schedule.go @@ -0,0 +1,23 @@ +package agentmodel + +// SchedulePlanState 是“首次排程”skill 的运行时状态骨架。 +type SchedulePlanState struct { + TraceID string + UserID int + ConversationID string + UserInput string + TaskClassIDs []int + UseQuickRefineOnly bool + Completed bool + FinalSummary string +} + +// ScheduleRefineState 是“连续微调排程”skill 的运行时状态骨架。 +type ScheduleRefineState struct { + TraceID string + UserID int + ConversationID string + UserInput string + Completed bool + FinalSummary string +} diff --git a/backend/agent2/model/taskquery.go b/backend/agent2/model/taskquery.go new file mode 100644 index 0000000..7748999 --- /dev/null +++ b/backend/agent2/model/taskquery.go @@ -0,0 +1,11 @@ +package agentmodel + +// TaskQueryState 是“任务查询”skill 的运行时状态骨架。 +type TaskQueryState struct { + UserInput string + RequestNowText string + NeedRetry bool + RetryCount int + MaxReflectRetry int + FinalReply string +} diff --git a/backend/agent2/node/quicknote.go b/backend/agent2/node/quicknote.go new file mode 100644 index 0000000..edced05 --- /dev/null +++ b/backend/agent2/node/quicknote.go @@ -0,0 +1,132 @@ +package agentnode + +import ( + "context" + "errors" + + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" +) + +const ( + // QuickNoteGraphNodeIntent 是随口记图里的“意图识别”节点名。 + // 这里把节点名下沉到 node 层,是为了让: + // 1. 节点自己的分支方法可以直接返回目标节点名; + // 2. graph 层只负责连线,不需要反向暴露常量给 node 层; + // 3. 后续若节点改名,只需要在这里统一收口。 + QuickNoteGraphNodeIntent = "quick_note_intent" + // QuickNoteGraphNodeRank 是随口记图里的“优先级评估”节点名。 + QuickNoteGraphNodeRank = "quick_note_priority" + // QuickNoteGraphNodePersist 是随口记图里的“持久化写库”节点名。 + QuickNoteGraphNodePersist = "quick_note_persist" + // QuickNoteGraphNodeExit 是随口记图里的“提前退出”节点名。 + QuickNoteGraphNodeExit = "quick_note_exit" +) + +// QuickNoteGraphRunInput 描述一次“随口记图运行”所需的请求级依赖。 +// +// 职责边界: +// 1. Model:当前请求实际使用的聊天模型; +// 2. State:本次图运行共享的状态对象; +// 3. Deps:工具层依赖,例如解析 user_id、执行写库; +// 4. SkipIntentVerification:若上游路由已高置信命中,可跳过二次意图判断; +// 5. EmitStage:向外层推送阶段消息的可选回调。 +// +// 不负责什么: +// 1. 不负责真正的 graph 连线; +// 2. 不负责工具注册与提取; +// 3. 不负责节点内部业务流转。 +type QuickNoteGraphRunInput struct { + Model *ark.ChatModel + State *agentmodel.QuickNoteState + Deps QuickNoteToolDeps + SkipIntentVerification bool + EmitStage func(stage, detail string) +} + +// QuickNoteNodes 是“随口记”节点容器。 +// +// 设计目的: +// 1. 把“请求级依赖”收口到 node 层,而不是继续堆在 graph 层; +// 2. 让 graph 层直接挂 `nodes.Intent / nodes.Priority / nodes.Persist` 这些方法; +// 3. 这样 graph 文件就只负责画图,不再负责依赖转接。 +// +// 职责边界: +// 1. 负责提供可直接挂载到 graph 的节点方法; +// 2. 负责在节点执行时读取本次请求的 input / tool / stage emitter; +// 3. 不负责 graph 编译与运行,也不负责 service 层收尾持久化。 +type QuickNoteNodes struct { + input QuickNoteGraphRunInput + createTaskTool tool.InvokableTool + emitStage func(stage, detail string) +} + +// NewQuickNoteNodes 创建随口记节点容器。 +// +// 说明: +// 1. 这里做的是“节点依赖注入”,不是 graph 连线; +// 2. emitStage 允许为空,内部会补成 no-op,避免节点里反复判空; +// 3. createTaskTool 为 persist 节点的硬依赖,缺失时直接报错,避免跑到写库节点再失败。 +func NewQuickNoteNodes(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool) (*QuickNoteNodes, error) { + if createTaskTool == nil { + return nil, errors.New("quick note nodes: createTaskTool is nil") + } + + emitStage := input.EmitStage + if emitStage == nil { + emitStage = func(stage, detail string) {} + } + + return &QuickNoteNodes{ + input: input, + createTaskTool: createTaskTool, + emitStage: emitStage, + }, nil +} + +// Exit 是图里的显式退出节点。 +// +// 职责边界: +// 1. 只负责把当前 state 原样透传到 END; +// 2. 不负责追加业务逻辑; +// 3. 保留这个节点,是为了后续若要补统一埋点、日志、收尾逻辑时有稳定挂载点。 +func (n *QuickNoteNodes) Exit(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { + _ = ctx + return st, nil +} + +// NextAfterIntent 负责根据意图识别结果决定 intent 后的分支走向。 +func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) { + _ = ctx + if st == nil || !st.IsQuickNoteIntent { + return QuickNoteGraphNodeExit, nil + } + if st.DeadlineValidationError != "" { + return QuickNoteGraphNodeExit, nil + } + return QuickNoteGraphNodeRank, nil + +} + +// NextAfterPersist 负责根据持久化结果决定 persist 后的分支走向。 +func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) { + _ = ctx + if st == nil { + return compose.END, nil + } + if st.Persisted { + return compose.END, nil + } + if st.CanRetryTool() { + return QuickNoteGraphNodePersist, nil + } + if st.AssistantReply == "" { + // 1. 重试次数耗尽且上游没有明确失败文案时,在这里补一条兜底回复; + // 2. 这样可以保证图结束后 service 层一定能拿到稳定可展示的失败信息; + // 3. 不在 graph 层处理,是因为这属于节点业务状态修正。 + st.AssistantReply = "抱歉,我已经重试了多次,还是没能成功记录这条任务,请稍后再试。" + } + return compose.END, nil +} diff --git a/backend/agent2/node/quicknote_flow.go b/backend/agent2/node/quicknote_flow.go new file mode 100644 index 0000000..9b1d857 --- /dev/null +++ b/backend/agent2/node/quicknote_flow.go @@ -0,0 +1,395 @@ +package agentnode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + "github.com/cloudwego/eino-ext/components/model/ark" +) + +// Intent 负责“意图识别 + 聚合规划 + 时间校验”。 +// +// 职责边界: +// 1. 负责判断本次请求是否属于随口记; +// 2. 负责把模型规划结果回填到 state; +// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL; +// 4. 不负责真正写库。 +func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { + if st == nil { + return nil, errors.New("quick note graph: nil state in intent node") + } + + // 1. 若上游路由已经高置信命中 quick_note,则直接进入单次聚合规划。 + // 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内; + // 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。 + if n.input.SkipIntentVerification { + n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。") + st.IsQuickNoteIntent = true + st.IntentJudgeReason = "上游路由已命中 quick_note,跳过二次意图判定" + st.PlannedBySingleCall = true + + n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。") + plan, planErr := planQuickNoteInSingleCall(ctx, n.input.Model, st.RequestNowText, st.RequestNow, st.UserInput) + if planErr != nil { + st.IntentJudgeReason += ";聚合规划失败,回退本地兜底" + } else { + 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 agentmodel.IsValidTaskPriority(plan.PriorityGroup) { + st.ExtractedPriority = plan.PriorityGroup + st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason) + } + st.ExtractedBanter = strings.TrimSpace(plan.Banter) + } + + // 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。 + if strings.TrimSpace(st.ExtractedTitle) == "" { + st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput) + } + + // 1.4 最后一定要做一轮本地时间硬校验。 + // 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间; + // 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。 + n.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点。" + n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") + return st, nil + } + if userDeadline != nil { + st.ExtractedDeadline = userDeadline + st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) + } + return st, nil + } + + // 2. 常规路径:先做一次意图识别,再做本地时间硬校验。 + n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。") + parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput) + if callErr != nil { + // 2.1 这里不直接返回 error,而是把它视为“本次未能确认是 quick note”,交给上层回退普通聊天。 + st.IsQuickNoteIntent = false + st.IntentJudgeReason = "意图识别失败,回退普通聊天" + return st, nil + } + + st.IsQuickNoteIntent = parsed.IsQuickNote + st.IntentJudgeReason = strings.TrimSpace(parsed.Reason) + if !st.IsQuickNoteIntent { + return st, nil + } + + title := strings.TrimSpace(parsed.Title) + if title == "" { + title = strings.TrimSpace(st.UserInput) + } + st.ExtractedTitle = title + + n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") + + // 2.2 先尝试吃模型返回的 deadline_at,用于减少后续重复推理。 + st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt) + if st.ExtractedDeadlineText != "" { + if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil { + st.ExtractedDeadline = deadline + } + } + + // 2.3 再强制对用户原句做一次时间线索校验。 + 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点。" + n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") + return st, nil + } + + // 2.4 若模型没提到 deadline,但用户原句能解析出来,则以用户原句为准补齐。 + if st.ExtractedDeadline == nil && userDeadline != nil { + st.ExtractedDeadline = userDeadline + if st.ExtractedDeadlineText == "" { + st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) + } + } + return st, nil +} + +// Priority 负责“优先级评估”。 +// +// 职责边界: +// 1. 负责在 intent 节点之后补齐 priority_group; +// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型; +// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走; +// 4. 不负责写库。 +func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { + if st == nil { + return nil, errors.New("quick note graph: nil state in priority node") + } + if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { + return st, nil + } + + // 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。 + if agentmodel.IsValidTaskPriority(st.ExtractedPriority) { + if strings.TrimSpace(st.ExtractedPriorityReason) == "" { + st.ExtractedPriorityReason = "复用聚合规划优先级" + } + n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。") + return st, nil + } + + // 2. 单请求聚合路径若没有给出合法 priority,则直接走本地兜底,优先保证低时延。 + if n.input.SkipIntentVerification || st.PlannedBySingleCall { + st.ExtractedPriority = fallbackPriority(st) + st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底" + n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。") + return st, nil + } + + n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。") + deadlineText := "无" + if st.ExtractedDeadline != nil { + deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline) + } + deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText) + if deadlineClue == "" { + deadlineClue = "无" + } + + parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText) + if callErr != nil { + st.ExtractedPriority = fallbackPriority(st) + st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略" + return st, nil + } + if parsed == nil || !agentmodel.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 +} + +// Persist 负责“调工具写库 + 有限次重试状态回填”。 +// +// 职责边界: +// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参; +// 2. 负责调用 createTaskTool 执行真正写库; +// 3. 负责把成功/失败结果回填到 state,供后续分支与回复使用; +// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。 +func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { + if st == nil { + return nil, errors.New("quick note graph: nil state in persist node") + } + if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { + return st, nil + } + + n.emitStage("quick_note.persisting", "正在写入任务数据。") + priority := st.ExtractedPriority + if !agentmodel.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) + } + + 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 = "抱歉,记录任务时参数处理失败,请稍后重试。" + n.emitStage("quick_note.failed", "参数构造失败,未完成写入。") + } + return st, nil + } + + rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput)) + if invokeErr != nil { + st.RecordToolError(invokeErr.Error()) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" + n.emitStage("quick_note.failed", "多次重试后仍未完成写入。") + } + return st, nil + } + + toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput) + if parseErr != nil { + st.RecordToolError("解析工具返回失败: " + parseErr.Error()) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。" + n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。") + } + return st, nil + } + if toolOutput.TaskID <= 0 { + st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID)) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。" + n.emitStage("quick_note.failed", "写入结果缺少有效 task_id,已终止成功回包。") + } + return st, nil + } + + // 1. 只有拿到有效 task_id,才视为真正写入成功; + // 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。 + st.RecordToolSuccess(toolOutput.TaskID) + if strings.TrimSpace(toolOutput.Title) != "" { + st.ExtractedTitle = strings.TrimSpace(toolOutput.Title) + } + if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) { + st.ExtractedPriority = toolOutput.PriorityGroup + } + + reply := strings.TrimSpace(toolOutput.Message) + if reply == "" { + reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority)) + } + st.AssistantReply = reply + n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。") + return st, 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) { + parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput) + if err != nil { + return nil, err + } + + 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), + } + if result.Banter != "" { + if idx := strings.Index(result.Banter, "\n"); idx >= 0 { + result.Banter = strings.TrimSpace(result.Banter[:idx]) + } + } + if result.DeadlineText != "" { + if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil { + result.Deadline = deadline + } + } + if result.UrgencyThresholdText != "" { + if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil { + result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline) + } + } + return result, nil +} + +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 *agentmodel.QuickNoteState) int { + if st == nil { + return agentmodel.QuickNotePrioritySimpleNotImportant + } + if st.ExtractedDeadline != nil { + if time.Until(*st.ExtractedDeadline) <= 48*time.Hour { + return agentmodel.QuickNotePriorityImportantUrgent + } + return agentmodel.QuickNotePriorityImportantNotUrgent + } + return agentmodel.QuickNotePrioritySimpleNotImportant +} + +// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。 +func deriveQuickNoteTitleFromInput(userInput string) string { + text := strings.TrimSpace(userInput) + if text == "" { + return "这条任务" + } + + prefixes := []string{ + "请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一个", "记个", "帮我记一个", + } + for _, prefix := range prefixes { + if strings.HasPrefix(text, prefix) { + text = strings.TrimSpace(strings.TrimPrefix(text, prefix)) + break + } + } + + suffixSeparators := []string{ + ",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得", + } + for _, sep := range suffixSeparators { + if idx := strings.Index(text, sep); idx > 0 { + text = strings.TrimSpace(text[:idx]) + break + } + } + + text = strings.Trim(text, ",。?!!? ") + if text == "" { + return strings.TrimSpace(userInput) + } + return text +} diff --git a/backend/agent2/node/quicknote_tool.go b/backend/agent2/node/quicknote_tool.go new file mode 100644 index 0000000..6b27941 --- /dev/null +++ b/backend/agent2/node/quicknote_tool.go @@ -0,0 +1,723 @@ +package agentnode + +import ( + "context" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared" + "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 !agentmodel.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 agentmodel.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: agentmodel.PriorityLabelCN(finalPriority), + DeadlineAt: deadlineStr, + Message: fmt.Sprintf("已记录:%s(%s)", finalTitle, agentmodel.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 +} + +// GetInvokableToolByName 通过工具名提取可执行工具实例。 +func GetInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) { + 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") + } + 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) +} + +// 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 + } +} + +// quickNoteLocation 返回随口记链路使用的业务时区。 +func quickNoteLocation() *time.Location { + loc, err := time.LoadLocation(agentmodel.QuickNoteTimezoneName) + if err != nil { + return time.Local + } + return loc +} + +// quickNoteNowToMinute 返回当前时间并截断到分钟级。 +func quickNoteNowToMinute() time.Time { + return agentshared.NowToMinute() +} + +// formatQuickNoteTimeToMinute 将时间格式化为分钟级字符串。 +func formatQuickNoteTimeToMinute(t time.Time) string { + return agentshared.FormatMinute(t.In(quickNoteLocation())) +} diff --git a/backend/agent2/node/schedule_plan.go b/backend/agent2/node/schedule_plan.go new file mode 100644 index 0000000..53e1195 --- /dev/null +++ b/backend/agent2/node/schedule_plan.go @@ -0,0 +1,25 @@ +package agentnode + +import ( + agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" + agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream" +) + +// SchedulePlanNodeDeps 描述“首次排程”节点层公共依赖。 +type SchedulePlanNodeDeps struct { + LLM *agentllm.Client + StageEmitter agentstream.StageEmitter +} + +// SchedulePlanNodes 是“首次排程”节点逻辑容器。 +type SchedulePlanNodes struct { + deps SchedulePlanNodeDeps +} + +// NewSchedulePlanNodes 创建首次排程节点容器。 +func NewSchedulePlanNodes(deps SchedulePlanNodeDeps) *SchedulePlanNodes { + if deps.StageEmitter == nil { + deps.StageEmitter = agentstream.NoopStageEmitter() + } + return &SchedulePlanNodes{deps: deps} +} diff --git a/backend/agent2/node/schedule_refine.go b/backend/agent2/node/schedule_refine.go new file mode 100644 index 0000000..f0c3a00 --- /dev/null +++ b/backend/agent2/node/schedule_refine.go @@ -0,0 +1,25 @@ +package agentnode + +import ( + agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" + agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream" +) + +// ScheduleRefineNodeDeps 描述“连续微调排程”节点层公共依赖。 +type ScheduleRefineNodeDeps struct { + LLM *agentllm.Client + StageEmitter agentstream.StageEmitter +} + +// ScheduleRefineNodes 是“连续微调排程”节点逻辑容器。 +type ScheduleRefineNodes struct { + deps ScheduleRefineNodeDeps +} + +// NewScheduleRefineNodes 创建连续微调节点容器。 +func NewScheduleRefineNodes(deps ScheduleRefineNodeDeps) *ScheduleRefineNodes { + if deps.StageEmitter == nil { + deps.StageEmitter = agentstream.NoopStageEmitter() + } + return &ScheduleRefineNodes{deps: deps} +} diff --git a/backend/agent2/node/taskquery.go b/backend/agent2/node/taskquery.go new file mode 100644 index 0000000..f3dd841 --- /dev/null +++ b/backend/agent2/node/taskquery.go @@ -0,0 +1,25 @@ +package agentnode + +import ( + agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" + agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream" +) + +// TaskQueryNodeDeps 描述“随口问任务”节点层的公共依赖。 +type TaskQueryNodeDeps struct { + LLM *agentllm.Client + StageEmitter agentstream.StageEmitter +} + +// TaskQueryNodes 是“随口问任务”节点逻辑容器。 +type TaskQueryNodes struct { + deps TaskQueryNodeDeps +} + +// NewTaskQueryNodes 创建任务查询节点容器。 +func NewTaskQueryNodes(deps TaskQueryNodeDeps) *TaskQueryNodes { + if deps.StageEmitter == nil { + deps.StageEmitter = agentstream.NoopStageEmitter() + } + return &TaskQueryNodes{deps: deps} +} diff --git a/backend/agent2/prompt/quicknote.go b/backend/agent2/prompt/quicknote.go new file mode 100644 index 0000000..efc13dc --- /dev/null +++ b/backend/agent2/prompt/quicknote.go @@ -0,0 +1,46 @@ +package agentprompt + +const ( + // QuickNotePlanPrompt 用于“单请求聚合规划”。 + QuickNotePlanPrompt = `你是 SmartFlow 的任务聚合规划器。 +你将基于用户输入,一次性输出任务规划结果,供后端直接写库。 + +必须完成以下五件事: +1) 提取任务标题 title(简洁明确)。 +2) 归一化截止时间 deadline_at(若存在时间线索,必须输出绝对时间)。 +3) 评估紧急分界时间 urgency_threshold_at(当任务被判定为不紧急任务时才会触发:你需要评估何时从不紧急象限自动平移到紧急象限,不可为空)。 +4) 评估优先级 priority_group(1~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 用于第一阶段:判断用户输入是否属于“随口记”。 + QuickNoteIntentPrompt = `你是 SmartFlow 的“随口记分诊器”。 +请判断用户输入是否表达了“帮我记一个任务/日程”的需求。 +- 若是,请提取任务标题与时间线索。 +- 时间处理必须严谨:若出现相对时间(如明天/后天/下周一/今晚),必须基于上文给出的“当前时间”换算为绝对时间。 +- 若不是,请明确返回“非随口记意图”。 +- 不要声称已经写入数据库。` + + // QuickNotePriorityPrompt 用于第二阶段:将任务归类到四象限优先级,并评估紧急分界线。 + QuickNotePriorityPrompt = `你是 SmartFlow 的任务优先级评估器。 +根据任务内容、时间约束和执行成本,输出优先级 priority_group: +1=重要且紧急,2=重要不紧急,3=简单不重要,4=不简单不重要。 +请给出简短理由,理由必须可解释。 +若你认为该任务需要后续自动平移,请额外输出 urgency_threshold_at(绝对时间,yyyy-MM-dd HH:mm);否则输出空字符串。` + + // QuickNoteReplyBanterPrompt 用于随口记成功后的“轻松跟进句”生成。 + QuickNoteReplyBanterPrompt = `你是 SmartFlow 的中文口语化回复润色助手。 +请根据用户原话生成一句轻松自然的跟进话术,让回复更有温度。 +要求: +- 只输出一句中文,不超过30字。 +- 顺着用户创建提醒的主题延伸,就像聊天时友好的问候一样,记得动用你知道的对应领域的知识。例如(注意,只是例子):用户说提醒他明天早上吃麦当劳,你润色回复应该类似这样:"薯饼记得趁热吃哦~"。 +- 可以轻微调侃,但语气友好,不刻薄。 +- 不得新增或修改任务事实(任务名、时间、优先级)。 +- 不要输出 markdown、编号、引号。` +) diff --git a/backend/agent2/prompt/route.go b/backend/agent2/prompt/route.go new file mode 100644 index 0000000..1beb475 --- /dev/null +++ b/backend/agent2/prompt/route.go @@ -0,0 +1,24 @@ +package agentprompt + +import ( + "fmt" + "strings" +) + +const routeSystemPrompt = ` +你是 SmartFlow 的一级路由助手。 +你的职责不是回答用户,而是判断这条消息更适合走哪条能力链路。 + +当前 agent2 仍在逐批迁移阶段,因此这里只先保留 prompt 落点与职责说明。 +真正迁移旧 route 提示词时,应把正式版本收敛到这里,而不是散落在 node 或 service 中。 +` + +// BuildRouteSystemPrompt 返回一级路由系统提示词。 +func BuildRouteSystemPrompt() string { + return strings.TrimSpace(routeSystemPrompt) +} + +// BuildRouteUserPrompt 构造一级路由用户提示词。 +func BuildRouteUserPrompt(userInput string) string { + return fmt.Sprintf("用户输入:%s", strings.TrimSpace(userInput)) +} diff --git a/backend/agent2/prompt/schedule.go b/backend/agent2/prompt/schedule.go new file mode 100644 index 0000000..487fdc3 --- /dev/null +++ b/backend/agent2/prompt/schedule.go @@ -0,0 +1,24 @@ +package agentprompt + +import ( + "fmt" + "strings" +) + +const scheduleSystemPrompt = ` +你是 SmartFlow 的智能排程助手。 +你的职责是把用户的排程目标转成结构化计划,并与后端粗排/硬校验能力配合完成排程。 + +当前 agent2 还没正式迁移 schedule 相关旧代码, +因此这里先定义 prompt 收口点,确保后面迁移时不会再把大段提示词散写回 nodes.go。 +` + +// BuildScheduleSystemPrompt 返回排程系统提示词骨架。 +func BuildScheduleSystemPrompt() string { + return strings.TrimSpace(scheduleSystemPrompt) +} + +// BuildScheduleUserPrompt 构造排程用户提示词骨架。 +func BuildScheduleUserPrompt(nowText, userInput string) string { + return fmt.Sprintf("当前时间(北京时间,精确到分钟):%s\n用户请求:%s", strings.TrimSpace(nowText), strings.TrimSpace(userInput)) +} diff --git a/backend/agent2/prompt/taskquery.go b/backend/agent2/prompt/taskquery.go new file mode 100644 index 0000000..00d8504 --- /dev/null +++ b/backend/agent2/prompt/taskquery.go @@ -0,0 +1,23 @@ +package agentprompt + +import ( + "fmt" + "strings" +) + +const taskQuerySystemPrompt = ` +你是 SmartFlow 的“任务查询规划助手”。 +你不直接回答最终结果,而是先判断用户想查哪一类任务、需要怎样排序、以及是否需要时间范围约束。 + +当前文件先保留 prompt 归档位置与基本职责说明。 +` + +// BuildTaskQuerySystemPrompt 返回任务查询系统提示词骨架。 +func BuildTaskQuerySystemPrompt() string { + return strings.TrimSpace(taskQuerySystemPrompt) +} + +// BuildTaskQueryUserPrompt 构造任务查询用户提示词骨架。 +func BuildTaskQueryUserPrompt(nowText, userInput string) string { + return fmt.Sprintf("当前时间(北京时间,精确到分钟):%s\n用户请求:%s", strings.TrimSpace(nowText), strings.TrimSpace(userInput)) +} diff --git a/backend/agent2/router/action_route.go b/backend/agent2/router/action_route.go new file mode 100644 index 0000000..e025796 --- /dev/null +++ b/backend/agent2/router/action_route.go @@ -0,0 +1,272 @@ +package agentrouter + +import ( + "context" + "fmt" + "log" + "regexp" + "strings" + "time" + + agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/google/uuid" +) + +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 + +输出格式必须严格如下(两行): + +一句不超过30字的中文理由 + +禁止输出任何其他内容。` + +// Action 是 agent2 路由层对业务动作的统一命名。 +// +// 这里直接定义在 router 包,而不是复用旧 route 包: +// 1. 当前这轮迁移要求只有 router 可以保留对旧链路的兼容语义; +// 2. chat / quicknote 已经要完全切到 agent2,自然不该再依赖旧包常量; +// 3. schedule/taskquery 尚未搬迁完成时,也能继续靠这些常量在 service 层做统一分发。 +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)) + + // 1. 调用目的:路由场景只需要稳定、短文本、禁用 thinking 的结构化输出。 + // 2. 这里复用 agent2 公共 LLM 封装,删除与 quicknote 重复的 JSON/文本调用样板代码。 + resp, err := agentllm.CallArkText(routeCtx, selectedModel, routeControlPrompt, userPrompt, agentllm.ArkCallOptions{ + Temperature: 0, + MaxTokens: 120, + Thinking: agentllm.ThinkingModeDisabled, + }) + if err != nil { + return nil, err + } + return ParseRouteControlTag(resp, 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) +} diff --git a/backend/agent2/router/route.go b/backend/agent2/router/route.go new file mode 100644 index 0000000..411f830 --- /dev/null +++ b/backend/agent2/router/route.go @@ -0,0 +1,67 @@ +package agentrouter + +import ( + "context" + "errors" + "fmt" +) + +// Dispatcher 是 agent2 的统一分发器。 +type Dispatcher struct { + resolver Resolver + handlers map[Action]SkillHandler +} + +// NewDispatcher 创建统一分发器。 +func NewDispatcher(resolver Resolver) *Dispatcher { + return &Dispatcher{ + resolver: resolver, + handlers: make(map[Action]SkillHandler), + } +} + +// Register 注册某个动作的处理函数。 +func (d *Dispatcher) Register(action Action, handler SkillHandler) error { + if d == nil { + return errors.New("dispatcher is nil") + } + if action == "" { + return errors.New("route action is empty") + } + if handler == nil { + return fmt.Errorf("handler for action %s is nil", action) + } + if _, exists := d.handlers[action]; exists { + return fmt.Errorf("handler for action %s already registered", action) + } + d.handlers[action] = handler + return nil +} + +// Dispatch 执行“分流 -> skill handler”完整入口。 +func (d *Dispatcher) Dispatch(ctx context.Context, req *AgentRequest) (*AgentResponse, error) { + if d == nil || d.resolver == nil { + return nil, errors.New("route dispatcher is not ready") + } + if req == nil { + return nil, errors.New("agent request is nil") + } + + // 1. 调用目的:统一先走一级路由,让入口层只关心“请求来了”, + // 不需要提前知道这是普通聊天、随口记、任务查询还是后续排程。 + decision, err := d.resolver.Resolve(ctx, req) + if err != nil { + return nil, err + } + if decision == nil { + return nil, errors.New("route decision is nil") + } + + // 2. 路由结果出来后,只根据 action 查找对应 handler。 + // 这里故意不做 skill 级 fallback,避免路由层和 skill 内部职责再次缠在一起。 + handler, exists := d.handlers[decision.Action] + if !exists { + return nil, fmt.Errorf("no handler registered for action %s", decision.Action) + } + return handler(ctx, req) +} diff --git a/backend/agent2/router/route_model.go b/backend/agent2/router/route_model.go new file mode 100644 index 0000000..76ab919 --- /dev/null +++ b/backend/agent2/router/route_model.go @@ -0,0 +1,34 @@ +package agentrouter + +import ( + "context" +) + +// Resolver 定义一级路由器能力。 +type Resolver interface { + Resolve(ctx context.Context, req *AgentRequest) (*RoutingDecision, error) +} + +// SkillHandler 是某个 skill 的统一执行入口。 +type SkillHandler func(ctx context.Context, req *AgentRequest) (*AgentResponse, error) + +// AgentRequest 是 agent2 路由层可见的最小请求结构。 +// +// 设计目的: +// 1. 让 router 层只依赖自己真正关心的字段; +// 2. 避免把整份 agentmodel 结构在迁移早期层层透传; +// 3. 后续若总入口还要追加别的字段,只需要在入口层做一次映射。 +type AgentRequest struct { + UserID int + ConversationID string + UserMessage string + ModelName string + Extra map[string]any +} + +// AgentResponse 是路由分发器对 skill handler 的统一响应外壳。 +type AgentResponse struct { + Action Action + Reply string + Meta map[string]any +} diff --git a/backend/agent2/shared/clone.go b/backend/agent2/shared/clone.go new file mode 100644 index 0000000..061484a --- /dev/null +++ b/backend/agent2/shared/clone.go @@ -0,0 +1,95 @@ +package agentshared + +import "github.com/LoveLosita/smartflow/backend/model" + +// CloneWeekSchedules 深拷贝周视图排程结果。 +// +// 职责边界: +// 1. 负责断开 []UserWeekSchedule 与内部 Events 切片的引用共享; +// 2. 负责服务于“缓存 DTO / graph state / API 响应”之间的安全复制; +// 3. 不负责业务过滤,不负责排序。 +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 +} + +// CloneHybridEntries 深拷贝混合排程条目切片。 +func CloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { + if len(src) == 0 { + return nil + } + dst := make([]model.HybridScheduleEntry, len(src)) + copy(dst, src) + return dst +} + +// CloneTaskClassItems 深拷贝任务块切片。 +// +// 这里不能直接 copy: +// 1. 因为 TaskClassItem 内部带若干指针字段; +// 2. 如果浅拷贝,后续某一步修改 EmbeddedTime / Status,会污染原状态; +// 3. 排程 graph 连续微调时,这种共享引用会非常难查,所以必须在公共层兜住。 +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 +} + +// CloneInts 深拷贝 int 切片。 +func CloneInts(src []int) []int { + if len(src) == 0 { + return nil + } + dst := make([]int, len(src)) + copy(dst, src) + return dst +} + +// CloneStrings 深拷贝 string 切片。 +func CloneStrings(src []string) []string { + if len(src) == 0 { + return nil + } + dst := make([]string, len(src)) + copy(dst, src) + return dst +} diff --git a/backend/agent2/shared/retry.go b/backend/agent2/shared/retry.go new file mode 100644 index 0000000..0d3e2a7 --- /dev/null +++ b/backend/agent2/shared/retry.go @@ -0,0 +1,85 @@ +package agentshared + +import ( + "context" + "time" +) + +// RetryOptions 描述公共重试策略。 +// +// 职责边界: +// 1. 这里只定义“是否重试、最多几次、间隔多久”; +// 2. 不关心具体业务是工具调用失败、模型 JSON 失败还是 DB 暂时不可用; +// 3. 真正的业务兜底文案仍应由上层 node 决定。 +type RetryOptions struct { + MaxAttempts int + Interval time.Duration + ShouldRetry func(err error) bool + OnRetry func(attempt int, err error) +} + +// Do 执行一个只返回 error 的重试任务。 +// +// 执行规则: +// 1. 第一次执行也算一次 attempt; +// 2. 任意一次成功即立即返回; +// 3. 上下文取消、达到最大次数、或 ShouldRetry=false 时立即停止。 +func Do(ctx context.Context, options RetryOptions, fn func(attempt int) error) error { + _, err := DoValue[struct{}](ctx, options, func(attempt int) (struct{}, error) { + return struct{}{}, fn(attempt) + }) + return err +} + +// DoValue 执行一个带返回值的通用重试任务。 +// +// 设计说明: +// 1. 旧 agent 里后续很多地方都会出现“失败重试 2~3 次”的模式; +// 2. 这里先把循环骨架统一,避免每个 skill 自己写 for + sleep + ctx.Done; +// 3. 上层只需关心“本轮失败要不要继续”,而不是重复造轮子。 +func DoValue[T any](ctx context.Context, options RetryOptions, fn func(attempt int) (T, error)) (T, error) { + var zero T + + maxAttempts := options.MaxAttempts + if maxAttempts <= 0 { + maxAttempts = 1 + } + + for attempt := 1; attempt <= maxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return zero, err + } + + value, err := fn(attempt) + if err == nil { + return value, nil + } + + // 1. 到最后一次了,直接返回原错误,避免无意义等待。 + if attempt >= maxAttempts { + return zero, err + } + // 2. 业务显式声明“不值得重试”时,立刻停止。 + if options.ShouldRetry != nil && !options.ShouldRetry(err) { + return zero, err + } + // 3. 把重试钩子留给上层,用于打点或阶段提示。 + if options.OnRetry != nil { + options.OnRetry(attempt, err) + } + // 4. 没有配置间隔则马上下一轮;配置了则等待,同时尊重 ctx 取消。 + if options.Interval <= 0 { + continue + } + + timer := time.NewTimer(options.Interval) + select { + case <-ctx.Done(): + timer.Stop() + return zero, ctx.Err() + case <-timer.C: + } + } + + return zero, nil +} diff --git a/backend/agent2/shared/time.go b/backend/agent2/shared/time.go new file mode 100644 index 0000000..d06d9cd --- /dev/null +++ b/backend/agent2/shared/time.go @@ -0,0 +1,49 @@ +package agentshared + +import ( + "sync" + "time" +) + +const ( + // MinuteLayout 是 agent2 内部统一的分钟级时间文本格式。 + // + // 设计原因: + // 1. agent 里大量场景只需要精确到分钟; + // 2. 秒级精度会增加提示词噪声,也容易让“同一请求内的当前时间”出现抖动; + // 3. 先统一成一份常量,后续 quicknote / schedule 都直接复用。 + MinuteLayout = "2006-01-02 15:04" +) + +var ( + shanghaiLocOnce sync.Once + shanghaiLoc *time.Location +) + +// ShanghaiLocation 返回 agent2 内部统一使用的东八区时区。 +func ShanghaiLocation() *time.Location { + shanghaiLocOnce.Do(func() { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + // 兜底使用固定东八区,避免极端环境下因为系统时区文件缺失导致整个链路失败。 + loc = time.FixedZone("CST", 8*3600) + } + shanghaiLoc = loc + }) + return shanghaiLoc +} + +// NowToMinute 返回当前北京时间,并截断到分钟级。 +func NowToMinute() time.Time { + return time.Now().In(ShanghaiLocation()).Truncate(time.Minute) +} + +// NormalizeToMinute 把任意时间统一到北京时间分钟粒度。 +func NormalizeToMinute(t time.Time) time.Time { + return t.In(ShanghaiLocation()).Truncate(time.Minute) +} + +// FormatMinute 把时间格式化为统一分钟级文本。 +func FormatMinute(t time.Time) string { + return NormalizeToMinute(t).Format(MinuteLayout) +} diff --git a/backend/agent2/stream/emitter.go b/backend/agent2/stream/emitter.go new file mode 100644 index 0000000..7af6fa9 --- /dev/null +++ b/backend/agent2/stream/emitter.go @@ -0,0 +1,115 @@ +package agentstream + +import ( + "fmt" + "strings" +) + +// PayloadEmitter 是真正向外层 SSE 管道写 chunk 的最小接口。 +// +// 说明: +// 1. 这里刻意不用 chan/string 绑死实现; +// 2. 上层既可以传“写 channel”的函数,也可以传“写 gin stream”的函数; +// 3. 只要签名是 `func(string) error`,都能接进来。 +type PayloadEmitter func(payload string) error + +// StageEmitter 是 graph/node 对“当前阶段”进行推送的最小接口。 +type StageEmitter func(stage, detail string) + +// NoopPayloadEmitter 返回一个空实现,便于骨架期安全占位。 +func NoopPayloadEmitter() PayloadEmitter { + return func(string) error { return nil } +} + +// NoopStageEmitter 返回一个空实现,避免 graph 在没有接前端时处处判空。 +func NoopStageEmitter() StageEmitter { + return func(stage, detail string) {} +} + +// WrapStageEmitter 把可空函数包装成稳定的 StageEmitter。 +func WrapStageEmitter(fn func(stage, detail string)) StageEmitter { + if fn == nil { + return NoopStageEmitter() + } + return fn +} + +// EmitStageAsReasoning 把“阶段提示”伪装成 reasoning chunk 推给前端。 +// +// 设计背景: +// 1. 你当前 Apifox 只认思考块和正文块,因此阶段提示需要先借 reasoning_content 走通; +// 2. 这样后续真正前端上线时,只需要在这一层换协议,而不必回到各 skill 重改 graph; +// 3. 这里不拼花哨格式,只给出稳定、可读、可 grep 的文本。 +func EmitStageAsReasoning(emit PayloadEmitter, requestID, modelName string, created int64, stage, detail string, includeRole bool) error { + if emit == nil { + return nil + } + + text := BuildStageReasoningText(stage, detail) + payload, err := ToOpenAIReasoningChunk(requestID, modelName, created, text, includeRole) + if err != nil { + return err + } + if payload == "" { + return nil + } + return emit(payload) +} + +// EmitAssistantReply 把一段完整正文作为 assistant chunk 推出。 +// +// 注意: +// 1. 这里是“整段发”,不是把文本强行拆碎; +// 2. 这样后续如果某条链路不需要真流式,也可以复用统一出口; +// 3. 真正按 token/chunk 细粒度流式输出,应由 llm.Stream + 上层循环处理。 +func EmitAssistantReply(emit PayloadEmitter, requestID, modelName string, created int64, content string, includeRole bool) error { + if emit == nil { + return nil + } + payload, err := ToOpenAIAssistantChunk(requestID, modelName, created, content, includeRole) + if err != nil { + return err + } + if payload == "" { + return nil + } + return emit(payload) +} + +// EmitFinish 统一输出 stop 结束块。 +func EmitFinish(emit PayloadEmitter, requestID, modelName string, created int64) error { + if emit == nil { + return nil + } + payload, err := ToOpenAIFinishStream(requestID, modelName, created) + if err != nil { + return err + } + if payload == "" { + return nil + } + return emit(payload) +} + +// EmitDone 统一输出 OpenAI 兼容流式结束标记。 +func EmitDone(emit PayloadEmitter) error { + if emit == nil { + return nil + } + return emit("[DONE]") +} + +// BuildStageReasoningText 生成统一阶段提示文本。 +func BuildStageReasoningText(stage, detail string) string { + stage = strings.TrimSpace(stage) + detail = strings.TrimSpace(detail) + + switch { + case stage != "" && detail != "": + return fmt.Sprintf("阶段:%s\n%s", stage, detail) + case stage != "": + return fmt.Sprintf("阶段:%s", stage) + default: + return detail + } +} diff --git a/backend/agent2/stream/openai.go b/backend/agent2/stream/openai.go new file mode 100644 index 0000000..4459451 --- /dev/null +++ b/backend/agent2/stream/openai.go @@ -0,0 +1,102 @@ +package agentstream + +import ( + "encoding/json" + + "github.com/cloudwego/eino/schema" +) + +// OpenAIChunkResponse 是 OpenAI 兼容的流式 chunk DTO。 +// +// 之所以单独放到 agent2/stream: +// 1. 未来无论 quicknote、taskquery 还是 schedule,只要需要 SSE 都会复用这套协议壳; +// 2. 这样 node/graph 层只关注“我要推什么内容”,不再自己拼 JSON; +// 3. 后续如果前端协议升级,也能在这里集中改。 +type OpenAIChunkResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIChunkChoice `json:"choices"` +} + +// OpenAIChunkChoice 对应 OpenAI choices[0]。 +type OpenAIChunkChoice struct { + Index int `json:"index"` + Delta OpenAIChunkDelta `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +// OpenAIChunkDelta 是真正承载 role/content/reasoning 的位置。 +type OpenAIChunkDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` +} + +// ToOpenAIStream 把 Eino message 转成 OpenAI 兼容 chunk。 +// +// 职责边界: +// 1. 负责把 chunk.Content / chunk.ReasoningContent 映射到协议字段; +// 2. 负责按 includeRole 决定是否在首块带上 assistant 角色; +// 3. 不负责发送,也不负责决定“这个 chunk 该不该推”。 +func ToOpenAIStream(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool) (string, error) { + delta := OpenAIChunkDelta{} + if includeRole { + delta.Role = "assistant" + } + if chunk != nil { + delta.Content = chunk.Content + delta.ReasoningContent = chunk.ReasoningContent + } + return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil) +} + +// ToOpenAIReasoningChunk 直接构造一个 reasoning chunk。 +func ToOpenAIReasoningChunk(requestID, modelName string, created int64, reasoning string, includeRole bool) (string, error) { + delta := OpenAIChunkDelta{ReasoningContent: reasoning} + if includeRole { + delta.Role = "assistant" + } + return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil) +} + +// ToOpenAIAssistantChunk 直接构造一个正文 chunk。 +func ToOpenAIAssistantChunk(requestID, modelName string, created int64, content string, includeRole bool) (string, error) { + delta := OpenAIChunkDelta{Content: content} + if includeRole { + delta.Role = "assistant" + } + return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil) +} + +// ToOpenAIFinishStream 生成流式结束 chunk(finish_reason=stop)。 +func ToOpenAIFinishStream(requestID, modelName string, created int64) (string, error) { + stop := "stop" + return buildOpenAIChunkPayload(requestID, modelName, created, OpenAIChunkDelta{}, &stop) +} + +func buildOpenAIChunkPayload(requestID, modelName string, created int64, delta OpenAIChunkDelta, finishReason *string) (string, error) { + // 1. 若既没有 role,也没有正文/思考,也没有 finish_reason,则视为“空块”,直接跳过。 + // 2. 这样可以避免上层每次都自己写一遍空块判断。 + if delta.Role == "" && delta.Content == "" && delta.ReasoningContent == "" && finishReason == nil { + return "", nil + } + + dto := OpenAIChunkResponse{ + ID: requestID, + Object: "chat.completion.chunk", + Created: created, + Model: modelName, + Choices: []OpenAIChunkChoice{{ + Index: 0, + Delta: delta, + FinishReason: finishReason, + }}, + } + data, err := json.Marshal(dto) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/backend/agent2/通用能力接入文档.md b/backend/agent2/通用能力接入文档.md new file mode 100644 index 0000000..cbcd5cb --- /dev/null +++ b/backend/agent2/通用能力接入文档.md @@ -0,0 +1,337 @@ +# agent2 通用能力接入文档 + +## 1. 文档目的 + +本文档用于说明 `agent2` 目录下“通用能力”的边界、放置位置、接入方式与维护要求。 + +这里的“通用能力”特指: + +1. 不只服务于某一个技能链路,而是可能被 `chat`、`quicknote`、`taskquery`、`schedule` 等多个模块共同复用的能力。 +2. 与具体业务语义弱耦合,抽出来后不会强行把某个单一技能的 prompt、状态字段、业务规则污染到其它模块。 +3. 抽出来后,能够明显减少样板代码、降低重复实现和后续迁移成本。 + +本文档不负责描述某个具体技能的业务流程,技能自身的图编排、状态字段、prompt 细节,应继续放在对应技能目录或对应决策记录中维护。 + +## 2. 当前目录分层 + +### 2.1 总入口层 + +文件: + +- `entrance.go` + +职责: + +1. 作为 `agent2` 模块对上层服务的统一入口。 +2. 负责把“路由器 + 各技能 handler”装配到一起。 +3. 不负责具体技能逻辑,不负责直接调模型,也不负责工具执行。 + +适合放什么: + +1. 模块级入口对象。 +2. 通用注册方法。 +3. 与“总分发”有关的最小门面封装。 + +不适合放什么: + +1. 某个具体技能的节点逻辑。 +2. 具体业务 DAO 调用。 +3. 某个技能独占的 prompt 或状态机。 + +### 2.2 路由层 + +目录: + +- `router/` + +当前通用能力: + +1. `Dispatcher` +2. `Resolver` +3. `AgentRequest / AgentResponse` +4. `Action` 与路由控制码解析 + +职责: + +1. 统一处理“请求该走哪条技能链路”的分流问题。 +2. 提供对上层稳定的动作枚举与请求壳结构。 +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 解析与 usage 合并能力。 +3. 把具体厂商 SDK 细节尽量压在适配层,不向上层节点扩散。 + +适合放什么: + +1. 所有技能都可能复用的模型调用壳。 +2. 通用 JSON 提取与反序列化。 +3. 流式/非流式调用的统一适配接口。 +4. usage 收敛、空响应错误格式化。 + +不适合放什么: + +1. 只服务于某一个技能的 prompt 文案。 +2. 某一个技能特有的输出结构体。 +3. 某一个技能独占的“成功文案润色”规则。 + +说明: + +1. 如果只是“基于通用 `Client` 再包一层技能专用函数”,例如 quicknote 的聚合规划调用,这种代码可以放在 `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、graph、node 只关心“我要推什么内容”,而不是自己拼 JSON。 +3. 为后续前端协议升级预留统一修改点。 + +适合放什么: + +1. chunk DTO。 +2. reasoning / content / finish 的统一封装。 +3. 阶段消息推送器接口。 + +不适合放什么: + +1. 某个技能的阶段命名表。 +2. 某个技能专属的正文文案。 +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. 如果代码里天然绑定某个技能状态结构、某个技能阶段名、某个技能 prompt 输出契约,一般不应硬抽成通用能力。 +2. 如果只是多个技能都重复了同一段样板代码,且抽出后不会让抽象变形,就应该评估下沉。 + +### 2.7 图层与节点层的协作约定 + +这是当前 `agent2` 已经明确下来的结构约束: + +1. `graph/` 只负责“画图”: + - 注册节点 + - 添加边 + - 添加分支 + - 编译与运行图 +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. 抽出来后接口可以做到“入参少、职责清、语义稳定”。 +4. 上层重复代码主要是在做样板装配,而不是业务决策。 + +典型例子: + +1. 模型消息拼装。 +2. JSON 提取与解析。 +3. usage 合并。 +4. OpenAI chunk 构造。 +5. 时间归一化。 +6. 深拷贝与重试工具。 +7. 总入口路由与技能分发。 + +## 4. 什么不应该强行抽公共层 + +出现以下情况时,不要为了“看起来复用”而硬抽: + +1. 抽完之后函数签名反而要塞一堆技能专属参数。 +2. 公共层开始知道某个技能的状态字段、阶段名、错误文案。 +3. 表面相似,实则每个技能的业务约束完全不同。 +4. 为了复用而引入大量 `if action == xxx`、`switch skill` 这类分支。 + +典型例子: + +1. quicknote 的优先级判定输出结构。 +2. taskquery 的查询规划字段。 +3. schedule 的排程状态快照。 +4. 某个技能特有的 prompt 模板。 + +## 5. 新增通用能力的接入步骤 + +### 5.1 先判断是否值得抽 + +1. 先确认这段逻辑是否已经在第二处出现重复。 +2. 再确认它是不是可以脱离单一技能语义独立存在。 +3. 如果暂时还不能抽,也要在代码注释或决策记录里写明原因,避免后面第三次重复时忘记。 + +### 5.2 选择落点目录 + +按职责优先落到以下目录: + +1. 路由协议与分发:`router/` +2. 模型调用与 JSON 解析:`llm/` +3. 流输出协议:`stream/` +4. 纯工具:`shared/` +5. 技能专属但可复用的壳:放对应技能语义文件,不要伪装成完全公共层 + +### 5.3 定义最小接口 + +1. 先定义最小可复用接口,只暴露上层真正需要的能力。 +2. 不要把下层 SDK、DAO、缓存实现细节直接透传到所有调用方。 +3. 优先让“公共层知道得更少”,而不是让“上层为了复用被迫知道更多”。 + +### 5.4 补注释 + +必须写清楚: + +1. 这个通用能力负责什么。 +2. 不负责什么。 +3. 为什么它适合抽到公共层。 +4. 失败时由谁兜底。 + +### 5.5 补测试 + +至少覆盖: + +1. 正常路径。 +2. 关键边界。 +3. 明确的失败路径。 + +如果迁移期暂时没法完整补齐,也要优先保证公共函数本身有最小回归测试。 + +### 5.6 更新本文档 + +只要出现以下任一情况,必须同步更新本文档: + +1. 新增了一个通用能力。 +2. 调整了某个通用能力的落点目录。 +3. 修改了某个公共接口的职责边界。 +4. 删掉了某个旧的公共实现,并由新实现替代。 + +## 6. 推荐接入模板 + +可以按下面这个思路接入: + +1. 先在技能代码里识别重复片段。 +2. 提炼出“最小公共函数 / 最小公共结构体 / 最小公共接口”。 +3. 放进 `router / llm / stream / shared` 之一。 +4. 先让新技能接这个公共实现。 +5. 再逐步回收旧技能里重复的老代码。 +6. 最后补本文档,说明这个能力现在归谁管、上层该怎么用。 + +## 7. 当前维护要求 + +1. `agent2` 的公共层要尽量保持“低耦合、强注释、易迁移”。 +2. 新技能开发时,优先复用这里已有的公共能力,而不是直接复制旧技能代码。 +3. 如果发现某段逻辑已经出现第二份实现,应优先评估抽公共层,而不是继续写第三份。 +4. 后续只要扩展通用能力,必须同步更新本文档,否则视为迁移或重构未完成。 diff --git a/backend/logic/refine_compound_ops.go b/backend/logic/refine_compound_ops.go index 73e2d69..40c75fd 100644 --- a/backend/logic/refine_compound_ops.go +++ b/backend/logic/refine_compound_ops.go @@ -158,11 +158,12 @@ func PlanMinContextSwitchMoves(tasks []RefineTaskCandidate, slots []RefineSlotCa Tasks []RefineTaskCandidate MinRank int } + groupingKeys := buildMinContextGroupingKeys(normalizedTasks) groupMap := make(map[string]*taskGroup) groupOrder := make([]string, 0, len(normalizedTasks)) for _, task := range normalizedTasks { - key := normalizeContextKey(task.ContextTag) + key := groupingKeys[task.TaskItemID] group, exists := groupMap[key] if !exists { group = &taskGroup{ @@ -341,6 +342,105 @@ func normalizeContextKey(tag string) string { return text } +// buildMinContextGroupingKeys 为 MinContextSwitch 生成“实际用于聚类”的分组键。 +// +// 步骤化说明: +// 1. 先优先使用现有 ContextTag,避免影响已稳定的显式标签链路; +// 2. 若整批任务只剩一个粗粒度标签(例如全是 General/High-Logic),说明标签对“同科目连续”帮助不足; +// 3. 此时再基于任务名做学科关键词兜底,只在确实能拉开分组时启用; +// 4. 若任务名也无法识别,则继续回落到原 ContextTag,保证行为可预测。 +func buildMinContextGroupingKeys(tasks []RefineTaskCandidate) map[int]string { + keys := make(map[int]string, len(tasks)) + distinctExplicit := make(map[string]struct{}, len(tasks)) + distinctNonCoarse := make(map[string]struct{}, len(tasks)) + + for _, task := range tasks { + key := normalizeContextKey(task.ContextTag) + keys[task.TaskItemID] = key + distinctExplicit[key] = struct{}{} + if !isCoarseContextKey(key) { + distinctNonCoarse[key] = struct{}{} + } + } + + // 1. 当显式标签已经至少区分出两类“非粗标签”时,直接尊重上游语义; + // 2. 避免把已稳定的 context_tag 分组再改写成名称启发式结果。 + if len(distinctNonCoarse) >= 2 { + return keys + } + // 1. 若显式标签本来就有 2 类及以上,且不全是粗标签,也继续沿用; + // 2. 只有“整批退化到同一个粗标签”时,才值得尝试名称兜底。 + if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 { + return keys + } + + inferredKeys := make(map[int]string, len(tasks)) + distinctInferred := make(map[string]struct{}, len(tasks)) + for _, task := range tasks { + inferred := inferSubjectContextKeyFromTaskName(task.Name) + if inferred == "" { + inferred = keys[task.TaskItemID] + } + inferredKeys[task.TaskItemID] = inferred + distinctInferred[inferred] = struct{}{} + } + if len(distinctInferred) >= 2 { + return inferredKeys + } + return keys +} + +func isCoarseContextKey(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "", "general", "high-logic", "high_logic", "memory", "review": + return true + default: + return false + } +} + +func inferSubjectContextKeyFromTaskName(name string) string { + text := strings.ToLower(strings.TrimSpace(name)) + if text == "" { + return "" + } + + subjectKeywordGroups := []struct { + keywords []string + groupKey string + }{ + { + keywords: []string{ + "概率", "随机事件", "随机变量", "条件概率", "全概率", "贝叶斯", + "分布", "大数定律", "中心极限定理", "参数估计", "期望", "方差", "协方差", "相关系数", + }, + groupKey: "subject:probability", + }, + { + keywords: []string{ + "数制", "码制", "逻辑代数", "逻辑函数", "卡诺图", "译码器", "编码器", + "数据选择器", "触发器", "时序电路", "状态图", "状态化简", "计数器", "寄存器", "数电", + }, + groupKey: "subject:digital_logic", + }, + { + keywords: []string{ + "命题逻辑", "谓词逻辑", "量词", "等值演算", "集合", "关系", "函数", + "图论", "欧拉回路", "哈密顿", "生成树", "离散", "组合数学", "容斥", "递推", + }, + groupKey: "subject:discrete_math", + }, + } + for _, group := range subjectKeywordGroups { + for _, keyword := range group.keywords { + if strings.Contains(text, keyword) { + return group.groupKey + } + } + } + return "" +} + func composeDayKey(week, day int) string { return fmt.Sprintf("%d-%d", week, day) } diff --git a/backend/logic/refine_compound_ops_test.go b/backend/logic/refine_compound_ops_test.go index a28535a..1c45ffa 100644 --- a/backend/logic/refine_compound_ops_test.go +++ b/backend/logic/refine_compound_ops_test.go @@ -81,6 +81,42 @@ func TestPlanMinContextSwitchMovesGroupsSameContext(t *testing.T) { } } +func TestPlanMinContextSwitchMovesFallsBackToTaskNameWhenAllGeneral(t *testing.T) { + tasks := []RefineTaskCandidate{ + {TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Name: "随机事件与概率基础概念复习", ContextTag: "General", OriginRank: 1}, + {TaskItemID: 302, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Name: "数制、码制与逻辑代数基础", ContextTag: "General", OriginRank: 2}, + {TaskItemID: 303, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Name: "第二章 条件概率与全概率公式", ContextTag: "General", OriginRank: 3}, + } + slots := []RefineSlotCandidate{ + {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, + {Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4}, + {Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6}, + } + moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{}) + if err != nil { + t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err) + } + if len(moves) != 3 { + t.Fatalf("期望移动 3 条,实际=%d", len(moves)) + } + + sort.SliceStable(moves, func(i, j int) bool { + if moves[i].ToWeek != moves[j].ToWeek { + return moves[i].ToWeek < moves[j].ToWeek + } + if moves[i].ToDay != moves[j].ToDay { + return moves[i].ToDay < moves[j].ToDay + } + return moves[i].ToSectionFrom < moves[j].ToSectionFrom + }) + if moves[0].TaskItemID != 301 || moves[1].TaskItemID != 303 { + t.Fatalf("期望概率任务通过名称兜底连续聚类,实际=%+v", moves) + } + if moves[2].TaskItemID != 302 { + t.Fatalf("期望数电任务落在最后一个坑位,实际=%+v", moves[2]) + } +} + func TestPlanEvenSpreadMovesReturnsErrorWhenSpanNotMatched(t *testing.T) { tasks := []RefineTaskCandidate{ {TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 3, OriginRank: 1}, // span=3 diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index 343ad28..1836c62 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -6,8 +6,8 @@ import ( "strings" "time" - "github.com/LoveLosita/smartflow/backend/agent/chat" - "github.com/LoveLosita/smartflow/backend/agent/route" + agentchat "github.com/LoveLosita/smartflow/backend/agent2/chat" + agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router" "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/dao" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" @@ -164,7 +164,7 @@ func (s *AgentService) runNormalChatFlow( // 3. 计算本次请求可用的历史 token 预算,并执行历史裁剪。 // 这样可以在上下文增长时稳定控制模型窗口,避免超长上下文引发报错或高延迟。 - historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, chat.SystemPrompt, userMessage) + historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, agentchat.SystemPrompt, userMessage) trimmedHistory, totalHistoryTokens, keptHistoryTokens, droppedCount := pkg.TrimHistoryByTokenBudget(chatHistory, historyBudget) chatHistory = trimmedHistory @@ -192,7 +192,7 @@ func (s *AgentService) runNormalChatFlow( // 6. 执行真正的流式聊天。 // fullText 用于后续写 Redis/持久化,outChan 用于把流片段实时推给前端。 - fullText, streamUsage, streamErr := chat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart) + fullText, streamUsage, streamErr := agentchat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart) if streamErr != nil { pushErrNonBlocking(errChan, streamErr) return @@ -321,7 +321,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin } // 3.2 chat:直接走普通聊天主链路。 - if routing.Action == route.ActionChat { + if routing.Action == agentrouter.ActionChat { s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan) return } @@ -331,7 +331,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin progress.Emit("request.accepted", routing.Detail) // 3.4 quick_note_create:执行随口记 graph。 - if routing.Action == route.ActionQuickNoteCreate { + if routing.Action == agentrouter.ActionQuickNoteCreate { quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph( requestCtx, selectedModel, @@ -371,7 +371,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin } // 3.5 task_query:执行任务查询 tool-calling。 - if routing.Action == route.ActionTaskQuery { + if routing.Action == agentrouter.ActionTaskQuery { reply, queryErr := s.runTaskQueryFlow(requestCtx, selectedModel, userMessage, userID, progress.Emit) if queryErr != nil { // 3.5.1 任务查询失败时回退普通聊天,避免请求直接中断。 @@ -393,7 +393,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin } // 3.6 schedule_plan:执行智能排程 graph。 - if routing.Action == route.ActionSchedulePlanCreate { + if routing.Action == agentrouter.ActionSchedulePlanCreate { reply, planErr := s.runSchedulePlanFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, extra, progress.Emit, outChan, resolvedModelName) if planErr != nil { log.Printf("智能排程 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, planErr) @@ -413,7 +413,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin } // 3.7 schedule_plan_refine:执行“连续微调排程”graph。 - if routing.Action == route.ActionSchedulePlanRefine { + if routing.Action == agentrouter.ActionSchedulePlanRefine { reply, refineErr := s.runScheduleRefineFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, progress.Emit, outChan, resolvedModelName) if refineErr != nil { // 连续微调失败不再回落普通聊天,直接上报错误。 diff --git a/backend/service/agentsvc/agent_quick_note.go b/backend/service/agentsvc/agent_quick_note.go index 9859bc3..e5cd608 100644 --- a/backend/service/agentsvc/agent_quick_note.go +++ b/backend/service/agentsvc/agent_quick_note.go @@ -7,20 +7,21 @@ import ( "strings" "time" - "github.com/LoveLosita/smartflow/backend/agent/chat" - "github.com/LoveLosita/smartflow/backend/agent/quicknote" - "github.com/LoveLosita/smartflow/backend/agent/route" + 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" "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" "github.com/google/uuid" - arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" ) // quickNoteRoutingDecision 只是路由层结果的本地别名。 // 保留这个别名是为了尽量少改调用侧(agent.go 中的字段访问保持不变)。 -type quickNoteRoutingDecision = route.RoutingDecision +type quickNoteRoutingDecision = agentrouter.RoutingDecision // quickNoteProgressEmitter 负责把“链路阶段状态”伪装成 OpenAI 兼容的 reasoning_content chunk。 // 设计目标: @@ -69,24 +70,17 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) { return } - reasoning := fmt.Sprintf("阶段:%s", stage) - if detail != "" { - reasoning += "\n" + detail - } - // 2.1 每条阶段消息末尾补双换行,避免客户端把多条 chunk 紧贴在同一行显示。 - // 这里统一在 emitter 层处理,所有接入 emitStage 的链路都会受益。 - reasoning += "\n\n" - - // 3. 复用 OpenAI 兼容封装:把阶段文本伪装成 reasoning_content。 - chunk, err := chat.ToOpenAIStream(&schema.Message{ReasoningContent: reasoning}, e.requestID, e.modelName, e.created, false) + // 3. 调用目的:阶段提示统一走 agent2/stream 的 reasoning chunk 包装, + // 避免 service 层继续自己拼 OpenAI 兼容 JSON。 + err := agentstream.EmitStageAsReasoning(func(payload string) error { + e.outChan <- payload + return nil + }, e.requestID, e.modelName, e.created, stage, detail, false) if err != nil { // 3.1 阶段推送失败不应影响主链路,只打日志即可。 log.Printf("输出随口记阶段状态失败 stage=%s err=%v", stage, err) return } - if chunk != "" { - e.outChan <- chunk - } } // tryHandleQuickNoteWithGraph 尝试用“随口记 graph”处理本次用户输入。 @@ -103,28 +97,28 @@ func (s *AgentService) tryHandleQuickNoteWithGraph( traceID string, trustRoute bool, emitStage func(stage, detail string), -) (handled bool, state *quicknote.QuickNoteState, err error) { +) (handled bool, state *agentmodel.QuickNoteState, err error) { // 1. 依赖预检:taskRepo 或模型未注入时,不做随口记处理,交给上层回落聊天。 if s.taskRepo == nil || selectedModel == nil { return false, nil, nil } // 2. 初始化随口记状态对象(贯穿 graph 全流程的共享上下文)。 - state = quicknote.NewQuickNoteState(traceID, userID, chatID, userMessage) + state = agentmodel.NewQuickNoteState(traceID, userID, chatID, userMessage) // 3. 执行 quick note graph。 // 本次依赖注入了两个“工具能力”: // 3.1 ResolveUserID:从当前请求上下文确定 user_id; // 3.2 CreateTask:真正执行任务写库。 - finalState, runErr := quicknote.RunQuickNoteGraph(ctx, quicknote.QuickNoteGraphRunInput{ + finalState, runErr := agentgraph.RunQuickNoteGraph(ctx, agentnode.QuickNoteGraphRunInput{ Model: selectedModel, State: state, - Deps: quicknote.QuickNoteToolDeps{ + Deps: agentnode.QuickNoteToolDeps{ ResolveUserID: func(ctx context.Context) (int, error) { // 当前链路 userID 已由上层鉴权拿到,这里直接复用。 return userID, nil }, - CreateTask: func(ctx context.Context, req quicknote.QuickNoteCreateTaskRequest) (*quicknote.QuickNoteCreateTaskResult, error) { + CreateTask: func(ctx context.Context, req agentnode.QuickNoteCreateTaskRequest) (*agentnode.QuickNoteCreateTaskResult, error) { // 3.2.1 把 quick note 的工具入参映射成项目 Task 模型。 taskModel := &model.Task{ UserID: req.UserID, @@ -142,7 +136,7 @@ func (s *AgentService) tryHandleQuickNoteWithGraph( } // 3.2.3 把写库结果回填给 graph 状态,用于后续回复拼装。 - return &quicknote.QuickNoteCreateTaskResult{ + return &agentnode.QuickNoteCreateTaskResult{ TaskID: created.ID, Title: created.Title, PriorityGroup: created.Priority, @@ -179,23 +173,17 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin requestID := "chatcmpl-" + uuid.NewString() created := time.Now().Unix() - // 2. 正文 chunk(完整一次性输出,不做人为拆片)。 - chunk, err := chat.ToOpenAIStream(&schema.Message{Role: schema.Assistant, Content: reply}, requestID, modelName, created, true) - if err != nil { + emit := func(payload string) error { + outChan <- payload + return nil + } + if err := agentstream.EmitAssistantReply(emit, requestID, modelName, created, reply, true); err != nil { return err } - if chunk != "" { - outChan <- chunk - } - - // 3. 按 OpenAI 风格补 finish chunk + [DONE],确保客户端可正确收尾。 - finishChunk, err := chat.ToOpenAIFinishStream(requestID, modelName, created) - if err != nil { + if err := agentstream.EmitFinish(emit, requestID, modelName, created); err != nil { return err } - outChan <- finishChunk - outChan <- "[DONE]" - return nil + return agentstream.EmitDone(emit) } // buildQuickNoteFinalReply 生成最终的一次性正文回复。 @@ -203,7 +191,7 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin // 1) 任务事实(标题/优先级/截止时间)由后端拼接,确保准确; // 2) 轻松跟进句交给 AI 生成,贴合用户话题; // 3) AI 生成失败时自动降级为固定友好文案,保证稳定可用。 -func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *quicknote.QuickNoteState) string { +func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *agentmodel.QuickNoteState) string { // 1. 极端兜底:状态为空时给出稳定失败文案,避免返回空字符串。 if state == nil { return "我这次没成功记上,别急,再发我一次我马上补上。" @@ -218,8 +206,8 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, } priorityText := "已安排优先级" - if quicknote.IsValidTaskPriority(state.ExtractedPriority) { - priorityText = fmt.Sprintf("优先级:%s", quicknote.PriorityLabelCN(state.ExtractedPriority)) + if agentmodel.IsValidTaskPriority(state.ExtractedPriority) { + priorityText = fmt.Sprintf("优先级:%s", agentmodel.PriorityLabelCN(state.ExtractedPriority)) } deadlineText := "" @@ -239,7 +227,7 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, } // 2.3 兜底生成轻松跟进句;失败则降级固定文案,确保体验连续。 - banter, err := generateQuickNoteBanter(ctx, selectedModel, userMessage, title, priorityText, deadlineText) + banter, err := agentllm.GenerateQuickNoteBanter(ctx, selectedModel, userMessage, title, priorityText, deadlineText) if err != nil { return factLine + " 这下可以先安心推进,不用等 ddl 来敲门了。" } @@ -262,81 +250,13 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, return "这次没成功写入任务,我没跑路,再给我一次我就把它稳稳记上。" } -// generateQuickNoteBanter 让模型根据用户原话生成一条“贴题轻松句”。 -// 约束: -// 1) 只生成跟进语气,不承担事实表达; -// 2) 不得改动任务事实; -// 3) 输出控制在一句,方便直接拼接在事实句后。 -func generateQuickNoteBanter( - ctx context.Context, - selectedModel *ark.ChatModel, - userMessage string, - title string, - priorityText string, - deadlineText string, -) (string, error) { - // 1. 模型防御校验。 - if selectedModel == nil { - return "", fmt.Errorf("model is nil") - } - - // 2. 把事实信息显式塞入 prompt,约束模型只能“润色语气”。 - prompt := fmt.Sprintf(`用户原话:%s -已确认事实: -- 任务标题:%s -- %s -- %s - -请输出一句轻松自然的跟进话术(仅一句)。`, - strings.TrimSpace(userMessage), - strings.TrimSpace(title), - strings.TrimSpace(priorityText), - strings.TrimSpace(deadlineText), - ) - - // 3. 构造消息: - // - system:定义输出边界(一句话、不改事实); - // - user:提供本次上下文素材。 - messages := []*schema.Message{ - schema.SystemMessage(quicknote.QuickNoteReplyBanterPrompt), - schema.UserMessage(prompt), - } - - // 4. 调用模型生成 banter,并显式关闭 thinking,减少额外延迟。 - resp, err := selectedModel.Generate(ctx, messages, - ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}), - einoModel.WithTemperature(0.7), - einoModel.WithMaxTokens(72), - ) - if err != nil { - return "", err - } - if resp == nil { - return "", fmt.Errorf("empty response") - } - - // 5. 输出清洗: - // 5.1 去首尾空白与引号; - // 5.2 若模型多行输出,只取第一行; - // 5.3 最终为空则视为失败,让上层走降级文案。 - text := strings.TrimSpace(resp.Content) - text = strings.Trim(text, "\"'“”‘’") - if text == "" { - return "", fmt.Errorf("empty content") - } - if idx := strings.Index(text, "\n"); idx >= 0 { - text = strings.TrimSpace(text[:idx]) - } - return text, nil -} - // decideQuickNoteRouting 决定当前输入是否进入“随口记 graph”。 // 该函数只是服务层薄封装,具体控制码解析逻辑已下沉到 agent/route 包。 func (s *AgentService) decideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) quickNoteRoutingDecision { // 这里保留方法是为了让 AgentService 对外语义完整, // 同时避免上层调用方直接依赖 route 包,降低耦合。 _ = s - return route.DecideQuickNoteRouting(ctx, selectedModel, userMessage) + return agentrouter.DecideQuickNoteRouting(ctx, selectedModel, userMessage) } // persistChatAfterReply 在“随口记 graph”返回后,复用当前项目的后置持久化策略: diff --git a/backend/service/agentsvc/agent_quick_note_route_test.go b/backend/service/agentsvc/agent_quick_note_route_test.go index e3b74a9..b42acda 100644 --- a/backend/service/agentsvc/agent_quick_note_route_test.go +++ b/backend/service/agentsvc/agent_quick_note_route_test.go @@ -4,28 +4,29 @@ import ( "strings" "testing" - "github.com/LoveLosita/smartflow/backend/agent/quicknote" - "github.com/LoveLosita/smartflow/backend/agent/route" + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router" ) // TestParseQuickNoteRouteControlTag_QuickNote -// 目的:验证模型控制码在 action=quick_note 时可被稳定解析, -// 并且会校验 nonce,避免历史脏内容或伪造片段误命中。 +// 目的: +// 1. 验证旧 quick note 兼容入口仍然可以解析控制码; +// 2. 验证旧 action=quick_note 会被统一映射到新动作 quick_note_create; +// 3. 验证 reason 仍然会被保留下来,方便上层做阶段提示与排障。 func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) { nonce := "abc123nonce" raw := ` 用户明确在请求未来提醒` - decision, err := route.ParseQuickNoteRouteControlTag(raw, nonce) + decision, err := agentrouter.ParseQuickNoteRouteControlTag(raw, nonce) if err != nil { t.Fatalf("解析失败: %v", err) } if decision == nil { t.Fatalf("decision 不应为空") } - // 兼容逻辑:历史 quick_note 会被统一映射到 quick_note_create。 - if decision.Action != route.ActionQuickNoteCreate { - t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionQuickNoteCreate, decision.Action) + if decision.Action != agentrouter.ActionQuickNoteCreate { + t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionQuickNoteCreate, decision.Action) } if strings.TrimSpace(decision.Reason) == "" { t.Fatalf("reason 不应为空") @@ -33,37 +34,40 @@ func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) { } // TestParseRouteControlTag_TaskQuery -// 目的:验证通用分流中 action=task_query 的控制码可稳定解析。 +// 目的:验证通用分流控制码在 action=task_query 时可以被稳定解析。 func TestParseRouteControlTag_TaskQuery(t *testing.T) { nonce := "taskquerynonce" raw := ` 用户在查最紧急任务` - decision, err := route.ParseRouteControlTag(raw, nonce) + decision, err := agentrouter.ParseRouteControlTag(raw, nonce) if err != nil { t.Fatalf("解析失败: %v", err) } if decision == nil { t.Fatalf("decision 不应为空") } - if decision.Action != route.ActionTaskQuery { - t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionTaskQuery, decision.Action) + if decision.Action != agentrouter.ActionTaskQuery { + t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionTaskQuery, decision.Action) } } // TestParseQuickNoteRouteControlTag_NonceMismatch -// 目的:确保 nonce 不匹配时直接报错,避免把非本次请求的控制码当作有效路由。 +// 目的:确保 nonce 不匹配时直接报错,避免把别的请求控制码误判成当前请求。 func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) { raw := `` - if _, err := route.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil { + if _, err := agentrouter.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil { t.Fatalf("期望 nonce 不匹配时报错,但未报错") } } // TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID -// 目的:即使 state.Persisted 被错误置为 true,只要 task_id 无效,也不能返回“安排成功”文案。 +// 目的: +// 1. 即使状态被错误标记为 Persisted=true; +// 2. 只要没有有效 task_id,就不能回成功文案; +// 3. 避免出现“回复成功但库里没数据”的假成功体验。 func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) { - state := &quicknote.QuickNoteState{ + state := &agentmodel.QuickNoteState{ Persisted: true, PersistedTaskID: 0, ExtractedTitle: "去下馆子", @@ -76,9 +80,11 @@ func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) { } // TestBuildQuickNoteFinalReply_UseExtractedBanter -// 目的:当聚合规划阶段已经产出 banter 时,最终回复应直接复用,避免再次调用润色模型。 +// 目的: +// 1. 当聚合规划阶段已经产出 banter 时,最终回复应直接复用; +// 2. 避免为了润色再次调用模型,增加不必要时延。 func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) { - state := &quicknote.QuickNoteState{ + state := &agentmodel.QuickNoteState{ Persisted: true, PersistedTaskID: 12, ExtractedTitle: "明天去取快递", diff --git a/backend/service/agentsvc/agent_route.go b/backend/service/agentsvc/agent_route.go index 249160b..82fc9cb 100644 --- a/backend/service/agentsvc/agent_route.go +++ b/backend/service/agentsvc/agent_route.go @@ -3,7 +3,7 @@ package agentsvc import ( "context" - "github.com/LoveLosita/smartflow/backend/agent/route" + agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router" "github.com/cloudwego/eino-ext/components/model/ark" ) @@ -12,7 +12,7 @@ import ( // 设计目的: // 1. 让 AgentService 对 route 包保持“最小接触面”; // 2. 后续若 route 包返回结构调整,只需改这个桥接文件。 -type actionRoutingDecision = route.RoutingDecision +type actionRoutingDecision = agentrouter.RoutingDecision // decideActionRouting 决定当前请求走向哪条业务链路。 // @@ -23,5 +23,5 @@ type actionRoutingDecision = route.RoutingDecision func (s *AgentService) decideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) actionRoutingDecision { // 这里保留方法封装,是为了避免上层直接依赖 route 包,降低耦合。 _ = s - return route.DecideActionRouting(ctx, selectedModel, userMessage) + return agentrouter.DecideActionRouting(ctx, selectedModel, userMessage) } diff --git a/backend/service/agentsvc/agent_schedule_refine.go b/backend/service/agentsvc/agent_schedule_refine.go index e7cf8a1..fb9de14 100644 --- a/backend/service/agentsvc/agent_schedule_refine.go +++ b/backend/service/agentsvc/agent_schedule_refine.go @@ -66,8 +66,12 @@ func (s *AgentService) runScheduleRefineFlow( // 4. 调用目的: // 4.1 saveSchedulePlanPreview 目前是“预览缓存 + MySQL 快照”的统一写入口; // 4.2 这里把 refine state 映射为 scheduleplan state,复用已有落盘链路; - // 4.3 这样可以保证 create/refine 两条链路写入口径一致,便于后续统一维护。 - s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState)) + // 4.3 但若是“独立复合分支已出站、终审仍失败”,则不覆盖上一版预览,避免外部误以为新方案已验证通过。 + if shouldPersistScheduleRefinePreview(finalState) { + s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState)) + } else { + emitStage("schedule_refine.preview.skipped", "复合分支终审未通过,本轮结果不覆盖上一版预览。") + } reply := strings.TrimSpace(finalState.FinalSummary) if reply == "" { @@ -152,3 +156,19 @@ func convertRefineStateToPlanState(st *schedulerefine.ScheduleRefineState) *sche Completed: st.Completed, } } + +// shouldPersistScheduleRefinePreview 判断“本轮微调结果是否应覆盖上一版预览”。 +// +// 职责边界: +// 1. 默认沿用原有 refine 持久化策略,保证普通 ReAct 微调链路不受影响; +// 2. 仅当“独立复合分支已直接出站,但终审未通过”时,拒绝覆盖上一版预览; +// 3. 这样可以避免外层把未经验证的复合结果当成新的基线继续滚动微调。 +func shouldPersistScheduleRefinePreview(st *schedulerefine.ScheduleRefineState) bool { + if st == nil { + return false + } + if st.CompositeRouteSucceeded && !schedulerefine.FinalHardCheckPassed(st) { + return false + } + return true +} diff --git a/backend/service/agentsvc/agent_schedule_refine_test.go b/backend/service/agentsvc/agent_schedule_refine_test.go new file mode 100644 index 0000000..b50f9ce --- /dev/null +++ b/backend/service/agentsvc/agent_schedule_refine_test.go @@ -0,0 +1,52 @@ +package agentsvc + +import ( + "testing" + + "github.com/LoveLosita/smartflow/backend/agent/schedulerefine" +) + +func TestShouldPersistScheduleRefinePreviewSkipsFailedCompositeRoute(t *testing.T) { + st := &schedulerefine.ScheduleRefineState{ + CompositeRouteSucceeded: true, + HardCheck: schedulerefine.HardCheckReport{ + PhysicsPassed: true, + OrderPassed: true, + IntentPassed: false, + }, + } + + if shouldPersistScheduleRefinePreview(st) { + t.Fatalf("期望复合分支终审失败时不覆盖上一版预览") + } +} + +func TestShouldPersistScheduleRefinePreviewAllowsPassedCompositeRoute(t *testing.T) { + st := &schedulerefine.ScheduleRefineState{ + CompositeRouteSucceeded: true, + HardCheck: schedulerefine.HardCheckReport{ + PhysicsPassed: true, + OrderPassed: true, + IntentPassed: true, + }, + } + + if !shouldPersistScheduleRefinePreview(st) { + t.Fatalf("期望复合分支终审通过时允许覆盖预览") + } +} + +func TestShouldPersistScheduleRefinePreviewKeepsReactPathBehavior(t *testing.T) { + st := &schedulerefine.ScheduleRefineState{ + CompositeRouteSucceeded: false, + HardCheck: schedulerefine.HardCheckReport{ + PhysicsPassed: true, + OrderPassed: true, + IntentPassed: false, + }, + } + + if !shouldPersistScheduleRefinePreview(st) { + t.Fatalf("期望非复合直出分支继续沿用原有预览持久化策略") + } +}