diff --git a/backend/app/api/v1/fission.py b/backend/app/api/v1/fission.py new file mode 100644 index 0000000..3f5ff98 --- /dev/null +++ b/backend/app/api/v1/fission.py @@ -0,0 +1,78 @@ +""" +app/api/v1/fission.py — 第11环裂变路由 + +POST /fission 触发裂变(1源任务→N套子任务) +GET /fission/{id} 查裂变进度(聚合N个子任务状态) +工作台内呈现,子任务走标准 SSE 流回进度。 +""" +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.response import ok, raise_not_found +from app.middleware.workspace_guard import CurrentUser, require_write_permission +from app.models.fission import FissionTask +from app.models.task import GenerationTask + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["fission"]) + + +class FissionCreate(BaseModel): + source_task_id: int + reference_level: str = Field(default="mid") # low/mid/high + fanout_count: int = Field(default=3, ge=1, le=5) + + +@router.post("/fission") +def create_fission_endpoint( + body: FissionCreate, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + """触发裂变:从源任务扇出 N 套完整笔记包。返回 fission_id + 子 task_ids。""" + from app.services.fission_service import create_fission + fission_id, task_ids = create_fission( + db, current_user, body.source_task_id, body.reference_level, body.fanout_count, + ) + return ok({"fission_id": fission_id, "task_ids": task_ids, + "reference_level": body.reference_level, "fanout_count": len(task_ids)}) + + +@router.get("/fission/{fission_id}") +def get_fission_progress( + fission_id: int, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + """查裂变进度:聚合 N 个子任务状态(x/N 完成)。""" + ft = db.query(FissionTask).filter( + FissionTask.id == fission_id, + FissionTask.workspace_id == current_user.workspace_id, + ).first() + if not ft: + raise_not_found("裂变任务不存在") + + subs = db.query(GenerationTask).filter( + GenerationTask.source_fission_id == fission_id, + GenerationTask.workspace_id == current_user.workspace_id, + ).all() + done_states = {"pending_selection", "pending_review", "approved", "archived"} + done = sum(1 for s in subs if s.status in done_states) + failed = sum(1 for s in subs if s.status == "failed") + total = len(subs) + # 聚合状态:全失败=failed,全完成=done,否则generating + agg = "done" if done == total and total > 0 else ("failed" if failed == total and total > 0 else "generating") + if ft.status != agg: + ft.status = agg; db.commit() + + return ok({ + "fission_id": ft.id, "reference_level": ft.reference_level, + "fanout_count": ft.fanout_count, "status": agg, + "progress": {"done": done, "failed": failed, "total": total}, + "task_ids": [s.id for s in subs], + }) diff --git a/backend/app/services/ai_engine/fission_prompt.py b/backend/app/services/ai_engine/fission_prompt.py new file mode 100644 index 0000000..59e71c6 --- /dev/null +++ b/backend/app/services/ai_engine/fission_prompt.py @@ -0,0 +1,43 @@ +""" +app/services/ai_engine/fission_prompt.py — 第11环裂变 三档参考度 prompt + +裂变=1爆款→N套完整笔记包。参考度档位控制"新套子贴原爆款多紧": + high=贴原爆款结构+卖点(最像) / mid=保卖点换叙事角度 / low=只借选题方向(最自由) + +🔴 占位规则(倩倩姐2026-06-16:先移植打通再请北哥定档位)。 + 档位真实措辞待北哥定义后替换 _LEVEL_RULES,引擎链路不动。 +""" + +# 三档占位规则:注入文案引擎 flywheel_context,约束"参考原爆款多少" +_LEVEL_RULES = { + "high": ( + "【裂变参考度=高】请紧贴下面这篇原爆款的正文结构、开头钩子、卖点排布与情绪语气," + "近乎仿写——只替换表达措辞做到不查重,骨架与卖点角度都保留。" + ), + "mid": ( + "【裂变参考度=中】请保留下面这篇原爆款的核心卖点,但换一个全新的叙事角度/切入场景重写," + "结构可调整,让读者看不出是同一套路。" + ), + "low": ( + "【裂变参考度=低】请只借鉴下面这篇原爆款的选题方向与目标人群," + "文案结构、卖点呈现、叙事全部自由发挥,做出明显差异化的新笔记。" + ), +} + + +def build_fission_context(source_note: dict, reference_level: str) -> str: + """ + 返回注入 text_variants(flywheel_context=) 的档位规则字符串。 + source_note: {title, content, ...} 原爆款笔记内容。 + reference_level: high/mid/low,非法值回落 mid。 + """ + rule = _LEVEL_RULES.get(reference_level, _LEVEL_RULES["mid"]) + title = (source_note or {}).get("title", "") + content = (source_note or {}).get("content", "") + src = f"原爆款标题:{title}\n原爆款正文:{content}".strip() + return f"{rule}\n\n--- 原爆款参考 ---\n{src}" + + +def valid_level(level: str | None) -> str: + """校验档位,非法回落 mid。""" + return level if level in _LEVEL_RULES else "mid" diff --git a/backend/app/services/fission_service.py b/backend/app/services/fission_service.py new file mode 100644 index 0000000..8818611 --- /dev/null +++ b/backend/app/services/fission_service.py @@ -0,0 +1,84 @@ +""" +app/services/fission_service.py — 第11环裂变 扇出 service + +1爆款 → N套完整笔记包。每套=一个独立 GenerationTask(回填 source_fission_id), +复用主管道 run_generation_pipeline 生成文案+配图。 +🔴 占位档位规则见 fission_prompt.py,真实措辞待北哥定义(倩倩姐2026-06-16先打通)。 +""" +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, enqueue_generation + +logger = logging.getLogger(__name__) + +MAX_FANOUT = 5 # 每用户并发上限5(红线),裂变扇出数受同等约束 + + +def create_fission( + db: Session, current_user: CurrentUser, + source_task_id: int, reference_level: str, fanout_count: int, +) -> tuple[int, list[int]]: + """ + 从一个源任务裂变出 N 套子任务。 + 返回 (fission_id, [子task_id,...])。 + """ + _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 记录 + ft = FissionTask( + workspace_id=current_user.workspace_id, + source_note=json.dumps(source_note, ensure_ascii=False), + reference_level=level, fanout_count=n, status="generating", + ) + db.add(ft); db.commit(); db.refresh(ft) + + # 扇出 N 个子任务(回填 source_fission_id),复用主管道 + task_ids: list[int] = [] + for _ in range(n): + sub = GenerationTask( + workspace_id=current_user.workspace_id, + product_id=src.product_id, operator_id=current_user.user_id, + theme=src.theme, text_count=src.text_count, image_count=src.image_count, + track="ai", need_product_image=src.need_product_image, + status="pending", source_fission_id=ft.id, + ) + db.add(sub); db.commit(); db.refresh(sub) + enqueue_generation(sub.id) + task_ids.append(sub.id) + + logger.info("fission created id=%s level=%s fanout=%s tasks=%s", + ft.id, level, n, task_ids) + return ft.id, task_ids + + +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 c.content + return src.theme or "" diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index c7f16ac..27331c7 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -92,6 +92,22 @@ def run_generation_pipeline(self, task_id: int) -> dict: product_dict = build_product_dict(product) flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict) + # 第11环裂变:子任务(source_fission_id非空)按裂变档位+源爆款生成,拼进文案上下文。 + if getattr(task, "source_fission_id", None): + import json as _json + from app.models.fission import FissionTask + from app.services.ai_engine.fission_prompt import build_fission_context + ft = db.query(FissionTask).filter(FissionTask.id == task.source_fission_id).first() + if ft: + try: + src_note = _json.loads(ft.source_note) if ft.source_note else {} + except Exception: + src_note = {} + fission_ctx = build_fission_context(src_note, ft.reference_level) + flywheel_fragment = f"{fission_ctx}\n\n{flywheel_fragment}".strip() + logger.info("裂变子任务注入档位: task_id=%s fission=%s level=%s", + task_id, ft.id, ft.reference_level) + if flywheel_fragment: seq += 1 _push_event_sync(task_id, workspace_id, "flywheel_injected", { diff --git a/backend/main.py b/backend/main.py index 1ad3e67..476888b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -78,6 +78,7 @@ def _register_routers(app: FastAPI) -> None: from app.api.v1.delivery import router as delivery_router from app.api.v1.stream import router as stream_router from app.api.v1.workspaces import router as workspaces_router + from app.api.v1.fission import router as fission_router prefix = "/api/v1" app.include_router(auth_router, prefix=prefix) @@ -90,6 +91,7 @@ def _register_routers(app: FastAPI) -> None: app.include_router(delivery_router, prefix=prefix) app.include_router(stream_router, prefix=prefix) app.include_router(workspaces_router, prefix=prefix) + app.include_router(fission_router, prefix=prefix) app = create_app()