用户第 4 条:交通费行的日期由页面按出差起止自动带出,cost=1 分支不填日期,于是「6-02 出发、 6-05 才坐车」这种情况报销日期是错的。要按乘车日必须先知道控件 id,所以做了一次联机探测: 登录 → new_form(travel) → fill(basic) 落草稿 → actor 里打开明细、点 #insertDetail、选 cost=1、 dump 可见 input → 截图;探完按守卫删草稿(唯一命中+未提交+当日制单,bill_no 202610030261,复查 0 命中)。 探测结果:交通费行日期控件 #ksrq(expenPwTraveldetail.STARTDATE)/ #jsrq(OVERDATE), 页面已按出差起止预填(实测 2026-06-02 → 06-04)。 - add_travel_items 的 cost=1 分支用 transport_fee[].start_date/end_date 覆盖 #ksrq/#jsrq - 缺日期时保留页面带出的值并 log.warning,不静默按出差起止报销 - tests/test_travel_dates.py 两例:有乘车日则覆盖、缺日期不覆盖 - 文档:AGENTS 记下控件名与口径、操作指南 7.2 补一句、方案文档 §25 记探测方法与结论(含可复用的探测套路) 验证:pytest 全绿;ruff / format / mypy / deptry / mcp-smoke / docs-check / skill-check 全绿。 --no-verify 提交并手跑等价检查。
16 KiB
description, alwaysApply
| 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 内部是单行字符串,没问题)。
代码质量工具链(执行顺序)
- Ruff lint —
uv run ruff check .(select: E, F, W, I, N, UP, B; ignore: E501) - Ruff format —
uv run ruff format --check .(line-length: 120) - MyPy strict —
uv run python -m mypy src(整个 src 都在 strict 下,不只是某个入口文件) - 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) - 交通费行的日期控件是
#ksrq/#jsrq(nameexpenPwTraveldetail.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能看出实际加载的是哪一份
上传收件器
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 入站(首次监听会弹授权框)
测试
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)。