docs: 全面体检并更新文档,工具参考改为从运行时 schema 生成

先体检再动手:44 个 Markdown,相对链接 0 处失效,逐份分辨「现状文档」与「历史记录」。
历史记录(error-experience/*、架构分析、已过时的全景图)保留原貌只加指路,不重写。

- 新增 scripts/gen_mcp_docs.py:起 stdio 子进程 list_tools 后整段重生成 docs/MCP.md 的「工具参考」
  (291 行 / 25 个工具,占这份文档四分之三,手工维护必漂)。--check 漂移退出码 1,--diff 打印差异;
  GROUPS 是刻意闸门——加了工具没登记就报错,防止「有工具没文档」。接进 make mcp-docs / docs-check
  与 tasks.py 同名任务
- 生成时抓到两处真实漂移:invoice_type 线上是 string/null 且默认 null,文档手写成 null/string 无默认;
  case_list 无参数在旧文档里写成一句散文。现已与线上一字不差
- 过期说法清理:src/README 的「browser_* 与 submit_* 尚未实现」(browser 早已联机验通、submit 定案不做);
  tests/README 的「当前 107 例」(现 148)与缺 test_mcp_browser.py 一行、「Playwright 填报不进单测」
  (现在选择器分支/状态判定/错误路径有单测,只有真实页面需要联机);.agents/README 的「Cursor Agent」;
  plans/README 里还写着「提交加确认 token」;scripts/data/README 表格有一行跑到正文后面
- 工程实践指南里把已删除的 CLI 入口当例子:mypy src/main.py、make run、python src/main.py 全部改为现状
  (mypy src / 唯一入口 python -m src.mcp.server),并补上「任务必须透传退出码」这条本轮踩到的坑
- README:「还没做完」重写为「边界与未完事项」——把刻意不做(提交、submit_* 整类、删除、撤回)与
  真的未完(科目/项目/人员固定取第 N 行,待业务口径)分开写;开发命令补 mcp-docs / docs-check / skill-install
- 操作指南:7.3 经济科目与项目从「要改就改索引」改成「已知限制,等口径,填报后务必人工核对」;
  相关命令补齐新目标;MCP.md 标准流程补上填报与「到此为止,提交由人」两步,并改掉与新纪律矛盾的
  「把截图报给用户」表述
- AGENTS:命令表加两行;测试段给出改工具后的固定顺序(mcp-smoke → mcp-docs → 重连宿主);提交纪律
  补「make check 之外还有 docs-check / skill-check 两道专项门禁」;截图产物改标注为排查证据
- install_skill.py:拿不到主目录时退出码改 4,别和「漂移」的 1 混成一类
- 已核对:submit_execute / src/main.py 在文档里的残留都是禁止性表述或「已删除」清单项,不是现状描述

验证:ruff / format --check / mypy src / deptry / pytest 148 passed / mcp-smoke 25 工具 /
docs-check 与 skill-check 均退出码 0。--no-verify 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-20 23:12:46 +08:00
parent 3392ab2152
commit 821822a408
18 changed files with 349 additions and 61 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-11
last_reviewed: 2026-10-20
---
# .agents — 项目内部维护目录
@@ -11,7 +11,7 @@ last_reviewed: 2026-06-11
| 子目录 | 说明 |
|--------|------|
| `docs/` | 维护文档:规范、经验总结、实施方案、操作指南 |
| `skills/` | Cursor Agent 技能:定义自动化工作流和代码检查流程 |
| `skills/` | Agent 技能:`reimbursement`(报销业务纪律,可用 `make skill-install` 装到用户级跨项目使用)、`pre-commit-check`、`clean-git-history` |
## 文档边界
+4 -2
View File
@@ -2,8 +2,10 @@
本目录存放维护人员与自动化代理相关资料,主要用于项目运维,不属于对外公开的用户文档。
- `docs/plans/`:实施方案与阶段记录(当前主线是 `MCP化改造方案-2026-10-19.md`,含实测数据与踩坑)
- `docs/guides/`:内部实操手册(`项目架构全景图.md` 已标注改造前的结构,以方案文档为准)
- `docs/plans/`:实施方案与阶段记录。当前主线 `MCP化改造方案-2026-10-19.md`(实测数据与踩坑都在里面;
§15 两条真实单据的联机全流程、§16-§17 定案「提交与 submit_* 整类不做」、§18 技能独立化)
- `docs/guides/`:内部实操手册。`项目架构全景图.md` 写的是**改造前**架构(顶部有过时标注),现状看 `src/README.md`;
`工程实践指南.md` 是工程约定(uv、门禁、提交纪律)
- `docs/standards/`:规范制度;`docs/error-experience/`、`docs/good-experience/`:经验总结(按日期命名,保留历史原貌)
- `skills/`:Agent 技能定义(见 `skills/README.md`)
+8 -4
View File
@@ -107,7 +107,7 @@ ignore_errors = true
```
- 启用严格模式,测试文件豁免。
- **运行方式**:`uv run mypy src/main.py`
- **运行方式**:`uv run python -m mypy src`(整个 src 都在 strict 下;`uv run mypy` 偶尔报 trampoline 错,用 `python -m` 形式)
### 3.3 Deptry(依赖审计)
@@ -178,14 +178,14 @@ check:
@uv lock --locked
@uv run ruff check .
@uv run ruff format --check .
@uv run mypy src/main.py
@uv run python -m mypy src
@uv run deptry .
test:
@uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing
run:
@uv run python src/main.py
@uv run python -m src.mcp.server # 唯一入口是 MCP stdio,没有 CLI 产品入口
clean:
@rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache
@@ -193,7 +193,9 @@ clean:
### 6.2 tasks.py(跨平台)
提供 `tasks.py` 作为 Windows 兼容的任务运行器,支持相同任务名:`install`、`check`、`test`、`run`、`clean`。
提供 `tasks.py` 作为 Windows 兼容的任务运行器,任务名与 Makefile 一致:`install`、`check`、`test`、
`mcp`、`mcp-smoke`、`mcp-docs`、`docs-check`、`upload`、`skill-install`、`skill-check`、`clean`。
**任务必须透传退出码**(本项目就栽过:`os.system` 的返回值被丢掉,门禁失败调用方仍看到 0)。
---
@@ -258,4 +260,6 @@ logs/
- [ ] `uv run mypy src/` 无类型错误
- [ ] `uv run deptry .` 无依赖问题
- [ ] `uv run python -m pytest --cov` 全部通过且覆盖率合理
- [ ] 改了 MCP 工具:`make mcp-smoke` 再 `make mcp-docs`,并确认 `make docs-check` 为 0
- [ ] 不承诺未完成的能力:INSTRUCTIONS 只列已注册工具;提交类动作永远不进工具层
- [ ] 所有命令使用 `uv run` 前缀,无全局 pip 操作
@@ -2,6 +2,7 @@
> **已过时(2026-10-19 MCP 化改造)**:本文描述的是改造前的架构,保留作为历史与决策背景。
> 现状请看 `docs/MCP.md`(工具契约)、`AGENTS.md`(项目操作指南)与
> `.agents/docs/plans/MCP化改造方案-2026-10-19.md`(决策与实测记录)。
> 现在的结构看 `src/README.md`(分层与依赖方向);本文只作历史留存,不要照它找文件。
> 具体差异:`src/agent/`(状态机与 SSE)、`src/web/`(Flask 界面)、CLI 入口、内置 LLM 抽取
> 与 `llama-index` 依赖均已删除;Word 出库单功能下线;提示词迁移为 `.agents/skills/reimbursement/references/`。
+3 -2
View File
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-07-23
last_reviewed: 2026-10-20
---
# .agents/docs/plans — 实施方案与工作交接
@@ -11,7 +11,8 @@ last_reviewed: 2026-07-23
| 文件 | 说明 |
|------|------|
| `架构分析-2026-06-15.md` | 项目架构分析与重构建议(模块拆分已完成;其中「校验与提示词耦合」的判断已过期,`validator.py` 现只依赖 stdlib) |
| `MCP化改造方案-2026-10-19.md` | 改造为 MCP 服务器:删除 Web 与自研状态机、抽取改由宿主 Agent 承担、浏览器常驻分步填报、提交加确认 token。含已验证缺陷清单与 P0-P4 闸门 |
| `MCP化改造方案-2026-10-19.md` | 改造为 MCP 服务器:删除 Web 与自研状态机、抽取改由宿主 Agent 承担、浏览器常驻分步填报。含已验证缺陷清单与 P0-P4 闸门;§16-§17 记录定案:**提交与 `submit_*` 整类不做**,
由人在财务系统里执行;§15 是两条真实单据走通全流程的联机验证记录 |
## 用途
+16 -1
View File
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-10-19
last_reviewed: 2026-10-20
---
# .agents/skills — Agent 技能目录
@@ -17,3 +17,18 @@ last_reviewed: 2026-10-19
命中描述的场景时 Agent 自动加载,无需手动调用。`reimbursement` 的技能正文与 `docs/MCP.md` 有意分工:
前者讲「怎么做才对」,后者讲「接口长什么样」。
### 装到用户级(跨项目可用)
本目录的技能只在打开本项目时可见。要让任何项目都能用 `reimbursement`:
```bash
make skill-install # 或 python tasks.py skill-install
make skill-check # 比对安装副本与仓库,漂移退出码 1
```
真源永远是仓库里这份,**别去编辑安装副本**(下次同步会被覆盖)。
坑:技能来源由宿主的 skills-manager 管,装进**已停用的来源目录**里文件明明存在、宿主却永远扫不到。
脚本会读 `~/.dsh/dsh-skills-manager.log` 里该目录最后一条 `source-enable/-disable` 并在停用时拒绝安装;
本机启用的是 `~/.agents/skills`,`~/.dsh/skills` 已停用。
+9 -3
View File
@@ -4,7 +4,7 @@ alwaysApply: true
---
---
last_reviewed: 2026-10-19
last_reviewed: 2026-10-20
---
# AGENTS — 项目操作指南
@@ -28,6 +28,8 @@ last_reviewed: 2026-10-19
| 代码检查(lint+format+typecheck+deptry) | `make check` | `python tasks.py check` |
| 运行测试(含覆盖率报告) | `make test` | `python tasks.py test` |
| MCP stdio 自检(起子进程握手列工具) | `make mcp-smoke` | `python tasks.py mcp-smoke` |
| 由线上 schema 重新生成 `docs/MCP.md` 工具参考 | `make mcp-docs` | `python tasks.py mcp-docs` |
| 检查工具文档是否与代码同源(漂移退出码 1) | `make docs-check` | `python tasks.py docs-check` |
| 启动上传收件器 | `make upload` | `python tasks.py upload` |
| 把报销技能装到用户级目录(脱离本仓库也能用) | `make skill-install` | `python tasks.py skill-install` |
| 检查安装副本与仓库是否漂移 | `make skill-check` | `python tasks.py skill-check` |
@@ -55,6 +57,8 @@ last_reviewed: 2026-10-19
若这个过程被中断(超时/杀进程),未暂存的文件会**被回滚成 HEAD 版本**且 `git fsck` 捞不回来。
本项目改造过程中真发生过一次,四个文件的改动靠锚点重放才恢复。
* 因此:提交前先把工作区改动 `git add` 干净,或用 `--no-verify` 提交并手动跑完等价检查(在提交说明里写明)。
* 改过 MCP 工具或技能,`make check` 之外还差两道专项门禁:`make docs-check`(工具文档同源)与
`make skill-check`(技能安装副本一致)——它们都靠退出码报漂移,别只看有没有打印错误
## 项目架构(MCP 服务器模式)
@@ -88,7 +92,7 @@ last_reviewed: 2026-10-19
| `invoice_summary.csv` / `payment_records.csv` | `match_payments` | 发票级与支付级汇总(申购单取数) |
| `travel_info.json` / `normal_info.json` | 宿主综合 → `save_info` | 差旅/普通报销的全部结构化数据 |
| `artifacts/*.xls` | `doc_requisition` | 低值易耗品申购单 |
| `screenshots/NNN-<步骤>.png` | `browser_*` 分步填报 | 过程截图,出问题时给用户看 |
| `screenshots/NNN-<步骤>.png` | `browser_*` 分步填报 | 排查证据(Agent 自己回看用,不必逐张给用户)|
| `.browser.lock` | `browser_open` | 谁拥有该会话的浏览器(PID + 心跳,180 秒可接管) |
@@ -162,4 +166,6 @@ make test # pytest + coverage report (term-missing)
测试目录 `tests/`,配置在 `pyproject.toml`(`testpaths`、`pythonpath=["."]`)。
COM 相关路径(申购单)不进单测——需要本机装有 Excel,改动后用真实生成验证(约 2-5 秒)。
改完 MCP 工具记得 `make mcp-smoke`(它验的是 wire 上的工具清单,不是进程内对象)。
改完 MCP 工具的顺序:`make mcp-smoke`(验 wire 上的工具清单,进程内 import 成功不代表宿主能看到)→
`make mcp-docs`(由线上 schema 重出 `docs/MCP.md` 的工具参考)→ 重连宿主(杀掉 stdio 子进程会自动重启并重同步工具)。
`make docs-check` 能在 CI/提交前发现文档与代码漂移(退出码 1)。
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install check test mcp mcp-smoke upload skill-install skill-check clean
.PHONY: install check test mcp mcp-smoke mcp-docs docs-check upload skill-install skill-check clean
install:
@uv sync
@@ -21,6 +21,12 @@ mcp:
mcp-smoke:
@uv run python scripts/mcp_smoke.py
mcp-docs:
@uv run python scripts/gen_mcp_docs.py
docs-check:
@uv run python scripts/gen_mcp_docs.py --check
upload:
@uv run python -m src.upload.server
+22 -12
View File
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-10-19
last_reviewed: 2026-10-20
---
# 财务报销自动化(MCP 服务器)
@@ -27,7 +27,7 @@ last_reviewed: 2026-10-19
```bash
uv sync # 依赖(要求 Python >= 3.12)
uv run playwright install chromium # 填报要用(P3 工具接入后需要)
uv run playwright install chromium # 浏览器填报要用
```
配好 `scripts/data/config.json`(参考 `config.example.json`:工号、密码、默认报销人、公务卡号、人员编号、上传 PIN)。
@@ -98,12 +98,16 @@ cases/ 会话产物(gitignore,含个人信息)
```bash
make install # uv sync + pre-commit
make check # ruff check + format --check + mypy src + deptry
make test # pytest + 覆盖率
make upload # 上传收件器
make mcp-smoke # stdio 自检
make test # pytest + 覆盖率(148 例,不需要网络与真实账号)
make mcp-smoke # stdio 自检:真起子进程握手、列工具
make mcp-docs # 由线上 schema 重新生成 docs/MCP.md 的工具参考
make docs-check # 工具文档漂移检测(退出码 1 即不一致)
make skill-install # 把报销技能装到用户级目录,跨项目可用
make upload # 上传收件器(手机扫码,独立进程)
```
测试覆盖:配置加载、发票分类与 CSV、金额匹配、会话模型、分析工具、骨架与规则一致性、申购单取值。
测试覆盖:配置加载、发票分类与 CSV、金额匹配、会话与分析工具、骨架与规则一致性、申购单取值,
以及分步填报的步骤顺序、续填找回、附件提交校验(用假页面跑真实逻辑,不碰 Playwright 与账号)。
## 平台与风险
@@ -114,12 +118,17 @@ make mcp-smoke # stdio 自检
- **提交前务必手动跑门禁**:本仓库 pre-commit 只跑 ruff,但它会 stash 未暂存改动,
中途中断会把工作区文件回滚成 HEAD(本项目改造过程中真发生过,见方案文档 §11)
## 还没做完
## 边界与未完事项
填报已能分步做(`browser_*`,见 [docs/MCP.md](docs/MCP.md))。**提交永远由人类执行**:不提供 `submit_execute`
这类代提交工具(用户明确否决),填完必须停下由本人在财务系统里核对并亲自点提交。
`submit_*` 整类(代提交、提交前预览)都不做:用户自己去财务系统里核对、提交、查状态。
Word 版易耗品出库单已永久下线(模板自 2026-08-31 起不在仓库,此前该路径一直静默失败)。
**刻意不做**:提交、提交前预览(`submit_*` 整类)、删除、撤回——都由本人在财务系统里做,
工具层不给这些动作开口子(错一条要写情况说明,不可逆的动作不交给自动化)。填报完成就是终点,
把单据编号与会话目录告诉用户即可。
**已知未完**(截至 2026-10-20):
- 经济科目固定取弹窗第 3 行、项目固定取第 1 行、补助人员取查询结果第一行:会静默填出财务上
不正确的数据,等用户给业务口径后改成可配置 + 唯一匹配
- Word 版易耗品出库单永久下线(模板自 2026-08-31 起不在仓库,此前该路径一直静默失败)
## 文档索引
@@ -128,4 +137,5 @@ Word 版易耗品出库单已永久下线(模板自 2026-08-31 起不在仓库
| [docs/MCP.md](docs/MCP.md) | 25 个工具的参数、返回信封、会话目录、注册方法 |
| [docs/报销操作指南.md](docs/报销操作指南.md) | 财务侧口径与材料准备要求 |
| [AGENTS.md](AGENTS.md) | 给 Agent 的项目操作指南(开发命令、约束、坑) |
| [.agents/docs/plans/MCP化改造方案-2026-10-19.md](.agents/docs/plans/MCP化改造方案-2026-10-19.md) | 改造决策、实测数据、踩坑记录 |
| [.agents/skills/reimbursement/SKILL.md](.agents/skills/reimbursement/SKILL.md) | 宿主 Agent 的操作纪律(可装到用户级跨项目用) |
| [.agents/docs/plans/MCP化改造方案-2026-10-19.md](.agents/docs/plans/MCP化改造方案-2026-10-19.md) | 改造决策、实测数据、踩坑记录、各项定案 |
+34 -24
View File
@@ -84,7 +84,11 @@ cases/<case_id>/
5. `match_payments` → **逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额)
6. `info_skeleton` 取骨架 → 填值 → `save_info`(自动跑校验);`validate_info` 可反复试算不落盘
7. 普通发票再 `doc_requisition` 生成申购单
8. 每步之后 `case_status` 复查;`case_reset(scope)` 清派生产物重跑
8. 每步之后 `case_status` 复查(`next_action` 与 `warnings` 最该看);`case_reset(scope)` 清派生产物重跑
9. 填报:`browser_open` → `browser_login`(返回 `need_human` 就请用户在**已打开的窗口**里完成再重调)→
`browser_new_form`(断线改用 `browser_open_form` 按**单据编号**找回原单续填,别重开新单)→
按返回的 `steps` 逐个 `browser_fill` → `browser_page_state` 复查 → `browser_close`
10. 到此为止:把单据编号与会话目录告诉用户,**提交由他本人在财务系统里做**
## 硬约束
@@ -96,8 +100,8 @@ cases/<case_id>/
## 提交:不做,永久不做
**提交由人类在财务系统里亲自点。** 本项目不提供、也不计划提供 `submit_execute` 这类代提交工具
(用户明确否决)。填报完成就是 Agent 的终点:把 `browser_page_state` 的截图与已填内容报给用户,
由本人核对后自己提交。删除(`#delete`)、撤回同理,都在页面上手工做。
(用户明确否决)。填报完成就是 Agent 的终点:把**单据编号**和会话目录(`case_status` 的 `case_dir`)告诉用户,
由他本人到财务系统里核对、提交、查状态。删除(`#delete`)、撤回同理,都在页面上手工做。
`submit_*` 整类(代提交、提交前预览 `submit_preview`)也一并不做:用户自己到财务系统里核对、提交、
查状态。Agent 到 `browser_fill` / `browser_page_state` 为止,把单据编号与会话目录告诉用户就够了;
@@ -109,7 +113,10 @@ cases/<case_id>/
# 工具参考
以下内容由运行中的服务器 schema 自动生成,与代码同源(`scripts/mcp_smoke.py` 打印同一份清单)。
<!-- 本节由 scripts/gen_mcp_docs.py 从运行时 schema 生成,别手改;改完工具跑 make mcp-docs -->
共 25 个工具,在宿主里都带 `mcp__arb__` 前缀。
## 会话与材料
每个报销任务是一个会话目录 cases/<case_id>/,阶段由产物推导。
@@ -124,13 +131,15 @@ cases/<case_id>/
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `name` | string | 是 | — | — |
| `invoice_type` | null/string | 否 | — | — |
| `invoice_type` | string/null | 否 | null | — |
### `mcp__arb__case_list`
列出所有报销会话及其推导阶段、计数与待收文件数。
无参数。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| (无参数) | — | — | — | — |
### `mcp__arb__case_status`
@@ -150,8 +159,8 @@ cases/<case_id>/
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `paths` | array/null | 否 | — | — |
| `source` | null/string | 否 | — | — |
| `paths` | array/null | 否 | null | — |
| `source` | string/null | 否 | null | — |
### `mcp__arb__case_inbox`
@@ -184,7 +193,7 @@ cases/<case_id>/
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `files` | array/null | 否 | — | — |
| `files` | array/null | 否 | null | — |
| `dpi` | integer | 否 | 150 | — |
| `force` | boolean | 否 | false | — |
@@ -203,7 +212,7 @@ cases/<case_id>/
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `kind` | null/string | 否 | — | — |
| `kind` | string/null | 否 | null | — |
### `mcp__arb__case_save_extraction`
@@ -233,7 +242,7 @@ cases/<case_id>/
## 匹配与校验
匹配与校验都是确定性代码;结论以 validate_info 为准,不要自己宣布可提交。
金额匹配是确定性的;完整性的裁判是规则文件,不是宿主。
### `mcp__arb__match_payments`
@@ -245,7 +254,7 @@ cases/<case_id>/
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `tolerance` | null/number | 否 | — | — |
| `tolerance` | number/null | 否 | null | — |
### `mcp__arb__info_skeleton`
@@ -280,8 +289,8 @@ cases/<case_id>/
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `info_type` | null/string | 否 | — | — |
| `draft` | null/object | 否 | — | — |
| `info_type` | string/null | 否 | null | — |
| `draft` | object/null | 否 | null | — |
### `mcp__arb__rules_get`
@@ -289,11 +298,11 @@ cases/<case_id>/
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `info_type` | null/string | 否 | — | — |
| `info_type` | string/null | 否 | null | — |
## 文档产物
Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺序:REQUISITION_TEMPLATE → templates/ → 项目根。
需要 Windows + Excel(COM)。Word 出库单已下线,只剩申购单。
### `mcp__arb__doc_requisition`
@@ -305,12 +314,12 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `exclude` | array/null | 否 | — | — |
| `project` | null/string | 否 | — | — |
| `exclude` | array/null | 否 | null | — |
| `project` | string/null | 否 | null | — |
## 浏览器分步填报
需先 `uv run playwright install chromium`。默认**有头**;归属由 `cases/<id>/.browser.lock` 记录 (PID + 心跳,180 秒未心跳可被接管)。中途断了不必重来:用 `browser_open_form` 从列表页找回原单据 续填,**不要再 `browser_new_form` 重复建单**。提交不在这里,填完交人工核对。
常驻会话加分步执行;提交由人在财务系统里做,工具不含提交动作。断线用 browser_open_form 按单据编号找回原单续填,别重开新单。
### `mcp__arb__browser_open`
@@ -340,21 +349,22 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `kind` | null/string | 否 | — | — |
| `kind` | string/null | 否 | null | — |
### `mcp__arb__browser_open_form`
从列表页找回已存在的单据接着填(中途断了不必重来)。
match 用报销说明里的关键词,必须在当前列表页唯一命中;命中多张直接失败,
让宿主把关键词收紧——财务系统里点错别人的单据是不能接受的后果。preview_only=true
只搜索定位、不点开单据,用来先确认是哪一张。
match 必须在当前列表页唯一命中;命中多张直接失败,让宿主把关键词收紧——财务系统里
点错别人的单据是不能接受的后果。注意命中判据是**列表页可见文字**,长报销说明会被
CSS 截断(`元器件采购(P3 全 ...`),所以最稳的 match 是**单据编号**(不会被截断)。
preview_only=true 只搜索定位、不点开单据,用来先确认是哪一张。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `match` | string | 是 | — | — |
| `kind` | null/string | 否 | — | — |
| `kind` | string/null | 否 | null | — |
| `preview_only` | boolean | 否 | false | — |
### `mcp__arb__browser_fill`
+3 -2
View File
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-10-19
last_reviewed: 2026-10-20
---
# docs — 公开文档目录
@@ -10,7 +10,8 @@ last_reviewed: 2026-10-19
| 文件 | 说明 |
|------|------|
| `MCP.md` | MCP 工具完整契约:注册方法、返回信封、会话目录、标准流程、25 个工具的参数表 |
| `MCP.md` | MCP 工具完整契约:注册方法、返回信封、会话目录、标准流程;工具参数表由
`make mcp-docs` 从运行时 schema 生成(`make docs-check` 检测漂移,别手改那一节)|
| `报销操作指南.md` | 面向最终用户的报销口径与材料准备说明 |
> 旧的 `API.md`(Flask 路由/SSE 文档)已随 Web 界面删除;需要历史接口形态时看 git 提交 `b2d271c`。
+9 -3
View File
@@ -208,11 +208,15 @@ SSO 需要扫码或验证码时人工完成即可,流程会继续。每次运
普通报销用「一条总明细」:所有发票合并成一条,报销总金额 = 各笔支付之和,单据数 = 发票张数;
支付方式逐笔录入,一笔一条。差旅按人去程/返程分条录交通费,补助按人按天算。
### 7.3 经济科目与项目
### 7.3 经济科目与项目(已知限制,待业务口径)
经济科目弹窗固定选第 3 行(`rows[2]`),项目弹窗固定选第 1 行。要改就改这两个索引,
经济科目弹窗当前固定选第 3 行(`rows[2]`),项目弹窗固定选第 1 行,补助人员取查询结果第一行;
位置在 `src/infra/browser/travel.py` 与 `normal.py`。
**这是已知限制而不是正确策略**:科目选错财务会退单,而代码不知道该按什么规则选。
等给出业务口径(按发票品名?按项目性质?按经费用途?)后改成可配置加唯一匹配。
在此之前,填报完成务必人工核对科目与项目。
### 7.4 网络报销链接动态获取
单点登录页的「网络报销」链接参数每次不同,因此用 `a:has(img[src*="wlbx"])` 定位并读 `href`,不硬编码 URL。
@@ -291,7 +295,9 @@ URL 类字段(`sso_login_url`、`portal_url`、`reimburse_url`、`reimburse_pa
```bash
make check # ruff + format + mypy src + deptry
make test # pytest + 覆盖率
make mcp-smoke # MCP stdio 自检
make mcp-smoke # MCP stdio 自检:真起子进程握手并列工具
make mcp-docs # 由线上 schema 重新生成 docs/MCP.md 的工具参考
make skill-install # 把报销技能装到用户级目录(跨项目可用)
make upload # 启动扫码上传收件器
# 不经 MCP 单独重生成申购单(调试用)
+7
View File
@@ -6,8 +6,15 @@ last_reviewed: 2026-10-19
| 文件/目录 | 说明 |
|-----------|------|
| `gen_mcp_docs.py` | 从运行时 schema 重新生成 `docs/MCP.md` 的工具参考一节(`make mcp-docs`;`make docs-check`
只比对,漂移退出码 1)。加了工具却没在 GROUPS 登记会直接报错——刻意闸门 |
| `install_skill.py` | 把报销技能同步到用户级技能目录,让它脱离本仓库可用(`make skill-install` /
`make skill-check`)。装之前会核实该目录作为技能来源是否启用:装进停用的来源里,宿主永远扫不到 |
| `mcp_smoke.py` | MCP stdio 自检:拉起自身、打印工具清单、验证 instructions 已下发、调一次 `case_list`。
`make mcp-smoke` 或 `python tasks.py mcp-smoke` |
| `data/` | 项目级配置目录(见 `data/README.md`) |
三个脚本都不改业务代码:`mcp-smoke` 验线上契约,`mcp-docs` 由线上 schema 出文档,`install_skill` 只管拷贝。
改完工具的顺序是 `mcp-smoke` → `mcp-docs`。
改完 MCP 工具一定要跑 `mcp_smoke`:它验的是**线上传输**的契约,进程内 import 成功不代表宿主能看到工具。
+211
View File
@@ -0,0 +1,211 @@
"""从运行中的 MCP 服务器生成 docs/MCP.md 的「工具参考」一节。
这一节占文档大半,手工维护一定会跟代码漂移,所以由运行时 schema 生成:
起 stdio 子进程 -> list_tools -> 按分组渲染 -> 从「# 工具参考」起到文件末尾整段替换。
用法:
uv run python scripts/gen_mcp_docs.py # 重新生成并写回
uv run python scripts/gen_mcp_docs.py --check # 只比对;有漂移退出码 1
uv run python scripts/gen_mcp_docs.py --diff # 比对并打印差异前 30 处
新增工具时必须在 GROUPS 里登记它属于哪一组,否则本脚本报错退出——这是刻意的闸门:
加了工具却不进文档,比不进更糟。
本文件刻意不写反引号与反斜杠(都用 chr 生成),因为它是从别的脚本里拼出来写盘的。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mcp.client.stdio import StdioServerParameters # noqa: E402
from mcp import Client # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
DOC = ROOT / "docs" / "MCP.md"
HEADING = "# 工具参考"
PREFIX = "mcp__arb__"
BACKTICK = chr(96)
NL = chr(10)
# 分组:标题 -> (组说明, 工具顺序)。顺序就是文档里的出现顺序。
GROUPS: list[tuple[str, str, list[str]]] = [
(
"会话与材料",
"每个报销任务是一个会话目录 cases/<case_id>/,阶段由产物推导。",
["case_create", "case_list", "case_status", "case_add_files", "case_inbox", "case_reset"],
),
(
"抽取交接",
"渲染页面与读文本层由宿主 Agent 自己判断,本服务不调用任何模型。",
[
"case_render_pages",
"case_read_text",
"extraction_spec",
"case_save_extraction",
"case_patch_extraction",
],
),
(
"匹配与校验",
"金额匹配是确定性的;完整性的裁判是规则文件,不是宿主。",
["match_payments", "info_skeleton", "save_info", "validate_info", "rules_get"],
),
(
"文档产物",
"需要 Windows + Excel(COM)。Word 出库单已下线,只剩申购单。",
["doc_requisition"],
),
(
"浏览器分步填报",
"常驻会话加分步执行;提交由人在财务系统里做,工具不含提交动作。"
"断线用 browser_open_form 按单据编号找回原单续填,别重开新单。",
[
"browser_open",
"browser_login",
"browser_new_form",
"browser_open_form",
"browser_fill",
"browser_page_state",
"browser_steps",
"browser_close",
],
),
]
def type_of(spec: dict[str, Any]) -> str:
"""参数类型:anyOf 用斜杠连接,保持 schema 里的原始顺序。"""
if "anyOf" in spec:
return "/".join(str(part.get("type", "?")) for part in spec["anyOf"] if isinstance(part, dict))
raw = spec.get("type")
if isinstance(raw, list):
return "/".join(str(item) for item in raw)
return str(raw or "?")
def render_tool(name: str, tool: Any) -> str:
lines = ["### " + BACKTICK + PREFIX + name + BACKTICK, ""]
description = (getattr(tool, "description", "") or "").rstrip()
body = description.splitlines()
if body:
lines.append(body[0].strip())
rest = [line.strip() for line in body[1:]]
while rest and not rest[0]:
rest.pop(0)
if rest:
lines.append("")
lines.extend(" " + line for line in rest)
lines.append("")
schema = getattr(tool, "input_schema", None) or {}
props = schema.get("properties") or {}
required = set(schema.get("required") or [])
lines.append("| 参数 | 类型 | 必填 | 默认 | 说明 |")
lines.append("|------|------|------|------|------|")
if not props:
lines.append("| (无参数) | — | — | — | — |")
for pname, spec in props.items():
spec = spec if isinstance(spec, dict) else {}
default = json.dumps(spec["default"], ensure_ascii=False) if "default" in spec else "—"
note_lines = str(spec.get("description", "")).replace("|", "/").splitlines()
note = (note_lines[0].strip() if note_lines else "") or "—"
need = "是" if pname in required else "否"
cell = BACKTICK + pname + BACKTICK
lines.append("| " + cell + " | " + type_of(spec) + " | " + need + " | " + default + " | " + note + " |")
return NL.join(lines)
async def fetch_tools() -> dict[str, Any]:
params = StdioServerParameters(command=sys.executable, args=["-m", "src.mcp.server"], cwd=str(ROOT))
async with Client(params) as client:
listed = await client.list_tools()
tools = list(getattr(listed, "tools", listed))
return {t.name: t for t in tools}
def build_section(live: dict[str, Any]) -> tuple[str, list[str]]:
"""渲染整节;返回正文与「登记表与线上不一致」清单。"""
problems: list[str] = []
planned = [n for _, _, names in GROUPS for n in names]
dup = sorted({n for n in planned if planned.count(n) > 1})
if dup:
problems.append("GROUPS 里重复登记:" + ", ".join(dup))
missing = sorted(set(live) - set(planned))
if missing:
problems.append("工具已上线但没进文档(去 GROUPS 登记它):" + ", ".join(missing))
ghost = sorted(set(planned) - set(live))
if ghost:
problems.append("文档登记了但线上没有的工具:" + ", ".join(ghost))
if problems:
return "", problems
out = [
HEADING,
"",
"<!-- 本节由 scripts/gen_mcp_docs.py 从运行时 schema 生成,别手改;改完工具跑 make mcp-docs -->",
"",
"共 " + str(len(live)) + " 个工具,在宿主里都带 " + BACKTICK + PREFIX + BACKTICK + " 前缀。",
]
for title, intro, names in GROUPS:
out += ["", "## " + title, "", intro]
for n in names:
out += ["", render_tool(n, live[n])]
return NL.join(out).rstrip() + NL, problems
def split_doc(text: str) -> tuple[str, str]:
idx = text.find(NL + HEADING)
if idx < 0:
raise SystemExit("docs/MCP.md 里找不到标题:" + HEADING)
return text[: idx + 1], text[idx + 1 :]
def show_diff(old: str, new: str, limit: int = 30) -> None:
a, b = old.splitlines(), new.splitlines()
shown = 0
for i in range(max(len(a), len(b))):
x = a[i] if i < len(a) else "<无>"
y = b[i] if i < len(b) else "<无>"
if x != y:
print(" 旧| " + x[:110])
print(" 新| " + y[:110])
shown += 1
if shown >= limit:
return
def main() -> int:
parser = argparse.ArgumentParser(description="生成 docs/MCP.md 的工具参考一节")
parser.add_argument("--check", action="store_true", help="只比对,不写文件")
parser.add_argument("--diff", action="store_true", help="比对并打印差异")
args = parser.parse_args()
live = asyncio.run(fetch_tools())
section, problems = build_section(live)
if problems:
for item in problems:
print("登记不一致:" + item, file=sys.stderr)
return 2
head, old = split_doc(DOC.read_text(encoding="utf-8"))
if section == old:
print("文档已与代码同源(" + str(len(live)) + " 个工具)")
return 0
if args.check or args.diff:
print("docs/MCP.md 的工具参考与线上 schema 不一致:跑 make mcp-docs 重新生成", file=sys.stderr)
if args.diff:
show_diff(old, section)
return 1
DOC.write_text(head + section, encoding="utf-8")
print("已重新生成工具参考:" + str(len(live)) + " 个工具," + str(len(section.splitlines())) + " 行")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2 -1
View File
@@ -36,7 +36,8 @@ def resolve_home() -> Path:
try:
return Path.home()
except RuntimeError:
raise SystemExit("无法确定用户主目录:设 DSH_SKILLS_DIR,或用 --dest 指定技能目录") from None
print("无法确定用户主目录:设 DSH_SKILLS_DIR,或用 --dest 指定技能目录", file=sys.stderr)
raise SystemExit(4) from None
def norm(path_str: str) -> str:
+4 -2
View File
@@ -9,7 +9,7 @@ MCP 服务器的全部实现:工具层在上,确定性内核在下,infra
```
src/
├── mcp/ 工具与协议层(25 个工具,stdio)
│ ├── server.py INSTRUCTIONS + TOOLS 注册 + build_server()
│ ├── server.py INSTRUCTIONS + 注册 + build_server()(工具表由 scripts/gen_mcp_docs.py 生成)
│ ├── tools/ materials 材料 / extraction 抽取交接 / analysis 匹配校验 / documents 产物
│ ├── schemas.py 抽取字段定义单一真源 + 日期金额规范化
│ └── envelope.py ok/fail 统一信封(工具不把异常抛穿协议)
@@ -31,4 +31,6 @@ src/
- **判断在上,事实在下**:读图、综合、决定交给宿主 Agent;本项目只做渲染、匹配、校验、生成
- **无状态**:阶段从磁盘产物推导,进程随时可杀可重启
- **不承诺未完成的事**:INSTRUCTIONS 只列已注册工具,`browser_*` 与 `submit_*` 尚未实现
- **不承诺未完成的事**:INSTRUCTIONS 只列已注册工具
- **提交不做,永久不做**:`submit_*` 整类(代提交、提交前预览)已被用户否决,填报完成就是终点;
删除与撤回同理,都在财务系统里由人做
+2
View File
@@ -14,6 +14,8 @@ TASKS = {
"test": "uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing",
"mcp": "uv run python -m src.mcp.server",
"mcp-smoke": "uv run python scripts/mcp_smoke.py",
"mcp-docs": "uv run python scripts/gen_mcp_docs.py",
"docs-check": "uv run python scripts/gen_mcp_docs.py --check",
"upload": "uv run python -m src.upload.server",
"skill-install": "uv run python scripts/install_skill.py",
"skill-check": "uv run python scripts/install_skill.py --check",
+5 -2
View File
@@ -4,7 +4,7 @@ last_reviewed: 2026-10-19
# tests — 单元测试
pytest 运行(`make test` 带覆盖率)。当前 107 例。
pytest 运行(`make test` 带覆盖率)。当前 148 例。
| 文件 | 覆盖范围 |
|------|---------|
@@ -14,11 +14,14 @@ pytest 运行(`make test` 带覆盖率)。当前 107 例。
| `test_mcp_analysis.py` | MCP 分析工具:缓存分组、凑额复核、业务约束违反、无发票场景、`save_info` 剥离多余字段、
草稿不落盘、`rules_get` 形状、`info_skeleton` 与规则同源、骨架填满即通过 |
| `test_requisition.py` | 申购明细:品名清洗、规格猜测、发票号去重、关键词排除、项目名取值链与回退 |
| `test_mcp_browser.py` | 分步填报工具:步骤顺序与跳步拦截、续填找回、need_human、浏览器锁、标签页切换、
附件提交校验、两个列表页互不认错。用 FakePage 跑 `base.py` 的真实逻辑,不依赖 Playwright 与真实账号 |
## 不进单测的部分
- **Office COM**(申购单):需要本机装有 Excel,改动后用真实生成验证(约 2-5 秒,产物单元格肉眼确认)
- **Playwright 填报**:需要真实财务系统账号,手工验证
- **真实填报**:账号和页面只有联机才有,靠 `browser_*` 实机走一遍 + 逐张看截图验证;
但选择器分支、状态判定、错误路径这些逻辑 `test_mcp_browser.py` 里有单测兜着
- **MCP wire 层**:用 `make mcp-smoke`(真起 stdio 子进程握手、列工具、调一次 `case_list`)
测试不碰 `cases/` 真实目录,一律用 `tmp_path` 造会话。