diff --git a/.codex/TASK_DISABLE_STAT_IMAGE_FALLBACK.md b/.codex/TASK_DISABLE_STAT_IMAGE_FALLBACK.md new file mode 100644 index 0000000..5362a4f --- /dev/null +++ b/.codex/TASK_DISABLE_STAT_IMAGE_FALLBACK.md @@ -0,0 +1,68 @@ +# 禁止统计兜底图进入日报生图流程 + +## 背景 + +2026-09-05 的两个群在真实图片事实校验失败后生成了 Level 3 Pillow 统计诊断图。后续重试仅按 PNG 可解码性复用了 `daily_image.png`,并将它记录为 `fallback_level=0`、`image_variant=normal`。另一个群因真实图及兜底图均包含无证据数字而没有图片。 + +## 目标 + +- 以后日报只接受正常 AI 生图或现有安全化 AI 生图流程的产物。 +- 图片生成或事实校验失败时明确失败,不生成排行榜/统计表兜底图。 +- 重试不得把旧 Level 3/Pillow 诊断图当作正常图片复用。 +- 群级自定义关键词主题默认只作用于下一次新运行;界面可明确选择有限使用次数;额度消费后自动回到 `random_preset`。 +- 修复和生产生效后,将 2026-09-05 的六个群全部切回 `random_preset`,重新生成并在逐图、逐目标验收后发送。 + +## 允许修改范围 + +- `app/image/` +- `app/pipeline/`(仅图片结果状态衔接需要时) +- `app/db/`、`app/api/groups.py`、调度任务快照(仅主题有限次数所需字段与原子消费) +- `frontend/src/` 中主题次数选择及对应 API 类型 +- `tests/` 中对应图片生成、流水线测试及 CI 随机顺序隔离修复 +- 本任务说明文件 + +## 禁止修改范围 + +- 与本次六群随机重生成无关的历史 `output/`、日志和数据库内容 +- 微信发送实现、群聊目标 +- Provider、Codex 登录/认证方式 +- 部署、服务重启、PR 合并 + +## 已确定实现要求 + +- 移除生产失败路径对 `render_local_infographic` 的调用,不再写出统计兜底 `daily_image.png`。 +- 失败前清理本次失败路径遗留的不可交付目标文件,避免后续仅按图片格式复用。 +- 现有文件复用必须结合运行元数据;Level 3、Pillow、`diagnostic_fallback` 或已知诊断图不得返回成功。 +- 保留真实图片两次事实校验及现有安全化 AI 重试流程。 +- 失败状态保持 fail-closed,不进入 `READY_TO_SEND`。 +- 生图前去除摘要阶段擅自附加、但原始聊天没有依据的数字单位;首次 OCR 事实校验失败后,使用列明拒绝项的纠错 Prompt 正常重画一次。 +- 自定义主题保存时默认次数为 1;允许选择有限正整数。图片成功生成并记录结果时原子扣减一次,同一天运行的重试和 Prompt 重建不重复扣减,失败或结果未知不扣减;扣至 0 后群配置恢复每日随机。 +- `ai_free` 与 `random_preset` 是持续模式,不消耗次数。 +- 六群实际生成必须先清除当天旧图的可复用条件并保留发送锁;所有图片通过 PNG、尺寸、事实契约、归属路径与哈希验收后,才逐群验证发送目标并串行发送。 + +## 验收标准 + +- 图片失败时无统计兜底图,结果为失败且不可发送。 +- 旧诊断图即使是可解码 PNG,也不会被 `existing_output_reused` 接纳。 +- 正常已有图片仍可复用,不额外调用 Provider。 +- 自定义关键词主题默认 1 次,可选多次;新运行只扣一次,重试不重复扣,耗尽后显示每日随机。 +- 相关测试、完整图片/流水线测试、静态检查通过。 +- 实际 diff 无范围外修改、敏感信息、TODO/debug 或临时文件。 + +## 测试命令 + +- `.venv\Scripts\python.exe -m pytest tests/test_v2_image_task.py tests/test_v2_pipeline.py -q` +- `.venv\Scripts\python.exe -m pytest tests/test_image_fact_verification.py tests/test_runtime_status.py tests/test_v2_ui_router_contract.py -q` +- `.venv\Scripts\python.exe -m pytest tests/test_group_image_theme_batch_api.py tests/test_generation_concurrency.py tests/test_daily_random_theme.py -q` +- `npm --prefix frontend test -- --run` +- `npm --prefix frontend run build` +- `.venv\Scripts\python.exe -m compileall app tests` +- `git diff --check` + +## 返回格式 + +- 根因证据 +- 修改文件与行为变化 +- 测试命令和结果 +- Git 分支、提交、Push、PR、CI 状态 +- 六群随机主题配置、生成验收、逐群发送证据;生产运行是否已生效 diff --git a/app/api/groups.py b/app/api/groups.py index 4e3afe1..0d00682 100644 --- a/app/api/groups.py +++ b/app/api/groups.py @@ -57,6 +57,7 @@ class GroupCreate(BaseModel): image_prompt_template: str = "default" image_theme: str = DEFAULT_IMAGE_THEME image_theme_custom: str = "" + image_theme_apply_count: int | None = Field(default=None, ge=1, le=30) image_prompt_override: str = "" wechat_send_enabled: bool = False @@ -82,6 +83,7 @@ class GroupUpdate(BaseModel): image_prompt_template: str | None = None image_theme: str | None = None image_theme_custom: str | None = None + image_theme_apply_count: int | None = Field(default=None, ge=1, le=30) image_prompt_override: str | None = None wechat_send_enabled: bool | None = None @@ -96,6 +98,7 @@ class GroupImagePromptUpdate(BaseModel): inherit_global: bool = False image_theme: str image_theme_custom: str = "" + image_theme_apply_count: int = Field(default=1, ge=1, le=30) expected_revision: str = "" @@ -103,6 +106,7 @@ class BatchImageThemeUpdate(BaseModel): group_ids: list[int] = Field(min_length=1) image_theme: str image_theme_custom: str = "" + image_theme_apply_count: int = Field(default=1, ge=1, le=30) @field_validator("group_ids") @classmethod @@ -122,6 +126,14 @@ def _validate_group_theme(theme: object, custom: object = "") -> tuple[str, str] raise HTTPException(status_code=422, detail=str(exc)) from exc +def _theme_remaining_runs(theme: str, apply_count: int | None) -> int: + """自由/每日随机持续生效;任何手动主题默认只用于下一次成功生图。""" + + if theme in {"ai_free", "random_preset"}: + return 0 + return int(apply_count or 1) + + def _validate_output_group_name(value: object, *, field_name: str) -> str: """拒绝会被当作文件路径的群名称,同时保留普通显示名标点。""" text = str(value or "") @@ -175,6 +187,7 @@ def _group_prompt_payload(group: Group) -> dict: "revision": prompt_revision(content), "image_theme": group.image_theme or DEFAULT_IMAGE_THEME, "image_theme_custom": group.image_theme_custom or "", + "image_theme_remaining_runs": int(group.image_theme_remaining_runs or 0), "resolved_theme": theme.to_meta(), "preview": preview, } @@ -237,6 +250,7 @@ def list_groups( "image_prompt_template": g.image_prompt_template, "image_theme": g.image_theme, "image_theme_custom": g.image_theme_custom, + "image_theme_remaining_runs": int(g.image_theme_remaining_runs or 0), "has_image_prompt_override": bool((g.image_prompt_override or "").strip()), "wechat_send_enabled": bool(g.wechat_send_enabled), "created_at": g.created_at.isoformat(), @@ -270,9 +284,13 @@ def create_group( values["wechat_group_name"], field_name="wechat_group_name" ) values["send_target"] = str(values.get("send_target") or "").strip() + apply_count = values.pop("image_theme_apply_count", None) values["image_theme"], values["image_theme_custom"] = _validate_group_theme( values.get("image_theme", DEFAULT_IMAGE_THEME), values.get("image_theme_custom", "") ) + values["image_theme_remaining_runs"] = _theme_remaining_runs( + values["image_theme"], apply_count + ) values["image_prompt_override"] = _validate_prompt_override(values.get("image_prompt_override", "")) wechat_group_id = str(values.get("wechat_group_id") or "").strip() existing = repo.find_group_by_wechat_id(session, wechat_group_id) if wechat_group_id else None @@ -325,6 +343,9 @@ def batch_update_group_image_theme( group_id, image_theme=theme, image_theme_custom=custom, + image_theme_remaining_runs=_theme_remaining_runs( + theme, payload.image_theme_apply_count + ), ) except SQLAlchemyError: session.rollback() @@ -344,6 +365,9 @@ def batch_update_group_image_theme( successes.append({ "group_id": group_id, "group_name": group_name, + "remaining_runs": _theme_remaining_runs( + theme, payload.image_theme_apply_count + ), }) status = "success" if not failures else "partial" if successes else "failed" @@ -369,6 +393,7 @@ def update_group( settings, explicitly_provided="send_time" in payload.model_fields_set, ) + apply_count = updates.pop("image_theme_apply_count", None) try: updates = validate_group_provider_values( updates, @@ -389,6 +414,13 @@ def update_group( ) updates["image_theme"] = theme updates["image_theme_custom"] = custom + updates["image_theme_remaining_runs"] = _theme_remaining_runs( + theme, apply_count + ) + elif apply_count is not None: + updates["image_theme_remaining_runs"] = _theme_remaining_runs( + group.image_theme, apply_count + ) if "image_prompt_override" in updates: updates["image_prompt_override"] = _validate_prompt_override(updates["image_prompt_override"]) if "send_target" in updates: @@ -423,6 +455,9 @@ def update_group_image_prompt( raise HTTPException(status_code=422, detail="群级 Prompt 不能为空;如需继承请使用恢复全局模板") group.image_theme = theme group.image_theme_custom = custom + group.image_theme_remaining_runs = _theme_remaining_runs( + theme, payload.image_theme_apply_count + ) group.image_prompt_override = "" if payload.inherit_global else _validate_prompt_override(payload.content) repo.save_group(session, group) return _group_prompt_payload(group) diff --git a/app/db/models.py b/app/db/models.py index 764d0b0..0941619 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -44,6 +44,7 @@ class Group(SQLModel, table=True): image_prompt_template: str = "default" # 生图 Prompt 模板名 image_theme: str = "ai_free" # 生图主题键(默认由 AI 按聊天内容自由发挥) image_theme_custom: str = "" # 自定义生图大主题(image_theme=custom 时使用) + image_theme_remaining_runs: int = 0 # 手动主题剩余成功生图次数;0 表示持续模式不计次 image_prompt_override: str = "" # 本群专属 Prompt 模板;为空时继承全局模板 wechat_send_enabled: bool = False # 独立于生成开关,默认禁止自动对外发送 diff --git a/app/db/repository.py b/app/db/repository.py index 26b18c6..30eaa43 100644 --- a/app/db/repository.py +++ b/app/db/repository.py @@ -8,6 +8,7 @@ from typing import Any from sqlalchemy import event, update +from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlmodel import Session, SQLModel, create_engine, select from app.config.settings import Settings @@ -188,6 +189,7 @@ def _ensure_relationship_schema_current() -> None: "image_prompt_template": "VARCHAR(64) NOT NULL DEFAULT 'default'", "image_theme": "VARCHAR(64) NOT NULL DEFAULT 'ai_free'", "image_theme_custom": "VARCHAR(80) NOT NULL DEFAULT ''", + "image_theme_remaining_runs": "INTEGER NOT NULL DEFAULT 0", "image_prompt_override": "TEXT NOT NULL DEFAULT ''", "wechat_send_enabled": "BOOLEAN NOT NULL DEFAULT 0", "deleted_at": "DATETIME NULL", @@ -245,6 +247,23 @@ def _migrate_parallel_summary_defaults() -> None: session.commit() +def _migrate_finite_image_theme_defaults() -> None: + """让升级前已保存的手动主题只再使用一次,随后回到每日随机。""" + + marker = "migration_finite_image_theme_uses_v1" + with Session(engine) as session: + if session.get(Setting, marker) is not None: + return + session.exec( + Group.__table__.update() + .where(Group.image_theme.notin_(("ai_free", "random_preset"))) + .where(Group.image_theme_remaining_runs <= 0) + .values(image_theme_remaining_runs=1) + ) + session.add(Setting(key=marker, value="done")) + session.commit() + + def _migrate_daily_schedule_defaults() -> None: """一次性把旧 00:01 生成/发送默认值升级为 00:30/08:30。 @@ -380,6 +399,7 @@ def init_db(settings: Settings) -> Any: _seed_defaults(settings) # 先种默认值再迁移,确保旧 .env 中的 deepseek-chat / 12000 也会升级。 _migrate_parallel_summary_defaults() + _migrate_finite_image_theme_defaults() _migrate_codex_summary_defaults() _migrate_codex_image_timeout_default() _migrate_daily_schedule_defaults() @@ -529,6 +549,7 @@ def update_group_image_theme( *, image_theme: str, image_theme_custom: str, + image_theme_remaining_runs: int, ) -> bool: """独立事务只更新群生图主题字段,避免通用保存路径带入其他配置。""" @@ -538,6 +559,7 @@ def update_group_image_theme( .values( image_theme=image_theme, image_theme_custom=image_theme_custom, + image_theme_remaining_runs=max(int(image_theme_remaining_runs), 0), updated_at=_now(), ) ) @@ -545,6 +567,86 @@ def update_group_image_theme( return result.rowcount == 1 +def consume_group_image_theme_for_run( + session: Session, + group_id: int, + *, + run_date: str, + expected_theme: str, + expected_custom: str, +) -> dict[str, Any]: + """为一个新运行至多消费一次手动主题;同一事务内写幂等标记并更新群配置。""" + + if expected_theme in {"ai_free", "random_preset"}: + return { + "consumed": False, + "already_consumed": False, + "remaining_runs": 0, + "next_theme": expected_theme, + } + + marker = f"image_theme_use:{group_id}:{run_date}" + claim = session.execute( + sqlite_insert(Setting) + .values(key=marker, value=expected_theme[:64], updated_at=_now()) + .on_conflict_do_nothing(index_elements=["key"]) + ) + if claim.rowcount != 1: + session.rollback() + group = session.get(Group, group_id) + return { + "consumed": False, + "already_consumed": True, + "remaining_runs": max( + int(getattr(group, "image_theme_remaining_runs", 0) or 0), 0 + ) + if group is not None + else 0, + "next_theme": str(getattr(group, "image_theme", "random_preset") or "random_preset") + if group is not None + else "random_preset", + } + + group = session.get(Group, group_id) + matches_snapshot = bool( + group is not None + and group.deleted_at is None + and str(group.image_theme or "") == expected_theme + and str(group.image_theme_custom or "") == expected_custom + ) + if not matches_snapshot: + session.commit() + return { + "consumed": False, + "already_consumed": False, + "config_changed": True, + "remaining_runs": max( + int(getattr(group, "image_theme_remaining_runs", 0) or 0), 0 + ) + if group is not None + else 0, + "next_theme": str(getattr(group, "image_theme", "random_preset") or "random_preset") + if group is not None + else "random_preset", + } + + remaining = max(int(group.image_theme_remaining_runs or 0), 1) + next_remaining = remaining - 1 + if next_remaining == 0: + group.image_theme = "random_preset" + group.image_theme_custom = "" + group.image_theme_remaining_runs = next_remaining + group.updated_at = _now() + session.add(group) + session.commit() + return { + "consumed": True, + "already_consumed": False, + "remaining_runs": next_remaining, + "next_theme": str(group.image_theme or "random_preset"), + } + + def delete_group(session: Session, group_id: int) -> Group | None: group = session.get(Group, group_id) if group is None: diff --git a/app/image/delivery_guard.py b/app/image/delivery_guard.py index a47a882..1a69d20 100644 --- a/app/image/delivery_guard.py +++ b/app/image/delivery_guard.py @@ -20,4 +20,35 @@ def image_delivery_eligible(metadata: Mapping[str, Any] | None) -> bool: metadata = metadata if isinstance(metadata, Mapping) else {} fallback_level = image_fallback_level(metadata) image_variant = str(metadata.get("image_variant") or "").strip().lower() - return fallback_level < 3 and image_variant != "pillow" + image_status = str(metadata.get("image_status") or "").strip().lower() + image_job = metadata.get("image_job") + job_status = ( + str(image_job.get("status") or "").strip().lower() + if isinstance(image_job, Mapping) + else "" + ) + if ( + fallback_level >= 3 + or image_variant == "pillow" + or image_status in {"failed", "diagnostic_fallback"} + or job_status in {"failed", "ambiguous_result", "diagnostic_fallback"} + ): + return False + + recovery_status = str( + metadata.get("image_recovery_status") + or metadata.get("recovery_status") + or "" + ).strip().lower() + if recovery_status == "existing_output_reused": + diagnostic_history = " ".join( + str(metadata.get(key) or "") + for key in ( + "last_error_summary", + "image_fallback_reason", + "prompt_fallback_reason", + ) + ).lower() + if "本地诊断图" in diagnostic_history or "fallback=l3" in diagnostic_history: + return False + return True diff --git a/app/image/fact_verification.py b/app/image/fact_verification.py index 1860014..e9b8422 100644 --- a/app/image/fact_verification.py +++ b/app/image/fact_verification.py @@ -111,6 +111,37 @@ def _numeric_fact_is_allowed(candidate: str, allowed: set[str]) -> bool: return False +def strip_unverified_prompt_numeric_units(prompt_file: Path) -> tuple[str, tuple[str, ...]]: + """去掉摘要擅自附加、但原始证据没有的数字单位。 + + 例如聊天只写了“大毯子45”,摘要扩写成“45元”时保留数字 45、 + 去掉未经证实的“元”。已有“38块”等同义单位证据时仍允许“38元”。 + """ + + prompt = prompt_file.read_text(encoding="utf-8") + _, numeric_evidence, _ = _load_evidence(prompt_file) + allowed_numbers = _numeric_facts(numeric_evidence) + allowed_numbers.update(str(number) for number in range(0, 11)) + stripped: list[str] = [] + + def replace(match: re.Match[str]) -> str: + raw = match.group(0) + candidate = _canonical_number(raw) + if _numeric_fact_is_allowed(candidate, allowed_numbers): + return raw + base_match = re.match(r"\d+(?:[.,]\d+)?", raw) + if base_match is None or base_match.end() == len(raw): + return raw + base = base_match.group(0) + if not _numeric_fact_is_allowed(_canonical_number(base), allowed_numbers): + return raw + stripped.append(raw) + return base + + sanitized = _NUMERIC_FACT_RE.sub(replace, prompt) + return sanitized, tuple(dict.fromkeys(stripped)) + + def _load_evidence(prompt_file: Path) -> tuple[list[str], str, int]: prompt = prompt_file.read_text(encoding="utf-8") visible_prompt = prompt.split(STRICT_IMAGE_FACT_MARKER, 1)[0] diff --git a/app/image/image_task.py b/app/image/image_task.py index 52566a3..05a75a1 100644 --- a/app/image/image_task.py +++ b/app/image/image_task.py @@ -11,6 +11,7 @@ import hashlib import inspect import json +import re import shutil from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass @@ -28,9 +29,9 @@ from app.image.fallback import ( image_failure_code, image_result_is_unknown, - render_local_infographic, sanitize_image_prompt, ) +from app.ai.strict_prompt_contract import STRICT_IMAGE_FACT_MARKER @dataclass @@ -185,28 +186,10 @@ def _call_generator( return self.generator.generate(prompt_file, self.output_path) def _local_fallback(self, failure_class: str) -> dict: - settings = getattr(self.generator, "settings", None) - detail = render_local_infographic( - group_name=self.group_name, - run_date=self.output_path.parent.name, - ranking_path=self.output_path.parent / "ranking.json", - run_path=self.output_path.parent / "run.json", - output_path=self.output_path, - font_path=str(getattr(settings, "image_fallback_font_path", "") or ""), - failure_class=failure_class, - ) - from app.image.fact_verification import strict_fact_verification_enabled + """兼容旧调用点:失败时清理目标文件,不再生成统计信息图。""" - if strict_fact_verification_enabled(self.prompt_file): - ok, verification_detail = verify_image_contract( - self.prompt_file, - self.output_path, - ) - if not ok: - if self.output_path.exists(): - self.output_path.unlink() - raise ValueError(verification_detail) - detail["fact_verification"] = verification_detail + if self.output_path.exists(): + self.output_path.unlink() error_type = ( failure_class if failure_class @@ -219,13 +202,48 @@ def _local_fallback(self, failure_class: str) -> dict: ) return { "group_name": self.group_name, - "status": "diagnostic_fallback", + "status": "failed", "success": False, - "detail": f"图片生成失败,已保留不可发送的本地诊断图:{self.output_path}", + "detail": "正常 AI 生图失败;统计表兜底已停用,本次不生成图片", "error_type": error_type, - "generator_detail": detail, + "generator_detail": { + "fallback_level": 0, + "image_variant": "normal", + "local_infographic_disabled": True, + }, } + def _fact_retry_prompt(self, verification_detail: str) -> Path: + """为事实校验重画生成一次性纠错 Prompt,不改写原始 Prompt。""" + + original = self.prompt_file.read_text(encoding="utf-8") + match = re.search(r"无证据数字:([^;\n]+)", verification_detail) + rejected_values: list[str] = [] + if match: + rejected_values = re.findall( + r"\d+(?:[.,]\d+)?(?:\s*(?:%|%|kg|KG|公斤|斤|元|块|万元|万|W|w|天|℃|°C|岁|厘米|cm|米|m|小时|分钟))?", + match.group(1), + )[:12] + rejected_text = "、".join(dict.fromkeys(rejected_values)) or "上一张中无法核验的数字或事实文字" + correction = ( + "【事实校验重画修正|最高优先级】\n" + f"上一张图片的 OCR 拒绝项为:{rejected_text}。\n" + "新图不得出现上述字符串;凡涉及这些值的事实说明、气泡、标签和数字特效全部省略," + "改用人物动作、表情和不含文字的图形表达。不要改成近似数字,也不要自行添加单位。\n" + "其他数字必须逐字照抄本 Prompt;无法稳定写对时宁可省略。" + ) + if STRICT_IMAGE_FACT_MARKER in original: + visible, contract = original.split(STRICT_IMAGE_FACT_MARKER, 1) + retry_prompt = ( + f"{correction}\n\n{visible.rstrip()}\n\n" + f"{STRICT_IMAGE_FACT_MARKER}{contract}" + ) + else: + retry_prompt = f"{correction}\n\n{original}" + retry_path = self.prompt_file.with_name("image_prompt.fact_retry.txt") + retry_path.write_text(retry_prompt, encoding="utf-8") + return retry_path + def run(self) -> dict: """执行生图并验证落盘。返回结构化结果。""" try: @@ -246,6 +264,19 @@ def run(self) -> dict: "detail": "图片已存在,跳过生成", "error_type": "", } + elif not self.force and self.output_path.exists(): + # 旧 Level 3/Pillow 诊断图不能继续留在默认输出路径,否则底层 + # 生成器只看到“可解码 PNG”就会走 existing_output_reused。 + try: + self.output_path.unlink() + except OSError as exc: + return { + "group_name": self.group_name, + "status": "failed", + "success": False, + "detail": f"旧诊断图无法清理,已停止生图:{str(exc)[:160]}", + "error_type": IMAGE_GENERATION_FAILED, + } if isinstance(run_state, dict) and run_state.get("image_force_local_fallback"): try: return self._local_fallback( @@ -352,8 +383,9 @@ def run(self) -> dict: try: if self.output_path.exists(): self.output_path.unlink() + retry_prompt = self._fact_retry_prompt(detail) retry_result = self._call_generator( - self.prompt_file, + retry_prompt, quality_retry=True, ) except Exception as exc: @@ -372,7 +404,7 @@ def run(self) -> dict: } if retry_result.success: retry_ok, retry_detail = verify_image_contract( - self.prompt_file, + retry_prompt, self.output_path, ) if retry_ok: diff --git a/app/pipeline/daily_pipeline.py b/app/pipeline/daily_pipeline.py index 3711f2e..fdbdd0b 100644 --- a/app/pipeline/daily_pipeline.py +++ b/app/pipeline/daily_pipeline.py @@ -156,6 +156,7 @@ def generate_all( "prompt_provider", "prompt_model", "image_enabled", "ranking_template", "ranking_count_policy", "sender_name_policy", "image_prompt_template", "image_theme", "image_theme_custom", + "image_theme_remaining_runs", "image_prompt_override", "send_target", } groups = [ @@ -553,6 +554,7 @@ def _make_image_job(self, group: Group, run_date: str, force: bool) -> ImageJob: return ImageStages( store=self.store, image_generator=self.image_generator, + consume_image_theme=self._consume_group_image_theme, ).make_job(self._group_name(group), run_date, force) def _image_hook(self, job: ImageJob, result: dict) -> None: @@ -560,12 +562,14 @@ def _image_hook(self, job: ImageJob, result: dict) -> None: ImageStages( store=self.store, image_generator=self.image_generator, + consume_image_theme=self._consume_group_image_theme, ).record_result(job, result) def _after_image(self, job: ImageJob, run_date: str) -> None: ImageStages( store=self.store, image_generator=self.image_generator, + consume_image_theme=self._consume_group_image_theme, ).advance_ready(job, run_date) def _run_image_jobs(self, image_jobs: list[ImageJob], run_date: str) -> list[dict]: @@ -573,6 +577,7 @@ def _run_image_jobs(self, image_jobs: list[ImageJob], run_date: str) -> list[dic return ImageStages( store=self.store, image_generator=self.image_generator, + consume_image_theme=self._consume_group_image_theme, ).run_jobs( image_jobs, run_date, @@ -1657,6 +1662,30 @@ def operation() -> Group | None: max_attempts=self.settings.sqlite_retry_max_attempts, ) + def _consume_group_image_theme( + self, + group_id: int, + run_date: str, + expected_theme: str, + expected_custom: str, + ) -> dict: + from sqlmodel import Session + + def operation() -> dict: + with Session(repo.engine) as session: + return repo.consume_group_image_theme_for_run( + session, + group_id, + run_date=run_date, + expected_theme=expected_theme, + expected_custom=expected_custom, + ) + + return run_with_sqlite_retry( + operation, + max_attempts=self.settings.sqlite_retry_max_attempts, + ) + def _save_json(self, path: Path, data) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8") diff --git a/app/pipeline/generation_stages.py b/app/pipeline/generation_stages.py index 5242855..107fa6e 100644 --- a/app/pipeline/generation_stages.py +++ b/app/pipeline/generation_stages.py @@ -13,6 +13,7 @@ from app.ai.prompt_builder_types import PromptInput from app.ai.speaker_attribution import build_attribution_contract from app.ai.strict_prompt_contract import append_strict_image_fact_contract +from app.image.fact_verification import strip_unverified_prompt_numeric_units from app.config.settings import Settings from app.core.observability import log_event from app.data_sources.base import V2Message, WeChatDataSource @@ -307,6 +308,12 @@ def _prepare_run( ) ) group = context.group + snapshot_theme = str(run.get("image_theme") or group.image_theme) + snapshot_custom = str( + run.get("image_theme_custom") + if "image_theme_custom" in run + else group.image_theme_custom + ) base = { "group_id": str(group.id), "wechat_group_id": group.wechat_group_id, @@ -324,8 +331,8 @@ def _prepare_run( ), "sender_name_policy": getattr(group, "sender_name_policy", "resolved"), "image_prompt_template": group.image_prompt_template, - "image_theme": group.image_theme, - "image_theme_custom": group.image_theme_custom, + "image_theme": snapshot_theme, + "image_theme_custom": snapshot_custom, "wechat_send_enabled": bool(getattr(group, "wechat_send_enabled", False)), "provider": self.data_source.name, "failed_stage": None, @@ -341,6 +348,8 @@ def _prepare_run( retry_stage=str(run.get("failed_stage") or "unknown"), ) self.store.update(context.group_name, context.run_date, status=PENDING, **base) + context.run.setdefault("image_theme", snapshot_theme) + context.run.setdefault("image_theme_custom", snapshot_custom) return StageResult.proceed(context) def _load_or_fetch_messages( @@ -699,8 +708,12 @@ def _build_prompt( speaker_count=ranking.speaker_count, messages=prompt_messages, template=group.image_prompt_template, - image_theme=group.image_theme, - image_theme_custom=group.image_theme_custom, + image_theme=str(context.run.get("image_theme") or group.image_theme), + image_theme_custom=str( + context.run.get("image_theme_custom") + if "image_theme_custom" in context.run + else group.image_theme_custom + ), template_override=getattr(group, "image_prompt_override", "") or "", previous_theme_signature=self.store.previous_theme_signature( context.group_name, @@ -809,39 +822,6 @@ def _execute_prompt_operation( error=prompt_out.error, ) self._record_prompt_timing(context, started_at) - if bool(context.group.image_enabled) and not context.reuse_persisted_topic_selection: - fallback_meta = dict(prompt_out.meta or {}) - fallback_meta.update( - mode="local_infographic", - fallback_level=3, - fallback_reason=PROMPT_FAILED, - ) - fallback_prompt = ( - "【任务】\n" - "外部内容整理失败,仅使用当天 ranking.json 生成本地简化信息图。\n" - "【画布】\n优先 1024×1536;其他完整可读的竖版尺寸也可接受\n" - ) - self.store.prompt_path(context.group_name, context.run_date).write_text( - fallback_prompt, - encoding="utf-8", - ) - self.store.update( - context.group_name, - context.run_date, - status=PROMPT_READY, - failed_stage=None, - error=None, - error_type=None, - prompt_fallback_level=3, - image_force_local_fallback=True, - prompt_fallback_reason=PROMPT_FAILED, - prompt_original_error=str(prompt_out.error)[:300], - ) - return StageResult.proceed( - PromptStageOutput( - prompt_meta=fallback_meta, - ) - ) self.store.update( context.group_name, context.run_date, @@ -849,6 +829,10 @@ def _execute_prompt_operation( failed_stage="prompt", error=prompt_out.error, error_type=PROMPT_FAILED, + prompt_fallback_level=0, + image_force_local_fallback=False, + prompt_fallback_reason="", + prompt_original_error=str(prompt_out.error)[:300], ) return StageResult.stop( { @@ -881,10 +865,15 @@ def _execute_prompt_operation( prompt_path.read_text(encoding="utf-8") ) prompt_path.write_text(strict_prompt, encoding="utf-8") + strict_prompt, stripped_units = strip_unverified_prompt_numeric_units( + prompt_path + ) + prompt_path.write_text(strict_prompt, encoding="utf-8") self.store.update( context.group_name, context.run_date, image_fact_contract="strict_evidence_v1", + prompt_stripped_numeric_units=list(stripped_units), ) return StageResult.proceed( PromptStageOutput( diff --git a/app/pipeline/image_stages.py b/app/pipeline/image_stages.py index 67e2ee2..5b882a7 100644 --- a/app/pipeline/image_stages.py +++ b/app/pipeline/image_stages.py @@ -27,9 +27,16 @@ class ImageStages: """构造、记录并收口受控并发的图片任务。""" - def __init__(self, *, store: RunStore, image_generator) -> None: + def __init__( + self, + *, + store: RunStore, + image_generator, + consume_image_theme: Callable[[int, str, str, str], dict] | None = None, + ) -> None: self.store = store self.image_generator = image_generator + self.consume_image_theme = consume_image_theme def make_job(self, group_name: str, run_date: str, force: bool) -> ImageJob: prompt_path = self.store.prompt_path(group_name, run_date) @@ -83,6 +90,33 @@ def make_job(self, group_name: str, run_date: str, force: bool) -> ImageJob: ) def record_result(self, job: ImageJob, result: dict) -> None: + run_date = job.output_path.parent.name + current = self.store.load_run(job.group_name, run_date) + theme_consumption: dict = {} + applied_theme = str(current.get("image_theme") or "") + if ( + result["success"] + and self.consume_image_theme is not None + and applied_theme not in {"", "ai_free", "random_preset"} + ): + try: + group_id = int(current.get("group_id") or 0) + if group_id <= 0: + raise ValueError("运行记录缺少有效 group_id") + theme_consumption = self.consume_image_theme( + group_id, + run_date, + applied_theme, + str(current.get("image_theme_custom") or ""), + ) + except Exception as exc: + result = { + **result, + "success": False, + "status": "failed", + "error_type": "IMAGE_THEME_CONSUME_FAILED", + "detail": f"图片已生成但一次性主题消费失败,已停止发送:{str(exc)[:160]}", + } status = IMAGE_READY if result["success"] else FAILED error_type = result.get("error_type") or IMAGE_GENERATION_FAILED error_detail = ( @@ -90,8 +124,6 @@ def record_result(self, job: ImageJob, result: dict) -> None: if not result["success"] else None ) - run_date = job.output_path.parent.name - current = self.store.load_run(job.group_name, run_date) stage_timings = dict(current.get("stage_timings") or {}) imagegen_ms = int(result.get("imagegen_ms") or 0) stage_timings["imagegen_ms"] = imagegen_ms @@ -109,6 +141,11 @@ def record_result(self, job: ImageJob, result: dict) -> None: else 0 ) finished_at = datetime.now().astimezone().isoformat() + theme_usage_recorded = bool( + theme_consumption.get("consumed") + or theme_consumption.get("already_consumed") + ) + theme_just_consumed = bool(theme_consumption.get("consumed")) image_job = current.get("image_job") if isinstance(current.get("image_job"), dict) else {} candidates = normalize_candidate_diagnostics(generator_detail) if result["success"]: @@ -180,6 +217,24 @@ def record_result(self, job: ImageJob, result: dict) -> None: image_fallback_reason=str(generator_detail.get("fallback_reason") or ""), image_fallback_font=str(generator_detail.get("fallback_font") or ""), image_safety_redactions=generator_detail.get("safety_redactions") or [], + image_theme_consumed=( + True if theme_usage_recorded else current.get("image_theme_consumed", False) + ), + image_theme_consumed_at=( + finished_at + if theme_just_consumed + else current.get("image_theme_consumed_at", "") + ), + image_theme_remaining_runs=( + int(theme_consumption.get("remaining_runs") or 0) + if theme_consumption + else current.get("image_theme_remaining_runs", 0) + ), + image_theme_next=( + str(theme_consumption.get("next_theme") or "random_preset") + if theme_consumption + else current.get("image_theme_next", "") + ), image_force_local_fallback=( False if result["success"] else current.get("image_force_local_fallback", False) ), diff --git a/app/scheduler/task_manifest.py b/app/scheduler/task_manifest.py index e2a8c1f..44bce85 100644 --- a/app/scheduler/task_manifest.py +++ b/app/scheduler/task_manifest.py @@ -59,6 +59,9 @@ def build_expected_groups( "image_prompt_template": str(group.image_prompt_template or "default"), "image_theme": str(group.image_theme or "ai_free"), "image_theme_custom": str(group.image_theme_custom or ""), + "image_theme_remaining_runs": int( + getattr(group, "image_theme_remaining_runs", 0) or 0 + ), "image_prompt_override": str(group.image_prompt_override or ""), "send_target": str(group.send_target or ""), "wechat_send_enabled": bool(group.wechat_send_enabled), diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 360d70e..ee73168 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -68,6 +68,7 @@ describe("frontend API contract", () => { group_ids: [7, 8], image_theme: "custom", image_theme_custom: "低饱和黏土摄影", + image_theme_apply_count: 3, }); expect(fetchMock).toHaveBeenCalledOnce(); @@ -79,6 +80,7 @@ describe("frontend API contract", () => { group_ids: [7, 8], image_theme: "custom", image_theme_custom: "低饱和黏土摄影", + image_theme_apply_count: 3, }), }), ); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 212ec81..dc54f9d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -91,6 +91,7 @@ export interface GroupV2 extends Group { image_prompt_template: string; image_theme: string; image_theme_custom: string; + image_theme_remaining_runs?: number; has_image_prompt_override: boolean; wechat_send_enabled: boolean; } @@ -115,6 +116,7 @@ export interface GroupPayload { image_prompt_template: string; image_theme?: string; image_theme_custom?: string; + image_theme_apply_count?: number; image_prompt_override?: string; wechat_send_enabled?: boolean; } @@ -404,6 +406,7 @@ export interface GroupImagePromptConfig { revision: string; image_theme: string; image_theme_custom: string; + image_theme_remaining_runs?: number; resolved_theme: Record; preview: string; } @@ -411,6 +414,7 @@ export interface GroupImagePromptConfig { export interface BatchImageThemeSuccess { group_id: number; group_name: string; + remaining_runs: number; } export interface BatchImageThemeFailure { @@ -540,6 +544,7 @@ export const batchUpdateGroupImageTheme = (body: { group_ids: number[]; image_theme: string; image_theme_custom: string; + image_theme_apply_count: number; }) => put("/groups/batch/image-theme", body); export const verifyGroupSendTarget = (groupId: number) => post<{ ok: boolean; target: string; detail: string }>(`/groups/${groupId}/verify-send-target`); diff --git a/frontend/src/pages/v2/ai-images/ImageStylePanel.tsx b/frontend/src/pages/v2/ai-images/ImageStylePanel.tsx index 289b682..ebf0397 100644 --- a/frontend/src/pages/v2/ai-images/ImageStylePanel.tsx +++ b/frontend/src/pages/v2/ai-images/ImageStylePanel.tsx @@ -28,8 +28,12 @@ function groupName(group: GroupV2): string { } function themeLabel(group: GroupV2, themes: ImageThemeOption[]): string { - if (group.image_theme === "custom") return group.image_theme_custom || "自定义描述"; - return themes.find((theme) => theme.key === group.image_theme)?.label || group.image_theme || "AI 自由发挥"; + const label = group.image_theme === "custom" + ? group.image_theme_custom || "自定义描述" + : themes.find((theme) => theme.key === group.image_theme)?.label || group.image_theme || "AI 自由发挥"; + return (group.image_theme_remaining_runs ?? 0) > 0 + ? `${label}(剩余 ${group.image_theme_remaining_runs} 次)` + : label; } export function ImageStylePanel({ @@ -47,6 +51,7 @@ export function ImageStylePanel({ const [custom, setCustom] = useState(""); const [themeText, setThemeText] = useState(""); const [themeConfirmed, setThemeConfirmed] = useState(false); + const [applyCount, setApplyCount] = useState(1); const [themeError, setThemeError] = useState(""); const [saving, setSaving] = useState(false); const [result, setResult] = useState(null); @@ -66,8 +71,9 @@ export function ImageStylePanel({ const selectedThemeLabel = theme === "custom" ? custom.trim() || "自定义描述" : themes.find((item) => item.key === theme)?.label || theme; + const limitedTheme = theme !== "ai_free" && theme !== "random_preset"; const sharedPreview = themeConfirmed - ? `【共享视觉风格】\n${themeText || selectedThemeLabel}\n\n【应用范围】\n仅更新所选群的生图风格配置。每个群原有 Prompt 模板、群名、统计周期与内容变量均保持不变。` + ? `【共享视觉风格】\n${themeText || selectedThemeLabel}\n\n【应用范围】\n${limitedTheme ? `用于接下来 ${applyCount} 次成功生图,耗尽后自动回到每日随机。` : "作为持续模式使用,不消耗次数。"}\n每个群原有 Prompt 模板、群名、统计周期与内容变量均保持不变。` : "请先在上方打开风格中心并点击“使用这个风格”。确认后再选择需要同步的群。"; const applyTheme = async (key: string, customValue = "") => { @@ -75,6 +81,7 @@ export function ImageStylePanel({ setTheme(key); setCustom(normalizedCustom); setThemeConfirmed(true); + if (key !== "ai_free" && key !== "random_preset") setApplyCount(1); setThemeError(""); setResult(null); try { @@ -118,12 +125,13 @@ export function ImageStylePanel({ group_ids: requestedIds, image_theme: theme, image_theme_custom: theme === "custom" ? custom.trim() : "", + image_theme_apply_count: limitedTheme ? applyCount : 1, }); setResult(response); setSelectedIds(response.failed.map((item) => item.group_id)); await loadCatalogs(); if (response.status === "success") { - toast(`已把「${selectedThemeLabel}」应用到 ${response.success.length} 个群`); + toast(`已把「${selectedThemeLabel}」应用到 ${response.success.length} 个群${limitedTheme ? `,每群 ${applyCount} 次` : ""}`); } else if (response.status === "partial") { toast(`已保存 ${response.success.length} 个群,${response.failed.length} 个群需要重试`); } else { @@ -163,6 +171,14 @@ export function ImageStylePanel({ {themeConfirmed ? : } {themeConfirmed ? `已确认:${selectedThemeLabel}` : "尚未确认风格,群选择不会触发保存"} + {themeConfirmed && limitedTheme && ( + + )}
共享风格注入预览
{sharedPreview}
@@ -190,7 +206,7 @@ export function ImageStylePanel({
- {themeConfirmed ? `将「${selectedThemeLabel}」应用到后续新运行` : "请先确认共享风格"} + {themeConfirmed ? `将「${selectedThemeLabel}」应用到后续${limitedTheme ? ` ${applyCount} 次成功生图` : "所有新运行"}` : "请先确认共享风格"}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index fee82ee..7c999e4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -7032,6 +7032,44 @@ textarea:focus-visible { color: var(--success); } +.ai-images-theme-use-count { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 10px; + padding: 10px 11px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--card); +} + +.ai-images-theme-use-count > span { + display: grid; + gap: 2px; +} + +.ai-images-theme-use-count b { + font-size: 11px; +} + +.ai-images-theme-use-count small { + color: var(--text-secondary); + font-size: 9.5px; +} + +.ai-images-theme-use-count select { + min-width: 86px; + min-height: 34px; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0 9px; + color: var(--text); + background: var(--bg); + font: inherit; + font-size: 11px; +} + .ai-images-default-preview pre { min-height: 112px; max-height: 150px; diff --git a/tests/test_group_image_theme_batch_api.py b/tests/test_group_image_theme_batch_api.py index e2f3286..3728b51 100644 --- a/tests/test_group_image_theme_batch_api.py +++ b/tests/test_group_image_theme_batch_api.py @@ -82,6 +82,7 @@ def test_batch_image_theme_updates_multiple_groups_and_only_theme_fields(batch_c "group_ids": [first_id, disabled_id], "image_theme": "custom", "image_theme_custom": " 低饱和黏土摄影 ", + "image_theme_apply_count": 3, }, ) @@ -90,8 +91,8 @@ def test_batch_image_theme_updates_multiple_groups_and_only_theme_fields(batch_c "status": "success", "requested_count": 2, "success": [ - {"group_id": first_id, "group_name": "一群"}, - {"group_id": disabled_id, "group_name": "停用群"}, + {"group_id": first_id, "group_name": "一群", "remaining_runs": 3}, + {"group_id": disabled_id, "group_name": "停用群", "remaining_runs": 3}, ], "failed": [], } @@ -99,8 +100,14 @@ def test_batch_image_theme_updates_multiple_groups_and_only_theme_fields(batch_c after = snapshot_group(engine, group_id) assert after["image_theme"] == "custom" assert after["image_theme_custom"] == "低饱和黏土摄影" + assert after["image_theme_remaining_runs"] == 3 assert after["updated_at"] >= before[group_id]["updated_at"] - unchanged = set(before[group_id]) - {"image_theme", "image_theme_custom", "updated_at"} + unchanged = set(before[group_id]) - { + "image_theme", + "image_theme_custom", + "image_theme_remaining_runs", + "updated_at", + } assert {field: after[field] for field in unchanged} == { field: before[group_id][field] for field in unchanged } @@ -133,7 +140,7 @@ def test_batch_image_theme_reports_missing_and_deleted_without_rolling_back_succ assert response.json() == { "status": "partial", "requested_count": 3, - "success": [{"group_id": active_id, "group_name": "正常群"}], + "success": [{"group_id": active_id, "group_name": "正常群", "remaining_runs": 0}], "failed": [ {"group_id": missing_id, "code": "GROUP_NOT_FOUND", "reason": "群不存在"}, {"group_id": deleted_id, "code": "GROUP_DELETED", "reason": "群已移入回收站"}, @@ -141,6 +148,7 @@ def test_batch_image_theme_reports_missing_and_deleted_without_rolling_back_succ } assert snapshot_group(engine, active_id)["image_theme"] == "random_preset" assert snapshot_group(engine, active_id)["image_theme_custom"] == "" + assert snapshot_group(engine, active_id)["image_theme_remaining_runs"] == 0 assert snapshot_group(engine, deleted_id)["image_theme"] == "ai_free" @@ -184,6 +192,8 @@ def test_batch_image_theme_returns_failed_when_every_target_is_invalid(batch_cli {"group_ids": [1], "image_theme": "custom", "image_theme_custom": ""}, {"group_ids": [1], "image_theme": "custom", "image_theme_custom": "a" * 81}, {"group_ids": [1], "image_theme": "custom", "image_theme_custom": "多行\n主题"}, + {"group_ids": [1], "image_theme": "custom", "image_theme_custom": "主题", "image_theme_apply_count": 0}, + {"group_ids": [1], "image_theme": "custom", "image_theme_custom": "主题", "image_theme_apply_count": 31}, ], ) def test_batch_image_theme_global_validation_is_422_with_zero_writes(batch_client, payload): @@ -230,5 +240,60 @@ def fail_middle(session, group_id, **values): "reason": "数据库保存失败,请重试", }] assert snapshot_group(engine, first_id)["image_theme"] == "ink_wash_editorial" + assert snapshot_group(engine, first_id)["image_theme_remaining_runs"] == 1 assert snapshot_group(engine, failed_id)["image_theme"] == "ai_free" assert snapshot_group(engine, last_id)["image_theme"] == "ink_wash_editorial" + assert snapshot_group(engine, last_id)["image_theme_remaining_runs"] == 1 + + +def test_theme_consumption_is_idempotent_and_returns_to_random(batch_client): + _client, engine = batch_client + group_id = save_group(engine, "一次主题群") + with Session(engine) as session: + repo.update_group_image_theme( + session, + group_id, + image_theme="custom", + image_theme_custom="奥特曼", + image_theme_remaining_runs=2, + ) + + with Session(engine) as session: + first = repo.consume_group_image_theme_for_run( + session, + group_id, + run_date="2026-09-05", + expected_theme="custom", + expected_custom="奥特曼", + ) + with Session(engine) as session: + duplicate = repo.consume_group_image_theme_for_run( + session, + group_id, + run_date="2026-09-05", + expected_theme="custom", + expected_custom="奥特曼", + ) + with Session(engine) as session: + second = repo.consume_group_image_theme_for_run( + session, + group_id, + run_date="2026-09-06", + expected_theme="custom", + expected_custom="奥特曼", + ) + group = session.get(Group, group_id) + + assert first == { + "consumed": True, + "already_consumed": False, + "remaining_runs": 1, + "next_theme": "custom", + } + assert duplicate["already_consumed"] is True + assert duplicate["remaining_runs"] == 1 + assert second["consumed"] is True + assert second["remaining_runs"] == 0 + assert second["next_theme"] == "random_preset" + assert group.image_theme == "random_preset" + assert group.image_theme_custom == "" diff --git a/tests/test_handoff.py b/tests/test_handoff.py index 7930476..b347bcc 100644 --- a/tests/test_handoff.py +++ b/tests/test_handoff.py @@ -106,10 +106,26 @@ def test_generate_writes_files(): def test_two_groups_isolated(): with Session(repo.engine) as session: - _get_or_create_group(session, "产品经理交流群", "group-b") + # 本用例必须可独立运行,不能依赖 test_generate_writes_files 先创建 A 群。 + first_group = _get_or_create_group(session, "示例UED-4群", "group-a") + second_group = _get_or_create_group(session, "产品经理交流群", "group-b") service = _test_report_service() - run = service.generate(session, report_date="2026-08-13", trigger_type="auto", force=True) - assert run.status == "success" + first_run = service.generate( + session, + group=first_group, + report_date="2026-08-13", + trigger_type="auto", + force=True, + ) + second_run = service.generate( + session, + group=second_group, + report_date="2026-08-13", + trigger_type="auto", + force=True, + ) + assert first_run.status == "success" + assert second_run.status == "success" day_dir = settings.output_dir / "2026-08-13" dirs = {p.name for p in day_dir.iterdir() if p.is_dir()} diff --git a/tests/test_image_fact_verification.py b/tests/test_image_fact_verification.py index 2241887..a271d17 100644 --- a/tests/test_image_fact_verification.py +++ b/tests/test_image_fact_verification.py @@ -8,7 +8,10 @@ from PIL import Image from app.ai.strict_prompt_contract import append_strict_image_fact_contract -from app.image.fact_verification import review_image_facts +from app.image.fact_verification import ( + review_image_facts, + strip_unverified_prompt_numeric_units, +) def _evidence(tmp_path: Path) -> tuple[Path, Path]: @@ -209,6 +212,32 @@ def test_allows_deterministic_header_numbers_and_currency_alias(tmp_path): assert review.unknown_numeric == () +def test_strips_unit_inferred_by_summary_but_keeps_evidenced_currency_alias(tmp_path): + prompt = tmp_path / "image_prompt.txt" + prompt.write_text( + "大毯子的价格为45元。另一个道具价值38元,倍率为1218.2。", + encoding="utf-8", + ) + (tmp_path / "messages.json").write_text( + json.dumps( + [ + {"sender_name": "甲", "content": "大毯子45"}, + {"sender_name": "乙", "content": "另一个道具38块"}, + {"sender_name": "丙", "content": "倍率1218.2"}, + ], + ensure_ascii=False, + ), + encoding="utf-8", + ) + + sanitized, stripped = strip_unverified_prompt_numeric_units(prompt) + + assert "大毯子的价格为45。" in sanitized + assert "价值38元" in sanitized + assert "倍率为1218.2" in sanitized + assert stripped == ("45元",) + + def test_strict_contract_removes_bmi_display_instructions(): prompt = """【版面4】 手指一路猜到BMI diff --git a/tests/test_v2_image_task.py b/tests/test_v2_image_task.py index d5a5fc1..7c4e63e 100644 --- a/tests/test_v2_image_task.py +++ b/tests/test_v2_image_task.py @@ -161,16 +161,15 @@ def test_single_failure_does_not_block_others(tmp_path): ] results = queue.run_all(jobs) assert [r["status"] for r in results] == [ - "diagnostic_fallback", + "failed", "success", - "diagnostic_fallback", + "failed", ] assert [r["success"] for r in results] == [False, True, False] - assert results[0]["generator_detail"]["fallback_level"] == 3 - # 外部失败的群保留不可发送诊断图,其他群仍独立成功。 - for job in jobs: - ok, _ = verify_image(job.output_path) - assert ok is True + assert results[0]["generator_detail"]["local_infographic_disabled"] is True + assert not jobs[0].output_path.exists() + assert verify_image(jobs[1].output_path)[0] is True + assert not jobs[2].output_path.exists() def test_policy_rejection_uses_safe_prompt_once(tmp_path): @@ -242,7 +241,7 @@ def generate(self, prompt_file, output_path): assert not job.output_path.exists() -def test_strict_verification_known_failure_keeps_diagnostic_fallback_failed(tmp_path, monkeypatch): +def test_strict_verification_known_failure_does_not_create_statistical_fallback(tmp_path, monkeypatch): from app.image.image_task import ImageTaskResult class QualityRetryFailsKnown: @@ -263,7 +262,6 @@ def generate(self, _prompt_file, output_path, **_kwargs): generator = QualityRetryFailsKnown() job = _job(tmp_path, "群1", generator) - fallback_calls = [] monkeypatch.setattr( "app.image.image_task.verify_image_contract", lambda *_args, **_kwargs: (False, "图片事实校验失败"), @@ -272,27 +270,55 @@ def generate(self, _prompt_file, output_path, **_kwargs): "app.image.fact_verification.strict_fact_verification_enabled", lambda _path: True, ) - monkeypatch.setattr( - ImageJob, - "_local_fallback", - lambda self, reason: fallback_calls.append(reason) - or { - "group_name": self.group_name, - "status": "diagnostic_fallback", - "success": False, - "detail": "local fallback", - "error_type": "IMAGE_CONTENT_VERIFICATION_FAILED", - "generator_detail": {"fallback_level": 3, "image_variant": "pillow"}, - }, - ) - result = job.run() - assert result["status"] == "diagnostic_fallback" + assert result["status"] == "failed" assert result["success"] is False assert result["error_type"] == "IMAGE_CONTENT_VERIFICATION_FAILED" assert generator.calls == 2 - assert fallback_calls == ["IMAGE_CONTENT_VERIFICATION_FAILED"] + assert result["generator_detail"]["local_infographic_disabled"] is True + assert not job.output_path.exists() + + +def test_fact_verification_retry_uses_correction_prompt(tmp_path, monkeypatch): + from app.image.image_task import ImageTaskResult + + class CapturingGenerator: + def __init__(self): + self.prompts: list[Path] = [] + + def generate(self, prompt_file, output_path, **_kwargs): + self.prompts.append(Path(prompt_file)) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(_PNG_1PX) + return ImageTaskResult(True, image_path=output_path) + + generator = CapturingGenerator() + job = _job(tmp_path, "纠错重画群", generator) + verification_results = iter( + [ + (False, "图片文件不存在"), + (False, "图片事实校验失败:无证据数字:45元, 11218"), + (True, "OK"), + ] + ) + monkeypatch.setattr( + "app.image.image_task.verify_image_contract", + lambda *_args, **_kwargs: next(verification_results), + ) + monkeypatch.setattr( + "app.image.fact_verification.strict_fact_verification_enabled", + lambda _path: True, + ) + + result = job.run() + + assert result["status"] == "success" + assert len(generator.prompts) == 2 + assert generator.prompts[1].name == "image_prompt.fact_retry.txt" + correction = generator.prompts[1].read_text(encoding="utf-8") + assert "新图不得出现上述字符串" in correction + assert "45元、11218" in correction def test_retry_does_not_treat_existing_diagnostic_png_as_success(tmp_path): @@ -318,6 +344,53 @@ def test_retry_does_not_treat_existing_diagnostic_png_as_success(tmp_path): assert len(generator.calls) == 1 +def test_retry_rejects_diagnostic_reused_after_metadata_was_reset(tmp_path): + generator = FakeGenerator() + job = _job(tmp_path, "诊断图元数据误清理群", generator) + job.output_path.parent.mkdir(parents=True, exist_ok=True) + job.output_path.write_bytes(_PNG_1PX) + (job.output_path.parent / "run.json").write_text( + json.dumps( + { + "image_fallback_level": 0, + "image_variant": "normal", + "image_recovery_status": "existing_output_reused", + "last_error_summary": "图片生成失败,已保留不可发送的本地诊断图", + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + result = job.run() + + assert result["status"] == "success" + assert len(generator.calls) == 1 + + +@pytest.mark.parametrize( + "run_state", + [ + {"image_status": "failed"}, + {"image_job": {"status": "ambiguous_result"}}, + ], +) +def test_retry_never_reuses_failed_or_ambiguous_existing_output(tmp_path, run_state): + generator = FakeGenerator() + job = _job(tmp_path, "失败结果残留群", generator) + job.output_path.parent.mkdir(parents=True, exist_ok=True) + job.output_path.write_bytes(_PNG_1PX) + (job.output_path.parent / "run.json").write_text( + json.dumps(run_state, ensure_ascii=False), + encoding="utf-8", + ) + + result = job.run() + + assert result["status"] == "success" + assert len(generator.calls) == 1 + + def test_strict_verification_unknown_retry_stays_failed_closed(tmp_path, monkeypatch): from app.image.image_task import ImageTaskResult diff --git a/tests/test_v2_pipeline.py b/tests/test_v2_pipeline.py index c427841..0b6542d 100644 --- a/tests/test_v2_pipeline.py +++ b/tests/test_v2_pipeline.py @@ -246,7 +246,7 @@ def send_text(self, target: str, text: str): ) -def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, image_enabled=True, send_time="08:30", image_theme="blue_white", image_theme_custom="", schedule_rule="daily_previous_day"): +def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, image_enabled=True, send_time="08:30", image_theme="blue_white", image_theme_custom="", image_theme_remaining_runs=0, schedule_rule="daily_previous_day"): from app.config.settings import get_settings source = source or FakeSource() @@ -271,6 +271,7 @@ def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, im image_enabled=image_enabled, image_theme=image_theme, image_theme_custom=image_theme_custom, + image_theme_remaining_runs=image_theme_remaining_runs, wechat_send_enabled=True, ) else: @@ -282,6 +283,7 @@ def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, im group.send_time = send_time group.image_theme = image_theme group.image_theme_custom = image_theme_custom + group.image_theme_remaining_runs = image_theme_remaining_runs group.wechat_send_enabled = True group = repo.save_group(session, group) @@ -678,6 +680,56 @@ def test_pipeline_passes_group_theme_and_records_request_metadata(tmp_path): assert run["image_theme_custom"] == "可切回的旧主题" +def test_custom_theme_consumes_once_per_new_run_and_then_returns_to_random(tmp_path): + prompt = FakePrompt() + pipeline, group = _make_pipeline( + tmp_path, + prompt=prompt, + image_theme="custom", + image_theme_custom="奥特曼", + image_theme_remaining_runs=2, + ) + + first = pipeline.generate_all(run_date="2099-01-05") + repeated = pipeline.generate_all(run_date="2099-01-05", force=True) + with Session(repo.engine) as session: + after_first = repo.get_group(session, group.id) + assert after_first.image_theme == "custom" + assert after_first.image_theme_remaining_runs == 1 + + second = pipeline.generate_all(run_date="2099-01-06") + with Session(repo.engine) as session: + after_second = repo.get_group(session, group.id) + + assert first[0]["status"] == "ready_to_send" + assert repeated[0]["status"] == "ready_to_send" + assert second[0]["status"] == "ready_to_send" + assert [item.image_theme for item in prompt.inputs] == ["custom", "custom", "custom"] + assert all(item.image_theme_custom == "奥特曼" for item in prompt.inputs) + assert after_second.image_theme == "random_preset" + assert after_second.image_theme_custom == "" + assert after_second.image_theme_remaining_runs == 0 + + +def test_failed_image_does_not_consume_custom_theme(tmp_path): + pipeline, group = _make_pipeline( + tmp_path, + gen=FakeGenerator(fail=True), + image_theme="custom", + image_theme_custom="奥特曼", + image_theme_remaining_runs=1, + ) + + result = pipeline.generate_all(run_date="2099-01-07") + with Session(repo.engine) as session: + after_failure = repo.get_group(session, group.id) + + assert result[0]["status"] == "failed" + assert after_failure.image_theme == "custom" + assert after_failure.image_theme_custom == "奥特曼" + assert after_failure.image_theme_remaining_runs == 1 + + def test_prompt_visible_group_name_prefers_name_saved_in_run(tmp_path): prompt = FakePrompt() pipeline, group = _make_pipeline(tmp_path, prompt=prompt, image_enabled=False) @@ -753,7 +805,7 @@ def test_force_generate_blocks_corrupt_state_before_name_sync(tmp_path, monkeypa assert run_path.read_bytes() == original -def test_force_generate_image_failure_keeps_diagnostic_fallback_failed(tmp_path): +def test_force_generate_image_failure_does_not_create_statistical_fallback(tmp_path): gen = FakeGenerator(fail=True) pipeline, group = _make_pipeline(tmp_path, gen=gen) result = pipeline.force_generate(group.id, "2026-08-18") @@ -762,14 +814,13 @@ def test_force_generate_image_failure_keeps_diagnostic_fallback_failed(tmp_path) assert run["status"] == FAILED assert run["failed_stage"] == "image" assert run["error_type"] == IMAGE_GENERATION_FAILED - assert run["image_status"] == "diagnostic_fallback" - assert run["image_fallback_level"] == 3 - assert run["image_variant"] == "pillow" - assert run["image_job"]["status"] == "diagnostic_fallback" + assert run["image_status"] == "failed" + assert run["image_fallback_level"] == 0 + assert run["image_variant"] == "normal" + assert run["image_job"]["status"] == "failed" assert run["image_job"]["receipt"]["image_path"] == "" - assert run["image_job"]["receipt"]["diagnostic_path"] - assert len(run["image_job"]["receipt"]["sha256"]) == 64 - assert pipeline.store.image_path("测试群", "2026-08-18").is_file() + assert run["image_job"]["receipt"]["diagnostic_path"] == "" + assert not pipeline.store.image_path("测试群", "2026-08-18").exists() def test_image_success_clears_stale_failure_fields(tmp_path): @@ -837,7 +888,7 @@ def test_generate_data_failure_marks_failed(tmp_path): assert run["failed_stage"] == "data" -def test_generate_prompt_failure_keeps_local_infographic_as_failed_diagnostic(tmp_path): +def test_generate_prompt_failure_stops_without_local_infographic(tmp_path): preserved_meta = { "topic_selection": { "political_keyword_policy_version": "political-keywords-v1", @@ -857,15 +908,10 @@ def test_generate_prompt_failure_keeps_local_infographic_as_failed_diagnostic(tm assert results[0]["error_type"] == "PROMPT_FAILED" run = pipeline.store.load_run("测试群", "2026-08-18") assert run["status"] == FAILED - assert run["failed_stage"] == "image" - assert run["image_status"] == "diagnostic_fallback" - assert run["prompt_fallback_level"] == 3 - assert run["image_fallback_level"] == 3 - assert run["image_variant"] == "pillow" - assert run["prompt_meta"]["topic_selection"] == preserved_meta["topic_selection"] - assert run["prompt_meta"]["layout_id"] == "split_focus" - assert run["prompt_meta"]["mode"] == "local_infographic" - assert pipeline.store.image_path("测试群", "2026-08-18").is_file() + assert run["failed_stage"] == "prompt" + assert run["prompt_fallback_level"] == 0 + assert run["image_force_local_fallback"] is False + assert not pipeline.store.image_path("测试群", "2026-08-18").exists() def test_generate_one_safe_topic_still_calls_normal_image_generator(tmp_path): @@ -1121,6 +1167,26 @@ def test_send_scan_rejects_diagnostic_fallback_without_sender_calls( assert pipeline.sender.image_calls == [] +def test_send_scan_rejects_diagnostic_image_reused_after_metadata_reset(tmp_path): + pipeline = _ready_to_send(tmp_path) + pipeline.store.update( + "测试群", + "2026-08-18", + status=READY_TO_SEND, + image_fallback_level=0, + image_variant="normal", + image_recovery_status="existing_output_reused", + last_error_summary="图片生成失败,已保留不可发送的本地诊断图", + ) + + result = pipeline.send_due(now=datetime(2026, 8, 18, 9, 0, 0))[0] + + assert result["status"] == "failed" + assert result["error_type"] == IMAGE_FALLBACK_NOT_SENDABLE + assert pipeline.sender.text_calls == [] + assert pipeline.sender.image_calls == [] + + def test_run_store_send_claim_rejects_diagnostic_fallback(tmp_path): store = RunStore(tmp_path / "output") store.save_run(