Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions IMAGE_LEADING_ZERO_RECOVERY_TASK.md
Original file line number Diff line number Diff line change
@@ -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 状态与未合并说明
26 changes: 25 additions & 1 deletion app/image/fact_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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)",
Expand Down
7 changes: 6 additions & 1 deletion app/image/regeneration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions tests/test_image_fact_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions tests/test_v2_image_regeneration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down