""" app/services/fission_pipeline.py — 第11环裂变 编排层(从 fission_service 拆出) Celery 内调用:一次 LLM 出 N 套完整笔记包 → 解析/兜底 → 评分@80 → 落 FissionNote → 逐套生图存 images_json。拆分原因:fission_service.py 超 200 行红线上限。 🔴 生图引擎零改动:复用单篇 generate_storyboard_images(codeproxy edits 带产品参考图)。 🔴 评分沿用 llm_score_copy,合格线 QUALITY_PASS_SCORE=80(禁改)。 🔴 FISSION_SYSTEM 作 messages[0].role=system 传(chat_complete 是 OpenAI 兼容)。 """ import logging from sqlalchemy.orm import Session logger = logging.getLogger(__name__) MAX_FANOUT = 5 # 每用户并发上限5(红线);裂变 N 套直出受同等约束 def _parse_fission_response( raw: str, source_note: dict, product: dict, note_count: int, image_count: int, ) -> list[dict]: """解析 LLM 返回的 N 套笔记;不可解析则品类兜底(不卡用户)。 解析链:parse_json_array(容错markdown) → notes_array_from_parsed(拎数组) → 空则 build_fallback_notes 品类草稿。每套补 imagePlan/tags 归一化。 """ import json as _json from app.services.ai_engine._text_prompt import parse_json_array from app.services.ai_engine.fission_prompt import ( normalize_tags, notes_array_from_parsed, ) from app.services.ai_engine.fission_fallback import ( build_fallback_image_plan, build_fallback_notes, ) notes = notes_array_from_parsed(parse_json_array(raw)) if not notes: # parse_json_array 只认数组;再试整段当对象解析(容错单对象返回) try: notes = notes_array_from_parsed(_json.loads(raw)) except (ValueError, TypeError): notes = [] if not notes: logger.warning("裂变 LLM 返回不可解析,启用品类兜底。raw[:120]=%s", str(raw)[:120]) return build_fallback_notes(source_note, product, note_count, image_count) out: list[dict] = [] for n in notes: if not isinstance(n, dict): continue n["tags"] = normalize_tags(n.get("tags", []), n.get("keywords", [])) plan = n.get("imagePlan") if not isinstance(plan, list) or len(plan) != image_count: n["imagePlan"] = build_fallback_image_plan(n, image_count) out.append(n) return out or build_fallback_notes(source_note, product, note_count, image_count) def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str]) -> None: """对每套笔记 LLM 评分(限2并发),结果就地写回 note['_score']/_passed/_block。 Celery 同步环境:用 asyncio.run 跑一个内部 gather(信号量限2并发, 对齐生图限流,避免中转站 429)。 """ import asyncio from app.services.ai_engine.llm_scorer import llm_score_copy from app.services.ai_engine.constants import QUALITY_PASS_SCORE async def _run() -> None: sem = asyncio.Semaphore(2) async def _one(note: dict) -> None: copy = { "title": note.get("title", ""), "content": note.get("content", ""), "tags": note.get("tags", []), } async with sem: try: r = await llm_score_copy( clients, copy, source_note, banned, pass_score=QUALITY_PASS_SCORE, ) except Exception as exc: # noqa: BLE001 logger.warning("裂变评分异常,记0分不达标: %s", exc) r = {"score": 0, "passed": False, "banned_words_found": []} note["_score"] = int(r.get("score", 0)) note["_block"] = bool(r.get("banned_words_found")) # 过线 = score≥80 且无硬拦违禁词 note["_passed"] = bool(r.get("passed")) and not note["_block"] await asyncio.gather(*(_one(n) for n in notes)) asyncio.run(_run()) def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_id: int) -> dict: """裂变主编排(Celery 内调用,clients 已构建好)。 流程:查 FissionTask+源产品 → 一次 chat_complete 出 N 套 → 解析/兜底 → 每套评分@80 → 按分排序取 N 套(不够用兜底补,不达标标 needs_optimization) → 落 FissionNote → 逐套生图存 images_json → 回写 FissionTask.status。 """ import json as _json from app.models.fission import FissionTask, FissionNote from app.models.task import GenerationTask from app.models.product import Product from app.workers.pipeline_steps import build_product_dict from app.services.ai_engine.fission_prompt import build_fission_prompt ft = db.query(FissionTask).filter(FissionTask.id == fission_id).first() if not ft: return {"fission_id": fission_id, "status": "not_found"} n = max(1, min(ft.fanout_count or 3, MAX_FANOUT)) try: source_note = _json.loads(ft.source_note) if ft.source_note else {} except (ValueError, TypeError): source_note = {} src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first() product_row = db.query(Product).filter(Product.id == src.product_id).first() if src else None if not src or not product_row: ft.status = "failed"; db.commit() return {"fission_id": fission_id, "status": "failed", "reason": "源任务或产品缺失"} product = build_product_dict(product_row) image_count = max(1, src.image_count or 3) banned = [] # 违禁词分级表后续接入;评分器内置 BANNED_WORDS_DEFAULT 已兜底 # 一次 LLM 出 N 套(FISSION_SYSTEM 作 messages[0].role=system) from app.services.ai_engine.fission_prompt import FISSION_SYSTEM user_prompt = build_fission_prompt( source_note, product, ft.reference_level, n, image_count, ) # max_tokens 按套数缩放:每套完整笔记包约 1200 token,留足余量 max_tokens = min(8192, 1800 + n * 1400) raw = "" try: import asyncio raw = asyncio.run(clients.chat_complete( messages=[{"role": "system", "content": FISSION_SYSTEM}, {"role": "user", "content": user_prompt}], model=clients._model, max_tokens=max_tokens, temperature=0.8, )) except Exception as exc: # noqa: BLE001 logger.warning("裂变 LLM 调用失败,启用品类兜底: %s", exc) notes = _parse_fission_response(raw, source_note, product, n, image_count) _score_notes(clients, notes, source_note, banned) # 排序:过线优先,再按分降序;取前 N 套(不足用兜底草稿补齐) notes.sort(key=lambda x: (x.get("_passed", False), x.get("_score", 0)), reverse=True) chosen = notes[:n] saved_ids: list[int] = [] for seq, note in enumerate(chosen): passed = bool(note.get("_passed")) fn = FissionNote( fission_id=fission_id, workspace_id=ft.workspace_id, seq=seq, note_json=_json.dumps(note, ensure_ascii=False), score=int(note.get("_score", 0)), passed=1 if passed else 0, needs_optimization=0 if passed else 1, # 不达标不丢弃,标降级草稿 dimension=str(note.get("dimension", ""))[:64], status="scored", ) db.add(fn); db.flush() saved_ids.append(fn.id) db.commit() logger.info("裂变出 %s 套已落库 fission=%s ids=%s", len(saved_ids), fission_id, saved_ids) # 逐套生图(复用单篇引擎),存 images_json from app.services.fission_images import generate_fission_images generate_fission_images(db, clients, ft, product, image_count, saved_ids) ft.status = "completed"; db.commit() return {"fission_id": fission_id, "status": "completed", "note_ids": saved_ids}