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
62 changes: 62 additions & 0 deletions GROUPBRIEF_L3_DIAGNOSTIC_FALLBACK_GUARD_TASK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 茶馆 Level 3 诊断图禁发任务

## 背景

茶馆日报在 Prompt 连续校验失败后生成了 Pillow Level 3 本地信息图。该文件本应仅供诊断,但旧流程把它记录为生图成功并推进到自动发送,最终在 2026-09-03 08:36 发送。Dashboard 同时以 `object-fit: cover` 裁切了竖版预览。

## 目标

- 保留 PR #16 已有的按真实 `sender_id` 去重、参与者补足和越权消息 ID 校验。
- 将 Level 3/Pillow 兜底定义为“已保存诊断产物的图片生成失败”,不得推进到发送就绪。
- 在发送扫描、原子 claim、发送前预检三层拒绝诊断图,统一错误码 `IMAGE_FALLBACK_NOT_SENDABLE`。
- 让 Dashboard 同时保留历史发送事实并明确展示图片失败、诊断图不可发送和完整竖图预览。

## 允许修改范围

- `app/image/`、`app/pipeline/`、`app/v2/`、`app/scheduler/` 中与图片状态和发送门禁直接相关的代码。
- `app/api/v2_ui_read.py` 的 Dashboard 只读投影。
- `frontend/src/api.ts`、`frontend/src/pages/v2/Dashboard.tsx`、`frontend/src/styles.css`。
- 与上述行为直接相关的后端测试、前端单测和 Playwright 测试。
- 本任务说明文件与 PR #16 描述。

## 禁止修改范围

- 不修改数据库结构或执行迁移。
- 不读取或修改 `.env`、认证、Provider、登录方式及任何密钥。
- 不修改既有 `output/`、`run.json`、PNG、发送记录或生产日志。
- 不调用生成、恢复、发送、重发、发布、部署或服务重启接口。
- 不改动主工作树的 `.codemap` 和复检报告。

## 已确定实现要求

1. Level 3/Pillow 允许落盘诊断 PNG、失败原因及 SHA-256,但任务返回失败并停在图片阶段 `FAILED`。
2. 自动重试仍受现有预算限制;诊断 PNG 不得被“已有有效图片”逻辑误判为正常成功。后续真实图片成功时清除兜底元数据并恢复正常流程。
3. 发送扫描、claim 和发送前预检均拒绝 `image_fallback_level >= 3` 或 `image_variant=pillow`,且不得调用文字或图片发送器。
4. Dashboard API 返回 `image_status`、`image_fallback_level`、`image_fallback_reason`、`image_variant`、`image_delivery_eligible`。
5. 历史 `SENT + Level 3` 保留“已发送”,同时将图片节点投影为失败,卡片显示“图片生成失败(已发送)”和“诊断图不可发送”,且不出现重发入口。
6. Dashboard 图片预览使用完整适配;普通有效图片和关闭生图的流程保持不变。

## 验收标准

- PR #16 的人物去重/补足/消息归属测试继续通过。
- Prompt、Provider、事实校验失败生成诊断图后,状态为图片失败而非待发送。
- 构造遗留 `READY_TO_SEND + Level 3/Pillow` 时,扫描、claim、发送前预检均返回统一错误码,发送器调用数为零。
- 真实图片成功、关闭生图、诊断后重试成功等流程通过。
- 历史 `SENT + Level 3` 的 API 与 UI 同时呈现发送事实和图片失败,无发送按钮。
- 后端相关测试和全量测试、Python 编译检查、前端单测、Playwright、前端构建及 `git diff --check` 全部通过。

## 测试命令

```powershell
python -m pytest tests/test_v2_prompt_builder.py tests/test_v2_image_task.py tests/test_v2_pipeline.py tests/test_runtime_status.py tests/test_v2_ui_router_contract.py -q
python -m pytest tests -q
python -m compileall -q app scripts tests
npm test
npm run test:e2e
npm run build
git diff --check
```

## 返回格式

返回修改摘要、根因闭环、测试命令与结果、实际 diff 范围、提交 SHA、远端 SHA、PR #16 地址与 CI 状态;明确说明未重发、未重启、未合并。
7 changes: 5 additions & 2 deletions app/ai/layouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ def to_meta(self) -> dict[str, Any]:
dialogue、reaction、close_up、punchline、insert。全部话题必须恰好出现一次于 panel_beats,
topic_order 也必须恰好覆盖全部话题。

只有 1 个入选话题时必须使用 hero_rhythm,并把该话题作为唯一 featured_topic_id;
不得为了满足双焦点结构复制或虚构第二个话题。

根据内容选择 hero_rhythm、dual_rhythm 或 ensemble_rhythm。无论哪种模式,都必须保留明显的
大/中/小格层级,禁止等宽等高列表。指定风格只控制配色、画材、造型、纹理和光影;版式不能改写画风。
优先选择最符合笑点节奏的分镜骨架;不得选择前一次使用的骨架,并尽量避开最近三次。"""
Expand Down Expand Up @@ -334,8 +337,8 @@ def build_layout_director_prompt(

def _expected_topic_ids(topic_ids: Iterable[str]) -> list[str]:
expected = [str(topic_id) for topic_id in topic_ids if str(topic_id)]
if not (2 <= len(expected) <= 7) or len(set(expected)) != len(expected):
raise LayoutPlanError("入选主题必须是 2~7 个不重复 ID")
if not (1 <= len(expected) <= 7) or len(set(expected)) != len(expected):
raise LayoutPlanError("入选主题必须是 1~7 个不重复 ID")
return expected


Expand Down
120 changes: 95 additions & 25 deletions app/ai/poster_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ def _selected_topics(selection: dict, topic_order: Iterable[str]) -> list[dict[s
return ordered


def _participant_identity(sender_id: object, speaker: object) -> tuple[str, str]:
normalized_sender_id = str(sender_id or "").strip().casefold()
normalized_speaker = str(speaker or "").strip()
return (
("id", normalized_sender_id)
if normalized_sender_id
else ("name", normalized_speaker)
)


def build_poster_editor_source(
selection: dict,
layout: LayoutPlan,
Expand Down Expand Up @@ -142,6 +152,12 @@ def build_poster_editor_source(
name = entry["speaker"]
if name not in participants:
participants.append(name)
available_identities = {
_participant_identity(entry["sender_id"], entry["speaker"])
for entry in evidence_dialogue
}
participant_min = 2 if len(available_identities) >= 2 else 1
participant_max = min(MAX_VISIBLE_PARTICIPANTS, len(available_identities))
beat = beats.get(topic_id)
topics.append(
{
Expand All @@ -150,6 +166,8 @@ def build_poster_editor_source(
"source_summary": str(item.get("summary") or "").strip(),
"source_visual_gag": str(item.get("visual_gag") or "").strip(),
"participant_options": participants,
"participant_min": participant_min,
"participant_max": participant_max,
"evidence_dialogue": evidence_dialogue,
"speaker_bindings": evidence_dialogue,
"shot_hints": [
Expand All @@ -173,7 +191,8 @@ def build_poster_editor_source(
POSTER_EDITOR_SYSTEM = """你是「群报 GroupBrief」的漫画日报内容编辑。
你只能根据给定的 evidence package 写一个 JSON 对象,不得输出 Markdown 或解释。
所有事实、姓名和对白必须来自对应 topic;不得改变金额、时间、地点和人物关系。
每个 panel 必须对应一个 topic_id,顺序不得改变。优先使用 2~4 位 participant_options;
每个 panel 必须对应一个 topic_id,顺序不得改变。participants 数量必须严格落在对应 topic 的
participant_min~participant_max 范围内;同一 sender_id 只能出现一次;
每位人物都要从 speaker_bindings 选择一个真实 message_id,并写可绘制的动作、站位或反应。
不得输出或改写人物姓名;程序会从 message_id 绑定中填入姓名。quote 只能逐字复制同一
message_id 的完整原消息或其中连续、语义完整的片段,不得跨消息拼接,不得改写,
Expand Down Expand Up @@ -334,7 +353,12 @@ def _assert_text_grounded(text: str, evidence: str, label: str) -> None:
raise PosterCopyError(f"{label}没有回收到对应真实话题")


def parse_poster_copy(raw: str, source: dict[str, Any]) -> DailyPosterCopy:
def parse_poster_copy(
raw: str,
source: dict[str, Any],
*,
repair_log: list[dict[str, Any]] | None = None,
) -> DailyPosterCopy:
try:
payload = json.loads(_strip_json_fence(raw))
except json.JSONDecodeError as exc:
Expand Down Expand Up @@ -390,22 +414,21 @@ def parse_poster_copy(raw: str, source: dict[str, Any]) -> DailyPosterCopy:
participant_options = [str(value).strip() for value in topic.get("participant_options") or [] if str(value).strip()]
bindings = _bindings_by_message_id(topic)
available_identities = {
("id", binding["sender_id"].casefold())
if binding["sender_id"]
else ("name", binding["speaker"])
_participant_identity(binding["sender_id"], binding["speaker"])
for binding in bindings.values()
}
raw_participants = raw_panel.get("participants")
if not isinstance(raw_participants, list):
raise PosterCopyError(f"版面{index}缺少 participants")
minimum = 2 if len(available_identities) >= 2 else 1
maximum = min(MAX_VISIBLE_PARTICIPANTS, len(available_identities))
if not bindings or not (minimum <= len(raw_participants) <= maximum):
if not bindings:
raise PosterCopyError(f"版面{index}应使用 {minimum}~{maximum} 位真实参与者")

participants: list[ParticipantCopy] = []
identities: set[tuple[str, str]] = set()
quoted_speakers: set[tuple[str, str]] = set()
participants_by_identity: dict[tuple[str, str], ParticipantCopy] = {}
identity_order: list[tuple[str, str]] = []
duplicate_count = 0
trimmed_count = 0
for person_index, raw_person in enumerate(raw_participants, start=1):
if not isinstance(raw_person, dict):
raise PosterCopyError(f"版面{index}第{person_index}位人物格式无效")
Expand All @@ -417,10 +440,9 @@ def parse_poster_copy(raw: str, source: dict[str, Any]) -> DailyPosterCopy:
raise PosterCopyError(f"版面{index}包含未授权的消息ID:{message_id or '空'}")
name = binding["speaker"]
sender_id = binding["sender_id"]
identity = ("id", sender_id.casefold()) if sender_id else ("name", name)
if name not in participant_options or identity in identities:
raise PosterCopyError(f"版面{index}包含未授权或重复的群友身份:{name}")
identities.add(identity)
identity = _participant_identity(sender_id, name)
if name not in participant_options:
raise PosterCopyError(f"版面{index}包含未授权的群友身份:{name}")
action = _clean_text(raw_person.get("action"), maximum=120, label=f"版面{index}人物动作")
quote = re.sub(r"\s+", " ", str(raw_person.get("quote") or "")).strip().strip("“”\"")
if quote:
Expand All @@ -442,21 +464,69 @@ def parse_poster_copy(raw: str, source: dict[str, Any]) -> DailyPosterCopy:
source_messages,
),
)
quoted_speakers.add(identity)
participants.append(
ParticipantCopy(
message_id=message_id,
sender_id=sender_id,
participant = ParticipantCopy(
message_id=message_id,
sender_id=sender_id,
name=name,
action=action,
quote=quote,
)
if identity in participants_by_identity:
duplicate_count += 1
if not participants_by_identity[identity].quote and participant.quote:
participants_by_identity[identity] = participant
continue
if len(identity_order) >= maximum:
trimmed_count += 1
continue
identity_order.append(identity)
participants_by_identity[identity] = participant

model_identity_count = len(identity_order)
filled_count = 0
if len(identity_order) < minimum:
for binding in bindings.values():
name = binding["speaker"]
identity = _participant_identity(binding["sender_id"], name)
if identity in participants_by_identity or name not in participant_options:
continue
identity_order.append(identity)
participants_by_identity[identity] = ParticipantCopy(
message_id=binding["message_id"],
sender_id=binding["sender_id"],
name=name,
action=action,
quote=quote,
action="参与本话题讨论",
quote="",
)
)
filled_count += 1
if len(identity_order) >= minimum:
break
if not (minimum <= len(identity_order) <= maximum):
raise PosterCopyError(f"版面{index}应使用 {minimum}~{maximum} 位真实参与者")

required_quotes = 2 if len(available_identities) >= 2 and len(participants) >= 2 else 1
participants = [participants_by_identity[identity] for identity in identity_order]
quoted_speakers = {
_participant_identity(participant.sender_id, participant.name)
for participant in participants
if participant.quote
}
# 自动补齐的人物对白保持为空,不能伪造原话;模型至少仍须提供一位真实气泡。
required_quotes = 2 if model_identity_count >= 2 else 1
if len(quoted_speakers) < required_quotes:
raise PosterCopyError(f"版面{index}缺少足够的真实多人对白")

if repair_log is not None and (duplicate_count or trimmed_count or filled_count):
repair_log.append(
{
"panel_index": index,
"topic_id": topic_id,
"deduplicated_count": duplicate_count,
"trimmed_count": trimmed_count,
"filled_count": filled_count,
"final_participant_count": len(participants),
}
)

panels.append(
PanelCopy(
topic_id=topic_id,
Expand Down Expand Up @@ -501,7 +571,7 @@ def _overall_visual(style_text: str, *, explicit_style: bool) -> str:
"生成一张适合微信手机端阅读的竖版漫画群报,优先采用 1024×1536、2:3 画布;其他完整可读的竖版尺寸也可以采用,不要为了匹配尺寸裁切或拉伸。",
style_line,
"整张图像一页热闹的群聊漫画:顶部是群名称、完整统计时间、主标题和副标题,中间由多个大小错落的话题漫画格组成,底部展示当天总结和统计数据。",
"每个话题都要画成一个真实的“群友讨论现场”,而不是单人物插画。每个话题优先选择 2~4 位真正参与该段聊天的群友出镜,人物旁边直接标注对应的真实群昵称。",
"每个话题都要画成一个真实的“群友讨论现场”。严格按对应版面已经列出的真实参与者出镜,不重复人物、不补无关群友;有多位真实参与者时通常展示 2~4 位,只有 1 位时就只画该人物。人物旁边直接标注对应的真实群昵称。",
"不同群友使用不同动作、表情和站位,以真实聊天气泡、人物反应、道具、动作线和视觉笑点表现讨论过程。所有剧情、人物关系和聊天内容均来自当天真实群聊,不额外编造新的聊天事实。",
)
)
Expand Down Expand Up @@ -621,8 +691,8 @@ def validate_fixed_prompt_contract(
headings = _section_headings(prompt)
panel_headings = [heading for heading in headings if re.fullmatch(r"版面\d+", heading)]
panel_count = len(panel_headings)
if not 2 <= panel_count <= 7:
raise PosterCopyError("最终 Prompt 必须包含连续的 2~7 个版面")
if not 1 <= panel_count <= 7:
raise PosterCopyError("最终 Prompt 必须包含连续的 1~7 个版面")
if expected_panel_count is not None and panel_count != expected_panel_count:
raise PosterCopyError("版面数量与本次已校验入选话题数量不一致")
expected = [
Expand Down
15 changes: 14 additions & 1 deletion app/ai/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from copy import deepcopy
from time import perf_counter
from datetime import datetime
from typing import Any

from app.ai.prompt_templates import (
ImagePromptTemplateError,
Expand Down Expand Up @@ -396,6 +397,10 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]:
selection["speaker_fingerprint"] = speaker_fingerprint
meta["topic_selection_version"] = selection["topic_selection_version"]
meta["topic_selection"] = selection
if selection.get("political_keyword_policy_version"):
meta["political_keyword_policy_version"] = selection[
"political_keyword_policy_version"
]
selected_payload = selected_topics_json(selection)
topic_ids = selected_topic_ids(selection)
recent_history = tuple(data.recent_layout_history or ())[:3]
Expand Down Expand Up @@ -456,6 +461,7 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]:
text = ""
final_calls = 0
last_violations: list[str] = []
participant_repairs: list[dict[str, Any]] = []
for attempt in range(FINAL_PROMPT_MAX_ATTEMPTS):
prompt = final_user_prompt
if attempt:
Expand All @@ -473,8 +479,13 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]:
max_tokens=6000,
)
final_calls += 1
attempt_repairs: list[dict[str, Any]] = []
try:
copy = parse_poster_copy(raw_copy, editor_source)
copy = parse_poster_copy(
raw_copy,
editor_source,
repair_log=attempt_repairs,
)
candidate_text = render_poster_prompt(
copy,
group_name=visible_group_name,
Expand All @@ -495,11 +506,13 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]:
)
continue
text = candidate_text
participant_repairs = attempt_repairs
meta["poster_copy_version"] = POSTER_COPY_VERSION
meta["poster_topic_count"] = len(copy.panels)
meta["poster_visible_participant_count"] = sum(
len(panel.participants) for panel in copy.panels
)
meta["poster_participant_repairs"] = participant_repairs
break
if not text:
raise ValueError("最终生图 Prompt 未通过固定漫画合同:" + ";".join(last_violations[:8]))
Expand Down
Loading