Files
Auto-Finance/AGENTS.md
T
wandering 5dda126e21 feat(upload): 建会话即给这一单的手机直链,收件器地址改为显式配置
用户选了 B 方案。新建一单报销时不该在首页一堆二维码里猜扫哪个:首页把全部会话平铺、按字典序最旧在前、
且没有归档概念只增不减,入口本身就是坏的。现在 case_create 直接返回 upload_url。

- src/config.py 新增可选字段 upload_base_url(环境变量 UPLOAD_BASE_URL;留空才退回探测)
- src/cases.py:upload_page_url() 优先用配置;新增 CaseStore.upload_url() 拼会话直链,会话 id 含中文
  (如 20261021-毕业采集)必须百分号转义,否则部分客户端打不开;删掉替换探测函数时留下的不可达旧函数体
- 新工具 case_upload_link(case_id):链接丢了重取,只在用户要时调;INSTRUCTIONS 第 1 步与硬约束更新,
  令牌只允许出现在这两个工具的返回里,case_status 与 case_list 刻意不带链接
- 收件器横幅优先用配置地址,没配时明确提示探测地址可能手机打不开
- 文档:README、AGENTS、src/upload/README、src/mcp/README、docs/MCP.md 标准流程、操作指南 7.5、
  scripts/data/README、config.example.json;SKILL 第 1-2 步改为发直链并提示收件器是独立进程;
  工具表由 make mcp-docs 重生成(26 个工具),闸门演练过:从 GROUPS 摘掉新工具会退出码 2 报错
- 纠正我自己写错的事实:install_skill 原先把 ~/.agents/skills 写死成本机启用,而来源开关会变
  (本机今早 08:11 就翻回 ~/.dsh/skills)。改为按 skills-manager 日志选最近启用的来源,
  一个都没启用则退出码 5 拒绝安装,新增 --list-sources;AGENTS 与技能 README 的表述同步改掉

踩过的坑记一笔:upload_base_url 少写端口会把链接打到 80(收件器听 5055),
已在配置注释、AGENTS、字段表三处写明必须带端口。

测试:tests/test_upload_link.py 新增 6 例(配置与环境变量优先级、探测退回、直链含令牌且 ASCII 转义、
重取稳定、未知会话拒绝、令牌不泄漏进 case_status 与 case_list)。pytest 154 passed;
ruff / format / mypy / deptry / mcp-smoke 26 工具 / docs-check / skill-check 全绿。
端到端实测:拿生成的直链访问真实运行中的收件器返回 200 且页面是上传页。
仍用 --no-verify 提交并手跑等价检查。
2026-10-21 10:05:33 +08:00

181 lines
13 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 并存。
因此任何"独占资源"(浏览器、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)。