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