上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事

- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -43,6 +43,10 @@ class CreateApiKeyRequest(BaseModel):
# 注:不接收 url 字段token站固定自家站基石B
class TestApiKeyRequest(CreateApiKeyRequest):
pass
def _format_key(k: UserApiKey) -> dict:
"""只显 provider + key_last4不暴露余额/用量/encrypted_key红线"""
return {
@@ -77,8 +81,13 @@ def create_api_key(
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
db: Session = Depends(get_db),
):
"""录入 API KeyFernet 加密存储只保存后4位明文用于展示"""
from app.core.response import raise_business
"""录入/更新 API KeyFernet 加密存储只保存后4位明文用于展示
覆盖式倩倩姐2026-06-23拍板同 user+workspace+provider 已存在则直接更新,
不报错——「切换/修改 key」无需先删再录前端点「修改」重录即覆盖。
"""
encrypted = encrypt_api_key(body.api_key)
last4 = mask_api_key(body.api_key)
# 检查同 user+workspace+provider 是否已有
existing = (
db.query(UserApiKey)
@@ -90,15 +99,19 @@ def create_api_key(
.first()
)
if existing:
raise_business(f"已存在 {body.provider} 的 API Key请先删除再录入")
existing.encrypted_key = encrypted
existing.key_last4 = last4
db.commit()
db.refresh(existing)
logger.info("API key updated: user=%s provider=%s", current_user.user_id, body.provider)
return ok(_format_key(existing))
encrypted = encrypt_api_key(body.api_key)
key_obj = UserApiKey(
user_id=current_user.user_id,
workspace_id=current_user.workspace_id,
provider=body.provider,
encrypted_key=encrypted,
key_last4=mask_api_key(body.api_key),
key_last4=last4,
)
db.add(key_obj)
db.commit()
@@ -107,6 +120,48 @@ def create_api_key(
return ok(_format_key(key_obj))
@router.post("/test")
async def test_api_key(
body: TestApiKeyRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
):
"""用客户输入的明文 key 做一次轻量连通性测试,不落库。"""
import httpx
from app.core.config import get_settings
from app.core.response import raise_business
settings = get_settings()
provider = body.provider
if provider in ("openai", "apiports"):
base = (settings.APIPORTS_BASE_URL or settings.IMAGE_API_BASE).rstrip("/")
model = settings.MODEL_TEXT
elif provider == "codeproxy":
base = settings.CODEPROXY_BASE_URL.rstrip("/")
model = "gpt-5.5"
else:
raise_business("该 Provider 暂不支持在线测试")
if not base:
raise_business("服务端未配置该 Provider 的 Base URL")
try:
async with httpx.AsyncClient(timeout=12) as client:
resp = await client.post(
f"{base}/chat/completions",
headers={"Authorization": f"Bearer {body.api_key}"},
json={
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 4,
},
)
resp.raise_for_status()
except Exception as exc:
raise_business(f"Key 测试失败:{type(exc).__name__}")
logger.info("API key test ok: user=%s provider=%s", current_user.user_id, provider)
return ok({"ok": True, "provider": provider})
@router.delete("/{key_id}")
def delete_api_key(
key_id: int,

View File

@@ -34,6 +34,11 @@ class LoginRequest(BaseModel):
return v
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
# ── 路由 ───────────────────────────────────────────────────
@router.post("/login")
def login(body: LoginRequest, db: Session = Depends(get_db)):
@@ -47,6 +52,18 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
})
@router.post("/change-password")
def change_password(
body: ChangePasswordRequest,
current_user: Annotated[CurrentUser, Depends(get_current_user)],
db: Session = Depends(get_db),
):
"""修改当前登录用户密码;首登强制改密也走此接口。"""
from app.services.auth_service import change_password as do_change_password
do_change_password(db, current_user.user_id, body.current_password, body.new_password)
return ok({"changed": True})
@router.get("/me")
def get_me(
current_user: Annotated[CurrentUser, Depends(get_current_user)],
@@ -63,6 +80,7 @@ def get_me(
"email": user.email,
"current_workspace_id": current_user.workspace_id,
"role": current_user.role,
"must_change_password": bool(getattr(user, "must_change_password", False)),
"workspace": {
"id": ws.id, "name": ws.name, "slug": ws.slug,
} if ws else None,

View File

@@ -4,16 +4,19 @@ app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
"""
import logging
import os
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, File, Form, UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.core.response import ok, paginate, raise_business, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
from app.models.product import BannedWord, BenchmarkNote
from app.models.product import BannedWord, BenchmarkNote, Product
logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"])
@@ -45,6 +48,8 @@ def _fmt_benchmark(b: BenchmarkNote) -> dict:
"screenshot_url": b.screenshot_url,
"highlights": b.highlights, "link_url": b.link_url,
"features": features, "analyze_status": b.analyze_status,
"analysis_source": features.get("source") if isinstance(features, dict) else None,
"analysis_warning": features.get("warning") if isinstance(features, dict) else None,
"created_at": b.created_at.isoformat(),
}
@@ -57,6 +62,70 @@ def _fmt_banned(bw: BannedWord) -> dict:
}
_ALLOWED_IMAGE_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
_MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
def _check_image_magic(data: bytes) -> bool:
if len(data) < 12:
return False
if data[:3] == b"\xff\xd8\xff":
return True
if data[:8] == b"\x89PNG\r\n\x1a\n":
return True
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return True
return False
def _clean_link_url(link_url: str | None) -> str | None:
value = (link_url or "").strip()
if not value:
return None
if not value.startswith(("http://", "https://")):
raise_business("原始链接必须以 http:// 或 https:// 开头")
return value
def _is_benchmark_upload_path(path: str) -> bool:
upload_root = os.path.abspath(get_settings().UPLOAD_ABS_ROOT)
benchmark_root = os.path.join(upload_root, "benchmarks")
candidate = os.path.abspath(path)
try:
return os.path.commonpath([benchmark_root, candidate]) == benchmark_root
except ValueError:
return False
async def _save_benchmark_screenshot(
file: UploadFile,
workspace_id: int,
product_id: int,
) -> str:
if file.content_type not in _ALLOWED_IMAGE_CONTENT_TYPES:
raise_business(f"不支持的截图类型 {file.content_type},仅支持 JPEG/PNG/WebP")
data = await file.read()
if len(data) > _MAX_IMAGE_SIZE_BYTES:
raise_business("截图超过 10 MB 限制")
if not _check_image_magic(data):
raise_business("截图文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
settings = get_settings()
ext = os.path.splitext(file.filename or "benchmark.jpg")[1] or ".jpg"
abs_dir = os.path.join(
settings.UPLOAD_ABS_ROOT,
"benchmarks",
str(workspace_id),
str(product_id),
)
os.makedirs(abs_dir, exist_ok=True)
save_path = os.path.join(abs_dir, f"{uuid.uuid4().hex}{ext}")
with open(save_path, "wb") as f:
f.write(data)
return save_path
# ── 标杆笔记 ────────────────────────────────────────────────
@router.get("/products/{product_id}/benchmarks")
def list_benchmarks(
@@ -81,12 +150,64 @@ def create_benchmark(
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
product = db.query(Product).filter(
Product.id == product_id,
Product.workspace_id == current_user.workspace_id,
).first()
if not product:
raise_not_found("产品不存在")
# 兼容旧前端:旧字段 screenshot_url 实际可能传的是外部原帖链接。
legacy_link = (body.screenshot_url or "").strip()
highlights = (body.highlights or "").strip()
link_url = _clean_link_url(body.link_url or legacy_link)
if not highlights and not link_url:
raise_business("请至少填写原始链接或亮点")
b = BenchmarkNote(
workspace_id=current_user.workspace_id,
product_id=product_id,
screenshot_url=body.screenshot_url,
highlights=body.highlights,
link_url=body.link_url,
screenshot_url=None,
highlights=highlights,
link_url=link_url,
)
db.add(b); db.commit(); db.refresh(b)
return ok(_fmt_benchmark(b))
@router.post("/products/{product_id}/benchmarks/upload")
async def upload_benchmark(
product_id: int,
screenshot: UploadFile | None = File(None),
highlights: str | None = Form(None),
link_url: str | None = Form(None),
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
product = db.query(Product).filter(
Product.id == product_id,
Product.workspace_id == current_user.workspace_id,
).first()
if not product:
raise_not_found("产品不存在")
clean_highlights = (highlights or "").strip()
clean_link_url = _clean_link_url(link_url)
if not screenshot and not clean_highlights and not clean_link_url:
raise_business("请至少上传截图、填写原始链接或填写亮点")
screenshot_path = None
if screenshot:
screenshot_path = await _save_benchmark_screenshot(
screenshot,
current_user.workspace_id,
product_id,
)
b = BenchmarkNote(
workspace_id=current_user.workspace_id,
product_id=product_id,
screenshot_url=screenshot_path,
highlights=clean_highlights,
link_url=clean_link_url,
)
db.add(b); db.commit(); db.refresh(b)
return ok(_fmt_benchmark(b))
@@ -117,17 +238,24 @@ async def analyze_benchmark_note(
).first()
if not b:
raise_not_found("标杆笔记不存在")
if not b.screenshot_url and not b.highlights:
raise_business("该标杆无截图也无手填亮点,无法分析")
if not b.screenshot_url and not b.highlights and not b.link_url:
raise_business("该标杆无截图、原始链接和手填亮点,无法分析")
# 读截图字节screenshot_url 存的是上传后的绝对/相对路径)
# 读截图字节:新数据存本地上传路径;旧数据可能误存外部 URL不能直接 open。
screenshot = None
if b.screenshot_url:
try:
with open(b.screenshot_url, "rb") as f:
screenshot = f.read()
except Exception as e:
logger.warning("标杆截图读取失败 id=%s: %s", benchmark_id, e)
if b.screenshot_url.startswith(("http://", "https://")):
logger.info("标杆截图字段为外部 URL跳过本地读取 id=%s", benchmark_id)
elif _is_benchmark_upload_path(b.screenshot_url) and os.path.exists(b.screenshot_url):
try:
with open(b.screenshot_url, "rb") as f:
screenshot = f.read()
except Exception as e:
logger.warning("标杆截图读取失败 id=%s: %s", benchmark_id, e)
elif os.path.exists(b.screenshot_url):
logger.warning("标杆截图路径不在上传目录,跳过读取 id=%s path=%s", benchmark_id, b.screenshot_url)
else:
logger.warning("标杆截图路径不存在 id=%s path=%s", benchmark_id, b.screenshot_url)
api_key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id,
@@ -137,12 +265,26 @@ async def analyze_benchmark_note(
if not api_key_row:
raise_business("未配置 API Key请先在设置中录入")
plain_key = decrypt_key(api_key_row.encrypted_key)
clients = build_ai_clients(plain_key)
# codeproxy 备用 key可选用户录入则用没录回落 env
alt_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider == "codeproxy",
).first()
alt_key = decrypt_key(alt_row.encrypted_key) if alt_row else None
clients = build_ai_clients(plain_key, alt_key=alt_key)
plain_key = None
alt_key = None
b.analyze_status = "analyzing"; db.commit()
try:
result = await analyze_benchmark(clients, screenshot, b.highlights)
analysis_text_parts = []
if b.highlights:
analysis_text_parts.append(b.highlights)
if b.link_url:
analysis_text_parts.append(f"原始链接:{b.link_url}")
result = await analyze_benchmark(clients, screenshot, "\n".join(analysis_text_parts))
finally:
await clients.aclose()

View File

@@ -1,8 +1,9 @@
"""
app/api/v1/delivery.py — 交付包路由
POST /tasks/{id}/package — 生成达人素材交付包
GET /delivery-packages/{id}/download — 下载status: pending/ready/downloaded
GET /delivery-packages/{id}/download — 查询下载状态(无副作用
POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌C1坑修复
POST /delivery-packages/{id}/mark-downloaded — 显式标记已下载
GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open
"""
@@ -78,7 +79,7 @@ def get_package_download(
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""下载交付包status: pending/ready/downloaded"""
"""查询交付包下载状态。GET 只读,实际下载由 download-file 完成"""
pkg = db.query(DeliveryPackage).filter(
DeliveryPackage.id == package_id,
DeliveryPackage.workspace_id == current_user.workspace_id,
@@ -86,11 +87,6 @@ def get_package_download(
if not pkg:
raise_not_found("交付包不存在")
if pkg.status == "ready" and pkg.download_url:
# 标记为 downloaded只能下一次防重复公开 URL
pkg.status = "downloaded"
db.commit()
return ok(_fmt_package(pkg))
@@ -124,6 +120,26 @@ def create_download_token(
return ok({"token": token, "expires_in": 60})
@router.post("/delivery-packages/{package_id}/mark-downloaded")
def mark_package_downloaded(
package_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""显式标记交付包已下载。所有 GET 下载接口保持只读。"""
pkg = db.query(DeliveryPackage).filter(
DeliveryPackage.id == package_id,
DeliveryPackage.workspace_id == current_user.workspace_id,
).first()
if not pkg:
raise_not_found("交付包不存在")
if pkg.status == "ready":
pkg.status = "downloaded"
db.commit()
db.refresh(pkg)
return ok(_fmt_package(pkg))
@router.get("/delivery-packages/{package_id}/download-file")
def download_package_file(
package_id: int,

View File

@@ -15,6 +15,7 @@ from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage
from app.models.task import GenerationTask
logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"])
@@ -121,6 +122,28 @@ def get_product(
return ok(_fmt_product(p))
@router.get("/products/{product_id}/preference/context")
def get_product_preference_context(
product_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""产品维度飞轮偏好上下文(新建任务页用,此时还没有 task_id
与 task 维度端点同口径,复用 flywheel_service.get_preference_context。"""
p = db.query(Product).filter(
Product.id == product_id, Product.workspace_id == current_user.workspace_id
).first()
if not p:
raise_not_found("产品不存在")
from app.services.flywheel_service import get_preference_context
product_dict = {
"custom_prompt": p.custom_prompt or "",
"style_tone": p.style_tone or "",
}
ctx = get_preference_context(db, current_user.workspace_id, product_id, product_dict)
return ok(ctx)
@router.put("/products/{product_id}")
def update_product(
product_id: int, body: ProductCreate,
@@ -148,12 +171,21 @@ def delete_product(
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""智能删除(仅管理员):没跑过任务的产品物理删干净(级联清图片/标杆),
已有历史任务的产品改软删保留历史,避免 generation_tasks 外键报错。"""
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
if not p:
raise_not_found("产品不存在")
p.is_active = False # 软删
has_task = db.query(GenerationTask.id).filter(GenerationTask.product_id == product_id).first() is not None
if has_task:
p.is_active = False # 有历史任务→软删保留
db.commit()
return ok({"deleted": product_id, "mode": "soft", "reason": "产品已有历史任务,已停用并保留记录"})
db.query(ProductImage).filter(ProductImage.product_id == product_id).delete()
db.query(BenchmarkNote).filter(BenchmarkNote.product_id == product_id).delete()
db.delete(p) # 无任务→物理删(已先显式清图片/标杆,不赌数据库级联)
db.commit()
return ok({"deleted": product_id})
return ok({"deleted": product_id, "mode": "hard"})
# ── 产品参考图上传 ──────────────────────────────────────────
@@ -337,8 +369,17 @@ async def analyze_product_image(
raise_business("未配置 API Key请先在设置中录入")
plain_key = decrypt_key(api_key_row.encrypted_key)
clients = build_ai_clients(plain_key)
# codeproxy 备用 key可选用户录入则用没录回落 env不抛错
alt_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider == "codeproxy",
).first()
alt_key = decrypt_key(alt_row.encrypted_key) if alt_row else None
clients = build_ai_clients(plain_key, alt_key=alt_key)
plain_key = None # 立即清零不传出基石B
alt_key = None
try:
raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data])

View File

@@ -53,28 +53,78 @@ def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
from app.api.v1.tasks import _score_array_to_obj
product = db.query(Product).filter(Product.id == t.product_id).first()
operator = db.query(User).filter(User.id == t.operator_id).first()
selected_text = db.query(TextCandidate).filter(
selected_texts = db.query(TextCandidate).filter(
TextCandidate.task_id == t.id, TextCandidate.is_selected == True
).first()
selected_image = db.query(ImageCandidate).filter(
).order_by(TextCandidate.strategy.asc(), TextCandidate.id.asc()).all()
selected_images = db.query(ImageCandidate).filter(
ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True
).first()
).order_by(ImageCandidate.strategy.asc(), ImageCandidate.seq.asc()).all()
# content 列存 JSON dict解包取 title/content 分字段返回
text_parsed: dict = {}
if selected_text and selected_text.content:
selected_text = selected_texts[0] if selected_texts else None
selected_image = selected_images[0] if selected_images else None
def _fmt_selected_text(selected_text: TextCandidate | None) -> dict | None:
if not selected_text:
return None
text_parsed: dict = {}
try:
text_parsed = json.loads(selected_text.content)
text_parsed = json.loads(selected_text.content or "{}")
except (json.JSONDecodeError, ValueError):
text_parsed = {"content": selected_text.content}
text_score = None
if selected_text.score_json:
try:
text_score = _score_array_to_obj(json.loads(selected_text.score_json))
except (json.JSONDecodeError, ValueError):
text_score = None
return {
"candidate_id": selected_text.id,
"strategy": selected_text.strategy,
"angle_label": selected_text.angle_label,
"title": text_parsed.get("title", ""),
"content": text_parsed.get("content", ""),
"tags": text_parsed.get("tags", []),
"score": text_score,
}
def _fmt_selected_image(selected_image: ImageCandidate | None) -> dict | None:
if not selected_image:
return None
return {
"candidate_id": selected_image.id,
"strategy": selected_image.strategy,
"url": selected_image.url,
"role": selected_image.role,
"seq": selected_image.seq,
}
images_by_strategy: dict[str, list[ImageCandidate]] = {}
for img in selected_images:
images_by_strategy.setdefault(img.strategy or "_", []).append(img)
selected_sets = []
for strategy in ("A", "B", "C"):
text = next((tc for tc in selected_texts if tc.strategy == strategy), None)
imgs = images_by_strategy.get(strategy, [])
if text or imgs:
selected_sets.append({
"strategy": strategy,
"selected_text": _fmt_selected_text(text),
"selected_images": [_fmt_selected_image(img) for img in imgs],
})
# 质量分:解析 score_json 明细数组算总分+透传维度(绝不读 eval_score,设计留NULL)
text_score = None
if selected_text and selected_text.score_json:
try:
text_score = _score_array_to_obj(json.loads(selected_text.score_json))
except (json.JSONDecodeError, ValueError):
text_score = None
legacy_text = _fmt_selected_text(selected_text)
legacy_image = _fmt_selected_image(selected_image)
# 兼容旧前端字段selected_text/selected_image 仍返回第一套第一张。
if legacy_text:
legacy_text.pop("strategy", None)
if legacy_image:
legacy_image = {
"candidate_id": legacy_image["candidate_id"],
"strategy": legacy_image["strategy"],
"url": legacy_image["url"],
}
return {
"task_id": t.id,
@@ -82,19 +132,9 @@ def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
"theme": t.theme,
"submitted_at": t.updated_at.isoformat(),
"operator_name": operator.username if operator else None,
"selected_text": {
"candidate_id": selected_text.id,
"angle_label": selected_text.angle_label,
"title": text_parsed.get("title", ""),
"content": text_parsed.get("content", ""), # 纯正文
"tags": text_parsed.get("tags", []),
"score": text_score, # 审核显分:总分+7维明细(C2)
} if selected_text else None,
"selected_image": {
"candidate_id": selected_image.id,
"strategy": selected_image.strategy,
"url": selected_image.url,
} if selected_image else None,
"selected_text": legacy_text,
"selected_image": legacy_image,
"selected_sets": selected_sets,
}

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, raise_not_found, raise_state_invalid
from app.middleware.workspace_guard import CurrentUser, require_write_permission
from app.middleware.workspace_guard import CurrentUser, require_write_permission, require_admin
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
from app.api.v1.tasks import (
@@ -45,6 +45,12 @@ def select_text(
).first()
if not tc:
raise_not_found("文案候选不存在")
# A/B/C 同套单选:避免旧选择累积导致审核/打包拿错套别。
db.query(TextCandidate).filter(
TextCandidate.task_id == task_id,
TextCandidate.strategy == tc.strategy,
TextCandidate.id != tc.id,
).update({"is_selected": False})
tc.is_selected = True
db.commit()
record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label)
@@ -70,6 +76,13 @@ def select_image(
).first()
if not ic:
raise_not_found("图片候选不存在")
# 同套同角色只保留一张已选图;重生后选择新图会自动取消旧图。
db.query(ImageCandidate).filter(
ImageCandidate.task_id == task_id,
ImageCandidate.strategy == ic.strategy,
ImageCandidate.role == ic.role,
ImageCandidate.id != ic.id,
).update({"is_selected": False})
ic.is_selected = True
db.commit()
# R7 断点2选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨;
@@ -89,18 +102,40 @@ def import_text(
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""轨B导入外部文案直接进候选池跳过 AI 生成洞2"""
_check_task_ownership(
"""轨B导入外部文案直接进候选池跳过 AI 生成洞2
按已导入条数 %3 轮转分配 strategy(A/B/C),让导入轨「去生图」时能出三套正交配图
(生图按 strategy 匹配文案NULL 套永远匹配不上)。
导入=客户实跑验证过的范本,记 text_import 飞轮信号(权重高于改稿/AI选稿,倩倩姐2026-06-26)。"""
from app.constants.enums import IMAGE_STRATEGY_ANGLE
from app.services.flywheel_service import record_signal
task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
# 已导入条数 %3 → A/B/C 轮转第1条A 第2条B 第3条C 第4条A…三套均匀分布
existing = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).count()
strategy = ("A", "B", "C")[existing % 3]
tc = TextCandidate(
workspace_id=current_user.workspace_id, task_id=task_id,
source="import", content=body.content, angle_label=body.angle_label,
strategy=strategy,
)
db.add(tc)
db.commit()
db.refresh(tc)
# 飞轮信号:导入文案=最值得学习的范本。角度优先用户填的,缺则按套别兜底,让偏好聚合学到角度。
# 飞轮辅助不阻塞主路径:记信号失败只 log,导入文案(tc 已 commit)照常返回。
try:
record_signal(
db, current_user, task, "text_import",
candidate_id=tc.id,
angle_label=body.angle_label or IMAGE_STRATEGY_ANGLE.get(strategy),
)
except Exception:
logger.warning(
"导入文案飞轮信号记录失败(不阻塞导入): task_id=%s candidate_id=%s",
task_id, tc.id,
)
return ok(_fmt_text(tc))
@@ -128,6 +163,18 @@ def regenerate(
current_user.workspace_id,
)
body = body or RegenerateRequest()
if str(task.status) == "generating" or getattr(task.status, "value", None) == "generating":
raise_state_invalid("任务正在生成中,请等待本轮完成后再重生")
import redis as redis_lib
from app.core.config import get_settings
try:
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
except Exception as exc:
logger.warning("检查重生锁失败,继续提交: task_id=%s err=%s", task_id, exc)
else:
if r.exists(f"pipeline:lock:{task_id}"):
raise_state_invalid("任务正在重生中,请等待本轮完成后再操作")
# role 必须配 strategy单张重生需知道是哪套的哪张
if body.role and not body.strategy:
raise_state_invalid("指定单张重生(role)时必须同时指定 strategy(套别)")
@@ -138,6 +185,39 @@ def regenerate(
return ok({"task_id": task_id, "status": "regenerating", "scope": scope})
@router.post("/{task_id}/generate-images")
def generate_images(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""导入轨「去生图」:复用库内导入文案,跳过文案生成,只生图(首次出图)。
复用 regenerate 的 generating 守卫 + redis 锁防重复烧钱。"""
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,
)
if str(task.status) == "generating" or getattr(task.status, "value", None) == "generating":
raise_state_invalid("任务正在生成中,请等待本轮完成")
# 必须库内有导入文案才放行生图,避免空语境生图(质量崩)
has_text = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).count()
if not has_text:
raise_state_invalid("无可用文案,请先导入文案再去生图")
import redis as redis_lib
from app.core.config import get_settings
try:
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
except Exception as exc:
logger.warning("检查生图锁失败,继续提交: task_id=%s err=%s", task_id, exc)
else:
if r.exists(f"pipeline:lock:{task_id}"):
raise_state_invalid("任务正在生成中,请等待本轮完成")
enqueue_generation(task.id, reuse_text=True)
return ok({"task_id": task_id, "status": "generating"})
@router.post("/{task_id}/submit-review")
def submit_review(
task_id: int,
@@ -151,11 +231,40 @@ def submit_review(
)
if task.status != "pending_selection":
raise_state_invalid("只有 pending_selection 状态可提审")
_validate_strategy_selection(db, task)
task.status = "pending_review"
db.commit()
return ok({"task_id": task_id, "status": "pending_review"})
def _validate_strategy_selection(db: Session, task: GenerationTask) -> None:
"""提审前最低门槛:至少 1 条已选文案 + 至少 1 张已选图。
三套 A/B/C 是「挑着选」——用户可只要其中一套、丢弃某套、或某套重新生成,
不强制三套齐全、不强制每套选满(倩倩姐 2026-06-26 拍板)。
"""
from app.core.response import raise_business
selected_text_count = (
db.query(TextCandidate.id)
.filter(TextCandidate.task_id == task.id, TextCandidate.is_selected == True)
.count()
)
selected_image_count = (
db.query(ImageCandidate.id)
.filter(ImageCandidate.task_id == task.id, ImageCandidate.is_selected == True)
.count()
)
parts = []
if selected_text_count < 1:
parts.append("请至少选择 1 条文案")
if selected_image_count < 1:
parts.append("请至少选择 1 张图片")
if parts:
raise_business("".join(parts))
@router.get("/{task_id}/preference/context")
def get_preference_context(
task_id: int,
@@ -211,3 +320,45 @@ def edit_text(
db.commit()
record_signal(db, current_user, task, "text_edit", candidate_id=tc.id, angle_label=tc.angle_label)
return ok({"candidate_id": candidate_id, "edited": True})
@router.delete("/{task_id}/text-candidates/{candidate_id}")
def delete_text_candidate(
task_id: int, candidate_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除单条文案候选仅管理员物理删倩倩姐2026-06-30拍板删除权限只给管理员"""
_check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
tc = db.query(TextCandidate).filter(
TextCandidate.id == candidate_id, TextCandidate.task_id == task_id
).first()
if not tc:
raise_not_found("文案候选不存在")
db.delete(tc)
db.commit()
return ok({"deleted": candidate_id})
@router.delete("/{task_id}/image-candidates/{candidate_id}")
def delete_image_candidate(
task_id: int, candidate_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除单张图片候选仅管理员物理删倩倩姐2026-06-30拍板删除权限只给管理员"""
_check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
ic = db.query(ImageCandidate).filter(
ImageCandidate.id == candidate_id, ImageCandidate.task_id == task_id
).first()
if not ic:
raise_not_found("图片候选不存在")
db.delete(ic)
db.commit()
return ok({"deleted": candidate_id})

View File

@@ -13,7 +13,11 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_write_permission
from app.middleware.workspace_guard import (
CurrentUser,
require_admin,
require_write_permission,
)
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
logger = logging.getLogger(__name__)
@@ -227,3 +231,42 @@ def get_task(
"text_candidates": [_fmt_text(tc) for tc in texts],
"image_candidates": [_fmt_image(ic) for ic in images],
})
@router.delete("/{task_id}")
def delete_task(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除整个图文任务仅管理员倩倩姐2026-06-26拍板
智能删:有成品(delivery_packages)→转归档保留(status=archived不进看板)
无成品(残次/废稿)→物理删(先清 ai_call_logs/preference_events 外键,候选 CASCADE
"""
from datetime import datetime
from app.models.flywheel import AiCallLog, PreferenceEvent
from app.models.task import DeliveryPackage
from app.constants.enums import TaskStatus
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
task = _check_task_ownership(task, current_user.workspace_id)
has_package = (
db.query(DeliveryPackage.id)
.filter(DeliveryPackage.task_id == task_id)
.first()
is not None
)
if has_package:
task.status = TaskStatus.ARCHIVED.value
task.archived_at = datetime.utcnow()
db.commit()
return ok({"task_id": task_id, "action": "archived"})
# 物理删:先清无 CASCADE 的外键引用(候选表 CASCADE 自动删)
db.query(AiCallLog).filter(AiCallLog.task_id == task_id).delete(synchronize_session=False)
db.query(PreferenceEvent).filter(PreferenceEvent.task_id == task_id).delete(synchronize_session=False)
db.delete(task)
db.commit()
return ok({"task_id": task_id, "action": "deleted"})

View File

@@ -0,0 +1,83 @@
"""
app/api/v1/users.py — 管理员账号管理路由
全部挂 require_admin路由层只做参数校验→调 service→格式化响应。
禁用=软删除可恢复(倩倩姐2026-06-30);不物理删除。
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok
from app.middleware.workspace_guard import CurrentUser, require_admin
from app.services import user_admin_service as svc
router = APIRouter(prefix="/users", tags=["users"])
class CreateUserRequest(BaseModel):
username: str
email: str
role: str
init_password: str
@field_validator("username", "email", "role", "init_password")
@classmethod
def not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("不能为空")
return v
class SetActiveRequest(BaseModel):
is_active: bool
@router.get("")
def list_users(
current_user: Annotated[CurrentUser, Depends(require_admin)],
db: Session = Depends(get_db),
):
"""列出本 workspace 成员(含已禁用)。"""
return ok(svc.list_workspace_users(db, current_user.workspace_id))
@router.post("")
def create_user(
body: CreateUserRequest,
current_user: Annotated[CurrentUser, Depends(require_admin)],
db: Session = Depends(get_db),
):
"""建号,强制首登改密。"""
return ok(svc.create_workspace_user(
db, current_user.workspace_id,
body.username, body.email, body.role, body.init_password,
))
@router.post("/{user_id}/active")
def set_active(
user_id: int,
body: SetActiveRequest,
current_user: Annotated[CurrentUser, Depends(require_admin)],
db: Session = Depends(get_db),
):
"""启用/禁用账号(软删除,可随时恢复)。"""
return ok(svc.set_user_active(
db, current_user.workspace_id, user_id, body.is_active, current_user.user_id,
))
@router.delete("/{user_id}")
def delete_user(
user_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)],
db: Session = Depends(get_db),
):
"""物理删除账号(不可恢复,与禁用区分)。"""
return ok(svc.delete_workspace_user(
db, current_user.workspace_id, user_id, current_user.user_id,
))

View File

@@ -39,5 +39,6 @@ def switch_workspace(
token = create_access_token(current_user.user_id, body.workspace_id, member.role)
return ok({
"current_workspace_id": body.workspace_id,
"role": member.role,
"token": token,
})

View File

@@ -62,6 +62,7 @@ class SignalType(str, Enum):
REJECT_WITH_REASON = "reject_with_reason"
REGENERATE = "regenerate"
TEXT_EDIT = "text_edit" # 改稿:用户真改了字=最强真实信号(倩倩姐2026-06-16拍板)
TEXT_IMPORT = "text_import" # 导入:客户实跑验证过跑得好的范本=最值得学习(倩倩姐2026-06-26拍板)
# ── 飞轮信号权重默认值(北哥可校准)─────────────────────
@@ -72,6 +73,9 @@ SIGNAL_WEIGHTS: dict[str, int] = {
SignalType.REJECT_WITH_REASON: -3,
SignalType.REGENERATE: -1,
SignalType.TEXT_EDIT: 5, # 最强信号,≥选择类(倩倩姐2026-06-16拍板;先激进跑一周再校准)
# 导入文案=客户实跑验证过的范本,权重高于改稿/AI选稿(倩倩姐2026-06-26拍板)。
# 8=改稿5+AI选稿3之上拉开梯度,体现"最值得学习"又不极端碾压;待倩倩姐最终校准。
SignalType.TEXT_IMPORT: 8,
}

View File

@@ -37,6 +37,7 @@ class Settings(BaseSettings):
# ── 应用配置 ──────────────────────────────────────
APP_ENV: str = "development"
CORS_ALLOW_ORIGINS: str = "http://localhost:3000"
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
CELERY_BROKER_URL: str = ""
CELERY_RESULT_BACKEND: str = ""

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import raise_forbidden, raise_unauthorized
from app.core.security import decode_access_token
from app.models.user import User
from app.models.workspace import WorkspaceMember
logger = logging.getLogger(__name__)
@@ -85,6 +86,9 @@ def require_write_permission(
current_user.workspace_id,
)
raise_forbidden("无权限访问此 workspace")
user = db.query(User).filter(User.id == current_user.user_id).first()
if user and getattr(user, "must_change_password", False):
raise_forbidden("首次登录必须先修改密码")
return current_user

View File

@@ -102,9 +102,9 @@ class BenchmarkNote(Base):
product_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
screenshot_url: Mapped[str | None] = mapped_column(String(512))
screenshot_url: Mapped[str | None] = mapped_column(Text)
highlights: Mapped[str | None] = mapped_column(Text) # 手填亮点
link_url: Mapped[str | None] = mapped_column(String(512))
link_url: Mapped[str | None] = mapped_column(Text)
# 009: 第2环标杆分析字段
features_json: Mapped[str | None] = mapped_column(Text, comment="爆款8特征分析结果JSONAI解析后写入")
analyze_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False, comment="AI分析状态: pending/analyzing/done/failed")

View File

@@ -22,6 +22,7 @@ class User(Base):
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)

View File

@@ -1,9 +1,10 @@
"""
_score_prompt.py — AI 评委 prompt让模型真读文案不机械找词
评判标准忠于《富贵情绪营销理论》原文(口播一手来源),标实战补充出处。
6维满分分布倩倩姐2026-06-15拍板与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
痛点人群精准18 / 情绪张力18 / 买点转化18 / 开头钩子15 / 标题点击力13 / 真实感13 = 95
7维满分分布倩倩姐2026-06-22拍板补北哥要害⑤标签精准·与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
痛点人群精准16 / 情绪张力16 / 买点转化16 / 开头钩子13 / 标题点击力13 / 真实感11 / 标签精准度10 = 95
+ 合规5机械硬拦不进AI评委= 100
合格线80不动新增"标签精准度"是补考核维度分从原6维各匀出总分仍95不是降门槛。
"真实感"替换旧"产品聚焦一件事(16)":富贵"很少提产品/前70%干货后30%植入"独立升维。
"""
from __future__ import annotations
@@ -18,13 +19,13 @@ SCORER_PERSONA = """你是一位资深小红书内容操盘手,深谙富贵情
# ── 6 维评判标准按权重降序合规第7维由代码机械硬拦不在此────
# 标准依据:括号内标注[富贵原文]或[实战补充],前者来自口播一手来源,权威最高。
SCORING_DIMENSIONS = """
【维度1·痛点人群精准满分18)】[富贵原文]
【维度1·痛点人群精准满分16)】[富贵原文]
判断:"说的就是我"——文案描述的处境/困扰,目标用户读了会不会对号入座。
好:具体到某类人的某个真实生活瞬间,让人觉得被看穿,落在"我的大问题/我的处境/我的渴望"上。
差:泛泛而谈谁都能套(如"适合所有想变美的女生"或PUA用户、拿别人的惨状吓唬人。
依据:富贵"我的大问题→处境→渴望"内容骨架;人群越具体穿透力越强;"用户被宠成爹你PUA他不好使"
【维度2·情绪张力满分18)】[富贵原文·第一性原理]
【维度2·情绪张力满分16)】[富贵原文·第一性原理]
判断:有没有"成果/后果"双向情绪,而不是平铺直叙报卖点。
· 后果=过去没用它,产生了什么糟糕处境(勾起恐惧/懊悔)
· 成果=用了它之后,会变成什么样(给出期待/乐观)
@@ -32,32 +33,43 @@ SCORING_DIMENSIONS = """
差:全程客观介绍产品、无情绪、像说明书;或只单向吓唬、或只空喊美好。
依据:富贵"营销第一性原理就是情绪,没有情绪什么内容都不转化""成果是未来、后果是过去,要做这两种情绪"
【维度3·买点转化满分18)】[富贵原文]
【维度3·买点转化满分16)】[富贵原文]
判断:产品卖点有没有翻译成用户能感知的场景化利益(人话),而不是品牌视角的功效/参数。
好:用户能想象到的使用场景和结果(如"出门前最后一步、同事问我今天气色真好")。
好:用户能想象到的使用场景和结果(如"出门前最后一步搞定、被身边人夸最近状态真好")。
差:品牌语言/参数罗列(如"采用XX技术""含XX成分"),用户无感。
依据:富贵"卖点是品牌视角、买点是用户视角""用户买的不是产品,是使用场景背后被解决的问题"
【维度4·开头钩子满分15)】[富贵原文]
【维度4·开头钩子满分13)】[富贵原文]
判断:开头能不能让人停下来、想继续读。
好:开头第一句就直击用户的大问题/痛处/具体场景代入,每句都打要害。
差:开头是套话或铺垫半天不进正题,没有任何抓人的点。
依据:富贵"内容就是锋利的刀,一定要插用户的心窝子"
【维度5·标题点击力满分13】[富贵原文+实战补充]
判断:标题有没有让目标用户想点的诱因。
好:标题带具体人群/场景/痛点/情绪钩子,一眼觉得"和我有关、我想看",最好来自用户真实说法
差:只有产品名、平淡无钩子、太像广告。
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文]标题善用痛点/人群/效果/情绪词[实战补充]。
【维度5·标题点击力满分13】[富贵原文+实战补充+北哥要害①]
判断:标题有没有让目标用户想点的诱因,且覆盖面够不够广
好:标题带悬念/反差/疑问钩子让人想点,同时"广覆盖"罩住尽量多的人(不一上来就锁死单一窄人群),精准筛人交给标签层
差:只有产品名、平淡无钩子、太像广告;或标题一上来就锁死单一窄人群(如只喊某一类人把其他人挡门外),覆盖面太窄
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文]北哥实战"标题广覆盖+悬念钩子,精准靠标签不靠标题筛人"[要害①]。
【维度6·真实感满分13)】[富贵原文]
【维度6·真实感满分11)】[富贵原文]
判断:整条文案是不是像真人分享,而不是品牌广告或功效说明书。
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格。
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板而非真实人设
依据:富贵"我们很少提产品""前70%是价值/场景后30%才是产品"用户能感受到"这不像广告"
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格;种草借第三方视角(闺蜜/同事/被人问)而非自夸回购
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板;或全程自卖自夸"我回购了N次"广告味重
依据:富贵"我们很少提产品""前70%是价值/场景后30%才是产品"北哥要害③"第三方闺蜜真实背书胜过自夸"
【维度7·标签精准度满分10】[北哥要害⑤·出单命门]
判断tags 是不是"有明确购买意向的人才会搜"的精准埋词,而不是凑流量的泛词。
好:目标人群+品类+核心卖点/场景组合成的精准长尾标签搜它的人就是想买这类的人至少2-3个精准埋词。
差:清一色"#好物分享 #真实测评 #种草"这类谁都能用、购买意向弱的泛流量大词,搜来的人不精准。
扣分细则(必须逐个标签判定,不许整体放水):把每个 tag 归为"精准埋词""泛词"
泛词=品类大词/平台流量词/无人群无卖点的口号词,如 #好物分享 #平价好物 #种草 #真实测评 #必入清单。
精准埋词=含明确人群或场景或具体痛点卖点,搜它的人就是目标买家,如 #久坐党护腰好物 #通勤提神咖啡。
评分上限泛词占比≥50% 最高只能给6分占比1/3左右给7-8分几乎全是精准埋词才给9-10分。
reason 里必须点名哪几个是泛词、哪几个是精准埋词,不能只给结论。
依据:北哥实战"精准购买意向标签埋词是出单命门,泛词价值低、精准长尾才带来真买家"[要害⑤]。
""".strip()
# ── 真实感已升为维度6满分13),此处保留空字符串避免调用方引用报错 ──
# ── 真实感已升为维度6满分11),此处保留空字符串避免调用方引用报错 ──
REALNESS_NOTE = "" # 升为 SCORING_DIMENSIONS 维度6不再重复附加
# ── 输出格式约束 ──────────────────────────────────────────
@@ -65,17 +77,20 @@ SCORER_OUTPUT_FORMAT = """
读完这条文案后严格返回纯JSON对象不要markdown代码块、不要多余文字格式
{
"dims": [
{"item":"痛点人群精准","score":<0-18整数>,"reason":"<针对本条的具体理由30字内>"},
{"item":"情绪张力","score":<0-18>,"reason":"..."},
{"item":"买点转化","score":<0-18>,"reason":"..."},
{"item":"开头钩子","score":<0-15>,"reason":"..."},
{"item":"痛点人群精准","score":<0-16整数>,"reason":"<针对本条的具体理由30字内>"},
{"item":"情绪张力","score":<0-16>,"reason":"..."},
{"item":"买点转化","score":<0-16>,"reason":"..."},
{"item":"开头钩子","score":<0-13>,"reason":"..."},
{"item":"标题点击力","score":<0-13>,"reason":"..."},
{"item":"真实感","score":<0-13>,"reason":"..."}
{"item":"真实感","score":<0-11>,"reason":"..."},
{"item":"标签精准度","score":<0-10>,"reason":"..."}
],
"verdict":"<优秀|合格|不合格>",
"summary":"<一句话总评,说清这条最大的优点和最该改的点>"
}
打分要敢拉开差距:平庸文案该给中低分,不要清一色高分;优秀的地方也别吝啬给高分。
只评上面7个维度。合规/违禁词由系统机械检测,禁止你输出"合规性/违禁词/合规"等任何合规相关维度,
也不要因为你主观觉得某词敏感而压分——"不假白/没那么黄/提气色"这类是合规的感受描述,不是功效违禁词。
""".strip()
# 合规维度满分(机械硬拦,不进 AI 评委)
@@ -86,9 +101,11 @@ AI_DIMS_MAX = 95
def build_score_prompt(copy: dict, product: dict | None = None) -> str:
"""组装单条文案的评委 prompt。copy={title,content,...}product 提供品牌/品类语境。"""
"""组装单条文案的评委 prompt。copy={title,content,tags,...}product 提供品牌/品类语境。"""
title = str(copy.get("title", "")).strip()
content = str(copy.get("content", "")).strip()
tags_raw = copy.get("tags") or []
tags = " ".join(str(t).strip() for t in tags_raw if str(t).strip()) or "(本条未给标签)"
ctx = ""
if product:
name = product.get("name") or product.get("title") or ""
@@ -99,6 +116,6 @@ def build_score_prompt(copy: dict, product: dict | None = None) -> str:
ctx = "【产品语境】\n" + "\n".join(bits) + "\n\n"
return (
f"{SCORING_DIMENSIONS}\n\n"
f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n\n"
f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n标签:{tags}\n\n"
f"{SCORER_OUTPUT_FORMAT}"
)

View File

@@ -35,7 +35,7 @@ def _cat_words(category: str) -> list[str]:
def score_title(title: str, cat_words: list[str], w: dict) -> dict:
ts = 0
if 6 <= len(title) <= 34: ts += 8
if re.search(r"[0-9一二三四五六七八九十几]|最近|这|这个|每天|早八|学生党|新手|宝妈|懒人|伪素颜", title): ts += 5
if re.search(r"[0-9一二三四五六七八九十几]|最近|这|这个|每天|早八|学生党|新手|宝妈|懒人", title): ts += 5
if _has_any(title, _SCENE_WORDS): ts += 6
if _has_any(title, cat_words): ts += 5
if re.search(r"[!?]|救星|不踩雷|闭眼入|会回购|被问|惊喜|加分|常备|省心|实用|别乱选|放心|有气色", title): ts += 6

View File

@@ -15,35 +15,38 @@ _PERSONA = """你是一个日常生活分享博主,不是品牌推广号。
比例70% 写生活场景和使用感受30% 提产品,绝不颠倒。
收尾铁律:每条结尾方式必须不同,禁止使用"东西放这了/买不买跟我没关系"这类被用滥的固定句式。"""
# ── Q1 随机变量池 ABC反同质化核心每次随机抽组合N条不撞──────────────
# 方法层:框架固定,内容可扩展,绝不写死
_POOL_A_IDENTITY: list[str] = [
"上班族早八妆前随手抹",
"宿舍懒人护肤三分钟搞定",
"敏感肌妈妈哄完娃才有五分钟",
"学生党第一次用高价护肤品",
"素颜出门前最后一步",
# ── Q1 叙事切入配方(反同质化核心·北哥四象限人群法,全品类通用不写死)────────
# 方法层:只给"切入维度类型",具体人群/场景由模型按本产品卖点+目标人群填充
# 北哥四象限:场景/习惯/适配/时机——主人群维度按条轮转保证N条开篇不撞
_AUDIENCE_DIMENSIONS: list[str] = [
"使用场景人群(按该产品最高频的使用场景圈定一类人)",
"使用习惯人群(懒人省心/进阶讲究等按习惯分的一类人)",
"适配特质人群(该产品特别适配的某类特质或需求人群)",
"使用时机人群(按使用时间/阶段/契机圈定的一类人)",
"性价比人群(看重划算、对价格敏感的一类人)",
]
_POOL_B_EMOTION: list[str] = [
"看到镜子里发现气色暗了一周",
"闺蜜问你最近皮肤怎么这么好",
"出门被催快点根本来不及叠瓶",
# 情绪触发类型(通用,模型按产品填具体内容)
_EMOTION_TRIGGERS: list[str] = [
"被身边人提醒或夸到才注意到的瞬间",
"自己用的时候突然意识到的变化",
"对比之前踩过的雷或走过的弯路",
]
_POOL_C_FLAW: list[str] = [
"第一次用量太少了没啥感觉",
"包装简单到以为是山寨",
"价格摆在那以为会很油很厚",
# 真实小波折类型(增可信,通用不写死品类)
_REAL_FLAWS: list[str] = [
"一开始用法没掌握或用量不对",
"第一眼对包装或价格有点误解",
"起效没想象中快、用了阵子才感觉到",
]
def _pick_combo() -> dict[str, str]:
"""随机抽一组 ABC 变量每次生成调用一次N条各不相同"""
def _pick_combo(index: int = 0) -> dict[str, str]:
"""抽一组通用叙事切入配方维度类型非写死内容主人群维度按index轮转N条不撞"""
return {
"identity": random.choice(_POOL_A_IDENTITY),
"emotion": random.choice(_POOL_B_EMOTION),
"flaw": random.choice(_POOL_C_FLAW),
"identity": _AUDIENCE_DIMENSIONS[index % len(_AUDIENCE_DIMENSIONS)],
"emotion": random.choice(_EMOTION_TRIGGERS),
"flaw": random.choice(_REAL_FLAWS),
}
@@ -61,30 +64,34 @@ _EMOJI_RULES = """
- 正文段落可点缀少量 emoji 烘托情绪(如 🥹 😭 🤍 💛每段最多1-2个不堆砌
- 结尾话题标签前后带表情,如 "#好物分享 🛒"
- emoji 服务情绪和分点,不要每句都加;整条正文 emoji 总量控制在 6-12 个
- 常用小红书 emoji 池:✅✨🌿💧🪞🧴📦🔍💛🤍🥹😭🛒(按语义选,不乱用)
- 常用小红书 emoji 池:✅✨🌿💧📦🔍💛🤍🥹😭🛒💯(按语义选,不乱用)
""".strip()
# ── Q2 5步框架 + Q3 四段结构 ─────────────────────────────────────────────
_STRUCTURE_RULES = """
【5步框架必须严格遵循
① Hook暴击低价/痛点:第一句戳中场景或价格锚点,吊足读者好奇
② 痛点共鸣2-3句描写使用前的真实困境用上面抽到的起因情绪A·B
② 痛点共鸣2-3句描写使用前的真实困境结合本条的情绪触发,并按北哥要害②召集多类人
③ 救星登场:自然带出产品,口吻是"碰巧发现/朋友安利/囤货时顺手",不是"推荐给你"
④ 卖点罗列每条加✅小标题3条以内卖点翻译成使用感受不是功效列表
⑤ 收尾(每条必须从下方策略池随机选一种,同批次不得重复同一种,禁止"东西放这了/买不买跟我没关系"此类固定句式):
【收尾策略池·每条选不同策略】
A·留白式感受只说自己现在的状态不提买不买"反正现在素颜出门我不慌"
B·反问读者把感受抛回给读者"你们护肤有没有那种一用就回不去的东西?"
C·场景延续把故事延伸到未来某个细节"下次同事再问我皮肤的事我就知道啥了"
D·克制回购暗示轻描淡写说自己行为"第一用完了,已经在备第二"
A·留白式感受只说自己现在的状态不提买不买"反正现在出门我都带着它,心里踏实多"
B·反问读者把感受抛回给读者"你们有没有那种一用就回不去的东西?"
C·场景延续把故事延伸到未来某个细节"下次再有人问起,我就知道该推荐啥了"
D·克制回购暗示轻描淡写说自己行为"第一用完了,已经在备第二"
E·纯记录收笔像日记最后一句不引导不评价"大概就这样,记录一下"
F·引导搜索仅在有品牌词时使用自然提一句"感兴趣可以搜搜『{品牌词}",不催单
【正文四段结构(必须)】
段1·痛点引入:描写使用前的困境/触发场景身份场景A·起因情绪B
段2·实测记录真实使用过程带上小缺点C真实感来源
段3·种草核心产品带来的变化用感受描述而非功效声称
段4·引导收尾从收尾策略池随机选一种佛系口吻不强推末尾带1-2个相关话题标签
【正文叙事骨架(开篇方式由本条叙事主线决定,禁止三套同一开头)】
段1·开篇分叉(按本条叙事主线三选一,这是三套拉开差异的关键):
- 痛点先行→开篇即戳使用前的困境/情绪(结合主切入人群维度,按要害②召集多类人
- 场景先行→开篇先白描一个真实高频使用场景,痛点在场景里自然带出,不喊口号
- 成分背书先行→开篇用成分原理/测评对比视角切入("为什么有用/亲测对比"),痛点后置
段2·实测记录真实使用过程带上本条的真实小波折真实感来源用第三方视角背书北哥要害③
段3·种草核心产品带来的变化用感受描述而非功效声称核心成分逐条对应痛点北哥要害④
段4·引导收尾从收尾策略池随机选一种佛系口吻不强推末尾带精准购买意向标签北哥要害⑤
※ 卖点罗列的措辞/顺序/emoji 三套之间禁止雷同;身边人种草/被夸被问这类桥段三套不得共用同一套。
【字数】正文350-400字不含标题tags3-5处段落空行增强可读性
""".strip()
@@ -92,23 +99,46 @@ _STRUCTURE_RULES = """
# ── Q8 标题公式5类结构每批次覆盖不同类型不重复──────────────────────
_TITLE_FORMULA = """
【标题公式5类每条用不同类型禁止同批重复
肤质型:「{肤质}+用了{产品}+{感受}」例→"油皮用了素颜霜整个夏天不脱妆"
价格型:「{价格锚点}+{产品}+{效感受}」例→"三位数买到大牌平替,用完第一罐回购第二罐"
功效型:「{使用场景}+{产品}+{可感知变化}」例→"早起素颜出门靠这罐,同事问我最近皮肤怎么"
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神奇产品,涂上去感觉素颜也能出门"
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"人说皮肤变好了,没说的是我了它一个月"
特征型:「{适配特征}+用了{产品}+{感受}」例→"久坐党用了这把椅子,腰一整天不酸了"
价格型:「{价格锚点}+{产品}+{效感受}」例→"三位数买到大牌平替,用完第一份就回购"
场景型:「{使用场景}+{产品}+{可感知变化}」例→"通勤路上靠这杯,到工位整个人都醒"
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神仙东西,用一次就回不去"
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"夸最近状态好,没说的是我了它一个月"
硬性约束标题≤20字禁止出现"绝绝子/YYDS/杀疯了";不直接写功效词(美白/祛斑等)。
硬性约束·标题必须有具体锚点:禁止"有点反差/真的绝/太惊艳/有点东西"这类空泛无信息量的标题,
必须落到一个具体的人群/场景/数字/反差细节上(读者扫一眼就知道讲什么、关我什么事)。
""".strip()
_ANTI_AI = f"""
【反AI味必须遵守】
- 禁止固定开篇套话:不许"姐妹们/宝子们/今天给大家分享"开头
- 禁止以下AI味词出现在正文{_NEGATIVE_WORDS}
- 禁止人群重叠:{'{count}'}条文案中身份场景不能重复靠变量池ABC保证)
- 禁止场景重复:同批次文案不能都是"早上上班"或都是"学生宿舍"
- 禁止人群重叠:{'{count}'}条文案主切入人群维度不能重复靠Q1切入配方按条轮转保证)
- 禁止场景重复:同批次文案开篇场景不能雷同不能N条都挤在同一个使用场景
- 避免生硬推销词:按头安利/绝绝子/闭眼冲 不能出现
""".strip()
# ── 系统 prompt合并Q2/Q3/Q4/Q7/Q8数据层走build_prompt动态注入──────────
# ── 北哥出单强化层·五大要害(实测出单命门,与富贵框架叠加,冲突以本层为准)──
_BEIGE_FIVE = """
【北哥出单强化层·五大要害(最高优先级,与上面框架叠加,冲突时以本层为准)】
要害①·标题广覆盖+悬念钩子:标题要罩住尽量多的人,不要一上来就锁死单一窄人群
(只喊一类窄人群会把其他潜在人群挡在外面);用悬念/反差/疑问做钩子留个扣子让人想点进来;
精准筛人交给标签层,不靠标题筛人。
要害②·开篇召集痛点:正文要把这个产品能解决的痛点尽量多列几个、召集多类人
"不管你是…还是…"的覆盖感),让更多人对号入座,不要只写一类人的一个痛点。
注意:召集痛点不必都堆在第一句——开篇方式由本条的叙事主线决定(见下方叙事主线),
痛点先行的可开篇即戳痛点,场景先行/成分背书先行的则在叙事展开中自然召集多类人,
绝不能因为这条要害就把三套都写成同一个"赶时间/痛点"开头。
要害③·第三方闺蜜真实背书:种草借第三方视角(闺蜜/同事/家人说、被人问、别人推荐),
"别人替你证言"的真实感,不是自己反复夸自己回购多少次;自夸味=广告味=掉信任。
要害④·成分逐条对应痛点每个核心成分都明确对上它解决的那个痛点成分1→痛点1成分2→痛点2
逐条映射讲清"为什么有用",翻译成用户能感知的结果,不堆术语、不把成分名堆在一起不解释。
要害⑤·精准购买意向标签埋词tags 不要凑流量泛词(#好物分享#真实测评 这类谁都能用的大词价值低),
要埋"有明确购买意向的人才会搜"的精准词——目标人群+品类+核心卖点/场景组合成精准长尾标签,
至少2-3个精准埋词。
这5条是北哥实测出单的命门务必逐条命中。
""".strip()
# ── 系统 prompt合并Q2/Q3/Q4/Q7/Q8 + 北哥五段数据层走build_prompt动态注入──
COPY_SYSTEM = f"""{_PERSONA}
合规红线(全品类通用):
@@ -118,6 +148,8 @@ COPY_SYSTEM = f"""{_PERSONA}
{_ANTI_AI.format(count="N")}
{_BEIGE_FIVE}
{_TITLE_FORMULA}
{_EMOJI_RULES}
@@ -178,30 +210,42 @@ def build_prompt(product: dict, count: int, extra_rules: str = "",
custom = (product.get("custom_prompt") or "").strip()
brand_kw = (product.get("brand_keyword") or "").strip()
# Q1每条抽一组随机变量,传给模型作角色约束
combos = [_pick_combo() for _ in range(count)]
# Q1每条按index轮转主人群维度+随机情绪/波折,传给模型作切入约束
combos = [_pick_combo(i) for i in range(count)]
combos_text = "\n".join(
f"{i+1}条:身份场景=「{c['identity']}」·起因情绪=「{c['emotion']}」·小缺点=「{c['flaw']}"
f"{i+1}条:主切入人群维度=「{c['identity']}」·情绪触发=「{c['emotion']}」·真实小波折=「{c['flaw']}"
for i, c in enumerate(combos)
)
angle_hint = f"文案角度要覆盖:{''.join(angles)}(每条用不同角度)。" if angles else ""
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
brand_rule = (
f"每条正文中自然植入品牌词「{brand_kw}」至少一次(融入语境,不生硬)。"
f"标题以悬念/钩子优先(北哥要害①),品牌词不必硬塞进标题;"
f"若要进标题须自然不抢戏,禁止把品牌词堆在标题开头当详情页商品名(如'{brand_kw}{name}'式)。"
) if brand_kw else ""
benchmark_block = _build_benchmark_block(product.get("benchmark_refs") or [])
# strategy_narrative 是三套拉开差异的核心,加强标记顶到最高权重
narrative_block = (
f"\n========== 本套叙事主线(最高优先级·决定开篇与全文走向,禁止偏离)==========\n"
f"{strategy_narrative}\n"
f"==============================================================\n"
if strategy_narrative else ""
)
lines = [
f"产品:{name}",
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效词;翻译范例:'烟酰胺''熬夜后第二天脸不那么黄了''高保湿''涂上去一整天都没搓泥拔干'{selling}",
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效/参数词;翻译范例:技术参数'XX配方/XX材质''用起来的某个具体好处''大容量''出门一整天都不用补'{selling}",
f"风格调性:{style}",
strategy_narrative,
narrative_block,
angle_hint,
brand_rule,
custom,
benchmark_block,
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用",
f"\n【Q1切入配方·每条主切入人群维度不同,模型按本产品把维度填成具体人群",
combos_text,
extra_rules,
f"\n请严格按5步框架+四段结构生成 {count}每条350-400字返回纯JSON数组。",
f"\n请严格按5步框架+叙事骨架生成 {count} 条,开篇方式必须服从上方叙事主线,每条350-400字返回纯JSON数组。",
]
return "\n".join(l for l in lines if l)
@@ -228,15 +272,15 @@ def build_local_drafts(product: dict, count: int) -> list[dict]:
_fallback_angles = ["生活场景型", "成分分析型", "使用感受型", "性价比型", "痛点切入型"]
angles_pool = product.get("text_angles") or _fallback_angles
for i in range(count):
c = _pick_combo()
c = _pick_combo(i)
# 循环取不同角度(角度相同的两条会被 dedupe_copies 过滤掉,所以必须不重复)
angle = angles_pool[i % len(angles_pool)]
yield {
"title": f"发现一个{name}{angle}用法",
"content": (
f"{c['identity']}{c['emotion']}\n\n"
f"最近开始用{name}{c['emotion']}\n\n"
f"用了一段时间,{points[i % len(points)]}这点最让我意外。\n\n"
f"说个小缺点{c['flaw']},后来才摸到感觉。\n\n"
f"说个小波折{c['flaw']},后来才摸到感觉。\n\n"
f"反正现在用顺手了。✅"
),
"tags": [f"#{name}", "#真实测评", "#好物分享"],

View File

@@ -0,0 +1,121 @@
"""
北哥醒图调色层去AI化·暖棕奶茶色调
来源:北哥 v2 教程 f_010 醒图参数倩倩姐2026-06-15录入Pillow 近似实现。
参数:色温+28(暖棕灵魂)/高光-35(压瓶身反光)/阴影+22/对比+9/饱和+6/暗角-10/HSL橙肤色。
开关:环境变量 BEIGE_COLOR_GRADE默认开(=1)off 则原样返回不调色。
诚实声明醒图是分区调整Pillow 只能做全局近似,方向一致、强度可参数化微调。
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
try:
from PIL import Image, ImageEnhance
_PILLOW_OK = True
except ImportError:
_PILLOW_OK = False
# 北哥醒图默认参数(归一化到 Pillow 可用范围±5 浮动由调用方覆盖)
# 数值含义见模块 docstring强度系数经验值落地后按实拍微调
BEIGE_PRESET: dict[str, float] = {
"warmth": 28.0, # 色温(暖棕灵魂,口诀+25~30) → R提/B压
"highlight": -35.0, # 高光(压瓶身反光,固定压暗高光区)
"shadow": 22.0, # 阴影(提暗部)
"contrast": 9.0, # 对比度
"saturation": 6.0, # 饱和(别高=廉价感)
"vignette": 10.0, # 暗角(聚焦中间,越大四角越暗)
}
def is_enabled() -> bool:
"""北哥调色开关,默认关(倩倩姐2026-06-22拍板)。设 BEIGE_COLOR_GRADE=1 重开。
关闭理由:北哥醒图 warmth=28 是给"手机翻拍真实照"去AI化用的
gpt-image-2 成片本身已渲染好色调,再叠暖色温→脏黄发闷(实测R-B黄偏11→14.2)。
去AI化靠 SynthID 破除+重编码即可调色这层在AI成片上帮倒忙。
本模块代码保留,需要时设 BEIGE_COLOR_GRADE=1 即可重开调参。
"""
return os.environ.get("BEIGE_COLOR_GRADE", "0") == "1"
def _apply_warmth(img: "Image.Image", warmth: float) -> "Image.Image":
"""色温暖棕奶茶调灵魂。R通道提、B通道压强度由 warmth 控制。"""
if abs(warmth) < 1:
return img
k = warmth / 100.0 # 28 → 0.28
r, g, b = img.split()
r = r.point(lambda v: min(255, int(v * (1 + 0.28 * k))))
b = b.point(lambda v: max(0, int(v * (1 - 0.30 * k))))
g = g.point(lambda v: min(255, int(v * (1 + 0.05 * k)))) # 微提绿保肤色不偏品红
return Image.merge("RGB", (r, g, b))
def _apply_high_shadow(img: "Image.Image", highlight: float, shadow: float) -> "Image.Image":
"""高光压暗(压反光) + 阴影提亮(提暗部),按像素亮度分区近似。"""
if abs(highlight) < 1 and abs(shadow) < 1:
return img
hl = highlight / 100.0 # -35 → -0.35
sh = shadow / 100.0 # 22 → 0.22
def _curve(v: int) -> int:
t = v / 255.0
# 高光区(t>0.6)按 hl 压暗;阴影区(t<0.4)按 sh 提亮;中间过渡
if t > 0.6:
v2 = t + hl * (t - 0.6) / 0.4 * t
elif t < 0.4:
v2 = t + sh * (0.4 - t) / 0.4 * (1 - t)
else:
v2 = t
return max(0, min(255, int(v2 * 255)))
lut = [_curve(i) for i in range(256)]
return img.point(lut * 3)
def _apply_vignette(img: "Image.Image", strength: float) -> "Image.Image":
"""暗角四角压暗聚焦中间。strength 越大越暗。"""
if strength < 1:
return img
w, h = img.size
s = strength / 100.0 # 10 → 0.10
mask = Image.new("L", (w, h), 0)
px = mask.load()
cx, cy = w / 2.0, h / 2.0
maxd = (cx ** 2 + cy ** 2) ** 0.5
# 降采样算遮罩省时每4px算一次
step = 4
for y in range(0, h, step):
for x in range(0, w, step):
d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 / maxd
darken = int(255 * s * (d ** 2))
for dy in range(step):
for dx in range(step):
if x + dx < w and y + dy < h:
px[x + dx, y + dy] = darken
black = Image.new("RGB", (w, h), (0, 0, 0))
return Image.composite(black, img, mask)
def grade(image_bytes: bytes, preset: dict | None = None) -> bytes:
"""对单张图施加北哥暖棕调色。开关关或Pillow缺失则原样返回失败降级返原图。"""
if not is_enabled() or not _PILLOW_OK:
return image_bytes
p = {**BEIGE_PRESET, **(preset or {})}
try:
img = Image.open(__import__("io").BytesIO(image_bytes)).convert("RGB")
img = _apply_warmth(img, p["warmth"])
img = _apply_high_shadow(img, p["highlight"], p["shadow"])
if p["contrast"]:
img = ImageEnhance.Contrast(img).enhance(1 + p["contrast"] / 100.0)
if p["saturation"]:
img = ImageEnhance.Color(img).enhance(1 + p["saturation"] / 100.0)
img = _apply_vignette(img, p["vignette"])
buf = __import__("io").BytesIO()
img.save(buf, format="JPEG", quality=100, subsampling=0, optimize=True)
return buf.getvalue()
except Exception as exc:
logger.warning("北哥调色失败,降级返回原图: %s", exc)
return image_bytes

View File

@@ -28,16 +28,17 @@ SCORE_WEIGHTS = {
"keyword": 20,
"compliance": 5,
}
# ── AI 评委 7 维满分分布倩倩姐2026-06-15拍板·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
# 6维AI读分(痛点18+情绪18+买点18+钩子15+标题13+真实感13=95) + 合规5 = 100
# "真实感"=富贵"很少提产品/前70%干货后30%植入"原则,替换旧机械维度"产品聚焦一件事(16)"
# ── AI 评委 7 维满分分布倩倩姐2026-06-22拍板补标签精准·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
# 7维AI读分(痛点16+情绪16+买点16+钩子13+标题13+真实感11+标签精准10=95) + 合规5 = 100
# 标签精准度=北哥要害⑤出单命门(精准购买意向埋词)分从原6维各匀出总分仍95合格线80不动非降门槛
AI_DIM_WEIGHTS = {
"痛点人群精准": 18,
"情绪张力": 18,
"买点转化": 18,
"开头钩子": 15,
"痛点人群精准": 16,
"情绪张力": 16,
"买点转化": 16,
"开头钩子": 13,
"标题点击力": 13,
"真实感": 13,
"真实感": 11,
"标签精准度": 10,
"compliance": 5, # 机械硬拦,不进 AI 评委
}
# 过线分。倩倩姐2026-06-15拍板80是临时观察值(AI评委给分克制84文案实为合格)。
@@ -54,13 +55,13 @@ DEDUP_TITLE_CONTENT_BODY = 0.72 # 标题+正文联合判重时的正文阈值
MAX_OPTIMIZE_ROUNDS = 2 # 最多重生成轮次
# ── storyboard 分镜角色(枚举不写死数量)────────────────
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤效果证明(按品类) ⑥促单
PAGE_ROLES = [
{"role": "hook", "name": "封面痛点大字", "focus": "负责点击:强情绪大字标题压痛点,产品露出,真实生活场景,像用户主动分享,不像广告海报"},
{"role": "product_closeup", "name": "单品特写", "focus": "负责种草锚点:单品高清特写+品牌词自然植入第2/6张都带品牌词强化记忆"},
{"role": "ingredient", "name": "成分拆解", "focus": "负责信任:核心成分信息、作用说明,避免医疗化和绝对化表达,信息清晰可信"},
{"role": "texture", "name": "质地展示", "focus": "负责种草:质地近景、涂抹过程、肤感说明,真实手部/桌面/日常光线"},
{"role": "applied_proof", "name": "上脸对比", "focus": "负责证明:可感知上脸效果,展示涂抹前后质地变化(不做肤色变白/瑕疵消失等违规暗示第5张"},
{"role": "applied_proof", "name": "效果证明", "focus": "负责证明:可感知的真实使用效果,按品类展示使用过程/状态变化(护肤=上脸肤感/食品=入口口感/家居数码=使用顺手等,不做违规功效暗示第5张"},
{"role": "closer", "name": "促单收尾", "focus": "负责转化:转化句+品牌词引导搜索品牌词成交软性收尾不硬广第6张再带一次品牌词"},
# 扩展角色8张链路用
{"role": "pain_scene", "name": "痛点共鸣", "focus": "负责共鸣:展示目标人群的真实困扰和使用前情境,但不做功效前后对比"},
@@ -79,7 +80,7 @@ ROLE_SCENE_PREFERENCE = {
"product_closeup": ["primary"], # 单品特写:白底主图
"ingredient": ["ingredient", "primary"], # 成分拆解:成分/包装细节
"texture": ["texture", "primary"], # 质地展示:质地特写
"applied_proof": ["model", "scene", "primary"], # 上脸:上脸图/场景
"applied_proof": ["model", "scene", "primary"], # 效果证明:使用场景/上身/上脸图
"social_proof": ["scene", "primary"], # 社交背书:场景
"closer": ["primary"], # 促单收尾:主图
"scenario": ["scene", "primary"],
@@ -91,8 +92,8 @@ ROLE_SCENE_PREFERENCE = {
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
STYLE_PROMPTS = {
"xiaohongshu_cover": "小红书种草风独立3:4图文海报/素材图1024×1536构图明亮干净真实实拍质感醒目中文短标题文字在安全区内",
"comparison": "小红书说明对比风独立3:4图文海报/素材图1024×1536构图质地/场景/肤感左右或上下对比,信息层级清晰",
"ingredient": "小红书成分科普风独立3:4图文海报/素材图1024×1536构图成分卡片布局浅色商务美妆风,避免医疗化表达",
"comparison": "小红书说明对比风独立3:4图文海报/素材图1024×1536构图质地/场景/使用状态左右或上下对比,信息层级清晰",
"ingredient": "小红书信息科普风独立3:4图文海报/素材图1024×1536构图成分/参数卡片布局,浅色干净商务风,避免医疗化表达",
}
STYLE_DEFAULT = "xiaohongshu_cover"
@@ -100,8 +101,8 @@ STYLE_DEFAULT = "xiaohongshu_cover"
# 按图数告诉模型整组图的种草节奏,让每张各司其职不雷同
NARRATIVE_BY_COUNT = {
3: "3张极速链路第1张负责点击第2张是按品类变化的核心证明页第3张负责软性转化。",
6: "6张标准种草链路封面点击、单品特写带品牌词、成分信任、质地种草、上脸证明、促单转化,每张画面和文字各司其职不重复。",
8: "8张沉浸测评链路点击、痛点共鸣、单品特写、成分、质地、上脸证明、背书、软性转化。",
6: "6张标准种草链路封面点击、单品特写带品牌词、成分/参数信任、质地/细节种草、按品类的真实效果证明、促单转化,每张画面和文字各司其职不重复。",
8: "8张沉浸测评链路点击、痛点共鸣、单品特写、成分/参数、质地/细节、按品类的真实效果证明、背书、软性转化。",
}
# ── 3套正交叙事策略倩倩姐2026-06-15起草北哥过目版──────────────
@@ -109,19 +110,19 @@ NARRATIVE_BY_COUNT = {
# 每套叙事链路注入 base_prompt 叙事链路段,替换 NARRATIVE_BY_COUNT 默认值
NARRATIVE_BY_STRATEGY = {
"A": (
'【套A·痛点先行】整组基调紧迫感、强对比、情绪共鸣文字短促带感叹号直戳"脸黄显疲惫""素颜不敢出门"'
'6张链路①痛点暴击封面强情绪大字直击暗黄/素颜焦虑)→ ②暗黄脸实拍对比(感叹号+对比词制造紧迫感)'
'→ ③单品特写+品牌词 → ④成分为什么能救暗黄(成分拆解+信任) → ⑤上脸提亮实证 → ⑥"别再顶着黄脸早八"软性促单。'
'【套A·痛点先行】整组基调紧迫感、强对比、情绪共鸣文字短促带感叹号直戳产品要解决的核心痛点(痛点取自上文产品卖点/人群,不要套用别的品类)'
'6张链路①痛点暴击封面强情绪大字直击该产品核心痛点)→ ②痛点实拍对比(感叹号+对比词制造紧迫感)'
'→ ③单品特写+品牌词 → ④核心成分为什么能解决该痛点(成分拆解+信任) → ⑤使用效果实证 → ⑥用痛点收束的软性促单。'
),
"B": (
'【套B·场景先行】整组基调轻松、生活化、代入感突出"快/省时/伪素颜自由",点到性价比不堆砌。'
'6张链路"早八来不及"场景封面(生活场景钩子) → ②手忙脚乱通勤场景代入早八焦虑'
'→ ③一抹搞定单品特写+品牌词 → ④养肤成分让你敢素颜 → ⑤30秒上脸效果 → ⑥"伪素颜自由+平价"软性促单。'
'【套B·场景先行】整组基调轻松、生活化、代入感突出该产品的真实使用场景与"快/省时/省心"卖点,点到性价比不堆砌。'
'6张链路真实使用场景封面(取该产品最高频的生活场景钩子) → ②场景代入(还原用户在该场景里的困扰'
'→ ③单品特写+品牌词 → ④核心成分支撑该场景卖点 → ⑤使用效果展示 → ⑥用场景化卖点收束的软性促单。'
),
"C": (
'【套C·成分背书先行】整组基调专业、可信、真实测评感强调成分逻辑+前后对比+像有用户实证背书。'
'6张链路①成分权威封面核心成分信息锚定信任 → ②核心成分图解(作用说明+清晰可信)'
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实上脸细节 → ⑥"成分党闭眼入"软性促单。'
'6张链路①成分权威封面该产品核心成分信息锚定信任) → ②核心成分图解(作用说明+清晰可信)'
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实使用细节 → ⑥用成分可信度收束的软性促单。'
),
}
@@ -130,23 +131,37 @@ NARRATIVE_BY_STRATEGY = {
# 与 NARRATIVE_BY_STRATEGY图片侧同根套A文案痛点先行↔套A图也痛点先行同套内文图一致
TEXT_NARRATIVE_BY_STRATEGY = {
"A": (
"【本条叙事主线·痛点先行】开篇直戳用户困扰(脸黄显疲惫/素颜不敢出门/早八顶着黄脸),"
"情绪强、句子短促带感叹,痛点贯穿全文到种草,结尾用'别再顶着黄脸早八'这类痛点收束促单。"
"基调:紧迫感、强对比、情绪共鸣"
"【本条叙事主线·痛点先行】开篇第一句必须直接说出'使用前的困扰/焦虑情绪'本身"
"(取自本产品要解决的那个具体痛点,以及试过很多没用的挫败感),先有情绪再有场景,"
"痛点取自上文产品卖点/目标人群、不要套用别品类;情绪强、句子短促带感叹"
"⚠️开篇禁止从'时间/场景'切入如一天里的某个时刻、赶时间、通勤那是套B的开法必须先抛情绪/痛点。"
"▶中段背书方式=自己反复挣扎后的转折(试错→死心→偶然回购/被一个生活细节打动),"
"靠自己使用前后的真实变化来自证,不要用'闺蜜/同事/家人种草'来背书。"
"▶卖点要带着'这个困扰被解决了'的情绪逐个讲emoji用情绪向😭🥹💛"
"▶收尾用该痛点的释然收束('现在不慌了'类),禁止用'被身边人夸/被问起'桥段那是套B的尾"
),
"B": (
"【本条叙事主线·场景先行】用真实生活场景开篇(早八来不及/通勤手忙脚乱/赶时间出门),"
"轻松代入感,突出'快/省时/伪素颜自由',点到平价性价比但不堆砌。"
"基调:轻松、生活化、像朋友随手分享"
"【本条叙事主线·场景先行】开篇用一个具体的生活使用场景白描切入"
"(最真实高频的那个使用时刻,画面感优先),痛点藏在场景里自然带出、不喊口号,"
"突出'快/省时/省心'类卖点,点到平价性价比但不堆砌"
"▶中段背书方式=身边人(同事/家人)在场景里的一句随口反馈,轻松不刻意。"
"▶成分融在使用场景里一两句带过即可不要单独列大段成分清单emoji用场景向☕🚇✨"
"▶收尾用场景延续('下次再有人问…我知道怎么答了'类)。"
),
"C": (
"【本条叙事主线·成分背书先行】用成分原理或测评视角开篇(核心成分为什么有用/亲测对比),"
"专业可信,带使用前后时间线对比,像真实用户实证背书,结尾用'成分党闭眼入'收束。"
"基调:专业、可信、真实测评感"
"【本条叙事主线·专业背书先行】开篇必须从'成分/参数/原理/测评对比'等专业依据切入"
"(按本产品品类对应:护肤食品讲成分配方、数码家居讲参数原理、服饰讲材质工艺,"
"如翻配料/参数表、对比实验、为什么这个设计/成分有用),先讲可信度再讲使用"
"⚠️开篇禁止从'时间/场景'切入如赶时间、通勤、被人问起那是套A/B的开法必须从专业依据入手。"
"▶中段背书方式=理性自证(参数/成分对比/和踩过的雷横向比/前后时间线实测/参考测评博主的拆解分析),"
"靠数据和亲测说话,不要用'闺蜜/同事/家人种草'来背书。"
"▶专业依据要讲得比A/B更深为什么这项关键/感知差异/和别家差在哪),是本套的差异优势,"
"emoji用理性向🔍💧🧪收尾用专业可信度收束。"
),
}
IMAGE_RETRY_ATTEMPTS = 3 # 生图重试总次数(含首次)
IMAGE_RETRY_BACKOFF_BASE = 2.0 # 指数退避底数(秒)
IMAGE_SIZE_DEFAULT = "1024x1536"
@@ -185,6 +200,7 @@ FLYWHEEL_WEIGHTS = {
"text_select": 3,
"image_select": 3,
"text_edit": 5, # 改稿=用户真动手改字=最强真实意图,与approve同级(倩倩姐2026-06-16拍板)
"text_import": 8, # 导入=客户实跑验证过的范本,权重最重(倩倩姐2026-06-26拍板),与enums.SIGNAL_WEIGHTS对齐
"approve": 5,
"reject_with_reason": -3,
"regenerate": -1,

View File

@@ -137,7 +137,13 @@ def build_fission_prompt(
source_note: dict, product: dict, reference_level: str,
note_count: int, image_count: int, dimensions: list[str] | None = None,
) -> str:
"""组装裂变 user prompt对齐上线版 handleContentSplit 的 prompt 变量拼装)。"""
"""组装裂变 user prompt对齐上线版 handleContentSplit 的 prompt 变量拼装)。
D3修复给每套分配正交叙事主线复用 TEXT_NARRATIVE_BY_STRATEGY A/B/C
套数超过3时按顺序循环套4=A/套5=B让各套开篇/背书/收尾正交拉开。
"""
from app.services.ai_engine.constants import TEXT_NARRATIVE_BY_STRATEGY
src = source_note or {}
prod = product or {}
dims = dimensions or DEFAULT_DIMENSIONS
@@ -149,9 +155,24 @@ def build_fission_prompt(
points = prod.get("selling_points", []) or []
audience = prod.get("target_audience", "") or "未提供"
keywords = prod.get("keywords", []) or []
kw_line = "".join(keywords) if keywords else "".join(
[x for x in [name, audience, "真实测评", "好物分享"] if x and x != "未提供"]
)
# D2修复去掉"真实测评/好物分享"泛词兜底评分prompt明确泛词占≥50%最高6分自打自
# 改用产品真实信息提炼精准词实在无keywords也不塞流量泛词
if keywords:
kw_line = "".join(keywords)
else:
# 从产品卖点/人群/名称里取实质性词,不拼平台泛词
sp_words = [str(s).strip() for s in (points[:2] if points else []) if str(s).strip()]
kw_parts = [x for x in ([name] + sp_words + ([audience] if audience != "未提供" else [])) if x and x != "未提供"]
kw_line = "".join(kw_parts) if kw_parts else ""
# D3修复按套序循环分配 A/B/C 叙事主线
narrative_keys = list(TEXT_NARRATIVE_BY_STRATEGY.keys()) # ["A","B","C"]
narrative_lines = []
for i in range(note_count):
key = narrative_keys[i % len(narrative_keys)]
narrative_lines.append(f"{i+1}套叙事主线:{TEXT_NARRATIVE_BY_STRATEGY[key]}")
narrative_block = "\n".join(narrative_lines)
return f"""爆款参考:
标题:{title}
正文:{content}
@@ -166,7 +187,11 @@ def build_fission_prompt(
生成数量:{note_count}
每套图片数量:{image_count}
【各套正交叙事主线——必须严格执行,这是本次裂变不同质的关键】
{narrative_block}
请生成{note_count}套完整小红书图文笔记包。每套都必须含标题、正文、标签、点击钩子标题、{image_count}张图的imagePlan。
每套必须严格按其指定叙事主线展开开篇/背书/收尾,不同套之间叙事结构必须正交、不重复。
imagePlan必须按叙事链路逐张递进。3张版第2张必须是按当前品类变化的核心证明页不能和第1张重复构图不得让第2张重复第1张标题。
正文必须像真实小红书种草笔记一样自然带2-5个emoji不要把图片规划/配图建议/内部审核建议写进正文。"""

View File

@@ -43,6 +43,15 @@ class AIClients:
_model_image: str = "gpt-image-2"
_model_text: str = "claude-opus-4-8" # 最强档(倩倩姐红线):Claude系一律4.8,绝不降级
def has_alt_channel(self) -> bool:
"""是否真正配置了 codeproxy 备用通道(有独立 key 才算)。
🔴 没配 codeproxy key 时(用户未录 + env 未设),编排层据此跳过 codeproxy
避免每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次。
录了 key 就自动启用真双通道互备,逻辑不变。
"""
return bool(self._alt_token)
def _client(self) -> httpx.AsyncClient:
"""主通道(apiports) client按当前事件循环缓存"""
return self._client_for(self._gpt_base, self._gpt_token)
@@ -67,15 +76,33 @@ class AIClients:
# ── ImageClient 协议实现(供 image_gen.py 使用)────────
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
"""按 provider 选 (base, token, client)codeproxy 走备用站独立 base+key"""
if provider == "codeproxy" and self._alt_token:
"""按 provider 选 (base, token, client)codeproxy 走备用站独立 base+key
🔴 绝不静默降级(红线):显式点名 codeproxy 却没有 codeproxy 独立 key 时,
必须抛错让该通道明确失败(由上层 generate_one_image 记录并切下一通道),
绝不静默用 apiports 主 key 冒充 codeproxy——那等于"假双通道"且无声退化,
既骗了主备设计也违反红线。用户要真备份就得录入第二把 key。
"""
if provider == "codeproxy":
if not self._alt_token:
raise RuntimeError(
"codeproxy 通道未配置独立 key,拒绝用 apiports 主 key 冒充"
"(红线:绝不静默降级)。请为该用户录入 codeproxy key 以启用真双通道互备。"
)
base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
return base, self._alt_token, self._client_for(base, self._alt_token)
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
return base, self._gpt_token, self._client_for(self._gpt_base, self._gpt_token)
async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
"""GPT edits endpoint带产品参考图禁纯文生图"""
"""GPT edits endpoint带产品参考图禁纯文生图
codeproxy 走 OpenAI Images `/images/edits`apiports 实测图片编辑注册在
`/chat/completions` 多模态通道,不能复用 `/images/edits`。
"""
if provider == "apiports":
return await self._apiports_chat_image_edit(prompt, reference_images)
import io
files: list[tuple] = [("prompt", (None, prompt))]
for i, img in enumerate(reference_images):
@@ -87,6 +114,29 @@ class AIClients:
resp.raise_for_status()
return _extract_image_bytes(resp.json())
async def _apiports_chat_image_edit(self, prompt: str, reference_images: list[bytes]) -> bytes:
"""apiports 图片编辑:走 /chat/completions 多模态,参考图用 data URL。"""
if not reference_images:
raise ValueError("apiports 图生图缺少参考图,拒绝纯文生图")
import base64
base, _, client = self._gpt_target("apiports")
content: list[dict] = [{"type": "text", "text": prompt}]
for img in reference_images:
b64 = base64.b64encode(img).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
payload = {
"model": self._model_image,
"messages": [{"role": "user", "content": content}],
}
resp = await client.post(f"{base}/chat/completions", json=payload, timeout=300.0)
resp.raise_for_status()
return _extract_chat_image_bytes(resp.json())
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes:
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
base, _, client = self._gpt_target(provider)
@@ -100,14 +150,17 @@ class AIClients:
if not self._gemini_key:
raise RuntimeError("Gemini key 未初始化")
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
url = f"{gemini_base}/models/{model}:generateContent?key={self._gemini_key}"
# 安全红线key 走 header(x-goog-api-key)不进 URL query
# 否则 httpx 异常的 __str__ 含完整 URL会把明文 key 带进 WARNING 日志。
url = f"{gemini_base}/models/{model}:generateContent"
parts: list[dict] = [{"text": prompt}]
for img in reference_images:
import base64
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=payload, timeout=120.0)
resp = await client.post(url, json=payload,
headers={"x-goog-api-key": self._gemini_key}, timeout=120.0)
resp.raise_for_status()
return _extract_gemini_image(resp.json())
@@ -126,9 +179,10 @@ class AIClients:
resp.raise_for_status()
return resp.json()
except (httpx.HTTPStatusError, httpx.TransportError, httpx.TimeoutException) as exc:
# 仅 5xx(服务端过载)或网络层错误才回落4xx(参数/鉴权)回落也没用,直接抛。
# 回落判定网络层错误、5xx(过载)、以及 402(欠费)/429(限流) 都该切通道——
# 这几种都是"本通道暂时不行、换通道有救"。401/403(鉴权)/400(参数) 回落也没用,直接抛。
status = getattr(getattr(exc, "response", None), "status_code", None)
retryable = status is None or status >= 500
retryable = status is None or status >= 500 or status in (402, 429)
if not (retryable and self._alt_token):
raise
alt_base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
@@ -196,21 +250,26 @@ class AIClients:
self._gpt_client_loop_id = None
def build_ai_clients(plain_key: str, gemini_key: str | None = None) -> AIClients:
def build_ai_clients(plain_key: str, gemini_key: str | None = None, alt_key: str | None = None) -> AIClients:
"""
用解密后的明文 key 构建 AIClients。
只在 Celery worker 函数体内调用plain_key 是局部变量。
httpx client 不在此预创建(避免绑死到调用方 loop首次 await 时按 loop 懒建。
调用完成后 caller 负责 await clients.aclose()。
alt_key用户录入的 codeproxy 备用站 key基石Bworker 内查库解密后传入)。
倩倩姐红线「全做成自己的不要埋进系统」——codeproxy key 优先用用户录入的,
未录入才回落 env CODEPROXY_KEY向后兼容避免没录时备用通道直接失效
base 地址(中转站 URL非秘密仍走 env 配置。
"""
gpt_base = (
os.environ.get("IMAGE_API_BASE") # 旧变量名
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
or ""
).rstrip("/")
# 备用站 codeproxy系统级 key非用户录入apiports 503 时切过去保生图成功
# 备用站 codeproxy优先用户录入 key回落 envapiports 503 时切过去保生图成功
alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
alt_token = os.environ.get("CODEPROXY_KEY") or None
alt_token = alt_key or os.environ.get("CODEPROXY_KEY") or None
return AIClients(
_gpt_token=plain_key,
_gpt_base=gpt_base or None,
@@ -240,6 +299,95 @@ def _extract_image_bytes(resp_json: dict) -> bytes:
raise ValueError(f"无法解析图片响应:{list(item.keys())}")
def _decode_b64_image(value: str) -> bytes:
"""解 data URL 或裸 base64 图片。"""
import base64
if value.startswith("data:"):
value = value.split(",", 1)[1]
return base64.b64decode(value)
def _bytes_from_image_item(item: dict) -> bytes | None:
"""兼容 chat 图片 item 里的常见 url/base64 字段。"""
if not isinstance(item, dict):
return None
for key in ("b64_json", "base64", "data"):
val = item.get(key)
if isinstance(val, str) and val:
return _decode_b64_image(val)
url_val = None
if isinstance(item.get("image_url"), dict):
url_val = item["image_url"].get("url")
elif isinstance(item.get("image_url"), str):
url_val = item.get("image_url")
elif isinstance(item.get("url"), str):
url_val = item.get("url")
if isinstance(url_val, str) and url_val:
if url_val.startswith("data:"):
return _decode_b64_image(url_val)
resp = httpx.get(url_val, timeout=30.0)
resp.raise_for_status()
return resp.content
return None
def _bytes_from_content_string(text: str) -> bytes | None:
"""从 chat 返回的正文字符串里抠图data URL / markdown 图链 / 裸图片URL。
很多中转站把 gpt-image 结果以 ![](url) 或 data URL 嵌在 content 文本里,
不走 images[]/parts 结构化字段,这里兜住这几种常见形态(前面带说明文字也能抠出)。
"""
import re
m = re.search(r"data:image/[\w.+-]+;base64,[A-Za-z0-9+/=]+", text)
if m:
return _decode_b64_image(m.group(0))
m = re.search(r"!\[[^\]]*\]\((https?://[^\s)]+)\)", text) # markdown 图链
if m:
resp = httpx.get(m.group(1), timeout=30.0)
resp.raise_for_status()
return resp.content
m = re.search( # 裸图片URL限图片后缀避免误抓非图链接
r"https?://[^\s)\]\"']+\.(?:png|jpe?g|webp|gif)(?:\?[^\s)\]\"']*)?",
text, re.IGNORECASE,
)
if m:
resp = httpx.get(m.group(0), timeout=30.0)
resp.raise_for_status()
return resp.content
return None
def _extract_chat_image_bytes(resp_json: dict) -> bytes:
"""从 /chat/completions 图片响应中提取图片 bytes。"""
choices = resp_json.get("choices") or []
if not choices:
raise ValueError("chat 图片响应缺少 choices")
message = choices[0].get("message") or {}
images = message.get("images")
if isinstance(images, list):
for item in images:
found = _bytes_from_image_item(item)
if found:
return found
content = message.get("content")
if isinstance(content, list):
for part in content:
found = _bytes_from_image_item(part)
if found:
return found
elif isinstance(content, str) and content:
found = _bytes_from_content_string(content)
if found:
return found
raise ValueError(
"无法解析 chat 图片响应:"
f"top={list(resp_json.keys())}, message={list(message.keys())}"
)
def _extract_gemini_image(resp_json: dict) -> bytes:
"""从 Gemini generateContent 响应提取图片 bytes"""
import base64

View File

@@ -26,21 +26,37 @@ def _pick_reference_for_role(
images_by_scene: dict[str, list[bytes]] | None,
fallback: list[bytes] | None,
) -> tuple[list[bytes] | None, str]:
"""R5多图按分镜 role 选该场景的产品图。取不到回落主图
"""R5多图把【全部】产品参考图都传给每张分镜,按 role 偏好排序
返回 (参考图bytes列表, 命中scene标签用于日志)。
🔴 产品内外一体,绝不按 role 只喂单张倩倩姐2026-06-26拍板纠正
旧逻辑命中偏好场景就 return 单张 → 质地特写分镜只拿到膏体微距、没有主图,
模型缺完整瓶身锚点 → 脑补瓶身走样+乱印字("3张好/4张每套坏一张"的根因)。
现改为每张分镜都把所有场景图一起传,只把本 role 最相关的场景排最前
(多模态对靠前的图权重更高),且主图(primary)始终在列表内做瓶身锚点。
返回 (排序后的全部参考图bytes列表, 排序说明用于日志)。
"""
if images_by_scene:
for scene in ROLE_SCENE_PREFERENCE.get(role, ["primary"]):
prefs = ROLE_SCENE_PREFERENCE.get(role, ["primary"])
ordered: list[bytes] = []
used_scenes: list[str] = []
# 1) 先按 role 偏好顺序排(本 role 最相关的视角靠前)
for scene in prefs:
imgs = images_by_scene.get(scene)
if imgs:
return imgs, scene
# 偏好全落空:用任意可用图兜底(仍优先 primary
if images_by_scene.get("primary"):
return images_by_scene["primary"], "primary"
ordered.extend(imgs)
used_scenes.append(scene)
# 2) 主图始终保底进列表(即使不在偏好里,也要补做瓶身锚点)
if "primary" not in used_scenes and images_by_scene.get("primary"):
ordered.extend(images_by_scene["primary"])
used_scenes.append("primary")
# 3) 其余场景图也补进来:同一产品的所有视角都给模型看,防瓶身走样
for scene, imgs in images_by_scene.items():
if imgs:
return imgs, f"{scene}(兜底)"
if scene not in used_scenes and imgs:
ordered.extend(imgs)
used_scenes.append(scene)
if ordered:
return ordered, "+".join(used_scenes)
return fallback, "fallback"
@@ -53,17 +69,42 @@ class ImageClient(Protocol):
async def gemini_generate(
self, prompt: str, reference_images: list[bytes], model: str
) -> bytes: ...
def has_alt_channel(self) -> bool: ...
def _image_provider_order() -> list[str]:
"""从环境变量读主备顺序(扒 imageProviderOrder"""
def _image_provider_order(has_alt: bool = True) -> list[str]:
"""从环境变量读主备顺序(扒 imageProviderOrder
🔴 真双通道互备倩倩姐2026-06拍板codeproxy / apiports 是两个独立中转站,
各用自己的 base+key, 任一站上游挂(502/503)由另一站顶上 —— 这才是真备份。
若 .env 把主备都设成同一个站(如都 codeproxy), 去重后只剩一个 → 退化成"假备份"
(一站挂全挂)。故只要序列用到 GPT 中转站家族, 就自动补齐缺失的那个,
保证 codeproxy+apiports 双通道都在。"gpt" 是 apiports 的等价别名(同 base+主key),
归一化掉避免重复尝试同一站。codeproxy 走 /images/editsapiports 走
/chat/completions 多模态编辑;两者都必须带产品参考图,不碰纯文生图。
🔴 has_alt=False用户/env 都没配 codeproxy key绝不把 codeproxy 补进序列——
否则每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次拖慢整体
倩倩姐2026-06-30方案A。录了 keyhas_alt=True才补齐真双通道逻辑不变。
"""
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
seen: list[str] = []
order: list[str] = []
for p in [primary, fallback]:
if p and p not in seen:
seen.append(p)
return seen
p = "apiports" if p == "gpt" else p
# 没配 codeproxy key 时,显式点名 codeproxy 也跳过(避免空转重试)
if p == "codeproxy" and not has_alt:
continue
if p and p not in order:
order.append(p)
if any(p in ("codeproxy", "apiports") for p in order):
for ch in ("codeproxy", "apiports"):
# codeproxy 仅在真有备用 key 时补齐
if ch == "codeproxy" and not has_alt:
continue
if ch not in order:
order.append(ch)
return order
def _gemini_models() -> list[str]:
@@ -90,12 +131,20 @@ async def _retry(coro_fn, attempts: int = IMAGE_RETRY_ATTEMPTS, backoff: float =
async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes:
if reference_images:
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true"
if allow_text_only:
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)")
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider)
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)")
# 🔴 红线瓶身忠于参考图原样禁止纯文生图脑补产品倩倩姐2026-06-15拍板
# ALLOW_TEXT_ONLY_IMAGE 默认 false即使显式设为 true 也拒绝——纯文生图会让产品包装
# 完全由模型自由发挥,严重走样,不符合合规与真实双重要求。
# 若要解锁请联系产品负责人,不在代码层开放。
_env_val = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "false").lower()
if _env_val == "true":
logger.warning(
"检测到 ALLOW_TEXT_ONLY_IMAGE=true但该选项已被锁定红线禁纯文生图"
"将强制抛出缺图异常,请为产品上传参考图。"
)
raise ValueError(
"缺少产品参考图,无法生成忠于实物的产品图,请先上传该产品的主图。"
"红线禁止纯文生图脑补产品包装ALLOW_TEXT_ONLY_IMAGE 已锁定为无效)"
)
async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes:
@@ -118,7 +167,9 @@ async def generate_one_image(
返回图片 bytesPNG/JPEG
"""
refs = reference_images or []
providers = _image_provider_order()
# 没配 codeproxy 备用 key 时不把它补进尝试序列避免每张图空转重试方案A
has_alt = bool(getattr(client, "has_alt_channel", lambda: True)())
providers = _image_provider_order(has_alt)
errors: list[str] = []
for provider in providers:
@@ -191,6 +242,9 @@ async def generate_storyboard_images(
f"素材使用={item.get('asset_use','')}"
f"{brand_line}"
f"禁止事项={item.get('forbidden','')}"
"参考图说明:随附的多张参考图都是【同一个产品】的不同视角(主图=产品/包装本体,"
"其余=质地/细节/场景特写产品外形、颜色、包装上原有文字必须100%忠于主图原样,"
"禁止重绘产品本体、禁止在包装上增删改任何文字(品牌词只放图上标题/贴纸等排版层,绝不印产品本体)。"
"排版要求独立小红书3:4图文海报画面完整标题只出现一次不与其他页重复"
"中文文字少而清晰,主标题+最多3个短点位可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌"
"不要生成App截图或笔记详情页界面。"

View File

@@ -21,6 +21,9 @@ except ImportError:
_PILLOW_OK = False
logger.warning("Pillow 未安装image_postprocessor 不可用")
# 北哥暖棕调色层A4自带开关与降级
from .beige_color_grade import grade as _beige_grade, is_enabled as _beige_enabled
# 比例映射表,对齐大卫 RATIO_MAP。key 为字符串如 '3:4'
RATIO_MAP: dict[str, tuple[int, int]] = {
"1:1": (1024, 1024),
@@ -74,12 +77,27 @@ def process_image(
img = ImageOps.fit(img, (tw, th), method=Image.LANCZOS)
logger.debug("resize %dx%d%dx%d (ratio=%s)", actual_w, actual_h, tw, th, aspect_ratio)
# --- Step2: resample_strength 削像素水印(可选,默认轻采样---
img = _apply_resample(img, resample_strength)
# --- Step2+3: 去AI化破水印硬路默认开倩倩姐2026-06-26拍板对齐大卫xhs实测版---
# 硬路 = 缩2px裁1px边错位采样 + 亮度/饱和微调,专破 SynthID 像素水印。
# 「像素不能有任何压缩」红线大卫硬路只动边缘2px没有全图来回缩放
# Clover 原 Step2缩98%再放大)是额外的全图 LANCZOS 重采样、会软化全图像素,
# 故硬路开启时直接跳过 Step2只走硬路局部错位——既破水印又不压全图像素。
hard_mode = os.environ.get("SYNTHID_HARD_MODE", "1") != "0"
if hard_mode:
# 硬路只动边缘(缩2px裁1px),用图片当前尺寸即可,不依赖 RATIO_MAP
# 故非标准比例(target=None)也走硬路,绝不降级到全图 LANCZOS 有损重采样。
img = _apply_synthid_break(img)
else:
# 仅硬路显式关闭时,才用全图轻采样兜底破水印
img = _apply_resample(img, resample_strength)
# --- Step3: SynthID 破除SYNTHID_HARD_MODE=1 才开,默认关---
if os.environ.get("SYNTHID_HARD_MODE") == "1" and target:
img = _apply_synthid_break(img, target)
# --- Step3.5: 北哥暖棕调色BEIGE_COLOR_GRADE 默认开去AI化核心---
# 在重编码前以 bytes 往返grade 内部自带开关/降级,关则原样透传
if _beige_enabled():
buf0 = io.BytesIO()
img.save(buf0, format="JPEG", quality=100, subsampling=0)
graded = _beige_grade(buf0.getvalue())
img = Image.open(io.BytesIO(graded)).convert("RGB")
# --- Step4: 高保真 JPEG 重编码,去所有元数据 ---
buf = io.BytesIO()
@@ -118,19 +136,20 @@ def _apply_resample(img: "Image.Image", strength: int) -> "Image.Image":
return img
def _apply_synthid_break(img: "Image.Image", target: tuple[int, int]) -> "Image.Image":
def _apply_synthid_break(img: "Image.Image") -> "Image.Image":
"""
SynthID 破除(SYNTHID_HARD_MODE=1 时调用):
对齐大卫逻辑 — 缩到(w-2,h-2)再裁掉1px边 + 亮度*1.005/饱和*0.998
SynthID 破除(硬路,默认开):对齐大卫 xhs 实测版。
顺序对齐大卫:先微调亮度/饱和(modulate) → 再缩2px裁1px边错位采样
只动边缘像素、按图片当前尺寸算,不依赖目标比例,不做全图重采样、不压全图像素。
诚实声明:只能削弱 SynthID不保证 100% 清除。
"""
tw, th = target
img = ImageOps.fit(img, (tw - 2, th - 2), method=Image.LANCZOS)
# 裁掉1px边消除边缘水印残留
img = img.crop((1, 1, tw - 3, th - 3))
# 微调亮度/饱和(对齐大卫 modulate brightness/saturation
# 先 modulate亮度*1.005/饱和*0.998),对齐大卫 modulate 在缩裁之前
img = ImageEnhance.Brightness(img).enhance(1.005)
img = ImageEnhance.Color(img).enhance(0.998)
# 按当前尺寸缩2px再裁1px边错位采样破边缘水印残留w-4 × h-4
w, h = img.size
img = ImageOps.fit(img, (w - 2, h - 2), method=Image.LANCZOS)
img = img.crop((1, 1, w - 3, h - 3))
return img

View File

@@ -1,7 +1,8 @@
"""
llm_scorer.py — AI 评委打分入口(让模型真读文案,替代机械找词)
合规第7维仍走机械硬拦(score_compliance)AI 读前6维给分+理由。
任何异常/解析失败 → 回退旧机械 score_copy绝不卡链路。
B1(倩倩姐2026-06-26):评分两通道(apiports+codeproxy强档兜底)都挂时,
绝不静默回退机械分糊弄,抛 ScoringUnavailableError 由上层逐条接住、标"评分不可用"
"""
from __future__ import annotations
import asyncio
@@ -12,17 +13,23 @@ from typing import Any
from .constants import BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, QUALITY_PASS_SCORE
from ._scoring_dims import score_compliance
from .text_scoring import score_copy
from ._score_prompt import SCORER_PERSONA, build_score_prompt, COMPLIANCE_MAX
logger = logging.getLogger(__name__)
# 6 个 AI 维度满分倩倩姐2026-06-15拍板·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
# 痛点18+情绪18+买点18+钩子15+标题13+真实感13=95+合规5=100
# "真实感"替换旧"产品聚焦一件事",对齐富贵"很少提产品/前70%干货后30%植入"原则
class ScoringUnavailableError(RuntimeError):
"""评分通道(apiports+codeproxy 强档兜底)均不可用。
按倩倩姐2026-06-26 B1拍板评分拿不到真AI分时绝不静默回退机械分糊弄
(机械分会误杀好文案/漏放烂文案,双破"质量过关"红线),明确抛错由上层逐条接住。"""
# 7 个 AI 维度满分倩倩姐2026-06-22拍板补标签精准·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
# 痛点16+情绪16+买点16+钩子13+标题13+真实感11+标签精准10=95+合规5=100
# "标签精准度"=北哥要害⑤(精准购买意向埋词)分从原6维各匀出总分仍95合格线80不动
_DIM_MAX = {
"痛点人群精准": 18, "情绪张力": 18, "买点转化": 18,
"开头钩子": 15, "标题点击力": 13, "真实感": 13,
"痛点人群精准": 16, "情绪张力": 16, "买点转化": 16,
"开头钩子": 13, "标题点击力": 13, "真实感": 11,
"标签精准度": 10,
}
# 评委合规相关默认权重(仅供 score_compliance 复用其内部硬拦逻辑)
_COMPLIANCE_W = {"compliance": COMPLIANCE_MAX}
@@ -65,22 +72,29 @@ async def llm_score_copy(
)
break
except Exception as exc: # noqa: BLE001 — 含 httpx.HTTPStatusError 503/429
# chat_complete 底层已含 apiports→codeproxy gpt-5.5 强档回落(含402欠费/429限流)。
# 走到这里说明两通道都失败。503/429 给瞬时过载留重试窗口,其余直接判通道不可用。
status = getattr(getattr(exc, "response", None), "status_code", 0)
if status in (503, 429) and attempt < 3:
await asyncio.sleep(backoff[min(attempt, 2)])
continue
logger.warning("AI评委调用失败回退机械打分: %s", exc)
return score_copy(copy, source, banned_words, pass_score=pass_score)
# B1红线绝不静默回退机械分糊弄明确抛错由调用方逐条接住、标记"评分不可用"
logger.error("AI评委两通道均失败判定评分不可用(不降级机械分): %s", exc)
raise ScoringUnavailableError(f"评分通道不可用: {exc}") from exc
verdict = _parse_verdict(raw)
if not verdict:
logger.warning("AI评委输出解析失败回退机械打分。raw[:120]=%s", raw[:120])
return score_copy(copy, source, banned_words, pass_score=pass_score)
# 拿到了回复但不是合法JSON模型偶发不听话。同样不静默机械分抛错让上层标记。
logger.error("AI评委输出解析失败(不降级机械分)。raw[:120]=%s", raw[:120])
raise ScoringUnavailableError("评分输出解析失败")
details: list[dict] = []
_COMPLIANCE_ALIASES = ("合规", "违禁", "禁用") # LLM 偶尔不听话多吐合规维度,显式剔除防污染机械合规分
for d in verdict["dims"]:
item = str(d.get("item", "")).strip()
if item not in _DIM_MAX: # 只收白名单6维模型偶尔多吐"总分"等噪声项,丢弃
if any(a in item for a in _COMPLIANCE_ALIASES):
continue # 合规只认机械 dim_comp丢弃 LLM 主观合规判定
if item not in _DIM_MAX: # 只收白名单7维模型偶尔多吐"总分"等噪声项,丢弃
continue
mx = _DIM_MAX[item]
sc = max(0, min(mx, int(round(float(d.get("score", 0))))))

View File

@@ -1,7 +1,7 @@
"""
package_exporter.py — 达人素材交付包生成
架构方案§五 1A步骤5按笔记分文件夹 + 图(01/02/03) + 文案.txt + 发布清单 + 合规说明
路径规则:uploads/packages/{workspace_id}/{task_id}/note_{n}/
路径规则:{UPLOAD_ABS_ROOT}/packages/{workspace_id}/{task_id}/note_{n}/
"""
from __future__ import annotations
import json

View File

@@ -53,7 +53,8 @@ def aggregate_preference_context(
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板)
# image_select(选图)按套别叙事角度计入,让选图偏好真正闭环回生图(R7断点2)
if sig_type in ("text_select", "approve", "text_edit", "image_select") and angle:
# text_import(导入)=客户实跑验证过的范本,权重最高(8),角度计入让飞轮最重学习(倩倩姐2026-06-26)
if sig_type in ("text_select", "approve", "text_edit", "image_select", "text_import") and angle:
angle_counts[angle] += weight
elif sig_type == "reject_with_reason":
reason = str(e.get("reason") or "").strip()

View File

@@ -67,7 +67,7 @@ def get_narrative_roles(image_count: int = 6) -> list[dict]:
"""
按图数返回分镜角色列表(扒 getNarrativeRolesQ6对齐北哥6张套路
≤3 张:极速链路 hook / applied_proof / closer
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分/参数 ④质地/细节 ⑤效果证明(按品类) ⑥促单
>6 张:沉浸链路 + pain_scene / scenario / social_proof
"""
count = clamp_count(image_count)
@@ -102,14 +102,14 @@ def build_visual_system(product: dict, analysis: dict | None = None) -> dict:
"typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"),
"sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"),
"layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"),
"texture": identity.get("materialTexture", "产品包装、质地、手背/上脸肤感要真实自然"),
"texture": identity.get("materialTexture", "产品包装、材质、质地/细节要真实自然,符合本品类的真实使用质感"),
"package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"),
"xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"),
"symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛每张最多2-4个"),
"quality_rules": [
"同组图片字体体系相对一致,但不要像固定模板",
"每张压图文字必须服务当前叙事角色,不能重复封面标题",
"护肤品优先出现手背涂抹质地微距自然上脸局部真实生活场景",
"效果证明页按品类出真实使用证据:护肤=手背涂抹/质地微距/自然上脸局部,食品=冲泡入口,家居数码=使用过程,服饰=上身材质,均为真实生活场景",
"人物真实自然有轻微皮肤纹理和生活感不要AI精修美女",
"禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after",
],

View File

@@ -18,11 +18,11 @@ import re
# ── sanitize扒 sanitizeImagePlanText防违禁视觉词进 prompt
_SANITIZE_RULES: list[tuple[str, str]] = [
(r"before\s*&\s*after", "质地与肤感说明"),
(r"before\s*/?\s*after", "质地与肤感说明"),
(r"\bbefore\b", "质地状态"),
(r"\bafter\b", "上脸肤感"),
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
(r"before\s*&\s*after", "质地与使用状态说明"),
(r"before\s*/?\s*after", "质地与使用状态说明"),
(r"\bbefore\b", "使用前状态"),
(r"\bafter\b", "使用后状态"),
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/使用状态说明"),
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
@@ -42,33 +42,33 @@ ROLE_STORYBOARD_TPL: dict[str, dict] = {
"hook": {
"goal": "{audience}因为{pain}停下划走,产生点开欲",
"overlay": "{hook}",
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实肤感/手部细节像iPhone随手实拍的封面不是海报",
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实使用细节/手部细节像iPhone随手实拍的封面不是海报",
"basis": "来自选中文案标题、人群{audience}、痛点{pain}",
"forbidden": "不要价格、不要重复后续卖点、不要App界面、不要广告海报感",
},
"product_closeup": {
"goal": "建立单品记忆锚点,让用户记住是哪个产品",
"overlay": "{brand}",
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读,品牌词自然出现在画面或瓶身",
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读且忠于参考图原样,品牌词出现在图上标题/贴纸等文字排版层,绝不印到瓶身",
"basis": "来自产品名和品牌词第2张和第6张都要带品牌词强化记忆",
"forbidden": "不要堆多个产品、不要花哨背景抢主体、不要改包装文字",
},
"ingredient": {
"goal": "用成分/配方信息建立信任,但不医疗化",
"goal": "用成分/配方/参数信息建立信任,但不夸大不医疗化",
"overlay": "看清{point}",
"visual": "成分卡片式布局,产品+成分图标/短说明,浅色商务美妆风,信息层级清楚",
"basis": "来自卖点里的成分/功效点,理性表达不夸大",
"visual": "信息卡片式布局,产品+成分/参数图标+短说明,浅色干净商务风,信息层级清楚(护肤食品讲成分配方,数码家居讲参数原理,服饰讲材质工艺)",
"basis": "来自卖点里的成分/参数/工艺点,理性表达不夸大",
"forbidden": "不要治疗/改善疾病承诺、不要医生背书、不要绝对化",
},
"texture": {
"goal": "让用户看到{point}的真实质感证据",
"overlay": "{point}看得见",
"visual": "手背或指尖涂抹质地微距,产品放在旁边,自然光,保留真实皮肤纹理,能看清延展肤感",
"basis": "来自卖点里的质地/肤感描述",
"visual": "质地/材质/细节微距,产品放在旁边,自然光,保留真实纹理(护肤=涂抹延展肤感,食品=色泽质地,服饰=面料织纹,数码家居=做工细节),能看清{point}",
"basis": "来自卖点里的质地/材质/做工描述",
"forbidden": "不要生成变白效果、不要医疗化对比、不要和封面同构图",
},
"applied_proof": {
"goal": "用可感知的上脸/使用证据证明{point}",
"goal": "用可感知的真实使用证据证明{point}",
"overlay": "{proof_overlay}",
"visual": "{proof_visual}",
"basis": "来自核心卖点{point}和用户对效果的关注",

View File

@@ -14,7 +14,7 @@ from typing import Any
from .constants import MAX_OPTIMIZE_ROUNDS
from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts
from .text_scoring import score_copy, dedupe_copies
from .llm_scorer import llm_score_copy
from .llm_scorer import llm_score_copy, ScoringUnavailableError
from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
logger = logging.getLogger(__name__)
@@ -69,6 +69,35 @@ async def _call_llm(client: Any, prompt: str, max_tokens: int = 8192) -> str:
# 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。
TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4"))
# 评分有限并发:评分无状态、逐条独立可并发,但仍限并发数防 apiports 限流(生成层串行
# 不动task45 实测全并发雪崩)。默认3=保守值(评分 max_tokens仅1500远轻于生成8192
# 且评分已带 503/429 重试兜底),可经 SCORE_CONCURRENCY 调。
_SCORE_CONCURRENCY = int(os.environ.get("SCORE_CONCURRENCY", "3"))
def _build_cross_set_avoidance(previous: list[dict]) -> str:
"""从已生成的其他套文案提炼回避约束,喂进生成 prompt 防三套撞车。
覆盖三个最易同质化的点:已用标签、背书人物桥段、收尾句式。"""
if not previous:
return ""
used_tags: list[str] = []
used_titles: list[str] = []
for c in previous:
used_titles.append(c.get("title", ""))
for t in (c.get("tags") or []):
tag = str(t).replace("🛒", "").strip()
if tag and tag not in used_tags:
used_tags.append(tag)
lines = ["【跨套去重·硬约束(本条务必与已发布的其它套拉开,禁止换皮重复)】"]
if used_titles:
lines.append(f"- 已用标题(禁止同主题/同句式):{ ''.join(t for t in used_titles if t) }")
if used_tags:
lines.append(f"- 已用标签(本条至少换掉一半,改用不同人群/场景/成分组合的精准长尾,扩大触达不抢同批流量):{ ''.join(used_tags) }")
lines.append("- 标签禁止与已用标签共用同一核心搜索词(别套已用的核心词,本条标签就别再都堆这个词,换成本套独有的人群/场景/卖点关键词分流)。")
lines.append("- 背书角色/桥段禁止与上面套雷同:若别套用了'闺蜜种草',本条换成自证/家人/同事/自己反复试错等不同来源;")
lines.append("- 卖点罗列的措辞顺序与 emoji 禁止与别套一致;同一个具体种草桥段全局只许出现一次。")
return "\n".join(lines)
async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str,
strategy_narrative: str = "") -> list[dict]:
@@ -119,25 +148,53 @@ async def generate_text_variants(
贯穿首批生成与优化轮,确保同套内文案同一叙事不串味。"""
banned_entries = build_entries_from_db(banned_word_rows or [])
extra = flywheel_context
# 跨套去重:把已生成套的标签/背书桥段提炼成回避约束喂进生成 prompt
# 防止三套撞车(仅事后 dedupe 删不掉"同用闺蜜背书/同套标签"的同质化)
avoid = _build_cross_set_avoidance(previous_copies or [])
if avoid:
extra = f"{extra}\n{avoid}" if extra else avoid
copies: list[dict] = await _generate_in_batches(
llm_client, product, count, extra, strategy_narrative)
if not copies:
copies = list(build_local_drafts(product, count)) # generator → list
candidates: list[dict] = []
for c in copies:
# 评分有限并发逐条独立无状态可并发sem 限并发数防 apiports 限流(生成层串行不动)。
# 每条语义与原串行完全一致:违禁词机械检查(同步,放 sem 外不占并发额度)→AI评分→
# 评分不可用则标记不计合格不炸整批auto_fixed 回写修正文案。gather 保序=候选顺序不变。
sem = asyncio.Semaphore(_SCORE_CONCURRENCY)
async def _score_one(c: dict) -> dict:
ban: CheckResult = check_and_fix(
f"{c.get('title','')} {c.get('content','')}",
banned_entries or None,
)
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
try:
async with sem:
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
except ScoringUnavailableError as exc:
# B1评分两通道都挂→不打机械分糊弄这条标"评分不可用·不计合格",不炸整批
logger.warning("文案评分不可用,标记不计合格(非质量问题): %s", exc)
c.update({"source": "ai", "score": 0,
"score_detail": [{"item": "评分不可用", "score": 0, "max": 100,
"note": "评分通道(apiports+codeproxy)暂不可用,非文案质量问题"}],
"passed": False, "banned_word_status": ban.status,
"scoring_unavailable": True, "verdict": "", "summary": "评分服务暂不可用"})
return c
c.update({"source": "ai", "score": scored["score"], "score_detail": scored["score_detail"],
"passed": scored["passed"], "banned_word_status": ban.status,
"verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")})
if ban.status == "auto_fixed" and ban.fixed_text:
c["content"] = ban.fixed_text
candidates.append(c)
return c
candidates: list[dict] = list(await asyncio.gather(*(_score_one(c) for c in copies)))
scoring_failures = sum(1 for c in candidates if c.get("scoring_unavailable"))
# 整批评分全因通道不可用而失败 → 抛错让上层透传"评分服务不可用",区别于"质量不合格"
if copies and scoring_failures == len(copies):
raise ScoringUnavailableError(
f"本套 {len(copies)} 条文案评分全部失败:评分通道(apiports+codeproxy)均不可用")
failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"]
# 优化轮默认关闭apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503
@@ -164,7 +221,11 @@ async def generate_text_variants(
logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试")
break
for nc in parse_json_array(raw2):
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
try:
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
except ScoringUnavailableError as exc:
logger.warning("优化轮评分不可用,跳过该条不计入: %s", exc)
continue
nc.update({"source": "ai", "score": sc2["score"], "score_detail": sc2["score_detail"],
"passed": sc2["passed"], "banned_word_status": "pass",
"verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")})

View File

@@ -9,6 +9,7 @@ import logging
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.response import raise_unauthorized
from app.middleware.workspace_guard import CurrentUser
from app.models.user import User
@@ -16,6 +17,7 @@ from app.models.workspace import WorkspaceMember
logger = logging.getLogger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
DEFAULT_SEED_PASSWORD = "Clover2026!"
def hash_password(plain: str) -> str:
@@ -38,6 +40,8 @@ def authenticate_user(
).first()
if not user or not verify_password(password, user.hashed_password):
raise_unauthorized("用户名或密码错误")
if get_settings().APP_ENV == "production" and password == DEFAULT_SEED_PASSWORD:
raise_unauthorized("默认密码已禁用,请联系管理员重置密码")
# 取用户所在的第一个 workspace手动建账号场景只有一个
member = (
@@ -68,4 +72,20 @@ def build_user_response(user: User, workspace_id: int, role: str) -> dict:
"email": user.email,
"current_workspace_id": workspace_id,
"role": role,
"must_change_password": bool(getattr(user, "must_change_password", False)),
}
def change_password(db: Session, user_id: int, current_password: str, new_password: str) -> None:
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user or not verify_password(current_password, user.hashed_password):
raise_unauthorized("当前密码错误")
if len(new_password) < 8:
from app.core.response import raise_param_error
raise_param_error("新密码至少 8 位")
if new_password == DEFAULT_SEED_PASSWORD:
from app.core.response import raise_param_error
raise_param_error("不能使用默认密码")
user.hashed_password = hash_password(new_password)
user.must_change_password = False
db.commit()

View File

@@ -33,6 +33,13 @@ def _parse_fission_response(
build_fallback_image_plan, build_fallback_notes,
)
def _fallback(reason: str) -> list[dict]:
notes = build_fallback_notes(source_note, product, note_count, image_count)
for note in notes:
note["is_fallback"] = True
note["fallback_reason"] = reason
return notes
notes = notes_array_from_parsed(parse_json_array(raw))
if not notes:
# parse_json_array 只认数组;再试整段当对象解析(容错单对象返回)
@@ -42,7 +49,7 @@ def _parse_fission_response(
notes = []
if not notes:
logger.warning("裂变 LLM 返回不可解析启用品类兜底。raw[:120]=%s", str(raw)[:120])
return build_fallback_notes(source_note, product, note_count, image_count)
return _fallback("llm_parse_failed")
out: list[dict] = []
for n in notes:
@@ -53,17 +60,21 @@ def _parse_fission_response(
if not isinstance(plan, list) or len(plan) != image_count:
n["imagePlan"] = build_fallback_image_plan(n, image_count)
out.append(n)
return out or build_fallback_notes(source_note, product, note_count, image_count)
return out or _fallback("llm_empty_notes")
def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str]) -> None:
def _score_notes(
clients, notes: list[dict], source_note: dict, banned: list[str],
product: dict | None = None,
) -> None:
"""对每套笔记 LLM 评分限2并发结果就地写回 note['_score']/_passed/_block。
Celery 同步环境:用 asyncio.run 跑一个内部 gather信号量限2并发
对齐生图限流,避免中转站 429
D1修复product 传给 llm_score_copy → build_score_prompt 组装【产品语境】
评委不再盲评,买点转化/痛点人群精准两维能基于产品信息给分
Celery 同步环境:用 asyncio.run 跑一个内部 gather信号量限2并发
"""
import asyncio
from app.services.ai_engine.llm_scorer import llm_score_copy
from app.services.ai_engine.llm_scorer import llm_score_copy, ScoringUnavailableError
from app.services.ai_engine.constants import QUALITY_PASS_SCORE
async def _run() -> None:
@@ -78,8 +89,17 @@ def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str
async with sem:
try:
r = await llm_score_copy(
clients, copy, source_note, banned, pass_score=QUALITY_PASS_SCORE,
clients, copy, product or source_note, banned,
pass_score=QUALITY_PASS_SCORE,
)
except ScoringUnavailableError as exc:
# B1红线对齐评分两通道都挂≠质量差明确标记不可用绝不静默记0分糊弄
logger.error("裂变评分通道不可用(非质量问题): %s", exc)
note["_scoring_unavailable"] = True
note["_score"] = 0
note["_block"] = False
note["_passed"] = False
return
except Exception as exc: # noqa: BLE001
logger.warning("裂变评分异常记0分不达标: %s", exc)
r = {"score": 0, "passed": False, "banned_words_found": []}
@@ -91,6 +111,11 @@ def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str
await asyncio.gather(*(_one(n) for n in notes))
asyncio.run(_run())
# 整批评分全因通道不可用而失败 → 抛错让上层(execute_fission_pipeline)感知,
# 标记任务"评分服务不可用"而非误判成完成/质量差
if notes and all(n.get("_scoring_unavailable") for n in notes):
raise ScoringUnavailableError(
f"裂变 {len(notes)} 套笔记评分全部失败:评分通道(apiports+codeproxy)均不可用")
def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_id: int) -> dict:
@@ -147,7 +172,8 @@ def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_
logger.warning("裂变 LLM 调用失败,启用品类兜底: %s", exc)
notes = _parse_fission_response(raw, source_note, product, n, image_count)
_score_notes(clients, notes, source_note, banned)
# D1修复传 product 给评委,不再盲评(买点转化/痛点人群精准需要产品语境)
_score_notes(clients, notes, source_note, banned, product=product)
# 排序:过线优先,再按分降序;取前 N 套(不足用兜底草稿补齐)
notes.sort(key=lambda x: (x.get("_passed", False), x.get("_score", 0)), reverse=True)
@@ -175,5 +201,9 @@ def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_
from app.services.fission_images import generate_fission_images
generate_fission_images(db, clients, ft, product, image_count, saved_ids)
ft.status = "completed"; db.commit()
return {"fission_id": fission_id, "status": "completed", "note_ids": saved_ids}
has_fallback = any(bool(note.get("is_fallback")) for note in chosen)
# status 字段是 varchar(20)"completed_with_fallback"(23字符)会撑爆触发 DataError 1406
# 故用短码 "completed_fb"(倩倩姐2026-06-30修);语义=完成但含降级草稿。
ft.status = "completed_fb" if has_fallback else "completed"
db.commit()
return {"fission_id": fission_id, "status": ft.status, "note_ids": saved_ids}

View File

@@ -23,6 +23,39 @@ logger = logging.getLogger(__name__)
MAX_FANOUT = 5 # 每用户并发上限5(红线);裂变 N 套直出受同等约束
def _source_copy_from_payload(payload) -> str:
"""从候选文案 JSON 中提取可裂变的正文,避免把整段 JSON 当 prompt。"""
if isinstance(payload, str):
raw = payload.strip()
if not raw:
return ""
try:
parsed = json.loads(raw)
except Exception:
return raw
return _source_copy_from_payload(parsed)
if isinstance(payload, dict):
parts: list[str] = []
for key in ("title", "content", "body", "note", "text", "opening", "selling_points", "tags"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
parts.append(value.strip())
elif isinstance(value, list):
items = [str(item).strip() for item in value if str(item).strip()]
if items:
parts.append(" / ".join(items))
if parts:
return "\n".join(parts)
return json.dumps(payload, ensure_ascii=False)
if isinstance(payload, list):
items = [_source_copy_from_payload(item) for item in payload]
return "\n".join(item for item in items if item)
return str(payload or "")
def create_fission(
db: Session, current_user: CurrentUser,
source_task_id: int, reference_level: str, fanout_count: int,
@@ -73,5 +106,5 @@ def _extract_source_copy(db: Session, src: GenerationTask) -> str:
.order_by(TextCandidate.is_selected.desc(), TextCandidate.id.asc())
.first())
if c and c.content:
return c.content
return _source_copy_from_payload(c.content)
return src.theme or ""

View File

@@ -8,7 +8,7 @@ preference_aggregator查最近50条 → 最常选角度 + 打回原因近3条
import logging
from typing import Any
from sqlalchemy import desc
from sqlalchemy import desc, func
from sqlalchemy.orm import Session
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership
@@ -69,6 +69,20 @@ def record_signal(
raise
def count_signals(db: Session, workspace_id: int, product_id: int) -> int:
"""该产品累计偏好信号总数(按 workspace+product走联合索引
供前端"飞轮已积累N条信号"累积感知用,体现越用越多。"""
return (
db.query(func.count(PreferenceEvent.id))
.filter(
PreferenceEvent.workspace_id == workspace_id,
PreferenceEvent.product_id == product_id,
)
.scalar()
or 0
)
def get_preference_context(
db: Session, workspace_id: int, product_id: int,
product_dict: dict[str, Any] | None = None,
@@ -105,4 +119,5 @@ def get_preference_context(
"recent_preference": ctx.get("recent_preference", ""),
"reject_reasons": ctx.get("reject_reasons", []),
"injected_count": ctx.get("injected_count", 0),
"signal_count": count_signals(db, workspace_id, product_id),
}

View File

@@ -68,17 +68,19 @@ def create_generation_task(
# 已生成完等挑选/审核的不占 worker。红线=每用户5个(可配置)。
_check_concurrency_limit(db, current_user.user_id, current_user.workspace_id)
from app.models.product import Product
product = db.query(Product).filter(
Product.id == body.product_id,
Product.workspace_id == current_user.workspace_id,
).first()
if not product:
raise_business("产品不存在")
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
need_img = getattr(body, "need_product_image", True)
if need_img:
from app.models.product import Product
product = db.query(Product).filter(
Product.id == body.product_id,
Product.workspace_id == current_user.workspace_id,
).first()
if not product:
raise_business("产品不存在")
if not (product.image_path or "").strip():
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
@@ -111,10 +113,14 @@ def create_generation_task(
def enqueue_generation(task_id: int, regen_strategy: str | None = None,
regen_role: str | None = None, custom_prompt: str | None = None) -> None:
regen_role: str | None = None, custom_prompt: str | None = None,
reuse_text: bool = False) -> None:
"""只推 task_id+可选重生参数)入队,绝不推 key基石B
regen_strategy/regen_role/custom_prompt 仅 R2 单张/单套重生时传,常规生成留 None。"""
regen_strategy/regen_role/custom_prompt 仅 R2 单张/单套重生时传,常规生成留 None。
reuse_text=True 仅导入轨「去生图」时传:首次出图但复用库内导入文案,跳过文案生成。"""
from app.workers.tasks import run_generation_pipeline
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)
regen_role=regen_role, custom_prompt=custom_prompt,
reuse_text=reuse_text)
logger.info("Enqueued task_id=%s regen=%s/%s reuse_text=%s",
task_id, regen_strategy, regen_role, reuse_text)

View File

@@ -0,0 +1,137 @@
"""
app/services/user_admin_service.py — 管理员账号管理 service
仅 admin 可调(路由层 require_admin 守卫)。
建号/列号/启禁用,全部限定在调用者所在 workspace 内。
禁用=软删除(is_active=False),可随时恢复(倩倩姐2026-06-30拍板);不物理删除。
"""
import logging
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.constants.enums import UserRole
from app.core.response import raise_business, raise_not_found, raise_param_error
from app.models.user import LoginRecord, User
from app.models.workspace import WorkspaceMember
from app.services.auth_service import DEFAULT_SEED_PASSWORD, hash_password
logger = logging.getLogger(__name__)
_VALID_ROLES = {r.value for r in UserRole}
def list_workspace_users(db: Session, workspace_id: int) -> list[dict]:
"""列出本 workspace 的所有成员(含禁用),带角色与最后登录时间。"""
rows = (
db.query(User, WorkspaceMember.role)
.join(WorkspaceMember, WorkspaceMember.user_id == User.id)
.filter(WorkspaceMember.workspace_id == workspace_id)
.order_by(User.id)
.all()
)
result: list[dict] = []
for user, role in rows:
last = (
db.query(func.max(LoginRecord.created_at))
.filter(LoginRecord.user_id == user.id)
.scalar()
)
# role 可能是 UserRole 枚举对象,统一取字符串值给前端匹配 ROLE_LABELS
role_str = role.value if hasattr(role, "value") else str(role)
result.append({
"id": user.id,
"username": user.username,
"email": user.email,
"role": role_str,
"is_active": bool(user.is_active),
"must_change_password": bool(user.must_change_password),
"last_login_at": last.isoformat() if last else None,
})
return result
def create_workspace_user(
db: Session, workspace_id: int, username: str, email: str,
role: str, init_password: str,
) -> dict:
"""管理员建号:建 User + 绑 WorkspaceMember强制首登改密。"""
username, email = username.strip(), email.strip().lower()
if not username or not email:
raise_param_error("用户名和邮箱不能为空")
if role not in _VALID_ROLES:
raise_param_error(f"角色非法,仅支持 {sorted(_VALID_ROLES)}")
if len(init_password) < 8:
raise_param_error("初始密码至少 8 位")
if init_password == DEFAULT_SEED_PASSWORD:
raise_param_error("初始密码不能使用系统默认密码")
if db.query(User).filter(User.username == username).first():
raise_business("用户名已存在")
if db.query(User).filter(User.email == email).first():
raise_business("邮箱已被占用")
user = User(
username=username, email=email,
hashed_password=hash_password(init_password),
is_active=True, must_change_password=True,
)
db.add(user)
db.flush()
db.add(WorkspaceMember(workspace_id=workspace_id, user_id=user.id, role=role))
db.commit()
logger.info("admin created user=%s role=%s in ws=%s", username, role, workspace_id)
return {"id": user.id, "username": user.username, "role": role}
def set_user_active(
db: Session, workspace_id: int, target_user_id: int,
is_active: bool, operator_user_id: int,
) -> dict:
"""启用/禁用账号(软删除)。禁止管理员禁用自己。"""
if target_user_id == operator_user_id and not is_active:
raise_business("不能禁用当前登录的管理员账号")
member = (
db.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.user_id == target_user_id,
)
.first()
)
if not member:
raise_not_found("该账号不在当前 workspace")
user = db.query(User).filter(User.id == target_user_id).first()
if not user:
raise_not_found("账号不存在")
user.is_active = is_active
db.commit()
logger.info("admin set user=%s active=%s", target_user_id, is_active)
return {"id": user.id, "is_active": is_active}
def delete_workspace_user(
db: Session, workspace_id: int, target_user_id: int, operator_user_id: int,
) -> dict:
"""物理删除账号:先清关联(login_record+workspace_member)再删 user。禁止删自己。"""
if target_user_id == operator_user_id:
raise_business("不能删除当前登录的管理员账号")
member = (
db.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.user_id == target_user_id,
)
.first()
)
if not member:
raise_not_found("该账号不在当前 workspace")
user = db.query(User).filter(User.id == target_user_id).first()
if not user:
raise_not_found("账号不存在")
username = user.username
db.query(LoginRecord).filter(LoginRecord.user_id == target_user_id).delete()
db.query(WorkspaceMember).filter(WorkspaceMember.user_id == target_user_id).delete()
db.delete(user)
db.commit()
logger.info("admin DELETED user=%s(%s) from ws=%s", target_user_id, username, workspace_id)
return {"id": target_user_id, "deleted": True}

View File

@@ -5,6 +5,7 @@ build_delivery_package查已选文案+图片 → package_exporter → 存路
import json
import logging
import os
from app.workers.celery_app import celery_app
@@ -40,7 +41,7 @@ def build_delivery_package(self, package_id: int) -> dict:
from app.core.config import get_settings
settings = get_settings()
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
upload_root = settings.UPLOAD_ABS_ROOT.rstrip("/")
# A8 多套打包:按 strategy(A/B/C 三套正交叙事)分组,每套成一篇独立 note。
# 北哥拿到的交付包含完整 3 套note_01/02/03不再只打第 1 条文案。
@@ -59,9 +60,15 @@ def build_delivery_package(self, package_id: int) -> dict:
def _read_image(ic) -> dict:
img_bytes = b""
if ic.url:
# url 已含 uploads 前缀;工作目录 /applstrip 当相对路径读,勿再拼 base(防 uploads/uploads)
path = ic.url
if os.path.isabs(path):
pass
elif path.startswith("/uploads/"):
path = f"{upload_root}/{path.removeprefix('/uploads/')}"
elif path.startswith("uploads/"):
path = f"{upload_root}/{path.removeprefix('uploads/')}"
try:
with open(ic.url.lstrip("/"), "rb") as f:
with open(path, "rb") as f:
img_bytes = f.read()
except OSError as e:
logger.warning("图片读取失败,跳过:%s %s", ic.url, e)
@@ -115,8 +122,8 @@ def build_delivery_package(self, package_id: int) -> dict:
raise ValueError("无图片候选,无法打包")
from app.services.ai_engine.package_exporter import build_delivery_package as do_build
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开
packages_base = f"{upload_base}/packages"
# 打包产物放持久化卷 /app/uploads/packages/,与图片目录 /app/uploads/{ws}/{task}/ 分开
packages_base = os.path.join(upload_root, "packages")
zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base)
pkg.package_path = zip_path

View File

@@ -32,15 +32,17 @@ def _resolve_image_path(img_path: str) -> str:
def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment: str,
push_fn, workspace_id: int, seq_start: int) -> tuple[list, int, bool]:
push_fn, workspace_id: int, seq_start: int) -> tuple[list, int, bool, str | None, int]:
"""
Step5: 调 generate_text_variants → 存 TextCandidate → 推 SSE → 写 ai_call_logs。
S1: 存库前过滤——只存 passed且score>=QUALITY_PASS_SCORE(80,红线)且banned_word_status!='hard_block' 的文案。
合格数 < task.text_count 时 needs_replenish=True由主任务发起后台补充子任务
返回 (candidates_raw, next_seq, needs_replenish)。
返回 (candidates_raw, next_seq, needs_replenish, text_fail_reason, saved_count)。
text_fail_reason: None|scoring_unavailable|generation_failed|quality_filtered|replenishing(B1透传0条原因)。
"""
import time
from app.services.ai_engine.text_variants import generate_text_variants
from app.services.ai_engine.llm_scorer import ScoringUnavailableError
from app.models.product import BannedWord
from app.models.task import TextCandidate
from app.models.flywheel import AiCallLog
@@ -70,6 +72,7 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
t0 = time.monotonic()
llm_success = True
scoring_unavailable = False # B1评分通道(apiports+codeproxy)整套都挂,用于区分"评分不可用"vs"质量不合格"
candidates_raw: list = []
for s in _strategies:
n = _per[s]
@@ -85,6 +88,12 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
flywheel_context=flywheel_fragment,
strategy_narrative=TEXT_NARRATIVE_BY_STRATEGY.get(s, ""),
))
except ScoringUnavailableError as exc:
# B1评分两通道均不可用——明确记号绝不当"质量不合格"糊弄用户
scoring_unavailable = True
llm_success = False
logger.error("generate_text_variants(套%s) 评分通道不可用: %s", s, exc)
part = []
except Exception as exc:
llm_success = False
logger.error("generate_text_variants(套%s) 失败: %s", s, exc)
@@ -115,10 +124,13 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
# S1: 存库前过滤——只存合格文案passed + score>=QUALITY_PASS_SCORE(80) + 非hard_block
seq = seq_start
saved_count = 0
partial_scoring_unavailable = False # 部分条目评分通道挂(整套没全挂故没抛异常),用于精确归因
for i, c in enumerate(candidates_raw):
score = c.get("score", 0)
passed = c.get("passed", False)
bw_status = c.get("banned_word_status", "pass")
if c.get("scoring_unavailable"):
partial_scoring_unavailable = True
if not (passed and score >= QUALITY_PASS_SCORE and bw_status != "hard_block"):
logger.info(
"文案[%d] 过滤丢弃: passed=%s score=%s banned=%s",
@@ -159,22 +171,40 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
"文案合格数不足: task_id=%s 目标=%s 实得=%s,将后台异步补充",
task.id, task.text_count, saved_count,
)
return candidates_raw, seq, needs_replenish
# 整套全挂(scoring_unavailable)或部分条目评分挂(partial)都按"评分不可用"归因,
# 不被 quality_filtered 掩盖——评分通道问题≠文案质量问题(B1红线)
scoring_unavailable = scoring_unavailable or partial_scoring_unavailable
# B1透传 0 条的真实原因,前端据此区分提示,不再把"评分挂了"显示成"没有合格文案"
if saved_count == 0 and scoring_unavailable:
text_fail_reason = "scoring_unavailable" # 评分服务不可用
elif saved_count == 0 and not llm_success:
text_fail_reason = "generation_failed" # 文案生成本身失败(非评分)
elif saved_count == 0:
text_fail_reason = "quality_filtered" # 生成成功但全部未达标
elif needs_replenish:
text_fail_reason = "replenishing" # 有产出但不足,补充中
else:
text_fail_reason = None # 正常足量
return candidates_raw, seq, needs_replenish, text_fail_reason, saved_count
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,
notes_by_strategy: dict[str, dict], upload_base_path: str,
regen_strategy: str | None = None,
regen_role: str | None = None,
custom_prompt: str | None = None,
flywheel_fragment: str | None = None) -> int:
flywheel_fragment: str | None = None,
reuse_text: bool = False) -> 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 新增不删旧。
reuse_text=True(导入轨):只遍历库内真有导入文案的套(notes_by_strategy 的键)
导入几套出几套,未导入的套不刷 batch_failed 噪声。
"""
import time
from app.services.ai_engine.image_gen import generate_storyboard_images
@@ -236,28 +266,66 @@ def run_image_generation(db, clients, task, product_dict: dict,
# 主图始终保底进 primary多图表为空或主图未入表时仍可用
if reference_images and not images_by_scene.get("primary"):
images_by_scene.setdefault("primary", []).extend(reference_images)
# 主图缺失告警:无 scene=primary 入表时,所有 primary 角色只能靠 image_path 兜底,
# 若用户把瓶身误标成 texture主图角色会取不到真瓶身 → 提前暴露在日志(不硬拦,纯测试场景仍放行)
if not images_by_scene.get("primary"):
logger.warning(
"task_id=%s 无 scene=primary 产品图,主图角色将无瓶身锚点,"
"请确认产品已正确标注主图(瓶身本体)。", task.id
)
if images_by_scene:
logger.info("R5多图已加载%s", {k: len(v) for k, v in images_by_scene.items()})
seq = seq_start
# R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事
_strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C")
# R2: 限定重生套别(regen_strategy)则只跑该套
# reuse_text(导入轨): 只跑库内真有导入文案的套(按 A/B/C 顺序),导入几套出几套;
# 否则全量 A/B/C 三套正交叙事。
if regen_strategy:
_strategies = (regen_strategy,)
elif reuse_text:
_strategies = tuple(s for s in ("A", "B", "C") if notes_by_strategy.get(s))
# 存量导入文案 strategy 可能为 NULL(归到键"_")A/B/C 全匹配不上→_strategies 空。
# 必须显式报错,否则循环静默跳过=零图产出但任务不报错,极难排查。
if not _strategies:
logger.error(
"导入文案均未分配套别(A/B/C),无法生图: task_id=%s keys=%s",
task.id, list(notes_by_strategy.keys()),
)
push_fn(task.id, workspace_id, "error", {
"code": 40003,
"message": "导入文案未分配套别(A/B/C),请重新导入文案后再去生图",
}, seq + 1)
return seq + 1
else:
_strategies = ("A", "B", "C")
_is_regen = bool(regen_strategy or regen_role)
# 进度总数:单张重生=1单套=image_count全量=image_count×3
# 进度总数:单张重生=1单套=image_count全量/导入=image_count×实际套数
if regen_role:
_img_total = 1
elif regen_strategy:
_img_total = task.image_count
else:
_img_total = task.image_count * 3
_img_total = task.image_count * len(_strategies)
for si, strategy in enumerate(_strategies):
t0 = time.monotonic()
img_success = True
img_error_code = None
try:
note_for_strategy = notes_by_strategy.get(strategy)
if not note_for_strategy:
logger.error("%s缺少合格文案,拒绝复用其他套文案生图: task_id=%s", strategy, task.id)
seq += 1
push_fn(task.id, workspace_id, "batch_failed", {
"batch": f"{strategy}_missing_text",
"reason": f"{strategy}缺少合格文案,未生成该套图片",
"strategy": strategy,
"retryable": False,
}, seq)
continue
image_results = asyncio.run(generate_storyboard_images(
client=clients,
note=first_copy,
note=note_for_strategy,
product=product_dict,
image_count=task.image_count,
reference_images=reference_images or None,

View File

@@ -46,19 +46,37 @@ def decrypt_user_key(db, operator_id: int, workspace_id: int) -> str:
return decrypt_key(api_key_row.encrypted_key)
def build_clients_and_clear_key(plain_key: str):
def decrypt_codeproxy_key(db, operator_id: int, workspace_id: int) -> str | None:
"""
查用户录入的 codeproxy 备用站 key → Fernet 解密,返回 plain_key 或 None。
备用通道:用户没录则返回 None不抛错build_ai_clients 会回落 env
主生图流程绝不因没录备用 key 而中断apiports 主通道才是必需)。
"""
from app.models.workspace import UserApiKey
from app.utils.fernet_utils import decrypt_key
row = db.query(UserApiKey).filter(
UserApiKey.user_id == operator_id,
UserApiKey.workspace_id == workspace_id,
UserApiKey.provider == "codeproxy",
).first()
return decrypt_key(row.encrypted_key) if row else None
def build_clients_and_clear_key(plain_key: str, alt_key: str | None = None):
"""
Step3: 构建 AIClientsplain_key 传入后立即由调用方置 None。
alt_key用户录入的 codeproxy 备用 key可选同样由调用方查库解密后传入。
返回 clients 对象。
"""
from app.services.ai_engine.gemini_factory import build_ai_clients
return build_ai_clients(plain_key)
return build_ai_clients(plain_key, alt_key=alt_key)
def build_product_dict(product) -> dict:
"""把 ORM product 转成 AI 引擎所需的 dict不含任何 key"""
return {
"id": product.id,
"id": getattr(product, "id", None),
"name": product.name,
"category": product.category or "通用好物",
"selling_points": json.loads(product.selling_points or "[]"),
@@ -133,4 +151,7 @@ def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict:
for e in recent
]
ctx = aggregate_preference_context(events_dicts, product_dict, workspace_id, product_id)
# 累积感知:补该产品累计信号总数(前端"飞轮已积累N条信号"),与展示端同口径
from app.services.flywheel_service import count_signals
ctx["signal_count"] = count_signals(db, workspace_id, product_id)
return ctx.get("prompt_fragment", ""), ctx

View File

@@ -34,6 +34,7 @@ def replenish_text_candidates(self, task_id: int) -> dict:
from app.workers.pipeline_steps import (
load_task_and_product,
decrypt_user_key,
decrypt_codeproxy_key,
build_clients_and_clear_key,
build_product_dict,
load_flywheel_context,
@@ -57,8 +58,10 @@ def replenish_text_candidates(self, task_id: int) -> dict:
workspace_id = task.workspace_id
plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
clients = build_clients_and_clear_key(plain_key)
alt_key = decrypt_codeproxy_key(db, task.operator_id, workspace_id)
clients = build_clients_and_clear_key(plain_key, alt_key)
plain_key = None # 用完即清除基石B
alt_key = None
product_dict = build_product_dict(product)
flywheel_fragment, _ = load_flywheel_context(
db, workspace_id, task.product_id, product_dict
@@ -71,7 +74,7 @@ def replenish_text_candidates(self, task_id: int) -> dict:
current = existing
while current < target and rounds < MAX_REPLENISH_ROUNDS:
rounds += 1
run_text_generation(
_, _, _, fail_reason, _ = run_text_generation(
db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq,
)
@@ -85,6 +88,11 @@ def replenish_text_candidates(self, task_id: int) -> dict:
"补充轮 %d: task_id=%s 库内合格=%d/%d",
rounds, task_id, current, target,
)
# 评分通道不可用时再补也是空烧 token(每轮还各 35s backoff)——立即停,
# 等通道恢复用户手动重试。区别于"质量不达标"(那个值得多补几轮)。
if fail_reason == "scoring_unavailable":
logger.error("补充中止: task_id=%s 评分通道不可用,停止空跑重试", task_id)
break
return {
"task_id": task_id,

View File

@@ -27,6 +27,26 @@ def _json_loads_safe(raw: str | None) -> dict:
return {"content": raw}
def _load_notes_by_strategy(db, task_id: int, target_strategy: str | None = None) -> dict[str, dict]:
"""按 A/B/C 套别取已入库文案,优先已选,其次本套最早一条。"""
from app.models.task import TextCandidate
q = db.query(TextCandidate).filter(TextCandidate.task_id == task_id)
if target_strategy:
q = q.filter(TextCandidate.strategy == target_strategy)
rows = q.order_by(
TextCandidate.strategy.asc(),
TextCandidate.is_selected.desc(),
TextCandidate.id.asc(),
).all()
notes: dict[str, dict] = {}
for row in rows:
strategy = row.strategy or "_"
if strategy not in notes:
notes[strategy] = _json_loads_safe(row.content)
return notes
def _get_db():
"""获取同步 DB SessionCelery worker 环境用同步)"""
from app.core.database import SessionLocal
@@ -79,7 +99,8 @@ def _release_run_lock(task_id: int) -> None:
queue="generation",
)
def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = None,
regen_role: str | None = None, custom_prompt: str | None = None) -> dict:
regen_role: str | None = None, custom_prompt: str | None = None,
reuse_text: bool = False) -> dict:
"""
生产链主任务。只接收 task_id绝不接收 key。
子步骤委托给 pipeline_steps查库/解密/飞轮上下文)
@@ -88,6 +109,10 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
R2 重生参数(均 None=常规全量生成)
regen_strategy='A'/'B'/'C' → 只重生该套regen_role 配合 → 只重生该套的该张;
custom_prompt → 人工追加提示词。重生模式跳过文案生成,复用已有合格文案。
reuse_text=True导入轨「去生图」首次出图——状态照常进 GENERATING(非重生)
但跳过文案生成、复用库内导入文案作生图依据。与重生区别:重生改图(状态不变)
导入是首次出图(进 GENERATING),信号语义不同,故单独标志位不复用 regen 参数。
"""
logger.info("run_generation_pipeline start: task_id=%s", task_id)
# 幂等双保险:抢不到锁说明已有 worker 在跑同一 task直接跳过(防重复烧钱)。
@@ -103,6 +128,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
from app.workers.pipeline_steps import (
load_task_and_product,
decrypt_user_key,
decrypt_codeproxy_key,
build_clients_and_clear_key,
build_product_dict,
load_flywheel_context,
@@ -120,13 +146,17 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
# Step2: Fernet 解密(局部变量,不传出)
plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
alt_key = decrypt_codeproxy_key(db, task.operator_id, workspace_id)
# Step3: 构建 AIClients
clients = build_clients_and_clear_key(plain_key)
clients = build_clients_and_clear_key(plain_key, alt_key)
plain_key = None # 用完即清除基石B
alt_key = None
task.status = TaskStatus.GENERATING
db.commit()
_is_regen = bool(regen_strategy or regen_role)
if not _is_regen:
task.status = TaskStatus.GENERATING
db.commit()
# Step4: 推 task_started + 飞轮上下文
seq += 1
@@ -145,20 +175,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
_push_event_sync(task_id, workspace_id, "flywheel_injected", {
"recent_preference": flywheel_ctx.get("recent_preference", ""),
"reject_reasons": flywheel_ctx.get("reject_reasons", []),
"injected_count": flywheel_ctx.get("injected_count", 0),
"signal_count": flywheel_ctx.get("signal_count", 0),
}, seq)
# Step5: 文案生成S1: 返回 needs_replenish=合格数<目标数,触发后台补充)
# R2 重生模式:跳过文案重生,复用库内已有合格文案作生图依据(重生只针对图片)
_is_regen = bool(regen_strategy or regen_role)
if _is_regen:
from app.models.task import TextCandidate as _TC
_existing = db.query(_TC).filter(
_TC.task_id == task_id
).order_by(_TC.id.asc()).first()
if not _existing:
# reuse_text(导入轨):同样跳过文案生成,复用库内导入文案;与重生共用复用分支。
if _is_regen or reuse_text:
notes_by_strategy = _load_notes_by_strategy(db, task_id, regen_strategy)
if regen_strategy and regen_strategy not in notes_by_strategy:
# 重生依赖已有文案作生图语境;一条都没有时硬失败,绝不以空文案降级生图(质量崩)
from app.constants.enums import TaskStatus as _TS
logger.warning("重生无可复用文案,拒绝空语境生图: task_id=%s", task_id)
logger.warning(
"重生无可复用文案,拒绝空语境生图: task_id=%s strategy=%s",
task_id, regen_strategy,
)
task.status = _TS.PENDING_SELECTION
db.commit()
_push_event_sync(task_id, workspace_id, "error", {
"code": 40002,
"message": f"{regen_strategy}无可复用文案,请先完成该套文案生成再重生图片",
}, seq + 1)
return {"task_id": task_id, "status": "regen_no_text"}
if not notes_by_strategy:
from app.constants.enums import TaskStatus as _TS
task.status = _TS.PENDING_SELECTION
db.commit()
_push_event_sync(task_id, workspace_id, "error", {
@@ -166,13 +207,15 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
"message": "无可复用文案,请先完成文案生成再重生图片",
}, seq + 1)
return {"task_id": task_id, "status": "regen_no_text"}
candidates_raw = [_json_loads_safe(_existing.content)]
candidates_raw = list(notes_by_strategy.values())
needs_replenish = False
text_fail_reason = None # 重生不生成新文案,无文案失败原因
else:
candidates_raw, seq, needs_replenish = run_text_generation(
candidates_raw, seq, needs_replenish, text_fail_reason, _saved = run_text_generation(
db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq,
)
notes_by_strategy = _load_notes_by_strategy(db, task_id)
if needs_replenish:
# 合格文案不足用户目标条数:先展示已合格的,后台异步补充(先展示后台补铁律)
# 注candidates_raw 是本轮原始生成数(含被过滤的低分条),非合格入库数;
@@ -187,24 +230,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
logger.error("触发后台补充失败: task_id=%s err=%s", task_id, _re)
# Step6+7+8: 图片生成 + 后处理 + 存盘
first_copy = candidates_raw[0] if candidates_raw else {}
# A/B/C 三套必须用各自 strategy 的合格文案,不再全套复用第一条文案。
settings = get_settings()
seq = run_image_generation(
db, clients, task, product_dict,
_push_event_sync, workspace_id, seq,
first_copy, settings.UPLOAD_BASE_PATH,
notes_by_strategy, settings.UPLOAD_ABS_ROOT,
regen_strategy=regen_strategy, regen_role=regen_role,
custom_prompt=custom_prompt,
flywheel_fragment=flywheel_fragment,
reuse_text=reuse_text,
)
# 最终状态 + task_done
task.status = TaskStatus.PENDING_SELECTION
db.commit()
# B1透传文案结果原因 + 实际合格数,前端据此区分"评分不可用/质量不合格/补充中/正常"
from app.models.task import TextCandidate as _TCcount
_text_saved = db.query(_TCcount).filter(_TCcount.task_id == task_id).count()
seq += 1
_push_event_sync(task_id, workspace_id, "task_done", {
"task_id": task_id, "status": "pending_selection"
"task_id": task_id, "status": "pending_selection",
"text_saved": _text_saved, "text_target": task.text_count,
"text_reason": text_fail_reason,
}, seq)
logger.info("run_generation_pipeline done: task_id=%s", task_id)
@@ -221,6 +271,11 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
t = db.query(GT).filter(GT.id == task_id).first()
if t:
t.status = TS.FAILED if exhausted else TS.GENERATING
# B6+#11:失败终态时落原因,供历史页/确认页查看(Redis error历史TTL仅1h会过期)。
# 复用 reject_reason 列(Text),前端按 status 区分展示"失败原因"/"打回原因",
# 物理不冲突(failed 不命中 rejected 展示分支),免加 Alembic 迁移。
if exhausted:
t.reject_reason = str(exc)[:2000]
db.commit()
except Exception:
pass
@@ -262,7 +317,7 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
db = _get_db()
try:
from app.models.task import GenerationTask
from app.workers.pipeline_steps import decrypt_user_key, build_clients_and_clear_key
from app.workers.pipeline_steps import decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key
from app.services.fission_pipeline import execute_fission_pipeline
src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first()
@@ -270,8 +325,10 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
return {"fission_id": fission_id, "status": "not_found"}
plain_key = decrypt_user_key(db, src.operator_id, src.workspace_id)
clients = build_clients_and_clear_key(plain_key)
alt_key = decrypt_codeproxy_key(db, src.operator_id, src.workspace_id)
clients = build_clients_and_clear_key(plain_key, alt_key)
plain_key = None # 用完即清基石B
alt_key = None
return execute_fission_pipeline(db, clients, fission_id, source_task_id)
except Exception as exc:
@@ -320,7 +377,7 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
from app.models.task import GenerationTask
from app.models.product import Product
from app.workers.pipeline_steps import (
decrypt_user_key, build_clients_and_clear_key, build_product_dict,
decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key, build_product_dict,
)
from app.services.fission_image_retry import retry_fission_note_images
@@ -340,8 +397,10 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
# key 归属源任务 operator与首轮生图一致基石B不接收明文 key
plain_key = decrypt_user_key(db, src.operator_id, ft.workspace_id)
clients = build_clients_and_clear_key(plain_key)
alt_key = decrypt_codeproxy_key(db, src.operator_id, ft.workspace_id)
clients = build_clients_and_clear_key(plain_key, alt_key)
plain_key = None # 用完即清基石B
alt_key = None
product = build_product_dict(product_row)
image_count = max(1, (src.image_count or 3))