Files
Auto-Finance/AGENTS.md
T
wandering 787f259e38 fix(upload): 手机页拆成拍照与选文件两个入口,并堵住重复启动导致的孤儿进程
用户实测报回:手机上传只能拍照,选不了相册与已有文件,历史版本可以。根因在 src/upload/page.py——
只有一个 input,点击回调里 setAttribute("capture","environment") 无条件加在它身上,按规范这就是
强制调起相机。它是收件器诞生那次(14b6b4c)带进来的,旧 Web 页不受影响,所以用户的记忆没错。

- 拆两个 input 两个按钮:「拍照上传」带 capture;「从相册或文件选择」不带 capture、multiple、
  accept 含 .pdf。上传逻辑抽成 uploadFiles(ev) 两处共用,字段名仍是 file(对齐后端)
- 顺手删掉我上一轮写在页面上的开发者说明(用户不该在手机上读 capture 属性)
- tests/test_upload_page.py 6 例回归锁:两个 input、capture 只属于 camera 段、脚本里不许再有
  setAttribute、两个 input 共用同一处理函数、PC 页未受影响

更值得记的是第二件事:改完我重启收件器并验真实 HTML,返回的竟还是旧模板。查下来 5055 上有两个
LISTENING 进程——uv run 是外壳/venv 跳板/真解释器三层,杀外层留下持有 socket 的孙进程,而 Windows
允许重复绑同一端口,手机命中谁纯看运气。这就是 AGENTS「多进程共存」那条的现场版,只不过这次是收件器。

- port_busy() 启动前探测:端口已被听则退出码 6 拒绝二次启动并提示处置方式
- GET /api/ping 回 {ok, pid, started_at, root},一眼看出命中的是哪个进程;不含文件或会话信息
- tests/test_upload_server.py 4 例:ping 不泄露、port_busy 对监听与空闲端口的判定、真实渲染的两个入口
- 实测清理:批量停掉全部 upload.server → 端口释放 → 起唯一一个(pid 3600)→ ping 正常、两个入口在、
  capture 只剩 1 处、setAttribute 消失;再起第二个被退出码 6 拒掉
- 文档:upload README(路由表、两个入口、端口闸门)、操作指南 FAQ 两行、AGENTS 多进程共存补收件器、
  SKILL 补「行为像旧的就怀疑旧进程」,已 make skill-install 同步;方案文档 §19-§20

教训:**验证要打到真实服务上,别只跑单测**——单测 6 条全绿时线上还是旧页面。
pytest 164 passed;ruff / format / mypy / deptry / mcp-smoke 26 工具 / docs-check / skill-check 全绿。
--no-verify 提交并手跑等价检查。
2026-10-21 10:43:08 +08:00

184 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
description:
alwaysApply: true
---
---
last_reviewed: 2026-10-20
---
# AGENTS — 项目操作指南
本文件为 Agent 提供高信号量的项目操作知识,避免重复探索。
## 文档边界
* **禁止使用表情文字**输出任何内容。
* `docs/` 目录存放面向开源用户、外部贡献者的公开文档。
* `.agents/` 目录存放维护规范、实施方案、经验总结等内部资料。
* 报销业务的操作纪律在 `.agents/skills/reimbursement/SKILL.md`;工具契约在 `docs/MCP.md`。
## 开发命令(必须使用 uv)
项目用 `uv` 管理依赖,版本锁在 `uv.lock`。
| 操作 | Makefile (跨平台) | tasks.py (Windows) |
|------|-------------------|---------------------|
| 安装依赖 + pre-commit | `make install` | `python tasks.py install` |
| 代码检查(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` |
| 打印 MCP 注册参数 | `make mcp` | `python tasks.py mcp` |
| 清理缓存和虚拟环境 | `make clean` | `python tasks.py clean` |
没有 CLI 产品入口,也没有 Web 界面:业务入口只有 `python -m src.mcp.server`(stdio),
工具在宿主里表现为 `mcp__arb__<tool>`。
**注意:** `tasks.py` 的 `check` 用 `&&` 连接(PowerShell 不支持 `&&`,但 tasks.py 内部是单行字符串,没问题)。
## 代码质量工具链(执行顺序)
1. **Ruff lint** — `uv run ruff check .` (select: E, F, W, I, N, UP, B; ignore: E501)
2. **Ruff format** — `uv run ruff format --check .` (line-length: 120)
3. **MyPy strict** — `uv run python -m mypy src`(整个 src 都在 strict 下,不只是某个入口文件)
4. **deptry** — `uv run deptry .`(检测未声明、未使用、过时依赖)
`uv run mypy` 在本机偶尔报 "uv trampoline failed to canonicalize script path",用 `uv run python -m mypy` 代替。
### 提交纪律(血的教训)
* **门禁退出码要单独确认再提交**,别把"跑检查"和"提交"写在同一条命令链里——检查失败也能走完流程。
* 本仓库 `pre-commit` 只挂 ruff,但它会 **stash 未暂存改动**再跑,跑完才还原。
若这个过程被中断(超时/杀进程),未暂存的文件会**被回滚成 HEAD 版本**且 `git fsck` 捞不回来。
本项目改造过程中真发生过一次,四个文件的改动靠锚点重放才恢复。
* 因此:提交前先把工作区改动 `git add` 干净,或用 `--no-verify` 提交并手动跑完等价检查(在提交说明里写明)。
* 改过 MCP 工具或技能,`make check` 之外还差两道专项门禁:`make docs-check`(工具文档同源)与
`make skill-check`(技能安装副本一致)——它们都靠退出码报漂移,别只看有没有打印错误
## 项目架构(MCP 服务器模式)
**唯一业务入口**:`python -m src.mcp.server`(stdio)。编排(读图判断、缺料追问、是否提交)由宿主 Agent 完成,
本项目**不调用任何大模型**,只提供确定性工具与内核。
- `src/mcp/` — 工具与协议层:`server.py` 注册与启动,`tools/` 按 materials / extraction / analysis / documents / browser 分模块,
`schemas.py` 抽取字段定义(单一真源),`envelope.py` 统一 `{ok, ...}` / `{ok:false, reason, next_action}` 信封
- `src/cases.py` — 会话目录 `cases/<case_id>/` 的唯一实现:材料、收件箱、产物与**阶段推导**(阶段由产物推导,不存在状态机文件)
- `src/core/` — 确定性内核:`extraction/`(缓存读写、PDF 渲染与文本层、自动转正)、`matching/`(金额匹配 + `runner.py` 从缓存匹配)、`validation/`(声明式规则校验)
- `src/infra/` — 基础设施:`browser/`(Playwright 填报 + `actor.py` 单线程持有者与磁盘锁)、`documents/`(CSV、申购单 Excel COM)、`com_thread.py`(COM 单线程串行 actor)
- `src/upload/` — 上传收件器(**独立进程** `python -m src.upload.server`):手机扫码/PC 上传落 `cases/<id>/inbox/`,不含业务编排,绝不由 MCP 子进程拉起
已删除:`src/agent/`(手写状态机与 SSE)、`src/web/`(Flask 界面)、`src/main.py`+`pipeline*.py`(CLI/双管道)、
`core/extraction/{extractor,llm_extractor}.py`(内置 LLM 与事件日志)、`infra/llm/`(提示词迁到 `.agents/skills/reimbursement/references/`)、
`infra/documents/consumable.py`(Word 出库单下线)、依赖 `llama-index*` 与 `python-dotenv`。
### 宿主侧两条硬约定
* 调 MCP 工具**路径一律正斜杠**(`D:/Code/...`),反斜杠会被 DSH 的参数绑定层拒收。
* `required: true` 的字段(含值为空串的备注类)**必须存在于** info JSON 中——这是刻意约束,别改规则文件;
用 `mcp__arb__info_skeleton` 取骨架再填值,别凭记忆造结构。
### 数据流关键产物
| 文件 | 生成者 | 作用 |
|------|--------|------|
| `pages/<stem>/p01.png` + `text.txt` + `meta.json` | `case_render_pages` | 给宿主读的页面与 PDF 文本层(含转正角度) |
| `.invoice_cache/<stem><ext>.json` | 宿主抽取 → `case_save_extraction` 规范化落盘 | 单张票据结构化数据(小改用 `case_patch_extraction`) |
| `match_result.json` / `invoice_groups.json` | `match_payments` | 支付与发票的关联、差旅/普通分组 |
| `invoice_summary.csv` / `payment_records.csv` | `match_payments` | 发票级与支付级汇总(申购单取数) |
| `travel_info.json` / `normal_info.json` | 宿主综合 → `save_info` | 差旅/普通报销的全部结构化数据 |
| `artifacts/*.xls` | `doc_requisition` | 低值易耗品申购单 |
| `screenshots/NNN-<步骤>.png` | `browser_*` 分步填报 | 排查证据(Agent 自己回看用,不必逐张给用户)|
| `.browser.lock` | `browser_open` | 谁拥有该会话的浏览器(PID + 心跳,180 秒可接管) |
### 缓存机制
会话缓存在 `cases/<case_id>/.invoice_cache/`,文件名与源文件同名(`发票1.pdf` → `.invoice_cache/发票1.pdf.json`)。
缓存 JSON 的 `_source_file` + 内容指纹决定"是否陈旧",`case_status` 会报 stale 项;删缓存即重抽。
## 重要约束
* **Windows-only**:申购单依赖 Microsoft Excel + COM(`pywin32`)。所有 Office COM 调用必须走
`src/infra/com_thread.py` 的单线程 actor——并发驱动 Word/Excel 会互抢 ActiveDocument 与模态框;
申购单空白模板在 `templates/`,可用环境变量 `REQUISITION_TEMPLATE` 指向别处,产物只写会话目录 `artifacts/`
* **浏览器自动化**:Playwright,会打开 Chromium(默认有头),期间不要手动干扰。Playwright sync API 有 greenlet
线程亲和,所有调用必须走 `src/infra/browser/actor.py` 的单线程 actor(与 COM actor 分开,套间要求不同);
浏览器归属用 `cases/<id>/.browser.lock`(PID+心跳)声明。返回 `owned_by_other_process` 时**不要**另开窗口,
那会让 Agent 看到的截图和实际操作的页面不是同一个
* **财务系统列表页结构(联机实测,别再猜)**:行选择是 `input[name=btSelectItem]` **单选框**,工具栏
`#insert` 新增 / `#update` 修改 / `#delete` 删除都作用于选中行;行内那个「查看」按钮是 `gzlc()`
**工作流程图,不是打开单据**。关键字搜索用右上角可见的 `#seacher`;`#expenexplain` 那套高级查询
在收起的面板里(元素存在但不可见,`fill` 会卡 30 秒)。表格是 AJAX 后加载的,用 `wait_rows_loaded()`
等,别拍脑袋 `wait_for_timeout`;空状态行(没有找到匹配的记录)不算命中,也别当成还在加载
直达报销 URL 不建立会话,会被踢回「长时间未操作,请重新登录!」
* **单据页是多标签共用一页**:基本信息 / 报销明细 / 支付方式 /(补助清单)/ 附件清单。不在当前标签上的
控件 `query_selector` 拿得到但**不可见**,点它必超时——所以每一步都显式 `open_tab(...)`,不要依赖
「上一步把页面留在了哪儿」(旧代码靠 `#next3`/`#next4` 串下来,续填时一律失灵)。basic 的候选标记
要给两个:日常单是 `#EXPENEXPLAIN`,差旅单是 `#CAUSE`
* **日常报销与差旅是两个独立列表页**(`reimburse_page` / `travel_page`),`navigate_to_reimburse(page_key)`
的快速通道必须比对目标路径,只判断「在不在报销系统」会让人停错页还以为进去了。判断在不在报销系统
用 `urlsplit().netloc` 精确比主机,别用子串(子串会把 http://elsewhere 判成主机 x)
* **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、
弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`,
按表头里的「附件文件名」找
* **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。
上传收件器同样会多代并存(`uv run` 的外壳/跳板/真解释器三层,杀外层会留下持有 socket 的孙进程,
而 Windows 允许重复绑同一端口):收件器启动前用 `port_busy()` 拒绝二次启动,并用 `GET /api/ping`
回自己的 pid 与启动时间,浏览器里一开就知道命中的是谁。
因此任何"独占资源"(浏览器、Office)都要有磁盘锁,别假设只有一个服务器实例
* **配置优先级**:环境变量 > `scripts/data/config.json` > `src/config.py` 内置默认;含 `your-` 的占位值
按未配置处理并告警。改完配置要重连常驻 MCP 子进程才生效(进程内有缓存)。`safe_config()` 才是
可以进日志/回显的那份,`load_config()` 里有明文密码
* **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`,
绝不要提交,也不要回显密码
* **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;普通发票生成低值易耗品申购单
## 技能(skill)分发
真源是 `.agents/skills/reimbursement/`(随仓库版本管理);`scripts/install_skill.py` 单向同步到
用户级目录,让技能在**任意项目**里可用(技能内容不引用仓库文件:字段权威来自 `extraction_spec` /
`info_skeleton` / `rules_get` 三个工具)。改完 SKILL 记得 `make skill-install`,别反向编辑安装副本。
* **别把某个目录当成「启用的那个」写死**:来源开关由 skills-manager 管,人会随手改——本机 2026-10-21
一早就从 `~/.agents/skills` 翻回了 `~/.dsh/skills`。装在已停用来源里的技能文件明明存在、
宿主却永远扫不到,比没装更难发现。所以 `install_skill.py` 读日志里每个候选目录最后一条
`source-enable/-disable`,**装进最近一次启用的那个**;一个都没启用就退出码 5 拒绝安装。
`--list-sources` 看当前状态,`--dest` 手工指定,`--force` 强装
* 同名技能项目级优先:`skill` 工具返回的 `resourceBase.path` 能看出实际加载的是哪一份
## 上传收件器
```bash
uv run python -m src.upload.server # 0.0.0.0:5055,手机扫码可用
uv run python -m src.upload.server --local # 只监听 127.0.0.1
uv run python -m src.upload.server --pin 1234 # 额外访问码
```
`case_create` 返回的 `upload_url` 是**这一单的手机直链**(含令牌),点开即上传,用户不用在一堆二维码里挑;
`case_upload_link(case_id)` 可重取。安全边界:令牌强制(无令牌 403)、只写不读(无列表无下载)、
扩展名白名单、25MB/文件、200 文件/会话、PIN 可选。二维码由服务端用 segno 生成,零外部 CDN。
* **`upload_base_url` 必须带端口**:收件器听 `--port`(默认 5055)。写成 `http://192.168.31.86/` 会让链接
打到 80,手机照样打不开。不设就退回探测默认路由网卡,而多网卡机器(VPN/Tailscale/虚拟交换机)
经常探到手机不可达的地址——这就是必须显式配置的理由
* **令牌只允许出现在两个地方**:`case_create` 与 `case_upload_link` 返回的那条链接里。别在其它工具输出、
日志、回复正文里重复;`case_status`/`case_list` 刻意不带链接,就是为了别每次轮询都复制一遍令牌
* 手机要能访问 PC:同 Wi-Fi,且 Windows 防火墙放行 5055 入站(首次监听会弹授权框)
## 测试
```bash
make test # pytest + coverage report (term-missing)
```
测试目录 `tests/`,配置在 `pyproject.toml`(`testpaths`、`pythonpath=["."]`)。
COM 相关路径(申购单)不进单测——需要本机装有 Excel,改动后用真实生成验证(约 2-5 秒)。
改完 MCP 工具的顺序:`make mcp-smoke`(验 wire 上的工具清单,进程内 import 成功不代表宿主能看到)→
`make mcp-docs`(由线上 schema 重出 `docs/MCP.md` 的工具参考)→ 重连宿主(杀掉 stdio 子进程会自动重启并重同步工具)。
`make docs-check` 能在 CI/提交前发现文档与代码漂移(退出码 1)。