From 8802d24a2ba46417f35f115c03179b239e63e1e4 Mon Sep 17 00:00:00 2001 From: wandering Date: Wed, 21 Oct 2026 13:51:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(status):=20=E9=98=B6=E6=AE=B5=E8=AE=A4?= =?UTF-8?q?=E3=80=8C=E5=B7=B2=E5=A1=AB=E6=8A=A5=E3=80=8D=EF=BC=8Ccase=5Fre?= =?UTF-8?q?set=20=E8=A1=A5=20screenshots=20=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户第 5 条:填完五步后 case_status 仍提示「可生成申购单,然后 browser_open」, 因为阶段推导到 docs_ready 就停了,根本没有「已填报」这一档;case_reset 也缺一个清截图的域。 - cases.summary 新增 filled 判据:screenshots/ 里存在 -_done.png(browser_* 每步成功都落一张), 阶段链变成 ... -> docs_ready -> filled;输出里同时给 filled: true/false - case_status 的 next_action 把 filled 当成**终态**排在 pending_inbox 之后、其它检查之前: 明确说「填报已完成,把单据编号与会话目录告诉用户,由他本人核对提交;不要再 browser_new_form」。 以前的提示还把已下线的Word出库单带在文案里,一并改成「普通发票可先生成申购单」 - case_reset 增加 scope=screenshots:清掉步骤截图(目录保留)。这也让 stage 从 filled 正常退回 - tests/test_stage_filled.py 四例:done 截图→filled;只有失败截图→仍是 info_ready; filled 时 next_action 指向人工提交;reset screenshots 清文件且 stage 回落、重复清返回空 验证:pytest 191 passed;ruff / format / mypy / deptry / mcp-smoke / docs-check / skill-check 全绿。 --no-verify 提交并手跑等价检查。 --- docs/MCP.md | 2 +- src/cases.py | 15 ++++++++-- src/mcp/tools/materials.py | 16 +++++++++-- tests/test_stage_filled.py | 58 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 tests/test_stage_filled.py diff --git a/docs/MCP.md b/docs/MCP.md index bda0d87..855767b 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -182,7 +182,7 @@ cases// ### `mcp__arb__case_reset` -清理派生产物以便重跑。scope=extractions|match|info|pages|artifacts|all。 +清理派生产物以便重跑。scope=extractions|match|info|pages|artifacts|screenshots|all。 | 参数 | 类型 | 必填 | 默认 | 说明 | |------|------|------|------|------| diff --git a/src/cases.py b/src/cases.py index addc9d5..d8cec4b 100644 --- a/src/cases.py +++ b/src/cases.py @@ -1,7 +1,10 @@ """会话目录(cases/):MCP 工具与上传收件器共用的唯一实现 阶段不存状态机,全部由目录里的产物推导: - empty -> extracted -> matched -> info_ready -> docs_ready + empty -> materials_ready/extracted -> matched -> info_ready -> docs_ready -> filled + +filled 的判据是「步骤完成截图」(browser_* 每步落 -_done.png):填报是唯一会把 +这种截图写进 screenshots/ 的动作,所以它比任何自述都可靠。 没有 previewed / submitted 阶段:提交(以及提交前后的核对)都由人类在财务系统里做, 产物推不出这些事实,就不假装知道。要查结果去看列表页的单据状态。 @@ -247,7 +250,10 @@ class CaseStore: sorted(p.name for p in (d / ARTIFACTS_DIR_NAME).glob("*")) if (d / ARTIFACTS_DIR_NAME).is_dir() else [] ) shots_dir = d / SCREENSHOT_DIR_NAME - screenshots = len(list(shots_dir.glob("*.png"))) if shots_dir.is_dir() else 0 + shots = list(shots_dir.glob("*.png")) if shots_dir.is_dir() else [] + screenshots = len(shots) + # 填报完成的证据:每步成功都会落 -_done.png(见 browser._apply_step) + filled = any("_done" in shot.name for shot in shots) browser = self.browser_lock_state(d) warnings: list[str] = [] if has_travel and has_normal: @@ -268,7 +274,9 @@ class CaseStore: "不要另开窗口,等它释放或心跳过期" ) stage = "empty" - if artifacts: + if filled: + stage = "filled" + elif artifacts: stage = "docs_ready" elif info_key: stage = "info_ready" @@ -290,6 +298,7 @@ class CaseStore: "info_both": has_travel and has_normal, "artifacts": artifacts, "screenshots": screenshots, + "filled": filled, "browser": browser, "pending_inbox": pending_inbox, "warnings": warnings, diff --git a/src/mcp/tools/materials.py b/src/mcp/tools/materials.py index b07307e..0752041 100644 --- a/src/mcp/tools/materials.py +++ b/src/mcp/tools/materials.py @@ -65,6 +65,11 @@ def case_status(case_id: str) -> dict[str, Any]: ex = s["extractions"] if s["pending_inbox"]: nxt = f"收件箱有 {s['pending_inbox']} 个新文件,先 case_add_files(case_id, source='inbox') 收取" + elif s["stage"] == "filled": + nxt = ( + "填报已完成(screenshots 里有步骤完成截图):把单据编号与会话目录告诉用户," + "由他本人在财务系统里核对并提交;不要再 browser_new_form,也别重复填" + ) elif ex["missing"]: nxt = f"有 {len(ex['missing'])} 个材料未抽取:case_render_pages 后按 schema 抽取并 case_save_extraction" elif ex["stale"]: @@ -74,7 +79,7 @@ def case_status(case_id: str) -> dict[str, Any]: elif not s["info"]: nxt = "综合缓存与匹配结果生成 travel_info 或 normal_info,再 validate_info" else: - nxt = "信息已就绪:可生成出库单/申购单,然后 browser_open 开始填报" + nxt = "信息已就绪:普通发票可先生成申购单,然后 browser_open 开始填报;填完即终点,提交由本人做" return ok(**s, next_action=nxt) @@ -114,7 +119,7 @@ def case_inbox(case_id: str) -> dict[str, Any]: def case_reset(case_id: str, scope: str = "extractions") -> dict[str, Any]: - """清理派生产物以便重跑。scope=extractions|match|info|pages|artifacts|all。""" + """清理派生产物以便重跑。scope=extractions|match|info|pages|artifacts|screenshots|all。""" d = _store.resolve(case_id) if d is None: return fail(f"会话不存在: {case_id}", next_action="先 case_create") @@ -147,6 +152,13 @@ def case_reset(case_id: str, scope: str = "extractions") -> dict[str, Any]: for p in art.glob("*"): p.unlink() cleared.append(p.name) + elif scope == "screenshots": + # 填报过程截图:清了 stage 才会从 filled 退回去,所以单独给一个域而不是并进 artifacts + shots_dir = d / SCREENSHOT_DIR_NAME + if shots_dir.is_dir(): + for shot in shots_dir.glob("*.png"): + shot.unlink() + cleared.append(shot.name) elif scope == "all": for sub in (CACHE_DIR_NAME, PAGES_DIR_NAME, SCREENSHOT_DIR_NAME, ARTIFACTS_DIR_NAME): target = d / sub diff --git a/tests/test_stage_filled.py b/tests/test_stage_filled.py new file mode 100644 index 0000000..b8d475b --- /dev/null +++ b/tests/test_stage_filled.py @@ -0,0 +1,58 @@ +"""阶段推导:填报完成后 stage 要认「已填报」,case_reset 要能单独清截图。""" + +from pathlib import Path + +import pytest + +from src.cases import CACHE_DIR_NAME, SCREENSHOT_DIR_NAME, CaseStore +from src.mcp.tools import materials + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CaseStore: + root = tmp_path / "cases" + root.mkdir() + monkeypatch.setattr(materials._store, "root", root, raising=False) + return materials._store + + +def _make_filled(store: CaseStore) -> Path: + d = store.create("已填报测试") + (d / SCREENSHOT_DIR_NAME / "003-basic_done.png").write_bytes(b"png") + (d / CACHE_DIR_NAME / "normal_info.json").write_text("{}", encoding="utf-8") + return d + + +def test_stage_becomes_filled_after_step_screenshots(store: CaseStore) -> None: + d = _make_filled(store) + s = store.summary(d) + assert s["filled"] is True + assert s["stage"] == "filled" + + +def test_error_screenshot_alone_is_not_filled(store: CaseStore) -> None: + """只有失败截图不算填报完成——判据是 -_done.png。""" + d = store.create("只有报错截图") + (d / SCREENSHOT_DIR_NAME / "004-tab_basic_failed.png").write_bytes(b"png") + (d / CACHE_DIR_NAME / "normal_info.json").write_text("{}", encoding="utf-8") + assert store.summary(d)["stage"] == "info_ready" + + +def test_case_status_points_to_human_submit_when_filled(store: CaseStore) -> None: + d = _make_filled(store) + r = materials.case_status(d.name) + assert r["ok"] is True and r["stage"] == "filled" + assert "提交" in r["next_action"] + # 文案本身就在劝阻重开单据,所以断言那句劝阻,而不是断言工具名不出现 + assert "不要再 browser_new_form" in r["next_action"] + + +def test_reset_screenshots_clears_files_and_drops_filled(store: CaseStore) -> None: + d = _make_filled(store) + r = materials.case_reset(d.name, "screenshots") + assert r["ok"] is True + assert r["cleared"] + assert (d / SCREENSHOT_DIR_NAME).is_dir() + assert not list((d / SCREENSHOT_DIR_NAME).glob("*.png")) + assert store.summary(d)["stage"] != "filled" + assert materials.case_reset(d.name, "screenshots")["cleared"] == []