Files
beige/backend/app/services/fission_service.py
yangqianqian df1856d793 上线版: 产品表单统一+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>
2026-06-30 18:08:13 +08:00

111 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 ""