From a5f56595e91564fbbb24e45899f432a14ba0633c Mon Sep 17 00:00:00 2001 From: wang Date: Tue, 24 Mar 2026 22:50:57 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/casbin/casbin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/casbin/casbin.go b/pkg/casbin/casbin.go index 744b250..3c8cab5 100644 --- a/pkg/casbin/casbin.go +++ b/pkg/casbin/casbin.go @@ -22,7 +22,7 @@ const ( type Permission struct { Subject string // 权限主体(如用户ID@组织ID) Object string // 权限对象(如资源ID) - Action string // 权限动作(如访问、读取、操作) + Action string // 权限动作(如访问,读取,操作) } type PolicySnapshot struct { From ea3648acaea23e86b973a55a72f8d729f6592ae5 Mon Sep 17 00:00:00 2001 From: wang Date: Fri, 3 Apr 2026 15:18:24 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=A7=84=E5=88=99-?= =?UTF-8?q?=E6=96=B0=E5=A2=9Eplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../personal-assistant-go-backend/SKILL.md | 2 + .../references/project-rules.md | 24 + AGENTS.md | 24 + ...76\350\256\241\346\226\271\346\241\210.md" | 405 ++++++++ ...46\344\271\240\346\200\273\347\272\262.md" | 148 +++ ...72\347\241\200\347\237\245\350\257\206.md" | 456 +++++++++ .../01-ChatModel\345\222\214Message.md" | 272 ++++++ ...le\345\244\232\350\275\256\357\274\211.md" | 881 +++++++++++++++++ ...26\345\257\271\350\257\235\357\274\211.md" | 747 ++++++++++++++ ...73\347\273\237\350\256\277\351\227\256.md" | 463 +++++++++ ...57\350\247\202\346\265\213\346\200\247.md" | 909 ++++++++++++++++++ ...63\347\256\200\345\215\225\344\272\206.md" | 462 +++++++++ ...46\344\270\262\346\213\274\346\216\245.md" | 353 +++++++ ...63\344\272\206\344\273\200\344\271\210.md" | 517 ++++++++++ ...5\243\347\234\213\346\207\202ToolsNode.md" | 591 ++++++++++++ ...\255\243\347\234\213\346\207\202Parser.md" | 430 +++++++++ ...6\255\243\347\234\213\346\207\202Store.md" | 661 +++++++++++++ ...55\243\347\234\213\346\207\202Retrieve.md" | 730 ++++++++++++++ ...\210Chain\344\270\216Graph\357\274\211.md" | 709 ++++++++++++++ ...77\230\351\234\200\350\246\201Workflow.md" | 731 ++++++++++++++ ...nt\344\270\200\346\212\212\346\242\255.md" | 706 ++++++++++++++ ...271\210\346\230\257EinoADK\357\274\237.md" | 698 ++++++++++++++ ...31\345\261\202\346\212\275\350\261\241.md" | 724 ++++++++++++++ ...06\350\247\243\345\220\227\357\274\237.md" | 759 +++++++++++++++ ...21\345\270\203\350\256\276\347\275\256.md" | 71 ++ ...42\350\257\225\351\200\237\350\247\210.md" | 36 + ...66\346\256\265\350\277\233\351\230\266.md" | 16 +- plan/README.md | 43 + plan/cross-module/.gitkeep | 1 + 29 files changed, 12562 insertions(+), 7 deletions(-) create mode 100644 "docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/00-\345\255\246\344\271\240\346\200\273\347\272\262.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/01-\345\211\215\347\275\256\345\237\272\347\241\200/01-\345\255\246\344\271\240AI\345\211\215\351\234\200\345\205\267\345\244\207\347\232\204\345\237\272\347\241\200\347\237\245\350\257\206.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/01-ChatModel\345\222\214Message.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/02-ChatModelAgent\343\200\201Runner\343\200\201AgentEvent\357\274\210Console\345\244\232\350\275\256\357\274\211.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/03-Memory\344\270\216Session\357\274\210\346\214\201\344\271\205\345\214\226\345\257\271\350\257\235\357\274\211.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/04-Tool\345\222\214\346\226\207\344\273\266\347\263\273\347\273\237\350\256\277\351\227\256.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/05-Callback\343\200\201Trace\345\222\214\347\224\237\344\272\247\347\272\247\345\217\257\350\247\202\346\265\213\346\200\247.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/01-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\346\212\212ChatModel\346\203\263\347\256\200\345\215\225\344\272\206.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/02-ChatTemplate\344\270\272\344\273\200\344\271\210\344\270\215\346\230\257\345\255\227\347\254\246\344\270\262\346\213\274\346\216\245.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/03-Embedding\345\210\260\345\272\225\350\247\243\345\206\263\344\272\206\344\273\200\344\271\210.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/04-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\345\206\231Tool\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202ToolsNode.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/05-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250DocumentLoader\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Parser.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/06-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250Indexer\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Store.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/07-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250Retriever\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Retrieve.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/01-\344\270\200\346\226\207\350\256\262\351\200\217\347\274\226\346\216\222\357\274\210Chain\344\270\216Graph\357\274\211.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/02-\346\227\242\347\204\266\346\234\211\344\272\206Chain\343\200\201Graph\357\274\214\344\270\272\344\275\225\350\277\230\351\234\200\350\246\201Workflow.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/03-\344\273\216\350\207\252\345\212\250\346\211\247\350\241\214\345\210\260\344\272\272\345\267\245\346\216\245\347\256\241\357\274\214\345\246\202\344\275\225\351\201\277\345\205\215Agent\344\270\200\346\212\212\346\242\255.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/01-\344\273\200\344\271\210\346\230\257EinoADK\357\274\237.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/02-\344\270\272\344\273\200\344\271\210\344\270\200\345\256\232\350\246\201\346\234\211Agent\350\277\231\345\261\202\346\212\275\350\261\241.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/03-\344\275\240\345\257\271ChatModelAgent\346\234\211\344\272\206\350\247\243\345\220\227\357\274\237.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/\344\273\223\345\272\223\345\217\221\345\270\203\350\256\276\347\275\256.md" create mode 100644 "docs/csdn/AI-Go-Eino-study/\351\235\242\350\257\225\351\200\237\350\247\210.md" create mode 100644 plan/README.md create mode 100644 plan/cross-module/.gitkeep diff --git a/.codex/skills/personal-assistant-go-backend/SKILL.md b/.codex/skills/personal-assistant-go-backend/SKILL.md index c8bfeaa..093a648 100644 --- a/.codex/skills/personal-assistant-go-backend/SKILL.md +++ b/.codex/skills/personal-assistant-go-backend/SKILL.md @@ -11,6 +11,8 @@ description: 用于 personal_assistant 仓库的 Go 后端开发、重构、代 先阅读 `references/project-rules.md` 作为权威规则,再按下方流程选择执行路径。 +任何执行型任务在进入实现流程前,先按项目根目录 `plan/README.md` 生成 `plan//pending-.md`,待用户明确确认后再执行。 + 做权限相关重构时,先检查是否越过 `AuthorizationService` 或直接触碰 `Enforcer`;若有,优先收口,再继续功能改动。 ## 工作流决策树 diff --git a/.codex/skills/personal-assistant-go-backend/references/project-rules.md b/.codex/skills/personal-assistant-go-backend/references/project-rules.md index 382aa04..869d19f 100644 --- a/.codex/skills/personal-assistant-go-backend/references/project-rules.md +++ b/.codex/skills/personal-assistant-go-backend/references/project-rules.md @@ -125,6 +125,30 @@ - `role-menu / role-api / role-capability / menu-api` 变更统一写 DB + outbox,不允许在业务 Service 内直接全量刷新 Casbin。 - `user-org-role / 成员状态` 变更允许同步收口当前主体投影,但仍必须补发异步修复事件。 +## 计划落盘规则 + +- 只要任务属于新增、重构、修复、联调、排障、迁移、删除、配置调整这类执行型工作,先写计划,不直接改代码。 +- 计划文件固定写到 `plan//pending-.md`。 +- 结构名固定使用英文:根目录为 `plan/`,跨模块目录为 `plan/cross-module/`,状态前缀为 `pending-` 和 `approved-`。 +- `` 和 `` 按语义决定中英文:稳定技术名词优先英文,如 `auth`、`permission`;更自然的业务表达可保留中文,如 `组织`、`菜单权限收口`。 +- 纯问答、纯解释、纯代码审查、纯只读排查,不强制生成计划文件。 +- 计划目录规则以项目根目录 `plan/README.md` 为准。 + +## 计划命名规则 + +- 文件名格式固定为 `pending-.md` 或 `approved-.md`。 +- `` 必须直接体现本次执行目标,可用英文技术短语,也可用中文业务短语,但都不能空泛。 +- 合格示例:`pending-login-auth-refactor.md`、`pending-菜单权限收口.md`、`pending-组织权限联调.md`。 +- 后续若用户直接说“先出计划”,默认先写入对应模块目录下的 `pending-.md`,无需额外指定路径。 + +## 审查后执行规则 + +- 生成待审计划后,只允许查代码、读文档、跑非修改型检查;未获明确确认前,不允许实施改动。 +- 用户明确确认后,先将计划文件改名为 `approved-.md`,再按计划执行。 +- 执行前需要在对话中回报计划路径和摘要,供用户审查。 +- 若执行中发现范围明显变化,禁止静默扩项,必须重新生成新的 `pending-.md` 给用户复审。 +- 涉及路由、服务、配置、权限或跨模块联调的执行任务,同样必须先经过待审计划流程。 + ## 提问引用规范 在实现或评审过程中需要向用户澄清时,追加一行: diff --git a/AGENTS.md b/AGENTS.md index 8bdf132..3a37485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,30 @@ - `role-menu / role-api / role-capability / menu-api` 变更统一写 DB + outbox,不允许在业务 Service 内直接全量刷新 Casbin。 - `user-org-role / 成员状态` 变更允许同步收口当前主体投影,但仍必须补发异步修复事件。 +## 计划落盘规则 + +- 只要任务属于新增、重构、修复、联调、排障、迁移、删除、配置调整这类执行型工作,先写计划,不直接改代码。 +- 计划文件固定写到 `plan//pending-.md`。 +- 结构名固定使用英文:根目录为 `plan/`,跨模块目录为 `plan/cross-module/`,状态前缀为 `pending-` 和 `approved-`。 +- `` 和 `` 按语义决定中英文:稳定技术名词优先英文,如 `auth`、`permission`;更自然的业务表达可保留中文,如 `组织`、`菜单权限收口`。 +- 纯问答、纯解释、纯代码审查、纯只读排查,不强制生成计划文件。 +- 计划目录规则以 `plan/README.md` 为准。 + +## 计划命名规则 + +- 文件名格式固定为 `pending-.md` 或 `approved-.md`。 +- `` 必须直接体现本次执行目标,可用英文技术短语,也可用中文业务短语,但都不能空泛。 +- 合格示例:`pending-login-auth-refactor.md`、`pending-菜单权限收口.md`、`pending-组织权限联调.md`。 +- 后续若用户直接说“先出计划”,默认先写入对应模块目录下的 `pending-.md`,无需额外指定路径。 + +## 审查后执行规则 + +- 生成待审计划后,只允许查代码、读文档、跑非修改型检查;未获明确确认前,不允许实施改动。 +- 用户明确确认后,先将计划文件改名为 `approved-.md`,再按计划执行。 +- 执行前需要在对话中回报计划路径和摘要,供用户审查。 +- 若执行中发现范围明显变化,禁止静默扩项,必须重新生成新的 `pending-.md` 给用户复审。 +- 涉及路由、服务、配置、权限或跨模块联调的执行任务,同样必须先经过待审计划流程。 + ## 提问引用规则 - 向用户提澄清问题时,在问题后追加一行: diff --git "a/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" new file mode 100644 index 0000000..f262209 --- /dev/null +++ "b/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" @@ -0,0 +1,405 @@ +# AI 助手架构设计方案 + +## 1. 文档定位 + +本文档用于定义 `personal_assistant` 中 AI 助手子域的正式设计方案,目标不是做一个独立的 AI demo,而是在现有业务系统中落地一套可演示、可扩展、可面试表达的 AI 应用能力。 + +本文档主要用于: + +1. 明确 AI 子域为什么放在 `personal_assistant` 内,而不是新开主项目。 +2. 固定 V1 的范围、边界、接口、交互和验收标准。 +3. 为后续实现提供可直接执行的分阶段计划。 +4. 让项目评审时能够同时看到 Go 后端工程能力与 Eino AI 编排能力。 + +适用范围: + +- `go/personal_assistant` +- `personal-assistant-frontend` +- `go/personal_assistant/docs` + +--- + +## 2. 设计结论 + +### 2.1 项目形态 + +AI 功能固定作为 `personal_assistant` 的正式子域落地,不新开独立主项目。 + +原因如下: + +1. AI 助手的核心价值不在“会聊天”,而在“能消费本系统真实业务上下文”。 +2. 当前系统已经具备任务、执行详情、用户状态、组织、权限、排行、项目文档等完整上下文,AI 应直接建立在这些正式能力之上。 +3. 如果单独拆仓,前期会人为切断业务上下文,反而弱化项目表达。 +4. 当前 `crawler` 之所以可独立,是因为它是外部采集基础设施;AI 子域不是基础设施,它是业务能力的上层消费层。 + +### 2.2 面试定位 + +该子域同时服务两类表达: + +1. Go 后端/架构表达:分层、DTO、资源级鉴权、SSE、会话持久化、可观测、与现有系统整合。 +2. AI 应用/Agent 表达:Eino、Tool 调用、Memory、Workflow、ChatModelAgent、结构化输出。 + +V1 不追求一次性把所有 AI 概念堆满,而是先做一条完整且可信的闭环。 + +### 2.3 V1 功能定位 + +V1 固定定位为“业务助手 + 项目说明问答”,支持以下 4 类能力: + +1. 根据当前用户对话,生成本次任务完成情况或任务汇报。 +2. 根据指定任务 / 组织 / 人员范围,生成汇总说明。 +3. 根据用户做题、排名、进度,给出阶段性分析和建议。 +4. 根据正式项目文档,回答“这个项目是什么、怎么使用、有哪些模块”。 + +--- + +## 3. 总体架构 + +### 3.1 架构原则 + +1. AI 只能消费正式业务能力,不能绕过现有业务边界直接散查数据库。 +2. 会话与消息必须持久化,不能只做临时流式输出。 +3. 业务数据权限必须先于模型回答,不能靠提示词“假装隔离”。 +4. 对用户来说,前端必须是正式产品体验,不接受 demo 式页面。 +5. 对面试官来说,系统必须可解释:请求如何进来、工具如何调用、结果如何流回、权限如何生效、数据如何落库。 + +### 3.2 后端分层 + +后端继续沿用当前仓库分层: + +1. `controller` + 负责 HTTP 绑定、SSE 输出、错误返回、上下文提取。 +2. `service` + 负责 AI 编排、鉴权收口、工具调度、会话写入。 +3. `repository` + 负责 AI 会话、消息、可选执行日志的持久化。 +4. `infrastructure` + 负责模型适配器、Eino 组件初始化、文档加载器。 +5. `router` + 负责统一注册 AI 路由,挂入业务分组。 + +### 3.3 前端分层 + +前端固定挂入 `Workbench` 子路由,不新建独立站点。 + +页面结构固定为: + +1. 左侧:会话列表。 +2. 中间:聊天主区域。 +3. 右侧:工具调用轨迹 / 结构化结果。 +4. 移动端:左侧会话折叠,主聊天区全宽。 + +--- + +## 4. 后端方案 + +### 4.1 路由与接口 + +新增接口固定如下: + +1. `POST /ai/conversations` + 创建会话。 +2. `GET /ai/conversations` + 查询当前用户的会话列表。 +3. `GET /ai/conversations/:id/messages` + 查询会话消息历史。 +4. `DELETE /ai/conversations/:id` + 删除会话。 +5. `POST /ai/conversations/:id/stream` + 发送消息并以 `text/event-stream` 流式返回。 + +### 4.2 SSE 事件协议 + +流式事件固定为: + +1. `conversation_started` +2. `assistant_token` +3. `tool_call_started` +4. `tool_call_finished` +5. `structured_block` +6. `message_completed` +7. `error` +8. `done` + +这样前端可以明确区分“生成中”“调用工具中”“已完成”,避免全部混成一坨文本。 + +### 4.3 会话持久化 + +会话真相源固定使用 MySQL: + +1. 会话表:保存用户、标题、当前组织上下文、最后活跃时间。 +2. 消息表:保存消息角色、内容、结构化结果摘要、失败状态。 +3. Redis 只做可选缓存或中间状态,不作为唯一真相源。 + +### 4.4 Agent 设计 + +V1 固定采用“单 `ChatModelAgent` + 外层 Workflow”的形式,不上多 Agent。 + +执行链路固定为: + +1. 读取登录用户与当前组织上下文。 +2. 加载最近 N 轮会话历史。 +3. 分类用户问题类型。 +4. 选择并调用对应 Tool。 +5. 将 Tool 结果组织为自然语言 + 结构化块。 +6. 流式回传。 +7. 写入消息与执行日志。 + +### 4.5 Tool 设计 + +V1 固定实现 4 个 Tool: + +1. `get_my_task_report` + 查询当前用户维度的任务与执行情况。 +2. `get_scoped_task_report` + 查询指定任务 / 组织 / 人员范围内的汇总。 +3. `get_user_progress_insight` + 查询用户刷题、排行、趋势并生成建议。 +4. `search_project_docs` + 查询正式项目说明文档并返回引用内容摘要。 + +所有 Tool 必须通过现有 Service 或新增只读 Facade 调用,不允许直接在 Tool 内部散落 SQL。 + +### 4.6 权限边界 + +权限规则固定如下: + +1. 默认范围为“当前登录用户 + 当前组织上下文”。 +2. 查询其他用户、其他组织、任务管理视角数据时,必须走现有资源级鉴权。 +3. 超级管理员可以跨组织查询,但回答中必须标明 scope。 +4. 文档问答只允许读取正式文档白名单。 + +### 4.7 文档知识源 + +V1 文档问答只纳入以下材料: + +1. `README.md` +2. 正式业务设计文档 +3. API / 架构 / 使用说明类文档 + +明确排除: + +1. `docs/csdn/**` +2. 学习型 Eino 笔记 +3. `下阶段进阶.md` 这类路线草稿 + +原因是这些内容不稳定,不能作为产品事实输出。 + +### 4.8 可观测性 + +AI 子域必须具备可观测能力: + +1. 每次请求带 `request_id`。 +2. 记录模型调用耗时。 +3. 记录 Tool 调用名称、参数摘要、耗时、成功失败。 +4. 支持把一轮问答串进现有 trace 体系。 +5. 异常时可以定位到“用户请求 -> Agent -> Tool -> 业务 Service”。 + +--- + +## 5. 前端方案 + +### 5.1 页面定位 + +AI 页面固定作为 `Workbench` 下的正式页面,例如: + +1. `/console/workbench/task` +2. `/console/workbench/assistant` + +这样可以与任务工作台形成直接关联,强调 AI 是业务增强层,而不是外置插件。 + +### 5.2 用户体验目标 + +前端体验目标固定为“丝滑、稳定、可恢复、可续聊”。 + +必须满足: + +1. 首屏不能白屏等接口。 +2. 发送后立即出现用户消息与“生成中”状态。 +3. 流式输出过程中自动滚动,但用户上滑查看历史时不能强行拉回底部。 +4. 支持停止生成、失败重试、删除会话、切换会话。 +5. 移动端必须可用,不能只适配桌面。 + +### 5.3 聊天区交互 + +聊天输入区固定支持: + +1. `Enter` 发送。 +2. `Shift + Enter` 换行。 +3. 自动增高文本框。 +4. 发送中按钮切换为“停止生成”。 +5. 失败时显示内联重试入口。 + +### 5.4 结构化展示 + +AI 输出不允许全部裸文本化,必须支持结构化卡片: + +1. 任务汇报:摘要、完成率、风险项、建议动作。 +2. 排名分析:当前排名、阶段变化、改进建议。 +3. 项目说明:模块说明、使用路径、关联功能。 +4. 工具轨迹:当前查了什么、完成了什么、耗时多久。 + +### 5.5 空态与引导 + +首屏空态固定展示推荐问题,例如: + +1. “帮我总结最近一次任务完成情况” +2. “统计当前组织最近一个任务的完成情况” +3. “分析我最近刷题状态并给建议” +4. “这个项目主要做什么,怎么使用” + +目的不是做花哨引导,而是降低用户不会问的成本。 + +### 5.6 错误反馈 + +前端错误提示固定采用“页面内联为主、全局 toast 为辅”: + +1. 权限不足:明确说明无权查看该范围。 +2. 会话流断开:提供重试。 +3. Tool 失败:显示“查询过程失败”,而不是简单报错。 +4. 网络异常:允许保留输入内容并再次提交。 + +--- + +## 6. 分阶段实施计划 + +### Phase 1:方案落文档 + +目标: + +1. 完成架构设计文档。 +2. 固定 API、Tool、会话模型、SSE 协议。 +3. 固定前端信息架构和交互标准。 + +验收: + +1. 设计文档可直接指导开发。 +2. 不再需要对“拆不拆项目”“先做什么”反复决策。 + +### Phase 2:后端骨架 + +目标: + +1. 建立 AI 子域路由、DTO、Service、Repository 骨架。 +2. 接入模型配置与 Eino 初始化。 +3. 打通会话创建、历史查询、SSE 空流骨架。 + +验收: + +1. 可以创建会话。 +2. 可以向指定会话发起流式请求。 +3. 可以把消息落库。 + +### Phase 3:Tool 闭环 + +目标: + +1. 接入 4 个 Tool。 +2. 完成权限裁剪。 +3. 让 Agent 能根据问题调用 Tool 并返回结构化结果。 + +验收: + +1. 能回答个人任务汇报。 +2. 能回答组织/任务范围汇总。 +3. 能回答用户进度建议。 +4. 能回答项目说明问答。 + +### Phase 4:前端正式页面 + +目标: + +1. 完成 Workbench 下 AI 助手页面。 +2. 接入会话列表、聊天区、工具轨迹区。 +3. 打通流式渲染与失败恢复。 + +验收: + +1. 可创建、切换、删除、续聊。 +2. 流式体验稳定。 +3. 桌面端与移动端都可用。 + +### Phase 5:可观测与演示打磨 + +目标: + +1. 接入 trace、日志、耗时指标。 +2. 补齐测试。 +3. 编写演示脚本。 + +验收: + +1. 面试时能完整展示一轮请求的链路。 +2. 能清楚讲明分层、Tool、鉴权、SSE、会话持久化和前端交互。 + +--- + +## 7. 测试与验收 + +### 7.1 后端测试 + +1. Tool 参数校验。 +2. 权限拒绝路径。 +3. 会话与消息持久化。 +4. SSE 事件顺序。 +5. Tool 成功 / 失败回包。 +6. 文档白名单过滤。 + +### 7.2 前端测试 + +1. 首屏空态展示。 +2. 发问流式返回。 +3. 会话切换。 +4. 停止生成。 +5. 失败重试。 +6. 移动端抽屉切换。 + +### 7.3 演示验收场景 + +固定以 4 个场景验收: + +1. “帮我总结这次任务完成情况” +2. “汇总某组织某任务的完成情况” +3. “分析某用户最近刷题状态并给建议” +4. “这个项目有什么用,怎么使用” + +--- + +## 8. 关键默认决策 + +1. AI 子域不独立拆仓。 +2. V1 不做 Multi-Agent。 +3. V1 不做向量库和复杂 RAG。 +4. V1 先做业务助手与文档问答。 +5. 前端必须作为正式用户入口设计,不接受 demo 式页面。 +6. 模型供应商可替换,但域层不写死厂商。 +7. 只借鉴成熟项目的产品思路与交互,不整仓翻译其他语言项目。 + +--- + +## 9. 后续扩展方向 + +V2 可以考虑扩展: + +1. Embedding / Retriever / Indexer 接入正式知识库。 +2. 更复杂的 Workflow。 +3. 多 Agent 协作。 +4. 管理员级批量分析视图。 +5. A2UI 或更强的结构化流式组件协议。 + +但这些全部放在 V1 闭环稳定之后,不提前抢跑。 + +--- + +## 10. 总结 + +这次 AI 子域建设的核心,不是“把 Eino 接进来”,而是把现有 `personal_assistant` 的正式业务能力,通过 Tool、Agent、会话、SSE 和前端工作台,组织成一个既能直接面对用户、又能对面试官清楚讲明白的完整系统。 + +V1 的判断非常明确: + +1. 不拆主项目。 +2. 先做单 Agent。 +3. 先做 4 个高价值 Tool。 +4. 先把产品体验做顺。 +5. 先把工程闭环做完整。 + +在这个基础上,再谈更复杂的 Agent 能力,项目才是稳的。 diff --git "a/docs/csdn/AI-Go-Eino-study/00-\345\255\246\344\271\240\346\200\273\347\272\262.md" "b/docs/csdn/AI-Go-Eino-study/00-\345\255\246\344\271\240\346\200\273\347\272\262.md" new file mode 100644 index 0000000..5f2da08 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/00-\345\255\246\344\271\240\346\200\273\347\272\262.md" @@ -0,0 +1,148 @@ +# Go Eino 学习总纲 + +> GitHub 主文:[当前文章](./00-学习总纲.md) +> CSDN 跳转:[Go Eino 学习总纲](https://blog.csdn.net/2302_80067378/category_13132166.html) +> 官方文档:[Eino 快速开始总入口](https://www.cloudwego.io/zh/docs/eino/quick_start/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:把 Quick Start、组件、编排和 ADK 串成一条更适合 Go 工程师的 Eino 学习路径。 +**适合谁看**:想系统学 Eino,但不想完全照官方目录走的 Go 开发者。 +**前置知识**:Go 基础、前置基础篇可选先读 +**对应 Demo**:[首批 5 个可运行 demo](../examples/README.md) + +**面试可讲点** +- 能说明自己不是零散学 API,而是按模型调用、组件、编排、Agent 四层建立知识体系。 +- 能解释为什么把 Middleware、Interrupt/Resume、Skill 等主题放到主线后半段。 + +--- +基于当前 Eino 官方中文文档结构,我更推荐按下面这条主线学习。这个顺序是我整理出来的“推荐学习路径”,不是对官方 Quick Start 的原样照搬;它更适合先建立从 `ChatModel` 到 `Agent / Runner` 的执行骨架,再补状态、工具和可观测性。 + +截至 `2026-03-25`,官方 Quick Start 的完整主线还包括 `第五章:Middleware`、`第七章:Interrupt/Resume`、`第八章:Graph Tool`、`第九章:Skill`、`第十章:A2UI`。我把其中一部分放到了后续阶段或按需选修里,是为了让主线更顺,不代表这些章节不重要。 + +总入口: + +- [Eino 快速开始总入口](https://www.cloudwego.io/zh/docs/eino/quick_start/) + +## 第一阶段:入门必学 + +这一组先建立从 `ChatModel` 到 `Agent / Runner` 的执行骨架,再补 `Memory`、`Tool`、`Callback / Trace`。如果你只想先抓住主线,先把这五篇跑通。 + +1. [第一章:ChatModel 与 Message(Console)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_01_chatmodel_and_message/) +2. [第二章:ChatModelAgent、Runner、AgentEvent(Console 多轮)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_02_chatmodelagent_runner_agentevent/) +3. [第三章:Memory 与 Session(持久化对话)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_03_memory_and_session/) +4. [第四章:Tool 与文件系统访问](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_04_tool_and_filesystem/) +5. [第五章:Callback 与 Trace(可观测性)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_06_callback_and_trace/) + +## 第二阶段:组件核心 + +这一组在“核心模块 -> Components 组件”下面,适合你补齐 `Chat`、`Tool`、`RAG` 这三条能力线。 + +1. [ChatModel 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_model_guide/) +2. [ChatTemplate 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_template_guide/) +3. [ToolsNode&Tool 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/tools_node_guide/) +4. [Document Loader 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/document_loader_guide/) +5. [Embedding 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/embedding_guide/) +6. [Indexer 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/indexer_guide/) +7. [Retriever 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/retriever_guide/) + + +## 第三阶段:编排进阶 + +这一组在“Chain & Graph & Workflow 编排功能”下面,适合在前两阶段跑顺以后继续往运行时和复杂流程走。 + +1. [Chain/Graph 编排介绍](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/chain_graph_introduction/) +2. [Workflow 编排框架](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/workflow_orchestration_framework/) +3. [Callback 用户手册](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/callback_manual/) +4. [Interrupt & CheckPoint 使用手册](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/checkpoint_interrupt/) +5. [CallOption 能力与规范](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/call_option_capabilities/) + +## 第四阶段:ADK 体系 + +这一组在“ADK - Agent Development Kit”下面,适合在理解组件和编排之后系统化学习 Agent。 + +1. [ADK Quickstart](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_quickstart/) +2. [Agent 抽象](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_interface/) +3. [Agent 协作](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_collaboration/) +4. [ChatModelAgent](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/chat_model/) +5. [Workflow Agents](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/workflow/) +6. [Agent Runner 与扩展](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_extension/) + +== 第一版本,先学习到这里。 +7. [Agent Callback](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/adk_agent_callback/) +8. [Supervisor Agent](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/supervisor/) +9. [Plan-Execute Agent](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/plan_execute/) + +可选入口: + +- [ADK 概述](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_preview/) + +## 第五阶段:按需选修 + +这一组偏产品化、工具化和生态集成,也包含了官方 Quick Start 里我没有放进主线的章节。 + +1. [第五章:Middleware(中间件模式)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_05_middleware/) +2. [第七章:Interrupt/Resume(中断与恢复)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_07_interrupt_resume/) +3. [第八章:Graph Tool(复杂工作流)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_08_graph_tool/) +4. [第九章:Skill(Console)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_09_skill_console/) +5. [第十章:A2UI 协议(流式 UI 组件)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_09_a2ui_protocol/) +6. [应用开发工具链(总入口)](https://www.cloudwego.io/zh/docs/eino/core_modules/devops/) +7. [Eino Dev 插件安装指南](https://www.cloudwego.io/zh/docs/eino/core_modules/devops/ide_plugin_guide/) +8. [Eino Dev 可视化编排插件功能指南](https://www.cloudwego.io/zh/docs/eino/core_modules/devops/visual_orchestration_plugin_guide/) +9. [Eino Dev 可视化调试插件功能指南](https://www.cloudwego.io/zh/docs/eino/core_modules/devops/visual_debug_plugin_guide/) +10. [ReAct Agent 使用手册](https://www.cloudwego.io/zh/docs/eino/core_modules/flow_integration_components/react_agent_manual/) +11. [Host Multi-Agent](https://www.cloudwego.io/zh/docs/eino/core_modules/flow_integration_components/multi_agent_hosting/) +12. [组件集成(总入口)](https://www.cloudwego.io/zh/docs/eino/ecosystem_integration/) +13. [发布记录 & 迁移指引(总入口)](https://www.cloudwego.io/zh/docs/eino/release_notes_and_migration/) + +## 你最适合的实际阅读顺序 + +如果你不想完全照目录走,我更建议按下面这条最短主线读: + +1. [ChatModel 与 Message](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_01_chatmodel_and_message/) +2. [ChatModelAgent、Runner、AgentEvent](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_02_chatmodelagent_runner_agentevent/) +3. [Memory 与 Session](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_03_memory_and_session/) +4. [Tool 与文件系统访问](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_04_tool_and_filesystem/) +5. [Callback 与 Trace](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_06_callback_and_trace/) +6. [ChatTemplate 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_template_guide/) +7. [ToolsNode&Tool 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/tools_node_guide/) +8. [Document Loader 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/document_loader_guide/) +9. [Embedding 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/embedding_guide/) +10. [Indexer 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/indexer_guide/) +11. [Retriever 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/retriever_guide/) +12. [Workflow 编排框架](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/workflow_orchestration_framework/) +13. [Interrupt & CheckPoint 使用手册](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/checkpoint_interrupt/) +14. [ADK Quickstart](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_quickstart/) +15. [ChatModelAgent](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/chat_model/) +16. [Workflow Agents](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/workflow/) + +如果你想把官方 Quick Start 也完整补齐,再接着看: + +- [第五章:Middleware(中间件模式)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_05_middleware/) +- [第七章:Interrupt/Resume(中断与恢复)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_07_interrupt_resume/) +- [第八章:Graph Tool(复杂工作流)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_08_graph_tool/) +- [第九章:Skill(Console)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_09_skill_console/) +- [第十章:A2UI 协议(流式 UI 组件)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_09_a2ui_protocol/) + + + + +// 第一步学习途径: +adk体系: +1. [ADK Quickstart](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_quickstart/) + + +额外学习: +- [第五章:Middleware(中间件模式)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_05_middleware/) +- [第九章:Skill(Console)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_09_skill_console/) +- [ReAct Agent 使用手册](https://www.cloudwego.io/zh/docs/eino/core_modules/flow_integration_components/react_agent_manual/) + +--- + +## 发布说明 + +- GitHub 主文:[Go Eino 学习总纲](./00-学习总纲.md) +- CSDN 跳转:[Go Eino 学习总纲](https://blog.csdn.net/2302_80067378/category_13132166.html) +- 官方文档:[Eino 快速开始总入口](https://www.cloudwego.io/zh/docs/eino/quick_start/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/01-\345\211\215\347\275\256\345\237\272\347\241\200/01-\345\255\246\344\271\240AI\345\211\215\351\234\200\345\205\267\345\244\207\347\232\204\345\237\272\347\241\200\347\237\245\350\257\206.md" "b/docs/csdn/AI-Go-Eino-study/01-\345\211\215\347\275\256\345\237\272\347\241\200/01-\345\255\246\344\271\240AI\345\211\215\351\234\200\345\205\267\345\244\207\347\232\204\345\237\272\347\241\200\347\237\245\350\257\206.md" new file mode 100644 index 0000000..425928f --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/01-\345\211\215\347\275\256\345\237\272\347\241\200/01-\345\255\246\344\271\240AI\345\211\215\351\234\200\345\205\267\345\244\207\347\232\204\345\237\272\347\241\200\347\237\245\350\257\206.md" @@ -0,0 +1,456 @@ +# AI大模型落地系列:学习AI前需具备的基础知识 + +> GitHub 主文:[当前文章](./01-学习AI前需具备的基础知识.md) +> CSDN 跳转:[AI大模型落地系列:学习AI前需具备的基础知识](https://zhumo.blog.csdn.net/article/details/158352635) +> 官方文档:[Eino 快速开始总入口](https://www.cloudwego.io/zh/docs/eino/quick_start/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:先把 prompt、Agent、Function Calling、MCP、上下文窗口、RAG 放进同一张认知图,再进入 Eino。 +**适合谁看**:刚从通用大模型学习切入,准备进入 Go AI 应用开发的读者。 +**前置知识**:Go 基础、对 LLM 基础概念有兴趣即可 +**对应 Demo**:[从学习总纲进入 Eino 主线](../00-学习总纲.md) + +**面试可讲点** +- 能用工程语言解释 Agent、Tool、Function Calling、MCP 各自解决的问题。 +- 能把 RAG 和上下文窗口讲成真实约束,而不是只背名词。 + +--- +前段时间,由于回家过年,躺在床上实在感觉无聊, +所以就在网上搜罗了相关资料,整理了学习内容,方便以后温故。 + +进来各种模型频繁迭代,好像光是闻着claude、gpt、deepseek、豆包这些模型升级的声音,就已经让我们热血澎湃。 +但你真的了解他们吗?你知道如何用好他们吗? +**如:** +* user prompt +* system prompt +* AI Agent +* function calling +* MCP +* RAG +* 上下文窗口 + +可能你零星的知道些皮毛,不过没关系,现在让我带着你深入学习一番。 + +--- + +# 一、什么是所谓的user prompt + +最早的 GPT,其实只是个“高级点的聊天机器人”。 + +你给它一句话(user prompt),它给你一句话回答。 +![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/dac435477b834556a3a89d5be410b66a.png) +它能聊天、能写文章、能解释代码 + +但它**不能真的帮你做事**。 + +比如你说: + +> 帮我把 C 盘的 hello_world.cpp 移动到 D 盘,并总结内容 + +它最多告诉你“应该怎么做”,但不会真的帮你操作文件。 + +于是问题来了: + +> 能不能让 AI 真正去执行任务? + +这就引出了 —— **AI Agent**。 + +--- + +# 二、user prompt 和 system prompt + +在讲 Agent 之前,我们先把基础打牢。 + +## 1、 user prompt(用户提示词) + +就是你在对话框里输入的内容。 + +例如: + +```text +你好 +``` + +早期 GPT 只有 user prompt。 + +模型没有人格设定、没有角色设定,只是普通问答。 + +--- + +## 2、 system prompt(系统提示词) + +后来人们发现,可以给模型“设定人设”。 + +比如: + +```text +你是一个傲娇的程序员,说话尽量傲娇,最好带 emoji。 +``` + +这个提示不让用户看到,但每次请求都会和 user prompt 一起发给模型。 + +于是模型有了: + +* 性格 +* 风格 +* 行为约束 + +本质上: + +> user prompt = 你说的话 +> system prompt = 模型的隐藏设定 + +--- + +# 三、AI Agent 是怎么让 AI 干活的? + +现在进入核心。 + +## 1、AI 的问题 + +AI 本身: + +* 只能输出文本 +* 不能操作系统 +* 不能读文件 +* 不能访问数据库 + +所以它只能“动脑”,不能“动手”。 + +--- + +## 2、Agent 的出现 + +AI Agent 本质上就是一段程序。 + +它的作用是: + +> 在 用户、AI、工具 之间做协调。 + +你可以理解为: + +| 角色 | 职责 | +| ----- | ----- | +| AI | 思考和决策 | +| Agent | 协调和调度 | +| Tool | 实际执行 | + +--- + +## 3、举个完整流程例子 + +用户说: + +> 读取 C 盘 hello_world.cpp,移动到 D 盘,并总结内容 + +流程是这样的: + +**第一步:Agent 告诉 AI 可以用哪些工具** + +例如: + +* read_file +* move_file + +**第二步:AI 决定调用 read_file** + +```text +调用 read_file,路径:C://hello_world.cpp +``` + +**第三步:Agent 真正执行工具** + +* 读取文件 +* 把内容返回给 AI + +**第四步:AI 决定调用 move_file** + +**第五步:Agent 执行移动** + +**第六步:AI 输出总结** + +**第七步:Agent 返回结果给用户** + +这就是一个完整的循环。 + +> 规划 → 执行 → 反馈 → 再规划 → 交付 + +--- + +# 四、Function Calling:工具调用的标准化革命 + +早期 Agent 有个问题: + +AI 是“猜”怎么调用工具的。 + +比如天气查询工具: + +```text +check_weather(city, date) +``` + +AI 可能会写: + +```text +上海 明天 +``` + +问题来了: +* 参数顺序错了? +* 明天不是标准日期? +* 少传字段? + +于是就出现了 **Function Calling**。 + + +**Function Calling**: 把工具描述从 system prompt中剥离,用JSON格式统一定义函数名、函数介绍、参数字段,并规范AI调用工具的回复格式。这就是Function Calling的核心: 用标准化格式让AI理解怎么调用工具,而不是猜。 + +--- + +## 1、工具定义(标准 JSON) + +```json +{ + "name": "check_weather", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + }, + "date": { + "type": "string", + "format": "YYYY-MM-DD" + } + }, + "required": ["city"] + } +} +``` + +--- + +## 2、AI 必须按格式调用 + +```json +{ + "function_call": { + "name": "check_weather", + "parameters": { + "city": "上海", + "date": "2025-11-14" + } + } +} +``` + +## 3、Function Calling的好处: + +1. **告别猜谜语**:以前靠System Prompt用自然语言描述工具,AI可能听不懂;现在用JSON格式,AI一看就会。 +2. **降低开发难度**:开发者不用自己写代码检测AI回复是否正确,若AI回复错误,AI的服务器端可检测并自动重试,降低用户开发难度和token开销。 +3. **跨场景通用**:无论是ChatGPT还是开源模型,只要支持**Function Calling**,就能用同一套工具。 + +| 对比项 | System Prompt(传统方式) | Function Calling(标准化方式) | +| ---- | ------------------- | ------------------------------------- | +| 工具描述 | 自然语言随意写(如你可以用查天气工具) | JSON格式强制规范(必须包含name/parameters) | +| 调用格式 | 等AI猜(可能返回散文式回复) | 固定JSON结构(如 {`"function_name":"..."`}) | +| 错误处理 | 开发者自己写代码重试 | 大模型服务端自动重试 | + +--- +# 五、MCP:AI 世界的 USB-C + +上文提到的Agent和Tool是怎么进行交互的?最简单的做法就是把Agent和Tool写在同一个程序里面,直接通过函数调用来完成,这也是现在大多数agent的做法。 + +但其实有些tool的功能其实挺**通用**的,可能多个agent都需要,但总不能在每个agent里面都拷贝一份相同的代码吧。 + +--- + +我们把tool变成服务,统一的托管,让所有的agent都来调用,这就是 **mcp server**。mcp是一个通信协议,专门用来规范agent和tool服务之间是怎么交互的。运行tool的服务叫做mcp server,调用它的agent叫做mcp client。mcp规定了mcp server如何和mcp client通信,以及mcp server有哪些接口。 + +mcp server既可以和agent跑在同一台机器上,通过标准输入输出进行通信。也可以被部署在网络上,通过http进行通信。虽然mcp是为了通用定制出来的标准,但实际上mcp本身却和ai模型没有关系,他并不关心agent用的是哪个模型,mcp只负责帮agent托管工具、资源。 + +--- + +你可以把 MCP 想象成电脑的 **USB-C 接口**: + +* 各种外设(如键盘、U盘、显示器)就是不同的 **MCP Server**,它们提供各自独特的功能。 +* 电脑就是 **AI Agent**,它作为 **MCP Client**,通过统一的 **USB-C 接口(即 MCP 协议)** 来连接和使用所有外设(MCP Server)。 +* 这样一来,无论你更换电脑还是外设,只要都支持 USB-C 标准,就能即插即用,非常方便。MCP 协议正是为 AI 世界带来了这种即插即用的便利性。 + + +如果说 Function Calling 解决的是: + +> “怎么调用工具” + +那么 MCP 解决的是: + +> “工具怎么统一接入” + +--- + +## 1、所以说,什么是 MCP? + +MCP = Model Control Protocol +它把工具变成一个服务(MCP Server)。 +Agent 不再直接调用工具,而是通过 MCP 协议访问。 + +--- + +## 2、完整流程示例 + +用户问: + +> 女朋友肚子疼怎么办? + +流程: + +1. Agent 通过 MCP 获取可用工具(如网页搜索) +2. 转换为 Function Calling 格式 +3. AI 选择 web_browse +4. Agent 通过 MCP 调用搜索服务 +5. 返回结果 +6. AI 生成建议 + +--- + +# 六、大模型的上下文窗口 + +很多人忽略这个概念,但它非常关键。 + +## 什么是上下文窗口? + +就是: + +> 模型一次对话能记住多少内容 + +你可以把它想象成一块黑板。 + +* 黑板大:能写很多 +* 黑板小:写几行就满 + +当写满时: + +> 模型会“擦掉最前面的内容” + +这就是为什么: + +* 对话太长会“失忆” +* 输入太大成本会上升 +* 回答会变慢 + +--- + +# 七、RAG:检索增强生成 +(这个等后面,会单独在写一篇博客细讲) + +最后讲一个企业级必备技术 —— RAG。 + +RAG = Retrieval-Augmented Generation + +简单说就是: + +> 先查资料,再生成回答。 + +--- + +## 为什么不直接把资料丢给模型? + +问题: + +* 有上下文窗口限制 +* 推理成本高 +* 输入越大越慢 +* 容易幻觉 + +--- + +## RAG 怎么做? + +1. 用户提问 +2. 向知识库检索相关片段 +3. 只把相关内容发给模型 +4. 模型基于检索结果回答 + +比如: + +用户问产品维修政策。 + +RAG 不会发 200 页手册。 + +而是: + +* 精准找 3 段相关内容 +* 送给模型 +* 生成答案 + +优点: + +* 成本低 +* 更准确 +* 更快 +* 可扩展 + +--- + +# 八、整套体系串起来是什么样? + +我们把今天讲的全部串起来: + +``` +用户 + ↓ +Agent + ↓(MCP 获取工具) +工具列表 + ↓(Function Calling 格式) +AI 模型 + ↓ +调用工具 + ↓ +RAG 检索知识 + ↓ +生成答案 + ↓ +返回用户 +``` + +--- + +# 九、最终总结 + +| 名词 | 作用 | +| ---------------- | ------- | +| user prompt | 用户输入 | +| system prompt | 模型隐藏设定 | +| Agent | 调度协调 | +| Tool | 实际执行 | +| Function Calling | 标准化工具调用 | +| MCP | 工具接入协议 | +| 上下文窗口 | 模型记忆容量 | +| RAG | 检索增强生成 | + +--- + +# 结语 + +AI 正在从“会聊天”进化为“能做事”。 + +这背后不是一个技术,而是一整套体系: + +- Agent 负责调度 +- Function Calling 负责规范调用 +- MCP 负责统一接入 +- RAG 负责精准知识增强 + +理解了这些,你基本就理解了当前 AI 应用的核心架构。 + +--- + +## 发布说明 + +- GitHub 主文:[AI大模型落地系列:学习AI前需具备的基础知识](./01-学习AI前需具备的基础知识.md) +- CSDN 跳转:[AI大模型落地系列:学习AI前需具备的基础知识](https://zhumo.blog.csdn.net/article/details/158352635) +- 官方文档:[Eino 快速开始总入口](https://www.cloudwego.io/zh/docs/eino/quick_start/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/01-ChatModel\345\222\214Message.md" "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/01-ChatModel\345\222\214Message.md" new file mode 100644 index 0000000..c3010e5 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/01-ChatModel\345\222\214Message.md" @@ -0,0 +1,272 @@ +# AI大模型落地系列:一文读懂 Eino 的 ChatModel 和 Message + +> GitHub 主文:[当前文章](./01-ChatModel和Message.md) +> CSDN 跳转:[AI大模型落地系列:一文读懂 Eino 的 ChatModel 和 Message](https://zhumo.blog.csdn.net/article/details/159393888) +> 官方文档:[Eino 第一章:ChatModel 与 Message](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_01_chatmodel_and_message/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从一次最小模型调用入手,建立 ChatModel、Message 和流式输出的统一心智模型。 +**适合谁看**:第一次接触 Eino,或第一次想把模型调用边界讲清楚的 Go 开发者。 +**前置知识**:前置基础篇、Go 基础、可用的大模型 API Key +**对应 Demo**:[examples/chatmodel-message](../../examples/chatmodel-message/README.md) + +**面试可讲点** +- 能解释 ChatModel 为什么是模型能力的统一接入层,而不是普通聊天接口。 +- 能说明 Message 为什么是对话协议,而不是 prompt 字符串。 + +--- +很多人学 Eino,一上来就盯着 `Agent`、`Tool`、`Memory` 这些词。 + +虽然它们听起来更像“真正的 AI 应用开发”。 +但 `ChatModel` 和 `Message` 这两个才是最基础的边界。 +它的之所以重要,是因为他将与你之后的所有AI开发形影不离。 + +## 1. 初入 Eino + +很多 Go 后端工程师第一次接触 Eino,路径大概都是这样的: + +- 先搜“Go 怎么写 Agent” +- 然后看到一堆工作流、工具调用、多轮会话 +- 最后一头扎进了复杂编排 + +你以为自己在学“AI 应用开发”,其实常常只是在拼装更大的调用链。只要底层那次最简单的模型请求没理解,后面的抽象层级越多,人就越容易飘。 + +说白了, ChatModel 和 Message 在整个Eino框架中,不是拿来炫功能的。 + +它是在回答一个更底层的问题: + +> 在 Eino 里,一次最基础的大模型对话,到底是怎么被表达、组织和执行的? + +这个问题不解决,后面所有“高级能力”都会变成悬空建筑。 + +## 2. ChatModel 与 Message 到底是什么 + +你可以这样理解 +> 用最小的 Go 代码,把一次用户输入变成一次模型调用(ChatModel),并且`Message` 不是普通字符串,而是对话协议。 + +注意,这里有两个重点。 + +**第一,`ChatModel`。** + +它解决的是“怎么和模型说话”。 + +**第二,`Message`。** + +它解决的是“你说的话,怎么被组织成模型能理解的上下文”。 + + +## 3. `ChatModel` 对我们而言到底有什么价值 + +如果你只把 `ChatModel` 理解成“调一下模型接口”,那就低估它了。 + +通常来说,它真正的价值至少有三层。 + +**第一层,统一模型调用边界。** + +你今天接 OpenAI,明天接 Ark,后天换千问。业务最怕的不是换模型,而是换模型就要改一大片调用代码。 + +`ChatModel` 的意义,就是先把“和模型交互”这件事抽象成一个稳定接口。你上层的业务逻辑关心的是“输入一组消息,拿回模型响应”,而不是每家厂商的参数细节。 + +**第二层,给后续编排留接口。** + +后面你看到的 `Agent`、`Runner`、`Graph`、`Chain`,本质上都不是凭空长出来的。它们之所以能组合,是因为底下先有一层统一的 `Component` 抽象。 + +如果没有 `ChatModel` 这种边界,后面的编排层就会变成一堆和供应商 SDK 强耦合的胶水代码。 + +**第三层,让测试可以做的更自然。** + +接口一旦稳定,mock 就有了位置。你做单测时,不必每次真打外部模型。 + +所以 `ChatModel` 的价值,不只是“能调模型”,而是: + +> 它把模型能力从“某个厂商的 HTTP 调用”提升成了“业务里可替换、可编排、可测试的一类能力”。 + +## 4. `Message` 的作用是什么 + +大多人会下意识觉得: + +“不就是传一段 prompt(提示词) 给模型吗?” + +真要这么理解,问题就来了。那系统指令放哪?用户问题放哪?模型回复怎么回灌进上下文?后面工具调用结果又怎么拼回对话链路? + +这就是 `Message` 存在的原因。 + +在 Eino 里,一次对话不是一段字符串,而是一组有角色的消息。 + +- `system`:系统指令 +- `user`:用户输入 +- `assistant`:模型回复 +- `tool`:工具返回结果 + +它本质上是在表达一种“对话协议”,而不是一段散装文本。 + +你只要把 `Message` 理解成协议,就会自然明白下面这些事: + +- 为什么系统指令通常放在最前面 +- 为什么多轮对话不是简单字符串拼接 +- 为什么后面接 Tool Calling 时,`tool` 角色必须单独存在 + +所以接触 Message 的时候,真正要你建立的,不只是 API 用法,而是一个认知转换: + +> 在 Eino 里组织上下文,操作的核心单位不是 prompt 字符串,而是 `schema.Message`。 + +## 5. 用千问把第一轮 Eino 对话跑通 +注:我之所以选择用千问,而非OpenAI,是因为他送的有免费额度,适合学习的时候用 + + + +先准备依赖和环境变量: + +```bash +go mod init eino-ch01-demo +go get github.com/cloudwego/eino@latest +go get github.com/cloudwego/eino-ext/components/model/qwen@latest + +export DASHSCOPE_API_KEY="你的百炼 API Key" +export QWEN_MODEL="qwen3.5-flash" +``` + +如果你在 Windows PowerShell 下,环境变量改成 `$env:DASHSCOPE_API_KEY="..."` 和 `$env:QWEN_MODEL="qwen-flash"` 就行。 + +然后把下面这份完整代码保存成 `main.go`: + +```go +package main + +import ( + "context" + "fmt" + "io" + "log" + "os" + "strings" + + "github.com/cloudwego/eino-ext/components/model/qwen" + "github.com/cloudwego/eino/schema" +) + +func main() { + ctx := context.Background() + + query := "用一句话解释 Eino 的 Component 设计解决了什么问题?" + if len(os.Args) > 1 { + query = strings.Join(os.Args[1:], " ") + } + + cm, err := qwen.NewChatModel(ctx, &qwen.ChatModelConfig{ + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKey: mustEnv("DASHSCOPE_API_KEY"), + Model: envOrDefault("QWEN_MODEL", "qwen3.5-flash"), + }) + if err != nil { + log.Fatalf("new qwen chat model failed: %v", err) + } + + messages := []*schema.Message{ + schema.SystemMessage("你是一个简洁、专业的 Go AI 框架助手。"), + schema.UserMessage(query), + } + + stream, err := cm.Stream(ctx, messages) + if err != nil { + log.Fatalf("stream chat failed: %v", err) + } + defer stream.Close() + + for { + chunk, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + log.Fatalf("recv stream failed: %v", err) + } + + fmt.Print(chunk.Content) + } + + fmt.Println() +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("%s is empty", key) + } + return v +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +``` + +直接执行 `go run . -- "用一句话解释 Eino 的 Component 设计解决了什么问题?"`,你就能看到千问按流式把回复一段段打出来。 + +## 6. 这段代码执行时到底发生了什么 + +按执行顺序,它其实做了四件事。 + +**第一,初始化 `ChatModel`。** + +`qwen.NewChatModel(...)` 这一层的意义,不只是创建一个客户端对象,而是把“千问模型能力”接成 Eino 能识别的 `ChatModel` 组件。 + +从这一步开始,你的代码面对的就是 Eino 抽象,而不再是散落的 HTTP 参数。 + +**第二,构造 `messages`。** + +这里不是简单传一个字符串,而是明确传入两条消息: + +- 一条 `system`,告诉模型你希望它以什么方式回答 +- 一条 `user`,表达当前用户问题 + +这就是第一章最关键的认知点之一。对话不是一段文本,而是一组有角色的消息。 + +**第三,调用 `Stream`。** + +这一点也很重要。这个代表流式生成的意思。 + +**第四,逐块读取并打印。** + +`chunk.Content` 看起来只是一个字段,但它意味着模型回复不必等到全部生成完再展示。前端可以边收边显示,后端也可以边收边处理。后面你做可观测、会话管理、回调链路时,这种流式思维会很自然。 + +所以本demo的意义,不只是“打印了一行字”,而是搭建起来了一个 Eino 的最小对话闭环。 + +## 7. 一分钟复盘 + +如果你读完这篇,只记住一句话,我希望是这一句: + +> Eino 本章不只是在教你写一个最简单的聊天 Demo,而是在教你建立“模型调用抽象”和“对话消息协议”这两个最基础的认知。 + +再压缩一点,就是三件事: + +- `ChatModel` 解决的是模型能力的统一接入 +- `Message` 解决的是对话上下文的结构化表达 +- `Stream` 让这次调用更接近真实产品里的交互方式(流式输出) + +本篇是为了之后的多轮对话、Runner、AgentEvent 打下坚实的根基。 + +## 参考资料 + +- Eino 第一章:ChatModel 与 Message(Console) + https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_01_chatmodel_and_message/ +- Eino ChatModel 使用说明 + https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_model_guide/ +- Eino Qwen 组件文档 + https://pkg.go.dev/github.com/cloudwego/eino-ext/components/model/qwen +- Eino Qwen 免费额度页面 +- https://bailian.console.aliyun.com/cn-beijing/?tab=model#/model-usage/free-quota + +--- + +## 发布说明 + +- GitHub 主文:[AI大模型落地系列:一文读懂 Eino 的 ChatModel 和 Message](./01-ChatModel和Message.md) +- CSDN 跳转:[AI大模型落地系列:一文读懂 Eino 的 ChatModel 和 Message](https://zhumo.blog.csdn.net/article/details/159393888) +- 官方文档:[Eino 第一章:ChatModel 与 Message](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_01_chatmodel_and_message/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/02-ChatModelAgent\343\200\201Runner\343\200\201AgentEvent\357\274\210Console\345\244\232\350\275\256\357\274\211.md" "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/02-ChatModelAgent\343\200\201Runner\343\200\201AgentEvent\357\274\210Console\345\244\232\350\275\256\357\274\211.md" new file mode 100644 index 0000000..a8fc771 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/02-ChatModelAgent\343\200\201Runner\343\200\201AgentEvent\357\274\210Console\345\244\232\350\275\256\357\274\211.md" @@ -0,0 +1,881 @@ +# AI大模型落地系列:一文读懂 ChatModelAgent、Runner、AgentEvent(Console 多轮) + +> GitHub 主文:[当前文章](./02-ChatModelAgent、Runner、AgentEvent(Console多轮).md) +> CSDN 跳转:[AI大模型落地系列:一文读懂 ChatModelAgent、Runner、AgentEvent(Console 多轮)](https://zhumo.blog.csdn.net/article/details/159468400) +> 官方文档:[Eino 第二章:ChatModelAgent、Runner、AgentEvent](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_02_chatmodelagent_runner_agentevent/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:把单轮聊天扩展成 Agent、Runner、事件流,建立 Console 多轮的最小执行闭环。 +**适合谁看**:已经跑通 ChatModel,希望继续理解 Agent 运行时的 Go 开发者。 +**前置知识**:ChatModel 与 Message、消息角色与流式输出 +**对应 Demo**:[官方示例 ch02(本仓后续补充同主题 demo)](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch02/main.go) + +**面试可讲点** +- 能讲清楚 ChatModelAgent、Runner、AgentEvent 三者的职责分工。 +- 能说明为什么事件流比直接返回字符串更适合后续扩展工具调用和可观测性。 + +--- +很多人第一次把多轮对话跑通,代码都长这样: + +```go +history = append(history, schema.UserMessage(line)) +events := runner.Run(ctx, history) +history = append(history, schema.AssistantMessage(content, nil)) +``` + +程序确实能聊起来。 +但我先泼盆冷水: + +> 这还不算你真正理解了 Agent。 + +你只是把历史消息带进了模型,还没有真正理解 Eino 为什么要在 `ChatModel` 之上再抽出 `ChatModelAgent`、`Runner` 和 `AgentEvent`。 + +这不是咬文嚼字。 +这是两个完全不同的认知层次。 + +- 前者是在“调模型” +- 后者是在“理解一个可运行的 Agent 抽象到底怎么工作” + +如果你前面已经看过上一篇,这一章的位置就会更容易看清。 + +第一章讲的是:怎么和模型说话。 +这一章讲的是:怎么把模型能力放进一套可运行的 Agent 骨架里。 + +也就是说,学习路径会从 `ChatModel` 再往前走一层,切到 `ChatModelAgent / Runner / AgentEvent` 这套运行时视角。 + +`Memory / Session`、`Tool`、`Callback / Trace` 这些能力,我会在后续章节继续展开。 +所以本篇不会展开持久化记忆、Tool 编排和可观测性细节。 +而是盯着以下几个核心主线: + +- `ChatModelAgent` 是什么 +- `Runner` 为什么要存在 +- `AgentEvent` 为什么不是多此一举 +- 一个最小 Console 多轮程序到底是怎么跑通的 + +## 1. 为什么“能多轮”不等于你真的理解了 Agent? + +很多人第一次做多轮对话,思路都差不多: + +1. 定义一个 `history []*schema.Message` +2. 每次用户输入都 append 进去 +3. 把 `history` 扔给模型 +4. 再把 assistant 的回复 append 回去 + +从效果上看,这当然已经是多轮。 +模型确实能记住上一轮说了什么。 + +但如果你把这件事直接等同于“我已经写出了 Agent”,那就有点过早下结论了。 + +因为这里面至少混了两个不同层级的问题: + +**第一层,是上下文累积。** + +也就是:上一轮说过的话,这一轮还能不能带上。 + +**第二层,是执行抽象。** + +也就是:一次 Agent 执行,到底怎么被启动、组织、输出、流式消费、以及后续扩展的。 + +前者更像“把消息继续传给模型”。 +后者才是“一个智能体运行时是怎么被定义出来的”。 + +如果只停留在第一层,你写出来的往往只是“带历史消息的模型调用”。 +它离真正的 Agent 运行时,还有一层抽象距离。 + +这个视角一旦建立起来,后面你再看 `Tool`、`Interrupt`、`CheckPoint`、`Supervisor`,脑子里就不会是一团散的。 +## 2. 何为`ChatModelAgent`? +大家可以先思考一个问题: +明明`ChatModel`,已经有了对话能力。可是为何 Eino 却依旧不满足于 `ChatModel`,还要再抽一层 `ChatModelAgent`? + +这里我先把边界说清。 + +- `ChatModel` 是组件。 +- 而`ChatModelAgent` 是 Agent。 + +这两个词只差了一个后缀,但职责并不在一个层面。 + +### 2.1 `ChatModel` 解决的是“模型调用边界” + +前面那篇 `ChatModel` 文章里,已经讲述过了它的核心价值: + +- 统一不同模型厂商的调用接口 +- 把“和模型说话”抽象成稳定能力 +- 为后续编排和测试留出边界 + +它的关注点很明确: + +> 输入一组消息,返回模型输出。 + +这已经很重要了。 +但它仍然只是“能力组件”,还不是完整的应用运行抽象。 + +### 2.2 `ChatModelAgent` 解决的是“把模型能力提升成可运行的 Agent” + +官方在 ADK 里定义的 `Agent` 接口,核心长这样: + +```go +type Agent interface { + Name(ctx context.Context) string + Description(ctx context.Context) string + Run(ctx context.Context, input *AgentInput, options ...AgentRunOption) *AsyncIterator[*AgentEvent] +} +``` + +这里最值得注意的不是 `Name()` 或 `Description()`。 +而是 `Run()`。 + +因为从这里开始,事情已经不是“模型返回一段文本”了。 +而是: + +> Agent 执行后,返回一个 `AsyncIterator[*AgentEvent]` 形式的事件流。 + +这说明什么? + +说明 `Agent` 关注的已经不是单次模型请求本身,而是一次完整执行过程的输出形态。 +因为 `Agent` 这层抽象已经规定:一个 Agent 必须以 `Run() -> AsyncIterator[*AgentEvent]` 的方式对外工作,所以 ChatModelAgent 的任务,其实就是把底层 ChatModel 的调用结果,适配成这套 `Agent 协议`。 +所以此时再回头看 `ChatModelAgent`,它就很好理解了: + +```go +agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "Ch02ConsoleAgent", + Description: "A minimal ChatModelAgent for console multi-turn chat.", + Instruction: "你是一个简洁、专业的 Eino 学习助手。", + Model: cm, +}) +``` + +`adk.ChatModelAgentConfig` 在本篇要关注的字段只有四个: + +- `Name`:这个 Agent 叫什么 +- `Description`:这个 Agent 用来干什么 +- `Instruction`:系统级行为约束 +- `Model`:它底层使用哪个 `ChatModel` + +你会发现,它并没有突然变出什么魔法能力。 +它底下还是模型。 + +但它做了一件非常关键的事: + +> 它把“单纯的模型能力”包装进了“统一的 Agent 执行协议”里。 + +那这层协议化有什么价值? + +我认为至少有三点。 + +**第一,统一上层抽象。** + +以后无论你用的是 `ChatModelAgent`、`WorkflowAgent` 还是别的 Agent,实现层可以不同,但对运行时来说,大家都按 `Run() -> AsyncIterator[*AgentEvent]` 这套协议来。 + +**第二,给扩展留位置。** + +今天这个 Agent 只有模型。 +明天它可以长出 Tool、Middleware、Interrupt、CheckPoint。 +如果没有统一的 Agent 抽象,后面这些能力只能不断往 `ChatModel` 身上硬塞。 + +**第三,让“AI 应用”真正变成一个能跑的对象。** + +`ChatModel` 更像数据库驱动,负责连接和执行。 +`ChatModelAgent` 更像服务层抽象,虽然底层还是那个能力,但现在它已经能被 Runner 统一驱动了。 + +这里也顺手澄清第一个误区: + +> `ChatModelAgent` 不是“另一个模型客户端”,它是“基于模型实现的 Agent”。 + +## 3. `Runner` 为什么不是多余的一层? + +很多人第一次看到 `Runner`,心里都会冒出一个问题: +```go +type Agent interface { + Name(ctx context.Context) string + Description(ctx context.Context) string + Run(ctx context.Context, input *AgentInput, options ...AgentRunOption) *AsyncIterator[*AgentEvent] +} +``` + +“既然官方给的 `Agent` 已经有 `Run()` 了,那为什么还要再包一个 Runner?” + +这很正常。 +从表面看,好像只是又多包了一层对象。 + +但如果你从运行时角度去看,`Runner` 并不是装饰品。 +它是 Agent 的统一执行入口。 + +### 3.1 `Runner` 解决的是“谁来驱动 Agent 执行” + +官方示例的典型写法是: + +```go +runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, +}) +``` + +`adk.RunnerConfig` 在本篇里要盯住两个字段: + +- `Agent`:这次 Runner 负责执行哪个 Agent +- `EnableStreaming`:是否按流式方式消费输出 + +`Runner` 的价值,不是替你做业务判断。 +它的价值是把 Agent 的执行过程,统一收口到一个稳定入口。 + +你可以把它理解成: + +- Agent 定义“这个东西能怎么跑” +- Runner 负责“这次具体怎么驱动它跑” + +### 3.2 为什么不能只盯着 `agent.Run()` + +如果只从“能不能跑”这个角度,很多事情当然也能绕开。 + +但工程上真正麻烦的从来不是“这一行代码能不能执行”,而是: + +- 执行入口是否统一 +- 流式输出怎么消费 +- 后面接中断恢复时往哪挂 +- 后面扩展 checkpoint、callback、query helper 时边界放哪 + +```go +type Runner struct { + a Agent // 要执行的 Agent + enableStreaming bool // 是否是流式的 + store CheckPointStore // 用于中断恢复的状态存储 +} +``` +而 Runner 却可以提供。 +所以 `Runner` 的意义,就是把这些运行时能力集中在一起,而不是散落到业务代码里。 + +你现在可能只是在写一个最小 Demo。 +看起来它只是“让 Agent 跑起来”。 + +但在更完整的 ADK 体系里,`Runner` 代表的是一种运行时收口点。 + +### 3.3 多轮对话中,为何要用 `runner.Run(ctx, history)` + +之所以提到这个,是因为官方文档里展示了 `runner.Query(ctx, "你好")` 这种便捷方式。 + +但本篇却故意不用它。 + +因为多轮对话的实现,最关键的不是“临时对话一句”,而是看清楚: + +> 而是每一轮执行,调用方到底传给 Agent 的是什么输入。 + +而多轮对话里最核心的输入,就是整段 `history`。 + +所以这里必须显式写: + +```go +events := runner.Run(ctx, history) +``` + +这一句比 `Query()` 更重要。 +因为它直接把“多轮靠谁维持”这件事暴露出来了。 + +从而也能顺手澄清第二个误区: + +> `Runner` 负责执行 Agent,但它不负责替你保存历史上下文。 +对上下文的持久化与会话管理,后续会在 `Memory / Session` 一章里单独展开。 +## 4. 为什么 Agent 不直接返回字符串,而是返回 `AgentEvent` 事件流? + +如果你以前主要写的是普通接口服务,第一次看到这种返回值会有点别扭: + +```go +Run(...) *AsyncIterator[*AgentEvent] +``` + +为什么不直接 `return string`? +为什么不直接 `return *schema.Message`? + +因为 Agent 的执行过程,本来就不是一个适合被压扁成“最终字符串”的东西。 + +### 4.1 `AgentEvent` 代表的是“执行过程中的一个事件单元” + +官方文档给出的关键字段,大致可以精简成这样: + +```go +type AgentEvent struct { + Output *AgentOutput + Action *AgentAction + Err error +} +``` + +本篇只需要关注三个点: + +- `event.Output`:这次事件有没有产出消息 +- `event.Action`:这次有没有控制动作,比如中断、转移、退出 +- `event.Err`:这次执行有没有在事件层面报错 + +这说明一件事: + +> Agent 输出的不是一坨最终结果,而是一连串可消费、可观察、可扩展的事件。 + +### 4.2 为什么必须是事件流 + +原因并不玄学。 +就是因为 Agent 的执行天然是过程性的。 + +最简单的情况里,模型可能是逐 token 流式返回。 +复杂一点的情况里,中间还会穿插: + +- Tool 调用 +- Tool 结果回灌 +- 状态切换 +- 中断与恢复 + +如果你要求它“一次性给我最终字符串”,那你等于把中间所有过程都抹掉了。 + +这会直接损失掉三类能力: + +- 流式体验 +- 可观测性 +- 更复杂的控制动作表达 + +所以 `AgentEvent` 不是多此一举。 +它是在为后面的复杂执行形态预留表达空间。 + +### 4.3 `AsyncIterator[*AgentEvent]` 怎么消费 + +最小消费模式通常就是这样: + +```go +for { + event, ok := events.Next() + if !ok { + break + } + if event.Err != nil { + return event.Err + } +} +``` + +这里有两个非常关键的点。 + +**第一,`Next()` 是逐个拿事件。** + +它不是“马上返回最终结果”,而是不断把过程中的事件交给你。 + +**第二,迭代器是一次性的。** + +每次 `runner.Run()` 都会生成一个新的 `*adk.AsyncIterator[*adk.AgentEvent]`。 +你把这次迭代器消费完,就结束了,不能指望再 rewind 一次重新读。 + +这一点非常像流。 +不是数组。 + +### 4.4 `event.Err` 和 `Recv()` 错误不是一回事 + +这里提前埋一个很多人会踩的坑。 + +如果 `event.Output.MessageOutput` 是流式输出,那你后面通常还会继续读: + +```go +frame, err := mv.MessageStream.Recv() +``` + +那么错误其实有两层: + +- 第一层是 `event.Err` +- 第二层是你继续 `Recv()` 流的时候发生的错误 + +这两个不要混成一件事。 + +也就是说: + +> 你不能只判断 `event.Err == nil` 就以为这轮流式消费一定没问题。 + +等会看完整 Demo 时,你会看到这两个地方都会显式处理。 + +## 5. 实战:一个精简的多轮对话程序 + +上面讲了半天抽象,如果你不把它真的跑起来,很容易只停在概念层。 + +接下来的例子中你将会看清:保留 Console 多轮 + Agent 执行抽象 + + +### 5.1 先准备依赖和环境变量 + +```bash +go mod init eino-ch02-demo +go get github.com/cloudwego/eino@latest +go get github.com/cloudwego/eino-ext/components/model/qwen@latest + +export DASHSCOPE_API_KEY="你的百炼 API Key" +export QWEN_MODEL="qwen3.5-flash" +``` + +如果你在 Windows PowerShell 下,可以改成: + +```powershell +$env:DASHSCOPE_API_KEY="你的百炼 API Key" +$env:QWEN_MODEL="qwen3.5-flash" +``` + +### 5.2 多轮对话的小程序 + +```go +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log" + "os" + "strings" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" + "github.com/cloudwego/eino-ext/components/model/qwen" +) + +func main() { + ctx := context.Background() + + // 1. 初始化 Qwen ChatModel。 + cm, err := qwen.NewChatModel(ctx, &qwen.ChatModelConfig{ + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKey: mustEnv("DASHSCOPE_API_KEY"), + Model: envOrDefault("QWEN_MODEL", "qwen3.5-flash"), + }) + if err != nil { + log.Fatalf("new qwen chat model failed: %v", err) + } + + // 2. 基于 ChatModel 构建一个最小 ChatModelAgent。 + agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "Ch02ConsoleAgent", + Description: "A minimal ChatModelAgent for console multi-turn chat.", + Instruction: "你是一个简洁、专业的 Eino 学习助手。", + Model: cm, + }) + if err != nil { + log.Fatalf("new chat model agent failed: %v", err) + } + + // 3. 用 Runner 驱动 Agent 执行,并开启流式输出。 + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, + }) + + // 4. 用内存里的 history 维护多轮上下文。 + // 注意:这只是进程内多轮,不是持久化记忆。 + history := make([]*schema.Message, 0, 16) + + fmt.Println("Enter your message (empty line to exit):") + scanner := bufio.NewScanner(os.Stdin) + + for { + fmt.Print("you> ") + if !scanner.Scan() { + break + } + + line := strings.TrimSpace(scanner.Text()) + if line == "" { + break + } + + // 4.1 记录用户输入 + history = append(history, schema.UserMessage(line)) + + // 4.2 把完整 history 交给 Runner 执行 Agent + content, err := collectAssistantFromEvents(runner.Run(ctx, history)) + if err != nil { + log.Fatalf("run agent failed: %v", err) + } + + // 4.3 把 assistant 回复也写回 history,进入下一轮 + history = append(history, schema.AssistantMessage(content, nil)) + } + + if err := scanner.Err(); err != nil { + log.Fatal(err) + } +} + +func collectAssistantFromEvents(events *adk.AsyncIterator[*adk.AgentEvent]) (string, error) { + var sb strings.Builder + + for { + event, ok := events.Next() + if !ok { + break + } + + // 第一层错误:AgentEvent 层面的执行错误 + if event.Err != nil { + return "", event.Err + } + if event.Output == nil || event.Output.MessageOutput == nil { + continue + } + + mv := event.Output.MessageOutput + if mv.Role != schema.Assistant && mv.Role != "" { + continue + } + + if mv.IsStreaming { + // 自动关闭底层流,避免资源泄漏。 + mv.MessageStream.SetAutomaticClose() + + for { + frame, err := mv.MessageStream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + // 第二层错误:流式读取阶段的错误 + return "", err + } + if frame != nil && frame.Content != "" { + fmt.Print(frame.Content) + sb.WriteString(frame.Content) + } + } + fmt.Println() + continue + } + + if mv.Message != nil { + fmt.Println(mv.Message.Content) + sb.WriteString(mv.Message.Content) + } + } + + return sb.String(), nil +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("%s is empty", key) + } + return v +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +``` + +### 5.3 运行效果 + +执行: + +```bash +go run . +``` + +然后你可以像这样连续提问: + +```text +you> 你好,解释一下 Eino 里的 Agent 是什么? +assistant> ... +you> 再用一句话总结一下 +assistant> ... +``` + +注意,这里有一个非常重要但特别容易忽略的事实: + +> 这个 Demo 的多轮能力,来自调用方维护 `history`,不是 Runner 在背后偷偷帮你做了记忆。 + +也就是说,程序一退出,这段对话就没了。 + +所以它是多轮。 +但还不是持久化记忆。 + +这个边界必须分清。 + +## 6. 这份代码到底在做什么? + +现在我们把上面的完整代码拆开,只看最关键的几步。 + +### 6.1 第一步:先初始化 `ChatModel` + +```go +cm, err := qwen.NewChatModel(ctx, &qwen.ChatModelConfig{ + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKey: mustEnv("DASHSCOPE_API_KEY"), + Model: envOrDefault("QWEN_MODEL", "qwen3.5-flash"), +}) +``` + +`qwen.NewChatModel` 做的事很简单: + +- 创建千问模型客户端 +- 让它以 Eino 的 `ChatModel` 接口形态暴露出来 + +到这一步为止,你还只是有了一个组件。 +它能调用模型,但还没有变成 Agent。 + +### 6.2 第二步:把 `ChatModel` 提升成 `ChatModelAgent` + +```go +agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "Ch02ConsoleAgent", + Description: "A minimal ChatModelAgent for console multi-turn chat.", + Instruction: "你是一个简洁、专业的 Eino 学习助手。", + Model: cm, +}) +``` + +这一层最重要的不是字段本身,而是角色变化。 + +在这之前,你拿到的是一个“模型组件”。 +在这之后,你拿到的是一个“可被 Runner 执行的 Agent”。 + +这里的 `Instruction` 可以理解成系统级约束。 +它不是用户输入。 +它是在定义这个 Agent 的行为风格。 + + +### 6.3 第三步:用 `Runner` 统一驱动 Agent + +```go +runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, +}) +``` + +这一步之后,执行入口就统一了。 + +后面不管你这个 Agent 是最简单的 `ChatModelAgent`,还是以后更复杂的 Workflow / Supervisor,本质上都能被 Runner 这一层驱动。 + +这个意义,在最小 Demo 里可能不够显眼。 +但一旦系统扩起来,统一执行入口会非常重要。 + +### 6.4 第四步:多轮对话其实是调用方维护 `history` + +最关键的一段代码就是这里: + +```go +history = append(history, schema.UserMessage(line)) +content, err := collectAssistantFromEvents(runner.Run(ctx, history)) +history = append(history, schema.AssistantMessage(content, nil)) +``` + +这三行里,藏着官方第二章最核心的事实: + +**第一,用户输入通过 `schema.UserMessage` 变成消息对象。** + +这不是普通字符串。 +它是有角色的消息。 + +**第二,`runner.Run(ctx, history)` 传入的是整段历史消息。** + +这意味着: + +> 这次执行能否“记住上文”,取决于你有没有把历史消息一起传进去。 + +**第三,assistant 回复必须显式 append 回 `history`。** + +如果你只记录用户输入,不记录模型回复,那下一轮上下文就是残缺的。 + +所以多轮对话最本质的机制并不神秘。 +就是: + +- 用户消息进 history +- 把完整 history 交给 Agent 跑一轮 +- 把 assistant 回复再写回 history + +没有 tools 的情况下,这里还有一个必须明确写出的技术事实: + +> 一次 `runner.Run()`,本质上只完成一轮模型调用。 + +多轮不是一次 `Run()` 自动自己循环出来的。 +而是调用方在外层 for 循环里一轮轮驱动出来的。 + +### 6.5 第五步:`collectAssistantFromEvents` 才是理解 `AgentEvent` 的关键 + +很多人看代码时,会把注意力放在 `NewChatModelAgent()` 或 `NewRunner()` 上。 +但真正把事件流消费逻辑讲清楚的,是这个函数。 + +```go +func collectAssistantFromEvents(events *adk.AsyncIterator[*adk.AgentEvent]) (string, error) +``` + +这个签名本身就已经说明了两件事: + +- 输入不是字符串,而是 `*adk.AsyncIterator[*adk.AgentEvent]` +- 输出才是我们最终想要落盘或回灌的 assistant 文本 + +它内部主要做了四件事。 + +**第一,循环调用 `events.Next()`。** + +这表示你在逐个消费 Agent 事件,而不是一次性拿最终答案。 + +**第二,先判断 `event.Err`。** + +这处理的是 AgentEvent 这一层已经暴露出来的错误。 + +**第三,拿到 `event.Output.MessageOutput`。** + +这说明当前事件里真的带了消息输出。 + +**第四,区分流式和非流式。** + +- 如果是流式,就继续从 `MessageStream.Recv()` 一帧一帧读 +- 如果不是流式,就直接读取完整消息 + +这就是为什么前面我一直强调: + +> Agent 的输出不是“一个字符串”,而是“需要被消费的一段事件流”。 + + + +## 7. 需要避开的三个坑 +说到这里,相信大家对概念理解已经差不多了。 +但真正上手时,最容易混掉的还是下面这三件事。 +### 7.1 多轮不等于记忆 + +本篇代码里有 `history`(存储上下文记忆的切片数组),所以程序在当前进程里当然能连续聊天。 + +但这不等于你已经做了 Memory。 + +只要进程退出: + +- `history` 就没了 +- 会话 ID 也没了 +- 下次无法恢复上一次对话 + +所以多轮和记忆不是一个词。 + +- 多轮关注“这一轮能不能带上上一轮上下文” +- 记忆关注“这段上下文能不能脱离当前进程独立存在” + +这也是为什么下一章要单独讲 `Memory / Session`。 + +### 7.2 `Runner` 不替你保存上下文 + +很多人看到 `Runner`,会下意识把“执行”和“记忆”混在一起。 + +但 `Runner` 负责的是执行流程,不是状态托管。 + +它不会替你: + +- 自动保存历史 +- 自动恢复会话 +- 自动管理 session id + +在本篇这个 Demo 里,谁维护上下文? + +答案非常朴素: + +> 就是你自己的 `history []*schema.Message`。 + +顺带再补一句很容易漏掉的: + +> `runner.Run()` 返回的 `*adk.AsyncIterator[*adk.AgentEvent]` 是一次性的,消费完就结束,不能拿来重复读取。 + +### 7.3 `event.Err` 和流读取错误不是一回事 + +这是最容易在排障时把人带沟里的点。 + +很多人只写: + +```go +if event.Err != nil { + return event.Err +} +``` + +然后就觉得错误处理完整了。 + +其实并没有。 + +如果 `event.Output.MessageOutput` 是流式输出,那么真正的错误还可能发生在: + +```go +frame, err := mv.MessageStream.Recv() +``` + +也就是说: + +- `event.Err` 处理的是事件层错误 +- `Recv()` 返回的 `err` 处理的是流消费阶段错误 + +这两个都得看。 + +如果你只查一个地方,很多“明明前面没报错,为什么最后还是失败”的问题就解释不通。 + +### 7.4 为啥要引入 `AgentEvent` + +因为一旦进入 Agent 视角,“回复内容”就不再是唯一输出了。 + +未来还可能表达: +- 工具调用过程 +- 中断信号 +- 状态迁移 +- 恢复点 + +## 8. 本章小结 +如果只看功能效果,这一章做的事情很简单。 + +无非就是: + +- 用户输入一句话 +- 模型回复一句话 +- 再带着上下文继续聊下去 + +但如果只看到这层,你就会低估本章节真正的价值。 + +因为本章的目的是让你从“`会调模型`”到“`会理解 Agent 运行时`”。 + +我真正想带给大家的是下面这套认知: + +- `ChatModel` 是组件,负责模型调用能力 +- `ChatModelAgent` 是 Agent,把模型能力提升成统一执行抽象 +- `Runner` 是执行入口,负责驱动 Agent 跑起来 +- `AgentEvent` 是输出单元,让执行过程能被按事件流消费 +- 多轮对话靠调用方维护 `history`,不是 Runner 自动记忆 + +如果你把这些边界吃透了,后面再看: + +- `Memory / Session` +- `Tool` +- `Callback / Trace` +- `Interrupt / Resume` +- `WorkflowAgent` + +你会发现很多概念一下就落地了。 + +因为你已经不再只是把 Eino 当成“一个能调模型的 Go 包”。 +你开始真正从运行时角度去理解它。 + +如果要用一句话收尾: + +> 本篇博客不是在教你“再写一个聊天 Demo”,而是在教你:怎么把“会调模型”这件事,升级成“会理解 Agent 怎么运行”。 + +所以我个人认为,`ChatModelAgent / Runner / AgentEvent` 是 Eino 学习路径里非常关键的一站。 + +它不是终点。 +但它决定了你后面看 ADK 时,是在背 API,还是在真正理解 Agent 的执行骨架。 + +在结尾处,补一张本篇博客实战项目的运行视角图: +```txt +调用方 -> Runner -> Agent -> AgentEvent 流 -> 调用方消费 + ↑ + history 只是输入的一部分 +``` + +--- + +## 发布说明 + +- GitHub 主文:[AI大模型落地系列:一文读懂 ChatModelAgent、Runner、AgentEvent(Console 多轮)](./02-ChatModelAgent、Runner、AgentEvent(Console多轮).md) +- CSDN 跳转:[AI大模型落地系列:一文读懂 ChatModelAgent、Runner、AgentEvent(Console 多轮)](https://zhumo.blog.csdn.net/article/details/159468400) +- 官方文档:[Eino 第二章:ChatModelAgent、Runner、AgentEvent](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_02_chatmodelagent_runner_agentevent/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/03-Memory\344\270\216Session\357\274\210\346\214\201\344\271\205\345\214\226\345\257\271\350\257\235\357\274\211.md" "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/03-Memory\344\270\216Session\357\274\210\346\214\201\344\271\205\345\214\226\345\257\271\350\257\235\357\274\211.md" new file mode 100644 index 0000000..e04493b --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/03-Memory\344\270\216Session\357\274\210\346\214\201\344\271\205\345\214\226\345\257\271\350\257\235\357\274\211.md" @@ -0,0 +1,747 @@ +# AI大模型落地系列:一文读懂 Eino 的 Memory 与 Session(持久化对话) + +> GitHub 主文:[当前文章](./03-Memory与Session(持久化对话).md) +> CSDN 跳转:[AI大模型落地系列:一文读懂 Eino 的 Memory 与 Session(持久化对话)](https://zhumo.blog.csdn.net/article/details/159430416) +> 官方文档:[Eino 第三章:Memory 与 Session](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_03_memory_and_session/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:用可恢复会话把多轮对话从临时上下文变成可持久化、可恢复的正式会话。 +**适合谁看**:准备把 demo 往真实会话系统推进的 Go 开发者。 +**前置知识**:ChatModelAgent、Runner、AgentEvent、基础文件读写 +**对应 Demo**:[examples/memory-session](../../examples/memory-session/README.md) + +**面试可讲点** +- 能解释 Memory、Session、Store 三者分别解决什么问题。 +- 能把会话恢复、持久化格式和上下文回放讲成一个完整链路。 + +--- +上一篇,我们把 Eino 的 `Tool` 和文件系统接上了。 + +现在看起来,Agent 好像已经不只是“会聊天”,而是真的能做点事了。 + +但只要你把项目一停,问题就会立刻暴露: + +- 它不记得你上一轮说了什么 +- 它不知道上一次会话停在什么地方 +- 它更不可能跨进程恢复上下文 + +这不是模型突然变笨了。 +而是你的对话历史根本没被保存下来。 + +很多人第一次做多轮对话时,容易把“上下文带着一起发给模型”误以为“已经做了记忆”。 +其实大多数时候,你只是把消息临时堆在内存里而已。 +程序一退出,这段“记忆”也就跟着一起没了。 + +所以本篇文章,我先引出一个很有趣的问题: + +> 为什么多轮对话一旦进程退出就会“失忆”,以及在 Eino 里,这件事到底该由谁负责? + +而接下来,这篇文章我将会分成两块来讲: + +- 首先带你做出最小可运行的 Demo,将持久化对话跑通 +- 再回头对照官方第三章源码,看它到底是怎么落地 `Memory / Session / Store` 的 + +## 1. 你以为的多轮对话vs实际上的多轮对话 + +前面几篇文章,我们已经解决了两个关键边界: + +- `ChatModel` 解决了“怎么和模型说话” +- `Tool` 为我们的大模型安装上了接触这个世界的双手 + +但真实项目里,还有第三个同样关键的问题: + +> 这次对话的状态,到底存哪儿? + +如果你现在的程序是这样的: + +```go +history := []*schema.Message{ + schema.UserMessage("你好"), +} +``` + +然后每轮把新消息 append 进去,再把整段 `history` 丢给模型。 + +从“单次运行项目”的角度,这当然能实现多轮有记忆对话。 +但从“工程系统”的角度,这仍然是一次**纯内存会话**。 + +它至少有三个非常现实的问题: + +- 进程一退出,对话历史就丢了 +- 你没法通过 `session-id` 恢复之前的会话 +- 你也没法做会话列表、删除、搜索、导出这些管理能力 + +说得再直一点: + +**多轮对话,不等于持久化会话。** + +前者只是“这一轮请求能不能带上上一轮消息”。 +后者问的是“这段状态能不能`脱离`当前进程独立存在”。 + +这个区别,在 demo 阶段不明显,一到真实业务里就立刻会变成刚需。 + +比如: + +- 客服对话要能下次继续 +- Copilot 类助手要能恢复上次的问题现场 +- 审批流或长任务要能停下来后继续 +- 用户会话要能按 ID 管理,而不是只活在某个进程变量里 + +所以本篇博客的重点,不是是“再教你一种新组件”,而是让你开始正视**会话状态**这件事。 + +## 2. Memory、Session、Store 到底在解决什么问题 + +先把最容易混的一点讲清楚。 + +> `Memory`、`Session`、`Store` 是业务层概念,不是 Eino 框架内置的核心组件。 + +这一点官方第三章写得很明确。 +Eino 负责的是“如何处理消息”,而“消息如何被保存、恢复、管理”这件事,完全是业务层自己决定的。 + +换句话说: + +- Eino 负责把消息交给模型或 Agent 处理 +- 业务层负责把消息存起来,并在下一次再取出来 + +所以,若这两个边界混了,就会造成 `Memory`、`Session`、`Store` 就会越看越乱。 + +### `Memory` 是什么 + +如果用后端的语言讲,`Memory` 不是“模型脑子里的一块魔法区域”。 + +它更像是: + +> 一套对话历史的持久化方案。 + +你可以把它存在: + +- 本地文件 +- MySQL +- Redis +- 对象存储 + + +### `Session` 是什么 + +你可以将`Session` 理解成一次完整对话的边界。 + +他至少能带给你3个锚点: + +- 这次会话的 ID 是谁 +- 这次会话什么时候创建 +- 这次会话目前积累了哪些消息 + +例如:一个简略版的结构 + +```go +type Session struct { + ID string + CreatedAt time.Time + messages []*schema.Message +} +``` + +注意这里的重点不在字段多少,而在含义: + +`Session` 不是“某一次请求”,而是“同一段对话生命周期里的状态容器”。 + +### `Store` 是什么 + +如果说 `Session` 是单个会话,那么 `Store` 解决的就是: + +> 这些会话到底存在哪,怎么取回来,怎么创建和管理。 + +这里做一个极简版本: + +```go +type Store struct { + dir string + cache map[string]*Session +} +``` + +它通常需要提供以下这些接口: + +- `GetOrCreate(id)`:有就加载,没有就新建 +- `List()`:列出已存在会话 +- `Delete(id)`:删除某个会话 + +所以读完本篇博客最应该建立的认知,不是些结构体名词,而是: + +> Eino 负责处理消息,`Memory / Session / Store` 负责让消息可恢复。 + +我再重复一次,这句话很重要: + +> `Memory / Session / Store` 是业务层概念,不是 Eino 框架核心组件。 + +## 3. 实战 Demo + +前面讲了半天,如果你脑子里还是抽象的,那最好的办法就是自己先跑一次。 + +看这个demo的时候,你需要怀着两个目的: + +- 将核心链路看懂 +- 再回头看官方源码时,不用在被目录和细节分散注意力 + +### 先准备依赖和环境变量 + +```bash +go mod init eino-ch03-demo +go get github.com/cloudwego/eino@latest +go get github.com/cloudwego/eino-ext/components/model/qwen@latest +go get github.com/google/uuid@latest + +export DASHSCOPE_API_KEY="你的百炼 API Key" +export QWEN_MODEL="qwen3.5-flash" +export SESSION_DIR="./data/sessions" +``` + +如果你在 Windows PowerShell 下: + +```powershell +$env:DASHSCOPE_API_KEY="你的百炼 API Key" +$env:QWEN_MODEL="qwen3.5-flash" +$env:SESSION_DIR=".\data\sessions" +``` + +### 把下面代码保存成 `main.go` + +```go +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" + "github.com/cloudwego/eino-ext/components/model/qwen" + "github.com/google/uuid" +) + +// Session 表示一个对话会话。 +// 会话元信息和消息都会持久化到对应的 jsonl 文件中。 +type Session struct { + ID string + CreatedAt time.Time + filePath string + messages []*schema.Message +} + +// Append 将一条消息追加到内存和会话文件中。 +func (s *Session) Append(msg *schema.Message) error { + s.messages = append(s.messages, msg) + + data, err := json.Marshal(msg) + if err != nil { + return err + } + + f, err := os.OpenFile(s.filePath, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + + _, err = fmt.Fprintf(f, "%s\n", data) + return err +} + +// GetMessages 返回一份消息切片副本,避免外部直接修改内部状态。 +func (s *Session) GetMessages() []*schema.Message { + result := make([]*schema.Message, len(s.messages)) + copy(result, s.messages) + return result +} + +// Title 使用第一条用户消息生成会话标题,便于展示和识别。 +func (s *Session) Title() string { + for _, msg := range s.messages { + if msg.Role == schema.User && msg.Content != "" { + title := msg.Content + if len([]rune(title)) > 40 { + title = string([]rune(title)[:40]) + "..." + } + return title + } + } + return "New Session" +} + +// Store 负责管理会话文件和内存缓存。 +type Store struct { + dir string + cache map[string]*Session +} + +func NewStore(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + return &Store{ + dir: dir, + cache: make(map[string]*Session), + }, nil +} + +// GetOrCreate 优先从缓存获取会话;如果磁盘不存在则创建,存在则加载。 +func (s *Store) GetOrCreate(id string) (*Session, error) { + if sess, ok := s.cache[id]; ok { + return sess, nil + } + + filePath := filepath.Join(s.dir, id+".jsonl") + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + sess, createErr := createSession(id, filePath) + if createErr != nil { + return nil, createErr + } + s.cache[id] = sess + return sess, nil + } + return nil, err + } + + sess, err := loadSession(filePath) + if err != nil { + return nil, err + } + + s.cache[id] = sess + return sess, nil +} + +// sessionHeader 是 jsonl 文件的第一行,用来保存会话元信息。 +type sessionHeader struct { + Type string `json:"type"` + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` +} + +// createSession 创建一个新的会话文件,并写入头信息。 +func createSession(id, filePath string) (*Session, error) { + header := sessionHeader{ + Type: "session", + ID: id, + CreatedAt: time.Now().UTC(), + } + + data, err := json.Marshal(header) + if err != nil { + return nil, err + } + + if err := os.WriteFile(filePath, append(data, '\n'), 0o644); err != nil { + return nil, err + } + + return &Session{ + ID: id, + CreatedAt: header.CreatedAt, + filePath: filePath, + messages: make([]*schema.Message, 0), + }, nil +} + +// loadSession 从 jsonl 文件恢复会话。 +// 第一行是头信息,后续每一行是一条消息。 +func loadSession(filePath string) (*Session, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if !scanner.Scan() { + return nil, fmt.Errorf("empty session file: %s", filePath) + } + + var header sessionHeader + if err := json.Unmarshal(scanner.Bytes(), &header); err != nil { + return nil, err + } + + sess := &Session{ + ID: header.ID, + CreatedAt: header.CreatedAt, + filePath: filePath, + messages: make([]*schema.Message, 0), + } + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var msg schema.Message + if err := json.Unmarshal([]byte(line), &msg); err != nil { + // 单条消息损坏时跳过,避免整个会话加载失败。 + continue + } + sess.messages = append(sess.messages, &msg) + } + + return sess, scanner.Err() +} + +func main() { + var sessionID string + flag.StringVar(&sessionID, "session", "", "session ID") + flag.Parse() + + ctx := context.Background() + + // 初始化 Qwen 大模型客户端。 + cm, err := qwen.NewChatModel(ctx, &qwen.ChatModelConfig{ + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKey: mustEnv("DASHSCOPE_API_KEY"), + Model: envOrDefault("QWEN_MODEL", "qwen3.5-flash"), + }) + if err != nil { + log.Fatalf("new qwen chat model failed: %v", err) + } + + // 创建一个基于 ChatModel 的简单 Agent。 + agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "MemoryDemoAgent", + Description: "ChatModelAgent with persistent session.", + Instruction: "你是一个简洁、专业的 Eino 学习助手。", + Model: cm, + }) + if err != nil { + log.Fatalf("new chat model agent failed: %v", err) + } + + // Runner 负责执行 Agent,并开启流式输出。 + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, + }) + + // Store 负责管理会话持久化目录。 + store, err := NewStore(envOrDefault("SESSION_DIR", "./data/sessions")) + if err != nil { + log.Fatalf("new store failed: %v", err) + } + + // 未传 session 参数时,新建一个会话;否则恢复旧会话。 + if sessionID == "" { + sessionID = uuid.NewString() + fmt.Printf("Created new session: %s\n", sessionID) + } else { + fmt.Printf("Resuming session: %s\n", sessionID) + } + + session, err := store.GetOrCreate(sessionID) + if err != nil { + log.Fatalf("get or create session failed: %v", err) + } + + fmt.Printf("Session title: %s\n", session.Title()) + fmt.Println("Enter your message (empty line to exit):") + + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Print("you> ") + if !scanner.Scan() { + break + } + + line := strings.TrimSpace(scanner.Text()) + if line == "" { + break + } + + // 1. 记录用户输入 + userMsg := schema.UserMessage(line) + if err := session.Append(userMsg); err != nil { + log.Fatalf("append user message failed: %v", err) + } + + // 2. 带上历史消息一起请求模型,实现“记忆” + history := session.GetMessages() + events := runner.Run(ctx, history) + + // 3. 一边打印流式输出,一边收集完整回复文本 + content, err := printAndCollectAssistant(events) + if err != nil { + log.Fatalf("run agent failed: %v", err) + } + + // 4. 将助手回复也保存到会话中,便于下次恢复上下文 + assistantMsg := schema.AssistantMessage(content, nil) + if err := session.Append(assistantMsg); err != nil { + log.Fatalf("append assistant message failed: %v", err) + } + } + + if err := scanner.Err(); err != nil { + log.Fatal(err) + } + + fmt.Printf("\nSession saved: %s\n", sessionID) + fmt.Printf("Resume with: go run . --session %s\n", sessionID) +} + +// printAndCollectAssistant 处理 Runner 返回的事件流: +// - 流式输出时实时打印内容 +// - 同时拼接成完整字符串,便于后续持久化 +func printAndCollectAssistant(events *adk.AsyncIterator[*adk.AgentEvent]) (string, error) { + var sb strings.Builder + + for { + event, ok := events.Next() + if !ok { + break + } + if event.Err != nil { + return "", event.Err + } + if event.Output == nil || event.Output.MessageOutput == nil { + continue + } + + mv := event.Output.MessageOutput + if mv.Role != schema.Assistant { + continue + } + + if mv.IsStreaming { + // 流式场景:不断接收分片并实时打印 + mv.MessageStream.SetAutomaticClose() + for { + frame, err := mv.MessageStream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", err + } + if frame != nil && frame.Content != "" { + sb.WriteString(frame.Content) + fmt.Print(frame.Content) + } + } + fmt.Println() + continue + } + + // 非流式场景:直接读取完整消息 + if mv.Message != nil { + sb.WriteString(mv.Message.Content) + fmt.Println(mv.Message.Content) + } + } + + return sb.String(), nil +} + +// mustEnv 读取必填环境变量,缺失则直接退出。 +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("%s is empty", key) + } + return v +} + +// envOrDefault 读取环境变量;如果为空则返回默认值。 +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +``` + +### 运行 + +第一次运行,创建新会话: + +```bash +go run . +``` + +第二次运行,恢复之前的会话: + +```bash +go run . --session +``` + +你可以这样试: + +```text +Created new session: 083d16da-6b13-4fe6-afb0-c45d8f490ce1 +Session title: New Session +Enter your message (empty line to exit): +you> 你好,我是张三 +你好,张三,很高兴认识你。 +you> 我叫什么名字? +你叫张三。 + +Session saved: 083d16da-6b13-4fe6-afb0-c45d8f490ce1 +Resume with: go run . --session 083d16da-6b13-4fe6-afb0-c45d8f490ce1 +``` + +到这里,其实最核心的事情已经发生了: + +- 用户消息被 `session.Append(userMsg)` 追加并写进磁盘 +- 下一轮调用前,通过 `session.GetMessages()` 取出完整历史 +- 模型返回的 assistant 消息也再次被 append 回会话 + +你只要把这个闭环看明白,这一章的主线就已经掌握了。 + +## 4. 一次用户输入,是怎么被保存和恢复的 + +为了避免你把上面的代码又看成一堆 API,我把它压成一条最关键的主线: + +```text +┌──────────────────────────────┐ +│ 用户输入一条消息 │ +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ session.Append(user) │ +│ 先把用户消息持久化 │ +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ session.GetMessages() │ +│ 拿到完整历史 │ +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ runner.Run(ctx, history) │ +│ 把历史交给 Agent 处理 │ +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ 收集 assistant 回复 │ +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ session.Append(assistant) │ +│ 再把助手回复持久化 │ +└──────────────────────────────┘ +``` + +这里最值得注意的是顺序。 + +### 第一步,先保存用户消息 + +很多人会下意识先调模型,拿到结果以后再一起存。 + +但从会话一致性的角度看,先把用户输入落盘更稳。 +这样即便中间模型调用失败了,你也至少知道“用户这次问了什么”。 + +### 第二步,再取完整历史 + +`session.GetMessages()` 这一步,意义不是“凑个 slice 出来”。 + +它的含义是: + +> 下一次模型调用,不再依赖某个临时变量,而是依赖会话当前的真实状态。 + +### 第三步,把完整历史交给 `runner.Run` + +这里就能看出业务层和框架层的边界了。 + +- `Session` 不负责生成答案 +- `Runner` 不负责存储消息 + +前者负责状态,后者负责执行。 + +这也是为什么我前面一直强调: + +> Eino 负责处理消息,业务层负责保存和恢复消息。 + +## 5. 解读 +我用到的jsonl这一个文件大概长这样: + +```json +{"type":"session","id":"083d16da-6b13-4fe6-afb0-c45d8f490ce1","created_at":"2026-03-24T10:00:00Z"} +{"role":"user","content":"你好,我是张三"} +{"role":"assistant","content":"你好,张三,很高兴认识你。"} +{"role":"user","content":"我叫什么名字?"} +{"role":"assistant","content":"你叫张三。"} +``` + +这么设计,不是为了“文件格式优雅”。 +而是因为: + +- 首行 header 记录会话元信息 +- 后续消息可以按行追加,无需每次重写整个文件 +- 就算某一行损坏,也不至于把整份会话都拖死 + +这也是为什么 JSONL 这么适合拿来讲“持久化对话”。 + + +### 第一,它没有额外基础设施门槛 + +你不用先装数据库,不用建表,不用配连接池。 +读者只要能跑 Go 程序,就能立刻看到“会话确实被保存下来了”。 + +### 第二,它天然适合展示追加写 + +一条用户消息、一条 assistant 消息,本来就是很适合按行落盘的数据。 + +把这件事用 JSONL 展开,读者很容易理解: + +> 原来所谓持久化会话,本质上就是把消息流变成可恢复的数据流。 + + +## 7. 一分钟复盘 + +如果你读完这篇,一定要记住其中的三点。 + +第一句: + +> 多轮对话,不等于持久化会话。 + +第二句: + +> `Memory / Session / Store` 是业务层概念,不是 Eino 框架核心组件。 + +第三句: + +> Eino 负责处理消息,业务层负责保存和恢复消息。 + +再把实现闭环压缩成一行,就是: + +- 用户输入先 `Append` +- 取完整历史 `GetMessages` +- 交给 `runner.Run` +- 把 assistant 回复再 `Append` 回去 + +你把这条线真的理解了,后面无论是文件版、数据库版,还是更复杂的 interrupt / resume,其实都是在这个基础上继续扩展。 + +下一篇如果继续顺着这条线往下拆,我更想回头讲一讲 `Runner / AgentEvent`。 +因为你会发现,一旦消息能被稳定保存下来,接下来真正值得深挖的,就是“Runner 到底怎么驱动整个 Agent 执行过程”。 + +## 参考资料 + +- Eino 第三章:[Memory 与 Session](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_03_memory_and_session/)(持久化对话) + +--- + +## 发布说明 + +- GitHub 主文:[AI大模型落地系列:一文读懂 Eino 的 Memory 与 Session(持久化对话)](./03-Memory与Session(持久化对话).md) +- CSDN 跳转:[AI大模型落地系列:一文读懂 Eino 的 Memory 与 Session(持久化对话)](https://zhumo.blog.csdn.net/article/details/159430416) +- 官方文档:[Eino 第三章:Memory 与 Session](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_03_memory_and_session/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/04-Tool\345\222\214\346\226\207\344\273\266\347\263\273\347\273\237\350\256\277\351\227\256.md" "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/04-Tool\345\222\214\346\226\207\344\273\266\347\263\273\347\273\237\350\256\277\351\227\256.md" new file mode 100644 index 0000000..7ee87b3 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/04-Tool\345\222\214\346\226\207\344\273\266\347\263\273\347\273\237\350\256\277\351\227\256.md" @@ -0,0 +1,463 @@ +# AI大模型落地系列:一文读懂 Eino 的 Tool 和文件系统访问 + +> GitHub 主文:[当前文章](./04-Tool和文件系统访问.md) +> CSDN 跳转:[AI大模型落地系列:一文读懂 Eino 的 Tool 和文件系统访问](https://zhumo.blog.csdn.net/article/details/159395909) +> 官方文档:[Eino 第四章:Tool 与文件系统访问](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_04_tool_and_filesystem/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:让 Agent 真正具备调用工具和访问文件系统的能力,理解 Tool、Backend、DeepAgent 的分工。 +**适合谁看**:想让模型真正动手,而不只是生成文本的读者。 +**前置知识**:ChatModel、Agent 运行闭环、基础文件系统知识 +**对应 Demo**:[examples/tool-filesystem](../../examples/tool-filesystem/README.md) + +**面试可讲点** +- 能解释 Tool、Backend、Agent 在一次工具调用中各自承担什么职责。 +- 能说明为什么 DeepAgent 比普通聊天 Agent 更接近执行型 Agent。 + +--- +上一篇,我们把 Eino 的 `ChatModel` 和 `Message` 跑通了。 +但很多人到这一步,会误以为自己已经摸到了 Agent 开发的门槛。 +其实没有。 +因为会对话,不等于会执行。 +一个只能生成文本的 Agent,在工程上还远远谈不上“能干活”。 +真正的分水岭,往往是 `Tool`。 + +上一篇解决模型调用边界,这一篇解决执行能力边界。放在 Eino 里,这个执行能力最直接的落点,就是给 `Agent` 接上 `Tool`、接上文件系统、接上 `DeepAgent`。如果还停留在“输入一段 prompt,输出一段文本”,那你写出来的东西更像一个高级聊天框,而不是一个真正能落地的 Agent。 + +## 1. 为什么跑通 ChatModel 以后,你的 Agent 还是只会聊天 + +很多 Go 后端工程师第一次接 Eino,最容易产生一个错觉: + +“我已经能把模型调通了,也能拿到回复了,那我是不是已经在做 Agent 了?” + +这话只对了一半。 + +`ChatModel` 解决的是“怎么和模型说话”,`Message` 解决的是“上下文怎么表达”。但这两个边界打通之后,你得到的,本质上还是一个**只能生成文本**的能力。 + +它能回答问题。 +它能续写内容。 +它甚至能看起来像是在“思考”。 + +但它依然: + +- 读不了文件 +- 查不了目录 +- 访问不了外部资源 +- 执行不了真实动作 + +很多所谓的 Agent 项目,本质上只是把 ChatModel 外面再包了一层壳。 + +这就像什么? + +像你写了一个返回 JSON 的接口,但接口后面没连数据库、没连缓存、没连业务系统。它当然“能响应”,但你很难说它真的“有业务能力”。 + +所以继上一篇文章之后,`ChatModel` 真正该补上的,不是更花哨的编排,而是让模型先有能力碰到外部世界。而这个入口,就是 `Tool`。 + +## 2. Tool 到底是什么 + +很多人一看到 `Tool` 这个词,会下意识把它理解成“插件”。 + +这个理解不算错,但还不够准。 + +在 Eino 里,`Tool` 更像一层统一的**外部能力声明**。模型不需要知道你的文件读取逻辑怎么写、shell 怎么执行、数据库怎么连,它只需要知道: + +- 这个工具叫什么 +- 它是干什么的 +- 它收什么参数 +- 它调用后会返回什么结果 + +从职责上看,可以简单分成三层: + +- `BaseTool`:提供工具元信息,让模型知道“这里有个工具可用” +- `InvokableTool`:一次性执行工具,输入通常是 JSON 参数,输出是字符串结果 +- `StreamableTool`:流式执行工具,适合 shell 这类会持续返回内容的场景 +```go +// BaseTool 提供工具的元信息,ChatModel 使用这些信息决定是否以及如何调用工具 +type BaseTool interface { + Info(ctx context.Context) (*schema.ToolInfo, error) +} + +// InvokableTool 是可以被 ToolsNode 执行的工具 +type InvokableTool interface { + BaseTool + // InvokableRun 执行工具,参数是 JSON 编码的字符串,返回字符串结果 + InvokableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (string, error) +} + +// StreamableTool 是 InvokableTool 的流式变体 +type StreamableTool interface { + BaseTool + // StreamableRun 流式执行工具,返回 StreamReader + StreamableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (*schema.StreamReader[string], error) +} + +``` + +> 对模型而言,Tool 不是一段代码,而是一份可以被选择调用的说明书(协议)。 + +这也是为什么 Tool 会成为 Agent 和普通聊天程序之间的分水岭。模型一旦具备 Tool Calling,它就不再只能“说”,而是可以“先调工具,再组织答案”。 + +## 3. 如何为Agent装上操作文件系统能力 + +如果你是做 ChatWithDoc、代码问答、项目助手这类场景,最可信的资料是什么? + +不是二手教程。 +不是群聊截图。 +也不是别人写的“速通笔记”。 + +最可信的,其实是项目自己的源码、注释和示例。 + +这也是为什么这将成为Agent的一次飞跃性进步。因为一旦 Agent 能读目录、读文件、grep 搜索、按 glob 查找,它就第一次具备了“自己去找依据”的能力。 + +如果 Agent 连文件都读不了,它通常还没从“聊天程序”跨进“执行程序”。 + +这里会出现两个容易混的概念。 + +**第一,`Backend`。** + +它是文件系统操作的抽象层,负责定义“列目录、读文件、搜索、写入、编辑”这些能力。 + +**第二,`LocalBackend`。** + +它是 `Backend` 的本地实现,直接访问你机器上的文件系统。你可以把它理解成: + +> Eino 没有把“读文件”硬编码在 Agent 里,而是先抽象成 Backend,再给出一个本地版实现。 +```go +import localbk "github.com/cloudwego/eino-ext/adk/backend/local" + +backend, err := localbk.NewBackend(ctx, &localbk.Config{}) +``` + +之所以这样设计。是因为今天你读的是本地目录,明天就可能换成别的存储后端。抽象先顶上,能力才有复用空间。 + +另外,`LocalBackend` 还有一个特别值得注意的点:**文件系统工具最好使用绝对路径。** + +## 4. 啥是DeepAgent +咱们先不谈其他,你先看看这些import导入的包。 +```go +import ( + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/adk/prebuilt/deep" + "github.com/cloudwego/eino/schema" +) +``` +#### 先了解何为adk +**adk** 可以理解为 Eino 里专门面向 Agent 的基础开发层。你可以认为他是一套针对底层**封装好的接口**。它把 Agent 运行所需的一套底层抽象、接口、事件流和执行机制先封装好,然后对上层的 Agent 实现和业务代码提供统一能力。 +#### 水道渠成的deepAgent +`github.com/cloudwego/eino/adk/prebuilt/deep` 则是建立在 adk 之上的一个 **开箱即用的**预置 Agent 实现,官方叫 DeepAgents。官方文档也明确说了,它是在 ChatModelAgent 基础上实现的一种现成 agent 方案,你不用自己从零拼提示词、工具和上下文管理,就能直接得到一个可运行的 Agent。 +**官方表述:** +![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/d7af48dfa4734e2e85e97d1dd851498d.png) + +`DeepAgent` 的优势,在于它把文件系统、命令执行和任务能力抬成了一等配置。你不需要从零拼每一个螺丝,直接把 `Backend` 和 `StreamingShell` 传进去,它就能把相关工具接起来。 +注:所谓的一等配置,就是能直接在Config中配置的参数 + +#### ChatModelAgent与DeepAgent区别 + +| 能力 | ChatModelAgent | DeepAgent | +| --- | --- | --- | +| 多轮对话 | 支持 | 支持 | +| 自定义 Tool | 需要手动逐个注册 | 可以手动注册,也可以接一级配置 | +| 文件系统访问 | 需要自己创建并注册相关 Tool | 配置 `Backend` 后自动接入 | +| 命令执行 | 需要自己额外接入 | 配置 `StreamingShell` 后自动接入 | +| 内置任务管理 | 无 | 默认带 `write_todos` | +| 子 Agent 能力 | 无 | 支持 | + +这里最重要的结论其实就一句: + +- 纯对话场景,用 `ChatModelAgent` +- 一旦要接文件系统、命令执行、任务规划,就切 `DeepAgent` + +官方第四章明确给出了这一组自动注册工具: + +- `read_file` +- `write_file` +- `edit_file` +- `glob` +- `grep` +- `execute` + +所以,很多 Agent 项目真正的第一步,不是上 Workflow,而是先把 Tool 接进去,先为你的大模型接上双手。 + +## 5. 跑通一个小 Demo + +本demo将会使用: +- `LocalBackend` +- `DeepAgent` +- 千问大模型 + +你将会使 “Agent 第一次碰到外部世界”。 + +同样,先准备依赖和环境变量: + +```bash +go mod init eino-ch04-demo +go get github.com/cloudwego/eino@latest +go get github.com/cloudwego/eino-ext/components/model/qwen@latest +go get github.com/cloudwego/eino-ext/adk/backend/local@latest + +export DASHSCOPE_API_KEY="你的百炼 API Key" +export QWEN_MODEL="qwen3.5-flash" +export PROJECT_ROOT=/path/to/your/project +``` + +如果你在 Windows PowerShell 下,环境变量改成: + +```powershell +$env:DASHSCOPE_API_KEY="你的百炼 API Key" +$env:QWEN_MODEL="qwen3.5-flash" +$env:PROJECT_ROOT="D:\\your\\project" +``` + +如果不设置 `PROJECT_ROOT`,上面这份代码会默认使用当前工作目录。 + +然后把下面这份代码保存成 `main.go`: + +```go +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + localbk "github.com/cloudwego/eino-ext/adk/backend/local" + "github.com/cloudwego/eino-ext/components/model/qwen" + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/adk/prebuilt/deep" + "github.com/cloudwego/eino/schema" +) + +func main() { + ctx := context.Background() + + projectRoot := envOrDefault("PROJECT_ROOT", ".") + projectRoot, err := filepath.Abs(projectRoot) + if err != nil { + log.Fatalf("resolve project root failed: %v", err) + } + + cm, err := qwen.NewChatModel(ctx, &qwen.ChatModelConfig{ + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKey: mustEnv("DASHSCOPE_API_KEY"), + Model: envOrDefault("QWEN_MODEL", "qwen3.5-flash"), + }) + if err != nil { + log.Fatalf("new qwen chat model failed: %v", err) + } + + backend, err := localbk.NewBackend(ctx, &localbk.Config{}) + if err != nil { + log.Fatalf("new local backend failed: %v", err) + } + + instruction := fmt.Sprintf(`你是一个专业的 Eino 助手。 +当你调用文件系统工具时,必须使用绝对路径。 +项目根目录是:%s +如果用户说“当前目录”,默认指 %s。`, projectRoot, projectRoot) + + agent, err := deep.New(ctx, &deep.Config{ + Name: "Ch04ToolAgent", + Description: "A minimal Eino agent with filesystem access.", + ChatModel: cm, + Instruction: instruction, + Backend: backend, + StreamingShell: backend, + MaxIteration: 20, + }) + if err != nil { + log.Fatalf("new deep agent failed: %v", err) + } + + query := "请列出当前目录下的 Go 文件,并读取 main.go 的前 20 行" + if len(os.Args) > 1 { + query = strings.Join(os.Args[1:], " ") + } + + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, + }) + + events := runner.Run(ctx, []*schema.Message{ + schema.UserMessage(query), + }) + + if err := printEvents(events); err != nil { + log.Fatalf("run agent failed: %v", err) + } +} + +// printEvents 不断消费 Agent 运行产生的事件流, +// 把助手回复、工具调用、工具结果按可读方式打印到终端。 +func printEvents(events *adk.AsyncIterator[*adk.AgentEvent]) error { + for { + event, ok := events.Next() + if !ok { + return nil + } + if event.Err != nil { + return event.Err + } + if event.Output == nil || event.Output.MessageOutput == nil { + continue + } + + // 实际输出 + mv := event.Output.MessageOutput + if mv.Role == schema.Tool { + content, err := drainMessageVariant(mv) + if err != nil { + return err + } + fmt.Printf("[tool result]\n%s\n\n", content) + continue + } + + if mv.Role != schema.Assistant && mv.Role != "" { + continue + } + + if mv.IsStreaming && mv.MessageStream != nil { + mv.MessageStream.SetAutomaticClose() + var toolCalls []schema.ToolCall + for { + frame, err := mv.MessageStream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + if frame == nil { + continue + } + if frame.Content != "" { + fmt.Print(frame.Content) + } + if len(frame.ToolCalls) > 0 { + toolCalls = append(toolCalls, frame.ToolCalls...) + } + } + fmt.Println() + for _, tc := range toolCalls { + fmt.Printf("[tool call] %s(%s)\n", tc.Function.Name, tc.Function.Arguments) + } + continue + } + + if mv.Message != nil { + fmt.Println(mv.Message.Content) + } + } +} + +// 拼接成完整string在返回 +func drainMessageVariant(mv *adk.MessageVariant) (string, error) { + if mv.Message != nil { + return mv.Message.Content, nil + } + if !mv.IsStreaming || mv.MessageStream == nil { + return "", nil + } + + var sb strings.Builder + for { + chunk, err := mv.MessageStream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", err + } + if chunk != nil && chunk.Content != "" { + sb.WriteString(chunk.Content) + } + } + return sb.String(), nil +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("%s is empty", key) + } + return v +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +``` + +直接执行: + +```bash +go run . -- "请列出当前目录下的 Go 文件,并读取 main.go 的前 20 行" +``` + +你会看到控制台里先出现 `tool call`,然后出现 `tool result`,最后才是模型整理后的自然语言回复。 + +这一步非常关键。因为它说明 Agent 已经不是“凭空回答”,而是在**先找依据,再组织答案**。 + +## 6. 一次 Tool 调用,在 Eino 里到底怎么走 + +当用户说“列出当前目录的文件,并读取 main.go”时,Eino 里发生的大致是这件事: + +```text +用户提问 + -> 模型判断这不是纯文本回答能解决的问题 + -> 生成 tool call(JSON 参数) + -> DeepAgent 把调用路由到对应 Tool + -> Backend/LocalBackend 真正执行文件系统操作 + -> tool result 回到上下文 + -> 模型基于结果生成最终回答 +``` + +这条链一旦跑通,你对 Agent 的理解就会发生变化。 + +不是“模型突然变聪明了”,而是: + +- 模型负责理解问题和决定要不要调工具 +- Tool 负责提供能力入口 +- Backend 负责把动作真正落到外部世界 +- Agent 负责把这一切串起来 + +这也是为什么 `DeepAgent` 比“单纯会聊天的 ChatModel”更接近工程里的执行型 Agent。 + +## 7. 一分钟复盘 + +如果你读完这篇,希望你能收获这些: + +- `ChatModel` 解决的是模型调用边界,不是执行能力边界 +- `Tool` 是 Agent 第一次真正碰到外部世界的入口 +- 文件系统能力之所以重要,是因为源码、注释、示例本身就是最可信的知识源 +- 纯对话继续用 `ChatModelAgent`,一旦要接文件系统和命令执行,就该切到 `DeepAgent` + + +## 参考资料 + +- Eino 第四章:Tool 与文件系统访问 + https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_04_tool_and_filesystem/ +- Eino DeepAgents 文档 + https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/deepagents/ +- Eino 官方示例 `cmd/ch04/main.go` + https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch04/main.go + +--- + +## 发布说明 + +- GitHub 主文:[AI大模型落地系列:一文读懂 Eino 的 Tool 和文件系统访问](./04-Tool和文件系统访问.md) +- CSDN 跳转:[AI大模型落地系列:一文读懂 Eino 的 Tool 和文件系统访问](https://zhumo.blog.csdn.net/article/details/159395909) +- 官方文档:[Eino 第四章:Tool 与文件系统访问](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_04_tool_and_filesystem/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/05-Callback\343\200\201Trace\345\222\214\347\224\237\344\272\247\347\272\247\345\217\257\350\247\202\346\265\213\346\200\247.md" "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/05-Callback\343\200\201Trace\345\222\214\347\224\237\344\272\247\347\272\247\345\217\257\350\247\202\346\265\213\346\200\247.md" new file mode 100644 index 0000000..138f0a3 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/02-\345\205\245\351\227\250\345\277\205\345\255\246/05-Callback\343\200\201Trace\345\222\214\347\224\237\344\272\247\347\272\247\345\217\257\350\247\202\346\265\213\346\200\247.md" @@ -0,0 +1,909 @@ +# AI大模型落地系列:一文读懂 Callback、Trace 和生产级可观测性 + +> GitHub 主文:[当前文章](./05-Callback、Trace和生产级可观测性.md) +> CSDN 跳转:[AI大模型落地系列:一文读懂 Callback、Trace 和生产级可观测性](https://zhumo.blog.csdn.net/article/details/159434369) +> 官方文档:[Eino 第六章:Callback 与 Trace](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_06_callback_and_trace/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:把模型调用、工具执行和事件流打上观测点,让 Agent 不再是黑盒。 +**适合谁看**:准备把 Eino 用到真实项目、需要排障和观测的 Go 开发者。 +**前置知识**:Tool 与文件系统访问、Runner 事件流、日志基础 +**对应 Demo**:[examples/callback-trace](../../examples/callback-trace/README.md) + +**面试可讲点** +- 能说清 Callback 关注的是旁路观测,而不是业务主流程控制。 +- 能区分组件级错误、事件流错误、流式消费错误分别出现在哪一层。 + +--- +如果你前面已经看过了这三篇文章: + +- [《AI大模型落地系列:一文读懂 Eino 的 ChatModel 和 Message》](https://blog.csdn.net/2302_80067378/article/details/159393888?spm=1001.2014.3001.5501) +- [《AI大模型落地系列:一文读懂 Eino 的 Tool 和文件系统访问》](https://blog.csdn.net/2302_80067378/article/details/159395909?spm=1001.2014.3001.5501) +- [《AI大模型落地系列:一文读懂 Eino 的 Memory 与 Session(持久化对话)》](https://blog.csdn.net/2302_80067378/article/details/159430416?spm=1001.2014.3001.5501) + +那你现在其实已经把 Eino 的三条基础线都摸到了: + +- `ChatModel` 让你和模型说上了话 +- `Tool` 让 Agent 能碰到外部世界 +- `Memory / Session` 让对话状态不再只活在内存里 + +但真正一进项目,第四个问题往往比前三个更早把人卡住: + +> 这次回答为什么慢? +> 到底调了几次模型? +> 是哪个 Tool 卡住了? +> Token 到底花在了哪一段链路上? +> 报错是模型、工具,还是你自己的编排出了问题? + +这就是很多 Agent 项目一上强度就开始像“黑盒”的原因。 +你只能看到用户输入和最后输出,中间那条调用链几乎是雾里的。 + +从后端工程角度来看,这不是“调试体验差”这么简单。 +它会直接影响你排障、限流、成本分析、稳定性判断,甚至影响你敢不敢把这套东西真的放进生产环境中。 + +所以本篇博客,我不打算把 `Callback` 当成一个抽象概念来讲。 +我想回答一个更有趣的问题: + +> 在 Eino 里,到底用什么机制,才能把 Agent 的运行过程从黑盒变的透明可见? + +答案就是:`Callback` 负责打观测点,`Trace` 负责把这些观测点组织成一条可读的链路。 + +## 1. 为什么 Agent 会变成黑盒? + +在只写 `ChatModel` Demo 的阶段,事情其实很简单。 + +你给模型一组 `Message`,它回你一段内容。 +出了问题,大不了把请求参数和返回值打印出来。 + +可一旦你把 `Tool` 接上,这条链路就变了。 + +模型不再只是“收消息 -> 回答案”,而是会在中间做以下这些动作: + +- 先判断要不要调用工具 +- 选择具体 Tool +- 组织 Tool 参数 +- 等待 Tool 返回结果 +- 再把 Tool 结果回灌给模型 +- 最后生成给用户的自然语言回答 + +如果再往前走一步,把[《一文读懂 Eino 的 Tool 和文件系统访问》](https://blog.csdn.net/2302_80067378/article/details/159395909?spm=1001.2014.3001.5501)里的 `DeepAgent` 用起来,再叠上 [《 Eino 的 Memory 与 Session(持久化对话)》](https://blog.csdn.net/2302_80067378/article/details/159430416?spm=1001.2014.3001.5501)里的多轮会话,你的链路会更长: + +- 一次用户输入可能触发多次模型调用 +- 一次模型调用可能触发多次 Tool +- 不同轮次之间还会夹着历史消息和会话状态 + +这个时候,只看“最终回答对不对”已经远远不够了。 + +后端排障真正关心的是另外几件事: + +- 这次请求慢,是模型慢,还是 Tool 慢 +- 这次没有调到 Tool,是模型判断错了,还是工具注册没生效 +- 这次报错出在 `ChatModel`、`Tool`,还是流式消费阶段 +- 这次 Token 暴涨,是 prompt 变长了,还是模型在反复推理 +- 这次链路和上一次相比,到底多了一步还是少了一步 + +这些问题,靠“多打几行业务日志”很难真正解决。 + +因为业务日志通常写在主流程里,而此时需要的,是一种能附着在组件生命周期上的旁路观察能力。 +这正是 `Callback` 的位置。 + +## 2. Callback 到底是什么? +(它不是业务逻辑,而是旁路观察机制) + +很多人第一次看到 `Callback`,会下意识把它理解成“拦截器”或者“钩子函数”。 + +这个理解只对了一半,因为只停在这里,还是太浅了。 + +在 Eino 里,`Callback` 更准确的定位是: + +> 它不是拿来做业务编排的,而是拿来在固定生命周期点上抽取运行信息的。 + +你可以把它理解成一条“旁路”: + +- 主路:`ChatModel`、`Tool`、`Graph`、`Agent` 真正在干活 +- 旁路:`Callback` 在关键节点上观察输入、输出、错误和流式数据 + +这个区别非常重要。 + +因为从工程职责上看,`Callback` 更适合做以下这些事: + +- 打日志 +- 采集耗时 +- 统计 token +- 上报 trace +- 记录错误链路 +- 做调试、审计和指标采集 + +也就是说,`Callback` 是一种机制,`Trace` 只是它的一种典型用途。 + +这句话可以单独记住: + +> `Callback` 解决“在哪些点能拿到运行信息”,`Trace` 解决“这些信息怎么被串成一条可读的链路”。 + +所以 CozeLoop、Langfuse、OpenTelemetry 这些东西,本质上都不是 Callback 本身。 +它们更像是 Callback 的落地方向。 + +## 3. Eino 的 5 个触发时机 +这个五个触发时期将会很切实的展示,为什么它能做日志、追踪、指标和调试 + +Eino 把回调点固定在组件生命周期的 5 个时机上。 +这也是它为什么适合做可观测性的原因。 + +| 时机 | 触发点 | 能拿到什么 | +| --- | --- | --- | +| `TimingOnStart` | 组件开始执行前 | 非流式输入 | +| `TimingOnEnd` | 组件成功返回后 | 非流式输出 | +| `TimingOnError` | 组件返回错误时 | `error` | +| `TimingOnStartWithStreamInput` | 组件接收流式输入时 | 流式输入 | +| `TimingOnEndWithStreamOutput` | 组件返回流式输出时 | 流式输出 | + +放到最常见的 `ChatModel -> Tool -> ChatModel` 链路里,大概是这样: + +```text +用户问题 + | + v +Runner / Agent + | + v +ChatModel.OnStart + | + v +模型决定调用 Tool + | + v +Tool.OnStart + | + v +Tool 执行 + | + +---- 成功 ----> Tool.OnEnd + | + +---- 失败 ----> Tool.OnError + | + v +ChatModel 继续推理 + | + v +ChatModel.OnEnd / OnEndWithStreamOutput + | + v +返回给用户 +``` + +这里有三个容易忽略点,但在生产里却又非常关键。 + +#### 第一,`OnError` 只负责“组件返回错误” +如果你拿到的是一个 `StreamReader`,真正的错误可能发生在消费流的过程中。 +这种错误不会自动走到 `OnError`,而是在你读流的时候返回出来。 +所以做流式排障时,你不能只盯着 `OnError`。 + +因为这一点非常绕,所以我举个具体的例子: +##### 1. 非流式场景: +这个很好理解,比如一个 Tool 读文件: + +```go +content, err := ReadFile("/tmp/a.txt") +``` + +如果这里直接报错了: + +```cmd +open /tmp/a.txt: no such file or directory +``` + +那么这属于 **组件执行时就返回 error**。 +这种错误,Callback 的 `OnError` 就能收到。 + +也就是: + +* Tool 开始执行 → `OnStart` +* Tool 返回 error → `OnError` + +这个没问题。 + + +##### 2. 流式场景: +这个问题,错误可能发生在“读的过程中” + +比如模型是流式输出: + +```go +stream, err := chatModel.Stream(ctx, input) +``` + +这一步可能是成功的。也就是说: + +* 模型已经成功返回了一个 `StreamReader` +* 所以从“组件调用”角度看,它**没有报错** + +但后面你真正开始读流: + +```go +for { + msg, err := stream.Recv() + ... +} +``` + +这时才可能发生错误,比如: + +* 网络中断 +* 上游服务超时 +* 流被提前关闭 +* 某一帧解析失败 + +比如读到一半时报: + +```text +unexpected EOF +``` + +这个错误发生在 **消费流的时候**,不是组件一开始返回的时候。 +所以它**不一定会自动触发 `OnError`**。 + +**一个最小例子:** + +```go +stream, err := model.Stream(ctx, input) +if err != nil { + // 这里的 err,通常会对应组件级错误,可能触发 OnError + return err +} + +for { + chunk, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + // 这里的 err 是“读流过程中的错误” + // 不一定自动走到 Callback 的 OnError + log.Printf("stream recv failed: %v", err) + return err + } + + fmt.Print(chunk.Content) +} +``` + +#### 第二,`RunInfo` 是你判断“现在是谁在执行”的关键元信息。 + +它通常会带着三类信息: + +- `Name`:这次执行的业务名称或节点名 +- `Type`:具体实现类型,比如某个模型实现 +- `Component`:这是 `ChatModel`、`Tool`,还是别的组件 + + + +我这样说,你可能没感觉,我举个例子:没有它,你的全局回调里只能知道: + +* “有个东西开始跑了” +* “有个东西结束了” + +但你不知道是谁。 + +那这种日志几乎没法排障。 + +比如你看到: + +```text +[start] +[end] +[start] +[end] +``` + +没有意义。 + +但如果加上 `RunInfo`,就变成: + +```text +[model:start] component=ChatModel type=Qwen +[tool:start] component=Tool name=glob +[tool:end] component=Tool name=glob duration=18ms +[model:end] component=ChatModel type=Qwen duration=1.3s +``` + +一下子就能看懂整条链路。 + + + + +#### 第三,同一个 Handler 可以通过 `context` 传状态。 + +这意味着你完全可以在 `OnStart` 里记开始时间,在 `OnEnd` 里算耗时。 +这比把时钟塞进业务逻辑里干净得多。 + +## 4. 实战练习:把链路看见 + +本篇博客,我不再单独造一个“纯 Callback 玩具 Demo”。 + +那种 Demo 最大的问题是:看起来学会了,实际上你还是感受不到它在真实 Agent 链路里的价值。 + +所以我直接复用上篇博客中的工具访问语境,做一个最小可运行的版本: + +- 模型还是用 Qwen +- Agent 还是用 `DeepAgent` +- 文件系统还是走 `LocalBackend` +- 新增一层本地 Callback 日志 +- 可选再接 CozeLoop + +这样一跑,你就能同时看到 `ChatModel` 和 `Tool` 两类节点的回调。 + +### 先准备依赖和环境变量 + +```bash +go mod init eino-ch06-demo +go get github.com/cloudwego/eino@latest +go get github.com/cloudwego/eino-ext/components/model/qwen@latest +go get github.com/cloudwego/eino-ext/adk/backend/local@latest +go get github.com/cloudwego/eino-ext/callbacks/cozeloop@latest +go get github.com/coze-dev/cozeloop-go@latest + +export DASHSCOPE_API_KEY="你的百炼 API Key" +export QWEN_MODEL="qwen3.5-flash" +export PROJECT_ROOT=/path/to/your/project + +# 可选:只有想接 CozeLoop 时才需要 +export COZELOOP_WORKSPACE_ID="your_workspace_id" +export COZELOOP_API_TOKEN="your_api_token" +``` + +如果你在 Windows PowerShell 下,写法是: + +```powershell +$env:DASHSCOPE_API_KEY="你的百炼 API Key" +$env:QWEN_MODEL="qwen3.5-flash" +$env:PROJECT_ROOT="D:\\your\\project" + +# 可选 +$env:COZELOOP_WORKSPACE_ID="your_workspace_id" +$env:COZELOOP_API_TOKEN="your_api_token" +``` + +### 完整demo案例 +把下面代码保存成 `main.go` + +这里有两个点先说在前面: + +- 当前版本里更适合用 `github.com/cloudwego/eino/utils/callbacks` 里的 `HandlerHelper` +- 我们只给 `ChatModel` 和 `Tool` 打观测点,这样最接近真实排障需求 + +```go +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/adk/prebuilt/deep" + "github.com/cloudwego/eino/callbacks" + "github.com/cloudwego/eino/components/model" + toolcb "github.com/cloudwego/eino/components/tool" + localbk "github.com/cloudwego/eino-ext/adk/backend/local" + clc "github.com/cloudwego/eino-ext/callbacks/cozeloop" + "github.com/cloudwego/eino-ext/components/model/qwen" + "github.com/cloudwego/eino/schema" + ucb "github.com/cloudwego/eino/utils/callbacks" + "github.com/coze-dev/cozeloop-go" +) + +// 用自定义空 struct 作为 context key,避免和其他包发生 key 冲突。 +// 这是一种 Go 里常见且更安全的写法。 +type modelStartKey struct{} +type toolStartKey struct{} + +func main() { + ctx := context.Background() + + // PROJECT_ROOT 用于告诉 Agent 当前项目根目录。 + // 如果没配,默认使用当前工作目录。 + projectRoot := envOrDefault("PROJECT_ROOT", ".") + projectRoot, err := filepath.Abs(projectRoot) + if err != nil { + log.Fatalf("resolve project root failed: %v", err) + } + + // 初始化 Qwen ChatModel。 + // DASHSCOPE_API_KEY 是必填;QWEN_MODEL 可不填,走默认值。 + cm, err := qwen.NewChatModel(ctx, &qwen.ChatModelConfig{ + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKey: mustEnv("DASHSCOPE_API_KEY"), + Model: envOrDefault("QWEN_MODEL", "qwen3.5-flash"), + }) + if err != nil { + log.Fatalf("new qwen chat model failed: %v", err) + } + + // LocalBackend 提供本地文件系统能力,供 DeepAgent 的工具链使用。 + backend, err := localbk.NewBackend(ctx, &localbk.Config{}) + if err != nil { + log.Fatalf("new local backend failed: %v", err) + } + + // 注册本地 trace/log Handler。 + // 注意:全局 Handler 应只在进程启动阶段注册一次,不要在每个请求里重复追加。 + callbacks.AppendGlobalHandlers(buildLocalTraceHandler()) + + // 可选接入 CozeLoop,把本地 Callback 事件上报为可视化 Trace。 + client, err := setupCozeLoop(ctx) + if err != nil { + // CozeLoop 失败不影响主流程运行,只降级为本地日志观测。 + log.Printf("setup cozeloop failed: %v", err) + } + if client != nil { + defer func() { + // 给异步上报留一点 flush 时间。 + // 真正生产服务里,更建议接入统一的优雅停机流程。 + time.Sleep(5 * time.Second) + client.Close(ctx) + }() + } + + // 构建 DeepAgent。 + // 这里把“访问文件系统时必须用绝对路径”的约束写进 Instruction, + // 避免模型生成相对路径导致工具执行不稳定。 + agent, err := deep.New(ctx, &deep.Config{ + Name: "Ch06TraceAgent", + Description: "A minimal Eino agent with callback tracing.", + ChatModel: cm, + Instruction: fmt.Sprintf(`你是一个专业的 Eino 助手。 +当你需要访问文件系统时,必须调用工具,并且必须使用绝对路径。 +项目根目录是:%s。`, projectRoot), + Backend: backend, + StreamingShell: backend, + MaxIteration: 20, + }) + if err != nil { + log.Fatalf("new deep agent failed: %v", err) + } + + // Runner 负责驱动 Agent 执行。 + // 这里开启流式输出,方便同时演示普通回调与流式消费场景。 + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, + }) + + // 默认问题,也支持通过命令行参数覆盖。 + query := "请先列出当前项目根目录下的 Markdown 文件,再告诉我哪一个最适合继续学习 Eino Callback。" + if len(os.Args) > 1 { + query = strings.Join(os.Args[1:], " ") + } + + log.Printf("query=%s", query) + + // 执行查询并消费 AgentEvent,收集最终 assistant 输出。 + answer, err := collectAssistantOutput(runner.Query(ctx, query)) + if err != nil { + log.Fatalf("runner query failed: %v", err) + } + + fmt.Printf("\nassistant> %s\n", answer) +} + +// buildLocalTraceHandler 构建本地观测 Handler。 +// 这里不做业务逻辑,只做日志、耗时、token、响应预览等旁路观测。 +// 这是 Callback 在生产中的推荐职责边界。 +func buildLocalTraceHandler() callbacks.Handler { + modelHandler := &ucb.ModelCallbackHandler{ + OnStart: func(ctx context.Context, info *callbacks.RunInfo, input *model.CallbackInput) context.Context { + name, typ, component := describeRunInfo(info) + + // 记录本轮模型调用的基本输入规模,便于排查: + // - 消息是否异常膨胀 + // - 工具列表是否如预期注入 + log.Printf("[model:start] component=%s name=%s type=%s messages=%d tools=%d", + component, name, typ, len(input.Messages), len(input.Tools)) + + // 通过 context 在 OnStart -> OnEnd 之间传递开始时间。 + return context.WithValue(ctx, modelStartKey{}, time.Now()) + }, + OnEnd: func(ctx context.Context, info *callbacks.RunInfo, output *model.CallbackOutput) context.Context { + name, typ, component := describeRunInfo(info) + + totalTokens := 0 + if output.TokenUsage != nil { + totalTokens = output.TokenUsage.TotalTokens + } + + replyPreview := "" + if output.Message != nil { + replyPreview = truncate(output.Message.Content, 80) + } + + // 这里只打预览,不打完整响应,避免日志过大或泄漏过多内容。 + log.Printf("[model:end] component=%s name=%s type=%s duration=%s total_tokens=%d reply=%q", + component, name, typ, elapsed(ctx, modelStartKey{}), totalTokens, replyPreview) + return ctx + }, + OnError: func(ctx context.Context, info *callbacks.RunInfo, err error) context.Context { + name, typ, component := describeRunInfo(info) + + // 注意:这里只能覆盖“组件返回错误”的情况。 + // 如果是流式输出,真正错误也可能发生在后续 Recv() 消费过程中。 + log.Printf("[model:error] component=%s name=%s type=%s err=%v", + component, name, typ, err) + return ctx + }, + } + + toolHandler := &ucb.ToolCallbackHandler{ + OnStart: func(ctx context.Context, info *callbacks.RunInfo, input *toolcb.CallbackInput) context.Context { + name, _, component := describeRunInfo(info) + + // 记录工具参数预览,有助于排查: + // - 参数是否组装正确 + // - 模型是否把路径/JSON 拼错 + log.Printf("[tool:start] component=%s name=%s args=%s", + component, name, truncate(input.ArgumentsInJSON, 120)) + + return context.WithValue(ctx, toolStartKey{}, time.Now()) + }, + OnEnd: func(ctx context.Context, info *callbacks.RunInfo, output *toolcb.CallbackOutput) context.Context { + name, _, component := describeRunInfo(info) + + // 只打印工具结果预览,避免日志过长。 + log.Printf("[tool:end] component=%s name=%s duration=%s response=%q", + component, name, elapsed(ctx, toolStartKey{}), truncate(toolResponsePreview(output), 120)) + return ctx + }, + OnError: func(ctx context.Context, info *callbacks.RunInfo, err error) context.Context { + name, _, component := describeRunInfo(info) + log.Printf("[tool:error] component=%s name=%s err=%v", + component, name, err) + return ctx + }, + } + + // HandlerHelper 按组件类型注册回调,比手动分发 Component 更清晰。 + // 把“模型回调”和“工具回调”注册进一个统一的 callbacks.Handler 里 + return ucb.NewHandlerHelper(). + ChatModel(modelHandler). + Tool(toolHandler). + Handler() +} + +// setupCozeLoop 可选初始化 CozeLoop。 +// 若未配置环境变量,则直接返回 nil,表示不启用远程 Trace 上报。 +func setupCozeLoop(ctx context.Context) (cozeloop.Client, error) { + apiToken := os.Getenv("COZELOOP_API_TOKEN") + workspaceID := os.Getenv("COZELOOP_WORKSPACE_ID") + if apiToken == "" || workspaceID == "" { + return nil, nil + } + + client, err := cozeloop.NewClient( + cozeloop.WithAPIToken(apiToken), + cozeloop.WithWorkspaceID(workspaceID), + ) + if err != nil { + return nil, err + } + + // 追加 CozeLoop Handler,把本地 Callback 事件同步上报。 + callbacks.AppendGlobalHandlers(clc.NewLoopHandler(client)) + log.Println("cozeloop tracing enabled") + + return client, nil +} + +// collectAssistantOutput 统一消费 Agent 事件流,兼容普通输出与流式输出。 +// 返回最终拼接后的 assistant 文本。 +func collectAssistantOutput(events *adk.AsyncIterator[*adk.AgentEvent]) (string, error) { + var sb strings.Builder + + for { + event, ok := events.Next() + if !ok { + break + } + + // 这里处理的是 AgentEvent 层面的错误。 + // 这和 Callback 的 OnError 不是一回事,二者都要关注。 + if event.Err != nil { + return "", event.Err + } + + if event.Output == nil || event.Output.MessageOutput == nil { + continue + } + + mv := event.Output.MessageOutput + + // 只消费 assistant 输出,跳过其他角色事件。 + if mv.Role != schema.Assistant && mv.Role != "" { + continue + } + + if mv.IsStreaming { + // 自动关闭底层流,避免消费完成后资源泄漏。 + mv.MessageStream.SetAutomaticClose() + + for { + frame, err := mv.MessageStream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + // 流式输出的真实错误可能发生在 Recv() 阶段, + // 而不是模型组件初始化流时。 + return "", err + } + if frame != nil && frame.Content != "" { + fmt.Print(frame.Content) + sb.WriteString(frame.Content) + } + } + continue + } + + // 非流式输出直接收集完整消息。 + if mv.Message != nil { + fmt.Print(mv.Message.Content) + sb.WriteString(mv.Message.Content) + } + } + + return sb.String(), nil +} + +// describeRunInfo 对 RunInfo 做容错包装,避免日志里出现大量空值。 +// 在生产里,RunInfo 不应被默认假设为一定完整。 +func describeRunInfo(info *callbacks.RunInfo) (name, typ, component string) { + if info == nil { + return "unknown", "unknown", "unknown" + } + + name = strings.TrimSpace(info.Name) + if name == "" { + name = "unnamed" + } + + typ = strings.TrimSpace(info.Type) + if typ == "" { + typ = "unknown" + } + + component = fmt.Sprintf("%v", info.Component) + if component == "" { + component = "unknown" + } + + return name, typ, component +} + +// elapsed 从 context 中取出开始时间并计算耗时。 +// 如果 key 不存在,返回 0,避免因为观测逻辑影响主流程。 +func elapsed(ctx context.Context, key any) time.Duration { + start, ok := ctx.Value(key).(time.Time) + if !ok { + return 0 + } + return time.Since(start).Round(time.Millisecond) +} + +// toolResponsePreview 尽量抽取适合打印到日志里的工具结果预览。 +// 不保证结果结构固定,因此按“Response -> ToolOutput -> 空串”顺序兜底。 +func toolResponsePreview(output *toolcb.CallbackOutput) string { + if output == nil { + return "" + } + if output.Response != "" { + return output.Response + } + if output.ToolOutput != nil { + return fmt.Sprintf("%v", output.ToolOutput) + } + return "" +} + +// truncate 用于限制日志体积,避免长文本直接打满终端或日志系统。 +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} + +// mustEnv 读取必需环境变量;缺失时直接终止进程。 +// 适合 API Key 这类“程序无法降级运行”的配置。 +func mustEnv(key string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + log.Fatalf("%s is empty", key) + } + return v +} + +// envOrDefault 读取可选环境变量;未配置则使用默认值。 +func envOrDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +``` + +### 直接运行 + +```bash +go run . -- "请列出当前项目根目录下的 Markdown 文件,再告诉我哪一篇最值得先看" +``` + +如果你没有配置 CozeLoop,这段代码依然能跑。 +你会先在终端里看到一层本地日志。 + +输出大概会长这样: + +```text +2026/03/24 21:08:41 query=请列出当前项目根目录下的 Markdown 文件,再告诉我哪一篇最值得先看 +2026/03/24 21:08:41 [model:start] component=ChatModel name=unnamed type=Qwen messages=2 tools=8 +2026/03/24 21:08:42 [tool:start] component=Tool name=glob args={"pattern":"D:\\workspace_go\\personal_assistant\\docs\\csdn\\go eino\\*.md"} +2026/03/24 21:08:42 [tool:end] component=Tool name=glob duration=18ms response="[D:\\workspace_go\\personal_assistant\\docs\\csdn\\go eino\\A学习总纲.md ...]" +2026/03/24 21:08:43 [model:end] component=ChatModel name=unnamed type=Qwen duration=1.324s total_tokens=286 reply="如果你要继续学 Callback,建议先看 A学习总纲,再结合 Tool 和 Memory 两篇..." +assistant> 你可以先看 A学习总纲,再回到 Tool 和 Memory 两篇文章,因为 Callback 的价值只有放到完整链路里才会明显。 +``` + +这段输出最关键的,不是“日志变多了”,而是你终于能回答这些问题了: + +- 这次有没有真的调到 Tool +- 调的是哪个 Tool +- Tool 参数长什么样 +- Tool 花了多久 +- 模型这一轮用了多少 token +- 最终回答是不是建立在工具返回结果之上 + +从工程视角看,这就是从黑盒走向透明的第一步。 + +### 总结为三点 + +**第一,用 `HandlerHelper` 按组件类型拆观测点。** + +如果你直接用通用 Handler,很多时候得自己 `switch RunInfo.Component`,再手动把 `CallbackInput` 转成具体类型。 +`HandlerHelper` 已经把这层胶水收掉了。 + +**第二,用 `context` 在 `OnStart -> OnEnd` 之间传状态。** + +这里我传的是开始时间,所以 `OnEnd` 能直接算出耗时。 +同一个模式也能用来透传 trace id、采样标记,或者一些只属于当前回调链路的上下文数据。 + +**第三,日志要面向排障,而不是面向“证明程序跑过”。** + +所以我打的不是“开始了”“结束了”这种空日志,而是这些真正有判断价值的信息: + +- 组件类型 +- 组件名称 +- 输入规模 +- Tool 参数 +- 响应预览 +- 耗时 +- token 数 + +这类信息,到了线上你就会感受到真正的价值。 + +## 5. 再接 CozeLoop,把日志升级成 Trace + +如果说上一节解决的是“我能看见每个点”,那这一节解决的就是: + +> 我能不能把这些点串成一条真正可追踪的链路? + +这就是 `Trace` 的价值。 + +在 Eino 里,CozeLoop 的接法其实不复杂。 +之前我在运行代码中,依旧有了这一段: + +```go + +// setupCozeLoop 可选初始化 CozeLoop。 +// 若未配置环境变量,则直接返回 nil,表示不启用远程 Trace 上报。 +func setupCozeLoop(ctx context.Context) (cozeloop.Client, error) { + apiToken := os.Getenv("COZELOOP_API_TOKEN") + workspaceID := os.Getenv("COZELOOP_WORKSPACE_ID") + if apiToken == "" || workspaceID == "" { + return nil, nil + } + + client, err := cozeloop.NewClient( + cozeloop.WithAPIToken(apiToken), + cozeloop.WithWorkspaceID(workspaceID), + ) + if err != nil { + return nil, err + } + + // 追加 CozeLoop Handler,把本地 Callback 事件同步上报。 + callbacks.AppendGlobalHandlers(clc.NewLoopHandler(client)) + log.Println("cozeloop tracing enabled") + + return client, nil +} +``` + +注意这里: + +- 没有改 Agent 主流程 +- 没有改 Tool 逻辑 +- 没有改消息结构 +- 只是多注册了一个全局 Handler + +这也是我前面一直强调“Callback 是旁路机制”的原因。 +你的业务代码并不会因为你接了 Trace 而变成另一套写法。 + +一旦 CozeLoop 配好,你就会从“能看终端日志”升级到“能看完整链路”。 +它更适合解决这些问题: + +- 哪个节点最慢 +- 哪一轮模型 token 最高 +- 哪个 Tool 的失败率高 +- 某次异常链路到底是怎么走出来的 + +还有一个很现实的工程细节要补一句: +`AppendGlobalHandlers` 应该只在服务启动期注册一次,它不是给你在请求中间动态追加用的。 + + +## 6. 六大细节 + + +1. `RunInfo` 可能为 `nil`。 + 顶层调用或者某些独立组件场景下,元信息不一定完整,所以 Handler 里先做 nil-check 是基本动作。 + +2. 不要修改 Callback 的输入输出对象。 + 这些对象会被下游节点和其他 Handler 共享,直接改内容很容易引入竞态和脏数据。 + +3. 流式输出要关注 `OnEndWithStreamOutput`。 + 如果你用的是流式模型或流式工具,真正的错误和观测点可能发生在读流阶段,而不是普通的 `OnEnd` / `OnError`。 + +4. `StreamReader` 必须关闭,否则可能泄漏 goroutine。 + 只要你注册了流式回调,拿到的就是一份私有流副本。你读完后不关闭,这条链路就可能一直挂着。 + +5. 同一 Handler 可以通过 `context` 传状态,不同 Handler 不要依赖执行顺序。 + `OnStart -> OnEnd` 之间传耗时、trace id 没问题,但你不能假设“先执行 A Handler,再执行 B Handler”。 + +6. Callback 是旁路,不要把业务逻辑塞进去。 + 它适合做日志、指标、追踪和调试,不适合做权限、补偿、持久化写入这类主业务动作。 + +你会发现,这 6 条看起来都不复杂。 +但一旦踩中,后果通常都不是“日志少打了一行”,而是定位混乱、内存泄漏,甚至把回调链本身写成新的不稳定源。 + +## 7. 一分钟复盘 + +看完本篇文章,你应该有了以下的想法: + +- `Callback` 不是让 Agent 更聪明的能力,而是让你更看得见它的能力 +- `Callback` 是机制,`Trace` 是它的典型落地方式 +- Eino 把观测点固定在 5 个生命周期时机上,所以它很适合做日志、指标和追踪 +- 本地日志适合第一层排障,CozeLoop 适合把点串成链路 +- 真正上线前,最该小心的是流式回调、共享输入输出、Handler 顺序和资源释放 + + + +## 参考资料 + +- Eino 第六章:[Callback 与 Trace(可观测性)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_06_callback_and_trace/) + +- Eino [Callback 用户手册](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/callback_manual/) + +--- + +## 发布说明 + +- GitHub 主文:[AI大模型落地系列:一文读懂 Callback、Trace 和生产级可观测性](./05-Callback、Trace和生产级可观测性.md) +- CSDN 跳转:[AI大模型落地系列:一文读懂 Callback、Trace 和生产级可观测性](https://zhumo.blog.csdn.net/article/details/159434369) +- 官方文档:[Eino 第六章:Callback 与 Trace](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_06_callback_and_trace/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/01-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\346\212\212ChatModel\346\203\263\347\256\200\345\215\225\344\272\206.md" "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/01-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\346\212\212ChatModel\346\203\263\347\256\200\345\215\225\344\272\206.md" new file mode 100644 index 0000000..363dcb6 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/01-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\346\212\212ChatModel\346\203\263\347\256\200\345\215\225\344\272\206.md" @@ -0,0 +1,462 @@ +# AI 大模型落地系列|Eino 组件核心篇:为什么很多人把 ChatModel 想简单了 + +> GitHub 主文:[当前文章](./01-为什么很多人把ChatModel想简单了.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人把 ChatModel 想简单了](https://zhumo.blog.csdn.net/article/details/159492224) +> 官方文档:[ChatModel 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_model_guide/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从组件边界、Option、Callback、WithTools 和自定义实现重新理解 ChatModel 的工程价值。 +**适合谁看**:已经能用 ChatModel,但还想把组件边界吃透的 Go 工程师。 +**前置知识**:ChatModel 与 Message、组件接口的基本认知 +**对应 Demo**:[examples/chatmodel-message](../../examples/chatmodel-message/README.md) + +**面试可讲点** +- 能解释 ChatModel 为什么是 Eino 组件体系的稳定支点。 +- 能说明公共 Option、WithTools、Callback、自定义实现分别扩展了哪一层能力。 + +--- +为什么很多人已经会调用大模型了,到了 Eino 里却依旧容易把 `ChatModel` 用浅? + +因为太多人把它当成一个“聊天接口”。 +发一组消息,回一段文本,事情好像就结束了。 + +可如果 `ChatModel` 只值这么点钱,Eino 根本没必要单独给它设计-(接口、Option、Callback、Tool 绑定和流式输出)等... + +你以为官方在讲用法,实际上它在交代边界。 + +如果你前面刚看过 入门篇的`ChatModel和Message`,那篇博客更多是为了教你把对话先跑起来。 +而本篇换到了另一个角度切入: + +> `ChatModel` 在 Eino 里,到底解决了什么问题? + +## 1. 为什么 `ChatModel` 不是“普通聊天接口” + +如果你直接调厂商 SDK,拿到的是“某家的模型能力”。 +如果你接的是 `ChatModel`,拿到的才是“Eino 能认的模型组件”。 + +这两者的差别,不在于能不能聊天,而在于边界有没有被收口。 + +**第一,它统一了模型接入方式。** + +今天你接 OpenAI,明天接千问,后天可能接公司内部网关。 +业务层最怕的不是换模型,而是换模型就换一套调用姿势。 + +`ChatModel` 把这件事压成了统一接口:给它一组消息,拿回一条消息,或者拿回一个流。 +上层逻辑不用直接面对每家供应商那些参数细节。 + +**第二,它给编排层留了稳定支点。** + +后面的 `Chain`、`Graph`、`Agent`、`Runner` 为什么能往上长? +不是因为这些名词更高级,而是因为底下先有一个统一的组件协议。 + +没有这层协议,所谓编排,很容易写成一堆和供应商 SDK 紧耦合的胶水代码。 + +**第三,它从一开始就给扩展留了位置。** + +如果你把本篇读完,在回过头来,就会发现: +它还拓展的有: + +- `Stream` +- `WithTools` +- 多模态字段 +- `Callback` +- 自定义实现 + +这已经说得很清楚了:官方压根没把 `ChatModel` 当成一个“打印回答”的玩具层。 +它是模型能力进入 Eino 体系的总入口。 + +一句话说透: + +> `ChatModel` 解决的不是“我能不能调模型”,而是“模型能力该怎样以组件的方式进入 Eino”。 + +## 2. 看过接口,你才能知道官方真正想让你理解的 + +官方给出的核心接口很短,但信息量不小: + +```go +type BaseChatModel interface { + Generate(ctx context.Context, input []*schema.Message, opts ...Option) (*schema.Message, error) + Stream(ctx context.Context, input []*schema.Message, opts ...Option) (*schema.StreamReader[*schema.Message], error) +} + +type ToolCallingChatModel interface { + BaseChatModel + WithTools(tools []*schema.ToolInfo) (ToolCallingChatModel, error) +} +``` + +这段代码里,最值得盯住的是三个动作。 + +**1. `Generate`** + +一次性拿完整回复。 +适合摘要、改写、离线任务、后台处理这类“等结果出来再继续”的场景。 + +**2. `Stream`** + +按流式往外吐内容。 +这不是锦上添花,而是产品化中,与用户交互的常态。 + +控制台逐字打印、前端打字机效果、边生成边观察 ToolCall,靠的都是这条链路。 + +**3. `WithTools`** + +这个非常关键。 +它说明 `ChatModel` 在 Eino 里从来就不只是“聊聊天”。 +它还可以被绑定工具,让模型从“只会生成文本”进入“可以做 tool calling(工具调用)”的状态。 +但同时,你会发现官方没有把 ChatModel 定义成一个巨大的万能接口。 +相反,它先给你了一个最小基座: + +- 完整输出 +- 流式输出 +- 工具绑定 + +这就是很典型的组件设计思路。 +先守住稳定的地基,再把扩展能力挂在边上。 + +为了更直观点,你可以先把最小调用记成这样: + +```go +// 传给模型的多条消息 +messages := []*schema.Message{ + schema.SystemMessage("你是一个简洁、专业的 Go 助手。"), + schema.UserMessage("用一句话解释 Eino 的 ChatModel。"), +} + +// 直接生成 +reply, err := cm.Generate(ctx, messages) +if err != nil { + return err +} +fmt.Println(reply.Content) + +// 流式生成 +stream, err := cm.Stream(ctx, messages) +if err != nil { + return err +} +defer stream.Close() + +for { + chunk, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + fmt.Print(chunk.Content) +} +``` + +这段代码不复杂,但它把 `Generate` 和 `Stream` 的边界已经说清了: + +- `Generate` 更像一次性拿结果 +- `Stream` 更像面向真实交互过程 + +## 3. `Message` 为什么不是字符串,而是“对话协议” + +很多人第一次接触 `schema.Message`,会觉得它不过是 prompt 的壳。 + +这就看浅了。 + +在 Eino 里,你操作的不是“一个大字符串”,而是一组有角色、有结构、有上下文语义的消息。 + +可以先看一个精简后的结构: + +```go +type Message struct { + // 表示当前消息的角色类型,比如 system、user、assistant、tool + Role schema.RoleType + + // 表示消息的纯文本内容 + Content string + + // 表示用户输入的多段内容,可包含文本、图片等多模态输入片段 + UserInputMultiContent []schema.MessageInputPart + + // 表示模型生成的多段输出,可包含文本、工具结果等多模态输出片段 + AssistantGenMultiContent []schema.MessageOutputPart + + // 表示当前消息中包含的工具调用信息 + ToolCalls []schema.ToolCall + + // 表示这条消息附带的响应元信息,比如 token 使用情况、模型信息等 + ResponseMeta *schema.ResponseMeta +} +``` + +这里最重要的不是字段多,而是字段背后的含义。 + +**`Role` 说明这条消息是谁说的。** + +常见角色有四个: + +- `system` +- `user` +- `assistant` +- `tool` + +一旦角色明确了,整个上下文组织方式就变了。 +你不再是“把几段文字拼起来”,而是在维护一套对话协议。 + +**`Content` 只是最基础的文本承载。** + +如果你只看到这个字段,很容易误以为 `Message` 还是老式 prompt。 +但后面的字段已经告诉你,官方想解决的问题远不止纯文本。 + +**`UserInputMultiContent` 和 `AssistantGenMultiContent` 说明它天生考虑了多模态。** + +文本、图片、音频、视频、文件,不是后补功能,而是消息层就留出了位置。 + +**`ToolCalls` 说明工具调用结果不是外挂。** + +以后你做 tool calling,多轮链路里 assistant 发起工具调用、tool 返回结果,最终都要回到 `Message` 这套协议里。 + +**`ResponseMeta` 说明模型输出不只是正文。** + +结束原因、token 统计这类信息,后面做可观测、排障、成本分析都要靠它。 + +所以真正该记住的不是“`Message` 有哪些字段”,而是这句话: + +> `schema.Message` 不是 prompt 文本,而是 Eino 里组织上下文的基本单元。 + +这一层一旦想明白,后面的多轮、Tool、多模态、Callback,你都会看得顺很多。 + +## 4. `Option` 不是参数补丁,而是模型调用的统一调度入口 + +很多人对 `Option` 的第一反应是:哦,就是几个可选参数。 + +这么理解不算错,但还是浅了半层。 + +因为 Eino 把模型参数统一塞进 `Option`,不是为了写法好看,而是为了让上层代码用统一方式调度不同模型能力。 + +官方这页文档提到的公共 Option 里,最常用的是这些: + +* `WithTemperature` // 设置采样温度,控制输出随机性 +* `WithMaxTokens` // 设置本次生成的最大输出 token 数 +* `WithModel` // 指定调用的模型名称 +* `WithTopP` // 设置 TopP 采样范围,控制候选词筛选 +* `WithStop` // 设置停止词,命中后终止生成 +* `WithTools` // 设置当前可供模型调用的工具列表 +* `WithToolChoice` // 设置模型的工具调用策略或指定调用某个工具 + + +你可以像这样传: + +```go +reply, err := cm.Generate(ctx, messages, + model.WithTemperature(0.7), + model.WithMaxTokens(1024), + model.WithModel("qwen-plus"), + model.WithTopP(0.9), + model.WithStop([]string{"Observation:"}), +) +``` + +工具相关的配置也能走统一入口: + +```go +reply, err := cm.Generate(ctx, messages, + model.WithTools(tools), + model.WithToolChoice(schema.ToolChoiceRequired, "query_weather"), +) +``` + +这里有个很容易混淆的点,顺手说清。 + +处于不同位置的同名 `WithTools`: + +- `ToolCallingChatModel.WithTools(tools)`:把工具绑定到一个新 model 实例上 +- `model.WithTools(tools)`:把工具作为本次调用的 Option 传进去 + +一个偏“模型实例能力绑定”。 +一个偏“单次调用配置”。 + +这就是组件体系里很常见的设计手法。 +同样叫 `WithTools`,但因为层次不一样,所以职责也不同。 + +本段落的意义,在于彰显 `Option` 的价值,并不是给你多几个旋钮而已。 +它真正解决的是: + +- 不同模型调用参数的统一入口 +- 工具和模型选择的统一配置方式 +- 上层业务不必和厂商私有参数直接耦合 + +如果你以后做的是平台化 AI 能力接入,这一层会特别有价值。 + +## 5. 为什么官方单独强调 `Callback` + +很多人第一次看 `Callback`,会本能地把它当成“日志钩子”。 + +这也不算错,但仍然低估了它。 + +`Callback` 真正解决的是:你怎么观察一次模型调用到底发生了什么。 + +官方给的输入输出结构也很直接: + +```go +type CallbackInput struct { + Messages []*schema.Message + Model string + Temperature *float32 + MaxTokens *int + Extra map[string]any +} + +type CallbackOutput struct { + Message *schema.Message + TokenUsage *schema.TokenUsage + Extra map[string]any +} +``` + +这意味着你至少能在三个时点做事: + +- 调用前看输入消息和模型配置 +- 调用后看输出消息和 token 使用 +- 流式调用时观察中间输出,尤其是 ToolCall 片段 + +最小示意可以写成这样: + +```go +handler := &callbacksHelper.ModelCallbackHandler{ + OnStart: func(ctx context.Context, info *callbacks.RunInfo, input *model.CallbackInput) context.Context { + fmt.Printf("start model=%s messages=%d\n", input.Model, len(input.Messages)) + return ctx + }, + OnEnd: func(ctx context.Context, info *callbacks.RunInfo, output *model.CallbackOutput) context.Context { + fmt.Printf("done tokens=%+v\n", output.TokenUsage) + return ctx + }, +} +``` + +如果你已经开始做 Agent,这个东西就更重要了。 + +因为一旦链路里出现: + +- 多轮消息 +- 工具调用 +- 流式输出 +- 多次模型往返 + +没有 `Callback`,你很快就会重新掉进黑盒里。 +而有了 `Callback`,后面接 `Trace`、接可观测平台、看 token 成本、查工具调用问题,才有抓手。 + + +## 6. 你可以自己实现 `ChatModel` +我觉得这页最容易被忽略、但却最有味道的部分,不是 `Generate`,也不是 `Message`。 + +而是最后那段“自行实现参考”。 + +**官网原话:** +![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/4ea977f6952644c491731f7d458a95d9.png) + + + + +这意味着 Eino 不只是给你几个现成适配器。 +它还在定义一条规范: + +> 如果你要接第三方模型、公司内网网关、私有推理服务,你应该按什么协议把它接进 Eino。 + +这件事对做企业内部平台的人尤其重要。 + +可以看成这种感觉: + +```go id="d6kyoz" +Eino的消息 --> 你包装一下 --> 你公司的模型接口 +你公司的返回 --> 你再包装一下 --> Eino的消息 +``` + +比如你公司已经有这样一个接口: + +```go id="xsxg2w" +func CallCompanyLLM(prompt string) (string, error) { + return "这是公司模型返回的结果", nil +} +``` + +那你自己实现 `ChatModel`,本质上就是再包一层: + +```go id="q0m71m" +func (m *MyChatModel) Generate(ctx context.Context, messages []*schema.Message) (*schema.Message, error) { + prompt := messages[len(messages)-1].Content + + text, err := CallCompanyLLM(prompt) + if err != nil { + return nil, err + } + + return &schema.Message{ + Role: schema.Assistant, + Content: text, + }, nil +} +``` + +意思就是: + +* 从 Eino 收到消息 +* 拿出内容 +* 调你自己的模型接口 +* 把结果塞回 `schema.Message` +* 这样 Eino 就能继续用了 + +所以“自己实现 `ChatModel`”翻成人话就是: + +**“把你自己的模型调用方式,包装成 Eino 规定的样子。”** + +这时再看前面开头的那条规范: + +- **第三方模型**:比如别家的大模型服务 +- **公司内网网关**:比如你们公司统一封装过的模型接口 +- **私有推理服务**:比如你们自己部署的模型服务 + +这时候,如果你只会直接调 SDK,后面的 Agent、Tool、Callback、Graph 往往都很难复用。 +但如果你按 `ChatModel` 协议接一层,整个系统就顺了。 + +官方给出的实现重点,可以压成一个 checklist: + +- 兼容公共 Option +- 正确触发 `OnStart / OnEnd / OnError` +- 流式输出结束后及时关闭 writer +- `WithTools` 返回新实例,而不是偷偷改当前实例 +- 让自定义模型也能被 `Chain`、`Graph`、`Agent` 直接消费 + +这才是组件ChatModel最硬的一层价值。 +所以本篇不仅在教你如何用框架,也在教你怎么把自己的模型能力接成框架的一部分。 +## 7. 总结 + +如果你问我,这篇 `ChatModel 使用说明` 到底在讲什么,我会给一个很直接的回答: + +> 它讲的不是“怎么调一次模型”,而是“模型能力在 Eino 里该怎样被标准化、结构化、可扩展地接入”。 + +再压缩成三句话,就是: + +- `Message` 是协议,不是字符串 +- `Stream` 是正经产品形态,不是锦上添花 +- `WithTools + Callback` 说明 `ChatModel` 从一开始就不是玩具层 + +所以别急着一上来就冲 `Agent`。 +很多人第一次学 Eino,最容易忽略的,恰恰是下面这层最关键的地基。 + +你把 `ChatModel` 看浅了,后面学到的很多东西都会像“会用”,但不一定“真懂”。 +而这层一旦吃透,`Tool`、`Trace`、`Runner`、`Agent` 这些能力,都会开始变得顺理成章。 + +## 参考资料 + +- Eino [ChatModel 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_model_guide/) +- AI大模型落地系列的入门必备教程中的[ChatModel与Message](https://blog.csdn.net/2302_80067378/article/details/159393888?spm=1001.2014.3001.5501) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人把 ChatModel 想简单了](./01-为什么很多人把ChatModel想简单了.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人把 ChatModel 想简单了](https://zhumo.blog.csdn.net/article/details/159492224) +- 官方文档:[ChatModel 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_model_guide/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/02-ChatTemplate\344\270\272\344\273\200\344\271\210\344\270\215\346\230\257\345\255\227\347\254\246\344\270\262\346\213\274\346\216\245.md" "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/02-ChatTemplate\344\270\272\344\273\200\344\271\210\344\270\215\346\230\257\345\255\227\347\254\246\344\270\262\346\213\274\346\216\245.md" new file mode 100644 index 0000000..1cf6d0c --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/02-ChatTemplate\344\270\272\344\273\200\344\271\210\344\270\215\346\230\257\345\255\227\347\254\246\344\270\262\346\213\274\346\216\245.md" @@ -0,0 +1,353 @@ +# AI 大模型落地系列|Eino 组件核心篇:ChatTemplate 为什么不是字符串拼接 + +> GitHub 主文:[当前文章](./02-ChatTemplate为什么不是字符串拼接.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:ChatTemplate 为什么不是字符串拼接](https://zhumo.blog.csdn.net/article/details/159500932) +> 官方文档:[ChatTemplate 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_template_guide/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:把 Prompt 组织方式从字符串拼装提升成消息模板和上下文协议。 +**适合谁看**:已经写过 Prompt,希望把 ChatTemplate 看成正式组件的读者。 +**前置知识**:ChatModel 与 Message、消息角色基础 +**对应 Demo**:[examples/chain-graph(包含 ChatTemplate 节点)](../../examples/chain-graph/README.md) + +**面试可讲点** +- 能解释 ChatTemplate 和字符串拼接的根本区别在于结构化上下文。 +- 能说明模板语法、MessagesPlaceholder 和下游编排的关系。 + +--- +为什么很多人已经会写 prompt(提示词) 了,到了 Eino 里,却还是经常把 `ChatTemplate` 用偏? + +因为太多人一看到 template,就条件反射地把它理解成“字符串替换器”。 +把 `{role}` 换进去,把 `{task}` 换进去,再把 history 手动拼成一大段文本,看起来也能跑。 +可问题恰恰就在这儿:你如果只是这么用,等于把 Eino 这层最关键的上下文组织能力,直接降级成了字符串拼接。 + +这篇文章就想回答两个问题: + +> `ChatTemplate` 到底解决了什么? +> 它为什么不是一个“高级一点的字符串模板”而已? + +## 1. `ChatTemplate` 是什么,不是什么 + +先把结论摆出来: + +`ChatTemplate` 不是字符串拼接工具。 +它是把变量、角色消息、历史对话组织成 `[]*schema.Message` 的组件。 + +这句话看起来只差几个字,实际差得很远。 + +如果你把它当成字符串模板,脑子里的链路通常是这样的: + +`变量 -> 替换文本 -> 拼 prompt -> 丢给模型` + +而 Eino 真正想让你建立的链路,是这样的: + +`变量 / 前驱节点输出 -> ChatTemplate -> []*schema.Message -> ChatModel` + +也就是说,`ChatTemplate` 干的不是“把几段字拼起来”,而是“把上下文整理成模型能消费的消息协议”。 + +这层价值主要体现在三件事上。 + +**第一**,它让 prompt 变成结构化消息,而不是一坨长字符串。 + +**第二**,它让多轮 history 的注入有统一入口,不用你手搓字符串去拼上下文。 + +**第三**,它能直接进入 `Chain`、`Graph`、`Callback` 这些编排和可观测链路里,说明它从一开始就不是一个工具函数,而是一个组件。 + +所以如果你问,`ChatTemplate` 在 Eino 里到底值不值得单独学? + +我的回答很直接: + +> 值得。因为它解决的不是“模板替换”,而是“Prompt 怎样以消息协议的方式进入 Eino”。 + +## 2. 接口虽短,但起的作用却不小 + +官方给出的核心接口其实非常短: +```go +type ChatTemplate interface { + Format(ctx context.Context, vs map[string]any, opts ...Option) ([]*schema.Message, error) +} +``` + +很多人第一次看到这行代码,会觉得不就是个格式化函数吗? +真要这么理解,还是看浅了。 + +这里最重要的,其实是 `Format` 的三个输入和一个输出。 + +`ctx` 不只是普通上下文。 +它即负责传递请求级信息,同时它也承载 `Callback Manager`。这意味着模板格式化这件事,并不是一个完全封闭的小动作,它是能被观测、能被接入回调链路的。 + +`vs` 虽是变量映射,但却不是“只能塞字符串”的变量映射。你既可以传 +```txt +"role": "专业助手" +``` +这种普通文本,也可以传 + ```txt + "history_key": []*schema.Message{...} + ``` + 这种消息列表。 + 换句话说,它接收的不是纯文本变量,而是上下文数据。 + +`opts` 也很有意思。 +官方没有给 `ChatTemplate` 设计一个“大而全的公共参数表”,而是把它作为具体实现的扩展点来留。这个意思其实很明确:Prompt 组件需要统一协议,但不想被统一成一个笨重的大接口。 + +最后是输出。 + +`Format` 返回的不是一段 prompt 文本,而是标准消息数组 `[]*schema.Message`。 +这一步就是 `ChatTemplate` 和字符串拼接最本质的分水岭。 + +你自己手拼字符串,最终交给模型的是一段文本。 +你用 `ChatTemplate`,最终交给模型的是一组角色明确、结构清晰的消息。 + +## 3. 官方提供了哪些构建方式 + + - **prompt.FromMessages()**:用于把多个 `message` 变成一个 `chat template`。 + + - **schema.Message{}**:`schema.Message` 是实现了 `Format` 接口的结构体,因此可直接构建 `schema.Message{}` 作为 template。 + + - **schema.SystemMessage()**:此方法是构建 `role` 为 `system` 的 `message` 快捷方法。 + + - **schema.AssistantMessage()**:此方法是构建 `role` 为 `assistant` 的 `message` 快捷方法。 + + - **schema.UserMessage()**:此方法是构建 `role` 为 `user` 的 `message` 快捷方法。 + + - **schema.ToolMessage()**:此方法是构建 `role` 为 `tool` 的 `message` 快捷方法。 + + - **schema.MessagesPlaceholder()**:可用于把一个 `[]*schema.Message` 插入到 `message` 列表中,常用于插入历史对话。 + +## 4. 一个最小例子,看懂它怎么工作 +> 先看一遍,留个整体印象,后面再拆开说。 + +```go +import ( + "github.com/cloudwego/eino/components/prompt" + "github.com/cloudwego/eino/schema" +) + +// 创建模板 +template := prompt.FromMessages(schema.FString, + schema.SystemMessage("你是一个{role}。"), + schema.MessagesPlaceholder("history_key", false), + &schema.Message{ + Role: schema.User, + Content: "请帮我{task}。", + }, +) + +// 准备变量 +variables := map[string]any{ + "role": "专业的助手", + "task": "写一首诗", + "history_key": []*schema.Message{ + {Role: schema.User, Content: "告诉我油画是什么?"}, + {Role: schema.Assistant, Content: "油画是xxx"}, + }, +} + +// 格式化模板 +messages, err := template.Format(context.Background(), variables) +``` + +这段代码真正值得你记住的,不是语法,而是它把几个关键动作放到了一起。 + +`system` 提示可以参数化,不需要写死。 + +history 可以整体注入,而且注入进去的仍然是 `[]*schema.Message`,不是你手工拼出来的一大段文本。 + +当前这一轮的 user 问题也可以模板化,跟 system 和 history 统一走一条格式化链路。 + +最后 `template.Format(...)` 产出的不是字符串,而是 `messages`。这些 `messages` 才是后面交给 `ChatModel` 的标准输入。 + +如果继续往下看,真正值得盯住的主要是下面三个点。 + +## 5. 三个最容易看浅的点 + +### 5.1 `schema.Message` 是模板单元,不是字符串壳 + +很多人学到 `prompt.FromMessages(...)` 时,会下意识把它理解成“多个 prompt 片段拼起来”。 +这个理解只对了一半。 + +它确实是在组合内容,但组合的不是普通字符串,而是消息模板。 + +比如: + +- `schema.SystemMessage(...)` +- `schema.UserMessage(...)` +- 甚至一个完整的 `schema.Message{}` + +这些东西放进 `prompt.FromMessages(...)` 以后,组成的是一组待格式化的消息模板,不是一篇待替换的大作文。 + +字符串拼接关心的是“句子怎么连起来”。 +而 `ChatTemplate` 关心的是“system 说什么,user 说什么,history 该插在哪,最后怎样变成标准消息协议”。 + +这两个层级,本来就不是一回事。 + +### 5.2 `MessagesPlaceholder` 才是很多人真正该盯住的点 + +如果说 `ChatTemplate` 里有一个最容易被低估、但对真实业务最重要的能力,那大概率就是 `schema.MessagesPlaceholder(...)`。 + +为什么? + +因为多轮对话里最常见的问题,从来不是“怎么替换 `{name}`”,而是“怎么把历史上下文塞进去,而且别塞乱了”。 + +很多人会这样干: + +把历史对话先手动拼成一大段字符串,再把它塞进某个 user prompt 里。 + +这种写法当然能跑,但它本质上还是字符串拼接。 +你原本可以传一个 `[]*schema.Message`,结果你自己把它打平成了纯文本。 +看起来省事,实际上是主动绕开了消息协议。 + +`schema.MessagesPlaceholder("history_key", false)` 的价值就在这儿。 +它让你可以把 `history_key` 对应的 `[]*schema.Message` 直接插进消息列表里。 + +也就是说,这条链路应该这么理解: + +`history -> MessagesPlaceholder -> []*schema.Message` + +它的重点不是“占位符”三个字,而是“history 仍然以消息数组的形态进入模板”。 + +这个思路一旦立住,你后面做多轮、做记忆、做 Agent 上下文拼装,脑子都会顺很多。 + +### 5.3 三种模板语法怎么选,别一上来就上复杂度 + +官方内置了三种模板化方式: + +- `schema.FString` +- `schema.GoTemplate` +- `schema.Jinja2` + +它们不是“谁更高级”,而是适用场景不同。 + +`schema.FString` 最直观,用 `{variable}` 做替换,适合大多数基础场景。 +如果你的需求只是把角色、任务、问题这类变量填进去,它通常就够了。 + +`schema.GoTemplate` 适合需要条件判断、循环拼接这类逻辑的场景。 +一旦你的模板里已经出现“有值就展示,没有就省略”“遍历一组数据生成内容”这种诉求,Go 模板会更顺手。 + +`schema.Jinja2` 更像是给有模板引擎经验的人准备的。(python风格) +如果你平时就熟悉 Jinja 风格,那它上手会更自然。 + +我的建议很简单: + +别把模板引擎选型搞成技术表演。 +简单替换就用 `schema.FString`,真有条件逻辑再上 `schema.GoTemplate`,已经习惯 Jinja 再选 `schema.Jinja2`。 + +你要解决的是消息组织问题,不是比赛谁的模板更花。 + + +## 6. 为什么它能进入 `Chain / Graph / Callback` +只看单独调用,你很容易以为 `ChatTemplate` 不过是个前置小工具。 +可一旦站到编排视角,它的定位就完全变了。 + +在 `Chain` 里,`ChatTemplate` 是一个很标准的上下文准备节点。 +它的任务不是回答问题,而是把输入变量整理成后续模型能吃的消息列表。 + +在 `Graph` 里,这个味道更明显。 +它可以消费前驱节点经过 `compose.WithOutputKey(...)` 包装后的 `map[string]any` 输出,然后继续把这些数据组织成消息。 + +短示意可以看成这样: + +```go +// 创建一个 Chain:输入是 map[string]any,输出是 []*schema.Message +// 也就是说,这条链路接收一组变量,最终产出标准消息列表,供后续 ChatModel 使用。 +chain := compose.NewChain[map[string]any, []*schema.Message]() + +// 把前面定义好的 ChatTemplate 挂到 Chain 上。 +// 作用:把输入变量格式化成消息数组。 +chain.AppendChatTemplate(template) + + +// 创建一个 Graph:输入是 string,输出是 []*schema.Message +// 这里的意思是:Graph 接收一段原始字符串,经过节点处理后,最终产出消息列表。 +graph := compose.NewGraph[string, []*schema.Message]() + +// 添加一个 Lambda 节点,节点名叫 rewrite_query +graph.AddLambdaNode( + "rewrite_query", + + // 这个 Lambda 的作用是把原始输入改写成一个更完整的用户问题 + // 例如输入:"123" + // 输出:"请帮我总结这段需求:123" + compose.InvokableLambda(func(ctx context.Context, input string) (string, error) { + return "请帮我总结这段需求:" + input, nil + }), + + // 把这个节点的输出包装成 map[string]any 里的一个字段,key 叫 query + // 这样后面的 ChatTemplate 就可以用 {query} 来取这个值 + compose.WithOutputKey("query"), +) + +// 添加一个 ChatTemplate 节点,节点名叫 prompt_node +graph.AddChatTemplateNode("prompt_node", prompt.FromMessages(schema.FString, + + // system 消息:给模型设定角色 + // 这里的 {role} 需要在运行时从变量里传入 + schema.SystemMessage("你是一个{role}。"), + + // user 消息:使用上一个节点产出的 query + // 因为 rewrite_query 节点通过 WithOutputKey("query") 输出了 query, + // 所以这里可以直接写 {query} + schema.UserMessage("{query}"), +)) +``` + +翻成人话就是: + +前面的节点先产出数据。 +如果它通过 `compose.WithOutputKey("query")` 把结果包成 `map[string]any`,那后面的 `ChatTemplate` 节点就可以直接用这个 key 去取值,再把它组织成标准消息。 + +这时你会发现,`ChatTemplate` 真正扮演的角色,其实是“消息协议装配器”。 +它站在模型前面,把上游零散的数据,整理成模型真正能消费的输入。 + +也正因为如此,它才能自然接进 `Chain` 和 `Graph`,而不是只能当一个局部 helper 用完即弃。说到底,它不是零散的字符串 helper,而是一个可以被编排系统识别的节点。 + +### 为什么连 `Callback` 也会进来 + +很多人看到 Prompt 组件的回调支持,会有一个误判: + +“模板格式化也要回调?是不是有点小题大做了?” + +如果你只是把 `ChatTemplate` 当字符串替换器,你确实会这么想。 +但如果你已经接受了它是一个正式组件,这件事就很合理了。 + +官方给了 `prompt.CallbackInput` 和 `prompt.CallbackOutput`,这意味着你在模板格式化前后,是可以被回调系统观察到的。 + +你能看到: + +- 输入的变量是什么 +- 当前模板集合是什么 +- 格式化产出的消息结果是什么 + +而在生命周期上,对应的就是 `OnStart`、`OnEnd`、`OnError` 这几个钩子。 + +这层能力的意义,不只是“记个日志”。 +而是在告诉你:Prompt 组件也属于 Eino 的运行链路,它不是一个藏在角落里的文本处理函数。 + + +## 7. 总结 + +如果你问我,本篇 `ChatTemplate` 真正想让人学会什么,我会把答案压成三句话: + +1、`ChatTemplate` 解决的是消息组织,不是字符串替换。 + +2、`MessagesPlaceholder` 是多轮上下文接入的关键,因为它让 history 以 `[]*schema.Message` 的形态进入模板,而不是被你手工压成文本。 + +3、`Chain`、`Graph`、`Callback` 这些能力同时出现,说明 `ChatTemplate` 从一开始就是组件层能力,不是 prompt 拼接小工具。 + +所以别再把它当“模板语法说明书”看了。 +你一旦把这层看懂,后面再去学 `ToolsNode&Tool`,或者继续往 `Retriever / RAG` 的上下文拼装走,很多设计都会顺理成章。 + +## 参考资料 + +- CloudWeGo Eino [ChatTemplate 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_template_guide/) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 组件核心篇:ChatTemplate 为什么不是字符串拼接](./02-ChatTemplate为什么不是字符串拼接.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:ChatTemplate 为什么不是字符串拼接](https://zhumo.blog.csdn.net/article/details/159500932) +- 官方文档:[ChatTemplate 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/chat_template_guide/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/03-Embedding\345\210\260\345\272\225\350\247\243\345\206\263\344\272\206\344\273\200\344\271\210.md" "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/03-Embedding\345\210\260\345\272\225\350\247\243\345\206\263\344\272\206\344\273\200\344\271\210.md" new file mode 100644 index 0000000..d922358 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/03-Embedding\345\210\260\345\272\225\350\247\243\345\206\263\344\272\206\344\273\200\344\271\210.md" @@ -0,0 +1,517 @@ +# AI 大模型落地系列|Eino 组件核心篇:Embedding 到底解决了什么 + +> GitHub 主文:[当前文章](./03-Embedding到底解决了什么.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:Embedding 到底解决了什么](https://zhumo.blog.csdn.net/article/details/159079089) +> 官方文档:[Embedding 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/embedding_guide/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:把 Embedding 从一个能调的接口讲成语义检索、RAG 入库和相似度计算的基础设施。 +**适合谁看**:准备进入 RAG 或语义检索体系的 Go 开发者。 +**前置知识**:前置基础篇中的 RAG 概念、向量与相似度的基本理解 +**对应 Demo**:[官方 Embedding 示例(本仓后续补充独立 demo)](https://www.cloudwego.io/zh/docs/eino/core_modules/components/embedding_guide/) + +**面试可讲点** +- 能说明 Embedding 解决的是把文本映射成可计算语义空间的问题。 +- 能把 Embedding 放进切块、入库、检索、生成的完整 RAG 链路里。 + +--- +说到 embedding 组件,本质上就是把**文本变成一串数字向量**,让**程序**能“按语义理解文本”,而不只是按字符串匹配。 + +你可以把它理解成: + +* 原始文本:`"今天天气不错"` +* 转成向量后:`[0.12, -0.87, 0.44, ...]` + +这串向量人是看不懂的,因为他是拿个程序看的。 +机器可以拿它来算“两个文本像不像”。 + +## 有啥用?! +### 他能干嘛? +平时大家会用到的地方 + +最常见就是这几类: + +**1. 文本相似度计算** +比如: + +* “怎么退款” +* “我要申请退钱” + +虽然字不一样,但意思接近。 +Embedding 后,这两句话的向量距离会比较近,所以系统知道它们语义相似。 +这个我在之前的博客中提到过 + +**2. 语义搜索** +这也是最常见的用途。 +比如你有很多文档、知识库、FAQ,用户问: + +* “怎么修改收货地址” + +系统不是只搜关键词“修改”“地址”,而是把这个问题也做成向量,然后去找**语义最接近**的文档片段。 +这样即使文档里写的是“变更配送地址”,也能搜出来。 + +**3. RAG / 知识库问答** +这类项目里 embedding 基本是核心组件之一了。流程通常是: + +* 先把知识库里的文本切块 +* 然后为每个文本块生成 embedding +* 存到向量库里 +* 用户提问时,也生成一个 embedding +* 去向量库里找最相关的内容 +* 再把找到的内容喂给大模型回答 + +也就是说,它是“**先找资料**”这一步的关键。 + +**4. 文本聚类 / 分类 / 去重** +这个是生活中其他方面的应用,非AI +比如你有很多评论、工单、反馈,可以用 embedding 做: + +* 相似工单归类 +* 重复问题合并 +* 用户反馈主题聚类 + +--- + +### 它不能直接干嘛? + +它**不是直接拿来生成回答的**。 +它更像一个“文本编码器”或者“语义检索工具”。 + +也就是: + +* **LLM**:负责生成、总结、对话 +* **Embedding**:负责把文本映射到语义空间,方便检索、匹配、聚类 + +--- + +### 总结: + +这个组件的核心用途就一句话: + +> **把文字转换成可计算的语义特征,方便程序判断哪些文本意思接近。** + +--- + +## 浅用之法 +接下来,我先说下基础语法。 +```go +EmbedStrings(ctx, texts []string, opts ...Option) ([][]float64, error) +``` + +意思就是: + +* 输入:多段文本 +* 输出:每段文本对应的一个向量 + +例如: + +```go +texts := []string{ + "hello", + "how are you", +} +vectors, err := embedder.EmbedStrings(ctx, texts) +``` + +返回的 `vectors` 就是两段文本的向量表示。 +后面你可以拿这些向量去做: + +* 相似度比较 +* 存入向量数据库 +* 召回相关知识片段 +* 聚类分析 + +--- + +## 食用之法 +它的使用可以分成两层来看: + +1. **最直接的用法:给几段文本生成向量**也就是我在浅用之法中提到的 +2. **真正落地的用法:配合检索 / 向量库 / RAG 一起用** + +我直接教你 “你上手怎么写”。 + +--- + +### 一、最基本用法:直接调用 `EmbedStrings` + +本质核心就这几步: + +#### 1. 创建 embedder + +```go +import "github.com/cloudwego/eino-ext/components/embedding/openai" +// 这个导入的包,是兼容openai的。 +// 如果你要用豆包,可以专门调用embedding/ark 这个包。 + +embedder, err := openai.NewEmbedder(ctx, &openai.EmbeddingConfig{ + APIKey: accessKey, + Model: "text-embedding-3-large", + Dimensions: &defaultDim, + Timeout: 0, +}) +if err != nil { + panic(err) +} +``` + +这里的作用是初始化一个“文本转向量”的对象。 + +几个关键参数: + +* `APIKey`:调用模型服务的密钥 +* `Model`:选哪个 embedding 模型 +* `Dimensions`:向量维度 +* `Timeout`:超时时间 + +--- + +#### 2. 调用 `EmbedStrings` + +```go +texts := []string{ + "hello", + "how are you", +} + +vectors, err := embedder.EmbedStrings(ctx, texts) +if err != nil { + panic(err) +} +``` + +这一步做完后: + +* `texts[0]` 对应 `vectors[0]` +* `texts[1]` 对应 `vectors[1]` + +也就是说,输入几段文本,输出几组向量。 + +--- + +#### 3. 向量拿来干嘛 + +生成出来的 `vectors` 一般不会直接打印给用户看,而是继续做下面这些事: + +* 存到向量数据库 +* 跟别的向量算相似度 +* 做召回 +* 做聚类 +* 做去重 + +--- + +### 二、完整demo + +你可以把它理解成一个普通组件,哪里需要文本转向量,哪里调用。 + +例如: + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/cloudwego/eino-ext/components/embedding/openai" +) + +func main() { + ctx := context.Background() + defaultDim := 3072 + accessKey := "your-api-key" + + embedder, err := openai.NewEmbedder(ctx, &openai.EmbeddingConfig{ + APIKey: accessKey, + Model: "text-embedding-3-large", + Dimensions: &defaultDim, + Timeout: 0, + }) + if err != nil { + log.Fatal(err) + } + + texts := []string{ + "退款怎么申请", + "如何进行退钱操作", + "今天天气不错", + } + + vectors, err := embedder.EmbedStrings(ctx, texts) + if err != nil { + log.Fatal(err) + } + + fmt.Println("文本数量:", len(vectors)) + fmt.Println("第一条文本向量维度:", len(vectors[0])) +} +``` + +这就是最标准的“用”了! + +--- + +### 三、带 Option 怎么用 + +公共 option 其实也挺有用的,比如 `WithModel`。 + +这表示你在调用时,可以临时覆盖模型参数。 + +```go +vectors, err := embedder.EmbedStrings(ctx, texts, + embedding.WithModel("text-embedding-3-small"), +) +``` + +大致意思就是: + +* `embedder` 初始化时有一个默认模型 +* 这次调用时,临时改成另一个模型 + +这个适合: + +* 平时默认用大模型 +* 某些场景为了省钱/提速,改用小模型 + +但是我在此,不得点明一下,虽然向量在不同模型之前还是有一定的兼容,但是尽量不切换,就不要切换,影响效果 + +--- + +### 四、在编排中怎么用 + +如果你不是手动一行一行写,而是用 Eino 的 `Chain` 或 `Graph`,就可以把 embedding 当成节点塞进去。 + +#### 在 Chain 中使用 +初次接触chain的话,你可以将其当成一条流水线 +```go +chain := compose.NewChain[[]string, [][]float64]() +chain.AppendEmbedding(embedder) +``` + +意思是: + +* 输入:`[]string` +* 输出:`[][]float64` + +也就是整条链专门做“文本 -> 向量”。 + +--- + +#### 在 Graph 中使用 + +```go +graph := compose.NewGraph[[]string, [][]float64]() +graph.AddEmbeddingNode("embedding_node", embedder) +``` + +意思是把 embedding 作为图里的一个节点,后面可以接别的节点一起跑。 + +--- + +### 五、带 Callback 怎么用 + +这个一般用于: + +* 记录日志 +* 统计 token +* 监控调用过程 +* 调试输入输出 + +Callback有点像 给整个链路,外挂了一层“生命周期 中间件 / 钩子机制" + +通常是:定义 handler,然后通过 `compose.WithCallbacks` 传进去。 + +例如: + +```go +handler := &callbacksHelper.EmbeddingCallbackHandler{ + OnStart: func(ctx context.Context, runInfo *callbacks.RunInfo, input *embedding.CallbackInput) context.Context { + log.Printf("开始 embedding,文本数: %d, 内容: %v\n", len(input.Texts), input.Texts) + return ctx + }, + OnEnd: func(ctx context.Context, runInfo *callbacks.RunInfo, output *embedding.CallbackOutput) context.Context { + log.Printf("embedding 完成,生成向量数: %d\n", len(output.Embeddings)) + return ctx + }, +} +``` + +然后运行时: + +```go +callbackHandler := callbacksHelper.NewHandlerHelper().Embedding(handler).Handler() + +runnable, _ := chain.Compile(ctx) +vectors, err := runnable.Invoke(ctx, []string{"hello", "how are you"}, + compose.WithCallbacks(callbackHandler), +) +``` + +这样你就能看到: + +* 输入了什么 +* 什么时候开始 +* 什么时候结束 +* 输出了多少向量 +* token 消耗多少 + +--- + +### 六、真实场景 + +真正业务里,embedding 很少是“调一下就结束”, +我拿知识库问答,给大家描绘一下整体流程。 + +#### 场景:做知识库问答 + +#### 第一步:把知识库切块 + +比如一篇文档切成很多段: + +```go +chunks := []string{ + "退款申请需要在订单完成后7天内提交", + "修改收货地址请在发货前联系人工客服", + "发票可在订单详情页申请", +} +``` + +#### 第二步:给每个 chunk 生成向量 + +```go +chunkVectors, err := embedder.EmbedStrings(ctx, chunks) +``` + +#### 第三步:存起来 + +通常会存到向量数据库里,同时保存原文: + +* 文本内容 +* 对应向量 +* 文档来源 +* chunk id + +#### 第四步:用户提问时,也生成向量 + +```go +query := []string{"订单下完以后地址还能改吗"} +queryVector, err := embedder.EmbedStrings(ctx, query) +``` + +#### 第五步:拿 query 的向量去检索最相近的 chunk + +找出最相似的几段知识。 + +#### 第六步:把召回结果交给大模型回答 + +这才变成完整的 RAG。 + +--- + +### 七、语法总结 + +#### 最小步骤 + +1. 初始化 embedder +2. 调 `EmbedStrings` +3. 拿到 `[][]float64` + +#### 常见增强 + +4. 用 `Option` 临时覆盖参数 +5. 用 `Callback` 打日志和监控 +6. 放进 `Chain` / `Graph` 编排 + +--- + +### 八、模板总结 + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/cloudwego/eino/components/embedding" + embeddingOpenAI "github.com/cloudwego/eino-ext/components/embedding/openai" +) + +func main() { + ctx := context.Background() + defaultDim := 3072 // 通常是定死的 + accessKey := "your-api-key" + + embedder, err := embeddingOpenAI.NewEmbedder(ctx, &embeddingOpenAI.EmbeddingConfig{ + APIKey: accessKey, + Model: "text-embedding-3-large", + Dimensions: &defaultDim, + Timeout: 0, + }) + if err != nil { + log.Fatal(err) + } + + texts := []string{ + "退款怎么申请", + "如何退钱", + "修改收货地址的方法", + } + + vectors, err := embedder.EmbedStrings( + ctx, + texts, + embedding.WithModel("text-embedding-3-small"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("生成了 %d 个向量\n", len(vectors)) + fmt.Printf("每个向量维度: %d\n", len(vectors[0])) +} +``` + +--- + + +### 九、尾声 + +大家可以把它记成: + +> **Embedding 的“用法”就是:先把文本喂进去生成向量,再把这个向量用于检索、匹配、聚类等后续处理。** + +相信大家看到这里,应该也明白了: +**“会调用 embedding”** 和 **“会用 embedding 做业务”** 是两回事。 + +前者很简单,就是: + +* NewEmbedder +* EmbedStrings + +后者才是完整链路,比如: + +* 文本切块 +* 向量生成 +* 向量存储 +* 相似检索 +* 大模型回答 + + +--- +1、OpenAI开发者([向量嵌入](https://developers.openai.com/api/docs/guides/embeddings/)) +2、官方文档 CloudWeGo([Embedding 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/embedding_guide/#option-%E5%92%8C-callback-%E4%BD%BF%E7%94%A8)) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 组件核心篇:Embedding 到底解决了什么](./03-Embedding到底解决了什么.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:Embedding 到底解决了什么](https://zhumo.blog.csdn.net/article/details/159079089) +- 官方文档:[Embedding 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/embedding_guide/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/04-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\345\206\231Tool\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202ToolsNode.md" "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/04-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\345\206\231Tool\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202ToolsNode.md" new file mode 100644 index 0000000..8dcde9b --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/04-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\345\206\231Tool\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202ToolsNode.md" @@ -0,0 +1,591 @@ +# AI 大模型落地系列|Eino 组件核心篇:为什么很多人会写 Tool,却没真正看懂 ToolsNode + +> GitHub 主文:[当前文章](./04-为什么很多人会写Tool,却没真正看懂ToolsNode.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会写 Tool,却没真正看懂 ToolsNode](https://zhumo.blog.csdn.net/article/details/159511006) +> 官方文档:[ToolsNode & Tool 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/tools_node_guide/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从 Tool、ToolsNode、ToolCall 到自定义 Tool 的完整链路重新理解工具能力是怎么接进 Eino 的。 +**适合谁看**:已经能写简单 Tool,但想把工具调用链讲透的工程师。 +**前置知识**:Tool 与文件系统访问、消息角色与函数调用基础 +**对应 Demo**:[examples/tool-filesystem(展示工具调用闭环)](../../examples/tool-filesystem/README.md) + +**面试可讲点** +- 能解释 Tool 和 ToolsNode 的职责边界,不把两者混为一谈。 +- 能说明 ToolCall 是怎么从模型决策一路落到工具执行的。 + +--- +很多人学 Eino 的 `Tool Calling`,第一反应是先把几个 `Tool` 注册上,再让 `Agent` 跑起来。 +代码能跑,演示也有。 +可一旦你继续追问:到底是谁决定调用哪个 `Tool`?`ToolsNode` 到底做了什么?工具结果又是怎么回到消息链路里的?很多人就开始说不清了。 + +这不奇怪。 +因为很多文章只教你“怎么把工具接上”,很少有人去讲透“执行边界”。 +结果就是,很多人会写 `Tool`,但对 `ToolsNode` 的理解还停留在“工具箱”这三个字上。 + +如果你前面已经看过我那篇入门篇[《AI大模型落地系列:一文读懂 Eino 的 Tool 和文件系统访问》](https://blog.csdn.net/2302_80067378/article/details/159395909?spm=1001.2014.3001.5501),那篇主要在讲:`Tool` 怎么让 Agent 真正碰到外部世界。 +这一篇换个角度。 +不讲文件系统接入,不讲 `DeepAgent`,只讲组件层最关键的一件事: + +> 在 Eino 里,`Tool` 和 `ToolsNode` 到底分别在解决什么? + +## 1. 工具调用(Tool Calling)中,调用链路十分值得重视 + +很多人对 `Tool` 的第一印象,是“给模型加插件”。 +很多人对 `ToolsNode` 的第一印象,是“工具执行器”。 + +这两个理解都不算错。 +但如果只停在这一步,还是太粗了。 + +因为真正影响你后面写 `Agent`、排查 `Tool` 问题、做多轮编排的,不是“知道有这么两个组件”,而是你能不能把它们的边界拆开。 + +说白一点: + +- `Tool` 解决的是“能力怎样被声明出来,供模型选择” +- `ToolsNode` 解决的是“模型一旦决定调用,系统怎样把调用真正执行掉” + +这两层一旦混在一起,后面就很容易出现三种常见误判: + +- 以为 `ToolsNode` 会替你决定该调哪个工具 +- 以为只要写了 `InvokableRun`,工具调用链路就算理解完了 +- 以为 `Tool Calling` 的核心只是“把函数包一层” + +这三种理解,都会让你在工程里很快撞墙。 + +因为一轮真正的工具调用,从来不是“注册一个函数”这么简单。 +它至少包括: + +- 模型基于 `ToolInfo` 决定要不要调工具 +- 模型产出 `ToolCall` +- `ToolsNode` 根据 `ToolCall` 找到对应 `Tool` +- 工具被实际执行 +- 执行结果再被封回消息链路,继续交给模型 + +真正该盯住的,不只是“工具怎么写”,而是“这一整条链路是怎么串起来的”。 + +## 2. `Tool` 是能力协议,`ToolsNode` 是执行中枢 + +先把最重要的一句话摆出来: + +> `ChatModel` 决定调用谁,`ToolsNode` 负责把调用真正落地。 + +这个顺序不能反。 + +`Tool` 不是决策器。 +`ToolsNode` 也不是决策器。 +真正做“要不要调工具、调哪个工具、传什么参数”这件事的,是前面的 `ChatModel`。 + +你可以把一轮链路先压成下面这样: + +```text +用户问题 + -> ChatModel + -> assistant message(内含 ToolCalls) + -> ToolsNode + -> tool message / ToolResult + -> ChatModel + -> 最终回答 +``` + +这里最容易被忽略的一点是: + +`ToolsNode` 不负责“思考”。 +它只负责“执行”。 + +也就是说,`ToolsNode` 不会自己判断“天气工具和搜索工具哪个更合适”。 +它做的事情更接近后端里的调度层: + +- 从输入消息里拿到 `ToolCalls` +- 按名称找到对应的 `Tool` +- 按参数实际执行 +- 把结果包装成后续消息 + +所以如果你问,`Tool` 和 `ToolsNode` 分别像什么? + +- `Tool` 更像一份对外能力协议 +- `ToolsNode` 更像一次工具调用的执行中枢 + +一个负责“把能力暴露给模型”,一个负责“把模型已经做出的调用决定执行掉”。 + +## 3. 一轮 Tool Calling 在 Eino 里到底怎么走 + +如果只看概念,很多人会觉得已经懂了。 +但真要把链路讲清楚,还是得抓住几个关键类型。 + +先看入口。 + +`schema.Message` 不只是对话消息,它也是工具调用的载体。 +当 `assistant` 这条消息里带上 `schema.Message.ToolCalls` 时,就意味着:模型已经产出了要执行的工具调用列表。 + +而 `schema.ToolCall` 里最关键的是两块: + +- `ID`:标识这一次具体调用 +- `Function`:里面有 `schema.FunctionCall.Name` 和 `schema.FunctionCall.Arguments` + +翻成人话就是: + +- `Name` 说明要调哪个工具 +- `Arguments` 是 JSON 字符串,说明这次调用传什么参数 + +官方源码是这么定义的: +这样一个顺序:**message**->**ToolCall**->**FuntionCall** +```go +// schema/message.go +type Message struct { + // 对于工具调用消息,这里的 role 应该是 'assistant' + Role RoleType `json:"role"` + + // 这里的每一个 ToolCall 都由 ChatModel 生成,并交给 ToolsNode 执行 + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + + // 其他字段…… +} + +// ToolCall 表示消息中的一次工具调用。 +// 当 assistant 消息里需要发起工具调用时,会使用它。 +type ToolCall struct { + // Index 用于标识一条消息中存在多个工具调用时的顺序位置。 + // 在流式模式下,它也用于标识某个工具调用的分片,以便后续合并。 + Index *int `json:"index,omitempty"` + + // ID 是这次工具调用的唯一标识,可用于定位某一次具体调用。 + ID string `json:"id"` + + // Type 是工具调用的类型,默认值是 "function"。 + Type string `json:"type"` + + // Function 表示这次要执行的函数调用信息。 + Function FunctionCall `json:"function"` + + // Extra 用来存储这次工具调用的额外信息。 + Extra map[string]any `json:"extra,omitempty"` +} + +// FunctionCall 表示消息中的函数调用。 +// 它用于 assistant 消息中。 +type FunctionCall struct { + // Name 是要调用的函数名称,可用于标识具体调用哪个函数。 + Name string `json:"name,omitempty"` + + // Arguments 是调用该函数时传入的参数,格式为 JSON 字符串。 + Arguments string `json:"arguments,omitempty"` +} +``` + +这时 `ToolsNode` 真正依赖的,不是你的业务 prompt,也不是用户原始问题,而是这条带着 `ToolCalls` 的 `assistant message`。 + +再往下,就是 `compose.ToolsNodeConfig`。 +这块配置的重点,虽然它 “字段多”。 + +最值得盯住的是这几个字段: + +- `Tools []tool.BaseTool`:当前可执行的工具列表 +- `ExecuteSequentially bool`:多个 `ToolCall` 时,是否按消息里的顺序串行执行 +- `UnknownToolsHandler`:模型调了一个未注册工具时怎么处理 +- `ToolArgumentsHandler`:工具执行前,是否要对参数做统一修正或预处理 +- `ToolCallMiddlewares`:是否要给工具调用挂统一中间件 + +这里有个点特别容易被理解错: + +`ExecuteSequentially` 控制的是**执行时序**,不是**模型决策顺序**。 + +模型在 `ToolCalls` 里给出的顺序,是它产出调用计划的顺序。 +`ToolsNode` 如果开启串行执行,就按这个顺序一个一个跑。 +如果不开启,就允许按自己的执行策略处理多个调用。 + +也就是说,这个配置回答的是: + +> 多个调用来了以后,执行层怎么跑? + +它回答的不是: + +> 模型为什么先调 A 再调 B? + +后一个问题,仍然属于 `ChatModel` 的决策范围。 + +至于 `UnknownToolsHandler` 和 `ToolArgumentsHandler`,它们都很像后端系统里的“兜底钩子”: + +- `UnknownToolsHandler` 适合处理模型幻觉出来的工具名,或者做统一降级 +- `ToolArgumentsHandler` 适合做参数清洗、默认值补齐、审计或兼容旧参数格式 + +所以一轮 `Tool Calling` 真正的边界应该这样看: + +- 模型负责产出 `ToolCall` +- `ToolsNode` 负责消费 `ToolCall` +- `Tool` 负责提供实际能力 + +这三层拆开以后,整条链路就顺了。 + +## 4. 一个 Tool 至少要包含什么 + +很多人第一次写自定义工具,容易把注意力全放在“函数体怎么写”上。 +其实不是。 + +对 Eino 来说,一个 `Tool` 至少要同时解决两件事: + +- 告诉模型“我是谁、能干什么、需要什么参数” +- 告诉运行时“真调到我时,我该怎么执行” + +所以最小定义一定绕不开 `tool.BaseTool`。 +```go +// 基础工具接口,提供工具信息 +type BaseTool interface { + Info(ctx context.Context) (*schema.ToolInfo, error) +} +``` + +`tool.BaseTool` 只要求一个 `Info(ctx)` 方法,返回 `*schema.ToolInfo`。 +而 `schema.ToolInfo` 本质上就是工具协议: + +- `Name`:工具名 +- `Desc`:工具描述 +- `ParamsOneOf`:参数约束 + +这一步不是走形式。 +模型到底能不能正确构造 `ToolCall`,很大程度上就取决于这份 `ToolInfo` 写得清不清楚。 + +在可执行接口上,Eino 把 `Tool` 分成两组。 + +第一组是标准工具: + +- `tool.InvokableTool`:同步调用,输入是 JSON 字符串,输出是字符串 +- `tool.StreamableTool`:流式调用,输出是字符串流 + +```go +type InvokableTool interface { + BaseTool + InvokableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (string, error) +} + +type StreamableTool interface { + BaseTool + StreamableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (*schema.StreamReader[string], error) +} +``` + + +第二组是增强型工具: +- `tool.EnhancedInvokableTool`:输入是 `*schema.ToolArgument`,输出是 `*schema.ToolResult` +- `tool.EnhancedStreamableTool`:输入是 `*schema.ToolArgument`,输出是 `*schema.StreamReader[*schema.ToolResult]` + +```go +// EnhancedInvokableTool 是支持返回结构化多模态结果的工具接口 +// 与返回字符串的 InvokableTool 不同,此接口返回 *schema.ToolResult +// 可以包含文本、图片、音频、视频和文件 +type EnhancedInvokableTool interface { + BaseTool + InvokableRun(ctx context.Context, toolArgument *schema.ToolArgument, opts ...Option) (*schema.ToolResult, error) +} + +// EnhancedStreamableTool 是支持返回结构化多模态结果的流式工具接口 +// 提供流式读取器以逐步访问多模态内容 +type EnhancedStreamableTool interface { + BaseTool + StreamableRun(ctx context.Context, toolArgument *schema.ToolArgument, opts ...Option) (*schema.StreamReader[*schema.ToolResult], error) +} +``` + +其中官方对,ToolPartType 定义了五种类型: +```go +// ToolPartType 定义工具输出部分的内容类型 +type ToolPartType string + +const ( + ToolPartTypeText ToolPartType = "text" // 文本 + ToolPartTypeImage ToolPartType = "image" // 图片 + ToolPartTypeAudio ToolPartType = "audio" // 音频 + ToolPartTypeVideo ToolPartType = "video" // 视频 + ToolPartTypeFile ToolPartType = "file" // 文件 +) +``` + +而恰恰 `schema.ToolResult` 不是一个简单字符串。 +它的核心是 `Parts []ToolOutputPart`,也就是你可以返回: + +- 文本 +- 图片 +- 音频 +- 视频 +- 文件 + +这也是为什么增强型 `Tool` 不只是“返回值换个结构体”。 +它其实是在告诉框架:这个工具的结果,不一定是一段纯文本。 + + +因此: +- 标准工具适合“查一下天气、算个表达式、调个普通接口”这类文本结果场景 +- 增强型工具适合“返回图片、音频、视频、文件,或者结构化多模态内容”这类场景 + + + + +还有一个细节必须记住。 + +> 当同一个工具同时实现了标准接口和增强型接口时,`ToolsNode` 会优先走增强型接口。 + +这点如果你没建立起心智,后面排查“为什么没走我预想的那个执行分支”时会很别扭。 + +## 5. 怎么自己创建一个 Tool + +说到“怎么写 Tool”,很多人最容易陷进去的地方,是把精力全花在“选哪个 helper 函数”上。 +其实 helper 重要,但不是最重要。 + +真正更重要的是三件事: + +- 参数约束是否和真实输入一致 +- 工具职责是否单一 +- 返回形态是否和消费场景匹配 + +从使用顺序上,我更推荐这样理解。 + +第一优先,`InferTool` 和 `InferEnhancedTool`。 + +这两个方法最适合日常业务开发。 +原因很简单:参数约束可以直接写在输入结构体上,你不需要一边维护函数入参,一边再手动维护一份 `ParamsOneOf`。 + +第二优先,`NewTool` 和 `NewEnhancedTool`。 + +这更适合你已经有很明确的 `schema.ToolInfo`,或者你就是想手工控制参数描述。 +它的优势是灵活,代价是你自己要保证 `ToolInfo` 和真实入参别跑偏。 + +第三种,直接实现接口。 + +这类写法最原始,但也最自由。 +如果你要做底层封装、复杂参数处理、或者对执行过程有更强控制欲,这种方式最稳。 +代价也最明显:参数解析、错误处理、结构约束,都得你自己收拾。 + +再往后,就是生态层选择: + +- 能直接复用的,优先看 `eino-ext` +- 外部系统已经通过 MCP 暴露能力的,可以直接把 MCP Tool 接进来 + +但无论你选哪一种创建方式,都别把重点放错。 + +很多人以为“把函数包成 Tool”是难点。 +其实真正的难点通常是: + +- 你有没有把 `schema.ToolInfo` 写清楚 +- 你给模型的参数约束,和真实执行需要的参数,是否一致 +- 你到底该返回普通字符串,还是该返回 `schema.ToolResult` + +如果这三件事没想清楚,helper 再顺手,后面也会开始歪。 + +## 6. 用一个最小例子,把“会注册”和“看懂执行链路”连起来 + +只说概念还是容易飘。 +不如直接看一个最小例子。 + +这个例子只做一件事:查询温度。 + +- 先定义一个最小 `weather` 工具 +- 再手工构造一条带 `ToolCalls` 的 `assistant message` +- 最后交给 `compose.ToolsNode` 执行 + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/components/tool/utils" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" +) + +// Tool 入参:城市名 +type WeatherInput struct { + City string `json:"city" jsonschema:"required" jsonschema_description:"要查询天气的城市"` +} + +// Tool 出参:城市 + 天气结果 +type WeatherOutput struct { + City string `json:"city"` + Weather string `json:"weather"` +} + +// Tool 的实际执行逻辑 +func queryWeather(_ context.Context, input *WeatherInput) (*WeatherOutput, error) { + return &WeatherOutput{ + City: input.City, + Weather: "晴,28度", + }, nil +} + +func main() { + ctx := context.Background() + + // 1) 把普通 Go 函数包装成 Eino Tool + weatherTool, err := utils.InferTool("weather", "查询城市天气", queryWeather) + if err != nil { + log.Fatal(err) + } + + // 2) 创建 ToolsNode:它只负责执行 ToolCall,不负责决定调用哪个 Tool + toolsNode, err := compose.NewToolNode(ctx, &compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{weatherTool}, + ExecuteSequentially: true, // 多个 ToolCall 时按顺序执行 + }) + if err != nil { + log.Fatal(err) + } + + // 3) 模拟 ChatModel 已经产出的 assistant 消息,其中带有 ToolCalls + input := &schema.Message{ + Role: schema.Assistant, + ToolCalls: []schema.ToolCall{ + { + ID: "call_weather_1", // 本次调用的唯一标识 + Type: "function", + Function: schema.FunctionCall{ + Name: "weather", // 要调用的 Tool 名称 + Arguments: `{"city":"深圳"}`, // 传给 Tool 的 JSON 参数 + }, + }, + }, + } + + // 4) ToolsNode 执行 ToolCall,返回 tool message + toolMessages, err := toolsNode.Invoke(ctx, input) + if err != nil { + log.Fatal(err) + } + + // 5) 打印执行结果;实际链路里这些结果通常会继续交给 ChatModel + for _, msg := range toolMessages { + fmt.Printf("role=%s content=%s\n", msg.Role, msg.Content) + } +} +``` + +如果执行顺利,你看到的大意会是这样: + +```text +role=tool content={"city":"深圳","weather":"晴,28度"} +``` + +这一刻最该记住的,不是“天气查出来了”。 +而是下面这件事: + +> 这条 `role=tool` 的消息,不是你手工拼出来的,而是 `ToolsNode` 根据 `assistant message` 里的 `ToolCalls` 执行后产出的。 + +这就把很多人原来脑子里断掉的那一截补上了: + +- `ChatModel` 先产出 `ToolCalls` +- `ToolsNode` 再逐个执行 +- 每次执行结果都回到消息链路里 +- 后面模型可以继续基于这些结果生成最终回答 + +如果你需要的不是纯文本,而是图片、文件这类结果,那就该考虑增强型工具。 +比如下面这个片段: + +```go +type ImageSearchInput struct { + Query string `json:"query" jsonschema:"required" jsonschema_description:"搜索关键词"` +} + +// 使用增强型 Tool:返回的不是纯字符串,而是结构化的 ToolResult +imageTool, err := utils.InferEnhancedTool( + "image_search", // Tool 名称 + "搜索并返回相关图片", // Tool 描述 + func(ctx context.Context, input *ImageSearchInput) (*schema.ToolResult, error) { + _ = ctx + _ = input + + imageURL := "https://example.com/cat.png" + + return &schema.ToolResult{ + Parts: []schema.ToolOutputPart{ + // 返回一段文本说明 + { + Type: schema.ToolPartTypeText, + Text: "找到 1 张图片", + }, + // 返回一张图片;这里用 URL 形式表示图片资源 + { + Type: schema.ToolPartTypeImage, + Image: &schema.ToolOutputImage{ + MessagePartCommon: schema.MessagePartCommon{ + URL: &imageURL, + }, + }, + }, + }, + }, nil + }, +) + +_ = imageTool +_ = err +``` + +这段代码真正想表达的,不是“语法还能这么写”。 +而是: + +- 如果你的结果天然带多模态,别硬塞成字符串 +- `schema.ToolResult` 本来就是给这种场景准备的 + +什么时候该用增强型工具? +一句话判断: + +> 结果如果不仅仅是文本,而是要把图片、文件、音视频作为一等输出返回,就别再用普通字符串接口硬撑。 + +## 7. 真正到了工程里,你还得关心这些 + +如果你只是写一个 demo,到这里已经够用了。 +但只要进入真实工程,下面这几个点很快就会变得比“工具能不能跑”更重要。 + +先说 `Option`。 + +很多人把它理解成“可选参数”。 +这当然没错,但还是太轻了。 +放到工具体系里,`Option` 更像运行时动态调度入口。 +比如超时、重试、最大返回条数、质量等级,这些都更适合走 `tool.Option` 机制,而不是硬编码在函数体里。 + +再说 `Middleware`。 + +`ToolsNode` 支持给工具调用挂 `ToolCallMiddlewares`。 +这件事的价值,不是“高级”,而是它让日志、指标、参数审计、统一包装这些横切逻辑终于有了稳定落点。 +特别是标准工具和增强型工具并存时,这层中间件会非常顺手。 + +然后是 `Callback`。 + +一旦链路里有多个 `ToolCall`、有流式输出、或者有失败重试,没有观测你会很快掉进黑盒。 +而工具级 `Callback` 至少能帮你看到: + +- 工具什么时候开始执行 +- 参数长什么样 +- 最终返回了什么 +- 流式输出有没有正常结束 + +最后是 `compose.GetToolCallID(ctx)`。 + +这个能力很朴素,但特别好用。 +不管是在 tool 函数体里打日志,还是在 callback handler 里串 trace,只要把 `ToolCallID` 打出来,单次调用链路就很容易串上。 +## 8. 总结 + +如果把今天这篇压成一句话,那就是: + +> `Tool` 负责把能力声明出来,`ToolsNode` 负责把一次 tool calling 执行到底。 + +前者解决的是“模型能调用什么”,后者解决的是“模型已经决定调用以后,系统怎样真正去做”。 +这两层一旦看懂,后面的 `Agent`、`Callback`、`Trace`、`Workflow`,你会顺很多。 + +## 参考资料 + +- CloudWeGo Eino [ToolsNode&Tool 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/tools_node_guide/) +- CloudWeGo Eino [How to Create a Tool](https://www.cloudwego.io/zh/docs/eino/core_modules/components/tools_node_guide/how_to_create_a_tool/) +- CloudWeGo Eino [第四章:Tool 与文件系统访问](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_04_tool_and_filesystem/) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会写 Tool,却没真正看懂 ToolsNode](./04-为什么很多人会写Tool,却没真正看懂ToolsNode.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会写 Tool,却没真正看懂 ToolsNode](https://zhumo.blog.csdn.net/article/details/159511006) +- 官方文档:[ToolsNode & Tool 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/tools_node_guide/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/05-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250DocumentLoader\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Parser.md" "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/05-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250DocumentLoader\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Parser.md" new file mode 100644 index 0000000..d4d1c8e --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/05-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250DocumentLoader\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Parser.md" @@ -0,0 +1,430 @@ +# AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Document Loader,却没真正看懂 Parser + +> GitHub 主文:[当前文章](./05-为什么很多人会用DocumentLoader,却没真正看懂Parser.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Document Loader,却没真正看懂 Parser](https://zhumo.blog.csdn.net/article/details/159514239) +> 官方文档:[Document Loader 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/document_loader_guide/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:把文档进入 RAG 前的加载、解析、结构化协议拆成 Loader 和 Parser 两层来看。 +**适合谁看**:准备搭建文档 ingestion 链路,或想把 RAG 入库前半段讲清楚的读者。 +**前置知识**:schema.Document 的基础概念、RAG 主链路认知 +**对应 Demo**:[官方接口与示例(本仓后续补充 Loader demo)](https://www.cloudwego.io/zh/docs/eino/core_modules/components/document_loader_guide/) + +**面试可讲点** +- 能解释 Loader 负责来源接入,Parser 负责内容解释,二者不是同一个层级。 +- 能把文件、元数据、文档协议和后续切块入库串成一条线。 + +--- +很多人第一次看到 `Document Loader`,第一反应都很直接: + +不就是“读文件”或者“抓网页”吗? + +本地文件读出来,网页内容拉下来,能拿到一段文本,事情似乎就结束了。 + +可如果你真把它只理解成一个“读取器”,后面一旦进入知识库入库、文档追踪、多格式解析、链路编排,你很快就会发现这个理解太浅了。 + +因为在 Eino 里,`Document Loader` 真正要解决的,不只是“把内容读出来”,而是: + +> 把不同来源的原始内容,统一收口成标准的 `[]*schema.Document`。 + +而在这条链路里,最容易被忽视的,其实不是 `Load` 本身,而是 Loader 背后的 `Parser`。 + +你可以把这篇文章先记成一句话: + +> `Loader` 管来源接入,`Parser` 管内容解释;前者解决“东西从哪来”,后者解决“这些内容该怎么进文档协议”。 + +如果这两层边界没拆开,很多人后面做 RAG 时,文档链路虽然也能跑,但通常会写得很糙。 + +## 1. `Document Loader` 到底解决什么,不只是“把文件读出来” + +先说结论: + +`Document Loader` 不是简单的 I/O 封装,它是文档进入系统前的“来源收口层”。 + +这层价值主要有三件事。 + +**第一,它统一了来源。** + +你的文档可能来自本地文件、网络 URL、S3,甚至以后还可能接企业内部对象存储。 +如果每一种来源都让上层逻辑直接自己读、自己转、自己拼元数据,后面的链路很快就会变得很散。 + +`Loader` 做的,就是把“来源差异”先压平。 + +**第二,它统一了输出协议。** + +不管前面读到的是 Markdown、HTML、PDF,还是普通文本,出去的时候都得变成 `[]*schema.Document`。 +一旦这个协议立住了,后面的 `Chain`、`Graph`、切分、索引、检索,才有稳定输入。 + +**第三,它把文档接入正式纳入运行时链路。** + +这也是很多人容易忽略的点。 +在 Eino 里,`Loader` 的 `ctx` 不只是拿来取消请求,它还承担 Callback Manager 的传递。 +这就意味着,文档加载不是一段藏在角落里的工具函数,而是可以被观察、被编排、被扩展的正式组件。 + +放到 RAG 里看,它是“数据进入系统的第一站”,但它还不是检索、不是索引、也不是切分策略本身。 + +它解决的是入口统一,不是后续所有问题。 + +## 2. 看懂 `Loader` 接口后,才知道官方真正想收口什么 + +官方给出的核心接口其实非常短: + +```go +type Loader interface { + Load(ctx context.Context, src Source, opts ...LoaderOption) ([]*schema.Document, error) +} + +type Source struct { + URI string +} +``` + +很多人第一次看到这段代码,会觉得信息量不大。 +可实际上,官方想收口的边界已经放得很清楚了。 + +先看 `Load`。 + +它返回的不是 `string`,也不是 `[]byte`,而是 `[]*schema.Document`。 +这一步非常关键。 + +它说明 Loader 的目标从来不是“把内容读出来就算完”,而是“把内容整理成系统认可的文档协议再交出去”。 + +再看 `src Source`。 + +`Source` 现在只有一个 `URI` 字段,设计得很克制。 +这个做法的好处是,它把“来源描述”压成了一个统一入口: + +- 本地文件路径可以是 URI +- 网络 URL 可以是 URI +- 存储系统对象地址也可以是 URI + +这其实是在提醒你:Loader 关注的是“统一来源标识”,不是给每种来源单独造一套接口。 + +最后看 `opts ...LoaderOption`。 + +官方没有给 Loader 设计一套很重的公共参数表,而是把公共层保持极简,把可变部分留给各个具体实现。 + +这代表的不是“设计不完整”,恰恰相反,它说明官方很清楚这层该怎么收: + +- 公共协议统一 +- 具体实现差异下放 +- 运行时扩展通过 Option 接进去 + +所以这段接口真正表达的是: + +> Loader 要统一的是调用姿势和输出协议,不是把所有来源都塞进一个笨重的大接口里。 + +## 3. `Source` 和 `schema.Document` 为什么是这条链路的关键协议 + +如果说 `Load` 是入口方法,那 `Source` 和 `schema.Document` 才是整条文档链路真正的协议地基。 + +`Source` 看起来简单,但 `URI` 的意义其实比“文件路径”大得多。 + +它不只告诉 Loader 去哪里取内容,也会影响后面的解析策略。 +尤其当你接 `ExtParser` 这类“基于扩展名选择解析器”的实现时,`URI` 不只是来源地址,它还是格式判断线索。 + +再看 `schema.Document`: + +```go +type Document struct { + ID string + Content string + MetaData map[string]any +} +``` + +这三个字段里,很多人最容易低估的是 `MetaData`。 + +可在工程里,`MetaData` 根本不是附赠字段,它几乎就是后续链路的挂载点。 + +它至少承载这些信息: + +- 文档来源 +- 原始 URI +- 文件扩展名 +- 页码、分段、子索引 +- 向量、分数、排序相关信息 +- 其他业务自定义字段 + +你现在如果把 `MetaData` 看轻,后面通常会在三个地方吃亏: + +- **来源追踪**:查到一段内容,却不知道它从哪来的 +- **排序和召回**:拿到了文档,却缺少分数、层级、子索引等附加信息 +- **链路排障**:内容不对,却没法判断是 Loader 问题、Parser 问题,还是后处理问题 + +所以别把 `Document` 理解成“内容字符串 + 一个 map”。 + +在 Eino 里,它更像是文档在系统里的统一载体。 +`Content` 是正文,`MetaData` 是上下文,二者缺一不可。 + +## 4. 为什么 `Parser` 不是配角,而是 Loader 内部真正的内容解释层 + +很多人学到 Loader 这一层时,会把注意力都放在“怎么读 URL”“怎么读文件”上。 + +可只要你继续往下一看,就会发现真正决定文档质量的,往往不是“读到了没有”,而是“读到以后怎么解释”。 + +这就是 `Parser` 的职责。 + +官方接口同样很短: + +```go +type Parser interface { + Parse(ctx context.Context, reader io.Reader, opts ...Option) ([]*schema.Document, error) +} +``` + +它做的事情也很清楚: + +> 从一个 `io.Reader` 里解析原始内容,并产出标准文档。 + +这层和 Loader 的边界一定要拆开看: + +- `Loader` 解决“从哪里拿内容” +- `Parser` 解决“拿到内容后按什么规则解释” + +这个区别看着像概念问题,实际上很工程。 + +因为同样是一份原始数据: + +- 当它是 `.txt` 时,你可能直接按文本处理 +- 当它是 `.html` 时,你通常要提正文、去标签 +- 当它是 `.pdf` 时,你可能要按页或按布局抽取内容 + +这些差异,不该压在 Loader 里写成一个越来越大的 `switch`,而应该下沉到 Parser 层。 + +官方给 Parser 的两个公共 Option 也很有意思: + +- `WithURI` +- `WithExtraMeta` + +这两个能力其实已经把 Parser 的工程定位说透了。 + +`WithURI` 说明解析器不只是吃字节流,它还会利用来源信息决定解析行为。 +`ExtParser` 能按扩展名挑解析器,靠的就是这个。 + +`WithExtraMeta` 则说明解析不是只管正文,元数据也应该在这一层被合理补齐并合并进文档。 + +说白了,很多人以为 Loader 是主角、Parser 是配件。 +但真到了多格式和工程落地场景里,你会发现: + +> Loader 决定入口通不通,Parser 决定进来的内容是不是“可用的文档”。 + +## 5. 一条完整链路在 Eino 里到底怎么走 + +如果把文档接入链路压成一条直线,它大致是这样: + +```text +URI + -> Loader 获取原始内容 + -> Parser 依据格式解析 + -> 构造 []*schema.Document + -> 进入 Chain / Graph + -> 再进入后续切分、索引、检索链路 +``` + +这里最关键的一点是: + +`Loader` 的输出不是一个局部变量,而是后续编排系统的正式输入。 + +所以你才能在 `Chain` 里直接接它: + +```go +chain := compose.NewChain[document.Source, []*schema.Document]() +chain.AppendLoader(loader) +``` + +也能在 `Graph` 里把它当节点挂进去: + +```go +graph := compose.NewGraph[document.Source, []*schema.Document]() +graph.AddLoaderNode("loader_node", loader) +``` + +这已经说明,官方设计 `Loader` 时,压根没把它当成一个“顺手写的帮助函数”,它从一开始就是可编排组件。 + +你如果把这层看明白,再回头看 RAG,就会顺很多。 + +很多人把知识库理解成“拿一堆文件,切一切,存向量库”。 +这当然没错,但真正第一步其实是: + +> 让不同来源的文档,以统一协议、带着必要元数据、可被观察地进入系统。 + +这一步就是 Loader 和 Parser 共同完成的。 + +## 6. 一个最小例子,把 `FileLoader`、`ExtParser` 和元数据串起来 + +如果只讲概念,还是容易飘。 +所以可以看一个最小组合: + +```go +textParser := parser.TextParser{} +htmlParser, _ := html.NewParser(ctx, &html.Config{Selector: gptr.Of("body")}) +pdfParser, _ := pdf.NewPDFParser(ctx, &pdf.Config{}) + +extParser, _ := parser.NewExtParser(ctx, &parser.ExtParserConfig{ + Parsers: map[string]parser.Parser{ + ".html": htmlParser, + ".pdf": pdfParser, + }, + FallbackParser: textParser, +}) + +loader, _ := file.NewFileLoader(ctx, &file.FileLoaderConfig{ + UseNameAsID: true, + Parser: extParser, +}) + +docs, _ := loader.Load(ctx, document.Source{ + URI: "./testdata/test.html", +}) + +fmt.Println(docs[0].ID) +fmt.Println(docs[0].Content) +fmt.Printf("%#v\n", docs[0].MetaData) +``` + +这段代码里,最该看的不是 API 语法,而是职责分工: + +- `FileLoader` 负责把本地文件变成可读内容 +- `ExtParser` 负责按扩展名把内容交给合适的解析器 +- 最终统一产出 `schema.Document` + +也就是说,真正让 `.html`、`.pdf`、普通文本走出不同解析路径的,不是 `FileLoader`,而是 `ExtParser` 背后的 Parser 选择机制。 + +这里还有一个很容易被忽视的点: + +`MetaData` 不是在最后“随手补一点信息”,而是从解析阶段就应该被认真传递和保存。 + +比如来源 URI、扩展名、页面信息,这些字段现在看着不起眼,可一旦你后面要做来源回溯、切分定位、召回解释,它们都会变得非常值钱。 + +## 7. `Option` 和 `Callback` 为什么不是装饰品 + +很多人一看到 Option,会下意识把它理解成“几个可选参数”;一看到 Callback,又觉得“加载文档还需要回调吗”。 +这么理解不能说错,但都太轻了。 + +先说 Option。 + +Loader 公共层没有很重的通用 Option,具体实现可以通过 `WrapLoaderImplSpecificOptFn` 扩展自己的运行时参数。 +Parser 这边则分成两层: + +- 公共 Option:`WithURI`、`WithExtraMeta` +- 实现特定 Option:通过 `WrapImplSpecificOptFn` 扩展 + +这意味着 Option 在这里真正扮演的是“运行时扩展入口”,不是参数补丁。 + +再说 Callback。 + +Loader 的回调输入输出是官方明确给出来的: + +- `LoaderCallbackInput` +- `LoaderCallbackOutput` + +这件事的意义很直接: + +你可以观察文档什么时候开始加载、加载了哪个来源、最后产出了多少个文档、失败发生在哪一步。 + +一旦链路里同时有本地文件、网页、S3,多种 Parser 并存,没有观测你会很快掉进黑盒。 + +所以 Callback 的价值,不是“打印两行日志”,而是把文档加载这一步正式接进可观测链路。 + +## 8. 自己实现 Loader / Parser 时,真正该守住哪些边界 + +如果你要自己写一个 Loader,最容易犯的错,就是把“来源获取”“内容解析”“元数据组装”“回调处理”全部揉进一个大函数里。 +代码当然也能跑,但只要格式一多、来源一多、链路一长,维护成本就会立刻上来。 + +更稳的做法,应该像下面这样收: + +```go +func (l *CustomLoader) Load( + ctx context.Context, + src document.Source, + opts ...document.LoaderOption, +) ([]*schema.Document, error) { + loaderOpts := document.GetLoaderImplSpecificOptions(&loaderOptions{ + Timeout: l.timeout, + }, opts...) + + reader, err := l.open(ctx, src, loaderOpts) + if err != nil { + return nil, err + } + defer reader.Close() + + ctx = callbacks.OnStart(ctx, &document.LoaderCallbackInput{ + Source: src, + }) + + docs, err := l.parser.Parse(ctx, reader, + parser.WithURI(src.URI), + parser.WithExtraMeta(map[string]any{ + "source": src.URI, + }), + ) + if err != nil { + callbacks.OnError(ctx, err) + return nil, err + } + + callbacks.OnEnd(ctx, &document.LoaderCallbackOutput{ + Source: src, + Docs: docs, + }) + return docs, nil +} +``` + +这段骨架里,真正该守住的是四条边界: + +**1. Loader 负责来源接入,不负责格式解释。** + +打开文件、请求网页、拉取对象存储,这些属于 Loader。 +至于 HTML 怎么提正文、PDF 怎么抽文本,这些应该交给 Parser。 + +**2. Parser 负责内容解释,不负责到处拿数据。** + +它吃的是 `io.Reader`,不是 URL,也不是文件系统路径。 +这样它才可复用,也更容易做单测。 + +**3. URI 和 MetaData 要沿着链路往下传。** + +如果你自己写 Loader,却忘了把 `src.URI` 和额外元数据传给 Parser,很多扩展能力就会直接失效。 +最典型的就是 `ExtParser` 选不对解析器,或者解析后的文档丢了来源信息。 + +**4. 回调和错误不要被吞。** + +加载失败时要返回有意义的错误。 +能进回调链路的地方,也别省。 +真正到了线上,排障时你会感谢自己没把这一步写成黑盒。 + +## 9. 总结 + +如果把今天这篇压成一句话,那就是: + +> `Document Loader` 解决的是来源收口,`Parser` 解决的是内容解释;前者让文档能进系统,后者决定进来的到底是不是“可用文档”。 + +再压缩成三句话,就是: + +- `Loader` 不是简单读取器,而是文档进入 Eino 的统一入口 +- `Parser` 不是配角,它决定原始内容怎样被解释成标准文档 +- `MetaData`、`Option`、`Callback` 说明这条链路从一开始就是工程组件,不是一次性 demo 代码 + +所以别把 `Document Loader` 只当成“读取 PDF、读取网页”的小功能。 +你一旦把这层看懂,后面再去接 `Indexer`、`Retriever`,或者继续往更完整的 RAG 流程走,很多设计都会顺理成章。 + +## 参考资料 + +- CloudWeGo Eino [Document Loader 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/document_loader_guide/) +- CloudWeGo Eino [ToolsNode&Tool 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/tools_node_guide/) +- CloudWeGo Eino [components/document/interface.go](https://github.com/cloudwego/eino/blob/main/components/document/interface.go) +- CloudWeGo Eino [components/document/parser/interface.go](https://github.com/cloudwego/eino/blob/main/components/document/parser/interface.go) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Document Loader,却没真正看懂 Parser](./05-为什么很多人会用DocumentLoader,却没真正看懂Parser.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Document Loader,却没真正看懂 Parser](https://zhumo.blog.csdn.net/article/details/159514239) +- 官方文档:[Document Loader 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/document_loader_guide/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/06-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250Indexer\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Store.md" "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/06-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250Indexer\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Store.md" new file mode 100644 index 0000000..81064a3 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/06-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250Indexer\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Store.md" @@ -0,0 +1,661 @@ +# AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Indexer,却没真正看懂 Store + +> GitHub 主文:[当前文章](./06-为什么很多人会用Indexer,却没真正看懂Store.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Indexer,却没真正看懂 Store](https://zhumo.blog.csdn.net/article/details/159537753) +> 官方文档:[Indexer 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/indexer_guide/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从 RAG 入库链路看清 Indexer 和 Store 的职责,避免把它当成向量库插入函数。 +**适合谁看**:准备做知识库写链路、需要把组件分层讲清楚的 Go 开发者。 +**前置知识**:Document Loader 与 Parser、Embedding 基础 +**对应 Demo**:[官方 Indexer 示例(本仓后续补充 Milvus demo)](https://www.cloudwego.io/zh/docs/eino/core_modules/components/indexer_guide/) + +**面试可讲点** +- 能解释 Indexer 不只是写向量库,而是统一文档写入协议的组件层。 +- 能说明 Store、Embedding、SubIndexes 这些概念为什么被拆到不同位置。 + +--- +很多人做知识库时,前面那几步通常都知道怎么搞: + +- 文档切块 +- 文本做 embedding +- 向量库建 collection + +看起来好像已经差不多了。 + +可真到落库这一步,问题马上就来了: + +- 文档正文怎么写进去? +- 向量是谁来生成、什么时候生成? +- 元数据和来源信息放哪里? +- 多个知识库、多个业务空间,怎么分区写? +- 写进去以后,怎么保证后面真的能被检索到? + +这时候你就会发现,`Indexer` 这层不是可有可无。 + +说白了: + +> `Indexer` 本质上就是“把文档写成以后能被检索的样子”的组件。 + +它不是只管塞一段文本进去。 +它更像是把文档、向量、元数据、子索引这些东西,一起整理好,再送进可检索后端。 + +这也是为什么它在知识库入库、语义搜索底库构建、多知识库分区写入这些场景里特别关键。 + +但很多人第一次看到 `Indexer`,还是会下意识地把它理解成: + +“哦,这不就是调一下 Milvus、VikingDB 或 ES 的写入接口吗?” + +问题也恰恰出在这儿。 + +因为如果事情真这么简单,那我们明明已经有: + +- `Embedding` +- 向量库 SDK +- 搜索引擎写入接口 + +为什么 Eino 还要单独设计一个 `Indexer`? + +这篇文章,想讲清楚的就是这件事。 + +如果你前面刚看过我上一篇 [Document Loader](https://blog.csdn.net/2302_80067378/article/details/159514239?spm=1001.2014.3001.5501) 的文章,那篇讲的是文档怎么进入 `[]*schema.Document` 这套统一协议。 +这一篇刚好接上下一站: + +> 当文档已经变成标准协议后,它到底怎么被写进“可检索系统”? + +## 1. 它的用处,它又被误会在哪? +先别急着看接口。 + +如果一上来,我就讲 API,这样虽可以记住函数名,但不利于大家理解它能解决什么问题。 +`Indexer` 更适合先从“它是拿来干嘛的”讲起。 + +### 它能干嘛 + +放到最常见的知识库链路里,`Indexer` 主要做的是这些事: + +**1. 把文档写进可检索后端** + +不是只写正文。 +而是把 `schema.Document` 这套统一协议,整理成后端真正能存、以后也真正能查的样子。 + +**2. 把向量和元数据一起落进去** + +很多场景不是只有文本内容。 +你还得把向量、来源、chunk 编号、业务标签这些信息一起写进去,不然后面检索和追踪都会很难受。 + +**3. 处理逻辑子索引或知识库分区** + +你可能不是只有一个知识库。 +多租户、多业务空间、多资料域,这些场景都要求写入时就把路由和隔离想清楚。 + +### 它最适合哪些场景 + +最常见的就是这几类: + +- 知识库入库 +- 语义搜索底库构建 +- 多知识库 / 多租户分区写入 + +### 它不能直接干嘛 + +这块也必须提前讲清楚。否则你会误会它的功能。 + +`Indexer` 很重要,但它不是万能层。 + +- 它不负责切块。文档怎么拆 chunk,不是它的职责。 +- 它不负责生成最终回答。那是 LLM 干的事。 +- 它不负责读侧召回。怎么把内容查出来,是 `Retriever` 的边界。 + +所以更准确地说: + +`Embedding` 是把文本变成向量。 +`Indexer` 是把内容写成可检索对象。 +`Retriever` 是再把这些东西查出来。 + +这三个环节如果混在一起,后面链路虽然也能跑,就会造成边界模糊,耦合严重的场景。 + +## 2. `Indexer` 不是“向量库 insert 封装” + +这里先把结论摆出来: + +`Indexer` 不是一个“帮你调 Milvus / VikingDB / ES SDK”的小工具。 +它是 Eino 在写入侧给出的统一组件协议。 + +这层协议真正收口的是三件事: + +- 文档输入统一成 `[]*schema.Document` +- 写入行为统一成 `Store(ctx, docs, opts...)` +- 写入结果统一成 `[]string` 形式的 `ids` + +这就意味着,它关心的是“写入侧边界”,不是某个后端产品自己的调用细节。 + +所以你看 `Indexer` 时,最好先把几个常见误会排掉: + +- 它不负责切块。文档怎么切成 chunk,是 `Loader / Parser` 后面的预处理问题,不是 `Indexer` 本体。 +- 它不负责读侧召回。相似度搜索、过滤、排序、返回 topK,那是 `Retriever` 的边界。 +- 它不等于 `Embedding`。向量可以在写入时生成,但生成向量这件事本身,仍然是另一层能力。 + +也正因为这三件事被拆开了,Eino 才能同时挂住 Milvus、VikingDB、ES、OpenSearch 这些看起来很不像一家人的后端实现。 + +如果它只是“向量库 insert 封装”,那 ES / OpenSearch 这两类实现就会显得很别扭。 +可官方偏偏把它们也归在 `Indexer` 下面,这恰恰说明: + +> `Indexer` 抽象的不是“向量库”,而是“可检索后端的写入入口”。(一套可插拔的接口) + +## 3. `Indexer` 在 RAG 入库链路里,到底站在哪 + +很多人理解 RAG 时,脑子里只有一句话: + +“文档切块,做 embedding,丢进向量库。” + +这当然没错,但如果你在 Eino 里写组件,你最好把链路拆得再清楚一点: + +```text +Source + -> Loader / Parser + -> []*schema.Document + -> (切块 / 清洗) + -> Embedding / Field Mapping + -> Indexer.Store + -> Retriever + -> ChatModel +``` + +这里最关键的是中间那段。 + +`Loader / Parser` 负责把不同来源的内容,收口成标准 `Document`。 +`Indexer` 负责把这些 `Document` 写进后端,让它以后能被查出来。 +而 `Retriever` 则负责真正把它们读出来。 + +也就是说: + +- `Loader / Parser` 管“东西从哪来、怎么解释” +- `Indexer` 管“怎么写进去” +- `Retriever` 管“怎么查出来” + +很多人之所以会把 `Indexer` 理解歪,就是因为把“写进去”和“以后怎么查”混成了一件事。 + +可在工程里,这两件事差得很远。 + +写入时你关心的是: + +- 文档 ID 怎么处理 +- 向量何时生成 +- 元数据写到哪些字段 +- 逻辑分区、子索引、批量写入怎么做 + +召回时你关心的是: + +- query 该怎么向量化 +- topK 怎么取 +- filter 怎么写 +- score 怎么解释 + +如果这两层边界不拆开,最后很容易变成“能跑,但组件职责已经糊了”。 + +## 4. 接口只有一个方法,但 `Store` 这个词一点都不简单 + +官方核心接口其实非常短: + +```go +type Indexer interface { + Store(ctx context.Context, docs []*schema.Document, opts ...Option) (ids []string, err error) +} +``` + +很多人第一次看到这个接口,会觉得信息量不大。 +可它真正想收口的边界,恰恰都藏在这几个参数里。 + +先看 `ctx`。 + +在 Eino 里,`ctx` 从来都不只是取消信号。 +官方文档已经明确写了,它还承担 `Callback Manager` 的传递。 +这意味着 `Store` 不是一个藏在角落里的工具函数,而是一段可以被编排、被观察、被追踪的正式运行时行为。 + +再看 `docs []*schema.Document`。 + +这很关键。 + +`Indexer` 吃进去的不是某家向量库自己的 row,也不是某个搜索引擎专属的字段结构,而是统一文档协议。 +这件事的价值在上一篇 `Document Loader` 里其实已经埋下了: + +> 文档一旦被标准化成 `schema.Document`,后面的写入端就终于可以和“来源差异”解耦。 + +最后看返回值 `ids []string`。 + +这块很多人会想当然地把它理解成“就是把 `doc.ID` 原样回给你”。 +但实际上,`ids` 更准确的意思是: + +> 后端最终确认写入成功的文档标识。 + +它可能是: + +- 直接沿用你传进来的 `Document.ID` +- 后端生成的新 ID +- 一次批量 upsert 之后真正生效的主键集合 + +所以 `Store` 这个词,千万别按数据库里那种“我插一行,你回一个自增主键”的直觉去理解。 + +在 Eino 语境里,一次 `Store` 里可能同时发生: + +- 文档字段映射 +- 向量生成 +- 批量写入 +- 子索引分流 +- 回调触发 +- 错误上抛 + +这已经明显不是一句“insert 一下”能说清的事了。 + +## 5. 公共 Option 真正控制的,不是“几个小参数” + +官方给 `Indexer` 的公共 option 很克制: + +```go +type Options struct { + SubIndexes []string // 子索引/子分区:这批文档要写到哪些逻辑分组里 + Embedding embedding.Embedder // 向量模型:写入前用它把文本转成向量 +} + +func WithSubIndexes(subIndexes []string) Option // 设置子索引/分区 +func WithEmbedding(emb embedding.Embedder) Option // 设置本次写入使用的向量生成器 +``` + +字段不多,但信息量不小。 + +### 4.1 `SubIndexes` 更像逻辑分区,不只是一个字符串数组 + +很多人第一次看 `SubIndexes`,会把它当成“顺手多传几个名字”。 +可如果你把它放回知识库场景里,就会发现它更像逻辑分区入口。 + +比如同一套物理后端里,你可能会按下面这些维度做隔离: + +- 不同知识库 +- 不同租户 +- 不同业务空间 +- 不同文档域 + +这时 `SubIndexes` 的作用,就不是“多一个参数”这么简单了。 +它更接近: + +> 在同一个 `Indexer` 抽象之下,把文档路由到不同的逻辑子索引或子分区。 + +所以我更愿意把它理解成写入侧的 namespace / partition 入口,而不是一个普通切片字段。 + +### 4.2 `Embedding` 是写入时临时挂接的能力,不是 `Indexer` 本体 + +`WithEmbedding` 更值得多看两眼。 + +它说明什么? + +说明 Eino 允许你在 `Store` 这一跳里,临时指定“这批文档怎么向量化”。 +也就是说,向量生成可以是: + +- `Indexer` 初始化时配置好的默认能力 +- 本次调用临时覆盖进去的 embedder + +这就把“写入协议”和“向量模型选择”拆开了。 + +而且还有一个容易被忽略的点。 + +VikingDB 示例里,官方给的是后端内建 embedding 配置: + +```go +EmbeddingConfig: volc_vikingdb.EmbeddingConfig{ + UseBuiltin: true, + ModelName: "bge-m3", + UseSparse: true, +}, +``` + +这恰恰说明: + +`Indexer` 可以挂接 embedding,但它本身不等于 embedding。 +有些实现会走外部 embedder,有些实现会直接利用后端内建能力。 + +> 如果想要了解更多,可以打开看一下,官方源码的行为细节。 + +## 6. 用一个 Milvus 最小例子,把 `Store` 的写入链路看顺 +(Milvus 是一个向量数据库,主要用于存储向量及其关联元数据,并支持相似度检索。) + +如果只讲概念,还是容易飘。 +不如直接看一个最典型的组合: + +- 外部 `Embedding` +- Milvus 负责向量存储 +- `Indexer.Store` 统一完成写入 + +```go +package main + +import ( + "context" + "log" + + "github.com/cloudwego/eino/components/embedding" + "github.com/cloudwego/eino/schema" + "github.com/cloudwego/eino-ext/components/indexer/milvus2" + "github.com/milvus-io/milvus/client/v2/milvusclient" +) + +func main() { + ctx := context.Background() + + // 这里假设 emb 已经提前初始化完成,比如 千问 / OpenAI / Ark 等 embedding 组件 + var emb embedding.Embedder + + idx, err := milvus2.NewIndexer(ctx, &milvus2.IndexerConfig{ + ClientConfig: &milvusclient.ClientConfig{ + Address: addr, + Username: username, + Password: password, + }, + Collection: "kb_chunks", + Dimension: 1024, // 必须和 embedding 模型输出维度一致 + MetricType: milvus2.COSINE, + IndexBuilder: milvus2.NewHNSWIndexBuilder().WithM(16).WithEfConstruction(200), + Embedding: emb, + }) + if err != nil { + log.Fatal(err) + } + + docs := []*schema.Document{ + { + ID: "chunk_001", + Content: "RAG 的第一步不是问模型,而是先把文档变成可检索对象。", + MetaData: map[string]any{ + "source": "rag_intro.md", + "chunk_no": 1, + }, + }, + } + + ids, err := idx.Store(ctx, docs) + if err != nil { + log.Fatal(err) + } + + log.Printf("stored ids=%v", ids) +} +``` + +这段代码真正值得看的,不是 Milvus 的参数怎么填,而是职责分工: + +- 业务层交给它的仍然是 `schema.Document` +- 向量生成能力通过 `Embedding` 挂进去 +- `Store` 统一把内容、向量、元数据写到后端 +- 返回的 `ids` 才是这次写入最终确认下来的结果 + +也就是说,业务层并没有直接面对“Milvus 的行结构”。 +它只是在说: + +> 我有一批标准文档,请把它们写成以后能被检索的样子。 + +这才是 `Indexer` 抽象真正值钱的地方。 + +## 7. 为什么说 `Indexer` 不只服务向量数据库 + +如果你只看 Milvus 或 VikingDB,很容易觉得 `Indexer` 就是“向量库接口”。 +可官方把 ES / OpenSearch 也放在 `Indexer` 下面,这个信号其实非常强。 + +来看 ES7 这种写法: + +```go +indexer, _ := es7.NewIndexer(ctx, &es7.IndexerConfig{ + Client: client, + Index: "kb_chunks", // 写入到 ES 的哪个索引 + + // 把统一的 Document 转成 ES 里的字段结构 + DocumentToFields: func(ctx context.Context, doc *schema.Document) (map[string]es7.FieldValue, error) { + return map[string]es7.FieldValue{ + "content": { + Value: doc.Content, // 文档正文 + EmbedKey: "content_vector", // 对 content 做向量化,结果写到 content_vector 字段 + }, + "source": { + Value: doc.MetaData["source"], // 普通元数据字段,不做向量化 + }, + }, nil + }, + + Embedding: emb, // 向量模型:把指定字段文本转成向量 +}) +``` + +这段代码很能说明问题。 + +这里的 `Indexer` 已经不是“往向量列里塞一个浮点数组”那么简单了,而是在做两件事: + +- 把 `Document` 映射成搜索引擎的字段结构 +- 决定哪些字段要向量化,哪些字段按普通字段存储 + +这说明 `Indexer` 抽象的是“检索后端的写入协议”,不是“某一家向量数据库的专属写法”。 + +换句话说: + +Milvus / VikingDB 让你更容易看见 `vector`。 +ES / OpenSearch 则提醒你别把 `Indexer` 只看成 `vector`。 + +它真正落的,是 `Document -> backend indexable representation` 这层转换。 + +## 8. 放进 Chain / Graph 里,你会发现 Indexer 也是正式组件 + +很多人平时把 `Indexer` 单独调用一下,就觉得这层已经懂了。 +其实不够。 + +只有当你把它放进编排里,才会更清楚它在 Eino 里的定位。 + +```go +// 在 Chain 中使用 +chain := compose.NewChain[[]*schema.Document, []string]() +chain.AppendIndexer(indexer) + +// 在 Graph 中使用 +graph := compose.NewGraph[[]*schema.Document, []string]() +graph.AddIndexerNode("indexer_node", indexer) +``` + +这段代码表达的不是“语法还能这么写”。 +它真正表达的是: + +`Indexer` 从一开始就不是一个 helper。 +它和 `ChatModel`、`Tool`、`Retriever` 一样,是能直接进入编排图的正式节点。 + +这带来的工程价值非常实际: + +- 你可以把文档加载、清洗、索引串成一条稳定流水线 +- 你可以通过 `compose.WithCallbacks` 统一观察整个入库过程 +- 你可以在更复杂的 Graph 里,把不同写入策略拆成不同节点 + +一旦你从“帮我写一下数据”切换到“它是编排节点”的视角,`Indexer` 的位置就完全不一样了。 + +## 9. `Callback` 和自定义实现 + +到了生产环境中,很多问题不是“能不能写进去”,而是: + +- 哪批文档写失败了 +- 哪一步失败的,是 embedding 还是 backend 写入 +- 返回的 `ids` 和输入文档是否一一对应 +- 某次写入到底落到了哪个子索引 + +这时候,`Callback` 的价值就出来了。 + +官方给的回调输入输出很精妙: + +```go +type CallbackInput struct { + Docs []*schema.Document + Extra map[string]any +} + +type CallbackOutput struct { + IDs []string + Extra map[string]any +} +``` + +字段不多,但刚好卡在写入侧最该观察的地方: + +- 进来的是什么文档 +- 出去的是哪些 ID + +如果你自己实现一个 `Indexer`,真正该守住的顺序也很明确: + +1. 先收公共 option(框架统一认的) +2. 再收实现级 option (你自己需要的) +3. 从 `ctx` 里拿 callback manager +4. OnStart +5. 执行真实写入 +6. OnError / OnEnd + +一个更稳的骨架,可以像下面这样写: + +```go +// MyIndexerOptions 是当前这个自定义 Indexer 的“实现级 option”。 +// 也就是:只有 MyIndexer 自己认识和使用的参数。 +type MyIndexerOptions struct { + BatchSize int // 批量写入时,每批处理多少条 + MaxRetries int // 写入失败时,最多重试几次 +} + +// WithBatchSize 用来生成一个实现级 option。 +// 调用方可以在 Store(..., opts...) 时传入它,覆盖默认批大小。 +func WithBatchSize(size int) indexer.Option { + return indexer.WrapImplSpecificOptFn(func(o *MyIndexerOptions) { + o.BatchSize = size + }) +} + +// Store 是 Indexer 对外暴露的统一写入入口。 +// 它做的不是“直接 insert”,而是: +// 1. 收集通用 option +// 2. 收集当前实现自己的 option +// 3. 触发回调开始事件 +// 4. 执行真实写入 +// 5. 根据结果触发结束或错误回调 +func (i *MyIndexer) Store(ctx context.Context, docs []*schema.Document, opts ...indexer.Option) ([]string, error) { + // 解析“公共 option”: + // 比如 SubIndexes、Embedding 这类所有 Indexer 都能理解的参数。 + commonOpts := indexer.GetCommonOptions(nil, opts...) + + // 解析“实现级 option”: + // 先给一个默认值,再用调用方传进来的 opts 覆盖。 + implOpts := indexer.GetImplSpecificOptions(&MyIndexerOptions{ + BatchSize: i.batchSize, + }, opts...) + + // 从 ctx 中拿到 callback manager。 + // 它负责记录这次 Store 的开始、结束和错误。 + cm := callbacks.ManagerFromContext(ctx) + runInfo := &callbacks.RunInfo{} + + // 通知回调系统:这次写入开始了。 + // 这里把输入文档和一些额外上下文信息带进去,便于日志、追踪和调试。 + ctx = cm.OnStart(ctx, runInfo, &indexer.CallbackInput{ + Docs: docs, + Extra: map[string]any{ + "sub_indexes": commonOpts.SubIndexes, + "batch_size": implOpts.BatchSize, + }, + }) + + // 执行真正的写入逻辑。 + // 这里会进入 doStore,完成 embedding、字段映射、批量写入等动作。 + ids, err := i.doStore(ctx, docs, commonOpts, implOpts) + if err != nil { + // 如果写入失败,通知回调系统发生了错误。 + cm.OnError(ctx, runInfo, err) + return nil, err + } + + // 如果写入成功,通知回调系统结束,并把最终写入成功的 IDs 带出去。 + cm.OnEnd(ctx, runInfo, &indexer.CallbackOutput{ + IDs: ids, + }) + return ids, nil +} + +// doStore 是真正执行写入细节的地方。 +// Store 负责“流程控制”,doStore 负责“实际干活”。 +func (i *MyIndexer) doStore( + ctx context.Context, + docs []*schema.Document, + commonOpts *indexer.Options, + implOpts *MyIndexerOptions, +) ([]string, error) { + // 如果本次写入指定了 Embedding,就先把文档内容转成向量。 + // 这样后续写入后端时,就能把文本和向量一起存进去。 + if commonOpts.Embedding != nil { + // 先提取所有文档的正文内容,准备批量做 embedding。 + texts := make([]string, len(docs)) + for j, doc := range docs { + texts[j] = doc.Content + } + + // 调用 embedding 模型,把文本批量转成向量。 + vectors, err := commonOpts.Embedding.EmbedStrings(ctx, texts) + if err != nil { + return nil, err + } + + // 把生成出来的向量挂回到每个 Document 上。 + for j, doc := range docs { + doc.WithVector(vectors[j]) + } + } + + // implOpts 里一般会继续参与下面的写入逻辑, + // 比如按 BatchSize 分批写、按 MaxRetries 做重试等。 + _ = implOpts + + // 这里继续做: + // - 批量写入 + // - 字段映射 + // - 分区/子索引路由 + // - 调用具体后端 SDK + // + // 最后返回后端确认写入成功的文档 ID 列表。 + return []string{"stored_doc_1"}, nil +} +``` + +这段骨架最重要的,不是细节实现,而是以下这几点: + +- 公共 option 和实现级 option 分开处理 +- callback 生命周期完整触发 +- embedding 是“写入前可挂接能力” +- 真正的 backend 写入逻辑被收敛在 `doStore` + +这才是一个能进工程的 `Store` 形状。 + + +## 10. 总结 + +用一句话总结: + +> `Store` 的本质不是一次 insert,而是“文档协议进入检索系统的统一写入入口”。 + +其中有3点需要重视: + +- `Indexer` 解决的是写入侧协议统一,不是某家后端 SDK 的薄封装 +- `Store` 里可能同时发生字段映射、向量生成、分区路由、批量写入和回调触发 +- ES / OpenSearch 的实现已经足够说明,`Indexer` 抽象的不是“向量库”,而是“可检索后端” + + +## 参考资料 + +- CloudWeGo Eino [Indexer 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/indexer_guide/) +- CloudWeGo Eino [components/indexer/interface.go](https://github.com/cloudwego/eino/blob/main/components/indexer/interface.go) +- CloudWeGo Eino [components/indexer/option.go](https://github.com/cloudwego/eino/blob/main/components/indexer/option.go) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Indexer,却没真正看懂 Store](./06-为什么很多人会用Indexer,却没真正看懂Store.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Indexer,却没真正看懂 Store](https://zhumo.blog.csdn.net/article/details/159537753) +- 官方文档:[Indexer 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/indexer_guide/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/07-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250Retriever\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Retrieve.md" "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/07-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250Retriever\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Retrieve.md" new file mode 100644 index 0000000..ec4cd13 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/03-\347\273\204\344\273\266\346\240\270\345\277\203/07-\344\270\272\344\273\200\344\271\210\345\276\210\345\244\232\344\272\272\344\274\232\347\224\250Retriever\357\274\214\345\215\264\346\262\241\347\234\237\346\255\243\347\234\213\346\207\202Retrieve.md" @@ -0,0 +1,730 @@ +# AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Retriever,却没真正看懂 Retrieve + +> GitHub 主文:[当前文章](./07-为什么很多人会用Retriever,却没真正看懂Retrieve.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Retriever,却没真正看懂 Retrieve](https://zhumo.blog.csdn.net/article/details/159549389) +> 官方文档:[Retriever 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/retriever_guide/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从检索读链路看清 Retriever 真正解决的不是搜一下,而是把召回动作标准化。 +**适合谁看**:已经知道 RAG,但想把召回阶段讲清楚的 Go 工程师。 +**前置知识**:Embedding 基础、Indexer 与 Store、TopK 和阈值等检索参数基础 +**对应 Demo**:[官方 Retriever 示例(本仓后续补充独立 demo)](https://www.cloudwego.io/zh/docs/eino/core_modules/components/retriever_guide/) + +**面试可讲点** +- 能解释 Retriever 的核心动作是受控 Retrieve,而不是简单数据库查询。 +- 能说明 TopK、阈值、MetaData、Embedding 配置为什么都会影响召回效果。 + +--- +很多人第一次看到 `Retriever`,第一反应都很直接: + +不就是调一下向量库或者搜索引擎的 `search`,把最像的几条文档捞出来吗? + +代码看起来也确实像这么回事。 + +可只要你继续往工程里走,问题马上就来了: + +- query 到底在哪里做 embedding? +- 多知识库、多子索引怎么切? +- `TopK` 和相似度阈值该放配置里,还是放运行时? +- 过滤条件到底写在 SDK 调用里,还是写在组件 option 里? +- 一次检索到底怎么进 `Chain`、`Graph`、`Callback` 这条正式运行时链路? + +如果这些事都散在业务代码里,检索当然也能跑,但通常跑不久就会乱。 + +所以这篇文章想讲清楚的,不是“怎么搜一次”,而是: + +> `Retriever` 为什么会被 Eino 单独抽成一个组件? + +上一篇《为什么很多人会用 Indexer,却没真正看懂 Store》讲的是“文档怎么写进去”,这一篇刚好接着讲“query 怎么把文档查出来”。 + +先把结论摆出来: + +> `Retriever` 是 Eino 在读侧给出的统一检索协议,不是某家向量库 SDK 的语法糖。 + + +## 1. Retriever 真正解决的,不只是“搜一下” + +先别急着看接口。 + +如果一上来就盯着 `Retrieve(ctx, query, opts...)` 这一个方法,很容易把它看成“检索调用的统一壳子”。 +这个理解不算全错,但还是太浅。 + +`Retriever` 真正收口的,其实是读侧这几件事: + +**第一,把 query 变成标准检索入口。** + +上层只需要给出查询字符串,至于后面是关键词检索、向量检索、混合检索,还是带过滤条件的召回,都由组件自己去接具体实现。 + +**第二,把结果统一成 `[]*schema.Document`。** + +不管底层是 VikingDB、Milvus、ES,还是 OpenSearch,最后交给上层的都不是某家 SDK 的 hit 结构,而是标准文档协议。 + +**第三,把检索正式纳入运行时链路。** + +它不是一个你在业务代码里顺手调一下的帮助函数,而是能进 `Chain`、能进 `Graph`、能挂 `Callback` 的正式组件。 + +你放到 RAG 里看,这层价值会更清楚。 + +一条典型链路里: + +- `Embedding` 负责把文本变成向量 +- `Indexer` 负责把文档写成可检索对象 +- `Retriever` 负责把 query 变成召回动作 +- `ChatModel` 负责基于召回结果生成答案 + +至于 `Rerank`,它通常在 `Retriever` 之后,对候选结果再做一轮重排;这不是 `Retriever` 本体要解决的事。 + +所以别把它理解成“搜索函数封装”。 + +更准确一点说: + +> `Retriever` 解决的是“查询如何以统一协议进入检索系统,并把结果以统一协议返回出来”。 + + +## 2. 接口只有一个方法,但 Retrieve 这个动作一点都不简单 + +官方给出的核心接口其实非常短: + +```go +type Retriever interface { + Retrieve(ctx context.Context, query string, opts ...Option) ([]*schema.Document, error) +} +``` + +如果只看长度,这接口甚至比 `Indexer` 还简单。 + +可真正要看的,不是它有几个方法,而是它在收什么边界。 + +先看 `retriever.Retriever`。 + +这说明 Eino 在组件层明确区分了“写入协议”和“读取协议”。 +你前面已经有了 `Indexer` 去负责 `Store`,这里再单独给 `Retrieve` 一层抽象,意思已经很明确了: + +> 写进去怎么做,和查出来怎么做,是两条边界。 + +再看 `Retrieve(ctx, query, opts...) ([]*schema.Document, error)`。 + +这个签名里最重要的有 4 个点。 + +**1. `ctx` 不只是取消信号。** + +在 Eino 里,它同时承担请求级信息和 callback manager 的传递。 +也就是说,检索这一步从一开始就被当成正式运行时行为,而不是藏在工具函数里的黑盒调用。 + +**2. 输入是 `query string`,不是某家后端的专属请求结构。** + +这一步把上层调用姿势压得很统一。 +至于 query 后面要不要向量化、怎么向量化、要不要混合检索,是组件内部的事。 + +**3. 返回的是 `[]*schema.Document`,不是原始 hit。** + +这点很关键。 +如果返回的是后端自己的结果结构,那这层抽象就基本失效了。 +现在统一返回 `Document`,说明它抽象的不是“某种数据库搜索请求”,而是“统一读侧输出协议”。 + +**4. `opts ...Option` 把运行时可变能力单独挂了出来。** + +这意味着检索行为不是完全写死在初始化配置里的。 +索引、子索引、`TopK`、阈值、embedding、过滤 DSL,都可以在调用时覆写。 + +再看 `schema.Document`: + +```go +type Document struct { + ID string + Content string + MetaData map[string]any +} +``` + +很多人看到这里,会把注意力放在 `Content` 上。 +但到了检索场景,真正不能轻视的,反而常常是 `MetaData`。 + +因为检索结果除了正文,往往还会带上这些信息: + +- 分数 +- 来源 +- 业务标签 +- 命中的索引或分区 +- 后端返回的其他上下文字段 + +这些信息不一定都在 `Content` 里,却很可能会被后续节点继续用到。 + +所以 `Retrieve` 虽然只有一个动作名,但里面可能同时发生: + +- query 预处理 +- 向量生成 +- 后端检索 +- 结果解析 +- metadata 注入 +- callback 生命周期触发 + +这已经明显不是一句“搜一下”能说完的事了。 + + +## 3. 公共 Option 收口的,不只是几个小参数 + +官方给 `Retriever` 的公共 option 长这样: + +```go +type Options struct { + Index *string + SubIndex *string + TopK *int + ScoreThreshold *float64 + Embedding embedding.Embedder + DSLInfo map[string]any +} +``` + +字段不算多,但每一个都对应着读侧真正会变的行为。 + +### 3.1 `Index` + +`Index` 是检索器使用的索引。 + +别把它只理解成“某个数据库里的索引名”。 +在不同实现里,它的含义可能不一样,但统一点在于: + +> 它决定你这次检索到底要落到哪一个可检索空间里。 + +这在多知识库、多业务库、多环境隔离里很常见。 + +### 3.2 `SubIndex` + +`SubIndex` 是子索引。 + +它更像逻辑上的进一步分流。 +比如同一套物理存储下,你可能还会按租户、业务线、数据域、时间分区去做更细颗粒度的检索路由。 + +这就是为什么 Eino 不把它粗暴合并进 `Index`。 + +它们虽然都在描述“查哪里”,但层级不一样。 + +### 3.3 `TopK` + +`TopK` 是返回文档数量上限。 + +这看起来像一个很普通的参数,但它其实会直接影响: + +- 召回范围 +- 下游模型上下文长度 +- 延迟 +- 成本 + +所以它不该永远被写死在初始化配置里。 +很多真实业务里,FAQ 检索、知识库问答、长文档辅助分析,它们需要的 `TopK` 根本不是一个数。 + +### 3.4 `ScoreThreshold` + +`ScoreThreshold` 是分数阈值。 + +这里有一个特别容易被用浅的点: + +> 它是过滤条件,不是排序开关。 + +也就是说,它的意义不是“把低分文档往后排”,而是“低于阈值的文档直接不要”。 + +所以如果你的召回结果“明明命中了,但又没返回”,除了看 `TopK`,还得看这里是不是把结果过滤掉了。 + +### 3.5 `Embedding` + +`Embedding` 是给 query 做向量化的组件。 + +这个字段很关键,因为它直接说明: + +`Retriever` 虽然吃的是自然语言 query,但它可以在内部把 query 变成向量,再去做相似度检索。 + +同时也正因为有这个字段,官方源码里还特别强调了一层约束: + +> 检索时使用的 embedder,应该和索引写入时使用的模型保持一致。 + +否则很容易出现一种很典型的线上问题: + +文档都在,索引也建好了,可召回效果就是不对。 + +问题往往不是数据库坏了,而是写入时和查询时压根不在一个向量空间里。 + +### 3.6 `DSLInfo` + +`DSLInfo` 是检索 DSL 信息。 + +官方文档里提到它在 Viking 类型检索器里会用到,但你更应该记住的是它的设计信号: + +> 公共 option 收口的是共性能力,但不会强行抹平所有后端差异。 + +像过滤表达式、查询 DSL 这种东西,不同后端差异很大。 +如果硬要塞成一个统一大接口,最后只会把抽象做笨。 + +所以 Eino 的做法很克制: + +- 共性参数统一收口 +- 后端特有能力允许保留 + +这比“什么都统一”更像工程设计。 + +### 3.7 不止公共 option,具体实现还能继续扩展 + +官方还提供了实现级 option 的包装方式。 + +这意味着你在自定义 `Retriever` 时,既要支持 `GetCommonOptions(...)`,也可以保留自己那套实现专属参数,而不用为了兼容框架把所有细节都塞进公共层。 + +说白了就是: + +> 公共 option 负责定义“所有 Retriever 都该听得懂的话”,实现级 option 负责保留“这一家后端自己的方言”。 + + +## 4. 它在 RAG 读链路里,到底站在哪 + +很多人理解 RAG,脑子里只有一句话: + +“把文档切块,做 embedding,丢向量库,查询的时候再搜出来。” + +这句话当然不算错,但一旦落到 Eino 组件边界上,还是得拆得更清楚一点。 + +先看最短版本: + +```text +Loader / Parser -> Indexer -> Retriever -> ChatModel +``` + +如果把过程展开一点,大致是这样: + +```text +原始资料 + -> Loader / Parser + -> []*schema.Document + -> 切块 / 清洗 + -> Indexer.Store + -> 可检索后端 + -> Retriever.Retrieve(query) + -> []*schema.Document + -> ChatModel +``` + +这里真正重要的,不是流程图本身,而是边界。 + +写入侧你关心的是: + +- 文档如何标准化 +- 向量什么时候生成 +- 元数据怎么落库 +- 写进哪个索引或分区 + +读取侧你关心的是: + +- query 怎么解释 +- 要查哪个索引或子索引 +- 召回多少条 +- 分数阈值怎么设 +- 过滤条件怎么下发 +- 返回什么 metadata 给下游 + +这也就是为什么上一篇讲 `Indexer` 时,我一直在强调“写入侧协议统一”。 + +这一篇换到 `Retriever`,重点就必须换成另一句: + +> `Retriever` 解决的是“查询如何进入可检索系统”,不是“文档如何写进去”。 + +如果这两层边界不拆开,最常见的结果就是: + +- 写入逻辑和检索逻辑缠在一起 +- 业务代码里到处散落后端 SDK 细节 +- 一旦你要换存储后端、换索引策略、加 callback,改动面会非常大 + +所以 `Retriever` 站在 RAG 链路里的位置,并不是“后面随便补一层的 search helper”。 +它就是读侧入口。 + + +## 5. 用 VikingDB 看一遍最小检索闭环 + +如果只讲抽象,还是容易飘。 +所以最好的办法,还是拿一个官方示例最完整的实现过一遍。 + +这里用 `Volc VikingDB Retriever`。 + +```go +package main + +import ( + "context" + "log" + + "github.com/cloudwego/eino-ext/components/retriever/volc_vikingdb" +) + +func ptr[T any](v T) *T { return &v } + +func main() { + ctx := context.Background() + + cfg := &volc_vikingdb.RetrieverConfig{ + Host: "api-vikingdb.volces.com", + Region: "cn-beijing", + AK: "your-ak", + SK: "your-sk", + Scheme: "https", + ConnectionTimeout: 0, + Collection: "eino_test", + Index: "test_index_1", + EmbeddingConfig: volc_vikingdb.EmbeddingConfig{ + UseBuiltin: true, + ModelName: "bge-m3", + UseSparse: true, + DenseWeight: 0.4, + }, + Partition: "", + TopK: ptr(10), + ScoreThreshold: ptr(0.1), + FilterDSL: nil, + } + + r, err := volc_vikingdb.NewRetriever(ctx, cfg) + if err != nil { + log.Fatal(err) + } + + docs, err := r.Retrieve(ctx, "怎么申请退款") + if err != nil { + log.Fatal(err) + } + + for _, doc := range docs { + log.Printf("id=%s metadata=%v content=%s", doc.ID, doc.MetaData, doc.Content) + } +} +``` + +这段代码看起来不复杂,但里面几个字段都很值得注意。 + +### `Collection` + +`Collection` 对应的是这批文档所在的数据集。 +你可以把它理解成“更大一级的检索容器”。 + +### `Index` + +`Index` 对应检索时真正使用的索引。 +这一步很像上一篇里 `Indexer` 的镜像面: + +- `Indexer` 决定内容怎么写进去 +- `Retriever` 决定查的时候落到哪个索引上 + +### `Partition` + +`Partition` 对应索引中的子索引划分字段。 +如果你的知识库不是一锅端,而是按租户、业务、区域、版本再做细分,那这层就很有用了。 + +### `FilterDSL` + +`FilterDSL` 对应标量过滤字段。 + +这点很工程。 +因为很多场景你不只是“找最像的内容”,还要先满足一层业务过滤,比如: + +- 只看某个知识库 +- 只看某个状态的数据 +- 只看某个时间范围 + +如果没有 DSL 这层,后端明明支持过滤,你在组件层就很难把这个能力干净地挂出来。 + +### `EmbeddingConfig` + +这块是 VikingDB 示例最有代表性的地方。 + +它说明 query 不一定非得由你先手工转成向量再传进去。 +像这里 `UseBuiltin: true`,就是让检索器直接使用 VikingDB 的内置 embedding 配置去完成向量化。 + +这也是为什么我前面一直在说: + +> `Retriever` 不是“搜结果”的那一下,而是“query 进入检索系统的整段过程”。 + +因为 query 在真正发起搜索前,可能已经先经历了向量化和过滤条件拼装。 + +### `TopK` 和 `ScoreThreshold` + +这两个参数一个控制“最多拿多少”,一个控制“低于多少不要”。 +别把它们混成一回事。 + +如果你后面想在单次调用时临时覆盖,也完全可以通过公共 option 去改,而不用把默认值写死在初始化配置里。 + +再补一句: + +Milvus、Elasticsearch、OpenSearch 这些实现,初始化参数和搜索模式都不一样,但最后都会收口到同一条调用协议上: + +```go +docs, err := retriever.Retrieve(ctx, query, opts...) +``` + +这就说明 `Retriever` 抽象的不是某一家后端,而是读侧检索动作本身。 + + +## 6. 为什么它能直接进 Chain、Graph 和 Callback + +如果 `Retriever` 只是一个普通 SDK 包装层,它其实没必要出现在编排系统里。 + +但官方文档明确给出了这两种挂法: + +```go +chain := compose.NewChain[string, []*schema.Document]() +chain.AppendRetriever(retriever) + +graph := compose.NewGraph[string, []*schema.Document]() +graph.AddRetrieverNode("retriever_node", retriever) +``` + +这已经说明一件很重要的事: + +> `Retriever` 是正式运行时节点,不是藏在代码角落里的工具函数。 + +再看 callback。 + +官方示例里,`Retriever` 这层可以直接挂 `retriever.CallbackInput` 和 `retriever.CallbackOutput`: + +```go +handler := &callbacksHelper.RetrieverCallbackHandler{ + OnStart: func(ctx context.Context, info *callbacks.RunInfo, input *retriever.CallbackInput) context.Context { + log.Printf("query=%s topK=%d", input.Query, input.TopK) + return ctx + }, + OnEnd: func(ctx context.Context, info *callbacks.RunInfo, output *retriever.CallbackOutput) context.Context { + log.Printf("docs=%d", len(output.Docs)) + return ctx + }, +} + +helper := callbacksHelper.NewHandlerHelper(). + Retriever(handler). + Handler() + +chain := compose.NewChain[string, []*schema.Document]() +chain.AppendRetriever(retriever) + +runner, _ := chain.Compile(ctx) +docs, _ := runner.Invoke(ctx, "怎么申请退款", compose.WithCallbacks(helper)) + +_ = docs +``` + +这段代码最值得盯住的,不是日志打印,而是它暴露出来的事实: + +- 你能在 `OnStart` 里看到 query 和运行时参数 +- 你能在 `OnEnd` 里拿到检索结果 +- 检索过程本身可以进入统一追踪和观测链路 + +这对排障非常重要。 + +因为 RAG 项目里最难受的问题之一就是: + +“答案不对,到底是模型幻觉,还是前面的召回就错了?” + +如果 `Retriever` 没进入 callback 链路,这个问题会很难查。 +你最后只能在业务层加一堆散乱日志,既不整洁,也不稳定。 + + +## 7. 自己实现一个 Retriever 时,哪些细节不能省 + +如果你要自己接一个新的检索后端,官方文档其实已经把骨架给得很清楚了。 + +真正要守住的顺序,大致就是下面这条: + +- `retriever.GetCommonOptions` +- `callbacks.ManagerFromContext` +- `OnStart` +- `doRetrieve` +- `OnError` +- `OnEnd` + +可以先看一个收过边界的骨架: + +```go +type MyRetriever struct { + index string + topK int + embedder embedding.Embedder +} + +func (r *MyRetriever) Retrieve( + ctx context.Context, + query string, + opts ...retriever.Option, +) ([]*schema.Document, error) { + commonOpts := retriever.GetCommonOptions(&retriever.Options{ + Index: &r.index, + TopK: &r.topK, + Embedding: r.embedder, + }, opts...) + + cm := callbacks.ManagerFromContext(ctx) + runInfo := &callbacks.RunInfo{} + + ctx = cm.OnStart(ctx, runInfo, &retriever.CallbackInput{ + Query: query, + TopK: *commonOpts.TopK, + ScoreThreshold: commonOpts.ScoreThreshold, + Extra: map[string]any{ + "index": commonOpts.Index, + "sub_index": commonOpts.SubIndex, + "dsl": commonOpts.DSLInfo, + }, + }) + + docs, err := r.doRetrieve(ctx, query, commonOpts) + if err != nil { + ctx = cm.OnError(ctx, runInfo, err) + return nil, err + } + + ctx = cm.OnEnd(ctx, runInfo, &retriever.CallbackOutput{ + Docs: docs, + }) + return docs, nil +} + +func (r *MyRetriever) doRetrieve( + ctx context.Context, + query string, + opts *retriever.Options, +) ([]*schema.Document, error) { + var queryVector []float64 + + if opts.Embedding != nil { + vectors, err := opts.Embedding.EmbedStrings(ctx, []string{query}) + if err != nil { + return nil, err + } + queryVector = vectors[0] + } + + _ = queryVector + + docs := []*schema.Document{ + { + ID: "doc_1", + Content: "退款申请一般需要先提交订单号和支付凭证。", + MetaData: map[string]any{ + "score": 0.92, + "source": "faq/refund.md", + "backend": "my_store", + }, + }, + } + + return docs, nil +} +``` + +这段骨架里,有两点特别不能省。 + +**第一,`Embedding` 只在需要时调用。** + +不是所有检索后端都要求你在组件里自己生成 query 向量。 +有的后端支持内置 embedding,有的实现则会直接走关键词或混合检索。 + +所以这里正确的姿势不是“无脑先 embed 一下”,而是: + +> 调用前先看 `opts.Embedding`,有就用,没有就按实现自己的检索模式走。 + +**第二,要把后续节点可能会用到的 metadata 补齐。** + +很多人自己实现 `Retriever` 时,只想着把正文查出来。 +这当然能跑,但后面一接真实业务就会发现不够用。 + +至少下面这些信息,通常值得带出去: + +- 召回分数 +- 来源标识 +- 后端文档 ID +- 命中的索引或分区 +- 你实现里特有的上下文信息 + +因为后续节点不一定只看 `Content`。 +它可能要做来源展示、结果解释、问题排查,甚至还要继续做 rerank 或引用标注。 + +如果 metadata 在这里丢了,后面再补就会很别扭。 + + +## 8. 5 个最容易把 Retriever 用浅的坑 + +### 8.1 把 `Retriever` 当成 SDK 薄封装 + +这是最常见的误区。 + +一旦你这么理解,代码里就会到处散落后端专属请求结构、过滤逻辑和日志逻辑。 +最后不是 Eino 在帮你统一边界,而是你自己把边界重新打碎了。 + +### 8.2 不看 `MetaData`,后面就追不动来源和分数 + +只拿正文,不看 metadata,短 demo 没什么感觉。 + +可一到线上,你很快就会遇到这些问题: + +- 这段答案是从哪篇文档来的? +- 这条结果分数到底高不高? +- 它命中了哪个索引或分区? + +这些都离不开 metadata。 + +### 8.3 `TopK` 和阈值写死 + +很多项目最开始为了省事,直接把 `TopK=5`、`threshold=0.3` 固定死。 + +问题是不同场景需要的召回范围并不一样。 +而且阈值本身还是过滤条件,不是排序条件。 +一旦写死,后面要调优效果就会非常别扭。 + +### 8.4 查询 embedding 和底库向量配置不匹配 + +这是检索效果异常里非常高频的一类问题。 + +写入时用的是一种模型,查询时换了另一种模型,或者维度根本对不上,最后最直观的表现就是: + +“库里明明有内容,可就是召不准。” + +别一上来就怀疑数据脏了,先看 query embedding 和底库配置是不是同一套。 + +### 8.5 不接 callback,召回问题很难排 + +RAG 项目里,很多问题不是“功能坏了”,而是“效果不稳定”。 + +这类问题如果没有 callback,你很难快速判断: + +- 这次 query 进来时到底用了什么参数 +- 检索结果到底返回了几条 +- 是前面没召回到,还是后面模型没用好 + +所以 callback 不是锦上添花,它在检索层经常就是排障入口。 + + +## 9. 总结 + +如果用一句话总结这篇 `Retriever 使用说明`,我会这样说: + +> `Retrieve` 的本质不是“调一次 search”,而是“让 query 以统一协议进入读侧检索系统”。 + +再压缩成几句,就是: + +- `Retriever` 解决的是读侧协议统一,不是某家后端 SDK 的简单包一层 +- `Retrieve` 里可能同时发生向量化、过滤、召回、结果解析和 callback 触发 +- `Indexer` 管“怎么写进去”,`Retriever` 管“怎么查出来”,两层边界不能混 + +你把这层看懂,后面无论是继续往 `Rerank`、完整 RAG、还是更复杂的编排链路走,脑子都会顺很多。 + + +## 参考资料 + +- CloudWeGo Eino [Retriever 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/retriever_guide/) +- CloudWeGo Eino [components/retriever/interface.go](https://github.com/cloudwego/eino/blob/main/components/retriever/interface.go) +- CloudWeGo Eino [components/retriever/option.go](https://github.com/cloudwego/eino/blob/main/components/retriever/option.go) +- CloudWeGo Eino Ext `components/retriever/volc_vikingdb/examples/builtin_embedding` + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Retriever,却没真正看懂 Retrieve](./07-为什么很多人会用Retriever,却没真正看懂Retrieve.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 组件核心篇:为什么很多人会用 Retriever,却没真正看懂 Retrieve](https://zhumo.blog.csdn.net/article/details/159549389) +- 官方文档:[Retriever 使用说明](https://www.cloudwego.io/zh/docs/eino/core_modules/components/retriever_guide/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/01-\344\270\200\346\226\207\350\256\262\351\200\217\347\274\226\346\216\222\357\274\210Chain\344\270\216Graph\357\274\211.md" "b/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/01-\344\270\200\346\226\207\350\256\262\351\200\217\347\274\226\346\216\222\357\274\210Chain\344\270\216Graph\357\274\211.md" new file mode 100644 index 0000000..297c261 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/01-\344\270\200\346\226\207\350\256\262\351\200\217\347\274\226\346\216\222\357\274\210Chain\344\270\216Graph\357\274\211.md" @@ -0,0 +1,709 @@ +# AI 大模型落地系列|Eino 编排进阶篇:一文讲透编排(Chain 与 Graph) + +> GitHub 主文:[当前文章](./01-一文讲透编排(Chain与Graph).md) +> CSDN 跳转:[AI 大模型落地系列|Eino 编排进阶篇:一文讲透编排(Chain 与 Graph)](https://zhumo.blog.csdn.net/article/details/159571042) +> 官方文档:[Chain/Graph 编排介绍](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/chain_graph_introduction/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从执行关系、类型边界、统一运行时三条线看清为什么复杂链路最终都会走到编排。 +**适合谁看**:已经理解基础组件,准备进入复杂链路和运行时建模的 Go 开发者。 +**前置知识**:ChatTemplate、Tool、Retriever 等核心组件、基础泛型和接口认知 +**对应 Demo**:[examples/chain-graph](../../examples/chain-graph/README.md) + +**面试可讲点** +- 能解释 Chain 和 Graph 的差别在于心智模型和关系显式度,而不是复杂度高低。 +- 能讲清 Compile、Runnable、类型对齐、状态边界这些编排层关键词。 + +--- +很多人第一次看 Eino 的 `Chain / Graph`,第一反应都差不多: + +不就是把 `prompt`、`model`、`tool` 接一下吗? + +这件事自己写几个函数也能干,为什么还要单独学一套编排? + +如果你只是跑个 demo,这个想法没什么问题。 +但只要链路一变长,问题马上就会冒出来: + +- 上一个节点到底给下一个节点传了什么,靠 `any` 还是靠猜? +- 同一条链路既要支持完整输出,又要支持流式输出,代码是不是得写两套? +- 工具调用、分支执行、状态共享,到底写在业务里,还是写在框架里? +- 多个节点汇聚到一个节点时,数据怎么合并,谁来兜底? + +这些问题如果都散在业务代码里,系统也能跑。 +但通常跑不久就会开始难改、难查、难扩。 + +所以这篇文章不打算做一份“API 目录导览”。 +我更想讲清楚这件事: + +> `Chain / Graph` 解决的不是“怎么把几个组件连起来”,而是“复杂执行链路怎样以稳定、可检查、可扩展的方式跑起来”。 + +本文会按三条线往下讲: + +- 为啥要用 +- 工程视角怎么拆 +- 代码场景怎么落 + +`Graph` 是主角。 +`Chain` 放到后面讲,因为它本质上是更顺手的入口,不是另一套世界观。 + +## 1. 为什么很多项目最后都会走到编排 + +如果你的程序只有一步,比如“把一条用户消息送进模型,然后拿回回答”,那当然不需要太重的编排。 + +问题在于,大多数真实项目不会永远停在这一步。 + +你很快就会碰到下面这些事: + +- 先做 prompt 组装,再调用模型 +- 模型命中了工具调用,还要执行工具,再把结果接回消息链路 +- 有的节点要走同步,有的节点要走流式 +- 某些运行过程需要保留状态,供后续节点继续判断 +- 某些节点前面有多个上游,后面还有多个分支 + +这时你会发现,麻烦不在于“多写几个函数”。 +麻烦在于,这些函数之间其实已经存在明确的运行关系。 + +它们不是散点逻辑,而是一张执行图。 + +从工程角度看,`Chain / Graph` 真正想收口的是 4 件事: + +**第一,节点之间的输入输出边界。** + +上游吐出来的值,下游到底能不能接,不能靠上线以后才发现。 +Eino 的思路是:尽量在 `Compile` 阶段就把这件事说明白。 + +**第二,执行关系。** + +谁先跑,谁后跑,谁分支,谁汇聚,谁结束,这些都不该藏在几十行 if/else 和回调里。 + +**第三,运行时范式。** + +同一条编排链,不应该“同步是一套写法,流式又是一套写法”。 +Eino 最后编译出来的是统一的 `Runnable`,可以 `Invoke`、可以 `Stream`、也可以 `Transform`。 + +**第四,工程扩展点。** + +状态、回调、工具调用、嵌套图,这些东西都不是 demo 里最抢眼的功能,但它们才决定你这套链路后面能不能活得久。 + +所以你如果要问: + +> 为什么很多后端工程师一开始觉得 `Chain / Graph` 有点“重”,做一阵子又会反过来觉得它有必要? + +答案很简单: + +因为系统一复杂,你迟早都要面对“编排”这件事。 +区别只在于,你是显式地把它交给框架,还是隐式地把它塞进业务代码。 + +## 2. 先把 Graph 看懂:它编排的不是函数,而是运行关系 + +很多人第一次看 `Graph`,关注点全在“怎么连节点”。 + +这只看到了表面。 + +`Graph` 真正重要的,不是你能不能写出 `AddEdge`。 +而是它把“节点”和“节点之间的运行关系”明确成了一张图。 + +看一个最小闭环: + +```go +ctx := context.Background() + +g := compose.NewGraph[map[string]any, *schema.Message]() + +tpl := prompt.FromMessages( + schema.FString, + schema.UserMessage("what's the weather in {location}?"), +) + +_ = g.AddChatTemplateNode("prompt", tpl) +_ = g.AddChatModelNode("model", &mockChatModel{}) + +_ = g.AddEdge(compose.START, "prompt") +_ = g.AddEdge("prompt", "model") +_ = g.AddEdge("model", compose.END) + +r, err := g.Compile(ctx) +if err != nil { + panic(err) +} + +out, err := r.Invoke(ctx, map[string]any{"location": "beijing"}) +if err != nil { + panic(err) +} +fmt.Println(out.Content) + +stream, err := r.Stream(ctx, map[string]any{"location": "beijing"}) +if err != nil { + panic(err) +} +defer stream.Close() + +for { + chunk, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + panic(err) + } + fmt.Println(chunk.Content) +} +``` + +- 这段代码解决了什么工程问题:它把 `prompt -> model -> end` 这条执行链路显式表达了出来,并在 `Compile` 之后收口成一个统一可运行对象。 +- 不用编排时,这段逻辑会散到哪里:模板格式化、模型调用、同步输出、流式输出这些逻辑通常会散在 controller、service、helper 甚至 goroutine 里,最后没人说得清“这条链路本来长什么样”。 + +这段代码最值得盯住的,不是天气问题,也不是 `mockChatModel`。 + +而是 5 个关键词: + +**1. `compose.NewGraph[I, O]`** + +图的输入、输出类型在一开始就定下来了。 +这和“全程都传 `map[string]any`,最后再断言类型”的思路不一样。 + +**2. `START / END`** + +图不是一堆松散节点。 +它有明确入口,也有明确终点。 + +**3. Node** + +`ChatTemplate`、`ChatModel`、`ToolsNode`、`Lambda`、甚至另一个 `Graph`,都可以是节点。 +也就是说,`Graph` 编排的不是某一种固定组件,而是“逻辑节点”。 + +**4. Edge** + +边不是装饰。 +边定义的是下一个要跑谁。 +这意味着“关系”本身成了第一等公民。 + +**5. `Compile`** + +这一步最容易被低估。 + +很多人会想:我都已经把节点和边加完了,为什么还要多一次编译? + +因为 `Compile` 干的不是“形式化地收个尾”。 +它是在把你刚才搭出来的图,转成一个真正可运行、可检查的 `Runnable`。 + +说白一点: + +> `Graph` 不是“边写边跑”的胶水脚本,它更像是先把执行拓扑搭清楚,再生成运行体。 + +这也是为什么 `Compile` 不是多余一步。 + +它把“图长什么样”和“图怎么跑”切开了。 +前者是结构定义,后者是运行时。 + +## 3. Graph 最值钱的,不是能连线,而是把边界定死 + +如果只把 `Graph` 理解成“可以把节点画成一张图”,那还是太浅。 + +它真正值钱的地方,在于把一条复杂链路里最容易失控的边界收住了。 + +### 3.1 先定类型,再谈连接 + +官方在“编排的设计理念”里反复强调一个词:`类型对齐`。 + +这不是文档里的漂亮话。 +这其实是在回答一个很现实的问题: + +> 上一个节点的输出,凭什么就能当下一个节点的输入? + +如果你的方案是“先都塞成 `any` 再说”,那后面每个节点都得自己做类型断言。 +如果你的方案是“统一都传 `map[string]any`”,那心智负担也只是换了个地方。 + +Eino 走的是另一条路: + +- 节点尽量保持开发者预期中的具体类型 +- 在 `Compile` 阶段检查上下游能不能对齐 +- 必要时通过 `WithOutputKey`、`WithInputKey` 做受控转换 + +这套设计对 Go 工程师很友好。 + +因为你脑子里想的,不再是“这团 `any` 里面可能装了什么”。 +而是“这个节点吐出来的东西,下一个节点有没有资格接”。 + +这就像搭积木。 +尺寸对上了,才能接上。 + +### 3.2 `WithOutputKey / WithInputKey` 不是小技巧,而是汇聚场景的正道 + +很多人把 `WithOutputKey`、`WithInputKey` 当成“偶尔拿来修一下类型”的小技巧。 + +其实不是。 + +它们真正重要的地方,在于多上游汇聚时,你必须正面回答两个问题: + +- 多个上游输出怎么合并? +- 下游到底从哪一个 key 取值? + +比如上游输出的是 `string`,但多个节点最终要汇聚到一个 `map[string]any` 节点,这时可以用 `compose.WithOutputKey("query")` 把它包成 map。 + +反过来,如果上游已经是 `map[string]any`,而下游只想拿其中一个字段,则用 `compose.WithInputKey("query")` 明确取值。 + +这件事看起来只是类型转换。 +本质上是在避免“汇聚以后到底该读哪份数据”变成隐式约定。 + +### 3.3 外部变量只读,不是洁癖,是并发和流式场景的底线 + +这是我觉得很多人最容易忽略、但又最工程化的一条原则。 + +官方明确提到,图里节点之间的数据流转,本质上是变量赋值,不是深拷贝。 +所以当输入是 `map`、`slice`、指针这类引用类型时,如果你在节点内部直接修改它,就可能把副作用带到外面。 + +这在分支、扇出、流式场景里尤其危险。 + +因为你以为自己只是“顺手改一下”。 +实际上你改的可能是整个运行过程共享着的那份值。 + +所以 Eino 的建议很明确: + +> Node、Branch、Handler 内部默认不要修改输入;真要改,先自己 Copy。 + +这不是框架保守。 +这是运行时系统必须守住的底线。 + +### 3.4 `Runnable` 统一了运行姿势 + +`Graph` 一旦 `Compile` 完,拿到的是 `Runnable`。 + +这件事很关键。 + +因为这说明编排产物最终不是“某个特殊 Graph 对象”。 +而是一个统一运行入口。 + +它至少有三种常用姿势: + +- `Invoke`:完整输入,完整输出 +- `Stream`:完整输入,流式输出 +- `Transform`:流式输入,流式输出 + +这意味着你不需要为了“换成流式”就重新发明一条执行链。 + +框架会在运行时帮你补齐缺失的流式范式。 +这比业务层自己维护两套流程稳定得多。 + +## 4. ToolCallAgent 这种场景,为什么更适合挂进 Graph + +如果说最能体现 `Graph` 工程价值的场景,我认为不是天气 demo。 + +而是 `ToolCallAgent`。 + +因为这个场景刚好包含了三层边界: + +- Prompt 怎么组 +- 模型怎么做工具决策 +- 工具结果怎么重新回到消息链路 + +看一个裁剪后的主链: + +```go +chatTpl := prompt.FromMessages( + schema.FString, + schema.SystemMessage("你是一名房产经纪人,结合用户信息推荐房产。"), + schema.MessagesPlaceholder("message_histories", true), + schema.UserMessage("{user_query}"), +) + +chatModel, _ := openai.NewChatModel(ctx, modelConf) + +userInfoTool := utils.NewTool( + &schema.ToolInfo{ + Name: "user_info", + Desc: "根据用户姓名和邮箱查询公司、职位、薪酬", + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ + "name": {Type: "string", Desc: "用户姓名"}, + "email": {Type: "string", Desc: "用户邮箱"}, + }), + }, + func(ctx context.Context, input *userInfoRequest) (*userInfoResponse, error) { + return &userInfoResponse{ + Name: input.Name, + Email: input.Email, + Company: "Bytedance", + Position: "CEO", + Salary: "9999", + }, nil + }, +) + +info, _ := userInfoTool.Info(ctx) +_ = chatModel.BindForcedTools([]*schema.ToolInfo{info}) + +toolsNode, _ := compose.NewToolNode(ctx, &compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{userInfoTool}, +}) + +g := compose.NewGraph[map[string]any, []*schema.Message]() +_ = g.AddChatTemplateNode("template", chatTpl) +_ = g.AddChatModelNode("chat_model", chatModel) +_ = g.AddToolsNode("tools", toolsNode) + +_ = g.AddEdge(compose.START, "template") +_ = g.AddEdge("template", "chat_model") +_ = g.AddEdge("chat_model", "tools") +_ = g.AddEdge("tools", compose.END) + +r, _ := g.Compile(ctx) +out, _ := r.Invoke(ctx, map[string]any{ + "message_histories": []*schema.Message{}, + "user_query": "我叫 zhangsan,邮箱是 zhangsan@bytedance.com,帮我推荐一处房产", +}) +``` + +- 这段代码解决了什么工程问题:它把“提示词准备 -> 模型决策 -> 工具执行 -> 结果回链路”压成了一条明确执行链,而不是让工具调用散在业务流程里。 +- 不用编排时,这段逻辑会散到哪里:prompt 组装、模型调用、`ToolCall` 解析、工具分发、工具结果封装、下一轮消息拼接,最后大概率会混在同一个 service 方法里。 + +这里最重要的一句话是: + +> `ChatModel` 负责决定调谁,`Graph` 负责把整条执行链跑通,`ToolsNode` 负责把已经做出的调用真正执行掉。 + +这个边界一旦看清,很多误解都会消失。 + +比如: + +- `ToolsNode` 不是决策器 +- `Tool` 不是流程控制器 +- `Graph` 不是为了让代码更“好看”才存在 + +它存在的原因很现实: + +如果你手写这条链,早期也能跑。 +但一旦你要加第二个工具、要记录 callback、要改成流式、要嵌套别的节点,代码会迅速变成“谁都能改,谁都不敢动”的样子。 + +`Graph` 的价值就在这儿。 + +它不是替你写业务。 +它是替你把执行边界立住。 + +## 5. Graph with state,重点不是“能存数据”,而是“数据放在哪一层” + +很多人一看到 `state`,直觉会很兴奋: + +那我是不是终于有地方塞各种临时变量了? + +如果你这样理解,后面很容易把 `state` 用偏。 + +`Graph with state` 的重点,不是“图里可以放全局变量”。 +而是“这次运行过程中,需要有一份只属于这次运行的上下文”。 + +看一个精简后的例子: + +```go +type runState struct { + Steps []string +} + +g := compose.NewGraph[string, string]( + compose.WithGenLocalState(func(ctx context.Context) *runState { + return &runState{} + }), +) + +_ = g.AddLambdaNode( + "prepare", + compose.InvokableLambda(func(ctx context.Context, in string) (string, error) { + return strings.ToUpper(in), nil + }), + compose.WithStatePreHandler(func(ctx context.Context, in string, state *runState) (string, error) { + state.Steps = append(state.Steps, "input:"+in) + return in, nil + }), + compose.WithStatePostHandler(func(ctx context.Context, out string, state *runState) (string, error) { + state.Steps = append(state.Steps, "prepare:"+out) + return out, nil + }), +) + +_ = g.AddLambdaNode( + "finish", + compose.InvokableLambda(func(ctx context.Context, in string) (string, error) { + var history string + err := compose.ProcessState[*runState](ctx, func(_ context.Context, state *runState) error { + history = strings.Join(state.Steps, " -> ") + state.Steps = append(state.Steps, "finish:"+in) + return nil + }) + if err != nil { + return "", err + } + return history + " -> finish:" + in, nil + }), +) + +_ = g.AddEdge(compose.START, "prepare") +_ = g.AddEdge("prepare", "finish") +_ = g.AddEdge("finish", compose.END) +``` + +- 这段代码解决了什么工程问题:它把“单次运行上下文”显式挂在图上,而不是让节点通过包变量、共享 map 或上下文外的全局对象偷偷交换信息。 +- 不用编排时,这段逻辑会散到哪里:某些人会把状态塞到闭包里,有些人会塞进全局 map,还有些人会把它挂到业务 struct 上,最后状态边界和生命周期一起失控。 + +这段代码里有 3 个点要分开看。 + +**1. `WithGenLocalState`** + +它定义的是:每次运行这张图时,怎么生成一份新的状态。 + +注意,是“每次运行一份新的”。 +不是“整个应用启动以后共用一份”。 + +**2. `WithStatePreHandler / WithStatePostHandler`** + +它们是节点外侧的钩子。 + +你可以理解成: + +- 节点真正执行前,先看一下输入和状态 +- 节点真正执行后,再看一下输出和状态 + +这很适合做运行过程中的记录、补充、调整。 + +**3. `ProcessState`** + +这是节点内部读写状态的入口。 + +当节点本身需要根据历史状态做判断时,就该走这里,而不是绕出去摸别的共享变量。 + +所以 `state` 的正确打开方式,不是“我终于有个地方可以乱塞东西”。 + +而是: + +> 这次运行里,哪些上下文确实属于图本身,而且后续节点还要继续用? + +如果不满足这个条件,就别放。 + +比如数据库连接、全局配置、跨请求缓存,这些都不该进这里。 +它们不是“单次运行上下文”。 + +## 6. Chain 为什么是更顺手的入口,而不是另一套框架 + +官方文档里有一句话我很认同: + +> `Chain` 可以视为 `Graph` 的简化封装。 + +这句话很重要。 + +因为很多人学到这里,会产生两个相反的误解: + +- 要么觉得 `Chain` 太简单,像玩具 +- 要么觉得 `Chain` 和 `Graph` 是两套并列框架 + +这两个理解都不对。 + +`Chain` 的本质,是把“线性链路”写得更顺。 + +看一个裁剪过的例子: + +```go +parallel := compose.NewParallel(). + AddLambda("role", compose.InvokableLambda(func(ctx context.Context, kvs map[string]any) (string, error) { + role, _ := kvs["role"].(string) + if role == "" { + role = "bird" + } + return role, nil + })). + AddLambda("input", compose.InvokableLambda(func(ctx context.Context, kvs map[string]any) (string, error) { + return "你的叫声是怎样的?", nil + })) + +rolePlayer := compose.NewChain[map[string]any, *schema.Message]() +rolePlayer. + AppendChatTemplate(prompt.FromMessages( + schema.FString, + schema.SystemMessage("You are a {role}."), + schema.UserMessage("{input}"), + )). + AppendChatModel(cm) + +chain := compose.NewChain[map[string]any, string]() +chain. + AppendLambda(compose.InvokableLambda(func(ctx context.Context, kvs map[string]any) (map[string]any, error) { + return kvs, nil + })). + AppendBranch( + compose.NewChainBranch(branchCond). + AddLambda("b1", b1). + AddLambda("b2", b2), + ). + AppendPassthrough(). + AppendParallel(parallel). + AppendGraph(rolePlayer). + AppendLambda(compose.InvokableLambda(func(ctx context.Context, m *schema.Message) (string, error) { + return m.Content, nil + })) + +r, _ := chain.Compile(ctx) +output, _ := r.Invoke(ctx, map[string]any{}) +``` + +- 这段代码解决了什么工程问题:它把一条以线性推进为主的执行链写得更紧凑,同时保留了分支、并行和图嵌套能力。 +- 不用编排时,这段逻辑会散到哪里:每一步都要手工传值、手工判断分支、手工等待并行结果、手工把子流程接回来,最后“主链”本身会淹没在细节里。 + +这段代码说明了两件事。 + +**第一,`Chain` 并不弱。** + +它不是只有 `AppendChatTemplate`、`AppendChatModel` 这种最简单的串联。 +它还能接 `branch`、接 `parallel`、接另一个 `graph`。 + +**第二,`Chain` 仍然是线性心智模型。** + +你写的时候,脑子里想的是“先做 A,再做 B,再做 C”。 +这比直接上图更顺手。 + +所以很多场景下,`Chain` 应该是你的第一选择。 +尤其是: + +- 处理链路天然线性 +- 中间节点之间没有太多复杂汇聚 +- 你更想快速表达主路径 + +但如果你已经明显开始关心: + +- 节点关系是不是要显式画出来 +- 哪些节点是多上游汇聚 +- 哪些节点是复杂分支 +- 哪些地方要更强的状态控制 + +那就别再硬拿 `Chain` 扛所有场景了。 + +## 7. 什么时候用 Chain,什么时候直接上 Graph + +这件事不复杂。 +我直接给结论。 + +### 更适合 `Chain` 的场景 + +- 主路径基本是线性的 +- 你更在乎表达“步骤顺序” +- 分支和并行只是局部点缀,不是主体结构 +- 你想快速把一个可运行流程搭起来 + +### 更适合 `Graph` 的场景 + +- 你需要显式表达节点关系 +- 多上游汇聚、扇出、复杂分支比较多 +- 你需要更清楚地控制输入输出边界 +- 你希望状态、工具调用、嵌套流程都放在一张正式运行图里 + +### 一个实用判断法 + +如果你现在写流程时,脑子里更像是在想: + +“下一步接谁?” + +那多半该用 `Graph`。 + +如果你现在写流程时,脑子里更像是在想: + +“下一步做什么?” + +那多半先用 `Chain`。 + +`Workflow` 则是下一层话题。 +它更强调字段映射、控制流和更细颗粒度的编排控制。 +但如果你现在还没把 `Chain / Graph` 看清,先别急着跳过去。 + +## 8. 5 个最容易把编排用浅的坑 + +### 8.1 把 `map[string]any` 当万能胶 + +`map[string]any` 不是不能用。 + +但如果你从头到尾都靠它传值,最后还是会回到“每个节点都在猜 key、猜类型”的老路上。 + +它更适合: + +- 明确的汇聚场景 +- 经由 `WithOutputKey`、`WithInputKey` 做受控转换 + +而不是变成整条链路的默认协议。 + +### 8.2 只写 `Invoke`,从来不看 `Stream / Transform` + +很多 demo 只写 `Invoke`,这是可以理解的。 + +但你如果做的是实际产品链路,迟早会遇到流式输出。 +更进一步,某些节点本身就要吃流、吐流,这时你就得理解 `Transform`。 + +如果你从设计阶段就把这件事忽略了,后面通常要补一套平行逻辑。 + +### 8.3 在节点里直接改外部引用类型 + +这是最隐蔽的坑之一。 + +尤其是 `map`、`slice`、指针。 +你以为自己只是改了当前节点的输入,实际上可能改的是整个运行过程共享的那份值。 + +这类 bug 一旦叠上分支、并发、流式,排起来会非常难受。 + +### 8.4 把 `state` 当“什么都能放”的储物箱 + +`state` 不是跨请求缓存。 +不是全局依赖容器。 +也不是你懒得设计边界时的逃生门。 + +它只该放这次运行过程中确实需要被后续节点继续消费的上下文。 + +### 8.5 把 `Chain` 当成 `Agent` + +`Chain` 可以承接很多 agent 的执行步骤。 +但它本身不是 agent 概念本身。 + +如果你把这两个层级混在一起,后面讨论 tool calling、event、runner、workflow agent 时,脑子会越来越乱。 + +`Chain / Graph` 解决的是编排。 +`Agent` 解决的是更上层的智能体运行抽象。 + +这两个层级要分开。 + +## 9. 总结 + +很多人学 `Chain / Graph` 时,最容易走偏的一点,就是把它当成“更高级一点的流程写法”。 + +这个理解不算错,但远远不够。 + +它真正值钱的地方在于: + +- 把节点和关系显式化 +- 把上下游边界定清楚 +- 把同步、流式、状态、工具调用纳入统一运行时 +- 把复杂链路从业务胶水里拆出来 + +所以如果你现在还在犹豫: + +> 这套东西到底是不是必须学? + +我的看法很直接: + +如果你只是写一个一屏能看完的小 demo,未必急着学。 +但只要你要做的不是一次性脚本,而是一条会持续演进的 AI 应用链路,那你迟早都要面对编排。 + +与其把编排偷偷写进业务代码,不如正面把它建模出来。 + +`Graph` 适合你把复杂关系讲清楚。 +`Chain` 适合你把主路径写顺。 + +这两层一旦看懂,后面的 `Workflow`、`Agent`、`GraphTool`,你会顺很多。 + +## 参考资料 + +1. [Chain/Graph 编排介绍](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/chain_graph_introduction/) +2. [编排的设计理念](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/orchestration_design_principles/) +3. [eino-examples/compose](https://github.com/cloudwego/eino-examples/tree/main/compose) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 编排进阶篇:一文讲透编排(Chain 与 Graph)](./01-一文讲透编排(Chain与Graph).md) +- CSDN 跳转:[AI 大模型落地系列|Eino 编排进阶篇:一文讲透编排(Chain 与 Graph)](https://zhumo.blog.csdn.net/article/details/159571042) +- 官方文档:[Chain/Graph 编排介绍](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/chain_graph_introduction/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/02-\346\227\242\347\204\266\346\234\211\344\272\206Chain\343\200\201Graph\357\274\214\344\270\272\344\275\225\350\277\230\351\234\200\350\246\201Workflow.md" "b/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/02-\346\227\242\347\204\266\346\234\211\344\272\206Chain\343\200\201Graph\357\274\214\344\270\272\344\275\225\350\277\230\351\234\200\350\246\201Workflow.md" new file mode 100644 index 0000000..3ab14ba --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/02-\346\227\242\347\204\266\346\234\211\344\272\206Chain\343\200\201Graph\357\274\214\344\270\272\344\275\225\350\277\230\351\234\200\350\246\201Workflow.md" @@ -0,0 +1,731 @@ +# AI 大模型落地系列|Eino 编排篇:既然有了 Chain、Graph,为何还需要 Workflow + +> GitHub 主文:[当前文章](./02-既然有了Chain、Graph,为何还需要Workflow.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 编排篇:既然有了 Chain、Graph,为何还需要 Workflow](https://zhumo.blog.csdn.net/article/details/159583345) +> 官方文档:[Workflow 编排框架](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/workflow_orchestration_framework/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从字段映射、控制流和更细颗粒度编排能力解释 Workflow 的独立存在价值。 +**适合谁看**:已经看过 Chain / Graph,开始考虑更复杂流程控制的工程师。 +**前置知识**:Chain 与 Graph、流程建模基础 +**对应 Demo**:[官方 Workflow 示例(本仓后续补充同主题 demo)](https://github.com/cloudwego/eino-examples/tree/main/compose/workflow) + +**面试可讲点** +- 能解释 Workflow 不是重复造轮子,而是在字段映射和控制流层面补了能力。 +- 能说明什么时候 Chain / Graph 足够,什么时候该升级到 Workflow。 + +--- +很多人学完 Eino的 `Chain / Graph` 之后,会产生一个很自然的判断: + +流程都能连起来了,为什么还要再学一个 `Workflow`? + +可一到真实项目,这个判断很快就会松动。 + +- 你手里已经有两个现成业务函数,输入输出都是强业务结构体,硬改成一个 common struct 很别扭。 +- 你有一个下游节点只想拿上游某两个字段,不想把整份上下文一路透传下去。 +- 你有一个节点既受前驱执行顺序控制,又只依赖更早节点的局部数据,`Graph` 能写,但你又会觉得费脑子。 + +这时候你会发现,问题已经不是“节点能不能连起来”。 +问题变成了“谁该在什么时候执行,以及它到底该吃谁的哪个字段”。 + + + +## 1. 为什么 Chain/Graph 讲完了,还要单独学 Workflow + +上一篇我把 `Chain / Graph` 的区别压成过两句话: + +- `Chain` 更像“**把固定步骤按顺序串起来**” +- `Graph` 更像“**流程在不同节点之间怎么分支、跳转、汇合**” + +这两句话对大多数编排问题都成立。 +但当你继续往工程里走,会遇到一类更细的麻烦: + +- 节点 A 和节点 B 的输入输出类型根本不对齐 +- 节点 D 的执行顺序受 B、C 控制,但它还想读 A 的一个字段 +- 节点 E 只需要等 D 执行完成,却完全不关心 D 的输出 + +这类问题,`Graph` 不是不能做。 +只是你往往要在下面几种方案里二选一: + +- 把多个节点都改成同一种输入输出结构 +- 中间塞 `map[string]any` +- 借 `OutputKey / InputKey / state` 兜一层 + +这些办法能解决问题,但表达不够直接。 + +`Workflow` 想解决的,正是这层“不够直接”。 +> 同时,又正如官方所说: +> Workflow 与 Graph API 具有同等级别的能力,都是编排“围绕大模型的信息流”的合适框架工具。 +> - 但 Workflow 节点的输入可以由任意前驱节点的任意输出字段组合而成。 +> - Graph 的 Edge 是既决定执行顺序,又决定数据传递。Workflow 中可以一起传递,也可以分开传递。 +![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/369b7026a5504db7a07976d43ccb1c8e.png) + +所以,它和 `Graph` 在能力层级上是同一层的: + +- 节点类型基本一致 +- 流处理、callback、option、state、interrupt / checkpoint 的运行规则基本一致 +- 它自己也实现 `AnyGraph`,可以被更大的 `Graph / Chain / Workflow` 当成子图接进去 + +所以别把它理解成“Graph 的语法糖”。 + +**`Graph` 主要解决节点关系建模,`Workflow` 主要解决字段级编排建模。** + +这两者不是高低关系,是表达重点不同。 + +## 2. Workflow 真正解决的 2 个工程问题 + +### 2.1 现有业务函数的输入输出,不必为了编排而改形状 + +假设你已经有两个业务函数: + +- `f1(ctx, OrderInput) -> RiskMaterial` +- `f2(ctx, AuditContext) -> AuditResult` + +现在你只想把 `f1` 的 `UserTags`、`OrderAmount` 这两个字段,映射给 `f2` 的 `Tags`、`Amount`。 + +如果你只用 `Graph`,通常有两条路: + +- 把两个函数都改成吃同一个 common struct +- 统一退化成 `map[string]any` + +前者侵入业务函数签名,后者牺牲强类型边界。 + +`Workflow` 的思路不一样: + +- 节点签名继续服从业务 +- 编排层只声明“从谁的哪个字段,映射到谁的哪个字段” + +**这不是语法糖,而是边界表达。** + +它让业务函数继续保持自己的输入输出语义,而不是为了拼装执行图去迁就编排框架。 + +### 2.2 控制流和数据流,可以拆开表达 + +再看另一个常见场景。 + +假设有一条链路: + +- `START` 提供用户原始请求 +- `Retriever` 负责查知识库 +- `Ranker` 决定是否需要补充召回 +- `PromptBuilder` 最终组装 prompt +- `Logger` 只负责记录“组装已完成” + +这里很容易出现两种关系: + +- `PromptBuilder` 的执行顺序受 `Ranker` 控制,但它同时还想读取 `START.prompt` 和 `Retriever.context` +- `Logger` 必须在 `PromptBuilder` 之后执行,但它并不消费 `PromptBuilder` 的输出 + +如果把这两类关系都压成同一种边,你能写出来,但图的语义会越来越绕。 + +`Workflow` 则把这件事拆开了: + +- 有些边同时承担控制和数据 +- 有些边只承担数据 +- 有些边只承担控制 + +你可能还不理解,请看demo: + +## 3. 先看最小闭环,再看为什么它不只是 Graph 套壳 + +先看一个最简单的 `Workflow`: + +```go +// 创建一个 Workflow:输入类型是 int,输出类型是 string +wf := compose.NewWorkflow[int, string]() + +// 添加一个 Lambda 节点:接收 int,转成 string +wf.AddLambdaNode("lambda", compose.InvokableLambda( + func(ctx context.Context, in int) (string, error) { + return strconv.Itoa(in), nil + }), +// 声明该节点的输入来自流程起点 START +).AddInput(compose.START) + +// 声明流程终点 END 的输入来自 lambda 节点 +wf.End().AddInput("lambda") + +// 编译 Workflow,生成可执行的 runner +runner, err := wf.Compile(context.Background()) +if err != nil { + panic(err) +} + +// 执行流程:传入 1,得到字符串结果 +out, err := runner.Invoke(context.Background(), 1) +if err != nil { + panic(err) +} + +// 输出结果:1 +fmt.Println(out) +``` + +这个例子看起来确实很像 `Graph`。 +因为这里的映射是“整体到整体”: + +- `START` 的全部输出,给 `lambda` 的全部输入 +- `lambda` 的全部输出,给 `END` 的全部输入 + +所以它在效果上接近一条普通边。 + +但这段代码里,已经有几个对后面很关键的点: + +**1. `NewWorkflow[I, O]` 先把整体边界定死。** + +这和 `Graph` 一样,入口和出口的类型在图创建时就确定了。 + +**2. `AddXXXNode` 返回的是 `*WorkflowNode`。** + +这意味着你不是“先加节点,再单独配置关系”。 +而是可以直接对节点做方法链式配置,比如: + +- `AddInput` +- `AddInputWithOptions` +- `SetStaticValue` + +**3. `Workflow` 把很多错误延迟到 `Compile`。** + +`Graph` 的 `AddXXXNode` 往往更早暴露错误。 +`Workflow` 则倾向于把字段映射、依赖关系、类型对齐等问题统一放到 `Compile` 里检查。 + +**4. 如果你觉得他跟 `Graph` 没两样** +那请看下方这个demo,你就会明白了。 + +## 4. 字段映射为什么是 Workflow 的主角 + +`Workflow` 最核心的能力,不是多了几个方法名。 +而是它把编排粒度从“节点到节点”推进到了“字段到字段”。 + +看一个很典型的例子: + +- 整体输入是 `message` +- `message.Message.Content` 给计数器 `c1` +- `message.Message.ReasoningContent` 给计数器 `c2` +- 两个计数结果最后汇总到 `END` + +代码可以写成这样: + +```go +type counter struct { + FullStr string // 被统计的大字符串 + SubStr string // 要查找的子串 +} + +type message struct { + *schema.Message // 原始消息,里面有 Content 和 ReasoningContent + SubStr string // 要统计的目标子串 +} + +// 统计子串在指定字符串中出现的次数 +wordCounter := func(ctx context.Context, c counter) (int, error) { + return strings.Count(c.FullStr, c.SubStr), nil +} + +// Workflow: 输入 message,输出统计结果 map +wf := compose.NewWorkflow[message, map[string]any]() + +// c1:统计 SubStr 在 Message.Content 中出现的次数 +wf.AddLambdaNode("c1", compose.InvokableLambda(wordCounter)). + AddInput( + compose.START, + compose.MapFields("SubStr", "SubStr"), // message.SubStr -> counter.SubStr + compose.MapFieldPaths([]string{"Message", "Content"}, []string{"FullStr"}), + // message.Message.Content -> counter.FullStr + ) + +// c2:统计 SubStr 在 Message.ReasoningContent 中出现的次数 +wf.AddLambdaNode("c2", compose.InvokableLambda(wordCounter)). + AddInput( + compose.START, + compose.MapFields("SubStr", "SubStr"), // message.SubStr -> counter.SubStr + compose.MapFieldPaths([]string{"Message", "ReasoningContent"}, []string{"FullStr"}), // message.Message.ReasoningContent -> counter.FullStr + ) + +// 汇总两个节点结果到输出 map +wf.End(). + AddInput("c1", compose.ToField("content_count")). + AddInput("c2", compose.ToField("reasoning_content_count")) +``` + +这段代码最该注意的不是“数了几个字符”。 +而是它把 3 件事一次性说清楚了: + +- 一个节点可以从同一个前驱拿多个字段 +- 一个节点也可以从多个前驱拿字段 +- 字段映射和节点签名是分开的 + +这就带来了 `Workflow` 最重要的设计收益: + +> **让节点签名服从业务,而不是服从编排。** + +### 4.1 常用 FieldMapping helper 应该怎么理解 + +这些 helper 不难记,但更重要的是记住它们解决的是什么映射关系: +(可以关注一下**to**与**from**出现时,上下游转换的方式。) + +- `compose.MapFields("A", "B")` + - 顶层字段到顶层字段 +- `compose.MapFieldPaths([]string{"req", "body"}, []string{"payload"})` + - 嵌套路径到路径 +- `compose.ToField("result")` + - 把上游整体输出塞进下游某个字段 +- `compose.FromField("payload")` + - 把上游某个字段当成下游整体输入 +- `compose.ToFieldPath([]string{"result", "payload"})` + - 把上游整体输出塞进下游嵌套路径 +- `compose.FromFieldPath([]string{"req", "body"})` + - 把上游嵌套字段当成下游整体输入 + +比如下面这个片段,就把这几个 helper 放在了一起: + +```go +wf.AddLambdaNode("validate", compose.InvokableLambda(validateBody)). + AddInput(compose.START, compose.FromFieldPath([]string{"req", "body"})) + +wf.End(). + AddInput("validate", compose.ToFieldPath([]string{"result", "payload"})) +``` + +第一句的意思是: + +- `START.req.body` 这段嵌套字段,作为 `validate` 节点的完整输入 + +第二句的意思是: + +- `validate` 的整体输出,塞到 `END.result.payload` + +这类表达,在 `Graph` 里往往就得借助中间结构、`state` 或额外节点了。 + +### 4.2 映射不是自由拼装,Workflow 有几条硬规则 + +字段映射很灵活,但不是没有约束。 + +**第一,merge 只能往不同字段合。** + +下面这种是允许的: + +- `c1 -> END.content_count` +- `c2 -> END.reasoning_content_count` + +但如果多个映射都往同一个字段写,`Compile` 会报冲突。 + +**第二,整体映射和字段映射不能混着往同一个输入上塞。** + +比如你一边 `AddInput("x")`,一边又 `AddInput("y", compose.ToField("k"))`,只要两者指向同一个目标输入,就会形成冲突。 + +**第三,struct 参与映射时,字段必须导出。** + +因为内部要走反射。 +如果字段没导出,映射本身就不成立。 +```go +// 首字母大小写的原因 +type Req struct { + Body string // 导出字段 + body string // 未导出字段 +} +``` + +**第四,类型校验有些发生在 `Compile`,有些只能推迟到运行时。** + +比如: + +- 上游 `int`,下游 `string`,这种在 `Compile` 阶段就能判死刑 +- 上游 `any`,下游 `int`,只有真正跑起来,拿到值的实际类型后才能判断 + +所以别把 `Compile` 理解成形式化步骤。 +它其实在替你提前挡掉一大批字段级错误。 + +## 5. 真正把 Workflow 和 Graph 拉开差距的,是控制流与数据流解耦 + +如果字段映射解决的是“谁给谁什么数据”, +那控制流与数据流拆开,解决的就是“谁决定谁执行”和“谁给谁数据”不一定是同一件事。 + +### 5.1 只有数据流,没有控制流 + +看一个简单例子: + +- `adder` 先把一组整数求和 +- `mul` 再把求和结果和 `START.Multiply` 相乘 + +其中 `mul` 的执行顺序受 `adder` 控制, +但 `mul.B` 这个字段的数据来自 `START`,不是来自 `adder`。 + +代码如下: + +```go +type calculator struct { + Add []int // 需要先做加法的一组数字 + Multiply int // 再用于乘法的数字 +} + +type mul struct { + A int // 第一个乘数 + B int // 第二个乘数 +} + +// 创建 Workflow:输入 calculator,最终输出 int +wf := compose.NewWorkflow[calculator, int]() + +// adder 节点:只取输入里的 Add 字段,交给 adder 计算 +wf.AddLambdaNode("adder", compose.InvokableLambda(adder)). + AddInput(compose.START, compose.FromField("Add")) + +// mul 节点:调用 multiplier,入参类型应为 mul +wf.AddLambdaNode("mul", compose.InvokableLambda(multiplier)). + // 把 adder 的输出结果作为 mul.A + AddInput("adder", compose.ToField("A")). + AddInputWithOptions( + compose.START, + []*compose.FieldMapping{ + // 把输入里的 Multiply 字段映射到 mul.B + compose.MapFields("Multiply", "B"), + }, + // 这里只做字段取值,不把 START 视为 mul 的直接依赖边 + compose.WithNoDirectDependency(), + ) + +// 结束节点:直接接收 mul 的输出,作为 Workflow 最终结果 +wf.End().AddInput("mul") +``` + +这里最关键的是: + +- `AddInput("adder", compose.ToField("A"))` 建立了正常的控制 + 数据依赖 +- `AddInputWithOptions(..., compose.WithNoDirectDependency())` 只负责把 `START.Multiply` 这个数据注入给 `mul.B` + +也就是说,`START` 不直接决定 `mul` 何时执行。 +它只是提供 `mul` 要消费的一块数据。 + +**这不是“跨节点随便取值”,而是“在已有控制路径上补一条纯数据依赖”。** + +这一点非常重要。 + +纯数据依赖仍然要求存在可达控制路径。 +如果控制上根本到不了,你也不能靠 `Workflow` 硬把字段从图外抠过来。 + +### 5.2 只有控制流,没有数据流 + +再看另一个更像真实业务的场景: + +- `b1` 先报价 +- `announcer` 只负责记录“报价 1 已完成” +- 分支判断报价是否足够高 +- 不够高就轮到 `b2` + +这里 `announcer` 必须在 `b1` 之后执行,但它不应该吃到 `b1` 的报价数据。 + +这时就该用 `AddDependency`: + +```go +// 创建一个 Workflow:输入是一个 float64,输出是 map[string]float64 +wf := compose.NewWorkflow[float64, map[string]float64]() + +// 节点 b1:直接接收 START 的输入,调用 bidder1 +wf.AddLambdaNode("b1", compose.InvokableLambda(bidder1)). + AddInput(compose.START) + +// 节点 announcer:不消费数据,只声明执行上依赖 b1 +// 也就是 b1 执行完后,announcer 才能执行 +wf.AddLambdaNode("announcer", compose.InvokableLambda(announcer)). + AddDependency("b1") + +// 给 b1 添加分支:根据 b1 的输出结果决定下一步去哪 +wf.AddBranch("b1", compose.NewGraphBranch( + func(ctx context.Context, in float64) (string, error) { + // 如果 b1 的结果大于 5,流程直接结束,不再走 b2 + if in > 5.0 { + return compose.END, nil + } + // 否则继续走 b2 + return "b2", nil + }, + map[string]bool{ + compose.END: true, // 合法分支目标:END + "b2": true, // 合法分支目标:b2 + }, +)) + +// 节点 b2:也使用原始输入 START,调用 bidder2 +// WithNoDirectDependency 表示这里主要是取 START 的值,不额外强调一条显式依赖边 +wf.AddLambdaNode("b2", compose.InvokableLambda(bidder2)). + AddInputWithOptions(compose.START, nil, compose.WithNoDirectDependency()) + +// 结束节点:汇总结果 +// b1 的输出放到最终结果的 bidder1 字段 +// b2 的输出放到最终结果的 bidder2 字段 +wf.End(). + AddInput("b1", compose.ToField("bidder1")). + AddInput("b2", compose.ToField("bidder2")) +``` + +这里的语义很清楚: + +- `AddDependency("b1")` 只建立控制依赖 +- `announcer` 不消费 `b1` 输出 +- `b2` 是否执行由 `branch` 决定 +- `b2` 自己的输入则通过别的映射关系单独声明 + +这就是 `Workflow` 和 `Graph` 在 branch 语义上的一个关键差别: + +**`Graph` 里的 branch 更像“控制和数据一起往下走”,`Workflow` 里的 branch 默认只管控制,数据怎么给要你自己说清楚。** + +## 6. Branch、Static Value、Stream,决定它是不是工程级编排 + +如果 `Workflow` 只有字段映射,它还只是“更细粒度的图”。 +真正让它进入工程态的,是它没有脱离 Eino 的统一运行时(runtime)。 + +### 6.1 `SetStaticValue` 解决的是配置注入,不是拿 `state` 顶锅 + +还是拿竞拍场景举例。 + +假设 `b1` 和 `b2` 都要吃 `Price` 和 `Budget`, +其中 `Price` 来自流程输入,`Budget` 是节点自己的静态配置。 + +这时比起把预算塞进 `state`,更直接的做法是: + +```go +type bidInput struct { + Price float64 // 当前价格:来自流程输入 + Budget float64 // 当前节点自己的预算:通过静态配置注入 +} + +// b1 节点:执行 bidder 逻辑 +wf.AddLambdaNode("b1", compose.InvokableLambda(bidder)). + // 把流程入口 START 的值映射到 bidInput.Price + AddInput(compose.START, compose.ToField("Price")). + // 给 bidInput.Budget 直接注入一个固定值 3.0 + // 说明这个值不是上游传来的,而是当前节点自己的配置 + SetStaticValue([]string{"Budget"}, 3.0) + +// b2 节点:同样执行 bidder 逻辑 +wf.AddLambdaNode("b2", compose.InvokableLambda(bidder)). + // 控制依赖:b2 要等 b1 执行完之后才会运行 + AddDependency("b1"). + AddInputWithOptions( + compose.START, + // 仍然从流程入口 START 取值,并映射到 bidInput.Price + []*compose.FieldMapping{compose.ToField("Price")}, + // 不把 START -> b2 视为直接控制依赖 + // 这里只是补一条“数据来源”,不是说 b2 由 START 直接触发 + compose.WithNoDirectDependency(), + ). + // 给 b2 单独注入自己的预算 4.0 + // 也就是说:b1 和 b2 吃的是同一个 Price,但 Budget 各不相同 + SetStaticValue([]string{"Budget"}, 4.0) +``` + +`SetStaticValue` 的价值很朴素: + +- 这是节点输入的一部分 +- 但它不来自任何前驱节点 +- 所以不该为了塞一个常量,把 `state` 搞成杂物间 + +**静态值是输入装配问题,不是状态管理问题。** + +### 6.2 `Workflow` 不是只能跑单次调用,它仍然是完整的 Eino runtime + +`Workflow` 的另一个容易被低估的点,是它没有脱离 `Runnable`。 + +也就是说,`Compile` 之后你拿到的仍然是统一的运行体: + +- 可以 `Invoke` +- 可以 `Transform` +- 也能进入更大的编排图 + +看一个流式例子。 +这里输入不再是单条消息,而是一条 `*schema.Message` 流: + +```go +type counter struct { + FullStr string // 当前收到的正文片段 + SubStr string // 要统计的目标子串,比如 "o" +} + +wordCounter := func(ctx context.Context, in *schema.StreamReader[counter]) ( + *schema.StreamReader[int], error, +) { + var subStr, cached string + // subStr: 已经拿到的目标子串 + // cached: 当目标子串还没到时,先暂存正文内容 + + // 一个回调函数 + return schema.StreamReaderWithConvert(in, func(chunk counter) (int, error) { + // 如果当前 chunk 带来了 SubStr,说明现在才拿到“统计目标” + if chunk.SubStr != "" { + subStr = chunk.SubStr + + // 把之前缓存的正文和当前正文拼起来一起统计 + full := cached + chunk.FullStr + cached = "" + + return strings.Count(full, subStr), nil + } + + // 如果 SubStr 还没到,就先缓存正文,暂时不能产出结果 + if subStr == "" { + cached += chunk.FullStr + return 0, schema.ErrNoValue + } + + // 如果已经拿到 SubStr,后续正文片段就可以直接统计 + return strings.Count(chunk.FullStr, subStr), nil + }), nil +} +``` + +然后 `Workflow` 这边这么接: + +```go +wf := compose.NewWorkflow[*schema.Message, map[string]int]() +// 创建一个 Workflow: +// 输入是 *schema.Message 的流 +// 输出是 map[string]int,最后把多个节点的统计结果汇总成一个 map + +wf.AddLambdaNode("c1", compose.TransformableLambda(wordCounter)). + // c1 节点处理 Message.Content + // 把输入消息里的 Content 字段映射到 counter.FullStr + AddInput(compose.START, compose.MapFields("Content", "FullStr")). + // 给 counter.SubStr 注入静态值 "o" + // 表示 c1 专门统计 Content 中 "o" 出现的次数 + SetStaticValue([]string{"SubStr"}, "o") + +wf.AddLambdaNode("c2", compose.TransformableLambda(wordCounter)). + // c2 节点处理 Message.ReasoningContent + // 把输入消息里的 ReasoningContent 映射到 counter.FullStr + AddInput(compose.START, compose.MapFields("ReasoningContent", "FullStr")). + // 同样统计 "o" 的出现次数 + SetStaticValue([]string{"SubStr"}, "o") + +wf.End(). + // 把 c1 的输出放到结果 map 的 content_count 字段 + AddInput("c1", compose.ToField("content_count")). + // 把 c2 的输出放到结果 map 的 reasoning_content_count 字段 + AddInput("c2", compose.ToField("reasoning_content_count")) + +runner, err := wf.Compile(context.Background()) +if err != nil { + panic(err) +} +// 编译 Workflow,得到可运行的 runner + +result, err := runner.Transform( + context.Background(), + schema.StreamReaderFromArray([]*schema.Message{ + // 第一段消息只有 ReasoningContent + {ReasoningContent: "I need to say something meaningful"}, + // 第二段消息只有 Content + {Content: "Hello world!"}, + }), +) +// 以流式方式执行: +// c1 统计 Content 里 "o" 的数量 +// c2 统计 ReasoningContent 里 "o" 的数量 + +if err != nil { + panic(err) +} +``` + +这个例子至少说明了三件事: + +- 字段映射到了流式场景,不需要换另一套写法 +- 静态值在流式场景仍然成立 +- 但静态值不保证是你收到的第一个 chunk,所以节点实现不能想当然 + +这就是为什么上面的 `wordCounter` 要先缓存字符串。 + +很多人第一次写 `Workflow` 流式逻辑,最容易犯的错就是默认“静态值先到、正文后到”。 +真实运行时没有这个保证。 + +### 6.3 这些运行边界,最好在动手前就知道 + +`Workflow` 很灵活,但边界也写得很明确: + +- 不支持环,所以你别拿它去硬凑 ReAct 那种 `chatmodel -> tools -> chatmodel` 的回路 +- `NodeTriggerMode` 固定为 `AllPredecessor` +- 因为没有环,`WithMaxRunSteps` 这类控制也没有意义 +- `WithNodeTriggerMode` 不支持自定义 + +这几条限制不是缺点。 +它们反而是在告诉你: + +**`Workflow` 适合复杂但可静态展开的编排,不适合靠回路驱动的 agent 结构。** + +## 7. Workflow 中,需要注意的五点 + +### 7.1 只把它当“字段映射器” + +如果你只记住了 `MapFields` 和 `ToField`, +很容易把 `Workflow` 用成一个“更好使的字段搬运工具”。 + +但它真正的价值不只在字段映射, +还在于控制流和数据流拆开以后,图的表达会清楚很多。 + +### 7.2 明明 `Chain / Graph` 已经够用,还是一上来就上 Workflow + +不是所有流程都值得用 `Workflow`。 + +如果你的主路径高度线性,节点间输入输出本来就对齐, +那 `Chain` 或普通 `Graph` 更省脑子。 + +工具不该为了“高级”而用。 +该用的时候用,不该用时别硬上。 + +### 7.3 把 `AddDependency` 和 `WithNoDirectDependency` 混着写,却没想清控制路径 + +这是最容易把图写乱的地方。 + +- `AddDependency` 只建控制关系 +- `WithNoDirectDependency` 只是在已有控制路径上补一条纯数据依赖 + +如果你自己都说不清某个节点到底是谁控制执行、谁提供数据, +那十有八九这条图还没想明白。 + +### 7.4 多路 merge 往同一字段写 + +`Workflow` 支持多路汇聚,不等于支持无脑覆盖。 + +多个映射往同一个字段写,或者一边整体映射一边字段映射, +都属于冲突。 + +这类问题最好在设计阶段就避免, +不要等 `Compile` 报错再回头找。 + +### 7.5 流式场景默认认为静态值一定先到 + +这个坑很隐蔽。 + +很多人本地 demo 跑通后,会下意识把流式输入理解成“配置先来,正文后到”。 +但 `Workflow` 不承诺这个顺序。 + +所以一旦你的节点既要吃流,又要吃静态值, +最好自己考虑缓存、拼接和 `ErrNoValue` 这类处理逻辑。 + + + +## 8. 总结 + + +Workflow 解决的是“数据怎么精确喂到字段里”,Graph 解决的是“图怎么更通用地跑起来”;前者更细,后者边界更大。却又因为 Workflow 是无环 DAG,所以它不适合直接承载 ReAct 这种靠回路推进的主流程。 + +DAG :Directed Acyclic Graph +(有向无环图) + + +## 参考资料 + +1. [Workflow 编排框架](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/workflow_orchestration_framework/) +2. [Chain/Graph 编排介绍](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/chain_graph_introduction/) +3. [eino-examples/compose/workflow](https://github.com/cloudwego/eino-examples/tree/main/compose/workflow) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 编排篇:既然有了 Chain、Graph,为何还需要 Workflow](./02-既然有了Chain、Graph,为何还需要Workflow.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 编排篇:既然有了 Chain、Graph,为何还需要 Workflow](https://zhumo.blog.csdn.net/article/details/159583345) +- 官方文档:[Workflow 编排框架](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/workflow_orchestration_framework/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/03-\344\273\216\350\207\252\345\212\250\346\211\247\350\241\214\345\210\260\344\272\272\345\267\245\346\216\245\347\256\241\357\274\214\345\246\202\344\275\225\351\201\277\345\205\215Agent\344\270\200\346\212\212\346\242\255.md" "b/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/03-\344\273\216\350\207\252\345\212\250\346\211\247\350\241\214\345\210\260\344\272\272\345\267\245\346\216\245\347\256\241\357\274\214\345\246\202\344\275\225\351\201\277\345\205\215Agent\344\270\200\346\212\212\346\242\255.md" new file mode 100644 index 0000000..52ba72e --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/04-\347\274\226\346\216\222\350\277\233\351\230\266/03-\344\273\216\350\207\252\345\212\250\346\211\247\350\241\214\345\210\260\344\272\272\345\267\245\346\216\245\347\256\241\357\274\214\345\246\202\344\275\225\351\201\277\345\205\215Agent\344\270\200\346\212\212\346\242\255.md" @@ -0,0 +1,706 @@ +# AI 大模型落地系列|Eino 编排篇:从自动执行到人工接管,如何避免Agent一把梭 + +> GitHub 主文:[当前文章](./03-从自动执行到人工接管,如何避免Agent一把梭.md) +> CSDN 跳转:[AI 大模型落地系列|Eino 编排篇:从自动执行到人工接管,如何避免Agent一把梭](https://zhumo.blog.csdn.net/article/details/159642323) +> 官方文档:[Interrupt & CheckPoint 使用手册](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/checkpoint_interrupt/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:把 Interrupt、Resume、CheckPoint 和人工审批串成一套真正可治理的 Agent 执行策略。 +**适合谁看**:准备在真实业务里接入敏感工具、审批流或人工接管能力的工程师。 +**前置知识**:Tool 调用闭环、Chain / Graph 基础、Agent Runner 基础 +**对应 Demo**:[官方 ch07 示例(本仓后续补充审批流 demo)](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch07/main.go) + +**面试可讲点** +- 能解释 Interrupt/Resume 解决的是可治理执行,而不是简单暂停。 +- 能把 CheckPoint、审批策略、版本边界和失败恢复讲成一套生产方案。 + +--- +很多人前面学到 `Agent + Tool` 时,第一反应都很兴奋: + +终于不用自己手动串动作了,模型会判断、会选工具、会自动把活干掉。 + +但只要你把 `Agent` 真正接到 `execute`、`write_file`、`send_mail` 这类 `Tool` 上,问题立刻就变了。 + +这时你最该关心的已经不是“它能不能调起来”,而是“它准备做危险操作时,谁来审批,现场怎么保存,用户确认后又怎么恢复执行”。 + +也正因为这样,`Interrupt / Resume` 在工程里不是一个可有可无的交互点,而是一套人工接管机制。 + +如果说前几章解决的是“Agent 怎么跑起来”,那本章解决的就是另一个问题: + +> 当 Agent 已经有能力调用真实 Tool 时,系统怎样在关键动作前停下来,等人确认,再从原地继续往下走。 +> 以及,Eino 又是如何把“中断、审批、恢复、持久化”这一整套链路收进同一个运行时里的。 + +## 1. 为什么敏感 Tool 不能默认全自动 + +前几章里的 Tool 调用,很多人都会默认理解成一句话: + +模型判断需要什么,就直接去调用什么。 + +这个思路放在 demo 里当然成立。 + +可一旦 Tool 不再只是“查天气”“读文档”,而是开始触碰真实环境,自动执行的风险会陡增: + +- `execute` 可能执行 shell 命令 +- `write_file` 可能覆盖配置 +- `send_mail` 可能把错误内容发给真实用户 +- 某些数据库 Tool 可能直接修改生产数据 + +很多人一开始会觉得,这不就是给 Tool 加个确认框吗? + +实际上,你要解决的不只是“要不要弹个确认框”,而是下面四件事得一起成立: + +- Tool 在危险动作前必须真的停下来,而不是只在 UI 上做个提示 +- 中断时要把这次调用的上下文保存住,不能确认完以后参数丢了 +- 拒绝和批准都要有确定结果,不能让 Runner 卡在半路 +- 进程重启、会话切换甚至跨机器恢复时,仍然要知道上次停在什么地方 + +这就是 `Interrupt / Resume` 存在的背景。 + +它关心的是人机协作时的执行控制权。 + +你可以把它理解成: + +- 自动执行像自动驾驶 +- `Interrupt` 像人工接管 + +系统当然还是能自己跑。 +但到了高风险动作,方向盘必须重新回到人手里。 + +## 2. 哪些 Tool 应该审批,哪些可以白名单放行 + +把风险说清以后,工程上第一个要落地的判断,不是先选 API,而是先划边界。 + +因为没有任何一个团队,会真的愿意给所有 Tool 都弹审批。 + +那样系统会慢得不可用。 + +### 2.1 必须审批的,是那些会对外部世界产生真实副作用的 Tool + +这类 Tool 最典型: + +- 执行命令 +- 写文件或删文件 +- 改数据库数据 +- 调用外部系统发消息、发邮件、下工单 +- 修改云资源、网络策略、系统配置 + +这些动作一旦执行,就不再只是“推理结果”,而是“现实世界里的变化”。 + +这类能力默认不该全自动。 + +### 2.2 可以白名单放行的,是强约束下的只读或低风险 Tool + +比如: + +- 只读查询 +- 本地纯计算 +- 固定范围内的格式化转换 +- 已经被沙箱限制死权限的安全工具 + +即便如此,我也不建议直接放飞。 + +更稳妥的做法通常是: + +- 白名单按 Tool 类型放 +- 黑名单按参数特征拦 +- 动态规则按操作范围升级审批 + +举个很实际的例子: + +- `execute("ls")` 和 `execute("rm -rf")` 不该一视同仁 +- `write_file` 写临时目录和写核心配置目录,也不该走同一条策略 + +这时候,`approvalMiddleware` 的价值就会体现出来。 + +因为它天然适合做这类“按 Tool 名 + 按参数内容”的集中治理。 + +## 3. Interrupt/Resume 到底在解决什么问题 + +把边界划清之后,再看机制本身,就更容易理解它为什么一定要成对出现。 + +`Interrupt / Resume` 解决的,不是交互花活,而是 Tool 的两阶段执行。 + +如果只看名字,很多人会把 `Interrupt / Resume` 想成“暂停一下,再继续”。 + +这话不算错,但只是表皮。 + +在审批流里,它更准确的意思其实是: + +**把一次 Tool 调用拆成两次进入。** + +第一次进入,不真的执行业务动作,只负责两件事: + +- 把当前输入和必要状态保存下来 +- 发出一个中断信号,让 Runner 停住并把审批信息交还给外部 + +第二次进入,也就是用户批准后的 `Resume`,才会去读回之前保存的状态,再决定真正执行还是拒绝返回。 + +流程图:**第一次调用 -> 中断 -> 审批 -> 恢复 -> 执行** + +```go +func myTool(ctx context.Context, args string) (string, error) { + // 看当前是不是“上次中断后恢复执行” + // storedArgs 是中断时保存下来的参数 + wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx) + + // 如果是第一次执行,就先发起审批并中断,不继续往下执行 + if !wasInterrupted { + return "", tool.StatefulInterrupt(ctx, approvalInfo(args), args) + } + + // 如果已经是恢复执行,就读取恢复时附带的审批结果 + isTarget, hasData, result := tool.GetResumeContext[*ApprovalResult](ctx) + + // 只有审批结果属于当前中断点、并且审批通过,才真正执行危险操作 + if isTarget && hasData && result.Approved { + return doDangerousThing(storedArgs) + } + + // 审批没通过,或者恢复数据不对,就拒绝执行 + return "operation rejected", nil +} +``` + +这个代码案例,是为了展示其背后的职责切分: + +- `tool.StatefulInterrupt` 负责“抛出中断,同时把本地状态保存” +- `tool.GetInterruptState[T]` 负责“恢复后拿回上次保存的状态,并判断这是不是第二次进入” +- `tool.GetResumeContext[T]` 负责“读取这次 Resume 是否就是冲着当前中断点来的,以及用户到底给了什么恢复数据” + +一旦你把这三件事看懂了,后面无论是 Tool 审批、参数补全、用户二次确认,思路都一样。 + +## 4. 官方 `execute` 示例:一次审批流是怎么跑完的 + +官方放到 GitHub 上的案例是 `cmd/ch07/main.go` [\[代码\]](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch07/main.go)。 + +它演示的不是复杂 Agent,而是一个非常有代表性的最小闭环: + +- 用户输入一句自然语言 +- Agent 判断需要调用 `execute` +- 中间件拦截这次 Tool 调用 +- Runner 收到 `Interrupt` 后暂停 +- 用户输入 `y/n` +- 系统恢复执行,继续跑完这轮 + +控制台输出大概是这样: + +```text +you> 请执行命令 echo hello + +⚠️ Approval Required ⚠️ +Tool: execute +Arguments: {"command":"echo hello"} + +Approve this action? (y/n): y +[tool result] hello + +hello +``` + +很多人第一次看这个示例时,会把关注点放在“怎么把 `y/n` 读出来”。 + +但更值得盯住的,是这条执行链到底断在哪里、又是从哪里接回去的。 + +```text +┌────────────────────────────────────────────┐ +│ 用户输入:请执行命令 echo hello │ +└────────────────────────────────────────────┘ + ↓ + ┌──────────────────────────┐ + │ Agent 决定调用 execute │ + └──────────────────────────┘ + ↓ + ┌──────────────────────────┐ + │ approvalMiddleware 拦截 │ + └──────────────────────────┘ + ↓ + ┌──────────────────────────┐ + │ 抛出 Interrupt │ + │ 保存 CheckPoint │ + └──────────────────────────┘ + ↓ + ┌──────────────────────────┐ + │ Runner 结束当前执行 │ + │ 把审批信息返回调用侧 │ + └──────────────────────────┘ + ↓ + ┌──────────────────────────┐ + │ 用户确认或拒绝 │ + └──────────────────────────┘ + ↓ + ┌──────────────────────────┐ + │ Resume 后重新进入 Tool │ + └──────────────────────────┘ + ↓ + ┌──────────────────────────┐ + │ 真正执行 execute 或拒绝 │ + └──────────────────────────┘ +``` + +也就是说,`Interrupt` 不是在 Tool 旁边挂一个提示层。 +它是真的把**这次调用变成了一个可恢复的执行断点**。 + +因为只有这样,审批才不是“前端交互”,而是“运行时协议”。 + +### 4.1 谁在主导这次中断 + +很多人会直觉地认为,暂停和恢复一定是 Runner 主导的。 + +实际上,在 Eino 这套机制里,先举手说“我这里要停一下”的,通常是**节点自己**,或者像 Tool middleware 这种包在节点外面的拦截层。 + +下面这段代码,就是官方示例里最关键的那一层: + +> 第一次进来先中断等审批,第二次恢复进来再看审批结果,批准才调用真正的 `execute`。 + +```go +func (m *approvalMiddleware) WrapInvokableToolCall( + _ context.Context, + endpoint adk.InvokableToolCallEndpoint, + tCtx *adk.ToolContext, +) (adk.InvokableToolCallEndpoint, error) { + // 只拦截 execute 这个工具; + // 其他工具不做审批,直接放行 + if tCtx.Name != "execute" { + return endpoint, nil + } + + // 返回一个“包了一层审批逻辑”的新 tool 调用函数 + return func(ctx context.Context, args string, opts ...tool.Option) (string, error) { + // 看这次调用是不是“中断后恢复执行” + // storedArgs 是上次中断时保存下来的原始参数 + wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx) + + // 第一次执行时,不真正调用 execute, + // 而是先抛出中断,请求外部审批,并把 args 保存起来 + if !wasInterrupted { + return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{ + ToolName: tCtx.Name, + ArgumentsInJSON: args, + }, args) + } + + // 恢复执行后,读取外部传回来的审批结果 + isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx) + + // 只有当前恢复数据确实属于这个中断点, + // 并且拿到了审批结果、且审批通过,才真正执行原始 execute + if isTarget && hasData && data.Approved { + return endpoint(ctx, storedArgs, opts...) + } + + // 审批没通过,就不执行工具,直接返回拒绝结果 + return fmt.Sprintf("tool '%s' disapproved", tCtx.Name), nil + }, nil +} +``` + +这段代码把审批流里最关键的职责分工都摆出来了。 + +### 4.2 `tool.StatefulInterrupt` + +它做的不是“报个错”,而是“挂起并存档”。 + +很多人第一次看会觉得,中断不就是返回一个特殊错误吗? + +从调用面上看,确实像。 +但它真正做的事情比普通错误大得多: + +- 向上层发出“这里要中断”的明确信号 +- 挂上展示给用户看的 `info` +- 顺手把 `state` 一起持久化,供下一次 `Resume` 取回 + +也正因为这样,`StatefulInterrupt` 很适合放那些“当前输入就是恢复时关键证据”的场景。 + +审批流就是典型例子。 + +你第一次拦下来的 `args`,就是第二次真正要执行的 `storedArgs`。 + +### 4.3 `tool.GetInterruptState[T]` + +`tool.GetInterruptState[T]` 解决的是“我现在到底是第一次进,还是恢复后第二次进”。 + +如果没有这个 API,开发者就得自己做一套状态管理: + +- 是不是中断过 +- 中断时保存了什么 +- 从哪儿取回来 + +这套事如果全让业务自己管,最后很容易变成一堆零散布尔值和自定义上下文。 + +### 4.4 `tool.GetResumeContext[T]` + +`tool.GetResumeContext[T]` 解决的是“这次恢复是不是找我,以及用户到底给了什么”。 + +这点也很关键。 + +真实系统里,不一定每次恢复都只对应一个唯一中断点。 +尤其到了嵌套图、并行中断、多 Tool 协作时,“恢复谁”本身就已经是个问题。 + +所以 `GetResumeContext[T]` 给了三层判断: + +- 这次 `Resume` 的目标是不是当前中断点 +- 恢复时有没有带数据 +- 数据是不是当前 Tool 预期的类型 + +它的作用,就是把恢复这件事从“继续跑”收紧成“有目标地恢复某个断点”。 + +## 5. 审批为什么适合放在 `middleware`,而不是写死在 Tool 里 + +看到这里,很自然会冒出一个问题: + +我把审批直接写进 `execute` Tool 里不就行了?为什么还要放到中间件中。 + +因为审批从来都不是某个单点业务逻辑。 +它更像一层横切治理规则。 + +你真正想控制的通常是: + +- 哪些 Tool 需要审批 +- 哪些参数命中高风险时才审批 +- 同步和流式调用是不是一套策略 +- 后面新增 Tool 时,规则能不能统一复用 + +这也是为什么官方示例把它放到了 middleware,而不是塞进 Tool 本体里。 + +中间件的好处很直接: + +- Tool 本身仍只负责“真正做事” +- 审批规则集中在一处配置 +- 新增或调整策略时,不需要改每个 Tool 的业务实现 + +如果用中间件做治理时,有一点非常值得注意: + +> 在 Agent 里做治理,**不能**只盯“业务成功路径”,还得把**同步、流式、恢复路径**一并考虑到。 +> 否则你只对同步做了拦截,选择流式路径时,拦截就可能失效。 + +只有把这些路径一起拦住,才是生产级治理。 + +## 6. CheckPoint 为什么不是“顺手存个参数” + +你可以把 `CheckPoint` 理解成断点存档。 + +没有 `CheckPoint`,就没有真正意义上的 `Resume`。 + +很多人会把 `CheckPointStore` 误解成一个“把审批参数存一下”的小缓存。 + +这个理解太窄了。 + +`CheckPoint` 在 Eino 里承担的是**运行现场持久化**。 + +在 ADK 的 Runner 视角里,至少有两件事必须同时成立: + +- `RunnerConfig` 里配置了 `CheckPointStore` +- 执行时传入了 `adk.WithCheckPointID(checkPointID)` + +只有这样,Runner 才知道: + +- 中断发生时要把现场保存到哪里 +- 之后恢复时该从哪个 key 把状态拉回来 + +官方给的典型代码配置如下: + +```go +runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, + CheckPointStore: adkstore.NewInMemoryStore(), +}) + +checkPointID := sessionID +events := runner.Run(ctx, history, adk.WithCheckPointID(checkPointID)) +``` + +它其实做了一件很重要的事: + +把“这次 Agent 运行”从一次临时调用,变成了一次**可恢复的会话执行**。 + +你会发现,`CheckPointStore` 不是普通缓存。为什么呢? + +缓存的思路通常是: + +- 丢了可以重算 +- 命中了算赚到 +- 不要求严格对应某次运行现场 + +但 `CheckPoint` 不是。 + +它保存的是这次运行“停在什么地方、手里拿着什么输入、下一步应该接到哪里”的现场信息。 + +按官方 `Agent Runner and Extension` 文档的说法,Runner 捕获到 `Interrupted Action` 后,如果同时配置了 `CheckPointStore` 和 `CheckPointID`,会把**原始输入、会话历史以及 InterruptInfo 等运行状态**持久化下来,后续再通过恢复接口继续执行。 + +所以你最好把 `CheckPointStore` 理解成: + +**恢复协议的一部分,而不是缓存层的一个可选优化。** + +到这里,其实审批流在 Agent 层的闭环已经完整了:Tool 为什么不能直接执行、中断是谁发起的、状态怎么保存、为什么恢复时还能接着往下跑。 + +接下来再往下看一层:这套能力在框架分层里到底属于哪里。 + +## 7. 从审批示例回到底层机制:Agent 只是复用了编排层的中断恢复能力 + +如果只看到前面这个审批示例,很多人会自然以为:这是 ADK 在 Agent 层单独做出来的一套审批机制。 + +但官方 `Interrupt & CheckPoint` 手册往下再看一层就会发现,Agent 里的 `Interrupt/Resume` 只是上层用法,底下复用的是更通用的编排中断恢复能力。 + +```go +func Resume(ctx context.Context, interruptIDs ...string) context.Context +func ResumeWithData(ctx context.Context, interruptID string, data any) context.Context +func BatchResumeWithData(ctx context.Context, resumeData map[string]any) context.Context +``` + +这些 API 解决的,就是更底层、更通用的问题: + +- 是恢复所有中断点,还是只恢复某一个 +- 恢复时要不要带自定义数据 +- 并行中断时要不要批量恢复 + +而 Tool 审批流,本质上只是在这个框架上定义了自己的 `ApprovalInfo` 和 `ApprovalResult`。 + +你现在再回头看 `tool.GetResumeContext[T]` 就会更容易理解了: + +它不是“顺便取一下用户输入”。 +它是在一个更通用的恢复机制之上,帮当前 Tool 判断: + +- 这次 `Resume` 是不是发给我的 +- 发给我的数据是不是我能消费的那份数据 + +这个分层关系一旦看懂,后面再学 `Graph Tool`、`Workflow Agent`、嵌套图里的中断恢复,脑子会清楚很多。 + +## 8. 理解完主线后,再补几个生产里很容易踩到的边界 + +上面几节,已经足够支撑你理解“审批流为什么能成立”。 + +但如果你准备把这套能力接进真实业务,就不能只停在 Quick Start 那一层了。 + +下面这些点,不是主线机制本身,而是生产环境里非常容易被忽略的边界条件。 + +### 8.1 静态 Interrupt:不是所有暂停都要在节点内部自己抛 + +手册里专门给了静态断点能力。 + +也就是说,你可以在 `Compile` 图的时候,通过 `compose.WithInterruptBeforeNodes(...)` 和 `compose.WithInterruptAfterNodes(...)` 这类选项,声明某些节点执行前或执行后必须暂停。 + +这类能力适合什么场景? + +- 某个节点前必须等人工确认 +- 某个步骤后必须做外部审计 +- 某些链路希望在固定位置留下可恢复断点 + +它和 Tool 内部动态 `Interrupt` 的区别在于: + +- 静态 Interrupt 是编排层声明式控制 +- 动态 Interrupt 是节点运行时自己决定要不要停 + +两者不是互斥关系。 +一个偏治理,一个偏业务时机。 + +### 8.2 动态 Interrupt:`v0.7.0+` 之后,重点不是“重跑”,而是“带状态地中断” + +官方手册明确写了: + +- `v0.7.0` 之前,动态中断更像“节点返回特殊错误后 rerun” +- `v0.7.0` 及之后,新增了 `Interrupt`、`StatefulInterrupt`、`CompositeInterrupt` + +这个变化很关键。 + +它意味着新语义下的中断不再只是“等会再跑一遍”。 +而是: + +- 可以保留局部状态 +- 可以透出内部中断信号 +- 可以支持并行中断与更精细的恢复目标 + +所以如果你现在新接这块能力,最好直接按 `v0.7.0+` 之后的语义去理解,不要再把它想成旧式 rerun。 + +### 8.3 流式传输和 CheckPoint 放在一起时,别忘了拼接规则 + +这一点特别容易被忽略。 + +普通 `Invoke` 场景里,保存 checkpoint 还算直接。 +但流式场景下,运行中的输出是分块到来的。 + +这时如果你希望在流中断点也能恢复,就必须告诉框架: + +**多个 chunk 最终怎么拼成一个可持久化的整体。** + +手册里给了专门的注册方法 `RegisterStreamChunkConcatFunc[T any](fn func([]T) (T, error))`。 + +默认情况下,Eino 已经给 `string`、`*schema.Message` 这些内置常见类型准备了 concat 逻辑。 +但如果你自己定义了流 chunk 结构,不补这层,checkpoint 这件事就不完整。 + +### 8.4 嵌套图里的 Interrupt/Resume,不只是“子图也能停一下” + +很多系统的复杂度,最终都不在单图里,而在嵌套图里。 + +比如: + +- 大图里挂一个子 Workflow +- 某个 Lambda 节点里再调一个独立 Graph +- Agent 里包着 FlowAgent,再包着 Tool 节点 + +这时中断恢复最难的地方已经不是“停不停”,而是: + +- 中断到底发生在第几层 +- 状态该保存到哪一层 +- `Resume` 到底要对准哪个中断点 + +官方手册和 `v0.7.*` 版本说明都在强调这一点。 + +所以你如果打算把审批流往复杂 Agent 里扩,最好从一开始就把“断点地址”和“恢复目标”当正式设计问题来看,而不是等出事了再补。 + +### 8.5 外部主动 Interrupt:它不是冷门能力,优雅退出时很实用 + +这也是很工程化的一条能力。 + +有时候中断不是节点自己想停,而是系统外部要求它先停下来。 + +典型场景就是: + +- 实例要优雅退出 +- 运维要求先挂起长链路 +- 某条执行流需要临时冻结等待外部资源 + +手册里提供了 `WithGraphInterrupt` 这套机制,让你在 Graph 外部主动触发 interrupt。 + +这类能力虽然不在 Quick Start 主线里,但它提醒了一个很重要的事实: + +`Interrupt / Resume` 不只是“审批专用功能”。 +它更像运行时的可暂停、可恢复协议。 + +审批只是它最容易理解、也最贴近业务价值的一种用法。 + +## 9. 真接进业务前,版本边界一定要单独确认 + +如果第 8 节讲的是能力边界,这一节讲的就是落地时最现实的一层:版本兼容。 + +只要 checkpoint 真要落盘、真要恢复,版本边界就不能轻描淡写。 + +### 9.1 `v0.3.26` 的问题,不是小 bug,而是 checkpoint 序列化 break + +截至 `2026-03-30`,官方 `Interrupt & CheckPoint 使用手册` 顶部仍然明确提示: + +`v0.3.26` 因为代码编写错误,导致 CheckPoint 的序列化内容产生 break。 + +官方建议也很直接: + +- 新接入 checkpoint 的业务,使用 `v0.3.26` 之后的版本 +- 更稳妥的做法是直接使用最新版本 +- 老业务如果版本低于 `v0.3.26`,可以先走官方兼容分支,等老数据淘汰后再回主干 + +这个提醒的分量很重。 + +因为它告诉你,checkpoint 不是无状态功能。 +一旦落盘,它就天然和版本兼容性绑在一起。 + +### 9.2 `v0.7.0` 的变化,是架构重构,不是小范围 API 调整 + +官方发布记录写得很明确: + +- `v0.7.0` 是 Interrupt-Resume 的架构级重构版本 +- 发布日期是 `2025-11-20` +- 新增 `GetInterruptState[T]`、`GetResumeContext` 这类类型安全恢复 API +- 支持隐式 `Resume All` 和显式 `Targeted Resume` +- Agent Tool 中断也改成了更标准的状态获取与组合中断处理方式 + +所以如果你现在写一篇面向新读者的文章,最稳妥的做法不是兼容讲两套,而是: + +**主线全部按 `v0.7.0+` 之后的语义写,旧版只作为版本坑提醒。** + +这样读者不容易把“历史做法”和“当前推荐做法”混到一起。 + +## 10. 最容易把审批流写坏的 6 个坑 + +写到这里,机制本身已经不难了。 + +真正容易漏掉的,往往是工程上那些看起来不起眼、但一漏就出事的细节。 + +### 10.1 只拦一个同步入口,忘了流式入口也会走 Tool + +这是 Quick Start 已经用代码提醒过你的坑。 + +如果你系统里 `execute` 可能走流式,而你只实现了 `WrapInvokableToolCall`,那审批就是有缺口的。 + +### 10.2 有 `Interrupt`,却没有稳定的 `CheckPointID` + +很多人本地 demo 能跑,就以为恢复链路已经成立了。 + +但如果 `CheckPointID` 是临时拼的、每轮都变,或者 `Resume` 时根本拿不到原来的 id,那恢复协议等于没闭环。 + +### 10.3 把 `CheckPointStore` 当普通缓存,随手改图结构和 CallOption + +官方手册专门提醒过: + +恢复只能复原输入和运行时节点数据,前提是**Graph 编排完全相同**,并且 `CallOption` 也要完整保持一致。 + +所以如果你一边保存 checkpoint,一边随手改编排图、改序列化方式、改恢复所需的 option,恢复失败一点都不奇怪。 + +### 10.4 Tool 审批写进每个 Tool 里,最后策略散落一地 + +这种写法最开始会显得“实现最快”。 + +但只要 Tool 数量上来,你就会发现审批策略根本没法统一治理。 + +中间件存在的意义,就是把这种横切规则从业务动作里剥出来。 + +### 10.5 自定义类型一上 checkpoint,却没想过序列化注册 + +手册已经写得很清楚: + +- 简单类型或 Eino 内置类型一般不用额外处理 +- 自定义结构体进入 checkpoint 时,最好提前注册稳定名字 + +这本质上是可恢复系统常见的序列化边界,不是 Eino 特有问题。 + +### 10.6 拒绝分支只想着“返回一句话”,没把治理结果当正式输出 + +审批被拒绝,不等于这轮执行“什么都没发生”。 + +至少从系统层面看,这仍然是一次完整决策: + +- 谁申请了什么动作 +- 谁拒绝了 +- 为什么拒绝 +- 这轮 Agent 最终该怎么收口 + +如果你后面要接审计、风控、工单系统,这些信息都不是可有可无的。 + +## 11. 总结 + +很多人一开始学 `Interrupt / Resume`,会把它看成 Agent 的一个附属能力。 + +但真走到生产环境,你会发现它的重要性一点都不比 `Tool Calling` 低。 + +因为 Agent 越有行动能力,你就越不能把所有控制权都交给它。 + +`Interrupt` 解决的是“该停时能不能真的停住”。 +`Resume` 解决的是“确认以后能不能从原地接着跑”。 +`CheckPoint` 解决的是“停住以后,现场能不能被可靠地保存和恢复”。 + +而 `approvalMiddleware` 则把这套东西从单个 Tool 的临时写法,提升成了整条 Agent 链路的治理策略。 + +所以如果你现在问我: + +> 第七章最值得带走的,到底是哪句话? + +我的答案会是: + +**Agent 一旦能调真实 Tool,审批就不再是前端交互,而是运行时协议;而 Interrupt / Resume + CheckPoint,就是这套协议在 Eino 里的落地方式。** + +## 参考资料 + +1. [第七章:Interrupt/Resume(中断与恢复)](https://www.cloudwego.io/zh/docs/eino/quick_start/chapter_07_interrupt_resume/) +2. [Interrupt & CheckPoint 使用手册](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/checkpoint_interrupt/) +3. [Eino ADK: Agent Runner and Extension](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_extension/) +4. [v0.7.*-interrupt resume refactor](https://www.cloudwego.io/zh/docs/eino/release_notes_and_migration/eino_v0.7._-interrupt_resume_refactor/) +5. [eino-examples/quickstart/chatwitheino](https://github.com/cloudwego/eino-examples/tree/main/quickstart/chatwitheino) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino 编排篇:从自动执行到人工接管,如何避免Agent一把梭](./03-从自动执行到人工接管,如何避免Agent一把梭.md) +- CSDN 跳转:[AI 大模型落地系列|Eino 编排篇:从自动执行到人工接管,如何避免Agent一把梭](https://zhumo.blog.csdn.net/article/details/159642323) +- 官方文档:[Interrupt & CheckPoint 使用手册](https://www.cloudwego.io/zh/docs/eino/core_modules/chain_and_graph_orchestration/checkpoint_interrupt/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/01-\344\273\200\344\271\210\346\230\257EinoADK\357\274\237.md" "b/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/01-\344\273\200\344\271\210\346\230\257EinoADK\357\274\237.md" new file mode 100644 index 0000000..7b75e32 --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/01-\344\273\200\344\271\210\346\230\257EinoADK\357\274\237.md" @@ -0,0 +1,698 @@ +# AI 大模型落地系列|Eino ADK体系篇:什么是 Eino ADK? + +> GitHub 主文:[当前文章](./01-什么是EinoADK?.md) +> CSDN 跳转:[AI 大模型落地系列|Eino ADK体系篇:什么是 Eino ADK?](https://zhumo.blog.csdn.net/article/details/159656025) +> 官方文档:[Eino ADK 概述](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_preview/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从概念总览切入,把 ADK 放回 Agent 开发套件而不是单个接口的层级来理解。 +**适合谁看**:已经接触过基础组件,准备系统进入 Agent 开发体系的读者。 +**前置知识**:Chain / Graph 基础、ChatModelAgent、Runner 基础 +**对应 Demo**:[官方 ADK 示例(本仓后续补充同主题 demo)](https://github.com/cloudwego/eino-examples/tree/main/adk) + +**面试可讲点** +- 能说明 ADK 不是某个 Agent 实现,而是一整套 Agent 开发与扩展能力集合。 +- 能把 ADK 中的 Agent 抽象、协作、Runner、扩展点串成体系。 + +--- +> **ADK是“构建单 Agent 和多 Agent 系统的一整套框架”** + +如果之前你看过 Eino ADK 官方文档,就会有一种很真实的感觉: + +名词我都见过。 + +`ChatModelAgent`、`Workflow Agents`、`Supervisor`、`Plan-Execute`、`Agent Runner`,这些词单独看都不陌生。 + +可如果现在关掉浏览器,自己讲一遍: + +- `Eino ADK` 到底是什么 +- `Agent` 为什么是它的核心抽象 +- 几类 Agent 到底是什么关系 +- 多 Agent 协作到底在协作什么 +- 我第一次真正上手,应该从哪里开始 + +估计很多人还是很蒙的。 + + +所以本篇文章,将会围绕以下 6点 讲清楚: + +1. `Eino ADK` 是什么 +2. `Agent` 是什么 +3. `ChatModel Agent / Workflow Agents / Custom Agent / Built-in Multi-Agent` 分别是什么 +4. 多 Agent 协作到底在协作什么 +5. 第一个最小可运行入口应该怎么搭 +6. 看完这篇以后,下一步该读什么 + + +## 1. 为啥初学者会觉得 ADK 很高深 + + Eino ADK 天然就横跨了几层不同的问题: + +- 有一层在回答:什么叫 Agent +- 有一层在回答:多个 Agent 怎么协作 +- 有一层在回答:哪些模式是开箱即用的 +- 还有一层在回答:Runner 怎么把 Agent 真的跑起来 + +如果你第一次接触时,直接把这些内容混在一起看,脑子里就很容易形成一种误解: + +“`ChatModelAgent`、`SequentialAgent`、`Supervisor`、`Plan-Execute` 不都是 Agent 吗?那我是不是直接挑一个最厉害的就行了?” + +问题就在这里。 + +它们都和 Agent 有关。 +但它们不是同一层的概念。 + +有的是**实现一种 Agent**。 +有的是**组合多个 Agent**。 +有的是**把多个基础能力封装成成熟范式**。 +还有的是**负责运行 Agent 的执行器**。 + +所以本篇文章,将会带你建立对整个体系的基础认知,并带给你一个基础小demo。 + +这样你后面再看 `Workflow Agents`、`Supervisor`、`Plan-Execute`,就不会觉得自己手足无措。 + +## 2. 什么是 Eino ADK + +先给一句最短的定义。 + +> `Eino ADK` 是 Eino 提供的 Go 语言 Agent / Multi-Agent 开发框架。 + +它参考了 Google-ADK 的设计,但不是简单照搬概念。 +它真正想解决的是: + +> 当你开始写 Agent,而且不止一个 Agent 时,如何把“运行、协作、上下文、治理”这些问题统一起来。 + +如果只说“它是一个 Agent 框架”,其实还不够全面。 + +更准确一点,你可以把它理解成: + +- 它给你一个统一的 Agent 抽象 +- 它给你多 Agent 协作时的通用原语 +- 它给你几种开箱即用的协作范式 +- 它还给你运行时能力,比如 `Runner`、中断恢复、切面能力等 + +官方在概述页里强调的几个关键词,其实非常关键: + +- 上下文传递 +- 事件流分发与转换 +- 任务控制权转让 +- 中断与恢复 +- 通用切面 + +这几个词合在一起看,你就会发现: + +`ADK` 不是“给模型外面再包一层壳”。 + +它更像是一个 **Agent 运行时和协作框架**。 + +这点后端开发者应该可以更敏感的感受到: + +- Eino 的 Components 层更像“零件层” +- ADK 更像“让这些零件长成会运行、会协作、可治理的智能体系统” + +所以它适合的就不只是“能聊天的 Agent”。 + +还包括: + +- 对话型智能体 +- 非对话型智能体 +- 多步骤任务型智能体 +- 工作流式智能体 +- Multi-Agent 协同系统 + +官方概述页里给了一个总览图,先看这个图,你会更容易理解 ADK 到底在体系里的哪个位置: + +![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://i-blog.csdnimg.cn/direct/a391587d4bc14fc18ce2d54c17086f6f.png) + + +> 图源: 这是我从 CloudWeGo 官方文档扒拉出来的。 + +先别急着把图里每个词都吃透。 + +你只要先抓住一个核心结论: + +> ADK 的目标,不是让你多学几个模式名。 +> 而是让你围绕 Agent 抽象,把单体 Agent、多 Agent 协作和运行时能力串成一套完整开发方法。 + +## 3. 什么是 Agent,以及它为什么是 ADK 的核心抽象 + +官方给的定义很朴素且准确: + +> Agent 是一个独立的、可执行的智能任务单元。 + +大家可以把它先想象成一个“有明确身份、有明确职责、能被调起来执行的智能体”。 + +只要一个场景需要和大语言模型交互,它通常都可以被抽象成 Agent。 + +例如: + +- 一个查询天气的 Agent +- 一个安排会议的 Agent +- 一个回答特定领域知识的 Agent + +### 3.1 为什么 ADK 要先定义 `Agent` 接口 + +因为没有统一抽象,后面的协作、组合、Runner、Interrupt、Callback 都没法成立。 + +Eino ADK 把 Agent 的基础接口定义成这样: + +```go +type Agent interface { + Name(ctx context.Context) string + Description(ctx context.Context) string + Run(ctx context.Context, input *AgentInput, opts ...AgentRunOption) *AsyncIterator[*AgentEvent] +} +``` + +这 3 个方法里,最值得注意的是下面这层含义: + +- `Name`:这个 Agent 叫什么,它的身份标识是什么 +- `Description`:这个 Agent 会什么,其他 Agent 怎么判断要不要找它协作 +- `Run`:这个 Agent 怎么真正被运行起来 + +所以 Agent 不是 Prompt 的别名。 + +它至少同时包含了三件事: + +- 身份 +- 职责 +- 执行入口 + +### 3.2 三个基础方法:`AgentInput`、`AgentEvent`、`Runner` + +很多人第一次卡住,不是在 `Agent` 三个方法本身。 + +而是在这几个配套概念: + +| 名词 | 你可以先怎么理解 | +| --- | --- | +| `AgentInput` | 这次要交给 Agent 的任务材料。默认最重要的是 `Messages`,也就是消息、上下文、历史、背景数据。 | +| `AgentEvent` | Agent 运行过程中吐出来的事件。不是只返回最终字符串,而是把执行过程和结果按事件流交给调用方。 | +| `Runner` | Agent 的执行器。真正把 Agent 跑起来,并负责很多运行时能力。 | + +比如 `AgentInput` 的核心定义,官方抽象页里写得很直接: + +```go +type AgentInput struct { + Messages []Message + EnableStreaming bool +} +``` + +这说明一个很重要的事实: + +> Agent 的输入并不只是“一句话”。 +> 它更像一份任务上下文。 + +而 `AgentEvent` 为什么重要? + +因为 ADK 不是把 Agent 看成“同步返回一个字符串的函数”。 +它把 Agent 看成“会在运行中持续产生事件的对象”。 + +这也是为什么 `Run()` 的返回值不是 `string`,而是 `AsyncIterator[*AgentEvent]`。 + +### 3.3 `ChatModelAgent` 为何重要 + +在 ADK 里,`ChatModelAgent` 是最关键、也最适合作为第一站的 Agent 实现。 + +原因很简单: + +- 它直接封装了和大语言模型的交互逻辑 +- 它本身就是一个“会思考、会生成、能调用工具”的 Agent +- 你第一次上手,最容易从它开始建立直觉 + +可以先把它理解成: + +> 用 LLM 做“大脑”的 Agent 实现。 + +### 3.4 为什么 `Agent Runner` 不该被忽略 + +很多人会把注意力全放在 Agent 身上,然后忽略 `Runner`。 + +但官方文档里说得很清楚: + +> Runner 是 Eino ADK 中负责执行 Agent 的核心引擎。 +> 任何 Agent 都应通过 Runner 来运行。 + +而且只有通过 `Runner` 跑起来时,你才能真正用到: + +- 多 Agent 协作过程中的上下文管理 +- 中断与恢复 +- 切面机制 +- Context 环境预处理 + +所以第一次学 ADK 时,一定要先把握住一个关系: + +> `Agent` 是任务单元。 +> `Runner` 是执行器。 + +二者缺一不可。 + +## 4. ADK 的四类基础扩展与封装关系,到底该怎么理解 + +这部分是很多人最容易略过去,但其实最该慢下来看的一段。 + +因为它在告诉你: +> ADK 围绕 Agent 抽象,至少有四种不同层次的能力块。 + + +| 类别 | 你可以先把它理解成 | 核心 | 典型代表 | 更适合干什么 | +| --- | --- | --- | --- | --- | +| `ChatModel Agent` | 用 LLM 做大脑的 Agent | 推理、生成、工具调用 | `ChatModelAgent` | 单 Agent 推理、ReAct、动态决策 | +| `Workflow Agents` | 预先写好流程的 Agent 组合器 | 顺序 / 循环 / 并发 | `SequentialAgent`、`LoopAgent`、`ParallelAgent` | 结构化编排、固定流程 | +| `Custom Logic` | 你自己实现的 Agent | 自定义代码与定制逻辑 | `type MyAgent struct{}` | 官方预置能力不够时的定制需求 | +| `EinoBuiltInAgent` | 开箱即用的 Multi-Agent 范式封装 | 基于前几类能力做出的成熟模式 | `Supervisor`、`Plan-Execute` | 更复杂的协作与问题求解 | + +如果你非要把它再压缩成一句话,我会这么说: + +- `ChatModel Agent` 是会“想”的 Agent +- `Workflow Agents` 是会“排流程”的 Agent +- `Custom Agent` 是你自己“写”的 Agent +- `Built-in Agent` 是官方帮你“封装好范式”的 Agent + +官方 Quickstart 也给了一个分类关系图: + +![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://i-blog.csdnimg.cn/direct/fd3861fa306b4add9a6768c17ed130ba.png) + + +> 图源: 这是我从 CloudWeGo 官方文档扒拉出来的。 + +### 4.1 为什么这四类东西不能混着看 + +因为它们虽然都围绕 Agent 展开,但解决的问题根本不同。 + + + + +比如: + +- `ChatModelAgent` 关注的是“一个 Agent 如何自己思考和调用工具” +- `Sequential / Loop / Parallel` 关注的是“多个 Agent 如何按规则协作” +- `Supervisor / Plan-Execute` 关注的是“复杂协作范式怎么开箱即用” + +你如果把这些东西都当成“同一层菜单”,就很容易出现两种误解: + +- 明明只是要一个会思考的 Agent,却上来就找 `Plan-Execute` +- 明明只是固定三步流程,却非要套 `Supervisor` + + +提醒: + +> 本篇说的 `Workflow Agents`,是 ADK 里的 Agent 级协作抽象。 +> 它和 Eino Compose 层 `Chain / Graph / Workflow` 那套字段映射与节点编排,不是同一个层级。 +> (一个是多个Agent之间的协作,另一个是节点之间的编排) + +## 5. 多 Agent 协作到底在说什么 + +这也是 ADK 最容易被说得很玄、但其实完全可以讲明白的一部分。 + +很多人一听“多 Agent 协作”,脑子里就只有一句话: + +“几个 Agent 一起干活。” + +这当然没错。 + +但如果只停在这句话,后面你还是不知道 ADK 到底在设计什么。 + +这其实可以从三个维度来讲这件事: + +- 协作方式 +- 上下文策略 +- 决策自主性 + +### 5.1 协作方式: +`Transfer` 和 `AgentAsTool` + +先看最关键的一层。 + +ADK 里两个最基础的协作动作,可以先这么理解: + +| 协作方式 | 新手能先怎么理解 | 什么时候更像它 | +| --- | --- | --- | +| `Transfer` | 当前 Agent 把任务转交给另一个 Agent,自己退出当前这轮 | 像“任务移交” | +| `ToolCall(AgentAsTool)` | 当前 Agent 把另一个 Agent 当成工具调一下,拿到结果后自己继续 | 像“调用一个子能力” | + +这个区别非常重要。 + +因为很多人第一次上手时,会误把“找别的 Agent 帮忙”和“把任务完全转给别的 Agent”混成一回事。 + +### 5.2 上下文策略: +为什么多 Agent 不只是“把消息丢过去” + +官方文档里强调了两种核心上下文策略: + +| 上下文策略 | 新手理解 | +| --- | --- | +| 上游 Agent 全对话 | 当前 Agent 直接看到上游 Agent 的完整历史与事件结果 | +| 全新任务描述 | 不直接传完整历史,而是把上游结果压缩成一份新的任务摘要,再交给下游 Agent | + +如果你再把官方协作文档继续往下看,会看到 ADK 还把上下文传递拆成了两个核心机制: + + +- `History`(更像“运行过程中的对话与事件历史”) +- `SessionValues`(更像“跨 Agent 共享的结构化状态”) + +其中 `History` 对新手尤其重要,因为它解释了很多人第一次看多 Agent 时的一个疑问: + +> 为什么后一个 Agent 会“知道”前一个 Agent 干了什么? + +答案不是魔法。 + +而是: + +> 前面 Agent 产生的 `AgentEvent` 会进入 History,后面的 Agent 构造 `AgentInput` 时可以读到这些历史。 + +### 5.3 决策自主性: +谁在决定下一个 Agent 是谁 + +它本质上只是在区分两件事: + +| 决策方式 | 新手理解 | +| --- | --- | +| 自主决策 | Agent 自己决定要不要找谁协作 | +| 预设决策 | 开发者提前把执行顺序写死 | + +这个维度一旦加进来,你就更容易理解: + +- `ChatModelAgent` / `SubAgents` 往往更接近“自主决策” +- `Sequential / Parallel / Loop` 更接近“预设决策” + +### 5.4 把组合原语放在一起看,就清楚多了 + +下面这张表,是我按照官方协作文档的结构,专门给新手重写的一版: + +| 组合原语 | 你可以先怎么理解 | 协作方式 | 上下文 | 决策方式 | +| --- | --- | --- | --- | --- | +| `SubAgents` | 父 Agent 带一组子 Agent,自主决定是否移交任务 | `Transfer` | 上游 Agent 全对话 | 自主决策 | +| `Sequential` | 多个 Agent 按顺序一个接一个执行 | `Transfer` | 上游 Agent 全对话 | 预设决策 | +| `Parallel` | 多个 Agent 基于同一输入并发执行 | `Transfer` | 上游 Agent 全对话 | 预设决策 | +| `Loop` | 一组 Agent 按顺序循环执行 | `Transfer` | 上游 Agent 全对话 | 预设决策 | +| `AgentAsTool` | 把一个 Agent 转成 Tool 给别的 Agent 调用 | `ToolCall` | 全新任务描述 | 自主决策 | + +写这张表的目的,不只是帮大家记住概念,更是想建立一个简单的判断: + +> 你到底是在做“任务移交”,还是在做“能力调用”? +> 你到底要的是“自主路由”,还是“预设流程”? + +只要这两个问题能回答清楚,你后面再看 `Workflow Agents`、`Supervisor`、`Plan-Execute`,理解速度会快非常多。 + +## 6. `ADK Examples` 案例 + +官方 Quickstart 里给了很多 examples。 + +很多人第一时间,会把这些例子当成“代码仓库目录”。 + +其实更有效的看法是: + +> 每个 example 都是在帮使用者建立一种 Agent 模式的直觉。 + +所以之下的案例,将会带你明白:**每个例子你到底该学什么。** + +| 示例 | 你该从它身上学到什么 | 第一次学时的建议 | +| --- | --- | --- | +| `intro/workflow/sequential` | 看清楚顺序接力:一个 Agent 的结果如何成为下一个 Agent 的输入背景 | 最先看,最好 | +| `intro/workflow/loop` | 看清楚反思迭代:为什么“写完再批判再改”天然适合 Loop | 第二个看 | +| `intro/workflow/parallel` | 看清楚并行协作:几个独立分析任务如何同时运行 | 第三个看 | +| `multiagent/supervisor` | 看清楚中心调度:一个总控 Agent 如何挑选专家 Agent | 前三个看懂后再看 | +| `multiagent/layered-supervisor` | 看清楚层级协作:为什么复杂任务会出现多层监督者 | 放在 supervisor 后面看 | +| `multiagent/plan-execute-replan` | 看清楚“计划 - 执行 - 重规划”的长任务闭环 | 先别急着实操,先理解结构 | +| `intro/chatmodel`(书籍推荐) | 看清楚中断恢复、Checkpoint、runner.Query / Resume 的配合 | 当你开始关心运行时治理时再看 | + +### 6.1 如果你是第一次学,我建议这样看 examples + +第一次上手,不要把 examples 全部平铺打开。 + +更稳的顺序是: + +1. 先看 `sequential` +2. 再看 `loop` +3. 再看 `parallel` +4. 然后再看 `supervisor` +5. 最后再去理解 `plan-execute-replan` + +这个顺序的本质不是“由简单到复杂”这么空泛。 + +而是: + +- 先建立 `Workflow Agents` 的直觉 +- 再看更高级的 Multi-Agent 协作范式 + +如果这条顺序不安排好,很多人第一次看 `Supervisor` 或 `Plan-Execute`,会直接觉得: + +“这不就是又包了一层 Agent 吗?” + +但其实它们都是在前面基础之上更高层的封装。 + +## 7. 最小 runnable 入口: +先跑通你的第一个 `ChatModelAgent + Runner` + +已讲完基础体系,终于该回到“第一段代码”了。 + +注意,这里我故意不直接上 `Sequential`、`Loop` 或 `Supervisor`。是因为: + +> 你第一次要跑通的,不是 Multi-Agent。 +> 而是 ADK 里最小、最完整的执行骨架。 + +也就是: + +`ChatModel -> ChatModelAgent -> Runner` + +![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://i-blog.csdnimg.cn/direct/2754a342e3a544498bed7294de4bd71f.png) + + +### 7.1 安装依赖 + +```bash +mkdir eino-adk-first-card +cd eino-adk-first-card + +go mod init eino-adk-first-card +go get github.com/cloudwego/eino@latest +go get github.com/cloudwego/eino-ext/components/model/qwen@latest +``` + +### 7.2 配环境变量 + +如果你在 macOS / Linux: + +```bash +export DASHSCOPE_API_KEY="你的百炼 API Key" +export QWEN_MODEL="qwen3.5-flash" +``` + +如果你在 Windows PowerShell: + +```powershell +$env:DASHSCOPE_API_KEY="你的百炼 API Key" +$env:QWEN_MODEL="qwen3.5-flash" +``` + +### 7.3 第一份完整代码 + +把下面代码保存成 `main.go`: + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" + "github.com/cloudwego/eino-ext/components/model/qwen" +) + +func main() { + ctx := context.Background() + + // 默认问题;也支持从命令行覆盖,方便本地调试与演示。 + query := "请用新手能看懂的话,解释一下什么是 Eino ADK。" + if len(os.Args) > 1 { + query = strings.Join(os.Args[1:], " ") + } + + // 初始化底层大模型客户端。 + // 这里使用阿里百炼兼容接口,通过环境变量读取密钥与模型名,避免硬编码敏感信息。 + cm, err := qwen.NewChatModel(ctx, &qwen.ChatModelConfig{ + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKey: mustEnv("DASHSCOPE_API_KEY"), + Model: envOrDefault("QWEN_MODEL", "qwen3.5-flash"), + }) + if err != nil { + log.Fatalf("new qwen chat model failed: %v", err) + } + + // 将底层模型封装为一个可执行的 Agent。 + // Name / Description 用于标识与协作;Instruction 用于约束该 Agent 的行为风格。 + agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "ADKIntroAgent", + Description: "负责向新手解释 Eino ADK 的基础概念", + Instruction: "你是一个面向 Go 新手的 Eino ADK 讲解助手。先给一句结论,再给三点解释,控制在 300 字以内。", + Model: cm, + }) + if err != nil { + log.Fatalf("new chat model agent failed: %v", err) + } + + // Runner 是 Agent 的统一执行入口。 + // 这里关闭流式输出,改为按事件迭代读取完整结果。 + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: false, + }) + + fmt.Printf("user> %s\n\n", query) + + // 发起一次查询,并消费 Agent 返回的事件流。 + if err := printAssistantOutputs(runner.Query(ctx, query)); err != nil { + log.Fatalf("run agent failed: %v", err) + } +} + +// printAssistantOutputs 负责从事件流中提取 assistant 消息并打印。 +// 这里只关心最终可读的消息内容,忽略中间无效事件或非 assistant 输出。 +func printAssistantOutputs(events *adk.AsyncIterator[*adk.AgentEvent]) error { + for { + event, ok := events.Next() + if !ok { + // 事件流结束,说明本次执行完成。 + return nil + } + + // 运行过程中的错误会挂在事件上,需要显式向上返回。 + if event.Err != nil { + return event.Err + } + + // 非消息类输出或空输出直接跳过。 + if event.Output == nil || event.Output.MessageOutput == nil { + continue + } + + mv := event.Output.MessageOutput + + // 只处理 assistant 角色的消息。 + // 某些场景下 Role 可能为空,这里一并兼容。 + if mv.Role != schema.Assistant && mv.Role != "" { + continue + } + if mv.Message == nil { + continue + } + + content := strings.TrimSpace(mv.Message.Content) + if content == "" { + continue + } + + fmt.Printf("assistant>\n%s\n", content) + } +} + +// mustEnv 读取必填环境变量;缺失时直接终止进程。 +// 适用于 API Key、数据库地址等启动必需配置。 +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("%s is empty", key) + } + return v +} + +// envOrDefault 读取可选环境变量;若未配置则回退到默认值。 +// 适用于模型名、超时、开关等可提供默认行为的配置项。 +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +``` + +### 7.4 运行 + +```bash +go run . -- "请解释一下为什么 Eino ADK 不只是几个 Agent 模式名" +``` + +你大概会看到这样一段输出: + +```text +user> 请解释一下为什么 Eino ADK 不只是几个 Agent 模式名 + +assistant> +... +``` + +### 7.5 这份最小代码,真正让你建立的是什么 + +第一次跑通时,你最该看见的不是“模型回复成功了”。 + +而是下面这条骨架终于成型了: + +1. `qwen.NewChatModel`:先有一个可调用的大模型 +2. `adk.NewChatModelAgent`:再把模型包成一个可执行的 Agent +3. `adk.NewRunner`:再交给 Runner 驱动执行 +4. `runner.Query(...)`:最后发起一次真正的 Agent 运行 + +也就是说,这段代码不是在教你“怎么问模型一个问题”。 + +它是在教你: + +> ADK 里第一个能跑起来的 Agent,到底是怎么被组装出来的。 + +## 8. What’s Next:这篇之后,你该怎么继续学 ADK + +你可以把这个当成 “ADK 后续学习树”。 + +![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://i-blog.csdnimg.cn/direct/edbe1493e1f64459818a704507111f85.png) + + +> 图源: 这是我从 CloudWeGo 官方文档扒拉出来的。 + +### 8.1 如果你是第一次学,我建议按这条顺序往下走 + +先看懂整体目录: + +1. `Quickstart` +2. `概述` +3. `Agent 抽象` +4. `Agent 协作` +5. `ChatModelAgent` +6. `Workflow Agents` +7. `Agent Runner 与扩展` + + +再往后,如果你要继续深入,再接着看: + +8. `Supervisor Agent` +9. `Plan-Execute Agent` +10. `Agent Callback` +11. `Interrupt / Resume / HITL` + + + + +## 参考资料 + +- [Eino ADK: 概述](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_preview/) +- [Eino ADK: Quickstart](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_quickstart/) +- [Eino ADK: Agent 抽象](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_interface/) +- [Eino ADK: Agent 协作](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_collaboration/) +- [Eino ADK: Agent Runner 与扩展](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_extension/) +- [Eino-examples/adk](https://github.com/cloudwego/eino-examples/tree/main/adk) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino ADK体系篇:什么是 Eino ADK?](./01-什么是EinoADK?.md) +- CSDN 跳转:[AI 大模型落地系列|Eino ADK体系篇:什么是 Eino ADK?](https://zhumo.blog.csdn.net/article/details/159656025) +- 官方文档:[Eino ADK 概述](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_preview/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/02-\344\270\272\344\273\200\344\271\210\344\270\200\345\256\232\350\246\201\346\234\211Agent\350\277\231\345\261\202\346\212\275\350\261\241.md" "b/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/02-\344\270\272\344\273\200\344\271\210\344\270\200\345\256\232\350\246\201\346\234\211Agent\350\277\231\345\261\202\346\212\275\350\261\241.md" new file mode 100644 index 0000000..000633d --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/02-\344\270\272\344\273\200\344\271\210\344\270\200\345\256\232\350\246\201\346\234\211Agent\350\277\231\345\261\202\346\212\275\350\261\241.md" @@ -0,0 +1,724 @@ +# AI 大模型落地系列|Eino ADK体系篇:为什么一定要有 Agent 这层抽象 + +> GitHub 主文:[当前文章](./02-为什么一定要有Agent这层抽象.md) +> CSDN 跳转:[AI 大模型落地系列|Eino ADK体系篇:为什么一定要有 Agent 这层抽象](https://zhumo.blog.csdn.net/article/details/159690023) +> 官方文档:[Eino ADK:Agent 抽象](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_interface/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从接口、输入输出、事件流和自定义实现理解 Agent 抽象为什么不是 Prompt 包装器。 +**适合谁看**:准备真正理解 Agent 协议,而不是只会调用现成 Agent 的 Go 工程师。 +**前置知识**:什么是 Eino ADK、ChatModelAgent、Runner 基础、Go 接口与泛型基础 +**对应 Demo**:[官方 Agent 接口文档与本地自定义 Agent 实战](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_interface/) + +**面试可讲点** +- 能解释 Name、Description、Run、AgentEvent、AsyncIterator 这些抽象为什么缺一不可。 +- 能说明 Agent 输入为什么是 Messages 而不是单个字符串。 + +--- +本篇只讲一点 +> 为什么 ADK 一定要单独定义 `Agent` 这层抽象? + +很多人真正没看懂的,不是 `Name`、`Description`、`Run`,而是这套协议到底统一了什么。 + +本文只做四件事: + +1. 讲清 `Agent` 有什么用,为什么它不是 Prompt 包装器 +2. 讲透 `AgentInput / AgentRunOption / AsyncIterator / AgentEvent` +3. 给一个 **零外部依赖** 的自定义 Agent demo +4. 帮你把后面 `Workflow / Runner / Interrupt` 的地基先打好 + +## 1. 为什么 `Agent` 抽象是必要的 + +如果没有 `Agent` 这一层,AI 应用很容易长成一堆分散的模型调用: + +- 这里直接调 `ChatModel` +- 那里自己拼 `Messages` +- Tool 结果自己处理 +- 多 Agent 协作时,每一层都重新定义输入输出 +- 中断、恢复、链路追踪、状态注入散在业务代码里 + +但只要系统开始复杂一点,问题就来了: + +- 谁是这次执行单元的身份标识 +- 别的 Agent 怎么知道它能做什么 +- 调用方拿到的是最终字符串,还是过程事件 +- 某个请求级参数该影响谁 +- 这次是输出了消息,还是触发了跳转、中断、退出 + +所以 `Agent` 抽象真正解决的,不是“怎么调模型”。 + +它解决的是: + +> 怎么把一次智能体执行,统一成一个可运行、可组合、可治理的对象。 + +把这件事画开,就是下面这张最小协议图: + +![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://i-blog.csdnimg.cn/direct/20d3895cf5ca403089a69d4ecfb35a4a.png) + + +你只要先记住一个判断就够了: + +> `Agent` 不单独存在,而是和 `AgentInput`、`AgentRunOption`、`AsyncIterator`、`AgentEvent` 一起构成运行协议。 + +## 2. `Agent` 接口:为什么这三个方法都不能少 + +官方定义很短: + +```go +type Agent interface { + Name(ctx context.Context) string + Description(ctx context.Context) string + Run(ctx context.Context, input *AgentInput, opts ...AgentRunOption) *AsyncIterator[*AgentEvent] +} +``` + +### `Name` + +`Name` 不只是“取个名字”。 + +它至少承担三件事: + +- Agent 的身份标识 +- 执行链路里的节点名 +- `DesignateAgent(...)` 这类定向 option 的匹配目标 + +### `Description` + +`Description` 也不只是注释。 + +它更像对外公开的职责声明: + +- 给人看,知道这个 Agent 会什么 +- 给别的 Agent 看,判断该不该把任务转给它 + +### `Run` + +`Run` 才是核心。 + +```go +Run(ctx context.Context, input *AgentInput, opts ...AgentRunOption) *AsyncIterator[*AgentEvent] +``` + +这一个签名,直接把四件事统一了: + +1. 一次 Agent 执行必须带 `context.Context` +2. 输入统一走 `AgentInput` +3. 请求级调参统一走 `AgentRunOption` +4. 输出统一走事件流 `AsyncIterator[*AgentEvent]` + +所以 `Run` 不是普通函数。 + +它是在规定: + +> ADK 里的一次 Agent 执行,应该以什么协议被启动、被调整、被消费。 + +## 3. `AgentInput`:为什么输入是 `Messages`,不是一个字符串 + +官方定义: + +```go +type AgentInput struct { + Messages []Message + EnableStreaming bool +} + +type Message = *schema.Message +``` + +很多人第一次看到这里,会下意识理解成“用户问题 + 一个流式开关”。 + +这个理解太轻了。 + +### `Messages` 是任务上下文,不是单条 prompt + +`Messages` 里可以放的不只是用户这一句。 + +它可以承载: + +- 当前问题 +- 对话历史 +- 上游 Agent 结果 +- 背景知识 +- 样例数据 +- 系统约束 + +也就是说,`Messages` 的意义不是“聊天格式”。 + +它真正的价值是: + +> 把一次任务所需的上下文统一收紧。 + +如果输入只是一条 `string prompt`,那每个 Agent 都得自己决定历史怎么塞、系统约束怎么塞、Tool 结果怎么塞,输入协议就会发散。 + +### `EnableStreaming` 是建议,不是强制 + +这是一个特别容易踩的点。 + +很多人会误以为: + +- `EnableStreaming=true` 就一定流式 +- `EnableStreaming=false` 就一定非流式 + +但官方文档强调得很清楚,它只是一个 **建议**。 + +它只会影响那些“同时支持流和非流”的组件,比如 `ChatModel`。 +如果某个组件天然只支持一种输出方式,比如很多 Tool,它不会因为这个字段就突然变成流式。 + +看图最直观: +![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/1cfc04d21a224ed69c2fcaa8e967b329.png) + + +这句最好直接背下来: + +> `EnableStreaming` 控制的是偏好,不是强制转换器。 + +实际输出到底是不是流,请看后面的 `MessageVariant.IsStreaming`。 + +## 4. `AgentRunOption` 和 `AgentWithOptions`:看起来像一回事,其实不是 + +这两个概念容易混。 + +最简单的分法就一张表: + +| 能力 | 作用时机 | 你可以先怎么理解 | +| --- | --- | --- | +| `AgentRunOption` | 请求期 | 这一次运行怎么调 | +| `AgentWithOptions` | 运行前 | 这个 Agent 先被怎么包装 | + +### `AgentRunOption` + +它是传给 `Run()` 的: + +```go +Run(ctx context.Context, input *AgentInput, opts ...AgentRunOption) +``` + +官方内置给了两个很典型的通用 option: + +- `WithSessionValues`:设置跨 Agent 读写数据 +- `WithSkipTransferMessages`:某些 Transfer 消息不进入 History + +除此之外,ADK 还给了两个很实用的扩展点: + +```go +adk.WrapImplSpecificOptFn(...) +adk.GetImplSpecificOptions(...) +``` + +这套设计的价值很直接: + +> 每个 Agent 都可以扩展出自己的请求级参数,而不用把所有行为都塞进一套全局 option。 + +比如后面 demo 里的: + +```go +WithAudience("newbie") +WithAudience("interview") +``` + +它就能证明 `AgentRunOption` 真的是“这次运行怎么调”,而不是静态配置。 + +`DesignateAgent(...)` 则是更偏多 Agent 场景的能力: + +```go +opt := adk.WithSessionValues(map[string]any{}).DesignateAgent("agent_1", "agent_2") +``` + +它的真正作用就是:在多 Agent 系统里,只让指定名字的 Agent 看见这个 option。 + +### `AgentWithOptions` + +它是这样用的: + +```go +func AgentWithOptions(ctx context.Context, agent Agent, opts ...AgentOption) Agent +``` + +官方当前内置支持的两个点是: + +- `WithDisallowTransferToParent` +- `WithHistoryRewriter` + +它们都不属于“这一次运行怎么调”。 + +它们属于: + +> 在真正执行前,先把 Agent 包一层通用行为。 + +所以别把这两个层级混掉。 + +## 5. `AsyncIterator`:为什么 Agent 不直接返回字符串 + +官方定义: + +```go +type AsyncIterator[T any] struct { + ... +} + +func (ai *AsyncIterator[T]) Next() (T, bool) +``` + +ADK 这里的一个关键设计是: + +> Agent 不是“输入一个值,输出一个值”的普通函数。 + +一次 Agent 执行,除了最终文本,还可能产生: + +- 中间输出 +- Tool 消息 +- 跳转行为 +- 中断行为 +- 错误 + +如果只返回 `string`,这些信息根本没地方放。 + +所以 ADK 选择的是: + +> 不直接给终值,而是给一串按顺序消费的事件。 + +### `Next()` 为什么重要 + +`Next()` 是阻塞式的。 + +也就是每次调用时,只会等两种结果: + +- 等到一个新的 `AgentEvent` +- 或者等到迭代器关闭,返回 `ok=false` + +这意味着调用方的消费逻辑会非常稳定: + +```go +for { + event, ok := iter.Next() + if !ok { + break + } + // handle event +} +``` + +### `NewAsyncIteratorPair` + goroutine 为什么是常见写法 + +官方给了这套基础设施: + +```go +iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() +``` + +- `iter` 给调用方消费 +- `gen` 给 Agent 内部发事件 + +自定义 Agent 常见实现会开 goroutine,不是为了炫技,而是因为: + +> `Run()` 的目标不是等所有事做完再返回,而是先把事件出口交出去,然后内部异步地产生事件。 + +如果不这么做,你会把“事件流协议”重新写回“阻塞函数返回值”。 + +## 6. `AgentEvent` / `AgentOutput` / `AgentAction`:一次执行到底吐出了什么 + +官方定义: + +```go +type AgentEvent struct { + AgentName string + RunPath []RunStep + Output *AgentOutput + Action *AgentAction + Err error +} +``` + +这部分只要抓住“事件里到底装了哪几类信息”就够了。 + +### `AgentName` 和 `RunPath` + +- `AgentName`:是谁发出的当前事件 +- `RunPath`:这个事件是沿着哪条调用链走到这里的 + +在单 Agent 场景里你可能感受不强。 +但一到多 Agent 场景,这两个字段就是链路上下文。 + +### `AgentOutput` + +官方定义: + +```go +type AgentOutput struct { + MessageOutput *MessageVariant + CustomizedOutput any +} +``` + +这说明 ADK 默认把“消息输出”当成第一公民,同时也允许你挂自定义输出。 + +而 `MessageVariant` 的价值是把流式和非流式统一起来: + +```go +type MessageVariant struct { + IsStreaming bool + Message Message + MessageStream MessageStream + Role schema.RoleType + ToolName string +} +``` + +最重要的不是字段多,而是这几个判断位很实用: + +- `IsStreaming`:当前到底是不是流 +- `Role`:当前是 Assistant 还是 Tool +- `ToolName`:如果是 Tool,工具名是什么 + +### `AgentAction` + +很多人看 `AgentEvent` 时,只盯着 `Output`。 + +但 ADK 还专门留了一条“行为输出通道”: + +```go +type AgentAction struct { + Exit bool + Interrupted *InterruptInfo + TransferToAgent *TransferToAgentAction + BreakLoop *BreakLoopAction + CustomizedAction any +} +``` + +它的意义很直接: + +> Agent 不只会“说什么”,还会“决定接下来怎么跑”。 + +官方当前内置几类 Action: + +- `NewExitAction()`:立刻退出 +- `NewTransferToAgentAction(name)`:跳到目标 Agent +- `Interrupted`:通知 Runner 当前中断 +- `BreakLoop`:让 LoopAgent 结束循环 + +你可以先把它们理解成下面这种最小意图: + +```go +gen.Send(&adk.AgentEvent{ + Action: adk.NewExitAction(), +}) + +gen.Send(&adk.AgentEvent{ + Action: adk.NewTransferToAgentAction("planner_agent"), +}) +``` + +### `Err` + +消费事件时,`Err` 绝对不能跳过: + +```go +if event.Err != nil { + // handle error +} +``` + +否则很容易出现一种假象: + +看起来“好像有输出”,但实际执行已经坏了。 + +### `SetLanguage` + +`Agent 抽象` 页最后补的 `SetLanguage`,你只要记住 4 句话: + +1. 它是全局设置 +2. 最好在程序初始化时设置 +3. 它只影响 ADK 内置 prompt +4. 不要在运行时来回切 + +因为一旦同一会话里出现混合语言提示词,问题会很隐蔽。 + +## 7. 自定义 Agent 实战:从零实现一个 `ConceptTutorAgent` + +这段代码的目标不是做知识推理,而是跑通 Agent 协议。 + +先看执行链: + +![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://i-blog.csdnimg.cn/direct/9032a2673b25491a99e6b509f4723540.png) + +它想证明 4 件事: + +- 自定义 Agent 本质上就是实现 `Agent` 接口 +- `Run()` 返回的是事件流,不是字符串 +- `AgentRunOption` 可以做请求级调参 +- 不接模型 API,也能把 Agent 协议本身跑通 + +### 完整代码 + +把下面代码保存成 `main.go`: + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" +) + +// audienceOptions 是当前自定义 Agent 的实现级运行参数。 +// 这类参数不进入通用 Agent 接口,而是通过 impl-specific option 透传。 +type audienceOptions struct { + audience string +} + +// WithAudience 为当前 Agent 注入“面向谁讲解”的运行选项。 +// 调用方可在不修改 Agent 接口的前提下,按次覆盖执行行为。 +func WithAudience(audience string) adk.AgentRunOption { + return adk.WrapImplSpecificOptFn(func(o *audienceOptions) { + o.audience = audience + }) +} + +// ConceptTutorAgent 是一个最小可运行的自定义 Agent。 +// 它不依赖大模型,而是演示:如何实现 Agent 接口、消费 AgentInput、产出 AgentEvent。 +type ConceptTutorAgent struct{} + +// Name 返回 Agent 的稳定标识,用于日志、协作和运行时识别。 +func (a *ConceptTutorAgent) Name(ctx context.Context) string { + return "ConceptTutorAgent" +} + +// Description 返回 Agent 的能力描述,供人类或其他 Agent 判断是否适合处理某类任务。 +func (a *ConceptTutorAgent) Description(ctx context.Context) string { + return "负责把一个技术概念讲成新手能听懂的三段话" +} + +// Run 是 Agent 的执行入口。 +// 它从输入消息中提取任务内容,读取实现级运行参数,并通过事件流返回结果。 +func (a *ConceptTutorAgent) Run(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] { + iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + + // Agent 的输出协议是事件流,因此这里异步生成事件并通过 iterator 暴露给调用方。 + go func() { + defer gen.Close() + + // 优先响应上游取消或超时,避免 goroutine 泄漏。 + if err := ctx.Err(); err != nil { + gen.Send(&adk.AgentEvent{Err: err}) + return + } + + // 基础入参校验:没有消息就无法构造任务上下文。 + if input == nil || len(input.Messages) == 0 { + gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agent input messages is empty")}) + return + } + + // 读取当前 Agent 自己定义的运行选项;未传时使用默认值。 + cfg := adk.GetImplSpecificOptions(&audienceOptions{audience: "newbie"}, opts...) + + // 约定使用最后一条 user message 作为本次要讲解的概念。 + concept := lastUserMessage(input.Messages) + if strings.TrimSpace(concept) == "" { + gen.Send(&adk.AgentEvent{Err: fmt.Errorf("last user message is empty")}) + return + } + + reply := buildReply(concept, cfg.audience, input.EnableStreaming) + + // 将最终文本包装成标准 assistant 消息事件返回。 + gen.Send(adk.EventFromMessage( + schema.AssistantMessage(reply, nil), + nil, + schema.Assistant, + "", + )) + }() + + return iter +} + +// lastUserMessage 从消息列表中逆序查找最后一条用户消息。 +// 这是一种常见约定:最新的 user 输入通常代表当前任务指令。 +func lastUserMessage(messages []adk.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg != nil && msg.Role == schema.User { + return msg.Content + } + } + return "" +} + +// buildReply 根据概念、受众和流式标记构造演示用回复。 +// 这里故意不接入真实模型,目的是突出 Agent 输入/输出协议本身。 +func buildReply(concept, audience string, enableStreaming bool) string { + prefix := "面向新手" + if audience == "interview" { + prefix = "面向面试复盘" + } + + streamingHint := "这次我没有实现流式输出,所以会一次性返回完整结果。" + if !enableStreaming { + streamingHint = "这次按非流式方式返回完整结果。" + } + + return fmt.Sprintf( + "%s\n\n一句话定义:这里把“%s”当成当前要讲解的概念。\n为什么重要:这个 demo 不是在做真实知识推理,而是在演示 Agent 如何围绕输入、事件和 option 组织一次执行。\n常见坑:别把 Messages 理解成单条 prompt,它其实承载的是任务上下文。\n补充:%s", + prefix, + concept, + streamingHint, + ) +} + +func main() { + ctx := context.Background() + + // 默认讲解概念;支持命令行覆盖,便于本地快速测试不同输入。 + concept := "Agent 抽象" + if len(os.Args) > 1 { + concept = strings.Join(os.Args[1:], " ") + } + + agent := &ConceptTutorAgent{} + + // AgentInput 承载本次任务上下文,而不只是单条 prompt。 + // 这里同时放入 system message 和 user message,模拟一次最小对话输入。 + input := &adk.AgentInput{ + Messages: []adk.Message{ + schema.SystemMessage("你是一个负责解释技术概念的教学 Agent。"), + schema.UserMessage(concept), + }, + EnableStreaming: true, + } + + fmt.Printf("agent=%s\n", agent.Name(ctx)) + fmt.Printf("description=%s\n\n", agent.Description(ctx)) + + // 直接运行自定义 Agent,并通过实现级 option 注入受众信息。 + iter := agent.Run(ctx, input, WithAudience("newbie")) + for { + event, ok := iter.Next() + if !ok { + break + } + + // 事件级错误需要显式处理;这也是事件流协议的一部分。 + if event.Err != nil { + log.Fatalf("agent failed: %v", event.Err) + } + if event.Output == nil || event.Output.MessageOutput == nil { + continue + } + + mv := event.Output.MessageOutput + if mv.Message == nil { + continue + } + + fmt.Printf("assistant>\n%s\n", mv.Message.Content) + } +} +``` + +### 运行 + +```bash +go mod init concept-tutor-demo +go get github.com/cloudwego/eino@latest +go run . -- "AsyncIterator" +``` + +你会看到类似输出: + +```text +agent=ConceptTutorAgent +description=负责把一个技术概念讲成新手能听懂的三段话 + +assistant> +面向新手 + +一句话定义:这里把“AsyncIterator”当成当前要讲解的概念。 +为什么重要:这个 demo 不是在做真实知识推理,而是在演示 Agent 如何围绕输入、事件和 option 组织一次执行。 +常见坑:别把 Messages 理解成单条 prompt,它其实承载的是任务上下文。 +补充:这次我没有实现流式输出,所以会一次性返回完整结果。 +``` + +### 这段代码对应了哪些抽象 + +1. `Name()`:给 Agent 身份 +2. `Description()`:给 Agent 职责描述 +3. `Run()`:按统一协议执行 +4. `AgentInput.Messages`:承载任务上下文 +5. `WithAudience(...)`:演示请求级 option +6. `NewAsyncIteratorPair()`:建立生产者和消费者 +7. `EventFromMessage(...)`:把输出装进 `AgentEvent` +8. `iter.Next()`:调用方按事件流消费 + +### 进阶补充:流式长什么样 + +这次 demo 故意没实现流式,就是为了说明: + +> `EnableStreaming=true` 不意味着你这个 Agent 必须流式输出。 + +如果你只想看“流式 `MessageVariant` 怎么发”,一个最小片段是: + +```go +stream := schema.StreamReaderFromArray([]adk.Message{ + schema.AssistantMessage("第一段。", nil), + schema.AssistantMessage("第二段。", nil), +}) + +gen.Send(adk.EventFromMessage(nil, stream, schema.Assistant, "")) +``` + +此时: + +- `IsStreaming = true` +- `Message = nil` +- `MessageStream != nil` + +## 8. 躲坑 + 下一步学习路线 + +### 最容易踩的 7 个坑 + +1. 把 `Agent` 当成 Prompt 包装器。 +2. 把 `Messages` 当成“用户这一句话”。 +3. 把 `EnableStreaming` 当成强制命令。 +4. 忘记 `gen.Close()`,导致迭代不结束。 +5. 只读输出,不处理 `event.Err`。 +6. 把 `AgentRunOption` 和 `AgentWithOptions` 混用。 +7. 在运行时随意切 `SetLanguage`。 + +### 看完这篇,下一步怎么学 + +建议按这 3 步往后走: + +1. 回看 [ADK 首卡总览](./AI%20大模型落地系列|Eino%20ADK%20篇:为什么很多人看完%20Quickstart,还是搭不出真正的%20Multi-Agent.md) +2. 再看 [ChatModelAgent、Runner、AgentEvent(Console 多轮)](../入门必学/AI大模型落地系列:一文读懂%20ChatModelAgent、Runner、AgentEvent(Console%20多轮).md),把“现成 Agent 怎么跑起来”补上 +3. 然后进入 `Agent 协作`、`Workflow Agents`、`Agent Runner 与扩展` + +这篇想让你建立的认知只有一句: + +> `Agent` 不是一段配置,而是一套统一输入、统一事件流、统一行为协议的运行对象。 + +## 参考资料 + +- [Eino ADK: Agent 抽象](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_interface/) +- [Eino ADK: 概述](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_preview/) +- [Eino ADK: Quickstart](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_quickstart/) +- [Eino ADK: Agent 协作](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_collaboration/) +- [Eino ADK: Agent Runner 与扩展](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_extension/) + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino ADK体系篇:为什么一定要有 Agent 这层抽象](./02-为什么一定要有Agent这层抽象.md) +- CSDN 跳转:[AI 大模型落地系列|Eino ADK体系篇:为什么一定要有 Agent 这层抽象](https://zhumo.blog.csdn.net/article/details/159690023) +- 官方文档:[Eino ADK:Agent 抽象](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_interface/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/03-\344\275\240\345\257\271ChatModelAgent\346\234\211\344\272\206\350\247\243\345\220\227\357\274\237.md" "b/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/03-\344\275\240\345\257\271ChatModelAgent\346\234\211\344\272\206\350\247\243\345\220\227\357\274\237.md" new file mode 100644 index 0000000..e6585cf --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/05-ADK\344\275\223\347\263\273/03-\344\275\240\345\257\271ChatModelAgent\346\234\211\344\272\206\350\247\243\345\220\227\357\274\237.md" @@ -0,0 +1,759 @@ +# AI 大模型落地系列|Eino ADK体系篇:你对 ChatModelAgent 有了解吗? + +> GitHub 主文:[当前文章](./03-你对ChatModelAgent有了解吗?.md) +> CSDN 跳转:[AI 大模型落地系列|Eino ADK体系篇:你对 ChatModelAgent 有了解吗?](https://zhumo.blog.csdn.net/article/details/159696365) +> 官方文档:[Eino ADK:ChatModelAgent](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/chat_model/) +> +> 最新版以 GitHub 仓库为准,CSDN 作为分发入口,官方文档作为权威参考。 + +**一句话摘要**:从 ReAct、Transfer、AgentAsTool、Middleware / Handler 这些扩展点深入理解 ChatModelAgent。 +**适合谁看**:已经知道 ChatModelAgent 名字,但还没真正掌握其工程边界的读者。 +**前置知识**:什么是 Eino ADK、为什么一定要有 Agent 这层抽象、Tool 调用闭环 +**对应 Demo**:[官方 ChatModelAgent 文档与实战示例](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/chat_model/) + +**面试可讲点** +- 能解释 ChatModelAgent 为什么是 Agent 能力的常用承载体,而不是万能 Agent。 +- 能区分普通 Tool、Transfer、AgentAsTool、Handler/Middleware 这些扩展位点。 + +--- +> **ChatModelAgent 是一个以 LLM 为决策核心、默认采用 ReAct 式循环来推进任务的 Agent。** + +很多人第一次看 `ChatModelAgent`,可能会下意识的认为: + +> 不就是 `ChatModel + Instruction + Tools` 吗? + +这句话不算错,但不全。 + + + +`ChatModelAgent` 在 ADK 里,承担的是“默认思考型 Agent”的角色。它不是单纯帮你调一次模型,而是把模型决策、工具调用、协作跳转、事件输出和扩展钩子,统一规范到一个可运行的 Agent 骨架里。 + +本篇不在重复 `Runner / Console 多轮` 这种入门动作,而是从以下 6 个更关键的问题入手: + +1. `ChatModelAgent` 在 ADK 里到底是什么 +2. 它内部为什么是一个 `ReAct` 循环,而不是一次模型调用 +3. `ReturnDirectly / Exit / MaxIterations / OutputKey` 这些字段到底解决什么问题 +4. `Tool`、`Transfer`、`AgentAsTool` 到底怎么选 +5. `Middleware / Handler` 为什么才是工程化分水岭 +6. 一个更贴后端场景的 Demo,应该怎么搭 + +## 1. 为什么很多人会把 `ChatModelAgent` 想简单 + +很多人一上来就把注意力放在这几个字段上: + +- `Instruction` +- `Model` +- `Tools` + +然后得出一个很自然的结论: + +> 这就是一个“会调模型、也会调工具”的配置对象。 + + +但他的重点其实是另一层: + +> `ChatModelAgent` 是 ADK 里最核心、最常用的预构建 Agent 之一,它把“思考 + 决策 + 调工具 + 协作 + 输出事件”规范成了一个统一实现。 + +也就是说,它不是 `ChatModel` 的语法糖。 + +它解决的是:当一个 Agent 需要靠 LLM 自己判断下一步该答、该调工具、该转给别人、还是该退出时,系统应该怎么组织这段运行过程。 + +这也是为什么你会发现: + +- 它有 `ReAct` 循环 +- 它有 `Transfer` +- 它可以把别的 Agent 当 Tool +- 它有专门的 `Handler` +- 它还要把整个过程输出成 `AgentEvent` + +如果只是“模型外面包一层”,根本没必要长出这一整套能力。 + +## 2. `ChatModelAgent` 在 ADK 里到底是什么 + +官方定义很直接: + +> `ChatModelAgent` 是 Eino ADK 中的一个核心预构建 Agent,它封装了与大语言模型交互、并支持使用工具来完成任务的复杂逻辑。 + +这句话里最重要的词,不是“模型”,而是“复杂逻辑”。 + +你可以把 ADK 里的几类 Agent 先粗分一下: + +| 类型 | 主要职责 | 决策方式 | +| --- | --- | --- | +| `ChatModelAgent` | 负责思考、推理、工具调用、动态决策 | 由 LLM 决定 | +| `Workflow Agents` | 负责顺序、循环、并行等固定流程 | 由预设流程决定 | +| `Supervisor / Plan-Execute` | 负责多 Agent 协作范式封装 | 仍以内置 ChatModelAgent 为核心 | +| `Custom Agent` | 负责高度定制的执行协议 | 由你自己实现 | + +所以 `ChatModelAgent` 的位置,其实非常像默认的“脑子”。 + +当你的 Agent 需要: + +- 根据上下文自行判断下一步动作 +- 在回答和工具之间切换 +- 在多个 Agent 之间转交任务 +- 在运行过程中插入工程逻辑 + +那它通常就会成为第一个候选。 + +把这套关系放到运行时视角里,看起来会更清楚: + +![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://i-blog.csdnimg.cn/direct/03658b1d41bc4e4ab34c45c8b651123e.png) + + +这张图里最该记住的是两点: + +1. `ChatModelAgent` 不等于“模型输出一段话” +2. 它真正对外暴露的是一整段可运行的决策过程 + +## 3. 其内部本质是一个 `ReAct` 循环 + + `ChatModelAgent` 的核心执行模式其实很清楚:它内部走的是 `ReAct`。 + +其内部是一个循环: + +1. 调模型,让模型先做判断 +2. 如果模型直接给答案,那就结束 +3. 如果模型发起 Tool Call,就执行工具 +4. 把工具结果回灌给模型 +5. 再让模型决定下一步 +6. 直到模型不再需要工具,或者 Agent 被强制结束 + +这套循环里,用以下四个词能直接对应上: + +- `Reason`:模型思考 +- `Action`:模型决定调用什么 +- `Act`:系统真的去执行动作 +- `Observation`:把动作结果喂回去 + +所以 `ChatModelAgent` 的关键,不在于“它能调工具”。 + +而在于: + +> 它允许模型把一次复杂任务拆成多轮判断,而不是一口气把答案硬生成出来。 + +这也是它和我们直接手写一段 `ChatModel.Generate(...)` 的根本区别。 + +### 没有 Tool 时会怎样 + +可以这么说: + +> 如果没有配置工具,`ChatModelAgent` 会退化为一次普通的 ChatModel 调用。 + +这意味着: + +- 不是所有 `ChatModelAgent` 都一定会循环 +- 只有当你给了工具、协作能力,或者模型真的产生 Tool Call,它才会进入完整的 `ReAct` 运行形态 + +### 为什么还需要 `MaxIterations` + +`ReAct` 的好处是灵活,风险是兜不住时会一直绕。 + +所以 `MaxIterations` 本质上是一个保险丝。 + +默认值是 `20`。超过这个次数还没结束,Agent 会直接报错退出。 + +这在真实业务里非常有必要。否则你很容易遇到两种问题: + +- 模型在几个工具之间来回试探,始终拖沓着 +- Prompt 写得含糊,模型不知道该答还是该继续调工具 + +很多线上“为什么 Agent 一直在调用工具”的问题,本质上都不是框架 bug,而是没有把循环上限和结束策略设计清楚。 + +## 4. 哪几组配置真正决定了行为 +### `Name / Description` + +这两个字段经常被初学者轻视。 + +但实际上它们比你想象的重要。 + +- `Name` 是 Agent 的身份标识 +- `Description` 决定别的 Agent 会不会把任务转给它 + +尤其在 `Transfer` 场景里,`Description` 不是装饰品,而是模型判断“谁更适合接手这件事”的依据。 + +### `Instruction / Model` + +这两个字段是最直观的: + +- `Instruction`:Agent 的系统约束 +- `Model`:底层使用哪个 `ChatModel` + +但有一点别搞混: + +`Instruction` 决定行为风格,`Model` 决定能力底座。 + +### `ToolsConfig` + +这组配置是 `ChatModelAgent` 和普通模型调用真正拉开差距的地方。 + +其中有两个很关键的扩展字段起到了作用: + +- `ReturnDirectly` +- `EmitInternalEvents` + +#### `ReturnDirectly` + +这个字段的意思是: + +> 某些工具一旦被调用成功,就不要再把结果送回模型二次润色了,直接把结果带着返回。 + +这个能力特别适合两类场景: + +- 工具结果本身就是最终答案 +- 工具结果本身就是“交接单”“审批单”“跳转结果”,再回模型反而会把结果弄脏 + +比如这篇后面 demo 里的 `handoff_to_human`,就很适合 `ReturnDirectly`。 + +#### `EmitInternalEvents` + +这个配置只在 `AgentAsTool` 场景里有意义。 + +默认情况下,当你把一个 Agent 包成 Tool 后,外层只会拿到最终的 ToolResult,看不到内层 Agent 的事件流。 + +而 `EmitInternalEvents=true` 时,内层 Agent 产生的事件会继续往外透出,调用方就能实时看到里面到底在干什么。 + +这个能力特别适合: + +- 你把一个复杂 Agent 当 Tool 用 +- 但又希望前端或调用方还能看到它的实时输出 + +### `OutputKey` + +这个字段很实用: + +> 把 Agent 最后一条输出消息,以某个 key 写进 `SessionValues` + +如果你的后续 Agent、Workflow、或者外层业务逻辑还要继续消费这次结果,它比你手动到处传字符串干净得多。 + +### `Exit` + +你可以把他当作一个特殊 Tool。 + +模型调用这个 Tool 并成功执行后,`ChatModelAgent` 会直接退出,效果和 `ReturnDirectly` 很像,但语义更明确: + +- `ReturnDirectly` 更像“某个工具调用后直接收口” +- `Exit` 更像“模型自己宣布:到这里结束,把这个最终结果拿出去” + +### `ModelRetryConfig` + +这是一个典型的工程字段。 + +它解决的不是“让回答更聪明”,而是“模型调用失败时,系统要不要重试,以及怎么重试”。 + + + +> 如果流式响应过程中发生错误,但策略允许重试,调用方读 stream 时会收到 `WillRetryError`。 + +所以在真实系统里做流式输出时,不能只管 happy path。否则一旦流中途断掉,你都不知道是彻底失败了,还是下一轮马上会补回来。 + +## 5. `Tool`、`Transfer`、`AgentAsTool` 到底怎么选 + +这一段是最值得展开讲的地方。 + +很多人第一次看这三种能力时,会觉得它们都像“把事情交给别人做”。但它们不是一回事。 + +![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/07897c1fe0bb47faa210ba031c66e9ea.png) + + +### 普通 `Tool` + +适合那种边界特别清晰、输入输出很稳定的能力,比如: + +- 查错误码 +- 查 runbook +- 算时间 +- 调外部 HTTP 接口 + +它更像函数调用。 + +### `Transfer` + +`Transfer` 的意思不是“调用另一个能力”,而是: + +> 当前 Agent 判断,另一个 Agent 更适合接手这件事,于是把任务控制权转过去。 + +官方页对应的实现机制是: + +- 给 `ChatModelAgent` 配置子 Agent +- 框架自动生成一个 `Transfer Tool` +- 模型根据各个 Agent 的 `Description` 决定要不要跳转 +- Runner 收到 Transfer Event 后,切到目标 Agent 继续执行 + +最小示意像这样: + +```go +// 创建一个上层 Agent,作为请求分发器使用。 +// 它本身由聊天模型驱动,职责是根据用户问题决定该交给谁处理。 +supervisor, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "dispatcher", // Agent 名称:运行时用于标识当前 Agent + Description: "负责分发用户请求", // 描述:帮助上层协作逻辑理解它的职责 + Model: cm, // 底层使用的聊天模型 +}) + +// 创建一个子 Agent,专门处理数据库相关问题。 +dbExpert, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "db_expert", // 子 Agent 名称 + Description: "擅长数据库故障排查", // 描述其擅长领域,便于被正确选择 + Model: cm, // 同样使用聊天模型驱动 +}) + +// 给 supervisor 挂载可协作的子 Agent。 +// 这样 supervisor 在处理请求时,就可以把数据库类问题分发给 dbExpert。 +dispatcher, _ := adk.SetSubAgents(ctx, supervisor, []adk.Agent{dbExpert}) +``` + +如果一个问题本来就该交给另一个 Agent 独立负责,那应该优先考虑 `Transfer`,而不是让当前 Agent 硬撑到底。 + +### `AgentAsTool` + +它的语义又不同: + +> 我不是把任务彻底交出去,我只是把另一个 Agent 当成一个“高级工具”来用。 + +什么时候适合这么做? + +当被调用的 Agent: + +- 不需要完整运行上下文 +- 只要一个明确请求参数就能独立完成工作 +- 更像一个“复杂工具”而不是一个“新的控制者” + +我这里从官方源码 `NewAgentTool(...)` 截取片段举例: + +```go +reporterTool := adk.NewAgentTool(ctx, reporterAgent) + +agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "ops_assistant", + Description: "负责处理线上故障", + Model: cm, + ToolsConfig: adk.ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{reporterTool}, + }, + EmitInternalEvents: true, + }, +}) +``` + +一句话记忆这三者: + +- `Tool`:调用一个函数 +- `Transfer`:把控制权交给另一个 Agent +- `AgentAsTool`:把另一个 Agent 当函数来调 + +## 6. `Middleware / Handler` 才是工程化分水岭 + +如果说 Tool 解决的是“Agent 能干什么”,那 `Handler` 解决的就是“Agent 在真实系统里怎么管”。 + +官方页给出的扩展点一共有几层: + +- `BeforeAgent` +- `BeforeModelRewriteState` +- `AfterModelRewriteState` +- `WrapModel` +- `WrapInvokableToolCall / WrapStreamableToolCall` + +把它们放到一张执行图里,会比只看接口名字更容易懂: + +![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/38f9a9fb09e640afb2522ae453ef9553.png) + + +### `BeforeAgent` + +这是最适合做“运行前改配置”的地方。 + +它能改的不是消息历史,而是本次运行的: + +- `Instruction` +- `Tools` +- `ReturnDirectly` + +所以它很适合做这些事: + +- 动态追加系统约束 +- 按租户或环境动态加工具 +- 把某个工具临时标记为 `ReturnDirectly` + +### `BeforeModelRewriteState / AfterModelRewriteState` + +这两个钩子盯的是 `Messages`。 + +适合做: + +- 历史裁剪 +- 敏感信息脱敏 +- 在模型调用前后检查消息状态 + +如果你只是想管“发给模型的消息长什么样”,优先看这组。 + +### `WrapModel` + +这个钩子适合拦截模型调用本身。 + +典型用途是: + +- 统一日志 +- 指标采集 +- 审计 +- 对模型输入输出做包装 + +它的价值在于:你不用改业务代码,就能把“模型调用前后”的工程逻辑拦下来。 + +### `WrapInvokableToolCall / WrapStreamableToolCall` + +这两个钩子盯的是工具层。 + +特别适合: + +- 打工具调用日志 +- 统计耗时 +- 做参数审计 +- 对工具结果二次包装 + +### 为什么新代码更推荐 `Handlers` + +官方和本地源码都已经把这个方向说得很明确了: + +- 老的 `AgentMiddleware` 是 struct 风格,适合简单静态扩展 +- 新的 `ChatModelAgentMiddleware` 是 interface 风格,更适合动态行为和上下文改写 + +如果你是现在开始写新的 `ChatModelAgent` 扩展,优先用 `Handlers` 更稳。 + +## 7. 实战:用 `ChatModelAgent` 搭一个故障分诊助手 +目的: +> 做一个“故障分诊助手”,能查 runbook、在高风险场景下直接升级给人工,并通过 handler 统一加上运行约束与工具日志。 + +本例子只演示三件事: + +1. `ChatModelAgent + Tool` +2. `ReturnDirectly` +3. `Handler` + +### 先装依赖 + +```bash +go get github.com/cloudwego/eino@latest +go get github.com/cloudwego/eino-ext/components/model/qwen@latest +``` + +环境变量至少准备两个: + +```powershell +$env:DASHSCOPE_API_KEY="你的百炼 API Key" +$env:QWEN_MODEL="qwen-plus" +``` + + + +### 完整代码 + +这段代码的目标不是做一个真正的运维平台,而是把 `ChatModelAgent` 这一页最重要的几个点跑通。 + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/components/tool/utils" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" + "github.com/cloudwego/eino-ext/components/model/qwen" +) + +// RunbookInput 是查询故障预案工具的输入。 +type RunbookInput struct { + Service string `json:"service" jsonschema:"description=服务名,enum=user,enum=order,enum=payment,enum=search"` + ErrorCode string `json:"error_code" jsonschema:"description=错误码,例如 DB_TIMEOUT、AUTH_EXPIRED、NO_STOCK"` +} + +// RunbookOutput 是故障预案工具的输出。 +type RunbookOutput struct { + Level string `json:"level"` + Suggestion string `json:"suggestion"` + Owner string `json:"owner"` +} + +// HandoffInput 是转人工工具的输入。 +type HandoffInput struct { + Reason string `json:"reason" jsonschema:"description=需要人工接手的原因"` +} + +// HandoffOutput 是转人工工具的输出。 +type HandoffOutput struct { + Ticket string `json:"ticket"` + Action string `json:"action"` +} + +// OpsGuardHandler 是一个自定义 middleware, +// 用来在 Agent 运行前补充约束,并在工具调用时统一打日志。 +type OpsGuardHandler struct { + *adk.BaseChatModelAgentMiddleware +} + +// NewOpsGuardHandler 创建自定义 handler。 +func NewOpsGuardHandler() *OpsGuardHandler { + return &OpsGuardHandler{ + BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}, + } +} + +// BeforeAgent 在整次 Agent 运行开始前执行。 +// 这里给本次运行动态追加额外指令。 +func (h *OpsGuardHandler) BeforeAgent( + ctx context.Context, + runCtx *adk.ChatModelAgentContext, +) (context.Context, *adk.ChatModelAgentContext, error) { + // 拷贝一份运行上下文,避免直接改原对象。 + nRunCtx := *runCtx + + // 动态补充本次运行约束: + // 1. 始终中文回复 + // 2. 先给结论再给依据 + // 3. 信息不足或风险高时优先转人工 + nRunCtx.Instruction += "\n\n始终使用中文回复。先给结论,再给依据。若缺少关键信息或风险较高,优先调用 handoff_to_human。" + + return ctx, &nRunCtx, nil +} + +// WrapInvokableToolCall 包装普通工具调用。 +// 这里主要用于统一记录工具名和入参日志。 +func (h *OpsGuardHandler) WrapInvokableToolCall( + ctx context.Context, + endpoint adk.InvokableToolCallEndpoint, + tCtx *adk.ToolContext, +) (adk.InvokableToolCallEndpoint, error) { + return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) { + log.Printf("[tool] name=%s args=%s", tCtx.Name, argumentsInJSON) + return endpoint(ctx, argumentsInJSON, opts...) + }, nil +} + +// newRunbookTool 创建“查询故障预案”工具。 +// 模型可根据 service + error_code 获取预定义排查建议。 +func newRunbookTool() tool.BaseTool { + t, err := utils.InferTool("search_runbook", "根据服务名和错误码查询故障处置建议", func(ctx context.Context, input *RunbookInput) (*RunbookOutput, error) { + switch { + // payment 服务数据库超时时,返回对应预案 + case input.Service == "payment" && input.ErrorCode == "DB_TIMEOUT": + return &RunbookOutput{ + Level: "high", + Suggestion: "先确认只读实例是否可用,再检查连接池是否打满,必要时切换到降级路径。", + Owner: "payment-oncall", + }, nil + + // user 服务鉴权过期时,返回对应预案 + case input.Service == "user" && input.ErrorCode == "AUTH_EXPIRED": + return &RunbookOutput{ + Level: "medium", + Suggestion: "先排查 token 过期时间配置,再确认网关和鉴权服务的时钟是否一致。", + Owner: "user-oncall", + }, nil + + // 未命中预案时,返回兜底结果,引导补充信息 + default: + return &RunbookOutput{ + Level: "unknown", + Suggestion: "没有命中预案,请补充 service、error_code 和最近一次发布时间。", + Owner: "triage-bot", + }, nil + } + }) + if err != nil { + log.Fatalf("new runbook tool failed: %v", err) + } + return t +} + +// newHandoffTool 创建“转人工”工具。 +// 当问题风险较高或信息不足时,用它生成交接单。 +func newHandoffTool() tool.BaseTool { + t, err := utils.InferTool("handoff_to_human", "当风险较高或信息不足时,生成交接给人工处理的说明", func(ctx context.Context, input *HandoffInput) (*HandoffOutput, error) { + return &HandoffOutput{ + Ticket: "INC-2026-031", + Action: "已生成交接单,请值班同学继续处理。原因:" + input.Reason, + }, nil + }) + if err != nil { + log.Fatalf("new handoff tool failed: %v", err) + } + return t +} + +// newModel 创建底层聊天模型。 +func newModel(ctx context.Context) *qwen.ChatModel { + cm, err := qwen.NewChatModel(ctx, &qwen.ChatModelConfig{ + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKey: mustEnv("DASHSCOPE_API_KEY"), + Model: envOrDefault("QWEN_MODEL", "qwen-plus"), + }) + if err != nil { + log.Fatalf("new qwen model failed: %v", err) + } + return cm +} + +// newTriageAgent 创建一个故障分诊 Agent。 +// 它可以: +// 1. 调用 runbook 工具查询预案 +// 2. 调用 handoff 工具转人工 +// 3. 在 handler 中做运行前约束和工具日志 +func newTriageAgent(ctx context.Context) adk.Agent { + agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "ops_triage_agent", // Agent 名称 + Description: "负责排查后端线上故障,能查询 runbook,并在高风险时升级给人工处理", + Instruction: "你是后端故障分诊助手。优先使用工具获取事实,再给结论。", + Model: newModel(ctx), + + // 配置可用工具。 + ToolsConfig: adk.ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{ + newRunbookTool(), + newHandoffTool(), + }, + }, + + // handoff_to_human 一旦被调用,工具结果可直接作为输出返回。 + ReturnDirectly: map[string]bool{ + "handoff_to_human": true, + }, + }, + + MaxIterations: 8, // 最多允许 8 轮 Agent 内部迭代 + OutputKey: "triage_result", // 本次运行结果的输出键名 + + // 注册自定义 middleware。 + Handlers: []adk.ChatModelAgentMiddleware{ + NewOpsGuardHandler(), + }, + }) + if err != nil { + log.Fatalf("new triage agent failed: %v", err) + } + return agent +} + +func main() { + ctx := context.Background() + + // 默认查询内容,可通过命令行参数覆盖。 + query := "payment 服务出现 DB_TIMEOUT,连接池已满,请给我排查建议。" + if len(os.Args) > 1 { + query = strings.Join(os.Args[1:], " ") + } + + // 创建 Runner,负责驱动 Agent 执行。 + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: newTriageAgent(ctx), + EnableStreaming: false, // 这里关闭流式输出 + }) + + // 发起查询,拿到事件流迭代器。 + iter := runner.Query(ctx, query) + + // 逐个消费 Agent 事件并打印结果。 + if err := printEvents(iter); err != nil { + log.Fatal(err) + } +} + +// printEvents 用于遍历 Agent 运行事件。 +// 如果是工具输出,打印工具名;否则按 assistant 输出打印。 +func printEvents(iter *adk.AsyncIterator[*adk.AgentEvent]) error { + for { + event, ok := iter.Next() + if !ok { + return nil + } + if event.Err != nil { + return event.Err + } + if event.Output == nil || event.Output.MessageOutput == nil { + continue + } + + mv := event.Output.MessageOutput + msg, err := mv.GetMessage() + if err != nil { + return err + } + + switch mv.Role { + case schema.Tool: + fmt.Printf("\n[tool:%s]\n%s\n", mv.ToolName, msg.Content) + default: + fmt.Printf("\n[assistant]\n%s\n", msg.Content) + } + } +} + +// mustEnv 读取必填环境变量;缺失则直接退出。 +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("environment variable %s is required", key) + } + return v +} + +// envOrDefault 读取环境变量;没有则返回默认值。 +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +``` + +### 这个 Demo 到底对应了什么 + +1. `search_runbook` 是普通 Tool,模型先查事实,再组织答案 +2. `handoff_to_human` 被配置成 `ReturnDirectly`,一旦调用就直接退出 +3. `OpsGuardHandler` 通过 `BeforeAgent` 和 `WrapInvokableToolCall` 把运行约束和工具日志插进来了 + +如果你本地跑的时候传一个“高风险但信息不足”的问题,比如: + +```bash +go run . "payment 服务持续报错,但我只有一句日志:DB_TIMEOUT,请直接给我下一步动作。" +``` + +常见表现会是两种: + +- 模型先调 `search_runbook`,再组织答案返回 +- 模型判断信息不足或风险过高,直接调 `handoff_to_human`,然后因为 `ReturnDirectly` 立即结束 + +这正是 `ChatModelAgent` 和普通模型调用的差别:它不是只会说话,而是会决定下一步怎么干。 + + + +## 8. 总结 + +本篇最想帮你建立的,不是某个 API 记忆点,而是一个判断: + +> `ChatModelAgent` 不是“模型调用升级版”,而是 ADK 里默认的思考型 Agent 实现。 + +它真正解决的是: + +- 让模型在回答、调工具、转交任务之间做动态决策 +- 让这些动作按照 `ReAct` 方式循环运行 +- 让运行过程以 `AgentEvent` 形式输出 +- 让你能通过 `Handler` 把日志、审计、裁剪、动态工具这些工程能力插进去 + +--- + +## 发布说明 + +- GitHub 主文:[AI 大模型落地系列|Eino ADK体系篇:你对 ChatModelAgent 有了解吗?](./03-你对ChatModelAgent有了解吗?.md) +- CSDN 跳转:[AI 大模型落地系列|Eino ADK体系篇:你对 ChatModelAgent 有了解吗?](https://zhumo.blog.csdn.net/article/details/159696365) +- 官方文档:[Eino ADK:ChatModelAgent](https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/agent_implementation/chat_model/) +- 最新版以 GitHub 仓库为准。 + diff --git "a/docs/csdn/AI-Go-Eino-study/\344\273\223\345\272\223\345\217\221\345\270\203\350\256\276\347\275\256.md" "b/docs/csdn/AI-Go-Eino-study/\344\273\223\345\272\223\345\217\221\345\270\203\350\256\276\347\275\256.md" new file mode 100644 index 0000000..264794a --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/\344\273\223\345\272\223\345\217\221\345\270\203\350\256\276\347\275\256.md" @@ -0,0 +1,71 @@ +# 仓库发布设置 + +这些内容不会自动从 README 同步到 GitHub,需要手动填写到仓库的 About 和设置面板中。这里把首发需要的内容固定下来,方便直接复制。 + +## About 填写建议 + +- Description:`中文 CloudWeGo Eino 学习手册,含系统教程、可运行 Go 示例和面试表达框架。` +- Website:`https://blog.csdn.net/2302_80067378/category_13132166.html` +- Topics: + - `go` + - `golang` + - `cloudwego` + - `eino` + - `ai-agent` + - `llm` + - `rag` + - `workflow` + - `tutorial` + +## 基本信息 + +- 仓库名:`go-eino-handbook` +- 描述:`中文 CloudWeGo Eino 学习手册,含系统教程、可运行 Go 示例和面试表达框架。` +- 主页:`https://blog.csdn.net/2302_80067378/category_13132166.html` +- 默认分支:`main` +- 许可证:`Apache-2.0` + +## Topics + +- `go` +- `golang` +- `cloudwego` +- `eino` +- `ai-agent` +- `llm` +- `rag` +- `workflow` +- `tutorial` + +## Social Preview 文案 + +- 标题:`Go Eino 中文学习手册` +- 副标题:`GitHub 主文站 / CSDN 分发站 / 官方文档参考源` +- 标语:`19 篇正文 + 1 篇总纲 + 5 个首批可运行 demo` + +## Release 节奏 + +1. `v1.0-eino-learning-path` + - README + - 学习总纲 + - 前置基础篇 + - 入门必学 5 篇 + - 3 个 demo +2. `v1.1-core-components` + - 组件核心 + - 编排进阶 + - 补齐 `callback-trace` 与 `chain-graph` +3. `v1.2-adk-and-interview` + - ADK 体系 + - 面试速览 + - 仓库首页文案和 social preview 最终版 + +## Publish Checklist + +- About 区的 Description、Website、Topics 已手动填写。 +- 仓库描述和 topics 填完。 +- README 首页渲染正常,文章数量写成 `19 篇正文 + 1 篇总纲`。 +- `docs/` 与 `examples/` 的相对链接点检一轮。 +- CSDN 文内加上 GitHub 主文优先级说明。 +- 选 3 个最稳定 demo 先录屏或截图,作为仓库首发素材。 + diff --git "a/docs/csdn/AI-Go-Eino-study/\351\235\242\350\257\225\351\200\237\350\247\210.md" "b/docs/csdn/AI-Go-Eino-study/\351\235\242\350\257\225\351\200\237\350\247\210.md" new file mode 100644 index 0000000..99ed00c --- /dev/null +++ "b/docs/csdn/AI-Go-Eino-study/\351\235\242\350\257\225\351\200\237\350\247\210.md" @@ -0,0 +1,36 @@ +# Eino 面试速览 + +这个仓库既是中文 Eino 学习入口,也可以直接拿来组织面试表达。最稳的讲法不是“我看过哪些 API”,而是“我怎么把 Eino 拆成四层能力”。 + +## 四层表达法 + +1. `模型调用层`:用 [`ChatModel 与 Message`](02-入门必学/01-ChatModel和Message.md) 解释模型能力如何被稳定接入。 +2. `组件协议层`:用 [`ChatTemplate`](03-组件核心/02-ChatTemplate为什么不是字符串拼接.md)、[`ToolsNode`](03-组件核心/04-为什么很多人会写Tool,却没真正看懂ToolsNode.md)、[`Retriever`](03-组件核心/07-为什么很多人会用Retriever,却没真正看懂Retrieve.md) 解释输入输出协议。 +3. `编排运行时层`:用 [`Chain / Graph`](04-编排进阶/01-一文讲透编排(Chain与Graph).md) 和 [`Workflow`](04-编排进阶/02-既然有了Chain、Graph,为何还需要Workflow.md) 解释复杂链路如何建模。 +4. `Agent 抽象层`:用 [`什么是 Eino ADK`](05-ADK体系/01-什么是EinoADK?.md) 和 [`为什么一定要有 Agent 抽象`](05-ADK体系/02-为什么一定要有Agent这层抽象.md) 解释为什么 Agent 不只是 Prompt 包装器。 + +## 推荐回答框架 + +- `我怎么开始学 Eino`:先跑 ChatModel,再补 Runner、Memory、Tool、Callback,最后才进入编排和 ADK。 +- `我怎么看 Agent`:Agent 不是大模型突然会做事,而是模型决策、工具能力、状态管理、运行时协议被系统性组织起来。 +- `我怎么看 RAG`:不要直接讲“向量库 + LLM”,先讲文档加载、解析、Embedding、Indexer、Retriever 的职责分层。 +- `我怎么看编排`:Chain 解决线性主链表达,Graph 解决关系显式化,Workflow 解决更细颗粒度的字段映射和控制流。 +- `我怎么看生产化`:先补 Callback/Trace、Interrupt/Resume、CheckPoint,再谈 Agent 真正进业务。 + +## 高频追问点 + +- 为什么 `Message` 不是字符串。 +- 为什么 `ChatModel` 要单独抽成组件。 +- `Tool` 和 `ToolsNode` 有什么区别。 +- `Chain` 和 `Graph` 到底怎么选。 +- `Runner`、`AgentEvent`、`AsyncIterator` 为什么不直接返回字符串。 +- `Interrupt/Resume` 解决的是暂停,还是治理。 + +## 对应示例 + +- 最小模型调用:[`examples/chatmodel-message`](../examples/chatmodel-message/README.md) +- 会话持久化:[`examples/memory-session`](../examples/memory-session/README.md) +- 文件系统工具调用:[`examples/tool-filesystem`](../examples/tool-filesystem/README.md) +- 可观测性:[`examples/callback-trace`](../examples/callback-trace/README.md) +- 最小编排:[`examples/chain-graph`](../examples/chain-graph/README.md) + diff --git "a/docs/\344\270\213\351\230\266\346\256\265\350\277\233\351\230\266.md" "b/docs/\344\270\213\351\230\266\346\256\265\350\277\233\351\230\266.md" index d47590e..49aad78 100644 --- "a/docs/\344\270\213\351\230\266\346\256\265\350\277\233\351\230\266.md" +++ "b/docs/\344\270\213\351\230\266\346\256\265\350\277\233\351\230\266.md" @@ -14,13 +14,15 @@ 对于排行版的优化,可以附加current所以住址。对于全体成员(如果消耗大的话,那就不必了) - - - - - - - +18、后期准备这样做: +准备建立tool封装一下,然后通过eino接入ai。 +并且它准备实现的功能分为3种。 +前置条件: 通过封装一个tool,可以调用某次任务,完成任务的详细情况。也可以传入具体时间点用来获取某时间范围内的任务总体资料。也可以获取,这个用户做过那些题目,甚至它的排名等等。 +我会在项目的前端界面种,建立一个对话助手一类的内容。 +1、通过用户的对话,可以生产本次任务的完成情况或者任务报纸。 +2、通过用户的对话,可以在生成一个指定任务/组织/人员范围内的具体完成情况,或者是汇报的总结。 +3、通过用户的对话,可以获取用的排名啊,做题情况呀,给出下阶段的努力方向。 +4、我会定期更新本项目的使用说明,用户可以在对话框种询问我这个项目有啥用。怎么用等,然后我可以与他交互对话。 diff --git a/plan/README.md b/plan/README.md new file mode 100644 index 0000000..e6b2537 --- /dev/null +++ b/plan/README.md @@ -0,0 +1,43 @@ +# 计划目录说明 + +## 目录用途 + +- 本目录用于存放本项目所有待审和已审的执行型计划。 +- 只要任务属于新增、重构、修复、联调、排障、迁移、删除、配置调整等会落代码或改规则的工作,必须先在本目录生成计划,再等待确认后执行。 +- 纯问答、纯解释、纯代码审查、纯只读排查,不强制生成计划文件。 + +## 命名规则 + +- 计划文件路径固定为 `plan//pending-.md` 或 `plan//approved-.md`。 +- 结构名固定使用英文:根目录为 `plan/`,跨模块目录为 `plan/cross-module/`,状态前缀为 `pending-` 和 `approved-`。 +- `` 和 `` 按语义决定中英文:稳定技术名词优先英文,如 `auth`、`permission`;更自然的业务表达可保留中文,如 `组织`、`菜单权限收口`。 +- 模块目录按首次使用时创建;当前已预留 `plan/cross-module/` 目录。 + +## 状态流转规则 + +- 第一步:先生成待审计划,文件名使用 `pending-.md`。 +- 第二步:在对话中回报计划路径和摘要,等待用户明确确认。 +- 第三步:确认后,将文件名改为 `approved-.md` 再执行。 +- 若执行中范围明显变化,必须新建新的 `pending-.md` 重新审查,禁止静默扩项。 + +## 计划模板 + +# 目标 + +# 范围 + +# 改动 + +# 验证 + +# 风险 + +# 执行顺序 + +# 待确认 + +## 示例路径 + +- `plan/auth/pending-login-auth-refactor.md` +- `plan/权限/pending-菜单权限收口.md` +- `plan/cross-module/pending-组织权限联调.md` diff --git a/plan/cross-module/.gitkeep b/plan/cross-module/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/plan/cross-module/.gitkeep @@ -0,0 +1 @@ + From 79a2f23893fefbeafcd0cdc7b82ac47a37b49f8f Mon Sep 17 00:00:00 2001 From: wang Date: Fri, 10 Apr 2026 21:56:39 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BA=86SSE=E5=BA=95?= =?UTF-8?q?=E5=B1=82=E5=9F=BA=E5=B1=82=E3=80=81=E5=AE=8C=E5=96=84=E4=BA=86?= =?UTF-8?q?AI=E5=AF=B9=E8=AF=9D=E7=9A=84=E6=A1=86=E6=9E=B6=E3=80=81?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BA=86=E4=B8=8B=E9=98=B6=E6=AE=B5=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=96=B9=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...257\345\257\271\346\216\245-go-eino-V1.md" | 28 + ...76\350\256\241\346\226\271\346\241\210.md" | 543 +++-- ...07\345\257\274\346\226\207\346\241\243.md" | 638 ++++++ docs/apifox/ai_assistant.openapi.json | 1819 +++++++++++++++++ flag/flagSql.go | 380 ++++ global/global.go | 4 + internal/controller/system/aiCtrl.go | 211 ++ internal/controller/system/supplier.go | 12 + internal/controller/system/supplierImpl.go | 179 ++ internal/controller/system/userCtrl.go | 71 + internal/core/config.go | 24 + internal/core/server.other.go | 2 +- internal/core/server.win.go | 2 +- internal/core/sse.go | 49 + internal/infrastructure/sse/authorizer.go | 98 + .../infrastructure/sse/backplane_pubsub.go | 206 ++ internal/infrastructure/sse/broker.go | 363 ++++ internal/infrastructure/sse/connection.go | 221 ++ internal/infrastructure/sse/handler.go | 91 + internal/infrastructure/sse/infrastructure.go | 67 + internal/infrastructure/sse/interfaces.go | 66 + internal/infrastructure/sse/replay_redis.go | 218 ++ internal/infrastructure/sse/time.go | 43 + internal/infrastructure/sse/types.go | 136 ++ internal/infrastructure/sse/writer.go | 312 +++ internal/infrastructure/sse/writer_test.go | 50 + internal/init/init.go | 3 + internal/middleware/corsMW.go | 31 +- internal/model/config/config.go | 41 + internal/model/config/sse.go | 15 + internal/model/dto/request/aiReq.go | 22 + internal/model/dto/response/aiResp.go | 175 ++ internal/model/entity/ai.go | 105 + .../repository/interfaces/aiRepository.go | 26 + internal/repository/system/aiRepo.go | 292 +++ internal/repository/system/supplier.go | 6 + internal/repository/system/supplierImpl.go | 384 ++++ internal/router/router.go | 33 +- internal/router/system/aiRouter.go | 56 + internal/router/system/enter.go | 1 + internal/service/contract/errors.go | 1 + internal/service/contract/system.go | 30 + internal/service/system/aiIntent.go | 75 + internal/service/system/aiMapper.go | 407 ++++ internal/service/system/aiRuntime.go | 115 ++ internal/service/system/aiRuntimeLocal.go | 491 +++++ .../service/system/aiRuntimeLocal_test.go | 183 ++ internal/service/system/aiSink.go | 242 +++ internal/service/system/aiSvc.go | 525 +++++ internal/service/system/supplier.go | 28 + internal/service/system/supplierImpl.go | 231 +++ pkg/errors/codes.go | 22 + .../approved-ai-a2ui-hybrid-assistant.md | 26 + ...50\351\207\212\350\241\245\345\205\250.md" | 36 + 54 files changed, 9115 insertions(+), 320 deletions(-) create mode 100644 "docs/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" create mode 100644 "docs/SSE\345\256\236\346\227\266\346\216\250\351\200\201\345\237\272\347\241\200\350\256\276\346\226\275\351\207\215\346\236\204\346\214\207\345\257\274\346\226\207\346\241\243.md" create mode 100644 docs/apifox/ai_assistant.openapi.json create mode 100644 internal/controller/system/aiCtrl.go create mode 100644 internal/core/sse.go create mode 100644 internal/infrastructure/sse/authorizer.go create mode 100644 internal/infrastructure/sse/backplane_pubsub.go create mode 100644 internal/infrastructure/sse/broker.go create mode 100644 internal/infrastructure/sse/connection.go create mode 100644 internal/infrastructure/sse/handler.go create mode 100644 internal/infrastructure/sse/infrastructure.go create mode 100644 internal/infrastructure/sse/interfaces.go create mode 100644 internal/infrastructure/sse/replay_redis.go create mode 100644 internal/infrastructure/sse/time.go create mode 100644 internal/infrastructure/sse/types.go create mode 100644 internal/infrastructure/sse/writer.go create mode 100644 internal/infrastructure/sse/writer_test.go create mode 100644 internal/model/config/sse.go create mode 100644 internal/model/dto/request/aiReq.go create mode 100644 internal/model/dto/response/aiResp.go create mode 100644 internal/model/entity/ai.go create mode 100644 internal/repository/interfaces/aiRepository.go create mode 100644 internal/repository/system/aiRepo.go create mode 100644 internal/router/system/aiRouter.go create mode 100644 internal/service/system/aiIntent.go create mode 100644 internal/service/system/aiMapper.go create mode 100644 internal/service/system/aiRuntime.go create mode 100644 internal/service/system/aiRuntimeLocal.go create mode 100644 internal/service/system/aiRuntimeLocal_test.go create mode 100644 internal/service/system/aiSink.go create mode 100644 internal/service/system/aiSvc.go create mode 100644 plan/cross-module/approved-ai-a2ui-hybrid-assistant.md create mode 100644 "plan/cross-module/approved-go-code-\347\224\237\344\272\247\347\272\247\344\270\255\346\226\207\346\263\250\351\207\212\350\241\245\345\205\250.md" diff --git "a/docs/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" "b/docs/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" new file mode 100644 index 0000000..32fecbc --- /dev/null +++ "b/docs/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" @@ -0,0 +1,28 @@ +# AI 助手后端对接说明(已并入主文档) + +本文档不再作为独立方案维护,正式内容已并入: + +- [AI助手架构设计方案.md](./AI助手架构设计方案.md) + +当前迁移结论固定如下: + +1. V1 从首版开始采用 go-eino `Interrupt / Resume + Checkpoint`,不再使用“业务 Service 管理待确认状态 + 第二条 SSE 流”的旧方案。 +2. 顶层前端协议继续采用业务 SSE 事件流,不切换到纯 A2UI 协议。 +3. AI 回复区的正式可见内容只保留四类: + - 思考摘要 + - 工具意图 + - 等待用户 + - 最终正文 +4. `最终正文` 以 Markdown 为唯一正式答案载体;任务卡、进度卡、文档卡不再作为正式用户可见协议。 +5. 工具执行记录继续保留在 `trace_items` 中,但只作为 `工具意图` 内的折叠记录,不再作为独立可见模块。 +6. `scope` 只作为可选上下文元数据保留,普通同上下文对话默认不展示。 +7. 正式接口固定为: + - `POST /ai/conversations` + - `GET /ai/conversations` + - `GET /ai/conversations/{id}/messages` + - `DELETE /ai/conversations/{id}` + - `POST /ai/conversations/{id}/stream` + - `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` +8. 旧的工具续跑流接口全量废弃。 + +若后续需要补充实现细节、OpenAPI 变更或前后端联调约束,只更新主文档,不再回写本文件。 diff --git "a/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" index f262209..1ad5ce1 100644 --- "a/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" +++ "b/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" @@ -2,404 +2,343 @@ ## 1. 文档定位 -本文档用于定义 `personal_assistant` 中 AI 助手子域的正式设计方案,目标不是做一个独立的 AI demo,而是在现有业务系统中落地一套可演示、可扩展、可面试表达的 AI 应用能力。 +本文档是 `personal_assistant` AI 子域的唯一正式方案文档,用于统一以下内容: -本文档主要用于: +1. 业务定位与分层边界。 +2. Go + Eino 的运行时基线。 +3. 前端真实协议、SSE 事件和消息模型。 +4. AI 回复区的四类可见内容规则。 +5. OpenAPI / Apifox 对外契约。 +6. V1 验收标准。 -1. 明确 AI 子域为什么放在 `personal_assistant` 内,而不是新开主项目。 -2. 固定 V1 的范围、边界、接口、交互和验收标准。 -3. 为后续实现提供可直接执行的分阶段计划。 -4. 让项目评审时能够同时看到 Go 后端工程能力与 Eino AI 编排能力。 +旧文档 [AI助手后端对接-go-eino-V1.md](./AI助手后端对接-go-eino-V1.md) 只保留迁移说明,不再维护独立结论。 -适用范围: +项目级 SSE 连接层、回放层、跨节点分发、安全与运维基线,统一以 [SSE实时推送基础设施重构指导文档.md](./SSE实时推送基础设施重构指导文档.md) 为准;本文档只保留 AI 子域协议、运行时和验收结论。 -- `go/personal_assistant` -- `personal-assistant-frontend` -- `go/personal_assistant/docs` +## 2. 真相源与案例基线 ---- +本方案固定以下 5 类真相源: -## 2. 设计结论 +1. `z_cur/UI/src/types/assistant.types.ts` +2. `z_cur/UI/src/stores/assistant.ts` +3. `z_cur/UI/src/components/business/Assistant/**` +4. `z_cur/Eino/eino-examples/quickstart/chatwitheino/docs/ch07_interrupt_resume.md` +5. `z_cur/Eino/eino-examples/quickstart/chatwitheino/docs/ch10_a2ui.md` -### 2.1 项目形态 +结论解释: -AI 功能固定作为 `personal_assistant` 的正式子域落地,不新开独立主项目。 +1. 前端真实协议和消息模型,以 `z_cur/UI` 现有实现为准。 +2. Eino 运行时基线,以 `Interrupt / Resume + Checkpoint` 案例为准。 +3. A2UI 只借鉴适合声明式渲染的部分,不直接照搬 `ch10` 的顶层协议。 -原因如下: +## 3. 设计结论 -1. AI 助手的核心价值不在“会聊天”,而在“能消费本系统真实业务上下文”。 -2. 当前系统已经具备任务、执行详情、用户状态、组织、权限、排行、项目文档等完整上下文,AI 应直接建立在这些正式能力之上。 -3. 如果单独拆仓,前期会人为切断业务上下文,反而弱化项目表达。 -4. 当前 `crawler` 之所以可独立,是因为它是外部采集基础设施;AI 子域不是基础设施,它是业务能力的上层消费层。 +### 3.1 项目定位 -### 2.2 面试定位 +AI 助手是 `personal_assistant` 的正式业务子域,不独立拆仓,不做脱离业务上下文的 demo。 -该子域同时服务两类表达: +V1 固定提供 4 类能力: -1. Go 后端/架构表达:分层、DTO、资源级鉴权、SSE、会话持久化、可观测、与现有系统整合。 -2. AI 应用/Agent 表达:Eino、Tool 调用、Memory、Workflow、ChatModelAgent、结构化输出。 +1. 我的任务汇报。 +2. 指定范围任务汇总。 +3. 用户进度分析。 +4. 正式项目文档问答。 -V1 不追求一次性把所有 AI 概念堆满,而是先做一条完整且可信的闭环。 +### 3.2 Eino 运行时结论 -### 2.3 V1 功能定位 +V1 从首版开始就把 `Interrupt / Checkpoint` 作为必选能力,不采用“业务 Service 自己维护待确认状态,再发起第二条 SSE 流”的旧方案。 -V1 固定定位为“业务助手 + 项目说明问答”,支持以下 4 类能力: +固定运行时如下: -1. 根据当前用户对话,生成本次任务完成情况或任务汇报。 -2. 根据指定任务 / 组织 / 人员范围,生成汇总说明。 -3. 根据用户做题、排名、进度,给出阶段性分析和建议。 -4. 根据正式项目文档,回答“这个项目是什么、怎么使用、有哪些模块”。 +1. `ChatModelAgent` 负责模型与 Tool 调度。 +2. `Runner` 负责执行与恢复。 +3. `Approval / Interrupt` 负责人工确认节点。 +4. `CheckPointStore` 负责运行时恢复点。 +5. 业务 Service 负责权限、会话、消息、SSE 事件映射与持久化收口。 ---- +### 3.3 协议结论 -## 3. 总体架构 +前端协议采用“业务事件流 + 内嵌 A2UI block”的混合模型,不切换到纯 A2UI 顶层协议。 -### 3.1 架构原则 +固定原则: -1. AI 只能消费正式业务能力,不能绕过现有业务边界直接散查数据库。 -2. 会话与消息必须持久化,不能只做临时流式输出。 -3. 业务数据权限必须先于模型回答,不能靠提示词“假装隔离”。 -4. 对用户来说,前端必须是正式产品体验,不接受 demo 式页面。 -5. 对面试官来说,系统必须可解释:请求如何进来、工具如何调用、结果如何流回、权限如何生效、数据如何落库。 +1. 顶层 SSE 仍是业务事件协议。 +2. `A2UI` 只作为 `structured_block.ui_block` 的渲染载荷出现。 +3. `trace_items` 与 `scope` 继续是恢复与上下文真相字段。 +4. `content` 是唯一正式最终答案正文。 +5. `ui_blocks` 只承载“思考摘要 / 工具意图 / 等待用户”三类可见结构化块。 -### 3.2 后端分层 +### 3.4 单流结论 -后端继续沿用当前仓库分层: - -1. `controller` - 负责 HTTP 绑定、SSE 输出、错误返回、上下文提取。 -2. `service` - 负责 AI 编排、鉴权收口、工具调度、会话写入。 -3. `repository` - 负责 AI 会话、消息、可选执行日志的持久化。 -4. `infrastructure` - 负责模型适配器、Eino 组件初始化、文档加载器。 -5. `router` - 负责统一注册 AI 路由,挂入业务分组。 - -### 3.3 前端分层 - -前端固定挂入 `Workbench` 子路由,不新建独立站点。 - -页面结构固定为: - -1. 左侧:会话列表。 -2. 中间:聊天主区域。 -3. 右侧:工具调用轨迹 / 结构化结果。 -4. 移动端:左侧会话折叠,主聊天区全宽。 - ---- - -## 4. 后端方案 - -### 4.1 路由与接口 - -新增接口固定如下: - -1. `POST /ai/conversations` - 创建会话。 -2. `GET /ai/conversations` - 查询当前用户的会话列表。 -3. `GET /ai/conversations/:id/messages` - 查询会话消息历史。 -4. `DELETE /ai/conversations/:id` - 删除会话。 -5. `POST /ai/conversations/:id/stream` - 发送消息并以 `text/event-stream` 流式返回。 - -### 4.2 SSE 事件协议 - -流式事件固定为: - -1. `conversation_started` -2. `assistant_token` -3. `tool_call_started` -4. `tool_call_finished` -5. `structured_block` -6. `message_completed` -7. `error` -8. `done` - -这样前端可以明确区分“生成中”“调用工具中”“已完成”,避免全部混成一坨文本。 - -### 4.3 会话持久化 - -会话真相源固定使用 MySQL: - -1. 会话表:保存用户、标题、当前组织上下文、最后活跃时间。 -2. 消息表:保存消息角色、内容、结构化结果摘要、失败状态。 -3. Redis 只做可选缓存或中间状态,不作为唯一真相源。 - -### 4.4 Agent 设计 - -V1 固定采用“单 `ChatModelAgent` + 外层 Workflow”的形式,不上多 Agent。 - -执行链路固定为: - -1. 读取登录用户与当前组织上下文。 -2. 加载最近 N 轮会话历史。 -3. 分类用户问题类型。 -4. 选择并调用对应 Tool。 -5. 将 Tool 结果组织为自然语言 + 结构化块。 -6. 流式回传。 -7. 写入消息与执行日志。 +V1 固定为“单条聊天 SSE 流 + 独立控制接口”: -### 4.5 Tool 设计 +1. `POST /ai/conversations/{id}/stream` + 开启唯一聊天事件流。 +2. `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` + 只提交确认决策,不返回第二条 SSE 流。 -V1 固定实现 4 个 Tool: +运行语义: -1. `get_my_task_report` - 查询当前用户维度的任务与执行情况。 -2. `get_scoped_task_report` - 查询指定任务 / 组织 / 人员范围内的汇总。 -3. `get_user_progress_insight` - 查询用户刷题、排行、趋势并生成建议。 -4. `search_project_docs` - 查询正式项目说明文档并返回引用内容摘要。 - -所有 Tool 必须通过现有 Service 或新增只读 Facade 调用,不允许直接在 Tool 内部散落 SQL。 - -### 4.6 权限边界 - -权限规则固定如下: - -1. 默认范围为“当前登录用户 + 当前组织上下文”。 -2. 查询其他用户、其他组织、任务管理视角数据时,必须走现有资源级鉴权。 -3. 超级管理员可以跨组织查询,但回答中必须标明 scope。 -4. 文档问答只允许读取正式文档白名单。 - -### 4.7 文档知识源 - -V1 文档问答只纳入以下材料: - -1. `README.md` -2. 正式业务设计文档 -3. API / 架构 / 使用说明类文档 - -明确排除: - -1. `docs/csdn/**` -2. 学习型 Eino 笔记 -3. `下阶段进阶.md` 这类路线草稿 - -原因是这些内容不稳定,不能作为产品事实输出。 - -### 4.8 可观测性 - -AI 子域必须具备可观测能力: - -1. 每次请求带 `request_id`。 -2. 记录模型调用耗时。 -3. 记录 Tool 调用名称、参数摘要、耗时、成功失败。 -4. 支持把一轮问答串进现有 trace 体系。 -5. 异常时可以定位到“用户请求 -> Agent -> Tool -> 业务 Service”。 +1. 命中 interrupt 后,`stream` 不结束业务轮次,只进入等待确认阶段。 +2. 服务端持续保活同一条 SSE 连接。 +3. 前端调用 decision 接口后,服务端在原流内 `Resume` 并继续输出后续事件。 +4. V1 不承诺“中途断流后重新附着到同一轮运行”;断流即本轮失败或停止。 ---- +## 4. 总体架构 -## 5. 前端方案 +### 4.1 后端分层 -### 5.1 页面定位 +后端继续沿用当前仓库分层: -AI 页面固定作为 `Workbench` 下的正式页面,例如: +1. `controller` + 负责 HTTP 绑定、SSE 写出、上下文提取、错误返回。 +2. `service` + 负责会话编排、Agent 调用、权限收口、协议映射、落库。 +3. `repository` + 负责会话、消息、审计、待确认记录的持久化。 +4. `infrastructure` + 负责模型适配器、Eino 初始化、CheckpointStore、文档加载器。 +5. `router` + 负责把 AI 路由挂入登录态业务分组。 -1. `/console/workbench/task` -2. `/console/workbench/assistant` +### 4.2 数据真相源 -这样可以与任务工作台形成直接关联,强调 AI 是业务增强层,而不是外置插件。 +V1 固定两类存储: -### 5.2 用户体验目标 +1. MySQL + 会话、消息、审计记录的业务真相源。 +2. Redis + `CheckPointStore` 与运行时 interrupt / resume 所需的状态存储。 -前端体验目标固定为“丝滑、稳定、可恢复、可续聊”。 +Redis 不是业务消息真相源;消息历史仍以 MySQL 为准。 -必须满足: +### 4.3 权限边界 -1. 首屏不能白屏等接口。 -2. 发送后立即出现用户消息与“生成中”状态。 -3. 流式输出过程中自动滚动,但用户上滑查看历史时不能强行拉回底部。 -4. 支持停止生成、失败重试、删除会话、切换会话。 -5. 移动端必须可用,不能只适配桌面。 +权限必须先于模型回答,而不是靠提示词兜底。 -### 5.3 聊天区交互 +固定规则: -聊天输入区固定支持: +1. 默认范围是“当前登录用户 + 当前组织”。 +2. 查询他人、跨组织、管理视角数据时,必须先走现有资源级鉴权。 +3. 文档问答只允许读取正式白名单文档。 +4. 越权请求在 Service 层直接拒绝,不把敏感数据交给模型。 -1. `Enter` 发送。 -2. `Shift + Enter` 换行。 -3. 自动增高文本框。 -4. 发送中按钮切换为“停止生成”。 -5. 失败时显示内联重试入口。 +## 5. AI 回复协议 -### 5.4 结构化展示 +### 5.1 四类可见内容 -AI 输出不允许全部裸文本化,必须支持结构化卡片: +单条 assistant 消息只允许出现以下四类可见内容: -1. 任务汇报:摘要、完成率、风险项、建议动作。 -2. 排名分析:当前排名、阶段变化、改进建议。 -3. 项目说明:模块说明、使用路径、关联功能。 -4. 工具轨迹:当前查了什么、完成了什么、耗时多久。 +1. 思考摘要 + 用于展示阶段性判断、当前动作、等待原因和下一步,不泄露原始长推理。 +2. 工具意图 + 用于说明为什么要调用工具、调用后会得到什么、是否需要确认。 +3. 等待用户 + 用于明确当前轮次为什么暂停、需要用户确认什么、确认后会发生什么。 +4. 最终正文 + 用于承载正式回答,是唯一正式结果正文。 -### 5.5 空态与引导 +### 5.2 消息模型 -首屏空态固定展示推荐问题,例如: +一条 assistant 消息不是单纯字符串,而是以下组合: -1. “帮我总结最近一次任务完成情况” -2. “统计当前组织最近一个任务的完成情况” -3. “分析我最近刷题状态并给建议” -4. “这个项目主要做什么,怎么使用” +1. `content` + Markdown 最终正文;`assistant_token` 和 `message_completed.content` 只承载它。 +2. `trace_items` + 工具执行记录与恢复真相。 +3. `ui_blocks` + 结构化可见块,只允许: + - `thinking_summary_block` + - `tool_intent_block` + - `waiting_user_block` +4. `scope` + 可选上下文元数据;仅在复杂范围、跨用户、跨组织或带文档白名单时返回,但默认不单独展示。 +5. `status / error_text` + 消息过程态与错误态。 -目的不是做花哨引导,而是降低用户不会问的成本。 +历史消息返回时,后端应优先补齐 `ui_blocks`,并保留 `trace_items / scope` 以支持状态恢复。 -### 5.6 错误反馈 +### 5.3 渲染规则 -前端错误提示固定采用“页面内联为主、全局 toast 为辅”: +前端固定按以下逻辑渲染单条 assistant 消息: -1. 权限不足:明确说明无权查看该范围。 -2. 会话流断开:提供重试。 -3. Tool 失败:显示“查询过程失败”,而不是简单报错。 -4. 网络异常:允许保留输入内容并再次提交。 +1. 思考摘要 +2. 工具意图 +3. 等待用户 +4. 最终正文 ---- +补充规则: -## 6. 分阶段实施计划 +1. 问候语、寒暄、感谢和无业务目标的短消息,只展示最终正文。 +2. 工具执行记录只作为 `工具意图` 内的折叠执行记录存在,不再作为独立可见模块。 +3. 等待用户块只负责说明暂停点;真正可点击的确认按钮仍固定放在消息列表下方、输入框上方的独立操作条。 +4. 用户在等待期间输入新消息时,以新消息轮次为最高优先级;旧等待态保留为历史,但不再继续假设后续结果。 -### Phase 1:方案落文档 +### 5.4 SSE 事件 -目标: +顶层事件固定保留以下 10 个: -1. 完成架构设计文档。 -2. 固定 API、Tool、会话模型、SSE 协议。 -3. 固定前端信息架构和交互标准。 +1. `conversation_started` +2. `assistant_token` +3. `tool_call_started` +4. `tool_call_finished` +5. `tool_call_waiting_confirmation` +6. `tool_call_confirmation_result` +7. `structured_block` +8. `message_completed` +9. `error` +10. `done` -验收: +其中: -1. 设计文档可直接指导开发。 -2. 不再需要对“拆不拆项目”“先做什么”反复决策。 +1. `tool_call_waiting_confirmation` payload 必须带 `interrupt_id`。 +2. `structured_block` 允许承载 `scope / ui_block`。 +3. `tool_call_confirmation_result` 表示“决策已受理并已恢复或跳过”,而不是第二条流的起点。 +4. `assistant_token` 只能用于流式输出最终正文,不能混入结论壳、指标壳或等待提示。 -### Phase 2:后端骨架 +## 6. 局部 A2UI 设计 -目标: +### 6.1 适用边界 -1. 建立 AI 子域路由、DTO、Service、Repository 骨架。 -2. 接入模型配置与 Eino 初始化。 -3. 打通会话创建、历史查询、SSE 空流骨架。 +| 分类 | 是否采用 A2UI | 结论 | +| --- | --- | --- | +| 思考摘要 | 是 | 适合声明式展示阶段性判断 | +| 工具意图 | 是 | 适合声明式展示目的、必要性、收益与确认要求 | +| 等待用户 | 是 | 适合声明式展示暂停原因和下一步 | +| 最终正文 | 否 | 继续使用 Markdown 作为唯一正式结果正文 | +| 会话列表 | 否 | 属于业务页面壳层 | +| 主布局 | 否 | 属于页面壳层,不应协议化 | +| 权限判断 | 否 | 属于业务规则,不应协议化 | +| 工具确认状态机 | 否 | 属于业务状态机与 interrupt 运行时 | +| SSE 协议 | 否 | 继续采用业务事件协议 | +| 消息持久化 | 否 | 继续以业务字段为真相 | -验收: +### 6.2 A2UI 子集 -1. 可以创建会话。 -2. 可以向指定会话发起流式请求。 -3. 可以把消息落库。 +基础布局组件固定为: -### Phase 3:Tool 闭环 +1. `Text` +2. `Row` +3. `Column` +4. `Card` -目标: +业务扩展组件固定为: -1. 接入 4 个 Tool。 -2. 完成权限裁剪。 -3. 让 Agent 能根据问题调用 Tool 并返回结构化结果。 +1. `Badge` +2. `BulletList` -验收: +Block 类型固定为: -1. 能回答个人任务汇报。 -2. 能回答组织/任务范围汇总。 -3. 能回答用户进度建议。 -4. 能回答项目说明问答。 +1. `thinking_summary_block` +2. `tool_intent_block` +3. `waiting_user_block` -### Phase 4:前端正式页面 +### 6.3 责任边界 -目标: +必须固定以下责任边界: -1. 完成 Workbench 下 AI 助手页面。 -2. 接入会话列表、聊天区、工具轨迹区。 -3. 打通流式渲染与失败恢复。 +1. `trace_items` 负责工具执行记录与恢复真相。 +2. `tool_intent_block` 只负责解释为什么需要工具,以及这轮工具调用的意图和收益。 +3. `waiting_user_block` 只负责表达暂停原因和等待点。 +4. `thinking_summary_block` 只承载“当前判断 / 当前动作 / 等待原因 / 下一步”的摘要,不是原始模型推理全文。 +5. `scope` 是元数据,不是默认可见内容。 -验收: +## 7. 后端实现方案 -1. 可创建、切换、删除、续聊。 -2. 流式体验稳定。 -3. 桌面端与移动端都可用。 +### 7.1 路由 -### Phase 5:可观测与演示打磨 +AI 接口固定为 6 个: -目标: +1. `POST /ai/conversations` +2. `GET /ai/conversations` +3. `GET /ai/conversations/{id}/messages` +4. `DELETE /ai/conversations/{id}` +5. `POST /ai/conversations/{id}/stream` +6. `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` -1. 接入 trace、日志、耗时指标。 -2. 补齐测试。 -3. 编写演示脚本。 +### 7.2 Service 责任 -验收: +AI Service 必须承担以下职责: -1. 面试时能完整展示一轮请求的链路。 -2. 能清楚讲明分层、Tool、鉴权、SSE、会话持久化和前端交互。 +1. 会话 CRUD 与消息历史装载。 +2. 用户 / 组织 /任务 / 文档范围裁剪。 +3. Tool 选择与参数装配。 +4. Eino Agent / Runner 调用。 +5. Interrupt 命中后的运行时恢复。 +6. SSE 事件映射。 +7. 会话、消息与审计落库。 ---- +另外固定一条产品侧门控规则: -## 7. 测试与验收 +1. 问候语、寒暄、感谢和无业务目标的短消息,直接走轻量直答路径。 +2. 只有进入任务分析、进度分析、范围汇总、文档问答这类业务意图时,才进入重型工具链路。 +3. `scope` 默认不前台展示;只有确实存在复杂范围时才回传。 -### 7.1 后端测试 +### 7.3 Tool 约束 -1. Tool 参数校验。 -2. 权限拒绝路径。 -3. 会话与消息持久化。 -4. SSE 事件顺序。 -5. Tool 成功 / 失败回包。 -6. 文档白名单过滤。 +V1 固定 4 类 Tool: -### 7.2 前端测试 +1. `get_my_task_report` +2. `get_scoped_task_report` +3. `get_user_progress_insight` +4. `search_project_docs` -1. 首屏空态展示。 -2. 发问流式返回。 -3. 会话切换。 -4. 停止生成。 -5. 失败重试。 -6. 移动端抽屉切换。 +约束固定如下: -### 7.3 演示验收场景 +1. Tool 不直接散落 SQL。 +2. Tool 统一通过现有 Service 或只读 Facade 获取数据。 +3. 文档类 Tool 只能访问正式白名单文档。 +4. Tool 输出必须能映射成 `trace_items`,并驱动 `tool_intent_block / waiting_user_block / 最终正文`。 -固定以 4 个场景验收: +## 8. OpenAPI / Apifox 结论 -1. “帮我总结这次任务完成情况” -2. “汇总某组织某任务的完成情况” -3. “分析某用户最近刷题状态并给建议” -4. “这个项目有什么用,怎么使用” +Apifox 契约以两份文件同步维护: ---- +1. `z_cur/UI/docs/apifox/ui-module.openapi.json` +2. `go/personal_assistant/docs/apifox/ai_assistant.openapi.json` -## 8. 关键默认决策 +两者必须保持一致,固定约束如下: -1. AI 子域不独立拆仓。 -2. V1 不做 Multi-Agent。 -3. V1 不做向量库和复杂 RAG。 -4. V1 先做业务助手与文档问答。 -5. 前端必须作为正式用户入口设计,不接受 demo 式页面。 -6. 模型供应商可替换,但域层不写死厂商。 -7. 只借鉴成熟项目的产品思路与交互,不整仓翻译其他语言项目。 +1. CRUD 与 decision 接口走 JSON BizResponse。 +2. `stream` 接口只输出 `text/event-stream`。 +3. 不再出现旧的工具续跑流接口描述。 +4. `thinking_summary_block / tool_intent_block / waiting_user_block / interrupt_id` 都必须进入 schema。 +5. SSE 示例必须体现“原流等待 + decision 接口控制 + 原流继续输出”。 +6. 文档必须明确:`content` 是唯一正式最终答案正文;`trace_items / scope` 是恢复与上下文字段,不是默认可见内容。 ---- +## 9. V1 验收标准 -## 9. 后续扩展方向 +### 9.1 协议验收 -V2 可以考虑扩展: +1. 主文档已完全切到“四类可见内容”心智。 +2. `tool_call_waiting_confirmation` 与 `tool_call_confirmation_result` 都带 `interrupt_id`。 +3. 历史消息能重建 `content / trace_items / ui_blocks / scope`。 +4. 不再出现任务卡、进度卡、文档卡和独立工具轨迹块。 -1. Embedding / Retriever / Indexer 接入正式知识库。 -2. 更复杂的 Workflow。 -3. 多 Agent 协作。 -4. 管理员级批量分析视图。 -5. A2UI 或更强的结构化流式组件协议。 +### 9.2 前端验收 -但这些全部放在 V1 闭环稳定之后,不提前抢跑。 +1. `structured_block.ui_block` 只渲染 3 类 block。 +2. 问候语与寒暄类短消息走轻量直答路径,只出现最终正文。 +3. 复杂问题先出现思考摘要,再按需要出现工具意图与等待用户。 +4. 工具执行记录默认折叠在工具意图内部,不再单独占区。 +5. 等待确认时,真正交互入口只保留底部独立确认条。 +6. 用户在等待期间输入新消息时,新消息轮次优先,旧轮次停止等待。 +7. decision 提交后不再开启第二条流,原流继续完成。 ---- +### 9.3 后端验收 -## 10. 总结 +1. 首版即采用 `Interrupt / Resume + Checkpoint`。 +2. `stream` 事件顺序正确,keepalive 不影响前端解析。 +3. decision 接口只提交控制命令,不直接返回 SSE。 +4. `interrupt_id` 与会话归属、权限、当前运行实例关系校验正确。 -这次 AI 子域建设的核心,不是“把 Eino 接进来”,而是把现有 `personal_assistant` 的正式业务能力,通过 Tool、Agent、会话、SSE 和前端工作台,组织成一个既能直接面对用户、又能对面试官清楚讲明白的完整系统。 +## 10. 后续扩展边界 -V1 的判断非常明确: +V2 可以考虑: -1. 不拆主项目。 -2. 先做单 Agent。 -3. 先做 4 个高价值 Tool。 -4. 先把产品体验做顺。 -5. 先把工程闭环做完整。 +1. 更完整的知识库索引与检索。 +2. 更复杂的 Workflow / Multi-Agent。 +3. 断流后附着到同一轮运行的恢复能力。 +4. 更通用的 A2UI 协议抽象。 -在这个基础上,再谈更复杂的 Agent 能力,项目才是稳的。 +这些都建立在 V1 四类可见内容、单流恢复和业务闭环稳定之后,不提前抢跑。 diff --git "a/docs/SSE\345\256\236\346\227\266\346\216\250\351\200\201\345\237\272\347\241\200\350\256\276\346\226\275\351\207\215\346\236\204\346\214\207\345\257\274\346\226\207\346\241\243.md" "b/docs/SSE\345\256\236\346\227\266\346\216\250\351\200\201\345\237\272\347\241\200\350\256\276\346\226\275\351\207\215\346\236\204\346\214\207\345\257\274\346\226\207\346\241\243.md" new file mode 100644 index 0000000..4dd6acf --- /dev/null +++ "b/docs/SSE\345\256\236\346\227\266\346\216\250\351\200\201\345\237\272\347\241\200\350\256\276\346\226\275\351\207\215\346\236\204\346\214\207\345\257\274\346\226\207\346\241\243.md" @@ -0,0 +1,638 @@ +# 项目级 SSE 实时推送基础设施重构指导文档 + +## 1. 文档定位 + +本文档是 `personal_assistant` 的项目级实时推送基础设施设计标准,用于统一以下内容: + +1. SSE 子系统的职责边界与模块分层。 +2. 单机连接管理与多实例事件分发的正式心智。 +3. AI 子域、排行榜实时刷新、任务/审批通知等场景的统一接入方式。 +4. 安全、稳定性、性能、可观测性与运维基线。 +5. 后续代码重构、测试验收与上线治理的统一标准。 + +本文档不是 AI 子域协议文档,也不是某个单独 handler 的实现说明。 + +固定边界如下: + +1. AI 子域的业务协议、SSE 事件语义、interrupt / resume 运行时约束,仍以 [AI助手架构设计方案.md](./AI助手架构设计方案.md) 为准。 +2. 双 Token、Cookie、CORS 相关认证基线,仍与 [双Token认证方案-整合版.md](./双Token认证方案-整合版.md) 保持一致。 +3. 多实例事件承载与一致性链路,继续复用项目现有的 Redis Stream + Outbox + Pub/Sub 基础设施心智,参见 [事件驱动架构-RedisStream-Outbox-双通道一致性实践.md](./事件驱动架构-RedisStream-Outbox-双通道一致性实践.md)。 + +## 2. 反例基线与重构动机 + +本次设计明确以 `z_cur/zhixin-master` 当前 SSE 实现作为反例基线,而不是复用其实现。 + +当前反例代码主要集中在: + +1. `z_cur/zhixin-master/pkg/sse/sse.go` +2. `z_cur/zhixin-master/api/front/sse/sse.go` +3. `z_cur/zhixin-master/api/front/route.go` +4. `z_cur/zhixin-master/api/front/user/login.go` +5. `z_cur/zhixin-master/tables/members.go` +6. `z_cur/zhixin-master/pages/index2.go` + +这套实现存在的核心问题不是“语法旧”或“缺少注释”,而是模型本身不适合作为生产级基础设施继续放大使用: + +1. `Hub.Clients` 被多个 goroutine 并发读写,连接注册与消息发送不在同一个受保护的并发域内,存在 `concurrent map read and map write` 风险。 +2. 同一条连接会被多个 goroutine 并发写 `gin.ResponseWriter`,SSE 帧可能交叉、脏写、丢帧或触发 race。 +3. 鉴权依赖 `query token`,会把敏感凭证暴露到浏览器历史、代理日志、监控与埋点链路。 +4. 已建立连接缺乏主动撤销机制,登出或 token 拉黑后,服务端不会主动收回旧连接。 +5. 心跳、`retry`、代理 buffering、写超时、慢客户端淘汰、drain 关闭等生产级问题没有被正式建模。 +6. 连接状态、消息顺序、消息投递、回放能力与测试基线都没有形成明确的系统设计。 + +因此本次重构的目标不是“修一个更安全的 handler”,而是把 SSE 从零散写法提升为正式的项目级长连接流式子系统。 + +## 3. 设计目标与非目标 + +### 3.1 设计目标 + +本次 SSE 基础设施固定追求以下 8 个目标: + +1. 项目级复用:同一套基础设施同时服务 AI 子域、排行榜刷新、通知推送与后续实时业务。 +2. 多实例优先:首版即按多节点部署与跨节点事件分发设计,而不是后补。 +3. 并发安全:连接注册表、消息投递、Writer 写出必须具备可证明的并发边界。 +4. 安全默认值:不接受 query token,连接鉴权、订阅授权、撤销回收都必须是默认能力。 +5. 稳定输出:代理超时、空闲断连、慢客户端、优雅关闭等问题必须显式建模。 +6. 高性能:广播不允许因慢连接拖垮整体吞吐,单个连接不能把整个发送链路反压死。 +7. 可观测:连接数、丢弃数、重放数、回收数、写超时、鉴权失败都必须能被观测。 +8. 与现有仓库一致:遵守当前分层、配置外置、Outbox、Redis Stream、JWT 与观测体系。 + +### 3.2 非目标 + +本次设计同时固定以下非目标,避免后续实现时盲目扩张: + +1. 不把连接状态存进 Redis 或数据库,连接始终是节点本地资源。 +2. 不把 AI token 级流式碎片写入 durable replay 源。 +3. 不承诺 AI `session stream` 在断流后重新附着回同一轮运行,V1 仍保持“断流即本轮停止”。 +4. 不把 SSE 设计成双向通信协议,客户端主动写入仍走普通 HTTP JSON 接口。 +5. 不要求仓库所有实时场景都立刻接入 durable replay;是否 durable 由场景分类决定。 + +## 4. 总体架构 + +### 4.1 四层子系统 + +项目级 SSE 子系统固定拆成 4 层: + +1. `HTTP Stream Handler` + 负责 HTTP 绑定、响应头、上下文生命周期、请求体解析、`Last-Event-ID` 读取、连接建立与结束。 +2. `Local Connection Broker` + 负责本机连接注册、注销、连接索引、按 `channel / subject` 投递、慢客户端淘汰、连接统计。 +3. `Replay Store` + 负责 durable 事件追加、断线回放、回放窗口判断、缺口修复与顺序恢复。 +4. `Cross-Node Backplane` + 负责跨节点实时广播与撤销通知,标准实现对接 Redis Pub/Sub。 + +固定禁令如下: + +1. 不能把连接管理、回放、鉴权、业务事件源混写在一个 `Hub` 或一个 handler 内。 +2. 不能让业务层直接持有 `ResponseWriter` 或直接向连接写字节。 +3. 不能把“本机连接表”和“跨节点消息总线”混成一个概念。 + +### 4.2 架构心智 + +```mermaid +flowchart LR + A["Controller / Stream Handler"] --> B["Local Connection Broker"] + B --> C["Per-Connection Writer Loop"] + D["Service"] --> E["Outbox / Domain Event"] + E --> F["Redis Stream (durable replay)"] + E --> G["Redis Pub/Sub (realtime notify)"] + F --> H["Replay Store"] + G --> I["Backplane Consumer"] + I --> B + H --> A +``` + +### 4.3 仓库落点建议 + +为避免职责散落,建议后续实现采用以下目录边界: + +1. `internal/infrastructure/sse` + 存放 `StreamWriter`、`ConnectionBroker`、`ReplayStore`、`Backplane`、策略对象与具体实现。 +2. `internal/controller/system` + 只保留业务入口、HTTP 绑定、鉴权接入与 SSE handler 适配。 +3. `internal/service/system` + 只产出业务事件、会话状态与 channel 语义,不感知底层连接写法。 +4. `internal/infrastructure/messaging` + 继续复用现有 Redis Stream / Pub/Sub 能力,不重复造轮子。 +5. `pkg/observability`、`pkg/jwt`、`pkg/ratelimit` + 继续承接链路观测、鉴权上下文与限流能力。 + +## 5. 两类流模型 + +项目内的 SSE 不再被视为单一类型,而是强制分成两类。 + +| 维度 | `session stream` | `channel stream` | +| --- | --- | --- | +| 典型场景 | AI 单轮对话、interrupt 等待与恢复 | 排行榜刷新、状态通知、审批广播 | +| 路由形态 | `POST + text/event-stream` | `GET + text/event-stream` 或带请求体的 `POST + text/event-stream` | +| 连接归属 | 当前请求、当前节点本地 | 当前订阅者连接、本地维护、跨节点分发 | +| durable replay | 默认不做 | 正式支持 | +| 事件来源 | 当前请求上下文内的运行时事件 | 业务事件、Outbox、Redis Stream | +| Backplane | 不参与主链路 | 正式参与 | +| 断流语义 | 本轮停止,不附着恢复 | 客户端可凭 `Last-Event-ID` 回放缺失事件 | +| 代表模块 | AI 助手 | 排行榜、任务通知、审批提醒 | + +固定结论如下: + +1. AI 子域只复用 `StreamWriter`、keepalive、错误/完成语义和中断等待模型,不复用排行榜类的 replay / backplane 语义。 +2. 排行榜、通知、状态刷新等 `channel stream` 必须具备 durable replay 能力,不能只靠内存广播。 +3. 不允许再用“同一套 hub 同时支撑 AI token 流与跨节点广播”的混合设计。 + +## 6. 正式接口与类型草案 + +### 6.1 统一事件信封 + +所有进入 SSE 子系统的业务事件统一抽象为 `StreamEvent`,避免各模块各自拼装帧文本。 + +```go +type StreamKind string + +const ( + StreamKindSession StreamKind = "session" + StreamKindChannel StreamKind = "channel" +) + +type StreamEvent struct { + EventID string `json:"event_id"` + StreamKind StreamKind `json:"stream_kind"` + Channel string `json:"channel"` + TenantID uint64 `json:"tenant_id"` + SubjectID uint64 `json:"subject_id"` + EventName string `json:"event_name"` + Data []byte `json:"data"` + OccurredAt time.Time `json:"occurred_at"` + RetryMS int64 `json:"retry_ms"` + Durable bool `json:"durable"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + Meta map[string]string `json:"meta,omitempty"` +} +``` + +字段规则固定如下: + +1. `EventID` 是正式事件编号;durable 事件必须可排序或可比较。 +2. `StreamKind` 决定事件是否允许进入 replay / backplane。 +3. `Channel` 是业务订阅维度,不等于用户 ID,也不直接暴露底层 Redis key。 +4. `TenantID` 与 `SubjectID` 用于授权、投递裁剪与审计。 +5. `Durable=true` 的事件必须可以被回放;AI token 级碎片默认 `Durable=false`。 + +### 6.2 连接级策略 + +连接行为统一通过 `ConnectionPolicy` 管理,不允许把 20 秒心跳、64 队列这类值散落在实现里。 + +```go +type ConnectionPolicy struct { + HeartbeatInterval time.Duration + WriteTimeout time.Duration + QueueCapacity int + MaxConnectionsPerSubject int + ReplayLimit int + IdleKickPolicy string +} +``` + +默认建议值如下: + +1. `HeartbeatInterval`: `15s` 到 `20s` +2. `WriteTimeout`: `5s` 到 `10s` +3. `QueueCapacity`: `64` 或 `128` +4. `MaxConnectionsPerSubject`: 按场景限制,避免单用户或单浏览器无限建连 +5. `ReplayLimit`: 按 channel 的单次回放窗口限制 +6. `IdleKickPolicy`: 默认 `drop_oldest` 或 `disconnect_slow_consumer` 二选一,本项目推荐默认断开慢客户端 + +### 6.3 五个基础接口 + +```go +type Authorizer interface { + AuthorizeConnect(ctx context.Context, req ConnectRequest) (*Principal, error) + AuthorizeSubscribe(ctx context.Context, principal *Principal, channel string) error + FilterEvent(ctx context.Context, principal *Principal, evt *StreamEvent) (*StreamEvent, error) +} + +type ConnectionBroker interface { + Register(conn *Connection) error + Unregister(connID string) + PublishToSubject(subjectID uint64, evt *StreamEvent) int + PublishToChannel(channel string, evt *StreamEvent) int + RevokeSubject(subjectID uint64, reason string) int + Stats() BrokerStats +} + +type StreamWriter interface { + WriteEvent(ctx context.Context, evt *StreamEvent) error + WriteHeartbeat(ctx context.Context) error + WriteTerminal(ctx context.Context, evt *StreamEvent) error +} + +type ReplayStore interface { + Append(ctx context.Context, evt *StreamEvent) error + ReplayAfter(ctx context.Context, channel string, lastEventID string, limit int) ([]*StreamEvent, error) +} + +type Backplane interface { + Publish(ctx context.Context, evt *StreamEvent) error + Subscribe(ctx context.Context, handler func(context.Context, *StreamEvent) error) error + PublishRevoke(ctx context.Context, revoke RevokeCommand) error +} +``` + +这些接口的职责边界固定如下: + +1. `Authorizer` 负责建连鉴权、订阅授权、事件二次裁剪,避免“能连上就能看到所有事件”。 +2. `ConnectionBroker` 只管理本机连接与本机投递,不关心 Redis 或业务真相。 +3. `StreamWriter` 只管把 `StreamEvent` 编码成合规 SSE 帧,并处理 flush、heartbeat、写超时。 +4. `ReplayStore` 只关心 durable 事件的回放,不持有连接。 +5. `Backplane` 只负责跨节点实时广播与撤销消息,不承担 durable 真相。 + +### 6.4 配置外置建议 + +SSE 子系统的运行参数必须配置外置,不能把心跳、写超时、队列长度、Origin 白名单硬编码在实现里。 + +建议落点如下: + +1. 在 `internal/model/config` 下新增独立的 `sse.go` 配置结构,或在确认不混淆职责的前提下扩展现有实时相关配置。 +2. `ConnectionPolicy` 由配置装配生成,而不是由各业务 handler 自己手写。 +3. `AllowedOrigins`、`QueueCapacity`、`HeartbeatInterval`、`WriteTimeout`、`ReplayLimit`、`MaxConnectionsPerSubject`、`DrainTimeout`、`PubSubChannelPrefix`、`ReplayStreamPrefix` 等都应进入配置层。 +4. 业务代码只读取 `global.Config`,不直接保留可变常量。 + +## 7. 并发模型与连接生命周期 + +### 7.1 固定并发模型 + +项目级 SSE 的并发模型固定为: + +1. Broker 维护受保护的连接注册表。 +2. 每个连接持有一个有界发送队列。 +3. 每个连接只允许一个 writer loop 实际写 `ResponseWriter`。 +4. 业务线程、broker 广播线程、backplane 消费线程都只能向连接队列投递,不能直接写流。 + +这条规则是整个重构的核心,不允许被破坏。 + +### 7.2 连接生命周期 + +标准生命周期如下: + +1. Handler 解析请求并完成登录态、租户边界、订阅权限校验。 +2. 创建连接对象、绑定 `ConnectionPolicy` 与本地发送队列。 +3. Broker 注册连接并建立索引。 +4. 如为 `channel stream` 且携带 `Last-Event-ID`,先从 `ReplayStore` 补发缺失事件。 +5. 启动该连接唯一的 writer loop,进入实时接收。 +6. 持续监听 `r.Context().Done()`、撤销命令、drain 命令与慢客户端淘汰信号。 +7. 连接结束时统一注销、清理索引、记录指标与日志。 + +### 7.3 慢客户端策略 + +慢客户端处理必须明确,不允许默认阻塞生产者: + +1. 每个连接必须有界队列,默认 `64` 或 `128`。 +2. 广播或单播时采用非阻塞入队,不等待网络写完成。 +3. 队列满时默认断开该连接,并记录 `slow_consumer_drop_total` 指标。 +4. 如业务确实需要保留最新态,可在少数场景改为 `drop_oldest`,但不能作为全局默认。 + +## 8. 发布、回放与多实例分发模型 + +### 8.1 项目级双通道模型 + +本项目实时事件固定采用双通道模型: + +1. `Outbox -> Redis Stream` + 负责 durable 真相事件的可靠出站、补偿与对账。 +2. `Redis Pub/Sub` + 负责跨节点低延迟分发与撤销广播。 + +固定规则如下: + +1. durable 真相先进入 Redis Stream,再根据需要同步触发 Pub/Sub。 +2. Pub/Sub 负责“快”,Redis Stream 负责“准”与“可补”。 +3. 任何节点如果错过 Pub/Sub 广播,都必须能通过 replay 从 durable 源追平。 + +### 8.2 channel stream 的正式流程 + +`channel stream` 的标准流程固定为: + +1. 业务变化写 DB。 +2. Service 产出业务事件并写入 Outbox。 +3. Outbox relay 将事件发布到 Redis Stream。 +4. 事件被需要的投影或实时模块消费。 +5. 如需要实时刷新,则同时向 Pub/Sub 发送轻量广播或事件摘要。 +6. 各节点的 backplane consumer 收到广播后,将事件投递到本机 Broker。 +7. 客户端断流重连时,带上 `Last-Event-ID` 从 ReplayStore 回放缺失事件。 + +### 8.3 session stream 的正式流程 + +`session stream` 固定采用请求级本地流: + +1. 当前 HTTP 请求就是该流的生命周期。 +2. 运行期事件直接进入该请求绑定的 writer loop。 +3. `tool_call_waiting_confirmation` 期间连接不断开,靠 keepalive 保活。 +4. 用户调用 decision 接口后,服务端在原流内继续输出后续事件。 +5. 本类流不进入 Redis Stream durable replay,不参与跨节点 backplane 主链路。 + +## 9. HTTP 协议、客户端与响应规范 + +### 9.1 项目标准客户端 + +项目级正式客户端统一采用 `fetch + SSE framing parser`,不把浏览器原生 `EventSource` 作为标准实现。 + +固定原因如下: + +1. `fetch` 更容易统一 `POST /stream` 这类带请求体的流式接口。 +2. `fetch` 能显式携带 `x-access-token` 等请求头。 +3. `fetch` 更容易统一取消控制、超时控制与业务重试。 +4. `EventSource` 的 header 能力受限,不适合作为项目级统一标准客户端。 + +### 9.2 路由风格 + +固定路由风格如下: + +1. 只读订阅类流允许 `GET + text/event-stream`。 +2. 需要请求体、上下文参数或会话状态的流允许 `POST + text/event-stream`。 +3. `GET`、`HEAD`、`OPTIONS` 是 safe methods,不允许顺手做状态变更。 +4. 建立 SSE 连接时不写入“已读”“消费确认”“在线状态”等副作用。 + +### 9.3 响应头规范 + +SSE 响应至少必须包含: + +```http +Content-Type: text/event-stream; charset=utf-8 +Cache-Control: no-cache +X-Accel-Buffering: no +``` + +根据场景可补充: + +```http +Connection: keep-alive +Vary: Origin +``` + +跨域且需要凭证时,必须精确指定 Origin,不允许: + +```http +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +``` + +### 9.4 SSE 帧规范 + +`StreamWriter` 负责统一编码,业务层不得手拼帧文本。 + +固定要求如下: + +1. 每个事件以空行结束。 +2. 多行 payload 必须拆成多行 `data:`. +3. 心跳使用注释行,例如 `: keepalive`。 +4. 需要建议重试间隔时可输出 `retry:`. +5. 终止事件与普通事件保持同一编码路径,避免出现两套结束语义。 + +## 10. 安全基线 + +### 10.1 鉴权基线 + +固定安全基线如下: + +1. 不接受 query token。 +2. 项目标准鉴权走现有 `x-access-token` 头。 +3. 若存在 Cookie 参与的跨域场景,必须精确 Origin 白名单并设置 `Vary: Origin`。 +4. 不允许在 SSE URL 中拼接 access token、refresh token 或任何敏感业务凭证。 + +### 10.2 三层校验 + +建立连接时必须完成 3 层校验: + +1. 登录态校验。 +2. 租户 / 组织 / 会话归属校验。 +3. channel / topic / conversation 订阅授权校验。 + +任何一层失败都必须在进入流式写出前返回错误,不允许“先连上再说”。 + +### 10.3 已建连接的主动撤销 + +连接建立成功后,仍然必须支持主动撤销: + +1. 登出后收回该用户相关连接。 +2. token 被加入黑名单后收回旧连接。 +3. 组织成员资格、权限、topic 订阅范围变化后,收回越权连接。 +4. AI 会话失效、轮次结束或被管理员中止后,收回对应 `session stream`。 + +撤销消息可通过本机命令或 backplane 跨节点广播完成,但都必须回到 Broker 统一收口。 + +### 10.4 额外安全要求 + +1. 日志中不得打印原始 token、Cookie、完整敏感 payload。 +2. 对连接建立与高频重连必须做限流,防止滥用。 +3. `GET` 类 SSE 只能读,状态变更必须走独立 POST / PUT / PATCH / DELETE 接口。 + +## 11. 稳定性、性能与运维基线 + +### 11.1 稳定性规则 + +固定稳定性规则如下: + +1. 心跳间隔默认 `15s` 到 `20s`。 +2. 每次写事件前设置单次写超时,默认 `5s` 到 `10s`。 +3. 写完每个事件后必须 `Flush`。 +4. 连接断开统一依赖 `Request.Context().Done()`。 +5. 代理层必须关闭 buffering,并把读超时设置到大于心跳间隔的区间。 + +### 11.2 性能规则 + +固定性能规则如下: + +1. 广播只做快照遍历与非阻塞入队,不让网络写阻塞广播主循环。 +2. 严禁用全局 worker 池串行承担连接写出。 +3. AI token 流不写 durable replay,避免把高频碎片放大到 Redis Stream。 +4. durable replay 只用于排行榜、通知、状态刷新等真正需要补偿的场景。 +5. 单用户、单主题的连接数必须有限制,防止浏览器多 tab 或恶意刷连接。 + +### 11.3 运维与发布 + +SSE 服务必须支持 drain 模式: + +1. 进入 drain 后拒绝新连接。 +2. 现有连接发送终止事件或关闭原因。 +3. Broker 主动收口本机连接。 +4. 最终再执行 HTTP Server 的优雅关闭。 + +部署策略建议如下: + +1. SSE 可以单独 listener,也可以单独 ingress 规则。 +2. 不要与普通 JSON API 共享同一套激进 `WriteTimeout`。 +3. 代理层必须关闭响应 buffering。 +4. 需要高连接数时优先单独调优 SSE 入口,而不是拖着所有 API 一起调。 + +## 12. 可观测性要求 + +SSE 子系统至少应暴露以下观测项: + +1. 当前连接数、按 `stream_kind / channel / node` 维度的活跃连接数。 +2. 连接建立成功数、失败数、授权拒绝数。 +3. 广播入队数、丢弃数、慢客户端淘汰数。 +4. replay 命中数、窗口越界数、回放条数。 +5. 写超时数、flush 失败数、backplane 投递失败数。 +6. 主动撤销数、drain 关闭数。 + +日志至少要带: + +1. `request_id` +2. `trace_id` +3. `stream_kind` +4. `channel` +5. `subject_id` +6. `conn_id` +7. `event_id` + +## 13. 与当前仓库分层的接入边界 + +### 13.1 Controller + +`internal/controller/system` 只负责: + +1. 绑定请求体与路径参数。 +2. 获取当前用户与上下文。 +3. 调用 `Authorizer` / `Handler` 建立流。 +4. 统一返回错误壳或流式响应。 + +禁止在 Controller: + +1. 直接管理连接表。 +2. 直接拼装 SSE 帧文本。 +3. 直接访问 Redis Stream 或 Pub/Sub。 + +### 13.2 Service + +`internal/service/system` 只负责: + +1. 产出业务事件。 +2. 决定事件属于 `session stream` 还是 `channel stream`。 +3. 完成权限与范围裁剪。 +4. 驱动 Outbox、业务状态与 AI 运行时。 + +禁止在 Service: + +1. 直接持有 `ResponseWriter`。 +2. 直接往某个连接写数据。 +3. 自己维护一套旁路连接缓存。 + +### 13.3 Repository + +`internal/repository` 只负责业务真相持久化,不感知 SSE。 + +固定规则如下: + +1. Repository 不直接发布 SSE 事件。 +2. Repository 不管理连接、回放或 topic。 +3. durable 事件统一通过 Service + Outbox 出站。 + +## 14. 模块接入指导 + +### 14.1 AI 子域接入 + +AI 子域固定接入方式: + +1. 使用 `POST /ai/conversations/{id}/stream` 建立 `session stream`。 +2. 运行时事件直接进入请求级 writer loop。 +3. `tool_call_waiting_confirmation` 期间仅保活,不切第二条流。 +4. decision 接口只提交控制命令,不直接返回 SSE。 +5. AI token、思考摘要、工具轨迹等事件默认不写 durable replay。 + +### 14.2 排行榜接入 + +排行榜类实时刷新固定接入方式: + +1. 业务真相变化先写 DB 与 Outbox。 +2. durable 事件进入 Redis Stream。 +3. backplane 负责跨节点实时通知。 +4. 订阅者通过 `channel stream` 接收更新。 +5. 重连时通过 `Last-Event-ID` 从 replay store 补齐缺失事件。 + +### 14.3 通知与审批接入 + +通知与审批场景按是否需要补偿分两类: + +1. 需要断线补偿、状态一致与历史追平的,走 `channel stream + durable replay`。 +2. 只需要当前在线态提醒的,可只走 backplane + 本机 broker,但要在文档里明确“非 durable”。 + +## 15. 测试与验收清单 + +### 15.1 单元测试 + +至少覆盖以下内容: + +1. `StreamWriter` 的 `event / id / data / retry` 编码。 +2. 多行 data 拆分。 +3. heartbeat 帧格式。 +4. 终止事件输出。 +5. 写超时与 flush 错误路径。 + +### 15.2 并发测试 + +并发测试必须启用 `go test -race`,至少覆盖: + +1. Broker 注册与注销并发。 +2. 单播、广播与撤销并发。 +3. 慢客户端淘汰。 +4. 多 goroutine 高频投递下无 map race。 +5. 同一连接永远只有一个 goroutine 实际写流。 + +### 15.3 集成测试 + +至少覆盖: + +1. `channel stream` 断线重连与 `Last-Event-ID` 回放。 +2. 回放窗口越界与缺口处理。 +3. 跨节点 Pub/Sub 分发。 +4. AI `session stream` 在 `tool_call_waiting_confirmation` 后持续 keepalive。 +5. decision 提交后原流继续输出,不产生第二条流。 + +### 15.4 安全与运维验收 + +至少覆盖: + +1. token 缺失、权限不足、topic 越权。 +2. query token 拒绝。 +3. 跨域 Origin 不匹配。 +4. token 拉黑或登出后的连接回收。 +5. 优雅关闭、drain、代理空闲超时与慢客户端阻塞。 + +## 16. 建议落地顺序 + +为避免一次性大改失控,建议按以下顺序推进: + +### 阶段 1:先收口基础抽象 + +1. 定义 `StreamEvent`、`ConnectionPolicy`、5 个基础接口。 +2. 建立 `internal/infrastructure/sse` 基础目录。 +3. 把 SSE 帧编码与 Writer 行为从业务层剥离。 + +### 阶段 2:先落本机并发模型 + +1. 实现 Broker 的受保护注册表。 +2. 实现每连接单 writer loop。 +3. 实现慢客户端淘汰、连接统计与主动撤销。 + +### 阶段 3:再接 durable replay 与 backplane + +1. 基于现有 Redis Stream 能力接入 `ReplayStore`。 +2. 基于 Pub/Sub 接入 `Backplane`。 +3. 明确 `channel stream` 的 durable / non-durable 分类。 + +### 阶段 4:最后迁移业务场景 + +1. 先迁 AI `session stream`,因为它只依赖本地 writer 与 keepalive 模型。 +2. 再迁排行榜与通知类 `channel stream`。 +3. 完成并发测试、集成测试、灰度发布与指标观察。 + +## 17. 最终结论 + +本项目后续所有 SSE 实现都必须基于以下统一判断: + +1. SSE 不是一个零散 handler,而是一套正式的长连接流式子系统。 +2. 连接是本机资源,事件才是跨节点资源。 +3. AI `session stream` 与排行榜 `channel stream` 必须分开建模。 +4. 并发模型的正确性优先级高于语法与样板代码。 +5. durable replay 必须依赖正式事件源,不依赖内存连接表。 +6. 安全、稳定性、性能与运维能力必须从首版设计时就进入正式约束,而不是上线后补洞。 diff --git a/docs/apifox/ai_assistant.openapi.json b/docs/apifox/ai_assistant.openapi.json new file mode 100644 index 0000000..2f743bb --- /dev/null +++ b/docs/apifox/ai_assistant.openapi.json @@ -0,0 +1,1819 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "z_cur/UI AI 助手模块 OpenAPI", + "version": "1.2.0", + "description": "只覆盖 `z_cur/UI` 当前 AI 助手模块需要的接口,可直接导入 Apifox。\n当前正式方案以 `go/personal_assistant/docs/AI助手架构设计方案.md`、`z_cur/UI/src/types/assistant.types.ts` 与 `z_cur/UI/src/stores/assistant.ts` 为准。\n当前协议固定为“业务 SSE 事件流 + 内嵌 A2UI block”的混合模型,不切换到纯 A2UI 顶层协议。\nAI 回复区只允许四类可见内容:`思考摘要 / 工具意图 / 等待用户 / 最终正文`。\n统一约束:\n1. CRUD 与 decision 接口走 JSON BizResponse;`stream` 接口返回 `text/event-stream`。\n2. `stream` 是唯一聊天 SSE 流;decision 接口只提交控制命令,不返回第二条 SSE 流。\n3. `assistant_token` 与 `message_completed.content` 只承载最终正文。\n4. `structured_block.ui_block` 只允许 `thinking_summary_block / tool_intent_block / waiting_user_block`。\n5. `trace_items / scope` 继续作为恢复与上下文字段存在,但不是默认可见内容。\n6. 命中 interrupt 后,服务端在原流内等待决策并恢复输出;旧的工具续跑流接口已废弃。" + }, + "servers": [ + { + "url": "http://127.0.0.1:9000", + "description": "本地开发环境。导入 Apifox 后请按实际服务地址修改。" + } + ], + "tags": [ + { + "name": "AI助手", + "description": "会话 CRUD、单条聊天 SSE 流与 interrupt 决策控制接口。" + } + ], + "security": [ + { + "BearerAuth": [] + }, + { + "AccessTokenHeader": [] + } + ], + "paths": { + "/ai/conversations": { + "get": { + "tags": [ + "AI助手" + ], + "summary": "获取 AI 会话列表", + "description": "仅返回当前登录用户可见的会话。\n`preview / updated_at / timestamp / group / is_generating` 都是前端列表直接依赖字段。\n建议按最近活跃时间倒序返回。", + "operationId": "ListAssistantConversations", + "responses": { + "200": { + "description": "成功与业务失败通常返回 HTTP 200;JWT 异常由中间件提前返回旧版认证错误壳。", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/BizAssistantConversationListResponse" + }, + { + "$ref": "#/components/schemas/BizErrorResponse" + }, + { + "$ref": "#/components/schemas/LegacyTokenErrorResponse" + } + ] + }, + "examples": { + "success": { + "summary": "查询成功", + "value": { + "code": 0, + "success": true, + "message": "获取成功", + "data": [ + { + "id": "conv_20260406_001", + "title": "任务汇报与项目摘要", + "preview": "帮我整理一版任务汇报并说明助手页面定位。", + "updated_at": "2026-04-06T10:40:00+08:00", + "timestamp": 1775433600000, + "group": "今天", + "is_generating": false + } + ], + "timestamp": 1775433600123 + } + } + } + } + } + }, + "401": { + "description": "账号已登录但未处于 active 状态时,ActiveUserMW 返回 HTTP 401 BizResponse。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BizUnauthorizedResponse" + }, + "examples": { + "unauthorized": { + "summary": "账号已禁用", + "value": { + "code": 4010, + "success": false, + "message": "账号已禁用,请联系管理员", + "data": null, + "timestamp": 1775432100123 + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "AI助手" + ], + "summary": "创建 AI 会话", + "description": "若请求体未传标题,可先创建占位会话,再在首条用户消息完成后回填标题和预览。\n初始 `is_generating=false`,直到真正开始 `stream`。", + "operationId": "CreateAssistantConversation", + "requestBody": { + "required": true, + "description": "创建会话请求。`title` 可选。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAssistantConversationRequest" + }, + "example": { + "title": "任务汇报与项目摘要" + } + } + } + }, + "responses": { + "200": { + "description": "成功与业务失败通常返回 HTTP 200;JWT 异常由中间件提前返回旧版认证错误壳。", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/BizAssistantConversationResponse" + }, + { + "$ref": "#/components/schemas/BizErrorResponse" + }, + { + "$ref": "#/components/schemas/LegacyTokenErrorResponse" + } + ] + }, + "examples": { + "success": { + "summary": "创建成功", + "value": { + "code": 0, + "success": true, + "message": "操作成功", + "data": { + "id": "conv_20260406_001", + "title": "任务汇报与项目摘要", + "preview": "", + "updated_at": "2026-04-06T10:39:00+08:00", + "timestamp": 1775433540000, + "group": "今天", + "is_generating": false + }, + "timestamp": 1775433540123 + } + } + } + } + } + }, + "401": { + "description": "账号已登录但未处于 active 状态时,ActiveUserMW 返回 HTTP 401 BizResponse。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BizUnauthorizedResponse" + }, + "examples": { + "unauthorized": { + "summary": "账号已禁用", + "value": { + "code": 4010, + "success": false, + "message": "账号已禁用,请联系管理员", + "data": null, + "timestamp": 1775432100123 + } + } + } + } + } + } + } + } + }, + "/ai/conversations/{id}/messages": { + "get": { + "tags": [ + "AI助手" + ], + "summary": "获取会话消息列表", + "description": "历史消息必须能完整重建 UI。\n后端返回时建议优先补齐 `ui_blocks`,并保留 `trace_items / scope`;`scope` 仅在复杂范围时返回。\nUI 侧会按“思考摘要 / 工具意图 / 等待用户 / 最终正文”重建消息结构。\n消息按创建时间升序返回。", + "operationId": "GetAssistantConversationMessages", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "conv_20260406_001" + } + ], + "responses": { + "200": { + "description": "成功与业务失败通常返回 HTTP 200;JWT 异常由中间件提前返回旧版认证错误壳。", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/BizAssistantMessageListResponse" + }, + { + "$ref": "#/components/schemas/BizErrorResponse" + }, + { + "$ref": "#/components/schemas/LegacyTokenErrorResponse" + } + ] + }, + "examples": { + "success": { + "summary": "查询成功", + "value": { + "code": 0, + "success": true, + "message": "获取成功", + "data": [ + { + "id": "msg_user_001", + "conversation_id": "conv_20260406_001", + "role": "user", + "content": "帮我整理一版任务汇报并说明助手页面定位。", + "created_at": "2026-04-06T10:40:01+08:00", + "status": "success", + "trace_items": [], + "ui_blocks": [] + }, + { + "id": "msg_ai_001", + "conversation_id": "conv_20260406_001", + "role": "assistant", + "content": "当前任务主线已进入收口阶段,主要阻塞集中在少数成员补齐和回归验证。\n\n- 任务完成率:81%\n- 覆盖成员:17 / 21\n- 阻塞项:3\n\n补充正式项目文档后,可以确认两点:\n\n- 助手继续挂在控制台 Workbench 中,不额外拆独立站点。\n- 后端接入继续采用单条 SSE 聊天流 + interrupt decision 控制接口。", + "created_at": "2026-04-06T10:40:15+08:00", + "status": "success", + "trace_items": [ + { + "key": "tool_task_snapshot", + "title": "读取任务执行快照", + "description": "读取完成率、成员覆盖情况和当前阻塞项。", + "status": "success", + "duration_ms": 148, + "content": "已拿到最新任务快照:完成率 81%,覆盖成员 17 / 21,阻塞项 3 个。" + }, + { + "key": "tool_doc_snapshot", + "title": "读取正式项目文档", + "description": "读取 README、架构设计方案和 AI UI 改造说明。", + "status": "success", + "duration_ms": 88, + "content": "已命中 README、架构设计方案与 AI UI 改造说明。" + } + ], + "ui_blocks": [ + { + "key": "block_thinking_summary_001", + "type": "thinking_summary_block", + "surface": { + "id": "surface_thinking_summary_001", + "root": "thinking_card_root", + "components": [ + { + "id": "thinking_card_root", + "type": "Card", + "tone": "primary", + "children": [ + "thinking_title", + "thinking_points" + ] + }, + { + "id": "thinking_title", + "type": "Text", + "value": "当前判断与下一步", + "usage_hint": "title" + }, + { + "id": "thinking_points", + "type": "BulletList", + "items": [ + "当前判断:本轮所需信息已经齐备,可以直接输出最终正文。", + "当前动作:已把工具结果收束成正式回答,不再重复展示中间过程。", + "下一步:如果你要改口吻、改结构或扩范围,可以继续追问。" + ] + } + ] + } + }, + { + "key": "block_tool_intent_001", + "type": "tool_intent_block", + "surface": { + "id": "surface_tool_intent_001", + "root": "tool_intent_card_root", + "components": [ + { + "id": "tool_intent_card_root", + "type": "Card", + "tone": "success", + "children": [ + "tool_badge", + "tool_title", + "tool_points" + ] + }, + { + "id": "tool_badge", + "type": "Badge", + "label": "已完成", + "tone": "success" + }, + { + "id": "tool_title", + "type": "Text", + "value": "本轮已处理 2 个工具", + "usage_hint": "title" + }, + { + "id": "tool_points", + "type": "BulletList", + "items": [ + "目的:任务快照、项目文档已经用于补齐本轮回答所需的信息。", + "必要性:这些问题依赖最新业务数据或正式文档,不能只靠上下文推断。", + "预期收益:最终正文会更准确,也更适合直接复用。" + ] + } + ] + } + } + ] + } + ], + "timestamp": 1775433615123 + } + } + } + } + } + }, + "401": { + "description": "账号已登录但未处于 active 状态时,ActiveUserMW 返回 HTTP 401 BizResponse。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BizUnauthorizedResponse" + }, + "examples": { + "unauthorized": { + "summary": "账号已禁用", + "value": { + "code": 4010, + "success": false, + "message": "账号已禁用,请联系管理员", + "data": null, + "timestamp": 1775432100123 + } + } + } + } + } + } + } + } + }, + "/ai/conversations/{id}": { + "delete": { + "tags": [ + "AI助手" + ], + "summary": "删除会话", + "description": "只能删除当前用户自己的会话。\n删除时应同步清理消息历史、审计记录与未完成的 interrupt 状态,或做软删除并在查询侧过滤。", + "operationId": "DeleteAssistantConversation", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "conv_20260406_001" + } + ], + "responses": { + "200": { + "description": "成功与业务失败通常返回 HTTP 200;JWT 异常由中间件提前返回旧版认证错误壳。", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/BizNullResponse" + }, + { + "$ref": "#/components/schemas/BizErrorResponse" + }, + { + "$ref": "#/components/schemas/LegacyTokenErrorResponse" + } + ] + }, + "examples": { + "success": { + "summary": "删除成功", + "value": { + "code": 0, + "success": true, + "message": "操作成功", + "data": null, + "timestamp": 1775433620123 + } + } + } + } + } + }, + "401": { + "description": "账号已登录但未处于 active 状态时,ActiveUserMW 返回 HTTP 401 BizResponse。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BizUnauthorizedResponse" + }, + "examples": { + "unauthorized": { + "summary": "账号已禁用", + "value": { + "code": 4010, + "success": false, + "message": "账号已禁用,请联系管理员", + "data": null, + "timestamp": 1775432100123 + } + } + } + } + } + } + } + } + }, + "/ai/conversations/{id}/stream": { + "post": { + "tags": [ + "AI助手" + ], + "summary": "发送用户消息并开启唯一聊天 SSE 流", + "description": "`stream` 是单轮问答的唯一事件流。\n命中 interrupt 后,服务端应发送 `tool_call_waiting_confirmation`,并在同一条连接上保持 keepalive;用户随后调用 decision 接口,服务端在原流内继续输出 `tool_call_confirmation_result / structured_block / assistant_token / message_completed / done`。\nV1 不做“断流后重新附着到同一轮运行”;连接断开即本轮失败或停止。\n问候语、寒暄和无业务目标的短消息可直接走轻量直答路径,不进入重型工具链路。\n`assistant_token` 和 `message_completed.content` 只用于最终正文;思考摘要、工具意图和等待用户通过 `structured_block.ui_block` 返回。\n`scope` 仅在复杂范围时通过 `structured_block.scope` 返回,普通同上下文对话默认不发。\n事件 payload 结构见 `AssistantEventPayloadCatalog`。", + "operationId": "StreamAssistantConversationMessage", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "conv_20260406_001" + } + ], + "requestBody": { + "required": true, + "description": "聊天流请求体。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamAssistantMessageRequest" + }, + "example": { + "conversation_id": "conv_20260406_001", + "content": "帮我整理一版任务汇报并说明助手页面定位。", + "context_user_name": "李雷", + "context_org_name": "算法训练营" + } + } + } + }, + "responses": { + "200": { + "description": "成功时返回 `text/event-stream`;若在进入 SSE 前被业务校验或 JWT 中间件拦截,则返回 JSON 错误壳。", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + }, + "examples": { + "sameStreamResume": { + "summary": "原流等待确认并在同一连接内继续输出", + "value": "event: conversation_started\ndata: {\"title\":\"任务汇报与项目摘要\"}\n\nevent: structured_block\ndata: {\"ui_block\":{\"key\":\"block_thinking_summary_001\",\"type\":\"thinking_summary_block\",\"surface\":{\"id\":\"surface_thinking_summary_001\",\"root\":\"thinking_card_root\",\"components\":[{\"id\":\"thinking_card_root\",\"type\":\"Card\",\"tone\":\"muted\",\"children\":[\"thinking_title\",\"thinking_points\"]},{\"id\":\"thinking_title\",\"type\":\"Text\",\"value\":\"当前判断与下一步\",\"usage_hint\":\"title\"},{\"id\":\"thinking_points\",\"type\":\"BulletList\",\"items\":[\"当前判断:这个问题需要结合最新业务数据或项目文档来回答。\",\"当前动作:正在安排所需工具,避免只靠上下文猜测。\",\"下一步:工具结果返回后再输出最终正文。\"]}]}}}\n\nevent: structured_block\ndata: {\"ui_block\":{\"key\":\"block_tool_intent_001\",\"type\":\"tool_intent_block\",\"surface\":{\"id\":\"surface_tool_intent_001\",\"root\":\"tool_intent_card_root\",\"components\":[{\"id\":\"tool_intent_card_root\",\"type\":\"Card\",\"tone\":\"warning\",\"children\":[\"tool_badge\",\"tool_title\",\"tool_points\"]},{\"id\":\"tool_badge\",\"type\":\"Badge\",\"label\":\"等待确认\",\"tone\":\"warning\"},{\"id\":\"tool_title\",\"type\":\"Text\",\"value\":\"文档工具需要你的确认\",\"usage_hint\":\"title\"},{\"id\":\"tool_points\",\"type\":\"BulletList\",\"items\":[\"目的:当前准备使用任务快照、项目文档来补齐回答依据。\",\"必要性:现有结果已够形成初步判断,但缺少正式文档支持。\",\"预期收益:继续后能明确页面定位、知识源和接入方式。\",\"确认要求:是否继续读取正式项目文档需要你决定。\"]}]}}}\n\nevent: structured_block\ndata: {\"ui_block\":{\"key\":\"block_waiting_user_001\",\"type\":\"waiting_user_block\",\"surface\":{\"id\":\"surface_waiting_user_001\",\"root\":\"waiting_card_root\",\"components\":[{\"id\":\"waiting_card_root\",\"type\":\"Card\",\"tone\":\"warning\",\"children\":[\"waiting_title\",\"waiting_description\",\"waiting_points\"]},{\"id\":\"waiting_title\",\"type\":\"Text\",\"value\":\"是否继续使用“项目文档摘要”工具?\",\"usage_hint\":\"title\"},{\"id\":\"waiting_description\",\"type\":\"Text\",\"value\":\"继续后会读取正式文档白名单并补充引用摘要;跳过则只基于已有业务数据继续输出。\",\"usage_hint\":\"body\"},{\"id\":\"waiting_points\",\"type\":\"BulletList\",\"items\":[\"继续后:会读取正式项目文档白名单,并把页面定位与接入说明补进回答。\",\"跳过后:只基于当前已拿到的业务数据继续输出。\"]}]}}}\n\nevent: tool_call_waiting_confirmation\ndata: {\"interrupt_id\":\"intr_20260406_doc_001\",\"key\":\"tool_doc_snapshot\",\"title\":\"读取正式项目文档\",\"description\":\"读取 README、架构设计方案和 AI UI 改造说明。\",\"confirmation_title\":\"是否继续使用“项目文档摘要”工具?\",\"confirmation_description\":\"继续后会读取正式文档白名单并补充引用摘要;跳过则只基于已有业务数据继续输出。\",\"actions\":[{\"key\":\"confirm_doc\",\"label\":\"继续使用\",\"action\":\"confirm\",\"style\":\"primary\"},{\"key\":\"skip_doc\",\"label\":\"跳过此工具\",\"action\":\"skip\",\"style\":\"default\"}]}\n\n: keepalive\n\n: user calls POST /ai/conversations/conv_20260406_001/interrupts/intr_20260406_doc_001/decision\n\nevent: tool_call_confirmation_result\ndata: {\"interrupt_id\":\"intr_20260406_doc_001\",\"key\":\"tool_doc_snapshot\",\"decision\":\"confirm\",\"status\":\"pending\",\"description\":\"已确认继续读取正式项目文档,准备补充最终回答。\"}\n\nevent: assistant_token\ndata: {\"token\":\"当前任务主线已进入收口阶段,主要阻塞集中在少数成员补齐和回归验证。\"}\n\nevent: message_completed\ndata: {\"content\":\"当前任务主线已进入收口阶段,主要阻塞集中在少数成员补齐和回归验证。\\n\\n- 任务完成率:81%\\n- 覆盖成员:17 / 21\\n- 阻塞项:3\\n\\n补充正式项目文档后,可以确认两点:\\n\\n- 助手继续挂在控制台 Workbench 中,不额外拆独立站点。\\n- 后端接入继续采用单条 SSE 聊天流 + interrupt decision 控制接口。\"}\n\nevent: done\ndata: {}\n" + } + } + }, + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/BizErrorResponse" + }, + { + "$ref": "#/components/schemas/LegacyTokenErrorResponse" + } + ] + }, + "examples": { + "invalidConversation": { + "summary": "会话不存在或无访问权限", + "value": { + "code": 4004, + "success": false, + "message": "会话不存在或无访问权限", + "data": null, + "timestamp": 1775432100123 + } + }, + "invalidToken": { + "summary": "Access Token 无效", + "value": { + "code": 11003, + "data": { + "message": "Invalid access token", + "reload": true + }, + "error": "Invalid access token" + } + } + } + } + } + }, + "401": { + "description": "账号已登录但未处于 active 状态时,ActiveUserMW 返回 HTTP 401 BizResponse。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BizUnauthorizedResponse" + }, + "examples": { + "unauthorized": { + "summary": "账号已禁用", + "value": { + "code": 4010, + "success": false, + "message": "账号已禁用,请联系管理员", + "data": null, + "timestamp": 1775432100123 + } + } + } + } + } + } + } + } + }, + "/ai/conversations/{id}/interrupts/{interrupt_id}/decision": { + "post": { + "tags": [ + "AI助手" + ], + "summary": "提交 interrupt 决策", + "description": "该接口只提交控制命令,不返回第二条 SSE 流。\n服务端收到决策后,应在对应 `stream` 的原连接内继续输出后续事件。\n必须校验 `conversation_id + interrupt_id` 与当前用户、当前运行实例的归属关系。", + "operationId": "SubmitAssistantInterruptDecision", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "conv_20260406_001" + }, + { + "name": "interrupt_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "intr_20260406_doc_001" + } + ], + "requestBody": { + "required": true, + "description": "interrupt 决策请求体。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantToolDecisionRequest" + }, + "example": { + "conversation_id": "conv_20260406_001", + "interrupt_id": "intr_20260406_doc_001", + "decision": "confirm", + "reason": "需要正式文档依据" + } + } + } + }, + "responses": { + "200": { + "description": "成功与业务失败通常返回 HTTP 200;后续 assistant 事件继续从已打开的 `stream` 返回。", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/BizAssistantInterruptDecisionResponse" + }, + { + "$ref": "#/components/schemas/BizErrorResponse" + }, + { + "$ref": "#/components/schemas/LegacyTokenErrorResponse" + } + ] + }, + "examples": { + "success": { + "summary": "提交成功", + "value": { + "code": 0, + "success": true, + "message": "决策已受理", + "data": { + "accepted": true, + "conversation_id": "conv_20260406_001", + "interrupt_id": "intr_20260406_doc_001", + "decision": "confirm" + }, + "timestamp": 1775433628123 + } + } + } + } + } + }, + "401": { + "description": "账号已登录但未处于 active 状态时,ActiveUserMW 返回 HTTP 401 BizResponse。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BizUnauthorizedResponse" + }, + "examples": { + "unauthorized": { + "summary": "账号已禁用", + "value": { + "code": 4010, + "success": false, + "message": "账号已禁用,请联系管理员", + "data": null, + "timestamp": 1775432100123 + } + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "推荐写法:`Authorization: Bearer `。" + }, + "AccessTokenHeader": { + "type": "apiKey", + "in": "header", + "name": "x-access-token", + "description": "兼容后端 JWT 中间件仍支持的旧 header 方案。" + } + }, + "schemas": { + "AuthErrorData": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "reload": { + "type": "boolean" + } + } + }, + "LegacyTokenErrorResponse": { + "type": "object", + "required": [ + "code", + "data", + "error" + ], + "properties": { + "code": { + "type": "integer", + "example": 11003 + }, + "data": { + "$ref": "#/components/schemas/AuthErrorData" + }, + "error": { + "type": "string", + "example": "Invalid access token" + } + } + }, + "BizErrorResponse": { + "type": "object", + "required": [ + "code", + "success", + "message", + "data", + "timestamp" + ], + "properties": { + "code": { + "type": "integer", + "example": 4001 + }, + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "参数错误" + }, + "data": { + "nullable": true + }, + "timestamp": { + "type": "integer", + "format": "int64", + "example": 1775432100123 + } + } + }, + "BizUnauthorizedResponse": { + "type": "object", + "required": [ + "code", + "success", + "message", + "data", + "timestamp" + ], + "properties": { + "code": { + "type": "integer", + "example": 4010 + }, + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "账号已禁用,请联系管理员" + }, + "data": { + "nullable": true + }, + "timestamp": { + "type": "integer", + "format": "int64", + "example": 1775432100123 + } + } + }, + "AssistantConversationGroup": { + "type": "string", + "enum": [ + "今天", + "最近", + "更早" + ] + }, + "AssistantMessageRole": { + "type": "string", + "enum": [ + "user", + "assistant", + "system" + ] + }, + "AssistantMessageStatus": { + "type": "string", + "enum": [ + "idle", + "loading", + "success", + "error", + "stopped" + ] + }, + "AssistantTraceStatus": { + "type": "string", + "enum": [ + "pending", + "success", + "error", + "awaiting_confirmation", + "skipped" + ] + }, + "AssistantTraceActionType": { + "type": "string", + "enum": [ + "confirm", + "skip", + "view_detail" + ] + }, + "AssistantA2UIBlockType": { + "type": "string", + "enum": [ + "thinking_summary_block", + "tool_intent_block", + "waiting_user_block" + ] + }, + "AssistantA2UIUsageHint": { + "type": "string", + "enum": [ + "caption", + "body", + "title", + "eyebrow", + "label" + ] + }, + "AssistantA2UITone": { + "type": "string", + "enum": [ + "primary", + "success", + "warning", + "danger", + "muted" + ] + }, + "AssistantScopeInfo": { + "type": "object", + "required": [ + "user_name", + "org_name", + "scope_label" + ], + "properties": { + "user_name": { + "type": "string" + }, + "org_name": { + "type": "string" + }, + "scope_label": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "doc_scope_label": { + "type": "string" + } + } + }, + "AssistantTraceAction": { + "type": "object", + "required": [ + "key", + "label", + "action" + ], + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/AssistantTraceActionType" + }, + "style": { + "type": "string", + "enum": [ + "primary", + "default", + "danger" + ] + } + } + }, + "AssistantTraceItem": { + "type": "object", + "required": [ + "key", + "title", + "description", + "status" + ], + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AssistantTraceStatus" + }, + "interrupt_id": { + "type": "string" + }, + "duration_ms": { + "type": "integer", + "format": "int64" + }, + "content": { + "type": "string" + }, + "detail_markdown": { + "type": "string" + }, + "requires_confirmation": { + "type": "boolean" + }, + "confirmation_title": { + "type": "string" + }, + "confirmation_description": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantTraceAction" + } + } + } + }, + "AssistantA2UIBinding": { + "type": "object", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + }, + "value_string": { + "type": "string" + } + } + }, + "AssistantConversation": { + "type": "object", + "required": [ + "id", + "title", + "preview", + "updated_at", + "timestamp", + "group", + "is_generating" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "preview": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "timestamp": { + "type": "integer", + "format": "int64" + }, + "group": { + "$ref": "#/components/schemas/AssistantConversationGroup" + }, + "is_generating": { + "type": "boolean" + } + } + }, + "CreateAssistantConversationRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 100 + } + } + }, + "StreamAssistantMessageRequest": { + "type": "object", + "required": [ + "conversation_id", + "content", + "context_user_name", + "context_org_name" + ], + "properties": { + "conversation_id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "context_user_name": { + "type": "string" + }, + "context_org_name": { + "type": "string" + } + } + }, + "AssistantToolDecisionRequest": { + "type": "object", + "required": [ + "conversation_id", + "interrupt_id", + "decision" + ], + "properties": { + "conversation_id": { + "type": "string" + }, + "interrupt_id": { + "type": "string" + }, + "decision": { + "type": "string", + "enum": [ + "confirm", + "skip" + ] + }, + "reason": { + "type": "string" + } + } + }, + "AssistantInterruptDecisionAccepted": { + "type": "object", + "required": [ + "accepted", + "conversation_id", + "interrupt_id", + "decision" + ], + "properties": { + "accepted": { + "type": "boolean", + "example": true + }, + "conversation_id": { + "type": "string" + }, + "interrupt_id": { + "type": "string" + }, + "decision": { + "type": "string", + "enum": [ + "confirm", + "skip" + ] + } + } + }, + "AssistantA2UITextComponent": { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "Text" + ] + }, + "value": { + "type": "string" + }, + "binding_key": { + "type": "string" + }, + "usage_hint": { + "allOf": [ + { + "$ref": "#/components/schemas/AssistantA2UIUsageHint" + } + ], + "nullable": true + }, + "tone": { + "allOf": [ + { + "$ref": "#/components/schemas/AssistantA2UITone" + } + ], + "nullable": true + } + } + }, + "AssistantA2UIRowComponent": { + "type": "object", + "required": [ + "id", + "type", + "children" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "Row" + ] + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AssistantA2UIColumnComponent": { + "type": "object", + "required": [ + "id", + "type", + "children" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "Column" + ] + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AssistantA2UICardComponent": { + "type": "object", + "required": [ + "id", + "type", + "children" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "Card" + ] + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "tone": { + "allOf": [ + { + "$ref": "#/components/schemas/AssistantA2UITone" + } + ], + "nullable": true + } + } + }, + "AssistantA2UIBadgeComponent": { + "type": "object", + "required": [ + "id", + "type", + "label" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "Badge" + ] + }, + "label": { + "type": "string" + }, + "tone": { + "allOf": [ + { + "$ref": "#/components/schemas/AssistantA2UITone" + } + ], + "nullable": true + } + } + }, + "AssistantA2UIBulletListComponent": { + "type": "object", + "required": [ + "id", + "type", + "items" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "BulletList" + ] + }, + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AssistantA2UIComponent": { + "oneOf": [ + { + "$ref": "#/components/schemas/AssistantA2UITextComponent" + }, + { + "$ref": "#/components/schemas/AssistantA2UIRowComponent" + }, + { + "$ref": "#/components/schemas/AssistantA2UIColumnComponent" + }, + { + "$ref": "#/components/schemas/AssistantA2UICardComponent" + }, + { + "$ref": "#/components/schemas/AssistantA2UIBadgeComponent" + }, + { + "$ref": "#/components/schemas/AssistantA2UIBulletListComponent" + } + ] + }, + "AssistantA2UISurface": { + "type": "object", + "required": [ + "id", + "root", + "components" + ], + "properties": { + "id": { + "type": "string" + }, + "root": { + "type": "string" + }, + "components": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantA2UIComponent" + } + }, + "bindings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantA2UIBinding" + } + } + } + }, + "AssistantA2UIBlock": { + "type": "object", + "required": [ + "key", + "type", + "surface" + ], + "properties": { + "key": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssistantA2UIBlockType" + }, + "surface": { + "$ref": "#/components/schemas/AssistantA2UISurface" + } + } + }, + "AssistantMessage": { + "type": "object", + "required": [ + "id", + "conversation_id", + "role", + "content", + "created_at", + "status", + "trace_items", + "ui_blocks" + ], + "properties": { + "id": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/AssistantMessageRole" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/AssistantMessageStatus" + }, + "trace_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantTraceItem" + } + }, + "ui_blocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantA2UIBlock" + } + }, + "scope": { + "allOf": [ + { + "$ref": "#/components/schemas/AssistantScopeInfo" + } + ], + "nullable": true + }, + "error_text": { + "type": "string" + } + } + }, + "AssistantStreamEventType": { + "type": "string", + "enum": [ + "conversation_started", + "assistant_token", + "tool_call_started", + "tool_call_finished", + "tool_call_waiting_confirmation", + "tool_call_confirmation_result", + "structured_block", + "message_completed", + "error", + "done" + ] + }, + "AssistantConversationStartedPayload": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string" + } + } + }, + "AssistantTokenPayload": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } + }, + "AssistantToolCallStartedPayload": { + "type": "object", + "required": [ + "key", + "title", + "description" + ], + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "AssistantToolCallFinishedPayload": { + "type": "object", + "required": [ + "key", + "description", + "duration_ms", + "status" + ], + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "duration_ms": { + "type": "integer", + "format": "int64" + }, + "status": { + "$ref": "#/components/schemas/AssistantTraceStatus" + }, + "content": { + "type": "string" + }, + "detail_markdown": { + "type": "string" + } + } + }, + "AssistantToolCallWaitingConfirmationPayload": { + "type": "object", + "required": [ + "interrupt_id", + "key", + "title", + "description", + "confirmation_title", + "confirmation_description", + "actions" + ], + "properties": { + "interrupt_id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "detail_markdown": { + "type": "string" + }, + "confirmation_title": { + "type": "string" + }, + "confirmation_description": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantTraceAction" + } + } + } + }, + "AssistantToolCallConfirmationResultPayload": { + "type": "object", + "required": [ + "interrupt_id", + "key", + "decision", + "status", + "description" + ], + "properties": { + "interrupt_id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "decision": { + "type": "string", + "enum": [ + "confirm", + "skip" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "skipped" + ] + }, + "description": { + "type": "string" + }, + "detail_markdown": { + "type": "string" + } + } + }, + "AssistantStructuredBlockPayload": { + "type": "object", + "properties": { + "scope": { + "$ref": "#/components/schemas/AssistantScopeInfo" + }, + "ui_block": { + "$ref": "#/components/schemas/AssistantA2UIBlock" + } + } + }, + "AssistantMessageCompletedPayload": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string" + } + } + }, + "AssistantErrorPayload": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "AssistantDonePayload": { + "type": "object", + "additionalProperties": false + }, + "AssistantEventPayloadCatalog": { + "type": "object", + "properties": { + "conversation_started": { + "$ref": "#/components/schemas/AssistantConversationStartedPayload" + }, + "assistant_token": { + "$ref": "#/components/schemas/AssistantTokenPayload" + }, + "tool_call_started": { + "$ref": "#/components/schemas/AssistantToolCallStartedPayload" + }, + "tool_call_finished": { + "$ref": "#/components/schemas/AssistantToolCallFinishedPayload" + }, + "tool_call_waiting_confirmation": { + "$ref": "#/components/schemas/AssistantToolCallWaitingConfirmationPayload" + }, + "tool_call_confirmation_result": { + "$ref": "#/components/schemas/AssistantToolCallConfirmationResultPayload" + }, + "structured_block": { + "$ref": "#/components/schemas/AssistantStructuredBlockPayload" + }, + "message_completed": { + "$ref": "#/components/schemas/AssistantMessageCompletedPayload" + }, + "error": { + "$ref": "#/components/schemas/AssistantErrorPayload" + }, + "done": { + "$ref": "#/components/schemas/AssistantDonePayload" + } + } + }, + "BizAssistantConversationResponse": { + "type": "object", + "required": [ + "code", + "success", + "message", + "data", + "timestamp" + ], + "properties": { + "code": { + "type": "integer", + "example": 0, + "description": "新版业务码。0 表示成功。" + }, + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "操作成功" + }, + "data": { + "$ref": "#/components/schemas/AssistantConversation" + }, + "timestamp": { + "type": "integer", + "format": "int64", + "example": 1775432100123 + } + } + }, + "BizAssistantConversationListResponse": { + "type": "object", + "required": [ + "code", + "success", + "message", + "data", + "timestamp" + ], + "properties": { + "code": { + "type": "integer", + "example": 0, + "description": "新版业务码。0 表示成功。" + }, + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "获取成功" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantConversation" + } + }, + "timestamp": { + "type": "integer", + "format": "int64", + "example": 1775432100123 + } + } + }, + "BizAssistantMessageListResponse": { + "type": "object", + "required": [ + "code", + "success", + "message", + "data", + "timestamp" + ], + "properties": { + "code": { + "type": "integer", + "example": 0, + "description": "新版业务码。0 表示成功。" + }, + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "获取成功" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "timestamp": { + "type": "integer", + "format": "int64", + "example": 1775432100123 + } + } + }, + "BizAssistantInterruptDecisionResponse": { + "type": "object", + "required": [ + "code", + "success", + "message", + "data", + "timestamp" + ], + "properties": { + "code": { + "type": "integer", + "example": 0, + "description": "新版业务码。0 表示成功。" + }, + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "决策已受理" + }, + "data": { + "$ref": "#/components/schemas/AssistantInterruptDecisionAccepted" + }, + "timestamp": { + "type": "integer", + "format": "int64", + "example": 1775432100123 + } + } + }, + "BizNullResponse": { + "type": "object", + "required": [ + "code", + "success", + "message", + "data", + "timestamp" + ], + "properties": { + "code": { + "type": "integer", + "example": 0, + "description": "新版业务码。0 表示成功。" + }, + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "操作成功" + }, + "data": { + "nullable": true + }, + "timestamp": { + "type": "integer", + "format": "int64", + "example": 1775432100123 + } + } + } + } + } +} diff --git a/flag/flagSql.go b/flag/flagSql.go index e7f14a4..6da0da2 100644 --- a/flag/flagSql.go +++ b/flag/flagSql.go @@ -24,6 +24,9 @@ func SQL() error { db := global.DB.Set("gorm:table_options", "ENGINE=InnoDB") if err := db.AutoMigrate( + &entity.AIConversation{}, // AI 会话表 + &entity.AIMessage{}, // AI 消息表 + &entity.AIInterrupt{}, // AI 中断表 &entity.User{}, // 用户表 &entity.Org{}, // 组织表 &entity.OrgMember{}, // 组织成员状态表 - 身份上的 @@ -170,6 +173,8 @@ func seedBuiltinRoles() error { // seedBuiltinCapabilities 初始化系统内置 capability(幂等)。 func seedBuiltinCapabilities() error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 for _, seed := range consts.BuiltinCapabilitySeeds() { record := entity.Capability{ Code: seed.Code, @@ -207,16 +212,22 @@ func seedBuiltinCapabilities() error { return err } } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 return nil } // seedBuiltinRoleCapabilities 确保组织管理员默认持有全部组织域 capability。 func seedBuiltinRoleCapabilities() error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 var orgAdmin entity.Role if err := global.DB.Where("code = ?", consts.RoleCodeOrgAdmin).First(&orgAdmin).Error; err != nil { return err } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 for _, code := range consts.BuiltinOrgAdminCapabilityCodes() { var capability entity.Capability if err := global.DB.Where("code = ?", code).First(&capability).Error; err != nil { @@ -230,11 +241,15 @@ func seedBuiltinRoleCapabilities() error { return err } } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return nil } // migrateOrgMemberLifecycleData 迁移组织成员生命周期相关的数据和结构,确保数据一致性和完整性 func migrateOrgMemberLifecycleData(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if err := ensureLifecycleSchema(db); err != nil { return err } @@ -259,6 +274,8 @@ func migrateOrgMemberLifecycleData(db *gorm.DB) error { if err := deduplicateOrgMembers(db); err != nil { return err } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if err := ensureUniqueIndex(db, "org_members", "uk_org_member", "org_id", "user_id"); err != nil { return err } @@ -283,6 +300,8 @@ func migrateOrgMemberLifecycleData(db *gorm.DB) error { return err } // 历史 users.current_org_id=1 的用户仅补入全体成员组织,不再回写到 org_id=1。 + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return backfillUsersByCurrentOrgToOrgMembers( db, legacyCurrentOrgID, @@ -295,6 +314,8 @@ func migrateOrgMemberLifecycleData(db *gorm.DB) error { // 说明:理论上 AutoMigrate 会自动补列,但在部分历史环境中可能出现“列未就绪即被查询”的情况, // 这里增加显式兜底,避免启动失败。 func ensureLifecycleSchema(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if err := ensureColumn(db, &entity.User{}, "Status"); err != nil { return err } @@ -304,6 +325,8 @@ func ensureLifecycleSchema(db *gorm.DB) error { if err := ensureColumn(db, &entity.User{}, "DisabledBy"); err != nil { return err } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if err := ensureColumn(db, &entity.User{}, "DisabledReason"); err != nil { return err } @@ -313,9 +336,26 @@ func ensureLifecycleSchema(db *gorm.DB) error { if err := ensureColumn(db, &entity.Org{}, "BuiltinKey"); err != nil { return err } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return nil } +// ensureColumn 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// - model:当前函数需要消费的输入参数。 +// - field:当前函数需要消费的输入参数。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func ensureColumn(db *gorm.DB, model any, field string) error { // 使用全新 Session,避免复用链路上残留的 Statement/Table 状态, // 导致 GORM 在 AddColumn 时错误地把目标表解析成历史上下文中的临时表名。 @@ -326,12 +366,29 @@ func ensureColumn(db *gorm.DB, model any, field string) error { return migrator.AddColumn(model, field) } +// normalizeOrgInviteCodes 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func normalizeOrgInviteCodes(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 var orgs []entity.Org if err := db.Unscoped().Select("id", "code").Order("id ASC").Find(&orgs).Error; err != nil { return err } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 usedCodes := make(map[string]struct{}, len(orgs)) for _, org := range orgs { code := strings.TrimSpace(org.Code) @@ -348,9 +405,25 @@ func normalizeOrgInviteCodes(db *gorm.DB) error { } usedCodes[code] = struct{}{} } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return nil } +// generateUniqueOrgCode 负责执行当前函数对应的核心逻辑。 +// 参数: +// - used:当前函数需要消费的输入参数。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func generateUniqueOrgCode(used map[string]struct{}) (string, error) { for i := 0; i < 10; i++ { raw := strings.ToUpper(strings.ReplaceAll(uuid.Must(uuid.NewV4()).String(), "-", "")) @@ -365,7 +438,22 @@ func generateUniqueOrgCode(used map[string]struct{}) (string, error) { return "", fmt.Errorf("failed to generate unique org code") } +// normalizeUserStatusWithFreeze 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func normalizeUserStatusWithFreeze(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if err := ensureUsersStatusColumnsBeforeNormalize(db); err != nil { return fmt.Errorf("ensure users lifecycle columns failed: %w", err) } @@ -377,6 +465,8 @@ func normalizeUserStatusWithFreeze(db *gorm.DB) error { } hasFreeze, err := columnExists(db, "users", "freeze") + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if err != nil { return err } @@ -384,6 +474,8 @@ func normalizeUserStatusWithFreeze(db *gorm.DB) error { return nil } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return db.Model(&entity.User{}). Where("freeze = ? AND status = ?", true, consts.UserStatusActive). Updates(map[string]any{ @@ -395,6 +487,8 @@ func normalizeUserStatusWithFreeze(db *gorm.DB) error { // ensureUsersStatusColumnsBeforeNormalize 在执行 status 相关数据修复前,确保列已存在。 // 先走 GORM Migrator,再用 SQL 兜底,兼容历史库。 func ensureUsersStatusColumnsBeforeNormalize(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if err := ensureColumn(db, &entity.User{}, "Freeze"); err != nil { return err } @@ -412,6 +506,8 @@ func ensureUsersStatusColumnsBeforeNormalize(db *gorm.DB) error { } // SQL 兜底:防止个别环境下 HasColumn/AddColumn 未按预期生效。 + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if err := ensureMySQLColumnWithDDL( db, "users", @@ -452,9 +548,27 @@ func ensureUsersStatusColumnsBeforeNormalize(db *gorm.DB) error { ); err != nil { return err } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return nil } +// ensureMySQLColumnWithDDL 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// - tableName:当前函数需要消费的输入参数。 +// - columnName:当前函数需要消费的输入参数。 +// - ddl:当前函数需要消费的输入参数。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func ensureMySQLColumnWithDDL(db *gorm.DB, tableName, columnName, ddl string) error { exists, err := columnExists(db, tableName, columnName) if err != nil { @@ -473,10 +587,36 @@ func ensureMySQLColumnWithDDL(db *gorm.DB, tableName, columnName, ddl string) er return nil } +// cleanupSoftDeletedUserOrgRoles 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func cleanupSoftDeletedUserOrgRoles(db *gorm.DB) error { return db.Exec("DELETE FROM user_org_roles WHERE deleted_at IS NOT NULL").Error } +// deduplicateUserOrgRoles 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func deduplicateUserOrgRoles(db *gorm.DB) error { return db.Exec(` DELETE u1 @@ -489,6 +629,19 @@ func deduplicateUserOrgRoles(db *gorm.DB) error { `).Error } +// deduplicateOrgMembers 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func deduplicateOrgMembers(db *gorm.DB) error { return db.Exec(` DELETE m1 @@ -500,7 +653,22 @@ func deduplicateOrgMembers(db *gorm.DB) error { `).Error } +// migrateOJTaskSchema 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func migrateOJTaskSchema(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if err := ensureUniqueIndex(db, "oj_tasks", "uk_oj_tasks_root_version", "root_task_id", "version_no"); err != nil { return err } @@ -534,6 +702,8 @@ func migrateOJTaskSchema(db *gorm.DB) error { if err := ensureUniqueIndex(db, "oj_question_intakes", "uk_oj_question_intakes_task_item", "task_item_id"); err != nil { return err } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if err := ensureIndex(db, "oj_question_intakes", "idx_oj_question_intakes_platform_title_status", "platform", "input_title", "status"); err != nil { return err } @@ -567,10 +737,27 @@ func migrateOJTaskSchema(db *gorm.DB) error { if err := ensureIndex(db, "oj_task_execution_user_items", "idx_oj_task_execution_user_items_execution_user_status_reason", "execution_user_id", "result_status", "reason"); err != nil { return err } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return ensureIndex(db, "oj_task_execution_user_items", "idx_oj_task_execution_user_items_execution_user_result", "execution_id", "user_id", "result_status") } +// backfillOJTaskItemSchema 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func backfillOJTaskItemSchema(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if !db.Migrator().HasTable("oj_task_items") { return nil } @@ -597,6 +784,8 @@ func backfillOJTaskItemSchema(db *gorm.DB) error { inputTitleExpr = "COALESCE(NULLIF(input_title, ''), NULLIF(question_code, ''), '')" } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 resolvedQuestionIDExpr := "COALESCE(resolved_question_id, 0)" if hasPlatformQuestionID { resolvedQuestionIDExpr = "CASE WHEN COALESCE(resolved_question_id, 0) > 0 THEN resolved_question_id ELSE COALESCE(platform_question_id, 0) END" @@ -636,10 +825,27 @@ func backfillOJTaskItemSchema(db *gorm.DB) error { resolution_status = %s WHERE deleted_at IS NULL `, inputTitleExpr, resolvedQuestionIDExpr, resolvedQuestionCodeExpr, resolvedTitleSnapshotExpr, resolutionStatusExpr) + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return db.Exec(updateSQL).Error } +// relaxLegacyOJTaskItemColumns 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func relaxLegacyOJTaskItemColumns(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if !db.Migrator().HasTable("oj_task_items") { return nil } @@ -665,6 +871,8 @@ func relaxLegacyOJTaskItemColumns(db *gorm.DB) error { } } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 hasPlatformQuestionID, err := columnExists(db, "oj_task_items", "platform_question_id") if err != nil { return err @@ -688,13 +896,32 @@ func relaxLegacyOJTaskItemColumns(db *gorm.DB) error { } } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return nil } +// syncOJQuestionIntakesFromTaskItems 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func syncOJQuestionIntakesFromTaskItems(db *gorm.DB) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if !db.Migrator().HasTable("oj_task_items") || !db.Migrator().HasTable("oj_question_intakes") { return nil } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 return db.Exec(` INSERT INTO oj_question_intakes ( task_id, @@ -732,6 +959,22 @@ func syncOJQuestionIntakesFromTaskItems(db *gorm.DB) error { `).Error } +// ensureUniqueIndex 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// - tableName:当前函数需要消费的输入参数。 +// - indexName:当前函数需要消费的输入参数。 +// - columns:当前函数需要消费的输入参数。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func ensureUniqueIndex(db *gorm.DB, tableName, indexName string, columns ...string) error { exists, err := indexExists(db, tableName, indexName) if err != nil { @@ -745,6 +988,22 @@ func ensureUniqueIndex(db *gorm.DB, tableName, indexName string, columns ...stri ).Error } +// ensureIndex 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// - tableName:当前函数需要消费的输入参数。 +// - indexName:当前函数需要消费的输入参数。 +// - columns:当前函数需要消费的输入参数。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func ensureIndex(db *gorm.DB, tableName, indexName string, columns ...string) error { exists, err := indexExists(db, tableName, indexName) if err != nil { @@ -758,6 +1017,21 @@ func ensureIndex(db *gorm.DB, tableName, indexName string, columns ...string) er ).Error } +// dropIndexIfExists 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// - tableName:当前函数需要消费的输入参数。 +// - indexName:当前函数需要消费的输入参数。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func dropIndexIfExists(db *gorm.DB, tableName, indexName string) error { exists, err := indexExists(db, tableName, indexName) if err != nil { @@ -769,6 +1043,22 @@ func dropIndexIfExists(db *gorm.DB, tableName, indexName string) error { return db.Exec(fmt.Sprintf("DROP INDEX %s ON %s", indexName, tableName)).Error } +// indexExists 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// - tableName:当前函数需要消费的输入参数。 +// - indexName:当前函数需要消费的输入参数。 +// +// 返回值: +// - bool:表示当前操作是否成功、命中或可继续执行。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func indexExists(db *gorm.DB, tableName, indexName string) (bool, error) { var count int64 row := db.Raw( @@ -786,6 +1076,22 @@ func indexExists(db *gorm.DB, tableName, indexName string) (bool, error) { return count > 0, nil } +// columnExists 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// - tableName:当前函数需要消费的输入参数。 +// - columnName:当前函数需要消费的输入参数。 +// +// 返回值: +// - bool:表示当前操作是否成功、命中或可继续执行。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func columnExists(db *gorm.DB, tableName, columnName string) (bool, error) { var count int64 row := db.Raw( @@ -803,6 +1109,19 @@ func columnExists(db *gorm.DB, tableName, columnName string) (bool, error) { return count > 0, nil } +// migrateAPILifecycleData 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func migrateAPILifecycleData(db *gorm.DB) error { if !db.Migrator().HasTable(&entity.API{}) { return nil @@ -815,7 +1134,23 @@ func migrateAPILifecycleData(db *gorm.DB) error { Error } +// ensureAllMembersOrg 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - uint:当前函数返回的处理结果。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func ensureAllMembersOrg(db *gorm.DB) (uint, error) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 var org entity.Org key := consts.OrgBuiltinKeyAllMembers queryDB := db.Session(&gorm.Session{NewDB: true}).Unscoped() @@ -860,6 +1195,8 @@ func ensureAllMembersOrg(db *gorm.DB) (uint, error) { return org.ID, nil } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 updates := map[string]any{ "is_builtin": true, } @@ -881,9 +1218,25 @@ func ensureAllMembersOrg(db *gorm.DB) (uint, error) { return 0, err } } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return org.ID, nil } +// generateAvailableOrgCodeFromDB 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func generateAvailableOrgCodeFromDB(db *gorm.DB) (string, error) { for i := 0; i < 10; i++ { code := "ORG-" + strings.ToUpper(strings.ReplaceAll(uuid.Must(uuid.NewV4()).String(), "-", ""))[:10] @@ -898,6 +1251,19 @@ func generateAvailableOrgCodeFromDB(db *gorm.DB) (string, error) { return "", fmt.Errorf("failed to generate org code from db") } +// backfillOrgMembersFromRoleRelations 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func backfillOrgMembersFromRoleRelations(db *gorm.DB) error { return db.Exec(` INSERT INTO org_members ( @@ -939,6 +1305,20 @@ func backfillUsersByCurrentOrgToOrgMembers( `, targetOrgID, consts.OrgMemberStatusActive, string(joinSource), sourceCurrentOrgID).Error } +// getMemberRoleID 负责执行当前函数对应的核心逻辑。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - uint:当前函数返回的处理结果。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func getMemberRoleID(db *gorm.DB) (uint, error) { var memberRole entity.Role if err := db.Session(&gorm.Session{NewDB: true}). diff --git a/global/global.go b/global/global.go index b013711..aff01fd 100644 --- a/global/global.go +++ b/global/global.go @@ -1,6 +1,7 @@ package global import ( + streaminfra "personal_assistant/internal/infrastructure/sse" "personal_assistant/internal/model/config" obsmetrics "personal_assistant/pkg/observability/metrics" obstrace "personal_assistant/pkg/observability/trace" @@ -33,4 +34,7 @@ var ( // 观测基础设施后端 ObservabilityMetrics obsmetrics.MetricsBackend // 观测指标后端 ObservabilityTraces obstrace.TraceBackend // 全链路追踪后端 + + // SSE 实时推送基础设施 + StreamInfra *streaminfra.Infrastructure ) diff --git a/internal/controller/system/aiCtrl.go b/internal/controller/system/aiCtrl.go new file mode 100644 index 0000000..635f421 --- /dev/null +++ b/internal/controller/system/aiCtrl.go @@ -0,0 +1,211 @@ +package system + +import ( + "strings" + + "personal_assistant/global" + streamsse "personal_assistant/internal/infrastructure/sse" + "personal_assistant/internal/model/dto/request" + serviceContract "personal_assistant/internal/service/contract" + bizerrors "personal_assistant/pkg/errors" + "personal_assistant/pkg/jwt" + "personal_assistant/pkg/response" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AICtrl 封装 AI 会话相关 HTTP 入口。 +// Controller 只负责参数接收、调用 Service 和统一响应,不在这里落业务编排。 +type AICtrl struct { + aiService serviceContract.AIServiceContract +} + +// CreateConversation 负责创建新的 AI 会话。 +// 参数: +// - c:Gin 请求上下文,承载请求体、响应写出器和鉴权结果。 +// +// 返回值:无。 +// 核心流程: +// 1. 绑定请求体,尽早拦截格式错误,避免无意义进入 Service。 +// 2. 从 JWT 中提取当前用户 ID,并调用 Service 完成会话创建。 +// 3. 统一把成功结果包装成标准响应。 +// +// 注意事项: +// - 参数绑定失败时直接返回统一错误响应,是为了把输入错误和业务错误明确区分开。 +func (ctrl *AICtrl) CreateConversation(c *gin.Context) { + var req request.CreateAssistantConversationReq + + // 先做请求体绑定,避免后续业务层再处理结构不完整的输入。 + if err := c.ShouldBindJSON(&req); err != nil { + global.Log.Error("AI 创建会话参数绑定失败", zap.Error(err)) + response.BizFailWithCodeMsg(bizerrors.CodeBindFailed, "参数绑定失败", c) + return + } + + // Controller 只负责透传用户身份和请求参数,真正的创建逻辑由 Service 决定。 + data, err := ctrl.aiService.CreateConversation(c.Request.Context(), jwt.GetUserID(c), &req) + if err != nil { + global.Log.Error("AI 创建会话失败", zap.Error(err)) + response.BizFailWithError(err, c) + return + } + + response.BizOkWithDetailed(data, "操作成功", c) +} + +// ListConversations 负责返回当前用户的会话列表。 +// 参数: +// - c:Gin 请求上下文。 +// +// 返回值:无。 +// 核心流程: +// 1. 从鉴权上下文读取用户 ID。 +// 2. 调用 Service 查询该用户可见的会话集合。 +// 3. 统一输出标准成功或失败响应。 +// +// 注意事项: +// - 列表查询不在 Controller 层做任何过滤拼装,避免和 Service 的权限边界混淆。 +func (ctrl *AICtrl) ListConversations(c *gin.Context) { + data, err := ctrl.aiService.ListConversations(c.Request.Context(), jwt.GetUserID(c)) + if err != nil { + global.Log.Error("AI 获取会话列表失败", zap.Error(err)) + response.BizFailWithError(err, c) + return + } + response.BizOkWithDetailed(data, "获取成功", c) +} + +// ListMessages 负责返回指定会话下的消息列表。 +// 参数: +// - c:Gin 请求上下文。 +// +// 返回值:无。 +// 核心流程: +// 1. 读取路径参数中的会话 ID。 +// 2. 调用 Service 校验归属并查询消息列表。 +// 3. 把 DTO 列表通过统一响应返回。 +// +// 注意事项: +// - 会话归属校验放在 Service 层,Controller 不重复实现权限判断,保持分层清晰。 +func (ctrl *AICtrl) ListMessages(c *gin.Context) { + data, err := ctrl.aiService.ListMessages(c.Request.Context(), jwt.GetUserID(c), c.Param("id")) + if err != nil { + global.Log.Error("AI 获取消息列表失败", zap.Error(err)) + response.BizFailWithError(err, c) + return + } + response.BizOkWithDetailed(data, "获取成功", c) +} + +// DeleteConversation 负责删除指定会话。 +// 参数: +// - c:Gin 请求上下文。 +// +// 返回值:无。 +// 核心流程: +// 1. 读取当前用户与目标会话 ID。 +// 2. 委托 Service 完成归属校验和级联删除。 +// 3. 删除成功后返回统一确认消息。 +// +// 注意事项: +// - 删除操作不在 Controller 层预读数据库,是为了避免重复查询和边界穿透。 +func (ctrl *AICtrl) DeleteConversation(c *gin.Context) { + if err := ctrl.aiService.DeleteConversation(c.Request.Context(), jwt.GetUserID(c), c.Param("id")); err != nil { + global.Log.Error("AI 删除会话失败", zap.Error(err)) + response.BizFailWithError(err, c) + return + } + response.BizOkWithMessage("删除成功", c) +} + +// StreamConversation 负责发起 AI 流式对话输出。 +// 参数: +// - c:Gin 请求上下文,同时也承载 HTTP 流式响应写出能力。 +// +// 返回值:无。 +// 核心流程: +// 1. 先拒绝 query token,防止认证信息暴露在 URL。 +// 2. 绑定流式请求参数,并创建 HTTP SSE writer。 +// 3. 调用 Service 执行完整的流式会话流程。 +// 4. 如果流尚未真正开始,再退回普通 JSON 错误响应。 +// +// 注意事项: +// - 一旦 SSE 已经写出响应头,就不能再回写普通 JSON;因此这里必须通过 writer.Started() 做分支判断。 +func (ctrl *AICtrl) StreamConversation(c *gin.Context) { + // SSE 明确禁止 query token,是为了避免令牌进入访问日志和浏览器历史。 + if strings.TrimSpace(c.Query("token")) != "" { + response.BizFailWithCodeMsg(bizerrors.CodeInvalidParams, "SSE 不接受 query token", c) + return + } + + var req request.StreamAssistantMessageReq + if err := c.ShouldBindJSON(&req); err != nil { + global.Log.Error("AI SSE 参数绑定失败", zap.Error(err)) + response.BizFailWithCodeMsg(bizerrors.CodeBindFailed, "参数绑定失败", c) + return + } + + // Writer 在 Controller 层创建,是因为它直接依赖 HTTP ResponseWriter。 + writer := streamsse.NewHTTPStreamWriter(c.Writer, resolveSSEPolicy()) + err := ctrl.aiService.StreamConversation(c.Request.Context(), jwt.GetUserID(c), c.Param("id"), &req, writer) + if err == nil { + return + } + + // 无论流是否已经开始,都先记录日志,方便排查长链路问题。 + global.Log.Error("AI SSE 流执行失败", zap.Error(err)) + + // 只有在还没开始写流时,才能安全退回标准错误响应。 + if !writer.Started() { + response.BizFailWithError(err, c) + } +} + +// SubmitDecision 负责提交 interrupt 决策结果。 +// 参数: +// - c:Gin 请求上下文。 +// +// 返回值:无。 +// 核心流程: +// 1. 绑定 confirm/skip 等决策参数。 +// 2. 调用 Service 校验 interrupt 状态并写入用户决策。 +// 3. 返回“已接受”的标准响应。 +// +// 注意事项: +// - Controller 只负责把决策原样转交 Service,不在这里解释 interrupt 状态机。 +func (ctrl *AICtrl) SubmitDecision(c *gin.Context) { + var req request.SubmitAssistantDecisionReq + if err := c.ShouldBindJSON(&req); err != nil { + global.Log.Error("AI interrupt 决策参数绑定失败", zap.Error(err)) + response.BizFailWithCodeMsg(bizerrors.CodeBindFailed, "参数绑定失败", c) + return + } + + data, err := ctrl.aiService.SubmitDecision(c.Request.Context(), jwt.GetUserID(c), c.Param("id"), c.Param("interrupt_id"), &req) + if err != nil { + global.Log.Error("AI interrupt 决策失败", zap.Error(err)) + response.BizFailWithError(err, c) + return + } + + response.BizOkWithDetailed(data, "操作成功", c) +} + +// resolveSSEPolicy 负责为当前请求解析可用的 SSE 连接策略。 +// 参数:无。 +// 返回值: +// - streamsse.ConnectionPolicy:当前节点应使用的 SSE 策略副本。 +// +// 核心流程: +// 1. 优先读取全局初始化阶段已经构建好的 SSE 基础设施配置。 +// 2. 若基础设施未启用,则回退到一套可运行的默认策略。 +// +// 注意事项: +// - 这里返回标准化后的默认值,而不是空结构体,是为了保证未启用全局基础设施时本地流式能力仍然可用。 +func resolveSSEPolicy() streamsse.ConnectionPolicy { + if global.StreamInfra != nil { + return global.StreamInfra.Policy + } + return streamsse.ConnectionPolicy{}.Normalize() +} diff --git a/internal/controller/system/supplier.go b/internal/controller/system/supplier.go index d8e4c75..402f4a7 100644 --- a/internal/controller/system/supplier.go +++ b/internal/controller/system/supplier.go @@ -4,7 +4,9 @@ import ( "personal_assistant/internal/service" ) +// Supplier 用于集中提供当前模块依赖对象。 type Supplier interface { + GetAICtrl() *AICtrl GetRefreshTokenCtrl() *RefreshTokenCtrl GetBaseCtrl() *BaseCtrl GetHealthCtrl() *HealthCtrl @@ -21,10 +23,15 @@ type Supplier interface { // SetUp 工厂函数-单例 func SetUp(service *service.Group) Supplier { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 cs := &controllerSupplier{} cs.refreshTokenCtrl = &RefreshTokenCtrl{ jwtService: service.SystemServiceSupplier.GetJWTSvc(), } + cs.aiCtrl = &AICtrl{ + aiService: service.SystemServiceSupplier.GetAISvc(), + } cs.baseCtrl = &BaseCtrl{ baseService: service.SystemServiceSupplier.GetBaseSvc(), } @@ -34,10 +41,13 @@ func SetUp(service *service.Group) Supplier { cs.userCtrl = &UserCtrl{ userService: service.SystemServiceSupplier.GetUserSvc(), jwtService: service.SystemServiceSupplier.GetJWTSvc(), + aiService: service.SystemServiceSupplier.GetAISvc(), } cs.orgCtrl = &OrgCtrl{ orgService: service.SystemServiceSupplier.GetOrgSvc(), } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 cs.ojCtrl = &OJCtrl{ ojService: service.SystemServiceSupplier.GetOJSvc(), } @@ -59,5 +69,7 @@ func SetUp(service *service.Group) Supplier { cs.observabilityCtrl = &ObservabilityCtrl{ observabilityService: service.SystemServiceSupplier.GetObservabilitySvc(), } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return cs } diff --git a/internal/controller/system/supplierImpl.go b/internal/controller/system/supplierImpl.go index 2a3294d..464200e 100644 --- a/internal/controller/system/supplierImpl.go +++ b/internal/controller/system/supplierImpl.go @@ -1,6 +1,8 @@ package system +// controllerSupplier 用于集中提供当前模块依赖对象。 type controllerSupplier struct { + aiCtrl *AICtrl refreshTokenCtrl *RefreshTokenCtrl baseCtrl *BaseCtrl healthCtrl *HealthCtrl @@ -15,46 +17,223 @@ type controllerSupplier struct { observabilityCtrl *ObservabilityCtrl } +// GetAICtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *AICtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (c *controllerSupplier) GetAICtrl() *AICtrl { + return c.aiCtrl +} + +// GetRefreshTokenCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *RefreshTokenCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetRefreshTokenCtrl() *RefreshTokenCtrl { return c.refreshTokenCtrl } + +// GetBaseCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *BaseCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetBaseCtrl() *BaseCtrl { return c.baseCtrl } + +// GetHealthCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *HealthCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetHealthCtrl() *HealthCtrl { return c.healthCtrl } + +// GetUserCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *UserCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetUserCtrl() *UserCtrl { return c.userCtrl } + +// GetOrgCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *OrgCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetOrgCtrl() *OrgCtrl { return c.orgCtrl } +// GetOJCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *OJCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetOJCtrl() *OJCtrl { return c.ojCtrl } +// GetOJTaskCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *OJTaskCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetOJTaskCtrl() *OJTaskCtrl { return c.ojTaskCtrl } +// GetApiCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *ApiCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetApiCtrl() *ApiCtrl { return c.apiCtrl } +// GetMenuCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *MenuCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetMenuCtrl() *MenuCtrl { return c.menuCtrl } +// GetRoleCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *RoleCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetRoleCtrl() *RoleCtrl { return c.roleCtrl } +// GetImageCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *ImageCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetImageCtrl() *ImageCtrl { return c.imageCtrl } +// GetObservabilityCtrl 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - *ObservabilityCtrl:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (c *controllerSupplier) GetObservabilityCtrl() *ObservabilityCtrl { return c.observabilityCtrl } diff --git a/internal/controller/system/userCtrl.go b/internal/controller/system/userCtrl.go index fc41c72..1f59410 100644 --- a/internal/controller/system/userCtrl.go +++ b/internal/controller/system/userCtrl.go @@ -18,13 +18,17 @@ import ( "go.uber.org/zap" ) +// UserCtrl 封装当前领域的控制器入口。 type UserCtrl struct { userService serviceContract.UserServiceContract jwtService serviceContract.JWTServiceContract + aiService serviceContract.AIServiceContract } // Register 注册 func (u *UserCtrl) Register(ctx *gin.Context) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 var req request.RegisterReq err := ctx.ShouldBindJSON(&req) if err != nil { @@ -34,6 +38,8 @@ func (u *UserCtrl) Register(ctx *gin.Context) { } // 执行注册 + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 user, err := u.userService.Register(ctx.Request.Context(), &req) if err != nil { global.Log.Error( @@ -49,11 +55,15 @@ func (u *UserCtrl) Register(ctx *gin.Context) { zap.Uint("userID", user.ID)) // 注册成功后,直接生成 Token 并返回(自动登录) + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 u.TokenNext(ctx, *user) } // Login 登录接口 func (u *UserCtrl) Login(ctx *gin.Context) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 var req request.LoginReq err := ctx.ShouldBindJSON(&req) if err != nil { @@ -65,6 +75,8 @@ func (u *UserCtrl) Login(ctx *gin.Context) { } // 执行手机号登录 + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 user, err := u.userService.PhoneLogin(ctx, &req) if err != nil { global.Log.Error("手机号登录失败", @@ -76,12 +88,32 @@ func (u *UserCtrl) Login(ctx *gin.Context) { return } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 u.TokenNext(ctx, *user) } +// TokenNext 负责执行当前函数对应的核心逻辑。 +// 参数: +// - c:调用方传入的目标对象或配置实例。 +// - user:当前函数需要消费的输入参数。 +// +// 返回值: +// - 无。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (u *UserCtrl) TokenNext(c *gin.Context, user entity.User) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 helper := response.NewAPIHelper(c, "LoginTokenNext") loginResp, refreshToken, refreshExpiresAt, jwtErr := u.jwtService.IssueLoginTokens(c.Request.Context(), user) + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if jwtErr != nil { helper.CommonError(jwtErr.Message, jwtErr.Code, jwtErr.Err) response.NewResponse[resp.AuthResponse, resp.AuthResponse](c). @@ -101,6 +133,8 @@ func (u *UserCtrl) TokenNext(c *gin.Context, user entity.User) { jwt.SetRefreshToken(c, refreshToken, maxAge) } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 response.NewResponse[resp.LoginResponse, resp.LoginResponse](c). SetTrans(&resp.LoginResponse{}). Success("登录成功", loginResp) @@ -110,6 +144,7 @@ func (u *UserCtrl) TokenNext(c *gin.Context, user entity.User) { func (u *UserCtrl) Logout(c *gin.Context) { // 读取必要信息(尽量复用已有的工具函数) uid := jwt.GetUUID(c) + userID := jwt.GetUserID(c) jwtStr := jwt.GetRefreshToken(c) // 清除刷新令牌 Cookie(HttpOnly) @@ -130,6 +165,9 @@ func (u *UserCtrl) Logout(c *gin.Context) { global.Log.Warn("加入刷新令牌黑名单失败", zap.Error(err)) } } + if u.aiService != nil && userID > 0 { + u.aiService.RevokeUserSessions(c.Request.Context(), userID, "logout") + } response.NewResponse[any, any](c). SetCode(bizerrors.CodeSuccess). @@ -211,6 +249,9 @@ func (u *UserCtrl) DeactivateAccount(c *gin.Context) { response.BizFailWithError(err, c) return } + if u.aiService != nil { + u.aiService.RevokeUserSessions(c.Request.Context(), userID, "deactivate") + } jwt.ClearRefreshToken(c) response.BizOkWithMessage("账号已禁用", c) } @@ -255,6 +296,8 @@ func (u *UserCtrl) GetUserDetail(c *gin.Context) { // GetUserRoles 获取用户在组织下的角色 func (u *UserCtrl) GetUserRoles(c *gin.Context) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 id := util.ParseUint(c.Param("id")) if id == 0 { response.BizFailWithMessage("ID无效", c) @@ -266,6 +309,8 @@ func (u *UserCtrl) GetUserRoles(c *gin.Context) { return } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 roles, err := u.userService.GetUserRoles(c.Request.Context(), uint(id), uint(orgID)) if err != nil { response.BizFailWithError(err, c) @@ -282,11 +327,15 @@ func (u *UserCtrl) GetUserRoles(c *gin.Context) { } } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 response.BizOkWithData(list, c) } // GetUserRoleMatrix 获取用户角色分配矩阵 func (u *UserCtrl) GetUserRoleMatrix(c *gin.Context) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 id := util.ParseUint(c.Param("id")) if id == 0 { response.BizFailWithMessage("ID无效", c) @@ -299,6 +348,8 @@ func (u *UserCtrl) GetUserRoleMatrix(c *gin.Context) { response.BizFailWithCodeMsg(bizerrors.CodeInvalidParams, "参数错误", c) return } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if query.OrgID == 0 { response.BizFailWithCodeMsg(bizerrors.CodeInvalidParams, "必须指定组织ID", c) return @@ -318,11 +369,15 @@ func (u *UserCtrl) GetUserRoleMatrix(c *gin.Context) { return } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 response.BizOkWithData(matrix, c) } // AssignRole 分配角色 func (u *UserCtrl) AssignRole(c *gin.Context) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 var req request.AssignUserRoleReq if err := c.ShouldBindJSON(&req); err != nil { global.Log.Error("分配角色参数绑定失败", zap.Error(err)) @@ -330,6 +385,8 @@ func (u *UserCtrl) AssignRole(c *gin.Context) { return } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 operatorID := jwt.GetUserID(c) if err := u.userService.AssignRole(c.Request.Context(), operatorID, &req); err != nil { global.Log.Error( @@ -343,11 +400,15 @@ func (u *UserCtrl) AssignRole(c *gin.Context) { return } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 response.BizOk(c) } // UpdateUserStatus 管理员启用/禁用账号 func (u *UserCtrl) UpdateUserStatus(c *gin.Context) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 id := util.ParseUint(c.Param("id")) if id == 0 { response.BizFailWithMessage("ID无效", c) @@ -355,6 +416,8 @@ func (u *UserCtrl) UpdateUserStatus(c *gin.Context) { } var req request.AdminUpdateUserStatusReq + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if err := c.ShouldBindJSON(&req); err != nil { response.BizFailWithMessage("参数错误", c) return @@ -366,6 +429,8 @@ func (u *UserCtrl) UpdateUserStatus(c *gin.Context) { response.BizFailWithError(err, c) return } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 response.BizOkWithMessage("用户状态更新成功", c) } @@ -373,6 +438,8 @@ func (u *UserCtrl) UpdateUserStatus(c *gin.Context) { // entityToUserDetail 将用户实体转换为详情DTO func entityToUserDetail(user *entity.User) *resp.UserDetailItem { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 item := &resp.UserDetailItem{ ID: user.ID, UUID: user.UUID.String(), @@ -394,11 +461,15 @@ func entityToUserDetail(user *entity.User) *resp.UserDetailItem { if user.DisabledAt != nil { item.DisabledAt = user.DisabledAt.Format(time.DateTime) } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if user.CurrentOrg != nil { item.CurrentOrg = &resp.OrgSimpleItem{ ID: user.CurrentOrg.ID, Name: user.CurrentOrg.Name, } } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return item } diff --git a/internal/core/config.go b/internal/core/config.go index a0ae1f1..89071ab 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -42,6 +42,20 @@ func InitConfig(path string) { viper.SetDefault("messaging.oj_question_upsert_topic", "oj_question_upsert") viper.SetDefault("messaging.oj_question_upsert_group", "oj_question_upsert_group") viper.SetDefault("messaging.oj_question_upsert_consumer", "oj_question_upsert_consumer") + viper.SetDefault("sse.heartbeat_interval_seconds", 20) + viper.SetDefault("sse.write_timeout_seconds", 10) + viper.SetDefault("sse.queue_capacity", 64) + viper.SetDefault("sse.max_connections_per_subject", 3) + viper.SetDefault("sse.replay_limit", 100) + viper.SetDefault("sse.allowed_origins", []string{ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://192.168.20.14:3000", + }) + viper.SetDefault("sse.drain_timeout_seconds", 15) + viper.SetDefault("sse.pubsub_channel_prefix", "sse") + viper.SetDefault("sse.replay_stream_prefix", "sse:replay") + viper.SetDefault("sse.ai_runtime_mode", "local") viper.SetDefault("rate_limit.oj_bind.limit", 3) viper.SetDefault("rate_limit.oj_bind.window_sec", 10) viper.SetDefault("observability.propagation.enabled", true) @@ -212,6 +226,16 @@ func InitConfig(path string) { _ = viper.BindEnv("messaging.cache_projection_topic", "MESSAGING_CACHE_PROJECTION_TOPIC") _ = viper.BindEnv("messaging.cache_projection_group", "MESSAGING_CACHE_PROJECTION_GROUP") _ = viper.BindEnv("messaging.cache_projection_consumer", "MESSAGING_CACHE_PROJECTION_CONSUMER") + _ = viper.BindEnv("sse.heartbeat_interval_seconds", "SSE_HEARTBEAT_INTERVAL_SECONDS") + _ = viper.BindEnv("sse.write_timeout_seconds", "SSE_WRITE_TIMEOUT_SECONDS") + _ = viper.BindEnv("sse.queue_capacity", "SSE_QUEUE_CAPACITY") + _ = viper.BindEnv("sse.max_connections_per_subject", "SSE_MAX_CONNECTIONS_PER_SUBJECT") + _ = viper.BindEnv("sse.replay_limit", "SSE_REPLAY_LIMIT") + _ = viper.BindEnv("sse.allowed_origins", "SSE_ALLOWED_ORIGINS") + _ = viper.BindEnv("sse.drain_timeout_seconds", "SSE_DRAIN_TIMEOUT_SECONDS") + _ = viper.BindEnv("sse.pubsub_channel_prefix", "SSE_PUBSUB_CHANNEL_PREFIX") + _ = viper.BindEnv("sse.replay_stream_prefix", "SSE_REPLAY_STREAM_PREFIX") + _ = viper.BindEnv("sse.ai_runtime_mode", "SSE_AI_RUNTIME_MODE") _ = viper.BindEnv("observability.enabled", "OBSERVABILITY_ENABLED") _ = viper.BindEnv("observability.service_name", "OBSERVABILITY_SERVICE_NAME") _ = viper.BindEnv("observability.service_trace.enabled", "OBSERVABILITY_SERVICE_TRACE_ENABLED") diff --git a/internal/core/server.other.go b/internal/core/server.other.go index 6dff9b0..1845c2e 100644 --- a/internal/core/server.other.go +++ b/internal/core/server.other.go @@ -15,7 +15,7 @@ func initServer(address string, router *gin.Engine) server { // 实现优雅重启 “重启” s := endless.NewServer(address, router) // 使用 endless 包创建一个新的 HTTP 服务器实例 s.ReadHeaderTimeout = 10 * time.Minute // 设置请求头的读取超时时间为 10 分钟 - s.WriteTimeout = 10 * time.Minute // 设置响应写入的超时时间为 10 分钟 + s.WriteTimeout = 0 // SSE 通过单次写 deadline 控制,不使用全局写超时 s.MaxHeaderBytes = 1 << 20 // 设置最大请求头的大小(1MB) return s // 返回创建的服务器实例 diff --git a/internal/core/server.win.go b/internal/core/server.win.go index 39f5985..d6c97fb 100644 --- a/internal/core/server.win.go +++ b/internal/core/server.win.go @@ -17,7 +17,7 @@ func initServer(address string, router *gin.Engine) server { Addr: address, // 设置服务器监听的地址 Handler: router, // 设置请求处理器(路由) ReadTimeout: 10 * time.Minute, // 设置请求的读取超时时间为 10 分钟 - WriteTimeout: 10 * time.Minute, // 设置响应的写入超时时间为 10 分钟 + WriteTimeout: 0, // SSE 通过单次写 deadline 控制,不使用全局写超时 MaxHeaderBytes: 1 << 20, // 设置最大请求头的大小(1MB) } } diff --git a/internal/core/sse.go b/internal/core/sse.go new file mode 100644 index 0000000..4fac612 --- /dev/null +++ b/internal/core/sse.go @@ -0,0 +1,49 @@ +package core + +import ( + "time" + + "personal_assistant/global" + "personal_assistant/internal/infrastructure/sse" +) + +// InitSSEInfrastructure 负责根据全局配置初始化 SSE 运行时基础设施。 +// 参数:无。 +// 返回值:无。 +// 核心流程: +// 1. 先确认配置和 Redis 都已就绪,避免在依赖缺失时构造出不可用实例。 +// 2. 把配置层的秒级数值映射为 ConnectionPolicy。 +// 3. 创建聚合后的 SSE Infrastructure 并挂到全局变量,供控制器和服务层复用。 +// +// 注意事项: +// - 这里不重复创建 Redis 客户端;SSE 只消费外层已经初始化好的全局资源,符合基础设施边界要求。 +func InitSSEInfrastructure() { + // 缺少配置或 Redis 时直接跳过,是为了允许未启用 SSE 的部署形态平稳启动。 + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + if global.Config == nil || global.Redis == nil { + return + } + + cfg := global.Config.SSE + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + policy := sse.ConnectionPolicy{ + HeartbeatInterval: time.Duration(cfg.HeartbeatIntervalSeconds) * time.Second, + WriteTimeout: time.Duration(cfg.WriteTimeoutSeconds) * time.Second, + QueueCapacity: cfg.QueueCapacity, + MaxConnectionsPerSubject: cfg.MaxConnectionsPerSubject, + ReplayLimit: cfg.ReplayLimit, + IdleKickPolicy: sse.IdleKickDisconnectSlowConsumer, + } + + // 统一在 core 层组装基础设施,是为了保证全局只存在一套 SSE 运行时实例。 + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + global.StreamInfra = sse.NewInfrastructure( + global.Redis, + policy, + cfg.ReplayStreamPrefix, + cfg.PubSubChannelPrefix, + ) +} diff --git a/internal/infrastructure/sse/authorizer.go b/internal/infrastructure/sse/authorizer.go new file mode 100644 index 0000000..ae9f7c5 --- /dev/null +++ b/internal/infrastructure/sse/authorizer.go @@ -0,0 +1,98 @@ +package sse + +import ( + "context" + "errors" +) + +var ( + // ErrQueryTokenNotAllowed 表示当前 SSE 接入策略不允许从 query string 读取 token。 + // 这样做是为了避免令牌进入访问日志、浏览器历史或代理缓存,降低泄露风险。 + ErrQueryTokenNotAllowed = errors.New("query token is not allowed for SSE") + + // ErrForbiddenChannel 表示当前主体无权订阅目标 channel。 + // 该错误预留给更严格的授权器实现使用,AllowAllAuthorizer 本身不会主动返回它。 + ErrForbiddenChannel = errors.New("channel subscription forbidden") +) + +// AllowAllAuthorizer 提供一套最宽松的授权实现。 +// 它的职责不是做业务鉴权,而是在缺省场景下兜底保证 SSE 基础设施可以跑通。 +// 注意: +// - 这里仍然会拒绝 query token,因为这是基础接入安全底线。 +// - 其余字段只做透传,不在这一层引入业务判断。 +type AllowAllAuthorizer struct{} + +// AuthorizeConnect 负责在连接建立前生成 Principal。 +// 参数: +// - ctx:连接建立阶段的上下文,保留给上层实现接入审计、超时控制或链路透传。 +// - req:客户端发起连接时携带的连接元信息。 +// +// 返回值: +// - *Principal:后续连接注册和事件过滤阶段都会复用的身份快照。 +// - error:当接入方式不被允许时返回错误。 +// +// 核心流程: +// 1. 先拒绝 query token,避免把认证信息暴露到 URL。 +// 2. 再把请求中的主体信息封装成 Principal,供后续链路共享。 +// +// 注意事项: +// - 这里不校验 subject 与 tenant 的真实性;根据上下文推测,真实鉴权会由更具体的 Authorizer 接管。 +func (a *AllowAllAuthorizer) AuthorizeConnect(ctx context.Context, req ConnectRequest) (*Principal, error) { + _ = ctx + + // 先执行基础安全校验,避免后续链路在无意中接受高风险的 token 传输方式。 + if req.QueryToken != "" { + return nil, ErrQueryTokenNotAllowed + } + + // 这里直接构造 Principal,是为了让后续 Broker 与 FilterEvent 可以拿到统一的主体快照。 + return &Principal{ + SubjectID: req.SubjectID, + TenantID: req.TenantID, + Origin: req.Origin, + }, nil +} + +// AuthorizeSubscribe 负责校验主体是否允许订阅目标 channel。 +// 参数: +// - ctx:订阅阶段上下文。 +// - principal:连接阶段已经确认的主体信息。 +// - channel:即将订阅的目标频道。 +// +// 返回值: +// - error:返回 nil 表示允许订阅。 +// +// 核心流程: +// 1. 当前实现仅保留方法位点,不做业务授权。 +// 2. 统一返回 nil,让调用方可以使用一套固定接口而不关心具体实现。 +// +// 注意事项: +// - 之所以保留空实现,而不是让调用方直接跳过,是为了后续替换为真实授权器时不改调用链。 +func (a *AllowAllAuthorizer) AuthorizeSubscribe(ctx context.Context, principal *Principal, channel string) error { + _ = ctx + _ = principal + _ = channel + return nil +} + +// FilterEvent 负责在事件写出前按主体做最终过滤。 +// 参数: +// - ctx:事件发送阶段上下文。 +// - principal:当前连接对应的主体快照。 +// - evt:待发送事件。 +// +// 返回值: +// - *StreamEvent:允许发送的事件;返回 nil 表示该事件应被丢弃。 +// - error:过滤阶段出现的异常。 +// +// 核心流程: +// 1. 当前实现不改写事件内容。 +// 2. 直接返回原始事件,保持链路最小干预。 +// +// 注意事项: +// - 如果后续要按租户、组织或字段级权限裁剪事件,应优先在这里做,而不是散落在各个调用方。 +func (a *AllowAllAuthorizer) FilterEvent(ctx context.Context, principal *Principal, evt *StreamEvent) (*StreamEvent, error) { + _ = ctx + _ = principal + return evt, nil +} diff --git a/internal/infrastructure/sse/backplane_pubsub.go b/internal/infrastructure/sse/backplane_pubsub.go new file mode 100644 index 0000000..7e07a5a --- /dev/null +++ b/internal/infrastructure/sse/backplane_pubsub.go @@ -0,0 +1,206 @@ +package sse + +import ( + "context" + "encoding/json" + "strings" + + "github.com/go-redis/redis/v8" +) + +// PubSubBackplane 封装 Redis Pub/Sub 作为多实例之间的广播背板。 +// 它只负责跨进程转发事件,不参与连接生命周期管理;真正的连接分发仍由本地 Broker 负责。 +type PubSubBackplane struct { + client *redis.Client + eventChannel string + revokeChannel string +} + +// NewPubSubBackplane 创建一个基于 Redis Pub/Sub 的背板实现。 +// 参数: +// - client:Redis 客户端;为空时对象仍可创建,但发布/订阅调用会直接降级为空操作。 +// - prefix:频道名前缀,用于不同环境或业务域隔离消息空间。 +// +// 返回值: +// - *PubSubBackplane:可选启用的背板实例。 +// +// 核心流程: +// 1. 先清洗 prefix,避免首尾空白导致频道命名不一致。 +// 2. 若未显式配置前缀,则回退到统一的 `sse` 默认值。 +// +// 注意事项: +// - 频道命名在这里统一收口,是为了避免发布端和订阅端各自拼接造成不兼容。 +func NewPubSubBackplane(client *redis.Client, prefix string) *PubSubBackplane { + base := strings.TrimSpace(prefix) + if base == "" { + base = "sse" + } + return &PubSubBackplane{ + client: client, + eventChannel: base + ":events", + revokeChannel: base + ":revoke", + } +} + +// Publish 负责把普通流事件广播到 Redis 背板。 +// 参数: +// - ctx:发布阶段上下文,用于超时、取消和链路透传。 +// - evt:待广播的流事件。 +// +// 返回值: +// - error:序列化或 Redis 发布失败时返回错误。 +// +// 核心流程: +// 1. 先做空指针和空客户端兜底,让未启用背板的环境保持无害降级。 +// 2. 再把事件序列化成 JSON,保证跨实例传输结构稳定。 +// 3. 最后通过 Redis Pub/Sub 发布到统一事件频道。 +// +// 注意事项: +// - 这里返回 nil 而不是报错终止,是因为“未配置背板”属于可接受部署形态,不应放大成业务异常。 +func (p *PubSubBackplane) Publish(ctx context.Context, evt *StreamEvent) error { + if p == nil || p.client == nil || evt == nil { + return nil + } + + // 统一序列化为 JSON,便于不同实例按同一结构反序列化,不依赖内存共享。 + raw, err := json.Marshal(evt) + if err != nil { + return err + } + + // Redis 的 Publish 是一次性操作,返回错误时交由上层决定是否重试或降级。 + return p.client.Publish(ctx, p.eventChannel, raw).Err() +} + +// Subscribe 负责持续订阅普通流事件并交给调用方处理。 +// 参数: +// - ctx:订阅生命周期上下文;取消时会主动退出循环并释放 Redis 订阅。 +// - handler:每条事件的处理函数。 +// +// 返回值: +// - error:上下文取消或 handler 返回错误时结束并返回对应错误。 +// +// 核心流程: +// 1. 先校验依赖是否齐全,未启用背板时直接空返回。 +// 2. 建立 Redis 订阅并在函数退出时关闭,避免连接泄露。 +// 3. 循环读取消息、反序列化事件并交给 handler。 +// +// 注意事项: +// - 反序列化失败的消息会被跳过而不是终止订阅,因为单条脏消息不应拖垮整条广播链路。 +func (p *PubSubBackplane) Subscribe( + ctx context.Context, + handler func(context.Context, *StreamEvent) error, +) error { + if p == nil || p.client == nil || handler == nil { + return nil + } + + // 订阅对象必须在退出时关闭,否则 Redis 客户端会一直保留服务器侧订阅状态。 + sub := p.client.Subscribe(ctx, p.eventChannel) + defer func() { _ = sub.Close() }() + + ch := sub.Channel() + for { + select { + // 上下文结束时立即退出,确保外部停机、重载或请求取消能及时生效。 + case <-ctx.Done(): + return ctx.Err() + + // 持续消费背板消息;nil 消息直接跳过,避免意外值触发空指针。 + case msg := <-ch: + if msg == nil { + continue + } + + // 对单条异常消息做隔离处理,避免整个订阅 goroutine 因脏数据退出。 + var evt StreamEvent + if err := json.Unmarshal([]byte(msg.Payload), &evt); err != nil { + continue + } + + // handler 返回错误时直接上抛,让调用方决定是否重建订阅或整体 fail-fast。 + if err := handler(ctx, &evt); err != nil { + return err + } + } + } +} + +// PublishRevoke 负责广播“撤销某主体连接”的控制命令。 +// 参数: +// - ctx:发布阶段上下文。 +// - revoke:撤销指令,通常用于跨实例同步踢线。 +// +// 返回值: +// - error:序列化或发布失败时返回错误。 +// +// 核心流程: +// 1. 未启用背板时直接降级为无操作。 +// 2. 序列化撤销命令后发布到独立频道,避免与普通事件混流。 +// +// 注意事项: +// - 普通业务事件与 revoke 指令拆频道,是为了让消费端按语义使用不同处理路径。 +func (p *PubSubBackplane) PublishRevoke(ctx context.Context, revoke RevokeCommand) error { + if p == nil || p.client == nil { + return nil + } + + raw, err := json.Marshal(revoke) + if err != nil { + return err + } + return p.client.Publish(ctx, p.revokeChannel, raw).Err() +} + +// SubscribeRevoke 负责持续监听撤销命令并交给调用方执行。 +// 参数: +// - ctx:订阅生命周期上下文。 +// - handler:收到撤销命令后的处理函数。 +// +// 返回值: +// - error:上下文取消或 handler 返回错误时结束。 +// +// 核心流程: +// 1. 建立 revoke 专用订阅。 +// 2. 循环反序列化命令并调用 handler。 +// 3. 在退出前关闭订阅对象,释放资源。 +// +// 注意事项: +// - 撤销命令是控制面消息,处理失败通常意味着跨实例一致性受影响,因此这里不会吞掉 handler 错误。 +func (p *PubSubBackplane) SubscribeRevoke( + ctx context.Context, + handler func(context.Context, RevokeCommand) error, +) error { + if p == nil || p.client == nil || handler == nil { + return nil + } + + sub := p.client.Subscribe(ctx, p.revokeChannel) + defer func() { _ = sub.Close() }() + + ch := sub.Channel() + for { + select { + // 统一尊重外部生命周期,避免后台订阅在服务停止后继续运行。 + case <-ctx.Done(): + return ctx.Err() + + // 逐条消费 revoke 命令;消息为空时直接跳过,保持循环健壮性。 + case msg := <-ch: + if msg == nil { + continue + } + + // 单条命令反序列化失败只影响当前消息,不让整个控制链路提前退出。 + var revoke RevokeCommand + if err := json.Unmarshal([]byte(msg.Payload), &revoke); err != nil { + continue + } + + // 控制命令处理失败需要尽快暴露给上层,否则撤销行为可能悄悄失效。 + if err := handler(ctx, revoke); err != nil { + return err + } + } + } +} diff --git a/internal/infrastructure/sse/broker.go b/internal/infrastructure/sse/broker.go new file mode 100644 index 0000000..2b2cbe2 --- /dev/null +++ b/internal/infrastructure/sse/broker.go @@ -0,0 +1,363 @@ +package sse + +import ( + "errors" + "sync" + "sync/atomic" +) + +// ErrBrokerDraining 表示 Broker 已进入排空阶段,不再接受新连接。 +// 停机时显式返回该错误,比继续注册后又立刻断开更利于调用方判断系统状态。 +var ErrBrokerDraining = errors.New("sse broker is draining") + +// Broker 负责维护本进程内所有 SSE 连接的注册表和分发索引。 +// 它同时按连接 ID、主体 ID 和频道维持多组索引,以便快速完成广播、踢线和统计。 +type Broker struct { + policy ConnectionPolicy + + mu sync.RWMutex + conns map[string]*Connection + bySubject map[uint64]map[string]*Connection + byChannel map[string]map[string]*Connection + + draining atomic.Bool + droppedSlowConsumers atomic.Int64 +} + +// NewBroker 创建一个带连接策略的 Broker。 +// 参数: +// - policy:连接和分发策略;会在这里统一归一化,避免调用方重复处理默认值。 +// +// 返回值: +// - *Broker:可直接用于注册和分发连接的实例。 +// +// 核心流程: +// 1. 归一化策略,确保队列容量、心跳和连接上限都有稳定默认值。 +// 2. 初始化多组索引 map,为后续 O(1) 级别查找做准备。 +// +// 注意事项: +// - 策略归一化放在构造函数里,是为了保证 Broker 内部始终只面对一套确定配置。 +func NewBroker(policy ConnectionPolicy) *Broker { + return &Broker{ + policy: policy.Normalize(), + conns: make(map[string]*Connection), + bySubject: make(map[uint64]map[string]*Connection), + byChannel: make(map[string]map[string]*Connection), + } +} + +// Register 负责把连接纳入 Broker 管理并启动其事件循环。 +// 参数: +// - conn:待注册连接。 +// +// 返回值: +// - error:连接为空、Broker 排空中,或主体连接数超限时返回错误。 +// +// 核心流程: +// 1. 先做无锁快速失败,减少排空阶段的锁竞争。 +// 2. 加写锁后二次确认状态,避免在竞争窗口内接入新连接。 +// 3. 更新总索引、主体索引和频道索引,并注册 onClose 回调。 +// 4. 最后启动连接 goroutine,让连接真正开始消费事件。 +// +// 注意事项: +// - `conn.Start()` 放在索引写入之后,是为了确保 goroutine 一旦启动,外部就能通过 Broker 找到它。 +func (b *Broker) Register(conn *Connection) error { + // 确保有问题时,快速失败 + if conn == nil { + return errors.New("connection is nil") + } + if b.draining.Load() { + return ErrBrokerDraining + } + + b.mu.Lock() + defer b.mu.Unlock() + + // 再次检查 draining 状态,是为了覆盖拿锁前后之间的竞态窗口。 + if b.draining.Load() { + return ErrBrokerDraining + } + + // 主体连接上限在注册阶段统一拦截,避免单个用户创建过多空闲连接拖垮节点资源。 + if conn.Principal != nil && conn.Principal.SubjectID > 0 { + if existing := len(b.bySubject[conn.Principal.SubjectID]); existing >= b.policy.MaxConnectionsPerSubject { + return errors.New("too many active stream connections for subject") + } + } + + // 先写主索引,再写辅助索引,保证后续注销时总能从 conns 找到连接对象。 + b.conns[conn.ID] = conn + if conn.Principal != nil && conn.Principal.SubjectID > 0 { + if b.bySubject[conn.Principal.SubjectID] == nil { + b.bySubject[conn.Principal.SubjectID] = make(map[string]*Connection) + } + b.bySubject[conn.Principal.SubjectID][conn.ID] = conn + } + if conn.Channel != "" { + if b.byChannel[conn.Channel] == nil { + b.byChannel[conn.Channel] = make(map[string]*Connection) + } + b.byChannel[conn.Channel][conn.ID] = conn + } + + // 关闭回调收口到 Broker,是为了保证连接自发关闭时索引也能同步清理。 + conn.onClose = func(c *Connection) { + b.unregisterInternal(c.ID, false) + } + + // 注册完成后再启动 goroutine,避免连接启动后还未被索引收录的短暂不可见状态。 + conn.Start() + return nil +} + +// Unregister 主动从 Broker 中移除连接。 +// 参数: +// - connID:待移除连接的唯一 ID。 +// +// 返回值: +// - 无。 +// +// 核心流程: +// 1. 委托给统一的内部注销逻辑。 +// 2. 明确要求关闭连接,避免只删索引不关底层 writer。 +// +// 注意事项: +// - 对外入口统一走这里,是为了确保“移除连接”和“关闭连接”语义保持一致。 +func (b *Broker) Unregister(connID string) { + b.unregisterInternal(connID, true) +} + +// unregisterInternal 负责执行真正的索引清理与可选关闭动作。 +// 参数: +// - connID:待移除连接 ID。 +// - closeConn:为 true 时在移除索引后主动关闭连接。 +// +// 返回值: +// - 无。 +// +// 核心流程: +// 1. 在锁内删除主索引和所有辅助索引。 +// 2. 在锁外执行连接关闭,避免关闭回调或 writer 逻辑放大锁持有时间。 +// +// 注意事项: +// - 锁外关闭是关键设计点,否则连接关闭链路若再次回调 Broker,会形成死锁风险。 +func (b *Broker) unregisterInternal(connID string, closeConn bool) { + var conn *Connection + + b.mu.Lock() + conn = b.conns[connID] + if conn != nil { + delete(b.conns, connID) + + // 主体索引为空时及时回收子 map,避免长时间积累空桶。 + if conn.Principal != nil && conn.Principal.SubjectID > 0 { + if conns := b.bySubject[conn.Principal.SubjectID]; conns != nil { + delete(conns, connID) + if len(conns) == 0 { + delete(b.bySubject, conn.Principal.SubjectID) + } + } + } + + // 频道索引同样在最后一个连接离开时删除,保证统计数据准确。 + if conn.Channel != "" { + if conns := b.byChannel[conn.Channel]; conns != nil { + delete(conns, connID) + if len(conns) == 0 { + delete(b.byChannel, conn.Channel) + } + } + } + } + b.mu.Unlock() + + // 关闭动作放到锁外执行,既减少锁冲突,也避免 onClose 回调产生重入问题。 + if closeConn && conn != nil { + conn.Close("unregister") + } +} + +// PublishToSubject 负责向某个主体的全部连接广播事件。 +// 参数: +// - subjectID:目标主体 ID。 +// - evt:待发送事件。 +// +// 返回值: +// - int:成功进入连接发送队列的数量。 +// +// 核心流程: +// 1. 先基于读锁生成连接快照。 +// 2. 再在锁外执行真正分发,降低广播期间的锁持有时间。 +// +// 注意事项: +// - 使用快照而不是直接持锁遍历,是为了避免慢写连接阻塞后续注册与注销。 +func (b *Broker) PublishToSubject(subjectID uint64, evt *StreamEvent) int { + conns := b.snapshotBySubject(subjectID) + return b.publish(conns, evt) +} + +// PublishToChannel 负责向某个频道的全部连接广播事件。 +// 参数、返回值和注意事项与 PublishToSubject 相同,只是索引维度换成频道。 +func (b *Broker) PublishToChannel(channel string, evt *StreamEvent) int { + conns := b.snapshotByChannel(channel) + return b.publish(conns, evt) +} + +// publish 负责把事件投递到一组连接的本地队列。 +// 参数: +// - conns:广播目标连接快照。 +// - evt:待发送事件。 +// +// 返回值: +// - int:成功入队的连接数。 +// +// 核心流程: +// 1. 空事件或空连接集直接返回,避免无意义工作。 +// 2. 尝试非阻塞入队,保持广播路径不会被慢消费者拖住。 +// 3. 对入队失败的慢连接统一记数并注销。 +// +// 注意事项: +// - 这里选择“踢掉慢消费者”而不是阻塞等待,是为了优先保护整体广播吞吐和服务可用性。 +func (b *Broker) publish(conns []*Connection, evt *StreamEvent) int { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + if evt == nil || len(conns) == 0 { + return 0 + } + + delivered := 0 + var slow []string + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + for _, conn := range conns { + if conn.Enqueue(evt) { + delivered++ + continue + } + slow = append(slow, conn.ID) + } + + // 慢消费者统一在第二阶段处理,避免遍历过程中边遍历边修改快照语义不清。 + if len(slow) > 0 { + for _, connID := range slow { + b.droppedSlowConsumers.Add(1) + b.Unregister(connID) + } + } + + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return delivered +} + +// RevokeSubject 负责主动关闭某主体的全部连接。 +// 参数: +// - subjectID:待撤销主体 ID。 +// - reason:关闭原因,会写入连接状态供排障使用。 +// +// 返回值: +// - int:被关闭连接数量。 +// +// 核心流程: +// 1. 先拍快照,避免关闭过程中影响遍历稳定性。 +// 2. 逐条调用连接关闭,让 onClose 回调自行完成索引清理。 +// +// 注意事项: +// - 关闭原因统一传入,是为了后续区分“用户主动退出”“系统排空”“权限撤销”等场景。 +func (b *Broker) RevokeSubject(subjectID uint64, reason string) int { + conns := b.snapshotBySubject(subjectID) + for _, conn := range conns { + conn.Close(reason) + } + return len(conns) +} + +// Stats 返回当前 Broker 的轻量级统计视图。 +// 参数:无。 +// 返回值: +// - BrokerStats:连接数、主体数、频道数以及慢消费者丢弃次数。 +// +// 核心流程: +// 1. 在读锁下读取索引尺寸。 +// 2. 原子读取累计指标,避免因统计而阻塞写路径。 +// +// 注意事项: +// - 统计值是瞬时快照,不保证跨字段的严格事务一致性,但足以用于观测和告警。 +func (b *Broker) Stats() BrokerStats { + b.mu.RLock() + defer b.mu.RUnlock() + return BrokerStats{ + Connections: len(b.conns), + Subjects: len(b.bySubject), + Channels: len(b.byChannel), + DroppedSlowConsumers: b.droppedSlowConsumers.Load(), + } +} + +// BeginDrain 负责把 Broker 切换到排空模式并主动关闭所有现存连接。 +// 参数:无。 +// 返回值:无。 +// 核心流程: +// 1. 通过原子状态只允许第一次调用生效,避免重复排空。 +// 2. 获取全部连接快照并逐个关闭。 +// +// 注意事项: +// - 先切状态再关连接,是为了让新的 Register 调用立即失败,而不是继续接受连接后再关闭。 +func (b *Broker) BeginDrain() { + if !b.draining.CompareAndSwap(false, true) { + return + } + for _, conn := range b.snapshotAll() { + conn.Close("drain") + } +} + +// Close 负责对外暴露统一的关闭入口。 +// 参数:无。 +// 返回值:无。 +// 核心流程: +// 1. 委托给 BeginDrain。 +// +// 注意事项: +// - 保留 Close 方法是为了让 Broker 更容易接入通用资源生命周期接口。 +func (b *Broker) Close() { + b.BeginDrain() +} + +// snapshotBySubject 负责在读锁下复制某主体的连接列表。 +// 之所以返回切片副本,是为了把后续广播逻辑挪到锁外执行,降低锁竞争。 +func (b *Broker) snapshotBySubject(subjectID uint64) []*Connection { + b.mu.RLock() + defer b.mu.RUnlock() + conns := b.bySubject[subjectID] + result := make([]*Connection, 0, len(conns)) + for _, conn := range conns { + result = append(result, conn) + } + return result +} + +// snapshotByChannel 负责在读锁下复制某频道的连接列表。 +// 它与 snapshotBySubject 的设计目标一致,都是用“快照换低锁占用”。 +func (b *Broker) snapshotByChannel(channel string) []*Connection { + b.mu.RLock() + defer b.mu.RUnlock() + conns := b.byChannel[channel] + result := make([]*Connection, 0, len(conns)) + for _, conn := range conns { + result = append(result, conn) + } + return result +} + +// snapshotAll 负责复制当前 Broker 管理的全部连接。 +// 停机排空场景用它而不是直接持锁遍历,是为了避免连接关闭回调与 Broker 锁互相等待。 +func (b *Broker) snapshotAll() []*Connection { + b.mu.RLock() + defer b.mu.RUnlock() + result := make([]*Connection, 0, len(b.conns)) + for _, conn := range b.conns { + result = append(result, conn) + } + return result +} diff --git a/internal/infrastructure/sse/connection.go b/internal/infrastructure/sse/connection.go new file mode 100644 index 0000000..e53d6b3 --- /dev/null +++ b/internal/infrastructure/sse/connection.go @@ -0,0 +1,221 @@ +package sse + +import ( + "context" + "sync" +) + +// Connection 表示一条已经通过鉴权并准备进入 Broker 管理的流连接。 +// 它内部通过独立 goroutine 负责心跳与事件写出,从而把慢客户端与上游广播逻辑解耦。 +type Connection struct { + // 身份和归属信息 + ID string + Principal *Principal + Channel string + + // 运行依赖 + writer StreamWriter + policy ConnectionPolicy + queue chan *StreamEvent + + // 生命周期控制 + ctx context.Context + cancel context.CancelFunc + closed chan struct{} + + // 关闭状态与回调 + mu sync.RWMutex + closeReason string + closeOnce sync.Once + onClose func(*Connection) +} + +// NewConnection 创建一条带独立队列和取消能力的连接对象。 +// 参数: +// - parent:上游生命周期上下文;为空时会自动回退到 Background,避免后续调用 panic。 +// - id:连接唯一标识。 +// - principal:连接所属主体快照。 +// - channel:订阅频道。 +// - writer:真正负责把事件写到客户端的输出器。 +// - policy:连接策略,决定队列长度、心跳等行为。 +// +// 返回值: +// - *Connection:尚未启动事件循环的连接对象。 +// +// 核心流程: +// 1. 兜底 parent,确保任何来源都能安全构造连接。 +// 2. 归一化策略,统一默认值。 +// 3. 派生可取消上下文与固定容量队列。 +// +// 注意事项: +// - 这里只创建对象,不启动 goroutine;启动时机交给 Broker 控制,避免未注册就开始收发事件。 +func NewConnection( + parent context.Context, + id string, + principal *Principal, + channel string, + writer StreamWriter, + policy ConnectionPolicy, +) *Connection { + if parent == nil { + parent = context.Background() + } + + policy = policy.Normalize() + ctx, cancel := context.WithCancel(parent) + return &Connection{ + ID: id, + Principal: principal, + Channel: channel, + writer: writer, + policy: policy, + queue: make(chan *StreamEvent, policy.QueueCapacity), + ctx: ctx, + cancel: cancel, + closed: make(chan struct{}), + } +} + +// Start 启动连接内部的事件循环 goroutine。 +// 参数:无。 +// 返回值:无。 +// 核心流程: +// 1. 异步进入 loop,开始消费队列和发送心跳。 +// +// 注意事项: +// - 这里不做幂等保护;根据上下文推测,调用方约定每个连接只会在注册成功后启动一次。 +func (c *Connection) Start() { + go c.loop() +} + +// Done 返回连接关闭通知通道。 +// 参数:无。 +// 返回值: +// - <-chan struct{}:连接彻底关闭时会被关闭的信号通道。 +// +// 核心流程: +// 1. 直接暴露只读通道给外部等待。 +// +// 注意事项: +// - 统一使用关闭 channel 表示结束,比发送单次值更适合多个等待者同时监听。 +func (c *Connection) Done() <-chan struct{} { + return c.closed +} + +// Enqueue 尝试把事件放入连接的待发送队列。 +// 参数: +// - evt:待发送事件。 +// +// 返回值: +// - bool:true 表示成功入队;false 表示连接已关闭或队列已满。 +// +// 核心流程: +// 1. 先快速检查连接是否已经关闭,避免向已结束连接继续写入。 +// 2. 再进行非阻塞发送,保证慢消费者不会反向阻塞 Broker 广播线程。 +// +// 注意事项: +// - 非阻塞入队是保护整体吞吐的关键;队列满时由 Broker 决定是否踢线。 +func (c *Connection) Enqueue(evt *StreamEvent) bool { + select { + case <-c.closed: + return false + default: + } + + select { + case c.queue <- evt: + return true + default: + return false + } +} + +// Close 负责以幂等方式关闭连接并记录关闭原因。 +// 参数: +// - reason:关闭原因,用于观测和排障。 +// +// 返回值:无。 +// 核心流程: +// 1. 通过 sync.Once 保证只执行一次真正关闭。 +// 2. 记录原因并取消上下文,唤醒 loop 退出。 +// 3. 关闭 done 通道并通知 Broker 清理索引。 +// +// 注意事项: +// - 关闭顺序先 cancel 再 close(closed),是为了让 loop 中依赖 ctx 的写操作尽快感知退出。 +func (c *Connection) Close(reason string) { + c.closeOnce.Do(func() { + c.mu.Lock() + c.closeReason = reason + c.mu.Unlock() + + c.cancel() + close(c.closed) + + // 通过回调把索引清理收口到 Broker,避免 Connection 自己依赖 Broker 具体实现。 + if c.onClose != nil { + c.onClose(c) + } + }) +} + +// CloseReason 返回连接最近一次关闭时记录的原因。 +// 参数:无。 +// 返回值: +// - string:关闭原因;未关闭时可能为空字符串。 +// +// 核心流程: +// 1. 通过读锁读取共享字段,避免与 Close 并发写产生数据竞争。 +// +// 注意事项: +// - 返回空串不一定表示异常,也可能只是连接仍处于活动状态。 +func (c *Connection) CloseReason() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.closeReason +} + +// loop 负责持续消费事件队列并按策略发送心跳。 +// 参数:无。 +// 返回值:无。 +// 核心流程: +// 1. 先校验 writer,缺失时立即关闭连接,避免 goroutine 空转。 +// 2. 启动心跳 ticker,定期向客户端发送 keepalive 防止中间链路超时。 +// 3. 在同一个 select 中统一处理上下文取消、事件发送和心跳发送。 +// +// 注意事项: +// - 任何一次写事件或写心跳失败都会关闭连接,因为这通常意味着底层 HTTP 流已不可用。 +func (c *Connection) loop() { + if c.writer == nil { + c.Close("writer_missing") + return + } + + ticker := timeTicker(c.policy.HeartbeatInterval) + defer ticker.Stop() + + for { + select { + // 上下文结束说明上游已要求断开连接,此时统一收口关闭原因。 + case <-c.ctx.Done(): + c.Close("context_done") + return + + // 事件队列中的消息优先走正式写出路径;空事件直接跳过,避免无意义写操作。 + case evt := <-c.queue: + if evt == nil { + continue + } + if err := c.writer.WriteEvent(c.ctx, evt); err != nil { + c.Close("write_failed") + return + } + + // 心跳用于维持长连接活性;失败通常说明客户端已断开或代理不再接受写入。 + case <-ticker.C(): + if err := c.writer.WriteHeartbeat(c.ctx); err != nil { + c.Close("heartbeat_failed") + return + } + } + } +} diff --git a/internal/infrastructure/sse/handler.go b/internal/infrastructure/sse/handler.go new file mode 100644 index 0000000..0697103 --- /dev/null +++ b/internal/infrastructure/sse/handler.go @@ -0,0 +1,91 @@ +package sse + +import ( + "context" + "strings" + + "github.com/google/uuid" +) + +// ChannelStreamHandler 负责把“连接请求”转换成受 Broker 管理的长连接会话。 +// 它位于鉴权、历史回放和实时订阅之间的编排层,不承担具体写出和存储实现。 +type ChannelStreamHandler struct { + Broker ConnectionBroker + Replay ReplayStore + Authorizer Authorizer + Policy ConnectionPolicy +} + +// Serve 负责处理一次 channel 级 SSE 连接请求。 +// 参数: +// - ctx:本次连接的生命周期上下文。 +// - req:连接请求元信息,包含 channel、Last-Event-ID 等。 +// - writer:流写出器,负责把历史事件与实时事件发送给客户端。 +// +// 返回值: +// - error:鉴权、回放、注册或写出阶段失败时返回错误。 +// +// 核心流程: +// 1. 兜底空上下文和空授权器,保证基础链路可运行。 +// 2. 完成连接鉴权与订阅授权。 +// 3. 根据 Last-Event-ID 回放缺失事件,减少客户端重连后的消息丢失。 +// 4. 创建 Connection 并注册到 Broker,随后阻塞等待连接结束。 +// +// 注意事项: +// - 回放发生在注册实时连接之前,是为了尽量缩小“历史补发”和“实时订阅”之间的消息缺口。 +func (h *ChannelStreamHandler) Serve( + ctx context.Context, + req ConnectRequest, + writer StreamWriter, +) error { + if h == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + if h.Authorizer == nil { + h.Authorizer = &AllowAllAuthorizer{} + } + + // 先建立主体身份,再决定是否允许进入后续订阅和事件过滤阶段。 + principal, err := h.Authorizer.AuthorizeConnect(ctx, req) + if err != nil { + return err + } + if err := h.Authorizer.AuthorizeSubscribe(ctx, principal, req.Channel); err != nil { + return err + } + + // 客户端携带 Last-Event-ID 时先做回放,尽量补齐重连期间错过的 durable 事件。 + if h.Replay != nil && strings.TrimSpace(req.LastEventID) != "" { + events, err := h.Replay.ReplayAfter(ctx, req.Channel, req.LastEventID, h.Policy.ReplayLimit) + if err != nil { + return err + } + for _, evt := range events { + // 过滤器返回 nil 时表示该主体不应再看到该事件,因此这里只跳过当前事件而不是整体报错。 + filtered, err := h.Authorizer.FilterEvent(ctx, principal, evt) + if err != nil || filtered == nil { + continue + } + if err := writer.WriteEvent(ctx, filtered); err != nil { + return err + } + } + } + + // 连接 ID 允许外部透传;为空时在这里补默认值,便于后续追踪和注销。 + connID := strings.TrimSpace(req.ConnID) + if connID == "" { + connID = uuid.NewString() + } + conn := NewConnection(ctx, connID, principal, req.Channel, writer, h.Policy) + if err := h.Broker.Register(conn); err != nil { + return err + } + + // 持续阻塞到连接结束,让调用方可以把 Serve 视为本次连接的同步生命周期。 + <-conn.Done() + return nil +} diff --git a/internal/infrastructure/sse/infrastructure.go b/internal/infrastructure/sse/infrastructure.go new file mode 100644 index 0000000..c5f9f21 --- /dev/null +++ b/internal/infrastructure/sse/infrastructure.go @@ -0,0 +1,67 @@ +package sse + +import ( + "context" + + "github.com/go-redis/redis/v8" +) + +// Infrastructure 聚合 SSE 所需的运行时基础设施。 +// 它把 Broker、回放存储和跨实例背板收口为一个对象,便于 core/init 统一初始化和关闭。 +type Infrastructure struct { + Broker *Broker // 本地连接 + ReplayStore ReplayStore + Backplane *PubSubBackplane // 多实例 + Policy ConnectionPolicy +} + +// NewInfrastructure 创建一套完整的 SSE 基础设施实例。 +// 参数: +// - client:Redis 客户端,用于回放与 Pub/Sub;为空时对应能力会自动降级。 +// - policy:连接策略。 +// - replayStreamPrefix:Redis Stream 前缀。 +// - pubSubPrefix:Pub/Sub 频道前缀。 +// +// 返回值: +// - *Infrastructure:聚合后的基础设施对象。 +// +// 核心流程: +// 1. 先归一化策略,确保所有子组件拿到同一套默认值。 +// 2. 创建本地 Broker。 +// 3. 创建回放存储与 Pub/Sub 背板。 +// +// 注意事项: +// - 这里统一装配而不是让各模块自行 new,是为了避免同一进程里出现多套 SSE 运行时实例。 +func NewInfrastructure( + client *redis.Client, + policy ConnectionPolicy, + replayStreamPrefix string, + pubSubPrefix string, +) *Infrastructure { + policy = policy.Normalize() + return &Infrastructure{ + Broker: NewBroker(policy), + ReplayStore: NewRedisReplayStore(client, replayStreamPrefix), + Backplane: NewPubSubBackplane(client, pubSubPrefix), + Policy: policy, + } +} + +// Close 负责关闭 SSE 基础设施当前进程内的活动连接。 +// 参数: +// - ctx:预留的关闭上下文;当前实现尚未消费该值。 +// +// 返回值:无。 +// 核心流程: +// 1. 判空保护,允许在未启用 SSE 的环境里安全调用。 +// 2. 通知 Broker 进入排空模式并关闭所有连接。 +// +// 注意事项: +// - 当前仅收口本地连接,不额外关闭 Redis 客户端;Redis 生命周期由更外层基础设施统一管理。 +func (i *Infrastructure) Close(ctx context.Context) { + _ = ctx + if i == nil || i.Broker == nil { + return + } + i.Broker.BeginDrain() +} diff --git a/internal/infrastructure/sse/interfaces.go b/internal/infrastructure/sse/interfaces.go new file mode 100644 index 0000000..b41f10f --- /dev/null +++ b/internal/infrastructure/sse/interfaces.go @@ -0,0 +1,66 @@ +package sse +/* +1. Authorizer 授权者 + 管“谁能连、谁能订阅、谁能看到什么” + +2. ConnectionBroker 连接经纪人 + 管“本机有哪些连接,消息怎么发给它们” + +3. StreamWriter + 管“怎么把消息写成 SSE 响应发给客户端” + +4. ReplayStore + 管“历史消息存哪、怎么补发” + +5. Backplane5. 背板 + 管“多机之间怎么同步消息和踢线命令” +*/ +import "context" + +// Authorizer 定义 SSE 接入链路的授权与事件过滤能力。 +// 它把“能否连”“能否订阅”“能看到什么事件”拆成三个阶段,方便按需替换实现。 +type Authorizer interface { + // AuthorizeConnect 在连接建立前生成主体身份快照。 + AuthorizeConnect(ctx context.Context, req ConnectRequest) (*Principal, error) // 连接前 + + // AuthorizeSubscribe 在主体身份已知后校验其是否允许订阅目标 channel。 + AuthorizeSubscribe(ctx context.Context, principal *Principal, channel string) error // 订阅前 + + // FilterEvent 在事件写出前做最后一层过滤或裁剪。 + FilterEvent(ctx context.Context, principal *Principal, evt *StreamEvent) (*StreamEvent, error) // 写出前 +} + +// ConnectionBroker 定义本地连接注册、广播和踢线的最小能力集合。 +// 业务层依赖接口而不是具体 Broker,有利于测试替身和未来实现替换。 +type ConnectionBroker interface { + Register(conn *Connection) error + Unregister(connID string) + PublishToSubject(subjectID uint64, evt *StreamEvent) int + PublishToChannel(channel string, evt *StreamEvent) int + RevokeSubject(subjectID uint64, reason string) int + Stats() BrokerStats +} + +// StreamWriter 抽象“如何把事件写到客户端”。 +// 这样 Connection 可以只关注生命周期与节流,而不直接依赖具体 HTTP 实现。 +type StreamWriter interface { + WriteEvent(ctx context.Context, evt *StreamEvent) error + WriteHeartbeat(ctx context.Context) error + WriteTerminal(ctx context.Context, evt *StreamEvent) error +} + +// ReplayStore 抽象 durable 事件的补发能力。 +// 只有实现了 Append 与 ReplayAfter,客户端断线重连后才有机会基于 Last-Event-ID 补齐消息。 +type ReplayStore interface { + Append(ctx context.Context, evt *StreamEvent) error // 追加 + ReplayAfter(ctx context.Context, channel string, lastEventID string, limit int) ([]*StreamEvent, error) // 补发limit条 +} + +// Backplane 抽象多实例之间的广播和撤销命令同步能力。 +// 它把数据面事件和控制面 revoke 命令分成两套接口,避免不同语义的消息混用。 +type Backplane interface { + Publish(ctx context.Context, evt *StreamEvent) error + Subscribe(ctx context.Context, handler func(context.Context, *StreamEvent) error) error + PublishRevoke(ctx context.Context, revoke RevokeCommand) error + SubscribeRevoke(ctx context.Context, handler func(context.Context, RevokeCommand) error) error +} diff --git a/internal/infrastructure/sse/replay_redis.go b/internal/infrastructure/sse/replay_redis.go new file mode 100644 index 0000000..9300888 --- /dev/null +++ b/internal/infrastructure/sse/replay_redis.go @@ -0,0 +1,218 @@ +package sse + +/* + 本接口,暂时只对 频道channel做了回放,后续如果需要对主体subject做回放。 +*/ + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/go-redis/redis/v8" +) + +// RedisReplayStore 使用 Redis Stream 保存可回放的 durable 事件。 +// 这里选择 Stream 而不是普通列表,是因为 Stream 天然支持按事件 ID 续读,适合 SSE 断线重连补发。 +type RedisReplayStore struct { + client *redis.Client + streamPrefix string +} + +// persistedStreamEvent 是写入 Redis 前的持久化载体。 +// 它把 `[]byte` 数据改为字符串,是为了简化 JSON 编码并避免 Redis 端出现二进制兼容问题。 +type persistedStreamEvent struct { + StreamKind string `json:"stream_kind"` + Channel string `json:"channel"` + TenantID uint64 `json:"tenant_id"` + SubjectID uint64 `json:"subject_id"` + EventName string `json:"event_name"` + Data string `json:"data"` + OccurredAt time.Time `json:"occurred_at"` + RetryMS int64 `json:"retry_ms"` + Durable bool `json:"durable"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + Meta map[string]string `json:"meta,omitempty"` +} + +// NewRedisReplayStore 创建一个 Redis Stream 回放存储。 +// 参数: +// - client:Redis 客户端。 +// - streamPrefix:流名前缀。 +// +// 返回值: +// - *RedisReplayStore:回放存储实例。 +// +// 核心流程: +// 1. 仅清洗前缀,不在此处探测 Redis 是否可用。 +// +// 注意事项: +// - 可用性检查延迟到真实读写阶段,是为了让初始化流程保持轻量,并由运行时错误决定是否降级。 +func NewRedisReplayStore(client *redis.Client, streamPrefix string) *RedisReplayStore { + return &RedisReplayStore{ + client: client, + streamPrefix: strings.TrimSpace(streamPrefix), + } +} + +// Append 负责把 durable 事件写入 Redis Stream。 +// 参数: +// - ctx:写入上下文。 +// - evt:待持久化事件。 +// +// 返回值: +// - error:序列化或 Redis 写入失败时返回错误。 +// +// 核心流程: +// 1. 未启用存储、事件为空或事件本身不可回放时直接空返回。 +// 2. 把运行时事件转换成持久化载体并编码成 JSON。 +// 3. 使用 XADD 追加到 channel 对应的 Stream 中。 +// 4. 若事件尚未带 EventID,则回填 Redis 生成的 Stream ID。 +// +// 注意事项: +// - 只有 `Durable=true` 的事件才入库,是为了把实时噪声与真正需要补发的状态变更区分开。 +func (r *RedisReplayStore) Append(ctx context.Context, evt *StreamEvent) error { + if r == nil || r.client == nil || evt == nil || !evt.Durable { + return nil + } + + // 先转换成稳定的持久化结构,避免直接把运行时对象序列化导致未来字段调整影响兼容性。 + store := persistedStreamEvent{ + StreamKind: string(evt.StreamKind), + Channel: evt.Channel, + TenantID: evt.TenantID, + SubjectID: evt.SubjectID, + EventName: evt.EventName, + Data: string(evt.Data), + OccurredAt: evt.OccurredAt, + RetryMS: evt.RetryMS, + Durable: evt.Durable, + RequestID: evt.RequestID, + TraceID: evt.TraceID, + Meta: evt.Meta, + } + raw, err := json.Marshal(store) + if err != nil { + return err + } + + // Stream ID 由 Redis 生成,天然适合用作 Last-Event-ID 的续读锚点。 + id, err := r.client.XAdd(ctx, &redis.XAddArgs{ + Stream: r.streamKey(evt.Channel), + Values: map[string]interface{}{"event": string(raw)}, + }).Result() + if err != nil { + return err + } + + // 回填 EventID 是为了让后续实时链路与回放链路使用同一套事件标识。 + if evt.EventID == "" { + evt.EventID = id + } + return nil +} + +// ReplayAfter 负责从某个 Last-Event-ID 之后读取历史事件。 +// 参数: +// - ctx:读取上下文。 +// - channel:目标频道。 +// - lastEventID:客户端最后一次确认收到的事件 ID。 +// - limit:最大回放条数。 +// +// 返回值: +// - []*StreamEvent:可继续发送给客户端的事件列表。 +// - error:Redis 读取失败时返回错误。 +// +// 核心流程: +// 1. 基础依赖为空时直接降级为空结果。 +// 2. 归一化 limit,并根据 lastEventID 生成排他起点。 +// 3. 读取 Stream 区间并逐条反序列化回运行时事件。 +// 4. 对损坏记录做跳过处理,保证回放链路尽量继续前进。 +// +// 注意事项: +// - 使用 `(%s` 构造起点是为了排除 lastEventID 本身,避免重连后把已收到的最后一条事件重复推送。 +func (r *RedisReplayStore) ReplayAfter( + ctx context.Context, + channel string, + lastEventID string, + limit int, +) ([]*StreamEvent, error) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + if r == nil || r.client == nil || channel == "" { + return nil, nil + } + if limit <= 0 { + limit = 100 + } + + start := "-" + if strings.TrimSpace(lastEventID) != "" { + start = fmt.Sprintf("(%s", strings.TrimSpace(lastEventID)) + } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + items, err := r.client.XRangeN(ctx, r.streamKey(channel), start, "+", int64(limit)).Result() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, err + } + + result := make([]*StreamEvent, 0, len(items)) + for _, item := range items { + raw, _ := item.Values["event"].(string) + if strings.TrimSpace(raw) == "" { + continue + } + + // 单条历史记录损坏时跳过当前项,而不是让整个重放失败,尽量提高重连恢复成功率。 + var persisted persistedStreamEvent + if err := json.Unmarshal([]byte(raw), &persisted); err != nil { + continue + } + result = append(result, &StreamEvent{ + EventID: item.ID, + StreamKind: StreamKind(persisted.StreamKind), + Channel: persisted.Channel, + TenantID: persisted.TenantID, + SubjectID: persisted.SubjectID, + EventName: persisted.EventName, + Data: []byte(persisted.Data), + OccurredAt: persisted.OccurredAt, + RetryMS: persisted.RetryMS, + Durable: persisted.Durable, + RequestID: persisted.RequestID, + TraceID: persisted.TraceID, + Meta: persisted.Meta, + }) + } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return result, nil +} + +// streamKey 负责把业务 channel 映射成 Redis Stream 键名。 +// 参数: +// - channel:业务频道名。 +// +// 返回值: +// - string:最终 Redis Stream key。 +// +// 核心流程: +// 1. 若未配置前缀则使用默认前缀。 +// 2. 统一 trim channel,避免因为空白差异导致写入和读取到不同 key。 +// +// 注意事项: +// - 键名拼装集中到这里,可以确保 Append 与 ReplayAfter 始终命中同一条 Stream。 +func (r *RedisReplayStore) streamKey(channel string) string { + prefix := r.streamPrefix + if prefix == "" { + prefix = "sse:replay" + } + return prefix + ":" + strings.TrimSpace(channel) +} diff --git a/internal/infrastructure/sse/time.go b/internal/infrastructure/sse/time.go new file mode 100644 index 0000000..11f21ff --- /dev/null +++ b/internal/infrastructure/sse/time.go @@ -0,0 +1,43 @@ +package sse + +import "time" + +// ticker 抽象最小定时器接口,方便在测试中替换真实 time.Ticker。 +// 这样 Connection.loop 不必直接依赖标准库具体类型,降低测试控制时间流逝的成本。 +type ticker interface { + C() <-chan time.Time + Stop() +} + +// stdTicker 是对标准库 time.Ticker 的轻量适配。 +// 它存在的意义是把标准库类型包一层,让业务逻辑只依赖接口。 +type stdTicker struct { + t *time.Ticker +} + +// C 返回定时事件通道。 +// 这里单独包一层,是为了让 Connection.loop 在测试时可替换为伪造实现。 +func (s *stdTicker) C() <-chan time.Time { + return s.t.C +} + +// Stop 停止底层定时器,避免 goroutine 与计时资源泄露。 +func (s *stdTicker) Stop() { + s.t.Stop() +} + +// timeTicker 创建一个标准库 ticker 适配器。 +// 参数: +// - d:触发间隔。 +// +// 返回值: +// - ticker:满足最小接口的实现。 +// +// 核心流程: +// 1. 包装 `time.NewTicker` 结果并返回接口类型。 +// +// 注意事项: +// - 之所以不直接在调用点创建 `time.Ticker`,是为了给测试预留替换位点。 +func timeTicker(d time.Duration) ticker { + return &stdTicker{t: time.NewTicker(d)} +} diff --git a/internal/infrastructure/sse/types.go b/internal/infrastructure/sse/types.go new file mode 100644 index 0000000..a34fadd --- /dev/null +++ b/internal/infrastructure/sse/types.go @@ -0,0 +1,136 @@ +package sse + +import "time" + +// StreamKind 描述事件是“会话级”还是“频道级”。 +// 通过显式类型区分,可以减少不同流语义之间的误用。 +type StreamKind string + +const ( + // StreamKindSession 表示事件属于单次会话流,通常只发给特定连接或主体。 + StreamKindSession StreamKind = "session" + + // StreamKindChannel 表示事件属于某个共享频道,通常需要广播给该频道下的多个连接。 + StreamKindChannel StreamKind = "channel" +) + +const ( + // IdleKickDisconnectSlowConsumer 表示慢消费者被检测到后直接断开连接。 + // 当前 Broker 的实现走的就是这一策略,因为它最有利于保护整体吞吐。 + IdleKickDisconnectSlowConsumer = "disconnect_slow_consumer" + + // IdleKickDropOldest 预留给未来“丢弃旧消息保留连接”的策略。 + // 当前代码中尚未启用该分支,但保留常量可让配置语义保持完整。 + IdleKickDropOldest = "drop_oldest" +) + +// StreamEvent 表示 SSE 链路中的标准事件结构。 +// 它同时覆盖实时发送、历史回放和跨实例广播三种场景,因此保留了较完整的上下文字段。 +type StreamEvent struct { + EventID string `json:"event_id"` // 唯一标志 + StreamKind StreamKind `json:"stream_kind"` // 会话级或频道级 + Channel string `json:"channel"` // 所属频道 + TenantID uint64 `json:"tenant_id"` // 租户ID + SubjectID uint64 `json:"subject_id"` // 发给谁 + EventName string `json:"event_name"` + Data []byte `json:"data"` + OccurredAt time.Time `json:"occurred_at"` + RetryMS int64 `json:"retry_ms"` // 断线重连时间,毫秒级 + Durable bool `json:"durable"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + Meta map[string]string `json:"meta,omitempty"` // 拓展字段 +} + +// ConnectionPolicy 定义连接管理和写出行为的关键运行参数。 +// 这些参数集中在一处,是为了让 Broker、Connection 和 Writer 使用同一套策略。 +type ConnectionPolicy struct { + HeartbeatInterval time.Duration // 心跳间隔 + WriteTimeout time.Duration // 写出超时 + QueueCapacity int // 写出队列容量 + MaxConnectionsPerSubject int // 每个主体的最大连接数 + ReplayLimit int // 回放限制 + IdleKickPolicy string // 空闲踢出策略 +} + +// Normalize 负责把连接策略补齐为可执行配置。 +// 参数:无。 +// 返回值: +// - ConnectionPolicy:填充默认值后的策略副本。 +// +// 核心流程: +// 1. 逐项检查时间、容量和限额字段是否有效。 +// 2. 对缺失项注入系统默认值,确保运行时不必反复判空。 +// +// 注意事项: +// - 返回的是副本而不是原地修改指针,便于调用方在不共享状态的前提下安全复用。 +func (p ConnectionPolicy) Normalize() ConnectionPolicy { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + if p.HeartbeatInterval <= 0 { + p.HeartbeatInterval = 20 * time.Second + } + if p.WriteTimeout <= 0 { + p.WriteTimeout = 10 * time.Second + } + if p.QueueCapacity <= 0 { + p.QueueCapacity = 64 + } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + if p.MaxConnectionsPerSubject <= 0 { + p.MaxConnectionsPerSubject = 3 + } + if p.ReplayLimit <= 0 { + p.ReplayLimit = 100 + } + if p.IdleKickPolicy == "" { + p.IdleKickPolicy = IdleKickDisconnectSlowConsumer + } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return p +} + +// Principal 表示 SSE 连接在基础设施层可识别的主体信息。 +// 它刻意只保留与流控制相关的通用字段,避免把过多业务模型耦合进基础设施。 +type Principal struct { + UserID uint + SubjectID uint64 + TenantID uint64 + OrgID uint + Origin string + Scopes []string + Meta map[string]string +} + +// ConnectRequest 表示建立连接时所需的元信息。 +// 根据上下文推测,该结构既可能来自 HTTP 请求解析,也可能来自更高层的协议适配层。 +type ConnectRequest struct { + ConnID string // 连接唯一标识 + StreamKind StreamKind + Channel string // 目标频道 + SubjectID uint64 + TenantID uint64 + Method string + LastEventID string // 用于断线重连时的事件回放 + Origin string // 连接来源 + QueryToken string + Headers map[string]string +} + +// RevokeCommand 表示跨实例同步的踢线命令。 +// 当某主体权限变化、被强制下线或会话失效时,可通过它触发各节点同步断开。 +type RevokeCommand struct { + SubjectID uint64 `json:"subject_id"` + Reason string `json:"reason"` +} + +// BrokerStats 是 Broker 的只读统计视图。 +// 这些指标主要用于监控和排障,不参与业务决策。 +type BrokerStats struct { + Connections int `json:"connections"` + Subjects int `json:"subjects"` + Channels int `json:"channels"` + DroppedSlowConsumers int64 `json:"dropped_slow_consumers"` +} diff --git a/internal/infrastructure/sse/writer.go b/internal/infrastructure/sse/writer.go new file mode 100644 index 0000000..c014ae1 --- /dev/null +++ b/internal/infrastructure/sse/writer.go @@ -0,0 +1,312 @@ +package sse + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// ErrStreamingUnsupported 表示底层 ResponseWriter 不支持流式刷新。 +// 在这种情况下继续走 SSE 会造成客户端长时间收不到数据,因此需要尽早报错。 +var ErrStreamingUnsupported = errors.New("streaming unsupported") + +// HTTPStreamWriter 负责把 StreamEvent 以 SSE 协议写入 HTTP 响应。 +// 它通过互斥锁串行化写操作,避免多 goroutine 并发写同一条 HTTP 连接导致协议内容交叉。 +type HTTPStreamWriter struct { + w http.ResponseWriter + rc *http.ResponseController + policy ConnectionPolicy + + mu sync.Mutex + prepared bool + started bool +} + +// NewHTTPStreamWriter 创建一个面向 HTTP 的 SSE 写出器。 +// 参数: +// - w:HTTP 响应写入器。 +// - policy:连接策略,主要用于写超时控制。 +// +// 返回值: +// - *HTTPStreamWriter:可复用的流写出器。 +// +// 核心流程: +// 1. 创建 ResponseController,优先使用更现代的 flush 与 deadline 能力。 +// 2. 归一化策略,确保写超时存在稳定默认值。 +// +// 注意事项: +// - 写出器本身不负责连接生命周期,仅负责协议层写入和 header 准备。 +func NewHTTPStreamWriter(w http.ResponseWriter, policy ConnectionPolicy) *HTTPStreamWriter { + return &HTTPStreamWriter{ + w: w, + rc: http.NewResponseController(w), + policy: policy.Normalize(), + } +} + +// Started 返回是否已经成功向客户端写出过任何内容。 +// 参数:无。 +// 返回值: +// - bool:true 表示响应头或事件正文已经开始发送。 +// +// 核心流程: +// 1. 通过互斥锁保护 started 标记,避免并发读写竞争。 +// +// 注意事项: +// - 控制器层会用它判断错误发生时能否继续回写标准 JSON 响应。 +func (h *HTTPStreamWriter) Started() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.started +} + +// WriteEvent 负责把一个标准事件编码并写出到客户端。 +// 参数: +// - ctx:写出上下文,用于截止时间控制。 +// - evt:待发送事件。 +// +// 返回值: +// - error:编码、写入或 flush 失败时返回错误。 +// +// 核心流程: +// 1. 空事件直接跳过,避免输出无意义帧。 +// 2. 先按 SSE 规范编码,再走统一 write 路径。 +// +// 注意事项: +// - 所有正式事件都走同一个底层 write,可以保证 header 准备与超时策略一致。 +func (h *HTTPStreamWriter) WriteEvent(ctx context.Context, evt *StreamEvent) error { + if evt == nil { + return nil + } + payload, err := EncodeEvent(evt) + if err != nil { + return err + } + return h.write(ctx, payload) +} + +// WriteHeartbeat 负责发送注释型心跳帧。 +// 参数: +// - ctx:写出上下文。 +// +// 返回值: +// - error:写入失败时返回错误。 +// +// 核心流程: +// 1. 复用统一 write 路径发送 `: keepalive` 注释帧。 +// +// 注意事项: +// - 选择注释帧而不是业务事件,是为了避免客户端把心跳误当成真实消息处理。 +func (h *HTTPStreamWriter) WriteHeartbeat(ctx context.Context) error { + return h.write(ctx, []byte(": keepalive\n\n")) +} + +// WriteTerminal 负责写出终止事件。 +// 参数与返回值与 WriteEvent 相同。 +// 核心流程: +// 1. 当前实现直接复用普通事件写出逻辑。 +// +// 注意事项: +// - 单独保留该方法是为了将来在终止帧上附加特殊 header 或埋点时不改接口。 +func (h *HTTPStreamWriter) WriteTerminal(ctx context.Context, evt *StreamEvent) error { + return h.WriteEvent(ctx, evt) +} + +// write 负责执行真正的 HTTP 写出。 +// 参数: +// - ctx:写出上下文。 +// - payload:已经编码好的 SSE 文本。 +// +// 返回值: +// - error:准备响应头、设置写超时、写正文或 flush 失败时返回错误。 +// +// 核心流程: +// 1. 通过互斥锁保证单连接写操作串行。 +// 2. 首次写入前准备 SSE 响应头并立即 flush。 +// 3. 为当前写操作设置 deadline,避免网络挂死时无限阻塞。 +// 4. 写入 payload 并再次 flush,让客户端尽快看到数据。 +// +// 注意事项: +// - started 只在真正写成功后置为 true,这样上层才能准确判断“是否还能退回普通响应”。 +func (h *HTTPStreamWriter) write(ctx context.Context, payload []byte) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + h.mu.Lock() + defer h.mu.Unlock() + + // 首次写入时必须先输出 SSE 所需响应头,否则客户端和代理可能不会按流式处理。 + if err := h.ensurePreparedLocked(); err != nil { + return err + } + + // 每次写入前都重置 deadline,避免长连接场景下复用过期的旧截止时间。 + if err := h.setWriteDeadlineLocked(ctx); err != nil { + return err + } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + if _, err := h.w.Write(payload); err != nil { + return err + } + if err := h.flushLocked(); err != nil { + return err + } + h.started = true + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return nil +} + +// ensurePreparedLocked 负责在首次写入前设置 SSE 所需响应头。 +// 参数:无。 +// 返回值: +// - error:首次 flush 失败时返回错误。 +// +// 核心流程: +// 1. 若已准备过则直接返回。 +// 2. 设置 SSE 所需的 Content-Type、禁止缓存和关闭代理缓冲。 +// 3. 发送 200 状态码并立即 flush,让客户端尽早进入流式读取状态。 +// +// 注意事项: +// - 这里要求调用方已持有互斥锁,避免 header 被并发重复写入。 +func (h *HTTPStreamWriter) ensurePreparedLocked() error { + if h.prepared { + return nil + } + header := h.w.Header() + header.Set("Content-Type", "text/event-stream; charset=utf-8") + header.Set("Cache-Control", "no-cache") + header.Set("X-Accel-Buffering", "no") + header.Set("Connection", "keep-alive") + h.w.WriteHeader(http.StatusOK) + h.prepared = true + return h.flushLocked() +} + +// setWriteDeadlineLocked 根据连接策略和上下文设置本次写入截止时间。 +// 参数: +// - ctx:可能自带 Deadline 的上下文。 +// +// 返回值: +// - error:底层 ResponseController 不支持以外的错误。 +// +// 核心流程: +// 1. 先以策略默认写超时作为基线。 +// 2. 若上下文已有更早的截止时间,则优先采用更严格的那个。 +// 3. 调用 ResponseController 设置 deadline。 +// +// 注意事项: +// - 忽略 `http.ErrNotSupported` 是为了兼容不支持 deadline 的 writer;这类场景仍可继续尝试流式输出。 +func (h *HTTPStreamWriter) setWriteDeadlineLocked(ctx context.Context) error { + deadline := time.Now().Add(h.policy.WriteTimeout) + if ctx != nil { + if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) { + deadline = dl + } + } + if err := h.rc.SetWriteDeadline(deadline); err != nil && !errors.Is(err, http.ErrNotSupported) { + return err + } + return nil +} + +// flushLocked 负责把已写入的字节尽快刷到客户端。 +// 参数:无。 +// 返回值: +// - error:当前 writer 完全不支持 flush 时返回 ErrStreamingUnsupported。 +// +// 核心流程: +// 1. 优先尝试 ResponseController.Flush。 +// 2. 若不支持,再退回传统的 http.Flusher。 +// 3. 两者都不可用时明确报错。 +// +// 注意事项: +// - 这里同样要求调用方已持有互斥锁,避免 flush 与并发写混在一起。 +func (h *HTTPStreamWriter) flushLocked() error { + if err := h.rc.Flush(); err == nil { + return nil + } + if flusher, ok := h.w.(http.Flusher); ok { + flusher.Flush() + return nil + } + return ErrStreamingUnsupported +} + +// LastEventIDFromRequest 从 HTTP 请求头中提取 SSE 标准的 Last-Event-ID。 +// 参数: +// - r:HTTP 请求对象。 +// +// 返回值: +// - string:客户端声明的最后已确认事件 ID。 +// +// 核心流程: +// 1. 对空请求做兜底。 +// 2. 直接读取标准 Header。 +// +// 注意事项: +// - 该函数只负责取值,不做格式校验;真正的回放语义由 ReplayStore 决定。 +func LastEventIDFromRequest(r *http.Request) string { + if r == nil { + return "" + } + return r.Header.Get("Last-Event-ID") +} + +// EncodeEvent 按 SSE 文本协议编码事件。 +// 参数: +// - evt:待编码事件。 +// +// 返回值: +// - []byte:符合 SSE 规范的文本负载。 +// - error:格式化写入失败时返回错误。 +// +// 核心流程: +// 1. 依次输出 retry、id、event 等元信息字段。 +// 2. 把 data 按行拆分成多个 `data:` 前缀,兼容多行文本。 +// 3. 最后补一个空行,表示当前事件结束。 +// +// 注意事项: +// - 先去掉 `\r` 再按 `\n` 分行,是为了兼容不同平台换行符并避免客户端看到重复空行。 +func EncodeEvent(evt *StreamEvent) ([]byte, error) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + if evt == nil { + return nil, nil + } + + var buf bytes.Buffer + if evt.RetryMS > 0 { + _, _ = fmt.Fprintf(&buf, "retry: %d\n", evt.RetryMS) + } + if evt.EventID != "" { + _, _ = fmt.Fprintf(&buf, "id: %s\n", evt.EventID) + } + if evt.EventName != "" { + _, _ = fmt.Fprintf(&buf, "event: %s\n", evt.EventName) + } + + // 先统一换行格式,再逐行输出 `data:`,这是 SSE 处理多行 payload 的标准方式。 + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + data := bytes.ReplaceAll(evt.Data, []byte("\r"), nil) + lines := bytes.Split(data, []byte("\n")) + for _, line := range lines { + if _, err := fmt.Fprintf(&buf, "data: %s\n", line); err != nil { + return nil, err + } + } + + // 事件之间必须用空行分隔,否则客户端无法正确识别边界。 + if _, err := io.WriteString(&buf, "\n"); err != nil { + return nil, err + } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return buf.Bytes(), nil +} diff --git a/internal/infrastructure/sse/writer_test.go b/internal/infrastructure/sse/writer_test.go new file mode 100644 index 0000000..75033df --- /dev/null +++ b/internal/infrastructure/sse/writer_test.go @@ -0,0 +1,50 @@ +package sse + +import ( + "strings" + "testing" +) + +// TestEncodeEvent_MultilineData 验证多行 data 在编码后会被拆成多条 `data:` 行。 +// 参数: +// - t:测试上下文。 +// +// 返回值:无。 +// 核心流程: +// 1. 构造包含重试、事件名和多行 data 的事件。 +// 2. 调用 EncodeEvent 得到实际 SSE 文本。 +// 3. 逐项断言关键片段都存在,确保编码格式符合预期。 +// +// 注意事项: +// - 这里不逐字节比较整段文本,而是按关键片段断言,能让测试在换行之外的轻微格式调整下更稳健。 +func TestEncodeEvent_MultilineData(t *testing.T) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + payload, err := EncodeEvent(&StreamEvent{ + EventID: "evt-1", + EventName: "assistant_token", + RetryMS: 1500, + Data: []byte("line1\nline2"), + }) + if err != nil { + t.Fatalf("EncodeEvent() error = %v", err) + } + + // 把结果转成文本后按关键片段核对,是为了明确覆盖 retry、id、event 和多行 data 的编码规则。 + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + text := string(payload) + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + for _, want := range []string{ + "retry: 1500\n", + "id: evt-1\n", + "event: assistant_token\n", + "data: line1\n", + "data: line2\n\n", + } { + if !strings.Contains(text, want) { + t.Fatalf("encoded payload missing %q, got %q", want, text) + } + } +} diff --git a/internal/init/init.go b/internal/init/init.go index d76ce16..f681388 100644 --- a/internal/init/init.go +++ b/internal/init/init.go @@ -22,6 +22,7 @@ import ( "personal_assistant/internal/service/system" ) +// Init 负责初始化当前模块所需的运行时资源。 func Init() { // 尝试加载 .env 文件, // 如果不存在也不报错(生产环境可能直接用环境变量) @@ -53,6 +54,8 @@ func Init() { // 连接redis global.Redis = core.ConnectRedis() + // 初始化项目级 SSE 基础设施(依赖 Redis) + core.InitSSEInfrastructure() // 初始化Casbin core.InitCasbin() // 初始化存储驱动(本地/七牛,七牛自动包装熔断器) diff --git a/internal/middleware/corsMW.go b/internal/middleware/corsMW.go index bb4a890..449e965 100644 --- a/internal/middleware/corsMW.go +++ b/internal/middleware/corsMW.go @@ -4,6 +4,8 @@ import ( "strings" "time" + "personal_assistant/global" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) @@ -12,20 +14,27 @@ import ( // Allows frontend from configured whitelist to access backend APIs with credentials func CORSMiddleware() gin.HandlerFunc { // Dynamic whitelist to ensure Access-Control-Allow-Origin echoes the exact Origin - allowed := map[string]bool{ - "http://localhost:3000": true, - "http://127.0.0.1:3000": true, - "http://192.168.20.14:3000": true, - // 如需从内网其他前端域调试,在此添加 - // "http://192.168.10.7:7000": true, - // "http://192.168.10.7:8082": true, + allowed := map[string]bool{} + for _, origin := range global.Config.SSE.AllowedOrigins { + origin = strings.TrimSpace(origin) + if origin != "" { + allowed[origin] = true + } + } + if len(allowed) == 0 { + allowed["http://localhost:3000"] = true + allowed["http://127.0.0.1:3000"] = true + allowed["http://192.168.20.14:3000"] = true } return cors.New(cors.Config{ AllowOriginFunc: func(origin string) bool { - // 只允许白名单中的Origin,且返回非*,以满足携带Cookie的跨域要求 - _ = allowed[strings.TrimSpace(origin)] - // 现在是允许所有通过 - return true + // // 只允许白名单中的Origin,且返回非*,以满足携带Cookie的跨域要求 + // origin = strings.TrimSpace(origin) + // if origin == "" { + // return true + // } + // return allowed[origin] + return true; }, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowHeaders: []string{"Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization", "X-Csrf-Token", "x-access-token", "Cookie", "Set-Cookie"}, diff --git a/internal/model/config/config.go b/internal/model/config/config.go index 73d54d3..01a431e 100644 --- a/internal/model/config/config.go +++ b/internal/model/config/config.go @@ -20,10 +20,24 @@ type Config struct { Crawler Crawler `json:"crawler" yaml:"crawler"` Task Task `json:"task" yaml:"task"` // 定时任务配置 Messaging Messaging `json:"messaging" yaml:"messaging"` // 消息队列配置 + SSE SSE `json:"sse" yaml:"sse"` // SSE 实时推送配置 RateLimit RateLimit `json:"rate_limit" yaml:"rate_limit"` // 限流配置 Observability Observability `json:"observability" yaml:"observability"` // 观测基础设施配置 } +// NewConfig 负责创建并返回当前对象所需的实例。 +// 参数: +// - 无。 +// +// 返回值: +// - *Config:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func NewConfig() *Config { // Redis配置初始化 _redis := &Redis{ @@ -283,6 +297,19 @@ func NewConfig() *Config { ), } + _sse := &SSE{ + HeartbeatIntervalSeconds: viper.GetInt("sse.heartbeat_interval_seconds"), + WriteTimeoutSeconds: viper.GetInt("sse.write_timeout_seconds"), + QueueCapacity: viper.GetInt("sse.queue_capacity"), + MaxConnectionsPerSubject: viper.GetInt("sse.max_connections_per_subject"), + ReplayLimit: viper.GetInt("sse.replay_limit"), + AllowedOrigins: viper.GetStringSlice("sse.allowed_origins"), + DrainTimeoutSeconds: viper.GetInt("sse.drain_timeout_seconds"), + PubSubChannelPrefix: viper.GetString("sse.pubsub_channel_prefix"), + ReplayStreamPrefix: viper.GetString("sse.replay_stream_prefix"), + AIRuntimeMode: viper.GetString("sse.ai_runtime_mode"), + } + _observability := &Observability{ Enabled: viper.GetBool("observability.enabled"), ServiceName: viper.GetString("observability.service_name"), @@ -354,11 +381,25 @@ func NewConfig() *Config { Crawler: *_crawler, Task: *_task, Messaging: *_messaging, + SSE: *_sse, RateLimit: *_rateLimit, Observability: *_observability, } } +// getCrawlerAPIPrefix 负责执行当前函数对应的核心逻辑。 +// 参数: +// - key:当前函数需要消费的输入参数。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func getCrawlerAPIPrefix(key string) string { if !viper.IsSet(key) { return "/v2" diff --git a/internal/model/config/sse.go b/internal/model/config/sse.go new file mode 100644 index 0000000..342ac35 --- /dev/null +++ b/internal/model/config/sse.go @@ -0,0 +1,15 @@ +package config + +// SSE 项目级实时推送配置 +type SSE struct { + HeartbeatIntervalSeconds int `json:"heartbeat_interval_seconds" yaml:"heartbeat_interval_seconds"` + WriteTimeoutSeconds int `json:"write_timeout_seconds" yaml:"write_timeout_seconds"` + QueueCapacity int `json:"queue_capacity" yaml:"queue_capacity"` + MaxConnectionsPerSubject int `json:"max_connections_per_subject" yaml:"max_connections_per_subject"` + ReplayLimit int `json:"replay_limit" yaml:"replay_limit"` + AllowedOrigins []string `json:"allowed_origins" yaml:"allowed_origins"` + DrainTimeoutSeconds int `json:"drain_timeout_seconds" yaml:"drain_timeout_seconds"` + PubSubChannelPrefix string `json:"pubsub_channel_prefix" yaml:"pubsub_channel_prefix"` + ReplayStreamPrefix string `json:"replay_stream_prefix" yaml:"replay_stream_prefix"` + AIRuntimeMode string `json:"ai_runtime_mode" yaml:"ai_runtime_mode"` +} diff --git a/internal/model/dto/request/aiReq.go b/internal/model/dto/request/aiReq.go new file mode 100644 index 0000000..d0b507f --- /dev/null +++ b/internal/model/dto/request/aiReq.go @@ -0,0 +1,22 @@ +package request + +// CreateAssistantConversationReq 定义当前接口使用的请求参数结构。 +type CreateAssistantConversationReq struct { + Title string `json:"title" binding:"omitempty,max=100"` // 会话标题,非必填,如果不填可以由 AI 根据内容自动生成 +} + +// StreamAssistantMessageReq 定义当前接口使用的请求参数结构。 +type StreamAssistantMessageReq struct { + ConversationID string `json:"conversation_id" binding:"required,max=64"` // 会话 ID + Content string `json:"content" binding:"required"` // 消息内容 + ContextUserName string `json:"context_user_name" binding:"required,max=100"` // 上下文中的用户名称,便于 AI 生成更自然的回复 + ContextOrgName string `json:"context_org_name" binding:"required,max=100"` // 上下文中的组织名称,便于 AI 生成更自然的回复 +} + +// SubmitAssistantDecisionReq 定义当前接口使用的请求参数结构。 +type SubmitAssistantDecisionReq struct { + ConversationID string `json:"conversation_id" binding:"required,max=64"` // 会话 ID + InterruptID string `json:"interrupt_id" binding:"required,max=64"` // 中断 ID + Decision string `json:"decision" binding:"required,oneof=confirm skip"` // 决策 + Reason string `json:"reason" binding:"omitempty,max=500"` // 原因 +} diff --git a/internal/model/dto/response/aiResp.go b/internal/model/dto/response/aiResp.go new file mode 100644 index 0000000..f0472ee --- /dev/null +++ b/internal/model/dto/response/aiResp.go @@ -0,0 +1,175 @@ +package response + +// AssistantConversationGroup 表示会话列表中的时间分组。 +type AssistantConversationGroup string + +const ( + AssistantConversationGroupToday AssistantConversationGroup = "今天" + AssistantConversationGroupRecent AssistantConversationGroup = "最近" + AssistantConversationGroupOlder AssistantConversationGroup = "更早" +) + +// AssistantConversationResp 表示会话列表项的响应结构。 +type AssistantConversationResp struct { + ID string `json:"id"` // 会话唯一标识。 + Title string `json:"title"` // 会话标题,通常由首轮问题或摘要生成。 + Preview string `json:"preview"` // 会话预览文本,通常用于列表摘要展示。 + UpdatedAt string `json:"updated_at"` // 会话最近更新时间的格式化字符串。 + Timestamp int64 `json:"timestamp"` // 会话最近更新时间的时间戳,便于排序或分组。 + Group AssistantConversationGroup `json:"group"` // 会话所属时间分组,如今天、最近、更早。 + IsGenerating bool `json:"is_generating"` // 当前会话是否仍有消息在生成中。 +} + +// AssistantTraceAction 表示轨迹节点上的可执行动作。 +type AssistantTraceAction struct { + Key string `json:"key"` // 动作唯一标识,用于前端定位或回传。 + Label string `json:"label"` // 动作按钮文案,展示给用户。 + Action string `json:"action"` // 动作类型或动作指令,如 accept / reject / retry。 + Style string `json:"style,omitempty"` // 动作样式标记,如 primary / danger,供前端渲染使用。 +} + +// AssistantTraceItem 表示一条执行轨迹节点。 +type AssistantTraceItem struct { + Key string `json:"key"` // 轨迹节点唯一标识。 + Title string `json:"title"` // 轨迹节点标题,概括当前步骤。 + Description string `json:"description"` // 轨迹节点简述,说明当前步骤做了什么。 + Status string `json:"status"` // 轨迹状态,如 running / success / failed / waiting。 + InterruptID string `json:"interrupt_id,omitempty"` // 中断确认场景下的中断 ID,用于后续确认或拒绝。 + DurationMS int64 `json:"duration_ms,omitempty"` // 当前步骤耗时,单位毫秒。 + Content string `json:"content,omitempty"` // 当前步骤的简要结果内容,适合直接展示。 + DetailMarkdown string `json:"detail_markdown,omitempty"` // 当前步骤的详细说明,通常为 Markdown 格式。 + RequiresConfirmation bool `json:"requires_confirmation,omitempty"` // 当前步骤是否需要用户确认后才能继续。 + ConfirmationTitle string `json:"confirmation_title,omitempty"` // 确认弹窗或确认区域的标题。 + ConfirmationDescription string `json:"confirmation_description,omitempty"` // 对确认事项的补充说明。 + Actions []AssistantTraceAction `json:"actions,omitempty"` // 当前节点可供用户选择的动作列表。 +} + +// AssistantA2UIBinding 表示 A2UI 中的一个绑定变量。 +type AssistantA2UIBinding struct { + Key string `json:"key"` // 绑定键名,供组件通过 binding_key 引用。 + ValueString string `json:"value_string,omitempty"` // 绑定值的字符串形式。 +} + +// AssistantA2UIComponent 表示 A2UI 中的一个组件节点。 +type AssistantA2UIComponent struct { + ID string `json:"id"` // 组件唯一标识。 + Type string `json:"type"` // 组件类型,如 text / card / list / button。 + Value string `json:"value,omitempty"` // 组件直接承载的文本或值。 + BindingKey string `json:"binding_key,omitempty"` // 组件绑定的数据键,值从 Bindings 中取。 + UsageHint string `json:"usage_hint,omitempty"` // 组件用途提示,帮助前端或模型理解展示意图。 + Tone string `json:"tone,omitempty"` // 组件语气或视觉风格,如 info / success / warning。 + Children []string `json:"children,omitempty"` // 子组件 ID 列表,用于组织组件树。 + Label string `json:"label,omitempty"` // 组件标签文本,常用于按钮、表单项等。 + Items []string `json:"items,omitempty"` // 列表类组件承载的条目集合。 +} + +// AssistantA2UISurface 表示一块完整的 A2UI 渲染面。 +type AssistantA2UISurface struct { + ID string `json:"id"` // Surface 唯一标识。 + Root string `json:"root"` // 根组件 ID,前端从该节点开始构建整棵组件树。 + Components []AssistantA2UIComponent `json:"components"` // 当前 Surface 下的全部组件定义。 + Bindings []AssistantA2UIBinding `json:"bindings,omitempty"` // 当前 Surface 用到的数据绑定集合。 +} + +// AssistantA2UIBlock 表示消息中的一个结构化 UI 区块。 +type AssistantA2UIBlock struct { + Key string `json:"key"` // UI 区块唯一标识。 + Type string `json:"type"` // UI 区块类型,用于区分不同展示协议。 + Surface AssistantA2UISurface `json:"surface"` // 当前区块对应的可渲染 Surface 数据。 +} + +// AssistantScopeInfo 表示当前消息所处的业务上下文范围。 +type AssistantScopeInfo struct { + UserName string `json:"user_name"` // 当前上下文中的用户名。 + OrgName string `json:"org_name"` // 当前上下文中的组织名。 + ScopeLabel string `json:"scope_label"` // 当前作用域标签,如个人空间、组织空间等。 + TaskName string `json:"task_name,omitempty"` // 当前关联的任务名称。 + DocScopeLabel string `json:"doc_scope_label,omitempty"` // 当前文档范围标签,用于说明文档上下文来源。 +} + +// AssistantMessageResp 表示单条消息的响应结构。 +type AssistantMessageResp struct { + ID string `json:"id"` // 消息唯一标识。 + ConversationID string `json:"conversation_id"` // 所属会话 ID。 + Role string `json:"role"` // 消息角色,如 user / assistant / system。 + Content string `json:"content"` // 消息正文内容。 + CreatedAt string `json:"created_at"` // 消息创建时间的格式化字符串。 + Status string `json:"status"` // 消息状态,如 pending / streaming / completed / failed。 + TraceItems []AssistantTraceItem `json:"trace_items"` // 当前消息关联的执行轨迹列表。 + UIBlocks []AssistantA2UIBlock `json:"ui_blocks"` // 当前消息附带的结构化 UI 区块。 + Scope *AssistantScopeInfo `json:"scope,omitempty"` // 当前消息关联的作用域信息,为空表示无额外上下文。 + ErrorText string `json:"error_text,omitempty"` // 错误信息文本,通常在失败场景下返回。 +} + +// AssistantInterruptDecisionAcceptedResp 表示用户处理中断确认后的响应结果。 +type AssistantInterruptDecisionAcceptedResp struct { + Accepted bool `json:"accepted"` // 后端是否成功接收本次确认决定。 + ConversationID string `json:"conversation_id"` // 当前中断所属会话 ID。 + InterruptID string `json:"interrupt_id"` // 被处理的中断 ID。 + Decision string `json:"decision"` // 用户的确认结果,如 accept / reject。 +} + +// AssistantConversationStartedPayload 表示会话开始事件的载荷。 +type AssistantConversationStartedPayload struct { + Title string `json:"title"` // 新会话生成后的标题。 +} + +// AssistantTokenPayload 表示流式输出 token 事件的载荷。 +type AssistantTokenPayload struct { + Token string `json:"token"` // 本次流式追加的 token 内容。 +} + +// AssistantToolCallStartedPayload 表示工具调用开始事件的载荷。 +type AssistantToolCallStartedPayload struct { + Key string `json:"key"` // 工具调用步骤唯一标识。 + Title string `json:"title"` // 工具调用步骤标题。 + Description string `json:"description"` // 工具调用开始时的说明文本。 +} + +// AssistantToolCallFinishedPayload 表示工具调用结束事件的载荷。 +type AssistantToolCallFinishedPayload struct { + Key string `json:"key"` // 工具调用步骤唯一标识。 + Description string `json:"description"` // 工具调用结束后的简述。 + DurationMS int64 `json:"duration_ms"` // 工具调用耗时,单位毫秒。 + Status string `json:"status"` // 工具调用结果状态,如 success / failed。 + Content string `json:"content,omitempty"` // 工具调用返回的简要结果。 + DetailMarkdown string `json:"detail_markdown,omitempty"`// 工具调用详细结果,通常为 Markdown。 +} + +// AssistantToolCallWaitingConfirmationPayload 表示工具调用进入待确认状态时的载荷。 +type AssistantToolCallWaitingConfirmationPayload struct { + InterruptID string `json:"interrupt_id"` // 本次待确认中断的唯一标识。 + Key string `json:"key"` // 当前工具调用步骤唯一标识。 + Title string `json:"title"` // 待确认步骤标题。 + Description string `json:"description"` // 待确认步骤说明。 + DetailMarkdown string `json:"detail_markdown,omitempty"` // 待确认的详细说明,通常为 Markdown。 + ConfirmationTitle string `json:"confirmation_title"` // 确认区域标题。 + ConfirmationDescription string `json:"confirmation_description"` // 确认区域说明文本。 + Actions []AssistantTraceAction `json:"actions"` // 用户可选的确认动作列表。 +} + +// AssistantToolCallConfirmationResultPayload 表示用户确认后返回的结果载荷。 +type AssistantToolCallConfirmationResultPayload struct { + InterruptID string `json:"interrupt_id"` // 已处理的中断 ID。 + Key string `json:"key"` // 对应的工具调用步骤 ID。 + Decision string `json:"decision"` // 用户做出的决定,如 accept / reject。 + Status string `json:"status"` // 决定生效后的步骤状态。 + Description string `json:"description"` // 对本次确认结果的简要说明。 + DetailMarkdown string `json:"detail_markdown,omitempty"` // 对本次确认结果的详细说明。 +} + +// AssistantStructuredBlockPayload 表示结构化 UI 或作用域信息事件的载荷。 +type AssistantStructuredBlockPayload struct { + UIBlock *AssistantA2UIBlock `json:"ui_block,omitempty"` // 本次下发的结构化 UI 区块。 + Scope *AssistantScopeInfo `json:"scope,omitempty"` // 本次下发的作用域信息。 +} + +// AssistantMessageCompletedPayload 表示消息生成完成事件的载荷。 +type AssistantMessageCompletedPayload struct { + Content string `json:"content"` // 消息最终完整内容。 +} + +// AssistantErrorPayload 表示错误事件的载荷。 +type AssistantErrorPayload struct { + Message string `json:"message"` // 错误描述信息。 +} \ No newline at end of file diff --git a/internal/model/entity/ai.go b/internal/model/entity/ai.go new file mode 100644 index 0000000..a1aaef1 --- /dev/null +++ b/internal/model/entity/ai.go @@ -0,0 +1,105 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +// AIConversation 定义当前文件中的核心数据结构或能力抽象。 +type AIConversation struct { + ID string `json:"id" gorm:"type:varchar(64);primaryKey;comment:'AI会话ID'"` + UserID uint `json:"user_id" gorm:"index;not null;comment:'所属用户ID'"` + OrgID *uint `json:"org_id,omitempty" gorm:"index;comment:'当前组织ID'"` + Title string `json:"title" gorm:"type:varchar(100);not null;default:'';comment:'会话标题'"` + Preview string `json:"preview" gorm:"type:varchar(500);not null;default:'';comment:'会话预览'"` + IsGenerating bool `json:"is_generating" gorm:"not null;default:false;index;comment:'是否正在生成中'"` + LastMessageAt *time.Time `json:"last_message_at,omitempty" gorm:"index;comment:'最后消息时间'"` + CreatedAt time.Time `json:"created_at" gorm:"type:datetime;not null;comment:'创建时间'"` + UpdatedAt time.Time `json:"updated_at" gorm:"type:datetime;not null;comment:'更新时间'"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;comment:'软删除时间'"` +} + +// TableName 返回当前模型在持久化层使用的数据表名。 +// 参数: +// - 无。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (AIConversation) TableName() string { + return "ai_conversations" +} + +// AIMessage 定义当前文件中的核心数据结构或能力抽象。 +type AIMessage struct { + ID string `json:"id" gorm:"type:varchar(64);primaryKey;comment:'AI消息ID'"` + ConversationID string `json:"conversation_id" gorm:"type:varchar(64);index;not null;comment:'会话ID'"` + Role string `json:"role" gorm:"type:varchar(16);not null;comment:'消息角色'"` + Content string `json:"content" gorm:"type:longtext;not null;comment:'消息正文'"` + Status string `json:"status" gorm:"type:varchar(32);not null;default:'success';comment:'消息状态'"` + TraceItemsJSON string `json:"trace_items_json" gorm:"type:longtext;not null;comment:'trace_items JSON'"` + UIBlocksJSON string `json:"ui_blocks_json" gorm:"type:longtext;not null;comment:'ui_blocks JSON'"` + ScopeJSON string `json:"scope_json" gorm:"type:longtext;not null;comment:'scope JSON'"` + ErrorText string `json:"error_text" gorm:"type:varchar(500);not null;default:'';comment:'错误文案'"` + CreatedAt time.Time `json:"created_at" gorm:"type:datetime;not null;index;comment:'创建时间'"` + UpdatedAt time.Time `json:"updated_at" gorm:"type:datetime;not null;comment:'更新时间'"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;comment:'软删除时间'"` +} + +// TableName 返回当前模型在持久化层使用的数据表名。 +// 参数: +// - 无。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (AIMessage) TableName() string { + return "ai_messages" +} + +// AIInterrupt 定义当前文件中的核心数据结构或能力抽象。 +type AIInterrupt struct { + InterruptID string `json:"interrupt_id" gorm:"type:varchar(64);primaryKey;comment:'Interrupt ID'"` + ConversationID string `json:"conversation_id" gorm:"type:varchar(64);index;not null;comment:'会话ID'"` + MessageID string `json:"message_id" gorm:"type:varchar(64);index;not null;comment:'消息ID'"` + UserID uint `json:"user_id" gorm:"index;not null;comment:'所属用户ID'"` + Status string `json:"status" gorm:"type:varchar(32);not null;index;comment:'中断状态'"` + ToolKey string `json:"tool_key" gorm:"type:varchar(100);not null;default:'';comment:'工具标识'"` + Decision string `json:"decision" gorm:"type:varchar(16);not null;default:'';comment:'用户决策'"` + Reason string `json:"reason" gorm:"type:varchar(500);not null;default:'';comment:'决策原因'"` + RuntimeStateJSON string `json:"runtime_state_json" gorm:"type:longtext;not null;comment:'运行时状态 JSON'"` + OwnerNodeID string `json:"owner_node_id" gorm:"type:varchar(128);not null;default:'';comment:'运行归属节点ID'"` + CreatedAt time.Time `json:"created_at" gorm:"type:datetime;not null;comment:'创建时间'"` + UpdatedAt time.Time `json:"updated_at" gorm:"type:datetime;not null;comment:'更新时间'"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;comment:'软删除时间'"` +} + +// TableName 返回当前模型在持久化层使用的数据表名。 +// 参数: +// - 无。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (AIInterrupt) TableName() string { + return "ai_interrupts" +} diff --git a/internal/repository/interfaces/aiRepository.go b/internal/repository/interfaces/aiRepository.go new file mode 100644 index 0000000..4b3320a --- /dev/null +++ b/internal/repository/interfaces/aiRepository.go @@ -0,0 +1,26 @@ +package interfaces + +import ( + "context" + + "personal_assistant/internal/model/entity" +) + +// AIRepository 定义当前领域访问持久化数据所需的仓储能力。 +type AIRepository interface { + CreateConversation(ctx context.Context, conversation *entity.AIConversation) error + GetConversationByID(ctx context.Context, conversationID string) (*entity.AIConversation, error) + ListConversationsByUser(ctx context.Context, userID uint) ([]*entity.AIConversation, error) + UpdateConversation(ctx context.Context, conversation *entity.AIConversation) error + DeleteConversationCascade(ctx context.Context, conversationID string) error + + CreateMessage(ctx context.Context, message *entity.AIMessage) error + UpdateMessage(ctx context.Context, message *entity.AIMessage) error + ListMessagesByConversation(ctx context.Context, conversationID string) ([]*entity.AIMessage, error) + + CreateInterrupt(ctx context.Context, interrupt *entity.AIInterrupt) error + GetInterruptByID(ctx context.Context, interruptID string) (*entity.AIInterrupt, error) + UpdateInterrupt(ctx context.Context, interrupt *entity.AIInterrupt) error + + WithTx(tx any) AIRepository +} diff --git a/internal/repository/system/aiRepo.go b/internal/repository/system/aiRepo.go new file mode 100644 index 0000000..5efeab8 --- /dev/null +++ b/internal/repository/system/aiRepo.go @@ -0,0 +1,292 @@ +package system + +import ( + "context" + "errors" + + "personal_assistant/internal/model/entity" + "personal_assistant/internal/repository/interfaces" + + "gorm.io/gorm" +) + +// AIGormRepository 定义当前领域访问持久化数据所需的仓储能力。 +type AIGormRepository struct { + db *gorm.DB +} + +// NewAIRepository 负责创建并返回当前对象所需的实例。 +// 参数: +// - db:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - interfaces.AIRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func NewAIRepository(db *gorm.DB) interfaces.AIRepository { + return &AIGormRepository{db: db} +} + +// WithTx 基于现有依赖绑定事务上下文并返回新的可复用实例。 +// 参数: +// - tx:当前事务对象或事务句柄。 +// +// 返回值: +// - interfaces.AIRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) WithTx(tx any) interfaces.AIRepository { + if transaction, ok := tx.(*gorm.DB); ok { + return &AIGormRepository{db: transaction} + } + return r +} + +// CreateConversation 负责创建当前场景对应的数据或对象。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - conversation:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) CreateConversation(ctx context.Context, conversation *entity.AIConversation) error { + return r.db.WithContext(ctx).Create(conversation).Error +} + +// GetConversationByID 用于获取当前场景需要的对象或数据。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - conversationID:目标会话 ID。 +// +// 返回值: +// - *entity.AIConversation:当前函数返回的目标对象;失败时可能为 nil。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) GetConversationByID(ctx context.Context, conversationID string) (*entity.AIConversation, error) { + var conversation entity.AIConversation + if err := r.db.WithContext(ctx).Where("id = ?", conversationID).First(&conversation).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &conversation, nil +} + +// ListConversationsByUser 用于查询并返回一组结果。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - userID:当前用户 ID。 +// +// 返回值: +// - []*entity.AIConversation:当前函数返回的结果集合。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) ListConversationsByUser(ctx context.Context, userID uint) ([]*entity.AIConversation, error) { + var conversations []*entity.AIConversation + if err := r.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("COALESCE(last_message_at, updated_at) DESC"). + Order("updated_at DESC"). + Find(&conversations).Error; err != nil { + return nil, err + } + return conversations, nil +} + +// UpdateConversation 负责更新当前场景对应的数据状态。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - conversation:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) UpdateConversation(ctx context.Context, conversation *entity.AIConversation) error { + return r.db.WithContext(ctx).Save(conversation).Error +} + +// DeleteConversationCascade 负责删除当前场景对应的数据。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - conversationID:目标会话 ID。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) DeleteConversationCascade(ctx context.Context, conversationID string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("conversation_id = ?", conversationID).Delete(&entity.AIMessage{}).Error; err != nil { + return err + } + if err := tx.Where("conversation_id = ?", conversationID).Delete(&entity.AIInterrupt{}).Error; err != nil { + return err + } + return tx.Where("id = ?", conversationID).Delete(&entity.AIConversation{}).Error + }) +} + +// CreateMessage 负责创建当前场景对应的数据或对象。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - message:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) CreateMessage(ctx context.Context, message *entity.AIMessage) error { + return r.db.WithContext(ctx).Create(message).Error +} + +// UpdateMessage 负责更新当前场景对应的数据状态。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - message:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) UpdateMessage(ctx context.Context, message *entity.AIMessage) error { + return r.db.WithContext(ctx).Save(message).Error +} + +// ListMessagesByConversation 用于查询并返回一组结果。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - conversationID:目标会话 ID。 +// +// 返回值: +// - []*entity.AIMessage:当前函数返回的结果集合。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) ListMessagesByConversation(ctx context.Context, conversationID string) ([]*entity.AIMessage, error) { + var messages []*entity.AIMessage + if err := r.db.WithContext(ctx). + Where("conversation_id = ?", conversationID). + Order("created_at ASC"). + Find(&messages).Error; err != nil { + return nil, err + } + return messages, nil +} + +// CreateInterrupt 负责创建当前场景对应的数据或对象。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - interrupt:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) CreateInterrupt(ctx context.Context, interrupt *entity.AIInterrupt) error { + return r.db.WithContext(ctx).Create(interrupt).Error +} + +// GetInterruptByID 用于获取当前场景需要的对象或数据。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - interruptID:目标 interrupt ID。 +// +// 返回值: +// - *entity.AIInterrupt:当前函数返回的目标对象;失败时可能为 nil。 +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) GetInterruptByID(ctx context.Context, interruptID string) (*entity.AIInterrupt, error) { + var interrupt entity.AIInterrupt + if err := r.db.WithContext(ctx).Where("interrupt_id = ?", interruptID).First(&interrupt).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &interrupt, nil +} + +// UpdateInterrupt 负责更新当前场景对应的数据状态。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - interrupt:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIGormRepository) UpdateInterrupt(ctx context.Context, interrupt *entity.AIInterrupt) error { + return r.db.WithContext(ctx).Save(interrupt).Error +} diff --git a/internal/repository/system/supplier.go b/internal/repository/system/supplier.go index 4daa5e1..c6b647b 100644 --- a/internal/repository/system/supplier.go +++ b/internal/repository/system/supplier.go @@ -7,7 +7,9 @@ import ( "gorm.io/gorm" ) +// Supplier 用于集中提供当前模块依赖对象。 type Supplier interface { + GetAIRepository() interfaces.AIRepository GetUserRepository() interfaces.UserRepository GetJWTRepository() interfaces.JWTRepository GetRoleRepository() interfaces.RoleRepository @@ -39,6 +41,7 @@ type Supplier interface { // SetUp 工厂函数,统一管理 - 现在支持配置驱动 func SetUp(factoryConfig *adapter.FactoryConfig) Supplier { var gormDB *gorm.DB + var aiRepo interfaces.AIRepository var userRepo interfaces.UserRepository var jwtRepo interfaces.JWTRepository var roleRepo interfaces.RoleRepository @@ -70,6 +73,7 @@ func SetUp(factoryConfig *adapter.FactoryConfig) Supplier { case adapter.MySQL: if db, ok := factoryConfig.Connection.(*gorm.DB); ok { gormDB = db + aiRepo = NewAIRepository(db) userRepo = NewUserRepository(db) jwtRepo = NewJwtRepository(db) roleRepo = NewRoleRepository(db) @@ -107,6 +111,7 @@ func SetUp(factoryConfig *adapter.FactoryConfig) Supplier { // 默认MySQL if db, ok := factoryConfig.Connection.(*gorm.DB); ok { gormDB = db + aiRepo = NewAIRepository(db) userRepo = NewUserRepository(db) jwtRepo = NewJwtRepository(db) roleRepo = NewRoleRepository(db) @@ -137,6 +142,7 @@ func SetUp(factoryConfig *adapter.FactoryConfig) Supplier { } return &RepositorySupplier{ db: gormDB, + aiRepository: aiRepo, userRepository: userRepo, jwtRepository: jwtRepo, roleRepository: roleRepo, diff --git a/internal/repository/system/supplierImpl.go b/internal/repository/system/supplierImpl.go index 56cab94..a9873a1 100644 --- a/internal/repository/system/supplierImpl.go +++ b/internal/repository/system/supplierImpl.go @@ -9,8 +9,10 @@ import ( "gorm.io/gorm" ) +// RepositorySupplier 用于集中提供当前模块依赖对象。 type RepositorySupplier struct { db *gorm.DB + aiRepository interfaces.AIRepository userRepository interfaces.UserRepository jwtRepository interfaces.JWTRepository roleRepository interfaces.RoleRepository @@ -40,110 +42,479 @@ type RepositorySupplier struct { observabilityRuntimeRepository interfaces.ObservabilityRuntimeRepository } +// GetAIRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.AIRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *RepositorySupplier) GetAIRepository() interfaces.AIRepository { + return r.aiRepository +} + +// GetUserRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.UserRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetUserRepository() interfaces.UserRepository { return r.userRepository } +// GetJWTRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.JWTRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetJWTRepository() interfaces.JWTRepository { return r.jwtRepository } +// GetRoleRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.RoleRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetRoleRepository() interfaces.RoleRepository { return r.roleRepository } +// GetCapabilityRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.CapabilityRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetCapabilityRepository() interfaces.CapabilityRepository { return r.capabilityRepository } +// GetMenuRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.MenuRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetMenuRepository() interfaces.MenuRepository { return r.menuRepository } +// GetAPIRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.APIRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetAPIRepository() interfaces.APIRepository { return r.apiRepository } +// GetOrgRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.OrgRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetOrgRepository() interfaces.OrgRepository { return r.orgRepository } +// GetOrgMemberRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.OrgMemberRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetOrgMemberRepository() interfaces.OrgMemberRepository { return r.orgMemberRepository } +// GetLeetcodeUserDetailRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LeetcodeUserDetailRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLeetcodeUserDetailRepository() interfaces.LeetcodeUserDetailRepository { return r.leetcodeUserDetailRepository } +// GetLuoguUserDetailRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LuoguUserDetailRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLuoguUserDetailRepository() interfaces.LuoguUserDetailRepository { return r.luoguUserDetailRepository } +// GetLanqiaoUserDetailRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LanqiaoUserDetailRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLanqiaoUserDetailRepository() interfaces.LanqiaoUserDetailRepository { return r.lanqiaoUserDetailRepository } +// GetLeetcodeQuestionBankRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LeetcodeQuestionBankRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLeetcodeQuestionBankRepository() interfaces.LeetcodeQuestionBankRepository { return r.leetcodeQuestionBankRepository } +// GetLeetcodeUserQuestionRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LeetcodeUserQuestionRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLeetcodeUserQuestionRepository() interfaces.LeetcodeUserQuestionRepository { return r.leetcodeUserQuestionRepository } +// GetLuoguQuestionBankRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LuoguQuestionBankRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLuoguQuestionBankRepository() interfaces.LuoguQuestionBankRepository { return r.luoguQuestionBankRepository } +// GetLanqiaoQuestionBankRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LanqiaoQuestionBankRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLanqiaoQuestionBankRepository() interfaces.LanqiaoQuestionBankRepository { return r.lanqiaoQuestionBankRepository } +// GetLuoguUserQuestionRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LuoguUserQuestionRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLuoguUserQuestionRepository() interfaces.LuoguUserQuestionRepository { return r.luoguUserQuestionRepository } +// GetLanqiaoUserQuestionRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.LanqiaoUserQuestionRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetLanqiaoUserQuestionRepository() interfaces.LanqiaoUserQuestionRepository { return r.lanqiaoUserQuestionRepository } +// GetOJTaskRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.OJTaskRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetOJTaskRepository() interfaces.OJTaskRepository { return r.ojTaskRepository } +// GetOJTaskExecutionRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.OJTaskExecutionRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetOJTaskExecutionRepository() interfaces.OJTaskExecutionRepository { return r.ojTaskExecutionRepository } +// GetOutboxRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.OutboxRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetOutboxRepository() interfaces.OutboxRepository { return r.outboxRepository } +// GetOJDailyStatsRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.OJDailyStatsRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetOJDailyStatsRepository() interfaces.OJDailyStatsRepository { return r.ojDailyStatsRepository } +// GetRankingReadModelRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.RankingReadModelRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetRankingReadModelRepository() interfaces.RankingReadModelRepository { return r.rankingReadModelRepository } +// GetImageRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.ImageRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetImageRepository() interfaces.ImageRepository { return r.imageRepository } +// GetObservabilityMetricRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.ObservabilityMetricRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetObservabilityMetricRepository() interfaces.ObservabilityMetricRepository { return r.observabilityMetricRepository } +// GetObservabilityTraceRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.ObservabilityTraceRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetObservabilityTraceRepository() interfaces.ObservabilityTraceRepository { return r.observabilityTraceRepository } +// GetObservabilityRuntimeRepository 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - interfaces.ObservabilityRuntimeRepository:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) GetObservabilityRuntimeRepository() interfaces.ObservabilityRuntimeRepository { return r.observabilityRuntimeRepository } +// InTx 在事务边界内执行回调逻辑,并把提交与回滚交给底层实现处理。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// - fn:当前函数需要消费的输入参数。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) InTx(ctx context.Context, fn func(tx any) error) error { if r == nil || r.db == nil { return errors.New("repository db is nil") @@ -156,6 +527,19 @@ func (r *RepositorySupplier) InTx(ctx context.Context, fn func(tx any) error) er }) } +// Ping 用于探测底层依赖当前是否可用。 +// 参数: +// - ctx:链路上下文,用于取消、超时控制和日志透传。 +// +// 返回值: +// - error:处理失败原因;成功时为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (r *RepositorySupplier) Ping(ctx context.Context) error { if r == nil || r.db == nil { return errors.New("repository db is nil") diff --git a/internal/router/router.go b/internal/router/router.go index 8b0b163..55b2b02 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -17,12 +17,26 @@ import ( "github.com/gin-gonic/gin" ) +// Routers 定义当前文件中的核心数据结构或能力抽象。 type Routers struct { System system.RouterGroup } var GroupApp = new(Routers) +// InitRouter 负责初始化当前模块所需的运行时资源。 +// 参数: +// - 无。 +// +// 返回值: +// - *gin.Engine:当前函数返回的目标对象;失败时可能为 nil。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func InitRouter() *gin.Engine { Router := gin.New() // 应用核心中间件(日志、恢复、CORS) @@ -31,12 +45,12 @@ func InitRouter() *gin.Engine { mountStatic(Router) // 配置并挂载会话中间件 attachSession(Router) - // 超时中间间 - Router.Use(middleware.TimeoutMiddleware(30 * time.Second)) systemRouter := GroupApp.System + timeoutMW := middleware.TimeoutMiddleware(30 * time.Second) PublicGroup := Router.Group("") + PublicGroup.Use(timeoutMW) { // 健康检查路由 systemRouter.InitHealthRouter(PublicGroup) @@ -54,9 +68,10 @@ func InitRouter() *gin.Engine { // 系统管理路由 - 需要JWT认证与权限管理 SystemGroup := Router.Group("") permissionMW := middleware.NewPermissionMiddleware(service.GroupApp) // 获取实例 - SystemGroup.Use(middleware.JWTAuth()) // JWT认证 - SystemGroup.Use(middleware.ActiveUserMW()) // 账号活跃态校验 - SystemGroup.Use(permissionMW.CheckPermission()) // 权限中间件 + SystemGroup.Use(timeoutMW) + SystemGroup.Use(middleware.JWTAuth()) // JWT认证 + SystemGroup.Use(middleware.ActiveUserMW()) // 账号活跃态校验 + SystemGroup.Use(permissionMW.CheckPermission()) // 权限中间件 { // 路由管理(api管理) systemRouter.InitApiRouter(SystemGroup) @@ -73,11 +88,16 @@ func InitRouter() *gin.Engine { } // 业务路由组 - 需要JWT,但不需严格的权限控制 BusinessGroup := Router.Group("") + BusinessGroup.Use(timeoutMW) BusinessGroup.Use(middleware.JWTAuth()) BusinessGroup.Use(middleware.ActiveUserMW()) + BusinessSSEGroup := Router.Group("") + BusinessSSEGroup.Use(middleware.JWTAuth()) + BusinessSSEGroup.Use(middleware.ActiveUserMW()) uploadRateLimitMW := middleware.UploadRateLimitMiddleware(global.UploadGlobalLimiter, global.UploadUserLimiter) ojBindRateLimitMW := middleware.OJBindRateLimitMiddleware(global.OJBindLimiters) { + systemRouter.InitAIRouter(BusinessGroup) // OJ 相关路由 systemRouter.InitOJRouter(BusinessGroup, ojBindRateLimitMW) // OJ 任务相关路由 @@ -89,6 +109,9 @@ func InitRouter() *gin.Engine { // 用户业务路由:登录即可维护个人资料、登出 systemRouter.InitUserBusinessRouter(BusinessGroup) } + { + systemRouter.InitAISSERouter(BusinessSSEGroup) + } return Router } diff --git a/internal/router/system/aiRouter.go b/internal/router/system/aiRouter.go new file mode 100644 index 0000000..4f29dea --- /dev/null +++ b/internal/router/system/aiRouter.go @@ -0,0 +1,56 @@ +package system + +import ( + "personal_assistant/internal/controller" + + "github.com/gin-gonic/gin" +) + +// AIRouter 负责当前领域相关路由的注册。 +type AIRouter struct{} + +// InitAIRouter 负责初始化当前模块所需的运行时资源。 +// 参数: +// - router:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - 无。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIRouter) InitAIRouter(router *gin.RouterGroup) { + aiRouter := router.Group("ai/conversations") + aiCtrl := controller.ApiGroupApp.SystemApiGroup.GetAICtrl() + { + aiRouter.POST("", aiCtrl.CreateConversation) // 创建会话 + aiRouter.GET("", aiCtrl.ListConversations) // 获取会话列表 + aiRouter.GET(":id/messages", aiCtrl.ListMessages) // 获取某个会话下的消息列表 + aiRouter.DELETE(":id", aiCtrl.DeleteConversation) // 删除指定会话 + aiRouter.POST(":id/interrupts/:interrupt_id/decision", aiCtrl.SubmitDecision) // 提交决策 + } +} + +// InitAISSERouter 负责初始化当前模块所需的运行时资源。 +// 参数: +// - router:调用方传入的目标对象或配置实例。 +// +// 返回值: +// - 无。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (r *AIRouter) InitAISSERouter(router *gin.RouterGroup) { + aiRouter := router.Group("ai/conversations") + aiCtrl := controller.ApiGroupApp.SystemApiGroup.GetAICtrl() + { + aiRouter.POST(":id/stream", aiCtrl.StreamConversation) // 流式会话 + } +} diff --git a/internal/router/system/enter.go b/internal/router/system/enter.go index a2a5848..1b9db35 100644 --- a/internal/router/system/enter.go +++ b/internal/router/system/enter.go @@ -12,6 +12,7 @@ type RouterGroup struct { // 业务模块 UserRouter // 用户管理路由 OrgRouter // 组织管理路由 + AIRouter // AI 助手路由 OJRouter // OJ判题模块路由 OJTaskRouter // OJ任务模块路由 diff --git a/internal/service/contract/errors.go b/internal/service/contract/errors.go index bc7f56d..c72abf5 100644 --- a/internal/service/contract/errors.go +++ b/internal/service/contract/errors.go @@ -9,4 +9,5 @@ var ( ErrOJAccountNotBound = errors.New("oj account not bound") ErrInvalidCredential = errors.New("invalid credential") ErrOJSyncDisabled = errors.New("oj sync disabled") + ErrAISessionMissing = errors.New("ai session missing on current node") ) diff --git a/internal/service/contract/system.go b/internal/service/contract/system.go index d5e1c88..c72c63a 100644 --- a/internal/service/contract/system.go +++ b/internal/service/contract/system.go @@ -4,6 +4,7 @@ import ( "context" "mime/multipart" + streamsse "personal_assistant/internal/infrastructure/sse" eventdto "personal_assistant/internal/model/dto/event" "personal_assistant/internal/model/dto/request" resp "personal_assistant/internal/model/dto/response" @@ -15,6 +16,7 @@ import ( "github.com/mojocn/base64Captcha" ) +// JWTServiceContract 定义当前服务对外暴露的能力契约。 type JWTServiceContract interface { IssueLoginTokens(ctx context.Context, user entity.User) (*resp.LoginResponse, string, int64, *erro.JWTError) IsInBlacklist(jwt string) bool @@ -22,6 +24,7 @@ type JWTServiceContract interface { JoinInBlacklist(ctx context.Context, jwtList entity.JwtBlacklist) error } +// AuthorizationServiceContract 定义当前服务对外暴露的能力契约。 type AuthorizationServiceContract interface { // GetUserRoles 获取用户角色 GetUserRoles(ctx context.Context, userID uint) ([]entity.Role, error) @@ -35,6 +38,7 @@ type AuthorizationServiceContract interface { AuthorizeOrgCapability(ctx context.Context, operatorID, orgID uint, capabilityCode string) error } +// PermissionProjectionServiceContract 定义当前服务对外暴露的能力契约。 type PermissionProjectionServiceContract interface { // RebuildAll 重建所有权限投影 RebuildAll(ctx context.Context) error @@ -54,16 +58,19 @@ type PermissionProjectionServiceContract interface { HandlePermissionProjectionEvent(ctx context.Context, event *eventdto.PermissionProjectionEvent) error } +// BaseServiceContract 定义当前服务对外暴露的能力契约。 type BaseServiceContract interface { GetCaptcha(store base64Captcha.Store) (string, string, error) VerifyAndSendEmailCode(ctx *gin.Context, store base64Captcha.Store, req *request.SendEmailVerificationCodeReq) error } +// HealthServiceContract 定义当前服务对外暴露的能力契约。 type HealthServiceContract interface { Health(ctx context.Context) (*resp.HealthResponse, error) Ping(ctx context.Context) (*resp.PingResponse, error) } +// UserServiceContract 定义当前服务对外暴露的能力契约。 type UserServiceContract interface { Register(ctx context.Context, req *request.RegisterReq) (*entity.User, error) PhoneLogin(ctx context.Context, req *request.LoginReq) (*entity.User, error) @@ -86,6 +93,7 @@ type UserServiceContract interface { CleanupDisabledUsers(ctx context.Context) (int, error) } +// OrgServiceContract 定义当前服务对外暴露的能力契约。 type OrgServiceContract interface { GetOrgList(ctx context.Context, userID uint, page, pageSize int, keyword string) ([]*readmodel.OrgWithMemberCount, int64, error) GetOrgDetail(ctx context.Context, userID uint, orgID uint) (*readmodel.OrgWithMemberCount, error) @@ -108,6 +116,7 @@ type OrgServiceContract interface { RecoverMember(ctx context.Context, operatorID, orgID, targetUserID uint, reason string) error } +// OJServiceContract 定义当前服务对外暴露的能力契约。 type OJServiceContract interface { BindOJAccount(ctx context.Context, userID uint, req *request.BindOJAccountReq) (*resp.BindOJAccountResp, error) BindLanqiaoAccount(ctx context.Context, userID uint, req *request.BindLanqiaoAccountReq) (*resp.BindOJAccountResp, error) @@ -123,6 +132,7 @@ type OJServiceContract interface { HandleLeetcodeBindSignal(ctx context.Context, userID uint) error } +// OJTaskServiceContract 定义当前服务对外暴露的能力契约。 type OJTaskServiceContract interface { AnalyzeTaskTitles(ctx context.Context, req *request.AnalyzeOJTaskTitlesReq) (*resp.OJTaskAnalyzeResp, error) CreateTask(ctx context.Context, operatorID uint, req *request.CreateOJTaskReq) (*resp.OJTaskCreateResp, error) @@ -142,6 +152,7 @@ type OJTaskServiceContract interface { HandleQuestionUpserted(ctx context.Context, event *eventdto.QuestionUpsertedEvent) error } +// OJDailyStatsProjectionServiceContract 定义当前服务对外暴露的能力契约。 type OJDailyStatsProjectionServiceContract interface { PublishOJDailyStatsProjectionEvent(ctx context.Context, event *eventdto.OJDailyStatsProjectionEvent) error HandleOJDailyStatsProjectionEvent(ctx context.Context, event *eventdto.OJDailyStatsProjectionEvent) error @@ -149,6 +160,7 @@ type OJDailyStatsProjectionServiceContract interface { RepairRecentWindow(ctx context.Context) error } +// CacheProjectionServiceContract 定义当前服务对外暴露的能力契约。 type CacheProjectionServiceContract interface { // HandleCacheProjectionEvent 处理缓存投影事件,根据事件类型和数据更新对应的缓存状态,确保系统内的缓存数据与底层数据源保持一致。 HandleCacheProjectionEvent(ctx context.Context, event *eventdto.CacheProjectionEvent) error @@ -157,6 +169,7 @@ type CacheProjectionServiceContract interface { RebuildAll(ctx context.Context) error } +// ApiServiceContract 定义当前服务对外暴露的能力契约。 type ApiServiceContract interface { GetAPIList(ctx context.Context, filter *request.ApiListFilter) ([]*entity.API, map[uint]*entity.Menu, int64, error) GetAPIByID(ctx context.Context, id uint) (*entity.API, *entity.Menu, error) @@ -166,6 +179,7 @@ type ApiServiceContract interface { SyncAPI(ctx context.Context, deleteRemoved bool) (added, restored, markedMissing, archived int, total int, err error) } +// MenuServiceContract 定义当前服务对外暴露的能力契约。 type MenuServiceContract interface { GetMenuTree(ctx context.Context) ([]*resp.MenuItem, error) GetMyMenus(ctx context.Context, userID uint, orgID *uint) ([]*resp.MenuItem, error) @@ -177,6 +191,7 @@ type MenuServiceContract interface { BindAPIs(ctx context.Context, menuID uint, apiIDs []uint) error } +// RoleServiceContract 定义当前服务对外暴露的能力契约。 type RoleServiceContract interface { GetRoleList(ctx context.Context, filter *request.RoleListFilter) ([]*entity.Role, int64, error) CreateRole(ctx context.Context, req *request.CreateRoleReq) error @@ -186,6 +201,7 @@ type RoleServiceContract interface { GetRoleMenuAPIMap(ctx context.Context, roleID uint, maxLevel *int) (*resp.RoleMenuAPIMappingItem, error) } +// ImageServiceContract 定义当前服务对外暴露的能力契约。 type ImageServiceContract interface { Upload(ctx context.Context, files []*multipart.FileHeader, req *request.UploadImageReq, uploaderID uint) ([]resp.ImageItem, error) Delete(ctx context.Context, ids []uint) error @@ -193,6 +209,7 @@ type ImageServiceContract interface { CleanOrphanFiles(ctx context.Context) error } +// ObservabilityServiceContract 定义当前服务对外暴露的能力契约。 type ObservabilityServiceContract interface { QueryMetrics(ctx context.Context, req *request.ObservabilityMetricsQueryReq) (*resp.ObservabilityMetricsQueryResp, error) QueryRuntimeMetrics(ctx context.Context, req *request.ObservabilityRuntimeMetricQueryReq) (*resp.ObservabilityRuntimeMetricQueryResp, error) @@ -208,6 +225,18 @@ type ObservabilityServiceContract interface { QueryTrace(ctx context.Context, req *request.ObservabilityTraceQueryReq) (*resp.ObservabilityTraceSummaryQueryResp, error) } +// AIServiceContract 定义当前服务对外暴露的能力契约。 +type AIServiceContract interface { + CreateConversation(ctx context.Context, userID uint, req *request.CreateAssistantConversationReq) (*resp.AssistantConversationResp, error) + ListConversations(ctx context.Context, userID uint) ([]*resp.AssistantConversationResp, error) + ListMessages(ctx context.Context, userID uint, conversationID string) ([]*resp.AssistantMessageResp, error) + DeleteConversation(ctx context.Context, userID uint, conversationID string) error + StreamConversation(ctx context.Context, userID uint, conversationID string, req *request.StreamAssistantMessageReq, writer streamsse.StreamWriter) error + SubmitDecision(ctx context.Context, userID uint, conversationID, interruptID string, req *request.SubmitAssistantDecisionReq) (*resp.AssistantInterruptDecisionAcceptedResp, error) + RevokeUserSessions(ctx context.Context, userID uint, reason string) int +} + +// Supplier 用于集中提供当前模块依赖对象。 type Supplier interface { GetJWTSvc() JWTServiceContract GetAuthorizationSvc() AuthorizationServiceContract @@ -225,4 +254,5 @@ type Supplier interface { GetObservabilitySvc() ObservabilityServiceContract GetCacheProjectionSvc() CacheProjectionServiceContract GetOJDailyStatsProjectionSvc() OJDailyStatsProjectionServiceContract + GetAISvc() AIServiceContract } diff --git a/internal/service/system/aiIntent.go b/internal/service/system/aiIntent.go new file mode 100644 index 0000000..da882fa --- /dev/null +++ b/internal/service/system/aiIntent.go @@ -0,0 +1,75 @@ +package system + +/* +这部分写的还不够成熟,下次至少要这样优化: +规则短路/硬拦截 → +意图与实体识别 → +结合上下文做路由 → +看置信度决定执行/澄清/fallback → +需要知识时再接检索与生成 → +持续评估和调参。 +*/ + +import "regexp" + +// aiIntentProfile 定义当前文件中的核心数据结构或能力抽象。 +type aiIntentProfile struct { + wantsTaskReport bool + wantsProgressInsight bool + wantsDocSupport bool + showScope bool + showThinkingSummary bool +} + +var ( + aiLightweightPromptRE = regexp.MustCompile(`^(你好(?:呀|啊)?|您好|hello|hi|嗨|哈喽|在吗|在么|早上好|中午好|下午好|晚上好|谢谢|thanks?|thank you|是的|好的|好|ok|okay|嗯|嗯嗯)[!!,.。??\s]*$`) + aiTaskReportPromptRE = regexp.MustCompile(`任务|汇报|日报|周报|进展|联调|闭环`) + aiProgressPromptRE = regexp.MustCompile(`进度|刷题|训练|排名|节奏|建议|最近.*天`) + aiDocPromptRE = regexp.MustCompile(`文档|README|架构|页面定位|接入|UI|改造说明|说明`) + aiScopePromptRE = regexp.MustCompile(`范围|scope|当前用户|当前组织|白名单|跨组织|跨用户|文档范围`) +) + +// isLightweightAIPrompt 负责执行当前函数对应的核心逻辑。 +// 参数: +// - input:当前阶段输入对象。 +// +// 返回值: +// - bool:表示当前操作是否成功、命中或可继续执行。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func isLightweightAIPrompt(input string) bool { + return aiLightweightPromptRE.MatchString(input) +} + +// detectAIIntent 负责执行当前函数对应的核心逻辑。 +// 参数: +// - input:当前阶段输入对象。 +// +// 返回值: +// - aiIntentProfile:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func detectAIIntent(input string) aiIntentProfile { + wantsTaskReport := aiTaskReportPromptRE.MatchString(input) + wantsProgressInsight := aiProgressPromptRE.MatchString(input) + wantsDocSupport := aiDocPromptRE.MatchString(input) + showScope := aiScopePromptRE.MatchString(input) + showThinkingSummary := wantsTaskReport || wantsProgressInsight || wantsDocSupport || len([]rune(input)) >= 16 + return aiIntentProfile{ + wantsTaskReport: wantsTaskReport, + wantsProgressInsight: wantsProgressInsight, + wantsDocSupport: wantsDocSupport, + showScope: showScope, + showThinkingSummary: showThinkingSummary, + } +} diff --git a/internal/service/system/aiMapper.go b/internal/service/system/aiMapper.go new file mode 100644 index 0000000..dd282fa --- /dev/null +++ b/internal/service/system/aiMapper.go @@ -0,0 +1,407 @@ +package system + +import ( + "encoding/json" + "strings" + "time" + + "personal_assistant/internal/model/dto/request" + resp "personal_assistant/internal/model/dto/response" + "personal_assistant/internal/model/entity" + + "github.com/google/uuid" +) + +// newAIID 负责执行当前函数对应的核心逻辑。 +// 参数: +// - prefix:当前函数需要消费的输入参数。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func newAIID(prefix string) string { + return prefix + "_" + strings.ReplaceAll(uuid.NewString(), "-", "") +} + +// deriveConversationTitle 生成会话标题 +func deriveConversationTitle(existingTitle string, content string) string { + title := strings.TrimSpace(existingTitle) + if title != "" && title != "新建会话" { + return truncateRunes(title, 100) + } + content = strings.TrimSpace(content) + if content == "" { + return "新建会话" + } + return truncateRunes(content, 24) +} + +// truncateRunes 负责执行当前函数对应的核心逻辑。 +// 参数: +// - input:当前阶段输入对象。 +// - limit:当前函数需要消费的输入参数。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func truncateRunes(input string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(input)) + if len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) +} + +// buildConversationPreview 负责执行当前函数对应的核心逻辑。 +// 参数: +// - content:当前函数需要消费的输入参数。 +// +// 返回值: +// - string:当前函数生成或返回的字符串结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func buildConversationPreview(content string) string { + return truncateRunes(content, 120) +} + +// deriveConversationGroup 负责执行当前函数对应的核心逻辑。 +// 参数: +// - ts:当前函数需要消费的输入参数。 +// +// 返回值: +// - resp.AssistantConversationGroup:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func deriveConversationGroup(ts time.Time) resp.AssistantConversationGroup { + now := time.Now() + y1, m1, d1 := ts.Date() + y2, m2, d2 := now.Date() + if y1 == y2 && m1 == m2 && d1 == d2 { + return resp.AssistantConversationGroupToday + } + if now.Sub(ts) <= 7*24*time.Hour { + return resp.AssistantConversationGroupRecent + } + return resp.AssistantConversationGroupOlder +} + +// conversationToResp 负责执行当前函数对应的核心逻辑。 +// 作用:把数据库里的会话实体,转成返回给前端的响应对象。 +func conversationToResp(conversation *entity.AIConversation) *resp.AssistantConversationResp { + if conversation == nil { + return nil + } + updatedAt := conversation.UpdatedAt + if conversation.LastMessageAt != nil && conversation.LastMessageAt.After(updatedAt) { + updatedAt = *conversation.LastMessageAt + } + return &resp.AssistantConversationResp{ + ID: conversation.ID, + Title: conversation.Title, + Preview: conversation.Preview, + UpdatedAt: updatedAt.Format(time.RFC3339), + Timestamp: updatedAt.UnixMilli(), + Group: deriveConversationGroup(updatedAt), + IsGenerating: conversation.IsGenerating, + } +} + +// messageToResp 负责执行当前函数对应的核心逻辑。 +// 作用:把消息实体转成前端响应对象。 +func messageToResp(message *entity.AIMessage) (*resp.AssistantMessageResp, error) { + if message == nil { + return nil, nil + } + return &resp.AssistantMessageResp{ + ID: message.ID, + ConversationID: message.ConversationID, + Role: message.Role, + Content: message.Content, + CreatedAt: message.CreatedAt.Format(time.RFC3339), + Status: message.Status, + // 从 JSON 字符串解码成结构化对象 + TraceItems: decodeAssistantTraceItems(message.TraceItemsJSON), + UIBlocks: decodeAssistantUIBlocks(message.UIBlocksJSON), + Scope: decodeAssistantScope(message.ScopeJSON), + ErrorText: message.ErrorText, + }, nil +} + +// encodeJSON 负责执行当前函数对应的核心逻辑。 +// 作用:把结构体/数组编码成 JSON 字符串。 +func encodeJSON(value any, emptyFallback string) string { + raw, err := json.Marshal(value) + if err != nil { + return emptyFallback + } + return string(raw) +} + +// decodeAssistantTraceItems 负责执行当前函数对应的核心逻辑。 +// 作用:把 traceItems 的 JSON 字符串解码成数组。 +func decodeAssistantTraceItems(raw string) []resp.AssistantTraceItem { + if strings.TrimSpace(raw) == "" { + return []resp.AssistantTraceItem{} + } + items := make([]resp.AssistantTraceItem, 0) + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return []resp.AssistantTraceItem{} + } + return items +} + +// decodeAssistantUIBlocks 负责执行当前函数对应的核心逻辑。 +// 作用:把 UI block 的 JSON 字符串解码成数组。 +func decodeAssistantUIBlocks(raw string) []resp.AssistantA2UIBlock { + if strings.TrimSpace(raw) == "" { + return []resp.AssistantA2UIBlock{} + } + items := make([]resp.AssistantA2UIBlock, 0) + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return []resp.AssistantA2UIBlock{} + } + return items +} + +// decodeAssistantScope 负责执行当前函数对应的核心逻辑。 +// 作用:把 scope 的 JSON 字符串解码成作用域信息。 +func decodeAssistantScope(raw string) *resp.AssistantScopeInfo { + if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "{}" { + return nil + } + var item resp.AssistantScopeInfo + if err := json.Unmarshal([]byte(raw), &item); err != nil { + return nil + } + if item.ScopeLabel == "" && item.UserName == "" && item.OrgName == "" { + return nil + } + return &item +} + +/* + 四、流式输出与上下文构造类 +*/ + +// splitReplyChunks 负责执行当前函数对应的核心逻辑。 +// 作用:把一整段回复拆成多个小块。 +func splitReplyChunks(content string, size int) []string { + if size <= 0 { + size = 48 + } + runes := []rune(content) + if len(runes) == 0 { + return nil + } + chunks := make([]string, 0, (len(runes)/size)+1) + for index := 0; index < len(runes); index += size { + end := index + size + if end > len(runes) { + end = len(runes) + } + chunks = append(chunks, string(runes[index:end])) + } + return chunks +} + +// buildScopeInfo 负责执行当前函数对应的核心逻辑。 +// 作用:根据请求参数,构造作用域信息。 +func buildScopeInfo(req *request.StreamAssistantMessageReq) *resp.AssistantScopeInfo { + if req == nil { + return nil + } + return &resp.AssistantScopeInfo{ + UserName: req.ContextUserName, + OrgName: req.ContextOrgName, + ScopeLabel: "当前用户 + 当前组织 + 最近任务 + 当前文档范围", + TaskName: "OJ 任务闭环联调 V2", + DocScopeLabel: "README、架构设计方案、AI UI 改造说明", + } +} + +/* + 五、UI 组件构造类 +*/ + +// textComponent 负责执行当前函数对应的核心逻辑。 +// 作用:构造一个文本组件。 +func textComponent(id string, value string, usageHint string, tone string) resp.AssistantA2UIComponent { + return resp.AssistantA2UIComponent{ID: id, Type: "Text", Value: value, UsageHint: usageHint, Tone: tone} +} + +// badgeComponent 负责执行当前函数对应的核心逻辑。 +// 作用:构造一个徽标组件。 +func badgeComponent(id string, label string, tone string) resp.AssistantA2UIComponent { + return resp.AssistantA2UIComponent{ID: id, Type: "Badge", Label: label, Tone: tone} +} + +// bulletListComponent 负责执行当前函数对应的核心逻辑。 +// 作用:构造一个项目符号列表组件。 +func bulletListComponent(id string, items []string) resp.AssistantA2UIComponent { + return resp.AssistantA2UIComponent{ID: id, Type: "BulletList", Items: items} +} + +// cardComponent 负责执行当前函数对应的核心逻辑。 +// 作用:构造一个卡片组件。 +func cardComponent(id string, tone string, children ...string) resp.AssistantA2UIComponent { + return resp.AssistantA2UIComponent{ID: id, Type: "Card", Tone: tone, Children: children} +} + +/* + 六、具体 UI Block 组装类 + 这些函数就不是“基础组件”了,而是更高一层: + 直接拼出一块完整的业务 UI block。 +*/ + +// buildThinkingSummaryBlock 负责执行当前函数对应的核心逻辑。 +// 作用:生成“当前判断与下一步”的思考摘要卡片。 +func buildThinkingSummaryBlock(plan *AIRuntimePlan) *resp.AssistantA2UIBlock { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + items := []string{ + "当前判断:这个问题需要结合业务上下文和已有结果来组织答案。", + "当前动作:先汇总可直接使用的信息,再决定是否需要额外工具确认。", + "下一步:在拿到确认结果后输出最终正文。", + } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + if plan != nil && plan.DocTool == nil { + items[1] = "当前动作:当前问题不需要用户确认,可以直接整理结果。" + } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return &resp.AssistantA2UIBlock{ + Key: "block_thinking_summary", + Type: "thinking_summary_block", + Surface: resp.AssistantA2UISurface{ + ID: "surface_thinking_summary", + Root: "thinking_card_root", + Components: []resp.AssistantA2UIComponent{ + cardComponent("thinking_card_root", "muted", "thinking_title", "thinking_points"), + textComponent("thinking_title", "当前判断与下一步", "title", ""), + bulletListComponent("thinking_points", items), + }, + }, + } +} + +// buildToolIntentBlock 负责执行当前函数对应的核心逻辑。 +// 作用:生成“某个工具即将调用,需要用户确认”的意图卡片。 +func buildToolIntentBlock(tool *AIToolBlueprint) *resp.AssistantA2UIBlock { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + if tool == nil { + return nil + } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + return &resp.AssistantA2UIBlock{ + Key: "block_tool_intent", + Type: "tool_intent_block", + Surface: resp.AssistantA2UISurface{ + ID: "surface_tool_intent", + Root: "tool_intent_card_root", + Components: []resp.AssistantA2UIComponent{ + cardComponent("tool_intent_card_root", "warning", "tool_badge", "tool_title", "tool_points"), + badgeComponent("tool_badge", "等待确认", "warning"), + textComponent("tool_title", tool.Title+"需要你的确认", "title", ""), + bulletListComponent("tool_points", []string{ + "目的:补充当前回答需要的正式依据或范围说明。", + "必要性:已有上下文能给初步回答,但缺少更稳的支撑信息。", + "确认要求:是否继续调用该工具由你决定。", + }), + }, + }, + } +} + +// buildWaitingUserBlock 负责执行当前函数对应的核心逻辑。 +// 作用:生成“当前正在等待用户决策”的卡片。 +func buildWaitingUserBlock(tool *AIToolBlueprint) *resp.AssistantA2UIBlock { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + if tool == nil { + return nil + } + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + return &resp.AssistantA2UIBlock{ + Key: "block_waiting_user", + Type: "waiting_user_block", + Surface: resp.AssistantA2UISurface{ + ID: "surface_waiting_user", + Root: "waiting_card_root", + Components: []resp.AssistantA2UIComponent{ + cardComponent("waiting_card_root", "warning", "waiting_title", "waiting_description", "waiting_points"), + textComponent("waiting_title", tool.ConfirmationTitle, "title", ""), + textComponent("waiting_description", tool.ConfirmationDescription, "body", ""), + bulletListComponent("waiting_points", []string{ + "继续后:会补充正式依据,再输出更完整的最终回答。", + "跳过后:只基于当前已有上下文继续输出。", + }), + }, + }, + } +} + +/* + 七、trace 与 UI block 的辅助更新类 +*/ + +// assistantTraceActions 负责执行当前函数对应的核心逻辑。 +// 作用:给 trace 生成两个标准操作按钮。 +func assistantTraceActions(toolKey string) []resp.AssistantTraceAction { + return []resp.AssistantTraceAction{ + {Key: toolKey + "_confirm", Label: "继续使用", Action: "confirm", Style: "primary"}, + {Key: toolKey + "_skip", Label: "跳过此工具", Action: "skip", Style: "default"}, + } +} + +// upsertTraceItem 负责执行当前函数对应的核心逻辑。 +// 作用:按 Key 更新或插入 trace item。 +func upsertTraceItem(items []resp.AssistantTraceItem, item resp.AssistantTraceItem) []resp.AssistantTraceItem { + for idx := range items { + if items[idx].Key == item.Key { + items[idx] = item + return items + } + } + return append(items, item) +} + +// upsertUIBlock 负责执行当前函数对应的核心逻辑。 +// 作用:按 Key 更新或插入 UI block。 +func upsertUIBlock(items []resp.AssistantA2UIBlock, block resp.AssistantA2UIBlock) []resp.AssistantA2UIBlock { + for idx := range items { + if items[idx].Key == block.Key { + items[idx] = block + return items + } + } + return append(items, block) +} diff --git a/internal/service/system/aiRuntime.go b/internal/service/system/aiRuntime.go new file mode 100644 index 0000000..0d9fbd0 --- /dev/null +++ b/internal/service/system/aiRuntime.go @@ -0,0 +1,115 @@ +package system + +import ( + "context" + + "personal_assistant/internal/model/dto/request" + resp "personal_assistant/internal/model/dto/response" + "personal_assistant/internal/model/entity" +) + +// AIToolBlueprint 描述一次“工具调用计划”的静态信息。 +// 它不是工具执行结果本身,而是 runtime 预先规划好的工具蓝图。 +type AIToolBlueprint struct { + Kind string // 工具类型,如 task / progress / doc + + Key string // 工具唯一键,用于 trace、事件和 interrupt 关联 + + Title string // 前端展示用标题 + + Description string // 工具用途或执行说明 + + DurationMS int64 // 模拟或记录的执行耗时(毫秒) + + Content string // 工具执行后的摘要结果 + + DetailMarkdown string // 更完整的工具结果详情,通常用于展开查看 + + RequiresConfirmation bool // 调用该工具前是否需要用户确认 + + ConfirmationTitle string // 等待确认时展示的标题 + + ConfirmationDescription string // 等待确认时展示的说明文案 +} + +// AIRuntimePlan 表示一次 AI 会话在执行前生成的“运行计划”。 +// runtime 会先产出 plan,再按 plan 执行。 +type AIRuntimePlan struct { + Title string // 本次会话标题 + + Preview string // 会话预览文本 + + Lightweight bool // 是否为轻量对话(如问候、感谢) + + LightweightReply string // 轻量对话时可直接返回的回复 + + Scope *resp.AssistantScopeInfo // 本次回答的作用域信息 + + ShowThinkingSummary bool // 是否展示“思考摘要”区块 + + TaskTool *AIToolBlueprint // 任务快照工具计划 + + ProgressTool *AIToolBlueprint // 进度分析工具计划 + + DocTool *AIToolBlueprint // 文档摘要工具计划(可能需要确认) + + FinalReplyConfirm string // 用户确认执行文档工具后的最终回复 + + FinalReplySkip string // 用户跳过文档工具后的最终回复 +} + +// AIRuntimePlanInput 是 Plan 阶段的输入参数。 +type AIRuntimePlanInput struct { + Conversation *entity.AIConversation // 当前会话,可为空(新建会话) + + Request *request.StreamAssistantMessageReq // 本次用户请求 +} + +// AIRuntimeExecutionInput 是 Execute 阶段的输入参数。 +type AIRuntimeExecutionInput struct { + UserID uint // 当前用户 ID + + Conversation *entity.AIConversation // 当前会话实体 + + Request *request.StreamAssistantMessageReq // 本次请求参数 + + Plan *AIRuntimePlan // 预先生成好的执行计划 + + Interrupt *entity.AIInterrupt // 本次执行关联的 interrupt,可为空 + + AssistantMsgID string // 当前 assistant 消息 ID +} + +// AIRuntimeDecisionCommand 表示一次 interrupt 决策提交命令。 +type AIRuntimeDecisionCommand struct { + UserID uint // 提交决策的用户 ID + + ConversationID string // 所属会话 ID + + InterruptID string // 目标 interrupt ID + + Decision string // 用户决策,如 confirm / skip + + Reason string // 决策附带说明,可为空 +} + +// AIRuntimeSink 是 runtime 输出事件的承接端。 +// runtime 不直接操作 SSE/数据库,而是统一写给 sink。 +type AIRuntimeSink interface { + Emit(ctx context.Context, eventName string, payload any) error // 发出一个运行时事件 + + Heartbeat(ctx context.Context) error // 发送保活心跳 +} + +// AIRuntime 定义 AI 运行时需要实现的核心能力。 +type AIRuntime interface { + Plan(ctx context.Context, input AIRuntimePlanInput) (*AIRuntimePlan, error) // 生成执行计划 + + Execute(ctx context.Context, input AIRuntimeExecutionInput, sink AIRuntimeSink) error // 按计划执行并输出事件 + + SubmitDecision(ctx context.Context, cmd AIRuntimeDecisionCommand) (bool, error) // 提交 interrupt 决策 + + RevokeUser(ctx context.Context, userID uint, reason string) int // 撤销某用户当前等待中的会话 + + NodeID() string // 返回当前 runtime 所属节点 ID +} \ No newline at end of file diff --git a/internal/service/system/aiRuntimeLocal.go b/internal/service/system/aiRuntimeLocal.go new file mode 100644 index 0000000..54f05d2 --- /dev/null +++ b/internal/service/system/aiRuntimeLocal.go @@ -0,0 +1,491 @@ +package system + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "time" + + resp "personal_assistant/internal/model/dto/response" +) + +// aiSessionSignal 表示运行时等待 interrupt 决策期间收到的一次会话信号。 +// 它既承载 confirm/skip 决策,也承载外部强制撤销信号。 +type aiSessionSignal struct { + Decision string // 用户决策 + Reason string + Revoked bool // 是不是外部强制撤销 +} + +// aiSessionRegistry 负责管理“等待用户决策”的 interrupt 会话。 +// 它通过 interruptID 和 userID 两套索引,分别解决精准唤醒和用户级批量撤销两个场景。 +type aiSessionRegistry struct { + mu sync.Mutex + byInterrupt map[string]chan aiSessionSignal + byUser map[uint]map[string]struct{} +} + +/* + 一、注册表相关函数 +*/ + +// newAISessionRegistry 负责创建本地会话注册表。 +// 作用:创建一个空的会话注册表 +func newAISessionRegistry() *aiSessionRegistry { + return &aiSessionRegistry{ + byInterrupt: make(map[string]chan aiSessionSignal), + byUser: make(map[uint]map[string]struct{}), + } +} + +// Register 负责登记一个等待决策的 interrupt,并返回监听通道与清理函数。 +// 作用:登记一个“正在等待用户确认”的 interrupt,并返回监听通道和清理函数。 +func (r *aiSessionRegistry) Register(userID uint, interruptID string) (<-chan aiSessionSignal, func()) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + r.mu.Lock() + defer r.mu.Unlock() + + ch := make(chan aiSessionSignal, 1) + r.byInterrupt[interruptID] = ch + + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + userSessions := r.byUser[userID] + if userSessions == nil { + userSessions = make(map[string]struct{}) + r.byUser[userID] = userSessions + } + userSessions[interruptID] = struct{}{} + + cleanup := func() { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.byInterrupt, interruptID) + if sessions := r.byUser[userID]; sessions != nil { + delete(sessions, interruptID) + if len(sessions) == 0 { + delete(r.byUser, userID) + } + } + } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return ch, cleanup +} + +// Submit 负责向某个等待中的 interrupt 投递一条决策信号。 +// 作用:给某个等待中的 interrupt 投递决策信号。 +func (r *aiSessionRegistry) Submit(interruptID string, signal aiSessionSignal) bool { + r.mu.Lock() + ch, ok := r.byInterrupt[interruptID] + r.mu.Unlock() + if !ok { + return false + } + + select { + case ch <- signal: + return true + default: + return false + } +} + +// RevokeUser 负责向某个用户当前所有等待中的 interrupt 广播撤销信号。 +// 作用:撤销某个用户当前节点上的所有等待会话。 +func (r *aiSessionRegistry) RevokeUser(userID uint, reason string) int { + r.mu.Lock() + interrupts := make([]string, 0, len(r.byUser[userID])) + for interruptID := range r.byUser[userID] { + interrupts = append(interrupts, interruptID) + } + r.mu.Unlock() + + count := 0 + for _, interruptID := range interrupts { + if r.Submit(interruptID, aiSessionSignal{Revoked: true, Reason: reason}) { + count++ + } + } + return count +} + +// LocalAIRuntime 是当前仓库用于本地演示和联调的 AI 运行时实现。 +// 根据上下文推测,它并不直接接外部 LLM,而是用规则化逻辑模拟计划、工具调用和 interrupt 流程。 +type LocalAIRuntime struct { + nodeID string + heartbeatInterval time.Duration + sessionRegistry *aiSessionRegistry +} + +/* + 二、runtime 本身相关函数 +*/ + +// NewLocalAIRuntime 负责创建本地 AI 运行时实例。 +// 作用:创建一个本地 runtime 实例。 +func NewLocalAIRuntime(heartbeatInterval time.Duration) *LocalAIRuntime { + host, err := os.Hostname() + if err != nil || strings.TrimSpace(host) == "" { + host = "local" + } + if heartbeatInterval <= 0 { + heartbeatInterval = 20 * time.Second + } + return &LocalAIRuntime{ + nodeID: host, + heartbeatInterval: heartbeatInterval, + sessionRegistry: newAISessionRegistry(), + } +} + +// NodeID 返回当前运行时实例所属的节点标识。 +// 作用:返回当前 runtime 所属节点 ID。 +func (r *LocalAIRuntime) NodeID() string { + return r.nodeID +} + +// Plan 负责基于用户输入生成本次 AI 会话的执行计划。 +// 作用:根据用户输入,先生成一份执行计划 AIRuntimePlan。 +func (r *LocalAIRuntime) Plan(_ context.Context, input AIRuntimePlanInput) (*AIRuntimePlan, error) { + if input.Request == nil { + return nil, errors.New("ai stream request is nil") + } + content := strings.TrimSpace(input.Request.Content) + if content == "" { + return nil, errors.New("ai stream content is empty") + } + + // 第一阶段:先确定会话标题,避免后续轻量和复杂场景各自重复推导标题。 + title := deriveConversationTitle("", content) + if input.Conversation != nil { + title = deriveConversationTitle(input.Conversation.Title, content) + } + + // 对非常轻量的输入直接生成固定回复,减少不必要的工具规划和 interrupt 开销。 + if isLightweightAIPrompt(content) { + reply := buildLightweightReply(content) + return &AIRuntimePlan{ + Title: title, + Preview: buildConversationPreview(content), + Lightweight: true, + LightweightReply: reply, + FinalReplyConfirm: reply, + FinalReplySkip: reply, + }, nil + } + + // 第二阶段:识别意图,并据此决定是否展示思考摘要、scope 和各种工具卡片。 + intent := detectAIIntent(content) + plan := &AIRuntimePlan{ + Title: title, + Preview: buildConversationPreview(content), + ShowThinkingSummary: intent.showThinkingSummary, + FinalReplyConfirm: buildScenarioFinalReply(content, intent, true), + FinalReplySkip: buildScenarioFinalReply(content, intent, false), + } + if intent.showScope { + plan.Scope = buildScopeInfo(input.Request) + } + + // 任务快照和训练进度属于无需用户确认的只读工具,可直接加入计划。 + if intent.wantsTaskReport { + plan.TaskTool = &AIToolBlueprint{ + Kind: "task", + Key: "tool_task_snapshot", + Title: "读取任务执行快照", + Description: "读取完成率、成员覆盖情况和当前阻塞项。", + DurationMS: 148, + Content: "已拿到最新任务快照:完成率 81%,覆盖成员 17 / 21,阻塞项 3 个。", + DetailMarkdown: "任务快照结果:\n\n- 完成率:81%\n- 覆盖成员:17 / 21\n- 阻塞项:3", + } + } + if intent.wantsProgressInsight { + plan.ProgressTool = &AIToolBlueprint{ + Kind: "progress", + Key: "tool_progress_snapshot", + Title: "读取最近训练进度", + Description: "汇总最近 7 天训练节奏、排名变化与建议方向。", + DurationMS: 126, + Content: "近 7 天新增 12 题,排名提升 2 位,建议继续集中中等题突破。", + DetailMarkdown: "训练进度结果:\n\n- 新增题目:12\n- 排名变化:+2\n- 建议方向:中等题突破", + } + } + + // 文档工具需要显式确认,是因为根据上下文推测,读取正式文档属于比纯业务数据更重的外部信息补充动作。 + if intent.wantsDocSupport { + plan.DocTool = &AIToolBlueprint{ + Kind: "doc", + Key: "tool_doc_snapshot", + Title: "读取正式项目文档", + Description: "读取 README、架构设计方案和 AI UI 改造说明。", + DurationMS: 182, + Content: "已补充正式项目文档摘要,可据此确认页面定位和后端接入方式。", + DetailMarkdown: "文档工具结果:\n\n- 助手继续挂在控制台 Workbench 中。\n- 后端继续采用单条 SSE 聊天流 + interrupt decision。", + RequiresConfirmation: true, + ConfirmationTitle: "是否继续使用“项目文档摘要”工具?", + ConfirmationDescription: "继续后会读取正式文档白名单并补充引用摘要;跳过则只基于已有业务数据继续输出。", + } + } + return plan, nil +} + +// Execute 负责按计划驱动本地 AI 运行时输出完整的流式事件序列。 +// 作用:按 plan 真正执行,并持续往 sink 发事件。 +func (r *LocalAIRuntime) Execute(ctx context.Context, input AIRuntimeExecutionInput, sink AIRuntimeSink) error { + if input.Plan == nil { + return errors.New("ai runtime plan is nil") + } + if sink == nil { + return errors.New("ai runtime sink is nil") + } + plan := input.Plan + + // 第一阶段:输出基础起始事件和无需等待用户确认的结构化块。 + if err := sink.Emit(ctx, "conversation_started", resp.AssistantConversationStartedPayload{Title: plan.Title}); err != nil { + return err + } + if plan.ShowThinkingSummary { + if err := sink.Emit(ctx, "structured_block", resp.AssistantStructuredBlockPayload{UIBlock: buildThinkingSummaryBlock(plan)}); err != nil { + return err + } + } + if plan.TaskTool != nil { + if err := r.runTool(ctx, sink, plan.TaskTool); err != nil { + return err + } + } + if plan.ProgressTool != nil { + if err := r.runTool(ctx, sink, plan.ProgressTool); err != nil { + return err + } + } + if plan.Scope != nil { + if err := sink.Emit(ctx, "structured_block", resp.AssistantStructuredBlockPayload{Scope: plan.Scope}); err != nil { + return err + } + } + + finalReply := plan.FinalReplyConfirm + if plan.DocTool != nil && input.Interrupt != nil { + // 第二阶段:先把“准备调用工具”和“等待用户确认”的 UI 信息推给前端。 + if err := sink.Emit(ctx, "structured_block", resp.AssistantStructuredBlockPayload{UIBlock: buildToolIntentBlock(plan.DocTool)}); err != nil { + return err + } + if err := sink.Emit(ctx, "structured_block", resp.AssistantStructuredBlockPayload{UIBlock: buildWaitingUserBlock(plan.DocTool)}); err != nil { + return err + } + + // 注册等待通道后再发 waiting 事件,避免用户极快提交决策时运行时还没建立监听。 + waitCh, cleanup := r.sessionRegistry.Register(input.UserID, input.Interrupt.InterruptID) + defer cleanup() + if err := sink.Emit(ctx, "tool_call_waiting_confirmation", resp.AssistantToolCallWaitingConfirmationPayload{ + InterruptID: input.Interrupt.InterruptID, + Key: plan.DocTool.Key, + Title: plan.DocTool.Title, + Description: plan.DocTool.Description, + DetailMarkdown: plan.DocTool.DetailMarkdown, + ConfirmationTitle: plan.DocTool.ConfirmationTitle, + ConfirmationDescription: plan.DocTool.ConfirmationDescription, + Actions: assistantTraceActions(plan.DocTool.Key), + }); err != nil { + return err + } + + signal, err := r.waitDecision(ctx, waitCh, sink) + if err != nil { + return err + } + if signal.Revoked { + return context.Canceled + } + + // 第三阶段:根据用户决策更新 trace 和最终回答分支。 + if signal.Decision == "skip" { + finalReply = plan.FinalReplySkip + if err := sink.Emit(ctx, "tool_call_confirmation_result", resp.AssistantToolCallConfirmationResultPayload{ + InterruptID: input.Interrupt.InterruptID, + Key: plan.DocTool.Key, + Decision: "skip", + Status: "skipped", + Description: "已跳过该工具,接下来将只基于当前已有上下文继续输出。", + }); err != nil { + return err + } + } else { + if err := sink.Emit(ctx, "tool_call_confirmation_result", resp.AssistantToolCallConfirmationResultPayload{ + InterruptID: input.Interrupt.InterruptID, + Key: plan.DocTool.Key, + Decision: "confirm", + Status: "pending", + Description: "已确认继续读取正式项目文档,准备补充最终回答。", + }); err != nil { + return err + } + if err := sink.Emit(ctx, "tool_call_finished", resp.AssistantToolCallFinishedPayload{ + Key: plan.DocTool.Key, + Description: "已完成正式项目文档摘要读取。", + DurationMS: plan.DocTool.DurationMS, + Status: "success", + Content: plan.DocTool.Content, + DetailMarkdown: plan.DocTool.DetailMarkdown, + }); err != nil { + return err + } + } + } + + // 第四阶段:把最终回复切成 token 块持续输出,模拟真实流式体验。 + for _, chunk := range splitReplyChunks(finalReply, 48) { + if err := sink.Emit(ctx, "assistant_token", resp.AssistantTokenPayload{Token: chunk}); err != nil { + return err + } + } + if err := sink.Emit(ctx, "message_completed", resp.AssistantMessageCompletedPayload{Content: finalReply}); err != nil { + return err + } + return sink.Emit(ctx, "done", map[string]any{}) +} + +/* + 五、等待/决策相关函数 +*/ + +// SubmitDecision 负责向本地会话注册表提交用户决策。 +// 作用:向本地 runtime 提交一次用户决策。 +func (r *LocalAIRuntime) SubmitDecision(_ context.Context, cmd AIRuntimeDecisionCommand) (bool, error) { + return r.sessionRegistry.Submit(cmd.InterruptID, aiSessionSignal{ + Decision: cmd.Decision, + Reason: cmd.Reason, + }), nil +} + +// RevokeUser 负责撤销某个用户当前节点上的等待会话。 +// 作用:撤销某个用户当前节点上的所有等待会话。 +func (r *LocalAIRuntime) RevokeUser(_ context.Context, userID uint, reason string) int { + return r.sessionRegistry.RevokeUser(userID, reason) +} + +// waitDecision 负责在等待用户决策期间维持心跳并监听最终信号。 +// 作用:在等待用户决策期间,一边等信号,一边持续发心跳。 +func (r *LocalAIRuntime) waitDecision(ctx context.Context, waitCh <-chan aiSessionSignal, sink AIRuntimeSink) (aiSessionSignal, error) { + ticker := time.NewTicker(r.heartbeatInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return aiSessionSignal{}, ctx.Err() + case signal := <-waitCh: + return signal, nil + case <-ticker.C: + if err := sink.Heartbeat(ctx); err != nil { + return aiSessionSignal{}, err + } + } + } +} + +// runTool 负责输出一个无需等待确认的工具执行事件。 +// 作用:执行一个“不需要用户确认”的工具。 +func (r *LocalAIRuntime) runTool(ctx context.Context, sink AIRuntimeSink, tool *AIToolBlueprint) error { + if err := sink.Emit(ctx, "tool_call_started", resp.AssistantToolCallStartedPayload{ + Key: tool.Key, + Title: tool.Title, + Description: tool.Description, + }); err != nil { + return err + } + return sink.Emit(ctx, "tool_call_finished", resp.AssistantToolCallFinishedPayload{ + Key: tool.Key, + Description: tool.Description, + DurationMS: tool.DurationMS, + Status: "success", + Content: tool.Content, + DetailMarkdown: tool.DetailMarkdown, + }) +} + +// buildLightweightReply 负责为轻量短消息生成直接回复。 +// 参数: +// - input:用户原始输入文本。 +// +// 返回值: +// - string:适合直接返回的轻量回复。 +// +// 核心流程: +// 1. 标准化输入文本。 +// 2. 根据感谢、确认等简单意图返回固定短回复。 +// +// 注意事项: +// - 轻量回复场景故意不走工具和复杂计划,是为了压缩交互延迟。 +func buildLightweightReply(input string) string { + normalized := strings.TrimSpace(input) + switch { + case strings.Contains(strings.ToLower(normalized), "thank"), strings.Contains(normalized, "谢谢"): + return "不客气。你可以继续让我总结任务、分析进度,或者解释项目文档。" + case normalized == "好" || normalized == "好的" || strings.EqualFold(normalized, "ok") || strings.EqualFold(normalized, "okay"): + return "我在。你可以继续补充任务范围、文档范围,或者直接提出下一个问题。" + default: + return "你好。我可以继续帮你整理任务进展、分析个人进度,或者解释项目文档。" + } +} + +// buildScenarioFinalReply 负责根据识别出的意图拼装最终答复模板。 +// 参数: +// - input:用户原始输入。 +// - intent:意图识别结果。 +// - confirmed:是否确认执行了文档工具。 +// +// 返回值: +// - string:最终答复文本。 +// +// 核心流程: +// 1. 依次根据任务、进度、文档等意图追加段落。 +// 2. 若没有任何结构化场景命中,则回退到直接回复。 +// +// 注意事项: +// - confirm/skip 两条路径在这里统一生成不同文本,能避免 Execute 阶段散落字符串拼接逻辑。 +func buildScenarioFinalReply(input string, intent aiIntentProfile, confirmed bool) string { + sections := make([]string, 0, 4) + if intent.wantsTaskReport { + sections = append(sections, "当前任务主线已进入收口阶段,主要阻塞集中在少数成员补齐和回归验证。\n\n- 任务完成率:81%\n- 覆盖成员:17 / 21\n- 阻塞项:3\n\n建议先补齐未完成成员,再做一轮回归验证。") + } + if intent.wantsProgressInsight { + sections = append(sections, "近 7 天训练节奏稳定,新增 12 题,排名提升 2 位。当前更值得投入的是中等题突破。\n\n- 当前排名:#5\n- 近 7 天新增:12 题\n- 重点方向:中等题\n\n建议未来三天集中完成 2 到 3 道中等题,并补齐错因总结。") + } + if intent.wantsDocSupport && confirmed { + sections = append(sections, "补充正式项目文档后,可以确认两点:\n\n- 助手继续挂在控制台 Workbench 中,不额外拆独立站点。\n- 后端接入继续采用单条 SSE 聊天流 + interrupt decision 控制接口。") + } + if intent.wantsDocSupport && !confirmed { + sections = append(sections, "本轮没有读取正式项目文档,因此页面定位和接入方式说明未纳入结果。") + } + if len(sections) == 0 { + sections = append(sections, buildDirectReply(input)) + } + return strings.Join(sections, "\n\n") +} + +// buildDirectReply 负责为未命中结构化意图的输入生成直接回复。 +// 参数: +// - input:用户原始输入。 +// +// 返回值: +// - string:直接回复文本。 +// +// 核心流程: +// 1. 先判断是否偏向文档解释需求。 +// 2. 否则回退到通用引导回复。 +// +// 注意事项: +// - 这里保留文档提示,是为了在未显式命中文档工具时仍给用户一个明确的下一步指引。 +func buildDirectReply(input string) string { + if aiDocPromptRE.MatchString(strings.TrimSpace(input)) { + return "如果你要我解释项目文档,直接告诉我要看哪份文档,或者说明你想确认页面定位、协议还是后端接入。" + } + return "这条问题目前不需要额外工具。我可以直接回答;如果你要任务、进度或文档结论,请把范围再说具体一点。" +} diff --git a/internal/service/system/aiRuntimeLocal_test.go b/internal/service/system/aiRuntimeLocal_test.go new file mode 100644 index 0000000..4697506 --- /dev/null +++ b/internal/service/system/aiRuntimeLocal_test.go @@ -0,0 +1,183 @@ +package system + +import ( + "context" + "sync" + "testing" + "time" + + "personal_assistant/internal/model/dto/request" + "personal_assistant/internal/model/entity" +) + +// runtimeTestSink 是 LocalAIRuntime 测试用的最小 sink 实现。 +// 它只记录事件名,不关心 payload 细节,适合验证事件顺序和关键阶段是否发生。 +type runtimeTestSink struct { + mu sync.Mutex + eventNames []string +} + +// Emit 负责记录测试期间收到的事件名。 +// 参数: +// - _:测试场景下忽略上下文本体,只保留接口签名一致性。 +// - eventName:当前收到的事件名。 +// - _:测试场景下忽略 payload 详情。 +// +// 返回值: +// - error:当前实现始终返回 nil。 +// +// 核心流程: +// 1. 在锁内把事件名追加到切片。 +// +// 注意事项: +// - 使用互斥锁是因为 Execute 在 goroutine 中运行,事件记录与断言读取会并发发生。 +func (s *runtimeTestSink) Emit(_ context.Context, eventName string, _ any) error { + s.mu.Lock() + defer s.mu.Unlock() + s.eventNames = append(s.eventNames, eventName) + return nil +} + +// Heartbeat 负责把心跳事件也纳入测试观察范围。 +// 参数: +// - _:测试中未使用的上下文。 +// +// 返回值: +// - error:当前实现始终返回 nil。 +// +// 核心流程: +// 1. 在锁内追加一个固定的 heartbeat 标记。 +// +// 注意事项: +// - 把心跳也记下来,可以帮助排查等待决策阶段是否真的持续保活。 +func (s *runtimeTestSink) Heartbeat(_ context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + s.eventNames = append(s.eventNames, "heartbeat") + return nil +} + +// snapshot 负责复制当前已经记录到的事件列表。 +// 参数:无。 +// 返回值: +// - []string:事件名快照副本。 +// +// 核心流程: +// 1. 在锁内复制底层切片,避免把内部可变状态直接暴露给断言逻辑。 +// +// 注意事项: +// - 返回副本而不是原切片,是为了避免调用方无意修改测试 sink 的内部状态。 +func (s *runtimeTestSink) snapshot() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.eventNames)) + copy(out, s.eventNames) + return out +} + +// TestLocalAIRuntimeExecute_ResumeAfterDecision 验证 interrupt 确认后运行时能够继续执行并输出终态事件。 +// 参数: +// - t:Go 测试上下文。 +// +// 返回值:无。 +// 核心流程: +// 1. 先构造会触发文档工具确认的请求并生成计划。 +// 2. 在 goroutine 中启动 Execute,模拟真实等待用户决策的异步流程。 +// 3. 轮询观察 waiting_confirmation 事件,确认运行时已经进入等待态。 +// 4. 提交 confirm 决策,并断言最终关键事件都出现。 +// +// 注意事项: +// - 这里显式等待 `tool_call_waiting_confirmation` 后再提交决策,是为了避免测试把“运行时尚未进入等待态”的竞态误当成业务失败。 +func TestLocalAIRuntimeExecute_ResumeAfterDecision(t *testing.T) { + runtime := NewLocalAIRuntime(10 * time.Millisecond) + req := &request.StreamAssistantMessageReq{ + ConversationID: "conv_1", + Content: "帮我整理一版任务汇报并说明助手页面定位。", + ContextUserName: "李雷", + ContextOrgName: "算法训练营", + } + + // 第一阶段:先生成计划,并确认该输入确实会命中需要确认的文档工具。 + plan, err := runtime.Plan(context.Background(), AIRuntimePlanInput{ + Conversation: &entity.AIConversation{ID: "conv_1", Title: "新建会话"}, + Request: req, + }) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + if plan.DocTool == nil { + t.Fatalf("plan.DocTool = nil, want confirmation tool") + } + + // 第二阶段:异步启动 Execute,模拟真实请求中“运行时等待决策、外部再提交决策”的并发过程。 + sink := &runtimeTestSink{} + doneCh := make(chan error, 1) + go func() { + doneCh <- runtime.Execute(context.Background(), AIRuntimeExecutionInput{ + UserID: 1, + Conversation: &entity.AIConversation{ID: "conv_1", Title: "新建会话"}, + Request: req, + Plan: plan, + Interrupt: &entity.AIInterrupt{InterruptID: "intr_1"}, + }, sink) + }() + + // 第三阶段:持续观察事件流,直到确认运行时已经进入 waiting_confirmation 状态。 + deadline := time.Now().Add(2 * time.Second) + for { + events := sink.snapshot() + found := false + for _, eventName := range events { + if eventName == "tool_call_waiting_confirmation" { + found = true + break + } + } + if found { + break + } + if time.Now().After(deadline) { + t.Fatalf("waiting_confirmation event not observed, got %v", events) + } + time.Sleep(10 * time.Millisecond) + } + + // 第四阶段:提交 confirm 决策,并等待 Execute 完整结束。 + ok, err := runtime.SubmitDecision(context.Background(), AIRuntimeDecisionCommand{ + UserID: 1, + ConversationID: "conv_1", + InterruptID: "intr_1", + Decision: "confirm", + }) + if err != nil { + t.Fatalf("SubmitDecision() error = %v", err) + } + if !ok { + t.Fatalf("SubmitDecision() ok = false, want true") + } + if err := <-doneCh; err != nil { + t.Fatalf("Execute() error = %v", err) + } + + // 第五阶段:校验关键阶段事件已经全部出现,证明等待、确认和收尾链路都已打通。 + events := sink.snapshot() + expected := []string{ + "conversation_started", + "tool_call_waiting_confirmation", + "tool_call_confirmation_result", + "message_completed", + "done", + } + for _, want := range expected { + found := false + for _, got := range events { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("event %q not found in %v", want, events) + } + } +} diff --git a/internal/service/system/aiSink.go b/internal/service/system/aiSink.go new file mode 100644 index 0000000..47a2893 --- /dev/null +++ b/internal/service/system/aiSink.go @@ -0,0 +1,242 @@ +package system + +import ( + "context" + "encoding/json" + "strings" + "time" + + streamsse "personal_assistant/internal/infrastructure/sse" + resp "personal_assistant/internal/model/dto/response" + "personal_assistant/internal/model/entity" + "personal_assistant/internal/repository/interfaces" +) + +// aiStreamSink 负责把运行时事件同时写到 SSE 输出与数据库消息状态中。 +// 它是 AIService 和 AIRuntime 之间的“状态汇聚层”,保证前端看到的事件序列与库内消息快照尽量一致。 +type aiStreamSink struct { + repo interfaces.AIRepository + writer streamsse.StreamWriter + message *entity.AIMessage + interrupt *entity.AIInterrupt + traceItems []resp.AssistantTraceItem + uiBlocks []resp.AssistantA2UIBlock + scope *resp.AssistantScopeInfo +} + +// newAIStreamSink 负责创建一条流式执行期间使用的 sink。 +// 创建一个 aiStreamSink 实例。 +func newAIStreamSink( + repo interfaces.AIRepository, + writer streamsse.StreamWriter, + message *entity.AIMessage, + interrupt *entity.AIInterrupt, +) *aiStreamSink { + return &aiStreamSink{ + repo: repo, + writer: writer, + message: message, + interrupt: interrupt, + traceItems: []resp.AssistantTraceItem{}, + uiBlocks: []resp.AssistantA2UIBlock{}, + } +} + +// Emit 负责发出一个运行时事件,并把事件影响同步折叠到消息快照。 +// 参数: +// - ctx:链路上下文。 +// - eventName:事件名。 +// - payload:事件载荷。 +// +// 作用:发出一个运行时事件,并把这个事件的影响同步到内存快照和数据库。 +func (s *aiStreamSink) Emit(ctx context.Context, eventName string, payload any) error { + raw, err := json.Marshal(payload) + if err != nil { + return err + } + + if err := s.writer.WriteEvent(ctx, &streamsse.StreamEvent{ + StreamKind: streamsse.StreamKindSession, + EventName: eventName, + Data: raw, + OccurredAt: time.Now(), + }); err != nil { + return err + } + + s.applyEvent(eventName, payload) + return s.persistMessage(ctx) +} + +// Heartbeat 负责向客户端发送 keepalive 心跳。 +// 作用:给客户端发心跳,只用于保活。 +// 核心流程: +// 1. 直接复用底层 writer 的心跳能力。 +func (s *aiStreamSink) Heartbeat(ctx context.Context) error { + return s.writer.WriteHeartbeat(ctx) +} + +// setStopped 负责把当前消息标记为“已中断”。 +// 作用:把当前消息状态改成“已停止 / 已中断”。 +func (s *aiStreamSink) setStopped() { + s.message.Status = aiMessageStatusStopped +} + +// setError 负责把当前消息标记为“失败”并记录错误文案。 +// 作用:把当前消息标记为失败,并记录错误文案。 +func (s *aiStreamSink) setError(message string) { + s.message.Status = aiMessageStatusError + s.message.ErrorText = message +} + +// persistMessage 负责把当前内存态消息快照写回数据库。 +// 作用:把当前 sink 内存里的最新状态写回数据库。 +func (s *aiStreamSink) persistMessage(ctx context.Context) error { + s.message.TraceItemsJSON = encodeJSON(s.traceItems, "[]") + s.message.UIBlocksJSON = encodeJSON(s.uiBlocks, "[]") + if s.scope == nil { + s.message.ScopeJSON = "{}" + } else { + s.message.ScopeJSON = encodeJSON(s.scope, "{}") + } + s.message.UpdatedAt = time.Now() + + if err := s.repo.UpdateMessage(ctx, s.message); err != nil { + return err + } + if s.interrupt != nil { + if err := s.repo.UpdateInterrupt(ctx, s.interrupt); err != nil { + return err + } + } + return nil +} + +// applyEvent 负责把单个运行时事件折叠到消息快照中。 +// 作用:根据事件类型,更新内存中的消息、轨迹、UI、scope、interrupt 状态。 +func (s *aiStreamSink) applyEvent(eventName string, payload any) { + switch eventName { + case "assistant_token": + // token 事件只追加正文内容,并在必要时把 idle 重新切回 loading。 + if item, ok := payload.(resp.AssistantTokenPayload); ok { + s.message.Content += item.Token + if s.message.Status == aiMessageStatusIdle { + s.message.Status = aiMessageStatusLoading + } + } + + case "tool_call_started": + // 工具开始时先写一条 pending trace,方便前端立刻显示“正在执行”。 + if item, ok := payload.(resp.AssistantToolCallStartedPayload); ok { + s.traceItems = upsertTraceItem(s.traceItems, resp.AssistantTraceItem{ + Key: item.Key, + Title: item.Title, + Description: item.Description, + Status: "pending", + }) + } + + case "tool_call_finished": + // 工具结束时用最新执行结果覆盖 trace,并在命中 interrupt 工具时推进 interrupt 状态。 + if item, ok := payload.(resp.AssistantToolCallFinishedPayload); ok { + s.traceItems = upsertTraceItem(s.traceItems, resp.AssistantTraceItem{ + Key: item.Key, + Title: existingTraceTitle(s.traceItems, item.Key, item.Key), + Description: item.Description, + Status: item.Status, + DurationMS: item.DurationMS, + Content: item.Content, + DetailMarkdown: item.DetailMarkdown, + }) + if s.interrupt != nil && s.interrupt.ToolKey == item.Key { + s.interrupt.Status = aiInterruptStatusDone + s.interrupt.UpdatedAt = time.Now() + } + } + + case "tool_call_waiting_confirmation": + // 等待确认时把消息切到 idle,表示生成暂时停住,等待用户显式决策。 + if item, ok := payload.(resp.AssistantToolCallWaitingConfirmationPayload); ok { + s.message.Status = aiMessageStatusIdle + s.traceItems = upsertTraceItem(s.traceItems, resp.AssistantTraceItem{ + Key: item.Key, + Title: item.Title, + Description: item.Description, + Status: "awaiting_confirmation", + InterruptID: item.InterruptID, + DetailMarkdown: item.DetailMarkdown, + RequiresConfirmation: true, + ConfirmationTitle: item.ConfirmationTitle, + ConfirmationDescription: item.ConfirmationDescription, + Actions: item.Actions, + }) + } + + case "tool_call_confirmation_result": + if item, ok := payload.(resp.AssistantToolCallConfirmationResultPayload); ok { + // confirm 后会重新进入加载态;skip 则保持后续由最终消息完成态覆盖。 + if item.Status == "pending" { + s.message.Status = aiMessageStatusLoading + } + + // waiting_user_block 只在等待阶段展示,一旦用户已决策就应移除,避免 UI 残留误导。 + filtered := make([]resp.AssistantA2UIBlock, 0, len(s.uiBlocks)) + for _, block := range s.uiBlocks { + if block.Type != "waiting_user_block" { + filtered = append(filtered, block) + } + } + s.uiBlocks = filtered + + // trace 里保留最终确认结果,供消息详情回放这次 interrupt 的决策路径。 + s.traceItems = upsertTraceItem(s.traceItems, resp.AssistantTraceItem{ + Key: item.Key, + Title: existingTraceTitle(s.traceItems, item.Key, item.Key), + Description: item.Description, + Status: item.Status, + InterruptID: item.InterruptID, + DetailMarkdown: item.DetailMarkdown, + }) + if s.interrupt != nil && s.interrupt.InterruptID == item.InterruptID && item.Status == "skipped" { + s.interrupt.Status = aiInterruptStatusSkipped + s.interrupt.UpdatedAt = time.Now() + } + } + + case "structured_block": + // 结构化块会影响 UI 呈现和 scope 信息,需要并入消息快照供后续列表/详情重放。 + if item, ok := payload.(resp.AssistantStructuredBlockPayload); ok { + if item.UIBlock != nil { + s.uiBlocks = upsertUIBlock(s.uiBlocks, *item.UIBlock) + } + if item.Scope != nil { + s.scope = item.Scope + } + } + + case "message_completed": + // completed 事件以最终正文为准,避免 token 逐步追加过程中出现尾部不一致。 + if item, ok := payload.(resp.AssistantMessageCompletedPayload); ok { + s.message.Content = item.Content + s.message.Status = aiMessageStatusSuccess + } + + case "error": + // error 事件直接覆盖错误状态与文案,保证数据库里能看到最终失败原因。 + if item, ok := payload.(resp.AssistantErrorPayload); ok { + s.message.Status = aiMessageStatusError + s.message.ErrorText = item.Message + } + } +} + +// existingTraceTitle 负责为 trace 更新场景找到一个稳定标题。 +// 作用:为 trace 更新场景找到一个稳定标题。 +func existingTraceTitle(items []resp.AssistantTraceItem, key string, fallback string) string { + for _, item := range items { + if item.Key == key && strings.TrimSpace(item.Title) != "" { + return item.Title + } + } + return fallback +} diff --git a/internal/service/system/aiSvc.go b/internal/service/system/aiSvc.go new file mode 100644 index 0000000..5ea8ec2 --- /dev/null +++ b/internal/service/system/aiSvc.go @@ -0,0 +1,525 @@ +package system + +import ( + "context" + "errors" + "strings" + "time" + + "personal_assistant/global" + streamsse "personal_assistant/internal/infrastructure/sse" + "personal_assistant/internal/model/dto/request" + resp "personal_assistant/internal/model/dto/response" + "personal_assistant/internal/model/entity" + "personal_assistant/internal/repository" + "personal_assistant/internal/repository/interfaces" + bizerrors "personal_assistant/pkg/errors" + + "go.uber.org/zap" +) + +const ( + // aiMessageStatusIdle 表示消息已进入等待用户确认或等待后续动作的空闲态。 + aiMessageStatusIdle = "idle" + + // aiMessageStatusLoading 表示消息仍在持续生成中。 + aiMessageStatusLoading = "loading" + + // aiMessageStatusSuccess 表示消息已完整生成成功。 + aiMessageStatusSuccess = "success" + + // aiMessageStatusError 表示消息在生成过程中失败。 + aiMessageStatusError = "error" + + // aiMessageStatusStopped 表示消息因取消、超时或撤销被中断。 + aiMessageStatusStopped = "stopped" + + // aiInterruptStatusAwaiting 表示 interrupt 已创建,等待用户明确决策。 + aiInterruptStatusAwaiting = "awaiting_confirmation" + + // aiInterruptStatusDecision 表示用户决策已提交,运行时可以继续推进。 + aiInterruptStatusDecision = "decision_received" + + // aiInterruptStatusDone 表示需要确认的工具已经执行完成。 + aiInterruptStatusDone = "completed" + + // aiInterruptStatusSkipped 表示用户显式选择跳过该工具。 + aiInterruptStatusSkipped = "skipped" +) + +// AIService 负责编排 AI 会话、消息、interrupt 与流式运行时之间的业务流程。 +// 它本身不直接操作 HTTP,也不直连数据库;所有持久化都通过 Repository 完成。 +type AIService struct { + txRunner repository.TxRunner + aiRepo interfaces.AIRepository + userRepo interfaces.UserRepository + runtime AIRuntime + policy streamsse.ConnectionPolicy +} + +// NewAIService 负责组装 AIService 所需依赖。 +// 参数: +// - repositoryGroup:仓储聚合对象,提供事务执行器和 AI 相关仓储。 +// +// 返回值: +// - *AIService:已经绑定仓储与本地运行时的服务实例。 +// +// 核心流程: +// 1. 优先读取全局 SSE 基础设施中的连接策略。 +// 2. 归一化策略后创建本地 AIRuntime。 +// 3. 从仓储聚合中提取 AI 会话与用户仓储。 +// +// 注意事项: +// - 这里不直接依赖 HTTP 层,而是只保留运行时策略,方便同一业务逻辑被不同入口复用。 +func NewAIService(repositoryGroup *repository.Group) *AIService { + policy := streamsse.ConnectionPolicy{} + if global.StreamInfra != nil { + policy = global.StreamInfra.Policy + } + + return &AIService{ + txRunner: repositoryGroup, + aiRepo: repositoryGroup.SystemRepositorySupplier.GetAIRepository(), + userRepo: repositoryGroup.SystemRepositorySupplier.GetUserRepository(), + runtime: NewLocalAIRuntime(policy.Normalize().HeartbeatInterval), + policy: policy.Normalize(), + } +} + +// CreateConversation 负责为当前用户创建一个新的 AI 会话。 +// 作用:给当前用户创建一个新的 AI 会话。 +func (s *AIService) CreateConversation( + ctx context.Context, + userID uint, + req *request.CreateAssistantConversationReq, +) (*resp.AssistantConversationResp, error) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + now := time.Now() + conversation := &entity.AIConversation{ + ID: newAIID("conv"), + UserID: userID, + Title: "新建会话", + Preview: "", + IsGenerating: false, + CreatedAt: now, + UpdatedAt: now, + } + + // 标题在这里做长度截断,是为了避免后续持久化层再承担展示字段清洗逻辑。 + if req != nil && strings.TrimSpace(req.Title) != "" { + conversation.Title = truncateRunes(req.Title, 100) + } + + user, err := s.userRepo.GetByID(ctx, userID) + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + if err != nil { + return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) + } + if user == nil { + return nil, bizerrors.New(bizerrors.CodeUserNotFound) + } + + conversation.OrgID = user.CurrentOrgID + if err := s.aiRepo.CreateConversation(ctx, conversation); err != nil { + return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) + } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return conversationToResp(conversation), nil +} + +// ListConversations 负责返回当前用户的会话列表。 +func (s *AIService) ListConversations(ctx context.Context, userID uint) ([]*resp.AssistantConversationResp, error) { + conversations, err := s.aiRepo.ListConversationsByUser(ctx, userID) + if err != nil { + return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) + } + + items := make([]*resp.AssistantConversationResp, 0, len(conversations)) + for _, conversation := range conversations { + items = append(items, conversationToResp(conversation)) + } + return items, nil +} + +// ListMessages 负责返回指定会话下的消息列表。 +func (s *AIService) ListMessages(ctx context.Context, userID uint, conversationID string) ([]*resp.AssistantMessageResp, error) { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + conversation, err := s.requireConversationOwner(ctx, userID, conversationID) + if err != nil { + return nil, err + } + + messages, err := s.aiRepo.ListMessagesByConversation(ctx, conversation.ID) + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + if err != nil { + return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) + } + + items := make([]*resp.AssistantMessageResp, 0, len(messages)) + for _, message := range messages { + item, mapErr := messageToResp(message) + if mapErr != nil { + return nil, bizerrors.Wrap(bizerrors.CodeInternalError, mapErr) + } + items = append(items, item) + } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return items, nil +} + +// DeleteConversation 负责删除当前用户拥有的会话。 +func (s *AIService) DeleteConversation(ctx context.Context, userID uint, conversationID string) error { + if _, err := s.requireConversationOwner(ctx, userID, conversationID); err != nil { + return err + } + if err := s.aiRepo.DeleteConversationCascade(ctx, conversationID); err != nil { + return bizerrors.Wrap(bizerrors.CodeDBError, err) + } + return nil +} + +// StreamConversation 负责启动一次完整的 AI 流式会话生成流程。 +// 参数: +// - ctx:本次流式请求的上下文;取消时会中断运行时和持久化收尾。 +// - userID:当前用户 ID。 +// - conversationID:目标会话 ID。 +// - req:流式消息请求。 +// - writer:SSE 输出器。 +// 核心流程: +// 1. 先校验请求参数、会话归属和会话忙碌状态。 +// 2. 调用运行时生成 Plan,并准备用户消息、AI 消息和可选 interrupt。 +// 3. 事务化落库会话状态与初始消息,确保流开始前数据库状态完整。 +// 4. 创建 sink 执行运行时,并在结束后统一做收尾。 +func (s *AIService) StreamConversation( + ctx context.Context, + userID uint, + conversationID string, + req *request.StreamAssistantMessageReq, + writer streamsse.StreamWriter, +) error { + // 第一阶段:先挡住明显非法输入,避免后续进入昂贵的数据库与运行时链路。 + if req == nil { + return bizerrors.New(bizerrors.CodeInvalidParams) + } + if strings.TrimSpace(req.ConversationID) != conversationID { + return bizerrors.NewWithMsg(bizerrors.CodeInvalidParams, "conversation_id 与路径参数不一致") + } + if writer == nil { + return bizerrors.New(bizerrors.CodeAIStreamingUnsupported) + } + + // 第二阶段:读取会话与用户上下文,保证本次流式执行建立在合法归属和可用会话之上。 + conversation, err := s.requireConversationOwner(ctx, userID, conversationID) + if err != nil { + return err + } + if conversation.IsGenerating { + return bizerrors.New(bizerrors.CodeAIConversationBusy) + } + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return bizerrors.Wrap(bizerrors.CodeDBError, err) + } + if user == nil { + return bizerrors.New(bizerrors.CodeUserNotFound) + } + + // 先做 Plan,是为了把“需要哪些工具、是否要 interrupt”在真正写流之前确定下来。 + plan, err := s.runtime.Plan(ctx, AIRuntimePlanInput{Conversation: conversation, Request: req}) + if err != nil { + return bizerrors.WrapWithMsg(bizerrors.CodeAIRequestRejected, "AI 请求无法解析", err) + } + + // 第三阶段:构造本次对话会产生的持久化对象,确保运行时开始前有可追踪的消息骨架。 + now := time.Now() + userMessage := &entity.AIMessage{ + ID: newAIID("msg_user"), + ConversationID: conversation.ID, + Role: "user", + Content: strings.TrimSpace(req.Content), + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + UIBlocksJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now, + UpdatedAt: now, + } + assistantMessage := &entity.AIMessage{ + ID: newAIID("msg_ai"), + ConversationID: conversation.ID, + Role: "assistant", + Content: "", + Status: aiMessageStatusLoading, + TraceItemsJSON: "[]", + UIBlocksJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now, + UpdatedAt: now, + } + + // 只有声明了需要确认的工具时才创建 interrupt,避免所有请求都落无意义的等待记录。 + var interrupt *entity.AIInterrupt + if plan.DocTool != nil && plan.DocTool.RequiresConfirmation { + interrupt = &entity.AIInterrupt{ + InterruptID: newAIID("intr"), + ConversationID: conversation.ID, + MessageID: assistantMessage.ID, + UserID: userID, + Status: aiInterruptStatusAwaiting, + ToolKey: plan.DocTool.Key, + RuntimeStateJSON: encodeJSON(map[string]any{"kind": plan.DocTool.Kind}, "{}"), + OwnerNodeID: s.runtime.NodeID(), + CreatedAt: now, + UpdatedAt: now, + } + } + + // 第四阶段:事务化写入起始状态,确保“会话进入生成中”与“消息骨架落库”具备一致性。 + if err := s.persistStreamStart(ctx, conversation, user, userMessage, assistantMessage, interrupt, now); err != nil { + return err + } + + // Sink 负责把运行时事件同步到 SSE 与数据库消息状态,两条链路共用同一份状态机。 + sink := newAIStreamSink(s.aiRepo, writer, assistantMessage, interrupt) + execErr := s.runtime.Execute(ctx, AIRuntimeExecutionInput{ + UserID: userID, + Conversation: conversation, + Request: req, + Plan: plan, + Interrupt: interrupt, + AssistantMsgID: assistantMessage.ID, + }, sink) + + // 所有已开始的流式请求都统一走 finishStream 收尾,避免成功和失败路径各自写一套状态处理逻辑。 + return s.finishStream(ctx, conversation, sink, execErr) +} + +// SubmitDecision 负责接收用户对 interrupt 的决策并推进状态机。 +// 作用:接收用户对 interrupt 的决策,并推进状态机。 +func (s *AIService) SubmitDecision( + ctx context.Context, + userID uint, + conversationID, + interruptID string, + req *request.SubmitAssistantDecisionReq, +) (*resp.AssistantInterruptDecisionAcceptedResp, error) { + if req == nil { + return nil, bizerrors.New(bizerrors.CodeInvalidParams) + } + if req.ConversationID != conversationID || req.InterruptID != interruptID { + return nil, bizerrors.NewWithMsg(bizerrors.CodeInvalidParams, "conversation_id 或 interrupt_id 与路径参数不一致") + } + + // 先确认当前用户确实拥有这次会话,避免借 interrupt ID 越权推进他人流程。 + if _, err := s.requireConversationOwner(ctx, userID, conversationID); err != nil { + return nil, err + } + interrupt, err := s.aiRepo.GetInterruptByID(ctx, interruptID) + if err != nil { + return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) + } + if interrupt == nil || interrupt.ConversationID != conversationID || interrupt.UserID != userID { + return nil, bizerrors.New(bizerrors.CodeAIInterruptNotFound) + } + if interrupt.Status != aiInterruptStatusAwaiting { + return nil, bizerrors.New(bizerrors.CodeAIInterruptConflict) + } + + // 运行时如果已经不再等待该 interrupt,会返回 unavailable;这时数据库不能继续盲目推进状态。 + ok, submitErr := s.runtime.SubmitDecision(ctx, AIRuntimeDecisionCommand{ + UserID: userID, + ConversationID: conversationID, + InterruptID: interruptID, + Decision: req.Decision, + Reason: req.Reason, + }) + if submitErr != nil { + return nil, bizerrors.Wrap(bizerrors.CodeInternalError, submitErr) + } + if !ok { + return nil, bizerrors.New(bizerrors.CodeAIInterruptUnavailable) + } + + // 决策被运行时接收后,再把数据库状态推进为“已收到决策”,保证线上状态与持久化状态一致。 + interrupt.Decision = req.Decision + interrupt.Reason = strings.TrimSpace(req.Reason) + interrupt.Status = aiInterruptStatusDecision + interrupt.UpdatedAt = time.Now() + if err := s.aiRepo.UpdateInterrupt(ctx, interrupt); err != nil { + return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) + } + return &resp.AssistantInterruptDecisionAcceptedResp{ + Accepted: true, + ConversationID: conversationID, + InterruptID: interruptID, + Decision: req.Decision, + }, nil +} + +// RevokeUserSessions 负责撤销某个用户当前节点上的运行中会话。 +// 参数: +// - ctx:链路上下文。 +// - userID:目标用户 ID。 +// - reason:撤销原因。 +// +// 返回值: +// - int:实际被撤销的会话数量。 +// +// 核心流程: +// 1. 直接委托运行时做用户级别的会话撤销。 +// +// 注意事项: +// - 这里不直接更新数据库,是因为撤销后的消息状态和会话状态要由运行时收尾链路统一落库。 +func (s *AIService) RevokeUserSessions(ctx context.Context, userID uint, reason string) int { + return s.runtime.RevokeUser(ctx, userID, reason) +} + +// requireConversationOwner 负责校验当前用户是否拥有指定会话。 +// 作用:撤销某个用户当前节点上的运行中会话。 +func (s *AIService) requireConversationOwner(ctx context.Context, userID uint, conversationID string) (*entity.AIConversation, error) { + conversation, err := s.aiRepo.GetConversationByID(ctx, conversationID) + if err != nil { + return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) + } + if conversation == nil || conversation.UserID != userID { + return nil, bizerrors.New(bizerrors.CodeAIConversationNotFound) + } + return conversation, nil +} + +// persistStreamStart 负责把流式会话起始状态一次性写入数据库。 +// 作用:校验当前用户是不是某个会话的拥有者。 +func (s *AIService) persistStreamStart( + ctx context.Context, + conversation *entity.AIConversation, + user *entity.User, + userMessage *entity.AIMessage, + assistantMessage *entity.AIMessage, + interrupt *entity.AIInterrupt, + now time.Time, +) error { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 + conversation.Title = deriveConversationTitle(conversation.Title, userMessage.Content) + conversation.Preview = buildConversationPreview(userMessage.Content) + conversation.IsGenerating = true + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 + conversation.LastMessageAt = &now + conversation.UpdatedAt = now + conversation.OrgID = user.CurrentOrgID + + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 + return s.txRunner.InTx(ctx, func(tx any) error { + txAI := s.aiRepo.WithTx(tx) + + // 会话状态先更新,是为了保证后续若消息已落库,列表页也能立即看到“生成中”态。 + if err := txAI.UpdateConversation(ctx, conversation); err != nil { + return err + } + if err := txAI.CreateMessage(ctx, userMessage); err != nil { + return err + } + if err := txAI.CreateMessage(ctx, assistantMessage); err != nil { + return err + } + + // interrupt 只在需要确认工具时创建,避免普通问答链路出现无意义等待记录。 + if interrupt != nil { + if err := txAI.CreateInterrupt(ctx, interrupt); err != nil { + return err + } + } + return nil + }) +} + +// finishStream 负责统一收尾一次流式会话执行。 +// +// 核心流程: +// 1. 无论成功失败,都先把会话从“生成中”切回非生成状态。 +// 2. 若运行成功则直接结束。 +// 3. 若流尚未开始,直接把执行错误返回给上层。 +// 4. 若是取消/超时,标记为 stopped;其他错误则写入 error 事件和 done 事件。 +// +// 注意事项: +// - 这里优先保证客户端已经打开的 SSE 流能收到终态,而不是简单把错误向上返回后中断连接。 +func (s *AIService) finishStream( + ctx context.Context, + conversation *entity.AIConversation, + sink *aiStreamSink, + execErr error, +) error { + now := time.Now() + conversation.IsGenerating = false + conversation.LastMessageAt = &now + conversation.UpdatedAt = now + + // 收尾状态更新失败要记录日志,但如果主流程已经出错,不再让收尾错误覆盖原始执行错误。 + if err := s.aiRepo.UpdateConversation(ctx, conversation); err != nil { + global.Log.Error("更新 AI 会话收尾状态失败", zap.String("conversation_id", conversation.ID), zap.Error(err)) + if execErr == nil { + return bizerrors.Wrap(bizerrors.CodeDBError, err) + } + } + if execErr == nil { + return nil + } + + // 如果流还没真正开始,把错误直接抛回控制器,让控制器返回标准 JSON 失败响应。 + if !streamWriterStarted(sink.writer) { + return execErr + } + + // 取消或超时属于“被中断”而不是“系统故障”,因此只标 stopped,不再额外发错误提示。 + if errors.Is(execErr, context.Canceled) || errors.Is(execErr, context.DeadlineExceeded) { + sink.setStopped() + _ = sink.persistMessage(ctx) + return nil + } + + // 其他错误需要同时写日志、更新消息错误状态,并主动向客户端补发 error/done 终态事件。 + global.Log.Error("AI 流式运行失败", zap.String("conversation_id", conversation.ID), zap.Error(execErr)) + message := "生成失败,请稍后重试。" + if bizErr := bizerrors.FromError(execErr); bizErr != nil && strings.TrimSpace(bizErr.Message) != "" { + message = bizErr.Message + } + + sink.setError(message) + _ = sink.Emit(ctx, "error", resp.AssistantErrorPayload{Message: message}) + _ = sink.Emit(ctx, "done", map[string]any{}) + return nil +} + +// startedWriter 抽象支持 Started 状态探测的流写出器。 +// 之所以单独定义接口,是为了避免 AIService 直接依赖具体 HTTP writer 实现。 +type startedWriter interface { + Started() bool +} + +// streamWriterStarted 负责判断某个流写出器是否已经真正开始写流。 +// 参数: +// - writer:当前请求使用的流写出器。 +// +// 返回值: +// - bool:true 表示响应头或事件内容已经开始发送。 +// +// 核心流程: +// 1. 尝试按 startedWriter 做能力断言。 +// 2. 不支持探测时默认认为流已开始,以避免误向客户端回写普通 JSON。 +// +// 注意事项: +// - 默认返回 true 看起来更保守,但它能防止“流式 writer 已写出,控制器却误判还能回 JSON”这种协议层错误。 +func streamWriterStarted(writer streamsse.StreamWriter) bool { + if sw, ok := writer.(startedWriter); ok { + return sw.Started() + } + return true +} diff --git a/internal/service/system/supplier.go b/internal/service/system/supplier.go index 6162082..2ae6436 100644 --- a/internal/service/system/supplier.go +++ b/internal/service/system/supplier.go @@ -20,6 +20,8 @@ var defaultServiceTraceModules = []string{ // SetUp 工厂函数,统一管理 func SetUp(repositoryGroup *repository.Group) contract.Supplier { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 ss := &serviceSupplier{} rawJWT := NewJWTService(repositoryGroup) @@ -37,6 +39,7 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { rawMenu := NewMenuService(repositoryGroup, rawPermissionProjection) rawRole := NewRoleService(repositoryGroup, rawPermissionProjection) rawImage := NewImageService(repositoryGroup) + rawAI := NewAIService(repositoryGroup) rawObservability := obsquery.NewQueryService( global.ObservabilityMetrics, global.ObservabilityTraces, @@ -55,8 +58,11 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { ojTaskSvc := contract.OJTaskServiceContract(rawOJTask) apiSvc := contract.ApiServiceContract(rawAPI) menuSvc := contract.MenuServiceContract(rawMenu) + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 roleSvc := contract.RoleServiceContract(rawRole) imageSvc := contract.ImageServiceContract(rawImage) + aiSvc := contract.AIServiceContract(rawAI) observabilitySvc := contract.ObservabilityServiceContract(rawObservability) cacheProjectionSvc := contract.CacheProjectionServiceContract(rawCacheProjection) ojDailyStatsProjectionSvc := contract.OJDailyStatsProjectionServiceContract(rawOJDailyStatsProjection) @@ -90,13 +96,31 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { ss.menuService = menuSvc ss.roleService = roleSvc ss.imageService = imageSvc + ss.aiService = aiSvc ss.observabilityService = observabilitySvc ss.cacheProjectionService = cacheProjectionSvc ss.ojDailyStatsProjectionService = ojDailyStatsProjectionSvc + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return ss } +// traceModuleEnabled 负责执行当前函数对应的核心逻辑。 +// 参数: +// - module:当前函数需要消费的输入参数。 +// +// 返回值: +// - bool:表示当前操作是否成功、命中或可继续执行。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func traceModuleEnabled(module string) bool { + // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 + // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 if global.Config == nil { return false } @@ -106,6 +130,8 @@ func traceModuleEnabled(module string) bool { } modules := cfg.Modules + // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 + // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 if len(modules) == 0 { modules = defaultServiceTraceModules } @@ -115,5 +141,7 @@ func traceModuleEnabled(module string) bool { return true } } + // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 + // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return false } diff --git a/internal/service/system/supplierImpl.go b/internal/service/system/supplierImpl.go index 45c9b35..249514d 100644 --- a/internal/service/system/supplierImpl.go +++ b/internal/service/system/supplierImpl.go @@ -20,63 +20,294 @@ type serviceSupplier struct { observabilityService contract.ObservabilityServiceContract cacheProjectionService contract.CacheProjectionServiceContract ojDailyStatsProjectionService contract.OJDailyStatsProjectionServiceContract + aiService contract.AIServiceContract } +// GetJWTSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.JWTServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetJWTSvc() contract.JWTServiceContract { return s.jwtService } + +// GetAuthorizationSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.AuthorizationServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetAuthorizationSvc() contract.AuthorizationServiceContract { return s.authorizationService } +// GetPermissionProjectionSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.PermissionProjectionServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetPermissionProjectionSvc() contract.PermissionProjectionServiceContract { return s.permissionProjectionService } + +// GetBaseSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.BaseServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetBaseSvc() contract.BaseServiceContract { return s.baseService } + +// GetHealthSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.HealthServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetHealthSvc() contract.HealthServiceContract { return s.healthService } + +// GetUserSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.UserServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetUserSvc() contract.UserServiceContract { return s.userService } + +// GetOrgSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.OrgServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetOrgSvc() contract.OrgServiceContract { return s.orgService } +// GetOJSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.OJServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetOJSvc() contract.OJServiceContract { return s.ojService } +// GetOJTaskSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.OJTaskServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetOJTaskSvc() contract.OJTaskServiceContract { return s.ojTaskService } +// GetApiSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.ApiServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetApiSvc() contract.ApiServiceContract { return s.apiService } +// GetMenuSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.MenuServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetMenuSvc() contract.MenuServiceContract { return s.menuService } +// GetRoleSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.RoleServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetRoleSvc() contract.RoleServiceContract { return s.roleService } +// GetImageSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.ImageServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetImageSvc() contract.ImageServiceContract { return s.imageService } +// GetObservabilitySvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.ObservabilityServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetObservabilitySvc() contract.ObservabilityServiceContract { return s.observabilityService } +// GetCacheProjectionSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.CacheProjectionServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetCacheProjectionSvc() contract.CacheProjectionServiceContract { return s.cacheProjectionService } +// GetOJDailyStatsProjectionSvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.OJDailyStatsProjectionServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 func (s *serviceSupplier) GetOJDailyStatsProjectionSvc() contract.OJDailyStatsProjectionServiceContract { return s.ojDailyStatsProjectionService } + +// GetAISvc 用于获取当前场景需要的对象或数据。 +// 参数: +// - 无。 +// +// 返回值: +// - contract.AIServiceContract:当前函数返回的处理结果。 +// +// 核心流程: +// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 +// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 +// +// 注意事项: +// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 +func (s *serviceSupplier) GetAISvc() contract.AIServiceContract { + return s.aiService +} diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 16176b5..19e3ee8 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -10,6 +10,7 @@ type BizCode int // - 2xxxx: 用户模块 // - 3xxxx: 组织与权限模块(组织/角色/菜单/API) // - 4xxxx: OJ模块 +// - 5xxxx: AI模块 const ( // ==================== 成功 ==================== @@ -93,6 +94,17 @@ const ( CodeOJTaskExecutionNotFound BizCode = 40107 // OJ任务执行记录不存在 CodeOJTaskPendingConfirmation BizCode = 40108 // OJ任务存在未确认的新题 CodeOJTaskQuestionAmbiguous BizCode = 40109 // OJ任务题目存在多个候选 + + // ==================== AI模块 5xxxx ==================== + + CodeAIConversationNotFound BizCode = 50001 // AI会话不存在 + CodeAIInterruptNotFound BizCode = 50002 // AI中断不存在 + CodeAIInterruptConflict BizCode = 50003 // AI中断状态冲突 + CodeAIConversationBusy BizCode = 50004 // AI会话正在运行 + CodeAIInterruptUnavailable BizCode = 50005 // AI中断无法在当前实例恢复 + CodeAIStreamingUnsupported BizCode = 50006 // AI流式输出不可用 + CodeAIRequestRejected BizCode = 50007 // AI请求被拒绝 + CodeAIMessageNotFound BizCode = 50008 // AI消息不存在 ) // codeMessages 错误码与默认消息的映射 @@ -171,6 +183,16 @@ var codeMessages = map[BizCode]string{ CodeOJTaskExecutionNotFound: "OJ任务执行记录不存在", CodeOJTaskPendingConfirmation: "存在未确认的新题目,请确认后再创建任务", CodeOJTaskQuestionAmbiguous: "任务题目存在多个候选,请先确认具体题目", + + // AI模块 + CodeAIConversationNotFound: "AI会话不存在", + CodeAIInterruptNotFound: "AI中断不存在", + CodeAIInterruptConflict: "AI中断状态不允许该操作", + CodeAIConversationBusy: "该会话仍在生成中,请稍后再试", + CodeAIInterruptUnavailable: "当前运行实例不可恢复,请重新发起本轮对话", + CodeAIStreamingUnsupported: "当前环境不支持流式输出", + CodeAIRequestRejected: "当前请求不符合 AI 流式约束", + CodeAIMessageNotFound: "AI消息不存在", } // Message 获取错误码对应的默认消息 diff --git a/plan/cross-module/approved-ai-a2ui-hybrid-assistant.md b/plan/cross-module/approved-ai-a2ui-hybrid-assistant.md new file mode 100644 index 0000000..24a79ac --- /dev/null +++ b/plan/cross-module/approved-ai-a2ui-hybrid-assistant.md @@ -0,0 +1,26 @@ +# approved-ai-a2ui-hybrid-assistant + +## 背景 + +本计划用于记录 AI 助手跨 `z_cur/UI` 与 `go/personal_assistant` 的已批准方案,避免后续开发再次回到“双段流 + 非 Checkpoint + 纯卡片协议”的旧结论。 + +## 已批准结论 + +1. V1 首版即采用 go-eino `Interrupt / Resume + Checkpoint`。 +2. 顶层协议继续使用业务 SSE 事件流,不改成纯 A2UI 顶层协议。 +3. A2UI 只进入消息内的声明式渲染块,不覆盖会话列表、主布局、权限状态机、SSE 协议和持久化。 +4. 流交互固定为“单条 `stream` + `interrupt decision` 控制接口”。 +5. `/tool-decisions/stream` 废弃。 + +## 本轮交付范围 + +1. 合并 AI 主文档与迁移说明。 +2. 更新 `z_cur/UI` 的类型、store、mock、渲染与交互,支持 `ui_blocks`。 +3. 更新 OpenAPI / Apifox,补齐 `ui_block`、A2UI 子集与 `interrupt_id`。 + +## 验收点 + +1. 前端可渲染 5 类 `ui_block`。 +2. 等待确认时不能继续发送新问题。 +3. decision 提交后不再开启第二条流。 +4. 文档与 OpenAPI 中不再出现 `/tool-decisions/stream`。 diff --git "a/plan/cross-module/approved-go-code-\347\224\237\344\272\247\347\272\247\344\270\255\346\226\207\346\263\250\351\207\212\350\241\245\345\205\250.md" "b/plan/cross-module/approved-go-code-\347\224\237\344\272\247\347\272\247\344\270\255\346\226\207\346\263\250\351\207\212\350\241\245\345\205\250.md" new file mode 100644 index 0000000..10ed3e6 --- /dev/null +++ "b/plan/cross-module/approved-go-code-\347\224\237\344\272\247\347\272\247\344\270\255\346\226\207\346\263\250\351\207\212\350\241\245\345\205\250.md" @@ -0,0 +1,36 @@ +# Cross-Module Go 代码生产级中文注释补全计划 + +## Summary +- 计划文件路径定为 `plan/cross-module/pending-go-code-生产级中文注释补全.md`;获批后改名为 `plan/cross-module/approved-go-code-生产级中文注释补全.md`,再执行注释补充。 +- 执行范围锁定为 2026-04-08 当前工作区内所有已修改或未跟踪的 `.go` 文件,共 47 个;不看暂存区,不处理 `.md`、README、`docs/` 等文档文件。 +- 交付方式锁定为直接修改仓库文件,并在对话中回报已处理文件分组与验证结果;不在对话里整批粘贴完整代码。 + +## Key Changes +- 只做注释层改动,不改业务逻辑、变量名、控制流、结构体字段、接口签名、路由、错误码、配置读取方式。 +- 按模块分组补注释,优先处理可读性最弱且并发/流式逻辑最重的区域: + - `internal/infrastructure/sse` 与 `internal/core/sse.go`:重点解释 `context` 取消、goroutine 生命周期、channel 缓冲、锁粒度、心跳、写超时、慢消费者处理、Pub/Sub 订阅退出、资源关闭时机。 + - `internal/service/system` 与 AI 相关 Controller/Repository/DTO/Entity:重点解释会话流启动、interrupt/decision 流程、状态迁移、消息持久化、运行时与 sink 的职责边界。 + - 其余基础与业务文件:`flag`、`global`、`core`、`init`、`router`、`repository`、`service`、`pkg/errors` 等,补齐函数职责、边界条件、错误处理原因和分层目的。 +- 每个函数或方法都补完整中文函数注释,至少覆盖:作用、参数、返回值、核心流程、注意事项。 +- 超过 20 行的函数,函数体内至少拆成 3 段局部注释;对关键分支、提前返回、错误包装/透传、资源操作、并发控制补“为什么这样做”。 +- 对无函数文件补类型、接口、常量、关键字段注释,保证阅读时能理解该文件承担的职责。 +- 对意图无法从局部代码完全确认的地方,明确使用“根据上下文推测”标注,避免把猜测写成确定事实。 + +## API / Type Changes +- 无对外行为变更。 +- 无接口、方法签名、结构体字段、配置结构、路由契约变更。 +- 仅新增或优化中文文档注释与局部说明性注释,使导出的类型、接口和核心内部结构更易读。 + +## Test Plan +- 注释补全后对所有触达文件执行 `gofmt`,仅用于保持 Go 语法与注释排版合法。 +- 执行已验证可跑的定向检查: + - `go test ./internal/infrastructure/sse -count=1` + - `go test ./internal/service/system -run TestAiRuntimeLocal -count=1` +- 对其余受影响包执行编译型 smoke test,确认纯注释改动未引入语法问题: + - `go test ./flag ./global ./internal/controller/system ./internal/core ./internal/init ./internal/model/... ./internal/repository/... ./internal/router/... ./internal/service/... ./pkg/errors -run '^$' -count=1` + +## Assumptions +- 因暂存区为空,本次按“当前工作区所有已修改/未跟踪 Go 文件”执行,这是已确认的范围替代方案。 +- 测试文件同样属于代码文件,纳入注释补全范围。 +- 若执行中发现新增文件或范围明显变化,需要重新生成新的 `pending-*.md` 计划,不静默扩项。 +- 实施顺序固定为:先落待审计划文件,再等你确认;确认后改名为 `approved-*`,随后开始实际补注释。 From a375239ccd5c4e208ba20f409063eb84bf4f3e3f Mon Sep 17 00:00:00 2001 From: wang Date: Tue, 21 Apr 2026 17:53:47 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=E8=AE=BE=E8=AE=A1MVC=E6=B8=90=E8=BF=9B?= =?UTF-8?q?=E5=88=B0DDD=E6=9E=B6=E6=9E=84=EF=BC=8C=E4=BC=98=E5=8C=96AI?= =?UTF-8?q?=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/vcs.xml | 1 - README.md | 359 +++++++++---- configs/configs.yaml | 20 + ...6\226\275\350\256\241\345\210\222-Qwen.md" | 72 +++ ...257\345\257\271\346\216\245-go-eino-V1.md" | 3 + ...76\350\256\241\346\226\271\346\241\210.md" | 164 +++++- ...66\346\236\204\351\242\204\350\247\210.md" | 382 ++++++++++++++ ...24\350\277\233\350\257\264\346\230\216.md" | 479 +++++++++++++++++ ...66\346\236\204\346\213\206\345\210\206.md" | 125 +++++ docs/AI/stage3_agent_runtime_assessment.md | 73 +++ global/global.go | 4 + go.mod | 31 +- go.sum | 143 +++++ internal/controller/system/aiCtrl.go | 44 -- internal/controller/system/supplier.go | 1 - internal/controller/system/userCtrl.go | 9 - internal/core/ai.go | 68 +++ internal/core/config.go | 17 +- internal/domain/ai/event.go | 54 ++ internal/domain/ai/message.go | 25 + internal/domain/ai/runtime.go | 48 ++ internal/domain/ai/runtime_test.go | 12 + internal/domain/ai/sink.go | 20 + internal/infrastructure/ai/eino/chat_model.go | 79 +++ .../infrastructure/ai/eino/chat_model_test.go | 13 + internal/infrastructure/ai/eino/options.go | 47 ++ internal/infrastructure/ai/eino/runtime.go | 170 ++++++ internal/infrastructure/ai/local/runtime.go | 158 ++++++ .../infrastructure/ai/local/runtime_test.go | 43 ++ internal/infrastructure/sse/interfaces.go | 5 +- internal/infrastructure/sse/types.go | 16 +- internal/init/init.go | 2 + internal/middleware/corsMW.go | 2 +- internal/model/config/ai.go | 14 + internal/model/config/config.go | 14 + internal/model/dto/request/aiReq.go | 16 +- internal/model/dto/response/aiResp.go | 62 +-- .../repository/interfaces/aiRepository.go | 10 + internal/repository/system/aiRepo.go | 74 +++ internal/router/system/aiRouter.go | 5 +- internal/service/contract/system.go | 2 - internal/service/system/aiIntent.go | 75 --- internal/service/system/aiMapper.go | 235 +-------- internal/service/system/aiProjector.go | 116 +++++ internal/service/system/aiRuntime.go | 115 ---- internal/service/system/aiRuntimeLocal.go | 491 ------------------ .../service/system/aiRuntimeLocal_test.go | 183 ------- internal/service/system/aiSink.go | 185 +------ internal/service/system/aiSvc.go | 220 +++----- pkg/casbin/casbin.go | 2 +- .../approved-ai-architecture-overview-doc.md | 42 ++ plan/ai/approved-ai-chinese-comments.md | 39 ++ .../approved-ai-current-implementation-doc.md | 45 ++ plan/ai/approved-basic-streaming-chat.md | 52 ++ plan/ai/approved-ci-lint-fix.md | 59 +++ plan/cross-module/approved-readme-rewrite.md | 168 ++++++ 56 files changed, 3265 insertions(+), 1648 deletions(-) create mode 100644 "docs/AI/AI\345\212\251\346\211\213\344\270\213\344\270\200\351\230\266\346\256\265\345\256\236\346\226\275\350\256\241\345\210\222-Qwen.md" rename "docs/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" => "docs/AI/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" (74%) rename "docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" => "docs/AI/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" (55%) create mode 100644 "docs/AI/AI\346\236\266\346\236\204\351\242\204\350\247\210.md" create mode 100644 "docs/AI/AI\351\241\271\347\233\256\346\274\224\350\277\233\350\257\264\346\230\216.md" create mode 100644 "docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" create mode 100644 docs/AI/stage3_agent_runtime_assessment.md create mode 100644 internal/core/ai.go create mode 100644 internal/domain/ai/event.go create mode 100644 internal/domain/ai/message.go create mode 100644 internal/domain/ai/runtime.go create mode 100644 internal/domain/ai/runtime_test.go create mode 100644 internal/domain/ai/sink.go create mode 100644 internal/infrastructure/ai/eino/chat_model.go create mode 100644 internal/infrastructure/ai/eino/chat_model_test.go create mode 100644 internal/infrastructure/ai/eino/options.go create mode 100644 internal/infrastructure/ai/eino/runtime.go create mode 100644 internal/infrastructure/ai/local/runtime.go create mode 100644 internal/infrastructure/ai/local/runtime_test.go create mode 100644 internal/model/config/ai.go delete mode 100644 internal/service/system/aiIntent.go create mode 100644 internal/service/system/aiProjector.go delete mode 100644 internal/service/system/aiRuntime.go delete mode 100644 internal/service/system/aiRuntimeLocal.go delete mode 100644 internal/service/system/aiRuntimeLocal_test.go create mode 100644 plan/ai/approved-ai-architecture-overview-doc.md create mode 100644 plan/ai/approved-ai-chinese-comments.md create mode 100644 plan/ai/approved-ai-current-implementation-doc.md create mode 100644 plan/ai/approved-basic-streaming-chat.md create mode 100644 plan/ai/approved-ci-lint-fix.md create mode 100644 plan/cross-module/approved-readme-rewrite.md diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 0a7935e..94a25f7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,6 +2,5 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 5eb8a8d..f7a5df8 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,177 @@ # Personal Assistant -> 一个基于 Go 的后端项目,聚焦用户与组织管理、RBAC 权限控制、OJ 刷题数据同步、图片资源管理。 - -## 项目亮点 - -- **用户认证**:支持注册、登录、登出、刷新 Token,采用双 Token 方案与 HttpOnly Refresh Token。 -- **组织与权限**:覆盖组织、角色、菜单、API 管理,支持用户-组织-角色关系建模。 -- **RBAC**:基于 Casbin 做权限校验,并通过权限投影链路支撑多实例下的策略收敛。 -- **OJ 能力**:支持 LeetCode / Luogu 账号绑定、异步同步、排行榜查询与缓存投影。 -- **图片管理**:支持本地 / 七牛双存储驱动,包含上传限流、孤儿文件清理等治理能力。 -- **事件驱动一致性**:基于 Redis Stream + Outbox + 双通道收口,用于异步解耦、投影刷新和多实例收敛。 -- **第三方基础设施接入**:在 `internal/infrastructure` 中统一封装 LeetCode / Luogu / Lanqiao 客户端,采用配置驱动,便于替换和扩展。 -- **可观测性**:提供请求链路追踪、GORM/任务埋点和指标聚合查询能力。 -- **稳定性治理**:集成七牛存储熔断、上传限流、Redis 分布式锁与任务协调。 -- **排行榜设计**:基于 Redis ZSet + 用户快照投影 + 读模型聚合查询,兼顾查询性能与异步收敛。 -- **框架设计**:按 Controller / Service / Repository / Router / Core / Infrastructure / pkg 分层,拆分业务编排、数据访问与基础设施初始化职责。 - -## 技术栈 - -- 语言与框架:Go, Gin -- 数据层:Gorm, MySQL -- 缓存与消息:Redis, Redis Stream -- 权限:Casbin -- 配置与日志:Viper, Zap -- 其他:Resty, robfig/cron, urfave/cli +> 一个以 Go + Gin 为核心的模块化单体后端,围绕“用户/组织/RBAC 权限治理 + OJ 数据同步与任务化运营 + AI 会话/SSE 流式助手 + 图片与可观测性治理”构建的业务型平台后端。 + +它解决的不是单一后台管理问题,而是把账号体系、组织协作、权限控制、OJ 数据运营、AI 助手能力和一批工程治理能力整合到一套后端里。 + +从代码现状看,本项目更适合被定义为“面向业务场景的 Go 平台后端”,不是微服务,也不是纯 CRUD 管理台。 + +## 为什么它不是简单 CRUD + +- 权限不是直接把规则写死在接口里,而是基于 `user-org-role` 关系表建模,DB 作为真相源,Casbin 作为权限投影层。 +- OJ 模块不是单次查询接口,而是覆盖绑定、同步、统计、曲线、排行榜和任务化运营。 +- OJ 任务支持任务版本化、定时调度、执行抢占和冻结快照,属于可追溯执行链路。 +- AI 子域已经落地了会话持久化、SSE 单流输出、interrupt/decision 与 Eino runtime 主链。 +- 异步收敛不是简单 goroutine,而是基于 Outbox + Redis Stream + Pub/Sub 组织跨模块最终一致性。 + +## 项目定位 + +- 用户/组织/RBAC 权限治理:覆盖账号生命周期、组织成员关系、角色/菜单/API/capability 权限管理。 +- OJ 数据同步与任务化运营:支持 LeetCode / Luogu / Lanqiao 账号绑定、排行榜、统计曲线和任务执行。 +- AI 会话/SSE 助手能力:提供会话管理、流式输出、人工决策回填和 Eino runtime 接入。 +- 图片与可观测性治理:覆盖图片上传删除、双存储驱动、链路埋点、指标查询和任务追踪。 + +## 核心能力 + +### 核心功能 + +- 用户认证:支持注册、登录、刷新 Token、登出、资料维护、密码修改、主动注销。 +- 组织管理:支持创建组织、加入组织、退出组织、切换当前组织、踢出成员、恢复成员。 +- RBAC 权限:支持角色、菜单、API 管理和 capability 建模,并将权限投影到 Casbin。 +- OJ 数据:支持 OJ 账号绑定、排行榜、统计数据、成绩曲线和题库同步。 +- OJ 任务:支持标题分析建任务、立即执行、版本派生、重试和执行结果追踪。 +- AI 会话:支持会话 CRUD、消息列表、SSE 流式输出和 interrupt/decision 决策恢复。 + +### 支撑能力 + +- 图片存储:支持本地 / 七牛双驱动、上传限流、批量删除和孤儿图片清理。 +- SSE 基础设施:支持连接管理、回放、Pub/Sub backplane 和多连接控制。 +- 可观测性:支持 HTTP/Gorm/任务 trace、指标聚合和查询接口。 +- 缓存与限流:支持活跃态缓存、OJ 绑定限流、上传限流、排行榜缓存。 + +### 工程能力 + +- 事件驱动收敛:基于 Outbox + Redis Stream + Pub/Sub 处理权限投影、缓存投影和异步任务触发。 +- 调度与协作:支持 Cron、Redis 分布式锁、任务抢占和消费者分组。 +- 初始化治理:支持自动迁移、Repository/Service/Controller 装配、基础设施统一初始化。 +- 运行与运维:支持 CLI、Docker、本地/生产部署配置和日志落盘。 + +## 架构总览 + +### 架构结论 + +- 模块化单体 +- 分层架构 / Handler-Service-Repository +- 业务真相在 MySQL,投影在 Redis / Casbin +- 异步解耦采用 Outbox + Redis Stream + Pub/Sub +- AI 子域采用 Eino runtime + SSE 单流控制 -## 目录结构 +### 启动链 ```text -. -├── cmd/ # 程序入口 -├── configs/ # 配置文件(yaml + casbin model) -├── internal/ -│ ├── controller/ # HTTP 控制器 -│ ├── service/ # 业务逻辑 -│ ├── repository/ # 数据访问层 -│ ├── router/ # 路由注册 -│ ├── middleware/ # 中间件 -│ ├── infrastructure/ # 外部服务接入与消息基础设施(LeetCode/Luogu/Lanqiao/Outbox) -│ └── core/ # 启动流程、配置、日志、数据库、任务初始化 -├── pkg/ # 公共组件(jwt、response、storage、errors 等) -├── docs/ # 项目文档 -├── docker-compose.yaml -└── Dockerfile +cmd/main + -> internal/init + -> internal/core / internal/infrastructure + -> internal/repository + -> internal/service + -> internal/controller + -> internal/router + -> gin server ``` -## 快速开始(本地) +### 运行时关键点 + +- 入口从 `cmd/main.go` 启动,主初始化编排在 `internal/init/init.go`。 +- `internal/core` 负责配置、日志、Gorm、Redis、Casbin、Storage、SSE、Observability、Outbox Relay、Cron 等基础设施初始化。 +- `internal/router/router.go` 负责中间件挂载和路由分组,业务路由在 `internal/router/system` 中按领域拆分。 +- `internal/service/system` 负责业务编排,`internal/repository` 负责数据库与投影数据访问。 + +## 核心模块 + +- 启动与基础设施 + 职责:配置、日志、Gorm、Redis、Casbin、Storage、SSE、Outbox、Cron 初始化。 + 关键目录:`cmd/`、`internal/init/`、`internal/core/` + +- 接口层 + 职责:Gin 路由注册、DTO 接参、统一响应、鉴权中间件接入。 + 关键目录:`internal/router/`、`internal/controller/`、`internal/middleware/` + +- 用户与认证 + 职责:注册登录、双 Token、账号状态、资料维护、当前组织上下文。 + 关键目录:`internal/service/system/userSvc.go`、`jwtSvc.go`、`baseSvc.go` + +- 组织与权限 + 职责:组织管理、成员关系、角色/菜单/API/capability 管理、权限判断与权限投影。 + 关键目录:`internal/service/system/orgSvc.go`、`roleSvc.go`、`menuSvc.go`、`authorizationSvc.go`、`permissionProjectionSvc.go` + +- OJ 业务 + 职责:LeetCode / Luogu / Lanqiao 账号绑定、数据同步、统计、排行、曲线、题库预热。 + 关键目录:`internal/service/system/ojSvc.go`、`ojLanqiaoSvc.go`、`internal/infrastructure/leetcode/`、`luogu/`、`lanqiao/` + +- OJ 任务 + 职责:任务分析、版本管理、调度执行、执行快照、用户命中详情查询。 + 关键目录:`internal/service/system/ojTaskSvc.go`、`ojTaskDispatcher.go` + +- AI / Agent + 职责:会话管理、消息持久化、流式输出、interrupt/decision、runtime 抽象与 Eino 接入。 + 关键目录:`internal/service/system/aiSvc.go`、`aiRuntime*.go`、`internal/infrastructure/ai/eino/` + +- 图片与存储 + 职责:图片上传、列表、删除、孤儿治理、本地 / 七牛驱动切换。 + 关键目录:`internal/service/system/imageSvc.go`、`pkg/storage/`、`pkg/imageops/` + +- 可观测性 + 职责:请求追踪、Gorm Trace、任务 Trace、指标聚合和查询接口。 + 关键目录:`internal/core/observability.go`、`internal/middleware/observabilityMW.go`、`pkg/observability/` + +- 消息与异步 + 职责:Outbox Relay、Redis Stream 消费、缓存投影、权限投影、OJ 日统计投影。 + 关键目录:`internal/core/outboxRelay.go`、`internal/core/subscriberInit.go`、`internal/infrastructure/outbox/`、`internal/infrastructure/messaging/` + +## 核心链路 + +### 请求流 + +客户端请求先经过 `RequestID / Observability / Logger / Recovery / CORS` 等全局中间件,再根据分组进入 `JWTAuth / ActiveUser / PermissionMiddleware`。Controller 负责接参与响应,Service 负责业务编排,Repository 负责落 MySQL / Redis,最终由 `pkg/response` 统一返回。 + +### 数据流 + +业务真相主要落在 MySQL,例如 `users / orgs / user_org_roles / roles / menus / apis / oj_* / ai_* / outbox_events`。Redis 负责缓存、排行榜、SSE 回放、消息消费、分布式锁和 trace stream。Casbin 只承担权限投影,不承担业务真相。 + +### 异步流 + +业务写库时同步写 Outbox;Relay 把 Outbox 事件推到 Redis Stream;Subscriber 消费后做权限投影、缓存投影、OJ 日统计修复和 OJ 任务触发。定时任务则负责全量同步、排行重建、禁用账号清理和孤儿图片清理。 + +### 典型业务链路 + +1. 注册 + 创建用户后,会补齐组织关系、默认角色、权限/缓存投影,完成账号初始状态收口。 + +2. OJ 绑定 + 调用外部 crawler 服务拉取数据,Upsert 用户 OJ 明细,再刷新排行缓存并发布后续投影事件。 + +3. OJ 任务执行 + 调度器扫描待执行任务,通过 Redis 锁抢占执行权,生成任务快照并写入执行结果。 + +4. AI 会话流式输出 + 先创建会话,再通过 `POST /ai/conversations/:id/stream` 进入流式对话,运行时会写消息骨架、推送 SSE,并在需要时通过 interrupt/decision 恢复执行。 + +## 技术栈与依赖 + +- 框架:Go、Gin +- 存储:MySQL、Gorm、Redis +- 权限:Casbin +- 异步:Redis Stream、Pub/Sub、Cron +- AI:CloudWeGo Eino、Qwen / OpenAI 兼容接入 +- 工程化:Viper、Zap、Resty、urfave/cli、Qiniu SDK + +### 依赖边界 + +- MySQL、Redis:必需依赖 +- OJ crawler 服务:可选外部依赖,使用 OJ 功能时必需 +- 七牛云存储:可选依赖,不使用时可退回本地存储 + +## 快速开始 ### 1. 前置依赖 -- Go `>= 1.24`(`go.mod` 中配置了 `toolchain go1.24.9`) +- Go `1.23+`,项目中启用了 `toolchain go1.24.9` - MySQL `8.x` - Redis `6+` -- 可选:OJ 爬虫服务(用于 LeetCode/洛谷数据接口) +- 可选:OJ crawler 服务 ### 2. 配置环境变量 -复制环境变量模板: - ```bash # Linux / macOS cp .env.example .env @@ -66,15 +180,15 @@ cp .env.example .env Copy-Item .env.example .env ``` -然后按你的环境修改 `.env`,至少确认: +至少确认以下配置可用: -- `DB_HOST/DB_PORT/DB_NAME/DB_USERNAME/DB_PASSWORD` -- `REDIS_ADDRESS/REDIS_PASSWORD/REDIS_DB` -- `JWT_ACCESS_TOKEN_SECRET/JWT_REFRESH_TOKEN_SECRET` -- `SYSTEM_HOST/SYSTEM_PORT` -- `CRAWLER_LEETCODE_BASE_URL/CRAWLER_LUOGU_BASE_URL`(如使用 OJ 功能) +- `DB_HOST / DB_PORT / DB_NAME / DB_USERNAME / DB_PASSWORD` +- `REDIS_ADDRESS / REDIS_PASSWORD / REDIS_DB` +- `JWT_ACCESS_TOKEN_SECRET / JWT_REFRESH_TOKEN_SECRET` +- `SYSTEM_HOST / SYSTEM_PORT` +- `CRAWLER_LEETCODE_BASE_URL / CRAWLER_LUOGU_BASE_URL / CRAWLER_LANQIAO_BASE_URL` -### 3. 启动服务 +### 3. 本地启动 ```bash go mod tidy @@ -85,14 +199,16 @@ go run cmd/main.go ### 4. 数据库初始化 -- 默认 `AUTO_MIGRATE=true` 时会自动迁移表结构。 -- 也可手动执行: +- `AUTO_MIGRATE=true` 时,启动时会自动迁移表结构。 +- 也可以手动执行: ```bash go run cmd/main.go --sql ``` -## Docker 启动 +### 5. Docker 启动 + +本地容器启动: ```bash docker compose up -d --build @@ -100,16 +216,36 @@ docker compose up -d --build 说明: -- 当前 `docker-compose.yaml` 只包含 `app` 服务。 -- MySQL / Redis 需要你自行提供并确保容器内可访问(例如通过 `host.docker.internal` 或同网络服务名)。 +- 根目录 `docker-compose.yaml` 当前只编排 `app` 服务。 +- `deploy/docker-compose.prod.yml` 提供生产场景下的 `app + web` 样例。 +- MySQL / Redis 需要你自行提供并保证网络可达。 + +## 配置说明 + +配置主要由 `.env.example` 和 `configs/configs.yaml` 驱动,建议按下面这些组来理解: + +- `system / jwt / session`:服务监听、环境、双 Token、Session +- `mysql / redis`:数据库、缓存与连接池 +- `storage / static`:本地静态目录、七牛存储、图片限制 +- `crawler`:LeetCode / Luogu / Lanqiao 外部接口地址与超时重试 +- `observability`:指标、trace、清理周期、采样与脱敏策略 +- `task / messaging`:定时任务、Outbox、Redis Stream、分布式锁 +- `sse / ai`:SSE 心跳与回放、AI provider、model、checkpoint、runtime 命令通道 + +具体键名和默认值,请以: + +- `.env.example` +- `configs/configs.yaml` + +为准。 ## 命令行工具 -项目内置 CLI 参数(一次仅支持一个): +项目内置 CLI 参数,一次仅支持一个: -- `--sql`:初始化/迁移数据库表结构 -- `--sql-export`:导出 MySQL 数据(依赖名为 `mysql` 的 Docker 容器) -- `--sql-import `:从 SQL 文件导入数据 +- `--sql`:初始化/迁移数据库结构 +- `--sql-export`:导出 SQL 数据 +- `--sql-import `:导入 SQL 文件 示例: @@ -117,64 +253,79 @@ docker compose up -d --build go run cmd/main.go --sql-import .\backup.sql ``` -## 接口分组概览 +说明:`--admin` 标志在当前代码中有声明,但主执行分支尚未接入,不建议视为已完成能力。 -> 当前路由前缀并非完全统一,以下为主要分组。 - -- 公共接口(无需 JWT) -- `POST /base/captcha` -- `POST /base/sendEmailVerificationCode` -- `POST /user/register` -- `POST /user/login` -- `POST /refreshToken` - -- 业务接口(需 JWT) -- `POST /user/logout` -- `PUT /user/profile` -- `PUT /user/phone` -- `PUT /user/password` -- `POST /oj/bind` -- `POST /oj/ranking_list` -- `POST /oj/stats` -- `POST /api/system/image/upload` -- `DELETE /api/system/image/delete` -- `GET /api/system/image/list` -- `GET /system/org/my` -- `PUT /system/org/current` - -- 系统管理接口(需 JWT + 权限) -- `/system/api/*` -- `/system/menu/*` -- `/system/role/*` -- `/system/org/*` -- `/system/user/list` -- `/system/user/{id}` -- `/system/user/{id}/roles` -- `/system/user/assign_role` - -## 认证说明 +## 认证方式 - Access Token 支持: -- 请求头 `x-access-token` -- 或 `Authorization: Bearer ` -- Refresh Token 默认使用 HttpOnly Cookie:`x-refresh-token` + - 请求头 `x-access-token` + - 或 `Authorization: Bearer ` +- Refresh Token 默认走 HttpOnly Cookie:`x-refresh-token` + +## 接口分组概览 + +> 当前代码中的路由前缀并未完全统一,下面按真实路由分组列示,不能简单理解为都挂在同一个 `/api` 前缀下。 + +| 分组 | 示例接口 | 权限要求 | +| --- | --- | --- | +| 健康检查 | `GET /api/v1/health`、`GET /api/v1/ping` | 无 | +| 基础服务 | `POST /base/captcha`、`POST /base/sendEmailVerificationCode` | 无 | +| 用户认证 | `POST /user/register`、`POST /user/login`、`POST /refreshToken` | 无 | +| 用户业务 | `POST /user/logout`、`PUT /user/profile`、`PUT /user/phone`、`PUT /user/password`、`POST /user/deactivate` | JWT | +| 组织业务 | `GET /system/org/my`、`PUT /system/org/current`、`POST /system/org/join`、`POST /system/org/leave` | JWT | +| 系统权限管理 | `/system/api/*`、`/system/menu/*`、`/system/role/*`、`/system/org/*`、`/system/user/*` | JWT + 权限 | +| OJ | `POST /oj/bind`、`POST /oj/lanqiao/bind`、`POST /oj/ranking_list`、`POST /oj/stats`、`POST /oj/curve` | JWT | +| OJ Task | `POST /oj/task/analyze`、`POST /oj/task`、`GET /oj/task/list`、`POST /oj/task/:id/execute-now` | JWT | +| AI 会话 | `POST /ai/conversations`、`GET /ai/conversations`、`GET /ai/conversations/:id/messages`、`DELETE /ai/conversations/:id` | JWT | +| AI SSE | `POST /ai/conversations/:id/stream`、`POST /ai/conversations/:id/interrupts/:interrupt_id/decision` | JWT | +| 图片 | `POST /api/system/image/upload`、`DELETE /api/system/image/delete`、`GET /api/system/image/list` | JWT | +| 可观测性 | `GET /system/observability/traces/detail/:id`、`POST /system/observability/traces/query`、`POST /system/observability/metrics/query` | JWT + 权限 | + +## 当前完成度与边界 + +### 已落地主链路 + +- 用户 / 组织 / 权限主链路 +- OJ 绑定、统计、曲线、排行主链路 +- OJ 任务版本化、调度、执行与快照主链路 +- AI 会话、SSE、interrupt/decision、Eino runtime 主链路 +- 图片治理、可观测性、Outbox 异步收敛主链路 + +### 不宜过度表述的部分 + +- 当前代码是单体应用,不是微服务体系 +- AI 子域已落地主链路,但不应表述为完整通用多 Agent 平台 +- 路由前缀与接口治理并未完全统一,不能表述为完整 OpenAPI 平台 + +### 外部依赖边界 + +- OJ 数据依赖外部 crawler 服务 +- 七牛仅是可选存储驱动,不是必需组件 +- 生产部署编排样例存在,但完整生产环境仍需自行补齐 MySQL / Redis / 网关等外围设施 ## 相关文档 - `docs/事件驱动架构-RedisStream-Outbox-双通道一致性实践.md` - `docs/Casbin-RBAC权限系统架构文档.md` - `docs/双Token认证方案-整合版.md` +- `docs/AI助手架构设计方案.md` +- `docs/SSE实时推送基础设施重构指导文档.md` - `docs/图片管理-技术文档.md` - `docs/图片上传流.md` - `docs/flag指令.md` -第三方集成、可观测性、排行榜与框架整体设计文档持续补充中。 +如果你是面试阅读者,建议优先看: + +1. `事件驱动架构-RedisStream-Outbox-双通道一致性实践` +2. `Casbin-RBAC权限系统架构文档` +3. `AI助手架构设计方案` -## 安全提醒(公开仓库前建议先做) +## 安全提醒 -- 全量替换 `.env` / `.env.example` / 配置文件中的真实密钥与密码。 -- 轮换数据库密码、Redis 密码、JWT 密钥、邮箱密钥、云存储密钥。 -- 确保 `.env` 不会被提交(已在 `.gitignore` 中忽略)。 +- 发布公开仓库前,务必替换 `.env`、`.env.example` 和配置文件中的密钥、密码与第三方凭证。 +- 建议轮换数据库密码、Redis 密码、JWT 密钥、邮箱密钥、对象存储密钥。 +- 确保 `.env` 不会被提交;当前仓库已在 `.gitignore` 中忽略。 +- `configs/configs.yaml` 中存在历史示例值,实际部署时应统一改为安全配置。 ## License diff --git a/configs/configs.yaml b/configs/configs.yaml index f148b13..307acf1 100644 --- a/configs/configs.yaml +++ b/configs/configs.yaml @@ -224,6 +224,26 @@ messaging: permission_projection_group: "permission_projection_group" permission_projection_consumer: "permission_projection_consumer" permission_policy_reload_channel: "permission_policy_reload" +sse: + heartbeat_interval_seconds: 20 + write_timeout_seconds: 10 + queue_capacity: 64 + max_connections_per_subject: 3 + replay_limit: 100 + drain_timeout_seconds: 15 + pubsub_channel_prefix: "sse" + replay_stream_prefix: "sse:replay" + ai_runtime_mode: "eino" +ai: + provider: "qwen" + api_key: "" + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1" + model: "qwen-plus" + by_azure: false + api_version: "" + system_prompt: "你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。" + temperature: 0.2 + max_completion_tokens: 1200 observability: enabled: true # 总开关 service_name: "personal_assistant" # 服务名维度(需稳定) diff --git "a/docs/AI/AI\345\212\251\346\211\213\344\270\213\344\270\200\351\230\266\346\256\265\345\256\236\346\226\275\350\256\241\345\210\222-Qwen.md" "b/docs/AI/AI\345\212\251\346\211\213\344\270\213\344\270\200\351\230\266\346\256\265\345\256\236\346\226\275\350\256\241\345\210\222-Qwen.md" new file mode 100644 index 0000000..eff5863 --- /dev/null +++ "b/docs/AI/AI\345\212\251\346\211\213\344\270\213\344\270\200\351\230\266\346\256\265\345\256\236\346\226\275\350\256\241\345\210\222-Qwen.md" @@ -0,0 +1,72 @@ +# AI Runtime -> Eino 第二阶段实施计划(Qwen 版) + +## 1. 文档定位 + +本文档是第二阶段的实施说明,补充主文档 [AI助手架构设计方案.md](./AI助手架构设计方案.md) 的落地范围、默认配置和验收口径。 + +第二阶段只做三件事: + +1. 正式模型路径默认切到 `Qwen + DashScope compatible-mode`。 +2. 把 task / progress / doc 三类正式能力统一收进 `EinoAIRuntime`。 +3. 把上下文真相从前端兼容字段收回服务端。 + +## 2. 当前正式结论 + +1. `AIRuntime` 继续保留为 service seam,`AIService` 不直接依赖具体模型 SDK。 +2. `LocalAIRuntime` 继续保留,但只作为 mock / test / fallback。 +3. 6 个 HTTP API、10 个 SSE 事件、单流 `decision` JSON 控制接口保持不变。 +4. 正式 provider 默认值改为: + - `ai.provider=qwen` + - `ai.base_url=https://dashscope.aliyuncs.com/compatible-mode/v1` + - `ai.model=qwen-plus` +5. `ContextUserName / ContextOrgName` 保留为兼容字段,但不再作为正式可信输入。 + +## 3. 实现范围 + +### 3.1 模型与运行时 + +1. 模型工厂新增 `qwen` provider,正式实现使用 `github.com/cloudwego/eino-ext/components/model/qwen`。 +2. `EinoAIRuntime` 对非 lightweight 请求统一走 Eino runner。 +3. `AIRuntimePlan` 继续保留,但 plan 逻辑从 `LocalAIRuntime` 中抽出,作为共享 planner。 + +### 3.2 工具执行 + +第二阶段固定 3 个工具: + +1. `get_task_snapshot` + - 无需确认。 + - 读取当前用户可见任务的最新执行快照。 +2. `get_progress_snapshot` + - 无需确认。 + - 读取最近 7 天训练进度与当前 OJ 分数。 +3. `search_project_docs` + - 需要确认。 + - 继续走 `interrupt / resume / checkpoint`。 + +这些工具都由 Eino 执行,但外部仍继续消费现有业务 SSE 事件映射。 + +### 3.3 上下文与持久化 + +1. scope 中的人名、组织名由服务端推导。 +2. `RuntimeStateJSON` 固定保留以下字段: + - `runtime_name` + - `checkpoint_id` + - `resume_target_id` + - `tool_name` +3. 历史消息恢复仍以 MySQL 中的消息快照和 interrupt 终态为准。 + +## 4. 验收口径 + +1. 非 lightweight 请求不再正式回退到 local execute 分支。 +2. task / progress 工具通过 Eino 工具链触发,并继续映射为已有 `tool_call_started / tool_call_finished` 事件。 +3. `search_project_docs` 的 confirm / skip 继续在原 SSE 流内恢复,不新增第二条 SSE。 +4. 兼容字段 `ContextUserName / ContextOrgName` 缺失或伪造时,scope 仍以服务端真相为准。 +5. `LocalAIRuntime` 只用于 fallback / test,不作为正式默认运行时。 + +## 5. 配置建议 + +开发环境建议: + +1. 默认模型:`qwen-plus` +2. 成本敏感场景可改为:`qwen3.5-flash` +3. 若 `ai.api_key`、`ai.model` 或 Redis checkpoint 条件不满足,运行时允许回退 `LocalAIRuntime`,但应视为非正式模式。 diff --git "a/docs/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" "b/docs/AI/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" similarity index 74% rename from "docs/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" rename to "docs/AI/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" index 32fecbc..bdcb643 100644 --- "a/docs/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" +++ "b/docs/AI/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" @@ -24,5 +24,8 @@ - `POST /ai/conversations/{id}/stream` - `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` 8. 旧的工具续跑流接口全量废弃。 +9. 第一阶段实现继续保留 `AIRuntime` 作为业务缝;正式运行时默认走 `EinoAIRuntime`,`LocalAIRuntime` 只保留为 mock / test / fallback。 +10. 第二阶段正式模型路径默认切到 `Qwen + DashScope compatible-mode`,不再默认走 `OpenAI / Ark`。 +11. 第二阶段开始,`task / progress / doc` 三类正式能力统一由 Eino 工具执行;前端上传的 `ContextUserName / ContextOrgName` 只保留兼容,不再作为正式真相输入。 若后续需要补充实现细节、OpenAPI 变更或前后端联调约束,只更新主文档,不再回写本文件。 diff --git "a/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/AI/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" similarity index 55% rename from "docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" rename to "docs/AI/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" index 1ad5ce1..5038160 100644 --- "a/docs/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" +++ "b/docs/AI/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" @@ -56,6 +56,20 @@ V1 从首版开始就把 `Interrupt / Checkpoint` 作为必选能力,不采用 4. `CheckPointStore` 负责运行时恢复点。 5. 业务 Service 负责权限、会话、消息、SSE 事件映射与持久化收口。 +第一阶段实现约束: + +1. `AIRuntime` 继续保留为 Service 与运行时之间的抽象缝。 +2. 正式运行时默认切到 `EinoAIRuntime`。 +3. `LocalAIRuntime` 只保留为 mock / test / fallback,不再作为正式运行时真相实现。 + +第二阶段正式口径: + +1. 正式模型 provider 默认使用 `Qwen + DashScope compatible-mode`,默认 `provider=qwen`。 +2. 非 lightweight 请求统一走 `EinoAIRuntime`,由 Eino 正式接管 `get_task_snapshot`、`get_progress_snapshot`、`search_project_docs` 三类工具执行。 +3. `ContextUserName / ContextOrgName` 只保留为兼容请求字段;正式上下文必须由服务端从登录态、当前组织和可见数据推导。 +4. `RuntimeStateJSON` 固定收口为 `runtime_name / checkpoint_id / resume_target_id / tool_name` 四个字段。 +5. 第二阶段实施说明单独见 [AI助手下一阶段实施计划-Qwen.md](./AI助手下一阶段实施计划-Qwen.md)。 + ### 3.3 协议结论 前端协议采用“业务事件流 + 内嵌 A2UI block”的混合模型,不切换到纯 A2UI 顶层协议。 @@ -172,7 +186,7 @@ Redis 不是业务消息真相源;消息历史仍以 MySQL 为准。 1. 问候语、寒暄、感谢和无业务目标的短消息,只展示最终正文。 2. 工具执行记录只作为 `工具意图` 内的折叠执行记录存在,不再作为独立可见模块。 3. 等待用户块只负责说明暂停点;真正可点击的确认按钮仍固定放在消息列表下方、输入框上方的独立操作条。 -4. 用户在等待期间输入新消息时,以新消息轮次为最高优先级;旧等待态保留为历史,但不再继续假设后续结果。 +4. 第三阶段第一批实现中,用户在等待期间输入新消息时默认直接拒绝为 `busy`;旧等待态保留为历史,但不做抢占恢复。 ### 5.4 SSE 事件 @@ -276,20 +290,114 @@ AI Service 必须承担以下职责: ### 7.3 Tool 约束 -V1 固定 4 类 Tool: +V1 产品能力固定为 4 类: + +1. 我的任务汇报。 +2. 指定范围任务汇总。 +3. 用户进度分析。 +4. 正式项目文档问答。 + +当前代码层面的 Eino Tool 收口为 3 个正式工具: -1. `get_my_task_report` -2. `get_scoped_task_report` -3. `get_user_progress_insight` -4. `search_project_docs` +1. `get_task_snapshot` + 负责读取当前用户可见的最新任务快照,覆盖“我的任务汇报”和“指定范围任务汇总”的基础数据入口。 +2. `get_progress_snapshot` + 负责读取用户最近训练进度、OJ 分数和当前组织信息。 +3. `search_project_docs` + 负责读取正式文档白名单,并且在执行前必须经过用户确认。 约束固定如下: 1. Tool 不直接散落 SQL。 -2. Tool 统一通过现有 Service 或只读 Facade 获取数据。 -3. 文档类 Tool 只能访问正式白名单文档。 +2. 任务和进度类 Tool 通过 `aiRuntimeDataService` 调用 Repository / ReadModel 获取服务端已裁剪的数据。 +3. 文档类 Tool 只能访问 `ai.doc_whitelist` 配置中的正式文档。 4. Tool 输出必须能映射成 `trace_items`,并驱动 `tool_intent_block / waiting_user_block / 最终正文`。 +### 7.4 当前实现链路 + +当前 AI 不是“Controller 直接请求模型”的实现,而是完整的会话编排链路: + +1. `internal/router/system/aiRouter.go` + 注册会话 CRUD、消息列表、流式对话和 interrupt decision 接口。 +2. `internal/controller/system/aiCtrl.go` + 只负责绑定参数、读取当前用户 ID、创建 SSE writer、调用 Service 和统一错误响应。 +3. `internal/service/system/aiSvc.go` + 负责会话归属校验、忙碌状态校验、服务端上下文解析、Plan 生成、消息骨架落库、Runtime 执行和收尾。 +4. `internal/service/system/aiSink.go` 与 `internal/service/system/aiProjector.go` + 把 runtime 事件同时写入 SSE 和数据库消息快照,保证前端实时流与历史消息能使用同一套 `content / trace_items / ui_blocks / scope`。 +5. `internal/repository/system/aiRepo.go` + 负责 `ai_conversations / ai_messages / ai_interrupts` 的 CRUD、行锁和恢复扫描查询。 + +一次 `POST /ai/conversations/{id}/stream` 的主流程固定为: + +1. 校验 `conversation_id` 与路径参数一致,并拒绝 query token。 +2. 校验当前用户拥有该会话,且会话没有其他生成中的轮次。 +3. 读取当前用户、当前组织、可见任务和文档白名单信息,生成服务端上下文。 +4. 调用 `AIRuntime.Plan` 得到本轮计划,判断是否轻量直答、是否需要任务 / 进度 / 文档工具。 +5. 事务化写入用户消息、assistant 消息骨架、会话 `is_generating=true`,必要时创建 `AIInterrupt`。 +6. 调用 `AIRuntime.Execute` 输出事件。 +7. `aiStreamSink` 将每个事件写到 SSE,同时折叠为消息状态并持久化。 +8. Runtime 结束后,Service 将会话切回非生成态;失败时根据流是否已经开始,选择 JSON 错误或 SSE `error / done` 收尾。 + +### 7.5 Runtime 职责 + +`AIRuntime` 是 Service 与具体模型 / Agent 实现之间的抽象缝,固定暴露: + +1. `Plan` + 生成执行计划,当前由 `planAIRuntime` 统一负责。 +2. `Execute` + 按计划执行,并只通过 `AIRuntimeSink` 输出事件。 +3. `SubmitDecision` + 接收用户对 interrupt 的确认或跳过。 +4. `RevokeUser` + 撤销指定用户当前等待中的本地会话。 +5. `NodeID` + 返回当前 runtime 所属节点,用于跨节点命令路由。 + +当前有两套实现: + +1. `LocalAIRuntime` + 作为 mock / test / fallback 使用。它不调用真实模型,按 plan 直接发出结构化块、工具事件、等待确认事件和最终正文,适合本地调试和 Eino 不可用时降级。 +2. `EinoAIRuntime` + 作为正式运行时。非 lightweight 请求默认走 Eino Runner;它创建 Agent、绑定 ChatModel、注册 Tool、启用 CheckPointStore,并通过 ApprovalMiddleware 在 `search_project_docs` 执行前触发 interrupt。 + +`EinoAIRuntime` 当前模型工厂支持: + +1. `qwen` + 默认 provider,默认 BaseURL 为 DashScope compatible-mode。 +2. `openai` +3. `ark` + +如果配置缺失、Redis 不可用、模型初始化失败或 runtime mode 非 `eino`,系统会回退到 `LocalAIRuntime`。 + +### 7.6 Interrupt / Resume 与控制面 + +文档工具是当前唯一必须人工确认的工具。确认链路如下: + +1. Plan 判断需要 `search_project_docs` 时,Service 预创建 `AIInterrupt`。 +2. Eino 的 `ApprovalMiddleware` 在工具执行前抛出 stateful interrupt。 +3. Runtime 将 `checkpoint_id / resume_target_id / tool_name` 写入 `RuntimeStateJSON`,并写入 Redis envelope。 +4. 原 SSE 流输出 `tool_call_waiting_confirmation`,同时保持心跳等待用户。 +5. 前端调用 `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` 提交 `confirm` 或 `skip`。 +6. Service 行锁更新 interrupt 决策,并把命令投递给本节点 runtime 或 Redis command bus 指定的 owner 节点。 +7. Runtime 在原流内 resume;如果原 owner 丢失,recovery loop 会基于 DB interrupt 和 Redis envelope 尝试恢复或停止该轮。 + +控制面由两类 Redis 数据支撑: + +1. CheckPointStore + 保存 Eino Runner 的 checkpoint,用于 resume。 +2. Runtime envelope / command bus + 保存 interrupt owner、租约和跨节点控制命令,用于多节点场景下的决策路由和恢复。 + +业务真相仍在 MySQL: + +1. `AIConversation` + 记录会话归属、标题、预览、生成态和最后消息时间。 +2. `AIMessage` + 记录用户消息和 assistant 消息,assistant 消息额外保存 `trace_items_json / ui_blocks_json / scope_json / error_text`。 +3. `AIInterrupt` + 记录确认状态、决策、原因、运行时恢复信息和 owner 节点。 + ## 8. OpenAPI / Apifox 结论 Apifox 契约以两份文件同步维护: @@ -334,11 +442,39 @@ Apifox 契约以两份文件同步维护: ## 10. 后续扩展边界 -V2 可以考虑: +后续扩展必须建立在 V1 四类可见内容、单流恢复和业务闭环稳定之后,不提前抢跑。 + +### 10.1 业务能力扩展 + +1. 任务能力可以从“最新任务快照”扩展到指定任务、指定组织、指定成员和时间窗口汇总。 +2. 进度能力可以从近 7 天 OJ 统计扩展到训练曲线、薄弱知识点、题目推荐和组织排名分析。 +3. 文档能力可以从白名单文件检索扩展到版本化知识库、文档引用定位、变更摘要和接口说明问答。 +4. 后续如果接入日程、图片、通知或组织协作模块,应先定义新的业务 Tool,不应让模型绕过 Service 直接访问底层资源。 + +### 10.2 Runtime 扩展 + +1. `AIRuntime` 抽象已经允许替换运行时,后续可以接入更复杂的 Eino Workflow、Graph 或 Multi-Agent。 +2. 现有 `Plan` 仍是规则化意图识别,后续可以演进为模型辅助规划,但 Service 必须继续保留权限、工具白名单和最终执行边界。 +3. 当前文档工具有人工确认,后续可按风险等级扩展到更多 Tool,例如跨组织查询、批量变更、发送通知等。 +4. `LocalAIRuntime` 应继续保留为测试和降级路径,避免正式模型不可用时整个 AI 子域不可验证。 + +### 10.3 恢复与多节点能力 + +1. 当前已经具备 checkpoint、owner lease、command bus 和 recovery loop 的基础结构。 +2. 后续可以补强断流后重新附着到同一轮运行的能力,但需要先明确前端重连协议、事件回放范围和消息幂等规则。 +3. 多节点部署下应继续以 DB interrupt 为业务真相,以 Redis envelope 作为运行控制面状态,不反向依赖 Redis 保存业务消息。 +4. Recovery 继续只处理可证明安全的状态;无法确认恢复目标时应停止该轮,而不是盲目重跑工具。 + +### 10.4 知识库与检索扩展 + +1. 当前 `search_project_docs` 是白名单文件分段检索,适合项目文档问答的第一阶段。 +2. 后续可引入 embedding、增量索引、权限标签和引用片段去重。 +3. 检索结果必须保留来源路径、标题和摘要,最终回答不能只给模型生成内容而丢失可追溯依据。 +4. 文档索引的构建、刷新和健康检查应放在基础设施或任务层,业务 Service 只消费封装后的检索能力。 -1. 更完整的知识库索引与检索。 -2. 更复杂的 Workflow / Multi-Agent。 -3. 断流后附着到同一轮运行的恢复能力。 -4. 更通用的 A2UI 协议抽象。 +### 10.5 协议与前端体验扩展 -这些都建立在 V1 四类可见内容、单流恢复和业务闭环稳定之后,不提前抢跑。 +1. A2UI 可以从当前 `Text / Row / Column / Card / Badge / BulletList` 子集逐步扩展,但顶层仍保持业务 SSE 事件协议。 +2. 可新增更丰富的 trace 展开、工具结果对比、引用来源跳转和等待态操作条。 +3. 新增可见块前必须先判断是否属于四类内容;如果只是工具执行细节,应优先折叠在 `trace_items` 或现有 block 内。 +4. 所有扩展都必须保证 `content` 仍是唯一正式最终答案正文,历史消息仍能从数据库快照完整恢复。 diff --git "a/docs/AI/AI\346\236\266\346\236\204\351\242\204\350\247\210.md" "b/docs/AI/AI\346\236\266\346\236\204\351\242\204\350\247\210.md" new file mode 100644 index 0000000..3ef0041 --- /dev/null +++ "b/docs/AI/AI\346\236\266\346\236\204\351\242\204\350\247\210.md" @@ -0,0 +1,382 @@ +# AI 架构概览 + +本文只分析 `personal_assistant` 项目中的 AI 子域,不覆盖整个系统。内容基于当前仓库代码整理,重点说明 Router、Controller、Service、Runtime、Sink、SSE、DB 落库、工具调用、用户确认和中断恢复之间的关系。 + +## 1. AI 架构总览 + +AI 模块采用清晰的分层链路: + +1. Router:注册 `/ai/conversations` 相关路由。 +2. Controller:绑定参数、读取当前用户、创建 SSE writer、调用 Service。 +3. Service:校验会话归属、生成 plan、创建消息骨架、调用 runtime、收尾状态。 +4. Runtime:负责 plan / execute / interrupt / resume / decision。 +5. Sink:把 runtime 事件同时写入 SSE 和 DB 消息快照。 +6. Repository:持久化 conversation、message、interrupt。 +7. Infrastructure:封装 Eino Agent、模型、工具、审批中断、checkpoint、Redis 控制面。 + +核心设计点是:runtime 不直接操作 HTTP 和 DB;它只向 `AIRuntimeSink` 发事件。`aiStreamSink` 再把事件同步到 SSE 和数据库,因此前端实时看到的内容与历史消息恢复使用同一套数据模型。 + +当前 AI 子域的主要真相源是: + +1. MySQL: + - `ai_conversations` + - `ai_messages` + - `ai_interrupts` +2. Redis: + - Eino checkpoint + - runtime envelope + - runtime command bus + - recovery lock + +MySQL 是业务消息和 interrupt 的真相源;Redis 是运行控制面,不承担业务消息真相。 + +## 2. 核心链路流程 + +### 2.1 流式对话主链路 + +一次流式对话从 `POST /ai/conversations/{id}/stream` 进入: + +1. `AIRouter.InitAISSERouter` 注册流式路由。 +2. `AICtrl.StreamConversation` 拒绝 query token,绑定 `StreamAssistantMessageReq`,创建 HTTP SSE writer。 +3. Controller 调用 `AIService.StreamConversation(ctx, userID, conversationID, req, writer)`。 +4. Service 校验 `conversation_id` 与路径一致,检查会话归属和 `IsGenerating`。 +5. Service 读取用户和服务端上下文,调用 `resolveRuntimeContext` 生成 `AIResolvedContext`。 +6. Service 调用 `runtime.Plan`,由 `planAIRuntime` 判断 lightweight、任务、进度、文档工具。 +7. Service 创建 user message、assistant message,必要时创建 `AIInterrupt`。 +8. `persistStreamStart` 在事务中锁定会话,写入消息骨架并把会话标记为生成中。 +9. Service 创建 `aiStreamSink`,调用 `runtime.Execute`。 +10. Runtime 发出 `conversation_started`、`structured_block`、`tool_call_*`、`assistant_token`、`message_completed`、`done` 等事件。 +11. `aiStreamSink.Emit` 先写 SSE,再调用 `aiMessageProjector.applyEvent` 更新内存态,最后持久化到 DB。 +12. `finishStream` 把会话切回非生成态;如果失败且 SSE 已开始,则通过 SSE 发送错误终态。 + +### 2.2 文档工具确认链路 + +文档工具 `search_project_docs` 需要用户确认: + +1. Plan 命中文档意图时生成 `DocTool`,Service 创建 `AIInterrupt`。 +2. `EinoAIRuntime.Execute` 运行 Eino Runner。 +3. `ApprovalMiddleware` 在执行 `search_project_docs` 前抛出 stateful interrupt。 +4. `EinoAIRuntime.handleInterrupt` 写入 runtime state,注册等待通道,写 Redis envelope,向前端发送等待确认事件。 +5. 前端调用 `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision`。 +6. `AIService.SubmitDecision` 行锁更新 interrupt,向本地 runtime 或 Redis command bus 投递决策。 +7. Runtime 收到 `confirm / skip` 后 resume 原 Runner,并继续通过同一个 sink 输出后续事件。 +8. 如果 owner 节点丢失,control plane 的 recovery loop 尝试恢复;无法安全恢复时停止该轮。 + +### 2.3 消息落库与流式输出的关系 + +Runtime 只产生事件,不直接写数据库。每个事件都会进入 `aiStreamSink.Emit`: + +1. 事件 payload 被 JSON 编码。 +2. 事件写入 SSE。 +3. `aiMessageProjector.applyEvent` 把事件折叠成消息快照。 +4. `aiMessageProjector.persistMessage` 更新 `AIMessage` 和必要的 `AIInterrupt`。 + +这意味着: + +1. `assistant_token` 会追加到消息正文。 +2. `tool_call_started / tool_call_finished` 会更新 `trace_items`。 +3. `structured_block` 会更新 `ui_blocks` 或 `scope`。 +4. `tool_call_waiting_confirmation` 会让消息进入等待态,并记录 interrupt。 +5. `message_completed` 会把 assistant 消息标记为成功。 +6. `error` 会记录 `error_text` 并清理等待态。 + +## 3. 关键目录与文件说明 + +### 3.1 核心骨架文件 + +- `internal/router/system/aiRouter.go` + 注册 AI 会话路由。普通 JSON 接口和 SSE stream 接口分开初始化。 + +- `internal/controller/system/aiCtrl.go` + AI HTTP 入口。Controller 只做参数绑定、JWT 用户读取、SSE writer 创建、错误响应,不写业务逻辑。 + +- `internal/service/system/aiSvc.go` + AI 业务编排核心。负责会话 CRUD、流式会话执行、interrupt 决策、撤销用户运行中会话、状态收尾。 + +- `internal/service/system/aiRuntime.go` + Runtime 抽象定义。核心接口是 `AIRuntime` 与 `AIRuntimeSink`,核心数据是 `AIRuntimePlan`、`AIRuntimeExecutionInput`、`AIRuntimeDecisionCommand`。 + +- `internal/service/system/aiRuntimeEino.go` + 正式 Eino runtime。负责构建 Runner、执行 Agent、消费 Eino iterator、处理 interrupt、resume checkpoint。 + +- `internal/service/system/aiRuntimeLocal.go` + 本地 fallback runtime。用于测试、mock、Eino 初始化失败降级;它不调用真实模型,按 plan 模拟输出事件。 + +- `internal/service/system/aiSink.go` + Runtime 到 SSE / DB 的桥。`Emit` 负责写 SSE、折叠事件、持久化消息。 + +- `internal/service/system/aiProjector.go` + 消息投影器。把 runtime 事件转换成 `AIMessage.Content / TraceItemsJSON / UIBlocksJSON / ScopeJSON / ErrorText` 和 interrupt 状态。 + +- `internal/repository/interfaces/aiRepository.go` + AI 仓储接口,定义会话、消息、interrupt 的持久化能力。 + +- `internal/repository/system/aiRepo.go` + GORM 实现,包含会话行锁、interrupt 行锁、恢复扫描查询和级联删除。 + +- `internal/model/entity/ai.go` + 三张业务真相表:`AIConversation`、`AIMessage`、`AIInterrupt`。 + +### 3.2 辅助实现文件 + +- `internal/service/system/aiPlanner.go` + 共享 planner,Eino 和 Local runtime 都复用它生成 plan。 + +- `internal/service/system/aiIntent.go` + 轻量意图和业务意图识别。 + +- `internal/service/system/aiMapper.go` + ID、标题、预览、DTO 映射、A2UI block、trace item 等辅助构造。 + +- `internal/service/system/aiContext.go` + 为 runtime 提供服务端上下文、任务快照、训练进度快照。 + +- `internal/service/system/aiRuntimeFactory.go` + 根据配置选择 `EinoAIRuntime` 或 `LocalAIRuntime`。 + +- `internal/service/system/aiControlPlane*.go` + Redis command bus、envelope、recovery loop、后台 resume。 + +- `internal/infrastructure/ai/eino/agent_factory.go` + 创建 ChatModel、Eino Agent、Runner。 + +- `internal/infrastructure/ai/eino/approval_middleware.go` + 在指定 tool 执行前触发 Eino stateful interrupt。 + +- `internal/infrastructure/ai/eino/docs_tool.go` + 文档白名单检索工具。 + +- `internal/infrastructure/ai/eino/task_progress_tools.go` + `get_task_snapshot` 与 `get_progress_snapshot` 工具封装。 + +- `internal/infrastructure/ai/runtimecontrol/*.go` + Redis envelope store 和 command bus 的基础设施实现。 + +## 4. 关键函数说明 + +### 4.1 入口与 Controller + +- `AIRouter.InitAIRouter` + 注册会话创建、列表、消息列表、删除、decision 接口。上游是总路由组,下游是 `AICtrl`。 + +- `AIRouter.InitAISSERouter` + 注册 `POST :id/stream`。这是流式对话入口。 + +- `AICtrl.CreateConversation` + 输入是 Gin context。绑定 `CreateAssistantConversationReq`,读取 `jwt.GetUserID(c)`,调用 `AIService.CreateConversation`。 + +- `AICtrl.StreamConversation` + 输入是 Gin context。绑定 `StreamAssistantMessageReq`,创建 `streamsse.NewHTTPStreamWriter`,调用 Service。失败时如果流未开始,返回 JSON BizError;流已开始则不再回写 JSON。 + +- `AICtrl.SubmitDecision` + 输入是 Gin context。绑定 `SubmitAssistantDecisionReq`,调用 `AIService.SubmitDecision`,返回 decision accepted 响应。 + +### 4.2 Service 编排 + +- `NewAIService` + 组装 repo、runtime、SSE policy、control plane。内部通过 `newConfiguredAIRuntimeWithControlPlane` 选择 runtime。 + +- `AIService.CreateConversation` + 创建会话。读取用户当前组织,把会话写入 `ai_conversations`,返回会话 DTO。 + +- `AIService.ListMessages` + 校验会话归属后读取消息列表,并把实体转换为响应 DTO。 + +- `AIService.StreamConversation` + 流式对话主编排。输入用户 ID、会话 ID、请求 DTO、SSE writer;输出 error。它负责校验、plan、消息骨架、interrupt、事务落库、runtime execute、finish。 + +- `AIService.persistStreamStart` + 在事务中锁定 conversation,避免并发生成;写入 user message、assistant message、interrupt,并把会话标记为 `IsGenerating=true`。 + +- `AIService.finishStream` + 统一收尾。成功则结束;取消/超时标记 stopped;其他错误写 error 事件和 done 事件。 + +- `AIService.SubmitDecision` + 决策入口。行锁读取 interrupt,校验状态,写入 decision,再投递给本地 runtime 或远程 owner 节点。 + +- `AIService.RevokeUserSessions` + 撤销某个用户等待中的 AI 会话。本地 owner 直接调用 runtime,远程 owner 走 command bus。 + +### 4.3 Runtime 抽象与实现 + +- `AIRuntime.Plan` + 输入 `AIRuntimePlanInput`,输出 `AIRuntimePlan`。当前 Eino 和 Local 都调用 `planAIRuntime`。 + +- `AIRuntime.Execute` + 输入 `AIRuntimeExecutionInput` 和 sink。runtime 只负责发事件,不直接写 HTTP / DB。 + +- `AIRuntime.SubmitDecision` + 把用户决策投递到当前 runtime 的 session registry。 + +- `planAIRuntime` + 解析用户输入,判断 lightweight、任务快照、进度快照、文档支持,生成工具蓝图和最终回答分支。 + +- `LocalAIRuntime.Execute` + 按 plan 模拟完整事件流。适合 fallback 和测试。 + +- `LocalAIRuntime.waitDecision` + 等待用户确认,同时按心跳间隔调用 `sink.Heartbeat` 保持 SSE 连接。 + +- `EinoAIRuntime.Execute` + 正式执行路径。lightweight 直接降级给 Local;非 lightweight 构建 Eino Runner,运行 Agent,消费 iterator,处理工具事件与最终正文。 + +- `EinoAIRuntime.handleInterrupt` + Eino 文档工具审批中断处理。注册等待通道,写 envelope,发送等待确认事件,收到 decision 后调用 Runner resume。 + +- `EinoAIRuntime.ResumeInterrupted` + 后台恢复入口。根据 interrupt 中的 checkpoint 和 resume target 恢复 Eino Runner,用 persist-only sink 把结果落库。 + +### 4.4 Sink 与持久化 + +- `newAIStreamSink` + 创建流式 sink,绑定 SSE writer、AI repo、assistant message 和 interrupt。 + +- `aiStreamSink.Emit` + 输入事件名和 payload。先 JSON 编码并写 SSE,再调用 projector 更新消息快照,最后持久化。 + +- `aiStreamSink.Heartbeat` + 在等待 decision 时发送 keepalive,不改变 DB 状态。 + +- `newAIMessageProjector` + 从已有 message JSON 字段恢复投影器内存态,供流式执行或恢复执行继续折叠事件。 + +- `aiMessageProjector.applyEvent` + 事件折叠核心。处理 token、工具开始/结束、等待确认、确认结果、结构化块、完成、错误。 + +- `aiMessageProjector.persistMessage` + 把投影后的 message 和 interrupt 写回 DB,保持历史消息可恢复。 + +- `newAIPersistOnlySink` + 后台 recovery 使用的 sink。它不写 SSE,只把恢复后的事件投影到 DB。 + +### 4.5 Eino 基础设施 + +- `NewChatModel` + 根据 provider 创建 Qwen / OpenAI / Ark 模型。默认 provider 是 qwen,DashScope compatible-mode 是默认 base URL。 + +- `NewRuntimeAgent` + 创建 Eino deep Agent,注册工具和 `ApprovalMiddleware`。 + +- `NewRunner` + 创建 Eino Runner,并接入 CheckPointStore。 + +- `NewApprovalMiddleware` + 包装指定 tool。第一次调用时抛 interrupt;resume 后根据 ApprovalResult 决定执行工具或返回跳过结果。 + +- `NewSearchProjectDocsTool` + 把文档白名单检索包装为 Eino invokable tool。 + +- `NewTaskSnapshotTool / NewProgressSnapshotTool` + 把 Service 提供的数据读取函数包装为 Eino tool。 + +### 4.6 控制面与恢复 + +- `AIService.StartControlPlane` + 启动 command loop 和 recovery loop。 + +- `AIService.handleRuntimeCommand` + 处理跨节点 command bus 命令,当前支持提交 decision 和撤销用户会话。 + +- `AIService.recoverStaleInterruptsOnce` + 扫描长时间未更新的 awaiting / decision interrupt。 + +- `AIService.recoverInterruptCandidate` + 根据 envelope、lease 和 runtime state 判断恢复或停止。 + +- `AIService.resumeRecoveredInterrupt` + 加载恢复上下文,调用 runtime 的 `ResumeInterrupted`,使用 persist-only sink 持久化恢复结果。 + +## 5. Mermaid 图 + +### 5.1 总体分层图 + +```mermaid +flowchart TB + Client[前端 Assistant UI] --> Router[AI Router] + Router --> Ctrl[AICtrl] + Ctrl --> Service[AIService] + Service --> Runtime[AIRuntime] + Runtime --> Sink[AIRuntimeSink] + + Sink --> SSE[SSE StreamWriter] + Sink --> Projector[aiMessageProjector] + Projector --> Repo[AIRepository] + Repo --> DB[(MySQL)] + + Runtime --> Eino[EinoAIRuntime] + Runtime --> Local[LocalAIRuntime] + Eino --> Agent[Eino Agent / Runner] + Agent --> Tools[Task / Progress / Docs Tools] + Eino --> Redis[(Redis Checkpoint / Envelope / Command Bus)] +``` + +### 5.2 流式请求链路图 + +```mermaid +sequenceDiagram + participant U as User + participant C as AICtrl + participant S as AIService + participant R as AIRuntime + participant K as aiStreamSink + participant W as SSE Writer + participant DB as MySQL + + U->>C: POST /ai/conversations/{id}/stream + C->>C: bind request + create SSE writer + C->>S: StreamConversation(ctx,userID,id,req,writer) + S->>DB: load conversation + user + S->>R: Plan(input) + R-->>S: AIRuntimePlan + S->>DB: transaction: conversation generating + messages + interrupt + S->>R: Execute(input,sink) + R->>K: Emit(conversation_started / tool / token / done) + K->>W: WriteEvent + K->>DB: persist projected message + R-->>S: exec result + S->>DB: finish conversation generating=false +``` + +### 5.3 Interrupt / Decision / Resume 图 + +```mermaid +sequenceDiagram + participant U as User + participant R as EinoAIRuntime + participant A as ApprovalMiddleware + participant K as aiStreamSink + participant S as AIService + participant Redis as Redis + participant DB as MySQL + + R->>A: call search_project_docs + A-->>R: StatefulInterrupt + R->>Redis: upsert envelope + renew lease + R->>K: tool_call_waiting_confirmation + K->>DB: message idle + interrupt awaiting + U->>S: POST decision(confirm/skip) + S->>DB: lock interrupt + save decision + S->>R: SubmitDecision or command bus + R->>A: ResumeWithParams + A-->>R: execute tool or skip + R->>K: confirmation_result + tool result + final answer + K->>DB: persist completed message / interrupt +``` + +## 6. 面试时如何介绍这套 AI 架构 + +可以这样讲: + +这套 AI 不是简单的模型代理,而是一个业务内嵌的可恢复流式 Agent 子系统。入口在 Gin Router 和 Controller,Controller 只处理 HTTP、鉴权上下文和 SSE writer。真正业务编排在 `AIService`:它先校验会话归属和并发状态,再生成运行计划,事务化创建用户消息、assistant 消息和必要的 interrupt,然后把执行交给 runtime。 + +Runtime 通过 `AIRuntime` 抽象隔离,正式实现是 `EinoAIRuntime`,本地降级是 `LocalAIRuntime`。Eino 负责 Agent、Tool、Checkpoint 和 Interrupt / Resume;Local 用于测试和 fallback。runtime 不直接写 HTTP 或数据库,而是只向 `AIRuntimeSink` 发事件。`aiStreamSink` 是系统里的关键桥接层,它把同一个事件同时写到 SSE 和数据库投影,所以实时输出和历史消息恢复是一致的。 + +人工确认通过 `AIInterrupt`、Eino `ApprovalMiddleware`、Redis checkpoint / envelope 和 decision 接口完成。文档工具执行前会中断,前端提交 confirm / skip 后,系统在原运行上下文里 resume;如果节点丢失,control plane 会尝试恢复或安全停止。这让 AI 对话既能流式输出,又能被持久化、审计和恢复。 + +## 待确认 + +当前文档按代码现状整理。以下内容如果后续实现变化,需要同步修订: + +1. `planAIRuntime` 当前仍是规则化意图识别,未来如果改成模型辅助规划,本文的 planner 说明需要更新。 +2. 当前必须人工确认的工具是 `search_project_docs`,如果更多工具引入确认流程,interrupt 章节需要扩展。 +3. 当前 recovery 只能在可证明安全的状态恢复,否则停止该轮;如果未来支持前端断线重连并重新附着原流,需要补充重连协议。 diff --git "a/docs/AI/AI\351\241\271\347\233\256\346\274\224\350\277\233\350\257\264\346\230\216.md" "b/docs/AI/AI\351\241\271\347\233\256\346\274\224\350\277\233\350\257\264\346\230\216.md" new file mode 100644 index 0000000..21d5913 --- /dev/null +++ "b/docs/AI/AI\351\241\271\347\233\256\346\274\224\350\277\233\350\257\264\346\230\216.md" @@ -0,0 +1,479 @@ +你可以把这套 AI 架构理解成:**它不是一开始就设计得这么复杂,而是被需求一步步“逼”出来的。** + +## 1. 最初:普通 API 问答 + +最早的形态可以很简单: + +```text +用户发问题 +-> Controller 接收 +-> Service 调用大模型 API +-> 返回字符串 +``` + +这时候 MVC 完全够用。 + +对应心智是: + +```text +AICtrl -> AIService -> LLM API -> response +``` + +问题也很明显: + +- 模型响应慢,用户只能等。 +- 没有流式体验。 +- 中间过程不可见。 +- 没有工具调用。 +- 没有消息状态。 +- 没有中断、恢复、确认。 + +所以这个阶段只需要 `Controller + Service`。 + +--- + +## 2. 后来:为了体验,引入 SSE + +模型回答可能很长,所以你引入 SSE。 + +链路变成: + +```text +用户发问题 +-> Controller 创建 SSE writer +-> Service 调用模型 +-> 一边生成一边推给前端 +``` + +这时就多了一个问题: + +> 模型输出不再是一次性字符串,而是一串事件。 + +所以你需要一个事件出口,也就是现在的: + +```text +AIRuntimeSink +aiStreamSink +``` + +它的出发点是: + +> Runtime 不应该直接碰 HTTP ResponseWriter,而是只发事件。 + +对应现在的代码: + +- [aiCtrl.go](d:/workspace_go/test/go/personal_assistant/internal/controller/system/aiCtrl.go) +- [aiSink.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiSink.go) + +--- + +## 3. 再后来:为了历史消息,需要落库 + +SSE 解决了实时体验,但还不够。 + +你还需要: + +- 会话列表 +- 历史消息 +- 生成状态 +- 错误状态 +- 工具轨迹 +- 等待确认状态 + +所以就有了: + +```text +AIConversation +AIMessage +AIInterrupt +``` + +对应职责: + +```text +AIConversation 记录会话 +AIMessage 记录用户消息和 assistant 消息 +AIInterrupt 记录等待确认 / 恢复信息 +``` + +这时 `aiStreamSink` 不能只写 SSE,它还要同步写 DB。 + +于是出现了: + +```text +runtime event +-> aiStreamSink + -> SSE + -> aiMessageProjector + -> AIRepository + -> DB +``` + +`aiProjector` 的出发点是: + +> 把运行时事件折叠成数据库里的消息快照。 + +例如: + +```text +assistant_token -> 追加 Content +tool_call_started -> 更新 trace_items +structured_block -> 更新 ui_blocks +message_completed -> 标记 success +error -> 标记 error +``` + +对应文件: + +- [aiProjector.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiProjector.go) +- [aiRepo.go](d:/workspace_go/test/go/personal_assistant/internal/repository/system/aiRepo.go) +- [ai.go](d:/workspace_go/test/go/personal_assistant/internal/model/entity/ai.go) + +--- + +## 4. 再后来:为了让 Agent 查业务数据,引入 Tool + +普通 LLM 只能回答它知道的东西。 +但你的系统里有真实业务数据: + +- 当前用户 +- 当前组织 +- OJ 任务 +- 训练进度 +- 项目文档 + +所以你引入工具: + +```text +get_task_snapshot +get_progress_snapshot +search_project_docs +``` + +这时模型不是单纯生成文本,而是: + +```text +读用户问题 +-> 判断需要哪些工具 +-> 调工具拿业务数据 +-> 再生成回答 +``` + +所以你需要 `aiContext.go`: + +```text +把系统里的真实业务数据整理成 AI 可用上下文 +``` + +比如: + +- `ResolveContext` +- `TaskSnapshot` +- `ProgressSnapshot` + +对应文件: + +- [aiContext.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go) +- [task_progress_tools.go](d:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/task_progress_tools.go) +- [docs_tool.go](d:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/docs_tool.go) + +--- + +## 5. 再后来:为了控制模型乱调用,引入 Plan + +如果完全让大模型自由决定工具,它可能: + +- 明明不该查文档却查文档 +- 重复调用工具 +- 越过业务边界 +- 调用不存在的工具 +- 在不需要复杂流程时也走重链路 + +所以你加了 `plan`。 + +`plan` 的出发点是: + +> 在真正执行前,先由业务侧决定本轮允许做什么。 + +现在链路是: + +```text +用户问题 +-> planAIRuntime +-> AIRuntimePlan +-> Runtime 按 plan 执行 +``` + +`AIRuntimePlan` 里会表达: + +```text +是否 lightweight +是否需要 task tool +是否需要 progress tool +是否需要 docs tool +是否需要展示 thinking summary +最终回答 confirm / skip 分支 +``` + +对应文件: + +- [aiPlanner.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiPlanner.go) +- [aiIntent.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiIntent.go) +- [aiRuntime.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiRuntime.go) + +--- + +## 6. 再后来:为了安全控制,引入 Interrupt / Decision / Resume + +文档工具、未来的记忆工具、跨组织查询、发送通知、批量变更,这些都不能让模型无限制调用。 + +所以出现了: + +```text +interrupt +decision +resume +``` + +它的出发点是: + +> 模型准备做一个有风险或需要确认的动作时,先暂停,等用户确认后再继续。 + +现在文档工具的链路是: + +```text +Eino 准备调用 search_project_docs +-> ApprovalMiddleware 打断 +-> Runtime 发送 tool_call_waiting_confirmation +-> 用户 confirm / skip +-> SubmitDecision +-> Runtime Resume +-> 继续生成结果 +``` + +这就是为什么你现在需要: + +```text +AIInterrupt +RuntimeStateJSON +checkpoint_id +resume_target_id +decision +reason +owner_node_id +``` + +对应文件: + +- [approval_middleware.go](d:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/approval_middleware.go) +- [aiControlPlane.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiControlPlane.go) +- [aiControlPlane_runtime.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiControlPlane_runtime.go) + +--- + +## 7. 再后来:为了替换实现,引入 Runtime 抽象 + +一开始你可能只接一个模型 API。 +后来你接了 Eino。 +未来你还可能接: + +- LLM Gateway +- mem0 +- LangGraph 类框架 +- 自研 Agent Runner +- 多模型路由 + +所以你不能让 `AIService` 直接依赖某个具体框架。 + +于是有了: + +```go +AIRuntime +``` + +它的出发点是: + +> AIService 不关心底层用 Eino、Local、网关还是其他 Agent 框架,只关心这轮对话能不能 Plan、Execute、SubmitDecision。 + +当前有两个实现: + +```text +LocalAIRuntime 本地模拟 / fallback / test +EinoAIRuntime 正式 Eino 执行引擎 +``` + +对应文件: + +- [aiRuntime.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiRuntime.go) +- [aiRuntimeLocal.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiRuntimeLocal.go) +- [aiRuntimeEino.go](d:/workspace_go/test/go/personal_assistant/internal/service/system/aiRuntimeEino.go) + +--- + +## 8. 再后来:为了多节点和恢复,引入 Runtime Control Plane + +如果只有单机,一次 SSE 等待用户确认还好。 +但如果未来多节点部署,就会出现问题: + +```text +用户的 stream 在 A 节点 +decision 请求打到 B 节点 +``` + +这时 B 节点怎么把决策送回 A 节点? + +所以你有了: + +```text +runtime command bus +runtime envelope +owner lease +recovery loop +``` + +它的出发点是: + +> 记录当前 interrupt 属于哪个 runtime 节点,并在 owner 丢失时恢复或安全停止。 + +对应组件: + +```text +RedisCommandBus +RedisEnvelopeStore +RecoveryLock +``` + +对应文件: + +- [redis_command_bus.go](d:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/runtimecontrol/redis_command_bus.go) +- [redis_envelope_store.go](d:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/runtimecontrol/redis_envelope_store.go) + +--- + +## 各包的出发点 + +```text +controller/system +``` + +出发点:HTTP 入口。 +只负责绑定参数、拿用户 ID、创建 SSE writer、返回响应。 + +```text +service/system +``` + +出发点:业务用例编排。 +决定这个用户能不能跑、怎么落库、什么时候调用 runtime、怎么收尾。 + +```text +service/system/aiRuntime.go +``` + +出发点:抽象 AI 执行引擎。 +隔离 Service 和具体 Agent 框架。 + +```text +service/system/aiSink.go +``` + +出发点:隔离 runtime 和输出通道。 +Runtime 只发事件,Sink 决定写 SSE 和 DB。 + +```text +service/system/aiProjector.go +``` + +出发点:把事件变成可恢复的消息状态。 +保证实时流和历史消息一致。 + +```text +service/system/aiPlanner.go +``` + +出发点:限制和规划工具调用。 +不要让大模型无限制自由决定流程。 + +```text +service/system/aiContext.go +``` + +出发点:给 Agent 提供真实业务上下文。 +模型不能直接查 DB,只能通过业务裁剪后的上下文和工具。 + +```text +infrastructure/ai/eino +``` + +出发点:技术实现适配。 +这里是 Eino、Agent、Tool、Checkpoint、ApprovalMiddleware。 + +```text +infrastructure/ai/runtimecontrol +``` + +出发点:运行控制面。 +解决跨节点 decision 路由、owner lease、recovery。 + +```text +repository +``` + +出发点:数据库访问。 +只负责 conversation、message、interrupt 的 CRUD / lock / query。 + +--- + +## 为什么最后会变成现在这样 + +因为 AI 模块经历了这条演进线: + +```text +同步问答 +-> 流式输出 +-> 会话与消息落库 +-> Agent 工具调用 +-> 工具调用规划 +-> 用户确认 interrupt +-> checkpoint resume +-> 多节点控制面 +-> runtime 抽象 +``` + +所以它自然从普通 MVC 的: + +```text +Controller -> Service -> Repository +``` + +演进成了: + +```text +Controller + -> Service + -> Runtime + -> Sink + -> SSE + -> Projector + -> Repository +``` + +这不是为了炫技,也不是为了强行 DDD。 + +而是因为你的 AI 模块已经有了普通 CRUD 没有的东西: + +- 长连接 +- 流式事件 +- 工具调用 +- 运行计划 +- 用户确认 +- 中断恢复 +- 多节点 owner +- 历史消息投影 +- 可替换 AI 框架 + +所以它需要一个比普通 Service 更清晰的执行边界。 + +一句话: + +**你的 AI 架构是从“调一次模型 API”一步步演进到“可恢复、可控制、可落库、可替换运行时的 Agent 执行系统”。** \ No newline at end of file diff --git "a/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" "b/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" new file mode 100644 index 0000000..cb974f3 --- /dev/null +++ "b/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" @@ -0,0 +1,125 @@ +## 如何将项目从MVC改造至DDD架构 + +如果要设计一个,原生的DDD架构,最起初的时候, + +```go +internal/ + core/ # 继续只放初始化/装配,不放 AI 领域协议 + + ai/ + interfaces/ # 对外入口层 + http/ + controller.go # 从 internal/controller/system/aiCtrl.go 迁移 + router.go # 从 internal/router/system/aiRouter.go 迁移 + mapper.go # HTTP DTO <-> application command/query 转换 + + application/ # 应用用例层 + service.go # 从 aiSvc.go 拆出用例编排 + command.go # 创建会话、流式对话、提交决策等命令 + query.go # 会话列表、消息列表等查询 + planner.go # 从 aiPlanner.go 迁移,先作为应用策略 + context_resolver.go # 从 aiContext.go 迁移 + projector.go # 从 aiProjector.go 迁移 + recovery_service.go # 从 aiControlPlane_runtime.go 中恢复编排迁移 + factory.go # 从 aiRuntimeFactory.go 迁移 + + domain/ # AI 领域核心 + runtime.go # AIRuntime / AIRuntimeRecoverer + sink.go # AIRuntimeSink + plan.go # AIRuntimePlan / ToolBlueprint / ExecutionInput + event.go # runtime event name / event payload 语义 + interrupt.go # interrupt 状态、RuntimeState + decision.go # confirm / skip / DecisionCommand + conversation.go # AIConversation 领域对象,可先轻量封装 + message.go # AIMessage 领域对象,可先轻量封装 + errors.go # AI 子域领域错误语义,可选 + + infrastructure/ # 技术实现层 + runtime/ + local/ + runtime.go # 从 aiRuntimeLocal.go 迁移 + eino/ + runtime.go # 从 aiRuntimeEino.go 迁移 + agent_factory.go # 从 infrastructure/ai/eino/agent_factory.go 迁移 + approval_middleware.go + checkpoint_store.go + docs_tool.go + models.go + task_progress_tools.go + + persistence/ + gorm_repository.go # 从 internal/repository/system/aiRepo.go 迁移 + model.go # 可选:AI 专属 GORM persistence model + + runtimecontrol/ + redis_command_bus.go # 从 infrastructure/ai/runtimecontrol 迁移 + redis_envelope_store.go + redis_recovery_lock.go # 后续可拆 + + sse/ + stream_sink.go # 从 aiSink.go 迁移 + http_writer_adapter.go # 如需隔离 HTTP SSE writer,可新增 + + dto/ # AI 子域接口 DTO,可选 + request.go # 从 internal/model/dto/request/aiReq.go 迁移 + response.go # 从 internal/model/dto/response/aiResp.go 迁移 + +``` + +但其实,**DDD 不要求你必须把目录命名成 interfaces/application/domain/infrastructure。** +DDD 更重要的是依赖方向和职责边界: + +- controller/router 就是对外入口层,不需要改名成 interfaces。 +- service/system 就是应用层,不需要改名成 application。 +- 新增 domain/ai 放 AI 子域稳定协议。 +- infrastructure/ai 放 Eino、Redis control、LLM gateway、mem0 这类技术实现。 + +这样就是“局部 DDD”,但仍然保留项目当前 MVC 项目的整体风格。 + +```go +internal/ + core/ # 初始化/装配,继续不动 + + controller/ + system/ + aiCtrl.go # AI HTTP 入口 + + router/ + system/ + aiRouter.go # AI 路由入口 + + service/ + system/ + aiSvc.go # AI 应用编排层 + aiPlanner.go + aiProjector.go + aiContext.go + aiRuntimeFactory.go + + domain/ + ai/ + runtime.go # AIRuntime 接口 + sink.go # AIRuntimeSink 接口 + plan.go # Plan / ExecutionInput + event.go # Runtime 事件定义 + interrupt.go # Interrupt 状态/协议 + decision.go # 用户决策协议 + + infrastructure/ + ai/ + eino/ # Eino runtime 实现 + runtimecontrol/ # Redis 控制面 / recovery / command bus + + repository/ + interfaces/ + aiRepository.go + system/ + aiRepo.go + +``` +因为在这次的项目中, +并不是为了用 DDD 推翻现有 MVC,而只需要给 AI 子域补一个 domain/ai 稳定核心层,让 Service 和 Infrastructure 都围着它依赖。 + + + + diff --git a/docs/AI/stage3_agent_runtime_assessment.md b/docs/AI/stage3_agent_runtime_assessment.md new file mode 100644 index 0000000..f3f8fac --- /dev/null +++ b/docs/AI/stage3_agent_runtime_assessment.md @@ -0,0 +1,73 @@ +# 项目当前阶段判断 + +- 本结论综合了 4 个并行 `gpt-5.4` 子任务结果:公开资料研究、本地代码审计、下一阶段方案设计、架构与面试包装;并结合本地只读验证 `go test ./internal/service/system -count=1`、`go test ./internal/infrastructure/ai/eino -count=1`。 +- 当前项目已进入“第二阶段正式运行时骨架基本完成,但第三阶段生产级闭环尚未完成”的状态。更准确地说,它已经不是 `LocalAIRuntime` demo,而是以 `EinoAIRuntime + Qwen + 单 SSE 流 + decision 控制` 为核心的 Agent Runtime 骨架。 +- 冲突消解一:关于“runtime 真相是否已迁出 local”。最终判断为“执行真相已迁出,规划与策略真相只部分迁出”。依据是 `EinoAIRuntime` 已是默认正式路径,但 planner 仍以 regex 和硬编码模板为主,且部分共享逻辑仍散落在 `internal/service/system/aiRuntimeLocal.go:347`。 +- 冲突消解二:关于“下一阶段主线应该是多节点恢复,还是先做 eval/observability”。最终判断是“主线做运行控制面闭环,eval/observability 作为同阶段验收与治理支撑”。原因是多节点 owner 路由和 durable resume 直接决定系统是否能在生产里成立,而 eval/observability 决定它是否可持续维护。 +- 冲突消解三:关于“等待确认期间新消息策略”。资料研究更倾向先明确 `reject`,代码现状也是 `busy reject`,而主文档曾写“新消息优先”。最终建议第三阶段先冻结为“显式 `reject while waiting`”,同步修正文档;等 owner 路由和 recovery 做完,再考虑真正的 interrupt 抢占。原因是现在直接支持抢占,会把 partial tool/UI/trace 清理和多节点恢复耦合到一起,风险过高。 + +# 已完成能力 + +- `EinoAIRuntime + Qwen` 已成为默认正式运行时骨架。依据见 `internal/core/config.go:58`、`internal/service/system/aiRuntimeFactory.go:16`、`internal/infrastructure/ai/eino/agent_factory.go:42`。 +- 共享 planner 已从单纯 local runtime 内部逻辑中抽出,`LocalAIRuntime` 与 `EinoAIRuntime` 已复用同一份规划入口。依据见 `internal/service/system/aiPlanner.go:10`、`internal/service/system/aiRuntimeLocal.go:152`、`internal/service/system/aiRuntimeEino.go:161`。 +- 正式上下文已收回服务端推导,前端上传的兼容字段不再是唯一真相。依据见 `internal/service/system/aiContext.go:28`、`internal/service/system/aiContext.go:71`。 +- `get_task_snapshot`、`get_progress_snapshot`、`search_project_docs` 已进入统一 Eino 工具链,task/progress 已不再依赖 local 假结果。依据见 `internal/service/system/aiRuntimeEino.go:107`、`internal/infrastructure/ai/eino/task_progress_tools.go:11`、`internal/infrastructure/ai/eino/docs_tool.go:27`。 +- 单节点原流 interrupt/resume 已闭合,`checkpoint_id / resume_target_id / tool_name / runtime_name` 已形成最小 runtime state。依据见 `internal/service/system/aiSvc.go:297`、`internal/service/system/aiSvc.go:307`、`internal/service/system/aiRuntimeEino.go:199`、`internal/service/system/aiRuntimeEino.go:297`。 +- 外部协议仍然稳定,6 个 API 和 10 个 SSE 事件没有被 Eino/A2UI 改写,且 sink 已具备单调状态合并和 waiting UI 收口能力。依据见 `internal/model/dto/response/aiResp.go:112`、`internal/service/system/aiSink.go:126`、`internal/service/system/aiSink.go:304`。 +- 作为面试项目,它已经“可讲”,而且能讲成“业务协议稳定前提下的 Agent Runtime 迁移工程”,不是简单的“接了个模型 SDK”。 + +# 缺失的关键闭环 + +- 多节点 interrupt owner 路由未完成。`OwnerNodeID` 已入库,但 `SubmitDecision` 仍直接打当前进程 runtime,没有命令路由。依据见 `internal/service/system/aiSvc.go:313`、`internal/service/system/aiSvc.go:360`。 +- durable resume 未完成。checkpoint 已进 Redis,但等待中的 decision 通道仍依赖本进程内存 registry;节点切换或进程重启时闭环会断。依据见 `internal/service/system/aiRuntimeLocal.go:22`、`internal/infrastructure/ai/eino/checkpoint_store.go:13`。 +- “等待确认期间新消息”存在文档与代码冲突。主文档写过“新消息轮次优先”,当前实现则直接 `busy reject`。依据见 `docs/AI助手架构设计方案.md:189`、`internal/service/system/aiSvc.go:245`。 +- 工具执行链虽然统一了,但规划、审批和策略框架还未正式化。当前仍以 regex planner 和 `search_project_docs` 特判审批为主,且部分共享模板仍留在 local runtime 文件中。依据见 `internal/service/system/aiPlanner.go:49`、`internal/infrastructure/ai/eino/approval_middleware.go:17`、`internal/service/system/aiRuntimeLocal.go:314`。 +- 权限、审计、超时、内部错误码、fallback 治理还没有形成正式生产边界。当前 factory 会回退 local runtime,但缺少明确审计和指标。依据见 `internal/service/system/aiRuntimeFactory.go:21`。 +- 回归与可观测性还不够硬。现有测试已覆盖部分 sink 和 runtime path,但 `skip / revoke / history reload / owner lost / checkpoint missing / multi-node` 仍缺系统化回归;history reload 也缺直接测试。 +- 结论上,当前项目最缺的不是“更多工具”,而是 `OwnerNodeID -> command route -> recovery -> policy -> metrics -> regression` 这条生产闭环。 + +# 下一阶段最值得做的事项 + +- 只做一条主线时,最推荐路线是:把 AI 子域从“单节点会话 runtime”升级为“可恢复、可路由、可观测的 runtime control plane”。 +- 第一优先级是多节点 owner 路由。没有它,`decision` 命中非 owner 节点时就会失败或误判 unavailable,当前 `OwnerNodeID` 只是字段,不是机制。 +- 第二优先级是 durable resume。目标不是新增第二条 SSE 流,而是让服务端运行控制面在 owner 节点失联后仍能安全收口或后台恢复。 +- 第三优先级是工具执行边界正式化。task/progress/doc 三类工具应统一补上 `ToolExecutionPolicy`、审计记录、超时包装和内部错误码。 +- 第四优先级是 fallback 治理。`LocalAIRuntime` 可以继续保留,但只能作为开发或显式 fallback;生产不能静默退化。 +- 第五优先级是 eval / regression / observability。先做事件级与 trace 级回归,再做小样本 eval,不要先陷入最终文本质量比较。 +- 如果只允许补 2 到 3 个 feature 来增强面试竞争力,最值钱的是:多节点 owner 路由与 recovery、AI observability + fallback dashboard、事件级 regression harness。 + +# 面试表达建议 + +- 当前项目可以讲,但要讲成“业务协议稳定前提下,把本地占位 runtime 迁移为 Eino 正式运行时骨架,并保住 interrupt/resume、checkpoint、single SSE stream、decision control 的工程项目”。 +- 当前最核心的 3 个亮点是:一,`EinoAIRuntime + Qwen` 已成为正式骨架而不是 demo fallback;二,顶层协议没有被框架示例绑架,仍保持单 SSE 流和独立 decision 控制口;三,task/progress/doc 已被统一进正式工具链,消息状态和 interrupt 状态已具备单调收口能力。 +- 当前最危险的 3 个短板是:一,多节点 owner 路由与 durable resume 未闭合;二,新消息抢占策略未正式定稿且文档与代码不一致;三,缺少生产级审计、fallback 监控和事件级 regression。 +- 面试时不要把它包装成“全功能 Agent 平台”,而应准确表述为“第二阶段正式骨架已经完成,第三阶段准备补运行控制面闭环”。这种表述更真实,也更能体现架构判断。 +- 一版可直接使用的项目介绍话术:`我做的是一个面向业务对话的 Agent Runtime 迁移项目。核心不是接大模型,而是在不改 6 个 API 和 10 个 SSE 事件协议的前提下,把原来的 LocalAIRuntime 迁到 Eino,并把 interrupt/resume、checkpoint、Qwen 模型接入、任务/进度/文档工具链统一起来。现在项目已经完成第二阶段正式骨架,下一阶段重点不是加更多工具,而是补多节点 owner 路由、durable resume、fallback 治理和事件级 regression,让它真正具备生产级运行控制能力。` +- 如果被追问“为什么不直接用 A2UI 或第二条续跑流”,推荐回答:`因为这个项目首先要守住既有业务协议和历史消息模型。Eino 负责 runtime,业务层继续输出稳定 SSE 事件;decision 只做控制输入,不新开第二条续跑流,这样前端和历史回放成本最低,也更符合现有架构边界。` + +# 推荐实施计划 + +第一批已落地范围: + +- `SubmitDecision` 已改成“先持久化,再按 `OwnerNodeID` 路由”,remote decision 不再依赖命中本地 runtime 才算成功。 +- Redis envelope / lease / recovery worker 已接入;后台 durable resume 首批只覆盖 `runtime_name=eino` 且 `tool_name=search_project_docs` 的 interrupt。 +- “等待确认期间新消息”当前阶段已冻结为 `reject while waiting`,避免在 owner 路由和 recovery 未完全稳定前引入抢占语义。 + +1. 先做 `AIRuntimeCommandBus`,让 `SubmitDecision` 和 `RevokeUserSessions` 按 `OwnerNodeID` 路由到真正的 owner 节点;本地 `sessionRegistry` 保留,但只负责本节点等待会话唤醒。 +2. 再做 owner lease 和 runtime envelope,至少在 Redis 中补齐 `interrupt_id / owner_node_id / checkpoint_id / resume_target_id / tool_name / lease_expire_at`,并引入 recovery worker。 +3. 将 durable resume 定义为“服务端运行控制面可恢复”,不是“客户端断线自动续流”;第一版 recovery 只要求两件事:owner 丢失但未收到 decision 时安全收口,owner 丢失但已收到 decision 且 checkpoint 完整时可后台恢复到终态。 +4. 补 `ToolExecutionPolicy`、AI 审计表、统一 timeout wrapper 和内部 machine error code;外部 HTTP/SSE 协议不变,内部治理能力增强。 +5. 为 runtime factory 增加 `ai.allow_local_fallback` 及配套审计、指标、结构化日志;生产默认禁用静默 fallback。 +6. 建最小 regression harness,先验 plan、tool 选择、事件序列、interrupt 状态,而不是先比最终文本;第一批样例至少覆盖 `lightweight / task / progress / doc confirm / doc skip / cancel while waiting / revoke while waiting / history reload / owner lost / checkpoint missing`。 +7. 建 AI observability 最小指标:plan latency、tool latency、interrupt wait duration、decision-to-resume duration、resume success rate、recovery success rate、fallback rate、checkpoint miss rate。 +8. 同步修正文档,把“等待期间新消息”策略在第三阶段先固定为 `reject while waiting`,并说明这是当前阶段的收敛策略,不是永久产品结论。 + +# 风险与注意事项 + +- 不要在第三阶段引入第二条 SSE 续跑流,也不要让前端直接依赖 runtime 原始事件;继续保持业务 SSE 事件作为稳定协议面。 +- 不要把 interrupt 继续当普通错误处理。公开资料和当前代码方向都支持把它当一等控制语义;错误、超时、cancel、interrupt 应分开建模。 +- 不要在 owner 路由和 recovery 未完成前就实现“等待期间新消息抢占”;否则 partial tool 输出、waiting UI、trace 清理会和多节点恢复纠缠在一起。 +- 不要让 Qwen 继续以“假装 OpenAI provider”的语义存在;正式路径应继续是 Eino 原生 `qwen` provider + DashScope compatible endpoint。 +- 需要尽早冻结 checkpoint 相关序列化和 identity key 语义,至少包括 `checkpoint_id / interrupt_id / resume_target_id / owner_node_id / runtime_name`;否则后续线上恢复会被兼容性拖垮。 +- 文档要与代码同步,尤其是“新消息策略”“fallback 语义”“durable resume 边界”三处;这三处如果继续口径不一致,会同时影响开发、测试和面试叙述。 +- 资料依据主要来自官方和主流可靠资料:Eino Runner/HITL/interrupt-resume 重构、Eino Qwen 组件、LangGraph durable execution/HITL、LangSmith double-texting 与 eval 指南、OpenAI agent eval/trace grading 指南。下一阶段的设计应继续优先对齐这些成熟机制,而不是自造第二套语义。 diff --git a/global/global.go b/global/global.go index aff01fd..b9d386f 100644 --- a/global/global.go +++ b/global/global.go @@ -1,6 +1,7 @@ package global import ( + aidomain "personal_assistant/internal/domain/ai" streaminfra "personal_assistant/internal/infrastructure/sse" "personal_assistant/internal/model/config" obsmetrics "personal_assistant/pkg/observability/metrics" @@ -37,4 +38,7 @@ var ( // SSE 实时推送基础设施 StreamInfra *streaminfra.Infrastructure + + // AI 运行时,由 core.InitAI 初始化,业务层只依赖 domain/ai.Runtime。 + AIRuntime aidomain.Runtime ) diff --git a/go.mod b/go.mod index 49a7a35..652ca55 100644 --- a/go.mod +++ b/go.mod @@ -38,13 +38,25 @@ require ( github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect github.com/alicebob/miniredis/v2 v2.35.0 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cloudwego/eino v0.8.0 // indirect + github.com/cloudwego/eino-ext/adk/backend/local v0.2.2 // indirect + github.com/cloudwego/eino-ext/components/model/ark v0.1.65 // indirect + github.com/cloudwego/eino-ext/components/model/openai v0.1.8 // indirect + github.com/cloudwego/eino-ext/components/model/qwen v0.1.8 // indirect + github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eino-contrib/jsonschema v1.0.3 // indirect + github.com/evanphx/json-patch v0.5.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gammazero/toposort v0.1.1 // indirect @@ -62,6 +74,7 @@ require ( github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/goph/emperror v0.17.2 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect @@ -75,21 +88,28 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect github.com/microsoft/go-mssqldb v1.8.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nikolalohinski/gonja v1.5.3 // indirect github.com/onsi/gomega v1.38.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -97,11 +117,16 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/volcengine/volc-sdk-golang v1.0.23 // indirect + github.com/volcengine/volcengine-go-sdk v1.2.9 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yargevad/filepathx v1.0.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.20.0 // indirect + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/image v0.23.0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect @@ -111,6 +136,8 @@ require ( golang.org/x/tools v0.36.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlserver v1.6.3 // indirect modernc.org/fileutil v1.0.0 // indirect diff --git a/go.sum b/go.sum index e894d68..5121d60 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= @@ -24,23 +25,58 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4= github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/casbin/casbin/v2 v2.2.2/go.mod h1:XXtYGrs/0zlOsJMeRteEdVi/FsB0ph7KgNfjoCoJUD8= github.com/casbin/casbin/v2 v2.11.0 h1:6M/sWT9gh2pUcL541be/rllWEVxcEV6wdg1t7MN6fHQ= github.com/casbin/casbin/v2 v2.11.0/go.mod h1:XXtYGrs/0zlOsJMeRteEdVi/FsB0ph7KgNfjoCoJUD8= github.com/casbin/gorm-adapter/v3 v3.0.2 h1:4F2VFElwPyFzvHfgwizD2JQxk2OFLwvRFZct1np0yBg= github.com/casbin/gorm-adapter/v3 v3.0.2/go.mod h1:mQI09sqvXfy5p6kZB5HBzZrgKWwxaJ4xMWpd5OGfHRY= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/eino v0.8.0 h1:DLbrgEAloA+l7aR2qim7qQocQB48DjPrb8LzG3PYMHY= +github.com/cloudwego/eino v0.8.0/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU= +github.com/cloudwego/eino-ext/adk/backend/local v0.2.2 h1:IWuzl4uZf4IkMN98ieRe9Ajl9E8L90twJh7gFBPXOrQ= +github.com/cloudwego/eino-ext/adk/backend/local v0.2.2/go.mod h1:os5Tq5FuSoz/MLqAdZER3ip49Oef9prc0kVsKsPYO48= +github.com/cloudwego/eino-ext/components/model/ark v0.1.65 h1:52ukXVU9ntToTa36SwI8be81qskGkpUEZraIFOf0wqk= +github.com/cloudwego/eino-ext/components/model/ark v0.1.65/go.mod h1:aabMR15RTXBSi9Eu13CWavzE+no5BQO4FJUEEdqImbg= +github.com/cloudwego/eino-ext/components/model/openai v0.1.8 h1:uVCE8nNvbhD37xGFgdKESWjvChDSkCAMA+DodhFRBaM= +github.com/cloudwego/eino-ext/components/model/openai v0.1.8/go.mod h1:K6g2VgULehhJC5dgFdPW3u7gZNZ1p6DhnfA5UhkRpNY= +github.com/cloudwego/eino-ext/components/model/qwen v0.1.8 h1:TFKuEBLbJhV1V5c2OLJ3kbyysHIGwx8hFkW9NnNJwyM= +github.com/cloudwego/eino-ext/components/model/qwen v0.1.8/go.mod h1:/PepNUOuofBYYqOM4ZOBN9q7uf+WoFv8ReRrbUn9HaA= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 h1:q242n5P5Tx3a2QLaBmkfEpfRs/o17Ac6u3EAgItEEOc= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -59,8 +95,15 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= +github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fvbock/endless v0.0.0-20170109170031-447134032cb6 h1:6VSn3hB5U5GeA6kQw4TwWIWbOhtvR2hmbBJnTOtqTWc= @@ -69,6 +112,7 @@ github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBv github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gammazero/toposort v0.1.1 h1:OivGxsWxF3U3+U80VoLJ+f50HcPU1MIqE1JlKzoJ2Eg= github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= @@ -127,6 +171,24 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -137,6 +199,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= +github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -149,6 +213,7 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= @@ -222,18 +287,24 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -255,6 +326,8 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -267,6 +340,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs= +github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= +github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= +github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -280,10 +357,15 @@ github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJ github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= +github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= +github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -292,9 +374,13 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzL github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk= github.com/qiniu/go-sdk/v7 v7.25.6 h1:89KQX16Bv2x7MxhwpzWGGvQBOPIlGpAcnPQyfS3tRok= github.com/qiniu/go-sdk/v7 v7.25.6/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o= @@ -314,6 +400,7 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -324,8 +411,13 @@ github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeH github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= github.com/songzhibin97/gkit v1.2.13 h1:paY0XJkdRuy9/8k9nTnbdrzo8pC22jIIFldUkOQv5nU= github.com/songzhibin97/gkit v1.2.13/go.mod h1:38CreNR27eTGaG1UMGihrXqI4xc3nGfYxLVKKVx6Ngg= github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= @@ -368,6 +460,14 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= +github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9PY+4OehIk5R8= +github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU= +github.com/volcengine/volcengine-go-sdk v1.2.9 h1:du2gnImtyWXKkQFnJW/GXCs+UBibGGOXIbP1Ams2pB8= +github.com/volcengine/volcengine-go-sdk v1.2.9/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= @@ -392,6 +492,7 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= @@ -416,8 +517,14 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -428,6 +535,10 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -450,6 +561,9 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -460,7 +574,9 @@ golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -475,6 +591,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -529,8 +646,11 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -547,13 +667,34 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= @@ -584,6 +725,8 @@ gorm.io/gorm v0.2.23/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w= modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8= diff --git a/internal/controller/system/aiCtrl.go b/internal/controller/system/aiCtrl.go index 635f421..60a97b5 100644 --- a/internal/controller/system/aiCtrl.go +++ b/internal/controller/system/aiCtrl.go @@ -22,17 +22,10 @@ type AICtrl struct { } // CreateConversation 负责创建新的 AI 会话。 -// 参数: -// - c:Gin 请求上下文,承载请求体、响应写出器和鉴权结果。 -// -// 返回值:无。 // 核心流程: // 1. 绑定请求体,尽早拦截格式错误,避免无意义进入 Service。 // 2. 从 JWT 中提取当前用户 ID,并调用 Service 完成会话创建。 // 3. 统一把成功结果包装成标准响应。 -// -// 注意事项: -// - 参数绑定失败时直接返回统一错误响应,是为了把输入错误和业务错误明确区分开。 func (ctrl *AICtrl) CreateConversation(c *gin.Context) { var req request.CreateAssistantConversationReq @@ -77,17 +70,10 @@ func (ctrl *AICtrl) ListConversations(c *gin.Context) { } // ListMessages 负责返回指定会话下的消息列表。 -// 参数: -// - c:Gin 请求上下文。 -// -// 返回值:无。 // 核心流程: // 1. 读取路径参数中的会话 ID。 // 2. 调用 Service 校验归属并查询消息列表。 // 3. 把 DTO 列表通过统一响应返回。 -// -// 注意事项: -// - 会话归属校验放在 Service 层,Controller 不重复实现权限判断,保持分层清晰。 func (ctrl *AICtrl) ListMessages(c *gin.Context) { data, err := ctrl.aiService.ListMessages(c.Request.Context(), jwt.GetUserID(c), c.Param("id")) if err != nil { @@ -162,36 +148,6 @@ func (ctrl *AICtrl) StreamConversation(c *gin.Context) { } } -// SubmitDecision 负责提交 interrupt 决策结果。 -// 参数: -// - c:Gin 请求上下文。 -// -// 返回值:无。 -// 核心流程: -// 1. 绑定 confirm/skip 等决策参数。 -// 2. 调用 Service 校验 interrupt 状态并写入用户决策。 -// 3. 返回“已接受”的标准响应。 -// -// 注意事项: -// - Controller 只负责把决策原样转交 Service,不在这里解释 interrupt 状态机。 -func (ctrl *AICtrl) SubmitDecision(c *gin.Context) { - var req request.SubmitAssistantDecisionReq - if err := c.ShouldBindJSON(&req); err != nil { - global.Log.Error("AI interrupt 决策参数绑定失败", zap.Error(err)) - response.BizFailWithCodeMsg(bizerrors.CodeBindFailed, "参数绑定失败", c) - return - } - - data, err := ctrl.aiService.SubmitDecision(c.Request.Context(), jwt.GetUserID(c), c.Param("id"), c.Param("interrupt_id"), &req) - if err != nil { - global.Log.Error("AI interrupt 决策失败", zap.Error(err)) - response.BizFailWithError(err, c) - return - } - - response.BizOkWithDetailed(data, "操作成功", c) -} - // resolveSSEPolicy 负责为当前请求解析可用的 SSE 连接策略。 // 参数:无。 // 返回值: diff --git a/internal/controller/system/supplier.go b/internal/controller/system/supplier.go index 402f4a7..eeed305 100644 --- a/internal/controller/system/supplier.go +++ b/internal/controller/system/supplier.go @@ -41,7 +41,6 @@ func SetUp(service *service.Group) Supplier { cs.userCtrl = &UserCtrl{ userService: service.SystemServiceSupplier.GetUserSvc(), jwtService: service.SystemServiceSupplier.GetJWTSvc(), - aiService: service.SystemServiceSupplier.GetAISvc(), } cs.orgCtrl = &OrgCtrl{ orgService: service.SystemServiceSupplier.GetOrgSvc(), diff --git a/internal/controller/system/userCtrl.go b/internal/controller/system/userCtrl.go index 1f59410..487e87c 100644 --- a/internal/controller/system/userCtrl.go +++ b/internal/controller/system/userCtrl.go @@ -22,7 +22,6 @@ import ( type UserCtrl struct { userService serviceContract.UserServiceContract jwtService serviceContract.JWTServiceContract - aiService serviceContract.AIServiceContract } // Register 注册 @@ -144,7 +143,6 @@ func (u *UserCtrl) TokenNext(c *gin.Context, user entity.User) { func (u *UserCtrl) Logout(c *gin.Context) { // 读取必要信息(尽量复用已有的工具函数) uid := jwt.GetUUID(c) - userID := jwt.GetUserID(c) jwtStr := jwt.GetRefreshToken(c) // 清除刷新令牌 Cookie(HttpOnly) @@ -165,10 +163,6 @@ func (u *UserCtrl) Logout(c *gin.Context) { global.Log.Warn("加入刷新令牌黑名单失败", zap.Error(err)) } } - if u.aiService != nil && userID > 0 { - u.aiService.RevokeUserSessions(c.Request.Context(), userID, "logout") - } - response.NewResponse[any, any](c). SetCode(bizerrors.CodeSuccess). Success("登出成功", @@ -249,9 +243,6 @@ func (u *UserCtrl) DeactivateAccount(c *gin.Context) { response.BizFailWithError(err, c) return } - if u.aiService != nil { - u.aiService.RevokeUserSessions(c.Request.Context(), userID, "deactivate") - } jwt.ClearRefreshToken(c) response.BizOkWithMessage("账号已禁用", c) } diff --git a/internal/core/ai.go b/internal/core/ai.go new file mode 100644 index 0000000..61fca4d --- /dev/null +++ b/internal/core/ai.go @@ -0,0 +1,68 @@ +package core + +import ( + "context" + "strings" + + "personal_assistant/global" + infraeino "personal_assistant/internal/infrastructure/ai/eino" + infralocal "personal_assistant/internal/infrastructure/ai/local" + streamsse "personal_assistant/internal/infrastructure/sse" + + "go.uber.org/zap" +) + +// InitAI 初始化项目级 AI runtime。 +// 参数:无。 +// +// 返回值:无。 +// +// 核心流程: +// 1. 读取 SSE 基础设施中的连接策略,用于统一 heartbeat 默认值。 +// 2. 先创建 local runtime 并写入全局兜底实例。 +// 3. 根据 `sse.ai_runtime_mode` 判断是否启用 Eino。 +// 4. Eino 初始化成功后替换全局 runtime;失败时继续使用 local。 +// +// 注意事项: +// - 这里属于初始化/装配层,可以读取 global.Config。 +// - Service 层只依赖 global.AIRuntime 注入后的 domain/ai.Runtime,不直接依赖 Eino SDK。 +func InitAI() { + policy := streamsse.ConnectionPolicy{} + if global.StreamInfra != nil { + policy = global.StreamInfra.Policy + } + policy = policy.Normalize() + + localRuntime := infralocal.NewRuntime(policy.HeartbeatInterval) + global.AIRuntime = localRuntime + + if global.Config == nil { + return + } + mode := strings.ToLower(strings.TrimSpace(global.Config.SSE.AIRuntimeMode)) + if mode == "" || mode == "local" { + return + } + if mode != "eino" { + global.Log.Warn("未知 AI runtime,回退到 local", zap.String("mode", mode)) + return + } + + runtime, err := infraeino.NewRuntime(context.Background(), infraeino.Options{ + Provider: global.Config.AI.Provider, + APIKey: global.Config.AI.APIKey, + BaseURL: global.Config.AI.BaseURL, + Model: global.Config.AI.Model, + ByAzure: global.Config.AI.ByAzure, + APIVersion: global.Config.AI.APIVersion, + SystemPrompt: global.Config.AI.SystemPrompt, + Temperature: global.Config.AI.Temperature, + MaxCompletionTokens: global.Config.AI.MaxCompletionTokens, + HeartbeatInterval: policy.HeartbeatInterval, + }) + if err != nil { + global.Log.Warn("Eino runtime 初始化失败,回退到 local", zap.Error(err)) + return + } + global.AIRuntime = runtime +} diff --git a/internal/core/config.go b/internal/core/config.go index 89071ab..68fe9fc 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -55,7 +55,13 @@ func InitConfig(path string) { viper.SetDefault("sse.drain_timeout_seconds", 15) viper.SetDefault("sse.pubsub_channel_prefix", "sse") viper.SetDefault("sse.replay_stream_prefix", "sse:replay") - viper.SetDefault("sse.ai_runtime_mode", "local") + viper.SetDefault("sse.ai_runtime_mode", "eino") + viper.SetDefault("ai.provider", "qwen") + viper.SetDefault("ai.base_url", "https://dashscope.aliyuncs.com/compatible-mode/v1") + viper.SetDefault("ai.model", "qwen-plus") + viper.SetDefault("ai.system_prompt", "你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。") + viper.SetDefault("ai.temperature", 0.2) + viper.SetDefault("ai.max_completion_tokens", 1200) viper.SetDefault("rate_limit.oj_bind.limit", 3) viper.SetDefault("rate_limit.oj_bind.window_sec", 10) viper.SetDefault("observability.propagation.enabled", true) @@ -236,6 +242,15 @@ func InitConfig(path string) { _ = viper.BindEnv("sse.pubsub_channel_prefix", "SSE_PUBSUB_CHANNEL_PREFIX") _ = viper.BindEnv("sse.replay_stream_prefix", "SSE_REPLAY_STREAM_PREFIX") _ = viper.BindEnv("sse.ai_runtime_mode", "SSE_AI_RUNTIME_MODE") + _ = viper.BindEnv("ai.provider", "AI_PROVIDER") + _ = viper.BindEnv("ai.api_key", "AI_API_KEY") + _ = viper.BindEnv("ai.base_url", "AI_BASE_URL") + _ = viper.BindEnv("ai.model", "AI_MODEL") + _ = viper.BindEnv("ai.by_azure", "AI_BY_AZURE") + _ = viper.BindEnv("ai.api_version", "AI_API_VERSION") + _ = viper.BindEnv("ai.system_prompt", "AI_SYSTEM_PROMPT") + _ = viper.BindEnv("ai.temperature", "AI_TEMPERATURE") + _ = viper.BindEnv("ai.max_completion_tokens", "AI_MAX_COMPLETION_TOKENS") _ = viper.BindEnv("observability.enabled", "OBSERVABILITY_ENABLED") _ = viper.BindEnv("observability.service_name", "OBSERVABILITY_SERVICE_NAME") _ = viper.BindEnv("observability.service_trace.enabled", "OBSERVABILITY_SERVICE_TRACE_ENABLED") diff --git a/internal/domain/ai/event.go b/internal/domain/ai/event.go new file mode 100644 index 0000000..64c9e26 --- /dev/null +++ b/internal/domain/ai/event.go @@ -0,0 +1,54 @@ +package ai + +// EventName 表示 AI runtime 输出给 Service Sink 的稳定事件名。 +// 它是 domain 层的最小事件协议,不依赖 SSE、HTTP 或数据库实现。 +type EventName string + +const ( + // EventConversationStarted 表示一次 AI 流式生成已经开始。 + EventConversationStarted EventName = "conversation_started" + + // EventAssistantToken 表示 assistant 本次追加输出的一段文本。 + EventAssistantToken EventName = "assistant_token" + + // EventMessageCompleted 表示 assistant 消息已经生成完整内容。 + EventMessageCompleted EventName = "message_completed" + + // EventError 表示 runtime 执行过程中出现可下发给前端的错误。 + EventError EventName = "error" + + // EventDone 表示当前 SSE 流已经进入最终结束态。 + EventDone EventName = "done" +) + +// Event 表示 runtime 发给 Service 的最小事件对象。 +// 参数: +// - Name:事件名,必须来自本文件定义的稳定事件集。 +// - Payload:事件载荷,由具体事件名决定其结构。 +// +// 注意事项: +// - domain 层只定义事件语义,不负责 JSON 编码、SSE 写出或 DB 投影。 +type Event struct { + Name EventName + Payload any +} + +// ConversationStartedPayload 表示会话开始事件的载荷。 +type ConversationStartedPayload struct { + Title string `json:"title"` +} + +// AssistantTokenPayload 表示 assistant token 追加事件的载荷。 +type AssistantTokenPayload struct { + Token string `json:"token"` +} + +// MessageCompletedPayload 表示 assistant 消息完成事件的载荷。 +type MessageCompletedPayload struct { + Content string `json:"content"` +} + +// ErrorPayload 表示 AI 流式执行失败时的事件载荷。 +type ErrorPayload struct { + Message string `json:"message"` +} diff --git a/internal/domain/ai/message.go b/internal/domain/ai/message.go new file mode 100644 index 0000000..6000639 --- /dev/null +++ b/internal/domain/ai/message.go @@ -0,0 +1,25 @@ +package ai + +const ( + // RoleUser 表示用户消息角色。 + RoleUser = "user" + // RoleAssistant 表示 AI assistant 消息角色。 + RoleAssistant = "assistant" + + // MessageStatusLoading 表示 assistant 消息仍在生成中。 + MessageStatusLoading = "loading" + // MessageStatusSuccess 表示 assistant 消息已成功完成。 + MessageStatusSuccess = "success" + // MessageStatusError 表示 assistant 消息生成失败。 + MessageStatusError = "error" + // MessageStatusStopped 表示 assistant 消息因取消或超时停止。 + MessageStatusStopped = "stopped" +) + +// Message 表示 runtime 可读取的最小历史消息结构。 +// 它用于向模型提供上下文,不暴露数据库实体或前端响应 DTO。 +type Message struct { + ID string + Role string + Content string +} diff --git a/internal/domain/ai/runtime.go b/internal/domain/ai/runtime.go new file mode 100644 index 0000000..14a9d86 --- /dev/null +++ b/internal/domain/ai/runtime.go @@ -0,0 +1,48 @@ +package ai + +import "context" + +// Runtime 定义 AI 子域最小的运行时边界。 +// 参数: +// - ctx:调用链上下文,取消时 runtime 必须尽快停止输出。 +// - input:本次流式对话的输入,包括用户消息、消息 ID 和历史消息。 +// - sink:runtime 的事件输出端,runtime 只能通过它向外发送事件。 +// +// 返回值: +// - StreamResult:本次生成的最终结果摘要。 +// - error:执行失败时返回原始错误,由 Service 决定如何包装为业务错误或 SSE 错误事件。 +// +// 核心流程: +// 1. Service 负责准备 StreamInput 和 Sink。 +// 2. Runtime 负责调用本地实现或模型实现。 +// 3. Runtime 只通过 Sink 输出 Event,不直接操作 HTTP、SSE writer 或数据库。 +// +// 注意事项: +// - domain/ai 只定义协议,不绑定 Eino、Gin、GORM、Redis 等技术实现。 +type Runtime interface { + Name() string + Stream(ctx context.Context, input StreamInput, sink Sink) (StreamResult, error) +} + +// StreamInput 表示一次基础 AI 流式对话的输入。 +// 它只包含 runtime 必须知道的信息,不包含 HTTP DTO 或数据库实体。 +type StreamInput struct { + UserID uint + + ConversationID string + + UserMessageID string + + AssistantMessageID string + + Content string + + History []Message +} + +// StreamResult 表示 runtime 执行完成后的结果摘要。 +// Service 当前主要依赖事件投影落库,Result 用于后续扩展审计、指标或 fallback 判断。 +type StreamResult struct { + Content string + FinishReason string +} diff --git a/internal/domain/ai/runtime_test.go b/internal/domain/ai/runtime_test.go new file mode 100644 index 0000000..7ec315a --- /dev/null +++ b/internal/domain/ai/runtime_test.go @@ -0,0 +1,12 @@ +package ai + +import "testing" + +func TestEventNamesAreStable(t *testing.T) { + if EventAssistantToken != "assistant_token" { + t.Fatalf("EventAssistantToken = %q", EventAssistantToken) + } + if EventMessageCompleted != "message_completed" { + t.Fatalf("EventMessageCompleted = %q", EventMessageCompleted) + } +} diff --git a/internal/domain/ai/sink.go b/internal/domain/ai/sink.go new file mode 100644 index 0000000..ad60763 --- /dev/null +++ b/internal/domain/ai/sink.go @@ -0,0 +1,20 @@ +package ai + +import "context" + +// Sink 表示 runtime 事件的承接端。 +// 参数: +// - ctx:调用链上下文。 +// - event:runtime 产生的领域事件。 +// +// 核心流程: +// 1. Runtime 调用 Emit 输出事件。 +// 2. Service 层具体实现 Sink,把事件写入 SSE。 +// 3. Service 层同时把事件投影到 assistant message,并通过 Repository 落库。 +// +// 注意事项: +// - Runtime 不应该绕过 Sink 直接写 HTTP 或数据库。 +type Sink interface { + Emit(ctx context.Context, event Event) error + Heartbeat(ctx context.Context) error +} diff --git a/internal/infrastructure/ai/eino/chat_model.go b/internal/infrastructure/ai/eino/chat_model.go new file mode 100644 index 0000000..167361b --- /dev/null +++ b/internal/infrastructure/ai/eino/chat_model.go @@ -0,0 +1,79 @@ +package eino + +import ( + "context" + "fmt" + "strings" + + arkeino "github.com/cloudwego/eino-ext/components/model/ark" + openaieino "github.com/cloudwego/eino-ext/components/model/openai" + qweneino "github.com/cloudwego/eino-ext/components/model/qwen" + einomodel "github.com/cloudwego/eino/components/model" +) + +// NewChatModel 根据配置创建 Eino ChatModel。 +// 参数: +// - ctx:初始化上下文。 +// - cfg:模型 provider、APIKey、模型名、baseURL 等配置。 +// +// 返回值: +// - einomodel.BaseChatModel:Eino 标准聊天模型接口。 +// - error:配置缺失或 provider 不支持时返回错误。 +// +// 核心流程: +// 1. 归一化 provider,默认使用 qwen。 +// 2. 校验 APIKey 和 model 两个必填参数。 +// 3. 根据 provider 创建 Qwen、OpenAI 或 Ark 模型。 +// +// 注意事项: +// - 本函数不读取全局配置,调用方必须显式传入 Options。 +func NewChatModel(ctx context.Context, cfg Options) (einomodel.BaseChatModel, error) { + provider := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if provider == "" { + provider = "qwen" + } + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, fmt.Errorf("ai api key is empty") + } + if strings.TrimSpace(cfg.Model) == "" { + return nil, fmt.Errorf("ai model is empty") + } + + temperature := float32(cfg.Temperature) + maxCompletionTokens := cfg.MaxCompletionTokens + + switch provider { + case "qwen": + baseURL := strings.TrimSpace(cfg.BaseURL) + if baseURL == "" { + baseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1" + } + return qweneino.NewChatModel(ctx, &qweneino.ChatModelConfig{ + APIKey: cfg.APIKey, + BaseURL: baseURL, + Model: cfg.Model, + Temperature: &temperature, + MaxTokens: &maxCompletionTokens, + }) + case "openai": + return openaieino.NewChatModel(ctx, &openaieino.ChatModelConfig{ + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + ByAzure: cfg.ByAzure, + APIVersion: cfg.APIVersion, + Temperature: &temperature, + MaxCompletionTokens: &maxCompletionTokens, + }) + case "ark": + return arkeino.NewChatModel(ctx, &arkeino.ChatModelConfig{ + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + Temperature: &temperature, + MaxCompletionTokens: &maxCompletionTokens, + }) + default: + return nil, fmt.Errorf("unsupported ai provider: %s", provider) + } +} diff --git a/internal/infrastructure/ai/eino/chat_model_test.go b/internal/infrastructure/ai/eino/chat_model_test.go new file mode 100644 index 0000000..c13fd2c --- /dev/null +++ b/internal/infrastructure/ai/eino/chat_model_test.go @@ -0,0 +1,13 @@ +package eino + +import ( + "context" + "testing" +) + +func TestNewChatModelRequiresModel(t *testing.T) { + _, err := NewChatModel(context.Background(), Options{APIKey: "test"}) + if err == nil { + t.Fatal("NewChatModel() error = nil, want missing model error") + } +} diff --git a/internal/infrastructure/ai/eino/options.go b/internal/infrastructure/ai/eino/options.go new file mode 100644 index 0000000..d1e43bc --- /dev/null +++ b/internal/infrastructure/ai/eino/options.go @@ -0,0 +1,47 @@ +package eino + +import "time" + +// Options 描述 Eino 基础流式 runtime 的初始化配置。 +// 作用:把外部传入的 AI 运行参数收拢成一个统一配置对象,供 NewRuntime 之类的构造函数使用。 +type Options struct { + // Provider 表示底层使用的模型提供商。 + // 例如:openai、qwen、ark 等。 + Provider string + + // APIKey 表示访问模型服务所需的鉴权密钥。 + // 一般用于请求大模型平台时进行身份认证。 + APIKey string + + // BaseURL 表示模型服务的基础请求地址。 + // 常用于兼容 OpenAI 协议的第三方平台,或自定义代理地址。 + BaseURL string + + // Model 表示本次运行时默认使用的模型名称。 + // 例如:gpt-4o、qwen-max、deepseek-chat 等。 + Model string + + // ByAzure 表示当前是否通过 Azure OpenAI 方式接入模型服务。 + // 如果为 true,后续请求参数组织方式可能与普通 OpenAI 不同。 + ByAzure bool + + // APIVersion 表示 Azure 或部分模型平台要求显式传入的 API 版本号。 + // 普通 OpenAI 兼容模式下,这个字段通常可以为空。 + APIVersion string + + // SystemPrompt 表示系统提示词。 + // 它用于定义 AI 的全局角色、行为边界和回答风格。 + SystemPrompt string + + // Temperature 表示采样温度。 + // 数值越高,输出通常越发散;数值越低,输出通常越稳定。 + Temperature float64 + + // MaxCompletionTokens 表示本次模型生成阶段允许输出的最大 token 数。 + // 用于限制回答长度,避免输出过长或消耗过多配额。 + MaxCompletionTokens int + + // HeartbeatInterval 表示流式输出时的心跳间隔。 + // 当模型长时间没有新 token 输出时,可按该间隔发送 keepalive,避免 SSE 连接被中断。 + HeartbeatInterval time.Duration +} diff --git a/internal/infrastructure/ai/eino/runtime.go b/internal/infrastructure/ai/eino/runtime.go new file mode 100644 index 0000000..1abf8d5 --- /dev/null +++ b/internal/infrastructure/ai/eino/runtime.go @@ -0,0 +1,170 @@ +package eino + +import ( + "context" + "errors" + "io" + "strings" + + einomodel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + + aidomain "personal_assistant/internal/domain/ai" +) + +type Runtime struct { + model einomodel.BaseChatModel + systemPrompt string +} + +// NewRuntime 创建 Eino 基础流式 runtime。 +// 参数: +// - ctx:初始化上下文。 +// - opt:模型和提示词配置。 +// +// 返回值: +// - *Runtime:可被 Service 注入使用的 Eino runtime。 +// - error:模型初始化失败时返回错误,由 core 层决定是否回退 local。 +// +// 核心流程: +// 1. 使用 Options 创建 ChatModel。 +// 2. 归一化 system prompt,缺失时使用基础对话提示词。 +// 3. 返回只负责基础流式对话的 runtime。 +// +// 注意事项: +// - 当前阶段不注册 Tool,不启用 ApprovalMiddleware,也不使用 checkpoint/resume。 +func NewRuntime(ctx context.Context, opt Options) (*Runtime, error) { + model, err := NewChatModel(ctx, opt) + if err != nil { + return nil, err + } + prompt := strings.TrimSpace(opt.SystemPrompt) + if prompt == "" { + prompt = "你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。" + } + return &Runtime{model: model, systemPrompt: prompt}, nil +} + +// Name 返回当前 runtime 的稳定名称。 +func (r *Runtime) Name() string { + return "eino" +} + +// Stream 调用 Eino ChatModel 执行基础流式对话。 +// 参数: +// - ctx:请求上下文,取消时模型流应停止。 +// - input:用户输入和历史消息。 +// - sink:事件输出端。 +// +// 返回值: +// - aidomain.StreamResult:最终聚合内容与结束原因。 +// - error:模型调用或事件输出失败时返回。 +// +// 核心流程: +// 1. 校验 runtime 和 sink。 +// 2. 先发送 conversation_started 事件。 +// 3. 构造 Eino 消息数组并调用模型 Stream。 +// 4. 把模型返回的每个文本片段转成 assistant_token。 +// 5. 输出 message_completed 和 done 终态事件。 +// +// 注意事项: +// - 本实现不允许模型调用工具,也不会进入人工确认或恢复流程。 +func (r *Runtime) Stream( + ctx context.Context, + input aidomain.StreamInput, + sink aidomain.Sink, +) (aidomain.StreamResult, error) { + if r == nil || r.model == nil { + return aidomain.StreamResult{}, errors.New("eino runtime model is nil") + } + if sink == nil { + return aidomain.StreamResult{}, errors.New("ai runtime sink is nil") + } + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventConversationStarted, + Payload: aidomain.ConversationStartedPayload{Title: deriveTitle(input.Content)}, + }); err != nil { + return aidomain.StreamResult{}, err + } + + reader, err := r.model.Stream(ctx, r.buildMessages(input)) + if err != nil { + return aidomain.StreamResult{}, err + } + defer reader.Close() + + var output strings.Builder + for { + msg, recvErr := reader.Recv() + if errors.Is(recvErr, io.EOF) { + break + } + if recvErr != nil { + return aidomain.StreamResult{}, recvErr + } + if msg == nil || msg.Content == "" { + continue + } + output.WriteString(msg.Content) + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventAssistantToken, + Payload: aidomain.AssistantTokenPayload{Token: msg.Content}, + }); err != nil { + return aidomain.StreamResult{}, err + } + } + + content := output.String() + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventMessageCompleted, + Payload: aidomain.MessageCompletedPayload{Content: content}, + }); err != nil { + return aidomain.StreamResult{}, err + } + if err := sink.Emit(ctx, aidomain.Event{Name: aidomain.EventDone, Payload: map[string]any{}}); err != nil { + return aidomain.StreamResult{}, err + } + return aidomain.StreamResult{Content: content, FinishReason: "stop"}, nil +} + +// buildMessages 把 domain 层历史消息转换成 Eino schema 消息。 +// 参数: +// - input:包含历史消息与当前用户输入的 StreamInput。 +// +// 返回值: +// - []*schema.Message:传给 Eino ChatModel 的消息序列。 +// +// 注意事项: +// - 这里只处理 user/assistant 文本消息,不注入 tool message。 +func (r *Runtime) buildMessages(input aidomain.StreamInput) []*schema.Message { + messages := []*schema.Message{schema.SystemMessage(r.systemPrompt)} + for _, item := range input.History { + content := strings.TrimSpace(item.Content) + if content == "" { + continue + } + switch strings.TrimSpace(item.Role) { + case aidomain.RoleAssistant: + messages = append(messages, schema.AssistantMessage(content, nil)) + default: + messages = append(messages, schema.UserMessage(content)) + } + } + if strings.TrimSpace(input.Content) != "" { + messages = append(messages, schema.UserMessage(strings.TrimSpace(input.Content))) + } + return messages +} + +// deriveTitle 根据用户输入生成会话开始事件标题。 +func deriveTitle(content string) string { + content = strings.TrimSpace(content) + if content == "" { + return "新建会话" + } + runes := []rune(content) + if len(runes) > 24 { + runes = runes[:24] + } + return string(runes) +} diff --git a/internal/infrastructure/ai/local/runtime.go b/internal/infrastructure/ai/local/runtime.go new file mode 100644 index 0000000..e70100e --- /dev/null +++ b/internal/infrastructure/ai/local/runtime.go @@ -0,0 +1,158 @@ +package local + +import ( + "context" + "os" + "strings" + "time" + + aidomain "personal_assistant/internal/domain/ai" +) + +type Runtime struct { + name string + heartbeatInterval time.Duration +} + +// NewRuntime 负责创建本地 AI runtime。 +// 参数: +// - heartbeatInterval:等待或长链路场景使用的心跳间隔;当前最小 runtime 仅保留该配置兼容。 +// +// 返回值: +// - *Runtime:可直接用于 Service 的本地 runtime 实例。 +// +// 核心流程: +// 1. 读取当前主机名作为 runtime 标识的一部分。 +// 2. 修正非法心跳配置,使用默认值兜底。 +// 3. 返回一个不依赖外部模型的本地实现。 +// +// 注意事项: +// - 该 runtime 用于本地开发、测试和 Eino 初始化失败时的降级。 +func NewRuntime(heartbeatInterval time.Duration) *Runtime { + host, err := os.Hostname() + if err != nil || strings.TrimSpace(host) == "" { + host = "local" + } + if heartbeatInterval <= 0 { + heartbeatInterval = 20 * time.Second + } + return &Runtime{name: "local:" + host, heartbeatInterval: heartbeatInterval} +} + +// Name 返回当前 runtime 的稳定名称。 +// 返回值: +// - string:用于日志、排障和运行时识别的名称。 +func (r *Runtime) Name() string { + if r == nil || strings.TrimSpace(r.name) == "" { + return "local" + } + return r.name +} + +// Stream 执行本地最小流式对话。 +// 参数: +// - ctx:请求上下文,取消时停止后续事件输出。 +// - input:本次用户输入与会话消息标识。 +// - sink:事件输出端,负责后续 SSE 和 DB 投影。 +// +// 返回值: +// - aidomain.StreamResult:最终回复内容与结束原因。 +// - error:事件输出失败或 sink 缺失时返回错误。 +// +// 核心流程: +// 1. 先发送 conversation_started。 +// 2. 根据用户输入生成确定性本地回复。 +// 3. 将回复拆成多个 token 事件输出。 +// 4. 发送 message_completed 和 done 终态事件。 +// +// 注意事项: +// - 本地 runtime 不调用模型、不调用工具、不触发 interrupt。 +func (r *Runtime) Stream(ctx context.Context, input aidomain.StreamInput, sink aidomain.Sink) (aidomain.StreamResult, error) { + if sink == nil { + return aidomain.StreamResult{}, errNilSink + } + title := deriveTitle(input.Content) + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventConversationStarted, + Payload: aidomain.ConversationStartedPayload{Title: title}, + }); err != nil { + return aidomain.StreamResult{}, err + } + + reply := buildReply(input.Content) + for _, chunk := range splitChunks(reply, 48) { + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventAssistantToken, + Payload: aidomain.AssistantTokenPayload{Token: chunk}, + }); err != nil { + return aidomain.StreamResult{}, err + } + } + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventMessageCompleted, + Payload: aidomain.MessageCompletedPayload{Content: reply}, + }); err != nil { + return aidomain.StreamResult{}, err + } + if err := sink.Emit(ctx, aidomain.Event{Name: aidomain.EventDone, Payload: map[string]any{}}); err != nil { + return aidomain.StreamResult{}, err + } + return aidomain.StreamResult{Content: reply, FinishReason: "stop"}, nil +} + +// buildReply 负责为本地 runtime 生成确定性回复。 +// 作用:在没有模型或模型不可用时,仍保证 SSE 与消息落库闭环可验证。 +func buildReply(content string) string { + content = strings.TrimSpace(content) + if content == "" { + return "我没有收到有效内容,请重新输入你的问题。" + } + return "我已收到你的问题:" + content + "\n\n当前阶段 AI 助手只保留基础流式对话能力;我会基于你的输入直接回答,不再调用工具或等待人工确认。" +} + +// deriveTitle 根据用户输入生成会话开始事件中的标题。 +// 作用:只做轻量截断,不引入模型或复杂规划。 +func deriveTitle(content string) string { + content = strings.TrimSpace(content) + if content == "" { + return "新建会话" + } + runes := []rune(content) + if len(runes) > 24 { + runes = runes[:24] + } + return string(runes) +} + +// splitChunks 把完整回复拆成固定大小的流式片段。 +// 参数: +// - content:完整文本。 +// - size:每个片段的 rune 数量;小于等于 0 时使用默认值。 +// +// 返回值: +// - []string:按顺序输出的文本片段。 +func splitChunks(content string, size int) []string { + if size <= 0 { + size = 48 + } + runes := []rune(content) + if len(runes) == 0 { + return nil + } + chunks := make([]string, 0, (len(runes)/size)+1) + for start := 0; start < len(runes); start += size { + end := start + size + if end > len(runes) { + end = len(runes) + } + chunks = append(chunks, string(runes[start:end])) + } + return chunks +} + +// runtimeError 表示本地 runtime 内部的轻量错误类型。 +type runtimeError string + +func (e runtimeError) Error() string { return string(e) } + +const errNilSink runtimeError = "ai runtime sink is nil" diff --git a/internal/infrastructure/ai/local/runtime_test.go b/internal/infrastructure/ai/local/runtime_test.go new file mode 100644 index 0000000..d4a1f89 --- /dev/null +++ b/internal/infrastructure/ai/local/runtime_test.go @@ -0,0 +1,43 @@ +package local + +import ( + "context" + "testing" + + aidomain "personal_assistant/internal/domain/ai" +) + +type captureSink struct { + events []aidomain.Event +} + +func (s *captureSink) Emit(_ context.Context, event aidomain.Event) error { + s.events = append(s.events, event) + return nil +} + +func (s *captureSink) Heartbeat(context.Context) error { + return nil +} + +func TestRuntimeStreamEmitsMinimalEvents(t *testing.T) { + runtime := NewRuntime(0) + sink := &captureSink{} + + result, err := runtime.Stream(context.Background(), aidomain.StreamInput{Content: "你好"}, sink) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if result.Content == "" { + t.Fatal("Stream() result content is empty") + } + if len(sink.events) < 4 { + t.Fatalf("events len = %d, want at least 4", len(sink.events)) + } + if sink.events[0].Name != aidomain.EventConversationStarted { + t.Fatalf("first event = %q", sink.events[0].Name) + } + if sink.events[len(sink.events)-1].Name != aidomain.EventDone { + t.Fatalf("last event = %q", sink.events[len(sink.events)-1].Name) + } +} diff --git a/internal/infrastructure/sse/interfaces.go b/internal/infrastructure/sse/interfaces.go index b41f10f..9fbd42d 100644 --- a/internal/infrastructure/sse/interfaces.go +++ b/internal/infrastructure/sse/interfaces.go @@ -1,4 +1,5 @@ package sse + /* 1. Authorizer 授权者 管“谁能连、谁能订阅、谁能看到什么” @@ -14,7 +15,7 @@ package sse 5. Backplane5. 背板 管“多机之间怎么同步消息和踢线命令” -*/ +*/ import "context" // Authorizer 定义 SSE 接入链路的授权与事件过滤能力。 @@ -52,7 +53,7 @@ type StreamWriter interface { // ReplayStore 抽象 durable 事件的补发能力。 // 只有实现了 Append 与 ReplayAfter,客户端断线重连后才有机会基于 Last-Event-ID 补齐消息。 type ReplayStore interface { - Append(ctx context.Context, evt *StreamEvent) error // 追加 + Append(ctx context.Context, evt *StreamEvent) error // 追加 ReplayAfter(ctx context.Context, channel string, lastEventID string, limit int) ([]*StreamEvent, error) // 补发limit条 } diff --git a/internal/infrastructure/sse/types.go b/internal/infrastructure/sse/types.go index a34fadd..a2daaa8 100644 --- a/internal/infrastructure/sse/types.go +++ b/internal/infrastructure/sse/types.go @@ -27,11 +27,11 @@ const ( // StreamEvent 表示 SSE 链路中的标准事件结构。 // 它同时覆盖实时发送、历史回放和跨实例广播三种场景,因此保留了较完整的上下文字段。 type StreamEvent struct { - EventID string `json:"event_id"` // 唯一标志 + EventID string `json:"event_id"` // 唯一标志 StreamKind StreamKind `json:"stream_kind"` // 会话级或频道级 - Channel string `json:"channel"` // 所属频道 - TenantID uint64 `json:"tenant_id"` // 租户ID - SubjectID uint64 `json:"subject_id"` // 发给谁 + Channel string `json:"channel"` // 所属频道 + TenantID uint64 `json:"tenant_id"` // 租户ID + SubjectID uint64 `json:"subject_id"` // 发给谁 EventName string `json:"event_name"` Data []byte `json:"data"` OccurredAt time.Time `json:"occurred_at"` @@ -47,10 +47,10 @@ type StreamEvent struct { type ConnectionPolicy struct { HeartbeatInterval time.Duration // 心跳间隔 WriteTimeout time.Duration // 写出超时 - QueueCapacity int // 写出队列容量 - MaxConnectionsPerSubject int // 每个主体的最大连接数 - ReplayLimit int // 回放限制 - IdleKickPolicy string // 空闲踢出策略 + QueueCapacity int // 写出队列容量 + MaxConnectionsPerSubject int // 每个主体的最大连接数 + ReplayLimit int // 回放限制 + IdleKickPolicy string // 空闲踢出策略 } // Normalize 负责把连接策略补齐为可执行配置。 diff --git a/internal/init/init.go b/internal/init/init.go index f681388..29b2ffb 100644 --- a/internal/init/init.go +++ b/internal/init/init.go @@ -56,6 +56,8 @@ func Init() { global.Redis = core.ConnectRedis() // 初始化项目级 SSE 基础设施(依赖 Redis) core.InitSSEInfrastructure() + // 初始化 AI runtime(依赖配置与 SSE 策略;失败会回退本地 runtime) + core.InitAI() // 初始化Casbin core.InitCasbin() // 初始化存储驱动(本地/七牛,七牛自动包装熔断器) diff --git a/internal/middleware/corsMW.go b/internal/middleware/corsMW.go index 449e965..d11b392 100644 --- a/internal/middleware/corsMW.go +++ b/internal/middleware/corsMW.go @@ -34,7 +34,7 @@ func CORSMiddleware() gin.HandlerFunc { // return true // } // return allowed[origin] - return true; + return true }, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowHeaders: []string{"Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization", "X-Csrf-Token", "x-access-token", "Cookie", "Set-Cookie"}, diff --git a/internal/model/config/ai.go b/internal/model/config/ai.go new file mode 100644 index 0000000..f804f71 --- /dev/null +++ b/internal/model/config/ai.go @@ -0,0 +1,14 @@ +package config + +// AI 描述 AI runtime 与 Eino 相关配置。 +type AI struct { + Provider string `json:"provider" yaml:"provider"` + APIKey string `json:"api_key" yaml:"api_key"` + BaseURL string `json:"base_url" yaml:"base_url"` + Model string `json:"model" yaml:"model"` + ByAzure bool `json:"by_azure" yaml:"by_azure"` + APIVersion string `json:"api_version" yaml:"api_version"` + SystemPrompt string `json:"system_prompt" yaml:"system_prompt"` + Temperature float64 `json:"temperature" yaml:"temperature"` + MaxCompletionTokens int `json:"max_completion_tokens" yaml:"max_completion_tokens"` +} diff --git a/internal/model/config/config.go b/internal/model/config/config.go index 01a431e..bb5a2e5 100644 --- a/internal/model/config/config.go +++ b/internal/model/config/config.go @@ -21,6 +21,7 @@ type Config struct { Task Task `json:"task" yaml:"task"` // 定时任务配置 Messaging Messaging `json:"messaging" yaml:"messaging"` // 消息队列配置 SSE SSE `json:"sse" yaml:"sse"` // SSE 实时推送配置 + AI AI `json:"ai" yaml:"ai"` // AI Runtime / Eino 配置 RateLimit RateLimit `json:"rate_limit" yaml:"rate_limit"` // 限流配置 Observability Observability `json:"observability" yaml:"observability"` // 观测基础设施配置 } @@ -310,6 +311,18 @@ func NewConfig() *Config { AIRuntimeMode: viper.GetString("sse.ai_runtime_mode"), } + _ai := &AI{ + Provider: viper.GetString("ai.provider"), + APIKey: viper.GetString("ai.api_key"), + BaseURL: viper.GetString("ai.base_url"), + Model: viper.GetString("ai.model"), + ByAzure: viper.GetBool("ai.by_azure"), + APIVersion: viper.GetString("ai.api_version"), + SystemPrompt: viper.GetString("ai.system_prompt"), + Temperature: viper.GetFloat64("ai.temperature"), + MaxCompletionTokens: viper.GetInt("ai.max_completion_tokens"), + } + _observability := &Observability{ Enabled: viper.GetBool("observability.enabled"), ServiceName: viper.GetString("observability.service_name"), @@ -382,6 +395,7 @@ func NewConfig() *Config { Task: *_task, Messaging: *_messaging, SSE: *_sse, + AI: *_ai, RateLimit: *_rateLimit, Observability: *_observability, } diff --git a/internal/model/dto/request/aiReq.go b/internal/model/dto/request/aiReq.go index d0b507f..a419643 100644 --- a/internal/model/dto/request/aiReq.go +++ b/internal/model/dto/request/aiReq.go @@ -7,16 +7,8 @@ type CreateAssistantConversationReq struct { // StreamAssistantMessageReq 定义当前接口使用的请求参数结构。 type StreamAssistantMessageReq struct { - ConversationID string `json:"conversation_id" binding:"required,max=64"` // 会话 ID - Content string `json:"content" binding:"required"` // 消息内容 - ContextUserName string `json:"context_user_name" binding:"required,max=100"` // 上下文中的用户名称,便于 AI 生成更自然的回复 - ContextOrgName string `json:"context_org_name" binding:"required,max=100"` // 上下文中的组织名称,便于 AI 生成更自然的回复 -} - -// SubmitAssistantDecisionReq 定义当前接口使用的请求参数结构。 -type SubmitAssistantDecisionReq struct { - ConversationID string `json:"conversation_id" binding:"required,max=64"` // 会话 ID - InterruptID string `json:"interrupt_id" binding:"required,max=64"` // 中断 ID - Decision string `json:"decision" binding:"required,oneof=confirm skip"` // 决策 - Reason string `json:"reason" binding:"omitempty,max=500"` // 原因 + ConversationID string `json:"conversation_id" binding:"required,max=64"` // 会话 ID + Content string `json:"content" binding:"required"` // 消息内容 + ContextUserName string `json:"context_user_name" binding:"omitempty,max=100"` // 兼容字段;正式上下文由服务端推导 + ContextOrgName string `json:"context_org_name" binding:"omitempty,max=100"` // 兼容字段;正式上下文由服务端推导 } diff --git a/internal/model/dto/response/aiResp.go b/internal/model/dto/response/aiResp.go index f0472ee..529095d 100644 --- a/internal/model/dto/response/aiResp.go +++ b/internal/model/dto/response/aiResp.go @@ -22,10 +22,10 @@ type AssistantConversationResp struct { // AssistantTraceAction 表示轨迹节点上的可执行动作。 type AssistantTraceAction struct { - Key string `json:"key"` // 动作唯一标识,用于前端定位或回传。 - Label string `json:"label"` // 动作按钮文案,展示给用户。 - Action string `json:"action"` // 动作类型或动作指令,如 accept / reject / retry。 - Style string `json:"style,omitempty"` // 动作样式标记,如 primary / danger,供前端渲染使用。 + Key string `json:"key"` // 动作唯一标识,用于前端定位或回传。 + Label string `json:"label"` // 动作按钮文案,展示给用户。 + Action string `json:"action"` // 动作类型或动作指令,如 accept / reject / retry。 + Style string `json:"style,omitempty"` // 动作样式标记,如 primary / danger,供前端渲染使用。 } // AssistantTraceItem 表示一条执行轨迹节点。 @@ -89,24 +89,16 @@ type AssistantScopeInfo struct { // AssistantMessageResp 表示单条消息的响应结构。 type AssistantMessageResp struct { - ID string `json:"id"` // 消息唯一标识。 - ConversationID string `json:"conversation_id"` // 所属会话 ID。 - Role string `json:"role"` // 消息角色,如 user / assistant / system。 - Content string `json:"content"` // 消息正文内容。 - CreatedAt string `json:"created_at"` // 消息创建时间的格式化字符串。 - Status string `json:"status"` // 消息状态,如 pending / streaming / completed / failed。 - TraceItems []AssistantTraceItem `json:"trace_items"` // 当前消息关联的执行轨迹列表。 - UIBlocks []AssistantA2UIBlock `json:"ui_blocks"` // 当前消息附带的结构化 UI 区块。 - Scope *AssistantScopeInfo `json:"scope,omitempty"` // 当前消息关联的作用域信息,为空表示无额外上下文。 - ErrorText string `json:"error_text,omitempty"` // 错误信息文本,通常在失败场景下返回。 -} - -// AssistantInterruptDecisionAcceptedResp 表示用户处理中断确认后的响应结果。 -type AssistantInterruptDecisionAcceptedResp struct { - Accepted bool `json:"accepted"` // 后端是否成功接收本次确认决定。 - ConversationID string `json:"conversation_id"` // 当前中断所属会话 ID。 - InterruptID string `json:"interrupt_id"` // 被处理的中断 ID。 - Decision string `json:"decision"` // 用户的确认结果,如 accept / reject。 + ID string `json:"id"` // 消息唯一标识。 + ConversationID string `json:"conversation_id"` // 所属会话 ID。 + Role string `json:"role"` // 消息角色,如 user / assistant / system。 + Content string `json:"content"` // 消息正文内容。 + CreatedAt string `json:"created_at"` // 消息创建时间的格式化字符串。 + Status string `json:"status"` // 消息状态,如 pending / streaming / completed / failed。 + TraceItems []AssistantTraceItem `json:"trace_items"` // 当前消息关联的执行轨迹列表。 + UIBlocks []AssistantA2UIBlock `json:"ui_blocks"` // 当前消息附带的结构化 UI 区块。 + Scope *AssistantScopeInfo `json:"scope,omitempty"` // 当前消息关联的作用域信息,为空表示无额外上下文。 + ErrorText string `json:"error_text,omitempty"` // 错误信息文本,通常在失败场景下返回。 } // AssistantConversationStartedPayload 表示会话开始事件的载荷。 @@ -128,12 +120,12 @@ type AssistantToolCallStartedPayload struct { // AssistantToolCallFinishedPayload 表示工具调用结束事件的载荷。 type AssistantToolCallFinishedPayload struct { - Key string `json:"key"` // 工具调用步骤唯一标识。 - Description string `json:"description"` // 工具调用结束后的简述。 - DurationMS int64 `json:"duration_ms"` // 工具调用耗时,单位毫秒。 - Status string `json:"status"` // 工具调用结果状态,如 success / failed。 - Content string `json:"content,omitempty"` // 工具调用返回的简要结果。 - DetailMarkdown string `json:"detail_markdown,omitempty"`// 工具调用详细结果,通常为 Markdown。 + Key string `json:"key"` // 工具调用步骤唯一标识。 + Description string `json:"description"` // 工具调用结束后的简述。 + DurationMS int64 `json:"duration_ms"` // 工具调用耗时,单位毫秒。 + Status string `json:"status"` // 工具调用结果状态,如 success / failed。 + Content string `json:"content,omitempty"` // 工具调用返回的简要结果。 + DetailMarkdown string `json:"detail_markdown,omitempty"` // 工具调用详细结果,通常为 Markdown。 } // AssistantToolCallWaitingConfirmationPayload 表示工具调用进入待确认状态时的载荷。 @@ -150,12 +142,12 @@ type AssistantToolCallWaitingConfirmationPayload struct { // AssistantToolCallConfirmationResultPayload 表示用户确认后返回的结果载荷。 type AssistantToolCallConfirmationResultPayload struct { - InterruptID string `json:"interrupt_id"` // 已处理的中断 ID。 - Key string `json:"key"` // 对应的工具调用步骤 ID。 - Decision string `json:"decision"` // 用户做出的决定,如 accept / reject。 - Status string `json:"status"` // 决定生效后的步骤状态。 - Description string `json:"description"` // 对本次确认结果的简要说明。 - DetailMarkdown string `json:"detail_markdown,omitempty"` // 对本次确认结果的详细说明。 + InterruptID string `json:"interrupt_id"` // 已处理的中断 ID。 + Key string `json:"key"` // 对应的工具调用步骤 ID。 + Decision string `json:"decision"` // 用户做出的决定,如 accept / reject。 + Status string `json:"status"` // 决定生效后的步骤状态。 + Description string `json:"description"` // 对本次确认结果的简要说明。 + DetailMarkdown string `json:"detail_markdown,omitempty"` // 对本次确认结果的详细说明。 } // AssistantStructuredBlockPayload 表示结构化 UI 或作用域信息事件的载荷。 @@ -172,4 +164,4 @@ type AssistantMessageCompletedPayload struct { // AssistantErrorPayload 表示错误事件的载荷。 type AssistantErrorPayload struct { Message string `json:"message"` // 错误描述信息。 -} \ No newline at end of file +} diff --git a/internal/repository/interfaces/aiRepository.go b/internal/repository/interfaces/aiRepository.go index 4b3320a..6d35a2d 100644 --- a/internal/repository/interfaces/aiRepository.go +++ b/internal/repository/interfaces/aiRepository.go @@ -2,6 +2,7 @@ package interfaces import ( "context" + "time" "personal_assistant/internal/model/entity" ) @@ -10,6 +11,7 @@ import ( type AIRepository interface { CreateConversation(ctx context.Context, conversation *entity.AIConversation) error GetConversationByID(ctx context.Context, conversationID string) (*entity.AIConversation, error) + GetConversationByIDForUpdate(ctx context.Context, conversationID string) (*entity.AIConversation, error) ListConversationsByUser(ctx context.Context, userID uint) ([]*entity.AIConversation, error) UpdateConversation(ctx context.Context, conversation *entity.AIConversation) error DeleteConversationCascade(ctx context.Context, conversationID string) error @@ -20,6 +22,14 @@ type AIRepository interface { CreateInterrupt(ctx context.Context, interrupt *entity.AIInterrupt) error GetInterruptByID(ctx context.Context, interruptID string) (*entity.AIInterrupt, error) + GetInterruptByIDForUpdate(ctx context.Context, interruptID string) (*entity.AIInterrupt, error) + ListInterruptsByUserAndStatuses(ctx context.Context, userID uint, statuses []string) ([]*entity.AIInterrupt, error) + ListInterruptsForRecovery( + ctx context.Context, + statuses []string, + updatedBefore time.Time, + limit int, + ) ([]*entity.AIInterrupt, error) UpdateInterrupt(ctx context.Context, interrupt *entity.AIInterrupt) error WithTx(tx any) AIRepository diff --git a/internal/repository/system/aiRepo.go b/internal/repository/system/aiRepo.go index 5efeab8..9cb3a78 100644 --- a/internal/repository/system/aiRepo.go +++ b/internal/repository/system/aiRepo.go @@ -3,11 +3,13 @@ package system import ( "context" "errors" + "time" "personal_assistant/internal/model/entity" "personal_assistant/internal/repository/interfaces" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // AIGormRepository 定义当前领域访问持久化数据所需的仓储能力。 @@ -96,6 +98,21 @@ func (r *AIGormRepository) GetConversationByID(ctx context.Context, conversation return &conversation, nil } +// GetConversationByIDForUpdate 在事务内锁定会话行,避免同一会话并发开启多条生成流。 +func (r *AIGormRepository) GetConversationByIDForUpdate(ctx context.Context, conversationID string) (*entity.AIConversation, error) { + var conversation entity.AIConversation + if err := r.db.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", conversationID). + First(&conversation).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &conversation, nil +} + // ListConversationsByUser 用于查询并返回一组结果。 // 参数: // - ctx:链路上下文,用于取消、超时控制和日志透传。 @@ -273,6 +290,63 @@ func (r *AIGormRepository) GetInterruptByID(ctx context.Context, interruptID str return &interrupt, nil } +// GetInterruptByIDForUpdate 在事务内锁定 interrupt 行,避免并发决策或恢复同时推进。 +func (r *AIGormRepository) GetInterruptByIDForUpdate( + ctx context.Context, + interruptID string, +) (*entity.AIInterrupt, error) { + var interrupt entity.AIInterrupt + if err := r.db.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("interrupt_id = ?", interruptID). + First(&interrupt).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &interrupt, nil +} + +// ListInterruptsByUserAndStatuses 返回某个用户指定状态下的 interrupt。 +func (r *AIGormRepository) ListInterruptsByUserAndStatuses( + ctx context.Context, + userID uint, + statuses []string, +) ([]*entity.AIInterrupt, error) { + var interrupts []*entity.AIInterrupt + query := r.db.WithContext(ctx).Where("user_id = ?", userID) + if len(statuses) > 0 { + query = query.Where("status IN ?", statuses) + } + if err := query.Order("updated_at ASC").Find(&interrupts).Error; err != nil { + return nil, err + } + return interrupts, nil +} + +// ListInterruptsForRecovery 返回达到恢复扫描条件的一批 interrupt。 +func (r *AIGormRepository) ListInterruptsForRecovery( + ctx context.Context, + statuses []string, + updatedBefore time.Time, + limit int, +) ([]*entity.AIInterrupt, error) { + var interrupts []*entity.AIInterrupt + query := r.db.WithContext(ctx). + Where("updated_at <= ?", updatedBefore) + if len(statuses) > 0 { + query = query.Where("status IN ?", statuses) + } + if limit > 0 { + query = query.Limit(limit) + } + if err := query.Order("updated_at ASC").Find(&interrupts).Error; err != nil { + return nil, err + } + return interrupts, nil +} + // UpdateInterrupt 负责更新当前场景对应的数据状态。 // 参数: // - ctx:链路上下文,用于取消、超时控制和日志透传。 diff --git a/internal/router/system/aiRouter.go b/internal/router/system/aiRouter.go index 4f29dea..accc7d8 100644 --- a/internal/router/system/aiRouter.go +++ b/internal/router/system/aiRouter.go @@ -26,11 +26,10 @@ func (r *AIRouter) InitAIRouter(router *gin.RouterGroup) { aiRouter := router.Group("ai/conversations") aiCtrl := controller.ApiGroupApp.SystemApiGroup.GetAICtrl() { - aiRouter.POST("", aiCtrl.CreateConversation) // 创建会话 - aiRouter.GET("", aiCtrl.ListConversations) // 获取会话列表 + aiRouter.POST("", aiCtrl.CreateConversation) // 创建会话 + aiRouter.GET("", aiCtrl.ListConversations) // 获取会话列表 aiRouter.GET(":id/messages", aiCtrl.ListMessages) // 获取某个会话下的消息列表 aiRouter.DELETE(":id", aiCtrl.DeleteConversation) // 删除指定会话 - aiRouter.POST(":id/interrupts/:interrupt_id/decision", aiCtrl.SubmitDecision) // 提交决策 } } diff --git a/internal/service/contract/system.go b/internal/service/contract/system.go index c72c63a..6dbc8cc 100644 --- a/internal/service/contract/system.go +++ b/internal/service/contract/system.go @@ -232,8 +232,6 @@ type AIServiceContract interface { ListMessages(ctx context.Context, userID uint, conversationID string) ([]*resp.AssistantMessageResp, error) DeleteConversation(ctx context.Context, userID uint, conversationID string) error StreamConversation(ctx context.Context, userID uint, conversationID string, req *request.StreamAssistantMessageReq, writer streamsse.StreamWriter) error - SubmitDecision(ctx context.Context, userID uint, conversationID, interruptID string, req *request.SubmitAssistantDecisionReq) (*resp.AssistantInterruptDecisionAcceptedResp, error) - RevokeUserSessions(ctx context.Context, userID uint, reason string) int } // Supplier 用于集中提供当前模块依赖对象。 diff --git a/internal/service/system/aiIntent.go b/internal/service/system/aiIntent.go deleted file mode 100644 index da882fa..0000000 --- a/internal/service/system/aiIntent.go +++ /dev/null @@ -1,75 +0,0 @@ -package system - -/* -这部分写的还不够成熟,下次至少要这样优化: -规则短路/硬拦截 → -意图与实体识别 → -结合上下文做路由 → -看置信度决定执行/澄清/fallback → -需要知识时再接检索与生成 → -持续评估和调参。 -*/ - -import "regexp" - -// aiIntentProfile 定义当前文件中的核心数据结构或能力抽象。 -type aiIntentProfile struct { - wantsTaskReport bool - wantsProgressInsight bool - wantsDocSupport bool - showScope bool - showThinkingSummary bool -} - -var ( - aiLightweightPromptRE = regexp.MustCompile(`^(你好(?:呀|啊)?|您好|hello|hi|嗨|哈喽|在吗|在么|早上好|中午好|下午好|晚上好|谢谢|thanks?|thank you|是的|好的|好|ok|okay|嗯|嗯嗯)[!!,.。??\s]*$`) - aiTaskReportPromptRE = regexp.MustCompile(`任务|汇报|日报|周报|进展|联调|闭环`) - aiProgressPromptRE = regexp.MustCompile(`进度|刷题|训练|排名|节奏|建议|最近.*天`) - aiDocPromptRE = regexp.MustCompile(`文档|README|架构|页面定位|接入|UI|改造说明|说明`) - aiScopePromptRE = regexp.MustCompile(`范围|scope|当前用户|当前组织|白名单|跨组织|跨用户|文档范围`) -) - -// isLightweightAIPrompt 负责执行当前函数对应的核心逻辑。 -// 参数: -// - input:当前阶段输入对象。 -// -// 返回值: -// - bool:表示当前操作是否成功、命中或可继续执行。 -// -// 核心流程: -// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 -// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 -// -// 注意事项: -// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 -func isLightweightAIPrompt(input string) bool { - return aiLightweightPromptRE.MatchString(input) -} - -// detectAIIntent 负责执行当前函数对应的核心逻辑。 -// 参数: -// - input:当前阶段输入对象。 -// -// 返回值: -// - aiIntentProfile:当前函数返回的处理结果。 -// -// 核心流程: -// 1. 根据当前输入整理本函数需要的上下文、默认值或依赖。 -// 2. 执行该函数对应的核心职责,并把结果传递给下一层或调用方。 -// -// 注意事项: -// - 具体细节需结合函数体与调用方一起理解;当前注释基于函数命名和上下文整理。 -func detectAIIntent(input string) aiIntentProfile { - wantsTaskReport := aiTaskReportPromptRE.MatchString(input) - wantsProgressInsight := aiProgressPromptRE.MatchString(input) - wantsDocSupport := aiDocPromptRE.MatchString(input) - showScope := aiScopePromptRE.MatchString(input) - showThinkingSummary := wantsTaskReport || wantsProgressInsight || wantsDocSupport || len([]rune(input)) >= 16 - return aiIntentProfile{ - wantsTaskReport: wantsTaskReport, - wantsProgressInsight: wantsProgressInsight, - wantsDocSupport: wantsDocSupport, - showScope: showScope, - showThinkingSummary: showThinkingSummary, - } -} diff --git a/internal/service/system/aiMapper.go b/internal/service/system/aiMapper.go index dd282fa..5026e7e 100644 --- a/internal/service/system/aiMapper.go +++ b/internal/service/system/aiMapper.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "personal_assistant/internal/model/dto/request" + aidomain "personal_assistant/internal/domain/ai" resp "personal_assistant/internal/model/dto/response" "personal_assistant/internal/model/entity" @@ -145,21 +145,30 @@ func messageToResp(message *entity.AIMessage) (*resp.AssistantMessageResp, error CreatedAt: message.CreatedAt.Format(time.RFC3339), Status: message.Status, // 从 JSON 字符串解码成结构化对象 - TraceItems: decodeAssistantTraceItems(message.TraceItemsJSON), - UIBlocks: decodeAssistantUIBlocks(message.UIBlocksJSON), - Scope: decodeAssistantScope(message.ScopeJSON), - ErrorText: message.ErrorText, + TraceItems: decodeAssistantTraceItems(message.TraceItemsJSON), + UIBlocks: decodeAssistantUIBlocks(message.UIBlocksJSON), + Scope: decodeAssistantScope(message.ScopeJSON), + ErrorText: message.ErrorText, }, nil } -// encodeJSON 负责执行当前函数对应的核心逻辑。 -// 作用:把结构体/数组编码成 JSON 字符串。 -func encodeJSON(value any, emptyFallback string) string { - raw, err := json.Marshal(value) - if err != nil { - return emptyFallback +func messagesToRuntimeHistory(messages []*entity.AIMessage) []aidomain.Message { + items := make([]aidomain.Message, 0, len(messages)) + for _, message := range messages { + if message == nil || strings.TrimSpace(message.Content) == "" { + continue + } + role := strings.TrimSpace(message.Role) + if role != aidomain.RoleAssistant { + role = aidomain.RoleUser + } + items = append(items, aidomain.Message{ + ID: message.ID, + Role: role, + Content: message.Content, + }) } - return string(raw) + return items } // decodeAssistantTraceItems 负责执行当前函数对应的核心逻辑。 @@ -203,205 +212,3 @@ func decodeAssistantScope(raw string) *resp.AssistantScopeInfo { } return &item } - -/* - 四、流式输出与上下文构造类 -*/ - -// splitReplyChunks 负责执行当前函数对应的核心逻辑。 -// 作用:把一整段回复拆成多个小块。 -func splitReplyChunks(content string, size int) []string { - if size <= 0 { - size = 48 - } - runes := []rune(content) - if len(runes) == 0 { - return nil - } - chunks := make([]string, 0, (len(runes)/size)+1) - for index := 0; index < len(runes); index += size { - end := index + size - if end > len(runes) { - end = len(runes) - } - chunks = append(chunks, string(runes[index:end])) - } - return chunks -} - -// buildScopeInfo 负责执行当前函数对应的核心逻辑。 -// 作用:根据请求参数,构造作用域信息。 -func buildScopeInfo(req *request.StreamAssistantMessageReq) *resp.AssistantScopeInfo { - if req == nil { - return nil - } - return &resp.AssistantScopeInfo{ - UserName: req.ContextUserName, - OrgName: req.ContextOrgName, - ScopeLabel: "当前用户 + 当前组织 + 最近任务 + 当前文档范围", - TaskName: "OJ 任务闭环联调 V2", - DocScopeLabel: "README、架构设计方案、AI UI 改造说明", - } -} - -/* - 五、UI 组件构造类 -*/ - -// textComponent 负责执行当前函数对应的核心逻辑。 -// 作用:构造一个文本组件。 -func textComponent(id string, value string, usageHint string, tone string) resp.AssistantA2UIComponent { - return resp.AssistantA2UIComponent{ID: id, Type: "Text", Value: value, UsageHint: usageHint, Tone: tone} -} - -// badgeComponent 负责执行当前函数对应的核心逻辑。 -// 作用:构造一个徽标组件。 -func badgeComponent(id string, label string, tone string) resp.AssistantA2UIComponent { - return resp.AssistantA2UIComponent{ID: id, Type: "Badge", Label: label, Tone: tone} -} - -// bulletListComponent 负责执行当前函数对应的核心逻辑。 -// 作用:构造一个项目符号列表组件。 -func bulletListComponent(id string, items []string) resp.AssistantA2UIComponent { - return resp.AssistantA2UIComponent{ID: id, Type: "BulletList", Items: items} -} - -// cardComponent 负责执行当前函数对应的核心逻辑。 -// 作用:构造一个卡片组件。 -func cardComponent(id string, tone string, children ...string) resp.AssistantA2UIComponent { - return resp.AssistantA2UIComponent{ID: id, Type: "Card", Tone: tone, Children: children} -} - -/* - 六、具体 UI Block 组装类 - 这些函数就不是“基础组件”了,而是更高一层: - 直接拼出一块完整的业务 UI block。 -*/ - -// buildThinkingSummaryBlock 负责执行当前函数对应的核心逻辑。 -// 作用:生成“当前判断与下一步”的思考摘要卡片。 -func buildThinkingSummaryBlock(plan *AIRuntimePlan) *resp.AssistantA2UIBlock { - // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 - // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 - items := []string{ - "当前判断:这个问题需要结合业务上下文和已有结果来组织答案。", - "当前动作:先汇总可直接使用的信息,再决定是否需要额外工具确认。", - "下一步:在拿到确认结果后输出最终正文。", - } - // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 - // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 - if plan != nil && plan.DocTool == nil { - items[1] = "当前动作:当前问题不需要用户确认,可以直接整理结果。" - } - // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 - // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 - return &resp.AssistantA2UIBlock{ - Key: "block_thinking_summary", - Type: "thinking_summary_block", - Surface: resp.AssistantA2UISurface{ - ID: "surface_thinking_summary", - Root: "thinking_card_root", - Components: []resp.AssistantA2UIComponent{ - cardComponent("thinking_card_root", "muted", "thinking_title", "thinking_points"), - textComponent("thinking_title", "当前判断与下一步", "title", ""), - bulletListComponent("thinking_points", items), - }, - }, - } -} - -// buildToolIntentBlock 负责执行当前函数对应的核心逻辑。 -// 作用:生成“某个工具即将调用,需要用户确认”的意图卡片。 -func buildToolIntentBlock(tool *AIToolBlueprint) *resp.AssistantA2UIBlock { - // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 - // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 - if tool == nil { - return nil - } - // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 - // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 - return &resp.AssistantA2UIBlock{ - Key: "block_tool_intent", - Type: "tool_intent_block", - Surface: resp.AssistantA2UISurface{ - ID: "surface_tool_intent", - Root: "tool_intent_card_root", - Components: []resp.AssistantA2UIComponent{ - cardComponent("tool_intent_card_root", "warning", "tool_badge", "tool_title", "tool_points"), - badgeComponent("tool_badge", "等待确认", "warning"), - textComponent("tool_title", tool.Title+"需要你的确认", "title", ""), - bulletListComponent("tool_points", []string{ - "目的:补充当前回答需要的正式依据或范围说明。", - "必要性:已有上下文能给初步回答,但缺少更稳的支撑信息。", - "确认要求:是否继续调用该工具由你决定。", - }), - }, - }, - } -} - -// buildWaitingUserBlock 负责执行当前函数对应的核心逻辑。 -// 作用:生成“当前正在等待用户决策”的卡片。 -func buildWaitingUserBlock(tool *AIToolBlueprint) *resp.AssistantA2UIBlock { - // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 - // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 - if tool == nil { - return nil - } - // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 - // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 - return &resp.AssistantA2UIBlock{ - Key: "block_waiting_user", - Type: "waiting_user_block", - Surface: resp.AssistantA2UISurface{ - ID: "surface_waiting_user", - Root: "waiting_card_root", - Components: []resp.AssistantA2UIComponent{ - cardComponent("waiting_card_root", "warning", "waiting_title", "waiting_description", "waiting_points"), - textComponent("waiting_title", tool.ConfirmationTitle, "title", ""), - textComponent("waiting_description", tool.ConfirmationDescription, "body", ""), - bulletListComponent("waiting_points", []string{ - "继续后:会补充正式依据,再输出更完整的最终回答。", - "跳过后:只基于当前已有上下文继续输出。", - }), - }, - }, - } -} - -/* - 七、trace 与 UI block 的辅助更新类 -*/ - -// assistantTraceActions 负责执行当前函数对应的核心逻辑。 -// 作用:给 trace 生成两个标准操作按钮。 -func assistantTraceActions(toolKey string) []resp.AssistantTraceAction { - return []resp.AssistantTraceAction{ - {Key: toolKey + "_confirm", Label: "继续使用", Action: "confirm", Style: "primary"}, - {Key: toolKey + "_skip", Label: "跳过此工具", Action: "skip", Style: "default"}, - } -} - -// upsertTraceItem 负责执行当前函数对应的核心逻辑。 -// 作用:按 Key 更新或插入 trace item。 -func upsertTraceItem(items []resp.AssistantTraceItem, item resp.AssistantTraceItem) []resp.AssistantTraceItem { - for idx := range items { - if items[idx].Key == item.Key { - items[idx] = item - return items - } - } - return append(items, item) -} - -// upsertUIBlock 负责执行当前函数对应的核心逻辑。 -// 作用:按 Key 更新或插入 UI block。 -func upsertUIBlock(items []resp.AssistantA2UIBlock, block resp.AssistantA2UIBlock) []resp.AssistantA2UIBlock { - for idx := range items { - if items[idx].Key == block.Key { - items[idx] = block - return items - } - } - return append(items, block) -} diff --git a/internal/service/system/aiProjector.go b/internal/service/system/aiProjector.go new file mode 100644 index 0000000..1a9d5b6 --- /dev/null +++ b/internal/service/system/aiProjector.go @@ -0,0 +1,116 @@ +package system + +import ( + "context" + "sync" + "time" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" + "personal_assistant/internal/repository/interfaces" +) + +// aiMessageProjector 负责把最小 runtime 事件折叠成 assistant 消息快照。 +// 它只处理基础流式文本状态,不再维护 A2UI、tool trace、interrupt 状态机。 +type aiMessageProjector struct { + mu sync.Mutex + repo interfaces.AIRepository + message *entity.AIMessage +} + +// newAIMessageProjector 创建消息投影器。 +// 参数: +// - repo:AI 仓储,用于持久化消息快照。 +// - message:当前 assistant 消息实体。 +// +// 返回值: +// - *aiMessageProjector:绑定消息和仓储后的投影器。 +func newAIMessageProjector(repo interfaces.AIRepository, message *entity.AIMessage) *aiMessageProjector { + return &aiMessageProjector{repo: repo, message: message} +} + +// setStopped 把 assistant 消息标记为停止态。 +// 作用:用于请求取消、超时等非系统故障场景的收尾。 +func (p *aiMessageProjector) setStopped() { + p.mu.Lock() + defer p.mu.Unlock() + + if p.message != nil { + p.message.Status = aiMessageStatusStopped + } +} + +// setError 把 assistant 消息标记为失败态并记录错误文案。 +// 参数: +// - message:可展示给用户或排障使用的错误说明。 +func (p *aiMessageProjector) setError(message string) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.message != nil { + p.message.Status = aiMessageStatusError + p.message.ErrorText = message + } +} + +// persistMessage 将当前内存中的 assistant 消息快照写回数据库。 +// 参数: +// - ctx:数据库操作上下文。 +// +// 返回值: +// - error:Repository 更新失败时返回原始错误。 +// +// 注意事项: +// - `trace_items_json`、`ui_blocks_json`、`scope_json` 本阶段保留兼容字段,但固定写空值。 +func (p *aiMessageProjector) persistMessage(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.message == nil { + return nil + } + p.message.TraceItemsJSON = "[]" + p.message.UIBlocksJSON = "[]" + p.message.ScopeJSON = "{}" + p.message.UpdatedAt = time.Now() + return p.repo.UpdateMessage(ctx, p.message) +} + +// applyEvent 根据 runtime 事件更新 assistant 消息内存态。 +// 参数: +// - event:domain/ai 定义的最小事件。 +// +// 核心流程: +// 1. `assistant_token` 追加到消息正文。 +// 2. `message_completed` 覆盖最终正文并标记成功。 +// 3. `error` 写入错误文案并标记失败。 +// +// 注意事项: +// - 本函数只更新内存态,真正落库由 persistMessage 统一完成。 +func (p *aiMessageProjector) applyEvent(event aidomain.Event) { + p.mu.Lock() + defer p.mu.Unlock() + if p.message == nil { + return + } + + switch event.Name { + case aidomain.EventAssistantToken: + if payload, ok := event.Payload.(aidomain.AssistantTokenPayload); ok { + p.message.Content += payload.Token + if p.message.Status == aiMessageStatusStopped || p.message.Status == aiMessageStatusError { + return + } + p.message.Status = aiMessageStatusLoading + } + case aidomain.EventMessageCompleted: + if payload, ok := event.Payload.(aidomain.MessageCompletedPayload); ok { + p.message.Content = payload.Content + } + p.message.Status = aiMessageStatusSuccess + case aidomain.EventError: + if payload, ok := event.Payload.(aidomain.ErrorPayload); ok { + p.message.ErrorText = payload.Message + } + p.message.Status = aiMessageStatusError + } +} diff --git a/internal/service/system/aiRuntime.go b/internal/service/system/aiRuntime.go deleted file mode 100644 index 0d9fbd0..0000000 --- a/internal/service/system/aiRuntime.go +++ /dev/null @@ -1,115 +0,0 @@ -package system - -import ( - "context" - - "personal_assistant/internal/model/dto/request" - resp "personal_assistant/internal/model/dto/response" - "personal_assistant/internal/model/entity" -) - -// AIToolBlueprint 描述一次“工具调用计划”的静态信息。 -// 它不是工具执行结果本身,而是 runtime 预先规划好的工具蓝图。 -type AIToolBlueprint struct { - Kind string // 工具类型,如 task / progress / doc - - Key string // 工具唯一键,用于 trace、事件和 interrupt 关联 - - Title string // 前端展示用标题 - - Description string // 工具用途或执行说明 - - DurationMS int64 // 模拟或记录的执行耗时(毫秒) - - Content string // 工具执行后的摘要结果 - - DetailMarkdown string // 更完整的工具结果详情,通常用于展开查看 - - RequiresConfirmation bool // 调用该工具前是否需要用户确认 - - ConfirmationTitle string // 等待确认时展示的标题 - - ConfirmationDescription string // 等待确认时展示的说明文案 -} - -// AIRuntimePlan 表示一次 AI 会话在执行前生成的“运行计划”。 -// runtime 会先产出 plan,再按 plan 执行。 -type AIRuntimePlan struct { - Title string // 本次会话标题 - - Preview string // 会话预览文本 - - Lightweight bool // 是否为轻量对话(如问候、感谢) - - LightweightReply string // 轻量对话时可直接返回的回复 - - Scope *resp.AssistantScopeInfo // 本次回答的作用域信息 - - ShowThinkingSummary bool // 是否展示“思考摘要”区块 - - TaskTool *AIToolBlueprint // 任务快照工具计划 - - ProgressTool *AIToolBlueprint // 进度分析工具计划 - - DocTool *AIToolBlueprint // 文档摘要工具计划(可能需要确认) - - FinalReplyConfirm string // 用户确认执行文档工具后的最终回复 - - FinalReplySkip string // 用户跳过文档工具后的最终回复 -} - -// AIRuntimePlanInput 是 Plan 阶段的输入参数。 -type AIRuntimePlanInput struct { - Conversation *entity.AIConversation // 当前会话,可为空(新建会话) - - Request *request.StreamAssistantMessageReq // 本次用户请求 -} - -// AIRuntimeExecutionInput 是 Execute 阶段的输入参数。 -type AIRuntimeExecutionInput struct { - UserID uint // 当前用户 ID - - Conversation *entity.AIConversation // 当前会话实体 - - Request *request.StreamAssistantMessageReq // 本次请求参数 - - Plan *AIRuntimePlan // 预先生成好的执行计划 - - Interrupt *entity.AIInterrupt // 本次执行关联的 interrupt,可为空 - - AssistantMsgID string // 当前 assistant 消息 ID -} - -// AIRuntimeDecisionCommand 表示一次 interrupt 决策提交命令。 -type AIRuntimeDecisionCommand struct { - UserID uint // 提交决策的用户 ID - - ConversationID string // 所属会话 ID - - InterruptID string // 目标 interrupt ID - - Decision string // 用户决策,如 confirm / skip - - Reason string // 决策附带说明,可为空 -} - -// AIRuntimeSink 是 runtime 输出事件的承接端。 -// runtime 不直接操作 SSE/数据库,而是统一写给 sink。 -type AIRuntimeSink interface { - Emit(ctx context.Context, eventName string, payload any) error // 发出一个运行时事件 - - Heartbeat(ctx context.Context) error // 发送保活心跳 -} - -// AIRuntime 定义 AI 运行时需要实现的核心能力。 -type AIRuntime interface { - Plan(ctx context.Context, input AIRuntimePlanInput) (*AIRuntimePlan, error) // 生成执行计划 - - Execute(ctx context.Context, input AIRuntimeExecutionInput, sink AIRuntimeSink) error // 按计划执行并输出事件 - - SubmitDecision(ctx context.Context, cmd AIRuntimeDecisionCommand) (bool, error) // 提交 interrupt 决策 - - RevokeUser(ctx context.Context, userID uint, reason string) int // 撤销某用户当前等待中的会话 - - NodeID() string // 返回当前 runtime 所属节点 ID -} \ No newline at end of file diff --git a/internal/service/system/aiRuntimeLocal.go b/internal/service/system/aiRuntimeLocal.go deleted file mode 100644 index 54f05d2..0000000 --- a/internal/service/system/aiRuntimeLocal.go +++ /dev/null @@ -1,491 +0,0 @@ -package system - -import ( - "context" - "errors" - "os" - "strings" - "sync" - "time" - - resp "personal_assistant/internal/model/dto/response" -) - -// aiSessionSignal 表示运行时等待 interrupt 决策期间收到的一次会话信号。 -// 它既承载 confirm/skip 决策,也承载外部强制撤销信号。 -type aiSessionSignal struct { - Decision string // 用户决策 - Reason string - Revoked bool // 是不是外部强制撤销 -} - -// aiSessionRegistry 负责管理“等待用户决策”的 interrupt 会话。 -// 它通过 interruptID 和 userID 两套索引,分别解决精准唤醒和用户级批量撤销两个场景。 -type aiSessionRegistry struct { - mu sync.Mutex - byInterrupt map[string]chan aiSessionSignal - byUser map[uint]map[string]struct{} -} - -/* - 一、注册表相关函数 -*/ - -// newAISessionRegistry 负责创建本地会话注册表。 -// 作用:创建一个空的会话注册表 -func newAISessionRegistry() *aiSessionRegistry { - return &aiSessionRegistry{ - byInterrupt: make(map[string]chan aiSessionSignal), - byUser: make(map[uint]map[string]struct{}), - } -} - -// Register 负责登记一个等待决策的 interrupt,并返回监听通道与清理函数。 -// 作用:登记一个“正在等待用户确认”的 interrupt,并返回监听通道和清理函数。 -func (r *aiSessionRegistry) Register(userID uint, interruptID string) (<-chan aiSessionSignal, func()) { - // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 - // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 - r.mu.Lock() - defer r.mu.Unlock() - - ch := make(chan aiSessionSignal, 1) - r.byInterrupt[interruptID] = ch - - // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 - // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 - userSessions := r.byUser[userID] - if userSessions == nil { - userSessions = make(map[string]struct{}) - r.byUser[userID] = userSessions - } - userSessions[interruptID] = struct{}{} - - cleanup := func() { - r.mu.Lock() - defer r.mu.Unlock() - - delete(r.byInterrupt, interruptID) - if sessions := r.byUser[userID]; sessions != nil { - delete(sessions, interruptID) - if len(sessions) == 0 { - delete(r.byUser, userID) - } - } - } - // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 - // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 - return ch, cleanup -} - -// Submit 负责向某个等待中的 interrupt 投递一条决策信号。 -// 作用:给某个等待中的 interrupt 投递决策信号。 -func (r *aiSessionRegistry) Submit(interruptID string, signal aiSessionSignal) bool { - r.mu.Lock() - ch, ok := r.byInterrupt[interruptID] - r.mu.Unlock() - if !ok { - return false - } - - select { - case ch <- signal: - return true - default: - return false - } -} - -// RevokeUser 负责向某个用户当前所有等待中的 interrupt 广播撤销信号。 -// 作用:撤销某个用户当前节点上的所有等待会话。 -func (r *aiSessionRegistry) RevokeUser(userID uint, reason string) int { - r.mu.Lock() - interrupts := make([]string, 0, len(r.byUser[userID])) - for interruptID := range r.byUser[userID] { - interrupts = append(interrupts, interruptID) - } - r.mu.Unlock() - - count := 0 - for _, interruptID := range interrupts { - if r.Submit(interruptID, aiSessionSignal{Revoked: true, Reason: reason}) { - count++ - } - } - return count -} - -// LocalAIRuntime 是当前仓库用于本地演示和联调的 AI 运行时实现。 -// 根据上下文推测,它并不直接接外部 LLM,而是用规则化逻辑模拟计划、工具调用和 interrupt 流程。 -type LocalAIRuntime struct { - nodeID string - heartbeatInterval time.Duration - sessionRegistry *aiSessionRegistry -} - -/* - 二、runtime 本身相关函数 -*/ - -// NewLocalAIRuntime 负责创建本地 AI 运行时实例。 -// 作用:创建一个本地 runtime 实例。 -func NewLocalAIRuntime(heartbeatInterval time.Duration) *LocalAIRuntime { - host, err := os.Hostname() - if err != nil || strings.TrimSpace(host) == "" { - host = "local" - } - if heartbeatInterval <= 0 { - heartbeatInterval = 20 * time.Second - } - return &LocalAIRuntime{ - nodeID: host, - heartbeatInterval: heartbeatInterval, - sessionRegistry: newAISessionRegistry(), - } -} - -// NodeID 返回当前运行时实例所属的节点标识。 -// 作用:返回当前 runtime 所属节点 ID。 -func (r *LocalAIRuntime) NodeID() string { - return r.nodeID -} - -// Plan 负责基于用户输入生成本次 AI 会话的执行计划。 -// 作用:根据用户输入,先生成一份执行计划 AIRuntimePlan。 -func (r *LocalAIRuntime) Plan(_ context.Context, input AIRuntimePlanInput) (*AIRuntimePlan, error) { - if input.Request == nil { - return nil, errors.New("ai stream request is nil") - } - content := strings.TrimSpace(input.Request.Content) - if content == "" { - return nil, errors.New("ai stream content is empty") - } - - // 第一阶段:先确定会话标题,避免后续轻量和复杂场景各自重复推导标题。 - title := deriveConversationTitle("", content) - if input.Conversation != nil { - title = deriveConversationTitle(input.Conversation.Title, content) - } - - // 对非常轻量的输入直接生成固定回复,减少不必要的工具规划和 interrupt 开销。 - if isLightweightAIPrompt(content) { - reply := buildLightweightReply(content) - return &AIRuntimePlan{ - Title: title, - Preview: buildConversationPreview(content), - Lightweight: true, - LightweightReply: reply, - FinalReplyConfirm: reply, - FinalReplySkip: reply, - }, nil - } - - // 第二阶段:识别意图,并据此决定是否展示思考摘要、scope 和各种工具卡片。 - intent := detectAIIntent(content) - plan := &AIRuntimePlan{ - Title: title, - Preview: buildConversationPreview(content), - ShowThinkingSummary: intent.showThinkingSummary, - FinalReplyConfirm: buildScenarioFinalReply(content, intent, true), - FinalReplySkip: buildScenarioFinalReply(content, intent, false), - } - if intent.showScope { - plan.Scope = buildScopeInfo(input.Request) - } - - // 任务快照和训练进度属于无需用户确认的只读工具,可直接加入计划。 - if intent.wantsTaskReport { - plan.TaskTool = &AIToolBlueprint{ - Kind: "task", - Key: "tool_task_snapshot", - Title: "读取任务执行快照", - Description: "读取完成率、成员覆盖情况和当前阻塞项。", - DurationMS: 148, - Content: "已拿到最新任务快照:完成率 81%,覆盖成员 17 / 21,阻塞项 3 个。", - DetailMarkdown: "任务快照结果:\n\n- 完成率:81%\n- 覆盖成员:17 / 21\n- 阻塞项:3", - } - } - if intent.wantsProgressInsight { - plan.ProgressTool = &AIToolBlueprint{ - Kind: "progress", - Key: "tool_progress_snapshot", - Title: "读取最近训练进度", - Description: "汇总最近 7 天训练节奏、排名变化与建议方向。", - DurationMS: 126, - Content: "近 7 天新增 12 题,排名提升 2 位,建议继续集中中等题突破。", - DetailMarkdown: "训练进度结果:\n\n- 新增题目:12\n- 排名变化:+2\n- 建议方向:中等题突破", - } - } - - // 文档工具需要显式确认,是因为根据上下文推测,读取正式文档属于比纯业务数据更重的外部信息补充动作。 - if intent.wantsDocSupport { - plan.DocTool = &AIToolBlueprint{ - Kind: "doc", - Key: "tool_doc_snapshot", - Title: "读取正式项目文档", - Description: "读取 README、架构设计方案和 AI UI 改造说明。", - DurationMS: 182, - Content: "已补充正式项目文档摘要,可据此确认页面定位和后端接入方式。", - DetailMarkdown: "文档工具结果:\n\n- 助手继续挂在控制台 Workbench 中。\n- 后端继续采用单条 SSE 聊天流 + interrupt decision。", - RequiresConfirmation: true, - ConfirmationTitle: "是否继续使用“项目文档摘要”工具?", - ConfirmationDescription: "继续后会读取正式文档白名单并补充引用摘要;跳过则只基于已有业务数据继续输出。", - } - } - return plan, nil -} - -// Execute 负责按计划驱动本地 AI 运行时输出完整的流式事件序列。 -// 作用:按 plan 真正执行,并持续往 sink 发事件。 -func (r *LocalAIRuntime) Execute(ctx context.Context, input AIRuntimeExecutionInput, sink AIRuntimeSink) error { - if input.Plan == nil { - return errors.New("ai runtime plan is nil") - } - if sink == nil { - return errors.New("ai runtime sink is nil") - } - plan := input.Plan - - // 第一阶段:输出基础起始事件和无需等待用户确认的结构化块。 - if err := sink.Emit(ctx, "conversation_started", resp.AssistantConversationStartedPayload{Title: plan.Title}); err != nil { - return err - } - if plan.ShowThinkingSummary { - if err := sink.Emit(ctx, "structured_block", resp.AssistantStructuredBlockPayload{UIBlock: buildThinkingSummaryBlock(plan)}); err != nil { - return err - } - } - if plan.TaskTool != nil { - if err := r.runTool(ctx, sink, plan.TaskTool); err != nil { - return err - } - } - if plan.ProgressTool != nil { - if err := r.runTool(ctx, sink, plan.ProgressTool); err != nil { - return err - } - } - if plan.Scope != nil { - if err := sink.Emit(ctx, "structured_block", resp.AssistantStructuredBlockPayload{Scope: plan.Scope}); err != nil { - return err - } - } - - finalReply := plan.FinalReplyConfirm - if plan.DocTool != nil && input.Interrupt != nil { - // 第二阶段:先把“准备调用工具”和“等待用户确认”的 UI 信息推给前端。 - if err := sink.Emit(ctx, "structured_block", resp.AssistantStructuredBlockPayload{UIBlock: buildToolIntentBlock(plan.DocTool)}); err != nil { - return err - } - if err := sink.Emit(ctx, "structured_block", resp.AssistantStructuredBlockPayload{UIBlock: buildWaitingUserBlock(plan.DocTool)}); err != nil { - return err - } - - // 注册等待通道后再发 waiting 事件,避免用户极快提交决策时运行时还没建立监听。 - waitCh, cleanup := r.sessionRegistry.Register(input.UserID, input.Interrupt.InterruptID) - defer cleanup() - if err := sink.Emit(ctx, "tool_call_waiting_confirmation", resp.AssistantToolCallWaitingConfirmationPayload{ - InterruptID: input.Interrupt.InterruptID, - Key: plan.DocTool.Key, - Title: plan.DocTool.Title, - Description: plan.DocTool.Description, - DetailMarkdown: plan.DocTool.DetailMarkdown, - ConfirmationTitle: plan.DocTool.ConfirmationTitle, - ConfirmationDescription: plan.DocTool.ConfirmationDescription, - Actions: assistantTraceActions(plan.DocTool.Key), - }); err != nil { - return err - } - - signal, err := r.waitDecision(ctx, waitCh, sink) - if err != nil { - return err - } - if signal.Revoked { - return context.Canceled - } - - // 第三阶段:根据用户决策更新 trace 和最终回答分支。 - if signal.Decision == "skip" { - finalReply = plan.FinalReplySkip - if err := sink.Emit(ctx, "tool_call_confirmation_result", resp.AssistantToolCallConfirmationResultPayload{ - InterruptID: input.Interrupt.InterruptID, - Key: plan.DocTool.Key, - Decision: "skip", - Status: "skipped", - Description: "已跳过该工具,接下来将只基于当前已有上下文继续输出。", - }); err != nil { - return err - } - } else { - if err := sink.Emit(ctx, "tool_call_confirmation_result", resp.AssistantToolCallConfirmationResultPayload{ - InterruptID: input.Interrupt.InterruptID, - Key: plan.DocTool.Key, - Decision: "confirm", - Status: "pending", - Description: "已确认继续读取正式项目文档,准备补充最终回答。", - }); err != nil { - return err - } - if err := sink.Emit(ctx, "tool_call_finished", resp.AssistantToolCallFinishedPayload{ - Key: plan.DocTool.Key, - Description: "已完成正式项目文档摘要读取。", - DurationMS: plan.DocTool.DurationMS, - Status: "success", - Content: plan.DocTool.Content, - DetailMarkdown: plan.DocTool.DetailMarkdown, - }); err != nil { - return err - } - } - } - - // 第四阶段:把最终回复切成 token 块持续输出,模拟真实流式体验。 - for _, chunk := range splitReplyChunks(finalReply, 48) { - if err := sink.Emit(ctx, "assistant_token", resp.AssistantTokenPayload{Token: chunk}); err != nil { - return err - } - } - if err := sink.Emit(ctx, "message_completed", resp.AssistantMessageCompletedPayload{Content: finalReply}); err != nil { - return err - } - return sink.Emit(ctx, "done", map[string]any{}) -} - -/* - 五、等待/决策相关函数 -*/ - -// SubmitDecision 负责向本地会话注册表提交用户决策。 -// 作用:向本地 runtime 提交一次用户决策。 -func (r *LocalAIRuntime) SubmitDecision(_ context.Context, cmd AIRuntimeDecisionCommand) (bool, error) { - return r.sessionRegistry.Submit(cmd.InterruptID, aiSessionSignal{ - Decision: cmd.Decision, - Reason: cmd.Reason, - }), nil -} - -// RevokeUser 负责撤销某个用户当前节点上的等待会话。 -// 作用:撤销某个用户当前节点上的所有等待会话。 -func (r *LocalAIRuntime) RevokeUser(_ context.Context, userID uint, reason string) int { - return r.sessionRegistry.RevokeUser(userID, reason) -} - -// waitDecision 负责在等待用户决策期间维持心跳并监听最终信号。 -// 作用:在等待用户决策期间,一边等信号,一边持续发心跳。 -func (r *LocalAIRuntime) waitDecision(ctx context.Context, waitCh <-chan aiSessionSignal, sink AIRuntimeSink) (aiSessionSignal, error) { - ticker := time.NewTicker(r.heartbeatInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return aiSessionSignal{}, ctx.Err() - case signal := <-waitCh: - return signal, nil - case <-ticker.C: - if err := sink.Heartbeat(ctx); err != nil { - return aiSessionSignal{}, err - } - } - } -} - -// runTool 负责输出一个无需等待确认的工具执行事件。 -// 作用:执行一个“不需要用户确认”的工具。 -func (r *LocalAIRuntime) runTool(ctx context.Context, sink AIRuntimeSink, tool *AIToolBlueprint) error { - if err := sink.Emit(ctx, "tool_call_started", resp.AssistantToolCallStartedPayload{ - Key: tool.Key, - Title: tool.Title, - Description: tool.Description, - }); err != nil { - return err - } - return sink.Emit(ctx, "tool_call_finished", resp.AssistantToolCallFinishedPayload{ - Key: tool.Key, - Description: tool.Description, - DurationMS: tool.DurationMS, - Status: "success", - Content: tool.Content, - DetailMarkdown: tool.DetailMarkdown, - }) -} - -// buildLightweightReply 负责为轻量短消息生成直接回复。 -// 参数: -// - input:用户原始输入文本。 -// -// 返回值: -// - string:适合直接返回的轻量回复。 -// -// 核心流程: -// 1. 标准化输入文本。 -// 2. 根据感谢、确认等简单意图返回固定短回复。 -// -// 注意事项: -// - 轻量回复场景故意不走工具和复杂计划,是为了压缩交互延迟。 -func buildLightweightReply(input string) string { - normalized := strings.TrimSpace(input) - switch { - case strings.Contains(strings.ToLower(normalized), "thank"), strings.Contains(normalized, "谢谢"): - return "不客气。你可以继续让我总结任务、分析进度,或者解释项目文档。" - case normalized == "好" || normalized == "好的" || strings.EqualFold(normalized, "ok") || strings.EqualFold(normalized, "okay"): - return "我在。你可以继续补充任务范围、文档范围,或者直接提出下一个问题。" - default: - return "你好。我可以继续帮你整理任务进展、分析个人进度,或者解释项目文档。" - } -} - -// buildScenarioFinalReply 负责根据识别出的意图拼装最终答复模板。 -// 参数: -// - input:用户原始输入。 -// - intent:意图识别结果。 -// - confirmed:是否确认执行了文档工具。 -// -// 返回值: -// - string:最终答复文本。 -// -// 核心流程: -// 1. 依次根据任务、进度、文档等意图追加段落。 -// 2. 若没有任何结构化场景命中,则回退到直接回复。 -// -// 注意事项: -// - confirm/skip 两条路径在这里统一生成不同文本,能避免 Execute 阶段散落字符串拼接逻辑。 -func buildScenarioFinalReply(input string, intent aiIntentProfile, confirmed bool) string { - sections := make([]string, 0, 4) - if intent.wantsTaskReport { - sections = append(sections, "当前任务主线已进入收口阶段,主要阻塞集中在少数成员补齐和回归验证。\n\n- 任务完成率:81%\n- 覆盖成员:17 / 21\n- 阻塞项:3\n\n建议先补齐未完成成员,再做一轮回归验证。") - } - if intent.wantsProgressInsight { - sections = append(sections, "近 7 天训练节奏稳定,新增 12 题,排名提升 2 位。当前更值得投入的是中等题突破。\n\n- 当前排名:#5\n- 近 7 天新增:12 题\n- 重点方向:中等题\n\n建议未来三天集中完成 2 到 3 道中等题,并补齐错因总结。") - } - if intent.wantsDocSupport && confirmed { - sections = append(sections, "补充正式项目文档后,可以确认两点:\n\n- 助手继续挂在控制台 Workbench 中,不额外拆独立站点。\n- 后端接入继续采用单条 SSE 聊天流 + interrupt decision 控制接口。") - } - if intent.wantsDocSupport && !confirmed { - sections = append(sections, "本轮没有读取正式项目文档,因此页面定位和接入方式说明未纳入结果。") - } - if len(sections) == 0 { - sections = append(sections, buildDirectReply(input)) - } - return strings.Join(sections, "\n\n") -} - -// buildDirectReply 负责为未命中结构化意图的输入生成直接回复。 -// 参数: -// - input:用户原始输入。 -// -// 返回值: -// - string:直接回复文本。 -// -// 核心流程: -// 1. 先判断是否偏向文档解释需求。 -// 2. 否则回退到通用引导回复。 -// -// 注意事项: -// - 这里保留文档提示,是为了在未显式命中文档工具时仍给用户一个明确的下一步指引。 -func buildDirectReply(input string) string { - if aiDocPromptRE.MatchString(strings.TrimSpace(input)) { - return "如果你要我解释项目文档,直接告诉我要看哪份文档,或者说明你想确认页面定位、协议还是后端接入。" - } - return "这条问题目前不需要额外工具。我可以直接回答;如果你要任务、进度或文档结论,请把范围再说具体一点。" -} diff --git a/internal/service/system/aiRuntimeLocal_test.go b/internal/service/system/aiRuntimeLocal_test.go deleted file mode 100644 index 4697506..0000000 --- a/internal/service/system/aiRuntimeLocal_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package system - -import ( - "context" - "sync" - "testing" - "time" - - "personal_assistant/internal/model/dto/request" - "personal_assistant/internal/model/entity" -) - -// runtimeTestSink 是 LocalAIRuntime 测试用的最小 sink 实现。 -// 它只记录事件名,不关心 payload 细节,适合验证事件顺序和关键阶段是否发生。 -type runtimeTestSink struct { - mu sync.Mutex - eventNames []string -} - -// Emit 负责记录测试期间收到的事件名。 -// 参数: -// - _:测试场景下忽略上下文本体,只保留接口签名一致性。 -// - eventName:当前收到的事件名。 -// - _:测试场景下忽略 payload 详情。 -// -// 返回值: -// - error:当前实现始终返回 nil。 -// -// 核心流程: -// 1. 在锁内把事件名追加到切片。 -// -// 注意事项: -// - 使用互斥锁是因为 Execute 在 goroutine 中运行,事件记录与断言读取会并发发生。 -func (s *runtimeTestSink) Emit(_ context.Context, eventName string, _ any) error { - s.mu.Lock() - defer s.mu.Unlock() - s.eventNames = append(s.eventNames, eventName) - return nil -} - -// Heartbeat 负责把心跳事件也纳入测试观察范围。 -// 参数: -// - _:测试中未使用的上下文。 -// -// 返回值: -// - error:当前实现始终返回 nil。 -// -// 核心流程: -// 1. 在锁内追加一个固定的 heartbeat 标记。 -// -// 注意事项: -// - 把心跳也记下来,可以帮助排查等待决策阶段是否真的持续保活。 -func (s *runtimeTestSink) Heartbeat(_ context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - s.eventNames = append(s.eventNames, "heartbeat") - return nil -} - -// snapshot 负责复制当前已经记录到的事件列表。 -// 参数:无。 -// 返回值: -// - []string:事件名快照副本。 -// -// 核心流程: -// 1. 在锁内复制底层切片,避免把内部可变状态直接暴露给断言逻辑。 -// -// 注意事项: -// - 返回副本而不是原切片,是为了避免调用方无意修改测试 sink 的内部状态。 -func (s *runtimeTestSink) snapshot() []string { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]string, len(s.eventNames)) - copy(out, s.eventNames) - return out -} - -// TestLocalAIRuntimeExecute_ResumeAfterDecision 验证 interrupt 确认后运行时能够继续执行并输出终态事件。 -// 参数: -// - t:Go 测试上下文。 -// -// 返回值:无。 -// 核心流程: -// 1. 先构造会触发文档工具确认的请求并生成计划。 -// 2. 在 goroutine 中启动 Execute,模拟真实等待用户决策的异步流程。 -// 3. 轮询观察 waiting_confirmation 事件,确认运行时已经进入等待态。 -// 4. 提交 confirm 决策,并断言最终关键事件都出现。 -// -// 注意事项: -// - 这里显式等待 `tool_call_waiting_confirmation` 后再提交决策,是为了避免测试把“运行时尚未进入等待态”的竞态误当成业务失败。 -func TestLocalAIRuntimeExecute_ResumeAfterDecision(t *testing.T) { - runtime := NewLocalAIRuntime(10 * time.Millisecond) - req := &request.StreamAssistantMessageReq{ - ConversationID: "conv_1", - Content: "帮我整理一版任务汇报并说明助手页面定位。", - ContextUserName: "李雷", - ContextOrgName: "算法训练营", - } - - // 第一阶段:先生成计划,并确认该输入确实会命中需要确认的文档工具。 - plan, err := runtime.Plan(context.Background(), AIRuntimePlanInput{ - Conversation: &entity.AIConversation{ID: "conv_1", Title: "新建会话"}, - Request: req, - }) - if err != nil { - t.Fatalf("Plan() error = %v", err) - } - if plan.DocTool == nil { - t.Fatalf("plan.DocTool = nil, want confirmation tool") - } - - // 第二阶段:异步启动 Execute,模拟真实请求中“运行时等待决策、外部再提交决策”的并发过程。 - sink := &runtimeTestSink{} - doneCh := make(chan error, 1) - go func() { - doneCh <- runtime.Execute(context.Background(), AIRuntimeExecutionInput{ - UserID: 1, - Conversation: &entity.AIConversation{ID: "conv_1", Title: "新建会话"}, - Request: req, - Plan: plan, - Interrupt: &entity.AIInterrupt{InterruptID: "intr_1"}, - }, sink) - }() - - // 第三阶段:持续观察事件流,直到确认运行时已经进入 waiting_confirmation 状态。 - deadline := time.Now().Add(2 * time.Second) - for { - events := sink.snapshot() - found := false - for _, eventName := range events { - if eventName == "tool_call_waiting_confirmation" { - found = true - break - } - } - if found { - break - } - if time.Now().After(deadline) { - t.Fatalf("waiting_confirmation event not observed, got %v", events) - } - time.Sleep(10 * time.Millisecond) - } - - // 第四阶段:提交 confirm 决策,并等待 Execute 完整结束。 - ok, err := runtime.SubmitDecision(context.Background(), AIRuntimeDecisionCommand{ - UserID: 1, - ConversationID: "conv_1", - InterruptID: "intr_1", - Decision: "confirm", - }) - if err != nil { - t.Fatalf("SubmitDecision() error = %v", err) - } - if !ok { - t.Fatalf("SubmitDecision() ok = false, want true") - } - if err := <-doneCh; err != nil { - t.Fatalf("Execute() error = %v", err) - } - - // 第五阶段:校验关键阶段事件已经全部出现,证明等待、确认和收尾链路都已打通。 - events := sink.snapshot() - expected := []string{ - "conversation_started", - "tool_call_waiting_confirmation", - "tool_call_confirmation_result", - "message_completed", - "done", - } - for _, want := range expected { - found := false - for _, got := range events { - if got == want { - found = true - break - } - } - if !found { - t.Fatalf("event %q not found in %v", want, events) - } - } -} diff --git a/internal/service/system/aiSink.go b/internal/service/system/aiSink.go index 47a2893..7127c8e 100644 --- a/internal/service/system/aiSink.go +++ b/internal/service/system/aiSink.go @@ -3,11 +3,10 @@ package system import ( "context" "encoding/json" - "strings" "time" + aidomain "personal_assistant/internal/domain/ai" streamsse "personal_assistant/internal/infrastructure/sse" - resp "personal_assistant/internal/model/dto/response" "personal_assistant/internal/model/entity" "personal_assistant/internal/repository/interfaces" ) @@ -15,13 +14,8 @@ import ( // aiStreamSink 负责把运行时事件同时写到 SSE 输出与数据库消息状态中。 // 它是 AIService 和 AIRuntime 之间的“状态汇聚层”,保证前端看到的事件序列与库内消息快照尽量一致。 type aiStreamSink struct { - repo interfaces.AIRepository - writer streamsse.StreamWriter - message *entity.AIMessage - interrupt *entity.AIInterrupt - traceItems []resp.AssistantTraceItem - uiBlocks []resp.AssistantA2UIBlock - scope *resp.AssistantScopeInfo + writer streamsse.StreamWriter + projector *aiMessageProjector } // newAIStreamSink 负责创建一条流式执行期间使用的 sink。 @@ -30,15 +24,10 @@ func newAIStreamSink( repo interfaces.AIRepository, writer streamsse.StreamWriter, message *entity.AIMessage, - interrupt *entity.AIInterrupt, ) *aiStreamSink { return &aiStreamSink{ - repo: repo, - writer: writer, - message: message, - interrupt: interrupt, - traceItems: []resp.AssistantTraceItem{}, - uiBlocks: []resp.AssistantA2UIBlock{}, + writer: writer, + projector: newAIMessageProjector(repo, message), } } @@ -49,23 +38,22 @@ func newAIStreamSink( // - payload:事件载荷。 // // 作用:发出一个运行时事件,并把这个事件的影响同步到内存快照和数据库。 -func (s *aiStreamSink) Emit(ctx context.Context, eventName string, payload any) error { - raw, err := json.Marshal(payload) +func (s *aiStreamSink) Emit(ctx context.Context, event aidomain.Event) error { + raw, err := json.Marshal(event.Payload) if err != nil { return err } if err := s.writer.WriteEvent(ctx, &streamsse.StreamEvent{ StreamKind: streamsse.StreamKindSession, - EventName: eventName, + EventName: string(event.Name), Data: raw, OccurredAt: time.Now(), }); err != nil { return err } - - s.applyEvent(eventName, payload) - return s.persistMessage(ctx) + s.projector.applyEvent(event) + return s.projector.persistMessage(ctx) } // Heartbeat 负责向客户端发送 keepalive 心跳。 @@ -79,164 +67,17 @@ func (s *aiStreamSink) Heartbeat(ctx context.Context) error { // setStopped 负责把当前消息标记为“已中断”。 // 作用:把当前消息状态改成“已停止 / 已中断”。 func (s *aiStreamSink) setStopped() { - s.message.Status = aiMessageStatusStopped + s.projector.setStopped() } // setError 负责把当前消息标记为“失败”并记录错误文案。 // 作用:把当前消息标记为失败,并记录错误文案。 func (s *aiStreamSink) setError(message string) { - s.message.Status = aiMessageStatusError - s.message.ErrorText = message + s.projector.setError(message) } // persistMessage 负责把当前内存态消息快照写回数据库。 // 作用:把当前 sink 内存里的最新状态写回数据库。 func (s *aiStreamSink) persistMessage(ctx context.Context) error { - s.message.TraceItemsJSON = encodeJSON(s.traceItems, "[]") - s.message.UIBlocksJSON = encodeJSON(s.uiBlocks, "[]") - if s.scope == nil { - s.message.ScopeJSON = "{}" - } else { - s.message.ScopeJSON = encodeJSON(s.scope, "{}") - } - s.message.UpdatedAt = time.Now() - - if err := s.repo.UpdateMessage(ctx, s.message); err != nil { - return err - } - if s.interrupt != nil { - if err := s.repo.UpdateInterrupt(ctx, s.interrupt); err != nil { - return err - } - } - return nil -} - -// applyEvent 负责把单个运行时事件折叠到消息快照中。 -// 作用:根据事件类型,更新内存中的消息、轨迹、UI、scope、interrupt 状态。 -func (s *aiStreamSink) applyEvent(eventName string, payload any) { - switch eventName { - case "assistant_token": - // token 事件只追加正文内容,并在必要时把 idle 重新切回 loading。 - if item, ok := payload.(resp.AssistantTokenPayload); ok { - s.message.Content += item.Token - if s.message.Status == aiMessageStatusIdle { - s.message.Status = aiMessageStatusLoading - } - } - - case "tool_call_started": - // 工具开始时先写一条 pending trace,方便前端立刻显示“正在执行”。 - if item, ok := payload.(resp.AssistantToolCallStartedPayload); ok { - s.traceItems = upsertTraceItem(s.traceItems, resp.AssistantTraceItem{ - Key: item.Key, - Title: item.Title, - Description: item.Description, - Status: "pending", - }) - } - - case "tool_call_finished": - // 工具结束时用最新执行结果覆盖 trace,并在命中 interrupt 工具时推进 interrupt 状态。 - if item, ok := payload.(resp.AssistantToolCallFinishedPayload); ok { - s.traceItems = upsertTraceItem(s.traceItems, resp.AssistantTraceItem{ - Key: item.Key, - Title: existingTraceTitle(s.traceItems, item.Key, item.Key), - Description: item.Description, - Status: item.Status, - DurationMS: item.DurationMS, - Content: item.Content, - DetailMarkdown: item.DetailMarkdown, - }) - if s.interrupt != nil && s.interrupt.ToolKey == item.Key { - s.interrupt.Status = aiInterruptStatusDone - s.interrupt.UpdatedAt = time.Now() - } - } - - case "tool_call_waiting_confirmation": - // 等待确认时把消息切到 idle,表示生成暂时停住,等待用户显式决策。 - if item, ok := payload.(resp.AssistantToolCallWaitingConfirmationPayload); ok { - s.message.Status = aiMessageStatusIdle - s.traceItems = upsertTraceItem(s.traceItems, resp.AssistantTraceItem{ - Key: item.Key, - Title: item.Title, - Description: item.Description, - Status: "awaiting_confirmation", - InterruptID: item.InterruptID, - DetailMarkdown: item.DetailMarkdown, - RequiresConfirmation: true, - ConfirmationTitle: item.ConfirmationTitle, - ConfirmationDescription: item.ConfirmationDescription, - Actions: item.Actions, - }) - } - - case "tool_call_confirmation_result": - if item, ok := payload.(resp.AssistantToolCallConfirmationResultPayload); ok { - // confirm 后会重新进入加载态;skip 则保持后续由最终消息完成态覆盖。 - if item.Status == "pending" { - s.message.Status = aiMessageStatusLoading - } - - // waiting_user_block 只在等待阶段展示,一旦用户已决策就应移除,避免 UI 残留误导。 - filtered := make([]resp.AssistantA2UIBlock, 0, len(s.uiBlocks)) - for _, block := range s.uiBlocks { - if block.Type != "waiting_user_block" { - filtered = append(filtered, block) - } - } - s.uiBlocks = filtered - - // trace 里保留最终确认结果,供消息详情回放这次 interrupt 的决策路径。 - s.traceItems = upsertTraceItem(s.traceItems, resp.AssistantTraceItem{ - Key: item.Key, - Title: existingTraceTitle(s.traceItems, item.Key, item.Key), - Description: item.Description, - Status: item.Status, - InterruptID: item.InterruptID, - DetailMarkdown: item.DetailMarkdown, - }) - if s.interrupt != nil && s.interrupt.InterruptID == item.InterruptID && item.Status == "skipped" { - s.interrupt.Status = aiInterruptStatusSkipped - s.interrupt.UpdatedAt = time.Now() - } - } - - case "structured_block": - // 结构化块会影响 UI 呈现和 scope 信息,需要并入消息快照供后续列表/详情重放。 - if item, ok := payload.(resp.AssistantStructuredBlockPayload); ok { - if item.UIBlock != nil { - s.uiBlocks = upsertUIBlock(s.uiBlocks, *item.UIBlock) - } - if item.Scope != nil { - s.scope = item.Scope - } - } - - case "message_completed": - // completed 事件以最终正文为准,避免 token 逐步追加过程中出现尾部不一致。 - if item, ok := payload.(resp.AssistantMessageCompletedPayload); ok { - s.message.Content = item.Content - s.message.Status = aiMessageStatusSuccess - } - - case "error": - // error 事件直接覆盖错误状态与文案,保证数据库里能看到最终失败原因。 - if item, ok := payload.(resp.AssistantErrorPayload); ok { - s.message.Status = aiMessageStatusError - s.message.ErrorText = item.Message - } - } -} - -// existingTraceTitle 负责为 trace 更新场景找到一个稳定标题。 -// 作用:为 trace 更新场景找到一个稳定标题。 -func existingTraceTitle(items []resp.AssistantTraceItem, key string, fallback string) string { - for _, item := range items { - if item.Key == key && strings.TrimSpace(item.Title) != "" { - return item.Title - } - } - return fallback + return s.projector.persistMessage(ctx) } diff --git a/internal/service/system/aiSvc.go b/internal/service/system/aiSvc.go index 5ea8ec2..c5e1f94 100644 --- a/internal/service/system/aiSvc.go +++ b/internal/service/system/aiSvc.go @@ -7,6 +7,7 @@ import ( "time" "personal_assistant/global" + aidomain "personal_assistant/internal/domain/ai" streamsse "personal_assistant/internal/infrastructure/sse" "personal_assistant/internal/model/dto/request" resp "personal_assistant/internal/model/dto/response" @@ -19,41 +20,26 @@ import ( ) const ( - // aiMessageStatusIdle 表示消息已进入等待用户确认或等待后续动作的空闲态。 - aiMessageStatusIdle = "idle" - // aiMessageStatusLoading 表示消息仍在持续生成中。 - aiMessageStatusLoading = "loading" + aiMessageStatusLoading = aidomain.MessageStatusLoading // aiMessageStatusSuccess 表示消息已完整生成成功。 - aiMessageStatusSuccess = "success" + aiMessageStatusSuccess = aidomain.MessageStatusSuccess // aiMessageStatusError 表示消息在生成过程中失败。 - aiMessageStatusError = "error" + aiMessageStatusError = aidomain.MessageStatusError // aiMessageStatusStopped 表示消息因取消、超时或撤销被中断。 - aiMessageStatusStopped = "stopped" - - // aiInterruptStatusAwaiting 表示 interrupt 已创建,等待用户明确决策。 - aiInterruptStatusAwaiting = "awaiting_confirmation" - - // aiInterruptStatusDecision 表示用户决策已提交,运行时可以继续推进。 - aiInterruptStatusDecision = "decision_received" - - // aiInterruptStatusDone 表示需要确认的工具已经执行完成。 - aiInterruptStatusDone = "completed" - - // aiInterruptStatusSkipped 表示用户显式选择跳过该工具。 - aiInterruptStatusSkipped = "skipped" + aiMessageStatusStopped = aidomain.MessageStatusStopped ) -// AIService 负责编排 AI 会话、消息、interrupt 与流式运行时之间的业务流程。 +// AIService 负责编排 AI 会话、消息与最小流式运行时之间的业务流程。 // 它本身不直接操作 HTTP,也不直连数据库;所有持久化都通过 Repository 完成。 type AIService struct { txRunner repository.TxRunner aiRepo interfaces.AIRepository userRepo interfaces.UserRepository - runtime AIRuntime + runtime aidomain.Runtime policy streamsse.ConnectionPolicy } @@ -72,6 +58,18 @@ type AIService struct { // 注意事项: // - 这里不直接依赖 HTTP 层,而是只保留运行时策略,方便同一业务逻辑被不同入口复用。 func NewAIService(repositoryGroup *repository.Group) *AIService { + return newAIServiceWithDeps(repositoryGroup, global.AIRuntime) +} + +// NewAIServiceWithRuntime 允许外部显式注入 runtime,方便阶段性迁移和测试替身接入。 +func NewAIServiceWithRuntime(repositoryGroup *repository.Group, runtime aidomain.Runtime) *AIService { + return newAIServiceWithDeps(repositoryGroup, runtime) +} + +func newAIServiceWithDeps( + repositoryGroup *repository.Group, + runtime aidomain.Runtime, +) *AIService { policy := streamsse.ConnectionPolicy{} if global.StreamInfra != nil { policy = global.StreamInfra.Policy @@ -81,7 +79,7 @@ func NewAIService(repositoryGroup *repository.Group) *AIService { txRunner: repositoryGroup, aiRepo: repositoryGroup.SystemRepositorySupplier.GetAIRepository(), userRepo: repositoryGroup.SystemRepositorySupplier.GetUserRepository(), - runtime: NewLocalAIRuntime(policy.Normalize().HeartbeatInterval), + runtime: runtime, policy: policy.Normalize(), } } @@ -191,9 +189,10 @@ func (s *AIService) DeleteConversation(ctx context.Context, userID uint, convers // - conversationID:目标会话 ID。 // - req:流式消息请求。 // - writer:SSE 输出器。 +// // 核心流程: // 1. 先校验请求参数、会话归属和会话忙碌状态。 -// 2. 调用运行时生成 Plan,并准备用户消息、AI 消息和可选 interrupt。 +// 2. 准备用户消息和 AI 消息。 // 3. 事务化落库会话状态与初始消息,确保流开始前数据库状态完整。 // 4. 创建 sink 执行运行时,并在结束后统一做收尾。 func (s *AIService) StreamConversation( @@ -213,6 +212,9 @@ func (s *AIService) StreamConversation( if writer == nil { return bizerrors.New(bizerrors.CodeAIStreamingUnsupported) } + if s.runtime == nil { + return bizerrors.New(bizerrors.CodeAIStreamingUnsupported) + } // 第二阶段:读取会话与用户上下文,保证本次流式执行建立在合法归属和可用会话之上。 conversation, err := s.requireConversationOwner(ctx, userID, conversationID) @@ -230,10 +232,9 @@ func (s *AIService) StreamConversation( return bizerrors.New(bizerrors.CodeUserNotFound) } - // 先做 Plan,是为了把“需要哪些工具、是否要 interrupt”在真正写流之前确定下来。 - plan, err := s.runtime.Plan(ctx, AIRuntimePlanInput{Conversation: conversation, Request: req}) + historyMessages, err := s.aiRepo.ListMessagesByConversation(ctx, conversation.ID) if err != nil { - return bizerrors.WrapWithMsg(bizerrors.CodeAIRequestRejected, "AI 请求无法解析", err) + return bizerrors.Wrap(bizerrors.CodeDBError, err) } // 第三阶段:构造本次对话会产生的持久化对象,确保运行时开始前有可追踪的消息骨架。 @@ -263,123 +264,26 @@ func (s *AIService) StreamConversation( UpdatedAt: now, } - // 只有声明了需要确认的工具时才创建 interrupt,避免所有请求都落无意义的等待记录。 - var interrupt *entity.AIInterrupt - if plan.DocTool != nil && plan.DocTool.RequiresConfirmation { - interrupt = &entity.AIInterrupt{ - InterruptID: newAIID("intr"), - ConversationID: conversation.ID, - MessageID: assistantMessage.ID, - UserID: userID, - Status: aiInterruptStatusAwaiting, - ToolKey: plan.DocTool.Key, - RuntimeStateJSON: encodeJSON(map[string]any{"kind": plan.DocTool.Kind}, "{}"), - OwnerNodeID: s.runtime.NodeID(), - CreatedAt: now, - UpdatedAt: now, - } - } - // 第四阶段:事务化写入起始状态,确保“会话进入生成中”与“消息骨架落库”具备一致性。 - if err := s.persistStreamStart(ctx, conversation, user, userMessage, assistantMessage, interrupt, now); err != nil { + if err := s.persistStreamStart(ctx, conversation, user, userMessage, assistantMessage, now); err != nil { return err } // Sink 负责把运行时事件同步到 SSE 与数据库消息状态,两条链路共用同一份状态机。 - sink := newAIStreamSink(s.aiRepo, writer, assistantMessage, interrupt) - execErr := s.runtime.Execute(ctx, AIRuntimeExecutionInput{ - UserID: userID, - Conversation: conversation, - Request: req, - Plan: plan, - Interrupt: interrupt, - AssistantMsgID: assistantMessage.ID, + sink := newAIStreamSink(s.aiRepo, writer, assistantMessage) + _, execErr := s.runtime.Stream(ctx, aidomain.StreamInput{ + UserID: userID, + ConversationID: conversation.ID, + UserMessageID: userMessage.ID, + AssistantMessageID: assistantMessage.ID, + Content: strings.TrimSpace(req.Content), + History: messagesToRuntimeHistory(historyMessages), }, sink) // 所有已开始的流式请求都统一走 finishStream 收尾,避免成功和失败路径各自写一套状态处理逻辑。 return s.finishStream(ctx, conversation, sink, execErr) } -// SubmitDecision 负责接收用户对 interrupt 的决策并推进状态机。 -// 作用:接收用户对 interrupt 的决策,并推进状态机。 -func (s *AIService) SubmitDecision( - ctx context.Context, - userID uint, - conversationID, - interruptID string, - req *request.SubmitAssistantDecisionReq, -) (*resp.AssistantInterruptDecisionAcceptedResp, error) { - if req == nil { - return nil, bizerrors.New(bizerrors.CodeInvalidParams) - } - if req.ConversationID != conversationID || req.InterruptID != interruptID { - return nil, bizerrors.NewWithMsg(bizerrors.CodeInvalidParams, "conversation_id 或 interrupt_id 与路径参数不一致") - } - - // 先确认当前用户确实拥有这次会话,避免借 interrupt ID 越权推进他人流程。 - if _, err := s.requireConversationOwner(ctx, userID, conversationID); err != nil { - return nil, err - } - interrupt, err := s.aiRepo.GetInterruptByID(ctx, interruptID) - if err != nil { - return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) - } - if interrupt == nil || interrupt.ConversationID != conversationID || interrupt.UserID != userID { - return nil, bizerrors.New(bizerrors.CodeAIInterruptNotFound) - } - if interrupt.Status != aiInterruptStatusAwaiting { - return nil, bizerrors.New(bizerrors.CodeAIInterruptConflict) - } - - // 运行时如果已经不再等待该 interrupt,会返回 unavailable;这时数据库不能继续盲目推进状态。 - ok, submitErr := s.runtime.SubmitDecision(ctx, AIRuntimeDecisionCommand{ - UserID: userID, - ConversationID: conversationID, - InterruptID: interruptID, - Decision: req.Decision, - Reason: req.Reason, - }) - if submitErr != nil { - return nil, bizerrors.Wrap(bizerrors.CodeInternalError, submitErr) - } - if !ok { - return nil, bizerrors.New(bizerrors.CodeAIInterruptUnavailable) - } - - // 决策被运行时接收后,再把数据库状态推进为“已收到决策”,保证线上状态与持久化状态一致。 - interrupt.Decision = req.Decision - interrupt.Reason = strings.TrimSpace(req.Reason) - interrupt.Status = aiInterruptStatusDecision - interrupt.UpdatedAt = time.Now() - if err := s.aiRepo.UpdateInterrupt(ctx, interrupt); err != nil { - return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) - } - return &resp.AssistantInterruptDecisionAcceptedResp{ - Accepted: true, - ConversationID: conversationID, - InterruptID: interruptID, - Decision: req.Decision, - }, nil -} - -// RevokeUserSessions 负责撤销某个用户当前节点上的运行中会话。 -// 参数: -// - ctx:链路上下文。 -// - userID:目标用户 ID。 -// - reason:撤销原因。 -// -// 返回值: -// - int:实际被撤销的会话数量。 -// -// 核心流程: -// 1. 直接委托运行时做用户级别的会话撤销。 -// -// 注意事项: -// - 这里不直接更新数据库,是因为撤销后的消息状态和会话状态要由运行时收尾链路统一落库。 -func (s *AIService) RevokeUserSessions(ctx context.Context, userID uint, reason string) int { - return s.runtime.RevokeUser(ctx, userID, reason) -} - // requireConversationOwner 负责校验当前用户是否拥有指定会话。 // 作用:撤销某个用户当前节点上的运行中会话。 func (s *AIService) requireConversationOwner(ctx context.Context, userID uint, conversationID string) (*entity.AIConversation, error) { @@ -401,27 +305,30 @@ func (s *AIService) persistStreamStart( user *entity.User, userMessage *entity.AIMessage, assistantMessage *entity.AIMessage, - interrupt *entity.AIInterrupt, now time.Time, ) error { - // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 - // 把前置判断集中在这里,是为了避免后续主逻辑夹杂过多防御性分支。 - conversation.Title = deriveConversationTitle(conversation.Title, userMessage.Content) - conversation.Preview = buildConversationPreview(userMessage.Content) - conversation.IsGenerating = true - // 第二阶段:进入当前函数的主体逻辑,逐步组装中间结果或推进状态。 - // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 - conversation.LastMessageAt = &now - conversation.UpdatedAt = now - conversation.OrgID = user.CurrentOrgID - - // 第三阶段:统一收口结果、状态更新或返回动作,保证对外行为一致。 - // 把收尾逻辑显式标出来,可以降低后续维护时遗漏边界处理的风险。 return s.txRunner.InTx(ctx, func(tx any) error { txAI := s.aiRepo.WithTx(tx) + lockedConversation, err := txAI.GetConversationByIDForUpdate(ctx, conversation.ID) + if err != nil { + return err + } + if lockedConversation == nil || lockedConversation.UserID != conversation.UserID { + return bizerrors.New(bizerrors.CodeAIConversationNotFound) + } + if lockedConversation.IsGenerating { + return bizerrors.New(bizerrors.CodeAIConversationBusy) + } + + lockedConversation.Title = deriveConversationTitle(lockedConversation.Title, userMessage.Content) + lockedConversation.Preview = buildConversationPreview(userMessage.Content) + lockedConversation.IsGenerating = true + lockedConversation.LastMessageAt = &now + lockedConversation.UpdatedAt = now + lockedConversation.OrgID = user.CurrentOrgID // 会话状态先更新,是为了保证后续若消息已落库,列表页也能立即看到“生成中”态。 - if err := txAI.UpdateConversation(ctx, conversation); err != nil { + if err := txAI.UpdateConversation(ctx, lockedConversation); err != nil { return err } if err := txAI.CreateMessage(ctx, userMessage); err != nil { @@ -430,13 +337,7 @@ func (s *AIService) persistStreamStart( if err := txAI.CreateMessage(ctx, assistantMessage); err != nil { return err } - - // interrupt 只在需要确认工具时创建,避免普通问答链路出现无意义等待记录。 - if interrupt != nil { - if err := txAI.CreateInterrupt(ctx, interrupt); err != nil { - return err - } - } + *conversation = *lockedConversation return nil }) } @@ -457,13 +358,16 @@ func (s *AIService) finishStream( sink *aiStreamSink, execErr error, ) error { + finishCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + now := time.Now() conversation.IsGenerating = false conversation.LastMessageAt = &now conversation.UpdatedAt = now // 收尾状态更新失败要记录日志,但如果主流程已经出错,不再让收尾错误覆盖原始执行错误。 - if err := s.aiRepo.UpdateConversation(ctx, conversation); err != nil { + if err := s.aiRepo.UpdateConversation(finishCtx, conversation); err != nil { global.Log.Error("更新 AI 会话收尾状态失败", zap.String("conversation_id", conversation.ID), zap.Error(err)) if execErr == nil { return bizerrors.Wrap(bizerrors.CodeDBError, err) @@ -481,7 +385,7 @@ func (s *AIService) finishStream( // 取消或超时属于“被中断”而不是“系统故障”,因此只标 stopped,不再额外发错误提示。 if errors.Is(execErr, context.Canceled) || errors.Is(execErr, context.DeadlineExceeded) { sink.setStopped() - _ = sink.persistMessage(ctx) + _ = sink.persistMessage(finishCtx) return nil } @@ -493,8 +397,8 @@ func (s *AIService) finishStream( } sink.setError(message) - _ = sink.Emit(ctx, "error", resp.AssistantErrorPayload{Message: message}) - _ = sink.Emit(ctx, "done", map[string]any{}) + _ = sink.Emit(ctx, aidomain.Event{Name: aidomain.EventError, Payload: aidomain.ErrorPayload{Message: message}}) + _ = sink.Emit(ctx, aidomain.Event{Name: aidomain.EventDone, Payload: map[string]any{}}) return nil } diff --git a/pkg/casbin/casbin.go b/pkg/casbin/casbin.go index 3c8cab5..8aa19ca 100644 --- a/pkg/casbin/casbin.go +++ b/pkg/casbin/casbin.go @@ -22,7 +22,7 @@ const ( type Permission struct { Subject string // 权限主体(如用户ID@组织ID) Object string // 权限对象(如资源ID) - Action string // 权限动作(如访问,读取,操作) + Action string // 权限动作(如访问,读取,操作) } type PolicySnapshot struct { diff --git a/plan/ai/approved-ai-architecture-overview-doc.md b/plan/ai/approved-ai-architecture-overview-doc.md new file mode 100644 index 0000000..8075ea3 --- /dev/null +++ b/plan/ai/approved-ai-architecture-overview-doc.md @@ -0,0 +1,42 @@ +# 目标 + +基于当前仓库真实代码,新增一篇 AI 架构概览文档,帮助维护者理解 AI 子域的入口、分层、运行时、SSE、落库、工具调用、用户确认和恢复链路。 + +# 范围 + +- 新增文档:`docs/ai-architecture-overview.md` +- 只分析 AI 子域,不泛化分析整个项目。 +- 覆盖 Router、Controller、Service、Runtime、Sink、SSE、Repository、Entity、Eino 基础设施、控制面与恢复。 + +# 改动 + +- 新增 `docs/ai-architecture-overview.md`。 +- 文档固定包含: + - AI 架构总览 + - 核心链路流程 + - 关键目录与文件说明 + - 关键函数说明 + - Mermaid 图 + - 面试时如何介绍这套 AI 架构 +- 不修改 Go 代码、配置、OpenAPI 或现有设计文档。 + +# 验证 + +- 检查目标文档已创建。 +- 检查文档包含 `AICtrl`、`AIService`、`AIRuntime`、`aiStreamSink`、SSE、DB、interrupt、resume 等关键字。 +- 本次为纯文档新增,不运行 Go 测试。 + +# 风险 + +- 文档依赖当前代码实现,后续 AI runtime 或协议变更时需要同步更新。 + +# 执行顺序 + +1. 将本文件改名为 `approved-ai-architecture-overview-doc.md`。 +2. 新增 `docs/ai-architecture-overview.md`。 +3. 做只读关键字检查。 +4. 回报新增文件和验证结果。 + +# 待确认 + +用户已明确要求 `PLEASE IMPLEMENT THIS PLAN`,本计划按已确认执行。 diff --git a/plan/ai/approved-ai-chinese-comments.md b/plan/ai/approved-ai-chinese-comments.md new file mode 100644 index 0000000..0d603f6 --- /dev/null +++ b/plan/ai/approved-ai-chinese-comments.md @@ -0,0 +1,39 @@ +# 目标 + +为本次 AI 子域最小流式对话重构新增的代码补充中文注释,风格对齐 `AICtrl.StreamConversation` 这类“职责、参数、返回值、核心流程、注意事项”的说明。 + +# 范围 + +- `internal/domain/ai/*` +- `internal/infrastructure/ai/local/*` +- `internal/infrastructure/ai/eino/*` +- `internal/core/ai.go` +- 如有必要,补充 `internal/service/system/aiProjector.go` 中新增最小投影逻辑的中文注释。 + +# 改动 + +- 只补充注释,不改业务逻辑、接口签名、配置、路由、DTO 或数据库结构。 +- 注释优先覆盖导出的类型、导出函数、核心私有函数和关键流程。 +- 避免无意义注释;只解释职责、输入输出、执行流程和边界约束。 + +# 验证 + +- 执行 `gofmt`。 +- 执行 `go test ./internal/domain/ai/...`。 +- 执行 `go test ./internal/infrastructure/ai/...`。 +- 执行 `go test ./internal/service/system/...`。 + +# 风险 + +- 仅注释变更,业务风险低。 +- 需要避免注释和实际行为不一致。 + +# 执行顺序 + +1. 审核新增文件中缺少中文注释的位置。 +2. 逐文件补充中文注释。 +3. 格式化并运行相关测试。 + +# 待确认 + +等待用户确认后,将本计划改名为 `approved-ai-chinese-comments.md` 并实施。 diff --git a/plan/ai/approved-ai-current-implementation-doc.md b/plan/ai/approved-ai-current-implementation-doc.md new file mode 100644 index 0000000..07de881 --- /dev/null +++ b/plan/ai/approved-ai-current-implementation-doc.md @@ -0,0 +1,45 @@ +# 目标 + +把当前 AI 子域的实现方式和未来扩展点写入正式文档,方便后续开发、联调和评审时有统一说明。 + +# 范围 + +- 目标文档:`docs/AI助手架构设计方案.md` +- 说明范围: + - 当前 AI 请求链路如何从 Router / Controller / Service 进入 Runtime。 + - 当前 `AIRuntime`、`EinoAIRuntime`、`LocalAIRuntime` 的职责边界。 + - 当前工具能力、SSE 事件、Interrupt / Resume、持久化与控制面如何协作。 + - 后续可扩展方向。 + +# 改动 + +- 在 `docs/AI助手架构设计方案.md` 中新增一节“当前实现说明与扩展点”。 +- 内容以现有代码为准,不引入未实现接口结论。 +- 不修改 Go 代码、不修改 OpenAPI、不修改配置。 + +# 验证 + +- 只做文档修改,验证方式为人工阅读和路径引用检查。 +- 确认新增内容与当前关键代码路径一致: + - `internal/controller/system/aiCtrl.go` + - `internal/service/system/aiSvc.go` + - `internal/service/system/aiRuntime*.go` + - `internal/infrastructure/ai/eino/*` + - `internal/repository/system/aiRepo.go` + - `internal/model/entity/ai.go` + +# 风险 + +- 若后续代码快速演进,文档可能需要同步更新。 +- 当前说明会避免过细函数级实现,减少和代码漂移的概率。 + +# 执行顺序 + +1. 将本计划从 `pending-` 改为 `approved-`。 +2. 编辑 `docs/AI助手架构设计方案.md`。 +3. 检查新增章节的标题层级、路径引用和术语一致性。 +4. 回报修改摘要。 + +# 待确认 + +请确认是否按本计划执行文档修改。 diff --git a/plan/ai/approved-basic-streaming-chat.md b/plan/ai/approved-basic-streaming-chat.md new file mode 100644 index 0000000..6fd5dea --- /dev/null +++ b/plan/ai/approved-basic-streaming-chat.md @@ -0,0 +1,52 @@ +# 目标 + +将 AI 子域从 Plan、Tool、A2UI、Interrupt/Resume 的复杂 Agent Runtime 收缩为最基础的流式对话闭环: + +```text +Controller/Router -> Service -> domain/ai Runtime/Sink/Event -> infrastructure/ai Runtime -> SSE -> DB message 落库 +``` + +# 范围 + +- 保留现有 MVC 外壳:AI Router、Controller、Service、Repository。 +- 新增 `internal/domain/ai` 作为 AI 子域最小稳定协议层。 +- 新增/迁移基础 runtime 到 `internal/infrastructure/ai/local` 和 `internal/infrastructure/ai/eino`。 +- 保留基础 5 个 AI API 和 SSE 流式输出。 +- 删除或停用 A2UI、Plan、Tool、Interrupt、Decision、Resume、Task/Progress/Docs 工具链路。 +- 不做 DB 表结构删除;`ai_interrupts` 表和 `trace_items_json/ui_blocks_json/scope_json` 字段先保留兼容。 + +# 改动 + +- 新增 domain runtime/sink/event/message 类型,domain 只依赖标准库。 +- 将 runtime 契约收缩为 `Name()` 和 `Stream(ctx, input, sink)`。 +- Service 不再调用 Plan,不创建 interrupt,只创建 user message 和 assistant message,再调用 runtime stream。 +- Sink/projector 只处理 `conversation_started`、`assistant_token`、`message_completed`、`error`、`done`。 +- 删除 decision 路由、Controller、Service、DTO 使用。 +- 删除旧 planner、intent、control plane、runtimecontrol、Eino tool/approval/checkpoint/docs/task/progress 链路。 + +# 验证 + +- `go test ./internal/domain/ai/...` +- `go test ./internal/infrastructure/ai/...` +- `go test ./internal/service/system/...` +- `go test ./internal/controller/system/...` +- `go test ./internal/router/system/...` +- `go test ./...` + +# 风险 + +- 前端若仍依赖 `structured_block` 或 decision API,会出现功能缺口;本阶段由前端同步收缩。 +- 历史 interrupt 数据会保留;本阶段不 drop 表,避免数据库迁移扩大范围。 +- Eino 当前实现与工具链耦合较深;先实现无工具基础流式 runtime,必要时回退 local。 + +# 执行顺序 + +1. 新增 domain 协议和基础 runtime。 +2. 接入 Service 最小流式流程。 +3. 收缩 HTTP/API 面。 +4. 删除旧复杂链路。 +5. 逐步运行测试并修复编译问题。 + +# 待确认 + +用户已明确要求实施该计划。 diff --git a/plan/ai/approved-ci-lint-fix.md b/plan/ai/approved-ci-lint-fix.md new file mode 100644 index 0000000..b1b1ce0 --- /dev/null +++ b/plan/ai/approved-ci-lint-fix.md @@ -0,0 +1,59 @@ +# 目标 + +修复当前本地复现 CI 时暴露的 `golangci-lint` 失败问题,让仓库可以通过 `.github/workflows/ci.yml` 中的本地可复现检查。 + +# 范围 + +仅处理本次 CI 检测明确暴露的问题: + +- 删除 AI 最小流式重构后遗留的未使用函数。 +- 对 lint 报出的格式问题文件执行 `gofmt`。 +- 不新增功能,不调整 API,不改数据库结构,不恢复已删除的 plan/tool/interrupt/resume/A2UI 链路。 + +# 改动 + +计划修改以下文件: + +- `internal/service/system/aiMapper.go` + - 删除未使用的 `encodeJSON`。 + - 删除未使用的 `splitReplyChunks`。 + - 删除未使用的 `buildScopeInfo`。 +- `internal/service/system/aiSink.go` + - 删除未使用的 `(*aiStreamSink).applyEvent`。 +- `internal/infrastructure/sse/interfaces.go` + - 执行 `gofmt`。 +- `internal/infrastructure/sse/types.go` + - 执行 `gofmt`。 +- `internal/middleware/corsMW.go` + - 执行 `gofmt`。 +- `pkg/casbin/casbin.go` + - 执行 `gofmt`。 + +# 验证 + +按 CI 顺序复跑: + +```powershell +go mod download +& 'C:\Program Files\Git\usr\bin\bash.exe' scripts/check_no_legacy_error_tracking.sh +go test ./... +go vet ./... +golangci-lint run --timeout=5m +``` + +# 风险 + +- 删除项都是 `unused` 报出的未引用函数,风险较低。 +- `gofmt` 会产生格式化 diff,可能触碰到非 AI 文件,但只限 lint 已报告的格式问题文件。 + +# 执行顺序 + +1. 将本计划从 `pending` 改名为 `approved`。 +2. 删除 AI 相关未使用函数。 +3. 对 lint 报出的格式文件执行 `gofmt`。 +4. 复跑 CI 本地命令。 +5. 汇总结果。 + +# 待确认 + +请确认是否按本计划执行。 diff --git a/plan/cross-module/approved-readme-rewrite.md b/plan/cross-module/approved-readme-rewrite.md new file mode 100644 index 0000000..5688545 --- /dev/null +++ b/plan/cross-module/approved-readme-rewrite.md @@ -0,0 +1,168 @@ +# README 重写方案(展示 + 面试取向) + +## Summary + +- 将 `README.md` 从“基础启动说明”升级为“项目入口页”,主线固定为 **Go 后端项目介绍**,优先服务 GitHub 展示和面试讲解。 +- 改造方式采用 **整体重写**:保留现有可用的快速开始、Docker、CLI、相关文档、安全提醒,但按当前真实代码状态重排结构。 +- 所有内容以代码为准,不补脑、不拔高;明确区分: + - **代码已落地** + - **待确认 / 规划中** + - **外部依赖边界** + +## Key Changes + +- README 统一使用中文,语气改为“架构先行、模块清晰、可直接拿来讲项目”,不写成营销文案。 +- 开头改成 3 段式: + - 一句话介绍项目是什么 + - 这个项目解决什么问题 + - 为什么它不是简单 CRUD +- 章节结构固定为下面这版,不再沿用当前“亮点堆列表”的写法: + +1. 项目简介 + 写一句话介绍 + 2~3 句定位说明,明确这是 **模块化单体 Go 后端**,不是微服务,也不是纯后台管理系统。 + +2. 项目定位 + 说明它覆盖: + - 用户/组织/RBAC 权限治理 + - OJ 数据同步与任务化运营 + - AI 会话/SSE 助手能力 + - 图片与可观测性等工程治理 + 这里要直接说“更接近业务型平台后端”。 + +3. 核心能力 + 分三组写,不再混排: + - 核心功能:用户认证、组织管理、RBAC、OJ、OJ 任务、AI 会话 + - 支撑能力:图片、SSE、可观测性、限流、缓存 + - 工程能力:Outbox、Redis Stream、分布式锁、自动迁移、CLI、Docker + 每项只写 1 句作用,不展开成审计报告。 + +4. 架构总览 + 明确写出架构结论: + - 模块化单体 + - 分层架构 / Handler-Service-Repository + - 业务真相在 MySQL,投影在 Redis/Casbin + - 异步解耦采用 Outbox + Redis Stream + Pub/Sub + - AI 子域采用 Eino runtime + SSE 单流 + 用一段文字图表示启动链: + `cmd/main -> internal/init -> core/infrastructure -> repository -> service -> controller -> router -> gin server` + +5. 核心模块 + 按模块写职责,不写目录树大抄: + - 启动与基础设施 + - 接口层 + - 用户与认证 + - 组织与权限 + - OJ 业务 + - OJ 任务 + - AI/Agent + - 图片与存储 + - 可观测性 + - 消息与异步 + 每个模块写“职责 + 关键目录”两部分即可。 + +6. 核心链路 + 固定写三条: + - 请求流 + - 数据流 + - 异步流 + 再补 2~4 个典型业务链路: + - 注册 + - OJ 绑定 + - OJ 任务执行 + - AI 会话流式输出 + +7. 技术栈与依赖 + 保留当前技术栈,但改成“框架 / 存储 / 权限 / 异步 / AI / 工程化”分组。 + 依赖边界要明确写: + - MySQL、Redis 为必需 + - OJ crawler 为可选外部依赖 + - 七牛为可选存储驱动 + +8. 快速开始 + 保留并校正现有内容: + - 前置依赖 + - `.env.example -> .env` + - 本地启动 + - 自动迁移 / `--sql` + - Docker 启动 + 这里不扩写部署理论,只保留可执行说明。 + +9. 配置说明 + 不枚举所有 env,但要归纳配置组: + - System / JWT / Session + - MySQL / Redis + - Storage / Static + - Crawler + - Observability + - Task / Messaging / SSE / AI + 并说明“具体键以 `.env.example` 和 `configs/configs.yaml` 为准”。 + +10. 接口分组概览 + 用“分组 + 示例接口”的方式重写,不再只列老接口。至少覆盖: + - 健康检查 + - 登录注册与刷新 + - 用户业务 + - 组织业务 + - 系统权限管理 + - OJ + - OJ Task + - AI 会话 / SSE + - 图片 + - 可观测性 + 继续保留“当前前缀不完全统一”的提醒,不伪装成已完全规范化。 + +11. 当前完成度与边界 + 新增一节,明确: + - 已落地主链路:用户/组织/权限、OJ、OJ 任务、AI 会话、图片、观测 + - 不宜过度表述:完整多 Agent 平台、微服务化、完整 OpenAPI 治理 + - 外部依赖边界:OJ 数据依赖 crawler + +12. 相关文档 + 保留并扩充 docs 索引,优先挂这些: + - 事件驱动架构 + - Casbin RBAC + - 双 Token + - AI 架构设计 + - SSE 基础设施 + - 图片管理 + - flag 指令 + +13. 安全提醒 + 保留现有内容,压缩成 3~4 条高信号提示。 + +14. License + 若仓库仍无 `LICENSE` 文件,则继续明确写“当前未提供 LICENSE”。 + +- 当前 README 中这些内容要弱化或重写: + - 过长的“项目亮点”平铺列表 + - 过粗的目录树说明 + - 未覆盖 AI / OJ Task / Observability 的接口概览 + - 容易被理解成“已经完全平台化”的表述 + +## Public Interfaces / Docs Impact + +- **不修改任何代码、接口、配置键或路由行为**。 +- 只调整 `README.md` 的文档表达,文档中记录的接口、配置、依赖必须严格对应现有实现。 +- README 对外口径固定为: + - 单体应用 + - 业务型平台后端 + - AI 子域已落地主链路,但不是完整通用多 Agent 平台 + +## Test Plan + +- 逐项核对 README 中提到的启动链与初始化顺序,依据 `cmd/main.go`、`internal/init/init.go`、`internal/router/router.go`。 +- 核对 README 中列出的模块与服务职责,确保能在 `internal/service/system`、`internal/core`、`internal/router/system` 找到直接对应。 +- 核对所有接口分组与示例路径,确保 AI、OJ Task、Observability、Health 等现有路由都被覆盖且不写错。 +- 核对快速开始命令、CLI 参数、Docker 说明与 `.env.example`、`deploy/docker-compose.prod.yml`、`flag/*` 一致。 +- 核对 docs 链接全部存在,避免 README 出现失效文档路径。 +- 检查最终 README 是否满足两个验收标准: + - 新读者 1 分钟内能看懂“项目做什么、架构怎么分、有什么亮点” + - 面试场景下可以直接拿 README 作为项目讲稿的提纲 + +## Assumptions + +- 受众默认是 **展示 + 面试**,不是纯维护手册。 +- 改造方式默认是 **整体重写**,不是在原结构上打补丁。 +- README 默认不新增徽章、截图、许可证声明或不存在的部署能力。 +- 对 AI 能力的表述默认采取保守口径:只讲当前代码已落地的会话、SSE、interrupt/decision、Eino runtime 主链。 +- 若后续进入执行阶段,先按仓库规则落盘计划到 `plan/cross-module/pending-readme-rewrite.md`,再实施文档修改。 From bef16db6be725f13e656945f82cda46e3ab79a14 Mon Sep 17 00:00:00 2001 From: wang Date: Tue, 21 Apr 2026 20:05:15 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=E6=96=B0=E5=A2=9Eflag-mysql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flag/flagSql.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/flag/flagSql.go b/flag/flagSql.go index 6da0da2..bf9ac7f 100644 --- a/flag/flagSql.go +++ b/flag/flagSql.go @@ -88,6 +88,12 @@ func SQL() error { if err := seedBuiltinRoles(); err != nil { return err } + if err := seedBuiltinAssistantMenu(); err != nil { + return err + } + if err := seedBuiltinAssistantRoleMenus(); err != nil { + return err + } if err := seedBuiltinCapabilities(); err != nil { return err } @@ -104,6 +110,17 @@ func SQL() error { return migrateOrgMemberLifecycleData(db) } +const ( + builtinAssistantMenuCode = "console:assistant" + builtinAssistantMenuName = "AI助手" + builtinAssistantMenuIcon = "RobotOutlined" + builtinAssistantMenuRouteName = "ConsoleAssistant" + builtinAssistantMenuRoutePath = "/console/assistant" + builtinAssistantMenuComponentPath = "@/views/Console/Workbench/AssistantWorkbench.vue" + builtinAssistantMenuDesc = "AI 助手工作台" + builtinAssistantMenuSort = 15 +) + // normalizeMenuAPISingleBinding 将同一 api_id 的多条菜单绑定裁剪为一条(保留最小 menu_id)。 // func normalizeMenuAPISingleBinding() error { // if !global.DB.Migrator().HasTable("menu_apis") { @@ -171,6 +188,77 @@ func seedBuiltinRoles() error { return nil } +// seedBuiltinAssistantMenu 确保 AI 助手菜单存在,便于前端把工作台入口纳入 RBAC 菜单权限。 +func seedBuiltinAssistantMenu() error { + record := entity.Menu{ + ParentID: 0, + Name: builtinAssistantMenuName, + Code: builtinAssistantMenuCode, + Icon: builtinAssistantMenuIcon, + Type: 2, + RouteName: builtinAssistantMenuRouteName, + RoutePath: builtinAssistantMenuRoutePath, + ComponentPath: builtinAssistantMenuComponentPath, + Status: 1, + Sort: builtinAssistantMenuSort, + Desc: builtinAssistantMenuDesc, + } + + var existing entity.Menu + query := global.DB.Unscoped(). + Where("code = ?", builtinAssistantMenuCode). + Or("route_path = ?", builtinAssistantMenuRoutePath) + if err := query.First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return global.DB.Create(&record).Error + } + return err + } + + updates := map[string]any{ + "name": builtinAssistantMenuName, + "code": builtinAssistantMenuCode, + "icon": builtinAssistantMenuIcon, + "type": 2, + "route_name": builtinAssistantMenuRouteName, + "route_path": builtinAssistantMenuRoutePath, + "component_path": builtinAssistantMenuComponentPath, + "status": 1, + "sort": builtinAssistantMenuSort, + "desc": builtinAssistantMenuDesc, + } + if existing.DeletedAt.Valid { + updates["deleted_at"] = nil + } + + return global.DB.Unscoped().Model(&existing).Updates(updates).Error +} + +// seedBuiltinAssistantRoleMenus 把 AI 助手菜单补给内置组织角色,维持当前“登录后可用”的默认体验。 +func seedBuiltinAssistantRoleMenus() error { + var assistantMenu entity.Menu + if err := global.DB.Where("code = ?", builtinAssistantMenuCode).First(&assistantMenu).Error; err != nil { + return err + } + + roleCodes := []string{consts.RoleCodeOrgAdmin, consts.RoleCodeMember} + for _, roleCode := range roleCodes { + var role entity.Role + if err := global.DB.Where("code = ?", roleCode).First(&role).Error; err != nil { + return err + } + if err := global.DB.Exec( + "INSERT IGNORE INTO role_menus (role_id, menu_id) VALUES (?, ?)", + role.ID, + assistantMenu.ID, + ).Error; err != nil { + return err + } + } + + return nil +} + // seedBuiltinCapabilities 初始化系统内置 capability(幂等)。 func seedBuiltinCapabilities() error { // 第一阶段:先处理入口参数、依赖或前置状态,尽早挡住不能继续推进的情况。 From c488c43f71210c0c5063afa78551fa6d6f70679a Mon Sep 17 00:00:00 2001 From: wang Date: Tue, 21 Apr 2026 20:18:05 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=E5=AE=8C=E5=96=84readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 359 ++++++++++++++++-------------------------------------- 1 file changed, 104 insertions(+), 255 deletions(-) diff --git a/README.md b/README.md index f7a5df8..5eb8a8d 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,63 @@ # Personal Assistant -> 一个以 Go + Gin 为核心的模块化单体后端,围绕“用户/组织/RBAC 权限治理 + OJ 数据同步与任务化运营 + AI 会话/SSE 流式助手 + 图片与可观测性治理”构建的业务型平台后端。 - -它解决的不是单一后台管理问题,而是把账号体系、组织协作、权限控制、OJ 数据运营、AI 助手能力和一批工程治理能力整合到一套后端里。 - -从代码现状看,本项目更适合被定义为“面向业务场景的 Go 平台后端”,不是微服务,也不是纯 CRUD 管理台。 - -## 为什么它不是简单 CRUD - -- 权限不是直接把规则写死在接口里,而是基于 `user-org-role` 关系表建模,DB 作为真相源,Casbin 作为权限投影层。 -- OJ 模块不是单次查询接口,而是覆盖绑定、同步、统计、曲线、排行榜和任务化运营。 -- OJ 任务支持任务版本化、定时调度、执行抢占和冻结快照,属于可追溯执行链路。 -- AI 子域已经落地了会话持久化、SSE 单流输出、interrupt/decision 与 Eino runtime 主链。 -- 异步收敛不是简单 goroutine,而是基于 Outbox + Redis Stream + Pub/Sub 组织跨模块最终一致性。 - -## 项目定位 - -- 用户/组织/RBAC 权限治理:覆盖账号生命周期、组织成员关系、角色/菜单/API/capability 权限管理。 -- OJ 数据同步与任务化运营:支持 LeetCode / Luogu / Lanqiao 账号绑定、排行榜、统计曲线和任务执行。 -- AI 会话/SSE 助手能力:提供会话管理、流式输出、人工决策回填和 Eino runtime 接入。 -- 图片与可观测性治理:覆盖图片上传删除、双存储驱动、链路埋点、指标查询和任务追踪。 - -## 核心能力 - -### 核心功能 - -- 用户认证:支持注册、登录、刷新 Token、登出、资料维护、密码修改、主动注销。 -- 组织管理:支持创建组织、加入组织、退出组织、切换当前组织、踢出成员、恢复成员。 -- RBAC 权限:支持角色、菜单、API 管理和 capability 建模,并将权限投影到 Casbin。 -- OJ 数据:支持 OJ 账号绑定、排行榜、统计数据、成绩曲线和题库同步。 -- OJ 任务:支持标题分析建任务、立即执行、版本派生、重试和执行结果追踪。 -- AI 会话:支持会话 CRUD、消息列表、SSE 流式输出和 interrupt/decision 决策恢复。 - -### 支撑能力 - -- 图片存储:支持本地 / 七牛双驱动、上传限流、批量删除和孤儿图片清理。 -- SSE 基础设施:支持连接管理、回放、Pub/Sub backplane 和多连接控制。 -- 可观测性:支持 HTTP/Gorm/任务 trace、指标聚合和查询接口。 -- 缓存与限流:支持活跃态缓存、OJ 绑定限流、上传限流、排行榜缓存。 - -### 工程能力 - -- 事件驱动收敛:基于 Outbox + Redis Stream + Pub/Sub 处理权限投影、缓存投影和异步任务触发。 -- 调度与协作:支持 Cron、Redis 分布式锁、任务抢占和消费者分组。 -- 初始化治理:支持自动迁移、Repository/Service/Controller 装配、基础设施统一初始化。 -- 运行与运维:支持 CLI、Docker、本地/生产部署配置和日志落盘。 - -## 架构总览 - -### 架构结论 - -- 模块化单体 -- 分层架构 / Handler-Service-Repository -- 业务真相在 MySQL,投影在 Redis / Casbin -- 异步解耦采用 Outbox + Redis Stream + Pub/Sub -- AI 子域采用 Eino runtime + SSE 单流控制 +> 一个基于 Go 的后端项目,聚焦用户与组织管理、RBAC 权限控制、OJ 刷题数据同步、图片资源管理。 + +## 项目亮点 + +- **用户认证**:支持注册、登录、登出、刷新 Token,采用双 Token 方案与 HttpOnly Refresh Token。 +- **组织与权限**:覆盖组织、角色、菜单、API 管理,支持用户-组织-角色关系建模。 +- **RBAC**:基于 Casbin 做权限校验,并通过权限投影链路支撑多实例下的策略收敛。 +- **OJ 能力**:支持 LeetCode / Luogu 账号绑定、异步同步、排行榜查询与缓存投影。 +- **图片管理**:支持本地 / 七牛双存储驱动,包含上传限流、孤儿文件清理等治理能力。 +- **事件驱动一致性**:基于 Redis Stream + Outbox + 双通道收口,用于异步解耦、投影刷新和多实例收敛。 +- **第三方基础设施接入**:在 `internal/infrastructure` 中统一封装 LeetCode / Luogu / Lanqiao 客户端,采用配置驱动,便于替换和扩展。 +- **可观测性**:提供请求链路追踪、GORM/任务埋点和指标聚合查询能力。 +- **稳定性治理**:集成七牛存储熔断、上传限流、Redis 分布式锁与任务协调。 +- **排行榜设计**:基于 Redis ZSet + 用户快照投影 + 读模型聚合查询,兼顾查询性能与异步收敛。 +- **框架设计**:按 Controller / Service / Repository / Router / Core / Infrastructure / pkg 分层,拆分业务编排、数据访问与基础设施初始化职责。 + +## 技术栈 + +- 语言与框架:Go, Gin +- 数据层:Gorm, MySQL +- 缓存与消息:Redis, Redis Stream +- 权限:Casbin +- 配置与日志:Viper, Zap +- 其他:Resty, robfig/cron, urfave/cli -### 启动链 +## 目录结构 ```text -cmd/main - -> internal/init - -> internal/core / internal/infrastructure - -> internal/repository - -> internal/service - -> internal/controller - -> internal/router - -> gin server +. +├── cmd/ # 程序入口 +├── configs/ # 配置文件(yaml + casbin model) +├── internal/ +│ ├── controller/ # HTTP 控制器 +│ ├── service/ # 业务逻辑 +│ ├── repository/ # 数据访问层 +│ ├── router/ # 路由注册 +│ ├── middleware/ # 中间件 +│ ├── infrastructure/ # 外部服务接入与消息基础设施(LeetCode/Luogu/Lanqiao/Outbox) +│ └── core/ # 启动流程、配置、日志、数据库、任务初始化 +├── pkg/ # 公共组件(jwt、response、storage、errors 等) +├── docs/ # 项目文档 +├── docker-compose.yaml +└── Dockerfile ``` -### 运行时关键点 - -- 入口从 `cmd/main.go` 启动,主初始化编排在 `internal/init/init.go`。 -- `internal/core` 负责配置、日志、Gorm、Redis、Casbin、Storage、SSE、Observability、Outbox Relay、Cron 等基础设施初始化。 -- `internal/router/router.go` 负责中间件挂载和路由分组,业务路由在 `internal/router/system` 中按领域拆分。 -- `internal/service/system` 负责业务编排,`internal/repository` 负责数据库与投影数据访问。 - -## 核心模块 - -- 启动与基础设施 - 职责:配置、日志、Gorm、Redis、Casbin、Storage、SSE、Outbox、Cron 初始化。 - 关键目录:`cmd/`、`internal/init/`、`internal/core/` - -- 接口层 - 职责:Gin 路由注册、DTO 接参、统一响应、鉴权中间件接入。 - 关键目录:`internal/router/`、`internal/controller/`、`internal/middleware/` - -- 用户与认证 - 职责:注册登录、双 Token、账号状态、资料维护、当前组织上下文。 - 关键目录:`internal/service/system/userSvc.go`、`jwtSvc.go`、`baseSvc.go` - -- 组织与权限 - 职责:组织管理、成员关系、角色/菜单/API/capability 管理、权限判断与权限投影。 - 关键目录:`internal/service/system/orgSvc.go`、`roleSvc.go`、`menuSvc.go`、`authorizationSvc.go`、`permissionProjectionSvc.go` - -- OJ 业务 - 职责:LeetCode / Luogu / Lanqiao 账号绑定、数据同步、统计、排行、曲线、题库预热。 - 关键目录:`internal/service/system/ojSvc.go`、`ojLanqiaoSvc.go`、`internal/infrastructure/leetcode/`、`luogu/`、`lanqiao/` - -- OJ 任务 - 职责:任务分析、版本管理、调度执行、执行快照、用户命中详情查询。 - 关键目录:`internal/service/system/ojTaskSvc.go`、`ojTaskDispatcher.go` - -- AI / Agent - 职责:会话管理、消息持久化、流式输出、interrupt/decision、runtime 抽象与 Eino 接入。 - 关键目录:`internal/service/system/aiSvc.go`、`aiRuntime*.go`、`internal/infrastructure/ai/eino/` - -- 图片与存储 - 职责:图片上传、列表、删除、孤儿治理、本地 / 七牛驱动切换。 - 关键目录:`internal/service/system/imageSvc.go`、`pkg/storage/`、`pkg/imageops/` - -- 可观测性 - 职责:请求追踪、Gorm Trace、任务 Trace、指标聚合和查询接口。 - 关键目录:`internal/core/observability.go`、`internal/middleware/observabilityMW.go`、`pkg/observability/` - -- 消息与异步 - 职责:Outbox Relay、Redis Stream 消费、缓存投影、权限投影、OJ 日统计投影。 - 关键目录:`internal/core/outboxRelay.go`、`internal/core/subscriberInit.go`、`internal/infrastructure/outbox/`、`internal/infrastructure/messaging/` - -## 核心链路 - -### 请求流 - -客户端请求先经过 `RequestID / Observability / Logger / Recovery / CORS` 等全局中间件,再根据分组进入 `JWTAuth / ActiveUser / PermissionMiddleware`。Controller 负责接参与响应,Service 负责业务编排,Repository 负责落 MySQL / Redis,最终由 `pkg/response` 统一返回。 - -### 数据流 - -业务真相主要落在 MySQL,例如 `users / orgs / user_org_roles / roles / menus / apis / oj_* / ai_* / outbox_events`。Redis 负责缓存、排行榜、SSE 回放、消息消费、分布式锁和 trace stream。Casbin 只承担权限投影,不承担业务真相。 - -### 异步流 - -业务写库时同步写 Outbox;Relay 把 Outbox 事件推到 Redis Stream;Subscriber 消费后做权限投影、缓存投影、OJ 日统计修复和 OJ 任务触发。定时任务则负责全量同步、排行重建、禁用账号清理和孤儿图片清理。 - -### 典型业务链路 - -1. 注册 - 创建用户后,会补齐组织关系、默认角色、权限/缓存投影,完成账号初始状态收口。 - -2. OJ 绑定 - 调用外部 crawler 服务拉取数据,Upsert 用户 OJ 明细,再刷新排行缓存并发布后续投影事件。 - -3. OJ 任务执行 - 调度器扫描待执行任务,通过 Redis 锁抢占执行权,生成任务快照并写入执行结果。 - -4. AI 会话流式输出 - 先创建会话,再通过 `POST /ai/conversations/:id/stream` 进入流式对话,运行时会写消息骨架、推送 SSE,并在需要时通过 interrupt/decision 恢复执行。 - -## 技术栈与依赖 - -- 框架:Go、Gin -- 存储:MySQL、Gorm、Redis -- 权限:Casbin -- 异步:Redis Stream、Pub/Sub、Cron -- AI:CloudWeGo Eino、Qwen / OpenAI 兼容接入 -- 工程化:Viper、Zap、Resty、urfave/cli、Qiniu SDK - -### 依赖边界 - -- MySQL、Redis:必需依赖 -- OJ crawler 服务:可选外部依赖,使用 OJ 功能时必需 -- 七牛云存储:可选依赖,不使用时可退回本地存储 - -## 快速开始 +## 快速开始(本地) ### 1. 前置依赖 -- Go `1.23+`,项目中启用了 `toolchain go1.24.9` +- Go `>= 1.24`(`go.mod` 中配置了 `toolchain go1.24.9`) - MySQL `8.x` - Redis `6+` -- 可选:OJ crawler 服务 +- 可选:OJ 爬虫服务(用于 LeetCode/洛谷数据接口) ### 2. 配置环境变量 +复制环境变量模板: + ```bash # Linux / macOS cp .env.example .env @@ -180,15 +66,15 @@ cp .env.example .env Copy-Item .env.example .env ``` -至少确认以下配置可用: +然后按你的环境修改 `.env`,至少确认: -- `DB_HOST / DB_PORT / DB_NAME / DB_USERNAME / DB_PASSWORD` -- `REDIS_ADDRESS / REDIS_PASSWORD / REDIS_DB` -- `JWT_ACCESS_TOKEN_SECRET / JWT_REFRESH_TOKEN_SECRET` -- `SYSTEM_HOST / SYSTEM_PORT` -- `CRAWLER_LEETCODE_BASE_URL / CRAWLER_LUOGU_BASE_URL / CRAWLER_LANQIAO_BASE_URL` +- `DB_HOST/DB_PORT/DB_NAME/DB_USERNAME/DB_PASSWORD` +- `REDIS_ADDRESS/REDIS_PASSWORD/REDIS_DB` +- `JWT_ACCESS_TOKEN_SECRET/JWT_REFRESH_TOKEN_SECRET` +- `SYSTEM_HOST/SYSTEM_PORT` +- `CRAWLER_LEETCODE_BASE_URL/CRAWLER_LUOGU_BASE_URL`(如使用 OJ 功能) -### 3. 本地启动 +### 3. 启动服务 ```bash go mod tidy @@ -199,16 +85,14 @@ go run cmd/main.go ### 4. 数据库初始化 -- `AUTO_MIGRATE=true` 时,启动时会自动迁移表结构。 -- 也可以手动执行: +- 默认 `AUTO_MIGRATE=true` 时会自动迁移表结构。 +- 也可手动执行: ```bash go run cmd/main.go --sql ``` -### 5. Docker 启动 - -本地容器启动: +## Docker 启动 ```bash docker compose up -d --build @@ -216,36 +100,16 @@ docker compose up -d --build 说明: -- 根目录 `docker-compose.yaml` 当前只编排 `app` 服务。 -- `deploy/docker-compose.prod.yml` 提供生产场景下的 `app + web` 样例。 -- MySQL / Redis 需要你自行提供并保证网络可达。 - -## 配置说明 - -配置主要由 `.env.example` 和 `configs/configs.yaml` 驱动,建议按下面这些组来理解: - -- `system / jwt / session`:服务监听、环境、双 Token、Session -- `mysql / redis`:数据库、缓存与连接池 -- `storage / static`:本地静态目录、七牛存储、图片限制 -- `crawler`:LeetCode / Luogu / Lanqiao 外部接口地址与超时重试 -- `observability`:指标、trace、清理周期、采样与脱敏策略 -- `task / messaging`:定时任务、Outbox、Redis Stream、分布式锁 -- `sse / ai`:SSE 心跳与回放、AI provider、model、checkpoint、runtime 命令通道 - -具体键名和默认值,请以: - -- `.env.example` -- `configs/configs.yaml` - -为准。 +- 当前 `docker-compose.yaml` 只包含 `app` 服务。 +- MySQL / Redis 需要你自行提供并确保容器内可访问(例如通过 `host.docker.internal` 或同网络服务名)。 ## 命令行工具 -项目内置 CLI 参数,一次仅支持一个: +项目内置 CLI 参数(一次仅支持一个): -- `--sql`:初始化/迁移数据库结构 -- `--sql-export`:导出 SQL 数据 -- `--sql-import `:导入 SQL 文件 +- `--sql`:初始化/迁移数据库表结构 +- `--sql-export`:导出 MySQL 数据(依赖名为 `mysql` 的 Docker 容器) +- `--sql-import `:从 SQL 文件导入数据 示例: @@ -253,79 +117,64 @@ docker compose up -d --build go run cmd/main.go --sql-import .\backup.sql ``` -说明:`--admin` 标志在当前代码中有声明,但主执行分支尚未接入,不建议视为已完成能力。 - -## 认证方式 - -- Access Token 支持: - - 请求头 `x-access-token` - - 或 `Authorization: Bearer ` -- Refresh Token 默认走 HttpOnly Cookie:`x-refresh-token` - ## 接口分组概览 -> 当前代码中的路由前缀并未完全统一,下面按真实路由分组列示,不能简单理解为都挂在同一个 `/api` 前缀下。 - -| 分组 | 示例接口 | 权限要求 | -| --- | --- | --- | -| 健康检查 | `GET /api/v1/health`、`GET /api/v1/ping` | 无 | -| 基础服务 | `POST /base/captcha`、`POST /base/sendEmailVerificationCode` | 无 | -| 用户认证 | `POST /user/register`、`POST /user/login`、`POST /refreshToken` | 无 | -| 用户业务 | `POST /user/logout`、`PUT /user/profile`、`PUT /user/phone`、`PUT /user/password`、`POST /user/deactivate` | JWT | -| 组织业务 | `GET /system/org/my`、`PUT /system/org/current`、`POST /system/org/join`、`POST /system/org/leave` | JWT | -| 系统权限管理 | `/system/api/*`、`/system/menu/*`、`/system/role/*`、`/system/org/*`、`/system/user/*` | JWT + 权限 | -| OJ | `POST /oj/bind`、`POST /oj/lanqiao/bind`、`POST /oj/ranking_list`、`POST /oj/stats`、`POST /oj/curve` | JWT | -| OJ Task | `POST /oj/task/analyze`、`POST /oj/task`、`GET /oj/task/list`、`POST /oj/task/:id/execute-now` | JWT | -| AI 会话 | `POST /ai/conversations`、`GET /ai/conversations`、`GET /ai/conversations/:id/messages`、`DELETE /ai/conversations/:id` | JWT | -| AI SSE | `POST /ai/conversations/:id/stream`、`POST /ai/conversations/:id/interrupts/:interrupt_id/decision` | JWT | -| 图片 | `POST /api/system/image/upload`、`DELETE /api/system/image/delete`、`GET /api/system/image/list` | JWT | -| 可观测性 | `GET /system/observability/traces/detail/:id`、`POST /system/observability/traces/query`、`POST /system/observability/metrics/query` | JWT + 权限 | +> 当前路由前缀并非完全统一,以下为主要分组。 + +- 公共接口(无需 JWT) +- `POST /base/captcha` +- `POST /base/sendEmailVerificationCode` +- `POST /user/register` +- `POST /user/login` +- `POST /refreshToken` + +- 业务接口(需 JWT) +- `POST /user/logout` +- `PUT /user/profile` +- `PUT /user/phone` +- `PUT /user/password` +- `POST /oj/bind` +- `POST /oj/ranking_list` +- `POST /oj/stats` +- `POST /api/system/image/upload` +- `DELETE /api/system/image/delete` +- `GET /api/system/image/list` +- `GET /system/org/my` +- `PUT /system/org/current` + +- 系统管理接口(需 JWT + 权限) +- `/system/api/*` +- `/system/menu/*` +- `/system/role/*` +- `/system/org/*` +- `/system/user/list` +- `/system/user/{id}` +- `/system/user/{id}/roles` +- `/system/user/assign_role` + +## 认证说明 -## 当前完成度与边界 - -### 已落地主链路 - -- 用户 / 组织 / 权限主链路 -- OJ 绑定、统计、曲线、排行主链路 -- OJ 任务版本化、调度、执行与快照主链路 -- AI 会话、SSE、interrupt/decision、Eino runtime 主链路 -- 图片治理、可观测性、Outbox 异步收敛主链路 - -### 不宜过度表述的部分 - -- 当前代码是单体应用,不是微服务体系 -- AI 子域已落地主链路,但不应表述为完整通用多 Agent 平台 -- 路由前缀与接口治理并未完全统一,不能表述为完整 OpenAPI 平台 - -### 外部依赖边界 - -- OJ 数据依赖外部 crawler 服务 -- 七牛仅是可选存储驱动,不是必需组件 -- 生产部署编排样例存在,但完整生产环境仍需自行补齐 MySQL / Redis / 网关等外围设施 +- Access Token 支持: +- 请求头 `x-access-token` +- 或 `Authorization: Bearer ` +- Refresh Token 默认使用 HttpOnly Cookie:`x-refresh-token` ## 相关文档 - `docs/事件驱动架构-RedisStream-Outbox-双通道一致性实践.md` - `docs/Casbin-RBAC权限系统架构文档.md` - `docs/双Token认证方案-整合版.md` -- `docs/AI助手架构设计方案.md` -- `docs/SSE实时推送基础设施重构指导文档.md` - `docs/图片管理-技术文档.md` - `docs/图片上传流.md` - `docs/flag指令.md` -如果你是面试阅读者,建议优先看: - -1. `事件驱动架构-RedisStream-Outbox-双通道一致性实践` -2. `Casbin-RBAC权限系统架构文档` -3. `AI助手架构设计方案` +第三方集成、可观测性、排行榜与框架整体设计文档持续补充中。 -## 安全提醒 +## 安全提醒(公开仓库前建议先做) -- 发布公开仓库前,务必替换 `.env`、`.env.example` 和配置文件中的密钥、密码与第三方凭证。 -- 建议轮换数据库密码、Redis 密码、JWT 密钥、邮箱密钥、对象存储密钥。 -- 确保 `.env` 不会被提交;当前仓库已在 `.gitignore` 中忽略。 -- `configs/configs.yaml` 中存在历史示例值,实际部署时应统一改为安全配置。 +- 全量替换 `.env` / `.env.example` / 配置文件中的真实密钥与密码。 +- 轮换数据库密码、Redis 密码、JWT 密钥、邮箱密钥、云存储密钥。 +- 确保 `.env` 不会被提交(已在 `.gitignore` 中忽略)。 ## License From 80d2b2fcade838016b9c8c2610be177229a8669f Mon Sep 17 00:00:00 2001 From: wang Date: Tue, 21 Apr 2026 20:50:59 +0800 Subject: [PATCH 07/17] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E8=B5=B0=E9=80=9A=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 225 ++++++-- ...6\226\275\350\256\241\345\210\222-Qwen.md" | 72 --- ...257\345\257\271\346\216\245-go-eino-V1.md" | 31 -- ...76\350\256\241\346\226\271\346\241\210.md" | 480 ------------------ ...66\346\236\204\351\242\204\350\247\210.md" | 382 -------------- ...66\346\236\204\346\213\206\345\210\206.md" | 3 +- docs/AI/stage3_agent_runtime_assessment.md | 73 --- ...66\346\256\265\346\274\224\350\277\233.md" | 29 ++ .../approved-env-template-and-ai-config.md | 82 +++ 9 files changed, 302 insertions(+), 1075 deletions(-) delete mode 100644 "docs/AI/AI\345\212\251\346\211\213\344\270\213\344\270\200\351\230\266\346\256\265\345\256\236\346\226\275\350\256\241\345\210\222-Qwen.md" delete mode 100644 "docs/AI/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" delete mode 100644 "docs/AI/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" delete mode 100644 "docs/AI/AI\346\236\266\346\236\204\351\242\204\350\247\210.md" delete mode 100644 docs/AI/stage3_agent_runtime_assessment.md create mode 100644 "docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" create mode 100644 plan/cross-module/approved-env-template-and-ai-config.md diff --git a/.env.example b/.env.example index d5d4514..cdd65c8 100644 --- a/.env.example +++ b/.env.example @@ -3,64 +3,126 @@ # ================================================================ # 使用方法: # 1. 复制此文件为 .env -# 2. 填入实际的值(引号可选,但包含特殊字符时建议加引号) +# 2. 按注释填入真实值;未启用的可选能力可保留默认值或空值 # 3. .env 文件已被 .gitignore 忽略,不会提交到版本库 # ================================================================ -# ======================== 数据库配置 ======================== -# MySQL 连接信息 +# ======================== 启动必填:数据库 ======================== DB_HOST=127.0.0.1 DB_PORT=3306 -DB_NAME=your_db_name -DB_USERNAME=your_db_user -DB_PASSWORD=your_db_password +DB_NAME=meta_assist +DB_USERNAME=root +DB_PASSWORD= +DB_CONFIG=charset=utf8mb4&parseTime=True&loc=Local +DB_MAX_IDLE_CONNS=10 +DB_MAX_OPEN_CONNS=100 +DB_LOG_MODE=silent -# ======================== Redis 配置 ======================== +# ======================== 启动必填:Redis ======================== REDIS_ADDRESS=127.0.0.1:6379 -REDIS_PASSWORD=your_redis_password +REDIS_PASSWORD= REDIS_DB=0 +REDIS_ACTIVE_USER_STATE_TTL_SECONDS=1800 +REDIS_ACTIVE_USER_STATE_TTL_JITTER_SECONDS=300 -# ======================== JWT 认证配置 ======================== -# 访问令牌密钥(建议使用 64 位随机字符串) +# ======================== 启动必填:JWT / Session ======================== +# 访问/刷新密钥建议使用至少 64 位随机字符串 JWT_ACCESS_TOKEN_SECRET=replace_with_a_64_char_random_string -# 刷新令牌密钥(建议使用 64 位随机字符串) JWT_REFRESH_TOKEN_SECRET=replace_with_another_64_char_random_string +JWT_ACCESS_TOKEN_EXPIRY_TIME=2h +JWT_REFRESH_TOKEN_EXPIRY_TIME=15d +JWT_ISSUER=personal_assistant +SYSTEM_SESSIONS_SECRET=replace_with_session_secret -# ======================== Session 配置 ======================== -SYSTEM_SESSIONS_SECRET=your_session_secret_here +# ======================== 启动必填:系统基础配置 ======================== +SYSTEM_HOST=0.0.0.0 +SYSTEM_PORT=9000 +SYSTEM_ENV=dev +SYSTEM_ROUTER_PREFIX=controller +SYSTEM_USE_MULTIPOINT=true +AUTO_MIGRATE=true + +# ======================== 可选:敏感数据编解码器 ======================== +# 仅在你需要启用敏感字段加密/哈希时填写真实 Base64 密钥 +SECURITY_SENSITIVE_DATA_ENABLED=false +SECURITY_SENSITIVE_DATA_CIPHER_PREFIX=enc:v1: +SECURITY_SENSITIVE_DATA_AES_KEY_BASE64= +SECURITY_SENSITIVE_DATA_HASH_KEY_BASE64= -# ======================== 邮件配置 ======================== +# ======================== 推荐补齐:AI / SSE ======================== +# 若希望真实大模型回答,请将 SSE_AI_RUNTIME_MODE 保持为 eino 并填写 AI_API_KEY。 +# 若仅想让服务先启动并使用本地兜底 runtime,可改为 local。 +SSE_AI_RUNTIME_MODE=eino +AI_PROVIDER=qwen +AI_API_KEY= +AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +AI_MODEL=qwen-plus +AI_BY_AZURE=false +AI_API_VERSION= +AI_SYSTEM_PROMPT=你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。 +AI_TEMPERATURE=0.2 +AI_MAX_COMPLETION_TOKENS=1200 +SSE_HEARTBEAT_INTERVAL_SECONDS=20 +SSE_WRITE_TIMEOUT_SECONDS=10 +SSE_QUEUE_CAPACITY=64 +SSE_MAX_CONNECTIONS_PER_SUBJECT=3 +SSE_REPLAY_LIMIT=100 +SSE_DRAIN_TIMEOUT_SECONDS=15 +SSE_PUBSUB_CHANNEL_PREFIX=sse +SSE_REPLAY_STREAM_PREFIX=sse:replay +# 逗号分隔;不写则使用代码默认值 +# SSE_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# ======================== 存储配置 ======================== +# 本地开发建议先使用 local,避免七牛配置缺失导致初始化失败 +STORAGE_CURRENT=local +STORAGE_LOCAL_BASE_URL= +STORAGE_LOCAL_KEY_PREFIX= +STORAGE_QINIU_BUCKET= +STORAGE_QINIU_DOMAIN= +STORAGE_QINIU_KEY_PREFIX= +STORAGE_QINIU_ACCESS_KEY= +STORAGE_QINIU_SECRET_KEY= + +# ======================== 邮件 / 第三方登录 / 地图 ======================== EMAIL_HOST=smtp.qq.com EMAIL_PORT=465 -EMAIL_FROM=your_email@example.com +EMAIL_FROM= EMAIL_NICKNAME=个人助手 -EMAIL_SECRET=your_email_app_password +EMAIL_SECRET= EMAIL_IS_SSL=true -# ======================== 七牛云存储配置 ======================== -# 当前使用的存储驱动:local 或 qiniu -STORAGE_CURRENT=qiniu -# 七牛云配置(使用 qiniu 驱动时必填) -STORAGE_QINIU_BUCKET=your_qiniu_bucket -STORAGE_QINIU_DOMAIN=https://your-cdn-domain.example.com -STORAGE_QINIU_ACCESS_KEY=your_qiniu_access_key -STORAGE_QINIU_SECRET_KEY=your_qiniu_secret_key - -# ======================== 高德地图配置 ======================== GAODE_ENABLE=false -GAODE_KEY=your_gaode_api_key +GAODE_KEY= -# ======================== QQ 登录配置 ======================== QQ_ENABLE=false -QQ_APP_ID=your_qq_app_id -QQ_APP_KEY=your_qq_app_key +QQ_APP_ID= +QQ_APP_KEY= QQ_REDIRECT_URI=http://localhost:8002/callback/qq # ======================== 爬虫服务配置 ======================== CRAWLER_LEETCODE_BASE_URL=http://127.0.0.1:8001 CRAWLER_LEETCODE_API_PREFIX=/v2 +LEETCODE_TIMEOUT_MS=10000 +LEETCODE_MAX_IDLE_CONNS=100 +LEETCODE_MAX_IDLE_CONNS_PER_HOST=40 +LEETCODE_IDLE_CONN_TIMEOUT_SEC=90 +LEETCODE_RETRY_COUNT=2 +LEETCODE_RETRY_WAIT_MS=200 +LEETCODE_RETRY_MAX_WAIT_MS=2000 +LEETCODE_RESPONSE_BODY_LIMIT_BYTES=2097152 + CRAWLER_LUOGU_BASE_URL=http://127.0.0.1:8001 CRAWLER_LUOGU_API_PREFIX=/v2 +LUOGU_TIMEOUT_MS=8000 +LUOGU_MAX_IDLE_CONNS=200 +LUOGU_MAX_IDLE_CONNS_PER_HOST=100 +LUOGU_IDLE_CONN_TIMEOUT_SEC=90 +LUOGU_RETRY_COUNT=2 +LUOGU_RETRY_WAIT_MS=200 +LUOGU_RETRY_MAX_WAIT_MS=2000 +LUOGU_RESPONSE_BODY_LIMIT_BYTES=4194304 + CRAWLER_LANQIAO_BASE_URL=http://127.0.0.1:8001 CRAWLER_LANQIAO_API_PREFIX=/v2 LANQIAO_TIMEOUT_MS=10000 @@ -72,16 +134,36 @@ LANQIAO_RETRY_WAIT_MS=200 LANQIAO_RETRY_MAX_WAIT_MS=2000 LANQIAO_RESPONSE_BODY_LIMIT_BYTES=4194304 -# ======================== 系统配置 ======================== -SYSTEM_HOST=0.0.0.0 -SYSTEM_PORT=9000 -SYSTEM_ENV=dev -# 自动迁移数据库表结构 -AUTO_MIGRATE=true +# ======================== 上传 / 静态文件 / 验证码 ======================== +UPLOAD_SIZE=20 +UPLOAD_PATH=uploads + +STATIC_PATH=./static/images +STATIC_PREFIX=/images +STATIC_MAX_SIZE=16 +STATIC_MAX_UPLOADS=15 + +CAPTCHA_HEIGHT=80 +CAPTCHA_WIDTH=240 +CAPTCHA_LENGTH=6 +CAPTCHA_MAX_SKEW=0.7 +CAPTCHA_DOT_COUNT=80 + +# ======================== 限流配置 ======================== +RATE_LIMIT_UPLOAD_GLOBAL_LIMIT=100 +RATE_LIMIT_UPLOAD_GLOBAL_WINDOW_SEC=60 +RATE_LIMIT_UPLOAD_USER_LIMIT=10 +RATE_LIMIT_UPLOAD_USER_WINDOW_SEC=60 +RATE_LIMIT_OJ_BIND_LIMIT=3 +RATE_LIMIT_OJ_BIND_WINDOW_SEC=10 # ======================== Observability 配置 ======================== OBSERVABILITY_ENABLED=true OBSERVABILITY_SERVICE_NAME=personal_assistant +OBSERVABILITY_SERVICE_TRACE_ENABLED=true +# 逗号分隔;不写则使用代码默认值 +# OBSERVABILITY_SERVICE_TRACE_MODULES=jwt,user,oj,image,observability +OBSERVABILITY_PROPAGATION_ENABLED=true OBSERVABILITY_PROPAGATION_REQUEST_ID_HEADER=X-Request-ID OBSERVABILITY_PROPAGATION_PARSE_W3C=true OBSERVABILITY_PROPAGATION_INJECT_W3C=true @@ -114,6 +196,8 @@ OBSERVABILITY_TRACES_MAX_DETAIL_BYTES=4096 OBSERVABILITY_TRACES_SUCCESS_RETENTION_DAYS=5 OBSERVABILITY_TRACES_ERROR_RETENTION_DAYS=10 OBSERVABILITY_TRACES_CLEANUP_CRON=30 2 * * * +# 逗号分隔;不写则使用代码默认值 +# OBSERVABILITY_TRACES_REDACT_KEYS=password,token,authorization,cookie,secret,apikey,access_token,refresh_token # ======================== Task / Messaging 配置 ======================== TASK_OUTBOX_CLEANUP_RETENTION_DAYS=7 @@ -121,5 +205,74 @@ TASK_OUTBOX_FAILED_CLEANUP_RETENTION_DAYS=30 TASK_DISTRIBUTED_LOCK_ENABLED=true TASK_DISTRIBUTED_LOCK_TTL_SECONDS=30 TASK_IMAGE_ORPHAN_CLEANUP_CRON=@daily +TASK_LUOGU_QUESTION_BANK_WARMUP_ENABLED=true +TASK_LUOGU_QUESTION_BANK_WARMUP_BATCH_SIZE=500 +TASK_LUOGU_QUESTION_BANK_WARMUP_LOCK_TTL_SECONDS=300 +TASK_LEETCODE_QUESTION_BANK_WARMUP_ENABLED=true +TASK_LEETCODE_QUESTION_BANK_WARMUP_BATCH_SIZE=500 +TASK_LEETCODE_QUESTION_BANK_WARMUP_LOCK_TTL_SECONDS=300 +TASK_LUOGU_SYNC_USER_INTERVAL_SECONDS=10 +TASK_LEETCODE_SYNC_USER_INTERVAL_SECONDS=10 +TASK_LEETCODE_SYNC_INTERVAL_SECONDS=3600 +TASK_RANKING_SYNC_INTERVAL_SECONDS=3600 +TASK_OJ_DAILY_STATS_REPAIR_CRON=@daily +TASK_OJ_DAILY_STATS_REPAIR_BATCH_SIZE=100 +TASK_OJ_DAILY_STATS_REPAIR_WINDOW_DAYS=35 +TASK_OJ_TASK_DISPATCH_ENABLED=true +TASK_OJ_TASK_DISPATCH_INTERVAL_SECONDS=10 +TASK_OJ_TASK_DISPATCH_BATCH_SIZE=10 +TASK_OJ_TASK_DISPATCH_WORKER_COUNT=1 +TASK_OJ_TASK_SNAPSHOT_INSERT_BATCH_SIZE=500 +TASK_OJ_TASK_EXECUTION_LOCK_TTL_SECONDS=60 +TASK_DISABLED_USER_CLEANUP_ENABLED=true +TASK_DISABLED_USER_RETENTION_DAYS=30 +TASK_DISABLED_USER_CLEANUP_CRON=@daily + +MESSAGING_REDIS_STREAM_READ_COUNT=1 +MESSAGING_REDIS_STREAM_BLOCK_MS=5000 MESSAGING_OUTBOX_RELAY_LOCK_ENABLED=true MESSAGING_OUTBOX_RELAY_LOCK_TTL_SECONDS=15 +MESSAGING_LUOGU_BIND_TOPIC=luogu.bind +MESSAGING_LUOGU_BIND_GROUP=luogu_bind_group +MESSAGING_LUOGU_BIND_CONSUMER=luogu_bind_consumer +MESSAGING_LEETCODE_BIND_TOPIC=leetcode.bind +MESSAGING_LEETCODE_BIND_GROUP=leetcode_bind_group +MESSAGING_LEETCODE_BIND_CONSUMER=leetcode_bind_consumer +MESSAGING_OJ_QUESTION_UPSERT_TOPIC=oj_question_upsert +MESSAGING_OJ_QUESTION_UPSERT_GROUP=oj_question_upsert_group +MESSAGING_OJ_QUESTION_UPSERT_CONSUMER=oj_question_upsert_consumer +MESSAGING_OJ_DAILY_STATS_PROJECTION_TOPIC=oj_daily_stats_projection +MESSAGING_OJ_DAILY_STATS_PROJECTION_GROUP=oj_daily_stats_projection_group +MESSAGING_OJ_DAILY_STATS_PROJECTION_CONSUMER=oj_daily_stats_projection_consumer +MESSAGING_CACHE_PROJECTION_TOPIC=cache_projection +MESSAGING_CACHE_PROJECTION_GROUP=cache_projection_group +MESSAGING_CACHE_PROJECTION_CONSUMER=cache_projection_consumer + +# ======================== 网站 / 日志(通常可保持默认) ======================== +WEBSITE_LOGO= +WEBSITE_FULL_LOGO= +WEBSITE_TITLE=个人助手 +WEBSITE_SLOGAN=标题 +WEBSITE_SLOGAN_EN=Blog Title +WEBSITE_DESCRIPTION=描述 +WEBSITE_VERSION=1.0.0 +WEBSITE_CREATED_AT=2025-1-15 +WEBSITE_ICP_FILING= +WEBSITE_PUBLIC_SECURITY_FILING= +WEBSITE_BILIBILI_URL= +WEBSITE_GITEE_URL= +WEBSITE_GITHUB_URL= +WEBSITE_BLOG_URL= +WEBSITE_NAME=十二 +WEBSITE_JOB=学生 +WEBSITE_ADDRESS= +WEBSITE_EMAIL= +WEBSITE_QQ_IMAGE= +WEBSITE_WECHAT_IMAGE= + +ZAP_LEVEL=warn +ZAP_FILENAME=log/assist_backed_app.log +ZAP_MAX_SIZE=200 +ZAP_MAX_BACKUPS=30 +ZAP_MAX_AGE=5 +ZAP_IS_CONSOLE_PRINT=true diff --git "a/docs/AI/AI\345\212\251\346\211\213\344\270\213\344\270\200\351\230\266\346\256\265\345\256\236\346\226\275\350\256\241\345\210\222-Qwen.md" "b/docs/AI/AI\345\212\251\346\211\213\344\270\213\344\270\200\351\230\266\346\256\265\345\256\236\346\226\275\350\256\241\345\210\222-Qwen.md" deleted file mode 100644 index eff5863..0000000 --- "a/docs/AI/AI\345\212\251\346\211\213\344\270\213\344\270\200\351\230\266\346\256\265\345\256\236\346\226\275\350\256\241\345\210\222-Qwen.md" +++ /dev/null @@ -1,72 +0,0 @@ -# AI Runtime -> Eino 第二阶段实施计划(Qwen 版) - -## 1. 文档定位 - -本文档是第二阶段的实施说明,补充主文档 [AI助手架构设计方案.md](./AI助手架构设计方案.md) 的落地范围、默认配置和验收口径。 - -第二阶段只做三件事: - -1. 正式模型路径默认切到 `Qwen + DashScope compatible-mode`。 -2. 把 task / progress / doc 三类正式能力统一收进 `EinoAIRuntime`。 -3. 把上下文真相从前端兼容字段收回服务端。 - -## 2. 当前正式结论 - -1. `AIRuntime` 继续保留为 service seam,`AIService` 不直接依赖具体模型 SDK。 -2. `LocalAIRuntime` 继续保留,但只作为 mock / test / fallback。 -3. 6 个 HTTP API、10 个 SSE 事件、单流 `decision` JSON 控制接口保持不变。 -4. 正式 provider 默认值改为: - - `ai.provider=qwen` - - `ai.base_url=https://dashscope.aliyuncs.com/compatible-mode/v1` - - `ai.model=qwen-plus` -5. `ContextUserName / ContextOrgName` 保留为兼容字段,但不再作为正式可信输入。 - -## 3. 实现范围 - -### 3.1 模型与运行时 - -1. 模型工厂新增 `qwen` provider,正式实现使用 `github.com/cloudwego/eino-ext/components/model/qwen`。 -2. `EinoAIRuntime` 对非 lightweight 请求统一走 Eino runner。 -3. `AIRuntimePlan` 继续保留,但 plan 逻辑从 `LocalAIRuntime` 中抽出,作为共享 planner。 - -### 3.2 工具执行 - -第二阶段固定 3 个工具: - -1. `get_task_snapshot` - - 无需确认。 - - 读取当前用户可见任务的最新执行快照。 -2. `get_progress_snapshot` - - 无需确认。 - - 读取最近 7 天训练进度与当前 OJ 分数。 -3. `search_project_docs` - - 需要确认。 - - 继续走 `interrupt / resume / checkpoint`。 - -这些工具都由 Eino 执行,但外部仍继续消费现有业务 SSE 事件映射。 - -### 3.3 上下文与持久化 - -1. scope 中的人名、组织名由服务端推导。 -2. `RuntimeStateJSON` 固定保留以下字段: - - `runtime_name` - - `checkpoint_id` - - `resume_target_id` - - `tool_name` -3. 历史消息恢复仍以 MySQL 中的消息快照和 interrupt 终态为准。 - -## 4. 验收口径 - -1. 非 lightweight 请求不再正式回退到 local execute 分支。 -2. task / progress 工具通过 Eino 工具链触发,并继续映射为已有 `tool_call_started / tool_call_finished` 事件。 -3. `search_project_docs` 的 confirm / skip 继续在原 SSE 流内恢复,不新增第二条 SSE。 -4. 兼容字段 `ContextUserName / ContextOrgName` 缺失或伪造时,scope 仍以服务端真相为准。 -5. `LocalAIRuntime` 只用于 fallback / test,不作为正式默认运行时。 - -## 5. 配置建议 - -开发环境建议: - -1. 默认模型:`qwen-plus` -2. 成本敏感场景可改为:`qwen3.5-flash` -3. 若 `ai.api_key`、`ai.model` 或 Redis checkpoint 条件不满足,运行时允许回退 `LocalAIRuntime`,但应视为非正式模式。 diff --git "a/docs/AI/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" "b/docs/AI/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" deleted file mode 100644 index bdcb643..0000000 --- "a/docs/AI/AI\345\212\251\346\211\213\345\220\216\347\253\257\345\257\271\346\216\245-go-eino-V1.md" +++ /dev/null @@ -1,31 +0,0 @@ -# AI 助手后端对接说明(已并入主文档) - -本文档不再作为独立方案维护,正式内容已并入: - -- [AI助手架构设计方案.md](./AI助手架构设计方案.md) - -当前迁移结论固定如下: - -1. V1 从首版开始采用 go-eino `Interrupt / Resume + Checkpoint`,不再使用“业务 Service 管理待确认状态 + 第二条 SSE 流”的旧方案。 -2. 顶层前端协议继续采用业务 SSE 事件流,不切换到纯 A2UI 协议。 -3. AI 回复区的正式可见内容只保留四类: - - 思考摘要 - - 工具意图 - - 等待用户 - - 最终正文 -4. `最终正文` 以 Markdown 为唯一正式答案载体;任务卡、进度卡、文档卡不再作为正式用户可见协议。 -5. 工具执行记录继续保留在 `trace_items` 中,但只作为 `工具意图` 内的折叠记录,不再作为独立可见模块。 -6. `scope` 只作为可选上下文元数据保留,普通同上下文对话默认不展示。 -7. 正式接口固定为: - - `POST /ai/conversations` - - `GET /ai/conversations` - - `GET /ai/conversations/{id}/messages` - - `DELETE /ai/conversations/{id}` - - `POST /ai/conversations/{id}/stream` - - `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` -8. 旧的工具续跑流接口全量废弃。 -9. 第一阶段实现继续保留 `AIRuntime` 作为业务缝;正式运行时默认走 `EinoAIRuntime`,`LocalAIRuntime` 只保留为 mock / test / fallback。 -10. 第二阶段正式模型路径默认切到 `Qwen + DashScope compatible-mode`,不再默认走 `OpenAI / Ark`。 -11. 第二阶段开始,`task / progress / doc` 三类正式能力统一由 Eino 工具执行;前端上传的 `ContextUserName / ContextOrgName` 只保留兼容,不再作为正式真相输入。 - -若后续需要补充实现细节、OpenAPI 变更或前后端联调约束,只更新主文档,不再回写本文件。 diff --git "a/docs/AI/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/AI/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" deleted file mode 100644 index 5038160..0000000 --- "a/docs/AI/AI\345\212\251\346\211\213\346\236\266\346\236\204\350\256\276\350\256\241\346\226\271\346\241\210.md" +++ /dev/null @@ -1,480 +0,0 @@ -# AI 助手架构设计方案 - -## 1. 文档定位 - -本文档是 `personal_assistant` AI 子域的唯一正式方案文档,用于统一以下内容: - -1. 业务定位与分层边界。 -2. Go + Eino 的运行时基线。 -3. 前端真实协议、SSE 事件和消息模型。 -4. AI 回复区的四类可见内容规则。 -5. OpenAPI / Apifox 对外契约。 -6. V1 验收标准。 - -旧文档 [AI助手后端对接-go-eino-V1.md](./AI助手后端对接-go-eino-V1.md) 只保留迁移说明,不再维护独立结论。 - -项目级 SSE 连接层、回放层、跨节点分发、安全与运维基线,统一以 [SSE实时推送基础设施重构指导文档.md](./SSE实时推送基础设施重构指导文档.md) 为准;本文档只保留 AI 子域协议、运行时和验收结论。 - -## 2. 真相源与案例基线 - -本方案固定以下 5 类真相源: - -1. `z_cur/UI/src/types/assistant.types.ts` -2. `z_cur/UI/src/stores/assistant.ts` -3. `z_cur/UI/src/components/business/Assistant/**` -4. `z_cur/Eino/eino-examples/quickstart/chatwitheino/docs/ch07_interrupt_resume.md` -5. `z_cur/Eino/eino-examples/quickstart/chatwitheino/docs/ch10_a2ui.md` - -结论解释: - -1. 前端真实协议和消息模型,以 `z_cur/UI` 现有实现为准。 -2. Eino 运行时基线,以 `Interrupt / Resume + Checkpoint` 案例为准。 -3. A2UI 只借鉴适合声明式渲染的部分,不直接照搬 `ch10` 的顶层协议。 - -## 3. 设计结论 - -### 3.1 项目定位 - -AI 助手是 `personal_assistant` 的正式业务子域,不独立拆仓,不做脱离业务上下文的 demo。 - -V1 固定提供 4 类能力: - -1. 我的任务汇报。 -2. 指定范围任务汇总。 -3. 用户进度分析。 -4. 正式项目文档问答。 - -### 3.2 Eino 运行时结论 - -V1 从首版开始就把 `Interrupt / Checkpoint` 作为必选能力,不采用“业务 Service 自己维护待确认状态,再发起第二条 SSE 流”的旧方案。 - -固定运行时如下: - -1. `ChatModelAgent` 负责模型与 Tool 调度。 -2. `Runner` 负责执行与恢复。 -3. `Approval / Interrupt` 负责人工确认节点。 -4. `CheckPointStore` 负责运行时恢复点。 -5. 业务 Service 负责权限、会话、消息、SSE 事件映射与持久化收口。 - -第一阶段实现约束: - -1. `AIRuntime` 继续保留为 Service 与运行时之间的抽象缝。 -2. 正式运行时默认切到 `EinoAIRuntime`。 -3. `LocalAIRuntime` 只保留为 mock / test / fallback,不再作为正式运行时真相实现。 - -第二阶段正式口径: - -1. 正式模型 provider 默认使用 `Qwen + DashScope compatible-mode`,默认 `provider=qwen`。 -2. 非 lightweight 请求统一走 `EinoAIRuntime`,由 Eino 正式接管 `get_task_snapshot`、`get_progress_snapshot`、`search_project_docs` 三类工具执行。 -3. `ContextUserName / ContextOrgName` 只保留为兼容请求字段;正式上下文必须由服务端从登录态、当前组织和可见数据推导。 -4. `RuntimeStateJSON` 固定收口为 `runtime_name / checkpoint_id / resume_target_id / tool_name` 四个字段。 -5. 第二阶段实施说明单独见 [AI助手下一阶段实施计划-Qwen.md](./AI助手下一阶段实施计划-Qwen.md)。 - -### 3.3 协议结论 - -前端协议采用“业务事件流 + 内嵌 A2UI block”的混合模型,不切换到纯 A2UI 顶层协议。 - -固定原则: - -1. 顶层 SSE 仍是业务事件协议。 -2. `A2UI` 只作为 `structured_block.ui_block` 的渲染载荷出现。 -3. `trace_items` 与 `scope` 继续是恢复与上下文真相字段。 -4. `content` 是唯一正式最终答案正文。 -5. `ui_blocks` 只承载“思考摘要 / 工具意图 / 等待用户”三类可见结构化块。 - -### 3.4 单流结论 - -V1 固定为“单条聊天 SSE 流 + 独立控制接口”: - -1. `POST /ai/conversations/{id}/stream` - 开启唯一聊天事件流。 -2. `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` - 只提交确认决策,不返回第二条 SSE 流。 - -运行语义: - -1. 命中 interrupt 后,`stream` 不结束业务轮次,只进入等待确认阶段。 -2. 服务端持续保活同一条 SSE 连接。 -3. 前端调用 decision 接口后,服务端在原流内 `Resume` 并继续输出后续事件。 -4. V1 不承诺“中途断流后重新附着到同一轮运行”;断流即本轮失败或停止。 - -## 4. 总体架构 - -### 4.1 后端分层 - -后端继续沿用当前仓库分层: - -1. `controller` - 负责 HTTP 绑定、SSE 写出、上下文提取、错误返回。 -2. `service` - 负责会话编排、Agent 调用、权限收口、协议映射、落库。 -3. `repository` - 负责会话、消息、审计、待确认记录的持久化。 -4. `infrastructure` - 负责模型适配器、Eino 初始化、CheckpointStore、文档加载器。 -5. `router` - 负责把 AI 路由挂入登录态业务分组。 - -### 4.2 数据真相源 - -V1 固定两类存储: - -1. MySQL - 会话、消息、审计记录的业务真相源。 -2. Redis - `CheckPointStore` 与运行时 interrupt / resume 所需的状态存储。 - -Redis 不是业务消息真相源;消息历史仍以 MySQL 为准。 - -### 4.3 权限边界 - -权限必须先于模型回答,而不是靠提示词兜底。 - -固定规则: - -1. 默认范围是“当前登录用户 + 当前组织”。 -2. 查询他人、跨组织、管理视角数据时,必须先走现有资源级鉴权。 -3. 文档问答只允许读取正式白名单文档。 -4. 越权请求在 Service 层直接拒绝,不把敏感数据交给模型。 - -## 5. AI 回复协议 - -### 5.1 四类可见内容 - -单条 assistant 消息只允许出现以下四类可见内容: - -1. 思考摘要 - 用于展示阶段性判断、当前动作、等待原因和下一步,不泄露原始长推理。 -2. 工具意图 - 用于说明为什么要调用工具、调用后会得到什么、是否需要确认。 -3. 等待用户 - 用于明确当前轮次为什么暂停、需要用户确认什么、确认后会发生什么。 -4. 最终正文 - 用于承载正式回答,是唯一正式结果正文。 - -### 5.2 消息模型 - -一条 assistant 消息不是单纯字符串,而是以下组合: - -1. `content` - Markdown 最终正文;`assistant_token` 和 `message_completed.content` 只承载它。 -2. `trace_items` - 工具执行记录与恢复真相。 -3. `ui_blocks` - 结构化可见块,只允许: - - `thinking_summary_block` - - `tool_intent_block` - - `waiting_user_block` -4. `scope` - 可选上下文元数据;仅在复杂范围、跨用户、跨组织或带文档白名单时返回,但默认不单独展示。 -5. `status / error_text` - 消息过程态与错误态。 - -历史消息返回时,后端应优先补齐 `ui_blocks`,并保留 `trace_items / scope` 以支持状态恢复。 - -### 5.3 渲染规则 - -前端固定按以下逻辑渲染单条 assistant 消息: - -1. 思考摘要 -2. 工具意图 -3. 等待用户 -4. 最终正文 - -补充规则: - -1. 问候语、寒暄、感谢和无业务目标的短消息,只展示最终正文。 -2. 工具执行记录只作为 `工具意图` 内的折叠执行记录存在,不再作为独立可见模块。 -3. 等待用户块只负责说明暂停点;真正可点击的确认按钮仍固定放在消息列表下方、输入框上方的独立操作条。 -4. 第三阶段第一批实现中,用户在等待期间输入新消息时默认直接拒绝为 `busy`;旧等待态保留为历史,但不做抢占恢复。 - -### 5.4 SSE 事件 - -顶层事件固定保留以下 10 个: - -1. `conversation_started` -2. `assistant_token` -3. `tool_call_started` -4. `tool_call_finished` -5. `tool_call_waiting_confirmation` -6. `tool_call_confirmation_result` -7. `structured_block` -8. `message_completed` -9. `error` -10. `done` - -其中: - -1. `tool_call_waiting_confirmation` payload 必须带 `interrupt_id`。 -2. `structured_block` 允许承载 `scope / ui_block`。 -3. `tool_call_confirmation_result` 表示“决策已受理并已恢复或跳过”,而不是第二条流的起点。 -4. `assistant_token` 只能用于流式输出最终正文,不能混入结论壳、指标壳或等待提示。 - -## 6. 局部 A2UI 设计 - -### 6.1 适用边界 - -| 分类 | 是否采用 A2UI | 结论 | -| --- | --- | --- | -| 思考摘要 | 是 | 适合声明式展示阶段性判断 | -| 工具意图 | 是 | 适合声明式展示目的、必要性、收益与确认要求 | -| 等待用户 | 是 | 适合声明式展示暂停原因和下一步 | -| 最终正文 | 否 | 继续使用 Markdown 作为唯一正式结果正文 | -| 会话列表 | 否 | 属于业务页面壳层 | -| 主布局 | 否 | 属于页面壳层,不应协议化 | -| 权限判断 | 否 | 属于业务规则,不应协议化 | -| 工具确认状态机 | 否 | 属于业务状态机与 interrupt 运行时 | -| SSE 协议 | 否 | 继续采用业务事件协议 | -| 消息持久化 | 否 | 继续以业务字段为真相 | - -### 6.2 A2UI 子集 - -基础布局组件固定为: - -1. `Text` -2. `Row` -3. `Column` -4. `Card` - -业务扩展组件固定为: - -1. `Badge` -2. `BulletList` - -Block 类型固定为: - -1. `thinking_summary_block` -2. `tool_intent_block` -3. `waiting_user_block` - -### 6.3 责任边界 - -必须固定以下责任边界: - -1. `trace_items` 负责工具执行记录与恢复真相。 -2. `tool_intent_block` 只负责解释为什么需要工具,以及这轮工具调用的意图和收益。 -3. `waiting_user_block` 只负责表达暂停原因和等待点。 -4. `thinking_summary_block` 只承载“当前判断 / 当前动作 / 等待原因 / 下一步”的摘要,不是原始模型推理全文。 -5. `scope` 是元数据,不是默认可见内容。 - -## 7. 后端实现方案 - -### 7.1 路由 - -AI 接口固定为 6 个: - -1. `POST /ai/conversations` -2. `GET /ai/conversations` -3. `GET /ai/conversations/{id}/messages` -4. `DELETE /ai/conversations/{id}` -5. `POST /ai/conversations/{id}/stream` -6. `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` - -### 7.2 Service 责任 - -AI Service 必须承担以下职责: - -1. 会话 CRUD 与消息历史装载。 -2. 用户 / 组织 /任务 / 文档范围裁剪。 -3. Tool 选择与参数装配。 -4. Eino Agent / Runner 调用。 -5. Interrupt 命中后的运行时恢复。 -6. SSE 事件映射。 -7. 会话、消息与审计落库。 - -另外固定一条产品侧门控规则: - -1. 问候语、寒暄、感谢和无业务目标的短消息,直接走轻量直答路径。 -2. 只有进入任务分析、进度分析、范围汇总、文档问答这类业务意图时,才进入重型工具链路。 -3. `scope` 默认不前台展示;只有确实存在复杂范围时才回传。 - -### 7.3 Tool 约束 - -V1 产品能力固定为 4 类: - -1. 我的任务汇报。 -2. 指定范围任务汇总。 -3. 用户进度分析。 -4. 正式项目文档问答。 - -当前代码层面的 Eino Tool 收口为 3 个正式工具: - -1. `get_task_snapshot` - 负责读取当前用户可见的最新任务快照,覆盖“我的任务汇报”和“指定范围任务汇总”的基础数据入口。 -2. `get_progress_snapshot` - 负责读取用户最近训练进度、OJ 分数和当前组织信息。 -3. `search_project_docs` - 负责读取正式文档白名单,并且在执行前必须经过用户确认。 - -约束固定如下: - -1. Tool 不直接散落 SQL。 -2. 任务和进度类 Tool 通过 `aiRuntimeDataService` 调用 Repository / ReadModel 获取服务端已裁剪的数据。 -3. 文档类 Tool 只能访问 `ai.doc_whitelist` 配置中的正式文档。 -4. Tool 输出必须能映射成 `trace_items`,并驱动 `tool_intent_block / waiting_user_block / 最终正文`。 - -### 7.4 当前实现链路 - -当前 AI 不是“Controller 直接请求模型”的实现,而是完整的会话编排链路: - -1. `internal/router/system/aiRouter.go` - 注册会话 CRUD、消息列表、流式对话和 interrupt decision 接口。 -2. `internal/controller/system/aiCtrl.go` - 只负责绑定参数、读取当前用户 ID、创建 SSE writer、调用 Service 和统一错误响应。 -3. `internal/service/system/aiSvc.go` - 负责会话归属校验、忙碌状态校验、服务端上下文解析、Plan 生成、消息骨架落库、Runtime 执行和收尾。 -4. `internal/service/system/aiSink.go` 与 `internal/service/system/aiProjector.go` - 把 runtime 事件同时写入 SSE 和数据库消息快照,保证前端实时流与历史消息能使用同一套 `content / trace_items / ui_blocks / scope`。 -5. `internal/repository/system/aiRepo.go` - 负责 `ai_conversations / ai_messages / ai_interrupts` 的 CRUD、行锁和恢复扫描查询。 - -一次 `POST /ai/conversations/{id}/stream` 的主流程固定为: - -1. 校验 `conversation_id` 与路径参数一致,并拒绝 query token。 -2. 校验当前用户拥有该会话,且会话没有其他生成中的轮次。 -3. 读取当前用户、当前组织、可见任务和文档白名单信息,生成服务端上下文。 -4. 调用 `AIRuntime.Plan` 得到本轮计划,判断是否轻量直答、是否需要任务 / 进度 / 文档工具。 -5. 事务化写入用户消息、assistant 消息骨架、会话 `is_generating=true`,必要时创建 `AIInterrupt`。 -6. 调用 `AIRuntime.Execute` 输出事件。 -7. `aiStreamSink` 将每个事件写到 SSE,同时折叠为消息状态并持久化。 -8. Runtime 结束后,Service 将会话切回非生成态;失败时根据流是否已经开始,选择 JSON 错误或 SSE `error / done` 收尾。 - -### 7.5 Runtime 职责 - -`AIRuntime` 是 Service 与具体模型 / Agent 实现之间的抽象缝,固定暴露: - -1. `Plan` - 生成执行计划,当前由 `planAIRuntime` 统一负责。 -2. `Execute` - 按计划执行,并只通过 `AIRuntimeSink` 输出事件。 -3. `SubmitDecision` - 接收用户对 interrupt 的确认或跳过。 -4. `RevokeUser` - 撤销指定用户当前等待中的本地会话。 -5. `NodeID` - 返回当前 runtime 所属节点,用于跨节点命令路由。 - -当前有两套实现: - -1. `LocalAIRuntime` - 作为 mock / test / fallback 使用。它不调用真实模型,按 plan 直接发出结构化块、工具事件、等待确认事件和最终正文,适合本地调试和 Eino 不可用时降级。 -2. `EinoAIRuntime` - 作为正式运行时。非 lightweight 请求默认走 Eino Runner;它创建 Agent、绑定 ChatModel、注册 Tool、启用 CheckPointStore,并通过 ApprovalMiddleware 在 `search_project_docs` 执行前触发 interrupt。 - -`EinoAIRuntime` 当前模型工厂支持: - -1. `qwen` - 默认 provider,默认 BaseURL 为 DashScope compatible-mode。 -2. `openai` -3. `ark` - -如果配置缺失、Redis 不可用、模型初始化失败或 runtime mode 非 `eino`,系统会回退到 `LocalAIRuntime`。 - -### 7.6 Interrupt / Resume 与控制面 - -文档工具是当前唯一必须人工确认的工具。确认链路如下: - -1. Plan 判断需要 `search_project_docs` 时,Service 预创建 `AIInterrupt`。 -2. Eino 的 `ApprovalMiddleware` 在工具执行前抛出 stateful interrupt。 -3. Runtime 将 `checkpoint_id / resume_target_id / tool_name` 写入 `RuntimeStateJSON`,并写入 Redis envelope。 -4. 原 SSE 流输出 `tool_call_waiting_confirmation`,同时保持心跳等待用户。 -5. 前端调用 `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision` 提交 `confirm` 或 `skip`。 -6. Service 行锁更新 interrupt 决策,并把命令投递给本节点 runtime 或 Redis command bus 指定的 owner 节点。 -7. Runtime 在原流内 resume;如果原 owner 丢失,recovery loop 会基于 DB interrupt 和 Redis envelope 尝试恢复或停止该轮。 - -控制面由两类 Redis 数据支撑: - -1. CheckPointStore - 保存 Eino Runner 的 checkpoint,用于 resume。 -2. Runtime envelope / command bus - 保存 interrupt owner、租约和跨节点控制命令,用于多节点场景下的决策路由和恢复。 - -业务真相仍在 MySQL: - -1. `AIConversation` - 记录会话归属、标题、预览、生成态和最后消息时间。 -2. `AIMessage` - 记录用户消息和 assistant 消息,assistant 消息额外保存 `trace_items_json / ui_blocks_json / scope_json / error_text`。 -3. `AIInterrupt` - 记录确认状态、决策、原因、运行时恢复信息和 owner 节点。 - -## 8. OpenAPI / Apifox 结论 - -Apifox 契约以两份文件同步维护: - -1. `z_cur/UI/docs/apifox/ui-module.openapi.json` -2. `go/personal_assistant/docs/apifox/ai_assistant.openapi.json` - -两者必须保持一致,固定约束如下: - -1. CRUD 与 decision 接口走 JSON BizResponse。 -2. `stream` 接口只输出 `text/event-stream`。 -3. 不再出现旧的工具续跑流接口描述。 -4. `thinking_summary_block / tool_intent_block / waiting_user_block / interrupt_id` 都必须进入 schema。 -5. SSE 示例必须体现“原流等待 + decision 接口控制 + 原流继续输出”。 -6. 文档必须明确:`content` 是唯一正式最终答案正文;`trace_items / scope` 是恢复与上下文字段,不是默认可见内容。 - -## 9. V1 验收标准 - -### 9.1 协议验收 - -1. 主文档已完全切到“四类可见内容”心智。 -2. `tool_call_waiting_confirmation` 与 `tool_call_confirmation_result` 都带 `interrupt_id`。 -3. 历史消息能重建 `content / trace_items / ui_blocks / scope`。 -4. 不再出现任务卡、进度卡、文档卡和独立工具轨迹块。 - -### 9.2 前端验收 - -1. `structured_block.ui_block` 只渲染 3 类 block。 -2. 问候语与寒暄类短消息走轻量直答路径,只出现最终正文。 -3. 复杂问题先出现思考摘要,再按需要出现工具意图与等待用户。 -4. 工具执行记录默认折叠在工具意图内部,不再单独占区。 -5. 等待确认时,真正交互入口只保留底部独立确认条。 -6. 用户在等待期间输入新消息时,新消息轮次优先,旧轮次停止等待。 -7. decision 提交后不再开启第二条流,原流继续完成。 - -### 9.3 后端验收 - -1. 首版即采用 `Interrupt / Resume + Checkpoint`。 -2. `stream` 事件顺序正确,keepalive 不影响前端解析。 -3. decision 接口只提交控制命令,不直接返回 SSE。 -4. `interrupt_id` 与会话归属、权限、当前运行实例关系校验正确。 - -## 10. 后续扩展边界 - -后续扩展必须建立在 V1 四类可见内容、单流恢复和业务闭环稳定之后,不提前抢跑。 - -### 10.1 业务能力扩展 - -1. 任务能力可以从“最新任务快照”扩展到指定任务、指定组织、指定成员和时间窗口汇总。 -2. 进度能力可以从近 7 天 OJ 统计扩展到训练曲线、薄弱知识点、题目推荐和组织排名分析。 -3. 文档能力可以从白名单文件检索扩展到版本化知识库、文档引用定位、变更摘要和接口说明问答。 -4. 后续如果接入日程、图片、通知或组织协作模块,应先定义新的业务 Tool,不应让模型绕过 Service 直接访问底层资源。 - -### 10.2 Runtime 扩展 - -1. `AIRuntime` 抽象已经允许替换运行时,后续可以接入更复杂的 Eino Workflow、Graph 或 Multi-Agent。 -2. 现有 `Plan` 仍是规则化意图识别,后续可以演进为模型辅助规划,但 Service 必须继续保留权限、工具白名单和最终执行边界。 -3. 当前文档工具有人工确认,后续可按风险等级扩展到更多 Tool,例如跨组织查询、批量变更、发送通知等。 -4. `LocalAIRuntime` 应继续保留为测试和降级路径,避免正式模型不可用时整个 AI 子域不可验证。 - -### 10.3 恢复与多节点能力 - -1. 当前已经具备 checkpoint、owner lease、command bus 和 recovery loop 的基础结构。 -2. 后续可以补强断流后重新附着到同一轮运行的能力,但需要先明确前端重连协议、事件回放范围和消息幂等规则。 -3. 多节点部署下应继续以 DB interrupt 为业务真相,以 Redis envelope 作为运行控制面状态,不反向依赖 Redis 保存业务消息。 -4. Recovery 继续只处理可证明安全的状态;无法确认恢复目标时应停止该轮,而不是盲目重跑工具。 - -### 10.4 知识库与检索扩展 - -1. 当前 `search_project_docs` 是白名单文件分段检索,适合项目文档问答的第一阶段。 -2. 后续可引入 embedding、增量索引、权限标签和引用片段去重。 -3. 检索结果必须保留来源路径、标题和摘要,最终回答不能只给模型生成内容而丢失可追溯依据。 -4. 文档索引的构建、刷新和健康检查应放在基础设施或任务层,业务 Service 只消费封装后的检索能力。 - -### 10.5 协议与前端体验扩展 - -1. A2UI 可以从当前 `Text / Row / Column / Card / Badge / BulletList` 子集逐步扩展,但顶层仍保持业务 SSE 事件协议。 -2. 可新增更丰富的 trace 展开、工具结果对比、引用来源跳转和等待态操作条。 -3. 新增可见块前必须先判断是否属于四类内容;如果只是工具执行细节,应优先折叠在 `trace_items` 或现有 block 内。 -4. 所有扩展都必须保证 `content` 仍是唯一正式最终答案正文,历史消息仍能从数据库快照完整恢复。 diff --git "a/docs/AI/AI\346\236\266\346\236\204\351\242\204\350\247\210.md" "b/docs/AI/AI\346\236\266\346\236\204\351\242\204\350\247\210.md" deleted file mode 100644 index 3ef0041..0000000 --- "a/docs/AI/AI\346\236\266\346\236\204\351\242\204\350\247\210.md" +++ /dev/null @@ -1,382 +0,0 @@ -# AI 架构概览 - -本文只分析 `personal_assistant` 项目中的 AI 子域,不覆盖整个系统。内容基于当前仓库代码整理,重点说明 Router、Controller、Service、Runtime、Sink、SSE、DB 落库、工具调用、用户确认和中断恢复之间的关系。 - -## 1. AI 架构总览 - -AI 模块采用清晰的分层链路: - -1. Router:注册 `/ai/conversations` 相关路由。 -2. Controller:绑定参数、读取当前用户、创建 SSE writer、调用 Service。 -3. Service:校验会话归属、生成 plan、创建消息骨架、调用 runtime、收尾状态。 -4. Runtime:负责 plan / execute / interrupt / resume / decision。 -5. Sink:把 runtime 事件同时写入 SSE 和 DB 消息快照。 -6. Repository:持久化 conversation、message、interrupt。 -7. Infrastructure:封装 Eino Agent、模型、工具、审批中断、checkpoint、Redis 控制面。 - -核心设计点是:runtime 不直接操作 HTTP 和 DB;它只向 `AIRuntimeSink` 发事件。`aiStreamSink` 再把事件同步到 SSE 和数据库,因此前端实时看到的内容与历史消息恢复使用同一套数据模型。 - -当前 AI 子域的主要真相源是: - -1. MySQL: - - `ai_conversations` - - `ai_messages` - - `ai_interrupts` -2. Redis: - - Eino checkpoint - - runtime envelope - - runtime command bus - - recovery lock - -MySQL 是业务消息和 interrupt 的真相源;Redis 是运行控制面,不承担业务消息真相。 - -## 2. 核心链路流程 - -### 2.1 流式对话主链路 - -一次流式对话从 `POST /ai/conversations/{id}/stream` 进入: - -1. `AIRouter.InitAISSERouter` 注册流式路由。 -2. `AICtrl.StreamConversation` 拒绝 query token,绑定 `StreamAssistantMessageReq`,创建 HTTP SSE writer。 -3. Controller 调用 `AIService.StreamConversation(ctx, userID, conversationID, req, writer)`。 -4. Service 校验 `conversation_id` 与路径一致,检查会话归属和 `IsGenerating`。 -5. Service 读取用户和服务端上下文,调用 `resolveRuntimeContext` 生成 `AIResolvedContext`。 -6. Service 调用 `runtime.Plan`,由 `planAIRuntime` 判断 lightweight、任务、进度、文档工具。 -7. Service 创建 user message、assistant message,必要时创建 `AIInterrupt`。 -8. `persistStreamStart` 在事务中锁定会话,写入消息骨架并把会话标记为生成中。 -9. Service 创建 `aiStreamSink`,调用 `runtime.Execute`。 -10. Runtime 发出 `conversation_started`、`structured_block`、`tool_call_*`、`assistant_token`、`message_completed`、`done` 等事件。 -11. `aiStreamSink.Emit` 先写 SSE,再调用 `aiMessageProjector.applyEvent` 更新内存态,最后持久化到 DB。 -12. `finishStream` 把会话切回非生成态;如果失败且 SSE 已开始,则通过 SSE 发送错误终态。 - -### 2.2 文档工具确认链路 - -文档工具 `search_project_docs` 需要用户确认: - -1. Plan 命中文档意图时生成 `DocTool`,Service 创建 `AIInterrupt`。 -2. `EinoAIRuntime.Execute` 运行 Eino Runner。 -3. `ApprovalMiddleware` 在执行 `search_project_docs` 前抛出 stateful interrupt。 -4. `EinoAIRuntime.handleInterrupt` 写入 runtime state,注册等待通道,写 Redis envelope,向前端发送等待确认事件。 -5. 前端调用 `POST /ai/conversations/{id}/interrupts/{interrupt_id}/decision`。 -6. `AIService.SubmitDecision` 行锁更新 interrupt,向本地 runtime 或 Redis command bus 投递决策。 -7. Runtime 收到 `confirm / skip` 后 resume 原 Runner,并继续通过同一个 sink 输出后续事件。 -8. 如果 owner 节点丢失,control plane 的 recovery loop 尝试恢复;无法安全恢复时停止该轮。 - -### 2.3 消息落库与流式输出的关系 - -Runtime 只产生事件,不直接写数据库。每个事件都会进入 `aiStreamSink.Emit`: - -1. 事件 payload 被 JSON 编码。 -2. 事件写入 SSE。 -3. `aiMessageProjector.applyEvent` 把事件折叠成消息快照。 -4. `aiMessageProjector.persistMessage` 更新 `AIMessage` 和必要的 `AIInterrupt`。 - -这意味着: - -1. `assistant_token` 会追加到消息正文。 -2. `tool_call_started / tool_call_finished` 会更新 `trace_items`。 -3. `structured_block` 会更新 `ui_blocks` 或 `scope`。 -4. `tool_call_waiting_confirmation` 会让消息进入等待态,并记录 interrupt。 -5. `message_completed` 会把 assistant 消息标记为成功。 -6. `error` 会记录 `error_text` 并清理等待态。 - -## 3. 关键目录与文件说明 - -### 3.1 核心骨架文件 - -- `internal/router/system/aiRouter.go` - 注册 AI 会话路由。普通 JSON 接口和 SSE stream 接口分开初始化。 - -- `internal/controller/system/aiCtrl.go` - AI HTTP 入口。Controller 只做参数绑定、JWT 用户读取、SSE writer 创建、错误响应,不写业务逻辑。 - -- `internal/service/system/aiSvc.go` - AI 业务编排核心。负责会话 CRUD、流式会话执行、interrupt 决策、撤销用户运行中会话、状态收尾。 - -- `internal/service/system/aiRuntime.go` - Runtime 抽象定义。核心接口是 `AIRuntime` 与 `AIRuntimeSink`,核心数据是 `AIRuntimePlan`、`AIRuntimeExecutionInput`、`AIRuntimeDecisionCommand`。 - -- `internal/service/system/aiRuntimeEino.go` - 正式 Eino runtime。负责构建 Runner、执行 Agent、消费 Eino iterator、处理 interrupt、resume checkpoint。 - -- `internal/service/system/aiRuntimeLocal.go` - 本地 fallback runtime。用于测试、mock、Eino 初始化失败降级;它不调用真实模型,按 plan 模拟输出事件。 - -- `internal/service/system/aiSink.go` - Runtime 到 SSE / DB 的桥。`Emit` 负责写 SSE、折叠事件、持久化消息。 - -- `internal/service/system/aiProjector.go` - 消息投影器。把 runtime 事件转换成 `AIMessage.Content / TraceItemsJSON / UIBlocksJSON / ScopeJSON / ErrorText` 和 interrupt 状态。 - -- `internal/repository/interfaces/aiRepository.go` - AI 仓储接口,定义会话、消息、interrupt 的持久化能力。 - -- `internal/repository/system/aiRepo.go` - GORM 实现,包含会话行锁、interrupt 行锁、恢复扫描查询和级联删除。 - -- `internal/model/entity/ai.go` - 三张业务真相表:`AIConversation`、`AIMessage`、`AIInterrupt`。 - -### 3.2 辅助实现文件 - -- `internal/service/system/aiPlanner.go` - 共享 planner,Eino 和 Local runtime 都复用它生成 plan。 - -- `internal/service/system/aiIntent.go` - 轻量意图和业务意图识别。 - -- `internal/service/system/aiMapper.go` - ID、标题、预览、DTO 映射、A2UI block、trace item 等辅助构造。 - -- `internal/service/system/aiContext.go` - 为 runtime 提供服务端上下文、任务快照、训练进度快照。 - -- `internal/service/system/aiRuntimeFactory.go` - 根据配置选择 `EinoAIRuntime` 或 `LocalAIRuntime`。 - -- `internal/service/system/aiControlPlane*.go` - Redis command bus、envelope、recovery loop、后台 resume。 - -- `internal/infrastructure/ai/eino/agent_factory.go` - 创建 ChatModel、Eino Agent、Runner。 - -- `internal/infrastructure/ai/eino/approval_middleware.go` - 在指定 tool 执行前触发 Eino stateful interrupt。 - -- `internal/infrastructure/ai/eino/docs_tool.go` - 文档白名单检索工具。 - -- `internal/infrastructure/ai/eino/task_progress_tools.go` - `get_task_snapshot` 与 `get_progress_snapshot` 工具封装。 - -- `internal/infrastructure/ai/runtimecontrol/*.go` - Redis envelope store 和 command bus 的基础设施实现。 - -## 4. 关键函数说明 - -### 4.1 入口与 Controller - -- `AIRouter.InitAIRouter` - 注册会话创建、列表、消息列表、删除、decision 接口。上游是总路由组,下游是 `AICtrl`。 - -- `AIRouter.InitAISSERouter` - 注册 `POST :id/stream`。这是流式对话入口。 - -- `AICtrl.CreateConversation` - 输入是 Gin context。绑定 `CreateAssistantConversationReq`,读取 `jwt.GetUserID(c)`,调用 `AIService.CreateConversation`。 - -- `AICtrl.StreamConversation` - 输入是 Gin context。绑定 `StreamAssistantMessageReq`,创建 `streamsse.NewHTTPStreamWriter`,调用 Service。失败时如果流未开始,返回 JSON BizError;流已开始则不再回写 JSON。 - -- `AICtrl.SubmitDecision` - 输入是 Gin context。绑定 `SubmitAssistantDecisionReq`,调用 `AIService.SubmitDecision`,返回 decision accepted 响应。 - -### 4.2 Service 编排 - -- `NewAIService` - 组装 repo、runtime、SSE policy、control plane。内部通过 `newConfiguredAIRuntimeWithControlPlane` 选择 runtime。 - -- `AIService.CreateConversation` - 创建会话。读取用户当前组织,把会话写入 `ai_conversations`,返回会话 DTO。 - -- `AIService.ListMessages` - 校验会话归属后读取消息列表,并把实体转换为响应 DTO。 - -- `AIService.StreamConversation` - 流式对话主编排。输入用户 ID、会话 ID、请求 DTO、SSE writer;输出 error。它负责校验、plan、消息骨架、interrupt、事务落库、runtime execute、finish。 - -- `AIService.persistStreamStart` - 在事务中锁定 conversation,避免并发生成;写入 user message、assistant message、interrupt,并把会话标记为 `IsGenerating=true`。 - -- `AIService.finishStream` - 统一收尾。成功则结束;取消/超时标记 stopped;其他错误写 error 事件和 done 事件。 - -- `AIService.SubmitDecision` - 决策入口。行锁读取 interrupt,校验状态,写入 decision,再投递给本地 runtime 或远程 owner 节点。 - -- `AIService.RevokeUserSessions` - 撤销某个用户等待中的 AI 会话。本地 owner 直接调用 runtime,远程 owner 走 command bus。 - -### 4.3 Runtime 抽象与实现 - -- `AIRuntime.Plan` - 输入 `AIRuntimePlanInput`,输出 `AIRuntimePlan`。当前 Eino 和 Local 都调用 `planAIRuntime`。 - -- `AIRuntime.Execute` - 输入 `AIRuntimeExecutionInput` 和 sink。runtime 只负责发事件,不直接写 HTTP / DB。 - -- `AIRuntime.SubmitDecision` - 把用户决策投递到当前 runtime 的 session registry。 - -- `planAIRuntime` - 解析用户输入,判断 lightweight、任务快照、进度快照、文档支持,生成工具蓝图和最终回答分支。 - -- `LocalAIRuntime.Execute` - 按 plan 模拟完整事件流。适合 fallback 和测试。 - -- `LocalAIRuntime.waitDecision` - 等待用户确认,同时按心跳间隔调用 `sink.Heartbeat` 保持 SSE 连接。 - -- `EinoAIRuntime.Execute` - 正式执行路径。lightweight 直接降级给 Local;非 lightweight 构建 Eino Runner,运行 Agent,消费 iterator,处理工具事件与最终正文。 - -- `EinoAIRuntime.handleInterrupt` - Eino 文档工具审批中断处理。注册等待通道,写 envelope,发送等待确认事件,收到 decision 后调用 Runner resume。 - -- `EinoAIRuntime.ResumeInterrupted` - 后台恢复入口。根据 interrupt 中的 checkpoint 和 resume target 恢复 Eino Runner,用 persist-only sink 把结果落库。 - -### 4.4 Sink 与持久化 - -- `newAIStreamSink` - 创建流式 sink,绑定 SSE writer、AI repo、assistant message 和 interrupt。 - -- `aiStreamSink.Emit` - 输入事件名和 payload。先 JSON 编码并写 SSE,再调用 projector 更新消息快照,最后持久化。 - -- `aiStreamSink.Heartbeat` - 在等待 decision 时发送 keepalive,不改变 DB 状态。 - -- `newAIMessageProjector` - 从已有 message JSON 字段恢复投影器内存态,供流式执行或恢复执行继续折叠事件。 - -- `aiMessageProjector.applyEvent` - 事件折叠核心。处理 token、工具开始/结束、等待确认、确认结果、结构化块、完成、错误。 - -- `aiMessageProjector.persistMessage` - 把投影后的 message 和 interrupt 写回 DB,保持历史消息可恢复。 - -- `newAIPersistOnlySink` - 后台 recovery 使用的 sink。它不写 SSE,只把恢复后的事件投影到 DB。 - -### 4.5 Eino 基础设施 - -- `NewChatModel` - 根据 provider 创建 Qwen / OpenAI / Ark 模型。默认 provider 是 qwen,DashScope compatible-mode 是默认 base URL。 - -- `NewRuntimeAgent` - 创建 Eino deep Agent,注册工具和 `ApprovalMiddleware`。 - -- `NewRunner` - 创建 Eino Runner,并接入 CheckPointStore。 - -- `NewApprovalMiddleware` - 包装指定 tool。第一次调用时抛 interrupt;resume 后根据 ApprovalResult 决定执行工具或返回跳过结果。 - -- `NewSearchProjectDocsTool` - 把文档白名单检索包装为 Eino invokable tool。 - -- `NewTaskSnapshotTool / NewProgressSnapshotTool` - 把 Service 提供的数据读取函数包装为 Eino tool。 - -### 4.6 控制面与恢复 - -- `AIService.StartControlPlane` - 启动 command loop 和 recovery loop。 - -- `AIService.handleRuntimeCommand` - 处理跨节点 command bus 命令,当前支持提交 decision 和撤销用户会话。 - -- `AIService.recoverStaleInterruptsOnce` - 扫描长时间未更新的 awaiting / decision interrupt。 - -- `AIService.recoverInterruptCandidate` - 根据 envelope、lease 和 runtime state 判断恢复或停止。 - -- `AIService.resumeRecoveredInterrupt` - 加载恢复上下文,调用 runtime 的 `ResumeInterrupted`,使用 persist-only sink 持久化恢复结果。 - -## 5. Mermaid 图 - -### 5.1 总体分层图 - -```mermaid -flowchart TB - Client[前端 Assistant UI] --> Router[AI Router] - Router --> Ctrl[AICtrl] - Ctrl --> Service[AIService] - Service --> Runtime[AIRuntime] - Runtime --> Sink[AIRuntimeSink] - - Sink --> SSE[SSE StreamWriter] - Sink --> Projector[aiMessageProjector] - Projector --> Repo[AIRepository] - Repo --> DB[(MySQL)] - - Runtime --> Eino[EinoAIRuntime] - Runtime --> Local[LocalAIRuntime] - Eino --> Agent[Eino Agent / Runner] - Agent --> Tools[Task / Progress / Docs Tools] - Eino --> Redis[(Redis Checkpoint / Envelope / Command Bus)] -``` - -### 5.2 流式请求链路图 - -```mermaid -sequenceDiagram - participant U as User - participant C as AICtrl - participant S as AIService - participant R as AIRuntime - participant K as aiStreamSink - participant W as SSE Writer - participant DB as MySQL - - U->>C: POST /ai/conversations/{id}/stream - C->>C: bind request + create SSE writer - C->>S: StreamConversation(ctx,userID,id,req,writer) - S->>DB: load conversation + user - S->>R: Plan(input) - R-->>S: AIRuntimePlan - S->>DB: transaction: conversation generating + messages + interrupt - S->>R: Execute(input,sink) - R->>K: Emit(conversation_started / tool / token / done) - K->>W: WriteEvent - K->>DB: persist projected message - R-->>S: exec result - S->>DB: finish conversation generating=false -``` - -### 5.3 Interrupt / Decision / Resume 图 - -```mermaid -sequenceDiagram - participant U as User - participant R as EinoAIRuntime - participant A as ApprovalMiddleware - participant K as aiStreamSink - participant S as AIService - participant Redis as Redis - participant DB as MySQL - - R->>A: call search_project_docs - A-->>R: StatefulInterrupt - R->>Redis: upsert envelope + renew lease - R->>K: tool_call_waiting_confirmation - K->>DB: message idle + interrupt awaiting - U->>S: POST decision(confirm/skip) - S->>DB: lock interrupt + save decision - S->>R: SubmitDecision or command bus - R->>A: ResumeWithParams - A-->>R: execute tool or skip - R->>K: confirmation_result + tool result + final answer - K->>DB: persist completed message / interrupt -``` - -## 6. 面试时如何介绍这套 AI 架构 - -可以这样讲: - -这套 AI 不是简单的模型代理,而是一个业务内嵌的可恢复流式 Agent 子系统。入口在 Gin Router 和 Controller,Controller 只处理 HTTP、鉴权上下文和 SSE writer。真正业务编排在 `AIService`:它先校验会话归属和并发状态,再生成运行计划,事务化创建用户消息、assistant 消息和必要的 interrupt,然后把执行交给 runtime。 - -Runtime 通过 `AIRuntime` 抽象隔离,正式实现是 `EinoAIRuntime`,本地降级是 `LocalAIRuntime`。Eino 负责 Agent、Tool、Checkpoint 和 Interrupt / Resume;Local 用于测试和 fallback。runtime 不直接写 HTTP 或数据库,而是只向 `AIRuntimeSink` 发事件。`aiStreamSink` 是系统里的关键桥接层,它把同一个事件同时写到 SSE 和数据库投影,所以实时输出和历史消息恢复是一致的。 - -人工确认通过 `AIInterrupt`、Eino `ApprovalMiddleware`、Redis checkpoint / envelope 和 decision 接口完成。文档工具执行前会中断,前端提交 confirm / skip 后,系统在原运行上下文里 resume;如果节点丢失,control plane 会尝试恢复或安全停止。这让 AI 对话既能流式输出,又能被持久化、审计和恢复。 - -## 待确认 - -当前文档按代码现状整理。以下内容如果后续实现变化,需要同步修订: - -1. `planAIRuntime` 当前仍是规则化意图识别,未来如果改成模型辅助规划,本文的 planner 说明需要更新。 -2. 当前必须人工确认的工具是 `search_project_docs`,如果更多工具引入确认流程,interrupt 章节需要扩展。 -3. 当前 recovery 只能在可证明安全的状态恢复,否则停止该轮;如果未来支持前端断线重连并重新附着原流,需要补充重连协议。 diff --git "a/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" "b/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" index cb974f3..c69fca8 100644 --- "a/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" +++ "b/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" @@ -122,4 +122,5 @@ internal/ - +我能不能在拆分的时候,先把这部分给踢掉,等后期在新添加上去,顺便把A2UI这个踢掉, + diff --git a/docs/AI/stage3_agent_runtime_assessment.md b/docs/AI/stage3_agent_runtime_assessment.md deleted file mode 100644 index f3f8fac..0000000 --- a/docs/AI/stage3_agent_runtime_assessment.md +++ /dev/null @@ -1,73 +0,0 @@ -# 项目当前阶段判断 - -- 本结论综合了 4 个并行 `gpt-5.4` 子任务结果:公开资料研究、本地代码审计、下一阶段方案设计、架构与面试包装;并结合本地只读验证 `go test ./internal/service/system -count=1`、`go test ./internal/infrastructure/ai/eino -count=1`。 -- 当前项目已进入“第二阶段正式运行时骨架基本完成,但第三阶段生产级闭环尚未完成”的状态。更准确地说,它已经不是 `LocalAIRuntime` demo,而是以 `EinoAIRuntime + Qwen + 单 SSE 流 + decision 控制` 为核心的 Agent Runtime 骨架。 -- 冲突消解一:关于“runtime 真相是否已迁出 local”。最终判断为“执行真相已迁出,规划与策略真相只部分迁出”。依据是 `EinoAIRuntime` 已是默认正式路径,但 planner 仍以 regex 和硬编码模板为主,且部分共享逻辑仍散落在 `internal/service/system/aiRuntimeLocal.go:347`。 -- 冲突消解二:关于“下一阶段主线应该是多节点恢复,还是先做 eval/observability”。最终判断是“主线做运行控制面闭环,eval/observability 作为同阶段验收与治理支撑”。原因是多节点 owner 路由和 durable resume 直接决定系统是否能在生产里成立,而 eval/observability 决定它是否可持续维护。 -- 冲突消解三:关于“等待确认期间新消息策略”。资料研究更倾向先明确 `reject`,代码现状也是 `busy reject`,而主文档曾写“新消息优先”。最终建议第三阶段先冻结为“显式 `reject while waiting`”,同步修正文档;等 owner 路由和 recovery 做完,再考虑真正的 interrupt 抢占。原因是现在直接支持抢占,会把 partial tool/UI/trace 清理和多节点恢复耦合到一起,风险过高。 - -# 已完成能力 - -- `EinoAIRuntime + Qwen` 已成为默认正式运行时骨架。依据见 `internal/core/config.go:58`、`internal/service/system/aiRuntimeFactory.go:16`、`internal/infrastructure/ai/eino/agent_factory.go:42`。 -- 共享 planner 已从单纯 local runtime 内部逻辑中抽出,`LocalAIRuntime` 与 `EinoAIRuntime` 已复用同一份规划入口。依据见 `internal/service/system/aiPlanner.go:10`、`internal/service/system/aiRuntimeLocal.go:152`、`internal/service/system/aiRuntimeEino.go:161`。 -- 正式上下文已收回服务端推导,前端上传的兼容字段不再是唯一真相。依据见 `internal/service/system/aiContext.go:28`、`internal/service/system/aiContext.go:71`。 -- `get_task_snapshot`、`get_progress_snapshot`、`search_project_docs` 已进入统一 Eino 工具链,task/progress 已不再依赖 local 假结果。依据见 `internal/service/system/aiRuntimeEino.go:107`、`internal/infrastructure/ai/eino/task_progress_tools.go:11`、`internal/infrastructure/ai/eino/docs_tool.go:27`。 -- 单节点原流 interrupt/resume 已闭合,`checkpoint_id / resume_target_id / tool_name / runtime_name` 已形成最小 runtime state。依据见 `internal/service/system/aiSvc.go:297`、`internal/service/system/aiSvc.go:307`、`internal/service/system/aiRuntimeEino.go:199`、`internal/service/system/aiRuntimeEino.go:297`。 -- 外部协议仍然稳定,6 个 API 和 10 个 SSE 事件没有被 Eino/A2UI 改写,且 sink 已具备单调状态合并和 waiting UI 收口能力。依据见 `internal/model/dto/response/aiResp.go:112`、`internal/service/system/aiSink.go:126`、`internal/service/system/aiSink.go:304`。 -- 作为面试项目,它已经“可讲”,而且能讲成“业务协议稳定前提下的 Agent Runtime 迁移工程”,不是简单的“接了个模型 SDK”。 - -# 缺失的关键闭环 - -- 多节点 interrupt owner 路由未完成。`OwnerNodeID` 已入库,但 `SubmitDecision` 仍直接打当前进程 runtime,没有命令路由。依据见 `internal/service/system/aiSvc.go:313`、`internal/service/system/aiSvc.go:360`。 -- durable resume 未完成。checkpoint 已进 Redis,但等待中的 decision 通道仍依赖本进程内存 registry;节点切换或进程重启时闭环会断。依据见 `internal/service/system/aiRuntimeLocal.go:22`、`internal/infrastructure/ai/eino/checkpoint_store.go:13`。 -- “等待确认期间新消息”存在文档与代码冲突。主文档写过“新消息轮次优先”,当前实现则直接 `busy reject`。依据见 `docs/AI助手架构设计方案.md:189`、`internal/service/system/aiSvc.go:245`。 -- 工具执行链虽然统一了,但规划、审批和策略框架还未正式化。当前仍以 regex planner 和 `search_project_docs` 特判审批为主,且部分共享模板仍留在 local runtime 文件中。依据见 `internal/service/system/aiPlanner.go:49`、`internal/infrastructure/ai/eino/approval_middleware.go:17`、`internal/service/system/aiRuntimeLocal.go:314`。 -- 权限、审计、超时、内部错误码、fallback 治理还没有形成正式生产边界。当前 factory 会回退 local runtime,但缺少明确审计和指标。依据见 `internal/service/system/aiRuntimeFactory.go:21`。 -- 回归与可观测性还不够硬。现有测试已覆盖部分 sink 和 runtime path,但 `skip / revoke / history reload / owner lost / checkpoint missing / multi-node` 仍缺系统化回归;history reload 也缺直接测试。 -- 结论上,当前项目最缺的不是“更多工具”,而是 `OwnerNodeID -> command route -> recovery -> policy -> metrics -> regression` 这条生产闭环。 - -# 下一阶段最值得做的事项 - -- 只做一条主线时,最推荐路线是:把 AI 子域从“单节点会话 runtime”升级为“可恢复、可路由、可观测的 runtime control plane”。 -- 第一优先级是多节点 owner 路由。没有它,`decision` 命中非 owner 节点时就会失败或误判 unavailable,当前 `OwnerNodeID` 只是字段,不是机制。 -- 第二优先级是 durable resume。目标不是新增第二条 SSE 流,而是让服务端运行控制面在 owner 节点失联后仍能安全收口或后台恢复。 -- 第三优先级是工具执行边界正式化。task/progress/doc 三类工具应统一补上 `ToolExecutionPolicy`、审计记录、超时包装和内部错误码。 -- 第四优先级是 fallback 治理。`LocalAIRuntime` 可以继续保留,但只能作为开发或显式 fallback;生产不能静默退化。 -- 第五优先级是 eval / regression / observability。先做事件级与 trace 级回归,再做小样本 eval,不要先陷入最终文本质量比较。 -- 如果只允许补 2 到 3 个 feature 来增强面试竞争力,最值钱的是:多节点 owner 路由与 recovery、AI observability + fallback dashboard、事件级 regression harness。 - -# 面试表达建议 - -- 当前项目可以讲,但要讲成“业务协议稳定前提下,把本地占位 runtime 迁移为 Eino 正式运行时骨架,并保住 interrupt/resume、checkpoint、single SSE stream、decision control 的工程项目”。 -- 当前最核心的 3 个亮点是:一,`EinoAIRuntime + Qwen` 已成为正式骨架而不是 demo fallback;二,顶层协议没有被框架示例绑架,仍保持单 SSE 流和独立 decision 控制口;三,task/progress/doc 已被统一进正式工具链,消息状态和 interrupt 状态已具备单调收口能力。 -- 当前最危险的 3 个短板是:一,多节点 owner 路由与 durable resume 未闭合;二,新消息抢占策略未正式定稿且文档与代码不一致;三,缺少生产级审计、fallback 监控和事件级 regression。 -- 面试时不要把它包装成“全功能 Agent 平台”,而应准确表述为“第二阶段正式骨架已经完成,第三阶段准备补运行控制面闭环”。这种表述更真实,也更能体现架构判断。 -- 一版可直接使用的项目介绍话术:`我做的是一个面向业务对话的 Agent Runtime 迁移项目。核心不是接大模型,而是在不改 6 个 API 和 10 个 SSE 事件协议的前提下,把原来的 LocalAIRuntime 迁到 Eino,并把 interrupt/resume、checkpoint、Qwen 模型接入、任务/进度/文档工具链统一起来。现在项目已经完成第二阶段正式骨架,下一阶段重点不是加更多工具,而是补多节点 owner 路由、durable resume、fallback 治理和事件级 regression,让它真正具备生产级运行控制能力。` -- 如果被追问“为什么不直接用 A2UI 或第二条续跑流”,推荐回答:`因为这个项目首先要守住既有业务协议和历史消息模型。Eino 负责 runtime,业务层继续输出稳定 SSE 事件;decision 只做控制输入,不新开第二条续跑流,这样前端和历史回放成本最低,也更符合现有架构边界。` - -# 推荐实施计划 - -第一批已落地范围: - -- `SubmitDecision` 已改成“先持久化,再按 `OwnerNodeID` 路由”,remote decision 不再依赖命中本地 runtime 才算成功。 -- Redis envelope / lease / recovery worker 已接入;后台 durable resume 首批只覆盖 `runtime_name=eino` 且 `tool_name=search_project_docs` 的 interrupt。 -- “等待确认期间新消息”当前阶段已冻结为 `reject while waiting`,避免在 owner 路由和 recovery 未完全稳定前引入抢占语义。 - -1. 先做 `AIRuntimeCommandBus`,让 `SubmitDecision` 和 `RevokeUserSessions` 按 `OwnerNodeID` 路由到真正的 owner 节点;本地 `sessionRegistry` 保留,但只负责本节点等待会话唤醒。 -2. 再做 owner lease 和 runtime envelope,至少在 Redis 中补齐 `interrupt_id / owner_node_id / checkpoint_id / resume_target_id / tool_name / lease_expire_at`,并引入 recovery worker。 -3. 将 durable resume 定义为“服务端运行控制面可恢复”,不是“客户端断线自动续流”;第一版 recovery 只要求两件事:owner 丢失但未收到 decision 时安全收口,owner 丢失但已收到 decision 且 checkpoint 完整时可后台恢复到终态。 -4. 补 `ToolExecutionPolicy`、AI 审计表、统一 timeout wrapper 和内部 machine error code;外部 HTTP/SSE 协议不变,内部治理能力增强。 -5. 为 runtime factory 增加 `ai.allow_local_fallback` 及配套审计、指标、结构化日志;生产默认禁用静默 fallback。 -6. 建最小 regression harness,先验 plan、tool 选择、事件序列、interrupt 状态,而不是先比最终文本;第一批样例至少覆盖 `lightweight / task / progress / doc confirm / doc skip / cancel while waiting / revoke while waiting / history reload / owner lost / checkpoint missing`。 -7. 建 AI observability 最小指标:plan latency、tool latency、interrupt wait duration、decision-to-resume duration、resume success rate、recovery success rate、fallback rate、checkpoint miss rate。 -8. 同步修正文档,把“等待期间新消息”策略在第三阶段先固定为 `reject while waiting`,并说明这是当前阶段的收敛策略,不是永久产品结论。 - -# 风险与注意事项 - -- 不要在第三阶段引入第二条 SSE 续跑流,也不要让前端直接依赖 runtime 原始事件;继续保持业务 SSE 事件作为稳定协议面。 -- 不要把 interrupt 继续当普通错误处理。公开资料和当前代码方向都支持把它当一等控制语义;错误、超时、cancel、interrupt 应分开建模。 -- 不要在 owner 路由和 recovery 未完成前就实现“等待期间新消息抢占”;否则 partial tool 输出、waiting UI、trace 清理会和多节点恢复纠缠在一起。 -- 不要让 Qwen 继续以“假装 OpenAI provider”的语义存在;正式路径应继续是 Eino 原生 `qwen` provider + DashScope compatible endpoint。 -- 需要尽早冻结 checkpoint 相关序列化和 identity key 语义,至少包括 `checkpoint_id / interrupt_id / resume_target_id / owner_node_id / runtime_name`;否则后续线上恢复会被兼容性拖垮。 -- 文档要与代码同步,尤其是“新消息策略”“fallback 语义”“durable resume 边界”三处;这三处如果继续口径不一致,会同时影响开发、测试和面试叙述。 -- 资料依据主要来自官方和主流可靠资料:Eino Runner/HITL/interrupt-resume 重构、Eino Qwen 组件、LangGraph durable execution/HITL、LangSmith double-texting 与 eval 指南、OpenAI agent eval/trace grading 指南。下一阶段的设计应继续优先对齐这些成熟机制,而不是自造第二套语义。 diff --git "a/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" "b/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" new file mode 100644 index 0000000..15b930b --- /dev/null +++ "b/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" @@ -0,0 +1,29 @@ +1、加上思维链 + +1、完善本地的tool + +3、考虑是封装成mcp,还是做成skills + +4、把react完成 + + + + + + + + + + + + + +不丝滑的地方: + +1、需要添加一个深度思考,能够让我看到他在干嘛。 + +2、AI助手回答问题的时候,是一停顿一停顿的,让我觉得他的视觉操作,是真的非常差劲。 + +3、两个的方向不对,应该是用户在上,助手在下的,但是本项目刚好搞反。 + +![image-20260421204913857](C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20260421204913857.png) \ No newline at end of file diff --git a/plan/cross-module/approved-env-template-and-ai-config.md b/plan/cross-module/approved-env-template-and-ai-config.md new file mode 100644 index 0000000..0b588eb --- /dev/null +++ b/plan/cross-module/approved-env-template-and-ai-config.md @@ -0,0 +1,82 @@ +# 目标 + +- 盘点项目当前通过环境变量读取的配置项。 +- 补齐 `.env.example` 中缺失的模板变量,重点覆盖 AI、认证密钥、数据库连接补充项和本地开发必需项。 +- 如当前工作区不存在 `.env`,基于模板生成一份仅含占位值和注释的本地开发模板,方便后续手工填写。 + +# 范围 + +- `./.env.example` +- `./.env`(仅在用户确认后创建或更新模板,不写入真实生产密钥) +- 只读参考: + - `internal/core/config.go` + - `internal/model/config/ai.go` + - `README.md` + +# 改动 + +- 对照 `internal/core/config.go` 中已绑定的环境变量,补齐 `.env.example` 缺失项。 +- 新增 AI 配置模板,至少包括: + - `AI_PROVIDER` + - `AI_API_KEY` + - `AI_BASE_URL` + - `AI_MODEL` + - `AI_BY_AZURE` + - `AI_API_VERSION` + - `AI_SYSTEM_PROMPT` + - `AI_TEMPERATURE` + - `AI_MAX_COMPLETION_TOKENS` +- 补齐当前模板中容易遗漏但会影响启动或行为的基础配置项,视实际绑定情况纳入: + - `DB_CONFIG` + - `DB_MAX_IDLE_CONNS` + - `DB_MAX_OPEN_CONNS` + - `DB_LOG_MODE` + - `JWT_ACCESS_TOKEN_EXPIRY_TIME` + - `JWT_REFRESH_TOKEN_EXPIRY_TIME` + - `JWT_ISSUER` + - `REDIS_ACTIVE_USER_STATE_TTL_SECONDS` + - `REDIS_ACTIVE_USER_STATE_TTL_JITTER_SECONDS` + - `STORAGE_LOCAL_BASE_URL` + - `STORAGE_LOCAL_KEY_PREFIX` + - `STORAGE_QINIU_KEY_PREFIX` +- 在模板注释中区分: + - 本地启动必填 + - 使用特定能力时必填 + - 可保持默认 +- 若创建本地 `.env`,默认写入可运行的开发占位模板: + - 数据库和 Redis 指向本机 + - 存储驱动默认使用 `local` + - AI 默认给出推荐 Provider 与 BaseURL 占位,但 `AI_API_KEY` 保留空值待用户填写 + - JWT / Session 写入明显标识为“仅本地开发使用”的占位密钥 + +# 验证 + +- 对照 `internal/core/config.go` 中 `BindEnv` 项,确认新增模板键名与代码一致。 +- 检查 `.env.example` 分组结构是否清晰,避免重复或互相冲突的键。 +- 若创建 `.env`,执行一次只读检查,确认关键启动项均已存在: + - MySQL + - Redis + - JWT + - Session + - AI + - Storage + +# 风险 + +- 若直接写入真实密钥,存在泄露风险;本次仅应写占位值或本地开发临时值。 +- 若把可选能力配置误标为启动必填,会增加本地启动门槛。 +- 若遗漏 `storage.current` 与 AI 相关默认值,用户后续仍可能在存储或 AI 初始化阶段踩坑。 + +# 执行顺序 + +1. 整理代码中已绑定的环境变量清单,并按模块分组。 +2. 设计 `.env.example` 的补齐方案,优先覆盖本地启动必需项和 AI 配置。 +3. 更新 `.env.example` 注释与占位值。 +4. 如用户需要,同时创建或更新本地 `.env` 模板。 +5. 做键名与分组校验,并向用户汇总哪些值仍需其手工填写。 + +# 待确认 + +- 是否只更新 `.env.example`,还是同时创建一份本地 `.env` 模板。 +- 本地 AI 模板是否默认按 `qwen + DashScope` 填推荐值,还是保留通用 OpenAI 兼容占位。 +- JWT / Session 是否允许我直接写入本地开发临时随机值,还是统一保留占位字符串。 From ef96851de0b16d3a375737920dae28c011f61ed4 Mon Sep 17 00:00:00 2001 From: wang Date: Wed, 22 Apr 2026 12:52:49 +0800 Subject: [PATCH 08/17] =?UTF-8?q?=E6=B5=81=E7=95=85=E5=BA=A6=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/apifox/ai_assistant.openapi.json | 69 ++++++--- internal/domain/ai/event.go | 24 +++ internal/domain/ai/runtime_test.go | 9 ++ internal/infrastructure/ai/eino/runtime.go | 143 ++++++++++++++++++ internal/infrastructure/ai/local/runtime.go | 56 +++++++ .../infrastructure/ai/local/runtime_test.go | 16 ++ internal/model/dto/response/aiResp.go | 15 ++ internal/repository/system/aiRepo.go | 2 + internal/service/system/aiProjector.go | 119 ++++++++++++++- internal/service/system/aiProjector_test.go | 141 +++++++++++++++++ internal/service/system/aiSink.go | 32 +++- internal/service/system/aiSink_test.go | 76 ++++++++++ 12 files changed, 673 insertions(+), 29 deletions(-) create mode 100644 internal/service/system/aiProjector_test.go create mode 100644 internal/service/system/aiSink_test.go diff --git a/docs/apifox/ai_assistant.openapi.json b/docs/apifox/ai_assistant.openapi.json index 2f743bb..12d1ee2 100644 --- a/docs/apifox/ai_assistant.openapi.json +++ b/docs/apifox/ai_assistant.openapi.json @@ -3,7 +3,7 @@ "info": { "title": "z_cur/UI AI 助手模块 OpenAPI", "version": "1.2.0", - "description": "只覆盖 `z_cur/UI` 当前 AI 助手模块需要的接口,可直接导入 Apifox。\n当前正式方案以 `go/personal_assistant/docs/AI助手架构设计方案.md`、`z_cur/UI/src/types/assistant.types.ts` 与 `z_cur/UI/src/stores/assistant.ts` 为准。\n当前协议固定为“业务 SSE 事件流 + 内嵌 A2UI block”的混合模型,不切换到纯 A2UI 顶层协议。\nAI 回复区只允许四类可见内容:`思考摘要 / 工具意图 / 等待用户 / 最终正文`。\n统一约束:\n1. CRUD 与 decision 接口走 JSON BizResponse;`stream` 接口返回 `text/event-stream`。\n2. `stream` 是唯一聊天 SSE 流;decision 接口只提交控制命令,不返回第二条 SSE 流。\n3. `assistant_token` 与 `message_completed.content` 只承载最终正文。\n4. `structured_block.ui_block` 只允许 `thinking_summary_block / tool_intent_block / waiting_user_block`。\n5. `trace_items / scope` 继续作为恢复与上下文字段存在,但不是默认可见内容。\n6. 命中 interrupt 后,服务端在原流内等待决策并恢复输出;旧的工具续跑流接口已废弃。" + "description": "覆盖当前 `personal-assistant-frontend` AI 助手模块使用的接口,可直接导入 Apifox。\n当前 V1 协议已经收敛为“单条聊天 SSE 流 + 思考短句流 + 最终正文流”。\n统一约束:\n1. CRUD 接口走 JSON BizResponse;`stream` 接口返回 `text/event-stream`。\n2. `stream` 是唯一聊天 SSE 流,不恢复 decision / interrupt / 第二条流。\n3. `thinking_started / thinking_delta / thinking_completed` 只承载用户可见的外显思考。\n4. `assistant_token` 与 `message_completed.content` 只承载最终正文。\n5. `trace_items` 持久化 `thinking_summary`,用于刷新后恢复思考区历史。\n6. `ui_blocks / scope` 字段继续保留兼容,但当前 V1 默认不下发结构化 UI。" }, "servers": [ { @@ -462,7 +462,7 @@ "AI助手" ], "summary": "发送用户消息并开启唯一聊天 SSE 流", - "description": "`stream` 是单轮问答的唯一事件流。\n命中 interrupt 后,服务端应发送 `tool_call_waiting_confirmation`,并在同一条连接上保持 keepalive;用户随后调用 decision 接口,服务端在原流内继续输出 `tool_call_confirmation_result / structured_block / assistant_token / message_completed / done`。\nV1 不做“断流后重新附着到同一轮运行”;连接断开即本轮失败或停止。\n问候语、寒暄和无业务目标的短消息可直接走轻量直答路径,不进入重型工具链路。\n`assistant_token` 和 `message_completed.content` 只用于最终正文;思考摘要、工具意图和等待用户通过 `structured_block.ui_block` 返回。\n`scope` 仅在复杂范围时通过 `structured_block.scope` 返回,普通同上下文对话默认不发。\n事件 payload 结构见 `AssistantEventPayloadCatalog`。", + "description": "`stream` 是单轮问答的唯一事件流。\n当前事件顺序固定为:`conversation_started -> thinking_started -> thinking_delta* -> thinking_completed -> assistant_token* -> message_completed -> done`;失败时输出 `error -> done`。\n`thinking_*` 事件用于展示用户可见的外显思考,不暴露模型私有原始推理。\n`assistant_token` 和 `message_completed.content` 只用于最终正文。\nV1 不恢复 interrupt / decision / structured_block 工具链路。\n事件 payload 结构见 `AssistantEventPayloadCatalog`。", "operationId": "StreamAssistantConversationMessage", "parameters": [ { @@ -503,7 +503,7 @@ "examples": { "sameStreamResume": { "summary": "原流等待确认并在同一连接内继续输出", - "value": "event: conversation_started\ndata: {\"title\":\"任务汇报与项目摘要\"}\n\nevent: structured_block\ndata: {\"ui_block\":{\"key\":\"block_thinking_summary_001\",\"type\":\"thinking_summary_block\",\"surface\":{\"id\":\"surface_thinking_summary_001\",\"root\":\"thinking_card_root\",\"components\":[{\"id\":\"thinking_card_root\",\"type\":\"Card\",\"tone\":\"muted\",\"children\":[\"thinking_title\",\"thinking_points\"]},{\"id\":\"thinking_title\",\"type\":\"Text\",\"value\":\"当前判断与下一步\",\"usage_hint\":\"title\"},{\"id\":\"thinking_points\",\"type\":\"BulletList\",\"items\":[\"当前判断:这个问题需要结合最新业务数据或项目文档来回答。\",\"当前动作:正在安排所需工具,避免只靠上下文猜测。\",\"下一步:工具结果返回后再输出最终正文。\"]}]}}}\n\nevent: structured_block\ndata: {\"ui_block\":{\"key\":\"block_tool_intent_001\",\"type\":\"tool_intent_block\",\"surface\":{\"id\":\"surface_tool_intent_001\",\"root\":\"tool_intent_card_root\",\"components\":[{\"id\":\"tool_intent_card_root\",\"type\":\"Card\",\"tone\":\"warning\",\"children\":[\"tool_badge\",\"tool_title\",\"tool_points\"]},{\"id\":\"tool_badge\",\"type\":\"Badge\",\"label\":\"等待确认\",\"tone\":\"warning\"},{\"id\":\"tool_title\",\"type\":\"Text\",\"value\":\"文档工具需要你的确认\",\"usage_hint\":\"title\"},{\"id\":\"tool_points\",\"type\":\"BulletList\",\"items\":[\"目的:当前准备使用任务快照、项目文档来补齐回答依据。\",\"必要性:现有结果已够形成初步判断,但缺少正式文档支持。\",\"预期收益:继续后能明确页面定位、知识源和接入方式。\",\"确认要求:是否继续读取正式项目文档需要你决定。\"]}]}}}\n\nevent: structured_block\ndata: {\"ui_block\":{\"key\":\"block_waiting_user_001\",\"type\":\"waiting_user_block\",\"surface\":{\"id\":\"surface_waiting_user_001\",\"root\":\"waiting_card_root\",\"components\":[{\"id\":\"waiting_card_root\",\"type\":\"Card\",\"tone\":\"warning\",\"children\":[\"waiting_title\",\"waiting_description\",\"waiting_points\"]},{\"id\":\"waiting_title\",\"type\":\"Text\",\"value\":\"是否继续使用“项目文档摘要”工具?\",\"usage_hint\":\"title\"},{\"id\":\"waiting_description\",\"type\":\"Text\",\"value\":\"继续后会读取正式文档白名单并补充引用摘要;跳过则只基于已有业务数据继续输出。\",\"usage_hint\":\"body\"},{\"id\":\"waiting_points\",\"type\":\"BulletList\",\"items\":[\"继续后:会读取正式项目文档白名单,并把页面定位与接入说明补进回答。\",\"跳过后:只基于当前已拿到的业务数据继续输出。\"]}]}}}\n\nevent: tool_call_waiting_confirmation\ndata: {\"interrupt_id\":\"intr_20260406_doc_001\",\"key\":\"tool_doc_snapshot\",\"title\":\"读取正式项目文档\",\"description\":\"读取 README、架构设计方案和 AI UI 改造说明。\",\"confirmation_title\":\"是否继续使用“项目文档摘要”工具?\",\"confirmation_description\":\"继续后会读取正式文档白名单并补充引用摘要;跳过则只基于已有业务数据继续输出。\",\"actions\":[{\"key\":\"confirm_doc\",\"label\":\"继续使用\",\"action\":\"confirm\",\"style\":\"primary\"},{\"key\":\"skip_doc\",\"label\":\"跳过此工具\",\"action\":\"skip\",\"style\":\"default\"}]}\n\n: keepalive\n\n: user calls POST /ai/conversations/conv_20260406_001/interrupts/intr_20260406_doc_001/decision\n\nevent: tool_call_confirmation_result\ndata: {\"interrupt_id\":\"intr_20260406_doc_001\",\"key\":\"tool_doc_snapshot\",\"decision\":\"confirm\",\"status\":\"pending\",\"description\":\"已确认继续读取正式项目文档,准备补充最终回答。\"}\n\nevent: assistant_token\ndata: {\"token\":\"当前任务主线已进入收口阶段,主要阻塞集中在少数成员补齐和回归验证。\"}\n\nevent: message_completed\ndata: {\"content\":\"当前任务主线已进入收口阶段,主要阻塞集中在少数成员补齐和回归验证。\\n\\n- 任务完成率:81%\\n- 覆盖成员:17 / 21\\n- 阻塞项:3\\n\\n补充正式项目文档后,可以确认两点:\\n\\n- 助手继续挂在控制台 Workbench 中,不额外拆独立站点。\\n- 后端接入继续采用单条 SSE 聊天流 + interrupt decision 控制接口。\"}\n\nevent: done\ndata: {}\n" + "value": "event: conversation_started\ndata: {\"title\":\"介绍一下这个项目\"}\n\nevent: thinking_started\ndata: {\"title\":\"深度思考\"}\n\nevent: thinking_delta\ndata: {\"delta\":\"正在理解你的问题,先提炼核心目标和约束。\\n\"}\n\nevent: thinking_delta\ndata: {\"delta\":\"当前关注点:需要先说明项目定位,再补充主要能力。\\n\"}\n\nevent: thinking_completed\ndata: {\"content\":\"正在理解你的问题,先提炼核心目标和约束。\\n当前关注点:需要先说明项目定位,再补充主要能力。\\n下一步会按重点组织回答,再输出正式结果。\"}\n\nevent: assistant_token\ndata: {\"token\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\"}\n\nevent: assistant_token\ndata: {\"token\":\"\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: message_completed\ndata: {\"content\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: done\ndata: {}\n" } } }, @@ -1410,12 +1410,10 @@ "type": "string", "enum": [ "conversation_started", + "thinking_started", + "thinking_delta", + "thinking_completed", "assistant_token", - "tool_call_started", - "tool_call_finished", - "tool_call_waiting_confirmation", - "tool_call_confirmation_result", - "structured_block", "message_completed", "error", "done" @@ -1432,6 +1430,39 @@ } } }, + "AssistantThinkingStartedPayload": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string" + } + } + }, + "AssistantThinkingDeltaPayload": { + "type": "object", + "required": [ + "delta" + ], + "properties": { + "delta": { + "type": "string" + } + } + }, + "AssistantThinkingCompletedPayload": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string" + } + } + }, "AssistantTokenPayload": { "type": "object", "required": [ @@ -1614,23 +1645,17 @@ "conversation_started": { "$ref": "#/components/schemas/AssistantConversationStartedPayload" }, - "assistant_token": { - "$ref": "#/components/schemas/AssistantTokenPayload" - }, - "tool_call_started": { - "$ref": "#/components/schemas/AssistantToolCallStartedPayload" - }, - "tool_call_finished": { - "$ref": "#/components/schemas/AssistantToolCallFinishedPayload" + "thinking_started": { + "$ref": "#/components/schemas/AssistantThinkingStartedPayload" }, - "tool_call_waiting_confirmation": { - "$ref": "#/components/schemas/AssistantToolCallWaitingConfirmationPayload" + "thinking_delta": { + "$ref": "#/components/schemas/AssistantThinkingDeltaPayload" }, - "tool_call_confirmation_result": { - "$ref": "#/components/schemas/AssistantToolCallConfirmationResultPayload" + "thinking_completed": { + "$ref": "#/components/schemas/AssistantThinkingCompletedPayload" }, - "structured_block": { - "$ref": "#/components/schemas/AssistantStructuredBlockPayload" + "assistant_token": { + "$ref": "#/components/schemas/AssistantTokenPayload" }, "message_completed": { "$ref": "#/components/schemas/AssistantMessageCompletedPayload" diff --git a/internal/domain/ai/event.go b/internal/domain/ai/event.go index 64c9e26..3cf28a3 100644 --- a/internal/domain/ai/event.go +++ b/internal/domain/ai/event.go @@ -8,6 +8,15 @@ const ( // EventConversationStarted 表示一次 AI 流式生成已经开始。 EventConversationStarted EventName = "conversation_started" + // EventThinkingStarted 表示面向用户可见的外显思考阶段开始。 + EventThinkingStarted EventName = "thinking_started" + + // EventThinkingDelta 表示外显思考阶段追加的一段文本。 + EventThinkingDelta EventName = "thinking_delta" + + // EventThinkingCompleted 表示外显思考阶段已经结束。 + EventThinkingCompleted EventName = "thinking_completed" + // EventAssistantToken 表示 assistant 本次追加输出的一段文本。 EventAssistantToken EventName = "assistant_token" @@ -38,6 +47,21 @@ type ConversationStartedPayload struct { Title string `json:"title"` } +// ThinkingStartedPayload 表示外显思考开始事件的载荷。 +type ThinkingStartedPayload struct { + Title string `json:"title"` +} + +// ThinkingDeltaPayload 表示外显思考追加事件的载荷。 +type ThinkingDeltaPayload struct { + Delta string `json:"delta"` +} + +// ThinkingCompletedPayload 表示外显思考结束事件的载荷。 +type ThinkingCompletedPayload struct { + Content string `json:"content"` +} + // AssistantTokenPayload 表示 assistant token 追加事件的载荷。 type AssistantTokenPayload struct { Token string `json:"token"` diff --git a/internal/domain/ai/runtime_test.go b/internal/domain/ai/runtime_test.go index 7ec315a..6688695 100644 --- a/internal/domain/ai/runtime_test.go +++ b/internal/domain/ai/runtime_test.go @@ -3,6 +3,15 @@ package ai import "testing" func TestEventNamesAreStable(t *testing.T) { + if EventThinkingStarted != "thinking_started" { + t.Fatalf("EventThinkingStarted = %q", EventThinkingStarted) + } + if EventThinkingDelta != "thinking_delta" { + t.Fatalf("EventThinkingDelta = %q", EventThinkingDelta) + } + if EventThinkingCompleted != "thinking_completed" { + t.Fatalf("EventThinkingCompleted = %q", EventThinkingCompleted) + } if EventAssistantToken != "assistant_token" { t.Fatalf("EventAssistantToken = %q", EventAssistantToken) } diff --git a/internal/infrastructure/ai/eino/runtime.go b/internal/infrastructure/ai/eino/runtime.go index 1abf8d5..950f97b 100644 --- a/internal/infrastructure/ai/eino/runtime.go +++ b/internal/infrastructure/ai/eino/runtime.go @@ -87,6 +87,10 @@ func (r *Runtime) Stream( return aidomain.StreamResult{}, err } + if err := r.emitVisibleThinking(ctx, input, sink); err != nil { + return aidomain.StreamResult{}, err + } + reader, err := r.model.Stream(ctx, r.buildMessages(input)) if err != nil { return aidomain.StreamResult{}, err @@ -127,6 +131,78 @@ func (r *Runtime) Stream( return aidomain.StreamResult{Content: content, FinishReason: "stop"}, nil } +func (r *Runtime) emitVisibleThinking( + ctx context.Context, + input aidomain.StreamInput, + sink aidomain.Sink, +) error { + content, err := r.generateThinkingSummary(ctx, input) + if err != nil { + content = buildVisibleThinkingSummary(input.Content) + } + content = normalizeThinkingSummary(content, input.Content) + if content == "" { + return nil + } + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventThinkingStarted, + Payload: aidomain.ThinkingStartedPayload{Title: "深度思考"}, + }); err != nil { + return err + } + for _, chunk := range splitTextChunks(content, 24) { + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventThinkingDelta, + Payload: aidomain.ThinkingDeltaPayload{Delta: chunk}, + }); err != nil { + return err + } + } + return sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventThinkingCompleted, + Payload: aidomain.ThinkingCompletedPayload{Content: content}, + }) +} + +func (r *Runtime) generateThinkingSummary(ctx context.Context, input aidomain.StreamInput) (string, error) { + messages := []*schema.Message{ + schema.SystemMessage(strings.TrimSpace(` +你需要先输出一段可以直接展示给用户的“深度思考”短句。 +要求: +1. 只描述“当前判断 / 正在做什么 / 下一步是什么”。 +2. 不泄露模型私有推理,不展示完整推导链。 +3. 不输出最终答案,不要复述太多用户原文。 +4. 最多 3 行,总长度控制在 120 个中文字符以内。 +5. 直接输出正文,不要加标题、编号、markdown 列表符号。`)), + schema.UserMessage(strings.TrimSpace(input.Content)), + } + return r.readStreamContent(ctx, messages) +} + +func (r *Runtime) readStreamContent(ctx context.Context, messages []*schema.Message) (string, error) { + reader, err := r.model.Stream(ctx, messages) + if err != nil { + return "", err + } + defer reader.Close() + + var output strings.Builder + for { + msg, recvErr := reader.Recv() + if errors.Is(recvErr, io.EOF) { + break + } + if recvErr != nil { + return "", recvErr + } + if msg == nil || msg.Content == "" { + continue + } + output.WriteString(msg.Content) + } + return output.String(), nil +} + // buildMessages 把 domain 层历史消息转换成 Eino schema 消息。 // 参数: // - input:包含历史消息与当前用户输入的 StreamInput。 @@ -156,6 +232,73 @@ func (r *Runtime) buildMessages(input aidomain.StreamInput) []*schema.Message { return messages } +func buildVisibleThinkingSummary(content string) string { + content = strings.TrimSpace(strings.ReplaceAll(content, "\n", " ")) + if content == "" { + return strings.Join([]string{ + "正在确认输入是否完整,并收拢本轮回答目标。", + "下一步会先组织回答结构,再输出正式结果。", + }, "\n") + } + return strings.Join([]string{ + "正在理解你的问题,先提炼核心目标和约束。", + "当前关注点:" + truncateRunes(content, 24), + "下一步会按重点组织回答,再输出正式结果。", + }, "\n") +} + +func normalizeThinkingSummary(content string, fallback string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + lines := strings.Split(content, "\n") + filtered := make([]string, 0, 3) + for _, line := range lines { + line = strings.TrimSpace(strings.TrimLeft(line, "-*0123456789.、 ")) + if line == "" { + continue + } + filtered = append(filtered, line) + if len(filtered) == 3 { + break + } + } + if len(filtered) == 0 { + return buildVisibleThinkingSummary(fallback) + } + content = strings.Join(filtered, "\n") + return truncateRunes(content, 120) +} + +func splitTextChunks(content string, size int) []string { + if size <= 0 { + size = 24 + } + runes := []rune(content) + if len(runes) == 0 { + return nil + } + chunks := make([]string, 0, (len(runes)/size)+1) + for start := 0; start < len(runes); start += size { + end := start + size + if end > len(runes) { + end = len(runes) + } + chunks = append(chunks, string(runes[start:end])) + } + return chunks +} + +func truncateRunes(content string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(content)) + if len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) +} + // deriveTitle 根据用户输入生成会话开始事件标题。 func deriveTitle(content string) string { content = strings.TrimSpace(content) diff --git a/internal/infrastructure/ai/local/runtime.go b/internal/infrastructure/ai/local/runtime.go index e70100e..8576480 100644 --- a/internal/infrastructure/ai/local/runtime.go +++ b/internal/infrastructure/ai/local/runtime.go @@ -79,6 +79,10 @@ func (r *Runtime) Stream(ctx context.Context, input aidomain.StreamInput, sink a return aidomain.StreamResult{}, err } + if err := emitVisibleThinking(ctx, sink, buildThinkingSummary(input.Content)); err != nil { + return aidomain.StreamResult{}, err + } + reply := buildReply(input.Content) for _, chunk := range splitChunks(reply, 48) { if err := sink.Emit(ctx, aidomain.Event{ @@ -110,6 +114,22 @@ func buildReply(content string) string { return "我已收到你的问题:" + content + "\n\n当前阶段 AI 助手只保留基础流式对话能力;我会基于你的输入直接回答,不再调用工具或等待人工确认。" } +// buildThinkingSummary 为本地 runtime 生成用户可见的外显思考短句。 +func buildThinkingSummary(content string) string { + content = strings.TrimSpace(strings.ReplaceAll(content, "\n", " ")) + if content == "" { + return strings.Join([]string{ + "正在检查输入是否完整,并确认本轮回答目标。", + "下一步会先归纳问题重点,再输出正式回复。", + }, "\n") + } + return strings.Join([]string{ + "正在理解你的问题,先提炼核心目标和约束。", + "当前关注点:" + truncateRunes(content, 24), + "下一步会按重点组织回答,再输出正式结果。", + }, "\n") +} + // deriveTitle 根据用户输入生成会话开始事件中的标题。 // 作用:只做轻量截断,不引入模型或复杂规划。 func deriveTitle(content string) string { @@ -150,6 +170,42 @@ func splitChunks(content string, size int) []string { return chunks } +func emitVisibleThinking(ctx context.Context, sink aidomain.Sink, content string) error { + content = strings.TrimSpace(content) + if content == "" { + return nil + } + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventThinkingStarted, + Payload: aidomain.ThinkingStartedPayload{Title: "深度思考"}, + }); err != nil { + return err + } + for _, chunk := range splitChunks(content, 24) { + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventThinkingDelta, + Payload: aidomain.ThinkingDeltaPayload{Delta: chunk}, + }); err != nil { + return err + } + } + return sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventThinkingCompleted, + Payload: aidomain.ThinkingCompletedPayload{Content: content}, + }) +} + +func truncateRunes(content string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(content)) + if len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) +} + // runtimeError 表示本地 runtime 内部的轻量错误类型。 type runtimeError string diff --git a/internal/infrastructure/ai/local/runtime_test.go b/internal/infrastructure/ai/local/runtime_test.go index d4a1f89..e8f8558 100644 --- a/internal/infrastructure/ai/local/runtime_test.go +++ b/internal/infrastructure/ai/local/runtime_test.go @@ -37,6 +37,22 @@ func TestRuntimeStreamEmitsMinimalEvents(t *testing.T) { if sink.events[0].Name != aidomain.EventConversationStarted { t.Fatalf("first event = %q", sink.events[0].Name) } + foundThinkingStarted := false + foundThinkingCompleted := false + for _, event := range sink.events { + if event.Name == aidomain.EventThinkingStarted { + foundThinkingStarted = true + } + if event.Name == aidomain.EventThinkingCompleted { + foundThinkingCompleted = true + } + } + if !foundThinkingStarted { + t.Fatal("thinking_started event not found") + } + if !foundThinkingCompleted { + t.Fatal("thinking_completed event not found") + } if sink.events[len(sink.events)-1].Name != aidomain.EventDone { t.Fatalf("last event = %q", sink.events[len(sink.events)-1].Name) } diff --git a/internal/model/dto/response/aiResp.go b/internal/model/dto/response/aiResp.go index 529095d..44faa2d 100644 --- a/internal/model/dto/response/aiResp.go +++ b/internal/model/dto/response/aiResp.go @@ -106,6 +106,21 @@ type AssistantConversationStartedPayload struct { Title string `json:"title"` // 新会话生成后的标题。 } +// AssistantThinkingStartedPayload 表示可见思考开始事件的载荷。 +type AssistantThinkingStartedPayload struct { + Title string `json:"title"` // 思考区标题,默认显示为“深度思考”。 +} + +// AssistantThinkingDeltaPayload 表示可见思考追加事件的载荷。 +type AssistantThinkingDeltaPayload struct { + Delta string `json:"delta"` // 本次追加的思考文本片段。 +} + +// AssistantThinkingCompletedPayload 表示可见思考结束事件的载荷。 +type AssistantThinkingCompletedPayload struct { + Content string `json:"content"` // 思考区最终完整内容。 +} + // AssistantTokenPayload 表示流式输出 token 事件的载荷。 type AssistantTokenPayload struct { Token string `json:"token"` // 本次流式追加的 token 内容。 diff --git a/internal/repository/system/aiRepo.go b/internal/repository/system/aiRepo.go index 9cb3a78..ad81f6f 100644 --- a/internal/repository/system/aiRepo.go +++ b/internal/repository/system/aiRepo.go @@ -240,6 +240,8 @@ func (r *AIGormRepository) ListMessagesByConversation(ctx context.Context, conve if err := r.db.WithContext(ctx). Where("conversation_id = ?", conversationID). Order("created_at ASC"). + Order("CASE role WHEN 'user' THEN 0 WHEN 'assistant' THEN 1 ELSE 2 END ASC"). + Order("id ASC"). Find(&messages).Error; err != nil { return nil, err } diff --git a/internal/service/system/aiProjector.go b/internal/service/system/aiProjector.go index 1a9d5b6..80b16ba 100644 --- a/internal/service/system/aiProjector.go +++ b/internal/service/system/aiProjector.go @@ -2,14 +2,23 @@ package system import ( "context" + "encoding/json" + "strings" "sync" "time" aidomain "personal_assistant/internal/domain/ai" + resp "personal_assistant/internal/model/dto/response" "personal_assistant/internal/model/entity" "personal_assistant/internal/repository/interfaces" ) +const ( + thinkingTraceKey = "thinking_summary" + thinkingTraceTitle = "深度思考" + thinkingTraceDescription = "正在整理当前判断和下一步。" +) + // aiMessageProjector 负责把最小 runtime 事件折叠成 assistant 消息快照。 // 它只处理基础流式文本状态,不再维护 A2UI、tool trace、interrupt 状态机。 type aiMessageProjector struct { @@ -37,6 +46,7 @@ func (p *aiMessageProjector) setStopped() { if p.message != nil { p.message.Status = aiMessageStatusStopped + p.updateThinkingTraceStatusLocked(aiMessageStatusStopped) } } @@ -50,6 +60,7 @@ func (p *aiMessageProjector) setError(message string) { if p.message != nil { p.message.Status = aiMessageStatusError p.message.ErrorText = message + p.updateThinkingTraceStatusLocked(aiMessageStatusError) } } @@ -68,9 +79,15 @@ func (p *aiMessageProjector) persistMessage(ctx context.Context) error { if p.message == nil { return nil } - p.message.TraceItemsJSON = "[]" - p.message.UIBlocksJSON = "[]" - p.message.ScopeJSON = "{}" + if strings.TrimSpace(p.message.TraceItemsJSON) == "" { + p.message.TraceItemsJSON = "[]" + } + if strings.TrimSpace(p.message.UIBlocksJSON) == "" { + p.message.UIBlocksJSON = "[]" + } + if strings.TrimSpace(p.message.ScopeJSON) == "" { + p.message.ScopeJSON = "{}" + } p.message.UpdatedAt = time.Now() return p.repo.UpdateMessage(ctx, p.message) } @@ -94,6 +111,30 @@ func (p *aiMessageProjector) applyEvent(event aidomain.Event) { } switch event.Name { + case aidomain.EventThinkingStarted: + p.upsertThinkingTraceLocked(func(item *resp.AssistantTraceItem) { + item.Title = thinkingTraceTitle + item.Description = thinkingTraceDescription + item.Status = aiMessageStatusLoading + }) + case aidomain.EventThinkingDelta: + if payload, ok := event.Payload.(aidomain.ThinkingDeltaPayload); ok { + p.upsertThinkingTraceLocked(func(item *resp.AssistantTraceItem) { + item.Title = thinkingTraceTitle + item.Description = thinkingTraceDescription + item.Status = aiMessageStatusLoading + item.Content += payload.Delta + }) + } + case aidomain.EventThinkingCompleted: + p.upsertThinkingTraceLocked(func(item *resp.AssistantTraceItem) { + item.Title = thinkingTraceTitle + item.Description = thinkingTraceDescription + item.Status = aiMessageStatusSuccess + if payload, ok := event.Payload.(aidomain.ThinkingCompletedPayload); ok && strings.TrimSpace(payload.Content) != "" { + item.Content = payload.Content + } + }) case aidomain.EventAssistantToken: if payload, ok := event.Payload.(aidomain.AssistantTokenPayload); ok { p.message.Content += payload.Token @@ -107,10 +148,82 @@ func (p *aiMessageProjector) applyEvent(event aidomain.Event) { p.message.Content = payload.Content } p.message.Status = aiMessageStatusSuccess + p.updateThinkingTraceStatusLocked(aiMessageStatusSuccess) case aidomain.EventError: if payload, ok := event.Payload.(aidomain.ErrorPayload); ok { p.message.ErrorText = payload.Message } p.message.Status = aiMessageStatusError + p.updateThinkingTraceStatusLocked(aiMessageStatusError) + } +} + +func (p *aiMessageProjector) decodeTraceItemsLocked() []resp.AssistantTraceItem { + if p.message == nil || strings.TrimSpace(p.message.TraceItemsJSON) == "" { + return []resp.AssistantTraceItem{} + } + items := make([]resp.AssistantTraceItem, 0) + if err := json.Unmarshal([]byte(p.message.TraceItemsJSON), &items); err != nil { + return []resp.AssistantTraceItem{} + } + return items +} + +func (p *aiMessageProjector) encodeTraceItemsLocked(items []resp.AssistantTraceItem) { + raw, err := json.Marshal(items) + if err != nil { + p.message.TraceItemsJSON = "[]" + return + } + p.message.TraceItemsJSON = string(raw) +} + +func (p *aiMessageProjector) upsertThinkingTraceLocked(mutator func(item *resp.AssistantTraceItem)) { + if p.message == nil { + return + } + items := p.decodeTraceItemsLocked() + index := -1 + for idx, item := range items { + if item.Key == thinkingTraceKey { + index = idx + break + } + } + + var target resp.AssistantTraceItem + if index >= 0 { + target = items[index] + } else { + target = resp.AssistantTraceItem{ + Key: thinkingTraceKey, + Title: thinkingTraceTitle, + Description: thinkingTraceDescription, + Status: aiMessageStatusLoading, + } + } + + mutator(&target) + + if index >= 0 { + items[index] = target + } else { + items = append(items, target) + } + p.encodeTraceItemsLocked(items) +} + +func (p *aiMessageProjector) updateThinkingTraceStatusLocked(status string) { + if p.message == nil { + return + } + items := p.decodeTraceItemsLocked() + for idx, item := range items { + if item.Key != thinkingTraceKey { + continue + } + items[idx].Status = status + p.encodeTraceItemsLocked(items) + return } } diff --git a/internal/service/system/aiProjector_test.go b/internal/service/system/aiProjector_test.go new file mode 100644 index 0000000..ae8db4d --- /dev/null +++ b/internal/service/system/aiProjector_test.go @@ -0,0 +1,141 @@ +package system + +import ( + "context" + "encoding/json" + "testing" + "time" + + aidomain "personal_assistant/internal/domain/ai" + resp "personal_assistant/internal/model/dto/response" + "personal_assistant/internal/model/entity" + "personal_assistant/internal/repository/interfaces" +) + +type projectorRepoStub struct { + updateCalls int + lastMessage *entity.AIMessage +} + +var _ interfaces.AIRepository = (*projectorRepoStub)(nil) + +func (s *projectorRepoStub) CreateConversation(context.Context, *entity.AIConversation) error { return nil } +func (s *projectorRepoStub) GetConversationByID(context.Context, string) (*entity.AIConversation, error) { + return nil, nil +} +func (s *projectorRepoStub) GetConversationByIDForUpdate(context.Context, string) (*entity.AIConversation, error) { + return nil, nil +} +func (s *projectorRepoStub) ListConversationsByUser(context.Context, uint) ([]*entity.AIConversation, error) { + return nil, nil +} +func (s *projectorRepoStub) UpdateConversation(context.Context, *entity.AIConversation) error { return nil } +func (s *projectorRepoStub) DeleteConversationCascade(context.Context, string) error { return nil } +func (s *projectorRepoStub) CreateMessage(context.Context, *entity.AIMessage) error { return nil } +func (s *projectorRepoStub) UpdateMessage(_ context.Context, message *entity.AIMessage) error { + s.updateCalls++ + cloned := *message + s.lastMessage = &cloned + return nil +} +func (s *projectorRepoStub) ListMessagesByConversation(context.Context, string) ([]*entity.AIMessage, error) { + return nil, nil +} +func (s *projectorRepoStub) CreateInterrupt(context.Context, *entity.AIInterrupt) error { return nil } +func (s *projectorRepoStub) GetInterruptByID(context.Context, string) (*entity.AIInterrupt, error) { + return nil, nil +} +func (s *projectorRepoStub) GetInterruptByIDForUpdate(context.Context, string) (*entity.AIInterrupt, error) { + return nil, nil +} +func (s *projectorRepoStub) ListInterruptsByUserAndStatuses(context.Context, uint, []string) ([]*entity.AIInterrupt, error) { + return nil, nil +} +func (s *projectorRepoStub) ListInterruptsForRecovery(context.Context, []string, time.Time, int) ([]*entity.AIInterrupt, error) { + return nil, nil +} +func (s *projectorRepoStub) UpdateInterrupt(context.Context, *entity.AIInterrupt) error { return nil } +func (s *projectorRepoStub) WithTx(any) interfaces.AIRepository { return s } + +func TestAIMessageProjectorPersistsThinkingTrace(t *testing.T) { + repo := &projectorRepoStub{} + message := &entity.AIMessage{ + ID: "msg_ai_1", + ConversationID: "conv_1", + Role: "assistant", + Status: aiMessageStatusLoading, + TraceItemsJSON: "[]", + UIBlocksJSON: "[]", + ScopeJSON: "{}", + } + projector := newAIMessageProjector(repo, message) + + projector.applyEvent(aidomain.Event{ + Name: aidomain.EventThinkingStarted, + Payload: aidomain.ThinkingStartedPayload{Title: "深度思考"}, + }) + projector.applyEvent(aidomain.Event{ + Name: aidomain.EventThinkingDelta, + Payload: aidomain.ThinkingDeltaPayload{Delta: "正在拆解问题。"}, + }) + projector.applyEvent(aidomain.Event{ + Name: aidomain.EventThinkingCompleted, + Payload: aidomain.ThinkingCompletedPayload{Content: "正在拆解问题。\n下一步会组织正式回答。"}, + }) + + if err := projector.persistMessage(context.Background()); err != nil { + t.Fatalf("persistMessage() error = %v", err) + } + + traceItems := decodeTraceItemsForTest(t, repo.lastMessage.TraceItemsJSON) + if len(traceItems) != 1 { + t.Fatalf("trace items len = %d, want 1", len(traceItems)) + } + if traceItems[0].Key != thinkingTraceKey { + t.Fatalf("trace key = %q", traceItems[0].Key) + } + if traceItems[0].Status != aiMessageStatusSuccess { + t.Fatalf("trace status = %q", traceItems[0].Status) + } + if traceItems[0].Content != "正在拆解问题。\n下一步会组织正式回答。" { + t.Fatalf("trace content = %q", traceItems[0].Content) + } +} + +func TestAIMessageProjectorMarksThinkingStopped(t *testing.T) { + repo := &projectorRepoStub{} + message := &entity.AIMessage{ + ID: "msg_ai_2", + ConversationID: "conv_2", + Role: "assistant", + Status: aiMessageStatusLoading, + TraceItemsJSON: "[]", + } + projector := newAIMessageProjector(repo, message) + projector.applyEvent(aidomain.Event{ + Name: aidomain.EventThinkingDelta, + Payload: aidomain.ThinkingDeltaPayload{Delta: "正在归纳重点。"}, + }) + projector.setStopped() + + if err := projector.persistMessage(context.Background()); err != nil { + t.Fatalf("persistMessage() error = %v", err) + } + + traceItems := decodeTraceItemsForTest(t, repo.lastMessage.TraceItemsJSON) + if len(traceItems) != 1 { + t.Fatalf("trace items len = %d, want 1", len(traceItems)) + } + if traceItems[0].Status != aiMessageStatusStopped { + t.Fatalf("trace status = %q, want %q", traceItems[0].Status, aiMessageStatusStopped) + } +} + +func decodeTraceItemsForTest(t *testing.T, raw string) []resp.AssistantTraceItem { + t.Helper() + items := make([]resp.AssistantTraceItem, 0) + if err := json.Unmarshal([]byte(raw), &items); err != nil { + t.Fatalf("unmarshal trace items error = %v", err) + } + return items +} diff --git a/internal/service/system/aiSink.go b/internal/service/system/aiSink.go index 7127c8e..9061aec 100644 --- a/internal/service/system/aiSink.go +++ b/internal/service/system/aiSink.go @@ -16,6 +16,9 @@ import ( type aiStreamSink struct { writer streamsse.StreamWriter projector *aiMessageProjector + + persistInterval time.Duration + lastPersistAt time.Time } // newAIStreamSink 负责创建一条流式执行期间使用的 sink。 @@ -26,8 +29,9 @@ func newAIStreamSink( message *entity.AIMessage, ) *aiStreamSink { return &aiStreamSink{ - writer: writer, - projector: newAIMessageProjector(repo, message), + writer: writer, + projector: newAIMessageProjector(repo, message), + persistInterval: 400 * time.Millisecond, } } @@ -53,7 +57,10 @@ func (s *aiStreamSink) Emit(ctx context.Context, event aidomain.Event) error { return err } s.projector.applyEvent(event) - return s.projector.persistMessage(ctx) + if !s.shouldPersist(event.Name) { + return nil + } + return s.persistMessage(ctx) } // Heartbeat 负责向客户端发送 keepalive 心跳。 @@ -79,5 +86,22 @@ func (s *aiStreamSink) setError(message string) { // persistMessage 负责把当前内存态消息快照写回数据库。 // 作用:把当前 sink 内存里的最新状态写回数据库。 func (s *aiStreamSink) persistMessage(ctx context.Context) error { - return s.projector.persistMessage(ctx) + if err := s.projector.persistMessage(ctx); err != nil { + return err + } + s.lastPersistAt = time.Now() + return nil +} + +func (s *aiStreamSink) shouldPersist(name aidomain.EventName) bool { + switch name { + case aidomain.EventThinkingCompleted, aidomain.EventMessageCompleted, aidomain.EventError, aidomain.EventDone: + return true + case aidomain.EventConversationStarted: + return false + } + if s.persistInterval <= 0 || s.lastPersistAt.IsZero() { + return true + } + return time.Since(s.lastPersistAt) >= s.persistInterval } diff --git a/internal/service/system/aiSink_test.go b/internal/service/system/aiSink_test.go new file mode 100644 index 0000000..bb09f38 --- /dev/null +++ b/internal/service/system/aiSink_test.go @@ -0,0 +1,76 @@ +package system + +import ( + "context" + "testing" + "time" + + aidomain "personal_assistant/internal/domain/ai" + streamsse "personal_assistant/internal/infrastructure/sse" + "personal_assistant/internal/model/entity" +) + +type writerStub struct { + events []streamsse.StreamEvent +} + +func (w *writerStub) WriteEvent(_ context.Context, evt *streamsse.StreamEvent) error { + if evt != nil { + w.events = append(w.events, *evt) + } + return nil +} + +func (w *writerStub) WriteHeartbeat(context.Context) error { return nil } +func (w *writerStub) WriteTerminal(ctx context.Context, evt *streamsse.StreamEvent) error { + return w.WriteEvent(ctx, evt) +} + +func TestAIStreamSinkThrottlesPersistButForceFlushesFinalEvents(t *testing.T) { + repo := &projectorRepoStub{} + writer := &writerStub{} + message := &entity.AIMessage{ + ID: "msg_ai_sink", + ConversationID: "conv_sink", + Role: "assistant", + Status: aiMessageStatusLoading, + TraceItemsJSON: "[]", + UIBlocksJSON: "[]", + ScopeJSON: "{}", + } + sink := newAIStreamSink(repo, writer, message) + sink.persistInterval = time.Hour + + if err := sink.Emit(context.Background(), aidomain.Event{ + Name: aidomain.EventAssistantToken, + Payload: aidomain.AssistantTokenPayload{Token: "第一段"}, + }); err != nil { + t.Fatalf("first Emit() error = %v", err) + } + if repo.updateCalls != 1 { + t.Fatalf("update calls after first token = %d, want 1", repo.updateCalls) + } + + if err := sink.Emit(context.Background(), aidomain.Event{ + Name: aidomain.EventAssistantToken, + Payload: aidomain.AssistantTokenPayload{Token: "第二段"}, + }); err != nil { + t.Fatalf("second Emit() error = %v", err) + } + if repo.updateCalls != 1 { + t.Fatalf("update calls after throttled token = %d, want 1", repo.updateCalls) + } + + if err := sink.Emit(context.Background(), aidomain.Event{ + Name: aidomain.EventMessageCompleted, + Payload: aidomain.MessageCompletedPayload{Content: "第一段第二段"}, + }); err != nil { + t.Fatalf("message_completed Emit() error = %v", err) + } + if repo.updateCalls != 2 { + t.Fatalf("update calls after message_completed = %d, want 2", repo.updateCalls) + } + if len(writer.events) != 3 { + t.Fatalf("writer events len = %d, want 3", len(writer.events)) + } +} From 809c6d561b95c39da3f8f21b54a6317c6246cb96 Mon Sep 17 00:00:00 2001 From: wang Date: Wed, 22 Apr 2026 14:16:32 +0800 Subject: [PATCH 09/17] =?UTF-8?q?=E5=AE=8C=E5=96=84AI=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../personal-assistant-go-backend/SKILL.md | 39 +++++ .../references/project-rules.md | 33 ++++ AGENTS.md | 21 ++- ...66\346\236\204\346\213\206\345\210\206.md" | 24 +++ "docs/AI/Tool\350\256\276\350\256\241.md" | 0 ...66\346\256\265\346\274\224\350\277\233.md" | 8 +- docs/apifox/ai_assistant.openapi.json | 69 ++++++++- internal/domain/ai/event.go | 24 --- internal/domain/ai/runtime_test.go | 9 -- internal/infrastructure/ai/eino/runtime.go | 143 ------------------ internal/infrastructure/ai/local/runtime.go | 56 ------- .../infrastructure/ai/local/runtime_test.go | 16 -- internal/model/dto/response/aiResp.go | 15 -- internal/service/system/aiProjector.go | 125 +-------------- internal/service/system/aiProjector_test.go | 87 +++++------ internal/service/system/aiSink.go | 4 +- internal/service/system/aiSink_test.go | 30 ++++ ...77\233\345\274\217ddd-rules-and-skills.md" | 65 ++++++++ 18 files changed, 322 insertions(+), 446 deletions(-) create mode 100644 "docs/AI/Tool\350\256\276\350\256\241.md" create mode 100644 "plan/ai/approved-ai-\346\270\220\350\277\233\345\274\217ddd-rules-and-skills.md" diff --git a/.codex/skills/personal-assistant-go-backend/SKILL.md b/.codex/skills/personal-assistant-go-backend/SKILL.md index 093a648..83cdfab 100644 --- a/.codex/skills/personal-assistant-go-backend/SKILL.md +++ b/.codex/skills/personal-assistant-go-backend/SKILL.md @@ -26,6 +26,45 @@ description: 用于 personal_assistant 仓库的 Go 后端开发、重构、代 4. 用户要求新增或改造独立基础设施能力(如 Trace/Metric/Audit/限流适配/消息客户端): - 按“独立基础设施实现流程”执行(Config 到 Init,再到接入层)。 +## AI 子域渐进式 DDD 工作流 + +当任务落在 AI 子域时,默认按“`MVC 主体 + AI 子域渐进式 DDD`”理解当前项目,而不是假设仓库已经完成全量 DDD 改造。 + +### 第 1 步:先判断当前改动属于哪一层 + +- HTTP 参数、SSE 入口、路由挂载: + - 继续放在 `internal/controller/system`、`internal/router/system` +- AI 用例编排、上下文组装、tool 注册与授权收口、sink/projector 协调: + - 放在 `internal/service/system` +- 稳定协议、事件、tool/runtime 抽象、领域语义: + - 放在 `internal/domain/ai` +- Eino / Local runtime、第三方模型 SDK、tool adapter、checkpoint / approval 等技术实现: + - 放在 `internal/infrastructure/ai` +- AI 持久化访问: + - 继续放在 `internal/repository/interfaces` 与 `internal/repository/system` + +### 第 2 步:保持 MVC 外壳,不做过度改名 + +- 不要为了“更像 DDD”把现有 `controller/router/service/repository` 全部改名成 interfaces/application/infrastructure。 +- 当前仓库的推荐方向是: + - MVC 外壳继续保留 + - AI 子域只在必要处补 `domain/ai` 与 `infrastructure/ai` +- 如果用户没有明确要求做全量目录迁移,默认做局部拆分,而不是扩大改造范围。 + +### 第 3 步:AI 子域评审重点 + +- 是否把协议和具体实现耦合在一起。 +- 是否让 `service/system` 直接依赖 Eino/Gin/GORM/Redis 等底层实现细节。 +- 是否把 runtime、tool、trace、prompt 拼装、恢复控制全部堆进单个 service 文件。 +- 是否把 AI 子域的局部 DDD 演进误做成项目整体全量 DDD 重构。 + +### AI 子域禁止模式 + +- 在 `domain/ai` 直接依赖 HTTP、GORM、Eino、Redis。 +- 把 `tool/runtime/event/trace` 的稳定协议继续散落在 `service/system` 多个文件中而不收口抽象。 +- 因为某次 AI 重构就改写全仓目录口径,导致 MVC 主体被迫跟着迁移。 +- 对外宣称“项目已经是完整 DDD 架构”,而实际只完成 AI 子域局部拆分。 + ## 标准实现流程 ### 第 1 步:DTO diff --git a/.codex/skills/personal-assistant-go-backend/references/project-rules.md b/.codex/skills/personal-assistant-go-backend/references/project-rules.md index 869d19f..5730c29 100644 --- a/.codex/skills/personal-assistant-go-backend/references/project-rules.md +++ b/.codex/skills/personal-assistant-go-backend/references/project-rules.md @@ -125,6 +125,39 @@ - `role-menu / role-api / role-capability / menu-api` 变更统一写 DB + outbox,不允许在业务 Service 内直接全量刷新 Casbin。 - `user-org-role / 成员状态` 变更允许同步收口当前主体投影,但仍必须补发异步修复事件。 +## 14. AI 子域渐进式 DDD 规则 + +- 当前项目的正式口径是 `MVC 主体 + AI 子域渐进式 DDD`。 +- 这表示: + - 项目整体仍以传统 MVC 目录和职责划分为主。 + - AI 子域在复杂度上升后,允许渐进式补 `internal/domain/ai` 与 `internal/infrastructure/ai`。 + - 默认目标不是全量 DDD 重构,也不是项目整体目录全面改名。 +- AI 子域目录职责: + - `internal/controller/system`、`internal/router/system` + - 继续作为 AI HTTP / SSE 入口层。 + - `internal/service/system` + - 继续作为 AI 应用编排层,负责会话流程、上下文组装、tool 注册与授权收口、sink/projector 协调。 + - `internal/domain/ai` + - 放稳定协议、事件、tool/runtime 抽象、领域语义。 + - 禁止依赖 Gin、GORM、Eino、Redis、第三方模型 SDK。 + - `internal/infrastructure/ai` + - 放 Eino / Local runtime、模型 SDK、tool adapter、checkpoint / approval / runtime control 等技术实现。 + - `internal/repository/*` + - 继续负责 AI 持久化访问,不因为 AI 子域拆分而绕开 Repository。 +- AI 任务落点判断: + - 协议与抽象:优先落 `domain/ai` + - 应用编排:优先落 `service/system` + - 基础设施适配:优先落 `infrastructure/ai` + - 持久化:优先落 `repository/*` +- 禁止模式: + - 把 AI 子域演进误表述为“已经完成全量 DDD 重构”。 + - 把 runtime、tool、trace、prompt 拼装、恢复控制等复杂度继续无边界堆进单个 `service` 文件。 + - 在 `domain/ai` 中直接依赖 HTTP、数据库或具体 Agent 框架。 + - 因 AI 子域局部改造而强行推动整个项目做无必要的目录迁移。 +- 阶段演进说明: + - A2UI、interrupt、approval、runtimecontrol 等能力允许按阶段收缩、停用或重建。 + - 无论具体功能阶段如何变化,AI 子域的依赖方向和目录边界必须保持稳定。 + ## 计划落盘规则 - 只要任务属于新增、重构、修复、联调、排障、迁移、删除、配置调整这类执行型工作,先写计划,不直接改代码。 diff --git a/AGENTS.md b/AGENTS.md index 3a37485..a4b7168 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ 仅在以下目录工作时应用本规则: -- `d:/workspace_go/personal_assistant` +- `d:/workspace_go/test/go/personal_assistant` ## Skill 联动 @@ -98,6 +98,25 @@ - `internal/service` 只允许通过 `AuthorizationService` 做业务授权;业务 Service 禁止直接操作 `Enforcer`。 - `role-menu / role-api / role-capability / menu-api` 变更统一写 DB + outbox,不允许在业务 Service 内直接全量刷新 Casbin。 - `user-org-role / 成员状态` 变更允许同步收口当前主体投影,但仍必须补发异步修复事件。 +14. AI 子域渐进式 DDD 规则: + - 项目整体口径是 `MVC 主体 + AI 子域渐进式 DDD`,默认目标不是全量 DDD 重构,也不是整体目录全面改名。 + - AI 子域优先保留现有 MVC 外壳: + - `internal/controller/system` 继续作为 AI HTTP 入口。 + - `internal/router/system` 继续作为 AI 路由入口。 + - `internal/service/system` 继续承担 AI 应用编排。 + - `internal/repository/*` 继续承担 AI 持久化访问。 + - 仅当 AI 能力需要稳定协议或可替换实现时,才渐进式补充: + - `internal/domain/ai`:放稳定协议、事件、tool/runtime 抽象、领域语义;禁止依赖 Gin、GORM、Eino、Redis 等技术实现。 + - `internal/infrastructure/ai`:放 Eino / Local runtime、第三方模型 SDK、外部 Agent 框架适配、checkpoint / tool adapter 等技术实现。 + - `internal/service/system`:放 AI 用例编排、上下文组装、tool 注册与授权收口、trace/projector 协调;禁止直接承载底层框架细节。 + - AI 方向新增能力时,先判断本次改动属于: + - 协议层:落 `domain/ai` + - 应用编排层:落 `service/system` + - 基础设施适配层:落 `infrastructure/ai` + - 持久化层:落 `repository/*` + - 禁止把 AI 子域的演进误表述为“已经完成全量 DDD 重构”;对外口径统一为“传统 MVC 架构基础上,针对 AI 核心模块做渐进式 DDD 分层改造”。 + - 禁止把 runtime、tool、trace、prompt 拼装、恢复控制等复杂度继续无边界堆进单个 `service` 文件;当某类 AI 逻辑已具备稳定抽象时,应优先拆到 `domain/ai` 或 `infrastructure/ai`。 + - A2UI、interrupt、approval、runtimecontrol 等能力允许按阶段收缩、停用或重建;但无论阶段如何变化,目录边界和依赖方向必须保持稳定。 ## 计划落盘规则 diff --git "a/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" "b/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" index c69fca8..2fee018 100644 --- "a/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" +++ "b/docs/AI/AI\351\242\206\345\237\237+DDD\346\236\266\346\236\204\346\213\206\345\210\206.md" @@ -120,6 +120,30 @@ internal/ 因为在这次的项目中, 并不是为了用 DDD 推翻现有 MVC,而只需要给 AI 子域补一个 domain/ai 稳定核心层,让 Service 和 Infrastructure 都围着它依赖。 +## 当前项目正式口径 + +当前项目对外和对内的统一表述,应当是: + +> **项目整体仍是传统 MVC 主体架构,针对 AI 核心子域做渐进式 DDD 分层改造。** + +这句话的含义是: + +- 不是说整个项目已经完成全量 DDD 重构。 +- 也不是说 AI 子域仍然应该继续全部堆在单个 MVC Service 里。 +- 而是: + - `controller/router/service/repository` 主体结构继续保留; + - 当 AI 逻辑出现稳定协议、事件语义、可替换 runtime、tool 抽象、恢复控制等需求时, + 再把这些内容渐进式收口到 `domain/ai` 与 `infrastructure/ai`。 + +因此,后续在本项目做 AI 改动时,默认优先遵守下面这条落点规则: + +- 稳定协议、事件、tool/runtime 抽象:放 `internal/domain/ai` +- Eino / Local runtime、模型 SDK、第三方 Agent 适配:放 `internal/infrastructure/ai` +- 会话流程、上下文组装、tool 注册授权、trace/projector 协调:放 `internal/service/system` +- HTTP / SSE 入口:继续放 `internal/controller/system` 与 `internal/router/system` + +这就是“AI 子域局部 DDD”,不是“项目整体目录全面改名”。 + 我能不能在拆分的时候,先把这部分给踢掉,等后期在新添加上去,顺便把A2UI这个踢掉, diff --git "a/docs/AI/Tool\350\256\276\350\256\241.md" "b/docs/AI/Tool\350\256\276\350\256\241.md" new file mode 100644 index 0000000..e69de29 diff --git "a/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" "b/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" index 15b930b..88db3c4 100644 --- "a/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" +++ "b/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" @@ -4,13 +4,15 @@ 3、考虑是封装成mcp,还是做成skills -4、把react完成 - - +4、把ReAct设计的完成 +5、设计mem0如何设计记忆模式 +6、把上下文恢复、上下文token压缩解决这些问题 +7、把interrupt / resume 这些复原回去 +6、LLM的网关我又改如何设计 diff --git a/docs/apifox/ai_assistant.openapi.json b/docs/apifox/ai_assistant.openapi.json index 12d1ee2..929a5cb 100644 --- a/docs/apifox/ai_assistant.openapi.json +++ b/docs/apifox/ai_assistant.openapi.json @@ -3,7 +3,7 @@ "info": { "title": "z_cur/UI AI 助手模块 OpenAPI", "version": "1.2.0", - "description": "覆盖当前 `personal-assistant-frontend` AI 助手模块使用的接口,可直接导入 Apifox。\n当前 V1 协议已经收敛为“单条聊天 SSE 流 + 思考短句流 + 最终正文流”。\n统一约束:\n1. CRUD 接口走 JSON BizResponse;`stream` 接口返回 `text/event-stream`。\n2. `stream` 是唯一聊天 SSE 流,不恢复 decision / interrupt / 第二条流。\n3. `thinking_started / thinking_delta / thinking_completed` 只承载用户可见的外显思考。\n4. `assistant_token` 与 `message_completed.content` 只承载最终正文。\n5. `trace_items` 持久化 `thinking_summary`,用于刷新后恢复思考区历史。\n6. `ui_blocks / scope` 字段继续保留兼容,但当前 V1 默认不下发结构化 UI。" + "description": "覆盖当前 `personal-assistant-frontend` AI 助手模块使用的接口,可直接导入 Apifox。\n当前 V1 协议已经收敛为“单条聊天 SSE 流 + 推理详情流 + 行动日志流 + 最终正文流”。\n统一约束:\n1. CRUD 接口走 JSON BizResponse;`stream` 接口返回 `text/event-stream`。\n2. `stream` 是唯一聊天 SSE 流,不恢复 decision / interrupt / 第二条流。\n3. `thinking_started / thinking_delta / thinking_completed` 承载用户可见的高细节推理详情,不暴露模型私有原始推理。\n4. `detail_step_started / detail_step_updated / detail_step_completed` 承载结构化行动日志。\n5. `assistant_token` 与 `message_completed.content` 只承载最终正文。\n6. `trace_items` 同时持久化 `thinking_summary` 和多个 `detail_step`,用于刷新后恢复过程区历史。" }, "servers": [ { @@ -14,7 +14,7 @@ "tags": [ { "name": "AI助手", - "description": "会话 CRUD、单条聊天 SSE 流与 interrupt 决策控制接口。" + "description": "会话 CRUD、单条聊天 SSE 流与消息历史恢复接口。" } ], "security": [ @@ -194,7 +194,7 @@ "AI助手" ], "summary": "获取会话消息列表", - "description": "历史消息必须能完整重建 UI。\n后端返回时建议优先补齐 `ui_blocks`,并保留 `trace_items / scope`;`scope` 仅在复杂范围时返回。\nUI 侧会按“思考摘要 / 工具意图 / 等待用户 / 最终正文”重建消息结构。\n消息按创建时间升序返回。", + "description": "历史消息必须能完整重建 UI。\n后端返回时应保留 `trace_items / scope`;`scope` 仅在复杂范围时返回。\nUI 侧会按“推理详情 / 行动日志 / 最终正文”重建消息结构。\n消息按创建时间升序返回;相同时间戳下按 `user -> assistant -> system` 稳定排序。", "operationId": "GetAssistantConversationMessages", "parameters": [ { @@ -462,7 +462,7 @@ "AI助手" ], "summary": "发送用户消息并开启唯一聊天 SSE 流", - "description": "`stream` 是单轮问答的唯一事件流。\n当前事件顺序固定为:`conversation_started -> thinking_started -> thinking_delta* -> thinking_completed -> assistant_token* -> message_completed -> done`;失败时输出 `error -> done`。\n`thinking_*` 事件用于展示用户可见的外显思考,不暴露模型私有原始推理。\n`assistant_token` 和 `message_completed.content` 只用于最终正文。\nV1 不恢复 interrupt / decision / structured_block 工具链路。\n事件 payload 结构见 `AssistantEventPayloadCatalog`。", + "description": "`stream` 是单轮问答的唯一事件流。\n当前事件顺序固定为:`conversation_started -> detail_step_started/detail_step_updated* -> thinking_started -> thinking_delta* -> thinking_completed -> detail_step_completed/detail_step_updated* -> assistant_token* -> message_completed -> detail_step_completed -> done`;失败时输出 `error -> done`。\n`thinking_*` 事件用于展示用户可见的高细节推理详情,不暴露模型私有原始推理。\n`detail_step_*` 事件用于展示结构化行动日志。\n`assistant_token` 和 `message_completed.content` 只用于最终正文。\nV1 不恢复 interrupt / decision / structured_block 工具链路。\n事件 payload 结构见 `AssistantEventPayloadCatalog`。", "operationId": "StreamAssistantConversationMessage", "parameters": [ { @@ -503,7 +503,7 @@ "examples": { "sameStreamResume": { "summary": "原流等待确认并在同一连接内继续输出", - "value": "event: conversation_started\ndata: {\"title\":\"介绍一下这个项目\"}\n\nevent: thinking_started\ndata: {\"title\":\"深度思考\"}\n\nevent: thinking_delta\ndata: {\"delta\":\"正在理解你的问题,先提炼核心目标和约束。\\n\"}\n\nevent: thinking_delta\ndata: {\"delta\":\"当前关注点:需要先说明项目定位,再补充主要能力。\\n\"}\n\nevent: thinking_completed\ndata: {\"content\":\"正在理解你的问题,先提炼核心目标和约束。\\n当前关注点:需要先说明项目定位,再补充主要能力。\\n下一步会按重点组织回答,再输出正式结果。\"}\n\nevent: assistant_token\ndata: {\"token\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\"}\n\nevent: assistant_token\ndata: {\"token\":\"\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: message_completed\ndata: {\"content\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: done\ndata: {}\n" + "value": "event: conversation_started\ndata: {\"title\":\"介绍一下这个项目\"}\n\nevent: detail_step_started\ndata: {\"key\":\"step_read_history\",\"kind\":\"action\",\"title\":\"读取历史上下文\",\"content\":\"当前没有可复用的历史消息,将按首轮问题直接展开本轮分析。\",\"status\":\"loading\",\"order\":10}\n\nevent: detail_step_completed\ndata: {\"key\":\"step_read_history\",\"kind\":\"action\",\"title\":\"读取历史上下文\",\"content\":\"当前没有可复用的历史消息,将按首轮问题直接展开本轮分析。\",\"status\":\"success\",\"order\":10}\n\nevent: thinking_started\ndata: {\"title\":\"深度思考\"}\n\nevent: thinking_delta\ndata: {\"delta\":\"先判断这轮问题更需要过程细节还是结果摘要,避免一开始就走错输出方向。\\n\"}\n\nevent: thinking_delta\ndata: {\"delta\":\"当前输入里最需要优先抓住的是项目定位、现状和接下来怎么讲清楚。\\n\"}\n\nevent: thinking_completed\ndata: {\"content\":\"先判断这轮问题更需要过程细节还是结果摘要,避免一开始就走错输出方向。\\n当前输入里最需要优先抓住的是项目定位、现状和接下来怎么讲清楚。\\n接下来会继续组织结构,再进入最终正文生成。\"}\n\nevent: detail_step_started\ndata: {\"key\":\"step_generate_answer\",\"kind\":\"action\",\"title\":\"生成正文\",\"content\":\"已进入正文生成阶段,接下来会持续输出最终回答。\",\"status\":\"loading\",\"order\":50}\n\nevent: assistant_token\ndata: {\"token\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\"}\n\nevent: assistant_token\ndata: {\"token\":\"\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: message_completed\ndata: {\"content\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: detail_step_completed\ndata: {\"key\":\"step_generate_answer\",\"kind\":\"action\",\"title\":\"生成正文\",\"content\":\"最终正文已经全部生成完成。\",\"status\":\"success\",\"order\":50}\n\nevent: done\ndata: {}\n" } } }, @@ -819,11 +819,17 @@ "AssistantTraceStatus": { "type": "string", "enum": [ - "pending", + "loading", "success", "error", - "awaiting_confirmation", - "skipped" + "stopped" + ] + }, + "AssistantTraceItemKind": { + "type": "string", + "enum": [ + "reasoning", + "action" ] }, "AssistantTraceActionType": { @@ -935,6 +941,13 @@ "status": { "$ref": "#/components/schemas/AssistantTraceStatus" }, + "kind": { + "$ref": "#/components/schemas/AssistantTraceItemKind" + }, + "order": { + "type": "integer", + "format": "int32" + }, "interrupt_id": { "type": "string" }, @@ -1413,6 +1426,9 @@ "thinking_started", "thinking_delta", "thinking_completed", + "detail_step_started", + "detail_step_updated", + "detail_step_completed", "assistant_token", "message_completed", "error", @@ -1463,6 +1479,34 @@ } } }, + "AssistantDetailStepPayload": { + "type": "object", + "required": [ + "key", + "title" + ], + "properties": { + "key": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/AssistantTraceItemKind" + }, + "title": { + "type": "string" + }, + "content": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AssistantTraceStatus" + }, + "order": { + "type": "integer", + "format": "int32" + } + } + }, "AssistantTokenPayload": { "type": "object", "required": [ @@ -1654,6 +1698,15 @@ "thinking_completed": { "$ref": "#/components/schemas/AssistantThinkingCompletedPayload" }, + "detail_step_started": { + "$ref": "#/components/schemas/AssistantDetailStepPayload" + }, + "detail_step_updated": { + "$ref": "#/components/schemas/AssistantDetailStepPayload" + }, + "detail_step_completed": { + "$ref": "#/components/schemas/AssistantDetailStepPayload" + }, "assistant_token": { "$ref": "#/components/schemas/AssistantTokenPayload" }, diff --git a/internal/domain/ai/event.go b/internal/domain/ai/event.go index 3cf28a3..64c9e26 100644 --- a/internal/domain/ai/event.go +++ b/internal/domain/ai/event.go @@ -8,15 +8,6 @@ const ( // EventConversationStarted 表示一次 AI 流式生成已经开始。 EventConversationStarted EventName = "conversation_started" - // EventThinkingStarted 表示面向用户可见的外显思考阶段开始。 - EventThinkingStarted EventName = "thinking_started" - - // EventThinkingDelta 表示外显思考阶段追加的一段文本。 - EventThinkingDelta EventName = "thinking_delta" - - // EventThinkingCompleted 表示外显思考阶段已经结束。 - EventThinkingCompleted EventName = "thinking_completed" - // EventAssistantToken 表示 assistant 本次追加输出的一段文本。 EventAssistantToken EventName = "assistant_token" @@ -47,21 +38,6 @@ type ConversationStartedPayload struct { Title string `json:"title"` } -// ThinkingStartedPayload 表示外显思考开始事件的载荷。 -type ThinkingStartedPayload struct { - Title string `json:"title"` -} - -// ThinkingDeltaPayload 表示外显思考追加事件的载荷。 -type ThinkingDeltaPayload struct { - Delta string `json:"delta"` -} - -// ThinkingCompletedPayload 表示外显思考结束事件的载荷。 -type ThinkingCompletedPayload struct { - Content string `json:"content"` -} - // AssistantTokenPayload 表示 assistant token 追加事件的载荷。 type AssistantTokenPayload struct { Token string `json:"token"` diff --git a/internal/domain/ai/runtime_test.go b/internal/domain/ai/runtime_test.go index 6688695..7ec315a 100644 --- a/internal/domain/ai/runtime_test.go +++ b/internal/domain/ai/runtime_test.go @@ -3,15 +3,6 @@ package ai import "testing" func TestEventNamesAreStable(t *testing.T) { - if EventThinkingStarted != "thinking_started" { - t.Fatalf("EventThinkingStarted = %q", EventThinkingStarted) - } - if EventThinkingDelta != "thinking_delta" { - t.Fatalf("EventThinkingDelta = %q", EventThinkingDelta) - } - if EventThinkingCompleted != "thinking_completed" { - t.Fatalf("EventThinkingCompleted = %q", EventThinkingCompleted) - } if EventAssistantToken != "assistant_token" { t.Fatalf("EventAssistantToken = %q", EventAssistantToken) } diff --git a/internal/infrastructure/ai/eino/runtime.go b/internal/infrastructure/ai/eino/runtime.go index 950f97b..1abf8d5 100644 --- a/internal/infrastructure/ai/eino/runtime.go +++ b/internal/infrastructure/ai/eino/runtime.go @@ -87,10 +87,6 @@ func (r *Runtime) Stream( return aidomain.StreamResult{}, err } - if err := r.emitVisibleThinking(ctx, input, sink); err != nil { - return aidomain.StreamResult{}, err - } - reader, err := r.model.Stream(ctx, r.buildMessages(input)) if err != nil { return aidomain.StreamResult{}, err @@ -131,78 +127,6 @@ func (r *Runtime) Stream( return aidomain.StreamResult{Content: content, FinishReason: "stop"}, nil } -func (r *Runtime) emitVisibleThinking( - ctx context.Context, - input aidomain.StreamInput, - sink aidomain.Sink, -) error { - content, err := r.generateThinkingSummary(ctx, input) - if err != nil { - content = buildVisibleThinkingSummary(input.Content) - } - content = normalizeThinkingSummary(content, input.Content) - if content == "" { - return nil - } - if err := sink.Emit(ctx, aidomain.Event{ - Name: aidomain.EventThinkingStarted, - Payload: aidomain.ThinkingStartedPayload{Title: "深度思考"}, - }); err != nil { - return err - } - for _, chunk := range splitTextChunks(content, 24) { - if err := sink.Emit(ctx, aidomain.Event{ - Name: aidomain.EventThinkingDelta, - Payload: aidomain.ThinkingDeltaPayload{Delta: chunk}, - }); err != nil { - return err - } - } - return sink.Emit(ctx, aidomain.Event{ - Name: aidomain.EventThinkingCompleted, - Payload: aidomain.ThinkingCompletedPayload{Content: content}, - }) -} - -func (r *Runtime) generateThinkingSummary(ctx context.Context, input aidomain.StreamInput) (string, error) { - messages := []*schema.Message{ - schema.SystemMessage(strings.TrimSpace(` -你需要先输出一段可以直接展示给用户的“深度思考”短句。 -要求: -1. 只描述“当前判断 / 正在做什么 / 下一步是什么”。 -2. 不泄露模型私有推理,不展示完整推导链。 -3. 不输出最终答案,不要复述太多用户原文。 -4. 最多 3 行,总长度控制在 120 个中文字符以内。 -5. 直接输出正文,不要加标题、编号、markdown 列表符号。`)), - schema.UserMessage(strings.TrimSpace(input.Content)), - } - return r.readStreamContent(ctx, messages) -} - -func (r *Runtime) readStreamContent(ctx context.Context, messages []*schema.Message) (string, error) { - reader, err := r.model.Stream(ctx, messages) - if err != nil { - return "", err - } - defer reader.Close() - - var output strings.Builder - for { - msg, recvErr := reader.Recv() - if errors.Is(recvErr, io.EOF) { - break - } - if recvErr != nil { - return "", recvErr - } - if msg == nil || msg.Content == "" { - continue - } - output.WriteString(msg.Content) - } - return output.String(), nil -} - // buildMessages 把 domain 层历史消息转换成 Eino schema 消息。 // 参数: // - input:包含历史消息与当前用户输入的 StreamInput。 @@ -232,73 +156,6 @@ func (r *Runtime) buildMessages(input aidomain.StreamInput) []*schema.Message { return messages } -func buildVisibleThinkingSummary(content string) string { - content = strings.TrimSpace(strings.ReplaceAll(content, "\n", " ")) - if content == "" { - return strings.Join([]string{ - "正在确认输入是否完整,并收拢本轮回答目标。", - "下一步会先组织回答结构,再输出正式结果。", - }, "\n") - } - return strings.Join([]string{ - "正在理解你的问题,先提炼核心目标和约束。", - "当前关注点:" + truncateRunes(content, 24), - "下一步会按重点组织回答,再输出正式结果。", - }, "\n") -} - -func normalizeThinkingSummary(content string, fallback string) string { - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\r", "\n") - lines := strings.Split(content, "\n") - filtered := make([]string, 0, 3) - for _, line := range lines { - line = strings.TrimSpace(strings.TrimLeft(line, "-*0123456789.、 ")) - if line == "" { - continue - } - filtered = append(filtered, line) - if len(filtered) == 3 { - break - } - } - if len(filtered) == 0 { - return buildVisibleThinkingSummary(fallback) - } - content = strings.Join(filtered, "\n") - return truncateRunes(content, 120) -} - -func splitTextChunks(content string, size int) []string { - if size <= 0 { - size = 24 - } - runes := []rune(content) - if len(runes) == 0 { - return nil - } - chunks := make([]string, 0, (len(runes)/size)+1) - for start := 0; start < len(runes); start += size { - end := start + size - if end > len(runes) { - end = len(runes) - } - chunks = append(chunks, string(runes[start:end])) - } - return chunks -} - -func truncateRunes(content string, limit int) string { - if limit <= 0 { - return "" - } - runes := []rune(strings.TrimSpace(content)) - if len(runes) <= limit { - return string(runes) - } - return string(runes[:limit]) -} - // deriveTitle 根据用户输入生成会话开始事件标题。 func deriveTitle(content string) string { content = strings.TrimSpace(content) diff --git a/internal/infrastructure/ai/local/runtime.go b/internal/infrastructure/ai/local/runtime.go index 8576480..e70100e 100644 --- a/internal/infrastructure/ai/local/runtime.go +++ b/internal/infrastructure/ai/local/runtime.go @@ -79,10 +79,6 @@ func (r *Runtime) Stream(ctx context.Context, input aidomain.StreamInput, sink a return aidomain.StreamResult{}, err } - if err := emitVisibleThinking(ctx, sink, buildThinkingSummary(input.Content)); err != nil { - return aidomain.StreamResult{}, err - } - reply := buildReply(input.Content) for _, chunk := range splitChunks(reply, 48) { if err := sink.Emit(ctx, aidomain.Event{ @@ -114,22 +110,6 @@ func buildReply(content string) string { return "我已收到你的问题:" + content + "\n\n当前阶段 AI 助手只保留基础流式对话能力;我会基于你的输入直接回答,不再调用工具或等待人工确认。" } -// buildThinkingSummary 为本地 runtime 生成用户可见的外显思考短句。 -func buildThinkingSummary(content string) string { - content = strings.TrimSpace(strings.ReplaceAll(content, "\n", " ")) - if content == "" { - return strings.Join([]string{ - "正在检查输入是否完整,并确认本轮回答目标。", - "下一步会先归纳问题重点,再输出正式回复。", - }, "\n") - } - return strings.Join([]string{ - "正在理解你的问题,先提炼核心目标和约束。", - "当前关注点:" + truncateRunes(content, 24), - "下一步会按重点组织回答,再输出正式结果。", - }, "\n") -} - // deriveTitle 根据用户输入生成会话开始事件中的标题。 // 作用:只做轻量截断,不引入模型或复杂规划。 func deriveTitle(content string) string { @@ -170,42 +150,6 @@ func splitChunks(content string, size int) []string { return chunks } -func emitVisibleThinking(ctx context.Context, sink aidomain.Sink, content string) error { - content = strings.TrimSpace(content) - if content == "" { - return nil - } - if err := sink.Emit(ctx, aidomain.Event{ - Name: aidomain.EventThinkingStarted, - Payload: aidomain.ThinkingStartedPayload{Title: "深度思考"}, - }); err != nil { - return err - } - for _, chunk := range splitChunks(content, 24) { - if err := sink.Emit(ctx, aidomain.Event{ - Name: aidomain.EventThinkingDelta, - Payload: aidomain.ThinkingDeltaPayload{Delta: chunk}, - }); err != nil { - return err - } - } - return sink.Emit(ctx, aidomain.Event{ - Name: aidomain.EventThinkingCompleted, - Payload: aidomain.ThinkingCompletedPayload{Content: content}, - }) -} - -func truncateRunes(content string, limit int) string { - if limit <= 0 { - return "" - } - runes := []rune(strings.TrimSpace(content)) - if len(runes) <= limit { - return string(runes) - } - return string(runes[:limit]) -} - // runtimeError 表示本地 runtime 内部的轻量错误类型。 type runtimeError string diff --git a/internal/infrastructure/ai/local/runtime_test.go b/internal/infrastructure/ai/local/runtime_test.go index e8f8558..d4a1f89 100644 --- a/internal/infrastructure/ai/local/runtime_test.go +++ b/internal/infrastructure/ai/local/runtime_test.go @@ -37,22 +37,6 @@ func TestRuntimeStreamEmitsMinimalEvents(t *testing.T) { if sink.events[0].Name != aidomain.EventConversationStarted { t.Fatalf("first event = %q", sink.events[0].Name) } - foundThinkingStarted := false - foundThinkingCompleted := false - for _, event := range sink.events { - if event.Name == aidomain.EventThinkingStarted { - foundThinkingStarted = true - } - if event.Name == aidomain.EventThinkingCompleted { - foundThinkingCompleted = true - } - } - if !foundThinkingStarted { - t.Fatal("thinking_started event not found") - } - if !foundThinkingCompleted { - t.Fatal("thinking_completed event not found") - } if sink.events[len(sink.events)-1].Name != aidomain.EventDone { t.Fatalf("last event = %q", sink.events[len(sink.events)-1].Name) } diff --git a/internal/model/dto/response/aiResp.go b/internal/model/dto/response/aiResp.go index 44faa2d..529095d 100644 --- a/internal/model/dto/response/aiResp.go +++ b/internal/model/dto/response/aiResp.go @@ -106,21 +106,6 @@ type AssistantConversationStartedPayload struct { Title string `json:"title"` // 新会话生成后的标题。 } -// AssistantThinkingStartedPayload 表示可见思考开始事件的载荷。 -type AssistantThinkingStartedPayload struct { - Title string `json:"title"` // 思考区标题,默认显示为“深度思考”。 -} - -// AssistantThinkingDeltaPayload 表示可见思考追加事件的载荷。 -type AssistantThinkingDeltaPayload struct { - Delta string `json:"delta"` // 本次追加的思考文本片段。 -} - -// AssistantThinkingCompletedPayload 表示可见思考结束事件的载荷。 -type AssistantThinkingCompletedPayload struct { - Content string `json:"content"` // 思考区最终完整内容。 -} - // AssistantTokenPayload 表示流式输出 token 事件的载荷。 type AssistantTokenPayload struct { Token string `json:"token"` // 本次流式追加的 token 内容。 diff --git a/internal/service/system/aiProjector.go b/internal/service/system/aiProjector.go index 80b16ba..d1d5359 100644 --- a/internal/service/system/aiProjector.go +++ b/internal/service/system/aiProjector.go @@ -2,23 +2,14 @@ package system import ( "context" - "encoding/json" - "strings" "sync" "time" aidomain "personal_assistant/internal/domain/ai" - resp "personal_assistant/internal/model/dto/response" "personal_assistant/internal/model/entity" "personal_assistant/internal/repository/interfaces" ) -const ( - thinkingTraceKey = "thinking_summary" - thinkingTraceTitle = "深度思考" - thinkingTraceDescription = "正在整理当前判断和下一步。" -) - // aiMessageProjector 负责把最小 runtime 事件折叠成 assistant 消息快照。 // 它只处理基础流式文本状态,不再维护 A2UI、tool trace、interrupt 状态机。 type aiMessageProjector struct { @@ -46,7 +37,6 @@ func (p *aiMessageProjector) setStopped() { if p.message != nil { p.message.Status = aiMessageStatusStopped - p.updateThinkingTraceStatusLocked(aiMessageStatusStopped) } } @@ -60,17 +50,10 @@ func (p *aiMessageProjector) setError(message string) { if p.message != nil { p.message.Status = aiMessageStatusError p.message.ErrorText = message - p.updateThinkingTraceStatusLocked(aiMessageStatusError) } } // persistMessage 将当前内存中的 assistant 消息快照写回数据库。 -// 参数: -// - ctx:数据库操作上下文。 -// -// 返回值: -// - error:Repository 更新失败时返回原始错误。 -// // 注意事项: // - `trace_items_json`、`ui_blocks_json`、`scope_json` 本阶段保留兼容字段,但固定写空值。 func (p *aiMessageProjector) persistMessage(ctx context.Context) error { @@ -79,15 +62,9 @@ func (p *aiMessageProjector) persistMessage(ctx context.Context) error { if p.message == nil { return nil } - if strings.TrimSpace(p.message.TraceItemsJSON) == "" { - p.message.TraceItemsJSON = "[]" - } - if strings.TrimSpace(p.message.UIBlocksJSON) == "" { - p.message.UIBlocksJSON = "[]" - } - if strings.TrimSpace(p.message.ScopeJSON) == "" { - p.message.ScopeJSON = "{}" - } + p.message.TraceItemsJSON = "[]" + p.message.UIBlocksJSON = "[]" + p.message.ScopeJSON = "{}" p.message.UpdatedAt = time.Now() return p.repo.UpdateMessage(ctx, p.message) } @@ -111,30 +88,6 @@ func (p *aiMessageProjector) applyEvent(event aidomain.Event) { } switch event.Name { - case aidomain.EventThinkingStarted: - p.upsertThinkingTraceLocked(func(item *resp.AssistantTraceItem) { - item.Title = thinkingTraceTitle - item.Description = thinkingTraceDescription - item.Status = aiMessageStatusLoading - }) - case aidomain.EventThinkingDelta: - if payload, ok := event.Payload.(aidomain.ThinkingDeltaPayload); ok { - p.upsertThinkingTraceLocked(func(item *resp.AssistantTraceItem) { - item.Title = thinkingTraceTitle - item.Description = thinkingTraceDescription - item.Status = aiMessageStatusLoading - item.Content += payload.Delta - }) - } - case aidomain.EventThinkingCompleted: - p.upsertThinkingTraceLocked(func(item *resp.AssistantTraceItem) { - item.Title = thinkingTraceTitle - item.Description = thinkingTraceDescription - item.Status = aiMessageStatusSuccess - if payload, ok := event.Payload.(aidomain.ThinkingCompletedPayload); ok && strings.TrimSpace(payload.Content) != "" { - item.Content = payload.Content - } - }) case aidomain.EventAssistantToken: if payload, ok := event.Payload.(aidomain.AssistantTokenPayload); ok { p.message.Content += payload.Token @@ -148,82 +101,10 @@ func (p *aiMessageProjector) applyEvent(event aidomain.Event) { p.message.Content = payload.Content } p.message.Status = aiMessageStatusSuccess - p.updateThinkingTraceStatusLocked(aiMessageStatusSuccess) case aidomain.EventError: if payload, ok := event.Payload.(aidomain.ErrorPayload); ok { p.message.ErrorText = payload.Message } p.message.Status = aiMessageStatusError - p.updateThinkingTraceStatusLocked(aiMessageStatusError) - } -} - -func (p *aiMessageProjector) decodeTraceItemsLocked() []resp.AssistantTraceItem { - if p.message == nil || strings.TrimSpace(p.message.TraceItemsJSON) == "" { - return []resp.AssistantTraceItem{} - } - items := make([]resp.AssistantTraceItem, 0) - if err := json.Unmarshal([]byte(p.message.TraceItemsJSON), &items); err != nil { - return []resp.AssistantTraceItem{} - } - return items -} - -func (p *aiMessageProjector) encodeTraceItemsLocked(items []resp.AssistantTraceItem) { - raw, err := json.Marshal(items) - if err != nil { - p.message.TraceItemsJSON = "[]" - return - } - p.message.TraceItemsJSON = string(raw) -} - -func (p *aiMessageProjector) upsertThinkingTraceLocked(mutator func(item *resp.AssistantTraceItem)) { - if p.message == nil { - return - } - items := p.decodeTraceItemsLocked() - index := -1 - for idx, item := range items { - if item.Key == thinkingTraceKey { - index = idx - break - } - } - - var target resp.AssistantTraceItem - if index >= 0 { - target = items[index] - } else { - target = resp.AssistantTraceItem{ - Key: thinkingTraceKey, - Title: thinkingTraceTitle, - Description: thinkingTraceDescription, - Status: aiMessageStatusLoading, - } - } - - mutator(&target) - - if index >= 0 { - items[index] = target - } else { - items = append(items, target) - } - p.encodeTraceItemsLocked(items) -} - -func (p *aiMessageProjector) updateThinkingTraceStatusLocked(status string) { - if p.message == nil { - return - } - items := p.decodeTraceItemsLocked() - for idx, item := range items { - if item.Key != thinkingTraceKey { - continue - } - items[idx].Status = status - p.encodeTraceItemsLocked(items) - return } } diff --git a/internal/service/system/aiProjector_test.go b/internal/service/system/aiProjector_test.go index ae8db4d..93f58a4 100644 --- a/internal/service/system/aiProjector_test.go +++ b/internal/service/system/aiProjector_test.go @@ -2,12 +2,10 @@ package system import ( "context" - "encoding/json" "testing" "time" aidomain "personal_assistant/internal/domain/ai" - resp "personal_assistant/internal/model/dto/response" "personal_assistant/internal/model/entity" "personal_assistant/internal/repository/interfaces" ) @@ -19,7 +17,9 @@ type projectorRepoStub struct { var _ interfaces.AIRepository = (*projectorRepoStub)(nil) -func (s *projectorRepoStub) CreateConversation(context.Context, *entity.AIConversation) error { return nil } +func (s *projectorRepoStub) CreateConversation(context.Context, *entity.AIConversation) error { + return nil +} func (s *projectorRepoStub) GetConversationByID(context.Context, string) (*entity.AIConversation, error) { return nil, nil } @@ -29,9 +29,11 @@ func (s *projectorRepoStub) GetConversationByIDForUpdate(context.Context, string func (s *projectorRepoStub) ListConversationsByUser(context.Context, uint) ([]*entity.AIConversation, error) { return nil, nil } -func (s *projectorRepoStub) UpdateConversation(context.Context, *entity.AIConversation) error { return nil } +func (s *projectorRepoStub) UpdateConversation(context.Context, *entity.AIConversation) error { + return nil +} func (s *projectorRepoStub) DeleteConversationCascade(context.Context, string) error { return nil } -func (s *projectorRepoStub) CreateMessage(context.Context, *entity.AIMessage) error { return nil } +func (s *projectorRepoStub) CreateMessage(context.Context, *entity.AIMessage) error { return nil } func (s *projectorRepoStub) UpdateMessage(_ context.Context, message *entity.AIMessage) error { s.updateCalls++ cloned := *message @@ -55,87 +57,76 @@ func (s *projectorRepoStub) ListInterruptsForRecovery(context.Context, []string, return nil, nil } func (s *projectorRepoStub) UpdateInterrupt(context.Context, *entity.AIInterrupt) error { return nil } -func (s *projectorRepoStub) WithTx(any) interfaces.AIRepository { return s } +func (s *projectorRepoStub) WithTx(any) interfaces.AIRepository { return s } -func TestAIMessageProjectorPersistsThinkingTrace(t *testing.T) { +func TestAIMessageProjectorPersistsBasicMessageAndClearsTraceJSON(t *testing.T) { repo := &projectorRepoStub{} message := &entity.AIMessage{ ID: "msg_ai_1", ConversationID: "conv_1", Role: "assistant", Status: aiMessageStatusLoading, - TraceItemsJSON: "[]", - UIBlocksJSON: "[]", - ScopeJSON: "{}", + TraceItemsJSON: `[{"key":"legacy","title":"legacy"}]`, + UIBlocksJSON: `[{"key":"legacy"}]`, + ScopeJSON: `{"legacy":true}`, } projector := newAIMessageProjector(repo, message) projector.applyEvent(aidomain.Event{ - Name: aidomain.EventThinkingStarted, - Payload: aidomain.ThinkingStartedPayload{Title: "深度思考"}, + Name: aidomain.EventAssistantToken, + Payload: aidomain.AssistantTokenPayload{Token: "第一段"}, }) projector.applyEvent(aidomain.Event{ - Name: aidomain.EventThinkingDelta, - Payload: aidomain.ThinkingDeltaPayload{Delta: "正在拆解问题。"}, - }) - projector.applyEvent(aidomain.Event{ - Name: aidomain.EventThinkingCompleted, - Payload: aidomain.ThinkingCompletedPayload{Content: "正在拆解问题。\n下一步会组织正式回答。"}, + Name: aidomain.EventMessageCompleted, + Payload: aidomain.MessageCompletedPayload{Content: "第一段第二段"}, }) if err := projector.persistMessage(context.Background()); err != nil { t.Fatalf("persistMessage() error = %v", err) } - - traceItems := decodeTraceItemsForTest(t, repo.lastMessage.TraceItemsJSON) - if len(traceItems) != 1 { - t.Fatalf("trace items len = %d, want 1", len(traceItems)) + if repo.lastMessage.Content != "第一段第二段" { + t.Fatalf("content = %q", repo.lastMessage.Content) } - if traceItems[0].Key != thinkingTraceKey { - t.Fatalf("trace key = %q", traceItems[0].Key) + if repo.lastMessage.Status != aiMessageStatusSuccess { + t.Fatalf("status = %q", repo.lastMessage.Status) } - if traceItems[0].Status != aiMessageStatusSuccess { - t.Fatalf("trace status = %q", traceItems[0].Status) + if repo.lastMessage.TraceItemsJSON != "[]" { + t.Fatalf("trace json = %q", repo.lastMessage.TraceItemsJSON) } - if traceItems[0].Content != "正在拆解问题。\n下一步会组织正式回答。" { - t.Fatalf("trace content = %q", traceItems[0].Content) + if repo.lastMessage.UIBlocksJSON != "[]" { + t.Fatalf("ui blocks json = %q", repo.lastMessage.UIBlocksJSON) + } + if repo.lastMessage.ScopeJSON != "{}" { + t.Fatalf("scope json = %q", repo.lastMessage.ScopeJSON) } } -func TestAIMessageProjectorMarksThinkingStopped(t *testing.T) { +func TestAIMessageProjectorSetStoppedAndError(t *testing.T) { repo := &projectorRepoStub{} message := &entity.AIMessage{ ID: "msg_ai_2", ConversationID: "conv_2", Role: "assistant", Status: aiMessageStatusLoading, - TraceItemsJSON: "[]", } projector := newAIMessageProjector(repo, message) - projector.applyEvent(aidomain.Event{ - Name: aidomain.EventThinkingDelta, - Payload: aidomain.ThinkingDeltaPayload{Delta: "正在归纳重点。"}, - }) - projector.setStopped() + projector.setStopped() if err := projector.persistMessage(context.Background()); err != nil { t.Fatalf("persistMessage() error = %v", err) } + if repo.lastMessage.Status != aiMessageStatusStopped { + t.Fatalf("stopped status = %q", repo.lastMessage.Status) + } - traceItems := decodeTraceItemsForTest(t, repo.lastMessage.TraceItemsJSON) - if len(traceItems) != 1 { - t.Fatalf("trace items len = %d, want 1", len(traceItems)) + projector.setError("模型调用失败") + if err := projector.persistMessage(context.Background()); err != nil { + t.Fatalf("persistMessage() error = %v", err) } - if traceItems[0].Status != aiMessageStatusStopped { - t.Fatalf("trace status = %q, want %q", traceItems[0].Status, aiMessageStatusStopped) + if repo.lastMessage.Status != aiMessageStatusError { + t.Fatalf("error status = %q", repo.lastMessage.Status) } -} - -func decodeTraceItemsForTest(t *testing.T, raw string) []resp.AssistantTraceItem { - t.Helper() - items := make([]resp.AssistantTraceItem, 0) - if err := json.Unmarshal([]byte(raw), &items); err != nil { - t.Fatalf("unmarshal trace items error = %v", err) + if repo.lastMessage.ErrorText != "模型调用失败" { + t.Fatalf("error text = %q", repo.lastMessage.ErrorText) } - return items } diff --git a/internal/service/system/aiSink.go b/internal/service/system/aiSink.go index 9061aec..f4ff6d4 100644 --- a/internal/service/system/aiSink.go +++ b/internal/service/system/aiSink.go @@ -95,7 +95,9 @@ func (s *aiStreamSink) persistMessage(ctx context.Context) error { func (s *aiStreamSink) shouldPersist(name aidomain.EventName) bool { switch name { - case aidomain.EventThinkingCompleted, aidomain.EventMessageCompleted, aidomain.EventError, aidomain.EventDone: + case aidomain.EventMessageCompleted, + aidomain.EventError, + aidomain.EventDone: return true case aidomain.EventConversationStarted: return false diff --git a/internal/service/system/aiSink_test.go b/internal/service/system/aiSink_test.go index bb09f38..e6615be 100644 --- a/internal/service/system/aiSink_test.go +++ b/internal/service/system/aiSink_test.go @@ -74,3 +74,33 @@ func TestAIStreamSinkThrottlesPersistButForceFlushesFinalEvents(t *testing.T) { t.Fatalf("writer events len = %d, want 3", len(writer.events)) } } + +func TestAIStreamSinkDoesNotPersistConversationStarted(t *testing.T) { + repo := &projectorRepoStub{} + writer := &writerStub{} + message := &entity.AIMessage{ + ID: "msg_ai_sink_step", + ConversationID: "conv_sink_step", + Role: "assistant", + Status: aiMessageStatusLoading, + TraceItemsJSON: "[]", + UIBlocksJSON: "[]", + ScopeJSON: "{}", + } + sink := newAIStreamSink(repo, writer, message) + sink.persistInterval = time.Hour + + if err := sink.Emit(context.Background(), aidomain.Event{ + Name: aidomain.EventConversationStarted, + Payload: aidomain.ConversationStartedPayload{Title: "新建会话"}, + }); err != nil { + t.Fatalf("Emit() error = %v", err) + } + + if repo.updateCalls != 0 { + t.Fatalf("update calls after conversation_started = %d, want 0", repo.updateCalls) + } + if len(writer.events) != 1 { + t.Fatalf("writer events len = %d, want 1", len(writer.events)) + } +} diff --git "a/plan/ai/approved-ai-\346\270\220\350\277\233\345\274\217ddd-rules-and-skills.md" "b/plan/ai/approved-ai-\346\270\220\350\277\233\345\274\217ddd-rules-and-skills.md" new file mode 100644 index 0000000..70b41c4 --- /dev/null +++ "b/plan/ai/approved-ai-\346\270\220\350\277\233\345\274\217ddd-rules-and-skills.md" @@ -0,0 +1,65 @@ +# 目标 + +- 明确把本项目“传统 MVC 主体 + AI 子域渐进式 DDD 拆分”的架构定位沉淀到仓库规则与本地 skill 中。 +- 补齐 AI 方向的落地约束,避免后续开发再次把 AI 子域误做成“全量 DDD 重构”或“继续堆在 MVC Service 内”的两种极端。 +- 让 Codex / 本地协作代理在处理 AI 相关任务时,默认遵守同一套目录边界、依赖方向和演进策略。 + +# 范围 + +- `AGENTS.md` +- `.codex/skills/personal-assistant-go-backend/SKILL.md` +- `.codex/skills/personal-assistant-go-backend/references/project-rules.md` +- 可选同步文档: + - `docs/AI/AI领域+DDD架构拆分.md` + +# 改动 + +- 在 `AGENTS.md` 中补充 AI 子域专项规则,至少覆盖: + - 项目整体仍以 MVC 为主体,不以“全量 DDD 化”为默认目标。 + - AI 子域允许渐进式引入 `domain/ai` 与 `infrastructure/ai`,但 `controller/router/service/repository` 主体结构继续保留。 + - AI 方向新增能力时,优先判断应放在: + - `domain/ai`:稳定协议、事件、tool/runtime 抽象。 + - `infrastructure/ai`:Eino / Local / 外部模型 / 第三方 Agent 适配。 + - `service/system`:AI 应用编排,不承载底层框架细节。 + - 禁止把 AI 子域演进误解为“一次性全盘 DDD 重构”。 + - 禁止把 AI runtime / tool / trace / prompt 拼装继续无边界堆进单个 service 文件。 +- 在本地 skill 中增加“AI 渐进式 DDD 工作流”说明,至少覆盖: + - 遇到 AI 子域开发、重构、评审时,先识别本次变更属于协议层、应用编排层还是基础设施适配层。 + - AI 子域优先复用现有 MVC 外壳,只在必要处补 `domain/ai` 与 `infrastructure/ai`。 + - 若用户要求做 AI tool、runtime、事件、trace、恢复、approval 等能力,应优先检查是否符合当前“渐进式 DDD”边界,而不是直接新增散落结构。 + - 评审 AI 代码时,将“依赖方向错误、协议与实现耦合、service 越界膨胀”列为重点问题。 +- 在 `references/project-rules.md` 中补充与 AGENTS 对齐的权威表述,避免 skill 与仓库规则脱节。 +- 如有必要,在 `docs/AI/AI领域+DDD架构拆分.md` 增补“当前项目口径”段落,明确: + - 这是 AI 子域的局部 DDD,不是项目整体目录全面改名。 + - A2UI、interrupt、tool、runtimecontrol 等能力可按阶段收缩或重建,但不影响“渐进式 DDD”主口径。 + +# 验证 + +- 检查 `AGENTS.md`、skill 和 reference 三处表述是否一致,不出现互相冲突的架构口径。 +- 检查新增规则是否能直接指导后续 AI 任务落点判断,而不是停留在空泛概念。 +- 检查 skill 中是否明确区分: + - MVC 主体不动 + - AI 子域局部拆分 + - domain / infrastructure 的边界 +- 如同步文档,确认文档措辞与仓库规则一致,不出现“已经全量 DDD 重构”的误导描述。 + +# 风险 + +- 若规则写得过泛,后续代理仍可能把 AI 改动继续堆进 `service/system`。 +- 若规则写得过硬,可能误导为“所有 AI 代码都必须立即迁入完整 DDD 目录”,反而提高改造成本。 +- 若只改 skill 不改 AGENTS / reference,规则来源会分裂,后续执行口径仍不统一。 + +# 执行顺序 + +1. 盘点现有 AGENTS、skill、reference 与 AI 架构文档中的表述差异。 +2. 设计统一口径:MVC 主体、AI 渐进式 DDD、边界与禁止模式。 +3. 更新 `AGENTS.md`。 +4. 更新本地 skill 与 `references/project-rules.md`。 +5. 视需要同步 `docs/AI/AI领域+DDD架构拆分.md`。 +6. 做文本一致性复核并向用户汇总。 + +# 待确认 + +- 是否要求同时修改仓库根 `AGENTS.md` 与本地 skill;默认建议两者都改,避免规则分裂。 +- 是否把 `docs/AI/AI领域+DDD架构拆分.md` 一并同步为正式口径;默认建议同步一小段,不大改全文。 +- 是否需要在 skill 中单独新增“AI 子域任务专用决策树”小节;默认建议新增。 From 16889af8efd72362144985d30bb42fb93fd3ea65 Mon Sep 17 00:00:00 2001 From: wang Date: Thu, 23 Apr 2026 21:14:58 +0800 Subject: [PATCH 10/17] =?UTF-8?q?=E8=BF=9E=E6=8E=A5Qdrant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + configs/configs.yaml | 3 + "docs/AI/ReAct\350\256\276\350\256\241.md" | 111 ++ "docs/AI/Tool\350\256\276\350\256\241.md" | 187 +++ docs/apifox/ai_assistant.openapi.json | 138 +- flag/flagSql.go | 18 + internal/core/config.go | 4 + internal/domain/ai/event.go | 36 + internal/domain/ai/runtime.go | 9 + internal/domain/ai/runtime_test.go | 6 + internal/domain/ai/tool.go | 95 ++ internal/infrastructure/ai/eino/runtime.go | 387 ++++- .../ai/eino/runtime_tools_test.go | 182 +++ internal/model/config/config.go | 7 + internal/model/config/qdrant.go | 7 + internal/model/dto/response/aiResp.go | 58 +- internal/model/entity/ai.go | 1 - internal/service/system/aiMapper.go | 14 - internal/service/system/aiMapper_test.go | 35 + internal/service/system/aiProjector.go | 134 +- internal/service/system/aiProjector_test.go | 60 +- internal/service/system/aiSink.go | 2 + internal/service/system/aiSink_test.go | 2 - internal/service/system/aiSvc.go | 105 +- internal/service/system/aiTool.go | 1335 +++++++++++++++++ internal/service/system/aiTool_test.go | 370 +++++ internal/service/system/supplier.go | 12 +- plan/ai/approved-ai-tool-runtime.md | 29 + plan/ai/approved-assistant-trace-merge.md | 42 + plan/ai/approved-qdrant-config.md | 32 + plan/ai/approved-remove-ai-ui-blocks.md | 64 + 31 files changed, 3286 insertions(+), 205 deletions(-) create mode 100644 "docs/AI/ReAct\350\256\276\350\256\241.md" create mode 100644 internal/domain/ai/tool.go create mode 100644 internal/infrastructure/ai/eino/runtime_tools_test.go create mode 100644 internal/model/config/qdrant.go create mode 100644 internal/service/system/aiMapper_test.go create mode 100644 internal/service/system/aiTool.go create mode 100644 internal/service/system/aiTool_test.go create mode 100644 plan/ai/approved-ai-tool-runtime.md create mode 100644 plan/ai/approved-assistant-trace-merge.md create mode 100644 plan/ai/approved-qdrant-config.md create mode 100644 plan/ai/approved-remove-ai-ui-blocks.md diff --git a/.env.example b/.env.example index cdd65c8..fb9ae7c 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,12 @@ AI_API_VERSION= AI_SYSTEM_PROMPT=你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。 AI_TEMPERATURE=0.2 AI_MAX_COMPLETION_TOKENS=1200 + +# ======================== 可选:Qdrant 向量数据库 ======================== +# Qdrant HTTP API 地址;容器同网络可使用 http://qdrant:6333 +QDRANT_ENDPOINT= +QDRANT_API_KEY= + SSE_HEARTBEAT_INTERVAL_SECONDS=20 SSE_WRITE_TIMEOUT_SECONDS=10 SSE_QUEUE_CAPACITY=64 diff --git a/configs/configs.yaml b/configs/configs.yaml index 307acf1..a4011c9 100644 --- a/configs/configs.yaml +++ b/configs/configs.yaml @@ -244,6 +244,9 @@ ai: system_prompt: "你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。" temperature: 0.2 max_completion_tokens: 1200 +qdrant: + endpoint: "" # Example: http://127.0.0.1:6333 + api_key: "" # Use QDRANT_API_KEY in .env for real credentials observability: enabled: true # 总开关 service_name: "personal_assistant" # 服务名维度(需稳定) diff --git "a/docs/AI/ReAct\350\256\276\350\256\241.md" "b/docs/AI/ReAct\350\256\276\350\256\241.md" new file mode 100644 index 0000000..571d140 --- /dev/null +++ "b/docs/AI/ReAct\350\256\276\350\256\241.md" @@ -0,0 +1,111 @@ +建议按“生产级 Agent Runtime”来重构,而不是在现有 `Stream()` 里手写 ReAct 循环。成熟路线是:**用 Eino ADK `ChatModelAgent + Runner` 承担 ReAct 编排,你的项目继续保留 `AIService -> Runtime -> Sink -> Projector -> Repository` 这条业务边界。** + +**目标架构** +```text +Controller + -> AIService + -> AgentRuntime(domain/ai.Runtime) + -> Eino ADK Runner + -> ChatModelAgent(ReAct) + -> ToolsNode + -> EventAdapter + -> aiStreamSink + -> SSE + -> aiMessageProjector + -> ai_messages.trace_items_json / ui_blocks_json / scope_json +``` + +ReAct 内部流程是: + +```text +Reason: 模型判断是否需要工具 +Action: 生成 tool_call +Act: 执行业务 Tool +Observation: tool_result 回灌给模型 +Repeat: 直到模型不再调用工具,输出最终回答 +``` + +**推荐编码顺序** + +1. **先扩展事件协议** + 当前 [event.go]() 只有 token/done/error,不够表达 ReAct。建议新增: + - `agent_step_started` + - `tool_call_started` + - `tool_call_finished` + - `tool_call_failed` + - `tool_call_waiting_confirmation` + - `structured_block` + - `thinking_summary` + + 注意:不要展示模型原始思维链。成熟产品通常展示“执行摘要 / 工具轨迹 / 可审计步骤”,不是裸 Chain-of-Thought。 + +2. **恢复 `aiProjector` 的 trace 投影能力** + 你现在的 [aiProjector.go]() 明确只处理基础文本。下一步应让它重新维护: + - `TraceItemsJSON`:工具调用轨迹、耗时、状态、确认动作 + - `UIBlocksJSON`:结构化结果卡片 + - `ScopeJSON`:当前用户、组织、业务上下文 + + 好处是前端刷新历史消息时,仍能看到 Agent 做过什么。 + +3. **新增 Tool 业务边界** + 不建议让 Eino Tool 直接查 DB。建议这样拆: + + ```text + internal/service/system/aiToolService.go + 负责业务查询、权限、组织范围、结果裁剪 + + internal/infrastructure/ai/eino/tools/*.go + 只做 Eino tool schema + adapter + ``` + + 工具参数里不要相信模型传来的 `user_id/org_id`,这些必须从 `StreamInput.UserID` 和服务端上下文注入。 + +4. **第一批 Tool 只做只读能力** + 建议从低风险工具开始: + - `get_current_user_scope` + - `get_oj_daily_stats` + - `search_project_docs` + - `get_conversation_summary` + + 先不要做写操作。写操作后面必须接 interrupt/confirmation。 + +5. **把 Eino runtime 从 ChatModel 改为 AgentRuntime** + 当前 [runtime.go]() 是直接 `r.model.Stream(...)`。重构后这里不再直接调 ChatModel,而是: + - 构造 `ChatModelAgent` + - 注册工具 `ToolsConfig` + - 用 `Runner` 执行 + - 把 `AgentEvent` 转成你的 `aidomain.Event` + - 继续通过 `sink.Emit()` 输出 + + Eino 官方的 `ChatModelAgent` 内部就是 ReAct;如果只是简单 ReAct,也可以用 `react.NewAgent`,但你后续要 interrupt/resume/checkpoint,所以更建议 ADK Runner。 + +6. **加 Planner/白名单,不要把所有工具暴露给模型** + 成熟 Agent 系统不会每轮都给模型全量工具。建议在 `AIService` 调 runtime 前先做一个轻量 plan: + + ```text + 用户问题 -> intent/plan -> allowed_tools -> AgentRuntime + ``` + + 例如普通闲聊不给工具;问 OJ 数据才开放 OJ 工具;问项目文档才开放文档检索。 + +7. **第二阶段再做 interrupt/resume** + 你的 [AIInterrupt]() 表已经有基础字段,可以后续接: + - 高风险工具调用前暂停 + - 写入 `AIInterrupt` + - SSE 发 `tool_call_waiting_confirmation` + - 用户确认后 `Runner.Resume(checkpoint_id)` + - projector 更新 trace 状态 + + 写操作、跨组织查询、发送通知、长期记忆写入,都应该走这套。 + +**成熟性检查清单** + +- Tool 有超时、限流、最大返回长度。 +- Agent 有 `MaxIterations/MaxStep`,避免无限循环。 +- Tool 参数和返回值都做 schema 校验。 +- Tool 结果做脱敏和摘要,不把大对象直接塞回模型。 +- trace 里记录 tool name、status、duration、redacted args、summary。 +- 所有 Tool 使用 `context.Context`,权限基于服务端用户上下文。 +- 测试覆盖:纯聊天、一次工具调用、多次工具调用、工具失败、超步数、SSE 中断、DB trace 落库。 + +官方参考我建议看这几份:CloudWeGo [ReAct Agent Manual](https://www.cloudwego.io/docs/eino/core_modules/flow_integration_components/react_agent_manual/)、[Eino ADK ChatModelAgent](https://www.cloudwego.io/docs/eino/core_modules/eino_adk/agent_implementation/chat_model/)、[Agent Runner and Extension](https://www.cloudwego.io/docs/eino/core_modules/eino_adk/agent_extension/)。当前项目要做“市面成熟”的 ReAct,优先走 ADK Runner 这条线。 \ No newline at end of file diff --git "a/docs/AI/Tool\350\256\276\350\256\241.md" "b/docs/AI/Tool\350\256\276\350\256\241.md" index e69de29..4c2daf9 100644 --- "a/docs/AI/Tool\350\256\276\350\256\241.md" +++ "b/docs/AI/Tool\350\256\276\350\256\241.md" @@ -0,0 +1,187 @@ +@[TOC](如何设计一个灵活、高效、安全的 AI 工具系统) + +在 AI 应用里面,决定系统上限的不止有LLM,还包括工具集(`tools`)的管理,也就是(Function Calling)。 +在没有 `tools` 之前,大模型也`只能`文本对话,无法进行具体的业务操作。而 `tools` 出来之后,就相当于为大模型安装上了双手。让他可以接触`真实的业务环境`。 + +很多人,对工具调用的固有认知还停留在,我设计一个 JSON Schema - 告诉大模型 `调用` 这个函数需要`传入的信息`,然后大模型调用工具并**产生数据**。 + +但在生产环境中,这是只是最基础的! + +因为在开发中,很快会遇到很多问题: + +1、**协议是否是统一** (比如东边用Json Schma,西边用自定义类型) +2、**权限边界是否清晰**(保证LLM不会越过权限做事) +3、**运行过程是否可控** (是否可追踪,能让用户看到,在干嘛) + +这些都是需要考虑的问题,而这`三点`,也是我下文着重突出的内容! + + + + + +## 技术设计: +这里我宏观讲解一下,`实现`与`使用`。 + +### 宏观定义: + +设计工具系统首先要考虑到的,就是`协议统一`问题! +目的就是为了不让把`工具`写的散乱,所以定义一套约束,这也就是接口的意义。 +如,你需要知道: +1. 这个函数 **叫什么**名字,**能干嘛**,**吃什么参数**(ToolSpec) +2. 他如何**调用**,目标的tool(TooCall) +3. 你调用过工具后,**返回的参数**也需要统一(ToolResult) +4. 同时为了统一性,你需要把`定义` 与 `执行` 绑到一个协议上。 + +具体函数如下: +```go +type ToolParameterType string + +const ( + ToolParameterTypeObject ToolParameterType = "object" + ToolParameterTypeString ToolParameterType = "string" + ToolParameterTypeInteger ToolParameterType = "integer" + ToolParameterTypeNumber ToolParameterType = "number" + ToolParameterTypeBoolean ToolParameterType = "boolean" + ToolParameterTypeArray ToolParameterType = "array" +) + +type ToolParameter struct { + Name string + Type ToolParameterType + Description string + Required bool + Enum []string + Properties []ToolParameter + Items *ToolParameter +} + +type ToolSpec struct { + Name string + Description string + Parameters []ToolParameter +} + +type ToolCall struct { + ID string + Name string + ArgumentsJSON string +} + +type ToolResult struct { + Output string + Summary string + DetailMarkdown string +} + +type Tool interface { + Spec() ToolSpec + Call(ctx context.Context, call ToolCall, callCtx ToolCallContext) (ToolResult, error) +} + +``` + + +### 技术选择 +我对入参的描述,是仿照的 Json Schema模式,而并没有直接选择,因为它对目前的我来说有点重,并且可读性也不高。 +重点是,目前我定义的这套接口中,重心不在这。 +因为工具系统的设计, +还需要着重考虑: +1、**权限的治理**(哪些工具是有权限调用的) +2、**可见性过滤**(哪些工具适合给大模型调用,不适合的就不包装发给大模型了) +3、调用的内容如何**被观测到**。 + +所以,虽 Json Schema 的兼容性会更高些,但是最差的结果,也就是我写一层兼容而已。因为我目前的中心明显不在这里。 + + + + +## 架构设计 + +如果要从架构方面说起,最重要的就是`业务实现`与`技术实现`了。 + +1. **业务实现**,回答的是‘这轮 AI 到底该做什么’,比如当前 用户是谁、有没有权限、哪些工具本轮可见等 +2. **技术实现**:‘我用什么手段把它做出来’,比如用 Eino 做 tool calling、用 GORM 落库等 + +为了这两种,我将这个模块拆分成了 `service` 、`domain`、`infra` 三层。 +其中 `domain` 做领域层,service与infra共同遵循。 + +然后service层就是做些业务编排,比如先通过用户权限,筛选出可以工具,然后拼装prompt提示词。最后统一调用AI。 +而Infrastructure层,做的是:用Eino对工具进行处理调用,解决这次调用问题! + +这样做的好处是,权限变了,工具变了,我只用改动service层, +但是如果我像换AI的编排框架,直接换infra层就行,也不会对其他造成影响。 + +另外就是为了维护系统的,灵活与高效。 +我`没有`让工具系统 `直接依赖` 完整的业务服务。而是分别调用了几个不同的业务接口,如`可观测性模块`接口、`权限模块`接口.. +避免了`上帝接口`的出现。也算是遵守了**单一职责原则**。 +这样工具层`只依赖`他所需要的真正能力,而且后续也方便Mock接口,做单元测试等... + + + + + + +## 安全性设计 +### 灵活权限设计: +在安全维度的设计,我最核心的点,就是`没有`对身份进行`硬编码`。 +我最初会在service层,获取三种东西:user_id、org_id、以及他是否是管理员。 +从而为他分配三种权限:**self_only**、**OrgCapability**、**SuperAdminOnly**。 +其中self_only,用来告诉它,它是由基础权限的。 +然后OrgCapability,用来过滤他在组织中有哪些权限做事。 +superAdminOnly,代表它可以进行运维管理。 + +### 安全兜底: +最后通过 capabilities,进行第一次权限过滤,并进入。 +当然,是获取工具列表前,用权限过滤一次。 +然后大模型调用 tool 时,在对权限进行筛选一次。 + + + + +## 扩展性与可维护性设计: +### 新增工具是低成本的 +因为domain层,已经将协议定义好。而之后要做的只是`增量开发`。 +所以之后新增,仅需 +- 声明一下工具的`元数据` +- 所属访问策略 +- 以及工具实现的具体逻辑。 +而不需要把新增的工具放到一个大大的switch中! + +### 修改权限也更加灵活方便 +修改权限的成本更低,比如`想为你分配`这个`工具`的`权限`,只需要你的角色分配对应`资源` capability 即可。 +新增的话,也只需要新增一个 capability 能力,然后分配给对应的**角色**, +注册工具时,就会自动筛选掉,不需要再操心其他。 + + + +## 性能与效率 +最典型的就是两点。 +1、分路执行,如果你什么权限都没有,也就代表你使用不了tool,所以可以直接走纯文本模式。而不必走ReAct。 +2、只暴露本轮可见工具。系统不会把全量的tools直接扔给大模型,即减少了token消耗,有减少了,因为越权问题,而导致的访问失败与风险。 + + + + +## 用户体验 +对于工具系统而言,`无法`直接对前端UI造成大的改动与美化。 +但是我可以通过 `tool_call_started` 与 `tool_call_finished`。 +来让前端知晓,执行到了那一步了,执行信息是什么。 +让用户对生产出来的效果,有一个基础认知。这远比AI好几秒,突然给出一个结果好的多。 + +## 总结 +如果只看原始的 Function Calling ,只能在demo场景应用。 +在生产环境中,有三个核心问题需要注意,分别是**协议定义是否统一**、**权限是否能得到保证**、**执行过程是否可以被观测**。 +为了解决这三点,我从三方面进行入手。 + +**第一,我自定义了轻量级统一工具协议**,我未用Json Schema,而只是模仿,因为Json Schema对我而言比较重,并且对人来说可读性也不是很高。 +同时在我工具集的设计中,我的重心是放在权限与可观察性上的。 +不过这并不代表我没有在意,在后期非要加上的话,我可以多加一层适配层。 + +**第二,三层架构的拆分**。我将我的工具集拆分成了三层。 +分别为Service,专注于业务实现,他的作用就是将各个业务函数编排到一起,去实现对应的功能。 +其次为domain层,定义好协议,如工具的元数据(出参、入参、工具名称..)的接口,方便后续做增量开发。 +最后为infra,这层是基础设施。专注于技术实现,比如我采用的具体技术是Eino,说不定以后会换成其他AI框架,像Langchain啊,都不是不行。 + +**第三, 双重安全防护措施**,在注册一次工具调用的时候,我会先根据权限筛选一遍,这样不仅可以省token,而且可以防止越界。在LLM调用具体tool的时候,我会在判断一遍。 + +故,因这三点,我做到了标准化、安全性、解耦、性能! diff --git a/docs/apifox/ai_assistant.openapi.json b/docs/apifox/ai_assistant.openapi.json index 929a5cb..48bd438 100644 --- a/docs/apifox/ai_assistant.openapi.json +++ b/docs/apifox/ai_assistant.openapi.json @@ -3,7 +3,7 @@ "info": { "title": "z_cur/UI AI 助手模块 OpenAPI", "version": "1.2.0", - "description": "覆盖当前 `personal-assistant-frontend` AI 助手模块使用的接口,可直接导入 Apifox。\n当前 V1 协议已经收敛为“单条聊天 SSE 流 + 推理详情流 + 行动日志流 + 最终正文流”。\n统一约束:\n1. CRUD 接口走 JSON BizResponse;`stream` 接口返回 `text/event-stream`。\n2. `stream` 是唯一聊天 SSE 流,不恢复 decision / interrupt / 第二条流。\n3. `thinking_started / thinking_delta / thinking_completed` 承载用户可见的高细节推理详情,不暴露模型私有原始推理。\n4. `detail_step_started / detail_step_updated / detail_step_completed` 承载结构化行动日志。\n5. `assistant_token` 与 `message_completed.content` 只承载最终正文。\n6. `trace_items` 同时持久化 `thinking_summary` 和多个 `detail_step`,用于刷新后恢复过程区历史。" + "description": "覆盖当前 `personal-assistant-frontend` AI 助手模块使用的接口,可直接导入 Apifox。\n当前 V1 协议已经收敛为“单条聊天 SSE 流 + 工具轨迹 + 最终正文”。\n统一约束:\n1. CRUD 接口走 JSON BizResponse;`stream` 接口返回 `text/event-stream`。\n2. `stream` 是唯一聊天 SSE 流,不恢复第二条推理流。\n3. `tool_call_started / tool_call_finished` 承载工具调用轨迹;前端会把 `trace_items` 折叠进同一条 assistant 消息显示。\n4. `assistant_token` 与 `message_completed.content` 只承载最终正文。\n5. `trace_items` 用于历史恢复和流式过程对齐,不额外暴露 `trace_items_json` 原始字符串。" }, "servers": [ { @@ -194,7 +194,7 @@ "AI助手" ], "summary": "获取会话消息列表", - "description": "历史消息必须能完整重建 UI。\n后端返回时应保留 `trace_items / scope`;`scope` 仅在复杂范围时返回。\nUI 侧会按“推理详情 / 行动日志 / 最终正文”重建消息结构。\n消息按创建时间升序返回;相同时间戳下按 `user -> assistant -> system` 稳定排序。", + "description": "历史消息必须能完整重建 UI。\n后端返回时保留 `content / trace_items / scope`;`scope` 仅在复杂范围时返回。\n前端会把 `trace_items` 折叠成普通文本,并与 `content` 合并为同一条 assistant 消息显示。\n消息按创建时间升序返回;相同时间戳下按 `user -> assistant -> system` 稳定排序。", "operationId": "GetAssistantConversationMessages", "parameters": [ { @@ -240,8 +240,7 @@ "content": "帮我整理一版任务汇报并说明助手页面定位。", "created_at": "2026-04-06T10:40:01+08:00", "status": "success", - "trace_items": [], - "ui_blocks": [] + "trace_items": [] }, { "id": "msg_ai_001", @@ -267,83 +266,6 @@ "duration_ms": 88, "content": "已命中 README、架构设计方案与 AI UI 改造说明。" } - ], - "ui_blocks": [ - { - "key": "block_thinking_summary_001", - "type": "thinking_summary_block", - "surface": { - "id": "surface_thinking_summary_001", - "root": "thinking_card_root", - "components": [ - { - "id": "thinking_card_root", - "type": "Card", - "tone": "primary", - "children": [ - "thinking_title", - "thinking_points" - ] - }, - { - "id": "thinking_title", - "type": "Text", - "value": "当前判断与下一步", - "usage_hint": "title" - }, - { - "id": "thinking_points", - "type": "BulletList", - "items": [ - "当前判断:本轮所需信息已经齐备,可以直接输出最终正文。", - "当前动作:已把工具结果收束成正式回答,不再重复展示中间过程。", - "下一步:如果你要改口吻、改结构或扩范围,可以继续追问。" - ] - } - ] - } - }, - { - "key": "block_tool_intent_001", - "type": "tool_intent_block", - "surface": { - "id": "surface_tool_intent_001", - "root": "tool_intent_card_root", - "components": [ - { - "id": "tool_intent_card_root", - "type": "Card", - "tone": "success", - "children": [ - "tool_badge", - "tool_title", - "tool_points" - ] - }, - { - "id": "tool_badge", - "type": "Badge", - "label": "已完成", - "tone": "success" - }, - { - "id": "tool_title", - "type": "Text", - "value": "本轮已处理 2 个工具", - "usage_hint": "title" - }, - { - "id": "tool_points", - "type": "BulletList", - "items": [ - "目的:任务快照、项目文档已经用于补齐本轮回答所需的信息。", - "必要性:这些问题依赖最新业务数据或正式文档,不能只靠上下文推断。", - "预期收益:最终正文会更准确,也更适合直接复用。" - ] - } - ] - } - } ] } ], @@ -462,7 +384,7 @@ "AI助手" ], "summary": "发送用户消息并开启唯一聊天 SSE 流", - "description": "`stream` 是单轮问答的唯一事件流。\n当前事件顺序固定为:`conversation_started -> detail_step_started/detail_step_updated* -> thinking_started -> thinking_delta* -> thinking_completed -> detail_step_completed/detail_step_updated* -> assistant_token* -> message_completed -> detail_step_completed -> done`;失败时输出 `error -> done`。\n`thinking_*` 事件用于展示用户可见的高细节推理详情,不暴露模型私有原始推理。\n`detail_step_*` 事件用于展示结构化行动日志。\n`assistant_token` 和 `message_completed.content` 只用于最终正文。\nV1 不恢复 interrupt / decision / structured_block 工具链路。\n事件 payload 结构见 `AssistantEventPayloadCatalog`。", + "description": "`stream` 是单轮问答的唯一事件流。\n常见事件顺序为:`conversation_started -> tool_call_started/tool_call_finished* -> assistant_token* -> message_completed -> done`;工具失败会通过 `tool_call_finished.status=failed` 下发,致命失败时输出 `error -> done`。\n`tool_call_*` 事件用于维护结构化 `trace_items`,前端会把这些轨迹折叠进同一条 assistant 消息显示。\n`assistant_token` 和 `message_completed.content` 只用于最终正文。\nV1 不恢复第二条推理流,也不额外暴露 `trace_items_json` 原始字符串。\n事件 payload 结构见 `AssistantEventPayloadCatalog`。", "operationId": "StreamAssistantConversationMessage", "parameters": [ { @@ -503,7 +425,7 @@ "examples": { "sameStreamResume": { "summary": "原流等待确认并在同一连接内继续输出", - "value": "event: conversation_started\ndata: {\"title\":\"介绍一下这个项目\"}\n\nevent: detail_step_started\ndata: {\"key\":\"step_read_history\",\"kind\":\"action\",\"title\":\"读取历史上下文\",\"content\":\"当前没有可复用的历史消息,将按首轮问题直接展开本轮分析。\",\"status\":\"loading\",\"order\":10}\n\nevent: detail_step_completed\ndata: {\"key\":\"step_read_history\",\"kind\":\"action\",\"title\":\"读取历史上下文\",\"content\":\"当前没有可复用的历史消息,将按首轮问题直接展开本轮分析。\",\"status\":\"success\",\"order\":10}\n\nevent: thinking_started\ndata: {\"title\":\"深度思考\"}\n\nevent: thinking_delta\ndata: {\"delta\":\"先判断这轮问题更需要过程细节还是结果摘要,避免一开始就走错输出方向。\\n\"}\n\nevent: thinking_delta\ndata: {\"delta\":\"当前输入里最需要优先抓住的是项目定位、现状和接下来怎么讲清楚。\\n\"}\n\nevent: thinking_completed\ndata: {\"content\":\"先判断这轮问题更需要过程细节还是结果摘要,避免一开始就走错输出方向。\\n当前输入里最需要优先抓住的是项目定位、现状和接下来怎么讲清楚。\\n接下来会继续组织结构,再进入最终正文生成。\"}\n\nevent: detail_step_started\ndata: {\"key\":\"step_generate_answer\",\"kind\":\"action\",\"title\":\"生成正文\",\"content\":\"已进入正文生成阶段,接下来会持续输出最终回答。\",\"status\":\"loading\",\"order\":50}\n\nevent: assistant_token\ndata: {\"token\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\"}\n\nevent: assistant_token\ndata: {\"token\":\"\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: message_completed\ndata: {\"content\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: detail_step_completed\ndata: {\"key\":\"step_generate_answer\",\"kind\":\"action\",\"title\":\"生成正文\",\"content\":\"最终正文已经全部生成完成。\",\"status\":\"success\",\"order\":50}\n\nevent: done\ndata: {}\n" + "value": "event: conversation_started\ndata: {\"title\":\"介绍一下这个项目\"}\n\nevent: tool_call_started\ndata: {\"key\":\"tool_call_1\",\"tool_name\":\"get_project_snapshot\",\"title\":\"调用工具 get_project_snapshot\",\"description\":\"正在执行工具调用。\"}\n\nevent: tool_call_finished\ndata: {\"key\":\"tool_call_1\",\"tool_name\":\"get_project_snapshot\",\"description\":\"工具调用完成。\",\"duration_ms\":148,\"status\":\"success\",\"content\":\"已拿到项目当前快照和关键状态。\"}\n\nevent: assistant_token\ndata: {\"token\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\"}\n\nevent: assistant_token\ndata: {\"token\":\"\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: message_completed\ndata: {\"content\":\"这个项目目前处于 AI 助手基础阶段,重点提供流式对话能力。\\n\\n当前已经打通会话、历史消息和流式输出闭环。\"}\n\nevent: done\ndata: {}\n" } } }, @@ -809,7 +731,6 @@ "AssistantMessageStatus": { "type": "string", "enum": [ - "idle", "loading", "success", "error", @@ -819,10 +740,10 @@ "AssistantTraceStatus": { "type": "string", "enum": [ - "loading", + "running", "success", - "error", - "stopped" + "failed", + "waiting" ] }, "AssistantTraceItemKind": { @@ -1371,8 +1292,7 @@ "content", "created_at", "status", - "trace_items", - "ui_blocks" + "trace_items" ], "properties": { "id": { @@ -1400,12 +1320,6 @@ "$ref": "#/components/schemas/AssistantTraceItem" } }, - "ui_blocks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssistantA2UIBlock" - } - }, "scope": { "allOf": [ { @@ -1423,12 +1337,8 @@ "type": "string", "enum": [ "conversation_started", - "thinking_started", - "thinking_delta", - "thinking_completed", - "detail_step_started", - "detail_step_updated", - "detail_step_completed", + "tool_call_started", + "tool_call_finished", "assistant_token", "message_completed", "error", @@ -1529,6 +1439,9 @@ "key": { "type": "string" }, + "tool_name": { + "type": "string" + }, "title": { "type": "string" }, @@ -1549,6 +1462,9 @@ "key": { "type": "string" }, + "tool_name": { + "type": "string" + }, "description": { "type": "string" }, @@ -1689,23 +1605,11 @@ "conversation_started": { "$ref": "#/components/schemas/AssistantConversationStartedPayload" }, - "thinking_started": { - "$ref": "#/components/schemas/AssistantThinkingStartedPayload" - }, - "thinking_delta": { - "$ref": "#/components/schemas/AssistantThinkingDeltaPayload" - }, - "thinking_completed": { - "$ref": "#/components/schemas/AssistantThinkingCompletedPayload" - }, - "detail_step_started": { - "$ref": "#/components/schemas/AssistantDetailStepPayload" - }, - "detail_step_updated": { - "$ref": "#/components/schemas/AssistantDetailStepPayload" + "tool_call_started": { + "$ref": "#/components/schemas/AssistantToolCallStartedPayload" }, - "detail_step_completed": { - "$ref": "#/components/schemas/AssistantDetailStepPayload" + "tool_call_finished": { + "$ref": "#/components/schemas/AssistantToolCallFinishedPayload" }, "assistant_token": { "$ref": "#/components/schemas/AssistantTokenPayload" diff --git a/flag/flagSql.go b/flag/flagSql.go index bf9ac7f..e836125 100644 --- a/flag/flagSql.go +++ b/flag/flagSql.go @@ -106,6 +106,9 @@ func SQL() error { if err := migrateAPILifecycleData(db); err != nil { return err } + if err := dropLegacyAIMessageUIColumn(db); err != nil { + return err + } return migrateOrgMemberLifecycleData(db) } @@ -1222,6 +1225,21 @@ func migrateAPILifecycleData(db *gorm.DB) error { Error } +// dropLegacyAIMessageUIColumn removes the legacy AI message UI column. +func dropLegacyAIMessageUIColumn(db *gorm.DB) error { + if !db.Migrator().HasTable("ai_messages") { + return nil + } + exists, err := columnExists(db, "ai_messages", "ui_blocks_json") + if err != nil { + return err + } + if !exists { + return nil + } + return db.Exec("ALTER TABLE ai_messages DROP COLUMN ui_blocks_json").Error +} + // ensureAllMembersOrg 负责执行当前函数对应的核心逻辑。 // 参数: // - db:调用方传入的目标对象或配置实例。 diff --git a/internal/core/config.go b/internal/core/config.go index 68fe9fc..1a65e2c 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -62,6 +62,8 @@ func InitConfig(path string) { viper.SetDefault("ai.system_prompt", "你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。") viper.SetDefault("ai.temperature", 0.2) viper.SetDefault("ai.max_completion_tokens", 1200) + viper.SetDefault("qdrant.endpoint", "") + viper.SetDefault("qdrant.api_key", "") viper.SetDefault("rate_limit.oj_bind.limit", 3) viper.SetDefault("rate_limit.oj_bind.window_sec", 10) viper.SetDefault("observability.propagation.enabled", true) @@ -251,6 +253,8 @@ func InitConfig(path string) { _ = viper.BindEnv("ai.system_prompt", "AI_SYSTEM_PROMPT") _ = viper.BindEnv("ai.temperature", "AI_TEMPERATURE") _ = viper.BindEnv("ai.max_completion_tokens", "AI_MAX_COMPLETION_TOKENS") + _ = viper.BindEnv("qdrant.endpoint", "QDRANT_ENDPOINT") + _ = viper.BindEnv("qdrant.api_key", "QDRANT_API_KEY") _ = viper.BindEnv("observability.enabled", "OBSERVABILITY_ENABLED") _ = viper.BindEnv("observability.service_name", "OBSERVABILITY_SERVICE_NAME") _ = viper.BindEnv("observability.service_trace.enabled", "OBSERVABILITY_SERVICE_TRACE_ENABLED") diff --git a/internal/domain/ai/event.go b/internal/domain/ai/event.go index 64c9e26..2f90bf6 100644 --- a/internal/domain/ai/event.go +++ b/internal/domain/ai/event.go @@ -14,6 +14,12 @@ const ( // EventMessageCompleted 表示 assistant 消息已经生成完整内容。 EventMessageCompleted EventName = "message_completed" + // EventToolCallStarted 表示 assistant 已经开始调用某个工具。 + EventToolCallStarted EventName = "tool_call_started" + + // EventToolCallFinished 表示一次工具调用已经结束。 + EventToolCallFinished EventName = "tool_call_finished" + // EventError 表示 runtime 执行过程中出现可下发给前端的错误。 EventError EventName = "error" @@ -48,6 +54,36 @@ type MessageCompletedPayload struct { Content string `json:"content"` } +// ToolCallStartedPayload 表示工具调用开始事件的载荷。 +type ToolCallStartedPayload struct { + // Key 是本次工具调用在 trace_items 中的稳定主键。 + Key string `json:"key"` + // ToolName 是被调用的工具名,便于前端展示和排障。 + ToolName string `json:"tool_name,omitempty"` + // Title 是前端或 trace 卡片展示用的标题。 + Title string `json:"title"` + // Description 是当前阶段的人类可读状态说明。 + Description string `json:"description"` +} + +// ToolCallFinishedPayload 表示工具调用结束事件的载荷。 +type ToolCallFinishedPayload struct { + // Key 是本次工具调用在 trace_items 中的稳定主键。 + Key string `json:"key"` + // ToolName 是完成执行的工具名。 + ToolName string `json:"tool_name,omitempty"` + // Description 是完成阶段的人类可读状态说明。 + Description string `json:"description"` + // DurationMS 记录本次工具执行耗时,单位毫秒。 + DurationMS int64 `json:"duration_ms"` + // Status 表示本次工具调用最终状态,如 success 或 failed。 + Status string `json:"status"` + // Content 是用于摘要展示的短结果。 + Content string `json:"content,omitempty"` + // DetailMarkdown 是给 trace 详情面板展示的完整内容。 + DetailMarkdown string `json:"detail_markdown,omitempty"` +} + // ErrorPayload 表示 AI 流式执行失败时的事件载荷。 type ErrorPayload struct { Message string `json:"message"` diff --git a/internal/domain/ai/runtime.go b/internal/domain/ai/runtime.go index 14a9d86..76e38f9 100644 --- a/internal/domain/ai/runtime.go +++ b/internal/domain/ai/runtime.go @@ -38,6 +38,15 @@ type StreamInput struct { Content string History []Message + + // DynamicSystemPrompt 是本轮动态拼装的 system prompt,用于注入可见工具和约束。 + DynamicSystemPrompt string + + // Tools 是本轮允许 runtime 暴露给模型的工具集合。 + Tools []Tool + + // ToolCallContext 是所有工具调用共享的执行上下文。 + ToolCallContext ToolCallContext } // StreamResult 表示 runtime 执行完成后的结果摘要。 diff --git a/internal/domain/ai/runtime_test.go b/internal/domain/ai/runtime_test.go index 7ec315a..ff0307d 100644 --- a/internal/domain/ai/runtime_test.go +++ b/internal/domain/ai/runtime_test.go @@ -9,4 +9,10 @@ func TestEventNamesAreStable(t *testing.T) { if EventMessageCompleted != "message_completed" { t.Fatalf("EventMessageCompleted = %q", EventMessageCompleted) } + if EventToolCallStarted != "tool_call_started" { + t.Fatalf("EventToolCallStarted = %q", EventToolCallStarted) + } + if EventToolCallFinished != "tool_call_finished" { + t.Fatalf("EventToolCallFinished = %q", EventToolCallFinished) + } } diff --git a/internal/domain/ai/tool.go b/internal/domain/ai/tool.go new file mode 100644 index 0000000..c6ed997 --- /dev/null +++ b/internal/domain/ai/tool.go @@ -0,0 +1,95 @@ +package ai + +import "context" + +// ToolParameterType 表示工具参数的 JSON 类型。 +type ToolParameterType string + +const ( + ToolParameterTypeObject ToolParameterType = "object" + ToolParameterTypeString ToolParameterType = "string" + ToolParameterTypeInteger ToolParameterType = "integer" + ToolParameterTypeNumber ToolParameterType = "number" + ToolParameterTypeBoolean ToolParameterType = "boolean" + ToolParameterTypeArray ToolParameterType = "array" +) + +// ToolParameter 描述单个工具参数。 +// 对 object / array 参数,使用 Properties / Items 继续描述子结构。 +type ToolParameter struct { + // Name 是参数名,会直接暴露给模型作为调用参数键名。 + Name string + // Type 表示参数的 JSON 基础类型。 + Type ToolParameterType + // Description 描述参数含义和使用限制,帮助模型正确构造调用参数。 + Description string + // Required 标记该参数是否必须由模型显式提供。 + Required bool + // Enum 给出允许的枚举值范围,便于模型约束输入。 + Enum []string + // Properties 描述 object 参数的子字段结构。 + Properties []ToolParameter + // Items 描述 array 参数的元素结构。 + Items *ToolParameter +} + +// ToolSpec 描述一个稳定的 AI tool 协议。 +type ToolSpec struct { + // Name 是工具的稳定标识,供模型发起 tool call 时引用。 + Name string + // Description 说明工具用途和适用边界。 + Description string + // Parameters 描述该工具接受的结构化参数列表。 + Parameters []ToolParameter +} + +// ToolCall 表示模型发起的一次工具调用。 +type ToolCall struct { + // ID 是本次调用在一轮对话内的稳定标识,用于 trace 关联。 + ID string + // Name 是模型请求执行的工具名。 + Name string + // ArgumentsJSON 是模型传入的 JSON 参数原文。 + ArgumentsJSON string +} + +// ToolResult 表示工具执行结果。 +// Output 会回传给模型;Summary / DetailMarkdown 用于 trace 投影和前端展示。 +type ToolResult struct { + // Output 是返回给模型继续推理使用的结构化结果正文。 + Output string + // Summary 是给 trace 摘要和前端卡片使用的短说明。 + Summary string + // DetailMarkdown 是给 trace 详情展示使用的可读内容。 + DetailMarkdown string +} + +// AIToolPrincipal 表示本轮 AI tool 调用可使用的最小授权事实。 +// 它只承载事实,不把用户强行映射成固定 AI 身份分类。 +type AIToolPrincipal struct { + // UserID 是当前发起会话的用户 ID。 + UserID uint + // CurrentOrgID 是用户当前选中的组织上下文,可为空。 + CurrentOrgID *uint + // IsSuperAdmin 标记当前用户是否具备超级管理员全局授权事实。 + IsSuperAdmin bool +} + +// ToolCallContext 表示本轮工具调用共享的最小上下文。 +type ToolCallContext struct { + // ConversationID 是当前 AI 会话 ID。 + ConversationID string + // UserMessageID 是触发本轮回答的用户消息 ID。 + UserMessageID string + // AssistantMessageID 是当前 assistant 消息 ID。 + AssistantMessageID string + // Principal 是本轮调用共享的最小授权事实。 + Principal AIToolPrincipal +} + +// Tool 表示可供 runtime 暴露给模型的稳定工具协议。 +// 具体工具实现只负责声明 spec 和执行业务,不负责决定是否可见。 +type Tool interface { + Spec() ToolSpec + Call(ctx context.Context, call ToolCall, callCtx ToolCallContext) (ToolResult, error) +} diff --git a/internal/infrastructure/ai/eino/runtime.go b/internal/infrastructure/ai/eino/runtime.go index 1abf8d5..7d49280 100644 --- a/internal/infrastructure/ai/eino/runtime.go +++ b/internal/infrastructure/ai/eino/runtime.go @@ -3,8 +3,11 @@ package eino import ( "context" "errors" + "fmt" "io" "strings" + "sync" + "time" einomodel "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" @@ -13,8 +16,12 @@ import ( ) type Runtime struct { - model einomodel.BaseChatModel + // model 是底层 Eino ChatModel,实现真实的对话和 tool calling。 + model einomodel.BaseChatModel + // systemPrompt 是 runtime 固定注入的基础系统提示词。 systemPrompt string + // bindMu 保护不支持无副作用 WithTools 的模型在 BindTools 时的并发安全。 + bindMu sync.Mutex } // NewRuntime 创建 Eino 基础流式 runtime。 @@ -74,12 +81,15 @@ func (r *Runtime) Stream( input aidomain.StreamInput, sink aidomain.Sink, ) (aidomain.StreamResult, error) { + // 运行前先确保 runtime 和 sink 都已正确初始化。 if r == nil || r.model == nil { return aidomain.StreamResult{}, errors.New("eino runtime model is nil") } if sink == nil { return aidomain.StreamResult{}, errors.New("ai runtime sink is nil") } + + // 每轮流式响应开始前先发 conversation_started,供 projector 建立起始态。 if err := sink.Emit(ctx, aidomain.Event{ Name: aidomain.EventConversationStarted, Payload: aidomain.ConversationStartedPayload{Title: deriveTitle(input.Content)}, @@ -87,14 +97,32 @@ func (r *Runtime) Stream( return aidomain.StreamResult{}, err } + // 无工具时保持原有纯文本流式路径,避免无谓进入 tool loop。 + if len(input.Tools) == 0 { + return r.streamTextOnly(ctx, input, sink) + } + + // 有工具时切换到 tool calling 路径。 + return r.streamWithTools(ctx, input, sink) +} + +// streamTextOnly 负责执行不带工具调用的纯文本流式对话。 +func (r *Runtime) streamTextOnly( + ctx context.Context, + input aidomain.StreamInput, + sink aidomain.Sink, +) (aidomain.StreamResult, error) { + // 先把 domain 消息转换成 Eino 消息,再启动模型流。 reader, err := r.model.Stream(ctx, r.buildMessages(input)) if err != nil { return aidomain.StreamResult{}, err } defer reader.Close() + // output 用于在流结束后拼出完整 assistant 正文。 var output strings.Builder for { + // 持续读取模型增量输出,直到收到 EOF。 msg, recvErr := reader.Recv() if errors.Is(recvErr, io.EOF) { break @@ -105,6 +133,8 @@ func (r *Runtime) Stream( if msg == nil || msg.Content == "" { continue } + + // 把文本片段累计到最终正文,同时实时转发 assistant_token。 output.WriteString(msg.Content) if err := sink.Emit(ctx, aidomain.Event{ Name: aidomain.EventAssistantToken, @@ -114,6 +144,7 @@ func (r *Runtime) Stream( } } + // 纯文本流结束后补发最终正文,帮助 projector 收敛最终状态。 content := output.String() if err := sink.Emit(ctx, aidomain.Event{ Name: aidomain.EventMessageCompleted, @@ -121,12 +152,323 @@ func (r *Runtime) Stream( }); err != nil { return aidomain.StreamResult{}, err } + + // 最后发 done 终态,通知上层 SSE 可以正常收尾。 if err := sink.Emit(ctx, aidomain.Event{Name: aidomain.EventDone, Payload: map[string]any{}}); err != nil { return aidomain.StreamResult{}, err } return aidomain.StreamResult{Content: content, FinishReason: "stop"}, nil } +// streamWithTools 负责执行带 tool calling 的多轮 assistant/tool 循环。 +func (r *Runtime) streamWithTools( + ctx context.Context, + input aidomain.StreamInput, + sink aidomain.Sink, +) (aidomain.StreamResult, error) { + // 先把本轮可见工具绑定到模型实例上。 + modelWithTools, unlock, err := r.bindToolModel(input.Tools) + if err != nil { + return aidomain.StreamResult{}, err + } + defer unlock() + + // messages 保存 assistant 和 tool 的完整往返上下文。 + messages := r.buildMessages(input) + // toolMap 用于把模型返回的 tool name 映射回真实实现。 + toolMap := make(map[string]aidomain.Tool, len(input.Tools)) + for _, tool := range input.Tools { + if tool == nil { + continue + } + + // tool 名做 trim,避免模型或注册表中的空白差异导致找不到实现。 + spec := tool.Spec() + toolMap[strings.TrimSpace(spec.Name)] = tool + } + + // maxToolTurns 防止模型持续循环调用工具导致请求无限悬挂。 + const maxToolTurns = 8 + for turn := 0; turn < maxToolTurns; turn++ { + // 每一轮都让模型基于最新 messages 再生成一次 assistant 响应。 + reader, err := modelWithTools.Stream(ctx, messages) + if err != nil { + return aidomain.StreamResult{}, err + } + + // drainAssistantTurn 会拼出本轮 assistant 的完整内容和 tool call 列表。 + contentChunks, assistantMessage, err := drainAssistantTurn(reader) + if err != nil { + return aidomain.StreamResult{}, err + } + if assistantMessage == nil { + // 极端情况下没有任何 assistant 消息时,补一个空 assistant 占位。 + assistantMessage = schema.AssistantMessage("", nil) + } + + // 先把 assistant 响应写回上下文,后续 tool message 才能正确挂在这轮 assistant 之后。 + messages = append(messages, schema.AssistantMessage(assistantMessage.Content, assistantMessage.ToolCalls)) + if len(assistantMessage.ToolCalls) == 0 { + // 某些模型只在最终 concat 内容里给出正文,这里兜底补上内容片段。 + if len(contentChunks) == 0 && assistantMessage.Content != "" { + contentChunks = append(contentChunks, assistantMessage.Content) + } + for _, chunk := range contentChunks { + // 跳过纯空白 chunk,避免前端看到无意义 token 闪烁。 + if strings.TrimSpace(chunk) == "" { + continue + } + + // 把最终 assistant 正文按 chunk 形式实时发给 projector/SSE。 + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventAssistantToken, + Payload: aidomain.AssistantTokenPayload{Token: chunk}, + }); err != nil { + return aidomain.StreamResult{}, err + } + } + + // 本轮没有 tool call,说明模型已经进入最终回答阶段。 + content := assistantMessage.Content + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventMessageCompleted, + Payload: aidomain.MessageCompletedPayload{Content: content}, + }); err != nil { + return aidomain.StreamResult{}, err + } + if err := sink.Emit(ctx, aidomain.Event{Name: aidomain.EventDone, Payload: map[string]any{}}); err != nil { + return aidomain.StreamResult{}, err + } + + // finish reason 优先取模型原始元信息,没有时回退 stop。 + finishReason := "stop" + if assistantMessage.ResponseMeta != nil && strings.TrimSpace(assistantMessage.ResponseMeta.FinishReason) != "" { + finishReason = assistantMessage.ResponseMeta.FinishReason + } + return aidomain.StreamResult{Content: content, FinishReason: finishReason}, nil + } + + // assistant 返回 tool call 后,顺序执行工具并把 tool message 追加回上下文。 + toolMessages, err := r.executeToolCalls(ctx, toolMap, assistantMessage.ToolCalls, input.ToolCallContext, sink) + if err != nil { + return aidomain.StreamResult{}, err + } + messages = append(messages, toolMessages...) + } + + // 超过保护阈值仍未收敛时,直接终止本轮请求。 + return aidomain.StreamResult{}, errors.New("eino runtime exceeded max tool turns") +} + +// bindToolModel 负责把 domain tool spec 转成 Eino tool schema 并绑定到模型。 +func (r *Runtime) bindToolModel(tools []aidomain.Tool) (einomodel.BaseChatModel, func(), error) { + // 先构建所有工具的 schema 定义。 + toolInfos := make([]*schema.ToolInfo, 0, len(tools)) + for _, tool := range tools { + if tool == nil { + continue + } + + // 每个工具都转成 Eino 可识别的 ToolInfo。 + info, err := buildSchemaToolInfo(tool.Spec()) + if err != nil { + return nil, func() {}, err + } + toolInfos = append(toolInfos, info) + } + + // 支持 WithTools 的模型优先走无副作用绑定路径。 + if toolCallingModel, ok := r.model.(einomodel.ToolCallingChatModel); ok { + bound, err := toolCallingModel.WithTools(toolInfos) + return bound, func() {}, err + } + // 只支持 BindTools 的模型需要串行绑定,避免并发请求互相污染。 + if chatModel, ok := r.model.(einomodel.ChatModel); ok { + r.bindMu.Lock() + if err := chatModel.BindTools(toolInfos); err != nil { + r.bindMu.Unlock() + return nil, func() {}, err + } + return chatModel, func() { r.bindMu.Unlock() }, nil + } + // 两种能力都不支持时,说明当前模型无法执行 tool calling。 + return nil, func() {}, errors.New("eino runtime model does not support tool calling") +} + +// buildSchemaToolInfo 把 domain 层 ToolSpec 转成 Eino 的 ToolInfo。 +func buildSchemaToolInfo(spec aidomain.ToolSpec) (*schema.ToolInfo, error) { + // 先把参数列表转成按名称索引的 schema 参数定义。 + params := make(map[string]*schema.ParameterInfo, len(spec.Parameters)) + for _, param := range spec.Parameters { + info, err := buildSchemaParameterInfo(param) + if err != nil { + return nil, err + } + params[param.Name] = info + } + + // ToolInfo 只承载 name、描述和参数协议,不包含任何业务实现。 + return &schema.ToolInfo{ + Name: spec.Name, + Desc: spec.Description, + ParamsOneOf: schema.NewParamsOneOfByParams(params), + }, nil +} + +// buildSchemaParameterInfo 递归把 domain 层参数定义转换成 Eino 参数协议。 +func buildSchemaParameterInfo(param aidomain.ToolParameter) (*schema.ParameterInfo, error) { + // 先填充当前参数节点的基础元信息。 + info := &schema.ParameterInfo{ + Type: schema.DataType(param.Type), + Desc: param.Description, + Enum: param.Enum, + Required: param.Required, + } + if param.Items != nil { + // array 参数需要继续递归描述元素结构。 + itemInfo, err := buildSchemaParameterInfo(*param.Items) + if err != nil { + return nil, err + } + info.ElemInfo = itemInfo + } + if len(param.Properties) > 0 { + // object 参数需要递归构建所有子字段定义。 + subParams := make(map[string]*schema.ParameterInfo, len(param.Properties)) + for _, child := range param.Properties { + childInfo, err := buildSchemaParameterInfo(child) + if err != nil { + return nil, err + } + subParams[child.Name] = childInfo + } + info.SubParams = subParams + } + return info, nil +} + +// drainAssistantTurn 负责从一轮 assistant 输出流中拼出最终消息和增量文本块。 +func drainAssistantTurn( + reader *schema.StreamReader[*schema.Message], +) ([]string, *schema.Message, error) { + defer reader.Close() + + // chunks 保存原始消息块,contentChunks 额外保留文本增量供前端逐块输出。 + chunks := make([]*schema.Message, 0, 8) + contentChunks := make([]string, 0, 8) + for { + // 持续读取模型输出,直到本轮 assistant 结束。 + msg, recvErr := reader.Recv() + if errors.Is(recvErr, io.EOF) { + break + } + if recvErr != nil { + return nil, nil, recvErr + } + if msg == nil { + continue + } + + // 每个消息块既参与最终 concat,也单独保留文本片段。 + chunks = append(chunks, msg) + if msg.Content != "" { + contentChunks = append(contentChunks, msg.Content) + } + } + if len(chunks) == 0 { + // 完全没有 chunk 时返回空 assistant,避免调用方处理 nil。 + return contentChunks, schema.AssistantMessage("", nil), nil + } + + // Eino 提供的 ConcatMessages 会统一合并文本和 tool calls。 + assistantMessage, err := schema.ConcatMessages(chunks) + if err != nil { + return nil, nil, err + } + return contentChunks, assistantMessage, nil +} + +// executeToolCalls 负责顺序执行本轮 assistant 产出的所有工具调用。 +func (r *Runtime) executeToolCalls( + ctx context.Context, + toolMap map[string]aidomain.Tool, + toolCalls []schema.ToolCall, + callCtx aidomain.ToolCallContext, + sink aidomain.Sink, +) ([]*schema.Message, error) { + // 每个工具执行完成后都会生成一条 tool message 回填给模型。 + messages := make([]*schema.Message, 0, len(toolCalls)) + for idx, toolCall := range toolCalls { + // 先归一化调用 ID 和工具名,用于 trace 以及 tool message 关联。 + callID := deriveToolCallID(toolCall, idx) + toolName := strings.TrimSpace(toolCall.Function.Name) + toolImpl, ok := toolMap[toolName] + if !ok { + return nil, fmt.Errorf("ai tool not found: %s", toolName) + } + + // 工具开始前先发 started 事件,让 projector 建立 running 态 trace 项。 + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventToolCallStarted, + Payload: aidomain.ToolCallStartedPayload{ + Key: callID, + ToolName: toolName, + Title: "调用工具 " + toolName, + Description: "正在执行工具调用。", + }, + }); err != nil { + return nil, err + } + + // 记录耗时并执行真实工具实现。 + startedAt := time.Now() + result, err := toolImpl.Call(ctx, aidomain.ToolCall{ + ID: callID, + Name: toolName, + ArgumentsJSON: toolCall.Function.Arguments, + }, callCtx) + durationMS := time.Since(startedAt).Milliseconds() + if err != nil { + // 工具失败时也要补 finished 事件,让前端和 trace 看到失败状态。 + if emitErr := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventToolCallFinished, + Payload: aidomain.ToolCallFinishedPayload{ + Key: callID, + ToolName: toolName, + Description: "工具调用失败。", + DurationMS: durationMS, + Status: "failed", + Content: summarizeToolOutput(err.Error()), + DetailMarkdown: err.Error(), + }, + }); emitErr != nil { + return nil, emitErr + } + return nil, err + } + + // 工具成功后把摘要和详情折叠进 finished 事件。 + if err := sink.Emit(ctx, aidomain.Event{ + Name: aidomain.EventToolCallFinished, + Payload: aidomain.ToolCallFinishedPayload{ + Key: callID, + ToolName: toolName, + Description: "工具调用完成。", + DurationMS: durationMS, + Status: "success", + Content: summarizeToolOutput(result.Summary), + DetailMarkdown: result.DetailMarkdown, + }, + }); err != nil { + return nil, err + } + + // ToolMessage 会作为下一轮模型输入,让模型基于工具输出继续生成回答。 + messages = append(messages, schema.ToolMessage(result.Output, callID, schema.WithToolName(toolName))) + } + return messages, nil +} + // buildMessages 把 domain 层历史消息转换成 Eino schema 消息。 // 参数: // - input:包含历史消息与当前用户输入的 StreamInput。 @@ -135,27 +477,66 @@ func (r *Runtime) Stream( // - []*schema.Message:传给 Eino ChatModel 的消息序列。 // // 注意事项: -// - 这里只处理 user/assistant 文本消息,不注入 tool message。 +// - 当本轮存在动态工具约束时,会额外插入一条动态 system prompt。 func (r *Runtime) buildMessages(input aidomain.StreamInput) []*schema.Message { - messages := []*schema.Message{schema.SystemMessage(r.systemPrompt)} + // 预留 system prompt、动态 prompt 和当前用户输入的容量。 + messages := make([]*schema.Message, 0, len(input.History)+3) + if strings.TrimSpace(r.systemPrompt) != "" { + // 固定 system prompt 始终放在最前面,提供通用对话约束。 + messages = append(messages, schema.SystemMessage(r.systemPrompt)) + } + if strings.TrimSpace(input.DynamicSystemPrompt) != "" { + // 动态 prompt 用于注入本轮工具清单和调用约束。 + messages = append(messages, schema.SystemMessage(strings.TrimSpace(input.DynamicSystemPrompt))) + } for _, item := range input.History { + // 历史空消息不参与上下文,避免噪音输入。 content := strings.TrimSpace(item.Content) if content == "" { continue } switch strings.TrimSpace(item.Role) { case aidomain.RoleAssistant: + // assistant 历史按 assistant message 回放。 messages = append(messages, schema.AssistantMessage(content, nil)) default: + // 其余角色统一按 user message 处理。 messages = append(messages, schema.UserMessage(content)) } } if strings.TrimSpace(input.Content) != "" { + // 当前用户输入始终放在最后,触发本轮模型生成。 messages = append(messages, schema.UserMessage(strings.TrimSpace(input.Content))) } return messages } +// deriveToolCallID 负责为工具调用生成稳定 trace key。 +func deriveToolCallID(toolCall schema.ToolCall, index int) string { + // 模型已提供 ID 时直接复用,保证与上游 tool call 标识一致。 + if strings.TrimSpace(toolCall.ID) != "" { + return strings.TrimSpace(toolCall.ID) + } + // 否则按顺序生成兜底 ID,避免 trace 丢失主键。 + return fmt.Sprintf("tool_call_%d", index+1) +} + +// summarizeToolOutput 负责把工具输出压缩成适合 trace 摘要展示的短文本。 +func summarizeToolOutput(content string) string { + // 先去掉前后空白,避免摘要出现纯空格内容。 + content = strings.TrimSpace(content) + if content == "" { + return "" + } + + // 长内容截断到 120 个 rune,防止 trace 卡片过长。 + runes := []rune(content) + if len(runes) <= 120 { + return string(runes) + } + return string(runes[:120]) +} + // deriveTitle 根据用户输入生成会话开始事件标题。 func deriveTitle(content string) string { content = strings.TrimSpace(content) diff --git a/internal/infrastructure/ai/eino/runtime_tools_test.go b/internal/infrastructure/ai/eino/runtime_tools_test.go new file mode 100644 index 0000000..562a4b9 --- /dev/null +++ b/internal/infrastructure/ai/eino/runtime_tools_test.go @@ -0,0 +1,182 @@ +package eino + +import ( + "context" + "testing" + + einomodel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + + aidomain "personal_assistant/internal/domain/ai" +) + +type runtimeEventSinkStub struct { + events []aidomain.Event +} + +func (s *runtimeEventSinkStub) Emit(_ context.Context, event aidomain.Event) error { + s.events = append(s.events, event) + return nil +} + +func (s *runtimeEventSinkStub) Heartbeat(context.Context) error { + return nil +} + +type fakeToolCallingChatModel struct { + streams [][]*schema.Message + streamCalls int + tools []*schema.ToolInfo + inputs [][]*schema.Message +} + +var _ einomodel.ToolCallingChatModel = (*fakeToolCallingChatModel)(nil) + +func (m *fakeToolCallingChatModel) Generate( + context.Context, + []*schema.Message, + ...einomodel.Option, +) (*schema.Message, error) { + return schema.AssistantMessage("", nil), nil +} + +func (m *fakeToolCallingChatModel) Stream( + _ context.Context, + input []*schema.Message, + _ ...einomodel.Option, +) (*schema.StreamReader[*schema.Message], error) { + cloned := make([]*schema.Message, len(input)) + copy(cloned, input) + m.inputs = append(m.inputs, cloned) + + if m.streamCalls >= len(m.streams) { + return schema.StreamReaderFromArray([]*schema.Message{}), nil + } + out := m.streams[m.streamCalls] + m.streamCalls++ + return schema.StreamReaderFromArray(out), nil +} + +func (m *fakeToolCallingChatModel) WithTools(tools []*schema.ToolInfo) (einomodel.ToolCallingChatModel, error) { + m.tools = tools + return m, nil +} + +type fakeRuntimeTool struct { + spec aidomain.ToolSpec + result aidomain.ToolResult + calls []aidomain.ToolCall + callCtxLog []aidomain.ToolCallContext +} + +func (t *fakeRuntimeTool) Spec() aidomain.ToolSpec { + return t.spec +} + +func (t *fakeRuntimeTool) Call( + _ context.Context, + call aidomain.ToolCall, + callCtx aidomain.ToolCallContext, +) (aidomain.ToolResult, error) { + t.calls = append(t.calls, call) + t.callCtxLog = append(t.callCtxLog, callCtx) + return t.result, nil +} + +func TestRuntimeStreamWithToolsEmitsToolEventsAndFinalTokens(t *testing.T) { + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: schema.FunctionCall{ + Name: "get_my_oj_stats", + Arguments: `{"platform":"leetcode"}`, + }, + }, + }), + }, + { + schema.AssistantMessage("统计如下:LeetCode 已通过 123 题。", nil), + }, + }, + } + runtime := &Runtime{ + model: model, + systemPrompt: "base system prompt", + } + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "get_my_oj_stats", + Description: "获取当前用户 OJ 统计", + Parameters: []aidomain.ToolParameter{ + {Name: "platform", Type: aidomain.ToolParameterTypeString, Required: true}, + }, + }, + result: aidomain.ToolResult{ + Output: `{"platform":"leetcode","passed_number":123}`, + Summary: "已返回当前用户的 OJ 统计", + DetailMarkdown: "```json\n{\"platform\":\"leetcode\",\"passed_number\":123}\n```", + }, + } + sink := &runtimeEventSinkStub{} + + result, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "帮我看 leetcode 统计", + DynamicSystemPrompt: "本轮只允许使用可见工具。", + Tools: []aidomain.Tool{tool}, + ToolCallContext: aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: 7}, + }, + }, sink) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if result.Content != "统计如下:LeetCode 已通过 123 题。" { + t.Fatalf("result.Content = %q", result.Content) + } + if len(model.tools) != 1 || model.tools[0].Name != "get_my_oj_stats" { + t.Fatalf("bound tools = %+v", model.tools) + } + if len(model.inputs) == 0 || len(model.inputs[0]) < 2 { + t.Fatalf("model inputs = %+v", model.inputs) + } + if model.inputs[0][0].Content != "base system prompt" { + t.Fatalf("first system prompt = %q", model.inputs[0][0].Content) + } + if model.inputs[0][1].Content != "本轮只允许使用可见工具。" { + t.Fatalf("dynamic system prompt = %q", model.inputs[0][1].Content) + } + if len(tool.calls) != 1 { + t.Fatalf("tool calls = %d, want 1", len(tool.calls)) + } + if tool.calls[0].ArgumentsJSON != `{"platform":"leetcode"}` { + t.Fatalf("tool arguments = %q", tool.calls[0].ArgumentsJSON) + } + if len(tool.callCtxLog) != 1 || tool.callCtxLog[0].Principal.UserID != 7 { + t.Fatalf("tool call context = %+v", tool.callCtxLog) + } + + eventNames := make([]aidomain.EventName, 0, len(sink.events)) + for _, event := range sink.events { + eventNames = append(eventNames, event.Name) + } + expected := []aidomain.EventName{ + aidomain.EventConversationStarted, + aidomain.EventToolCallStarted, + aidomain.EventToolCallFinished, + aidomain.EventAssistantToken, + aidomain.EventMessageCompleted, + aidomain.EventDone, + } + if len(eventNames) != len(expected) { + t.Fatalf("event count = %d, want %d (%v)", len(eventNames), len(expected), eventNames) + } + for i, name := range expected { + if eventNames[i] != name { + t.Fatalf("event[%d] = %q, want %q", i, eventNames[i], name) + } + } +} diff --git a/internal/model/config/config.go b/internal/model/config/config.go index bb5a2e5..2c1082c 100644 --- a/internal/model/config/config.go +++ b/internal/model/config/config.go @@ -22,6 +22,7 @@ type Config struct { Messaging Messaging `json:"messaging" yaml:"messaging"` // 消息队列配置 SSE SSE `json:"sse" yaml:"sse"` // SSE 实时推送配置 AI AI `json:"ai" yaml:"ai"` // AI Runtime / Eino 配置 + Qdrant Qdrant `json:"qdrant" yaml:"qdrant"` // Qdrant 向量数据库配置 RateLimit RateLimit `json:"rate_limit" yaml:"rate_limit"` // 限流配置 Observability Observability `json:"observability" yaml:"observability"` // 观测基础设施配置 } @@ -323,6 +324,11 @@ func NewConfig() *Config { MaxCompletionTokens: viper.GetInt("ai.max_completion_tokens"), } + _qdrant := &Qdrant{ + Endpoint: viper.GetString("qdrant.endpoint"), + APIKey: viper.GetString("qdrant.api_key"), + } + _observability := &Observability{ Enabled: viper.GetBool("observability.enabled"), ServiceName: viper.GetString("observability.service_name"), @@ -396,6 +402,7 @@ func NewConfig() *Config { Messaging: *_messaging, SSE: *_sse, AI: *_ai, + Qdrant: *_qdrant, RateLimit: *_rateLimit, Observability: *_observability, } diff --git a/internal/model/config/qdrant.go b/internal/model/config/qdrant.go new file mode 100644 index 0000000..7f100ae --- /dev/null +++ b/internal/model/config/qdrant.go @@ -0,0 +1,7 @@ +package config + +// Qdrant describes vector database connection settings. +type Qdrant struct { + Endpoint string `json:"endpoint" yaml:"endpoint"` + APIKey string `json:"api_key" yaml:"api_key"` +} diff --git a/internal/model/dto/response/aiResp.go b/internal/model/dto/response/aiResp.go index 529095d..059c475 100644 --- a/internal/model/dto/response/aiResp.go +++ b/internal/model/dto/response/aiResp.go @@ -36,48 +36,14 @@ type AssistantTraceItem struct { Status string `json:"status"` // 轨迹状态,如 running / success / failed / waiting。 InterruptID string `json:"interrupt_id,omitempty"` // 中断确认场景下的中断 ID,用于后续确认或拒绝。 DurationMS int64 `json:"duration_ms,omitempty"` // 当前步骤耗时,单位毫秒。 - Content string `json:"content,omitempty"` // 当前步骤的简要结果内容,适合直接展示。 - DetailMarkdown string `json:"detail_markdown,omitempty"` // 当前步骤的详细说明,通常为 Markdown 格式。 + Content string `json:"content,omitempty"` // 当前步骤的简要结果内容,供前端折叠进普通消息显示。 + DetailMarkdown string `json:"detail_markdown,omitempty"` // 当前步骤的详细说明,通常为 Markdown 格式,仅在前端需要补充时使用。 RequiresConfirmation bool `json:"requires_confirmation,omitempty"` // 当前步骤是否需要用户确认后才能继续。 ConfirmationTitle string `json:"confirmation_title,omitempty"` // 确认弹窗或确认区域的标题。 ConfirmationDescription string `json:"confirmation_description,omitempty"` // 对确认事项的补充说明。 Actions []AssistantTraceAction `json:"actions,omitempty"` // 当前节点可供用户选择的动作列表。 } -// AssistantA2UIBinding 表示 A2UI 中的一个绑定变量。 -type AssistantA2UIBinding struct { - Key string `json:"key"` // 绑定键名,供组件通过 binding_key 引用。 - ValueString string `json:"value_string,omitempty"` // 绑定值的字符串形式。 -} - -// AssistantA2UIComponent 表示 A2UI 中的一个组件节点。 -type AssistantA2UIComponent struct { - ID string `json:"id"` // 组件唯一标识。 - Type string `json:"type"` // 组件类型,如 text / card / list / button。 - Value string `json:"value,omitempty"` // 组件直接承载的文本或值。 - BindingKey string `json:"binding_key,omitempty"` // 组件绑定的数据键,值从 Bindings 中取。 - UsageHint string `json:"usage_hint,omitempty"` // 组件用途提示,帮助前端或模型理解展示意图。 - Tone string `json:"tone,omitempty"` // 组件语气或视觉风格,如 info / success / warning。 - Children []string `json:"children,omitempty"` // 子组件 ID 列表,用于组织组件树。 - Label string `json:"label,omitempty"` // 组件标签文本,常用于按钮、表单项等。 - Items []string `json:"items,omitempty"` // 列表类组件承载的条目集合。 -} - -// AssistantA2UISurface 表示一块完整的 A2UI 渲染面。 -type AssistantA2UISurface struct { - ID string `json:"id"` // Surface 唯一标识。 - Root string `json:"root"` // 根组件 ID,前端从该节点开始构建整棵组件树。 - Components []AssistantA2UIComponent `json:"components"` // 当前 Surface 下的全部组件定义。 - Bindings []AssistantA2UIBinding `json:"bindings,omitempty"` // 当前 Surface 用到的数据绑定集合。 -} - -// AssistantA2UIBlock 表示消息中的一个结构化 UI 区块。 -type AssistantA2UIBlock struct { - Key string `json:"key"` // UI 区块唯一标识。 - Type string `json:"type"` // UI 区块类型,用于区分不同展示协议。 - Surface AssistantA2UISurface `json:"surface"` // 当前区块对应的可渲染 Surface 数据。 -} - // AssistantScopeInfo 表示当前消息所处的业务上下文范围。 type AssistantScopeInfo struct { UserName string `json:"user_name"` // 当前上下文中的用户名。 @@ -92,11 +58,10 @@ type AssistantMessageResp struct { ID string `json:"id"` // 消息唯一标识。 ConversationID string `json:"conversation_id"` // 所属会话 ID。 Role string `json:"role"` // 消息角色,如 user / assistant / system。 - Content string `json:"content"` // 消息正文内容。 + Content string `json:"content"` // 消息最终正文内容。 CreatedAt string `json:"created_at"` // 消息创建时间的格式化字符串。 - Status string `json:"status"` // 消息状态,如 pending / streaming / completed / failed。 - TraceItems []AssistantTraceItem `json:"trace_items"` // 当前消息关联的执行轨迹列表。 - UIBlocks []AssistantA2UIBlock `json:"ui_blocks"` // 当前消息附带的结构化 UI 区块。 + Status string `json:"status"` // 消息状态,如 loading / success / error / stopped。 + TraceItems []AssistantTraceItem `json:"trace_items"` // 当前消息关联的执行轨迹列表,前端会把这些轨迹折叠进同一条消息显示。 Scope *AssistantScopeInfo `json:"scope,omitempty"` // 当前消息关联的作用域信息,为空表示无额外上下文。 ErrorText string `json:"error_text,omitempty"` // 错误信息文本,通常在失败场景下返回。 } @@ -113,14 +78,16 @@ type AssistantTokenPayload struct { // AssistantToolCallStartedPayload 表示工具调用开始事件的载荷。 type AssistantToolCallStartedPayload struct { - Key string `json:"key"` // 工具调用步骤唯一标识。 - Title string `json:"title"` // 工具调用步骤标题。 - Description string `json:"description"` // 工具调用开始时的说明文本。 + Key string `json:"key"` // 工具调用步骤唯一标识。 + ToolName string `json:"tool_name,omitempty"` // 被调用的工具名,便于前端展示和排障。 + Title string `json:"title"` // 工具调用步骤标题。 + Description string `json:"description"` // 工具调用开始时的说明文本。 } // AssistantToolCallFinishedPayload 表示工具调用结束事件的载荷。 type AssistantToolCallFinishedPayload struct { Key string `json:"key"` // 工具调用步骤唯一标识。 + ToolName string `json:"tool_name,omitempty"` // 执行完成的工具名,便于前端展示和排障。 Description string `json:"description"` // 工具调用结束后的简述。 DurationMS int64 `json:"duration_ms"` // 工具调用耗时,单位毫秒。 Status string `json:"status"` // 工具调用结果状态,如 success / failed。 @@ -150,10 +117,9 @@ type AssistantToolCallConfirmationResultPayload struct { DetailMarkdown string `json:"detail_markdown,omitempty"` // 对本次确认结果的详细说明。 } -// AssistantStructuredBlockPayload 表示结构化 UI 或作用域信息事件的载荷。 +// AssistantStructuredBlockPayload 表示作用域信息事件的载荷。 type AssistantStructuredBlockPayload struct { - UIBlock *AssistantA2UIBlock `json:"ui_block,omitempty"` // 本次下发的结构化 UI 区块。 - Scope *AssistantScopeInfo `json:"scope,omitempty"` // 本次下发的作用域信息。 + Scope *AssistantScopeInfo `json:"scope,omitempty"` // 本次下发的作用域信息。 } // AssistantMessageCompletedPayload 表示消息生成完成事件的载荷。 diff --git a/internal/model/entity/ai.go b/internal/model/entity/ai.go index a1aaef1..2de8bd5 100644 --- a/internal/model/entity/ai.go +++ b/internal/model/entity/ai.go @@ -45,7 +45,6 @@ type AIMessage struct { Content string `json:"content" gorm:"type:longtext;not null;comment:'消息正文'"` Status string `json:"status" gorm:"type:varchar(32);not null;default:'success';comment:'消息状态'"` TraceItemsJSON string `json:"trace_items_json" gorm:"type:longtext;not null;comment:'trace_items JSON'"` - UIBlocksJSON string `json:"ui_blocks_json" gorm:"type:longtext;not null;comment:'ui_blocks JSON'"` ScopeJSON string `json:"scope_json" gorm:"type:longtext;not null;comment:'scope JSON'"` ErrorText string `json:"error_text" gorm:"type:varchar(500);not null;default:'';comment:'错误文案'"` CreatedAt time.Time `json:"created_at" gorm:"type:datetime;not null;index;comment:'创建时间'"` diff --git a/internal/service/system/aiMapper.go b/internal/service/system/aiMapper.go index 5026e7e..ebf3ed9 100644 --- a/internal/service/system/aiMapper.go +++ b/internal/service/system/aiMapper.go @@ -146,7 +146,6 @@ func messageToResp(message *entity.AIMessage) (*resp.AssistantMessageResp, error Status: message.Status, // 从 JSON 字符串解码成结构化对象 TraceItems: decodeAssistantTraceItems(message.TraceItemsJSON), - UIBlocks: decodeAssistantUIBlocks(message.UIBlocksJSON), Scope: decodeAssistantScope(message.ScopeJSON), ErrorText: message.ErrorText, }, nil @@ -184,19 +183,6 @@ func decodeAssistantTraceItems(raw string) []resp.AssistantTraceItem { return items } -// decodeAssistantUIBlocks 负责执行当前函数对应的核心逻辑。 -// 作用:把 UI block 的 JSON 字符串解码成数组。 -func decodeAssistantUIBlocks(raw string) []resp.AssistantA2UIBlock { - if strings.TrimSpace(raw) == "" { - return []resp.AssistantA2UIBlock{} - } - items := make([]resp.AssistantA2UIBlock, 0) - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return []resp.AssistantA2UIBlock{} - } - return items -} - // decodeAssistantScope 负责执行当前函数对应的核心逻辑。 // 作用:把 scope 的 JSON 字符串解码成作用域信息。 func decodeAssistantScope(raw string) *resp.AssistantScopeInfo { diff --git a/internal/service/system/aiMapper_test.go b/internal/service/system/aiMapper_test.go new file mode 100644 index 0000000..466cc90 --- /dev/null +++ b/internal/service/system/aiMapper_test.go @@ -0,0 +1,35 @@ +package system + +import ( + "testing" + "time" + + "personal_assistant/internal/model/entity" +) + +func TestMessageToRespPreservesStoredTraceItemOrder(t *testing.T) { + message := &entity.AIMessage{ + ID: "msg_ai_order", + ConversationID: "conv_order", + Role: "assistant", + Content: "最终正文", + Status: aiMessageStatusSuccess, + TraceItemsJSON: `[{"key":"tool_call_2","title":"第二步","description":"第二步描述","status":"success"},{"key":"tool_call_10","title":"第十步","description":"第十步描述","status":"failed"}]`, + ScopeJSON: "{}", + CreatedAt: time.Unix(1713859200, 0), + } + + item, err := messageToResp(message) + if err != nil { + t.Fatalf("messageToResp() error = %v", err) + } + if len(item.TraceItems) != 2 { + t.Fatalf("trace item len = %d, want 2", len(item.TraceItems)) + } + if item.TraceItems[0].Key != "tool_call_2" { + t.Fatalf("trace item[0] key = %q", item.TraceItems[0].Key) + } + if item.TraceItems[1].Key != "tool_call_10" { + t.Fatalf("trace item[1] key = %q", item.TraceItems[1].Key) + } +} diff --git a/internal/service/system/aiProjector.go b/internal/service/system/aiProjector.go index d1d5359..d696010 100644 --- a/internal/service/system/aiProjector.go +++ b/internal/service/system/aiProjector.go @@ -2,20 +2,26 @@ package system import ( "context" + "encoding/json" "sync" "time" aidomain "personal_assistant/internal/domain/ai" + resp "personal_assistant/internal/model/dto/response" "personal_assistant/internal/model/entity" "personal_assistant/internal/repository/interfaces" ) -// aiMessageProjector 负责把最小 runtime 事件折叠成 assistant 消息快照。 -// 它只处理基础流式文本状态,不再维护 A2UI、tool trace、interrupt 状态机。 +// aiMessageProjector 负责把 runtime 事件折叠成 assistant 消息快照。 +// 当前阶段维护文本输出和 tool trace,不恢复 interrupt / approval 状态机。 type aiMessageProjector struct { mu sync.Mutex repo interfaces.AIRepository message *entity.AIMessage + // traceItems 保存当前 assistant 消息已折叠出的 trace 列表快照。 + traceItems []resp.AssistantTraceItem + // traceIndex 维护 trace key 到切片下标的映射,便于 started/finished 事件合并。 + traceIndex map[string]int } // newAIMessageProjector 创建消息投影器。 @@ -26,7 +32,16 @@ type aiMessageProjector struct { // 返回值: // - *aiMessageProjector:绑定消息和仓储后的投影器。 func newAIMessageProjector(repo interfaces.AIRepository, message *entity.AIMessage) *aiMessageProjector { - return &aiMessageProjector{repo: repo, message: message} + return &aiMessageProjector{ + // repo 负责把折叠后的消息快照持久化到数据库。 + repo: repo, + // message 是当前要被实时更新的 assistant 消息实体。 + message: message, + // traceItems 从空切片起步,后续按 tool 事件逐步追加或更新。 + traceItems: []resp.AssistantTraceItem{}, + // traceIndex 用于快速定位同一个 tool call 对应的 trace 项。 + traceIndex: make(map[string]int), + } } // setStopped 把 assistant 消息标记为停止态。 @@ -55,16 +70,21 @@ func (p *aiMessageProjector) setError(message string) { // persistMessage 将当前内存中的 assistant 消息快照写回数据库。 // 注意事项: -// - `trace_items_json`、`ui_blocks_json`、`scope_json` 本阶段保留兼容字段,但固定写空值。 +// - `scope_json` 当前阶段仍固定写空对象;`trace_items_json` 则根据 tool 事件实时折叠。 func (p *aiMessageProjector) persistMessage(ctx context.Context) error { p.mu.Lock() defer p.mu.Unlock() + + // message 为空时说明当前 projector 还没有可持久化的目标消息。 if p.message == nil { return nil } - p.message.TraceItemsJSON = "[]" - p.message.UIBlocksJSON = "[]" + + // trace_items_json 折叠保存工具轨迹,供消息详情和 trace 展示复用。 + p.message.TraceItemsJSON = encodeAssistantTraceItems(p.traceItems) + // 当前阶段 scope 仍未恢复,因此固定写空对象占位。 p.message.ScopeJSON = "{}" + // 每次持久化都刷新消息更新时间,保证列表页状态一致。 p.message.UpdatedAt = time.Now() return p.repo.UpdateMessage(ctx, p.message) } @@ -75,14 +95,17 @@ func (p *aiMessageProjector) persistMessage(ctx context.Context) error { // // 核心流程: // 1. `assistant_token` 追加到消息正文。 -// 2. `message_completed` 覆盖最终正文并标记成功。 -// 3. `error` 写入错误文案并标记失败。 +// 2. `tool_call_started` / `tool_call_finished` 维护 trace_items。 +// 3. `message_completed` 覆盖最终正文并标记成功。 +// 4. `error` 写入错误文案并标记失败。 // // 注意事项: // - 本函数只更新内存态,真正落库由 persistMessage 统一完成。 func (p *aiMessageProjector) applyEvent(event aidomain.Event) { p.mu.Lock() defer p.mu.Unlock() + + // message 不存在时无需继续折叠事件。 if p.message == nil { return } @@ -90,7 +113,9 @@ func (p *aiMessageProjector) applyEvent(event aidomain.Event) { switch event.Name { case aidomain.EventAssistantToken: if payload, ok := event.Payload.(aidomain.AssistantTokenPayload); ok { + // assistant_token 只负责追加正文内容。 p.message.Content += payload.Token + // 已进入 stopped / error 终态后,不再把状态改回 loading。 if p.message.Status == aiMessageStatusStopped || p.message.Status == aiMessageStatusError { return } @@ -98,13 +123,106 @@ func (p *aiMessageProjector) applyEvent(event aidomain.Event) { } case aidomain.EventMessageCompleted: if payload, ok := event.Payload.(aidomain.MessageCompletedPayload); ok { + // message_completed 使用最终正文覆盖累积文本,避免 chunk 合并误差。 p.message.Content = payload.Content } + // 收到完成事件后统一切到 success。 p.message.Status = aiMessageStatusSuccess + case aidomain.EventToolCallStarted: + if payload, ok := event.Payload.(aidomain.ToolCallStartedPayload); ok { + // started 事件先生成 running 态 trace 项,占住稳定 key。 + item := resp.AssistantTraceItem{ + Key: payload.Key, + Title: payload.Title, + Description: payload.Description, + Status: "running", + } + p.upsertTraceItem(item) + } + case aidomain.EventToolCallFinished: + if payload, ok := event.Payload.(aidomain.ToolCallFinishedPayload); ok { + // finished 事件补充执行结果、耗时和详情,复用 started 时的同一个 key。 + item := resp.AssistantTraceItem{ + Key: payload.Key, + Description: payload.Description, + Status: payload.Status, + DurationMS: payload.DurationMS, + Content: payload.Content, + DetailMarkdown: payload.DetailMarkdown, + } + if payload.ToolName != "" { + // Title 为空时用统一标题回填,避免前端看到匿名 trace 卡片。 + item.Title = "调用工具 " + payload.ToolName + } + p.upsertTraceItem(item) + } case aidomain.EventError: if payload, ok := event.Payload.(aidomain.ErrorPayload); ok { + // error 事件把用户可见错误文案同步到消息实体。 p.message.ErrorText = payload.Message } + // 一旦 runtime 报错,消息状态立即切到 error。 p.message.Status = aiMessageStatusError } } + +// upsertTraceItem 负责按 key 合并同一次工具调用的 started/finished trace。 +func (p *aiMessageProjector) upsertTraceItem(item resp.AssistantTraceItem) { + if stringsIndex, ok := p.traceIndex[item.Key]; ok { + // 已存在同 key trace 时,只覆盖本次事件提供的增量字段。 + current := p.traceItems[stringsIndex] + if item.Title != "" { + current.Title = item.Title + } + if item.Description != "" { + current.Description = item.Description + } + if item.Status != "" { + current.Status = item.Status + } + if item.DurationMS > 0 { + current.DurationMS = item.DurationMS + } + if item.Content != "" { + current.Content = item.Content + } + if item.DetailMarkdown != "" { + current.DetailMarkdown = item.DetailMarkdown + } + p.traceItems[stringsIndex] = current + return + } + + // 首次出现的 key 直接写入索引并追加到 trace 列表末尾。 + p.traceIndex[item.Key] = len(p.traceItems) + p.traceItems = append(p.traceItems, item) +} + +// buildAITraceIndex 根据已有 trace_items 构建 key 到下标的索引。 +func buildAITraceIndex(items []resp.AssistantTraceItem) map[string]int { + // 预分配容量,避免后续重复扩容。 + index := make(map[string]int, len(items)) + for i, item := range items { + // 空 key 不能参与 started/finished 合并,直接跳过。 + if item.Key == "" { + continue + } + index[item.Key] = i + } + return index +} + +// encodeAssistantTraceItems 负责把 trace_items 安全编码成 JSON 字符串。 +func encodeAssistantTraceItems(items []resp.AssistantTraceItem) string { + // 空结果固定返回 [],保持数据库字段格式稳定。 + if len(items) == 0 { + return "[]" + } + + // JSON 编码失败时回退为空数组,避免消息持久化被 trace 展示字段拖垮。 + raw, err := json.Marshal(items) + if err != nil { + return "[]" + } + return string(raw) +} diff --git a/internal/service/system/aiProjector_test.go b/internal/service/system/aiProjector_test.go index 93f58a4..47cd425 100644 --- a/internal/service/system/aiProjector_test.go +++ b/internal/service/system/aiProjector_test.go @@ -67,7 +67,6 @@ func TestAIMessageProjectorPersistsBasicMessageAndClearsTraceJSON(t *testing.T) Role: "assistant", Status: aiMessageStatusLoading, TraceItemsJSON: `[{"key":"legacy","title":"legacy"}]`, - UIBlocksJSON: `[{"key":"legacy"}]`, ScopeJSON: `{"legacy":true}`, } projector := newAIMessageProjector(repo, message) @@ -93,9 +92,6 @@ func TestAIMessageProjectorPersistsBasicMessageAndClearsTraceJSON(t *testing.T) if repo.lastMessage.TraceItemsJSON != "[]" { t.Fatalf("trace json = %q", repo.lastMessage.TraceItemsJSON) } - if repo.lastMessage.UIBlocksJSON != "[]" { - t.Fatalf("ui blocks json = %q", repo.lastMessage.UIBlocksJSON) - } if repo.lastMessage.ScopeJSON != "{}" { t.Fatalf("scope json = %q", repo.lastMessage.ScopeJSON) } @@ -130,3 +126,59 @@ func TestAIMessageProjectorSetStoppedAndError(t *testing.T) { t.Fatalf("error text = %q", repo.lastMessage.ErrorText) } } + +func TestAIMessageProjectorPersistsToolTraceItems(t *testing.T) { + repo := &projectorRepoStub{} + message := &entity.AIMessage{ + ID: "msg_ai_trace", + ConversationID: "conv_trace", + Role: "assistant", + Status: aiMessageStatusLoading, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + } + projector := newAIMessageProjector(repo, message) + + projector.applyEvent(aidomain.Event{ + Name: aidomain.EventToolCallStarted, + Payload: aidomain.ToolCallStartedPayload{ + Key: "call_1", + ToolName: "get_my_oj_stats", + Title: "调用工具 get_my_oj_stats", + Description: "正在执行工具调用。", + }, + }) + projector.applyEvent(aidomain.Event{ + Name: aidomain.EventToolCallFinished, + Payload: aidomain.ToolCallFinishedPayload{ + Key: "call_1", + ToolName: "get_my_oj_stats", + Description: "工具调用完成。", + DurationMS: 23, + Status: "success", + Content: "已返回当前用户的 OJ 统计", + DetailMarkdown: "```json\n{}\n```", + }, + }) + + if err := projector.persistMessage(context.Background()); err != nil { + t.Fatalf("persistMessage() error = %v", err) + } + + items := decodeAssistantTraceItems(repo.lastMessage.TraceItemsJSON) + if len(items) != 1 { + t.Fatalf("trace item len = %d, want 1", len(items)) + } + if items[0].Key != "call_1" { + t.Fatalf("trace item key = %q", items[0].Key) + } + if items[0].Status != "success" { + t.Fatalf("trace item status = %q", items[0].Status) + } + if items[0].DurationMS != 23 { + t.Fatalf("trace item duration = %d", items[0].DurationMS) + } + if items[0].Content != "已返回当前用户的 OJ 统计" { + t.Fatalf("trace item content = %q", items[0].Content) + } +} diff --git a/internal/service/system/aiSink.go b/internal/service/system/aiSink.go index f4ff6d4..ccd2b89 100644 --- a/internal/service/system/aiSink.go +++ b/internal/service/system/aiSink.go @@ -96,6 +96,8 @@ func (s *aiStreamSink) persistMessage(ctx context.Context) error { func (s *aiStreamSink) shouldPersist(name aidomain.EventName) bool { switch name { case aidomain.EventMessageCompleted, + aidomain.EventToolCallStarted, + aidomain.EventToolCallFinished, aidomain.EventError, aidomain.EventDone: return true diff --git a/internal/service/system/aiSink_test.go b/internal/service/system/aiSink_test.go index e6615be..edcd081 100644 --- a/internal/service/system/aiSink_test.go +++ b/internal/service/system/aiSink_test.go @@ -35,7 +35,6 @@ func TestAIStreamSinkThrottlesPersistButForceFlushesFinalEvents(t *testing.T) { Role: "assistant", Status: aiMessageStatusLoading, TraceItemsJSON: "[]", - UIBlocksJSON: "[]", ScopeJSON: "{}", } sink := newAIStreamSink(repo, writer, message) @@ -84,7 +83,6 @@ func TestAIStreamSinkDoesNotPersistConversationStarted(t *testing.T) { Role: "assistant", Status: aiMessageStatusLoading, TraceItemsJSON: "[]", - UIBlocksJSON: "[]", ScopeJSON: "{}", } sink := newAIStreamSink(repo, writer, message) diff --git a/internal/service/system/aiSvc.go b/internal/service/system/aiSvc.go index c5e1f94..f756549 100644 --- a/internal/service/system/aiSvc.go +++ b/internal/service/system/aiSvc.go @@ -41,6 +41,10 @@ type AIService struct { userRepo interfaces.UserRepository runtime aidomain.Runtime policy streamsse.ConnectionPolicy + // authorizationSvc 用于补充超级管理员和组织能力的真实鉴权。 + authorizationSvc aiAuthorizationService + // toolRegistry 负责注册、过滤并执行本轮可见 AI tool。 + toolRegistry *aiToolRegistry } // NewAIService 负责组装 AIService 所需依赖。 @@ -58,20 +62,33 @@ type AIService struct { // 注意事项: // - 这里不直接依赖 HTTP 层,而是只保留运行时策略,方便同一业务逻辑被不同入口复用。 func NewAIService(repositoryGroup *repository.Group) *AIService { - return newAIServiceWithDeps(repositoryGroup, global.AIRuntime) + return newAIServiceWithDeps(repositoryGroup, global.AIRuntime, AIDeps{}) } // NewAIServiceWithRuntime 允许外部显式注入 runtime,方便阶段性迁移和测试替身接入。 func NewAIServiceWithRuntime(repositoryGroup *repository.Group, runtime aidomain.Runtime) *AIService { - return newAIServiceWithDeps(repositoryGroup, runtime) + return newAIServiceWithDeps(repositoryGroup, runtime, AIDeps{}) +} + +// NewAIServiceWithRuntimeAndDeps 允许同时注入 runtime 与 AI tool 所需依赖。 +func NewAIServiceWithRuntimeAndDeps( + repositoryGroup *repository.Group, + runtime aidomain.Runtime, + deps AIDeps, +) *AIService { + // 统一走同一套构造逻辑,避免普通模式和 tool 模式初始化分叉。 + return newAIServiceWithDeps(repositoryGroup, runtime, deps) } func newAIServiceWithDeps( repositoryGroup *repository.Group, runtime aidomain.Runtime, + deps AIDeps, ) *AIService { + // 默认使用零值策略,兼容未初始化全局 SSE 基础设施的场景。 policy := streamsse.ConnectionPolicy{} if global.StreamInfra != nil { + // 如果全局流式基础设施已初始化,则复用它的连接策略。 policy = global.StreamInfra.Policy } @@ -81,6 +98,10 @@ func newAIServiceWithDeps( userRepo: repositoryGroup.SystemRepositorySupplier.GetUserRepository(), runtime: runtime, policy: policy.Normalize(), + // 授权服务只保存到 AIService,供构造 principal 和执行前鉴权复用。 + authorizationSvc: deps.Authorization, + // tool registry 根据当前注入依赖决定哪些工具真正可用。 + toolRegistry: newAIToolRegistry(deps), } } @@ -246,7 +267,6 @@ func (s *AIService) StreamConversation( Content: strings.TrimSpace(req.Content), Status: aiMessageStatusSuccess, TraceItemsJSON: "[]", - UIBlocksJSON: "[]", ScopeJSON: "{}", CreatedAt: now, UpdatedAt: now, @@ -258,7 +278,6 @@ func (s *AIService) StreamConversation( Content: "", Status: aiMessageStatusLoading, TraceItemsJSON: "[]", - UIBlocksJSON: "[]", ScopeJSON: "{}", CreatedAt: now, UpdatedAt: now, @@ -271,13 +290,38 @@ func (s *AIService) StreamConversation( // Sink 负责把运行时事件同步到 SSE 与数据库消息状态,两条链路共用同一份状态机。 sink := newAIStreamSink(s.aiRepo, writer, assistantMessage) - _, execErr := s.runtime.Stream(ctx, aidomain.StreamInput{ - UserID: userID, + + // principal 只承载当前用户的授权事实,不做固定 AI 身份分类。 + toolPrincipal, err := s.buildAIToolPrincipal(ctx, user) + if err != nil { + return err + } + + // toolCallCtx 把本轮消息和授权事实传给后续所有工具调用。 + toolCallCtx := aidomain.ToolCallContext{ ConversationID: conversation.ID, UserMessageID: userMessage.ID, AssistantMessageID: assistantMessage.ID, - Content: strings.TrimSpace(req.Content), - History: messagesToRuntimeHistory(historyMessages), + Principal: toolPrincipal, + } + + // 先按 policy 过滤本轮可见工具,避免把无权限工具暴露给模型。 + visibleTools, err := s.filterVisibleAITools(ctx, toolCallCtx) + if err != nil { + return err + } + + // 把动态 prompt、可见工具和调用上下文一并注入 runtime。 + _, execErr := s.runtime.Stream(ctx, aidomain.StreamInput{ + UserID: userID, + ConversationID: conversation.ID, + UserMessageID: userMessage.ID, + AssistantMessageID: assistantMessage.ID, + Content: strings.TrimSpace(req.Content), + History: messagesToRuntimeHistory(historyMessages), + DynamicSystemPrompt: buildAIToolDynamicPrompt(visibleTools, toolPrincipal), + Tools: visibleTools, + ToolCallContext: toolCallCtx, }, sink) // 所有已开始的流式请求都统一走 finishStream 收尾,避免成功和失败路径各自写一套状态处理逻辑。 @@ -427,3 +471,48 @@ func streamWriterStarted(writer streamsse.StreamWriter) bool { } return true } + +func (s *AIService) buildAIToolPrincipal( + ctx context.Context, + user *entity.User, +) (aidomain.AIToolPrincipal, error) { + // 先构造零值 principal,便于统一在后续分支里逐步回填授权事实。 + principal := aidomain.AIToolPrincipal{} + if user == nil { + return principal, bizerrors.New(bizerrors.CodeUserNotFound) + } + + // 用户 ID 和当前组织是所有工具都可能依赖的最小上下文。 + principal.UserID = user.ID + principal.CurrentOrgID = user.CurrentOrgID + if s.authorizationSvc == nil { + // 未注入授权服务时,仅保留基础事实,不额外推断超级管理员状态。 + return principal, nil + } + + // 超级管理员状态单独查询,避免把 AI 身份和业务角色绑定在一起。 + isSuperAdmin, err := s.authorizationSvc.IsSuperAdmin(ctx, user.ID) + if err != nil { + return aidomain.AIToolPrincipal{}, bizerrors.Wrap(bizerrors.CodeDBError, err) + } + principal.IsSuperAdmin = isSuperAdmin + return principal, nil +} + +// filterVisibleAITools 负责按当前 principal 过滤出本轮真正可见的工具集合。 +func (s *AIService) filterVisibleAITools( + ctx context.Context, + callCtx aidomain.ToolCallContext, +) ([]aidomain.Tool, error) { + // 未配置 registry 时直接退化成无工具模式。 + if s.toolRegistry == nil { + return nil, nil + } + + // 可见性过滤失败时向上包装为内部错误,避免泄露底层策略实现细节。 + tools, err := s.toolRegistry.FilterVisibleTools(ctx, callCtx) + if err != nil { + return nil, bizerrors.Wrap(bizerrors.CodeInternalError, err) + } + return tools, nil +} diff --git a/internal/service/system/aiTool.go b/internal/service/system/aiTool.go new file mode 100644 index 0000000..f39890a --- /dev/null +++ b/internal/service/system/aiTool.go @@ -0,0 +1,1335 @@ +package system + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/consts" + "personal_assistant/internal/model/dto/request" + resp "personal_assistant/internal/model/dto/response" + bizerrors "personal_assistant/pkg/errors" +) + +// aiAuthorizationService 表示 AI tool 侧依赖的最小授权能力集合。 +// 它只暴露可见性过滤和执行前鉴权必需的方法,不泄露完整授权服务细节。 +type aiAuthorizationService interface { + IsSuperAdmin(ctx context.Context, userID uint) (bool, error) + CheckUserCapabilityInOrg(ctx context.Context, userID, orgID uint, capabilityCode string) (bool, error) + AuthorizeOrgCapability(ctx context.Context, operatorID, orgID uint, capabilityCode string) error +} + +// aiOJService 表示 AI tool 查询个人或组织 OJ 统计时依赖的最小 OJ 能力。 +type aiOJService interface { + GetRankingList(ctx context.Context, userID uint, req *request.OJRankingListReq) (*resp.OJRankingListResp, error) + GetUserStats(ctx context.Context, userID uint, req *request.OJStatsReq) (*resp.OJStatsResp, error) + GetCurve(ctx context.Context, userID uint, req *request.OJCurveReq) (*resp.OJCurveResp, error) +} + +// aiOJTaskService 表示 AI tool 查询 OJ 任务、执行和分析结果时依赖的最小任务能力。 +type aiOJTaskService interface { + AnalyzeTaskTitles(ctx context.Context, req *request.AnalyzeOJTaskTitlesReq) (*resp.OJTaskAnalyzeResp, error) + GetTaskDetail(ctx context.Context, userID, taskID uint) (*resp.OJTaskDetailResp, error) + GetTaskExecutionDetail(ctx context.Context, userID, taskID, executionID uint) (*resp.OJTaskExecutionResp, error) + GetTaskExecutionUsers( + ctx context.Context, + userID, taskID, executionID uint, + req *request.OJTaskExecutionUserListReq, + ) (*resp.OJTaskExecutionUserListResp, error) + GetTaskExecutionUserDetail( + ctx context.Context, + userID, taskID, executionID, targetUserID uint, + ) (*resp.OJTaskExecutionUserDetailResp, error) +} + +// aiObservabilityService 表示 AI tool 查询 trace 和指标时依赖的最小观测能力。 +type aiObservabilityService interface { + QueryMetrics(ctx context.Context, req *request.ObservabilityMetricsQueryReq) (*resp.ObservabilityMetricsQueryResp, error) + QueryRuntimeMetrics( + ctx context.Context, + req *request.ObservabilityRuntimeMetricQueryReq, + ) (*resp.ObservabilityRuntimeMetricQueryResp, error) + QueryTraceDetail( + ctx context.Context, + id string, + idType string, + limit int, + offset int, + includePayload bool, + includeErrorDetail bool, + ) (*resp.ObservabilityTraceQueryResp, error) + QueryTrace(ctx context.Context, req *request.ObservabilityTraceQueryReq) (*resp.ObservabilityTraceSummaryQueryResp, error) +} + +// AIDeps 表示 AIService 构建 tool loop 时需要的最小服务依赖。 +type AIDeps struct { + // Authorization 提供工具可见性过滤和执行前二次鉴权所需的授权能力。 + Authorization aiAuthorizationService + // OJ 提供个人排行、统计和曲线等 OJ 查询能力。 + OJ aiOJService + // OJTask 提供任务执行和题目分析等 OJ 任务能力。 + OJTask aiOJTaskService + // Observability 提供 trace 和指标查询能力。 + Observability aiObservabilityService +} + +// aiToolPolicyKind 表示工具可见性和执行前鉴权采用的策略类型。 +type aiToolPolicyKind string + +const ( + // aiToolPolicySelfOnly 表示工具只允许围绕当前登录用户自己的数据执行。 + aiToolPolicySelfOnly aiToolPolicyKind = "self_only" + // aiToolPolicyOrgCapability 表示工具要求当前用户对目标组织具备指定 capability。 + aiToolPolicyOrgCapability aiToolPolicyKind = "org_capability" + // aiToolPolicySuperAdminOnly 表示工具只允许超级管理员使用。 + aiToolPolicySuperAdminOnly aiToolPolicyKind = "super_admin_only" +) + +// aiToolPolicy 描述单个工具的访问策略。 +type aiToolPolicy struct { + // Kind 表示当前工具使用哪一类访问控制策略。 + Kind aiToolPolicyKind + // CapabilityCode 表示组织能力策略下要求的 capability code。 + CapabilityCode string +} + +// aiServiceTool 表示 service 层注册的一个具体 AI tool 实现。 +type aiServiceTool struct { + // spec 是暴露给 runtime 和模型的稳定工具协议。 + spec aidomain.ToolSpec + // policy 描述工具的可见性和执行前鉴权要求。 + policy aiToolPolicy + // call 承载工具的实际业务执行逻辑。 + call func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) +} + +// Spec 返回工具的稳定协议定义。 +func (t *aiServiceTool) Spec() aidomain.ToolSpec { + // spec 在注册阶段就已固定,不在运行时动态变更。 + return t.spec +} + +// Call 执行具体工具逻辑。 +func (t *aiServiceTool) Call( + ctx context.Context, + call aidomain.ToolCall, + callCtx aidomain.ToolCallContext, +) (aidomain.ToolResult, error) { + // 工具或执行闭包未初始化时,直接返回内部错误,避免 nil 调用 panic。 + if t == nil || t.call == nil { + return aidomain.ToolResult{}, bizerrors.NewWithMsg(bizerrors.CodeInternalError, "AI tool 未正确初始化") + } + + // 真正的业务逻辑交给具体工具闭包执行。 + return t.call(ctx, call, callCtx) +} + +// aiToolRegistry 负责注册工具、过滤可见性,并提供执行前定位能力。 +type aiToolRegistry struct { + // authorization 用于按 principal 判断组织能力类工具是否可见。 + authorization aiAuthorizationService + // tools 保存当前进程可注册的全部工具目录。 + tools []*aiServiceTool +} + +// newAIToolRegistry 创建 AI tool 注册表。 +func newAIToolRegistry(deps AIDeps) *aiToolRegistry { + // 先创建空注册表,并挂上可见性过滤会用到的授权服务。 + r := &aiToolRegistry{authorization: deps.Authorization} + // 再根据当前注入依赖构建完整工具目录。 + r.tools = r.buildCatalog(deps) + return r +} + +// buildCatalog 根据当前依赖拼出本进程真正可提供的工具目录。 +func (r *aiToolRegistry) buildCatalog(deps AIDeps) []*aiServiceTool { + // 先按预估容量创建切片,减少追加时的扩容次数。 + tools := make([]*aiServiceTool, 0, 11) + if deps.OJ != nil { + // 个人 OJ 工具只依赖 OJService,本身不需要额外授权服务。 + tools = append(tools, + newAIGetMyRankingTool(deps.OJ), + newAIGetMyOJStatsTool(deps.OJ), + newAIGetMyOJCurveTool(deps.OJ), + ) + } + if deps.OJ != nil && deps.Authorization != nil { + // 组织排行榜工具既需要 OJ 数据,也需要组织能力鉴权。 + tools = append(tools, newAIGetOrgRankingSummaryTool(deps.OJ, deps.Authorization)) + } + if deps.OJTask != nil && deps.Authorization != nil { + // 任务执行类工具统一依赖 OJTaskService 和授权服务。 + tools = append(tools, + newAIGetTaskExecutionSummaryTool(deps.OJTask, deps.Authorization), + newAIListTaskExecutionUsersTool(deps.OJTask, deps.Authorization), + newAIGetTaskExecutionUserDetailTool(deps.OJTask, deps.Authorization), + newAIAnalyzeTaskTitlesTool(deps.OJTask, deps.Authorization), + ) + } + if deps.Observability != nil && deps.Authorization != nil { + // 观测类工具要求 super admin,因此同时依赖观测服务和授权服务。 + tools = append(tools, + newAIQueryTraceDetailByRequestIDTool(deps.Observability, deps.Authorization), + newAIQueryTraceSummaryTool(deps.Observability, deps.Authorization), + newAIQueryRuntimeMetricsTool(deps.Observability, deps.Authorization), + newAIQueryObservabilityMetricsTool(deps.Observability, deps.Authorization), + ) + } + return tools +} + +// FilterVisibleTools 按本轮 principal 过滤出模型真正可见的工具集合。 +func (r *aiToolRegistry) FilterVisibleTools( + ctx context.Context, + callCtx aidomain.ToolCallContext, +) ([]aidomain.Tool, error) { + // 没有可注册工具时直接退化成无工具模式。 + if r == nil || len(r.tools) == 0 { + return nil, nil + } + + // visible 收集通过 policy 过滤的工具,供 runtime 暴露给模型。 + visible := make([]aidomain.Tool, 0, len(r.tools)) + for _, tool := range r.tools { + // 每个工具都按其 policy 和当前 principal 做一次可见性判断。 + ok, err := r.isVisible(ctx, tool.policy, callCtx.Principal) + if err != nil { + return nil, err + } + if ok { + visible = append(visible, tool) + } + } + return visible, nil +} + +// isVisible 判断某个 policy 在当前 principal 下是否应该暴露给模型。 +func (r *aiToolRegistry) isVisible( + ctx context.Context, + policy aiToolPolicy, + principal aidomain.AIToolPrincipal, +) (bool, error) { + switch policy.Kind { + case aiToolPolicySelfOnly: + // SelfOnly 只要求当前有合法用户上下文即可。 + return principal.UserID != 0, nil + case aiToolPolicySuperAdminOnly: + // 观测类工具只有超级管理员可见。 + return principal.IsSuperAdmin, nil + case aiToolPolicyOrgCapability: + // 超级管理员对组织能力类工具直接视为可见。 + if principal.IsSuperAdmin { + return true, nil + } + // 缺少授权服务、用户或组织上下文时,该工具对本轮不可见。 + if r == nil || r.authorization == nil || principal.UserID == 0 || principal.CurrentOrgID == nil || *principal.CurrentOrgID == 0 { + return false, nil + } + // 组织能力类工具按当前组织上下文做一次轻量可见性探测。 + return r.authorization.CheckUserCapabilityInOrg( + ctx, + principal.UserID, + *principal.CurrentOrgID, + policy.CapabilityCode, + ) + default: + // 未识别的策略类型一律按不可见处理。 + return false, nil + } +} + +// findTool 按名称从注册表里查找具体工具实现。 +func (r *aiToolRegistry) findTool(name string) *aiServiceTool { + // 统一 trim 输入,避免调用方传入带空白的工具名。 + normalizedName := strings.TrimSpace(name) + for _, tool := range r.tools { + // 只返回名称完全匹配的工具实现。 + if tool != nil && tool.spec.Name == normalizedName { + return tool + } + } + return nil +} + +// buildAIToolDynamicPrompt 生成“本轮工具使用说明”提示词。 +// +// 该提示词会喂给模型,用于约束模型的工具使用行为,包括: +// 1. 只能使用本轮明确列出的工具; +// 2. 缺少精确标识时不要猜; +// 3. 工具可见 ≠ 最终一定执行成功,执行期仍会鉴权; +// 4. 当前组织上下文是什么; +// 5. 每个工具的参数定义是什么。 +// +// 设计价值: +// - 减少模型臆造工具; +// - 减少模型胡乱猜 org_id / task_id / request_id; +// - 将“后端权限事实”同步给模型,提高调用成功率。 +func buildAIToolDynamicPrompt( + tools []aidomain.Tool, + principal aidomain.AIToolPrincipal, +) string { + // builder 按顺序拼出固定约束、组织上下文和可见工具清单。 + var builder strings.Builder + builder.WriteString("你是 personal_assistant 的 AI 助手。\n") + builder.WriteString("本轮只能使用下面明确列出的工具;不要假设还有其他工具。\n") + builder.WriteString("如果用户请求需要 org_id、task_id、execution_id、request_id 等精确标识,而上下文里没有,不要猜测,直接向用户索取。\n") + builder.WriteString("工具可见性已经按当前授权事实过滤,但真正执行时仍会再次鉴权;如果工具报权限错误,直接向用户说明。\n") + if principal.CurrentOrgID != nil && *principal.CurrentOrgID > 0 { + // 当前组织上下文单独写进 prompt,帮助模型优先复用默认 org_id。 + builder.WriteString(fmt.Sprintf("当前组织上下文 org_id=%d。\n", *principal.CurrentOrgID)) + } + if len(tools) == 0 { + // 无工具时明确告知模型只能基于上下文回答,避免虚构工具调用。 + builder.WriteString("本轮没有可用工具,请直接基于已有上下文回答,无法确认的数据不要编造。") + return builder.String() + } + + // 有工具时逐个列出名称、描述和参数协议。 + builder.WriteString("本轮可用工具清单:\n") + for idx, tool := range tools { + spec := tool.Spec() + builder.WriteString(fmt.Sprintf("%d. %s: %s\n", idx+1, spec.Name, spec.Description)) + if len(spec.Parameters) == 0 { + builder.WriteString(" 参数:无\n") + continue + } + builder.WriteString(" 参数:\n") + for _, param := range spec.Parameters { + builder.WriteString(" - ") + builder.WriteString(formatAIToolParameterPrompt(param)) + builder.WriteString("\n") + } + } + return strings.TrimSpace(builder.String()) +} + +// formatAIToolParameterPrompt 把单个参数协议转成可读的 prompt 文本。 +func formatAIToolParameterPrompt(param aidomain.ToolParameter) string { + // meta 先拼出类型、必填性和枚举约束。 + meta := string(param.Type) + if param.Required { + meta += ", required" + } else { + meta += ", optional" + } + if len(param.Enum) > 0 { + meta += ", enum=" + strings.Join(param.Enum, "|") + } + if param.Type == aidomain.ToolParameterTypeArray && param.Items != nil { + // array 参数额外补出元素类型说明。 + meta += ", items=" + describeAIToolParameterType(*param.Items) + } + + // line 是当前参数的主描述行。 + line := fmt.Sprintf("%s (%s)", param.Name, meta) + if strings.TrimSpace(param.Description) != "" { + line += ": " + strings.TrimSpace(param.Description) + } + if len(param.Properties) == 0 { + return line + } + + // object 参数递归列出所有子字段,方便模型一次性看懂结构。 + children := make([]string, 0, len(param.Properties)) + for _, child := range param.Properties { + children = append(children, formatAIToolParameterPrompt(child)) + } + return line + "; fields={" + strings.Join(children, "; ") + "}" +} + +// describeAIToolParameterType 返回参数类型的简短可读描述。 +func describeAIToolParameterType(param aidomain.ToolParameter) string { + switch param.Type { + case aidomain.ToolParameterTypeObject: + // object 直接返回 object 标记。 + return "object" + case aidomain.ToolParameterTypeArray: + // array 继续递归描述元素类型。 + if param.Items == nil { + return "array" + } + return "array<" + describeAIToolParameterType(*param.Items) + ">" + default: + // 基础类型直接返回底层 type 值。 + return string(param.Type) + } +} + +// newAISelfOnlyPolicy 创建 SelfOnly 访问策略。 +func newAISelfOnlyPolicy() aiToolPolicy { + // SelfOnly 只依赖当前用户事实,不要求组织能力或超级管理员。 + return aiToolPolicy{Kind: aiToolPolicySelfOnly} +} + +// newAIOrgCapabilityPolicy 创建组织能力访问策略。 +func newAIOrgCapabilityPolicy(capabilityCode string) aiToolPolicy { + // capability code 由具体工具声明,执行时再结合参数做真实鉴权。 + return aiToolPolicy{ + Kind: aiToolPolicyOrgCapability, + CapabilityCode: capabilityCode, + } +} + +// newAISuperAdminOnlyPolicy 创建超级管理员访问策略。 +func newAISuperAdminOnlyPolicy() aiToolPolicy { + // 观测类工具统一走 super admin 策略。 + return aiToolPolicy{Kind: aiToolPolicySuperAdminOnly} +} + +// decodeAIToolArgs 负责把模型传入的 JSON 参数解析到目标结构。 +func decodeAIToolArgs(call aidomain.ToolCall, out any) error { + // 空参数默认按空对象处理,兼容无参工具或模型漏传空对象的情况。 + if strings.TrimSpace(call.ArgumentsJSON) == "" { + call.ArgumentsJSON = "{}" + } + // JSON 解析失败时统一包装成参数错误,方便前端和模型理解。 + if err := json.Unmarshal([]byte(call.ArgumentsJSON), out); err != nil { + return bizerrors.WrapWithMsg(bizerrors.CodeInvalidParams, "AI tool 参数解析失败", err) + } + return nil +} + +// buildAIToolResult 负责把工具返回值编码成模型输出和 trace 展示内容。 +func buildAIToolResult(payload any, summary string) (aidomain.ToolResult, error) { + // raw 用于回传给模型,保持紧凑 JSON 结构。 + raw, err := json.Marshal(payload) + if err != nil { + return aidomain.ToolResult{}, bizerrors.Wrap(bizerrors.CodeInternalError, err) + } + // pretty 用于 trace 明细展示,提升可读性。 + pretty, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return aidomain.ToolResult{}, bizerrors.Wrap(bizerrors.CodeInternalError, err) + } + + // 未显式提供 summary 时,从原始 JSON 截断一份短摘要。 + summary = strings.TrimSpace(summary) + if summary == "" { + summary = truncateRunes(string(raw), 120) + } + return aidomain.ToolResult{ + Output: string(raw), + Summary: summary, + DetailMarkdown: "```json\n" + string(pretty) + "\n```", + }, nil +} + +// requireAISuperAdmin 在工具执行阶段强制校验超级管理员权限。 +func requireAISuperAdmin( + ctx context.Context, + authorization aiAuthorizationService, + principal aidomain.AIToolPrincipal, +) error { + // 缺少授权服务时视为无法授权。 + if authorization == nil { + return bizerrors.New(bizerrors.CodePermissionDenied) + } + // 再次实时查询超级管理员状态,避免只依赖 prompt 阶段的可见性判断。 + ok, err := authorization.IsSuperAdmin(ctx, principal.UserID) + if err != nil { + return bizerrors.Wrap(bizerrors.CodeInternalError, err) + } + if !ok { + return bizerrors.New(bizerrors.CodePermissionDenied) + } + return nil +} + +// requireAIOrgCapability 在工具执行阶段强制校验指定组织能力。 +func requireAIOrgCapability( + ctx context.Context, + authorization aiAuthorizationService, + principal aidomain.AIToolPrincipal, + orgID uint, + capabilityCode string, +) error { + // 缺少授权服务时无法完成组织能力校验。 + if authorization == nil { + return bizerrors.New(bizerrors.CodePermissionDenied) + } + // 组织能力校验必须落到明确的 org_id 上。 + if orgID == 0 { + return bizerrors.NewWithMsg(bizerrors.CodeInvalidParams, "org_id 不能为空") + } + // 真实授权交给正式 AuthorizationService 收口。 + return authorization.AuthorizeOrgCapability(ctx, principal.UserID, orgID, capabilityCode) +} + +// requireAITaskOrgCapability 依据任务关联组织做执行阶段能力收口。 +func requireAITaskOrgCapability( + ctx context.Context, + authorization aiAuthorizationService, + principal aidomain.AIToolPrincipal, + taskDetail *resp.OJTaskDetailResp, + capabilityCode string, +) error { + // 任务详情不存在时直接按任务不存在处理。 + if taskDetail == nil { + return bizerrors.New(bizerrors.CodeOJTaskNotFound) + } + + // 从任务详情里提取所有关联组织,供后续统一做能力校验。 + orgIDs := make([]uint, 0, len(taskDetail.Orgs)) + for _, item := range taskDetail.Orgs { + if item == nil || item.OrgID == 0 { + continue + } + orgIDs = append(orgIDs, item.OrgID) + } + if len(orgIDs) == 0 { + return bizerrors.New(bizerrors.CodePermissionDenied) + } + return requireAIOrgCapabilityForMany(ctx, authorization, principal, orgIDs, capabilityCode) +} + +// requireAIOrgCapabilityForMany 顺序校验多个组织上的同一项 capability。 +func requireAIOrgCapabilityForMany( + ctx context.Context, + authorization aiAuthorizationService, + principal aidomain.AIToolPrincipal, + orgIDs []uint, + capabilityCode string, +) error { + // seen 用于去重,避免同一组织重复触发授权调用。 + seen := make(map[uint]struct{}, len(orgIDs)) + for _, orgID := range orgIDs { + if orgID == 0 { + continue + } + if _, ok := seen[orgID]; ok { + continue + } + + // 每个唯一组织都做一次真实 capability 校验。 + seen[orgID] = struct{}{} + if err := requireAIOrgCapability(ctx, authorization, principal, orgID, capabilityCode); err != nil { + return err + } + } + if len(seen) == 0 { + return bizerrors.New(bizerrors.CodePermissionDenied) + } + return nil +} + +// aiMyRankingArgs 表示个人排行工具的输入参数。 +type aiMyRankingArgs struct { + // Platform 表示目标 OJ 平台。 + Platform string `json:"platform"` + // Scope 表示排行范围,省略时默认 current_org。 + Scope string `json:"scope,omitempty"` +} + +// newAIGetMyRankingTool 创建个人排行摘要工具。 +func newAIGetMyRankingTool(ojSvc aiOJService) *aiServiceTool { + // 个人排行工具只面向当前登录用户自己的数据。 + return &aiServiceTool{ + // spec 描述模型可见的工具协议。 + spec: aidomain.ToolSpec{ + Name: "get_my_ranking", + Description: "获取当前登录用户在指定 OJ 排行榜中的个人排名摘要,不返回其他用户完整榜单。", + Parameters: []aidomain.ToolParameter{ + {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"leetcode", "luogu", "lanqiao"}}, + {Name: "scope", Type: aidomain.ToolParameterTypeString, Description: "排行范围,默认 current_org", Enum: []string{"current_org", "all_members"}}, + }, + }, + // policy 声明该工具只围绕当前用户自己的数据执行。 + policy: newAISelfOnlyPolicy(), + // call 负责查询当前用户在指定平台下的个人排行摘要。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析模型传入的结构化参数。 + var args aiMyRankingArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 调 OJService 获取当前用户自己的排行信息。 + rankResp, err := ojSvc.GetRankingList(ctx, callCtx.Principal.UserID, &request.OJRankingListReq{ + Page: 1, + PageSize: 1, + Platform: strings.TrimSpace(args.Platform), + Scope: strings.TrimSpace(args.Scope), + }) + if err != nil { + return aidomain.ToolResult{}, err + } + + // 只返回个人摘要,不把完整榜单数据直接透给模型。 + payload := map[string]any{ + "platform": args.Platform, + "scope": defaultString(args.Scope, "current_org"), + "my_rank": nil, + "total": int64(0), + } + if rankResp != nil { + payload["my_rank"] = rankResp.MyRank + payload["total"] = rankResp.Total + } + return buildAIToolResult(payload, "已返回当前用户的排行摘要") + }, + } +} + +// aiMyPlatformArgs 表示个人平台统计类工具的输入参数。 +type aiMyPlatformArgs struct { + // Platform 表示目标 OJ 平台。 + Platform string `json:"platform"` +} + +// newAIGetMyOJStatsTool 创建个人 OJ 统计工具。 +func newAIGetMyOJStatsTool(ojSvc aiOJService) *aiServiceTool { + // 个人统计工具只查询当前用户在单个平台上的统计。 + return &aiServiceTool{ + // spec 描述模型可见的工具名、描述和参数协议。 + spec: aidomain.ToolSpec{ + Name: "get_my_oj_stats", + Description: "获取当前登录用户在指定 OJ 平台上的个人统计。", + Parameters: []aidomain.ToolParameter{ + {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"leetcode", "luogu", "lanqiao"}}, + }, + }, + // policy 仍然是 SelfOnly。 + policy: newAISelfOnlyPolicy(), + // call 负责查询并返回当前用户的平台统计。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析参数,确保 platform 可用。 + var args aiMyPlatformArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 调 OJService 拉取当前用户的平台统计。 + stats, err := ojSvc.GetUserStats(ctx, callCtx.Principal.UserID, &request.OJStatsReq{ + Platform: strings.TrimSpace(args.Platform), + }) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(stats, "已返回当前用户的 OJ 统计") + }, + } +} + +// newAIGetMyOJCurveTool 创建个人做题曲线工具。 +func newAIGetMyOJCurveTool(ojSvc aiOJService) *aiServiceTool { + // 个人曲线工具只查询当前用户自己的做题趋势。 + return &aiServiceTool{ + // spec 定义模型如何调用该工具。 + spec: aidomain.ToolSpec{ + Name: "get_my_oj_curve", + Description: "获取当前登录用户在指定 OJ 平台上的最近做题曲线。", + Parameters: []aidomain.ToolParameter{ + {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"leetcode", "luogu", "lanqiao"}}, + }, + }, + // policy 声明这是 SelfOnly 工具。 + policy: newAISelfOnlyPolicy(), + // call 负责查询并返回个人曲线。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析平台参数。 + var args aiMyPlatformArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 调 OJService 获取当前用户的平台曲线数据。 + curve, err := ojSvc.GetCurve(ctx, callCtx.Principal.UserID, &request.OJCurveReq{ + Platform: strings.TrimSpace(args.Platform), + }) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(curve, "已返回当前用户的 OJ 曲线") + }, + } +} + +// aiOrgRankingArgs 表示组织排行摘要工具的输入参数。 +type aiOrgRankingArgs struct { + // OrgID 表示目标组织 ID,省略时默认当前组织。 + OrgID *uint `json:"org_id,omitempty"` + // Platform 表示目标 OJ 平台。 + Platform string `json:"platform"` + // Page 表示页码,省略时交给下游服务兜底。 + Page int `json:"page,omitempty"` + // PageSize 表示分页大小,省略时交给下游服务兜底。 + PageSize int `json:"page_size,omitempty"` +} + +// newAIGetOrgRankingSummaryTool 创建组织排行摘要工具。 +func newAIGetOrgRankingSummaryTool( + ojSvc aiOJService, + authorization aiAuthorizationService, +) *aiServiceTool { + // 组织排行工具要求目标组织具备 OJ 任务管理能力。 + return &aiServiceTool{ + // spec 告诉模型可以按 org_id 和 platform 查询组织排行摘要。 + spec: aidomain.ToolSpec{ + Name: "get_org_ranking_summary", + Description: "获取指定组织在指定 OJ 平台排行榜中的摘要,需要 OJ 任务管理能力。", + Parameters: []aidomain.ToolParameter{ + {Name: "org_id", Type: aidomain.ToolParameterTypeInteger, Description: "目标组织 ID;省略时默认当前组织"}, + {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"leetcode", "luogu", "lanqiao"}}, + {Name: "page", Type: aidomain.ToolParameterTypeInteger, Description: "页码,默认 1"}, + {Name: "page_size", Type: aidomain.ToolParameterTypeInteger, Description: "分页大小,默认 20"}, + }, + }, + // policy 声明该工具受组织 capability 控制。 + policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), + // call 负责解析目标组织、做真实鉴权并查询组织排行摘要。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析模型传入的组织和分页参数。 + var args aiOrgRankingArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // org_id 允许省略,但最终必须解析成一个确定组织。 + orgID, err := resolveAIOrgID(args.OrgID, callCtx.Principal.CurrentOrgID) + if err != nil { + return aidomain.ToolResult{}, err + } + // 执行前再次按目标组织做正式 capability 鉴权。 + if err := requireAIOrgCapability( + ctx, + authorization, + callCtx.Principal, + orgID, + consts.CapabilityCodeOJTaskManage, + ); err != nil { + return aidomain.ToolResult{}, err + } + + // 通过 OJService 查询指定组织的排行摘要。 + out, err := ojSvc.GetRankingList(ctx, callCtx.Principal.UserID, &request.OJRankingListReq{ + Page: args.Page, + PageSize: args.PageSize, + Platform: strings.TrimSpace(args.Platform), + Scope: "org", + OrgID: &orgID, + }) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回组织排行榜摘要") + }, + } +} + +// aiTaskExecutionArgs 表示任务执行摘要工具的输入参数。 +type aiTaskExecutionArgs struct { + // TaskID 表示目标任务 ID。 + TaskID uint `json:"task_id"` + // ExecutionID 表示目标执行 ID。 + ExecutionID uint `json:"execution_id"` +} + +// newAIGetTaskExecutionSummaryTool 创建任务执行摘要工具。 +func newAIGetTaskExecutionSummaryTool( + taskSvc aiOJTaskService, + authorization aiAuthorizationService, +) *aiServiceTool { + // 任务执行摘要工具既要复用任务可见性,也要对关联组织做 capability 收口。 + return &aiServiceTool{ + // spec 描述模型需要提供 task_id 和 execution_id。 + spec: aidomain.ToolSpec{ + Name: "get_task_execution_summary", + Description: "获取指定任务执行的摘要,需要先具备任务可见性,再通过关联组织能力校验。", + Parameters: []aidomain.ToolParameter{ + {Name: "task_id", Type: aidomain.ToolParameterTypeInteger, Description: "任务 ID", Required: true}, + {Name: "execution_id", Type: aidomain.ToolParameterTypeInteger, Description: "执行 ID", Required: true}, + }, + }, + // policy 用于控制该工具是否在当前组织上下文里可见。 + policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), + // call 负责先校验任务可见性,再按任务关联组织做能力收口。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析任务和执行标识。 + var args aiTaskExecutionArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 先复用现有任务详情查询,让 OJTaskService 承担原有可见性校验。 + detail, err := taskSvc.GetTaskDetail(ctx, callCtx.Principal.UserID, args.TaskID) + if err != nil { + return aidomain.ToolResult{}, err + } + // 再按任务关联组织做 OJ 任务管理能力收口。 + if err := requireAITaskOrgCapability( + ctx, + authorization, + callCtx.Principal, + detail, + consts.CapabilityCodeOJTaskManage, + ); err != nil { + return aidomain.ToolResult{}, err + } + + // 能力校验通过后,再查询具体执行摘要。 + out, err := taskSvc.GetTaskExecutionDetail(ctx, callCtx.Principal.UserID, args.TaskID, args.ExecutionID) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回任务执行摘要") + }, + } +} + +// aiTaskExecutionUsersArgs 表示任务执行用户列表工具的输入参数。 +type aiTaskExecutionUsersArgs struct { + // TaskID 表示目标任务 ID。 + TaskID uint `json:"task_id"` + // ExecutionID 表示目标执行 ID。 + ExecutionID uint `json:"execution_id"` + // Page 表示页码。 + Page int `json:"page,omitempty"` + // PageSize 表示分页大小。 + PageSize int `json:"page_size,omitempty"` + // AllCompleted 表示是否只保留全部完成的用户。 + AllCompleted *bool `json:"all_completed,omitempty"` + // Username 表示用户名关键字过滤条件。 + Username string `json:"username,omitempty"` +} + +// newAIListTaskExecutionUsersTool 创建任务执行用户列表工具。 +func newAIListTaskExecutionUsersTool( + taskSvc aiOJTaskService, + authorization aiAuthorizationService, +) *aiServiceTool { + // 用户列表工具复用任务可见性和组织能力双重收口。 + return &aiServiceTool{ + // spec 描述模型可传的分页和筛选参数。 + spec: aidomain.ToolSpec{ + Name: "list_task_execution_users", + Description: "分页列出指定任务执行下的用户结果,需要任务可见性和组织能力。", + Parameters: []aidomain.ToolParameter{ + {Name: "task_id", Type: aidomain.ToolParameterTypeInteger, Description: "任务 ID", Required: true}, + {Name: "execution_id", Type: aidomain.ToolParameterTypeInteger, Description: "执行 ID", Required: true}, + {Name: "page", Type: aidomain.ToolParameterTypeInteger, Description: "页码,默认 1"}, + {Name: "page_size", Type: aidomain.ToolParameterTypeInteger, Description: "分页大小,默认 20"}, + {Name: "all_completed", Type: aidomain.ToolParameterTypeBoolean, Description: "是否只看已全部完成的用户"}, + {Name: "username", Type: aidomain.ToolParameterTypeString, Description: "用户名关键字"}, + }, + }, + // policy 声明这是组织能力类工具。 + policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), + // call 负责按任务执行分页查询用户结果。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析任务标识和过滤条件。 + var args aiTaskExecutionUsersArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 先拿任务详情,复用现有任务可见性校验。 + detail, err := taskSvc.GetTaskDetail(ctx, callCtx.Principal.UserID, args.TaskID) + if err != nil { + return aidomain.ToolResult{}, err + } + // 再按任务关联组织做 capability 收口。 + if err := requireAITaskOrgCapability( + ctx, + authorization, + callCtx.Principal, + detail, + consts.CapabilityCodeOJTaskManage, + ); err != nil { + return aidomain.ToolResult{}, err + } + + // 能力通过后,按分页和筛选条件查询执行用户列表。 + out, err := taskSvc.GetTaskExecutionUsers(ctx, callCtx.Principal.UserID, args.TaskID, args.ExecutionID, &request.OJTaskExecutionUserListReq{ + Page: args.Page, + PageSize: args.PageSize, + AllCompleted: args.AllCompleted, + Username: strings.TrimSpace(args.Username), + }) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回任务执行用户列表") + }, + } +} + +// aiTaskExecutionUserDetailArgs 表示任务执行用户详情工具的输入参数。 +type aiTaskExecutionUserDetailArgs struct { + // TaskID 表示目标任务 ID。 + TaskID uint `json:"task_id"` + // ExecutionID 表示目标执行 ID。 + ExecutionID uint `json:"execution_id"` + // TargetUserID 表示要查看详情的目标用户 ID。 + TargetUserID uint `json:"target_user_id"` +} + +// newAIGetTaskExecutionUserDetailTool 创建任务执行用户详情工具。 +func newAIGetTaskExecutionUserDetailTool( + taskSvc aiOJTaskService, + authorization aiAuthorizationService, +) *aiServiceTool { + // 用户详情工具与任务摘要工具共享同一套权限收口思路。 + return &aiServiceTool{ + // spec 要求模型给出 task、execution 和 target user 三个关键标识。 + spec: aidomain.ToolSpec{ + Name: "get_task_execution_user_detail", + Description: "获取指定任务执行中某个用户的详细结果,需要任务可见性和组织能力。", + Parameters: []aidomain.ToolParameter{ + {Name: "task_id", Type: aidomain.ToolParameterTypeInteger, Description: "任务 ID", Required: true}, + {Name: "execution_id", Type: aidomain.ToolParameterTypeInteger, Description: "执行 ID", Required: true}, + {Name: "target_user_id", Type: aidomain.ToolParameterTypeInteger, Description: "目标用户 ID", Required: true}, + }, + }, + // policy 仍然是 OJ 任务管理能力。 + policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), + // call 负责查询指定执行里某个用户的详细结果。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析任务、执行和目标用户参数。 + var args aiTaskExecutionUserDetailArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 先用任务详情复用既有任务可见性逻辑。 + detail, err := taskSvc.GetTaskDetail(ctx, callCtx.Principal.UserID, args.TaskID) + if err != nil { + return aidomain.ToolResult{}, err + } + // 再按任务关联组织做执行前 capability 收口。 + if err := requireAITaskOrgCapability( + ctx, + authorization, + callCtx.Principal, + detail, + consts.CapabilityCodeOJTaskManage, + ); err != nil { + return aidomain.ToolResult{}, err + } + + // 授权通过后查询目标用户在该执行中的详细结果。 + out, err := taskSvc.GetTaskExecutionUserDetail( + ctx, + callCtx.Principal.UserID, + args.TaskID, + args.ExecutionID, + args.TargetUserID, + ) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回执行用户详情") + }, + } +} + +// aiAnalyzeTaskItemArgs 表示单个题目分析项。 +type aiAnalyzeTaskItemArgs struct { + // Platform 表示题目所属 OJ 平台。 + Platform string `json:"platform"` + // Title 表示原始题目标题。 + Title string `json:"title"` +} + +// aiAnalyzeTaskTitlesArgs 表示题目标题分析工具的输入参数。 +type aiAnalyzeTaskTitlesArgs struct { + // OrgID 表示目标组织 ID,省略时默认当前组织。 + OrgID *uint `json:"org_id,omitempty"` + // Items 表示待分析的题目列表。 + Items []aiAnalyzeTaskItemArgs `json:"items"` +} + +// newAIAnalyzeTaskTitlesTool 创建题目标题分析工具。 +func newAIAnalyzeTaskTitlesTool( + taskSvc aiOJTaskService, + authorization aiAuthorizationService, +) *aiServiceTool { + // itemParam 描述数组元素的对象结构,让模型知道 items 内每项字段。 + itemParam := aidomain.ToolParameter{ + Type: aidomain.ToolParameterTypeObject, + Properties: []aidomain.ToolParameter{ + {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"luogu", "leetcode", "lanqiao"}}, + {Name: "title", Type: aidomain.ToolParameterTypeString, Description: "题目标题", Required: true}, + }, + } + return &aiServiceTool{ + // spec 描述按组织上下文分析一组题目标题的能力。 + spec: aidomain.ToolSpec{ + Name: "analyze_task_titles", + Description: "分析一组 OJ 题目标题并返回可解析结果,需要指定组织并具备 OJ 任务管理能力。", + Parameters: []aidomain.ToolParameter{ + {Name: "org_id", Type: aidomain.ToolParameterTypeInteger, Description: "目标组织 ID;省略时默认当前组织"}, + {Name: "items", Type: aidomain.ToolParameterTypeArray, Description: "待分析题目列表", Required: true, Items: &itemParam}, + }, + }, + // policy 声明该工具需要组织能力。 + policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), + // call 负责解析题目列表、校验组织能力并调用任务分析服务。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析组织和题目数组参数。 + var args aiAnalyzeTaskTitlesArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 组织 ID 允许省略,但最终必须解析成一个确定组织。 + orgID, err := resolveAIOrgID(args.OrgID, callCtx.Principal.CurrentOrgID) + if err != nil { + return aidomain.ToolResult{}, err + } + // 执行前按目标组织做真实能力鉴权。 + if err := requireAIOrgCapability( + ctx, + authorization, + callCtx.Principal, + orgID, + consts.CapabilityCodeOJTaskManage, + ); err != nil { + return aidomain.ToolResult{}, err + } + + // 把模型输入转成已有 Service 所需的请求 DTO。 + items := make([]request.AnalyzeOJTaskTitleItemReq, 0, len(args.Items)) + for _, item := range args.Items { + items = append(items, request.AnalyzeOJTaskTitleItemReq{ + Platform: strings.TrimSpace(item.Platform), + Title: strings.TrimSpace(item.Title), + }) + } + + // 调 OJTaskService 复用现有标题分析能力。 + out, err := taskSvc.AnalyzeTaskTitles(ctx, &request.AnalyzeOJTaskTitlesReq{Items: items}) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回题目分析结果") + }, + } +} + +// aiTraceDetailArgs 表示 trace 详情工具的输入参数。 +type aiTraceDetailArgs struct { + // RequestID 表示要查询的 request_id。 + RequestID string `json:"request_id"` + // Limit 表示返回条数上限。 + Limit int `json:"limit,omitempty"` + // Offset 表示分页偏移量。 + Offset int `json:"offset,omitempty"` + // IncludePayload 表示是否返回请求响应摘要。 + IncludePayload bool `json:"include_payload,omitempty"` + // IncludeErrorDetail 表示是否返回错误详情。 + IncludeErrorDetail bool `json:"include_error_detail,omitempty"` +} + +// newAIQueryTraceDetailByRequestIDTool 创建 request_id 维度的 trace 详情工具。 +func newAIQueryTraceDetailByRequestIDTool( + obsSvc aiObservabilityService, + authorization aiAuthorizationService, +) *aiServiceTool { + // trace 详情属于观测类工具,只允许超级管理员使用。 + return &aiServiceTool{ + // spec 描述按 request_id 查询链路详情的能力。 + spec: aidomain.ToolSpec{ + Name: "query_trace_detail_by_request_id", + Description: "按 request_id 查询链路详情,仅超级管理员可用。", + Parameters: []aidomain.ToolParameter{ + {Name: "request_id", Type: aidomain.ToolParameterTypeString, Description: "请求 ID", Required: true}, + {Name: "limit", Type: aidomain.ToolParameterTypeInteger, Description: "返回条数,默认 100"}, + {Name: "offset", Type: aidomain.ToolParameterTypeInteger, Description: "偏移量,默认 0"}, + {Name: "include_payload", Type: aidomain.ToolParameterTypeBoolean, Description: "是否包含请求/响应摘要"}, + {Name: "include_error_detail", Type: aidomain.ToolParameterTypeBoolean, Description: "是否包含错误详情"}, + }, + }, + // policy 声明该工具只对超级管理员可见。 + policy: newAISuperAdminOnlyPolicy(), + // call 负责执行超级管理员校验并查询 trace 详情。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析 request_id 和分页参数。 + var args aiTraceDetailArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 观测类工具执行前再次强制校验超级管理员权限。 + if err := requireAISuperAdmin(ctx, authorization, callCtx.Principal); err != nil { + return aidomain.ToolResult{}, err + } + + // 调观测服务按 request_id 拉取链路详情。 + out, err := obsSvc.QueryTraceDetail( + ctx, + strings.TrimSpace(args.RequestID), + request.TraceDetailIDTypeRequest, + args.Limit, + args.Offset, + args.IncludePayload, + args.IncludeErrorDetail, + ) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回请求链路详情") + }, + } +} + +// aiTraceSummaryArgs 表示 trace 摘要列表工具的输入参数。 +type aiTraceSummaryArgs struct { + // TraceID 表示 trace_id 过滤条件。 + TraceID string `json:"trace_id,omitempty"` + // RequestID 表示 request_id 过滤条件。 + RequestID string `json:"request_id,omitempty"` + // Service 表示服务名过滤条件。 + Service string `json:"service,omitempty"` + // Status 表示链路状态过滤条件。 + Status string `json:"status,omitempty"` + // RootStage 表示根阶段过滤条件。 + RootStage string `json:"root_stage,omitempty"` + // StartAt 表示查询时间窗口开始时间。 + StartAt string `json:"start_at,omitempty"` + // EndAt 表示查询时间窗口结束时间。 + EndAt string `json:"end_at,omitempty"` + // Limit 表示返回条数。 + Limit int `json:"limit,omitempty"` + // Offset 表示分页偏移量。 + Offset int `json:"offset,omitempty"` +} + +// newAIQueryTraceSummaryTool 创建 trace 摘要列表工具。 +func newAIQueryTraceSummaryTool( + obsSvc aiObservabilityService, + authorization aiAuthorizationService, +) *aiServiceTool { + // trace 摘要属于观测类工具,只允许超级管理员使用。 + return &aiServiceTool{ + // spec 描述 trace 列表支持的过滤字段。 + spec: aidomain.ToolSpec{ + Name: "query_trace_summary", + Description: "查询链路摘要列表,仅超级管理员可用。", + Parameters: []aidomain.ToolParameter{ + {Name: "trace_id", Type: aidomain.ToolParameterTypeString, Description: "trace_id"}, + {Name: "request_id", Type: aidomain.ToolParameterTypeString, Description: "request_id"}, + {Name: "service", Type: aidomain.ToolParameterTypeString, Description: "服务名"}, + {Name: "status", Type: aidomain.ToolParameterTypeString, Description: "状态"}, + {Name: "root_stage", Type: aidomain.ToolParameterTypeString, Description: "root stage", Enum: []string{request.TraceRootStageHTTP, request.TraceRootStageTask, request.TraceRootStageAll}}, + {Name: "start_at", Type: aidomain.ToolParameterTypeString, Description: "开始时间"}, + {Name: "end_at", Type: aidomain.ToolParameterTypeString, Description: "结束时间"}, + {Name: "limit", Type: aidomain.ToolParameterTypeInteger, Description: "返回条数"}, + {Name: "offset", Type: aidomain.ToolParameterTypeInteger, Description: "偏移量"}, + }, + }, + // policy 声明该工具只对超级管理员可见。 + policy: newAISuperAdminOnlyPolicy(), + // call 负责执行超级管理员鉴权并查询 trace 摘要列表。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析各种 trace 过滤条件。 + var args aiTraceSummaryArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 执行前再次验证调用者是否为超级管理员。 + if err := requireAISuperAdmin(ctx, authorization, callCtx.Principal); err != nil { + return aidomain.ToolResult{}, err + } + + // 调观测服务按过滤条件查询 trace 摘要列表。 + out, err := obsSvc.QueryTrace(ctx, &request.ObservabilityTraceQueryReq{ + TraceID: strings.TrimSpace(args.TraceID), + RequestID: strings.TrimSpace(args.RequestID), + Service: strings.TrimSpace(args.Service), + Status: strings.TrimSpace(args.Status), + RootStage: strings.TrimSpace(args.RootStage), + StartAt: strings.TrimSpace(args.StartAt), + EndAt: strings.TrimSpace(args.EndAt), + Limit: args.Limit, + Offset: args.Offset, + }) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回链路摘要列表") + }, + } +} + +// aiRuntimeMetricsArgs 表示运行时指标工具的输入参数。 +type aiRuntimeMetricsArgs struct { + // Metric 表示要查询的指标名。 + Metric string `json:"metric"` + // StartAt 表示时间窗口开始时间。 + StartAt string `json:"start_at,omitempty"` + // EndAt 表示时间窗口结束时间。 + EndAt string `json:"end_at,omitempty"` + // Granularity 表示聚合粒度。 + Granularity string `json:"granularity,omitempty"` + // TaskName 表示任务名过滤条件。 + TaskName string `json:"task_name,omitempty"` + // Topic 表示 topic 过滤条件。 + Topic string `json:"topic,omitempty"` + // Status 表示状态过滤条件。 + Status string `json:"status,omitempty"` + // Limit 表示返回条数。 + Limit int `json:"limit,omitempty"` +} + +// newAIQueryRuntimeMetricsTool 创建运行时指标查询工具。 +func newAIQueryRuntimeMetricsTool( + obsSvc aiObservabilityService, + authorization aiAuthorizationService, +) *aiServiceTool { + // 运行时指标属于观测类工具,只允许超级管理员使用。 + return &aiServiceTool{ + // spec 描述指标查询支持的过滤参数。 + spec: aidomain.ToolSpec{ + Name: "query_runtime_metrics", + Description: "查询运行时指标,仅超级管理员可用。", + Parameters: []aidomain.ToolParameter{ + {Name: "metric", Type: aidomain.ToolParameterTypeString, Description: "指标名称", Required: true}, + {Name: "start_at", Type: aidomain.ToolParameterTypeString, Description: "开始时间"}, + {Name: "end_at", Type: aidomain.ToolParameterTypeString, Description: "结束时间"}, + {Name: "granularity", Type: aidomain.ToolParameterTypeString, Description: "粒度"}, + {Name: "task_name", Type: aidomain.ToolParameterTypeString, Description: "任务名"}, + {Name: "topic", Type: aidomain.ToolParameterTypeString, Description: "topic"}, + {Name: "status", Type: aidomain.ToolParameterTypeString, Description: "状态"}, + {Name: "limit", Type: aidomain.ToolParameterTypeInteger, Description: "返回条数"}, + }, + }, + // policy 声明该工具只对超级管理员可见。 + policy: newAISuperAdminOnlyPolicy(), + // call 负责鉴权并查询运行时指标。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析指标查询参数。 + var args aiRuntimeMetricsArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 观测类工具执行前统一校验超级管理员权限。 + if err := requireAISuperAdmin(ctx, authorization, callCtx.Principal); err != nil { + return aidomain.ToolResult{}, err + } + + // 调观测服务查询运行时指标。 + out, err := obsSvc.QueryRuntimeMetrics(ctx, &request.ObservabilityRuntimeMetricQueryReq{ + Metric: strings.TrimSpace(args.Metric), + StartAt: strings.TrimSpace(args.StartAt), + EndAt: strings.TrimSpace(args.EndAt), + Granularity: strings.TrimSpace(args.Granularity), + TaskName: strings.TrimSpace(args.TaskName), + Topic: strings.TrimSpace(args.Topic), + Status: strings.TrimSpace(args.Status), + Limit: args.Limit, + }) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回运行时指标") + }, + } +} + +// aiObservabilityMetricsArgs 表示 HTTP 观测指标工具的输入参数。 +type aiObservabilityMetricsArgs struct { + // Granularity 表示聚合粒度。 + Granularity string `json:"granularity"` + // StartAt 表示查询时间窗口开始时间。 + StartAt string `json:"start_at"` + // EndAt 表示查询时间窗口结束时间。 + EndAt string `json:"end_at"` + // Service 表示服务名过滤条件。 + Service string `json:"service,omitempty"` + // RouteTemplate 表示路由模板过滤条件。 + RouteTemplate string `json:"route_template,omitempty"` + // Method 表示 HTTP 方法过滤条件。 + Method string `json:"method,omitempty"` + // StatusClass 表示状态码段过滤条件。 + StatusClass int `json:"status_class,omitempty"` + // ErrorCode 表示错误码过滤条件。 + ErrorCode *string `json:"error_code,omitempty"` + // Limit 表示返回条数。 + Limit int `json:"limit,omitempty"` +} + +// newAIQueryObservabilityMetricsTool 创建 HTTP 观测指标查询工具。 +func newAIQueryObservabilityMetricsTool( + obsSvc aiObservabilityService, + authorization aiAuthorizationService, +) *aiServiceTool { + // HTTP 观测指标属于观测类工具,只允许超级管理员使用。 + return &aiServiceTool{ + // spec 描述 HTTP 指标查询需要的时间窗口和过滤参数。 + spec: aidomain.ToolSpec{ + Name: "query_observability_metrics", + Description: "查询 HTTP 观测指标,仅超级管理员可用。", + Parameters: []aidomain.ToolParameter{ + {Name: "granularity", Type: aidomain.ToolParameterTypeString, Description: "聚合粒度", Required: true}, + {Name: "start_at", Type: aidomain.ToolParameterTypeString, Description: "开始时间", Required: true}, + {Name: "end_at", Type: aidomain.ToolParameterTypeString, Description: "结束时间", Required: true}, + {Name: "service", Type: aidomain.ToolParameterTypeString, Description: "服务名"}, + {Name: "route_template", Type: aidomain.ToolParameterTypeString, Description: "路由模板"}, + {Name: "method", Type: aidomain.ToolParameterTypeString, Description: "HTTP 方法"}, + {Name: "status_class", Type: aidomain.ToolParameterTypeInteger, Description: "状态码段,例如 2 / 4 / 5"}, + {Name: "error_code", Type: aidomain.ToolParameterTypeString, Description: "错误码"}, + {Name: "limit", Type: aidomain.ToolParameterTypeInteger, Description: "返回条数"}, + }, + }, + // policy 声明该工具只对超级管理员可见。 + policy: newAISuperAdminOnlyPolicy(), + // call 负责鉴权并查询 HTTP 观测指标。 + call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { + // 先解析时间窗口和过滤参数。 + var args aiObservabilityMetricsArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return aidomain.ToolResult{}, err + } + + // 执行前再次校验超级管理员权限。 + if err := requireAISuperAdmin(ctx, authorization, callCtx.Principal); err != nil { + return aidomain.ToolResult{}, err + } + + // 调观测服务查询 HTTP 指标聚合结果。 + out, err := obsSvc.QueryMetrics(ctx, &request.ObservabilityMetricsQueryReq{ + Granularity: strings.TrimSpace(args.Granularity), + StartAt: strings.TrimSpace(args.StartAt), + EndAt: strings.TrimSpace(args.EndAt), + Service: strings.TrimSpace(args.Service), + RouteTemplate: strings.TrimSpace(args.RouteTemplate), + Method: strings.TrimSpace(args.Method), + StatusClass: args.StatusClass, + ErrorCode: args.ErrorCode, + Limit: args.Limit, + }) + if err != nil { + return aidomain.ToolResult{}, err + } + return buildAIToolResult(out, "已返回观测指标") + }, + } +} + +// resolveAIOrgID 负责解析工具执行时真正要使用的组织 ID。 +func resolveAIOrgID(orgID *uint, currentOrgID *uint) (uint, error) { + // 参数里显式给了 org_id 时优先使用参数值。 + if orgID != nil && *orgID > 0 { + return *orgID, nil + } + // 否则回退到当前用户上下文里的组织。 + if currentOrgID != nil && *currentOrgID > 0 { + return *currentOrgID, nil + } + // 两者都没有时无法继续执行组织能力类工具。 + return 0, bizerrors.NewWithMsg(bizerrors.CodeInvalidParams, "缺少可用的 org_id") +} + +// defaultString 在空字符串时返回兜底值。 +func defaultString(value string, fallback string) string { + // 先去掉首尾空白,避免把空格当成有效值。 + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} diff --git a/internal/service/system/aiTool_test.go b/internal/service/system/aiTool_test.go new file mode 100644 index 0000000..c7d2b39 --- /dev/null +++ b/internal/service/system/aiTool_test.go @@ -0,0 +1,370 @@ +package system + +import ( + "context" + "testing" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/consts" + "personal_assistant/internal/model/dto/request" + resp "personal_assistant/internal/model/dto/response" + bizerrors "personal_assistant/pkg/errors" +) + +type fakeAIToolAuthorization struct { + superAdmin bool + capabilities map[uint]map[string]bool + checkCalls int + authorizeCalls int +} + +func (f *fakeAIToolAuthorization) IsSuperAdmin(context.Context, uint) (bool, error) { + return f.superAdmin, nil +} + +func (f *fakeAIToolAuthorization) CheckUserCapabilityInOrg( + _ context.Context, + _ uint, + orgID uint, + capabilityCode string, +) (bool, error) { + f.checkCalls++ + return f.hasCapability(orgID, capabilityCode), nil +} + +func (f *fakeAIToolAuthorization) AuthorizeOrgCapability( + _ context.Context, + _ uint, + orgID uint, + capabilityCode string, +) error { + f.authorizeCalls++ + if f.superAdmin || f.hasCapability(orgID, capabilityCode) { + return nil + } + return bizerrors.New(bizerrors.CodePermissionDenied) +} + +func (f *fakeAIToolAuthorization) hasCapability(orgID uint, capabilityCode string) bool { + if f == nil { + return false + } + if caps, ok := f.capabilities[orgID]; ok { + return caps[capabilityCode] + } + return false +} + +type fakeAIToolOJService struct{} + +func (f *fakeAIToolOJService) GetRankingList( + context.Context, + uint, + *request.OJRankingListReq, +) (*resp.OJRankingListResp, error) { + return &resp.OJRankingListResp{}, nil +} + +func (f *fakeAIToolOJService) GetUserStats( + context.Context, + uint, + *request.OJStatsReq, +) (*resp.OJStatsResp, error) { + return &resp.OJStatsResp{}, nil +} + +func (f *fakeAIToolOJService) GetCurve( + context.Context, + uint, + *request.OJCurveReq, +) (*resp.OJCurveResp, error) { + return &resp.OJCurveResp{}, nil +} + +type fakeAIToolOJTaskService struct { + taskDetailResp *resp.OJTaskDetailResp + taskExecutionResp *resp.OJTaskExecutionResp + taskExecutionUsersResp *resp.OJTaskExecutionUserListResp + taskExecutionUserResp *resp.OJTaskExecutionUserDetailResp + analyzeResp *resp.OJTaskAnalyzeResp + taskDetailCalls int + taskExecutionCalls int + taskExecutionUsersCalls int + taskExecutionUserCalls int + analyzeCalls int +} + +func (f *fakeAIToolOJTaskService) AnalyzeTaskTitles( + context.Context, + *request.AnalyzeOJTaskTitlesReq, +) (*resp.OJTaskAnalyzeResp, error) { + f.analyzeCalls++ + if f.analyzeResp == nil { + return &resp.OJTaskAnalyzeResp{}, nil + } + return f.analyzeResp, nil +} + +func (f *fakeAIToolOJTaskService) GetTaskDetail( + context.Context, + uint, + uint, +) (*resp.OJTaskDetailResp, error) { + f.taskDetailCalls++ + if f.taskDetailResp == nil { + return &resp.OJTaskDetailResp{}, nil + } + return f.taskDetailResp, nil +} + +func (f *fakeAIToolOJTaskService) GetTaskExecutionDetail( + context.Context, + uint, + uint, + uint, +) (*resp.OJTaskExecutionResp, error) { + f.taskExecutionCalls++ + if f.taskExecutionResp == nil { + return &resp.OJTaskExecutionResp{}, nil + } + return f.taskExecutionResp, nil +} + +func (f *fakeAIToolOJTaskService) GetTaskExecutionUsers( + context.Context, + uint, + uint, + uint, + *request.OJTaskExecutionUserListReq, +) (*resp.OJTaskExecutionUserListResp, error) { + f.taskExecutionUsersCalls++ + if f.taskExecutionUsersResp == nil { + return &resp.OJTaskExecutionUserListResp{}, nil + } + return f.taskExecutionUsersResp, nil +} + +func (f *fakeAIToolOJTaskService) GetTaskExecutionUserDetail( + context.Context, + uint, + uint, + uint, + uint, +) (*resp.OJTaskExecutionUserDetailResp, error) { + f.taskExecutionUserCalls++ + if f.taskExecutionUserResp == nil { + return &resp.OJTaskExecutionUserDetailResp{}, nil + } + return f.taskExecutionUserResp, nil +} + +type fakeAIToolObservabilityService struct { + runtimeMetricsResp *resp.ObservabilityRuntimeMetricQueryResp + runtimeCalls int +} + +func (f *fakeAIToolObservabilityService) QueryMetrics( + context.Context, + *request.ObservabilityMetricsQueryReq, +) (*resp.ObservabilityMetricsQueryResp, error) { + return &resp.ObservabilityMetricsQueryResp{}, nil +} + +func (f *fakeAIToolObservabilityService) QueryRuntimeMetrics( + context.Context, + *request.ObservabilityRuntimeMetricQueryReq, +) (*resp.ObservabilityRuntimeMetricQueryResp, error) { + f.runtimeCalls++ + if f.runtimeMetricsResp == nil { + return &resp.ObservabilityRuntimeMetricQueryResp{}, nil + } + return f.runtimeMetricsResp, nil +} + +func (f *fakeAIToolObservabilityService) QueryTraceDetail( + context.Context, + string, + string, + int, + int, + bool, + bool, +) (*resp.ObservabilityTraceQueryResp, error) { + return &resp.ObservabilityTraceQueryResp{}, nil +} + +func (f *fakeAIToolObservabilityService) QueryTrace( + context.Context, + *request.ObservabilityTraceQueryReq, +) (*resp.ObservabilityTraceSummaryQueryResp, error) { + return &resp.ObservabilityTraceSummaryQueryResp{}, nil +} + +func TestAIToolRegistryFilterVisibleToolsByPolicy(t *testing.T) { + orgID := uint(10) + auth := &fakeAIToolAuthorization{ + capabilities: map[uint]map[string]bool{ + orgID: {consts.CapabilityCodeOJTaskManage: true}, + }, + } + registry := newAIToolRegistry(AIDeps{ + Authorization: auth, + OJ: &fakeAIToolOJService{}, + OJTask: &fakeAIToolOJTaskService{}, + Observability: &fakeAIToolObservabilityService{}, + }) + + tools, err := registry.FilterVisibleTools(context.Background(), aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{ + UserID: 1, + CurrentOrgID: &orgID, + }, + }) + if err != nil { + t.Fatalf("FilterVisibleTools() error = %v", err) + } + + names := toolNames(tools) + assertContainsTool(t, names, "get_my_oj_stats") + assertContainsTool(t, names, "get_org_ranking_summary") + assertNotContainsTool(t, names, "query_runtime_metrics") + + auth.superAdmin = true + tools, err = registry.FilterVisibleTools(context.Background(), aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{ + UserID: 1, + CurrentOrgID: &orgID, + IsSuperAdmin: true, + }, + }) + if err != nil { + t.Fatalf("FilterVisibleTools() error = %v", err) + } + + names = toolNames(tools) + assertContainsTool(t, names, "query_runtime_metrics") +} + +func TestAIToolExecutionReauthorizesTaskOrgCapability(t *testing.T) { + taskSvc := &fakeAIToolOJTaskService{ + taskDetailResp: &resp.OJTaskDetailResp{ + TaskID: 1, + Orgs: []*resp.OJTaskOrgItemResp{ + {OrgID: 9, OrgName: "org-9"}, + }, + }, + taskExecutionResp: &resp.OJTaskExecutionResp{ + TaskID: 1, + ExecutionID: 2, + Status: "succeeded", + }, + } + auth := &fakeAIToolAuthorization{ + capabilities: map[uint]map[string]bool{ + 9: {consts.CapabilityCodeOJTaskManage: true}, + }, + } + tool := newAIToolRegistry(AIDeps{ + Authorization: auth, + OJTask: taskSvc, + }).findTool("get_task_execution_summary") + if tool == nil { + t.Fatal("tool get_task_execution_summary not found") + } + + _, err := tool.Call(context.Background(), aidomain.ToolCall{ + ID: "call_1", + Name: "get_task_execution_summary", + ArgumentsJSON: `{"task_id":1,"execution_id":2}`, + }, aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: 7}, + }) + if err != nil { + t.Fatalf("tool.Call() error = %v", err) + } + if taskSvc.taskDetailCalls != 1 { + t.Fatalf("taskDetailCalls = %d, want 1", taskSvc.taskDetailCalls) + } + if taskSvc.taskExecutionCalls != 1 { + t.Fatalf("taskExecutionCalls = %d, want 1", taskSvc.taskExecutionCalls) + } + if auth.authorizeCalls != 1 { + t.Fatalf("authorizeCalls = %d, want 1", auth.authorizeCalls) + } + + taskSvc.taskDetailResp = &resp.OJTaskDetailResp{ + TaskID: 1, + Orgs: []*resp.OJTaskOrgItemResp{ + {OrgID: 10, OrgName: "org-10"}, + }, + } + _, err = tool.Call(context.Background(), aidomain.ToolCall{ + ID: "call_2", + Name: "get_task_execution_summary", + ArgumentsJSON: `{"task_id":1,"execution_id":2}`, + }, aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: 7}, + }) + if bizErr := bizerrors.FromError(err); bizErr == nil || bizErr.Code != bizerrors.CodePermissionDenied { + t.Fatalf("tool.Call() error = %v, want permission denied", err) + } + if taskSvc.taskExecutionCalls != 1 { + t.Fatalf("taskExecutionCalls after denied call = %d, want still 1", taskSvc.taskExecutionCalls) + } +} + +func TestAIToolSuperAdminExecutionDeniedWhenNotSuperAdmin(t *testing.T) { + auth := &fakeAIToolAuthorization{} + obsSvc := &fakeAIToolObservabilityService{} + tool := newAIToolRegistry(AIDeps{ + Authorization: auth, + Observability: obsSvc, + }).findTool("query_runtime_metrics") + if tool == nil { + t.Fatal("tool query_runtime_metrics not found") + } + + _, err := tool.Call(context.Background(), aidomain.ToolCall{ + ID: "call_3", + Name: "query_runtime_metrics", + ArgumentsJSON: `{"metric":"job_duration"}`, + }, aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: 9}, + }) + if bizErr := bizerrors.FromError(err); bizErr == nil || bizErr.Code != bizerrors.CodePermissionDenied { + t.Fatalf("tool.Call() error = %v, want permission denied", err) + } + if obsSvc.runtimeCalls != 0 { + t.Fatalf("runtimeCalls = %d, want 0", obsSvc.runtimeCalls) + } +} + +func toolNames(tools []aidomain.Tool) []string { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + if tool == nil { + continue + } + names = append(names, tool.Spec().Name) + } + return names +} + +func assertContainsTool(t *testing.T, names []string, target string) { + t.Helper() + for _, name := range names { + if name == target { + return + } + } + t.Fatalf("tool %s not found in %v", target, names) +} + +func assertNotContainsTool(t *testing.T, names []string, target string) { + t.Helper() + for _, name := range names { + if name == target { + t.Fatalf("tool %s unexpectedly found in %v", target, names) + } + } +} diff --git a/internal/service/system/supplier.go b/internal/service/system/supplier.go index 2ae6436..7f3f4bd 100644 --- a/internal/service/system/supplier.go +++ b/internal/service/system/supplier.go @@ -39,7 +39,6 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { rawMenu := NewMenuService(repositoryGroup, rawPermissionProjection) rawRole := NewRoleService(repositoryGroup, rawPermissionProjection) rawImage := NewImageService(repositoryGroup) - rawAI := NewAIService(repositoryGroup) rawObservability := obsquery.NewQueryService( global.ObservabilityMetrics, global.ObservabilityTraces, @@ -62,7 +61,6 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { // 这里单独分段,是为了让阅读者更容易看清主要业务动作发生的位置。 roleSvc := contract.RoleServiceContract(rawRole) imageSvc := contract.ImageServiceContract(rawImage) - aiSvc := contract.AIServiceContract(rawAI) observabilitySvc := contract.ObservabilityServiceContract(rawObservability) cacheProjectionSvc := contract.CacheProjectionServiceContract(rawCacheProjection) ojDailyStatsProjectionSvc := contract.OJDailyStatsProjectionServiceContract(rawOJDailyStatsProjection) @@ -83,6 +81,16 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { observabilitySvc = obsdecorator.WrapObservabilityService(observabilitySvc) } + // AIService 在这里注入 runtime 所需的最小依赖,让 tool 可见性和执行鉴权都走正式 Service。 + rawAI := NewAIServiceWithRuntimeAndDeps(repositoryGroup, global.AIRuntime, AIDeps{ + Authorization: authorizationSvc, + OJ: ojSvc, + OJTask: ojTaskSvc, + Observability: observabilitySvc, + }) + // 对外仍只暴露统一的 AIService 契约,不把具体 tool 依赖细节泄露到上层。 + aiSvc := contract.AIServiceContract(rawAI) + ss.jwtService = jwtSvc ss.authorizationService = authorizationSvc ss.permissionProjectionService = permissionProjectionSvc diff --git a/plan/ai/approved-ai-tool-runtime.md b/plan/ai/approved-ai-tool-runtime.md new file mode 100644 index 0000000..dc591fd --- /dev/null +++ b/plan/ai/approved-ai-tool-runtime.md @@ -0,0 +1,29 @@ +# AI Tool Runtime Minimum Loop + +## Summary + +Implement the first-stage AI tool loop without adding HTTP routes, controllers, repositories, database schema, interrupt, checkpoint, A2UI, MCP, or write-operation tools. The AI layer keeps the existing MVC shell and adds domain protocols, service orchestration, Eino tool calling, and trace projection. + +## Key Changes + +- Add stable AI tool protocol types in `internal/domain/ai`, extend `StreamInput`, and add tool call events. +- Add service-side tool principal, registry, policy filtering, prompt building, helpers, and the full first-stage read-only tool catalog. +- Keep user authorization fact-based instead of mapping users into fixed role buckets. Tool visibility and execution authorization are controlled by per-tool policy. +- Inject Authorization, OJ, OJTask, and Observability service contracts into AIService through `AIDeps`. +- Use Eino tool calling when tools are available, and preserve the existing pure text stream path when no tool is available. +- Persist tool events through the existing sink/projector path into `trace_items_json`. + +## Permission Policy + +- Personal tools are self-only. +- OJ task and organization tools use `consts.CapabilityCodeOJTaskManage` and do parameter-level authorization at execution time. +- Observability tools are super-admin only. +- No API resource permission mapping and no virtual tool resource table will be added in this phase. + +## Tests + +- Registry and policy filtering tests for self-only, org capability, and super-admin tools. +- Execution authorization tests for denied org capability and denied super-admin access. +- Projector tests for tool started/finished trace items. +- Eino adapter tests with fake tools/model behavior where feasible. +- Run targeted AI tests and `go test ./...`. diff --git a/plan/ai/approved-assistant-trace-merge.md b/plan/ai/approved-assistant-trace-merge.md new file mode 100644 index 0000000..8887eef --- /dev/null +++ b/plan/ai/approved-assistant-trace-merge.md @@ -0,0 +1,42 @@ +# 目标 + +将工具调用轨迹并入普通 assistant 消息显示,前端不再为 `trace_items` 单独渲染额外 UI。 + +# 范围 + +- 保持后端 `ai_messages.content` 与 `ai_messages.trace_items_json` 存储模型不变 +- 保持历史消息接口继续返回 `content + trace_items` +- 保持 SSE 继续下发 `tool_call_started / tool_call_finished / assistant_token / message_completed / error / done` +- 前端改为把 `trace_items` 折叠成普通文本并与 `content` 合并显示 + +# 改动 + +- 后端校准 `AssistantMessageResp` 注释、SSE 协议说明、OpenAPI 示例与现行 `tool_call_*` 事件保持一致 +- 前端在 `assistant` store 中消费 `tool_call_started` 与 `tool_call_finished` 并 upsert 到当前 assistant 消息的 `trace_items` +- 前端取消对 `trace_items` 的按 key 重排,保留服务端顺序 +- 前端新增消息格式化逻辑,将 `trace_items` 折叠成简短普通文本并在渲染时与 `content` 合并 + +# 验证 + +- `go test ./internal/service/system -run "AIMessageProjector|AIStreamSink|AIService"` +- `go test ./internal/infrastructure/ai/eino -run Tool` +- `npx vue-tsc --noEmit` +- `npm run build` + +# 风险 + +- OpenAPI 示例与前端真实消费路径存在旧描述残留,需要同步清理 +- 历史消息与流式消息都依赖同一套 trace 折叠格式,需避免两端表现不一致 +- 多工具场景必须保留服务端顺序,不能继续被前端排序逻辑打乱 + +# 执行顺序 + +1. 校准后端协议注释、OpenAPI 与相关测试 +2. 调整前端类型与 store 的 SSE 消费 +3. 调整 assistant 消息合并渲染 +4. 跑后端测试与前端校验 + +# 待确认 + +- 默认展示顺序为工具过程文本在前、最终正文在后 +- `detail_markdown` 仅在失败或摘要不足时做最小补充,不在成功场景全文内联 diff --git a/plan/ai/approved-qdrant-config.md b/plan/ai/approved-qdrant-config.md new file mode 100644 index 0000000..bb45b54 --- /dev/null +++ b/plan/ai/approved-qdrant-config.md @@ -0,0 +1,32 @@ +# Qdrant Config Integration + +# Goal + +Add Qdrant vector database configuration support and configure the local `.env`. + +# Scope + +- Expose Qdrant config through `global.Config.Qdrant.Endpoint` and `global.Config.Qdrant.APIKey`. +- Bind `QDRANT_ENDPOINT` and `QDRANT_API_KEY` from environment variables. +- Add template placeholders without real credentials. +- Store the real local Qdrant API key only in ignored `.env`. + +# Changes + +- Add a Qdrant config struct under `internal/model/config`. +- Register Qdrant in the root `Config` object and `NewConfig()`. +- Add default values and env bindings in `internal/core/config.go`. +- Add Qdrant placeholders to `.env.example` and `configs/configs.yaml`. +- Add local `.env` values using the existing MySQL host and port `6333`. + +# Verification + +- Run `go test ./internal/model/config ./internal/core ./internal/infrastructure/ai/...`. +- Verify `.env` contains `QDRANT_ENDPOINT` and `QDRANT_API_KEY` without printing secrets. +- Check `git status` to ensure `.env` remains ignored. + +# Assumptions + +- Qdrant HTTP endpoint uses port `6333`. +- The provided Qdrant password is used as the Qdrant API key. +- No Qdrant client, collection initialization, embedding, indexer, or retriever logic is added in this task. diff --git a/plan/ai/approved-remove-ai-ui-blocks.md b/plan/ai/approved-remove-ai-ui-blocks.md new file mode 100644 index 0000000..87f8968 --- /dev/null +++ b/plan/ai/approved-remove-ai-ui-blocks.md @@ -0,0 +1,64 @@ +# 目标 + +全局移除 AI 消息中的 UI Blocks / A2UI 持久化与接口协议,删除 `UIBlocksJSON = "[]"` 相关逻辑,并让后端不再读写或返回 `ui_blocks`。 + +# 范围 + +- AI 消息实体:`internal/model/entity/ai.go` +- AI 响应 DTO:`internal/model/dto/response/aiResp.go` +- AI 消息创建、投影、映射:`internal/service/system/aiSvc.go`、`aiProjector.go`、`aiMapper.go` +- AI sink/projector 测试:`internal/service/system/aiSink_test.go`、`aiProjector_test.go` +- 自动迁移:`flag/flagSql.go` + +# 改动 + +1. 删除实体字段: + - 从 `entity.AIMessage` 移除 `UIBlocksJSON`。 + - 后端不再映射数据库列 `ui_blocks_json`。 + +2. 删除响应协议: + - 从 `AssistantMessageResp` 移除 `UIBlocks` / `json:"ui_blocks"`。 + - 删除 `AssistantA2UIBinding`、`AssistantA2UIComponent`、`AssistantA2UISurface`、`AssistantA2UIBlock`。 + - 删除 `AssistantStructuredBlockPayload.UIBlock` 相关字段;若该 payload 只剩 scope,则改名或收缩为仅 scope 结构。 + +3. 删除业务读写: + - 移除创建消息时的 `UIBlocksJSON: "[]"`。 + - 移除 projector 落库时的 `p.message.UIBlocksJSON = "[]"`。 + - 移除 mapper 中 `decodeAssistantUIBlocks` 及相关调用。 + +4. 删除测试残留: + - 移除测试数据里的 `UIBlocksJSON` 初始化。 + - 移除断言 `UIBlocksJSON == "[]"`。 + +5. 数据库迁移: + - 在 `flag.SQL()` 中增加幂等迁移函数,如 `dropAIMessageUIBlocksColumn(db)`。 + - 若 `ai_messages.ui_blocks_json` 存在,则执行 `DROP COLUMN ui_blocks_json`。 + - 该迁移只处理 `ui_blocks_json`,不影响 `trace_items_json` 和 `scope_json`。 + +# 验证 + +- 执行 `go test ./internal/service/system -run "AIMessageProjector|AIStreamSink|AIService"`. +- 执行 `go test ./internal/model/... ./internal/repository/... ./internal/service/system/...`,若耗时过长则至少覆盖 AI 相关包。 +- 执行 `go test ./...`,如环境依赖导致失败,记录具体失败原因。 +- 使用 `rg "UIBlocksJSON|ui_blocks_json|UIBlocks|AssistantA2UI|A2UI"` 确认无实现残留;计划历史文件中的说明不作为阻塞项。 + +# 风险 + +- 这是破坏性协议变更:前端如果仍读取 `message.ui_blocks`,需要同步删除或兼容缺失字段。 +- 这是破坏性数据库变更:`DROP COLUMN ui_blocks_json` 会永久删除历史 UI block 数据。 +- GORM `AutoMigrate` 不会自动删列,所以必须显式迁移;生产执行前应确认该字段没有保留价值。 +- 如果后续恢复结构化 UI,需要重新设计独立协议或重新建表/字段。 + +# 执行顺序 + +1. 将本计划改名为 `approved-remove-ai-ui-blocks.md`。 +2. 删除 DTO 层 A2UI/UIBlocks 类型与字段。 +3. 删除实体字段和 service 层读写映射。 +4. 更新 projector/sink 相关测试。 +5. 增加 `ai_messages.ui_blocks_json` 幂等删列迁移。 +6. 运行验证命令并清理编译错误。 +7. 汇报改动范围、迁移影响和测试结果。 + +# 待确认 + +- 请确认是否按本计划执行,包括物理删除数据库列 `ai_messages.ui_blocks_json`。 From 6fbb8c408b0753b03cbd53087297fd9b2c745a2f Mon Sep 17 00:00:00 2001 From: wang Date: Fri, 24 Apr 2026 15:09:01 +0800 Subject: [PATCH 11/17] =?UTF-8?q?qdrant=E5=B7=B2=E7=BB=8F=E5=87=86?= =?UTF-8?q?=E5=A4=87=E5=A6=A5=E5=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 +- configs/configs.yaml | 13 +- ...5\256\345\272\223\351\205\215\347\275\256" | 199 ++++++++++++++++ global/global.go | 2 + go.mod | 41 ++-- go.sum | 109 +++++---- internal/core/config.go | 18 ++ internal/core/qdrant.go | 219 ++++++++++++++++++ internal/init/init.go | 8 + internal/model/config/config.go | 13 +- internal/model/config/qdrant.go | 29 ++- .../repository/system/observabilityDelete.go | 3 + .../system/observabilityMetricRepo.go | 31 ++- .../system/observabilityMetricRepo_test.go | 152 ++++++++++++ .../system/observabilityTraceRepo.go | 27 ++- .../system/observabilityTraceRepo_test.go | 163 +++++++++++++ .../approved-qdrant-client-collection-init.md | 32 +++ .../cleanup_soft_deleted_observability.ps1 | 126 ++++++++++ 18 files changed, 1123 insertions(+), 73 deletions(-) create mode 100644 "docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256" create mode 100644 internal/core/qdrant.go create mode 100644 internal/repository/system/observabilityDelete.go create mode 100644 internal/repository/system/observabilityMetricRepo_test.go create mode 100644 internal/repository/system/observabilityTraceRepo_test.go create mode 100644 plan/ai/approved-qdrant-client-collection-init.md create mode 100644 scripts/cleanup_soft_deleted_observability.ps1 diff --git a/.env.example b/.env.example index fb9ae7c..a5a8991 100644 --- a/.env.example +++ b/.env.example @@ -64,9 +64,18 @@ AI_TEMPERATURE=0.2 AI_MAX_COMPLETION_TOKENS=1200 # ======================== 可选:Qdrant 向量数据库 ======================== -# Qdrant HTTP API 地址;容器同网络可使用 http://qdrant:6333 +# Qdrant HTTP API 地址用于 REST 访问;Go client 使用 gRPC host/port +QDRANT_ENABLED=true QDRANT_ENDPOINT= +QDRANT_GRPC_HOST= +QDRANT_GRPC_PORT=6334 QDRANT_API_KEY= +QDRANT_COLLECTION_NAME=ai_knowledge_chunks +QDRANT_VECTOR_SIZE=1024 +QDRANT_DISTANCE=cosine +QDRANT_INIT_COLLECTION=true +QDRANT_TIMEOUT_SECONDS=10 +QDRANT_USE_TLS=false SSE_HEARTBEAT_INTERVAL_SECONDS=20 SSE_WRITE_TIMEOUT_SECONDS=10 diff --git a/configs/configs.yaml b/configs/configs.yaml index a4011c9..e30dad0 100644 --- a/configs/configs.yaml +++ b/configs/configs.yaml @@ -245,8 +245,17 @@ ai: temperature: 0.2 max_completion_tokens: 1200 qdrant: - endpoint: "" # Example: http://127.0.0.1:6333 - api_key: "" # Use QDRANT_API_KEY in .env for real credentials + enabled: true + endpoint: "" # HTTP/REST endpoint, example: http://127.0.0.1:6333 + grpc_host: "" # gRPC host for github.com/qdrant/go-client + grpc_port: 6334 + api_key: "" # Use QDRANT_API_KEY in .env for real credentials + collection_name: "ai_knowledge_chunks" + vector_size: 1024 + distance: "cosine" + init_collection: true + timeout_seconds: 10 + use_tls: false observability: enabled: true # 总开关 service_name: "personal_assistant" # 服务名维度(需稳定) diff --git "a/docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256" "b/docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256" new file mode 100644 index 0000000..ddbbe71 --- /dev/null +++ "b/docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256" @@ -0,0 +1,199 @@ +可以,按“启动链路 -> 连接逻辑 -> collection 初始化 -> 为什么这么设计”给你拆开讲。 + +**整体链路** +真正触发连接 Qdrant 的入口不在 `qdrant.go` 本身,而是在 [init.go](d:/workspace_go/test/go/personal_assistant/internal/init/init.go:25)。 + +启动顺序里,代码先做这几步: + +1. `godotenv.Load()` 读本地 `.env` +2. `core.InitConfig("configs")` 把 `configs.yaml + 环境变量` 装进 `global.Config` +3. `global.Log = core.InitLogger()` +4. `core.InitQdrant(context.Background())` +5. 成功后把返回的 client 放进 [global.go](d:/workspace_go/test/go/personal_assistant/global/global.go:21) 里的 `global.QdrantClient` + +对应位置是 [init.go:35](d:/workspace_go/test/go/personal_assistant/internal/init/init.go:35) 到 [init.go:40](d:/workspace_go/test/go/personal_assistant/internal/init/init.go:40)。 + +也就是说,Qdrant 是“启动期基础设施”,不是业务请求到来时临时连。 + +**配置是怎么进入这里的** +Qdrant 的配置结构在 [internal/model/config/qdrant.go](d:/workspace_go/test/go/personal_assistant/internal/model/config/qdrant.go:3)。 + +核心字段分成两类: + +- 连接参数:`Enabled`、`Endpoint`、`GRPCHost`、`GRPCPort`、`APIKey`、`UseTLS` +- collection 参数:`CollectionName`、`VectorSize`、`Distance`、`InitCollection`、`TimeoutSeconds` + +这些字段在 `config.go` 里从 viper 读取,再挂到 `global.Config.Qdrant`。所以 `InitQdrant()` 里第一件事就是拿 [qdrant.go:38](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:38) 的 `global.Config.Qdrant`。 + +**连接 Qdrant 的核心逻辑** +主入口是 [internal/core/qdrant.go:34](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:34) 的 `InitQdrant(ctx)`。 + +它的执行顺序是: + +1. 先检查 `global.Config` 是否为空 + 位置:[qdrant.go:35](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:35) + +2. 读取 `qdrantCfg := global.Config.Qdrant` + 位置:[qdrant.go:38](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:38) + +3. 如果 `qdrant.enabled=false`,直接跳过 + 位置:[qdrant.go:39](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:39) + 这里返回 `nil, nil`,表示“不启用,不报错”。 + +4. 调 `newQdrantClient(qdrantCfg)` 创建官方 Go client + 位置:[qdrant.go:44](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:44) + +5. 创建一个带超时的上下文 + 位置:[qdrant.go:49](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:49) + 超时时间来自 `qdrant.timeout_seconds`,如果没配或非法,就兜底 10 秒。 + +6. 对 Qdrant 做 `HealthCheck()` + 位置:[qdrant.go:52](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:52) + 这一层不是“真的做业务”,只是确认: + - 地址对不对 + - 端口通不通 + - API key 是否可用 + - TLS 配置是否正确 + - 服务是不是活着 + +7. 如果 `qdrant.init_collection=true`,继续做 collection 初始化/校验 + 位置:[qdrant.go:59](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:59) + +8. 全部通过后,把 client 返回给初始化编排层,最终挂到 `global.QdrantClient` + +**为什么我不用 6333,而是 6334** +这是这段逻辑里最容易混淆的点。 + +- `6333` 是 Qdrant 的 HTTP/REST 端口 +- `6334` 是 Qdrant 的 gRPC 端口 + +你这里用的是官方 Go SDK:`github.com/qdrant/go-client/qdrant`。这个 SDK 的高层 client 走的是 gRPC,所以真正建连时必须用 `grpc_host + grpc_port`,默认就是 `6334`。 + +对应代码在 [qdrant.go:74](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:74): + +- 先算出 host +- 再决定 port +- 然后调用 `qdrant.NewClient(...)` + +真正传进去的是: + +- `Host` +- `Port` +- `APIKey` +- `UseTLS` + +不是 `Endpoint` + +`Endpoint` 在这里的作用只是“兼容上一阶段配置”,当你没显式写 `grpc_host` 时,我会从 `endpoint` 里把 host 解析出来。这个逻辑在 [qdrant.go:167](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:167)。 + +优先级是: + +1. `GRPCHost` 有值,就直接用它 +2. `GRPCHost` 为空,就从 `Endpoint` 里解析 host +3. 两者都没有,就报错 + +也就是说: + +- `QDRANT_ENDPOINT=http://180.184.87.86:6333` +- `QDRANT_GRPC_HOST=180.184.87.86` +- `QDRANT_GRPC_PORT=6334` + +这三者现在是并存的,但真正给 Go client 用的是后两者。 + +**collection 初始化在干什么** +这一段在 [qdrant.go:105](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:105) 的 `ensureQdrantCollection(...)`。 + +它不是“往向量库写数据”,它做的是“确保将来可以安全写”。 + +执行顺序是: + +1. 校验本地配置是否合法 + 位置:[qdrant.go:110](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:110) + - `CollectionName` 不能为空 + - `VectorSize` 必须 > 0 + - `Distance` 要能转换成 Qdrant 枚举 + +2. 调 `CollectionExists()` 看 collection 在不在 + 位置:[qdrant.go:122](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:122) + +3. 如果不存在,就创建 + 位置:[qdrant.go:126](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:126) + +创建时用的是: + +- `collection_name = ai_knowledge_chunks` +- `size = 1024` +- `distance = cosine` + +也就是: + +```go +VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{ + Size: 1024, + Distance: qdrant.Distance_Cosine, +}) +``` + +4. 如果已经存在,就不是直接跳过,而是继续做 schema 校验 + 位置:[qdrant.go:140](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:140) + +它会读取已有 collection 的配置,然后检查: + +- 是不是单向量 collection +- 维度是不是 1024 +- 距离算法是不是 cosine + +如果不一致,直接报错,不让服务启动。 + +**为什么要校验而不是默认复用** +因为向量库最怕“表存在,但 schema 不对”。 + +比如你以后 embedding 模型吐的是 `1024` 维,但线上 collection 是 `1536` 维,后果不是“效果差一点”,而是后续写入和检索直接出问题。 + +所以我这里的策略是: + +- 不存在:创建 +- 已存在且匹配:通过 +- 已存在但不匹配:启动失败 + +这是典型的 fail-fast。 + +**`parseQdrantDistance()` 干了什么** +位置:[qdrant.go:188](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:188) + +因为配置文件里写字符串更自然: + +- `cosine` +- `dot` +- `euclid` +- `manhattan` + +但 SDK 需要的是枚举: + +- `qdrant.Distance_Cosine` +- `qdrant.Distance_Dot` +- ... + +所以这里做一次转换。这样配置层保持可读,SDK 细节被关在 core 层,不污染业务层。 + +**超时逻辑** +位置:[qdrant.go:206](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:206) + +`qdrantTimeout()` 的作用很简单: + +- 如果你配了 `timeout_seconds`,就用配置值 +- 如果没配或 <=0,就用 10 秒默认值 + +然后 [qdrant.go:49](d:/workspace_go/test/go/personal_assistant/internal/core/qdrant.go:49) 用它包一个 `context.WithTimeout(...)` + +这样启动时不会因为 Qdrant 网络抖动一直卡死。 + +**一句话总结这套逻辑** +这套连接逻辑本质上是: + +“启动时根据配置创建官方 gRPC client -> 做健康检查 -> 自动创建或严格校验基础 collection -> 成功后挂到 `global.QdrantClient` 供后续业务使用;任何关键步骤失败都直接阻断启动。” + +如果你愿意,我下一步可以继续给你讲两件事: + +1. 这套逻辑现在有哪些风险点/可以继续优化的地方 +2. 以后真正做“写入向量”和“检索”时,应该怎么接在这套初始化后面 \ No newline at end of file diff --git a/global/global.go b/global/global.go index b9d386f..6c7a2c4 100644 --- a/global/global.go +++ b/global/global.go @@ -12,6 +12,7 @@ import ( "github.com/casbin/casbin/v2" "github.com/gin-gonic/gin" "github.com/go-redis/redis/v8" + "github.com/qdrant/go-client/qdrant" "github.com/songzhibin97/gkit/cache/local_cache" "go.uber.org/zap" "gorm.io/gorm" @@ -22,6 +23,7 @@ var ( Log *zap.Logger // 全局日志实例 DB *gorm.DB // 全局数据库连接实例 Redis *redis.Client // 全局Redis客户端实例 + QdrantClient *qdrant.Client // 全局Qdrant客户端实例 BlackCache local_cache.Cache // 全局黑名单缓存实例 CasbinEnforcer *casbin.Enforcer // 全局Casbin执行器实例 Router *gin.Engine // 全局路由实例(用于API同步等功能) diff --git a/go.mod b/go.mod index 652ca55..597f678 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,22 @@ module personal_assistant -go 1.23.0 +go 1.24.0 toolchain go1.24.9 require ( + github.com/alicebob/miniredis/v2 v2.35.0 github.com/casbin/casbin/v2 v2.11.0 github.com/casbin/gorm-adapter/v3 v3.0.2 + github.com/cloudwego/eino v0.8.0 + github.com/cloudwego/eino-ext/components/model/ark v0.1.65 + github.com/cloudwego/eino-ext/components/model/openai v0.1.8 + github.com/cloudwego/eino-ext/components/model/qwen v0.1.8 github.com/fvbock/endless v0.0.0-20170109170031-447134032cb6 github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/sessions v1.0.4 github.com/gin-gonic/gin v1.11.0 + github.com/glebarez/sqlite v1.11.0 github.com/go-redis/redis/v8 v8.11.5 github.com/go-resty/resty/v2 v2.16.2 github.com/gofrs/uuid v4.4.0+incompatible @@ -20,6 +26,7 @@ require ( github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/mojocn/base64Captcha v1.3.8 github.com/natefinch/lumberjack v2.0.0+incompatible + github.com/qdrant/go-client v1.17.1 github.com/qiniu/go-sdk/v7 v7.25.6 github.com/robfig/cron/v3 v3.0.1 github.com/songzhibin97/gkit v1.2.13 @@ -27,7 +34,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/urfave/cli v1.22.17 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.41.0 + golang.org/x/crypto v0.48.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.0 ) @@ -37,20 +44,13 @@ require ( github.com/BurntSushi/toml v1.5.0 // indirect github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect - github.com/alicebob/miniredis/v2 v2.35.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cloudwego/eino v0.8.0 // indirect - github.com/cloudwego/eino-ext/adk/backend/local v0.2.2 // indirect - github.com/cloudwego/eino-ext/components/model/ark v0.1.65 // indirect - github.com/cloudwego/eino-ext/components/model/openai v0.1.8 // indirect - github.com/cloudwego/eino-ext/components/model/qwen v0.1.8 // indirect github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -62,7 +62,6 @@ require ( github.com/gammazero/toposort v0.1.1 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect @@ -74,6 +73,7 @@ require ( github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/goph/emperror v0.17.2 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -94,7 +94,6 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect github.com/microsoft/go-mssqldb v1.8.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -108,7 +107,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -128,13 +127,15 @@ require ( golang.org/x/arch v0.20.0 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/image v0.23.0 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.36.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 5121d60..72df9a9 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,6 @@ github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevB github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= -github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= -github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= @@ -43,12 +41,10 @@ github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqR github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= +github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/casbin/casbin/v2 v2.2.2/go.mod h1:XXtYGrs/0zlOsJMeRteEdVi/FsB0ph7KgNfjoCoJUD8= @@ -58,23 +54,19 @@ github.com/casbin/gorm-adapter/v3 v3.0.2 h1:4F2VFElwPyFzvHfgwizD2JQxk2OFLwvRFZct github.com/casbin/gorm-adapter/v3 v3.0.2/go.mod h1:mQI09sqvXfy5p6kZB5HBzZrgKWwxaJ4xMWpd5OGfHRY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/eino v0.8.0 h1:DLbrgEAloA+l7aR2qim7qQocQB48DjPrb8LzG3PYMHY= github.com/cloudwego/eino v0.8.0/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU= -github.com/cloudwego/eino-ext/adk/backend/local v0.2.2 h1:IWuzl4uZf4IkMN98ieRe9Ajl9E8L90twJh7gFBPXOrQ= -github.com/cloudwego/eino-ext/adk/backend/local v0.2.2/go.mod h1:os5Tq5FuSoz/MLqAdZER3ip49Oef9prc0kVsKsPYO48= github.com/cloudwego/eino-ext/components/model/ark v0.1.65 h1:52ukXVU9ntToTa36SwI8be81qskGkpUEZraIFOf0wqk= github.com/cloudwego/eino-ext/components/model/ark v0.1.65/go.mod h1:aabMR15RTXBSi9Eu13CWavzE+no5BQO4FJUEEdqImbg= github.com/cloudwego/eino-ext/components/model/openai v0.1.8 h1:uVCE8nNvbhD37xGFgdKESWjvChDSkCAMA+DodhFRBaM= github.com/cloudwego/eino-ext/components/model/openai v0.1.8/go.mod h1:K6g2VgULehhJC5dgFdPW3u7gZNZ1p6DhnfA5UhkRpNY= github.com/cloudwego/eino-ext/components/model/qwen v0.1.8 h1:TFKuEBLbJhV1V5c2OLJ3kbyysHIGwx8hFkW9NnNJwyM= github.com/cloudwego/eino-ext/components/model/qwen v0.1.8/go.mod h1:/PepNUOuofBYYqOM4ZOBN9q7uf+WoFv8ReRrbUn9HaA= -github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ= -github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 h1:q242n5P5Tx3a2QLaBmkfEpfRs/o17Ac6u3EAgItEEOc= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -125,6 +117,12 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -183,6 +181,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -195,12 +195,16 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -289,6 +293,7 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= @@ -297,6 +302,8 @@ github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -330,6 +337,7 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -338,12 +346,10 @@ github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2y github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs= -github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -381,6 +387,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/qdrant/go-client v1.17.1 h1:7QmPwDddrHL3hC4NfycwtQlraVKRLcRi++BX6TTm+3g= +github.com/qdrant/go-client v1.17.1/go.mod h1:n1h6GhkdAzcohoXt/5Z19I2yxbCkMA6Jejob3S6NZT8= github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk= github.com/qiniu/go-sdk/v7 v7.25.6 h1:89KQX16Bv2x7MxhwpzWGGvQBOPIlGpAcnPQyfS3tRok= github.com/qiniu/go-sdk/v7 v7.25.6/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o= @@ -414,10 +422,14 @@ github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhr github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/songzhibin97/gkit v1.2.13 h1:paY0XJkdRuy9/8k9nTnbdrzo8pC22jIIFldUkOQv5nU= github.com/songzhibin97/gkit v1.2.13/go.mod h1:38CreNR27eTGaG1UMGihrXqI4xc3nGfYxLVKKVx6Ngg= github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= @@ -466,12 +478,26 @@ github.com/volcengine/volcengine-go-sdk v1.2.9 h1:du2gnImtyWXKkQFnJW/GXCs+UBibGG github.com/volcengine/volcengine-go-sdk v1.2.9/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -515,8 +541,8 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= @@ -533,8 +559,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -559,8 +585,8 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -572,8 +598,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -591,7 +617,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -606,8 +631,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -624,6 +649,8 @@ golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -641,10 +668,10 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -660,21 +687,27 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -686,8 +719,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -715,8 +748,6 @@ gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqK gorm.io/driver/postgres v0.2.6/go.mod h1:AsPyuhKFOplSmQwOPsycVKbe0dRxF8v18KZ7p9i8dIs= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlserver v0.2.4/go.mod h1:TcPfkdce5b8qlCMgyUeUdm7HQa1ZzWUuxzI+odcueLA= gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= diff --git a/internal/core/config.go b/internal/core/config.go index 1a65e2c..4fe17f3 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -62,8 +62,17 @@ func InitConfig(path string) { viper.SetDefault("ai.system_prompt", "你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。") viper.SetDefault("ai.temperature", 0.2) viper.SetDefault("ai.max_completion_tokens", 1200) + viper.SetDefault("qdrant.enabled", true) viper.SetDefault("qdrant.endpoint", "") + viper.SetDefault("qdrant.grpc_host", "") + viper.SetDefault("qdrant.grpc_port", 6334) viper.SetDefault("qdrant.api_key", "") + viper.SetDefault("qdrant.collection_name", "ai_knowledge_chunks") + viper.SetDefault("qdrant.vector_size", 1024) + viper.SetDefault("qdrant.distance", "cosine") + viper.SetDefault("qdrant.init_collection", true) + viper.SetDefault("qdrant.timeout_seconds", 10) + viper.SetDefault("qdrant.use_tls", false) viper.SetDefault("rate_limit.oj_bind.limit", 3) viper.SetDefault("rate_limit.oj_bind.window_sec", 10) viper.SetDefault("observability.propagation.enabled", true) @@ -253,8 +262,17 @@ func InitConfig(path string) { _ = viper.BindEnv("ai.system_prompt", "AI_SYSTEM_PROMPT") _ = viper.BindEnv("ai.temperature", "AI_TEMPERATURE") _ = viper.BindEnv("ai.max_completion_tokens", "AI_MAX_COMPLETION_TOKENS") + _ = viper.BindEnv("qdrant.enabled", "QDRANT_ENABLED") _ = viper.BindEnv("qdrant.endpoint", "QDRANT_ENDPOINT") + _ = viper.BindEnv("qdrant.grpc_host", "QDRANT_GRPC_HOST") + _ = viper.BindEnv("qdrant.grpc_port", "QDRANT_GRPC_PORT") _ = viper.BindEnv("qdrant.api_key", "QDRANT_API_KEY") + _ = viper.BindEnv("qdrant.collection_name", "QDRANT_COLLECTION_NAME") + _ = viper.BindEnv("qdrant.vector_size", "QDRANT_VECTOR_SIZE") + _ = viper.BindEnv("qdrant.distance", "QDRANT_DISTANCE") + _ = viper.BindEnv("qdrant.init_collection", "QDRANT_INIT_COLLECTION") + _ = viper.BindEnv("qdrant.timeout_seconds", "QDRANT_TIMEOUT_SECONDS") + _ = viper.BindEnv("qdrant.use_tls", "QDRANT_USE_TLS") _ = viper.BindEnv("observability.enabled", "OBSERVABILITY_ENABLED") _ = viper.BindEnv("observability.service_name", "OBSERVABILITY_SERVICE_NAME") _ = viper.BindEnv("observability.service_trace.enabled", "OBSERVABILITY_SERVICE_TRACE_ENABLED") diff --git a/internal/core/qdrant.go b/internal/core/qdrant.go new file mode 100644 index 0000000..2626d95 --- /dev/null +++ b/internal/core/qdrant.go @@ -0,0 +1,219 @@ +package core + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "personal_assistant/global" + "personal_assistant/internal/model/config" + + "github.com/qdrant/go-client/qdrant" + "go.uber.org/zap" +) + +// InitQdrant 初始化项目级 Qdrant client,并按配置确保基础 collection 可用。 +// +// 参数: +// - ctx:启动期上下文;为空时会回退到 context.Background()。 +// +// 返回值: +// - *qdrant.Client:初始化成功后的官方 Qdrant gRPC client;禁用时返回 nil。 +// - error:配置缺失、连接失败、健康检查失败或 collection 校验失败时返回原始错误链。 +// +// 启动策略: +// - qdrant.enabled=false 时跳过初始化,不影响主服务启动。 +// - qdrant.enabled=true 时采用 fail-fast,避免后续 RAG/向量能力在运行期才暴露不可用。 +// +// 边界说明: +// - 本函数只做基础设施装配和 collection 准备,不负责 embedding、索引写入或检索业务。 +// - 具体退出进程的决策由 internal/init 编排层处理,便于测试和复用。 +func InitQdrant(ctx context.Context) (*qdrant.Client, error) { + if global.Config == nil { + return nil, errors.New("global config is nil") + } + qdrantCfg := global.Config.Qdrant + if !qdrantCfg.Enabled { + global.Log.Info("Qdrant initialization skipped: disabled") + return nil, nil + } + + client, err := newQdrantClient(qdrantCfg) + if err != nil { + return nil, err + } + + runCtx, cancel := context.WithTimeout(normalizeContext(ctx), qdrantTimeout(qdrantCfg)) + defer cancel() + + health, err := client.HealthCheck(runCtx) + if err != nil { + _ = client.Close() + return nil, fmt.Errorf("qdrant health check failed: %w", err) + } + global.Log.Info("Qdrant health check succeeded", zap.String("version", health.GetVersion())) + + if qdrantCfg.InitCollection { + if err := ensureQdrantCollection(runCtx, client, qdrantCfg); err != nil { + _ = client.Close() + return nil, err + } + } + return client, nil +} + +// newQdrantClient 根据配置创建官方 Qdrant gRPC client。 +// +// 说明: +// - github.com/qdrant/go-client 使用 gRPC 协议,端口应指向 6334,而不是 HTTP/REST 的 6333。 +// - APIKey 只在 client 配置中传递,不写入日志,避免敏感信息泄漏。 +// - 这里只负责构造连接对象,连通性由 InitQdrant 的 HealthCheck 统一验证。 +func newQdrantClient(qdrantCfg config.Qdrant) (*qdrant.Client, error) { + host, err := qdrantGRPCHost(qdrantCfg) + if err != nil { + return nil, err + } + port := qdrantCfg.GRPCPort + if port <= 0 { + port = 6334 + } + client, err := qdrant.NewClient(&qdrant.Config{ + Host: host, + Port: port, + APIKey: strings.TrimSpace(qdrantCfg.APIKey), + UseTLS: qdrantCfg.UseTLS, + }) + if err != nil { + return nil, fmt.Errorf("create qdrant client failed: %w", err) + } + return client, nil +} + +// ensureQdrantCollection 确保配置指定的单向量 collection 已存在且 schema 匹配。 +// +// 核心流程: +// 1. 校验 collection 名称、向量维度和距离算法配置。 +// 2. 调用 CollectionExists 判断是否已存在。 +// 3. 不存在则按配置创建;已存在则读取 collection 信息并校验向量参数。 +// +// 生产约束: +// - 已存在 collection 的维度或距离算法不匹配时必须返回错误。 +// - 这里不自动删除或重建 collection,避免误删线上已有向量数据。 +func ensureQdrantCollection( + ctx context.Context, + client *qdrant.Client, + qdrantCfg config.Qdrant, +) error { + collectionName := strings.TrimSpace(qdrantCfg.CollectionName) + if collectionName == "" { + return errors.New("qdrant collection name is empty") + } + if qdrantCfg.VectorSize <= 0 { + return fmt.Errorf("qdrant vector size must be positive: %d", qdrantCfg.VectorSize) + } + distance, err := parseQdrantDistance(qdrantCfg.Distance) + if err != nil { + return err + } + + exists, err := client.CollectionExists(ctx, collectionName) + if err != nil { + return fmt.Errorf("check qdrant collection failed: %w", err) + } + if !exists { + if err := client.CreateCollection(ctx, &qdrant.CreateCollection{ + CollectionName: collectionName, + VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{ + Size: uint64(qdrantCfg.VectorSize), + Distance: distance, + }), + }); err != nil { + return fmt.Errorf("create qdrant collection failed: %w", err) + } + global.Log.Info("Qdrant collection created", zap.String("collection", collectionName)) + return nil + } + + info, err := client.GetCollectionInfo(ctx, collectionName) + if err != nil { + return fmt.Errorf("get qdrant collection info failed: %w", err) + } + // 只接受单向量 collection;多向量 collection 需要明确的向量名映射,不能静默兼容。 + params := info.GetConfig().GetParams().GetVectorsConfig().GetParams() + if params == nil { + return fmt.Errorf("qdrant collection %q is not a single-vector collection", collectionName) + } + if params.GetSize() != uint64(qdrantCfg.VectorSize) || params.GetDistance() != distance { + return fmt.Errorf( + "qdrant collection %q config mismatch: got size=%d distance=%s, want size=%d distance=%s", + collectionName, + params.GetSize(), + params.GetDistance().String(), + qdrantCfg.VectorSize, + distance.String(), + ) + } + global.Log.Info("Qdrant collection verified", zap.String("collection", collectionName)) + return nil +} + +// qdrantGRPCHost 返回官方 Go client 所需的 gRPC host。 +// +// GRPCHost 优先级高于 Endpoint;Endpoint 仅作为兼容上一阶段 HTTP/REST 配置的 host 来源。 +// 注意这里不会复用 Endpoint 端口,因为官方 Go client 连接的是 gRPC 端口,默认 6334。 +func qdrantGRPCHost(qdrantCfg config.Qdrant) (string, error) { + if host := strings.TrimSpace(qdrantCfg.GRPCHost); host != "" { + return host, nil + } + endpoint := strings.TrimSpace(qdrantCfg.Endpoint) + if endpoint == "" { + return "", errors.New("qdrant grpc_host or endpoint is required") + } + parsed, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("parse qdrant endpoint failed: %w", err) + } + if host := parsed.Hostname(); host != "" { + return host, nil + } + return "", fmt.Errorf("qdrant endpoint has no host: %q", endpoint) +} + +// parseQdrantDistance 将配置中的稳定字符串转换为 Qdrant gRPC 枚举。 +// +// 配置层保留字符串是为了让 .env/configs.yaml 可读;转换集中在 core 层,避免业务层依赖 SDK 枚举。 +func parseQdrantDistance(distance string) (qdrant.Distance, error) { + switch strings.ToLower(strings.TrimSpace(distance)) { + case "", "cosine": + return qdrant.Distance_Cosine, nil + case "dot": + return qdrant.Distance_Dot, nil + case "euclid", "euclidean": + return qdrant.Distance_Euclid, nil + case "manhattan": + return qdrant.Distance_Manhattan, nil + default: + return qdrant.Distance_UnknownDistance, fmt.Errorf("unsupported qdrant distance: %q", distance) + } +} + +// qdrantTimeout 返回启动期 Qdrant 操作超时时间。 +// +// 配置缺失或非法时使用 10 秒兜底,避免启动阶段长期卡死。 +func qdrantTimeout(qdrantCfg config.Qdrant) time.Duration { + if qdrantCfg.TimeoutSeconds <= 0 { + return 10 * time.Second + } + return time.Duration(qdrantCfg.TimeoutSeconds) * time.Second +} + +// normalizeContext 统一处理 nil context,避免启动编排或测试替身传空值时 panic。 +func normalizeContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} diff --git a/internal/init/init.go b/internal/init/init.go index 29b2ffb..561a6d1 100644 --- a/internal/init/init.go +++ b/internal/init/init.go @@ -32,6 +32,14 @@ func Init() { // 初始化日志 global.Log = core.InitLogger() + // 初始化 Qdrant 客户端(依赖配置;失败会导致 RAG/向量能力不可用) + qdrantClient, err := core.InitQdrant(context.Background()) + if err != nil { + global.Log.Error("init qdrant failed", zap.Error(err)) + os.Exit(1) + } + global.QdrantClient = qdrantClient + // 敏感数据编解码器(依赖配置) if err := core.InitSensitiveDataCodec(); err != nil { global.Log.Error("init sensitive data codec failed", zap.Error(err)) diff --git a/internal/model/config/config.go b/internal/model/config/config.go index 2c1082c..8849816 100644 --- a/internal/model/config/config.go +++ b/internal/model/config/config.go @@ -325,8 +325,17 @@ func NewConfig() *Config { } _qdrant := &Qdrant{ - Endpoint: viper.GetString("qdrant.endpoint"), - APIKey: viper.GetString("qdrant.api_key"), + Enabled: viper.GetBool("qdrant.enabled"), + Endpoint: viper.GetString("qdrant.endpoint"), + GRPCHost: viper.GetString("qdrant.grpc_host"), + GRPCPort: viper.GetInt("qdrant.grpc_port"), + APIKey: viper.GetString("qdrant.api_key"), + CollectionName: viper.GetString("qdrant.collection_name"), + VectorSize: viper.GetInt("qdrant.vector_size"), + Distance: viper.GetString("qdrant.distance"), + InitCollection: viper.GetBool("qdrant.init_collection"), + TimeoutSeconds: viper.GetInt("qdrant.timeout_seconds"), + UseTLS: viper.GetBool("qdrant.use_tls"), } _observability := &Observability{ diff --git a/internal/model/config/qdrant.go b/internal/model/config/qdrant.go index 7f100ae..1904e4c 100644 --- a/internal/model/config/qdrant.go +++ b/internal/model/config/qdrant.go @@ -1,7 +1,30 @@ package config -// Qdrant describes vector database connection settings. +// Qdrant 描述项目级向量数据库连接和启动期 collection 初始化配置。 +// +// 注意: +// - Endpoint 保留给 HTTP/REST 访问语义,官方 Go client 实际使用 GRPCHost/GRPCPort。 +// - VectorSize 必须和后续 embedding 模型输出维度一致,否则写入向量时会失败。 +// - APIKey 属于敏感配置,只允许通过 .env 或运行环境注入,禁止写入模板真实值。 type Qdrant struct { - Endpoint string `json:"endpoint" yaml:"endpoint"` - APIKey string `json:"api_key" yaml:"api_key"` + Enabled bool `json:"enabled" yaml:"enabled"` // 是否启用 Qdrant 初始化;开启后连接失败会阻断启动 + Endpoint string `json:"endpoint" yaml:"endpoint"` // Qdrant HTTP/REST 地址,例如 http://127.0.0.1:6333 + GRPCHost string `json:"grpc_host" yaml:"grpc_host"` // Qdrant gRPC host;为空时从 Endpoint 中解析 host + GRPCPort int `json:"grpc_port" yaml:"grpc_port"` // Qdrant gRPC 端口,官方 Go client 默认使用 6334 + APIKey string `json:"api_key" yaml:"api_key"` // Qdrant API key;仅从安全环境变量读取真实值 + + // CollectionName 是启动期需要确保存在的单向量 collection 名称。 + CollectionName string `json:"collection_name" yaml:"collection_name"` + + // VectorSize 是 collection 的向量维度,必须和 embedding 模型输出保持一致。 + VectorSize int `json:"vector_size" yaml:"vector_size"` + + // Distance 是向量相似度算法,当前支持 cosine、dot、euclid/euclidean、manhattan。 + Distance string `json:"distance" yaml:"distance"` + + InitCollection bool `json:"init_collection" yaml:"init_collection"` // 是否在启动期自动创建/校验 collection + + // TimeoutSeconds 控制 health check、collection 创建和校验的启动期超时时间。 + TimeoutSeconds int `json:"timeout_seconds" yaml:"timeout_seconds"` + UseTLS bool `json:"use_tls" yaml:"use_tls"` // 是否使用 TLS 连接 Qdrant gRPC 服务 } diff --git a/internal/repository/system/observabilityDelete.go b/internal/repository/system/observabilityDelete.go new file mode 100644 index 0000000..192a1ed --- /dev/null +++ b/internal/repository/system/observabilityDelete.go @@ -0,0 +1,3 @@ +package system + +const observabilityDeleteBatchSize = 1000 diff --git a/internal/repository/system/observabilityMetricRepo.go b/internal/repository/system/observabilityMetricRepo.go index 13f8a2f..b31a825 100644 --- a/internal/repository/system/observabilityMetricRepo.go +++ b/internal/repository/system/observabilityMetricRepo.go @@ -184,9 +184,34 @@ func (r *observabilityMetricRepository) DeleteBeforeByGranularity( granularity string, before time.Time, ) error { - return r.db.WithContext(ctx). - Where("granularity = ? AND bucket_start < ?", granularity, before). - Delete(&entity.ObservabilityMetric{}).Error + granularity = strings.TrimSpace(granularity) + if granularity == "" || before.IsZero() { + return nil + } + + // 使用默认作用域先筛出“仍活跃”的目标行,再按 ID 分批 Unscoped 物理删除, + // 避免大表保留清理落成单次长事务,同时不误删历史已软删除数据。 + for { + var ids []uint + if err := r.db.WithContext(ctx). + Model(&entity.ObservabilityMetric{}). + Where("granularity = ? AND bucket_start < ?", granularity, before). + Order("bucket_start ASC"). + Order("id ASC"). + Limit(observabilityDeleteBatchSize). + Pluck("id", &ids).Error; err != nil { + return err + } + if len(ids) == 0 { + return nil + } + if err := r.db.WithContext(ctx). + Unscoped(). + Where("id IN ?", ids). + Delete(&entity.ObservabilityMetric{}).Error; err != nil { + return err + } + } } func aggregateBucketExpr(granularity string) string { diff --git a/internal/repository/system/observabilityMetricRepo_test.go b/internal/repository/system/observabilityMetricRepo_test.go new file mode 100644 index 0000000..7d9f280 --- /dev/null +++ b/internal/repository/system/observabilityMetricRepo_test.go @@ -0,0 +1,152 @@ +package system + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "personal_assistant/internal/model/entity" +) + +func TestObservabilityMetricRepositoryDeleteBeforeByGranularityHardDeletesExpiredRows(t *testing.T) { + db := newObservabilityRepositoryTestDB(t, &entity.ObservabilityMetric{}) + repo := NewObservabilityMetricRepository(db) + ctx := context.Background() + cutoff := time.Date(2026, 4, 24, 2, 10, 0, 0, time.UTC) + + expiredActive := seedObservabilityMetric(t, db, metricSeedOptions{ + granularity: "1m", + bucketStart: cutoff.Add(-2 * time.Hour), + suffix: "expired-active", + }) + freshActive := seedObservabilityMetric(t, db, metricSeedOptions{ + granularity: "1m", + bucketStart: cutoff.Add(30 * time.Minute), + suffix: "fresh-active", + }) + expiredSoftDeleted := seedObservabilityMetric(t, db, metricSeedOptions{ + granularity: "1m", + bucketStart: cutoff.Add(-3 * time.Hour), + suffix: "expired-soft-deleted", + }) + if err := db.Delete(expiredSoftDeleted).Error; err != nil { + t.Fatalf("soft delete metric: %v", err) + } + + if err := repo.DeleteBeforeByGranularity(ctx, "1m", cutoff); err != nil { + t.Fatalf("DeleteBeforeByGranularity() error = %v", err) + } + + assertMetricMissingUnscoped(t, db, expiredActive.ID) + assertMetricExists(t, db, freshActive.ID) + + var softDeleted entity.ObservabilityMetric + if err := db.Unscoped().First(&softDeleted, expiredSoftDeleted.ID).Error; err != nil { + t.Fatalf("load soft-deleted metric: %v", err) + } + if !softDeleted.DeletedAt.Valid { + t.Fatalf("soft-deleted metric deleted_at valid = false, want true") + } +} + +func TestObservabilityMetricRepositoryDeleteBeforeByGranularityDeletesInBatches(t *testing.T) { + db := newObservabilityRepositoryTestDB(t, &entity.ObservabilityMetric{}) + repo := NewObservabilityMetricRepository(db) + ctx := context.Background() + cutoff := time.Date(2026, 4, 24, 2, 10, 0, 0, time.UTC) + + totalExpired := observabilityDeleteBatchSize + 25 + for i := 0; i < totalExpired; i++ { + seedObservabilityMetric(t, db, metricSeedOptions{ + granularity: "5m", + bucketStart: cutoff.Add(-time.Duration(i+1) * time.Minute), + suffix: fmt.Sprintf("expired-%d", i), + }) + } + freshActive := seedObservabilityMetric(t, db, metricSeedOptions{ + granularity: "5m", + bucketStart: cutoff.Add(10 * time.Minute), + suffix: "fresh-active", + }) + + if err := repo.DeleteBeforeByGranularity(ctx, "5m", cutoff); err != nil { + t.Fatalf("DeleteBeforeByGranularity() error = %v", err) + } + + var expiredRemaining int64 + if err := db.Model(&entity.ObservabilityMetric{}). + Where("granularity = ? AND bucket_start < ?", "5m", cutoff). + Count(&expiredRemaining).Error; err != nil { + t.Fatalf("count expired metrics: %v", err) + } + if expiredRemaining != 0 { + t.Fatalf("expiredRemaining = %d, want 0", expiredRemaining) + } + assertMetricExists(t, db, freshActive.ID) +} + +type metricSeedOptions struct { + granularity string + bucketStart time.Time + suffix string +} + +func newObservabilityRepositoryTestDB(t *testing.T, models ...interface{}) *gorm.DB { + t.Helper() + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(models...); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return db +} + +func seedObservabilityMetric(t *testing.T, db *gorm.DB, opt metricSeedOptions) *entity.ObservabilityMetric { + t.Helper() + + row := &entity.ObservabilityMetric{ + Granularity: opt.granularity, + BucketStart: opt.bucketStart, + Service: "personal_assistant", + RouteTemplate: "/test/" + opt.suffix, + Method: "GET", + StatusClass: 2, + ErrorCode: "", + RequestCount: 1, + ErrorCount: 0, + TotalLatencyMs: 20, + MaxLatencyMs: 20, + } + if err := db.Create(row).Error; err != nil { + t.Fatalf("create metric: %v", err) + } + return row +} + +func assertMetricExists(t *testing.T, db *gorm.DB, id uint) { + t.Helper() + + var row entity.ObservabilityMetric + if err := db.First(&row, id).Error; err != nil { + t.Fatalf("metric %d should exist: %v", id, err) + } +} + +func assertMetricMissingUnscoped(t *testing.T, db *gorm.DB, id uint) { + t.Helper() + + var row entity.ObservabilityMetric + err := db.Unscoped().First(&row, id).Error + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("metric %d should be physically deleted, got err = %v", id, err) + } +} diff --git a/internal/repository/system/observabilityTraceRepo.go b/internal/repository/system/observabilityTraceRepo.go index fe2e79f..73bd3de 100644 --- a/internal/repository/system/observabilityTraceRepo.go +++ b/internal/repository/system/observabilityTraceRepo.go @@ -279,7 +279,28 @@ func (r *observabilityTraceRepository) DeleteBeforeByStatus( if status == "" || before.IsZero() { return nil } - return r.db.WithContext(ctx). - Where("status = ? AND start_at < ?", status, before). - Delete(&entity.ObservabilityTraceSpan{}).Error + + // 对 trace 明细使用状态+时间有序分批硬删,避免千万级历史数据在一次清理里形成超大事务。 + for { + var ids []uint + if err := r.db.WithContext(ctx). + Model(&entity.ObservabilityTraceSpan{}). + Where("status = ? AND start_at < ?", status, before). + Order("status ASC"). + Order("start_at ASC"). + Order("id ASC"). + Limit(observabilityDeleteBatchSize). + Pluck("id", &ids).Error; err != nil { + return err + } + if len(ids) == 0 { + return nil + } + if err := r.db.WithContext(ctx). + Unscoped(). + Where("id IN ?", ids). + Delete(&entity.ObservabilityTraceSpan{}).Error; err != nil { + return err + } + } } diff --git a/internal/repository/system/observabilityTraceRepo_test.go b/internal/repository/system/observabilityTraceRepo_test.go new file mode 100644 index 0000000..88c4256 --- /dev/null +++ b/internal/repository/system/observabilityTraceRepo_test.go @@ -0,0 +1,163 @@ +package system + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "personal_assistant/internal/model/entity" +) + +func TestObservabilityTraceRepositoryDeleteBeforeByStatusHardDeletesExpiredRows(t *testing.T) { + db := newObservabilityTraceRepositoryTestDB(t) + repo := NewObservabilityTraceRepository(db) + ctx := context.Background() + cutoff := time.Date(2026, 4, 24, 2, 30, 0, 0, time.UTC) + + expiredOK := seedObservabilityTraceSpan(t, db, traceSeedOptions{ + status: "ok", + startAt: cutoff.Add(-2 * time.Hour), + suffix: "expired-ok", + }) + freshOK := seedObservabilityTraceSpan(t, db, traceSeedOptions{ + status: "ok", + startAt: cutoff.Add(20 * time.Minute), + suffix: "fresh-ok", + }) + expiredError := seedObservabilityTraceSpan(t, db, traceSeedOptions{ + status: "error", + startAt: cutoff.Add(-90 * time.Minute), + suffix: "expired-error", + }) + expiredSoftDeletedOK := seedObservabilityTraceSpan(t, db, traceSeedOptions{ + status: "ok", + startAt: cutoff.Add(-3 * time.Hour), + suffix: "expired-soft-deleted-ok", + }) + if err := db.Delete(expiredSoftDeletedOK).Error; err != nil { + t.Fatalf("soft delete trace: %v", err) + } + + if err := repo.DeleteBeforeByStatus(ctx, "ok", cutoff); err != nil { + t.Fatalf("DeleteBeforeByStatus() error = %v", err) + } + + assertTraceMissingUnscoped(t, db, expiredOK.ID) + assertTraceExists(t, db, freshOK.ID) + assertTraceExists(t, db, expiredError.ID) + + var softDeleted entity.ObservabilityTraceSpan + if err := db.Unscoped().First(&softDeleted, expiredSoftDeletedOK.ID).Error; err != nil { + t.Fatalf("load soft-deleted trace: %v", err) + } + if !softDeleted.DeletedAt.Valid { + t.Fatalf("soft-deleted trace deleted_at valid = false, want true") + } +} + +func TestObservabilityTraceRepositoryDeleteBeforeByStatusDeletesInBatches(t *testing.T) { + db := newObservabilityTraceRepositoryTestDB(t) + repo := NewObservabilityTraceRepository(db) + ctx := context.Background() + cutoff := time.Date(2026, 4, 24, 2, 30, 0, 0, time.UTC) + + totalExpired := observabilityDeleteBatchSize + 25 + for i := 0; i < totalExpired; i++ { + seedObservabilityTraceSpan(t, db, traceSeedOptions{ + status: "error", + startAt: cutoff.Add(-time.Duration(i+1) * time.Second), + suffix: fmt.Sprintf("expired-error-%d", i), + }) + } + freshError := seedObservabilityTraceSpan(t, db, traceSeedOptions{ + status: "error", + startAt: cutoff.Add(5 * time.Minute), + suffix: "fresh-error", + }) + + if err := repo.DeleteBeforeByStatus(ctx, "error", cutoff); err != nil { + t.Fatalf("DeleteBeforeByStatus() error = %v", err) + } + + var expiredRemaining int64 + if err := db.Model(&entity.ObservabilityTraceSpan{}). + Where("status = ? AND start_at < ?", "error", cutoff). + Count(&expiredRemaining).Error; err != nil { + t.Fatalf("count expired traces: %v", err) + } + if expiredRemaining != 0 { + t.Fatalf("expiredRemaining = %d, want 0", expiredRemaining) + } + assertTraceExists(t, db, freshError.ID) +} + +type traceSeedOptions struct { + status string + startAt time.Time + suffix string +} + +func newObservabilityTraceRepositoryTestDB(t *testing.T) *gorm.DB { + t.Helper() + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&entity.ObservabilityTraceSpan{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return db +} + +func seedObservabilityTraceSpan(t *testing.T, db *gorm.DB, opt traceSeedOptions) *entity.ObservabilityTraceSpan { + t.Helper() + + row := &entity.ObservabilityTraceSpan{ + SpanID: "span-" + opt.suffix, + ParentSpanID: "", + TraceID: "trace-" + opt.suffix, + RequestID: "request-" + opt.suffix, + Service: "personal_assistant", + Stage: "controller", + Name: "controller.test." + opt.suffix, + Kind: "internal", + Status: opt.status, + StartAt: opt.startAt, + EndAt: opt.startAt.Add(2 * time.Millisecond), + DurationMs: 2, + ErrorCode: "", + Message: "", + TagsJSON: "{}", + ErrorDetailJSON: "{}", + } + if err := db.Create(row).Error; err != nil { + t.Fatalf("create trace span: %v", err) + } + return row +} + +func assertTraceExists(t *testing.T, db *gorm.DB, id uint) { + t.Helper() + + var row entity.ObservabilityTraceSpan + if err := db.First(&row, id).Error; err != nil { + t.Fatalf("trace %d should exist: %v", id, err) + } +} + +func assertTraceMissingUnscoped(t *testing.T, db *gorm.DB, id uint) { + t.Helper() + + var row entity.ObservabilityTraceSpan + err := db.Unscoped().First(&row, id).Error + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("trace %d should be physically deleted, got err = %v", id, err) + } +} diff --git a/plan/ai/approved-qdrant-client-collection-init.md b/plan/ai/approved-qdrant-client-collection-init.md new file mode 100644 index 0000000..206f7f4 --- /dev/null +++ b/plan/ai/approved-qdrant-client-collection-init.md @@ -0,0 +1,32 @@ +# Qdrant Client And Collection Init + +# Goal + +Initialize the official Qdrant Go client and ensure the base vector collection exists at application startup. + +# Scope + +- Add Qdrant gRPC client configuration alongside the existing HTTP endpoint. +- Initialize a global Qdrant client in the runtime infrastructure layer. +- Create or validate the `ai_knowledge_chunks` collection with 1024-dimensional cosine vectors. +- Do not add RAG, embedding, indexer, retriever, or API behavior in this task. + +# Changes + +- Extend `internal/model/config.Qdrant` with enabled, gRPC, collection, timeout, and TLS settings. +- Bind the new `QDRANT_*` environment variables and defaults in `internal/core/config.go`. +- Add `internal/core/qdrant.go` for health check, client creation, collection existence check, creation, and existing schema validation. +- Add `global.QdrantClient` and initialize it from `internal/init/init.go` after config and logger setup. +- Add `github.com/qdrant/go-client` as the official gRPC client dependency. + +# Verification + +- Run `go test ./internal/model/config ./internal/core ./internal/infrastructure/ai/...`. +- Verify `.env.example` and `configs/configs.yaml` contain only Qdrant placeholders. +- Verify local `.env` contains the required Qdrant keys without printing secrets. + +# Assumptions + +- The Go client uses Qdrant gRPC on port `6334`; HTTP/REST remains on `6333`. +- The first collection is `ai_knowledge_chunks`, vector size `1024`, distance `cosine`. +- If Qdrant is enabled and initialization fails, startup must fail fast. diff --git a/scripts/cleanup_soft_deleted_observability.ps1 b/scripts/cleanup_soft_deleted_observability.ps1 new file mode 100644 index 0000000..ad26c97 --- /dev/null +++ b/scripts/cleanup_soft_deleted_observability.ps1 @@ -0,0 +1,126 @@ +# Cleanup script for already soft-deleted observability rows. +# +# Usage examples: +# ./scripts/cleanup_soft_deleted_observability.ps1 -MySqlCli "mysql" -MySqlDB "meta_assist" +# ./scripts/cleanup_soft_deleted_observability.ps1 -MySqlHost "127.0.0.1" -MySqlPort 3306 -MySqlUser "root" -MySqlPassword "secret" -MySqlDB "meta_assist" -BatchSize 1000 +# ./scripts/cleanup_soft_deleted_observability.ps1 -DryRun + +param( + [string]$MySqlCli = "mysql", + [string]$MySqlHost = "127.0.0.1", + [int]$MySqlPort = 3306, + [string]$MySqlUser = "root", + [string]$MySqlPassword = "", + [string]$MySqlDB = "", + [int]$BatchSize = 1000, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function New-MySqlArgs { + param( + [string]$Sql, + [switch]$MaskPassword + ) + + $args = @("-h", $MySqlHost, "-P", "$MySqlPort", "-u", $MySqlUser, "-N", "-B") + if ($MySqlPassword -ne "") { + if ($MaskPassword) { + $args += "-p******" + } else { + $args += "-p$MySqlPassword" + } + } + $args += $MySqlDB + $args += "-e" + $args += $Sql + return $args +} + +function Invoke-MySqlText { + param( + [string]$Sql + ) + + if ([string]::IsNullOrWhiteSpace($MySqlDB)) { + throw "MySqlDB is empty." + } + + $displayArgs = New-MySqlArgs -Sql $Sql -MaskPassword + if ($DryRun) { + Write-Host "[DryRun][MySQL] $MySqlCli $($displayArgs -join ' ')" + return "" + } + + $args = New-MySqlArgs -Sql $Sql + return (& $MySqlCli @args | Out-String).Trim() +} + +function Invoke-MySqlInt { + param( + [string]$Sql + ) + + $raw = Invoke-MySqlText -Sql $Sql + if ([string]::IsNullOrWhiteSpace($raw)) { + return 0 + } + return [int64]::Parse(($raw -split "`r?`n")[-1].Trim()) +} + +function Remove-SoftDeletedRowsByTable { + param( + [string]$TableName + ) + + Write-Host "Checking table $TableName ..." + $countSql = "SELECT COUNT(*) FROM $TableName WHERE deleted_at IS NOT NULL;" + $initialTotal = Invoke-MySqlInt -Sql $countSql + + if ($DryRun) { + $deleteSql = "DELETE FROM $TableName WHERE deleted_at IS NOT NULL ORDER BY deleted_at ASC, id ASC LIMIT $BatchSize; SELECT ROW_COUNT();" + Invoke-MySqlText -Sql $deleteSql | Out-Null + return + } + + Write-Host "Table $TableName soft-deleted rows: $initialTotal" + if ($initialTotal -le 0) { + return + } + + $remaining = $initialTotal + $deletedTotal = 0 + $round = 0 + + while ($true) { + $round++ + $deleteSql = "DELETE FROM $TableName WHERE deleted_at IS NOT NULL ORDER BY deleted_at ASC, id ASC LIMIT $BatchSize; SELECT ROW_COUNT();" + $affected = Invoke-MySqlInt -Sql $deleteSql + if ($affected -le 0) { + break + } + + $deletedTotal += $affected + $remaining = [Math]::Max(0, $remaining - $affected) + Write-Host ("[{0}] table={1} deleted={2} deleted_total={3} remaining_estimate={4}" -f $round, $TableName, $affected, $deletedTotal, $remaining) + } + + $finalRemaining = Invoke-MySqlInt -Sql $countSql + Write-Host "Table $TableName cleanup completed. deleted_total=$deletedTotal remaining=$finalRemaining" +} + +if ($BatchSize -le 0) { + throw "BatchSize must be greater than 0." +} + +if ([string]::IsNullOrWhiteSpace($MySqlDB)) { + Write-Host "MySqlDB is empty, skip observability cleanup." + exit 0 +} + +Write-Host "Starting soft-deleted observability cleanup..." +Remove-SoftDeletedRowsByTable -TableName "observability_trace_spans" +Remove-SoftDeletedRowsByTable -TableName "observability_metrics" +Write-Host "Soft-deleted observability cleanup completed." From 2449830bfee5eb2c4323482a03204b1425dd9e75 Mon Sep 17 00:00:00 2001 From: wang Date: Fri, 24 Apr 2026 17:09:23 +0800 Subject: [PATCH 12/17] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BA=86ReAct=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=B8=BA=E8=AE=B0=E5=BF=86=E6=A8=A1=E5=9D=97=E3=80=81?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8E=8B=E7=BC=A9=E4=B8=8E=E7=BD=91?= =?UTF-8?q?=E5=85=B3LLM=E5=9F=8B=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- "docs/AI/ReAct\350\256\276\350\256\241.md" | 625 ++++++++++++++---- ...66\346\256\265\346\274\224\350\277\233.md" | 2 + ...41\345\235\227\350\256\276\350\256\241.md" | 102 +++ internal/infrastructure/ai/eino/chat_model.go | 5 + .../infrastructure/ai/eino/chat_model_test.go | 18 + internal/infrastructure/ai/eino/options.go | 15 +- internal/infrastructure/ai/eino/runtime.go | 52 -- .../ai/eino/runtime_tools_test.go | 147 ++++ .../infrastructure/ai/eino/tool_schema.go | 59 ++ internal/service/system/aiContext.go | 115 ++++ internal/service/system/aiContext_test.go | 148 +++++ internal/service/system/aiMapper.go | 2 + internal/service/system/aiPrompt.go | 19 + internal/service/system/aiSvc.go | 23 +- internal/service/system/aiTool.go | 6 + plan/cross-module/approved-ai-react-v1.md | 48 ++ .../cleanup_soft_deleted_observability.ps1 | 126 ---- 17 files changed, 1219 insertions(+), 293 deletions(-) create mode 100644 "docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" create mode 100644 internal/infrastructure/ai/eino/tool_schema.go create mode 100644 internal/service/system/aiContext.go create mode 100644 internal/service/system/aiContext_test.go create mode 100644 internal/service/system/aiPrompt.go create mode 100644 plan/cross-module/approved-ai-react-v1.md delete mode 100644 scripts/cleanup_soft_deleted_observability.ps1 diff --git "a/docs/AI/ReAct\350\256\276\350\256\241.md" "b/docs/AI/ReAct\350\256\276\350\256\241.md" index 571d140..30c7d9e 100644 --- "a/docs/AI/ReAct\350\256\276\350\256\241.md" +++ "b/docs/AI/ReAct\350\256\276\350\256\241.md" @@ -1,111 +1,514 @@ -建议按“生产级 Agent Runtime”来重构,而不是在现有 `Stream()` 里手写 ReAct 循环。成熟路线是:**用 Eino ADK `ChatModelAgent + Runner` 承担 ReAct 编排,你的项目继续保留 `AIService -> Runtime -> Sink -> Projector -> Repository` 这条业务边界。** - -**目标架构** -```text -Controller - -> AIService - -> AgentRuntime(domain/ai.Runtime) - -> Eino ADK Runner - -> ChatModelAgent(ReAct) - -> ToolsNode - -> EventAdapter - -> aiStreamSink - -> SSE - -> aiMessageProjector - -> ai_messages.trace_items_json / ui_blocks_json / scope_json -``` - -ReAct 内部流程是: - -```text -Reason: 模型判断是否需要工具 -Action: 生成 tool_call -Act: 执行业务 Tool -Observation: tool_result 回灌给模型 -Repeat: 直到模型不再调用工具,输出最终回答 -``` - -**推荐编码顺序** - -1. **先扩展事件协议** - 当前 [event.go]() 只有 token/done/error,不够表达 ReAct。建议新增: - - `agent_step_started` - - `tool_call_started` - - `tool_call_finished` - - `tool_call_failed` - - `tool_call_waiting_confirmation` - - `structured_block` - - `thinking_summary` - - 注意:不要展示模型原始思维链。成熟产品通常展示“执行摘要 / 工具轨迹 / 可审计步骤”,不是裸 Chain-of-Thought。 - -2. **恢复 `aiProjector` 的 trace 投影能力** - 你现在的 [aiProjector.go]() 明确只处理基础文本。下一步应让它重新维护: - - `TraceItemsJSON`:工具调用轨迹、耗时、状态、确认动作 - - `UIBlocksJSON`:结构化结果卡片 - - `ScopeJSON`:当前用户、组织、业务上下文 - - 好处是前端刷新历史消息时,仍能看到 Agent 做过什么。 - -3. **新增 Tool 业务边界** - 不建议让 Eino Tool 直接查 DB。建议这样拆: - - ```text - internal/service/system/aiToolService.go - 负责业务查询、权限、组织范围、结果裁剪 - - internal/infrastructure/ai/eino/tools/*.go - 只做 Eino tool schema + adapter - ``` - - 工具参数里不要相信模型传来的 `user_id/org_id`,这些必须从 `StreamInput.UserID` 和服务端上下文注入。 - -4. **第一批 Tool 只做只读能力** - 建议从低风险工具开始: - - `get_current_user_scope` - - `get_oj_daily_stats` - - `search_project_docs` - - `get_conversation_summary` - - 先不要做写操作。写操作后面必须接 interrupt/confirmation。 - -5. **把 Eino runtime 从 ChatModel 改为 AgentRuntime** - 当前 [runtime.go]() 是直接 `r.model.Stream(...)`。重构后这里不再直接调 ChatModel,而是: - - 构造 `ChatModelAgent` - - 注册工具 `ToolsConfig` - - 用 `Runner` 执行 - - 把 `AgentEvent` 转成你的 `aidomain.Event` - - 继续通过 `sink.Emit()` 输出 - - Eino 官方的 `ChatModelAgent` 内部就是 ReAct;如果只是简单 ReAct,也可以用 `react.NewAgent`,但你后续要 interrupt/resume/checkpoint,所以更建议 ADK Runner。 - -6. **加 Planner/白名单,不要把所有工具暴露给模型** - 成熟 Agent 系统不会每轮都给模型全量工具。建议在 `AIService` 调 runtime 前先做一个轻量 plan: - - ```text - 用户问题 -> intent/plan -> allowed_tools -> AgentRuntime - ``` - - 例如普通闲聊不给工具;问 OJ 数据才开放 OJ 工具;问项目文档才开放文档检索。 - -7. **第二阶段再做 interrupt/resume** - 你的 [AIInterrupt]() 表已经有基础字段,可以后续接: - - 高风险工具调用前暂停 - - 写入 `AIInterrupt` - - SSE 发 `tool_call_waiting_confirmation` - - 用户确认后 `Runner.Resume(checkpoint_id)` - - projector 更新 trace 状态 - - 写操作、跨组织查询、发送通知、长期记忆写入,都应该走这套。 - -**成熟性检查清单** - -- Tool 有超时、限流、最大返回长度。 -- Agent 有 `MaxIterations/MaxStep`,避免无限循环。 -- Tool 参数和返回值都做 schema 校验。 -- Tool 结果做脱敏和摘要,不把大对象直接塞回模型。 -- trace 里记录 tool name、status、duration、redacted args、summary。 -- 所有 Tool 使用 `context.Context`,权限基于服务端用户上下文。 -- 测试覆盖:纯聊天、一次工具调用、多次工具调用、工具失败、超步数、SSE 中断、DB trace 落库。 - -官方参考我建议看这几份:CloudWeGo [ReAct Agent Manual](https://www.cloudwego.io/docs/eino/core_modules/flow_integration_components/react_agent_manual/)、[Eino ADK ChatModelAgent](https://www.cloudwego.io/docs/eino/core_modules/eino_adk/agent_implementation/chat_model/)、[Agent Runner and Extension](https://www.cloudwego.io/docs/eino/core_modules/eino_adk/agent_extension/)。当前项目要做“市面成熟”的 ReAct,优先走 ADK Runner 这条线。 \ No newline at end of file +这次实现的核心思路其实很简单: + +**我没有去“发明一个新框架”,而是把你现有的三层边界补完整,让它稳定地跑单 Agent ReAct。** + +也就是: + +- `domain/ai` 继续只定义协议 +- `service/system` 继续负责业务编排、工具过滤、上下文装配 +- `infrastructure/ai/eino` 继续负责模型执行和 tool loop +- `aiSink + aiProjector` 继续负责 SSE 和消息落库 + +所以你现在得到的不是“推翻重写”,而是“把原来已有骨架补成可交付版本”。 + +--- + +**一、我到底实现了什么** + +这次主要做了 4 件事。 + +1. 把“上下文装配”从 `AIService` 里抽出来,变成单独协作者。 +入口还是 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:223),但它现在不再自己直接拼 `History + DynamicSystemPrompt`,而是交给 `contextAssembler`,[aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:38)、[aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:319)。 + +2. 预埋了后续扩展点,但默认不启用。 +也就是: +- `aiMemoryProvider` +- `aiContextCompressor` +- `aiPromptBuilder` + +这些都在 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:55) 和 [aiPrompt.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiPrompt.go:7)。 +默认没注入时,行为和你之前基本一致。 + +3. 把 ReAct runtime 固化成两条路径。 +在 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:79): +- 没工具:走 `streamTextOnly` +- 有工具:走 `streamWithTools` + +这样逻辑边界就很清楚,不会把普通对话和 ReAct 混成一锅。 + +4. 给未来的 LLM 网关留了注入口。 +新增了 `ChatModelFactory`,定义在 [options.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/options.go:12),实际使用在 [chat_model.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/chat_model.go:30)。 +现在不用它也没关系,但以后你做网关、模型路由、熔断,就不需要改 service 层。 + +--- + +**二、为什么要这样做** + +因为你现在真正需要的不是“更多功能”,而是**把控制面和执行面分开**。 + +之前 `AIService` 已经做很多事情了: +- 查会话 +- 查历史 +- 创建消息 +- 过滤工具 +- 拼 prompt +- 调 runtime +- 收尾 + +这没错,但“上下文构造”已经开始变成一个独立职责了。你后面要加: +- memory recall +- 上下文压缩 +- prompt builder +- 甚至未来 query rewrite + +如果这些还堆在 `AIService` 里,`StreamConversation` 会越来越难看。 + +所以我把它抽成了: + +- `AIService` 负责调度 +- `aiContextAssembler` 负责构造 runtime 输入 + +这一步是这次实现里最重要的结构变化。 + +--- + +**三、一次请求现在是怎么跑的** + +你可以把当前完整链路理解成这 8 步。 + +### 1. Controller 调到 `AIService.StreamConversation` + +主入口还是 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:223)。 + +这里做的是业务入口校验: +- `req` 不能为空 +- 路径上的 `conversationID` 和 body 里的要一致 +- `writer` 不能是空 +- `runtime` 不能是空 + +然后读取: +- 会话 +- 用户 +- 历史消息 + +--- + +### 2. 创建本轮 user/assistant 消息骨架并落库 + +这一步还在 `AIService` 里。 + +会先创建两条消息: +- 用户消息:直接 `success` +- assistant 消息:先是 `loading` + +然后调用 `persistStreamStart(...)` 做事务化写入。 +这样做的目的,是在真正调用模型前,数据库里就已经有这轮对话的“骨架”。 + +好处是: +- 前端列表页能立即看到“生成中” +- 后续就算流中断,库里也有 assistant 占位消息可追踪 + +--- + +### 3. 创建 sink + +这里创建的是 [aiSink.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSink.go:16) 里的 `aiStreamSink`。 + +它的职责非常关键: + +- 往 SSE 发事件 +- 把这些事件折叠成 assistant 消息状态 +- 定时落库 + +也就是说 runtime 根本不需要知道数据库和 SSE,它只管发 `aidomain.Event`。 +这就是你现在这套设计最好的地方之一。 + +--- + +### 4. 构造工具调用上下文和 principal + +在 `AIService` 里会构造: + +- `AIToolPrincipal` +- `ToolCallContext` + +`principal` 只保存最小授权事实: +- `UserID` +- `CurrentOrgID` +- `IsSuperAdmin` + +而不是塞一大堆业务对象。 +这个定义在 [tool.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/tool.go:61)。 + +这一步的意义是: +**service 把“谁在调用”准备好,tool runtime 只消费这个最小事实。** + +--- + +### 5. 过滤本轮可见工具 + +调用的是 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:190) 的 `FilterVisibleTools(...)`。 + +这里做的是“本轮可见性过滤”,不是最终执行授权。 +比如: +- self only +- org capability +- super admin only + +只把当前用户**看得见**的工具暴露给模型。 + +为什么要这样做? +因为如果你把所有工具都给模型,它会尝试调用本来不该看到的工具,失败率会很高。 + +所以现在是两层保护: +1. 可见性过滤:决定模型能不能看见 +2. 执行前二次鉴权:决定工具能不能真正执行 + +这点你原来的设计就很好,我保留了。 + +--- + +### 6. 由 `contextAssembler` 统一构造 runtime 输入 + +这是这次新加的核心点。 + +位置在 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:79)。 + +它现在做三件事: + +1. 把 DB 历史消息转成 `aidomain.Message` +2. 可选追加 memory recall +3. 可选做上下文压缩 +4. 生成动态 prompt + +它的返回值是: + +- `History` +- `DynamicSystemPrompt` + +也就是最后真正喂给 runtime 的上下文快照。 + +默认行为非常保守: +- `Memory == nil`:不召回 +- `Compressor == nil`:不压缩 +- `PromptBuilder == nil`:走默认 prompt builder + +所以这次改动不会改变你现在线上行为。 + +--- + +### 7. runtime 执行 ReAct + +入口在 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:79)。 + +它先发一个: +- `conversation_started` + +然后分成两条路: + +#### 路径 A:无工具 +走 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:110) 的 `streamTextOnly(...)` + +流程是: +- 构造 messages +- 调模型流式输出 +- 每个 chunk 发 `assistant_token` +- 结束时发 `message_completed` +- 最后发 `done` + +#### 路径 B:有工具 +走 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:164) 的 `streamWithTools(...)` + +流程是: +1. 绑定本轮可见工具到模型 +2. 构造完整 messages +3. 模型先回答一轮 assistant +4. 如果 assistant 没有 tool call: + - 直接进入最终回答 +5. 如果 assistant 有 tool call: + - 顺序执行工具 + - 把 tool result 转成 `ToolMessage` + - 再塞回 messages + - 模型继续下一轮 + +这就是标准单 Agent ReAct loop。 + +--- + +### 8. sink/projector 折叠事件并落库 + +runtime 发出来的不是 HTTP 响应,也不是 DB 更新,而是 `aidomain.Event`。 + +这些事件进入 [aiSink.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSink.go:45) 的 `Emit(...)` 后,会发生两件事: + +1. 写到 SSE +2. 调 `projector.applyEvent(...)` + +投影逻辑在 [aiProjector.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProjector.go:104)。 + +它会根据事件更新 assistant 消息快照: + +- `assistant_token` + 追加正文 +- `tool_call_started` + 创建 running trace item +- `tool_call_finished` + 更新 trace item 为 success/failed +- `message_completed` + 覆盖最终正文并标记 success +- `error` + 写错误文案并标记 error + +最终 `trace_items_json` 和 `content/status/error_text` 都会落到消息表里。 + +--- + +**四、动态 prompt 是怎么工作的** + +默认 prompt 逻辑还在 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:275)。 + +它现在固定会告诉模型: + +- 本轮只能用列出的工具 +- 缺少 `org_id/task_id/execution_id/request_id` 时不要猜 +- 工具可见不等于一定能执行成功 +- 当前组织上下文是什么 +- 每个工具参数是什么 + +这个 prompt 的目标不是“教模型变聪明”,而是**降低错误调用率**。 + +举个例子: + +如果用户说“帮我查排名”,而工具要求 `platform` 必填,模型此时应该: +- 不去乱猜 `leetcode` +- 不去乱调工具 +- 而是直接自然追问用户 + +这个行为在测试里已经覆盖了,[runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:231)。 + +--- + +**五、工具执行阶段我做了哪些细节处理** + +工具执行在 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:340)。 + +这里有几个关键点: + +1. `tool name` 会先 `TrimSpace` +避免模型输出名字前后有空格导致匹配失败。 + +2. `ArgumentsJSON` 原文完整透传 +不会在 runtime 层先偷偷改参数结构,工具自己解析。 + +3. 每次工具调用都会发两种事件 +- `tool_call_started` +- `tool_call_finished` + +这样前端 trace 能稳定显示运行态和结束态。 + +4. 工具失败时,先发失败 trace,再返回 error +这点很重要。 +如果工具失败但没有 `finished(failed)` 事件,前端会一直停在“running”。 +我现在保证失败也会补一个 failed trace。 + +这部分也加了测试,[runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:273)。 + +--- + +**六、为什么我没有做 interrupt/resume** + +因为你后面已经明确了: +你当前缺参数场景,本质上就是普通多轮对话。 + +比如: +- 用户说“查我的排名” +- 模型说“你要查 leetcode 还是 luogu” +- 用户下一轮回答“leetcode” + +这根本不需要 runtime 级 interrupt。 +这只是 assistant 在正常追问。 + +所以我这次坚持了一个原则: + +**缺参时,模型自然追问;不是 runtime 状态机去接管。** + +这让你的 ReAct V1 保持简单,也更符合当前产品阶段。 + +--- + +**七、我为什么要加 `aiContextAssembler`、`aiPromptBuilder`、`ChatModelFactory`** + +这三个是“为未来留钩子”,但又不污染现在逻辑。 + +### `aiContextAssembler` +这是给你未来接: +- memory recall +- 历史裁剪 +- 压缩摘要 + +的位置。 + +如果以后你要做: +- 最近 20 条消息 + 历史摘要 +- 召回用户长期偏好 +- 对话主题记忆 + +就改它,不需要把 `AIService` 撕开。 + +### `aiPromptBuilder` +这是给你未来做: +- 不同业务模式下不同 prompt +- A/B prompt 实验 +- prompt 配置外置化 + +的位置。 + +### `ChatModelFactory` +这是给你未来做: +- LLM 网关 +- provider 路由 +- 模型降级 +- 熔断重试 + +的位置。 + +它只存在于 infrastructure,不会上浮到 service。 +这点很重要,因为这样不会让 `service/system` 被 Eino 反向污染。 + +--- + +**八、测试是怎么证明这套实现可用的** + +我补了几组关键测试。 + +### 上下文装配测试 +在 [aiContext_test.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext_test.go:54) 和 [aiContext_test.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext_test.go:104) + +验证: +- 默认会用 stored history +- 默认 prompt 会包含工具说明和“不要猜测” +- memory/compressor/promptBuilder 注入后会被调用 +- 没注入时行为不变 + +### runtime 纯文本路径 +在 [runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:91) + +验证: +- 无工具时按纯文本流跑 +- 事件顺序正确 + +### runtime 工具路径 +在 [runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:133) + +验证: +- tool schema 绑定成功 +- 动态 prompt 注入成功 +- tool arguments 原文透传成功 +- tool call context 正确带入 +- 事件顺序正确 + +### 缺参自然追问 +在 [runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:231) + +验证: +- 有工具但模型不调用工具,直接追问用户 +- 这是合法路径 + +### 工具失败 trace +在 [runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:273) + +验证: +- 工具失败时会先发 `tool_call_finished(failed)` +- 不会让前端 trace 卡死在 running + +### 自定义模型工厂 +在 [chat_model_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/chat_model_test.go:10) + +验证: +- `ChatModelFactory` 注入时,`NewChatModel` 会优先使用它 + +--- + +**九、你现在这套 ReAct 的完整职责边界** + +你可以这么记: + +### `domain/ai` +只定义协议: +- `Runtime` +- `StreamInput` +- `Tool` +- `Event` + +看这里: +[runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/runtime.go:5) +[tool.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/tool.go:5) +[event.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/event.go:5) + +### `service/system` +只做业务编排: +- 查用户/会话/历史 +- 建消息骨架 +- 过滤工具 +- 组上下文 +- 调 runtime +- 做流式收尾 + +看这里: +[aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:223) +[aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:55) +[aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:67) + +### `infrastructure/ai/eino` +只做执行: +- 创建 model +- tool binding +- tool loop +- 发事件 + +看这里: +[runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:79) +[chat_model.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/chat_model.go:30) + +### `aiSink + aiProjector` +只做状态汇聚: +- SSE 输出 +- assistant 消息内容折叠 +- tool trace 折叠 +- DB 落库 + +看这里: +[aiSink.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSink.go:45) +[aiProjector.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProjector.go:104) + +--- + +**十、如果你要继续往下做,下一步最合理是什么** + +现在最值得继续做的不是 ADK,也不是 interrupt,而是这三项里的一个: + +1. 工具失败分类 +现在 runtime 还是“工具失败即整轮失败”。后面你可以细分: +- 参数错误 -> 更适合让模型追问 +- 业务错误 -> 直接解释 +- 系统错误 -> 整轮失败 + +2. 记忆模块落地 +你已经有 `aiMemoryProvider` 入口了。下一步可以直接挂: +- 最近主题摘要 +- 用户偏好记忆 +- 重要事实召回 + +3. 上下文压缩 +你已经有 `aiContextCompressor`。可以先做最简单版: +- 历史消息超过 N 条时摘要前半段 +- 保留最近 K 条原文 + +如果你愿意,我下一条可以继续直接给你画一张更具体的图: + +**“用户发一句话后,`AIService -> ContextAssembler -> Runtime -> Tool -> Sink -> Projector -> DB/SSE` 的时序图”** + +或者我也可以直接继续帮你做下一步实现,比如: +- `memory provider` +- `上下文压缩` +- `工具失败分类` \ No newline at end of file diff --git "a/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" "b/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" index 88db3c4..62a5ee6 100644 --- "a/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" +++ "b/docs/AI/\344\270\213\351\230\266\346\256\265\346\274\224\350\277\233.md" @@ -6,6 +6,8 @@ 4、把ReAct设计的完成 +5、配置向量数据库 + 5、设计mem0如何设计记忆模式 6、把上下文恢复、上下文token压缩解决这些问题 diff --git "a/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" "b/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" new file mode 100644 index 0000000..cdf9722 --- /dev/null +++ "b/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" @@ -0,0 +1,102 @@ +你的项目不该把“记忆”做成一个通用聊天外挂,而应该做成一套`权限感知的领域记忆系统 + 单入口多模式助手`。 + +**先给结论** +最成熟的产品形态不是做 3 个割裂的机器人,而是做`一个统一 AI 入口`,再按`意图 + 角色 + 权限`自动路由到 4 条执行车道: + +- `general_qa`:普通问答。 +- `oj_tutor`:算法题讲解、做题画像、薄弱点分析。 +- `org_analyst`:管理员看组织整体情况、排行、任务执行、共性问题。 +- `ops_copilot`:超级管理员看 trace、metrics、错误排查、runbook、故障复盘。 + +这正好和你仓库现在的形态契合,因为你已经有: +- 上下文装配扩展点 [aiContext.go]() +- 流式主编排入口 [aiSvc.go]() +- 按 `self / org capability / super admin` 过滤工具的机制 [aiTool.go]() +- Qdrant 基础设施入口 [qdrant.go]() + +**站在资深用户视角,真正的诉求** +- 普通用户不要“会聊天”,而是要`连续性`。上次偏好、最近在练什么题、哪些知识点薄弱、解释风格偏好,下一次还能接上。 +- 管理员不要“AI 会总结”,而是要`可信的组织视图`。能看整体趋势、异常人群、任务完成情况、组织常见问题,还不能越权看到不该看的个人细节。 +- 超级管理员不要“AI 会说故障原因”,而是要`可验证、可追溯、可审计`。AI 必须能拉 trace/metrics/logs/runbook,给出证据链,还要保留会话、工具调用和诊断过程。 +- 所有人都要`单入口`。用户不想自己选机器人,系统应自动分流;必要时告诉用户“当前由 OJ 教练 / 运维助手处理”。 + +**架构师视角的最终成熟方案** +- 外层产品形态用`一个入口 + 自动路由`,借鉴 Glean 的 auto-routing 思路;UI 上仍然只有一个“AI 助手”。 +- 记忆分 4 层,但不要平均用力: +- `感知记忆`:本轮消息、附件、题目文本、当前 trace 条件。只活一轮。 +- `短期记忆`:当前会话历史、工具结果、执行计划、当前工作状态。要可压缩、可 checkpoint。 +- `长期记忆`:题解经验、组织 FAQ、历史任务结论、故障案例摘要、runbook 摘要。用于跨会话召回。 +- `实体记忆`:用户偏好、OJ 能力画像、组织上下文、角色事实、服务拓扑、事故标签。必须结构化。 +- 永远放在 prompt 里的“核心记忆块”只保留 2 到 4 个,借鉴 Letta 的 core memory 思路: +- `assistant_policy` +- `user_profile` +- `current_org_context` +- `current_task_goal` +- 其他内容一律按需召回,不要全塞进上下文。 + +**存什么、怎么存、什么时候取** +- `MySQL / 关系库`存实体记忆:用户偏好、当前学习目标、题型薄弱点、组织上下文、incident 元数据、runbook 元数据。因为这些需要精确更新和强一致。 +- `Qdrant`存长期非结构化记忆:历史题解摘要、知识文档切片、会话摘要、事故复盘摘要、FAQ、运维经验。你现在已有 Qdrant 初始化,不需要重造基础设施。 +- 初期不要一上来上图数据库。先做`关系库 + 向量库`混合存储;只有当“时间变化的关系推理”成为核心卖点时,再引入 Zep/Graphiti 风格的时序知识图谱。 +- Qdrant 初期建议不要按角色拆很多 collection,而是先做一个 `ai_memory` collection,用 payload 过滤: +- `scope_type` +- `scope_id` +- `org_id` +- `memory_type` +- `visibility` +- `topic` +- `importance` +- `effective_at` +- `expires_at` +- `source_kind` +- `source_id` + +**检索策略必须这么做** +- 每轮开始前,先加载`实体记忆`:用户画像、当前组织、角色事实、活跃学习计划。 +- 再按当前问题做一次`长期记忆召回`:普通用户召回个人与组织知识,管理员召回组织知识,超级管理员召回 runbook 和 incident 记忆。 +- 推理过程中允许模型按需触发“查记忆”工具,但`运维场景优先查实时工具,不优先查旧记忆`。因为 ops 最怕拿过期结论。 +- 所有召回都必须做`permission-trimmed retrieval`。向量召回不能绕过权限,这是企业助手成败分水岭。微软 Copilot 和 Glean 的成熟点都在这里,不在 prompt 花活。 + +**写回策略必须异步化** +- 同步写回:会话摘要、显式偏好变更、当前任务状态。 +- 异步写回:从对话和工具结果中抽取“值得记住”的事实,做去重、冲突消解、embedding、TTL、质量评分。 +- 普通用户重点写:偏好、学习画像、做题经验、近期目标。 +- 管理员重点写:组织共性问题、任务执行异常模式、FAQ 沉淀。 +- 超级管理员重点写:故障案例摘要、排障路径、根因、修复动作、回滚经验。 +- 不要把原始长日志、原始 trace payload、大段工具输出直接写成长期记忆。长期记忆应该存`摘要和结论`,原始证据仍走工具实时查。 + +**你这个项目最关键的一条设计原则** +记忆也必须走权限体系,不能只给 tool 做权限。 + +你现在 [aiTool.go]() 已经有工具可见性过滤,这是好基础。最终成熟版应该做到: +- `self memory` 只能本人看到。 +- `org memory` 只能具备组织 capability 的管理员看到。 +- `platform ops memory` 只能超级管理员看到。 +- 超级管理员的“执行类工具”默认只读诊断;涉及重启、变更、清缓存、补数据这类动作必须 approval gate,不让模型直接执行。 + +**按你仓库的落点,应该怎么放** +- 不要把记忆写进 `runtime.go`。runtime 只负责模型流和 tool loop。 +- 把“召回 + 压缩”继续收口在 [aiContext.go]()。 +- 把“本轮结束后的记忆写回编排”放在 `internal/service/system`,建议单独做 `aiMemorySvc.go`。 +- 把稳定协议放到 `internal/domain/ai`,例如 `memory.go`,定义 `Recall`, `WriteBack`, `Scope`, `MemoryFact`。 +- 把 Qdrant、embedding、rerank、extractor 放到 `internal/infrastructure/ai/memory`。 +- 把结构化 CRUD 放到 `internal/repository/interfaces` 和 `internal/repository/system`。 +- 把用户画像、组织画像、incident 摘要这些实体放到 `internal/model/entity` 或只读聚合放到 `readmodel`。 + +**我建议你的实施顺序** +1. 先做 `Phase 1`:用户短期记忆 + 用户实体记忆 + OJ 学习画像。 +2. 再做 `Phase 2`:组织级长期记忆 + 管理员视角召回 + 会话压缩。 +3. 再做 `Phase 3`:超级管理员运维助手,接 trace/metrics/log 工具,沉淀 incident memory。 +4. 最后评估 `Phase 4`:是否真的需要 Graphiti/Zep 这类时序图谱,而不是先把复杂度引进来。 + +**成熟产品里你最该借鉴的点** +- LangGraph:短期状态和 checkpoint 分离。https://docs.langchain.com/oss/javascript/concepts/memory +- Mem0:多层记忆、抽取、冲突消解、写回流水线。https://docs.mem0.ai/core-concepts/memory-types https://docs.mem0.ai/core-concepts/memory-operations/add +- Letta:核心记忆块永远可见,归档记忆按需召回。https://docs.letta.com/guides/core-concepts/memory/memory-blocks https://docs.letta.com/guides/core-concepts/memory/archival-memory +- Zep / Graphiti:适合“事实会变化”的时序图谱记忆。https://help.getzep.com/graph-overview https://www.getzep.com/product/open-source/ +- Microsoft 365 Copilot:grounding 必须继承原权限,且要有治理与 DLP。https://support.microsoft.com/en-us/topic/what-information-does-copilot-use-to-answer-my-prompt-934f537d-ff7d-4059-9fec-a751e4651307 https://learn.microsoft.com/en-us/microsoft-365/copilot/configure-secure-governed-data-foundation-microsoft-365-copilot +- Glean:单入口、权限感知、自动路由到专用 agent。https://docs.glean.com/get-started/golive/setup-glean-assistant https://docs.glean.com/user-guide/assistant/ai-answers https://docs.glean.com/administration/managing-agents/agent-routing +- Datadog Bits / Elastic AI Assistant:运维助手的正确方向是“查实时观测数据 + 沉淀 incident memory + 支持会话导出复盘”。https://docs.datadoghq.com/bits_ai/bits_assistant/ https://www.elastic.co/docs/solutions/observability/ai/observability-ai-assistant +- GitHub Copilot Spaces:把固定领域知识做成可审阅、可复用的 curated context pack。https://github.com/features/preview/copilot-spaces + +如果你要继续往“落地实现”走,下一步我建议直接给你出一份`本仓库可执行的记忆模块分层方案`,按你项目规则落成 `plan/ai/pending-记忆模块架构落地.md`。 \ No newline at end of file diff --git a/internal/infrastructure/ai/eino/chat_model.go b/internal/infrastructure/ai/eino/chat_model.go index 167361b..109cec8 100644 --- a/internal/infrastructure/ai/eino/chat_model.go +++ b/internal/infrastructure/ai/eino/chat_model.go @@ -28,6 +28,11 @@ import ( // 注意事项: // - 本函数不读取全局配置,调用方必须显式传入 Options。 func NewChatModel(ctx context.Context, cfg Options) (einomodel.BaseChatModel, error) { + if cfg.ChatModelFactory != nil { + // 自定义工厂完全接管模型创建流程,便于未来接入模型网关或路由层。 + return cfg.ChatModelFactory(ctx, cfg) + } + provider := strings.ToLower(strings.TrimSpace(cfg.Provider)) if provider == "" { provider = "qwen" diff --git a/internal/infrastructure/ai/eino/chat_model_test.go b/internal/infrastructure/ai/eino/chat_model_test.go index c13fd2c..da4b9e5 100644 --- a/internal/infrastructure/ai/eino/chat_model_test.go +++ b/internal/infrastructure/ai/eino/chat_model_test.go @@ -3,8 +3,26 @@ package eino import ( "context" "testing" + + einomodel "github.com/cloudwego/eino/components/model" ) +func TestNewChatModelUsesCustomFactoryWhenProvided(t *testing.T) { + expected := &fakeToolCallingChatModel{} + + model, err := NewChatModel(context.Background(), Options{ + ChatModelFactory: func(context.Context, Options) (einomodel.BaseChatModel, error) { + return expected, nil + }, + }) + if err != nil { + t.Fatalf("NewChatModel() error = %v", err) + } + if model != expected { + t.Fatalf("model = %#v, want %#v", model, expected) + } +} + func TestNewChatModelRequiresModel(t *testing.T) { _, err := NewChatModel(context.Background(), Options{APIKey: "test"}) if err == nil { diff --git a/internal/infrastructure/ai/eino/options.go b/internal/infrastructure/ai/eino/options.go index d1e43bc..eccaea9 100644 --- a/internal/infrastructure/ai/eino/options.go +++ b/internal/infrastructure/ai/eino/options.go @@ -1,6 +1,15 @@ package eino -import "time" +import ( + "context" + "time" + + einomodel "github.com/cloudwego/eino/components/model" +) + +// ChatModelFactory 允许调用方自定义底层 ChatModel 构造逻辑。 +// 未注入时仍走 provider 分支创建默认模型。 +type ChatModelFactory func(ctx context.Context, cfg Options) (einomodel.BaseChatModel, error) // Options 描述 Eino 基础流式 runtime 的初始化配置。 // 作用:把外部传入的 AI 运行参数收拢成一个统一配置对象,供 NewRuntime 之类的构造函数使用。 @@ -29,6 +38,10 @@ type Options struct { // 普通 OpenAI 兼容模式下,这个字段通常可以为空。 APIVersion string + // ChatModelFactory 允许未来在 infrastructure 层注入模型网关或路由工厂。 + // 若为空,则继续使用当前 provider 对应的默认模型实现。 + ChatModelFactory ChatModelFactory + // SystemPrompt 表示系统提示词。 // 它用于定义 AI 的全局角色、行为边界和回答风格。 SystemPrompt string diff --git a/internal/infrastructure/ai/eino/runtime.go b/internal/infrastructure/ai/eino/runtime.go index 7d49280..24ea6ce 100644 --- a/internal/infrastructure/ai/eino/runtime.go +++ b/internal/infrastructure/ai/eino/runtime.go @@ -295,58 +295,6 @@ func (r *Runtime) bindToolModel(tools []aidomain.Tool) (einomodel.BaseChatModel, return nil, func() {}, errors.New("eino runtime model does not support tool calling") } -// buildSchemaToolInfo 把 domain 层 ToolSpec 转成 Eino 的 ToolInfo。 -func buildSchemaToolInfo(spec aidomain.ToolSpec) (*schema.ToolInfo, error) { - // 先把参数列表转成按名称索引的 schema 参数定义。 - params := make(map[string]*schema.ParameterInfo, len(spec.Parameters)) - for _, param := range spec.Parameters { - info, err := buildSchemaParameterInfo(param) - if err != nil { - return nil, err - } - params[param.Name] = info - } - - // ToolInfo 只承载 name、描述和参数协议,不包含任何业务实现。 - return &schema.ToolInfo{ - Name: spec.Name, - Desc: spec.Description, - ParamsOneOf: schema.NewParamsOneOfByParams(params), - }, nil -} - -// buildSchemaParameterInfo 递归把 domain 层参数定义转换成 Eino 参数协议。 -func buildSchemaParameterInfo(param aidomain.ToolParameter) (*schema.ParameterInfo, error) { - // 先填充当前参数节点的基础元信息。 - info := &schema.ParameterInfo{ - Type: schema.DataType(param.Type), - Desc: param.Description, - Enum: param.Enum, - Required: param.Required, - } - if param.Items != nil { - // array 参数需要继续递归描述元素结构。 - itemInfo, err := buildSchemaParameterInfo(*param.Items) - if err != nil { - return nil, err - } - info.ElemInfo = itemInfo - } - if len(param.Properties) > 0 { - // object 参数需要递归构建所有子字段定义。 - subParams := make(map[string]*schema.ParameterInfo, len(param.Properties)) - for _, child := range param.Properties { - childInfo, err := buildSchemaParameterInfo(child) - if err != nil { - return nil, err - } - subParams[child.Name] = childInfo - } - info.SubParams = subParams - } - return info, nil -} - // drainAssistantTurn 负责从一轮 assistant 输出流中拼出最终消息和增量文本块。 func drainAssistantTurn( reader *schema.StreamReader[*schema.Message], diff --git a/internal/infrastructure/ai/eino/runtime_tools_test.go b/internal/infrastructure/ai/eino/runtime_tools_test.go index 562a4b9..dc55f84 100644 --- a/internal/infrastructure/ai/eino/runtime_tools_test.go +++ b/internal/infrastructure/ai/eino/runtime_tools_test.go @@ -2,6 +2,7 @@ package eino import ( "context" + "errors" "testing" einomodel "github.com/cloudwego/eino/components/model" @@ -65,6 +66,7 @@ func (m *fakeToolCallingChatModel) WithTools(tools []*schema.ToolInfo) (einomode type fakeRuntimeTool struct { spec aidomain.ToolSpec result aidomain.ToolResult + err error calls []aidomain.ToolCall callCtxLog []aidomain.ToolCallContext } @@ -80,9 +82,54 @@ func (t *fakeRuntimeTool) Call( ) (aidomain.ToolResult, error) { t.calls = append(t.calls, call) t.callCtxLog = append(t.callCtxLog, callCtx) + if t.err != nil { + return aidomain.ToolResult{}, t.err + } return t.result, nil } +func TestRuntimeStreamTextOnlyEmitsFinalContent(t *testing.T) { + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("你好,", nil), + schema.AssistantMessage("请继续说明需求。", nil), + }, + }, + } + runtime := &Runtime{ + model: model, + systemPrompt: "base system prompt", + } + sink := &runtimeEventSinkStub{} + + result, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "你好", + }, sink) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if result.Content != "你好,请继续说明需求。" { + t.Fatalf("result.Content = %q", result.Content) + } + + expected := []aidomain.EventName{ + aidomain.EventConversationStarted, + aidomain.EventAssistantToken, + aidomain.EventAssistantToken, + aidomain.EventMessageCompleted, + aidomain.EventDone, + } + if len(sink.events) != len(expected) { + t.Fatalf("event count = %d, want %d", len(sink.events), len(expected)) + } + for i, name := range expected { + if sink.events[i].Name != name { + t.Fatalf("event[%d] = %q, want %q", i, sink.events[i].Name, name) + } + } +} + func TestRuntimeStreamWithToolsEmitsToolEventsAndFinalTokens(t *testing.T) { model := &fakeToolCallingChatModel{ streams: [][]*schema.Message{ @@ -180,3 +227,103 @@ func TestRuntimeStreamWithToolsEmitsToolEventsAndFinalTokens(t *testing.T) { } } } + +func TestRuntimeStreamWithToolsCanNaturallyAskForMissingParams(t *testing.T) { + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("你想查哪个平台,是 leetcode、luogu 还是 lanqiao?", nil), + }, + }, + } + runtime := &Runtime{ + model: model, + systemPrompt: "base system prompt", + } + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "get_my_oj_stats", + Description: "获取当前用户 OJ 统计", + Parameters: []aidomain.ToolParameter{ + {Name: "platform", Type: aidomain.ToolParameterTypeString, Required: true}, + }, + }, + } + sink := &runtimeEventSinkStub{} + + result, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "帮我看一下我的统计", + DynamicSystemPrompt: "缺少平台时不要猜,直接追问。", + Tools: []aidomain.Tool{tool}, + }, sink) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if len(tool.calls) != 0 { + t.Fatalf("tool calls = %d, want 0", len(tool.calls)) + } + if result.Content == "" { + t.Fatal("result.Content = empty") + } + if sink.events[1].Name != aidomain.EventAssistantToken { + t.Fatalf("event[1] = %q, want assistant_token", sink.events[1].Name) + } +} + +func TestRuntimeStreamWithToolsEmitsFailedTraceBeforeReturningError(t *testing.T) { + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: schema.FunctionCall{ + Name: "get_my_oj_stats", + Arguments: `{"platform":"leetcode"}`, + }, + }, + }), + }, + }, + } + runtime := &Runtime{ + model: model, + systemPrompt: "base system prompt", + } + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "get_my_oj_stats", + Description: "获取当前用户 OJ 统计", + Parameters: []aidomain.ToolParameter{ + {Name: "platform", Type: aidomain.ToolParameterTypeString, Required: true}, + }, + }, + err: errors.New("tool failed"), + } + sink := &runtimeEventSinkStub{} + + _, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "帮我看 leetcode 统计", + Tools: []aidomain.Tool{tool}, + }, sink) + if err == nil { + t.Fatal("Stream() error = nil, want tool failure") + } + if len(sink.events) != 3 { + t.Fatalf("event count = %d, want 3", len(sink.events)) + } + if sink.events[1].Name != aidomain.EventToolCallStarted { + t.Fatalf("event[1] = %q, want tool_call_started", sink.events[1].Name) + } + if sink.events[2].Name != aidomain.EventToolCallFinished { + t.Fatalf("event[2] = %q, want tool_call_finished", sink.events[2].Name) + } + payload, ok := sink.events[2].Payload.(aidomain.ToolCallFinishedPayload) + if !ok { + t.Fatalf("event[2] payload type = %T", sink.events[2].Payload) + } + if payload.Status != "failed" { + t.Fatalf("payload.Status = %q, want failed", payload.Status) + } +} diff --git a/internal/infrastructure/ai/eino/tool_schema.go b/internal/infrastructure/ai/eino/tool_schema.go new file mode 100644 index 0000000..be5bd91 --- /dev/null +++ b/internal/infrastructure/ai/eino/tool_schema.go @@ -0,0 +1,59 @@ +package eino + +import ( + aidomain "personal_assistant/internal/domain/ai" + + "github.com/cloudwego/eino/schema" +) + +// buildSchemaToolInfo 把 domain 层 ToolSpec 转成 Eino 的 ToolInfo。 +func buildSchemaToolInfo(spec aidomain.ToolSpec) (*schema.ToolInfo, error) { + // 先把参数列表转成按名称索引的 schema 参数定义。 + params := make(map[string]*schema.ParameterInfo, len(spec.Parameters)) + for _, param := range spec.Parameters { + info, err := buildSchemaParameterInfo(param) + if err != nil { + return nil, err + } + params[param.Name] = info + } + + // ToolInfo 只承载 name、描述和参数协议,不包含任何业务实现。 + return &schema.ToolInfo{ + Name: spec.Name, + Desc: spec.Description, + ParamsOneOf: schema.NewParamsOneOfByParams(params), + }, nil +} + +// buildSchemaParameterInfo 递归把 domain 层参数定义转换成 Eino 参数协议。 +func buildSchemaParameterInfo(param aidomain.ToolParameter) (*schema.ParameterInfo, error) { + // 先填充当前参数节点的基础元信息。 + info := &schema.ParameterInfo{ + Type: schema.DataType(param.Type), + Desc: param.Description, + Enum: param.Enum, + Required: param.Required, + } + if param.Items != nil { + // array 参数需要继续递归描述元素结构。 + itemInfo, err := buildSchemaParameterInfo(*param.Items) + if err != nil { + return nil, err + } + info.ElemInfo = itemInfo + } + if len(param.Properties) > 0 { + // object 参数需要递归构建所有子字段定义。 + subParams := make(map[string]*schema.ParameterInfo, len(param.Properties)) + for _, child := range param.Properties { + childInfo, err := buildSchemaParameterInfo(child) + if err != nil { + return nil, err + } + subParams[child.Name] = childInfo + } + info.SubParams = subParams + } + return info, nil +} diff --git a/internal/service/system/aiContext.go b/internal/service/system/aiContext.go new file mode 100644 index 0000000..a75c0d5 --- /dev/null +++ b/internal/service/system/aiContext.go @@ -0,0 +1,115 @@ +package system + +import ( + "context" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" +) + +// aiMemoryRecallInput 描述未来记忆召回组件需要消费的最小上下文。 +type aiMemoryRecallInput struct { + ConversationID string + UserID uint + Query string + History []aidomain.Message + ToolCallCtx aidomain.ToolCallContext +} + +// aiMemoryProvider 负责为当前会话提供额外的记忆消息。 +// 默认不注入实现,因此当前阶段不会改变上下文行为。 +type aiMemoryProvider interface { + RecallMessages(ctx context.Context, input aiMemoryRecallInput) ([]aidomain.Message, error) +} + +// aiContextCompressionInput 描述上下文压缩组件的输入。 +type aiContextCompressionInput struct { + ConversationID string + Query string + Messages []aidomain.Message +} + +// aiContextCompressor 负责在进入 runtime 前压缩消息上下文。 +// 默认不注入实现,因此当前阶段不会裁剪或摘要历史消息。 +type aiContextCompressor interface { + CompressMessages(ctx context.Context, input aiContextCompressionInput) ([]aidomain.Message, error) +} + +// aiContextBuildArgs 是一次 runtime 上下文装配所需的输入。 +type aiContextBuildArgs struct { + ConversationID string + UserID uint + Query string + StoredMessages []*entity.AIMessage + VisibleTools []aidomain.Tool + ToolCallCtx aidomain.ToolCallContext +} + +// aiContextSnapshot 表示装配完成后可直接喂给 runtime 的上下文片段。 +type aiContextSnapshot struct { + History []aidomain.Message + DynamicSystemPrompt string // 未来可扩展 DynamicToolPrompt、MemoryAugmentation 等片段。 +} + +// aiContextAssembler 负责统一收口历史消息、记忆扩展点、压缩扩展点和动态 prompt。 +type aiContextAssembler interface { + Build(ctx context.Context, args aiContextBuildArgs) (aiContextSnapshot, error) +} + +type defaultAIContextAssembler struct { + memory aiMemoryProvider + compressor aiContextCompressor + promptBuilder aiPromptBuilder +} + +func newAIContextAssembler(deps AIDeps) aiContextAssembler { + builder := deps.PromptBuilder + if builder == nil { + builder = defaultAIToolPromptBuilder{} + } + return &defaultAIContextAssembler{ + memory: deps.Memory, + compressor: deps.Compressor, + promptBuilder: builder, + } +} + +// Build 根据当前会话状态生成 runtime 所需的历史消息和动态 prompt。 +// 当前阶段默认行为仅做消息转换和 prompt 构造;未来记忆与压缩能力通过可选接口接入。 +func (a *defaultAIContextAssembler) Build( + ctx context.Context, + args aiContextBuildArgs, +) (aiContextSnapshot, error) { + history := messagesToRuntimeHistory(args.StoredMessages) + if a.memory != nil { + recalled, err := a.memory.RecallMessages(ctx, aiMemoryRecallInput{ + ConversationID: args.ConversationID, + UserID: args.UserID, + Query: args.Query, + History: history, + ToolCallCtx: args.ToolCallCtx, + }) + if err != nil { + return aiContextSnapshot{}, err + } + if len(recalled) > 0 { + history = append(history, recalled...) + } + } + if a.compressor != nil { + compressed, err := a.compressor.CompressMessages(ctx, aiContextCompressionInput{ + ConversationID: args.ConversationID, + Query: args.Query, + Messages: history, + }) + if err != nil { + return aiContextSnapshot{}, err + } + history = compressed + } + + return aiContextSnapshot{ + History: history, + DynamicSystemPrompt: a.promptBuilder.BuildDynamicPrompt(args.VisibleTools, args.ToolCallCtx.Principal), + }, nil +} diff --git a/internal/service/system/aiContext_test.go b/internal/service/system/aiContext_test.go new file mode 100644 index 0000000..49835ba --- /dev/null +++ b/internal/service/system/aiContext_test.go @@ -0,0 +1,148 @@ +package system + +import ( + "context" + "strings" + "testing" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" +) + +type fakeContextTool struct { + spec aidomain.ToolSpec +} + +func (t *fakeContextTool) Spec() aidomain.ToolSpec { + return t.spec +} + +func (t *fakeContextTool) Call(context.Context, aidomain.ToolCall, aidomain.ToolCallContext) (aidomain.ToolResult, error) { + return aidomain.ToolResult{}, nil +} + +type fakeMemoryProvider struct { + output []aidomain.Message + calls int +} + +func (f *fakeMemoryProvider) RecallMessages(context.Context, aiMemoryRecallInput) ([]aidomain.Message, error) { + f.calls++ + return f.output, nil +} + +type fakeContextCompressor struct { + output []aidomain.Message + calls int +} + +func (f *fakeContextCompressor) CompressMessages(context.Context, aiContextCompressionInput) ([]aidomain.Message, error) { + f.calls++ + return f.output, nil +} + +type fakePromptBuilder struct { + output string + calls int +} + +func (f *fakePromptBuilder) BuildDynamicPrompt([]aidomain.Tool, aidomain.AIToolPrincipal) string { + f.calls++ + return f.output +} + +func TestDefaultAIContextAssemblerUsesStoredHistoryAndDefaultPrompt(t *testing.T) { + assembler := newAIContextAssembler(AIDeps{}) + tool := &fakeContextTool{ + spec: aidomain.ToolSpec{ + Name: "get_my_oj_stats", + Description: "获取当前登录用户在指定 OJ 平台上的个人统计。", + }, + } + orgID := uint(9) + + snapshot, err := assembler.Build(context.Background(), aiContextBuildArgs{ + ConversationID: "conv_1", + UserID: 7, + Query: "帮我看一下排名", + StoredMessages: []*entity.AIMessage{ + {ID: "msg_1", Role: aidomain.RoleUser, Content: "第一句"}, + {ID: "msg_2", Role: aidomain.RoleAssistant, Content: "第二句"}, + {ID: "msg_3", Role: aidomain.RoleUser, Content: " "}, + }, + VisibleTools: []aidomain.Tool{tool}, + ToolCallCtx: aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{ + UserID: 7, + CurrentOrgID: &orgID, + }, + }, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if len(snapshot.History) != 2 { + t.Fatalf("history len = %d, want 2", len(snapshot.History)) + } + if snapshot.History[0].Role != aidomain.RoleUser { + t.Fatalf("history[0].Role = %q", snapshot.History[0].Role) + } + if snapshot.History[1].Role != aidomain.RoleAssistant { + t.Fatalf("history[1].Role = %q", snapshot.History[1].Role) + } + if snapshot.DynamicSystemPrompt == "" { + t.Fatal("DynamicSystemPrompt = empty") + } + if want := "get_my_oj_stats"; !strings.Contains(snapshot.DynamicSystemPrompt, want) { + t.Fatalf("DynamicSystemPrompt = %q, want contains %q", snapshot.DynamicSystemPrompt, want) + } + if want := "不要猜测"; !strings.Contains(snapshot.DynamicSystemPrompt, want) { + t.Fatalf("DynamicSystemPrompt = %q, want contains %q", snapshot.DynamicSystemPrompt, want) + } +} + +func TestDefaultAIContextAssemblerCallsOptionalProviders(t *testing.T) { + memory := &fakeMemoryProvider{ + output: []aidomain.Message{ + {ID: "mem_1", Role: aidomain.RoleAssistant, Content: "记忆片段"}, + }, + } + compressor := &fakeContextCompressor{ + output: []aidomain.Message{ + {ID: "cmp_1", Role: aidomain.RoleAssistant, Content: "压缩后的上下文"}, + }, + } + promptBuilder := &fakePromptBuilder{output: "custom prompt"} + assembler := newAIContextAssembler(AIDeps{ + Memory: memory, + Compressor: compressor, + PromptBuilder: promptBuilder, + }) + + snapshot, err := assembler.Build(context.Background(), aiContextBuildArgs{ + ConversationID: "conv_2", + UserID: 8, + Query: "帮我查一下统计", + StoredMessages: []*entity.AIMessage{ + {ID: "msg_1", Role: aidomain.RoleUser, Content: "原始历史"}, + }, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if memory.calls != 1 { + t.Fatalf("memory calls = %d, want 1", memory.calls) + } + if compressor.calls != 1 { + t.Fatalf("compressor calls = %d, want 1", compressor.calls) + } + if promptBuilder.calls != 1 { + t.Fatalf("promptBuilder calls = %d, want 1", promptBuilder.calls) + } + if len(snapshot.History) != 1 || snapshot.History[0].ID != "cmp_1" { + t.Fatalf("snapshot.History = %+v, want compressed output", snapshot.History) + } + if snapshot.DynamicSystemPrompt != "custom prompt" { + t.Fatalf("DynamicSystemPrompt = %q", snapshot.DynamicSystemPrompt) + } +} diff --git a/internal/service/system/aiMapper.go b/internal/service/system/aiMapper.go index ebf3ed9..d90a80a 100644 --- a/internal/service/system/aiMapper.go +++ b/internal/service/system/aiMapper.go @@ -151,6 +151,8 @@ func messageToResp(message *entity.AIMessage) (*resp.AssistantMessageResp, error }, nil } +// messagesToRuntimeHistory 负责执行当前函数对应的核心逻辑。 +// 作用:把消息实体列表转成运行时历史消息列表。 func messagesToRuntimeHistory(messages []*entity.AIMessage) []aidomain.Message { items := make([]aidomain.Message, 0, len(messages)) for _, message := range messages { diff --git a/internal/service/system/aiPrompt.go b/internal/service/system/aiPrompt.go new file mode 100644 index 0000000..e18b34b --- /dev/null +++ b/internal/service/system/aiPrompt.go @@ -0,0 +1,19 @@ +package system + +import aidomain "personal_assistant/internal/domain/ai" + +// aiPromptBuilder 负责生成本轮 runtime 需要的动态 system prompt。 +// 该接口仅在 service/system 内部使用,不向 controller 或 domain 泄漏具体实现。 +type aiPromptBuilder interface { + BuildDynamicPrompt(tools []aidomain.Tool, principal aidomain.AIToolPrincipal) string +} + +// defaultAIToolPromptBuilder 复用当前内建的工具约束 prompt 逻辑。 +type defaultAIToolPromptBuilder struct{} + +func (defaultAIToolPromptBuilder) BuildDynamicPrompt( + tools []aidomain.Tool, + principal aidomain.AIToolPrincipal, +) string { + return buildAIToolDynamicPrompt(tools, principal) +} diff --git a/internal/service/system/aiSvc.go b/internal/service/system/aiSvc.go index f756549..6f03bc5 100644 --- a/internal/service/system/aiSvc.go +++ b/internal/service/system/aiSvc.go @@ -45,6 +45,8 @@ type AIService struct { authorizationSvc aiAuthorizationService // toolRegistry 负责注册、过滤并执行本轮可见 AI tool。 toolRegistry *aiToolRegistry + // contextAssembler 负责组装 runtime 所需的历史消息和动态 prompt。 + contextAssembler aiContextAssembler } // NewAIService 负责组装 AIService 所需依赖。 @@ -102,6 +104,8 @@ func newAIServiceWithDeps( authorizationSvc: deps.Authorization, // tool registry 根据当前注入依赖决定哪些工具真正可用。 toolRegistry: newAIToolRegistry(deps), + // 上下文装配器负责收口历史消息、动态 prompt 和未来扩展点。 + contextAssembler: newAIContextAssembler(deps), } } @@ -253,7 +257,7 @@ func (s *AIService) StreamConversation( return bizerrors.New(bizerrors.CodeUserNotFound) } - historyMessages, err := s.aiRepo.ListMessagesByConversation(ctx, conversation.ID) + storedMessages, err := s.aiRepo.ListMessagesByConversation(ctx, conversation.ID) if err != nil { return bizerrors.Wrap(bizerrors.CodeDBError, err) } @@ -311,6 +315,19 @@ func (s *AIService) StreamConversation( return err } + // 统一由上下文装配器收口历史消息和动态 prompt,方便后续接入记忆召回和压缩。 + contextSnapshot, err := s.contextAssembler.Build(ctx, aiContextBuildArgs{ + ConversationID: conversation.ID, + UserID: userID, + Query: strings.TrimSpace(req.Content), + StoredMessages: storedMessages, + VisibleTools: visibleTools, + ToolCallCtx: toolCallCtx, + }) + if err != nil { + return bizerrors.Wrap(bizerrors.CodeInternalError, err) + } + // 把动态 prompt、可见工具和调用上下文一并注入 runtime。 _, execErr := s.runtime.Stream(ctx, aidomain.StreamInput{ UserID: userID, @@ -318,8 +335,8 @@ func (s *AIService) StreamConversation( UserMessageID: userMessage.ID, AssistantMessageID: assistantMessage.ID, Content: strings.TrimSpace(req.Content), - History: messagesToRuntimeHistory(historyMessages), - DynamicSystemPrompt: buildAIToolDynamicPrompt(visibleTools, toolPrincipal), + History: contextSnapshot.History, + DynamicSystemPrompt: contextSnapshot.DynamicSystemPrompt, Tools: visibleTools, ToolCallContext: toolCallCtx, }, sink) diff --git a/internal/service/system/aiTool.go b/internal/service/system/aiTool.go index f39890a..edc906a 100644 --- a/internal/service/system/aiTool.go +++ b/internal/service/system/aiTool.go @@ -73,6 +73,12 @@ type AIDeps struct { OJTask aiOJTaskService // Observability 提供 trace 和指标查询能力。 Observability aiObservabilityService + // Memory 提供可选的额外记忆召回能力;未注入时保持只读历史消息路径。 + Memory aiMemoryProvider + // Compressor 提供可选的上下文压缩能力;未注入时保持原始历史消息。 + Compressor aiContextCompressor + // PromptBuilder 允许调用方替换默认动态 prompt 构造逻辑。 + PromptBuilder aiPromptBuilder } // aiToolPolicyKind 表示工具可见性和执行前鉴权采用的策略类型。 diff --git a/plan/cross-module/approved-ai-react-v1.md b/plan/cross-module/approved-ai-react-v1.md new file mode 100644 index 0000000..395e372 --- /dev/null +++ b/plan/cross-module/approved-ai-react-v1.md @@ -0,0 +1,48 @@ +# 目标 + +在当前 `domain/ai -> service/system -> infrastructure/ai/eino` 分层内完成自研单 Agent ReAct V1,保持 HTTP 和 domain 稳定协议不变,不接入 ADK、不引入 interrupt/resume/planner,并为后续 `memory / 上下文压缩 / LLM 网关` 预埋接口与装配点。 + +# 范围 + +- 保持现有 AI HTTP 路由、控制器、DTO 和 SSE 事件集不变。 +- 在 `service/system` 内新增上下文装配扩展点,收口历史消息转换、动态 prompt 构造和未来上下文扩展点。 +- 在 `infrastructure/ai/eino` 内稳定当前 ReAct tool loop,并预埋可选 `ChatModelFactory` 注入点。 +- 保留现有工具集,不新增业务工具。 + +# 改动 + +- 保持 `aidomain.Runtime`、`aidomain.StreamInput`、`aidomain.Event`、`aidomain.Tool` 协议不变。 +- 扩展 `AIDeps`,增加可选的 `Memory / Compressor / PromptBuilder` 依赖。 +- 新增 `aiMemoryProvider / aiContextCompressor / aiPromptBuilder / aiContextAssembler` 内部协作者。 +- 将上下文装配从 `aiSvc.go` 中下沉为独立协作者,但继续由 `AIService.StreamConversation` 负责主编排。 +- 继续由 `aiToolRegistry` 负责工具注册、可见性过滤和执行前二次鉴权。 +- 规范动态 prompt,固定“只用可见工具、缺参不猜、执行期仍会再鉴权、工具失败不编造结果”等约束。 +- 在 `eino` runtime 中保持 `streamTextOnly + streamWithTools` 双路径和单 Agent 顺序 ReAct 循环。 +- 对 `chat_model.go` / `options.go` 预埋 `ChatModelFactory` 注入点;默认未注入时沿用现有 provider 分支。 +- 补齐 ReAct 相关测试,覆盖无工具、有工具、缺参追问、工具失败、未注入扩展依赖等场景。 + +# 验证 + +- `go test ./internal/infrastructure/ai/eino` +- `go test ./internal/service/system` +- 必要时补充针对 AI 相关文件的定向测试用例 + +# 风险 + +- 动态 prompt 和上下文装配拆分后,若拼装顺序变动,可能影响模型输出和工具选择行为。 +- `ChatModelFactory` 预埋若处理不当,可能破坏现有 `qwen/openai/ark` provider 路径。 +- 保持“工具失败即终止当前轮”的既有行为,需确保 trace 和 SSE 错误终态仍能稳定收敛。 + +# 执行顺序 + +1. 计划文件落盘并转为 `approved` +2. 拆出 `service/system` 的上下文装配与 prompt 协作者 +3. 扩展 `AIDeps` 和 `AIService` 的上下文依赖注入 +4. 拆出 `eino` 的 tool schema 适配文件,并预埋 `ChatModelFactory` +5. 补充和修正单元测试 +6. 执行定向 `go test` + +# 待确认 + +- 本次缺参场景采用普通多轮对话自然追问,不设计 interrupt/resume 状态机。 +- 后续能力仅预埋接口和装配点,不提供默认空实现,不引入当前不可见的新行为分支。 diff --git a/scripts/cleanup_soft_deleted_observability.ps1 b/scripts/cleanup_soft_deleted_observability.ps1 deleted file mode 100644 index ad26c97..0000000 --- a/scripts/cleanup_soft_deleted_observability.ps1 +++ /dev/null @@ -1,126 +0,0 @@ -# Cleanup script for already soft-deleted observability rows. -# -# Usage examples: -# ./scripts/cleanup_soft_deleted_observability.ps1 -MySqlCli "mysql" -MySqlDB "meta_assist" -# ./scripts/cleanup_soft_deleted_observability.ps1 -MySqlHost "127.0.0.1" -MySqlPort 3306 -MySqlUser "root" -MySqlPassword "secret" -MySqlDB "meta_assist" -BatchSize 1000 -# ./scripts/cleanup_soft_deleted_observability.ps1 -DryRun - -param( - [string]$MySqlCli = "mysql", - [string]$MySqlHost = "127.0.0.1", - [int]$MySqlPort = 3306, - [string]$MySqlUser = "root", - [string]$MySqlPassword = "", - [string]$MySqlDB = "", - [int]$BatchSize = 1000, - [switch]$DryRun -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -function New-MySqlArgs { - param( - [string]$Sql, - [switch]$MaskPassword - ) - - $args = @("-h", $MySqlHost, "-P", "$MySqlPort", "-u", $MySqlUser, "-N", "-B") - if ($MySqlPassword -ne "") { - if ($MaskPassword) { - $args += "-p******" - } else { - $args += "-p$MySqlPassword" - } - } - $args += $MySqlDB - $args += "-e" - $args += $Sql - return $args -} - -function Invoke-MySqlText { - param( - [string]$Sql - ) - - if ([string]::IsNullOrWhiteSpace($MySqlDB)) { - throw "MySqlDB is empty." - } - - $displayArgs = New-MySqlArgs -Sql $Sql -MaskPassword - if ($DryRun) { - Write-Host "[DryRun][MySQL] $MySqlCli $($displayArgs -join ' ')" - return "" - } - - $args = New-MySqlArgs -Sql $Sql - return (& $MySqlCli @args | Out-String).Trim() -} - -function Invoke-MySqlInt { - param( - [string]$Sql - ) - - $raw = Invoke-MySqlText -Sql $Sql - if ([string]::IsNullOrWhiteSpace($raw)) { - return 0 - } - return [int64]::Parse(($raw -split "`r?`n")[-1].Trim()) -} - -function Remove-SoftDeletedRowsByTable { - param( - [string]$TableName - ) - - Write-Host "Checking table $TableName ..." - $countSql = "SELECT COUNT(*) FROM $TableName WHERE deleted_at IS NOT NULL;" - $initialTotal = Invoke-MySqlInt -Sql $countSql - - if ($DryRun) { - $deleteSql = "DELETE FROM $TableName WHERE deleted_at IS NOT NULL ORDER BY deleted_at ASC, id ASC LIMIT $BatchSize; SELECT ROW_COUNT();" - Invoke-MySqlText -Sql $deleteSql | Out-Null - return - } - - Write-Host "Table $TableName soft-deleted rows: $initialTotal" - if ($initialTotal -le 0) { - return - } - - $remaining = $initialTotal - $deletedTotal = 0 - $round = 0 - - while ($true) { - $round++ - $deleteSql = "DELETE FROM $TableName WHERE deleted_at IS NOT NULL ORDER BY deleted_at ASC, id ASC LIMIT $BatchSize; SELECT ROW_COUNT();" - $affected = Invoke-MySqlInt -Sql $deleteSql - if ($affected -le 0) { - break - } - - $deletedTotal += $affected - $remaining = [Math]::Max(0, $remaining - $affected) - Write-Host ("[{0}] table={1} deleted={2} deleted_total={3} remaining_estimate={4}" -f $round, $TableName, $affected, $deletedTotal, $remaining) - } - - $finalRemaining = Invoke-MySqlInt -Sql $countSql - Write-Host "Table $TableName cleanup completed. deleted_total=$deletedTotal remaining=$finalRemaining" -} - -if ($BatchSize -le 0) { - throw "BatchSize must be greater than 0." -} - -if ([string]::IsNullOrWhiteSpace($MySqlDB)) { - Write-Host "MySqlDB is empty, skip observability cleanup." - exit 0 -} - -Write-Host "Starting soft-deleted observability cleanup..." -Remove-SoftDeletedRowsByTable -TableName "observability_trace_spans" -Remove-SoftDeletedRowsByTable -TableName "observability_metrics" -Write-Host "Soft-deleted observability cleanup completed." From b20fdc83c7f82adaba52d207ee7c77d5906130b2 Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 25 Apr 2026 14:57:36 +0800 Subject: [PATCH 13/17] =?UTF-8?q?=E5=AE=8C=E5=96=84tool=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...56\345\272\223\351\205\215\347\275\256.md" | 0 "docs/AI/ReAct\350\256\276\350\256\241.md" | 582 +++--------- ...45\274\217Tool\344\274\230\345\214\226.md" | 283 ++++++ ...41\345\235\227\350\256\276\350\256\241.md" | 548 +++++++++--- internal/domain/ai/progressive.go | 80 ++ internal/domain/ai/tool.go | 26 + internal/domain/ai/tool_descriptor.go | 17 + internal/domain/ai/tool_error.go | 124 +++ internal/infrastructure/ai/eino/runtime.go | 68 +- .../ai/eino/runtime_tools_test.go | 338 ++++++- internal/infrastructure/ai/eino/selector.go | 204 +++++ .../infrastructure/ai/eino/selector_test.go | 95 ++ .../infrastructure/ai/eino/tool_repair.go | 160 ++++ .../infrastructure/ai/eino/tool_schema.go | 43 +- .../infrastructure/ai/eino/tool_validation.go | 489 +++++++++++ .../ai/eino/tool_validation_test.go | 117 +++ internal/service/system/aiContext.go | 20 +- internal/service/system/aiContext_test.go | 33 +- internal/service/system/aiDeps.go | 15 + internal/service/system/aiProgressive.go | 23 + internal/service/system/aiPrompt.go | 19 - internal/service/system/aiSvc.go | 34 +- internal/service/system/aiTool_test.go | 370 -------- internal/service/system/aiselect/planner.go | 126 +++ .../service/system/aiselect/planner_test.go | 228 +++++ internal/service/system/aiselect/prompt.go | 96 ++ internal/service/system/aitool/deps.go | 65 ++ internal/service/system/aitool/metadata.go | 109 +++ internal/service/system/aitool/registry.go | 6 + .../service/system/aitool/registry_test.go | 691 +++++++++++++++ .../system/{aiTool.go => aitool/tools.go} | 828 ++++++++++++------ internal/service/system/aitool/validation.go | 293 +++++++ internal/service/system/supplier.go | 39 +- ...oved-ai-tool-registry-metadata-refactor.md | 53 ++ 34 files changed, 4927 insertions(+), 1295 deletions(-) rename "docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256" => "docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256.md" (100%) create mode 100644 "docs/AI/Tool\350\256\276\350\256\241-\346\270\220\350\277\233\345\274\217Tool\344\274\230\345\214\226.md" create mode 100644 internal/domain/ai/progressive.go create mode 100644 internal/domain/ai/tool_descriptor.go create mode 100644 internal/domain/ai/tool_error.go create mode 100644 internal/infrastructure/ai/eino/selector.go create mode 100644 internal/infrastructure/ai/eino/selector_test.go create mode 100644 internal/infrastructure/ai/eino/tool_repair.go create mode 100644 internal/infrastructure/ai/eino/tool_validation.go create mode 100644 internal/infrastructure/ai/eino/tool_validation_test.go create mode 100644 internal/service/system/aiDeps.go create mode 100644 internal/service/system/aiProgressive.go delete mode 100644 internal/service/system/aiPrompt.go delete mode 100644 internal/service/system/aiTool_test.go create mode 100644 internal/service/system/aiselect/planner.go create mode 100644 internal/service/system/aiselect/planner_test.go create mode 100644 internal/service/system/aiselect/prompt.go create mode 100644 internal/service/system/aitool/deps.go create mode 100644 internal/service/system/aitool/metadata.go create mode 100644 internal/service/system/aitool/registry.go create mode 100644 internal/service/system/aitool/registry_test.go rename internal/service/system/{aiTool.go => aitool/tools.go} (65%) create mode 100644 internal/service/system/aitool/validation.go create mode 100644 plan/ai/approved-ai-tool-registry-metadata-refactor.md diff --git "a/docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256" "b/docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256.md" similarity index 100% rename from "docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256" rename to "docs/AI/Qdrant\345\220\221\351\207\217\346\225\260\346\215\256\345\272\223\351\205\215\347\275\256.md" diff --git "a/docs/AI/ReAct\350\256\276\350\256\241.md" "b/docs/AI/ReAct\350\256\276\350\256\241.md" index 30c7d9e..eebfd87 100644 --- "a/docs/AI/ReAct\350\256\276\350\256\241.md" +++ "b/docs/AI/ReAct\350\256\276\350\256\241.md" @@ -1,514 +1,148 @@ -这次实现的核心思路其实很简单: +# ReAct 设计 -**我没有去“发明一个新框架”,而是把你现有的三层边界补完整,让它稳定地跑单 Agent ReAct。** +## 当前版本目标 -也就是: +当前实现采用**三段式渐进 Tool 加载**,目标是减少每轮把全部工具详细 schema 一次性暴露给模型的 token 成本,同时保留现有 ReAct 的参数自修正和工具执行稳定性。 -- `domain/ai` 继续只定义协议 -- `service/system` 继续负责业务编排、工具过滤、上下文装配 -- `infrastructure/ai/eino` 继续负责模型执行和 tool loop -- `aiSink + aiProjector` 继续负责 SSE 和消息落库 +完整链路: -所以你现在得到的不是“推翻重写”,而是“把原来已有骨架补成可交付版本”。 +1. `ToolGroupBrief`:先选工具组 +2. `ToolBrief`:在组内再选候选工具 +3. `ToolSpec`:只对最终选中的工具注入完整 schema,再执行最终 ReAct ---- +## 分层职责 -**一、我到底实现了什么** +- `internal/domain/ai` + - 定义共享的渐进式选择协议:`ToolGroupBrief`、`ToolBrief`、`ToolGroupSelection`、`ToolSelection` +- `internal/service/system` + - 过滤本轮可见工具 + - 生成工具组 brief / 工具 brief + - 调用内部 selector + - 只把最终选中的工具交给 runtime +- `internal/infrastructure/ai/eino` + - 实现内部 selector(非流式、严格 JSON 输出) + - 执行最终 ReAct runtime -这次主要做了 4 件事。 +## 运行时流程 -1. 把“上下文装配”从 `AIService` 里抽出来,变成单独协作者。 -入口还是 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:223),但它现在不再自己直接拼 `History + DynamicSystemPrompt`,而是交给 `contextAssembler`,[aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:38)、[aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:319)。 +```mermaid +flowchart TD + A["用户输入"] --> B["AIService 过滤可见工具"] + B --> C["contextAssembler 只组装基础 History"] + C --> D["第一阶段 selector: ToolGroupBrief"] + D -->|direct_answer| E["最终 runtime 无工具回答"] + D -->|ask_user| F["最终 runtime 无工具追问"] + D -->|select_group| G["第二阶段 selector: ToolBrief"] + G -->|confidence=high| H["展开 1~3 个工具"] + G -->|confidence=low 或空结果| I["预扩到该组全部工具"] + H --> J["最终 runtime: full ToolSpec + ReAct"] + I --> J + D -->|selector 失败| K["回退单阶段: 全量可见工具"] + G -->|selector 失败| K + K --> J +``` -2. 预埋了后续扩展点,但默认不启用。 -也就是: -- `aiMemoryProvider` -- `aiContextCompressor` -- `aiPromptBuilder` +## selector 规则 -这些都在 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:55) 和 [aiPrompt.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiPrompt.go:7)。 -默认没注入时,行为和你之前基本一致。 +### 第一阶段:选组 -3. 把 ReAct runtime 固化成两条路径。 -在 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:79): -- 没工具:走 `streamTextOnly` -- 有工具:走 `streamWithTools` +输入: -这样逻辑边界就很清楚,不会把普通对话和 ReAct 混成一锅。 - -4. 给未来的 LLM 网关留了注入口。 -新增了 `ChatModelFactory`,定义在 [options.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/options.go:12),实际使用在 [chat_model.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/chat_model.go:30)。 -现在不用它也没关系,但以后你做网关、模型路由、熔断,就不需要改 service 层。 - ---- - -**二、为什么要这样做** - -因为你现在真正需要的不是“更多功能”,而是**把控制面和执行面分开**。 - -之前 `AIService` 已经做很多事情了: -- 查会话 -- 查历史 -- 创建消息 -- 过滤工具 -- 拼 prompt -- 调 runtime -- 收尾 - -这没错,但“上下文构造”已经开始变成一个独立职责了。你后面要加: -- memory recall -- 上下文压缩 -- prompt builder -- 甚至未来 query rewrite - -如果这些还堆在 `AIService` 里,`StreamConversation` 会越来越难看。 - -所以我把它抽成了: - -- `AIService` 负责调度 -- `aiContextAssembler` 负责构造 runtime 输入 - -这一步是这次实现里最重要的结构变化。 - ---- - -**三、一次请求现在是怎么跑的** - -你可以把当前完整链路理解成这 8 步。 - -### 1. Controller 调到 `AIService.StreamConversation` - -主入口还是 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:223)。 - -这里做的是业务入口校验: -- `req` 不能为空 -- 路径上的 `conversationID` 和 body 里的要一致 -- `writer` 不能是空 -- `runtime` 不能是空 - -然后读取: -- 会话 -- 用户 +- 用户当前 query - 历史消息 +- 当前可见的 `ToolGroupBrief` ---- - -### 2. 创建本轮 user/assistant 消息骨架并落库 - -这一步还在 `AIService` 里。 - -会先创建两条消息: -- 用户消息:直接 `success` -- assistant 消息:先是 `loading` - -然后调用 `persistStreamStart(...)` 做事务化写入。 -这样做的目的,是在真正调用模型前,数据库里就已经有这轮对话的“骨架”。 - -好处是: -- 前端列表页能立即看到“生成中” -- 后续就算流中断,库里也有 assistant 占位消息可追踪 - ---- - -### 3. 创建 sink - -这里创建的是 [aiSink.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSink.go:16) 里的 `aiStreamSink`。 - -它的职责非常关键: - -- 往 SSE 发事件 -- 把这些事件折叠成 assistant 消息状态 -- 定时落库 - -也就是说 runtime 根本不需要知道数据库和 SSE,它只管发 `aidomain.Event`。 -这就是你现在这套设计最好的地方之一。 - ---- - -### 4. 构造工具调用上下文和 principal - -在 `AIService` 里会构造: - -- `AIToolPrincipal` -- `ToolCallContext` - -`principal` 只保存最小授权事实: -- `UserID` -- `CurrentOrgID` -- `IsSuperAdmin` - -而不是塞一大堆业务对象。 -这个定义在 [tool.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/tool.go:61)。 - -这一步的意义是: -**service 把“谁在调用”准备好,tool runtime 只消费这个最小事实。** - ---- - -### 5. 过滤本轮可见工具 - -调用的是 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:190) 的 `FilterVisibleTools(...)`。 - -这里做的是“本轮可见性过滤”,不是最终执行授权。 -比如: -- self only -- org capability -- super admin only - -只把当前用户**看得见**的工具暴露给模型。 - -为什么要这样做? -因为如果你把所有工具都给模型,它会尝试调用本来不该看到的工具,失败率会很高。 - -所以现在是两层保护: -1. 可见性过滤:决定模型能不能看见 -2. 执行前二次鉴权:决定工具能不能真正执行 - -这点你原来的设计就很好,我保留了。 - ---- - -### 6. 由 `contextAssembler` 统一构造 runtime 输入 - -这是这次新加的核心点。 - -位置在 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:79)。 - -它现在做三件事: - -1. 把 DB 历史消息转成 `aidomain.Message` -2. 可选追加 memory recall -3. 可选做上下文压缩 -4. 生成动态 prompt - -它的返回值是: - -- `History` -- `DynamicSystemPrompt` - -也就是最后真正喂给 runtime 的上下文快照。 - -默认行为非常保守: -- `Memory == nil`:不召回 -- `Compressor == nil`:不压缩 -- `PromptBuilder == nil`:走默认 prompt builder - -所以这次改动不会改变你现在线上行为。 - ---- - -### 7. runtime 执行 ReAct - -入口在 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:79)。 +输出: -它先发一个: -- `conversation_started` +- `decision=direct_answer` +- `decision=ask_user` +- `decision=select_group` -然后分成两条路: +第一阶段不返回用户可见 prose,只返回内部结构化 JSON。 -#### 路径 A:无工具 -走 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:110) 的 `streamTextOnly(...)` +### 第二阶段:组选中后的工具选择 -流程是: -- 构造 messages -- 调模型流式输出 -- 每个 chunk 发 `assistant_token` -- 结束时发 `message_completed` -- 最后发 `done` +输入: -#### 路径 B:有工具 -走 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:164) 的 `streamWithTools(...)` - -流程是: -1. 绑定本轮可见工具到模型 -2. 构造完整 messages -3. 模型先回答一轮 assistant -4. 如果 assistant 没有 tool call: - - 直接进入最终回答 -5. 如果 assistant 有 tool call: - - 顺序执行工具 - - 把 tool result 转成 `ToolMessage` - - 再塞回 messages - - 模型继续下一轮 - -这就是标准单 Agent ReAct loop。 - ---- - -### 8. sink/projector 折叠事件并落库 - -runtime 发出来的不是 HTTP 响应,也不是 DB 更新,而是 `aidomain.Event`。 - -这些事件进入 [aiSink.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSink.go:45) 的 `Emit(...)` 后,会发生两件事: - -1. 写到 SSE -2. 调 `projector.applyEvent(...)` - -投影逻辑在 [aiProjector.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProjector.go:104)。 - -它会根据事件更新 assistant 消息快照: - -- `assistant_token` - 追加正文 -- `tool_call_started` - 创建 running trace item -- `tool_call_finished` - 更新 trace item 为 success/failed -- `message_completed` - 覆盖最终正文并标记 success -- `error` - 写错误文案并标记 error - -最终 `trace_items_json` 和 `content/status/error_text` 都会落到消息表里。 - ---- - -**四、动态 prompt 是怎么工作的** - -默认 prompt 逻辑还在 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:275)。 - -它现在固定会告诉模型: - -- 本轮只能用列出的工具 -- 缺少 `org_id/task_id/execution_id/request_id` 时不要猜 -- 工具可见不等于一定能执行成功 -- 当前组织上下文是什么 -- 每个工具参数是什么 - -这个 prompt 的目标不是“教模型变聪明”,而是**降低错误调用率**。 - -举个例子: - -如果用户说“帮我查排名”,而工具要求 `platform` 必填,模型此时应该: -- 不去乱猜 `leetcode` -- 不去乱调工具 -- 而是直接自然追问用户 - -这个行为在测试里已经覆盖了,[runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:231)。 - ---- - -**五、工具执行阶段我做了哪些细节处理** - -工具执行在 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:340)。 - -这里有几个关键点: - -1. `tool name` 会先 `TrimSpace` -避免模型输出名字前后有空格导致匹配失败。 - -2. `ArgumentsJSON` 原文完整透传 -不会在 runtime 层先偷偷改参数结构,工具自己解析。 - -3. 每次工具调用都会发两种事件 -- `tool_call_started` -- `tool_call_finished` - -这样前端 trace 能稳定显示运行态和结束态。 - -4. 工具失败时,先发失败 trace,再返回 error -这点很重要。 -如果工具失败但没有 `finished(failed)` 事件,前端会一直停在“running”。 -我现在保证失败也会补一个 failed trace。 - -这部分也加了测试,[runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:273)。 - ---- - -**六、为什么我没有做 interrupt/resume** - -因为你后面已经明确了: -你当前缺参数场景,本质上就是普通多轮对话。 - -比如: -- 用户说“查我的排名” -- 模型说“你要查 leetcode 还是 luogu” -- 用户下一轮回答“leetcode” - -这根本不需要 runtime 级 interrupt。 -这只是 assistant 在正常追问。 - -所以我这次坚持了一个原则: - -**缺参时,模型自然追问;不是 runtime 状态机去接管。** - -这让你的 ReAct V1 保持简单,也更符合当前产品阶段。 - ---- - -**七、我为什么要加 `aiContextAssembler`、`aiPromptBuilder`、`ChatModelFactory`** - -这三个是“为未来留钩子”,但又不污染现在逻辑。 - -### `aiContextAssembler` -这是给你未来接: -- memory recall -- 历史裁剪 -- 压缩摘要 - -的位置。 - -如果以后你要做: -- 最近 20 条消息 + 历史摘要 -- 召回用户长期偏好 -- 对话主题记忆 - -就改它,不需要把 `AIService` 撕开。 - -### `aiPromptBuilder` -这是给你未来做: -- 不同业务模式下不同 prompt -- A/B prompt 实验 -- prompt 配置外置化 - -的位置。 - -### `ChatModelFactory` -这是给你未来做: -- LLM 网关 -- provider 路由 -- 模型降级 -- 熔断重试 - -的位置。 - -它只存在于 infrastructure,不会上浮到 service。 -这点很重要,因为这样不会让 `service/system` 被 Eino 反向污染。 - ---- - -**八、测试是怎么证明这套实现可用的** - -我补了几组关键测试。 - -### 上下文装配测试 -在 [aiContext_test.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext_test.go:54) 和 [aiContext_test.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext_test.go:104) - -验证: -- 默认会用 stored history -- 默认 prompt 会包含工具说明和“不要猜测” -- memory/compressor/promptBuilder 注入后会被调用 -- 没注入时行为不变 - -### runtime 纯文本路径 -在 [runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:91) +- 用户当前 query +- 历史消息 +- 已选中的 `ToolGroupBrief` +- 该组下的 `ToolBrief` -验证: -- 无工具时按纯文本流跑 -- 事件顺序正确 +输出: -### runtime 工具路径 -在 [runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:133) +- `selected_tool_names` +- `confidence=high|low` -验证: -- tool schema 绑定成功 -- 动态 prompt 注入成功 -- tool arguments 原文透传成功 -- tool call context 正确带入 -- 事件顺序正确 +约束: -### 缺参自然追问 -在 [runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:231) +- 最多选 3 个工具 +- `confidence=low` 或空结果时,预扩为该组全部工具 +- selector 失败时,直接回退到单阶段全量工具路径 -验证: -- 有工具但模型不调用工具,直接追问用户 -- 这是合法路径 +## prompt 与 schema 策略 -### 工具失败 trace -在 [runtime_tools_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime_tools_test.go:273) +### 第一、二阶段 -验证: -- 工具失败时会先发 `tool_call_finished(failed)` -- 不会让前端 trace 卡死在 running +第一、二阶段只消费 brief: -### 自定义模型工厂 -在 [chat_model_test.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/chat_model_test.go:10) +- `ToolGroupBrief` +- `ToolBrief` -验证: -- `ChatModelFactory` 注入时,`NewChatModel` 会优先使用它 +这里不注入完整 `ToolSpec`,也不展开字段级 schema 细节。 ---- +### 最终执行阶段 -**九、你现在这套 ReAct 的完整职责边界** +最终进入 ReAct 时,仍使用完整 `ToolSpec/ToolParameter`: -你可以这么记: +- enum +- format +- range +- pattern +- examples +- default -### `domain/ai` -只定义协议: -- `Runtime` -- `StreamInput` -- `Tool` -- `Event` +但最终执行 prompt 只保留全局规则,不再逐个把所有字段细节重复翻译成大段自然语言。 -看这里: -[runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/runtime.go:5) -[tool.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/tool.go:5) -[event.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/event.go:5) +## 错误恢复语义 -### `service/system` -只做业务编排: -- 查用户/会话/历史 -- 建消息骨架 -- 过滤工具 -- 组上下文 -- 调 runtime -- 做流式收尾 +最终执行阶段仍保留原有恢复策略: -看这里: -[aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:223) -[aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:55) -[aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:67) +- `missing_user_input` + - 追问用户,不继续调工具 +- `repairable_invalid_param` + - 同轮修正参数并重试 +- `terminal_tool_error` + - 终止本轮 -### `infrastructure/ai/eino` -只做执行: -- 创建 model -- tool binding -- tool loop -- 发事件 +渐进式加载只优化“工具发现与暴露”,不改变最终 ReAct 的错误恢复语义。 -看这里: -[runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:79) -[chat_model.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/chat_model.go:30) +## 当前工具分组 -### `aiSink + aiProjector` -只做状态汇聚: -- SSE 输出 -- assistant 消息内容折叠 -- tool trace 折叠 -- DB 落库 +- `oj_personal` + - `get_my_ranking` + - `get_my_oj_stats` + - `get_my_oj_curve` +- `oj_org` + - `get_org_ranking_summary` +- `oj_task` + - `get_task_execution_summary` + - `list_task_execution_users` + - `get_task_execution_user_detail` + - `analyze_task_titles` +- `observability_trace` + - `query_trace_detail_by_request_id` + - `query_trace_summary` +- `observability_metrics` + - `query_runtime_metrics` + - `query_observability_metrics` -看这里: -[aiSink.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSink.go:45) -[aiProjector.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProjector.go:104) - ---- - -**十、如果你要继续往下做,下一步最合理是什么** - -现在最值得继续做的不是 ADK,也不是 interrupt,而是这三项里的一个: - -1. 工具失败分类 -现在 runtime 还是“工具失败即整轮失败”。后面你可以细分: -- 参数错误 -> 更适合让模型追问 -- 业务错误 -> 直接解释 -- 系统错误 -> 整轮失败 - -2. 记忆模块落地 -你已经有 `aiMemoryProvider` 入口了。下一步可以直接挂: -- 最近主题摘要 -- 用户偏好记忆 -- 重要事实召回 - -3. 上下文压缩 -你已经有 `aiContextCompressor`。可以先做最简单版: -- 历史消息超过 N 条时摘要前半段 -- 保留最近 K 条原文 - -如果你愿意,我下一条可以继续直接给你画一张更具体的图: +## 默认策略 -**“用户发一句话后,`AIService -> ContextAssembler -> Runtime -> Tool -> Sink -> Projector -> DB/SSE` 的时序图”** - -或者我也可以直接继续帮你做下一步实现,比如: -- `memory provider` -- `上下文压缩` -- `工具失败分类` \ No newline at end of file +- 始终启用三段式渐进加载 +- selector 对用户完全不可见 +- 第一阶段先选组,第二阶段选工具,第三阶段才注入 full spec +- 第二阶段最多选 3 个工具 +- 第二阶段 `confidence=low` 时,预扩到该组全部工具一次 +- selector 失败时,回退到当前单阶段全量工具路径 +- 不引入 ADK,不引入 interrupt/resume,不新增数据库字段 diff --git "a/docs/AI/Tool\350\256\276\350\256\241-\346\270\220\350\277\233\345\274\217Tool\344\274\230\345\214\226.md" "b/docs/AI/Tool\350\256\276\350\256\241-\346\270\220\350\277\233\345\274\217Tool\344\274\230\345\214\226.md" new file mode 100644 index 0000000..433ff01 --- /dev/null +++ "b/docs/AI/Tool\350\256\276\350\256\241-\346\270\220\350\277\233\345\274\217Tool\344\274\230\345\214\226.md" @@ -0,0 +1,283 @@ +整体上,我这次是把“工具发现”和“工具执行”拆开了。 +以前是:`可见工具 -> 全量详细暴露 -> 直接 ReAct`。 +现在是:`可见工具 -> 先选组 -> 再选工具 -> 只把最终工具交给 ReAct`。 + +你可以按这 4 层理解。 + +**1. 目录和职责** +`domain/ai` 现在多了一组“渐进式选择协议”,在 [progressive.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/progressive.go:1)。这里定义的不是具体实现,而是共享类型: + +- `ToolGroupID` +- `ToolBrief` +- `ToolGroupBrief` +- `ToolGroupSelectionInput` +- `ToolGroupSelection` +- `ToolSelectionInput` +- `ToolSelection` + +这层的作用是:让 `service/system` 和 `infrastructure/ai/eino` 可以通过稳定协议通信,而不是互相依赖实现细节。 + +`service/system` 现在负责“业务编排 + 工具发现”: +- 主入口还是 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:231) +- 渐进式选择编排在 [aiProgressive.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProgressive.go:1) +- 工具注册、分组、brief 生成、展开逻辑在 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:67) +- prompt 构造接口在 [aiPrompt.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiPrompt.go) +- 上下文装配在 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:1) + +`infrastructure/ai/eino` 现在负责“内部 selector + 最终 runtime 执行”: +- selector 在 [selector.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/selector.go:1) +- 最终 ReAct runtime 还是 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:79) + +`service/system/supplier.go` 是装配层,在 [supplier.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/supplier.go:26)。 +这里负责把 `ProgressiveToolSelector` 注入进 `AIService`,但 `AIService` 自己不 import Eino。 + +**2. 现在一次请求怎么跑** +主入口还是 `AIService.StreamConversation(...)`,在 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:231)。 + +它现在的核心流程是: + +1. 查会话、查用户、查历史消息 +2. 创建 user/assistant 消息骨架并落库 +3. 构造 `ToolCallContext` +4. 调 `filterVisibleAITools(...)` 得到本轮可见工具 +5. 调 `contextAssembler.Build(...)` 只生成基础 `History` +6. 调 `buildAIToolExecutionPlan(...)` 做三段式工具选择 +7. 把 `executionPlan.Tools + executionPlan.DynamicSystemPrompt` 交给最终 `runtime.Stream(...)` + +所以现在 `runtime` 不再负责“我有哪些工具可选”,它只负责“拿着已经选定的工具去跑最终 ReAct”。 + +**3. 三段式选择是怎么实现的** +核心函数是 [aiProgressive.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProgressive.go:27) 的: + +```go +func (s *AIService) buildAIToolExecutionPlan( + ctx context.Context, + query string, + history []aidomain.Message, + visibleTools []aidomain.Tool, + principal aidomain.AIToolPrincipal, +) (aiToolExecutionPlan, error) +``` + +它的 5 个参数分别代表: +- `ctx`:内部选择器调用上下文 +- `query`:当前用户问题 +- `history`:已经装配好的历史消息 +- `visibleTools`:本轮经过权限过滤后真正可见的工具 +- `principal`:当前用户最小授权事实,用来生成最终 prompt + +它返回 `aiToolExecutionPlan`,这个结构只有两个字段,在 [aiProgressive.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProgressive.go:15): +- `Tools []aidomain.Tool` +- `DynamicSystemPrompt string` + +也就是说,这个函数的职责非常聚焦: +**决定最终到底给 runtime 哪些工具,以及给它什么 prompt。** + +内部流程是: + +1. 先做一个 `defaultPlan` + - `Tools = visibleTools` + - `DynamicSystemPrompt = 全量可见工具的最终 prompt` + 这是回退路径。 + +2. 如果没工具、没 selector、没 registry + - 直接返回 `defaultPlan` + +3. 调第一阶段 selector:`SelectGroup(...)` + 输入是 [progressive.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/progressive.go:50) 的 `ToolGroupSelectionInput` + - `Query` + - `History` + - `Groups` + +4. 第一阶段结果有 3 种: + - `direct_answer` + - `ask_user` + - `select_group` + +5. 如果是 `direct_answer` 或 `ask_user` + - 不注入任何工具 + - 只构造一个 decision prompt + - 最终还是走一次普通 `runtime.Stream(...)` + 这里用的是 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:593) 的 `buildAIDecisionPrompt(...)` + +6. 如果是 `select_group` + - 先找到对应 `ToolGroupBrief` + - 再从这个组里取 `ToolBrief` + - 调第二阶段 selector:`SelectTools(...)` + +7. 第二阶段输出 `ToolSelection` + 在 [progressive.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/progressive.go:73) + - `SelectedToolNames` + - `Confidence` + - `Reason` + +8. 如果 `confidence=high` + - 用 `ExpandVisibleToolsByNames(...)` 精确展开 +9. 如果 `confidence=low` 或展开为空 + - 用 `ExpandVisibleToolsByGroup(...)` 预扩为整组工具 + +10. 最后用选中的工具构造最终 prompt,然后进入真正 ReAct + +**4. 工具 registry 现在多了什么** +`AIDeps` 在 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:67) 多了一个: + +- `ToolSelector aiProgressiveToolSelector` + +`aiServiceTool` 在 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:107) 多了两个字段: + +- `group aidomain.ToolGroupID` +- `brief aidomain.ToolBrief` + +注意这里我没有把 12 个 tool constructor 全部改成手写 metadata,而是统一在 `newAIToolRegistry(...)` 之后调用 `decorateCatalogMetadata()`,见 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:164)。 +这样做的好处是:改动集中,风险小。 + +新增的几个 registry 方法是这次重构的关键: + +- [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:294) `ListVisibleToolGroupBriefs(...)` + 作用:把本轮可见工具压缩成组级 brief,给第一阶段 selector 用 + +- [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:311) `ListVisibleToolBriefsByGroup(...)` + 作用:从已选组里取组内工具 brief,给第二阶段 selector 用 + +- [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:326) `ExpandVisibleToolsByNames(...)` + 作用:把第二阶段选出来的工具名,恢复成真正的 `aidomain.Tool` + 这里我限制最多 3 个 + +- [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:360) `ExpandVisibleToolsByGroup(...)` + 作用:低置信时直接回退成整组工具 + +metadata 本身是通过 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:431) 的 `aiBuildToolMetadata(...)` 生成的。 +它会根据 `ToolSpec.Name` 给每个工具补: +- 所属组 +- `summary` +- `when_to_use` +- `required_slots` +- `domain_tags` + +**5. prompt 现在怎么分工** +以前 `aiContextAssembler.Build(...)` 会顺手把动态 prompt 也做掉。 +现在不会了。 + +`aiContextSnapshot` 在 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:49) 现在只剩: +- `History []aidomain.Message` + +也就是说,`contextAssembler` 现在只负责“基础上下文”,见 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:72)。 + +prompt 构造现在分成两类,在 [aiPrompt.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiPrompt.go): +- `BuildDynamicPrompt(tools, principal)` + 用于最终进入 ReAct 时 +- `BuildDecisionPrompt(decision, reason, missingSlots)` + 用于第一阶段直接判定“回答”或“追问”的场景 + +最终工具 prompt 用的是 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:565) 的 `buildAIToolDynamicPrompt(...)`。 +这版已经被我压短了,现在只保留: +- 本轮只能用当前注入工具 +- 缺关键标识别猜 +- recoverable error 怎么处理 +- RFC3339 规则 +- 当前组织上下文 +- 工具列表仅保留 `name + description` + +不再逐个展开所有参数细节。 + +而第一阶段直接答复/追问时,用的是 [aiTool.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiTool.go:593) 的 `buildAIDecisionPrompt(...)`。 + +**6. Eino selector 是怎么实现的** +真正的 selector 在 [selector.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/selector.go:15): + +```go +type ProgressiveToolSelector struct { + model einomodel.BaseChatModel + systemPrompt string +} +``` + +这说明它本质上就是一个很薄的“内部模型调用器”,并不是新 runtime。 + +构造函数是 [selector.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/selector.go:20): + +```go +func NewProgressiveToolSelector(ctx context.Context, opt Options) (*ProgressiveToolSelector, error) +``` + +参数 `opt` 复用你现有的 `eino.Options`,所以: +- provider +- model +- api key +- base url +- system prompt +这些都不用重新定义。 + +两个核心函数: + +- [selector.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/selector.go:35) `SelectGroup(...)` +- [selector.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/selector.go:61) `SelectTools(...)` + +它们都不是流式,也不绑定 tools,而是内部调用 `generateJSON(...)`,见 [selector.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/selector.go:92)。 + +`generateJSON(...)` 的逻辑很简单: +1. 组装 `system + selector prompt + history + query` +2. 调 `model.Generate(...)` +3. 只接受 JSON +4. 用 `unmarshalSelectorJSON(...)` 做解析和清洗 + +这里有一个很重要的设计点: +**第一、二阶段 selector 只消费 brief,不消费 full schema。** + +所以它的 prompt 里只会出现: +- group brief JSON +- tool brief JSON + +不会出现: +- `format=rfc3339` +- `enum` +- `min/max` +- `examples` +这些 full schema 细节。 + +**7. selector 是怎么注入进服务层的** +装配在 [supplier.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/supplier.go:26)。 + +这里的逻辑是: +1. 如果当前 `AIRuntimeMode=eino` +2. 用同一套 AI 配置创建 `NewProgressiveToolSelector(...)` +3. 成功则把它塞进 `AIDeps.ToolSelector` +4. 失败则只打 warn,回退到单阶段路径 + +所以 service 层只知道一个接口 `aiProgressiveToolSelector`,并不知道 Eino 的具体类型。 +这就是这次我刻意保住的边界。 + +**8. 最终 runtime 有没有被改坏** +没有,本质上没变。 + +最终执行仍然走 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:79) 的 `Stream(...)`,有工具时仍然走 [runtime.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/eino/runtime.go:164) 的 `streamWithTools(...)`。 + +区别只在于: + +- 以前 `input.Tools = 所有可见工具` +- 现在 `input.Tools = 渐进式选择后的工具` + +所以现有的: +- schema 校验 +- 参数自修正 +- tool observation +- tool trace +- SSE 事件 +都还能继续用。 + +**9. 你可以怎么记住这套实现** +一句话版本: + +- `domain/ai/progressive.go` + 定义“怎么选工具”的协议 +- `service/system/aiTool.go` + 把完整工具变成 `group + brief` +- `service/system/aiProgressive.go` + 做三段式执行计划 +- `infrastructure/ai/eino/selector.go` + 用模型做两阶段内部选择 +- `service/system/aiSvc.go` + 把最终选中的工具交给原来的 ReAct runtime + +如果你愿意,我下一条可以继续给你画一张非常具体的时序图: +`用户提问 -> AIService -> ToolRegistry -> GroupSelector -> ToolSelector -> Runtime -> Tool -> Sink/Projector`。 \ No newline at end of file diff --git "a/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" "b/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" index cdf9722..340aa3c 100644 --- "a/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" +++ "b/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" @@ -1,102 +1,446 @@ -你的项目不该把“记忆”做成一个通用聊天外挂,而应该做成一套`权限感知的领域记忆系统 + 单入口多模式助手`。 - -**先给结论** -最成熟的产品形态不是做 3 个割裂的机器人,而是做`一个统一 AI 入口`,再按`意图 + 角色 + 权限`自动路由到 4 条执行车道: - -- `general_qa`:普通问答。 -- `oj_tutor`:算法题讲解、做题画像、薄弱点分析。 -- `org_analyst`:管理员看组织整体情况、排行、任务执行、共性问题。 -- `ops_copilot`:超级管理员看 trace、metrics、错误排查、runbook、故障复盘。 - -这正好和你仓库现在的形态契合,因为你已经有: -- 上下文装配扩展点 [aiContext.go]() -- 流式主编排入口 [aiSvc.go]() -- 按 `self / org capability / super admin` 过滤工具的机制 [aiTool.go]() -- Qdrant 基础设施入口 [qdrant.go]() - -**站在资深用户视角,真正的诉求** -- 普通用户不要“会聊天”,而是要`连续性`。上次偏好、最近在练什么题、哪些知识点薄弱、解释风格偏好,下一次还能接上。 -- 管理员不要“AI 会总结”,而是要`可信的组织视图`。能看整体趋势、异常人群、任务完成情况、组织常见问题,还不能越权看到不该看的个人细节。 -- 超级管理员不要“AI 会说故障原因”,而是要`可验证、可追溯、可审计`。AI 必须能拉 trace/metrics/logs/runbook,给出证据链,还要保留会话、工具调用和诊断过程。 -- 所有人都要`单入口`。用户不想自己选机器人,系统应自动分流;必要时告诉用户“当前由 OJ 教练 / 运维助手处理”。 - -**架构师视角的最终成熟方案** -- 外层产品形态用`一个入口 + 自动路由`,借鉴 Glean 的 auto-routing 思路;UI 上仍然只有一个“AI 助手”。 -- 记忆分 4 层,但不要平均用力: -- `感知记忆`:本轮消息、附件、题目文本、当前 trace 条件。只活一轮。 -- `短期记忆`:当前会话历史、工具结果、执行计划、当前工作状态。要可压缩、可 checkpoint。 -- `长期记忆`:题解经验、组织 FAQ、历史任务结论、故障案例摘要、runbook 摘要。用于跨会话召回。 -- `实体记忆`:用户偏好、OJ 能力画像、组织上下文、角色事实、服务拓扑、事故标签。必须结构化。 -- 永远放在 prompt 里的“核心记忆块”只保留 2 到 4 个,借鉴 Letta 的 core memory 思路: -- `assistant_policy` -- `user_profile` -- `current_org_context` -- `current_task_goal` -- 其他内容一律按需召回,不要全塞进上下文。 - -**存什么、怎么存、什么时候取** -- `MySQL / 关系库`存实体记忆:用户偏好、当前学习目标、题型薄弱点、组织上下文、incident 元数据、runbook 元数据。因为这些需要精确更新和强一致。 -- `Qdrant`存长期非结构化记忆:历史题解摘要、知识文档切片、会话摘要、事故复盘摘要、FAQ、运维经验。你现在已有 Qdrant 初始化,不需要重造基础设施。 -- 初期不要一上来上图数据库。先做`关系库 + 向量库`混合存储;只有当“时间变化的关系推理”成为核心卖点时,再引入 Zep/Graphiti 风格的时序知识图谱。 -- Qdrant 初期建议不要按角色拆很多 collection,而是先做一个 `ai_memory` collection,用 payload 过滤: -- `scope_type` -- `scope_id` -- `org_id` -- `memory_type` -- `visibility` -- `topic` -- `importance` -- `effective_at` -- `expires_at` -- `source_kind` -- `source_id` - -**检索策略必须这么做** -- 每轮开始前,先加载`实体记忆`:用户画像、当前组织、角色事实、活跃学习计划。 -- 再按当前问题做一次`长期记忆召回`:普通用户召回个人与组织知识,管理员召回组织知识,超级管理员召回 runbook 和 incident 记忆。 -- 推理过程中允许模型按需触发“查记忆”工具,但`运维场景优先查实时工具,不优先查旧记忆`。因为 ops 最怕拿过期结论。 -- 所有召回都必须做`permission-trimmed retrieval`。向量召回不能绕过权限,这是企业助手成败分水岭。微软 Copilot 和 Glean 的成熟点都在这里,不在 prompt 花活。 - -**写回策略必须异步化** -- 同步写回:会话摘要、显式偏好变更、当前任务状态。 -- 异步写回:从对话和工具结果中抽取“值得记住”的事实,做去重、冲突消解、embedding、TTL、质量评分。 -- 普通用户重点写:偏好、学习画像、做题经验、近期目标。 -- 管理员重点写:组织共性问题、任务执行异常模式、FAQ 沉淀。 -- 超级管理员重点写:故障案例摘要、排障路径、根因、修复动作、回滚经验。 -- 不要把原始长日志、原始 trace payload、大段工具输出直接写成长期记忆。长期记忆应该存`摘要和结论`,原始证据仍走工具实时查。 - -**你这个项目最关键的一条设计原则** -记忆也必须走权限体系,不能只给 tool 做权限。 - -你现在 [aiTool.go]() 已经有工具可见性过滤,这是好基础。最终成熟版应该做到: -- `self memory` 只能本人看到。 -- `org memory` 只能具备组织 capability 的管理员看到。 -- `platform ops memory` 只能超级管理员看到。 -- 超级管理员的“执行类工具”默认只读诊断;涉及重启、变更、清缓存、补数据这类动作必须 approval gate,不让模型直接执行。 - -**按你仓库的落点,应该怎么放** -- 不要把记忆写进 `runtime.go`。runtime 只负责模型流和 tool loop。 -- 把“召回 + 压缩”继续收口在 [aiContext.go]()。 -- 把“本轮结束后的记忆写回编排”放在 `internal/service/system`,建议单独做 `aiMemorySvc.go`。 -- 把稳定协议放到 `internal/domain/ai`,例如 `memory.go`,定义 `Recall`, `WriteBack`, `Scope`, `MemoryFact`。 -- 把 Qdrant、embedding、rerank、extractor 放到 `internal/infrastructure/ai/memory`。 -- 把结构化 CRUD 放到 `internal/repository/interfaces` 和 `internal/repository/system`。 -- 把用户画像、组织画像、incident 摘要这些实体放到 `internal/model/entity` 或只读聚合放到 `readmodel`。 - -**我建议你的实施顺序** -1. 先做 `Phase 1`:用户短期记忆 + 用户实体记忆 + OJ 学习画像。 -2. 再做 `Phase 2`:组织级长期记忆 + 管理员视角召回 + 会话压缩。 -3. 再做 `Phase 3`:超级管理员运维助手,接 trace/metrics/log 工具,沉淀 incident memory。 -4. 最后评估 `Phase 4`:是否真的需要 Graphiti/Zep 这类时序图谱,而不是先把复杂度引进来。 - -**成熟产品里你最该借鉴的点** -- LangGraph:短期状态和 checkpoint 分离。https://docs.langchain.com/oss/javascript/concepts/memory -- Mem0:多层记忆、抽取、冲突消解、写回流水线。https://docs.mem0.ai/core-concepts/memory-types https://docs.mem0.ai/core-concepts/memory-operations/add -- Letta:核心记忆块永远可见,归档记忆按需召回。https://docs.letta.com/guides/core-concepts/memory/memory-blocks https://docs.letta.com/guides/core-concepts/memory/archival-memory -- Zep / Graphiti:适合“事实会变化”的时序图谱记忆。https://help.getzep.com/graph-overview https://www.getzep.com/product/open-source/ -- Microsoft 365 Copilot:grounding 必须继承原权限,且要有治理与 DLP。https://support.microsoft.com/en-us/topic/what-information-does-copilot-use-to-answer-my-prompt-934f537d-ff7d-4059-9fec-a751e4651307 https://learn.microsoft.com/en-us/microsoft-365/copilot/configure-secure-governed-data-foundation-microsoft-365-copilot -- Glean:单入口、权限感知、自动路由到专用 agent。https://docs.glean.com/get-started/golive/setup-glean-assistant https://docs.glean.com/user-guide/assistant/ai-answers https://docs.glean.com/administration/managing-agents/agent-routing -- Datadog Bits / Elastic AI Assistant:运维助手的正确方向是“查实时观测数据 + 沉淀 incident memory + 支持会话导出复盘”。https://docs.datadoghq.com/bits_ai/bits_assistant/ https://www.elastic.co/docs/solutions/observability/ai/observability-ai-assistant -- GitHub Copilot Spaces:把固定领域知识做成可审阅、可复用的 curated context pack。https://github.com/features/preview/copilot-spaces - -如果你要继续往“落地实现”走,下一步我建议直接给你出一份`本仓库可执行的记忆模块分层方案`,按你项目规则落成 `plan/ai/pending-记忆模块架构落地.md`。 \ No newline at end of file +**目标** +这套设计的目的只有 5 个: + +- 让 AI 具备`跨会话连续性`,能记住用户偏好、OJ 学习画像、组织上下文、运维经验。 +- 让记忆和 tool 一样,`严格走权限边界`。 +- 把短期上下文控制在可用范围内,避免 context 爆炸。 +- 把“实时数据查询”和“历史记忆召回”分开,尤其是超管运维场景。 +- 保持你当前仓库的边界不变:MVC 外壳不动,AI 子域渐进式扩展。 + +你现在最合适的落点仍然是: +- 读路径接在 [`aiContext.go`]() +- 主编排接在 [`aiSvc.go`]() +- tool 权限继续复用 [`aiTool.go`]() +- Qdrant 基础设施继续复用 [`qdrant.go`]() +- tool 证据链复用 [`aiProjector.go`]() 里的 `TraceItemsJSON` + +**目录设计** +第一版建议直接这样落: + +```text +internal/domain/ai/ + memory.go + memory_types.go + +internal/model/config/ + ai.go + qdrant.go + +internal/model/entity/ + ai_memory_fact.go + ai_memory_document.go + ai_conversation_summary.go + +internal/repository/interfaces/ + aiMemoryRepository.go + +internal/repository/system/ + aiMemoryRepo.go + +internal/infrastructure/ai/memory/ + provider.go + compressor.go + extractor.go + qdrant_store.go + embedder.go + outbox_consumer.go + +internal/service/system/ + aiMemorySvc.go + aiMemoryPolicy.go + aiContext.go // 扩展,不重写 + aiSvc.go // 扩展写回 hook + +internal/core/ + qdrant.go // 扩展 memory collection 初始化 + embedding.go // 如果 embedding client 独立初始化 +``` + +每层职责固定如下: + +- `domain/ai`:定义记忆类型、作用域、读写协议。 +- `service/system`:决定存什么、什么时候召回、什么时候写回。 +- `repository`:负责 MySQL 的 facts / summaries / documents CRUD。 +- `infrastructure/ai/memory`:负责 embedding、Qdrant 检索、压缩、抽取。 +- `core`:负责 Qdrant / embedding client 生命周期和配置映射。 + +**核心模型** +你不要只做一个“memory 表”。至少拆成 3 类数据。 + +```go +// internal/domain/ai/memory.go +type MemoryScopeType string +const ( + MemoryScopeSelf MemoryScopeType = "self" + MemoryScopeOrg MemoryScopeType = "org" + MemoryScopePlatformOps MemoryScopeType = "platform_ops" +) + +type MemoryType string +const ( + MemoryTypeEntity MemoryType = "entity" + MemoryTypeSessionSummary MemoryType = "session_summary" + MemoryTypeEpisodic MemoryType = "episodic" + MemoryTypeSemantic MemoryType = "semantic" + MemoryTypeProcedural MemoryType = "procedural" + MemoryTypeIncident MemoryType = "incident" + MemoryTypeFAQ MemoryType = "faq" +) + +type MemoryVisibility string +const ( + MemoryVisibilitySelf MemoryVisibility = "self" + MemoryVisibilityOrg MemoryVisibility = "org" + MemoryVisibilitySuperAdmin MemoryVisibility = "super_admin" +) +``` + +然后持久化实体建议这样拆: + +```go +// ai_memory_facts:结构化实体记忆 +type AIMemoryFact struct { + ID uint + ScopeKey string // self:user:123 / org:45 / platform_ops + ScopeType string + UserID *uint + OrgID *uint + Namespace string // user_preference / oj_profile / org_profile / ops_profile + FactKey string // answer_style / weak_topics / current_goal / runbook_owner + FactValueJSON string // 结构化 JSON + Summary string + Confidence float64 + SourceKind string // message / tool_result / incident / admin_set + SourceID string + EffectiveAt *time.Time + ExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +```go +// ai_memory_documents:长期语义记忆的 canonical metadata +type AIMemoryDocument struct { + ID string + ScopeKey string + ScopeType string + UserID *uint + OrgID *uint + MemoryType string // episodic / semantic / procedural / incident / faq + Topic string // dp / graph / ranking / qdrant / trace + Title string + Summary string + ContentText string // 这里存摘要或 chunk,不存超长原始日志 + SourceKind string + SourceID string + Importance float64 + QualityScore float64 + QdrantPointID string + EmbeddingModel string + EffectiveAt *time.Time + ExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +```go +// ai_conversation_summaries:短期记忆压缩产物 +type AIConversationSummary struct { + ConversationID string + CompressedUntilMessageID string + SummaryText string + KeyPointsJSON string + OpenLoopsJSON string + TokenEstimate int + UpdatedAt time.Time +} +``` + +**为什么这样拆** +- `facts` 解决“精确事实”和“可更新真相”。 +- `documents` 解决“语义召回”和“历史经验复用”。 +- `conversation_summaries` 解决“context window 不够”。 + +**数据库和向量库细节** +MySQL 里建议加这些索引: + +- `ai_memory_facts` +- `uk(scope_key, namespace, fact_key)` +- `idx(scope_key, updated_at)` +- `idx(expires_at)` + +- `ai_memory_documents` +- `idx(scope_key, memory_type, updated_at)` +- `idx(topic, updated_at)` +- `idx(expires_at)` + +- `ai_conversation_summaries` +- `pk(conversation_id)` + +Qdrant 不要直接把 MySQL 当替代品。Qdrant 只负责召回,MySQL 才是记忆元数据真相。 + +Qdrant payload 统一长这样: + +```json +{ + "memory_id": "mem_01", + "scope_key": "org:45", + "scope_type": "org", + "user_id": 0, + "org_id": 45, + "memory_type": "semantic", + "visibility": "org", + "topic": "dp", + "importance": 0.88, + "quality_score": 0.91, + "effective_at": "2026-04-24T00:00:00Z", + "expires_at": null, + "source_kind": "tool_result", + "source_id": "msg_ai_123" +} +``` + +建议把 Qdrant collection 分成两类,不要混: + +- `ai_knowledge_chunks`:静态知识库、FAQ、文档。 +- `ai_memory_chunks`:动态长期记忆、对话摘要、incident 经验。 + +**配置设计** +在 [`internal/model/config/ai.go`]() 里加: + +```go +type AIMemory struct { + Enabled bool + RecallTopK int + RecallMaxChars int + RecentRawTurns int + CompressThresholdTokens int + SummaryRefreshEveryTurns int + WritebackAsync bool + EnableEntityMemory bool + EnableLongTermMemory bool + EnableOrgMemory bool + EnableOpsMemory bool + MinImportance float64 + EmbedModel string +} + +type AI struct { + ... + Memory AIMemory `json:"memory" yaml:"memory"` +} +``` + +在 `qdrant.go` 里扩展: + +```go +type Qdrant struct { + ... + KnowledgeCollectionName string + MemoryCollectionName string +} +``` + +默认值建议: + +- `RecallTopK = 6` +- `RecentRawTurns = 8` +- `CompressThresholdTokens = 6000` +- `SummaryRefreshEveryTurns = 10` +- `MinImportance = 0.65` + +**服务接口和函数设计** +第一版不用搞太花,按你现有风格,先加这几个接口就够了。 + +```go +// service/system/aiContext.go +type aiMemoryRecallResult struct { + PromptBlocks []string + Messages []aidomain.Message +} + +type aiMemoryProvider interface { + Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) +} + +type aiMemoryWriter interface { + OnTurnCompleted(ctx context.Context, input aiMemoryWritebackInput) error +} +``` + +```go +// service/system/aiMemorySvc.go +type AIMemoryService struct { + repo interfaces.AIMemoryRepository + outboxRepo interfaces.OutboxRepository + policy aiMemoryPolicy + vectorStore aidomain.MemoryVectorStore +} + +func (s *AIMemoryService) Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) +func (s *AIMemoryService) OnTurnCompleted(ctx context.Context, input aiMemoryWritebackInput) error +func (s *AIMemoryService) RefreshConversationSummary(ctx context.Context, conversationID string) error +func (s *AIMemoryService) ExtractCandidates(ctx context.Context, input aiMemoryWritebackInput) ([]aiMemoryCandidate, error) +func (s *AIMemoryService) UpsertFact(ctx context.Context, fact entity.AIMemoryFact) error +func (s *AIMemoryService) ScheduleDocumentUpsert(ctx context.Context, docs []entity.AIMemoryDocument) error +``` + +```go +// infrastructure/ai/memory/provider.go +func (p *Provider) Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) +func (p *Provider) searchEntityFacts(ctx context.Context, input aiMemoryRecallInput) ([]entity.AIMemoryFact, error) +func (p *Provider) searchSemanticDocs(ctx context.Context, input aiMemoryRecallInput) ([]memoryHit, error) +func (p *Provider) buildPromptBlocks(facts []entity.AIMemoryFact, hits []memoryHit) []string +``` + +```go +// infrastructure/ai/memory/compressor.go +func (c *Compressor) CompressMessages(ctx context.Context, input aiContextCompressionInput) ([]aidomain.Message, error) +func (c *Compressor) EstimateTokens(messages []aidomain.Message) int +func (c *Compressor) BuildOrRefreshSummary(ctx context.Context, conversationID string, messages []aidomain.Message) (*entity.AIConversationSummary, error) +``` + +```go +// infrastructure/ai/memory/extractor.go +func (e *Extractor) ExtractFacts(ctx context.Context, input aiMemoryWritebackInput) ([]entity.AIMemoryFact, error) +func (e *Extractor) ExtractDocuments(ctx context.Context, input aiMemoryWritebackInput) ([]entity.AIMemoryDocument, error) +``` + +**整体逻辑** +完整链路就按“读 -> 用 -> 写”走。 + +1. `任务开始前读` +- 从 `ToolCallContext.Principal` 推导 scope。 +- scope 优先级固定为:`self -> org -> platform_ops` +- 先查 `ai_memory_facts` +- 再查 `ai_conversation_summaries` +- 再查 Qdrant `ai_memory_chunks` +- 最后把结果组装成: +- `PromptBlocks`:用户偏好、组织上下文、核心画像 +- `Messages`:少量召回摘要 + +2. `任务执行中用` +- `aiContextAssembler.Build` 先拿 recent raw messages +- 如果 token 超阈值,就用 `conversation summary + recent turns` +- 再拼上 memory prompt block +- 再拼 tool prompt +- 然后把结果交给 runtime +- ops 场景优先走实时 tool,不优先依赖旧记忆 + +3. `任务结束后写` +- `finishStream` 成功后触发 `memoryWriter.OnTurnCompleted` +- 同步写: +- 会话摘要 +- 显式偏好变更 +- 显式目标变更 +- 异步写: +- 对话结论 +- tool 结果摘要 +- incident / runbook 摘要 +- embedding + Qdrant upsert + +**什么值得存** +只存“下次有帮助”的内容: + +- 用户偏好:语言、解释风格、是否喜欢步骤化、是否偏好简洁答案 +- OJ 画像:弱项知识点、最近训练主题、常错模式、当前目标 +- 组织画像:组织 FAQ、共性薄弱点、任务执行异常模式 +- 运维经验:事故根因、排查路径、缓解动作、runbook 摘要 + +不要存: + +- 原始长日志 +- 原始 trace payload +- 每一次完整工具输出 +- 闲聊 +- 中间推理草稿 + +**权限设计** +记忆权限和 tool 权限同级,不是附属品。 + +- `self memory`:只能本人读写 +- `org memory`:只有具备组织 capability 的管理员能读 +- `platform_ops memory`:只有超级管理员能读 +- 权限校验失败时,`fail closed` +- Qdrant 检索失败时,`fail open`,直接降级为无长期记忆回答 +- MySQL fact 读取失败时,记录错误日志,继续走纯上下文回答 + +**压缩策略** +建议默认策略: + +- 原始 recent messages 只保留最近 `8` 轮 +- 超过 `6000` token 时触发压缩 +- 每 `10` 轮刷新一次 summary +- summary 始终记录: +- 已确定事实 +- 当前目标 +- 未完成事项 +- 关键 tool 结果结论 + +第一版不要做滑动窗口 + 分层缓存 + checkpoint 全上。 +先做:`summary + recent turns + top-k recall` + +**写回策略** +第一版建议分同步和异步: + +- 同步: +- `RefreshConversationSummary` +- `UpsertFact` for 明确偏好、明确目标、管理员手动设置事实 + +- 异步: +- `ExtractDocuments` +- `Embedding` +- `Qdrant Upsert` + +异步最好复用现有 outbox,不建议直接在流式请求线程里做 embedding。 + +事件名可以直接定成: + +- `ai.memory.turn_completed` +- `ai.memory.document_upsert_requested` + +**开发顺序** +按这个顺序做,风险最低: + +1. 做 `domain/ai/memory.go` +2. 做 `entity + repository` +3. 做 `AIMemoryService.Recall` +4. 接入 `aiContext.go` +5. 做 `conversation summary` +6. 接入 `finishStream` 写回 hook +7. 做异步 embedding + Qdrant upsert +8. 最后再做 memory debug / admin API + +**第一阶段必须完成的最小闭环** +做到这 4 件事,你的第一版就能用了: + +- 结构化 facts 能存用户偏好和 OJ 弱项 +- conversation summary 能压缩上下文 +- Qdrant 能召回历史题解摘要和 FAQ +- 召回结果能按 `self / org / platform_ops` 做过滤 + +**可选管理接口** +如果你后面要做运维和调试,建议只给超管开放: + +- `GET /ai/memory/debug/recall` +- `GET /ai/memory/facts` +- `POST /ai/memory/rebuild/{conversation_id}` + +这些路由放 `internal/router/system`,不要塞到主 router。 + +**你开发时最重要的 3 条原则** +- 记忆真相在 MySQL,Qdrant 只是召回索引。 +- 运维类问题优先实时工具,记忆只做补充。 +- 先把 `entity + summary + long-term recall` 三件事做稳,再考虑图谱。 + +**参考产品/官方文档** +- Eino Memory / Session:https://www.cloudwego.io/docs/eino/quick_start/chapter_03_memory_and_session/ +- Eino Overview:https://www.cloudwego.io/docs/eino/overview/ +- LangGraph Memory:https://docs.langchain.com/oss/javascript/langgraph/memory +- Mem0 Memory Types:https://docs.mem0.ai/core-concepts/memory-types +- Letta Memory Blocks:https://docs.letta.com/guides/core-concepts/memory/memory-blocks +- Microsoft Copilot 权限与 grounding:https://support.microsoft.com/en-us/topic/what-information-does-copilot-use-to-answer-my-prompt-934f537d-ff7d-4059-9fec-a751e4651307 + +如果你要继续下一步,我建议直接把这份设计落成一份待审计划,然后再按你仓库规则拆成 `Phase 1` 的文件级开发清单。 \ No newline at end of file diff --git a/internal/domain/ai/progressive.go b/internal/domain/ai/progressive.go new file mode 100644 index 0000000..34340b6 --- /dev/null +++ b/internal/domain/ai/progressive.go @@ -0,0 +1,80 @@ +package ai + +// ToolGroupID 表示渐进式工具选择中的工具组标识。 +type ToolGroupID string + +// 渐进式工具选择支持的固定工具组。 +const ( + ToolGroupOJPersonal ToolGroupID = "oj_personal" + ToolGroupOJOrg ToolGroupID = "oj_org" + ToolGroupOJTask ToolGroupID = "oj_task" + ToolGroupObservabilityTrace ToolGroupID = "observability_trace" + ToolGroupObservabilityMetrics ToolGroupID = "observability_metrics" +) + +// ToolBrief 表示第一、二阶段选择器使用的轻量工具简介。 +type ToolBrief struct { + Name string `json:"name"` + Summary string `json:"summary"` + WhenToUse string `json:"when_to_use"` + RequiredSlots []string `json:"required_slots,omitempty"` + DomainTags []string `json:"domain_tags,omitempty"` +} + +// ToolGroupBrief 表示第一阶段选择器消费的工具组简介。 +type ToolGroupBrief struct { + ID ToolGroupID `json:"id"` + Summary string `json:"summary"` + WhenToUse string `json:"when_to_use"` + ToolNames []string `json:"tool_names,omitempty"` + DomainTags []string `json:"domain_tags,omitempty"` +} + +// ToolSelectionDecision 表示第一阶段 selector 的决策结果。 +type ToolSelectionDecision string + +// 第一阶段 selector 的标准决策枚举。 +const ( + ToolSelectionDecisionDirectAnswer ToolSelectionDecision = "direct_answer" + ToolSelectionDecisionAskUser ToolSelectionDecision = "ask_user" + ToolSelectionDecisionSelectGroup ToolSelectionDecision = "select_group" +) + +// ToolSelectionConfidence 表示第二阶段 selector 选择结果的置信度。 +type ToolSelectionConfidence string + +// 第二阶段工具选择的置信度枚举。 +const ( + ToolSelectionConfidenceHigh ToolSelectionConfidence = "high" + ToolSelectionConfidenceLow ToolSelectionConfidence = "low" +) + +// ToolGroupSelectionInput 表示第一阶段选择器输入。 +type ToolGroupSelectionInput struct { + Query string `json:"query"` + History []Message `json:"history,omitempty"` + Groups []ToolGroupBrief `json:"groups"` +} + +// ToolGroupSelection 表示第一阶段选择器输出。 +type ToolGroupSelection struct { + Decision ToolSelectionDecision `json:"decision"` + GroupID ToolGroupID `json:"group_id,omitempty"` + Reason string `json:"reason,omitempty"` + MissingSlots []string `json:"missing_slots,omitempty"` +} + +// ToolSelectionInput 表示第二阶段选择器输入。 +type ToolSelectionInput struct { + Query string `json:"query"` + History []Message `json:"history,omitempty"` + Group ToolGroupBrief `json:"group"` + Tools []ToolBrief `json:"tools"` +} + +// ToolSelection 表示第二阶段选择器输出。 +type ToolSelection struct { + SelectedToolNames []string `json:"selected_tool_names,omitempty"` + Confidence ToolSelectionConfidence `json:"confidence"` + Reason string `json:"reason,omitempty"` +} diff --git a/internal/domain/ai/tool.go b/internal/domain/ai/tool.go index c6ed997..4a817ef 100644 --- a/internal/domain/ai/tool.go +++ b/internal/domain/ai/tool.go @@ -5,6 +5,7 @@ import "context" // ToolParameterType 表示工具参数的 JSON 类型。 type ToolParameterType string +// ToolParameterType 枚举了工具参数可使用的 JSON 基础类型。 const ( ToolParameterTypeObject ToolParameterType = "object" ToolParameterTypeString ToolParameterType = "string" @@ -14,6 +15,11 @@ const ( ToolParameterTypeArray ToolParameterType = "array" ) +const ( + // ToolParameterFormatRFC3339 表示字符串必须符合 RFC3339 时间格式。 + ToolParameterFormatRFC3339 = "rfc3339" +) + // ToolParameter 描述单个工具参数。 // 对 object / array 参数,使用 Properties / Items 继续描述子结构。 type ToolParameter struct { @@ -27,6 +33,26 @@ type ToolParameter struct { Required bool // Enum 给出允许的枚举值范围,便于模型约束输入。 Enum []string + // Format 描述字符串参数的格式约束,如 RFC3339。 + Format string + // Pattern 描述字符串参数的正则模式约束。 + Pattern string + // MinLength 描述字符串最小长度约束。 + MinLength *int + // MaxLength 描述字符串最大长度约束。 + MaxLength *int + // Minimum 描述 number/integer 参数的最小值约束。 + Minimum *float64 + // Maximum 描述 number/integer 参数的最大值约束。 + Maximum *float64 + // MinItems 描述数组最少元素个数。 + MinItems *int + // MaxItems 描述数组最多元素个数。 + MaxItems *int + // Examples 给出推荐示例值,帮助模型修正参数。 + Examples []string + // DefaultValue 仅用于提示默认值,不代表 runtime 会静默注入。 + DefaultValue string // Properties 描述 object 参数的子字段结构。 Properties []ToolParameter // Items 描述 array 参数的元素结构。 diff --git a/internal/domain/ai/tool_descriptor.go b/internal/domain/ai/tool_descriptor.go new file mode 100644 index 0000000..783dabc --- /dev/null +++ b/internal/domain/ai/tool_descriptor.go @@ -0,0 +1,17 @@ +package ai + +// ToolDescriptor 收口单个 tool 对 selector 和 registry 可见的稳定描述信息。 +// 具体业务实现留在 service/app 层,但 metadata 真相应稳定且可复用。 +type ToolDescriptor struct { + Spec ToolSpec + GroupID ToolGroupID + Brief ToolBrief +} + +// ToolGroupProfile 描述单个工具组的固定语义和使用边界。 +// ToolNames 属于运行时聚合结果,因此不放在静态 profile 中。 +type ToolGroupProfile struct { + Summary string + WhenToUse string + DomainTags []string +} diff --git a/internal/domain/ai/tool_error.go b/internal/domain/ai/tool_error.go new file mode 100644 index 0000000..bc5659c --- /dev/null +++ b/internal/domain/ai/tool_error.go @@ -0,0 +1,124 @@ +package ai + +import ( + "errors" + "fmt" +) + +// ToolObservationClassification 表示一次 tool 失败对 ReAct 的恢复语义。 +type ToolObservationClassification string + +const ( + // ToolObservationMissingUserInput 表示缺少继续执行所需的用户输入。 + ToolObservationMissingUserInput ToolObservationClassification = "missing_user_input" + // ToolObservationRepairableInvalidParam 表示参数错误,但模型可在同一轮修正。 + ToolObservationRepairableInvalidParam ToolObservationClassification = "repairable_invalid_param" + // ToolObservationTerminalToolError 表示错误不可恢复,应终止本轮 tool loop。 + ToolObservationTerminalToolError ToolObservationClassification = "terminal_tool_error" +) + +// ToolFieldError 描述单个字段的错误详情,供模型自修正和前端排障使用。 +type ToolFieldError struct { + Field string `json:"field"` + Reason string `json:"reason"` + Expected string `json:"expected,omitempty"` + Allowed []string `json:"allowed,omitempty"` + Example string `json:"example,omitempty"` +} + +// ToolObservation 表示 runtime 回喂给模型的结构化 tool observation。 +type ToolObservation struct { + Classification ToolObservationClassification `json:"classification"` + ToolName string `json:"tool_name"` + Message string `json:"message"` + FieldErrors []ToolFieldError `json:"field_errors,omitempty"` +} + +// ToolIssueError 表示一次带恢复语义的 tool 错误。 +type ToolIssueError struct { + Classification ToolObservationClassification + Message string + FieldErrors []ToolFieldError + Cause error +} + +func (e *ToolIssueError) Error() string { + if e == nil { + return "" + } + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Cause) + } + return e.Message +} + +func (e *ToolIssueError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Observation 把当前错误转换为可回喂模型的结构化 observation。 +func (e *ToolIssueError) Observation(toolName string) ToolObservation { + if e == nil { + return ToolObservation{ToolName: toolName} + } + return ToolObservation{ + Classification: e.Classification, + ToolName: toolName, + Message: e.Message, + FieldErrors: append([]ToolFieldError(nil), e.FieldErrors...), + } +} + +// NewToolIssueError 创建结构化 tool 错误。 +func NewToolIssueError( + classification ToolObservationClassification, + message string, + fieldErrors []ToolFieldError, + cause error, +) *ToolIssueError { + return &ToolIssueError{ + Classification: classification, + Message: message, + FieldErrors: append([]ToolFieldError(nil), fieldErrors...), + Cause: cause, + } +} + +// NewMissingUserInputError 创建“缺少用户输入”错误。 +func NewMissingUserInputError(message string, fieldErrors ...ToolFieldError) *ToolIssueError { + return NewToolIssueError(ToolObservationMissingUserInput, message, fieldErrors, nil) +} + +// NewRepairableInvalidParamError 创建“参数可修复”错误。 +func NewRepairableInvalidParamError(message string, fieldErrors ...ToolFieldError) *ToolIssueError { + return NewToolIssueError(ToolObservationRepairableInvalidParam, message, fieldErrors, nil) +} + +// NewRepairableInvalidParamErrorWithCause 创建“参数可修复”错误并携带原始 cause。 +func NewRepairableInvalidParamErrorWithCause( + message string, + cause error, + fieldErrors ...ToolFieldError, +) *ToolIssueError { + return NewToolIssueError(ToolObservationRepairableInvalidParam, message, fieldErrors, cause) +} + +// NewTerminalToolError 创建“不可恢复”错误。 +func NewTerminalToolError(message string, cause error) *ToolIssueError { + return NewToolIssueError(ToolObservationTerminalToolError, message, nil, cause) +} + +// FromToolIssueError 从 error 链中提取 ToolIssueError。 +func FromToolIssueError(err error) *ToolIssueError { + if err == nil { + return nil + } + var issue *ToolIssueError + if errors.As(err, &issue) { + return issue + } + return nil +} diff --git a/internal/infrastructure/ai/eino/runtime.go b/internal/infrastructure/ai/eino/runtime.go index 24ea6ce..1451162 100644 --- a/internal/infrastructure/ai/eino/runtime.go +++ b/internal/infrastructure/ai/eino/runtime.go @@ -189,6 +189,7 @@ func (r *Runtime) streamWithTools( // maxToolTurns 防止模型持续循环调用工具导致请求无限悬挂。 const maxToolTurns = 8 + repairState := newToolRepairState() for turn := 0; turn < maxToolTurns; turn++ { // 每一轮都让模型基于最新 messages 再生成一次 assistant 响应。 reader, err := modelWithTools.Stream(ctx, messages) @@ -249,7 +250,14 @@ func (r *Runtime) streamWithTools( } // assistant 返回 tool call 后,顺序执行工具并把 tool message 追加回上下文。 - toolMessages, err := r.executeToolCalls(ctx, toolMap, assistantMessage.ToolCalls, input.ToolCallContext, sink) + toolMessages, err := r.executeToolCalls( + ctx, + toolMap, + assistantMessage.ToolCalls, + input.ToolCallContext, + sink, + repairState, + ) if err != nil { return aidomain.StreamResult{}, err } @@ -343,6 +351,7 @@ func (r *Runtime) executeToolCalls( toolCalls []schema.ToolCall, callCtx aidomain.ToolCallContext, sink aidomain.Sink, + repairState *toolRepairState, ) ([]*schema.Message, error) { // 每个工具执行完成后都会生成一条 tool message 回填给模型。 messages := make([]*schema.Message, 0, len(toolCalls)) @@ -368,15 +377,43 @@ func (r *Runtime) executeToolCalls( return nil, err } - // 记录耗时并执行真实工具实现。 + // 记录耗时,并在执行前先做 schema/补充校验。 startedAt := time.Now() - result, err := toolImpl.Call(ctx, aidomain.ToolCall{ + validatedArgs, validationErr := validateToolCallArguments(toolImpl.Spec(), toolCall.Function.Arguments) + runtimeCall := aidomain.ToolCall{ ID: callID, Name: toolName, - ArgumentsJSON: toolCall.Function.Arguments, - }, callCtx) + ArgumentsJSON: validatedArgs.NormalizedJSON, + } + + if validationErr == nil { + if validatingTool, ok := toolImpl.(runtimeValidatingTool); ok { + validationErr = validatingTool.Validate(ctx, runtimeCall, callCtx) + } + } + + var ( + result aidomain.ToolResult + err error + ) + if validationErr != nil { + err = validationErr + } else { + result, err = toolImpl.Call(ctx, runtimeCall, callCtx) + } durationMS := time.Since(startedAt).Milliseconds() if err != nil { + issue := classifyToolError(err) + summary := err.Error() + detail := err.Error() + if issue != nil { + summary = issue.Message + if compact, pretty := marshalToolObservation(issue.Observation(toolName)); compact != "" { + summary = compact + detail = pretty + } + } + // 工具失败时也要补 finished 事件,让前端和 trace 看到失败状态。 if emitErr := sink.Emit(ctx, aidomain.Event{ Name: aidomain.EventToolCallFinished, @@ -386,13 +423,28 @@ func (r *Runtime) executeToolCalls( Description: "工具调用失败。", DurationMS: durationMS, Status: "failed", - Content: summarizeToolOutput(err.Error()), - DetailMarkdown: err.Error(), + Content: summarizeToolOutput(summary), + DetailMarkdown: detail, }, }); emitErr != nil { return nil, emitErr } - return nil, err + + if issue == nil || issue.Classification == aidomain.ToolObservationTerminalToolError { + return nil, err + } + + observation := issue.Observation(toolName) + if observation.Classification == aidomain.ToolObservationRepairableInvalidParam { + retryKey := toolName + ":" + runtimeCall.ArgumentsJSON + if !repairState.consume(retryKey) { + return nil, err + } + } + + observationOutput, _ := marshalToolObservation(observation) + messages = append(messages, schema.ToolMessage(observationOutput, callID, schema.WithToolName(toolName))) + break } // 工具成功后把摘要和详情折叠进 finished 事件。 diff --git a/internal/infrastructure/ai/eino/runtime_tools_test.go b/internal/infrastructure/ai/eino/runtime_tools_test.go index dc55f84..466d8a1 100644 --- a/internal/infrastructure/ai/eino/runtime_tools_test.go +++ b/internal/infrastructure/ai/eino/runtime_tools_test.go @@ -2,6 +2,7 @@ package eino import ( "context" + "encoding/json" "errors" "testing" @@ -25,27 +26,43 @@ func (s *runtimeEventSinkStub) Heartbeat(context.Context) error { } type fakeToolCallingChatModel struct { - streams [][]*schema.Message - streamCalls int - tools []*schema.ToolInfo - inputs [][]*schema.Message + streams [][]*schema.Message + streamCalls int + tools []*schema.ToolInfo + inputs [][]*schema.Message + generateMsg *schema.Message + generateErr error + generateInputs [][]*schema.Message } var _ einomodel.ToolCallingChatModel = (*fakeToolCallingChatModel)(nil) func (m *fakeToolCallingChatModel) Generate( - context.Context, - []*schema.Message, - ...einomodel.Option, + ctx context.Context, + input []*schema.Message, + opts ...einomodel.Option, ) (*schema.Message, error) { + _ = ctx + _ = opts + cloned := make([]*schema.Message, len(input)) + copy(cloned, input) + m.generateInputs = append(m.generateInputs, cloned) + if m.generateErr != nil { + return nil, m.generateErr + } + if m.generateMsg != nil { + return m.generateMsg, nil + } return schema.AssistantMessage("", nil), nil } func (m *fakeToolCallingChatModel) Stream( - _ context.Context, + ctx context.Context, input []*schema.Message, - _ ...einomodel.Option, + opts ...einomodel.Option, ) (*schema.StreamReader[*schema.Message], error) { + _ = ctx + _ = opts cloned := make([]*schema.Message, len(input)) copy(cloned, input) m.inputs = append(m.inputs, cloned) @@ -67,6 +84,7 @@ type fakeRuntimeTool struct { spec aidomain.ToolSpec result aidomain.ToolResult err error + validate func(context.Context, aidomain.ToolCall, aidomain.ToolCallContext) error calls []aidomain.ToolCall callCtxLog []aidomain.ToolCallContext } @@ -88,6 +106,17 @@ func (t *fakeRuntimeTool) Call( return t.result, nil } +func (t *fakeRuntimeTool) Validate( + ctx context.Context, + call aidomain.ToolCall, + callCtx aidomain.ToolCallContext, +) error { + if t.validate == nil { + return nil + } + return t.validate(ctx, call, callCtx) +} + func TestRuntimeStreamTextOnlyEmitsFinalContent(t *testing.T) { model := &fakeToolCallingChatModel{ streams: [][]*schema.Message{ @@ -327,3 +356,294 @@ func TestRuntimeStreamWithToolsEmitsFailedTraceBeforeReturningError(t *testing.T t.Fatalf("payload.Status = %q, want failed", payload.Status) } } + +func TestRuntimeStreamWithToolsRepairsInvalidGranularityInSameTurn(t *testing.T) { + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_observability_metrics", + Arguments: `{"granularity":"1h","start_at":"2026-04-24T09:20:00Z","end_at":"2026-04-24T10:20:00Z"}`, + }, + }, + }), + }, + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_2", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_observability_metrics", + Arguments: `{"granularity":"1m","start_at":"2026-04-24T09:20:00Z","end_at":"2026-04-24T10:20:00Z"}`, + }, + }, + }), + }, + { + schema.AssistantMessage("查询完成,今天 17:20 的指标已经返回。", nil), + }, + }, + } + runtime := &Runtime{model: model, systemPrompt: "base system prompt"} + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "query_observability_metrics", + Description: "查询 HTTP 观测指标。", + Parameters: []aidomain.ToolParameter{ + {Name: "granularity", Type: aidomain.ToolParameterTypeString, Required: true, Enum: []string{"1m", "5m", "1d", "1w"}}, + {Name: "start_at", Type: aidomain.ToolParameterTypeString, Required: true, Format: aidomain.ToolParameterFormatRFC3339}, + {Name: "end_at", Type: aidomain.ToolParameterTypeString, Required: true, Format: aidomain.ToolParameterFormatRFC3339}, + }, + }, + result: aidomain.ToolResult{ + Output: `{"points":[{"ts":"2026-04-24T10:20:00Z","value":12}]}`, + Summary: "指标查询成功", + DetailMarkdown: "```json\n{\"points\":[{\"ts\":\"2026-04-24T10:20:00Z\",\"value\":12}]}\n```", + }, + } + sink := &runtimeEventSinkStub{} + + result, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "帮我看今天此时的 HTTP 指标", + Tools: []aidomain.Tool{tool}, + }, sink) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if result.Content == "" { + t.Fatal("result.Content = empty") + } + if len(tool.calls) != 1 { + t.Fatalf("tool calls = %d, want 1", len(tool.calls)) + } + var got map[string]any + if err := json.Unmarshal([]byte(tool.calls[0].ArgumentsJSON), &got); err != nil { + t.Fatalf("json.Unmarshal(arguments) error = %v", err) + } + if got["granularity"] != "1m" || got["start_at"] != "2026-04-24T09:20:00Z" || got["end_at"] != "2026-04-24T10:20:00Z" { + t.Fatalf("tool arguments = %#v", got) + } + if len(sink.events) != 8 { + t.Fatalf("event count = %d, want 8", len(sink.events)) + } + if sink.events[2].Name != aidomain.EventToolCallFinished { + t.Fatalf("event[2] = %q, want tool_call_finished", sink.events[2].Name) + } + failedPayload, ok := sink.events[2].Payload.(aidomain.ToolCallFinishedPayload) + if !ok { + t.Fatalf("event[2] payload type = %T", sink.events[2].Payload) + } + if failedPayload.Status != "failed" { + t.Fatalf("failedPayload.Status = %q, want failed", failedPayload.Status) + } + if sink.events[4].Name != aidomain.EventToolCallFinished { + t.Fatalf("event[4] = %q, want tool_call_finished", sink.events[4].Name) + } + successPayload, ok := sink.events[4].Payload.(aidomain.ToolCallFinishedPayload) + if !ok { + t.Fatalf("event[4] payload type = %T", sink.events[4].Payload) + } + if successPayload.Status != "success" { + t.Fatalf("successPayload.Status = %q, want success", successPayload.Status) + } +} + +func TestRuntimeStreamWithToolsRepairsInvalidRFC3339InSameTurn(t *testing.T) { + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_observability_metrics", + Arguments: `{"granularity":"1m","start_at":"2026-04-24 09:20:00","end_at":"2026-04-24T10:20:00Z"}`, + }, + }, + }), + }, + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_2", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_observability_metrics", + Arguments: `{"granularity":"1m","start_at":"2026-04-24T09:20:00Z","end_at":"2026-04-24T10:20:00Z"}`, + }, + }, + }), + }, + { + schema.AssistantMessage("已修正时间格式并完成查询。", nil), + }, + }, + } + runtime := &Runtime{model: model, systemPrompt: "base system prompt"} + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "query_observability_metrics", + Parameters: []aidomain.ToolParameter{ + {Name: "granularity", Type: aidomain.ToolParameterTypeString, Required: true, Enum: []string{"1m", "5m", "1d", "1w"}}, + {Name: "start_at", Type: aidomain.ToolParameterTypeString, Required: true, Format: aidomain.ToolParameterFormatRFC3339}, + {Name: "end_at", Type: aidomain.ToolParameterTypeString, Required: true, Format: aidomain.ToolParameterFormatRFC3339}, + }, + }, + result: aidomain.ToolResult{ + Output: `{"ok":true}`, + Summary: "查询成功", + DetailMarkdown: "ok", + }, + } + sink := &runtimeEventSinkStub{} + + result, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "帮我查这个时间窗口的指标", + Tools: []aidomain.Tool{tool}, + }, sink) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if result.Content != "已修正时间格式并完成查询。" { + t.Fatalf("result.Content = %q", result.Content) + } + if len(tool.calls) != 1 { + t.Fatalf("tool calls = %d, want 1", len(tool.calls)) + } +} + +func TestRuntimeStreamWithToolsAsksUserWhenRequiredParamMissing(t *testing.T) { + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: schema.FunctionCall{ + Name: "get_my_oj_stats", + Arguments: `{}`, + }, + }, + }), + }, + { + schema.AssistantMessage("你想查哪个平台?请告诉我是 leetcode、luogu 还是 lanqiao。", nil), + }, + }, + } + runtime := &Runtime{model: model, systemPrompt: "base system prompt"} + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "get_my_oj_stats", + Parameters: []aidomain.ToolParameter{ + { + Name: "platform", + Type: aidomain.ToolParameterTypeString, + Required: true, + Enum: []string{"leetcode", "luogu", "lanqiao"}, + }, + }, + }, + } + sink := &runtimeEventSinkStub{} + + result, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "帮我查一下我的 OJ 统计", + Tools: []aidomain.Tool{tool}, + }, sink) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if len(tool.calls) != 0 { + t.Fatalf("tool calls = %d, want 0", len(tool.calls)) + } + if result.Content == "" { + t.Fatal("result.Content = empty") + } + failedPayload, ok := sink.events[2].Payload.(aidomain.ToolCallFinishedPayload) + if !ok { + t.Fatalf("event[2] payload type = %T", sink.events[2].Payload) + } + if failedPayload.Status != "failed" { + t.Fatalf("failedPayload.Status = %q, want failed", failedPayload.Status) + } + if failedPayload.DetailMarkdown == "" { + t.Fatal("failedPayload.DetailMarkdown = empty") + } +} + +func TestRuntimeStreamWithToolsStopsAfterRepeatedInvalidRepairAttempts(t *testing.T) { + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_observability_metrics", + Arguments: `{"granularity":"1h","start_at":"2026-04-24T09:20:00Z","end_at":"2026-04-24T10:20:00Z"}`, + }, + }, + }), + }, + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_2", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_observability_metrics", + Arguments: `{"granularity":"1h","start_at":"2026-04-24T09:20:00Z","end_at":"2026-04-24T10:20:00Z"}`, + }, + }, + }), + }, + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_3", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_observability_metrics", + Arguments: `{"granularity":"1h","start_at":"2026-04-24T09:20:00Z","end_at":"2026-04-24T10:20:00Z"}`, + }, + }, + }), + }, + }, + } + runtime := &Runtime{model: model, systemPrompt: "base system prompt"} + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "query_observability_metrics", + Parameters: []aidomain.ToolParameter{ + {Name: "granularity", Type: aidomain.ToolParameterTypeString, Required: true, Enum: []string{"1m", "5m", "1d", "1w"}}, + {Name: "start_at", Type: aidomain.ToolParameterTypeString, Required: true, Format: aidomain.ToolParameterFormatRFC3339}, + {Name: "end_at", Type: aidomain.ToolParameterTypeString, Required: true, Format: aidomain.ToolParameterFormatRFC3339}, + }, + }, + } + sink := &runtimeEventSinkStub{} + + _, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "继续修复这个错误参数", + Tools: []aidomain.Tool{tool}, + }, sink) + if err == nil { + t.Fatal("Stream() error = nil, want repair budget exceeded") + } + if len(tool.calls) != 0 { + t.Fatalf("tool calls = %d, want 0", len(tool.calls)) + } + if len(sink.events) != 7 { + t.Fatalf("event count = %d, want 7", len(sink.events)) + } +} diff --git a/internal/infrastructure/ai/eino/selector.go b/internal/infrastructure/ai/eino/selector.go new file mode 100644 index 0000000..ebfe2c4 --- /dev/null +++ b/internal/infrastructure/ai/eino/selector.go @@ -0,0 +1,204 @@ +package eino + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + einomodel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + + aidomain "personal_assistant/internal/domain/ai" +) + +type ProgressiveToolSelector struct { + model einomodel.BaseChatModel + systemPrompt string +} + +func NewProgressiveToolSelector(ctx context.Context, opt Options) (*ProgressiveToolSelector, error) { + model, err := NewChatModel(ctx, opt) + if err != nil { + return nil, err + } + prompt := strings.TrimSpace(opt.SystemPrompt) + if prompt == "" { + prompt = "你是 personal_assistant 的内部工具选择器。你只输出 JSON,不输出自然语言解释。" + } + return &ProgressiveToolSelector{ + model: model, + systemPrompt: prompt, + }, nil +} + +func (s *ProgressiveToolSelector) SelectGroup( + ctx context.Context, + input aidomain.ToolGroupSelectionInput, +) (aidomain.ToolGroupSelection, error) { + payload, err := json.Marshal(input.Groups) + if err != nil { + return aidomain.ToolGroupSelection{}, err + } + + response, err := s.generateJSON(ctx, buildGroupSelectionPrompt(input.Query, string(payload)), input.History, input.Query) + if err != nil { + return aidomain.ToolGroupSelection{}, err + } + + var result aidomain.ToolGroupSelection + if err := unmarshalSelectorJSON(response, &result); err != nil { + return aidomain.ToolGroupSelection{}, err + } + switch result.Decision { + case aidomain.ToolSelectionDecisionDirectAnswer, aidomain.ToolSelectionDecisionAskUser, aidomain.ToolSelectionDecisionSelectGroup: + default: + return aidomain.ToolGroupSelection{}, fmt.Errorf("invalid selector decision: %s", result.Decision) + } + return result, nil +} + +func (s *ProgressiveToolSelector) SelectTools( + ctx context.Context, + input aidomain.ToolSelectionInput, +) (aidomain.ToolSelection, error) { + payload, err := json.Marshal(input.Tools) + if err != nil { + return aidomain.ToolSelection{}, err + } + + response, err := s.generateJSON( + ctx, + buildToolSelectionPrompt(input.Query, input.Group, string(payload)), + input.History, + input.Query, + ) + if err != nil { + return aidomain.ToolSelection{}, err + } + + var result aidomain.ToolSelection + if err := unmarshalSelectorJSON(response, &result); err != nil { + return aidomain.ToolSelection{}, err + } + switch result.Confidence { + case aidomain.ToolSelectionConfidenceHigh, aidomain.ToolSelectionConfidenceLow: + default: + return aidomain.ToolSelection{}, fmt.Errorf("invalid selector confidence: %s", result.Confidence) + } + return result, nil +} + +func (s *ProgressiveToolSelector) generateJSON( + ctx context.Context, + prompt string, + history []aidomain.Message, + query string, +) (string, error) { + if s == nil || s.model == nil { + return "", fmt.Errorf("progressive tool selector model is nil") + } + messages := make([]*schema.Message, 0, len(history)+3) + if strings.TrimSpace(s.systemPrompt) != "" { + messages = append(messages, schema.SystemMessage(strings.TrimSpace(s.systemPrompt))) + } + messages = append(messages, schema.SystemMessage(strings.TrimSpace(prompt))) + for _, item := range history { + content := strings.TrimSpace(item.Content) + if content == "" { + continue + } + if strings.TrimSpace(item.Role) == aidomain.RoleAssistant { + messages = append(messages, schema.AssistantMessage(content, nil)) + continue + } + messages = append(messages, schema.UserMessage(content)) + } + if strings.TrimSpace(query) != "" { + messages = append(messages, schema.UserMessage(strings.TrimSpace(query))) + } + + msg, err := s.model.Generate(ctx, messages) + if err != nil { + return "", err + } + if msg == nil || strings.TrimSpace(msg.Content) == "" { + return "", fmt.Errorf("selector returned empty content") + } + return strings.TrimSpace(msg.Content), nil +} + +func buildGroupSelectionPrompt(query string, groupsJSON string) string { + return strings.TrimSpace(fmt.Sprintf(` +你正在执行第一阶段工具选择。任务是只根据用户问题和历史上下文,从给定工具组中做一个决策。 + +用户当前问题:%s + +候选工具组(JSON 数组): +%s + +只输出一个 JSON 对象,不要输出 markdown、不要输出解释。 +输出格式: +{ + "decision": "direct_answer | ask_user | select_group", + "group_id": "仅在 decision=select_group 时填写", + "reason": "可选,简短说明内部判断依据", + "missing_slots": ["仅在 decision=ask_user 时可选填写缺失字段"] +} + +决策规则: +1. 如果完全不需要工具即可回答,输出 direct_answer。 +2. 如果明显缺少关键信息,且在进入工具前应先追问用户,输出 ask_user。 +3. 如果需要工具,输出最合适的一个 group_id,不要返回多个组。 +4. 除 JSON 外不要输出任何内容。`, query, groupsJSON)) +} + +func buildToolSelectionPrompt(query string, group aidomain.ToolGroupBrief, toolsJSON string) string { + return strings.TrimSpace(fmt.Sprintf(` +你正在执行第二阶段工具选择。用户问题已经被路由到工具组 %s。 + +工具组摘要: +%s + +用户当前问题:%s + +候选工具(JSON 数组): +%s + +只输出一个 JSON 对象,不要输出 markdown、不要输出解释。 +输出格式: +{ + "selected_tool_names": ["最多 3 个工具名"], + "confidence": "high | low", + "reason": "可选,简短说明内部判断依据" +} + +规则: +1. 最多选择 3 个工具。 +2. 如果能明确判断需要哪些工具,confidence=high。 +3. 如果无法稳定判断或工具可能不够,confidence=low。 +4. 只输出 JSON。`, group.ID, group.Summary, query, toolsJSON)) +} + +func unmarshalSelectorJSON(raw string, target any) error { + trimmed := strings.TrimSpace(raw) + if strings.HasPrefix(trimmed, "```") { + trimmed = strings.TrimPrefix(trimmed, "```json") + trimmed = strings.TrimPrefix(trimmed, "```") + trimmed = strings.TrimSuffix(strings.TrimSpace(trimmed), "```") + trimmed = strings.TrimSpace(trimmed) + } + + start := strings.Index(trimmed, "{") + end := strings.LastIndex(trimmed, "}") + if start >= 0 && end > start { + trimmed = trimmed[start : end+1] + } + if trimmed == "" { + return fmt.Errorf("selector output is empty") + } + if err := json.Unmarshal([]byte(trimmed), target); err != nil { + return fmt.Errorf("invalid selector json: %w", err) + } + return nil +} diff --git a/internal/infrastructure/ai/eino/selector_test.go b/internal/infrastructure/ai/eino/selector_test.go new file mode 100644 index 0000000..7e0d8c5 --- /dev/null +++ b/internal/infrastructure/ai/eino/selector_test.go @@ -0,0 +1,95 @@ +package eino + +import ( + "context" + "strings" + "testing" + + "github.com/cloudwego/eino/schema" + + aidomain "personal_assistant/internal/domain/ai" +) + +func TestProgressiveToolSelectorSelectGroupParsesJSON(t *testing.T) { + model := &fakeToolCallingChatModel{ + generateMsg: schema.AssistantMessage(`{"decision":"select_group","group_id":"oj_personal","reason":"用户在问个人 OJ 表现"}`, nil), + } + selector := &ProgressiveToolSelector{ + model: model, + systemPrompt: "selector system prompt", + } + + result, err := selector.SelectGroup(context.Background(), aidomain.ToolGroupSelectionInput{ + Query: "帮我看下我的力扣排名", + Groups: []aidomain.ToolGroupBrief{ + {ID: aidomain.ToolGroupOJPersonal, Summary: "个人 OJ"}, + }, + }) + if err != nil { + t.Fatalf("SelectGroup() error = %v", err) + } + if result.Decision != aidomain.ToolSelectionDecisionSelectGroup { + t.Fatalf("decision = %q", result.Decision) + } + if result.GroupID != aidomain.ToolGroupOJPersonal { + t.Fatalf("group_id = %q", result.GroupID) + } + if len(model.generateInputs) != 1 { + t.Fatalf("generateInputs = %d, want 1", len(model.generateInputs)) + } + if got := model.generateInputs[0][1].Content; !strings.Contains(got, `"summary":"个人 OJ"`) { + t.Fatalf("selector prompt = %q, want contains group brief", got) + } +} + +func TestProgressiveToolSelectorSelectToolsRejectsInvalidJSON(t *testing.T) { + model := &fakeToolCallingChatModel{ + generateMsg: schema.AssistantMessage(`not-json`, nil), + } + selector := &ProgressiveToolSelector{model: model, systemPrompt: "selector"} + + _, err := selector.SelectTools(context.Background(), aidomain.ToolSelectionInput{ + Query: "帮我查指标", + Group: aidomain.ToolGroupBrief{ID: aidomain.ToolGroupObservabilityMetrics}, + Tools: []aidomain.ToolBrief{ + {Name: "query_runtime_metrics", Summary: "runtime"}, + }, + }) + if err == nil { + t.Fatal("SelectTools() error = nil, want invalid json") + } +} + +func TestProgressiveToolSelectorSelectToolsUsesBriefOnly(t *testing.T) { + model := &fakeToolCallingChatModel{ + generateMsg: schema.AssistantMessage(`{"selected_tool_names":["query_runtime_metrics"],"confidence":"high"}`, nil), + } + selector := &ProgressiveToolSelector{model: model, systemPrompt: "selector"} + + _, err := selector.SelectTools(context.Background(), aidomain.ToolSelectionInput{ + Query: "帮我查运行时指标", + Group: aidomain.ToolGroupBrief{ + ID: aidomain.ToolGroupObservabilityMetrics, + Summary: "查询运行时指标与 HTTP 观测指标。", + }, + Tools: []aidomain.ToolBrief{ + { + Name: "query_runtime_metrics", + Summary: "查询运行时指标。", + WhenToUse: "用户要看运行时指标时使用。", + RequiredSlots: []string{"metric"}, + DomainTags: []string{"observability", "metrics"}, + }, + }, + }) + if err != nil { + t.Fatalf("SelectTools() error = %v", err) + } + got := model.generateInputs[0][1].Content + if !strings.Contains(got, `"required_slots":["metric"]`) { + t.Fatalf("selector prompt = %q, want contains tool brief", got) + } + if strings.Contains(got, "format=rfc3339") || strings.Contains(got, `"format":"rfc3339"`) { + t.Fatalf("selector prompt unexpectedly contains full schema detail: %q", got) + } +} diff --git a/internal/infrastructure/ai/eino/tool_repair.go b/internal/infrastructure/ai/eino/tool_repair.go new file mode 100644 index 0000000..b3b6cd5 --- /dev/null +++ b/internal/infrastructure/ai/eino/tool_repair.go @@ -0,0 +1,160 @@ +package eino + +import ( + "context" + "encoding/json" + "errors" + "strings" + + aidomain "personal_assistant/internal/domain/ai" + bizerrors "personal_assistant/pkg/errors" +) + +const maxRepairAttemptsPerTurn = 2 + +type runtimeValidatingTool interface { + Validate(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) error +} + +type toolRepairState struct { + attempts int + seen map[string]int +} + +func newToolRepairState() *toolRepairState { + return &toolRepairState{ + seen: make(map[string]int), + } +} + +func (s *toolRepairState) consume(key string) bool { + if s == nil { + return false + } + s.attempts++ + s.seen[key]++ + return s.attempts <= maxRepairAttemptsPerTurn +} + +func classifyToolError(err error) *aidomain.ToolIssueError { + if issue := aidomain.FromToolIssueError(err); issue != nil { + return issue + } + + var bizErr *bizerrors.BizError + if errors.As(err, &bizErr) && bizErr != nil { + switch bizErr.Code { + case bizerrors.CodeInvalidParams, bizerrors.CodeBindFailed, bizerrors.CodeValidateFailed: + message := strings.TrimSpace(bizErr.Message) + fieldErrors := inferFieldErrorsFromMessage(message) + if seemsMissingUserInput(message) { + return aidomain.NewMissingUserInputError(message, fieldErrors...) + } + return aidomain.NewRepairableInvalidParamError(message, fieldErrors...) + case bizerrors.CodePermissionDenied, bizerrors.CodeDBError, bizerrors.CodeInternalError, bizerrors.CodeThirdPartyError: + return aidomain.NewTerminalToolError(strings.TrimSpace(bizErr.Message), err) + } + } + + return aidomain.NewTerminalToolError("工具调用失败。", err) +} + +func seemsMissingUserInput(message string) bool { + message = strings.TrimSpace(message) + if message == "" { + return false + } + needles := []string{ + "不能为空", + "缺失", + "未提供", + "必须指定", + "需要提供", + } + for _, needle := range needles { + if strings.Contains(message, needle) { + return true + } + } + return false +} + +func inferFieldErrorsFromMessage(message string) []aidomain.ToolFieldError { + message = strings.TrimSpace(message) + if message == "" { + return nil + } + field := inferFieldName(message) + if field == "" { + return nil + } + + reason := "invalid_param" + switch { + case strings.Contains(message, "不能为空"), strings.Contains(message, "缺失"), strings.Contains(message, "未提供"), strings.Contains(message, "必须指定"): + reason = "missing_required" + case strings.Contains(message, "仅支持"): + reason = "invalid_enum" + case strings.Contains(message, "RFC3339"), strings.Contains(message, "格式"): + reason = "invalid_format" + case strings.Contains(message, "必须大于"), strings.Contains(message, "不能超过"): + reason = "invalid_range" + } + + return []aidomain.ToolFieldError{ + { + Field: field, + Reason: reason, + Expected: message, + }, + } +} + +func inferFieldName(message string) string { + candidates := []string{ + "platform", + "scope", + "org_id", + "page", + "page_size", + "task_id", + "execution_id", + "target_user_id", + "username", + "request_id", + "trace_id", + "root_stage", + "status", + "metric", + "granularity", + "start_at", + "end_at", + "limit", + "offset", + "status_class", + "service", + "route_template", + "method", + "error_code", + } + for _, candidate := range candidates { + if strings.Contains(message, candidate) { + return candidate + } + } + return "" +} + +func marshalToolObservation(observation aidomain.ToolObservation) (compact string, pretty string) { + raw, err := json.Marshal(observation) + if err != nil { + return observation.Message, observation.Message + } + compact = string(raw) + + indented, err := json.MarshalIndent(observation, "", " ") + if err != nil { + return compact, compact + } + return compact, "```json\n" + string(indented) + "\n```" +} diff --git a/internal/infrastructure/ai/eino/tool_schema.go b/internal/infrastructure/ai/eino/tool_schema.go index be5bd91..afb3259 100644 --- a/internal/infrastructure/ai/eino/tool_schema.go +++ b/internal/infrastructure/ai/eino/tool_schema.go @@ -1,6 +1,9 @@ package eino import ( + "fmt" + "strings" + aidomain "personal_assistant/internal/domain/ai" "github.com/cloudwego/eino/schema" @@ -31,7 +34,7 @@ func buildSchemaParameterInfo(param aidomain.ToolParameter) (*schema.ParameterIn // 先填充当前参数节点的基础元信息。 info := &schema.ParameterInfo{ Type: schema.DataType(param.Type), - Desc: param.Description, + Desc: buildSchemaParameterDesc(param), Enum: param.Enum, Required: param.Required, } @@ -57,3 +60,41 @@ func buildSchemaParameterInfo(param aidomain.ToolParameter) (*schema.ParameterIn } return info, nil } + +func buildSchemaParameterDesc(param aidomain.ToolParameter) string { + parts := make([]string, 0, 8) + if desc := strings.TrimSpace(param.Description); desc != "" { + parts = append(parts, desc) + } + if strings.TrimSpace(param.Format) != "" { + parts = append(parts, "format="+strings.TrimSpace(param.Format)) + } + if strings.TrimSpace(param.Pattern) != "" { + parts = append(parts, "pattern="+strings.TrimSpace(param.Pattern)) + } + if param.MinLength != nil { + parts = append(parts, fmt.Sprintf("min_length=%d", *param.MinLength)) + } + if param.MaxLength != nil { + parts = append(parts, fmt.Sprintf("max_length=%d", *param.MaxLength)) + } + if param.Minimum != nil { + parts = append(parts, "min="+formatConstraintNumber(*param.Minimum)) + } + if param.Maximum != nil { + parts = append(parts, "max="+formatConstraintNumber(*param.Maximum)) + } + if param.MinItems != nil { + parts = append(parts, fmt.Sprintf("min_items=%d", *param.MinItems)) + } + if param.MaxItems != nil { + parts = append(parts, fmt.Sprintf("max_items=%d", *param.MaxItems)) + } + if defaultValue := strings.TrimSpace(param.DefaultValue); defaultValue != "" { + parts = append(parts, "default="+defaultValue) + } + if len(param.Examples) > 0 { + parts = append(parts, "examples="+strings.Join(param.Examples, " | ")) + } + return strings.Join(parts, "; ") +} diff --git a/internal/infrastructure/ai/eino/tool_validation.go b/internal/infrastructure/ai/eino/tool_validation.go new file mode 100644 index 0000000..6144e17 --- /dev/null +++ b/internal/infrastructure/ai/eino/tool_validation.go @@ -0,0 +1,489 @@ +package eino + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + aidomain "personal_assistant/internal/domain/ai" +) + +type validatedToolArguments struct { + NormalizedJSON string +} + +func validateToolCallArguments(spec aidomain.ToolSpec, raw string) (validatedToolArguments, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + raw = "{}" + } + + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + + var payload any + if err := decoder.Decode(&payload); err != nil { + return validatedToolArguments{NormalizedJSON: raw}, aidomain.NewRepairableInvalidParamErrorWithCause( + "工具参数不是合法的 JSON 对象。", + err, + aidomain.ToolFieldError{ + Field: "arguments", + Reason: "invalid_json", + Expected: "合法的 JSON 对象", + Example: `{"platform":"leetcode"}`, + }, + ) + } + + root, ok := payload.(map[string]any) + if !ok { + return validatedToolArguments{NormalizedJSON: raw}, aidomain.NewRepairableInvalidParamError( + "工具参数必须是 JSON 对象。", + aidomain.ToolFieldError{ + Field: "arguments", + Reason: "invalid_type", + Expected: "JSON object", + Example: `{"platform":"leetcode"}`, + }, + ) + } + + normalizedRoot := make(map[string]any, len(spec.Parameters)) + fieldErrors := make([]aidomain.ToolFieldError, 0) + hasMissing := false + for _, param := range spec.Parameters { + value, exists := root[param.Name] + normalized, keep, errs, missing := validateToolParameter(param, value, exists, param.Name) + if keep { + normalizedRoot[param.Name] = normalized + } + fieldErrors = append(fieldErrors, errs...) + hasMissing = hasMissing || missing + } + + normalizedJSON := marshalNormalizedToolArgs(normalizedRoot, raw) + if len(fieldErrors) == 0 { + return validatedToolArguments{NormalizedJSON: normalizedJSON}, nil + } + + if hasMissing { + return validatedToolArguments{NormalizedJSON: normalizedJSON}, aidomain.NewMissingUserInputError( + "缺少继续执行所需的信息,请先向用户追问。", + fieldErrors..., + ) + } + return validatedToolArguments{NormalizedJSON: normalizedJSON}, aidomain.NewRepairableInvalidParamError( + "工具参数不合法,请修正后重试。", + fieldErrors..., + ) +} + +func marshalNormalizedToolArgs(payload map[string]any, fallback string) string { + raw, err := json.Marshal(payload) + if err != nil { + return fallback + } + return string(raw) +} + +func validateToolParameter( + param aidomain.ToolParameter, + value any, + exists bool, + fieldPath string, +) (normalized any, keep bool, fieldErrors []aidomain.ToolFieldError, missing bool) { + if !exists || value == nil { + if param.Required { + return nil, false, []aidomain.ToolFieldError{toolMissingFieldError(param, fieldPath)}, true + } + return nil, false, nil, false + } + + switch param.Type { + case aidomain.ToolParameterTypeString: + return validateStringParameter(param, value, fieldPath) + case aidomain.ToolParameterTypeInteger: + return validateIntegerParameter(param, value, fieldPath) + case aidomain.ToolParameterTypeNumber: + return validateNumberParameter(param, value, fieldPath) + case aidomain.ToolParameterTypeBoolean: + boolValue, ok := value.(bool) + if !ok { + return value, true, []aidomain.ToolFieldError{toolInvalidFieldError( + fieldPath, + "invalid_type", + "boolean", + nil, + firstExample(param), + )}, false + } + return boolValue, true, nil, false + case aidomain.ToolParameterTypeArray: + return validateArrayParameter(param, value, fieldPath) + case aidomain.ToolParameterTypeObject: + return validateObjectParameter(param, value, fieldPath) + default: + return value, true, []aidomain.ToolFieldError{toolInvalidFieldError( + fieldPath, + "unsupported_type", + string(param.Type), + nil, + firstExample(param), + )}, false + } +} + +func validateStringParameter( + param aidomain.ToolParameter, + value any, + fieldPath string, +) (normalized any, keep bool, fieldErrors []aidomain.ToolFieldError, missing bool) { + stringValue, ok := value.(string) + if !ok { + return value, true, []aidomain.ToolFieldError{toolInvalidFieldError( + fieldPath, + "invalid_type", + toolExpectedSummary(param), + param.Enum, + firstExample(param), + )}, false + } + + stringValue = strings.TrimSpace(stringValue) + if stringValue == "" { + if param.Required { + return nil, false, []aidomain.ToolFieldError{toolMissingFieldError(param, fieldPath)}, true + } + return nil, false, nil, false + } + + if shouldNormalizeLowercase(param) { + stringValue = strings.ToLower(stringValue) + } + + fieldErrors = make([]aidomain.ToolFieldError, 0) + if len(param.Enum) > 0 && !containsString(param.Enum, stringValue) { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "invalid_enum", + toolExpectedSummary(param), + param.Enum, + firstExample(param), + )) + } + if strings.TrimSpace(param.Format) == aidomain.ToolParameterFormatRFC3339 { + parsed, err := time.Parse(time.RFC3339, stringValue) + if err != nil { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "invalid_format", + toolExpectedSummary(param), + param.Enum, + firstExample(param), + )) + } else { + stringValue = parsed.UTC().Format(time.RFC3339) + } + } + if strings.TrimSpace(param.Pattern) != "" { + matched, err := regexp.MatchString(strings.TrimSpace(param.Pattern), stringValue) + if err != nil || !matched { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "invalid_pattern", + toolExpectedSummary(param), + param.Enum, + firstExample(param), + )) + } + } + if param.MinLength != nil && len([]rune(stringValue)) < *param.MinLength { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "min_length", + toolExpectedSummary(param), + param.Enum, + firstExample(param), + )) + } + if param.MaxLength != nil && len([]rune(stringValue)) > *param.MaxLength { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "max_length", + toolExpectedSummary(param), + param.Enum, + firstExample(param), + )) + } + return stringValue, true, fieldErrors, false +} + +func validateIntegerParameter( + param aidomain.ToolParameter, + value any, + fieldPath string, +) (normalized any, keep bool, fieldErrors []aidomain.ToolFieldError, missing bool) { + integerValue, ok := parseIntegerValue(value) + if !ok { + return value, true, []aidomain.ToolFieldError{toolInvalidFieldError( + fieldPath, + "invalid_type", + toolExpectedSummary(param), + nil, + firstExample(param), + )}, false + } + fieldErrors = validateNumericBounds(param, float64(integerValue), fieldPath) + return integerValue, true, fieldErrors, false +} + +func validateNumberParameter( + param aidomain.ToolParameter, + value any, + fieldPath string, +) (normalized any, keep bool, fieldErrors []aidomain.ToolFieldError, missing bool) { + numberValue, ok := parseNumberValue(value) + if !ok { + return value, true, []aidomain.ToolFieldError{toolInvalidFieldError( + fieldPath, + "invalid_type", + toolExpectedSummary(param), + nil, + firstExample(param), + )}, false + } + fieldErrors = validateNumericBounds(param, numberValue, fieldPath) + return numberValue, true, fieldErrors, false +} + +func validateNumericBounds(param aidomain.ToolParameter, value float64, fieldPath string) []aidomain.ToolFieldError { + fieldErrors := make([]aidomain.ToolFieldError, 0) + if param.Minimum != nil && value < *param.Minimum { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "out_of_range", + toolExpectedSummary(param), + nil, + firstExample(param), + )) + } + if param.Maximum != nil && value > *param.Maximum { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "out_of_range", + toolExpectedSummary(param), + nil, + firstExample(param), + )) + } + return fieldErrors +} + +func validateArrayParameter( + param aidomain.ToolParameter, + value any, + fieldPath string, +) (normalized any, keep bool, fieldErrors []aidomain.ToolFieldError, missing bool) { + items, ok := value.([]any) + if !ok { + return value, true, []aidomain.ToolFieldError{toolInvalidFieldError( + fieldPath, + "invalid_type", + toolExpectedSummary(param), + nil, + firstExample(param), + )}, false + } + if len(items) == 0 && param.Required { + return []any{}, true, []aidomain.ToolFieldError{toolMissingFieldError(param, fieldPath)}, true + } + fieldErrors = make([]aidomain.ToolFieldError, 0) + if param.MinItems != nil && len(items) < *param.MinItems { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "min_items", + toolExpectedSummary(param), + nil, + firstExample(param), + )) + } + if param.MaxItems != nil && len(items) > *param.MaxItems { + fieldErrors = append(fieldErrors, toolInvalidFieldError( + fieldPath, + "max_items", + toolExpectedSummary(param), + nil, + firstExample(param), + )) + } + + normalizedItems := make([]any, 0, len(items)) + if param.Items == nil { + return items, true, fieldErrors, false + } + for idx, item := range items { + childPath := fmt.Sprintf("%s[%d]", fieldPath, idx) + childNormalized, childKeep, childErrors, childMissing := validateToolParameter(*param.Items, item, true, childPath) + if childKeep { + normalizedItems = append(normalizedItems, childNormalized) + } + fieldErrors = append(fieldErrors, childErrors...) + missing = missing || childMissing + } + return normalizedItems, true, fieldErrors, missing +} + +func validateObjectParameter( + param aidomain.ToolParameter, + value any, + fieldPath string, +) (normalized any, keep bool, fieldErrors []aidomain.ToolFieldError, missing bool) { + objectValue, ok := value.(map[string]any) + if !ok { + return value, true, []aidomain.ToolFieldError{toolInvalidFieldError( + fieldPath, + "invalid_type", + toolExpectedSummary(param), + nil, + firstExample(param), + )}, false + } + normalizedObject := make(map[string]any, len(param.Properties)) + fieldErrors = make([]aidomain.ToolFieldError, 0) + for _, child := range param.Properties { + childPath := child.Name + if fieldPath != "" { + childPath = fieldPath + "." + child.Name + } + childValue, exists := objectValue[child.Name] + childNormalized, childKeep, childErrors, childMissing := validateToolParameter(child, childValue, exists, childPath) + if childKeep { + normalizedObject[child.Name] = childNormalized + } + fieldErrors = append(fieldErrors, childErrors...) + missing = missing || childMissing + } + return normalizedObject, true, fieldErrors, missing +} + +func parseIntegerValue(value any) (int64, bool) { + switch typed := value.(type) { + case json.Number: + integerValue, err := typed.Int64() + return integerValue, err == nil + case float64: + if float64(int64(typed)) != typed { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + default: + return 0, false + } +} + +func parseNumberValue(value any) (float64, bool) { + switch typed := value.(type) { + case json.Number: + floatValue, err := typed.Float64() + return floatValue, err == nil + case float64: + return typed, true + case int: + return float64(typed), true + case int64: + return float64(typed), true + default: + return 0, false + } +} + +func toolMissingFieldError(param aidomain.ToolParameter, fieldPath string) aidomain.ToolFieldError { + return aidomain.ToolFieldError{ + Field: fieldPath, + Reason: "missing_required", + Expected: toolExpectedSummary(param), + Allowed: append([]string(nil), param.Enum...), + Example: firstExample(param), + } +} + +func toolInvalidFieldError(fieldPath, reason, expected string, allowed []string, example string) aidomain.ToolFieldError { + return aidomain.ToolFieldError{ + Field: fieldPath, + Reason: reason, + Expected: expected, + Allowed: append([]string(nil), allowed...), + Example: example, + } +} + +func toolExpectedSummary(param aidomain.ToolParameter) string { + parts := []string{string(param.Type)} + if strings.TrimSpace(param.Format) != "" { + parts = append(parts, "format="+strings.TrimSpace(param.Format)) + } + if len(param.Enum) > 0 { + parts = append(parts, "enum="+strings.Join(param.Enum, "/")) + } + if param.Minimum != nil { + parts = append(parts, "min="+formatConstraintNumber(*param.Minimum)) + } + if param.Maximum != nil { + parts = append(parts, "max="+formatConstraintNumber(*param.Maximum)) + } + if param.MinLength != nil { + parts = append(parts, fmt.Sprintf("min_length=%d", *param.MinLength)) + } + if param.MaxLength != nil { + parts = append(parts, fmt.Sprintf("max_length=%d", *param.MaxLength)) + } + if param.MinItems != nil { + parts = append(parts, fmt.Sprintf("min_items=%d", *param.MinItems)) + } + if param.MaxItems != nil { + parts = append(parts, fmt.Sprintf("max_items=%d", *param.MaxItems)) + } + return strings.Join(parts, ", ") +} + +func formatConstraintNumber(value float64) string { + if float64(int64(value)) == value { + return fmt.Sprintf("%d", int64(value)) + } + return fmt.Sprintf("%g", value) +} + +func firstExample(param aidomain.ToolParameter) string { + if len(param.Examples) == 0 { + return "" + } + return param.Examples[0] +} + +func shouldNormalizeLowercase(param aidomain.ToolParameter) bool { + if len(param.Enum) == 0 { + return false + } + for _, item := range param.Enum { + trimmed := strings.TrimSpace(item) + if trimmed == "" || strings.ToLower(trimmed) != trimmed { + return false + } + } + return true +} + +func containsString(values []string, target string) bool { + for _, item := range values { + if target == strings.TrimSpace(item) { + return true + } + } + return false +} diff --git a/internal/infrastructure/ai/eino/tool_validation_test.go b/internal/infrastructure/ai/eino/tool_validation_test.go new file mode 100644 index 0000000..12710c1 --- /dev/null +++ b/internal/infrastructure/ai/eino/tool_validation_test.go @@ -0,0 +1,117 @@ +package eino + +import ( + "strings" + "testing" + + aidomain "personal_assistant/internal/domain/ai" +) + +func TestValidateToolCallArgumentsMissingRequiredField(t *testing.T) { + spec := aidomain.ToolSpec{ + Name: "get_my_oj_stats", + Parameters: []aidomain.ToolParameter{ + { + Name: "platform", + Type: aidomain.ToolParameterTypeString, + Required: true, + Enum: []string{"leetcode", "luogu", "lanqiao"}, + }, + }, + } + + _, err := validateToolCallArguments(spec, `{}`) + issue := aidomain.FromToolIssueError(err) + if issue == nil { + t.Fatalf("validateToolCallArguments() error = %v, want ToolIssueError", err) + } + if issue.Classification != aidomain.ToolObservationMissingUserInput { + t.Fatalf("classification = %q, want %q", issue.Classification, aidomain.ToolObservationMissingUserInput) + } +} + +func TestValidateToolCallArgumentsRejectsInvalidEnum(t *testing.T) { + spec := aidomain.ToolSpec{ + Name: "query_observability_metrics", + Parameters: []aidomain.ToolParameter{ + { + Name: "granularity", + Type: aidomain.ToolParameterTypeString, + Required: true, + Enum: []string{"1m", "5m", "1d", "1w"}, + }, + }, + } + + _, err := validateToolCallArguments(spec, `{"granularity":"1h"}`) + issue := aidomain.FromToolIssueError(err) + if issue == nil { + t.Fatalf("validateToolCallArguments() error = %v, want ToolIssueError", err) + } + if issue.Classification != aidomain.ToolObservationRepairableInvalidParam { + t.Fatalf("classification = %q, want %q", issue.Classification, aidomain.ToolObservationRepairableInvalidParam) + } +} + +func TestValidateToolCallArgumentsNormalizesRFC3339(t *testing.T) { + spec := aidomain.ToolSpec{ + Name: "query_observability_metrics", + Parameters: []aidomain.ToolParameter{ + { + Name: "start_at", + Type: aidomain.ToolParameterTypeString, + Required: true, + Format: aidomain.ToolParameterFormatRFC3339, + }, + }, + } + + validated, err := validateToolCallArguments(spec, `{"start_at":"2026-04-24T17:20:00+08:00"}`) + if err != nil { + t.Fatalf("validateToolCallArguments() error = %v", err) + } + if !strings.Contains(validated.NormalizedJSON, `"2026-04-24T09:20:00Z"`) { + t.Fatalf("NormalizedJSON = %q, want UTC RFC3339", validated.NormalizedJSON) + } +} + +func TestValidateToolCallArgumentsValidatesNestedArrayObjects(t *testing.T) { + spec := aidomain.ToolSpec{ + Name: "analyze_task_titles", + Parameters: []aidomain.ToolParameter{ + { + Name: "items", + Type: aidomain.ToolParameterTypeArray, + Required: true, + MinItems: func() *int { v := 1; return &v }(), + Items: &aidomain.ToolParameter{ + Type: aidomain.ToolParameterTypeObject, + Properties: []aidomain.ToolParameter{ + { + Name: "platform", + Type: aidomain.ToolParameterTypeString, + Required: true, + Enum: []string{"leetcode", "luogu", "lanqiao"}, + }, + { + Name: "title", + Type: aidomain.ToolParameterTypeString, + Required: true, + MinLength: func() *int { v := 1; return &v }(), + MaxLength: func() *int { v := 255; return &v }(), + }, + }, + }, + }, + }, + } + + _, err := validateToolCallArguments(spec, `{"items":[{"platform":"codeforces","title":"A"}]}`) + issue := aidomain.FromToolIssueError(err) + if issue == nil { + t.Fatalf("validateToolCallArguments() error = %v, want ToolIssueError", err) + } + if issue.Classification != aidomain.ToolObservationRepairableInvalidParam { + t.Fatalf("classification = %q, want %q", issue.Classification, aidomain.ToolObservationRepairableInvalidParam) + } +} diff --git a/internal/service/system/aiContext.go b/internal/service/system/aiContext.go index a75c0d5..1ddde7a 100644 --- a/internal/service/system/aiContext.go +++ b/internal/service/system/aiContext.go @@ -47,8 +47,7 @@ type aiContextBuildArgs struct { // aiContextSnapshot 表示装配完成后可直接喂给 runtime 的上下文片段。 type aiContextSnapshot struct { - History []aidomain.Message - DynamicSystemPrompt string // 未来可扩展 DynamicToolPrompt、MemoryAugmentation 等片段。 + History []aidomain.Message } // aiContextAssembler 负责统一收口历史消息、记忆扩展点、压缩扩展点和动态 prompt。 @@ -57,20 +56,14 @@ type aiContextAssembler interface { } type defaultAIContextAssembler struct { - memory aiMemoryProvider - compressor aiContextCompressor - promptBuilder aiPromptBuilder + memory aiMemoryProvider + compressor aiContextCompressor } func newAIContextAssembler(deps AIDeps) aiContextAssembler { - builder := deps.PromptBuilder - if builder == nil { - builder = defaultAIToolPromptBuilder{} - } return &defaultAIContextAssembler{ - memory: deps.Memory, - compressor: deps.Compressor, - promptBuilder: builder, + memory: deps.Memory, + compressor: deps.Compressor, } } @@ -109,7 +102,6 @@ func (a *defaultAIContextAssembler) Build( } return aiContextSnapshot{ - History: history, - DynamicSystemPrompt: a.promptBuilder.BuildDynamicPrompt(args.VisibleTools, args.ToolCallCtx.Principal), + History: history, }, nil } diff --git a/internal/service/system/aiContext_test.go b/internal/service/system/aiContext_test.go index 49835ba..94ec72b 100644 --- a/internal/service/system/aiContext_test.go +++ b/internal/service/system/aiContext_test.go @@ -2,7 +2,6 @@ package system import ( "context" - "strings" "testing" aidomain "personal_assistant/internal/domain/ai" @@ -51,7 +50,16 @@ func (f *fakePromptBuilder) BuildDynamicPrompt([]aidomain.Tool, aidomain.AIToolP return f.output } -func TestDefaultAIContextAssemblerUsesStoredHistoryAndDefaultPrompt(t *testing.T) { +func (f *fakePromptBuilder) BuildDecisionPrompt( + aidomain.ToolSelectionDecision, + string, + []string, +) string { + f.calls++ + return f.output +} + +func TestDefaultAIContextAssemblerUsesStoredHistory(t *testing.T) { assembler := newAIContextAssembler(AIDeps{}) tool := &fakeContextTool{ spec: aidomain.ToolSpec{ @@ -90,15 +98,6 @@ func TestDefaultAIContextAssemblerUsesStoredHistoryAndDefaultPrompt(t *testing.T if snapshot.History[1].Role != aidomain.RoleAssistant { t.Fatalf("history[1].Role = %q", snapshot.History[1].Role) } - if snapshot.DynamicSystemPrompt == "" { - t.Fatal("DynamicSystemPrompt = empty") - } - if want := "get_my_oj_stats"; !strings.Contains(snapshot.DynamicSystemPrompt, want) { - t.Fatalf("DynamicSystemPrompt = %q, want contains %q", snapshot.DynamicSystemPrompt, want) - } - if want := "不要猜测"; !strings.Contains(snapshot.DynamicSystemPrompt, want) { - t.Fatalf("DynamicSystemPrompt = %q, want contains %q", snapshot.DynamicSystemPrompt, want) - } } func TestDefaultAIContextAssemblerCallsOptionalProviders(t *testing.T) { @@ -112,11 +111,9 @@ func TestDefaultAIContextAssemblerCallsOptionalProviders(t *testing.T) { {ID: "cmp_1", Role: aidomain.RoleAssistant, Content: "压缩后的上下文"}, }, } - promptBuilder := &fakePromptBuilder{output: "custom prompt"} assembler := newAIContextAssembler(AIDeps{ - Memory: memory, - Compressor: compressor, - PromptBuilder: promptBuilder, + Memory: memory, + Compressor: compressor, }) snapshot, err := assembler.Build(context.Background(), aiContextBuildArgs{ @@ -136,13 +133,7 @@ func TestDefaultAIContextAssemblerCallsOptionalProviders(t *testing.T) { if compressor.calls != 1 { t.Fatalf("compressor calls = %d, want 1", compressor.calls) } - if promptBuilder.calls != 1 { - t.Fatalf("promptBuilder calls = %d, want 1", promptBuilder.calls) - } if len(snapshot.History) != 1 || snapshot.History[0].ID != "cmp_1" { t.Fatalf("snapshot.History = %+v, want compressed output", snapshot.History) } - if snapshot.DynamicSystemPrompt != "custom prompt" { - t.Fatalf("DynamicSystemPrompt = %q", snapshot.DynamicSystemPrompt) - } } diff --git a/internal/service/system/aiDeps.go b/internal/service/system/aiDeps.go new file mode 100644 index 0000000..1a8f841 --- /dev/null +++ b/internal/service/system/aiDeps.go @@ -0,0 +1,15 @@ +package system + +import ( + "personal_assistant/internal/service/system/aiselect" + "personal_assistant/internal/service/system/aitool" +) + +// AIDeps 收口 AIService 运行期依赖,其中 tool 相关能力下沉到子包。 +type AIDeps struct { + Tools aitool.Deps + Memory aiMemoryProvider + Compressor aiContextCompressor + PromptBuilder aiselect.PromptBuilder + Selector aiselect.Selector +} diff --git a/internal/service/system/aiProgressive.go b/internal/service/system/aiProgressive.go new file mode 100644 index 0000000..39919c1 --- /dev/null +++ b/internal/service/system/aiProgressive.go @@ -0,0 +1,23 @@ +package system + +import ( + "context" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/service/system/aiselect" +) + +type aiToolExecutionPlan = aiselect.ExecutionPlan + +func (s *AIService) buildAIToolExecutionPlan( + ctx context.Context, + query string, + history []aidomain.Message, + visibleTools []aidomain.Tool, + principal aidomain.AIToolPrincipal, +) (aiToolExecutionPlan, error) { + if s == nil || s.toolPlanner == nil { + return aiToolExecutionPlan{Tools: visibleTools}, nil + } + return s.toolPlanner.BuildExecutionPlan(ctx, query, history, visibleTools, principal) +} diff --git a/internal/service/system/aiPrompt.go b/internal/service/system/aiPrompt.go deleted file mode 100644 index e18b34b..0000000 --- a/internal/service/system/aiPrompt.go +++ /dev/null @@ -1,19 +0,0 @@ -package system - -import aidomain "personal_assistant/internal/domain/ai" - -// aiPromptBuilder 负责生成本轮 runtime 需要的动态 system prompt。 -// 该接口仅在 service/system 内部使用,不向 controller 或 domain 泄漏具体实现。 -type aiPromptBuilder interface { - BuildDynamicPrompt(tools []aidomain.Tool, principal aidomain.AIToolPrincipal) string -} - -// defaultAIToolPromptBuilder 复用当前内建的工具约束 prompt 逻辑。 -type defaultAIToolPromptBuilder struct{} - -func (defaultAIToolPromptBuilder) BuildDynamicPrompt( - tools []aidomain.Tool, - principal aidomain.AIToolPrincipal, -) string { - return buildAIToolDynamicPrompt(tools, principal) -} diff --git a/internal/service/system/aiSvc.go b/internal/service/system/aiSvc.go index 6f03bc5..ca249b8 100644 --- a/internal/service/system/aiSvc.go +++ b/internal/service/system/aiSvc.go @@ -14,6 +14,8 @@ import ( "personal_assistant/internal/model/entity" "personal_assistant/internal/repository" "personal_assistant/internal/repository/interfaces" + "personal_assistant/internal/service/system/aiselect" + "personal_assistant/internal/service/system/aitool" bizerrors "personal_assistant/pkg/errors" "go.uber.org/zap" @@ -42,11 +44,13 @@ type AIService struct { runtime aidomain.Runtime policy streamsse.ConnectionPolicy // authorizationSvc 用于补充超级管理员和组织能力的真实鉴权。 - authorizationSvc aiAuthorizationService + authorizationSvc aitool.AuthorizationService // toolRegistry 负责注册、过滤并执行本轮可见 AI tool。 - toolRegistry *aiToolRegistry + toolRegistry *aitool.Registry // contextAssembler 负责组装 runtime 所需的历史消息和动态 prompt。 contextAssembler aiContextAssembler + // toolPlanner 负责渐进式工具选择与动态 prompt 组装。 + toolPlanner *aiselect.Planner } // NewAIService 负责组装 AIService 所需依赖。 @@ -94,6 +98,8 @@ func newAIServiceWithDeps( policy = global.StreamInfra.Policy } + registry := aitool.NewRegistry(deps.Tools) + return &AIService{ txRunner: repositoryGroup, aiRepo: repositoryGroup.SystemRepositorySupplier.GetAIRepository(), @@ -101,11 +107,13 @@ func newAIServiceWithDeps( runtime: runtime, policy: policy.Normalize(), // 授权服务只保存到 AIService,供构造 principal 和执行前鉴权复用。 - authorizationSvc: deps.Authorization, + authorizationSvc: deps.Tools.Authorization, // tool registry 根据当前注入依赖决定哪些工具真正可用。 - toolRegistry: newAIToolRegistry(deps), + toolRegistry: registry, // 上下文装配器负责收口历史消息、动态 prompt 和未来扩展点。 contextAssembler: newAIContextAssembler(deps), + // 渐进式 planner 负责把 selector 和 prompt builder 组合成本轮执行计划。 + toolPlanner: aiselect.NewPlanner(registry, deps.Selector, deps.PromptBuilder), } } @@ -328,7 +336,19 @@ func (s *AIService) StreamConversation( return bizerrors.Wrap(bizerrors.CodeInternalError, err) } - // 把动态 prompt、可见工具和调用上下文一并注入 runtime。 + // 按渐进式 selector 解析最终执行计划;失败时自动回退单阶段全量工具。 + executionPlan, err := s.buildAIToolExecutionPlan( + ctx, + strings.TrimSpace(req.Content), + contextSnapshot.History, + visibleTools, + toolPrincipal, + ) + if err != nil { + return bizerrors.Wrap(bizerrors.CodeInternalError, err) + } + + // 把最终 prompt、已选工具和调用上下文一并注入 runtime。 _, execErr := s.runtime.Stream(ctx, aidomain.StreamInput{ UserID: userID, ConversationID: conversation.ID, @@ -336,8 +356,8 @@ func (s *AIService) StreamConversation( AssistantMessageID: assistantMessage.ID, Content: strings.TrimSpace(req.Content), History: contextSnapshot.History, - DynamicSystemPrompt: contextSnapshot.DynamicSystemPrompt, - Tools: visibleTools, + DynamicSystemPrompt: executionPlan.DynamicSystemPrompt, + Tools: executionPlan.Tools, ToolCallContext: toolCallCtx, }, sink) diff --git a/internal/service/system/aiTool_test.go b/internal/service/system/aiTool_test.go deleted file mode 100644 index c7d2b39..0000000 --- a/internal/service/system/aiTool_test.go +++ /dev/null @@ -1,370 +0,0 @@ -package system - -import ( - "context" - "testing" - - aidomain "personal_assistant/internal/domain/ai" - "personal_assistant/internal/model/consts" - "personal_assistant/internal/model/dto/request" - resp "personal_assistant/internal/model/dto/response" - bizerrors "personal_assistant/pkg/errors" -) - -type fakeAIToolAuthorization struct { - superAdmin bool - capabilities map[uint]map[string]bool - checkCalls int - authorizeCalls int -} - -func (f *fakeAIToolAuthorization) IsSuperAdmin(context.Context, uint) (bool, error) { - return f.superAdmin, nil -} - -func (f *fakeAIToolAuthorization) CheckUserCapabilityInOrg( - _ context.Context, - _ uint, - orgID uint, - capabilityCode string, -) (bool, error) { - f.checkCalls++ - return f.hasCapability(orgID, capabilityCode), nil -} - -func (f *fakeAIToolAuthorization) AuthorizeOrgCapability( - _ context.Context, - _ uint, - orgID uint, - capabilityCode string, -) error { - f.authorizeCalls++ - if f.superAdmin || f.hasCapability(orgID, capabilityCode) { - return nil - } - return bizerrors.New(bizerrors.CodePermissionDenied) -} - -func (f *fakeAIToolAuthorization) hasCapability(orgID uint, capabilityCode string) bool { - if f == nil { - return false - } - if caps, ok := f.capabilities[orgID]; ok { - return caps[capabilityCode] - } - return false -} - -type fakeAIToolOJService struct{} - -func (f *fakeAIToolOJService) GetRankingList( - context.Context, - uint, - *request.OJRankingListReq, -) (*resp.OJRankingListResp, error) { - return &resp.OJRankingListResp{}, nil -} - -func (f *fakeAIToolOJService) GetUserStats( - context.Context, - uint, - *request.OJStatsReq, -) (*resp.OJStatsResp, error) { - return &resp.OJStatsResp{}, nil -} - -func (f *fakeAIToolOJService) GetCurve( - context.Context, - uint, - *request.OJCurveReq, -) (*resp.OJCurveResp, error) { - return &resp.OJCurveResp{}, nil -} - -type fakeAIToolOJTaskService struct { - taskDetailResp *resp.OJTaskDetailResp - taskExecutionResp *resp.OJTaskExecutionResp - taskExecutionUsersResp *resp.OJTaskExecutionUserListResp - taskExecutionUserResp *resp.OJTaskExecutionUserDetailResp - analyzeResp *resp.OJTaskAnalyzeResp - taskDetailCalls int - taskExecutionCalls int - taskExecutionUsersCalls int - taskExecutionUserCalls int - analyzeCalls int -} - -func (f *fakeAIToolOJTaskService) AnalyzeTaskTitles( - context.Context, - *request.AnalyzeOJTaskTitlesReq, -) (*resp.OJTaskAnalyzeResp, error) { - f.analyzeCalls++ - if f.analyzeResp == nil { - return &resp.OJTaskAnalyzeResp{}, nil - } - return f.analyzeResp, nil -} - -func (f *fakeAIToolOJTaskService) GetTaskDetail( - context.Context, - uint, - uint, -) (*resp.OJTaskDetailResp, error) { - f.taskDetailCalls++ - if f.taskDetailResp == nil { - return &resp.OJTaskDetailResp{}, nil - } - return f.taskDetailResp, nil -} - -func (f *fakeAIToolOJTaskService) GetTaskExecutionDetail( - context.Context, - uint, - uint, - uint, -) (*resp.OJTaskExecutionResp, error) { - f.taskExecutionCalls++ - if f.taskExecutionResp == nil { - return &resp.OJTaskExecutionResp{}, nil - } - return f.taskExecutionResp, nil -} - -func (f *fakeAIToolOJTaskService) GetTaskExecutionUsers( - context.Context, - uint, - uint, - uint, - *request.OJTaskExecutionUserListReq, -) (*resp.OJTaskExecutionUserListResp, error) { - f.taskExecutionUsersCalls++ - if f.taskExecutionUsersResp == nil { - return &resp.OJTaskExecutionUserListResp{}, nil - } - return f.taskExecutionUsersResp, nil -} - -func (f *fakeAIToolOJTaskService) GetTaskExecutionUserDetail( - context.Context, - uint, - uint, - uint, - uint, -) (*resp.OJTaskExecutionUserDetailResp, error) { - f.taskExecutionUserCalls++ - if f.taskExecutionUserResp == nil { - return &resp.OJTaskExecutionUserDetailResp{}, nil - } - return f.taskExecutionUserResp, nil -} - -type fakeAIToolObservabilityService struct { - runtimeMetricsResp *resp.ObservabilityRuntimeMetricQueryResp - runtimeCalls int -} - -func (f *fakeAIToolObservabilityService) QueryMetrics( - context.Context, - *request.ObservabilityMetricsQueryReq, -) (*resp.ObservabilityMetricsQueryResp, error) { - return &resp.ObservabilityMetricsQueryResp{}, nil -} - -func (f *fakeAIToolObservabilityService) QueryRuntimeMetrics( - context.Context, - *request.ObservabilityRuntimeMetricQueryReq, -) (*resp.ObservabilityRuntimeMetricQueryResp, error) { - f.runtimeCalls++ - if f.runtimeMetricsResp == nil { - return &resp.ObservabilityRuntimeMetricQueryResp{}, nil - } - return f.runtimeMetricsResp, nil -} - -func (f *fakeAIToolObservabilityService) QueryTraceDetail( - context.Context, - string, - string, - int, - int, - bool, - bool, -) (*resp.ObservabilityTraceQueryResp, error) { - return &resp.ObservabilityTraceQueryResp{}, nil -} - -func (f *fakeAIToolObservabilityService) QueryTrace( - context.Context, - *request.ObservabilityTraceQueryReq, -) (*resp.ObservabilityTraceSummaryQueryResp, error) { - return &resp.ObservabilityTraceSummaryQueryResp{}, nil -} - -func TestAIToolRegistryFilterVisibleToolsByPolicy(t *testing.T) { - orgID := uint(10) - auth := &fakeAIToolAuthorization{ - capabilities: map[uint]map[string]bool{ - orgID: {consts.CapabilityCodeOJTaskManage: true}, - }, - } - registry := newAIToolRegistry(AIDeps{ - Authorization: auth, - OJ: &fakeAIToolOJService{}, - OJTask: &fakeAIToolOJTaskService{}, - Observability: &fakeAIToolObservabilityService{}, - }) - - tools, err := registry.FilterVisibleTools(context.Background(), aidomain.ToolCallContext{ - Principal: aidomain.AIToolPrincipal{ - UserID: 1, - CurrentOrgID: &orgID, - }, - }) - if err != nil { - t.Fatalf("FilterVisibleTools() error = %v", err) - } - - names := toolNames(tools) - assertContainsTool(t, names, "get_my_oj_stats") - assertContainsTool(t, names, "get_org_ranking_summary") - assertNotContainsTool(t, names, "query_runtime_metrics") - - auth.superAdmin = true - tools, err = registry.FilterVisibleTools(context.Background(), aidomain.ToolCallContext{ - Principal: aidomain.AIToolPrincipal{ - UserID: 1, - CurrentOrgID: &orgID, - IsSuperAdmin: true, - }, - }) - if err != nil { - t.Fatalf("FilterVisibleTools() error = %v", err) - } - - names = toolNames(tools) - assertContainsTool(t, names, "query_runtime_metrics") -} - -func TestAIToolExecutionReauthorizesTaskOrgCapability(t *testing.T) { - taskSvc := &fakeAIToolOJTaskService{ - taskDetailResp: &resp.OJTaskDetailResp{ - TaskID: 1, - Orgs: []*resp.OJTaskOrgItemResp{ - {OrgID: 9, OrgName: "org-9"}, - }, - }, - taskExecutionResp: &resp.OJTaskExecutionResp{ - TaskID: 1, - ExecutionID: 2, - Status: "succeeded", - }, - } - auth := &fakeAIToolAuthorization{ - capabilities: map[uint]map[string]bool{ - 9: {consts.CapabilityCodeOJTaskManage: true}, - }, - } - tool := newAIToolRegistry(AIDeps{ - Authorization: auth, - OJTask: taskSvc, - }).findTool("get_task_execution_summary") - if tool == nil { - t.Fatal("tool get_task_execution_summary not found") - } - - _, err := tool.Call(context.Background(), aidomain.ToolCall{ - ID: "call_1", - Name: "get_task_execution_summary", - ArgumentsJSON: `{"task_id":1,"execution_id":2}`, - }, aidomain.ToolCallContext{ - Principal: aidomain.AIToolPrincipal{UserID: 7}, - }) - if err != nil { - t.Fatalf("tool.Call() error = %v", err) - } - if taskSvc.taskDetailCalls != 1 { - t.Fatalf("taskDetailCalls = %d, want 1", taskSvc.taskDetailCalls) - } - if taskSvc.taskExecutionCalls != 1 { - t.Fatalf("taskExecutionCalls = %d, want 1", taskSvc.taskExecutionCalls) - } - if auth.authorizeCalls != 1 { - t.Fatalf("authorizeCalls = %d, want 1", auth.authorizeCalls) - } - - taskSvc.taskDetailResp = &resp.OJTaskDetailResp{ - TaskID: 1, - Orgs: []*resp.OJTaskOrgItemResp{ - {OrgID: 10, OrgName: "org-10"}, - }, - } - _, err = tool.Call(context.Background(), aidomain.ToolCall{ - ID: "call_2", - Name: "get_task_execution_summary", - ArgumentsJSON: `{"task_id":1,"execution_id":2}`, - }, aidomain.ToolCallContext{ - Principal: aidomain.AIToolPrincipal{UserID: 7}, - }) - if bizErr := bizerrors.FromError(err); bizErr == nil || bizErr.Code != bizerrors.CodePermissionDenied { - t.Fatalf("tool.Call() error = %v, want permission denied", err) - } - if taskSvc.taskExecutionCalls != 1 { - t.Fatalf("taskExecutionCalls after denied call = %d, want still 1", taskSvc.taskExecutionCalls) - } -} - -func TestAIToolSuperAdminExecutionDeniedWhenNotSuperAdmin(t *testing.T) { - auth := &fakeAIToolAuthorization{} - obsSvc := &fakeAIToolObservabilityService{} - tool := newAIToolRegistry(AIDeps{ - Authorization: auth, - Observability: obsSvc, - }).findTool("query_runtime_metrics") - if tool == nil { - t.Fatal("tool query_runtime_metrics not found") - } - - _, err := tool.Call(context.Background(), aidomain.ToolCall{ - ID: "call_3", - Name: "query_runtime_metrics", - ArgumentsJSON: `{"metric":"job_duration"}`, - }, aidomain.ToolCallContext{ - Principal: aidomain.AIToolPrincipal{UserID: 9}, - }) - if bizErr := bizerrors.FromError(err); bizErr == nil || bizErr.Code != bizerrors.CodePermissionDenied { - t.Fatalf("tool.Call() error = %v, want permission denied", err) - } - if obsSvc.runtimeCalls != 0 { - t.Fatalf("runtimeCalls = %d, want 0", obsSvc.runtimeCalls) - } -} - -func toolNames(tools []aidomain.Tool) []string { - names := make([]string, 0, len(tools)) - for _, tool := range tools { - if tool == nil { - continue - } - names = append(names, tool.Spec().Name) - } - return names -} - -func assertContainsTool(t *testing.T, names []string, target string) { - t.Helper() - for _, name := range names { - if name == target { - return - } - } - t.Fatalf("tool %s not found in %v", target, names) -} - -func assertNotContainsTool(t *testing.T, names []string, target string) { - t.Helper() - for _, name := range names { - if name == target { - t.Fatalf("tool %s unexpectedly found in %v", target, names) - } - } -} diff --git a/internal/service/system/aiselect/planner.go b/internal/service/system/aiselect/planner.go new file mode 100644 index 0000000..44ee2d3 --- /dev/null +++ b/internal/service/system/aiselect/planner.go @@ -0,0 +1,126 @@ +package aiselect + +import ( + "context" + "strings" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/service/system/aitool" +) + +// Selector 定义渐进式两阶段工具选择器能力。 +type Selector interface { + SelectGroup(ctx context.Context, input aidomain.ToolGroupSelectionInput) (aidomain.ToolGroupSelection, error) + SelectTools(ctx context.Context, input aidomain.ToolSelectionInput) (aidomain.ToolSelection, error) +} + +// ExecutionPlan 表示本轮最终要暴露给 runtime 的工具计划。 +type ExecutionPlan struct { + Tools []aidomain.Tool + DynamicSystemPrompt string +} + +// Planner 负责把可见工具、selector 和 prompt builder 组装成本轮执行计划。 +type Planner struct { + registry *aitool.Registry + selector Selector + promptBuilder PromptBuilder +} + +// NewPlanner 创建渐进式工具计划器。 +func NewPlanner(registry *aitool.Registry, selector Selector, promptBuilder PromptBuilder) *Planner { + return &Planner{ + registry: registry, + selector: selector, + promptBuilder: normalizePromptBuilder(promptBuilder), + } +} + +// BuildExecutionPlan 按渐进式选择逻辑生成本轮工具执行计划。 +func (p *Planner) BuildExecutionPlan( + ctx context.Context, + query string, + history []aidomain.Message, + visibleTools []aidomain.Tool, + principal aidomain.AIToolPrincipal, +) (ExecutionPlan, error) { + promptBuilder := normalizePromptBuilder(p.promptBuilder) + defaultPlan := ExecutionPlan{ + Tools: visibleTools, + DynamicSystemPrompt: promptBuilder.BuildDynamicPrompt(visibleTools, principal), + } + if len(visibleTools) == 0 || p == nil || p.selector == nil || p.registry == nil { + return defaultPlan, nil + } + + groupBriefs := p.registry.ListVisibleToolGroupBriefs(visibleTools) + if len(groupBriefs) == 0 { + return defaultPlan, nil + } + + groupSelection, err := p.selector.SelectGroup(ctx, aidomain.ToolGroupSelectionInput{ + Query: strings.TrimSpace(query), + History: history, + Groups: groupBriefs, + }) + if err != nil { + return defaultPlan, nil + } + + switch groupSelection.Decision { + case aidomain.ToolSelectionDecisionDirectAnswer, aidomain.ToolSelectionDecisionAskUser: + return ExecutionPlan{ + Tools: nil, + DynamicSystemPrompt: promptBuilder.BuildDecisionPrompt( + groupSelection.Decision, + groupSelection.Reason, + groupSelection.MissingSlots, + ), + }, nil + case aidomain.ToolSelectionDecisionSelectGroup: + // continue + default: + return defaultPlan, nil + } + + groupBrief, ok := findToolGroupBrief(groupBriefs, groupSelection.GroupID) + if !ok { + return defaultPlan, nil + } + toolBriefs := p.registry.ListVisibleToolBriefsByGroup(visibleTools, groupSelection.GroupID) + if len(toolBriefs) == 0 { + return defaultPlan, nil + } + + toolSelection, err := p.selector.SelectTools(ctx, aidomain.ToolSelectionInput{ + Query: strings.TrimSpace(query), + History: history, + Group: groupBrief, + Tools: toolBriefs, + }) + if err != nil { + return defaultPlan, nil + } + + selectedTools := p.registry.ExpandVisibleToolsByNames(visibleTools, toolSelection.SelectedToolNames) + if toolSelection.Confidence == aidomain.ToolSelectionConfidenceLow || len(selectedTools) == 0 { + selectedTools = p.registry.ExpandVisibleToolsByGroup(visibleTools, groupSelection.GroupID) + } + if len(selectedTools) == 0 { + return defaultPlan, nil + } + + return ExecutionPlan{ + Tools: selectedTools, + DynamicSystemPrompt: promptBuilder.BuildDynamicPrompt(selectedTools, principal), + }, nil +} + +func findToolGroupBrief(items []aidomain.ToolGroupBrief, groupID aidomain.ToolGroupID) (aidomain.ToolGroupBrief, bool) { + for _, item := range items { + if item.ID == groupID { + return item, true + } + } + return aidomain.ToolGroupBrief{}, false +} diff --git a/internal/service/system/aiselect/planner_test.go b/internal/service/system/aiselect/planner_test.go new file mode 100644 index 0000000..f1d0205 --- /dev/null +++ b/internal/service/system/aiselect/planner_test.go @@ -0,0 +1,228 @@ +package aiselect + +import ( + "context" + "testing" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/dto/request" + resp "personal_assistant/internal/model/dto/response" + "personal_assistant/internal/service/system/aitool" +) + +type fakeProgressiveToolSelector struct { + groupResult aidomain.ToolGroupSelection + groupErr error + toolResult aidomain.ToolSelection + toolErr error + groupCalls int + toolCalls int +} + +func (f *fakeProgressiveToolSelector) SelectGroup( + context.Context, + aidomain.ToolGroupSelectionInput, +) (aidomain.ToolGroupSelection, error) { + f.groupCalls++ + return f.groupResult, f.groupErr +} + +func (f *fakeProgressiveToolSelector) SelectTools( + context.Context, + aidomain.ToolSelectionInput, +) (aidomain.ToolSelection, error) { + f.toolCalls++ + return f.toolResult, f.toolErr +} + +type fakePromptBuilder struct { + output string + calls int +} + +func (f *fakePromptBuilder) BuildDynamicPrompt([]aidomain.Tool, aidomain.AIToolPrincipal) string { + f.calls++ + return f.output +} + +func (f *fakePromptBuilder) BuildDecisionPrompt( + aidomain.ToolSelectionDecision, + string, + []string, +) string { + f.calls++ + return f.output +} + +type fakeAuthorization struct{} + +func (f *fakeAuthorization) IsSuperAdmin(context.Context, uint) (bool, error) { + return false, nil +} + +func (f *fakeAuthorization) CheckUserCapabilityInOrg(context.Context, uint, uint, string) (bool, error) { + return false, nil +} + +func (f *fakeAuthorization) AuthorizeOrgCapability(context.Context, uint, uint, string) error { + return nil +} + +type fakeOJService struct{} + +func (f *fakeOJService) GetRankingList(context.Context, uint, *request.OJRankingListReq) (*resp.OJRankingListResp, error) { + return &resp.OJRankingListResp{}, nil +} + +func (f *fakeOJService) GetUserStats(context.Context, uint, *request.OJStatsReq) (*resp.OJStatsResp, error) { + return &resp.OJStatsResp{}, nil +} + +func (f *fakeOJService) GetCurve(context.Context, uint, *request.OJCurveReq) (*resp.OJCurveResp, error) { + return &resp.OJCurveResp{}, nil +} + +func TestBuildAIToolExecutionPlanUsesDirectAnswerWithoutTools(t *testing.T) { + planner, visibleTools, promptBuilder := newProgressivePlanTestPlanner(t) + selector := &fakeProgressiveToolSelector{ + groupResult: aidomain.ToolGroupSelection{ + Decision: aidomain.ToolSelectionDecisionDirectAnswer, + Reason: "问题不需要工具", + }, + } + planner.selector = selector + planner.promptBuilder = promptBuilder + promptBuilder.output = "decision prompt" + + plan, err := planner.BuildExecutionPlan( + context.Background(), + "你好", + nil, + visibleTools, + aidomain.AIToolPrincipal{UserID: 7}, + ) + if err != nil { + t.Fatalf("BuildExecutionPlan() error = %v", err) + } + if len(plan.Tools) != 0 { + t.Fatalf("plan.Tools len = %d, want 0", len(plan.Tools)) + } + if plan.DynamicSystemPrompt != "decision prompt" { + t.Fatalf("plan.DynamicSystemPrompt = %q", plan.DynamicSystemPrompt) + } + if selector.groupCalls != 1 || selector.toolCalls != 0 { + t.Fatalf("selector calls = (%d,%d), want (1,0)", selector.groupCalls, selector.toolCalls) + } +} + +func TestBuildAIToolExecutionPlanSelectsSubsetOfTools(t *testing.T) { + planner, visibleTools, promptBuilder := newProgressivePlanTestPlanner(t) + selector := &fakeProgressiveToolSelector{ + groupResult: aidomain.ToolGroupSelection{ + Decision: aidomain.ToolSelectionDecisionSelectGroup, + GroupID: aidomain.ToolGroupOJPersonal, + }, + toolResult: aidomain.ToolSelection{ + SelectedToolNames: []string{"get_my_ranking"}, + Confidence: aidomain.ToolSelectionConfidenceHigh, + }, + } + planner.selector = selector + planner.promptBuilder = promptBuilder + promptBuilder.output = "react prompt" + + plan, err := planner.BuildExecutionPlan( + context.Background(), + "帮我查我的排名", + nil, + visibleTools, + aidomain.AIToolPrincipal{UserID: 7}, + ) + if err != nil { + t.Fatalf("BuildExecutionPlan() error = %v", err) + } + if len(plan.Tools) != 1 { + t.Fatalf("plan.Tools len = %d, want 1", len(plan.Tools)) + } + if plan.Tools[0].Spec().Name != "get_my_ranking" { + t.Fatalf("selected tool = %q", plan.Tools[0].Spec().Name) + } + if selector.groupCalls != 1 || selector.toolCalls != 1 { + t.Fatalf("selector calls = (%d,%d), want (1,1)", selector.groupCalls, selector.toolCalls) + } +} + +func TestBuildAIToolExecutionPlanExpandsWholeGroupWhenLowConfidence(t *testing.T) { + planner, visibleTools, promptBuilder := newProgressivePlanTestPlanner(t) + selector := &fakeProgressiveToolSelector{ + groupResult: aidomain.ToolGroupSelection{ + Decision: aidomain.ToolSelectionDecisionSelectGroup, + GroupID: aidomain.ToolGroupOJPersonal, + }, + toolResult: aidomain.ToolSelection{ + SelectedToolNames: []string{"get_my_ranking"}, + Confidence: aidomain.ToolSelectionConfidenceLow, + }, + } + planner.selector = selector + planner.promptBuilder = promptBuilder + promptBuilder.output = "react prompt" + + plan, err := planner.BuildExecutionPlan( + context.Background(), + "帮我看个人 OJ 表现", + nil, + visibleTools, + aidomain.AIToolPrincipal{UserID: 7}, + ) + if err != nil { + t.Fatalf("BuildExecutionPlan() error = %v", err) + } + if len(plan.Tools) != 3 { + t.Fatalf("plan.Tools len = %d, want 3", len(plan.Tools)) + } +} + +func TestBuildAIToolExecutionPlanFallsBackWhenSelectorFails(t *testing.T) { + planner, visibleTools, promptBuilder := newProgressivePlanTestPlanner(t) + selector := &fakeProgressiveToolSelector{groupErr: context.DeadlineExceeded} + planner.selector = selector + planner.promptBuilder = promptBuilder + promptBuilder.output = "fallback prompt" + + plan, err := planner.BuildExecutionPlan( + context.Background(), + "帮我查排名", + nil, + visibleTools, + aidomain.AIToolPrincipal{UserID: 7}, + ) + if err != nil { + t.Fatalf("BuildExecutionPlan() error = %v", err) + } + if len(plan.Tools) != len(visibleTools) { + t.Fatalf("plan.Tools len = %d, want %d", len(plan.Tools), len(visibleTools)) + } + if plan.DynamicSystemPrompt != "fallback prompt" { + t.Fatalf("plan.DynamicSystemPrompt = %q", plan.DynamicSystemPrompt) + } +} + +func newProgressivePlanTestPlanner(t *testing.T) (*Planner, []aidomain.Tool, *fakePromptBuilder) { + t.Helper() + registry := aitool.NewRegistry(aitool.Deps{ + Authorization: &fakeAuthorization{}, + OJ: &fakeOJService{}, + }) + visibleTools, err := registry.FilterVisibleTools(context.Background(), aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: 7}, + }) + if err != nil { + t.Fatalf("FilterVisibleTools() error = %v", err) + } + if len(visibleTools) != 3 { + t.Fatalf("visibleTools len = %d, want 3", len(visibleTools)) + } + builder := &fakePromptBuilder{output: "prompt"} + return NewPlanner(registry, nil, builder), visibleTools, builder +} diff --git a/internal/service/system/aiselect/prompt.go b/internal/service/system/aiselect/prompt.go new file mode 100644 index 0000000..061801c --- /dev/null +++ b/internal/service/system/aiselect/prompt.go @@ -0,0 +1,96 @@ +package aiselect + +import ( + "fmt" + "strings" + + aidomain "personal_assistant/internal/domain/ai" +) + +// PromptBuilder 负责生成本轮 runtime 所需的动态 system prompt。 +type PromptBuilder interface { + BuildDynamicPrompt(tools []aidomain.Tool, principal aidomain.AIToolPrincipal) string + BuildDecisionPrompt( + decision aidomain.ToolSelectionDecision, + reason string, + missingSlots []string, + ) string +} + +type defaultPromptBuilder struct{} + +func normalizePromptBuilder(builder PromptBuilder) PromptBuilder { + if builder != nil { + return builder + } + return defaultPromptBuilder{} +} + +func (defaultPromptBuilder) BuildDynamicPrompt( + tools []aidomain.Tool, + principal aidomain.AIToolPrincipal, +) string { + return buildToolDynamicPrompt(tools, principal) +} + +func (defaultPromptBuilder) BuildDecisionPrompt( + decision aidomain.ToolSelectionDecision, + reason string, + missingSlots []string, +) string { + return buildDecisionPrompt(decision, reason, missingSlots) +} + +func buildToolDynamicPrompt(tools []aidomain.Tool, principal aidomain.AIToolPrincipal) string { + var builder strings.Builder + builder.WriteString("你是 personal_assistant 的 AI 助手。\n") + builder.WriteString("本轮只能使用当前注入的工具;不要假设还有其他工具。\n") + builder.WriteString("如果用户请求需要 org_id、task_id、execution_id、request_id 等精确标识,而上下文里没有,不要猜测,直接向用户索取。\n") + builder.WriteString("工具可见性已经按当前授权事实过滤,但真正执行时仍会再次鉴权;如果工具报权限错误,直接向用户说明。\n") + builder.WriteString("如果工具 observation 的 classification=missing_user_input,不要继续调用工具,直接向用户追问缺失信息。\n") + builder.WriteString("如果工具 observation 的 classification=repairable_invalid_param,先修正同一工具参数再重试,不要重复提交完全相同的参数。\n") + builder.WriteString("时间字段必须使用 RFC3339,例如 2026-04-24T09:20:00Z。\n") + if principal.CurrentOrgID != nil && *principal.CurrentOrgID > 0 { + builder.WriteString(fmt.Sprintf("当前组织上下文 org_id=%d。\n", *principal.CurrentOrgID)) + } + if len(tools) == 0 { + builder.WriteString("本轮没有可用工具,请直接基于已有上下文回答,无法确认的数据不要编造。") + return builder.String() + } + + builder.WriteString("本轮可用工具:\n") + for idx, tool := range tools { + spec := tool.Spec() + builder.WriteString(fmt.Sprintf("%d. %s: %s\n", idx+1, spec.Name, spec.Description)) + } + return strings.TrimSpace(builder.String()) +} + +func buildDecisionPrompt( + decision aidomain.ToolSelectionDecision, + reason string, + missingSlots []string, +) string { + var builder strings.Builder + builder.WriteString("你是 personal_assistant 的 AI 助手。\n") + builder.WriteString("不要提及任何内部工具选择或路由过程。\n") + switch decision { + case aidomain.ToolSelectionDecisionAskUser: + builder.WriteString("当前缺少继续处理所需的关键信息。请只向用户提出一个简洁、具体、自然的问题,不要直接回答未确认的信息。\n") + if len(missingSlots) > 0 { + builder.WriteString("缺少字段:") + builder.WriteString(strings.Join(missingSlots, ", ")) + builder.WriteString("\n") + } + case aidomain.ToolSelectionDecisionDirectAnswer: + builder.WriteString("当前无需调用工具。请直接基于已有上下文回答用户,不要编造未确认的数据。\n") + default: + builder.WriteString("请直接基于已有上下文回答用户。\n") + } + if strings.TrimSpace(reason) != "" { + builder.WriteString("内部判定依据:") + builder.WriteString(strings.TrimSpace(reason)) + builder.WriteString("\n") + } + return strings.TrimSpace(builder.String()) +} diff --git a/internal/service/system/aitool/deps.go b/internal/service/system/aitool/deps.go new file mode 100644 index 0000000..6f540a8 --- /dev/null +++ b/internal/service/system/aitool/deps.go @@ -0,0 +1,65 @@ +package aitool + +import ( + "context" + + "personal_assistant/internal/model/dto/request" + resp "personal_assistant/internal/model/dto/response" +) + +// AuthorizationService 表示 AI tool 侧依赖的最小授权能力集合。 +type AuthorizationService interface { + IsSuperAdmin(ctx context.Context, userID uint) (bool, error) + CheckUserCapabilityInOrg(ctx context.Context, userID, orgID uint, capabilityCode string) (bool, error) + AuthorizeOrgCapability(ctx context.Context, operatorID, orgID uint, capabilityCode string) error +} + +// OJService 表示 AI tool 查询个人或组织 OJ 统计时依赖的最小 OJ 能力。 +type OJService interface { + GetRankingList(ctx context.Context, userID uint, req *request.OJRankingListReq) (*resp.OJRankingListResp, error) + GetUserStats(ctx context.Context, userID uint, req *request.OJStatsReq) (*resp.OJStatsResp, error) + GetCurve(ctx context.Context, userID uint, req *request.OJCurveReq) (*resp.OJCurveResp, error) +} + +// OJTaskService 表示 AI tool 查询 OJ 任务、执行和分析结果时依赖的最小任务能力。 +type OJTaskService interface { + AnalyzeTaskTitles(ctx context.Context, req *request.AnalyzeOJTaskTitlesReq) (*resp.OJTaskAnalyzeResp, error) + GetTaskDetail(ctx context.Context, userID, taskID uint) (*resp.OJTaskDetailResp, error) + GetTaskExecutionDetail(ctx context.Context, userID, taskID, executionID uint) (*resp.OJTaskExecutionResp, error) + GetTaskExecutionUsers( + ctx context.Context, + userID, taskID, executionID uint, + req *request.OJTaskExecutionUserListReq, + ) (*resp.OJTaskExecutionUserListResp, error) + GetTaskExecutionUserDetail( + ctx context.Context, + userID, taskID, executionID, targetUserID uint, + ) (*resp.OJTaskExecutionUserDetailResp, error) +} + +// ObservabilityService 表示 AI tool 查询 trace 和指标时依赖的最小观测能力。 +type ObservabilityService interface { + QueryMetrics(ctx context.Context, req *request.ObservabilityMetricsQueryReq) (*resp.ObservabilityMetricsQueryResp, error) + QueryRuntimeMetrics( + ctx context.Context, + req *request.ObservabilityRuntimeMetricQueryReq, + ) (*resp.ObservabilityRuntimeMetricQueryResp, error) + QueryTraceDetail( + ctx context.Context, + id string, + idType string, + limit int, + offset int, + includePayload bool, + includeErrorDetail bool, + ) (*resp.ObservabilityTraceQueryResp, error) + QueryTrace(ctx context.Context, req *request.ObservabilityTraceQueryReq) (*resp.ObservabilityTraceSummaryQueryResp, error) +} + +// Deps 表示 AI tool registry 构建时需要的最小业务依赖。 +type Deps struct { + Authorization AuthorizationService + OJ OJService + OJTask OJTaskService + Observability ObservabilityService +} diff --git a/internal/service/system/aitool/metadata.go b/internal/service/system/aitool/metadata.go new file mode 100644 index 0000000..1440a91 --- /dev/null +++ b/internal/service/system/aitool/metadata.go @@ -0,0 +1,109 @@ +package aitool + +import ( + "strings" + + aidomain "personal_assistant/internal/domain/ai" +) + +var toolGroupProfiles = map[aidomain.ToolGroupID]aidomain.ToolGroupProfile{ + aidomain.ToolGroupOJPersonal: { + Summary: "查询当前用户在 OJ 平台上的个人排名、统计和曲线。", + WhenToUse: "用户提问自己的刷题表现、排名、统计变化时使用。", + DomainTags: []string{"oj", "personal"}, + }, + aidomain.ToolGroupOJOrg: { + Summary: "查询组织范围内的 OJ 排名汇总。", + WhenToUse: "用户提问当前组织或指定组织的排名汇总时使用。", + DomainTags: []string{"oj", "org"}, + }, + aidomain.ToolGroupOJTask: { + Summary: "查询 OJ 任务、执行明细、用户执行明细和题目分析。", + WhenToUse: "用户提问 OJ 任务、作业执行、名单或题目分析时使用。", + DomainTags: []string{"oj", "task"}, + }, + aidomain.ToolGroupObservabilityTrace: { + Summary: "查询链路追踪详情与追踪摘要。", + WhenToUse: "用户提问 trace、request 链路、失败调用排查时使用。", + DomainTags: []string{"observability", "trace"}, + }, + aidomain.ToolGroupObservabilityMetrics: { + Summary: "查询运行时指标与 HTTP 观测指标。", + WhenToUse: "用户提问运行时指标、HTTP 指标趋势、状态码分布时使用。", + DomainTags: []string{"observability", "metrics"}, + }, +} + +func newAIToolDescriptor( + spec aidomain.ToolSpec, + groupID aidomain.ToolGroupID, + summary string, + whenToUse string, + tags ...string, +) aidomain.ToolDescriptor { + return aidomain.ToolDescriptor{ + Spec: spec, + GroupID: groupID, + Brief: aidomain.ToolBrief{ + Name: spec.Name, + Summary: strings.TrimSpace(summary), + WhenToUse: strings.TrimSpace(whenToUse), + RequiredSlots: requiredToolSlots(spec), + DomainTags: append([]string(nil), tags...), + }, + } +} + +func buildToolGroupBrief(groupID aidomain.ToolGroupID, visibleTools []aidomain.Tool) aidomain.ToolGroupBrief { + profile, ok := toolGroupProfiles[groupID] + if !ok { + return aidomain.ToolGroupBrief{ + ID: groupID, + ToolNames: collectToolNames(visibleTools), + } + } + return aidomain.ToolGroupBrief{ + ID: groupID, + Summary: profile.Summary, + WhenToUse: profile.WhenToUse, + ToolNames: collectToolNames(visibleTools), + DomainTags: append([]string(nil), profile.DomainTags...), + } +} + +func collectToolNames(tools []aidomain.Tool) []string { + items := make([]string, 0, len(tools)) + for _, tool := range tools { + if tool == nil { + continue + } + name := strings.TrimSpace(tool.Spec().Name) + if name == "" { + continue + } + items = append(items, name) + } + return items +} + +func requiredToolSlots(spec aidomain.ToolSpec) []string { + items := make([]string, 0, len(spec.Parameters)) + for _, param := range spec.Parameters { + if !param.Required { + continue + } + items = append(items, param.Name) + } + return items +} + +func truncateToolSummary(input string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(input)) + if len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) +} diff --git a/internal/service/system/aitool/registry.go b/internal/service/system/aitool/registry.go new file mode 100644 index 0000000..f6cffd8 --- /dev/null +++ b/internal/service/system/aitool/registry.go @@ -0,0 +1,6 @@ +package aitool + +// NewRegistry 创建 AI tool 注册表。 +func NewRegistry(deps Deps) *Registry { + return newAIToolRegistry(deps) +} diff --git a/internal/service/system/aitool/registry_test.go b/internal/service/system/aitool/registry_test.go new file mode 100644 index 0000000..9129e17 --- /dev/null +++ b/internal/service/system/aitool/registry_test.go @@ -0,0 +1,691 @@ +package aitool + +import ( + "context" + "testing" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/consts" + "personal_assistant/internal/model/dto/request" + resp "personal_assistant/internal/model/dto/response" + bizerrors "personal_assistant/pkg/errors" +) + +type fakeAIToolAuthorization struct { + superAdmin bool + capabilities map[uint]map[string]bool + checkCalls int + authorizeCalls int +} + +func (f *fakeAIToolAuthorization) IsSuperAdmin(context.Context, uint) (bool, error) { + return f.superAdmin, nil +} + +func (f *fakeAIToolAuthorization) CheckUserCapabilityInOrg( + _ context.Context, + _ uint, + orgID uint, + capabilityCode string, +) (bool, error) { + f.checkCalls++ + return f.hasCapability(orgID, capabilityCode), nil +} + +func (f *fakeAIToolAuthorization) AuthorizeOrgCapability( + _ context.Context, + _ uint, + orgID uint, + capabilityCode string, +) error { + f.authorizeCalls++ + if f.superAdmin || f.hasCapability(orgID, capabilityCode) { + return nil + } + return bizerrors.New(bizerrors.CodePermissionDenied) +} + +func (f *fakeAIToolAuthorization) hasCapability(orgID uint, capabilityCode string) bool { + if f == nil { + return false + } + if caps, ok := f.capabilities[orgID]; ok { + return caps[capabilityCode] + } + return false +} + +type fakeAIToolOJService struct{} + +func (f *fakeAIToolOJService) GetRankingList( + context.Context, + uint, + *request.OJRankingListReq, +) (*resp.OJRankingListResp, error) { + return &resp.OJRankingListResp{}, nil +} + +func (f *fakeAIToolOJService) GetUserStats( + context.Context, + uint, + *request.OJStatsReq, +) (*resp.OJStatsResp, error) { + return &resp.OJStatsResp{}, nil +} + +func (f *fakeAIToolOJService) GetCurve( + context.Context, + uint, + *request.OJCurveReq, +) (*resp.OJCurveResp, error) { + return &resp.OJCurveResp{}, nil +} + +type fakeAIToolOJTaskService struct { + taskDetailResp *resp.OJTaskDetailResp + taskExecutionResp *resp.OJTaskExecutionResp + taskExecutionUsersResp *resp.OJTaskExecutionUserListResp + taskExecutionUserResp *resp.OJTaskExecutionUserDetailResp + analyzeResp *resp.OJTaskAnalyzeResp + taskDetailCalls int + taskExecutionCalls int + taskExecutionUsersCalls int + taskExecutionUserCalls int + analyzeCalls int +} + +func (f *fakeAIToolOJTaskService) AnalyzeTaskTitles( + context.Context, + *request.AnalyzeOJTaskTitlesReq, +) (*resp.OJTaskAnalyzeResp, error) { + f.analyzeCalls++ + if f.analyzeResp == nil { + return &resp.OJTaskAnalyzeResp{}, nil + } + return f.analyzeResp, nil +} + +func (f *fakeAIToolOJTaskService) GetTaskDetail( + context.Context, + uint, + uint, +) (*resp.OJTaskDetailResp, error) { + f.taskDetailCalls++ + if f.taskDetailResp == nil { + return &resp.OJTaskDetailResp{}, nil + } + return f.taskDetailResp, nil +} + +func (f *fakeAIToolOJTaskService) GetTaskExecutionDetail( + context.Context, + uint, + uint, + uint, +) (*resp.OJTaskExecutionResp, error) { + f.taskExecutionCalls++ + if f.taskExecutionResp == nil { + return &resp.OJTaskExecutionResp{}, nil + } + return f.taskExecutionResp, nil +} + +func (f *fakeAIToolOJTaskService) GetTaskExecutionUsers( + context.Context, + uint, + uint, + uint, + *request.OJTaskExecutionUserListReq, +) (*resp.OJTaskExecutionUserListResp, error) { + f.taskExecutionUsersCalls++ + if f.taskExecutionUsersResp == nil { + return &resp.OJTaskExecutionUserListResp{}, nil + } + return f.taskExecutionUsersResp, nil +} + +func (f *fakeAIToolOJTaskService) GetTaskExecutionUserDetail( + context.Context, + uint, + uint, + uint, + uint, +) (*resp.OJTaskExecutionUserDetailResp, error) { + f.taskExecutionUserCalls++ + if f.taskExecutionUserResp == nil { + return &resp.OJTaskExecutionUserDetailResp{}, nil + } + return f.taskExecutionUserResp, nil +} + +type fakeAIToolObservabilityService struct { + runtimeMetricsResp *resp.ObservabilityRuntimeMetricQueryResp + runtimeCalls int +} + +func (f *fakeAIToolObservabilityService) QueryMetrics( + context.Context, + *request.ObservabilityMetricsQueryReq, +) (*resp.ObservabilityMetricsQueryResp, error) { + return &resp.ObservabilityMetricsQueryResp{}, nil +} + +func (f *fakeAIToolObservabilityService) QueryRuntimeMetrics( + context.Context, + *request.ObservabilityRuntimeMetricQueryReq, +) (*resp.ObservabilityRuntimeMetricQueryResp, error) { + f.runtimeCalls++ + if f.runtimeMetricsResp == nil { + return &resp.ObservabilityRuntimeMetricQueryResp{}, nil + } + return f.runtimeMetricsResp, nil +} + +func (f *fakeAIToolObservabilityService) QueryTraceDetail( + context.Context, + string, + string, + int, + int, + bool, + bool, +) (*resp.ObservabilityTraceQueryResp, error) { + return &resp.ObservabilityTraceQueryResp{}, nil +} + +func (f *fakeAIToolObservabilityService) QueryTrace( + context.Context, + *request.ObservabilityTraceQueryReq, +) (*resp.ObservabilityTraceSummaryQueryResp, error) { + return &resp.ObservabilityTraceSummaryQueryResp{}, nil +} + +func TestAIToolRegistryFilterVisibleToolsByPolicy(t *testing.T) { + orgID := uint(10) + auth := &fakeAIToolAuthorization{ + capabilities: map[uint]map[string]bool{ + orgID: {consts.CapabilityCodeOJTaskManage: true}, + }, + } + registry := newAIToolRegistry(Deps{ + Authorization: auth, + OJ: &fakeAIToolOJService{}, + OJTask: &fakeAIToolOJTaskService{}, + Observability: &fakeAIToolObservabilityService{}, + }) + + tools, err := registry.FilterVisibleTools(context.Background(), aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{ + UserID: 1, + CurrentOrgID: &orgID, + }, + }) + if err != nil { + t.Fatalf("FilterVisibleTools() error = %v", err) + } + + names := toolNames(tools) + assertContainsTool(t, names, "get_my_oj_stats") + assertContainsTool(t, names, "get_org_ranking_summary") + assertNotContainsTool(t, names, "query_runtime_metrics") + + auth.superAdmin = true + tools, err = registry.FilterVisibleTools(context.Background(), aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{ + UserID: 1, + CurrentOrgID: &orgID, + IsSuperAdmin: true, + }, + }) + if err != nil { + t.Fatalf("FilterVisibleTools() error = %v", err) + } + + names = toolNames(tools) + assertContainsTool(t, names, "query_runtime_metrics") +} + +func TestAIToolExecutionReauthorizesTaskOrgCapability(t *testing.T) { + taskSvc := &fakeAIToolOJTaskService{ + taskDetailResp: &resp.OJTaskDetailResp{ + TaskID: 1, + Orgs: []*resp.OJTaskOrgItemResp{ + {OrgID: 9, OrgName: "org-9"}, + }, + }, + taskExecutionResp: &resp.OJTaskExecutionResp{ + TaskID: 1, + ExecutionID: 2, + Status: "succeeded", + }, + } + auth := &fakeAIToolAuthorization{ + capabilities: map[uint]map[string]bool{ + 9: {consts.CapabilityCodeOJTaskManage: true}, + }, + } + tool := newAIToolRegistry(Deps{ + Authorization: auth, + OJTask: taskSvc, + }).findTool("get_task_execution_summary") + if tool == nil { + t.Fatal("tool get_task_execution_summary not found") + } + + _, err := tool.Call(context.Background(), aidomain.ToolCall{ + ID: "call_1", + Name: "get_task_execution_summary", + ArgumentsJSON: `{"task_id":1,"execution_id":2}`, + }, aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: 7}, + }) + if err != nil { + t.Fatalf("tool.Call() error = %v", err) + } + if taskSvc.taskDetailCalls != 1 { + t.Fatalf("taskDetailCalls = %d, want 1", taskSvc.taskDetailCalls) + } + if taskSvc.taskExecutionCalls != 1 { + t.Fatalf("taskExecutionCalls = %d, want 1", taskSvc.taskExecutionCalls) + } + if auth.authorizeCalls != 1 { + t.Fatalf("authorizeCalls = %d, want 1", auth.authorizeCalls) + } + + taskSvc.taskDetailResp = &resp.OJTaskDetailResp{ + TaskID: 1, + Orgs: []*resp.OJTaskOrgItemResp{ + {OrgID: 10, OrgName: "org-10"}, + }, + } + _, err = tool.Call(context.Background(), aidomain.ToolCall{ + ID: "call_2", + Name: "get_task_execution_summary", + ArgumentsJSON: `{"task_id":1,"execution_id":2}`, + }, aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: 7}, + }) + if bizErr := bizerrors.FromError(err); bizErr == nil || bizErr.Code != bizerrors.CodePermissionDenied { + t.Fatalf("tool.Call() error = %v, want permission denied", err) + } + if taskSvc.taskExecutionCalls != 1 { + t.Fatalf("taskExecutionCalls after denied call = %d, want still 1", taskSvc.taskExecutionCalls) + } +} + +func TestAIToolSuperAdminExecutionDeniedWhenNotSuperAdmin(t *testing.T) { + auth := &fakeAIToolAuthorization{} + obsSvc := &fakeAIToolObservabilityService{} + tool := newAIToolRegistry(Deps{ + Authorization: auth, + Observability: obsSvc, + }).findTool("query_runtime_metrics") + if tool == nil { + t.Fatal("tool query_runtime_metrics not found") + } + + _, err := tool.Call(context.Background(), aidomain.ToolCall{ + ID: "call_3", + Name: "query_runtime_metrics", + ArgumentsJSON: `{"metric":"outbox_events_total","status":"pending","granularity":"5m"}`, + }, aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: 9}, + }) + if bizErr := bizerrors.FromError(err); bizErr == nil || bizErr.Code != bizerrors.CodePermissionDenied { + t.Fatalf("tool.Call() error = %v, want permission denied", err) + } + if obsSvc.runtimeCalls != 0 { + t.Fatalf("runtimeCalls = %d, want 0", obsSvc.runtimeCalls) + } +} + +func toolNames(tools []aidomain.Tool) []string { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + if tool == nil { + continue + } + names = append(names, tool.Spec().Name) + } + return names +} + +func assertContainsTool(t *testing.T, names []string, target string) { + t.Helper() + for _, name := range names { + if name == target { + return + } + } + t.Fatalf("tool %s not found in %v", target, names) +} + +func assertNotContainsTool(t *testing.T, names []string, target string) { + t.Helper() + for _, name := range names { + if name == target { + t.Fatalf("tool %s unexpectedly found in %v", target, names) + } + } +} + +func TestAIToolRegistrySchemasExposeDetailedConstraints(t *testing.T) { + registry := newAIToolRegistry(Deps{ + Authorization: &fakeAIToolAuthorization{superAdmin: true}, + OJ: &fakeAIToolOJService{}, + OJTask: &fakeAIToolOJTaskService{}, + Observability: &fakeAIToolObservabilityService{}, + }) + + checks := []struct { + toolName string + param string + assert func(t *testing.T, param aidomain.ToolParameter) + }{ + { + toolName: "get_my_ranking", + param: "platform", + assert: func(t *testing.T, param aidomain.ToolParameter) { + assertEnum(t, param, []string{"leetcode", "luogu", "lanqiao"}) + assertExamplesPresent(t, param) + }, + }, + { + toolName: "get_my_ranking", + param: "scope", + assert: func(t *testing.T, param aidomain.ToolParameter) { + assertEnum(t, param, []string{"current_org", "all_members"}) + if param.DefaultValue != "current_org" { + t.Fatalf("scope default = %q, want current_org", param.DefaultValue) + } + }, + }, + { + toolName: "get_org_ranking_summary", + param: "page_size", + assert: func(t *testing.T, param aidomain.ToolParameter) { + assertMinMax(t, param, 1, 100) + if param.DefaultValue != "20" { + t.Fatalf("page_size default = %q, want 20", param.DefaultValue) + } + }, + }, + { + toolName: "list_task_execution_users", + param: "username", + assert: func(t *testing.T, param aidomain.ToolParameter) { + if param.MaxLength == nil || *param.MaxLength != 50 { + t.Fatalf("username max_length = %v, want 50", param.MaxLength) + } + }, + }, + { + toolName: "query_trace_detail_by_request_id", + param: "request_id", + assert: func(t *testing.T, param aidomain.ToolParameter) { + if param.MinLength == nil || *param.MinLength != 1 { + t.Fatalf("request_id min_length = %v, want 1", param.MinLength) + } + assertExamplesPresent(t, param) + }, + }, + { + toolName: "query_trace_summary", + param: "root_stage", + assert: func(t *testing.T, param aidomain.ToolParameter) { + assertEnum(t, param, []string{"http.request", "task", "all"}) + if param.DefaultValue != "http.request" { + t.Fatalf("root_stage default = %q, want http.request", param.DefaultValue) + } + }, + }, + { + toolName: "query_trace_summary", + param: "start_at", + assert: func(t *testing.T, param aidomain.ToolParameter) { + if param.Format != aidomain.ToolParameterFormatRFC3339 { + t.Fatalf("start_at format = %q, want RFC3339", param.Format) + } + }, + }, + { + toolName: "query_runtime_metrics", + param: "granularity", + assert: func(t *testing.T, param aidomain.ToolParameter) { + assertEnum(t, param, []string{"1m", "5m", "1h", "1d"}) + if param.DefaultValue != "5m" { + t.Fatalf("granularity default = %q, want 5m", param.DefaultValue) + } + }, + }, + { + toolName: "query_runtime_metrics", + param: "limit", + assert: func(t *testing.T, param aidomain.ToolParameter) { + assertMinMax(t, param, 1, 2000) + }, + }, + { + toolName: "query_observability_metrics", + param: "granularity", + assert: func(t *testing.T, param aidomain.ToolParameter) { + assertEnum(t, param, []string{"1m", "5m", "1d", "1w"}) + }, + }, + { + toolName: "query_observability_metrics", + param: "start_at", + assert: func(t *testing.T, param aidomain.ToolParameter) { + if param.Format != aidomain.ToolParameterFormatRFC3339 { + t.Fatalf("start_at format = %q, want RFC3339", param.Format) + } + }, + }, + { + toolName: "query_observability_metrics", + param: "limit", + assert: func(t *testing.T, param aidomain.ToolParameter) { + assertMinMax(t, param, 1, 50000) + if param.DefaultValue != "5000" { + t.Fatalf("limit default = %q, want 5000", param.DefaultValue) + } + }, + }, + } + + for _, tc := range checks { + t.Run(tc.toolName+"_"+tc.param, func(t *testing.T) { + tool := registry.findTool(tc.toolName) + if tool == nil { + t.Fatalf("tool %s not found", tc.toolName) + } + param := mustFindParam(t, tool.Spec().Parameters, tc.param) + tc.assert(t, param) + }) + } + + analyzeTool := registry.findTool("analyze_task_titles") + if analyzeTool == nil { + t.Fatal("tool analyze_task_titles not found") + } + itemsParam := mustFindParam(t, analyzeTool.Spec().Parameters, "items") + if itemsParam.MinItems == nil || *itemsParam.MinItems != 1 { + t.Fatalf("items min_items = %v, want 1", itemsParam.MinItems) + } + if itemsParam.Items == nil { + t.Fatal("items param missing item schema") + } + platformParam := mustFindParam(t, itemsParam.Items.Properties, "platform") + assertEnum(t, platformParam, []string{"leetcode", "luogu", "lanqiao"}) + titleParam := mustFindParam(t, itemsParam.Items.Properties, "title") + if titleParam.MaxLength == nil || *titleParam.MaxLength != 255 { + t.Fatalf("title max_length = %v, want 255", titleParam.MaxLength) + } +} + +func TestAIToolRegistryProgressiveMetadataAndExpansion(t *testing.T) { + registry := newAIToolRegistry(Deps{ + Authorization: &fakeAIToolAuthorization{}, + OJ: &fakeAIToolOJService{}, + OJTask: &fakeAIToolOJTaskService{}, + Observability: &fakeAIToolObservabilityService{}, + }) + visibleTools, err := registry.FilterVisibleTools(context.Background(), aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{ + UserID: 1, + IsSuperAdmin: true, + }, + }) + if err != nil { + t.Fatalf("FilterVisibleTools() error = %v", err) + } + + groupBriefs := registry.ListVisibleToolGroupBriefs(visibleTools) + if len(groupBriefs) != 5 { + t.Fatalf("groupBriefs len = %d, want 5", len(groupBriefs)) + } + + personalBriefs := registry.ListVisibleToolBriefsByGroup(visibleTools, aidomain.ToolGroupOJPersonal) + if len(personalBriefs) != 3 { + t.Fatalf("personalBriefs len = %d, want 3", len(personalBriefs)) + } + if personalBriefs[0].Name == "" || personalBriefs[0].Summary == "" || personalBriefs[0].WhenToUse == "" { + t.Fatalf("personalBriefs[0] = %+v, want populated brief", personalBriefs[0]) + } + + selected := registry.ExpandVisibleToolsByNames(visibleTools, []string{"get_my_oj_curve", "get_my_ranking"}) + if len(selected) != 2 { + t.Fatalf("selected len = %d, want 2", len(selected)) + } + if selected[0].Spec().Name != "get_my_oj_curve" || selected[1].Spec().Name != "get_my_ranking" { + t.Fatalf("selected tools = [%s, %s]", selected[0].Spec().Name, selected[1].Spec().Name) + } + + groupExpanded := registry.ExpandVisibleToolsByGroup(visibleTools, aidomain.ToolGroupObservabilityMetrics) + if len(groupExpanded) != 2 { + t.Fatalf("groupExpanded len = %d, want 2", len(groupExpanded)) + } +} + +func TestAIValidateRuntimeMetricsArgs(t *testing.T) { + if err := aiValidateRuntimeMetricsArgs(aiRuntimeMetricsArgs{ + Metric: "task_execution_total", + Status: "published", + StartAt: "2026-04-24T09:20:00Z", + EndAt: "2026-04-24T10:20:00Z", + Granularity: "5m", + }); err == nil { + t.Fatal("aiValidateRuntimeMetricsArgs() error = nil, want invalid status") + } + + if err := aiValidateRuntimeMetricsArgs(aiRuntimeMetricsArgs{ + Metric: "task_duration_seconds", + Status: "success", + StartAt: "2026-04-01T09:20:00Z", + EndAt: "2026-04-24T10:20:00Z", + Granularity: "5m", + }); err == nil { + t.Fatal("aiValidateRuntimeMetricsArgs() error = nil, want invalid duration range") + } + + if err := aiValidateRuntimeMetricsArgs(aiRuntimeMetricsArgs{ + Metric: "event_consume_total", + Status: "error", + StartAt: "2026-04-24T09:20:00Z", + EndAt: "2026-04-24T10:20:00Z", + Granularity: "5m", + }); err != nil { + t.Fatalf("aiValidateRuntimeMetricsArgs() error = %v", err) + } +} + +func TestAIValidateObservabilityMetricsArgs(t *testing.T) { + if err := aiValidateObservabilityMetricsArgs(aiObservabilityMetricsArgs{ + Granularity: "1m", + StartAt: "2026-04-24T10:20:00Z", + EndAt: "2026-04-24T09:20:00Z", + }); err == nil { + t.Fatal("aiValidateObservabilityMetricsArgs() error = nil, want invalid time range") + } + + if err := aiValidateObservabilityMetricsArgs(aiObservabilityMetricsArgs{ + Granularity: "1m", + StartAt: "2026-04-24T09:20:00Z", + EndAt: "2026-04-24T10:20:00Z", + StatusClass: 7, + }); err == nil { + t.Fatal("aiValidateObservabilityMetricsArgs() error = nil, want invalid status_class") + } + + if err := aiValidateObservabilityMetricsArgs(aiObservabilityMetricsArgs{ + Granularity: "1m", + StartAt: "2026-04-24T09:20:00Z", + EndAt: "2026-04-24T10:20:00Z", + StatusClass: 2, + }); err != nil { + t.Fatalf("aiValidateObservabilityMetricsArgs() error = %v", err) + } +} + +func TestAIValidateTraceSummaryArgs(t *testing.T) { + if err := aiValidateTraceSummaryArgs(aiTraceSummaryArgs{ + StartAt: "2026-04-24T10:20:00Z", + EndAt: "2026-04-24T09:20:00Z", + }); err == nil { + t.Fatal("aiValidateTraceSummaryArgs() error = nil, want invalid time range") + } + + if err := aiValidateTraceSummaryArgs(aiTraceSummaryArgs{ + StartAt: "2026-04-24T09:20:00Z", + EndAt: "2026-04-24T10:20:00Z", + }); err != nil { + t.Fatalf("aiValidateTraceSummaryArgs() error = %v", err) + } +} + +func mustFindParam(t *testing.T, params []aidomain.ToolParameter, name string) aidomain.ToolParameter { + t.Helper() + for _, param := range params { + if param.Name == name { + return param + } + } + t.Fatalf("param %s not found", name) + return aidomain.ToolParameter{} +} + +func assertEnum(t *testing.T, param aidomain.ToolParameter, want []string) { + t.Helper() + if len(param.Enum) != len(want) { + t.Fatalf("%s enum = %v, want %v", param.Name, param.Enum, want) + } + for _, item := range want { + found := false + for _, got := range param.Enum { + if got == item { + found = true + break + } + } + if !found { + t.Fatalf("%s enum missing %q in %v", param.Name, item, param.Enum) + } + } +} + +func assertMinMax(t *testing.T, param aidomain.ToolParameter, min float64, max float64) { + t.Helper() + if param.Minimum == nil || *param.Minimum != min { + t.Fatalf("%s min = %v, want %v", param.Name, param.Minimum, min) + } + if param.Maximum == nil || *param.Maximum != max { + t.Fatalf("%s max = %v, want %v", param.Name, param.Maximum, max) + } +} + +func assertExamplesPresent(t *testing.T, param aidomain.ToolParameter) { + t.Helper() + if len(param.Examples) == 0 { + t.Fatalf("%s examples = empty", param.Name) + } +} diff --git a/internal/service/system/aiTool.go b/internal/service/system/aitool/tools.go similarity index 65% rename from internal/service/system/aiTool.go rename to internal/service/system/aitool/tools.go index edc906a..f4a1fdc 100644 --- a/internal/service/system/aiTool.go +++ b/internal/service/system/aitool/tools.go @@ -1,9 +1,8 @@ -package system +package aitool import ( "context" "encoding/json" - "fmt" "strings" aidomain "personal_assistant/internal/domain/ai" @@ -13,73 +12,10 @@ import ( bizerrors "personal_assistant/pkg/errors" ) -// aiAuthorizationService 表示 AI tool 侧依赖的最小授权能力集合。 -// 它只暴露可见性过滤和执行前鉴权必需的方法,不泄露完整授权服务细节。 -type aiAuthorizationService interface { - IsSuperAdmin(ctx context.Context, userID uint) (bool, error) - CheckUserCapabilityInOrg(ctx context.Context, userID, orgID uint, capabilityCode string) (bool, error) - AuthorizeOrgCapability(ctx context.Context, operatorID, orgID uint, capabilityCode string) error -} - -// aiOJService 表示 AI tool 查询个人或组织 OJ 统计时依赖的最小 OJ 能力。 -type aiOJService interface { - GetRankingList(ctx context.Context, userID uint, req *request.OJRankingListReq) (*resp.OJRankingListResp, error) - GetUserStats(ctx context.Context, userID uint, req *request.OJStatsReq) (*resp.OJStatsResp, error) - GetCurve(ctx context.Context, userID uint, req *request.OJCurveReq) (*resp.OJCurveResp, error) -} - -// aiOJTaskService 表示 AI tool 查询 OJ 任务、执行和分析结果时依赖的最小任务能力。 -type aiOJTaskService interface { - AnalyzeTaskTitles(ctx context.Context, req *request.AnalyzeOJTaskTitlesReq) (*resp.OJTaskAnalyzeResp, error) - GetTaskDetail(ctx context.Context, userID, taskID uint) (*resp.OJTaskDetailResp, error) - GetTaskExecutionDetail(ctx context.Context, userID, taskID, executionID uint) (*resp.OJTaskExecutionResp, error) - GetTaskExecutionUsers( - ctx context.Context, - userID, taskID, executionID uint, - req *request.OJTaskExecutionUserListReq, - ) (*resp.OJTaskExecutionUserListResp, error) - GetTaskExecutionUserDetail( - ctx context.Context, - userID, taskID, executionID, targetUserID uint, - ) (*resp.OJTaskExecutionUserDetailResp, error) -} - -// aiObservabilityService 表示 AI tool 查询 trace 和指标时依赖的最小观测能力。 -type aiObservabilityService interface { - QueryMetrics(ctx context.Context, req *request.ObservabilityMetricsQueryReq) (*resp.ObservabilityMetricsQueryResp, error) - QueryRuntimeMetrics( - ctx context.Context, - req *request.ObservabilityRuntimeMetricQueryReq, - ) (*resp.ObservabilityRuntimeMetricQueryResp, error) - QueryTraceDetail( - ctx context.Context, - id string, - idType string, - limit int, - offset int, - includePayload bool, - includeErrorDetail bool, - ) (*resp.ObservabilityTraceQueryResp, error) - QueryTrace(ctx context.Context, req *request.ObservabilityTraceQueryReq) (*resp.ObservabilityTraceSummaryQueryResp, error) -} - -// AIDeps 表示 AIService 构建 tool loop 时需要的最小服务依赖。 -type AIDeps struct { - // Authorization 提供工具可见性过滤和执行前二次鉴权所需的授权能力。 - Authorization aiAuthorizationService - // OJ 提供个人排行、统计和曲线等 OJ 查询能力。 - OJ aiOJService - // OJTask 提供任务执行和题目分析等 OJ 任务能力。 - OJTask aiOJTaskService - // Observability 提供 trace 和指标查询能力。 - Observability aiObservabilityService - // Memory 提供可选的额外记忆召回能力;未注入时保持只读历史消息路径。 - Memory aiMemoryProvider - // Compressor 提供可选的上下文压缩能力;未注入时保持原始历史消息。 - Compressor aiContextCompressor - // PromptBuilder 允许调用方替换默认动态 prompt 构造逻辑。 - PromptBuilder aiPromptBuilder -} +type aiAuthorizationService = AuthorizationService +type aiOJService = OJService +type aiOJTaskService = OJTaskService +type aiObservabilityService = ObservabilityService // aiToolPolicyKind 表示工具可见性和执行前鉴权采用的策略类型。 type aiToolPolicyKind string @@ -103,10 +39,12 @@ type aiToolPolicy struct { // aiServiceTool 表示 service 层注册的一个具体 AI tool 实现。 type aiServiceTool struct { - // spec 是暴露给 runtime 和模型的稳定工具协议。 - spec aidomain.ToolSpec + // descriptor 是单个工具的稳定元数据真相。 + descriptor aidomain.ToolDescriptor // policy 描述工具的可见性和执行前鉴权要求。 policy aiToolPolicy + // validate 承载跨字段或条件参数校验逻辑;为空时只走 schema 级预校验。 + validate func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) error // call 承载工具的实际业务执行逻辑。 call func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) } @@ -114,7 +52,7 @@ type aiServiceTool struct { // Spec 返回工具的稳定协议定义。 func (t *aiServiceTool) Spec() aidomain.ToolSpec { // spec 在注册阶段就已固定,不在运行时动态变更。 - return t.spec + return t.descriptor.Spec } // Call 执行具体工具逻辑。 @@ -132,25 +70,50 @@ func (t *aiServiceTool) Call( return t.call(ctx, call, callCtx) } -// aiToolRegistry 负责注册工具、过滤可见性,并提供执行前定位能力。 -type aiToolRegistry struct { +// Validate 执行 tool 的补充参数校验。 +func (t *aiServiceTool) Validate( + ctx context.Context, + call aidomain.ToolCall, + callCtx aidomain.ToolCallContext, +) error { + if t == nil || t.validate == nil { + return nil + } + return t.validate(ctx, call, callCtx) +} + +// Registry 负责注册工具、过滤可见性,并提供执行前定位能力。 +type Registry struct { // authorization 用于按 principal 判断组织能力类工具是否可见。 authorization aiAuthorizationService // tools 保存当前进程可注册的全部工具目录。 tools []*aiServiceTool + // byName 提供工具名到工具元数据的直接索引。 + byName map[string]*aiServiceTool } // newAIToolRegistry 创建 AI tool 注册表。 -func newAIToolRegistry(deps AIDeps) *aiToolRegistry { +func newAIToolRegistry(deps Deps) *Registry { // 先创建空注册表,并挂上可见性过滤会用到的授权服务。 - r := &aiToolRegistry{authorization: deps.Authorization} + r := &Registry{authorization: deps.Authorization} // 再根据当前注入依赖构建完整工具目录。 r.tools = r.buildCatalog(deps) + r.byName = make(map[string]*aiServiceTool, len(r.tools)) + for _, tool := range r.tools { + if tool == nil { + continue + } + name := strings.TrimSpace(tool.Spec().Name) + if name == "" { + continue + } + r.byName[name] = tool + } return r } // buildCatalog 根据当前依赖拼出本进程真正可提供的工具目录。 -func (r *aiToolRegistry) buildCatalog(deps AIDeps) []*aiServiceTool { +func (r *Registry) buildCatalog(deps Deps) []*aiServiceTool { // 先按预估容量创建切片,减少追加时的扩容次数。 tools := make([]*aiServiceTool, 0, 11) if deps.OJ != nil { @@ -187,7 +150,7 @@ func (r *aiToolRegistry) buildCatalog(deps AIDeps) []*aiServiceTool { } // FilterVisibleTools 按本轮 principal 过滤出模型真正可见的工具集合。 -func (r *aiToolRegistry) FilterVisibleTools( +func (r *Registry) FilterVisibleTools( ctx context.Context, callCtx aidomain.ToolCallContext, ) ([]aidomain.Tool, error) { @@ -212,7 +175,7 @@ func (r *aiToolRegistry) FilterVisibleTools( } // isVisible 判断某个 policy 在当前 principal 下是否应该暴露给模型。 -func (r *aiToolRegistry) isVisible( +func (r *Registry) isVisible( ctx context.Context, policy aiToolPolicy, principal aidomain.AIToolPrincipal, @@ -247,120 +210,96 @@ func (r *aiToolRegistry) isVisible( } // findTool 按名称从注册表里查找具体工具实现。 -func (r *aiToolRegistry) findTool(name string) *aiServiceTool { +func (r *Registry) findTool(name string) *aiServiceTool { // 统一 trim 输入,避免调用方传入带空白的工具名。 normalizedName := strings.TrimSpace(name) - for _, tool := range r.tools { - // 只返回名称完全匹配的工具实现。 - if tool != nil && tool.spec.Name == normalizedName { - return tool - } + if r == nil || normalizedName == "" { + return nil } - return nil + return r.byName[normalizedName] } -// buildAIToolDynamicPrompt 生成“本轮工具使用说明”提示词。 -// -// 该提示词会喂给模型,用于约束模型的工具使用行为,包括: -// 1. 只能使用本轮明确列出的工具; -// 2. 缺少精确标识时不要猜; -// 3. 工具可见 ≠ 最终一定执行成功,执行期仍会鉴权; -// 4. 当前组织上下文是什么; -// 5. 每个工具的参数定义是什么。 -// -// 设计价值: -// - 减少模型臆造工具; -// - 减少模型胡乱猜 org_id / task_id / request_id; -// - 将“后端权限事实”同步给模型,提高调用成功率。 -func buildAIToolDynamicPrompt( - tools []aidomain.Tool, - principal aidomain.AIToolPrincipal, -) string { - // builder 按顺序拼出固定约束、组织上下文和可见工具清单。 - var builder strings.Builder - builder.WriteString("你是 personal_assistant 的 AI 助手。\n") - builder.WriteString("本轮只能使用下面明确列出的工具;不要假设还有其他工具。\n") - builder.WriteString("如果用户请求需要 org_id、task_id、execution_id、request_id 等精确标识,而上下文里没有,不要猜测,直接向用户索取。\n") - builder.WriteString("工具可见性已经按当前授权事实过滤,但真正执行时仍会再次鉴权;如果工具报权限错误,直接向用户说明。\n") - if principal.CurrentOrgID != nil && *principal.CurrentOrgID > 0 { - // 当前组织上下文单独写进 prompt,帮助模型优先复用默认 org_id。 - builder.WriteString(fmt.Sprintf("当前组织上下文 org_id=%d。\n", *principal.CurrentOrgID)) - } - if len(tools) == 0 { - // 无工具时明确告知模型只能基于上下文回答,避免虚构工具调用。 - builder.WriteString("本轮没有可用工具,请直接基于已有上下文回答,无法确认的数据不要编造。") - return builder.String() - } - - // 有工具时逐个列出名称、描述和参数协议。 - builder.WriteString("本轮可用工具清单:\n") - for idx, tool := range tools { - spec := tool.Spec() - builder.WriteString(fmt.Sprintf("%d. %s: %s\n", idx+1, spec.Name, spec.Description)) - if len(spec.Parameters) == 0 { - builder.WriteString(" 参数:无\n") +// ListVisibleToolGroupBriefs 按当前可见工具生成第一阶段 selector 需要的组简介。 +func (r *Registry) ListVisibleToolGroupBriefs(tools []aidomain.Tool) []aidomain.ToolGroupBrief { + seen := make(map[aidomain.ToolGroupID]struct{}, len(tools)) + items := make([]aidomain.ToolGroupBrief, 0, len(tools)) + for _, tool := range tools { + metaTool := r.findTool(tool.Spec().Name) + if metaTool == nil || metaTool.descriptor.GroupID == "" { continue } - builder.WriteString(" 参数:\n") - for _, param := range spec.Parameters { - builder.WriteString(" - ") - builder.WriteString(formatAIToolParameterPrompt(param)) - builder.WriteString("\n") + if _, ok := seen[metaTool.descriptor.GroupID]; ok { + continue } + seen[metaTool.descriptor.GroupID] = struct{}{} + groupTools := r.ExpandVisibleToolsByGroup(tools, metaTool.descriptor.GroupID) + items = append(items, buildToolGroupBrief(metaTool.descriptor.GroupID, groupTools)) } - return strings.TrimSpace(builder.String()) + return items } -// formatAIToolParameterPrompt 把单个参数协议转成可读的 prompt 文本。 -func formatAIToolParameterPrompt(param aidomain.ToolParameter) string { - // meta 先拼出类型、必填性和枚举约束。 - meta := string(param.Type) - if param.Required { - meta += ", required" - } else { - meta += ", optional" - } - if len(param.Enum) > 0 { - meta += ", enum=" + strings.Join(param.Enum, "|") - } - if param.Type == aidomain.ToolParameterTypeArray && param.Items != nil { - // array 参数额外补出元素类型说明。 - meta += ", items=" + describeAIToolParameterType(*param.Items) +// ListVisibleToolBriefsByGroup 返回指定组内、当前仍然可见的工具简介列表。 +func (r *Registry) ListVisibleToolBriefsByGroup( + tools []aidomain.Tool, + groupID aidomain.ToolGroupID, +) []aidomain.ToolBrief { + items := make([]aidomain.ToolBrief, 0, len(tools)) + for _, tool := range tools { + metaTool := r.findTool(tool.Spec().Name) + if metaTool == nil || metaTool.descriptor.GroupID != groupID { + continue + } + items = append(items, metaTool.descriptor.Brief) } + return items +} - // line 是当前参数的主描述行。 - line := fmt.Sprintf("%s (%s)", param.Name, meta) - if strings.TrimSpace(param.Description) != "" { - line += ": " + strings.TrimSpace(param.Description) +// ExpandVisibleToolsByNames 按名称从当前可见工具集中展开最终要暴露的工具子集。 +func (r *Registry) ExpandVisibleToolsByNames(tools []aidomain.Tool, names []string) []aidomain.Tool { + if len(tools) == 0 || len(names) == 0 { + return nil } - if len(param.Properties) == 0 { - return line + visibleByName := make(map[string]aidomain.Tool, len(tools)) + for _, tool := range tools { + if tool == nil { + continue + } + visibleByName[tool.Spec().Name] = tool } - - // object 参数递归列出所有子字段,方便模型一次性看懂结构。 - children := make([]string, 0, len(param.Properties)) - for _, child := range param.Properties { - children = append(children, formatAIToolParameterPrompt(child)) + items := make([]aidomain.Tool, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + tool, ok := visibleByName[name] + if !ok { + continue + } + seen[name] = struct{}{} + items = append(items, tool) + if len(items) >= 3 { + break + } } - return line + "; fields={" + strings.Join(children, "; ") + "}" + return items } -// describeAIToolParameterType 返回参数类型的简短可读描述。 -func describeAIToolParameterType(param aidomain.ToolParameter) string { - switch param.Type { - case aidomain.ToolParameterTypeObject: - // object 直接返回 object 标记。 - return "object" - case aidomain.ToolParameterTypeArray: - // array 继续递归描述元素类型。 - if param.Items == nil { - return "array" +// ExpandVisibleToolsByGroup 在低置信度场景下回退到指定组的全部可见工具。 +func (r *Registry) ExpandVisibleToolsByGroup(tools []aidomain.Tool, groupID aidomain.ToolGroupID) []aidomain.Tool { + items := make([]aidomain.Tool, 0, len(tools)) + for _, tool := range tools { + metaTool := r.findTool(tool.Spec().Name) + if metaTool == nil || metaTool.descriptor.GroupID != groupID { + continue } - return "array<" + describeAIToolParameterType(*param.Items) + ">" - default: - // 基础类型直接返回底层 type 值。 - return string(param.Type) + items = append(items, tool) } + return items } // newAISelfOnlyPolicy 创建 SelfOnly 访问策略。 @@ -392,7 +331,16 @@ func decodeAIToolArgs(call aidomain.ToolCall, out any) error { } // JSON 解析失败时统一包装成参数错误,方便前端和模型理解。 if err := json.Unmarshal([]byte(call.ArgumentsJSON), out); err != nil { - return bizerrors.WrapWithMsg(bizerrors.CodeInvalidParams, "AI tool 参数解析失败", err) + return aidomain.NewRepairableInvalidParamErrorWithCause( + "AI tool 参数解析失败,请按 JSON 对象格式重新组织参数。", + err, + aidomain.ToolFieldError{ + Field: "arguments", + Reason: "invalid_json", + Expected: "合法的 JSON 对象", + Example: `{"platform":"leetcode"}`, + }, + ) } return nil } @@ -413,7 +361,7 @@ func buildAIToolResult(payload any, summary string) (aidomain.ToolResult, error) // 未显式提供 summary 时,从原始 JSON 截断一份短摘要。 summary = strings.TrimSpace(summary) if summary == "" { - summary = truncateRunes(string(raw), 120) + summary = truncateToolSummary(string(raw), 120) } return aidomain.ToolResult{ Output: string(raw), @@ -532,15 +480,30 @@ type aiMyRankingArgs struct { func newAIGetMyRankingTool(ojSvc aiOJService) *aiServiceTool { // 个人排行工具只面向当前登录用户自己的数据。 return &aiServiceTool{ - // spec 描述模型可见的工具协议。 - spec: aidomain.ToolSpec{ + // descriptor 在工具定义时直接声明稳定协议和渐进式 brief。 + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "get_my_ranking", Description: "获取当前登录用户在指定 OJ 排行榜中的个人排名摘要,不返回其他用户完整榜单。", Parameters: []aidomain.ToolParameter{ - {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"leetcode", "luogu", "lanqiao"}}, - {Name: "scope", Type: aidomain.ToolParameterTypeString, Description: "排行范围,默认 current_org", Enum: []string{"current_org", "all_members"}}, + { + Name: "platform", + Type: aidomain.ToolParameterTypeString, + Description: "OJ 平台,只支持个人已绑定数据的平台。", + Required: true, + Enum: []string{"leetcode", "luogu", "lanqiao"}, + Examples: []string{"leetcode", "luogu"}, + DefaultValue: "", + }, + { + Name: "scope", + Type: aidomain.ToolParameterTypeString, + Description: "排行范围;省略时默认 current_org。", + Enum: []string{"current_org", "all_members"}, + Examples: []string{"current_org"}, + DefaultValue: "current_org", + }, }, - }, + }, aidomain.ToolGroupOJPersonal, "查询当前用户在指定 OJ 平台的个人排名。", "用户明确想看自己的排名或名次时使用。", "oj", "personal", "ranking"), // policy 声明该工具只围绕当前用户自己的数据执行。 policy: newAISelfOnlyPolicy(), // call 负责查询当前用户在指定平台下的个人排行摘要。 @@ -588,14 +551,20 @@ type aiMyPlatformArgs struct { func newAIGetMyOJStatsTool(ojSvc aiOJService) *aiServiceTool { // 个人统计工具只查询当前用户在单个平台上的统计。 return &aiServiceTool{ - // spec 描述模型可见的工具名、描述和参数协议。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "get_my_oj_stats", Description: "获取当前登录用户在指定 OJ 平台上的个人统计。", Parameters: []aidomain.ToolParameter{ - {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"leetcode", "luogu", "lanqiao"}}, + { + Name: "platform", + Type: aidomain.ToolParameterTypeString, + Description: "OJ 平台。", + Required: true, + Enum: []string{"leetcode", "luogu", "lanqiao"}, + Examples: []string{"leetcode"}, + }, }, - }, + }, aidomain.ToolGroupOJPersonal, "查询当前用户在指定 OJ 平台的个人统计。", "用户想看自己的通过数、题量或个人统计时使用。", "oj", "personal", "stats"), // policy 仍然是 SelfOnly。 policy: newAISelfOnlyPolicy(), // call 负责查询并返回当前用户的平台统计。 @@ -622,14 +591,20 @@ func newAIGetMyOJStatsTool(ojSvc aiOJService) *aiServiceTool { func newAIGetMyOJCurveTool(ojSvc aiOJService) *aiServiceTool { // 个人曲线工具只查询当前用户自己的做题趋势。 return &aiServiceTool{ - // spec 定义模型如何调用该工具。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "get_my_oj_curve", Description: "获取当前登录用户在指定 OJ 平台上的最近做题曲线。", Parameters: []aidomain.ToolParameter{ - {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"leetcode", "luogu", "lanqiao"}}, + { + Name: "platform", + Type: aidomain.ToolParameterTypeString, + Description: "OJ 平台。", + Required: true, + Enum: []string{"leetcode", "luogu", "lanqiao"}, + Examples: []string{"leetcode"}, + }, }, - }, + }, aidomain.ToolGroupOJPersonal, "查询当前用户在指定 OJ 平台的曲线趋势。", "用户想看自己的刷题变化趋势或曲线时使用。", "oj", "personal", "curve"), // policy 声明这是 SelfOnly 工具。 policy: newAISelfOnlyPolicy(), // call 负责查询并返回个人曲线。 @@ -671,17 +646,45 @@ func newAIGetOrgRankingSummaryTool( ) *aiServiceTool { // 组织排行工具要求目标组织具备 OJ 任务管理能力。 return &aiServiceTool{ - // spec 告诉模型可以按 org_id 和 platform 查询组织排行摘要。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "get_org_ranking_summary", Description: "获取指定组织在指定 OJ 平台排行榜中的摘要,需要 OJ 任务管理能力。", Parameters: []aidomain.ToolParameter{ - {Name: "org_id", Type: aidomain.ToolParameterTypeInteger, Description: "目标组织 ID;省略时默认当前组织"}, - {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"leetcode", "luogu", "lanqiao"}}, - {Name: "page", Type: aidomain.ToolParameterTypeInteger, Description: "页码,默认 1"}, - {Name: "page_size", Type: aidomain.ToolParameterTypeInteger, Description: "分页大小,默认 20"}, + { + Name: "org_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "目标组织 ID;省略时默认当前组织。", + Minimum: aiFloatPtr(1), + Examples: []string{"12"}, + DefaultValue: "current_org_id", + }, + { + Name: "platform", + Type: aidomain.ToolParameterTypeString, + Description: "OJ 平台。", + Required: true, + Enum: []string{"leetcode", "luogu", "lanqiao"}, + Examples: []string{"leetcode"}, + }, + { + Name: "page", + Type: aidomain.ToolParameterTypeInteger, + Description: "页码。", + Minimum: aiFloatPtr(1), + Examples: []string{"1"}, + DefaultValue: "1", + }, + { + Name: "page_size", + Type: aidomain.ToolParameterTypeInteger, + Description: "分页大小。", + Minimum: aiFloatPtr(1), + Maximum: aiFloatPtr(100), + Examples: []string{"20"}, + DefaultValue: "20", + }, }, - }, + }, aidomain.ToolGroupOJOrg, "查询组织范围内的 OJ 排名汇总。", "用户想看组织排行榜、榜单分页或组织范围排名时使用。", "oj", "org", "ranking"), // policy 声明该工具受组织 capability 控制。 policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), // call 负责解析目标组织、做真实鉴权并查询组织排行摘要。 @@ -739,15 +742,28 @@ func newAIGetTaskExecutionSummaryTool( ) *aiServiceTool { // 任务执行摘要工具既要复用任务可见性,也要对关联组织做 capability 收口。 return &aiServiceTool{ - // spec 描述模型需要提供 task_id 和 execution_id。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "get_task_execution_summary", Description: "获取指定任务执行的摘要,需要先具备任务可见性,再通过关联组织能力校验。", Parameters: []aidomain.ToolParameter{ - {Name: "task_id", Type: aidomain.ToolParameterTypeInteger, Description: "任务 ID", Required: true}, - {Name: "execution_id", Type: aidomain.ToolParameterTypeInteger, Description: "执行 ID", Required: true}, + { + Name: "task_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "任务 ID。", + Required: true, + Minimum: aiFloatPtr(1), + Examples: []string{"101"}, + }, + { + Name: "execution_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "执行 ID。", + Required: true, + Minimum: aiFloatPtr(1), + Examples: []string{"202"}, + }, }, - }, + }, aidomain.ToolGroupOJTask, "查询任务执行摘要。", "用户想看某个任务执行结果、状态或概览时使用。", "oj", "task", "summary"), // policy 用于控制该工具是否在当前组织上下文里可见。 policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), // call 负责先校验任务可见性,再按任务关联组织做能力收口。 @@ -807,19 +823,59 @@ func newAIListTaskExecutionUsersTool( ) *aiServiceTool { // 用户列表工具复用任务可见性和组织能力双重收口。 return &aiServiceTool{ - // spec 描述模型可传的分页和筛选参数。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "list_task_execution_users", Description: "分页列出指定任务执行下的用户结果,需要任务可见性和组织能力。", Parameters: []aidomain.ToolParameter{ - {Name: "task_id", Type: aidomain.ToolParameterTypeInteger, Description: "任务 ID", Required: true}, - {Name: "execution_id", Type: aidomain.ToolParameterTypeInteger, Description: "执行 ID", Required: true}, - {Name: "page", Type: aidomain.ToolParameterTypeInteger, Description: "页码,默认 1"}, - {Name: "page_size", Type: aidomain.ToolParameterTypeInteger, Description: "分页大小,默认 20"}, - {Name: "all_completed", Type: aidomain.ToolParameterTypeBoolean, Description: "是否只看已全部完成的用户"}, - {Name: "username", Type: aidomain.ToolParameterTypeString, Description: "用户名关键字"}, + { + Name: "task_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "任务 ID。", + Required: true, + Minimum: aiFloatPtr(1), + Examples: []string{"101"}, + }, + { + Name: "execution_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "执行 ID。", + Required: true, + Minimum: aiFloatPtr(1), + Examples: []string{"202"}, + }, + { + Name: "page", + Type: aidomain.ToolParameterTypeInteger, + Description: "页码。", + Minimum: aiFloatPtr(1), + Examples: []string{"1"}, + DefaultValue: "1", + }, + { + Name: "page_size", + Type: aidomain.ToolParameterTypeInteger, + Description: "分页大小。", + Minimum: aiFloatPtr(1), + Maximum: aiFloatPtr(200), + Examples: []string{"20"}, + DefaultValue: "20", + }, + { + Name: "all_completed", + Type: aidomain.ToolParameterTypeBoolean, + Description: "是否只看已全部完成的用户。", + Examples: []string{"true"}, + DefaultValue: "false", + }, + { + Name: "username", + Type: aidomain.ToolParameterTypeString, + Description: "用户名关键字。", + MaxLength: aiIntPtr(50), + Examples: []string{"alice"}, + }, }, - }, + }, aidomain.ToolGroupOJTask, "列出任务执行用户名单。", "用户想看某次执行有哪些人、分页结果或按用户名筛选时使用。", "oj", "task", "users"), // policy 声明这是组织能力类工具。 policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), // call 负责按任务执行分页查询用户结果。 @@ -878,16 +934,36 @@ func newAIGetTaskExecutionUserDetailTool( ) *aiServiceTool { // 用户详情工具与任务摘要工具共享同一套权限收口思路。 return &aiServiceTool{ - // spec 要求模型给出 task、execution 和 target user 三个关键标识。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "get_task_execution_user_detail", Description: "获取指定任务执行中某个用户的详细结果,需要任务可见性和组织能力。", Parameters: []aidomain.ToolParameter{ - {Name: "task_id", Type: aidomain.ToolParameterTypeInteger, Description: "任务 ID", Required: true}, - {Name: "execution_id", Type: aidomain.ToolParameterTypeInteger, Description: "执行 ID", Required: true}, - {Name: "target_user_id", Type: aidomain.ToolParameterTypeInteger, Description: "目标用户 ID", Required: true}, + { + Name: "task_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "任务 ID。", + Required: true, + Minimum: aiFloatPtr(1), + Examples: []string{"101"}, + }, + { + Name: "execution_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "执行 ID。", + Required: true, + Minimum: aiFloatPtr(1), + Examples: []string{"202"}, + }, + { + Name: "target_user_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "目标用户 ID。", + Required: true, + Minimum: aiFloatPtr(1), + Examples: []string{"303"}, + }, }, - }, + }, aidomain.ToolGroupOJTask, "查询单个用户的任务执行明细。", "用户想看某个同学或指定用户的执行详情时使用。", "oj", "task", "user_detail"), // policy 仍然是 OJ 任务管理能力。 policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), // call 负责查询指定执行里某个用户的详细结果。 @@ -955,20 +1031,49 @@ func newAIAnalyzeTaskTitlesTool( itemParam := aidomain.ToolParameter{ Type: aidomain.ToolParameterTypeObject, Properties: []aidomain.ToolParameter{ - {Name: "platform", Type: aidomain.ToolParameterTypeString, Description: "OJ 平台", Required: true, Enum: []string{"luogu", "leetcode", "lanqiao"}}, - {Name: "title", Type: aidomain.ToolParameterTypeString, Description: "题目标题", Required: true}, + { + Name: "platform", + Type: aidomain.ToolParameterTypeString, + Description: "OJ 平台。", + Required: true, + Enum: []string{"luogu", "leetcode", "lanqiao"}, + Examples: []string{"leetcode"}, + }, + { + Name: "title", + Type: aidomain.ToolParameterTypeString, + Description: "题目标题。", + Required: true, + MinLength: aiIntPtr(1), + MaxLength: aiIntPtr(255), + Examples: []string{"Two Sum"}, + }, }, } return &aiServiceTool{ - // spec 描述按组织上下文分析一组题目标题的能力。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "analyze_task_titles", Description: "分析一组 OJ 题目标题并返回可解析结果,需要指定组织并具备 OJ 任务管理能力。", Parameters: []aidomain.ToolParameter{ - {Name: "org_id", Type: aidomain.ToolParameterTypeInteger, Description: "目标组织 ID;省略时默认当前组织"}, - {Name: "items", Type: aidomain.ToolParameterTypeArray, Description: "待分析题目列表", Required: true, Items: &itemParam}, + { + Name: "org_id", + Type: aidomain.ToolParameterTypeInteger, + Description: "目标组织 ID;省略时默认当前组织。", + Minimum: aiFloatPtr(1), + Examples: []string{"12"}, + DefaultValue: "current_org_id", + }, + { + Name: "items", + Type: aidomain.ToolParameterTypeArray, + Description: "待分析题目列表。", + Required: true, + MinItems: aiIntPtr(1), + Examples: []string{`[{"platform":"leetcode","title":"Two Sum"}]`}, + Items: &itemParam, + }, }, - }, + }, aidomain.ToolGroupOJTask, "分析题目标题并生成任务题目建议。", "用户提供题目标题列表,希望做任务题目分析时使用。", "oj", "task", "analyze"), // policy 声明该工具需要组织能力。 policy: newAIOrgCapabilityPolicy(consts.CapabilityCodeOJTaskManage), // call 负责解析题目列表、校验组织能力并调用任务分析服务。 @@ -1035,18 +1140,51 @@ func newAIQueryTraceDetailByRequestIDTool( ) *aiServiceTool { // trace 详情属于观测类工具,只允许超级管理员使用。 return &aiServiceTool{ - // spec 描述按 request_id 查询链路详情的能力。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "query_trace_detail_by_request_id", Description: "按 request_id 查询链路详情,仅超级管理员可用。", Parameters: []aidomain.ToolParameter{ - {Name: "request_id", Type: aidomain.ToolParameterTypeString, Description: "请求 ID", Required: true}, - {Name: "limit", Type: aidomain.ToolParameterTypeInteger, Description: "返回条数,默认 100"}, - {Name: "offset", Type: aidomain.ToolParameterTypeInteger, Description: "偏移量,默认 0"}, - {Name: "include_payload", Type: aidomain.ToolParameterTypeBoolean, Description: "是否包含请求/响应摘要"}, - {Name: "include_error_detail", Type: aidomain.ToolParameterTypeBoolean, Description: "是否包含错误详情"}, + { + Name: "request_id", + Type: aidomain.ToolParameterTypeString, + Description: "请求 ID。", + Required: true, + MinLength: aiIntPtr(1), + Examples: []string{"req_01hxyz"}, + }, + { + Name: "limit", + Type: aidomain.ToolParameterTypeInteger, + Description: "返回条数。", + Minimum: aiFloatPtr(1), + Maximum: aiFloatPtr(1000), + Examples: []string{"200"}, + DefaultValue: "200", + }, + { + Name: "offset", + Type: aidomain.ToolParameterTypeInteger, + Description: "偏移量。", + Minimum: aiFloatPtr(0), + Examples: []string{"0"}, + DefaultValue: "0", + }, + { + Name: "include_payload", + Type: aidomain.ToolParameterTypeBoolean, + Description: "是否包含请求/响应摘要。", + Examples: []string{"true"}, + DefaultValue: "false", + }, + { + Name: "include_error_detail", + Type: aidomain.ToolParameterTypeBoolean, + Description: "是否包含错误详情。", + Examples: []string{"true"}, + DefaultValue: "false", + }, }, - }, + }, aidomain.ToolGroupObservabilityTrace, "按 request_id 或 trace_id 查询链路详情。", "用户要排查某次请求的完整链路详情时使用。", "observability", "trace", "detail"), // policy 声明该工具只对超级管理员可见。 policy: newAISuperAdminOnlyPolicy(), // call 负责执行超级管理员校验并查询 trace 详情。 @@ -1109,24 +1247,70 @@ func newAIQueryTraceSummaryTool( ) *aiServiceTool { // trace 摘要属于观测类工具,只允许超级管理员使用。 return &aiServiceTool{ - // spec 描述 trace 列表支持的过滤字段。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "query_trace_summary", Description: "查询链路摘要列表,仅超级管理员可用。", Parameters: []aidomain.ToolParameter{ - {Name: "trace_id", Type: aidomain.ToolParameterTypeString, Description: "trace_id"}, - {Name: "request_id", Type: aidomain.ToolParameterTypeString, Description: "request_id"}, - {Name: "service", Type: aidomain.ToolParameterTypeString, Description: "服务名"}, - {Name: "status", Type: aidomain.ToolParameterTypeString, Description: "状态"}, - {Name: "root_stage", Type: aidomain.ToolParameterTypeString, Description: "root stage", Enum: []string{request.TraceRootStageHTTP, request.TraceRootStageTask, request.TraceRootStageAll}}, - {Name: "start_at", Type: aidomain.ToolParameterTypeString, Description: "开始时间"}, - {Name: "end_at", Type: aidomain.ToolParameterTypeString, Description: "结束时间"}, - {Name: "limit", Type: aidomain.ToolParameterTypeInteger, Description: "返回条数"}, - {Name: "offset", Type: aidomain.ToolParameterTypeInteger, Description: "偏移量"}, + {Name: "trace_id", Type: aidomain.ToolParameterTypeString, Description: "trace_id。", Examples: []string{"trace_01hxyz"}}, + {Name: "request_id", Type: aidomain.ToolParameterTypeString, Description: "request_id。", Examples: []string{"req_01hxyz"}}, + {Name: "service", Type: aidomain.ToolParameterTypeString, Description: "服务名。", Examples: []string{"personal_assistant"}}, + { + Name: "status", + Type: aidomain.ToolParameterTypeString, + Description: "链路状态。", + Enum: []string{"ok", "error"}, + Examples: []string{"ok"}, + }, + { + Name: "root_stage", + Type: aidomain.ToolParameterTypeString, + Description: "根阶段。", + Enum: []string{request.TraceRootStageHTTP, request.TraceRootStageTask, request.TraceRootStageAll}, + Examples: []string{request.TraceRootStageHTTP}, + DefaultValue: request.TraceRootStageHTTP, + }, + { + Name: "start_at", + Type: aidomain.ToolParameterTypeString, + Description: "开始时间。", + Format: aidomain.ToolParameterFormatRFC3339, + Examples: []string{"2026-04-24T09:20:00Z"}, + }, + { + Name: "end_at", + Type: aidomain.ToolParameterTypeString, + Description: "结束时间。", + Format: aidomain.ToolParameterFormatRFC3339, + Examples: []string{"2026-04-24T10:20:00Z"}, + }, + { + Name: "limit", + Type: aidomain.ToolParameterTypeInteger, + Description: "返回条数。", + Minimum: aiFloatPtr(1), + Maximum: aiFloatPtr(1000), + Examples: []string{"200"}, + DefaultValue: "200", + }, + { + Name: "offset", + Type: aidomain.ToolParameterTypeInteger, + Description: "偏移量。", + Minimum: aiFloatPtr(0), + Examples: []string{"0"}, + DefaultValue: "0", + }, }, - }, + }, aidomain.ToolGroupObservabilityTrace, "查询链路追踪摘要列表。", "用户要按时间、状态或阶段筛 trace 摘要时使用。", "observability", "trace", "summary"), // policy 声明该工具只对超级管理员可见。 policy: newAISuperAdminOnlyPolicy(), + validate: func(_ context.Context, call aidomain.ToolCall, _ aidomain.ToolCallContext) error { + var args aiTraceSummaryArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return err + } + return aiValidateTraceSummaryArgs(args) + }, // call 负责执行超级管理员鉴权并查询 trace 摘要列表。 call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { // 先解析各种 trace 过滤条件。 @@ -1134,6 +1318,9 @@ func newAIQueryTraceSummaryTool( if err := decodeAIToolArgs(call, &args); err != nil { return aidomain.ToolResult{}, err } + if err := aiValidateTraceSummaryArgs(args); err != nil { + return aidomain.ToolResult{}, err + } // 执行前再次验证调用者是否为超级管理员。 if err := requireAISuperAdmin(ctx, authorization, callCtx.Principal); err != nil { @@ -1187,23 +1374,76 @@ func newAIQueryRuntimeMetricsTool( ) *aiServiceTool { // 运行时指标属于观测类工具,只允许超级管理员使用。 return &aiServiceTool{ - // spec 描述指标查询支持的过滤参数。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "query_runtime_metrics", Description: "查询运行时指标,仅超级管理员可用。", Parameters: []aidomain.ToolParameter{ - {Name: "metric", Type: aidomain.ToolParameterTypeString, Description: "指标名称", Required: true}, - {Name: "start_at", Type: aidomain.ToolParameterTypeString, Description: "开始时间"}, - {Name: "end_at", Type: aidomain.ToolParameterTypeString, Description: "结束时间"}, - {Name: "granularity", Type: aidomain.ToolParameterTypeString, Description: "粒度"}, - {Name: "task_name", Type: aidomain.ToolParameterTypeString, Description: "任务名"}, - {Name: "topic", Type: aidomain.ToolParameterTypeString, Description: "topic"}, - {Name: "status", Type: aidomain.ToolParameterTypeString, Description: "状态"}, - {Name: "limit", Type: aidomain.ToolParameterTypeInteger, Description: "返回条数"}, + { + Name: "metric", + Type: aidomain.ToolParameterTypeString, + Description: "指标名称。", + Required: true, + Enum: []string{ + "task_execution_total", + "task_duration_seconds", + "outbox_events_total", + "outbox_publish_duration_seconds", + "event_consume_total", + "event_consume_duration_seconds", + }, + Examples: []string{"task_execution_total"}, + }, + { + Name: "start_at", + Type: aidomain.ToolParameterTypeString, + Description: "开始时间。部分 metric 必填。", + Format: aidomain.ToolParameterFormatRFC3339, + Examples: []string{"2026-04-24T09:20:00Z"}, + }, + { + Name: "end_at", + Type: aidomain.ToolParameterTypeString, + Description: "结束时间。部分 metric 必填,且必须大于 start_at。", + Format: aidomain.ToolParameterFormatRFC3339, + Examples: []string{"2026-04-24T10:20:00Z"}, + }, + { + Name: "granularity", + Type: aidomain.ToolParameterTypeString, + Description: "聚合粒度;省略时默认 5m。", + Enum: []string{"1m", "5m", "1h", "1d"}, + Examples: []string{"5m"}, + DefaultValue: "5m", + }, + {Name: "task_name", Type: aidomain.ToolParameterTypeString, Description: "任务名;仅任务类 metric 使用。", Examples: []string{"sync_ranking_projection"}}, + {Name: "topic", Type: aidomain.ToolParameterTypeString, Description: "topic;仅事件类 metric 使用。", Examples: []string{"permission_projection"}}, + { + Name: "status", + Type: aidomain.ToolParameterTypeString, + Description: "状态;不同 metric 支持的状态集合不同。", + Enum: []string{"success", "error", "skipped", "pending", "published", "failed"}, + Examples: []string{"success", "pending"}, + }, + { + Name: "limit", + Type: aidomain.ToolParameterTypeInteger, + Description: "返回条数。", + Minimum: aiFloatPtr(1), + Maximum: aiFloatPtr(2000), + Examples: []string{"500"}, + DefaultValue: "500", + }, }, - }, + }, aidomain.ToolGroupObservabilityMetrics, "查询运行时指标。", "用户要看任务执行、事件消费、outbox 或时长类运行时指标时使用。", "observability", "metrics", "runtime"), // policy 声明该工具只对超级管理员可见。 policy: newAISuperAdminOnlyPolicy(), + validate: func(_ context.Context, call aidomain.ToolCall, _ aidomain.ToolCallContext) error { + var args aiRuntimeMetricsArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return err + } + return aiValidateRuntimeMetricsArgs(args) + }, // call 负责鉴权并查询运行时指标。 call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { // 先解析指标查询参数。 @@ -1211,6 +1451,9 @@ func newAIQueryRuntimeMetricsTool( if err := decodeAIToolArgs(call, &args); err != nil { return aidomain.ToolResult{}, err } + if err := aiValidateRuntimeMetricsArgs(args); err != nil { + return aidomain.ToolResult{}, err + } // 观测类工具执行前统一校验超级管理员权限。 if err := requireAISuperAdmin(ctx, authorization, callCtx.Principal); err != nil { @@ -1265,24 +1508,66 @@ func newAIQueryObservabilityMetricsTool( ) *aiServiceTool { // HTTP 观测指标属于观测类工具,只允许超级管理员使用。 return &aiServiceTool{ - // spec 描述 HTTP 指标查询需要的时间窗口和过滤参数。 - spec: aidomain.ToolSpec{ + descriptor: newAIToolDescriptor(aidomain.ToolSpec{ Name: "query_observability_metrics", Description: "查询 HTTP 观测指标,仅超级管理员可用。", Parameters: []aidomain.ToolParameter{ - {Name: "granularity", Type: aidomain.ToolParameterTypeString, Description: "聚合粒度", Required: true}, - {Name: "start_at", Type: aidomain.ToolParameterTypeString, Description: "开始时间", Required: true}, - {Name: "end_at", Type: aidomain.ToolParameterTypeString, Description: "结束时间", Required: true}, - {Name: "service", Type: aidomain.ToolParameterTypeString, Description: "服务名"}, - {Name: "route_template", Type: aidomain.ToolParameterTypeString, Description: "路由模板"}, - {Name: "method", Type: aidomain.ToolParameterTypeString, Description: "HTTP 方法"}, - {Name: "status_class", Type: aidomain.ToolParameterTypeInteger, Description: "状态码段,例如 2 / 4 / 5"}, - {Name: "error_code", Type: aidomain.ToolParameterTypeString, Description: "错误码"}, - {Name: "limit", Type: aidomain.ToolParameterTypeInteger, Description: "返回条数"}, + { + Name: "granularity", + Type: aidomain.ToolParameterTypeString, + Description: "聚合粒度。", + Required: true, + Enum: []string{"1m", "5m", "1d", "1w"}, + Examples: []string{"1m"}, + }, + { + Name: "start_at", + Type: aidomain.ToolParameterTypeString, + Description: "开始时间。", + Required: true, + Format: aidomain.ToolParameterFormatRFC3339, + Examples: []string{"2026-04-24T09:20:00Z"}, + }, + { + Name: "end_at", + Type: aidomain.ToolParameterTypeString, + Description: "结束时间,必须大于 start_at。", + Required: true, + Format: aidomain.ToolParameterFormatRFC3339, + Examples: []string{"2026-04-24T10:20:00Z"}, + }, + {Name: "service", Type: aidomain.ToolParameterTypeString, Description: "服务名。", Examples: []string{"personal_assistant"}}, + {Name: "route_template", Type: aidomain.ToolParameterTypeString, Description: "路由模板。", Examples: []string{"/api/system/ai/conversations/:id/stream"}}, + {Name: "method", Type: aidomain.ToolParameterTypeString, Description: "HTTP 方法。", Examples: []string{"GET"}}, + { + Name: "status_class", + Type: aidomain.ToolParameterTypeInteger, + Description: "状态码段,例如 2 / 4 / 5。", + Minimum: aiFloatPtr(1), + Maximum: aiFloatPtr(5), + Examples: []string{"2"}, + }, + {Name: "error_code", Type: aidomain.ToolParameterTypeString, Description: "错误码。", Examples: []string{"10001"}}, + { + Name: "limit", + Type: aidomain.ToolParameterTypeInteger, + Description: "返回条数。", + Minimum: aiFloatPtr(1), + Maximum: aiFloatPtr(50000), + Examples: []string{"5000"}, + DefaultValue: "5000", + }, }, - }, + }, aidomain.ToolGroupObservabilityMetrics, "查询 HTTP 观测指标。", "用户要看 HTTP 请求量、时延、状态码等观测指标时使用。", "observability", "metrics", "http"), // policy 声明该工具只对超级管理员可见。 policy: newAISuperAdminOnlyPolicy(), + validate: func(_ context.Context, call aidomain.ToolCall, _ aidomain.ToolCallContext) error { + var args aiObservabilityMetricsArgs + if err := decodeAIToolArgs(call, &args); err != nil { + return err + } + return aiValidateObservabilityMetricsArgs(args) + }, // call 负责鉴权并查询 HTTP 观测指标。 call: func(ctx context.Context, call aidomain.ToolCall, callCtx aidomain.ToolCallContext) (aidomain.ToolResult, error) { // 先解析时间窗口和过滤参数。 @@ -1290,6 +1575,9 @@ func newAIQueryObservabilityMetricsTool( if err := decodeAIToolArgs(call, &args); err != nil { return aidomain.ToolResult{}, err } + if err := aiValidateObservabilityMetricsArgs(args); err != nil { + return aidomain.ToolResult{}, err + } // 执行前再次校验超级管理员权限。 if err := requireAISuperAdmin(ctx, authorization, callCtx.Principal); err != nil { @@ -1327,7 +1615,15 @@ func resolveAIOrgID(orgID *uint, currentOrgID *uint) (uint, error) { return *currentOrgID, nil } // 两者都没有时无法继续执行组织能力类工具。 - return 0, bizerrors.NewWithMsg(bizerrors.CodeInvalidParams, "缺少可用的 org_id") + return 0, aidomain.NewMissingUserInputError( + "缺少可用的 org_id,请先确认要查询的组织。", + aidomain.ToolFieldError{ + Field: "org_id", + Reason: "missing_required", + Expected: "大于 0 的组织 ID,或当前会话存在 current_org_id", + Example: "12", + }, + ) } // defaultString 在空字符串时返回兜底值。 diff --git a/internal/service/system/aitool/validation.go b/internal/service/system/aitool/validation.go new file mode 100644 index 0000000..2bb6f64 --- /dev/null +++ b/internal/service/system/aitool/validation.go @@ -0,0 +1,293 @@ +package aitool + +import ( + "fmt" + "strings" + "time" + + aidomain "personal_assistant/internal/domain/ai" +) + +func aiIntPtr(value int) *int { + return &value +} + +func aiFloatPtr(value float64) *float64 { + return &value +} + +func aiFirstExample(param aidomain.ToolParameter) string { + if len(param.Examples) == 0 { + return "" + } + return param.Examples[0] +} + +func aiMissingFieldError(param aidomain.ToolParameter, field string) aidomain.ToolFieldError { + return aidomain.ToolFieldError{ + Field: field, + Reason: "missing_required", + Expected: aiExpectedSummary(param), + Allowed: append([]string(nil), param.Enum...), + Example: aiFirstExample(param), + } +} + +func aiInvalidFieldError( + field string, + reason string, + expected string, + allowed []string, + example string, +) aidomain.ToolFieldError { + return aidomain.ToolFieldError{ + Field: field, + Reason: reason, + Expected: expected, + Allowed: append([]string(nil), allowed...), + Example: example, + } +} + +func aiExpectedSummary(param aidomain.ToolParameter) string { + parts := make([]string, 0, 6) + parts = append(parts, string(param.Type)) + if strings.TrimSpace(param.Format) != "" { + parts = append(parts, "format="+strings.TrimSpace(param.Format)) + } + if len(param.Enum) > 0 { + parts = append(parts, "enum="+strings.Join(param.Enum, "/")) + } + if param.Minimum != nil { + parts = append(parts, "min="+trimFloatForPrompt(*param.Minimum)) + } + if param.Maximum != nil { + parts = append(parts, "max="+trimFloatForPrompt(*param.Maximum)) + } + if param.MinLength != nil { + parts = append(parts, fmt.Sprintf("min_length=%d", *param.MinLength)) + } + if param.MaxLength != nil { + parts = append(parts, fmt.Sprintf("max_length=%d", *param.MaxLength)) + } + if param.MinItems != nil { + parts = append(parts, fmt.Sprintf("min_items=%d", *param.MinItems)) + } + if param.MaxItems != nil { + parts = append(parts, fmt.Sprintf("max_items=%d", *param.MaxItems)) + } + return strings.Join(parts, ", ") +} + +func trimFloatForPrompt(value float64) string { + if float64(int64(value)) == value { + return fmt.Sprintf("%d", int64(value)) + } + return fmt.Sprintf("%g", value) +} + +func aiParseRFC3339Field(field string, raw string, required bool, example string) (time.Time, error) { + value := strings.TrimSpace(raw) + if value == "" { + if required { + return time.Time{}, aidomain.NewMissingUserInputError( + field+" 缺失,无法继续执行。", + aidomain.ToolFieldError{ + Field: field, + Reason: "missing_required", + Expected: "RFC3339 时间", + Example: example, + }, + ) + } + return time.Time{}, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, aidomain.NewRepairableInvalidParamErrorWithCause( + field+" 必须是 RFC3339 时间。", + err, + aidomain.ToolFieldError{ + Field: field, + Reason: "invalid_format", + Expected: "RFC3339 时间", + Example: example, + }, + ) + } + return parsed.UTC(), nil +} + +func aiValidateOptionalTimeRange( + startField string, + startRaw string, + endField string, + endRaw string, +) error { + start, err := aiParseRFC3339Field(startField, startRaw, false, "2026-04-24T09:20:00Z") + if err != nil { + return err + } + end, err := aiParseRFC3339Field(endField, endRaw, false, "2026-04-24T10:20:00Z") + if err != nil { + return err + } + if !start.IsZero() && !end.IsZero() && !end.After(start) { + return aidomain.NewRepairableInvalidParamError( + endField+" 必须大于 "+startField+"。", + aidomain.ToolFieldError{ + Field: endField, + Reason: "invalid_range", + Expected: endField + " > " + startField, + Example: "2026-04-24T10:20:00Z", + }, + ) + } + return nil +} + +func aiValidateRequiredTimeRange(startField string, startRaw string, endField string, endRaw string) (time.Time, time.Time, error) { + start, err := aiParseRFC3339Field(startField, startRaw, true, "2026-04-24T09:20:00Z") + if err != nil { + return time.Time{}, time.Time{}, err + } + end, err := aiParseRFC3339Field(endField, endRaw, true, "2026-04-24T10:20:00Z") + if err != nil { + return time.Time{}, time.Time{}, err + } + if !end.After(start) { + return time.Time{}, time.Time{}, aidomain.NewRepairableInvalidParamError( + endField+" 必须大于 "+startField+"。", + aidomain.ToolFieldError{ + Field: endField, + Reason: "invalid_range", + Expected: endField + " > " + startField, + Example: "2026-04-24T10:20:00Z", + }, + ) + } + return start, end, nil +} + +func aiValidateObservabilityMetricsArgs(args aiObservabilityMetricsArgs) error { + _, _, err := aiValidateRequiredTimeRange("start_at", args.StartAt, "end_at", args.EndAt) + if err != nil { + return err + } + if args.StatusClass != 0 && (args.StatusClass < 1 || args.StatusClass > 5) { + return aidomain.NewRepairableInvalidParamError( + "status_class 仅支持 1~5 的状态码段。", + aidomain.ToolFieldError{ + Field: "status_class", + Reason: "out_of_range", + Expected: "1~5 之间的整数", + Example: "2", + }, + ) + } + return nil +} + +func aiValidateRuntimeMetricsArgs(args aiRuntimeMetricsArgs) error { + metric := strings.TrimSpace(args.Metric) + switch metric { + case "task_execution_total": + if _, _, err := aiValidateRequiredTimeRange("start_at", args.StartAt, "end_at", args.EndAt); err != nil { + return err + } + return aiValidateRuntimeStatus(strings.TrimSpace(args.Status), []string{"success", "error", "skipped"}) + case "task_duration_seconds": + start, end, err := aiValidateRequiredTimeRange("start_at", args.StartAt, "end_at", args.EndAt) + if err != nil { + return err + } + if end.Sub(start) > 7*24*time.Hour { + return aidomain.NewRepairableInvalidParamError( + "duration 查询时间范围不能超过 7 天。", + aidomain.ToolFieldError{ + Field: "end_at", + Reason: "invalid_range", + Expected: "与 start_at 的间隔不超过 7 天", + Example: "2026-04-24T10:20:00Z", + }, + ) + } + return aiValidateRuntimeStatus(strings.TrimSpace(args.Status), []string{"success", "error", "skipped"}) + case "outbox_publish_duration_seconds", "event_consume_duration_seconds": + start, end, err := aiValidateRequiredTimeRange("start_at", args.StartAt, "end_at", args.EndAt) + if err != nil { + return err + } + if end.Sub(start) > 7*24*time.Hour { + return aidomain.NewRepairableInvalidParamError( + "duration 查询时间范围不能超过 7 天。", + aidomain.ToolFieldError{ + Field: "end_at", + Reason: "invalid_range", + Expected: "与 start_at 的间隔不超过 7 天", + Example: "2026-04-24T10:20:00Z", + }, + ) + } + return aiValidateRuntimeStatus(strings.TrimSpace(args.Status), []string{"success", "error"}) + case "event_consume_total": + if _, _, err := aiValidateRequiredTimeRange("start_at", args.StartAt, "end_at", args.EndAt); err != nil { + return err + } + return aiValidateRuntimeStatus(strings.TrimSpace(args.Status), []string{"success", "error"}) + case "outbox_events_total": + status := strings.TrimSpace(strings.ToLower(args.Status)) + if err := aiValidateRuntimeStatus(status, []string{"pending", "published", "failed"}); err != nil { + return err + } + if status == "" || status == "pending" { + return nil + } + _, _, err := aiValidateRequiredTimeRange("start_at", args.StartAt, "end_at", args.EndAt) + return err + default: + return aidomain.NewRepairableInvalidParamError( + "metric 取值不合法。", + aidomain.ToolFieldError{ + Field: "metric", + Reason: "invalid_enum", + Expected: "task_execution_total/task_duration_seconds/outbox_events_total/outbox_publish_duration_seconds/event_consume_total/event_consume_duration_seconds", + Allowed: []string{ + "task_execution_total", + "task_duration_seconds", + "outbox_events_total", + "outbox_publish_duration_seconds", + "event_consume_total", + "event_consume_duration_seconds", + }, + Example: "task_execution_total", + }, + ) + } +} + +func aiValidateRuntimeStatus(status string, allowed []string) error { + status = strings.TrimSpace(strings.ToLower(status)) + if status == "" { + return nil + } + for _, item := range allowed { + if status == item { + return nil + } + } + return aidomain.NewRepairableInvalidParamError( + "status 取值不合法。", + aidomain.ToolFieldError{ + Field: "status", + Reason: "invalid_enum", + Expected: "仅支持指定状态枚举", + Allowed: allowed, + Example: allowed[0], + }, + ) +} + +func aiValidateTraceSummaryArgs(args aiTraceSummaryArgs) error { + return aiValidateOptionalTimeRange("start_at", args.StartAt, "end_at", args.EndAt) +} diff --git a/internal/service/system/supplier.go b/internal/service/system/supplier.go index 7f3f4bd..9cfa4d8 100644 --- a/internal/service/system/supplier.go +++ b/internal/service/system/supplier.go @@ -1,13 +1,19 @@ package system import ( + "context" "strings" "personal_assistant/global" + infraeino "personal_assistant/internal/infrastructure/ai/eino" obsquery "personal_assistant/internal/observability/query" obsdecorator "personal_assistant/internal/observability/trace/decorator" "personal_assistant/internal/repository" "personal_assistant/internal/service/contract" + "personal_assistant/internal/service/system/aiselect" + "personal_assistant/internal/service/system/aitool" + + "go.uber.org/zap" ) var defaultServiceTraceModules = []string{ @@ -81,12 +87,37 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { observabilitySvc = obsdecorator.WrapObservabilityService(observabilitySvc) } + var progressiveSelector aiselect.Selector + if global.Config != nil && strings.EqualFold(strings.TrimSpace(global.Config.SSE.AIRuntimeMode), "eino") { + selector, err := infraeino.NewProgressiveToolSelector(context.Background(), infraeino.Options{ + Provider: global.Config.AI.Provider, + APIKey: global.Config.AI.APIKey, + BaseURL: global.Config.AI.BaseURL, + Model: global.Config.AI.Model, + ByAzure: global.Config.AI.ByAzure, + APIVersion: global.Config.AI.APIVersion, + SystemPrompt: global.Config.AI.SystemPrompt, + Temperature: global.Config.AI.Temperature, + MaxCompletionTokens: global.Config.AI.MaxCompletionTokens, + }) + if err != nil { + if global.Log != nil { + global.Log.Warn("AI progressive selector 初始化失败,回退单阶段工具暴露", zap.Error(err)) + } + } else { + progressiveSelector = selector + } + } + // AIService 在这里注入 runtime 所需的最小依赖,让 tool 可见性和执行鉴权都走正式 Service。 rawAI := NewAIServiceWithRuntimeAndDeps(repositoryGroup, global.AIRuntime, AIDeps{ - Authorization: authorizationSvc, - OJ: ojSvc, - OJTask: ojTaskSvc, - Observability: observabilitySvc, + Tools: aitool.Deps{ + Authorization: authorizationSvc, + OJ: ojSvc, + OJTask: ojTaskSvc, + Observability: observabilitySvc, + }, + Selector: progressiveSelector, }) // 对外仍只暴露统一的 AIService 契约,不把具体 tool 依赖细节泄露到上层。 aiSvc := contract.AIServiceContract(rawAI) diff --git a/plan/ai/approved-ai-tool-registry-metadata-refactor.md b/plan/ai/approved-ai-tool-registry-metadata-refactor.md new file mode 100644 index 0000000..ad757c0 --- /dev/null +++ b/plan/ai/approved-ai-tool-registry-metadata-refactor.md @@ -0,0 +1,53 @@ +# 目标 + +- 收口 `internal/service/system/aiTool.go` 中与工具元数据和分组映射相关的重复真相,降低 `switch spec.Name` 带来的维护成本。 +- 在不改变现有工具协议、权限策略、运行时行为和渐进式选择结果的前提下,拆分 `aiTool.go` 的职责边界。 +- 让新增 AI tool 时优先在工具定义处一次性声明 `group / brief / policy / spec / validate / call`,避免后置补元数据。 + +# 范围 + +- `internal/service/system/aiTool.go` +- `internal/service/system/aiTool_test.go` +- 与 AI tool 渐进式元数据装配直接相关的 `internal/service/system/aiProgressive*.go` 测试或辅助代码 + +# 改动 + +- 将 AI tool 的分组和 brief 元数据从“按 `spec.Name` 二次推导”改为“在工具注册时直接声明”。 +- 拆分 `aiTool.go` 内当前混合的职责,至少把以下内容从单文件中收口: + - registry / visible filtering + - tool metadata / group metadata + - prompt helper + - 具体 tool 定义 +- 保留现有 `policy.Kind` 等有限枚举分发;不强行消灭所有 `switch`,只清理重复映射和单文件堆积。 +- 删除或缩减 `aiBuildToolMetadata` 这类基于工具名的后置映射逻辑,避免工具定义和元数据定义分离。 +- 对组级说明和工具 brief 改成更稳定的声明式结构,保证渐进式 selector 继续消费同样的数据形状。 +- 补齐或调整测试,覆盖: + - 可见工具元数据仍能正确分组 + - 按组展开与按名称展开行为不变 + - 未引入新的权限可见性回归 + +# 验证 + +- 运行 AI tool 相关单测,至少覆盖: + - `go test ./internal/service/system -run TestAIToolRegistry` + - 必要时补充 `aiProgressive` 相关测试 +- 若结构拆分涉及编译依赖,执行最小必要的包级编译或测试,确认无循环依赖和无行为回归。 + +# 风险 + +- 元数据迁移过程中若有遗漏,可能导致某些 tool 在 progressive selector 中分组错误或 brief 缺失。 +- 若拆分文件时误改初始化顺序,可能影响 registry 构建结果。 +- 当前 `aiTool.go` 中工具实现较多,若一次拆分过大,review 成本会升高;需要控制为“结构收口,不扩功能”。 + +# 执行顺序 + +1. 盘点 `aiTool.go` 中 registry、metadata、prompt、tool implementation 四类职责的边界。 +2. 设计新的声明式元数据承载方式,确保每个 tool 在定义时就带齐 metadata。 +3. 拆出 registry / metadata / prompt 辅助代码,缩减主文件体积。 +4. 迁移具体 tool 的 metadata 装配逻辑,删除后置 `switch spec.Name` 映射。 +5. 运行并补齐相关测试,确认行为保持一致。 + +# 待确认 + +- 默认本次只做结构重构,不调整 tool 名称、参数协议、权限模型和 selector 策略。 +- 默认不把 AI tool 整体下沉到 `internal/domain/ai`,继续保持 `service/system` 作为应用编排与工具注册入口。 From dd60817e1a2e2149c3efd7d7d82334b8ca4115dd Mon Sep 17 00:00:00 2001 From: wang Date: Sun, 26 Apr 2026 12:58:16 +0800 Subject: [PATCH 14/17] =?UTF-8?q?rag=E5=88=87=E7=89=87=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...73\347\273\223\351\252\250\346\236\266.md" | 390 +++++++++++ ...3\255\346\224\271\350\277\233\347\202\271" | 26 + ...5\346\224\271\350\277\233\347\202\2712.md" | 265 ++++++++ ...5\277\206-two-\345\206\273\347\273\223.md" | 67 ++ ...41\345\235\227\350\256\276\350\256\241.md" | 560 ++++------------ ...76\350\256\241\346\265\201\347\250\213.md" | 49 ++ flag/flagSql.go | 4 + internal/core/config.go | 42 ++ internal/core/config_memory_test.go | 77 +++ internal/core/qdrant.go | 32 +- internal/domain/ai/memory.go | 152 +++++ internal/domain/ai/memory_policy.go | 207 ++++++ internal/domain/ai/memory_rag.go | 80 +++ internal/domain/ai/memory_writeback.go | 38 ++ internal/infrastructure/ai/memory/chunker.go | 226 +++++++ .../infrastructure/ai/memory/chunker_test.go | 57 ++ internal/infrastructure/ai/memory/embedder.go | 187 ++++++ .../infrastructure/ai/memory/embedder_test.go | 74 +++ .../infrastructure/ai/memory/extractor.go | 257 +++++++ .../ai/memory/extractor_test.go | 113 ++++ .../infrastructure/ai/memory/qdrant_store.go | 115 ++++ .../ai/memory/qdrant_store_test.go | 80 +++ internal/model/config/ai.go | 74 ++- internal/model/config/config.go | 78 ++- internal/model/config/qdrant.go | 6 + .../model/entity/ai_conversation_summary.go | 43 ++ internal/model/entity/ai_memory_document.go | 77 +++ .../model/entity/ai_memory_document_chunk.go | 50 ++ internal/model/entity/ai_memory_fact.go | 54 ++ .../interfaces/aiMemoryRepository.go | 37 ++ internal/repository/system/aiMemoryRepo.go | 468 +++++++++++++ .../repository/system/aiMemoryRepo_test.go | 603 +++++++++++++++++ internal/repository/system/supplier.go | 5 + internal/repository/system/supplierImpl.go | 6 + internal/service/system/aiDeps.go | 7 + internal/service/system/aiMemoryHook.go | 63 ++ internal/service/system/aiMemoryIndex.go | 170 +++++ internal/service/system/aiMemoryIndex_test.go | 213 ++++++ internal/service/system/aiMemoryPolicy.go | 627 ++++++++++++++++++ .../service/system/aiMemoryPolicy_test.go | 213 ++++++ internal/service/system/aiMemoryRecall.go | 275 ++++++++ .../service/system/aiMemoryRecall_test.go | 262 ++++++++ internal/service/system/aiMemorySvc.go | 125 ++++ internal/service/system/aiMemoryWriteback.go | 440 ++++++++++++ .../service/system/aiMemoryWriteback_test.go | 283 ++++++++ internal/service/system/aiSvc.go | 12 +- internal/service/system/supplier.go | 6 +- plan/ai/approved-memory-context-recovery.md | 60 ++ ...proved-memory-governance-implementation.md | 89 +++ .../approved-memory-module-phase1-freeze.md | 58 ++ plan/ai/approved-memory-rag-indexing.md | 34 + plan/ai/approved-memory-writeback-hook.md | 42 ++ 52 files changed, 7107 insertions(+), 471 deletions(-) create mode 100644 "docs/AI/\350\256\260\345\277\206-one-\345\206\273\347\273\223\351\252\250\346\236\266.md" create mode 100644 "docs/AI/\350\256\260\345\277\206-three-\345\220\216\347\273\255\346\224\271\350\277\233\347\202\271" create mode 100644 "docs/AI/\350\256\260\345\277\206-three-\345\220\216\347\273\255\346\224\271\350\277\233\347\202\2712.md" create mode 100644 "docs/AI/\350\256\260\345\277\206-two-\345\206\273\347\273\223.md" create mode 100644 "docs/AI/\350\256\276\350\256\241\346\265\201\347\250\213.md" create mode 100644 internal/core/config_memory_test.go create mode 100644 internal/domain/ai/memory.go create mode 100644 internal/domain/ai/memory_policy.go create mode 100644 internal/domain/ai/memory_rag.go create mode 100644 internal/domain/ai/memory_writeback.go create mode 100644 internal/infrastructure/ai/memory/chunker.go create mode 100644 internal/infrastructure/ai/memory/chunker_test.go create mode 100644 internal/infrastructure/ai/memory/embedder.go create mode 100644 internal/infrastructure/ai/memory/embedder_test.go create mode 100644 internal/infrastructure/ai/memory/extractor.go create mode 100644 internal/infrastructure/ai/memory/extractor_test.go create mode 100644 internal/infrastructure/ai/memory/qdrant_store.go create mode 100644 internal/infrastructure/ai/memory/qdrant_store_test.go create mode 100644 internal/model/entity/ai_conversation_summary.go create mode 100644 internal/model/entity/ai_memory_document.go create mode 100644 internal/model/entity/ai_memory_document_chunk.go create mode 100644 internal/model/entity/ai_memory_fact.go create mode 100644 internal/repository/interfaces/aiMemoryRepository.go create mode 100644 internal/repository/system/aiMemoryRepo.go create mode 100644 internal/repository/system/aiMemoryRepo_test.go create mode 100644 internal/service/system/aiMemoryHook.go create mode 100644 internal/service/system/aiMemoryIndex.go create mode 100644 internal/service/system/aiMemoryIndex_test.go create mode 100644 internal/service/system/aiMemoryPolicy.go create mode 100644 internal/service/system/aiMemoryPolicy_test.go create mode 100644 internal/service/system/aiMemoryRecall.go create mode 100644 internal/service/system/aiMemoryRecall_test.go create mode 100644 internal/service/system/aiMemorySvc.go create mode 100644 internal/service/system/aiMemoryWriteback.go create mode 100644 internal/service/system/aiMemoryWriteback_test.go create mode 100644 plan/ai/approved-memory-context-recovery.md create mode 100644 plan/ai/approved-memory-governance-implementation.md create mode 100644 plan/ai/approved-memory-module-phase1-freeze.md create mode 100644 plan/ai/approved-memory-rag-indexing.md create mode 100644 plan/ai/approved-memory-writeback-hook.md diff --git "a/docs/AI/\350\256\260\345\277\206-one-\345\206\273\347\273\223\351\252\250\346\236\266.md" "b/docs/AI/\350\256\260\345\277\206-one-\345\206\273\347\273\223\351\252\250\346\236\266.md" new file mode 100644 index 0000000..66d3516 --- /dev/null +++ "b/docs/AI/\350\256\260\345\277\206-one-\345\206\273\347\273\223\351\252\250\346\236\266.md" @@ -0,0 +1,390 @@ +可以。我按“为什么这么分层”来讲,不只说文件名,也说每层承担什么职责、重要字段为什么这样设计、函数为什么这样写。 + +**整体设计** +这次我做的不是“完整记忆系统”,而是 **Phase 1 冻结骨架**。目标只有两个: + +- 先把记忆系统的 **稳定语义** 定下来:`facts / documents / conversation_summary`、`scope / visibility`、配置口径、Repository/Service 契约。 +- 先把 **正式落库能力** 建好,但 **不接入当前 AI 主链路**,避免现在就改变对话行为。 + +所以你会看到这版代码的特点是: + +- `domain` 先定义语义和 helper。 +- `entity` 先定义三张表。 +- `repository` 先把读写规则落稳。 +- `service` 只放骨架,不真正 recall/writeback。 +- `config` 和 `migration` 先接好,保证后面阶段能直接接力。 + +--- + +**1. 目录结构为什么这么拆** +这次新增主要落在这几个目录: + +- [internal/domain/ai/memory.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:9) +作用:定义“记忆是什么”的语义层,不依赖 GORM、Gin、Qdrant。 +- [internal/model/entity/ai_memory_fact.go](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:6) +作用:定义 `fact` 表结构。 +- [internal/model/entity/ai_memory_document.go](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:10) +作用:定义 `document` 表结构。 +- [internal/model/entity/ai_conversation_summary.go](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_conversation_summary.go:6) +作用:定义 `conversation summary` 表结构。 +- [internal/repository/interfaces/aiMemoryRepository.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/interfaces/aiMemoryRepository.go:11) +作用:冻结 memory 仓储接口。 +- [internal/repository/system/aiMemoryRepo.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:17) +作用:GORM 版 memory repository 实现。 +- [internal/service/system/aiMemorySvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:28) +作用:记忆服务骨架,后续 recall/writeback 从这里长出来。 +- [internal/service/system/aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:4) +作用:给下一步“记忆治理”预留固定位置。 +- [internal/model/config/ai.go](/D:/workspace_go/test/go/personal_assistant/internal/model/config/ai.go:4) +作用:冻结 `AI.Memory` 配置结构。 +- [internal/model/config/qdrant.go](/D:/workspace_go/test/go/personal_assistant/internal/model/config/qdrant.go:9) +作用:冻结 Qdrant 的 knowledge/memory collection 配置。 +- [internal/model/config/config.go](/D:/workspace_go/test/go/personal_assistant/internal/model/config/config.go:330) +作用:把 Viper 配置真正读到结构体。 +- [internal/core/config.go](/D:/workspace_go/test/go/personal_assistant/internal/core/config.go:65) +作用:默认值和 env binding。 +- [internal/repository/system/supplier.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/supplier.go:13) +作用:把 memory repo 接进系统 supplier。 +- [internal/repository/system/supplierImpl.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/supplierImpl.go:63) +作用:提供 `GetAIMemoryRepository()`。 +- [flag/flagSql.go](/D:/workspace_go/test/go/personal_assistant/flag/flagSql.go:30) +作用:把三张新表纳入 AutoMigrate。 +- [internal/core/config_memory_test.go](/D:/workspace_go/test/go/personal_assistant/internal/core/config_memory_test.go:12) +作用:验证配置和兼容口径。 +- [internal/repository/system/aiMemoryRepo_test.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo_test.go:17) +作用:验证 repository 语义。 + +这套拆法的核心原因是: + +- `domain` 负责稳定语义。 +- `entity` 负责落库形状。 +- `repository` 负责持久化规则。 +- `service` 负责未来业务编排。 +- `config` 负责运行时开关。 +- `flag` 负责建表。 + +这样后面你做 `治理 / writeback / 压缩恢复 / RAG`,不会再反复改层次。 + +--- + +**2. domain 层是怎么设计的** +[internal/domain/ai/memory.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:9) 这一个文件,承担了 4 件事。 + +第一,冻结枚举语义: + +- `MemoryScopeType` 在 [memory.go:9](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:9) +- `MemoryType` 在 [memory.go:18](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:18) +- `MemoryVisibility` 在 [memory.go:31](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:31) + +我这里把 `scope` 和 `visibility` 明确拆开,是因为它们不是一回事: + +- `scope` 解决“这条记忆属于谁” +- `visibility` 解决“谁可以读它” + +这是你后面做权限过滤时最重要的基础。 + +第二,冻结业务 namespace 常量。 +同文件里我把 `user_preference / oj_profile / oj_goal / org_profile / org_learning_pattern / ops_incident / ops_runbook` 固定下来。这样后面写入和召回不会到处散落魔法字符串。 + +第三,统一生成 `scope_key`。 +核心函数是: + +- [BuildMemoryScopeKey](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:64) +- [BuildConversationMemoryScopeKey](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:84) + +这里我刻意不让业务代码自己拼 `"self:user:123"`,而是统一走 helper。原因很直接: + +- 防止格式漂移 +- 防止后面清理、查询、权限判断时出现多种 key 口径 +- 让 summary/fact/document 共用同一套 scope 规则 + +第四,定义 query struct: + +- [MemoryFactQuery](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:93) +- [MemoryDocumentQuery](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:102) + +我不用一长串参数,而用 query struct,是为了给后面“治理、混合召回、排序、分页、过滤扩展”留空间,不然 Repository 很快就会签名爆炸。 + +--- + +**3. 三个实体为什么这样分** +**AIMemoryFact** +定义在 [ai_memory_fact.go](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:6)。 + +它表示“结构化、稳定、可覆盖”的事实。最重要的字段是: + +- `ScopeKey` [line 8](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:8) +表示归属范围。 +- `ScopeType` [line 9](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:9) +保存枚举值,避免每次靠解析 `scope_key` 字符串判断类型。 +- `Visibility` [line 10](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:10) +表示访问等级。 +- `UserID` / `OrgID` +虽然 `scope_key` 已经能表达归属,但我还是保留这两个字段,目的是方便权限校验、清理、调试和后台查询。 +- `Namespace` [line 13](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:13) +表示它属于哪个业务域。 +- `FactKey` [line 14](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:14) +表示这个 namespace 下的具体键。 +- `FactValueJSON` [line 15](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:15) +事实值本体。 +- `Summary` [line 16](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:16) +给模型和调试看的可读摘要。 +- `Confidence` [line 17](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:17) +后面治理会用它判断是否允许写入。 +- `SourceKind` / `SourceID` [line 18-19](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:18) +保存来源证据链。 +- `EffectiveAt` / `ExpiresAt` [line 20-21](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_fact.go:20) +后面做新鲜度和过期策略靠它。 + +为什么 `Fact` 不做软删除? +因为你这一步已经定了:它是“唯一键覆盖更新”的模型。`fact` 更像当前有效状态,不像历史文档,所以我保留唯一键覆盖,不引入软删除复杂度。 + +**AIMemoryDocument** +定义在 [ai_memory_document.go](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:10)。 + +它表示“文本型、可召回”的长期记忆。最重要字段: + +- `MemoryType` [line 17](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:17) +区分 `semantic / episodic / procedural / incident / faq` +- `Topic` [line 18](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:18) +给轻量主题过滤和排序用 +- `Title` / `Summary` / `ContentText` [line 19-21](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:19) +这是召回文本的三层表达 +- `Importance` / `QualityScore` [line 22-23](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:22) +后面召回排序和治理会用 +- `EmbeddingModel` [line 24](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:24) +记录向量模型 +- `QdrantPointID` [line 25](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:25) +你要求它只是单 chunk 兼容字段,所以我只保留,不扩 chunk 表 +- `DeletedAt` [line 32](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_memory_document.go:32) +这里保留软删除,因为 document 天然更像“可归档文本”,后面人工失效、治理清理都会更自然。 + +**AIConversationSummary** +定义在 [ai_conversation_summary.go](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_conversation_summary.go:6)。 + +它表示“当前会话的压缩结果”,不是长期知识。关键字段: + +- `ConversationID` [line 7](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_conversation_summary.go:7) +直接做主键,因为一个会话只保留一份当前摘要。 +- `UserID` / `OrgID` / `ScopeKey` [line 8-10](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_conversation_summary.go:8) +这是你特别要求增加的,我也认为很对。后面权限校验、后台清理、调试都需要。 +- `CompressedUntilMessageID` [line 11](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_conversation_summary.go:11) +这个字段非常关键,它定义了“摘要已经覆盖到哪里”。 +- `SummaryText` [line 12](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_conversation_summary.go:12) +给模型读的主体摘要。 +- `KeyPointsJSON` / `OpenLoopsJSON` [line 13-14](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_conversation_summary.go:13) +后面恢复上下文时会非常有用,一个表示关键结论,一个表示未完成事项。 +- `TokenEstimate` [line 15](/D:/workspace_go/test/go/personal_assistant/internal/model/entity/ai_conversation_summary.go:15) +后面调优压缩效果要靠它。 + +--- + +**4. Repository 为什么这样写** +接口先定义在 [aiMemoryRepository.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/interfaces/aiMemoryRepository.go:11)。 + +这一层我只冻结最小能力: + +- `WithTx` +- `UpsertFact` +- `ListFacts` +- `BatchUpsertDocuments` +- `ListDocuments` +- `GetConversationSummary` +- `UpsertConversationSummary` + +这套接口的思路很明确: + +- `Fact` 是单条覆盖更新 +- `Document` 未来一般是批量写入 +- `Summary` 是按会话读写 + +具体实现都在 [aiMemoryRepo.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:17)。 + +几个核心函数: + +- [NewAIMemoryRepository](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:22) +标准构造。 +- [WithTx](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:27) +为什么参数是 `any`? +因为你现有仓库事务接口就是 `Group.InTx(ctx, func(tx any) error)` 这一套,我这里跟现有风格保持一致,避免 memory 成为特例。 +- [UpsertFact](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:35) +这里我用了 `OnConflict`,冲突列就是 `scope_key + namespace + fact_key`。这和你冻结的“唯一键覆盖更新”完全一致。 +- [ListFacts](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:66) +默认过滤 `expires_at`,并要求必须传 `ScopeKeys + AllowedVisibilities`。这是我在代码层落实“查询必须同时校验 scope 和 visibility”。 +- [BatchUpsertDocuments](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:96) +这里按 `id` 做 upsert,并且把 `deleted_at = nil`,意思是文档如果同 ID 被重新写入,会自动“恢复生效”。 +- [ListDocuments](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:135) +默认依赖 GORM 软删除过滤,同时再过滤过期数据。 +- [GetConversationSummary](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:165) +按 `conversation_id` 读。 +- [UpsertConversationSummary](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:180) +按 `conversation_id` 覆盖更新,符合“每个会话只有一份当前摘要”的设计。 + +--- + +**5. Service 为什么只做骨架** +[aiMemorySvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:28) 现在是“冻结服务边界”,不是完整业务逻辑。 + +关键点: + +- [AIMemoryService](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:28) +只持有 `repo + outboxRepo + policy` +- [NewAIMemoryService](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:35) +不是自己 `new repo`,而是从正式的 `repository.Group.SystemRepositorySupplier` 取,这符合你项目的依赖组织方式。 +- [Recall](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:47) +- [OnTurnCompleted](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:54) +- [RefreshConversationSummary](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:61) + +这 3 个现在都返回 `errAIMemoryPhase1NotImplemented`,是故意的。因为这版不能改当前 AI 行为,只能冻结接口。 + +为什么方法签名这样定? +因为它们要对齐你现有 AI 接入点: + +- recall 输入已经在 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:11) 里有 `aiMemoryRecallInput` +- 未来读侧接入点在 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:78) +- future writeback 收尾点在 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:436) + +也就是说,这个 skeleton 不是空想,它是按你现有 AI 主链路预留出来的。 + +目前真正可用的是两个 pass-through: + +- [UpsertFact](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:70) +- [ScheduleDocumentUpsert](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:78) + +它们先让后续阶段有地方接入,但现在不改变主逻辑。 + +[aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:4) 现在只放了空壳,也是故意的:下一步“记忆治理”就从这里长,不会再另起炉灶。 + +--- + +**6. 配置层为什么这么写** +配置分 3 层: + +- 结构体定义 +- Viper 读取 +- 默认值和 env 绑定 + +`AI.Memory` 结构体在 [ai.go](/D:/workspace_go/test/go/personal_assistant/internal/model/config/ai.go:18)。 + +这些字段本质上是在冻结后续能力边界: + +- `RecallTopK` +- `RecallMaxChars` +- `RecentRawTurns` +- `CompressThresholdTokens` +- `SummaryRefreshEveryTurns` +- `WritebackAsync` +- `EnableEntityMemory` +- `EnableLongTermMemory` +- `EnableOrgMemory` +- `EnableOpsMemory` +- `MinImportance` +- `EmbedModel` + +意思是:后面你做 recall、压缩、writeback、RAG 时,不要再临时硬编码行为。 + +Qdrant 结构体在 [qdrant.go](/D:/workspace_go/test/go/personal_assistant/internal/model/config/qdrant.go:9)。 + +这里最关键的是: + +- `CollectionName` [line 17](/D:/workspace_go/test/go/personal_assistant/internal/model/config/qdrant.go:17) +保留旧口径兼容 +- `KnowledgeCollectionName` [line 20](/D:/workspace_go/test/go/personal_assistant/internal/model/config/qdrant.go:20) +新知识库 collection +- `MemoryCollectionName` [line 23](/D:/workspace_go/test/go/personal_assistant/internal/model/config/qdrant.go:23) +新记忆 collection,但这一阶段只保留配置位,不初始化 + +真正的兼容逻辑在 [config.go](/D:/workspace_go/test/go/personal_assistant/internal/model/config/config.go:346)。 + +这里我专门做了这件事: + +- 如果显式设置了 `qdrant.knowledge_collection_name`,优先用新键 +- 如果没有显式设置,就回退到旧 `qdrant.collection_name` + +这个细节是必须的,不然你一旦给新键设默认值,旧键兼容就失效了。这个兼容判断在 [hasExplicitConfigValue](/D:/workspace_go/test/go/personal_assistant/internal/model/config/config.go:476)。 + +默认值和 env 绑定都在 [internal/core/config.go](/D:/workspace_go/test/go/personal_assistant/internal/core/config.go:65)。 + +你能看到: + +- 默认值从 [line 65](/D:/workspace_go/test/go/personal_assistant/internal/core/config.go:65) 开始 +- env binding 从 [line 280](/D:/workspace_go/test/go/personal_assistant/internal/core/config.go:280) 开始 + +这样后面切环境时不需要改代码。 + +--- + +**7. supplier 和 migration 为什么要改** +supplier 的修改很简单,但很重要。 + +- 接口扩展在 [supplier.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/supplier.go:13) +- 创建 repo 在 [supplier.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/supplier.go:79) +- 存进 supplier struct 在 [supplier.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/supplier.go:150) +- getter 在 [supplierImpl.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/supplierImpl.go:64) + +这么做的原因只有一个: + +**memory repo 必须成为正式系统依赖,而不是临时自己 new 的旁路对象。** + +否则后面事务、测试、依赖注入都会不统一。 + +迁移入口在 [flagSql.go](/D:/workspace_go/test/go/personal_assistant/flag/flagSql.go:30)。 + +这里我只做了一件事:把 3 个模型放进 AutoMigrate。因为这一阶段目标是“正式落库”,不是单独写一套 migration runner。 + +--- + +**8. 测试怎么覆盖的** +配置测试在 [config_memory_test.go](/D:/workspace_go/test/go/personal_assistant/internal/core/config_memory_test.go:12)。 + +它主要验证两件事: + +- `AI.Memory` 的 env binding 生效 +- `qdrant.collection_name` 到 `knowledge_collection_name` 的兼容回退生效 + +repository 测试在 [aiMemoryRepo_test.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo_test.go:17)。 + +它覆盖了 5 件事: + +- 三张表和关键索引能迁出来 +- `Fact` 能按唯一键覆盖更新 +- `ListFacts` 默认过滤过期数据 +- `Document` 默认过滤过期和软删除 +- `ConversationSummary` 能按 `conversation_id` 覆盖更新,并保留 `user_id/org_id/scope_key` + +这组测试其实就是在保护你这次冻结下来的规则。 + +--- + +**9. 这版刻意没做什么** +这部分也很重要,不然容易误解为“记忆系统已经完成”。 + +这版没做: + +- 没把 memory 接到 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:78) 的正式 recall +- 没把 memory 接到 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:436) 的正式 writeback +- 没做记忆治理规则 +- 没做 document chunk 表 +- 没做 embedding / Qdrant upsert +- 没做混合召回 +- 没做 admin API / delete API + +所以这版的准确定位是: + +**把“记忆系统的地基”浇筑好,而不是把整栋楼盖完。** + +--- + +**10. 你接下来怎么看这套代码** +如果你要理解后续开发顺序,可以直接按这条链看: + +1. 先看 [memory.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory.go:9),理解语义和 scope/visibility 口径 +2. 再看三个 entity,理解三张表各自装什么 +3. 再看 [aiMemoryRepository.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/interfaces/aiMemoryRepository.go:11) 和 [aiMemoryRepo.go](/D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:22),理解落库规则 +4. 再看 [aiMemorySvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:28),理解后续 recall/writeback 会从哪里接 +5. 最后看测试,理解我真正冻结了哪些行为 + +如果你要,我下一条可以继续按同样方式,专门给你讲: + +- `AIMemoryFact / AIMemoryDocument / AIConversationSummary` 这 3 张表该怎么在你业务里使用 +- 或者直接讲下一步 `记忆治理` 应该怎么接到这套骨架上 \ No newline at end of file diff --git "a/docs/AI/\350\256\260\345\277\206-three-\345\220\216\347\273\255\346\224\271\350\277\233\347\202\271" "b/docs/AI/\350\256\260\345\277\206-three-\345\220\216\347\273\255\346\224\271\350\277\233\347\202\271" new file mode 100644 index 0000000..db8c5fc --- /dev/null +++ "b/docs/AI/\350\256\260\345\277\206-three-\345\220\216\347\273\255\346\224\271\350\277\233\347\202\271" @@ -0,0 +1,26 @@ +整体评价:这次改动是一个**合格的 v1 写回骨架**。它把第 3 步从“设计阶段”推进到了“可运行的生产链路雏形”:成功轮次触发、规则抽取、policy 治理、MySQL 写入、测试覆盖都已经接上。 + +**优点** +- 分层比较干净:`domain/ai` 定义抽取协议,`infrastructure/ai/memory` 放规则抽取器,`service/system` 负责编排写回,没有把 GORM/Gin/Eino 混进 domain。见 [memory_writeback.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory_writeback.go:5)、[extractor.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/extractor.go:18)。 +- hook 时机合理:只在 `finishStream` 成功且 `execErr == nil` 后触发,失败、取消、超时不会污染长期记忆。见 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:365)。 +- 写回失败不影响主回答:异步模式带 timeout,错误只记日志,这是辅助记忆链路应有的降级语义。见 [aiMemoryHook.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryHook.go:36)。 +- 复用了已有治理规则:facts/documents 写入前都走 `aiMemoryPolicy`,没有绕过治理直接落库。见 [aiMemoryWriteback.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryWriteback.go:144)。 +- v1 抽取策略保守,降低脏数据风险:只抽明确表达的个人偏好/目标,以及知识型长回答 document。见 [extractor.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/extractor.go:102)。 +- 测试覆盖了关键路径,并且 `go test ./...` 已通过。 + +**缺点 / 风险** +- summary 现在只是“旧摘要 + 最近一轮”的截断拼接,不是真正语义压缩;当旧摘要接近上限时,新一轮内容可能被截掉,因为当前截断保留的是前半段。见 [extractor.go](/D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/extractor.go:78)。 +- 异步写回是 goroutine,不是 durable outbox;进程退出、重启或 panic 时,本轮记忆可能丢失。对 v1 可以接受,但不是可靠写回。 +- `buildWritebackSnapshot` 当前读取整段会话消息再按 ID 找两条,长会话下成本会升高。见 [aiMemoryWriteback.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryWriteback.go:68)。 +- 规则抽取器能力有限,容易少写,也可能误抓表达。例如复杂偏好、多事实、多语言混合、否定句、撤销记忆都还处理不了。 +- `MinImportance`、extractor 阈值、writeback timeout 没完全配置化;`aiMemoryWritebackTimeout = 10s` 仍是代码常量。见 [aiMemoryHook.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryHook.go:14)。 +- policy 决策被拒绝时没有记录 reason,后续排查“为什么没写入记忆”会比较困难。 +- summary 在 org 会话下会使用 org scope,但当前 v1 的长期自动写入策略又限定 self memory;这里需要在第 4 步恢复前明确“会话 summary 是否可 org scope 共享”。 + +**后续改进点** +1. 优先修 summary 策略:改成保留最新轮次,或引入真正 compressor,避免新信息被旧摘要挤掉。 +2. 增加 `GetMessageByID/ListMessagesByIDs`,writeback 只读本轮 user/assistant 消息,避免长会话全量扫描。 +3. 给 writeback 增加观测:记录 extracted/skipped/written 数量和 policy reason,便于调试。 +4. 把 timeout、document 最小长度、summary 字符上限、importance 阈值接到配置。 +5. 后续如果要求可靠写回,把 async goroutine 改成 outbox 事件,再由 worker 消费。 +6. 第 4 步上下文恢复前,先明确 summary 的权限语义:个人会话 summary 用 self scope;组织会话 summary 是否仍只服务本人,还是作为 org 共享记忆。 \ No newline at end of file diff --git "a/docs/AI/\350\256\260\345\277\206-three-\345\220\216\347\273\255\346\224\271\350\277\233\347\202\2712.md" "b/docs/AI/\350\256\260\345\277\206-three-\345\220\216\347\273\255\346\224\271\350\277\233\347\202\2712.md" new file mode 100644 index 0000000..bb44752 --- /dev/null +++ "b/docs/AI/\350\256\260\345\277\206-three-\345\220\216\347\273\255\346\224\271\350\277\233\347\202\2712.md" @@ -0,0 +1,265 @@ +后续优化点,核心就是把现在的 **“能用版上下文压缩”**,升级成 **“更准、更稳、更可观测、更省 token 的成熟版”**。 + +现在第 4 步的压缩方式是: + +```text +memory message + recent turns +``` + +也就是: + +```text +会话摘要 + 用户 facts + 最近几轮原始消息 +``` + +这个方案能用,但比较粗。后续可以从 7 个方向优化。 + +--- + +## 1. 引入真正的 LLM Compressor + +现在的 summary 主要来自第 3 步 writeback 的规则版摘要,偏“拼接 + 截断”。 + +后续应该做成: + +```text +旧 summary + 最近若干轮消息 +-> LLM 重新压缩 +-> 新 summary / key_points / open_loops +``` + +也就是让 LLM 真正理解: + +```text +哪些是已确定结论 +哪些是用户当前目标 +哪些是未完成问题 +哪些旧内容可以删掉 +哪些新内容必须保留 +``` + +现在是“机械压缩”,后续是“语义压缩”。 + +--- + +## 2. summary 要优先保留最新信息 + +你之前也发现了一个问题: + +```text +旧 summary + 最近一轮 +-> 从前往后截断 +``` + +如果旧 summary 很长,新一轮内容可能被截掉。 + +后续应该改成: + +```text +最新内容优先 +旧 summary 可被压缩或裁剪 +``` + +更合理的策略是: + +```text +recent facts / open loops / latest decisions 优先保留 +老旧背景信息降权 +``` + +否则 AI 会记住很早以前的东西,反而忘了刚刚聊到哪里。 + +--- + +## 3. recent turns 不要只按轮数,也要按 token 预算 + +现在是: + +```text +RecentRawTurns = 8 +``` + +这个简单,但不精确。 + +因为有些一轮很短,有些一轮特别长。 + +后续更成熟的做法是: + +```text +先保留最近 N 轮 +再按 token budget 裁剪 +``` + +或者: + +```text +recent turns 最多占 3000 tokens +memory message 最多占 1500 tokens +当前 query 永远保留 +``` + +也就是从“按轮数”升级成“按 token 预算”。 + +--- + +## 4. facts 要做优先级排序 + +现在 facts 可能只是按更新时间/limit 读取。 + +后续应该按重要性排序,比如: + +```text +用户显式偏好 > 当前目标 > 最近学习画像 > 普通历史偏好 +``` + +还可以结合: + +```text +source_kind +confidence +updated_at +namespace +expires_at +``` + +比如当前用户问的是面试,那就优先放: + +```text +user_preference +current_goal +interview_related_profile +``` + +不相关的 fact 可以不带。 + +否则 facts 多了以后,也会挤占上下文。 + +--- + +## 5. synthetic memory message 最好不要长期用 assistant role + +现在计划里说: + +```text +synthetic memory message 暂用 assistant role +``` + +这可以临时用,但不完美。 + +因为这条 message 不是 assistant 真正说过的话,它更像系统背景资料。 + +后续如果 runtime 支持,最好改成: + +```text +system / developer / memory / context +``` + +类似: + +```text +system memory block +``` + +这样模型更容易理解: + +> 这是系统恢复的背景,不是普通聊天历史。 + +--- + +## 6. 增加可观测和调试信息 + +后续一定要能看到: + +```text +本轮是否触发压缩 +原始历史 token 多少 +压缩后 token 多少 +保留了几轮 recent turns +带入了哪些 facts +带入了哪条 summary +哪些内容被裁掉 +``` + +否则你会很难排查: + +```text +为什么 AI 忘了前面内容? +为什么某个用户偏好没生效? +为什么上下文还是很长? +为什么 summary 没更新? +``` + +可以记录成 trace: + +```text +memory_recall_started +memory_summary_loaded +memory_facts_loaded +context_compressed +context_compress_skipped +memory_prompt_built +``` + +这对面试和线上排查都很加分。 + +--- + +## 7. 后续接入 RAG / documents + +第 4 步只恢复: + +```text +summary + facts +``` + +还不读: + +```text +documents / Qdrant / RAG +``` + +后续第 5、6 步可以把 `AIMemoryDocument` 做成: + +```text +document -> chunk -> embedding -> Qdrant +``` + +然后第 7 步混合召回时,完整上下文来源会变成: + +```text +conversation summary ++ stable facts ++ recent turns ++ RAG documents ++ realtime tools +``` + +这时候才是比较完整的 memory retrieval。 + +--- + +## 最重要的优化顺序 + +我建议你按这个顺序做: + +```text +1. 修 summary 策略,避免新内容被旧摘要挤掉 +2. 增加压缩可观测,能看到压缩前后发生了什么 +3. recent turns 从按轮数升级成 token budget +4. facts 做优先级排序和 namespace 过滤 +5. 引入 LLM Compressor,生成高质量 summary/key_points/open_loops +6. synthetic memory message 改成更合适的 role/block +7. 接入 RAG documents 和 Qdrant 召回 +``` + +--- + +## 面试可以这样说 + +你可以这样讲: + +> 第 4 步第一版采用工程上稳定的压缩策略,也就是 conversation summary + stable facts + recent turns。后续我会重点优化 summary 质量和上下文预算。比如引入 LLM Compressor,让它基于旧 summary 和最近若干轮重新生成结构化摘要;recent turns 不再只按轮数,而是按 token budget 裁剪;facts 会按来源、更新时间、namespace 和重要性排序;同时增加可观测,记录压缩前后 token、保留了哪些 facts、summary 是否命中。等 RAG 阶段完成后,再把 document 召回结果纳入混合上下文。 + +最简单记: + +> **现在是“规则版压缩能用”,后续要升级成“LLM 语义压缩 + token 预算 + facts 排序 + RAG 混合召回”。** diff --git "a/docs/AI/\350\256\260\345\277\206-two-\345\206\273\347\273\223.md" "b/docs/AI/\350\256\260\345\277\206-two-\345\206\273\347\273\223.md" new file mode 100644 index 0000000..5735045 --- /dev/null +++ "b/docs/AI/\350\256\260\345\277\206-two-\345\206\273\347\273\223.md" @@ -0,0 +1,67 @@ +记忆治理现在主要做在 **policy 层**,不是写在 Repository 里。核心文件是 [aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:11),它负责在 writeback 真正入库前判断:能不能存、归到哪个 scope、谁能读写、多久过期、能不能覆盖旧值。 + +整体分成 4 块: + +1. **候选内容建模** + + 在 [memory_policy.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory_policy.go:51) 定义了治理需要的输入结构: + + - `MemoryFactCandidate`:待写入的结构化事实,比如用户偏好、OJ 画像、目标。 + - `MemoryDocumentCandidate`:待写入的长期文本,比如 FAQ、runbook、incident 摘要。 + - `MemoryAccessContext`:当前主体和授权上下文,包括 `Principal`、已批准的 org scope、是否允许 platform ops。 + - `MemoryDecision`:统一返回 `Allowed / ReasonCode / Reason`,方便后面日志和调试。 + +2. **写入准入** + + `ShouldStoreFact` 在 [aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:14) 做 fact 准入: + + - 禁止原始 trace、完整工具输出这类来源。 + - 如果和实时业务真相冲突,拒绝。 + - 低价值、空 namespace、空 fact key、空 value,拒绝。 + - 然后依次过 `ResolveScope -> ResolveVisibility -> CanWriteMemory`。 + + `ShouldStoreDocument` 在 [aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:63) 做 document 准入: + + - `session_summary` 不能作为 document 存,要走 conversation summary。 + - 禁止 raw trace / full tool output。 + - 低价值、空摘要且空正文,拒绝。 + - 通过权限后,会生成 `ContentHash / SummaryHash / DedupKey` 做去重。 + +3. **权限和 scope** + + `ResolveScope` 在 [aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:137): + + - `self`:必须有当前用户,且候选 userID 不能和 principal 不一致。 + - `org`:必须传明确 orgID,并且这个 org 必须在 `ApprovedOrgScopeKeys` 或 `ApprovedOrgIDs` 里。 + - `platform_ops`:必须显式允许 `AllowPlatformOps`,且主体必须是超管。 + + `ResolveVisibility` 在 [aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:232) 固定映射: + + - `self -> self` + - `org -> org` + - `platform_ops -> super_admin` + + 读写权限最后都走 `evaluateMemoryAccess`,也就是 fail closed:缺授权信息就拒绝,不会默认放行。 + +4. **过期、覆盖、去重** + + `ResolveTTL` 在 [aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:273): + + - `user_preference / org_profile / ops_incident / ops_runbook` 默认长期有效。 + - `oj_goal` 30 天。 + - `oj_profile` 60 天。 + - `org_learning_pattern` 14 天。 + - summary 不靠 TTL。 + + `ShouldOverrideFact` 在 [aiMemoryPolicy.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go:330): + + - 候选值为空拒绝。 + - 和当前值一样则跳过。 + - 按来源优先级覆盖: + - 个人记忆:用户显式声明 > 管理员设置 > 工具验证摘要 > 模型推断。 + - 组织/平台记忆:管理员设置 > 用户显式声明 > 工具验证摘要 > 模型推断。 + + Document 去重键在 [memory_policy.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory_policy.go:173):优先用 `source_kind + source_id + topic`,否则退到 summary hash,再退到 content hash。 + +当前状态有一个关键点:**治理规则已经实现并有测试,但还没有真正接入主 writeback 链路。** +[AIMemoryService](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemorySvc.go:55) 里的 `Recall / OnTurnCompleted / RefreshConversationSummary` 现在仍是 phase 1 skeleton,返回 `errAIMemoryPhase1NotImplemented`。也就是说,治理能力已经准备好了,下一步要在 `memory writeback hook` 里把这些 policy 调起来,再允许入 Repository。 \ No newline at end of file diff --git "a/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" "b/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" index 340aa3c..1ccc018 100644 --- "a/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" +++ "b/docs/AI/\350\256\260\345\277\206\346\250\241\345\235\227\350\256\276\350\256\241.md" @@ -1,446 +1,114 @@ -**目标** -这套设计的目的只有 5 个: - -- 让 AI 具备`跨会话连续性`,能记住用户偏好、OJ 学习画像、组织上下文、运维经验。 -- 让记忆和 tool 一样,`严格走权限边界`。 -- 把短期上下文控制在可用范围内,避免 context 爆炸。 -- 把“实时数据查询”和“历史记忆召回”分开,尤其是超管运维场景。 -- 保持你当前仓库的边界不变:MVC 外壳不动,AI 子域渐进式扩展。 - -你现在最合适的落点仍然是: -- 读路径接在 [`aiContext.go`]() -- 主编排接在 [`aiSvc.go`]() -- tool 权限继续复用 [`aiTool.go`]() -- Qdrant 基础设施继续复用 [`qdrant.go`]() -- tool 证据链复用 [`aiProjector.go`]() 里的 `TraceItemsJSON` - -**目录设计** -第一版建议直接这样落: - -```text -internal/domain/ai/ - memory.go - memory_types.go - -internal/model/config/ - ai.go - qdrant.go - -internal/model/entity/ - ai_memory_fact.go - ai_memory_document.go - ai_conversation_summary.go - -internal/repository/interfaces/ - aiMemoryRepository.go - -internal/repository/system/ - aiMemoryRepo.go - -internal/infrastructure/ai/memory/ - provider.go - compressor.go - extractor.go - qdrant_store.go - embedder.go - outbox_consumer.go - -internal/service/system/ - aiMemorySvc.go - aiMemoryPolicy.go - aiContext.go // 扩展,不重写 - aiSvc.go // 扩展写回 hook - -internal/core/ - qdrant.go // 扩展 memory collection 初始化 - embedding.go // 如果 embedding client 独立初始化 -``` - -每层职责固定如下: - -- `domain/ai`:定义记忆类型、作用域、读写协议。 -- `service/system`:决定存什么、什么时候召回、什么时候写回。 -- `repository`:负责 MySQL 的 facts / summaries / documents CRUD。 -- `infrastructure/ai/memory`:负责 embedding、Qdrant 检索、压缩、抽取。 -- `core`:负责 Qdrant / embedding client 生命周期和配置映射。 - -**核心模型** -你不要只做一个“memory 表”。至少拆成 3 类数据。 - -```go -// internal/domain/ai/memory.go -type MemoryScopeType string -const ( - MemoryScopeSelf MemoryScopeType = "self" - MemoryScopeOrg MemoryScopeType = "org" - MemoryScopePlatformOps MemoryScopeType = "platform_ops" -) - -type MemoryType string -const ( - MemoryTypeEntity MemoryType = "entity" - MemoryTypeSessionSummary MemoryType = "session_summary" - MemoryTypeEpisodic MemoryType = "episodic" - MemoryTypeSemantic MemoryType = "semantic" - MemoryTypeProcedural MemoryType = "procedural" - MemoryTypeIncident MemoryType = "incident" - MemoryTypeFAQ MemoryType = "faq" -) - -type MemoryVisibility string -const ( - MemoryVisibilitySelf MemoryVisibility = "self" - MemoryVisibilityOrg MemoryVisibility = "org" - MemoryVisibilitySuperAdmin MemoryVisibility = "super_admin" -) -``` - -然后持久化实体建议这样拆: - -```go -// ai_memory_facts:结构化实体记忆 -type AIMemoryFact struct { - ID uint - ScopeKey string // self:user:123 / org:45 / platform_ops - ScopeType string - UserID *uint - OrgID *uint - Namespace string // user_preference / oj_profile / org_profile / ops_profile - FactKey string // answer_style / weak_topics / current_goal / runbook_owner - FactValueJSON string // 结构化 JSON - Summary string - Confidence float64 - SourceKind string // message / tool_result / incident / admin_set - SourceID string - EffectiveAt *time.Time - ExpiresAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} -``` - -```go -// ai_memory_documents:长期语义记忆的 canonical metadata -type AIMemoryDocument struct { - ID string - ScopeKey string - ScopeType string - UserID *uint - OrgID *uint - MemoryType string // episodic / semantic / procedural / incident / faq - Topic string // dp / graph / ranking / qdrant / trace - Title string - Summary string - ContentText string // 这里存摘要或 chunk,不存超长原始日志 - SourceKind string - SourceID string - Importance float64 - QualityScore float64 - QdrantPointID string - EmbeddingModel string - EffectiveAt *time.Time - ExpiresAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} -``` - -```go -// ai_conversation_summaries:短期记忆压缩产物 -type AIConversationSummary struct { - ConversationID string - CompressedUntilMessageID string - SummaryText string - KeyPointsJSON string - OpenLoopsJSON string - TokenEstimate int - UpdatedAt time.Time -} -``` - -**为什么这样拆** -- `facts` 解决“精确事实”和“可更新真相”。 -- `documents` 解决“语义召回”和“历史经验复用”。 -- `conversation_summaries` 解决“context window 不够”。 - -**数据库和向量库细节** -MySQL 里建议加这些索引: - -- `ai_memory_facts` -- `uk(scope_key, namespace, fact_key)` -- `idx(scope_key, updated_at)` -- `idx(expires_at)` - -- `ai_memory_documents` -- `idx(scope_key, memory_type, updated_at)` -- `idx(topic, updated_at)` -- `idx(expires_at)` - -- `ai_conversation_summaries` -- `pk(conversation_id)` - -Qdrant 不要直接把 MySQL 当替代品。Qdrant 只负责召回,MySQL 才是记忆元数据真相。 - -Qdrant payload 统一长这样: - -```json -{ - "memory_id": "mem_01", - "scope_key": "org:45", - "scope_type": "org", - "user_id": 0, - "org_id": 45, - "memory_type": "semantic", - "visibility": "org", - "topic": "dp", - "importance": 0.88, - "quality_score": 0.91, - "effective_at": "2026-04-24T00:00:00Z", - "expires_at": null, - "source_kind": "tool_result", - "source_id": "msg_ai_123" -} -``` - -建议把 Qdrant collection 分成两类,不要混: - -- `ai_knowledge_chunks`:静态知识库、FAQ、文档。 -- `ai_memory_chunks`:动态长期记忆、对话摘要、incident 经验。 - -**配置设计** -在 [`internal/model/config/ai.go`]() 里加: - -```go -type AIMemory struct { - Enabled bool - RecallTopK int - RecallMaxChars int - RecentRawTurns int - CompressThresholdTokens int - SummaryRefreshEveryTurns int - WritebackAsync bool - EnableEntityMemory bool - EnableLongTermMemory bool - EnableOrgMemory bool - EnableOpsMemory bool - MinImportance float64 - EmbedModel string -} - -type AI struct { - ... - Memory AIMemory `json:"memory" yaml:"memory"` -} -``` - -在 `qdrant.go` 里扩展: - -```go -type Qdrant struct { - ... - KnowledgeCollectionName string - MemoryCollectionName string -} -``` - -默认值建议: - -- `RecallTopK = 6` -- `RecentRawTurns = 8` -- `CompressThresholdTokens = 6000` -- `SummaryRefreshEveryTurns = 10` -- `MinImportance = 0.65` - -**服务接口和函数设计** -第一版不用搞太花,按你现有风格,先加这几个接口就够了。 - -```go -// service/system/aiContext.go -type aiMemoryRecallResult struct { - PromptBlocks []string - Messages []aidomain.Message -} - -type aiMemoryProvider interface { - Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) -} - -type aiMemoryWriter interface { - OnTurnCompleted(ctx context.Context, input aiMemoryWritebackInput) error -} -``` - -```go -// service/system/aiMemorySvc.go -type AIMemoryService struct { - repo interfaces.AIMemoryRepository - outboxRepo interfaces.OutboxRepository - policy aiMemoryPolicy - vectorStore aidomain.MemoryVectorStore -} - -func (s *AIMemoryService) Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) -func (s *AIMemoryService) OnTurnCompleted(ctx context.Context, input aiMemoryWritebackInput) error -func (s *AIMemoryService) RefreshConversationSummary(ctx context.Context, conversationID string) error -func (s *AIMemoryService) ExtractCandidates(ctx context.Context, input aiMemoryWritebackInput) ([]aiMemoryCandidate, error) -func (s *AIMemoryService) UpsertFact(ctx context.Context, fact entity.AIMemoryFact) error -func (s *AIMemoryService) ScheduleDocumentUpsert(ctx context.Context, docs []entity.AIMemoryDocument) error -``` - -```go -// infrastructure/ai/memory/provider.go -func (p *Provider) Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) -func (p *Provider) searchEntityFacts(ctx context.Context, input aiMemoryRecallInput) ([]entity.AIMemoryFact, error) -func (p *Provider) searchSemanticDocs(ctx context.Context, input aiMemoryRecallInput) ([]memoryHit, error) -func (p *Provider) buildPromptBlocks(facts []entity.AIMemoryFact, hits []memoryHit) []string -``` - -```go -// infrastructure/ai/memory/compressor.go -func (c *Compressor) CompressMessages(ctx context.Context, input aiContextCompressionInput) ([]aidomain.Message, error) -func (c *Compressor) EstimateTokens(messages []aidomain.Message) int -func (c *Compressor) BuildOrRefreshSummary(ctx context.Context, conversationID string, messages []aidomain.Message) (*entity.AIConversationSummary, error) -``` - -```go -// infrastructure/ai/memory/extractor.go -func (e *Extractor) ExtractFacts(ctx context.Context, input aiMemoryWritebackInput) ([]entity.AIMemoryFact, error) -func (e *Extractor) ExtractDocuments(ctx context.Context, input aiMemoryWritebackInput) ([]entity.AIMemoryDocument, error) -``` - -**整体逻辑** -完整链路就按“读 -> 用 -> 写”走。 - -1. `任务开始前读` -- 从 `ToolCallContext.Principal` 推导 scope。 -- scope 优先级固定为:`self -> org -> platform_ops` -- 先查 `ai_memory_facts` -- 再查 `ai_conversation_summaries` -- 再查 Qdrant `ai_memory_chunks` -- 最后把结果组装成: -- `PromptBlocks`:用户偏好、组织上下文、核心画像 -- `Messages`:少量召回摘要 - -2. `任务执行中用` -- `aiContextAssembler.Build` 先拿 recent raw messages -- 如果 token 超阈值,就用 `conversation summary + recent turns` -- 再拼上 memory prompt block -- 再拼 tool prompt -- 然后把结果交给 runtime -- ops 场景优先走实时 tool,不优先依赖旧记忆 - -3. `任务结束后写` -- `finishStream` 成功后触发 `memoryWriter.OnTurnCompleted` -- 同步写: -- 会话摘要 -- 显式偏好变更 -- 显式目标变更 -- 异步写: -- 对话结论 -- tool 结果摘要 -- incident / runbook 摘要 -- embedding + Qdrant upsert - -**什么值得存** -只存“下次有帮助”的内容: - -- 用户偏好:语言、解释风格、是否喜欢步骤化、是否偏好简洁答案 -- OJ 画像:弱项知识点、最近训练主题、常错模式、当前目标 -- 组织画像:组织 FAQ、共性薄弱点、任务执行异常模式 -- 运维经验:事故根因、排查路径、缓解动作、runbook 摘要 - -不要存: - -- 原始长日志 -- 原始 trace payload -- 每一次完整工具输出 -- 闲聊 -- 中间推理草稿 - -**权限设计** -记忆权限和 tool 权限同级,不是附属品。 - -- `self memory`:只能本人读写 -- `org memory`:只有具备组织 capability 的管理员能读 -- `platform_ops memory`:只有超级管理员能读 -- 权限校验失败时,`fail closed` -- Qdrant 检索失败时,`fail open`,直接降级为无长期记忆回答 -- MySQL fact 读取失败时,记录错误日志,继续走纯上下文回答 - -**压缩策略** -建议默认策略: - -- 原始 recent messages 只保留最近 `8` 轮 -- 超过 `6000` token 时触发压缩 -- 每 `10` 轮刷新一次 summary -- summary 始终记录: -- 已确定事实 -- 当前目标 -- 未完成事项 -- 关键 tool 结果结论 - -第一版不要做滑动窗口 + 分层缓存 + checkpoint 全上。 -先做:`summary + recent turns + top-k recall` - -**写回策略** -第一版建议分同步和异步: - -- 同步: -- `RefreshConversationSummary` -- `UpsertFact` for 明确偏好、明确目标、管理员手动设置事实 - -- 异步: -- `ExtractDocuments` -- `Embedding` -- `Qdrant Upsert` - -异步最好复用现有 outbox,不建议直接在流式请求线程里做 embedding。 - -事件名可以直接定成: - -- `ai.memory.turn_completed` -- `ai.memory.document_upsert_requested` - -**开发顺序** -按这个顺序做,风险最低: - -1. 做 `domain/ai/memory.go` -2. 做 `entity + repository` -3. 做 `AIMemoryService.Recall` -4. 接入 `aiContext.go` -5. 做 `conversation summary` -6. 接入 `finishStream` 写回 hook -7. 做异步 embedding + Qdrant upsert -8. 最后再做 memory debug / admin API - -**第一阶段必须完成的最小闭环** -做到这 4 件事,你的第一版就能用了: - -- 结构化 facts 能存用户偏好和 OJ 弱项 -- conversation summary 能压缩上下文 -- Qdrant 能召回历史题解摘要和 FAQ -- 召回结果能按 `self / org / platform_ops` 做过滤 - -**可选管理接口** -如果你后面要做运维和调试,建议只给超管开放: - -- `GET /ai/memory/debug/recall` -- `GET /ai/memory/facts` -- `POST /ai/memory/rebuild/{conversation_id}` - -这些路由放 `internal/router/system`,不要塞到主 router。 - -**你开发时最重要的 3 条原则** -- 记忆真相在 MySQL,Qdrant 只是召回索引。 -- 运维类问题优先实时工具,记忆只做补充。 -- 先把 `entity + summary + long-term recall` 三件事做稳,再考虑图谱。 - -**参考产品/官方文档** -- Eino Memory / Session:https://www.cloudwego.io/docs/eino/quick_start/chapter_03_memory_and_session/ -- Eino Overview:https://www.cloudwego.io/docs/eino/overview/ -- LangGraph Memory:https://docs.langchain.com/oss/javascript/langgraph/memory -- Mem0 Memory Types:https://docs.mem0.ai/core-concepts/memory-types -- Letta Memory Blocks:https://docs.letta.com/guides/core-concepts/memory/memory-blocks -- Microsoft Copilot 权限与 grounding:https://support.microsoft.com/en-us/topic/what-information-does-copilot-use-to-answer-my-prompt-934f537d-ff7d-4059-9fec-a751e4651307 - -如果你要继续下一步,我建议直接把这份设计落成一份待审计划,然后再按你仓库规则拆成 `Phase 1` 的文件级开发清单。 \ No newline at end of file +**1. 记忆模块设计** +定位:这套记忆不是业务真相库,而是 AI 的辅助连续性层。`OJ/任务执行/组织关系/观测数据` 的真相仍在正式 Service 和 Repository,记忆只保存“下次有帮助的稳定结论”。 + +- 业务目标: + - 个人侧:记住用户偏好、OJ 学习画像、阶段目标。 + - 组织侧:记住组织 FAQ、训练共性问题、组织上下文。 + - 运维侧:记住 incident 结论、runbook 摘要、排障经验。 + - 会话侧:记住长会话摘要,解决上下文压缩与恢复。 +- 持久化模型固定 3 类: + - `AIMemoryFact`:结构化稳定事实。适合偏好、画像、目标、FAQ 标签、runbook owner 这类可覆盖内容。 + - `AIMemoryDocument`:可语义召回文本。适合题解摘要、组织 FAQ 文本、incident 复盘摘要、程序性 SOP。 + - `AIConversationSummary`:会话压缩产物。只服务当前会话的上下文恢复。 +- 作用域固定 3 档: + - `self`:当前用户私有记忆。 + - `org`:当前组织共享记忆。 + - `platform_ops`:平台级运维记忆,仅超管可见。 +- 业务 namespace 建议固定: + - `user_preference` + - `oj_profile` + - `oj_goal` + - `org_profile` + - `org_learning_pattern` + - `ops_incident` + - `ops_runbook` +- 真相源固定: + - `MySQL` 存 `facts/documents/summary`,是记忆真相源。 + - `Qdrant` 只存 `documents/chunks` 索引,不是真相源。 +- 目录落点: + - `internal/domain/ai`:定义 `MemoryScopeType / MemoryType / MemoryVisibility` 和协议。 + - `internal/model/entity`:新增 3 个实体。 + - `internal/repository/interfaces`:新增 `AIMemoryRepository`。 + - `internal/repository/system`:实现 facts/documents/summary CRUD。 + - `internal/service/system`:新增 `AIMemoryService`、`aiMemoryPolicy`。 + - `internal/infrastructure/ai/memory`:放 extractor、compressor、embedder、qdrant store。 +- 配置落点: + - `internal/model/config/ai.go` 增加 `AI.Memory`。 + - `internal/model/config/qdrant.go` 扩展 `KnowledgeCollectionName`、`MemoryCollectionName`。 +- 接口先冻结成这组能力: + - `Recall(ctx, input)` + - `OnTurnCompleted(ctx, input)` + - `RefreshConversationSummary(ctx, conversationID)` + - `UpsertFact(ctx, fact)` + - `ScheduleDocumentUpsert(ctx, docs)` +- 与现有系统的接入点固定: + - 读侧接 [aiContext.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:10) + - 写侧接 [aiSvc.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:436) + - 权限事实复用 [tool.go](/D:/workspace_go/test/go/personal_assistant/internal/domain/ai/tool.go:93) 的 `AIToolPrincipal` + - 证据链复用 [aiProjector.go](/D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProjector.go:73) 的 `TraceItemsJSON` +- 本步产出价值: + - 冻结“记忆对象、作用域、权限、配置、仓储契约”。 + - 后续 `治理/writeback/压缩恢复/RAG` 都按同一套模型接力,不会返工。 + +**2. 记忆治理** +定位:在模型冻结后,先定义“哪些内容允许进入记忆系统、如何更新、何时失效、谁能读写”。这一步是 writeback 之前的准入层和护栏层。 + +- 治理目标: + - 防脏数据 + - 防低价值数据 + - 防越权数据 + - 防记忆和业务真相冲突 +- 准入规则: + - 允许写入:用户偏好、OJ 弱项、当前目标、组织 FAQ、组织共性问题、incident 结论、runbook 摘要、会话摘要。 + - 禁止写入:原始长日志、完整 trace payload、完整工具输出、闲聊、中间推理草稿、瞬时状态数字。 +- 分类规则: + - 可结构化、可覆盖的内容进 `Fact`。 + - 需要语义召回的文本进 `Document`。 + - 只为长会话恢复服务的内容进 `ConversationSummary`。 +- 覆盖规则: + - `Fact` 以 `scope_key + namespace + fact_key` 为语义唯一键。 + - 用户显式表达覆盖模型推断。 + - 管理员设置覆盖普通自动抽取。 + - `Summary` 按 `conversation_id` 整体覆盖。 +- 去重规则: + - `Fact` 靠唯一键去重。 + - `Document` 第一版用 `source_kind + source_id + topic` 或摘要哈希去重。 + - 不先做复杂语义去重,先保证规则稳定。 +- 新鲜度与过期: + - `user_preference` 默认长期有效。 + - `oj_goal`、临时训练目标必须有 TTL。 + - `oj_profile` 允许被新周期画像刷新。 + - `ops_incident`、`ops_runbook` 长期保留,但支持人工失效。 + - `expires_at` 到期后默认不参与召回。 +- 权限规则: + - `self`:仅本人可读写。 + - `org`:仅当前组织且具备对应 capability 的主体可读写。 + - `platform_ops`:仅 `IsSuperAdmin=true` 可读写。 + - 记忆读取必须先过权限过滤,再允许进入 prompt。 +- 真相优先级: + - 实时 tool / 正式业务 Service > 用户显式声明 > 管理员设置 > Fact > Summary > RAG Document + - 例如排名、任务状态、trace 明细永远以实时查询为准,记忆只能作背景补充。 +- 失败语义: + - 权限判断失败:`fail closed` + - Qdrant 检索失败:`fail open`,降级为无长期记忆 + - MySQL 记忆读取失败:记录日志,降级为纯上下文回答 +- 实现落点: + - `internal/service/system/aiMemoryPolicy.go` +- 规则函数建议先固定: + - `ShouldStoreFact` + - `ShouldStoreDocument` + - `ResolveScope` + - `ResolveVisibility` + - `ResolveTTL` + - `ShouldOverrideFact` + - `CanReadMemory` + - `CanWriteMemory` +- 本步产出价值: + - 后续 `writeback` 有明确准入标准。 + - 后续 `RAG` 不会把脏文本直接切块入库。 + - 后续 `混合召回` 有统一的权限和过滤口径。 + +这两步在本项目里的关系很明确: + +`记忆模块设计` 先冻结对象和边界,`记忆治理` 再定义准入和护栏。 +前者不定,后者无从判断;后者不定,`writeback` 一开始就会把错误内容写进系统。 \ No newline at end of file diff --git "a/docs/AI/\350\256\276\350\256\241\346\265\201\347\250\213.md" "b/docs/AI/\350\256\276\350\256\241\346\265\201\347\250\213.md" new file mode 100644 index 0000000..d76ad17 --- /dev/null +++ "b/docs/AI/\350\256\276\350\256\241\346\265\201\347\250\213.md" @@ -0,0 +1,49 @@ +完整路线就按这条线走: + +### 1. 记忆模块设计 +依赖:无。 +价值:先冻结 `facts / documents / conversation_summary` 这三类记忆模型,以及 scope、权限、配置、Repository/Service 契约。 +为什么排第一:后面的 `治理`、`writeback`、`压缩恢复`、`RAG` 都要共用这套模型,不先定下来,后面每一步都会返工。 + +### 2. 记忆治理 +依赖:第 1 步给出的记忆类型、作用域、权限边界。 +价值:明确“什么能存、什么不能存、如何覆盖、如何去重、何时过期、谁能读写”。 +为什么排第二:`writeback` 一旦开始写数据,就会产生真实记忆;治理不先定,后面会把脏数据、低价值数据、越权数据一起写进去。 + +### 3. memory writeback hook +依赖:第 1 步的数据模型,第 2 步的治理规则。 +价值:把每轮对话里值得保留的信息真正写出来,形成记忆生产链路。写出来的内容包括 `summary`、`facts`、以及后续可入库的 `documents`。 +为什么排第三:没有写回,后面的 `上下文恢复` 和 `RAG` 都没有数据源。 + +### 4. 上下文压缩,以及上下文恢复 +依赖:第 3 步产出的 `conversation summary / facts`。 +价值:把长会话从“全量历史”变成“`summary + recent turns`”,并能在下一轮把必要上下文恢复回来。 +为什么排第四:这是最先见效的读侧能力,而且它直接借力上一步已经写回的记忆数据。 + +### 5. `RAG 切分入库` +依赖:第 3 步写回出来的 `documents`,第 2 步定义的治理规则。 +价值:把长期知识、经验、FAQ、题解摘要做切块、embedding、入向量库,建立可检索索引。 +为什么排第五:RAG 不是先做召回,而是先做可召回的数据;没有切分入库,召回无从开始。 + +### 6. RAG 召回 +依赖:第 5 步已经建好的 chunk 和向量索引。 +价值:让系统能按 query 从长期知识里取回候选内容。 +为什么排第六:召回必须建立在索引已经存在的前提上,所以只能排在切分入库之后。 + +### 7. `混合召回策略` +依赖:第 4 步的 `summary/facts` 恢复能力,第 6 步的 `RAG` 召回能力。 +价值:统一编排 `recent turns + facts + summary + RAG + realtime tools`,决定每轮到底带什么上下文进模型。 +为什么排第七:它不是单点能力,而是总调度层,必须等前面的几种来源都已经能稳定工作后再做。 + +### 8. `可观测和调试` +依赖:前 1 到 7 步的完整链路。 +价值:能看到“写回了什么、压缩了什么、召回了什么、为什么命中或没命中、哪一步失败”。 +为什么排第八:只有全链路都落地后,调试视角才完整;这一步是把系统从“能跑”变成“能定位、能优化”。 + +这条路线的接力关系就是: + +`记忆模块设计 -> 记忆治理 -> memory writeback hook -> 上下文压缩/恢复 -> RAG 切分入库 -> RAG 召回 -> 混合召回策略 -> 可观测和调试` + +压成一句话: + +`先定模型,再定规则;先把数据写出来,再把短期上下文用起来;再建立长期知识索引;再统一召回;最后把整条链路看清楚。` \ No newline at end of file diff --git a/flag/flagSql.go b/flag/flagSql.go index e836125..f328b1f 100644 --- a/flag/flagSql.go +++ b/flag/flagSql.go @@ -27,6 +27,10 @@ func SQL() error { &entity.AIConversation{}, // AI 会话表 &entity.AIMessage{}, // AI 消息表 &entity.AIInterrupt{}, // AI 中断表 + &entity.AIMemoryFact{}, // AI 结构化事实记忆表 + &entity.AIMemoryDocument{}, // AI 长期记忆文档表 + &entity.AIMemoryDocumentChunk{}, // AI 长期记忆文档切块表 + &entity.AIConversationSummary{}, // AI 会话压缩摘要表 &entity.User{}, // 用户表 &entity.Org{}, // 组织表 &entity.OrgMember{}, // 组织成员状态表 - 身份上的 diff --git a/internal/core/config.go b/internal/core/config.go index 4fe17f3..fb53270 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -62,12 +62,33 @@ func InitConfig(path string) { viper.SetDefault("ai.system_prompt", "你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。") viper.SetDefault("ai.temperature", 0.2) viper.SetDefault("ai.max_completion_tokens", 1200) + viper.SetDefault("ai.memory.enabled", false) + viper.SetDefault("ai.memory.recall_top_k", 6) + viper.SetDefault("ai.memory.recall_max_chars", 2000) + viper.SetDefault("ai.memory.recent_raw_turns", 8) + viper.SetDefault("ai.memory.compress_threshold_tokens", 6000) + viper.SetDefault("ai.memory.summary_refresh_every_turns", 10) + viper.SetDefault("ai.memory.writeback_async", true) + viper.SetDefault("ai.memory.enable_entity_memory", true) + viper.SetDefault("ai.memory.enable_long_term_memory", true) + viper.SetDefault("ai.memory.enable_org_memory", true) + viper.SetDefault("ai.memory.enable_ops_memory", true) + viper.SetDefault("ai.memory.min_importance", 0.65) + viper.SetDefault("ai.memory.embed_model", "qwen3-vl-embedding") + viper.SetDefault("ai.memory.embed_endpoint", "https://dashscope.aliyuncs.com/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding") + viper.SetDefault("ai.memory.embed_dimension", 1024) + viper.SetDefault("ai.memory.chunk_max_chars", 1200) + viper.SetDefault("ai.memory.chunk_overlap_chars", 150) + viper.SetDefault("ai.memory.index_batch_size", 20) + viper.SetDefault("ai.memory.index_timeout_seconds", 30) viper.SetDefault("qdrant.enabled", true) viper.SetDefault("qdrant.endpoint", "") viper.SetDefault("qdrant.grpc_host", "") viper.SetDefault("qdrant.grpc_port", 6334) viper.SetDefault("qdrant.api_key", "") viper.SetDefault("qdrant.collection_name", "ai_knowledge_chunks") + viper.SetDefault("qdrant.knowledge_collection_name", "ai_knowledge_chunks") + viper.SetDefault("qdrant.memory_collection_name", "ai_memory_chunks") viper.SetDefault("qdrant.vector_size", 1024) viper.SetDefault("qdrant.distance", "cosine") viper.SetDefault("qdrant.init_collection", true) @@ -262,12 +283,33 @@ func InitConfig(path string) { _ = viper.BindEnv("ai.system_prompt", "AI_SYSTEM_PROMPT") _ = viper.BindEnv("ai.temperature", "AI_TEMPERATURE") _ = viper.BindEnv("ai.max_completion_tokens", "AI_MAX_COMPLETION_TOKENS") + _ = viper.BindEnv("ai.memory.enabled", "AI_MEMORY_ENABLED") + _ = viper.BindEnv("ai.memory.recall_top_k", "AI_MEMORY_RECALL_TOP_K") + _ = viper.BindEnv("ai.memory.recall_max_chars", "AI_MEMORY_RECALL_MAX_CHARS") + _ = viper.BindEnv("ai.memory.recent_raw_turns", "AI_MEMORY_RECENT_RAW_TURNS") + _ = viper.BindEnv("ai.memory.compress_threshold_tokens", "AI_MEMORY_COMPRESS_THRESHOLD_TOKENS") + _ = viper.BindEnv("ai.memory.summary_refresh_every_turns", "AI_MEMORY_SUMMARY_REFRESH_EVERY_TURNS") + _ = viper.BindEnv("ai.memory.writeback_async", "AI_MEMORY_WRITEBACK_ASYNC") + _ = viper.BindEnv("ai.memory.enable_entity_memory", "AI_MEMORY_ENABLE_ENTITY_MEMORY") + _ = viper.BindEnv("ai.memory.enable_long_term_memory", "AI_MEMORY_ENABLE_LONG_TERM_MEMORY") + _ = viper.BindEnv("ai.memory.enable_org_memory", "AI_MEMORY_ENABLE_ORG_MEMORY") + _ = viper.BindEnv("ai.memory.enable_ops_memory", "AI_MEMORY_ENABLE_OPS_MEMORY") + _ = viper.BindEnv("ai.memory.min_importance", "AI_MEMORY_MIN_IMPORTANCE") + _ = viper.BindEnv("ai.memory.embed_model", "AI_MEMORY_EMBED_MODEL") + _ = viper.BindEnv("ai.memory.embed_endpoint", "AI_MEMORY_EMBED_ENDPOINT") + _ = viper.BindEnv("ai.memory.embed_dimension", "AI_MEMORY_EMBED_DIMENSION") + _ = viper.BindEnv("ai.memory.chunk_max_chars", "AI_MEMORY_CHUNK_MAX_CHARS") + _ = viper.BindEnv("ai.memory.chunk_overlap_chars", "AI_MEMORY_CHUNK_OVERLAP_CHARS") + _ = viper.BindEnv("ai.memory.index_batch_size", "AI_MEMORY_INDEX_BATCH_SIZE") + _ = viper.BindEnv("ai.memory.index_timeout_seconds", "AI_MEMORY_INDEX_TIMEOUT_SECONDS") _ = viper.BindEnv("qdrant.enabled", "QDRANT_ENABLED") _ = viper.BindEnv("qdrant.endpoint", "QDRANT_ENDPOINT") _ = viper.BindEnv("qdrant.grpc_host", "QDRANT_GRPC_HOST") _ = viper.BindEnv("qdrant.grpc_port", "QDRANT_GRPC_PORT") _ = viper.BindEnv("qdrant.api_key", "QDRANT_API_KEY") _ = viper.BindEnv("qdrant.collection_name", "QDRANT_COLLECTION_NAME") + _ = viper.BindEnv("qdrant.knowledge_collection_name", "QDRANT_KNOWLEDGE_COLLECTION_NAME") + _ = viper.BindEnv("qdrant.memory_collection_name", "QDRANT_MEMORY_COLLECTION_NAME") _ = viper.BindEnv("qdrant.vector_size", "QDRANT_VECTOR_SIZE") _ = viper.BindEnv("qdrant.distance", "QDRANT_DISTANCE") _ = viper.BindEnv("qdrant.init_collection", "QDRANT_INIT_COLLECTION") diff --git a/internal/core/config_memory_test.go b/internal/core/config_memory_test.go new file mode 100644 index 0000000..77471ec --- /dev/null +++ b/internal/core/config_memory_test.go @@ -0,0 +1,77 @@ +package core + +import ( + "testing" + + "github.com/spf13/viper" + "go.uber.org/zap" + + "personal_assistant/global" +) + +func TestInitConfigBindsAIMemoryAndQdrantCompatibility(t *testing.T) { + viper.Reset() + oldLog := global.Log + oldConfig := global.Config + global.Log = zap.NewNop() + t.Cleanup(func() { + viper.Reset() + global.Log = oldLog + global.Config = oldConfig + }) + + t.Setenv("AI_MEMORY_ENABLED", "true") + t.Setenv("AI_MEMORY_RECALL_TOP_K", "9") + t.Setenv("AI_MEMORY_RECALL_MAX_CHARS", "4096") + t.Setenv("AI_MEMORY_EMBED_DIMENSION", "1024") + t.Setenv("AI_MEMORY_INDEX_BATCH_SIZE", "11") + t.Setenv("QDRANT_COLLECTION_NAME", "legacy-knowledge") + t.Setenv("QDRANT_MEMORY_COLLECTION_NAME", "memory-chunks") + + InitConfig(t.TempDir()) + + if global.Config == nil { + t.Fatal("global.Config = nil") + } + if !global.Config.AI.Memory.Enabled { + t.Fatal("AI.Memory.Enabled = false, want true") + } + if global.Config.AI.Memory.RecallTopK != 9 { + t.Fatalf("AI.Memory.RecallTopK = %d, want 9", global.Config.AI.Memory.RecallTopK) + } + if global.Config.AI.Memory.RecallMaxChars != 4096 { + t.Fatalf("AI.Memory.RecallMaxChars = %d, want 4096", global.Config.AI.Memory.RecallMaxChars) + } + if global.Config.AI.Memory.SummaryRefreshEveryTurns != 10 { + t.Fatalf( + "AI.Memory.SummaryRefreshEveryTurns = %d, want default 10", + global.Config.AI.Memory.SummaryRefreshEveryTurns, + ) + } + if global.Config.AI.Memory.EmbedModel != "qwen3-vl-embedding" { + t.Fatalf("AI.Memory.EmbedModel = %q, want qwen3-vl-embedding", global.Config.AI.Memory.EmbedModel) + } + if global.Config.AI.Memory.EmbedDimension != 1024 { + t.Fatalf("AI.Memory.EmbedDimension = %d, want 1024", global.Config.AI.Memory.EmbedDimension) + } + if global.Config.AI.Memory.IndexBatchSize != 11 { + t.Fatalf("AI.Memory.IndexBatchSize = %d, want 11", global.Config.AI.Memory.IndexBatchSize) + } + if global.Config.Qdrant.CollectionName != "legacy-knowledge" { + t.Fatalf("Qdrant.CollectionName = %q, want %q", global.Config.Qdrant.CollectionName, "legacy-knowledge") + } + if global.Config.Qdrant.KnowledgeCollectionName != "legacy-knowledge" { + t.Fatalf( + "Qdrant.KnowledgeCollectionName = %q, want %q", + global.Config.Qdrant.KnowledgeCollectionName, + "legacy-knowledge", + ) + } + if global.Config.Qdrant.MemoryCollectionName != "memory-chunks" { + t.Fatalf( + "Qdrant.MemoryCollectionName = %q, want %q", + global.Config.Qdrant.MemoryCollectionName, + "memory-chunks", + ) + } +} diff --git a/internal/core/qdrant.go b/internal/core/qdrant.go index 2626d95..e1ddbce 100644 --- a/internal/core/qdrant.go +++ b/internal/core/qdrant.go @@ -57,7 +57,7 @@ func InitQdrant(ctx context.Context) (*qdrant.Client, error) { global.Log.Info("Qdrant health check succeeded", zap.String("version", health.GetVersion())) if qdrantCfg.InitCollection { - if err := ensureQdrantCollection(runCtx, client, qdrantCfg); err != nil { + if err := ensureQdrantCollections(runCtx, client, qdrantCfg); err != nil { _ = client.Close() return nil, err } @@ -102,12 +102,40 @@ func newQdrantClient(qdrantCfg config.Qdrant) (*qdrant.Client, error) { // 生产约束: // - 已存在 collection 的维度或距离算法不匹配时必须返回错误。 // - 这里不自动删除或重建 collection,避免误删线上已有向量数据。 +func ensureQdrantCollections( + ctx context.Context, + client *qdrant.Client, + qdrantCfg config.Qdrant, +) error { + if err := ensureQdrantCollection(ctx, client, qdrantCfg, qdrantCfg.CollectionName); err != nil { + return err + } + memoryCollectionName := strings.TrimSpace(qdrantCfg.MemoryCollectionName) + if memoryCollectionName == "" { + memoryCollectionName = strings.TrimSpace(qdrantCfg.CollectionName) + } + if memoryCollectionName == "" || memoryCollectionName == strings.TrimSpace(qdrantCfg.CollectionName) { + return nil + } + if global.Config != nil && + global.Config.AI.Memory.EmbedDimension > 0 && + global.Config.AI.Memory.EmbedDimension != qdrantCfg.VectorSize { + return fmt.Errorf( + "qdrant memory collection vector size mismatch: qdrant.vector_size=%d ai.memory.embed_dimension=%d", + qdrantCfg.VectorSize, + global.Config.AI.Memory.EmbedDimension, + ) + } + return ensureQdrantCollection(ctx, client, qdrantCfg, memoryCollectionName) +} + func ensureQdrantCollection( ctx context.Context, client *qdrant.Client, qdrantCfg config.Qdrant, + collectionName string, ) error { - collectionName := strings.TrimSpace(qdrantCfg.CollectionName) + collectionName = strings.TrimSpace(collectionName) if collectionName == "" { return errors.New("qdrant collection name is empty") } diff --git a/internal/domain/ai/memory.go b/internal/domain/ai/memory.go new file mode 100644 index 0000000..b8892ad --- /dev/null +++ b/internal/domain/ai/memory.go @@ -0,0 +1,152 @@ +package ai + +import ( + "fmt" + "strings" +) + +// MemoryScopeType 表示记忆数据的归属作用域。 +type MemoryScopeType string + +const ( + MemoryScopeSelf MemoryScopeType = "self" + MemoryScopeOrg MemoryScopeType = "org" + MemoryScopePlatformOps MemoryScopeType = "platform_ops" +) + +// MemoryType 表示长期记忆文档的分类。 +type MemoryType string + +const ( + MemoryTypeEntity MemoryType = "entity" + MemoryTypeSessionSummary MemoryType = "session_summary" + MemoryTypeEpisodic MemoryType = "episodic" + MemoryTypeSemantic MemoryType = "semantic" + MemoryTypeProcedural MemoryType = "procedural" + MemoryTypeIncident MemoryType = "incident" + MemoryTypeFAQ MemoryType = "faq" +) + +// MemoryVisibility 表示记忆的访问等级。 +type MemoryVisibility string + +const ( + MemoryVisibilitySelf MemoryVisibility = "self" + MemoryVisibilityOrg MemoryVisibility = "org" + MemoryVisibilitySuperAdmin MemoryVisibility = "super_admin" +) + +const ( + MemoryNamespaceUserPreference = "user_preference" + MemoryNamespaceOJProfile = "oj_profile" + MemoryNamespaceOJGoal = "oj_goal" + MemoryNamespaceOrgProfile = "org_profile" + MemoryNamespaceOrgLearning = "org_learning_pattern" + MemoryNamespaceOpsIncident = "ops_incident" + MemoryNamespaceOpsRunbook = "ops_runbook" +) + +// BuildSelfMemoryScopeKey 统一生成个人作用域的 scope_key。 +func BuildSelfMemoryScopeKey(userID uint) string { + return fmt.Sprintf("self:user:%d", userID) +} + +// BuildOrgMemoryScopeKey 统一生成组织作用域的 scope_key。 +func BuildOrgMemoryScopeKey(orgID uint) string { + return fmt.Sprintf("org:%d", orgID) +} + +// BuildPlatformOpsMemoryScopeKey 返回平台运维级记忆作用域。 +func BuildPlatformOpsMemoryScopeKey() string { + return string(MemoryScopePlatformOps) +} + +// BuildMemoryScopeKey 按固定规则生成 scope_key,禁止在业务代码中手写格式。 +func BuildMemoryScopeKey(scopeType MemoryScopeType, userID uint, orgID *uint) (string, error) { + switch scopeType { + case MemoryScopeSelf: + if userID == 0 { + return "", fmt.Errorf("user_id is required for self scope") + } + return BuildSelfMemoryScopeKey(userID), nil + case MemoryScopeOrg: + if orgID == nil || *orgID == 0 { + return "", fmt.Errorf("org_id is required for org scope") + } + return BuildOrgMemoryScopeKey(*orgID), nil + case MemoryScopePlatformOps: + return BuildPlatformOpsMemoryScopeKey(), nil + default: + return "", fmt.Errorf("unsupported memory scope type: %s", scopeType) + } +} + +// BuildConversationMemoryScopeKey 根据会话所属用户和当前组织快照生成 summary scope_key。 +func BuildConversationMemoryScopeKey(userID uint, orgID *uint) string { + if orgID != nil && *orgID > 0 { + return BuildOrgMemoryScopeKey(*orgID) + } + return BuildSelfMemoryScopeKey(userID) +} + +// MemoryFactQuery 描述 facts 的读取条件。 +type MemoryFactQuery struct { + // ScopeKeys 是允许读取的记忆归属集合。 + // 调用方必须先完成主体解析与 scope 计算,再把最终允许访问的 scope_key 传入仓储层。 + ScopeKeys []string + // AllowedVisibilities 是本次调用允许暴露给当前主体的访问等级集合。 + // scope 负责“属于谁”,visibility 负责“谁能看”,查询时必须同时满足两者。 + AllowedVisibilities []MemoryVisibility + // Namespace 用于按业务域过滤结构化事实,例如 user_preference、oj_goal。 + Namespace string + // FactKeys 用于在同一 namespace 下进一步收窄到具体事实键。 + FactKeys []string + // Limit 控制本次读取最多返回多少条记录;小于等于 0 表示不主动限制。 + Limit int +} + +// MemoryDocumentQuery 描述 documents 的读取条件。 +type MemoryDocumentQuery struct { + // ScopeKeys 是允许访问的文档归属集合。 + ScopeKeys []string + // AllowedVisibilities 是当前主体允许读取的访问等级集合。 + AllowedVisibilities []MemoryVisibility + // MemoryTypes 用于限定文档分类,例如 semantic、episodic、incident。 + MemoryTypes []MemoryType + // Topic 用于对同类文档做轻量主题过滤;后续可扩展为更细的业务标签。 + Topic string + // Limit 控制最大返回条数;小于等于 0 表示不主动限制。 + Limit int +} + +// NormalizeMemoryVisibilities 把 visibility 列表转换为稳定字符串切片。 +func NormalizeMemoryVisibilities(visibilities []MemoryVisibility) []string { + if len(visibilities) == 0 { + return nil + } + items := make([]string, 0, len(visibilities)) + for _, item := range visibilities { + value := strings.TrimSpace(string(item)) + if value == "" { + continue + } + items = append(items, value) + } + return items +} + +// NormalizeMemoryTypes 把 memory type 列表转换为稳定字符串切片。 +func NormalizeMemoryTypes(types []MemoryType) []string { + if len(types) == 0 { + return nil + } + items := make([]string, 0, len(types)) + for _, item := range types { + value := strings.TrimSpace(string(item)) + if value == "" { + continue + } + items = append(items, value) + } + return items +} diff --git a/internal/domain/ai/memory_policy.go b/internal/domain/ai/memory_policy.go new file mode 100644 index 0000000..70321f7 --- /dev/null +++ b/internal/domain/ai/memory_policy.go @@ -0,0 +1,207 @@ +package ai + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" +) + +// MemorySourceKind 表示记忆候选内容的来源类型。 +type MemorySourceKind string + +const ( + MemorySourceExplicitUserStatement MemorySourceKind = "explicit_user_statement" + MemorySourceAdminSet MemorySourceKind = "admin_set" + MemorySourceToolVerifiedSummary MemorySourceKind = "tool_verified_summary" + MemorySourceModelInferred MemorySourceKind = "model_inferred" + MemorySourceRawTracePayload MemorySourceKind = "raw_trace_payload" + MemorySourceFullToolOutput MemorySourceKind = "full_tool_output" +) + +const ( + MemoryReasonAllowSelfScope = "allow_self_scope" + MemoryReasonAllowOrgScope = "allow_org_scope" + MemoryReasonAllowPlatformOpsScope = "allow_platform_ops_scope" + + MemoryReasonDenyPermissionDependencyMissing = "deny_permission_dependency_missing" + MemoryReasonDenyScopeMismatch = "deny_scope_mismatch" + MemoryReasonDenyVisibilityMismatch = "deny_visibility_mismatch" + MemoryReasonDenyNotSuperAdmin = "deny_not_super_admin" + MemoryReasonDenyNotInApprovedOrgScope = "deny_not_in_approved_org_scope" + MemoryReasonDenyForbiddenSource = "deny_forbidden_source" + MemoryReasonDenyLowValueContent = "deny_low_value_content" + MemoryReasonDenyTruthConflict = "deny_truth_conflict" + MemoryReasonDenyDuplicateDocument = "deny_duplicate_document" + + MemoryReasonOverrideExplicitUserStatement = "override_explicit_user_statement" + MemoryReasonOverrideAdminSet = "override_admin_set" + MemoryReasonSkipLowerPrioritySource = "skip_lower_priority_source" + MemoryReasonSkipSameValue = "skip_same_value" + + MemoryReasonAllowStoreFact = "allow_store_fact" + MemoryReasonAllowStoreDocument = "allow_store_document" + MemoryReasonAllowTTLExpiring = "allow_ttl_expiring" + MemoryReasonAllowTTLPersistent = "allow_ttl_persistent" + MemoryReasonAllowSamePriority = "allow_same_priority_source" + MemoryReasonAllowDocumentRefresh = "allow_document_refresh" +) + +// MemoryAccessContext 表示 policy 可消费的显式授权上下文。 +type MemoryAccessContext struct { + Principal AIToolPrincipal + ApprovedOrgScopeKeys []string + ApprovedOrgIDs []uint + AllowPlatformOps bool +} + +// MemoryDecision 是所有 policy 决策的基础返回结构。 +type MemoryDecision struct { + Allowed bool + ReasonCode string + Reason string +} + +// MemoryScopeDecision 表示 scope 解析结果。 +type MemoryScopeDecision struct { + MemoryDecision + ScopeType MemoryScopeType + ScopeKey string + UserID *uint + OrgID *uint +} + +// MemoryVisibilityDecision 表示 visibility 解析结果。 +type MemoryVisibilityDecision struct { + MemoryDecision + Visibility MemoryVisibility +} + +// MemoryTTLDecision 表示 TTL 解析结果。 +type MemoryTTLDecision struct { + MemoryDecision + ExpiresAt *time.Time +} + +// MemoryDocumentDecision 表示文档写入决策及去重元数据。 +type MemoryDocumentDecision struct { + MemoryDecision + ContentHash string + SummaryHash string + DedupKey string +} + +// MemoryAccessTarget 表示可参与读写校验的记忆目标。 +type MemoryAccessTarget struct { + ScopeType MemoryScopeType + ScopeKey string + Visibility MemoryVisibility + UserID *uint + OrgID *uint +} + +// MemoryScopeInput 描述待解析的作用域输入。 +type MemoryScopeInput struct { + ScopeType MemoryScopeType + UserID *uint + OrgID *uint +} + +// MemoryFactCandidate 表示待写入的事实候选项。 +type MemoryFactCandidate struct { + ScopeType MemoryScopeType + UserID *uint + OrgID *uint + Namespace string + FactKey string + FactValueJSON string + Summary string + SourceKind MemorySourceKind + SourceID string + LowValue bool + TruthConflict bool +} + +// MemoryDocumentCandidate 表示待写入的文档候选项。 +type MemoryDocumentCandidate struct { + ScopeType MemoryScopeType + UserID *uint + OrgID *uint + MemoryType MemoryType + Topic string + Title string + Summary string + ContentText string + SourceKind MemorySourceKind + SourceID string + LowValue bool + TruthConflict bool +} + +// MemoryFactVersion 描述事实覆盖比较所需的最小信息。 +type MemoryFactVersion struct { + ValueJSON string + SourceKind MemorySourceKind + Namespace string + ScopeType MemoryScopeType + Description string +} + +// MemoryConversationSummaryQuery 收紧会话摘要读取契约。 +type MemoryConversationSummaryQuery struct { + ConversationID string + UserID uint + OrgID *uint + ScopeKey string +} + +// NormalizeMemoryText 统一压平空白,保证哈希和去重键稳定。 +func NormalizeMemoryText(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +// BuildMemoryDocumentContentHash 基于规范化内容生成内容哈希。 +func BuildMemoryDocumentContentHash(content string) string { + return hashMemoryValue(NormalizeMemoryText(content)) +} + +// BuildMemoryDocumentSummaryHash 基于规范化摘要生成摘要哈希。 +func BuildMemoryDocumentSummaryHash(summary string) string { + return hashMemoryValue(NormalizeMemoryText(summary)) +} + +// BuildMemoryDocumentDedupKey 生成第一版规则去重键。 +func BuildMemoryDocumentDedupKey(sourceKind MemorySourceKind, sourceID string, topic string, summary string, content string) string { + normalizedSourceKind := NormalizeMemoryText(string(sourceKind)) + normalizedSourceID := NormalizeMemoryText(sourceID) + normalizedTopic := NormalizeMemoryText(topic) + if normalizedSourceKind != "" && normalizedSourceID != "" && normalizedTopic != "" { + return "src:" + hashMemoryValue( + strings.ToLower(strings.Join([]string{normalizedSourceKind, normalizedSourceID, normalizedTopic}, "\n")), + ) + } + + summaryHash := BuildMemoryDocumentSummaryHash(summary) + if summaryHash != "" { + return "summary:" + summaryHash + } + contentHash := BuildMemoryDocumentContentHash(content) + if contentHash != "" { + return "content:" + contentHash + } + return "" +} + +func hashMemoryValue(value string) string { + if value == "" { + return "" + } + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +// DescribeMemoryScope 返回便于日志和测试阅读的 scope 文本。 +func DescribeMemoryScope(scopeType MemoryScopeType, scopeKey string) string { + return fmt.Sprintf("scope_type=%s scope_key=%s", scopeType, scopeKey) +} diff --git a/internal/domain/ai/memory_rag.go b/internal/domain/ai/memory_rag.go new file mode 100644 index 0000000..4048b25 --- /dev/null +++ b/internal/domain/ai/memory_rag.go @@ -0,0 +1,80 @@ +package ai + +import "context" + +// MemoryDocumentForIndex 是 RAG 索引器消费的长期记忆文档快照。 +type MemoryDocumentForIndex struct { + ID string + ScopeKey string + ScopeType string + Visibility string + UserID *uint + OrgID *uint + MemoryType string + Topic string + Title string + Summary string + Content string + SourceKind string + SourceID string +} + +// MemoryDocumentChunk 表示切分后可索引的记忆片段。 +type MemoryDocumentChunk struct { + ID string + DocumentID string + ScopeKey string + ScopeType string + Visibility string + UserID *uint + OrgID *uint + MemoryType string + Topic string + SourceKind string + SourceID string + ChunkIndex int + ContentText string + ContentHash string + TokenEstimate int + EmbeddingModel string + EmbeddingDimension int + QdrantPointID string +} + +// MemoryEmbeddingInput 描述一次 embedding 请求。 +type MemoryEmbeddingInput struct { + Texts []string +} + +// MemoryEmbeddingResult 描述 embedding 输出。 +type MemoryEmbeddingResult struct { + Vectors [][]float32 +} + +// MemoryVectorChunk 表示准备写入向量库的 chunk。 +type MemoryVectorChunk struct { + Chunk MemoryDocumentChunk + Vector []float32 +} + +// MemoryChunker 负责把长期记忆文档切分成可 embedding 的 chunks。 +type MemoryChunker interface { + Chunk(ctx context.Context, doc MemoryDocumentForIndex) ([]MemoryDocumentChunk, error) +} + +// MemoryEmbedder 负责为 chunk 文本生成向量。 +type MemoryEmbedder interface { + Embed(ctx context.Context, input MemoryEmbeddingInput) (MemoryEmbeddingResult, error) +} + +// MemoryVectorStore 负责把 memory chunks 写入向量库。 +type MemoryVectorStore interface { + DeleteDocumentChunks(ctx context.Context, documentID string) error + UpsertChunks(ctx context.Context, chunks []MemoryVectorChunk) error +} + +// MemoryDocumentIndexer 定义 memory documents 的索引建设能力。 +type MemoryDocumentIndexer interface { + IndexDocuments(ctx context.Context, documentIDs []string) error + IndexPendingDocuments(ctx context.Context, limit int) error +} diff --git a/internal/domain/ai/memory_writeback.go b/internal/domain/ai/memory_writeback.go new file mode 100644 index 0000000..e1332ed --- /dev/null +++ b/internal/domain/ai/memory_writeback.go @@ -0,0 +1,38 @@ +package ai + +import "context" + +// MemoryExtractionInput is the stable input consumed by writeback extractors. +type MemoryExtractionInput struct { + ConversationID string + UserID uint + OrgID *uint + Principal AIToolPrincipal + + UserMessage Message + AssistantMessage Message + + PreviousSummaryText string +} + +// ConversationSummaryDraft is a technology-neutral summary proposal. +type ConversationSummaryDraft struct { + ConversationID string + CompressedUntilMessageID string + SummaryText string + KeyPointsJSON string + OpenLoopsJSON string + TokenEstimate int +} + +// MemoryExtractionResult groups all memory candidates extracted from one turn. +type MemoryExtractionResult struct { + Summary *ConversationSummaryDraft + Facts []MemoryFactCandidate + Documents []MemoryDocumentCandidate +} + +// MemoryExtractor extracts writeback candidates without knowing persistence. +type MemoryExtractor interface { + Extract(ctx context.Context, input MemoryExtractionInput) (MemoryExtractionResult, error) +} diff --git a/internal/infrastructure/ai/memory/chunker.go b/internal/infrastructure/ai/memory/chunker.go new file mode 100644 index 0000000..23ec6c9 --- /dev/null +++ b/internal/infrastructure/ai/memory/chunker.go @@ -0,0 +1,226 @@ +package memory + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "unicode/utf8" + + aidomain "personal_assistant/internal/domain/ai" +) + +const ( + defaultChunkMaxChars = 1200 + defaultChunkOverlapChars = 150 +) + +// ChunkerOptions 配置记忆文档切分策略。 +type ChunkerOptions struct { + MaxChars int + OverlapChars int +} + +// ParagraphChunker 按段落优先切分记忆文档,必要时回退到字符窗口。 +type ParagraphChunker struct { + maxChars int + overlapChars int +} + +// NewParagraphChunker 创建 paragraph-aware chunker。 +func NewParagraphChunker(opts ChunkerOptions) *ParagraphChunker { + if opts.MaxChars <= 0 { + opts.MaxChars = defaultChunkMaxChars + } + if opts.OverlapChars < 0 { + opts.OverlapChars = 0 + } + if opts.OverlapChars >= opts.MaxChars { + opts.OverlapChars = opts.MaxChars / 4 + } + return &ParagraphChunker{ + maxChars: opts.MaxChars, + overlapChars: opts.OverlapChars, + } +} + +// Chunk 把文档正文拆成稳定 chunks。 +func (c *ParagraphChunker) Chunk( + ctx context.Context, + doc aidomain.MemoryDocumentForIndex, +) ([]aidomain.MemoryDocumentChunk, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + content := normalizeChunkText(doc.Content) + if content == "" { + return nil, nil + } + parts := c.splitText(content) + chunks := make([]aidomain.MemoryDocumentChunk, 0, len(parts)) + for idx, part := range parts { + hash := aidomain.BuildMemoryDocumentContentHash(part) + chunkID := buildMemoryChunkID(doc.ID, idx, hash) + pointID := buildMemoryChunkPointID(doc.ID, idx, hash) + chunks = append(chunks, aidomain.MemoryDocumentChunk{ + ID: chunkID, + DocumentID: doc.ID, + ScopeKey: doc.ScopeKey, + ScopeType: doc.ScopeType, + Visibility: doc.Visibility, + UserID: cloneUintPtr(doc.UserID), + OrgID: cloneUintPtr(doc.OrgID), + MemoryType: doc.MemoryType, + Topic: doc.Topic, + SourceKind: doc.SourceKind, + SourceID: doc.SourceID, + ChunkIndex: idx, + ContentText: part, + ContentHash: hash, + TokenEstimate: estimateChunkTokens(part), + QdrantPointID: pointID, + }) + } + return chunks, nil +} + +func (c *ParagraphChunker) splitText(content string) []string { + paragraphs := splitParagraphs(content) + chunks := make([]string, 0, len(paragraphs)) + current := "" + for _, paragraph := range paragraphs { + if utf8.RuneCountInString(paragraph) > c.maxChars { + if current != "" { + chunks = append(chunks, current) + current = c.chunkOverlap(current) + } + for _, part := range splitByRuneWindow(paragraph, c.maxChars, c.overlapChars) { + if part != "" { + chunks = append(chunks, part) + } + } + current = "" + continue + } + candidate := paragraph + if current != "" { + candidate = current + "\n\n" + paragraph + } + if utf8.RuneCountInString(candidate) <= c.maxChars { + current = candidate + continue + } + if current != "" { + chunks = append(chunks, current) + current = strings.TrimSpace(c.chunkOverlap(current) + "\n\n" + paragraph) + } else { + current = paragraph + } + } + if strings.TrimSpace(current) != "" { + chunks = append(chunks, current) + } + return chunks +} + +func (c *ParagraphChunker) chunkOverlap(value string) string { + if c.overlapChars <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= c.overlapChars { + return strings.TrimSpace(value) + } + return strings.TrimSpace(string(runes[len(runes)-c.overlapChars:])) +} + +func splitByRuneWindow(value string, maxChars int, overlap int) []string { + runes := []rune(value) + if len(runes) <= maxChars { + return []string{strings.TrimSpace(value)} + } + step := maxChars - overlap + if step <= 0 { + step = maxChars + } + chunks := make([]string, 0, (len(runes)/step)+1) + for start := 0; start < len(runes); start += step { + end := start + maxChars + if end > len(runes) { + end = len(runes) + } + chunk := strings.TrimSpace(string(runes[start:end])) + if chunk != "" { + chunks = append(chunks, chunk) + } + if end == len(runes) { + break + } + } + return chunks +} + +func splitParagraphs(value string) []string { + value = strings.ReplaceAll(value, "\r\n", "\n") + raw := strings.Split(value, "\n\n") + paragraphs := make([]string, 0, len(raw)) + for _, item := range raw { + item = normalizeChunkText(item) + if item != "" { + paragraphs = append(paragraphs, item) + } + } + if len(paragraphs) == 0 { + return []string{normalizeChunkText(value)} + } + return paragraphs +} + +func normalizeChunkText(value string) string { + lines := strings.Split(strings.TrimSpace(value), "\n") + normalized := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.Join(strings.Fields(strings.TrimSpace(line)), " ") + if line != "" { + normalized = append(normalized, line) + } + } + return strings.TrimSpace(strings.Join(normalized, "\n")) +} + +func estimateChunkTokens(value string) int { + runes := utf8.RuneCountInString(value) + if runes == 0 { + return 0 + } + return (runes + 3) / 4 +} + +func buildMemoryChunkID(documentID string, chunkIndex int, contentHash string) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\n%d\n%s", documentID, chunkIndex, contentHash))) + return "mem_chunk_" + hex.EncodeToString(sum[:])[:32] +} + +func buildMemoryChunkPointID(documentID string, chunkIndex int, contentHash string) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\n%d\n%s", documentID, chunkIndex, contentHash))) + hexValue := hex.EncodeToString(sum[:])[:32] + return fmt.Sprintf( + "%s-%s-%s-%s-%s", + hexValue[0:8], + hexValue[8:12], + hexValue[12:16], + hexValue[16:20], + hexValue[20:32], + ) +} + +func cloneUintPtr(value *uint) *uint { + if value == nil { + return nil + } + copied := *value + return &copied +} diff --git a/internal/infrastructure/ai/memory/chunker_test.go b/internal/infrastructure/ai/memory/chunker_test.go new file mode 100644 index 0000000..c84d123 --- /dev/null +++ b/internal/infrastructure/ai/memory/chunker_test.go @@ -0,0 +1,57 @@ +package memory + +import ( + "context" + "strings" + "testing" + + aidomain "personal_assistant/internal/domain/ai" +) + +func TestParagraphChunkerShortText(t *testing.T) { + chunker := NewParagraphChunker(ChunkerOptions{MaxChars: 100, OverlapChars: 10}) + chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ + ID: "doc-1", + ScopeKey: "self:user:1", + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + Content: "短文本内容", + }) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + if len(chunks) != 1 { + t.Fatalf("chunks len = %d, want 1", len(chunks)) + } + if chunks[0].ContentText != "短文本内容" || chunks[0].QdrantPointID == "" || chunks[0].ContentHash == "" { + t.Fatalf("unexpected chunk: %+v", chunks[0]) + } +} + +func TestParagraphChunkerLongTextWithOverlap(t *testing.T) { + chunker := NewParagraphChunker(ChunkerOptions{MaxChars: 20, OverlapChars: 5}) + chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ + ID: "doc-2", + Content: strings.Repeat("a", 45), + }) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + if len(chunks) != 3 { + t.Fatalf("chunks len = %d, want 3: %+v", len(chunks), chunks) + } + if !strings.HasPrefix(chunks[1].ContentText, strings.Repeat("a", 5)) { + t.Fatalf("chunk overlap missing: %+v", chunks[1]) + } +} + +func TestParagraphChunkerEmptyText(t *testing.T) { + chunker := NewParagraphChunker(ChunkerOptions{}) + chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ID: "doc-empty", Content: " "}) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + if len(chunks) != 0 { + t.Fatalf("chunks len = %d, want 0", len(chunks)) + } +} diff --git a/internal/infrastructure/ai/memory/embedder.go b/internal/infrastructure/ai/memory/embedder.go new file mode 100644 index 0000000..b133a0a --- /dev/null +++ b/internal/infrastructure/ai/memory/embedder.go @@ -0,0 +1,187 @@ +package memory + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + aidomain "personal_assistant/internal/domain/ai" +) + +const defaultDashScopeEmbeddingEndpoint = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding" + +// EmbedderOptions 配置 DashScope multimodal embedding 客户端。 +type EmbedderOptions struct { + APIKey string + Endpoint string + Model string + Dimension int + Timeout time.Duration + Client *http.Client +} + +// DashScopeEmbedder 调用阿里云百炼 multimodal embedding 接口。 +type DashScopeEmbedder struct { + apiKey string + endpoint string + model string + dimension int + client *http.Client +} + +// NewDashScopeEmbedder 创建 DashScope embedding 客户端。 +func NewDashScopeEmbedder(opts EmbedderOptions) *DashScopeEmbedder { + if strings.TrimSpace(opts.Endpoint) == "" { + opts.Endpoint = defaultDashScopeEmbeddingEndpoint + } + if opts.Client == nil { + timeout := opts.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + opts.Client = &http.Client{Timeout: timeout} + } + return &DashScopeEmbedder{ + apiKey: strings.TrimSpace(opts.APIKey), + endpoint: strings.TrimSpace(opts.Endpoint), + model: strings.TrimSpace(opts.Model), + dimension: opts.Dimension, + client: opts.Client, + } +} + +// Embed 为输入文本生成向量。 +func (e *DashScopeEmbedder) Embed( + ctx context.Context, + input aidomain.MemoryEmbeddingInput, +) (aidomain.MemoryEmbeddingResult, error) { + if e == nil { + return aidomain.MemoryEmbeddingResult{}, fmt.Errorf("dashscope embedder is nil") + } + if e.apiKey == "" { + return aidomain.MemoryEmbeddingResult{}, fmt.Errorf("dashscope embedding api key is required") + } + if e.model == "" { + return aidomain.MemoryEmbeddingResult{}, fmt.Errorf("dashscope embedding model is required") + } + if e.dimension <= 0 { + return aidomain.MemoryEmbeddingResult{}, fmt.Errorf("dashscope embedding dimension must be positive") + } + texts := normalizeEmbeddingTexts(input.Texts) + if len(texts) == 0 { + return aidomain.MemoryEmbeddingResult{}, nil + } + + reqBody := dashScopeEmbeddingRequest{ + Model: e.model, + Input: dashScopeEmbeddingInput{ + Contents: make([]dashScopeEmbeddingContent, 0, len(texts)), + }, + Parameters: dashScopeEmbeddingParameters{Dimension: e.dimension}, + } + for _, text := range texts { + reqBody.Input.Contents = append(reqBody.Input.Contents, dashScopeEmbeddingContent{Text: text}) + } + payload, err := json.Marshal(reqBody) + if err != nil { + return aidomain.MemoryEmbeddingResult{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, bytes.NewReader(payload)) + if err != nil { + return aidomain.MemoryEmbeddingResult{}, err + } + req.Header.Set("Authorization", "Bearer "+e.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := e.client.Do(req) + if err != nil { + return aidomain.MemoryEmbeddingResult{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return aidomain.MemoryEmbeddingResult{}, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return aidomain.MemoryEmbeddingResult{}, fmt.Errorf("dashscope embedding failed: status=%d body=%s", resp.StatusCode, truncateEmbeddingErrorBody(string(body))) + } + + var parsed dashScopeEmbeddingResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return aidomain.MemoryEmbeddingResult{}, err + } + if len(parsed.Output.Embeddings) != len(texts) { + return aidomain.MemoryEmbeddingResult{}, fmt.Errorf("dashscope embedding count = %d, want %d", len(parsed.Output.Embeddings), len(texts)) + } + vectors := make([][]float32, len(texts)) + for _, item := range parsed.Output.Embeddings { + if item.Index < 0 || item.Index >= len(texts) { + return aidomain.MemoryEmbeddingResult{}, fmt.Errorf("dashscope embedding index out of range: %d", item.Index) + } + if len(item.Embedding) != e.dimension { + return aidomain.MemoryEmbeddingResult{}, fmt.Errorf("dashscope embedding dimension = %d, want %d", len(item.Embedding), e.dimension) + } + vector := make([]float32, len(item.Embedding)) + for i, value := range item.Embedding { + vector[i] = float32(value) + } + vectors[item.Index] = vector + } + return aidomain.MemoryEmbeddingResult{Vectors: vectors}, nil +} + +type dashScopeEmbeddingRequest struct { + Model string `json:"model"` + Input dashScopeEmbeddingInput `json:"input"` + Parameters dashScopeEmbeddingParameters `json:"parameters"` +} + +type dashScopeEmbeddingInput struct { + Contents []dashScopeEmbeddingContent `json:"contents"` +} + +type dashScopeEmbeddingContent struct { + Text string `json:"text"` +} + +type dashScopeEmbeddingParameters struct { + Dimension int `json:"dimension"` +} + +type dashScopeEmbeddingResponse struct { + Output dashScopeEmbeddingOutput `json:"output"` +} + +type dashScopeEmbeddingOutput struct { + Embeddings []dashScopeEmbeddingItem `json:"embeddings"` +} + +type dashScopeEmbeddingItem struct { + Index int `json:"index"` + Embedding []float64 `json:"embedding"` +} + +func normalizeEmbeddingTexts(texts []string) []string { + items := make([]string, 0, len(texts)) + for _, text := range texts { + text = strings.TrimSpace(text) + if text != "" { + items = append(items, text) + } + } + return items +} + +func truncateEmbeddingErrorBody(value string) string { + value = strings.TrimSpace(value) + if len(value) <= 500 { + return value + } + return value[:500] +} diff --git a/internal/infrastructure/ai/memory/embedder_test.go b/internal/infrastructure/ai/memory/embedder_test.go new file mode 100644 index 0000000..064351e --- /dev/null +++ b/internal/infrastructure/ai/memory/embedder_test.go @@ -0,0 +1,74 @@ +package memory + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + aidomain "personal_assistant/internal/domain/ai" +) + +func TestDashScopeEmbedderSendsExpectedRequest(t *testing.T) { + var captured dashScopeEmbeddingRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("Authorization = %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode request: %v", err) + } + _, _ = w.Write([]byte(`{"output":{"embeddings":[{"index":0,"embedding":[0.1,0.2,0.3]}]}}`)) + })) + defer server.Close() + + embedder := NewDashScopeEmbedder(EmbedderOptions{ + APIKey: "test-key", + Endpoint: server.URL, + Model: "qwen3-vl-embedding", + Dimension: 3, + }) + result, err := embedder.Embed(context.Background(), aidomain.MemoryEmbeddingInput{Texts: []string{"hello"}}) + if err != nil { + t.Fatalf("Embed() error = %v", err) + } + if captured.Model != "qwen3-vl-embedding" || captured.Parameters.Dimension != 3 { + t.Fatalf("captured request = %+v", captured) + } + if len(captured.Input.Contents) != 1 || captured.Input.Contents[0].Text != "hello" { + t.Fatalf("captured contents = %+v", captured.Input.Contents) + } + if len(result.Vectors) != 1 || len(result.Vectors[0]) != 3 { + t.Fatalf("vectors = %+v", result.Vectors) + } +} + +func TestDashScopeEmbedderRejectsDimensionMismatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"output":{"embeddings":[{"index":0,"embedding":[0.1,0.2]}]}}`)) + })) + defer server.Close() + + embedder := NewDashScopeEmbedder(EmbedderOptions{ + APIKey: "test-key", + Endpoint: server.URL, + Model: "qwen3-vl-embedding", + Dimension: 3, + }) + if _, err := embedder.Embed(context.Background(), aidomain.MemoryEmbeddingInput{Texts: []string{"hello"}}); err == nil { + t.Fatal("Embed() error = nil, want dimension mismatch") + } +} + +func TestDashScopeEmbedderRequiresAPIKeyAndModel(t *testing.T) { + embedder := NewDashScopeEmbedder(EmbedderOptions{Dimension: 3}) + if _, err := embedder.Embed(context.Background(), aidomain.MemoryEmbeddingInput{Texts: []string{"hello"}}); err == nil { + t.Fatal("Embed() error = nil, want missing api key") + } + + embedder = NewDashScopeEmbedder(EmbedderOptions{APIKey: "test-key", Dimension: 3}) + if _, err := embedder.Embed(context.Background(), aidomain.MemoryEmbeddingInput{Texts: []string{"hello"}}); err == nil { + t.Fatal("Embed() error = nil, want missing model") + } +} diff --git a/internal/infrastructure/ai/memory/extractor.go b/internal/infrastructure/ai/memory/extractor.go new file mode 100644 index 0000000..64fb0e7 --- /dev/null +++ b/internal/infrastructure/ai/memory/extractor.go @@ -0,0 +1,257 @@ +package memory + +import ( + "context" + "encoding/json" + "strings" + "unicode/utf8" + + aidomain "personal_assistant/internal/domain/ai" +) + +const ( + defaultSummaryMaxRunes = 1800 + defaultDocumentMinRunes = 240 + defaultDocumentSummaryMaxRunes = 240 +) + +// RuleExtractor is a conservative deterministic extractor for memory writeback. +type RuleExtractor struct { + summaryMaxRunes int + documentMinRunes int + documentSummaryMaxRunes int +} + +// Options configures RuleExtractor without coupling it to global config. +type Options struct { + SummaryMaxRunes int + DocumentMinRunes int + DocumentSummaryMaxRunes int +} + +// NewRuleExtractor creates the v1 rule-based memory extractor. +func NewRuleExtractor(opts Options) *RuleExtractor { + if opts.SummaryMaxRunes <= 0 { + opts.SummaryMaxRunes = defaultSummaryMaxRunes + } + if opts.DocumentMinRunes <= 0 { + opts.DocumentMinRunes = defaultDocumentMinRunes + } + if opts.DocumentSummaryMaxRunes <= 0 { + opts.DocumentSummaryMaxRunes = defaultDocumentSummaryMaxRunes + } + return &RuleExtractor{ + summaryMaxRunes: opts.SummaryMaxRunes, + documentMinRunes: opts.DocumentMinRunes, + documentSummaryMaxRunes: opts.DocumentSummaryMaxRunes, + } +} + +// Extract produces summary/fact/document candidates from the completed turn. +func (e *RuleExtractor) Extract( + ctx context.Context, + input aidomain.MemoryExtractionInput, +) (aidomain.MemoryExtractionResult, error) { + select { + case <-ctx.Done(): + return aidomain.MemoryExtractionResult{}, ctx.Err() + default: + } + + result := aidomain.MemoryExtractionResult{ + Summary: e.buildSummary(input), + Facts: e.extractFacts(input), + } + if doc := e.extractDocument(input); doc != nil { + result.Documents = append(result.Documents, *doc) + } + return result, nil +} + +func (e *RuleExtractor) buildSummary(input aidomain.MemoryExtractionInput) *aidomain.ConversationSummaryDraft { + userText := normalizeText(input.UserMessage.Content) + assistantText := normalizeText(input.AssistantMessage.Content) + if userText == "" && assistantText == "" { + return nil + } + + parts := make([]string, 0, 3) + if previous := normalizeText(input.PreviousSummaryText); previous != "" { + parts = append(parts, previous) + } + turn := strings.TrimSpace("用户: " + truncateRunes(userText, 320) + "\n助手: " + truncateRunes(assistantText, 900)) + if turn != "" { + parts = append(parts, "最近一轮: "+turn) + } + summaryText := truncateRunes(strings.Join(parts, "\n\n"), e.summaryMaxRunes) + if summaryText == "" { + return nil + } + + keyPoints, _ := json.Marshal([]string{truncateRunes(userText, 160)}) + return &aidomain.ConversationSummaryDraft{ + ConversationID: input.ConversationID, + CompressedUntilMessageID: input.AssistantMessage.ID, + SummaryText: summaryText, + KeyPointsJSON: string(keyPoints), + OpenLoopsJSON: "[]", + TokenEstimate: estimateTokens(summaryText), + } +} + +func (e *RuleExtractor) extractFacts(input aidomain.MemoryExtractionInput) []aidomain.MemoryFactCandidate { + userText := normalizeText(input.UserMessage.Content) + if userText == "" || input.UserID == 0 { + return nil + } + + facts := make([]aidomain.MemoryFactCandidate, 0, 2) + if value := captureAfterAny(userText, []string{"我的目标是", "目标是"}); value != "" { + facts = append(facts, newSelfFactCandidate( + input.UserID, + aidomain.MemoryNamespaceOJGoal, + "current_goal", + value, + input.UserMessage.ID, + )) + } + if value := captureAfterAny(userText, []string{"以后请", "请以后", "以后都", "记住", "请记住"}); value != "" { + facts = append(facts, newSelfFactCandidate( + input.UserID, + aidomain.MemoryNamespaceUserPreference, + "answer_preference", + value, + input.UserMessage.ID, + )) + } + return facts +} + +func (e *RuleExtractor) extractDocument(input aidomain.MemoryExtractionInput) *aidomain.MemoryDocumentCandidate { + query := normalizeText(input.UserMessage.Content) + content := normalizeText(input.AssistantMessage.Content) + if utf8.RuneCountInString(content) < e.documentMinRunes || !isKnowledgeQuery(query) || input.UserID == 0 { + return nil + } + + summary := truncateRunes(firstParagraph(content), e.documentSummaryMaxRunes) + if summary == "" { + summary = truncateRunes(content, e.documentSummaryMaxRunes) + } + userID := input.UserID + return &aidomain.MemoryDocumentCandidate{ + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + MemoryType: aidomain.MemoryTypeSemantic, + Topic: inferTopic(query), + Title: truncateRunes(query, 120), + Summary: summary, + ContentText: content, + SourceKind: aidomain.MemorySourceModelInferred, + SourceID: input.AssistantMessage.ID, + } +} + +func newSelfFactCandidate( + userID uint, + namespace string, + factKey string, + value string, + sourceID string, +) aidomain.MemoryFactCandidate { + value = truncateRunes(normalizeText(value), 240) + payload, _ := json.Marshal(map[string]string{"value": value}) + return aidomain.MemoryFactCandidate{ + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + Namespace: namespace, + FactKey: factKey, + FactValueJSON: string(payload), + Summary: value, + SourceKind: aidomain.MemorySourceExplicitUserStatement, + SourceID: sourceID, + LowValue: utf8.RuneCountInString(value) < 2, + } +} + +func captureAfterAny(text string, markers []string) string { + for _, marker := range markers { + idx := strings.Index(text, marker) + if idx < 0 { + continue + } + value := strings.TrimSpace(text[idx+len(marker):]) + value = strings.Trim(value, " ::,,。.;;") + return truncateAtAny(value, []string{"。", "\n", ";", ";"}) + } + return "" +} + +func isKnowledgeQuery(query string) bool { + keywords := []string{ + "方案", "步骤", "设计", "总结", "排障", "复盘", "如何做", "怎么做", "架构", "实现", + "faq", "runbook", "troubleshoot", "design", "steps", "summary", "architecture", + } + lower := strings.ToLower(query) + for _, keyword := range keywords { + if strings.Contains(lower, strings.ToLower(keyword)) { + return true + } + } + return false +} + +func inferTopic(query string) string { + lower := strings.ToLower(query) + switch { + case strings.Contains(lower, "runbook"): + return "runbook" + case strings.Contains(lower, "faq"): + return "faq" + case strings.Contains(query, "排障"), strings.Contains(lower, "troubleshoot"): + return "troubleshooting" + case strings.Contains(query, "设计"), strings.Contains(query, "架构"), strings.Contains(lower, "design"), strings.Contains(lower, "architecture"): + return "design" + default: + return "conversation_knowledge" + } +} + +func firstParagraph(value string) string { + for _, sep := range []string{"\n\n", "\r\n\r\n"} { + if idx := strings.Index(value, sep); idx >= 0 { + return strings.TrimSpace(value[:idx]) + } + } + return value +} + +func normalizeText(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func truncateAtAny(value string, seps []string) string { + for _, sep := range seps { + if idx := strings.Index(value, sep); idx >= 0 { + return strings.TrimSpace(value[:idx]) + } + } + return strings.TrimSpace(value) +} + +func truncateRunes(value string, limit int) string { + value = strings.TrimSpace(value) + if limit <= 0 || utf8.RuneCountInString(value) <= limit { + return value + } + runes := []rune(value) + return strings.TrimSpace(string(runes[:limit])) +} + +func estimateTokens(value string) int { + runes := utf8.RuneCountInString(value) + if runes == 0 { + return 0 + } + return (runes + 3) / 4 +} diff --git a/internal/infrastructure/ai/memory/extractor_test.go b/internal/infrastructure/ai/memory/extractor_test.go new file mode 100644 index 0000000..9898d18 --- /dev/null +++ b/internal/infrastructure/ai/memory/extractor_test.go @@ -0,0 +1,113 @@ +package memory + +import ( + "context" + "strings" + "testing" + + aidomain "personal_assistant/internal/domain/ai" +) + +func TestRuleExtractorExtractsExplicitSelfFact(t *testing.T) { + extractor := NewRuleExtractor(Options{}) + + result, err := extractor.Extract(context.Background(), aidomain.MemoryExtractionInput{ + ConversationID: "conv-1", + UserID: 7, + UserMessage: aidomain.Message{ + ID: "msg-user-1", + Role: aidomain.RoleUser, + Content: "请记住以后请用更简洁的方式回答我。", + }, + AssistantMessage: aidomain.Message{ + ID: "msg-ai-1", + Role: aidomain.RoleAssistant, + Content: "好的,我会尽量简洁。", + }, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(result.Facts) != 1 { + t.Fatalf("facts len = %d, want 1", len(result.Facts)) + } + fact := result.Facts[0] + if fact.ScopeType != aidomain.MemoryScopeSelf { + t.Fatalf("fact scope = %q, want self", fact.ScopeType) + } + if fact.Namespace != aidomain.MemoryNamespaceUserPreference { + t.Fatalf("namespace = %q, want %q", fact.Namespace, aidomain.MemoryNamespaceUserPreference) + } + if fact.FactKey != "answer_preference" { + t.Fatalf("fact_key = %q, want answer_preference", fact.FactKey) + } + if !strings.Contains(fact.FactValueJSON, "更简洁") { + t.Fatalf("fact value = %s, want captured preference", fact.FactValueJSON) + } +} + +func TestRuleExtractorSkipsCasualChatDocument(t *testing.T) { + extractor := NewRuleExtractor(Options{}) + + result, err := extractor.Extract(context.Background(), aidomain.MemoryExtractionInput{ + ConversationID: "conv-2", + UserID: 8, + UserMessage: aidomain.Message{ + ID: "msg-user-2", + Role: aidomain.RoleUser, + Content: "你好", + }, + AssistantMessage: aidomain.Message{ + ID: "msg-ai-2", + Role: aidomain.RoleAssistant, + Content: "你好,有什么可以帮你?", + }, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(result.Facts) != 0 { + t.Fatalf("facts len = %d, want 0", len(result.Facts)) + } + if len(result.Documents) != 0 { + t.Fatalf("documents len = %d, want 0", len(result.Documents)) + } +} + +func TestRuleExtractorBuildsKnowledgeDocument(t *testing.T) { + extractor := NewRuleExtractor(Options{DocumentMinRunes: 40}) + + result, err := extractor.Extract(context.Background(), aidomain.MemoryExtractionInput{ + ConversationID: "conv-3", + UserID: 9, + UserMessage: aidomain.Message{ + ID: "msg-user-3", + Role: aidomain.RoleUser, + Content: "请给我一个 memory writeback hook 的实现方案", + }, + AssistantMessage: aidomain.Message{ + ID: "msg-ai-3", + Role: aidomain.RoleAssistant, + Content: strings.Repeat( + "先在流式成功收尾后触发写回,再抽取 summary facts documents,最后经过治理策略写入 MySQL。 ", + 3, + ), + }, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(result.Documents) != 1 { + t.Fatalf("documents len = %d, want 1", len(result.Documents)) + } + doc := result.Documents[0] + if doc.ScopeType != aidomain.MemoryScopeSelf { + t.Fatalf("doc scope = %q, want self", doc.ScopeType) + } + if doc.SourceID != "msg-ai-3" { + t.Fatalf("source_id = %q, want msg-ai-3", doc.SourceID) + } + if doc.Topic != "conversation_knowledge" { + t.Fatalf("topic = %q, want conversation_knowledge", doc.Topic) + } +} diff --git a/internal/infrastructure/ai/memory/qdrant_store.go b/internal/infrastructure/ai/memory/qdrant_store.go new file mode 100644 index 0000000..1293f8e --- /dev/null +++ b/internal/infrastructure/ai/memory/qdrant_store.go @@ -0,0 +1,115 @@ +package memory + +import ( + "context" + "fmt" + "strings" + + aidomain "personal_assistant/internal/domain/ai" + + "github.com/qdrant/go-client/qdrant" +) + +// QdrantPointsClient 是 Qdrant client 在 memory index 阶段需要的最小能力。 +type QdrantPointsClient interface { + Delete(ctx context.Context, request *qdrant.DeletePoints) (*qdrant.UpdateResult, error) + Upsert(ctx context.Context, request *qdrant.UpsertPoints) (*qdrant.UpdateResult, error) +} + +// VectorStoreOptions 配置 Qdrant memory vector store。 +type VectorStoreOptions struct { + CollectionName string +} + +// QdrantVectorStore 把 memory chunks 写入 Qdrant collection。 +type QdrantVectorStore struct { + client QdrantPointsClient + collectionName string +} + +// NewQdrantVectorStore 创建 Qdrant vector store。 +func NewQdrantVectorStore(client QdrantPointsClient, opts VectorStoreOptions) *QdrantVectorStore { + return &QdrantVectorStore{ + client: client, + collectionName: strings.TrimSpace(opts.CollectionName), + } +} + +// DeleteDocumentChunks 删除指定 document 已存在的旧 points。 +func (s *QdrantVectorStore) DeleteDocumentChunks(ctx context.Context, documentID string) error { + if s == nil || s.client == nil { + return nil + } + documentID = strings.TrimSpace(documentID) + if documentID == "" { + return nil + } + if s.collectionName == "" { + return fmt.Errorf("qdrant memory collection name is required") + } + wait := true + _, err := s.client.Delete(ctx, &qdrant.DeletePoints{ + CollectionName: s.collectionName, + Wait: &wait, + Points: qdrant.NewPointsSelectorFilter(&qdrant.Filter{ + Must: []*qdrant.Condition{ + qdrant.NewMatchKeyword("document_id", documentID), + }, + }), + }) + return err +} + +// UpsertChunks 写入最新 memory chunk vectors。 +func (s *QdrantVectorStore) UpsertChunks(ctx context.Context, chunks []aidomain.MemoryVectorChunk) error { + if s == nil || s.client == nil || len(chunks) == 0 { + return nil + } + if s.collectionName == "" { + return fmt.Errorf("qdrant memory collection name is required") + } + points := make([]*qdrant.PointStruct, 0, len(chunks)) + for _, item := range chunks { + if len(item.Vector) == 0 || strings.TrimSpace(item.Chunk.QdrantPointID) == "" { + continue + } + points = append(points, &qdrant.PointStruct{ + Id: qdrant.NewID(item.Chunk.QdrantPointID), + Vectors: qdrant.NewVectorsDense(item.Vector), + Payload: qdrant.NewValueMap(buildMemoryVectorPayload(item.Chunk)), + }) + } + if len(points) == 0 { + return nil + } + wait := true + _, err := s.client.Upsert(ctx, &qdrant.UpsertPoints{ + CollectionName: s.collectionName, + Wait: &wait, + Points: points, + }) + return err +} + +func buildMemoryVectorPayload(chunk aidomain.MemoryDocumentChunk) map[string]any { + payload := map[string]any{ + "document_id": chunk.DocumentID, + "chunk_id": chunk.ID, + "scope_key": chunk.ScopeKey, + "scope_type": chunk.ScopeType, + "visibility": chunk.Visibility, + "memory_type": chunk.MemoryType, + "topic": chunk.Topic, + "source_kind": chunk.SourceKind, + "source_id": chunk.SourceID, + "chunk_index": chunk.ChunkIndex, + "content_hash": chunk.ContentHash, + } + if chunk.UserID != nil { + payload["user_id"] = int64(*chunk.UserID) + } + if chunk.OrgID != nil { + payload["org_id"] = int64(*chunk.OrgID) + } + return payload +} diff --git a/internal/infrastructure/ai/memory/qdrant_store_test.go b/internal/infrastructure/ai/memory/qdrant_store_test.go new file mode 100644 index 0000000..73b7b03 --- /dev/null +++ b/internal/infrastructure/ai/memory/qdrant_store_test.go @@ -0,0 +1,80 @@ +package memory + +import ( + "context" + "testing" + + aidomain "personal_assistant/internal/domain/ai" + + "github.com/qdrant/go-client/qdrant" +) + +func TestQdrantVectorStoreDeletesAndUpsertsMemoryChunks(t *testing.T) { + client := &fakeQdrantPointsClient{} + store := NewQdrantVectorStore(client, VectorStoreOptions{CollectionName: "ai_memory_chunks"}) + userID := uint(7) + + err := store.DeleteDocumentChunks(context.Background(), "doc-1") + if err != nil { + t.Fatalf("DeleteDocumentChunks() error = %v", err) + } + if client.deleteRequest == nil || client.deleteRequest.CollectionName != "ai_memory_chunks" { + t.Fatalf("delete request = %+v", client.deleteRequest) + } + filter := client.deleteRequest.GetPoints().GetFilter() + if filter == nil || len(filter.Must) != 1 { + t.Fatalf("delete filter = %+v", filter) + } + + err = store.UpsertChunks(context.Background(), []aidomain.MemoryVectorChunk{ + { + Chunk: aidomain.MemoryDocumentChunk{ + ID: "chunk-1", + DocumentID: "doc-1", + ScopeKey: "self:user:7", + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "design", + SourceKind: string(aidomain.MemorySourceModelInferred), + SourceID: "msg-1", + ChunkIndex: 0, + ContentHash: "hash-1", + QdrantPointID: "11111111-1111-1111-1111-111111111111", + }, + Vector: []float32{0.1, 0.2, 0.3}, + }, + }) + if err != nil { + t.Fatalf("UpsertChunks() error = %v", err) + } + if client.upsertRequest == nil || client.upsertRequest.CollectionName != "ai_memory_chunks" { + t.Fatalf("upsert request = %+v", client.upsertRequest) + } + if len(client.upsertRequest.Points) != 1 { + t.Fatalf("upsert points len = %d, want 1", len(client.upsertRequest.Points)) + } + payload := client.upsertRequest.Points[0].Payload + if payload["document_id"].GetStringValue() != "doc-1" || + payload["chunk_id"].GetStringValue() != "chunk-1" || + payload["scope_key"].GetStringValue() != "self:user:7" || + payload["user_id"].GetIntegerValue() != 7 { + t.Fatalf("payload = %+v", payload) + } +} + +type fakeQdrantPointsClient struct { + deleteRequest *qdrant.DeletePoints + upsertRequest *qdrant.UpsertPoints +} + +func (f *fakeQdrantPointsClient) Delete(_ context.Context, request *qdrant.DeletePoints) (*qdrant.UpdateResult, error) { + f.deleteRequest = request + return &qdrant.UpdateResult{}, nil +} + +func (f *fakeQdrantPointsClient) Upsert(_ context.Context, request *qdrant.UpsertPoints) (*qdrant.UpdateResult, error) { + f.upsertRequest = request + return &qdrant.UpdateResult{}, nil +} diff --git a/internal/model/config/ai.go b/internal/model/config/ai.go index f804f71..ce865af 100644 --- a/internal/model/config/ai.go +++ b/internal/model/config/ai.go @@ -2,13 +2,69 @@ package config // AI 描述 AI runtime 与 Eino 相关配置。 type AI struct { - Provider string `json:"provider" yaml:"provider"` - APIKey string `json:"api_key" yaml:"api_key"` - BaseURL string `json:"base_url" yaml:"base_url"` - Model string `json:"model" yaml:"model"` - ByAzure bool `json:"by_azure" yaml:"by_azure"` - APIVersion string `json:"api_version" yaml:"api_version"` - SystemPrompt string `json:"system_prompt" yaml:"system_prompt"` - Temperature float64 `json:"temperature" yaml:"temperature"` - MaxCompletionTokens int `json:"max_completion_tokens" yaml:"max_completion_tokens"` + Provider string `json:"provider" yaml:"provider"` + APIKey string `json:"api_key" yaml:"api_key"` + BaseURL string `json:"base_url" yaml:"base_url"` + Model string `json:"model" yaml:"model"` + ByAzure bool `json:"by_azure" yaml:"by_azure"` + APIVersion string `json:"api_version" yaml:"api_version"` + SystemPrompt string `json:"system_prompt" yaml:"system_prompt"` + Temperature float64 `json:"temperature" yaml:"temperature"` + MaxCompletionTokens int `json:"max_completion_tokens" yaml:"max_completion_tokens"` + Memory AIMemory `json:"memory" yaml:"memory"` +} + +// AIMemory 描述记忆模块的冻结配置。 +type AIMemory struct { + // Enabled 控制记忆模块总开关。 + // Phase 1 只影响配置装配和后续能力接线,不会直接改变当前 AI 对话链路。 + Enabled bool `json:"enabled" yaml:"enabled"` + // RecallTopK 控制单次召回时每类候选最多取多少条记忆。 + // 后续混合召回会基于这个值做二次裁剪,而不是无上限地把记忆塞进 prompt。 + RecallTopK int `json:"recall_top_k" yaml:"recall_top_k"` + // RecallMaxChars 控制记忆召回结果在拼装 prompt 前允许占用的最大字符数。 + // 这个值用于防止召回文本过长,导致压缩摘要和最近消息被挤出上下文。 + RecallMaxChars int `json:"recall_max_chars" yaml:"recall_max_chars"` + // RecentRawTurns 控制上下文恢复时保留多少轮最近原始消息。 + // 后续会采用 “summary + recent turns” 的组合,这个值决定 recent turns 的窗口大小。 + RecentRawTurns int `json:"recent_raw_turns" yaml:"recent_raw_turns"` + // CompressThresholdTokens 表示会话历史接近多少 token 时开始触发摘要压缩。 + // 它不是模型最大上下文,而是系统层面的提前压缩阈值。 + CompressThresholdTokens int `json:"compress_threshold_tokens" yaml:"compress_threshold_tokens"` + // SummaryRefreshEveryTurns 控制会话摘要每累计多少轮对话后刷新一次。 + // 后续写回链路会据此决定是同步刷新 summary,还是延迟到下一次压缩窗口再做。 + SummaryRefreshEveryTurns int `json:"summary_refresh_every_turns" yaml:"summary_refresh_every_turns"` + // WritebackAsync 控制记忆写回是否优先走异步路径。 + // Phase 1 先冻结配置口径,后续 writeback hook 接入时会据此决定是否走 outbox。 + WritebackAsync bool `json:"writeback_async" yaml:"writeback_async"` + // EnableEntityMemory 控制结构化事实记忆是否启用。 + // 这类记忆通常对应用户偏好、目标、画像等可覆盖事实,最终落到 AIMemoryFact。 + EnableEntityMemory bool `json:"enable_entity_memory" yaml:"enable_entity_memory"` + // EnableLongTermMemory 控制长期文本记忆是否启用。 + // 这类记忆最终落到 AIMemoryDocument,并在后续阶段进入 embedding / vector index 流程。 + EnableLongTermMemory bool `json:"enable_long_term_memory" yaml:"enable_long_term_memory"` + // EnableOrgMemory 控制组织级共享记忆是否参与写入和召回。 + // 关闭后,系统仍可保留 self 级记忆,但不会沉淀 org 侧 FAQ、画像或组织经验。 + EnableOrgMemory bool `json:"enable_org_memory" yaml:"enable_org_memory"` + // EnableOpsMemory 控制平台运维类记忆是否启用。 + // 这类记忆通常对应 incident、runbook 和排障经验,后续只允许超管范围读写。 + EnableOpsMemory bool `json:"enable_ops_memory" yaml:"enable_ops_memory"` + // MinImportance 是记忆准入或保留时的最低重要度阈值。 + // 后续治理阶段会用它过滤低价值候选,避免把闲聊、噪音和瞬时状态写进长期记忆。 + MinImportance float64 `json:"min_importance" yaml:"min_importance"` + // EmbedModel 指定记忆文档使用的 embedding 模型名称。 + // 默认使用阿里云百炼 qwen3-vl-embedding。 + EmbedModel string `json:"embed_model" yaml:"embed_model"` + // EmbedEndpoint 指定记忆文档 embedding 的 HTTP 接口地址。 + EmbedEndpoint string `json:"embed_endpoint" yaml:"embed_endpoint"` + // EmbedDimension 指定 embedding 输出维度,必须和 Qdrant memory collection 维度一致。 + EmbedDimension int `json:"embed_dimension" yaml:"embed_dimension"` + // ChunkMaxChars 控制单个记忆 chunk 的最大字符数。 + ChunkMaxChars int `json:"chunk_max_chars" yaml:"chunk_max_chars"` + // ChunkOverlapChars 控制相邻 chunk 的尾部重叠字符数。 + ChunkOverlapChars int `json:"chunk_overlap_chars" yaml:"chunk_overlap_chars"` + // IndexBatchSize 控制补偿扫描时单批最多处理多少 documents。 + IndexBatchSize int `json:"index_batch_size" yaml:"index_batch_size"` + // IndexTimeoutSeconds 控制单次异步索引任务超时时间。 + IndexTimeoutSeconds int `json:"index_timeout_seconds" yaml:"index_timeout_seconds"` } diff --git a/internal/model/config/config.go b/internal/model/config/config.go index 8849816..0ef7cb7 100644 --- a/internal/model/config/config.go +++ b/internal/model/config/config.go @@ -1,6 +1,10 @@ package config -import "github.com/spf13/viper" +import ( + "os" + + "github.com/spf13/viper" +) // Config 应用全局配置结构体,包含所有核心模块配置 type Config struct { @@ -322,20 +326,49 @@ func NewConfig() *Config { SystemPrompt: viper.GetString("ai.system_prompt"), Temperature: viper.GetFloat64("ai.temperature"), MaxCompletionTokens: viper.GetInt("ai.max_completion_tokens"), + Memory: AIMemory{ + Enabled: viper.GetBool("ai.memory.enabled"), + RecallTopK: viper.GetInt("ai.memory.recall_top_k"), + RecallMaxChars: viper.GetInt("ai.memory.recall_max_chars"), + RecentRawTurns: viper.GetInt("ai.memory.recent_raw_turns"), + CompressThresholdTokens: viper.GetInt("ai.memory.compress_threshold_tokens"), + SummaryRefreshEveryTurns: viper.GetInt("ai.memory.summary_refresh_every_turns"), + WritebackAsync: viper.GetBool("ai.memory.writeback_async"), + EnableEntityMemory: viper.GetBool("ai.memory.enable_entity_memory"), + EnableLongTermMemory: viper.GetBool("ai.memory.enable_long_term_memory"), + EnableOrgMemory: viper.GetBool("ai.memory.enable_org_memory"), + EnableOpsMemory: viper.GetBool("ai.memory.enable_ops_memory"), + MinImportance: viper.GetFloat64("ai.memory.min_importance"), + EmbedModel: viper.GetString("ai.memory.embed_model"), + EmbedEndpoint: viper.GetString("ai.memory.embed_endpoint"), + EmbedDimension: viper.GetInt("ai.memory.embed_dimension"), + ChunkMaxChars: viper.GetInt("ai.memory.chunk_max_chars"), + ChunkOverlapChars: viper.GetInt("ai.memory.chunk_overlap_chars"), + IndexBatchSize: viper.GetInt("ai.memory.index_batch_size"), + IndexTimeoutSeconds: viper.GetInt("ai.memory.index_timeout_seconds"), + }, + } + + legacyCollectionName := viper.GetString("qdrant.collection_name") + knowledgeCollectionName := viper.GetString("qdrant.knowledge_collection_name") + if !hasExplicitConfigValue("qdrant.knowledge_collection_name", "QDRANT_KNOWLEDGE_COLLECTION_NAME") { + knowledgeCollectionName = firstNonEmptyString(legacyCollectionName, knowledgeCollectionName) } _qdrant := &Qdrant{ - Enabled: viper.GetBool("qdrant.enabled"), - Endpoint: viper.GetString("qdrant.endpoint"), - GRPCHost: viper.GetString("qdrant.grpc_host"), - GRPCPort: viper.GetInt("qdrant.grpc_port"), - APIKey: viper.GetString("qdrant.api_key"), - CollectionName: viper.GetString("qdrant.collection_name"), - VectorSize: viper.GetInt("qdrant.vector_size"), - Distance: viper.GetString("qdrant.distance"), - InitCollection: viper.GetBool("qdrant.init_collection"), - TimeoutSeconds: viper.GetInt("qdrant.timeout_seconds"), - UseTLS: viper.GetBool("qdrant.use_tls"), + Enabled: viper.GetBool("qdrant.enabled"), + Endpoint: viper.GetString("qdrant.endpoint"), + GRPCHost: viper.GetString("qdrant.grpc_host"), + GRPCPort: viper.GetInt("qdrant.grpc_port"), + APIKey: viper.GetString("qdrant.api_key"), + CollectionName: knowledgeCollectionName, + KnowledgeCollectionName: knowledgeCollectionName, + MemoryCollectionName: viper.GetString("qdrant.memory_collection_name"), + VectorSize: viper.GetInt("qdrant.vector_size"), + Distance: viper.GetString("qdrant.distance"), + InitCollection: viper.GetBool("qdrant.init_collection"), + TimeoutSeconds: viper.GetInt("qdrant.timeout_seconds"), + UseTLS: viper.GetBool("qdrant.use_tls"), } _observability := &Observability{ @@ -436,3 +469,24 @@ func getCrawlerAPIPrefix(key string) string { } return viper.GetString(key) } + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func hasExplicitConfigValue(key string, envKeys ...string) bool { + if viper.InConfig(key) { + return true + } + for _, envKey := range envKeys { + if _, ok := os.LookupEnv(envKey); ok { + return true + } + } + return false +} diff --git a/internal/model/config/qdrant.go b/internal/model/config/qdrant.go index 1904e4c..97e85a4 100644 --- a/internal/model/config/qdrant.go +++ b/internal/model/config/qdrant.go @@ -16,6 +16,12 @@ type Qdrant struct { // CollectionName 是启动期需要确保存在的单向量 collection 名称。 CollectionName string `json:"collection_name" yaml:"collection_name"` + // KnowledgeCollectionName 是知识库类向量集合名称;为空时兼容回退到 CollectionName。 + KnowledgeCollectionName string `json:"knowledge_collection_name" yaml:"knowledge_collection_name"` + + // MemoryCollectionName 是记忆类向量集合名称;Phase 1 仅保留配置,不在启动期初始化。 + MemoryCollectionName string `json:"memory_collection_name" yaml:"memory_collection_name"` + // VectorSize 是 collection 的向量维度,必须和 embedding 模型输出保持一致。 VectorSize int `json:"vector_size" yaml:"vector_size"` diff --git a/internal/model/entity/ai_conversation_summary.go b/internal/model/entity/ai_conversation_summary.go new file mode 100644 index 0000000..b8d5894 --- /dev/null +++ b/internal/model/entity/ai_conversation_summary.go @@ -0,0 +1,43 @@ +package entity + +import "time" + +// AIConversationSummary 表示会话级压缩摘要。 +type AIConversationSummary struct { + // ConversationID 是会话主键,同时也是 summary 表的主键。 + // 一个会话只保留一份当前有效摘要,因此后续更新按 conversation_id 直接覆盖。 + ConversationID string `json:"conversation_id" gorm:"type:varchar(64);primaryKey;comment:'会话ID'"` + // UserID 是会话所属用户快照。 + // 即使 ScopeKey 已能表达归属,保留显式 user_id 仍有利于权限校验和后台检索。 + UserID uint `json:"user_id" gorm:"not null;index:idx_ai_conversation_summaries_user_id;comment:'所属用户ID'"` + // OrgID 是会话所属组织快照。 + // 会话可能发生在组织上下文内,因此需要保留组织维度辅助恢复和清理。 + OrgID *uint `json:"org_id,omitempty" gorm:"index:idx_ai_conversation_summaries_org_id;comment:'所属组织ID快照'"` + // ScopeKey 是会话摘要归属范围的统一键。 + // 后续 summary 读取时也必须遵守与 facts/documents 相同的作用域口径。 + ScopeKey string `json:"scope_key" gorm:"type:varchar(128);not null;index:idx_ai_conversation_summaries_scope_key;comment:'会话摘要作用域键'"` + // CompressedUntilMessageID 记录摘要已经覆盖到哪一条消息。 + // 后续恢复上下文时,会用 “summary + 该消息之后的 recent turns” 重建会话状态。 + CompressedUntilMessageID string `json:"compressed_until_message_id" gorm:"type:varchar(64);not null;default:'';comment:'压缩截至消息ID'"` + // SummaryText 是给模型直接读取的压缩后正文。 + SummaryText string `json:"summary_text" gorm:"type:longtext;not null;comment:'压缩摘要文本'"` + // KeyPointsJSON 保存摘要中的关键结论列表。 + // 它适合在后续调试、观察和更精细的上下文恢复策略里单独消费。 + KeyPointsJSON string `json:"key_points_json" gorm:"type:longtext;not null;comment:'关键点JSON'"` + // OpenLoopsJSON 保存当前尚未闭环的问题、待办或未确认信息。 + // 这类信息往往比普通摘要更影响下一轮对话连续性。 + OpenLoopsJSON string `json:"open_loops_json" gorm:"type:longtext;not null;comment:'未闭环事项JSON'"` + // TokenEstimate 记录当前摘要大致占用的 token 数量。 + // 后续压缩调优和 prompt 预算控制会依赖这个估算值。 + TokenEstimate int `json:"token_estimate" gorm:"not null;default:0;comment:'token估算值'"` + // CreatedAt 表示摘要首次创建时间。 + CreatedAt time.Time `json:"created_at" gorm:"type:datetime;not null;comment:'创建时间'"` + // UpdatedAt 表示摘要最近一次刷新时间。 + // summary 按 conversation_id 覆盖更新,因此这个时间能直观看出最后一次压缩发生在何时。 + UpdatedAt time.Time `json:"updated_at" gorm:"type:datetime;not null;index:idx_ai_conversation_summaries_updated_at;comment:'更新时间'"` +} + +// TableName 返回会话摘要表名。 +func (AIConversationSummary) TableName() string { + return "ai_conversation_summaries" +} diff --git a/internal/model/entity/ai_memory_document.go b/internal/model/entity/ai_memory_document.go new file mode 100644 index 0000000..55c76df --- /dev/null +++ b/internal/model/entity/ai_memory_document.go @@ -0,0 +1,77 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +// AIMemoryDocument 表示可语义召回的长期记忆文档。 +type AIMemoryDocument struct { + // ID 是文档记录的稳定主键。 + // 当前阶段用它作为 upsert 键;后续如引入 chunk 表,也仍然需要它作为文档根 ID。 + ID string `json:"id" gorm:"type:varchar(64);primaryKey;comment:'记忆文档ID'"` + // ScopeKey 决定文档归属到哪个记忆范围,例如个人、组织或平台运维域。 + ScopeKey string `json:"scope_key" gorm:"type:varchar(128);not null;index:idx_ai_memory_documents_scope_key_memory_type_updated_at,priority:1;uniqueIndex:uk_ai_memory_documents_scope_key_dedup_key,priority:1;comment:'记忆作用域键'"` + // ScopeType 冗余保存作用域类型,方便过滤和调试,不必每次从 ScopeKey 字符串反推。 + ScopeType string `json:"scope_type" gorm:"type:varchar(32);not null;comment:'记忆作用域类型'"` + // Visibility 表示这份文档允许被哪些主体读取。 + // 未来召回时会先做 scope 过滤,再做 visibility 过滤。 + Visibility string `json:"visibility" gorm:"type:varchar(32);not null;comment:'记忆访问等级'"` + // UserID 是关联用户快照,主要服务于调试、清理和权限辅助判断。 + UserID *uint `json:"user_id,omitempty" gorm:"index;comment:'关联用户ID'"` + // OrgID 是关联组织快照,便于做组织级召回和后台管理查询。 + OrgID *uint `json:"org_id,omitempty" gorm:"index;comment:'关联组织ID'"` + // MemoryType 标记这份文档属于哪一类长期记忆。 + // 后续可以用它区分 semantic、episodic、procedural、incident、faq 等不同召回策略。 + MemoryType string `json:"memory_type" gorm:"type:varchar(32);not null;index:idx_ai_memory_documents_scope_key_memory_type_updated_at,priority:2;comment:'长期记忆类型'"` + // Topic 是对文档主题的轻量标签,适合做简单过滤、聚合和调试展示。 + Topic string `json:"topic" gorm:"type:varchar(128);not null;default:'';index:idx_ai_memory_documents_topic_updated_at,priority:1;comment:'主题'"` + // Title 是文档标题,主要用于人类可读展示和后台排查。 + Title string `json:"title" gorm:"type:varchar(255);not null;default:'';comment:'标题'"` + // Summary 是文档的短摘要,用于低成本展示、排序和 prompt 拼装前的预览。 + Summary string `json:"summary" gorm:"type:varchar(1000);not null;default:'';comment:'摘要'"` + // ContentText 是实际参与召回和后续 embedding 的正文内容。 + // 当前阶段按单文档存储,后续多 chunk 阶段会从这里切分出 chunk 子表。 + ContentText string `json:"content_text" gorm:"type:longtext;not null;comment:'召回文本内容'"` + // ContentHash 是规范化正文内容后的稳定哈希。 + // 第一版规则去重优先看 source/topic 组合,缺失时再回退到内容哈希。 + ContentHash string `json:"content_hash" gorm:"type:char(64);not null;default:'';comment:'正文内容哈希'"` + // SummaryHash 是规范化摘要后的稳定哈希。 + // 当缺少 source 标识时,规则去重会优先回退到摘要哈希。 + SummaryHash string `json:"summary_hash" gorm:"type:char(64);not null;default:'';comment:'摘要哈希'"` + // DedupKey 是第一版规则去重的统一键。 + // 它按 source/topic 组合或摘要/正文哈希生成,并在同一 scope 下保持唯一。 + DedupKey string `json:"dedup_key" gorm:"type:varchar(255);not null;default:'';uniqueIndex:uk_ai_memory_documents_scope_key_dedup_key,priority:2;comment:'规则去重键'"` + // Importance 表示这份文档的重要度,用于后续治理和召回排序。 + Importance float64 `json:"importance" gorm:"type:decimal(5,4);not null;default:0;comment:'重要度'"` + // QualityScore 表示这份文档的质量分,例如结构完整度、噪音程度或人工校验结果。 + QualityScore float64 `json:"quality_score" gorm:"type:decimal(5,4);not null;default:0;comment:'质量分'"` + // EmbeddingModel 记录这份文档对应的向量模型。 + // 这样后续切模型或做重建时,可以判断旧索引是不是需要重算。 + EmbeddingModel string `json:"embedding_model" gorm:"type:varchar(128);not null;default:'';comment:'Embedding模型名'"` + // QdrantPointID 是单 chunk 模式下的兼容字段。 + // 当前只保留一个 point id;进入多 chunk 阶段后,会拆到独立 chunk 表管理。 + QdrantPointID string `json:"qdrant_point_id" gorm:"type:varchar(128);not null;default:'';comment:'Qdrant单chunk兼容point ID'"` + // SourceKind 表示文档来源,例如 conversation_summary、faq_import、incident_postmortem。 + SourceKind string `json:"source_kind" gorm:"type:varchar(64);not null;default:'';comment:'来源类型'"` + // SourceID 记录来源对象标识,便于后续去重、回源和追踪。 + SourceID string `json:"source_id" gorm:"type:varchar(128);not null;default:'';comment:'来源ID'"` + // EffectiveAt 表示文档从什么时候开始可参与召回;为空时表示立即可用。 + EffectiveAt *time.Time `json:"effective_at,omitempty" gorm:"type:datetime;comment:'生效时间'"` + // ExpiresAt 表示文档何时过期。 + // repository 默认会过滤过期文档,避免旧 runbook 或旧 FAQ 继续污染召回结果。 + ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"type:datetime;index:idx_ai_memory_documents_expires_at;comment:'过期时间'"` + // CreatedAt 表示文档首次写入时间。 + CreatedAt time.Time `json:"created_at" gorm:"type:datetime;not null;comment:'创建时间'"` + // UpdatedAt 表示文档最近一次更新或重建时间。 + UpdatedAt time.Time `json:"updated_at" gorm:"type:datetime;not null;index:idx_ai_memory_documents_scope_key_memory_type_updated_at,priority:3;index:idx_ai_memory_documents_topic_updated_at,priority:2;comment:'更新时间'"` + // DeletedAt 使用 GORM 软删除。 + // documents 与 facts 不同,它更适合“逻辑失效”而不是唯一键覆盖,所以保留软删除能力。 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;comment:'软删除时间'"` +} + +// TableName 返回长期记忆文档表名。 +func (AIMemoryDocument) TableName() string { + return "ai_memory_documents" +} diff --git a/internal/model/entity/ai_memory_document_chunk.go b/internal/model/entity/ai_memory_document_chunk.go new file mode 100644 index 0000000..0b33b41 --- /dev/null +++ b/internal/model/entity/ai_memory_document_chunk.go @@ -0,0 +1,50 @@ +package entity + +import "time" + +// AIMemoryDocumentChunk 表示长期记忆文档切分后的可向量化片段。 +type AIMemoryDocumentChunk struct { + // ID 是 chunk 的稳定主键,由 document_id、chunk_index 和内容 hash 生成。 + ID string `json:"id" gorm:"type:varchar(64);primaryKey;comment:'记忆文档chunk ID'"` + // DocumentID 关联 ai_memory_documents.id。 + DocumentID string `json:"document_id" gorm:"type:varchar(64);not null;index:idx_ai_memory_document_chunks_document_id,priority:1;index:idx_ai_memory_document_chunks_document_index,priority:1;comment:'记忆文档ID'"` + // ScopeKey 是权限过滤所需的归属键。 + ScopeKey string `json:"scope_key" gorm:"type:varchar(128);not null;index:idx_ai_memory_document_chunks_scope_key_memory_type,priority:1;comment:'记忆作用域键'"` + // ScopeType 冗余保存作用域类型。 + ScopeType string `json:"scope_type" gorm:"type:varchar(32);not null;comment:'记忆作用域类型'"` + // Visibility 表示访问等级。 + Visibility string `json:"visibility" gorm:"type:varchar(32);not null;comment:'记忆访问等级'"` + // UserID 是关联用户快照。 + UserID *uint `json:"user_id,omitempty" gorm:"index;comment:'关联用户ID'"` + // OrgID 是关联组织快照。 + OrgID *uint `json:"org_id,omitempty" gorm:"index;comment:'关联组织ID'"` + // MemoryType 标记长期记忆类型。 + MemoryType string `json:"memory_type" gorm:"type:varchar(32);not null;index:idx_ai_memory_document_chunks_scope_key_memory_type,priority:2;comment:'长期记忆类型'"` + // Topic 是轻量主题标签。 + Topic string `json:"topic" gorm:"type:varchar(128);not null;default:'';index:idx_ai_memory_document_chunks_topic,comment:'主题'"` + // ChunkIndex 表示 chunk 在文档内的顺序。 + ChunkIndex int `json:"chunk_index" gorm:"not null;index:idx_ai_memory_document_chunks_document_index,priority:2;comment:'chunk顺序'"` + // ContentText 是参与 embedding 的 chunk 正文。 + ContentText string `json:"content_text" gorm:"type:longtext;not null;comment:'chunk正文'"` + // ContentHash 是规范化 chunk 正文后的稳定 hash。 + ContentHash string `json:"content_hash" gorm:"type:char(64);not null;default:'';comment:'chunk内容hash'"` + // TokenEstimate 是轻量 token 估算值。 + TokenEstimate int `json:"token_estimate" gorm:"not null;default:0;comment:'token估算值'"` + // EmbeddingModel 记录生成该 chunk 向量时使用的模型。 + EmbeddingModel string `json:"embedding_model" gorm:"type:varchar(128);not null;default:'';index:idx_ai_memory_document_chunks_embedding,priority:1;comment:'Embedding模型名'"` + // EmbeddingDimension 记录向量维度。 + EmbeddingDimension int `json:"embedding_dimension" gorm:"not null;default:0;index:idx_ai_memory_document_chunks_embedding,priority:2;comment:'Embedding维度'"` + // QdrantPointID 是该 chunk 在 Qdrant 中的 point id。 + QdrantPointID string `json:"qdrant_point_id" gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_ai_memory_document_chunks_qdrant_point_id;comment:'Qdrant point ID'"` + // IndexedAt 表示该 chunk 完成 Qdrant 写入的时间。 + IndexedAt *time.Time `json:"indexed_at,omitempty" gorm:"type:datetime;index;comment:'向量索引时间'"` + // CreatedAt 表示首次写入时间。 + CreatedAt time.Time `json:"created_at" gorm:"type:datetime;not null;comment:'创建时间'"` + // UpdatedAt 表示最近一次更新时间。 + UpdatedAt time.Time `json:"updated_at" gorm:"type:datetime;not null;comment:'更新时间'"` +} + +// TableName 返回记忆文档 chunk 表名。 +func (AIMemoryDocumentChunk) TableName() string { + return "ai_memory_document_chunks" +} diff --git a/internal/model/entity/ai_memory_fact.go b/internal/model/entity/ai_memory_fact.go new file mode 100644 index 0000000..126da75 --- /dev/null +++ b/internal/model/entity/ai_memory_fact.go @@ -0,0 +1,54 @@ +package entity + +import "time" + +// AIMemoryFact 表示结构化稳定事实记忆。 +type AIMemoryFact struct { + // ID 是事实记录的数据库主键,仅用于表内唯一标识和更新审计。 + ID uint `json:"id" gorm:"primaryKey;comment:'记忆事实主键ID'"` + // ScopeKey 是统一生成的归属键,决定这条事实属于哪个主体范围。 + // 典型格式包括 self:user:{user_id}、org:{org_id}、platform_ops。 + ScopeKey string `json:"scope_key" gorm:"type:varchar(128);not null;uniqueIndex:uk_ai_memory_facts_scope_key_namespace_fact_key,priority:1;index:idx_ai_memory_facts_scope_key_updated_at,priority:1;comment:'记忆作用域键'"` + // ScopeType 冗余保存作用域类型,避免调用方每次都从 ScopeKey 反解析归属级别。 + ScopeType string `json:"scope_type" gorm:"type:varchar(32);not null;comment:'记忆作用域类型'"` + // Visibility 表示访问等级,决定当前主体是否被允许读取这条事实。 + // 它和 ScopeKey 一起构成最终的授权边界。 + Visibility string `json:"visibility" gorm:"type:varchar(32);not null;comment:'记忆访问等级'"` + // UserID 是关联用户快照,便于权限校验、清理任务和后台调试使用。 + UserID *uint `json:"user_id,omitempty" gorm:"index;comment:'关联用户ID'"` + // OrgID 是关联组织快照,便于组织级记忆过滤、清理和调试使用。 + OrgID *uint `json:"org_id,omitempty" gorm:"index;comment:'关联组织ID'"` + // Namespace 表示事实所属的业务命名空间,例如 user_preference、oj_profile。 + // 后续治理和召回会先按 namespace 分层处理,再按 fact_key 精确定位。 + Namespace string `json:"namespace" gorm:"type:varchar(64);not null;uniqueIndex:uk_ai_memory_facts_scope_key_namespace_fact_key,priority:2;comment:'事实命名空间'"` + // FactKey 是 namespace 下的具体键,例如 answer_style、current_goal。 + FactKey string `json:"fact_key" gorm:"type:varchar(64);not null;uniqueIndex:uk_ai_memory_facts_scope_key_namespace_fact_key,priority:3;comment:'事实键'"` + // FactValueJSON 保存结构化事实值本体。 + // 使用 JSON 而不是拆散成多列,是为了兼容不同 namespace 的异构字段结构。 + FactValueJSON string `json:"fact_value_json" gorm:"type:longtext;not null;comment:'事实值JSON'"` + // Summary 是给调试、回显和后续 prompt 装配用的可读摘要。 + // 它不是真相源,只是对 FactValueJSON 的人类可读表达。 + Summary string `json:"summary" gorm:"type:varchar(500);not null;default:'';comment:'事实摘要'"` + // Confidence 表示这条事实的置信度。 + // 后续治理阶段会根据来源和置信度决定是否允许写入、覆盖或召回。 + Confidence float64 `json:"confidence" gorm:"type:decimal(5,4);not null;default:0;comment:'事实置信度'"` + // SourceKind 表示事实来源类型,例如 conversation、tool_result、admin_set。 + SourceKind string `json:"source_kind" gorm:"type:varchar(64);not null;default:'';comment:'事实来源类型'"` + // SourceID 记录来源对象主键或消息 ID,便于追溯事实是从哪一次输入或事件抽取出来的。 + SourceID string `json:"source_id" gorm:"type:varchar(128);not null;default:'';comment:'事实来源ID'"` + // EffectiveAt 表示这条事实从什么时间开始生效;为空时表示立即生效。 + EffectiveAt *time.Time `json:"effective_at,omitempty" gorm:"type:datetime;comment:'生效时间'"` + // ExpiresAt 表示这条事实的失效时间。 + // repository 默认会过滤过期记录,避免旧目标、旧状态继续污染后续召回。 + ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"type:datetime;index:idx_ai_memory_facts_expires_at;comment:'过期时间'"` + // CreatedAt 表示首次写入时间。 + CreatedAt time.Time `json:"created_at" gorm:"type:datetime;not null;comment:'创建时间'"` + // UpdatedAt 表示最近一次覆盖更新时间。 + // facts 不做软删除,后续同唯一键写入会直接覆盖并刷新这个时间。 + UpdatedAt time.Time `json:"updated_at" gorm:"type:datetime;not null;index:idx_ai_memory_facts_scope_key_updated_at,priority:2;comment:'更新时间'"` +} + +// TableName 返回事实记忆表名。 +func (AIMemoryFact) TableName() string { + return "ai_memory_facts" +} diff --git a/internal/repository/interfaces/aiMemoryRepository.go b/internal/repository/interfaces/aiMemoryRepository.go new file mode 100644 index 0000000..2b4145f --- /dev/null +++ b/internal/repository/interfaces/aiMemoryRepository.go @@ -0,0 +1,37 @@ +package interfaces + +import ( + "context" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" +) + +// AIMemoryRepository 定义记忆模块的最小仓储能力集合。 +type AIMemoryRepository interface { + // WithTx 绑定事务上下文,保持 memory repository 与现有 repository group 的事务风格一致。 + WithTx(tx any) AIMemoryRepository + + // UpsertFact 按 scope_key + namespace + fact_key 唯一键覆盖写入结构化事实。 + UpsertFact(ctx context.Context, fact *entity.AIMemoryFact) error + // ListFacts 按默认规则读取 facts,并自动过滤过期记录。 + ListFacts(ctx context.Context, query aidomain.MemoryFactQuery) ([]*entity.AIMemoryFact, error) + + // BatchUpsertDocuments 批量写入或覆盖长期记忆文档元数据。 + BatchUpsertDocuments(ctx context.Context, docs []*entity.AIMemoryDocument) error + // ListDocuments 按默认规则读取 documents,并自动过滤软删除和过期记录。 + ListDocuments(ctx context.Context, query aidomain.MemoryDocumentQuery) ([]*entity.AIMemoryDocument, error) + // ListDocumentsByIDs 按 document id 读取仍有效的 documents。 + ListDocumentsByIDs(ctx context.Context, ids []string) ([]*entity.AIMemoryDocument, error) + // ListDocumentsNeedingIndex 扫描需要建立或重建向量索引的 documents。 + ListDocumentsNeedingIndex(ctx context.Context, limit int, embedModel string, dimension int) ([]*entity.AIMemoryDocument, error) + // ReplaceDocumentChunks 按 document 覆盖保存最新 chunks。 + ReplaceDocumentChunks(ctx context.Context, documentID string, chunks []*entity.AIMemoryDocumentChunk) error + // ListDocumentChunks 按 document 读取 chunks,按 chunk_index 升序返回。 + ListDocumentChunks(ctx context.Context, documentID string) ([]*entity.AIMemoryDocumentChunk, error) + + // GetConversationSummary 按 conversation_id + user_id + org_id + scope_key 读取当前有效摘要。 + GetConversationSummary(ctx context.Context, query aidomain.MemoryConversationSummaryQuery) (*entity.AIConversationSummary, error) + // UpsertConversationSummary 按 conversation_id 覆盖更新会话摘要。 + UpsertConversationSummary(ctx context.Context, summary *entity.AIConversationSummary) error +} diff --git a/internal/repository/system/aiMemoryRepo.go b/internal/repository/system/aiMemoryRepo.go new file mode 100644 index 0000000..901f8cd --- /dev/null +++ b/internal/repository/system/aiMemoryRepo.go @@ -0,0 +1,468 @@ +package system + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" + "personal_assistant/internal/repository/interfaces" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// AIMemoryGormRepository 基于 GORM 实现记忆仓储。 +type AIMemoryGormRepository struct { + db *gorm.DB +} + +// NewAIMemoryRepository 创建记忆仓储实例。 +func NewAIMemoryRepository(db *gorm.DB) interfaces.AIMemoryRepository { + return &AIMemoryGormRepository{db: db} +} + +// WithTx 绑定事务上下文并返回新的仓储实例。 +func (r *AIMemoryGormRepository) WithTx(tx any) interfaces.AIMemoryRepository { + if transaction, ok := tx.(*gorm.DB); ok { + return &AIMemoryGormRepository{db: transaction} + } + return r +} + +// UpsertFact 按唯一键覆盖更新结构化事实。 +func (r *AIMemoryGormRepository) UpsertFact(ctx context.Context, fact *entity.AIMemoryFact) error { + if fact == nil { + return nil + } + now := time.Now() + // facts 的真相语义是“当前有效值”,不是历史事件流。 + // 因此这里采用唯一键覆盖,而不是先删后插或保留多版本并存。 + return r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "scope_key"}, + {Name: "namespace"}, + {Name: "fact_key"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "scope_type": fact.ScopeType, + "visibility": fact.Visibility, + "user_id": fact.UserID, + "org_id": fact.OrgID, + "fact_value_json": fact.FactValueJSON, + "summary": fact.Summary, + "confidence": fact.Confidence, + "source_kind": fact.SourceKind, + "source_id": fact.SourceID, + "effective_at": fact.EffectiveAt, + "expires_at": fact.ExpiresAt, + "updated_at": now, + }), + }). + Create(fact).Error +} + +// ListFacts 按默认规则过滤过期 facts。 +func (r *AIMemoryGormRepository) ListFacts( + ctx context.Context, + query aidomain.MemoryFactQuery, +) ([]*entity.AIMemoryFact, error) { + if len(query.ScopeKeys) == 0 || len(query.AllowedVisibilities) == 0 { + // 调用方没有给出完整授权边界时,仓储层直接返回空结果,避免误查全表。 + return []*entity.AIMemoryFact{}, nil + } + var rows []*entity.AIMemoryFact + now := time.Now() + db := r.db.WithContext(ctx). + Model(&entity.AIMemoryFact{}). + Where("scope_key IN ?", query.ScopeKeys). + Where("visibility IN ?", aidomain.NormalizeMemoryVisibilities(query.AllowedVisibilities)). + Where("(expires_at IS NULL OR expires_at > ?)", now) + if query.Namespace != "" { + db = db.Where("namespace = ?", query.Namespace) + } + if len(query.FactKeys) > 0 { + db = db.Where("fact_key IN ?", query.FactKeys) + } + if query.Limit > 0 { + db = db.Limit(query.Limit) + } + if err := db.Order("updated_at DESC").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// BatchUpsertDocuments 批量 upsert 文档元数据。 +func (r *AIMemoryGormRepository) BatchUpsertDocuments( + ctx context.Context, + docs []*entity.AIMemoryDocument, +) error { + if len(docs) == 0 { + return nil + } + normalizedDocs, err := r.prepareDocumentUpserts(ctx, docs) + if err != nil { + return err + } + if len(normalizedDocs) == 0 { + return nil + } + now := time.Now() + // documents 后续会进入 embedding / vector store 流程,因此这里保留“按文档 ID 重写元数据”的能力。 + // 如果旧文档曾被软删除,再次 upsert 时会自动恢复 deleted_at。 + assignments := clause.Set{ + {Column: clause.Column{Name: "scope_key"}, Value: clause.Column{Table: "excluded", Name: "scope_key"}}, + {Column: clause.Column{Name: "scope_type"}, Value: clause.Column{Table: "excluded", Name: "scope_type"}}, + {Column: clause.Column{Name: "visibility"}, Value: clause.Column{Table: "excluded", Name: "visibility"}}, + {Column: clause.Column{Name: "user_id"}, Value: clause.Column{Table: "excluded", Name: "user_id"}}, + {Column: clause.Column{Name: "org_id"}, Value: clause.Column{Table: "excluded", Name: "org_id"}}, + {Column: clause.Column{Name: "memory_type"}, Value: clause.Column{Table: "excluded", Name: "memory_type"}}, + {Column: clause.Column{Name: "topic"}, Value: clause.Column{Table: "excluded", Name: "topic"}}, + {Column: clause.Column{Name: "title"}, Value: clause.Column{Table: "excluded", Name: "title"}}, + {Column: clause.Column{Name: "summary"}, Value: clause.Column{Table: "excluded", Name: "summary"}}, + {Column: clause.Column{Name: "content_text"}, Value: clause.Column{Table: "excluded", Name: "content_text"}}, + {Column: clause.Column{Name: "content_hash"}, Value: clause.Column{Table: "excluded", Name: "content_hash"}}, + {Column: clause.Column{Name: "summary_hash"}, Value: clause.Column{Table: "excluded", Name: "summary_hash"}}, + {Column: clause.Column{Name: "dedup_key"}, Value: clause.Column{Table: "excluded", Name: "dedup_key"}}, + {Column: clause.Column{Name: "importance"}, Value: clause.Column{Table: "excluded", Name: "importance"}}, + {Column: clause.Column{Name: "quality_score"}, Value: clause.Column{Table: "excluded", Name: "quality_score"}}, + {Column: clause.Column{Name: "embedding_model"}, Value: clause.Column{Table: "excluded", Name: "embedding_model"}}, + {Column: clause.Column{Name: "qdrant_point_id"}, Value: clause.Column{Table: "excluded", Name: "qdrant_point_id"}}, + {Column: clause.Column{Name: "source_kind"}, Value: clause.Column{Table: "excluded", Name: "source_kind"}}, + {Column: clause.Column{Name: "source_id"}, Value: clause.Column{Table: "excluded", Name: "source_id"}}, + {Column: clause.Column{Name: "effective_at"}, Value: clause.Column{Table: "excluded", Name: "effective_at"}}, + {Column: clause.Column{Name: "expires_at"}, Value: clause.Column{Table: "excluded", Name: "expires_at"}}, + {Column: clause.Column{Name: "deleted_at"}, Value: nil}, + {Column: clause.Column{Name: "updated_at"}, Value: now}, + } + return r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: assignments, + }). + Create(normalizedDocs).Error +} + +// ListDocuments 按默认规则过滤过期和已删除文档。 +func (r *AIMemoryGormRepository) ListDocuments( + ctx context.Context, + query aidomain.MemoryDocumentQuery, +) ([]*entity.AIMemoryDocument, error) { + if len(query.ScopeKeys) == 0 || len(query.AllowedVisibilities) == 0 { + // 与 facts 一样,scope 和 visibility 任一缺失都不放宽查询。 + return []*entity.AIMemoryDocument{}, nil + } + var rows []*entity.AIMemoryDocument + now := time.Now() + db := r.db.WithContext(ctx). + Model(&entity.AIMemoryDocument{}). + Where("scope_key IN ?", query.ScopeKeys). + Where("visibility IN ?", aidomain.NormalizeMemoryVisibilities(query.AllowedVisibilities)). + Where("(expires_at IS NULL OR expires_at > ?)", now) + if len(query.MemoryTypes) > 0 { + db = db.Where("memory_type IN ?", aidomain.NormalizeMemoryTypes(query.MemoryTypes)) + } + if query.Topic != "" { + db = db.Where("topic = ?", query.Topic) + } + if query.Limit > 0 { + db = db.Limit(query.Limit) + } + if err := db.Order("updated_at DESC").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// ListDocumentsByIDs 按 document id 读取仍有效的文档。 +func (r *AIMemoryGormRepository) ListDocumentsByIDs( + ctx context.Context, + ids []string, +) ([]*entity.AIMemoryDocument, error) { + normalizedIDs := normalizeMemoryDocumentIDs(ids) + if len(normalizedIDs) == 0 { + return []*entity.AIMemoryDocument{}, nil + } + var rows []*entity.AIMemoryDocument + now := time.Now() + err := r.db.WithContext(ctx). + Model(&entity.AIMemoryDocument{}). + Where("id IN ?", normalizedIDs). + Where("(expires_at IS NULL OR expires_at > ?)", now). + Order("updated_at ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +// ListDocumentsNeedingIndex 扫描未索引或索引已过期的长期记忆文档。 +func (r *AIMemoryGormRepository) ListDocumentsNeedingIndex( + ctx context.Context, + limit int, + embedModel string, + dimension int, +) ([]*entity.AIMemoryDocument, error) { + embedModel = strings.TrimSpace(embedModel) + if embedModel == "" || dimension <= 0 { + return []*entity.AIMemoryDocument{}, nil + } + var rows []*entity.AIMemoryDocument + now := time.Now() + db := r.db.WithContext(ctx). + Model(&entity.AIMemoryDocument{}). + Where("content_text <> ''"). + Where("(expires_at IS NULL OR expires_at > ?)", now). + Where(` + NOT EXISTS ( + SELECT 1 FROM ai_memory_document_chunks c + WHERE c.document_id = ai_memory_documents.id + ) + OR EXISTS ( + SELECT 1 FROM ai_memory_document_chunks c + WHERE c.document_id = ai_memory_documents.id + AND (c.embedding_model <> ? OR c.embedding_dimension <> ?) + ) + OR ai_memory_documents.updated_at > ( + SELECT COALESCE(MAX(c.indexed_at), '1970-01-01 00:00:00') + FROM ai_memory_document_chunks c + WHERE c.document_id = ai_memory_documents.id + ) + `, embedModel, dimension) + if limit > 0 { + db = db.Limit(limit) + } + if err := db.Order("updated_at ASC").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// ReplaceDocumentChunks 按 document 覆盖保存最新 chunks。 +func (r *AIMemoryGormRepository) ReplaceDocumentChunks( + ctx context.Context, + documentID string, + chunks []*entity.AIMemoryDocumentChunk, +) error { + documentID = strings.TrimSpace(documentID) + if documentID == "" { + return nil + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("document_id = ?", documentID).Delete(&entity.AIMemoryDocumentChunk{}).Error; err != nil { + return err + } + if len(chunks) == 0 { + return nil + } + now := time.Now() + normalized := make([]*entity.AIMemoryDocumentChunk, 0, len(chunks)) + for _, chunk := range chunks { + if chunk == nil { + continue + } + chunk.DocumentID = documentID + if chunk.CreatedAt.IsZero() { + chunk.CreatedAt = now + } + chunk.UpdatedAt = now + normalized = append(normalized, chunk) + } + if len(normalized) == 0 { + return nil + } + return tx.Create(normalized).Error + }) +} + +// ListDocumentChunks 读取指定 document 的 chunks。 +func (r *AIMemoryGormRepository) ListDocumentChunks( + ctx context.Context, + documentID string, +) ([]*entity.AIMemoryDocumentChunk, error) { + documentID = strings.TrimSpace(documentID) + if documentID == "" { + return []*entity.AIMemoryDocumentChunk{}, nil + } + var rows []*entity.AIMemoryDocumentChunk + if err := r.db.WithContext(ctx). + Where("document_id = ?", documentID). + Order("chunk_index ASC"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// GetConversationSummary 获取指定会话的压缩摘要。 +func (r *AIMemoryGormRepository) GetConversationSummary( + ctx context.Context, + query aidomain.MemoryConversationSummaryQuery, +) (*entity.AIConversationSummary, error) { + if strings.TrimSpace(query.ConversationID) == "" || query.UserID == 0 || strings.TrimSpace(query.ScopeKey) == "" { + return nil, nil + } + var summary entity.AIConversationSummary + // summary 不是历史版本集合,而是按 conversation_id 覆盖维护的当前快照。 + // 读取时必须同时校验 user_id / org_id / scope_key,避免跨主体误命中。 + db := r.db.WithContext(ctx). + Where("conversation_id = ?", query.ConversationID). + Where("user_id = ?", query.UserID). + Where("scope_key = ?", query.ScopeKey) + if query.OrgID == nil { + db = db.Where("org_id IS NULL") + } else { + db = db.Where("org_id = ?", *query.OrgID) + } + if err := db.First(&summary).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &summary, nil +} + +// UpsertConversationSummary 按主键覆盖更新会话摘要。 +func (r *AIMemoryGormRepository) UpsertConversationSummary( + ctx context.Context, + summary *entity.AIConversationSummary, +) error { + if summary == nil { + return nil + } + now := time.Now() + // 会话摘要始终保留“当前版本”。 + // 因此这里按 conversation_id 主键覆盖,不保留多版本并存。 + return r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "conversation_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "user_id": summary.UserID, + "org_id": summary.OrgID, + "scope_key": summary.ScopeKey, + "compressed_until_message_id": summary.CompressedUntilMessageID, + "summary_text": summary.SummaryText, + "key_points_json": summary.KeyPointsJSON, + "open_loops_json": summary.OpenLoopsJSON, + "token_estimate": summary.TokenEstimate, + "updated_at": now, + }), + }). + Create(summary).Error +} + +func (r *AIMemoryGormRepository) prepareDocumentUpserts( + ctx context.Context, + docs []*entity.AIMemoryDocument, +) ([]*entity.AIMemoryDocument, error) { + dedupedDocs := make([]*entity.AIMemoryDocument, 0, len(docs)) + indexByKey := make(map[string]int, len(docs)) + for _, doc := range docs { + if doc == nil { + continue + } + if err := normalizeMemoryDocumentMetadata(doc); err != nil { + return nil, err + } + key := buildScopedDocumentDedupIdentity(doc.ScopeKey, doc.DedupKey) + if existingIndex, ok := indexByKey[key]; ok { + dedupedDocs[existingIndex] = doc + continue + } + indexByKey[key] = len(dedupedDocs) + dedupedDocs = append(dedupedDocs, doc) + } + + for _, doc := range dedupedDocs { + existingID, err := r.findExistingDocumentID(ctx, doc.ScopeKey, doc.DedupKey) + if err != nil { + return nil, err + } + if existingID != "" { + doc.ID = existingID + } + } + return dedupedDocs, nil +} + +func (r *AIMemoryGormRepository) findExistingDocumentID( + ctx context.Context, + scopeKey string, + dedupKey string, +) (string, error) { + var row entity.AIMemoryDocument + err := r.db.WithContext(ctx). + Unscoped(). + Select("id"). + Where("scope_key = ?", scopeKey). + Where("dedup_key = ?", dedupKey). + First(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + return row.ID, nil +} + +func normalizeMemoryDocumentMetadata(doc *entity.AIMemoryDocument) error { + if doc == nil { + return nil + } + doc.ScopeKey = strings.TrimSpace(doc.ScopeKey) + doc.ID = strings.TrimSpace(doc.ID) + doc.Topic = strings.TrimSpace(doc.Topic) + doc.SourceID = strings.TrimSpace(doc.SourceID) + if doc.ScopeKey == "" { + return fmt.Errorf("memory document scope_key is required") + } + if doc.ID == "" { + return fmt.Errorf("memory document id is required") + } + + doc.ContentHash = aidomain.BuildMemoryDocumentContentHash(doc.ContentText) + doc.SummaryHash = aidomain.BuildMemoryDocumentSummaryHash(doc.Summary) + doc.DedupKey = aidomain.BuildMemoryDocumentDedupKey( + aidomain.MemorySourceKind(doc.SourceKind), + doc.SourceID, + doc.Topic, + doc.Summary, + doc.ContentText, + ) + if doc.DedupKey == "" { + return fmt.Errorf("memory document dedup_key is required") + } + return nil +} + +func buildScopedDocumentDedupIdentity(scopeKey string, dedupKey string) string { + return strings.TrimSpace(scopeKey) + "\n" + strings.TrimSpace(dedupKey) +} + +func normalizeMemoryDocumentIDs(ids []string) []string { + if len(ids) == 0 { + return nil + } + seen := make(map[string]struct{}, len(ids)) + items := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + items = append(items, id) + } + return items +} diff --git a/internal/repository/system/aiMemoryRepo_test.go b/internal/repository/system/aiMemoryRepo_test.go new file mode 100644 index 0000000..45fa6be --- /dev/null +++ b/internal/repository/system/aiMemoryRepo_test.go @@ -0,0 +1,603 @@ +package system + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" + "personal_assistant/internal/repository/interfaces" +) + +func TestAIMemoryModelsAutoMigrateAddsExpectedTablesAndIndexes(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + + if !db.Migrator().HasTable(&entity.AIMemoryFact{}) { + t.Fatal("ai_memory_facts table missing") + } + if !db.Migrator().HasTable(&entity.AIMemoryDocument{}) { + t.Fatal("ai_memory_documents table missing") + } + if !db.Migrator().HasTable(&entity.AIMemoryDocumentChunk{}) { + t.Fatal("ai_memory_document_chunks table missing") + } + if !db.Migrator().HasTable(&entity.AIConversationSummary{}) { + t.Fatal("ai_conversation_summaries table missing") + } + + assertHasIndex(t, db, &entity.AIMemoryFact{}, "uk_ai_memory_facts_scope_key_namespace_fact_key") + assertHasIndex(t, db, &entity.AIMemoryFact{}, "idx_ai_memory_facts_scope_key_updated_at") + assertHasIndex(t, db, &entity.AIMemoryFact{}, "idx_ai_memory_facts_expires_at") + assertHasIndex(t, db, &entity.AIMemoryDocument{}, "idx_ai_memory_documents_scope_key_memory_type_updated_at") + assertHasIndex(t, db, &entity.AIMemoryDocument{}, "idx_ai_memory_documents_topic_updated_at") + assertHasIndex(t, db, &entity.AIMemoryDocument{}, "idx_ai_memory_documents_expires_at") + assertHasIndex(t, db, &entity.AIMemoryDocument{}, "uk_ai_memory_documents_scope_key_dedup_key") + assertHasIndex(t, db, &entity.AIMemoryDocumentChunk{}, "idx_ai_memory_document_chunks_document_id") + assertHasIndex(t, db, &entity.AIMemoryDocumentChunk{}, "idx_ai_memory_document_chunks_document_index") + assertHasIndex(t, db, &entity.AIMemoryDocumentChunk{}, "uk_ai_memory_document_chunks_qdrant_point_id") + assertHasIndex(t, db, &entity.AIConversationSummary{}, "idx_ai_conversation_summaries_scope_key") + assertHasIndex(t, db, &entity.AIConversationSummary{}, "idx_ai_conversation_summaries_user_id") + assertHasIndex(t, db, &entity.AIConversationSummary{}, "idx_ai_conversation_summaries_org_id") + assertHasIndex(t, db, &entity.AIConversationSummary{}, "idx_ai_conversation_summaries_updated_at") +} + +func TestAIMemoryRepositoryUpsertFactOverwritesByUniqueKey(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(101) + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + + fact := &entity.AIMemoryFact{ + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + Namespace: aidomain.MemoryNamespaceUserPreference, + FactKey: "answer_style", + FactValueJSON: `{"style":"brief"}`, + Summary: "prefer brief answer", + Confidence: 0.82, + SourceKind: "conversation", + SourceID: "msg-1", + } + if err := repo.UpsertFact(ctx, fact); err != nil { + t.Fatalf("UpsertFact(create) error = %v", err) + } + + updated := &entity.AIMemoryFact{ + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + Namespace: aidomain.MemoryNamespaceUserPreference, + FactKey: "answer_style", + FactValueJSON: `{"style":"step_by_step"}`, + Summary: "prefer step-by-step answer", + Confidence: 0.95, + SourceKind: "conversation", + SourceID: "msg-2", + } + if err := repo.UpsertFact(ctx, updated); err != nil { + t.Fatalf("UpsertFact(update) error = %v", err) + } + + var rows []entity.AIMemoryFact + if err := db.Model(&entity.AIMemoryFact{}).Find(&rows).Error; err != nil { + t.Fatalf("load facts: %v", err) + } + if len(rows) != 1 { + t.Fatalf("facts row count = %d, want 1", len(rows)) + } + if rows[0].Summary != updated.Summary { + t.Fatalf("fact summary = %q, want %q", rows[0].Summary, updated.Summary) + } + if rows[0].FactValueJSON != updated.FactValueJSON { + t.Fatalf("fact value = %q, want %q", rows[0].FactValueJSON, updated.FactValueJSON) + } + if rows[0].SourceID != updated.SourceID { + t.Fatalf("fact source_id = %q, want %q", rows[0].SourceID, updated.SourceID) + } +} + +func TestAIMemoryRepositoryListFactsFiltersExpiredAndVisibility(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(102) + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + expiredAt := time.Now().Add(-time.Hour) + + mustUpsertFact(t, repo, ctx, &entity.AIMemoryFact{ + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + Namespace: aidomain.MemoryNamespaceUserPreference, + FactKey: "language", + FactValueJSON: `{"value":"go"}`, + Summary: "prefer go", + }) + mustUpsertFact(t, repo, ctx, &entity.AIMemoryFact{ + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + Namespace: aidomain.MemoryNamespaceUserPreference, + FactKey: "legacy_goal", + FactValueJSON: `{"value":"expired"}`, + Summary: "expired", + ExpiresAt: &expiredAt, + }) + mustUpsertFact(t, repo, ctx, &entity.AIMemoryFact{ + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilityOrg), + UserID: &userID, + Namespace: aidomain.MemoryNamespaceUserPreference, + FactKey: "org_only", + FactValueJSON: `{"value":"hidden"}`, + Summary: "hidden by visibility", + }) + + rows, err := repo.ListFacts(ctx, aidomain.MemoryFactQuery{ + ScopeKeys: []string{scopeKey}, + AllowedVisibilities: []aidomain.MemoryVisibility{aidomain.MemoryVisibilitySelf}, + Namespace: aidomain.MemoryNamespaceUserPreference, + }) + if err != nil { + t.Fatalf("ListFacts() error = %v", err) + } + if len(rows) != 1 { + t.Fatalf("ListFacts() count = %d, want 1", len(rows)) + } + if rows[0].FactKey != "language" { + t.Fatalf("ListFacts()[0].FactKey = %q, want %q", rows[0].FactKey, "language") + } +} + +func TestAIMemoryRepositoryListDocumentsFiltersExpiredAndSoftDeleted(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(103) + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + expiredAt := time.Now().Add(-time.Hour) + + docs := []*entity.AIMemoryDocument{ + { + ID: "doc-active", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "crawler", + Title: "active", + Summary: "active", + ContentText: "active content", + }, + { + ID: "doc-expired", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "crawler", + Title: "expired", + Summary: "expired", + ContentText: "expired content", + ExpiresAt: &expiredAt, + }, + { + ID: "doc-deleted", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "crawler", + Title: "deleted", + Summary: "deleted", + ContentText: "deleted content", + }, + } + if err := repo.BatchUpsertDocuments(ctx, docs); err != nil { + t.Fatalf("BatchUpsertDocuments() error = %v", err) + } + if err := db.Delete(&entity.AIMemoryDocument{}, "id = ?", "doc-deleted").Error; err != nil { + t.Fatalf("soft delete document: %v", err) + } + + rows, err := repo.ListDocuments(ctx, aidomain.MemoryDocumentQuery{ + ScopeKeys: []string{scopeKey}, + AllowedVisibilities: []aidomain.MemoryVisibility{aidomain.MemoryVisibilitySelf}, + MemoryTypes: []aidomain.MemoryType{aidomain.MemoryTypeSemantic}, + Topic: "crawler", + }) + if err != nil { + t.Fatalf("ListDocuments() error = %v", err) + } + if len(rows) != 1 { + t.Fatalf("ListDocuments() count = %d, want 1", len(rows)) + } + if rows[0].ID != "doc-active" { + t.Fatalf("ListDocuments()[0].ID = %q, want %q", rows[0].ID, "doc-active") + } +} + +func TestAIMemoryRepositoryBatchUpsertDocumentsDedupsByScopeAndDedupKey(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(104) + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + + first := &entity.AIMemoryDocument{ + ID: "doc-faq-1", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeFAQ), + Topic: "deploy", + Title: "faq", + Summary: "How to deploy", + ContentText: "Use docker compose for local deployment", + SourceKind: "faq_import", + SourceID: "faq-001", + } + second := &entity.AIMemoryDocument{ + ID: "doc-faq-2", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeFAQ), + Topic: "deploy", + Title: "faq updated", + Summary: "How to deploy safely", + ContentText: "Use docker compose and run migrations before release", + SourceKind: "faq_import", + SourceID: "faq-001", + } + + if err := repo.BatchUpsertDocuments(ctx, []*entity.AIMemoryDocument{first}); err != nil { + t.Fatalf("BatchUpsertDocuments(first) error = %v", err) + } + if err := repo.BatchUpsertDocuments(ctx, []*entity.AIMemoryDocument{second}); err != nil { + t.Fatalf("BatchUpsertDocuments(second) error = %v", err) + } + + var count int64 + if err := db.Model(&entity.AIMemoryDocument{}).Count(&count).Error; err != nil { + t.Fatalf("count documents: %v", err) + } + if count != 1 { + t.Fatalf("document row count = %d, want 1", count) + } + + row := &entity.AIMemoryDocument{} + if err := db.First(row).Error; err != nil { + t.Fatalf("load deduped document: %v", err) + } + if row.ID != first.ID { + t.Fatalf("deduped document id = %q, want preserved existing id %q", row.ID, first.ID) + } + if row.Summary != second.Summary { + t.Fatalf("deduped document summary = %q, want %q", row.Summary, second.Summary) + } + if row.ContentHash == "" || row.SummaryHash == "" || row.DedupKey == "" { + t.Fatalf("dedup metadata not generated: %+v", row) + } + wantDedupKey := aidomain.BuildMemoryDocumentDedupKey( + aidomain.MemorySourceKind(second.SourceKind), + second.SourceID, + second.Topic, + second.Summary, + second.ContentText, + ) + if row.DedupKey != wantDedupKey { + t.Fatalf("dedup_key = %q, want %q", row.DedupKey, wantDedupKey) + } +} + +func TestAIMemoryRepositoryUpsertConversationSummaryOverwritesByConversationID(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(105) + orgID := uint(205) + + first := &entity.AIConversationSummary{ + ConversationID: "conv-1", + UserID: userID, + ScopeKey: aidomain.BuildConversationMemoryScopeKey(userID, nil), + CompressedUntilMessageID: "msg-1", + SummaryText: "first summary", + KeyPointsJSON: `["a"]`, + OpenLoopsJSON: `[]`, + TokenEstimate: 120, + } + if err := repo.UpsertConversationSummary(ctx, first); err != nil { + t.Fatalf("UpsertConversationSummary(create) error = %v", err) + } + + second := &entity.AIConversationSummary{ + ConversationID: "conv-1", + UserID: userID, + OrgID: &orgID, + ScopeKey: aidomain.BuildConversationMemoryScopeKey(userID, &orgID), + CompressedUntilMessageID: "msg-9", + SummaryText: "updated summary", + KeyPointsJSON: `["a","b"]`, + OpenLoopsJSON: `["follow-up"]`, + TokenEstimate: 256, + } + if err := repo.UpsertConversationSummary(ctx, second); err != nil { + t.Fatalf("UpsertConversationSummary(update) error = %v", err) + } + + var count int64 + if err := db.Model(&entity.AIConversationSummary{}).Count(&count).Error; err != nil { + t.Fatalf("count summaries: %v", err) + } + if count != 1 { + t.Fatalf("summary row count = %d, want 1", count) + } + + row, err := repo.GetConversationSummary(ctx, aidomain.MemoryConversationSummaryQuery{ + ConversationID: "conv-1", + UserID: userID, + OrgID: &orgID, + ScopeKey: second.ScopeKey, + }) + if err != nil { + t.Fatalf("GetConversationSummary() error = %v", err) + } + if row == nil { + t.Fatal("GetConversationSummary() returned nil row") + } + if row.ScopeKey != second.ScopeKey { + t.Fatalf("summary scope_key = %q, want %q", row.ScopeKey, second.ScopeKey) + } + if row.CompressedUntilMessageID != second.CompressedUntilMessageID { + t.Fatalf( + "summary compressed_until_message_id = %q, want %q", + row.CompressedUntilMessageID, + second.CompressedUntilMessageID, + ) + } + if row.OrgID == nil || *row.OrgID != orgID { + t.Fatalf("summary org_id = %v, want %d", row.OrgID, orgID) + } +} + +func TestAIMemoryRepositoryGetConversationSummaryRejectsMismatchedIdentity(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(106) + orgID := uint(206) + + summary := &entity.AIConversationSummary{ + ConversationID: "conv-2", + UserID: userID, + OrgID: &orgID, + ScopeKey: aidomain.BuildConversationMemoryScopeKey(userID, &orgID), + CompressedUntilMessageID: "msg-3", + SummaryText: "summary", + KeyPointsJSON: `["a"]`, + OpenLoopsJSON: `[]`, + TokenEstimate: 42, + } + if err := repo.UpsertConversationSummary(ctx, summary); err != nil { + t.Fatalf("UpsertConversationSummary() error = %v", err) + } + + mismatchCases := []aidomain.MemoryConversationSummaryQuery{ + { + ConversationID: "conv-2", + UserID: 999, + OrgID: &orgID, + ScopeKey: summary.ScopeKey, + }, + { + ConversationID: "conv-2", + UserID: userID, + OrgID: nil, + ScopeKey: summary.ScopeKey, + }, + { + ConversationID: "conv-2", + UserID: userID, + OrgID: &orgID, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(userID), + }, + } + + for _, query := range mismatchCases { + row, err := repo.GetConversationSummary(ctx, query) + if err != nil { + t.Fatalf("GetConversationSummary(%+v) error = %v", query, err) + } + if row != nil { + t.Fatalf("GetConversationSummary(%+v) = %+v, want nil", query, row) + } + } +} + +func TestAIMemoryRepositoryReplaceDocumentChunksOverwrites(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + now := time.Now() + + first := []*entity.AIMemoryDocumentChunk{ + { + ID: "chunk-1", + DocumentID: "doc-chunks", + ScopeKey: "self:user:1", + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + MemoryType: string(aidomain.MemoryTypeSemantic), + ChunkIndex: 0, + ContentText: "first", + ContentHash: "hash-first", + EmbeddingModel: "qwen3-vl-embedding", + EmbeddingDimension: 1024, + QdrantPointID: "11111111-1111-1111-1111-111111111111", + IndexedAt: &now, + }, + } + if err := repo.ReplaceDocumentChunks(ctx, "doc-chunks", first); err != nil { + t.Fatalf("ReplaceDocumentChunks(first) error = %v", err) + } + second := []*entity.AIMemoryDocumentChunk{ + { + ID: "chunk-2", + DocumentID: "doc-chunks", + ScopeKey: "self:user:1", + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + MemoryType: string(aidomain.MemoryTypeSemantic), + ChunkIndex: 0, + ContentText: "second", + ContentHash: "hash-second", + EmbeddingModel: "qwen3-vl-embedding", + EmbeddingDimension: 1024, + QdrantPointID: "22222222-2222-2222-2222-222222222222", + IndexedAt: &now, + }, + } + if err := repo.ReplaceDocumentChunks(ctx, "doc-chunks", second); err != nil { + t.Fatalf("ReplaceDocumentChunks(second) error = %v", err) + } + rows, err := repo.ListDocumentChunks(ctx, "doc-chunks") + if err != nil { + t.Fatalf("ListDocumentChunks() error = %v", err) + } + if len(rows) != 1 || rows[0].ID != "chunk-2" { + t.Fatalf("chunks = %+v, want only chunk-2", rows) + } +} + +func TestAIMemoryRepositoryListDocumentsNeedingIndex(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(107) + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + doc := &entity.AIMemoryDocument{ + ID: "doc-index-needed", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "design", + Title: "design", + Summary: "summary", + ContentText: "content", + SourceKind: string(aidomain.MemorySourceModelInferred), + SourceID: "msg-1", + } + if err := repo.BatchUpsertDocuments(ctx, []*entity.AIMemoryDocument{doc}); err != nil { + t.Fatalf("BatchUpsertDocuments() error = %v", err) + } + rows, err := repo.ListDocumentsNeedingIndex(ctx, 10, "qwen3-vl-embedding", 1024) + if err != nil { + t.Fatalf("ListDocumentsNeedingIndex() error = %v", err) + } + if len(rows) != 1 || rows[0].ID != doc.ID { + t.Fatalf("documents needing index = %+v, want %s", rows, doc.ID) + } + + indexedAt := time.Now().Add(time.Hour) + if err := repo.ReplaceDocumentChunks(ctx, doc.ID, []*entity.AIMemoryDocumentChunk{ + { + ID: "chunk-indexed", + DocumentID: doc.ID, + ScopeKey: doc.ScopeKey, + ScopeType: doc.ScopeType, + Visibility: doc.Visibility, + MemoryType: doc.MemoryType, + ChunkIndex: 0, + ContentText: doc.ContentText, + ContentHash: doc.ContentHash, + EmbeddingModel: "qwen3-vl-embedding", + EmbeddingDimension: 1024, + QdrantPointID: "33333333-3333-3333-3333-333333333333", + IndexedAt: &indexedAt, + }, + }); err != nil { + t.Fatalf("ReplaceDocumentChunks() error = %v", err) + } + rows, err = repo.ListDocumentsNeedingIndex(ctx, 10, "qwen3-vl-embedding", 1024) + if err != nil { + t.Fatalf("ListDocumentsNeedingIndex(indexed) error = %v", err) + } + if len(rows) != 0 { + t.Fatalf("documents needing index after indexed = %+v, want empty", rows) + } + + if err := db.Model(&entity.AIMemoryDocument{}). + Where("id = ?", doc.ID). + Updates(map[string]any{ + "content_text": "updated content", + "updated_at": time.Now().Add(2 * time.Hour), + }).Error; err != nil { + t.Fatalf("update document content: %v", err) + } + rows, err = repo.ListDocumentsNeedingIndex(ctx, 10, "qwen3-vl-embedding", 1024) + if err != nil { + t.Fatalf("ListDocumentsNeedingIndex(updated) error = %v", err) + } + if len(rows) != 1 || rows[0].ID != doc.ID { + t.Fatalf("documents needing index after update = %+v, want %s", rows, doc.ID) + } +} + +func newAIMemoryRepositoryTestDB(t *testing.T) *gorm.DB { + t.Helper() + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &entity.AIMemoryFact{}, + &entity.AIMemoryDocument{}, + &entity.AIMemoryDocumentChunk{}, + &entity.AIConversationSummary{}, + ); err != nil { + t.Fatalf("auto migrate ai memory models: %v", err) + } + return db +} + +func mustUpsertFact( + t *testing.T, + repo interfaces.AIMemoryRepository, + ctx context.Context, + fact *entity.AIMemoryFact, +) { + t.Helper() + if err := repo.UpsertFact(ctx, fact); err != nil { + t.Fatalf("UpsertFact() error = %v", err) + } +} + +func assertHasIndex(t *testing.T, db *gorm.DB, model any, name string) { + t.Helper() + if !db.Migrator().HasIndex(model, name) { + t.Fatalf("index %s missing", name) + } +} diff --git a/internal/repository/system/supplier.go b/internal/repository/system/supplier.go index c6b647b..c38a3c7 100644 --- a/internal/repository/system/supplier.go +++ b/internal/repository/system/supplier.go @@ -10,6 +10,7 @@ import ( // Supplier 用于集中提供当前模块依赖对象。 type Supplier interface { GetAIRepository() interfaces.AIRepository + GetAIMemoryRepository() interfaces.AIMemoryRepository GetUserRepository() interfaces.UserRepository GetJWTRepository() interfaces.JWTRepository GetRoleRepository() interfaces.RoleRepository @@ -42,6 +43,7 @@ type Supplier interface { func SetUp(factoryConfig *adapter.FactoryConfig) Supplier { var gormDB *gorm.DB var aiRepo interfaces.AIRepository + var aiMemoryRepo interfaces.AIMemoryRepository var userRepo interfaces.UserRepository var jwtRepo interfaces.JWTRepository var roleRepo interfaces.RoleRepository @@ -74,6 +76,7 @@ func SetUp(factoryConfig *adapter.FactoryConfig) Supplier { if db, ok := factoryConfig.Connection.(*gorm.DB); ok { gormDB = db aiRepo = NewAIRepository(db) + aiMemoryRepo = NewAIMemoryRepository(db) userRepo = NewUserRepository(db) jwtRepo = NewJwtRepository(db) roleRepo = NewRoleRepository(db) @@ -112,6 +115,7 @@ func SetUp(factoryConfig *adapter.FactoryConfig) Supplier { if db, ok := factoryConfig.Connection.(*gorm.DB); ok { gormDB = db aiRepo = NewAIRepository(db) + aiMemoryRepo = NewAIMemoryRepository(db) userRepo = NewUserRepository(db) jwtRepo = NewJwtRepository(db) roleRepo = NewRoleRepository(db) @@ -143,6 +147,7 @@ func SetUp(factoryConfig *adapter.FactoryConfig) Supplier { return &RepositorySupplier{ db: gormDB, aiRepository: aiRepo, + aiMemoryRepository: aiMemoryRepo, userRepository: userRepo, jwtRepository: jwtRepo, roleRepository: roleRepo, diff --git a/internal/repository/system/supplierImpl.go b/internal/repository/system/supplierImpl.go index a9873a1..cbf7ff8 100644 --- a/internal/repository/system/supplierImpl.go +++ b/internal/repository/system/supplierImpl.go @@ -13,6 +13,7 @@ import ( type RepositorySupplier struct { db *gorm.DB aiRepository interfaces.AIRepository + aiMemoryRepository interfaces.AIMemoryRepository userRepository interfaces.UserRepository jwtRepository interfaces.JWTRepository roleRepository interfaces.RoleRepository @@ -59,6 +60,11 @@ func (r *RepositorySupplier) GetAIRepository() interfaces.AIRepository { return r.aiRepository } +// GetAIMemoryRepository 返回记忆模块正式仓储依赖。 +func (r *RepositorySupplier) GetAIMemoryRepository() interfaces.AIMemoryRepository { + return r.aiMemoryRepository +} + // GetUserRepository 用于获取当前场景需要的对象或数据。 // 参数: // - 无。 diff --git a/internal/service/system/aiDeps.go b/internal/service/system/aiDeps.go index 1a8f841..12883ea 100644 --- a/internal/service/system/aiDeps.go +++ b/internal/service/system/aiDeps.go @@ -1,15 +1,22 @@ package system import ( + "context" + "personal_assistant/internal/service/system/aiselect" "personal_assistant/internal/service/system/aitool" ) +type aiMemoryWritebackHook interface { + OnTurnCompleted(ctx context.Context, input aiMemoryWritebackInput) error +} + // AIDeps 收口 AIService 运行期依赖,其中 tool 相关能力下沉到子包。 type AIDeps struct { Tools aitool.Deps Memory aiMemoryProvider Compressor aiContextCompressor + Writeback aiMemoryWritebackHook PromptBuilder aiselect.PromptBuilder Selector aiselect.Selector } diff --git a/internal/service/system/aiMemoryHook.go b/internal/service/system/aiMemoryHook.go new file mode 100644 index 0000000..20d0452 --- /dev/null +++ b/internal/service/system/aiMemoryHook.go @@ -0,0 +1,63 @@ +package system + +import ( + "context" + "time" + + "personal_assistant/global" + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" + + "go.uber.org/zap" +) + +const aiMemoryWritebackTimeout = 10 * time.Second + +func (s *AIService) triggerMemoryWriteback( + ctx context.Context, + conversation *entity.AIConversation, + userMessage *entity.AIMessage, + assistantMessage *entity.AIMessage, + principal aidomain.AIToolPrincipal, +) { + if s == nil || s.memoryWriteback == nil || !aiMemoryEnabled() || + conversation == nil || userMessage == nil || assistantMessage == nil { + return + } + input := aiMemoryWritebackInput{ + ConversationID: conversation.ID, + UserID: conversation.UserID, + OrgID: cloneMemoryUintPtr(conversation.OrgID), + UserMessageID: userMessage.ID, + AssistantMessageID: assistantMessage.ID, + Principal: principal, + } + + run := func(execCtx context.Context) { + if err := s.memoryWriteback.OnTurnCompleted(execCtx, input); err != nil && global.Log != nil { + global.Log.Error( + "AI memory writeback failed", + zap.String("conversation_id", conversation.ID), + zap.String("user_message_id", userMessage.ID), + zap.String("assistant_message_id", assistantMessage.ID), + zap.Error(err), + ) + } + } + + if global.Config != nil && global.Config.AI.Memory.WritebackAsync { + go func() { + writeCtx, cancel := context.WithTimeout(context.Background(), aiMemoryWritebackTimeout) + defer cancel() + run(writeCtx) + }() + return + } + + if ctx == nil { + ctx = context.Background() + } + writeCtx, cancel := context.WithTimeout(ctx, aiMemoryWritebackTimeout) + defer cancel() + run(writeCtx) +} diff --git a/internal/service/system/aiMemoryIndex.go b/internal/service/system/aiMemoryIndex.go new file mode 100644 index 0000000..bde7c30 --- /dev/null +++ b/internal/service/system/aiMemoryIndex.go @@ -0,0 +1,170 @@ +package system + +import ( + "context" + "fmt" + "time" + + "personal_assistant/global" + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" + + "go.uber.org/zap" +) + +// IndexDocuments 为指定长期记忆 documents 建立 Qdrant 向量索引。 +func (s *AIMemoryService) IndexDocuments(ctx context.Context, documentIDs []string) error { + if !s.memoryIndexingReady() || len(documentIDs) == 0 { + return nil + } + docs, err := s.repo.ListDocumentsByIDs(ctx, documentIDs) + if err != nil { + return err + } + return s.indexDocumentRows(ctx, docs) +} + +// IndexPendingDocuments 扫描并补偿尚未完成索引的 documents。 +func (s *AIMemoryService) IndexPendingDocuments(ctx context.Context, limit int) error { + if !s.memoryIndexingReady() { + return nil + } + if limit <= 0 { + limit = aiMemoryIndexBatchSize() + } + docs, err := s.repo.ListDocumentsNeedingIndex(ctx, limit, aiMemoryEmbedModel(), aiMemoryEmbedDimension()) + if err != nil { + return err + } + return s.indexDocumentRows(ctx, docs) +} + +func (s *AIMemoryService) memoryIndexingReady() bool { + return aiMemoryEnabled() && + aiMemoryLongTermEnabled() && + s != nil && + s.repo != nil && + s.chunker != nil && + s.embedder != nil && + s.vectorStore != nil +} + +func (s *AIMemoryService) indexDocumentRows(ctx context.Context, docs []*entity.AIMemoryDocument) error { + for _, doc := range docs { + if doc == nil { + continue + } + if err := s.indexOneDocument(ctx, doc); err != nil { + return err + } + } + return nil +} + +func (s *AIMemoryService) indexOneDocument(ctx context.Context, doc *entity.AIMemoryDocument) error { + chunks, err := s.chunker.Chunk(ctx, memoryDocumentEntityToIndex(doc)) + if err != nil { + return err + } + if len(chunks) == 0 { + return s.repo.ReplaceDocumentChunks(ctx, doc.ID, nil) + } + texts := make([]string, 0, len(chunks)) + for i := range chunks { + chunks[i].EmbeddingModel = aiMemoryEmbedModel() + chunks[i].EmbeddingDimension = aiMemoryEmbedDimension() + texts = append(texts, chunks[i].ContentText) + } + embeddings, err := s.embedder.Embed(ctx, aidomain.MemoryEmbeddingInput{Texts: texts}) + if err != nil { + return err + } + if len(embeddings.Vectors) != len(chunks) { + return fmt.Errorf("memory embedding count = %d, want %d", len(embeddings.Vectors), len(chunks)) + } + + vectorChunks := make([]aidomain.MemoryVectorChunk, 0, len(chunks)) + entities := make([]*entity.AIMemoryDocumentChunk, 0, len(chunks)) + indexedAt := time.Now() + for i, chunk := range chunks { + vectorChunks = append(vectorChunks, aidomain.MemoryVectorChunk{ + Chunk: chunk, + Vector: embeddings.Vectors[i], + }) + entities = append(entities, memoryChunkToEntity(chunk, indexedAt)) + } + if err := s.vectorStore.DeleteDocumentChunks(ctx, doc.ID); err != nil { + return err + } + if err := s.vectorStore.UpsertChunks(ctx, vectorChunks); err != nil { + return err + } + return s.repo.ReplaceDocumentChunks(ctx, doc.ID, entities) +} + +func (s *AIMemoryService) triggerDocumentIndex(ctx context.Context, docs []*entity.AIMemoryDocument) { + if !s.memoryIndexingReady() || len(docs) == 0 { + return + } + ids := make([]string, 0, len(docs)) + for _, doc := range docs { + if doc != nil && doc.ID != "" { + ids = append(ids, doc.ID) + } + } + if len(ids) == 0 { + return + } + go func() { + runCtx, cancel := context.WithTimeout(context.Background(), time.Duration(aiMemoryIndexTimeoutSeconds())*time.Second) + defer cancel() + if err := s.IndexDocuments(runCtx, ids); err != nil && global.Log != nil { + global.Log.Warn("AI memory document index failed", zap.Error(err)) + } + }() +} + +func memoryDocumentEntityToIndex(doc *entity.AIMemoryDocument) aidomain.MemoryDocumentForIndex { + if doc == nil { + return aidomain.MemoryDocumentForIndex{} + } + return aidomain.MemoryDocumentForIndex{ + ID: doc.ID, + ScopeKey: doc.ScopeKey, + ScopeType: doc.ScopeType, + Visibility: doc.Visibility, + UserID: cloneMemoryUintPtr(doc.UserID), + OrgID: cloneMemoryUintPtr(doc.OrgID), + MemoryType: doc.MemoryType, + Topic: doc.Topic, + Title: doc.Title, + Summary: doc.Summary, + Content: doc.ContentText, + SourceKind: doc.SourceKind, + SourceID: doc.SourceID, + } +} + +func memoryChunkToEntity(chunk aidomain.MemoryDocumentChunk, indexedAt time.Time) *entity.AIMemoryDocumentChunk { + return &entity.AIMemoryDocumentChunk{ + ID: chunk.ID, + DocumentID: chunk.DocumentID, + ScopeKey: chunk.ScopeKey, + ScopeType: chunk.ScopeType, + Visibility: chunk.Visibility, + UserID: cloneMemoryUintPtr(chunk.UserID), + OrgID: cloneMemoryUintPtr(chunk.OrgID), + MemoryType: chunk.MemoryType, + Topic: chunk.Topic, + ChunkIndex: chunk.ChunkIndex, + ContentText: chunk.ContentText, + ContentHash: chunk.ContentHash, + TokenEstimate: chunk.TokenEstimate, + EmbeddingModel: chunk.EmbeddingModel, + EmbeddingDimension: chunk.EmbeddingDimension, + QdrantPointID: chunk.QdrantPointID, + IndexedAt: &indexedAt, + CreatedAt: indexedAt, + UpdatedAt: indexedAt, + } +} diff --git a/internal/service/system/aiMemoryIndex_test.go b/internal/service/system/aiMemoryIndex_test.go new file mode 100644 index 0000000..8db13a7 --- /dev/null +++ b/internal/service/system/aiMemoryIndex_test.go @@ -0,0 +1,213 @@ +package system + +import ( + "context" + stderrors "errors" + "testing" + "time" + + aidomain "personal_assistant/internal/domain/ai" + aimemory "personal_assistant/internal/infrastructure/ai/memory" + "personal_assistant/internal/model/config" + "personal_assistant/internal/model/entity" +) + +func TestAIMemoryIndexDocumentsPersistsChunksAndUpsertsVectors(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + service.chunker = aimemory.NewParagraphChunker(aimemory.ChunkerOptions{MaxChars: 100, OverlapChars: 0}) + service.embedder = &fakeMemoryEmbedder{vectors: [][]float32{{0.1, 0.2, 0.3}}} + vectorStore := &fakeMemoryVectorStore{} + service.vectorStore = vectorStore + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableLongTermMemory: true, + EmbedModel: "qwen3-vl-embedding", + EmbedDimension: 3, + }) + defer restore() + + doc := createMemoryIndexDocument(t, service, "doc-index-ok", "可索引内容") + if err := service.IndexDocuments(context.Background(), []string{doc.ID}); err != nil { + t.Fatalf("IndexDocuments() error = %v", err) + } + chunks, err := service.repo.ListDocumentChunks(context.Background(), doc.ID) + if err != nil { + t.Fatalf("ListDocumentChunks() error = %v", err) + } + if len(chunks) != 1 { + t.Fatalf("chunks len = %d, want 1", len(chunks)) + } + if chunks[0].EmbeddingModel != "qwen3-vl-embedding" || chunks[0].EmbeddingDimension != 3 { + t.Fatalf("chunk embedding metadata = %+v", chunks[0]) + } + if vectorStore.deletedDocumentID != doc.ID || len(vectorStore.upserted) != 1 { + t.Fatalf("vector store deleted=%q upserted=%+v", vectorStore.deletedDocumentID, vectorStore.upserted) + } +} + +func TestAIMemoryIndexDocumentsDoesNotPersistChunksWhenEmbeddingFails(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + service.chunker = aimemory.NewParagraphChunker(aimemory.ChunkerOptions{MaxChars: 100, OverlapChars: 0}) + service.embedder = &fakeMemoryEmbedder{err: stderrors.New("embedding failed")} + service.vectorStore = &fakeMemoryVectorStore{} + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableLongTermMemory: true, + EmbedModel: "qwen3-vl-embedding", + EmbedDimension: 3, + }) + defer restore() + + doc := createMemoryIndexDocument(t, service, "doc-index-embed-fail", "可索引内容") + if err := service.IndexDocuments(context.Background(), []string{doc.ID}); err == nil { + t.Fatal("IndexDocuments() error = nil, want embedding error") + } + chunks, err := service.repo.ListDocumentChunks(context.Background(), doc.ID) + if err != nil { + t.Fatalf("ListDocumentChunks() error = %v", err) + } + if len(chunks) != 0 { + t.Fatalf("chunks len = %d, want 0", len(chunks)) + } +} + +func TestAIMemoryIndexDocumentsDoesNotPersistChunksWhenVectorStoreFails(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + service.chunker = aimemory.NewParagraphChunker(aimemory.ChunkerOptions{MaxChars: 100, OverlapChars: 0}) + service.embedder = &fakeMemoryEmbedder{vectors: [][]float32{{0.1, 0.2, 0.3}}} + service.vectorStore = &fakeMemoryVectorStore{err: stderrors.New("qdrant failed")} + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableLongTermMemory: true, + EmbedModel: "qwen3-vl-embedding", + EmbedDimension: 3, + }) + defer restore() + + doc := createMemoryIndexDocument(t, service, "doc-index-vector-fail", "可索引内容") + if err := service.IndexDocuments(context.Background(), []string{doc.ID}); err == nil { + t.Fatal("IndexDocuments() error = nil, want vector store error") + } + chunks, err := service.repo.ListDocumentChunks(context.Background(), doc.ID) + if err != nil { + t.Fatalf("ListDocumentChunks() error = %v", err) + } + if len(chunks) != 0 { + t.Fatalf("chunks len = %d, want 0", len(chunks)) + } +} + +func TestAIMemoryWritebackIndexFailureDoesNotFailTurnCompleted(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, aimemory.NewRuleExtractor(aimemory.Options{DocumentMinRunes: 40})) + service.chunker = aimemory.NewParagraphChunker(aimemory.ChunkerOptions{MaxChars: 100, OverlapChars: 0}) + embedder := &fakeMemoryEmbedder{err: stderrors.New("embedding failed"), called: make(chan struct{}, 1)} + service.embedder = embedder + service.vectorStore = &fakeMemoryVectorStore{} + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + EnableLongTermMemory: true, + EmbedModel: "qwen3-vl-embedding", + EmbedDimension: 3, + }) + defer restore() + + createAIWritebackMessagesWithContent( + t, + db, + "conv-index-fail", + "msg-user-index-fail", + "msg-ai-index-fail", + "请给我一个 RAG 切分入库的实现方案", + repeatMemoryText("实现方案包括切块、embedding、写入 Qdrant 和保存 chunk 映射。", 6), + aiMessageStatusSuccess, + ) + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-index-fail", + UserID: 22, + UserMessageID: "msg-user-index-fail", + AssistantMessageID: "msg-ai-index-fail", + Principal: aidomain.AIToolPrincipal{UserID: 22}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryDocument{}, 1) + select { + case <-embedder.called: + case <-time.After(time.Second): + t.Fatal("embedding was not called by async indexer") + } +} + +func createMemoryIndexDocument(t *testing.T, service *AIMemoryService, id string, content string) *entity.AIMemoryDocument { + t.Helper() + userID := uint(21) + doc := &entity.AIMemoryDocument{ + ID: id, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(userID), + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "design", + Title: "design", + Summary: "summary", + ContentText: content, + SourceKind: string(aidomain.MemorySourceModelInferred), + SourceID: "msg-index", + } + if err := service.repo.BatchUpsertDocuments(context.Background(), []*entity.AIMemoryDocument{doc}); err != nil { + t.Fatalf("BatchUpsertDocuments() error = %v", err) + } + return doc +} + +type fakeMemoryEmbedder struct { + vectors [][]float32 + err error + called chan struct{} +} + +func (f *fakeMemoryEmbedder) Embed( + _ context.Context, + input aidomain.MemoryEmbeddingInput, +) (aidomain.MemoryEmbeddingResult, error) { + if f.called != nil { + select { + case f.called <- struct{}{}: + default: + } + } + if f.err != nil { + return aidomain.MemoryEmbeddingResult{}, f.err + } + if len(f.vectors) > 0 { + return aidomain.MemoryEmbeddingResult{Vectors: f.vectors}, nil + } + vectors := make([][]float32, len(input.Texts)) + for i := range vectors { + vectors[i] = []float32{0.1, 0.2, 0.3} + } + return aidomain.MemoryEmbeddingResult{Vectors: vectors}, nil +} + +type fakeMemoryVectorStore struct { + deletedDocumentID string + upserted []aidomain.MemoryVectorChunk + err error +} + +func (f *fakeMemoryVectorStore) DeleteDocumentChunks(_ context.Context, documentID string) error { + f.deletedDocumentID = documentID + return f.err +} + +func (f *fakeMemoryVectorStore) UpsertChunks(_ context.Context, chunks []aidomain.MemoryVectorChunk) error { + f.upserted = append([]aidomain.MemoryVectorChunk(nil), chunks...) + return f.err +} diff --git a/internal/service/system/aiMemoryPolicy.go b/internal/service/system/aiMemoryPolicy.go new file mode 100644 index 0000000..9f53ede --- /dev/null +++ b/internal/service/system/aiMemoryPolicy.go @@ -0,0 +1,627 @@ +package system + +import ( + "fmt" + "strings" + "time" + + aidomain "personal_assistant/internal/domain/ai" +) + +// aiMemoryPolicy 收口 writeback 之前的准入、权限和覆盖规则。 +type aiMemoryPolicy struct{} + +func (p aiMemoryPolicy) ShouldStoreFact( + candidate aidomain.MemoryFactCandidate, + access aidomain.MemoryAccessContext, +) aidomain.MemoryDecision { + if isForbiddenMemorySource(candidate.SourceKind) { + return denyDecision( + aidomain.MemoryReasonDenyForbiddenSource, + fmt.Sprintf("source_kind=%s is forbidden for fact memory", candidate.SourceKind), + ) + } + if candidate.TruthConflict { + return denyDecision( + aidomain.MemoryReasonDenyTruthConflict, + "candidate fact conflicts with runtime truth source", + ) + } + if candidate.LowValue || + strings.TrimSpace(candidate.Namespace) == "" || + strings.TrimSpace(candidate.FactKey) == "" || + strings.TrimSpace(candidate.FactValueJSON) == "" { + return denyDecision( + aidomain.MemoryReasonDenyLowValueContent, + "fact candidate is empty or marked as low value", + ) + } + + scopeDecision := p.ResolveScope(aidomain.MemoryScopeInput{ + ScopeType: candidate.ScopeType, + UserID: candidate.UserID, + OrgID: candidate.OrgID, + }, access) + if !scopeDecision.Allowed { + return scopeDecision.MemoryDecision + } + visibilityDecision := p.ResolveVisibility(scopeDecision, candidate.SourceKind) + if !visibilityDecision.Allowed { + return visibilityDecision.MemoryDecision + } + writeDecision := p.CanWriteMemory(newMemoryAccessTarget(scopeDecision, visibilityDecision), access) + if !writeDecision.Allowed { + return writeDecision + } + + return allowDecision( + aidomain.MemoryReasonAllowStoreFact, + fmt.Sprintf("fact candidate is allowed under %s", aidomain.DescribeMemoryScope(scopeDecision.ScopeType, scopeDecision.ScopeKey)), + ) +} + +func (p aiMemoryPolicy) ShouldStoreDocument( + candidate aidomain.MemoryDocumentCandidate, + access aidomain.MemoryAccessContext, +) aidomain.MemoryDocumentDecision { + if candidate.MemoryType == aidomain.MemoryTypeSessionSummary { + return denyDocumentDecision( + aidomain.MemoryReasonDenyForbiddenSource, + "session_summary must use conversation_summary storage instead of document memory", + ) + } + if isForbiddenMemorySource(candidate.SourceKind) { + return denyDocumentDecision( + aidomain.MemoryReasonDenyForbiddenSource, + fmt.Sprintf("source_kind=%s is forbidden for document memory", candidate.SourceKind), + ) + } + if candidate.TruthConflict { + return denyDocumentDecision( + aidomain.MemoryReasonDenyTruthConflict, + "candidate document conflicts with runtime truth source", + ) + } + if candidate.LowValue || + (strings.TrimSpace(candidate.Summary) == "" && strings.TrimSpace(candidate.ContentText) == "") { + return denyDocumentDecision( + aidomain.MemoryReasonDenyLowValueContent, + "document candidate is empty or marked as low value", + ) + } + + scopeDecision := p.ResolveScope(aidomain.MemoryScopeInput{ + ScopeType: candidate.ScopeType, + UserID: candidate.UserID, + OrgID: candidate.OrgID, + }, access) + if !scopeDecision.Allowed { + return denyDocumentDecision(scopeDecision.ReasonCode, scopeDecision.Reason) + } + visibilityDecision := p.ResolveVisibility(scopeDecision, candidate.SourceKind) + if !visibilityDecision.Allowed { + return denyDocumentDecision(visibilityDecision.ReasonCode, visibilityDecision.Reason) + } + writeDecision := p.CanWriteMemory(newMemoryAccessTarget(scopeDecision, visibilityDecision), access) + if !writeDecision.Allowed { + return denyDocumentDecision(writeDecision.ReasonCode, writeDecision.Reason) + } + + contentHash := aidomain.BuildMemoryDocumentContentHash(candidate.ContentText) + summaryHash := aidomain.BuildMemoryDocumentSummaryHash(candidate.Summary) + dedupKey := aidomain.BuildMemoryDocumentDedupKey( + candidate.SourceKind, + candidate.SourceID, + candidate.Topic, + candidate.Summary, + candidate.ContentText, + ) + if dedupKey == "" { + return denyDocumentDecision( + aidomain.MemoryReasonDenyDuplicateDocument, + "document candidate does not have a stable dedup key", + ) + } + + return aidomain.MemoryDocumentDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowStoreDocument, + fmt.Sprintf("document candidate is allowed under %s", aidomain.DescribeMemoryScope(scopeDecision.ScopeType, scopeDecision.ScopeKey)), + ), + ContentHash: contentHash, + SummaryHash: summaryHash, + DedupKey: dedupKey, + } +} + +func (p aiMemoryPolicy) ResolveScope( + input aidomain.MemoryScopeInput, + access aidomain.MemoryAccessContext, +) aidomain.MemoryScopeDecision { + switch input.ScopeType { + case aidomain.MemoryScopeSelf: + if access.Principal.UserID == 0 { + return denyScopeDecision( + aidomain.MemoryReasonDenyPermissionDependencyMissing, + "principal.user_id is required for self scope", + ) + } + resolvedUserID := access.Principal.UserID + if input.UserID != nil && *input.UserID > 0 { + if *input.UserID != access.Principal.UserID { + return denyScopeDecision( + aidomain.MemoryReasonDenyScopeMismatch, + fmt.Sprintf("self scope user_id=%d does not match principal user_id=%d", *input.UserID, access.Principal.UserID), + ) + } + resolvedUserID = *input.UserID + } + scopeKey := aidomain.BuildSelfMemoryScopeKey(resolvedUserID) + return aidomain.MemoryScopeDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowSelfScope, + fmt.Sprintf("resolved self scope for user_id=%d", resolvedUserID), + ), + ScopeType: aidomain.MemoryScopeSelf, + ScopeKey: scopeKey, + UserID: &resolvedUserID, + } + + case aidomain.MemoryScopeOrg: + if input.OrgID == nil || *input.OrgID == 0 { + return denyScopeDecision( + aidomain.MemoryReasonDenyPermissionDependencyMissing, + "explicit authorized org_id is required for org scope", + ) + } + if len(access.ApprovedOrgScopeKeys) == 0 && len(access.ApprovedOrgIDs) == 0 { + return denyScopeDecision( + aidomain.MemoryReasonDenyPermissionDependencyMissing, + "approved org scopes are required for org memory", + ) + } + if !isApprovedOrgScope(*input.OrgID, access) { + return denyScopeDecision( + aidomain.MemoryReasonDenyNotInApprovedOrgScope, + fmt.Sprintf("org_id=%d is not in approved org scope set", *input.OrgID), + ) + } + scopeKey := aidomain.BuildOrgMemoryScopeKey(*input.OrgID) + return aidomain.MemoryScopeDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowOrgScope, + fmt.Sprintf("resolved org scope for org_id=%d", *input.OrgID), + ), + ScopeType: aidomain.MemoryScopeOrg, + ScopeKey: scopeKey, + UserID: cloneMemoryUintPtr(input.UserID), + OrgID: cloneMemoryUintPtr(input.OrgID), + } + + case aidomain.MemoryScopePlatformOps: + if !access.AllowPlatformOps { + return denyScopeDecision( + aidomain.MemoryReasonDenyPermissionDependencyMissing, + "platform_ops scope requires explicit authorization result", + ) + } + if !access.Principal.IsSuperAdmin { + return denyScopeDecision( + aidomain.MemoryReasonDenyNotSuperAdmin, + "platform_ops scope requires super admin principal", + ) + } + scopeKey := aidomain.BuildPlatformOpsMemoryScopeKey() + return aidomain.MemoryScopeDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowPlatformOpsScope, + "resolved platform_ops scope for super admin", + ), + ScopeType: aidomain.MemoryScopePlatformOps, + ScopeKey: scopeKey, + UserID: cloneMemoryUintPtr(input.UserID), + } + } + + return denyScopeDecision( + aidomain.MemoryReasonDenyScopeMismatch, + fmt.Sprintf("unsupported memory scope type=%s", input.ScopeType), + ) +} + +func (p aiMemoryPolicy) ResolveVisibility( + scope aidomain.MemoryScopeDecision, + _ aidomain.MemorySourceKind, +) aidomain.MemoryVisibilityDecision { + if !scope.Allowed { + return aidomain.MemoryVisibilityDecision{MemoryDecision: scope.MemoryDecision} + } + switch scope.ScopeType { + case aidomain.MemoryScopeSelf: + return aidomain.MemoryVisibilityDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowSelfScope, + "self scope maps to self visibility", + ), + Visibility: aidomain.MemoryVisibilitySelf, + } + case aidomain.MemoryScopeOrg: + return aidomain.MemoryVisibilityDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowOrgScope, + "org scope maps to org visibility", + ), + Visibility: aidomain.MemoryVisibilityOrg, + } + case aidomain.MemoryScopePlatformOps: + return aidomain.MemoryVisibilityDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowPlatformOpsScope, + "platform_ops scope maps to super_admin visibility", + ), + Visibility: aidomain.MemoryVisibilitySuperAdmin, + } + } + return aidomain.MemoryVisibilityDecision{ + MemoryDecision: denyDecision( + aidomain.MemoryReasonDenyVisibilityMismatch, + fmt.Sprintf("unsupported visibility mapping for scope_type=%s", scope.ScopeType), + ), + } +} + +func (p aiMemoryPolicy) ResolveTTL(namespace string, memoryType aidomain.MemoryType) aidomain.MemoryTTLDecision { + now := time.Now() + switch strings.TrimSpace(namespace) { + case aidomain.MemoryNamespaceUserPreference, aidomain.MemoryNamespaceOrgProfile, + aidomain.MemoryNamespaceOpsIncident, aidomain.MemoryNamespaceOpsRunbook: + return aidomain.MemoryTTLDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowTTLPersistent, + fmt.Sprintf("namespace=%s is persistent by policy", namespace), + ), + } + case aidomain.MemoryNamespaceOJGoal: + expiresAt := now.Add(30 * 24 * time.Hour) + return aidomain.MemoryTTLDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowTTLExpiring, + fmt.Sprintf("namespace=%s expires in 30 days", namespace), + ), + ExpiresAt: &expiresAt, + } + case aidomain.MemoryNamespaceOJProfile: + expiresAt := now.Add(60 * 24 * time.Hour) + return aidomain.MemoryTTLDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowTTLExpiring, + fmt.Sprintf("namespace=%s expires in 60 days", namespace), + ), + ExpiresAt: &expiresAt, + } + case aidomain.MemoryNamespaceOrgLearning: + expiresAt := now.Add(14 * 24 * time.Hour) + return aidomain.MemoryTTLDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowTTLExpiring, + fmt.Sprintf("namespace=%s expires in 14 days", namespace), + ), + ExpiresAt: &expiresAt, + } + } + + if memoryType == aidomain.MemoryTypeSessionSummary { + return aidomain.MemoryTTLDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowTTLPersistent, + "conversation summary does not rely on ttl", + ), + } + } + + return aidomain.MemoryTTLDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowTTLPersistent, + "default memory ttl is persistent until explicitly invalidated", + ), + } +} + +func (p aiMemoryPolicy) ShouldOverrideFact( + current aidomain.MemoryFactVersion, + candidate aidomain.MemoryFactVersion, + scopeType aidomain.MemoryScopeType, + namespace string, +) aidomain.MemoryDecision { + currentValue := strings.TrimSpace(current.ValueJSON) + candidateValue := strings.TrimSpace(candidate.ValueJSON) + if candidateValue == "" { + return denyDecision( + aidomain.MemoryReasonDenyLowValueContent, + "candidate fact value is empty", + ) + } + if currentValue == candidateValue { + return denyDecision( + aidomain.MemoryReasonSkipSameValue, + "candidate fact has the same value as current fact", + ) + } + + currentPriority := memorySourcePriority(scopeType, namespace, current.SourceKind) + candidatePriority := memorySourcePriority(scopeType, namespace, candidate.SourceKind) + if candidatePriority < currentPriority { + return denyDecision( + aidomain.MemoryReasonSkipLowerPrioritySource, + fmt.Sprintf("candidate source=%s has lower priority than current source=%s", candidate.SourceKind, current.SourceKind), + ) + } + if candidatePriority > currentPriority { + return allowDecision(overrideReasonCode(candidate.SourceKind), overrideReasonText(candidate.SourceKind)) + } + + return allowDecision( + aidomain.MemoryReasonAllowSamePriority, + fmt.Sprintf("candidate source=%s has the same priority and a new value", candidate.SourceKind), + ) +} + +func (p aiMemoryPolicy) CanReadMemory( + target aidomain.MemoryAccessTarget, + access aidomain.MemoryAccessContext, +) aidomain.MemoryDecision { + return evaluateMemoryAccess(target, access) +} + +func (p aiMemoryPolicy) CanWriteMemory( + target aidomain.MemoryAccessTarget, + access aidomain.MemoryAccessContext, +) aidomain.MemoryDecision { + return evaluateMemoryAccess(target, access) +} + +func evaluateMemoryAccess( + target aidomain.MemoryAccessTarget, + access aidomain.MemoryAccessContext, +) aidomain.MemoryDecision { + switch target.ScopeType { + case aidomain.MemoryScopeSelf: + if access.Principal.UserID == 0 || target.UserID == nil || *target.UserID == 0 { + return denyDecision( + aidomain.MemoryReasonDenyPermissionDependencyMissing, + "self memory requires principal.user_id and target.user_id", + ) + } + expectedScopeKey := aidomain.BuildSelfMemoryScopeKey(*target.UserID) + if strings.TrimSpace(target.ScopeKey) != expectedScopeKey || *target.UserID != access.Principal.UserID { + return denyDecision( + aidomain.MemoryReasonDenyScopeMismatch, + fmt.Sprintf("self memory target does not match principal user_id=%d", access.Principal.UserID), + ) + } + if target.Visibility != aidomain.MemoryVisibilitySelf { + return denyDecision( + aidomain.MemoryReasonDenyVisibilityMismatch, + fmt.Sprintf("self memory requires visibility=%s", aidomain.MemoryVisibilitySelf), + ) + } + return allowDecision( + aidomain.MemoryReasonAllowSelfScope, + fmt.Sprintf("self memory access allowed for user_id=%d", access.Principal.UserID), + ) + + case aidomain.MemoryScopeOrg: + if target.OrgID == nil || *target.OrgID == 0 || strings.TrimSpace(target.ScopeKey) == "" { + return denyDecision( + aidomain.MemoryReasonDenyPermissionDependencyMissing, + "org memory requires explicit target org_id and scope_key", + ) + } + if len(access.ApprovedOrgScopeKeys) == 0 && len(access.ApprovedOrgIDs) == 0 { + return denyDecision( + aidomain.MemoryReasonDenyPermissionDependencyMissing, + "org memory requires approved org scope input", + ) + } + expectedScopeKey := aidomain.BuildOrgMemoryScopeKey(*target.OrgID) + if target.ScopeKey != expectedScopeKey { + return denyDecision( + aidomain.MemoryReasonDenyScopeMismatch, + fmt.Sprintf("org memory scope_key=%s does not match org_id=%d", target.ScopeKey, *target.OrgID), + ) + } + if target.Visibility != aidomain.MemoryVisibilityOrg { + return denyDecision( + aidomain.MemoryReasonDenyVisibilityMismatch, + fmt.Sprintf("org memory requires visibility=%s", aidomain.MemoryVisibilityOrg), + ) + } + if !isApprovedOrgScope(*target.OrgID, access) { + return denyDecision( + aidomain.MemoryReasonDenyNotInApprovedOrgScope, + fmt.Sprintf("org_id=%d is not in approved org scope set", *target.OrgID), + ) + } + return allowDecision( + aidomain.MemoryReasonAllowOrgScope, + fmt.Sprintf("org memory access allowed for org_id=%d", *target.OrgID), + ) + + case aidomain.MemoryScopePlatformOps: + if !access.AllowPlatformOps { + return denyDecision( + aidomain.MemoryReasonDenyPermissionDependencyMissing, + "platform_ops memory requires explicit authorization input", + ) + } + if !access.Principal.IsSuperAdmin { + return denyDecision( + aidomain.MemoryReasonDenyNotSuperAdmin, + "platform_ops memory requires super admin principal", + ) + } + if target.ScopeKey != aidomain.BuildPlatformOpsMemoryScopeKey() { + return denyDecision( + aidomain.MemoryReasonDenyScopeMismatch, + fmt.Sprintf("platform_ops memory requires scope_key=%s", aidomain.BuildPlatformOpsMemoryScopeKey()), + ) + } + if target.Visibility != aidomain.MemoryVisibilitySuperAdmin { + return denyDecision( + aidomain.MemoryReasonDenyVisibilityMismatch, + fmt.Sprintf("platform_ops memory requires visibility=%s", aidomain.MemoryVisibilitySuperAdmin), + ) + } + return allowDecision( + aidomain.MemoryReasonAllowPlatformOpsScope, + "platform_ops memory access allowed for super admin", + ) + } + + return denyDecision( + aidomain.MemoryReasonDenyScopeMismatch, + fmt.Sprintf("unsupported memory scope_type=%s", target.ScopeType), + ) +} + +func newMemoryAccessTarget( + scope aidomain.MemoryScopeDecision, + visibility aidomain.MemoryVisibilityDecision, +) aidomain.MemoryAccessTarget { + return aidomain.MemoryAccessTarget{ + ScopeType: scope.ScopeType, + ScopeKey: scope.ScopeKey, + Visibility: visibility.Visibility, + UserID: cloneMemoryUintPtr(scope.UserID), + OrgID: cloneMemoryUintPtr(scope.OrgID), + } +} + +func isForbiddenMemorySource(sourceKind aidomain.MemorySourceKind) bool { + switch sourceKind { + case aidomain.MemorySourceRawTracePayload, aidomain.MemorySourceFullToolOutput: + return true + default: + return false + } +} + +func isApprovedOrgScope(orgID uint, access aidomain.MemoryAccessContext) bool { + for _, approvedID := range access.ApprovedOrgIDs { + if approvedID == orgID { + return true + } + } + scopeKey := aidomain.BuildOrgMemoryScopeKey(orgID) + for _, approvedScopeKey := range access.ApprovedOrgScopeKeys { + if strings.TrimSpace(approvedScopeKey) == scopeKey { + return true + } + } + return false +} + +func memorySourcePriority( + scopeType aidomain.MemoryScopeType, + namespace string, + sourceKind aidomain.MemorySourceKind, +) int { + if scopeType == aidomain.MemoryScopeOrg || scopeType == aidomain.MemoryScopePlatformOps { + return publicMemorySourcePriority(sourceKind) + } + if scopeType == aidomain.MemoryScopeSelf { + if strings.TrimSpace(namespace) == aidomain.MemoryNamespaceUserPreference { + return privateMemorySourcePriority(sourceKind) + } + return privateMemorySourcePriority(sourceKind) + } + return privateMemorySourcePriority(sourceKind) +} + +func privateMemorySourcePriority(sourceKind aidomain.MemorySourceKind) int { + switch sourceKind { + case aidomain.MemorySourceExplicitUserStatement: + return 4 + case aidomain.MemorySourceAdminSet: + return 3 + case aidomain.MemorySourceToolVerifiedSummary: + return 2 + case aidomain.MemorySourceModelInferred: + return 1 + default: + return 0 + } +} + +func publicMemorySourcePriority(sourceKind aidomain.MemorySourceKind) int { + switch sourceKind { + case aidomain.MemorySourceAdminSet: + return 4 + case aidomain.MemorySourceExplicitUserStatement: + return 3 + case aidomain.MemorySourceToolVerifiedSummary: + return 2 + case aidomain.MemorySourceModelInferred: + return 1 + default: + return 0 + } +} + +func overrideReasonCode(sourceKind aidomain.MemorySourceKind) string { + switch sourceKind { + case aidomain.MemorySourceExplicitUserStatement: + return aidomain.MemoryReasonOverrideExplicitUserStatement + case aidomain.MemorySourceAdminSet: + return aidomain.MemoryReasonOverrideAdminSet + default: + return aidomain.MemoryReasonAllowSamePriority + } +} + +func overrideReasonText(sourceKind aidomain.MemorySourceKind) string { + switch sourceKind { + case aidomain.MemorySourceExplicitUserStatement: + return "candidate explicit user statement overrides current fact" + case aidomain.MemorySourceAdminSet: + return "candidate admin setting overrides current fact" + default: + return fmt.Sprintf("candidate source=%s overrides current fact", sourceKind) + } +} + +func cloneMemoryUintPtr(value *uint) *uint { + if value == nil { + return nil + } + out := *value + return &out +} + +func allowDecision(code string, reason string) aidomain.MemoryDecision { + return aidomain.MemoryDecision{ + Allowed: true, + ReasonCode: code, + Reason: reason, + } +} + +func denyDecision(code string, reason string) aidomain.MemoryDecision { + return aidomain.MemoryDecision{ + Allowed: false, + ReasonCode: code, + Reason: reason, + } +} + +func denyScopeDecision(code string, reason string) aidomain.MemoryScopeDecision { + return aidomain.MemoryScopeDecision{ + MemoryDecision: denyDecision(code, reason), + } +} + +func denyDocumentDecision(code string, reason string) aidomain.MemoryDocumentDecision { + return aidomain.MemoryDocumentDecision{ + MemoryDecision: denyDecision(code, reason), + } +} diff --git a/internal/service/system/aiMemoryPolicy_test.go b/internal/service/system/aiMemoryPolicy_test.go new file mode 100644 index 0000000..b4cb0ca --- /dev/null +++ b/internal/service/system/aiMemoryPolicy_test.go @@ -0,0 +1,213 @@ +package system + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + aidomain "personal_assistant/internal/domain/ai" +) + +func TestAIMemoryPolicyResolveScopeRejectsOrgWithoutApprovedScope(t *testing.T) { + policy := aiMemoryPolicy{} + orgID := uint(23) + + decision := policy.ResolveScope( + aidomain.MemoryScopeInput{ + ScopeType: aidomain.MemoryScopeOrg, + OrgID: &orgID, + }, + aidomain.MemoryAccessContext{ + Principal: aidomain.AIToolPrincipal{ + UserID: 7, + CurrentOrgID: &orgID, + }, + }, + ) + + if decision.Allowed { + t.Fatalf("ResolveScope() allowed = true, want false") + } + if decision.ReasonCode != aidomain.MemoryReasonDenyPermissionDependencyMissing { + t.Fatalf("ResolveScope() reason_code = %q, want %q", decision.ReasonCode, aidomain.MemoryReasonDenyPermissionDependencyMissing) + } +} + +func TestAIMemoryPolicyShouldOverrideFactPrefersExplicitUserPreferenceForSelf(t *testing.T) { + policy := aiMemoryPolicy{} + + decision := policy.ShouldOverrideFact( + aidomain.MemoryFactVersion{ + ValueJSON: `{"style":"detailed"}`, + SourceKind: aidomain.MemorySourceAdminSet, + }, + aidomain.MemoryFactVersion{ + ValueJSON: `{"style":"brief"}`, + SourceKind: aidomain.MemorySourceExplicitUserStatement, + }, + aidomain.MemoryScopeSelf, + aidomain.MemoryNamespaceUserPreference, + ) + + if !decision.Allowed { + t.Fatalf("ShouldOverrideFact() allowed = false, want true") + } + if decision.ReasonCode != aidomain.MemoryReasonOverrideExplicitUserStatement { + t.Fatalf("ShouldOverrideFact() reason_code = %q, want %q", decision.ReasonCode, aidomain.MemoryReasonOverrideExplicitUserStatement) + } +} + +func TestAIMemoryPolicyShouldOverrideFactPrefersAdminSetForOrgMemory(t *testing.T) { + policy := aiMemoryPolicy{} + + decision := policy.ShouldOverrideFact( + aidomain.MemoryFactVersion{ + ValueJSON: `{"tone":"mentor"}`, + SourceKind: aidomain.MemorySourceExplicitUserStatement, + }, + aidomain.MemoryFactVersion{ + ValueJSON: `{"tone":"strict"}`, + SourceKind: aidomain.MemorySourceAdminSet, + }, + aidomain.MemoryScopeOrg, + aidomain.MemoryNamespaceOrgProfile, + ) + + if !decision.Allowed { + t.Fatalf("ShouldOverrideFact() allowed = false, want true") + } + if decision.ReasonCode != aidomain.MemoryReasonOverrideAdminSet { + t.Fatalf("ShouldOverrideFact() reason_code = %q, want %q", decision.ReasonCode, aidomain.MemoryReasonOverrideAdminSet) + } +} + +func TestAIMemoryPolicyShouldStoreDocumentRejectsRawTracePayload(t *testing.T) { + policy := aiMemoryPolicy{} + userID := uint(9) + + decision := policy.ShouldStoreDocument( + aidomain.MemoryDocumentCandidate{ + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + MemoryType: aidomain.MemoryTypeSemantic, + Topic: "trace", + Summary: "trace payload", + ContentText: `{"span":"full"}`, + SourceKind: aidomain.MemorySourceRawTracePayload, + }, + aidomain.MemoryAccessContext{ + Principal: aidomain.AIToolPrincipal{UserID: userID}, + }, + ) + + if decision.Allowed { + t.Fatalf("ShouldStoreDocument() allowed = true, want false") + } + if decision.ReasonCode != aidomain.MemoryReasonDenyForbiddenSource { + t.Fatalf("ShouldStoreDocument() reason_code = %q, want %q", decision.ReasonCode, aidomain.MemoryReasonDenyForbiddenSource) + } +} + +func TestAIMemoryPolicyShouldStoreDocumentBuildsDedupMetadata(t *testing.T) { + policy := aiMemoryPolicy{} + orgID := uint(24) + + decision := policy.ShouldStoreDocument( + aidomain.MemoryDocumentCandidate{ + ScopeType: aidomain.MemoryScopeOrg, + OrgID: &orgID, + MemoryType: aidomain.MemoryTypeFAQ, + Topic: "deploy", + Title: "FAQ", + Summary: " Deploy with migration ", + ContentText: "Use docker compose and run migrations before restart.", + SourceKind: "faq_import", + SourceID: "faq-001", + }, + aidomain.MemoryAccessContext{ + Principal: aidomain.AIToolPrincipal{UserID: 8, CurrentOrgID: &orgID}, + ApprovedOrgScopeKeys: []string{aidomain.BuildOrgMemoryScopeKey(orgID)}, + }, + ) + + if !decision.Allowed { + t.Fatalf("ShouldStoreDocument() allowed = false, want true, reason=%s", decision.Reason) + } + if decision.ReasonCode != aidomain.MemoryReasonAllowStoreDocument { + t.Fatalf("ShouldStoreDocument() reason_code = %q, want %q", decision.ReasonCode, aidomain.MemoryReasonAllowStoreDocument) + } + + wantContentHash := sha256Hex(normalizeWhitespace("Use docker compose and run migrations before restart.")) + wantSummaryHash := sha256Hex(normalizeWhitespace(" Deploy with migration ")) + wantDedupKey := "src:" + sha256Hex(strings.ToLower(strings.Join([]string{"faq_import", "faq-001", "deploy"}, "\n"))) + + if decision.ContentHash != wantContentHash { + t.Fatalf("content_hash = %q, want %q", decision.ContentHash, wantContentHash) + } + if decision.SummaryHash != wantSummaryHash { + t.Fatalf("summary_hash = %q, want %q", decision.SummaryHash, wantSummaryHash) + } + if decision.DedupKey != wantDedupKey { + t.Fatalf("dedup_key = %q, want %q", decision.DedupKey, wantDedupKey) + } +} + +func TestAIMemoryPolicyCanWriteMemoryFailsClosedWithoutApprovedOrgScope(t *testing.T) { + policy := aiMemoryPolicy{} + orgID := uint(25) + + decision := policy.CanWriteMemory( + aidomain.MemoryAccessTarget{ + ScopeType: aidomain.MemoryScopeOrg, + ScopeKey: aidomain.BuildOrgMemoryScopeKey(orgID), + Visibility: aidomain.MemoryVisibilityOrg, + OrgID: &orgID, + }, + aidomain.MemoryAccessContext{ + Principal: aidomain.AIToolPrincipal{ + UserID: 7, + CurrentOrgID: &orgID, + }, + }, + ) + + if decision.Allowed { + t.Fatalf("CanWriteMemory() allowed = true, want false") + } + if decision.ReasonCode != aidomain.MemoryReasonDenyPermissionDependencyMissing { + t.Fatalf("CanWriteMemory() reason_code = %q, want %q", decision.ReasonCode, aidomain.MemoryReasonDenyPermissionDependencyMissing) + } +} + +func TestAIMemoryPolicyCanReadMemoryRejectsPlatformOpsForNonSuperAdmin(t *testing.T) { + policy := aiMemoryPolicy{} + + decision := policy.CanReadMemory( + aidomain.MemoryAccessTarget{ + ScopeType: aidomain.MemoryScopePlatformOps, + ScopeKey: aidomain.BuildPlatformOpsMemoryScopeKey(), + Visibility: aidomain.MemoryVisibilitySuperAdmin, + }, + aidomain.MemoryAccessContext{ + Principal: aidomain.AIToolPrincipal{UserID: 11}, + AllowPlatformOps: true, + }, + ) + + if decision.Allowed { + t.Fatalf("CanReadMemory() allowed = true, want false") + } + if decision.ReasonCode != aidomain.MemoryReasonDenyNotSuperAdmin { + t.Fatalf("CanReadMemory() reason_code = %q, want %q", decision.ReasonCode, aidomain.MemoryReasonDenyNotSuperAdmin) + } +} + +func sha256Hex(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func normalizeWhitespace(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} diff --git a/internal/service/system/aiMemoryRecall.go b/internal/service/system/aiMemoryRecall.go new file mode 100644 index 0000000..d9635a4 --- /dev/null +++ b/internal/service/system/aiMemoryRecall.go @@ -0,0 +1,275 @@ +package system + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + "personal_assistant/global" + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" +) + +const ( + defaultAIMemoryRecallTopK = 10 + defaultAIMemoryRecallMaxChars = 4000 + defaultAIMemoryRecentRawTurns = 8 + aiMemoryContextMessageIDPrefix = "memory_context" + aiMemoryContextMessageHeader = "以下是系统恢复的记忆上下文,仅作为背景,不代表用户本轮新输入。" + aiMemoryContextTruncationIndicator = "\n..." +) + +// Recall 从已沉淀的 summary 和 self facts 中恢复本轮可读的记忆上下文。 +func (s *AIMemoryService) Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) { + if !aiMemoryEnabled() || s == nil || s.repo == nil || input.UserID == 0 { + return aiMemoryRecallResult{}, nil + } + + summary, err := s.recallConversationSummary(ctx, input) + if err != nil { + return aiMemoryRecallResult{}, err + } + facts, err := s.recallSelfFacts(ctx, input.UserID) + if err != nil { + return aiMemoryRecallResult{}, err + } + + content := buildAIMemoryContextContent(summary, facts, input.Query, aiMemoryRecallMaxChars()) + if strings.TrimSpace(content) == "" { + return aiMemoryRecallResult{}, nil + } + message := aidomain.Message{ + ID: buildAIMemoryContextMessageID(input.ConversationID), + Role: aidomain.RoleAssistant, + Content: content, + } + return aiMemoryRecallResult{ + PromptBlocks: []string{content}, + Messages: []aidomain.Message{message}, + }, nil +} + +// RecallMessages 满足 aiContextAssembler 的记忆扩展点。 +func (s *AIMemoryService) RecallMessages(ctx context.Context, input aiMemoryRecallInput) ([]aidomain.Message, error) { + result, err := s.Recall(ctx, input) + if err != nil { + return nil, err + } + return result.Messages, nil +} + +// CompressMessages 在进入 runtime 前把上下文压成 memory + recent turns。 +func (s *AIMemoryService) CompressMessages(ctx context.Context, input aiContextCompressionInput) ([]aidomain.Message, error) { + _ = ctx + if !aiMemoryEnabled() || len(input.Messages) == 0 { + return input.Messages, nil + } + + memoryMessages, rawMessages := splitAIMemoryContextMessages(input.Messages) + reordered := joinAIMemoryFirst(memoryMessages, rawMessages) + threshold := aiMemoryCompressThresholdTokens() + if threshold <= 0 || estimateAIMemoryTokens(reordered) <= threshold { + return reordered, nil + } + + recent := selectRecentAIMemoryRawMessages(rawMessages, aiMemoryRecentRawMessageLimit()) + return joinAIMemoryFirst(memoryMessages, recent), nil +} + +func (s *AIMemoryService) recallConversationSummary( + ctx context.Context, + input aiMemoryRecallInput, +) (*entity.AIConversationSummary, error) { + conversationID := strings.TrimSpace(input.ConversationID) + if conversationID == "" { + return nil, nil + } + orgID := cloneMemoryUintPtr(input.ToolCallCtx.Principal.CurrentOrgID) + scopeKey := aidomain.BuildConversationMemoryScopeKey(input.UserID, orgID) + return s.repo.GetConversationSummary(ctx, aidomain.MemoryConversationSummaryQuery{ + ConversationID: conversationID, + UserID: input.UserID, + OrgID: orgID, + ScopeKey: scopeKey, + }) +} + +func (s *AIMemoryService) recallSelfFacts(ctx context.Context, userID uint) ([]*entity.AIMemoryFact, error) { + if !aiMemoryEntityEnabled() || userID == 0 { + return nil, nil + } + return s.repo.ListFacts(ctx, aidomain.MemoryFactQuery{ + ScopeKeys: []string{aidomain.BuildSelfMemoryScopeKey(userID)}, + AllowedVisibilities: []aidomain.MemoryVisibility{aidomain.MemoryVisibilitySelf}, + Limit: aiMemoryRecallTopK(), + }) +} + +func buildAIMemoryContextContent( + summary *entity.AIConversationSummary, + facts []*entity.AIMemoryFact, + query string, + maxChars int, +) string { + summaryText := "" + if summary != nil { + summaryText = normalizeAIMemoryContextLine(summary.SummaryText) + } + factLines := renderAIMemoryFactLines(facts) + currentQuery := normalizeAIMemoryContextLine(query) + if summaryText == "" && len(factLines) == 0 { + return "" + } + + var builder strings.Builder + builder.WriteString(aiMemoryContextMessageHeader) + builder.WriteString("\n\n## Conversation Summary\n") + if summaryText == "" { + builder.WriteString("- 无") + } else { + builder.WriteString(summaryText) + } + if len(factLines) > 0 { + builder.WriteString("\n\n## Stable Facts\n") + for _, line := range factLines { + builder.WriteString("- ") + builder.WriteString(line) + builder.WriteString("\n") + } + } + if currentQuery != "" { + builder.WriteString("\n## Current Query\n") + builder.WriteString(currentQuery) + } + return truncateAIMemoryContext(builder.String(), maxChars) +} + +func renderAIMemoryFactLines(facts []*entity.AIMemoryFact) []string { + if len(facts) == 0 { + return nil + } + lines := make([]string, 0, len(facts)) + for _, fact := range facts { + if fact == nil { + continue + } + namespace := normalizeAIMemoryContextLine(fact.Namespace) + factKey := normalizeAIMemoryContextLine(fact.FactKey) + value := normalizeAIMemoryContextLine(fact.Summary) + if value == "" { + value = normalizeAIMemoryContextLine(fact.FactValueJSON) + } + if namespace == "" || factKey == "" || value == "" { + continue + } + lines = append(lines, fmt.Sprintf("%s/%s: %s", namespace, factKey, value)) + } + return lines +} + +func splitAIMemoryContextMessages(messages []aidomain.Message) ([]aidomain.Message, []aidomain.Message) { + memoryMessages := make([]aidomain.Message, 0, 1) + rawMessages := make([]aidomain.Message, 0, len(messages)) + for _, message := range messages { + if isAIMemoryContextMessage(message) { + memoryMessages = append(memoryMessages, message) + continue + } + rawMessages = append(rawMessages, message) + } + return memoryMessages, rawMessages +} + +func joinAIMemoryFirst(memoryMessages []aidomain.Message, rawMessages []aidomain.Message) []aidomain.Message { + if len(memoryMessages) == 0 { + return append([]aidomain.Message(nil), rawMessages...) + } + items := make([]aidomain.Message, 0, len(memoryMessages)+len(rawMessages)) + items = append(items, memoryMessages...) + items = append(items, rawMessages...) + return items +} + +func selectRecentAIMemoryRawMessages(messages []aidomain.Message, limit int) []aidomain.Message { + if limit <= 0 || len(messages) <= limit { + return append([]aidomain.Message(nil), messages...) + } + return append([]aidomain.Message(nil), messages[len(messages)-limit:]...) +} + +func isAIMemoryContextMessage(message aidomain.Message) bool { + id := strings.TrimSpace(message.ID) + if strings.HasPrefix(id, aiMemoryContextMessageIDPrefix) { + return true + } + return strings.Contains(message.Content, aiMemoryContextMessageHeader) +} + +func buildAIMemoryContextMessageID(conversationID string) string { + conversationID = strings.TrimSpace(conversationID) + if conversationID == "" { + return aiMemoryContextMessageIDPrefix + } + return aiMemoryContextMessageIDPrefix + "_" + conversationID +} + +func normalizeAIMemoryContextLine(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func truncateAIMemoryContext(value string, maxChars int) string { + if maxChars <= 0 || utf8.RuneCountInString(value) <= maxChars { + return value + } + indicatorRunes := utf8.RuneCountInString(aiMemoryContextTruncationIndicator) + limit := maxChars - indicatorRunes + if limit <= 0 { + limit = maxChars + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) + aiMemoryContextTruncationIndicator +} + +func estimateAIMemoryTokens(messages []aidomain.Message) int { + totalRunes := 0 + for _, message := range messages { + totalRunes += utf8.RuneCountInString(message.Content) + } + if totalRunes == 0 { + return 0 + } + return (totalRunes + 3) / 4 +} + +func aiMemoryRecallTopK() int { + if global.Config == nil || global.Config.AI.Memory.RecallTopK <= 0 { + return defaultAIMemoryRecallTopK + } + return global.Config.AI.Memory.RecallTopK +} + +func aiMemoryRecallMaxChars() int { + if global.Config == nil || global.Config.AI.Memory.RecallMaxChars <= 0 { + return defaultAIMemoryRecallMaxChars + } + return global.Config.AI.Memory.RecallMaxChars +} + +func aiMemoryRecentRawMessageLimit() int { + turns := defaultAIMemoryRecentRawTurns + if global.Config != nil && global.Config.AI.Memory.RecentRawTurns > 0 { + turns = global.Config.AI.Memory.RecentRawTurns + } + return turns * 2 +} + +func aiMemoryCompressThresholdTokens() int { + if global.Config == nil { + return 0 + } + return global.Config.AI.Memory.CompressThresholdTokens +} diff --git a/internal/service/system/aiMemoryRecall_test.go b/internal/service/system/aiMemoryRecall_test.go new file mode 100644 index 0000000..831565d --- /dev/null +++ b/internal/service/system/aiMemoryRecall_test.go @@ -0,0 +1,262 @@ +package system + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/config" + "personal_assistant/internal/model/entity" +) + +func TestAIMemoryRecallMessagesDisabledReturnsEmpty(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + restore := setAIMemoryTestConfig(t, config.AIMemory{Enabled: false}) + defer restore() + + messages, err := service.RecallMessages(context.Background(), aiMemoryRecallInput{ + ConversationID: "conv-disabled-recall", + UserID: 12, + Query: "帮我恢复上下文", + }) + if err != nil { + t.Fatalf("RecallMessages() error = %v", err) + } + if len(messages) != 0 { + t.Fatalf("RecallMessages() len = %d, want 0", len(messages)) + } +} + +func TestAIMemoryRecallMessagesBuildsSummaryAndFactsContext(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + RecallTopK: 5, + RecallMaxChars: 2000, + }) + defer restore() + + ctx := context.Background() + userID := uint(13) + conversationID := "conv-recall" + upsertAIMemoryRecallSummary(t, service, conversationID, userID, "用户确认采用 summary + recent turns 的上下文恢复方案。") + upsertAIMemoryRecallFact(t, service, userID, "answer_style", "以后回答尽量简洁,并优先给出可执行步骤。") + + messages, err := service.RecallMessages(ctx, aiMemoryRecallInput{ + ConversationID: conversationID, + UserID: userID, + Query: "下一步怎么实现压缩?", + ToolCallCtx: aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: userID}, + }, + }) + if err != nil { + t.Fatalf("RecallMessages() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("RecallMessages() len = %d, want 1", len(messages)) + } + message := messages[0] + if message.Role != aidomain.RoleAssistant { + t.Fatalf("memory role = %q, want assistant", message.Role) + } + assertAIMemoryRecallContains(t, message.Content, aiMemoryContextMessageHeader) + assertAIMemoryRecallContains(t, message.Content, "## Conversation Summary") + assertAIMemoryRecallContains(t, message.Content, "用户确认采用 summary + recent turns 的上下文恢复方案。") + assertAIMemoryRecallContains(t, message.Content, "## Stable Facts") + assertAIMemoryRecallContains(t, message.Content, "user_preference/answer_style") + assertAIMemoryRecallContains(t, message.Content, "以后回答尽量简洁") + assertAIMemoryRecallContains(t, message.Content, "## Current Query") + assertAIMemoryRecallContains(t, message.Content, "下一步怎么实现压缩?") +} + +func TestAIMemoryRecallMessagesEmptyWhenNoSummaryOrFacts(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + }) + defer restore() + + messages, err := service.RecallMessages(context.Background(), aiMemoryRecallInput{ + ConversationID: "conv-empty-recall", + UserID: 14, + Query: "没有记忆时不应注入", + }) + if err != nil { + t.Fatalf("RecallMessages() error = %v", err) + } + if len(messages) != 0 { + t.Fatalf("RecallMessages() len = %d, want 0", len(messages)) + } +} + +func TestAIMemoryCompressMessagesKeepsBelowThresholdHistory(t *testing.T) { + service := &AIMemoryService{} + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + CompressThresholdTokens: 1000, + }) + defer restore() + + input := []aidomain.Message{ + {ID: "msg-1", Role: aidomain.RoleUser, Content: "第一句"}, + {ID: "msg-2", Role: aidomain.RoleAssistant, Content: "第二句"}, + } + output, err := service.CompressMessages(context.Background(), aiContextCompressionInput{Messages: input}) + if err != nil { + t.Fatalf("CompressMessages() error = %v", err) + } + if len(output) != len(input) || output[0].ID != "msg-1" || output[1].ID != "msg-2" { + t.Fatalf("CompressMessages() = %+v, want original order", output) + } +} + +func TestAIMemoryCompressMessagesKeepsMemoryAndRecentTurns(t *testing.T) { + service := &AIMemoryService{} + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + RecentRawTurns: 2, + CompressThresholdTokens: 1, + }) + defer restore() + + input := []aidomain.Message{ + {ID: "msg-1", Role: aidomain.RoleUser, Content: "message one content"}, + {ID: "msg-2", Role: aidomain.RoleAssistant, Content: "message two content"}, + {ID: "msg-3", Role: aidomain.RoleUser, Content: "message three content"}, + {ID: "msg-4", Role: aidomain.RoleAssistant, Content: "message four content"}, + {ID: "msg-5", Role: aidomain.RoleUser, Content: "message five content"}, + {ID: "msg-6", Role: aidomain.RoleAssistant, Content: "message six content"}, + {ID: "memory_context_conv", Role: aidomain.RoleAssistant, Content: aiMemoryContextMessageHeader + "\nsummary"}, + } + + output, err := service.CompressMessages(context.Background(), aiContextCompressionInput{Messages: input}) + if err != nil { + t.Fatalf("CompressMessages() error = %v", err) + } + wantIDs := []string{"memory_context_conv", "msg-3", "msg-4", "msg-5", "msg-6"} + if len(output) != len(wantIDs) { + t.Fatalf("CompressMessages() len = %d, want %d: %+v", len(output), len(wantIDs), output) + } + for i, want := range wantIDs { + if output[i].ID != want { + t.Fatalf("CompressMessages()[%d].ID = %q, want %q", i, output[i].ID, want) + } + } +} + +func TestDefaultAIContextAssemblerRecallsAndCompressesWithAIMemoryService(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + RecentRawTurns: 1, + CompressThresholdTokens: 1, + RecallMaxChars: 2000, + }) + defer restore() + + userID := uint(15) + conversationID := "conv-assembler-recall" + upsertAIMemoryRecallSummary(t, service, conversationID, userID, "旧历史已压缩成摘要,关键决策是采用 memory + recent turns。") + assembler := newAIContextAssembler(AIDeps{ + Memory: service, + Compressor: service, + }) + + snapshot, err := assembler.Build(context.Background(), aiContextBuildArgs{ + ConversationID: conversationID, + UserID: userID, + Query: "继续实现", + StoredMessages: []*entity.AIMessage{ + {ID: "msg-1", Role: aidomain.RoleUser, Content: "很早的用户消息"}, + {ID: "msg-2", Role: aidomain.RoleAssistant, Content: "很早的助手消息"}, + {ID: "msg-3", Role: aidomain.RoleUser, Content: "最近的用户消息"}, + {ID: "msg-4", Role: aidomain.RoleAssistant, Content: "最近的助手消息"}, + }, + ToolCallCtx: aidomain.ToolCallContext{ + Principal: aidomain.AIToolPrincipal{UserID: userID}, + }, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + wantIDs := []string{aiMemoryContextMessageIDPrefix + "_" + conversationID, "msg-3", "msg-4"} + if len(snapshot.History) != len(wantIDs) { + t.Fatalf("History len = %d, want %d: %+v", len(snapshot.History), len(wantIDs), snapshot.History) + } + for i, want := range wantIDs { + if snapshot.History[i].ID != want { + t.Fatalf("History[%d].ID = %q, want %q", i, snapshot.History[i].ID, want) + } + } + assertAIMemoryRecallContains(t, snapshot.History[0].Content, "旧历史已压缩成摘要") +} + +func upsertAIMemoryRecallSummary( + t *testing.T, + service *AIMemoryService, + conversationID string, + userID uint, + summary string, +) { + t.Helper() + now := time.Now() + if err := service.repo.UpsertConversationSummary(context.Background(), &entity.AIConversationSummary{ + ConversationID: conversationID, + UserID: userID, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(userID), + SummaryText: summary, + KeyPointsJSON: "[]", + OpenLoopsJSON: "[]", + TokenEstimate: estimateAIMemoryTokens([]aidomain.Message{{Content: summary}}), + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("upsert summary: %v", err) + } +} + +func upsertAIMemoryRecallFact(t *testing.T, service *AIMemoryService, userID uint, factKey string, summary string) { + t.Helper() + now := time.Now() + if err := service.repo.UpsertFact(context.Background(), &entity.AIMemoryFact{ + ScopeKey: aidomain.BuildSelfMemoryScopeKey(userID), + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + Namespace: aidomain.MemoryNamespaceUserPreference, + FactKey: factKey, + FactValueJSON: fmtAIMemoryRecallFactValue(summary), + Summary: summary, + Confidence: 0.9, + SourceKind: string(aidomain.MemorySourceExplicitUserStatement), + SourceID: "msg-fact", + EffectiveAt: &now, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("upsert fact: %v", err) + } +} + +func fmtAIMemoryRecallFactValue(summary string) string { + payload, _ := json.Marshal(map[string]string{"value": summary}) + return string(payload) +} + +func assertAIMemoryRecallContains(t *testing.T, value string, want string) { + t.Helper() + if !strings.Contains(value, want) { + t.Fatalf("value does not contain %q:\n%s", want, value) + } +} diff --git a/internal/service/system/aiMemorySvc.go b/internal/service/system/aiMemorySvc.go new file mode 100644 index 0000000..49f2a93 --- /dev/null +++ b/internal/service/system/aiMemorySvc.go @@ -0,0 +1,125 @@ +package system + +import ( + "context" + "errors" + "strings" + "time" + + "personal_assistant/global" + aidomain "personal_assistant/internal/domain/ai" + aimemory "personal_assistant/internal/infrastructure/ai/memory" + "personal_assistant/internal/model/entity" + "personal_assistant/internal/repository" + "personal_assistant/internal/repository/interfaces" +) + +var errAIMemoryPhase1NotImplemented = errors.New("ai memory phase 1 skeleton is not integrated yet") + +type aiMemoryRecallResult struct { + // PromptBlocks 预留给后续把记忆整理成可拼接到 prompt 的文本块。 + PromptBlocks []string + // Messages 预留给后续直接注入 runtime history 的记忆消息片段。 + Messages []aidomain.Message +} + +type aiMemoryWritebackInput struct { + // ConversationID 标识当前完成写回的会话。 + ConversationID string + // UserID 表示这次写回归属于哪个用户。 + UserID uint + // OrgID 表示这次写回发生时的组织上下文;为空时表示个人会话。 + OrgID *uint + // UserMessageID 是触发本轮回答的用户消息 ID。 + UserMessageID string + // AssistantMessageID 是本轮完成写回的 assistant 消息 ID。 + AssistantMessageID string + // Principal 是本轮 AI tool 链路已经解析出的授权事实。 + Principal aidomain.AIToolPrincipal +} + +// AIMemoryService 收口记忆模块的仓储依赖和后续扩展点。 +type AIMemoryService struct { + // aiRepo 用于读取已落库的会话消息快照。 + aiRepo interfaces.AIRepository + // repo 是记忆模块的正式持久化入口。 + repo interfaces.AIMemoryRepository + // outboxRepo 为后续异步 document upsert 预留,本阶段不实际投递 memory outbox 事件。 + outboxRepo interfaces.OutboxRepository + // policy 预留给下一步记忆治理规则。 + policy aiMemoryPolicy + // extractor 负责从完成轮次中抽取候选记忆。 + extractor aidomain.MemoryExtractor + // chunker 负责把长期记忆文档切成可向量化片段。 + chunker aidomain.MemoryChunker + // embedder 负责为 chunk 文本生成向量。 + embedder aidomain.MemoryEmbedder + // vectorStore 负责把 chunk 向量写入 Qdrant。 + vectorStore aidomain.MemoryVectorStore +} + +// NewAIMemoryService 基于正式 repository group 构造记忆服务骨架。 +func NewAIMemoryService(repositoryGroup *repository.Group) *AIMemoryService { + if repositoryGroup == nil || repositoryGroup.SystemRepositorySupplier == nil { + // Phase 1 先保证骨架可安全构造,不把 memory 变成启动期硬依赖。 + return &AIMemoryService{} + } + return &AIMemoryService{ + aiRepo: repositoryGroup.SystemRepositorySupplier.GetAIRepository(), + repo: repositoryGroup.SystemRepositorySupplier.GetAIMemoryRepository(), + outboxRepo: repositoryGroup.SystemRepositorySupplier.GetOutboxRepository(), + policy: aiMemoryPolicy{}, + extractor: aimemory.NewRuleExtractor(aimemory.Options{}), + chunker: aimemory.NewParagraphChunker(aimemory.ChunkerOptions{ + MaxChars: aiMemoryChunkMaxChars(), + OverlapChars: aiMemoryChunkOverlapChars(), + }), + embedder: aimemory.NewDashScopeEmbedder(aimemory.EmbedderOptions{ + APIKey: aiMemoryAPIKey(), + Endpoint: aiMemoryEmbedEndpoint(), + Model: aiMemoryEmbedModel(), + Dimension: aiMemoryEmbedDimension(), + Timeout: time.Duration(aiMemoryIndexTimeoutSeconds()) * time.Second, + }), + vectorStore: newAIMemoryVectorStore(), + } +} + +// RefreshConversationSummary 预留给后续 summary 刷新流程。 +func (s *AIMemoryService) RefreshConversationSummary(ctx context.Context, conversationID string) error { + _ = ctx + if strings.TrimSpace(conversationID) == "" { + // 空会话 ID 不触发任何动作,避免后续接主链路时引入无意义写放大。 + return nil + } + return errAIMemoryPhase1NotImplemented +} + +// UpsertFact 直接透传到仓储层,供后续流程复用。 +func (s *AIMemoryService) UpsertFact(ctx context.Context, fact *entity.AIMemoryFact) error { + if s == nil || s.repo == nil || fact == nil { + return nil + } + return s.repo.UpsertFact(ctx, fact) +} + +// ScheduleDocumentUpsert 当前阶段不走 outbox,直接透传到仓储层批量 upsert。 +func (s *AIMemoryService) ScheduleDocumentUpsert(ctx context.Context, docs []*entity.AIMemoryDocument) error { + if s == nil || s.repo == nil || len(docs) == 0 { + return nil + } + if err := s.repo.BatchUpsertDocuments(ctx, docs); err != nil { + return err + } + s.triggerDocumentIndex(ctx, docs) + return nil +} + +func newAIMemoryVectorStore() aidomain.MemoryVectorStore { + if global.QdrantClient == nil { + return nil + } + return aimemory.NewQdrantVectorStore(global.QdrantClient, aimemory.VectorStoreOptions{ + CollectionName: aiMemoryCollectionName(), + }) +} diff --git a/internal/service/system/aiMemoryWriteback.go b/internal/service/system/aiMemoryWriteback.go new file mode 100644 index 0000000..2a8ba8e --- /dev/null +++ b/internal/service/system/aiMemoryWriteback.go @@ -0,0 +1,440 @@ +package system + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + "time" + + "personal_assistant/global" + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" +) + +type aiMemoryWritebackSnapshot struct { + UserMessage aidomain.Message + AssistantMessage aidomain.Message + PreviousSummary *entity.AIConversationSummary +} + +// OnTurnCompleted extracts and persists memory candidates after a successful AI turn. +func (s *AIMemoryService) OnTurnCompleted(ctx context.Context, input aiMemoryWritebackInput) error { + if !aiMemoryEnabled() || s == nil || s.repo == nil || s.aiRepo == nil || s.extractor == nil { + return nil + } + if strings.TrimSpace(input.ConversationID) == "" || + strings.TrimSpace(input.UserMessageID) == "" || + strings.TrimSpace(input.AssistantMessageID) == "" || + input.UserID == 0 { + return nil + } + + snapshot, err := s.buildWritebackSnapshot(ctx, input) + if err != nil { + return err + } + if snapshot == nil || strings.TrimSpace(snapshot.AssistantMessage.Content) == "" { + return nil + } + + extracted, err := s.extractor.Extract(ctx, aidomain.MemoryExtractionInput{ + ConversationID: input.ConversationID, + UserID: input.UserID, + OrgID: input.OrgID, + Principal: normalizeMemoryPrincipal(input), + UserMessage: snapshot.UserMessage, + AssistantMessage: snapshot.AssistantMessage, + PreviousSummaryText: previousSummaryText(snapshot.PreviousSummary), + }) + if err != nil { + return err + } + + access := s.buildMemoryAccessContext(input) + if err := s.applyConversationSummary(ctx, input, extracted.Summary); err != nil { + return err + } + if err := s.applyFactCandidates(ctx, extracted.Facts, access); err != nil { + return err + } + return s.applyDocumentCandidates(ctx, extracted.Documents, access) +} + +func (s *AIMemoryService) buildWritebackSnapshot( + ctx context.Context, + input aiMemoryWritebackInput, +) (*aiMemoryWritebackSnapshot, error) { + messages, err := s.aiRepo.ListMessagesByConversation(ctx, input.ConversationID) + if err != nil { + return nil, err + } + var userMessage *entity.AIMessage + var assistantMessage *entity.AIMessage + for _, item := range messages { + if item == nil { + continue + } + if item.ID == input.UserMessageID { + userMessage = item + } + if item.ID == input.AssistantMessageID { + assistantMessage = item + } + } + if userMessage == nil || assistantMessage == nil || assistantMessage.Status != aiMessageStatusSuccess { + return nil, nil + } + + scopeKey := aidomain.BuildConversationMemoryScopeKey(input.UserID, input.OrgID) + previous, err := s.repo.GetConversationSummary(ctx, aidomain.MemoryConversationSummaryQuery{ + ConversationID: input.ConversationID, + UserID: input.UserID, + OrgID: input.OrgID, + ScopeKey: scopeKey, + }) + if err != nil { + return nil, err + } + + return &aiMemoryWritebackSnapshot{ + UserMessage: aiEntityMessageToDomain(userMessage), + AssistantMessage: aiEntityMessageToDomain(assistantMessage), + PreviousSummary: previous, + }, nil +} + +func (s *AIMemoryService) applyConversationSummary( + ctx context.Context, + input aiMemoryWritebackInput, + draft *aidomain.ConversationSummaryDraft, +) error { + if draft == nil || strings.TrimSpace(draft.SummaryText) == "" { + return nil + } + now := time.Now() + scopeKey := aidomain.BuildConversationMemoryScopeKey(input.UserID, input.OrgID) + return s.repo.UpsertConversationSummary(ctx, &entity.AIConversationSummary{ + ConversationID: input.ConversationID, + UserID: input.UserID, + OrgID: cloneMemoryUintPtr(input.OrgID), + ScopeKey: scopeKey, + CompressedUntilMessageID: draft.CompressedUntilMessageID, + SummaryText: draft.SummaryText, + KeyPointsJSON: defaultMemoryJSONList(draft.KeyPointsJSON), + OpenLoopsJSON: defaultMemoryJSONList(draft.OpenLoopsJSON), + TokenEstimate: draft.TokenEstimate, + CreatedAt: now, + UpdatedAt: now, + }) +} + +func (s *AIMemoryService) applyFactCandidates( + ctx context.Context, + candidates []aidomain.MemoryFactCandidate, + access aidomain.MemoryAccessContext, +) error { + if !aiMemoryEntityEnabled() || len(candidates) == 0 { + return nil + } + for _, candidate := range candidates { + if candidate.ScopeType != aidomain.MemoryScopeSelf { + continue + } + if decision := s.policy.ShouldStoreFact(candidate, access); !decision.Allowed { + continue + } + scopeDecision := s.policy.ResolveScope(aidomain.MemoryScopeInput{ + ScopeType: candidate.ScopeType, + UserID: candidate.UserID, + OrgID: candidate.OrgID, + }, access) + if !scopeDecision.Allowed { + continue + } + visibilityDecision := s.policy.ResolveVisibility(scopeDecision, candidate.SourceKind) + if !visibilityDecision.Allowed { + continue + } + shouldUpsert, err := s.shouldUpsertFact(ctx, candidate, scopeDecision, visibilityDecision) + if err != nil { + return err + } + if !shouldUpsert { + continue + } + ttl := s.policy.ResolveTTL(candidate.Namespace, "") + fact := buildMemoryFactEntity(candidate, scopeDecision, visibilityDecision, ttl.ExpiresAt) + if err := s.repo.UpsertFact(ctx, fact); err != nil { + return err + } + } + return nil +} + +func (s *AIMemoryService) shouldUpsertFact( + ctx context.Context, + candidate aidomain.MemoryFactCandidate, + scopeDecision aidomain.MemoryScopeDecision, + visibilityDecision aidomain.MemoryVisibilityDecision, +) (bool, error) { + rows, err := s.repo.ListFacts(ctx, aidomain.MemoryFactQuery{ + ScopeKeys: []string{scopeDecision.ScopeKey}, + AllowedVisibilities: []aidomain.MemoryVisibility{visibilityDecision.Visibility}, + Namespace: candidate.Namespace, + FactKeys: []string{candidate.FactKey}, + Limit: 1, + }) + if err != nil || len(rows) == 0 || rows[0] == nil { + return err == nil, err + } + decision := s.policy.ShouldOverrideFact( + aidomain.MemoryFactVersion{ + ValueJSON: rows[0].FactValueJSON, + SourceKind: aidomain.MemorySourceKind(rows[0].SourceKind), + }, + aidomain.MemoryFactVersion{ + ValueJSON: candidate.FactValueJSON, + SourceKind: candidate.SourceKind, + }, + scopeDecision.ScopeType, + candidate.Namespace, + ) + return decision.Allowed, nil +} + +func (s *AIMemoryService) applyDocumentCandidates( + ctx context.Context, + candidates []aidomain.MemoryDocumentCandidate, + access aidomain.MemoryAccessContext, +) error { + if !aiMemoryLongTermEnabled() || len(candidates) == 0 { + return nil + } + docs := make([]*entity.AIMemoryDocument, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.ScopeType != aidomain.MemoryScopeSelf { + continue + } + decision := s.policy.ShouldStoreDocument(candidate, access) + if !decision.Allowed { + continue + } + scopeDecision := s.policy.ResolveScope(aidomain.MemoryScopeInput{ + ScopeType: candidate.ScopeType, + UserID: candidate.UserID, + OrgID: candidate.OrgID, + }, access) + if !scopeDecision.Allowed { + continue + } + visibilityDecision := s.policy.ResolveVisibility(scopeDecision, candidate.SourceKind) + if !visibilityDecision.Allowed { + continue + } + ttl := s.policy.ResolveTTL("", candidate.MemoryType) + docs = append(docs, buildMemoryDocumentEntity(candidate, scopeDecision, visibilityDecision, decision, ttl.ExpiresAt)) + } + if len(docs) == 0 { + return nil + } + if err := s.repo.BatchUpsertDocuments(ctx, docs); err != nil { + return err + } + s.triggerDocumentIndex(ctx, docs) + return nil +} + +func buildMemoryFactEntity( + candidate aidomain.MemoryFactCandidate, + scopeDecision aidomain.MemoryScopeDecision, + visibilityDecision aidomain.MemoryVisibilityDecision, + expiresAt *time.Time, +) *entity.AIMemoryFact { + now := time.Now() + return &entity.AIMemoryFact{ + ScopeKey: scopeDecision.ScopeKey, + ScopeType: string(scopeDecision.ScopeType), + Visibility: string(visibilityDecision.Visibility), + UserID: cloneMemoryUintPtr(scopeDecision.UserID), + OrgID: cloneMemoryUintPtr(scopeDecision.OrgID), + Namespace: candidate.Namespace, + FactKey: candidate.FactKey, + FactValueJSON: candidate.FactValueJSON, + Summary: candidate.Summary, + Confidence: 0.9, + SourceKind: string(candidate.SourceKind), + SourceID: candidate.SourceID, + EffectiveAt: &now, + ExpiresAt: expiresAt, + CreatedAt: now, + UpdatedAt: now, + } +} + +func buildMemoryDocumentEntity( + candidate aidomain.MemoryDocumentCandidate, + scopeDecision aidomain.MemoryScopeDecision, + visibilityDecision aidomain.MemoryVisibilityDecision, + decision aidomain.MemoryDocumentDecision, + expiresAt *time.Time, +) *entity.AIMemoryDocument { + now := time.Now() + return &entity.AIMemoryDocument{ + ID: buildMemoryDocumentID(scopeDecision.ScopeKey, decision.DedupKey), + ScopeKey: scopeDecision.ScopeKey, + ScopeType: string(scopeDecision.ScopeType), + Visibility: string(visibilityDecision.Visibility), + UserID: cloneMemoryUintPtr(scopeDecision.UserID), + OrgID: cloneMemoryUintPtr(scopeDecision.OrgID), + MemoryType: string(candidate.MemoryType), + Topic: candidate.Topic, + Title: candidate.Title, + Summary: candidate.Summary, + ContentText: candidate.ContentText, + ContentHash: decision.ContentHash, + SummaryHash: decision.SummaryHash, + DedupKey: decision.DedupKey, + Importance: 0.8, + QualityScore: 0.8, + EmbeddingModel: aiMemoryEmbedModel(), + SourceKind: string(candidate.SourceKind), + SourceID: candidate.SourceID, + EffectiveAt: &now, + ExpiresAt: expiresAt, + CreatedAt: now, + UpdatedAt: now, + } +} + +func (s *AIMemoryService) buildMemoryAccessContext(input aiMemoryWritebackInput) aidomain.MemoryAccessContext { + return aidomain.MemoryAccessContext{ + Principal: normalizeMemoryPrincipal(input), + } +} + +func normalizeMemoryPrincipal(input aiMemoryWritebackInput) aidomain.AIToolPrincipal { + principal := input.Principal + if principal.UserID == 0 { + principal.UserID = input.UserID + } + if principal.CurrentOrgID == nil { + principal.CurrentOrgID = cloneMemoryUintPtr(input.OrgID) + } + return principal +} + +func aiEntityMessageToDomain(message *entity.AIMessage) aidomain.Message { + if message == nil { + return aidomain.Message{} + } + role := strings.TrimSpace(message.Role) + if role != aidomain.RoleAssistant { + role = aidomain.RoleUser + } + return aidomain.Message{ + ID: message.ID, + Role: role, + Content: message.Content, + } +} + +func previousSummaryText(summary *entity.AIConversationSummary) string { + if summary == nil { + return "" + } + return summary.SummaryText +} + +func defaultMemoryJSONList(value string) string { + if strings.TrimSpace(value) == "" { + return "[]" + } + return value +} + +func buildMemoryDocumentID(scopeKey string, dedupKey string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(scopeKey) + "\n" + strings.TrimSpace(dedupKey))) + return "mem_doc_" + hex.EncodeToString(sum[:])[:32] +} + +func aiMemoryEnabled() bool { + return global.Config != nil && global.Config.AI.Memory.Enabled +} + +func aiMemoryEntityEnabled() bool { + return global.Config != nil && global.Config.AI.Memory.EnableEntityMemory +} + +func aiMemoryLongTermEnabled() bool { + return global.Config != nil && global.Config.AI.Memory.EnableLongTermMemory +} + +func aiMemoryEmbedModel() string { + if global.Config == nil { + return "qwen3-vl-embedding" + } + if value := strings.TrimSpace(global.Config.AI.Memory.EmbedModel); value != "" { + return value + } + return "qwen3-vl-embedding" +} + +func aiMemoryAPIKey() string { + if global.Config == nil { + return "" + } + return strings.TrimSpace(global.Config.AI.APIKey) +} + +func aiMemoryEmbedEndpoint() string { + if global.Config == nil { + return "" + } + return strings.TrimSpace(global.Config.AI.Memory.EmbedEndpoint) +} + +func aiMemoryEmbedDimension() int { + if global.Config == nil || global.Config.AI.Memory.EmbedDimension <= 0 { + return 1024 + } + return global.Config.AI.Memory.EmbedDimension +} + +func aiMemoryChunkMaxChars() int { + if global.Config == nil || global.Config.AI.Memory.ChunkMaxChars <= 0 { + return 1200 + } + return global.Config.AI.Memory.ChunkMaxChars +} + +func aiMemoryChunkOverlapChars() int { + if global.Config == nil || global.Config.AI.Memory.ChunkOverlapChars < 0 { + return 150 + } + return global.Config.AI.Memory.ChunkOverlapChars +} + +func aiMemoryIndexBatchSize() int { + if global.Config == nil || global.Config.AI.Memory.IndexBatchSize <= 0 { + return 20 + } + return global.Config.AI.Memory.IndexBatchSize +} + +func aiMemoryIndexTimeoutSeconds() int { + if global.Config == nil || global.Config.AI.Memory.IndexTimeoutSeconds <= 0 { + return 30 + } + return global.Config.AI.Memory.IndexTimeoutSeconds +} + +func aiMemoryCollectionName() string { + if global.Config == nil { + return "" + } + if value := strings.TrimSpace(global.Config.Qdrant.MemoryCollectionName); value != "" { + return value + } + return strings.TrimSpace(global.Config.Qdrant.CollectionName) +} diff --git a/internal/service/system/aiMemoryWriteback_test.go b/internal/service/system/aiMemoryWriteback_test.go new file mode 100644 index 0000000..40db717 --- /dev/null +++ b/internal/service/system/aiMemoryWriteback_test.go @@ -0,0 +1,283 @@ +package system + +import ( + "context" + stderrors "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "personal_assistant/global" + aidomain "personal_assistant/internal/domain/ai" + aimemory "personal_assistant/internal/infrastructure/ai/memory" + "personal_assistant/internal/model/config" + "personal_assistant/internal/model/entity" + reposystem "personal_assistant/internal/repository/system" +) + +func TestAIMemoryWritebackDisabledDoesNotWrite(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, aimemory.NewRuleExtractor(aimemory.Options{})) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: false, + EnableEntityMemory: true, + EnableLongTermMemory: true, + }) + defer restore() + + createAIWritebackMessages(t, db, "conv-disabled", "msg-user-disabled", "msg-ai-disabled", aiMessageStatusSuccess) + + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-disabled", + UserID: 7, + UserMessageID: "msg-user-disabled", + AssistantMessageID: "msg-ai-disabled", + Principal: aidomain.AIToolPrincipal{UserID: 7}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + + assertAIMemoryWritebackCount(t, db, &entity.AIConversationSummary{}, 0) + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryFact{}, 0) + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryDocument{}, 0) +} + +func TestAIMemoryWritebackPersistsSummaryFactAndDocument(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, aimemory.NewRuleExtractor(aimemory.Options{DocumentMinRunes: 40})) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + EnableLongTermMemory: true, + EmbedModel: "text-embedding-test", + }) + defer restore() + + createAIWritebackMessagesWithContent( + t, + db, + "conv-success", + "msg-user-success", + "msg-ai-success", + "请记住以后请用更简洁的方式回答我,并给我一个 memory writeback hook 的实现方案", + fmt.Sprintf("实现方案:%s", repeatMemoryText("流式成功收尾后触发写回,抽取 summary facts documents,再经过治理策略落库。", 4)), + aiMessageStatusSuccess, + ) + + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-success", + UserID: 8, + UserMessageID: "msg-user-success", + AssistantMessageID: "msg-ai-success", + Principal: aidomain.AIToolPrincipal{UserID: 8}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + + assertAIMemoryWritebackCount(t, db, &entity.AIConversationSummary{}, 1) + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryFact{}, 1) + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryDocument{}, 1) + + var fact entity.AIMemoryFact + if err := db.First(&fact).Error; err != nil { + t.Fatalf("load fact: %v", err) + } + if fact.ScopeKey != aidomain.BuildSelfMemoryScopeKey(8) { + t.Fatalf("fact scope_key = %q", fact.ScopeKey) + } + if fact.Namespace != aidomain.MemoryNamespaceUserPreference { + t.Fatalf("fact namespace = %q", fact.Namespace) + } + + var doc entity.AIMemoryDocument + if err := db.First(&doc).Error; err != nil { + t.Fatalf("load document: %v", err) + } + if doc.EmbeddingModel != "text-embedding-test" { + t.Fatalf("embedding_model = %q, want text-embedding-test", doc.EmbeddingModel) + } + if doc.DedupKey == "" { + t.Fatal("document dedup_key is empty") + } +} + +func TestAIMemoryWritebackSkipsUnsuccessfulAssistantMessage(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, aimemory.NewRuleExtractor(aimemory.Options{})) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + EnableLongTermMemory: true, + }) + defer restore() + + createAIWritebackMessages(t, db, "conv-error", "msg-user-error", "msg-ai-error", aiMessageStatusError) + + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-error", + UserID: 9, + UserMessageID: "msg-user-error", + AssistantMessageID: "msg-ai-error", + Principal: aidomain.AIToolPrincipal{UserID: 9}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + assertAIMemoryWritebackCount(t, db, &entity.AIConversationSummary{}, 0) +} + +func TestAIServiceTriggerMemoryWritebackSwallowsHookError(t *testing.T) { + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + WritebackAsync: false, + }) + defer restore() + + hook := &fakeMemoryWritebackHook{err: stderrors.New("writeback failed")} + service := &AIService{memoryWriteback: hook} + service.triggerMemoryWriteback( + context.Background(), + &entity.AIConversation{ID: "conv-hook", UserID: 10}, + &entity.AIMessage{ID: "msg-user-hook"}, + &entity.AIMessage{ID: "msg-ai-hook"}, + aidomain.AIToolPrincipal{UserID: 10}, + ) + + if hook.calls != 1 { + t.Fatalf("hook calls = %d, want 1", hook.calls) + } +} + +type fakeMemoryWritebackHook struct { + calls int + err error +} + +func (f *fakeMemoryWritebackHook) OnTurnCompleted(context.Context, aiMemoryWritebackInput) error { + f.calls++ + return f.err +} + +func newAIMemoryWritebackTestService(db *gorm.DB, extractor aidomain.MemoryExtractor) *AIMemoryService { + return &AIMemoryService{ + aiRepo: reposystem.NewAIRepository(db), + repo: reposystem.NewAIMemoryRepository(db), + policy: aiMemoryPolicy{}, + extractor: extractor, + } +} + +func newAIMemoryWritebackTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &entity.AIConversation{}, + &entity.AIMessage{}, + &entity.AIMemoryFact{}, + &entity.AIMemoryDocument{}, + &entity.AIMemoryDocumentChunk{}, + &entity.AIConversationSummary{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return db +} + +func setAIMemoryTestConfig(t *testing.T, memory config.AIMemory) func() { + t.Helper() + previous := global.Config + global.Config = &config.Config{AI: config.AI{Memory: memory}} + return func() { + global.Config = previous + } +} + +func createAIWritebackMessages( + t *testing.T, + db *gorm.DB, + conversationID string, + userMessageID string, + assistantMessageID string, + assistantStatus string, +) { + t.Helper() + createAIWritebackMessagesWithContent( + t, + db, + conversationID, + userMessageID, + assistantMessageID, + "请记住以后请用简洁方式回答。", + "好的,我会记住。", + assistantStatus, + ) +} + +func createAIWritebackMessagesWithContent( + t *testing.T, + db *gorm.DB, + conversationID string, + userMessageID string, + assistantMessageID string, + userContent string, + assistantContent string, + assistantStatus string, +) { + t.Helper() + now := time.Now() + messages := []*entity.AIMessage{ + { + ID: userMessageID, + ConversationID: conversationID, + Role: aidomain.RoleUser, + Content: userContent, + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: assistantMessageID, + ConversationID: conversationID, + Role: aidomain.RoleAssistant, + Content: assistantContent, + Status: assistantStatus, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now.Add(time.Millisecond), + UpdatedAt: now.Add(time.Millisecond), + }, + } + if err := db.Create(messages).Error; err != nil { + t.Fatalf("create messages: %v", err) + } +} + +func assertAIMemoryWritebackCount(t *testing.T, db *gorm.DB, model any, want int64) { + t.Helper() + var count int64 + if err := db.Model(model).Count(&count).Error; err != nil { + t.Fatalf("count %T: %v", model, err) + } + if count != want { + t.Fatalf("count %T = %d, want %d", model, count, want) + } +} + +func repeatMemoryText(value string, times int) string { + var builder strings.Builder + for i := 0; i < times; i++ { + builder.WriteString(value) + } + return builder.String() +} diff --git a/internal/service/system/aiSvc.go b/internal/service/system/aiSvc.go index ca249b8..52bb99a 100644 --- a/internal/service/system/aiSvc.go +++ b/internal/service/system/aiSvc.go @@ -49,6 +49,8 @@ type AIService struct { toolRegistry *aitool.Registry // contextAssembler 负责组装 runtime 所需的历史消息和动态 prompt。 contextAssembler aiContextAssembler + // memoryWriteback 在成功轮次完成后异步/同步写入记忆。 + memoryWriteback aiMemoryWritebackHook // toolPlanner 负责渐进式工具选择与动态 prompt 组装。 toolPlanner *aiselect.Planner } @@ -112,6 +114,7 @@ func newAIServiceWithDeps( toolRegistry: registry, // 上下文装配器负责收口历史消息、动态 prompt 和未来扩展点。 contextAssembler: newAIContextAssembler(deps), + memoryWriteback: deps.Writeback, // 渐进式 planner 负责把 selector 和 prompt builder 组合成本轮执行计划。 toolPlanner: aiselect.NewPlanner(registry, deps.Selector, deps.PromptBuilder), } @@ -362,7 +365,14 @@ func (s *AIService) StreamConversation( }, sink) // 所有已开始的流式请求都统一走 finishStream 收尾,避免成功和失败路径各自写一套状态处理逻辑。 - return s.finishStream(ctx, conversation, sink, execErr) + finishErr := s.finishStream(ctx, conversation, sink, execErr) + if finishErr != nil { + return finishErr + } + if execErr == nil { + s.triggerMemoryWriteback(ctx, conversation, userMessage, assistantMessage, toolPrincipal) + } + return nil } // requireConversationOwner 负责校验当前用户是否拥有指定会话。 diff --git a/internal/service/system/supplier.go b/internal/service/system/supplier.go index 9cfa4d8..5a4460d 100644 --- a/internal/service/system/supplier.go +++ b/internal/service/system/supplier.go @@ -45,6 +45,7 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { rawMenu := NewMenuService(repositoryGroup, rawPermissionProjection) rawRole := NewRoleService(repositoryGroup, rawPermissionProjection) rawImage := NewImageService(repositoryGroup) + rawAIMemory := NewAIMemoryService(repositoryGroup) rawObservability := obsquery.NewQueryService( global.ObservabilityMetrics, global.ObservabilityTraces, @@ -117,7 +118,10 @@ func SetUp(repositoryGroup *repository.Group) contract.Supplier { OJTask: ojTaskSvc, Observability: observabilitySvc, }, - Selector: progressiveSelector, + Memory: rawAIMemory, + Compressor: rawAIMemory, + Writeback: rawAIMemory, + Selector: progressiveSelector, }) // 对外仍只暴露统一的 AIService 契约,不把具体 tool 依赖细节泄露到上层。 aiSvc := contract.AIServiceContract(rawAI) diff --git a/plan/ai/approved-memory-context-recovery.md b/plan/ai/approved-memory-context-recovery.md new file mode 100644 index 0000000..4b3d122 --- /dev/null +++ b/plan/ai/approved-memory-context-recovery.md @@ -0,0 +1,60 @@ +# 目标 + +实现 AI 记忆第 4 步“上下文压缩,以及上下文恢复”:将长会话输入从“全量历史”改为“conversation summary + facts + recent turns”,在下一轮请求进入 runtime 前恢复必要上下文。 + +# 范围 + +- 只实现读侧上下文恢复和压缩,不实现 RAG 向量召回。 +- 复用第 3 步写入的 `AIConversationSummary` 与 `AIMemoryFact`。 +- 不新增 HTTP API,不改 Controller/Router。 +- 不新增数据库表。 +- document 记忆暂不进入 prompt;第 5/6 步 RAG 切分与召回再接入。 + +# 改动 + +- 改造 `AIMemoryService`: + - 实现 `Recall(ctx, aiMemoryRecallInput)`。 + - 新增 `RecallMessages(ctx, aiMemoryRecallInput)`,满足现有 `aiMemoryProvider`。 + - 新增 `CompressMessages(ctx, aiContextCompressionInput)`,满足现有 `aiContextCompressor`。 +- 在 `AIMemoryService.Recall` 中: + - 计算 self scope,读取当前会话 summary。 + - 读取 self 可见 facts。 + - 将 summary/facts 转成稳定的 memory context message。 + - 不读取 org/platform 记忆,避免权限语义扩大。 +- 在 `CompressMessages` 中: + - 按 `global.Config.AI.Memory.RecentRawTurns` 保留最近 N 轮原始消息。 + - 如果消息未超过阈值,保持原样。 + - 如果已存在 memory message,则保持其在前,后接 recent turns。 +- 改造 service 注入: + - 在 `SetUp` 中把同一个 `AIMemoryService` 同时注入 `AIDeps.Memory`、`AIDeps.Compressor`、`AIDeps.Writeback`。 + - 保证 `aiContextAssembler.Build` 进入 runtime 前自动执行 recall + compress。 +- 补充测试: + - memory disabled 时上下文不变。 + - 有 summary/facts 时生成 memory message。 + - 长历史只保留 recent turns,并保留 memory message。 + - `go test ./internal/service/system ./internal/repository/system ./internal/domain/ai` 通过。 + +# 验证 + +- 运行目标测试: + - `go test ./internal/service/system ./internal/repository/system ./internal/domain/ai` +- 完成后再运行: + - `go test ./...` + +# 风险 + +- v1 将 summary/facts 作为一条 synthetic system-style context message 注入;当前 domain 只有 user/assistant role,需采用 assistant role 或扩展 role。优先不扩 runtime 协议,使用 assistant role 且内容带明显边界。 +- summary 质量仍取决于第 3 步规则摘要,后续可替换为真正 compressor。 +- 只接 self 记忆,org/platform 记忆需要后续授权能力明确后再开。 + +# 执行顺序 + +1. 用户确认计划后,将本文件改名为 `approved-memory-context-recovery.md`。 +2. 实现 `AIMemoryService.RecallMessages` 和 `Recall`。 +3. 实现 `AIMemoryService.CompressMessages`。 +4. 在 `SetUp` 中注入 Memory/Compressor。 +5. 补单测并运行验证。 + +# 待确认 + +等待用户明确确认后执行。 diff --git a/plan/ai/approved-memory-governance-implementation.md b/plan/ai/approved-memory-governance-implementation.md new file mode 100644 index 0000000..f4bce7e --- /dev/null +++ b/plan/ai/approved-memory-governance-implementation.md @@ -0,0 +1,89 @@ +# 目标 + +在已冻结的 memory 骨架上实现“记忆治理”第一版规则收口,补齐 `aiMemoryPolicy` 的准入、权限、覆盖、TTL、去重与原因码返回,并同步收紧 `ConversationSummary` 读取契约与 `AIMemoryDocument` 去重字段,保证后续 writeback / recall 都有稳定护栏。 + +# 范围 + +- `internal/service/system` + - 实现 `aiMemoryPolicy` 的输入上下文、决策结果、核心规则函数与配套测试。 + - 视需要补充 `AIMemoryService` 的最小治理接线,但不直接把 memory 接入主链路 writeback。 +- `internal/domain/ai` + - 补充治理所需的稳定类型、来源优先级、scope/visibility 决策辅助结构。 +- `internal/model/entity` + - 为 `AIMemoryDocument` 增加 `content_hash / summary_hash / dedup_key`。 + - 视读取契约需要补充 summary 查询入参结构。 +- `internal/repository/interfaces` + - 收紧 `ConversationSummary` 读取接口,改为按 `conversation_id + user_id + org_id + scope_key` 读取。 +- `internal/repository/system` + - 实现新的 summary 查询条件。 + - 补充 document 去重字段的持久化更新。 + - 增加对应 repository 回归测试。 + +# 改动 + +- `aiMemoryPolicy` + - 新增显式授权上下文,固定只消费: + - `Principal` + - `ApprovedOrgScopeKeys` / `ApprovedOrgIDs` + - `AllowPlatformOps` + - 新增统一决策返回: + - `Allowed` + - `ReasonCode` + - `Reason` + - 实现并测试以下规则函数: + - `ShouldStoreFact` + - `ShouldStoreDocument` + - `ResolveScope` + - `ResolveVisibility` + - `ResolveTTL` + - `ShouldOverrideFact` + - `CanReadMemory` + - `CanWriteMemory` +- 权限与失败语义 + - `org` scope 只能消费显式传入的已授权组织集合。 + - 禁止通过 `CurrentOrgID` 或 policy 内部调用授权服务推断组织权限。 + - `platform_ops` 必须依赖显式超管事实。 + - 权限依赖缺失一律 `fail closed`。 +- 覆盖与优先级 + - `self/user_preference` 采用 `explicit_user_statement > admin_set > tool_verified_summary > model_inferred`。 + - `org/platform_ops` 公共记忆采用 `admin_set > explicit_user_statement > tool_verified_summary > model_inferred`。 + - 其余 `self` namespace 默认沿用私有记忆优先级。 +- 去重与 TTL + - 为 `AIMemoryDocument` 预留 `content_hash / summary_hash / dedup_key`。 + - 第一版 document 去重只做稳定规则去重,不做近似语义合并。 + - 按设计补 namespace / memory type 的 TTL 解析规则。 +- Summary 契约收紧 + - `GetConversationSummary` 改为显式匹配: + - `conversation_id` + - `user_id` + - `org_id` + - `scope_key` + - 任一不匹配都按“无可用摘要”处理,不允许先按主键命中再信任记录。 + +# 验证 + +- `go test ./internal/service/system -run AIMemory` + - 覆盖 policy 权限、覆盖优先级、来源过滤、TTL、原因码。 +- `go test ./internal/repository/system -run AIMemory` + - 覆盖 document 字段迁移、summary 读取契约收紧、现有 upsert 行为不回归。 +- 如有必要,补充 `flag.SQL()` / AutoMigrate 相关索引与字段存在性验证。 + +# 风险 + +- `ConversationSummary` 读取接口收紧后,所有调用点都必须同步改签名,否则会出现编译或行为回归。 +- document 去重字段落库后,若后续写入链路未统一生成规则,容易出现“字段存在但未稳定使用”的半成品状态。 +- policy 规则如果把“权限失败”和“内容读取失败”混在一起,会破坏 fail-closed 语义,需要测试明确区分。 + +# 执行顺序 + +1. 收口 domain/service 层治理类型与原因码。 +2. 实现 `aiMemoryPolicy` 规则函数与单测。 +3. 扩展 `AIMemoryDocument` 字段与 repository upsert/query。 +4. 收紧 `ConversationSummary` repository 接口与测试。 +5. 视需要补 service 侧最小接线与编译修正。 +6. 运行定向测试并回报结果。 + +# 待确认 + +- 这次实现默认只完成“治理规则 + repository 契约 + 测试”,不把 memory 正式接入 `aiSvc` 主链路 writeback/recall。 +- document 去重字段本次先保证“字段、生成规则、测试”到位;若现有写入入口尚未落地,不额外扩项到 Qdrant 或 embedding 流程。 diff --git a/plan/ai/approved-memory-module-phase1-freeze.md b/plan/ai/approved-memory-module-phase1-freeze.md new file mode 100644 index 0000000..8c69102 --- /dev/null +++ b/plan/ai/approved-memory-module-phase1-freeze.md @@ -0,0 +1,58 @@ +# 目标 + +实现记忆模块 Phase 1 冻结版基础骨架,正式落库 `facts / documents / conversation_summary` 三类模型,冻结 scope/visibility、配置、repository/service 契约,并把 memory repository 接入现有 supplier 体系。 + +# 范围 + +- 新增 memory domain 类型与 scope helper。 +- 新增 3 个 memory entity、repository interface/impl、service 骨架。 +- 扩展 AI/Qdrant 配置和加载逻辑。 +- 扩展 `flag.SQL()` 自动迁移与索引创建。 +- 扩展 repository supplier 接口与实现。 +- 增加最小配置、repository、迁移回归测试。 + +# 改动 + +- `internal/domain/ai` + - 增加 memory 类型、visibility、scope helper、query struct。 +- `internal/model/entity` + - 新增 `AIMemoryFact`、`AIMemoryDocument`、`AIConversationSummary`。 +- `internal/model/config` + - `AI` 增加 `Memory` 子配置。 + - `Qdrant` 增加 `KnowledgeCollectionName`、`MemoryCollectionName`,保留兼容字段。 +- `internal/repository/interfaces` + - 新增 `AIMemoryRepository`。 +- `internal/repository/system` + - 新增 memory repository。 + - supplier / setup / impl 接入 `GetAIMemoryRepository()`。 +- `internal/service/system` + - 新增 `AIMemoryService`、`aiMemoryPolicy` 骨架。 +- `flag/flagSql.go` + - 纳入新表迁移与 memory 索引创建。 +- `tests` + - 补配置、repository、迁移回归测试。 + +# 验证 + +- `go test` 覆盖新增 repository/config/service 基础测试。 +- 验证 `flag.SQL()` 后 3 张表和关键索引存在。 +- 验证现有 AI 会话相关构造和 repository group 初始化不回归。 + +# 风险 + +- Qdrant 配置兼容处理不当会影响现有 collection 初始化。 +- supplier 接口变更会影响现有依赖构造,需保证全链路编译通过。 +- `AIMemoryFact` 不使用软删除,覆盖更新逻辑必须稳定。 + +# 执行顺序 + +1. 补 plan 文件并转已审状态。 +2. 实现 memory domain/entity/config。 +3. 实现 repository interface/impl 与 supplier 接入。 +4. 实现 service 骨架。 +5. 扩展 `flag.SQL()` 迁移与索引。 +6. 增加测试并执行验证。 + +# 待确认 + +- 无;按已确认方案直接实施。 diff --git a/plan/ai/approved-memory-rag-indexing.md b/plan/ai/approved-memory-rag-indexing.md new file mode 100644 index 0000000..bcd2d1a --- /dev/null +++ b/plan/ai/approved-memory-rag-indexing.md @@ -0,0 +1,34 @@ +# Memory RAG 切分入库实施计划 + +## Summary + +实现第 5 步 `RAG 切分入库`:把第 3 步写入 MySQL 的 `AIMemoryDocument` 切成 chunks,使用阿里云百炼 `qwen3-vl-embedding` 生成 1024 维向量,写入 Qdrant `MemoryCollectionName`,并在 MySQL 保存 chunk 与 point 映射。 + +本阶段只建立“可召回索引”,不把 RAG 召回接入对话上下文;召回接入放到第 6 步。 + +## Key Changes + +- 新增 `AIMemoryDocumentChunk`,表名 `ai_memory_document_chunks`,保存 chunk 文本、hash、embedding 模型、维度、Qdrant point、索引时间和权限过滤字段。 +- 扩展 memory repository:支持扫描待索引 documents、按 document 覆盖 chunks、读取 document chunks。 +- 在 `domain/ai` 定义 `MemoryChunker`、`MemoryEmbedder`、`MemoryVectorStore`、`MemoryDocumentIndexer`。 +- 在 `infrastructure/ai/memory` 实现 paragraph-aware chunker、DashScope multimodal embedding client、Qdrant memory vector store。 +- 扩展 `AI.Memory` 配置:`EmbedEndpoint`、`EmbedDimension`、`ChunkMaxChars`、`ChunkOverlapChars`、`IndexBatchSize`、`IndexTimeoutSeconds`。 +- Qdrant 启动时确保 `MemoryCollectionName` 存在,维度匹配 `AI.Memory.EmbedDimension`。 +- 在 `AIMemoryService` 增加 `IndexDocuments` 与 `IndexPendingDocuments`。 +- writeback 成功写入 documents 后异步触发索引,失败只记录日志;补偿入口由 `IndexPendingDocuments` 扫描未索引 documents。 + +## Test Plan + +- chunker 单测:短文本、长文本、overlap、空文本。 +- DashScope embedding client 单测:请求体、正常响应、维度不匹配、缺少 APIKey/model。 +- Qdrant vector store 单测:collection、point id、payload、旧 points 删除。 +- repository 单测:chunks 覆盖、待索引扫描、内容更新重建、过期/删除过滤。 +- service 集成单测:索引成功、embedding 失败、Qdrant 失败、writeback 异步失败不影响主流程。 +- 回归:`go test ./internal/service/system ./internal/repository/system ./internal/domain/ai ./internal/infrastructure/ai/memory` 和 `go test ./...`。 + +## Assumptions + +- 本阶段只做索引建设,不做 RAG 召回注入。 +- 默认模型锁定为 `qwen3-vl-embedding + dimension=1024`。 +- 只处理 writeback 产生的 self documents;org/platform 召回权限后续再接。 +- 不引入 outbox 事件;可靠性先靠 `IndexPendingDocuments` 补偿扫描。 diff --git a/plan/ai/approved-memory-writeback-hook.md b/plan/ai/approved-memory-writeback-hook.md new file mode 100644 index 0000000..5df0e0d --- /dev/null +++ b/plan/ai/approved-memory-writeback-hook.md @@ -0,0 +1,42 @@ +# 目标 + +实现 AI 记忆第 3 步 `memory writeback hook`:在一轮流式对话成功完成后,按配置触发记忆写回,将可保留的 summary、facts、documents 经过治理规则后写入现有记忆表。 + +# 范围 + +- 只实现写侧生产链路,不接第 4 步上下文恢复。 +- v1 使用规则抽取器和稳定接口,不使用 LLM 做结构化抽取。 +- 自动写入范围保守限定为个人 self memory;org/platform 共享记忆后续在授权能力收口后再开启。 +- 不新增数据库表,复用 `ai_memory_facts`、`ai_memory_documents`、`ai_conversation_summaries`。 + +# 改动 + +- 扩展 `domain/ai` 写回协议,定义抽取输入、抽取结果、summary draft 与 extractor 接口。 +- 新增 `internal/infrastructure/ai/memory` 规则抽取器,生成保守的 summary、明确表达的个人 fact,以及知识型长回答 document。 +- 改造 `AIMemoryService.OnTurnCompleted`,编排读取消息快照、调用 extractor、走 `aiMemoryPolicy` 治理、写入 Repository。 +- 在 AI 流式成功收尾后触发 writeback;配置关闭时 no-op,异步模式下后台执行并记录失败日志。 +- 补充必要 repository 查询能力,用于 fact 覆盖判断和消息快照读取。 + +# 验证 + +- 新增/更新 writeback、规则抽取器、policy 集成相关单测。 +- 运行 `go test ./internal/service/system ./internal/repository/system ./internal/domain/ai ./internal/infrastructure/ai/memory`。 + +# 风险 + +- 规则抽取器较保守,v1 宁可少写,不自动扩大共享记忆范围。 +- writeback 是辅助链路,失败不应影响用户本轮回答。 +- document 只先落 MySQL,不做 embedding/chunk/Qdrant。 + +# 执行顺序 + +1. 将本计划从 pending 流转为 approved。 +2. 扩展 domain 协议与 extractor 接口。 +3. 实现规则抽取器。 +4. 补 repository 查询能力。 +5. 实现 `AIMemoryService.OnTurnCompleted` 和 AIService hook。 +6. 补测试并运行验证。 + +# 待确认 + +用户已明确要求 “PLEASE IMPLEMENT THIS PLAN”,按该确认执行。 From ea1c1109b09e597385475d4fc7891c5e0a32fe37 Mon Sep 17 00:00:00 2001 From: wang Date: Sun, 26 Apr 2026 13:49:49 +0800 Subject: [PATCH 15/17] =?UTF-8?q?=E5=AE=8C=E5=96=84RAG=E5=8F=AC=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...7\206-five-RAG\345\210\207\345\210\206.md" | 178 ++++++++++++++++++ internal/core/config.go | 4 + internal/core/config_memory_test.go | 8 + internal/domain/ai/memory_rag.go | 23 +++ .../infrastructure/ai/memory/qdrant_store.go | 92 +++++++++ .../ai/memory/qdrant_store_test.go | 75 ++++++++ internal/model/config/ai.go | 4 + internal/model/config/config.go | 2 + .../interfaces/aiMemoryRepository.go | 2 + internal/repository/system/aiMemoryRepo.go | 42 +++++ .../repository/system/aiMemoryRepo_test.go | 108 +++++++++++ internal/service/system/aiMemoryIndex_test.go | 14 ++ internal/service/system/aiMemoryRecall.go | 176 ++++++++++++++++- .../service/system/aiMemoryRecall_test.go | 174 +++++++++++++++++ internal/service/system/aiMemorySvc.go | 10 +- plan/ai/approved-memory-rag-recall.md | 27 +++ 16 files changed, 936 insertions(+), 3 deletions(-) create mode 100644 "docs/AI/\350\256\260\345\277\206-five-RAG\345\210\207\345\210\206.md" create mode 100644 plan/ai/approved-memory-rag-recall.md diff --git "a/docs/AI/\350\256\260\345\277\206-five-RAG\345\210\207\345\210\206.md" "b/docs/AI/\350\256\260\345\277\206-five-RAG\345\210\207\345\210\206.md" new file mode 100644 index 0000000..64ff3e3 --- /dev/null +++ "b/docs/AI/\350\256\260\345\277\206-five-RAG\345\210\207\345\210\206.md" @@ -0,0 +1,178 @@ +把这一步想成“给 AI 建图书馆”会比较好理解。 + +你做的不是把整段对话直接塞进向量库,而是把一次对话里**真正值得长期保留的知识**,先筛出来、洗干净、贴好权限和标签,再切成适合检索的小片段,最后做 embedding 并写入向量库。这样后面做召回时,AI 查到的不是一堆原始聊天记录,而是一套**可控、可追踪、可过滤**的知识索引。 + +按当前实现看,这条链路的主入口在 [aiMemoryWriteback.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryWriteback.go:22),索引构建在 [aiMemoryIndex.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryIndex.go:16),切分器在 [chunker.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/chunker.go:49),embedding 在 [embedder.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/embedder.go:59),Qdrant 写入在 [qdrant_store.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/qdrant_store.go:64)。 + +**先说这一步在整体链路里的位置** +前面第 3 步“写回 documents”,本质上只是把一份长期知识文档沉淀到业务库里。 +第 5 步“RAG 切分入库”,做的是把这些 document 进一步变成“以后可以被召回”的索引数据。 + +也就是说: + +1. 第 3 步解决“有没有知识资产”。 +2. 第 5 步解决“这份知识以后能不能被高质量查到”。 + +没有第 5 步,`documents` 只是数据库里的一段长文本;有了第 5 步,它才真正变成 RAG 可用的数据。 + +**你具体是怎么做的** + +1. **先从对话里抽出值得入库的 document** +在 [extractor.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/extractor.go:51) 里,`RuleExtractor` 会在一轮对话完成后抽三类东西:summary、facts、documents。 +其中 document 不是每轮都存,只有满足“像知识型回答”时才会生成,规则在 [extractDocument](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/extractor.go:130)。 + +它大概会看两件事: +- 用户问题是不是“怎么做、方案、设计、排障、总结”这类知识型问题 +- 助手回答是不是足够长、足够像一段可复用的知识 + +这一步的目的很明确:**不要把所有聊天都进 RAG,只把有长期价值的内容沉淀进去。** + +2. **在入库前做治理,而不是直接存** +抽出来的 document candidate 会先经过治理规则,在 [aiMemoryWriteback.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryWriteback.go:206) 调 `applyDocumentCandidates`,治理逻辑在 [aiMemoryPolicy.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryPolicy.go) 里。 + +这里会做几件事: +- 拒绝不该存的来源 +- 拒绝和真相源冲突的内容 +- 拒绝低价值、空内容 +- 解析它属于哪个 scope +- 解析它的 visibility +- 给它算 `content_hash`、`summary_hash`、`dedup_key` + +这一步的本质是:**先把“该不该存、归谁、谁能看、怎么去重”说清楚,再谈 RAG。** + +3. **先把 document 作为“文档根”写进业务库** +真正落库时,会把 candidate 组装成 `AIMemoryDocument`,在 [buildMemoryDocumentEntity](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryWriteback.go:275) 里完成。 +这里不是只存正文,还会存: + +- `scope_key / scope_type` +- `visibility` +- `memory_type` +- `topic / title / summary` +- `content_text` +- `source_kind / source_id` +- `content_hash / summary_hash / dedup_key` +- `embedding_model` + +文档 ID 也不是随机生成,而是基于 `scopeKey + dedupKey` 计算稳定 ID,在 [buildMemoryDocumentID](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryWriteback.go:356)。 + +这意味着一份“同 scope 下的同类知识”会稳定映射到同一个 document,而不是越存越多、越来越乱。 + +4. **仓储层按去重语义做 upsert,不是无脑 append** +文档真正写库走的是 [BatchUpsertDocuments](D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:101)。 +这里的设计重点不是“插入”,而是“归并”: + +- 同一 scope 下,按 `dedup_key` 去重 +- 已有文档更新时,保留已有 document 根 ID +- 同时更新摘要、正文、哈希、来源、模型信息 +- 支持软删除恢复 + +人话说就是:**你不是在堆聊天日志,而是在维护一份持续演进的知识文档。** + +5. **切分时不是硬切,而是“段落优先 + 滑窗兜底”** +切分器在 [chunker.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/chunker.go:49)。 +核心思路很实用: + +- 优先按段落切 +- 尽量让一个 chunk 语义完整 +- 如果段落太长,再退化成固定字符窗口 +- 相邻 chunk 保留 overlap,减少语义断裂 + +对应实现是: +- 初始化参数在 [NewParagraphChunker](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/chunker.go:32) +- 主切分逻辑在 [splitText](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/chunker.go:90) +- 超长文本兜底在 [splitByRuneWindow](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/chunker.go:140) + +这比“每 1000 字切一刀”好很多,因为你在尽量保留知识块的自然边界。 +而 overlap 的作用,是避免一句关键内容刚好被切在边界上,导致召回时上下文断掉。 + +6. **每个 chunk 都有稳定身份,不会乱** +切完之后,每个 chunk 都会生成: +- `chunk_id` +- `chunk_index` +- `content_hash` +- `qdrant_point_id` +- `token_estimate` + +这些在 [chunker.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/chunker.go:202) 和 [chunker.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/chunker.go:207) 里生成。 + +这样做有两个价值: +- 方便排查“哪一段知识进了向量库” +- 后续重建索引时可以稳定替换,而不是每次生成一堆新 point + +7. **embedding 是批量做的,不是一条一条请求** +索引构建在 [indexOneDocument](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryIndex.go:64)。 +流程是: + +- 先把一个 document 切成多个 chunks +- 把所有 chunk 文本组装成 `texts` +- 一次性调用 embedding 接口 +- 校验返回数量和维度是否匹配 +- 再把 chunk 和 vector 绑定起来 + +embedding 客户端在 [embedder.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/embedder.go:59),当前接的是 DashScope。 +批量做的好处很简单:**减少调用次数,控制成本,也让一份 document 的索引构建更一致。** + +8. **写向量库前先删旧的,再写新的** +向量库这层在 [qdrant_store.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/qdrant_store.go:39) 和 [qdrant_store.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/qdrant_store.go:64)。 + +策略是: +- 先按 `document_id` 删除旧 chunks +- 再 upsert 新 chunks + +这很关键,因为 document 内容一旦更新,chunk 边界可能会变,不能靠增量 patch 硬拼。 +你这里实际选择的是**文档级重建**,不是 chunk 级局部修补,这个策略简单、稳定、容易保证一致性。 + +而且写 Qdrant 时还会把这些过滤信息一并放进 payload,在 [buildMemoryVectorPayload](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/qdrant_store.go:94): +- document_id +- scope_key +- visibility +- memory_type +- topic +- source_kind +- user_id / org_id + +这说明你一开始就考虑了后续召回时的权限过滤和主题过滤,而不是只存裸向量。 + +9. **写完向量库以后,还会把 chunk 元数据回写数据库** +Qdrant 不是唯一的数据面。 +你还会把 chunk 元数据回写到 `ai_memory_document_chunks`,对应 [memoryChunkToEntity](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryIndex.go:148) 和仓储层 [ReplaceDocumentChunks](D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:248)。 + +这一步保存的是: +- chunk 内容 +- 顺序 +- hash +- embedding model +- embedding dimension +- qdrant point id +- indexed_at + +它的意义是: +- 以后能知道“哪些文档已经完成索引” +- 方便做索引补偿 +- 方便排查 embedding 模型切换、维度不一致、索引陈旧等问题 + +10. **你还设计了补偿和重建,不是只支持首次入库** +这点很重要。你不是“写一次就完”,而是支持后续补偿扫描。 +在 [IndexPendingDocuments](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryIndex.go:28) 和 [ListDocumentsNeedingIndex](D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:206) 里,系统会把这些文档重新拉出来建索引: + +- 还没有任何 chunk 的 document +- 已有 chunk 但模型变了 +- 已有 chunk 但向量维度变了 +- document 更新时间晚于最近一次 indexed_at + +这说明你设计的是**可持续维护的索引系统**,不是一次性的脚本。 + +**一句人话总结** +你这个模块做的事情可以概括成一句: + +**把 AI 对话里有长期价值的内容,治理成结构化 document,再切成稳定 chunk,批量做 embedding,写入 Qdrant,并把 chunk 元数据回写数据库,从而建立一套可重建、可过滤、可追踪的 RAG 索引。** + +**如果面试时要 1 分钟讲清楚** +你可以直接这么说: + +> 我做的 RAG 切分入库,不是把原始对话直接扔进向量库,而是先在写回阶段从知识型问答里抽出长期 document,再结合治理规则做权限归属、可见性控制和去重,先把 document 根写入 MySQL。之后索引阶段会把 document 按“段落优先、超长滑窗兜底”的策略切成 chunks,给每个 chunk 生成稳定 ID 和 point ID,批量调用 embedding 接口生成向量,再写入 Qdrant。为了保证可维护性,我同时把 chunk 元数据回写数据库,并支持按模型变更、维度变更、文档更新时间做补偿重建。这样后续召回拿到的不是原始聊天记录,而是一套可控的知识索引。 + +补一句边界也很有价值: +按当前代码,**建库侧已经做完,召回侧目前还是 summary + facts 优先**,向量召回主链路还没完全接进 runtime,在 [aiMemoryRecall.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:24) 这里能看出来。 + +如果你要,我可以下一条继续帮你把这段压成一版更适合简历/面试口述的“项目表达版”。 \ No newline at end of file diff --git a/internal/core/config.go b/internal/core/config.go index fb53270..c60dcd2 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -65,6 +65,8 @@ func InitConfig(path string) { viper.SetDefault("ai.memory.enabled", false) viper.SetDefault("ai.memory.recall_top_k", 6) viper.SetDefault("ai.memory.recall_max_chars", 2000) + viper.SetDefault("ai.memory.recall_min_score", 0.2) + viper.SetDefault("ai.memory.rag_max_chars", 2000) viper.SetDefault("ai.memory.recent_raw_turns", 8) viper.SetDefault("ai.memory.compress_threshold_tokens", 6000) viper.SetDefault("ai.memory.summary_refresh_every_turns", 10) @@ -286,6 +288,8 @@ func InitConfig(path string) { _ = viper.BindEnv("ai.memory.enabled", "AI_MEMORY_ENABLED") _ = viper.BindEnv("ai.memory.recall_top_k", "AI_MEMORY_RECALL_TOP_K") _ = viper.BindEnv("ai.memory.recall_max_chars", "AI_MEMORY_RECALL_MAX_CHARS") + _ = viper.BindEnv("ai.memory.recall_min_score", "AI_MEMORY_RECALL_MIN_SCORE") + _ = viper.BindEnv("ai.memory.rag_max_chars", "AI_MEMORY_RAG_MAX_CHARS") _ = viper.BindEnv("ai.memory.recent_raw_turns", "AI_MEMORY_RECENT_RAW_TURNS") _ = viper.BindEnv("ai.memory.compress_threshold_tokens", "AI_MEMORY_COMPRESS_THRESHOLD_TOKENS") _ = viper.BindEnv("ai.memory.summary_refresh_every_turns", "AI_MEMORY_SUMMARY_REFRESH_EVERY_TURNS") diff --git a/internal/core/config_memory_test.go b/internal/core/config_memory_test.go index 77471ec..bd39b38 100644 --- a/internal/core/config_memory_test.go +++ b/internal/core/config_memory_test.go @@ -23,6 +23,8 @@ func TestInitConfigBindsAIMemoryAndQdrantCompatibility(t *testing.T) { t.Setenv("AI_MEMORY_ENABLED", "true") t.Setenv("AI_MEMORY_RECALL_TOP_K", "9") t.Setenv("AI_MEMORY_RECALL_MAX_CHARS", "4096") + t.Setenv("AI_MEMORY_RECALL_MIN_SCORE", "0.42") + t.Setenv("AI_MEMORY_RAG_MAX_CHARS", "1024") t.Setenv("AI_MEMORY_EMBED_DIMENSION", "1024") t.Setenv("AI_MEMORY_INDEX_BATCH_SIZE", "11") t.Setenv("QDRANT_COLLECTION_NAME", "legacy-knowledge") @@ -42,6 +44,12 @@ func TestInitConfigBindsAIMemoryAndQdrantCompatibility(t *testing.T) { if global.Config.AI.Memory.RecallMaxChars != 4096 { t.Fatalf("AI.Memory.RecallMaxChars = %d, want 4096", global.Config.AI.Memory.RecallMaxChars) } + if global.Config.AI.Memory.RecallMinScore != 0.42 { + t.Fatalf("AI.Memory.RecallMinScore = %f, want 0.42", global.Config.AI.Memory.RecallMinScore) + } + if global.Config.AI.Memory.RAGMaxChars != 1024 { + t.Fatalf("AI.Memory.RAGMaxChars = %d, want 1024", global.Config.AI.Memory.RAGMaxChars) + } if global.Config.AI.Memory.SummaryRefreshEveryTurns != 10 { t.Fatalf( "AI.Memory.SummaryRefreshEveryTurns = %d, want default 10", diff --git a/internal/domain/ai/memory_rag.go b/internal/domain/ai/memory_rag.go index 4048b25..b298477 100644 --- a/internal/domain/ai/memory_rag.go +++ b/internal/domain/ai/memory_rag.go @@ -57,6 +57,24 @@ type MemoryVectorChunk struct { Vector []float32 } +// MemoryVectorSearchInput 描述一次 memory vector 检索请求。 +type MemoryVectorSearchInput struct { + Vector []float32 + ScopeKey string + Visibility string + UserID uint + Limit int + MinScore float64 +} + +// MemoryVectorSearchResult 描述 Qdrant 返回的候选 chunk。 +type MemoryVectorSearchResult struct { + QdrantPointID string + ChunkID string + DocumentID string + Score float64 +} + // MemoryChunker 负责把长期记忆文档切分成可 embedding 的 chunks。 type MemoryChunker interface { Chunk(ctx context.Context, doc MemoryDocumentForIndex) ([]MemoryDocumentChunk, error) @@ -73,6 +91,11 @@ type MemoryVectorStore interface { UpsertChunks(ctx context.Context, chunks []MemoryVectorChunk) error } +// MemoryVectorSearcher 负责按 query vector 从向量库召回候选 chunks。 +type MemoryVectorSearcher interface { + SearchChunks(ctx context.Context, input MemoryVectorSearchInput) ([]MemoryVectorSearchResult, error) +} + // MemoryDocumentIndexer 定义 memory documents 的索引建设能力。 type MemoryDocumentIndexer interface { IndexDocuments(ctx context.Context, documentIDs []string) error diff --git a/internal/infrastructure/ai/memory/qdrant_store.go b/internal/infrastructure/ai/memory/qdrant_store.go index 1293f8e..3ea5632 100644 --- a/internal/infrastructure/ai/memory/qdrant_store.go +++ b/internal/infrastructure/ai/memory/qdrant_store.go @@ -13,6 +13,7 @@ import ( // QdrantPointsClient 是 Qdrant client 在 memory index 阶段需要的最小能力。 type QdrantPointsClient interface { Delete(ctx context.Context, request *qdrant.DeletePoints) (*qdrant.UpdateResult, error) + Query(ctx context.Context, request *qdrant.QueryPoints) ([]*qdrant.ScoredPoint, error) Upsert(ctx context.Context, request *qdrant.UpsertPoints) (*qdrant.UpdateResult, error) } @@ -91,6 +92,56 @@ func (s *QdrantVectorStore) UpsertChunks(ctx context.Context, chunks []aidomain. return err } +// SearchChunks 按 query vector 检索 memory chunk 候选。 +func (s *QdrantVectorStore) SearchChunks( + ctx context.Context, + input aidomain.MemoryVectorSearchInput, +) ([]aidomain.MemoryVectorSearchResult, error) { + if s == nil || s.client == nil || len(input.Vector) == 0 { + return nil, nil + } + if s.collectionName == "" { + return nil, fmt.Errorf("qdrant memory collection name is required") + } + limit := uint64(input.Limit) + if limit == 0 { + return nil, nil + } + scoreThreshold := float32(input.MinScore) + request := &qdrant.QueryPoints{ + CollectionName: s.collectionName, + Query: qdrant.NewQueryDense(input.Vector), + Filter: buildMemoryVectorSearchFilter(input), + Limit: &limit, + WithPayload: qdrant.NewWithPayloadInclude("document_id", "chunk_id", "scope_key", "visibility", "user_id"), + } + if input.MinScore > 0 { + request.ScoreThreshold = &scoreThreshold + } + points, err := s.client.Query(ctx, request) + if err != nil { + return nil, err + } + results := make([]aidomain.MemoryVectorSearchResult, 0, len(points)) + for _, point := range points { + if point == nil { + continue + } + pointID := memoryQdrantPointID(point.GetId()) + if pointID == "" { + continue + } + payload := point.GetPayload() + results = append(results, aidomain.MemoryVectorSearchResult{ + QdrantPointID: pointID, + ChunkID: memoryQdrantPayloadString(payload, "chunk_id"), + DocumentID: memoryQdrantPayloadString(payload, "document_id"), + Score: float64(point.GetScore()), + }) + } + return results, nil +} + func buildMemoryVectorPayload(chunk aidomain.MemoryDocumentChunk) map[string]any { payload := map[string]any{ "document_id": chunk.DocumentID, @@ -113,3 +164,44 @@ func buildMemoryVectorPayload(chunk aidomain.MemoryDocumentChunk) map[string]any } return payload } + +func buildMemoryVectorSearchFilter(input aidomain.MemoryVectorSearchInput) *qdrant.Filter { + must := make([]*qdrant.Condition, 0, 3) + if scopeKey := strings.TrimSpace(input.ScopeKey); scopeKey != "" { + must = append(must, qdrant.NewMatchKeyword("scope_key", scopeKey)) + } + if visibility := strings.TrimSpace(input.Visibility); visibility != "" { + must = append(must, qdrant.NewMatchKeyword("visibility", visibility)) + } + if input.UserID > 0 { + must = append(must, qdrant.NewMatchInt("user_id", int64(input.UserID))) + } + if len(must) == 0 { + return nil + } + return &qdrant.Filter{Must: must} +} + +func memoryQdrantPointID(pointID *qdrant.PointId) string { + if pointID == nil { + return "" + } + if uuid := strings.TrimSpace(pointID.GetUuid()); uuid != "" { + return uuid + } + if num := pointID.GetNum(); num > 0 { + return fmt.Sprintf("%d", num) + } + return "" +} + +func memoryQdrantPayloadString(payload map[string]*qdrant.Value, key string) string { + if len(payload) == 0 { + return "" + } + value := payload[key] + if value == nil { + return "" + } + return strings.TrimSpace(value.GetStringValue()) +} diff --git a/internal/infrastructure/ai/memory/qdrant_store_test.go b/internal/infrastructure/ai/memory/qdrant_store_test.go index 73b7b03..99528f8 100644 --- a/internal/infrastructure/ai/memory/qdrant_store_test.go +++ b/internal/infrastructure/ai/memory/qdrant_store_test.go @@ -64,8 +64,62 @@ func TestQdrantVectorStoreDeletesAndUpsertsMemoryChunks(t *testing.T) { } } +func TestQdrantVectorStoreSearchesMemoryChunksWithSelfFilter(t *testing.T) { + client := &fakeQdrantPointsClient{ + queryResponse: []*qdrant.ScoredPoint{ + { + Id: qdrant.NewID("11111111-1111-1111-1111-111111111111"), + Score: 0.87, + Payload: qdrant.NewValueMap(map[string]any{ + "document_id": "doc-1", + "chunk_id": "chunk-1", + }), + }, + }, + } + store := NewQdrantVectorStore(client, VectorStoreOptions{CollectionName: "ai_memory_chunks"}) + + results, err := store.SearchChunks(context.Background(), aidomain.MemoryVectorSearchInput{ + Vector: []float32{0.1, 0.2, 0.3}, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(7), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: 7, + Limit: 5, + MinScore: 0.2, + }) + if err != nil { + t.Fatalf("SearchChunks() error = %v", err) + } + if client.queryRequest == nil || client.queryRequest.CollectionName != "ai_memory_chunks" { + t.Fatalf("query request = %+v", client.queryRequest) + } + if client.queryRequest.GetLimit() != 5 { + t.Fatalf("query limit = %d, want 5", client.queryRequest.GetLimit()) + } + if client.queryRequest.GetScoreThreshold() != float32(0.2) { + t.Fatalf("score threshold = %f, want 0.2", client.queryRequest.GetScoreThreshold()) + } + must := client.queryRequest.GetFilter().GetMust() + if len(must) != 3 { + t.Fatalf("filter must len = %d, want 3: %+v", len(must), must) + } + assertQdrantMatchKeyword(t, must[0], "scope_key", "self:user:7") + assertQdrantMatchKeyword(t, must[1], "visibility", "self") + assertQdrantMatchInt(t, must[2], "user_id", 7) + if len(results) != 1 || + results[0].QdrantPointID != "11111111-1111-1111-1111-111111111111" || + results[0].DocumentID != "doc-1" || + results[0].ChunkID != "chunk-1" || + results[0].Score < 0.86 || + results[0].Score > 0.88 { + t.Fatalf("results = %+v", results) + } +} + type fakeQdrantPointsClient struct { deleteRequest *qdrant.DeletePoints + queryRequest *qdrant.QueryPoints + queryResponse []*qdrant.ScoredPoint upsertRequest *qdrant.UpsertPoints } @@ -74,7 +128,28 @@ func (f *fakeQdrantPointsClient) Delete(_ context.Context, request *qdrant.Delet return &qdrant.UpdateResult{}, nil } +func (f *fakeQdrantPointsClient) Query(_ context.Context, request *qdrant.QueryPoints) ([]*qdrant.ScoredPoint, error) { + f.queryRequest = request + return f.queryResponse, nil +} + func (f *fakeQdrantPointsClient) Upsert(_ context.Context, request *qdrant.UpsertPoints) (*qdrant.UpdateResult, error) { f.upsertRequest = request return &qdrant.UpdateResult{}, nil } + +func assertQdrantMatchKeyword(t *testing.T, condition *qdrant.Condition, key string, value string) { + t.Helper() + field := condition.GetField() + if field == nil || field.GetKey() != key || field.GetMatch().GetKeyword() != value { + t.Fatalf("condition = %+v, want %s=%s", condition, key, value) + } +} + +func assertQdrantMatchInt(t *testing.T, condition *qdrant.Condition, key string, value int64) { + t.Helper() + field := condition.GetField() + if field == nil || field.GetKey() != key || field.GetMatch().GetInteger() != value { + t.Fatalf("condition = %+v, want %s=%d", condition, key, value) + } +} diff --git a/internal/model/config/ai.go b/internal/model/config/ai.go index ce865af..e2c7cdb 100644 --- a/internal/model/config/ai.go +++ b/internal/model/config/ai.go @@ -25,6 +25,10 @@ type AIMemory struct { // RecallMaxChars 控制记忆召回结果在拼装 prompt 前允许占用的最大字符数。 // 这个值用于防止召回文本过长,导致压缩摘要和最近消息被挤出上下文。 RecallMaxChars int `json:"recall_max_chars" yaml:"recall_max_chars"` + // RecallMinScore 控制 RAG 向量召回候选的最低相似度分数。 + RecallMinScore float64 `json:"recall_min_score" yaml:"recall_min_score"` + // RAGMaxChars 控制长期文档片段注入 memory message 的最大字符数。 + RAGMaxChars int `json:"rag_max_chars" yaml:"rag_max_chars"` // RecentRawTurns 控制上下文恢复时保留多少轮最近原始消息。 // 后续会采用 “summary + recent turns” 的组合,这个值决定 recent turns 的窗口大小。 RecentRawTurns int `json:"recent_raw_turns" yaml:"recent_raw_turns"` diff --git a/internal/model/config/config.go b/internal/model/config/config.go index 0ef7cb7..68f128b 100644 --- a/internal/model/config/config.go +++ b/internal/model/config/config.go @@ -330,6 +330,8 @@ func NewConfig() *Config { Enabled: viper.GetBool("ai.memory.enabled"), RecallTopK: viper.GetInt("ai.memory.recall_top_k"), RecallMaxChars: viper.GetInt("ai.memory.recall_max_chars"), + RecallMinScore: viper.GetFloat64("ai.memory.recall_min_score"), + RAGMaxChars: viper.GetInt("ai.memory.rag_max_chars"), RecentRawTurns: viper.GetInt("ai.memory.recent_raw_turns"), CompressThresholdTokens: viper.GetInt("ai.memory.compress_threshold_tokens"), SummaryRefreshEveryTurns: viper.GetInt("ai.memory.summary_refresh_every_turns"), diff --git a/internal/repository/interfaces/aiMemoryRepository.go b/internal/repository/interfaces/aiMemoryRepository.go index 2b4145f..3802361 100644 --- a/internal/repository/interfaces/aiMemoryRepository.go +++ b/internal/repository/interfaces/aiMemoryRepository.go @@ -29,6 +29,8 @@ type AIMemoryRepository interface { ReplaceDocumentChunks(ctx context.Context, documentID string, chunks []*entity.AIMemoryDocumentChunk) error // ListDocumentChunks 按 document 读取 chunks,按 chunk_index 升序返回。 ListDocumentChunks(ctx context.Context, documentID string) ([]*entity.AIMemoryDocumentChunk, error) + // ListDocumentChunksByPointIDs 按 Qdrant point ids 回查仍有效的 chunks。 + ListDocumentChunksByPointIDs(ctx context.Context, pointIDs []string) ([]*entity.AIMemoryDocumentChunk, error) // GetConversationSummary 按 conversation_id + user_id + org_id + scope_key 读取当前有效摘要。 GetConversationSummary(ctx context.Context, query aidomain.MemoryConversationSummaryQuery) (*entity.AIConversationSummary, error) diff --git a/internal/repository/system/aiMemoryRepo.go b/internal/repository/system/aiMemoryRepo.go index 901f8cd..52d2e42 100644 --- a/internal/repository/system/aiMemoryRepo.go +++ b/internal/repository/system/aiMemoryRepo.go @@ -300,6 +300,28 @@ func (r *AIMemoryGormRepository) ListDocumentChunks( return rows, nil } +// ListDocumentChunksByPointIDs 按 Qdrant point ids 回查仍有效的 chunks。 +func (r *AIMemoryGormRepository) ListDocumentChunksByPointIDs( + ctx context.Context, + pointIDs []string, +) ([]*entity.AIMemoryDocumentChunk, error) { + normalizedIDs := normalizeMemoryPointIDs(pointIDs) + if len(normalizedIDs) == 0 { + return []*entity.AIMemoryDocumentChunk{}, nil + } + var rows []*entity.AIMemoryDocumentChunk + now := time.Now() + if err := r.db.WithContext(ctx). + Model(&entity.AIMemoryDocumentChunk{}). + Joins("JOIN ai_memory_documents d ON d.id = ai_memory_document_chunks.document_id AND d.deleted_at IS NULL"). + Where("ai_memory_document_chunks.qdrant_point_id IN ?", normalizedIDs). + Where("(d.expires_at IS NULL OR d.expires_at > ?)", now). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + // GetConversationSummary 获取指定会话的压缩摘要。 func (r *AIMemoryGormRepository) GetConversationSummary( ctx context.Context, @@ -466,3 +488,23 @@ func normalizeMemoryDocumentIDs(ids []string) []string { } return items } + +func normalizeMemoryPointIDs(ids []string) []string { + if len(ids) == 0 { + return nil + } + seen := make(map[string]struct{}, len(ids)) + items := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + items = append(items, id) + } + return items +} diff --git a/internal/repository/system/aiMemoryRepo_test.go b/internal/repository/system/aiMemoryRepo_test.go index 45fa6be..a85247d 100644 --- a/internal/repository/system/aiMemoryRepo_test.go +++ b/internal/repository/system/aiMemoryRepo_test.go @@ -488,6 +488,90 @@ func TestAIMemoryRepositoryReplaceDocumentChunksOverwrites(t *testing.T) { } } +func TestAIMemoryRepositoryListDocumentChunksByPointIDsFiltersInvalidDocuments(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(108) + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + expiredAt := time.Now().Add(-time.Hour) + now := time.Now() + + docs := []*entity.AIMemoryDocument{ + { + ID: "doc-point-active", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "active", + Summary: "active", + ContentText: "active content", + }, + { + ID: "doc-point-expired", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "expired", + Summary: "expired", + ContentText: "expired content", + ExpiresAt: &expiredAt, + }, + { + ID: "doc-point-deleted", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "deleted", + Summary: "deleted", + ContentText: "deleted content", + }, + } + if err := repo.BatchUpsertDocuments(ctx, docs); err != nil { + t.Fatalf("BatchUpsertDocuments() error = %v", err) + } + chunksByDocument := map[string][]*entity.AIMemoryDocumentChunk{ + "doc-point-active": { + buildAIMemoryRepositoryTestChunk("chunk-point-active", "doc-point-active", scopeKey, "11111111-1111-1111-1111-111111111111", now), + }, + "doc-point-expired": { + buildAIMemoryRepositoryTestChunk("chunk-point-expired", "doc-point-expired", scopeKey, "22222222-2222-2222-2222-222222222222", now), + }, + "doc-point-deleted": { + buildAIMemoryRepositoryTestChunk("chunk-point-deleted", "doc-point-deleted", scopeKey, "33333333-3333-3333-3333-333333333333", now), + }, + } + for documentID, chunks := range chunksByDocument { + if err := repo.ReplaceDocumentChunks(ctx, documentID, chunks); err != nil { + t.Fatalf("ReplaceDocumentChunks(%s) error = %v", documentID, err) + } + } + if err := db.Delete(&entity.AIMemoryDocument{}, "id = ?", "doc-point-deleted").Error; err != nil { + t.Fatalf("soft delete document: %v", err) + } + + rows, err := repo.ListDocumentChunksByPointIDs(ctx, []string{ + "33333333-3333-3333-3333-333333333333", + "22222222-2222-2222-2222-222222222222", + "11111111-1111-1111-1111-111111111111", + }) + if err != nil { + t.Fatalf("ListDocumentChunksByPointIDs() error = %v", err) + } + if len(rows) != 1 || rows[0].ID != "chunk-point-active" { + t.Fatalf("chunks = %+v, want only active chunk", rows) + } +} + func TestAIMemoryRepositoryListDocumentsNeedingIndex(t *testing.T) { db := newAIMemoryRepositoryTestDB(t) repo := NewAIMemoryRepository(db) @@ -564,6 +648,30 @@ func TestAIMemoryRepositoryListDocumentsNeedingIndex(t *testing.T) { } } +func buildAIMemoryRepositoryTestChunk( + id string, + documentID string, + scopeKey string, + pointID string, + indexedAt time.Time, +) *entity.AIMemoryDocumentChunk { + return &entity.AIMemoryDocumentChunk{ + ID: id, + DocumentID: documentID, + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + MemoryType: string(aidomain.MemoryTypeSemantic), + ChunkIndex: 0, + ContentText: id + " content", + ContentHash: id + "-hash", + EmbeddingModel: "qwen3-vl-embedding", + EmbeddingDimension: 1024, + QdrantPointID: pointID, + IndexedAt: &indexedAt, + } +} + func newAIMemoryRepositoryTestDB(t *testing.T) *gorm.DB { t.Helper() diff --git a/internal/service/system/aiMemoryIndex_test.go b/internal/service/system/aiMemoryIndex_test.go index 8db13a7..deffba6 100644 --- a/internal/service/system/aiMemoryIndex_test.go +++ b/internal/service/system/aiMemoryIndex_test.go @@ -199,7 +199,10 @@ func (f *fakeMemoryEmbedder) Embed( type fakeMemoryVectorStore struct { deletedDocumentID string upserted []aidomain.MemoryVectorChunk + searchInput aidomain.MemoryVectorSearchInput + searchResults []aidomain.MemoryVectorSearchResult err error + searchErr error } func (f *fakeMemoryVectorStore) DeleteDocumentChunks(_ context.Context, documentID string) error { @@ -211,3 +214,14 @@ func (f *fakeMemoryVectorStore) UpsertChunks(_ context.Context, chunks []aidomai f.upserted = append([]aidomain.MemoryVectorChunk(nil), chunks...) return f.err } + +func (f *fakeMemoryVectorStore) SearchChunks( + _ context.Context, + input aidomain.MemoryVectorSearchInput, +) ([]aidomain.MemoryVectorSearchResult, error) { + f.searchInput = input + if f.searchErr != nil { + return nil, f.searchErr + } + return f.searchResults, nil +} diff --git a/internal/service/system/aiMemoryRecall.go b/internal/service/system/aiMemoryRecall.go index d9635a4..1920fa0 100644 --- a/internal/service/system/aiMemoryRecall.go +++ b/internal/service/system/aiMemoryRecall.go @@ -9,17 +9,26 @@ import ( "personal_assistant/global" aidomain "personal_assistant/internal/domain/ai" "personal_assistant/internal/model/entity" + + "go.uber.org/zap" ) const ( defaultAIMemoryRecallTopK = 10 defaultAIMemoryRecallMaxChars = 4000 + defaultAIMemoryRecallMinScore = 0.2 + defaultAIMemoryRAGMaxChars = 2000 defaultAIMemoryRecentRawTurns = 8 aiMemoryContextMessageIDPrefix = "memory_context" aiMemoryContextMessageHeader = "以下是系统恢复的记忆上下文,仅作为背景,不代表用户本轮新输入。" aiMemoryContextTruncationIndicator = "\n..." ) +type aiMemoryRAGRecallItem struct { + Score float64 + Chunk *entity.AIMemoryDocumentChunk +} + // Recall 从已沉淀的 summary 和 self facts 中恢复本轮可读的记忆上下文。 func (s *AIMemoryService) Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) { if !aiMemoryEnabled() || s == nil || s.repo == nil || input.UserID == 0 { @@ -34,8 +43,9 @@ func (s *AIMemoryService) Recall(ctx context.Context, input aiMemoryRecallInput) if err != nil { return aiMemoryRecallResult{}, err } + ragItems := s.recallLongTermDocumentsFailOpen(ctx, input) - content := buildAIMemoryContextContent(summary, facts, input.Query, aiMemoryRecallMaxChars()) + content := buildAIMemoryContextContent(summary, facts, ragItems, input.Query, aiMemoryRecallMaxChars()) if strings.TrimSpace(content) == "" { return aiMemoryRecallResult{}, nil } @@ -106,9 +116,117 @@ func (s *AIMemoryService) recallSelfFacts(ctx context.Context, userID uint) ([]* }) } +func (s *AIMemoryService) recallLongTermDocumentsFailOpen( + ctx context.Context, + input aiMemoryRecallInput, +) []aiMemoryRAGRecallItem { + items, err := s.recallLongTermDocuments(ctx, input) + if err == nil { + return items + } + if global.Log != nil { + global.Log.Warn( + "AI memory RAG recall failed", + zap.String("conversation_id", input.ConversationID), + zap.Uint("user_id", input.UserID), + zap.Error(err), + ) + } + return nil +} + +func (s *AIMemoryService) recallLongTermDocuments( + ctx context.Context, + input aiMemoryRecallInput, +) ([]aiMemoryRAGRecallItem, error) { + query := strings.TrimSpace(input.Query) + if !aiMemoryLongTermEnabled() || + query == "" || + s == nil || + s.repo == nil || + s.embedder == nil || + s.vectorSearcher == nil || + input.UserID == 0 { + return nil, nil + } + + embedding, err := s.embedder.Embed(ctx, aidomain.MemoryEmbeddingInput{Texts: []string{query}}) + if err != nil { + return nil, err + } + if len(embedding.Vectors) != 1 { + return nil, fmt.Errorf("memory query embedding count = %d, want 1", len(embedding.Vectors)) + } + vector := embedding.Vectors[0] + if len(vector) != aiMemoryEmbedDimension() { + return nil, fmt.Errorf("memory query embedding dimension = %d, want %d", len(vector), aiMemoryEmbedDimension()) + } + + minScore := aiMemoryRecallMinScore() + results, err := s.vectorSearcher.SearchChunks(ctx, aidomain.MemoryVectorSearchInput{ + Vector: vector, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(input.UserID), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: input.UserID, + Limit: aiMemoryRecallTopK(), + MinScore: minScore, + }) + if err != nil { + return nil, err + } + pointIDs := make([]string, 0, len(results)) + for _, result := range results { + if result.Score < minScore { + continue + } + if pointID := strings.TrimSpace(result.QdrantPointID); pointID != "" { + pointIDs = append(pointIDs, pointID) + } + } + if len(pointIDs) == 0 { + return nil, nil + } + + chunks, err := s.repo.ListDocumentChunksByPointIDs(ctx, pointIDs) + if err != nil { + return nil, err + } + chunkByPointID := make(map[string]*entity.AIMemoryDocumentChunk, len(chunks)) + scopeKey := aidomain.BuildSelfMemoryScopeKey(input.UserID) + for _, chunk := range chunks { + if chunk == nil { + continue + } + if chunk.EmbeddingModel != aiMemoryEmbedModel() || chunk.EmbeddingDimension != aiMemoryEmbedDimension() { + continue + } + if chunk.ScopeKey != scopeKey || chunk.Visibility != string(aidomain.MemoryVisibilitySelf) { + continue + } + if chunk.UserID == nil || *chunk.UserID != input.UserID { + continue + } + chunkByPointID[strings.TrimSpace(chunk.QdrantPointID)] = chunk + } + + items := make([]aiMemoryRAGRecallItem, 0, len(results)) + for _, result := range results { + if result.Score < minScore { + continue + } + chunk := chunkByPointID[strings.TrimSpace(result.QdrantPointID)] + if chunk == nil { + continue + } + items = append(items, aiMemoryRAGRecallItem{Score: result.Score, Chunk: chunk}) + } + return items, nil +} + func buildAIMemoryContextContent( summary *entity.AIConversationSummary, facts []*entity.AIMemoryFact, + ragItems []aiMemoryRAGRecallItem, query string, maxChars int, ) string { @@ -117,8 +235,9 @@ func buildAIMemoryContextContent( summaryText = normalizeAIMemoryContextLine(summary.SummaryText) } factLines := renderAIMemoryFactLines(facts) + ragSection := renderAIMemoryRAGSection(ragItems, aiMemoryRAGMaxChars()) currentQuery := normalizeAIMemoryContextLine(query) - if summaryText == "" && len(factLines) == 0 { + if summaryText == "" && len(factLines) == 0 && ragSection == "" { return "" } @@ -138,6 +257,10 @@ func buildAIMemoryContextContent( builder.WriteString("\n") } } + if ragSection != "" { + builder.WriteString("\n\n## Long-term Documents\n") + builder.WriteString(ragSection) + } if currentQuery != "" { builder.WriteString("\n## Current Query\n") builder.WriteString(currentQuery) @@ -168,6 +291,41 @@ func renderAIMemoryFactLines(facts []*entity.AIMemoryFact) []string { return lines } +func renderAIMemoryRAGSection(items []aiMemoryRAGRecallItem, maxChars int) string { + if len(items) == 0 { + return "" + } + var builder strings.Builder + for _, item := range items { + if item.Chunk == nil { + continue + } + content := normalizeAIMemoryContextLine(item.Chunk.ContentText) + if content == "" { + continue + } + topic := normalizeAIMemoryContextLine(item.Chunk.Topic) + memoryType := normalizeAIMemoryContextLine(item.Chunk.MemoryType) + builder.WriteString("- ") + if topic != "" || memoryType != "" { + builder.WriteString("[") + if memoryType != "" { + builder.WriteString(memoryType) + } + if topic != "" { + if memoryType != "" { + builder.WriteString("/") + } + builder.WriteString(topic) + } + builder.WriteString(fmt.Sprintf(" score=%.3f] ", item.Score)) + } + builder.WriteString(content) + builder.WriteString("\n") + } + return strings.TrimSpace(truncateAIMemoryContext(builder.String(), maxChars)) +} + func splitAIMemoryContextMessages(messages []aidomain.Message) ([]aidomain.Message, []aidomain.Message) { memoryMessages := make([]aidomain.Message, 0, 1) rawMessages := make([]aidomain.Message, 0, len(messages)) @@ -259,6 +417,20 @@ func aiMemoryRecallMaxChars() int { return global.Config.AI.Memory.RecallMaxChars } +func aiMemoryRecallMinScore() float64 { + if global.Config == nil || global.Config.AI.Memory.RecallMinScore <= 0 { + return defaultAIMemoryRecallMinScore + } + return global.Config.AI.Memory.RecallMinScore +} + +func aiMemoryRAGMaxChars() int { + if global.Config == nil || global.Config.AI.Memory.RAGMaxChars <= 0 { + return defaultAIMemoryRAGMaxChars + } + return global.Config.AI.Memory.RAGMaxChars +} + func aiMemoryRecentRawMessageLimit() int { turns := defaultAIMemoryRecentRawTurns if global.Config != nil && global.Config.AI.Memory.RecentRawTurns > 0 { diff --git a/internal/service/system/aiMemoryRecall_test.go b/internal/service/system/aiMemoryRecall_test.go index 831565d..68f3c78 100644 --- a/internal/service/system/aiMemoryRecall_test.go +++ b/internal/service/system/aiMemoryRecall_test.go @@ -3,6 +3,7 @@ package system import ( "context" "encoding/json" + stderrors "errors" "strings" "testing" "time" @@ -76,6 +77,127 @@ func TestAIMemoryRecallMessagesBuildsSummaryAndFactsContext(t *testing.T) { assertAIMemoryRecallContains(t, message.Content, "下一步怎么实现压缩?") } +func TestAIMemoryRecallMessagesInjectsRAGDocumentsInScoreOrder(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + service.embedder = &fakeMemoryEmbedder{vectors: [][]float32{{0.1, 0.2, 0.3}}} + vectorSearcher := &fakeMemoryVectorStore{ + searchResults: []aidomain.MemoryVectorSearchResult{ + {QdrantPointID: "22222222-2222-2222-2222-222222222222", Score: 0.91}, + {QdrantPointID: "11111111-1111-1111-1111-111111111111", Score: 0.82}, + {QdrantPointID: "33333333-3333-3333-3333-333333333333", Score: 0.42}, + }, + } + service.vectorSearcher = vectorSearcher + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableLongTermMemory: true, + RecallTopK: 3, + RecallMaxChars: 4000, + RecallMinScore: 0.5, + RAGMaxChars: 2000, + EmbedModel: "qwen3-vl-embedding", + EmbedDimension: 3, + }) + defer restore() + + ctx := context.Background() + userID := uint(16) + upsertAIMemoryRecallDocumentChunk( + t, + service, + userID, + "doc-rag-1", + "chunk-rag-1", + "11111111-1111-1111-1111-111111111111", + "较低分但仍然命中的长期知识片段。", + ) + upsertAIMemoryRecallDocumentChunk( + t, + service, + userID, + "doc-rag-2", + "chunk-rag-2", + "22222222-2222-2222-2222-222222222222", + "最高分的长期知识片段,应排在前面。", + ) + upsertAIMemoryRecallDocumentChunk( + t, + service, + userID, + "doc-rag-low-score", + "chunk-rag-low-score", + "33333333-3333-3333-3333-333333333333", + "低分片段不应注入。", + ) + + messages, err := service.RecallMessages(ctx, aiMemoryRecallInput{ + ConversationID: "conv-rag-recall", + UserID: userID, + Query: "RAG 召回如何做?", + }) + if err != nil { + t.Fatalf("RecallMessages() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("RecallMessages() len = %d, want 1", len(messages)) + } + content := messages[0].Content + assertAIMemoryRecallContains(t, content, "## Long-term Documents") + assertAIMemoryRecallContains(t, content, "最高分的长期知识片段") + assertAIMemoryRecallContains(t, content, "较低分但仍然命中的长期知识片段") + if strings.Contains(content, "低分片段不应注入") { + t.Fatalf("low score RAG content was injected:\n%s", content) + } + first := strings.Index(content, "最高分的长期知识片段") + second := strings.Index(content, "较低分但仍然命中的长期知识片段") + if first < 0 || second < 0 || first > second { + t.Fatalf("RAG order not preserved by Qdrant score:\n%s", content) + } + if vectorSearcher.searchInput.ScopeKey != aidomain.BuildSelfMemoryScopeKey(userID) || + vectorSearcher.searchInput.Visibility != string(aidomain.MemoryVisibilitySelf) || + vectorSearcher.searchInput.UserID != userID || + vectorSearcher.searchInput.Limit != 3 || + vectorSearcher.searchInput.MinScore != 0.5 { + t.Fatalf("search input = %+v", vectorSearcher.searchInput) + } +} + +func TestAIMemoryRecallMessagesRAGFailureKeepsSummary(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + service.embedder = &fakeMemoryEmbedder{err: stderrors.New("embedding failed")} + service.vectorSearcher = &fakeMemoryVectorStore{} + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableLongTermMemory: true, + RecallMaxChars: 4000, + EmbedModel: "qwen3-vl-embedding", + EmbedDimension: 3, + }) + defer restore() + + userID := uint(17) + conversationID := "conv-rag-fail-open" + upsertAIMemoryRecallSummary(t, service, conversationID, userID, "RAG 失败时仍应保留摘要。") + + messages, err := service.RecallMessages(context.Background(), aiMemoryRecallInput{ + ConversationID: conversationID, + UserID: userID, + Query: "触发 RAG 召回", + }) + if err != nil { + t.Fatalf("RecallMessages() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("RecallMessages() len = %d, want 1", len(messages)) + } + assertAIMemoryRecallContains(t, messages[0].Content, "RAG 失败时仍应保留摘要。") + if strings.Contains(messages[0].Content, "## Long-term Documents") { + t.Fatalf("RAG section should be empty on fail-open:\n%s", messages[0].Content) + } +} + func TestAIMemoryRecallMessagesEmptyWhenNoSummaryOrFacts(t *testing.T) { db := newAIMemoryWritebackTestDB(t) service := newAIMemoryWritebackTestService(db, nil) @@ -249,6 +371,58 @@ func upsertAIMemoryRecallFact(t *testing.T, service *AIMemoryService, userID uin } } +func upsertAIMemoryRecallDocumentChunk( + t *testing.T, + service *AIMemoryService, + userID uint, + documentID string, + chunkID string, + pointID string, + content string, +) { + t.Helper() + now := time.Now() + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + doc := &entity.AIMemoryDocument{ + ID: documentID, + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "rag", + Summary: "rag summary", + ContentText: content, + SourceKind: string(aidomain.MemorySourceModelInferred), + SourceID: chunkID, + } + if err := service.repo.BatchUpsertDocuments(context.Background(), []*entity.AIMemoryDocument{doc}); err != nil { + t.Fatalf("BatchUpsertDocuments() error = %v", err) + } + if err := service.repo.ReplaceDocumentChunks(context.Background(), documentID, []*entity.AIMemoryDocumentChunk{ + { + ID: chunkID, + DocumentID: documentID, + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + ChunkIndex: 0, + ContentText: content, + ContentHash: aidomain.BuildMemoryDocumentContentHash(content), + EmbeddingModel: "qwen3-vl-embedding", + EmbeddingDimension: 3, + QdrantPointID: pointID, + IndexedAt: &now, + }, + }); err != nil { + t.Fatalf("ReplaceDocumentChunks() error = %v", err) + } +} + func fmtAIMemoryRecallFactValue(summary string) string { payload, _ := json.Marshal(map[string]string{"value": summary}) return string(payload) diff --git a/internal/service/system/aiMemorySvc.go b/internal/service/system/aiMemorySvc.go index 49f2a93..ae8b5ad 100644 --- a/internal/service/system/aiMemorySvc.go +++ b/internal/service/system/aiMemorySvc.go @@ -56,6 +56,8 @@ type AIMemoryService struct { embedder aidomain.MemoryEmbedder // vectorStore 负责把 chunk 向量写入 Qdrant。 vectorStore aidomain.MemoryVectorStore + // vectorSearcher 负责从 Qdrant 召回 memory chunks。 + vectorSearcher aidomain.MemoryVectorSearcher } // NewAIMemoryService 基于正式 repository group 构造记忆服务骨架。 @@ -64,6 +66,7 @@ func NewAIMemoryService(repositoryGroup *repository.Group) *AIMemoryService { // Phase 1 先保证骨架可安全构造,不把 memory 变成启动期硬依赖。 return &AIMemoryService{} } + qdrantStore := newAIMemoryQdrantStore() return &AIMemoryService{ aiRepo: repositoryGroup.SystemRepositorySupplier.GetAIRepository(), repo: repositoryGroup.SystemRepositorySupplier.GetAIMemoryRepository(), @@ -81,7 +84,8 @@ func NewAIMemoryService(repositoryGroup *repository.Group) *AIMemoryService { Dimension: aiMemoryEmbedDimension(), Timeout: time.Duration(aiMemoryIndexTimeoutSeconds()) * time.Second, }), - vectorStore: newAIMemoryVectorStore(), + vectorStore: qdrantStore, + vectorSearcher: qdrantStore, } } @@ -116,6 +120,10 @@ func (s *AIMemoryService) ScheduleDocumentUpsert(ctx context.Context, docs []*en } func newAIMemoryVectorStore() aidomain.MemoryVectorStore { + return newAIMemoryQdrantStore() +} + +func newAIMemoryQdrantStore() *aimemory.QdrantVectorStore { if global.QdrantClient == nil { return nil } diff --git a/plan/ai/approved-memory-rag-recall.md b/plan/ai/approved-memory-rag-recall.md new file mode 100644 index 0000000..85e3722 --- /dev/null +++ b/plan/ai/approved-memory-rag-recall.md @@ -0,0 +1,27 @@ +# Memory RAG 召回实施计划 + +## Summary + +实现第 6 步 `RAG 召回`:用户发起对话时,用当前 query 生成与索引阶段完全一致的 embedding,在 Qdrant memory collection 中检索 self scope 的长期记忆 chunks,经 MySQL 二次校验后,把命中的内容注入现有 memory message 的 `Long-term Documents` 分区。RAG 失败按 fail open 降级,不影响 summary、facts、recent turns。 + +## Key Changes + +- 扩展配置:新增 `AI.Memory.RecallMinScore float64` 和 `AI.Memory.RAGMaxChars int`,分别绑定 `AI_MEMORY_RECALL_MIN_SCORE`、`AI_MEMORY_RAG_MAX_CHARS`;默认 `RecallMinScore=0.2`,`RAGMaxChars=2000`。 +- 扩展 RAG 协议:在 `domain/ai` 增加 query 召回输入、搜索结果、`MemoryVectorSearcher` 接口;query embedding 复用现有 `MemoryEmbedder`,强制使用 `aiMemoryEmbedModel()` 和 `aiMemoryEmbedDimension()`。 +- 扩展 Qdrant 检索:在 memory vector store 增加 `SearchChunks`,使用 `qdrant.QueryPoints`;filter 使用 `scope_key=aidomain.BuildSelfMemoryScopeKey(userID)`、`visibility=self`、`user_id=userID`,禁止写成 `self:{userID}`。 +- 扩展 MySQL 回查:repository 新增按 `qdrant_point_id` 批量读取 chunks 的方法,并 join documents 二次校验 document 未删除、未过期;service 按 Qdrant score 顺序恢复排序,并过滤低于 `RecallMinScore` 的结果。 +- 扩展 `AIMemoryService.Recall`:在 `Conversation Summary`、`Stable Facts` 后追加 `Long-term Documents`;RAG 召回、embedding、Qdrant、MySQL 回查任一失败只记录 warn 并降级为空 RAG,不返回错误中断对话。 + +## Test Plan + +- Qdrant search 单测:验证 collection、query vector、topK、min score 前置数据、self scope filter、visibility/user_id filter。 +- Repository 单测:按 point ids 回查 chunks;已删除、过期 document 不返回;service 能按 Qdrant score 顺序恢复排序。 +- Service 单测:命中 RAG 时 memory message 包含 `Long-term Documents`;低分结果被过滤;空 query、memory disabled、long-term disabled 不召回;embedding/Qdrant/MySQL 失败时 summary/facts 仍正常返回。 +- 集成回归:`aiContextAssembler` 注入 memory 后,runtime history 包含 summary/facts/RAG,并继续被 `CompressMessages` 保留在上下文最前。 +- 执行测试:`go test ./internal/service/system ./internal/repository/system ./internal/domain/ai ./internal/infrastructure/ai/memory`,最后跑 `go test ./...`。 + +## Assumptions + +- 本次只做 self memory RAG 召回并注入上下文,不开放 org/platform 召回。 +- 本次不接 `qwen3-vl-rerank`,不做混合排序;第 7 步再处理 facts、summary、RAG、tools 的统一预算和重排。 +- RAG 正文以 MySQL chunk 表为准,Qdrant 只作为向量检索和 point metadata 来源。 From 6917f989c065d0082c1c8b1274a2235d51b762f6 Mon Sep 17 00:00:00 2001 From: wang Date: Wed, 6 May 2026 17:33:52 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=E5=AE=8C=E5=96=84lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/configs.yaml | 4 + ...\206-three-RAG\345\217\254\345\233\236.md" | 135 ++++ ...67\345\220\210\345\217\254\345\233\236.md" | 203 +++++ ...76\350\256\241\346\265\201\347\250\213.md" | 16 +- internal/core/config.go | 10 + internal/core/config_memory_test.go | 29 + internal/domain/ai/memory.go | 9 + internal/domain/ai/memory_policy.go | 25 + internal/domain/ai/memory_writeback.go | 9 + internal/infrastructure/ai/eino/runtime.go | 91 ++- .../ai/eino/runtime_tools_test.go | 130 ++++ internal/infrastructure/ai/memory/chunker.go | 726 +++++++++++++++++- .../infrastructure/ai/memory/chunker_test.go | 99 +++ internal/infrastructure/ai/memory/embedder.go | 4 +- .../infrastructure/ai/memory/extractor.go | 123 ++- .../infrastructure/ai/memory/llm_extractor.go | 503 ++++++++++++ .../ai/memory/llm_extractor_test.go | 298 +++++++ internal/model/config/ai.go | 12 + internal/model/config/config.go | 5 + .../interfaces/aiMemoryRepository.go | 2 + internal/repository/system/aiMemoryRepo.go | 57 +- .../repository/system/aiMemoryRepo_test.go | 109 +++ internal/service/system/aiContext.go | 48 +- internal/service/system/aiContext_test.go | 33 +- internal/service/system/aiHybridContext.go | 449 +++++++++++ internal/service/system/aiMemoryExtractor.go | 148 ++++ internal/service/system/aiMemoryIndex_test.go | 42 + internal/service/system/aiMemoryPolicy.go | 167 +++- .../service/system/aiMemoryPolicy_test.go | 100 +++ internal/service/system/aiMemoryRecall.go | 401 +++++++--- .../service/system/aiMemoryRecall_test.go | 421 +++++++++- internal/service/system/aiMemorySvc.go | 14 +- internal/service/system/aiMemoryWriteback.go | 222 +++++- .../service/system/aiMemoryWriteback_test.go | 453 +++++++++++ internal/service/system/aiProjector.go | 14 - internal/service/system/aiSvc.go | 13 + internal/service/system/aitool/validation.go | 71 -- plan/ai/approved-memory-hybrid-recall.md | 28 + ...approved-memory-llm-governance-refactor.md | 40 + .../approved-memory-rag-v1-chunking-recall.md | 46 ++ plan/ai/approved-memory-ttl-hint-policy.md | 50 ++ .../approved-ai-memory-context-refactor.md | 51 ++ 42 files changed, 5084 insertions(+), 326 deletions(-) create mode 100644 "docs/AI/\350\256\260\345\277\206-three-RAG\345\217\254\345\233\236.md" create mode 100644 "docs/AI/\350\256\260\345\277\206-\346\234\200\345\220\216-\346\267\267\345\220\210\345\217\254\345\233\236.md" create mode 100644 internal/infrastructure/ai/memory/llm_extractor.go create mode 100644 internal/infrastructure/ai/memory/llm_extractor_test.go create mode 100644 internal/service/system/aiHybridContext.go create mode 100644 internal/service/system/aiMemoryExtractor.go create mode 100644 plan/ai/approved-memory-hybrid-recall.md create mode 100644 plan/ai/approved-memory-llm-governance-refactor.md create mode 100644 plan/ai/approved-memory-rag-v1-chunking-recall.md create mode 100644 plan/ai/approved-memory-ttl-hint-policy.md create mode 100644 plan/cross-module/approved-ai-memory-context-refactor.md diff --git a/configs/configs.yaml b/configs/configs.yaml index e30dad0..3a3ca21 100644 --- a/configs/configs.yaml +++ b/configs/configs.yaml @@ -244,6 +244,10 @@ ai: system_prompt: "你是 personal_assistant 项目的 AI 助手。当前阶段只提供基础流式对话,不调用工具,不请求人工确认。请直接、准确地回答用户问题。" temperature: 0.2 max_completion_tokens: 1200 + memory: + extractor_mode: "rule" + extract_timeout_seconds: 20 + extract_max_chars: 6000 qdrant: enabled: true endpoint: "" # HTTP/REST endpoint, example: http://127.0.0.1:6333 diff --git "a/docs/AI/\350\256\260\345\277\206-three-RAG\345\217\254\345\233\236.md" "b/docs/AI/\350\256\260\345\277\206-three-RAG\345\217\254\345\233\236.md" new file mode 100644 index 0000000..336169e --- /dev/null +++ "b/docs/AI/\350\256\260\345\277\206-three-RAG\345\217\254\345\233\236.md" @@ -0,0 +1,135 @@ +从当前代码看,你这个“RAG 召回”模块已经不是概念层了,而是一条比较完整的链路:**把用户当前 query 转成向量,从 Qdrant 里按权限边界召回 chunk,再回表拿正文,最后把结果作为一段“记忆上下文”注入到 AI runtime。** +主入口在 [aiMemoryRecall.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:28),接入上下文组装在 [aiContext.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:64)。 + +**先用一句人话讲清它在做什么** +你不是让 AI 每次都去翻完整历史,也不是让它直接扫数据库全文。 +你做的是: + +1. 先把“这次用户想问什么”编码成向量。 +2. 用这个向量去长期知识库里找最相近的知识片段。 +3. 把这些片段按权限、分数、模型版本再过滤一遍。 +4. 最后把它们整理成一段简洁的背景材料,塞回本轮上下文。 + +所以它的核心价值不是“查得快”,而是**在不把历史全塞进 prompt 的前提下,把真正相关的长期知识捞回来。** + +**完整链路是怎么走的** + +1. **上下文组装阶段触发召回** +在 [aiContext.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:64) 的 `Build` 里,系统先拿到当前会话的历史消息,然后如果挂了 `memory` provider,就会调用 `RecallMessages(...)`。 +也就是说,RAG 召回不是一个独立接口,而是 **runtime 上下文构建的一部分**。 + +2. **召回不是只查向量,还会同时恢复 summary 和 facts** +在 [aiMemoryRecall.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:28) 的 `Recall` 里,实际做了三类恢复: + +- 会话摘要 `summary` +- 稳定事实 `facts` +- 长期文档 `ragItems` + +所以你这个模块的设计不是“只有向量召回”,而是 **短期摘要记忆 + 结构化事实 + 长期文档 RAG** 三路合流。 +RAG 只是其中“长期知识”这一层。 + +3. **先把当前 query 做 embedding** +真正的长期文档召回在 [recallLongTermDocuments](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:126)。 +这里先做几层前置判断: + +- 长期记忆功能是否开启 +- query 是否为空 +- `embedder` 和 `vectorSearcher` 是否可用 +- `userID` 是否有效 + +通过后,调用 [embedder.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/embedder.go:59) 里的 `Embed`,把当前 query 转成一个向量。 +而且这里用的是**和建索引时同一套 embedding 模型和维度**,这一点很重要,否则 query 向量和 chunk 向量根本不在一个空间里。 + +4. **用 query vector 去 Qdrant 检索** +向量检索接口定义在 [memory_rag.go](D:/workspace_go/test/go/personal_assistant/internal/domain/ai/memory_rag.go:60),真正实现是在 [qdrant_store.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/qdrant_store.go:83) 的 `SearchChunks`。 + +你这里传给 Qdrant 的不是“裸向量 + topK”,而是带过滤条件的检索请求: + +- `scope_key` +- `visibility` +- `user_id` +- `limit` +- `min_score` + +过滤器构造在 [qdrant_store.go](D:/workspace_go/test/go/personal_assistant/internal/infrastructure/ai/memory/qdrant_store.go:152)。 + +这一步很关键,因为它说明你不是“先查出来再看能不能读”,而是**在检索阶段就把权限边界带进去**。 +当前实现先收敛在 `self` 作用域,也就是个人长期知识: + +- `scope_key = self:user:{userID}` +- `visibility = self` +- `user_id = 当前用户` + +这其实是一个很稳的第一版,先把个人知识召回链路打通,再考虑组织级知识。 + +5. **Qdrant 返回的只是候选点,不直接信任** +Qdrant 搜出来后,返回的是一组 `pointID + score`,不是最终要注入 prompt 的正文。 +你后面又做了一层**数据库回表校验**,在 [aiMemoryRecall.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:174) 调 `ListDocumentChunksByPointIDs(...)`,仓储实现见 [aiMemoryRepo.go](D:/workspace_go/test/go/personal_assistant/internal/repository/system/aiMemoryRepo.go:304)。 + +这里回表的意义非常大: + +- 过滤掉已过期 document +- 过滤掉已软删除 document +- 校验 chunk 的 `embedding_model` +- 校验 `embedding_dimension` +- 再次校验 `scope_key / visibility / user_id` + +也就是说,你不是“Qdrant 查到了就直接信”,而是把 Qdrant 当成**召回加速层**,真正要进上下文的内容,仍然回到业务库做二次确认。 +这保证了数据一致性,也降低了向量库和业务数据漂移带来的风险。 + +6. **按分数顺序组装长期知识片段** +回表成功后,会按 Qdrant 返回顺序保留候选 chunk,并带上分数,变成 `aiMemoryRAGRecallItem`。 +最终渲染逻辑在 [renderAIMemoryRAGSection](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:258)。 + +你没有把它做成一坨原始文本,而是渲染成这样的结构: + +- `[memoryType/topic score=0.xxx] chunk内容` + +这个设计很好,因为它保留了最重要的辅助信息: + +- 这段知识属于什么类型 +- 主题是什么 +- 相似度大概有多高 + +这样既方便调试,也方便后续做更细的 prompt 控制。 + +7. **最后不是直接返回给前端,而是注入成一条 memory message** +所有召回结果最终会被拼成一条特殊的 assistant message,见 [buildAIMemoryContextContent](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:207)。 +内容结构大概是: + +- `Conversation Summary` +- `Stable Facts` +- `Long-term Documents` +- `Current Query` + +然后以一条 `memory_context_xxx` 消息的形式插入 runtime history。 +也就是说,RAG 召回结果在你的系统里扮演的是**系统恢复的背景上下文**,不是直接展示给用户的最终答案。 + +**这个模块里几个很重要的设计点** + +- **RAG 是 fail-open 的** + 在 [recallLongTermDocumentsFailOpen](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:108),如果 embedding 或向量检索失败,只记日志,不阻断主链路。 + 这非常像生产系统思路:RAG 是增强能力,不应该因为它挂了就让整个 AI 对话不可用。 + +- **召回结果有最小分数门槛** + `minScore` 是配置项,默认值在 [aiMemoryRecall.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:16)。 + 低于阈值的 chunk 不会注入,避免把“沾点边但其实没用”的内容喂给模型。 + +- **召回内容还有长度预算** + 长期文档 section 会被单独裁剪,避免 RAG 片段把 prompt 撑爆。 + 这说明你不是“召回越多越好”,而是在控制 token 成本和噪声。 + +- **召回和压缩是连着的** + 回忆消息注入后,还会走 `CompressMessages(...)`,只保留 memory + recent turns。 + 所以整个设计目标不是“把更多东西放进上下文”,而是**用更少但更值钱的信息替代冗长历史**。 + +**如果把它讲成面试里的“我是怎么做的”** +你可以这样说: + +> 我把 RAG 召回做成了 AI 上下文组装阶段的一部分,而不是一个独立查询接口。系统在进入 runtime 前,会先恢复会话摘要和稳定事实,再把当前 query 用和建索引阶段一致的 embedding 模型转成向量,去 Qdrant 按 `scope_key`、`visibility`、`user_id` 做带权限边界的向量检索。拿到候选 point 之后,我没有直接把 Qdrant 返回结果喂给模型,而是回表查询 chunk 元数据,再校验模型版本、维度、权限和文档有效期,最后按分数顺序把长期知识片段整理成一段 memory context 注入到历史消息里。整个链路是 fail-open 的,RAG 失败不会影响主对话,只是退化成 summary/fact 记忆。 + +**一句最精炼的总结** +你这个模块本质上是在做: +**“带权限过滤和二次校验的 query-driven 长期知识召回,并把召回结果转成可控的上下文记忆,而不是把向量检索结果直接裸塞给模型。”** + +如果你要,我下一条可以继续帮你把这个点压成一版更适合简历/面试的 `30 秒版` 和 `1 分钟版`。 \ No newline at end of file diff --git "a/docs/AI/\350\256\260\345\277\206-\346\234\200\345\220\216-\346\267\267\345\220\210\345\217\254\345\233\236.md" "b/docs/AI/\350\256\260\345\277\206-\346\234\200\345\220\216-\346\267\267\345\220\210\345\217\254\345\233\236.md" new file mode 100644 index 0000000..26adca4 --- /dev/null +++ "b/docs/AI/\350\256\260\345\277\206-\346\234\200\345\220\216-\346\267\267\345\220\210\345\217\254\345\233\236.md" @@ -0,0 +1,203 @@ +你这个“混合召回策略”,本质上不是一个单独的检索器,而是一个**上下文总装配层**。 +它做的事情可以概括成一句话: + +**把不同来源的上下文按优先级、预算和稳定性拼成“这一轮真正要喂给模型的历史”,同时把实时工具留到下一层执行计划去决策。** + +当前实现的核心入口在 [aiContext.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:64) 和 [aiHybridContext.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:63)。 + +**先说你是怎么设计这层的** +你没有把 `summary / facts / RAG / recent turns / tools` 分散在各处各自处理,而是做了三层收口: + +1. `Recall` 先把记忆侧结果统一产出 + 在 [aiMemoryRecall.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall.go:28),它会把 `summary + facts + RAG` 先整理成一个记忆结果 `aiMemoryRecallResult`。 + +2. `ContextAssembler` 负责拿到原始历史和 recall 结果 + 在 [aiContext.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiContext.go:78),它先读原始会话历史,再调用记忆召回。 + +3. `HybridPlanner` 决定最终 history 怎么拼 + 在 [aiHybridContext.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:63),真正决定“保留哪些消息、压掉哪些消息、记忆放前面还是后面”的,是这个 planner。 + +所以你的实现不是“有几个召回来源就顺手 append 一下”,而是明确分成了: + +- 记忆生产 +- 上下文装配 +- 最终裁剪规划 + +这就是它为什么可以叫“混合召回策略”,而不是单纯的 memory restore。 + +**一轮请求进来后,这个模块是怎么工作的** +按实际链路,流程大概是这样: + +1. 用户发起一轮 AI 会话 +2. 系统先过滤本轮可见工具 + 见 [aiSvc.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:324) +3. 然后进入 `contextAssembler.Build(...)` + 见 [aiSvc.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:331) +4. `Build` 先把数据库里的历史消息转成 runtime history +5. 再调用 memory recall,把 `summary/facts/RAG` 恢复出来 +6. 然后把这些结果交给 `HybridPlanner` +7. `HybridPlanner` 依据 token 预算决定: + - 是保留完整历史 + - 还是切成 `memory + recent turns` +8. 最后把这份 `contextSnapshot.History` 交给 runtime +9. runtime 再基于这份 history 和可见工具做工具执行计划 + 见 [aiProgressive.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProgressive.go:11) + +所以这里的“混合”不是所有来源直接并列丢给模型,而是有一个**总调度器**先做裁剪和排序。 + +**summary / facts / RAG 在你这里是怎么混起来的** +这一层不是在 planner 里临时拼的,而是在 [buildAIMemoryContextContent](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:105) 里先整理成一条“记忆上下文消息”。 + +它的规则很清楚: + +- `summary` 放最前面 + 因为它是长历史压缩结果,最稳定,也最适合先给模型建立背景。 +- `facts` 放在中间 + 用来提供稳定事实,比如用户偏好、目标、画像之类。 +- `RAG` 放后面 + 作为和当前 query 相关的长期知识补充。 + +而且你不是简单拼接,而是做了**排序和预算控制**。 + +`facts` 的排序规则在 [renderSortedAIMemoryFactLines](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:183): + +- 先按 namespace 排 +- 再按 source priority 排 +- 工具验证/显式用户输入优先于模型推断 + +`RAG` 的排序规则在 [renderSortedAIMemoryRAGLines](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:224): + +- 先按 score 降序 +- 分数一样再按 chunk id 稳定排序 + +这说明你的“混合召回”不是平权拼接,而是有**来源优先级**的。 + +**真正的核心决策:什么时候保留完整历史,什么时候只留 recent turns** +这个逻辑在 [defaultAIHybridContextPlanner.Plan](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:63)。 + +它做的事情很务实: + +- 如果 memory 没开,直接返回原始历史 +- 如果总 token 还没超阈值,就保留 `memory + full raw history` +- 如果总 token 超了,就触发压缩: + - 保留记忆消息 + - 原始历史只保留最近几轮 + - 至少保留最少 2 条 recent raw message + +这就是你整个混合召回策略最关键的落点: + +**不是“多召回更多内容”,而是“用 memory 替代长历史,只保留必要 recent turns”。** + +这在测试里也有明确验证,见 [aiMemoryRecall_test.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiMemoryRecall_test.go:362)。 +测试结果就是: + +- history 里会保留 `memory_context` +- 很早的消息会被裁掉 +- 最近一轮 user/assistant 会保留 + +这就是典型的 `summary + recent turns` 模式。 + +**你是怎么控制预算和裁剪的** +这块你做得挺工程化的,不是凭感觉拼接。 + +在 [buildAIMemoryContextContent](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:105) 里有两层预算: + +- 总记忆字符预算 `RecallMaxChars` +- RAG 单独预算 `RAGMaxChars` + +这意味着: +- `summary/facts` 不会被 RAG 完全挤掉 +- `RAG` 只是长期知识补充,不是无限塞进去 + +而且 `appendAIMemoryContextPart(...)` 在 [aiHybridContext.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:164) 会逐段尝试追加,超预算就截断或停止。 +这让整个 context assembly 变成了一个**预算受控的拼装过程**,不是一把梭。 + +**realtime tools 在这套混合策略里扮演什么角色** +这里要讲准确一点。 + +你在第 7 步里写了 `recent turns + facts + summary + RAG + realtime tools`,但从当前代码看,`realtime tools` **不是直接被 planner 拼进 history 的一段文本**。 + +当前实现是: + +- planner 会知道本轮有多少 `VisibleTools` + 见 [aiHybridContext.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:70) +- 但它并不会把工具结果提前注入 memory block +- 真正的工具决策是在上下文规划完成之后,由 `buildAIToolExecutionPlan(...)` 去做 + 见 [aiProgressive.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiProgressive.go:11) + +所以更准确的说法是: + +**你的混合召回策略目前已经统一了 memory 类来源,而 realtime tools 处于下一层“基于上下文做实时真相查询”的执行计划里。** + +这其实很合理,因为: +- `summary/facts/RAG` 是“背景记忆” +- `tool` 是“本轮实时真相源” + +你在设计上没有把这两类东西混成一锅,这是对的。 + +**你还做了一个很实用的东西:diagnostics** +这点很像生产系统思路。 + +在 [aiHybridContextDiagnostics](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiHybridContext.go:33) 里,你记录了: + +- summary 有多少候选、保留了多少 +- facts 有多少候选、丢了多少 +- RAG 保留了多少 +- 是否触发压缩 +- recent messages 保留了多少 +- 当前 query 是否已经在历史里 +- 总 history token 大概是多少 +- 可见工具数量是多少 + +然后这些诊断信息会在 [aiSvc.go](D:/workspace_go/test/go/personal_assistant/internal/service/system/aiSvc.go:344) 打日志。 + +这很重要,因为混合召回最难的问题之一就是: +**“为什么这轮模型答偏了?到底是没召回到,还是被压掉了,还是 recent turns 不够?”** + +你加了 diagnostics 之后,这些问题就能定位。 + +**如果用更人类的话概括这套模块** +你这个模块其实像一个“会议主持人”。 + +它拿到几类信息: + +- 长期背景摘要 +- 稳定事实 +- 长期知识片段 +- 最近对话 +- 本轮可用工具 + +然后做的不是把所有材料都发给模型,而是先判断: + +- 哪些信息是稳定背景 +- 哪些信息和当前问题最相关 +- 哪些旧消息已经可以丢掉 +- 哪些 recent turns 必须保留,避免模型失去当前对话连续性 +- 工具应该暂时保留到下一步实时查询,而不是先塞进记忆 + +最终输出的是一份**又短、又够用、又不丢关键上下文**的 history。 + +**面试里可以怎么讲** +你可以直接这样说: + +> 我把混合召回做成了 AI 上下文装配层,而不是把 summary、facts、RAG 和历史消息分别散落在各个模块里。具体做法是:先由 memory recall 恢复会话摘要、稳定事实和长期文档召回结果,并整理成一条 memory context;再由 hybrid planner 统一计算 token 预算,决定是保留完整历史,还是切换到 `memory + recent turns` 模式。facts 会按来源优先级排序,RAG 结果按分数排序并受单独预算限制,整个过程会输出 diagnostics 记录保留和裁剪情况。至于 realtime tools,我没有把它们直接塞进记忆上下文,而是把它们留到下一层工具执行计划去做实时真相查询,这样背景记忆和实时事实是分层处理的。 + +**最后给你一个准确边界** +如果要说得严谨一点,你现在这套“混合召回策略”已经做到了: + +- `summary + facts + RAG + recent turns` 的统一编排 +- token 预算控制 +- 来源优先级和排序 +- diagnostics 可观测性 +- 与 realtime tools 的分层衔接 + +但还**没做到特别复杂的动态策略**,比如: + +- 针对不同 query 类型切不同召回权重 +- 根据 tool 意图先决定是否压缩 history +- 多路召回 rerank +- 工具结果反哺下一轮 memory planner + +所以它目前是一个**稳定、可解释、工程上可落地的第一版混合召回总调度层**。 + +如果你要,我下一条可以直接帮你把这段压成一版适合简历/面试口述的 `1 分钟版`。 diff --git "a/docs/AI/\350\256\276\350\256\241\346\265\201\347\250\213.md" "b/docs/AI/\350\256\276\350\256\241\346\265\201\347\250\213.md" index d76ad17..1e8a90f 100644 --- "a/docs/AI/\350\256\276\350\256\241\346\265\201\347\250\213.md" +++ "b/docs/AI/\350\256\276\350\256\241\346\265\201\347\250\213.md" @@ -7,8 +7,18 @@ ### 2. 记忆治理 依赖:第 1 步给出的记忆类型、作用域、权限边界。 -价值:明确“什么能存、什么不能存、如何覆盖、如何去重、何时过期、谁能读写”。 -为什么排第二:`writeback` 一旦开始写数据,就会产生真实记忆;治理不先定,后面会把脏数据、低价值数据、越权数据一起写进去。 +价值:明确什么内容值得被提议为记忆、什么内容最终允许入库,以及如何覆盖、如何去重、何时过期、谁能读写。 +为什么排第二:`writeback` 一旦开始写数据,就会产生真实记忆;治理不先定,后面会把低价值、不稳定、越权或与业务真相冲突的内容一起写进长期记忆。 + +如果后续引入 LLM,这一层不是把治理规则全部变成 prompt,而是采用 `LLM 提议 + Policy 裁决` 的双层治理模式。 + +- LLM 负责软规则:根据记忆 schema 和 prompt,从对话、工具结果、摘要中提议 `Fact / Document / ConversationSummary` 候选。 +- Policy 负责硬规则:基于真实身份、scope、visibility、权限、TTL、dedup key、覆盖优先级,决定候选是否允许落库。 +- Prompt 是治理意图,Policy 是治理裁决。 + +也就是说,LLM 可以判断“这段内容像不像值得记的用户偏好、长期知识或会话摘要”,但不能决定最终 scope、visibility、权限、TTL、去重键和是否入库。最终写入 MySQL 的必须是经过 Service policy 校验后的 canonical memory record。 + +TTL 也按这个原则处理:不直接靠正则从原文硬抠过期时间,而是让 LLM 输出结构化 `ttl_hint`,例如 `default / persistent / duration / until_date / session_only`。Policy 再按 namespace 和 memory type 做白名单校验、范围约束和默认值回退,最后统一计算真正的 `expires_at`。例如 `oj_goal` 可以接受合法的 30 天 duration hint,而 `user_preference` 默认长期有效,不会因为 LLM 提议 3 天就被短期过期。 ### 3. memory writeback hook 依赖:第 1 步的数据模型,第 2 步的治理规则。 @@ -46,4 +56,4 @@ 压成一句话: -`先定模型,再定规则;先把数据写出来,再把短期上下文用起来;再建立长期知识索引;再统一召回;最后把整条链路看清楚。` \ No newline at end of file +`先定模型,再定规则;先把数据写出来,再把短期上下文用起来;再建立长期知识索引;再统一召回;最后把整条链路看清楚。` diff --git a/internal/core/config.go b/internal/core/config.go index c60dcd2..6de9be8 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -68,6 +68,7 @@ func InitConfig(path string) { viper.SetDefault("ai.memory.recall_min_score", 0.2) viper.SetDefault("ai.memory.rag_max_chars", 2000) viper.SetDefault("ai.memory.recent_raw_turns", 8) + viper.SetDefault("ai.memory.recent_raw_token_budget", 3000) viper.SetDefault("ai.memory.compress_threshold_tokens", 6000) viper.SetDefault("ai.memory.summary_refresh_every_turns", 10) viper.SetDefault("ai.memory.writeback_async", true) @@ -76,6 +77,10 @@ func InitConfig(path string) { viper.SetDefault("ai.memory.enable_org_memory", true) viper.SetDefault("ai.memory.enable_ops_memory", true) viper.SetDefault("ai.memory.min_importance", 0.65) + viper.SetDefault("ai.memory.extractor_mode", "rule") + viper.SetDefault("ai.memory.extract_timeout_seconds", 20) + viper.SetDefault("ai.memory.extract_max_chars", 6000) + viper.SetDefault("ai.memory.tool_output_token_budget", 800) viper.SetDefault("ai.memory.embed_model", "qwen3-vl-embedding") viper.SetDefault("ai.memory.embed_endpoint", "https://dashscope.aliyuncs.com/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding") viper.SetDefault("ai.memory.embed_dimension", 1024) @@ -291,6 +296,7 @@ func InitConfig(path string) { _ = viper.BindEnv("ai.memory.recall_min_score", "AI_MEMORY_RECALL_MIN_SCORE") _ = viper.BindEnv("ai.memory.rag_max_chars", "AI_MEMORY_RAG_MAX_CHARS") _ = viper.BindEnv("ai.memory.recent_raw_turns", "AI_MEMORY_RECENT_RAW_TURNS") + _ = viper.BindEnv("ai.memory.recent_raw_token_budget", "AI_MEMORY_RECENT_RAW_TOKEN_BUDGET") _ = viper.BindEnv("ai.memory.compress_threshold_tokens", "AI_MEMORY_COMPRESS_THRESHOLD_TOKENS") _ = viper.BindEnv("ai.memory.summary_refresh_every_turns", "AI_MEMORY_SUMMARY_REFRESH_EVERY_TURNS") _ = viper.BindEnv("ai.memory.writeback_async", "AI_MEMORY_WRITEBACK_ASYNC") @@ -299,6 +305,10 @@ func InitConfig(path string) { _ = viper.BindEnv("ai.memory.enable_org_memory", "AI_MEMORY_ENABLE_ORG_MEMORY") _ = viper.BindEnv("ai.memory.enable_ops_memory", "AI_MEMORY_ENABLE_OPS_MEMORY") _ = viper.BindEnv("ai.memory.min_importance", "AI_MEMORY_MIN_IMPORTANCE") + _ = viper.BindEnv("ai.memory.extractor_mode", "AI_MEMORY_EXTRACTOR_MODE") + _ = viper.BindEnv("ai.memory.extract_timeout_seconds", "AI_MEMORY_EXTRACT_TIMEOUT_SECONDS") + _ = viper.BindEnv("ai.memory.extract_max_chars", "AI_MEMORY_EXTRACT_MAX_CHARS") + _ = viper.BindEnv("ai.memory.tool_output_token_budget", "AI_MEMORY_TOOL_OUTPUT_TOKEN_BUDGET") _ = viper.BindEnv("ai.memory.embed_model", "AI_MEMORY_EMBED_MODEL") _ = viper.BindEnv("ai.memory.embed_endpoint", "AI_MEMORY_EMBED_ENDPOINT") _ = viper.BindEnv("ai.memory.embed_dimension", "AI_MEMORY_EMBED_DIMENSION") diff --git a/internal/core/config_memory_test.go b/internal/core/config_memory_test.go index bd39b38..264428f 100644 --- a/internal/core/config_memory_test.go +++ b/internal/core/config_memory_test.go @@ -25,6 +25,11 @@ func TestInitConfigBindsAIMemoryAndQdrantCompatibility(t *testing.T) { t.Setenv("AI_MEMORY_RECALL_MAX_CHARS", "4096") t.Setenv("AI_MEMORY_RECALL_MIN_SCORE", "0.42") t.Setenv("AI_MEMORY_RAG_MAX_CHARS", "1024") + t.Setenv("AI_MEMORY_RECENT_RAW_TOKEN_BUDGET", "2048") + t.Setenv("AI_MEMORY_EXTRACTOR_MODE", "llm") + t.Setenv("AI_MEMORY_EXTRACT_TIMEOUT_SECONDS", "17") + t.Setenv("AI_MEMORY_EXTRACT_MAX_CHARS", "2048") + t.Setenv("AI_MEMORY_TOOL_OUTPUT_TOKEN_BUDGET", "256") t.Setenv("AI_MEMORY_EMBED_DIMENSION", "1024") t.Setenv("AI_MEMORY_INDEX_BATCH_SIZE", "11") t.Setenv("QDRANT_COLLECTION_NAME", "legacy-knowledge") @@ -50,6 +55,30 @@ func TestInitConfigBindsAIMemoryAndQdrantCompatibility(t *testing.T) { if global.Config.AI.Memory.RAGMaxChars != 1024 { t.Fatalf("AI.Memory.RAGMaxChars = %d, want 1024", global.Config.AI.Memory.RAGMaxChars) } + if global.Config.AI.Memory.RecentRawTokenBudget != 2048 { + t.Fatalf( + "AI.Memory.RecentRawTokenBudget = %d, want 2048", + global.Config.AI.Memory.RecentRawTokenBudget, + ) + } + if global.Config.AI.Memory.ExtractorMode != "llm" { + t.Fatalf("AI.Memory.ExtractorMode = %q, want llm", global.Config.AI.Memory.ExtractorMode) + } + if global.Config.AI.Memory.ExtractTimeoutSeconds != 17 { + t.Fatalf( + "AI.Memory.ExtractTimeoutSeconds = %d, want 17", + global.Config.AI.Memory.ExtractTimeoutSeconds, + ) + } + if global.Config.AI.Memory.ExtractMaxChars != 2048 { + t.Fatalf("AI.Memory.ExtractMaxChars = %d, want 2048", global.Config.AI.Memory.ExtractMaxChars) + } + if global.Config.AI.Memory.ToolOutputTokenBudget != 256 { + t.Fatalf( + "AI.Memory.ToolOutputTokenBudget = %d, want 256", + global.Config.AI.Memory.ToolOutputTokenBudget, + ) + } if global.Config.AI.Memory.SummaryRefreshEveryTurns != 10 { t.Fatalf( "AI.Memory.SummaryRefreshEveryTurns = %d, want default 10", diff --git a/internal/domain/ai/memory.go b/internal/domain/ai/memory.go index b8892ad..6233e22 100644 --- a/internal/domain/ai/memory.go +++ b/internal/domain/ai/memory.go @@ -97,6 +97,9 @@ type MemoryFactQuery struct { // AllowedVisibilities 是本次调用允许暴露给当前主体的访问等级集合。 // scope 负责“属于谁”,visibility 负责“谁能看”,查询时必须同时满足两者。 AllowedVisibilities []MemoryVisibility + // Namespaces 用于一次查询多个业务域的结构化事实。 + // 当它非空时优先于 Namespace 生效。 + Namespaces []string // Namespace 用于按业务域过滤结构化事实,例如 user_preference、oj_goal。 Namespace string // FactKeys 用于在同一 namespace 下进一步收窄到具体事实键。 @@ -119,6 +122,12 @@ type MemoryDocumentQuery struct { Limit int } +// MemoryDocumentChunkRef 描述指定 document 内某个 chunk 的精确定位。 +type MemoryDocumentChunkRef struct { + DocumentID string + ChunkIndex int +} + // NormalizeMemoryVisibilities 把 visibility 列表转换为稳定字符串切片。 func NormalizeMemoryVisibilities(visibilities []MemoryVisibility) []string { if len(visibilities) == 0 { diff --git a/internal/domain/ai/memory_policy.go b/internal/domain/ai/memory_policy.go index 70321f7..5b24ad2 100644 --- a/internal/domain/ai/memory_policy.go +++ b/internal/domain/ai/memory_policy.go @@ -20,6 +20,17 @@ const ( MemorySourceFullToolOutput MemorySourceKind = "full_tool_output" ) +// MemoryTTLHintKind 表示 LLM 可提议的受控 TTL 语义类型。 +type MemoryTTLHintKind string + +const ( + MemoryTTLHintDefault MemoryTTLHintKind = "default" + MemoryTTLHintPersistent MemoryTTLHintKind = "persistent" + MemoryTTLHintDuration MemoryTTLHintKind = "duration" + MemoryTTLHintUntilDate MemoryTTLHintKind = "until_date" + MemoryTTLHintSessionOnly MemoryTTLHintKind = "session_only" +) + const ( MemoryReasonAllowSelfScope = "allow_self_scope" MemoryReasonAllowOrgScope = "allow_org_scope" @@ -117,6 +128,8 @@ type MemoryFactCandidate struct { FactKey string FactValueJSON string Summary string + Confidence float64 + TTLHint *MemoryTTLHint SourceKind MemorySourceKind SourceID string LowValue bool @@ -133,12 +146,24 @@ type MemoryDocumentCandidate struct { Title string Summary string ContentText string + Confidence float64 + TTLHint *MemoryTTLHint SourceKind MemorySourceKind SourceID string LowValue bool TruthConflict bool } +// MemoryTTLHint 描述 LLM 提议的时间语义,最终 expires_at 仍由 policy 计算。 +type MemoryTTLHint struct { + Kind MemoryTTLHintKind + Value int + Unit string + UntilDate string + Reason string + Confidence float64 +} + // MemoryFactVersion 描述事实覆盖比较所需的最小信息。 type MemoryFactVersion struct { ValueJSON string diff --git a/internal/domain/ai/memory_writeback.go b/internal/domain/ai/memory_writeback.go index e1332ed..8e4cea5 100644 --- a/internal/domain/ai/memory_writeback.go +++ b/internal/domain/ai/memory_writeback.go @@ -12,9 +12,18 @@ type MemoryExtractionInput struct { UserMessage Message AssistantMessage Message + RecentMessages []Message PreviousSummaryText string + SummaryRefreshMode MemorySummaryRefreshMode } +type MemorySummaryRefreshMode string + +const ( + MemorySummaryRefreshModeHeadUpdate MemorySummaryRefreshMode = "head_update" + MemorySummaryRefreshModeFullRefresh MemorySummaryRefreshMode = "full_refresh" +) + // ConversationSummaryDraft is a technology-neutral summary proposal. type ConversationSummaryDraft struct { ConversationID string diff --git a/internal/infrastructure/ai/eino/runtime.go b/internal/infrastructure/ai/eino/runtime.go index 1451162..b25b138 100644 --- a/internal/infrastructure/ai/eino/runtime.go +++ b/internal/infrastructure/ai/eino/runtime.go @@ -2,16 +2,19 @@ package eino import ( "context" + "encoding/json" "errors" "fmt" "io" "strings" "sync" "time" + "unicode/utf8" einomodel "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" + "personal_assistant/global" aidomain "personal_assistant/internal/domain/ai" ) @@ -24,6 +27,11 @@ type Runtime struct { bindMu sync.Mutex } +type legacyBindToolsChatModel interface { + einomodel.BaseChatModel + BindTools(tools []*schema.ToolInfo) error +} + // NewRuntime 创建 Eino 基础流式 runtime。 // 参数: // - ctx:初始化上下文。 @@ -291,7 +299,7 @@ func (r *Runtime) bindToolModel(tools []aidomain.Tool) (einomodel.BaseChatModel, return bound, func() {}, err } // 只支持 BindTools 的模型需要串行绑定,避免并发请求互相污染。 - if chatModel, ok := r.model.(einomodel.ChatModel); ok { + if chatModel, ok := r.model.(legacyBindToolsChatModel); ok { r.bindMu.Lock() if err := chatModel.BindTools(toolInfos); err != nil { r.bindMu.Unlock() @@ -464,7 +472,10 @@ func (r *Runtime) executeToolCalls( } // ToolMessage 会作为下一轮模型输入,让模型基于工具输出继续生成回答。 - messages = append(messages, schema.ToolMessage(result.Output, callID, schema.WithToolName(toolName))) + messages = append( + messages, + schema.ToolMessage(compressToolMessageOutput(result), callID, schema.WithToolName(toolName)), + ) } return messages, nil } @@ -537,6 +548,82 @@ func summarizeToolOutput(content string) string { return string(runes[:120]) } +func compressToolMessageOutput(result aidomain.ToolResult) string { + output := strings.TrimSpace(result.Output) + budget := aiMemoryToolOutputTokenBudget() + if output == "" || budget <= 0 { + return output + } + originalTokenEstimate := estimateToolOutputTokens(output) + if originalTokenEstimate <= budget { + return output + } + + summary := strings.TrimSpace(result.Summary) + if summary == "" { + summary = summarizeToolOutput(output) + } + maxPreviewRunes := budget * 4 + if maxPreviewRunes < 160 { + maxPreviewRunes = 160 + } + preview := truncateToolOutputPreview(output, maxPreviewRunes) + for { + payload := map[string]any{ + "summary": summary, + "truncated": true, + "preview": preview, + "original_token_estimate": originalTokenEstimate, + } + raw, err := json.Marshal(payload) + if err != nil { + return output + } + if estimateToolOutputTokens(string(raw)) <= budget || utf8.RuneCountInString(preview) <= 80 { + return string(raw) + } + nextLimit := utf8.RuneCountInString(preview) - maxToolOutputInt(utf8.RuneCountInString(preview)/4, 40) + preview = truncateToolOutputPreview(preview, nextLimit) + } +} + +func truncateToolOutputPreview(input string, limit int) string { + input = strings.TrimSpace(input) + if limit <= 0 { + return "" + } + runes := []rune(input) + if len(runes) <= limit { + return input + } + if limit <= 3 { + return string(runes[:limit]) + } + return string(runes[:limit-3]) + "..." +} + +func estimateToolOutputTokens(content string) int { + runes := utf8.RuneCountInString(strings.TrimSpace(content)) + if runes == 0 { + return 0 + } + return (runes + 3) / 4 +} + +func aiMemoryToolOutputTokenBudget() int { + if global.Config == nil || global.Config.AI.Memory.ToolOutputTokenBudget <= 0 { + return 800 + } + return global.Config.AI.Memory.ToolOutputTokenBudget +} + +func maxToolOutputInt(left int, right int) int { + if left > right { + return left + } + return right +} + // deriveTitle 根据用户输入生成会话开始事件标题。 func deriveTitle(content string) string { content = strings.TrimSpace(content) diff --git a/internal/infrastructure/ai/eino/runtime_tools_test.go b/internal/infrastructure/ai/eino/runtime_tools_test.go index 466d8a1..df5ffa3 100644 --- a/internal/infrastructure/ai/eino/runtime_tools_test.go +++ b/internal/infrastructure/ai/eino/runtime_tools_test.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" einomodel "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" + "personal_assistant/global" aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/config" ) type runtimeEventSinkStub struct { @@ -257,6 +260,124 @@ func TestRuntimeStreamWithToolsEmitsToolEventsAndFinalTokens(t *testing.T) { } } +func TestRuntimeStreamWithLargeToolOutputCompressesFeedbackEnvelope(t *testing.T) { + restore := setRuntimeAIMemoryConfig(t, config.AIMemory{ToolOutputTokenBudget: 20}) + defer restore() + + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_big", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_big_payload", + Arguments: `{}`, + }, + }, + }), + }, + { + schema.AssistantMessage("已处理大结果。", nil), + }, + }, + } + runtime := &Runtime{model: model, systemPrompt: "base system prompt"} + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "query_big_payload", + }, + result: aidomain.ToolResult{ + Output: `{"payload":"` + strings.Repeat("x", 320) + `"}`, + Summary: "已返回大结果", + DetailMarkdown: "```json\n{\"payload\":\"...\"}\n```", + }, + } + sink := &runtimeEventSinkStub{} + + if _, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "给我大结果", + Tools: []aidomain.Tool{tool}, + }, sink); err != nil { + t.Fatalf("Stream() error = %v", err) + } + if len(model.inputs) < 2 { + t.Fatalf("model.inputs len = %d, want second turn input", len(model.inputs)) + } + foundCompressed := false + for _, message := range model.inputs[1] { + if strings.Contains(message.Content, `"truncated":true`) { + foundCompressed = true + if !strings.Contains(message.Content, `"original_token_estimate"`) { + t.Fatalf("compressed tool message missing token estimate: %s", message.Content) + } + } + } + if !foundCompressed { + t.Fatalf("second turn input did not contain compressed tool message: %+v", model.inputs[1]) + } +} + +func TestRuntimeStreamKeepsSmallToolOutputRaw(t *testing.T) { + restore := setRuntimeAIMemoryConfig(t, config.AIMemory{ToolOutputTokenBudget: 200}) + defer restore() + + model := &fakeToolCallingChatModel{ + streams: [][]*schema.Message{ + { + schema.AssistantMessage("", []schema.ToolCall{ + { + ID: "call_small", + Type: "function", + Function: schema.FunctionCall{ + Name: "query_small_payload", + Arguments: `{}`, + }, + }, + }), + }, + { + schema.AssistantMessage("已处理小结果。", nil), + }, + }, + } + runtime := &Runtime{model: model, systemPrompt: "base system prompt"} + tool := &fakeRuntimeTool{ + spec: aidomain.ToolSpec{ + Name: "query_small_payload", + }, + result: aidomain.ToolResult{ + Output: `{"ok":true,"count":2}`, + Summary: "已返回小结果", + DetailMarkdown: "```json\n{\"ok\":true,\"count\":2}\n```", + }, + } + sink := &runtimeEventSinkStub{} + + if _, err := runtime.Stream(context.Background(), aidomain.StreamInput{ + Content: "给我小结果", + Tools: []aidomain.Tool{tool}, + }, sink); err != nil { + t.Fatalf("Stream() error = %v", err) + } + if len(model.inputs) < 2 { + t.Fatalf("model.inputs len = %d, want second turn input", len(model.inputs)) + } + foundRaw := false + for _, message := range model.inputs[1] { + if message.Content == `{"ok":true,"count":2}` { + foundRaw = true + } + if strings.Contains(message.Content, `"truncated":true`) { + t.Fatalf("small tool output should not be compressed: %s", message.Content) + } + } + if !foundRaw { + t.Fatalf("second turn input did not contain raw tool output: %+v", model.inputs[1]) + } +} + func TestRuntimeStreamWithToolsCanNaturallyAskForMissingParams(t *testing.T) { model := &fakeToolCallingChatModel{ streams: [][]*schema.Message{ @@ -647,3 +768,12 @@ func TestRuntimeStreamWithToolsStopsAfterRepeatedInvalidRepairAttempts(t *testin t.Fatalf("event count = %d, want 7", len(sink.events)) } } + +func setRuntimeAIMemoryConfig(t *testing.T, memory config.AIMemory) func() { + t.Helper() + previous := global.Config + global.Config = &config.Config{AI: config.AI{Memory: memory}} + return func() { + global.Config = previous + } +} diff --git a/internal/infrastructure/ai/memory/chunker.go b/internal/infrastructure/ai/memory/chunker.go index 23ec6c9..7bbeba5 100644 --- a/internal/infrastructure/ai/memory/chunker.go +++ b/internal/infrastructure/ai/memory/chunker.go @@ -16,13 +16,33 @@ const ( defaultChunkOverlapChars = 150 ) +type chunkBlockKind string + +const ( + blockHeading chunkBlockKind = "heading" + blockProse chunkBlockKind = "prose" + blockCode chunkBlockKind = "code" + blockTable chunkBlockKind = "table" + blockList chunkBlockKind = "list" +) + +type chunkBlock struct { + Kind chunkBlockKind + Text string +} + +type chunkListItem struct { + Marker string + Text string +} + // ChunkerOptions 配置记忆文档切分策略。 type ChunkerOptions struct { MaxChars int OverlapChars int } -// ParagraphChunker 按段落优先切分记忆文档,必要时回退到字符窗口。 +// ParagraphChunker 按 block / 段落 / 句界优先切分记忆文档,必要时回退到字符窗口。 type ParagraphChunker struct { maxChars int overlapChars int @@ -55,7 +75,7 @@ func (c *ParagraphChunker) Chunk( return nil, ctx.Err() default: } - content := normalizeChunkText(doc.Content) + content := normalizeChunkDocumentText(doc.Content) if content == "" { return nil, nil } @@ -88,37 +108,227 @@ func (c *ParagraphChunker) Chunk( } func (c *ParagraphChunker) splitText(content string) []string { - paragraphs := splitParagraphs(content) - chunks := make([]string, 0, len(paragraphs)) + blocks := splitBlocks(content) + chunks := make([]string, 0, len(blocks)) current := "" - for _, paragraph := range paragraphs { - if utf8.RuneCountInString(paragraph) > c.maxChars { - if current != "" { + for _, block := range blocks { + blockChunks := c.splitBlock(block) + if len(blockChunks) == 0 { + continue + } + if len(blockChunks) > 1 { + if strings.TrimSpace(current) != "" { chunks = append(chunks, current) - current = c.chunkOverlap(current) - } - for _, part := range splitByRuneWindow(paragraph, c.maxChars, c.overlapChars) { - if part != "" { - chunks = append(chunks, part) - } + current = "" } - current = "" + chunks = append(chunks, blockChunks...) continue } - candidate := paragraph - if current != "" { - candidate = current + "\n\n" + paragraph + + part := strings.TrimSpace(blockChunks[0]) + if part == "" { + continue + } + if current == "" { + current = part + continue } + + candidate := joinChunkParts(current, part, "\n\n") if utf8.RuneCountInString(candidate) <= c.maxChars { current = candidate continue } - if current != "" { - chunks = append(chunks, current) - current = strings.TrimSpace(c.chunkOverlap(current) + "\n\n" + paragraph) - } else { - current = paragraph + + chunks = append(chunks, current) + current = c.startChunkWithOverlap(current, part, "\n\n") + } + if strings.TrimSpace(current) != "" { + chunks = append(chunks, current) + } + return chunks +} + +func (c *ParagraphChunker) splitBlock(block chunkBlock) []string { + switch block.Kind { + case blockHeading: + return c.splitHeadingBlock(block.Text) + case blockList: + return c.splitListBlock(block.Text) + case blockTable: + return c.splitTableBlock(block.Text) + case blockCode: + return c.splitCodeBlock(block.Text) + default: + return c.splitProseBlock(block.Text) + } +} + +func (c *ParagraphChunker) splitHeadingBlock(text string) []string { + text = normalizeInlineText(text) + if text == "" { + return nil + } + if utf8.RuneCountInString(text) <= c.maxChars { + return []string{text} + } + return splitByRuneWindow(text, c.maxChars, 0) +} + +func (c *ParagraphChunker) splitProseBlock(text string) []string { + text = normalizeInlineText(text) + if text == "" { + return nil + } + if utf8.RuneCountInString(text) <= c.maxChars { + return []string{text} + } + return c.buildChunkSequence(splitNarrativeUnits(text, c.maxChars), " ") +} + +func (c *ParagraphChunker) splitListBlock(text string) []string { + items := splitListItems(text) + if len(items) == 0 { + return c.splitProseBlock(text) + } + + parts := make([]string, 0, len(items)) + for _, item := range items { + body := normalizeInlineText(item.Text) + if body == "" { + continue + } + prefix := strings.TrimSpace(item.Marker) + if prefix == "" { + prefix = "-" + } + fullText := strings.TrimSpace(prefix + " " + body) + if utf8.RuneCountInString(fullText) <= c.maxChars { + parts = append(parts, fullText) + continue + } + + bodyBudget := c.maxChars - utf8.RuneCountInString(prefix) - 1 + if bodyBudget <= 0 { + bodyBudget = c.maxChars + } + for _, unit := range splitNarrativeUnits(body, bodyBudget) { + unit = strings.TrimSpace(unit) + if unit == "" { + continue + } + parts = append(parts, strings.TrimSpace(prefix+" "+unit)) + } + } + return c.buildChunkSequence(parts, "\n") +} + +func (c *ParagraphChunker) splitTableBlock(text string) []string { + text = normalizeChunkDocumentText(text) + if text == "" { + return nil + } + if utf8.RuneCountInString(text) <= c.maxChars { + return []string{text} + } + + lines := splitRawLines(text) + if len(lines) < 2 || !isMarkdownTableDelimiterLine(lines[1]) { + return splitByRuneWindow(text, c.maxChars, 0) + } + + prefix := strings.TrimSpace(lines[0]) + "\n" + strings.TrimSpace(lines[1]) + bodyBudget := c.maxChars - utf8.RuneCountInString(prefix) - 1 + if bodyBudget <= 0 { + return splitByRuneWindow(text, c.maxChars, 0) + } + + rows := make([]string, 0, len(lines)-2) + for _, line := range lines[2:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if utf8.RuneCountInString(line) <= bodyBudget { + rows = append(rows, line) + continue + } + rows = append(rows, splitByRuneWindow(line, bodyBudget, 0)...) + } + return packWrappedParts(rows, prefix, "", "\n", c.maxChars) +} + +func (c *ParagraphChunker) splitCodeBlock(text string) []string { + text = normalizeChunkDocumentText(text) + if text == "" { + return nil + } + if utf8.RuneCountInString(text) <= c.maxChars { + return []string{text} + } + + lines := splitRawLines(text) + prefix := "" + suffix := "" + bodyLines := lines + if len(lines) > 0 && isFenceLine(lines[0]) { + prefix = strings.TrimSpace(lines[0]) + bodyLines = lines[1:] + } + if len(bodyLines) > 0 && isFenceLine(bodyLines[len(bodyLines)-1]) { + suffix = strings.TrimSpace(bodyLines[len(bodyLines)-1]) + bodyLines = bodyLines[:len(bodyLines)-1] + } + + bodyBudget := c.maxChars + if prefix != "" { + bodyBudget -= utf8.RuneCountInString(prefix) + 1 + } + if suffix != "" { + bodyBudget -= utf8.RuneCountInString(suffix) + 1 + } + if bodyBudget <= 0 { + return splitByRuneWindow(text, c.maxChars, 0) + } + + groups := splitCodeGroups(bodyLines) + parts := make([]string, 0, len(groups)) + for _, group := range groups { + group = strings.TrimSpace(group) + if group == "" { + continue + } + if utf8.RuneCountInString(group) <= bodyBudget { + parts = append(parts, group) + continue } + parts = append(parts, splitCodeGroupByLineWindow(group, bodyBudget)...) + } + return packWrappedParts(parts, prefix, suffix, "\n\n", c.maxChars) +} + +func (c *ParagraphChunker) buildChunkSequence(parts []string, separator string) []string { + if len(parts) == 0 { + return nil + } + chunks := make([]string, 0, len(parts)) + current := "" + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if current == "" { + current = part + continue + } + candidate := joinChunkParts(current, part, separator) + if utf8.RuneCountInString(candidate) <= c.maxChars { + current = candidate + continue + } + chunks = append(chunks, current) + current = c.startChunkWithOverlap(current, part, separator) } if strings.TrimSpace(current) != "" { chunks = append(chunks, current) @@ -126,6 +336,22 @@ func (c *ParagraphChunker) splitText(content string) []string { return chunks } +func (c *ParagraphChunker) startChunkWithOverlap(previous string, next string, separator string) string { + next = strings.TrimSpace(next) + if next == "" { + return "" + } + overlap := c.chunkOverlap(previous) + if overlap == "" { + return next + } + candidate := joinChunkParts(overlap, next, separator) + if utf8.RuneCountInString(candidate) <= c.maxChars { + return candidate + } + return next +} + func (c *ParagraphChunker) chunkOverlap(value string) string { if c.overlapChars <= 0 { return "" @@ -137,6 +363,367 @@ func (c *ParagraphChunker) chunkOverlap(value string) string { return strings.TrimSpace(string(runes[len(runes)-c.overlapChars:])) } +func splitBlocks(content string) []chunkBlock { + content = normalizeChunkDocumentText(content) + if content == "" { + return nil + } + + lines := strings.Split(content, "\n") + blocks := make([]chunkBlock, 0, len(lines)) + for index := 0; index < len(lines); { + line := strings.TrimRight(lines[index], " \t") + trimmed := strings.TrimSpace(line) + if trimmed == "" { + index++ + continue + } + + switch { + case isFenceLine(trimmed): + start := index + index++ + for index < len(lines) { + if isFenceLine(strings.TrimSpace(lines[index])) { + index++ + break + } + index++ + } + blocks = append(blocks, chunkBlock{Kind: blockCode, Text: strings.Join(lines[start:index], "\n")}) + case isHeadingLine(trimmed): + blocks = append(blocks, chunkBlock{Kind: blockHeading, Text: trimmed}) + index++ + case isMarkdownTableStart(lines, index): + start := index + index += 2 + for index < len(lines) { + next := strings.TrimSpace(lines[index]) + if next == "" || (!strings.Contains(next, "|") && !isMarkdownTableDelimiterLine(next)) { + break + } + index++ + } + blocks = append(blocks, chunkBlock{Kind: blockTable, Text: strings.Join(lines[start:index], "\n")}) + case isListItemLine(trimmed): + start := index + index++ + for index < len(lines) { + next := strings.TrimSpace(lines[index]) + if next == "" || isFenceLine(next) || isHeadingLine(next) || isMarkdownTableStart(lines, index) { + break + } + index++ + } + blocks = append(blocks, chunkBlock{Kind: blockList, Text: strings.Join(lines[start:index], "\n")}) + default: + start := index + index++ + for index < len(lines) { + next := strings.TrimSpace(lines[index]) + if next == "" || isFenceLine(next) || isHeadingLine(next) || isMarkdownTableStart(lines, index) || isListItemLine(next) { + break + } + index++ + } + blocks = append(blocks, chunkBlock{Kind: blockProse, Text: strings.Join(lines[start:index], "\n")}) + } + } + return blocks +} + +// MergeChunkTextsWithOverlap 按字符 overlap 合并相邻 chunk,避免 recall 扩窗后重复注入。 +func MergeChunkTextsWithOverlap(parts []string, overlapChars int) string { + if len(parts) == 0 { + return "" + } + merged := "" + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if merged == "" { + merged = part + continue + } + merged += trimLeadingChunkOverlap(merged, part, overlapChars) + } + return strings.TrimSpace(merged) +} + +func trimLeadingChunkOverlap(previous string, current string, overlapChars int) string { + if current == "" || overlapChars <= 0 { + return current + } + prevRunes := []rune(previous) + currentRunes := []rune(current) + limit := overlapChars + if len(prevRunes) < limit { + limit = len(prevRunes) + } + if len(currentRunes) < limit { + limit = len(currentRunes) + } + for size := limit; size > 0; size-- { + if string(prevRunes[len(prevRunes)-size:]) == string(currentRunes[:size]) { + return string(currentRunes[size:]) + } + } + return current +} + +func splitNarrativeUnits(text string, maxChars int) []string { + text = normalizeInlineText(text) + if text == "" { + return nil + } + if maxChars <= 0 || utf8.RuneCountInString(text) <= maxChars { + return []string{text} + } + + strongUnits := splitByBoundaries(text, isStrongSentenceBoundary) + if len(strongUnits) == 0 { + strongUnits = []string{text} + } + + units := make([]string, 0, len(strongUnits)) + for _, unit := range strongUnits { + unit = normalizeInlineText(unit) + if unit == "" { + continue + } + if utf8.RuneCountInString(unit) <= maxChars { + units = append(units, unit) + continue + } + + softUnits := splitByBoundaries(unit, isSoftSentenceBoundary) + if len(softUnits) == 0 { + softUnits = []string{unit} + } + for _, softUnit := range softUnits { + softUnit = normalizeInlineText(softUnit) + if softUnit == "" { + continue + } + if utf8.RuneCountInString(softUnit) <= maxChars { + units = append(units, softUnit) + continue + } + units = append(units, splitByRuneWindow(softUnit, maxChars, 0)...) + } + } + return units +} + +func splitByBoundaries(text string, isBoundary func(rune) bool) []string { + if strings.TrimSpace(text) == "" { + return nil + } + parts := make([]string, 0, 8) + var builder strings.Builder + for _, r := range text { + builder.WriteRune(r) + if isBoundary(r) { + part := strings.TrimSpace(builder.String()) + if part != "" { + parts = append(parts, part) + } + builder.Reset() + } + } + tail := strings.TrimSpace(builder.String()) + if tail != "" { + parts = append(parts, tail) + } + return parts +} + +func splitListItems(text string) []chunkListItem { + lines := splitRawLines(text) + items := make([]chunkListItem, 0, len(lines)) + current := chunkListItem{} + flush := func() { + if strings.TrimSpace(current.Text) == "" { + current = chunkListItem{} + return + } + items = append(items, current) + current = chunkListItem{} + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + marker, body, ok := parseListMarker(trimmed) + if ok { + flush() + current = chunkListItem{Marker: marker, Text: body} + continue + } + if current.Text == "" { + current = chunkListItem{Marker: "-", Text: trimmed} + continue + } + current.Text = strings.TrimSpace(current.Text + " " + trimmed) + } + flush() + return items +} + +func parseListMarker(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if len(line) >= 2 { + switch line[0] { + case '-', '*', '+': + if line[1] == ' ' || line[1] == '\t' { + return string(line[0]), strings.TrimSpace(line[1:]), true + } + } + } + + index := 0 + for index < len(line) && line[index] >= '0' && line[index] <= '9' { + index++ + } + if index > 0 && index+1 < len(line) && (line[index] == '.' || line[index] == ')') && + (line[index+1] == ' ' || line[index+1] == '\t') { + return strings.TrimSpace(line[:index+1]), strings.TrimSpace(line[index+1:]), true + } + return "", "", false +} + +func splitCodeGroups(lines []string) []string { + groups := make([]string, 0, len(lines)) + current := make([]string, 0, len(lines)) + flush := func() { + if len(current) == 0 { + return + } + groups = append(groups, strings.Join(current, "\n")) + current = current[:0] + } + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + flush() + continue + } + current = append(current, strings.TrimRight(line, " \t")) + } + flush() + return groups +} + +func splitCodeGroupByLineWindow(group string, maxChars int) []string { + lines := splitRawLines(group) + if len(lines) == 0 { + return nil + } + parts := make([]string, 0, len(lines)) + current := "" + for _, line := range lines { + line = strings.TrimRight(line, " \t") + if line == "" { + continue + } + if utf8.RuneCountInString(line) > maxChars { + if strings.TrimSpace(current) != "" { + parts = append(parts, current) + current = "" + } + parts = append(parts, splitByRuneWindow(line, maxChars, 0)...) + continue + } + if current == "" { + current = line + continue + } + candidate := current + "\n" + line + if utf8.RuneCountInString(candidate) <= maxChars { + current = candidate + continue + } + parts = append(parts, current) + current = line + } + if strings.TrimSpace(current) != "" { + parts = append(parts, current) + } + return parts +} + +func packWrappedParts(parts []string, prefix string, suffix string, separator string, maxChars int) []string { + if len(parts) == 0 { + return nil + } + chunks := make([]string, 0, len(parts)) + current := "" + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + candidateBody := part + if current != "" { + candidateBody = joinChunkParts(current, part, separator) + } + candidate := wrapChunkBody(prefix, suffix, candidateBody) + if utf8.RuneCountInString(candidate) <= maxChars { + current = candidateBody + continue + } + if strings.TrimSpace(current) != "" { + chunks = append(chunks, wrapChunkBody(prefix, suffix, current)) + } + current = part + } + if strings.TrimSpace(current) != "" { + chunks = append(chunks, wrapChunkBody(prefix, suffix, current)) + } + return chunks +} + +func wrapChunkBody(prefix string, suffix string, body string) string { + body = strings.TrimSpace(body) + if prefix == "" && suffix == "" { + return body + } + var builder strings.Builder + if strings.TrimSpace(prefix) != "" { + builder.WriteString(strings.TrimSpace(prefix)) + } + if body != "" { + if builder.Len() > 0 { + builder.WriteString("\n") + } + builder.WriteString(body) + } + if strings.TrimSpace(suffix) != "" { + if builder.Len() > 0 { + builder.WriteString("\n") + } + builder.WriteString(strings.TrimSpace(suffix)) + } + return strings.TrimSpace(builder.String()) +} + +func joinChunkParts(left string, right string, separator string) string { + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + switch { + case left == "": + return right + case right == "": + return left + default: + return strings.TrimSpace(left + separator + right) + } +} + func splitByRuneWindow(value string, maxChars int, overlap int) []string { runes := []rune(value) if len(runes) <= maxChars { @@ -163,32 +750,89 @@ func splitByRuneWindow(value string, maxChars int, overlap int) []string { return chunks } -func splitParagraphs(value string) []string { +func splitRawLines(value string) []string { + value = normalizeChunkDocumentText(value) + if value == "" { + return nil + } + raw := strings.Split(value, "\n") + lines := make([]string, 0, len(raw)) + for _, line := range raw { + lines = append(lines, strings.TrimRight(line, " \t")) + } + return lines +} + +func normalizeChunkDocumentText(value string) string { value = strings.ReplaceAll(value, "\r\n", "\n") - raw := strings.Split(value, "\n\n") - paragraphs := make([]string, 0, len(raw)) - for _, item := range raw { - item = normalizeChunkText(item) - if item != "" { - paragraphs = append(paragraphs, item) - } + return strings.TrimSpace(value) +} + +func normalizeInlineText(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func isFenceLine(line string) bool { + return strings.HasPrefix(strings.TrimSpace(line), "```") +} + +func isHeadingLine(line string) bool { + line = strings.TrimSpace(line) + return strings.HasPrefix(line, "#") +} + +func isListItemLine(line string) bool { + _, _, ok := parseListMarker(line) + return ok +} + +func isMarkdownTableStart(lines []string, index int) bool { + if index < 0 || index+1 >= len(lines) { + return false } - if len(paragraphs) == 0 { - return []string{normalizeChunkText(value)} + current := strings.TrimSpace(lines[index]) + next := strings.TrimSpace(lines[index+1]) + if current == "" || next == "" { + return false } - return paragraphs + return strings.Contains(current, "|") && isMarkdownTableDelimiterLine(next) } -func normalizeChunkText(value string) string { - lines := strings.Split(strings.TrimSpace(value), "\n") - normalized := make([]string, 0, len(lines)) - for _, line := range lines { - line = strings.Join(strings.Fields(strings.TrimSpace(line)), " ") - if line != "" { - normalized = append(normalized, line) +func isMarkdownTableDelimiterLine(line string) bool { + line = strings.TrimSpace(line) + if line == "" { + return false + } + trimmed := strings.ReplaceAll(line, "|", "") + trimmed = strings.ReplaceAll(trimmed, " ", "") + trimmed = strings.ReplaceAll(trimmed, "\t", "") + if trimmed == "" || !strings.Contains(trimmed, "-") { + return false + } + for _, r := range trimmed { + if r != '-' && r != ':' { + return false } } - return strings.TrimSpace(strings.Join(normalized, "\n")) + return true +} + +func isStrongSentenceBoundary(r rune) bool { + switch r { + case '。', '!', '?', ';', '.', '!', '?', ';': + return true + default: + return false + } +} + +func isSoftSentenceBoundary(r rune) bool { + switch r { + case ',', ',', ':', ':': + return true + default: + return false + } } func estimateChunkTokens(value string) int { diff --git a/internal/infrastructure/ai/memory/chunker_test.go b/internal/infrastructure/ai/memory/chunker_test.go index c84d123..99fc072 100644 --- a/internal/infrastructure/ai/memory/chunker_test.go +++ b/internal/infrastructure/ai/memory/chunker_test.go @@ -45,6 +45,105 @@ func TestParagraphChunkerLongTextWithOverlap(t *testing.T) { } } +func TestParagraphChunkerProsePrefersStrongSentenceBoundary(t *testing.T) { + chunker := NewParagraphChunker(ChunkerOptions{MaxChars: 18, OverlapChars: 0}) + chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ + ID: "doc-prose-strong", + Content: "第一句很短,包含逗号。第二句也很短。第三句继续。", + }) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + if len(chunks) < 2 { + t.Fatalf("chunks len = %d, want at least 2", len(chunks)) + } + if chunks[0].ContentText != "第一句很短,包含逗号。" { + t.Fatalf("first chunk = %q, want strong sentence split", chunks[0].ContentText) + } +} + +func TestParagraphChunkerFallsBackToSoftBoundaryForLongSentence(t *testing.T) { + chunker := NewParagraphChunker(ChunkerOptions{MaxChars: 12, OverlapChars: 0}) + chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ + ID: "doc-prose-soft", + Content: "这是一个很长的句子,需要在逗号这里切开,因为整句放不下。", + }) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + if len(chunks) < 2 { + t.Fatalf("chunks len = %d, want at least 2", len(chunks)) + } + if chunks[0].ContentText != "这是一个很长的句子," { + t.Fatalf("first chunk = %q, want soft boundary split", chunks[0].ContentText) + } +} + +func TestParagraphChunkerKeepsCodeFenceMultiline(t *testing.T) { + chunker := NewParagraphChunker(ChunkerOptions{MaxChars: 40, OverlapChars: 0}) + chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ + ID: "doc-code", + Content: "```go\nfunc alpha() {\n println(\"a\")\n}\n\nfunc beta() {\n println(\"b\")\n}\n```", + }) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + if len(chunks) < 2 { + t.Fatalf("chunks len = %d, want at least 2", len(chunks)) + } + if !strings.Contains(chunks[0].ContentText, "```go\nfunc alpha()") { + t.Fatalf("first code chunk lost multiline code fence:\n%s", chunks[0].ContentText) + } + if !strings.Contains(chunks[1].ContentText, "```go") { + t.Fatalf("second code chunk should repeat fence:\n%s", chunks[1].ContentText) + } +} + +func TestParagraphChunkerRepeatsTableHeaderWhenSplit(t *testing.T) { + chunker := NewParagraphChunker(ChunkerOptions{MaxChars: 48, OverlapChars: 0}) + chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ + ID: "doc-table", + Content: "| 列1 | 列2 |\n| --- | --- |\n| 行1 | 数据1 |\n| 行2 | 数据2 |\n| 行3 | 数据3 |", + }) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + if len(chunks) < 2 { + t.Fatalf("chunks len = %d, want at least 2", len(chunks)) + } + for _, chunk := range chunks { + if !strings.Contains(chunk.ContentText, "| 列1 | 列2 |") || !strings.Contains(chunk.ContentText, "| --- | --- |") { + t.Fatalf("table chunk missing repeated header:\n%s", chunk.ContentText) + } + } +} + +func TestParagraphChunkerKeepsListItemsAtomic(t *testing.T) { + chunker := NewParagraphChunker(ChunkerOptions{MaxChars: 18, OverlapChars: 0}) + chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ + ID: "doc-list", + Content: "- 第一项说明\n- 第二项说明\n- 第三项说明", + }) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + if len(chunks) < 2 { + t.Fatalf("chunks len = %d, want at least 2", len(chunks)) + } + for _, chunk := range chunks { + lines := strings.Split(chunk.ContentText, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if !strings.HasPrefix(line, "- ") { + t.Fatalf("list item was split without bullet marker: %q", line) + } + } + } +} + func TestParagraphChunkerEmptyText(t *testing.T) { chunker := NewParagraphChunker(ChunkerOptions{}) chunks, err := chunker.Chunk(context.Background(), aidomain.MemoryDocumentForIndex{ID: "doc-empty", Content: " "}) diff --git a/internal/infrastructure/ai/memory/embedder.go b/internal/infrastructure/ai/memory/embedder.go index b133a0a..5a40a10 100644 --- a/internal/infrastructure/ai/memory/embedder.go +++ b/internal/infrastructure/ai/memory/embedder.go @@ -103,7 +103,9 @@ func (e *DashScopeEmbedder) Embed( if err != nil { return aidomain.MemoryEmbeddingResult{}, err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { return aidomain.MemoryEmbeddingResult{}, err diff --git a/internal/infrastructure/ai/memory/extractor.go b/internal/infrastructure/ai/memory/extractor.go index 64fb0e7..b478bdc 100644 --- a/internal/infrastructure/ai/memory/extractor.go +++ b/internal/infrastructure/ai/memory/extractor.go @@ -69,32 +69,33 @@ func (e *RuleExtractor) Extract( } func (e *RuleExtractor) buildSummary(input aidomain.MemoryExtractionInput) *aidomain.ConversationSummaryDraft { - userText := normalizeText(input.UserMessage.Content) - assistantText := normalizeText(input.AssistantMessage.Content) - if userText == "" && assistantText == "" { + recentMessages := buildSummaryRecentMessages(input) + if len(recentMessages) == 0 { return nil } - parts := make([]string, 0, 3) - if previous := normalizeText(input.PreviousSummaryText); previous != "" { - parts = append(parts, previous) + parts := make([]string, 0, 2) + recentSummary := renderSummaryRecentMessages(recentMessages) + if recentSummary != "" { + parts = append(parts, "最新进展:\n"+recentSummary) } - turn := strings.TrimSpace("用户: " + truncateRunes(userText, 320) + "\n助手: " + truncateRunes(assistantText, 900)) - if turn != "" { - parts = append(parts, "最近一轮: "+turn) + if previous := normalizeText(input.PreviousSummaryText); previous != "" && + input.SummaryRefreshMode == aidomain.MemorySummaryRefreshModeFullRefresh { + parts = append(parts, "历史摘要:\n"+previous) } summaryText := truncateRunes(strings.Join(parts, "\n\n"), e.summaryMaxRunes) if summaryText == "" { return nil } - keyPoints, _ := json.Marshal([]string{truncateRunes(userText, 160)}) + keyPoints, _ := json.Marshal(buildSummaryKeyPoints(recentMessages)) + openLoops, _ := json.Marshal(buildSummaryOpenLoops(recentMessages)) return &aidomain.ConversationSummaryDraft{ ConversationID: input.ConversationID, CompressedUntilMessageID: input.AssistantMessage.ID, SummaryText: summaryText, KeyPointsJSON: string(keyPoints), - OpenLoopsJSON: "[]", + OpenLoopsJSON: string(openLoops), TokenEstimate: estimateTokens(summaryText), } } @@ -255,3 +256,103 @@ func estimateTokens(value string) int { } return (runes + 3) / 4 } + +func buildSummaryRecentMessages(input aidomain.MemoryExtractionInput) []aidomain.Message { + if len(input.RecentMessages) > 0 { + return input.RecentMessages + } + messages := make([]aidomain.Message, 0, 2) + if strings.TrimSpace(input.UserMessage.Content) != "" { + messages = append(messages, input.UserMessage) + } + if strings.TrimSpace(input.AssistantMessage.Content) != "" { + messages = append(messages, input.AssistantMessage) + } + return messages +} + +func renderSummaryRecentMessages(messages []aidomain.Message) string { + if len(messages) == 0 { + return "" + } + if len(messages) > 6 { + messages = messages[len(messages)-6:] + } + lines := make([]string, 0, len(messages)) + for _, message := range messages { + content := normalizeText(message.Content) + if content == "" { + continue + } + roleLabel := "用户" + if message.Role == aidomain.RoleAssistant { + roleLabel = "助手" + content = firstParagraph(content) + } + lines = append(lines, roleLabel+": "+truncateRunes(content, 240)) + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func buildSummaryKeyPoints(messages []aidomain.Message) []string { + items := make([]string, 0, 4) + seen := map[string]struct{}{} + for i := len(messages) - 1; i >= 0; i-- { + message := messages[i] + if message.Role != aidomain.RoleAssistant { + continue + } + content := truncateRunes(normalizeText(firstParagraph(message.Content)), 180) + if content == "" { + continue + } + if _, ok := seen[content]; ok { + continue + } + seen[content] = struct{}{} + items = append(items, content) + if len(items) >= 4 { + break + } + } + if len(items) == 0 { + for i := len(messages) - 1; i >= 0; i-- { + content := truncateRunes(normalizeText(messages[i].Content), 180) + if content != "" { + items = append(items, content) + break + } + } + } + return items +} + +func buildSummaryOpenLoops(messages []aidomain.Message) []string { + items := make([]string, 0, 2) + for i := len(messages) - 1; i >= 0; i-- { + message := messages[i] + if message.Role != aidomain.RoleUser { + continue + } + content := truncateRunes(normalizeText(message.Content), 180) + if content == "" || !looksLikeOpenLoop(content) { + continue + } + items = append(items, content) + break + } + return items +} + +func looksLikeOpenLoop(content string) bool { + if strings.Contains(content, "?") || strings.Contains(content, "?") { + return true + } + keywords := []string{"怎么", "如何", "下一步", "还需要", "帮我", "请给我", "是否"} + for _, keyword := range keywords { + if strings.Contains(content, keyword) { + return true + } + } + return false +} diff --git a/internal/infrastructure/ai/memory/llm_extractor.go b/internal/infrastructure/ai/memory/llm_extractor.go new file mode 100644 index 0000000..c966b5d --- /dev/null +++ b/internal/infrastructure/ai/memory/llm_extractor.go @@ -0,0 +1,503 @@ +package memory + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + einomodel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + + aidomain "personal_assistant/internal/domain/ai" + infraeino "personal_assistant/internal/infrastructure/ai/eino" +) + +const ( + defaultLLMExtractorTimeout = 20 * time.Second + defaultLLMExtractorMaxInputRunes = 6000 + defaultLLMDocumentContentMaxRunes = 1200 + copiedAssistantDocumentMinRunes = 240 +) + +// LLMExtractorOptions configures the memory candidate extractor without reading global config. +type LLMExtractorOptions struct { + Provider string + APIKey string + BaseURL string + Model string + ByAzure bool + APIVersion string + SystemPrompt string + Temperature float64 + MaxCompletionTokens int + Timeout time.Duration + MaxInputChars int + ChatModel einomodel.BaseChatModel +} + +// LLMExtractor asks a chat model to propose memory candidates; policy code still makes final decisions. +type LLMExtractor struct { + model einomodel.BaseChatModel + systemPrompt string + timeout time.Duration + maxInputRunes int +} + +// NewLLMExtractor creates a real LLM-backed memory extractor. +func NewLLMExtractor(ctx context.Context, opts LLMExtractorOptions) (*LLMExtractor, error) { + model := opts.ChatModel + if model == nil { + created, err := infraeino.NewChatModel(ctx, infraeino.Options{ + Provider: opts.Provider, + APIKey: opts.APIKey, + BaseURL: opts.BaseURL, + Model: opts.Model, + ByAzure: opts.ByAzure, + APIVersion: opts.APIVersion, + Temperature: opts.Temperature, + MaxCompletionTokens: opts.MaxCompletionTokens, + }) + if err != nil { + return nil, err + } + model = created + } + + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultLLMExtractorTimeout + } + maxInputRunes := opts.MaxInputChars + if maxInputRunes <= 0 { + maxInputRunes = defaultLLMExtractorMaxInputRunes + } + systemPrompt := strings.TrimSpace(opts.SystemPrompt) + if systemPrompt == "" { + systemPrompt = "你是 personal_assistant 的记忆候选提议器。你只输出 JSON,不输出解释。" + } + + return &LLMExtractor{ + model: model, + systemPrompt: systemPrompt, + timeout: timeout, + maxInputRunes: maxInputRunes, + }, nil +} + +// Extract asks the model for memory candidates and converts them to domain proposals. +func (e *LLMExtractor) Extract( + ctx context.Context, + input aidomain.MemoryExtractionInput, +) (aidomain.MemoryExtractionResult, error) { + if e == nil || e.model == nil { + return aidomain.MemoryExtractionResult{}, fmt.Errorf("llm memory extractor model is nil") + } + select { + case <-ctx.Done(): + return aidomain.MemoryExtractionResult{}, ctx.Err() + default: + } + + callCtx := ctx + cancel := func() {} + if e.timeout > 0 { + callCtx, cancel = context.WithTimeout(ctx, e.timeout) + } + defer cancel() + + msg, err := e.model.Generate(callCtx, []*schema.Message{ + schema.SystemMessage(e.systemPrompt), + schema.UserMessage(e.buildPrompt(input)), + }) + if err != nil { + return aidomain.MemoryExtractionResult{}, err + } + if msg == nil || strings.TrimSpace(msg.Content) == "" { + return aidomain.MemoryExtractionResult{}, fmt.Errorf("llm memory extractor returned empty content") + } + + var output llmMemoryExtractionOutput + if err := unmarshalLLMExtractorJSON(msg.Content, &output); err != nil { + return aidomain.MemoryExtractionResult{}, err + } + return e.toExtractionResult(input, output), nil +} + +func (e *LLMExtractor) buildPrompt(input aidomain.MemoryExtractionInput) string { + payload := llmMemoryPromptPayload{ + ConversationID: input.ConversationID, + UserID: input.UserID, + PreviousSummaryText: truncateRunes(input.PreviousSummaryText, e.maxInputRunes), + SummaryRefreshMode: string(input.SummaryRefreshMode), + UserMessage: llmMemoryPromptMessage{ + ID: input.UserMessage.ID, + Role: input.UserMessage.Role, + Content: truncateRunes(input.UserMessage.Content, e.maxInputRunes), + }, + AssistantMessage: llmMemoryPromptMessage{ + ID: input.AssistantMessage.ID, + Role: input.AssistantMessage.Role, + Content: truncateRunes(input.AssistantMessage.Content, e.maxInputRunes), + }, + RecentMessages: buildLLMRecentPromptMessages(input.RecentMessages, e.maxInputRunes), + } + payloadJSON, _ := json.MarshalIndent(payload, "", " ") + return strings.TrimSpace(fmt.Sprintf(` +你需要从一次已完成的用户/助手对话中提议候选记忆。 + +治理边界: +1. 你只负责提议候选,不决定最终 scope、visibility、TTL、dedup key、覆盖和落库。 +2. 第一版只允许提议当前用户自己的 self 记忆,不要提议 org 或 platform_ops。 +3. 不要记录原始 trace、完整工具输出、完整日志、中间推理、闲聊和瞬时状态数字。 +4. 只把稳定偏好、阶段目标、用户画像、可复用知识摘要提议为记忆。 +5. ttl_hint 只能输出受控时间语义,不要输出最终 expires_at。 +6. 只输出 JSON 对象,不要输出 markdown、不要输出解释。 +7. summary_refresh_mode=head_update 时,重点产出最新 decisions 和 open loops,不要重写整段长期背景。 +8. summary_refresh_mode=full_refresh 时,允许基于 previous_summary_text + recent_messages 重建完整摘要。 + +document 提取规则: +1. document 只用于可复用方案、设计、FAQ、runbook、排障步骤、项目经验。 +2. 不要把用户偏好、阶段目标、会话主线、open loops 放进 document;这些分别属于 facts 或 summary。 +3. content 必须是清洗浓缩后的可语义召回正文,需要去掉寒暄、过渡句、本轮上下文依赖和临场解释。 +4. 不要复制整段 assistant answer;只保留可以脱离本轮对话独立理解的长期知识。 +5. 如果没有长期可复用知识,documents 必须输出空数组 []。 + +输出 JSON schema: +{ + "summary": { + "summary_text": "可选,会话摘要", + "key_points": ["可选,关键点"], + "open_loops": ["可选,待跟进事项"] + }, + "facts": [ + { + "namespace": "user_preference | oj_profile | oj_goal", + "fact_key": "稳定的 snake_case key", + "value": "简洁事实值", + "summary": "可选,人类可读摘要", + "confidence": 0.0, + "ttl_hint": { + "kind": "default | persistent | duration | until_date | session_only", + "value": 30, + "unit": "day", + "until_date": "YYYY-MM-DD,仅 kind=until_date 时填写", + "reason": "可选,简短解释时间语义来源", + "confidence": 0.0 + } + } + ], + "documents": [ + { + "memory_type": "semantic | procedural | faq | episodic", + "topic": "简短主题", + "title": "标题", + "summary": "摘要", + "content": "清洗浓缩后的可语义召回正文,建议 300-800 字,最长 1200 字符", + "confidence": 0.0, + "ttl_hint": { + "kind": "default | persistent | duration | until_date | session_only", + "value": 30, + "unit": "day", + "until_date": "YYYY-MM-DD,仅 kind=until_date 时填写", + "reason": "可选,简短解释时间语义来源", + "confidence": 0.0 + } + } + ] +} + +待分析输入: +%s`, string(payloadJSON))) +} + +func (e *LLMExtractor) toExtractionResult( + input aidomain.MemoryExtractionInput, + output llmMemoryExtractionOutput, +) aidomain.MemoryExtractionResult { + result := aidomain.MemoryExtractionResult{ + Summary: buildLLMSummaryDraft(input, output.Summary), + Facts: buildLLMFactCandidates(input, output.Facts), + Documents: buildLLMDocumentCandidates(input, output.Documents), + } + return result +} + +type llmMemoryPromptPayload struct { + ConversationID string `json:"conversation_id"` + UserID uint `json:"user_id"` + PreviousSummaryText string `json:"previous_summary_text"` + SummaryRefreshMode string `json:"summary_refresh_mode"` + UserMessage llmMemoryPromptMessage `json:"user_message"` + AssistantMessage llmMemoryPromptMessage `json:"assistant_message"` + RecentMessages []llmMemoryPromptMessage `json:"recent_messages,omitempty"` +} + +type llmMemoryPromptMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` +} + +type llmMemoryExtractionOutput struct { + Summary *llmSummaryCandidate `json:"summary"` + Facts []llmFactCandidate `json:"facts"` + Documents []llmDocumentCandidate `json:"documents"` +} + +type llmSummaryCandidate struct { + SummaryText string `json:"summary_text"` + KeyPoints []string `json:"key_points"` + OpenLoops []string `json:"open_loops"` +} + +type llmFactCandidate struct { + Namespace string `json:"namespace"` + FactKey string `json:"fact_key"` + Value string `json:"value"` + Summary string `json:"summary"` + Confidence float64 `json:"confidence"` + TTLHint *llmTTLHint `json:"ttl_hint"` +} + +type llmDocumentCandidate struct { + MemoryType string `json:"memory_type"` + Topic string `json:"topic"` + Title string `json:"title"` + Summary string `json:"summary"` + Content string `json:"content"` + Confidence float64 `json:"confidence"` + TTLHint *llmTTLHint `json:"ttl_hint"` +} + +type llmTTLHint struct { + Kind string `json:"kind"` + Value int `json:"value"` + Unit string `json:"unit"` + UntilDate string `json:"until_date"` + Reason string `json:"reason"` + Confidence float64 `json:"confidence"` +} + +func buildLLMSummaryDraft( + input aidomain.MemoryExtractionInput, + candidate *llmSummaryCandidate, +) *aidomain.ConversationSummaryDraft { + if candidate == nil || strings.TrimSpace(candidate.SummaryText) == "" { + return nil + } + keyPoints, _ := json.Marshal(normalizeLLMStringList(candidate.KeyPoints)) + openLoops, _ := json.Marshal(normalizeLLMStringList(candidate.OpenLoops)) + summaryText := normalizeText(candidate.SummaryText) + return &aidomain.ConversationSummaryDraft{ + ConversationID: input.ConversationID, + CompressedUntilMessageID: input.AssistantMessage.ID, + SummaryText: summaryText, + KeyPointsJSON: string(keyPoints), + OpenLoopsJSON: string(openLoops), + TokenEstimate: estimateTokens(summaryText), + } +} + +func buildLLMFactCandidates( + input aidomain.MemoryExtractionInput, + candidates []llmFactCandidate, +) []aidomain.MemoryFactCandidate { + if input.UserID == 0 || len(candidates) == 0 { + return nil + } + userID := input.UserID + facts := make([]aidomain.MemoryFactCandidate, 0, len(candidates)) + for _, item := range candidates { + namespace := normalizeLLMFactNamespace(item.Namespace) + factKey := normalizeLLMFactKey(item.FactKey) + value := truncateRunes(normalizeText(item.Value), 500) + if namespace == "" || factKey == "" || value == "" { + continue + } + payload, _ := json.Marshal(map[string]string{"value": value}) + summary := truncateRunes(normalizeText(firstNonEmptyMemoryString(item.Summary, value)), 500) + facts = append(facts, aidomain.MemoryFactCandidate{ + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + Namespace: namespace, + FactKey: factKey, + FactValueJSON: string(payload), + Summary: summary, + Confidence: item.Confidence, + TTLHint: buildLLMTTLHint(item.TTLHint), + SourceKind: aidomain.MemorySourceModelInferred, + SourceID: input.AssistantMessage.ID, + LowValue: len([]rune(value)) < 2 || isLowConfidenceLLMValue(item.Confidence) || isSessionOnlyLLMHint(item.TTLHint), + }) + } + return facts +} + +func buildLLMDocumentCandidates( + input aidomain.MemoryExtractionInput, + candidates []llmDocumentCandidate, +) []aidomain.MemoryDocumentCandidate { + if input.UserID == 0 || len(candidates) == 0 { + return nil + } + userID := input.UserID + docs := make([]aidomain.MemoryDocumentCandidate, 0, len(candidates)) + for _, item := range candidates { + rawContent := normalizeText(item.Content) + summary := truncateRunes(normalizeText(item.Summary), defaultDocumentSummaryMaxRunes) + content := truncateRunes(rawContent, defaultLLMDocumentContentMaxRunes) + if content == "" { + content = summary + } + if content == "" { + continue + } + copiedAssistant := isCopiedAssistantDocumentContent(rawContent, input.AssistantMessage.Content) + docs = append(docs, aidomain.MemoryDocumentCandidate{ + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + MemoryType: normalizeLLMMemoryType(item.MemoryType), + Topic: truncateRunes(normalizeText(firstNonEmptyMemoryString(item.Topic, "conversation_knowledge")), 120), + Title: truncateRunes(normalizeText(firstNonEmptyMemoryString(item.Title, item.Topic)), 120), + Summary: summary, + ContentText: content, + Confidence: item.Confidence, + TTLHint: buildLLMTTLHint(item.TTLHint), + SourceKind: aidomain.MemorySourceModelInferred, + SourceID: input.AssistantMessage.ID, + LowValue: (len([]rune(content)) < 20 && len([]rune(summary)) < 10) || copiedAssistant || isLowConfidenceLLMValue(item.Confidence) || isSessionOnlyLLMHint(item.TTLHint), + }) + } + return docs +} + +func unmarshalLLMExtractorJSON(raw string, target any) error { + trimmed := strings.TrimSpace(raw) + if strings.HasPrefix(trimmed, "```") { + trimmed = strings.TrimPrefix(trimmed, "```json") + trimmed = strings.TrimPrefix(trimmed, "```JSON") + trimmed = strings.TrimPrefix(trimmed, "```") + trimmed = strings.TrimSuffix(strings.TrimSpace(trimmed), "```") + trimmed = strings.TrimSpace(trimmed) + } + start := strings.Index(trimmed, "{") + end := strings.LastIndex(trimmed, "}") + if start >= 0 && end > start { + trimmed = trimmed[start : end+1] + } + if trimmed == "" { + return fmt.Errorf("llm memory extractor output is empty") + } + if err := json.Unmarshal([]byte(trimmed), target); err != nil { + return fmt.Errorf("invalid llm memory extractor json: %w", err) + } + return nil +} + +func normalizeLLMFactNamespace(namespace string) string { + switch strings.TrimSpace(namespace) { + case aidomain.MemoryNamespaceUserPreference: + return aidomain.MemoryNamespaceUserPreference + case aidomain.MemoryNamespaceOJProfile: + return aidomain.MemoryNamespaceOJProfile + case aidomain.MemoryNamespaceOJGoal: + return aidomain.MemoryNamespaceOJGoal + default: + return "" + } +} + +func normalizeLLMMemoryType(memoryType string) aidomain.MemoryType { + switch aidomain.MemoryType(strings.TrimSpace(memoryType)) { + case aidomain.MemoryTypeSemantic: + return aidomain.MemoryTypeSemantic + case aidomain.MemoryTypeProcedural: + return aidomain.MemoryTypeProcedural + case aidomain.MemoryTypeFAQ: + return aidomain.MemoryTypeFAQ + case aidomain.MemoryTypeEpisodic: + return aidomain.MemoryTypeEpisodic + default: + return aidomain.MemoryTypeSemantic + } +} + +func buildLLMTTLHint(input *llmTTLHint) *aidomain.MemoryTTLHint { + if input == nil { + return nil + } + kind := aidomain.MemoryTTLHintKind(strings.TrimSpace(input.Kind)) + if kind == "" { + kind = aidomain.MemoryTTLHintDefault + } + return &aidomain.MemoryTTLHint{ + Kind: kind, + Value: input.Value, + Unit: strings.TrimSpace(input.Unit), + UntilDate: strings.TrimSpace(input.UntilDate), + Reason: truncateRunes(normalizeText(input.Reason), 240), + Confidence: input.Confidence, + } +} + +func isLowConfidenceLLMValue(confidence float64) bool { + return confidence > 0 && confidence < 0.5 +} + +func isSessionOnlyLLMHint(input *llmTTLHint) bool { + return input != nil && strings.TrimSpace(input.Kind) == string(aidomain.MemoryTTLHintSessionOnly) +} + +func isCopiedAssistantDocumentContent(content string, assistantContent string) bool { + content = normalizeText(content) + if len([]rune(content)) <= copiedAssistantDocumentMinRunes { + return false + } + return content == normalizeText(assistantContent) +} + +func normalizeLLMFactKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + return strings.Join(strings.Fields(value), "_") +} + +func normalizeLLMStringList(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = normalizeText(value) + if value != "" { + out = append(out, value) + } + } + return out +} + +func firstNonEmptyMemoryString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func buildLLMRecentPromptMessages(messages []aidomain.Message, maxInputRunes int) []llmMemoryPromptMessage { + if len(messages) == 0 { + return nil + } + items := make([]llmMemoryPromptMessage, 0, len(messages)) + for _, message := range messages { + content := truncateRunes(message.Content, maxInputRunes) + if strings.TrimSpace(content) == "" { + continue + } + items = append(items, llmMemoryPromptMessage{ + ID: message.ID, + Role: message.Role, + Content: content, + }) + } + return items +} diff --git a/internal/infrastructure/ai/memory/llm_extractor_test.go b/internal/infrastructure/ai/memory/llm_extractor_test.go new file mode 100644 index 0000000..e082981 --- /dev/null +++ b/internal/infrastructure/ai/memory/llm_extractor_test.go @@ -0,0 +1,298 @@ +package memory + +import ( + "context" + "errors" + "strings" + "testing" + + einomodel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + + aidomain "personal_assistant/internal/domain/ai" +) + +type fakeLLMExtractorChatModel struct { + generateMsg *schema.Message + generateErr error + generateInputs [][]*schema.Message +} + +func (m *fakeLLMExtractorChatModel) Generate( + ctx context.Context, + input []*schema.Message, + opts ...einomodel.Option, +) (*schema.Message, error) { + _ = opts + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + cloned := make([]*schema.Message, len(input)) + copy(cloned, input) + m.generateInputs = append(m.generateInputs, cloned) + if m.generateErr != nil { + return nil, m.generateErr + } + return m.generateMsg, nil +} + +func (m *fakeLLMExtractorChatModel) Stream( + ctx context.Context, + input []*schema.Message, + opts ...einomodel.Option, +) (*schema.StreamReader[*schema.Message], error) { + _ = ctx + _ = input + _ = opts + return schema.StreamReaderFromArray([]*schema.Message{}), nil +} + +func TestLLMExtractorExtractsStructuredCandidates(t *testing.T) { + model := &fakeLLMExtractorChatModel{ + generateMsg: schema.AssistantMessage(`{ + "summary": { + "summary_text": "用户希望回答更简洁,并询问记忆写回设计。", + "key_points": ["偏好简洁回答"], + "open_loops": ["继续完善 writeback"] + }, + "facts": [ + { + "namespace": "user_preference", + "fact_key": "answer_style", + "value": "更简洁", + "summary": "用户偏好简洁回答", + "confidence": 0.92, + "ttl_hint": { + "kind": "persistent", + "reason": "用户表达的是长期回答偏好", + "confidence": 0.9 + } + } + ], + "documents": [ + { + "memory_type": "semantic", + "topic": "memory_writeback", + "title": "记忆写回治理", + "summary": "LLM 提议候选,policy 裁决。", + "content": "Prompt 负责治理意图,Policy 负责治理裁决。", + "confidence": 0.88, + "ttl_hint": { + "kind": "duration", + "value": 30, + "unit": "day", + "reason": "示例知识阶段性有效", + "confidence": 0.81 + } + } + ] + }`, nil), + } + extractor := newTestLLMExtractor(t, model) + + result, err := extractor.Extract(context.Background(), buildLLMExtractorTestInput()) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if result.Summary == nil || !strings.Contains(result.Summary.SummaryText, "更简洁") { + t.Fatalf("summary = %#v, want concise preference", result.Summary) + } + if len(result.Facts) != 1 { + t.Fatalf("facts len = %d, want 1", len(result.Facts)) + } + fact := result.Facts[0] + if fact.ScopeType != aidomain.MemoryScopeSelf || fact.SourceKind != aidomain.MemorySourceModelInferred { + t.Fatalf("fact scope/source = %s/%s, want self/model_inferred", fact.ScopeType, fact.SourceKind) + } + if fact.Namespace != aidomain.MemoryNamespaceUserPreference || fact.FactKey != "answer_style" { + t.Fatalf("fact = %#v, want user_preference answer_style", fact) + } + if fact.Confidence != 0.92 || fact.TTLHint == nil || fact.TTLHint.Kind != aidomain.MemoryTTLHintPersistent { + t.Fatalf("fact confidence/ttl_hint = %v/%#v, want persistent hint", fact.Confidence, fact.TTLHint) + } + if len(result.Documents) != 1 { + t.Fatalf("documents len = %d, want 1", len(result.Documents)) + } + doc := result.Documents[0] + if doc.ScopeType != aidomain.MemoryScopeSelf || doc.SourceKind != aidomain.MemorySourceModelInferred { + t.Fatalf("doc scope/source = %s/%s, want self/model_inferred", doc.ScopeType, doc.SourceKind) + } + if doc.MemoryType != aidomain.MemoryTypeSemantic { + t.Fatalf("doc memory_type = %q, want semantic", doc.MemoryType) + } + if doc.Confidence != 0.88 || doc.TTLHint == nil || doc.TTLHint.Value != 30 { + t.Fatalf("doc confidence/ttl_hint = %v/%#v, want duration 30 days", doc.Confidence, doc.TTLHint) + } + if doc.ContentText != "Prompt 负责治理意图,Policy 负责治理裁决。" { + t.Fatalf("doc content = %q, want condensed document content", doc.ContentText) + } +} + +func TestLLMExtractorPromptRequiresCondensedDocuments(t *testing.T) { + extractor := &LLMExtractor{maxInputRunes: 200} + + prompt := extractor.buildPrompt(buildLLMExtractorTestInput()) + for _, want := range []string{ + "document 只用于可复用方案、设计、FAQ、runbook、排障步骤、项目经验", + "不要复制整段 assistant answer", + "如果没有长期可复用知识,documents 必须输出空数组 []", + "清洗浓缩后的可语义召回正文", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt does not contain %q:\n%s", want, prompt) + } + } +} + +func TestLLMDocumentCandidatesTruncateLongContent(t *testing.T) { + longContent := strings.Repeat("知", defaultLLMDocumentContentMaxRunes+20) + + docs := buildLLMDocumentCandidates(buildLLMExtractorTestInput(), []llmDocumentCandidate{ + { + MemoryType: "semantic", + Topic: "memory_writeback", + Title: "记忆写回治理", + Summary: "长期知识摘要", + Content: longContent, + Confidence: 0.9, + }, + }) + + if len(docs) != 1 { + t.Fatalf("documents len = %d, want 1", len(docs)) + } + if got := len([]rune(docs[0].ContentText)); got != defaultLLMDocumentContentMaxRunes { + t.Fatalf("content runes = %d, want %d", got, defaultLLMDocumentContentMaxRunes) + } +} + +func TestLLMDocumentCandidatesMarkCopiedAssistantAnswerLowValue(t *testing.T) { + input := buildLLMExtractorTestInput() + input.AssistantMessage.Content = strings.Repeat("完整回答", 90) + + docs := buildLLMDocumentCandidates(input, []llmDocumentCandidate{ + { + MemoryType: "semantic", + Topic: "memory_writeback", + Title: "记忆写回治理", + Summary: "长期知识摘要", + Content: input.AssistantMessage.Content, + Confidence: 0.9, + }, + }) + + if len(docs) != 1 { + t.Fatalf("documents len = %d, want 1", len(docs)) + } + if !docs[0].LowValue { + t.Fatal("document LowValue = false, want true for copied assistant answer") + } +} + +func TestLLMDocumentCandidatesUseSummaryFallbackAndSkipEmpty(t *testing.T) { + docs := buildLLMDocumentCandidates(buildLLMExtractorTestInput(), []llmDocumentCandidate{ + { + MemoryType: "semantic", + Topic: "memory_writeback", + Title: "记忆写回治理", + Summary: "摘要可作为兜底正文", + Confidence: 0.9, + }, + { + MemoryType: "semantic", + Topic: "empty", + Title: "空文档", + Summary: " ", + Content: " ", + Confidence: 0.9, + }, + }) + + if len(docs) != 1 { + t.Fatalf("documents len = %d, want 1", len(docs)) + } + if docs[0].ContentText != "摘要可作为兜底正文" { + t.Fatalf("content fallback = %q, want summary", docs[0].ContentText) + } +} + +func TestLLMExtractorParsesFencedJSON(t *testing.T) { + model := &fakeLLMExtractorChatModel{ + generateMsg: schema.AssistantMessage("```json\n{\"facts\":[],\"documents\":[]}\n```", nil), + } + extractor := newTestLLMExtractor(t, model) + + if _, err := extractor.Extract(context.Background(), buildLLMExtractorTestInput()); err != nil { + t.Fatalf("Extract() error = %v", err) + } +} + +func TestLLMExtractorRejectsInvalidJSON(t *testing.T) { + model := &fakeLLMExtractorChatModel{ + generateMsg: schema.AssistantMessage("not-json", nil), + } + extractor := newTestLLMExtractor(t, model) + + if _, err := extractor.Extract(context.Background(), buildLLMExtractorTestInput()); err == nil { + t.Fatal("Extract() error = nil, want invalid json error") + } +} + +func TestLLMExtractorRejectsEmptyOutput(t *testing.T) { + model := &fakeLLMExtractorChatModel{ + generateMsg: schema.AssistantMessage("", nil), + } + extractor := newTestLLMExtractor(t, model) + + if _, err := extractor.Extract(context.Background(), buildLLMExtractorTestInput()); err == nil { + t.Fatal("Extract() error = nil, want empty output error") + } +} + +func TestLLMExtractorRespectsCanceledContext(t *testing.T) { + model := &fakeLLMExtractorChatModel{ + generateMsg: schema.AssistantMessage(`{"facts":[],"documents":[]}`, nil), + } + extractor := newTestLLMExtractor(t, model) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := extractor.Extract(ctx, buildLLMExtractorTestInput()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Extract() error = %v, want context.Canceled", err) + } + if len(model.generateInputs) != 0 { + t.Fatalf("generateInputs len = %d, want 0", len(model.generateInputs)) + } +} + +func newTestLLMExtractor(t *testing.T, model *fakeLLMExtractorChatModel) *LLMExtractor { + t.Helper() + extractor, err := NewLLMExtractor(context.Background(), LLMExtractorOptions{ + ChatModel: model, + MaxInputChars: 200, + }) + if err != nil { + t.Fatalf("NewLLMExtractor() error = %v", err) + } + return extractor +} + +func buildLLMExtractorTestInput() aidomain.MemoryExtractionInput { + return aidomain.MemoryExtractionInput{ + ConversationID: "conv-llm", + UserID: 7, + UserMessage: aidomain.Message{ + ID: "msg-user-llm", + Role: aidomain.RoleUser, + Content: "请记住以后回答更简洁,并解释 memory writeback hook。", + }, + AssistantMessage: aidomain.Message{ + ID: "msg-ai-llm", + Role: aidomain.RoleAssistant, + Content: "Prompt 是治理意图,Policy 是治理裁决。", + }, + } +} diff --git a/internal/model/config/ai.go b/internal/model/config/ai.go index e2c7cdb..c4b7157 100644 --- a/internal/model/config/ai.go +++ b/internal/model/config/ai.go @@ -32,6 +32,9 @@ type AIMemory struct { // RecentRawTurns 控制上下文恢复时保留多少轮最近原始消息。 // 后续会采用 “summary + recent turns” 的组合,这个值决定 recent turns 的窗口大小。 RecentRawTurns int `json:"recent_raw_turns" yaml:"recent_raw_turns"` + // RecentRawTokenBudget 控制 recent turns 在压缩模式下允许占用的 token 预算。 + // 系统会从最新消息向前回扫,直到达到这个预算或 RecentRawTurns 上限。 + RecentRawTokenBudget int `json:"recent_raw_token_budget" yaml:"recent_raw_token_budget"` // CompressThresholdTokens 表示会话历史接近多少 token 时开始触发摘要压缩。 // 它不是模型最大上下文,而是系统层面的提前压缩阈值。 CompressThresholdTokens int `json:"compress_threshold_tokens" yaml:"compress_threshold_tokens"` @@ -56,6 +59,15 @@ type AIMemory struct { // MinImportance 是记忆准入或保留时的最低重要度阈值。 // 后续治理阶段会用它过滤低价值候选,避免把闲聊、噪音和瞬时状态写进长期记忆。 MinImportance float64 `json:"min_importance" yaml:"min_importance"` + // ExtractorMode 控制写回候选抽取方式:rule 表示规则抽取,llm 表示 LLM 提议 + policy 裁决。 + ExtractorMode string `json:"extractor_mode" yaml:"extractor_mode"` + // ExtractTimeoutSeconds 控制 LLM 候选提议的单次超时时间。 + ExtractTimeoutSeconds int `json:"extract_timeout_seconds" yaml:"extract_timeout_seconds"` + // ExtractMaxChars 控制进入候选提议 prompt 的单段文本最大字符数。 + ExtractMaxChars int `json:"extract_max_chars" yaml:"extract_max_chars"` + // ToolOutputTokenBudget 控制单次工具输出回喂模型前允许占用的最大 token 预算。 + // 超预算时 runtime 会改成压缩版 feedback envelope,而不是把原始工具输出整段塞回上下文。 + ToolOutputTokenBudget int `json:"tool_output_token_budget" yaml:"tool_output_token_budget"` // EmbedModel 指定记忆文档使用的 embedding 模型名称。 // 默认使用阿里云百炼 qwen3-vl-embedding。 EmbedModel string `json:"embed_model" yaml:"embed_model"` diff --git a/internal/model/config/config.go b/internal/model/config/config.go index 68f128b..ab9ba2b 100644 --- a/internal/model/config/config.go +++ b/internal/model/config/config.go @@ -333,6 +333,7 @@ func NewConfig() *Config { RecallMinScore: viper.GetFloat64("ai.memory.recall_min_score"), RAGMaxChars: viper.GetInt("ai.memory.rag_max_chars"), RecentRawTurns: viper.GetInt("ai.memory.recent_raw_turns"), + RecentRawTokenBudget: viper.GetInt("ai.memory.recent_raw_token_budget"), CompressThresholdTokens: viper.GetInt("ai.memory.compress_threshold_tokens"), SummaryRefreshEveryTurns: viper.GetInt("ai.memory.summary_refresh_every_turns"), WritebackAsync: viper.GetBool("ai.memory.writeback_async"), @@ -341,6 +342,10 @@ func NewConfig() *Config { EnableOrgMemory: viper.GetBool("ai.memory.enable_org_memory"), EnableOpsMemory: viper.GetBool("ai.memory.enable_ops_memory"), MinImportance: viper.GetFloat64("ai.memory.min_importance"), + ExtractorMode: viper.GetString("ai.memory.extractor_mode"), + ExtractTimeoutSeconds: viper.GetInt("ai.memory.extract_timeout_seconds"), + ExtractMaxChars: viper.GetInt("ai.memory.extract_max_chars"), + ToolOutputTokenBudget: viper.GetInt("ai.memory.tool_output_token_budget"), EmbedModel: viper.GetString("ai.memory.embed_model"), EmbedEndpoint: viper.GetString("ai.memory.embed_endpoint"), EmbedDimension: viper.GetInt("ai.memory.embed_dimension"), diff --git a/internal/repository/interfaces/aiMemoryRepository.go b/internal/repository/interfaces/aiMemoryRepository.go index 3802361..e48590a 100644 --- a/internal/repository/interfaces/aiMemoryRepository.go +++ b/internal/repository/interfaces/aiMemoryRepository.go @@ -29,6 +29,8 @@ type AIMemoryRepository interface { ReplaceDocumentChunks(ctx context.Context, documentID string, chunks []*entity.AIMemoryDocumentChunk) error // ListDocumentChunks 按 document 读取 chunks,按 chunk_index 升序返回。 ListDocumentChunks(ctx context.Context, documentID string) ([]*entity.AIMemoryDocumentChunk, error) + // ListDocumentChunksByRefs 按 document_id + chunk_index 精确回查仍有效的 chunks。 + ListDocumentChunksByRefs(ctx context.Context, refs []aidomain.MemoryDocumentChunkRef) ([]*entity.AIMemoryDocumentChunk, error) // ListDocumentChunksByPointIDs 按 Qdrant point ids 回查仍有效的 chunks。 ListDocumentChunksByPointIDs(ctx context.Context, pointIDs []string) ([]*entity.AIMemoryDocumentChunk, error) diff --git a/internal/repository/system/aiMemoryRepo.go b/internal/repository/system/aiMemoryRepo.go index 52d2e42..b876a38 100644 --- a/internal/repository/system/aiMemoryRepo.go +++ b/internal/repository/system/aiMemoryRepo.go @@ -82,7 +82,9 @@ func (r *AIMemoryGormRepository) ListFacts( Where("scope_key IN ?", query.ScopeKeys). Where("visibility IN ?", aidomain.NormalizeMemoryVisibilities(query.AllowedVisibilities)). Where("(expires_at IS NULL OR expires_at > ?)", now) - if query.Namespace != "" { + if len(query.Namespaces) > 0 { + db = db.Where("namespace IN ?", query.Namespaces) + } else if query.Namespace != "" { db = db.Where("namespace = ?", query.Namespace) } if len(query.FactKeys) > 0 { @@ -300,6 +302,38 @@ func (r *AIMemoryGormRepository) ListDocumentChunks( return rows, nil } +// ListDocumentChunksByRefs 按 document_id + chunk_index 精确回查仍有效的 chunks。 +func (r *AIMemoryGormRepository) ListDocumentChunksByRefs( + ctx context.Context, + refs []aidomain.MemoryDocumentChunkRef, +) ([]*entity.AIMemoryDocumentChunk, error) { + normalizedRefs := normalizeMemoryChunkRefs(refs) + if len(normalizedRefs) == 0 { + return []*entity.AIMemoryDocumentChunk{}, nil + } + + var rows []*entity.AIMemoryDocumentChunk + now := time.Now() + conditions := make([]string, 0, len(normalizedRefs)) + args := make([]any, 0, len(normalizedRefs)*2) + for _, ref := range normalizedRefs { + conditions = append(conditions, "(ai_memory_document_chunks.document_id = ? AND ai_memory_document_chunks.chunk_index = ?)") + args = append(args, ref.DocumentID, ref.ChunkIndex) + } + db := r.db.WithContext(ctx). + Model(&entity.AIMemoryDocumentChunk{}). + Joins("JOIN ai_memory_documents d ON d.id = ai_memory_document_chunks.document_id AND d.deleted_at IS NULL"). + Where("(d.expires_at IS NULL OR d.expires_at > ?)", now). + Where(strings.Join(conditions, " OR "), args...) + if err := db. + Order("ai_memory_document_chunks.document_id ASC"). + Order("ai_memory_document_chunks.chunk_index ASC"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + // ListDocumentChunksByPointIDs 按 Qdrant point ids 回查仍有效的 chunks。 func (r *AIMemoryGormRepository) ListDocumentChunksByPointIDs( ctx context.Context, @@ -508,3 +542,24 @@ func normalizeMemoryPointIDs(ids []string) []string { } return items } + +func normalizeMemoryChunkRefs(refs []aidomain.MemoryDocumentChunkRef) []aidomain.MemoryDocumentChunkRef { + if len(refs) == 0 { + return nil + } + seen := make(map[string]struct{}, len(refs)) + items := make([]aidomain.MemoryDocumentChunkRef, 0, len(refs)) + for _, ref := range refs { + ref.DocumentID = strings.TrimSpace(ref.DocumentID) + if ref.DocumentID == "" || ref.ChunkIndex < 0 { + continue + } + key := fmt.Sprintf("%s#%d", ref.DocumentID, ref.ChunkIndex) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + items = append(items, ref) + } + return items +} diff --git a/internal/repository/system/aiMemoryRepo_test.go b/internal/repository/system/aiMemoryRepo_test.go index a85247d..26beaff 100644 --- a/internal/repository/system/aiMemoryRepo_test.go +++ b/internal/repository/system/aiMemoryRepo_test.go @@ -572,6 +572,115 @@ func TestAIMemoryRepositoryListDocumentChunksByPointIDsFiltersInvalidDocuments(t } } +func TestAIMemoryRepositoryListDocumentChunksByRefsFiltersAndSorts(t *testing.T) { + db := newAIMemoryRepositoryTestDB(t) + repo := NewAIMemoryRepository(db) + ctx := context.Background() + userID := uint(109) + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + expiredAt := time.Now().Add(-time.Hour) + now := time.Now() + + docs := []*entity.AIMemoryDocument{ + { + ID: "doc-ref-active-a", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "active-a", + Summary: "active-a", + ContentText: "active-a content", + }, + { + ID: "doc-ref-active-b", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "active-b", + Summary: "active-b", + ContentText: "active-b content", + }, + { + ID: "doc-ref-expired", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "expired", + Summary: "expired", + ContentText: "expired content", + ExpiresAt: &expiredAt, + }, + { + ID: "doc-ref-deleted", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "deleted", + Summary: "deleted", + ContentText: "deleted content", + }, + } + if err := repo.BatchUpsertDocuments(ctx, docs); err != nil { + t.Fatalf("BatchUpsertDocuments() error = %v", err) + } + + activeA0 := buildAIMemoryRepositoryTestChunk("chunk-ref-a-0", "doc-ref-active-a", scopeKey, "44444444-1111-1111-1111-111111111111", now) + activeA0.ChunkIndex = 0 + activeB1 := buildAIMemoryRepositoryTestChunk("chunk-ref-b-1", "doc-ref-active-b", scopeKey, "55555555-1111-1111-1111-111111111111", now) + activeB1.ChunkIndex = 1 + expired0 := buildAIMemoryRepositoryTestChunk("chunk-ref-expired-0", "doc-ref-expired", scopeKey, "66666666-1111-1111-1111-111111111111", now) + expired0.ChunkIndex = 0 + deleted0 := buildAIMemoryRepositoryTestChunk("chunk-ref-deleted-0", "doc-ref-deleted", scopeKey, "77777777-1111-1111-1111-111111111111", now) + deleted0.ChunkIndex = 0 + + if err := repo.ReplaceDocumentChunks(ctx, "doc-ref-active-a", []*entity.AIMemoryDocumentChunk{activeA0}); err != nil { + t.Fatalf("ReplaceDocumentChunks(active-a) error = %v", err) + } + if err := repo.ReplaceDocumentChunks(ctx, "doc-ref-active-b", []*entity.AIMemoryDocumentChunk{activeB1}); err != nil { + t.Fatalf("ReplaceDocumentChunks(active-b) error = %v", err) + } + if err := repo.ReplaceDocumentChunks(ctx, "doc-ref-expired", []*entity.AIMemoryDocumentChunk{expired0}); err != nil { + t.Fatalf("ReplaceDocumentChunks(expired) error = %v", err) + } + if err := repo.ReplaceDocumentChunks(ctx, "doc-ref-deleted", []*entity.AIMemoryDocumentChunk{deleted0}); err != nil { + t.Fatalf("ReplaceDocumentChunks(deleted) error = %v", err) + } + if err := db.Delete(&entity.AIMemoryDocument{}, "id = ?", "doc-ref-deleted").Error; err != nil { + t.Fatalf("soft delete document: %v", err) + } + + rows, err := repo.ListDocumentChunksByRefs(ctx, []aidomain.MemoryDocumentChunkRef{ + {DocumentID: "doc-ref-active-b", ChunkIndex: 1}, + {DocumentID: "doc-ref-active-a", ChunkIndex: 0}, + {DocumentID: "doc-ref-active-b", ChunkIndex: 1}, + {DocumentID: "doc-ref-expired", ChunkIndex: 0}, + {DocumentID: "doc-ref-deleted", ChunkIndex: 0}, + {DocumentID: "", ChunkIndex: 0}, + {DocumentID: "doc-ref-active-a", ChunkIndex: -1}, + }) + if err != nil { + t.Fatalf("ListDocumentChunksByRefs() error = %v", err) + } + if len(rows) != 2 { + t.Fatalf("rows len = %d, want 2: %+v", len(rows), rows) + } + if rows[0].ID != "chunk-ref-a-0" || rows[1].ID != "chunk-ref-b-1" { + t.Fatalf("rows order = %+v, want active-a then active-b", rows) + } +} + func TestAIMemoryRepositoryListDocumentsNeedingIndex(t *testing.T) { db := newAIMemoryRepositoryTestDB(t) repo := NewAIMemoryRepository(db) diff --git a/internal/service/system/aiContext.go b/internal/service/system/aiContext.go index 1ddde7a..7ed64e9 100644 --- a/internal/service/system/aiContext.go +++ b/internal/service/system/aiContext.go @@ -22,6 +22,10 @@ type aiMemoryProvider interface { RecallMessages(ctx context.Context, input aiMemoryRecallInput) ([]aidomain.Message, error) } +type aiStructuredMemoryProvider interface { + Recall(ctx context.Context, input aiMemoryRecallInput) (aiMemoryRecallResult, error) +} + // aiContextCompressionInput 描述上下文压缩组件的输入。 type aiContextCompressionInput struct { ConversationID string @@ -47,7 +51,8 @@ type aiContextBuildArgs struct { // aiContextSnapshot 表示装配完成后可直接喂给 runtime 的上下文片段。 type aiContextSnapshot struct { - History []aidomain.Message + History []aidomain.Message + Diagnostics aiHybridContextDiagnostics } // aiContextAssembler 负责统一收口历史消息、记忆扩展点、压缩扩展点和动态 prompt。 @@ -58,12 +63,14 @@ type aiContextAssembler interface { type defaultAIContextAssembler struct { memory aiMemoryProvider compressor aiContextCompressor + planner aiHybridContextPlanner } func newAIContextAssembler(deps AIDeps) aiContextAssembler { return &defaultAIContextAssembler{ memory: deps.Memory, compressor: deps.Compressor, + planner: newDefaultAIHybridContextPlanner(), } } @@ -74,34 +81,61 @@ func (a *defaultAIContextAssembler) Build( args aiContextBuildArgs, ) (aiContextSnapshot, error) { history := messagesToRuntimeHistory(args.StoredMessages) + recallResult := aiMemoryRecallResult{} if a.memory != nil { - recalled, err := a.memory.RecallMessages(ctx, aiMemoryRecallInput{ + input := aiMemoryRecallInput{ ConversationID: args.ConversationID, UserID: args.UserID, Query: args.Query, History: history, ToolCallCtx: args.ToolCallCtx, + } + if structured, ok := a.memory.(aiStructuredMemoryProvider); ok { + result, err := structured.Recall(ctx, input) + if err != nil { + return aiContextSnapshot{}, err + } + recallResult = result + } else { + recalled, err := a.memory.RecallMessages(ctx, input) + if err != nil { + return aiContextSnapshot{}, err + } + recallResult.Messages = recalled + } + } + + if a.planner != nil { + planned, err := a.planner.Plan(ctx, aiHybridContextInput{ + ConversationID: args.ConversationID, + Query: args.Query, + RawHistory: history, + Recall: recallResult, + VisibleTools: args.VisibleTools, }) if err != nil { return aiContextSnapshot{}, err } - if len(recalled) > 0 { - history = append(history, recalled...) - } + return aiContextSnapshot(planned), nil } + if a.compressor != nil { + inputMessages := joinAIMemoryFirst(recallResult.Messages, history) compressed, err := a.compressor.CompressMessages(ctx, aiContextCompressionInput{ ConversationID: args.ConversationID, Query: args.Query, - Messages: history, + Messages: inputMessages, }) if err != nil { return aiContextSnapshot{}, err } history = compressed + } else { + history = joinAIMemoryFirst(recallResult.Messages, history) } return aiContextSnapshot{ - History: history, + History: history, + Diagnostics: recallResult.Diagnostics, }, nil } diff --git a/internal/service/system/aiContext_test.go b/internal/service/system/aiContext_test.go index 94ec72b..dc055b6 100644 --- a/internal/service/system/aiContext_test.go +++ b/internal/service/system/aiContext_test.go @@ -5,6 +5,7 @@ import ( "testing" aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/config" "personal_assistant/internal/model/entity" ) @@ -40,25 +41,6 @@ func (f *fakeContextCompressor) CompressMessages(context.Context, aiContextCompr return f.output, nil } -type fakePromptBuilder struct { - output string - calls int -} - -func (f *fakePromptBuilder) BuildDynamicPrompt([]aidomain.Tool, aidomain.AIToolPrincipal) string { - f.calls++ - return f.output -} - -func (f *fakePromptBuilder) BuildDecisionPrompt( - aidomain.ToolSelectionDecision, - string, - []string, -) string { - f.calls++ - return f.output -} - func TestDefaultAIContextAssemblerUsesStoredHistory(t *testing.T) { assembler := newAIContextAssembler(AIDeps{}) tool := &fakeContextTool{ @@ -100,7 +82,10 @@ func TestDefaultAIContextAssemblerUsesStoredHistory(t *testing.T) { } } -func TestDefaultAIContextAssemblerCallsOptionalProviders(t *testing.T) { +func TestDefaultAIContextAssemblerUsesHybridPlannerForOptionalMemory(t *testing.T) { + restore := setAIMemoryTestConfig(t, config.AIMemory{Enabled: true}) + defer restore() + memory := &fakeMemoryProvider{ output: []aidomain.Message{ {ID: "mem_1", Role: aidomain.RoleAssistant, Content: "记忆片段"}, @@ -130,10 +115,10 @@ func TestDefaultAIContextAssemblerCallsOptionalProviders(t *testing.T) { if memory.calls != 1 { t.Fatalf("memory calls = %d, want 1", memory.calls) } - if compressor.calls != 1 { - t.Fatalf("compressor calls = %d, want 1", compressor.calls) + if compressor.calls != 0 { + t.Fatalf("compressor calls = %d, want 0; hybrid planner owns compression", compressor.calls) } - if len(snapshot.History) != 1 || snapshot.History[0].ID != "cmp_1" { - t.Fatalf("snapshot.History = %+v, want compressed output", snapshot.History) + if len(snapshot.History) != 2 || snapshot.History[0].ID != "mem_1" || snapshot.History[1].ID != "msg_1" { + t.Fatalf("snapshot.History = %+v, want memory first then raw history", snapshot.History) } } diff --git a/internal/service/system/aiHybridContext.go b/internal/service/system/aiHybridContext.go new file mode 100644 index 0000000..dc96747 --- /dev/null +++ b/internal/service/system/aiHybridContext.go @@ -0,0 +1,449 @@ +package system + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "unicode/utf8" + + aidomain "personal_assistant/internal/domain/ai" + "personal_assistant/internal/model/entity" +) + +const minAIMemoryRecentRawMessageLimit = 2 + +type aiHybridContextPlanner interface { + Plan(ctx context.Context, input aiHybridContextInput) (aiHybridContextResult, error) +} + +type aiHybridContextInput struct { + ConversationID string + Query string + RawHistory []aidomain.Message + Recall aiMemoryRecallResult + VisibleTools []aidomain.Tool +} + +type aiHybridContextResult struct { + History []aidomain.Message + Diagnostics aiHybridContextDiagnostics +} + +type aiHybridContextDiagnostics struct { + SummaryCandidates int + SummaryKept int + FactCandidates int + FactsKept int + FactsDropped int + RAGCandidates int + RAGKept int + RAGDropped int + RAGMinScore float64 + RecallMaxChars int + RAGMaxChars int + MemoryChars int + MemoryTokens int + RecentMessagesTokenBudget int + RecentMessagesTokens int + RawMessageCandidates int + RecentMessagesKept int + RawMessagesDropped int + CompressionTriggered bool + CompressionReason string + HistoryTokens int + CurrentQueryProvided bool + CurrentQueryInHistory bool + VisibleTools int + RAGRemainingChars int +} + +type defaultAIHybridContextPlanner struct{} + +func newDefaultAIHybridContextPlanner() aiHybridContextPlanner { + return &defaultAIHybridContextPlanner{} +} + +func (p *defaultAIHybridContextPlanner) Plan( + ctx context.Context, + input aiHybridContextInput, +) (aiHybridContextResult, error) { + _ = ctx + rawHistory := append([]aidomain.Message(nil), input.RawHistory...) + diagnostics := input.Recall.Diagnostics + diagnostics.RawMessageCandidates = len(rawHistory) + diagnostics.VisibleTools = len(input.VisibleTools) + diagnostics.CurrentQueryProvided = strings.TrimSpace(input.Query) != "" + diagnostics.CurrentQueryInHistory = aiHistoryContainsCurrentQuery(rawHistory, input.Query) + diagnostics.RecentMessagesTokenBudget = aiMemoryRecentRawTokenBudget() + diagnostics.CompressionReason = "skip" + + if !aiMemoryEnabled() { + diagnostics.RecentMessagesKept = len(rawHistory) + diagnostics.RecentMessagesTokens = estimateAIMemoryTokens(rawHistory) + diagnostics.HistoryTokens = estimateAIMemoryTokens(rawHistory) + return aiHybridContextResult{History: rawHistory, Diagnostics: diagnostics}, nil + } + + memoryMessages := append([]aidomain.Message(nil), input.Recall.Messages...) + reordered := joinAIMemoryFirst(memoryMessages, rawHistory) + threshold := aiMemoryCompressThresholdTokens() + totalTokens := estimateAIMemoryTokens(reordered) + if threshold <= 0 || totalTokens <= threshold { + diagnostics.RecentMessagesKept = len(rawHistory) + diagnostics.RecentMessagesTokens = estimateAIMemoryTokens(rawHistory) + diagnostics.HistoryTokens = totalTokens + return aiHybridContextResult{History: reordered, Diagnostics: diagnostics}, nil + } + + diagnostics.CompressionTriggered = true + recentSelection := selectRecentAIMemoryRawMessagesByBudget( + rawHistory, + aiMemoryRecentRawMessageLimitWithMinimum(), + aiMemoryRecentRawTokenBudget(), + ) + recent := recentSelection.Messages + diagnostics.RecentMessagesKept = len(recent) + diagnostics.RecentMessagesTokens = recentSelection.Tokens + if dropped := len(rawHistory) - len(recent); dropped > 0 { + diagnostics.RawMessagesDropped = dropped + diagnostics.CompressionReason = "budget" + } else { + diagnostics.CompressionReason = "threshold" + } + history := joinAIMemoryFirst(memoryMessages, recent) + diagnostics.HistoryTokens = estimateAIMemoryTokens(history) + return aiHybridContextResult{History: history, Diagnostics: diagnostics}, nil +} + +func buildAIMemoryContextContent( + summary *entity.AIConversationSummary, + facts []*entity.AIMemoryFact, + ragItems []aiMemoryRAGRecallItem, + maxChars int, +) (string, aiHybridContextDiagnostics) { + diagnostics := aiHybridContextDiagnostics{ + RAGMinScore: aiMemoryRecallMinScore(), + RecallMaxChars: maxChars, + RAGMaxChars: aiMemoryRAGMaxChars(), + RecentMessagesTokenBudget: aiMemoryRecentRawTokenBudget(), + } + summaryText := "" + if summary != nil { + summaryText = normalizeAIMemoryContextLine(summary.SummaryText) + } + keyPoints := decodeAIMemorySummaryLines(summaryKeyPointsJSON(summary)) + openLoops := decodeAIMemorySummaryLines(summaryOpenLoopsJSON(summary)) + if summaryText != "" || len(keyPoints) > 0 || len(openLoops) > 0 { + diagnostics.SummaryCandidates = 1 + diagnostics.SummaryKept = 1 + } + + factLines := renderSortedAIMemoryFactLines(facts) + diagnostics.FactCandidates = len(factLines) + ragLines := renderSortedAIMemoryRAGLines(ragItems) + diagnostics.RAGCandidates = len(ragLines) + if summaryText == "" && len(keyPoints) == 0 && len(openLoops) == 0 && len(factLines) == 0 && len(ragLines) == 0 { + return "", diagnostics + } + + var builder strings.Builder + appendAIMemoryContextPart(&builder, aiMemoryContextMessageHeader, maxChars) + if len(keyPoints) > 0 { + appendAIMemoryContextPart(&builder, "\n\n## Latest Decisions\n", maxChars) + for _, line := range keyPoints { + appendAIMemoryContextPart(&builder, "- "+line+"\n", maxChars) + } + } + if len(openLoops) > 0 { + appendAIMemoryContextPart(&builder, "\n\n## Open Loops\n", maxChars) + for _, line := range openLoops { + appendAIMemoryContextPart(&builder, "- "+line+"\n", maxChars) + } + } + appendAIMemoryContextPart(&builder, "\n\n## Conversation Summary\n", maxChars) + if summaryText == "" { + appendAIMemoryContextPart(&builder, "- 无", maxChars) + } else { + appendAIMemoryContextPart(&builder, summaryText, maxChars) + } + + for _, line := range factLines { + prefix := "\n" + if diagnostics.FactsKept == 0 { + prefix = "\n\n## Stable Facts\n" + } + if appendAIMemoryContextPart(&builder, prefix+"- "+line+"\n", maxChars) { + diagnostics.FactsKept++ + } + } + diagnostics.FactsDropped = diagnostics.FactCandidates - diagnostics.FactsKept + + ragBudget := aiMemoryRAGMaxChars() + diagnostics.RAGRemainingChars = ragBudget + ragUsed := 0 + for _, line := range ragLines { + prefix := "" + if diagnostics.RAGKept == 0 { + prefix = "\n\n## Long-term Documents\n" + } + part := prefix + line + "\n" + partRunes := utf8.RuneCountInString(part) + if ragBudget > 0 && ragUsed+partRunes > ragBudget { + remaining := ragBudget - ragUsed + if remaining <= utf8.RuneCountInString(prefix)+1 { + break + } + part = truncateAIMemoryContext(part, remaining) + partRunes = utf8.RuneCountInString(part) + } + if appendAIMemoryContextPart(&builder, part, maxChars) { + diagnostics.RAGKept++ + ragUsed += partRunes + diagnostics.RAGRemainingChars = ragBudget - ragUsed + } else { + break + } + } + diagnostics.RAGDropped = diagnostics.RAGCandidates - diagnostics.RAGKept + + content := strings.TrimRight(builder.String(), "\n") + diagnostics.MemoryChars = utf8.RuneCountInString(content) + diagnostics.MemoryTokens = estimateAIMemoryTokens([]aidomain.Message{{Content: content}}) + return content, diagnostics +} + +func appendAIMemoryContextPart(builder *strings.Builder, part string, maxChars int) bool { + if part == "" { + return false + } + if maxChars <= 0 { + builder.WriteString(part) + return true + } + current := utf8.RuneCountInString(builder.String()) + remaining := maxChars - current + if remaining <= 0 { + return false + } + partRunes := utf8.RuneCountInString(part) + if partRunes <= remaining { + builder.WriteString(part) + return true + } + if remaining <= utf8.RuneCountInString(aiMemoryContextTruncationIndicator) { + return false + } + builder.WriteString(truncateAIMemoryContext(part, remaining)) + return true +} + +func renderSortedAIMemoryFactLines(facts []*entity.AIMemoryFact) []string { + sorted := append([]*entity.AIMemoryFact(nil), facts...) + sort.SliceStable(sorted, func(i, j int) bool { + return compareAIMemoryFacts(sorted[i], sorted[j]) + }) + + lines := make([]string, 0, len(sorted)) + for _, fact := range sorted { + line, ok := renderAIMemoryFactLine(fact) + if ok { + lines = append(lines, line) + } + } + return lines +} + +func renderAIMemoryFactLine(fact *entity.AIMemoryFact) (string, bool) { + if fact == nil { + return "", false + } + namespace := normalizeAIMemoryContextLine(fact.Namespace) + factKey := normalizeAIMemoryContextLine(fact.FactKey) + value := normalizeAIMemoryContextLine(fact.Summary) + if value == "" { + value = normalizeAIMemoryContextLine(fact.FactValueJSON) + } + if namespace == "" || factKey == "" || value == "" { + return "", false + } + return fmt.Sprintf("%s/%s: %s", namespace, factKey, value), true +} + +func renderSortedAIMemoryRAGLines(items []aiMemoryRAGRecallItem) []string { + sorted := append([]aiMemoryRAGRecallItem(nil), items...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].Score != sorted[j].Score { + return sorted[i].Score > sorted[j].Score + } + leftID := "" + rightID := "" + if sorted[i].Chunk != nil { + leftID = sorted[i].Chunk.ID + } + if sorted[j].Chunk != nil { + rightID = sorted[j].Chunk.ID + } + return leftID < rightID + }) + + lines := make([]string, 0, len(sorted)) + for _, item := range sorted { + line, ok := renderAIMemoryRAGLine(item) + if ok { + lines = append(lines, line) + } + } + return lines +} + +func renderAIMemoryRAGLine(item aiMemoryRAGRecallItem) (string, bool) { + if item.Chunk == nil { + return "", false + } + content := strings.TrimSpace(item.ExpandedText) + if content == "" { + content = item.Chunk.ContentText + } + content = normalizeAIMemoryContextLine(content) + if content == "" { + return "", false + } + topic := normalizeAIMemoryContextLine(item.Chunk.Topic) + memoryType := normalizeAIMemoryContextLine(item.Chunk.MemoryType) + var builder strings.Builder + builder.WriteString("- ") + if topic != "" || memoryType != "" { + builder.WriteString("[") + if memoryType != "" { + builder.WriteString(memoryType) + } + if topic != "" { + if memoryType != "" { + builder.WriteString("/") + } + builder.WriteString(topic) + } + builder.WriteString(fmt.Sprintf(" score=%.3f] ", item.Score)) + } + builder.WriteString(content) + return builder.String(), true +} + +func aiMemorySourcePriority(sourceKind string) int { + source := strings.ToLower(strings.TrimSpace(sourceKind)) + switch source { + case string(aidomain.MemorySourceExplicitUserStatement): + return 0 + case string(aidomain.MemorySourceToolVerifiedSummary): + return 1 + case string(aidomain.MemorySourceAdminSet): + return 2 + case string(aidomain.MemorySourceModelInferred): + return 3 + } + if strings.Contains(source, "explicit") || strings.Contains(source, "user") { + return 0 + } + if strings.Contains(source, "tool") || strings.Contains(source, "service") || strings.Contains(source, "realtime") { + return 1 + } + if strings.Contains(source, "admin") || strings.Contains(source, "manual") { + return 2 + } + return 4 +} + +func aiMemoryNamespacePriority(namespace string) int { + switch strings.TrimSpace(namespace) { + case aidomain.MemoryNamespaceUserPreference: + return 0 + case aidomain.MemoryNamespaceOJGoal: + return 1 + case aidomain.MemoryNamespaceOJProfile: + return 2 + default: + return 3 + } +} + +func compareAIMemoryFacts(left *entity.AIMemoryFact, right *entity.AIMemoryFact) bool { + if left == nil { + return false + } + if right == nil { + return true + } + leftNamespacePriority := aiMemoryNamespacePriority(left.Namespace) + rightNamespacePriority := aiMemoryNamespacePriority(right.Namespace) + if leftNamespacePriority != rightNamespacePriority { + return leftNamespacePriority < rightNamespacePriority + } + leftPriority := aiMemorySourcePriority(left.SourceKind) + rightPriority := aiMemorySourcePriority(right.SourceKind) + if leftPriority != rightPriority { + return leftPriority < rightPriority + } + if left.Confidence != right.Confidence { + return left.Confidence > right.Confidence + } + if !left.UpdatedAt.Equal(right.UpdatedAt) { + return left.UpdatedAt.After(right.UpdatedAt) + } + return left.FactKey < right.FactKey +} + +func decodeAIMemorySummaryLines(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var items []string + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + item = normalizeAIMemoryContextLine(item) + if item != "" { + out = append(out, item) + } + } + return out +} + +func summaryKeyPointsJSON(summary *entity.AIConversationSummary) string { + if summary == nil { + return "" + } + return summary.KeyPointsJSON +} + +func summaryOpenLoopsJSON(summary *entity.AIConversationSummary) string { + if summary == nil { + return "" + } + return summary.OpenLoopsJSON +} + +func aiMemoryRecentRawMessageLimitWithMinimum() int { + limit := aiMemoryRecentRawMessageLimit() + if limit < minAIMemoryRecentRawMessageLimit { + return minAIMemoryRecentRawMessageLimit + } + return limit +} + +func aiHistoryContainsCurrentQuery(history []aidomain.Message, query string) bool { + query = strings.TrimSpace(query) + if query == "" { + return false + } + for _, message := range history { + if message.Role == aidomain.RoleUser && strings.TrimSpace(message.Content) == query { + return true + } + } + return false +} diff --git a/internal/service/system/aiMemoryExtractor.go b/internal/service/system/aiMemoryExtractor.go new file mode 100644 index 0000000..34563aa --- /dev/null +++ b/internal/service/system/aiMemoryExtractor.go @@ -0,0 +1,148 @@ +package system + +import ( + "context" + "strings" + "time" + + "go.uber.org/zap" + + "personal_assistant/global" + aidomain "personal_assistant/internal/domain/ai" + aimemory "personal_assistant/internal/infrastructure/ai/memory" +) + +const ( + defaultAIMemoryExtractorMode = "rule" + defaultAIMemoryExtractTimeoutSeconds = 20 + defaultAIMemoryExtractMaxChars = 6000 +) + +type aiMemoryFallbackExtractor struct { + primary aidomain.MemoryExtractor + fallback aidomain.MemoryExtractor +} + +func newAIMemoryExtractor() aidomain.MemoryExtractor { + ruleExtractor := aimemory.NewRuleExtractor(aimemory.Options{}) + if aiMemoryExtractorMode() != "llm" { + return ruleExtractor + } + + llmExtractor, err := aimemory.NewLLMExtractor(context.Background(), aimemory.LLMExtractorOptions{ + Provider: aiMemoryProviderName(), + APIKey: aiMemoryAPIKey(), + BaseURL: aiMemoryBaseURL(), + Model: aiMemoryModel(), + ByAzure: aiMemoryByAzure(), + APIVersion: aiMemoryAPIVersion(), + Temperature: aiMemoryTemperature(), + MaxCompletionTokens: aiMemoryMaxCompletionTokens(), + Timeout: time.Duration(aiMemoryExtractTimeoutSeconds()) * time.Second, + MaxInputChars: aiMemoryExtractMaxChars(), + }) + if err != nil { + if global.Log != nil { + global.Log.Warn("AI memory LLM extractor 初始化失败,回退到规则抽取", zap.Error(err)) + } + return ruleExtractor + } + return aiMemoryFallbackExtractor{ + primary: llmExtractor, + fallback: ruleExtractor, + } +} + +func (e aiMemoryFallbackExtractor) Extract( + ctx context.Context, + input aidomain.MemoryExtractionInput, +) (aidomain.MemoryExtractionResult, error) { + if e.primary == nil { + if e.fallback == nil { + return aidomain.MemoryExtractionResult{}, nil + } + return e.fallback.Extract(ctx, input) + } + result, err := e.primary.Extract(ctx, input) + if err == nil { + return result, nil + } + if global.Log != nil { + global.Log.Warn("AI memory LLM extractor 执行失败,回退到规则抽取", zap.Error(err)) + } + if e.fallback == nil { + return aidomain.MemoryExtractionResult{}, err + } + return e.fallback.Extract(ctx, input) +} + +func aiMemoryExtractorMode() string { + if global.Config == nil { + return defaultAIMemoryExtractorMode + } + mode := strings.ToLower(strings.TrimSpace(global.Config.AI.Memory.ExtractorMode)) + if mode == "" { + return defaultAIMemoryExtractorMode + } + return mode +} + +func aiMemoryExtractTimeoutSeconds() int { + if global.Config == nil || global.Config.AI.Memory.ExtractTimeoutSeconds <= 0 { + return defaultAIMemoryExtractTimeoutSeconds + } + return global.Config.AI.Memory.ExtractTimeoutSeconds +} + +func aiMemoryExtractMaxChars() int { + if global.Config == nil || global.Config.AI.Memory.ExtractMaxChars <= 0 { + return defaultAIMemoryExtractMaxChars + } + return global.Config.AI.Memory.ExtractMaxChars +} + +func aiMemoryProviderName() string { + if global.Config == nil { + return "" + } + return strings.TrimSpace(global.Config.AI.Provider) +} + +func aiMemoryBaseURL() string { + if global.Config == nil { + return "" + } + return strings.TrimSpace(global.Config.AI.BaseURL) +} + +func aiMemoryModel() string { + if global.Config == nil { + return "" + } + return strings.TrimSpace(global.Config.AI.Model) +} + +func aiMemoryByAzure() bool { + return global.Config != nil && global.Config.AI.ByAzure +} + +func aiMemoryAPIVersion() string { + if global.Config == nil { + return "" + } + return strings.TrimSpace(global.Config.AI.APIVersion) +} + +func aiMemoryTemperature() float64 { + if global.Config == nil { + return 0 + } + return global.Config.AI.Temperature +} + +func aiMemoryMaxCompletionTokens() int { + if global.Config == nil { + return 0 + } + return global.Config.AI.MaxCompletionTokens +} diff --git a/internal/service/system/aiMemoryIndex_test.go b/internal/service/system/aiMemoryIndex_test.go index deffba6..ad68bc0 100644 --- a/internal/service/system/aiMemoryIndex_test.go +++ b/internal/service/system/aiMemoryIndex_test.go @@ -46,6 +46,48 @@ func TestAIMemoryIndexDocumentsPersistsChunksAndUpsertsVectors(t *testing.T) { } } +func TestAIMemoryIndexDocumentsSupportsMultiChunkStructuredContent(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + service.chunker = aimemory.NewParagraphChunker(aimemory.ChunkerOptions{MaxChars: 36, OverlapChars: 0}) + service.embedder = &fakeMemoryEmbedder{} + vectorStore := &fakeMemoryVectorStore{} + service.vectorStore = vectorStore + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableLongTermMemory: true, + EmbedModel: "qwen3-vl-embedding", + EmbedDimension: 3, + }) + defer restore() + + doc := createMemoryIndexDocument( + t, + service, + "doc-index-multi", + "第一句很短,包含逗号。第二句也很短。\n\n| 列1 | 列2 |\n| --- | --- |\n| 行1 | 数据1 |\n| 行2 | 数据2 |", + ) + if err := service.IndexDocuments(context.Background(), []string{doc.ID}); err != nil { + t.Fatalf("IndexDocuments() error = %v", err) + } + + chunks, err := service.repo.ListDocumentChunks(context.Background(), doc.ID) + if err != nil { + t.Fatalf("ListDocumentChunks() error = %v", err) + } + if len(chunks) < 2 { + t.Fatalf("chunks len = %d, want at least 2", len(chunks)) + } + if len(vectorStore.upserted) != len(chunks) { + t.Fatalf("upserted vectors len = %d, want %d", len(vectorStore.upserted), len(chunks)) + } + for _, chunk := range chunks { + if chunk.EmbeddingModel != "qwen3-vl-embedding" || chunk.EmbeddingDimension != 3 { + t.Fatalf("chunk embedding metadata = %+v", chunk) + } + } +} + func TestAIMemoryIndexDocumentsDoesNotPersistChunksWhenEmbeddingFails(t *testing.T) { db := newAIMemoryWritebackTestDB(t) service := newAIMemoryWritebackTestService(db, nil) diff --git a/internal/service/system/aiMemoryPolicy.go b/internal/service/system/aiMemoryPolicy.go index 9f53ede..a2e5080 100644 --- a/internal/service/system/aiMemoryPolicy.go +++ b/internal/service/system/aiMemoryPolicy.go @@ -2,6 +2,7 @@ package system import ( "fmt" + "math" "strings" "time" @@ -11,6 +12,8 @@ import ( // aiMemoryPolicy 收口 writeback 之前的准入、权限和覆盖规则。 type aiMemoryPolicy struct{} +const minimumMemoryCandidateConfidence = 0.5 + func (p aiMemoryPolicy) ShouldStoreFact( candidate aidomain.MemoryFactCandidate, access aidomain.MemoryAccessContext, @@ -30,7 +33,9 @@ func (p aiMemoryPolicy) ShouldStoreFact( if candidate.LowValue || strings.TrimSpace(candidate.Namespace) == "" || strings.TrimSpace(candidate.FactKey) == "" || - strings.TrimSpace(candidate.FactValueJSON) == "" { + strings.TrimSpace(candidate.FactValueJSON) == "" || + isMemoryConfidenceTooLow(candidate.Confidence) || + isSessionOnlyTTLHint(candidate.TTLHint) { return denyDecision( aidomain.MemoryReasonDenyLowValueContent, "fact candidate is empty or marked as low value", @@ -83,7 +88,9 @@ func (p aiMemoryPolicy) ShouldStoreDocument( ) } if candidate.LowValue || - (strings.TrimSpace(candidate.Summary) == "" && strings.TrimSpace(candidate.ContentText) == "") { + (strings.TrimSpace(candidate.Summary) == "" && strings.TrimSpace(candidate.ContentText) == "") || + isMemoryConfidenceTooLow(candidate.Confidence) || + isSessionOnlyTTLHint(candidate.TTLHint) { return denyDocumentDecision( aidomain.MemoryReasonDenyLowValueContent, "document candidate is empty or marked as low value", @@ -270,8 +277,23 @@ func (p aiMemoryPolicy) ResolveVisibility( } } -func (p aiMemoryPolicy) ResolveTTL(namespace string, memoryType aidomain.MemoryType) aidomain.MemoryTTLDecision { +func (p aiMemoryPolicy) ResolveTTL( + namespace string, + memoryType aidomain.MemoryType, + hint *aidomain.MemoryTTLHint, +) aidomain.MemoryTTLDecision { now := time.Now() + if decision, ok := resolveTTLHint(now, strings.TrimSpace(namespace), memoryType, hint); ok { + return decision + } + return defaultMemoryTTLDecision(now, namespace, memoryType) +} + +func defaultMemoryTTLDecision( + now time.Time, + namespace string, + memoryType aidomain.MemoryType, +) aidomain.MemoryTTLDecision { switch strings.TrimSpace(namespace) { case aidomain.MemoryNamespaceUserPreference, aidomain.MemoryNamespaceOrgProfile, aidomain.MemoryNamespaceOpsIncident, aidomain.MemoryNamespaceOpsRunbook: @@ -327,6 +349,137 @@ func (p aiMemoryPolicy) ResolveTTL(namespace string, memoryType aidomain.MemoryT } } +func resolveTTLHint( + now time.Time, + namespace string, + memoryType aidomain.MemoryType, + hint *aidomain.MemoryTTLHint, +) (aidomain.MemoryTTLDecision, bool) { + if hint == nil { + return aidomain.MemoryTTLDecision{}, false + } + kind := aidomain.MemoryTTLHintKind(strings.TrimSpace(string(hint.Kind))) + if kind == "" || kind == aidomain.MemoryTTLHintDefault { + return aidomain.MemoryTTLDecision{}, false + } + if kind == aidomain.MemoryTTLHintSessionOnly { + return aidomain.MemoryTTLDecision{}, false + } + if kind == aidomain.MemoryTTLHintPersistent { + if ttlPersistentAllowed(namespace, memoryType) { + return aidomain.MemoryTTLDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowTTLPersistent, + fmt.Sprintf("ttl_hint persistent accepted for namespace=%s memory_type=%s", namespace, memoryType), + ), + }, true + } + return aidomain.MemoryTTLDecision{}, false + } + + minDays, maxDays, ok := ttlDurationBounds(namespace, memoryType) + if !ok { + return aidomain.MemoryTTLDecision{}, false + } + + var days int + switch kind { + case aidomain.MemoryTTLHintDuration: + parsedDays, valid := ttlHintDurationDays(hint) + if !valid { + return aidomain.MemoryTTLDecision{}, false + } + days = clampMemoryTTLDays(parsedDays, minDays, maxDays) + case aidomain.MemoryTTLHintUntilDate: + parsedUntil, valid := parseTTLHintUntilDate(hint.UntilDate) + if !valid || !parsedUntil.After(now) { + return aidomain.MemoryTTLDecision{}, false + } + days = clampMemoryTTLDays(int(math.Ceil(parsedUntil.Sub(now).Hours()/24)), minDays, maxDays) + default: + return aidomain.MemoryTTLDecision{}, false + } + + expiresAt := now.Add(time.Duration(days) * 24 * time.Hour) + return aidomain.MemoryTTLDecision{ + MemoryDecision: allowDecision( + aidomain.MemoryReasonAllowTTLExpiring, + fmt.Sprintf("ttl_hint %s accepted as %d days for namespace=%s memory_type=%s", kind, days, namespace, memoryType), + ), + ExpiresAt: &expiresAt, + }, true +} + +func ttlPersistentAllowed(namespace string, memoryType aidomain.MemoryType) bool { + switch namespace { + case aidomain.MemoryNamespaceUserPreference, aidomain.MemoryNamespaceOrgProfile, + aidomain.MemoryNamespaceOpsIncident, aidomain.MemoryNamespaceOpsRunbook: + return true + case "": + return memoryType != aidomain.MemoryTypeSessionSummary + default: + return false + } +} + +func ttlDurationBounds(namespace string, memoryType aidomain.MemoryType) (int, int, bool) { + switch namespace { + case aidomain.MemoryNamespaceOJGoal: + return 1, 90, true + case aidomain.MemoryNamespaceOJProfile: + return 7, 180, true + case aidomain.MemoryNamespaceOrgLearning: + return 1, 60, true + } + if namespace == "" { + switch memoryType { + case aidomain.MemoryTypeEpisodic: + return 1, 180, true + case aidomain.MemoryTypeIncident, aidomain.MemoryTypeProcedural: + return 7, 365, true + default: + return 0, 0, false + } + } + return 0, 0, false +} + +func ttlHintDurationDays(hint *aidomain.MemoryTTLHint) (int, bool) { + if hint == nil || hint.Value <= 0 { + return 0, false + } + switch strings.ToLower(strings.TrimSpace(hint.Unit)) { + case "", "day", "days", "d": + return hint.Value, true + default: + return 0, false + } +} + +func parseTTLHintUntilDate(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + if parsed, err := time.Parse("2006-01-02", value); err == nil { + return parsed, true + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, true + } + return time.Time{}, false +} + +func clampMemoryTTLDays(days int, minDays int, maxDays int) int { + if days < minDays { + return minDays + } + if days > maxDays { + return maxDays + } + return days +} + func (p aiMemoryPolicy) ShouldOverrideFact( current aidomain.MemoryFactVersion, candidate aidomain.MemoryFactVersion, @@ -506,6 +659,14 @@ func isForbiddenMemorySource(sourceKind aidomain.MemorySourceKind) bool { } } +func isMemoryConfidenceTooLow(confidence float64) bool { + return confidence > 0 && confidence < minimumMemoryCandidateConfidence +} + +func isSessionOnlyTTLHint(hint *aidomain.MemoryTTLHint) bool { + return hint != nil && hint.Kind == aidomain.MemoryTTLHintSessionOnly +} + func isApprovedOrgScope(orgID uint, access aidomain.MemoryAccessContext) bool { for _, approvedID := range access.ApprovedOrgIDs { if approvedID == orgID { diff --git a/internal/service/system/aiMemoryPolicy_test.go b/internal/service/system/aiMemoryPolicy_test.go index b4cb0ca..4adfed2 100644 --- a/internal/service/system/aiMemoryPolicy_test.go +++ b/internal/service/system/aiMemoryPolicy_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "strings" "testing" + "time" aidomain "personal_assistant/internal/domain/ai" ) @@ -203,6 +204,96 @@ func TestAIMemoryPolicyCanReadMemoryRejectsPlatformOpsForNonSuperAdmin(t *testin } } +func TestAIMemoryPolicyResolveTTLAcceptsOJGoalDurationHint(t *testing.T) { + policy := aiMemoryPolicy{} + before := time.Now() + + decision := policy.ResolveTTL( + aidomain.MemoryNamespaceOJGoal, + "", + &aidomain.MemoryTTLHint{ + Kind: aidomain.MemoryTTLHintDuration, + Value: 30, + Unit: "day", + }, + ) + + if !decision.Allowed || decision.ExpiresAt == nil { + t.Fatalf("ResolveTTL() = %#v, want expiring decision", decision) + } + assertTTLApproxDays(t, before, *decision.ExpiresAt, 30) +} + +func TestAIMemoryPolicyResolveTTLClampsOJGoalDurationHint(t *testing.T) { + policy := aiMemoryPolicy{} + before := time.Now() + + decision := policy.ResolveTTL( + aidomain.MemoryNamespaceOJGoal, + "", + &aidomain.MemoryTTLHint{ + Kind: aidomain.MemoryTTLHintDuration, + Value: 200, + Unit: "day", + }, + ) + + if !decision.Allowed || decision.ExpiresAt == nil { + t.Fatalf("ResolveTTL() = %#v, want clamped expiring decision", decision) + } + assertTTLApproxDays(t, before, *decision.ExpiresAt, 90) +} + +func TestAIMemoryPolicyResolveTTLIgnoresDurationForUserPreference(t *testing.T) { + policy := aiMemoryPolicy{} + + decision := policy.ResolveTTL( + aidomain.MemoryNamespaceUserPreference, + "", + &aidomain.MemoryTTLHint{ + Kind: aidomain.MemoryTTLHintDuration, + Value: 3, + Unit: "day", + }, + ) + + if !decision.Allowed { + t.Fatalf("ResolveTTL() allowed = false, want true") + } + if decision.ExpiresAt != nil { + t.Fatalf("ResolveTTL() expires_at = %v, want persistent default", decision.ExpiresAt) + } +} + +func TestAIMemoryPolicyShouldStoreFactRejectsLowConfidenceCandidate(t *testing.T) { + policy := aiMemoryPolicy{} + userID := uint(31) + + decision := policy.ShouldStoreFact( + aidomain.MemoryFactCandidate{ + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + Namespace: aidomain.MemoryNamespaceUserPreference, + FactKey: "answer_style", + FactValueJSON: `{"value":"concise"}`, + Summary: "prefers concise answers", + Confidence: 0.2, + SourceKind: aidomain.MemorySourceModelInferred, + SourceID: "msg-low-confidence", + }, + aidomain.MemoryAccessContext{ + Principal: aidomain.AIToolPrincipal{UserID: userID}, + }, + ) + + if decision.Allowed { + t.Fatalf("ShouldStoreFact() allowed = true, want false") + } + if decision.ReasonCode != aidomain.MemoryReasonDenyLowValueContent { + t.Fatalf("ShouldStoreFact() reason_code = %q, want %q", decision.ReasonCode, aidomain.MemoryReasonDenyLowValueContent) + } +} + func sha256Hex(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) @@ -211,3 +302,12 @@ func sha256Hex(value string) string { func normalizeWhitespace(value string) string { return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") } + +func assertTTLApproxDays(t *testing.T, base time.Time, expiresAt time.Time, wantDays int) { + t.Helper() + got := expiresAt.Sub(base) + want := time.Duration(wantDays) * 24 * time.Hour + if got < want-time.Minute || got > want+time.Minute { + t.Fatalf("ttl duration = %s, want about %s", got, want) + } +} diff --git a/internal/service/system/aiMemoryRecall.go b/internal/service/system/aiMemoryRecall.go index 1920fa0..e1af738 100644 --- a/internal/service/system/aiMemoryRecall.go +++ b/internal/service/system/aiMemoryRecall.go @@ -3,30 +3,40 @@ package system import ( "context" "fmt" + "sort" "strings" "unicode/utf8" "personal_assistant/global" aidomain "personal_assistant/internal/domain/ai" + aimemory "personal_assistant/internal/infrastructure/ai/memory" "personal_assistant/internal/model/entity" "go.uber.org/zap" ) const ( - defaultAIMemoryRecallTopK = 10 - defaultAIMemoryRecallMaxChars = 4000 - defaultAIMemoryRecallMinScore = 0.2 - defaultAIMemoryRAGMaxChars = 2000 - defaultAIMemoryRecentRawTurns = 8 - aiMemoryContextMessageIDPrefix = "memory_context" - aiMemoryContextMessageHeader = "以下是系统恢复的记忆上下文,仅作为背景,不代表用户本轮新输入。" - aiMemoryContextTruncationIndicator = "\n..." + defaultAIMemoryRecallTopK = 10 + defaultAIMemoryRecallMaxChars = 4000 + defaultAIMemoryRecallMinScore = 0.2 + defaultAIMemoryRAGMaxChars = 2000 + defaultAIMemoryRecentRawTurns = 8 + defaultAIMemoryRecentRawTokenBudget = 3000 + aiMemoryContextMessageIDPrefix = "memory_context" + aiMemoryContextMessageHeader = "以下是系统恢复的记忆上下文,仅作为背景,不代表用户本轮新输入。" + aiMemoryContextTruncationIndicator = "\n..." ) type aiMemoryRAGRecallItem struct { - Score float64 - Chunk *entity.AIMemoryDocumentChunk + Score float64 + Chunk *entity.AIMemoryDocumentChunk + ExpandedChunks []*entity.AIMemoryDocumentChunk + ExpandedText string +} + +type aiRecentMessageSelection struct { + Messages []aidomain.Message + Tokens int } // Recall 从已沉淀的 summary 和 self facts 中恢复本轮可读的记忆上下文。 @@ -39,15 +49,20 @@ func (s *AIMemoryService) Recall(ctx context.Context, input aiMemoryRecallInput) if err != nil { return aiMemoryRecallResult{}, err } - facts, err := s.recallSelfFacts(ctx, input.UserID) + facts, err := s.recallSelfFacts(ctx, input.UserID, input.Query) if err != nil { return aiMemoryRecallResult{}, err } ragItems := s.recallLongTermDocumentsFailOpen(ctx, input) - content := buildAIMemoryContextContent(summary, facts, ragItems, input.Query, aiMemoryRecallMaxChars()) + content, diagnostics := buildAIMemoryContextContent(summary, facts, ragItems, aiMemoryRecallMaxChars()) if strings.TrimSpace(content) == "" { - return aiMemoryRecallResult{}, nil + return aiMemoryRecallResult{ + Summary: summary, + Facts: facts, + RAGItems: ragItems, + Diagnostics: diagnostics, + }, nil } message := aidomain.Message{ ID: buildAIMemoryContextMessageID(input.ConversationID), @@ -57,6 +72,10 @@ func (s *AIMemoryService) Recall(ctx context.Context, input aiMemoryRecallInput) return aiMemoryRecallResult{ PromptBlocks: []string{content}, Messages: []aidomain.Message{message}, + Summary: summary, + Facts: facts, + RAGItems: ragItems, + Diagnostics: diagnostics, }, nil } @@ -83,8 +102,12 @@ func (s *AIMemoryService) CompressMessages(ctx context.Context, input aiContextC return reordered, nil } - recent := selectRecentAIMemoryRawMessages(rawMessages, aiMemoryRecentRawMessageLimit()) - return joinAIMemoryFirst(memoryMessages, recent), nil + recentSelection := selectRecentAIMemoryRawMessagesByBudget( + rawMessages, + aiMemoryRecentRawMessageLimit(), + aiMemoryRecentRawTokenBudget(), + ) + return joinAIMemoryFirst(memoryMessages, recentSelection.Messages), nil } func (s *AIMemoryService) recallConversationSummary( @@ -105,15 +128,29 @@ func (s *AIMemoryService) recallConversationSummary( }) } -func (s *AIMemoryService) recallSelfFacts(ctx context.Context, userID uint) ([]*entity.AIMemoryFact, error) { +func (s *AIMemoryService) recallSelfFacts(ctx context.Context, userID uint, query string) ([]*entity.AIMemoryFact, error) { if !aiMemoryEntityEnabled() || userID == 0 { return nil, nil } - return s.repo.ListFacts(ctx, aidomain.MemoryFactQuery{ + namespaces, namespaceFiltered := aiMemoryFactNamespacesForQuery(query) + rows, err := s.repo.ListFacts(ctx, aidomain.MemoryFactQuery{ ScopeKeys: []string{aidomain.BuildSelfMemoryScopeKey(userID)}, AllowedVisibilities: []aidomain.MemoryVisibility{aidomain.MemoryVisibilitySelf}, - Limit: aiMemoryRecallTopK(), + Namespaces: namespaces, + Limit: aiMemoryFactScanLimit(), }) + if err != nil { + return nil, err + } + sortAIMemoryFacts(rows) + if namespaceFiltered { + rows = filterAIMemoryFactsByNamespaces(rows, namespaces) + } + limit := aiMemoryRecallTopK() + if limit > 0 && len(rows) > limit { + rows = rows[:limit] + } + return rows, nil } func (s *AIMemoryService) recallLongTermDocumentsFailOpen( @@ -194,16 +231,7 @@ func (s *AIMemoryService) recallLongTermDocuments( chunkByPointID := make(map[string]*entity.AIMemoryDocumentChunk, len(chunks)) scopeKey := aidomain.BuildSelfMemoryScopeKey(input.UserID) for _, chunk := range chunks { - if chunk == nil { - continue - } - if chunk.EmbeddingModel != aiMemoryEmbedModel() || chunk.EmbeddingDimension != aiMemoryEmbedDimension() { - continue - } - if chunk.ScopeKey != scopeKey || chunk.Visibility != string(aidomain.MemoryVisibilitySelf) { - continue - } - if chunk.UserID == nil || *chunk.UserID != input.UserID { + if !isValidAIMemoryRAGChunk(chunk, scopeKey, input.UserID) { continue } chunkByPointID[strings.TrimSpace(chunk.QdrantPointID)] = chunk @@ -220,110 +248,98 @@ func (s *AIMemoryService) recallLongTermDocuments( } items = append(items, aiMemoryRAGRecallItem{Score: result.Score, Chunk: chunk}) } - return items, nil -} - -func buildAIMemoryContextContent( - summary *entity.AIConversationSummary, - facts []*entity.AIMemoryFact, - ragItems []aiMemoryRAGRecallItem, - query string, - maxChars int, -) string { - summaryText := "" - if summary != nil { - summaryText = normalizeAIMemoryContextLine(summary.SummaryText) - } - factLines := renderAIMemoryFactLines(facts) - ragSection := renderAIMemoryRAGSection(ragItems, aiMemoryRAGMaxChars()) - currentQuery := normalizeAIMemoryContextLine(query) - if summaryText == "" && len(factLines) == 0 && ragSection == "" { - return "" - } - - var builder strings.Builder - builder.WriteString(aiMemoryContextMessageHeader) - builder.WriteString("\n\n## Conversation Summary\n") - if summaryText == "" { - builder.WriteString("- 无") - } else { - builder.WriteString(summaryText) - } - if len(factLines) > 0 { - builder.WriteString("\n\n## Stable Facts\n") - for _, line := range factLines { - builder.WriteString("- ") - builder.WriteString(line) - builder.WriteString("\n") - } - } - if ragSection != "" { - builder.WriteString("\n\n## Long-term Documents\n") - builder.WriteString(ragSection) + if len(items) == 0 { + return nil, nil } - if currentQuery != "" { - builder.WriteString("\n## Current Query\n") - builder.WriteString(currentQuery) + if err := s.expandRAGItems(ctx, input.UserID, items); err != nil { + return nil, err } - return truncateAIMemoryContext(builder.String(), maxChars) + return items, nil } -func renderAIMemoryFactLines(facts []*entity.AIMemoryFact) []string { - if len(facts) == 0 { - return nil - } - lines := make([]string, 0, len(facts)) - for _, fact := range facts { - if fact == nil { +func (s *AIMemoryService) expandRAGItems( + ctx context.Context, + userID uint, + items []aiMemoryRAGRecallItem, +) error { + refs := make([]aidomain.MemoryDocumentChunkRef, 0, len(items)*3) + for _, item := range items { + if item.Chunk == nil { continue } - namespace := normalizeAIMemoryContextLine(fact.Namespace) - factKey := normalizeAIMemoryContextLine(fact.FactKey) - value := normalizeAIMemoryContextLine(fact.Summary) - if value == "" { - value = normalizeAIMemoryContextLine(fact.FactValueJSON) - } - if namespace == "" || factKey == "" || value == "" { - continue + for index := item.Chunk.ChunkIndex - 1; index <= item.Chunk.ChunkIndex+1; index++ { + if index < 0 { + continue + } + refs = append(refs, aidomain.MemoryDocumentChunkRef{ + DocumentID: item.Chunk.DocumentID, + ChunkIndex: index, + }) } - lines = append(lines, fmt.Sprintf("%s/%s: %s", namespace, factKey, value)) } - return lines -} + if len(refs) == 0 { + return nil + } -func renderAIMemoryRAGSection(items []aiMemoryRAGRecallItem, maxChars int) string { - if len(items) == 0 { - return "" + rows, err := s.repo.ListDocumentChunksByRefs(ctx, refs) + if err != nil { + return err } - var builder strings.Builder - for _, item := range items { - if item.Chunk == nil { + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + chunkByRef := make(map[string]*entity.AIMemoryDocumentChunk, len(rows)) + for _, row := range rows { + if !isValidAIMemoryRAGChunk(row, scopeKey, userID) { continue } - content := normalizeAIMemoryContextLine(item.Chunk.ContentText) - if content == "" { + chunkByRef[buildAIMemoryChunkRefKey(row.DocumentID, row.ChunkIndex)] = row + } + + for index := range items { + primary := items[index].Chunk + if primary == nil { continue } - topic := normalizeAIMemoryContextLine(item.Chunk.Topic) - memoryType := normalizeAIMemoryContextLine(item.Chunk.MemoryType) - builder.WriteString("- ") - if topic != "" || memoryType != "" { - builder.WriteString("[") - if memoryType != "" { - builder.WriteString(memoryType) + expanded := make([]*entity.AIMemoryDocumentChunk, 0, 3) + texts := make([]string, 0, 3) + for chunkIndex := primary.ChunkIndex - 1; chunkIndex <= primary.ChunkIndex+1; chunkIndex++ { + if chunkIndex < 0 { + continue } - if topic != "" { - if memoryType != "" { - builder.WriteString("/") - } - builder.WriteString(topic) + chunk := chunkByRef[buildAIMemoryChunkRefKey(primary.DocumentID, chunkIndex)] + if chunk == nil { + continue } - builder.WriteString(fmt.Sprintf(" score=%.3f] ", item.Score)) + expanded = append(expanded, chunk) + texts = append(texts, chunk.ContentText) } - builder.WriteString(content) - builder.WriteString("\n") + if len(expanded) == 0 { + expanded = []*entity.AIMemoryDocumentChunk{primary} + texts = []string{primary.ContentText} + } + items[index].ExpandedChunks = expanded + items[index].ExpandedText = aimemory.MergeChunkTextsWithOverlap(texts, aiMemoryChunkOverlapChars()) + } + return nil +} + +func isValidAIMemoryRAGChunk(chunk *entity.AIMemoryDocumentChunk, scopeKey string, userID uint) bool { + if chunk == nil { + return false } - return strings.TrimSpace(truncateAIMemoryContext(builder.String(), maxChars)) + if chunk.EmbeddingModel != aiMemoryEmbedModel() || chunk.EmbeddingDimension != aiMemoryEmbedDimension() { + return false + } + if chunk.ScopeKey != scopeKey || chunk.Visibility != string(aidomain.MemoryVisibilitySelf) { + return false + } + if chunk.UserID == nil || *chunk.UserID != userID { + return false + } + return true +} + +func buildAIMemoryChunkRefKey(documentID string, chunkIndex int) string { + return strings.TrimSpace(documentID) + "#" + fmt.Sprintf("%d", chunkIndex) } func splitAIMemoryContextMessages(messages []aidomain.Message) ([]aidomain.Message, []aidomain.Message) { @@ -349,11 +365,71 @@ func joinAIMemoryFirst(memoryMessages []aidomain.Message, rawMessages []aidomain return items } -func selectRecentAIMemoryRawMessages(messages []aidomain.Message, limit int) []aidomain.Message { - if limit <= 0 || len(messages) <= limit { - return append([]aidomain.Message(nil), messages...) +func selectRecentAIMemoryRawMessagesByBudget( + messages []aidomain.Message, + limit int, + tokenBudget int, +) aiRecentMessageSelection { + if len(messages) == 0 { + return aiRecentMessageSelection{} + } + if limit <= 0 && tokenBudget <= 0 { + return aiRecentMessageSelection{ + Messages: append([]aidomain.Message(nil), messages...), + Tokens: estimateAIMemoryTokens(messages), + } } - return append([]aidomain.Message(nil), messages[len(messages)-limit:]...) + + selected := make(map[int]struct{}, len(messages)) + tokens := 0 + add := func(index int) { + if index < 0 || index >= len(messages) { + return + } + if _, exists := selected[index]; exists { + return + } + selected[index] = struct{}{} + tokens += estimateAIMemoryTokens([]aidomain.Message{messages[index]}) + } + + lastUserIndex := -1 + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == aidomain.RoleUser { + lastUserIndex = i + break + } + } + if lastUserIndex >= 0 { + add(lastUserIndex) + if next := lastUserIndex + 1; next < len(messages) && messages[next].Role == aidomain.RoleAssistant { + add(next) + } + } else { + add(len(messages) - 1) + } + + for i := len(messages) - 1; i >= 0; i-- { + if _, exists := selected[i]; exists { + continue + } + if limit > 0 && len(selected) >= limit { + break + } + messageTokens := estimateAIMemoryTokens([]aidomain.Message{messages[i]}) + if tokenBudget > 0 && tokens+messageTokens > tokenBudget { + break + } + add(i) + } + + ordered := make([]aidomain.Message, 0, len(selected)) + for i := 0; i < len(messages); i++ { + if _, exists := selected[i]; exists { + ordered = append(ordered, messages[i]) + } + } + return aiRecentMessageSelection{Messages: ordered, Tokens: tokens} } func isAIMemoryContextMessage(message aidomain.Message) bool { @@ -439,9 +515,98 @@ func aiMemoryRecentRawMessageLimit() int { return turns * 2 } +func aiMemoryRecentRawTokenBudget() int { + if global.Config == nil || global.Config.AI.Memory.RecentRawTokenBudget <= 0 { + return defaultAIMemoryRecentRawTokenBudget + } + return global.Config.AI.Memory.RecentRawTokenBudget +} + func aiMemoryCompressThresholdTokens() int { if global.Config == nil { return 0 } return global.Config.AI.Memory.CompressThresholdTokens } + +func aiMemorySummaryRefreshEveryTurns() int { + if global.Config == nil || global.Config.AI.Memory.SummaryRefreshEveryTurns <= 0 { + return 10 + } + return global.Config.AI.Memory.SummaryRefreshEveryTurns +} + +func aiMemoryFactScanLimit() int { + limit := aiMemoryRecallTopK() * 4 + if limit < 20 { + limit = 20 + } + return limit +} + +func aiMemoryFactNamespacesForQuery(query string) ([]string, bool) { + namespaces := []string{aidomain.MemoryNamespaceUserPreference} + lower := strings.ToLower(strings.TrimSpace(query)) + if lower == "" { + return nil, false + } + keywords := []string{ + "目标", "计划", "刷题", "oj", "leetcode", "codeforces", "面试", "学习", "luogu", "lanqiao", + } + for _, keyword := range keywords { + if strings.Contains(lower, strings.ToLower(keyword)) { + namespaces = append(namespaces, aidomain.MemoryNamespaceOJGoal, aidomain.MemoryNamespaceOJProfile) + return uniqueAIMemoryNamespaces(namespaces), true + } + } + return nil, false +} + +func uniqueAIMemoryNamespaces(input []string) []string { + if len(input) == 0 { + return nil + } + seen := make(map[string]struct{}, len(input)) + out := make([]string, 0, len(input)) + for _, item := range input { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} + +func filterAIMemoryFactsByNamespaces( + facts []*entity.AIMemoryFact, + namespaces []string, +) []*entity.AIMemoryFact { + if len(namespaces) == 0 || len(facts) == 0 { + return facts + } + allowed := make(map[string]struct{}, len(namespaces)) + for _, namespace := range namespaces { + allowed[strings.TrimSpace(namespace)] = struct{}{} + } + filtered := make([]*entity.AIMemoryFact, 0, len(facts)) + for _, fact := range facts { + if fact == nil { + continue + } + if _, ok := allowed[strings.TrimSpace(fact.Namespace)]; ok { + filtered = append(filtered, fact) + } + } + return filtered +} + +func sortAIMemoryFacts(facts []*entity.AIMemoryFact) { + sort.SliceStable(facts, func(i, j int) bool { + return compareAIMemoryFacts(facts[i], facts[j]) + }) +} diff --git a/internal/service/system/aiMemoryRecall_test.go b/internal/service/system/aiMemoryRecall_test.go index 68f3c78..eadec60 100644 --- a/internal/service/system/aiMemoryRecall_test.go +++ b/internal/service/system/aiMemoryRecall_test.go @@ -73,8 +73,9 @@ func TestAIMemoryRecallMessagesBuildsSummaryAndFactsContext(t *testing.T) { assertAIMemoryRecallContains(t, message.Content, "## Stable Facts") assertAIMemoryRecallContains(t, message.Content, "user_preference/answer_style") assertAIMemoryRecallContains(t, message.Content, "以后回答尽量简洁") - assertAIMemoryRecallContains(t, message.Content, "## Current Query") - assertAIMemoryRecallContains(t, message.Content, "下一步怎么实现压缩?") + if strings.Contains(message.Content, "## Current Query") || strings.Contains(message.Content, "下一步怎么实现压缩?") { + t.Fatalf("memory message must not duplicate current query:\n%s", message.Content) + } } func TestAIMemoryRecallMessagesInjectsRAGDocumentsInScoreOrder(t *testing.T) { @@ -163,6 +164,131 @@ func TestAIMemoryRecallMessagesInjectsRAGDocumentsInScoreOrder(t *testing.T) { } } +func TestAIMemoryRecallMessagesExpandsAdjacentChunksAndDedupesOverlap(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + service.embedder = &fakeMemoryEmbedder{vectors: [][]float32{{0.1, 0.2, 0.3}}} + vectorSearcher := &fakeMemoryVectorStore{ + searchResults: []aidomain.MemoryVectorSearchResult{ + {QdrantPointID: "88888888-2222-2222-2222-222222222222", Score: 0.93}, + }, + } + service.vectorSearcher = vectorSearcher + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableLongTermMemory: true, + RecallTopK: 3, + RecallMaxChars: 4000, + RecallMinScore: 0.5, + RAGMaxChars: 2000, + EmbedModel: "qwen3-vl-embedding", + EmbedDimension: 3, + ChunkOverlapChars: 5, + }) + defer restore() + + userID := uint(32) + now := time.Now() + scopeKey := aidomain.BuildSelfMemoryScopeKey(userID) + doc := &entity.AIMemoryDocument{ + ID: "doc-rag-window", + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + Title: "rag", + Summary: "rag summary", + ContentText: "aaaaa第一段内容\n\n内容\n\n第二段核心\n\n核心\n\n第三段结尾", + SourceKind: string(aidomain.MemorySourceModelInferred), + SourceID: "chunk-rag-window", + } + if err := service.repo.BatchUpsertDocuments(context.Background(), []*entity.AIMemoryDocument{doc}); err != nil { + t.Fatalf("BatchUpsertDocuments() error = %v", err) + } + if err := service.repo.ReplaceDocumentChunks(context.Background(), doc.ID, []*entity.AIMemoryDocumentChunk{ + { + ID: "chunk-rag-window-0", + DocumentID: doc.ID, + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + ChunkIndex: 0, + ContentText: "aaaaa第一段内容", + ContentHash: aidomain.BuildMemoryDocumentContentHash("aaaaa第一段内容"), + EmbeddingModel: "qwen3-vl-embedding", + EmbeddingDimension: 3, + QdrantPointID: "88888888-1111-1111-1111-111111111111", + IndexedAt: &now, + }, + { + ID: "chunk-rag-window-1", + DocumentID: doc.ID, + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + ChunkIndex: 1, + ContentText: "内容\n\n第二段核心", + ContentHash: aidomain.BuildMemoryDocumentContentHash("内容\n\n第二段核心"), + EmbeddingModel: "qwen3-vl-embedding", + EmbeddingDimension: 3, + QdrantPointID: "88888888-2222-2222-2222-222222222222", + IndexedAt: &now, + }, + { + ID: "chunk-rag-window-2", + DocumentID: doc.ID, + ScopeKey: scopeKey, + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + ChunkIndex: 2, + ContentText: "核心\n\n第三段结尾", + ContentHash: aidomain.BuildMemoryDocumentContentHash("核心\n\n第三段结尾"), + EmbeddingModel: "qwen3-vl-embedding", + EmbeddingDimension: 3, + QdrantPointID: "88888888-3333-3333-3333-333333333333", + IndexedAt: &now, + }, + }); err != nil { + t.Fatalf("ReplaceDocumentChunks() error = %v", err) + } + + messages, err := service.RecallMessages(context.Background(), aiMemoryRecallInput{ + ConversationID: "conv-rag-window", + UserID: userID, + Query: "帮我回忆这一段长期知识", + }) + if err != nil { + t.Fatalf("RecallMessages() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("RecallMessages() len = %d, want 1", len(messages)) + } + content := messages[0].Content + assertAIMemoryRecallContains(t, content, "aaaaa第一段内容") + assertAIMemoryRecallContains(t, content, "第二段核心") + assertAIMemoryRecallContains(t, content, "第三段结尾") + if strings.Contains(content, "内容内容") || strings.Contains(content, "核心核心") { + t.Fatalf("expanded RAG content contains duplicated overlap:\n%s", content) + } + first := strings.Index(content, "aaaaa第一段内容") + second := strings.Index(content, "第二段核心") + third := strings.Index(content, "第三段结尾") + if !(first >= 0 && first < second && second < third) { + t.Fatalf("expanded chunk order invalid:\n%s", content) + } +} + func TestAIMemoryRecallMessagesRAGFailureKeepsSummary(t *testing.T) { db := newAIMemoryWritebackTestDB(t) service := newAIMemoryWritebackTestService(db, nil) @@ -198,6 +324,89 @@ func TestAIMemoryRecallMessagesRAGFailureKeepsSummary(t *testing.T) { } } +func TestAIMemoryHybridMemoryContentSortsFactsAndClipsRAGFirst(t *testing.T) { + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + RecallMaxChars: 360, + RAGMaxChars: 120, + RecallMinScore: 0.5, + }) + defer restore() + + now := time.Now() + userID := uint(18) + facts := []*entity.AIMemoryFact{ + { + ScopeKey: aidomain.BuildSelfMemoryScopeKey(userID), + Namespace: "z_namespace", + FactKey: "later", + Summary: "后面的 namespace", + SourceKind: string(aidomain.MemorySourceExplicitUserStatement), + UpdatedAt: now, + }, + { + ScopeKey: aidomain.BuildSelfMemoryScopeKey(userID), + Namespace: "a_namespace", + FactKey: "model", + Summary: "模型推断的事实", + SourceKind: string(aidomain.MemorySourceModelInferred), + UpdatedAt: now.Add(2 * time.Hour), + }, + { + ScopeKey: aidomain.BuildSelfMemoryScopeKey(userID), + Namespace: "a_namespace", + FactKey: "explicit", + Summary: "用户显式确认的事实", + SourceKind: string(aidomain.MemorySourceExplicitUserStatement), + UpdatedAt: now, + }, + } + ragItems := []aiMemoryRAGRecallItem{ + { + Score: 0.6, + Chunk: &entity.AIMemoryDocumentChunk{ + ID: "chunk-low", + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + ContentText: repeatMemoryText("低分长期文档内容", 12), + }, + }, + { + Score: 0.9, + Chunk: &entity.AIMemoryDocumentChunk{ + ID: "chunk-high", + MemoryType: string(aidomain.MemoryTypeSemantic), + Topic: "rag", + ContentText: repeatMemoryText("高分长期文档内容", 12), + }, + }, + } + + content, diagnostics := buildAIMemoryContextContent( + &entity.AIConversationSummary{SummaryText: "固定保留的摘要。"}, + facts, + ragItems, + aiMemoryRecallMaxChars(), + ) + assertAIMemoryRecallContains(t, content, "固定保留的摘要。") + assertAIMemoryRecallContains(t, content, "用户显式确认的事实") + assertAIMemoryRecallContains(t, content, "模型推断的事实") + explicitIndex := strings.Index(content, "用户显式确认的事实") + modelIndex := strings.Index(content, "模型推断的事实") + if explicitIndex < 0 || modelIndex < 0 || explicitIndex > modelIndex { + t.Fatalf("facts not sorted by namespace/source priority:\n%s", content) + } + if strings.Contains(content, "低分长期文档内容") && strings.Index(content, "低分长期文档内容") < strings.Index(content, "高分长期文档内容") { + t.Fatalf("RAG items not sorted by score:\n%s", content) + } + if strings.Contains(content, "## Current Query") { + t.Fatalf("memory content contains Current Query section:\n%s", content) + } + if diagnostics.SummaryKept != 1 || diagnostics.FactsKept == 0 || diagnostics.RAGCandidates != 2 || diagnostics.RAGDropped == 0 { + t.Fatalf("diagnostics = %+v, want summary/facts kept and RAG clipped first", diagnostics) + } +} + func TestAIMemoryRecallMessagesEmptyWhenNoSummaryOrFacts(t *testing.T) { db := newAIMemoryWritebackTestDB(t) service := newAIMemoryWritebackTestService(db, nil) @@ -275,6 +484,165 @@ func TestAIMemoryCompressMessagesKeepsMemoryAndRecentTurns(t *testing.T) { } } +func TestAIMemoryHybridPlannerKeepsRecentTurnAndExplicitQueryDiagnostics(t *testing.T) { + planner := newDefaultAIHybridContextPlanner() + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + RecentRawTurns: 1, + CompressThresholdTokens: 1, + }) + defer restore() + + result, err := planner.Plan(context.Background(), aiHybridContextInput{ + ConversationID: "conv-hybrid", + Query: "当前新问题", + RawHistory: []aidomain.Message{ + {ID: "msg-1", Role: aidomain.RoleUser, Content: "很早的用户消息"}, + {ID: "msg-2", Role: aidomain.RoleAssistant, Content: "很早的助手消息"}, + {ID: "msg-3", Role: aidomain.RoleUser, Content: "最近的用户消息"}, + {ID: "msg-4", Role: aidomain.RoleAssistant, Content: "最近的助手消息"}, + }, + Recall: aiMemoryRecallResult{ + Messages: []aidomain.Message{ + {ID: "memory_context_conv", Role: aidomain.RoleAssistant, Content: aiMemoryContextMessageHeader + "\nsummary"}, + }, + Diagnostics: aiHybridContextDiagnostics{SummaryKept: 1}, + }, + }) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + wantIDs := []string{"memory_context_conv", "msg-3", "msg-4"} + if len(result.History) != len(wantIDs) { + t.Fatalf("History len = %d, want %d: %+v", len(result.History), len(wantIDs), result.History) + } + for i, want := range wantIDs { + if result.History[i].ID != want { + t.Fatalf("History[%d].ID = %q, want %q", i, result.History[i].ID, want) + } + } + if !result.Diagnostics.CurrentQueryProvided || result.Diagnostics.CurrentQueryInHistory { + t.Fatalf("query diagnostics = %+v, want explicit query preserved outside clipped history", result.Diagnostics) + } + if !result.Diagnostics.CompressionTriggered || result.Diagnostics.RecentMessagesKept != 2 { + t.Fatalf("compression diagnostics = %+v", result.Diagnostics) + } +} + +func TestAIMemoryHybridPlannerUsesTokenBudgetForRecentTurns(t *testing.T) { + planner := newDefaultAIHybridContextPlanner() + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + RecentRawTurns: 3, + RecentRawTokenBudget: 6, + CompressThresholdTokens: 1, + }) + defer restore() + + result, err := planner.Plan(context.Background(), aiHybridContextInput{ + ConversationID: "conv-token-budget", + RawHistory: []aidomain.Message{ + {ID: "msg-1", Role: aidomain.RoleUser, Content: "11111111111111111111"}, + {ID: "msg-2", Role: aidomain.RoleAssistant, Content: "22222222222222222222"}, + {ID: "msg-3", Role: aidomain.RoleUser, Content: "33333333333333333333"}, + {ID: "msg-4", Role: aidomain.RoleAssistant, Content: "44444444444444444444"}, + }, + }) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + wantIDs := []string{"msg-3", "msg-4"} + if len(result.History) != len(wantIDs) { + t.Fatalf("History len = %d, want %d: %+v", len(result.History), len(wantIDs), result.History) + } + for i, want := range wantIDs { + if result.History[i].ID != want { + t.Fatalf("History[%d].ID = %q, want %q", i, result.History[i].ID, want) + } + } + if result.Diagnostics.RecentMessagesTokenBudget != 6 || result.Diagnostics.CompressionReason != "budget" { + t.Fatalf("diagnostics = %+v, want token budget compression", result.Diagnostics) + } + if result.Diagnostics.RecentMessagesTokens < 10 { + t.Fatalf("RecentMessagesTokens = %d, want forced latest pair kept", result.Diagnostics.RecentMessagesTokens) + } +} + +func TestAIMemoryContextContentPlacesSummaryHeadBeforeSummaryText(t *testing.T) { + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + RecallMaxChars: 2000, + RAGMaxChars: 200, + }) + defer restore() + + content, _ := buildAIMemoryContextContent( + &entity.AIConversationSummary{ + SummaryText: "这是会话正文摘要。", + KeyPointsJSON: `["确认采用方案 B","recent turns 改成 token budget"]`, + OpenLoopsJSON: `["补 runtime tool 压缩测试"]`, + }, + nil, + nil, + aiMemoryRecallMaxChars(), + ) + latestIndex := strings.Index(content, "## Latest Decisions") + openLoopIndex := strings.Index(content, "## Open Loops") + summaryIndex := strings.Index(content, "## Conversation Summary") + if latestIndex < 0 || openLoopIndex < 0 || summaryIndex < 0 { + t.Fatalf("summary head sections missing:\n%s", content) + } + if !(latestIndex < openLoopIndex && openLoopIndex < summaryIndex) { + t.Fatalf("summary head order invalid:\n%s", content) + } + assertAIMemoryRecallContains(t, content, "确认采用方案 B") + assertAIMemoryRecallContains(t, content, "补 runtime tool 压缩测试") + assertAIMemoryRecallContains(t, content, "这是会话正文摘要。") +} + +func TestAIMemoryRecallFiltersFactsByQueryNamespaces(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, nil) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + RecallTopK: 5, + RecallMaxChars: 2000, + }) + defer restore() + + userID := uint(31) + upsertAIMemoryRecallFact(t, service, userID, "answer_style", "以后先给结论,再给步骤。") + upsertAIMemoryRecallFactWithNamespace(t, service, userID, aidomain.MemoryNamespaceOJGoal, "current_goal", "本周面试前刷完 20 道题。") + upsertAIMemoryRecallFactWithNamespace(t, service, userID, aidomain.MemoryNamespaceOJProfile, "main_platform", "LeetCode 是当前主刷平台。") + upsertAIMemoryRecallFactWithNamespace(t, service, userID, "misc", "noise", "这个 namespace 不应该被带入。") + + result, err := service.Recall(context.Background(), aiMemoryRecallInput{ + ConversationID: "conv-fact-filter", + UserID: userID, + Query: "帮我整理面试刷题计划", + }) + if err != nil { + t.Fatalf("Recall() error = %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("Recall().Messages len = %d, want 1", len(result.Messages)) + } + content := result.Messages[0].Content + assertAIMemoryRecallContains(t, content, "user_preference/answer_style") + assertAIMemoryRecallContains(t, content, "oj_goal/current_goal") + assertAIMemoryRecallContains(t, content, "oj_profile/main_platform") + if strings.Contains(content, "misc/noise") { + t.Fatalf("unexpected namespace fact injected:\n%s", content) + } + preferenceIndex := strings.Index(content, "user_preference/answer_style") + goalIndex := strings.Index(content, "oj_goal/current_goal") + profileIndex := strings.Index(content, "oj_profile/main_platform") + if !(preferenceIndex >= 0 && preferenceIndex < goalIndex && goalIndex < profileIndex) { + t.Fatalf("fact order invalid:\n%s", content) + } +} + func TestDefaultAIContextAssemblerRecallsAndCompressesWithAIMemoryService(t *testing.T) { db := newAIMemoryWritebackTestDB(t) service := newAIMemoryWritebackTestService(db, nil) @@ -322,6 +690,9 @@ func TestDefaultAIContextAssemblerRecallsAndCompressesWithAIMemoryService(t *tes } } assertAIMemoryRecallContains(t, snapshot.History[0].Content, "旧历史已压缩成摘要") + if snapshot.Diagnostics.CompressionReason == "" || snapshot.Diagnostics.RecentMessagesTokenBudget <= 0 { + t.Fatalf("Diagnostics = %+v, want compression reason and token budget", snapshot.Diagnostics) + } } func upsertAIMemoryRecallSummary( @@ -371,6 +742,36 @@ func upsertAIMemoryRecallFact(t *testing.T, service *AIMemoryService, userID uin } } +func upsertAIMemoryRecallFactWithNamespace( + t *testing.T, + service *AIMemoryService, + userID uint, + namespace string, + factKey string, + summary string, +) { + t.Helper() + now := time.Now() + if err := service.repo.UpsertFact(context.Background(), &entity.AIMemoryFact{ + ScopeKey: aidomain.BuildSelfMemoryScopeKey(userID), + ScopeType: string(aidomain.MemoryScopeSelf), + Visibility: string(aidomain.MemoryVisibilitySelf), + UserID: &userID, + Namespace: namespace, + FactKey: factKey, + FactValueJSON: fmtAIMemoryRecallFactValue(summary), + Summary: summary, + Confidence: 0.9, + SourceKind: string(aidomain.MemorySourceExplicitUserStatement), + SourceID: "msg-fact-" + factKey, + EffectiveAt: &now, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("upsert fact: %v", err) + } +} + func upsertAIMemoryRecallDocumentChunk( t *testing.T, service *AIMemoryService, @@ -379,6 +780,20 @@ func upsertAIMemoryRecallDocumentChunk( chunkID string, pointID string, content string, +) { + t.Helper() + upsertAIMemoryRecallDocumentChunkWithIndex(t, service, userID, documentID, chunkID, pointID, 0, content) +} + +func upsertAIMemoryRecallDocumentChunkWithIndex( + t *testing.T, + service *AIMemoryService, + userID uint, + documentID string, + chunkID string, + pointID string, + chunkIndex int, + content string, ) { t.Helper() now := time.Now() @@ -410,7 +825,7 @@ func upsertAIMemoryRecallDocumentChunk( UserID: &userID, MemoryType: string(aidomain.MemoryTypeSemantic), Topic: "rag", - ChunkIndex: 0, + ChunkIndex: chunkIndex, ContentText: content, ContentHash: aidomain.BuildMemoryDocumentContentHash(content), EmbeddingModel: "qwen3-vl-embedding", diff --git a/internal/service/system/aiMemorySvc.go b/internal/service/system/aiMemorySvc.go index ae8b5ad..e64abcc 100644 --- a/internal/service/system/aiMemorySvc.go +++ b/internal/service/system/aiMemorySvc.go @@ -21,6 +21,14 @@ type aiMemoryRecallResult struct { PromptBlocks []string // Messages 预留给后续直接注入 runtime history 的记忆消息片段。 Messages []aidomain.Message + // Summary 是本轮恢复出的会话摘要快照。 + Summary *entity.AIConversationSummary + // Facts 是本轮恢复出的结构化事实候选。 + Facts []*entity.AIMemoryFact + // RAGItems 是本轮按 query 召回的长期文档片段。 + RAGItems []aiMemoryRAGRecallItem + // Diagnostics 记录召回侧各来源的预算和裁剪情况。 + Diagnostics aiHybridContextDiagnostics } type aiMemoryWritebackInput struct { @@ -72,7 +80,7 @@ func NewAIMemoryService(repositoryGroup *repository.Group) *AIMemoryService { repo: repositoryGroup.SystemRepositorySupplier.GetAIMemoryRepository(), outboxRepo: repositoryGroup.SystemRepositorySupplier.GetOutboxRepository(), policy: aiMemoryPolicy{}, - extractor: aimemory.NewRuleExtractor(aimemory.Options{}), + extractor: newAIMemoryExtractor(), chunker: aimemory.NewParagraphChunker(aimemory.ChunkerOptions{ MaxChars: aiMemoryChunkMaxChars(), OverlapChars: aiMemoryChunkOverlapChars(), @@ -119,10 +127,6 @@ func (s *AIMemoryService) ScheduleDocumentUpsert(ctx context.Context, docs []*en return nil } -func newAIMemoryVectorStore() aidomain.MemoryVectorStore { - return newAIMemoryQdrantStore() -} - func newAIMemoryQdrantStore() *aimemory.QdrantVectorStore { if global.QdrantClient == nil { return nil diff --git a/internal/service/system/aiMemoryWriteback.go b/internal/service/system/aiMemoryWriteback.go index 2a8ba8e..e5c87b4 100644 --- a/internal/service/system/aiMemoryWriteback.go +++ b/internal/service/system/aiMemoryWriteback.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "strings" "time" @@ -16,6 +17,7 @@ type aiMemoryWritebackSnapshot struct { UserMessage aidomain.Message AssistantMessage aidomain.Message PreviousSummary *entity.AIConversationSummary + Messages []*entity.AIMessage } // OnTurnCompleted extracts and persists memory candidates after a successful AI turn. @@ -37,6 +39,7 @@ func (s *AIMemoryService) OnTurnCompleted(ctx context.Context, input aiMemoryWri if snapshot == nil || strings.TrimSpace(snapshot.AssistantMessage.Content) == "" { return nil } + summaryRefreshMode, recentMessages := buildAIMemorySummaryRefreshInput(snapshot.Messages, snapshot.PreviousSummary) extracted, err := s.extractor.Extract(ctx, aidomain.MemoryExtractionInput{ ConversationID: input.ConversationID, @@ -45,14 +48,16 @@ func (s *AIMemoryService) OnTurnCompleted(ctx context.Context, input aiMemoryWri Principal: normalizeMemoryPrincipal(input), UserMessage: snapshot.UserMessage, AssistantMessage: snapshot.AssistantMessage, + RecentMessages: recentMessages, PreviousSummaryText: previousSummaryText(snapshot.PreviousSummary), + SummaryRefreshMode: summaryRefreshMode, }) if err != nil { return err } access := s.buildMemoryAccessContext(input) - if err := s.applyConversationSummary(ctx, input, extracted.Summary); err != nil { + if err := s.applyConversationSummary(ctx, input, snapshot.PreviousSummary, summaryRefreshMode, extracted.Summary); err != nil { return err } if err := s.applyFactCandidates(ctx, extracted.Facts, access); err != nil { @@ -101,32 +106,22 @@ func (s *AIMemoryService) buildWritebackSnapshot( UserMessage: aiEntityMessageToDomain(userMessage), AssistantMessage: aiEntityMessageToDomain(assistantMessage), PreviousSummary: previous, + Messages: messages, }, nil } func (s *AIMemoryService) applyConversationSummary( ctx context.Context, input aiMemoryWritebackInput, + previous *entity.AIConversationSummary, + refreshMode aidomain.MemorySummaryRefreshMode, draft *aidomain.ConversationSummaryDraft, ) error { - if draft == nil || strings.TrimSpace(draft.SummaryText) == "" { + summary := buildAIMemoryConversationSummaryEntity(input, previous, refreshMode, draft) + if summary == nil { return nil } - now := time.Now() - scopeKey := aidomain.BuildConversationMemoryScopeKey(input.UserID, input.OrgID) - return s.repo.UpsertConversationSummary(ctx, &entity.AIConversationSummary{ - ConversationID: input.ConversationID, - UserID: input.UserID, - OrgID: cloneMemoryUintPtr(input.OrgID), - ScopeKey: scopeKey, - CompressedUntilMessageID: draft.CompressedUntilMessageID, - SummaryText: draft.SummaryText, - KeyPointsJSON: defaultMemoryJSONList(draft.KeyPointsJSON), - OpenLoopsJSON: defaultMemoryJSONList(draft.OpenLoopsJSON), - TokenEstimate: draft.TokenEstimate, - CreatedAt: now, - UpdatedAt: now, - }) + return s.repo.UpsertConversationSummary(ctx, summary) } func (s *AIMemoryService) applyFactCandidates( @@ -163,7 +158,7 @@ func (s *AIMemoryService) applyFactCandidates( if !shouldUpsert { continue } - ttl := s.policy.ResolveTTL(candidate.Namespace, "") + ttl := s.policy.ResolveTTL(candidate.Namespace, "", candidate.TTLHint) fact := buildMemoryFactEntity(candidate, scopeDecision, visibilityDecision, ttl.ExpiresAt) if err := s.repo.UpsertFact(ctx, fact); err != nil { return err @@ -232,7 +227,7 @@ func (s *AIMemoryService) applyDocumentCandidates( if !visibilityDecision.Allowed { continue } - ttl := s.policy.ResolveTTL("", candidate.MemoryType) + ttl := s.policy.ResolveTTL("", candidate.MemoryType, candidate.TTLHint) docs = append(docs, buildMemoryDocumentEntity(candidate, scopeDecision, visibilityDecision, decision, ttl.ExpiresAt)) } if len(docs) == 0 { @@ -262,7 +257,7 @@ func buildMemoryFactEntity( FactKey: candidate.FactKey, FactValueJSON: candidate.FactValueJSON, Summary: candidate.Summary, - Confidence: 0.9, + Confidence: normalizeMemoryConfidence(candidate.Confidence, 0.9), SourceKind: string(candidate.SourceKind), SourceID: candidate.SourceID, EffectiveAt: &now, @@ -295,8 +290,8 @@ func buildMemoryDocumentEntity( ContentHash: decision.ContentHash, SummaryHash: decision.SummaryHash, DedupKey: decision.DedupKey, - Importance: 0.8, - QualityScore: 0.8, + Importance: normalizeMemoryConfidence(candidate.Confidence, 0.8), + QualityScore: normalizeMemoryConfidence(candidate.Confidence, 0.8), EmbeddingModel: aiMemoryEmbedModel(), SourceKind: string(candidate.SourceKind), SourceID: candidate.SourceID, @@ -346,6 +341,179 @@ func previousSummaryText(summary *entity.AIConversationSummary) string { return summary.SummaryText } +func buildAIMemorySummaryRefreshInput( + messages []*entity.AIMessage, + previous *entity.AIConversationSummary, +) (aidomain.MemorySummaryRefreshMode, []aidomain.Message) { + recentEntities := selectAIMemoryMessagesAfterSummaryBoundary(messages, previous) + recentMessages := aiEntityMessagesToDomain(recentEntities) + if previous == nil || strings.TrimSpace(previous.SummaryText) == "" { + return aidomain.MemorySummaryRefreshModeFullRefresh, recentMessages + } + if countAIMemoryCompletedTurns(recentEntities) >= aiMemorySummaryRefreshEveryTurns() { + return aidomain.MemorySummaryRefreshModeFullRefresh, recentMessages + } + return aidomain.MemorySummaryRefreshModeHeadUpdate, recentMessages +} + +func selectAIMemoryMessagesAfterSummaryBoundary( + messages []*entity.AIMessage, + previous *entity.AIConversationSummary, +) []*entity.AIMessage { + if len(messages) == 0 { + return nil + } + boundaryID := "" + if previous != nil { + boundaryID = strings.TrimSpace(previous.CompressedUntilMessageID) + } + start := 0 + if boundaryID != "" { + for index, message := range messages { + if message != nil && message.ID == boundaryID { + start = index + 1 + break + } + } + } + return append([]*entity.AIMessage(nil), messages[start:]...) +} + +func aiEntityMessagesToDomain(messages []*entity.AIMessage) []aidomain.Message { + items := make([]aidomain.Message, 0, len(messages)) + for _, message := range messages { + if message == nil { + continue + } + domainMessage := aiEntityMessageToDomain(message) + if strings.TrimSpace(domainMessage.Content) == "" { + continue + } + items = append(items, domainMessage) + } + return items +} + +func countAIMemoryCompletedTurns(messages []*entity.AIMessage) int { + count := 0 + for _, message := range messages { + if message == nil { + continue + } + if strings.TrimSpace(message.Role) == aidomain.RoleAssistant && message.Status == aiMessageStatusSuccess { + count++ + } + } + return count +} + +func buildAIMemoryConversationSummaryEntity( + input aiMemoryWritebackInput, + previous *entity.AIConversationSummary, + refreshMode aidomain.MemorySummaryRefreshMode, + draft *aidomain.ConversationSummaryDraft, +) *entity.AIConversationSummary { + scopeKey := aidomain.BuildConversationMemoryScopeKey(input.UserID, input.OrgID) + now := time.Now() + switch refreshMode { + case aidomain.MemorySummaryRefreshModeHeadUpdate: + if previous == nil && draft == nil { + return nil + } + summaryText := "" + compressedUntilMessageID := "" + createdAt := now + if previous != nil { + summaryText = strings.TrimSpace(previous.SummaryText) + compressedUntilMessageID = strings.TrimSpace(previous.CompressedUntilMessageID) + createdAt = previous.CreatedAt + } + if summaryText == "" && draft != nil { + summaryText = strings.TrimSpace(draft.SummaryText) + } + if compressedUntilMessageID == "" && draft != nil { + compressedUntilMessageID = strings.TrimSpace(draft.CompressedUntilMessageID) + } + return &entity.AIConversationSummary{ + ConversationID: input.ConversationID, + UserID: input.UserID, + OrgID: cloneMemoryUintPtr(input.OrgID), + ScopeKey: scopeKey, + CompressedUntilMessageID: compressedUntilMessageID, + SummaryText: summaryText, + KeyPointsJSON: mergeAIMemorySummaryJSONList(summaryKeyPointsJSON(previous), summaryDraftKeyPointsJSON(draft), 8), + OpenLoopsJSON: mergeAIMemorySummaryJSONList(summaryOpenLoopsJSON(previous), summaryDraftOpenLoopsJSON(draft), 8), + TokenEstimate: estimateAIMemoryTokens([]aidomain.Message{{Content: summaryText}}), + CreatedAt: createdAt, + UpdatedAt: now, + } + default: + if draft == nil || strings.TrimSpace(draft.SummaryText) == "" { + return nil + } + createdAt := now + if previous != nil && !previous.CreatedAt.IsZero() { + createdAt = previous.CreatedAt + } + return &entity.AIConversationSummary{ + ConversationID: input.ConversationID, + UserID: input.UserID, + OrgID: cloneMemoryUintPtr(input.OrgID), + ScopeKey: scopeKey, + CompressedUntilMessageID: draft.CompressedUntilMessageID, + SummaryText: strings.TrimSpace(draft.SummaryText), + KeyPointsJSON: defaultMemoryJSONList(draft.KeyPointsJSON), + OpenLoopsJSON: defaultMemoryJSONList(draft.OpenLoopsJSON), + TokenEstimate: draft.TokenEstimate, + CreatedAt: createdAt, + UpdatedAt: now, + } + } +} + +func mergeAIMemorySummaryJSONList(baseJSON string, newJSON string, limit int) string { + base := decodeAIMemorySummaryLines(baseJSON) + recent := decodeAIMemorySummaryLines(newJSON) + merged := make([]string, 0, len(recent)+len(base)) + seen := make(map[string]struct{}, len(recent)+len(base)) + for _, item := range append(recent, base...) { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + merged = append(merged, item) + if limit > 0 && len(merged) >= limit { + break + } + } + if len(merged) == 0 { + return "[]" + } + raw, err := json.Marshal(merged) + if err != nil { + return "[]" + } + return string(raw) +} + +func summaryDraftKeyPointsJSON(draft *aidomain.ConversationSummaryDraft) string { + if draft == nil { + return "" + } + return draft.KeyPointsJSON +} + +func summaryDraftOpenLoopsJSON(draft *aidomain.ConversationSummaryDraft) string { + if draft == nil { + return "" + } + return draft.OpenLoopsJSON +} + func defaultMemoryJSONList(value string) string { if strings.TrimSpace(value) == "" { return "[]" @@ -358,6 +526,16 @@ func buildMemoryDocumentID(scopeKey string, dedupKey string) string { return "mem_doc_" + hex.EncodeToString(sum[:])[:32] } +func normalizeMemoryConfidence(confidence float64, fallback float64) float64 { + if confidence <= 0 { + return fallback + } + if confidence > 1 { + return 1 + } + return confidence +} + func aiMemoryEnabled() bool { return global.Config != nil && global.Config.AI.Memory.Enabled } diff --git a/internal/service/system/aiMemoryWriteback_test.go b/internal/service/system/aiMemoryWriteback_test.go index 40db717..5bee003 100644 --- a/internal/service/system/aiMemoryWriteback_test.go +++ b/internal/service/system/aiMemoryWriteback_test.go @@ -107,6 +107,350 @@ func TestAIMemoryWritebackPersistsSummaryFactAndDocument(t *testing.T) { } } +func TestAIMemoryWritebackPersistsModelInferredCandidates(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, staticMemoryExtractor{}) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + EnableLongTermMemory: true, + EmbedModel: "text-embedding-test", + }) + defer restore() + + createAIWritebackMessagesWithContent( + t, + db, + "conv-llm-success", + "msg-user-llm-success", + "msg-ai-llm-success", + "请记住我的回答偏好", + "以后可以更简洁地回答。", + aiMessageStatusSuccess, + ) + + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-llm-success", + UserID: 18, + UserMessageID: "msg-user-llm-success", + AssistantMessageID: "msg-ai-llm-success", + Principal: aidomain.AIToolPrincipal{UserID: 18}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + + assertAIMemoryWritebackCount(t, db, &entity.AIConversationSummary{}, 1) + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryFact{}, 1) + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryDocument{}, 1) + + var fact entity.AIMemoryFact + if err := db.First(&fact).Error; err != nil { + t.Fatalf("load fact: %v", err) + } + if fact.SourceKind != string(aidomain.MemorySourceModelInferred) { + t.Fatalf("fact source_kind = %q, want model_inferred", fact.SourceKind) + } + if fact.Confidence < 0.909 || fact.Confidence > 0.911 { + t.Fatalf("fact confidence = %v, want 0.91", fact.Confidence) + } +} + +func TestAIMemoryWritebackAppliesTTLHintThroughPolicy(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, ttlHintMemoryExtractor{}) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + }) + defer restore() + + createAIWritebackMessagesWithContent( + t, + db, + "conv-ttl-hint", + "msg-user-ttl-hint", + "msg-ai-ttl-hint", + "我最近 30 天目标是刷 200 道题。", + "已记录你的阶段目标。", + aiMessageStatusSuccess, + ) + before := time.Now() + + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-ttl-hint", + UserID: 28, + UserMessageID: "msg-user-ttl-hint", + AssistantMessageID: "msg-ai-ttl-hint", + Principal: aidomain.AIToolPrincipal{UserID: 28}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + + var fact entity.AIMemoryFact + if err := db.First(&fact).Error; err != nil { + t.Fatalf("load fact: %v", err) + } + if fact.ExpiresAt == nil { + t.Fatal("fact expires_at = nil, want ttl hint expiration") + } + assertAIMemoryWritebackTTLApproxDays(t, before, *fact.ExpiresAt, 30) +} + +func TestAIMemoryWritebackFallsBackWhenLLMExtractorFails(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, aiMemoryFallbackExtractor{ + primary: failingMemoryExtractor{err: stderrors.New("llm failed")}, + fallback: aimemory.NewRuleExtractor(aimemory.Options{DocumentMinRunes: 40}), + }) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + EnableLongTermMemory: true, + EmbedModel: "text-embedding-test", + }) + defer restore() + + createAIWritebackMessagesWithContent( + t, + db, + "conv-llm-fallback", + "msg-user-llm-fallback", + "msg-ai-llm-fallback", + "请记住以后请用更简洁的方式回答我,并给我一个 memory writeback hook 的实现方案", + fmt.Sprintf("实现方案:%s", repeatMemoryText("流式成功收尾后触发写回,抽取 summary facts documents,再经过治理策略落库。", 4)), + aiMessageStatusSuccess, + ) + + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-llm-fallback", + UserID: 19, + UserMessageID: "msg-user-llm-fallback", + AssistantMessageID: "msg-ai-llm-fallback", + Principal: aidomain.AIToolPrincipal{UserID: 19}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + + assertAIMemoryWritebackCount(t, db, &entity.AIConversationSummary{}, 1) + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryFact{}, 1) + assertAIMemoryWritebackCount(t, db, &entity.AIMemoryDocument{}, 1) +} + +func TestAIMemoryWritebackHeadUpdateKeepsSummaryTextAndBoundary(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, staticMemoryExtractor{}) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + EnableLongTermMemory: true, + SummaryRefreshEveryTurns: 5, + }) + defer restore() + + now := time.Now() + createAIWritebackMessageRows(t, db, + &entity.AIMessage{ + ID: "msg-user-prev", + ConversationID: "conv-head-update", + Role: aidomain.RoleUser, + Content: "上一轮问题", + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now, + UpdatedAt: now, + }, + &entity.AIMessage{ + ID: "msg-ai-prev", + ConversationID: "conv-head-update", + Role: aidomain.RoleAssistant, + Content: "上一轮回答", + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now.Add(time.Millisecond), + UpdatedAt: now.Add(time.Millisecond), + }, + &entity.AIMessage{ + ID: "msg-user-current", + ConversationID: "conv-head-update", + Role: aidomain.RoleUser, + Content: "请继续细化当前方案", + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now.Add(2 * time.Millisecond), + UpdatedAt: now.Add(2 * time.Millisecond), + }, + &entity.AIMessage{ + ID: "msg-ai-current", + ConversationID: "conv-head-update", + Role: aidomain.RoleAssistant, + Content: "好的,我会继续细化。", + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now.Add(3 * time.Millisecond), + UpdatedAt: now.Add(3 * time.Millisecond), + }, + ) + if err := service.repo.UpsertConversationSummary(context.Background(), &entity.AIConversationSummary{ + ConversationID: "conv-head-update", + UserID: 38, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(38), + CompressedUntilMessageID: "msg-ai-prev", + SummaryText: "旧摘要正文", + KeyPointsJSON: `["旧关键点"]`, + OpenLoopsJSON: `["旧待办"]`, + TokenEstimate: 6, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("upsert previous summary: %v", err) + } + + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-head-update", + UserID: 38, + UserMessageID: "msg-user-current", + AssistantMessageID: "msg-ai-current", + Principal: aidomain.AIToolPrincipal{UserID: 38}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + + summary, err := service.repo.GetConversationSummary(context.Background(), aidomain.MemoryConversationSummaryQuery{ + ConversationID: "conv-head-update", + UserID: 38, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(38), + }) + if err != nil { + t.Fatalf("GetConversationSummary() error = %v", err) + } + if summary == nil { + t.Fatal("summary = nil") + } + if summary.SummaryText != "旧摘要正文" { + t.Fatalf("SummaryText = %q, want old summary text kept", summary.SummaryText) + } + if summary.CompressedUntilMessageID != "msg-ai-prev" { + t.Fatalf("CompressedUntilMessageID = %q, want msg-ai-prev", summary.CompressedUntilMessageID) + } + if !strings.Contains(summary.KeyPointsJSON, "偏好简洁") || !strings.Contains(summary.KeyPointsJSON, "旧关键点") { + t.Fatalf("KeyPointsJSON = %s", summary.KeyPointsJSON) + } +} + +func TestAIMemoryWritebackFullRefreshAdvancesBoundary(t *testing.T) { + db := newAIMemoryWritebackTestDB(t) + service := newAIMemoryWritebackTestService(db, staticMemoryExtractor{}) + restore := setAIMemoryTestConfig(t, config.AIMemory{ + Enabled: true, + EnableEntityMemory: true, + EnableLongTermMemory: true, + SummaryRefreshEveryTurns: 1, + }) + defer restore() + + now := time.Now() + createAIWritebackMessageRows(t, db, + &entity.AIMessage{ + ID: "msg-user-prev", + ConversationID: "conv-full-refresh", + Role: aidomain.RoleUser, + Content: "上一轮问题", + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now, + UpdatedAt: now, + }, + &entity.AIMessage{ + ID: "msg-ai-prev", + ConversationID: "conv-full-refresh", + Role: aidomain.RoleAssistant, + Content: "上一轮回答", + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now.Add(time.Millisecond), + UpdatedAt: now.Add(time.Millisecond), + }, + &entity.AIMessage{ + ID: "msg-user-current", + ConversationID: "conv-full-refresh", + Role: aidomain.RoleUser, + Content: "请继续细化当前方案", + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now.Add(2 * time.Millisecond), + UpdatedAt: now.Add(2 * time.Millisecond), + }, + &entity.AIMessage{ + ID: "msg-ai-current", + ConversationID: "conv-full-refresh", + Role: aidomain.RoleAssistant, + Content: "好的,我会继续细化。", + Status: aiMessageStatusSuccess, + TraceItemsJSON: "[]", + ScopeJSON: "{}", + CreatedAt: now.Add(3 * time.Millisecond), + UpdatedAt: now.Add(3 * time.Millisecond), + }, + ) + if err := service.repo.UpsertConversationSummary(context.Background(), &entity.AIConversationSummary{ + ConversationID: "conv-full-refresh", + UserID: 39, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(39), + CompressedUntilMessageID: "msg-ai-prev", + SummaryText: "旧摘要正文", + KeyPointsJSON: `["旧关键点"]`, + OpenLoopsJSON: `["旧待办"]`, + TokenEstimate: 6, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("upsert previous summary: %v", err) + } + + err := service.OnTurnCompleted(context.Background(), aiMemoryWritebackInput{ + ConversationID: "conv-full-refresh", + UserID: 39, + UserMessageID: "msg-user-current", + AssistantMessageID: "msg-ai-current", + Principal: aidomain.AIToolPrincipal{UserID: 39}, + }) + if err != nil { + t.Fatalf("OnTurnCompleted() error = %v", err) + } + + summary, err := service.repo.GetConversationSummary(context.Background(), aidomain.MemoryConversationSummaryQuery{ + ConversationID: "conv-full-refresh", + UserID: 39, + ScopeKey: aidomain.BuildSelfMemoryScopeKey(39), + }) + if err != nil { + t.Fatalf("GetConversationSummary() error = %v", err) + } + if summary == nil { + t.Fatal("summary = nil") + } + if summary.SummaryText != "用户希望回答更简洁。" { + t.Fatalf("SummaryText = %q, want refreshed summary text", summary.SummaryText) + } + if summary.CompressedUntilMessageID != "msg-ai-current" { + t.Fatalf("CompressedUntilMessageID = %q, want msg-ai-current", summary.CompressedUntilMessageID) + } + if summary.KeyPointsJSON != `["偏好简洁"]` { + t.Fatalf("KeyPointsJSON = %s, want refreshed draft", summary.KeyPointsJSON) + } +} + func TestAIMemoryWritebackSkipsUnsuccessfulAssistantMessage(t *testing.T) { db := newAIMemoryWritebackTestDB(t) service := newAIMemoryWritebackTestService(db, aimemory.NewRuleExtractor(aimemory.Options{})) @@ -164,6 +508,99 @@ func (f *fakeMemoryWritebackHook) OnTurnCompleted(context.Context, aiMemoryWrite return f.err } +type failingMemoryExtractor struct { + err error +} + +func (f failingMemoryExtractor) Extract( + context.Context, + aidomain.MemoryExtractionInput, +) (aidomain.MemoryExtractionResult, error) { + return aidomain.MemoryExtractionResult{}, f.err +} + +type staticMemoryExtractor struct{} + +func (staticMemoryExtractor) Extract( + _ context.Context, + input aidomain.MemoryExtractionInput, +) (aidomain.MemoryExtractionResult, error) { + userID := input.UserID + return aidomain.MemoryExtractionResult{ + Summary: &aidomain.ConversationSummaryDraft{ + ConversationID: input.ConversationID, + CompressedUntilMessageID: input.AssistantMessage.ID, + SummaryText: "用户希望回答更简洁。", + KeyPointsJSON: `["偏好简洁"]`, + OpenLoopsJSON: `[]`, + TokenEstimate: 8, + }, + Facts: []aidomain.MemoryFactCandidate{ + { + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + Namespace: aidomain.MemoryNamespaceUserPreference, + FactKey: "answer_style", + FactValueJSON: `{"value":"更简洁"}`, + Summary: "用户偏好简洁回答", + Confidence: 0.91, + TTLHint: &aidomain.MemoryTTLHint{ + Kind: aidomain.MemoryTTLHintPersistent, + Reason: "用户表达的是长期回答偏好", + Confidence: 0.9, + }, + SourceKind: aidomain.MemorySourceModelInferred, + SourceID: input.AssistantMessage.ID, + }, + }, + Documents: []aidomain.MemoryDocumentCandidate{ + { + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + MemoryType: aidomain.MemoryTypeSemantic, + Topic: "memory_governance", + Title: "LLM 提议与 Policy 裁决", + Summary: "Prompt 是治理意图,Policy 是治理裁决。", + ContentText: "LLM 只提议候选记忆,Service policy 负责权限、TTL、去重、覆盖和最终落库。", + Confidence: 0.86, + SourceKind: aidomain.MemorySourceModelInferred, + SourceID: input.AssistantMessage.ID, + }, + }, + }, nil +} + +type ttlHintMemoryExtractor struct{} + +func (ttlHintMemoryExtractor) Extract( + _ context.Context, + input aidomain.MemoryExtractionInput, +) (aidomain.MemoryExtractionResult, error) { + userID := input.UserID + return aidomain.MemoryExtractionResult{ + Facts: []aidomain.MemoryFactCandidate{ + { + ScopeType: aidomain.MemoryScopeSelf, + UserID: &userID, + Namespace: aidomain.MemoryNamespaceOJGoal, + FactKey: "current_goal", + FactValueJSON: `{"goal":"200 problems in 30 days"}`, + Summary: "最近 30 天目标是刷 200 道题", + Confidence: 0.94, + TTLHint: &aidomain.MemoryTTLHint{ + Kind: aidomain.MemoryTTLHintDuration, + Value: 30, + Unit: "day", + Reason: "用户明确说最近 30 天目标", + Confidence: 0.93, + }, + SourceKind: aidomain.MemorySourceModelInferred, + SourceID: input.AssistantMessage.ID, + }, + }, + }, nil +} + func newAIMemoryWritebackTestService(db *gorm.DB, extractor aidomain.MemoryExtractor) *AIMemoryService { return &AIMemoryService{ aiRepo: reposystem.NewAIRepository(db), @@ -263,6 +700,13 @@ func createAIWritebackMessagesWithContent( } } +func createAIWritebackMessageRows(t *testing.T, db *gorm.DB, messages ...*entity.AIMessage) { + t.Helper() + if err := db.Create(messages).Error; err != nil { + t.Fatalf("create messages: %v", err) + } +} + func assertAIMemoryWritebackCount(t *testing.T, db *gorm.DB, model any, want int64) { t.Helper() var count int64 @@ -281,3 +725,12 @@ func repeatMemoryText(value string, times int) string { } return builder.String() } + +func assertAIMemoryWritebackTTLApproxDays(t *testing.T, base time.Time, expiresAt time.Time, wantDays int) { + t.Helper() + got := expiresAt.Sub(base) + want := time.Duration(wantDays) * 24 * time.Hour + if got < want-time.Minute || got > want+time.Minute { + t.Fatalf("ttl duration = %s, want about %s", got, want) + } +} diff --git a/internal/service/system/aiProjector.go b/internal/service/system/aiProjector.go index d696010..53496ab 100644 --- a/internal/service/system/aiProjector.go +++ b/internal/service/system/aiProjector.go @@ -198,20 +198,6 @@ func (p *aiMessageProjector) upsertTraceItem(item resp.AssistantTraceItem) { p.traceItems = append(p.traceItems, item) } -// buildAITraceIndex 根据已有 trace_items 构建 key 到下标的索引。 -func buildAITraceIndex(items []resp.AssistantTraceItem) map[string]int { - // 预分配容量,避免后续重复扩容。 - index := make(map[string]int, len(items)) - for i, item := range items { - // 空 key 不能参与 started/finished 合并,直接跳过。 - if item.Key == "" { - continue - } - index[item.Key] = i - } - return index -} - // encodeAssistantTraceItems 负责把 trace_items 安全编码成 JSON 字符串。 func encodeAssistantTraceItems(items []resp.AssistantTraceItem) string { // 空结果固定返回 [],保持数据库字段格式稳定。 diff --git a/internal/service/system/aiSvc.go b/internal/service/system/aiSvc.go index 52bb99a..33eef3a 100644 --- a/internal/service/system/aiSvc.go +++ b/internal/service/system/aiSvc.go @@ -338,6 +338,19 @@ func (s *AIService) StreamConversation( if err != nil { return bizerrors.Wrap(bizerrors.CodeInternalError, err) } + if global.Log != nil { + global.Log.Debug( + "AI hybrid context planned", + zap.String("conversation_id", conversation.ID), + zap.Uint("user_id", userID), + zap.Int("summary_kept", contextSnapshot.Diagnostics.SummaryKept), + zap.Int("facts_kept", contextSnapshot.Diagnostics.FactsKept), + zap.Int("rag_kept", contextSnapshot.Diagnostics.RAGKept), + zap.Bool("compression_triggered", contextSnapshot.Diagnostics.CompressionTriggered), + zap.Int("recent_messages_kept", contextSnapshot.Diagnostics.RecentMessagesKept), + zap.Int("history_tokens", contextSnapshot.Diagnostics.HistoryTokens), + ) + } // 按渐进式 selector 解析最终执行计划;失败时自动回退单阶段全量工具。 executionPlan, err := s.buildAIToolExecutionPlan( diff --git a/internal/service/system/aitool/validation.go b/internal/service/system/aitool/validation.go index 2bb6f64..5e96984 100644 --- a/internal/service/system/aitool/validation.go +++ b/internal/service/system/aitool/validation.go @@ -1,7 +1,6 @@ package aitool import ( - "fmt" "strings" "time" @@ -16,76 +15,6 @@ func aiFloatPtr(value float64) *float64 { return &value } -func aiFirstExample(param aidomain.ToolParameter) string { - if len(param.Examples) == 0 { - return "" - } - return param.Examples[0] -} - -func aiMissingFieldError(param aidomain.ToolParameter, field string) aidomain.ToolFieldError { - return aidomain.ToolFieldError{ - Field: field, - Reason: "missing_required", - Expected: aiExpectedSummary(param), - Allowed: append([]string(nil), param.Enum...), - Example: aiFirstExample(param), - } -} - -func aiInvalidFieldError( - field string, - reason string, - expected string, - allowed []string, - example string, -) aidomain.ToolFieldError { - return aidomain.ToolFieldError{ - Field: field, - Reason: reason, - Expected: expected, - Allowed: append([]string(nil), allowed...), - Example: example, - } -} - -func aiExpectedSummary(param aidomain.ToolParameter) string { - parts := make([]string, 0, 6) - parts = append(parts, string(param.Type)) - if strings.TrimSpace(param.Format) != "" { - parts = append(parts, "format="+strings.TrimSpace(param.Format)) - } - if len(param.Enum) > 0 { - parts = append(parts, "enum="+strings.Join(param.Enum, "/")) - } - if param.Minimum != nil { - parts = append(parts, "min="+trimFloatForPrompt(*param.Minimum)) - } - if param.Maximum != nil { - parts = append(parts, "max="+trimFloatForPrompt(*param.Maximum)) - } - if param.MinLength != nil { - parts = append(parts, fmt.Sprintf("min_length=%d", *param.MinLength)) - } - if param.MaxLength != nil { - parts = append(parts, fmt.Sprintf("max_length=%d", *param.MaxLength)) - } - if param.MinItems != nil { - parts = append(parts, fmt.Sprintf("min_items=%d", *param.MinItems)) - } - if param.MaxItems != nil { - parts = append(parts, fmt.Sprintf("max_items=%d", *param.MaxItems)) - } - return strings.Join(parts, ", ") -} - -func trimFloatForPrompt(value float64) string { - if float64(int64(value)) == value { - return fmt.Sprintf("%d", int64(value)) - } - return fmt.Sprintf("%g", value) -} - func aiParseRFC3339Field(field string, raw string, required bool, example string) (time.Time, error) { value := strings.TrimSpace(raw) if value == "" { diff --git a/plan/ai/approved-memory-hybrid-recall.md b/plan/ai/approved-memory-hybrid-recall.md new file mode 100644 index 0000000..a2bfcdf --- /dev/null +++ b/plan/ai/approved-memory-hybrid-recall.md @@ -0,0 +1,28 @@ +# Memory 混合召回策略实施计划 + +## Summary + +实现第 7 步 `混合召回策略`:把 `summary / facts / RAG / recent turns / tool selector 输入` 从当前分散逻辑收口为统一 planner。核心目标是固定优先级、预算和诊断输出:summary 固定保留,facts 在预算内稳定排序保留,RAG 优先被裁剪,recent turns 至少保留当前意图,tool selector 不会只看到被裁剪到失真的 history。 + +## Key Changes + +- 新增 `aiHybridContextPlanner`,输入 current query、raw history、summary、facts、RAG candidates、visible tools 和配置预算,输出 `runtime history + plan diagnostics`;`aiContextAssembler.Build` 改为通过该 planner 统一生成上下文。 +- 调整 memory message:保留 `Conversation Summary`、`Stable Facts`、`Long-term Documents`,删除完整 `Current Query` 分区;当前 query 继续作为本轮最后一条 user message 进入 runtime,不在 memory message 中重复注入。 +- 固定上下文优先级:Conversation Summary pinned 保留;Stable Facts 按 `namespace -> source priority -> updated_at DESC` 排序后在 `RecallMaxChars` 内保留;RAG documents 按 Qdrant score 排序并受 `RAGMaxChars` 限制;预算不足时优先裁剪 RAG。 +- Recent turns 策略:默认按 `AI.Memory.RecentRawTurns` 保留;即使压缩阈值很小,也至少保留最近 1 轮 user/assistant,当前 query 对应 user message必须保留;如果当前 query 尚未在 stored history 中,则 selector 输入额外显式携带 query。 +- Tool selector 输入保障:selector 使用 `current query + hybrid memory summary/facts + retained recent turns`,不依赖可能被裁剪到丢失当前意图的普通 history;tools 仍通过现有 `DynamicSystemPrompt + selected Tools` 注入,不塞进 memory message。 +- Diagnostics:planner 返回各来源的候选数、保留数、裁剪数、估算 token/chars、是否触发压缩、recent turns 保留数量、RAG min score/命中数量;本阶段先用于 service 日志和单测断言,后续第 8 步再接 trace/调试接口。 + +## Test Plan + +- Hybrid planner 单测:summary 永远保留;facts 按 namespace/source/updated_at 排序;RAG 按 score 排序;预算不足时先裁剪 RAG,再裁剪 facts,summary 不丢。 +- Current query 单测:memory message 不包含 `Current Query` 分区;runtime/selector 输入仍能拿到 current query;极小预算下 current query 和最近至少 1 轮仍保留。 +- Compression 单测:未超过阈值时保留 memory + 原始 history;超过阈值时保留 memory + recent turns;`RecentRawTurns` 默认生效。 +- Tool selector 集成测试:selector 收到 current query、memory summary/facts、recent turns;不会只收到被压缩后的空/失真 history。 +- 回归测试:现有 writeback、context recovery、RAG recall、Qdrant search 测试继续通过;执行 `go test ./internal/service/system ./internal/domain/ai ./internal/infrastructure/ai/memory` 和 `go test ./...`。 + +## Assumptions + +- `source priority` 使用现有 `SourceKind`,优先级为:tool/realtime service > explicit user statement > admin/manual > model inferred > unknown;未识别来源排最后。 +- v1 不新增数据库表,不新增 HTTP API,不接 rerank,不开放 org/platform memory。 +- planner diagnostics 先作为内部结构和日志字段,不在对外响应中暴露。 diff --git a/plan/ai/approved-memory-llm-governance-refactor.md b/plan/ai/approved-memory-llm-governance-refactor.md new file mode 100644 index 0000000..be61c7e --- /dev/null +++ b/plan/ai/approved-memory-llm-governance-refactor.md @@ -0,0 +1,40 @@ +# 目标 + +在 AI 记忆写回链路中引入真实 LLM extractor,并保持 `LLM 提议 + Policy 裁决` 的治理边界。 + +# 范围 + +- 更新 `docs/AI/设计流程.md` 中 `### 2. 记忆治理` 的设计描述。 +- 增加 memory extractor 配置项,默认仍走规则抽取。 +- 在 `internal/infrastructure/ai/memory` 增加 LLM extractor。 +- 重构 `AIMemoryService` 的 extractor 装配,LLM 失败时回退规则抽取。 + +# 改动 + +- `config.AIMemory` 新增 `extractor_mode`、`extract_timeout_seconds`、`extract_max_chars`。 +- `AI_MEMORY_EXTRACTOR_MODE=llm` 时启用真实 LLM 候选提议。 +- LLM 输出只转成 `Fact / Document / ConversationSummary` 候选,不决定最终 scope、visibility、TTL、dedup 或落库。 +- 第一版只允许 `self` 个人记忆候选。 + +# 验证 + +- `go test ./internal/infrastructure/ai/memory` +- `go test ./internal/service/system -run "AIMemoryWriteback|AIMemoryPolicy"` +- `go test ./internal/core -run TestInitConfigBindsAIMemoryAndQdrantCompatibility` + +# 风险 + +- 当前工作区已有 AI 记忆/混合召回未提交改动,本次只做增量修改,不回滚已有改动。 +- LLM JSON 输出不稳定,因此需要严格解析、校验和 fallback。 + +# 执行顺序 + +1. 计划流转为 approved。 +2. 增加配置字段、默认值和环境变量绑定。 +3. 实现 LLM extractor 与 fallback。 +4. 更新 Service 装配和文档。 +5. 补充测试并运行目标测试。 + +# 待确认 + +用户已通过 `PLEASE IMPLEMENT THIS PLAN` 明确确认执行。 diff --git a/plan/ai/approved-memory-rag-v1-chunking-recall.md b/plan/ai/approved-memory-rag-v1-chunking-recall.md new file mode 100644 index 0000000..7f14032 --- /dev/null +++ b/plan/ai/approved-memory-rag-v1-chunking-recall.md @@ -0,0 +1,46 @@ +# AI Memory RAG V1 切分与召回升级 + +## 目标 + +- 把当前 `段落 + 字符窗口` 升级为 `段落优先 -> 强句界 -> 软边界 -> 硬切` 的分层 chunking。 +- 把 recall 从“只注入命中的单个 chunk”升级为固定邻块扩窗 `k-1/k/k+1`。 +- 不改 HTTP / Router / DTO / 配置键,继续复用现有 `ChunkMaxChars`、`ChunkOverlapChars`、`RecallTopK`、`RAGMaxChars`。 + +## 范围 + +- `internal/infrastructure/ai/memory/chunker.go` +- `internal/domain/ai` +- `internal/repository/interfaces` +- `internal/repository/system/aiMemoryRepo.go` +- `internal/service/system/aiMemoryRecall.go` +- `internal/service/system/aiHybridContext.go` +- 对应测试文件 + +## 改动 + +- 保留 `ParagraphChunker` 对外入口,内部引入 block 识别、prose/list/table/code 分层切分和 V1 overlap 去重 helper。 +- 新增内部查询类型 `MemoryDocumentChunkRef{DocumentID, ChunkIndex}`。 +- 在 `AIMemoryRepository` 增加 `ListDocumentChunksByRefs(ctx, refs)`,用于批量回查邻接 chunk。 +- recall 保留现有向量召回口径,升级为 primary chunk 命中后按 `k-1/k/k+1` 扩窗、去重、合并并注入。 +- `renderAIMemoryRAGLine` 优先渲染扩窗后的合并文本,预算仍由 `RAGMaxChars` 控制。 + +## 验证 + +- `go test ./internal/infrastructure/ai/memory ./internal/repository/system ./internal/service/system` + +## 风险 + +- 代码块、表格和列表切分逻辑变复杂,需用测试锁住边界行为。 +- 邻块扩窗若不做 overlap 去重,会造成重复上下文;实现时必须去重后再注入。 + +## 执行顺序 + +1. 实现 chunker 分层切分与 overlap helper。 +2. 扩展 domain / repository 邻块回查接口与实现。 +3. 升级 recall 邻块扩窗、合并和渲染逻辑。 +4. 更新索引与召回相关测试。 +5. 跑目标测试集验证。 + +## 待确认 + +- 无。本计划已由用户确认执行。 diff --git a/plan/ai/approved-memory-ttl-hint-policy.md b/plan/ai/approved-memory-ttl-hint-policy.md new file mode 100644 index 0000000..08dedb3 --- /dev/null +++ b/plan/ai/approved-memory-ttl-hint-policy.md @@ -0,0 +1,50 @@ +# 目标 + +把记忆治理升级到 `LLM 提议 ttl_hint/confidence + Policy 裁决 expires_at` 的形态,避免靠正则从原文硬抠 TTL。 + +# 范围 + +- AI 记忆写回候选结构。 +- LLM extractor 输出 schema 与解析。 +- `aiMemoryPolicy.ResolveTTL` 的入参和裁决逻辑。 +- facts/documents 写回时的 TTL 使用。 +- 对应单元测试与文档说明。 + +# 改动 + +- 在 `internal/domain/ai` 增加 `MemoryTTLHint`,支持 `default / persistent / duration / until_date / session_only` 这类受控类型。 +- 在 `MemoryFactCandidate` 和 `MemoryDocumentCandidate` 增加 `Confidence`、`TTLHint` 字段。 +- LLM extractor prompt 要求输出结构化 `ttl_hint`,并把模型输出转成候选 hint;不允许 LLM 直接决定最终 `expires_at`。 +- Policy 新增或重构 `ResolveTTL(namespace, memoryType, hint)`: + - `user_preference` 默认长期,拒绝短期 duration 覆盖。 + - `oj_goal` 默认 30 天,允许合法 duration hint,并对天数做范围约束。 + - `oj_profile` 默认 60 天。 + - `org_learning_pattern` 默认 14 天。 + - 非法、越界、解析失败或不匹配 namespace 的 hint 回退 policy 默认 TTL。 +- 写回链路使用 policy 裁决后的 `ExpiresAt`,仍由 Service 构造最终 entity。 +- 文档补充:TTL 不是正则硬抠,而是 LLM 提议类型化时间语义,policy 做 allowlist、clamp 和最终计算。 + +# 验证 + +- `go test ./internal/infrastructure/ai/memory` +- `go test ./internal/service/system -run "AIMemoryWriteback|AIMemoryPolicy"` +- `go test ./internal/service/system` +- `git diff --check` + +# 风险 + +- 当前工作区已有 AI 记忆/混合召回未提交改动,实施时只做增量修改,不回滚已有改动。 +- 不扩展 org/platform_ops 写回权限,避免和授权链路混在本次变更里。 +- 不改 DB schema;`ttl_hint` 只参与入库前裁决,最终仍只写现有 `expires_at`。 + +# 执行顺序 + +1. 用户确认计划后,将本文件改名为 `approved-memory-ttl-hint-policy.md`。 +2. 扩展 domain candidate 和 TTL hint 类型。 +3. 更新 LLM extractor prompt、解析和候选构造。 +4. 重构 policy TTL 裁决逻辑与写回调用点。 +5. 补测试、更新文档、运行验证。 + +# 待确认 + +请确认是否按本计划实施。 diff --git a/plan/cross-module/approved-ai-memory-context-refactor.md b/plan/cross-module/approved-ai-memory-context-refactor.md new file mode 100644 index 0000000..20fc2a9 --- /dev/null +++ b/plan/cross-module/approved-ai-memory-context-refactor.md @@ -0,0 +1,51 @@ +# 目标 + +一次性重构当前 AI 记忆链路,把上下文装配升级为:`latest summary head + filtered facts + bounded RAG + token-budget recent turns`,并把大工具输出从“原样回喂模型”改成“预算受控回喂”。 + +# 范围 + +- `internal/service/system` +- `internal/infrastructure/ai/memory` +- `internal/infrastructure/ai/eino` +- `internal/model/config` +- 少量 `internal/domain/ai`、`internal/repository/*` + +# 改动 + +- recent turns 从固定轮数升级为 token budget。 +- summary 改成“最新决策优先 + LLM 优先回退 + 按窗口 full refresh/head update”。 +- facts 增加 query 相关的 namespace 过滤和重要性排序。 +- 工具结果只在模型回喂层做主动压缩,trace 展示保持不变。 +- RAG 继续纳入混合上下文,但维持当前 self-scope 和 fail-open 语义。 + +# 验证 + +- 补齐 `aiMemoryRecall_test.go` +- 补齐 `aiMemoryWriteback_test.go` +- 补齐 `runtime_tools_test.go` +- 补齐 `aiContext_test.go` +- 运行最小必要 Go 测试 + +# 风险 + +- 当前工作区已有相关 AI memory 改动,实施时必须避免覆盖现有未提交内容。 +- summary 刷新策略、facts 过滤、tool output 压缩会同时影响上下文质量与 token 成本,需要靠测试和 diagnostics 保底。 + +# 执行顺序 + +1. 配置和 domain/repository 接口扩展 +2. recent turns token budget 与 diagnostics +3. summary writeback/refresh 策略重构 +4. facts 过滤与排序 +5. tool output 主动压缩 +6. RAG 拼装预算与顺序调整 +7. 测试补齐与回归验证 + +# 待确认 + +已确认并进入实施: + +- summary:LLM 优先回退 +- summary full refresh 频率:按 `SummaryRefreshEveryTurns` +- facts:查询相关优先 +- 工具压缩:只压模型回喂层 From 1fa94a7cc8cb954a1c778e3ad7b22f57bce8f972 Mon Sep 17 00:00:00 2001 From: wang Date: Wed, 6 May 2026 18:59:59 +0800 Subject: [PATCH 17/17] =?UTF-8?q?=E8=B5=B0lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 1 + README.md | 385 ++++++++++++------ .../model/entity/ai_memory_document_chunk.go | 2 +- .../approved-port9000-cicd-login.md | 51 +++ plan/docs/approved-readme-rewrite.md | 54 +++ 5 files changed, 363 insertions(+), 130 deletions(-) create mode 100644 .gitattributes create mode 100644 plan/cross-module/approved-port9000-cicd-login.md create mode 100644 plan/docs/approved-readme-rewrite.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d207b18 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.go text eol=lf diff --git a/README.md b/README.md index 5eb8a8d..49806b3 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,307 @@ # Personal Assistant -> 一个基于 Go 的后端项目,聚焦用户与组织管理、RBAC 权限控制、OJ 刷题数据同步、图片资源管理。 - -## 项目亮点 - -- **用户认证**:支持注册、登录、登出、刷新 Token,采用双 Token 方案与 HttpOnly Refresh Token。 -- **组织与权限**:覆盖组织、角色、菜单、API 管理,支持用户-组织-角色关系建模。 -- **RBAC**:基于 Casbin 做权限校验,并通过权限投影链路支撑多实例下的策略收敛。 -- **OJ 能力**:支持 LeetCode / Luogu 账号绑定、异步同步、排行榜查询与缓存投影。 -- **图片管理**:支持本地 / 七牛双存储驱动,包含上传限流、孤儿文件清理等治理能力。 -- **事件驱动一致性**:基于 Redis Stream + Outbox + 双通道收口,用于异步解耦、投影刷新和多实例收敛。 -- **第三方基础设施接入**:在 `internal/infrastructure` 中统一封装 LeetCode / Luogu / Lanqiao 客户端,采用配置驱动,便于替换和扩展。 -- **可观测性**:提供请求链路追踪、GORM/任务埋点和指标聚合查询能力。 -- **稳定性治理**:集成七牛存储熔断、上传限流、Redis 分布式锁与任务协调。 -- **排行榜设计**:基于 Redis ZSet + 用户快照投影 + 读模型聚合查询,兼顾查询性能与异步收敛。 -- **框架设计**:按 Controller / Service / Repository / Router / Core / Infrastructure / pkg 分层,拆分业务编排、数据访问与基础设施初始化职责。 +> 一个基于 Go + Gin 的个人助手后端系统,围绕用户组织、权限投影、OJ 数据同步、图片资源治理、AI 会话记忆和可观测性做工程化整合。 -## 技术栈 +这个项目不是单一的 CRUD 后台。它的核心价值在于把认证授权、组织协作、异步任务、外部 OJ 数据、AI 流式对话、记忆召回、事件一致性和运行期观测放到同一个后端体系里,并用清晰的分层边界约束复杂度。 + +整体架构口径是:**传统 MVC 主体 + AI 子域渐进式 DDD**。项目主体仍按 Controller / Service / Repository / Router / Core 组织;AI 子域在复杂度较高的位置补充 `internal/domain/ai` 和 `internal/infrastructure/ai`,用于隔离稳定协议与具体运行时实现。 + +## 核心能力 + +| 模块 | 能力 | 关键实现 | +| --- | --- | --- | +| 用户与认证 | 注册、登录、登出、刷新 Token、账号状态管理 | Access Token + Refresh Token;Refresh Token 使用 HttpOnly Cookie;活跃态校验支持 Redis 缓存与 DB 回源 | +| 组织与权限 | 组织、成员、角色、菜单、API、能力点管理 | 权限真相保存在 DB 关系表;Casbin 作为权限投影;用户当前组织通过 `current_org_id` 参与授权上下文 | +| OJ 数据 | LeetCode / Luogu / Lanqiao 账号绑定、数据同步、排行榜、曲线统计 | 外部 crawler client、Redis Stream、Outbox、缓存投影、读模型聚合 | +| OJ 任务 | 任务创建、版本派生、立即执行、重试、执行明细查询 | 任务调度、快照落库、执行用户明细、Redis 分布式锁防重复执行 | +| AI 助手 | 会话管理、SSE 流式输出、Eino / local runtime 切换、AI tool 协议 | `domain/ai.Runtime` 定义运行时协议;Service 负责上下文组装、tool 授权和落库收尾 | +| AI 记忆 | 会话摘要、事实记忆、长期文档、RAG 召回 | MySQL 保存事实和摘要;Qdrant 保存向量 chunk;写回链路由 extractor + policy 控制 | +| 图片资源 | 上传、删除、列表、本地 / 七牛存储 | 上传限流、七牛熔断、软删除、孤儿文件清理 | +| 事件一致性 | 权限、缓存、OJ 统计、任务触发等异步收敛 | Outbox + Redis Stream + subscriber;多实例通过锁和消息投影降低重复处理 | +| 可观测性 | 请求链路、指标聚合、运行时查询 | Request ID、W3C trace propagation、metrics flush、trace span 入库和查询接口 | -- 语言与框架:Go, Gin -- 数据层:Gorm, MySQL -- 缓存与消息:Redis, Redis Stream -- 权限:Casbin -- 配置与日志:Viper, Zap -- 其他:Resty, robfig/cron, urfave/cli +## 架构总览 + +```text +Client + | + v +Gin Router + Middleware + |-- RequestID / Observability / CORS / Timeout + |-- JWTAuth / ActiveUserMW / PermissionMiddleware + v +Controller + | + v +Service + |-- AuthorizationService + |-- AI context / memory / tool orchestration + |-- Outbox events / projections / task scheduling + v +Repository + | + v +MySQL + +Redis / Redis Stream / Qdrant / external crawler / object storage +通过 core、infrastructure 和 pkg 封装接入,不直接塞进 Controller。 +``` -## 目录结构 +主要目录职责: ```text -. -├── cmd/ # 程序入口 -├── configs/ # 配置文件(yaml + casbin model) -├── internal/ -│ ├── controller/ # HTTP 控制器 -│ ├── service/ # 业务逻辑 -│ ├── repository/ # 数据访问层 -│ ├── router/ # 路由注册 -│ ├── middleware/ # 中间件 -│ ├── infrastructure/ # 外部服务接入与消息基础设施(LeetCode/Luogu/Lanqiao/Outbox) -│ └── core/ # 启动流程、配置、日志、数据库、任务初始化 -├── pkg/ # 公共组件(jwt、response、storage、errors 等) -├── docs/ # 项目文档 -├── docker-compose.yaml -└── Dockerfile +cmd/ 程序入口 +configs/ YAML 配置与 Casbin model +internal/core/ 配置、日志、DB、Redis、Qdrant、AI、SSE、Casbin、存储、任务等初始化 +internal/router/ 路由组和中间件挂载 +internal/controller/system/ HTTP 参数接收、校验、响应组装 +internal/service/system/ 业务编排、权限收口、AI 上下文和任务调度 +internal/repository/ DB CRUD / JOIN / 读模型查询 +internal/domain/ai/ AI 稳定协议、事件、runtime、tool、memory 抽象 +internal/infrastructure/ 外部 OJ client、Redis 消息、SSE、AI runtime、Qdrant 适配 +internal/model/ entity、DTO、readmodel、config +pkg/ jwt、response、errors、casbin、storage、ratelimit、redislock、observability 等公共能力 +docs/ 架构、权限、AI、事件、图片、接口说明 +plan/ 执行型任务计划 ``` -## 快速开始(本地) +## 重点实现链路 + +### 认证与权限 + +- 登录后发放 Access Token,并通过 HttpOnly Cookie 设置 Refresh Token。 +- `JWTAuth` 负责解析访问令牌,`ActiveUserMW` 负责账号活跃态校验,`PermissionMiddleware` 负责组织、角色和权限上下文。 +- JWT 不再信任角色字段,授权时动态读取 DB / 投影状态。 +- `role-menu`、`role-api`、`role-capability`、`menu-api` 等关系变化先写 DB,再通过 outbox / subscriber 收敛 Casbin 投影。 + +### OJ 数据与任务 + +- OJ 账号绑定和数据同步通过 infrastructure client 调用外部 crawler 服务。 +- 绑定、题目 upsert、每日统计、缓存刷新等链路通过 Redis Stream 和 Outbox 解耦。 +- 排行榜读侧使用缓存投影和 read model 聚合,避免每次请求都扫明细表。 +- OJ 任务支持创建、派生版本、立即执行、重试和执行明细查询;调度与执行侧使用 Redis 锁降低多实例重复执行风险。 + +### AI 会话与记忆 + +- HTTP 会话接口负责创建、查询和删除会话;流式输出走 SSE。 +- Service 在执行前准备用户消息、会话历史、当前组织上下文、可见工具和记忆上下文。 +- `internal/domain/ai` 只定义 runtime、event、sink、tool、memory 等稳定协议,不依赖 Gin、GORM、Eino 或 Redis。 +- `internal/infrastructure/ai` 承载 Eino runtime、local runtime、tool schema、Qdrant memory store、embedding、chunker 等技术实现。 +- 成功的 AI 轮次会触发记忆写回:消息快照 -> extractor 提取候选 -> policy 裁决 scope / visibility / TTL -> MySQL 保存 summary / facts / documents -> 可选切分并写入 Qdrant。 + +### 事件一致性与观测 + +- Outbox Relay 将业务事件投递到 Redis Stream,subscriber 负责投影修复和异步处理。 +- 权限投影、缓存投影、OJ 每日统计投影和 OJ 任务触发各自有明确 topic / group / consumer 配置。 +- 可观测性中间件统一注入 request id,支持 W3C trace 解析与注入。 +- metrics 和 trace span 通过批量 flush / Redis Stream 入库,并通过 `/system/observability/*` 查询。 + +## 技术栈 + +- 语言与框架:Go 1.24、Gin +- 数据访问:GORM、MySQL +- 缓存与消息:Redis、Redis Stream +- 权限:Casbin、gorm-adapter +- AI:CloudWeGo Eino、OpenAI/Qwen/Ark 兼容模型适配、Qdrant +- 存储:本地文件、七牛云 +- 配置与日志:Viper、godotenv、Zap、lumberjack +- 任务与稳定性:robfig/cron、Redis 分布式锁、限流、熔断 +- 工程质量:go test、go vet、golangci-lint + +## 本地启动 ### 1. 前置依赖 -- Go `>= 1.24`(`go.mod` 中配置了 `toolchain go1.24.9`) -- MySQL `8.x` -- Redis `6+` -- 可选:OJ 爬虫服务(用于 LeetCode/洛谷数据接口) +必需: + +- Go `1.24.x`,`go.mod` 中指定 `toolchain go1.24.9` +- MySQL 8.x +- Redis 6+ -### 2. 配置环境变量 +按需启用: -复制环境变量模板: +- Qdrant:默认配置为启用;如果没有本地 Qdrant,请先把 `QDRANT_ENABLED=false` +- OJ crawler 服务:用于 LeetCode / Luogu / Lanqiao 数据同步 +- AI 模型 API Key:`SSE_AI_RUNTIME_MODE=eino` 时使用;Eino 初始化失败会回退到 local runtime +- 七牛云:仅 `STORAGE_CURRENT=qiniu` 时需要 -```bash -# Linux / macOS -cp .env.example .env +### 2. 准备配置 -# Windows PowerShell +```powershell Copy-Item .env.example .env ``` -然后按你的环境修改 `.env`,至少确认: +至少检查这些配置: -- `DB_HOST/DB_PORT/DB_NAME/DB_USERNAME/DB_PASSWORD` -- `REDIS_ADDRESS/REDIS_PASSWORD/REDIS_DB` -- `JWT_ACCESS_TOKEN_SECRET/JWT_REFRESH_TOKEN_SECRET` -- `SYSTEM_HOST/SYSTEM_PORT` -- `CRAWLER_LEETCODE_BASE_URL/CRAWLER_LUOGU_BASE_URL`(如使用 OJ 功能) +```text +DB_HOST / DB_PORT / DB_NAME / DB_USERNAME / DB_PASSWORD +REDIS_ADDRESS / REDIS_PASSWORD / REDIS_DB +JWT_ACCESS_TOKEN_SECRET / JWT_REFRESH_TOKEN_SECRET +SYSTEM_SESSIONS_SECRET +SYSTEM_HOST / SYSTEM_PORT +QDRANT_ENABLED / QDRANT_ENDPOINT / QDRANT_GRPC_HOST / QDRANT_GRPC_PORT +SSE_AI_RUNTIME_MODE / AI_PROVIDER / AI_API_KEY / AI_MODEL +CRAWLER_LEETCODE_BASE_URL / CRAWLER_LUOGU_BASE_URL / CRAWLER_LANQIAO_BASE_URL +``` + +最小本地启动建议: + +```text +QDRANT_ENABLED=false +SSE_AI_RUNTIME_MODE=local +STORAGE_CURRENT=local +``` -### 3. 启动服务 +### 3. 安装依赖并启动 -```bash -go mod tidy -go run cmd/main.go +```powershell +go mod download +go run .\cmd\main.go ``` -默认监听:`0.0.0.0:9000` +默认监听地址由 `SYSTEM_HOST` + `SYSTEM_PORT` 决定,模板中为 `0.0.0.0:9000`。 + +启动时会按顺序初始化配置、日志、Qdrant、敏感数据编解码器、DB、自动迁移、Redis、SSE、AI runtime、Casbin、存储、限流器、Repository、Observability、subscriber、权限投影和定时任务。 ### 4. 数据库初始化 -- 默认 `AUTO_MIGRATE=true` 时会自动迁移表结构。 -- 也可手动执行: +`AUTO_MIGRATE=true` 时启动会自动建表和迁移。也可以手动执行: -```bash -go run cmd/main.go --sql +```powershell +go run .\cmd\main.go --sql ``` ## Docker 启动 -```bash +```powershell docker compose up -d --build ``` -说明: +当前 `docker-compose.yaml` 只包含 `app` 服务: -- 当前 `docker-compose.yaml` 只包含 `app` 服务。 -- MySQL / Redis 需要你自行提供并确保容器内可访问(例如通过 `host.docker.internal` 或同网络服务名)。 +- 暴露端口:`9000:9000` +- 挂载目录:`./static:/app/static`、`./log:/app/log` +- 读取环境变量:`.env` -## 命令行工具 +MySQL、Redis、Qdrant 不在 compose 内,需要外部提供。容器访问宿主机服务时,可按实际环境使用 `host.docker.internal` 或同网络服务名。 -项目内置 CLI 参数(一次仅支持一个): +## CLI 与 CI -- `--sql`:初始化/迁移数据库表结构 -- `--sql-export`:导出 MySQL 数据(依赖名为 `mysql` 的 Docker 容器) -- `--sql-import `:从 SQL 文件导入数据 +内置 CLI 一次只允许执行一个命令: -示例: +```powershell +go run .\cmd\main.go --sql +go run .\cmd\main.go --sql-export +go run .\cmd\main.go --sql-import .\backup.sql +``` + +CI 当前执行: -```bash -go run cmd/main.go --sql-import .\backup.sql +```text +go mod download +bash scripts/check_no_legacy_error_tracking.sh +go test ./... +go vet ./... +golangci-lint v1.64 ``` -## 接口分组概览 - -> 当前路由前缀并非完全统一,以下为主要分组。 - -- 公共接口(无需 JWT) -- `POST /base/captcha` -- `POST /base/sendEmailVerificationCode` -- `POST /user/register` -- `POST /user/login` -- `POST /refreshToken` - -- 业务接口(需 JWT) -- `POST /user/logout` -- `PUT /user/profile` -- `PUT /user/phone` -- `PUT /user/password` -- `POST /oj/bind` -- `POST /oj/ranking_list` -- `POST /oj/stats` -- `POST /api/system/image/upload` -- `DELETE /api/system/image/delete` -- `GET /api/system/image/list` -- `GET /system/org/my` -- `PUT /system/org/current` - -- 系统管理接口(需 JWT + 权限) -- `/system/api/*` -- `/system/menu/*` -- `/system/role/*` -- `/system/org/*` -- `/system/user/list` -- `/system/user/{id}` -- `/system/user/{id}/roles` -- `/system/user/assign_role` - -## 认证说明 - -- Access Token 支持: -- 请求头 `x-access-token` -- 或 `Authorization: Bearer ` -- Refresh Token 默认使用 HttpOnly Cookie:`x-refresh-token` - -## 相关文档 +## 接口分组 + +公共接口: + +```text +GET /api/v1/health +GET /api/v1/ping +POST /base/captcha +POST /base/sendEmailVerificationCode +POST /user/register +POST /user/login +POST /refreshToken +``` + +登录后可访问的业务接口: + +```text +POST /user/logout +PUT /user/profile +PUT /user/phone +PUT /user/password +POST /user/deactivate + +POST /oj/bind +POST /oj/lanqiao/bind +POST /oj/ranking_list +POST /oj/stats +POST /oj/curve + +POST /oj/task +GET /oj/task/list +POST /oj/task/analyze +POST /oj/task/:id/execute-now +POST /oj/task/:id/revise +POST /oj/task/:id/retry +GET /oj/task/:id + +POST /ai/conversations +GET /ai/conversations +GET /ai/conversations/:id/messages +DELETE /ai/conversations/:id +POST /ai/conversations/:id/stream + +POST /api/system/image/upload +DELETE /api/system/image/delete +GET /api/system/image/list + +GET /system/org/my +PUT /system/org/current +POST /system/org/join +POST /system/org/leave +``` + +需要 JWT + 权限投影校验的系统管理接口: + +```text +/system/api/* +/system/menu/* +/system/role/* +/system/org/* +/system/user/* +/system/observability/* +``` + +Access Token 支持: + +```text +x-access-token: +Authorization: Bearer +``` + +Refresh Token 默认从 HttpOnly Cookie `x-refresh-token` 读取,刷新接口也兼容 JSON body 中的 refresh token 字段。 + +## 文档索引 + +重点文档: -- `docs/事件驱动架构-RedisStream-Outbox-双通道一致性实践.md` - `docs/Casbin-RBAC权限系统架构文档.md` - `docs/双Token认证方案-整合版.md` +- `docs/事件驱动架构-RedisStream-Outbox-双通道一致性实践.md` +- `docs/SSE实时推送基础设施重构指导文档.md` - `docs/图片管理-技术文档.md` - `docs/图片上传流.md` -- `docs/flag指令.md` - -第三方集成、可观测性、排行榜与框架整体设计文档持续补充中。 - -## 安全提醒(公开仓库前建议先做) - -- 全量替换 `.env` / `.env.example` / 配置文件中的真实密钥与密码。 -- 轮换数据库密码、Redis 密码、JWT 密钥、邮箱密钥、云存储密钥。 -- 确保 `.env` 不会被提交(已在 `.gitignore` 中忽略)。 +- `docs/context从api贯穿到repository.md` +- `docs/AI/AI项目演进说明.md` +- `docs/AI/AI领域+DDD架构拆分.md` +- `docs/AI/记忆模块设计.md` +- `docs/AI/记忆-最后-混合召回.md` +- `docs/AI/Qdrant向量数据库配置.md` +- `docs/apifox/*.openapi.json` + +## 安全说明 + +- `.env` 不应提交到公开仓库。 +- 公开前必须轮换 MySQL、Redis、JWT、Session、邮箱、AI、Qdrant、七牛等密钥。 +- `configs/configs.yaml` 和 `.env.example` 只能保留占位值或本地开发默认值。 +- Qdrant API Key、AI API Key、七牛 AK/SK 等只允许通过环境变量注入。 ## License diff --git a/internal/model/entity/ai_memory_document_chunk.go b/internal/model/entity/ai_memory_document_chunk.go index 0b33b41..b41bb20 100644 --- a/internal/model/entity/ai_memory_document_chunk.go +++ b/internal/model/entity/ai_memory_document_chunk.go @@ -21,7 +21,7 @@ type AIMemoryDocumentChunk struct { // MemoryType 标记长期记忆类型。 MemoryType string `json:"memory_type" gorm:"type:varchar(32);not null;index:idx_ai_memory_document_chunks_scope_key_memory_type,priority:2;comment:'长期记忆类型'"` // Topic 是轻量主题标签。 - Topic string `json:"topic" gorm:"type:varchar(128);not null;default:'';index:idx_ai_memory_document_chunks_topic,comment:'主题'"` + Topic string `json:"topic" gorm:"type:varchar(128);not null;default:'';index:idx_ai_memory_document_chunks_topic;comment:'主题'"` // ChunkIndex 表示 chunk 在文档内的顺序。 ChunkIndex int `json:"chunk_index" gorm:"not null;index:idx_ai_memory_document_chunks_document_index,priority:2;comment:'chunk顺序'"` // ContentText 是参与 embedding 的 chunk 正文。 diff --git a/plan/cross-module/approved-port9000-cicd-login.md b/plan/cross-module/approved-port9000-cicd-login.md new file mode 100644 index 0000000..e070cf2 --- /dev/null +++ b/plan/cross-module/approved-port9000-cicd-login.md @@ -0,0 +1,51 @@ +# 目标 + +- 解决本机启动失败:`listen tcp 0.0.0.0:9000: bind`。 +- 确认并补齐 GitHub CI/CD 触发方式,避免误把业务登录接口和 GitHub Actions 触发混在一起。 + +# 范围 + +- 本机运行态排障:定位并处理占用 `9000` 端口的旧进程。 +- 仓库 CI/CD 配置排查:检查 `.github/workflows/*.yml` 的触发事件和必要 secrets。 +- 如用户明确要求“业务登录成功后触发 GitHub workflow”,再新增安全受控的 workflow dispatch 方案。 + +# 改动 + +- 优先不改代码:先停止占用 `9000` 的旧 Go 运行进程,重新启动服务验证。 +- 如只是要“提交/合并后触发 CI/CD”,保留现有 GitHub Actions 触发模型,并仅补充缺失配置或说明。 +- 如确认为“应用登录后触发 CI/CD”,按本仓库分层规则新增: + - 配置结构:GitHub workflow dispatch endpoint、仓库、分支、token 环境变量。 + - `pkg/*`:封装 GitHub Actions dispatch 客户端,不直接依赖业务。 + - `internal/core`:读取配置并初始化客户端。 + - `internal/service/system`:在登录成功后的受控位置编排触发逻辑。 + - `internal/controller/system`:保持只接参、调用 Service、统一响应,不直接调用 GitHub SDK。 + +# 验证 + +- `Get-NetTCPConnection -LocalPort 9000` 确认端口释放。 +- 启动服务,确认不再出现 `bind` 错误。 +- 只读确认 `.github/workflows/ci.yml`、`.github/workflows/cd-prod.yml` 的触发条件。 +- 若改 CI/CD 或登录触发逻辑,执行最小必要检查:`go test ./...`,必要时补充针对新增服务/client 的单测。 + +# 风险 + +- 直接停止 PID 可能中断你当前正在使用的本机后端实例。 +- GitHub Actions 不能由“登录 GitHub 网站”自然触发;它通常由 `push`、`pull_request`、`workflow_dispatch` 等仓库事件触发。 +- 业务登录触发 CI/CD 属于敏感能力,必须有开关、鉴权、限流和 token 配置外置,否则会有误触发和凭据泄露风险。 + +# 执行顺序 + +1. 确认是否允许停止当前占用 `9000` 的进程 PID `32316`。 +2. 停止旧进程后重新检查端口。 +3. 重新启动本机服务并确认启动成功。 +4. 确认你说的“登录触发 CI/CD”具体含义。 +5. 若只是 GitHub 仓库事件触发,核对现有 workflow 并给出需要配置的 secrets 清单。 +6. 若确认为业务登录触发 workflow,再进入新的实现计划或在本计划补充后执行。 + +# 待确认 + +- 是否允许我停止当前占用 `9000` 的 PID `32316`? +- “登录的时候 GitHub 会触发 CI/CD”具体指哪一种: + - A. 代码 `push` 到 GitHub 或 PR 时自动触发。 + - B. 合并到 `main` 后自动部署。 + - C. 用户调用本系统登录接口成功后,由后端主动触发 GitHub Actions。 diff --git a/plan/docs/approved-readme-rewrite.md b/plan/docs/approved-readme-rewrite.md new file mode 100644 index 0000000..a015356 --- /dev/null +++ b/plan/docs/approved-readme-rewrite.md @@ -0,0 +1,54 @@ +# 目标 + +重写项目根目录 `README.md`,将其调整为展示 + 面试导向的项目说明,覆盖项目定位、核心能力、架构主线、启动依赖、接口分组和文档入口。 + +# 范围 + +- 修改 `README.md`。 +- 本计划文件按规则流转为 `approved-readme-rewrite.md` 后执行。 +- 不修改业务代码、配置文件、接口文档或 CI 配置。 + +# 改动 + +- README 首页结构调整为: + - 项目定位 + - 核心能力 + - 架构总览 + - 重点实现链路 + - 技术栈 + - 本地启动 + - Docker 启动 + - CLI/CI + - 接口分组 + - 文档索引 + - 安全说明 +- 项目表述采用保守口径: + - `传统 MVC 主体 + AI 子域渐进式 DDD` + - 不宣称全量 DDD 架构 + - 明确 `internal/domain/ai` 与 `internal/infrastructure/ai` 的职责边界 +- 启动说明按当前代码修正: + - Go 1.24 / toolchain 1.24.9 + - MySQL、Redis 为基础依赖 + - Qdrant 默认启用,未配置时可关闭 + - Docker Compose 只启动 app,不包含 MySQL / Redis / Qdrant + +# 验证 + +- 人工检查 Markdown 结构。 +- 执行 `go test ./...`,确认文档改动未伴随业务代码破坏。 + +# 风险 + +- README 中如过度描述未默认开启的能力,容易在面试追问中被质疑;因此 AI memory、Qdrant 等能力按“配置驱动/可启用”表述。 +- 当前配置模板包含较多占位项,README 只列关键必填项,避免变成配置手册。 + +# 执行顺序 + +1. 将本文件改名为 `approved-readme-rewrite.md`。 +2. 替换 `README.md`。 +3. 检查 Markdown 内容。 +4. 运行 `go test ./...`。 + +# 待确认 + +用户已明确要求执行该计划。