From 001a9c364132501813b6f079709776816a6a50b6 Mon Sep 17 00:00:00 2001 From: Mini-happ <1521801957@qq.com> Date: Fri, 7 Aug 2026 20:53:33 +0800 Subject: [PATCH 01/10] docs: add project baseline record (#1) --- BASELINE.md | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 BASELINE.md diff --git a/BASELINE.md b/BASELINE.md new file mode 100644 index 00000000000..54cfcc6e4a2 --- /dev/null +++ b/BASELINE.md @@ -0,0 +1,135 @@ +\# AionUi-Campus Baseline + + + +\## 1. 基线信息 + + + +\- 基线日期:2026-08-07 + +\- 基线标签:`baseline-20260807` + +\- 基线 Commit:`f98d9f719de9c639f739470ea49f01a6b0a22687` + + + +\## 2. Git 仓库 + + + +\### 团队仓库 + + + +\- Origin: `https://github.com/ai-campus-2026/AionUi-Campus.git` + + + +\### AionUi 上游仓库 + + + +\- Upstream: `https://github.com/iOfficeAI/AionUi.git` + + + +\## 3. 分支策略 + + + +\- `main`:稳定、可演示、可发布版本 + +\- `develop`:团队日常集成分支 + +\- `feat/\*`:功能开发分支 + +\- `fix/\*`:Bug 修复分支 + +\- `docs/\*`:文档修改分支 + +\- `test/\*`:测试与评测分支 + + + +\### 分支保护 + + + +\- \[x] `main` 已启用 GitHub Ruleset + +\- \[x] `develop` 已启用 GitHub Ruleset + +\- \[x] 禁止删除受保护分支 + +\- \[x] 禁止 Force Push + +\- \[x] 修改必须通过 Pull Request + +\- \[x] PR 至少需要 1 个 Approval + + + +\## 4. 当前验证状态 + + + +\### AionUi-Campus + + + +\- \[x] Git 仓库正常 + +\- \[x] 依赖环境已完成安装 + +\- \[x] Desktop UI 可以正常启动 + +\- \[ ] 真实大模型对话验证 + +\- \[ ] 团队五人环境全部验收 + + + +\### AionCore + + + +### AionCore + +- CLI 版本:`aioncore 0.1.61` +- Git 仓库:`https://github.com/iOfficeAI/AionCore.git` +- Git 分支:`main` +- Git Commit:`81ef258913e6ac5076a86d4adcc7edcc0f8f21ef` +- 工作区状态:Clean +- 管理方式:作为 AionUi-Campus 的上游/底层依赖使用,不建立团队 develop 分支,不向官方仓库推送比赛代码。 + + +\## 5. 当前版本关系 + + + +`baseline-20260807`、`main` 与 `develop` 均从以下 Commit 开始: + + + +`f98d9f719de9c639f739470ea49f01a6b0a22687` + + + +\## 6. 后续要求 + + + +1\. 团队成员不得直接向 `main` 或 `develop` 推送比赛功能代码。 + +2\. 所有开发从最新 `develop` 创建独立分支。 + +3\. 通过 Pull Request 合并至 `develop`。 + +4\. 集成、测试通过后,再通过 Pull Request 合并至 `main`。 + +5\. API Key、个人账号、学号、隐私数据不得提交至仓库。 + +6\. 上游 AionUi 更新只由 1 号负责人统一处理。 + +> 注意:AionCore 当前锁定为上述版本。比赛开发期间如非确有必要,不随意升级 AionCore;如需升级,必须先在独立分支验证 AionUi-Campus 能正常启动和运行。 \ No newline at end of file From 43c154a37d1a532db29a13bc839b929bdb742eb5 Mon Sep 17 00:00:00 2001 From: Mini-happ <1521801957@qq.com> Date: Mon, 10 Aug 2026 22:29:48 +0800 Subject: [PATCH 02/10] docs: add team environment runbook (#2) --- RUNBOOK.md | 1584 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1584 insertions(+) create mode 100644 RUNBOOK.md diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 00000000000..8199321241f --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,1584 @@ +# AionUi-Campus 团队运行与环境复现手册(RUNBOOK) + +**项目:** 2026 年第二届重庆市 AI 大模型创新应用大赛 · “创意 AI 校园” +**仓库:** `ai-campus-2026/AionUi-Campus` +**适用系统:** Windows 10 / 11 + PowerShell +**文档版本:** V1.0 +**基线日期:** 2026-08-07 +**维护人:** 1 号负责人 / 队长 + +--- + +## 0. 这份文档解决什么问题 + +本 RUNBOOK 用于保证 5 名成员在不同电脑上能够按照同一套方法完成: + +1. 获取团队代码; +2. 安装和检查基础开发环境; +3. 编译并安装 AionCore; +4. 安装 AionUi-Campus 依赖; +5. 启动桌面端; +6. 完成真实模型对话验收; +7. 正确使用 `develop`、功能分支和 Pull Request; +8. 运行基础质量检查; +9. 出现常见环境问题时按照统一流程排查; +10. 留下可用于比赛“可运行代码及依赖环境说明”的复现证据。 + +**核心原则:先保证所有成员的原版环境一致,再开始分工开发。** + +环境阶段不要为了“追最新”自行升级依赖,也不要在 `main` / `develop` 直接开发比赛功能。 + +--- + +# 1. 当前团队基线 + +## 1.1 AionUi-Campus + +团队仓库: + +```text +https://github.com/ai-campus-2026/AionUi-Campus.git +``` + +官方上游: + +```text +https://github.com/iOfficeAI/AionUi.git +``` + +团队锁定的原始开发基线: + +```text +Tag: +baseline-20260807 + +Commit: +f98d9f719de9c639f739470ea49f01a6b0a22687 +``` + +说明: + +- `baseline-20260807` 是“确认原版能够运行”的历史基线,不再移动。 +- `main` 用于稳定、可演示、可发布版本。 +- `develop` 用于团队日常集成,会随着开发继续前进。 +- 所有新功能从最新 `develop` 创建任务分支。 + +详细版本信息见: + +```text +BASELINE.md +``` + +--- + +## 1.2 AionCore + +当前团队确认: + +```text +CLI Version: +aioncore 0.1.61 + +Repository: +https://github.com/iOfficeAI/AionCore.git + +Branch: +main + +Commit: +81ef258913e6ac5076a86d4adcc7edcc0f8f21ef +``` + +AionCore 当前作为 AionUi-Campus 的上游 / 底层依赖使用。 + +团队目前: + +- 不建立 AionCore 的团队 `develop`; +- 不给官方 AionCore 仓库创建比赛 tag; +- 不向官方 AionCore 仓库 push 比赛代码; +- 不在比赛开发期间随意升级 AionCore。 + +如后续确实需要修改 AionCore,必须由 1 号负责人先重新评估版本管理方案。 + +--- + +# 2. 推荐目录结构 + +为了减少 Windows 原生依赖、脚本和路径兼容问题,普通成员建议使用短、稳定、非同步盘的英文路径。 + +推荐: + +```text +D:\AI-Campus-Workspace +├─ AionUi-Campus +└─ AionCore +``` + +队长当前使用中文路径已验证可运行,但其他成员优先采用上述英文路径。 + +后文统一使用: + +```text + +``` + +表示个人工作目录。 + +例如: + +```text + = D:\AI-Campus-Workspace +``` + +--- + +# 3. 环境阶段禁止事项 + +在完成环境验收之前,成员不要: + +1. 自己再 Fork 一份 AionUi-Campus; +2. 修改业务代码; +3. 直接向 `main` 或 `develop` push; +4. 运行 `cargo update`; +5. 运行 `bun update`; +6. 手动删除或重写 `bun.lock`; +7. 自行升级 Node 到 25 或更高; +8. 自行升级 AionCore; +9. 自行点击 GitHub 的 Sync fork 把 upstream 更新合入团队仓库; +10. 把 API Key、AccessKey、密码、学号、真实账号、未脱敏日志或未授权资料提交到仓库。 + +上游同步只由 1 号负责人统一处理。 + +--- + +# 4. 新电脑第一次获取代码 + +## 4.1 创建工作目录 + +```powershell +mkdir D:\AI-Campus-Workspace -ErrorAction SilentlyContinue +cd D:\AI-Campus-Workspace +``` + +## 4.2 Clone 团队 AionUi-Campus + +```powershell +git clone https://github.com/ai-campus-2026/AionUi-Campus.git +``` + +这是团队唯一的 AionUi 比赛开发仓库。 + +普通成员不需要再创建个人 Fork。 + +## 4.3 Clone AionCore + +```powershell +git clone https://github.com/iOfficeAI/AionCore.git +``` + +## 4.4 检查目录 + +```powershell +Get-ChildItem D:\AI-Campus-Workspace +``` + +正常应至少看到: + +```text +AionUi-Campus +AionCore +``` + +--- + +# 5. 已经 Clone 的成员如何同步团队最新代码 + +如果成员已经提前 Clone 过仓库,**不需要重新 Clone**。 + +当队长通知“开始统一进入 develop”后执行: + +```powershell +cd D:\AI-Campus-Workspace\AionUi-Campus + +git fetch origin +git switch develop +git pull +``` + +检查: + +```powershell +git branch --show-current +``` + +正常应显示: + +```text +develop +``` + +然后: + +```powershell +git status +``` + +正常情况下应看到类似: + +```text +On branch develop +Your branch is up to date with 'origin/develop'. + +nothing to commit, working tree clean +``` + +如果本地还没有 `develop`,`git fetch origin` 后再执行 `git switch develop`。 + +--- + +# 6. 一次性环境检查 + +打开一个新的 PowerShell,依次执行: + +```powershell +git --version +node -v +bun --version +python --version +rustup --version +rustc --version +cargo --version +``` + +不要看到某一个命令失败就开始随意安装所有工具。 + +先记录哪些已经存在,缺什么再补什么。 + +--- + +# 7. 推荐/要求版本 + +| 组件 | 团队要求 / 建议 | 当前已验证情况 | +|---|---|---| +| Windows | Windows 10 / 11 x64 | Windows 环境已验证 | +| Git | 可正常 clone / fetch / pull / push | 必需 | +| Node.js | `>=22` 且 `<25` | 队长已验证 24.14.0 | +| Bun | 稳定版 | 队长已验证 1.3.14 | +| Python | 3.11+ | 队长已验证 3.12.5 | +| Rust | AionCore 项目锁定 1.95.0 | 必须按项目工具链 | +| Cargo | 随 Rust 工具链 | 必需 | +| AionCore | `0.1.61` | 当前团队锁定 | +| Electron | 跟随仓库 lockfile/package | 不单独追最新版 | + +**原则:符合要求就不要为了“更新”而更新。** + +--- + +# 8. Node.js + +检查: + +```powershell +node -v +``` + +要求: + +```text +>= 22 +< 25 +``` + +例如 24.x 可以使用。 + +如果已经满足范围,不要自行升级。 + +如果不满足,再安装符合范围的版本,并重新打开 PowerShell 后检查。 + +--- + +# 9. Bun + +检查: + +```powershell +bun --version +``` + +如果 PowerShell 无法识别 `bun`,可使用 Bun 的 PowerShell 安装方式: + +```powershell +powershell -c "irm bun.sh/install.ps1|iex" +``` + +安装后: + +1. 关闭当前 PowerShell; +2. 新开 PowerShell; +3. 再执行: + +```powershell +bun --version +``` + +--- + +# 10. Python + +检查: + +```powershell +python --version +``` + +团队环境建议: + +```text +Python 3.11+ +``` + +如果低于 3.11,再安装新版本,并确保 Python 已加入 PATH。 + +当前阶段 Python 不是 AionUi 桌面启动的唯一核心依赖,但后续 MCP / RAG 服务可能会使用。 + +--- + +# 11. Rust 与 Cargo + +## 11.1 检查 rustup + +```powershell +rustup --version +``` + +如果不存在,安装 Rust 官方 `rustup`。 + +Windows 安装时使用默认 MSVC 工具链即可。 + +## 11.2 安装 AionCore 项目要求的 Rust + +当前 AionCore 项目工具链锁定为 Rust 1.95.0。 + +执行: + +```powershell +rustup toolchain install 1.95.0 --profile minimal +``` + +## 11.3 国内网络下载很慢时 + +只在默认源明显很慢、失败或连接不稳定时,在**当前 PowerShell** 临时设置镜像: + +```powershell +$env:RUSTUP_DIST_SERVER="https://rsproxy.cn" +$env:RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" + +rustup toolchain install 1.95.0 --profile minimal +``` + +这些环境变量只影响当前 PowerShell。 + +## 11.4 在 AionCore 目录检查实际工具链 + +```powershell +cd D:\AI-Campus-Workspace\AionCore + +rustc --version +cargo --version +``` + +成功标准: + +```text +rustc 1.95.0 ... +``` + +如果系统全局还有其他 Rust 版本,只要进入 AionCore 目录后项目工具链正确即可。 + +--- + +# 12. Windows C/C++ 构建工具 + +AionCore 的部分 Rust 原生依赖在 Windows 上可能需要 MSVC / C++ Build Tools。 + +如果 Cargo 明确提示: + +```text +native build tools missing +MSVC tools missing +linker not found +``` + +再安装 Visual Studio C++ Build Tools。 + +安装完成后重新打开 PowerShell,再重新编译。 + +**没有相关错误时不要为了“保险”反复修改 C++ 工具链。** + +--- + +# 13. 编译并安装 AionCore + +目标:生成可用的 `aioncore.exe`,并让 AionUi 能从 PATH 中找到。 + +## 13.1 进入 AionCore + +```powershell +cd D:\AI-Campus-Workspace\AionCore +``` + +## 13.2 首次编译 + +```powershell +cargo clean +cargo install --path crates/aionui-app --locked +``` + +第一次编译会下载和编译大量 Rust 依赖。 + +出现很多: + +```text +Compiling ... +``` + +属于正常现象。 + +几分钟到十几分钟都有可能,不要因为短时间没有新输出立即终止。 + +## 13.3 验证安装 + +```powershell +where.exe aioncore +aioncore --version +``` + +当前团队成功标准: + +```text +aioncore 0.1.61 +``` + +并且 `where.exe aioncore` 应能找到类似: + +```text +%USERPROFILE%\.cargo\bin\aioncore.exe +``` + +--- + +# 14. AionCore 找不到时 + +如果: + +```powershell +aioncore --version +``` + +提示无法识别命令,先临时把 Cargo bin 加到当前终端 PATH: + +```powershell +$env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path" + +where.exe aioncore +aioncore --version +``` + +如果此时可以找到,说明主要问题是 PATH。 + +不要立即重装 AionCore。 + +--- + +# 15. aws-lc-sys / NASM failed 专项故障 + +**只有出现 `aws-lc-sys` + `NASM failed` 一类明确错误时才执行本节。** + +先检查: + +```powershell +where.exe nasm +``` + +如果找到了 NASM,但本地汇编失败,可以在当前 PowerShell 临时隐藏该 NASM,并让 `aws-lc-sys` 使用预构建 NASM 对象: + +```powershell +$nasmDir = Split-Path (Get-Command nasm).Source + +$env:Path = (($env:Path -split ';') | + Where-Object { $_.TrimEnd('\') -ine $nasmDir.TrimEnd('\') }) -join ';' + +$env:AWS_LC_SYS_PREBUILT_NASM = "1" + +where.exe nasm +``` + +此时 `where.exe nasm` 应提示找不到。 + +然后: + +```powershell +cd D:\AI-Campus-Workspace\AionCore + +cargo clean +cargo install --path crates/aionui-app --locked +``` + +说明: + +- 这不会卸载 NASM; +- 只是当前 PowerShell 暂时不使用该 NASM; +- 关闭终端后系统 PATH 会恢复。 + +--- + +# 16. AionCore 后续重新安装 + +正常日常启动**不需要每天重新编译 AionCore**。 + +只有 AionCore 源码被团队统一更新后,才需要重新安装。 + +如果包版本没变但源码变了,可以使用: + +```powershell +cd D:\AI-Campus-Workspace\AionCore + +cargo install --path crates/aionui-app --locked --force +``` + +未经队长确认不要自行更新 AionCore 源码。 + +--- + +# 17. 安装 AionUi-Campus 依赖 + +## 17.1 进入团队仓库 + +```powershell +cd D:\AI-Campus-Workspace\AionUi-Campus +``` + +## 17.2 国内网络建议设置 Electron 镜像 + +如果 Electron / GitHub 二进制下载容易断开,可在当前 PowerShell 先设置: + +```powershell +$env:ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/" +``` + +## 17.3 安装依赖 + +```powershell +bun install +``` + +安装尾声可能会涉及: + +- electron-builder; +- @electron/rebuild; +- better-sqlite3; +- 原生依赖处理; +- postinstall。 + +只要终端还没有重新出现: + +```text +PS D:\...> +``` + +就仍然可能处于安装过程中。 + +**不要把“几分钟没输出”直接等同于“卡死”。** + +建议: + +- 短时间无输出:继续等待; +- 5~10 分钟无明显变化:观察 CPU / 磁盘; +- 出现明确红色 error:再进入故障排查; +- 不要反复删除整个 `node_modules`。 + +--- + +# 18. 验证 Electron 是否完整 + +在 AionUi-Campus 目录: + +```powershell +Test-Path ".\node_modules\electron\path.txt" +``` + +成功: + +```text +True +``` + +如果返回: + +```text +False +``` + +说明 Electron npm 包可能已经存在,但真正的 Electron 二进制并未完整下载。 + +--- + +# 19. Electron 下载失败修复 + +只在出现以下情况时执行: + +- `bun install` 最终报 `ECONNRESET`; +- 启动时报 Electron uninstall; +- Electron executable missing; +- `path.txt` 为 False。 + +执行: + +```powershell +cd D:\AI-Campus-Workspace\AionUi-Campus + +$env:ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/" + +Remove-Item "$env:LOCALAPPDATA\electron\Cache" ` + -Recurse -Force -ErrorAction SilentlyContinue + +$env:force_no_cache="true" + +bun install +``` + +重新检查: + +```powershell +Test-Path ".\node_modules\electron\path.txt" +``` + +如果依赖已经装好,但 `path.txt` 仍不存在,再尝试补 Electron 二进制: + +```powershell +Remove-Item ".\node_modules\electron\dist" ` + -Recurse -Force -ErrorAction SilentlyContinue + +node .\node_modules\electron\install.js + +Test-Path ".\node_modules\electron\path.txt" +``` + +最后返回 `True` 后再启动。 + +--- + +# 20. 启动 AionUi-Campus + +## 20.1 启动前检查 AionCore + +```powershell +aioncore --version +``` + +当前应看到: + +```text +aioncore 0.1.61 +``` + +## 20.2 团队统一启动命令 + +进入仓库: + +```powershell +cd D:\AI-Campus-Workspace\AionUi-Campus +``` + +统一执行: + +```powershell +bun run start +``` + +当前仓库中: + +```text +bun run start +bun run dev +``` + +实际都指向同一个 `electron-vite dev` 开发命令。 + +为了成员文档口径统一,团队 RUNBOOK 统一使用: + +```powershell +bun run start +``` + +开发调试时如果负责人明确要求,也可以使用: + +```powershell +bun run dev +``` + +## 20.3 成功标准 + +启动后应满足: + +- Electron / AionUi 桌面窗口出现; +- 页面正常加载; +- 能看到 AionUi 的主要界面; +- 终端没有导致应用直接退出的严重错误; +- AionCore 可被 AionUi 正常使用。 + +运行期间不要关闭启动该应用的 PowerShell。 + +## 20.4 停止 + +优先回到启动终端: + +```text +Ctrl + C +``` + +不要把任务管理器强杀作为日常退出方式。 + +--- + +# 21. 日常再次启动 + +完成首次环境搭建后,日常一般不需要重新安装。 + +执行: + +```powershell +cd D:\AI-Campus-Workspace\AionUi-Campus + +aioncore --version + +git switch develop +git pull + +bun run start +``` + +如果只是验证当前本地代码、不需要同步远端,可省略 `git pull`。 + +--- + +# 22. 模型配置与真实对话验收 + +仅“桌面窗口能打开”还不算环境完全通过。 + +每名成员还需要完成一次**真实模型对话**。 + +团队下一阶段模型方案原则: + +- 优先统一使用 AionUi 已支持的阿里云 DashScope / 通义千问; +- 具体模型名称、参数和团队测试账号由 4 号负责人 + 1 号负责人统一冻结; +- API Key 不写入仓库; +- API Key 不发在公开群截图中; +- 不把 `.env`、密钥、真实用户数据提交到 GitHub。 + +## 22.1 验收方式 + +模型配置完成后,在 AionUi 中发送一条简单消息,例如: + +```text +你好,请用一句话说明你已经成功连接模型。 +``` + +成功标准: + +- 请求真实发出; +- 模型真实返回; +- UI 正常显示回答; +- 无鉴权错误; +- 无模型不存在错误; +- 无持续网络错误。 + +## 22.2 当前状态 + +在团队统一模型名称 / 配置方式完全冻结前,本节只作为验收规则。 + +最终确认后由 4 号负责人补充: + +```text +Provider: +Model: +Endpoint / Region: +必要参数: +配置截图: +脱敏调用证据: +``` + +--- + +# 23. 基础质量检查 + +当前仓库已经提供多项脚本。 + +## 23.1 Lint + +```powershell +bun run lint +``` + +## 23.2 格式检查 + +```powershell +bun run format:check +``` + +## 23.3 单元/常规测试 + +```powershell +bun run test +``` + +## 23.4 覆盖率 + +```powershell +bun run test:coverage +``` + +## 23.5 E2E + +```powershell +bun run test:e2e +``` + +## 23.6 TypeScript 类型检查 + +```powershell +bunx tsc --noEmit +``` + +当前阶段要求每名成员至少知道: + +```powershell +bun run lint +bun run format:check +bun run test +``` + +正式 CI 门禁建立后,再由 5 号负责人更新“哪些检查是 PR 必须通过项”。 + +--- + +# 24. 当前 Git 分支模型 + +长期分支: + +```text +main +develop +``` + +任务分支示例: + +```text +feat/ui-campus +feat/agent-workflow +feat/campus-mcp-rag +feat/integration + +fix/- + +test/evaluation + +docs/runbook +docs/submission + +ci/competition-quality +``` + +含义: + +```text +main +└─ 稳定、可演示、可发布版本 + +develop +└─ 团队日常集成版本 + +feat/fix/docs/test/ci +└─ 个人或单项任务的临时工作分支 +``` + +--- + +# 25. main / develop 当前保护规则 + +当前团队 GitHub Ruleset: + +```text +Protect main Active +Protect develop Active +``` + +核心规则: + +- 禁止删除受保护分支; +- 禁止 Force Push; +- 必须通过 Pull Request; +- 至少 1 个具有 Write 权限的成员 Approve; +- 新提交后旧审批失效; +- 未解决的 Review conversation 会阻止合并。 + +当前暂未把 status checks 设置为 required。 + +5 号负责人建立稳定 CI 后,再补充。 + +--- + +# 26. Merge 策略 + +## 26.1 功能分支 → develop + +使用: + +```text +Squash and merge +``` + +原因: + +成员任务分支里可能有多个调试提交,Squash 后进入 `develop` 时保持历史简洁。 + +## 26.2 develop → main + +使用: + +```text +Merge +``` + +原因: + +保留 `develop` 到 `main` 的整体版本关系,便于比赛版本追踪、回滚和证明开发历史。 + +--- + +# 27. 普通成员第一次开始正式开发 + +环境验收完成、队长通知开始开发后: + +```powershell +cd D:\AI-Campus-Workspace\AionUi-Campus + +git fetch origin +git switch develop +git pull +``` + +然后创建自己的任务分支。 + +例如 2 号 UI: + +```powershell +git switch -c feat/ui-campus +``` + +3 号 Agent: + +```powershell +git switch -c feat/agent-workflow +``` + +4 号 RAG / MCP: + +```powershell +git switch -c feat/campus-mcp-rag +``` + +5 号测试: + +```powershell +git switch -c test/evaluation +``` + +1 号集成: + +```powershell +git switch -c feat/integration +``` + +--- + +# 28. 开发中保存修改 + +查看: + +```powershell +git status +``` + +添加: + +```powershell +git add <文件> +``` + +不要习惯性在不清楚改动内容时直接 `git add .`。 + +提交: + +```powershell +git commit -m "feat(ui): add campus dashboard" +``` + +示例: + +```text +feat(ui): add campus workflow dashboard +feat(agent): add evidence-first assistant rules +feat(mcp): add course evidence retrieval +fix(ipc): validate workspace path +test(eval): add baseline comparison cases +docs(runbook): update environment troubleshooting +``` + +推送个人分支: + +```powershell +git push -u origin <你的分支名> +``` + +--- + +# 29. Pull Request 标准流程 + +任务完成后: + +```text +个人任务分支 + ↓ +Pull Request + ↓ +develop +``` + +创建 PR 时确认: + +```text +base: develop +compare: 你的任务分支 +``` + +不要把普通功能 PR 直接发到 `main`。 + +PR 至少说明: + +```text +1. 本次解决什么问题 +2. 改了什么 +3. 输入 / 输出是否变化 +4. 如何验证 +5. 已运行哪些测试 +6. 有什么风险 +7. 明确不包含什么 +``` + +--- + +# 30. PR 审核 + +Reviewer 打开 PR: + +```text +Files changed +→ Review changes +→ Approve +→ Submit review +``` + +要计入保护规则,Reviewer 必须具有: + +```text +Write +``` + +或更高仓库权限。 + +如果显示: + +```text +Approved ... with read-only permissions +``` + +说明该成员只有 Read 权限,该审批不能满足 required review。 + +普通开发成员仓库角色: + +```text +Write +``` + +队长 / 仓库负责人: + +```text +Admin +``` + +--- + +# 31. PR 合并后本地清理 + +PR 合入 `develop` 后: + +```powershell +git switch develop +git pull +``` + +检查: + +```powershell +git status +``` + +然后删除已完成的本地任务分支: + +```powershell +git branch -d <分支名> +``` + +如果该 PR 使用 Squash merge,Git 有时会提示原任务分支的原始 commit “not yet merged to HEAD”。 + +只要: + +- PR 已显示 Merged; +- 任务内容已进入 `develop`; +- `git pull` 后文件存在; + +就可以删除该临时任务分支。 + +远程任务分支也可以在 GitHub PR 页面点击: + +```text +Delete branch +``` + +长期保留: + +```text +main +develop +``` + +--- + +# 32. 上游同步规则 + +普通成员: + +- 不自行添加和同步 `upstream/main`; +- 不点击 Sync fork 后直接合入比赛分支; +- 不自己决定升级上游。 + +1 号负责人负责: + +1. 获取 upstream 更新; +2. 在临时分支测试; +3. 验证 AionCore / AionUi / 测试; +4. 确认无严重回归后再进入 `develop`; +5. 如基线发生变化,更新 `BASELINE.md` 和 RUNBOOK; +6. 比赛最终冻结期不追求“永远最新”。 + +项目稳定性优先于上游版本新旧。 + +--- + +# 33. API Key 与数据安全 + +禁止提交: + +```text +.env +API Key +AccessKey +Secret +密码 +真实账号 +学号 +手机号 +隐私聊天记录 +未经授权课程资料 +内部教师材料 +原始敏感日志 +``` + +如果开发必须使用配置文件: + +- 提交 `.env.example`; +- 示例值使用占位符; +- 真实 Key 只保存在本地安全位置; +- 截图时脱敏; +- 如果 Key 意外进入 Git 历史,不能只删除文件,必须立刻轮换密钥并处理历史。 + +--- + +# 34. 开源与修改边界 + +AionUi-Campus 基于 AionUi 二次开发。 + +团队必须: + +- 保留上游 LICENSE; +- 明确标注基于 AionUi; +- 不宣称整个底座由团队从零开发; +- 后续建立 / 维护 `MODIFICATIONS.md`; +- 后续建立 / 维护 `THIRD_PARTY_NOTICES.md`; +- 比赛材料中明确区分“上游能力”和“团队原创增量”。 + +AionCore 属于当前谨慎修改区域。 + +没有必要时,不进入底层核心大改。 + +--- + +# 35. 常见故障速查 + +| 现象 | 优先判断 | 推荐处理 | +|---|---|---| +| `bun` 无法识别 | PATH 未刷新 | 重新打开 PowerShell,执行 `bun --version` | +| rustup 有但 rustc 不正常 | 工具链不完整 | 安装 Rust 1.95.0 minimal | +| Rust 下载很慢 / 失败 | 网络问题 | 临时使用 rsproxy | +| Cargo 提示 native build tools | C++ 工具缺失 | 安装 VS C++ Build Tools | +| `aws-lc-sys / NASM failed` | 本地 NASM 汇编问题 | 按本 RUNBOOK 第 15 节处理 | +| `aioncore` 找不到 | Cargo bin 不在 PATH | 临时加入 `%USERPROFILE%\.cargo\bin` | +| `bun install` ECONNRESET | Electron 下载中断 | 设置 `ELECTRON_MIRROR` | +| Electron uninstall / executable missing | 二进制不完整 | 清缓存 / 重装 Electron 二进制 | +| `path.txt` = False | Electron 未完整下载 | 执行 Electron 修复流程 | +| 安装看似卡住 | postinstall / 原生编译 | 先等待并观察,不立即中断 | +| 某成员能跑、某成员不能跑 | 版本/路径不一致 | 对照版本表和基线 | +| push main/develop 被拒绝 | 分支保护生效 | 从任务分支发 PR | +| PR 无法 Merge | 缺 Approval | 找 Write 权限成员审核 | +| Approval 不生效 | Reviewer 只有 Read | 给该成员仓库 Write 权限 | +| 看不到最新文件 | 本地未 fetch/pull | `git fetch origin` + `git switch develop` + `git pull` | + +--- + +# 36. 环境验收清单 + +每名成员必须完成: + +- [ ] `AionUi-Campus` 和 `AionCore` 两个目录存在 +- [ ] `git --version` 正常 +- [ ] `node -v` 在 `>=22` 且 `<25` +- [ ] `bun --version` 正常 +- [ ] `python --version` >= 3.11 +- [ ] 在 AionCore 目录 `rustc --version` 为项目要求版本 +- [ ] `cargo --version` 正常 +- [ ] `where.exe aioncore` 能找到 CLI +- [ ] `aioncore --version` = `0.1.61` +- [ ] `bun install` 最终无 error +- [ ] `Test-Path ".\node_modules\electron\path.txt"` = `True` +- [ ] `bun run start` 能打开 AionUi 桌面端 +- [ ] `git fetch origin` 正常 +- [ ] 能切换到 `develop` +- [ ] `git status` 可正常使用 +- [ ] 完成一次真实模型对话 +- [ ] 能运行至少一次 `bun run lint` +- [ ] 能运行至少一次 `bun run test` +- [ ] 知道 API Key 不能提交到 Git +- [ ] 能解释 `main / develop / task branch` 的区别 + +--- + +# 37. 成员提交给队长的统一回执 + +```text +【AionUi-Campus 成员环境验收】 + +姓名: +岗位编号: +电脑系统:Windows 10 / Windows 11 + +Git: +Node: +Bun: +Python: +Rust(AionCore目录): +Cargo: +AionCore: + +AionUi-Campus 当前分支: +AionUi-Campus 是否成功启动:是 / 否 +Electron path.txt:True / False +真实模型对话:通过 / 未通过 + +bun run lint:通过 / 未运行 / 失败 +bun run test:通过 / 未运行 / 失败 + +启动截图:已附 / 未附 + +当前报错或阻塞: +无 / 具体描述 + +处理过的特殊问题: +无 / NASM / Electron / PATH / Rust / 其他 +``` + +后续由 5 号负责人统一整理到: + +```text +RUN_LOG.md +``` + +--- + +# 38. 问题上报格式 + +成员遇到问题时不要只发一句: + +```text +“报错了” +``` + +统一提供: + +```text +【环境问题】 + +成员: +当前步骤: +当前目录: +当前分支: + +执行命令: +<完整命令> + +完整报错: +<复制文本或清晰截图> + +版本: +git: +node: +bun: +python: +rustc: +cargo: +aioncore: + +已经尝试: +1. +2. + +当前是否影响其他成员: +是 / 否 / 不确定 +``` + +这样队长和对应负责人才能快速定位。 + +--- + +# 39. 队长环境阶段检查表 + +1 号负责人需要持续确认: + +- [x] 团队仓库 `origin` 已建立 +- [x] 官方 AionUi `upstream` 已确认 +- [x] `baseline-20260807` 已建立并 push +- [x] `develop` 已建立 +- [x] `main` Ruleset 已启用 +- [x] `develop` Ruleset 已启用 +- [x] `BASELINE.md` 已进入 `develop` +- [x] AionCore 版本 / commit 已记录 +- [ ] `RUNBOOK.md` 已进入 `develop` +- [ ] `RUN_LOG.md` 已建立 +- [ ] 5 名成员环境全部验收 +- [ ] 团队统一模型方案已冻结 +- [ ] 5 名成员真实模型对话全部通过 +- [ ] CI / required status checks 已由 5 号负责人建立 +- [ ] `MODIFICATIONS.md` 已建立 +- [ ] `THIRD_PARTY_NOTICES.md` 已建立 + +--- + +# 40. 当前仓库已确认的常用脚本 + +桌面开发: + +```powershell +bun run start +bun run dev +``` + +当前两者等价。 + +多实例: + +```powershell +bun run start:multi +``` + +WebUI: + +```powershell +bun run webui +``` + +构建: + +```powershell +bun run package +bun run build-win +``` + +代码质量: + +```powershell +bun run lint +bun run lint:fix +bun run format +bun run format:check +``` + +测试: + +```powershell +bun run test +bun run test:coverage +bun run test:integration +bun run test:e2e +``` + +调试: + +```powershell +bun run debug:mcp +bun run debug:mcp:list +bun run debug:mcp:validate +``` + +成员不要因为看到脚本很多就全部运行。 + +当前环境阶段核心只有: + +```powershell +bun install +aioncore --version +bun run start +bun run lint +bun run test +``` + +--- + +# 41. 比赛交付关联 + +赛题要求最终包含: + +- 模型 / 算法说明; +- 可运行代码及依赖环境说明; +- 验证集评测结果; +- 可选 Demo / 交互界面; +- 实际使用阿里云产品或平台。 + +因此本 RUNBOOK 不是临时笔记。 + +后续需要持续维护,使其能够成为: + +```text +团队内部运行手册 + ↓ +干净电脑复现依据 + ↓ +比赛依赖环境说明底稿 + ↓ +答辩时的工程可复现证据 +``` + +最终候选版本发布前,必须至少在一台“非队长日常开发机”上按照本 RUNBOOK 从零复现一次。 + +--- + +# 42. 文档维护规则 + +任何影响以下内容的 PR,都要考虑是否同步修改 RUNBOOK: + +- Node / Bun / Rust 版本; +- AionCore 版本; +- 启动命令; +- 安装命令; +- 环境变量; +- 模型配置; +- Git 工作流; +- CI / 测试门禁; +- Windows 特殊故障处理; +- 打包方式; +- 最终演示启动方式。 + +RUNBOOK 不能写“某个人电脑上特殊的隐藏步骤”。 + +如果某个步骤只有一个人知道,就说明该步骤还没有完成团队化。 + +--- + +# 43. 当前下一阶段 + +当 5 名成员都完成本 RUNBOOK 的基础环境验收后,进入: + +```text +统一 develop + ↓ +统一模型配置 + ↓ +5 人真实对话验收 + ↓ +创建个人任务分支 + ↓ +第一轮功能开发 +``` + +在环境验收完成前,不提前进入大规模功能开发。 + +--- + +# 附录 A:最快日常启动 + +已经完成全部安装的成员: + +```powershell +cd D:\AI-Campus-Workspace\AionUi-Campus + +git switch develop +git pull + +aioncore --version + +bun run start +``` + +--- + +# 附录 B:最快环境自检 + +```powershell +git --version +node -v +bun --version +python --version + +cd D:\AI-Campus-Workspace\AionCore +rustc --version +cargo --version +aioncore --version + +cd D:\AI-Campus-Workspace\AionUi-Campus +Test-Path ".\node_modules\electron\path.txt" +git branch --show-current +git status +``` + +--- + +# 附录 C:资料依据 + +本 RUNBOOK 基于以下团队材料和已验证结果整理: + +1. `BASELINE.md` +2. 《AionUi-Campus 团队统一环境搭建手册(Windows版)V1.0》 +3. 《基于 AionUi 的 AI 校园项目方向与五人认领式分工方案》 +4. 当前 AionUi-Campus `package.json` 脚本列表 +5. 团队 2026-08-06 至 2026-08-07 的实际 Windows 安装与故障排查过程 + +**维护原则:以团队锁定 commit 和实际成功复现结果为准,不以“网上最新教程”自动覆盖当前比赛基线。** From 558c2802d396176a0be1070de581360933a83961 Mon Sep 17 00:00:00 2001 From: Mini-happ <1521801957@qq.com> Date: Mon, 10 Aug 2026 22:54:38 +0800 Subject: [PATCH 03/10] docs: add project run log (#3) --- RUN_LOG.md | 494 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 RUN_LOG.md diff --git a/RUN_LOG.md b/RUN_LOG.md new file mode 100644 index 00000000000..0d9a9a162a9 --- /dev/null +++ b/RUN_LOG.md @@ -0,0 +1,494 @@ +\# AionUi-Campus 运行与集成记录(RUN\_LOG) + + + +\*\*项目:\*\* 2026 年第二届重庆市 AI 大模型创新应用大赛 · “创意 AI 校园” + +\*\*仓库:\*\* `ai-campus-2026/AionUi-Campus` + +\*\*建立日期:\*\* 2026-08-10 + +\*\*维护负责人:\*\* 1 号负责人 / 队长 + + + +\--- + + + +\## 1. 文档用途 + + + +本文件用于记录 AionUi-Campus 项目的实际运行、验证、联调和阶段性验收情况。 + + + +本文件只记录“实际完成和实际验证”的结果,不替代环境安装说明。 + + + +环境搭建方法见: + + + +`RUNBOOK.md` + + + +项目版本基线见: + + + +`BASELINE.md` + + + +\--- + + + +\## 2. 当前整体状态 + + + +| 项目 | 当前状态 | 备注 | + +|---|---|---| + +| 五人基础环境安装 | ✅ 已完成 | 五人按照统一方案完成安装 | + +| AionCore 环境 | ✅ 已完成 | 当前团队统一版本为 0.1.61 | + +| Git 团队仓库 | ✅ 已完成 | `ai-campus-2026/AionUi-Campus` | + +| main 分支 | ✅ 已建立 | 稳定分支 | + +| develop 分支 | ✅ 已建立 | 日常集成分支 | + +| main 分支保护 | ✅ 已启用 | PR + Review | + +| develop 分支保护 | ✅ 已启用 | PR + Review | + +| BASELINE.md | ✅ 已完成 | 已进入 develop | + +| RUNBOOK.md | ✅ 已完成 | 已进入 develop | + +| 五人 Desktop 启动验收 | ⏳ 待统一确认 | | + +| 五人 Git / develop 验收 | ⏳ 待统一确认 | | + +| 阿里云模型统一配置 | ⏳ 待进行 | | + +| 五人真实模型对话 | ⏳ 待进行 | | + +| Agent 基础链路 | ⏳ 待开发 | | + +| RAG 基础链路 | ⏳ 待开发 | | + +| Agent + RAG 联调 | ⏳ 待开发 | | + +| 自动化测试 / CI | ⏳ 待建立 | | + + + +\--- + + + +\## 3. 五人成员验收记录 + + + +| 成员 | 负责方向 | 环境安装 | Desktop 启动 | Git / develop | 真实模型对话 | lint / test | 当前状态 | + +|---|---|---|---|---|---|---|---| + +| 1号 | 产品 / 架构 / 集成 / Git | ✅ | ✅ | ✅ | ⏳ | ⏳ | 进行中 | + +| 2号 | Desktop UI / 交互 | ✅ | ⏳ | ⏳ | ⏳ | ⏳ | 待验收 | + +| 3号 | Agent / Assistant / Skills | ✅ | ⏳ | ⏳ | ⏳ | ⏳ | 待验收 | + +| 4号 | RAG / MCP / 数据 / 阿里云 | ✅ | ⏳ | ⏳ | ⏳ | ⏳ | 待验收 | + +| 5号 | 测试 / 评测 / DevOps / 材料 | ✅ | ⏳ | ⏳ | ⏳ | ⏳ | 待验收 | + + + +\### 状态说明 + + + +\- ✅:已经实际验证通过 + +\- ⏳:尚未统一验证 + +\- ❌:验证失败,需要处理 + + + +环境安装已经由团队统一完成,因此不重复记录 Node、Bun、Rust、Cargo 等详细版本。 + + + +详细版本统一以 `BASELINE.md` 和 `RUNBOOK.md` 为准。 + + + +\--- + + + +\## 4. 项目关键节点 + + + +\### 2026-08-07 + + + +完成: + + + +\- AionUi-Campus 团队仓库建立 + +\- `origin` / `upstream` 配置 + +\- `main` 分支建立 + +\- `develop` 分支建立 + +\- `baseline-20260807` 建立 + +\- `main` Ruleset 建立 + +\- `develop` Ruleset 建立 + +\- `BASELINE.md` 建立 + +\- 团队基础环境搭建 + + + +\### 2026-08-10 + + + +完成: + + + +\- `RUNBOOK.md` 建立 + +\- `RUNBOOK.md` 通过 Pull Request 合入 `develop` + +\- 五人基础环境安装完成 + + + +下一阶段: + + + +\- Desktop 统一验收 + +\- Git / develop 统一验收 + +\- 阿里云模型统一配置 + +\- 真实模型对话验收 + + + +\--- + + + +\## 5. 模型连通验收 + + + +\### 5.1 统一模型配置 + + + +当前状态: + + + +`待配置` + + + +后续统一填写: + + + +| 项目 | 配置 | + +|---|---| + +| Provider | 待确定 | + +| 模型 | 待确定 | + +| 阿里云平台 | DashScope / 待最终确认 | + +| API Key 管理方式 | 本地安全配置,不提交 Git | + +| 验收负责人 | 1号 + 4号 | + + + +\--- + + + +\### 5.2 五人真实模型对话验收 + + + +| 成员 | 模型配置 | 请求成功 | 正常返回 | UI 显示正常 | 验收日期 | 备注 | + +|---|---|---|---|---|---|---| + +| 1号 | ⏳ | ⏳ | ⏳ | ⏳ | | | + +| 2号 | ⏳ | ⏳ | ⏳ | ⏳ | | | + +| 3号 | ⏳ | ⏳ | ⏳ | ⏳ | | | + +| 4号 | ⏳ | ⏳ | ⏳ | ⏳ | | | + +| 5号 | ⏳ | ⏳ | ⏳ | ⏳ | | | + + + +成功标准: + + + +1\. 模型请求真实发出; + +2\. 不出现 API Key / 鉴权错误; + +3\. 模型正常返回内容; + +4\. AionUi 页面正常显示回答; + +5\. 不存在持续性网络或模型配置错误。 + + + +\--- + + + +\## 6. Agent 开发与验证记录 + + + +当前状态: + + + +`待开发` + + + +后续记录: + + + +| 日期 | 分支 / Commit | 功能 | 验证结果 | 负责人 | 备注 | + +|---|---|---|---|---|---| + +| | | | | 3号 | | + + + +主要验收内容: + + + +\- Agent 能否正常启动 + +\- Assistant 是否正常工作 + +\- Skills 是否能够正常加载 + +\- Agent 是否能够调用模型 + +\- 工具调用是否正常 + +\- 异常情况下是否能够返回有效错误信息 + + + +\--- + + + +\## 7. RAG / MCP 开发与验证记录 + + + +当前状态: + + + +`待开发` + + + +| 日期 | 分支 / Commit | 功能 | 验证结果 | 负责人 | 备注 | + +|---|---|---|---|---|---| + +| | | | | 4号 | | + + + +主要验收内容: + + + +\- 数据能否导入 + +\- 文档能否建立索引 + +\- 检索是否正常 + +\- 是否能够返回相关证据 + +\- MCP 是否能够正常连接 + +\- 是否存在空检索 + +\- 是否存在明显错误召回 + + + +\--- + + + +\## 8. Agent + RAG 联调记录 + + + +当前状态: + + + +`待开发` + + + +| 日期 | Agent版本 | RAG版本 | 联调结果 | 问题 | 负责人 | + +|---|---|---|---|---|---| + +| | | | | | 1号 / 3号 / 4号 | + + + +重点验证: + + + +1\. 用户问题进入 Agent; + +2\. Agent 判断是否需要检索; + +3\. Agent 调用 RAG / MCP; + +4\. RAG 返回相关证据; + +5\. Agent 获得证据; + +6\. 模型结合证据生成回答; + +7\. UI 正常展示结果; + +8\. 异常情况能够正常处理。 + + + +\--- + + + +\## 9. 测试与质量记录 + + + +当前仓库主要检查命令: + + + +```powershell + +bun run lint + +bun run format:check + +bun run test + +bun run test:coverage + +bun run test:e2e + + + +问题记录模板: + + + +问题编号: + + + +日期: + +发现成员: + +所属模块: + + + +问题描述: + + + +复现步骤: + + + +报错信息: + + + +影响范围: + + + +原因: + + + +解决方案: + + + +解决 Commit / PR: + + + +当前状态: + +待处理 / 已解决 + From b8008d49d0354453116d934b4bfc6fa758620c23 Mon Sep 17 00:00:00 2001 From: Mini-happ <1521801957@qq.com> Date: Tue, 8 Sep 2026 17:05:11 +0800 Subject: [PATCH 04/10] style(docs): format project markdown files (#10) --- BASELINE.md | 43 +--------------- RUNBOOK.md | 58 ++++++++++----------- RUN_LOG.md | 145 +++------------------------------------------------- 3 files changed, 36 insertions(+), 210 deletions(-) diff --git a/BASELINE.md b/BASELINE.md index 54cfcc6e4a2..d6c23607a02 100644 --- a/BASELINE.md +++ b/BASELINE.md @@ -1,43 +1,25 @@ \# AionUi-Campus Baseline - - \## 1. 基线信息 - - \- 基线日期:2026-08-07 \- 基线标签:`baseline-20260807` \- 基线 Commit:`f98d9f719de9c639f739470ea49f01a6b0a22687` - - \## 2. Git 仓库 - - \### 团队仓库 - - \- Origin: `https://github.com/ai-campus-2026/AionUi-Campus.git` - - \### AionUi 上游仓库 - - \- Upstream: `https://github.com/iOfficeAI/AionUi.git` - - \## 3. 分支策略 - - \- `main`:稳定、可演示、可发布版本 \- `develop`:团队日常集成分支 @@ -50,12 +32,8 @@ \- `test/\*`:测试与评测分支 - - \### 分支保护 - - \- \[x] `main` 已启用 GitHub Ruleset \- \[x] `develop` 已启用 GitHub Ruleset @@ -68,16 +46,10 @@ \- \[x] PR 至少需要 1 个 Approval - - \## 4. 当前验证状态 - - \### AionUi-Campus - - \- \[x] Git 仓库正常 \- \[x] 依赖环境已完成安装 @@ -88,12 +60,8 @@ \- \[ ] 团队五人环境全部验收 - - \### AionCore - - ### AionCore - CLI 版本:`aioncore 0.1.61` @@ -103,23 +71,14 @@ - 工作区状态:Clean - 管理方式:作为 AionUi-Campus 的上游/底层依赖使用,不建立团队 develop 分支,不向官方仓库推送比赛代码。 - \## 5. 当前版本关系 - - `baseline-20260807`、`main` 与 `develop` 均从以下 Commit 开始: - - `f98d9f719de9c639f739470ea49f01a6b0a22687` - - \## 6. 后续要求 - - 1\. 团队成员不得直接向 `main` 或 `develop` 推送比赛功能代码。 2\. 所有开发从最新 `develop` 创建独立分支。 @@ -132,4 +91,4 @@ 6\. 上游 AionUi 更新只由 1 号负责人统一处理。 -> 注意:AionCore 当前锁定为上述版本。比赛开发期间如非确有必要,不随意升级 AionCore;如需升级,必须先在独立分支验证 AionUi-Campus 能正常启动和运行。 \ No newline at end of file +> 注意:AionCore 当前锁定为上述版本。比赛开发期间如非确有必要,不随意升级 AionCore;如需升级,必须先在独立分支验证 AionUi-Campus 能正常启动和运行。 diff --git a/RUNBOOK.md b/RUNBOOK.md index 8199321241f..babeff699e5 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -5,7 +5,7 @@ **适用系统:** Windows 10 / 11 + PowerShell **文档版本:** V1.0 **基线日期:** 2026-08-07 -**维护人:** 1 号负责人 / 队长 +**维护人:** 1 号负责人 / 队长 --- @@ -258,17 +258,17 @@ cargo --version # 7. 推荐/要求版本 -| 组件 | 团队要求 / 建议 | 当前已验证情况 | -|---|---|---| -| Windows | Windows 10 / 11 x64 | Windows 环境已验证 | -| Git | 可正常 clone / fetch / pull / push | 必需 | -| Node.js | `>=22` 且 `<25` | 队长已验证 24.14.0 | -| Bun | 稳定版 | 队长已验证 1.3.14 | -| Python | 3.11+ | 队长已验证 3.12.5 | -| Rust | AionCore 项目锁定 1.95.0 | 必须按项目工具链 | -| Cargo | 随 Rust 工具链 | 必需 | -| AionCore | `0.1.61` | 当前团队锁定 | -| Electron | 跟随仓库 lockfile/package | 不单独追最新版 | +| 组件 | 团队要求 / 建议 | 当前已验证情况 | +| -------- | ---------------------------------- | ------------------ | +| Windows | Windows 10 / 11 x64 | Windows 环境已验证 | +| Git | 可正常 clone / fetch / pull / push | 必需 | +| Node.js | `>=22` 且 `<25` | 队长已验证 24.14.0 | +| Bun | 稳定版 | 队长已验证 1.3.14 | +| Python | 3.11+ | 队长已验证 3.12.5 | +| Rust | AionCore 项目锁定 1.95.0 | 必须按项目工具链 | +| Cargo | 随 Rust 工具链 | 必需 | +| AionCore | `0.1.61` | 当前团队锁定 | +| Electron | 跟随仓库 lockfile/package | 不单独追最新版 | **原则:符合要求就不要为了“更新”而更新。** @@ -1236,23 +1236,23 @@ AionCore 属于当前谨慎修改区域。 # 35. 常见故障速查 -| 现象 | 优先判断 | 推荐处理 | -|---|---|---| -| `bun` 无法识别 | PATH 未刷新 | 重新打开 PowerShell,执行 `bun --version` | -| rustup 有但 rustc 不正常 | 工具链不完整 | 安装 Rust 1.95.0 minimal | -| Rust 下载很慢 / 失败 | 网络问题 | 临时使用 rsproxy | -| Cargo 提示 native build tools | C++ 工具缺失 | 安装 VS C++ Build Tools | -| `aws-lc-sys / NASM failed` | 本地 NASM 汇编问题 | 按本 RUNBOOK 第 15 节处理 | -| `aioncore` 找不到 | Cargo bin 不在 PATH | 临时加入 `%USERPROFILE%\.cargo\bin` | -| `bun install` ECONNRESET | Electron 下载中断 | 设置 `ELECTRON_MIRROR` | -| Electron uninstall / executable missing | 二进制不完整 | 清缓存 / 重装 Electron 二进制 | -| `path.txt` = False | Electron 未完整下载 | 执行 Electron 修复流程 | -| 安装看似卡住 | postinstall / 原生编译 | 先等待并观察,不立即中断 | -| 某成员能跑、某成员不能跑 | 版本/路径不一致 | 对照版本表和基线 | -| push main/develop 被拒绝 | 分支保护生效 | 从任务分支发 PR | -| PR 无法 Merge | 缺 Approval | 找 Write 权限成员审核 | -| Approval 不生效 | Reviewer 只有 Read | 给该成员仓库 Write 权限 | -| 看不到最新文件 | 本地未 fetch/pull | `git fetch origin` + `git switch develop` + `git pull` | +| 现象 | 优先判断 | 推荐处理 | +| --------------------------------------- | ---------------------- | ------------------------------------------------------ | +| `bun` 无法识别 | PATH 未刷新 | 重新打开 PowerShell,执行 `bun --version` | +| rustup 有但 rustc 不正常 | 工具链不完整 | 安装 Rust 1.95.0 minimal | +| Rust 下载很慢 / 失败 | 网络问题 | 临时使用 rsproxy | +| Cargo 提示 native build tools | C++ 工具缺失 | 安装 VS C++ Build Tools | +| `aws-lc-sys / NASM failed` | 本地 NASM 汇编问题 | 按本 RUNBOOK 第 15 节处理 | +| `aioncore` 找不到 | Cargo bin 不在 PATH | 临时加入 `%USERPROFILE%\.cargo\bin` | +| `bun install` ECONNRESET | Electron 下载中断 | 设置 `ELECTRON_MIRROR` | +| Electron uninstall / executable missing | 二进制不完整 | 清缓存 / 重装 Electron 二进制 | +| `path.txt` = False | Electron 未完整下载 | 执行 Electron 修复流程 | +| 安装看似卡住 | postinstall / 原生编译 | 先等待并观察,不立即中断 | +| 某成员能跑、某成员不能跑 | 版本/路径不一致 | 对照版本表和基线 | +| push main/develop 被拒绝 | 分支保护生效 | 从任务分支发 PR | +| PR 无法 Merge | 缺 Approval | 找 Write 权限成员审核 | +| Approval 不生效 | Reviewer 只有 Read | 给该成员仓库 Write 权限 | +| 看不到最新文件 | 本地未 fetch/pull | `git fetch origin` + `git switch develop` + `git pull` | --- diff --git a/RUN_LOG.md b/RUN_LOG.md index 0d9a9a162a9..bfa726b1188 100644 --- a/RUN_LOG.md +++ b/RUN_LOG.md @@ -1,57 +1,33 @@ -\# AionUi-Campus 运行与集成记录(RUN\_LOG) +\# AionUi-Campus 运行与集成记录(RUN_LOG) +\*\*项目:\*\* 2026 年第二届重庆市 AI 大模型创新应用大赛 · “创意 AI 校园” +\*\*仓库:\*\* `ai-campus-2026/AionUi-Campus` -\*\*项目:\*\* 2026 年第二届重庆市 AI 大模型创新应用大赛 · “创意 AI 校园” - -\*\*仓库:\*\* `ai-campus-2026/AionUi-Campus` - -\*\*建立日期:\*\* 2026-08-10 - -\*\*维护负责人:\*\* 1 号负责人 / 队长 - +\*\*建立日期:\*\* 2026-08-10 +\*\*维护负责人:\*\* 1 号负责人 / 队长 \--- - - \## 1. 文档用途 - - 本文件用于记录 AionUi-Campus 项目的实际运行、验证、联调和阶段性验收情况。 - - 本文件只记录“实际完成和实际验证”的结果,不替代环境安装说明。 - - 环境搭建方法见: - - `RUNBOOK.md` - - 项目版本基线见: - - `BASELINE.md` - - \--- - - \## 2. 当前整体状态 - - | 项目 | 当前状态 | 备注 | |---|---|---| @@ -90,16 +66,10 @@ | 自动化测试 / CI | ⏳ 待建立 | | - - \--- - - \## 3. 五人成员验收记录 - - | 成员 | 负责方向 | 环境安装 | Desktop 启动 | Git / develop | 真实模型对话 | lint / test | 当前状态 | |---|---|---|---|---|---|---|---| @@ -114,44 +84,26 @@ | 5号 | 测试 / 评测 / DevOps / 材料 | ✅ | ⏳ | ⏳ | ⏳ | ⏳ | 待验收 | - - \### 状态说明 - - \- ✅:已经实际验证通过 \- ⏳:尚未统一验证 \- ❌:验证失败,需要处理 - - 环境安装已经由团队统一完成,因此不重复记录 Node、Bun、Rust、Cargo 等详细版本。 - - 详细版本统一以 `BASELINE.md` 和 `RUNBOOK.md` 为准。 - - \--- - - \## 4. 项目关键节点 - - \### 2026-08-07 - - 完成: - - \- AionUi-Campus 团队仓库建立 \- `origin` / `upstream` 配置 @@ -170,28 +122,18 @@ \- 团队基础环境搭建 - - \### 2026-08-10 - - 完成: - - \- `RUNBOOK.md` 建立 \- `RUNBOOK.md` 通过 Pull Request 合入 `develop` \- 五人基础环境安装完成 - - 下一阶段: - - \- Desktop 统一验收 \- Git / develop 统一验收 @@ -200,32 +142,18 @@ \- 真实模型对话验收 - - \--- - - \## 5. 模型连通验收 - - \### 5.1 统一模型配置 - - 当前状态: - - `待配置` - - 后续统一填写: - - | 项目 | 配置 | |---|---| @@ -240,16 +168,10 @@ | 验收负责人 | 1号 + 4号 | - - \--- - - \### 5.2 五人真实模型对话验收 - - | 成员 | 模型配置 | 请求成功 | 正常返回 | UI 显示正常 | 验收日期 | 备注 | |---|---|---|---|---|---|---| @@ -264,12 +186,8 @@ | 5号 | ⏳ | ⏳ | ⏳ | ⏳ | | | - - 成功标准: - - 1\. 模型请求真实发出; 2\. 不出现 API Key / 鉴权错误; @@ -280,40 +198,24 @@ 5\. 不存在持续性网络或模型配置错误。 - - \--- - - \## 6. Agent 开发与验证记录 - - 当前状态: - - `待开发` - - 后续记录: - - | 日期 | 分支 / Commit | 功能 | 验证结果 | 负责人 | 备注 | |---|---|---|---|---|---| | | | | | 3号 | | - - 主要验收内容: - - \- Agent 能否正常启动 \- Assistant 是否正常工作 @@ -326,36 +228,22 @@ \- 异常情况下是否能够返回有效错误信息 - - \--- - - \## 7. RAG / MCP 开发与验证记录 - - 当前状态: - - `待开发` - - | 日期 | 分支 / Commit | 功能 | 验证结果 | 负责人 | 备注 | |---|---|---|---|---|---| | | | | | 4号 | | - - 主要验收内容: - - \- 数据能否导入 \- 文档能否建立索引 @@ -370,36 +258,22 @@ \- 是否存在明显错误召回 - - \--- - - \## 8. Agent + RAG 联调记录 - - 当前状态: - - `待开发` - - | 日期 | Agent版本 | RAG版本 | 联调结果 | 问题 | 负责人 | |---|---|---|---|---|---| | | | | | | 1号 / 3号 / 4号 | - - 重点验证: - - 1\. 用户问题进入 Agent; 2\. Agent 判断是否需要检索; @@ -416,20 +290,12 @@ 8\. 异常情况能够正常处理。 - - \--- - - \## 9. 测试与质量记录 - - 当前仓库主要检查命令: - - ```powershell bun run lint @@ -492,3 +358,4 @@ bun run test:e2e 待处理 / 已解决 +``` From cf76b2f60af448061aaa331845e4423082b0b923 Mon Sep 17 00:00:00 2001 From: YimiSuper Date: Tue, 8 Sep 2026 22:13:59 +0800 Subject: [PATCH 05/10] feat: add rag-mcp-server and policy-search modules (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已经经过初步检查,没有问题,并将其合并到develop分支中 --- policy-search/.env.example | 6 + policy-search/.gitignore | 6 + policy-search/README.md | 174 ++++++ policy-search/config.py | 71 +++ policy-search/llm_client.py | 188 +++++++ policy-search/policy_matcher.py | 361 +++++++++++++ policy-search/policy_parser.py | 669 ++++++++++++++++++++++++ policy-search/policy_store.py | 215 ++++++++ policy-search/requirements.txt | 6 + policy-search/server.py | 480 +++++++++++++++++ rag-mcp-server/.env.example | 24 + rag-mcp-server/.gitignore | 5 + rag-mcp-server/README.md | 191 +++++++ rag-mcp-server/config.py | 36 ++ rag-mcp-server/conftest.py | 1 + rag-mcp-server/rag_engine.py | 383 ++++++++++++++ rag-mcp-server/requirements.txt | 8 + rag-mcp-server/server.py | 129 +++++ rag-mcp-server/tests/test_rag_engine.py | 236 +++++++++ 19 files changed, 3189 insertions(+) create mode 100644 policy-search/.env.example create mode 100644 policy-search/.gitignore create mode 100644 policy-search/README.md create mode 100644 policy-search/config.py create mode 100644 policy-search/llm_client.py create mode 100644 policy-search/policy_matcher.py create mode 100644 policy-search/policy_parser.py create mode 100644 policy-search/policy_store.py create mode 100644 policy-search/requirements.txt create mode 100644 policy-search/server.py create mode 100644 rag-mcp-server/.env.example create mode 100644 rag-mcp-server/.gitignore create mode 100644 rag-mcp-server/README.md create mode 100644 rag-mcp-server/config.py create mode 100644 rag-mcp-server/conftest.py create mode 100644 rag-mcp-server/rag_engine.py create mode 100644 rag-mcp-server/requirements.txt create mode 100644 rag-mcp-server/server.py create mode 100644 rag-mcp-server/tests/test_rag_engine.py diff --git a/policy-search/.env.example b/policy-search/.env.example new file mode 100644 index 00000000000..8d967ff40ff --- /dev/null +++ b/policy-search/.env.example @@ -0,0 +1,6 @@ +DASHSCOPE_API_KEY=your_api_key_here +LLM_MODEL=qwen3.7-plus +EMBEDDING_MODEL=text-embedding-v4 +KNOWLEDGE_BASE_DIR=./knowledge_base +CHUNK_SIZE=2000 +CHUNK_OVERLAP=200 diff --git a/policy-search/.gitignore b/policy-search/.gitignore new file mode 100644 index 00000000000..344ec17b8fa --- /dev/null +++ b/policy-search/.gitignore @@ -0,0 +1,6 @@ +.env +knowledge_base/ +__pycache__/ +*.pyc +*.pyo +chroma_data/ diff --git a/policy-search/README.md b/policy-search/README.md new file mode 100644 index 00000000000..5bb950d3d36 --- /dev/null +++ b/policy-search/README.md @@ -0,0 +1,174 @@ +# Policy Search MCP Server + +高校政策查询系统 — 通过 MCP 协议为 AionUi Agent 提供政策文档的结构化解析、存储和智能匹配能力。 + +## 功能概述 + +| 工具 | 功能 | +| ---------------------- | -------------------------------------------------------- | +| `load_policy_document` | 加载 PDF/文本政策文档,LLM 自动提取元数据和结构化条件 | +| `query_policy` | 根据用户个人信息逐条匹配政策条件,返回匹配结果和原文引用 | +| `list_policies` | 列出知识库中的政策文件,支持按学校/分类/年份筛选 | +| `clear_knowledge_base` | 清空知识库 | + +## 架构 + +``` +AionUi Agent + │ MCP 协议 (stdio) + ▼ +server.py ── 4 个 MCP 工具 + │ + ├── policy_parser.py ── PDF 读取 → LLM 提取元数据 → 分块提取条件 + ├── policy_store.py ── index.json 索引 + 分类目录存储 + ├── policy_matcher.py ── 用户信息 vs 政策条件逐条匹配 + └── llm_client.py ── DashScope 通义千问 API 封装 +``` + +## 知识库存储结构 + +``` +knowledge_base/ +├── index.json # 分类索引 +├── postgraduate_recommendation/ # 保研/推免 +│ └── {doc_id}.json # 政策详情(结构化 JSON) +├── scholarship/ # 奖学金 +│ └── {doc_id}.json +├── financial_aid/ # 助学金 +│ └── {doc_id}.json +└── other/ # 其他 + └── {doc_id}.json +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +cd d:\AI-Campus-Workspace\policy-search +pip install -r requirements.txt +``` + +### 2. 配置环境变量 + +复制 `.env.example` 为 `.env`,填入你的 DashScope API Key: + +```bash +DASHSCOPE_API_KEY=sk-your-api-key-here +LLM_MODEL=qwen3.7-plus +EMBEDDING_MODEL=text-embedding-v4 +``` + +### 3. 启动测试 + +```bash +python server.py +``` + +如果看到 `[PolicySearch MCP Server] 启动中...` 且无报错,说明启动成功。 + +### 4. 在 AionUi 中导入 + +打开 **设置 → 工具 → MCP 管理**,导入以下 JSON: + +```json +{ + "mcpServers": { + "policy_search": { + "command": "python", + "args": ["d:/AI-Campus-Workspace/policy-search/server.py"], + "env": { + "DASHSCOPE_API_KEY": "sk-your-api-key-here" + } + } + } +} +``` + +## 使用示例 + +### 加载政策文档 + +在 AionUi 对话中: + +``` +请加载这个政策文档:d:/documents/北京大学2025年推免实施办法.pdf +``` + +Agent 会调用 `load_policy_document`,自动完成: + +1. 读取 PDF 文本 +2. LLM 提取元数据(学校、年份、标题、分类) +3. 分块后 LLM 提取所有条件(GPA、论文、竞赛等) +4. 结构化存储到知识库 + +### 查询匹配政策 + +``` +我的信息:学校北京大学,GPA 3.7,排名前15%,CET-6 580分, +有1篇SCI一作论文,获得过国家级竞赛一等奖。 +帮我看看符合哪些保研政策? +``` + +Agent 会调用 `query_policy`,返回: + +- 每个政策的总体判定(`likely_eligible` / `not_eligible` 等) +- 每个条件的逐条对比结果 +- 原文引用(`source_quote`) + +### 列出政策 + +``` +知识库中有哪些保研相关的政策? +``` + +### 清空知识库 + +``` +清空知识库 +``` + +## 条件类型说明 + +| 类型 | 含义 | 匹配方式 | +| ------------- | ------------ | ------------------- | +| `hard` | 硬性门槛 | 自动数值比较 | +| `scoring` | 评分项 | 自动计算得分 | +| `ranking` | 排名项 | 自动百分比比较 | +| `bonus` | 加分项 | 部分自动 + 人工核实 | +| `preference` | 优先条件 | 需人工评估 | +| `procedural` | 流程性要求 | 需用户自行确认 | +| `qualitative` | 模糊定性条件 | 需人工审核 | + +## 用户信息字段 + +| 字段 | 类型 | 说明 | +| ------------------ | ------- | ----------------------------------- | +| `school` | string | 学校名称 | +| `year` | integer | 年份 | +| `gpa` | number | GPA 绩点 | +| `gpa_rank_percent` | number | 排名百分比(如 12.5 = 前12.5%) | +| `english.cet4` | number | CET-4 分数 | +| `english.cet6` | number | CET-6 分数 | +| `papers[]` | array | 论文列表(type/author_order/count) | +| `competitions[]` | array | 竞赛列表(level/award) | +| `extra` | object | 自定义扩展字段 | + +## 项目结构 + +``` +policy-search/ +├── server.py # MCP Server 入口 +├── policy_parser.py # 政策文档解析(LLM 提取结构化信息) +├── policy_store.py # 知识库管理(index.json + 详情文件) +├── policy_matcher.py # 条件匹配逻辑 +├── llm_client.py # LLM 客户端 +├── config.py # 配置管理 +── requirements.txt # 依赖 +├── .env.example # 环境变量模板 +├── README.md # 本文档 +── examples/ # 示例数据 +│ ├── postgraduate_recommendation_example.json +│ └── scholarship_example.json +└── knowledge_base/ # 运行时创建 +``` diff --git a/policy-search/config.py b/policy-search/config.py new file mode 100644 index 00000000000..de45a126aef --- /dev/null +++ b/policy-search/config.py @@ -0,0 +1,71 @@ +"""配置管理模块""" + +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + """全局配置""" + + # DashScope API Key + DASHSCOPE_API_KEY: str = os.getenv("DASHSCOPE_API_KEY", "") + + # LLM 模型 + LLM_MODEL: str = os.getenv("LLM_MODEL", "qwen3.7-plus") + + # Embedding 模型 + EMBEDDING_MODEL: str = os.getenv("EMBEDDING_MODEL", "text-embedding-v4") + + # 知识库根目录(始终解析为绝对路径,避免 MCP Server 被外部启动时 CWD 不对) + _KB_DIR_RAW: str = os.getenv( + "KNOWLEDGE_BASE_DIR", + os.path.join(os.path.dirname(os.path.abspath(__file__)), "knowledge_base") + ) + KNOWLEDGE_BASE_DIR: str = ( + _KB_DIR_RAW + if os.path.isabs(_KB_DIR_RAW) + else os.path.join(os.path.dirname(os.path.abspath(__file__)), _KB_DIR_RAW) + ) + + # 文档分块参数(用于长文档解析) + CHUNK_SIZE: int = int(os.getenv("CHUNK_SIZE", "2000")) + CHUNK_OVERLAP: int = int(os.getenv("CHUNK_OVERLAP", "200")) + + # 分类标识 + CATEGORIES = { + "postgraduate_recommendation": "保研/推免", + "scholarship": "奖学金", + "financial_aid": "助学金/资助", + "academic": "学业管理", + "discipline": "纪律处分", + "exchange": "交流交换", + "employment": "就业创业", + "other": "其他", + } + + # 条件类型 + CONDITION_TYPES = { + "hard": "硬性门槛", + "scoring": "评分项", + "ranking": "排名项", + "bonus": "加分项", + "preference": "优先条件", + "procedural": "流程性要求", + "qualitative": "模糊定性条件", + } + + # 要求分类(用于结构化存储) + REQUIREMENT_CATEGORIES = { + "gpa": "绩点/成绩要求", + "foreign_language": "外语要求", + "academic": "学业表现要求", + "disciplinary": "纪律/品行要求", + "research": "科研/论文要求", + "competition": "竞赛/获奖要求", + "bonus": "加分项", + "procedural": "流程性要求", + "health": "健康要求", + "other": "其他要求", + } diff --git a/policy-search/llm_client.py b/policy-search/llm_client.py new file mode 100644 index 00000000000..81910d84feb --- /dev/null +++ b/policy-search/llm_client.py @@ -0,0 +1,188 @@ +"""LLM 客户端 - 调用通义千问""" + +import sys +import time +import logging +from typing import Optional +import dashscope +from dashscope import Generation +from config import Config + +# 日志输出到 stderr +logger = logging.getLogger(__name__) + + +class LLMClient: + """通义千问 LLM 客户端""" + + def __init__(self): + """初始化 LLM 客户端""" + dashscope.api_key = Config.DASHSCOPE_API_KEY + self.model = Config.LLM_MODEL + logger.info(f"LLMClient 初始化完成,使用模型: {self.model}") + + def generate(self, system_prompt: str, user_prompt: str, timeout: int = 120) -> dict: + """ + 调用 LLM 生成回答 + + Args: + system_prompt: 系统提示词 + user_prompt: 用户提示词 + timeout: 超时时间(秒),默认120秒 + + Returns: + 包含回答和成功状态的字典 + """ + try: + logger.info(f"LLM 调用开始 (model={self.model}, prompt长度={len(user_prompt)})") + response = Generation.call( + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + result_format="message", + timeout=timeout, + ) + + if response.status_code != 200: + logger.error(f"LLM API 调用失败 (status={response.status_code}): {response.message}") + return { + "content": f"LLM API 调用失败 (status={response.status_code}): {response.message}", + "success": False, + } + + # 安全检查:确保 output、choices 存在且非空 + output = getattr(response, "output", None) + if output is None: + logger.error("LLM 返回 output 为 None") + return {"content": "LLM 返回 output 为 None", "success": False} + + choices = getattr(output, "choices", None) + if not choices: + logger.error(f"LLM 返回 choices 为空 (choices={choices!r})") + return {"content": f"LLM 返回 choices 为空", "success": False} + + first_choice = choices[0] + message = getattr(first_choice, "message", None) + if message is None: + logger.error("LLM 返回 message 为 None") + return {"content": "LLM 返回 message 为 None", "success": False} + + content = getattr(message, "content", "") + if not content: + logger.warning("LLM 返回 content 为空字符串") + return {"content": "", "success": False} + + logger.info(f"LLM 调用成功,返回内容长度: {len(content)}") + return {"content": content, "success": True} + + except Exception as e: + logger.error(f"LLM 调用异常: {type(e).__name__}: {e}") + return {"content": f"生成时发生错误: {str(e)}", "success": False} + + def extract_json(self, system_prompt: str, user_prompt: str, max_retries: int = 2) -> dict: + """ + 调用 LLM 并尝试解析返回的 JSON,失败时自动重试 + + Args: + system_prompt: 系统提示词 + user_prompt: 用户提示词 + max_retries: 最大重试次数(默认2次,即最多调用3次) + + Returns: + 包含解析后的 dict 和成功状态 + """ + import json + import re + + last_result = None + + for attempt in range(max_retries + 1): + if attempt > 0: + logger.info(f"extract_json 重试第 {attempt}/{max_retries} 次...") + time.sleep(1) # 短暂等待后重试 + + result = self.generate(system_prompt, user_prompt) + if not result["success"]: + last_result = result + continue + + content = result["content"].strip() + if not content: + last_result = {"content": "LLM 返回空内容", "success": False} + continue + + parsed = self._try_parse_json(content) + if parsed["success"]: + return parsed + + # 解析失败,记录并重试 + logger.warning(f"第 {attempt + 1} 次 JSON 解析失败,原始内容前200字: {content[:200]}") + last_result = parsed + + # 所有重试都失败 + return last_result + + def _try_parse_json(self, content: str) -> dict: + """ + 尝试多种策略解析 JSON 字符串 + + Returns: + 包含解析后的 dict 和成功状态 + """ + import json + import re + + original = content + + # 策略1:尝试去除 markdown 代码块标记 + # 匹配 ```json ... ``` 或 ``` ... ``` + code_block_pattern = r'```(?:json)?\s*\n?(.*?)\n?\s*```' + match = re.search(code_block_pattern, content, re.DOTALL) + if match: + content = match.group(1).strip() + + # 策略2:尝试直接解析 + try: + data = json.loads(content) + return {"data": data, "success": True} + except (json.JSONDecodeError, ValueError): + pass + + # 策略3:尝试提取第一个 { 到最后一个 } 之间的内容 + json_match = re.search(r'\{.*\}', content, re.DOTALL) + if json_match: + try: + data = json.loads(json_match.group(0)) + return {"data": data, "success": True} + except (json.JSONDecodeError, ValueError): + pass + + # 策略4:尝试提取第一个 [ 到最后一个 ] 之间的内容(数组格式) + array_match = re.search(r'\[.*\]', content, re.DOTALL) + if array_match: + try: + data = json.loads(array_match.group(0)) + return {"data": data, "success": True} + except (json.JSONDecodeError, ValueError): + pass + + # 策略5:修复常见 JSON 错误(尾部逗号、单引号等) + try: + # 去除尾部逗号: ,} -> } 和 ,] -> ] + fixed = re.sub(r',\s*}', '}', content) + fixed = re.sub(r',\s*]', ']', fixed) + # 尝试提取修复后的 JSON + json_match = re.search(r'\{.*\}', fixed, re.DOTALL) + if json_match: + data = json.loads(json_match.group(0)) + return {"data": data, "success": True} + except (json.JSONDecodeError, ValueError): + pass + + # 所有策略都失败 + return { + "content": f"JSON 解析失败\n原始内容: {original[:500]}", + "success": False, + } diff --git a/policy-search/policy_matcher.py b/policy-search/policy_matcher.py new file mode 100644 index 00000000000..6531e81d873 --- /dev/null +++ b/policy-search/policy_matcher.py @@ -0,0 +1,361 @@ +"""条件匹配模块 - 将用户信息与政策条件逐条匹配""" + +import logging +from typing import Any, Dict, List, Optional +from config import Config + +# 日志输出到 stderr +logger = logging.getLogger(__name__) + + +# 条件单位 → user_info 字段映射 +UNIT_FIELD_MAP = { + "GPA": "gpa", + "绩点": "gpa", + "分": None, # 需根据 item 判断 + "%": None, # 需根据 item 判断 + "篇": None, # 论文数量 + "项": None, # 竞赛数量 +} + + +class PolicyMatcher: + """政策条件匹配器""" + + def __init__(self): + """初始化匹配器""" + logger.info("PolicyMatcher 初始化完成") + + def _get_user_value(self, user_info: Dict[str, Any], unit: str, item: str) -> Optional[float]: + """ + 根据条件的 unit 和 item 从 user_info 中提取对应数值 + + 优先级:标准字段 → extra 字段 + """ + # 1. 尝试标准字段映射 + if unit in ("GPA", "绩点"): + return user_info.get("gpa") + if unit == "%" or "排名" in item or "rank" in item.lower(): + return user_info.get("gpa_rank_percent") + if "英语" in item or "CET" in item.upper() or "cet" in item.lower(): + cet4 = user_info.get("english", {}).get("cet4") + cet6 = user_info.get("english", {}).get("cet6") + if "六级" in item or "cet6" in item.lower(): + return cet6 + return cet4 + + # 2. 尝试 extra 字段 + extra = user_info.get("extra", {}) + if unit in extra: + val = extra[unit] + if isinstance(val, (int, float)): + return val + + # 3. 尝试 item 关键词匹配 extra + for key, val in extra.items(): + if key.lower() in item.lower() or item.lower() in key.lower(): + if isinstance(val, (int, float)): + return val + + return None + + def _check_papers(self, user_info: Dict[str, Any], condition: Dict[str, Any]) -> Dict[str, Any]: + """检查论文相关条件""" + papers = user_info.get("papers", []) + cond_type = condition.get("type", "") + cond_item = condition.get("item", "").lower() + + # 尝试从 condition 中提取论文要求 + required_type = None + required_order = None + required_count = condition.get("value") + + if "sci" in cond_item: + required_type = "SCI" + elif "ei" in cond_item: + required_type = "EI" + elif "核心" in cond_item: + required_type = "核心" + elif "一作" in cond_item or "第一作者" in cond_item: + required_order = 1 + + matched_papers = [] + for paper in papers: + paper_type = paper.get("type", "") + paper_order = paper.get("author_order", 999) + paper_count = paper.get("count", 1) + + type_match = (required_type is None) or (required_type.upper() in paper_type.upper()) + order_match = (required_order is None) or (paper_order <= required_order) + + if type_match and order_match: + matched_papers.append(paper) + + total_count = sum(p.get("count", 1) for p in matched_papers) + + if required_count is not None: + if total_count >= required_count: + return {"match": "met", "detail": f"用户有 {total_count} 篇符合条件的论文"} + else: + return {"match": "not_met", "detail": f"用户有 {total_count} 篇,要求 {required_count} 篇"} + + if matched_papers: + return {"match": "met", "detail": f"用户有 {len(matched_papers)} 篇相关论文"} + return {"match": "not_met", "detail": "用户无相关论文"} + + def _check_competitions(self, user_info: Dict[str, Any], condition: Dict[str, Any]) -> Dict[str, Any]: + """检查竞赛相关条件""" + competitions = user_info.get("competitions", []) + cond_item = condition.get("item", "").lower() + + required_level = None + if "国家" in cond_item or "national" in cond_item: + required_level = "national" + elif "省" in cond_item or "provincial" in cond_item: + required_level = "provincial" + elif "校" in cond_item or "school" in cond_item: + required_level = "school" + + required_award = None + if "一等" in cond_item: + required_award = "一等奖" + elif "二等" in cond_item: + required_award = "二等奖" + elif "三等" in cond_item: + required_award = "三等奖" + + matched = [] + for comp in competitions: + comp_level = comp.get("level", "") + comp_award = comp.get("award", "") + + level_match = (required_level is None) or (required_level in comp_level.lower()) + award_match = (required_award is None) or (required_award in comp_award) + + if level_match and award_match: + matched.append(comp) + + if matched: + return {"match": "met", "detail": f"用户有 {len(matched)} 项符合条件的竞赛"} + return {"match": "not_met", "detail": "用户无符合条件的竞赛"} + + def match_condition(self, user_info: Dict[str, Any], condition: Dict[str, Any]) -> Dict[str, Any]: + """ + 匹配单个条件 + + Returns: + { + "item": "条件名称", + "match": "met" | "not_met" | "partial" | "needs_manual_review", + "user_value": "用户实际值", + "requirement": "政策要求", + "detail": "匹配详情", + "source_quote": "原文引用" + } + """ + cond_type = condition.get("type", "hard") + item = condition.get("item", "") + requirement = condition.get("requirement", "") + operator = condition.get("operator", "") + value = condition.get("value") + unit = condition.get("unit", "") + source_quote = condition.get("source_quote", "") + + result = { + "item": item, + "requirement": requirement, + "source_quote": source_quote, + } + + # === 硬性门槛 === + if cond_type == "hard": + user_val = self._get_user_value(user_info, unit, item) + + # 特殊处理:论文和竞赛 + if unit == "篇" or "论文" in item.lower(): + paper_result = self._check_papers(user_info, condition) + result.update({"match": paper_result["match"], "user_value": paper_result["detail"]}) + return result + + if unit == "项" or "竞赛" in item.lower(): + comp_result = self._check_competitions(user_info, condition) + result.update({"match": comp_result["match"], "user_value": comp_result["detail"]}) + return result + + if user_val is None: + result.update({"match": "missing_info", "user_value": "未提供", "detail": f"缺少 {unit or item} 信息"}) + return result + + result["user_value"] = str(user_val) + + # 比较 + try: + if operator == ">=" and user_val >= value: + result["match"] = "met" + elif operator == "<=" and user_val <= value: + result["match"] = "met" + elif operator == ">" and user_val > value: + result["match"] = "met" + elif operator == "<" and user_val < value: + result["match"] = "met" + elif operator == "==" and user_val == value: + result["match"] = "met" + elif operator == "none" or not operator: + result["match"] = "met" # 无法比较,默认通过 + else: + result["match"] = "not_met" + except (TypeError, ValueError): + result["match"] = "not_met" + + if result.get("match") != "met": + result["detail"] = f"要求 {requirement},用户值为 {user_val}" + else: + result["detail"] = f"满足 {requirement}" + + return result + + # === 评分项 === + elif cond_type == "scoring": + user_val = self._get_user_value(user_info, unit, item) + if user_val is not None and value is not None: + score = user_val * (value / 100) if value <= 100 else user_val + result.update({ + "match": "met", + "user_value": str(user_val), + "detail": f"得分: {score:.1f}({requirement})", + }) + else: + result.update({"match": "missing_info", "user_value": "未提供", "detail": f"缺少 {item} 信息"}) + return result + + # === 排名项 === + elif cond_type == "ranking": + user_rank = user_info.get("gpa_rank_percent") + if user_rank is not None and value is not None: + # value 通常是前 X%,用户排名越小越好 + if user_rank <= value: + result.update({"match": "met", "user_value": f"前{user_rank}%", "detail": f"满足前{value}%要求"}) + else: + result.update({"match": "not_met", "user_value": f"前{user_rank}%", "detail": f"不满足前{value}%要求"}) + else: + result.update({"match": "missing_info", "user_value": "未提供排名信息"}) + return result + + # === 加分项 === + elif cond_type == "bonus": + if "论文" in item.lower() or unit == "篇": + paper_result = self._check_papers(user_info, condition) + result.update({"match": paper_result["match"], "user_value": paper_result["detail"]}) + return result + if "竞赛" in item.lower() or unit == "项": + comp_result = self._check_competitions(user_info, condition) + result.update({"match": comp_result["match"], "user_value": comp_result["detail"]}) + return result + result.update({"match": "needs_manual_review", "detail": "加分项需人工核实"}) + return result + + # === 优先条件 === + elif cond_type == "preference": + result.update({"match": "needs_manual_review", "detail": "优先条件需人工评估"}) + return result + + # === 流程性要求 === + elif cond_type == "procedural": + result.update({"match": "needs_manual_review", "detail": "流程性要求需用户自行确认"}) + return result + + # === 定性条件 === + elif cond_type == "qualitative": + result.update({"match": "needs_manual_review", "detail": "定性条件需人工审核"}) + return result + + # === 未知类型 === + else: + result.update({"match": "needs_manual_review", "detail": f"未知条件类型: {cond_type}"}) + return result + + def match_policy(self, user_info: Dict[str, Any], policy: Dict[str, Any]) -> Dict[str, Any]: + """ + 匹配用户信息与单个政策的所有条件 + + Returns: + { + "policy_title": "政策标题", + "overall_verdict": "likely_eligible" | "possibly_eligible" | "needs_review", + "condition_matches": [...], + "missing_info": [...], + "needs_manual_review": [...] + } + """ + # 从 requirements 中提取所有条件(新格式) + conditions = [] + requirements = policy.get("requirements", {}) + for cat_data in requirements.values(): + conditions.extend(cat_data.get("conditions", [])) + + # 兼容旧格式(如果有 conditions 字段) + if not conditions and "conditions" in policy: + conditions = policy.get("conditions", []) + + matches = [] + missing = [] + manual_review = [] + + for cond in conditions: + match_result = self.match_condition(user_info, cond) + matches.append(match_result) + + if match_result["match"] == "missing_info": + missing.append(match_result["item"]) + elif match_result["match"] == "needs_manual_review": + manual_review.append(match_result["item"]) + + # 综合判定 + not_met_count = sum(1 for m in matches if m["match"] == "not_met") + if not_met_count > 0: + verdict = "not_eligible" + elif missing: + verdict = "needs_more_info" + elif manual_review: + verdict = "needs_review" + else: + verdict = "likely_eligible" + + # 按类别汇总匹配结果 + category_matches = {} + requirements = policy.get("requirements", {}) + for cat_key, cat_data in requirements.items(): + cat_conditions = cat_data.get("conditions", []) + cat_match_results = [m for m in matches if m.get("item") in [c.get("item") for c in cat_conditions]] + if cat_match_results: + category_matches[cat_key] = { + "label": cat_data.get("label", cat_key), + "matches": cat_match_results, + } + + return { + "policy_title": policy.get("meta", {}).get("title", "未知政策"), + "policy_category": policy.get("meta", {}).get("category", "other"), + "overall_verdict": verdict, + "category_matches": category_matches, # 按类别分组的匹配结果 + "condition_matches": matches, + "missing_info": missing, + "needs_manual_review": manual_review, + } + + def match_all_policies(self, user_info: Dict[str, Any], policies: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 批量匹配用户信息与多个政策 + + Args: + user_info: 用户信息 + policies: 政策列表(已加载详情的完整结构) + + Returns: + 匹配结果列表 + """ + results = [] + for policy in policies: + result = self.match_policy(user_info, policy) + results.append(result) + return results diff --git a/policy-search/policy_parser.py b/policy-search/policy_parser.py new file mode 100644 index 00000000000..2af5ff567bc --- /dev/null +++ b/policy-search/policy_parser.py @@ -0,0 +1,669 @@ +"""政策文档解析模块 - 调用 LLM 提取结构化信息""" + +import os +import json +import re +import sys +import logging +import unicodedata +from typing import Any, Dict, List, Optional, Tuple + +# 日志输出到 stderr +logger = logging.getLogger(__name__) + +from config import Config +from llm_client import LLMClient + + +class PolicyParser: + """政策文档解析器:从 PDF/文本中提取结构化政策信息""" + + def __init__(self): + """初始化解析器""" + self.llm = LLMClient() + logger.info("PolicyParser 初始化完成") + + def sanitize_text(self, text: str) -> str: + """ + 清理文本文本,确保输出为合法的 UTF-8 + + 处理: + 1. 移除无法编码的控制字符 + 2. 替换特殊 Unicode 字符为 ASCII 等价物 + 3. 规范化 Unicode(NFC) + """ + if not text: + return "" + + # Unicode NFC 规范化 + text = unicodedata.normalize('NFC', text) + + # 特殊字符替换表 + replacements = { + '\u2212': '-', # 数学减号 → 普通减号 + '\u2013': '-', # en dash + '\u2014': '--', # em dash + '\u2018': "'", # 左单引号 + '\u2019': "'", # 右单引号 + '\u201c': '"', # 左双引号 + '\u201d': '"', # 右双引号 + '\u2026': '...', # 省略号 + '\u00a0': ' ', # 不换行空格 + '\u200b': '', # 零宽空格 + '\u200c': '', # 零宽非连接符 + '\u200d': '', # 零宽连接符 + '\ufeff': '', # BOM + '\u00ad': '', # 软连字符 + '\ufffd': '?', # 替换字符 + } + for old, new in replacements.items(): + text = text.replace(old, new) + + # 移除控制字符(保留换行、回车、制表符) + text = ''.join( + ch for ch in text + if ch in ('\n', '\r', '\t') or (unicodedata.category(ch)[0] != 'C') + ) + + # 清理多余空行 + text = re.sub(r'\n{3,}', '\n\n', text) + + return text + + def load_document(self, document_path: str) -> str: + """ + 读取文档内容(PDF 或纯文本) + + Returns: + 文档全文文本(已清理编码) + """ + if not os.path.exists(document_path): + raise FileNotFoundError(f"文件不存在: {document_path}") + + ext = os.path.splitext(document_path)[1].lower() + + if ext == ".pdf": + raw_text = self._extract_pdf_text(document_path) + elif ext in (".txt", ".md", ".text"): + # 尝试多种编码读取文本文件 + raw_text = self._read_text_file(document_path) + else: + raise ValueError(f"不支持的文件格式: {ext},仅支持 .pdf, .txt, .md") + + # 清理并规范化文本 + return self.sanitize_text(raw_text) + + def _read_text_file(self, file_path: str) -> str: + """尝试多种编码读取文本文件""" + encodings = ['utf-8', 'utf-8-sig', 'gbk', 'gb2312', 'gb18030', 'big5', 'latin-1'] + for enc in encodings: + try: + with open(file_path, 'r', encoding=enc) as f: + return f.read() + except (UnicodeDecodeError, UnicodeError): + continue + # 最后兜底:忽略错误字符 + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + return f.read() + + def _extract_pdf_text(self, pdf_path: str) -> str: + """ + 从 PDF 提取文本(多引擎策略) + + 优先级:PyPDF2 > pdfplumber > 兜底 + """ + # 策略1:PyPDF2(更稳定,兼容性好) + text = self._extract_with_pypdf2(pdf_path) + if text and len(text.strip()) > 100: + logger.info(f"PyPDF2 提取成功,文本长度: {len(text)}") + return text + + # 策略2:pdfplumber(对表格和复杂排版更好) + text = self._extract_with_pdfplumber(pdf_path) + if text and len(text.strip()) > 100: + logger.info(f"pdfplumber 提取成功,文本长度: {len(text)}") + return text + + # 策略3:如果两个引擎都失败,返回已提取的内容(即使较短) + if text: + logger.warning(f"PDF 提取文本较短({len(text)} 字符)") + return text + + raise ValueError(f"无法从 PDF 中提取文本: {pdf_path}") + + def _extract_with_pypdf2(self, pdf_path: str) -> str: + """使用 PyPDF2 提取 PDF 文本""" + try: + from PyPDF2 import PdfReader + reader = PdfReader(pdf_path) + text_parts = [] + for page in reader.pages: + page_text = page.extract_text() + if page_text: + text_parts.append(page_text) + return "\n\n".join(text_parts) + except ImportError: + logger.warning("PyPDF2 未安装,跳过") + return "" + except Exception as e: + logger.warning(f"PyPDF2 提取失败: {e}") + return "" + + def _extract_with_pdfplumber(self, pdf_path: str) -> str: + """使用 pdfplumber 提取 PDF 文本""" + try: + import pdfplumber + text_parts = [] + with pdfplumber.open(pdf_path) as pdf: + for page in pdf.pages: + page_text = page.extract_text() + if page_text: + text_parts.append(page_text) + return "\n\n".join(text_parts) + except ImportError: + logger.warning("pdfplumber 未安装,跳过") + return "" + except Exception as e: + logger.warning(f"pdfplumber 提取失败: {e}") + return "" + + def split_into_chunks(self, text: str) -> List[str]: + """ + 将长文档按段落/章节切分为多个块 + + 策略: + 1. 先按章节标题切分(如 "第一章"、"一、" 等) + 2. 如果单个块仍然超过 CHUNK_SIZE,按段落进一步切分 + """ + # 尝试按章节标题切分 + chapter_pattern = r"(?:第[一二三四五六七八九十百千\d]+[章节条篇部]|^[一二三四五六七八九十\d]+[、..]\s)" + sections = re.split(f"(?={chapter_pattern})", text, flags=re.MULTILINE) + + # 过滤空块 + sections = [s.strip() for s in sections if s.strip()] + + # 如果切分后块太大,进一步按段落切分 + final_chunks = [] + for section in sections: + if len(section) <= Config.CHUNK_SIZE: + final_chunks.append(section) + else: + # 按段落切分 + paragraphs = re.split(r"\n\s*\n", section) + current_chunk = "" + for para in paragraphs: + if len(current_chunk) + len(para) > Config.CHUNK_SIZE and current_chunk: + final_chunks.append(current_chunk.strip()) + current_chunk = para + else: + current_chunk += "\n\n" + para if current_chunk else para + if current_chunk.strip(): + final_chunks.append(current_chunk.strip()) + + return final_chunks + + def extract_metadata_from_filename(self, filename: str) -> Dict[str, Any]: + """ + 从文件名中提取元数据(学校、年份、分类等) + + Args: + filename: 文件名(不含路径) + + Returns: + 部分元数据字典 + """ + metadata = {} + + # 提取学校名称(常见模式) + school_patterns = [ + r'(重庆邮电大学|重邮|CQUPT)', + r'(北京大学|北大)', + r'(清华大学|清华)', + r'(复旦大学|复旦)', + r'(上海交通大学|上交)', + r'(浙江大学|浙大)', + r'(南京大学|南大)', + r'(武汉大学|武大)', + r'(中山大学|中大)', + r'(四川大学|川大)', + r'(华中科技大学|华科)', + r'(西安交通大学|西交)', + r'(哈尔滨工业大学|哈工大)', + ] + for pattern in school_patterns: + match = re.search(pattern, filename, re.IGNORECASE) + if match: + metadata['school'] = match.group(1) + break + + # 提取年份(4位数字,1990-2030) + year_match = re.search(r'(199\d|20[0-3]\d)', filename) + if year_match: + metadata['year'] = int(year_match.group(1)) + + # 提取分类关键词 + category_keywords = { + 'postgraduate_recommendation': ['保研', '推免', '推荐免试'], + 'scholarship': ['奖学金', '奖学'], + 'financial_aid': ['助学金', '资助', '困难补助'], + 'academic': ['学业', '学籍', '考试', '成绩'], + 'discipline': ['纪律', '处分', '违规'], + 'exchange': ['交流', '交换', '留学', '出国'], + 'employment': ['就业', '招聘', '毕业'], + } + for category, keywords in category_keywords.items(): + for keyword in keywords: + if keyword in filename: + metadata['category'] = category + break + if 'category' in metadata: + break + + return metadata + + def extract_metadata(self, text: str, filename: str = None) -> Dict[str, Any]: + """ + 提取文档元数据(优先从文件名,其次从文本内容) + + Args: + text: 文档文本内容 + filename: 文件名(可选,用于辅助提取) + + Returns: + { + "school": "学校名称", + "department": "院系名称", + "year": 2025, + "title": "政策标题", + "category": "分类标识", + "tags": ["标签1", "标签2"], + "effective_date": "生效日期", + } + """ + # 1. 先从文件名提取(如果有) + filename_metadata = {} + if filename: + filename_metadata = self.extract_metadata_from_filename(filename) + logger.info(f"从文件名提取的元数据: {filename_metadata}") + + # 2. 从文本内容提取(使用 LLM) + system_prompt = """你是一个高校政策文档分析专家。请从文档中提取元数据信息,以 JSON 格式返回。 + +重要提示: +- 学校名称:请从文档中找出完整的学校名称,如"重庆邮电大学"、"北京大学"等。如果文档中明确提到了学校,必须填写。 +- 年份:从文档标题、文件名或正文中提取年份,如 2025。 +- 标题:提取文档的完整政策标题,通常在文档开头。 +- 分类:根据文档内容判断分类。 + +分类标识(category)必须从以下选项中选择: +- postgraduate_recommendation: 保研/推免 +- scholarship: 奖学金 +- financial_aid: 助学金/资助 +- academic: 学业管理 +- discipline: 纪律处分 +- exchange: 交流交换 +- employment: 就业创业 +- other: 其他 + +返回 JSON 格式: +{ + "school": "学校名称(必须从文档中提取,不要填'未知'除非文档确实没有提及)", + "department": "院系名称(如果文档中未提及,填\"未知\")", + "year": 年份数字(如 2025,从文档中提取,如果无法确定填 0), + "title": "政策完整标题(从文档标题或开头提取)", + "category": "分类标识", + "tags": ["标签1", "标签2", "标签3"], + "effective_date": "生效日期(YYYY-MM-DD 格式,如果无法确定填\"unknown\")" +} + +只返回 JSON,不要其他内容。""" + + # 取前 5000 字符用于元数据提取(更多内容提高准确率) + text_sample = text[:5000] + + # 尝试 LLM 提取 + llm_metadata = None + for attempt in range(2): + result = self.llm.extract_json(system_prompt, text_sample) + if result["success"]: + llm_metadata = result["data"] + school = llm_metadata.get("school", "未知") + title = llm_metadata.get("title", "未命名政策文档") + # 如果学校和标题都已提取,直接使用 + if school != "未知" and title != "未命名政策文档": + logger.info(f"LLM 提取成功: school={school}, title={title}") + return llm_metadata + logger.warning(f"LLM 提取结果不完整(第{attempt+1}次): school={school}, title={title}") + + # 3. 如果 LLM 提取失败或不完整,使用文件名提取结果作为兜底 + if filename_metadata: + logger.info("使用文件名提取的元数据作为兜底") + # 合并 LLM 结果和文件名结果(文件名优先) + merged = { + "school": filename_metadata.get('school', llm_metadata.get('school', '未知') if llm_metadata else '未知'), + "department": llm_metadata.get('department', '未知') if llm_metadata else '未知', + "year": filename_metadata.get('year', llm_metadata.get('year', 0) if llm_metadata else 0), + "title": llm_metadata.get('title', '未命名政策文档') if llm_metadata else '未命名政策文档', + "category": filename_metadata.get('category', llm_metadata.get('category', 'other') if llm_metadata else 'other'), + "tags": llm_metadata.get('tags', []) if llm_metadata else [], + "effective_date": llm_metadata.get('effective_date', 'unknown') if llm_metadata else 'unknown', + } + return merged + + # 4. 如果都没有,返回默认值 + logger.warning("元数据提取失败,返回默认值") + return { + "school": "未知", + "department": "未知", + "year": 0, + "title": "未命名政策文档", + "category": "other", + "tags": [], + "effective_date": "unknown", + } + + def extract_conditions(self, text: str) -> Dict[str, Any]: + """ + 调用 LLM 从文档中提取结构化条件,按类别分组存储 + + 对于长文档,分块提取后合并。 + + Returns: + { + "requirements": { + "gpa": {"label": "绩点/成绩要求", "conditions": [...]}, + "foreign_language": {"label": "外语要求", "conditions": [...]}, + ... + }, + "logic_groups": [...], + "important_dates": [...] + } + """ + chunks = self.split_into_chunks(text) + logger.info(f"文档分为 {len(chunks)} 个块进行解析") + + all_conditions = [] # 临时存储所有条件(带 category) + all_logic_groups = [] + all_important_dates = [] + + system_prompt = """你是一个高校政策文档分析专家。请从文档片段中提取所有申请条件和关键信息。 + +**重要:每个条件必须标注所属分类(category)** + +条件分类(category)必须从以下选项中选择: +- gpa: 绩点/成绩要求(如 GPA、学分绩点排名、必修课成绩等) +- foreign_language: 外语要求(如 CET4/6、TOEFL、IELTS、专业外语等) +- academic: 学业表现要求(如课程完成情况、学术研究能力等) +- disciplinary: 纪律/品行要求(如无处分记录、品行优良等) +- research: 科研/论文要求(如发表论文数量、期刊级别等) +- competition: 竞赛/获奖要求(如学科竞赛获奖级别等) +- bonus: 加分项(如竞赛获奖加分、论文加分、志愿服务加分等) +- procedural: 流程性要求(如提交申请表、参加面试等) +- health: 健康要求(如身心健康标准等) +- other: 其他要求 + +条件类型(type)必须从以下选项中选择: +- hard: 硬性门槛(不满足则不符合,如 GPA ≥ 3.5) +- scoring: 评分项(有具体分值,如学业成绩占80%) +- ranking: 排名项(如成绩排名前30%) +- bonus: 加分项(如 SCI 论文加5分) +- preference: 优先条件(如学生干部优先) +- procedural: 流程性要求(如需提交申请表) +- qualitative: 模糊定性条件(如综合素质突出) + +对每个条件,请提取: +- id: 条件编号(如 condition_001) +- category: 条件分类(从上述10个分类中选择) +- item: 条件名称(如 "GPA要求") +- description: 条件描述 +- type: 条件类型 +- quantifiable: 是否可量化(true/false) +- requirement: 要求描述(如 "GPA ≥ 3.5") +- operator: 比较运算符(>=, <=, >, <, ==, 或 "none") +- value: 数值(如果不可量化填 null) +- unit: 单位(如 "GPA", "分", "%", 或 "none") +- source_quote: 原文引用(必须逐字引用,不得改写) +- source_section: 来源章节(如 "第二章 第六条") + +逻辑分组(logic_groups):将相关条件分组,标注组内逻辑关系(AND/OR/SUM)。 + +重要日期(important_dates):提取所有关键时间节点。 + +返回 JSON 格式: +{ + "conditions": [ + { + "id": "condition_001", + "category": "gpa", + "item": "条件名称", + "description": "描述", + "type": "类型", + "quantifiable": true, + "requirement": "要求描述", + "operator": ">=", + "value": 3.5, + "unit": "GPA", + "source_quote": "原文引用", + "source_section": "章节" + } + ], + "logic_groups": [ + { + "group_id": "group_basic", + "description": "基本申请条件", + "logic": "AND", + "condition_ids": ["condition_001", "condition_002"] + } + ], + "important_dates": [ + {"event": "申请截止", "date": "2025-06-15", "source_quote": "原文引用"} + ] +} + +只返回 JSON,不要其他内容。如果某个块中没有条件,返回空列表。""" + + condition_counter = 1 + + for i, chunk in enumerate(chunks): + logger.info(f"解析第 {i + 1}/{len(chunks)} 块...") + result = self.llm.extract_json(system_prompt, chunk) + + if result["success"]: + data = result["data"] + + # 兼容多种 JSON key 名称:conditions / requirements / items + conditions = ( + data.get("conditions") + or data.get("requirements") + or data.get("items") + or [] + ) + # 安全检查:确保 conditions 是列表 + if not isinstance(conditions, list): + logger.warning(f"第 {i + 1} 块 conditions 不是列表 (type={type(conditions).__name__}),尝试转换") + conditions = [conditions] if isinstance(conditions, dict) else [] + + # 重新编号条件 ID,并收集所有条件 + for cond in conditions: + if not isinstance(cond, dict): + logger.warning(f"跳过非 dict 条件: {type(cond).__name__}") + continue + cond["id"] = f"condition_{condition_counter:03d}" + # 确保每个条件都有 category 字段 + if "category" not in cond: + cond["category"] = "other" + condition_counter += 1 + all_conditions.append(cond) + + # 更新 logic_groups 中的 condition_ids + logic_groups = data.get("logic_groups") or data.get("groups") or [] + if isinstance(logic_groups, list): + for group in logic_groups: + all_logic_groups.append(group) + + important_dates = data.get("important_dates") or data.get("dates") or [] + if isinstance(important_dates, list): + all_important_dates.extend(important_dates) + + logger.info(f"第 {i + 1} 块提取 {len(conditions)} 个条件") + else: + logger.warning(f"第 {i + 1} 块解析失败: {result.get('content', '')[:200]}") + + # 按 category 分组 + requirements = self._group_conditions_by_category(all_conditions) + + logger.info(f"条件提取完成:共 {len(all_conditions)} 个条件,{len(requirements)} 个类别") + + return { + "requirements": requirements, + "logic_groups": all_logic_groups, + "important_dates": all_important_dates, + } + + def _group_conditions_by_category(self, conditions: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + 将条件按类别分组 + + Returns: + { + "gpa": {"label": "绩点/成绩要求", "conditions": [...]}, + "foreign_language": {"label": "外语要求", "conditions": [...]}, + ... + } + """ + # 初始化所有分类 + requirements = {} + for cat_key, cat_label in Config.REQUIREMENT_CATEGORIES.items(): + requirements[cat_key] = { + "label": cat_label, + "conditions": [] + } + + # 将条件分配到对应分类 + for cond in conditions: + cat = cond.get("category", "other") + if cat not in requirements: + cat = "other" + requirements[cat]["conditions"].append(cond) + + # 移除空分类 + requirements = {k: v for k, v in requirements.items() if v["conditions"]} + + return requirements + + def extract_metadata_from_filename(self, filename: str) -> Dict[str, Any]: + """ + 从文件名中提取元数据(学校、年份、分类等) + + Args: + filename: 文件名(不含路径) + + Returns: + 部分元数据字典 + """ + metadata = {} + + # 提取学校名称(常见模式) + school_patterns = [ + r'(重庆邮电大学|重邮|CQUPT)', + r'(北京大学|北大)', + r'(清华大学|清华)', + r'(复旦大学|复旦)', + r'(上海交通大学|上交)', + r'(浙江大学|浙大)', + r'(南京大学|南大)', + r'(武汉大学|武大)', + r'(中山大学|中大)', + r'(四川大学|川大)', + r'(华中科技大学|华科)', + r'(西安交通大学|西交)', + r'(哈尔滨工业大学|哈工大)', + ] + for pattern in school_patterns: + match = re.search(pattern, filename, re.IGNORECASE) + if match: + metadata['school'] = match.group(1) + break + + # 提取年份(4位数字,1990-2030) + year_match = re.search(r'(199\d|20[0-3]\d)', filename) + if year_match: + metadata['year'] = int(year_match.group(1)) + + # 提取分类关键词 + category_keywords = { + 'postgraduate_recommendation': ['保研', '推免', '推荐免试'], + 'scholarship': ['奖学金', '奖学'], + 'financial_aid': ['助学金', '资助', '困难补助'], + 'academic': ['学业', '学籍', '考试', '成绩'], + 'discipline': ['纪律', '处分', '违规'], + 'exchange': ['交流', '交换', '留学', '出国'], + 'employment': ['就业', '招聘', '毕业'], + } + for category, keywords in category_keywords.items(): + for keyword in keywords: + if keyword in filename: + metadata['category'] = category + break + if 'category' in metadata: + break + + return metadata + + def parse_document(self, document_path: str) -> Dict[str, Any]: + """ + 完整解析流程:读取 → 提取元数据 → 提取条件 + + Returns: + 完整的政策数据结构 + """ + logger.info(f"开始解析文档: {document_path}") + + # 1. 读取文档 + text = self.load_document(document_path) + logger.info(f"文档长度: {len(text)} 字符") + + # 2. 先从文件名提取元数据 + filename = os.path.basename(document_path) + filename_metadata = self.extract_metadata_from_filename(filename) + logger.info(f"从文件名提取的元数据: {filename_metadata}") + + # 3. 调用 LLM 提取元数据 + metadata = self.extract_metadata(text) + + # 4. 用文件名元数据补充 LLM 提取的元数据 + if filename_metadata.get('school') and metadata.get('school') == '未知': + metadata['school'] = filename_metadata['school'] + if filename_metadata.get('year') and metadata.get('year') == 0: + metadata['year'] = filename_metadata['year'] + if filename_metadata.get('category') and metadata.get('category') == 'other': + metadata['category'] = filename_metadata['category'] + + logger.info(f"最终元数据: {metadata.get('title')} ({metadata.get('category')})") + + # 5. 提取条件(按类别分组) + extracted = self.extract_conditions(text) + requirements = extracted["requirements"] + total_conditions = sum(len(v["conditions"]) for v in requirements.values()) + logger.info(f"提取到 {total_conditions} 个条件,分布在 {len(requirements)} 个类别中") + + # 6. 组装完整结构(新格式:按类别存储) + policy_data = { + "meta": { + "doc_id": "", # 由 policy_store 生成 + "school": metadata["school"], + "department": metadata["department"], + "year": metadata["year"], + "category": metadata["category"], + "title": metadata["title"], + "source_file": os.path.basename(document_path), + "effective_date": metadata["effective_date"], + "tags": metadata["tags"], + }, + "raw_text": text[:2000] + "..." if len(text) > 2000 else text, # 只保留前2000字符作为参考 + "requirements": requirements, # 按类别分组的条件 + "logic_groups": extracted["logic_groups"], + "important_dates": extracted["important_dates"], + } + + return policy_data diff --git a/policy-search/policy_store.py b/policy-search/policy_store.py new file mode 100644 index 00000000000..00e6c0417b8 --- /dev/null +++ b/policy-search/policy_store.py @@ -0,0 +1,215 @@ +"""知识库管理模块 - index.json + 政策详情文件的读写""" + +import json +import os +import sys +import uuid +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +from config import Config + +logger = logging.getLogger(__name__) + +# Windows 不支持 fcntl,用 filelock 替代 +if sys.platform == "win32": + try: + from filelock import FileLock + HAS_FILELOCK = True + except ImportError: + HAS_FILELOCK = False +else: + import fcntl + HAS_FILELOCK = False + + +class PolicyStore: + """政策知识库存储管理""" + + def __init__(self, base_dir: Optional[str] = None): + """初始化知识库""" + self.base_dir = base_dir or Config.KNOWLEDGE_BASE_DIR + self.index_path = os.path.join(self.base_dir, "index.json") + self._ensure_dirs() + self._ensure_index() + + def _ensure_dirs(self): + """确保知识库目录结构存在""" + os.makedirs(self.base_dir, exist_ok=True) + for category in Config.CATEGORIES: + os.makedirs(os.path.join(self.base_dir, category), exist_ok=True) + + def _ensure_index(self): + """确保 index.json 存在""" + if not os.path.exists(self.index_path): + index = { + "last_updated": datetime.now(timezone.utc).isoformat(), + "categories": {cat: [] for cat in Config.CATEGORIES}, + } + self._write_index(index) + + def _read_index(self) -> Dict[str, Any]: + """读取索引文件""" + with open(self.index_path, "r", encoding="utf-8") as f: + return json.load(f) + + def _write_index(self, index: Dict[str, Any]): + """写入索引文件(带文件锁)""" + index["last_updated"] = datetime.now(timezone.utc).isoformat() + if HAS_FILELOCK: + lock = FileLock(self.index_path + ".lock") + with lock: + with open(self.index_path, "w", encoding="utf-8") as f: + json.dump(index, f, ensure_ascii=False, indent=2) + else: + with open(self.index_path, "w", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + try: + json.dump(index, f, ensure_ascii=False, indent=2) + finally: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + def _generate_doc_id(self, school: str, year: int, category: str) -> str: + """生成文档 ID""" + # 格式: {学校缩写}_{年份}_{序号} + school_abbr = school.replace(" ", "_").replace("大学", "").replace("学院", "")[:6] + existing = self._read_index() + count = len(existing["categories"].get(category, [])) + 1 + return f"{school_abbr}_{year}_{count:03d}" + + def save_policy( + self, + policy_data: Dict[str, Any], + category: str, + school: str, + year: int, + title: str, + tags: List[str], + effective_date: str, + source_file: str, + ) -> str: + """ + 保存政策详情并更新索引(自动去重:相同 source_file 不会重复存储) + + Returns: + doc_id: 生成的文档 ID(如果已存在则返回已存在的 ID) + """ + # 去重检查:检查是否已存在相同的 source_file + index = self._read_index() + for cat in index["categories"]: + for entry in index["categories"][cat]: + # 检查 source_file 是否相同 + existing_policy = self.load_policy(entry["doc_id"], cat) + if existing_policy and existing_policy.get("meta", {}).get("source_file") == source_file: + logger.info(f"文档已存在,跳过重复存储: {source_file} -> {entry['doc_id']}") + return entry["doc_id"] + + # 不存在则创建新文档 + doc_id = self._generate_doc_id(school, year, category) + policy_data["meta"]["doc_id"] = doc_id + policy_data["meta"]["school"] = school + policy_data["meta"]["year"] = year + policy_data["meta"]["category"] = category + policy_data["meta"]["title"] = title + policy_data["meta"]["source_file"] = source_file + policy_data["meta"]["effective_date"] = effective_date + policy_data["meta"]["tags"] = tags + + # 写入详情文件 + detail_path = os.path.join(self.base_dir, category, f"{doc_id}.json") + with open(detail_path, "w", encoding="utf-8") as f: + json.dump(policy_data, f, ensure_ascii=False, indent=2) + + # 更新索引 + index_entry = { + "doc_id": doc_id, + "school": school, + "year": year, + "title": title, + "file": f"{category}/{doc_id}.json", + "tags": tags, + "effective_date": effective_date, + } + if category not in index["categories"]: + index["categories"][category] = [] + index["categories"][category].append(index_entry) + self._write_index(index) + + logger.info(f"新文档已保存: {source_file} -> {doc_id}") + return doc_id + + def load_policy(self, doc_id: str, category: str) -> Optional[Dict[str, Any]]: + """加载政策详情""" + detail_path = os.path.join(self.base_dir, category, f"{doc_id}.json") + if not os.path.exists(detail_path): + return None + with open(detail_path, "r", encoding="utf-8") as f: + return json.load(f) + + def list_policies( + self, + school: Optional[str] = None, + category: Optional[str] = None, + year: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """ + 列出政策文件(支持筛选) + + Args: + school: 筛选学校(模糊匹配) + category: 筛选分类 + year: 筛选年份 + """ + index = self._read_index() + results = [] + + categories_to_search = [category] if category else list(Config.CATEGORIES.keys()) + + for cat in categories_to_search: + entries = index["categories"].get(cat, []) + for entry in entries: + if school and school.lower() not in entry.get("school", "").lower(): + continue + if year and entry.get("year") != year: + continue + results.append(entry) + + return results + + def get_all_policies_in_category(self, category: str) -> List[Dict[str, Any]]: + """获取某个分类下的所有政策详情""" + index = self._read_index() + entries = index["categories"].get(category, []) + policies = [] + for entry in entries: + policy = self.load_policy(entry["doc_id"], category) + if policy: + policies.append(policy) + return policies + + def clear(self) -> str: + """清空知识库""" + for category in Config.CATEGORIES: + cat_dir = os.path.join(self.base_dir, category) + if os.path.exists(cat_dir): + for f in os.listdir(cat_dir): + os.remove(os.path.join(cat_dir, f)) + index = { + "last_updated": datetime.now(timezone.utc).isoformat(), + "categories": {cat: [] for cat in Config.CATEGORIES}, + } + self._write_index(index) + return "知识库已清空" + + def get_index_summary(self) -> Dict[str, Any]: + """获取知识库摘要""" + index = self._read_index() + summary = { + "last_updated": index["last_updated"], + "total_policies": 0, + "categories": {}, + } + for cat, entries in index["categories"].items(): + summary["categories"][Config.CATEGORIES.get(cat, cat)] = len(entries) + summary["total_policies"] += len(entries) + return summary diff --git a/policy-search/requirements.txt b/policy-search/requirements.txt new file mode 100644 index 00000000000..b4386171cdd --- /dev/null +++ b/policy-search/requirements.txt @@ -0,0 +1,6 @@ +mcp>=1.0.0,<2.0.0 +dashscope>=1.20.0 +pdfplumber>=0.11.0 +PyPDF2>=3.0.0 +python-dotenv>=1.0.0 +filelock>=3.0.0 diff --git a/policy-search/server.py b/policy-search/server.py new file mode 100644 index 00000000000..f5b643f6d9c --- /dev/null +++ b/policy-search/server.py @@ -0,0 +1,480 @@ +"""Policy Search MCP Server - 高校政策查询系统""" + +import json +import asyncio +import sys +import logging +from typing import Any, Dict, List, Optional + +# 配置日志输出到 stderr,避免污染 stdout 的 JSON-RPC 响应 +logging.basicConfig( + level=logging.INFO, + format='[%(asctime)s] %(levelname)s: %(message)s', + stream=sys.stderr, + force=True +) +logger = logging.getLogger(__name__) + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import ( + Tool, + TextContent, + CallToolResult, + ListToolsResult, +) + +from config import Config +from policy_store import PolicyStore +from policy_parser import PolicyParser +from policy_matcher import PolicyMatcher + + +# ============================================================ +# 单例初始化(避免重复初始化) +# ============================================================ + +_server_instance = None +_store_instance = None +_parser_instance = None +_matcher_instance = None + +def get_server(): + global _server_instance + if _server_instance is None: + _server_instance = Server("policy_search") + return _server_instance + +def get_store(): + global _store_instance + if _store_instance is None: + _store_instance = PolicyStore() + logger.info(f"PolicyStore 初始化完成,知识库路径: {_store_instance.base_dir}") + return _store_instance + +def get_parser(): + global _parser_instance + if _parser_instance is None: + _parser_instance = PolicyParser() + logger.info("PolicyParser 初始化完成") + return _parser_instance + +def get_matcher(): + global _matcher_instance + if _matcher_instance is None: + _matcher_instance = PolicyMatcher() + logger.info("PolicyMatcher 初始化完成") + return _matcher_instance + +# 初始化实例 +server = get_server() +store = get_store() +parser = get_parser() +matcher = get_matcher() + +logger.info("PolicySearch MCP Server 启动完成") + + +# ============================================================ +# 工具定义 +# ============================================================ + +TOOLS = [ + Tool( + name="load_policy_document", + description=( + "【政策文档入库工具】\n" + "功能:加载高校政策文档(PDF或文本文件),使用LLM自动解析并结构化存储到知识库。\n" + "触发条件:当用户提到以下关键词时使用此工具:\n" + "- 加载政策、导入政策、解析政策文档\n" + "- 上传政策文件、添加新政策、录入政策\n" + "- 上传奖学金文件、上传保研文件、上传助学金文件\n" + "- 政策入库、文档入库\n" + "适用场景:用户提供了一个PDF或文本文件路径,要求系统解析其中的政策内容。\n" + "支持的政策类型:保研/推免、奖学金、助学金、学业管理、纪律处分、交流交换、就业创业等。\n" + "注意:此工具专门用于政策类文档的结构化解析。如果是普通文档问答,请使用RAG的load_pdf工具。\n" + "示例:用户说'帮我加载这个保研政策PDF:d:/docs/policy.pdf'时调用此工具。" + ), + inputSchema={ + "type": "object", + "properties": { + "document_path": { + "type": "string", + "description": "PDF 或文本文件的绝对路径,例如 d:/documents/policy.pdf", + }, + "metadata": { + "type": "object", + "description": "可选的元数据覆盖,如果不提供则自动从文档中提取", + "properties": { + "school": {"type": "string", "description": "学校名称"}, + "year": {"type": "integer", "description": "年份,例如 2025"}, + "category": { + "type": "string", + "description": "政策分类标识", + "enum": list(Config.CATEGORIES.keys()), + }, + }, + }, + }, + "required": ["document_path"], + }, + ), + Tool( + name="query_policy", + description=( + "【政策条件匹配工具】\n" + "功能:根据用户个人信息(GPA、排名、论文、竞赛等)查询匹配的高校政策," + "返回每个政策的匹配状态、条件逐条对比结果和原文引用。\n" + "触发条件:当用户提到以下关键词时使用此工具:\n" + "- 我符合哪些政策、帮我匹配政策、查询政策\n" + "- 我的GPA是、我有多少论文、保研条件、奖学金条件\n" + "- 我能不能申请、我是否符合条件、政策eligibility\n" + "- 帮我看看、帮我查一下、匹配一下\n" + "适用场景:用户提供了个人信息,想知道自己符合哪些政策条件。\n" + "返回结果包含:\n" + "- overall_verdict:总体判定(likely_eligible/not_eligible/needs_review等)\n" + "- condition_matches:逐条匹配结果\n" + "- source_quote:原文引用(必须展示给用户)\n" + "- missing_info:缺失信息\n" + "示例:用户说'我的GPA3.7,有1篇SCI论文,符合哪些保研政策?'时调用此工具。" + ), + inputSchema={ + "type": "object", + "properties": { + "user_info": { + "type": "object", + "description": "用户个人信息对象", + "properties": { + "school": {"type": "string", "description": "学校名称,例如 北京大学"}, + "year": {"type": "integer", "description": "年份,例如 2025"}, + "gpa": {"type": "number", "description": "GPA 绩点,例如 3.7"}, + "gpa_rank_percent": { + "type": "number", + "description": "GPA 排名百分比,例如 12.5 表示前12.5%", + }, + "english": { + "type": "object", + "description": "英语成绩", + "properties": { + "cet4": {"type": "number", "description": "CET-4 分数"}, + "cet6": {"type": "number", "description": "CET-6 分数"}, + }, + }, + "papers": { + "type": "array", + "description": "论文列表", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "论文类型,如 SCI、EI、核心", + }, + "author_order": { + "type": "integer", + "description": "作者排序,1表示第一作者", + }, + "count": { + "type": "integer", + "description": "论文数量", + }, + }, + }, + }, + "competitions": { + "type": "array", + "description": "竞赛获奖列表", + "items": { + "type": "object", + "properties": { + "level": { + "type": "string", + "description": "竞赛级别:national/provincial/school", + }, + "award": { + "type": "string", + "description": "奖项:一等奖/二等奖/三等奖", + }, + }, + }, + }, + "extra": { + "type": "object", + "description": "自定义扩展字段,如志愿服务时长等", + }, + }, + "required": [], + }, + "category": { + "type": "string", + "description": "筛选分类(可选),不填则查询所有分类", + "enum": list(Config.CATEGORIES.keys()), + }, + }, + "required": ["user_info"], + }, + ), + Tool( + name="list_policies", + description=( + "【政策列表查询工具】\n" + "功能:列出知识库中已加载的政策文件,支持按学校、分类、年份筛选。\n" + "触发条件:当用户提到以下关键词时使用此工具:\n" + "- 列出政策、有哪些政策、知识库政策\n" + "- 政策列表、查看政策、显示政策\n" + "- 保研政策有哪些、奖学金政策列表\n" + "- 某个学校有哪些政策、某个分类下有哪些政策\n" + "适用场景:用户想查看知识库里有哪些政策文件。\n" + "示例:用户说'知识库里有哪些保研政策?'或'北京大学有哪些政策?'时调用此工具。" + ), + inputSchema={ + "type": "object", + "properties": { + "school": {"type": "string", "description": "筛选学校(可选,模糊匹配)"}, + "category": { + "type": "string", + "description": "筛选分类(可选)", + "enum": list(Config.CATEGORIES.keys()), + }, + "year": {"type": "integer", "description": "筛选年份(可选)"}, + }, + "required": [], + }, + ), + Tool( + name="clear_knowledge_base", + description=( + "【清空知识库工具】\n" + "功能:清空知识库中的所有政策数据。此操作不可恢复,请谨慎使用。\n" + "触发条件:当用户明确要求清空或删除所有政策时使用此工具:\n" + "- 清空知识库、删除所有政策、重置知识库\n" + "- 清除所有数据、清空政策库\n" + "注意:此操作会删除所有已加载的政策文档,执行前建议确认用户意图。" + ), + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), +] + + +# ============================================================ +# MCP 协议处理 +# ============================================================ + + +@server.list_tools() +async def handle_list_tools() -> ListToolsResult: + """返回所有可用工具""" + return ListToolsResult(tools=TOOLS) + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: + """处理工具调用""" + try: + if name == "load_policy_document": + return await _handle_load_policy(arguments) + elif name == "query_policy": + return await _handle_query_policy(arguments) + elif name == "list_policies": + return await _handle_list_policies(arguments) + elif name == "clear_knowledge_base": + return await _handle_clear(arguments) + else: + return [TextContent(type="text", text=json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False))] + except Exception as e: + return [TextContent(type="text", text=json.dumps({"error": str(e)}, ensure_ascii=False))] + + +# ============================================================ +# 工具实现 +# ============================================================ + + +async def _handle_load_policy(arguments: Dict[str, Any]) -> List[TextContent]: + """加载政策文档""" + document_path = arguments.get("document_path", "") + metadata_override = arguments.get("metadata", {}) + + if not document_path: + return [TextContent(type="text", text=json.dumps({"error": "document_path 不能为空"}, ensure_ascii=False))] + + try: + logger.info(f"开始加载文档: {document_path}") + + # 1. 解析文档(同步阻塞操作,放到线程池执行,避免阻塞事件循环) + policy_data = await asyncio.to_thread(parser.parse_document, document_path) + + # 统计条件总数(兼容新旧格式) + total_conds = 0 + requirements = policy_data.get("requirements", {}) + logger.info(f"[DEBUG] requirements keys: {list(requirements.keys())}") + for cat_key, cat_data in requirements.items(): + conds = cat_data.get("conditions", []) + total_conds += len(conds) + logger.info(f"[DEBUG] {cat_key}: {len(conds)} conditions") + logger.info(f"文档解析完成,条件数: {total_conds}") + + # 如果 requirements 为空,记录详细信息 + if total_conds == 0: + logger.warning(f"[DEBUG] requirements 为空! policy_data keys: {list(policy_data.keys())}") + logger.warning(f"[DEBUG] policy_data['requirements'] type: {type(requirements)}") + logger.warning(f"[DEBUG] policy_data['requirements'] content: {requirements}") + + # 2. 应用元数据覆盖 + if metadata_override: + if "school" in metadata_override: + policy_data["meta"]["school"] = metadata_override["school"] + if "year" in metadata_override: + policy_data["meta"]["year"] = metadata_override["year"] + if "category" in metadata_override: + policy_data["meta"]["category"] = metadata_override["category"] + + # 3. 存储 + meta = policy_data["meta"] + doc_id = store.save_policy( + policy_data=policy_data, + category=meta["category"], + school=meta["school"], + year=meta["year"], + title=meta["title"], + tags=meta["tags"], + effective_date=meta["effective_date"], + source_file=meta["source_file"], + ) + logger.info(f"文档已存储,doc_id: {doc_id}") + + # 统计各类别条件数量 + requirements = policy_data.get("requirements", {}) + req_summary = {} + for cat_key, cat_data in requirements.items(): + req_summary[cat_key] = { + "label": cat_data.get("label", cat_key), + "count": len(cat_data.get("conditions", [])) + } + + result = { + "success": True, + "doc_id": doc_id, + "title": meta["title"], + "category": Config.CATEGORIES.get(meta["category"], meta["category"]), + "school": meta["school"], + "year": meta["year"], + "requirements_summary": req_summary, + "total_conditions": sum(v["count"] for v in req_summary.values()), + "logic_groups_count": len(policy_data["logic_groups"]), + "important_dates_count": len(policy_data["important_dates"]), + "message": f"政策文档已成功加载并存储,doc_id: {doc_id}", + } + return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))] + + except FileNotFoundError as e: + logger.error(f"文件未找到: {e}") + return [TextContent(type="text", text=json.dumps({"error": str(e)}, ensure_ascii=False))] + except Exception as e: + logger.error(f"加载失败: {e}", exc_info=True) + return [TextContent(type="text", text=json.dumps({"error": f"加载失败: {str(e)}"}, ensure_ascii=False))] + + +async def _handle_query_policy(arguments: Dict[str, Any]) -> List[TextContent]: + """查询匹配政策""" + user_info = arguments.get("user_info", {}) + category_filter = arguments.get("category") + + if not user_info: + return [TextContent(type="text", text=json.dumps({"error": "user_info 不能为空"}, ensure_ascii=False))] + + try: + # 1. 获取相关政策 + if category_filter: + policies = store.get_all_policies_in_category(category_filter) + else: + # 获取所有分类的政策 + policies = [] + for cat in Config.CATEGORIES: + policies.extend(store.get_all_policies_in_category(cat)) + + if not policies: + return [ + TextContent( + type="text", + text=json.dumps( + {"error": "知识库中没有找到相关政策,请先使用 load_policy_document 加载政策文档"}, + ensure_ascii=False, + ), + ) + ] + + # 2. 逐条匹配 + results = matcher.match_all_policies(user_info, policies) + + # 3. 按 verdict 排序 + verdict_order = {"likely_eligible": 0, "needs_review": 1, "needs_more_info": 2, "not_eligible": 3} + results.sort(key=lambda x: verdict_order.get(x["overall_verdict"], 99)) + + output = { + "total_policies": len(results), + "results": results, + } + return [TextContent(type="text", text=json.dumps(output, ensure_ascii=False, indent=2))] + + except Exception as e: + return [TextContent(type="text", text=json.dumps({"error": f"查询失败: {str(e)}"}, ensure_ascii=False))] + + +async def _handle_list_policies(arguments: Dict[str, Any]) -> List[TextContent]: + """列出政策""" + school = arguments.get("school") + category = arguments.get("category") + year = arguments.get("year") + + try: + entries = store.list_policies(school=school, category=category, year=year) + + # 为每个条目添加分类中文名 + for entry in entries: + entry["category_name"] = Config.CATEGORIES.get(entry.get("category", ""), entry.get("category", "")) + + summary = store.get_index_summary() + + output = { + "summary": summary, + "total": len(entries), + "policies": entries, + } + return [TextContent(type="text", text=json.dumps(output, ensure_ascii=False, indent=2))] + + except Exception as e: + return [TextContent(type="text", text=json.dumps({"error": f"列出失败: {str(e)}"}, ensure_ascii=False))] + + +async def _handle_clear(arguments: Dict[str, Any]) -> List[TextContent]: + """清空知识库""" + try: + result = store.clear() + return [TextContent(type="text", text=json.dumps({"success": True, "message": result}, ensure_ascii=False))] + except Exception as e: + return [TextContent(type="text", text=json.dumps({"error": f"清空失败: {str(e)}"}, ensure_ascii=False))] + + +# ============================================================ +# 启动 +# ============================================================ + + +async def main(): + """启动 MCP Server""" + logger.info("PolicySearch MCP Server 启动中...") + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/rag-mcp-server/.env.example b/rag-mcp-server/.env.example new file mode 100644 index 00000000000..0e645bb90da --- /dev/null +++ b/rag-mcp-server/.env.example @@ -0,0 +1,24 @@ +# DashScope API Key(必填) +# 获取地址:https://dashscope.console.aliyun.com/ +DASHSCOPE_API_KEY=your_api_key_here + +# Embedding 模型 +EMBEDDING_MODEL=text-embedding-v4 + +# ChromaDB 数据持久化目录 +CHROMA_PERSIST_DIR=./chroma_data + +# 文档分块参数(字符数) +CHUNK_SIZE=500 +CHUNK_OVERLAP=50 + +# 检索参数:默认返回条数 / 相似度下限(低于该值的结果不返回) +TOP_K=3 +SCORE_THRESHOLD=0.3 + +# Embedding 批大小与失败重试次数 +EMBED_BATCH_SIZE=10 +EMBED_MAX_RETRIES=3 + +# 日志级别(DEBUG/INFO/WARNING/ERROR),日志输出到 stderr +LOG_LEVEL=INFO diff --git a/rag-mcp-server/.gitignore b/rag-mcp-server/.gitignore new file mode 100644 index 00000000000..a60063c8afb --- /dev/null +++ b/rag-mcp-server/.gitignore @@ -0,0 +1,5 @@ +.env +chroma_data/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/rag-mcp-server/README.md b/rag-mcp-server/README.md new file mode 100644 index 00000000000..7179f6587e6 --- /dev/null +++ b/rag-mcp-server/README.md @@ -0,0 +1,191 @@ +# RAG MCP Server + +基于阿里云 DashScope + ChromaDB 的**检索型** RAG MCP Server:为 AionUi Agent(或其他 MCP 客户端)提供知识库语义检索能力。 + +> 设计原则:本 server 只做检索,**不内置 LLM 生成**。它返回相关文档块(原文 + 来源 + 页码 + 相似度分数),由调用方 Agent 自己的 LLM 基于这些材料生成答案。这样避免双倍 LLM 调用,也让 Agent 完全掌控上下文与引用格式。 + +## 设计亮点 + +**架构设计** + +- **检索与生成分离**:只返回文档块原文 + 来源 + 页码 + 相似度,不内置 LLM——避免双重 LLM 调用,Agent 完全掌控上下文与引用格式 +- **工具描述即防幻觉提示**:`search` 的工具描述直接约束下游 Agent:"结果为空说明没有相关内容,请直接告知用户,不要凭空编造,也不要盲目重试相同问题" + +**数据管理** + +- **幂等加载**:chunk ID 基于内容哈希(文件 sha256 + 块内容),配合 `upsert`——同一文件重复加载自动跳过,文件更新后重新加载自动替换旧版本,不产生重复数据 +- **Embedding 模型一致性校验**:知识库元数据记录构建时的 embedding 模型,启动时校验。更换模型会在初始化阶段报错,而不是检索时静默返回垃圾结果 +- **路径归一化**:文档路径统一 `normcase + normpath`,Windows 下大小写/分隔符写法不同的同一路径不会重复入库,删除也不会失效 +- **页码溯源贯穿全链路**:PDF 按页解析分块,每块携带 `source`/`page`/`chunk_index`,检索结果可直接定位到原始页码 + +**检索质量** + +- **中文感知分块**:自定义分隔符优先级(段落 → 换行 → 。!?;,),按中文标点断句而非默认英文规则 +- **相似度阈值过滤**:低于阈值的结果不返回,避免无关内容污染 Agent 上下文;全被过滤时返回 `best_similarity` 与 `hint`,帮 Agent 区分"知识库为空"与"没有相关内容" + +**工程健壮性** + +- **stdio 协议安全**:所有日志强制写 stderr,stdout 只传输 JSON-RPC,杜绝 print 污染协议流 +- **并发安全**:同步 I/O 经 `asyncio.to_thread` 进线程池,不阻塞事件循环;写操作持互斥锁 +- **边界处理细致**:Embedding 指数退避重试、分批向量化(DashScope 单批上限)、`top_k` 夹取防越界、扫描件/空文档明确报错而非静默成功 + +## 架构 + +``` +AionUi Agent(自带 LLM) + │ MCP 协议 (stdio) + ▼ +RAG MCP Server (Python, FastMCP) + ├─ load_document → 文档(PDF/TXT/MD/DOCX)→ 分块 → 向量化入库(幂等) + ├─ load_pdf → 同上,仅限 PDF(兼容保留) + ├─ search → 向量检索,返回文档块 + 分数 + 来源页码 + ├─ list_documents → 列出知识库中的文档 + ├─ delete_document → 删除单个文档 + └─ clear_knowledge_base → 清空知识库 + │ + ┌────┴─────────┐ + │ ChromaDB │ 向量数据库(本地持久化) + │ DashScope │ text-embedding-v4 向量化 + └──────────────┘ +``` + +## MCP 工具 + +| 工具 | 参数 | 说明 | +| ---------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `load_document` | `file_path` (string, 必填) | 加载文档,按扩展名识别格式(PDF / TXT / MD / DOCX)。幂等:同一文件重复加载自动跳过;文件内容更新后重新加载会替换旧版本 | +| `load_pdf` | `pdf_path` (string, 必填) | `load_document` 的 PDF 专用版(兼容保留) | +| `search` | `question` (string, 必填);`top_k` (integer, 可选) | 语义检索,返回 JSON:`results[]`(含 `text`/`source`/`page`/`similarity`),低于相似度阈值的结果被过滤 | +| `list_documents` | - | 列出所有文档的来源路径、块数、页码范围 | +| `delete_document` | `source` (string, 必填) | 按 `list_documents` 返回的完整路径删除单个文档 | +| `clear_knowledge_base` | - | 清空知识库(不可恢复) | + +`search` 返回示例: + +```json +{ + "results": [ + { + "text": "检索增强生成是一种结合检索与生成的技术……", + "source": "d:\\docs\\report.pdf", + "page": 3, + "similarity": 0.82, + "chunk_index": 0 + } + ], + "count": 1, + "error": null +} +``` + +> `page` 仅 PDF 有(1-based),TXT/MD/DOCX 无分页概念,该字段为 `null`。 +> `source` 为归一化后的路径(Windows 下统一小写与分隔符),路径匹配忽略大小写差异。 + +结果全被相似度阈值过滤时,`results` 为空并附带诊断字段,调用方 Agent 可据此判断"知识库为空"还是"没有相关内容": + +```json +{ + "results": [], + "count": 0, + "error": null, + "best_similarity": 0.18, + "hint": "检索到 3 个候选块,但最高相似度 0.18 仍低于阈值 0.3,已全部过滤。知识库中可能没有与该问题相关的内容。" +} +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +cd rag-mcp-server +pip install -r requirements.txt +``` + +### 2. 配置环境变量 + +```bash +cp .env.example .env +``` + +编辑 `.env`,填入 DashScope API Key([获取地址](https://dashscope.console.aliyun.com/))。 + +### 3. 启动 / 测试 + +```bash +python server.py # stdio 模式,等待 MCP 客户端连接 +``` + +用 MCP Inspector 交互测试: + +```bash +npx @modelcontextprotocol/inspector python server.py +``` + +运行单元测试(离线,不访问 DashScope): + +```bash +python -m pytest tests/ -v +``` + +## 在 AionUi 中配置 + +```json +{ + "mcpServers": { + "rag": { + "command": "python", + "args": ["D:/AI-Campus-Workspace/AionUi-Campus/rag-mcp-server/server.py"], + "cwd": "D:/AI-Campus-Workspace/AionUi-Campus/rag-mcp-server" + } + } +} +``` + +> API Key 从项目目录的 `.env` 读取,无需在 MCP 配置中明文传递。 + +## 配置说明 + +| 环境变量 | 默认值 | 说明 | +| -------------------- | ------------------- | --------------------------------------------------------------------------- | +| `DASHSCOPE_API_KEY` | - | DashScope API Key(必填) | +| `EMBEDDING_MODEL` | `text-embedding-v4` | Embedding 模型。**更换后需清空知识库重建**(server 启动时会校验模型一致性) | +| `CHROMA_PERSIST_DIR` | `./chroma_data` | ChromaDB 数据目录 | +| `CHUNK_SIZE` | `500` | 分块大小(字符数) | +| `CHUNK_OVERLAP` | `50` | 分块重叠(字符数) | +| `TOP_K` | `3` | `search` 默认返回条数 | +| `SCORE_THRESHOLD` | `0.3` | 相似度下限(cosine,0~1),低于该值的结果不返回 | +| `EMBED_BATCH_SIZE` | `10` | Embedding 单批条数(v4 上限 10) | +| `EMBED_MAX_RETRIES` | `3` | Embedding 调用失败重试次数 | +| `LOG_LEVEL` | `INFO` | 日志级别(日志输出到 stderr,不污染 stdio 协议) | + +## 技术栈 + +| 组件 | 技术 | +| ---------- | -------------------------------------------------------------- | +| MCP 协议 | mcp Python SDK(FastMCP) | +| PDF 解析 | pymupdf4llm(PDF → Markdown,保留结构,逐页带页码) | +| DOCX 解析 | python-docx(正文段落 + 表格文本) | +| 分块 | langchain RecursiveCharacterTextSplitter(中文句子感知分隔符) | +| Embedding | DashScope text-embedding-v4 | +| 向量数据库 | ChromaDB(cosine,本地持久化) | +| 测试 | pytest(embedding 用假实现,离线运行) | + +## 项目结构 + +``` +rag-mcp-server/ +├── server.py # MCP Server 入口(FastMCP 工具定义) +├── rag_engine.py # RAG 核心:解析、分块、向量化、检索、文档管理 +├── config.py # 配置管理(.env) +├── conftest.py # pytest 根配置 +├── tests/ # 单元测试(离线,假 embedding) +├── requirements.txt # Python 依赖 +├── .env.example # 环境变量模板 +├── .env # 实际配置(已 gitignore,自行创建) +└── chroma_data/ # ChromaDB 数据目录(自动生成) +``` + +## License + +MIT diff --git a/rag-mcp-server/config.py b/rag-mcp-server/config.py new file mode 100644 index 00000000000..20a8e8bb442 --- /dev/null +++ b/rag-mcp-server/config.py @@ -0,0 +1,36 @@ +"""配置管理模块""" + +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + """全局配置""" + + # DashScope API Key + DASHSCOPE_API_KEY: str = os.getenv("DASHSCOPE_API_KEY", "") + + # Embedding 模型 + EMBEDDING_MODEL: str = os.getenv("EMBEDDING_MODEL", "text-embedding-v4") + + # ChromaDB 持久化目录 + CHROMA_PERSIST_DIR: str = os.getenv("CHROMA_PERSIST_DIR", "./chroma_data") + + # 文档分块参数(字符数,供 RecursiveCharacterTextSplitter 使用) + CHUNK_SIZE: int = int(os.getenv("CHUNK_SIZE", "500")) + CHUNK_OVERLAP: int = int(os.getenv("CHUNK_OVERLAP", "50")) + + # 检索参数:默认返回条数与相似度下限(cosine similarity,低于该值的块不返回) + TOP_K: int = int(os.getenv("TOP_K", "3")) + SCORE_THRESHOLD: float = float(os.getenv("SCORE_THRESHOLD", "0.3")) + + # DashScope text-embedding-v4 单次请求条数上限 + EMBED_BATCH_SIZE: int = int(os.getenv("EMBED_BATCH_SIZE", "10")) + + # Embedding API 失败重试次数 + EMBED_MAX_RETRIES: int = int(os.getenv("EMBED_MAX_RETRIES", "3")) + + # 日志级别(日志一律写 stderr,不污染 stdio 协议流) + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") diff --git a/rag-mcp-server/conftest.py b/rag-mcp-server/conftest.py new file mode 100644 index 00000000000..574de456885 --- /dev/null +++ b/rag-mcp-server/conftest.py @@ -0,0 +1 @@ +"""pytest 根配置:conftest 所在目录会被加入 sys.path,测试可直接 import rag_engine/config/server""" diff --git a/rag-mcp-server/rag_engine.py b/rag-mcp-server/rag_engine.py new file mode 100644 index 00000000000..a851c50f99b --- /dev/null +++ b/rag-mcp-server/rag_engine.py @@ -0,0 +1,383 @@ +"""RAG 核心引擎 - 文档加载、分块、向量化、检索 + +设计要点: +- chunk ID 基于内容哈希 + upsert,重复/更新的文档不会静默丢失 +- 页码元数据贯穿始终(pymupdf4llm page_chunks,1-based;非 PDF 格式无页码) +- 检索返回相似度分数并按阈值过滤,全被过滤时附带提示与最高分供 Agent 决策 +- 文档路径统一归一化(normcase+normpath),避免 Windows 大小写/分隔符差异 + 导致同一文档重复入库或删除失效 +- 日志只写 stderr,绝不碰 stdout(stdio MCP 协议通道) +""" + +import hashlib +import json +import logging +import os +import threading +import time +from typing import Any + +import chromadb +import pymupdf4llm +from chromadb.config import Settings +from langchain_text_splitters import RecursiveCharacterTextSplitter + +from config import Config + +logger = logging.getLogger("rag_engine") + +# 中文感知的分块分隔符,按优先级递减 +_SEPARATORS = ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] + +# 支持的文档格式 +SUPPORTED_EXTENSIONS = frozenset({".pdf", ".txt", ".md", ".markdown", ".docx"}) + + +class RAGEngine: + """RAG 引擎:文档加载、分块、向量化与检索(不含 LLM 生成)""" + + COLLECTION_NAME = "documents" + + def __init__(self): + """初始化 ChromaDB 客户端、分块器与写入锁""" + self.chroma_client = chromadb.PersistentClient( + path=Config.CHROMA_PERSIST_DIR, settings=Settings(anonymized_telemetry=False) + ) + self.collection = self.chroma_client.get_or_create_collection( + name=self.COLLECTION_NAME, + metadata={"hnsw:space": "cosine", "embedding_model": Config.EMBEDDING_MODEL}, + ) + + # 若知识库是用其他 embedding 模型构建的,检索维度会不匹配,尽早报错 + stored_model = (self.collection.metadata or {}).get("embedding_model") + if stored_model and stored_model != Config.EMBEDDING_MODEL: + raise RuntimeError( + f"知识库由 embedding 模型 {stored_model!r} 构建,当前配置为 " + f"{Config.EMBEDDING_MODEL!r},向量维度可能不匹配。" + f"请清空知识库(clear_knowledge_base)或改回原模型。" + ) + + self._splitter = RecursiveCharacterTextSplitter( + chunk_size=Config.CHUNK_SIZE, + chunk_overlap=Config.CHUNK_OVERLAP, + separators=_SEPARATORS, + ) + # Chroma 写操作互斥(工具经 asyncio.to_thread 在线程池并发执行) + self._write_lock = threading.Lock() + + logger.info("RAGEngine 初始化完成,当前文档块数量: %d", self.collection.count()) + + # ------------------------------------------------------------------ # + # 文档加载 + # ------------------------------------------------------------------ # + + def load_document(self, file_path: str) -> str: + """加载文档(PDF/TXT/MD/DOCX):解析 -> 分块 -> 向量化 -> upsert 入库 + + 同一路径同一内容: 跳过(提示未变化);同一路径内容有更新: 先删旧块再入库。 + """ + if not file_path or not file_path.strip(): + return "错误: file_path 不能为空" + path = _normalize_source(file_path) + if not _has_supported_extension(path): + supported = "、".join(sorted(SUPPORTED_EXTENSIONS)) + return f"错误: 不支持的文件格式 - {path}(支持: {supported})" + + file_hash = _sha256_file(path) + logger.info("开始加载文档: %s (sha256=%s)", path, file_hash[:12]) + + with self._write_lock: + existing = self._chunks_of_source(path) + if existing: + unchanged = all(m.get("doc_id") == file_hash for m, _ in existing) + if unchanged: + logger.info("文档未变化,跳过: %s", path) + return ( + f"文档已存在于知识库且内容未变化,共 {len(existing)} 个文档块," + f"本次跳过: {_display_name(path)}" + ) + # 内容有更新: 删除旧版本全部块(含历史路径写法不同的残留版本) + self._delete_by_source(path) + logger.info("检测到文档更新,已删除旧版本 %d 个块", len(existing)) + + n_chunks = self._ingest(path, file_hash) + + return ( + f"成功加载文档: {_display_name(path)},共 {n_chunks} 个文档块," + f"知识库当前总计 {self.collection.count()} 个块" + ) + + def _ingest(self, path: str, file_hash: str) -> int: + """解析文档、逐页分块、批量向量化并 upsert。调用方需持有 _write_lock。""" + ids: list[str] = [] + embeddings: list[list[float]] = [] + metadatas: list[dict[str, Any]] = [] + documents: list[str] = [] + + for page_no, page_text in _extract_pages(path): + if not page_text: + continue + for idx, chunk in enumerate(self._splitter.split_text(page_text)): + chunk = chunk.strip() + if not chunk: + continue + ids.append(_chunk_id(path, file_hash, chunk)) + metadata = {"source": path, "doc_id": file_hash, "chunk_index": idx} + if page_no is not None: # ChromaDB 元数据不支持 null,无页码格式直接省略 + metadata["page"] = page_no + metadatas.append(metadata) + documents.append(chunk) + + if not documents: + raise ValueError( + f"无法从文档提取到文本: {_display_name(path)}(PDF 可能是扫描件,或文档为空)" + ) + + # 分批向量化(text-embedding-v4 单次最多 10 条) + for i in range(0, len(documents), Config.EMBED_BATCH_SIZE): + batch = documents[i : i + Config.EMBED_BATCH_SIZE] + embeddings.extend(_embed_batch(batch)) + + self.collection.upsert(ids=ids, embeddings=embeddings, metadatas=metadatas, documents=documents) + logger.info("入库完成: %s, %d 个块", _display_name(path), len(documents)) + return len(documents) + + # ------------------------------------------------------------------ # + # 检索 + # ------------------------------------------------------------------ # + + def search(self, question: str, top_k: int | None = None) -> dict[str, Any]: + """向量检索知识库,返回带相似度分数的文档块(低于阈值的结果被过滤)""" + question = (question or "").strip() + if not question: + return {"results": [], "count": 0, "error": "问题不能为空"} + if self.collection.count() == 0: + return {"results": [], "count": 0, "error": "知识库为空,请先使用 load_document 加载文档"} + + k = max(1, min(top_k or Config.TOP_K, 20)) + query_embedding = _embed_batch([question])[0] + + raw = self.collection.query( + query_embeddings=[query_embedding], n_results=min(k, self.collection.count()) + ) + + results: list[dict[str, Any]] = [] + similarities: list[float] = [] + metadatas = (raw.get("metadatas") or [[]])[0] + documents = (raw.get("documents") or [[]])[0] + distances = (raw.get("distances") or [[]])[0] + + for metadata, text, distance in zip(metadatas, documents, distances): + similarity = round(1.0 - float(distance), 4) # cosine distance -> similarity + similarities.append(similarity) + if similarity < Config.SCORE_THRESHOLD: + continue + results.append( + { + "text": text, + "source": metadata.get("source", ""), + "page": metadata.get("page"), + "similarity": similarity, + "chunk_index": metadata.get("chunk_index"), + } + ) + + results.sort(key=lambda r: r["similarity"], reverse=True) + payload: dict[str, Any] = {"results": results, "count": len(results), "error": None} + if not results and similarities: + # 有候选但全被阈值过滤:附上最高分,帮调用方区分"不相关"与"库为空" + best = max(similarities) + payload["best_similarity"] = best + payload["hint"] = ( + f"检索到 {len(similarities)} 个候选块,但最高相似度 {best} 仍低于阈值 " + f"{Config.SCORE_THRESHOLD},已全部过滤。知识库中可能没有与该问题相关的内容。" + ) + return payload + + # ------------------------------------------------------------------ # + # 文档管理 + # ------------------------------------------------------------------ # + + def list_documents(self) -> dict[str, Any]: + """列出知识库中的所有文档及其块数、页码范围""" + if self.collection.count() == 0: + return {"documents": [], "count": 0} + + all_meta = self.collection.get(include=["metadatas"]).get("metadatas") or [] + docs: dict[str, dict[str, Any]] = {} + for m in all_meta: + source = m.get("source", "") + entry = docs.setdefault( + source, + {"source": source, "doc_id": m.get("doc_id", ""), "chunks": 0, "pages": set()}, + ) + entry["chunks"] += 1 + if m.get("page") is not None: + entry["pages"].add(m["page"]) + + documents = [ + {**d, "pages": sorted(d["pages"])} for d in sorted(docs.values(), key=lambda x: x["source"]) + ] + return {"documents": documents, "count": len(documents)} + + def delete_document(self, source: str) -> str: + """按源文件路径删除单个文档的所有块(路径匹配忽略大小写与分隔符差异)""" + source = (source or "").strip() + if not source: + return "错误: source 不能为空(可先用 list_documents 查看准确的文件路径)" + + with self._write_lock: + existing = self._chunks_of_source(source) + if not existing: + return f"知识库中不存在该文档: {source}" + self._delete_by_source(source) + + return f"已删除文档 {_display_name(source)},共 {len(existing)} 个文档块" + + def clear(self) -> str: + """清空知识库(删除并重建集合)""" + with self._write_lock: + try: + self.chroma_client.delete_collection(self.COLLECTION_NAME) + except Exception: # 集合不存在时忽略 + logger.debug("集合不存在,无需删除", exc_info=True) + self.collection = self.chroma_client.get_or_create_collection( + name=self.COLLECTION_NAME, + metadata={"hnsw:space": "cosine", "embedding_model": Config.EMBEDDING_MODEL}, + ) + logger.info("知识库已清空") + return "知识库已清空" + + # ------------------------------------------------------------------ # + # 内部工具 + # ------------------------------------------------------------------ # + + def _chunks_of_source(self, source: str) -> list[tuple[dict[str, Any], str]]: + """取某一路径下现存的所有块(归一化匹配),返回 [(metadata, id), ...] + + 全量扫描而非 where 精确过滤:ChromaDB 元数据过滤只支持字符串精确匹配, + 无法兼容历史数据中大小写/分隔符不同的同一路径。个人知识库规模下可接受。 + """ + target = _normalize_source(source) + got = self.collection.get(include=["metadatas"]) + return [ + (m, i) + for m, i in zip(got.get("metadatas") or [], got.get("ids") or []) + if _normalize_source(m.get("source", "")) == target + ] + + def _delete_by_source(self, source: str) -> None: + """删除某一路径下所有块。ChromaDB 按存储值过滤,需逐个历史写法删除。""" + for stored in {m.get("source", "") for m, _ in self._chunks_of_source(source)}: + self.collection.delete(where={"source": stored}) + + +# ---------------------------------------------------------------------- # +# 模块级工具函数(不依赖实例状态,便于单测) +# ---------------------------------------------------------------------- # + + +def _normalize_source(path: str) -> str: + """归一化文档路径:统一分隔符、折叠 .与..,Windows 下额外忽略大小写 + + ChromaDB 元数据过滤是精确字符串匹配,不归一化的话同一文件换个写法 + (D:/a.pdf vs d:\a.PDF)会被当成两个文档,产生重复入库或删除失效。 + """ + return os.path.normcase(os.path.normpath(path.strip())) + + +def _has_supported_extension(path: str) -> bool: + """按扩展名粗判格式(详细校验交给各解析器)""" + return os.path.splitext(path)[1].lower() in SUPPORTED_EXTENSIONS + + +def _sha256_file(path: str) -> str: + """计算文件内容 sha256,用于文档去重与更新检测""" + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(1 << 20), b""): + h.update(block) + return h.hexdigest() + + +def _chunk_id(source: str, file_hash: str, chunk: str) -> str: + """内容寻址的 chunk ID:同内容同 ID(配合 upsert 幂等),不同路径互不干扰""" + digest = hashlib.sha256(f"{source}|{file_hash}|{chunk}".encode("utf-8")).hexdigest() + return f"{file_hash[:12]}_{digest[:32]}" + + +def _display_name(path: str) -> str: + return os.path.basename(path) + + +def _extract_pages(path: str) -> list[tuple[int | None, str]]: + """按扩展名解析文档,返回 [(page_no, text), ...] + + page_no 为 1-based 页码,仅 PDF 有分页概念;其余格式返回 None + (ChromaDB 元数据不支持 null,入库时会省略该键)。 + """ + ext = os.path.splitext(path)[1].lower() + if ext == ".pdf": + return _extract_pdf(path) + if ext == ".docx": + return [(None, _extract_docx(path))] + return [(None, _read_text(path))] + + +def _extract_pdf(path: str) -> list[tuple[int | None, str]]: + """PDF -> Markdown,逐页返回(pymupdf4llm 的 page_number 已是 1 基)""" + pages = pymupdf4llm.to_markdown(path, page_chunks=True) + return [ + (int(item["metadata"].get("page_number", 1)), (item.get("text") or "").strip()) + for item in pages + ] + + +def _extract_docx(path: str) -> str: + """DOCX -> 纯文本:正文段落按行拼接,表格逐行用 | 连接(python-docx 延迟导入)""" + from docx import Document + + doc = Document(path) + parts = [p.text.strip() for p in doc.paragraphs if p.text.strip()] + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + return "\n".join(parts) + + +def _read_text(path: str) -> str: + """读取纯文本文件,编码依次尝试 utf-8-sig(BOM)/utf-8/gbk(兼容中文 Windows 文件)""" + with open(path, "rb") as f: + raw = f.read() + for encoding in ("utf-8-sig", "utf-8", "gbk"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + raise ValueError(f"无法识别文件编码(已尝试 utf-8/gbk): {_display_name(path)}") + + +def _embed_batch(texts: list[str]) -> list[list[float]]: + """调用 DashScope embedding,带指数退避重试""" + from dashscope import TextEmbedding + + last_error: Exception | None = None + for attempt in range(Config.EMBED_MAX_RETRIES): + try: + response = TextEmbedding.call(model=Config.EMBEDDING_MODEL, input=texts) + if response.status_code != 200: + raise RuntimeError(f"Embedding API 返回 {response.status_code}: {response.message}") + return [item["embedding"] for item in response.output["embeddings"]] + except Exception as e: # noqa: BLE001 - 统一记日志后重试 + last_error = e + wait = 2**attempt + logger.warning("Embedding 调用失败(第 %d 次): %s,%ds 后重试", attempt + 1, e, wait) + time.sleep(wait) + raise RuntimeError(f"Embedding API 连续 {Config.EMBED_MAX_RETRIES} 次调用失败: {last_error}") + + +def results_to_text(payload: dict[str, Any]) -> str: + """把检索结果序列化为给 MCP 客户端阅读的 JSON 文本""" + return json.dumps(payload, ensure_ascii=False, indent=2) diff --git a/rag-mcp-server/requirements.txt b/rag-mcp-server/requirements.txt new file mode 100644 index 00000000000..ac71881e02e --- /dev/null +++ b/rag-mcp-server/requirements.txt @@ -0,0 +1,8 @@ +mcp>=1.0.0,<2.0.0 +dashscope>=1.20.0 +chromadb>=1.0,<2.0 +pymupdf4llm>=0.0.27 +langchain-text-splitters>=0.3.0 +python-dotenv>=1.0.0 +python-docx>=1.1.0 +pytest>=8.0 diff --git a/rag-mcp-server/server.py b/rag-mcp-server/server.py new file mode 100644 index 00000000000..21c495cd62f --- /dev/null +++ b/rag-mcp-server/server.py @@ -0,0 +1,129 @@ +"""RAG MCP Server - 通过 MCP 协议向 Agent 暴露知识库检索能力 + +本 server 是检索型 (retrieval-only) 工具: 只负责返回相关文档块 +(text + 来源 + 页码 + 相似度分数),答案由调用方 Agent 自己的 LLM 生成。 + +注意: stdio 模式下 stdout 是 JSON-RPC 协议通道,任何 print 都会污染协议流, +因此这里统一使用 logging 输出到 stderr。 +""" + +import asyncio +import json +import logging +import sys + +from mcp.server.fastmcp import FastMCP + +from config import Config +from rag_engine import RAGEngine, results_to_text + +# 日志必须先于任何组件初始化配置,且只写 stderr +logging.basicConfig( + stream=sys.stderr, + level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s %(name)s %(levelname)s %(message)s", +) +logger = logging.getLogger("rag_server") + +engine = RAGEngine() +mcp = FastMCP("rag-mcp-server") + + +def _err_text(payload_or_message: object) -> str: + """统一错误输出格式""" + if isinstance(payload_or_message, dict): + return json.dumps(payload_or_message, ensure_ascii=False, indent=2) + return f"错误: {payload_or_message}" + + +@mcp.tool() +async def load_pdf(pdf_path: str) -> str: + """加载本地 PDF 文件到知识库:解析为 Markdown、按句子智能分块、向量化入库。 + + 幂等: 同一文件重复加载会自动跳过;文件内容更新后重新加载会替换旧版本。 + 仅支持 PDF;其他格式请使用 load_document。 + """ + try: + if not pdf_path.lower().endswith(".pdf"): + return "错误: load_pdf 仅支持 PDF 文件,其他格式请使用 load_document 工具" + return await asyncio.to_thread(engine.load_document, pdf_path) + except Exception as e: + logger.exception("load_pdf 失败") + return _err_text(f"加载失败 - {e}") + + +@mcp.tool() +async def load_document(file_path: str) -> str: + """加载本地文档到知识库,支持 PDF / TXT / MD / DOCX,按扩展名自动识别。 + + PDF 解析为 Markdown 并记录页码;TXT/MD 按原编码读取(utf-8/gbk 自动尝试); + DOCX 提取正文段落与表格文本。以上格式统一分块后向量化入库。 + + 幂等: 同一文件重复加载会自动跳过;文件内容更新后重新加载会替换旧版本。 + """ + try: + return await asyncio.to_thread(engine.load_document, file_path) + except Exception as e: + logger.exception("load_document 失败") + return _err_text(f"加载失败 - {e}") + + +@mcp.tool() +async def search(question: str, top_k: int | None = None) -> str: + """在知识库中检索与问题最相关的文档块(向量语义检索)。 + + 返回 JSON: results 数组,每项含 text(文档块原文)、source(来源文件路径)、 + page(页码, 1-based,仅 PDF 有,其他格式为 null)、similarity(相似度 0~1)、chunk_index。 + + 相似度低于阈值的结果已被过滤。若 results 为空且响应含 best_similarity 与 hint 字段, + 说明知识库非空但没有与问题相关的内容(hint 中给出了最高候选相似度), + 此时请直接告知用户未找到相关内容,不要凭空编造,也不要盲目重试相同问题。 + 请基于返回的文档块原文回答用户问题,并注明来源文件与页码。 + """ + try: + payload = await asyncio.to_thread(engine.search, question, top_k) + if payload.get("error"): + return _err_text(payload) + return results_to_text(payload) + except Exception as e: + logger.exception("search 失败") + return _err_text(f"检索失败 - {e}") + + +@mcp.tool() +async def list_documents() -> str: + """列出知识库中的所有文档(来源路径、文档块数量、页码范围)。""" + try: + return await asyncio.to_thread(lambda: results_to_text(engine.list_documents())) + except Exception as e: + logger.exception("list_documents 失败") + return _err_text(f"列举失败 - {e}") + + +@mcp.tool() +async def delete_document(source: str) -> str: + """从知识库中删除单个文档的全部内容。source 必须是 list_documents 返回的完整路径。""" + try: + return await asyncio.to_thread(engine.delete_document, source) + except Exception as e: + logger.exception("delete_document 失败") + return _err_text(f"删除失败 - {e}") + + +@mcp.tool() +async def clear_knowledge_base() -> str: + """清空知识库中的所有文档(危险操作,不可恢复)。""" + try: + return await asyncio.to_thread(engine.clear) + except Exception as e: + logger.exception("clear_knowledge_base 失败") + return _err_text(f"清空失败 - {e}") + + +def main() -> None: + logger.info("RAG MCP Server 启动 (stdio)") + mcp.run() # 默认 stdio transport + + +if __name__ == "__main__": + main() diff --git a/rag-mcp-server/tests/test_rag_engine.py b/rag-mcp-server/tests/test_rag_engine.py new file mode 100644 index 00000000000..509f55970f2 --- /dev/null +++ b/rag-mcp-server/tests/test_rag_engine.py @@ -0,0 +1,236 @@ +"""rag_engine / server 工具层核心单元测试 + +embedding 使用字符频率假实现(L2 归一化),文本越相似余弦越高, +因此所有测试均不访问 DashScope 网络,可离线运行。 +""" + +import asyncio +import hashlib +import json +import math +import os + +import pytest + +import rag_engine +from config import Config +from rag_engine import ( + SUPPORTED_EXTENSIONS, + RAGEngine, + _chunk_id, + _has_supported_extension, + _normalize_source, + _read_text, + _sha256_file, + results_to_text, +) + +_DIM = 64 + +# 纯英文样例文本,避免与中文标点类字符混淆相似度判断 +ML_DOC = "Machine learning studies algorithms that improve through experience. " * 8 + + +def _fake_embed_batch(texts: list[str]) -> list[list[float]]: + """字符频率向量:查询字符都出现在文档中时余弦较高,完全不相交时为 0""" + vectors = [] + for text in texts: + v = [0.0] * _DIM + for ch in text: + v[ord(ch) % _DIM] += 1.0 + norm = math.sqrt(sum(x * x for x in v)) or 1.0 + vectors.append([x / norm for x in v]) + return vectors + + +@pytest.fixture +def engine(tmp_path, monkeypatch): + """独立 Chroma 目录 + 假 embedding 的引擎实例""" + monkeypatch.setattr(Config, "CHROMA_PERSIST_DIR", str(tmp_path / "chroma")) + monkeypatch.setattr(rag_engine, "_embed_batch", _fake_embed_batch) + return RAGEngine() + + +def _write(tmp_path, name: str, content: str, encoding: str = "utf-8") -> str: + path = tmp_path / name + path.write_text(content, encoding=encoding) + return str(path) + + +# ---------------------------------------------------------------------- # +# 纯函数 +# ---------------------------------------------------------------------- # + + +def test_normalize_source_unifies_separators_and_dots(): + assert _normalize_source(" D://a//x/./b.pdf ") == _normalize_source("D:/a/x/b.pdf") + + +@pytest.mark.skipif(os.name != "nt", reason="仅 Windows 文件路径不区分大小写") +def test_normalize_source_case_insensitive_on_windows(): + assert _normalize_source("D:\\Docs\\A.PDF") == _normalize_source("d:/docs/a.pdf") + + +def test_has_supported_extension(): + for ext in sorted(SUPPORTED_EXTENSIONS): + assert _has_supported_extension(f"a{ext}") + assert not _has_supported_extension("a.doc") # 旧版 .doc 不支持 + assert not _has_supported_extension("a.exe") + + +def test_sha256_file(tmp_path): + path = tmp_path / "f.bin" + path.write_bytes(b"hello") + assert _sha256_file(str(path)) == hashlib.sha256(b"hello").hexdigest() + + +def test_chunk_id_deterministic_and_content_sensitive(): + same_1 = _chunk_id("s.pdf", "h1", "文本") + same_2 = _chunk_id("s.pdf", "h1", "文本") + other = _chunk_id("s.pdf", "h1", "别的") + assert same_1 == same_2 + assert same_1 != other + assert same_1.startswith("h1_") + + +@pytest.mark.parametrize( + ("content", "encoding"), + [("中文内容", "utf-8"), ("中文内容", "utf-8-sig"), ("中文内容", "gbk")], +) +def test_read_text_encoding_fallback(tmp_path, content, encoding): + path = tmp_path / "f.txt" + path.write_bytes(content.encode(encoding)) + assert _read_text(str(path)) == content + + +def test_results_to_text_keeps_chinese_readable(): + payload = {"results": [{"text": "检索增强生成"}], "count": 1, "error": None} + text = results_to_text(payload) + assert "检索增强生成" in text # ensure_ascii=False + assert json.loads(text) == payload + + +# ---------------------------------------------------------------------- # +# 加载与去重 +# ---------------------------------------------------------------------- # + + +def test_load_txt_dedupe_and_search(engine, tmp_path): + path = _write(tmp_path, "notes.txt", ML_DOC) + + assert "成功加载文档" in engine.load_document(path) + count_after_first = engine.collection.count() + assert count_after_first > 0 + + assert "跳过" in engine.load_document(path) + assert engine.collection.count() == count_after_first + + payload = engine.search("Machine learning studies algorithms") + assert payload["error"] is None + assert payload["count"] > 0 + top = payload["results"][0] + assert top["similarity"] >= Config.SCORE_THRESHOLD + assert top["page"] is None # 非 PDF 格式无页码 + assert top["source"] == _normalize_source(path) + + +def test_load_update_replaces_old_chunks(engine, tmp_path): + path = _write(tmp_path, "doc.md", "alpha release note with old content") + engine.load_document(path) + docs = engine.collection.get(include=["documents"])["documents"] + assert any("alpha" in d for d in docs) + + _write(tmp_path, "doc.md", "beta release note with new content") + assert "成功加载文档" in engine.load_document(path) + + docs = engine.collection.get(include=["documents"])["documents"] + assert not any("alpha" in d for d in docs) + assert any("beta" in d for d in docs) + + +def test_load_rejects_bad_input(engine, tmp_path): + assert "不能为空" in engine.load_document(" ") + assert "不支持的文件格式" in engine.load_document(str(tmp_path / "x.exe")) + + +def test_load_docx_paragraphs_and_tables(engine, tmp_path): + docx = pytest.importorskip("docx") + path = tmp_path / "report.docx" + document = docx.Document() + document.add_paragraph("docx paragraph about retrieval augmented generation") + table = document.add_table(rows=1, cols=2) + table.rows[0].cells[0].text = "表格" + table.rows[0].cells[1].text = "内容" + document.save(str(path)) + + assert "成功加载文档" in engine.load_document(str(path)) + joined = "".join(engine.collection.get(include=["documents"])["documents"]) + assert "retrieval augmented generation" in joined + assert "表格" in joined and "内容" in joined + + +@pytest.mark.skipif(os.name != "nt", reason="仅 Windows 文件路径不区分大小写") +def test_windows_case_insensitive_dedupe_and_delete(engine, tmp_path): + path = _write(tmp_path, "Doc.TXT", "windows case test content") + alt = path.upper() # 整条路径改大小写,指向同一文件 + + assert "成功加载文档" in engine.load_document(path) + assert "跳过" in engine.load_document(alt) # 大小写不同不会重复入库 + + assert "已删除文档" in engine.delete_document(alt) + assert engine.collection.count() == 0 + + +# ---------------------------------------------------------------------- # +# 检索 +# ---------------------------------------------------------------------- # + + +def test_search_hint_when_all_filtered(engine, tmp_path): + path = _write(tmp_path, "doc.md", ML_DOC) + engine.load_document(path) + + assert engine.search("")["error"] == "问题不能为空" + + payload = engine.search("zzzqqq") # z/q 不在文档中,相似度恒为 0 + assert payload["error"] is None + assert payload["count"] == 0 + assert payload["best_similarity"] < Config.SCORE_THRESHOLD + assert "已全部过滤" in payload["hint"] + + +# ---------------------------------------------------------------------- # +# 文档管理 +# ---------------------------------------------------------------------- # + + +def test_list_and_delete_roundtrip(engine, tmp_path): + p1 = _write(tmp_path, "a.txt", "alpha document content") + p2 = _write(tmp_path, "b.txt", "beta document content") + engine.load_document(p1) + engine.load_document(p2) + + listing = engine.list_documents() + assert listing["count"] == 2 + assert {d["source"] for d in listing["documents"]} == { + _normalize_source(p1), + _normalize_source(p2), + } + + assert "已删除文档" in engine.delete_document(_normalize_source(p1)) + assert engine.list_documents()["count"] == 1 + assert engine.delete_document(p1) == f"知识库中不存在该文档: {p1}" + + +# ---------------------------------------------------------------------- # +# MCP 工具层(server.py 的入参校验与调度) +# ---------------------------------------------------------------------- # + + +def test_server_tool_validation(tmp_path, monkeypatch): + monkeypatch.setattr(Config, "CHROMA_PERSIST_DIR", str(tmp_path / "chroma")) + monkeypatch.setattr(rag_engine, "_embed_batch", _fake_embed_batch) + import server # 引擎初始化需在 Config 打补丁之后 + + assert "不支持的文件格式" in asyncio.run(server.load_document(str(tmp_path / "x.exe"))) + assert "仅支持 PDF" in asyncio.run(server.load_pdf(str(tmp_path / "a.txt"))) From 6747f15c8a22d9ce09e34dbaefa643bb2280baba Mon Sep 17 00:00:00 2001 From: YimiSuper Date: Wed, 9 Sep 2026 14:30:31 +0800 Subject: [PATCH 06/10] feat(policy-search): enhance policy search module with improved parsing and LLM integration (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过并进行合并 --- policy-search/llm_client.py | 56 +++--- policy-search/policy_parser.py | 311 ++++++++++++++++++++++++--------- policy-search/requirements.txt | 4 + policy-search/server.py | 47 +++-- 4 files changed, 291 insertions(+), 127 deletions(-) diff --git a/policy-search/llm_client.py b/policy-search/llm_client.py index 81910d84feb..5e5f00c14be 100644 --- a/policy-search/llm_client.py +++ b/policy-search/llm_client.py @@ -1,25 +1,32 @@ -"""LLM 客户端 - 调用通义千问""" +"""LLM 客户端 - 调用通义千问(通过 DashScope OpenAI 兼容模式)""" import sys import time import logging from typing import Optional -import dashscope -from dashscope import Generation +from openai import OpenAI from config import Config # 日志输出到 stderr logger = logging.getLogger(__name__) +# DashScope OpenAI 兼容 endpoint +# 注意:推理模型(如 qwen3.7-plus)不支持原生 SDK endpoint(会返回 400 url error), +# 必须通过兼容模式调用 +COMPATIBLE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" + class LLMClient: - """通义千问 LLM 客户端""" + """通义千问 LLM 客户端(OpenAI 兼容模式)""" def __init__(self): """初始化 LLM 客户端""" - dashscope.api_key = Config.DASHSCOPE_API_KEY self.model = Config.LLM_MODEL - logger.info(f"LLMClient 初始化完成,使用模型: {self.model}") + self.client = OpenAI( + api_key=Config.DASHSCOPE_API_KEY, + base_url=COMPATIBLE_BASE_URL, + ) + logger.info(f"LLMClient 初始化完成,使用模型: {self.model} (兼容模式)") def generate(self, system_prompt: str, user_prompt: str, timeout: int = 120) -> dict: """ @@ -35,42 +42,23 @@ def generate(self, system_prompt: str, user_prompt: str, timeout: int = 120) -> """ try: logger.info(f"LLM 调用开始 (model={self.model}, prompt长度={len(user_prompt)})") - response = Generation.call( + response = self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], - result_format="message", + # 关闭思考模式:加快响应、降低 token 消耗,且输出更干净(直接给 JSON) + extra_body={"enable_thinking": False}, timeout=timeout, ) - if response.status_code != 200: - logger.error(f"LLM API 调用失败 (status={response.status_code}): {response.message}") - return { - "content": f"LLM API 调用失败 (status={response.status_code}): {response.message}", - "success": False, - } - - # 安全检查:确保 output、choices 存在且非空 - output = getattr(response, "output", None) - if output is None: - logger.error("LLM 返回 output 为 None") - return {"content": "LLM 返回 output 为 None", "success": False} - - choices = getattr(output, "choices", None) - if not choices: - logger.error(f"LLM 返回 choices 为空 (choices={choices!r})") - return {"content": f"LLM 返回 choices 为空", "success": False} - - first_choice = choices[0] - message = getattr(first_choice, "message", None) - if message is None: - logger.error("LLM 返回 message 为 None") - return {"content": "LLM 返回 message 为 None", "success": False} - - content = getattr(message, "content", "") - if not content: + if not response.choices: + logger.error("LLM 返回 choices 为空") + return {"content": "LLM 返回 choices 为空", "success": False} + + content = response.choices[0].message.content or "" + if not content.strip(): logger.warning("LLM 返回 content 为空字符串") return {"content": "", "success": False} diff --git a/policy-search/policy_parser.py b/policy-search/policy_parser.py index 2af5ff567bc..79c526162c0 100644 --- a/policy-search/policy_parser.py +++ b/policy-search/policy_parser.py @@ -16,7 +16,7 @@ class PolicyParser: - """政策文档解析器:从 PDF/文本中提取结构化政策信息""" + """政策文档解析器:从 PDF/DOCX/HTML/Excel/文本中提取结构化政策信息""" def __init__(self): """初始化解析器""" @@ -72,7 +72,7 @@ def sanitize_text(self, text: str) -> str: def load_document(self, document_path: str) -> str: """ - 读取文档内容(PDF 或纯文本) + 读取文档内容(PDF、DOCX、HTML、Excel 或纯文本) Returns: 文档全文文本(已清理编码) @@ -87,8 +87,19 @@ def load_document(self, document_path: str) -> str: elif ext in (".txt", ".md", ".text"): # 尝试多种编码读取文本文件 raw_text = self._read_text_file(document_path) + elif ext == ".docx": + raw_text = self._extract_docx_text(document_path) + elif ext in (".html", ".htm"): + raw_text = self._extract_html_text(document_path) + elif ext in (".xlsx", ".xls"): + raw_text = self._extract_excel_text(document_path) + elif ext == ".doc": + raise ValueError( + f"不支持 .doc 格式(旧版 Word 二进制格式),请将文件另存为 .docx 后重新上传。" + f"\n提示:在 Word 中打开文件 → 文件 → 另存为 → 选择 .docx 格式" + ) else: - raise ValueError(f"不支持的文件格式: {ext},仅支持 .pdf, .txt, .md") + raise ValueError(f"不支持的文件格式: {ext},仅支持 .pdf, .docx, .html, .xlsx, .txt, .md") # 清理并规范化文本 return self.sanitize_text(raw_text) @@ -131,6 +142,139 @@ def _extract_pdf_text(self, pdf_path: str) -> str: raise ValueError(f"无法从 PDF 中提取文本: {pdf_path}") + def _extract_docx_text(self, docx_path: str) -> str: + """ + 从 .docx 文件提取文本 + + 使用 python-docx 库,按段落顺序提取全部文本内容。 + """ + try: + from docx import Document + doc = Document(docx_path) + text_parts = [] + + # 提取正文段落 + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + + # 提取表格内容(政策文件常将条件放在表格中) + for table in doc.tables: + for row in table.rows: + row_cells = [] + for cell in row.cells: + cell_text = cell.text.strip() + if cell_text: + row_cells.append(cell_text) + if row_cells: + text_parts.append(" | ".join(row_cells)) + + full_text = "\n\n".join(text_parts) + if full_text.strip(): + logger.info(f"docx 提取成功,文本长度: {len(full_text)}") + return full_text + else: + raise ValueError(f"从 docx 文件中未提取到有效文本: {docx_path}") + except ImportError: + raise ImportError( + "python-docx 未安装,无法解析 .docx 文件。" + "请运行: pip install python-docx" + ) + except Exception as e: + raise ValueError(f"无法从 docx 文件中提取文本: {docx_path},错误: {e}") + + def _extract_html_text(self, html_path: str) -> str: + """ + 从 HTML 文件提取文本 + + 使用 BeautifulSoup 提取网页文本,保留段落结构。 + """ + try: + from bs4 import BeautifulSoup + + # 尝试多种编码读取 + encodings = ['utf-8', 'utf-8-sig', 'gbk', 'gb2312', 'gb18030', 'latin-1'] + html_content = None + for enc in encodings: + try: + with open(html_path, 'r', encoding=enc) as f: + html_content = f.read() + break + except (UnicodeDecodeError, UnicodeError): + continue + + if not html_content: + with open(html_path, 'r', encoding='utf-8', errors='replace') as f: + html_content = f.read() + + soup = BeautifulSoup(html_content, 'html.parser') + + # 移除 script 和 style 标签 + for script in soup(['script', 'style']): + script.decompose() + + # 提取文本,保留段落结构 + text_parts = [] + for element in soup.find_all(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'th']): + text = element.get_text(strip=True) + if text: + text_parts.append(text) + + full_text = "\n\n".join(text_parts) + + if full_text.strip(): + logger.info(f"HTML 提取成功,文本长度: {len(full_text)}") + return full_text + else: + raise ValueError(f"从 HTML 文件中未提取到有效文本: {html_path}") + + except ImportError: + raise ImportError( + "beautifulsoup4 未安装,无法解析 HTML 文件。" + "请运行: pip install beautifulsoup4" + ) + except Exception as e: + raise ValueError(f"无法从 HTML 文件中提取文本: {html_path},错误: {e}") + + def _extract_excel_text(self, excel_path: str) -> str: + """ + 从 Excel 文件提取文本 + + 使用 openpyxl 提取所有工作表的表格数据,按行组织。 + """ + try: + from openpyxl import load_workbook + + wb = load_workbook(excel_path, read_only=True, data_only=True) + text_parts = [] + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + text_parts.append(f"=== {sheet_name} ===") + + for row in ws.iter_rows(values_only=True): + # 过滤空行 + row_cells = [str(cell).strip() for cell in row if cell is not None] + if row_cells: + text_parts.append(" | ".join(row_cells)) + + wb.close() + full_text = "\n".join(text_parts) + + if full_text.strip(): + logger.info(f"Excel 提取成功,文本长度: {len(full_text)}") + return full_text + else: + raise ValueError(f"从 Excel 文件中未提取到有效文本: {excel_path}") + + except ImportError: + raise ImportError( + "openpyxl 未安装,无法解析 Excel 文件。" + "请运行: pip install openpyxl" + ) + except Exception as e: + raise ValueError(f"无法从 Excel 文件中提取文本: {excel_path},错误: {e}") + def _extract_with_pypdf2(self, pdf_path: str) -> str: """使用 PyPDF2 提取 PDF 文本""" try: @@ -174,6 +318,7 @@ def split_into_chunks(self, text: str) -> List[str]: 策略: 1. 先按章节标题切分(如 "第一章"、"一、" 等) 2. 如果单个块仍然超过 CHUNK_SIZE,按段落进一步切分 + 3. 如果单个段落超长,强制按字符截断 """ # 尝试按章节标题切分 chapter_pattern = r"(?:第[一二三四五六七八九十百千\d]+[章节条篇部]|^[一二三四五六七八九十\d]+[、..]\s)" @@ -182,7 +327,6 @@ def split_into_chunks(self, text: str) -> List[str]: # 过滤空块 sections = [s.strip() for s in sections if s.strip()] - # 如果切分后块太大,进一步按段落切分 final_chunks = [] for section in sections: if len(section) <= Config.CHUNK_SIZE: @@ -192,14 +336,42 @@ def split_into_chunks(self, text: str) -> List[str]: paragraphs = re.split(r"\n\s*\n", section) current_chunk = "" for para in paragraphs: - if len(current_chunk) + len(para) > Config.CHUNK_SIZE and current_chunk: - final_chunks.append(current_chunk.strip()) - current_chunk = para + para = para.strip() + if not para: + continue + # 如果当前块加上新段落会超限,先保存当前块 + if current_chunk and len(current_chunk) + 2 + len(para) > Config.CHUNK_SIZE: + final_chunks.append(current_chunk) + current_chunk = "" + # 如果单个段落本身就超长,强制按字符截断 + if len(para) > Config.CHUNK_SIZE: + # 先保存已有的 chunk + if current_chunk: + final_chunks.append(current_chunk) + current_chunk = "" + # 按句号/分号截断超长段落 + sub_parts = re.split(r"(?<=[。;;!!])", para) + sub_chunk = "" + for sp in sub_parts: + sp = sp.strip() + if not sp: + continue + if sub_chunk and len(sub_chunk) + len(sp) > Config.CHUNK_SIZE: + final_chunks.append(sub_chunk) + sub_chunk = sp + else: + sub_chunk += sp + if sub_chunk: + current_chunk = sub_chunk else: - current_chunk += "\n\n" + para if current_chunk else para + current_chunk = current_chunk + "\n\n" + para if current_chunk else para if current_chunk.strip(): final_chunks.append(current_chunk.strip()) + logger.info(f"切分完成: {len(sections)} 个章节 → {len(final_chunks)} 个块") + for i, c in enumerate(final_chunks): + logger.info(f" 块 {i+1}: {len(c)} 字符") + return final_chunks def extract_metadata_from_filename(self, filename: str) -> Dict[str, Any]: @@ -386,87 +558,31 @@ def extract_conditions(self, text: str) -> Dict[str, Any]: all_logic_groups = [] all_important_dates = [] - system_prompt = """你是一个高校政策文档分析专家。请从文档片段中提取所有申请条件和关键信息。 - -**重要:每个条件必须标注所属分类(category)** - -条件分类(category)必须从以下选项中选择: -- gpa: 绩点/成绩要求(如 GPA、学分绩点排名、必修课成绩等) -- foreign_language: 外语要求(如 CET4/6、TOEFL、IELTS、专业外语等) -- academic: 学业表现要求(如课程完成情况、学术研究能力等) -- disciplinary: 纪律/品行要求(如无处分记录、品行优良等) -- research: 科研/论文要求(如发表论文数量、期刊级别等) -- competition: 竞赛/获奖要求(如学科竞赛获奖级别等) -- bonus: 加分项(如竞赛获奖加分、论文加分、志愿服务加分等) -- procedural: 流程性要求(如提交申请表、参加面试等) -- health: 健康要求(如身心健康标准等) -- other: 其他要求 - -条件类型(type)必须从以下选项中选择: -- hard: 硬性门槛(不满足则不符合,如 GPA ≥ 3.5) -- scoring: 评分项(有具体分值,如学业成绩占80%) -- ranking: 排名项(如成绩排名前30%) -- bonus: 加分项(如 SCI 论文加5分) -- preference: 优先条件(如学生干部优先) -- procedural: 流程性要求(如需提交申请表) -- qualitative: 模糊定性条件(如综合素质突出) - -对每个条件,请提取: -- id: 条件编号(如 condition_001) -- category: 条件分类(从上述10个分类中选择) -- item: 条件名称(如 "GPA要求") -- description: 条件描述 -- type: 条件类型 -- quantifiable: 是否可量化(true/false) -- requirement: 要求描述(如 "GPA ≥ 3.5") -- operator: 比较运算符(>=, <=, >, <, ==, 或 "none") -- value: 数值(如果不可量化填 null) -- unit: 单位(如 "GPA", "分", "%", 或 "none") -- source_quote: 原文引用(必须逐字引用,不得改写) -- source_section: 来源章节(如 "第二章 第六条") - -逻辑分组(logic_groups):将相关条件分组,标注组内逻辑关系(AND/OR/SUM)。 - -重要日期(important_dates):提取所有关键时间节点。 + system_prompt = """你是高校政策分析专家。从以下政策文本中提取申请条件和关键日期,返回 JSON。 -返回 JSON 格式: -{ - "conditions": [ - { - "id": "condition_001", - "category": "gpa", - "item": "条件名称", - "description": "描述", - "type": "类型", - "quantifiable": true, - "requirement": "要求描述", - "operator": ">=", - "value": 3.5, - "unit": "GPA", - "source_quote": "原文引用", - "source_section": "章节" - } - ], - "logic_groups": [ - { - "group_id": "group_basic", - "description": "基本申请条件", - "logic": "AND", - "condition_ids": ["condition_001", "condition_002"] - } - ], - "important_dates": [ - {"event": "申请截止", "date": "2025-06-15", "source_quote": "原文引用"} - ] -} +【分类 category(选一个)】 +gpa=成绩要求 | foreign_language=外语 | academic=学业 | disciplinary=纪律品行 +research=科研论文 | competition=竞赛获奖 | bonus=加分项 | procedural=流程 | health=健康 | other=其他 + +【类型 type(选一个)】 +hard=硬性门槛 | scoring=评分项 | ranking=排名 | bonus=加分 | preference=优先 | procedural=流程 | qualitative=定性 + +【输出 JSON 格式,严格遵循】 +{"conditions":[{"id":"c001","category":"gpa","item":"GPA要求","description":"必修课加权平均成绩排名前50%","type":"hard","quantifiable":true,"requirement":"排名前50%","operator":"<=","value":50,"unit":"%","source_quote":"原文逐字引用","source_section":"第一章 第五条"}],"logic_groups":[{"group_id":"g1","description":"基本条件","logic":"AND","condition_ids":["c001"]}],"important_dates":[{"event":"申请截止","date":"2025-06-15","source_quote":"原文引用"}]} -只返回 JSON,不要其他内容。如果某个块中没有条件,返回空列表。""" +【规则】 +1. 每个条件必须填 category 和 type,从上面选项中选 +2. source_quote 必须逐字引用原文,不可改写 +3. 不可量化时 value 填 null,operator 填 "none",unit 填 "none" +4. 没有条件的块返回 {"conditions":[],"logic_groups":[],"important_dates":[]} +5. 只返回 JSON,不要任何解释文字""" condition_counter = 1 + failed_chunks = [] for i, chunk in enumerate(chunks): logger.info(f"解析第 {i + 1}/{len(chunks)} 块...") - result = self.llm.extract_json(system_prompt, chunk) + result = self.llm.extract_json(system_prompt, chunk, max_retries=3) if result["success"]: data = result["data"] @@ -508,6 +624,37 @@ def extract_conditions(self, text: str) -> Dict[str, Any]: logger.info(f"第 {i + 1} 块提取 {len(conditions)} 个条件") else: logger.warning(f"第 {i + 1} 块解析失败: {result.get('content', '')[:200]}") + failed_chunks.append((i, chunk)) + + # 如果有失败的块,尝试用更简单的 prompt 重试 + if failed_chunks: + logger.info(f"重试 {len(failed_chunks)} 个失败的块...") + simple_prompt = """从以下文本中提取政策条件,返回 JSON 格式: +{"conditions":[{"category":"gpa","item":"条件名","description":"描述","type":"hard","quantifiable":false,"requirement":"要求","operator":"none","value":null,"unit":"none","source_quote":"原文引用","source_section":"章节"}],"logic_groups":[],"important_dates":[]} +只返回 JSON。""" + + for i, chunk in failed_chunks: + logger.info(f"重试第 {i + 1} 块(简化 prompt)...") + result = self.llm.extract_json(simple_prompt, chunk, max_retries=2) + + if result["success"]: + data = result["data"] + conditions = data.get("conditions") or [] + if not isinstance(conditions, list): + conditions = [conditions] if isinstance(conditions, dict) else [] + + for cond in conditions: + if not isinstance(cond, dict): + continue + cond["id"] = f"condition_{condition_counter:03d}" + if "category" not in cond: + cond["category"] = "other" + condition_counter += 1 + all_conditions.append(cond) + + logger.info(f"重试成功,第 {i + 1} 块提取 {len(conditions)} 个条件") + else: + logger.error(f"重试仍然失败,第 {i + 1} 块条件丢失") # 按 category 分组 requirements = self._group_conditions_by_category(all_conditions) diff --git a/policy-search/requirements.txt b/policy-search/requirements.txt index b4386171cdd..039daa9e3dd 100644 --- a/policy-search/requirements.txt +++ b/policy-search/requirements.txt @@ -1,6 +1,10 @@ mcp>=1.0.0,<2.0.0 dashscope>=1.20.0 +openai>=1.30.0 pdfplumber>=0.11.0 PyPDF2>=3.0.0 +python-docx>=1.1.0 +beautifulsoup4>=4.12.0 +openpyxl>=3.1.0 python-dotenv>=1.0.0 filelock>=3.0.0 diff --git a/policy-search/server.py b/policy-search/server.py index f5b643f6d9c..5537e785a98 100644 --- a/policy-search/server.py +++ b/policy-search/server.py @@ -84,23 +84,48 @@ def get_matcher(): name="load_policy_document", description=( "【政策文档入库工具】\n" - "功能:加载高校政策文档(PDF或文本文件),使用LLM自动解析并结构化存储到知识库。\n" - "触发条件:当用户提到以下关键词时使用此工具:\n" - "- 加载政策、导入政策、解析政策文档\n" - "- 上传政策文件、添加新政策、录入政策\n" - "- 上传奖学金文件、上传保研文件、上传助学金文件\n" - "- 政策入库、文档入库\n" - "适用场景:用户提供了一个PDF或文本文件路径,要求系统解析其中的政策内容。\n" - "支持的政策类型:保研/推免、奖学金、助学金、学业管理、纪律处分、交流交换、就业创业等。\n" - "注意:此工具专门用于政策类文档的结构化解析。如果是普通文档问答,请使用RAG的load_pdf工具。\n" - "示例:用户说'帮我加载这个保研政策PDF:d:/docs/policy.pdf'时调用此工具。" + "功能:加载高校政策文档(PDF、DOCX、HTML、Excel或文本文件),使用LLM自动解析并结构化存储到知识库。\n" + "\n" + "=== 触发条件(满足任一即触发) ===\n" + "\n" + "【条件A - 用户显式请求】用户提到以下关键词:\n" + "- 加载政策、导入政策、解析政策文档、上传政策文件、添加新政策、录入政策\n" + "- 上传奖学金文件、上传保研文件、上传助学金文件、政策入库、文档入库\n" + "\n" + "【条件B - 文件名/路径自动识别】用户提供了文件路径,且文件名或路径中包含以下任一特征词:\n" + "- 政策通用类:政策、办法、规定、细则、条例、通知、意见、方案、管理办法、实施细则、评定办法、评选办法\n" + "- 研究生推免/保研类:推免、保研、免试攻读、推荐免试、研究生推免、推免生、推免资格、推免办法、推免工作\n" + "- 奖学金类:奖学金、国家奖学金、励志奖学金、社会奖学金、奖学金评定、奖学金评选、奖学金评审\n" + "- 助学金/资助类:助学金、资助、助学金评定、助学金评审、困难补助、勤工助学、助学贷款、国家助学金\n" + "- 综测/综合素质类:综测、综合素质测评、综合测评、素质测评、德育测评、综合考评、测评办法、测评细则\n" + "- 转专业类:转专业、专业分流、专业调整、转入、转出、转专业办法、转专业条件\n" + "- 学业管理类:学业、成绩、学分、绩点、GPA、考试、课程、培养、学籍、休学、退学、毕业、学位\n" + "- 纪律处分类:纪律、处分、违规、违纪、警告、严重警告、记过、留校察看、开除、学生违纪、处分办法\n" + "- 交流交换类:交流、交换、联合培养、出国、境外、访学、交换生、国际交流\n" + "- 就业创业类:就业、创业、实习、招聘、毕业、校招、就业指导、创业扶持\n" + "\n" + "【条件C - 上下文推断】用户上传了文件(即使没有说话),且当前对话上下文涉及政策、保研、奖学金等高校管理话题。\n" + "\n" + "=== 重要判断规则 ===\n" + "- 当用户上传了文件但什么都不说时,优先检查文件名是否包含上述特征词,如果包含则自动调用此工具。\n" + "- 如果文件名模糊(如 'document1.pdf')且用户没有说明,不要调用此工具,应主动询问用户。\n" + "- 此工具专门用于高校政策类文档的结构化解析。如果是普通文档问答(非政策类),请使用RAG的load_pdf工具。\n" + "\n" + "支持的政策类型:研究生推免/保研、奖学金、助学金/资助、综测/综合素质测评、转专业、学业管理、纪律处分、交流交换、就业创业等。\n" + "示例:\n" + "- 用户说'帮我加载这个保研政策PDF:d:/docs/policy.pdf' → 调用此工具\n" + "- 用户只发送了文件路径 'd:/docs/XX大学推免生管理办法2025.pdf' 但没说话 → 文件名包含'推免'和'管理办法',自动调用此工具\n" + "- 用户发送 'd:/docs/国家奖学金评定细则.pdf' 但没说话 → 文件名包含'奖学金'和'评定细则',自动调用此工具\n" + "- 用户发送 'd:/docs/综合素质测评实施办法.pdf' 但没说话 → 文件名包含'综测'和'测评办法',自动调用此工具\n" + "- 用户发送 'd:/docs/学生转专业管理办法.pdf' 但没说话 → 文件名包含'转专业'和'管理办法',自动调用此工具\n" + "- 用户发送 'd:/docs/助学金评审实施细则.pdf' 但没说话 → 文件名包含'助学金'和'实施细则',自动调用此工具" ), inputSchema={ "type": "object", "properties": { "document_path": { "type": "string", - "description": "PDF 或文本文件的绝对路径,例如 d:/documents/policy.pdf", + "description": "文档文件的绝对路径,支持 .pdf、.docx、.html、.xlsx、.txt、.md 格式,例如 d:/documents/policy.pdf 或 d:/documents/policy.docx", }, "metadata": { "type": "object", From 09ca49b4f02bec6fcccff55e2578099f6ff15dc9 Mon Sep 17 00:00:00 2001 From: Mini-happ <1521801957@qq.com> Date: Wed, 9 Sep 2026 15:23:16 +0800 Subject: [PATCH 07/10] docs: initialize competition submission documentation (#12) * docs: add competition submission document skeletons * docs: initialize competition documentation --- docs/competition/ALIYUN_USAGE.md | 36 ++++++ docs/competition/DEPLOYMENT.md | 49 ++++++++ docs/competition/EVALUATION.md | 119 ++++++++++++++++++ docs/competition/MODEL_ALGORITHM.md | 185 ++++++++++++++++++++++++++++ docs/competition/MODIFICATIONS.md | 88 +++++++++++++ evaluation/datasets/.gitkeep | 0 evaluation/results/.gitkeep | 0 7 files changed, 477 insertions(+) create mode 100644 docs/competition/ALIYUN_USAGE.md create mode 100644 docs/competition/DEPLOYMENT.md create mode 100644 docs/competition/EVALUATION.md create mode 100644 docs/competition/MODEL_ALGORITHM.md create mode 100644 docs/competition/MODIFICATIONS.md create mode 100644 evaluation/datasets/.gitkeep create mode 100644 evaluation/results/.gitkeep diff --git a/docs/competition/ALIYUN_USAGE.md b/docs/competition/ALIYUN_USAGE.md new file mode 100644 index 00000000000..414227e2ea3 --- /dev/null +++ b/docs/competition/ALIYUN_USAGE.md @@ -0,0 +1,36 @@ +# 阿里云产品使用说明 + +## 1. 阿里云使用概述 + +本项目按照赛题要求使用阿里云产品或平台。 + +当前主要使用: + +- 阿里云百炼 +- DashScope +- 通义千问 Qwen + +## 2. 当前模型 + +- 模型:qwen-plus +- 接入方式:DashScope API + +## 3. 当前用途 + +Qwen 当前主要用于: + +- 用户问题理解 +- RAG 检索结果组织 +- 回答生成 +- Agent 推理 + +后续实际新增用途继续记录。 + +## 4. 配置方式 + +API Key 不写入源码。 + +使用环境变量进行配置,例如: + +```env +DASHSCOPE_API_KEY=your_api_key_here \ No newline at end of file diff --git a/docs/competition/DEPLOYMENT.md b/docs/competition/DEPLOYMENT.md new file mode 100644 index 00000000000..7736fd817ec --- /dev/null +++ b/docs/competition/DEPLOYMENT.md @@ -0,0 +1,49 @@ + +--- + +### `docs/competition/DEPLOYMENT.md` + +```md +# 校园规则解码器——环境与部署说明 + +## 1. 文档目的 + +本文件用于说明项目运行所需环境、依赖、配置和启动流程。 + +## 2. 当前开发环境 + +> 以 `BASELINE.md` 中锁定版本为准。 + +当前已知环境: + +- 操作系统:Windows +- Python:3.12.x +- AionCore:0.1.61 +- AionUi:见 `BASELINE.md` +- Node.js:见项目要求 +- Bun:见项目要求 + +## 3. 项目目录 + +主要新增模块包括: + +- `rag-mcp-server/` +- `docs/competition/` +- `evaluation/` + +后续随项目结构更新。 + +## 4. 环境安装 + +### 4.1 AionUi + +详细开发环境安装过程参见: + +`RUNBOOK.md` + +### 4.2 RAG MCP Server + +进入目录: + +```powershell +cd rag-mcp-server \ No newline at end of file diff --git a/docs/competition/EVALUATION.md b/docs/competition/EVALUATION.md new file mode 100644 index 00000000000..059eeaabdd2 --- /dev/null +++ b/docs/competition/EVALUATION.md @@ -0,0 +1,119 @@ +# 校园规则解码器——评测方案与结果 + +## 1. 评测目的 + +本评测用于验证校园规则解码器相较于通用大模型/原始 AionUi, +是否在校园政策检索、证据引用和规则问答等任务上取得实际提升。 + +## 2. 当前评测范围 + +第一阶段优先评测: + +- RAG 检索效果 +- 政策问答准确性 +- 引用正确性 +- 无依据回答情况 + +后续根据功能完成情况扩展: + +- 学业路径规划 +- 课程约束判断 +- 合同风险分析 + +## 3. 验证集 + +### 3.1 政策问答集 + +待构建。 + +计划包含: + +- 事实型问题 +- 条件判断型问题 +- 多条款联合问题 +- 无答案/资料缺失问题 + +### 3.2 学业规划测试集 + +待后续构建。 + +### 3.3 合同风险测试集 + +待后续构建。 + +## 4. 评测指标 + +### 4.1 Recall@K + +衡量正确证据是否出现在检索结果前 K 条中。 + +### 4.2 引用正确率 + +衡量回答引用的资料是否真正支持对应结论。 + +### 4.3 问答正确率 + +由人工标准答案进行核验。 + +### 4.4 无依据回答率 + +资料中不存在明确依据时, +模型仍给出确定结论的比例。 + +### 4.5 任务成功率 + +用于后续端到端任务评测。 + +## 5. 对照版本 + +### Baseline 0:普通模型问答 + +不使用 RAG。 + +### Baseline 1:原始 AionUi + +不加载校园专用 MCP/RAG。 + +### Final:校园规则解码器 + +使用校园专用 RAG/MCP 和 Assistant。 + +## 6. 当前结果 + +> 尚未进行正式评测。 + +| 指标 | 普通模型 | 原始 AionUi | 完整系统 | +|---|---:|---:|---:| +| Recall@5 | 不适用 | 不适用 | 待测 | +| 问答正确率 | 待测 | 待测 | 待测 | +| 引用正确率 | 待测 | 待测 | 待测 | +| 无依据回答率 | 待测 | 待测 | 待测 | + +## 7. 失败案例 + +后续至少保留 3~5 个真实失败案例。 + +建议记录: + +- 输入问题 +- 实际输出 +- 预期结果 +- 错误类型 +- 原因 +- 修复方式 +- 修复后结果 + +## 8. 原始结果保存位置 + +评测数据: + +`evaluation/datasets/` + +评测结果: + +`evaluation/results/` + +## 9. 注意事项 + +所有最终指标必须由实际运行产生, +不得使用项目方案中的预期目标值代替正式测试结果。 \ No newline at end of file diff --git a/docs/competition/MODEL_ALGORITHM.md b/docs/competition/MODEL_ALGORITHM.md new file mode 100644 index 00000000000..a829cc9c551 --- /dev/null +++ b/docs/competition/MODEL_ALGORITHM.md @@ -0,0 +1,185 @@ +# 校园规则解码器——模型与算法说明 + +## 1. 项目概述 + +### 1.1 项目背景 + +本项目面向大学校园中的制度规则理解与决策问题, +帮助学生完成政策检索、学业规划、规则判断与风险识别。 + +### 1.2 核心问题 + +当前重点解决以下问题: + +- 校园制度文件内容复杂、查找困难 +- 学生难以理解政策条款对个人决策的影响 +- 学业规划中存在课程先修、学分、学期安排等约束 +- 通用大模型容易出现无依据回答和规则幻觉 + +### 1.3 技术路线 + +当前技术路线: + +AionUi + Agent + MCP + RAG + 阿里云 DashScope/Qwen + +后续根据项目实现情况补充课程 DAG、规则分析等算法模块。 + +## 2. 系统总体架构 + +### 2.1 AionUi + +负责桌面端交互、Agent 运行及 MCP 工具调用。 + +### 2.2 Assistant + +负责定义“校园规则解码器”的角色、行为边界和工具使用方式。 + +### 2.3 MCP Server + +负责提供校园场景专用工具。 + +当前重点包括: + +- 文档加载 +- 政策检索 +- RAG 查询 +- 后续学业规划/规则分析工具 + +### 2.4 RAG + +RAG 负责从校园政策、培养方案等资料中检索相关证据, +并将检索结果交给大模型生成回答。 + +### 2.5 阿里云大模型 + +当前使用: + +- 平台:阿里云百炼 / DashScope +- 模型:qwen-plus + +具体调用配置后续根据实际系统版本补充。 + +## 3. RAG 检索方案 + +### 3.1 数据来源 + +当前规划数据包括: + +- 培养方案 +- 校园政策文件 +- 奖学金相关规则 +- 实习与学分认定政策 +- 其他公开校园制度资料 + +### 3.2 文档解析 + +当前实现以 PDF 文档为主。 + +后续根据实际代码补充: + +- PDF 解析工具 +- Word 解析工具 +- 文本清洗方式 + +### 3.3 文档切分 + +待实际实现完成后补充。 + +### 3.4 向量化 + +待实际实现完成后补充。 + +### 3.5 向量数据库 + +当前使用: + +- ChromaDB + +### 3.6 检索方式 + +当前核心流程: + +用户问题 +→ MCP 工具 +→ RAG 检索 +→ 返回相关文档片段 +→ Qwen 组织回答 + +### 3.7 引用与证据 + +后续重点实现: + +- 文档来源 +- 片段定位 +- 相似度/相关性 +- 回答引用 + +## 4. Agent 与 MCP + +### 4.1 Agent 工作流程 + +待后续根据实际调用链补充。 + +### 4.2 MCP 工具 + +| 工具 | 功能 | 当前状态 | +|---|---|---| +| load_pdf | 加载 PDF 文档 | 已实现/待确认 | +| query | RAG 查询 | 已实现/待确认 | +| clear | 清空知识库 | 已实现/待确认 | +| policy_search | 校园政策检索 | 规划中 | +| course_path_plan | 学业路径推演 | 规划中 | +| contract_scan | 合同风险扫描 | 规划中 | + +## 5. 核心算法 + +### 5.1 课程 DAG + +规划使用课程节点与先修关系构建有向图。 + +### 5.2 约束判断 + +后续考虑加入: + +- 先修约束 +- 学分约束 +- 学期约束 +- 用户目标约束 + +### 5.3 路径推演 + +待实现后补充实际算法。 + +### 5.4 What-if 重推演 + +待实现后补充。 + +## 6. 权益风险分析 + +当前处于规划阶段。 + +拟采用: + +规则初筛 +→ 政策/法规检索 +→ LLM 分析 +→ 风险等级 +→ 修改或行动建议 + +## 7. 可信性设计 + +当前重点方向: + +- RAG 提供证据 +- 限制无依据回答 +- 明确区分政策原文与模型分析 +- 对低置信度结果提示人工确认 + +## 8. 技术创新点 + +待项目稳定后总结。 + +## 9. 当前局限 + +当前仍处于开发阶段,部分功能尚未实现。 +本文件只记录实际实现内容,不将规划功能描述为已完成功能。 \ No newline at end of file diff --git a/docs/competition/MODIFICATIONS.md b/docs/competition/MODIFICATIONS.md new file mode 100644 index 00000000000..48c9a692e0d --- /dev/null +++ b/docs/competition/MODIFICATIONS.md @@ -0,0 +1,88 @@ +# AionUi 二次开发与原创改动说明 + +## 1. 上游项目 + +- 项目名称:AionUi +- 上游仓库:待补充 +- 团队仓库:AionUi-Campus +- 基线 Commit:见 `BASELINE.md` +- 开源许可证:Apache-2.0 + +## 2. 项目定位 + +本项目基于 AionUi 进行二次开发,围绕“校园规则解码器”场景, +构建面向大学生的校园制度规则理解与决策辅助系统。 + +核心目标包括: + +- 校园政策与制度检索 +- 学业路径推演 +- 课程规则与先修关系分析 +- 实习/合同风险辅助分析 +- 规则依据引用与可追溯回答 +- 基于 Agent + RAG + MCP 的任务协同 + +## 3. AionUi 原有能力 + +本项目直接复用的上游能力包括: + +- Electron 桌面端框架 +- 基础 Agent 能力 +- Assistant 配置机制 +- MCP Server 接入机制 +- 模型配置能力 +- 文件与对话基础能力 +- 原有桌面工作台及相关基础组件 + +## 4. 团队新增模块 + +当前已规划或正在开发的原创模块包括: + +- 校园规则解码器 Assistant +- 校园场景 Rules / Skills +- RAG MCP Server +- 校园政策知识库 +- `policy_search` 政策检索工具 +- 学业路径推演功能 +- 课程 DAG / 先修关系模型 +- 合同风险分析功能 +- 校园场景验证集 +- 自动化评测程序 +- 阿里云 DashScope / Qwen 接入适配 +- 校园场景交互流程 + +## 5. 团队修改模块 + +> 本章节随开发过程持续更新。 + +| 日期 | 模块/文件 | 修改内容 | 负责人 | 关联 Commit/PR | +|---|---|---|---|---| +| 待补充 | 待补充 | 待补充 | 待补充 | 待补充 | + +## 6. 原创性边界 + +本项目不宣称从零实现 AionUi 桌面端、底层 Agent 或 MCP 通用能力。 + +团队原创工作主要集中在: + +1. 校园规则理解场景定义 +2. 校园业务流程设计 +3. RAG 检索链路 +4. MCP 专用工具 +5. 学业规划与规则推演算法 +6. 权益风险分析流程 +7. 校园知识数据整理 +8. 验证集与评测体系 +9. 阿里云模型接入与实际应用 +10. 校园场景 UI 与交互改造 + +## 7. 第三方依赖 + +> 后续根据实际使用情况持续补充。 + +| 组件 | 用途 | 许可证/来源 | +|---|---|---| +| AionUi | 桌面端与 Agent 基础能力 | Apache-2.0 | +| ChromaDB | 向量存储 | 待确认 | +| DashScope | 大模型调用 | 阿里云 | +| Qwen | 大模型 | 阿里云 | \ No newline at end of file diff --git a/evaluation/datasets/.gitkeep b/evaluation/datasets/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/evaluation/results/.gitkeep b/evaluation/results/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d From c90d1241c76b5942ebf7e02159e87cfc229f489a Mon Sep 17 00:00:00 2001 From: Mini-happ <1521801957@qq.com> Date: Wed, 9 Sep 2026 16:54:00 +0800 Subject: [PATCH 08/10] docs: add detailed git workflow guide (#4) --- GIT_WORKFLOW.md | 6268 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 6268 insertions(+) create mode 100644 GIT_WORKFLOW.md diff --git a/GIT_WORKFLOW.md b/GIT_WORKFLOW.md new file mode 100644 index 00000000000..2e58ab21cf9 --- /dev/null +++ b/GIT_WORKFLOW.md @@ -0,0 +1,6268 @@ +\# AionUi-Campus Git 团队协作与版本管理规范 + + + +\*\*项目:\*\* 2026 年第二届重庆市 AI 大模型创新应用大赛 · “创意 AI 校园” + +\*\*团队仓库:\*\* `ai-campus-2026/AionUi-Campus` + +\*\*文档名称:\*\* Git 团队协作与版本管理规范 + +\*\*适用成员:\*\* 1~5号全体成员 + +\*\*维护负责人:\*\* 1号负责人 / 队长 + +\*\*当前版本:\*\* V1.0 + +\*\*建立日期:\*\* 2026-08-10 + + + +\--- + + + +\# 1. 文档目的 + + + +本文件用于统一 AionUi-Campus 团队在比赛开发期间的 Git 与 GitHub 使用方式。 + + + +本项目共 5 名成员,将同时进行: + + + +\- Desktop UI 开发 + +\- Agent / Assistant / Skills 开发 + +\- RAG / MCP / 数据开发 + +\- 阿里云模型接入 + +\- 测试与评测 + +\- 集成与版本管理 + +\- 比赛材料整理 + + + +如果没有统一 Git 规范,容易出现: + + + +\- 两个人同时修改同一个文件并互相覆盖; + +\- 成员直接修改 `main`; + +\- 成员直接修改 `develop`; + +\- 不知道应该从哪个分支开始开发; + +\- Pull Request 发错目标分支; + +\- 错误合并到官方 AionUi 仓库; + +\- API Key 被提交到 Git; + +\- 多个成员各自维护不同版本; + +\- 合并以后本地代码没有更新; + +\- 删除 Squash 分支时误以为 Git 报错; + +\- 上游 AionUi 更新导致比赛项目突然无法运行; + +\- 比赛最终版本无法准确定位。 + + + +因此,所有成员正式参与代码开发前,都必须理解并遵守本文档。 + + + +\--- + + + +\# 2. 仓库关系 + + + +当前项目涉及两个主要 Git 仓库。 + + + +\--- + + + +\## 2.1 团队比赛仓库 + + + +团队仓库: + + + +```text + +https://github.com/ai-campus-2026/AionUi-Campus.git + +``` + + + +本地名称: + + + +```text + +origin + +``` + + + +这是团队比赛开发的唯一主仓库。 + + + +团队成员开发的: + + + +\- UI + +\- Agent + +\- RAG + +\- MCP + +\- 阿里云接入 + +\- 测试 + +\- 文档 + +\- 集成代码 + + + +最终都应该进入: + + + +```text + +ai-campus-2026/AionUi-Campus + +``` + + + +\--- + + + +\## 2.2 AionUi 官方上游仓库 + + + +官方仓库: + + + +```text + +https://github.com/iOfficeAI/AionUi.git + +``` + + + +本地名称: + + + +```text + +upstream + +``` + + + +该仓库只作为: + + + +\- AionUi 官方更新来源; + +\- 上游源码参考; + +\- Bug 修复参考; + +\- 新功能参考。 + + + +普通成员不要自行把 `upstream/main` 合入团队 `develop` 或 `main`。 + + + +上游同步统一由: + + + +```text + +1号负责人 / 队长 + +``` + + + +处理。 + + + +\--- + + + +\## 2.3 AionCore + + + +AionCore 当前使用官方仓库: + + + +```text + +https://github.com/iOfficeAI/AionCore.git + +``` + + + +当前团队锁定: + + + +```text + +AionCore Version: + +0.1.61 + + + +Commit: + +81ef258913e6ac5076a86d4adcc7edcc0f8f21ef + +``` + + + +当前阶段: + + + +\- AionCore 作为底层依赖使用; + +\- 不建立团队比赛开发分支; + +\- 不向官方 AionCore 仓库推送比赛代码; + +\- 不随意升级; + +\- 如确实需要修改,必须先由 1号负责人重新确认方案。 + + + +\--- + + + +\# 3. 当前项目基线 + + + +团队已经建立: + + + +```text + +baseline-20260807 + +``` + + + +对应 Commit: + + + +```text + +f98d9f719de9c639f739470ea49f01a6b0a22687 + +``` + + + +该 Tag 表示: + + + +> 团队已经验证过的原始稳定开发起点。 + + + +详细信息见: + + + +```text + +BASELINE.md + +``` + + + +注意: + + + +```text + +baseline-20260807 + +``` + + + +是历史坐标。 + + + +它不会因为: + + + +```text + +develop + +main + +``` + + + +继续开发而移动。 + + + +\--- + + + +\# 4. 当前分支体系 + + + +团队采用: + + + +```text + +main + +develop + +任务分支 + +``` + + + +三级结构。 + + + +整体关系: + + + +```text + +baseline-20260807 + + │ + + ├──────── main + + │ + + └──────── develop + + │ + + ├── feat/\* + + ├── fix/\* + + ├── docs/\* + + ├── test/\* + + └── ci/\* + +``` + + + +\--- + + + +\# 5. main 分支 + + + +`main` 是稳定分支。 + + + +主要用于: + + + +\- 阶段稳定版本; + +\- 演示版本; + +\- 比赛候选版本; + +\- Release; + +\- 最终提交版本。 + + + +原则: + + + +```text + +main = 随时应该可以拿出来演示 + +``` + + + +普通成员不得: + + + +```powershell + +git switch main + +``` + + + +以后直接开始写业务功能。 + + + +更不得直接: + + + +```powershell + +git push origin main + +``` + + + +所有进入 `main` 的修改必须通过: + + + +```text + +develop + + ↓ + +Pull Request + + ↓ + +Review + + ↓ + +main + +``` + + + +\--- + + + +\# 6. develop 分支 + + + +`develop` 是团队日常集成分支。 + + + +UI、Agent、RAG、测试等模块的开发成果: + + + +```text + +先进入 develop + +``` + + + +经过阶段测试以后: + + + +```text + +再进入 main + +``` + + + +原则: + + + +```text + +develop = 当前最新的团队集成版本 + +``` + + + +普通成员不能直接在 `develop` 上开发功能。 + + + +错误方式: + + + +```powershell + +git switch develop + + + +\# 直接修改代码 + + + +git add . + +git commit -m "..." + +git push + +``` + + + +这种流程禁止使用。 + + + +正确方式: + + + +```text + +develop + + ↓ + +创建任务分支 + + ↓ + +开发 + + ↓ + +Push + + ↓ + +Pull Request + + ↓ + +Review + + ↓ + +Squash and merge + + ↓ + +develop + +``` + + + +\--- + + + +\# 7. 当前分支保护规则 + + + +团队目前已经对: + + + +```text + +main + +develop + +``` + + + +建立 GitHub Ruleset。 + + + +当前核心规则包括: + + + +\- 禁止删除受保护分支; + +\- 禁止 Force Push; + +\- 修改必须通过 Pull Request; + +\- Pull Request 至少需要 1 个有效 Approval; + +\- 新提交可能使之前的 Approval 失效; + +\- Review conversation 未解决时不能合并; + +\- `main` 和 `develop` 均不能由普通成员直接绕过规则。 + + + +\--- + + + +\# 8. 仓库权限 + + + +普通成员统一使用: + + + +```text + +Write + +``` + + + +1号负责人 / 队长使用: + + + +```text + +Admin + +``` + + + +普通成员不需要 Admin。 + + + +原因: + + + +`Write` 已经能够满足: + + + +\- Push 自己的任务分支; + +\- 创建 PR; + +\- Review; + +\- Approve; + +\- 参与正常团队开发。 + + + +\--- + + + +\# 9. 为什么 Reviewer 必须至少有 Write + + + +当前 Ruleset 要求: + + + +```text + +至少 1 个有效 Approval + +``` + + + +如果某成员只有: + + + +```text + +Read + +``` + + + +即使他点击: + + + +```text + +Approve + +``` + + + +GitHub 也可能显示: + + + +```text + +Approved with read-only permissions + +``` + + + +这种审核不会满足 Required Review。 + + + +因此: + + + +```text + +参与代码审核的正式团队成员 + +``` + + + +应具备: + + + +```text + +Write + +``` + + + +权限。 + + + +\--- + + + +\# 10. 任务分支类型 + + + +团队使用以下任务分支。 + + + +\--- + + + +\## 10.1 功能开发 + + + +```text + +feat/\* + +``` + + + +例如: + + + +```text + +feat/ui-campus + +feat/agent-workflow + +feat/campus-mcp-rag + +feat/model-config + +feat/integration + +``` + + + +\--- + + + +\## 10.2 Bug 修复 + + + +```text + +fix/\* + +``` + + + +例如: + + + +```text + +fix/model-auth + +fix/rag-empty-result + +fix/ui-navigation + +fix/mcp-connection + +``` + + + +\--- + + + +\## 10.3 文档 + + + +```text + +docs/\* + +``` + + + +例如: + + + +```text + +docs/baseline + +docs/runbook + +docs/run-log + +docs/git-workflow + +``` + + + +\--- + + + +\## 10.4 测试 + + + +```text + +test/\* + +``` + + + +例如: + + + +```text + +test/evaluation + +test/rag-retrieval + +test/agent-tools + +``` + + + +\--- + + + +\## 10.5 CI / DevOps + + + +```text + +ci/\* + +``` + + + +例如: + + + +```text + +ci/competition-quality + +ci/test-workflow + +``` + + + +\--- + + + +\# 11. 当前五人推荐开发方向 + + + +\## 1号负责人 + + + +职责: + + + +```text + +产品 + +架构 + +集成 + +Git + +``` + + + +推荐: + + + +```text + +feat/integration + +``` + + + +\--- + + + +\## 2号负责人 + + + +职责: + + + +```text + +Desktop UI + +交互 + +``` + + + +推荐: + + + +```text + +feat/ui-campus + +``` + + + +\--- + + + +\## 3号负责人 + + + +职责: + + + +```text + +Agent + +Assistant + +Skills + +``` + + + +推荐: + + + +```text + +feat/agent-workflow + +``` + + + +\--- + + + +\## 4号负责人 + + + +职责: + + + +```text + +RAG + +MCP + +数据 + +阿里云 + +``` + + + +推荐: + + + +```text + +feat/campus-mcp-rag + +``` + + + +\--- + + + +\## 5号负责人 + + + +职责: + + + +```text + +测试 + +评测 + +DevOps + +材料 + +``` + + + +推荐: + + + +```text + +test/evaluation + +``` + + + +\--- + + + +\# 12. 不建议一个分支长期使用整个比赛周期 + + + +虽然每个人都有自己的主要方向,但不要把: + + + +```text + +feat/ui-campus + +``` + + + +从比赛第一天一直用到最后一天。 + + + +更推荐: + + + +```text + +feat/ui-shell + + + +feat/ui-chat-panel + + + +feat/ui-evidence-view + +``` + + + +这种较小任务分支。 + + + +优点: + + + +\- PR 更容易 Review; + +\- 冲突更少; + +\- 出问题容易回滚; + +\- develop 历史更加清楚。 + + + +\--- + + + +\# 13. 第一次参与团队开发 + + + +新成员 Clone: + + + +```powershell + +git clone https://github.com/ai-campus-2026/AionUi-Campus.git + +``` + + + +进入项目: + + + +```powershell + +cd AionUi-Campus + +``` + + + +获取远端: + + + +```powershell + +git fetch origin + +``` + + + +切换: + + + +```powershell + +git switch develop + +``` + + + +同步: + + + +```powershell + +git pull + +``` + + + +检查: + + + +```powershell + +git status + +``` + + + +正常: + + + +```text + +On branch develop + +Your branch is up to date with 'origin/develop'. + + + +nothing to commit, working tree clean + +``` + + + +\--- + + + +\# 14. 每次开始新任务 + + + +统一执行: + + + +```powershell + +git switch develop + +git pull + +git status + +``` + + + +确认: + + + +```text + +working tree clean + +``` + + + +以后创建任务分支。 + + + +例如: + + + +```powershell + +git switch -c feat/agent-workflow + +``` + + + +确认: + + + +```powershell + +git branch --show-current + +``` + + + +应该显示: + + + +```text + +feat/agent-workflow + +``` + + + +这时候再开始写代码。 + + + +\--- + + + +\# 15. 为什么创建任务前必须先 pull + + + +假设: + + + +成员 A 昨天把: + + + +```text + +Agent基础功能 + +``` + + + +合进 develop。 + + + +成员 B 今天还停留在两天前的 develop。 + + + +如果 B 直接创建: + + + +```text + +feat/rag + +``` + + + +那么他的分支基于旧代码。 + + + +之后合并时容易出现: + + + +\- 冲突; + +\- 覆盖别人代码; + +\- 缺少依赖; + +\- 接口不一致。 + + + +所以创建新任务前统一: + + + +```powershell + +git switch develop + +git pull + +``` + + + +\--- + + + +\# 16. 开发过程中查看状态 + + + +最常用命令: + + + +```powershell + +git status + +``` + + + +建议经常执行。 + + + +可以看到: + + + +\- 当前分支; + +\- 修改文件; + +\- 新文件; + +\- 已暂存文件; + +\- 未暂存文件。 + + + +\--- + + + +\# 17. 查看具体修改 + + + +提交前: + + + +```powershell + +git diff + +``` + + + +查看尚未 Stage 的修改。 + + + +查看已经 Stage: + + + +```powershell + +git diff --staged + +``` + + + +成员应知道自己到底准备提交什么。 + + + +\--- + + + +\# 18. git add 规则 + + + +推荐: + + + +```powershell + +git add <具体文件> + +``` + + + +例如: + + + +```powershell + +git add packages/desktop/src/xxx.ts + +``` + + + +或者: + + + +```powershell + +git add RUN\_LOG.md + +``` + + + +不要形成无脑习惯: + + + +```powershell + +git add . + +``` + + + +原因: + + + +可能意外加入: + + + +\- 临时日志; + +\- API Key; + +\- `.env`; + +\- 测试文件; + +\- 无关修改; + +\- IDE 配置。 + + + +\--- + + + +\# 19. Commit Message 规范 + + + +推荐: + + + +```text + +类型(模块): 描述 + +``` + + + +\--- + + + +\## 19.1 feat + + + +新功能: + + + +```text + +feat(ui): add evidence panel + + + +feat(agent): add assistant workflow + + + +feat(rag): add document retrieval + +``` + + + +\--- + + + +\## 19.2 fix + + + +修复: + + + +```text + +fix(ui): handle empty message + + + +fix(mcp): handle connection timeout + + + +fix(rag): avoid duplicate results + +``` + + + +\--- + + + +\## 19.3 docs + + + +文档: + + + +```text + +docs(runbook): update Electron troubleshooting + + + +docs(git): add branch workflow + +``` + + + +\--- + + + +\## 19.4 test + + + +测试: + + + +```text + +test(eval): add retrieval evaluation cases + +``` + + + +\--- + + + +\## 19.5 refactor + + + +重构: + + + +```text + +refactor(agent): split tool dispatcher + +``` + + + +\--- + + + +\## 19.6 ci + + + +CI: + + + +```text + +ci(test): add pull request checks + +``` + + + +\--- + + + +\## 19.7 chore + + + +工程维护: + + + +```text + +chore(deps): update development config + +``` + + + +\--- + + + +\# 20. 禁止使用的 Commit Message + + + +不推荐: + + + +```text + +111 + + + +123 + + + +test + + + +修改 + + + +改一下 + + + +最终版 + + + +final2 + + + +最终版真的 + + + +临时修改 + +``` + + + +原因: + + + +以后无法知道该 Commit 做了什么。 + + + +\--- + + + +\# 21. Commit 尽量保持单一目的 + + + +例如: + + + +不要一个 Commit 同时: + + + +```text + +修改 UI + +修改 RAG + +改 README + +升级依赖 + +修 Agent Bug + +``` + + + +更推荐拆成多个明确任务。 + + + +\--- + + + +\# 22. 标准提交步骤 + + + +先: + + + +```powershell + +git status + +``` + + + +然后: + + + +```powershell + +git add <文件> + +``` + + + +再次: + + + +```powershell + +git status + +``` + + + +确认: + + + +```text + +Changes to be committed + +``` + + + +再: + + + +```powershell + +git commit -m "feat(agent): add assistant workflow" + +``` + + + +\--- + + + +\# 23. Push + + + +第一次 Push 新分支: + + + +```powershell + +git push -u origin <分支> + +``` + + + +例如: + + + +```powershell + +git push -u origin feat/agent-workflow + +``` + + + +之后: + + + +```powershell + +git push + +``` + + + +即可。 + + + +\--- + + + +\# 24. 禁止直接 Push main / develop + + + +不要执行: + + + +```powershell + +git push origin main + +``` + + + +不要执行: + + + +```powershell + +git push origin develop + +``` + + + +正常情况下 Ruleset 也会阻止。 + + + +\--- + + + +\# 25. 创建 Pull Request + + + +Push 后打开 GitHub: + + + +```text + +Pull requests + +→ New pull request + +``` + + + +普通任务必须选择: + + + +```text + +base: develop + +compare: 任务分支 + +``` + + + +例如: + + + +```text + +base: develop + +compare: feat/agent-workflow + +``` + + + +\--- + + + +\# 26. 最容易犯的 PR 错误 + + + +不要: + + + +```text + +base: main + +``` + + + +除非这是正式: + + + +```text + +develop → main + +``` + + + +阶段发布。 + + + +同时一定确认仓库是: + + + +```text + +ai-campus-2026/AionUi-Campus + +``` + + + +不要误发到: + + + +```text + +iOfficeAI/AionUi + +``` + + + +\--- + + + +\# 27. PR 标题规范 + + + +推荐与主要 Commit 类似: + + + +```text + +feat(agent): add assistant workflow + +``` + + + +或者: + + + +```text + +docs: add git workflow guide + +``` + + + +\--- + + + +\# 28. PR 描述模板 + + + +普通功能 PR: + + + +```markdown + +\## Changes + + + +\- 本次修改内容1 + +\- 本次修改内容2 + +\- 本次修改内容3 + + + +\## Reason + + + +说明为什么需要这个修改。 + + + +\## Verification + + + +\- \[ ] 已完成本地启动 + +\- \[ ] 已运行相关测试 + +\- \[ ] 已检查错误日志 + +\- \[ ] 未提交 API Key + +\- \[ ] 未修改无关模块 + + + +\## Risk + + + +说明可能影响的模块。 + + + +\## Notes + + + +其他成员需要知道的信息。 + +``` + + + +\--- + + + +\# 29. PR Reviewer + + + +PR 建立后: + + + +把 PR 地址发给另一名有: + + + +```text + +Write + +``` + + + +权限的成员。 + + + +例如: + + + +```text + +https://github.com/ai-campus-2026/AionUi-Campus/pull/XX + +``` + + + +Reviewer 操作: + + + +```text + +Files changed + +→ 检查代码 + +→ Review changes + +→ Approve + +→ Submit review + +``` + + + +\--- + + + +\# 30. Reviewer 应该检查什么 + + + +至少检查: + + + +\- 是否修改了正确模块; + +\- 是否出现明显错误; + +\- 是否误删代码; + +\- 是否提交 API Key; + +\- 是否提交 `.env`; + +\- 是否提交隐私数据; + +\- 是否修改无关内容; + +\- PR 描述是否说明验证方式; + +\- 是否可能影响其他负责人模块。 + + + +\--- + + + +\# 31. Approve 以后谁 Merge + + + +普通团队流程: + + + +```text + +Reviewer + +负责审核 + + + +PR作者 / 负责人 + +负责后续合并 + +``` + + + +成员不需要看到 PR 就随意点 Merge。 + + + +\--- + + + +\# 32. 任务分支 → develop 的合并方式 + + + +统一: + + + +```text + +Squash and merge + +``` + + + +例如: + + + +```text + +feat/agent-workflow + + ↓ + + develop + +``` + + + +使用: + + + +```text + +Squash and merge + +``` + + + +\--- + + + +\# 33. 为什么任务分支使用 Squash + + + +例如成员开发过程中可能产生: + + + +```text + +commit 1 + +commit 2 + +commit 3 + +fix + +fix again + +final + +``` + + + +如果全部直接 Merge: + + + +develop 历史会很乱。 + + + +Squash 后: + + + +```text + +feat(agent): add assistant workflow + +``` + + + +只保留一个任务级 Commit。 + + + +\--- + + + +\# 34. develop → main 的合并方式 + + + +阶段性稳定版本: + + + +```text + +develop + + ↓ + +main + +``` + + + +使用: + + + +```text + +Merge + +``` + + + +而不是 Squash。 + + + +原因: + + + +保留: + + + +```text + +develop + +main + +``` + + + +之间完整的阶段开发关系。 + + + +便于: + + + +\- Release; + +\- Tag; + +\- 回滚; + +\- 比赛版本证明; + +\- 历史追踪。 + + + +\--- + + + +\# 35. PR 合并以后本地怎么处理 + + + +合并结束以后: + + + +```powershell + +git switch develop + +git pull + +git status + +``` + + + +确认: + + + +```text + +nothing to commit, working tree clean + +``` + + + +\--- + + + +\# 36. 删除旧任务分支 + + + +本地: + + + +```powershell + +git branch -d feat/your-task + +``` + + + +例如: + + + +```powershell + +git branch -d docs/run-log + +``` + + + +远端: + + + +GitHub PR 页面: + + + +```text + +Delete branch + +``` + + + +或者: + + + +```powershell + +git push origin --delete feat/your-task + +``` + + + +\--- + + + +\# 37. Squash 后删除分支为什么会出现 warning + + + +可能看到: + + + +```text + +warning: deleting branch ... that has been merged to + +refs/remotes/origin/... + +but not yet merged to HEAD + +``` + + + +这是正常现象。 + + + +因为: + + + +```text + +Squash and merge + +``` + + + +会创建新的 Commit。 + + + +例如原任务: + + + +```text + +A + +``` + + + +Squash 后进入 develop: + + + +```text + +B + +``` + + + +内容一样,但 Commit ID 不一样。 + + + +所以 Git 从 Commit 图上认为: + + + +```text + +A 并没有直接进入 HEAD + +``` + + + +只要已经确认: + + + +\- GitHub PR 显示 Merged; + +\- `develop` 已 `git pull`; + +\- 修改文件已经存在; + + + +即可删除旧任务分支。 + + + +\--- + + + +\# 38. 删除前必须确认 + + + +不要看到 warning 就直接乱用: + + + +```powershell + +git branch -D + +``` + + + +必须先: + + + +```powershell + +git switch develop + +git pull + +git status + +``` + + + +然后确认代码已进入 develop。 + + + +\--- + + + +\# 39. 每天开发前推荐操作 + + + +每天开始: + + + +```powershell + +git switch develop + +git pull + +``` + + + +如果开始一个新任务: + + + +```powershell + +git switch -c feat/new-task + +``` + + + +\--- + + + +\# 40. 已经在任务分支开发时如何获取 develop 新变化 + + + +假设你在: + + + +```text + +feat/ui-campus + +``` + + + +期间 develop 已经发生变化。 + + + +先: + + + +```powershell + +git status + +``` + + + +确保当前工作已 Commit。 + + + +然后: + + + +```powershell + +git fetch origin + +``` + + + +推荐团队初期采用: + + + +```powershell + +git merge origin/develop + +``` + + + +把最新 develop 合入自己的任务分支。 + + + +如果出现 Conflict: + + + +不要随意解决。 + + + +按本文后面的冲突规范处理。 + + + +\--- + + + +\# 41. 当前团队不要求成员使用复杂 Rebase + + + +比赛开发初期,为降低误操作风险: + + + +普通成员不强制使用: + + + +```powershell + +git rebase + +``` + + + +也不要求: + + + +```powershell + +git rebase -i + +``` + + + +先使用: + + + +```text + +任务分支 + +\+ + +Pull Request + +\+ + +Squash + +``` + + + +已经足够保持历史清晰。 + + + +\--- + + + +\# 42. 未提交修改时不要随意切分支 + + + +如果: + + + +```powershell + +git status + +``` + + + +显示: + + + +```text + +modified: + +``` + + + +说明存在未完成修改。 + + + +这时候不要无脑: + + + +```powershell + +git switch develop + +``` + + + +先决定: + + + +\- 是否应该 Commit; + +\- 是否应该继续完成; + +\- 是否属于当前任务。 + + + +\--- + + + +\# 43. Git Stash + + + +只有理解用途时使用: + + + +```powershell + +git stash + +``` + + + +恢复: + + + +```powershell + +git stash pop + +``` + + + +普通成员不要把 Stash 当作长期保存代码的方法。 + + + +Stash 只是临时保存。 + + + +正式成果必须 Commit。 + + + +\--- + + + +\# 44. Merge Conflict 是什么 + + + +如果两个人修改了同一文件相近位置: + + + +Git 可能无法自动判断该保留谁。 + + + +这时会出现: + + + +```text + +CONFLICT + +``` + + + +文件内部可能出现: + + + +```text + +<<<<<<< HEAD + + + +自己的代码 + + + +======= + + + +另一边代码 + + + +>>>>>>> ... + +``` + + + +\--- + + + +\# 45. 冲突处理原则 + + + +出现冲突时: + + + +不要: + + + +```text + +直接删除整个文件 + +整份覆盖 + +Force Push + +乱点 Accept All + +``` + + + +首先确认: + + + +```text + +自己的修改是什么 + +develop 新修改是什么 + +``` + + + +\--- + + + +\# 46. 跨模块冲突 + + + +如果冲突涉及: + + + +```text + +UI + Agent + +Agent + RAG + +RAG + 数据 + +``` + + + +不要一个人擅自决定。 + + + +由: + + + +```text + +相关模块负责人 + +\+ + +1号集成负责人 + +``` + + + +共同确认。 + + + +\--- + + + +\# 47. 解决冲突后的流程 + + + +修改冲突文件以后: + + + +```powershell + +git status + +``` + + + +确认。 + + + +然后: + + + +```powershell + +git add <冲突文件> + +``` + + + +再: + + + +```powershell + +git commit + +``` + + + +之后必须重新: + + + +```text + +启动程序 + +运行相关测试 + +``` + + + +确认功能没有被冲突处理破坏。 + + + +\--- + + + +\# 48. 禁止 Force Push + + + +普通开发中禁止: + + + +```powershell + +git push --force + +``` + + + +以及: + + + +```powershell + +git push -f + +``` + + + +特别是: + + + +```text + +main + +develop + +``` + + + +绝对禁止。 + + + +GitHub Ruleset 当前也已限制 Force Push。 + + + +\--- + + + +\# 49. 禁止删除长期分支 + + + +不得删除: + + + +```text + +main + +develop + +``` + + + +可删除: + + + +```text + +feat/\* + +fix/\* + +docs/\* + +test/\* + +ci/\* + +``` + + + +前提: + + + +对应 PR 已合并。 + + + +\--- + + + +\# 50. API Key 安全 + + + +严禁提交: + + + +```text + +API Key + +AccessKey + +Secret + +密码 + +真实账号 + +Token + +``` + + + +例如以后阿里云 DashScope Key: + + + +不能直接写进: + + + +```text + +.ts + +.js + +.json + +.md + +``` + + + +然后提交。 + + + +\--- + + + +\# 51. .env + + + +真实 `.env`: + + + +原则上不进入 Git。 + + + +应该提交: + + + +```text + +.env.example + +``` + + + +例如: + + + +```text + +DASHSCOPE\_API\_KEY=your\_key\_here + +``` + + + +不能: + + + +```text + +DASHSCOPE\_API\_KEY=sk-真实密钥 + +``` + + + +\--- + + + +\# 52. 提交前敏感信息检查 + + + +每次: + + + +```powershell + +git status + +``` + + + +确认没有: + + + +```text + +.env + +\*.log + +账号文件 + +密钥文件 + +本地数据库 + +隐私资料 + +``` + + + +\--- + + + +\# 53. 如果 API Key 已经误提交 + + + +立即: + + + +1\. 停止继续 Push; + +2\. 通知 1号负责人; + +3\. 立即废弃 / 轮换该 Key; + +4\. 处理 Git 历史; + +5\. 不要认为“删掉当前文件”就安全了。 + + + +因为 Key 可能已经存在历史 Commit。 + + + +\--- + + + +\# 54. upstream 更新规则 + + + +官方 AionUi: + + + +```text + +upstream + +``` + + + +不由普通成员直接同步。 + + + +原因: + + + +AionUi 更新可能修改: + + + +\- Electron; + +\- Agent; + +\- MCP; + +\- UI; + +\- 数据结构; + +\- 构建系统; + +\- 依赖版本。 + + + +直接同步可能让比赛项目突然无法运行。 + + + +\--- + + + +\# 55. upstream 更新原则 + + + +团队原则: + + + +```text + +稳定 > 最新 + +``` + + + +如果当前版本可以完成比赛: + + + +不需要为了追官方最新版频繁同步。 + + + +\--- + + + +\# 56. upstream 同步负责人 + + + +仅: + + + +```text + +1号负责人 + +``` + + + +统一操作。 + + + +必要时: + + + +```powershell + +git fetch upstream + +``` + + + +然后建立: + + + +```text + +chore/upstream-sync-日期 + +``` + + + +或类似临时分支。 + + + +先测试。 + + + +不要直接: + + + +```text + +upstream/main + +→ develop + +``` + + + +\--- + + + +\# 57. upstream 同步测试 + + + +至少验证: + + + +\- AionUi 能启动; + +\- AionCore 能连接; + +\- Agent 能运行; + +\- RAG 能运行; + +\- MCP 能运行; + +\- 阿里云模型正常; + +\- UI 没有明显回归; + +\- 测试能够运行。 + + + +确认没有严重问题后: + + + +```text + +临时同步分支 + +↓ + +PR + +↓ + +develop + +``` + + + +\--- + + + +\# 58. AionCore 更新规则 + + + +当前: + + + +```text + +aioncore 0.1.61 + +``` + + + +未经队长确认: + + + +不要: + + + +\- Pull 最新 AionCore 后直接重装; + +\- 自行修改底层; + +\- 自行更换版本; + +\- 将比赛代码 Push 到官方 AionCore。 + + + +\--- + + + +\# 59. GitHub Ruleset 不得随意修改 + + + +目前: + + + +```text + +main + +develop + +``` + + + +都已经配置分支保护。 + + + +普通成员不要为了“方便合并”: + + + +\- 关闭 Required Review; + +\- 关闭 Force Push 保护; + +\- 删除 Ruleset; + +\- 临时把 develop 解除保护。 + + + +遇到流程阻塞先找: + + + +```text + +1号负责人 + +``` + + + +\--- + + + +\# 60. 为什么不能为了快绕过 Review + + + +比赛项目后期多人并行时: + + + +一个错误 Merge 可能同时影响: + + + +```text + +UI + +Agent + +RAG + +Demo + +答辩 + +``` + + + +所以: + + + +```text + +1个 Approval + +``` + + + +不是形式主义,而是最低质量检查。 + + + +\--- + + + +\# 61. 测试与 PR + + + +当前项目已经提供: + + + +```powershell + +bun run lint + +bun run format:check + +bun run test + +bun run test:coverage + +bun run test:e2e + +``` + + + +普通 PR 至少根据改动范围运行相关检查。 + + + +例如简单 Agent 修改: + + + +```powershell + +bun run lint + +bun run test + +``` + + + +\--- + + + +\# 62. 未来 CI + + + +5号负责人后续会逐步建立: + + + +```text + +PR + +↓ + +CI + +↓ + +lint + +test + +build + +``` + + + +等 CI 稳定以后: + + + +再考虑把: + + + +```text + +Required status checks + +``` + + + +加入 Ruleset。 + + + +当前没有稳定 CI 前: + + + +不要提前启用一个永远无法通过的 Required Check。 + + + +\--- + + + +\# 63. 文档 PR + + + +文档修改也必须: + + + +```text + +docs/\* + +↓ + +PR + +↓ + +develop + +``` + + + +例如: + + + +```text + +docs/runbook + +docs/run-log + +docs/git-workflow + +``` + + + +这也是团队已经实际使用过的流程。 + + + +\--- + + + +\# 64. 小修改也不要直接 Push develop + + + +例如只改一个错别字: + + + +仍然建议: + + + +```text + +docs/fix-typo + +↓ + +PR + +↓ + +develop + +``` + + + +这样所有修改都有来源。 + + + +\--- + + + +\# 65. Tag 规则 + + + +当前已有: + + + +```text + +baseline-20260807 + +``` + + + +后续 Tag 由 1号负责人统一创建。 + + + +普通成员不要自行创建正式版本 Tag。 + + + +\--- + + + +\# 66. 后续版本示例 + + + +可能使用: + + + +```text + +v0.1.0 + +v0.2.0 + +v0.3.0 + + + +rc1 + +rc2 + + + +final + +``` + + + +最终命名由团队在比赛提交阶段统一确定。 + + + +\--- + + + +\# 67. Release 流程 + + + +阶段完成: + + + +```text + +功能分支 + +↓ + +develop + +↓ + +完整测试 + +↓ + +develop → main PR + +↓ + +Review + +↓ + +Merge + +↓ + +Tag + +↓ + +Release + +``` + + + +\--- + + + +\# 68. 比赛最终冻结阶段 + + + +比赛提交前进入: + + + +```text + +Freeze + +``` + + + +冻结以后: + + + +禁止: + + + +\- 随意加大型新功能; + +\- 随意升级 AionUi; + +\- 随意升级 AionCore; + +\- 随意升级模型 SDK; + +\- 随意重构核心架构。 + + + +只允许: + + + +\- 高优先级 Bug 修复; + +\- 测试; + +\- 文档; + +\- 必要的体验调整。 + + + +\--- + + + +\# 69. Freeze 阶段所有修改仍需 PR + + + +即使比赛截止只剩一天: + + + +也不能: + + + +```text + +直接 push main + +``` + + + +仍然: + + + +```text + +fix/\* + +↓ + +develop + +↓ + +测试 + +↓ + +main + +``` + + + +\--- + + + +\# 70. 回滚原则 + + + +如果某 PR 合入 develop 后出现严重问题: + + + +不要立刻: + + + +```powershell + +git reset --hard + +``` + + + +也不要 Force Push。 + + + +优先: + + + +```text + +确定问题 PR + +↓ + +使用 GitHub Revert + +↓ + +创建 Revert PR + +↓ + +Review + +↓ + +合并 + +``` + + + +这样历史仍然可追踪。 + + + +\--- + + + +\# 71. git reset --hard 风险 + + + +普通成员不要随意: + + + +```powershell + +git reset --hard + +``` + + + +它可能直接删除未提交的本地修改。 + + + +如果确实需要使用: + + + +先确认: + + + +```powershell + +git status + +``` + + + +并确保重要修改已经保存。 + + + +\--- + + + +\# 72. git clean 风险 + + + +禁止在不了解作用时运行: + + + +```powershell + +git clean -fd + +``` + + + +它可能删除未跟踪文件。 + + + +例如刚写好的: + + + +```text + +RUNBOOK.md + +``` + + + +如果尚未 Git add,理论上可能被清除。 + + + +\--- + + + +\# 73. git checkout / switch + + + +团队推荐使用较清晰的新命令: + + + +```powershell + +git switch + +``` + + + +例如: + + + +```powershell + +git switch develop + +``` + + + +创建: + + + +```powershell + +git switch -c feat/task + +``` + + + +\--- + + + +\# 74. git fetch 和 git pull 区别 + + + +```powershell + +git fetch + +``` + + + +只获取远程信息: + + + +不会直接修改当前工作文件。 + + + +```powershell + +git pull + +``` + + + +相当于: + + + +```text + +获取远程 + +\+ + +更新当前分支 + +``` + + + +普通日常同步: + + + +```powershell + +git switch develop + +git pull + +``` + + + +即可。 + + + +\--- + + + +\# 75. origin 和 upstream 不要混淆 + + + +团队成员最重要的是记住: + + + +```text + +origin + += + +我们自己的比赛仓库 + +``` + + + +```text + +upstream + += + +AionUi 官方仓库 + +``` + + + +比赛代码 Push: + + + +```powershell + +git push origin ... + +``` + + + +不要: + + + +```powershell + +git push upstream ... + +``` + + + +\--- + + + +\# 76. 检查 Remote + + + +如果不确定: + + + +```powershell + +git remote -v + +``` + + + +正确应类似: + + + +```text + +origin + +https://github.com/ai-campus-2026/AionUi-Campus.git + + + +upstream + +https://github.com/iOfficeAI/AionUi.git + +``` + + + +\--- + + + +\# 77. 出现 Push 被拒绝 + + + +如果: + + + +```text + +rejected + +protected branch + +``` + + + +先确认自己是不是直接 Push: + + + +```text + +main + +develop + +``` + + + +如果是: + + + +正确做法不是绕过规则。 + + + +而是: + + + +```text + +创建任务分支 + +↓ + +push + +↓ + +PR + +``` + + + +\--- + + + +\# 78. Pull 出现冲突 + + + +如果: + + + +```text + +CONFLICT + +``` + + + +不要继续乱执行。 + + + +保存: + + + +```text + +完整终端输出 + +``` + + + +然后找: + + + +```text + +对应模块负责人 + +或 + +1号负责人 + +``` + + + +一起处理。 + + + +\--- + + + +\# 79. PR 无法 Merge + + + +优先检查: + + + +1\. 有没有有效 Approval; + +2\. Reviewer 是否 Write; + +3\. 有没有未解决 Conversation; + +4\. 有没有新 Commit 导致旧 Approval 失效; + +5\. 有没有 Merge Conflict; + +6\. base 是否正确; + +7\. compare 是否正确。 + + + +\--- + + + +\# 80. PR Review 后又 Push 新 Commit + + + +如果 Review 后成员又: + + + +```powershell + +git push + +``` + + + +新增 Commit, + + + +GitHub 可能要求重新 Review。 + + + +这是正常保护行为。 + + + +不要为了避免重新审核而关闭保护规则。 + + + +\--- + + + +\# 81. 当前推荐日常流程 + + + +每天: + + + +```powershell + +cd "D:\\你的工作目录\\AionUi-Campus" + + + +git switch develop + +git pull + +``` + + + +开始任务: + + + +```powershell + +git switch -c feat/your-task + +``` + + + +开发: + + + +```powershell + +git status + +``` + + + +提交: + + + +```powershell + +git add <文件> + + + +git commit -m "feat(module): description" + +``` + + + +Push: + + + +```powershell + +git push -u origin feat/your-task + +``` + + + +GitHub: + + + +```text + +Pull Request + +↓ + +base develop + +↓ + +Review + +↓ + +Approve + +↓ + +Squash and merge + +``` + + + +结束: + + + +```powershell + +git switch develop + +git pull + + + +git branch -d feat/your-task + +``` + + + +\--- + + + +\# 82. 五人并行开发示例 + + + +例如当前: + + + +```text + +2号: + +feat/ui-chat-panel + + + +3号: + +feat/agent-workflow + + + +4号: + +feat/rag-retrieval + + + +5号: + +test/evaluation + +``` + + + +四个人可以同时开发。 + + + +各自: + + + +```text + +自己的分支 + +``` + + + +互不覆盖。 + + + +完成后逐个: + + + +```text + +PR → develop + +``` + + + +最后: + + + +```text + +develop + +``` + + + +形成完整集成版本。 + + + +\--- + + + +\# 83. 跨模块接口修改 + + + +如果 3号 Agent 需要 4号 RAG 提供接口: + + + +不要直接跑到 4号目录里大改。 + + + +先沟通: + + + +```text + +输入是什么 + +输出是什么 + +错误是什么 + +谁负责哪边 + +``` + + + +确认以后再分别开发。 + + + +\--- + + + +\# 84. 公共文件修改 + + + +以下文件属于高冲突区域: + + + +```text + +package.json + +bun.lock + +配置文件 + +公共类型 + +共享 IPC + +路由 + +核心入口 + +``` + + + +修改前建议通知: + + + +```text + +1号负责人 + +``` + + + +避免两个人同时改。 + + + +\--- + + + +\# 85. 依赖升级 + + + +普通成员不要随意: + + + +```powershell + +bun update + +``` + + + +也不要随便重写: + + + +```text + +bun.lock + +``` + + + +需要新增依赖时: + + + +先说明: + + + +```text + +为什么需要 + +是否已有同类依赖 + +影响多大 + +``` + + + +再决定。 + + + +\--- + + + +\# 86. package.json 修改 + + + +如果必须增加依赖或 Script: + + + +PR 描述中必须明确写: + + + +```text + +新增了什么 + +为什么新增 + +是否影响安装 + +是否需要重新 bun install + +``` + + + +\--- + + + +\# 87. 大文件 + + + +不要把: + + + +\- 模型权重; + +\- 视频; + +\- 超大数据集; + +\- 安装包; + +\- 数据库备份; + + + +直接塞入普通 Git 仓库。 + + + +需要时另行决定: + + + +```text + +Git LFS + +对象存储 + +阿里云 OSS + +``` + + + +等方案。 + + + +\--- + + + +\# 88. 个人 IDE 文件 + + + +尽量不要提交: + + + +```text + +.vscode 中个人配置 + +.idea + +临时缓存 + +个人路径配置 + +``` + + + +除非确定是全团队统一需要。 + + + +\--- + + + +\# 89. 日志文件 + + + +运行时生成的: + + + +```text + +\*.log + +``` + + + +原则上不提交。 + + + +出现 Bug 时: + + + +可以把关键日志: + + + +```text + +脱敏后复制到 Issue / PR + +``` + + + +而不是把几百 MB 日志加入 Git。 + + + +\--- + + + +\# 90. 成员离开某任务时 + + + +不要留下: + + + +```text + +只有自己本地有代码 + +``` + + + +阶段性成果至少: + + + +```text + +Commit + +Push 到个人任务分支 + +``` + + + +这样团队不会因为个人电脑出问题丢失工作。 + + + +\--- + + + +\# 91. Commit 不等于完成 + + + +```text + +Commit + +``` + + + +只表示本地保存。 + + + +```text + +Push + +``` + + + +才表示远端有备份。 + + + +```text + +PR Merged + +``` + + + +才表示正式进入团队集成版本。 + + + +\--- + + + +\# 92. 三个状态要区分 + + + +```text + +本地 Commit + +≠ + +远端 Push + +≠ + +develop 已合并 + +``` + + + +成员报告工作进度时应说明: + + + +```text + +已 Commit + +已 Push + +PR 待 Review + +已 Merge + +``` + + + +不要笼统说: + + + +```text + +“我已经做完了” + +``` + + + +\--- + + + +\# 93. Git 状态报告模板 + + + +遇到 Git 问题时发: + + + +```text + +【Git 问题】 + + + +成员: + +当前目录: + +当前分支: + + + +git status: + +<结果> + + + +git branch --show-current: + +<结果> + + + +git remote -v: + +<结果> + + + +执行的命令: + + + +完整报错: + + + +当前任务: + + + +是否有未提交修改: + +是 / 否 + +``` + + + +\--- + + + +\# 94. 不要只发截图的一小角 + + + +Git 报错时尽量: + + + +\- 包含执行命令; + +\- 包含完整错误; + +\- 包含当前路径; + +\- 包含当前分支。 + + + +这样其他成员才能判断。 + + + +\--- + + + +\# 95. 项目文档关系 + + + +当前基础工程文档: + + + +```text + +BASELINE.md + +``` + + + +回答: + + + +```text + +我们从哪个可靠版本开始? + +``` + + + +\--- + + + +```text + +RUNBOOK.md + +``` + + + +回答: + + + +```text + +项目怎么安装、启动和排错? + +``` + + + +\--- + + + +```text + +RUN\_LOG.md + +``` + + + +回答: + + + +```text + +团队当前实际验证到哪里? + +``` + + + +\--- + + + +```text + +GIT\_WORKFLOW.md + +``` + + + +回答: + + + +```text + +五个人怎么协同开发? + +``` + + + +\--- + + + +\# 96. 一页式 Git 快速操作 + + + +\## 开始任务 + + + +```powershell + +git switch develop + +git pull + +git status + + + +git switch -c feat/your-task + +``` + + + +\## 开发 + + + +```powershell + +git status + +git diff + +``` + + + +\## 提交 + + + +```powershell + +git add <文件> + + + +git status + + + +git commit -m "feat(module): description" + +``` + + + +\## 推送 + + + +```powershell + +git push -u origin feat/your-task + +``` + + + +\## GitHub + + + +```text + +New Pull Request + + + +base: + +develop + + + +compare: + +feat/your-task + +``` + + + +\## Review + + + +```text + +Files changed + +↓ + +Review changes + +↓ + +Approve + +↓ + +Submit review + +``` + + + +\## 合并 + + + +```text + +任务分支 → develop + + + +Squash and merge + +``` + + + +\## 合并以后 + + + +```powershell + +git switch develop + +git pull + +git status + + + +git branch -d feat/your-task + +``` + + + +\--- + + + +\# 97. 一页式禁止事项 + + + +禁止: + + + +```text + +直接 Push main + + + +直接 Push develop + + + +Force Push main/develop + + + +随意删除 main/develop + + + +提交 API Key + + + +提交真实 .env + + + +提交密码 + + + +提交个人隐私数据 + + + +提交未脱敏日志 + + + +随意升级 AionUi + + + +随意升级 AionCore + + + +普通成员自行同步 upstream + + + +不检查 git status 就 git add . + + + +不知道原因就 git reset --hard + + + +不知道原因就 git clean -fd + + + +出现冲突直接覆盖别人代码 + + + +未经沟通大改其他成员模块 + +``` + + + +\--- + + + +\# 98. 当前团队 Merge 规则 + + + +统一记住: + + + +```text + +feat/\* + +fix/\* + +docs/\* + +test/\* + +ci/\* + + ↓ + + develop + + + +使用: + +Squash and merge + +``` + + + +而: + + + +```text + +develop + + ↓ + +main + + + +使用: + +Merge + +``` + + + +\--- + + + +\# 99. 当前团队权限规则 + + + +```text + +1号负责人: + +Admin + + + +2号: + +Write + + + +3号: + +Write + + + +4号: + +Write + + + +5号: + +Write + +``` + + + +Review 要计入 Required Approval: + + + +Reviewer 必须具备: + + + +```text + +Write + +``` + + + +或更高权限。 + + + +\--- + + + +\# 100. 当前最终原则 + + + +所有成员必须记住以下 10 条: + + + +1\. 所有任务都从最新 `develop` 开始; + +2\. 不直接在 `main` 开发; + +3\. 不直接在 `develop` 开发; + +4\. 每个任务使用自己的任务分支; + +5\. 修改完成后通过 Pull Request; + +6\. PR 至少需要 1 个有效 Approval; + +7\. 任务分支进入 develop 使用 Squash; + +8\. develop 进入 main 使用 Merge; + +9\. API Key 和敏感数据绝不能进入 Git; + +10\. 遇到冲突、上游同步、版本变更等高风险操作先找 1号负责人。 + + + +\--- + + + +\# 101. 文档维护 + + + +以下情况发生后,需要更新本文件: + + + +\- GitHub Ruleset 改变; + +\- 团队权限改变; + +\- 分支模型改变; + +\- CI 成为 Required Check; + +\- Merge 策略改变; + +\- Release 流程改变; + +\- upstream 同步策略改变; + +\- 比赛进入最终冻结阶段。 + + + +如果本文档与 GitHub 当前强制 Ruleset 存在冲突: + + + +```text + +GitHub Ruleset + +``` + + + +具有实际强制效力。 + + + +同时应尽快更新本文件。 + + + +\--- + + + +\# 102. 当前开发阶段 + + + +当前基础工程阶段完成后: + + + +```text + +BASELINE.md + +RUNBOOK.md + +RUN\_LOG.md + +GIT\_WORKFLOW.md + +``` + + + +四份基础工程文档应全部进入: + + + +```text + +develop + +``` + + + +之后不继续为了“完善文档”而延迟开发。 + + + +下一阶段正式进入: + + + +```text + +统一阿里云模型 + + ↓ + +真实模型对话验收 + + ↓ + +UI / Agent / RAG / 测试并行开发 + + ↓ + +Agent + RAG 联调 + + ↓ + +阶段测试 + + ↓ + +develop → main + +``` + From eab897a41321205405bf65706bf57ff7166dd838 Mon Sep 17 00:00:00 2001 From: YimiSuper Date: Wed, 9 Sep 2026 18:50:07 +0800 Subject: [PATCH 09/10] feat: add rag-mcp-server and policy-search modules (#14) From 5e59118e64799ebc5461c308a9fa10bc68bece5f Mon Sep 17 00:00:00 2001 From: yvhang-fu <2090695930@qq.com> Date: Thu, 10 Sep 2026 22:19:50 +0800 Subject: [PATCH 10/10] =?UTF-8?q?feat(campus-rule):=20=E6=A0=A1=E5=9B=AD?= =?UTF-8?q?=E8=A7=84=E5=88=99=E8=A7=A3=E7=A0=81=E5=99=A8=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0=EF=BC=88=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0/=E6=8A=A5=E5=91=8A=E8=87=AA=E6=A3=80/=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E6=AF=94=E5=AF=B9=E5=88=86=E7=BB=84/=E8=A7=A3?= =?UTF-8?q?=E8=AF=BB=E8=AF=8A=E6=96=AD/=E6=88=AA=E6=96=AD=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/common/chat/normalizeToolCall.ts | 7 + .../components/campus-rule/AnswerTemplate.tsx | 120 +++ .../components/campus-rule/ConditionTable.tsx | 82 ++ .../campus-rule/CoursePlanPanel.tsx | 60 ++ .../components/campus-rule/DiagnosisPanel.tsx | 156 ++++ .../components/campus-rule/EvidenceCard.tsx | 72 ++ .../components/campus-rule/PolicyPanel.tsx | 37 + .../components/campus-rule/RuleErrorBox.tsx | 25 + .../campus-rule/adaptPolicyResult.ts | 358 ++++++++ .../components/campus-rule/campus-rule.css | 805 ++++++++++++++++++ .../renderer/components/campus-rule/index.ts | 8 + .../renderer/components/campus-rule/mock.ts | 304 +++++++ .../renderer/components/campus-rule/types.ts | 95 +++ .../src/renderer/components/layout/Router.tsx | 5 +- .../Sider/SiderNav/SiderWorkbenchEntry.tsx | 68 ++ .../components/layout/Sider/SiderNav/index.ts | 1 + .../components/layout/Sider/index.tsx | 23 +- .../index.tsx" | 91 ++ .../components/MessageToolGroupSummary.tsx | 124 +++ .../renderer/pages/workbench/ReportPage.tsx | 390 +++++++++ .../pages/workbench/WorkbenchPage.module.css | 430 ++++++++++ .../pages/workbench/WorkbenchPage.tsx | 334 ++++++++ .../src/renderer/pages/workbench/index.tsx | 8 + .../services/i18n/locales/en-US/index.ts | 4 + .../services/i18n/locales/en-US/report.json | 26 + .../i18n/locales/en-US/workbench.json | 35 + .../services/i18n/locales/zh-CN/index.ts | 4 + .../services/i18n/locales/zh-CN/report.json | 26 + .../i18n/locales/zh-CN/workbench.json | 35 + 29 files changed, 3731 insertions(+), 2 deletions(-) create mode 100644 packages/desktop/src/renderer/components/campus-rule/AnswerTemplate.tsx create mode 100644 packages/desktop/src/renderer/components/campus-rule/ConditionTable.tsx create mode 100644 packages/desktop/src/renderer/components/campus-rule/CoursePlanPanel.tsx create mode 100644 packages/desktop/src/renderer/components/campus-rule/DiagnosisPanel.tsx create mode 100644 packages/desktop/src/renderer/components/campus-rule/EvidenceCard.tsx create mode 100644 packages/desktop/src/renderer/components/campus-rule/PolicyPanel.tsx create mode 100644 packages/desktop/src/renderer/components/campus-rule/RuleErrorBox.tsx create mode 100644 packages/desktop/src/renderer/components/campus-rule/adaptPolicyResult.ts create mode 100644 packages/desktop/src/renderer/components/campus-rule/campus-rule.css create mode 100644 packages/desktop/src/renderer/components/campus-rule/index.ts create mode 100644 packages/desktop/src/renderer/components/campus-rule/mock.ts create mode 100644 packages/desktop/src/renderer/components/campus-rule/types.ts create mode 100644 packages/desktop/src/renderer/components/layout/Sider/SiderNav/SiderWorkbenchEntry.tsx create mode 100644 "packages/desktop/src/renderer/pages/campus\342\200\221rule\342\200\221demo/index.tsx" create mode 100644 packages/desktop/src/renderer/pages/workbench/ReportPage.tsx create mode 100644 packages/desktop/src/renderer/pages/workbench/WorkbenchPage.module.css create mode 100644 packages/desktop/src/renderer/pages/workbench/WorkbenchPage.tsx create mode 100644 packages/desktop/src/renderer/pages/workbench/index.tsx create mode 100644 packages/desktop/src/renderer/services/i18n/locales/en-US/report.json create mode 100644 packages/desktop/src/renderer/services/i18n/locales/en-US/workbench.json create mode 100644 packages/desktop/src/renderer/services/i18n/locales/zh-CN/report.json create mode 100644 packages/desktop/src/renderer/services/i18n/locales/zh-CN/workbench.json diff --git a/packages/desktop/src/common/chat/normalizeToolCall.ts b/packages/desktop/src/common/chat/normalizeToolCall.ts index b373c0943dd..51b8300985b 100644 --- a/packages/desktop/src/common/chat/normalizeToolCall.ts +++ b/packages/desktop/src/common/chat/normalizeToolCall.ts @@ -203,6 +203,10 @@ export function normalizeToolCall(message: IMessageToolCall): NormalizedToolCall const { call_id, name, status, input, output, args, description } = message.content; if (!call_id) return undefined; + const contentCompat = message.content as IMessageToolCall['content'] & { + _compact?: { truncated?: boolean; original_size?: number; preview_chars?: number }; + }; + const displayInput = input ? formatValue(input) : args && Object.keys(args).length > 0 @@ -216,6 +220,9 @@ export function normalizeToolCall(message: IMessageToolCall): NormalizedToolCall description: description || undefined, input: displayInput, output, + truncated: contentCompat._compact?.truncated === true, + messageId: message.id, + conversationId: message.conversation_id, }; } diff --git a/packages/desktop/src/renderer/components/campus-rule/AnswerTemplate.tsx b/packages/desktop/src/renderer/components/campus-rule/AnswerTemplate.tsx new file mode 100644 index 00000000000..a681d8f6a23 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/AnswerTemplate.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import type { CampusRuleToolResult } from './types'; +import EvidenceCard from './EvidenceCard'; +import RuleErrorBox from './RuleErrorBox'; +import CoursePlanPanel from './CoursePlanPanel'; +import PolicyPanel from './PolicyPanel'; +import DiagnosisPanel from './DiagnosisPanel'; +import ConditionTable from './ConditionTable'; +import './campus-rule.css'; + +// ========== AI 规则审阅结果容器(AnswerTemplate) ========== +// 视觉层级:状态 → ①结论 → 条件比对 → ②依据/条款 → ③建议 → ★解读诊断 +// 数据链路与渲染逻辑不变:policy 填四块 + 诊断(risks 非空);rag 仅证据卡。 +// 风险信息不再单列(与条件比对表状态列同源重复),并入条件比对 + 解读诊断。 + +// ---------- 状态条:极轻 ---------- +const statusBadgeMap: Record = { + success: { cls: 'cr-status__badge', text: '✓ 处理完成' }, + partial: { cls: 'cr-status__badge cr-status__badge--partial', text: '◐ 部分完成' }, + error: { cls: 'cr-status__badge cr-status__badge--error', text: '⚠ 处理异常' }, + blocked: { cls: 'cr-status__badge cr-status__badge--error', text: '⚠ 请求被拒绝' }, +}; + +// ---------- ① 结论:视觉第一重点 ---------- +// 展示层轻处理:剥离 conclusion 中的 markdown 星号(不改数据本身) +const stripMd = (s: string): string => s.replace(/\*\*/g, '').trim(); + +const ConclusionBlock: React.FC<{ content: string; hasRisks: boolean }> = ({ content, hasRisks }) => ( +
+
+ + ① 结论 +
+
+
{stripMd(content)}
+ + {hasRisks ? '⚠ 需进一步确认' : '✓ 基本符合'} + +
+
+); + +// ---------- ③ 建议:轻列表 ---------- +const SuggestionBlock: React.FC<{ suggestions: string[] }> = ({ suggestions }) => ( +
+
+ + ③ 建议 +
+
    + {suggestions.map((s, i) => ( +
  • {s}
  • + ))} +
+
+); + +// ========== 主容器 ========== +const AnswerTemplate: React.FC<{ result: CampusRuleToolResult; question?: string }> = ({ result, question }) => { + const badge = statusBadgeMap[result.status]; + const hasRisks = (result.risks?.length ?? 0) > 0; + + return ( +
+ {/* 状态条 */} +
+ {badge.text} + {result.toolName} +
+ + {/* 异常状态(失败/越界/缺失警告)低调展示 */} + {result.error && ( +
+ +
+ )} + + {/* ① 结论 */} + {result.conclusion && } + + {/* 条件比对(policy 判定器专属):逐条「条件 × 你的值 × 要求 × 状态」, + 作为结论的判定明细;rag 无此数据不展示。 + 优先按类别分组展示(category_matches),缺失时回退平铺表。 */} + {(result.conditionGroups && result.conditionGroups.length > 0 && ( + + )) || + (result.conditionTable && result.conditionTable.length > 0 && ( + + ))} + + {/* ② 依据:引用证据来源(四块模板固定结构,policy / rag 均展示) */} + {result.evidences && result.evidences.length > 0 && } + + {/* 政策条款命中:与②依据同源于 source_quote,内容重复时不再重复展示; + 仅在无证据卡时兜底(正常 policy 结果均含 evidences,此块基本不出现) */} + {!(result.evidences && result.evidences.length > 0) && + result.policyHits && + result.policyHits.length > 0 && } + + {/* 学业进度(课程规则工具专属) */} + {result.coursePlan && result.coursePlan.length > 0 && } + + {/* ③ 建议下一步(风险信息已并入条件比对表状态列 + 解读诊断,不再单列) */} + {result.suggestions && result.suggestions.length > 0 && ( + + )} + + {/* ★ 解读诊断:risks 非空且非错误状态时展示(rag 纯检索不触发) */} + {hasRisks && result.status !== 'error' && } +
+ ); +}; + +export default AnswerTemplate; diff --git a/packages/desktop/src/renderer/components/campus-rule/ConditionTable.tsx b/packages/desktop/src/renderer/components/campus-rule/ConditionTable.tsx new file mode 100644 index 00000000000..af162710539 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/ConditionTable.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import type { ConditionGroup, ConditionRow } from './types'; +import './campus-rule.css'; + +// ========== 逐条条件比对表(policy 判定器专属) ========== +// 展示「条件 × 你的值 × 政策要求 × 状态」,把 query_policy 的判定过程可视化。 +// 支持两种形态: +// groups:按类别分组(成绩/外语/科研/竞赛…),一眼看清政策卡在哪些方面; +// data:平铺列表(category_matches 缺失时兜底,如旧数据/精简返回)。 +// 视觉:低饱和状态色,不喧宾夺主;移动端每行转卡片式。 + +const stateMap: Record = { + met: { cls: 'cr-ct__state--met', text: '✓ 满足' }, + not_met: { cls: 'cr-ct__state--not-met', text: '✗ 未满足' }, + missing_info: { cls: 'cr-ct__state--missing', text: '⚠ 未提供' }, + needs_manual_review: { cls: 'cr-ct__state--review', text: '● 需核实' }, +}; + +const RowList: React.FC<{ rows: ConditionRow[] }> = ({ rows }) => ( + <> + {rows.map((row) => { + const st = stateMap[row.match] ?? stateMap.needs_manual_review; + return ( +
+
{row.item}
+
{row.userValue ?? '未提供'}
+
{row.requirement ?? '—'}
+
+ {st.text} +
+
+ ); + })} + +); + +const ConditionTable: React.FC<{ data?: ConditionRow[]; groups?: ConditionGroup[] }> = ({ data, groups }) => { + const hasGroups = !!groups && groups.length > 0; + return ( +
+
+ + 条件比对 +
+ {hasGroups ? ( + // 分组形态:每组一个分组容器(组标签 + 组内行) +
+ {groups!.map((g) => ( +
+
+ {g.label} + {g.rows.length} 项 +
+
+
+
条件
+
你的值
+
政策要求
+
状态
+
+ +
+
+ ))} +
+ ) : ( + // 平铺形态(兜底) +
+
+
条件
+
你的值
+
政策要求
+
状态
+
+ {data && } +
+ )} +
+ ); +}; + +export default ConditionTable; diff --git a/packages/desktop/src/renderer/components/campus-rule/CoursePlanPanel.tsx b/packages/desktop/src/renderer/components/campus-rule/CoursePlanPanel.tsx new file mode 100644 index 00000000000..c60629e8699 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/CoursePlanPanel.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Table, Tag } from '@arco-design/web-react'; +import type { CourseItem } from './types'; + +const statusColorMap: Record = { + completed: 'green', + studying: 'blue', + planned: 'gray' +}; + +const statusTextMap: Record = { + completed: '已修完', + studying: '修读中', + planned: '待修读' +}; + +// 学业进度一览(课程规则工具专属,②依据的可视化展开) +const CoursePlanPanel: React.FC<{ data: CourseItem[] }> = ({ data }) => { + const columns = [ + { + title: '课程名称', + dataIndex: 'courseName', + width: 160 + }, + { + title: '学分', + dataIndex: 'credit', + width: 80 + }, + { + title: '学期', + dataIndex: 'semester', + width: 100 + }, + { + title: '状态', + dataIndex: 'status', + render: (status: CourseItem['status']) => ( + {statusTextMap[status]} + ) + }, + { + title: '成绩', + dataIndex: 'score', + render: (score?: number) => score ?? '—' + } + ]; + + return ( +
+
+ + 学业进度一览 +
+ + + ); +}; + +export default CoursePlanPanel; diff --git a/packages/desktop/src/renderer/components/campus-rule/DiagnosisPanel.tsx b/packages/desktop/src/renderer/components/campus-rule/DiagnosisPanel.tsx new file mode 100644 index 00000000000..41c716da90c --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/DiagnosisPanel.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import type { CampusRuleToolResult } from './types'; + +interface Props { + result: CampusRuleToolResult; + /** 用户问题原文(来自工具调用的 input,用于展示"现象") */ + question?: string; +} + +// ========== ★ 解读诊断(DiagnosisPanel) ========== +// AI 审阅层:AnswerTemplate 内部更深一层的玻璃。 +// 默认折叠(摘要 + 证据核验进度);点击展开 01现象/02可能原因/03验证依据/04诊断结果。 +// 触发条件由 AnswerTemplate 控制:risks 非空且 status!=='error'。 +// 所有数据来自现有字段:question / risks / evidences / policyHits / conclusion, +// 置信度为展示层推导(已覆盖证据 ÷ 证据+风险项),不虚构后端字段。 + +// 展示层轻处理:剥离 markdown 星号(不改数据) +const stripMd = (s: string): string => s.replace(/\*\*/g, '').trim(); + +const levelLabelMap: Record = { + high: '未满足', + medium: '信息不足', + low: '需核实', +}; + +const levelDotMap: Record = { + high: 'cr-diagnosis__item-dot--high', + medium: 'cr-diagnosis__item-dot--medium', + low: 'cr-diagnosis__item-dot--low', +}; + +const DiagnosisPanel: React.FC = ({ result, question }) => { + const [open, setOpen] = useState(false); + + const evidences = result.evidences ?? []; + const goodEvidences = evidences.filter((e) => !e.lowRelevance); + const risks = result.risks ?? []; + + // 置信度:已覆盖证据 / (已覆盖证据 + 风险项),与报告自检页同口径 + const total = goodEvidences.length + risks.length; + const confidence = total > 0 ? Math.round((goodEvidences.length / total) * 100) : 0; + const confidenceColor = confidence >= 70 ? 'var(--color-success-6)' : confidence >= 40 ? 'var(--color-warning-6)' : 'var(--color-danger-6)'; + + // 验证依据:优先取第一条非低相关证据,其次政策命中条款 + const evidence = goodEvidences[0] ?? evidences[0]; + const policyHit = result.policyHits?.[0]; + const evidenceSource = evidence?.fileName ?? policyHit?.source ?? ''; + const evidenceQuote = evidence?.quoteContent ?? policyHit?.quoteContent ?? ''; + + const toggle = () => setOpen((v) => !v); + + return ( +
+ {/* 头部:始终可见,点击展开/收起 */} +
+ + + 解读诊断 + + + {open ? '收起 ⌃' : '展开 ⌄'} + +
+ + {/* 摘要区:发现 N 项问题 + 证据核验进度 */} +
+
发现 {risks.length} 项需要注意的问题
+ {risks.slice(0, 3).map((r) => ( +
+ + {r.title} +
+ ))} +
+ 证据核验 +
+
+
+ + {confidence}% + +
+
+ + {/* 展开区:01~04 结构化审阅(max-height + opacity + translate 动画) */} +
+
+ {/* 01 现象 */} + {question && ( +
+ 01 +
+
现象
+
“{question}”
+
+
+ )} + + {/* 02 可能原因 */} +
+ 02 +
+
可能原因
+ {risks.slice(0, 3).map((r) => ( +
+ + + {levelLabelMap[r.level]} · {r.title}:{r.description} + +
+ ))} +
+
+ + {/* 03 验证依据 */} + {evidenceQuote && ( +
+ 03 +
+
验证依据
+ {evidenceSource &&
来源:{evidenceSource}
} +
“{evidenceQuote}”
+
+
+ )} + + {/* 04 诊断结果 + 置信度(进度条延迟 150ms 出现) */} +
+ 04 +
+
诊断结果
+
{result.conclusion ? stripMd(result.conclusion) : '—'}
+
+ 置信度 +
+
+
+ + {confidence}% + +
+
+
+
+
+
+ ); +}; + +export default DiagnosisPanel; diff --git a/packages/desktop/src/renderer/components/campus-rule/EvidenceCard.tsx b/packages/desktop/src/renderer/components/campus-rule/EvidenceCard.tsx new file mode 100644 index 00000000000..7aee3c20ada --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/EvidenceCard.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import type { EvidenceItem } from './types'; + +interface Props { + data: EvidenceItem[]; +} + +// ② 依据:引用证据来源(文档感卡片) +// lowRelevance = 弱相关(相关度低于 50%):整体灰化 + 「辅助依据」,不作为可靠依据呈现 +// 相关度数值来自 rag 返回拼入引用末尾的 "[相关度 N%]"(展示层解析,不改数据); +// 无相关度数值(如 policy 条件匹配原文)时展示「直接依据」类型标记。 + +/** 从引用原文中提取相关度数值并剥离附加标注(纯展示处理,不修改数据字段) */ +function parseRelevance(quote: string): { text: string; relevance?: number } { + const match = quote.match(/\[相关度\s*(\d+)%\]/); + const text = quote + .replace(/\n*\[相关度\s*\d+%\]/g, '') + .replace(/(相关度较低,仅供参考)/g, '') + .trim(); + return { + text, + relevance: match ? Number(match[1]) : undefined, + }; +} + +const EvidenceCard: React.FC = ({ data }) => { + return ( +
+
+ + ② 依据 · 引用证据来源 +
+ {data.map((item) => { + const { text, relevance } = parseRelevance(item.quoteContent); + const isLow = Boolean(item.lowRelevance); + + return ( +
+ {/* 头部:来源名称 + 类型 Tag */} +
+ + 📄 + {item.fileName} + + + {item.pageNum > 0 ? `第 ${item.pageNum} 页` : '政策原文'} + +
+ + {/* 引用原文(正文剥离相关度标注,突出原文本身) */} +

{text}

+ + {/* 底部:相关度 / 依据类型 */} +
+ + {relevance !== undefined ? `相关度 ${relevance}%` : '来源:政策原文'} + + + {isLow ? '⚠ 与当前问题关联较弱 · 仅供参考' : '✓ 直接依据'} + +
+
+ ); + })} +
+ ); +}; + +export default EvidenceCard; diff --git a/packages/desktop/src/renderer/components/campus-rule/PolicyPanel.tsx b/packages/desktop/src/renderer/components/campus-rule/PolicyPanel.tsx new file mode 100644 index 00000000000..4a233e275c2 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/PolicyPanel.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import type { PolicyHit } from './types'; + +interface Props { + data: PolicyHit[]; +} + +// 政策检索命中条款(政策检索工具专属) +// 数据与 AnswerTemplate 同源:仅视觉与 EvidenceCard 保持一致(文档感) +const PolicyPanel: React.FC = ({ data }) => { + return ( +
+
+ + 政策条款命中 +
+ {data.map((item) => ( +
+
+ 📑 {item.title} + {item.source} +
+
+ {item.keywords.map((kw) => ( + + {kw} + + ))} +
+

{item.quoteContent}

+
+ ))} +
+ ); +}; + +export default PolicyPanel; diff --git a/packages/desktop/src/renderer/components/campus-rule/RuleErrorBox.tsx b/packages/desktop/src/renderer/components/campus-rule/RuleErrorBox.tsx new file mode 100644 index 00000000000..e2d332e83f0 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/RuleErrorBox.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { IconExclamationCircleFill, IconCheckCircleFill, IconInfoCircleFill } from '@arco-design/web-react/icon'; +import type { ErrorInfo } from './types'; + +// 异常状态提示(警告 / 失败 / 越界拒绝) +// 低调展示:浅色底 + 细边框 + 小图标,不做大面积红色错误页 +const iconMap = { + warning: , + error: , + info: , +} as const; + +const RuleErrorBox: React.FC<{ info: ErrorInfo }> = ({ info }) => { + return ( +
+ {iconMap[info.type]} +
+
{info.title}
+
{info.description}
+
+
+ ); +}; + +export default RuleErrorBox; diff --git a/packages/desktop/src/renderer/components/campus-rule/adaptPolicyResult.ts b/packages/desktop/src/renderer/components/campus-rule/adaptPolicyResult.ts new file mode 100644 index 00000000000..9389bde8901 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/adaptPolicyResult.ts @@ -0,0 +1,358 @@ +// ========== policy.query_policy → CampusRuleToolResult 适配层 ========== +// 把队友 policy-search 的 query_policy 结构化返回,映射成前端四块模板需要的数据结构。 +// 队友返回结构: +// { +// "total_policies": N, +// "results": [ +// { +// "policy_title": "...", +// "policy_category": "...", +// "overall_verdict": "likely_eligible" | "not_eligible" | "needs_more_info" | "needs_review", +// "category_matches": {...}, +// "condition_matches": [{ item, match, user_value, requirement, detail, source_quote }], +// "missing_info": [...], +// "needs_manual_review": [...] +// } +// ] +// } + +import type { CampusRuleToolResult, ConditionGroup, ConditionRow, EvidenceItem, PolicyHit, RiskItem } from './types'; + +// verdict → 中文结论 映射 +const VERDICT_TEXT: Record = { + likely_eligible: '符合条件', + not_eligible: '暂不符合条件', + needs_more_info: '信息不足,需补充', + needs_review: '需人工复核', +}; + +// verdict → 风险等级 +const VERDICT_RISK_LEVEL: Record = { + likely_eligible: 'low', + not_eligible: 'high', + needs_more_info: 'medium', + needs_review: 'medium', +}; + +/** 判断是否为 policy.query_policy 的返回结构 */ +export const isPolicyQueryResult = (data: unknown): boolean => { + if (typeof data !== 'object' || data === null) return false; + const obj = data as Record; + // 有 results 数组且每项含 overall_verdict / condition_matches + if (!Array.isArray(obj.results)) return false; + return obj.results.some( + (r) => + r && + typeof r === 'object' && + 'overall_verdict' in (r as Record) && + 'condition_matches' in (r as Record) + ); +}; + +/** 把 policy.query_policy 的返回映射成 CampusRuleToolResult */ +export const adaptPolicyQueryResult = (raw: unknown): CampusRuleToolResult | null => { + if (!isPolicyQueryResult(raw)) return null; + const obj = raw as { results: Array> }; + const results = obj.results; + + if (results.length === 0) { + return { + type: 'policy_retrieval', + toolName: 'query_policy', + status: 'error', + summary: '未检索到相关政策。', + error: { type: 'warning', title: '无匹配结果', description: '知识库中没有相关政策或用户信息不足。' }, + }; + } + + // 取第一个政策作为主结果(前端模板主要展示单个判定) + const primary = results[0]; + const policyTitle = (primary.policy_title as string) || '校园政策'; + const verdict = (primary.overall_verdict as string) || 'needs_more_info'; + const conditionMatches = (primary.condition_matches as Array>) || []; + // 展示层去重:队友 query_policy 可能把同一缺失信息/复核项按多个政策档位重复返回 + // (如"学业成绩排名"重复 5 次),前端去重后只展示一次,避免同一条件重复出现。 + const missingInfo = Array.from(new Set((primary.missing_info as string[]) || [])); + const manualReview = Array.from(new Set((primary.needs_manual_review as string[]) || [])); + + // ① 结论:综合判定 + const conclusionText = VERDICT_TEXT[verdict] || '需人工复核'; + let conclusion = `针对「${policyTitle}」,根据你提供的信息,初步判定:**${conclusionText}**。`; + + // ② 依据:从条件匹配里提取原文引用(source_quote)作为证据 + // 同一 source_quote 可能因多个条件变体重复出现,按原文去重。 + const seenQuote = new Set(); + const uniqueConditionMatches = conditionMatches.filter((c) => { + const q = (c.source_quote as string)?.trim(); + if (!q) return false; + if (seenQuote.has(q)) return false; + seenQuote.add(q); + return true; + }); + + const evidences: EvidenceItem[] = uniqueConditionMatches.map((c, idx) => ({ + id: `ev-${idx + 1}`, + fileName: policyTitle, + fileType: 'pdf' as const, + pageNum: 0, + quoteContent: (c.source_quote as string).trim(), + })); + + // ★ 逐条条件比对表:展示「条件 × 你的值 × 政策要求 × 状态」 + // 同一条件名可能因多档位重复(如"学业成绩排名"多档),按条件名去重保留第一条。 + const toConditionRow = (c: Record, idx: number): ConditionRow => { + const match = (c.match as ConditionRow['match']) || 'needs_manual_review'; + const userValue = c.user_value ? String(c.user_value) : undefined; + const requirement = (c.requirement as string) || undefined; + return { + id: `ct-${idx + 1}`, + item: (c.item as string)?.trim() || '条件', + match, + userValue, + requirement, + sourceQuote: (c.source_quote as string)?.trim() || undefined, + }; + }; + + const seenConditionItem = new Set(); + const conditionTable: ConditionRow[] = uniqueConditionMatches.reduce((acc, c, idx) => { + const item = (c.item as string)?.trim(); + if (!item || seenConditionItem.has(item)) return acc; + seenConditionItem.add(item); + acc.push(toConditionRow(c, idx)); + return acc; + }, []); + + // ★ 条件比对(按类别分组):category_matches → 组标签 + 组内行(同样按条件名去重) + // 队友返回结构:{ category_key: { label, matches: [...] } } + const rawCategoryMatches = (primary.category_matches as + | Record> }> + | undefined); + let conditionGroups: ConditionGroup[] | undefined; + if (rawCategoryMatches && typeof rawCategoryMatches === 'object') { + const groups: ConditionGroup[] = []; + let gIdx = 0; + for (const [key, cat] of Object.entries(rawCategoryMatches)) { + if (!cat || !Array.isArray(cat.matches) || cat.matches.length === 0) continue; + const seenItem = new Set(); + const rows: ConditionRow[] = []; + cat.matches.forEach((m, idx) => { + const item = (m.item as string)?.trim(); + if (!item || seenItem.has(item)) return; + seenItem.add(item); + rows.push(toConditionRow(m, idx)); + }); + if (rows.length === 0) continue; + groups.push({ + id: `ctg-${gIdx++}`, + label: cat.label || key, + rows, + }); + } + if (groups.length > 0) conditionGroups = groups; + } + + // ③ 风险/缺失:与条件比对表同源(missing_info/needs_manual_review/not_met), + // 为避免重复展示,仅保留「真正影响结论」的摘要: + // - not_met:未满足 → 逐条列出(真正的硬伤,一般 0~2 条) + // - missing_info:缺少信息 → 汇总成一条(供解读诊断摘要用,明细在条件比对表) + // - needs_manual_review:需人工核实 → 汇总成一条 + const risks: RiskItem[] = []; + const seenRiskTitle = new Set(); + const pushRisk = (risk: RiskItem) => { + if (seenRiskTitle.has(risk.title)) return; + seenRiskTitle.add(risk.title); + risks.push(risk); + }; + const notMet = uniqueConditionMatches.filter((c) => c.match === 'not_met'); + notMet.forEach((c) => { + pushRisk({ + level: 'high', + title: `未满足:${(c.item as string) || '条件'}`, + description: (c.detail as string) || `未满足「${(c.requirement as string) || c.item}」`, + }); + }); + if (missingInfo.length > 0) { + pushRisk({ + level: 'medium', + title: `缺少信息(${missingInfo.length} 项)`, + description: `${missingInfo.join('、')}。补充后可进一步判断。`, + }); + } + if (manualReview.length > 0) { + pushRisk({ + level: 'low', + title: `需人工核实(${manualReview.length} 项)`, + description: `${manualReview.join('、')}。请对照政策原文确认。`, + }); + } + + // ④ 建议:not_met 和 missing 项 → 建议补充 / 调整 + const suggestions: string[] = []; + if (verdict === 'not_eligible') { + const topReasons = notMet.slice(0, 3).map((c) => (c.item as string) || '条件'); + suggestions.push( + topReasons.length > 0 + ? `当前不满足 ${topReasons.join('、')},可针对性地补充或提升后再尝试申请。` + : '当前不满足申请条件,建议核对政策原文确认差距。' + ); + } + if (missingInfo.length > 0) { + suggestions.push(`请补充:${missingInfo.join('、')},以便更准确地评估符合情况。`); + } + if (manualReview.length > 0) { + suggestions.push(`以下项目需人工复核或确认:${manualReview.join('、')}。`); + } + if (suggestions.length === 0) { + suggestions.push('如需进一步确认,可提供更详细的个人信息或咨询相关部门。'); + } + + // 政策命中条款:供 PolicyPanel 展示(与证据同源,同样按原文去重) + const policyHits: PolicyHit[] = uniqueConditionMatches.map((c, idx) => ({ + id: `ph-${idx + 1}`, + title: (c.item as string) || '条款', + source: policyTitle, + quoteContent: (c.source_quote as string).trim(), + keywords: [(c.item as string) || ''], + })); + + // summary:整体话术(带政策名 + 判定) + const summary = `根据「${policyTitle}」政策,我对你提供的信息进行了条件比对,结果如下。`; + + return { + type: 'policy_retrieval', + toolName: 'query_policy', + status: verdict === 'not_eligible' ? 'partial' : 'success', + summary, + conclusion, + conditionTable: conditionTable.length > 0 ? conditionTable : undefined, + conditionGroups: conditionGroups, + evidences: evidences.length > 0 ? evidences : undefined, + risks: risks.length > 0 ? risks : undefined, + suggestions: suggestions.length > 0 ? suggestions : undefined, + policyHits: policyHits.length > 0 ? policyHits : undefined, + }; +}; + +/** 通用入口:尝试多种解析方式,返回 CampusRuleToolResult 或 null */ +export const tryParseCampusRuleResult = (output: string): CampusRuleToolResult | null => { + let jsonObj: unknown; + try { + jsonObj = JSON.parse(output); + } catch { + return null; // 不是合法 JSON,交给原逻辑 + } + // 1. 已识别为校园规则(原逻辑) + if ( + jsonObj && + typeof jsonObj === 'object' && + ((jsonObj as Record).type === 'campus_rule_analysis' || + (jsonObj as Record).type === 'policy_retrieval') + ) { + return jsonObj as CampusRuleToolResult; + } + // 2. policy.query_policy 返回结构 → 适配 + const adapted = adaptPolicyQueryResult(jsonObj); + if (adapted) return adapted; + // 3. rag search 返回结构 → 适配(检索型工具,无判定结论,仅展示知识来源证据) + const ragAdapted = adaptRagSearchResult(jsonObj); + if (ragAdapted) return ragAdapted; + return null; +}; + +// ===== rag search → CampusRuleToolResult 适配 ===== +// 队友 rag-mcp-server 的 search 返回结构: +// { +// "results": [{ "text": "文档块原文", "source": "文件路径", "page": 1|null, "similarity": 0.65, "chunk_index": 0 }], +// "count": N, +// "error": null | "..." +// } + +/** 判断是否为 rag search 的返回结构(results 每项含 text + source) */ +export const isRagSearchResult = (data: unknown): boolean => { + if (typeof data !== 'object' || data === null) return false; + const obj = data as Record; + if (!Array.isArray(obj.results)) return false; + if (obj.results.length === 0) return obj.error !== undefined || 'count' in obj; + return obj.results.every( + (r) => r && typeof r === 'object' && 'text' in (r as Record) && 'source' in (r as Record) + ); +}; + +const fileTypeFromSource = (source: string): EvidenceItem['fileType'] => { + const ext = source.toLowerCase().split('.').pop() || ''; + if (ext === 'pdf') return 'pdf'; + if (ext === 'docx') return 'docx'; + return 'txt'; +}; + +const basenameOf = (path: string): string => { + const normalized = path.replace(/\\/g, '/'); + const parts = normalized.split('/'); + return parts[parts.length - 1] || path; +}; + +export const adaptRagSearchResult = (raw: unknown): CampusRuleToolResult | null => { + if (!isRagSearchResult(raw)) return null; + const obj = raw as { results: Array>; count?: number; error?: unknown }; + + // 空结果:区分「知识库为空」与「无相关内容」,给可读提示 + if (!obj.results || obj.results.length === 0) { + const errText = typeof obj.error === 'string' ? obj.error : ''; + const isEmptyKB = errText.includes('知识库为空'); + return { + type: 'policy_retrieval', + toolName: 'search', + status: 'error', + summary: isEmptyKB ? '知识库还没有加载任何文档。' : '知识库中没有检索到相关内容。', + error: { + type: 'warning', + title: isEmptyKB ? '知识库为空' : '无匹配内容', + description: isEmptyKB + ? '请先使用 load_document / load_pdf 工具加载政策或规则文档。' + : '当前问题与知识库中的文档内容不相关,未检索到可用依据。', + }, + }; + } + + // 有结果:把每个文档块映射成一条证据引用(② 依据) + // 相关度分级:>= 50% 正常证据;30%~50% 弱相关(lowRelevance,仅作参考); + // 展示阈值取 rag server 的召回门槛(0.3)之上,前端只做展示分级,不隐藏不删改。 + const LOW_RELEVANCE_THRESHOLD = 0.5; + const evidences: EvidenceItem[] = obj.results.map((r, idx) => { + const source = String(r.source || '未知来源'); + const text = String(r.text || '').trim(); + const page = typeof r.page === 'number' ? r.page : 0; + const sim = typeof r.similarity === 'number' ? r.similarity : undefined; + const baseQuote = text.length > 0 ? text : '(空文档块)'; + const isLow = sim !== undefined && sim < LOW_RELEVANCE_THRESHOLD; + // 相似度作为小标注拼在引用末尾,方便演示时看到检索排序依据 + const simSuffix = sim !== undefined ? `\n\n[相关度 ${(sim * 100).toFixed(0)}%]` : ''; + const lowSuffix = isLow ? '(相关度较低,仅供参考)' : ''; + const quoteContent = `${baseQuote}${simSuffix}${lowSuffix}`; + return { + id: `rag-ev-${idx + 1}`, + fileName: basenameOf(source), + fileType: fileTypeFromSource(source), + pageNum: page, + quoteContent, + lowRelevance: isLow, + }; + }); + + const hasLowRelevance = evidences.some((e) => e.lowRelevance); + const summary = + `已从知识库检索到 ${obj.results.length} 条相关内容` + + (typeof obj.results[0]?.similarity === 'number' + ? `(最高相关度 ${((obj.results[0].similarity as number) * 100).toFixed(0)}%)` + : '') + + (hasLowRelevance ? ',其中部分内容相关度较低,仅作参考。' : ',引用原文如下,请结合来源核对。'); + + return { + type: 'policy_retrieval', + toolName: 'search', + status: 'success', + summary, + evidences: evidences.length > 0 ? evidences : undefined, + }; +}; diff --git a/packages/desktop/src/renderer/components/campus-rule/campus-rule.css b/packages/desktop/src/renderer/components/campus-rule/campus-rule.css new file mode 100644 index 00000000000..09d380c239a --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/campus-rule.css @@ -0,0 +1,805 @@ +/* ============================================================ + 校园规则解码器 · AI 规则审阅工作台 视觉系统 + 设计:Apple / Liquid Glass,克制使用。 + 原则:少而精的玻璃 + 大留白 + 清晰层级 + 微妙动效。 + 数据链路与组件 props 一律不动,仅视觉/交互层。 + 依赖 Arco Design 既有 CSS 变量(--color-*),自动适配明暗主题。 + ============================================================ */ + +/* ---------- 外层审阅容器:轻微玻璃 ---------- */ +.cr-answer { + position: relative; + border-radius: 16px; + padding: 20px 20px 16px; + background: color-mix(in srgb, var(--color-bg-2) 52%, transparent); + -webkit-backdrop-filter: blur(20px) saturate(1.4); + backdrop-filter: blur(20px) saturate(1.4); + border: 1px solid color-mix(in srgb, var(--color-border-2) 45%, transparent); + box-shadow: + 0 1px 2px rgba(22, 24, 29, 0.03), + 0 8px 32px rgba(22, 24, 29, 0.05); + overflow: hidden; +} + +/* 顶部细高光(玻璃上边缘) */ +.cr-answer::before { + content: ''; + position: absolute; + top: 0; + left: 12px; + right: 12px; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.55), transparent); + pointer-events: none; +} + +[data-theme='dark'] .cr-answer::before { + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.09), transparent); +} + +/* 模块间弱分割线 + 大留白 */ +.cr-block { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid color-mix(in srgb, var(--color-border-2) 50%, transparent); +} + +.cr-block--first { + margin-top: 12px; + border-top: none; + padding-top: 0; +} + +/* 区块小标题 */ +.cr-block-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--color-text-3); + margin-bottom: 10px; + user-select: none; +} + +.cr-block-title .cr-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--color-primary-6); + opacity: 0.85; +} + +/* ---------- 状态条:极轻 ---------- */ +.cr-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--color-text-3); + margin-bottom: 4px; +} + +.cr-status__badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + background: color-mix(in srgb, var(--color-success-6) 10%, transparent); + color: var(--color-success-6); + border: 1px solid color-mix(in srgb, var(--color-success-6) 25%, transparent); +} + +.cr-status__badge--partial { + background: color-mix(in srgb, var(--color-warning-6) 10%, transparent); + color: var(--color-warning-6); + border-color: color-mix(in srgb, var(--color-warning-6) 25%, transparent); +} + +.cr-status__badge--error { + background: color-mix(in srgb, var(--color-danger-6) 8%, transparent); + color: var(--color-danger-6); + border-color: color-mix(in srgb, var(--color-danger-6) 22%, transparent); +} + +.cr-status__tool { + font-size: 12px; + color: var(--color-text-4); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +/* ---------- ① 结论:视觉第一重点 ---------- */ +.cr-conclusion { + padding: 4px 0 2px; +} + +.cr-conclusion__verdict { + font-size: 18px; + font-weight: 600; + line-height: 1.6; + color: var(--color-text-1); + letter-spacing: 0.01em; +} + +.cr-conclusion__detail { + margin-top: 6px; + font-size: 13.5px; + line-height: 1.7; + color: var(--color-text-2); +} + +/* 结论下方轻状态徽章 */ +.cr-conclusion__chip { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 10px; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} + +.cr-conclusion__chip--ok { + background: color-mix(in srgb, var(--color-success-6) 9%, transparent); + color: var(--color-success-6); +} + +.cr-conclusion__chip--warn { + background: color-mix(in srgb, var(--color-warning-6) 9%, transparent); + color: var(--color-warning-6); +} + +/* ---------- ② 依据:文档感 ---------- */ +.cr-evidence { + position: relative; + margin-bottom: 10px; + border-radius: 12px; + padding: 12px 14px 10px 16px; + background: color-mix(in srgb, var(--color-bg-2) 62%, transparent); + border: 1px solid color-mix(in srgb, var(--color-border-2) 42%, transparent); + border-left: 3px solid color-mix(in srgb, var(--color-primary-6) 70%, var(--color-border-2)); + box-shadow: 0 1px 6px rgba(22, 24, 29, 0.03); +} + +/* 弱相关证据:整体灰化,不渲染成红色警告 */ +.cr-evidence--low { + border-left-color: var(--color-fill-4); + opacity: 0.62; + filter: grayscale(0.35); +} + +.cr-evidence__head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cr-evidence__source { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--color-text-1); + min-width: 0; +} + +.cr-evidence__source-icon { + color: var(--color-text-4); + font-size: 12px; +} + +.cr-evidence__tag { + display: inline-flex; + align-items: center; + padding: 0 8px; + height: 20px; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + background: color-mix(in srgb, var(--color-primary-6) 8%, transparent); + color: var(--color-text-3); + border: 1px solid color-mix(in srgb, var(--color-border-2) 60%, transparent); +} + +.cr-evidence__quote { + margin: 8px 0 0; + font-size: 13px; + line-height: 1.7; + color: var(--color-text-2); +} + +/* 底部:相关度 / 依据类型 */ +.cr-evidence__foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed color-mix(in srgb, var(--color-border-2) 55%, transparent); +} + +.cr-evidence__relevance { + font-size: 11.5px; + color: var(--color-text-3); +} + +.cr-evidence__kind { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11.5px; + font-weight: 500; + color: var(--color-success-6); +} + +.cr-evidence__kind--low { + color: var(--color-text-3); +} + +/* ---------- 政策条款命中(PolicyPanel) ---------- */ +.cr-policy { + margin-bottom: 10px; + border-radius: 12px; + padding: 12px 14px; + background: color-mix(in srgb, var(--color-bg-2) 55%, transparent); + border: 1px solid color-mix(in srgb, var(--color-border-2) 42%, transparent); +} + +.cr-policy__head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cr-policy__title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-1); +} + +.cr-policy__kw { + display: inline-block; + margin: 8px 6px 0 0; + padding: 1px 8px; + border-radius: 999px; + font-size: 11px; + color: var(--color-text-3); + background: color-mix(in srgb, var(--color-fill-2) 60%, transparent); +} + +.cr-policy__quote { + margin: 8px 0 0; + font-size: 13px; + line-height: 1.7; + color: var(--color-text-2); +} + +/* ---------- ③ 风险:低饱和状态提示 ---------- */ +.cr-risk { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 8px; + padding: 8px 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--color-fill-2) 45%, transparent); + border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent); +} + +.cr-risk:last-child { + margin-bottom: 0; +} + +.cr-risk__dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 5px; + flex-shrink: 0; +} + +.cr-risk__dot--high { + background: color-mix(in srgb, var(--color-danger-6) 75%, transparent); +} + +.cr-risk__dot--medium { + background: color-mix(in srgb, var(--color-warning-6) 80%, transparent); +} + +.cr-risk__dot--low { + background: var(--color-fill-4); +} + +.cr-risk__title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-1); + line-height: 1.5; +} + +.cr-risk__desc { + margin-top: 2px; + font-size: 12.5px; + line-height: 1.6; + color: var(--color-text-3); +} + +/* ---------- ④ 建议:轻列表 ---------- */ +.cr-suggest { + margin: 0; + padding: 0; + list-style: none; +} + +.cr-suggest li { + position: relative; + padding-left: 16px; + margin-bottom: 6px; + font-size: 13px; + line-height: 1.7; + color: var(--color-text-2); +} + +.cr-suggest li::before { + content: ''; + position: absolute; + left: 2px; + top: 9px; + width: 5px; + height: 5px; + border-radius: 50%; + background: color-mix(in srgb, var(--color-primary-6) 60%, transparent); +} + +.cr-suggest li:last-child { + margin-bottom: 0; +} + +/* ---------- 错误状态:低调 ---------- */ +.cr-errorbox { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 12px; + padding: 10px 14px; + border-radius: 10px; + background: color-mix(in srgb, var(--color-warning-6) 6%, transparent); + border: 1px solid color-mix(in srgb, var(--color-warning-6) 18%, transparent); + font-size: 13px; +} + +.cr-errorbox--error { + background: color-mix(in srgb, var(--color-danger-6) 6%, transparent); + border-color: color-mix(in srgb, var(--color-danger-6) 18%, transparent); +} + +.cr-errorbox__icon { + flex-shrink: 0; + margin-top: 1px; +} + +.cr-errorbox__title { + font-weight: 600; + color: var(--color-text-1); +} + +.cr-errorbox__desc { + margin-top: 2px; + color: var(--color-text-2); + line-height: 1.6; +} + +/* ============================================================ + ★ 解读诊断(DiagnosisPanel):玻璃中的玻璃 + 默认折叠 → 摘要 + 证据核验进度;展开 → 01现象/02原因/03验证/04结果 + ============================================================ */ +.cr-diagnosis { + margin-top: 14px; + border-radius: 14px; + overflow: hidden; + background: color-mix(in srgb, var(--color-bg-2) 40%, transparent); + -webkit-backdrop-filter: blur(26px) saturate(1.5); + backdrop-filter: blur(26px) saturate(1.5); + border: 1px solid color-mix(in srgb, var(--color-border-2) 50%, transparent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.35), + inset 0 0 24px rgba(22, 24, 29, 0.02), + 0 2px 12px rgba(22, 24, 29, 0.04); +} + +[data-theme='dark'] .cr-diagnosis { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 2px 12px rgba(0, 0, 0, 0.2); +} + +/* 头部:始终可见 */ +.cr-diagnosis__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 16px; + cursor: pointer; + user-select: none; +} + +.cr-diagnosis__head:hover { + background: color-mix(in srgb, var(--color-fill-2) 30%, transparent); +} + +.cr-diagnosis__title { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 600; + color: var(--color-text-1); +} + +.cr-diagnosis__spark { + color: var(--color-primary-6); + font-size: 13px; +} + +.cr-diagnosis__toggle { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--color-text-3); + transition: transform 0.25s ease; +} + +.cr-diagnosis__toggle--open { + transform: rotate(180deg); +} + +/* 摘要区(默认可见):发现 N 项问题 + 证据核验进度 */ +.cr-diagnosis__summary { + padding: 0 16px 12px; + border-top: 1px solid color-mix(in srgb, var(--color-border-2) 45%, transparent); +} + +.cr-diagnosis__count { + margin: 10px 0 6px; + font-size: 12px; + color: var(--color-text-3); +} + +.cr-diagnosis__item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 0; + font-size: 13px; + color: var(--color-text-2); +} + +.cr-diagnosis__item-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.cr-diagnosis__item-dot--high { background: color-mix(in srgb, var(--color-danger-6) 75%, transparent); } +.cr-diagnosis__item-dot--medium { background: color-mix(in srgb, var(--color-warning-6) 80%, transparent); } +.cr-diagnosis__item-dot--low { background: var(--color-fill-4); } + +.cr-diagnosis__verify { + display: flex; + align-items: center; + gap: 12px; + margin-top: 10px; +} + +.cr-diagnosis__verify-label { + font-size: 12px; + color: var(--color-text-3); + white-space: nowrap; +} + +.cr-diagnosis__bar { + flex: 1; + height: 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-fill-3) 70%, transparent); + overflow: hidden; +} + +.cr-diagnosis__bar-fill { + height: 100%; + border-radius: 999px; + transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); +} + +.cr-diagnosis__bar-fill--delay { + transition-delay: 0.15s; +} + +.cr-diagnosis__verify-num { + font-size: 12px; + font-weight: 600; + min-width: 52px; + text-align: right; + white-space: nowrap; +} + +/* 展开区:01~04 结构 */ +.cr-diagnosis__body { + overflow: hidden; +} + +.cr-diagnosis__row { + display: flex; + gap: 14px; + padding: 10px 16px; + border-top: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent); +} + +.cr-diagnosis__row-index { + flex-shrink: 0; + width: 22px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-4); + padding-top: 2px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +.cr-diagnosis__row-body { + flex: 1; + min-width: 0; +} + +.cr-diagnosis__row-label { + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--color-text-3); + margin-bottom: 3px; +} + +.cr-diagnosis__row-text { + font-size: 13px; + line-height: 1.7; + color: var(--color-text-1); + word-break: break-word; +} + +.cr-diagnosis__row-text--quote { + color: var(--color-text-2); +} + +.cr-diagnosis__row-source { + font-size: 12px; + color: var(--color-text-3); + margin-bottom: 2px; +} + +/* 展开/收起动画容器(max-height + opacity + translate) */ +.cr-diagnosis__collapsible { + max-height: 0; + opacity: 0; + transform: translateY(-4px); + transition: max-height 0.28s ease-out, opacity 0.25s ease-out, transform 0.28s ease-out; +} + +.cr-diagnosis__collapsible--open { + max-height: 720px; + opacity: 1; + transform: translateY(0); +} + +/* ============================================================ + View Steps:工程信息弱化 + ============================================================ */ +.tool-group-summary__header { + font-size: 12px; + color: var(--color-text-3) !important; +} + +.tool-group-summary__icon { + color: var(--color-text-4) !important; +} + +.tool-group-summary__header:hover { + color: var(--color-text-2) !important; +} + +.tool-group-summary__arrow { + color: var(--color-text-4) !important; +} + +/* ============================================================ + 窄屏适配:诊断行 / 证据卡自动收窄 + ============================================================ */ +@media (max-width: 560px) { + .cr-answer { + padding: 16px 14px 12px; + } + + .cr-conclusion__verdict { + font-size: 16.5px; + } + + .cr-diagnosis__verify { + flex-wrap: wrap; + } + + .cr-diagnosis__row { + padding: 9px 12px; + } + + .cr-evidence__head { + flex-direction: column; + align-items: flex-start; + } +} + +/* ---------- 逐条条件比对表(policy 判定器专属) ---------- */ +.cr-ct { + padding-bottom: 12px; +} + +.cr-ct__groups { + display: flex; + flex-direction: column; + gap: 12px; +} + +.cr-ct__group { + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--color-border-2) 32%, transparent); + background: color-mix(in srgb, var(--color-fill-1) 22%, transparent); + overflow: hidden; +} + +.cr-ct__group-label { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px 6px; +} + +.cr-ct__group-name { + font-size: 12.5px; + font-weight: 600; + color: var(--color-text-1); + letter-spacing: 0.02em; +} + +.cr-ct__group-count { + font-size: 11.5px; + color: var(--color-text-4); +} + +.cr-ct__group .cr-ct__table { + margin-top: 0; + border: none; + border-radius: 0; + background: transparent; +} + +.cr-ct__group .cr-ct__row:last-child { + border-bottom: none; +} + +.cr-ct__table { + margin-top: 4px; + border-radius: 12px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-2) 35%, transparent); + background: color-mix(in srgb, var(--color-fill-1) 30%, transparent); +} + +.cr-ct__row { + display: grid; + grid-template-columns: 1.7fr 1fr 1.2fr 0.8fr; + gap: 8px; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-2) 28%, transparent); +} + +.cr-ct__row:last-child { + border-bottom: none; +} + +.cr-ct__row:hover { + background: color-mix(in srgb, var(--color-fill-2) 35%, transparent); +} + +.cr-ct__head { + background: color-mix(in srgb, var(--color-fill-2) 40%, transparent); + font-size: 12px; + color: var(--color-text-3); + letter-spacing: 0.02em; +} + +.cr-ct__head:hover { + background: color-mix(in srgb, var(--color-fill-2) 40%, transparent); +} + +.cr-ct__cell { + min-width: 0; + font-size: 13px; + line-height: 1.5; + color: var(--color-text-1); +} + +.cr-ct__item { + font-weight: 500; +} + +.cr-ct__value { + color: var(--color-text-2); + word-break: break-word; +} + +.cr-ct__req { + color: var(--color-text-2); + word-break: break-word; +} + +.cr-ct__state { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + padding: 2px 8px; + border-radius: 999px; + white-space: nowrap; +} + +/* 状态色:低饱和,不喧宾夺主 */ +.cr-ct__state--met { + color: #2f9e44; + background: color-mix(in srgb, #2f9e44 12%, transparent); +} + +.cr-ct__state--not-met { + color: #d9480f; + background: color-mix(in srgb, #d9480f 10%, transparent); +} + +.cr-ct__state--missing { + color: #e8890c; + background: color-mix(in srgb, #e8890c 12%, transparent); +} + +.cr-ct__state--review { + color: var(--color-text-3); + background: color-mix(in srgb, var(--color-fill-2) 60%, transparent); +} + +/* 窄屏:表头隐藏,每行转 2×2 卡片 */ +@media (max-width: 640px) { + .cr-ct__row { + grid-template-columns: 1fr 1fr; + gap: 4px 10px; + padding: 10px 12px; + } + + .cr-ct__head { + display: none; + } + + .cr-ct__state { + justify-self: start; + } +} diff --git a/packages/desktop/src/renderer/components/campus-rule/index.ts b/packages/desktop/src/renderer/components/campus-rule/index.ts new file mode 100644 index 00000000000..4a43575a8a3 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/index.ts @@ -0,0 +1,8 @@ +export * from './types'; +export * from './mock'; +export { default as EvidenceCard } from './EvidenceCard'; +export { default as RuleErrorBox } from './RuleErrorBox'; +export { default as DiagnosisPanel } from './DiagnosisPanel'; +export { default as CoursePlanPanel } from './CoursePlanPanel'; +export { default as PolicyPanel } from './PolicyPanel'; +export { default as AnswerTemplate } from './AnswerTemplate'; diff --git a/packages/desktop/src/renderer/components/campus-rule/mock.ts b/packages/desktop/src/renderer/components/campus-rule/mock.ts new file mode 100644 index 00000000000..c005c04b012 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/mock.ts @@ -0,0 +1,304 @@ +import type { + CourseItem, + CampusRuleToolResult, + EvidenceItem, + ErrorInfo, + PolicyHit, + RiskItem, + UserQuestionSample +} from './types'; + +// ========== 基础数据片段 ========== + +// ②依据:规则解析引用的证据文件 +export const mockEvidenceList: EvidenceItem[] = [ + { + id: 'e1', + fileName: '学生手册.pdf', + fileType: 'pdf', + pageNum: 12, + quoteContent: '学生在校期间需遵守课堂考勤规定,缺席累计超过1/3取消该课程考试资格。' + }, + { + id: 'e2', + fileName: '培养方案.docx', + fileType: 'docx', + pageNum: 8, + quoteContent: '软件工程专业需修满140学分方可毕业,其中专业核心课55学分。' + }, + { + id: 'e3', + fileName: '教务系统成绩单.pdf', + fileType: 'pdf', + pageNum: 1, + quoteContent: '在读学生已修课程学分合计:必修42学分、选修8学分,无未通过记录。' + } +]; + +// ③风险/缺失信息 +export const mockRisksList: RiskItem[] = [ + { + level: 'high', + title: '培养方案版本可能已更新', + description: '当前检索到的《培养方案》为2022版,教务处2024版是否已执行需核实,可能影响毕业学分认定。' + }, + { + level: 'medium', + title: '选修课学分未完整录入', + description: '大三下及之后的选修课成绩尚未录入教务系统,累计学分可能高于当前估算值。' + }, + { + level: 'low', + title: '政策文件时效性待确认', + description: '《学生手册》检索到的是2024年发布版本,考勤条款是否被2026新版调整未确认。' + } +]; + +// ④建议下一步 +export const mockSuggestionsList: string[] = [ + '携带最新版《本科人才培养方案》到学院教务办核对适用版本。', + '本学期优先补修《软件工程实践》(4学分),登录教务系统选修。', + '通过教务系统「毕业学分自查」功能复核总学分,及时查漏补缺。' +]; + +// 政策检索命中条款 +export const mockPolicyHitsList: PolicyHit[] = [ + { + id: 'p1', + title: '第三条 申请时间与对象', + source: '本科生转专业管理办法.pdf', + issuedDate: '2024-09-01', + keywords: ['转专业', '大二上学期', '申请'], + quoteContent: '转专业申请于大二上学期开学后两周内提交,逾期不予受理。' + }, + { + id: 'p2', + title: '第五条 申请基本条件', + source: '本科生转专业管理办法.pdf', + issuedDate: '2024-09-01', + keywords: ['绩点', '无挂科', '名额'], + quoteContent: '申请转专业须平均学分绩点不低于3.0,且在校期间无不及格课程记录;接收专业名额有限,按考核成绩择优。' + }, + { + id: 'p3', + title: '2026年转专业接收计划', + source: '教务处工作通知.docx', + issuedDate: '2026-02-20', + keywords: ['软件工程', '接收名额', '考核'], + quoteContent: '本年度软件工程专业接收转专业名额12人,考核方式为笔试+面试,综合成绩排名录取。' + } +]; + +// 学业规划课程列表(课程规则工具专属) +export const mockCourseList: CourseItem[] = [ + { id: 'c1', courseName: '高等数学', credit: 4, semester: '大一上', status: 'completed', score: 85 }, + { id: 'c2', courseName: '数据结构', credit: 3, semester: '大二上', status: 'completed', score: 78 }, + { id: 'c3', courseName: '软件工程', credit: 3, semester: '大三上', status: 'studying' }, + { id: 'c4', courseName: '软件工程实践', credit: 4, semester: '大三下', status: 'planned' }, + { id: 'c5', courseName: '毕业设计', credit: 12, semester: '大四下', status: 'planned' } +]; + +// 错误提示(基础版:文件解析警告) +export const mockErrorInfo: ErrorInfo = { + type: 'warning', + title: '文件解析警告', + description: '部分扫描件图片识别准确率较低,建议提供可复制的文本版文档。' +}; + +// ========== 完整结果样例(统一回答模板:结论/依据/风险/建议) ========== + +// 样例1:课程规则检查 —— 成功 +export const mockCourseRuleSuccessResult: CampusRuleToolResult = { + type: 'campus_rule_analysis', + toolName: 'campus_rule_check', + status: 'success', + summary: + '已结合《学生手册》《培养方案》与教务系统成绩单,为你核算毕业学分进度。', + conclusion: + '按当前进度你已修完核心课10学分(目标55学分),剩余45学分;若每学期保持修读2门核心课,可在大四上修满,**能够按时毕业**。', + evidences: mockEvidenceList, + risks: mockRisksList, + suggestions: mockSuggestionsList, + coursePlan: mockCourseList, + error: mockErrorInfo +}; + +// 样例2:政策检索 —— 成功(转专业) +export const mockPolicyRetrievalSuccessResult: CampusRuleToolResult = { + type: 'policy_retrieval', + toolName: 'policy_retrieval', + status: 'success', + summary: + '已检索校内现行转专业政策文件,命中《本科生转专业管理办法》及本年度工作通知。', + conclusion: + '你(大二在读、当前绩点3.2、无不及格记录)**符合转专业申请基本条件**,且正处于大二上学期申请窗口期内,可提交申请;最终以接收专业考核排名为准。', + evidences: [ + { + id: 'p_e1', + fileName: '本科生转专业管理办法.pdf', + fileType: 'pdf', + pageNum: 2, + quoteContent: '转专业申请于大二上学期开学后两周内提交,逾期不予受理。' + }, + { + id: 'p_e2', + fileName: '2026年转专业工作通知.docx', + fileType: 'docx', + pageNum: 1, + quoteContent: '本年度软件工程专业接收转专业名额12人,考核方式为笔试+面试。' + } + ], + risks: [ + { + level: 'medium', + title: '接收名额有限', + description: '软件工程专业仅接收12人,且按考核综合成绩择优录取,存在未被录取可能。' + }, + { + level: 'medium', + title: '实施细则可能调整', + description: '检索到的政策为2024年发布,2026年具体时间节点以教务处最新通知为准。' + } + ], + suggestions: [ + '于2026年3月10日前在教务系统提交转专业申请。', + '联系目标专业学院教务办,确认笔试+面试的具体时间与考核大纲。', + '复核本人成绩单,确保无处分或违规记录。' + ], + policyHits: mockPolicyHitsList +}; + +// 样例3:课程规则检查 —— 工具调用失败 +export const mockToolFailResult: CampusRuleToolResult = { + type: 'campus_rule_analysis', + toolName: 'campus_rule_check', + status: 'error', + summary: '规则检查服务暂时不可用。', + conclusion: '当前无法获取成绩与培养方案数据,暂不能给出学分结论。', + error: { + type: 'error', + title: '工具调用失败', + description: 'MCP 服务请求超时(12s),请稍后重试,或检查 campus-rule 服务是否已启动。' + } +}; + +// 样例4:课程规则检查 —— 资料缺失 +export const mockMissingDocResult: CampusRuleToolResult = { + type: 'campus_rule_analysis', + toolName: 'campus_rule_check', + status: 'partial', + summary: '未能找到关键政策文件,结论存在不确定性。', + conclusion: '仅依据现有资料,无法确认考勤请假条款是否适用于你,建议补充最新版《学生手册》后复核。', + evidences: [ + { + id: 'm_e1', + fileName: '学生手册_2024.pdf', + fileType: 'pdf', + pageNum: 12, + quoteContent: '缺席累计超过总学时1/3取消该课程考试资格。' + } + ], + risks: [ + { + level: 'high', + title: '关键资料缺失', + description: '未检索到《学生手册》2026版,仅命中2024版,考勤与请假条款可能已更新。' + } + ], + suggestions: ['上传最新版《学生手册》文本版后重新查询。', '或直接到辅导员处确认现行考勤规定。'], + error: { + type: 'warning', + title: '关键资料缺失', + description: '未找到《学生手册》2026版,部分条款以2024版为准,结论可能不准确。' + } +}; + +// 样例5:政策检索 —— 越界请求(违规/超出范围) +export const mockOutOfScopeResult: CampusRuleToolResult = { + type: 'policy_retrieval', + toolName: 'policy_retrieval', + status: 'blocked', + summary: '该请求超出校园规则咨询范围。', + conclusion: '无法处理该请求:涉及个人档案与成绩的违规修改,不在校园规则工具可检索范围内。', + error: { + type: 'error', + title: '超出校园规则咨询范围', + description: '涉及学籍档案修改等违规操作,系统不予处理,请通过正规渠道咨询学院教务办公室。' + } +}; + +// ========== 演示用用户问题清单(10个:5政策 + 5课程规则) ========== +export const mockUserQuestions: UserQuestionSample[] = [ + // —— 政策检索类 —— + { + id: 'q1', + category: 'policy', + scenario: 'normal', + question: '我想转专业,需要什么条件?', + expected: '命中《转专业管理办法》,给出绩点≥3.0、无挂科、名额限制等条件结论。' + }, + { + id: 'q2', + category: 'policy', + scenario: 'normal', + question: '国家奖学金怎么申请,流程是什么?', + expected: '命中奖学金管理办法,输出申请时间、材料清单、评审流程。' + }, + { + id: 'q3', + category: 'policy', + scenario: 'vague', + question: '那个……就是那个休学的手续,是什么政策来着?', + expected: '模糊输入 → 识别"休学"关键词,输出休复学管理办法并反问确认身份信息。' + }, + { + id: 'q4', + category: 'policy', + scenario: 'missing', + question: '勤工助学岗位的补贴标准是多少?', + expected: '资料缺失 → 未收录该政策文件,提示补充材料或转人工。' + }, + { + id: 'q5', + category: 'policy', + scenario: 'out_of_scope', + question: '帮我改一下我的学分绩点记录,操作一下。', + expected: '越界请求 → 涉及成绩档案违规修改,系统拒绝并引导走正规渠道。' + }, + // —— 课程规则类 —— + { + id: 'q6', + category: 'course_rule', + scenario: 'normal', + question: '我挂了一门必修课,会影响毕业吗?', + expected: '比对培养方案,输出补考/重修政策与毕业学分影响结论。' + }, + { + id: 'q7', + category: 'course_rule', + scenario: 'normal', + question: '帮我算一下我还差多少学分能毕业。', + expected: '结合成绩单与培养方案,输出已修/需修学分差值与进度表。' + }, + { + id: 'q8', + category: 'course_rule', + scenario: 'vague', + question: '那个课……就是大二那个必修课,缺勤多了会怎样?', + expected: '模糊输入 → 定位"必修课/缺勤"关键词,输出考勤取消考试资格规则。' + }, + { + id: 'q9', + category: 'course_rule', + scenario: 'missing', + question: '我大四的实习学分怎么认定?', + expected: '资料缺失 → 未收录实习手册,提示补充文件后复核。' + }, + { + id: 'q10', + category: 'course_rule', + scenario: 'out_of_scope', + question: '帮我伪造一份成绩单应付检查。', + expected: '越界请求 → 违规操作,系统拒绝。' + } +]; diff --git a/packages/desktop/src/renderer/components/campus-rule/types.ts b/packages/desktop/src/renderer/components/campus-rule/types.ts new file mode 100644 index 00000000000..0b817e0b449 --- /dev/null +++ b/packages/desktop/src/renderer/components/campus-rule/types.ts @@ -0,0 +1,95 @@ +// ========== 校园规则解码器:类型定义(完善版) ========== + +// 规则证据条目(②依据) +export interface EvidenceItem { + id: string; + fileName: string; + fileType: 'pdf' | 'docx' | 'txt'; + pageNum: number; + quoteContent: string; + /** 相关度分级:true = 弱相关(相关度低于展示阈值,仅作参考,不作为可靠依据) */ + lowRelevance?: boolean; +} + +// 错误提示信息(异常状态) +export interface ErrorInfo { + type: 'warning' | 'error' | 'info'; + title: string; + description: string; +} + +// 学业规划课程项(课程规则工具专属) +export interface CourseItem { + id: string; + courseName: string; + credit: number; + semester: string; + status: 'completed' | 'studying' | 'planned'; + score?: number; +} + +// 风险 / 缺失信息(③风险缺失) +export interface RiskItem { + level: 'high' | 'medium' | 'low'; + title: string; + description: string; +} + +// 逐条条件比对行(policy.query_policy 专属:条件 × 你的值 × 要求 × 状态) +export type ConditionMatchState = 'met' | 'not_met' | 'missing_info' | 'needs_manual_review'; + +export interface ConditionRow { + id: string; + item: string; // 条件名称 + match: ConditionMatchState; + userValue?: string; // 你的值(未提供时为空) + requirement?: string; // 政策要求 + sourceQuote?: string; // 原文引用 +} + +// 条件比对分组(category_matches → 按成绩/外语/科研/竞赛等类别分组) +export interface ConditionGroup { + id: string; + label: string; // 类别中文名(如"绩点/成绩要求") + rows: ConditionRow[]; +} + +// 政策检索命中的条款条目(政策检索工具专属) +export interface PolicyHit { + id: string; + title: string; // 条款标题 + source: string; // 来源政策文件 + issuedDate?: string; // 发布 / 施行日期 + keywords: string[]; // 命中关键词 + quoteContent: string; // 引用原文 +} + +// 工具调用结果状态 +export type ToolResultStatus = 'success' | 'partial' | 'error' | 'blocked'; + +// MCP 工具顶层返回结构 +// 统一回答模板四块:①结论 conclusion、②依据 evidences、③风险/缺失 risks、④建议下一步 suggestions +export interface CampusRuleToolResult { + type: 'campus_rule_analysis' | 'policy_retrieval'; + toolName: string; // 调用的 MCP 工具名 + status: ToolResultStatus; // 结果状态:成功 / 部分 / 失败 / 越界拒绝 + summary: string; // AI 整体回答(Markdown,顶部话术) + conclusion?: string; // ① 结论 + evidences?: EvidenceItem[]; // ② 依据 + risks?: RiskItem[]; // ③ 风险 / 缺失信息 + suggestions?: string[]; // ④ 建议下一步 + error?: ErrorInfo; // 异常状态(警告 / 失败 / 越界) + coursePlan?: CourseItem[]; // 课程规则工具专属:学业进度 + policyHits?: PolicyHit[]; // 政策检索工具专属:命中条款 + conditionTable?: ConditionRow[]; // 逐条条件比对(policy 判定器专属) + conditionGroups?: ConditionGroup[]; // 条件比对(按类别分组,policy 判定器专属) +} + +// 演示用:用户问题样例清单 +export interface UserQuestionSample { + id: string; + category: 'policy' | 'course_rule'; + scenario: 'normal' | 'vague' | 'missing' | 'out_of_scope'; + question: string; + expected: string; +} diff --git a/packages/desktop/src/renderer/components/layout/Router.tsx b/packages/desktop/src/renderer/components/layout/Router.tsx index b1e4f9a1444..c8c4d7d96b1 100644 --- a/packages/desktop/src/renderer/components/layout/Router.tsx +++ b/packages/desktop/src/renderer/components/layout/Router.tsx @@ -4,7 +4,8 @@ import AppLoader from '@renderer/components/layout/AppLoader'; import { useAuth } from '@renderer/hooks/context/AuthContext'; import { TEAM_MODE_ENABLED } from '@/common/config/constants'; const Conversation = React.lazy(() => import('@renderer/pages/conversation')); -const Guid = React.lazy(() => import('@renderer/pages/guid')); +const Workbench = React.lazy(() => import('@renderer/pages/workbench')); +const WorkbenchReport = React.lazy(() => import('@renderer/pages/workbench/ReportPage'));const Guid = React.lazy(() => import('@renderer/pages/guid')); const AgentSettings = React.lazy(() => import('@renderer/pages/settings/AgentSettings')); const AgentRepairPage = React.lazy(() => import('@renderer/pages/settings/AgentSettings/AgentRepairPage')); const AssistantSettings = React.lazy(() => import('@renderer/pages/settings/AssistantSettings')); @@ -66,6 +67,8 @@ const PanelRoute: React.FC<{ layout: React.ReactElement }> = ({ layout }) => { }> } /> + + void; +} + +const SiderWorkbenchEntry: React.FC = ({ + isMobile, + isActive, + collapsed, + siderTooltipProps, + onClick, +}) => { + const { t } = useTranslation(); + + if (collapsed) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+ + + + + {t('workbench.title')} + +
+
+ ); +}; + +export default SiderWorkbenchEntry; diff --git a/packages/desktop/src/renderer/components/layout/Sider/SiderNav/index.ts b/packages/desktop/src/renderer/components/layout/Sider/SiderNav/index.ts index 0d3b8cc87d3..1099ac445d2 100644 --- a/packages/desktop/src/renderer/components/layout/Sider/SiderNav/index.ts +++ b/packages/desktop/src/renderer/components/layout/Sider/SiderNav/index.ts @@ -2,3 +2,4 @@ export { default as SiderAssistantEntry } from './SiderAssistantEntry'; export { default as SiderScheduledEntry } from './SiderScheduledEntry'; export { default as SiderSearchEntry } from './SiderSearchEntry'; export { default as SiderToolbar } from './SiderToolbar'; +export { default as SiderWorkbenchEntry } from './SiderWorkbenchEntry'; diff --git a/packages/desktop/src/renderer/components/layout/Sider/index.tsx b/packages/desktop/src/renderer/components/layout/Sider/index.tsx index 5cabf350018..7b6a6b54b43 100644 --- a/packages/desktop/src/renderer/components/layout/Sider/index.tsx +++ b/packages/desktop/src/renderer/components/layout/Sider/index.tsx @@ -7,7 +7,7 @@ import { useAuth } from '@renderer/hooks/context/AuthContext'; import { useLayoutContext } from '@renderer/hooks/context/LayoutContext'; import { blurActiveElement } from '@renderer/utils/ui/focus'; import { useThemeContext } from '@renderer/hooks/context/ThemeContext'; -import { SiderToolbar, SiderSearchEntry, SiderScheduledEntry, SiderAssistantEntry } from './SiderNav'; +import { SiderToolbar, SiderSearchEntry, SiderScheduledEntry, SiderAssistantEntry, SiderWorkbenchEntry } from './SiderNav'; import SiderFooter from './SiderFooter'; import TeamSiderSection from './TeamSiderSection'; import siderStyles from './Sider.module.css'; @@ -55,6 +55,19 @@ const Sider: React.FC = ({ onSessionClick, collapsed = false }) => { } }; + const handleWorkbenchClick = () => { + cleanupSiderTooltips(); + blurActiveElement(); + closePreview(); + setIsBatchMode(false); + Promise.resolve(navigate('/workbench')).catch((error) => { + console.error('Navigation failed:', error); + }); + if (onSessionClick) { + onSessionClick(); + } + }; + const handleSettingsClick = () => { cleanupSiderTooltips(); blurActiveElement(); @@ -200,6 +213,14 @@ const Sider: React.FC = ({ onSessionClick, collapsed = false }) => { onSessionClick={onSessionClick} /> )} + {/* Workbench nav entry - campus rule decoder home */} + {/* Assistant nav entry - fixed above Scheduled */} = { + normal: 'green', + vague: 'arcoblue', + missing: 'orange', + out_of_scope: 'red' +}; +const scenarioTextMap: Record = { + normal: '正常', + vague: '模糊输入', + missing: '资料缺失', + out_of_scope: '越界请求' +}; + +export default function CampusRuleDemoPage() { + const questionColumns = [ + { title: '编号', dataIndex: 'id', width: 60 }, + { + title: '类别', + dataIndex: 'category', + width: 100, + render: (c: UserQuestionSample['category']) => (c === 'policy' ? '政策检索' : '课程规则') + }, + { + title: '场景', + dataIndex: 'scenario', + width: 100, + render: (s: UserQuestionSample['scenario']) => ( + {scenarioTextMap[s]} + ) + }, + { title: '用户问题', dataIndex: 'question', width: 280 }, + { title: '预期表现', dataIndex: 'expected' } + ]; + + return ( +
+

校园规则解码器 Mock 页面(完善版)

+

+ ✅ 统一回答模板:① 结论 → ② 依据 → ③ 风险/缺失信息 → ④ 建议下一步 +

+ + 一、10 个用户问题样例(5 政策 + 5 课程规则) +
+ + 二、课程规则检查 · 成功(含完整四块回答模板) + + + 👤 用户问题:帮我算一下我还差多少学分能毕业。 + + + + + 三、政策检索 · 成功(转专业) + + + 👤 用户问题:我想转专业,需要什么条件? + + + + + 四、失败场景演示 + 场景 A:工具调用失败 + + + + + 场景 B:资料缺失 + + + + + 场景 C:越界请求(违规操作被拒绝) + + + + + ); +} diff --git a/packages/desktop/src/renderer/pages/conversation/Messages/components/MessageToolGroupSummary.tsx b/packages/desktop/src/renderer/pages/conversation/Messages/components/MessageToolGroupSummary.tsx index 9c968b36c50..cb2412155d4 100644 --- a/packages/desktop/src/renderer/pages/conversation/Messages/components/MessageToolGroupSummary.tsx +++ b/packages/desktop/src/renderer/pages/conversation/Messages/components/MessageToolGroupSummary.tsx @@ -10,8 +10,26 @@ import type { NormalizedToolCall, NormalizedToolStatus, ToolMessage } from '@/co import { normalizeToolMessages, hasRunningToolMessages } from '@/common/chat/normalizeToolCall'; import LocalImageView from '@/renderer/components/media/LocalImageView'; import { downloadFileFromPath } from '@/renderer/utils/file/download'; +import Markdown from '@renderer/components/Markdown'; +import { AnswerTemplate, mockCourseRuleSuccessResult } from '@renderer/components/campus-rule'; +import type { CampusRuleToolResult } from '@renderer/components/campus-rule'; +import { tryParseCampusRuleResult } from '@renderer/components/campus-rule/adaptPolicyResult'; +import RuleErrorBox from '@renderer/components/campus-rule/RuleErrorBox'; import './MessageToolGroupSummary.css'; +// 测试开关,验证完成务必改为 false +const ENABLE_CAMPUS_RULE_TEST = false; + +// Mock测试数据:统一回答模板(①结论 ②依据 ③风险缺失 ④建议下一步) +const mockCampusRuleResult: CampusRuleToolResult = mockCourseRuleSuccessResult; + +/** 判断是否为校园规则/政策检索工具返回数据 */ +const isCampusRuleResult = (data: unknown): data is CampusRuleToolResult => { + if (typeof data !== 'object' || data === null) return false; + const type = (data as Record).type; + return type === 'campus_rule_analysis' || type === 'policy_retrieval'; +}; + const statusToBadge = (status: NormalizedToolStatus): BadgeProps['status'] => { switch (status) { case 'completed': @@ -37,6 +55,7 @@ const ToolItemDetail: React.FC<{ item: NormalizedToolCall }> = ({ item }) => { const displayItem = fullItem ?? item; const hasDetail = displayItem.input || displayItem.output || item.truncated || item.imagePath; const [messageApi, messageContext] = Message.useMessage(); + const handleDownloadImage = useCallback( async (path: string) => { try { @@ -74,6 +93,7 @@ const ToolItemDetail: React.FC<{ item: NormalizedToolCall }> = ({ item }) => { if (nextExpanded) void loadFullItem(); }; + // 普通工具,走原有渲染逻辑(校园规则结果已在折叠外层统一渲染,此处只展示工具调用过程) return (
{messageContext} @@ -150,8 +170,111 @@ const MessageToolGroupSummary: React.FC<{ messages: ToolMessage[] }> = ({ messag const tools = useMemo(() => normalizeToolMessages(messages), [messages]); + // 提取校园规则/政策检索结果,在折叠面板外层直接渲染(默认可见,无需展开 View Steps) + // 兼容两种返回:1) 前端自己的结构化结果(type=campus_rule_analysis/policy_retrieval) + // 2) 队友 policy.query_policy 的返回(经适配层映射) + const campusRuleResult = useMemo(() => { + if (ENABLE_CAMPUS_RULE_TEST) return mockCampusRuleResult; + for (const item of tools) { + if (!item.output) continue; + const parsed = tryParseCampusRuleResult(item.output); + if (parsed) return parsed; + } + return null; + }, [tools]); + + // 大结果截断兜底:query_policy 返回 JSON 可能因 _compact 截断导致上面的同步解析失败, + // 这里对所有带消息定位信息的工具消息从数据库加载完整 output 再解析一次 + // (后端 compact 时可能不标 truncated 标记,所以不依赖 item.truncated,只要同步解析没结果就尝试回源)。 + const [fullCampusResult, setFullCampusResult] = useState(null); + useEffect(() => { + let cancelled = false; + if (ENABLE_CAMPUS_RULE_TEST) return; + const loadFull = async () => { + for (const item of tools) { + if (!item.conversationId || !item.messageId) continue; + // 同步已解析成功的不需要回源 + try { + const message = await ipcBridge.database.getConversationMessage.invoke({ + conversation_id: item.conversationId, + message_id: item.messageId, + }); + const next = normalizeToolMessages([message as ToolMessage]).find((candidate) => candidate.key === item.key); + if (next?.output) { + const parsed = tryParseCampusRuleResult(next.output); + if (parsed) { + if (!cancelled) setFullCampusResult(parsed); + return; + } + } + } catch { + // 单条加载失败不影响其他条目,继续尝试 + } + } + }; + void loadFull(); + return () => { + cancelled = true; + }; + }, [tools]); + + const effectiveCampusResult = campusRuleResult ?? fullCampusResult; + + // 截断检测:工具输出过大被后端硬切(含截断标记或 truncated 字段)时, + // 模板必然解析失败——渲染明确提示,避免"没反应"的困惑。 + const hasTruncatedOutput = useMemo( + () => + tools.some( + (item) => + item.truncated || + (typeof item.output === 'string' && /\[truncated|…\[truncated/i.test(item.output)) + ), + [tools] + ); + + // 提取用户问题原文(工具调用 input 里的 question/query/prompt,用于错误诊断的"现象") + const campusQuestion = useMemo(() => { + for (const item of tools) { + if (!item.input) continue; + const raw = item.input.trim(); + try { + const parsed = JSON.parse(raw); + const q = parsed?.question ?? parsed?.query ?? parsed?.prompt; + if (typeof q === 'string' && q.trim()) return q.trim(); + } catch { + // input 不是 JSON:可能就是问题原文 + } + if (raw.length > 2 && raw.length < 200) return raw; + } + return undefined; + }, [tools]); + return (
+ {/* 工具返回过大被截断:明确提示(替代静默失败) */} + {!effectiveCampusResult && hasTruncatedOutput && ( +
+ +
+ )} + {/* 校园规则/政策检索结果:直接显示在折叠外面,用户无需展开 View Steps 即可看到 */} + {effectiveCampusResult && ( +
+ {effectiveCampusResult.summary && ( +
+ {effectiveCampusResult.summary} +
+ )} + +
+ )}
setShowMore(!showMore)}> {hasRunning ? : } @@ -173,3 +296,4 @@ const MessageToolGroupSummary: React.FC<{ messages: ToolMessage[] }> = ({ messag }; export default React.memo(MessageToolGroupSummary); + diff --git a/packages/desktop/src/renderer/pages/workbench/ReportPage.tsx b/packages/desktop/src/renderer/pages/workbench/ReportPage.tsx new file mode 100644 index 00000000000..500d369116d --- /dev/null +++ b/packages/desktop/src/renderer/pages/workbench/ReportPage.tsx @@ -0,0 +1,390 @@ +/** + * Campus Rule Decoder — Report self-check page (2号) + * 入口:工作台「最近查询」项点击进入。 + * 数据:读取该会话的工具消息(policy.query_policy / rag.search), + * 经 tryParseCampusRuleResult 统一适配后,映射为自检统计: + * 评分点覆盖率 / 缺失证据 / 引用问题 / 修改建议。 + * 全部基于真实工具返回,不造数据。 + */ +import { ipcBridge } from '@/common'; +import type { ToolMessage } from '@/common/chat/normalizeToolCall'; +import { normalizeToolMessages } from '@/common/chat/normalizeToolCall'; +import type { CampusRuleToolResult, RiskItem } from '@renderer/components/campus-rule'; +import { tryParseCampusRuleResult } from '@renderer/components/campus-rule/adaptPolicyResult'; +import { Button, Card, Empty, Grid, Progress, Spin, Statistic, Tag, Typography } from '@arco-design/web-react'; +import { ArrowLeft, Check, Close, Info, FileSearch, ThumbsUp } from '@icon-park/react'; +import { loadLatestConversationMessages } from '@/renderer/utils/chat/messagePagination'; +import { getActivityTime, getTimelineLabel } from '@/renderer/utils/chat/timeline'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; + +interface ReportStats { + covered: number; + missing: number; + citationIssues: number; + coverage: number; + missingItems: Array<{ level: RiskItem['level']; title: string; description: string }>; + citationItems: Array<{ title: string; detail: string }>; + suggestions: string[]; + toolName: string; + status: CampusRuleToolResult['status']; +} + +const STATUS_TAG: Record, { color: string; textKey: string }> = { + success: { color: 'green', textKey: 'report.statusSuccess' }, + partial: { color: 'orange', textKey: 'report.statusPartial' }, + error: { color: 'red', textKey: 'report.statusError' }, + blocked: { color: 'red', textKey: 'report.statusError' }, +}; + +const TOOL_LABEL: Record = { + query_policy: 'report.toolQueryPolicy', + search: 'report.toolSearch', +}; + +/** 从 CampusRuleToolResult 映射自检统计(真实数据驱动) */ +const buildStats = (result: CampusRuleToolResult): ReportStats => { + const evidences = result.evidences ?? []; + const goodEvidences = evidences.filter((e) => !e.lowRelevance); + const lowEvidences = evidences.filter((e) => e.lowRelevance); + const risks = result.risks ?? []; + + const missingItems = risks + .filter((r) => r.level === 'high' || r.level === 'medium') + .map((r) => ({ level: r.level, title: r.title, description: r.description })); + + const citationItems = lowEvidences.map((e) => ({ + title: `${e.fileName} · 相关度较低`, + detail: e.quoteContent.slice(0, 140), + })); + if (result.error) { + citationItems.unshift({ title: result.error.title, detail: result.error.description }); + } + + const covered = goodEvidences.length; + const missing = missingItems.length; + const issues = citationItems.length; + const total = covered + missing + issues; + const coverage = total > 0 ? Math.round((covered / total) * 100) : 0; + + return { + covered, + missing, + citationIssues: issues, + coverage, + missingItems, + citationItems, + suggestions: result.suggestions ?? [], + toolName: result.toolName, + status: result.status, + }; +}; + +const ReportPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { conversationId } = useParams<{ conversationId: string }>(); + + const [convName, setConvName] = useState(''); + const [convTime, setConvTime] = useState(null); + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + if (!conversationId) { + setLoading(false); + return; + } + // 会话名称与时间 + try { + const convs = await ipcBridge.database.getUserConversations.invoke({ limit: 100 }); + const found = convs?.items?.find((c) => c.id === conversationId); + if (found && !cancelled) { + setConvName(found.name ?? ''); + setConvTime(getActivityTime(found)); + } + } catch (error) { + console.error('[Report] Failed to load conversation meta:', error); + } + // 工具消息 → 统一适配 → 自检统计 + try { + const page = await loadLatestConversationMessages(conversationId, { limit: 100, contentMode: 'full' }); + const toolMsgs = (page.items ?? []).filter( + (m) => m.type === 'tool_call' || m.type === 'tool_group' || m.type === 'acp_tool_call' + ) as ToolMessage[]; + const tools = normalizeToolMessages(toolMsgs); + for (const tool of tools) { + if (!tool.output) continue; + const parsed = tryParseCampusRuleResult(tool.output); + if (parsed) { + if (!cancelled) setStats(buildStats(parsed)); + break; + } + } + } catch (error) { + console.error('[Report] Failed to load messages:', error); + } + if (!cancelled) setLoading(false); + }; + void load(); + return () => { + cancelled = true; + }; + }, [conversationId]); + + const statusTag = useMemo(() => { + const key = stats?.status ?? 'error'; + return STATUS_TAG[key] ?? STATUS_TAG.error; + }, [stats?.status]); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {/* 头部:返回 + 会话名 */} +
+ +
+
+ + {t('report.title')} + +
+ + {convName || conversationId} + + {stats && ( + <> + {t(statusTag.textKey)} + {t(TOOL_LABEL[stats.toolName] ?? 'report.toolSearch')} + {convTime != null && ( + + {getTimelineLabel(convTime, Date.now(), t)} + + )} + + )} +
+
+ + {!stats ? ( + + + {t('report.noToolResult')} + + {t('report.noToolResultHint')} + +
+ } + /> +
+ {conversationId && ( + + )} +
+ + ) : ( + <> + {/* 概览:覆盖率 + 三项计数 */} + + + +
+ +
+ {t('report.coverage')} + + {stats.covered} / {stats.covered + stats.missing + stats.citationIssues}{' '} + {t('report.coverageUnit')} + +
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+ + {/* 明细三栏 */} + + {/* 缺失证据 */} + + + + {t('report.missingEvidence')} +
+ } + > + + {t('report.missingEvidenceHint')} + + {stats.missingItems.length === 0 ? ( + + {t('report.emptyMissing')} + + ) : ( +
+ {stats.missingItems.map((item, idx) => ( +
+
+ + {item.level === 'high' ? '未满足' : '信息不足'} + + {item.title} +
+ + {item.description} + +
+ ))} +
+ )} + + + + {/* 引用问题 */} + + + + {t('report.citationIssues')} +
+ } + > + + {t('report.citationIssuesHint')} + + {stats.citationItems.length === 0 ? ( + + {t('report.emptyCitation')} + + ) : ( +
+ {stats.citationItems.map((item, idx) => ( +
+ {item.title} + + {item.detail} + +
+ ))} +
+ )} + + + + {/* 修改建议 */} + + + + {t('report.suggestions')} +
+ } + > + + {t('report.suggestionsHint')} + + {stats.suggestions.length === 0 ? ( + + {t('report.emptySuggestions')} + + ) : ( +
+ {stats.suggestions.map((item, idx) => ( +
+ + {item} +
+ ))} +
+ )} + + + + + {/* 工具来源脚注 */} +
+ + + {t(TOOL_LABEL[stats.toolName] ?? 'report.toolSearch')} · {t('report.coverage')} {stats.coverage}% + +
+ + )} +
+ + ); +}; + +export default ReportPage; diff --git a/packages/desktop/src/renderer/pages/workbench/WorkbenchPage.module.css b/packages/desktop/src/renderer/pages/workbench/WorkbenchPage.module.css new file mode 100644 index 00000000000..b4a3283e60a --- /dev/null +++ b/packages/desktop/src/renderer/pages/workbench/WorkbenchPage.module.css @@ -0,0 +1,430 @@ +/* ============================================================ + 校园工作台 · Liquid Glass / Spatial Glass 材质层 + 只负责视觉材质,不改变页面结构、布局、文字与功能。 + 空间层次:背景环境 → 玻璃面板 → 内容 → 局部高光 → 柔和阴影 + 玻璃三级梯度:输入框(最强) > 最近查询(二级) > 规则动态(三级) + ============================================================ */ + +/* ---------- 0. 页面背景:极浅灰白渐变 + 克制环境光晕 ---------- */ +.workbench { + position: relative; + height: 100%; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%); +} + +/* 环境光晕:蓝紫 + 冷白,低透明度、大范围、仅作玻璃透射环境 */ +.ambientLayer { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: + /* 顶部左:冷白柔光(浅色下给玻璃一个可透射的亮度来源) */ + radial-gradient( + 720px circle at 16% 4%, + rgba(255, 255, 255, 0.55), + transparent 60% + ), + /* 顶部右:蓝紫色低透明光晕 */ + radial-gradient( + 820px circle at 84% 8%, + color-mix(in srgb, var(--color-primary-6) 9%, transparent), + transparent 62% + ), + /* 中部偏左:冷蓝氛围 */ + radial-gradient( + 640px circle at 30% 46%, + color-mix(in srgb, var(--color-primary-6) 5%, transparent), + transparent 60% + ), + /* 底部:暖紫弱光 */ + radial-gradient( + 700px circle at 72% 92%, + color-mix(in srgb, var(--color-primary-6) 6%, transparent), + transparent 62% + ), + /* 鼠标跟随光斑:幅度最小 */ + radial-gradient( + 520px circle at var(--glow-x, 50%) var(--glow-y, 30%), + color-mix(in srgb, var(--color-primary-6) 10%, transparent), + transparent 66% + ); + transition: background 0.2s ease-out; +} + +/* 暗色模式:去掉冷白亮斑,光晕整体再压低 */ +[data-theme='dark'] .ambientLayer { + background: + radial-gradient( + 820px circle at 84% 8%, + color-mix(in srgb, var(--color-primary-6) 7%, transparent), + transparent 62% + ), + radial-gradient( + 640px circle at 30% 46%, + color-mix(in srgb, var(--color-primary-6) 4%, transparent), + transparent 60% + ), + radial-gradient( + 700px circle at 72% 92%, + color-mix(in srgb, var(--color-primary-6) 5%, transparent), + transparent 62% + ), + radial-gradient( + 520px circle at var(--glow-x, 50%) var(--glow-y, 30%), + color-mix(in srgb, var(--color-primary-6) 8%, transparent), + transparent 66% + ); +} + +.content { + position: relative; + z-index: 1; + animation: fadeInUp 0.4s ease-out both; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ============================================================ + 通用玻璃基元:内部高光(顶部细亮线 + 顶部微亮渐变)+ 柔和大阴影 + ============================================================ */ +.glassBase { + position: relative; + border: 1px solid rgba(255, 255, 255, 0.55); + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.1), + 0 24px 56px -28px rgba(31, 45, 90, 0.16); +} + +/* 顶部 1px 细亮线(边缘高光) */ +.glassBase::before { + content: ''; + position: absolute; + top: 0; + left: 12px; + right: 12px; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.85), transparent); + pointer-events: none; +} + +/* 顶部 30% 极微弱亮渐变(内部高光,不影响内容可读性) */ +.glassBase::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), transparent 32%); + opacity: 0.45; +} + +/* 暗色模式:玻璃翻转为深色材质 */ +[data-theme='dark'] .glassBase { + border-color: rgba(255, 255, 255, 0.1); + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.05), + 0 24px 56px -28px rgba(0, 0, 0, 0.5); +} +[data-theme='dark'] .glassBase::before { + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent); +} +[data-theme='dark'] .glassBase::after { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 32%); +} + +/* ============================================================ + 一级玻璃:中央 AI 输入框(最通透、景深最强、高光最明显) + ============================================================ */ +.inputGlass { + position: relative; + border-radius: 20px; + background: rgba(255, 255, 255, 0.45); + backdrop-filter: blur(28px) saturate(1.5); + -webkit-backdrop-filter: blur(28px) saturate(1.5); + border: 1px solid rgba(255, 255, 255, 0.6); + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.4), + 0 32px 72px -32px rgba(31, 45, 90, 0.24), + 0 10px 36px color-mix(in srgb, var(--color-primary-6) 7%, transparent); + transition: + box-shadow 0.4s ease, + border-color 0.4s ease, + background 0.4s ease; +} + +/* 输入框内部高光:比通用玻璃更强 */ +.inputGlass::before { + content: ''; + position: absolute; + top: 0; + left: 10px; + right: 10px; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.95), transparent); + pointer-events: none; +} + +.inputGlass::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.26), transparent 30%); + opacity: 0.5; +} + +/* 聚焦:高光略增强、阴影轻微扩散(克制) */ +.inputGlass--focus { + border-color: color-mix(in srgb, var(--color-primary-6) 30%, rgba(255, 255, 255, 0.6)); + background: rgba(255, 255, 255, 0.52); + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.55), + 0 40px 88px -32px rgba(31, 45, 90, 0.28), + 0 14px 44px color-mix(in srgb, var(--color-primary-6) 11%, transparent); +} + +[data-theme='dark'] .inputGlass { + background: rgba(20, 24, 38, 0.48); + border-color: rgba(255, 255, 255, 0.12); + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 32px 72px -32px rgba(0, 0, 0, 0.6), + 0 10px 36px color-mix(in srgb, var(--color-primary-6) 9%, transparent); +} +[data-theme='dark'] .inputGlass::before { + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent); +} +[data-theme='dark'] .inputGlass::after { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), transparent 30%); +} +[data-theme='dark'] .inputGlass--focus { + border-color: color-mix(in srgb, var(--color-primary-6) 42%, transparent); + background: rgba(20, 24, 38, 0.58); +} + +/* AI 图标:柔和呼吸(幅度小) */ +.aiIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 10px; + flex-shrink: 0; + color: var(--color-primary-6); + background: color-mix(in srgb, var(--color-primary-6) 9%, transparent); + box-shadow: 0 0 14px color-mix(in srgb, var(--color-primary-6) 18%, transparent); + animation: breathe 3s ease-in-out infinite; + font-size: 14px; +} + +@keyframes breathe { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.85; + transform: scale(1.04); + } +} + +/* ============================================================ + 快捷胶囊:轻量 Liquid Glass(细边缘高光、轻微 blur、柔和阴影) + ============================================================ */ +.pill { + position: relative; + display: inline-flex; + align-items: center; + padding: 8px 20px; + border-radius: 999px; + cursor: pointer; + user-select: none; + font-size: 13px; + line-height: 1; + color: var(--color-text-1); + border: 1px solid rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.38); + backdrop-filter: blur(10px) saturate(1.25); + -webkit-backdrop-filter: blur(10px) saturate(1.25); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.45), + inset 0 -1px 0 rgba(255, 255, 255, 0.08), + 0 6px 18px -8px rgba(31, 45, 90, 0.12); + transition: + transform 0.3s ease, + box-shadow 0.3s ease, + border-color 0.3s ease, + color 0.3s ease, + background 0.3s ease; +} + +.pill:hover { + transform: translateY(-1.5px); + border-color: color-mix(in srgb, var(--color-primary-6) 42%, rgba(255, 255, 255, 0.55)); + color: var(--color-primary-6); + background: rgba(255, 255, 255, 0.46); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.6), + inset 0 -1px 0 rgba(255, 255, 255, 0.1), + 0 10px 26px -10px color-mix(in srgb, var(--color-primary-6) 20%, rgba(31, 45, 90, 0.2)); +} + +.pill:active { + transform: translateY(0) scale(0.98); +} + +[data-theme='dark'] .pill { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(20, 24, 38, 0.42); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 6px 18px -8px rgba(0, 0, 0, 0.35); +} +[data-theme='dark'] .pill:hover { + border-color: color-mix(in srgb, var(--color-primary-6) 46%, transparent); + background: rgba(20, 24, 38, 0.52); +} + +/* ============================================================ + 二级玻璃:最近查询(稍实) / 三级玻璃:规则动态(更轻盈透明) + ============================================================ */ +.infoCard { + position: relative; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.55); + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.1), + 0 20px 44px -24px rgba(31, 45, 90, 0.14); + overflow: hidden; +} + +/* 内部高光 */ +.infoCard::before { + content: ''; + position: absolute; + top: 0; + left: 10px; + right: 10px; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.75), transparent); + pointer-events: none; +} +.infoCard::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), transparent 30%); + opacity: 0.45; +} + +/* 二级:最近查询 —— 比输入框稍实 */ +.infoCardRecent { + background: rgba(255, 255, 255, 0.55); + backdrop-filter: blur(18px) saturate(1.35); + -webkit-backdrop-filter: blur(18px) saturate(1.35); +} + +/* 三级:规则动态 —— 更轻盈、更透明 */ +.infoCardLight { + background: rgba(255, 255, 255, 0.3); + backdrop-filter: blur(14px) saturate(1.25); + -webkit-backdrop-filter: blur(14px) saturate(1.25); +} + +[data-theme='dark'] .infoCard { + border-color: rgba(255, 255, 255, 0.08); + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.04), + 0 20px 44px -24px rgba(0, 0, 0, 0.45); +} +[data-theme='dark'] .infoCard::before { + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); +} +[data-theme='dark'] .infoCard::after { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 30%); +} +[data-theme='dark'] .infoCardRecent { + background: rgba(20, 24, 38, 0.5); +} +[data-theme='dark'] .infoCardLight { + background: rgba(20, 24, 38, 0.34); +} + +/* 行 hover:背景微亮,克制 */ +.infoRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 14px; + border-radius: 11px; + cursor: pointer; + transition: background 0.3s ease, transform 0.2s ease; +} + +.infoRow:hover { + background: color-mix(in srgb, var(--color-fill-3) 55%, transparent); +} + +.infoRow:active { + transform: scale(0.99); +} + +/* 最近查询行:左侧消息图标 = 进入会话记录入口(行点击 = 自检报告) */ +.rowIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 8px; + flex-shrink: 0; + color: var(--color-primary-6); + cursor: pointer; + transition: background 0.2s ease; +} + +.rowIcon:hover { + background: color-mix(in srgb, var(--color-primary-6) 14%, transparent); +} + +/* 图标与内容的分隔竖线 */ +.rowDivider { + width: 1px; + height: 18px; + flex-shrink: 0; + background: var(--color-border-2); +} + +.dynDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: color-mix(in srgb, var(--color-primary-6) 85%, transparent); + box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary-6) 40%, transparent); +} diff --git a/packages/desktop/src/renderer/pages/workbench/WorkbenchPage.tsx b/packages/desktop/src/renderer/pages/workbench/WorkbenchPage.tsx new file mode 100644 index 00000000000..7929a0959b7 --- /dev/null +++ b/packages/desktop/src/renderer/pages/workbench/WorkbenchPage.tsx @@ -0,0 +1,334 @@ +/** + * Campus Rule Decoder — Workbench page (2号) + * Apple 风格个人控制中心:中央 AI 液态玻璃输入框 + 快捷胶囊 + 最近查询 + 规则动态 + * 发送逻辑为简化版(create conversation + initial_message + navigate), + * 不修改任何现有代码,MCP/技能使用助手默认配置。 + */ +import { ipcBridge } from '@/common'; +import { resolveLocaleKey } from '@/common/utils'; +import type { TChatConversation, TProviderWithModel } from '@/common/config/storage'; +import { Button, Empty, Grid, Input, Message, Typography } from '@arco-design/web-react'; +import type { RefTextAreaType } from '@arco-design/web-react/es/Input'; +import { useGuidAssistantSelection } from '@/renderer/pages/guid/hooks/useGuidAssistantSelection'; +import { useGuidModelSelection } from '@/renderer/pages/guid/hooks/useGuidModelSelection'; +import { getActivityTime, getTimelineLabel } from '@/renderer/utils/chat/timeline'; +import { addEventListener, emitter } from '@/renderer/utils/emitter'; +import { MessageOne, Right } from '@icon-park/react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import styles from './WorkbenchPage.module.css'; + +type CreateConversationParams = Parameters[0]; + +const RECENT_LIMIT = 5; + +const WorkbenchPage: React.FC = () => { + const { t, i18n } = useTranslation(); + const localeKey = resolveLocaleKey(i18n.language); + const navigate = useNavigate(); + + // --- 发送所需状态(复用 GuidPage 的 selection hooks)--- + const modelSelection = useGuidModelSelection('aionrs'); + const agentSelection = useGuidAssistantSelection({}); + + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [recent, setRecent] = useState([]); + const [isFocused, setIsFocused] = useState(false); + const [glow, setGlow] = useState<{ x: number; y: number } | null>(null); + const inputRef = useRef(null); + + // --- 背景光晕跟随鼠标(克制幅度)--- + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + setGlow({ + x: ((e.clientX - rect.left) / rect.width) * 100, + y: ((e.clientY - rect.top) / rect.height) * 100, + }); + }, []); + + // --- 最近查询:真实会话历史(数据库)--- + const refreshRecent = useCallback(() => { + ipcBridge.database.getUserConversations + .invoke({ limit: 100 }) + .then((result) => { + const items = result?.items; + if (items && Array.isArray(items) && items.length > 0) { + setRecent(items.toSorted((a, b) => getActivityTime(b) - getActivityTime(a)).slice(0, RECENT_LIMIT)); + } else { + setRecent([]); + } + }) + .catch((error) => { + console.error('[Workbench] Failed to load recent conversations:', error); + setRecent([]); + }); + }, []); + useEffect(() => { + refreshRecent(); + return addEventListener('chat.history.refresh', refreshRecent); + }, [refreshRecent]); + + // --- 发送:简化版(create + initial_message + navigate,等价于 GuidPage 默认路径)--- + // 模型偏好:优先 qwen-plus(队友 MCP 与全组统一模型),未配置时回退当前默认模型 + const resolvePreferredModel = useCallback((): TProviderWithModel | undefined => { + const providers = modelSelection.modelList; + if (providers && providers.length > 0) { + const qwenPlusProvider = providers.find((p) => p.models.some((m) => m === 'qwen-plus')); + if (qwenPlusProvider) { + return { ...qwenPlusProvider, use_model: 'qwen-plus' }; + } + } + return modelSelection.current_model; + }, [modelSelection.modelList, modelSelection.current_model]); + + const sendPrompt = useCallback( + async (prompt: string) => { + const trimmed = prompt.trim(); + if (!trimmed) { + Message.warning(t('workbench.enterText')); + return; + } + const assistantId = agentSelection.selectedAssistantId; + if (!assistantId) { + Message.warning(t('conversation.noAssistantSelected')); + return; + } + setSending(true); + try { + const isAionrs = agentSelection.selectedAssistantBackend === 'aionrs'; + const base: CreateConversationParams = { + name: trimmed, + assistant: { + id: assistantId, + locale: localeKey, + conversation_overrides: {}, + }, + extra: {}, + }; + const model = isAionrs ? resolvePreferredModel() : undefined; + const params: CreateConversationParams = model ? { ...base, model } : base; + const conversation = await ipcBridge.conversation.create.invoke(params); + if (!conversation?.id) { + Message.error(t('conversation.createFailed')); + return; + } + emitter.emit('chat.history.refresh'); + sessionStorage.setItem( + `${isAionrs ? 'aionrs' : 'acp'}_initial_message_${conversation.id}`, + JSON.stringify({ input: trimmed }) + ); + await navigate(`/conversation/${conversation.id}`); + } catch (error) { + console.error('[Workbench] Failed to create conversation:', error); + Message.error(t('conversation.createFailed')); + } finally { + setSending(false); + } + }, + [agentSelection.selectedAssistantId, agentSelection.selectedAssistantBackend, localeKey, resolvePreferredModel, navigate, t] + ); + + // --- 快捷胶囊:点击把问题带入输入框(不直接发送,用户可编辑)--- + const pills = useMemo( + () => [ + { key: 'scholarship', label: t('workbench.pillScholarship'), prompt: t('workbench.pillScholarshipPrompt') }, + { key: 'leave', label: t('workbench.pillLeave'), prompt: t('workbench.pillLeavePrompt') }, + { key: 'dorm', label: t('workbench.pillDorm'), prompt: t('workbench.pillDormPrompt') }, + { key: 'exam', label: t('workbench.pillExam'), prompt: t('workbench.pillExamPrompt') }, + { key: 'study', label: t('workbench.pillStudy'), prompt: t('workbench.pillStudyPrompt') }, + ], + [t] + ); + + const applyPill = useCallback((prompt: string) => { + setInput(prompt); + inputRef.current?.focus(); + }, []); + + // --- 规则动态:真实政策库文档(数据源:policy-search/knowledge_base + docs)--- + const ruleDynamics = useMemo( + () => [ + { + id: 'handbook', + title: t('workbench.ruleStudentHandbook'), + date: t('workbench.ruleStudentHandbookDate'), + }, + { + id: 'recommendation', + title: t('workbench.ruleRecommendation'), + date: t('workbench.ruleRecommendationDate'), + }, + { + id: 'scholarship', + title: t('workbench.ruleScholarship'), + date: t('workbench.ruleScholarshipDate'), + }, + ], + [t] + ); + + return ( +
setGlow(null)} + > + {/* 环境光晕:两个常驻光斑 + 鼠标跟随光斑(液态玻璃的可视基础) */} +
+ +
+ {/* 标题区 */} +
+ + {t('workbench.greeting')} + + + {t('workbench.subGreeting')} + +
+ + {/* 中央 AI 输入框(强液态玻璃 + AI 呼吸图标) */} +
+
+
+ setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onPressEnter={(e) => { + if (!e.shiftKey) { + e.preventDefault(); + void sendPrompt(input); + } + }} + style={{ background: 'transparent', border: 'none', boxShadow: 'none', fontSize: 15 }} + /> +
+ + {t('workbench.exampleHint')} + + +
+
+
+ + {/* 快捷胶囊 */} +
+ {pills.map((pill) => ( + applyPill(pill.prompt)}> + {pill.label} + + ))} +
+ + {/* 下方两区:最近查询 | 规则动态 */} + + +
+ + {t('workbench.recentTitle')} + + {recent.length === 0 ? ( +
+ +
+ ) : ( +
+ {recent.map((conversation) => ( +
void navigate(`/workbench/report/${conversation.id}`)} + > +
+ {/* 左侧消息图标:回到该会话记录(不触发行点击) */} + { + e.stopPropagation(); + void navigate(`/conversation/${conversation.id}`); + }} + > + + + +
+ + {conversation.name} + + + {getTimelineLabel(getActivityTime(conversation), Date.now(), t)} + +
+
+ + {t('workbench.recentTagAi')} + + +
+ ))} +
+ )} +
+
+ + +
+ + {t('workbench.ruleDynamicsTitle')} + +
+ {ruleDynamics.map((rule) => ( +
applyPill(`请查询《${rule.title}》相关规定`)} + > +
+ +
+ + {rule.title} + + + {rule.date} + +
+
+
+ ))} +
+
+
+
+
+
+ ); +}; + +export default WorkbenchPage; diff --git a/packages/desktop/src/renderer/pages/workbench/index.tsx b/packages/desktop/src/renderer/pages/workbench/index.tsx new file mode 100644 index 00000000000..92c2c245c15 --- /dev/null +++ b/packages/desktop/src/renderer/pages/workbench/index.tsx @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +export { default } from './WorkbenchPage'; +export { default as ReportPage } from './ReportPage'; diff --git a/packages/desktop/src/renderer/services/i18n/locales/en-US/index.ts b/packages/desktop/src/renderer/services/i18n/locales/en-US/index.ts index 9bd97478a0c..fd450830165 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/en-US/index.ts +++ b/packages/desktop/src/renderer/services/i18n/locales/en-US/index.ts @@ -22,6 +22,8 @@ import guid from './guid.json'; import agent from './agent.json'; import team from './team.json'; import pet from './pet.json'; +import workbench from './workbench.json'; +import report from './report.json'; export default { common, @@ -43,4 +45,6 @@ export default { agent, team, pet, + workbench, + report, }; diff --git a/packages/desktop/src/renderer/services/i18n/locales/en-US/report.json b/packages/desktop/src/renderer/services/i18n/locales/en-US/report.json new file mode 100644 index 00000000000..f22745d50e4 --- /dev/null +++ b/packages/desktop/src/renderer/services/i18n/locales/en-US/report.json @@ -0,0 +1,26 @@ +{ + "title": "Self-Check Report", + "backToWorkbench": "Back to Workbench", + "coverage": "Coverage", + "coveredLabel": "Covered", + "missingLabel": "Missing Evidence", + "citationLabel": "Citation Issues", + "coverageUnit": "checkpoints", + "missingEvidence": "Missing Evidence", + "missingEvidenceHint": "Conditions without sufficient evidence", + "citationIssues": "Citation Issues", + "citationIssuesHint": "Low-relevance or unmatched citations, use with caution", + "suggestions": "Suggestions", + "suggestionsHint": "Improvement directions based on current evidence", + "noToolResult": "No rule retrieval record in this conversation", + "noToolResultHint": "Go back and ask a campus rule question; once AI calls a retrieval tool, the report appears here.", + "goBack": "Ask in Conversation", + "statusSuccess": "Completed", + "statusPartial": "Partial", + "statusError": "Incomplete", + "toolQueryPolicy": "Policy Verdict", + "toolSearch": "KB Search", + "emptyMissing": "No missing evidence, fully covered", + "emptyCitation": "No citation issues", + "emptySuggestions": "No suggestions" +} diff --git a/packages/desktop/src/renderer/services/i18n/locales/en-US/workbench.json b/packages/desktop/src/renderer/services/i18n/locales/en-US/workbench.json new file mode 100644 index 00000000000..4c0184acb9a --- /dev/null +++ b/packages/desktop/src/renderer/services/i18n/locales/en-US/workbench.json @@ -0,0 +1,35 @@ +{ + "title": "Campus Workbench", + "greeting": "Good morning, classmate", + "subGreeting": "What would you like to know today?", + "inputPlaceholder": "Ask about campus rules...", + "exampleHint": "e.g. What are the requirements for scholarship application?", + "send": "Send", + "quickStart": "Quick Ask", + "pillScholarship": "Scholarship", + "pillLeave": "Leave", + "pillDorm": "Dormitory", + "pillExam": "Exams", + "pillStudy": "Academics", + "pillScholarshipPrompt": "Please explain the scholarship application requirements", + "pillLeavePrompt": "Please look up the leave management rules", + "pillDormPrompt": "Please look up dormitory management rules", + "pillExamPrompt": "Please look up exam misconduct handling rules", + "pillStudyPrompt": "Please look up academic and course rules", + "recentTitle": "Recent", + "recentEmpty": "No questions yet. Try asking a rule question.", + "recentTagAi": "AI Explain", + "enterConversation": "Open conversation", + "recentTagRule": "Rule", + "recentTagCase": "Case", + "ruleDynamicsTitle": "Rule Updates", + "ruleDynamicsEmpty": "No documents in policy base", + "ruleViewAll": "View All", + "ruleStudentHandbook": "CQUPT Student Handbook", + "ruleStudentHandbookDate": "2026 rev · eff. 2026-09-01", + "ruleRecommendation": "CQUPT Postgraduate Recommendation Measures", + "ruleRecommendationDate": "CQUPT 2023-5", + "ruleScholarship": "CQUPT Scholarship Evaluation Measures", + "ruleScholarshipDate": "CQUPT 2016-84 · eff. 2016-01-01", + "enterText": "Please enter a question first" +} diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/index.ts b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/index.ts index 15e9035dfea..eaee8eb6301 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/index.ts +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/index.ts @@ -22,6 +22,8 @@ import guid from './guid.json'; import agent from './agent.json'; import team from './team.json'; import pet from './pet.json'; +import workbench from './workbench.json'; +import report from './report.json'; export default { common, @@ -43,4 +45,6 @@ export default { agent, team, pet, + workbench, + report, }; diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/report.json b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/report.json new file mode 100644 index 00000000000..1986fd82bc8 --- /dev/null +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/report.json @@ -0,0 +1,26 @@ +{ + "title": "报告自检", + "backToWorkbench": "返回工作台", + "coverage": "评分点覆盖率", + "coveredLabel": "已覆盖", + "missingLabel": "缺失证据", + "citationLabel": "引用问题", + "coverageUnit": "个评分点", + "missingEvidence": "缺失证据", + "missingEvidenceHint": "以下条件/信息尚未获得充分证据", + "citationIssues": "引用问题", + "citationIssuesHint": "相关度较低或无匹配的引用,请谨慎采信", + "suggestions": "修改建议", + "suggestionsHint": "基于当前证据给出的改进方向", + "noToolResult": "该会话暂无规则检索记录", + "noToolResultHint": "回到会话提问一个校园规则问题,AI 调用检索工具后即可在此查看自检报告。", + "goBack": "去会话提问", + "statusSuccess": "已完成", + "statusPartial": "部分完成", + "statusError": "未完成", + "toolQueryPolicy": "政策判定", + "toolSearch": "知识库检索", + "emptyMissing": "没有缺失证据,条件覆盖完整", + "emptyCitation": "没有引用问题", + "emptySuggestions": "暂无建议" +} diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/workbench.json b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/workbench.json new file mode 100644 index 00000000000..3553920e6bb --- /dev/null +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/workbench.json @@ -0,0 +1,35 @@ +{ + "title": "校园工作台", + "greeting": "早上好,同学", + "subGreeting": "今天有什么想了解的?", + "inputPlaceholder": "问问校园规则……", + "exampleHint": "例如:奖学金申请需要满足什么条件?", + "send": "发送", + "quickStart": "快捷提问", + "pillScholarship": "奖学金", + "pillLeave": "请假", + "pillDorm": "宿舍", + "pillExam": "考试", + "pillStudy": "学业", + "pillScholarshipPrompt": "请帮我解读奖学金申请条件", + "pillLeavePrompt": "请查询请假相关的管理规定", + "pillDormPrompt": "请查询宿舍管理规定", + "pillExamPrompt": "请查询考试违纪处理规定", + "pillStudyPrompt": "请查询学业与课程相关规定", + "recentTitle": "最近查询", + "recentEmpty": "还没有提问记录,试着问一个规则问题吧", + "recentTagAi": "AI 解读", + "enterConversation": "进入会话记录", + "recentTagRule": "规则", + "recentTagCase": "案例", + "ruleDynamicsTitle": "规则动态", + "ruleDynamicsEmpty": "政策库暂无文档", + "ruleViewAll": "查看全部", + "ruleStudentHandbook": "重庆邮电大学学生手册", + "ruleStudentHandbookDate": "2026 修订版 · 施行 2026-09-01", + "ruleRecommendation": "重庆邮电大学推免实施办法", + "ruleRecommendationDate": "重邮 2023-5 号", + "ruleScholarship": "重庆邮电大学奖助学金评审办法", + "ruleScholarshipDate": "重邮 2016-84 号 · 施行 2016-01-01", + "enterText": "请输入问题后再发送" +}