--- 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__`。 **注意:** `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//` 的唯一实现:材料、收件箱、产物与**阶段推导**(阶段由产物推导,不存在状态机文件) - `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//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//p01.png` + `text.txt` + `meta.json` | `case_render_pages` | 给宿主读的页面与 PDF 文本层(含转正角度) | | `.invoice_cache/.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//.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//.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) * **交通费行的日期控件是 `#ksrq`/`#jsrq`**(name `expenPwTraveldetail.STARTDATE`/`OVERDATE`,2026-10-21 联机实测): 页面默认按「基本信息」的出差起止带出,但报销要按**实际乘车日**,所以填报时用 info 的 `transport_fee[].start_date/end_date` 覆盖;缺日期时保留页面值并告警,不静默按出差起止报 * **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、 弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`, 按表头里的「附件文件名」找 * **自由文本超宽 = 静默 500**(2026-10-21 联机实测):差旅明细的备注写到 101 个字, `POST /expen/travel/detailadd` 回 500,页面上一个字的提示都没有,弹窗停在原地—— 现场看着像「脚本没点着确定」,白查一轮。同一张单里 97 字(宽度 130)的备注却存进去了, 说明上限夹在两者之间(要么按 100 字符,要么按 131~170 之间的字节数;后端不肯说,别拿真单据试)。 于是 `validator.TEXT_WIDTH_LIMIT = 100`(汉字算 2、字母数字算 1 ⇒ 约 50 个汉字:宽度 ≤100 同时满足"100 字符"与"131 字节"两种可能口径,是两边都安全的取法), 普通报销的说明另用更短的 `DESCRIPTION_WIDTH_LIMIT = 40`。两道闸: `validate_extracted_info`(规则里的 `max_len_bytes`,`missing_fields` 会带上 「过长:宽度 N > 上限 M」的说明)与 `browser_fill` 在 POST 之前的 `_text_limit_problems`(失败时返回 `too_long` 列出超长字段)。 备注不是记事本:一个发票号 20 位就吃掉 20 宽度,发票本身就是附件,备注只写"谁、哪段、几张票" * **多进程共存**:宿主会保留旧代 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`, 绝不要提交,也不要回显密码 * **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;货物类普通发票生成低值易耗品申购单。 **口径(用户 2026-10-21 定案)**:因出差发生的会议费/会务费/培训费这类普通发票**并入差旅单** (明细「会务费培训费」栏 + 会议通知当附件),不拆成两张单、也不因此放弃差旅补助;判定只在 `src/core/expense_kind.py` 一处(`is_conference_like`/`goods_invoices`), `case_status` 与 `validate_info` 的提示都由它驱动——以前三处一律写"混合发票只能走普通报销", 等于把这条合法路径说成错的,Agent 照做就会白拆一张单、还漏掉补助。 交通工具的代码表在 `src/core/vehicles.py`(中文选项 → `#jtgj` 的 value,含 train→火车 等别名), 体检与填报共用;认不出的值现在**直接报错**,不再静默不选(老写法 `if vehicle in vehicle_map` 遇上英文枚举就是不选、还返回成功) ## 技能(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)。