第11环裂变重写:对齐上线版 split.js 一次LLM出N套完整笔记包

架构从"扇出N个GenerationTask各跑完整管道"改为"一次LLM调用直接出N套
完整笔记包(N=1~5)",落 FissionNote 表 + 独立展示页。

后端:
- 018迁移:fission_notes 表(文案JSON+score+passed+imagePlan+images+status)
- fission_prompt:FISSION_SYSTEM+三档参考度(low/mid/high)+normalize_tags+品类兜底
- fission_pipeline:一次LLM出N套→各评分(@80合格线)→排序→落库,不达标标
  needs_optimization 非丢弃;apiports 503 回落 codeproxy gpt-5.5 强档兜底
- fission_images:每套串行调现有生图接口(零改动image_gen/storyboard)
- tasks.py:run_fission_pipeline Celery task,删旧扇出注入
- api/v1/fission:进度聚合FissionNote + GET /fission/{id}/notes(剥内部字段)

前端:FissionProgress对齐状态机 + N套独立展示页 + FissionNoteCard

测试:test_fission_engine(19)+test_fission_pipeline(5) 全过;104 全量回归绿

实测task5(fanout=2,mid)端到端跑通:一次出2套→seq0=85过/seq1=79标优化→
生图codeproxy/edits→1024×1536去AI化→task completed→notes端点返完整数据

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 11:17:37 +08:00
parent 7f419f4c8b
commit d85dcd401b
18 changed files with 1772 additions and 106 deletions

View File

@@ -14,6 +14,19 @@ from app.workers.replenish_task import replenish_text_candidates # noqa: F401
logger = logging.getLogger(__name__)
def _json_loads_safe(raw: str | None) -> dict:
"""TextCandidate.content 存整条 JSON dicttitle/content/tags/...)。
解析失败兼容旧数据:纯正文则包成 {'content': raw}。供 R2 重生复用已有文案。"""
import json as _json
if not raw:
return {}
try:
v = _json.loads(raw)
return v if isinstance(v, dict) else {"content": raw}
except (ValueError, TypeError):
return {"content": raw}
def _get_db():
"""获取同步 DB SessionCelery worker 环境用同步)"""
from app.core.database import SessionLocal
@@ -36,6 +49,28 @@ def _push_event_sync(task_id: int, workspace_id: int, event: str, data: dict, se
r.publish(channel, record)
def _acquire_run_lock(task_id: int) -> bool:
"""
幂等锁:同一 task 同时只允许一条 pipeline 在跑。
visibility_timeout 是主防线(防 broker 误判重投);此锁是双保险,
挡住"窗口内仍并发重投"导致两个 worker 同跑同一 task、重复烧钱(task75 教训)。
返回 True=抢到锁可执行False=已有人在跑,本次直接跳过。
锁 TTL 略大于 visibility_timeout确保跨整个任务生命周期。
"""
import redis as redis_lib
from app.core.config import get_settings
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
# NX+EX不存在才设自带过期防死锁(worker 崩了锁自动释放)
return bool(r.set(f"pipeline:lock:{task_id}", "1", nx=True, ex=7500))
def _release_run_lock(task_id: int) -> None:
import redis as redis_lib
from app.core.config import get_settings
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
r.delete(f"pipeline:lock:{task_id}")
@celery_app.task(
bind=True,
name="app.workers.tasks.run_generation_pipeline",
@@ -43,13 +78,22 @@ def _push_event_sync(task_id: int, workspace_id: int, event: str, data: dict, se
default_retry_delay=30,
queue="generation",
)
def run_generation_pipeline(self, task_id: int) -> dict:
def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = None,
regen_role: str | None = None, custom_prompt: str | None = None) -> dict:
"""
生产链主任务。只接收 task_id绝不接收 key。
子步骤委托给 pipeline_steps查库/解密/飞轮上下文)
和 pipeline_io文案/图片生成+存储)。
R2 重生参数(均 None=常规全量生成)
regen_strategy='A'/'B'/'C' → 只重生该套regen_role 配合 → 只重生该套的该张;
custom_prompt → 人工追加提示词。重生模式跳过文案生成,复用已有合格文案。
"""
logger.info("run_generation_pipeline start: task_id=%s", task_id)
# 幂等双保险:抢不到锁说明已有 worker 在跑同一 task直接跳过(防重复烧钱)。
if not _acquire_run_lock(task_id):
logger.warning("run_generation_pipeline 跳过(已有实例在跑): task_id=%s", task_id)
return {"task_id": task_id, "status": "skipped_locked"}
db = _get_db()
seq = 0
workspace_id = 0 # G3坑修复确保 except 里能用真实 workspace_id
@@ -86,28 +130,13 @@ def run_generation_pipeline(self, task_id: int) -> dict:
# Step4: 推 task_started + 飞轮上下文
seq += 1
_push_event_sync(task_id, workspace_id, "task_started", {
"task_id": task_id, "total_text": task.text_count, "total_image": task.image_count,
"task_id": task_id, "total_text": task.text_count,
"total_image": task.image_count * 3,
}, seq)
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", {
@@ -116,10 +145,31 @@ def run_generation_pipeline(self, task_id: int) -> dict:
}, seq)
# Step5: 文案生成S1: 返回 needs_replenish=合格数<目标数,触发后台补充)
candidates_raw, seq, needs_replenish = run_text_generation(
db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq,
)
# R2 重生模式:跳过文案重生,复用库内已有合格文案作生图依据(重生只针对图片)
_is_regen = bool(regen_strategy or regen_role)
if _is_regen:
from app.models.task import TextCandidate as _TC
_existing = db.query(_TC).filter(
_TC.task_id == task_id
).order_by(_TC.id.asc()).first()
if not _existing:
# 重生依赖已有文案作生图语境;一条都没有时硬失败,绝不以空文案降级生图(质量崩)
from app.constants.enums import TaskStatus as _TS
logger.warning("重生无可复用文案,拒绝空语境生图: task_id=%s", task_id)
task.status = _TS.PENDING_SELECTION
db.commit()
_push_event_sync(task_id, workspace_id, "error", {
"code": 40002,
"message": "无可复用文案,请先完成文案生成再重生图片",
}, seq + 1)
return {"task_id": task_id, "status": "regen_no_text"}
candidates_raw = [_json_loads_safe(_existing.content)]
needs_replenish = False
else:
candidates_raw, seq, needs_replenish = run_text_generation(
db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq,
)
if needs_replenish:
# 合格文案不足用户目标条数:先展示已合格的,后台异步补充(先展示后台补铁律)
# 注candidates_raw 是本轮原始生成数(含被过滤的低分条),非合格入库数;
@@ -140,6 +190,8 @@ def run_generation_pipeline(self, task_id: int) -> dict:
db, clients, task, product_dict,
_push_event_sync, workspace_id, seq,
first_copy, settings.UPLOAD_BASE_PATH,
regen_strategy=regen_strategy, regen_role=regen_role,
custom_prompt=custom_prompt,
)
# 最终状态 + task_done
@@ -174,4 +226,63 @@ def run_generation_pipeline(self, task_id: int) -> dict:
return {"task_id": task_id, "status": "failed", "error": str(exc)}
raise self.retry(exc=exc)
finally:
# 释放幂等锁:本次执行结束(成功/失败/重试间隙)即放锁。
# retry 是串行的(旧执行抛异常结束→30s后新执行重新抢锁),非并发,故每次都放。
# 真正的并发威胁(broker 窗口内重投)由 visibility_timeout=2h 兜底。
_release_run_lock(task_id)
db.close()
@celery_app.task(
bind=True,
name="app.workers.tasks.run_fission_pipeline",
max_retries=2,
default_retry_delay=30,
queue="generation",
)
def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
"""第11环裂变主任务一次 LLM 出 N 套完整笔记包(对齐上线版 split.js
只接收 id基石B不接收 key。幂等锁防重复烧钱。
解密 key→构建 clients→委托 fission_pipeline.execute_fission_pipeline。
"""
logger.info("run_fission_pipeline start: fission_id=%s src=%s", fission_id, source_task_id)
lock_key = f"fission:lock:{fission_id}"
import redis as _redis
from app.core.config import get_settings as _gs
_r = _redis.from_url(_gs().REDIS_URL, decode_responses=True)
if not _r.set(lock_key, "1", nx=True, ex=7500):
logger.warning("run_fission_pipeline 跳过(已在跑): fission_id=%s", fission_id)
return {"fission_id": fission_id, "status": "skipped_locked"}
db = _get_db()
try:
from app.models.task import GenerationTask
from app.workers.pipeline_steps import decrypt_user_key, build_clients_and_clear_key
from app.services.fission_pipeline import execute_fission_pipeline
src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first()
if not src:
return {"fission_id": fission_id, "status": "not_found"}
plain_key = decrypt_user_key(db, src.operator_id, src.workspace_id)
clients = build_clients_and_clear_key(plain_key)
plain_key = None # 用完即清基石B
return execute_fission_pipeline(db, clients, fission_id, source_task_id)
except Exception as exc:
logger.error("run_fission_pipeline failed: fission_id=%s err=%s", fission_id, exc)
exhausted = self.request.retries >= self.max_retries
if exhausted:
try:
from app.models.fission import FissionTask
ft = db.query(FissionTask).filter(FissionTask.id == fission_id).first()
if ft:
ft.status = "failed"; db.commit()
except Exception:
pass
return {"fission_id": fission_id, "status": "failed", "error": str(exc)}
raise self.retry(exc=exc)
finally:
_r.delete(lock_key)
db.close()