diff --git a/IMAGE_LEADING_ZERO_RECOVERY_TASK.md b/IMAGE_LEADING_ZERO_RECOVERY_TASK.md new file mode 100644 index 0000000..07e97e4 --- /dev/null +++ b/IMAGE_LEADING_ZERO_RECOVERY_TASK.md @@ -0,0 +1,71 @@ +# 图片日期数字误判永久修复与 2026-09-04 恢复任务 + +## 背景 + +2026-09-01 的前导零修复仍停留在未合并的 PR #15,当前 `master` 与 Alter 托管的 8766 生产进程仍运行旧逻辑。2026-09-04 群 23(茶馆V3.0(三周年纪念)🐮🐴)再次失败:Level 3 本地信息图页眉包含运行日期 `2026-09-04`,严格 OCR 事实校验报“无证据数字:04”。简化 Prompt 没有携带本地信息图直接使用的 `run.json` 元数据,因此仅去除前导零仍不能覆盖 11 日以后等日期。随后确认群 27(米游涩泛二次元同好摸鱼群3.2)也把 Level 3 排行数据卡误当成成功图片并进入待发送。用户明确指出此类数据卡不是群报,因此本次恢复必须同时满足事实校验和正常分镜群报版式,不能再用 Level 3 卡片冒充成功。 + +## 目标 + +- 将带前导零的整数 OCR 结果按其数值归一化,使 `05` 与 `5`、`09` 与 `9`、`01` 与 `1` 等价。 +- 将 Level 3 渲染实际使用的可信 `run.json` 群名、运行日期、统计区间和计数加入事实证据,准确允许本次运行日期,同时继续拒绝其他日期和无依据数字。 +- 图片恢复成功时清除对应的图片内容校验失败字段。 +- 恢复 2026-09-04 的群 23 和群 27 正常分镜群报,并保留 `send_hold=true` 与人工发送要求。 +- 依靠独立的 PR #16 阻止 Level 3/Pillow 诊断图被标记为成功、推进到可发送状态或被发送层认领。 + +## 允许修改范围 + +- `app/image/fact_verification.py` +- `app/image/regeneration.py` +- `tests/test_image_fact_verification.py` +- `tests/test_v2_image_regeneration.py` +- 本任务说明文件 +- 2026-09-04 群 23、群 27 的运行状态与图片产物(在备份后通过既有恢复入口写入) + +## 禁止修改范围 + +- 不修改消息、排行榜或其他四个群的图片。 +- 不调用微信发送、发送排程、邮件发送或目标核验接口。 +- 不修改 Provider、登录、认证、`.env`、API Key 或远端权限。 +- 不重启生产服务,不改写 Git 历史,不自动合并 PR。 + +## 已确定实现要求 + +- 只去除整数部分的冗余前导零,不放宽未知数字、单位、百分比和禁止事实校验。 +- 恢复流程必须在图片进入 `READY_TO_SEND` 前写入并保留 `send_hold=true`、`needs_manual_send=true`。 +- Level 3/Pillow 排行或诊断数据卡不属于正常群报,即使事实校验通过也不得作为成功结果或进入发送流程。 +- 群 23 和群 27 仅使用各自已有 `run.json`、`ranking.json`、证据对话和历史成功群报作为依据,通过内置 ImageGen 生成正常分镜群报;不得虚构人名、数字、引语或外部事实。 +- 恢复结果必须通过 PNG 解码、尺寸、哈希及严格事实校验,写入前先备份原图,并通过既有恢复入口原子提升。 + +## 验收标准 + +- 前导零回归测试通过,未知数字仍然失败关闭。 +- 图片内容校验失败经成功恢复后,`error`、`error_type`、`failed_stage` 被清除。 +- 真实 `2026.09.04` OCR 页眉通过,伪造的其他运行日期仍失败关闭。 +- 两群 `daily_image.png` 均为正常分镜群报,存在、可解码、接口返回 `image/png`,状态为 `READY_TO_SEND`。 +- 两群 `send_hold=true`、`needs_manual_send=true`、所有发送时间戳为空,且没有微信发送尝试证据。 +- Dashboard 与 `run.json` 的图片成功/人工持有状态一致。 +- PR #16 的诊断回退图发送防线通过 CI,但在用户确认合并并部署前不得宣称生产永久生效。 + +## 测试命令 + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\test_image_fact_verification.py tests\test_v2_image_regeneration.py -q +.\.venv\Scripts\python.exe -m pytest tests -q +git diff --check +``` + +## 运行恢复记录 + +- 首次计划的恢复前目录复制因 PowerShell `-LiteralPath` 不展开通配符而失败,且该错误当时未终止后续恢复;因此不存在可声明为成功的恢复前文件副本。 +- 恢复完成后已将 7 个产物逐文件 SHA-256 校验复制到 `output/.backups/image-date-fact-post-recovery-20260904-075918/`,用于保留当前验收态。 +- 用户复核后确认该恢复态仍是错误的 Level 3 数据卡,故该卡片仅作为 `daily_image.previous.png` 留存,不再视为成功群报。 +- 群 27 的错误数据卡在替换前已备份到 `output/.backups/invalid-group-report-hold-20260904-081524/`。 +- 两群已用内置 ImageGen 生成正常分镜群报,严格事实校验均通过;通过既有恢复入口原子替换后,`image_fallback_level=0`、`image_variant=normal`,并继续持有发送锁。 +- 原失败证据仍保留在 `run.json` 的 `attempt_ledger`;本次未调用微信发送。 + +## 返回格式 + +- 根因与修复摘要 +- 恢复图片的路径、尺寸、SHA-256、事实校验结果 +- `send_hold` 与零发送证据 +- Git 分支、提交、Push、PR、CI 状态与未合并说明 diff --git a/app/image/fact_verification.py b/app/image/fact_verification.py index 3156792..1860014 100644 --- a/app/image/fact_verification.py +++ b/app/image/fact_verification.py @@ -89,7 +89,9 @@ def _canonical_number(value: str) -> str: normalized = normalized.replace("块", "元") if re.fullmatch(r"0+", normalized): return "0" - return normalized + # OCR 常保留日期、分镜或昵称中的整数前导零;它们不应把 + # 05/09 之类的版式数字与已允许的 5/9 判为两个不同事实。 + return re.sub(r"^0+(?=\d)", "", normalized) def _numeric_facts(value: str) -> set[str]: @@ -120,6 +122,28 @@ def _load_evidence(prompt_file: Path) -> tuple[list[str], str, int]: line.strip() for line in visible_prompt.splitlines() if line.strip() ] numeric_evidence_lines: list[str] = [] + # Level 3 本地信息图会直接渲染 run.json 中的群名、运行日期和人数, + # 而简化 Prompt 不一定重复这些字段。把渲染所依赖的确定性元数据加入 + # 证据,避免日期 04/11 等被误判,同时仍拒绝不属于本次运行的数字。 + run_path = prompt_file.with_name("run.json") + try: + run = json.loads(run_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + run = {} + if isinstance(run, dict): + trusted_run_fields = ( + "group_name", + "run_date", + "period_start", + "period_end", + "message_count", + "speaker_count", + ) + for field in trusted_run_fields: + value = str(run.get(field) or "").strip() + if value: + evidence_lines.append(value) + numeric_evidence_lines.append(value) for heading in ("群名称", "统计时间", "数据"): match = re.search( rf"【{heading}】\s*(.*?)(?=\n【|\Z)", diff --git a/app/image/regeneration.py b/app/image/regeneration.py index f2f307b..14fa991 100644 --- a/app/image/regeneration.py +++ b/app/image/regeneration.py @@ -21,6 +21,7 @@ from app.image.fact_verification import strict_fact_verification_enabled from app.image.image_task import ImageTaskResult, verify_image_contract from app.v2.constants import ( + IMAGE_CONTENT_VERIFICATION_FAILED, IMAGE_FILE_MISSING, IMAGE_GENERATION_FAILED, READY_TO_SEND, @@ -244,7 +245,11 @@ def _promote_image( current = store.load_run(group_name, run_date) next_status = SENT if current.get("status") == SENT else READY_TO_SEND success_fields: dict[str, Any] = {} - if str(current.get("error_type") or "") in {IMAGE_GENERATION_FAILED, IMAGE_FILE_MISSING}: + if str(current.get("error_type") or "") in { + IMAGE_CONTENT_VERIFICATION_FAILED, + IMAGE_GENERATION_FAILED, + IMAGE_FILE_MISSING, + }: success_fields.update(error=None, error_type=None, failed_stage=None) store.update( group_name, diff --git a/tests/test_image_fact_verification.py b/tests/test_image_fact_verification.py index 5ab152b..2241887 100644 --- a/tests/test_image_fact_verification.py +++ b/tests/test_image_fact_verification.py @@ -94,6 +94,82 @@ def test_numeric_units_do_not_join_across_ocr_lines(tmp_path): assert review.unknown_numeric == () +def test_allows_zero_padded_layout_numbers(tmp_path): + prompt, image = _evidence(tmp_path) + + review = review_image_facts( + prompt, + image, + ocr_text="分镜 01\n成员 05\n日期 09\n体重 078.8万元", + ) + + assert review.ok + assert review.unknown_numeric == () + + +def test_allows_local_fallback_header_from_trusted_run_metadata(tmp_path): + prompt = tmp_path / "image_prompt.txt" + prompt.write_text( + "【任务】\n外部内容整理失败,仅使用当天 ranking.json 生成本地简化信息图。", + encoding="utf-8", + ) + (tmp_path / "messages.json").write_text( + json.dumps([{"sender_name": "群友甲", "content": "测试消息"}], ensure_ascii=False), + encoding="utf-8", + ) + (tmp_path / "ranking.json").write_text( + json.dumps({"message_count": 161, "speaker_count": 20}), + encoding="utf-8", + ) + (tmp_path / "run.json").write_text( + json.dumps( + { + "group_name": "茶馆V3.0(三周年纪念)🐮🐴", + "run_date": "2026-09-04", + "message_count": 161, + "speaker_count": 20, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + image = tmp_path / "daily_image.png" + Image.new("RGB", (16, 16), "white").save(image) + + review = review_image_facts( + prompt, + image, + ocr_text="茶馆V3.0(三周年纪念)\n2026.09.04 · 简化版数据卡片\n消息 161\n参与 20", + ) + + assert review.ok + assert review.unknown_numeric == () + + +def test_rejects_local_fallback_header_for_a_different_run_date(tmp_path): + prompt = tmp_path / "image_prompt.txt" + prompt.write_text("【任务】\n生成本地简化信息图。", encoding="utf-8") + (tmp_path / "messages.json").write_text( + json.dumps([{"sender_name": "群友甲", "content": "测试消息"}], ensure_ascii=False), + encoding="utf-8", + ) + (tmp_path / "run.json").write_text( + json.dumps({"run_date": "2026-09-04"}), + encoding="utf-8", + ) + image = tmp_path / "daily_image.png" + Image.new("RGB", (16, 16), "white").save(image) + + review = review_image_facts( + prompt, + image, + ocr_text="2026.09.11 · 简化版数据卡片", + ) + + assert not review.ok + assert "11" in review.unknown_numeric + + def test_allows_deterministic_header_numbers_and_currency_alias(tmp_path): prompt = tmp_path / "image_prompt.txt" prompt.write_text( diff --git a/tests/test_v2_image_regeneration.py b/tests/test_v2_image_regeneration.py index 6ae5ed5..fb9ba18 100644 --- a/tests/test_v2_image_regeneration.py +++ b/tests/test_v2_image_regeneration.py @@ -94,6 +94,26 @@ def test_success_atomically_replaces_image_backs_up_old_and_holds_send(tmp_path) assert run["image_force_local_fallback"] is False +def test_success_clears_image_content_verification_failure(tmp_path): + settings, store, group, run_date = _run(tmp_path, status="FAILED") + store.update( + group, + run_date, + failed_stage="image", + error="图片事实校验失败:无证据数字:05", + error_type="IMAGE_CONTENT_VERIFICATION_FAILED", + ) + + run = run_regeneration_now(settings, group, run_date, SuccessGenerator()) + + assert run["status"] == "READY_TO_SEND" + assert run["error"] is None + assert run["error_type"] is None + assert run["failed_stage"] is None + assert run["send_hold"] is True + assert run["needs_manual_send"] is True + + def test_cli_failure_keeps_old_image_and_fails_closed(tmp_path): settings, store, group, run_date = _run(tmp_path)