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:
@@ -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)。
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user