docs: 添加 AI Agent 模块文档及文档事实校验脚本

This commit is contained in:
sugar1569 2026-09-27 04:22:14 +08:00 committed by 吴昊天
parent d044cf3559
commit ef5d99260b
2 changed files with 590 additions and 0 deletions

View File

@ -0,0 +1,200 @@
# AI Agent 模块
> 核对日期:2026-09-24。本文是 `backend/modules/aiagent/` 的功能真源,路由、服务与数据表均以模块代码为准;模块目录内的 `README.md` 只保留开发视角速览。
## 1. 模块概述
AI Agent(插件标识 `aiagent`)是默认安装的后台智能体模块,提供四块能力:
| 能力 | 说明 |
|---|---|
| AI 对话 | 后台对话工作台,支持流式(SSE)与非流式响应、工具调用循环、Skill 按需加载与确认卡交互 |
| 模型配置 | 对接 OpenAI 兼容协议与一号通 AI 两类模型(模型管理为系统能力,配置存核心表 `eb_ai_model`) |
| MCP 连接 | 连接系统内置 MCP 服务(`service_mcp` 模块)与任意远程 Streamable HTTP MCP Server,统一发现与调用工具 |
| 自动化任务 | 按周期无人值守执行指令,复用对话执行引擎,运行过程可回放,完成后邮件/企业微信通知 |
模块自带入口菜单「AI」,点击直达对话页;模型、连接器(MCP Server)、Skill 与任务管理均为对话页内的管理面板,无独立页面菜单。接口权限节点由 Installer 注册(33 个 `auth_type=2` 节点),角色按需勾选。
依赖关系:
- `service_mcp`(软依赖):通过 HTTP JSON-RPC 调用 `POST /api/mcp`,不引用其任何类;可用性 = 模块启用 + 系统配置 `mcp_enabled=1` + `mcp_appkey` 非空。
- AI 模型:经 `app/services/ai/AiModelServices::getChatHandler()` 获取适配器,协议支持 `openai_compatible`(DeepSeek、通义千问、Kimi、GLM、豆包、OpenAI 及 vLLM/Ollama 自建网关)与 `yihaotong`(一号通,全局凭证);模型 ID 传 0 时直接走一号通 AI,开箱可用。
- 队列:自动化任务经 `crmeb\basic\BaseJobs` 派发;未开启 `queue_open` 或缓存驱动非 Redis 时同步执行(手动「立即执行」会阻塞请求)。
## 2. 对话与工具循环
### 2.1 对话流程
入口 `AgentChatService`(`backend/modules/aiagent/service/AgentChatService.php`):
1. 消息校验(单条上限 8000 字)→ 会话归属校验或新建会话(标题取首条消息前 40 字);
2. 组装上下文:挂载连接器工具定义、Skill(显式选择注入全文,否则仅注入目录由模型按需加载)、历史转录(最近 50 条压缩,历史 user/assistant 各限 4000 字、tool 限 600 字)、系统提示词与 `.crmeb/AGENTS.md` 兜底规范(截断 16000 字,恒定最先注入);
3. 工具循环:模型输出 `{"tool_call":{"name":...,"arguments":{...}}}` JSON 时解析执行(本模块不使用原生 function calling,属文本协议工具循环,兼容一号通 `<|FunctionCallBegin|>` 私有标记);工具结果以「权威工具结果」块回注(上限 16000 字,Skill 附属资料 32000 字),直至模型给出最终答复或达到轮数上限(流式路径固定 6 轮,非流式/任务路径按 `max_steps` 3-30);
4. 落库:会话消息含 `user` / `assistant` / `tool` / `confirm` 四种角色。
### 2.2 流式事件(SSE)
`chat_stream` / `confirm_stream` 以 `data: {json}` 帧输出,事件依次为:
| 事件 | 含义 |
|---|---|
| `start` | 会话就绪 |
| `skills` | 当前挂载的 Skill 清单(随按需加载动态更新) |
| `reasoning` | 思维链增量(`<think>` 内嵌思维链改道于此) |
| `delta` | 正文增量 |
| `tool` | 一次工具调用及结果(前端清空本轮流式前言) |
| `confirm` | 需用户确认的交互卡 |
| `followups` | 推荐追问(最多 3 个、30 字内) |
| `replace` | 正文被过滤/校正后整段替换 |
| `done` / `error` | 结束或失败 |
客户端断开时已生成的部分回复先落库再退出;流式输出需代理层关闭缓冲(响应头 `X-Accel-Buffering: no`)。
### 2.3 确认卡(写工具交互)
声明 `interaction: "execute"` 的写工具执行前进入 `confirm` 状态:服务端保存参数快照与当轮上下文,客户端弹确认卡;`confirm_stream` 按 `approve/reject` 续跑。状态机 `pending → approved/rejected/expired`,以消息 payload 乐观锁防重放;新用户消息会使全部 pending 卡过期。确认卡展示对 password/secret/token/api_key 等敏感字段打码。同一签名(工具名+参数)每轮对话只执行一次。
### 2.4 对话层虚拟工具
三个虚拟工具只在对话层拦截,不注册到 MCP:
| 工具 | 用途 |
|---|---|
| `crmeb_ask_user` | 模型向用户发起单选/多选/文本问询(回答经 `confirm_stream` 返回) |
| `crmeb_skill_load` | 按需加载 Skill 正文或附属资料(目录内保留名 `EXAMPLES.md` 不进入上下文) |
| `crmeb_task_create` / `crmeb_task_list` | 模型代用户创建/查询自动化任务(新建任务默认只读自主级别) |
无人值守场景不注册这三个工具(无用户可交互,且防止任务自我修改调度)。
## 3. 模型 / MCP / Skill 配置
### 3.1 模型
模型管理为系统能力(核心表 `eb_ai_model`,管理端「AI 模型管理」),字段含协议、`api_key`、`base_url`、模型标识、温度、`max_tokens`、`supports_tools` 等。对话页模型清单首位固定提供「一号通AI」虚拟项(模型 ID 0),复用全局 `yihaotong_appid/appsecret`,未配置任何模型时也可直接对话。
### 3.2 MCP Server 连接
连接器存 `eb_ai_mcp_server`,与后台管理页同源同权;对外工具名统一 `{server_key}.{tool}` 前缀,内置服务为 `builtin.*`。
- **内置 MCP(`builtin`)**:指向本系统 `POST /api/mcp`。Installer 预置该记录,URL 取 `site_url` 拼接 `/api/mcp`(官方 Docker 部署自动改用 `http://crmeb_nginx/api/mcp`);`api_key` 留空表示运行时跟随系统配置 `mcp_appkey`(service_mcp 系统账号密钥的镜像,系统侧轮换后无需手动同步)。工具清单缓存为空时自动补同步一次(失败静默,10 分钟冷却)。
- **远程 MCP**:JSON-RPC 2.0 over Streamable HTTP(协议版本 `2024-11-05`),仅支持 HTTP 端点(拒绝 `command` 本地进程类型);支持 `mcpServers` JSON 批量导入(Cursor / Claude Desktop 风格)。认证请求头 `X-Mcp-Key`,可附加自定义 headers,超时 3-120 秒。`tools/list` 游标分页拉取并缓存。
- **预置连接器**:`builtin`(启用)、`tavily` 联网搜索(keyless 免费模式,启用)、`amap` 高德地图(停用,需填 Key)、`memcp` 记忆库(停用,需填密钥)。
- **安全**:所有出站 URL 经 `SsrfGuard` 校验(默认仅公网;`ai_allow_private_endpoint=1` 时放行内网,云元数据地址始终禁止),DNS 解析钉扎防重绑定;headers 名称须为合法 token。
- 同步与测试:「同步工具」写回工具清单缓存(`interaction`/`label` 随缓存,service_mcp 侧声明变更后需重新同步才生效);「测试连接」完整握手并拉取清单,不落缓存。
### 3.3 Skill 体系
一个 Skill 即 `.crmeb/skills/{key}/SKILL.md` 一个文件:frontmatter(`name/description/status`)为元信息,正文为注入模型的工作规则。**存储即文件系统,无数据表**。
- key 正则 `^[a-z][a-z0-9_-]{0,63}$`;文件写入上限 200KB(临时文件 + rename 原子写);注入模型时截断 32768 字节。
- 附属资料:目录内除 `SKILL.md` 与保留名 `EXAMPLES.md` 外的 `.md` 文件,由模型经 `crmeb_skill_load` 的 `file` 参数按需加载(realpath 校验防目录穿越)。
- 与 `.crmeb/AGENTS.md` 的关系:AGENTS.md 恒定最先注入作兜底规范;Skill 更具体,冲突时以 Skill 为准。
**编写要求**(新增或修改 Skill 时遵循):
- **职责边界**:技能只写工具 `description`/`inputSchema` 承载不了的**流程编排、查证顺序、失败自愈与展示规范**;参数级约束(枚举、格式、校验规则)一律在 MCP 工具侧维护,禁止在技能中复述,避免双源漂移;
- **结构**:开头一句话圈定适用范围与涉及工具;正文按任务阶段组织(查证 → 决策确认 → 执行 → 复核),配「参数构造要点」「常见报错与处理」「边界与引导」小节;有固定输出形态的附可直接套用的模板;
- **语气**:简体中文指令式,每条规则可执行、可判定(「必须/禁止/先…再…」),不写背景科普;
- **篇幅**:正文建议 8KB 以内——正文随每次对话注入,所有启用技能共享上下文预算,超过 32768 字节会被截断;
- **frontmatter**:`name` 中文短名(后台与技能目录展示),`description` 单行概括「做什么 + 何时触发」(供模型按需加载判断),`status` 1 启用 / 0 停用;
- **变更登记**:新增/删除内置 Skill 须同步本节清单表与模块 README 的「内置资产」;
- **回归**:涉及提示词的改动按模块 README「定向回归」人工过一遍对话工具循环与确认卡。
- 内置 7 个 Skill(`backend/modules/aiagent/.crmeb/skills/`):
| key | 名称 | 职责 |
|---|---|---|
| `crmeb-assistant` | CRMEB 业务助手 | 用 `builtin.*` 工具查询商品/订单/售后/优惠券/用户数据,并核对各统计工具口径差异 |
| `user` | 用户管理 | 用户写操作(资料/备注/分组/标签/等级/付费会员)的查证流程、覆盖语义、批量核对与边界 |
| `coupon` | 优惠券管理 | 创建优惠券的查证与确认流程、参数构造要点、报错自愈;停用删除引导后台;发放效果统计口径 |
| `crontab` | 系统定时任务 | 区分系统定时任务与 AI 自动化任务两类概念,指引任务菜单位置 |
| `doc` | 功能说明 | 功能归属/配置入口/支持状态查证;附件 `功能索引.md` 为历史版本功能速查(仅明确问及时加载) |
| `emailformat` | 邮件格式 | 约束 `builtin.crmeb_email_send` 正文必须为内联样式简单 HTML |
| `systemsetting` | 系统设置 | 查询/修改系统配置的用法与确认卡授权流程 |
Skill 管理依赖 Web 运行用户对模块 `.crmeb/skills/` 目录的写权限;删除 Skill 会清理目录内全部文件。
## 4. 自动化任务
### 4.1 任务配置
任务存 `eb_ai_agent_task`,字段含指令(instruction)、模型、连接器、Skill、调度、自主级别、轮数(3-30)与软超时(60-1800 秒)、通知渠道(邮件 / 企业微信群机器人)。
调度类型:每隔 N 分钟(1-43200)、每日 HH:MM、每周(周几|HH:MM)、每月(几日|HH:MM,仅 1-28)、每年(月|日|HH:MM)。
自主级别:
| 值 | 含义 | 现状 |
|---|---|---|
| 1 | 只读(默认) | 需确认写工具对模型不可见 |
| 2 | 需审批 | **一期未实现**,保存时直接拒绝该取值 |
| 3 | 全自动 | 保留全部工具并豁免确认,仅超级管理员(level=0)可设 |
### 4.2 调度与执行链路
1. **调度扫描**:定时任务 `aiAgentTask`(`eb_system_crontab` 预置记录,每分钟)调用 `AgentTaskServices::dispatchDueTasks()`:回收孤儿运行 → 取到期任务(单次上限 50)→ **先推进 next_run_time 再入队**(保证扫描幂等)→ 创建运行记录(唯一索引防重复投递)→ `AgentTaskJob` 入队。timer 进程内毫秒级完成,LLM 多轮调用全部在队列容器执行。
2. **执行**:`AgentTaskRunnerService::run()` 为队列唯一入口——数据库 CAS 领取(仅 `running 且 started_at=0` 可写 started_at,并发只有一个赢家)→ 新建独立审计会话(运行记录回写 conversation_id,全过程可回放)→ 以 `unattended=true` 与任务自主级别复用 `AgentChatService::chat()` 执行 → 软超时到点强制收尾并标记 `timeout`。
3. **交付保障**:主路径为模型自主调用交付类工具(邮件/企业微信,收件目标受工具端白名单硬约束);运行结束校验工具轨迹,未交付则按通知渠道兜底投递(邮件走 `EmailChannel`,企业微信直连 Webhook 配置 `aiagent_qywechat_webhook`);失败/超时同样发送失败提醒。
4. **状态机**:`running → success / failed / timeout`;运行记录含摘要、错误、工具调用次数、token 用量与起止时间。队列 Job 恒定领取即删除(不走队列级重试),失败由状态机标记并通知,避免 LLM 多轮重复消耗。
5. **无人值守邮件白名单**:收件人仅允许任务登记的 `notify_email` 与系统配置 `aiagent_email_recipients`,两者皆无则拒绝发送。
## 5. 数据表
模块自有 5 张表(安装 `CREATE TABLE IF NOT EXISTS` 幂等创建,卸载 DROP):
| 表 | 用途 | 关键字段 |
|---|---|---|
| `eb_ai_agent_conversation` | 会话 | `admin_id`(归属)、`title`、`is_pin`(存量安装自动补列) |
| `eb_ai_agent_message` | 消息 | `conversation_id`、`role`(user/assistant/tool/confirm)、`content`、`tool_name`、`tool_payload`(工具参数/结果/确认卡快照 JSON) |
| `eb_ai_agent_task` | 自动化任务 | `instruction`、`schedule_type/schedule_value`、`autonomy`、`max_steps`、`timeout`、`notify_channels/notify_email`、`next_run_time` |
| `eb_ai_agent_task_run` | 运行记录 | `task_id`、`conversation_id`(回放)、`status`、`trigger_type`(1 调度/2 手动)、`summary`、`error`、`tool_calls`、`usage`;唯一索引 `uk_task_trigger` 防重复投递 |
| `eb_ai_mcp_server` | MCP 连接器 | `server_key`(唯一,工具命名空间前缀)、`url`、`api_key`、`headers`、`tools`(清单缓存)、`tools_sync_time` |
依赖的核心表(非自有):`eb_ai_model`(模型)、`eb_system_crontab`(调度记录)、`eb_system_config` / `eb_system_config_tab`(配置)、`eb_system_menus`(菜单权限)。
模块预置的系统配置(「AI 自动化任务」配置分组):`aiagent_qywechat_webhook`(企业微信群机器人 Webhook)、`ai_allow_private_endpoint`(是否允许内网 AI/MCP 地址,默认 0)、`aiagent_email_recipients`(无人值守邮件收件白名单)。
## 6. 接口与权限
路由前缀 `/adminapi/aiagent`,中间件链:跨域 → 管理员登录(`AdminAuthTokenMiddleware`)→ 角色权限(`AdminCheckRoleMiddleware`,按 `system_menus` 中 `auth_type=2` 且 `api_url` 匹配)→ 操作日志。数据层会话/任务一律按 `admin_id` 归属校验。
| 分组 | 方法与路径 | 用途 |
|---|---|---|
| 对话 | GET `/options` | 模型/连接器/Skill 选项 |
| | GET `/conversations`、GET `/messages/:conversation_id` | 会话列表 / 消息明细 |
| | DELETE `/conversation/:id`、PUT `/conversation/:id` | 删除 / 改标题与置顶(仅本人) |
| | POST `/chat` | 非流式对话 |
| | POST `/chat_stream` | SSE 流式对话 |
| | POST `/confirm_stream` | SSE 处理确认/问询卡片 |
| 任务 | GET `/tasks`、POST `/task`、GET/PUT/DELETE `/task/:id` | 任务 CRUD(分页、仅本人) |
| | PUT `/task/:id/status` | 启停 |
| | POST `/task/:id/run` | 立即执行(入队) |
| | GET `/task/:id/runs`、GET `/task_runs`、GET `/task_run/:id` | 运行历史 / 全量记录 / 运行详情(含会话回放) |
| 连接器 | GET `/mcp_servers`、POST `/mcp_server`、GET/PUT/DELETE `/mcp_server/:id` | 连接器 CRUD(POST 支持 mcpServers JSON 导入) |
| | PUT `/mcp_server/:id/status` | 启停 |
| | POST `/mcp_server/:id/sync`、POST `/mcp_server/:id/test` | 同步工具清单 / 连通性测试 |
| Skill | GET `/skills`、POST `/skill`、GET/PUT/DELETE `/skill/:key` | Skill CRUD(文件系统) |
| | PUT `/skill/:key/status` | 启停 |
| | GET/POST/DELETE `/skill/:key/attachment`、GET `/skill/:key/attachments` | 附属资料管理(文件名经查询参数 `file` 传递) |
路由变量约定:Skill key 使用全局 pattern `[A-Za-z0-9_-]+`(连字符不受默认 `[\w.]+` 限制);Skill 相关接口 `:key` 即目录名。
## 7. 已知限制
| 限制 | 说明 |
|---|---|
| 「需审批」自主级别未实现 | `autonomy=2` 保存时直接拒绝,审批链路待后续版本 |
| MCP 仅支持远程 Streamable HTTP | 拒绝 `command` 本地进程类型 |
| 流式对话工具轮数固定 | 流式路径单次最多 6 轮(不读任务配置);自动化任务走非流式路径,受 `max_steps` 3-30 控制 |
| 工具交互声明依赖缓存 | `interaction` 随「同步工具」写入缓存,service_mcp 侧变更后需重新同步内置 MCP |
| 队列降级阻塞 | 未开启队列时手动「立即执行」同步等待完成 |
| Docker 回环地址不可达 | 容器内 localhost 指向容器自身;内置地址自动候选修复,自定义连接器需填容器网络地址 |
| 早期安装的 service_mcp 兼容 | 需禁用再启用一次以补种系统账号与 `mcp_appkey` 镜像,随后重新「同步工具」 |
| 上下文限长 | 用户消息 8000 字;Skill 正文/附属资料 32768 字节;AGENTS.md 16000 字;工具结果 16000 字(附属资料 32000) |
| Skill 管理需文件写权限 | Web 运行用户对模块 `.crmeb/skills/` 需可写 |
## 8. 相关文档
- MCP 服务与内置工具清单:[MCP 接口](api/MCP接口.md)
- 模块与驱动体系:[模块与驱动](模块与驱动.md)
- 模块开发视角速览:`backend/modules/aiagent/README.md`

390
scripts/check-doc-facts.mjs Normal file
View File

@ -0,0 +1,390 @@
#!/usr/bin/env node
// +----------------------------------------------------------------------
// | CRMEB 文档事实校验脚本
// +----------------------------------------------------------------------
// | 校验两类易漂移的开发资料事实:
// | 1. 代码事实数量:模块 / 驱动 / 默认安装 / MCP 内置工具 / 基础表 /
// | 开发规则 / 技能 / 子智能体数量,与各文档中的数字声明逐一比对
// | 2. 根入口文档链接:根目录各入口文件(README / AGENTS.md / CLAUDE.md /
// | llms.txt 等)中的本地相对链接目标是否存在
// |
// | 用法:node scripts/check-doc-facts.mjs [--root 仓库根目录]
// | 退出码:0=通过;1=存在 error(可接入 CI / 提交流程拦截)
// | 维护:文档中的数字声明调整措辞后,同步更新下方 DOC_FACTS 注册表
// +----------------------------------------------------------------------
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join, resolve, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
let repoRoot = resolve(scriptDir, '..');
// --root 参数:显式指定仓库根(脚本不在 scripts/ 下运行时使用)
const rootArg = process.argv.indexOf('--root');
if (rootArg > -1 && process.argv[rootArg + 1]) {
repoRoot = resolve(process.argv[rootArg + 1]);
}
const errors = [];
const checked = { facts: 0, links: 0 };
function error(message) {
errors.push(message);
}
function readText(relPath) {
return readFileSync(join(repoRoot, relPath), 'utf8');
}
// ---------------------------------------------------------------------------
// 一、代码事实统计(以磁盘实现为唯一基线)
// ---------------------------------------------------------------------------
/** 递归收集目录下指定深度的 plugin.json(用于 modules 两层/三层嵌套) */
function findPluginJson(baseDir, maxDepth) {
const found = [];
const walk = (dir, depth) => {
if (depth > maxDepth) return;
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const full = join(dir, entry.name);
const manifest = join(full, 'plugin.json');
if (existsSync(manifest)) found.push(manifest);
walk(full, depth + 1);
}
};
walk(baseDir, 1);
return found;
}
function readManifest(file) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch (e) {
error(`plugin.json 解析失败: ${relative(repoRoot, file)}(${e.message})`);
return {};
}
}
/** 模块:backend/modules 递归 plugin.json */
function statModules() {
const files = findPluginJson(join(repoRoot, 'backend/modules'), 3);
const modules = files.map((file) => {
const json = readManifest(file);
return {
key: String(json.key ?? ''),
required: json.required === true,
defaultInstall: json.default_install === true,
};
});
return {
total: modules.length,
required: modules.filter((m) => m.required).length,
defaultOnly: modules.filter((m) => m.defaultInstall && !m.required).length,
defaultAll: modules.filter((m) => m.defaultInstall || m.required).length,
list: modules,
};
}
/** 驱动:backend/driver/<类别>/<供应商>/plugin.json */
function statDrivers() {
const files = findPluginJson(join(repoRoot, 'backend/driver'), 3);
const drivers = files.map((file) => {
const json = readManifest(file);
return { key: String(json.key ?? ''), defaultInstall: json.default_install === true };
});
return {
total: drivers.length,
defaultInstall: drivers.filter((d) => d.defaultInstall).length,
list: drivers,
};
}
/** MCP 内置工具:tools/ 下以 Tool.php 结尾的类文件 */
function statMcpTools() {
const dir = join(repoRoot, 'backend/modules/service/mcp/tools');
let files = [];
try {
files = readdirSync(dir).filter((f) => f.endsWith('Tool.php'));
} catch {
error('未找到 backend/modules/service/mcp/tools/ 目录');
}
return {
total: files.length,
dev: files.filter((f) => /^Dev.+Tool\.php$/.test(f)).length,
};
}
/** 基础表:安装 SQL 中的 CREATE TABLE 语句数 */
function statTables() {
const sql = join(repoRoot, 'backend/public/install/crmeb_table.sql');
if (!existsSync(sql)) {
error('未找到 backend/public/install/crmeb_table.sql');
return 0;
}
return (readFileSync(sql, 'utf8').match(/CREATE\s+TABLE/gi) ?? []).length;
}
/** AI 开发资产:规则 / 技能 / 子智能体数量 */
function statAssets() {
const rulesDir = join(repoRoot, '.crmeb/rules');
const skillsDir = join(repoRoot, '.crmeb/skills');
const agentsDir = join(repoRoot, '.crmeb/agents');
const countMd = (dir) => {
try {
return readdirSync(dir).filter((f) => f.endsWith('.md')).length;
} catch {
return 0;
}
};
let skills = 0;
try {
skills = readdirSync(skillsDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && existsSync(join(skillsDir, e.name, 'SKILL.md')))
.length;
} catch {
/* .crmeb 缺失时归零,由声明比对报错提示 */
}
return { rules: countMd(rulesDir), skills, agents: countMd(agentsDir) };
}
const facts = {
modules: statModules(),
drivers: statDrivers(),
mcpTools: statMcpTools(),
tables: statTables(),
assets: statAssets(),
};
const FACT_LABELS = {
'modules.total': `模块总数(backend/modules/**/plugin.json)`,
'modules.required': `必装模块数(required=true)`,
'modules.defaultOnly': `默认安装模块数(default_install=true 且非必装)`,
'modules.defaultAll': `新装自动安装模块数(required 或 default_install)`,
'drivers.total': `驱动总数(backend/driver/**/plugin.json)`,
'drivers.defaultInstall': `默认安装驱动数(default_install=true)`,
'mcpTools.total': `MCP 内置工具数(tools/*Tool.php)`,
'mcpTools.dev': `MCP 开发知识工具数(Dev*Tool.php)`,
'tables.total': `基础表数(crmeb_table.sql 的 CREATE TABLE)`,
'assets.rules': `开发规则数(.crmeb/rules/*.md)`,
'assets.skills': `开发技能数(.crmeb/skills/*/SKILL.md)`,
'assets.agents': `子智能体数(.crmeb/agents/*.md)`,
};
// ---------------------------------------------------------------------------
// 二、文档数字声明注册表
// ---------------------------------------------------------------------------
// 每条声明 = { file, regex, checks }:
// - regex 需与文档中的声明行匹配,捕获组按顺序对应 checks 中的事实键
// - 文档措辞调整后需同步修改此处;新增数量声明时补一条注册
const DOC_FACTS = [
{
file: 'help/docs/模块与驱动.md',
regex: /\|\s*业务模块\s*\|\s*`backend\/modules\/`\s*\|\s*(\d+) 个清单/,
checks: ['modules.total'],
},
{
file: 'help/docs/模块与驱动.md',
regex: /\|\s*第三方驱动\s*\|\s*`backend\/driver\/`\s*\|\s*(\d+) 个清单/,
checks: ['drivers.total'],
},
{
file: 'help/docs/模块与驱动.md',
regex: /新装自动安装 (\d+) 个模块((\d+) 个必装 \+ (\d+) 个默认安装)/,
checks: ['modules.defaultAll', 'modules.required', 'modules.defaultOnly'],
},
{
file: 'help/docs/模块与驱动.md',
regex: /新装自动安装 (\d+) 个驱动/,
checks: ['drivers.defaultInstall'],
},
{
file: 'help/docs/项目说明.md',
regex: /当前可发现 (\d+) 个模块、(\d+) 个驱动/,
checks: ['modules.total', 'drivers.total'],
},
{
file: 'help/docs/项目说明.md',
regex: /新装默认安装 (\d+) 个模块和 (\d+) 个驱动/,
checks: ['modules.defaultAll', 'drivers.defaultInstall'],
},
{
file: 'help/docs/文档索引.md',
regex: /(\d+) 个模块、(\d+) 个驱动、默认安装清单/,
checks: ['modules.total', 'drivers.total'],
},
{
file: 'README_ZH.md',
regex: /当前内置 (\d+) 个官方模块([^)]*)与 (\d+) 个官方驱动/,
checks: ['modules.total', 'drivers.total'],
},
{
file: 'README_ZH.md',
regex: /当前内置 (\d+) 个模块与 (\d+) 个驱动/,
checks: ['modules.total', 'drivers.total'],
},
{
file: 'README.md',
regex: /(\d+) 条开发规则、(\d+) 个开发技能与 (\d+) 个专项子智能体/,
checks: ['assets.rules', 'assets.skills', 'assets.agents'],
},
{
file: '.crmeb/README.md',
regex: /(\d+) 个专项技能,详细教程/,
checks: ['assets.skills'],
},
{
file: '.crmeb/README.md',
regex: /(\d+) 个技能及按需加载的参考手册/,
checks: ['assets.skills'],
},
{
file: '.crmeb/README.md',
regex: /(\d+) 个专项角色说明/,
checks: ['assets.agents'],
},
{
file: 'help/docs/api/MCP接口.md',
regex: /当前自动发现 (\d+) 个内置工具,其中 (\d+) 个业务工具、(\d+) 个开发知识工具/,
checks: ['mcpTools.total', 'mcpTools.business', 'mcpTools.dev'],
},
];
/** 校验文档数字声明 */
function checkDocFacts() {
const factValue = (kind) => {
if (kind === 'mcpTools.business') return facts.mcpTools.total - facts.mcpTools.dev;
const [group, key] = kind.split('.');
return facts[group]?.[key];
};
const byFile = new Map();
for (const rule of DOC_FACTS) {
if (!byFile.has(rule.file)) byFile.set(rule.file, []);
byFile.get(rule.file).push(rule);
}
for (const [file, rules] of byFile) {
let content;
try {
content = readText(file);
} catch {
error(`声明校验目标不存在: ${file}`);
continue;
}
const lines = content.split('\n');
for (const rule of rules) {
let matched = false;
lines.forEach((line, idx) => {
const m = line.match(rule.regex);
if (!m) return;
matched = true;
rule.checks.forEach((kind, i) => {
checked.facts += 1;
const declared = Number(m[i + 1]);
const actual = factValue(kind);
if (Number.isNaN(declared)) {
error(`${file}:${idx + 1} 数字声明无法解析(${kind})`);
} else if (declared !== actual) {
error(
`${file}:${idx + 1} 数字漂移:声明 ${declared},代码实际 ${actual}(${FACT_LABELS[kind] ?? kind})`,
);
}
});
});
if (!matched) {
error(`声明校验规则未匹配到任何行: ${file}(${rule.regex}),文档措辞可能已变化,请同步更新 scripts/check-doc-facts.mjs`);
}
}
}
}
// ---------------------------------------------------------------------------
// 三、根入口文档链接校验
// ---------------------------------------------------------------------------
const ROOT_ENTRIES = [
'README.md',
'README_ZH.md',
'AGENTS.md',
'CLAUDE.md',
'llms.txt',
'CONTRIBUTING.md',
'.crmeb/README.md',
];
/** 剥离围栏代码块,避免校验示例中的链接 */
function stripFencedBlocks(text) {
return text.replace(/^```[^\n]*\n[\s\S]*?^```/gm, (block) => block.replace(/[^\n]/g, ''));
}
function collectLinks(text, allowAngle) {
const links = [];
// Markdown 链接 [text](target),排除图片 ![alt](src)
const mdLink = /(?<!\!)\[[^\]]*\]\(([^)\s]+)\)/g;
// llms.txt 风格内联链接 <target>(其他入口文件含 HTML 标签,不适用)
const angleLink = allowAngle ? /<(?!https?:)([^<>\s]+)>/g : null;
for (const m of text.matchAll(mdLink)) links.push(m[1]);
if (angleLink) for (const m of text.matchAll(angleLink)) links.push(m[1]);
return links;
}
function checkRootLinks() {
for (const entry of ROOT_ENTRIES) {
const fullPath = join(repoRoot, entry);
if (!existsSync(fullPath)) {
error(`根入口文件缺失: ${entry}`);
continue;
}
const text = stripFencedBlocks(readText(entry));
const baseDir = dirname(fullPath);
const allowAngle = entry === 'llms.txt';
for (const raw of collectLinks(text, allowAngle)) {
let target = raw;
// 排除外部链接、邮件与页内锚点
if (/^(https?:|mailto:|#)/i.test(target)) continue;
// 去掉页内锚点与查询串
target = target.split('#')[0].split('?')[0];
if (target === '') continue;
try {
target = decodeURIComponent(target);
} catch {
/* 保留原样 */
}
checked.links += 1;
const resolved = resolve(baseDir, target);
if (!existsSync(resolved)) {
error(`${entry}: 本地链接目标不存在 ${raw} → ${relative(repoRoot, resolved)}`);
}
}
}
}
// ---------------------------------------------------------------------------
// 四、执行与输出
// ---------------------------------------------------------------------------
checkDocFacts();
checkRootLinks();
if (errors.length > 0) {
console.error(`文档事实校验未通过(${errors.length} 个 error):\n`);
for (const e of errors) console.error(` ${e}`);
console.error(
`\n基线统计:模块 ${facts.modules.total}(必装 ${facts.modules.required} + 默认 ${facts.modules.defaultOnly})、` +
`驱动 ${facts.drivers.total}(默认安装 ${facts.drivers.defaultInstall})、` +
`MCP 工具 ${facts.mcpTools.total}(开发知识 ${facts.mcpTools.dev})、` +
`基础表 ${facts.tables}、规则 ${facts.assets.rules} / 技能 ${facts.assets.skills} / 子智能体 ${facts.assets.agents}`,
);
process.exit(1);
}
console.log(
`文档事实校验通过:数字声明 ${checked.facts} 处、根入口链接 ${checked.links} 条均与代码一致。` +
`基线:模块 ${facts.modules.total}、驱动 ${facts.drivers.total}、MCP 工具 ${facts.mcpTools.total}、基础表 ${facts.tables}、` +
`规则 ${facts.assets.rules} / 技能 ${facts.assets.skills} / 子智能体 ${facts.assets.agents}。`,
);