- 产品编辑入口统一走 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>
111 lines
4.2 KiB
Python
111 lines
4.2 KiB
Python
"""
|
||
app/services/fission_service.py — 第11环裂变 service 入口(对齐上线版 split.js)
|
||
|
||
裂变 = 1爆款 → 一次 LLM 出 N 套完整笔记包(N=1~5)。
|
||
本文件只留 API 入口:
|
||
- create_fission:建 FissionTask 后丢 run_fission_pipeline.delay,不再扇出 GenerationTask
|
||
编排层(解析/评分/落库/生图)在 app/services/fission_pipeline.py(红线≤200行已拆)。
|
||
"""
|
||
import json
|
||
import logging
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.response import raise_business
|
||
from app.middleware.workspace_guard import CurrentUser
|
||
from app.models.fission import FissionTask
|
||
from app.models.task import GenerationTask
|
||
from app.services.ai_engine.fission_prompt import valid_level
|
||
from app.services.task_service import _check_user_has_key
|
||
|
||
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,
|
||
) -> tuple[int, list[int]]:
|
||
"""
|
||
从一个源任务建裂变任务,丢 Celery 一次出 N 套笔记包。
|
||
返回 (fission_id, [])。子笔记落 FissionNote,不再扇出 GenerationTask,
|
||
故第二个返回值恒为空列表(保留签名兼容旧调用方)。
|
||
"""
|
||
_check_user_has_key(db, current_user.user_id, current_user.workspace_id)
|
||
|
||
level = valid_level(reference_level)
|
||
n = max(1, min(fanout_count or 3, MAX_FANOUT))
|
||
|
||
# 读源任务(取产品+主题+已选文案作为爆款源)
|
||
src = db.query(GenerationTask).filter(
|
||
GenerationTask.id == source_task_id,
|
||
GenerationTask.workspace_id == current_user.workspace_id,
|
||
).first()
|
||
if not src:
|
||
raise_business("裂变源任务不存在")
|
||
|
||
source_note = {"title": src.theme or "", "content": _extract_source_copy(db, src)}
|
||
|
||
# 建 fission_task 记录(带 source_task_id 供 pipeline 取产品/张数)
|
||
ft = FissionTask(
|
||
workspace_id=current_user.workspace_id,
|
||
source_note=json.dumps(source_note, ensure_ascii=False),
|
||
source_task_id=source_task_id,
|
||
reference_level=level, fanout_count=n, status="generating",
|
||
)
|
||
db.add(ft); db.commit(); db.refresh(ft)
|
||
|
||
# 丢 Celery:一次 LLM 出 N 套(不扇出 GenerationTask)
|
||
from app.workers.tasks import run_fission_pipeline
|
||
run_fission_pipeline.delay(ft.id, source_task_id)
|
||
|
||
logger.info("fission created id=%s level=%s n=%s src_task=%s",
|
||
ft.id, level, n, source_task_id)
|
||
return ft.id, []
|
||
|
||
|
||
def _extract_source_copy(db: Session, src: GenerationTask) -> str:
|
||
"""取源任务已选/最高分文案当爆款正文;无则用主题兜底。"""
|
||
from app.models.task import TextCandidate
|
||
c = (db.query(TextCandidate)
|
||
.filter(TextCandidate.task_id == src.id)
|
||
.order_by(TextCandidate.is_selected.desc(), TextCandidate.id.asc())
|
||
.first())
|
||
if c and c.content:
|
||
return _source_copy_from_payload(c.content)
|
||
return src.theme or ""
|