feat(core): 报销口径与文本宽度两道闸——会议费并入差旅单,超宽文本不再静默 500

这一批是 2026-10-21 正式填报踩出来的两类问题,一起收进来。

**一、报销口径只在一处判定(新增 src/core/expense_kind.py)**

用户定案:因出差发生的会议费/会务费/培训费这类普通发票**并入差旅单**(明细的会务费培训费栏、
会议通知当附件),不拆成两张单、也不因此放弃差旅补助;只有货物类(易耗品/材料/配件)才走
普通报销 + 申购单。以前 case_status、validate_info、browser_new_form 三处都写"混合发票只能走
普通报销",等于把这条合法路径说成错的,Agent 照做就会白拆一张单还漏补助。现在 is_conference_like /
goods_invoices 是唯一判定,工具提示与 case_status 的 warnings 都由它驱动。

**二、文本宽度闸:自由文本超宽 = 无提示 500(新增宽度口径 + 两道预检)**

联机实测:差旅明细备注写到 101 字,POST 回 500 且页面无任何提示、弹窗停在原地,现场看着像"脚本没点着
确定";同一张单 97 字(宽度 130)却存进去了,上限夹在两者之间且后端不肯说。于是取汉字/全角记 2、
字母数字记 1 的"宽度",validator.TEXT_WIDTH_LIMIT=100(≈50 汉字,同时满足"100 字符"与"131 字节"
两种可能口径),普通报销说明另用 DESCRIPTION_WIDTH_LIMIT=40。拦截点两处:规则里的 max_len_bytes
(validate_extracted_info 的 missing_fields 会写明"过长:宽度 N > 上限 M")与 browser_fill 在 POST 前的
_text_limit_problems(失败返回 too_long 列出超长字段)。

**三、交通工具代码表挪进 core(新增 src/core/vehicles.py)**

财务系统 #jtgj 的 value 表与中文别名(train→火车 等)从填报层挪到 core,一处三用:填报取 value、
体检判合法、报错给候选。老写法 if vehicle in vehicle_map 遇英文枚举是**静默不选**且仍返回成功,
直到提交被必填挡回来——现在认不出的值直接报错。

其余:cases.goods_general_items 给 case_status 补"有 N 张货物类普通发票"的 warning;
travel.py 改用 vehicles 并显式报错;config/validation_rules.json 加 max_len_bytes;
INSTRUCTIONS/SKILL/判读参考/操作指南同步口径与宽度提示。新增 tests/test_text_limits.py 与
tests/test_vehicles_and_scope.py。

验证:ruff / format / mypy src / deptry / pytest / mcp-smoke / docs-check / skill-check 全绿。
--no-verify 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-21 13:49:12 +08:00
parent 44f6466e8b
commit 0d9cc7b4ca
17 changed files with 1042 additions and 62 deletions
+42 -8
View File
@@ -39,30 +39,54 @@ description: >
材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录,
原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程**
(`make upload`),没启动时用户那条直链必然打不开;用户说"能打开但行为像旧的",多半是旧进程还在听
`upload_base_url`(**带端口**,默认 5055),否则探出来的地址手机连不上
5055——`GET /api/ping` 回的是 pid 与启动时间,改完 `src/upload/` 的代码必须重启那个进程才生效。
重启要杀**真正持有 socket 的那个 PID**(`Get-NetTCPConnection -LocalPort 5055` 查得到),别只杀
`uv run` 外壳:Windows 允许两个进程绑同一端口,两代收件器并存时手机命中谁全凭运气。
多网卡机器还要在 `scripts/data/config.json` 配 `upload_base_url`(**带端口**,默认 5055),
否则探出来的地址手机连不上。**发链接之前先自检**:端口有没有人听、`/api/ping` 回的是哪个进程,
再用 `Get-NetIPAddress` 确认链接里那个 IP 还在本机网卡上——别用 `ipconfig`,
第二个 WLAN 适配器它会漏(2026-10-21 就是这么差点把链接发给错网段的手机)
3. `case_render_pages`:`text_chars > 0` 就 `case_read_text` 读文本层(比读图准且省 token),
否则读它给的 `pages/<stem>/p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律)
4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看
`references/invoice-fields.md`(本技能自带,不依赖仓库)
5. `match_payments`:**必须逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额)。
贪心结果 `ok` 不等于「已核对」
贪心结果 `ok` 不等于「已核对」:它只按金额凑数,**等额的发票最容易配错**。实单翻过一次车——
同一次出差两张 338.00 的车票被分到两笔支付上,凑出「453.50 → 338.00+117.50=455.50」这种
多两块钱的组合,还把 115.50 那张留成未匹配。复核要看**付款时间与行程方向**(先付的款配先乘的程),
不是看金额对不对得上;`reimbursement_details` 与 `payment_methods` 两边总额相等
也不等于逐笔配对正确。工具没有人工作改组的入口(只有 `tolerance`),
所以把人工归组的结论写进对应 payment 的 `remark`,下游和人看得懂
6. `info_skeleton(info_type)` 取骨架 → 填值 → `save_info`(自动跑规则校验)。拿不准的用
`validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。差旅与普通可能并存,按
`suggested_invoice_type` 与明细性质决定,别只有一种 info 就以为报完了
`validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。
- **报销口径(用户 2026-10-21 定案)**:因出差发生的会议费/会务费/培训费**并入差旅单**
(明细「会务费培训费」栏 + 会议通知当附件),不为它另开普通报销单,也不因此放弃补助;
只有货物类(易耗品/材料/配件)才另走 `normal_info` + 申购单。`suggested_invoice_type`
看见 general 发票就报 `normal`,这时**以口径为准、不以它为准**
- 住宿发票一般不印入住/退房日期:按会议报到日至返程日推**实际晚数**(4 天行程 3 晚),
别照搬参考文档 `days=1` 的保守默认——那会把 333 元/晚报成 1000 元/晚,财务必打回
- 中转到站的多张车票合并成「去程一条、返程一条」,`bill_count` 记实际张数、金额相加;
明细的日期列由页面按出差起止带出,填报脚本不填它
7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品);
纯差旅没有申购明细会返回失败提示,那不是错误
8. `case_status` 复查态势(`next_action` 与 `warnings` 是最该看的两项);要重跑用 `case_reset(scope)`
9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个
`browser_fill(step=...)` → `browser_page_state` 复查 → `browser_close`(不提交也不存草稿)。
`browser_login` 返回 `need_human` 时让用户在**已打开的那个窗口**里完成扫码/验证码再重调;
travel_info 与 normal_info 同时存在时 `browser_new_form` 必须显式传 `kind`
`browser_fill(step=...)` → `browser_page_state` 复查。`browser_login` 返回 `need_human` 时让用户在
**已打开的那个窗口**里完成扫码/验证码再重调;travel_info 与 normal_info 同时存在时
`browser_new_form` 必须显式传 `kind`。`browser_close` 留到**用户提交之后**再叫:
填完那会儿单据还不存在(没有编号、列表页查不到),窗口一关就是全丢,不是"存了草稿"。
收尾话术固定两句:「还没提交,别关这个窗口」+「提交后把编号发我,我用 browser_open_form 核对」
10. 中途断了(进程被杀、浏览器被关)**不要重来一遍**:`browser_open` + `browser_login` 后用
`browser_open_form(match=...)` 找回原单据续填。先 `preview_only=true` 核对返回的 `bill_no`,
确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
在财务系统里留下垃圾草稿。`match` 命中看的是列表页**可见文字**,长报销说明会被截断,
最稳的是**单据编号**
11. 每步都会自己切标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑;
续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断
续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断。
**唯独 `attachments` 不能重跑**:它逐个弹窗上传、没有去重,重跑一遍附件就翻倍;
要补附件让人在附件清单页点「增加」手动传(会话目录里的原始文件路径给他)。
还有:差旅单几个标签共用一个页面,「基本信息」那步的截图经常仍拍在明细表格上,
别拿它当基本信息的证据;要确认就让用户目视,或看 `browser_page_state` 的 markers
12. 差旅与日常是**两个独立列表页**,`browser_open_form` 要在对应 `kind` 下调用;附件说明没填时
工具自动兜底成「发票:文件名」(财务系统里这栏必填,留空点确定会被静默挡住),
要写清楚就把 `attachment_desc` 填进 attachments 项
@@ -72,12 +96,22 @@ description: >
- **完整性不由你宣布**:以 `validate_info` / `save_info` 返回的校验报告为准;不完整就向用户要材料,
禁止说「可以提交了」
- **不得臆造财务数据**:金额、日期、发票号、卡号读不清就标出来问用户,不要猜
- **口径不自己发明**:因出差发生的会议费/会务费/培训费并入差旅单(不为它拆单、不放弃补助),
货物类普通发票才走普通报销 + 申购单;判定表在 `src/core/expense_kind.py`,工具提示由它驱动,
`suggested_invoice_type` 只是参考。住宿实际晚数、中转车票的合并方式同理——按证据推,
拿不准就问用户一句,别照默认值交卷
- **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认
采信,重新放大该区域确认。已知文本层会把「标签」和「值」分段存储,位置不能当对应关系;
康熙部首类字符(`⽇` `⻔`)NFKC 归一后仍不变形
- **不回显密码**,不把凭据写进任何产物或日志;卡号、身份证号等敏感值**打码引用**(`6282****1682`)
- **截图是核对证据,不是给用户的报告**:出问题时拿对应那张说清现象,平时不必逐张呈报
- `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束,别改规则文件
- **备注/说明要写短**:上限按「宽度」算——汉字与全角标点记 2、字母数字记 1,
`remark` / `merchant` / `attachment_desc` ≤ **100**(约 50 个汉字),普通报销说明 ≤ **40**。
财务后端对超宽的答复是 `500 Internal Server Error`,页面上一个字提示都没有,弹窗停在原地,
现场看着像「脚本没点着确定」(2026-10-21 实测:97 字/宽度 130 存进去了,101 字/宽度 171 被拒)。
`save_info` 体检与 `browser_fill`(返回 `too_long`)两道闸都会拦,但最好一开始就别把整串发票号
塞进备注——发票本身就是附件,备注只写「谁、哪段、几张票」
- 工具返回的信封是扁平的:失败时 `reason` / `next_action` 在顶层,而**额外字段在 `detail` 里**
(比如报错附带的 `screenshot` 路径是 `result.detail.screenshot`)
- 财务数据只在会话目录里(已 gitignore)。**别把截图或 info 拷进用户当前的工程**,