feat(M7): 第11环裂变引擎端到端打通
- fission_service: 1源任务扇出N套子任务(回填source_fission_id,复用主管道),MAX_FANOUT=5红线 - fission_prompt: 三档参考度(high/mid/low)占位规则,真实措辞待北哥定义 - tasks.py: worker识别裂变子任务→注入对应档位规则进文案上下文(关键缺口修复) - api/v1/fission: POST触发 + GET进度聚合, main.py注册路由 - 实测: fission_id=2扇出task70/71,worker注入日志level=high,进度端点聚合正确 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
78
backend/app/api/v1/fission.py
Normal file
78
backend/app/api/v1/fission.py
Normal file
@@ -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],
|
||||||
|
})
|
||||||
43
backend/app/services/ai_engine/fission_prompt.py
Normal file
43
backend/app/services/ai_engine/fission_prompt.py
Normal file
@@ -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"
|
||||||
84
backend/app/services/fission_service.py
Normal file
84
backend/app/services/fission_service.py
Normal file
@@ -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 ""
|
||||||
@@ -92,6 +92,22 @@ def run_generation_pipeline(self, task_id: int) -> dict:
|
|||||||
product_dict = build_product_dict(product)
|
product_dict = build_product_dict(product)
|
||||||
flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict)
|
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:
|
if flywheel_fragment:
|
||||||
seq += 1
|
seq += 1
|
||||||
_push_event_sync(task_id, workspace_id, "flywheel_injected", {
|
_push_event_sync(task_id, workspace_id, "flywheel_injected", {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ def _register_routers(app: FastAPI) -> None:
|
|||||||
from app.api.v1.delivery import router as delivery_router
|
from app.api.v1.delivery import router as delivery_router
|
||||||
from app.api.v1.stream import router as stream_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.workspaces import router as workspaces_router
|
||||||
|
from app.api.v1.fission import router as fission_router
|
||||||
|
|
||||||
prefix = "/api/v1"
|
prefix = "/api/v1"
|
||||||
app.include_router(auth_router, prefix=prefix)
|
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(delivery_router, prefix=prefix)
|
||||||
app.include_router(stream_router, prefix=prefix)
|
app.include_router(stream_router, prefix=prefix)
|
||||||
app.include_router(workspaces_router, prefix=prefix)
|
app.include_router(workspaces_router, prefix=prefix)
|
||||||
|
app.include_router(fission_router, prefix=prefix)
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
Reference in New Issue
Block a user