feat(status): 阶段认「已填报」,case_reset 补 screenshots 域

用户第 5 条:填完五步后 case_status 仍提示「可生成申购单,然后 browser_open」,
因为阶段推导到 docs_ready 就停了,根本没有「已填报」这一档;case_reset 也缺一个清截图的域。

- cases.summary 新增 filled 判据:screenshots/ 里存在 <NNN>-<step>_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 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-21 13:51:17 +08:00
parent d2fcedc6cd
commit 8802d24a2b
4 changed files with 85 additions and 6 deletions
+1 -1
View File
@@ -182,7 +182,7 @@ cases/<case_id>/
### `mcp__arb__case_reset`
清理派生产物以便重跑。scope=extractions|match|info|pages|artifacts|all。
清理派生产物以便重跑。scope=extractions|match|info|pages|artifacts|screenshots|all。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
+12 -3
View File
@@ -1,7 +1,10 @@
"""会话目录(cases/<case_id>):MCP 工具与上传收件器共用的唯一实现
阶段不存状态机,全部由目录里的产物推导:
empty -> extracted -> matched -> info_ready -> docs_ready
empty -> materials_ready/extracted -> matched -> info_ready -> docs_ready -> filled
filled 的判据是「步骤完成截图」(browser_* 每步落 <NNN>-<step>_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)
# 填报完成的证据:每步成功都会落 <NNN>-<step>_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,
+14 -2
View File
@@ -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
+58
View File
@@ -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:
"""只有失败截图不算填报完成——判据是 <NNN>-<step>_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"] == []