存量积累:生图素人感约束+评图分+幂等防重跑+审核回路

- 015-017迁移:image_candidate 文案复审/AI视觉分/重生标记
- constants C7素人感约束(反电商摆拍对齐真实笔记)+C3叠字口子
- celery visibility_timeout=2h 防长任务被误判重投重复烧钱(task75教训)
- image_scorer 评图分(只筛选+展示,真实信号才进飞轮权重)
- storyboard/image_gen/pipeline_io 生图存量
- task_actions/tasks/task_service/flywheel 审核回路+飞轮存量

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 11:16:42 +08:00
parent cefdbaabdc
commit 7f419f4c8b
16 changed files with 346 additions and 30 deletions

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -95,22 +95,38 @@ def import_text(
return ok(_fmt_text(tc)) 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") @router.post("/{task_id}/regenerate")
def regenerate( def regenerate(
task_id: int, task_id: int,
body: RegenerateRequest | None = None,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db), 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.flywheel_service import record_signal
from app.services.task_service import enqueue_generation from app.services.task_service import enqueue_generation
task = _check_task_ownership( task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(), db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id, 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") record_signal(db, current_user, task, "regenerate")
enqueue_generation(task.id) enqueue_generation(task.id, regen_strategy=body.strategy,
return ok({"task_id": task_id, "status": "regenerating"}) 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") @router.post("/{task_id}/submit-review")

View File

@@ -60,6 +60,10 @@ def _fmt_task(t: GenerationTask) -> dict:
"id": t.id, "product_id": t.product_id, "theme": t.theme, "id": t.id, "product_id": t.product_id, "theme": t.theme,
"status": t.status, "text_count": t.text_count, "image_count": t.image_count, "status": t.status, "text_count": t.text_count, "image_count": t.image_count,
"track": t.track, "need_product_image": t.need_product_image, "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(), "created_at": t.created_at.isoformat(),
} }
@@ -129,6 +133,7 @@ def _fmt_text(tc: TextCandidate) -> dict:
# AI 评委总评verdict/summary 存在 content 列,新数据有,旧数据为空字符串降级) # AI 评委总评verdict/summary 存在 content 列,新数据有,旧数据为空字符串降级)
"verdict": parsed.get("verdict", ""), # "优秀"|"合格"|"不合格" "verdict": parsed.get("verdict", ""), # "优秀"|"合格"|"不合格"
"summary": parsed.get("summary", ""), # 一句话总评含改进点 "summary": parsed.get("summary", ""), # 一句话总评含改进点
"edited": tc.edited, # D1 改稿标记(飞轮最强信号)
} }
@@ -136,6 +141,9 @@ def _fmt_image(ic: ImageCandidate) -> dict:
return { return {
"candidate_id": ic.id, "role": ic.role, "url": ic.url, "candidate_id": ic.id, "role": ic.role, "url": ic.url,
"strategy": ic.strategy, "seq": ic.seq, "is_selected": ic.is_selected, "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,
} }

View File

@@ -119,7 +119,14 @@ class ImageCandidate(Base):
strategy: Mapped[str | None] = mapped_column(String(4)) # A/B/C二期 strategy: Mapped[str | None] = mapped_column(String(4)) # A/B/C二期
seq: Mapped[int] = mapped_column(Integer, default=1) # 分镜序号 seq: Mapped[int] = mapped_column(Integer, default=1) # 分镜序号
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False) 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 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( created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False DateTime, server_default=func.now(), nullable=False
) )

View File

@@ -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 = { FLYWHEEL_WEIGHTS = {
"text_select": 3, "text_select": 3,
"image_select": 3, "image_select": 3,
"text_edit": 5, # 改稿=用户真动手改字=最强真实意图,与approve同级(倩倩姐2026-06-16拍板)
"approve": 5, "approve": 5,
"reject_with_reason": -3, "reject_with_reason": -3,
"regenerate": -1, "regenerate": -1,

View File

@@ -41,7 +41,7 @@ class AIClients:
_pool_loop_id: int | None = field(default=None, repr=False) _pool_loop_id: int | None = field(default=None, repr=False)
_gemini_key: str | None = field(default=None, repr=False) # 局部变量不打印 _gemini_key: str | None = field(default=None, repr=False) # 局部变量不打印
_model_image: str = "gpt-image-2" _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: def _client(self) -> httpx.AsyncClient:
"""主通道(apiports) client按当前事件循环缓存""" """主通道(apiports) client按当前事件循环缓存"""
@@ -111,16 +111,44 @@ class AIClients:
resp.raise_for_status() resp.raise_for_status()
return _extract_gemini_image(resp.json()) 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: async def chat_complete(self, messages: list[dict], model: str | None = None, max_tokens: int = 4096, temperature: float = 0.75) -> str:
"""文字生成(文案生成用)""" """文字生成(文案生成用)。apiports 503 时自动回落 codeproxy。"""
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
payload = {"model": model or self._model_text, "messages": messages, "max_tokens": max_tokens, "temperature": temperature} payload = {"model": model or self._model_text, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}
# 单批≤4条文案正常 40-55s 返回apiports 网关 ~60s 上限。客户端超时设 75s # 单批≤4条文案正常 40-55s 返回apiports 网关 ~60s 上限。客户端超时设 75s
# 略高于网关上限即可过长如180s会在 apiports 卡顿时干等,拖慢整体。 # 略高于网关上限即可过长如180s会在 apiports 卡顿时干等,拖慢整体。
timeout = float(os.environ.get("TEXT_LLM_TIMEOUT", "75")) timeout = float(os.environ.get("TEXT_LLM_TIMEOUT", "75"))
resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=timeout) data = await self._chat_post_failover(payload, timeout)
resp.raise_for_status() return data["choices"][0]["message"]["content"] or ""
return resp.json()["choices"][0]["message"]["content"] or ""
async def gpt_vision_analyze(self, prompt: str, images: list[bytes], model: str | None = None) -> str: 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") used_model = model or os.environ.get("MODEL_TEXT", "claude-opus-4-8")
messages = [{"role": "user", "content": content}] messages = [{"role": "user", "content": content}]
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
payload = { payload = {
"model": used_model, "model": used_model,
"messages": messages, "messages": messages,
"max_tokens": 2048, "max_tokens": 2048,
"temperature": 0.2, "temperature": 0.2,
} }
resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=90.0) # 评图也走 codeproxy 回落apiports 503 时切备用站,模型档不变(守红线)。
resp.raise_for_status() data = await self._chat_post_failover(payload, 90.0)
return resp.json()["choices"][0]["message"]["content"] or "" return data["choices"][0]["message"]["content"] or ""
# duck-type: text_variants._call_llm 用的属性 # duck-type: text_variants._call_llm 用的属性
@property @property

View File

@@ -15,6 +15,7 @@ import os
from typing import Any, Protocol from typing import Any, Protocol
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT 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 from .storyboard import plan_image_set, sanitize_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -126,15 +127,24 @@ async def generate_storyboard_images(
reference_images: list[bytes] | None = None, reference_images: list[bytes] | None = None,
analysis: dict | None = None, analysis: dict | None = None,
strategy: str | None = None, strategy: str | None = None,
target_role: str | None = None,
custom_prompt: str | None = None,
) -> list[dict]: ) -> list[dict]:
""" """
按 storyboard 逐张生图asyncio.gather 并发),返回每张结果列表。 按 storyboard 逐张生图asyncio.gather 并发),返回每张结果列表。
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略 strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
target_role: 非空时只生成该 role 那一张R2 单张重生)
custom_prompt: 非空时追加到每张 per_prompt 末尾R2 人工提示词)
每项:{role, name, image_bytes, error} 每项:{role, name, image_bytes, error}
""" """
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy) plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
storyboard = plan["storyboard"] storyboard = plan["storyboard"]
base_prompt = plan["base_prompt"] 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: async def _gen_one(item: dict) -> dict:
# 逐图 prompt 9 字段(扒 promptFromStoryboard:323-334每张差异化 # 逐图 prompt 9 字段(扒 promptFromStoryboard:323-334每张差异化
@@ -156,12 +166,31 @@ async def generate_storyboard_images(
"中文文字少而清晰,主标题+最多3个短点位可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌" "中文文字少而清晰,主标题+最多3个短点位可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌"
"不要生成App截图或笔记详情页界面。" "不要生成App截图或笔记详情页界面。"
) )
# R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束
if custom_prompt:
per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}"
try: try:
img_bytes = await generate_one_image(client, per_prompt, reference_images) 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: except Exception as exc:
logger.error("分镜 %s 生图失败: %s", item["role"], 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 # 限并发apiports 图片接口有 QPS 限制6 张全并发会撞 429/503
concurrency = int(os.environ.get("IMAGE_CONCURRENCY", "2")) concurrency = int(os.environ.get("IMAGE_CONCURRENCY", "2"))

View File

@@ -0,0 +1,76 @@
"""
AI 评图分 — E12 飞轮·展示层倩倩姐2026-06-16拍板
红线(不可违反):
- 只做"生成时筛选 + 落库 + 前端展示 + 高分优先排序"
- 绝不进飞轮权重:飞轮权重只认真实信号(选了哪张/改了什么/过审与否)。
- eval_score 全程留 NULLAI 分单独存 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

View File

@@ -48,13 +48,14 @@ def aggregate_preference_context(
for e in relevant: for e in relevant:
sig_type = e.get("signal_type", "") 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)) 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 angle_counts[angle] += weight
elif sig_type == "reject_with_reason": elif sig_type == "reject_with_reason":
reason = str(e.get("reason", "")).strip() reason = str(e.get("reason") or "").strip()
if reason: if reason:
reject_reasons.append(reason) reject_reasons.append(reason)

View File

@@ -9,7 +9,7 @@ storyboard 分镜引擎
from __future__ import annotations from __future__ import annotations
import re import re
from .constants import ( 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, STYLE_PROMPTS, STYLE_DEFAULT, NARRATIVE_BY_COUNT, NARRATIVE_BY_STRATEGY,
) )
# sanitize_text 移至 templates腾行数此处 re-export 供 image_gen 沿用 import # 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_NEGATIVE_CONSTRAINTS}"
f"\n{IMAGE_XHS_STYLE_CONSTRAINTS}"
) )
return { return {

View File

@@ -95,10 +95,10 @@ def get_preference_context(
"injected_count": len(recent), "injected_count": len(recent),
} }
# 统计最常被选中的角度 # 统计最常被选中的角度text_edit 改稿=最强真实信号,按权重计入,倩倩姐2026-06-16
angle_counts: dict[str, int] = {} angle_counts: dict[str, int] = {}
for ev in recent: 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 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] top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3]

View File

@@ -79,8 +79,11 @@ def create_generation_task(
return task return task
def enqueue_generation(task_id: int) -> None: def enqueue_generation(task_id: int, regen_strategy: str | None = None,
"""只推 task_id 入队,绝不推 key基石B""" 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 from app.workers.tasks import run_generation_pipeline
run_generation_pipeline.delay(task_id) run_generation_pipeline.delay(task_id, regen_strategy=regen_strategy,
logger.info("Enqueued task_id=%s", task_id) regen_role=regen_role, custom_prompt=custom_prompt)
logger.info("Enqueued task_id=%s regen=%s/%s", task_id, regen_strategy, regen_role)

View File

@@ -29,6 +29,11 @@ celery_app.conf.update(
task_track_started=True, task_track_started=True,
task_acks_late=True, # 任务处理完才 ACK防丢失 task_acks_late=True, # 任务处理完才 ACK防丢失
worker_prefetch_multiplier=1, # 一次只取1条防长任务堆积 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={ task_routes={
"app.workers.tasks.run_generation_pipeline": {"queue": "generation"}, "app.workers.tasks.run_generation_pipeline": {"queue": "generation"},
"app.workers.tasks.build_delivery_package": {"queue": "packaging"}, "app.workers.tasks.build_delivery_package": {"queue": "packaging"},

View File

@@ -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, def run_image_generation(db, clients, task, product_dict: dict,
push_fn, workspace_id: int, seq_start: int, 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。 Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
返回 next_seq。 返回 next_seq。
R2 局部重生(均 None=全量A/B/C)regen_strategy 限定只跑该套regen_role 配合限定该套该张;
custom_prompt 人工追加提示词。重生产出 is_regen=True 新增不删旧。
""" """
import time import time
from app.services.ai_engine.image_gen import generate_storyboard_images 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 seq = seq_start
# 3套正交叙事 A/B/C每套各 image_count 张独立生图 # R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事
for strategy in ("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() t0 = time.monotonic()
img_success = True img_success = True
img_error_code = None img_error_code = None
@@ -210,6 +225,8 @@ def run_image_generation(db, clients, task, product_dict: dict,
image_count=task.image_count, image_count=task.image_count,
reference_images=reference_images or None, reference_images=reference_images or None,
strategy=strategy, strategy=strategy,
target_role=regen_role,
custom_prompt=custom_prompt,
)) ))
except Exception as exc: except Exception as exc:
img_success = False img_success = False
@@ -261,17 +278,25 @@ def run_image_generation(db, clients, task, product_dict: dict,
url=img_url, url=img_url,
seq=i + 1, seq=i + 1,
strategy=strategy, # 写入 A/B/C非 hardcode 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.add(ic)
db.flush() db.flush()
seq += 1 seq += 1
push_fn(task.id, workspace_id, "image_candidate", { 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"], "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)
seq += 1 seq += 1
push_fn(task.id, workspace_id, "image_progress", { 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) }, seq)
# 写 ai_call_logs每套一条失败不阻断 # 写 ai_call_logs每套一条失败不阻断