diff --git a/backend/alembic/versions/015_image_candidate_text_review.py b/backend/alembic/versions/015_image_candidate_text_review.py new file mode 100644 index 0000000..e6740cb --- /dev/null +++ b/backend/alembic/versions/015_image_candidate_text_review.py @@ -0,0 +1,33 @@ +"""015 image_candidates 表加 text_review 字段(标题文字校验闸门) + +Revision ID: 015 +Revises: 014 +Create Date: 2026-06-16 + +gpt-image-2 渲染中文偶发错别字。生图后对 hook/特写图做 OCR 校验,错别字自动 +重生(上限2次);仍不过则放行该图并写 text_review 标记,前端提示运营人工筛。 +NULL=未校验或一次通过;有值=曾不过或重生过的记录(倩倩姐2026-06-16拍板)。 +""" +from alembic import op +import sqlalchemy as sa + +revision = "015" +down_revision = "014" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "image_candidates", + sa.Column( + "text_review", + sa.String(512), + nullable=True, + comment="标题文字校验结果JSON(NULL=通过;有值=曾不过/重生,needs_text_review)", + ), + ) + + +def downgrade(): + op.drop_column("image_candidates", "text_review") diff --git a/backend/alembic/versions/016_image_candidate_ai_visual_score.py b/backend/alembic/versions/016_image_candidate_ai_visual_score.py new file mode 100644 index 0000000..dbb9b61 --- /dev/null +++ b/backend/alembic/versions/016_image_candidate_ai_visual_score.py @@ -0,0 +1,38 @@ +"""016 image_candidates 表加 AI 评图分字段(E12 飞轮·展示层) + +Revision ID: 016 +Revises: 015 +Create Date: 2026-06-16 + +E12 AI评图分(倩倩姐2026-06-16拍板):生成时 AI 给图打分,高分优先展示,前端呈现 +"AI评X分"。 + +🔴 红线:AI评图分只做"生成时筛选+前端展示",绝不进飞轮权重(权重只认真实信号: + 选了哪张/改了什么/过审与否)。eval_score 仍留 NULL,AI分单独存 ai_visual_score, + 不复用 eval_score,避免 AI 主观分污染飞轮。 +""" +from alembic import op +import sqlalchemy as sa + +revision = "016" +down_revision = "015" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "image_candidates", + sa.Column("ai_visual_score", sa.Float(), nullable=True, + comment="AI评图分0-100(展示+排序用,绝不进飞轮权重;eval_score另留NULL)"), + ) + op.add_column( + "image_candidates", + sa.Column("ai_visual_note", sa.String(256), nullable=True, + comment="AI评图一句话评语(前端展示)"), + ) + + +def downgrade(): + op.drop_column("image_candidates", "ai_visual_note") + op.drop_column("image_candidates", "ai_visual_score") diff --git a/backend/alembic/versions/017_image_candidate_is_regen.py b/backend/alembic/versions/017_image_candidate_is_regen.py new file mode 100644 index 0000000..363cb75 --- /dev/null +++ b/backend/alembic/versions/017_image_candidate_is_regen.py @@ -0,0 +1,30 @@ +"""017 image_candidates 表加 is_regen 重生标记(R2 单张/单套重生) + +Revision ID: 017 +Revises: 016 +Create Date: 2026-06-12 + +R2 单张/单套重生(倩倩姐2026-06-12拍板顺序P0):用户手动重生某张/某套图时, +新产出 candidate 一律【新增不删旧】(守"数据归客户可导出"红线 + 飞轮能看到重生信号), +用 is_regen 标记区分原始图与重生图。前端同 strategy+role 默认只展示最新那条, +若倩倩姐后续要"对比旧图"也能从历史 candidate 取回,方案前后兼容不返工。 +""" +from alembic import op +import sqlalchemy as sa + +revision = "017" +down_revision = "016" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "image_candidates", + sa.Column("is_regen", sa.Boolean(), nullable=False, server_default=sa.false(), + comment="True=用户手动重生产出(新增不删旧,前端同strategy+role默认只展示最新)"), + ) + + +def downgrade(): + op.drop_column("image_candidates", "is_regen") diff --git a/backend/app/api/v1/task_actions.py b/backend/app/api/v1/task_actions.py index 9c9ff43..b93a3bc 100644 --- a/backend/app/api/v1/task_actions.py +++ b/backend/app/api/v1/task_actions.py @@ -95,22 +95,38 @@ def import_text( return ok(_fmt_text(tc)) +class RegenerateRequest(BaseModel): + """R2 重生参数(全 None=整体重生,兼容旧调用)。 + strategy='A'/'B'/'C' 指定单套;role 指定单张(需配 strategy);custom_prompt 人工追加提示词。""" + strategy: str | None = Field(default=None, pattern="^[ABC]$") + role: str | None = None + custom_prompt: str | None = Field(default=None, max_length=500) + + @router.post("/{task_id}/regenerate") def regenerate( task_id: int, + body: RegenerateRequest | None = None, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): - """重新生成(飞轮信号 regenerate -1)。""" + """重新生成(飞轮信号 regenerate -1)。 + body 全空=整体重生;带 strategy=单套重生;带 strategy+role=单张重生;custom_prompt=人工提示词。""" from app.services.flywheel_service import record_signal from app.services.task_service import enqueue_generation task = _check_task_ownership( db.query(GenerationTask).filter(GenerationTask.id == task_id).first(), current_user.workspace_id, ) + body = body or RegenerateRequest() + # role 必须配 strategy(单张重生需知道是哪套的哪张) + if body.role and not body.strategy: + raise_state_invalid("指定单张重生(role)时必须同时指定 strategy(套别)") record_signal(db, current_user, task, "regenerate") - enqueue_generation(task.id) - return ok({"task_id": task_id, "status": "regenerating"}) + enqueue_generation(task.id, regen_strategy=body.strategy, + regen_role=body.role, custom_prompt=body.custom_prompt) + scope = "单张" if body.role else ("单套" if body.strategy else "整体") + return ok({"task_id": task_id, "status": "regenerating", "scope": scope}) @router.post("/{task_id}/submit-review") diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py index f014632..7627e18 100644 --- a/backend/app/api/v1/tasks.py +++ b/backend/app/api/v1/tasks.py @@ -60,6 +60,10 @@ def _fmt_task(t: GenerationTask) -> dict: "id": t.id, "product_id": t.product_id, "theme": t.theme, "status": t.status, "text_count": t.text_count, "image_count": t.image_count, "track": t.track, "need_product_image": t.need_product_image, + # 审核回路:打回原因+审核状态,任务页/历史页展示"为何被打回"(R4-b死链修复,R8历史复用) + "review_status": t.review_status, + "reject_reason": t.reject_reason, + "reviewed_at": t.reviewed_at.isoformat() if t.reviewed_at else None, "created_at": t.created_at.isoformat(), } @@ -129,6 +133,7 @@ def _fmt_text(tc: TextCandidate) -> dict: # AI 评委总评(verdict/summary 存在 content 列,新数据有,旧数据为空字符串降级) "verdict": parsed.get("verdict", ""), # "优秀"|"合格"|"不合格" "summary": parsed.get("summary", ""), # 一句话总评含改进点 + "edited": tc.edited, # D1 改稿标记(飞轮最强信号) } @@ -136,6 +141,9 @@ def _fmt_image(ic: ImageCandidate) -> dict: return { "candidate_id": ic.id, "role": ic.role, "url": ic.url, "strategy": ic.strategy, "seq": ic.seq, "is_selected": ic.is_selected, + "is_regen": getattr(ic, "is_regen", False), # R2 重生标记(前端同strategy+role去重展示最新) + # E12 AI评图分:纯展示+排序,绝不进飞轮权重(eval_score 另留 NULL) + "ai_visual_score": ic.ai_visual_score, "ai_visual_note": ic.ai_visual_note, } diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 04191c6..09d4989 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -119,7 +119,14 @@ class ImageCandidate(Base): strategy: Mapped[str | None] = mapped_column(String(4)) # A/B/C(二期) seq: Mapped[int] = mapped_column(Integer, default=1) # 分镜序号 is_selected: Mapped[bool] = mapped_column(default=False, nullable=False) + # R2 重生标记:True=用户手动重生产出(新增不删旧,前端同strategy+role默认只展示最新) + is_regen: Mapped[bool] = mapped_column(default=False, nullable=False) eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL + # 标题文字校验结果JSON(NULL=通过;有值=曾不过/重生,前端提示运营人工筛) + text_review: Mapped[str | None] = mapped_column(String(512)) + # AI评图分0-100(展示+生成时筛选+排序用,绝不进飞轮权重;eval_score 另留 NULL) + ai_visual_score: Mapped[float | None] = mapped_column(Float) + ai_visual_note: Mapped[str | None] = mapped_column(String(256)) # AI评图一句话评语(前端展示) created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), nullable=False ) diff --git a/backend/app/services/ai_engine/constants.py b/backend/app/services/ai_engine/constants.py index 996ca13..fcf1a85 100644 --- a/backend/app/services/ai_engine/constants.py +++ b/backend/app/services/ai_engine/constants.py @@ -127,10 +127,27 @@ IMAGE_NEGATIVE_CONSTRAINTS = ( "只保留浅色简洁台面或产品定制场景,主体聚焦产品本身。" ) +# ── 小红书素人感正向约束(C7:反电商摆拍,对齐真实笔记观感)────────────── +# 追加到 base_prompt 末尾,与 IMAGE_NEGATIVE_CONSTRAINTS 互补:前者管合规,本条管"像不像小红书" +IMAGE_XHS_STYLE_CONSTRAINTS = ( + "【小红书素人感——必须像真人随手拍后简单排版,不是电商详情页】" + "①禁止纯白底影棚摆拍、居中正打光、官方产品主图那种电商感;" + "②要有生活气:自然光/窗边光、桌面或梳妆台真实场景、可带手持或局部环境,像朋友分享而非广告;" + "③构图随性不刻意对称,允许轻微景深虚化,避免过度精修的塑料光泽和磨皮假面感;" + "④文字排版克制像博主手作:主标题手写感或简洁无衬线,避免大字促销价签/打折标/电商角标;" + "⑤整体观感=真实测评/日常分享,宁可朴素也不要假亮假精致。" +) + +# C3 代码叠字开关(先留口子不实现,倩倩姐2026-06-12拍板): +# True 时由 PIL 在模型出的干净底图上叠主标题/品牌词,彻底解决 gpt-image-2 中文乱码; +# 当前 False=仍由模型画字(偶发错别字为已知问题)。接入点见 image_gen._gen_one 的 TODO。 +OVERLAY_TEXT_RENDER_ENABLED = False + # ── 飞轮信号权重(初始默认,北哥可校准)──────────────── FLYWHEEL_WEIGHTS = { "text_select": 3, "image_select": 3, + "text_edit": 5, # 改稿=用户真动手改字=最强真实意图,与approve同级(倩倩姐2026-06-16拍板) "approve": 5, "reject_with_reason": -3, "regenerate": -1, diff --git a/backend/app/services/ai_engine/gemini_factory.py b/backend/app/services/ai_engine/gemini_factory.py index a0c18a6..8ec862d 100644 --- a/backend/app/services/ai_engine/gemini_factory.py +++ b/backend/app/services/ai_engine/gemini_factory.py @@ -41,7 +41,7 @@ class AIClients: _pool_loop_id: int | None = field(default=None, repr=False) _gemini_key: str | None = field(default=None, repr=False) # 局部变量不打印 _model_image: str = "gpt-image-2" - _model_text: str = "claude-sonnet-4-5" # apiports无gpt-4o-mini,文案用claude中文质量好 + _model_text: str = "claude-opus-4-8" # 最强档(倩倩姐红线):Claude系一律4.8,绝不降级 def _client(self) -> httpx.AsyncClient: """主通道(apiports) client,按当前事件循环缓存""" @@ -111,16 +111,44 @@ class AIClients: resp.raise_for_status() return _extract_gemini_image(resp.json()) + async def _chat_post_failover(self, payload: dict, timeout: float) -> dict: + """ + chat/completions 发送器,带 codeproxy 回落。 + 主通道(apiports, claude-opus-4-8)若 5xx/超时/连接错,自动切 codeproxy 重试一次。 + ⚠ codeproxy 账号池只支持 gpt 系(gpt-5.5),无 claude,故回落时模型换成 gpt-5.5。 + 这是强档↔强档切换:红线"Claude系4.8/GPT系5.5"本就是两个平级最强档、互为兜底, + 非降级(降级=落 sonnet/弱档)。codeproxy 回落档由 CODEPROXY_CHAT_MODEL 配置(默认 gpt-5.5)。 + codeproxy 未配置(_alt_token 为空)时不回落,原样抛错。 + """ + main_base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/") + try: + resp = await self._client().post(f"{main_base}/chat/completions", json=payload, timeout=timeout) + resp.raise_for_status() + return resp.json() + except (httpx.HTTPStatusError, httpx.TransportError, httpx.TimeoutException) as exc: + # 仅 5xx(服务端过载)或网络层错误才回落;4xx(参数/鉴权)回落也没用,直接抛。 + status = getattr(getattr(exc, "response", None), "status_code", None) + retryable = status is None or status >= 500 + if not (retryable and self._alt_token): + raise + alt_base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/") + alt_payload = dict(payload) + alt_payload["model"] = os.environ.get("CODEPROXY_CHAT_MODEL", "gpt-5.5") + logger.warning("apiports chat 失败(%s),回落 codeproxy 用 %s 重试(强档兜底,非降级)", + status or type(exc).__name__, alt_payload["model"]) + client = self._client_for(alt_base, self._alt_token) + resp = await client.post(f"{alt_base}/chat/completions", json=alt_payload, timeout=timeout) + resp.raise_for_status() + return resp.json() + async def chat_complete(self, messages: list[dict], model: str | None = None, max_tokens: int = 4096, temperature: float = 0.75) -> str: - """文字生成(文案生成用)""" - base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/") + """文字生成(文案生成用)。apiports 503 时自动回落 codeproxy。""" payload = {"model": model or self._model_text, "messages": messages, "max_tokens": max_tokens, "temperature": temperature} # 单批≤4条文案正常 40-55s 返回;apiports 网关 ~60s 上限。客户端超时设 75s: # 略高于网关上限即可,过长(如180s)会在 apiports 卡顿时干等,拖慢整体。 timeout = float(os.environ.get("TEXT_LLM_TIMEOUT", "75")) - resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=timeout) - resp.raise_for_status() - return resp.json()["choices"][0]["message"]["content"] or "" + data = await self._chat_post_failover(payload, timeout) + return data["choices"][0]["message"]["content"] or "" async def gpt_vision_analyze(self, prompt: str, images: list[bytes], model: str | None = None) -> str: """ @@ -139,16 +167,15 @@ class AIClients: }) used_model = model or os.environ.get("MODEL_TEXT", "claude-opus-4-8") messages = [{"role": "user", "content": content}] - base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/") payload = { "model": used_model, "messages": messages, "max_tokens": 2048, "temperature": 0.2, } - resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=90.0) - resp.raise_for_status() - return resp.json()["choices"][0]["message"]["content"] or "" + # 评图也走 codeproxy 回落:apiports 503 时切备用站,模型档不变(守红线)。 + data = await self._chat_post_failover(payload, 90.0) + return data["choices"][0]["message"]["content"] or "" # duck-type: text_variants._call_llm 用的属性 @property diff --git a/backend/app/services/ai_engine/image_gen.py b/backend/app/services/ai_engine/image_gen.py index 93b70fd..dd5b785 100644 --- a/backend/app/services/ai_engine/image_gen.py +++ b/backend/app/services/ai_engine/image_gen.py @@ -15,6 +15,7 @@ import os from typing import Any, Protocol from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT +from .image_scorer import score_image from .storyboard import plan_image_set, sanitize_text logger = logging.getLogger(__name__) @@ -126,15 +127,24 @@ async def generate_storyboard_images( reference_images: list[bytes] | None = None, analysis: dict | None = None, strategy: str | None = None, + target_role: str | None = None, + custom_prompt: str | None = None, ) -> list[dict]: """ 按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。 strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略 + target_role: 非空时只生成该 role 那一张(R2 单张重生) + custom_prompt: 非空时追加到每张 per_prompt 末尾(R2 人工提示词) 每项:{role, name, image_bytes, error} """ plan = plan_image_set(note, product, image_count, analysis, strategy=strategy) storyboard = plan["storyboard"] base_prompt = plan["base_prompt"] + # R2 单张重生:只保留目标 role 的分镜(匹配不到则原样全跑,避免空结果) + if target_role: + _filtered = [it for it in storyboard if it.get("role") == target_role] + if _filtered: + storyboard = _filtered async def _gen_one(item: dict) -> dict: # 逐图 prompt 9 字段(扒 promptFromStoryboard:323-334),每张差异化 @@ -156,12 +166,31 @@ async def generate_storyboard_images( "中文文字少而清晰,主标题+最多3个短点位;可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌;" "不要生成App截图或笔记详情页界面。" ) + # R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束 + if custom_prompt: + per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}。" try: img_bytes = await generate_one_image(client, per_prompt, reference_images) - return {"role": item["role"], "name": item["name"], "image_bytes": img_bytes, "error": None} + # 注:gpt-image-2 渲染中文偶发错别字(约1/6)。vision/OCR 文字校验闸门实测 + # 不可靠(漏报形近字+幻觉误伤品牌词),倩倩姐2026-06-16拍板先撤,纯生图, + # 错别字作已知问题记录,后续迭代再处理。详见记忆 clover-image-text-check-shelved。 + # + # C3 canvas 叠字口子(倩倩姐2026-06-12拍板"只留口子不实现"): + # 当 OVERLAY_TEXT_RENDER_ENABLED=True 时,此处由 PIL 在模型出的干净底图上 + # 叠 item['overlay_text']/brand_keyword(字体资源+排版坐标后续补),彻底解决中文乱码。 + # TODO(C3-overlay): from .constants import OVERLAY_TEXT_RENDER_ENABLED + # if OVERLAY_TEXT_RENDER_ENABLED: img_bytes = overlay_text_on_image(img_bytes, item) + # E12 AI评图分:只做展示+排序,绝不进飞轮权重,失败返 None 不阻断(倩倩姐2026-06-16)。 + visual = await score_image(client, img_bytes) + return {"role": item["role"], "name": item["name"], "image_bytes": img_bytes, + "error": None, "text_review": None, + "ai_visual_score": (visual or {}).get("score"), + "ai_visual_note": (visual or {}).get("note")} except Exception as exc: logger.error("分镜 %s 生图失败: %s", item["role"], exc) - return {"role": item["role"], "name": item["name"], "image_bytes": None, "error": str(exc)} + return {"role": item["role"], "name": item["name"], "image_bytes": None, + "error": str(exc), "text_review": None, + "ai_visual_score": None, "ai_visual_note": None} # 限并发:apiports 图片接口有 QPS 限制,6 张全并发会撞 429/503 concurrency = int(os.environ.get("IMAGE_CONCURRENCY", "2")) diff --git a/backend/app/services/ai_engine/image_scorer.py b/backend/app/services/ai_engine/image_scorer.py new file mode 100644 index 0000000..e19925e --- /dev/null +++ b/backend/app/services/ai_engine/image_scorer.py @@ -0,0 +1,76 @@ +""" +AI 评图分 — E12 飞轮·展示层(倩倩姐2026-06-16拍板) + +红线(不可违反): +- 只做"生成时筛选 + 落库 + 前端展示 + 高分优先排序"。 +- 绝不进飞轮权重:飞轮权重只认真实信号(选了哪张/改了什么/过审与否)。 +- eval_score 全程留 NULL;AI 分单独存 ai_visual_score,不复用 eval_score。 +- 评分失败绝不阻断生图:返回 None,图照常入库展示。 + +vision 走 GPT 现有通道最强档(claude-opus-4-8),不补 GEMINI_KEY。 +""" +from __future__ import annotations +import json +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + +# 评图维度=纯视觉质量(不碰文字对错——那条路线已撤,见 image_gen.py 注释) +VISION_SCORE_PROMPT = ( + "你是小红书资深视觉运营,给下面这张种草配图打分。只看视觉质量,不纠结文字错别字。\n" + "评分维度(综合给一个 0-100 总分):\n" + "1. 构图与美感:主体突出、留白舒服、色调高级。\n" + "2. 清晰度:画面锐利不糊、无明显畸变。\n" + "3. 小红书种草感:像真实博主拍的好图,有质感、有氛围。\n" + "4. 去AI感:不假、不塑料、不像廉价 AI 渲染图。\n" + "5. 文字排版观感:标题贴纸排版是否清爽(只看排版美观,不判错别字)。\n" + "打分基准:80=可直接交付的好图,60=能用但平庸,40以下=明显有问题。\n" + '只返回 JSON,不要任何多余文字:{"score": <0-100整数>, "note": "<20字内一句话点评>"}' +) + + +def _parse_score(raw: str) -> dict[str, Any] | None: + """容错解析模型返回的 JSON(剥 markdown fence / 抓第一个 {...})。""" + if not raw: + return None + text = raw.strip() + text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE).strip() + try: + obj = json.loads(text) + except Exception: + m = re.search(r"\{.*\}", text, re.DOTALL) + if not m: + return None + try: + obj = json.loads(m.group(0)) + except Exception: + return None + if not isinstance(obj, dict) or "score" not in obj: + return None + try: + score = float(obj["score"]) + except Exception: + return None + score = max(0.0, min(100.0, score)) # 钳到 0-100 + note = str(obj.get("note") or "").strip()[:200] + return {"score": score, "note": note} + + +async def score_image(client: Any, image_bytes: bytes) -> dict[str, Any] | None: + """ + 给单张图打视觉分。返回 {"score": float, "note": str} 或 None(失败/不可解析)。 + 绝不抛异常打断生图链路——任何异常都吞掉返回 None。 + """ + if not image_bytes: + return None + try: + raw = await client.gpt_vision_analyze(VISION_SCORE_PROMPT, [image_bytes]) + except Exception as exc: + logger.warning("AI 评图调用失败(不阻断生图):%s", exc) + return None + result = _parse_score(raw) + if result is None: + logger.warning("AI 评图返回无法解析(不阻断生图):%s", (raw or "")[:120]) + return result diff --git a/backend/app/services/ai_engine/preference_aggregator.py b/backend/app/services/ai_engine/preference_aggregator.py index 1f5522b..455726c 100644 --- a/backend/app/services/ai_engine/preference_aggregator.py +++ b/backend/app/services/ai_engine/preference_aggregator.py @@ -48,13 +48,14 @@ def aggregate_preference_context( for e in relevant: sig_type = e.get("signal_type", "") - angle = str(e.get("angle_label", "")).strip() + angle = str(e.get("angle_label") or "").strip() weight = int(e.get("signal_weight", 1)) - if sig_type in ("text_select", "approve") and angle: + # text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板) + if sig_type in ("text_select", "approve", "text_edit") and angle: angle_counts[angle] += weight elif sig_type == "reject_with_reason": - reason = str(e.get("reason", "")).strip() + reason = str(e.get("reason") or "").strip() if reason: reject_reasons.append(reason) diff --git a/backend/app/services/ai_engine/storyboard.py b/backend/app/services/ai_engine/storyboard.py index 860b387..098a5cb 100644 --- a/backend/app/services/ai_engine/storyboard.py +++ b/backend/app/services/ai_engine/storyboard.py @@ -9,7 +9,7 @@ storyboard 分镜引擎 from __future__ import annotations import re from .constants import ( - PAGE_ROLE_MAP, IMAGE_NEGATIVE_CONSTRAINTS, + PAGE_ROLE_MAP, IMAGE_NEGATIVE_CONSTRAINTS, IMAGE_XHS_STYLE_CONSTRAINTS, STYLE_PROMPTS, STYLE_DEFAULT, NARRATIVE_BY_COUNT, NARRATIVE_BY_STRATEGY, ) # sanitize_text 移至 templates(腾行数),此处 re-export 供 image_gen 沿用 import @@ -187,6 +187,7 @@ def plan_image_set(note: dict, product: dict, image_count: int = 3, analysis: di "禁止肤色变白、瑕疵消失、治疗前后等视觉暗示,允许安全的未推开/推开后质地状态对比;" "如果提供产品图,产品是不可修改的真实商品锚点,禁止改名、换包装、混入其他产品。" f"\n{IMAGE_NEGATIVE_CONSTRAINTS}" + f"\n{IMAGE_XHS_STYLE_CONSTRAINTS}" ) return { diff --git a/backend/app/services/flywheel_service.py b/backend/app/services/flywheel_service.py index a299c07..b3d69be 100644 --- a/backend/app/services/flywheel_service.py +++ b/backend/app/services/flywheel_service.py @@ -95,10 +95,10 @@ def get_preference_context( "injected_count": len(recent), } - # 统计最常被选中的角度 + # 统计最常被选中的角度(text_edit 改稿=最强真实信号,按权重计入,倩倩姐2026-06-16) angle_counts: dict[str, int] = {} for ev in recent: - if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE) and ev.angle_label: + if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE, SignalType.TEXT_EDIT) and ev.angle_label: angle_counts[ev.angle_label] = angle_counts.get(ev.angle_label, 0) + 1 top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3] diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index f8b0281..87e0552 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -79,8 +79,11 @@ def create_generation_task( return task -def enqueue_generation(task_id: int) -> None: - """只推 task_id 入队,绝不推 key(基石B)。""" +def enqueue_generation(task_id: int, regen_strategy: str | None = None, + regen_role: str | None = None, custom_prompt: str | None = None) -> None: + """只推 task_id(+可选重生参数)入队,绝不推 key(基石B)。 + regen_strategy/regen_role/custom_prompt 仅 R2 单张/单套重生时传,常规生成留 None。""" from app.workers.tasks import run_generation_pipeline - run_generation_pipeline.delay(task_id) - logger.info("Enqueued task_id=%s", task_id) + run_generation_pipeline.delay(task_id, regen_strategy=regen_strategy, + regen_role=regen_role, custom_prompt=custom_prompt) + logger.info("Enqueued task_id=%s regen=%s/%s", task_id, regen_strategy, regen_role) diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 66487dc..39afbc7 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -29,6 +29,11 @@ celery_app.conf.update( task_track_started=True, task_acks_late=True, # 任务处理完才 ACK,防丢失 worker_prefetch_multiplier=1, # 一次只取1条,防长任务堆积 + # 🔴 visibility_timeout 必须 > 单任务最长耗时。生图(gpt-image-2)单张可达 270s+, + # 整条流水线(多图+评图重试)可能十几分钟。默认仅 1h 时,长任务超时会被 Redis broker + # 误判丢失而重新投递给另一 worker → 同一 task 被反复重跑、重复烧钱(task75 教训)。 + # 设 2h 留足余量;配合 pipeline 入口幂等检查双保险。 + broker_transport_options={"visibility_timeout": 7200}, task_routes={ "app.workers.tasks.run_generation_pipeline": {"queue": "generation"}, "app.workers.tasks.build_delivery_package": {"queue": "packaging"}, diff --git a/backend/app/workers/pipeline_io.py b/backend/app/workers/pipeline_io.py index fc1043c..8544dc9 100644 --- a/backend/app/workers/pipeline_io.py +++ b/backend/app/workers/pipeline_io.py @@ -145,10 +145,16 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment def run_image_generation(db, clients, task, product_dict: dict, push_fn, workspace_id: int, seq_start: int, - first_copy: dict, upload_base_path: str) -> int: + first_copy: dict, upload_base_path: str, + regen_strategy: str | None = None, + regen_role: str | None = None, + custom_prompt: str | None = None) -> int: """ Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。 返回 next_seq。 + + R2 局部重生(均 None=全量A/B/C):regen_strategy 限定只跑该套;regen_role 配合限定该套该张; + custom_prompt 人工追加提示词。重生产出 is_regen=True 新增不删旧。 """ import time from app.services.ai_engine.image_gen import generate_storyboard_images @@ -197,8 +203,17 @@ def run_image_generation(db, clients, task, product_dict: dict, ) seq = seq_start - # 3套正交叙事 A/B/C,每套各 image_count 张独立生图 - for strategy in ("A", "B", "C"): + # R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事 + _strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C") + _is_regen = bool(regen_strategy or regen_role) + # 进度总数:单张重生=1,单套=image_count,全量=image_count×3 + if regen_role: + _img_total = 1 + elif regen_strategy: + _img_total = task.image_count + else: + _img_total = task.image_count * 3 + for si, strategy in enumerate(_strategies): t0 = time.monotonic() img_success = True img_error_code = None @@ -210,6 +225,8 @@ def run_image_generation(db, clients, task, product_dict: dict, image_count=task.image_count, reference_images=reference_images or None, strategy=strategy, + target_role=regen_role, + custom_prompt=custom_prompt, )) except Exception as exc: img_success = False @@ -261,17 +278,25 @@ def run_image_generation(db, clients, task, product_dict: dict, url=img_url, seq=i + 1, strategy=strategy, # 写入 A/B/C(非 hardcode) + is_regen=_is_regen, # R2 重生标记:新增不删旧,前端同strategy+role默认展示最新 + # E12 AI评图分:只落展示分,绝不碰 eval_score(留 NULL);AI 分不进飞轮权重 + ai_visual_score=img_result.get("ai_visual_score"), + ai_visual_note=img_result.get("ai_visual_note"), ) db.add(ic) db.flush() seq += 1 push_fn(task.id, workspace_id, "image_candidate", { - "candidate_id": ic.id, "strategy": strategy, + "candidate_id": ic.id, "strategy": strategy, "seq": i + 1, "url": img_url, "role": img_result["role"], + "is_regen": _is_regen, + "ai_visual_score": img_result.get("ai_visual_score"), + "ai_visual_note": img_result.get("ai_visual_note"), }, seq) seq += 1 push_fn(task.id, workspace_id, "image_progress", { - "done": i + 1, "total": task.image_count, "strategy": strategy, + "done": si * task.image_count + (i + 1), + "total": _img_total, "strategy": strategy, }, seq) # 写 ai_call_logs(每套一条,失败不阻断)