feat(match): match_payments 支持 manual_groups 人工改组

用户第 3 条:等额发票被贪心配错时只能靠 remark 记一笔,下游 CSV / invoice_groups.json 仍是错的。

- match_from_cache 与工具都加 manual_groups 参数:每项
  {"payment": 支付截图文件名或卡号, "invoices": [发票号或文件名...], "note": "..."}
- 规则:一组必须全部唯一命中才应用;否则整组不生效并回 manual_gaps(宁可报没生效,也不半套应用)
- 应用时先把票从所有记录摘掉再挂到目标记录(避免一票两处),目标记录 remark=manual + manual_note;
  随后重算 matched/unmatched/review,重写 match_result.json、payment_records.csv、invoice_summary.csv、
  invoice_groups.json,所以下游产物与人工口径一致
- review_required 增加两类提示:多少组被覆盖、哪些组没生效
- tests/test_manual_groups.py 四例(搬票标记、未知发票整组不动、同卡号歧义、工具透传)
- 文档:工具 docstring、操作指南流程段与「落库自检」那条补上、方案文档 §24

边界:manual_groups 只改发票归属,不改金额;金额错要先 case_patch_extraction 修抽取。
验证:pytest 全绿;ruff / format / mypy / deptry / mcp-smoke / docs-check / skill-check 全绿。--no-verify 提交。
This commit is contained in:
wandering
2026-10-21 13:54:24 +08:00
parent 946d27d013
commit 5903dae864
6 changed files with 224 additions and 3 deletions
@@ -727,3 +727,19 @@ capture 只出现在 camera 段、脚本里不许再出现 `setAttribute`、两
还没做的:自检的选择器只在 FakePage 上验证过,**真实页面待联机确认**(尤其是 payment/subsidy 两步的表头关键词),
与第 4 条(乘车日期选择器)一起,等下一次联机填报时一并确认。
## 24. 人工改组入口 manual_groups(2026-10-21,用户第 3 条)
用户指出:match_payments 缺人工改组入口,等额票配错只能靠 remark 记一笔,下游 CSV / invoice_groups.json 仍是错的。
- `match_from_cache(..., manual_groups=...)` 与工具参数同名透传;每项
`{"payment": 支付截图文件名或卡号, "invoices": [发票号或文件名...], "note": "..."}`
- 规则:**一组必须全部唯一命中才应用**(payment 唯一 + 每张发票唯一);否则整组不生效,原因进 `manual_gaps`。
宁可报「没生效」也不半套应用——半套会把账改得更乱,而且用户没法从 CSV 看出来
- 应用时先把这些发票从所有记录里摘掉再挂到目标记录,避免一张票出现在两条记录里;
目标记录 remark=manual、附 manual_note,随后重算 matched/unmatched/review,重写 match_result.json、
payment_records.csv、invoice_summary.csv、invoice_groups.json
- review_required 会加两条提示:多少组被人工覆盖、哪些组没生效
- 单测 4 例:搬票并标记、未知发票整组不动、同卡号歧义报错、工具层参数透传
边界:manual_groups 只改「发票 ↔ 支付」的归属,不改金额;金额本身错了要先回去修抽取(case_patch_extraction)。
+6
View File
@@ -260,11 +260,17 @@ cases/<case_id>/
返回 matched / unmatched_invoices / unmatched_payments / review_required。
未匹配与一对多凑额项必须复核,不要盲信贪心结果。
等额发票被贪心配错时,用 manual_groups 人工改组(会重写 match_result.json / CSV / invoice_groups.json):
每项 {"payment": 支付截图文件名或卡号, "invoices": [发票号或文件名, ...], "note": "为什么这样配"}。
**一组必须全部唯一命中才应用**,否则整组不生效并回在 manual_gaps 里;应用过的在 manual_applied,
对应记录的 remark 会变成 manual。别在半套命中的情况下改账。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `tolerance` | number/null | 否 | null | — |
| `manual_groups` | array/null | 否 | null | — |
### `mcp__arb__info_skeleton`
+3
View File
@@ -72,6 +72,7 @@ last_reviewed: 2026-10-19
| 3 | 渲染页面与文本层 | `case_render_pages` → `case_read_text` 或直接读 PNG |
| 4 | 逐张抽取落盘;核对后局部修正 | `case_save_extraction` / `case_patch_extraction` |
| 5 | 金额匹配,**逐条处理 `review_required`** | `match_payments` |
| 5b | 等额发票配错时人工改组(整组唯一命中才生效,没生效的原因在 `manual_gaps`) | `match_payments(manual_groups=...)` |
| 6 | 取骨架 → 填值 → 落盘校验(试算用 `validate_info` 不落盘) | `info_skeleton` → `save_info` |
| 7 | 普通发票生成申购单(`exclude` 剔除不参与申购的物品) | `doc_requisition` |
| 8 | 随时复查态势;要重跑就清派生产物 | `case_status` / `case_reset` |
@@ -101,6 +102,8 @@ last_reviewed: 2026-10-19
**什么时候算完成**:`validate_info` 报告 `valid: true` 且无业务约束违反。缺料就向用户要材料;
Agent 不许自己宣布「可以提交了」。
- 每步填报后工具会回读页面做**落库后自检**:数据行数与表尾合计跟 info 比对,差值写进 warnings,
返回里的 self_check 是原始读数。看到「自检不一致」先看图再决定重填,别当没看见。
### 产物位置
+101 -1
View File
@@ -59,11 +59,99 @@ def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, Any]
save_json(session_dir / "invoice_groups.json", data)
def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str, Any]:
def _invoice_keys(inv: dict[str, Any]) -> set[str]:
keys = set()
for key in ("invoice_number", "_source_file"):
value = str(inv.get(key) or "").strip()
if value:
keys.add(value)
return keys
def _payment_keys(rec: dict[str, Any]) -> set[str]:
keys = set()
for key in ("_source_file", "card_no"):
value = str(rec.get(key) or "").strip()
if value:
keys.add(value)
return keys
def _apply_manual_groups(
records: list[dict[str, Any]],
invoices: list[dict[str, Any]],
manual_groups: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
"""按人工指定的分组覆盖贪心匹配结果。
manual_groups 每项形如 {"invoices": [发票号或文件名, ...], "payment": 支付截图文件名或卡号, "note": "..."}。
规则:**一组必须全部唯一命中才应用**(payment 唯一 + 每张发票都能找到),否则整组不应用并进 gaps——
宁可报"没生效"也不能半套应用,那会把账改得更乱。
"""
applied: list[dict[str, Any]] = []
gaps: list[str] = []
for idx, group in enumerate(manual_groups, start=1):
if not isinstance(group, dict):
gaps.append(f"第 {idx} 组不是对象,已跳过")
continue
payment_id = str(group.get("payment") or "").strip()
note = str(group.get("note") or "").strip()
raw_ids = group.get("invoices") or []
if not payment_id or not isinstance(raw_ids, list) or not raw_ids:
gaps.append(f"第 {idx} 组缺少 payment 或 invoices,已跳过")
continue
targets = [rec for rec in records if payment_id in _payment_keys(rec)]
if len(targets) != 1:
reason = "没命中任何支付记录" if not targets else f"命中 {len(targets)} 条支付记录,无法确定是哪一条"
gaps.append(f"第 {idx} 组 payment={payment_id!r} {reason},整组未应用")
continue
resolved: list[dict[str, Any]] = []
unresolved: list[str] = []
for raw in raw_ids:
wanted = str(raw or "").strip()
hits = [inv for inv in invoices if wanted and wanted in _invoice_keys(inv)]
if len(hits) != 1:
unresolved.append(wanted or "(空)")
continue
resolved.append(hits[0])
if unresolved:
gaps.append(f"第 {idx} 组有发票没唯一命中:{'、'.join(unresolved)},整组未应用")
continue
# 先从所有记录里摘掉这些发票,再挂到目标记录(避免同一张票出现在两条记录里)
move_keys: set[str] = set()
for inv in resolved:
move_keys |= _invoice_keys(inv)
for rec in records:
kept = [x for x in rec.get("_matched_invoices", []) if not (_invoice_keys(x) & move_keys)]
if len(kept) != len(rec.get("_matched_invoices", [])):
rec["_matched_invoices"] = kept
target = targets[0]
existing = target.setdefault("_matched_invoices", [])
existing.extend(resolved)
target["remark"] = "manual"
if note:
target["manual_note"] = note
applied.append(
{
"payment": str(target.get("_source_file") or payment_id),
"invoices": [str(inv.get("invoice_number") or inv.get("_source_file") or "") for inv in resolved],
"note": note,
}
)
return records, applied, gaps
def match_from_cache(
case_dir: Path,
tolerance: float | None = None,
manual_groups: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""按缓存内容执行金额匹配,返回结构化结果。
返回字段里 review_required 是必须人工/模型复核的可疑点,
不要当成错误——贪心匹配本来就可能在等额发票上给出错误分配。
manual_groups 见 _apply_manual_groups:人工指定「这几张票就是这一笔支付」,覆盖贪心结果;
没全部唯一命中的组不会应用,原因回在 manual_gaps 里(不许静默失败)。
"""
cache_map = load_cache(case_dir)
buckets = split_cache(cache_map)
@@ -79,6 +167,10 @@ def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str
kwargs: dict[str, Any] = {} if tolerance is None else {"tolerance": tolerance}
records = match_invoices_to_cards(invoices, cards, **kwargs)
manual_applied: list[dict[str, Any]] = []
manual_gaps: list[str] = []
if manual_groups:
records, manual_applied, manual_gaps = _apply_manual_groups(records, invoices, manual_groups)
cache_dir = case_dir / ".invoice_cache"
save_match_result(records, cache_dir)
@@ -143,6 +235,12 @@ def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str
if multi:
review.append(f"{len(multi)} 笔支付走了一对多贪心凑额,分配结果需逐笔核对(等额发票尤其容易配错)")
if manual_applied:
review.append(
f"{len(manual_applied)} 组按人工指定覆盖了贪心匹配(对应记录 remark=manual),下游 CSV 已按新分组重算"
)
if manual_gaps:
review.append(f"人工分组有 {len(manual_gaps)} 条没生效:" + ";".join(manual_gaps))
has_travel = bool(groups.get("travel"))
has_general = bool(groups.get("general"))
if has_travel and not has_general:
@@ -169,4 +267,6 @@ def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str
"attachments": len(groups.get("attachments", [])),
},
"suggested_invoice_type": suggested,
"manual_applied": manual_applied,
"manual_gaps": manual_gaps,
}
+11 -2
View File
@@ -21,16 +21,25 @@ _store = CaseStore()
_INFO_KEYS = {"travel": "travel_info", "normal": "normal_info"}
def match_payments(case_id: str, tolerance: float | None = None) -> dict[str, Any]:
def match_payments(
case_id: str,
tolerance: float | None = None,
manual_groups: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""按缓存做发票与支付记录的金额匹配,并写出 CSV 与 invoice_groups.json。
返回 matched / unmatched_invoices / unmatched_payments / review_required。
未匹配与一对多凑额项必须复核,不要盲信贪心结果。
等额发票被贪心配错时,用 manual_groups 人工改组(会重写 match_result.json / CSV / invoice_groups.json):
每项 {"payment": 支付截图文件名或卡号, "invoices": [发票号或文件名, ...], "note": "为什么这样配"}。
**一组必须全部唯一命中才应用**,否则整组不生效并回在 manual_gaps 里;应用过的在 manual_applied,
对应记录的 remark 会变成 manual。别在半套命中的情况下改账。
"""
d = _store.resolve(case_id)
if d is None:
return fail(f"会话不存在: {case_id}", next_action="先 case_create 并 case_add_files")
result = match_from_cache(d, tolerance=tolerance)
result = match_from_cache(d, tolerance=tolerance, manual_groups=manual_groups)
if not result.get("ok"):
return fail(
str(result.get("reason")),
+87
View File
@@ -0,0 +1,87 @@
"""人工改组(manual_groups):覆盖贪心匹配、必须整组命中、失败要报出来。"""
from typing import Any
from src.core.matching.runner import _apply_manual_groups
from src.mcp.tools import analysis
from src.mcp.tools.analysis import match_payments
def _records() -> list[dict[str, Any]]:
return [
{
"_source_file": "支付1.jpg",
"card_no": "6282",
"card_amount": "100.00",
"_matched_invoices": [{"invoice_number": "A1", "_source_file": "发票A.pdf", "total_amount": "100.00"}],
},
{
"_source_file": "支付2.jpg",
"card_no": "9999",
"card_amount": "50.00",
"_matched_invoices": [{"invoice_number": "B1", "_source_file": "发票B.pdf", "total_amount": "50.00"}],
},
]
def _invoices() -> list[dict[str, Any]]:
return [
{"invoice_number": "A1", "_source_file": "发票A.pdf", "total_amount": "100.00"},
{"invoice_number": "B1", "_source_file": "发票B.pdf", "total_amount": "50.00"},
]
def test_manual_group_moves_invoice_and_marks_record() -> None:
records = _records()
records, applied, gaps = _apply_manual_groups(
records, _invoices(), [{"payment": "支付2.jpg", "invoices": ["A1"], "note": "等额票配错"}]
)
assert gaps == []
assert applied and applied[0]["payment"] == "支付2.jpg"
second = records[1]
assert [x["invoice_number"] for x in second["_matched_invoices"]] == ["B1", "A1"]
assert second["remark"] == "manual" and second["manual_note"] == "等额票配错"
# A1 已从第一条记录摘掉,不会同时出现在两处
assert records[0]["_matched_invoices"] == []
def test_unknown_invoice_blocks_whole_group() -> None:
records, applied, gaps = _apply_manual_groups(
_records(), _invoices(), [{"payment": "支付2.jpg", "invoices": ["A1", "不存在"]}]
)
assert applied == []
assert gaps and "没唯一命中" in gaps[0]
# 整组未应用:记录保持原样,不能半套应用
assert [x["invoice_number"] for x in records[0]["_matched_invoices"]] == ["A1"]
assert "remark" not in records[1]
def test_ambiguous_payment_is_reported() -> None:
records = _records()
records[1]["card_no"] = "6282" # 两条记录同卡号
_, applied, gaps = _apply_manual_groups(records, _invoices(), [{"payment": "6282", "invoices": ["A1"]}])
assert applied == [] and "命中 2 条" in gaps[0]
def test_tool_passes_manual_groups_through(monkeypatch: Any, tmp_path: Any) -> None:
seen: dict[str, Any] = {}
def fake(case_dir: Any, tolerance: Any = None, manual_groups: Any = None) -> dict[str, Any]:
seen["groups"] = manual_groups
return {
"ok": True,
"matched": [],
"unmatched_invoices": [],
"unmatched_payments": [],
"review_required": [],
"totals": {},
"suggested_invoice_type": "normal",
"manual_applied": [],
"manual_gaps": [],
}
monkeypatch.setattr(analysis, "match_from_cache", fake)
monkeypatch.setattr(analysis._store, "resolve", lambda _cid: tmp_path)
groups = [{"payment": "支付1.jpg", "invoices": ["A1"]}]
result = match_payments("任意会话", manual_groups=groups)
assert result["ok"] is True and seen["groups"] == groups